Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Changelog

## 2.5.0

### Changed — Button / ButtonDropdown minimum widths

**`Button` now has a `min-w-[150px]` floor — but only for labeled buttons at the default size.**

The floor applies when **both** are true:

- the button has a non-empty `label` (icon-only buttons are exempt), and
- no explicit `size="xs"` or `size="sm"` is passed. Passing `size="xs"`/`size="sm"` is
treated as a deliberate compaction and opts out of the floor. Omitting `size` (which
still resolves to the `sm` visual style) or passing `md`/`lg`/`xl` keeps the floor.

**`ButtonDropdown` now has a 150px minimum _total_ width** (button portion + divider +
trigger, previously an unreleased 114px floor on the button portion alone). The floor
lives on the wrapper; the button portion `grow`s to fill it. `IButtonDropdownProps`
gains a wrapper-level `className` prop (merged last).

**Class conflicts now resolve in favour of consumer classes.** Both components run
their final class list through [`tailwind-merge`](https://github.com/dcastil/tailwind-merge)
(new dependency), with your `className` merged last. No `!important` is used for the
new widths.

### Migration / override recipes

Audit call sites for anything that must stay narrower than 150px:

- **Icon-only `Button`** (`label=""` + icon, `w-[30px]`, `w-7`, etc.): no change needed —
the floor never applies without a label.
- **Compact toolbar `Button`s** that already pass `size="sm"`/`size="xs"`: no change
needed — explicit small sizes are exempt.
- **Any other `Button` that must be narrower**: pass `className="min-w-0"` (or any
`min-w-*` of your choosing) — consumer `min-w-*`/`max-w-*` always wins over the
default via tailwind-merge.
- **`ButtonDropdown` that must be narrower than 150px total**: pass the new wrapper
prop `className="min-w-0"`. Existing `button.className` values such as
`"max-w-[122px]"` continue to cap the button portion and now reliably win over the
internal defaults (they are merged after them). Recommended compact recipe:

```tsx
<ButtonDropdown
className="min-w-0"
button={{ label: "New Page", size: "sm", className: "max-w-[122px]" }}
dropDown={...}
/>
```

### Notes

- Without a wrapper `min-w-0`, a `button.className` max-width caps the button portion
but the control still reserves 150px total — pass both when space is tight.
- The `asLink` render branch gets the same conditional floor and tailwind-merge
behaviour as the `<button>` branch.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agility/plenum-ui",
"version": "2.4.1",
"version": "2.5.0",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down Expand Up @@ -47,6 +47,7 @@
"npm-dts": "^1.3.12",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.21",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.2.4",
"typescript": "^5.1.6"
},
Expand Down
145 changes: 80 additions & 65 deletions stories/atoms/buttons/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { default as cn } from "classnames";
import { twMerge } from "tailwind-merge";
import React, { HTMLAttributeAnchorTarget, forwardRef } from "react";
import { DynamicIcon, UnifiedIconName, IDynamicIconProps } from "../../icons";

Expand All @@ -8,7 +9,7 @@ export interface IButtonProps
extends Omit<React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "ref"> {
/** Is the button a Primary CTA, alternative or danger button? */
actionType?: BTNActionType;
/** How lg should the button be? - Defaults to 'base'. */
/** How lg should the button be? - Defaults to 'sm'. Explicitly passing "xs" or "sm" also opts the button out of the default min-w-[150px] floor. */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** The Button's text content. */
label: string;
Expand Down Expand Up @@ -38,7 +39,7 @@ export interface IButtonProps
const _Button = (
{
actionType = "primary",
size = "sm",
size,
label,
icon,
iconObj,
Expand All @@ -53,6 +54,10 @@ const _Button = (
}: IButtonProps,
ref: React.LegacyRef<HTMLButtonElement>
) => {
const resolvedSize = size ?? "sm";
// The 150px floor only applies to labeled buttons that haven't explicitly opted into a
// compact size — icon-only buttons and explicit size="xs"/"sm" call sites keep their own width.
const applyMinWidth = !!label && size !== "xs" && size !== "sm";
let iconStyles = cn(
{ "text-white h-5 w-5 stroke-[1.5]": actionType === "primary" || actionType === "danger" },
{ "text-purple-700 h-5 w-5 stroke-[1.5]": actionType === "secondary" },
Expand All @@ -71,7 +76,11 @@ const _Button = (
{ "border-red-800 border-r-white": actionType === "danger" },
{ "border-yellow-800 border-r-transparent-black-70": actionType === "warning" }
);
const loaderSize = cn({ "h-4 w-4": size === "sm" }, { "h-5 w-5": size === "md" }, { "h-6 w-6 ": size === "lg" });
const loaderSize = cn(
{ "h-4 w-4": resolvedSize === "sm" },
{ "h-5 w-5": resolvedSize === "md" },
{ "h-6 w-6 ": resolvedSize === "lg" }
);

return asLink ? (
//@ts-ignore
Expand All @@ -80,35 +89,38 @@ const _Button = (
href: asLink.href,
target: asLink.target,
title: asLink.title,
className: cn(
"inline-flex items-center justify-center gap-x-2 font rounded-[3px] !ring-offset-white outline-none focus-visible:ring-2 focus-visible:ring-purple-600 focus-visible:ring-offset-2 focus-within:ring-2 focus-within:ring-purple-600 focus-within:ring-offset-2 focus:ring-2 focus:ring-purple-600 focus:ring-offset-2 active:ring-2 active:ring-purple-600 active:ring-offset-2 transition-all",
{ "w-full": fullWidth },
{ "px-[11px] py-[7px] text-xs": size === "xs" },
{ "px-[13px] py-[9px] text-sm": size === "sm" },
{ "px-[17px] py-[9px] text-sm": size === "md" },
{ "px-[17px] py-[9px] text-base": size === "lg" },
{ "px-[25px] py-[13px] text-base": size === "xl" },
{
"bg-violet-800 text-white hover:border-violet-700 hover:bg-violet-700 disabled:bg-violet-400 disabled:hover:bg-violet-400 disabled:focus-visible:ring-0":
actionType === "primary"
},
{
" bg-purple-50 border-purple-400 border hover:border-purple-500 text-purple-700 hover:bg-purple-100 focus-within:bg-purple-100 focus-visible:bg-purple-100 focus:bg-purple-100 active:bg-purple-100 disabled:bg-purple-50 disabled:hover:bg-purple-50 disabled:text-purple-300 disabled:focus-visible:ring-0":
actionType === "secondary"
},
{
"border-gray-300 bg-white border text-gray-700 hover:bg-gray-50 focus-visible:!border-gray-300 focus-within:!border-gray-300 focus:!border-gray-300 active:!border-gray-300 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-300 disabled:focus-visible:ring-0":
actionType === "alternative"
},
{
" bg-red-600 text-white hover:bg-red-700 <focus-visible:!></focus-visible:!>ring-red-500 focus:!ring-red-500 active:!ring-red-500 focus-within:!ring-red-500 disabled:bg-red-400 disabled:hover:bg-red-400 disabled:text-gray-50 disabled:focus-visible:ring-0":
actionType === "danger"
},
{
" bg-yellow-500 text-transparent-black-70 hover:bg-yellow-700 <focus-visible:!></focus-visible:!>ring-yellow-500 focus:!ring-yellow-500 active:!ring-yellow-500 focus-within:!ring-yellow-500 disabled:bg-yellow-400 disabled:hover:bg-yellow-400 disabled:text-transparent-black-70 disabled:focus-visible:ring-0":
actionType === "warning"
},
className ? className : ""
className: twMerge(
cn(
"inline-flex items-center justify-center gap-x-2 font rounded-[3px] !ring-offset-white outline-none focus-visible:ring-2 focus-visible:ring-purple-600 focus-visible:ring-offset-2 focus-within:ring-2 focus-within:ring-purple-600 focus-within:ring-offset-2 focus:ring-2 focus:ring-purple-600 focus:ring-offset-2 active:ring-2 active:ring-purple-600 active:ring-offset-2 transition-all",
{ "min-w-[150px]": applyMinWidth },
{ "w-full": fullWidth },
{ "px-[11px] py-[7px] text-xs": resolvedSize === "xs" },
{ "px-[13px] py-[9px] text-sm": resolvedSize === "sm" },
{ "px-[17px] py-[9px] text-sm": resolvedSize === "md" },
{ "px-[17px] py-[9px] text-base": resolvedSize === "lg" },
{ "px-[25px] py-[13px] text-base": resolvedSize === "xl" },
{
"bg-violet-800 text-white hover:border-violet-700 hover:bg-violet-700 disabled:bg-violet-400 disabled:hover:bg-violet-400 disabled:focus-visible:ring-0":
actionType === "primary"
},
{
" bg-purple-50 border-purple-400 border hover:border-purple-500 text-purple-700 hover:bg-purple-100 focus-within:bg-purple-100 focus-visible:bg-purple-100 focus:bg-purple-100 active:bg-purple-100 disabled:bg-purple-50 disabled:hover:bg-purple-50 disabled:text-purple-300 disabled:focus-visible:ring-0":
actionType === "secondary"
},
{
"border-gray-300 bg-white border text-gray-700 hover:bg-gray-50 focus-visible:!border-gray-300 focus-within:!border-gray-300 focus:!border-gray-300 active:!border-gray-300 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-300 disabled:focus-visible:ring-0":
actionType === "alternative"
},
{
" bg-red-600 text-white hover:bg-red-700 <focus-visible:!></focus-visible:!>ring-red-500 focus:!ring-red-500 active:!ring-red-500 focus-within:!ring-red-500 disabled:bg-red-400 disabled:hover:bg-red-400 disabled:text-gray-50 disabled:focus-visible:ring-0":
actionType === "danger"
},
{
" bg-yellow-500 text-transparent-black-70 hover:bg-yellow-700 <focus-visible:!></focus-visible:!>ring-yellow-500 focus:!ring-yellow-500 active:!ring-yellow-500 focus-within:!ring-yellow-500 disabled:bg-yellow-400 disabled:hover:bg-yellow-400 disabled:text-transparent-black-70 disabled:focus-visible:ring-0":
actionType === "warning"
},
className ? className : ""
)
),
onClick: props.onClick
}}
Expand Down Expand Up @@ -160,39 +172,42 @@ const _Button = (
) : (
<button
type="button"
className={cn(
" px-4 py-2 inline-flex items-center justify-center gap-x-2 rounded !ring-offset-white outline-none focus-visible:ring-2 focus-visible:ring-purple-600 focus-visible:ring-offset-2 focus-within:ring-2 focus-within:ring-purple-600 focus-within:ring-offset-2 focus:ring-2 focus:ring-purple-600 focus:ring-offset-2 active:ring-2 active:ring-purple-600 active:ring-offset-2 transition-all h-9",
{ "w-full": fullWidth },
{ "text-xs": size === "xs" },
{ "text-sm": size === "sm" },
{ "text-sm": size === "md" },
{ "text-base": size === "lg" },
{ "text-base": size === "xl" },
{
"bg-violet-800 text-white hover:border-violet-700 hover:bg-violet-700 disabled:bg-violet-400 disabled:hover:bg-violet-400 disabled:focus-visible:ring-0":
actionType === "primary"
},
{
" bg-purple-50 border-purple-400 border hover:border-purple-500 text-purple-700 hover:bg-purple-100 focus-within:bg-purple-100 focus-visible:bg-purple-100 focus:bg-purple-100 active:bg-purple-100 disabled:bg-purple-50 disabled:hover:bg-purple-50 disabled:text-purple-300 disabled:focus-visible:ring-0":
actionType === "secondary"
},
{
"border-gray-300 bg-white border text-gray-700 hover:bg-gray-50 focus-visible:!border-gray-300 focus-within:!border-gray-300 focus:!border-gray-300 active:!border-gray-300 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-300 disabled:focus-visible:ring-0":
actionType === "alternative"
},
{
"bg-red-600 text-white hover:bg-red-700 focus-visible:!ring-red-500 focus:!ring-red-500 active:!ring-red-500 focus-within:!ring-red-500 disabled:bg-red-400 disabled:hover:bg-red-400 disabled:text-gray-50 disabled:focus-visible:ring-0":
actionType === "danger"
},
{
"border-gray-300 border bg-white text-red-600 hover:bg-red-50 focus-visible:!ring-red-500 focus:!ring-red-500 active:bg-red-100 active:ring-red-500 focus-within:!ring-red-500 disabled:bg-white disabled:hover:bg-white disabled:text-red-300 disabled:!ring-0 disabled:focus-visible:ring-0":
actionType === "danger-secondary"
},
{
"bg-yellow-500 text-transparent-black-70 hover:bg-yellow-700 focus-visible:!ring-yellow-500 focus:!ring-yellow-500 active:!ring-yellow-500 focus-within:!ring-yellow-500 disabled:bg-yellow-300 disabled:hover:bg-yellow-300 disabled:text-transparent-black-30 disabled:focus-visible:ring-0":
actionType === "warning"
},
className ? className : ""
className={twMerge(
cn(
" px-4 py-2 inline-flex items-center justify-center gap-x-2 rounded !ring-offset-white outline-none focus-visible:ring-2 focus-visible:ring-purple-600 focus-visible:ring-offset-2 focus-within:ring-2 focus-within:ring-purple-600 focus-within:ring-offset-2 focus:ring-2 focus:ring-purple-600 focus:ring-offset-2 active:ring-2 active:ring-purple-600 active:ring-offset-2 transition-all h-9",
{ "min-w-[150px]": applyMinWidth },
{ "w-full": fullWidth },
{ "text-xs": resolvedSize === "xs" },
{ "text-sm": resolvedSize === "sm" },
{ "text-sm": resolvedSize === "md" },
{ "text-base": resolvedSize === "lg" },
{ "text-base": resolvedSize === "xl" },
{
"bg-violet-800 text-white hover:border-violet-700 hover:bg-violet-700 disabled:bg-violet-400 disabled:hover:bg-violet-400 disabled:focus-visible:ring-0":
actionType === "primary"
},
{
" bg-purple-50 border-purple-400 border hover:border-purple-500 text-purple-700 hover:bg-purple-100 focus-within:bg-purple-100 focus-visible:bg-purple-100 focus:bg-purple-100 active:bg-purple-100 disabled:bg-purple-50 disabled:hover:bg-purple-50 disabled:text-purple-300 disabled:focus-visible:ring-0":
actionType === "secondary"
},
{
"border-gray-300 bg-white border text-gray-700 hover:bg-gray-50 focus-visible:!border-gray-300 focus-within:!border-gray-300 focus:!border-gray-300 active:!border-gray-300 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-300 disabled:focus-visible:ring-0":
actionType === "alternative"
},
{
"bg-red-600 text-white hover:bg-red-700 focus-visible:!ring-red-500 focus:!ring-red-500 active:!ring-red-500 focus-within:!ring-red-500 disabled:bg-red-400 disabled:hover:bg-red-400 disabled:text-gray-50 disabled:focus-visible:ring-0":
actionType === "danger"
},
{
"border-gray-300 border bg-white text-red-600 hover:bg-red-50 focus-visible:!ring-red-500 focus:!ring-red-500 active:bg-red-100 active:ring-red-500 focus-within:!ring-red-500 disabled:bg-white disabled:hover:bg-white disabled:text-red-300 disabled:!ring-0 disabled:focus-visible:ring-0":
actionType === "danger-secondary"
},
{
"bg-yellow-500 text-transparent-black-70 hover:bg-yellow-700 focus-visible:!ring-yellow-500 focus:!ring-yellow-500 active:!ring-yellow-500 focus-within:!ring-yellow-500 disabled:bg-yellow-300 disabled:hover:bg-yellow-300 disabled:text-transparent-black-30 disabled:focus-visible:ring-0":
actionType === "warning"
},
className ? className : ""
)
)}
ref={ref}
{...props}
Expand Down
71 changes: 71 additions & 0 deletions stories/atoms/buttons/Button/MinWidth/MinWidth.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react";
import Button from "../Button";

// Stories documenting the min-width behaviour introduced in v2.5:
// labeled buttons at the default size get a 150px floor; icon-only buttons,
// explicit size="xs"/"sm" buttons, and consumers passing their own min-w-* do not.
const meta: Meta<typeof Button> = {
title: "Design System/atoms/Buttons/Button/Min Width",
component: Button,
tags: ["autodocs"],
argTypes: {}
};

export default meta;
type Story = StoryObj<typeof Button>;

/** A labeled button with no explicit size gets the 150px min-width floor. */
export const LabeledDefaultSize: Story = {
args: {
actionType: "primary",
label: "Save"
}
};

/** An icon-only button (no label) is exempt from the floor and can be sized freely. */
export const IconOnly: Story = {
args: {
actionType: "alternative",
label: "",
icon: "IconDots",
className: "w-[30px] !px-1"
}
};

/** Explicitly passing size="sm" opts out of the floor — compact toolbars keep their width. */
export const ExplicitSmall: Story = {
args: {
actionType: "secondary",
label: "Compact",
size: "sm",
className: "!px-3"
}
};

/** Explicitly passing size="xs" also opts out of the floor. */
export const ExplicitExtraSmall: Story = {
args: {
actionType: "secondary",
label: "Tiny",
size: "xs",
className: "!px-3"
}
};

/** Consumer classes win: min-w-0 (or any min-w-*) removes the floor via tailwind-merge. */
export const ConsumerMinWidthOverride: Story = {
args: {
actionType: "primary",
label: "Override",
className: "min-w-0"
}
};

/** Larger explicit sizes (md/lg/xl) keep the floor. */
export const MediumKeepsFloor: Story = {
args: {
actionType: "primary",
label: "Medium",
size: "md"
}
};
40 changes: 40 additions & 0 deletions stories/atoms/buttons/Button/tests/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,46 @@ describe("<Button>", () => {
});
});

describe("min-width floor", () => {
it("applies min-w-[150px] to a labeled button with no explicit size", () => {
render(<Button {...defaultProps} />);

expect(screen.getByRole("button")).toHaveClass("min-w-[150px]");
});

it("applies min-w-[150px] at explicit size md", () => {
render(<Button {...defaultProps} size="md" />);

expect(screen.getByRole("button")).toHaveClass("min-w-[150px]");
});

it("does not apply to an icon-only button (no label)", () => {
render(<Button label="" icon="IconDots" />);

expect(screen.getByRole("button")).not.toHaveClass("min-w-[150px]");
});

it("does not apply at explicit size sm", () => {
render(<Button {...defaultProps} size="sm" />);

expect(screen.getByRole("button")).not.toHaveClass("min-w-[150px]");
});

it("does not apply at explicit size xs", () => {
render(<Button {...defaultProps} size="xs" />);

expect(screen.getByRole("button")).not.toHaveClass("min-w-[150px]");
});

it("is overridden by a consumer min-w-* class", () => {
render(<Button {...defaultProps} className="min-w-0" />);

const buttonElement = screen.getByRole("button");
expect(buttonElement).not.toHaveClass("min-w-[150px]");
expect(buttonElement).toHaveClass("min-w-0");
});
});

it("calls onClick when clicked", () => {
const handleClick = vi.fn();
render(<Button {...defaultProps} onClick={handleClick} />);
Expand Down
Loading
Loading