-
toast('Hello World!')} variant="solid" color="primary">
+ toast('Hello World!')} variant="solid" color="primary" size="md">
Default Toast
toast.success('Success! Your action was completed.')}
variant="solid"
color="primary"
+ size="md"
>
Success Toast
toast.error('Error! Something went wrong.')}
variant="solid"
- color="destructive"
+ color="danger"
+ size="md"
>
Error Toast
@@ -39,6 +41,7 @@ export const BasicToasts: Story = {
onClick={() => toast.warning('Warning! Please check your input.')}
variant="solid"
color="warning"
+ size="md"
>
Warning Toast
@@ -46,6 +49,7 @@ export const BasicToasts: Story = {
onClick={() => toast.info('Info: Here is some information.')}
variant="solid"
color="secondary"
+ size="md"
>
Info Toast
@@ -71,6 +75,7 @@ export const ToastWithDescriptions: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
With Description
@@ -81,7 +86,8 @@ export const ToastWithDescriptions: Story = {
})
}
variant="solid"
- color="destructive"
+ color="danger"
+ size="md"
>
Error with Description
@@ -93,6 +99,7 @@ export const ToastWithDescriptions: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
Success with Description
@@ -121,6 +128,7 @@ export const ToastWithActions: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
With Action Button
@@ -135,7 +143,8 @@ export const ToastWithActions: Story = {
})
}
variant="solid"
- color="destructive"
+ color="danger"
+ size="md"
>
Error with Action
@@ -151,6 +160,7 @@ export const ToastWithActions: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
Success with Action
@@ -177,7 +187,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Top left', { position: 'top-left' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Top Left
@@ -185,7 +195,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Top center', { position: 'top-center' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Top Center
@@ -193,7 +203,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Top right', { position: 'top-right' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Top Right
@@ -208,7 +218,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Bottom left', { position: 'bottom-left' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Bottom Left
@@ -216,7 +226,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Bottom center', { position: 'bottom-center' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Bottom Center
@@ -224,7 +234,7 @@ export const ToastPositions: Story = {
onClick={() => toast('Bottom right', { position: 'bottom-right' })}
variant="outlined"
size="sm"
- color={ButtonColor.None}
+ color="secondary"
>
Bottom Right
@@ -248,6 +258,7 @@ export const ToastDurations: Story = {
onClick={() => toast('Quick message', { duration: 1000 })}
variant="solid"
color="primary"
+ size="md"
>
1 Second
@@ -255,6 +266,7 @@ export const ToastDurations: Story = {
onClick={() => toast('Standard message', { duration: 4000 })}
variant="solid"
color="primary"
+ size="md"
>
4 Seconds
@@ -262,6 +274,7 @@ export const ToastDurations: Story = {
onClick={() => toast('Long message', { duration: 8000 })}
variant="solid"
color="primary"
+ size="md"
>
8 Seconds
@@ -269,6 +282,7 @@ export const ToastDurations: Story = {
onClick={() => toast('Persistent message', { duration: Infinity })}
variant="solid"
color="warning"
+ size="md"
>
Persistent
@@ -297,6 +311,7 @@ export const CustomStyledToasts: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
Custom Styled
@@ -311,6 +326,7 @@ export const CustomStyledToasts: Story = {
}
variant="solid"
color="primary"
+ size="md"
>
Gradient Toast
@@ -325,6 +341,7 @@ export const CustomStyledToasts: Story = {
}
variant="solid"
color="warning"
+ size="md"
>
Custom Warning
@@ -353,6 +370,7 @@ export const ToastWithPromises: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
Promise Toast
@@ -375,6 +393,7 @@ export const ToastWithPromises: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
Random Promise
@@ -389,6 +408,7 @@ export const ToastWithPromises: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
File Upload
@@ -415,6 +435,7 @@ export const MultipleToasts: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
Show Multiple
@@ -427,6 +448,7 @@ export const MultipleToasts: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
Different Types
@@ -439,6 +461,7 @@ export const MultipleToasts: Story = {
}}
variant="solid"
color="primary"
+ size="md"
>
Different Positions
@@ -459,7 +482,7 @@ export const Default: Story = {
Click the buttons below to see different types of toasts in action.
- toast('Hello World!')} variant="solid" color="primary">
+ toast('Hello World!')} variant="solid" color="primary" size="md">
Show Toast
diff --git a/apps/docs/stories/table.stories.tsx b/apps/docs/stories/table.stories.tsx
index 09245e0a..040df388 100644
--- a/apps/docs/stories/table.stories.tsx
+++ b/apps/docs/stories/table.stories.tsx
@@ -11,7 +11,6 @@ import {
import {
Badge,
Button,
- ButtonColor,
Table,
TableBody,
TableCaption,
@@ -267,25 +266,15 @@ export const Enhanced: Story = {
-
+
-
+
@@ -381,7 +370,7 @@ export const Empty: Story = {
size="sm"
className="mt-2"
variant="ghost"
- color={ButtonColor.None}
+ color="secondary"
prefix={ }
>
Add User
diff --git a/apps/docs/stories/tooltip.stories.tsx b/apps/docs/stories/tooltip.stories.tsx
index 6c00730d..d6de60dc 100644
--- a/apps/docs/stories/tooltip.stories.tsx
+++ b/apps/docs/stories/tooltip.stories.tsx
@@ -286,7 +286,7 @@ export const TooltipShowcase: Story = {
variant={ButtonVariant.Solid}
color={ButtonColor.Secondary}
size={ButtonSize.MD}
- style={{ width: TRIGGER_WIDTH }}
+ width={TRIGGER_WIDTH}
>
{side} / {align}
@@ -312,7 +312,7 @@ export const TooltipShowcase: Story = {
variant={ButtonVariant.Solid}
color={ButtonColor.Secondary}
size={ButtonSize.MD}
- style={{ width: TRIGGER_WIDTH }}
+ width={TRIGGER_WIDTH}
>
{label}
@@ -470,7 +470,8 @@ export const TooltipShowcase: Story = {
diff --git a/packages/ui/src/announcement-banner/announcement-banner.tsx b/packages/ui/src/announcement-banner/announcement-banner.tsx
index 8d8ca71e..c2ae2a13 100644
--- a/packages/ui/src/announcement-banner/announcement-banner.tsx
+++ b/packages/ui/src/announcement-banner/announcement-banner.tsx
@@ -103,6 +103,7 @@ export const AnnouncementBanner = React.forwardRef
{action.label}
@@ -116,10 +117,12 @@ export const AnnouncementBanner = React.forwardRef }
+ size="md"
+ icon
onClick={onClose}
- />
+ >
+
+
)}
);
diff --git a/packages/ui/src/button-group/__tests__/button-group.test.tsx b/packages/ui/src/button-group/__tests__/button-group.test.tsx
new file mode 100644
index 00000000..6e50c79d
--- /dev/null
+++ b/packages/ui/src/button-group/__tests__/button-group.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it } from 'vitest';
+import { Button } from '../../button/index.js';
+import { ButtonGroup } from '../button-group.js';
+
+describe('ButtonGroup rendering', () => {
+ it('renders a div with role=group', () => {
+ render(
+
+
+ A
+
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group.tagName).toBe('DIV');
+ expect(group).toBe(screen.getByTestId('group'));
+ });
+
+ it('renders every child button', () => {
+ render(
+
+
+ A
+
+
+ B
+
+ ,
+ );
+
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ });
+
+ it('exposes size, variant and color on the group element', () => {
+ render(
+
+
+ A
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+ expect(group).toHaveAttribute('data-size', 'sm');
+ expect(group).toHaveAttribute('data-variant', 'outlined');
+ expect(group).toHaveAttribute('data-color', 'secondary');
+ });
+
+ it('keeps the component class next to a custom className', () => {
+ render(
+
+
+ A
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+ expect(group).toHaveClass('custom-class');
+ expect(group.className.split(' ').length).toBeGreaterThan(1);
+ });
+
+ it('forwards arbitrary div attributes', () => {
+ render(
+
+
+ A
+
+ ,
+ );
+
+ const group = screen.getByRole('group', { name: 'Time range' });
+ expect(group).toHaveAttribute('id', 'ranges');
+ });
+
+ it('forwards ref to the group element', () => {
+ const ref = createRef();
+ render(
+
+
+ A
+
+ ,
+ );
+
+ expect(ref.current).toBe(screen.getByRole('group'));
+ });
+});
+
+describe('ButtonGroup child styling', () => {
+ it('styles the cluster only, children keep their own tokens', () => {
+ render(
+
+
+ A
+
+
+ B
+
+ ,
+ );
+
+ const a = screen.getByTestId('a');
+ expect(a).toHaveAttribute('data-size', 'md');
+ expect(a).toHaveAttribute('data-variant', 'solid');
+ expect(a).toHaveAttribute('data-color', 'primary');
+
+ const b = screen.getByTestId('b');
+ expect(b).toHaveAttribute('data-size', 'sm');
+ expect(b).toHaveAttribute('data-color', 'danger');
+ });
+});
diff --git a/packages/ui/src/button-group/button-group.module.scss b/packages/ui/src/button-group/button-group.module.scss
index d5968327..38604c34 100644
--- a/packages/ui/src/button-group/button-group.module.scss
+++ b/packages/ui/src/button-group/button-group.module.scss
@@ -7,6 +7,10 @@
.button-group > * {
border-radius: 0;
position: relative;
+ /* The dashed variant draws its border as an SVG rect, and a single `rx` cannot
+ express the per-corner rounding a grouped button has. Squaring it off leaves
+ only the group's two outer corners uncurved, instead of rounding all four. */
+ --button-border-radius: 0;
}
.button-group > * + * {
diff --git a/packages/ui/src/button-group/button-group.tsx b/packages/ui/src/button-group/button-group.tsx
index 31f54f2e..735eccda 100644
--- a/packages/ui/src/button-group/button-group.tsx
+++ b/packages/ui/src/button-group/button-group.tsx
@@ -1,91 +1,76 @@
-import type React from 'react';
-import { forwardRef, useMemo } from 'react';
+import { forwardRef, type HTMLAttributes } from 'react';
import { cn } from '../lib/utils';
-import {
- type ButtonColorValue,
- ButtonGroupContext,
- type ButtonSizeValue,
- type ButtonVariantValue,
-} from '../button';
+import { type SizeType, type VariantColorType } from '../button';
import styles from './button-group.module.scss';
export type ButtonGroupProps = {
/**
- * Default `size` applied to descendant `Button`s that do not set their own `size`.
- * Individual buttons can still override this locally.
+ * Mirrored on the group element as `data-size`. Not inherited by the buttons: set `size`
+ * on every child too.
*/
- size?: ButtonSizeValue;
- /**
- * Default `variant` applied to descendant `Button`s that do not set their own `variant`.
- * Individual buttons can still override this locally.
- */
- variant?: ButtonVariantValue;
- /**
- * Default `color` applied to descendant `Button`s that do not set their own `color`.
- * Individual buttons can still override this locally (e.g. to mark one action destructive).
- */
- color?: ButtonColorValue;
+ size?: SizeType;
/**
* Forwarded to the rendered group element as `data-testid`.
*/
testId?: string;
-} & Omit, 'color'>;
+} & Omit, 'color'> &
+ VariantColorType;
/**
* Segmented cluster of related buttons. Renders as `` with
* inline-flex children, deduped internal borders, and only the outer corners
- * rounded. `size` / `variant` / `color` set on the group propagate to descendant
- * `Button`s through context — per-button props still take precedence.
+ * rounded. `size` / `variant` / `color` set on the group style the cluster itself,
+ * they are not inherited by the buttons: set them on every child too.
*
* @example
* ```tsx
* // Time-range segmented control — all three buttons share the group's variant + color
*
- * Day
- * Week
- * Month
+ * Day
+ * Week
+ * Month
*
* ```
*
* @example
* ```tsx
- * // Per-button override — last button opts into a destructive color
+ * // Per-button override — last button opts into a danger color
*
- * Approve
- * Hold
- * Reject
+ * Approve
+ * Hold
+ * Reject
*
* ```
*
* @example
* ```tsx
* // Icon-only navigation cluster
- *
- * } aria-label="Previous" />
- * } aria-label="Next" />
+ *
+ *
+ *
+ *
+ *
+ *
+ *
*
* ```
*/
const ButtonGroup = forwardRef(
({ size, variant, color, className, children, testId, ...props }, ref) => {
- const value = useMemo(() => ({ size, variant, color, inGroup: true }), [size, variant, color]);
-
return (
-
- {/* biome-ignore lint/a11y/useSemanticElements: is the standard ButtonGroup pattern; alternatives (fieldset/menu) carry unwanted semantics. */}
-
- {children}
-
-
+ // biome-ignore lint/a11y/useSemanticElements:
is the standard ButtonGroup pattern; alternatives (fieldset/menu) carry unwanted semantics.
+
+ {children}
+
);
},
);
diff --git a/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap b/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap
new file mode 100644
index 00000000..51495af2
--- /dev/null
+++ b/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap
@@ -0,0 +1,49 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Button type error messages > prints a message that names the constraint that failed 1`] = `
+{
+ "disabled and disabledTooltip > a possibly undefined disabled still needs a reason > \`disabled\` typed \`boolean | undefined\` is still \`disabled\`": "TS2741: Property ''\`disabled\` needs \`disabledTooltip\`, a disabled control has to tell the user why it cannot be used'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabled: boolean | undefined; }' but required in type 'ADisabledButtonMustSayWhy'.",
+ "disabled and disabledTooltip > a reason without disabled is rejected > \`disabledTooltip\` never renders unless \`disabled\` is set": "TS2741: Property ''\`disabledTooltip\` only renders while \`disabled\` is set, add \`disabled\` or drop the tooltip'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabledTooltip: string; }' but required in type 'ADisabledReasonNeedsADisabledButton'.",
+ "disabled and disabledTooltip > disabled without a reason is rejected > a disabled button must explain itself through \`disabledTooltip\`": "TS2741: Property ''\`disabled\` needs \`disabledTooltip\`, a disabled control has to tell the user why it cannot be used'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabled: true; }' but required in type 'ADisabledButtonMustSayWhy'.",
+ "icon mode > an icon without an accessible name is rejected > an icon button has no visible text, so \`aria-label\` is required": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record
& RefAttributes)'.
+ Property ''aria-label'' is missing in type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; }' but required in type 'IconButtonProps'.",
+ "icon mode > children are required > a button with nothing inside it is not allowed": "TS2322: Type '{ size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Property 'children' is missing in type '{ size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' but required in type 'ButtonBaseProps'.",
+ "icon mode > icon only accepts true > drop the prop for a text button, \`icon={false}\` is not a mode": "TS2322: Type 'false' is not assignable to type 'true'.",
+ "icon mode > prefix and suffix are rejected, the children are the icon > \`prefix\` has no slot to render into in icon mode": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; prefix: Element; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'prefix' are incompatible.
+ Type 'Element' is not assignable to type 'undefined'.",
+ "icon mode > prefix and suffix are rejected, the children are the icon > \`suffix\` has no slot to render into in icon mode": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; suffix: Element; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'suffix' are incompatible.
+ Type 'Element' is not assignable to type 'undefined'.",
+ "icon mode > text children are rejected > icon mode renders an element, not a label": "TS2322: Type '{ children: string; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'children' are incompatible.
+ Type 'string' is not assignable to type 'ReactNode & ReactElement>'.",
+ "prefix, suffix and children > children are required > a button with nothing inside it is not allowed": "TS2322: Type '{ size: "md"; variant: "solid"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Type '{ size: "md"; variant: "solid"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'.
+ Property 'children' is missing in type '{ size: "md"; variant: "solid"; color: "primary"; }' but required in type 'ButtonBaseProps'.",
+ "remaining props > rejects a textOverflow outside the set > \`clip\` is not a ButtonTextOverflow": "TS2322: Type '"clip"' is not assignable to type 'TextOverflowType | undefined'.",
+ "size > is required > \`size\` has no default, it must be picked explicitly": "TS2322: Type '{ children: string; variant: "solid"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Type '{ children: string; variant: "solid"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'.
+ Property 'size' is missing in type '{ children: string; variant: "solid"; color: "primary"; }' but required in type 'ButtonBaseProps'.",
+ "size > rejects a size outside the scale > \`lg\` is not a ButtonSize": "TS2322: Type '"lg"' is not assignable to type 'SizeType'.",
+ "test ids > rejects a raw data-testid > use \`testId\`, it survives the tooltip trigger cloning the button": "TS2741: Property ''\`data-testid\` is written as the \`testId\` prop, which survives the tooltip trigger cloning the button'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; "data-testid": string; }' but required in type 'TheTestIdPropIsCalledTestId'.",
+ "unknown props > rejects a misspelled prop > \`onClik\` is not a prop, a generic \`T extends ButtonProps\` alone would let it through": "TS2322: Type '() => void' is not assignable to type 'never'.",
+ "variant and color > both are required > \`color\` has no default": "TS2322: Type '{ children: string; size: "md"; variant: "solid"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Type '{ children: string; size: "md"; variant: "solid"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'.
+ Property 'color' is missing in type '{ children: string; size: "md"; variant: "solid"; }' but required in type 'ColoredVariantProps'.",
+ "variant and color > both are required > \`variant\` has no default": "TS2322: Type '{ children: string; size: "md"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Type '{ children: string; size: "md"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'.
+ Property 'variant' is missing in type '{ children: string; size: "md"; color: "primary"; }' but required in type 'ColoredVariantProps'.",
+ "variant and color > outlined, ghost and dashed reject every other color > \`dashed\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "dashed"; color: "warning"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'color' are incompatible.
+ Type '"warning"' is not assignable to type '"secondary"'.",
+ "variant and color > outlined, ghost and dashed reject every other color > \`ghost\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "ghost"; color: "danger"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'color' are incompatible.
+ Type '"danger"' is not assignable to type '"secondary"'.",
+ "variant and color > outlined, ghost and dashed reject every other color > \`outlined\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "outlined"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'.
+ Types of property 'color' are incompatible.
+ Type '"primary"' is not assignable to type '"secondary"'.",
+ "variant and color > rejects a variant outside the set > \`elevated\` is not a ButtonVariant": "TS2322: Type '"elevated"' is not assignable to type '"link" | "solid" | "outlined" | "ghost" | "dashed"'.",
+}
+`;
diff --git a/packages/ui/src/button/__tests__/button.affixes.test.tsx b/packages/ui/src/button/__tests__/button.affixes.test.tsx
new file mode 100644
index 00000000..c9c9d3f4
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.affixes.test.tsx
@@ -0,0 +1,165 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button prefix', () => {
+ it('clones the element into the prefix slot with the sizing class', () => {
+ render(
+ }>
+ Label
+ ,
+ );
+
+ const prefix = screen.getByTestId('prefix');
+ expect(document.querySelector('[data-slot="button-prefix-slot"]')).toContainElement(prefix);
+ expect(prefix.className).not.toBe('');
+ });
+
+ it('keeps the class the caller put on the element', () => {
+ render(
+ }
+ >
+ Label
+ ,
+ );
+
+ const prefix = screen.getByTestId('prefix');
+ expect(prefix).toHaveClass('user-class');
+ expect(prefix.classList.length).toBe(2);
+ });
+
+ it('marks the wrapper empty when there is no prefix, so it can collapse', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute(
+ 'data-empty',
+ 'true',
+ );
+ });
+
+ it('marks the wrapper filled when a prefix is given', () => {
+ render(
+ }>
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute(
+ 'data-empty',
+ 'false',
+ );
+ });
+
+ it('keeps the wrapper mounted without a prefix, it also holds the spinner', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toBeInTheDocument();
+ expect(document.querySelector('[data-slot="button-prefix-loading"]')).toBeInTheDocument();
+ });
+
+ it('drops the prefix when the prop is removed', () => {
+ const { rerender } = render(
+ }>
+ Label
+ ,
+ );
+ expect(screen.getByTestId('prefix')).toBeInTheDocument();
+
+ rerender(
+
+ Label
+ ,
+ );
+
+ expect(screen.queryByTestId('prefix')).not.toBeInTheDocument();
+ });
+});
+
+describe('Button suffix', () => {
+ it('clones the element into the suffix slot with the sizing class', () => {
+ render(
+ }>
+ Label
+ ,
+ );
+
+ const suffix = screen.getByTestId('suffix');
+ expect(document.querySelector('[data-slot="button-suffix-slot"]')).toContainElement(suffix);
+ expect(suffix.className).not.toBe('');
+ });
+
+ it('keeps the class the caller put on the element', () => {
+ render(
+ }
+ >
+ Label
+ ,
+ );
+
+ const suffix = screen.getByTestId('suffix');
+ expect(suffix).toHaveClass('user-class');
+ expect(suffix.classList.length).toBe(2);
+ });
+
+ it('renders no suffix slot at all without a suffix', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-suffix-slot"]')).toBeNull();
+ });
+
+ it('keeps the affixes out of the accessible name', () => {
+ render(
+ }
+ suffix={ }
+ >
+ Create alert
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAccessibleName('Create alert');
+ });
+
+ it('orders the prefix, the label and the suffix in the DOM', () => {
+ render(
+ }
+ suffix={ }
+ >
+ Label
+ ,
+ );
+
+ // @ts-ignore
+ const slots = [...screen.getByRole('button').children].map((child) =>
+ child.getAttribute('data-slot'),
+ );
+ expect(slots).toEqual(['button-prefix-wrapper', 'button-label', 'button-suffix-slot']);
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx
new file mode 100644
index 00000000..7f94a63c
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx
@@ -0,0 +1,312 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { queryOpenTooltip } from './test-utils.js';
+
+const REASON = 'You need write access to edit alerts';
+
+describe('Button disabledTooltip', () => {
+ it('shows the reason on hover', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('shows the reason on keyboard focus', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+
+ await user.tab();
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('hides the reason again when the pointer leaves', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ await user.unhover(button);
+
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
+ });
+
+ it('hides the reason again on blur', async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ Delete alert
+
+
+ >,
+ );
+
+ await user.tab();
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ await user.tab();
+
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
+ });
+
+ it('renders a rich reason, not only text', async () => {
+ const user = userEvent.setup();
+ render(
+ Ask an admin}
+ >
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByTestId('reason')).toBeInTheDocument();
+ });
+
+ it('ties the button to the popup with aria-describedby', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip.id).toBeTruthy();
+ expect(button).toHaveAttribute('aria-describedby', tooltip.id);
+ });
+
+ it('points aria-describedby at nothing while there is no popup', () => {
+ render(
+
+ Delete alert
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby');
+ });
+});
+
+describe('Button disabledTooltip while usable', () => {
+ it('says nothing while the button is enabled', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ expect(button).toHaveAttribute('aria-disabled', 'false');
+ });
+
+ it('stops saying it the moment the button becomes usable', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('is never mounted when there is no reason to give', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+});
+
+describe('Button disabledTooltip while loading', () => {
+ it('says nothing while loading, busy is not unavailable', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Deleting…
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('is still not natively disabled while loading and disabled at once', () => {
+ render(
+
+ Deleting…
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toBeEnabled();
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('shows the reason again once loading ends', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ Deleting…
+ ,
+ );
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('does not pop back open when the reason disappears and returns with the pointer away', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ Delete alert
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ rerender(
+
+ Deleting…
+ ,
+ );
+ await user.unhover(button);
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+
+ await waitFor(() => expect(queryOpenTooltip()).toBeNull());
+ });
+});
+
+describe('Button disabledTooltip element identity', () => {
+ it('keeps the same element as the tooltip content is mounted and unmounted', () => {
+ const { rerender } = render(
+
+ Delete alert
+ ,
+ );
+ const button = screen.getByTestId('btn');
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+ expect(screen.getByTestId('btn')).toBe(button);
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+ expect(screen.getByTestId('btn')).toBe(button);
+ });
+
+ it('keeps forwarding testId and data attributes through the tooltip trigger', () => {
+ render(
+
+ Delete alert
+ ,
+ );
+
+ expect(screen.getByTestId('btn')).toHaveAttribute('data-foo', 'bar');
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.disabled.test.tsx b/packages/ui/src/button/__tests__/button.disabled.test.tsx
new file mode 100644
index 00000000..74ff5e80
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.disabled.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button disabled state', () => {
+ it('marks itself unavailable through aria-disabled instead of the native attribute', () => {
+ render(
+
+ Delete
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).toBeEnabled();
+ });
+
+ it('stays focusable and tabbable, so the reason for it stays reachable', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete
+ ,
+ );
+
+ await user.tab();
+
+ expect(screen.getByRole('button')).toHaveFocus();
+ });
+
+ it('swallows clicks and double clicks', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onDoubleClick = vi.fn();
+ render(
+
+ Delete
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+ await user.dblClick(button);
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onDoubleClick).not.toHaveBeenCalled();
+ });
+
+ it.each(['{Enter}', '[Space]'])('swallows %s', async (key) => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Delete
+ ,
+ );
+
+ screen.getByRole('button').focus();
+ await user.keyboard(key);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it('does not claim to be busy', () => {
+ render(
+
+ Delete
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy');
+ });
+
+ it('accepts clicks again once it is enabled', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const { rerender } = render(
+
+ Delete
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+
+ rerender(
+
+ Delete
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.form.test.tsx b/packages/ui/src/button/__tests__/button.form.test.tsx
new file mode 100644
index 00000000..8915a31b
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.form.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+function renderForm(button: React.ReactNode, onSubmit = vi.fn()) {
+ render(
+ ,
+ );
+
+ return onSubmit;
+}
+
+describe('Button inside a form', () => {
+ it('submits the form with type="submit"', async () => {
+ const user = userEvent.setup();
+ const onSubmit = renderForm(
+
+ Submit
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not submit the form with the default type', async () => {
+ const user = userEvent.setup();
+ const onSubmit = renderForm(
+
+ Do something else
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('resets the form fields with type="reset"', async () => {
+ const user = userEvent.setup();
+ renderForm(
+
+ Reset
+ ,
+ );
+ const input = screen.getByTestId('input');
+
+ await user.type(input, 'typed');
+ expect(input).toHaveValue('typed');
+
+ await user.click(screen.getByRole('button'));
+
+ expect(input).toHaveValue('');
+ });
+
+ it('does not submit while disabled', async () => {
+ const user = userEvent.setup();
+ const onSubmit = renderForm(
+
+ Submit
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('does not submit twice while loading', async () => {
+ const user = userEvent.setup();
+ const onSubmit = renderForm(
+
+ Submitting…
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.forward-ref.test.tsx b/packages/ui/src/button/__tests__/button.forward-ref.test.tsx
new file mode 100644
index 00000000..41ea01dd
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.forward-ref.test.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it } from 'vitest';
+import { Button } from '../index.js';
+import { Tooltip, TooltipProvider } from '../../tooltip/index.js';
+
+describe('Button forwardRef', () => {
+ it('forwards the ref to the rendered button element', () => {
+ const ref = createRef();
+ render(
+
+ Click
+ ,
+ );
+
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
+ expect(ref.current).toBe(screen.getByRole('button'));
+ expect(ref.current).toHaveAttribute('data-slot', 'button');
+ });
+
+ it('forwards the ref through the tooltip trigger the ellipsis mode mounts', () => {
+ const ref = createRef();
+ render(
+
+ Click
+ ,
+ );
+
+ expect(ref.current).toBe(screen.getByRole('button'));
+ });
+
+ it('forwards the ref without a tooltip trigger in the way', () => {
+ const ref = createRef();
+ render(
+
+ Click
+ ,
+ );
+
+ expect(ref.current).toBe(screen.getByRole('button'));
+ });
+
+ it('forwards the ref from inside a wrapping tooltip', () => {
+ const ref = createRef();
+ render(
+
+
+
+ Click
+
+
+ ,
+ );
+
+ expect(ref.current).toBe(screen.getByRole('button'));
+ });
+
+ it('calls a callback ref with the button and with null on unmount', () => {
+ const seen: Array = [];
+ const { unmount } = render(
+ {
+ seen.push(node);
+ }}
+ >
+ Click
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ unmount();
+
+ expect(seen[0]).toBe(button);
+ expect(seen.at(-1)).toBeNull();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.icon.test.tsx b/packages/ui/src/button/__tests__/button.icon.test.tsx
new file mode 100644
index 00000000..2e8dcf9a
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.icon.test.tsx
@@ -0,0 +1,99 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button icon mode', () => {
+ it('marks itself with data-icon so the square padding tokens apply', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-icon', 'true');
+ });
+
+ it('renders the children into the prefix slot, so the loading swap works on them', () => {
+ render(
+
+
+ ,
+ );
+
+ const slot = document.querySelector('[data-slot="button-prefix-slot"]');
+ expect(slot).toContainElement(screen.getByTestId('icon'));
+ });
+
+ it('renders neither a label slot nor a suffix slot', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-label"]')).toBeNull();
+ expect(document.querySelector('[data-slot="button-suffix-slot"]')).toBeNull();
+ });
+
+ it('keeps the prefix wrapper open, the icon is the only content', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute(
+ 'data-empty',
+ 'false',
+ );
+ });
+
+ it('takes its accessible name from aria-label, having no text of its own', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAccessibleName('Star this dashboard');
+ });
+
+ it('still clicks like any other button', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('swaps to the spinner and stops responding while loading', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
+ expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.interaction.test.tsx b/packages/ui/src/button/__tests__/button.interaction.test.tsx
new file mode 100644
index 00000000..2687f69e
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.interaction.test.tsx
@@ -0,0 +1,167 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button pointer interaction', () => {
+ it('calls onClick with the button as the event target', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Click me
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(onClick.mock.calls[0]?.[0].target).toBe(button);
+ });
+
+ it('calls onClick once per click', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Click me
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalledTimes(3);
+ });
+
+ it('calls onDoubleClick once and onClick twice on a double click', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onDoubleClick = vi.fn();
+ render(
+
+ Double
+ ,
+ );
+
+ await user.dblClick(screen.getByRole('button'));
+
+ expect(onDoubleClick).toHaveBeenCalledTimes(1);
+ expect(onClick).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not activate on hover alone', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Hover me
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+});
+
+describe('Button keyboard interaction', () => {
+ it('activates on Enter', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Save
+ ,
+ );
+
+ screen.getByRole('button').focus();
+ await user.keyboard('{Enter}');
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('activates on Space', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Save
+ ,
+ );
+
+ screen.getByRole('button').focus();
+ await user.keyboard('[Space]');
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('forwards onKeyDown and onKeyUp with the pressed key', async () => {
+ const user = userEvent.setup();
+ const onKeyDown = vi.fn();
+ const onKeyUp = vi.fn();
+ render(
+
+ Save
+ ,
+ );
+
+ screen.getByRole('button').focus();
+ await user.keyboard('{Escape}');
+
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
+ expect(onKeyDown.mock.calls[0]?.[0].key).toBe('Escape');
+ expect(onKeyUp).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('Button focus', () => {
+ it('is reachable by Tab', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Save
+ ,
+ );
+
+ await user.tab();
+
+ expect(screen.getByRole('button')).toHaveFocus();
+ });
+
+ it('is skipped by Tab when tabIndex is -1', async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ Skipped
+
+
+ >,
+ );
+
+ await user.tab();
+
+ expect(screen.getByTestId('input')).toHaveFocus();
+ });
+
+ it('takes focus on mount with autoFocus', () => {
+ render(
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- the prop is what this test covers
+
+ Save
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveFocus();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx
new file mode 100644
index 00000000..9fd8a6de
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx
@@ -0,0 +1,95 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { mockLabelMeasurement, resetLabelMeasurement, truncate } from './test-utils.js';
+
+const BUSY = 'Deleting the rules, this can take a minute';
+const REASON = 'You need write access to edit alerts';
+const LABEL = 'Delete every alert rule in this workspace';
+
+beforeAll(mockLabelMeasurement);
+afterEach(resetLabelMeasurement);
+
+describe('Button loadingTooltip', () => {
+ it('says what the button is busy with on hover', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(BUSY);
+ });
+
+ it('says nothing while the button is idle', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('takes the place of the disabled reason while loading', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ Delete alert
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(BUSY);
+ expect(tooltip).not.toHaveTextContent(REASON);
+
+ rerender(
+
+ Delete alert
+ ,
+ );
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('stacks above the truncated label, busy first', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(`${BUSY}${LABEL}`);
+ expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.loading.test.tsx b/packages/ui/src/button/__tests__/button.loading.test.tsx
new file mode 100644
index 00000000..520dddc7
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.loading.test.tsx
@@ -0,0 +1,212 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button loading state', () => {
+ it('announces itself as busy and unavailable', () => {
+ render(
+
+ Saving…
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('never sets the native disabled attribute, so the click does not throw focus away', () => {
+ render(
+
+ Saving…
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toBeEnabled();
+ button.focus();
+ expect(button).toHaveFocus();
+ });
+
+ it('keeps aria-busy off while idle', () => {
+ render(
+
+ Save
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).not.toHaveAttribute('aria-busy');
+ expect(button).toHaveAttribute('aria-disabled', 'false');
+ });
+
+ it('keeps the label as the accessible name while busy', () => {
+ render(
+
+ Saving…
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAccessibleName('Saving…');
+ });
+});
+
+describe('Button loading spinner', () => {
+ it('hides the spinner from screen readers, aria-busy already says it', () => {
+ render(
+
+ Saving…
+ ,
+ );
+
+ const slot = document.querySelector('[data-slot="button-prefix-loading"]');
+ expect(slot).toHaveAttribute('aria-hidden', 'true');
+ expect(slot).toContainElement(document.querySelector('[data-slot="spinner"]'));
+ });
+
+ it('keeps the spinner mounted while idle, the swap is a css cross-fade', () => {
+ render(
+
+ Save
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument();
+ });
+
+ it('keeps the prefix and suffix mounted next to the spinner', () => {
+ render(
+ }
+ suffix={ }
+ >
+ Saving…
+ ,
+ );
+
+ expect(screen.getByTestId('prefix')).toBeInTheDocument();
+ expect(screen.getByTestId('suffix')).toBeInTheDocument();
+ expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument();
+ });
+
+ it('leaves the prefix wrapper collapsed when there is no prefix to make room for', () => {
+ const { rerender } = render(
+
+ Saving…
+ ,
+ );
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute(
+ 'data-empty',
+ 'true',
+ );
+
+ rerender(
+ }
+ >
+ Saving…
+ ,
+ );
+ expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute(
+ 'data-empty',
+ 'false',
+ );
+ });
+});
+
+describe('Button loading activation', () => {
+ it('swallows clicks and double clicks', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onDoubleClick = vi.fn();
+ render(
+
+ Saving…
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+ await user.dblClick(button);
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onDoubleClick).not.toHaveBeenCalled();
+ });
+
+ it.each(['{Enter}', '[Space]'])('swallows %s', async (key) => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+ Saving…
+ ,
+ );
+
+ screen.getByRole('button').focus();
+ await user.keyboard(key);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it('accepts clicks again once loading ends', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const { rerender } = render(
+
+ Saving…
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+ expect(onClick).not.toHaveBeenCalled();
+
+ rerender(
+
+ Save
+ ,
+ );
+
+ await user.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the focus it already had when loading starts and ends', () => {
+ const { rerender } = render(
+
+ Save
+ ,
+ );
+ const button = screen.getByRole('button');
+ button.focus();
+
+ rerender(
+
+ Saving…
+ ,
+ );
+ expect(button).toHaveFocus();
+
+ rerender(
+
+ Save
+ ,
+ );
+ expect(button).toHaveFocus();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx
new file mode 100644
index 00000000..d8e3966d
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx
@@ -0,0 +1,218 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js';
+
+const LABEL = 'A very long destructive label';
+
+beforeAll(mockLabelMeasurement);
+afterEach(resetLabelMeasurement);
+
+describe('Button overflow tooltip', () => {
+ it('shows the full label on hover once the text is ellipsed', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(LABEL);
+ });
+
+ it('shows the full label on keyboard focus too', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.tab();
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(LABEL);
+ });
+
+ it('ties the button to the popup with aria-describedby', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(button).toHaveAttribute('aria-describedby', tooltip.id);
+ });
+
+ it('closes again on blur', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+ <>
+
+ {LABEL}
+
+
+ >,
+ );
+
+ await user.tab();
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ await user.tab();
+
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
+ });
+});
+
+describe('Button overflow tooltip while the label fits', () => {
+ it('puts nothing in the DOM at all', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Short
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('closes an open popup when a resize makes the label fit', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+
+ resize(300, 300);
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('stops opening on later hovers', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+ const button = screen.getByRole('button');
+
+ await user.hover(button);
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ await user.unhover(button);
+
+ resize(300, 300);
+ await user.hover(button);
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+});
+
+describe('Button overflow tooltip opt-outs', () => {
+ it('is never mounted for textOverflow=none', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+
+ it('does not even make the button a trigger for textOverflow=none', () => {
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-slot', 'tooltip-trigger');
+ });
+
+ it('is never mounted for an icon button', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+ });
+});
+
+describe('Button overflow tooltip element identity', () => {
+ it('keeps the same element, and its focus, across a truncation flip', () => {
+ render(
+
+ {LABEL}
+ ,
+ );
+ const button = screen.getByTestId('btn');
+ button.focus();
+
+ resize(300, 100);
+ expect(screen.getByTestId('btn')).toBe(button);
+ expect(button).toHaveFocus();
+
+ resize(300, 300);
+ expect(screen.getByTestId('btn')).toBe(button);
+ expect(button).toHaveFocus();
+ });
+
+ it('leaves focus where it was when the button was not focused', () => {
+ render(
+ <>
+
+ {LABEL}
+
+
+ >,
+ );
+ const input = screen.getByTestId('input');
+ input.focus();
+
+ resize(300, 100);
+
+ expect(input).toHaveFocus();
+ });
+
+ it('keeps rendering a native button through the tooltip trigger', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByTestId('btn').tagName).toBe('BUTTON');
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.rendering.test.tsx b/packages/ui/src/button/__tests__/button.rendering.test.tsx
new file mode 100644
index 00000000..4ae48aa3
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.rendering.test.tsx
@@ -0,0 +1,218 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../button.js';
+
+describe('Button rendering', () => {
+ it('renders a native button that is named by its children', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Label' });
+ expect(button.tagName).toBe('BUTTON');
+ });
+
+ it('defaults to type="button", so a button inside a form never submits by accident', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
+ });
+
+ it.each(['submit', 'reset', 'button'] as const)('honours type="%s"', (type) => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('type', type);
+ });
+
+ it('exposes testId as data-testid', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByTestId('my-button').tagName).toBe('BUTTON');
+ });
+
+ it('leaves data-testid off when no testId is given', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-testid');
+ });
+
+ it('keeps the component class next to a custom className', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveClass('custom-class');
+ expect(button.className.split(' ').length).toBeGreaterThan(1);
+ });
+
+ it('forwards id, tabIndex and aria attributes', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Toolbar action' });
+ expect(button).toHaveAttribute('id', 'save');
+ expect(button).toHaveAttribute('tabindex', '-1');
+ expect(button).toHaveAttribute('aria-haspopup', 'menu');
+ });
+
+ it('forwards arbitrary data-* attributes', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('data-foo', 'bar');
+ expect(button).toHaveAttribute('data-analytics-id', 'save');
+ });
+
+ it('wraps the label in its own slot, so the affixes are not measured with it', () => {
+ render(
+ }>
+ Label
+ ,
+ );
+
+ const label = document.querySelector('[data-slot="button-label"]');
+ expect(label).toHaveTextContent('Label');
+ expect(label?.querySelector('[data-testid="prefix"]')).toBeNull();
+ });
+
+ it('accepts a key without reading it as a prop, which React warns about', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+ Label
+ ,
+ );
+
+ expect(error).not.toHaveBeenCalled();
+ error.mockRestore();
+ });
+
+ it('renders element children inside the label slot', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-label"]')).toContainElement(
+ screen.getByTestId('strong'),
+ );
+ });
+});
+
+describe('Button width', () => {
+ it('maps width to the width custom property', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button').style.getPropertyValue('--button-internal-width')).toBe(
+ '10rem',
+ );
+ });
+
+ it('maps maxWidth to the max-width custom property', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button').style.getPropertyValue('--button-internal-max-width')).toBe(
+ '20rem',
+ );
+ });
+
+ it('writes a number as px, custom properties get no unit from React', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const { style } = screen.getByRole('button');
+ expect(style.getPropertyValue('--button-internal-width')).toBe('200px');
+ expect(style.getPropertyValue('--button-internal-max-width')).toBe('320px');
+ });
+
+ it('keeps a zero, which is a real length', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button').style.getPropertyValue('--button-internal-max-width')).toBe(
+ '0px',
+ );
+ });
+
+ it('leaves both custom properties unset by default, so the size tokens win', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const { style } = screen.getByRole('button');
+ expect(style.getPropertyValue('--button-internal-width')).toBe('');
+ expect(style.getPropertyValue('--button-internal-max-width')).toBe('');
+ });
+
+ it('keeps the caller style alongside the custom properties', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveStyle({ color: 'rgb(255, 0, 0)', marginTop: '4px' });
+ expect(button.style.getPropertyValue('--button-internal-width')).toBe('10rem');
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.text-overflow.test.tsx b/packages/ui/src/button/__tests__/button.text-overflow.test.tsx
new file mode 100644
index 00000000..c52258d8
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.text-overflow.test.tsx
@@ -0,0 +1,170 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js';
+
+beforeAll(mockLabelMeasurement);
+afterEach(resetLabelMeasurement);
+
+describe('Button textOverflow', () => {
+ it('defaults to ellipsis', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-text-overflow', 'ellipsis');
+ });
+
+ it.each(['none', 'ellipsis'] as const)(
+ 'exposes textOverflow=%s as data-text-overflow',
+ (textOverflow) => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-text-overflow', textOverflow);
+ },
+ );
+
+ it('gives the label its own slot to measure', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-label"]')).toHaveTextContent('Label');
+ });
+
+ it('has no label slot to measure on an icon button', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(document.querySelector('[data-slot="button-label"]')).toBeNull();
+ });
+});
+
+describe('Button truncation flag', () => {
+ it('flags a label that does not fit', () => {
+ truncate();
+ render(
+
+ A very long destructive label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-truncated');
+ });
+
+ it('leaves a label that fits unflagged', () => {
+ render(
+
+ Short
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+ });
+
+ it('ignores a one pixel overflow, which rounded measurements produce on their own', () => {
+ resize(101, 100);
+ render(
+
+ Short
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+ });
+
+ it('picks the flag up when the button is resized down', () => {
+ render(
+
+ A very long destructive label
+ ,
+ );
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+
+ resize(300, 100);
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-truncated');
+ });
+
+ it('drops the flag when the button is resized back up', () => {
+ truncate();
+ render(
+
+ A very long destructive label
+ ,
+ );
+ expect(screen.getByRole('button')).toHaveAttribute('data-truncated');
+
+ resize(300, 300);
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+ });
+
+ it('keeps measuring across repeated flips', () => {
+ render(
+
+ A very long destructive label
+ ,
+ );
+
+ resize(300, 100);
+ expect(screen.getByRole('button')).toHaveAttribute('data-truncated');
+
+ resize(300, 300);
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+
+ resize(300, 100);
+ expect(screen.getByRole('button')).toHaveAttribute('data-truncated');
+ });
+
+ it('re-measures when the label text changes at the same width', async () => {
+ const { rerender } = render(
+
+ Save
+ ,
+ );
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+
+ truncate();
+ rerender(
+
+ Save every alert rule in this workspace
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('data-truncated'));
+ });
+
+ it('never flags textOverflow=none, which clips instead of truncating', () => {
+ truncate();
+ render(
+
+ A very long label
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+ });
+
+ it('never flags an icon button, which has no label to truncate', () => {
+ truncate();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated');
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx b/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx
new file mode 100644
index 00000000..843dbce7
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx
@@ -0,0 +1,224 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { Tooltip } from '../../tooltip/index.js';
+import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js';
+
+const REASON = 'You need write access to edit alerts';
+const LABEL = 'A very long destructive label';
+
+beforeAll(mockLabelMeasurement);
+afterEach(resetLabelMeasurement);
+
+describe('Button reason stacked over the truncated label', () => {
+ it('shows both, reason first, separated by a divider', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(`${REASON}${LABEL}`);
+ expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeInTheDocument();
+ });
+
+ it('shows the reason alone when the label is not truncated', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delete
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(REASON);
+ expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeNull();
+ });
+
+ it('hands the popup back to the label once the button is usable', async () => {
+ const user = userEvent.setup();
+ truncate();
+ const { rerender } = render(
+
+ {LABEL}
+ ,
+ );
+
+ rerender(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(LABEL);
+ expect(tooltip).not.toHaveTextContent(REASON);
+ });
+
+ it('shows the reason for textOverflow=none, which has no label popup of its own', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+ {LABEL}
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('shows the reason for an icon button, which has no label popup either', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON);
+ });
+
+ it('keeps the same element across a truncation flip', () => {
+ render(
+
+ {LABEL}
+ ,
+ );
+ const button = screen.getByTestId('btn');
+
+ resize(300, 100);
+
+ expect(screen.getByTestId('btn')).toBe(button);
+ });
+});
+
+describe('Button inside another tooltip', () => {
+ it('adds the truncated label to the wrapping popup instead of opening a second one', async () => {
+ const user = userEvent.setup();
+ truncate();
+ render(
+
+
+ {LABEL}
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltips = await screen.findAllByRole('tooltip');
+ expect(tooltips).toHaveLength(1);
+ expect(tooltips[0]).toHaveTextContent(`Outer reason${LABEL}`);
+ });
+
+ it('adds the disabled reason under the outer title', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ Delete alert
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltips = await screen.findAllByRole('tooltip');
+ expect(tooltips).toHaveLength(1);
+ expect(tooltips[0]).toHaveTextContent(`Outer reason${REASON}`);
+ });
+
+ it('shows the disabled reason when the wrapping tooltip has no title of its own', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ Delete alert
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ const tooltips = await screen.findAllByRole('tooltip');
+ expect(tooltips).toHaveLength(1);
+ expect(tooltips[0]).toHaveTextContent(REASON);
+ });
+
+ it('does not become a trigger of its own', () => {
+ truncate();
+ render(
+
+
+ {LABEL}
+
+ ,
+ );
+
+ expect(document.querySelectorAll('[data-slot="tooltip-trigger"]')).toHaveLength(1);
+ expect(screen.getByTestId('btn')).toHaveAttribute('data-truncated');
+ });
+
+ it('keeps its own testId, which the cloning trigger would otherwise drop', () => {
+ render(
+
+
+ Delete alert
+
+ ,
+ );
+
+ expect(screen.getByTestId('btn')).toBeInTheDocument();
+ });
+
+ it('stays hoverable while disabled, so the wrapping tooltip still opens', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ Delete alert
+
+ ,
+ );
+
+ await user.hover(screen.getByRole('button'));
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Outer reason');
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.types.messages.test.ts b/packages/ui/src/button/__tests__/button.types.messages.test.ts
new file mode 100644
index 00000000..1147b478
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.types.messages.test.ts
@@ -0,0 +1,140 @@
+/**
+ * Message assertions for the type tests in `button.types.test-d.tsx`.
+ *
+ * `@ts-expect-error` only asserts that *an* error happens on the next line. It says nothing about
+ * which constraint fired or what the compiler printed, so a case can keep passing for entirely the
+ * wrong reason, and a rewrite of the prop types can quietly turn a readable message into an
+ * unreadable one.
+ *
+ * This test closes that gap. It compiles the same fixture with the `@ts-expect-error` directives
+ * blanked out, then snapshots the diagnostic each case actually produces, keyed by
+ * `describe > test > reason`. Blanking (rather than deleting) keeps every line number intact, so a
+ * diagnostic on the line below a directive belongs to that directive.
+ *
+ * Two failure modes are covered beyond the snapshot itself: a case that stops erroring, and an error
+ * on a line no directive covers, meaning a combination we accept has started to fail.
+ */
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+const currentDir = dirname(fileURLToPath(import.meta.url));
+const fixturePath = resolve(currentDir, 'button.types.test-d.tsx');
+const tsconfigPath = resolve(currentDir, '../../../tsconfig.json');
+
+const DESCRIBE = /^\s*describe\('([^']+)'/;
+const TEST = /^\s*test\('([^']+)'/;
+const EXPECT_ERROR = /^\s*\/\/\s*@ts-expect-error\s*-?\s*(.*)$/;
+
+interface ExpectedError {
+ /** 1-based line the directive suppresses, i.e. the line holding the opening ` {
+ const describeMatch = DESCRIBE.exec(line);
+ if (describeMatch?.[1] !== undefined) {
+ describeName = describeMatch[1];
+ return;
+ }
+
+ const testMatch = TEST.exec(line);
+ if (testMatch?.[1] !== undefined) {
+ testName = testMatch[1];
+ return;
+ }
+
+ const expectErrorMatch = EXPECT_ERROR.exec(line);
+ if (expectErrorMatch?.[1] === undefined) {
+ return;
+ }
+
+ expected.push({
+ line: index + 2,
+ label: `${describeName} > ${testName} > ${expectErrorMatch[1].trim()}`,
+ });
+ lines[index] = '';
+ });
+
+ return { source: lines.join('\n'), expected };
+}
+
+/**
+ * Compiles the package exactly as `tsc` would, with the fixture's contents swapped for `source`.
+ */
+function compileFixture(source: string): ts.Diagnostic[] {
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
+ const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(tsconfigPath));
+
+ const host = ts.createCompilerHost(config.options, true);
+ const getSourceFile = host.getSourceFile.bind(host);
+ const readFile = host.readFile.bind(host);
+
+ host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => {
+ if (resolve(fileName) !== fixturePath) {
+ return getSourceFile(fileName, languageVersion, onError, shouldCreate);
+ }
+
+ return ts.createSourceFile(fileName, source, languageVersion, true, ts.ScriptKind.TSX);
+ };
+ host.readFile = (fileName) => (resolve(fileName) === fixturePath ? source : readFile(fileName));
+
+ const program = ts.createProgram(config.fileNames, config.options, host);
+ const fixture = program.getSourceFile(fixturePath);
+
+ return [...program.getSyntacticDiagnostics(fixture), ...program.getSemanticDiagnostics(fixture)];
+}
+
+const { source, expected } = readFixture();
+const diagnostics = compileFixture(source);
+
+/** Diagnostics grouped by the 1-based line they were reported on. */
+const byLine = new Map();
+for (const diagnostic of diagnostics) {
+ if (diagnostic.file === undefined || diagnostic.start === undefined) {
+ continue;
+ }
+
+ const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
+ const message = `TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n ')}`;
+
+ byLine.set(line + 1, [...(byLine.get(line + 1) ?? []), message]);
+}
+
+describe('Button type error messages', () => {
+ it('reports an error for every rejected combination', () => {
+ const silent = expected.filter(({ line }) => !byLine.has(line)).map(({ label }) => label);
+
+ expect(silent).toEqual([]);
+ });
+
+ it('reports nothing for the accepted combinations', () => {
+ const covered = new Set(expected.map(({ line }) => line));
+ const unexpected = [...byLine.entries()]
+ .filter(([line]) => !covered.has(line))
+ .map(([line, messages]) => `${line}: ${messages[0]}`);
+
+ expect(unexpected).toEqual([]);
+ });
+
+ it('prints a message that names the constraint that failed', () => {
+ const messages = Object.fromEntries(
+ expected.map(({ line, label }) => [label, byLine.get(line)?.join('\n') ?? null]),
+ );
+
+ expect(messages).toMatchSnapshot();
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.types.test-d.tsx b/packages/ui/src/button/__tests__/button.types.test-d.tsx
new file mode 100644
index 00000000..d9d90af2
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.types.test-d.tsx
@@ -0,0 +1,369 @@
+/**
+ * Type-level tests for the props of {@link Button}.
+ *
+ * Run by `vitest --typecheck` (see `typecheck` in `vitest.config.ts`) and, because the file lives
+ * under `src` and is not excluded by `tsconfig.json`, also by `pnpm type-check`.
+ *
+ * Nothing here executes. Each case is a JSX element written the way a consumer writes it: it either
+ * compiles, or it is marked `@ts-expect-error` because we refuse that combination. TypeScript
+ * reports an unused `@ts-expect-error` as an error of its own, so loosening a constraint by accident
+ * fails the build instead of passing silently.
+ *
+ * The comment suppresses the line directly below it, which is why it sits inside `assertType(...)`
+ * right above the opening ` ;
+const noop = (): void => {};
+const buttonRef = createRef();
+declare const maybeDisabled: boolean | undefined;
+
+describe('size', () => {
+ test('accepts every ButtonSize', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('is required', () => {
+ assertType(
+ // @ts-expect-error - `size` has no default, it must be picked explicitly
+
+ Label
+ ,
+ );
+ });
+
+ test('rejects a size outside the scale', () => {
+ assertType(
+ // @ts-expect-error - `lg` is not a ButtonSize
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('variant and color', () => {
+ test('solid and link accept every color', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('outlined, ghost and dashed accept secondary', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('outlined, ghost and dashed reject every other color', () => {
+ assertType(
+ // @ts-expect-error - `outlined` only has a secondary treatment
+
+ Label
+ ,
+ );
+ assertType(
+ // @ts-expect-error - `ghost` only has a secondary treatment
+
+ Label
+ ,
+ );
+ assertType(
+ // @ts-expect-error - `dashed` only has a secondary treatment
+
+ Label
+ ,
+ );
+ });
+
+ test('both are required', () => {
+ assertType(
+ // @ts-expect-error - `variant` has no default
+
+ Label
+ ,
+ );
+ assertType(
+ // @ts-expect-error - `color` has no default
+
+ Label
+ ,
+ );
+ });
+
+ test('rejects a variant outside the set', () => {
+ assertType(
+ // @ts-expect-error - `elevated` is not a ButtonVariant
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('disabled and disabledTooltip', () => {
+ test('accepts the pair', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('accepts an explicit undefined reason as the opt-out', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('a possibly undefined disabled still needs a reason', () => {
+ assertType(
+ // @ts-expect-error - `disabled` typed `boolean | undefined` is still `disabled`
+
+ Label
+ ,
+ );
+ });
+
+ test('disabled without a reason is rejected', () => {
+ assertType(
+ // @ts-expect-error - a disabled button must explain itself through `disabledTooltip`
+
+ Label
+ ,
+ );
+ });
+
+ test('a reason without disabled is rejected', () => {
+ assertType(
+ // @ts-expect-error - `disabledTooltip` never renders unless `disabled` is set
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('icon mode', () => {
+ test('accepts an icon with an accessible name', () => {
+ assertType(
+
+ {icon}
+ ,
+ );
+ });
+
+ test('an icon without an accessible name is rejected', () => {
+ assertType(
+ // @ts-expect-error - an icon button has no visible text, so `aria-label` is required
+
+ {icon}
+ ,
+ );
+ });
+
+ test('prefix and suffix are rejected, the children are the icon', () => {
+ assertType(
+ // @ts-expect-error - `prefix` has no slot to render into in icon mode
+
+ {icon}
+ ,
+ );
+ assertType(
+ // @ts-expect-error - `suffix` has no slot to render into in icon mode
+
+ {icon}
+ ,
+ );
+ });
+
+ test('text children are rejected', () => {
+ assertType(
+ // @ts-expect-error - icon mode renders an element, not a label
+
+ Label
+ ,
+ );
+ });
+
+ test('children are required', () => {
+ assertType(
+ // @ts-expect-error - a button with nothing inside it is not allowed
+ ,
+ );
+ });
+
+ test('icon only accepts true', () => {
+ assertType(
+ // @ts-expect-error - drop the prop for a text button, `icon={false}` is not a mode
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('prefix, suffix and children', () => {
+ test('accepts either affix', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('children are required', () => {
+ assertType(
+ // @ts-expect-error - a button with nothing inside it is not allowed
+ ,
+ );
+ });
+});
+
+describe('test ids', () => {
+ test('accepts testId and arbitrary data attributes', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('rejects a raw data-testid', () => {
+ assertType(
+ // @ts-expect-error - use `testId`, it survives the tooltip trigger cloning the button
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('unknown props', () => {
+ test('accepts key and ref', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('accepts the forwarded focus and pointer handlers', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('rejects a misspelled prop', () => {
+ assertType(
+ // @ts-expect-error - `onClik` is not a prop, a generic `T extends ButtonProps` alone would let it through
+
+ Label
+ ,
+ );
+ });
+});
+
+describe('remaining props', () => {
+ test('accepts the presentational and native ones', () => {
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ assertType(
+
+ Label
+ ,
+ );
+ });
+
+ test('rejects a textOverflow outside the set', () => {
+ assertType(
+ // @ts-expect-error - `clip` is not a ButtonTextOverflow
+
+ Label
+ ,
+ );
+ });
+});
diff --git a/packages/ui/src/button/__tests__/button.variants.test.tsx b/packages/ui/src/button/__tests__/button.variants.test.tsx
new file mode 100644
index 00000000..f3dbf330
--- /dev/null
+++ b/packages/ui/src/button/__tests__/button.variants.test.tsx
@@ -0,0 +1,97 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Button } from '../button.js';
+import { ButtonColor, ButtonSize } from '../constants.js';
+import type { VariantColorType } from '../types.js';
+
+const VARIANTS = [
+ { variant: 'solid', color: 'primary' },
+ { variant: 'link', color: 'primary' },
+ { variant: 'outlined', color: 'secondary' },
+ { variant: 'ghost', color: 'secondary' },
+ { variant: 'dashed', color: 'secondary' },
+] as const satisfies readonly VariantColorType[];
+
+describe('Button style tokens', () => {
+ it.each(VARIANTS)('exposes variant=$variant as data-variant', ({ variant, color }) => {
+ render(
+ // @ts-expect-error For some reason, it's complaining about disabled
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-variant', variant);
+ });
+
+ it.each(Object.values(ButtonSize))('exposes size=%s as data-size', (size) => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-size', size);
+ });
+
+ it.each(Object.values(ButtonColor))('exposes color=%s as data-color', (color) => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-color', color);
+ });
+
+ it('marks a non-icon button with data-icon="false"', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('data-icon', 'false');
+ });
+});
+
+describe('Button dashed border', () => {
+ it('draws the dashes as an svg overlay, so they can be animated', () => {
+ const { container } = render(
+
+ Add step
+ ,
+ );
+
+ const overlay = container.querySelector('[data-slot="button-dashed-border"]');
+ expect(overlay?.tagName).toBe('svg');
+ expect(overlay?.querySelector('rect')).toBeInTheDocument();
+ });
+
+ it('keeps the overlay out of the accessibility tree and out of the tab order', () => {
+ const { container } = render(
+
+ Add step
+ ,
+ );
+
+ const overlay = container.querySelector('[data-slot="button-dashed-border"]');
+ expect(overlay).toHaveAttribute('aria-hidden', 'true');
+ expect(overlay).toHaveAttribute('focusable', 'false');
+ expect(screen.getByRole('button')).toHaveAccessibleName('Add step');
+ });
+
+ it.each(VARIANTS.filter(({ variant }) => variant !== 'dashed'))(
+ 'leaves variant=$variant without an overlay',
+ ({ variant, color }) => {
+ const { container } = render(
+ // @ts-expect-error For some reason, it's complaining about disabled
+
+ Label
+ ,
+ );
+
+ expect(container.querySelector('[data-slot="button-dashed-border"]')).toBeNull();
+ },
+ );
+});
diff --git a/packages/ui/src/button/__tests__/test-utils.tsx b/packages/ui/src/button/__tests__/test-utils.tsx
new file mode 100644
index 00000000..6da14705
--- /dev/null
+++ b/packages/ui/src/button/__tests__/test-utils.tsx
@@ -0,0 +1,88 @@
+import { act } from '@testing-library/react';
+
+/**
+ * jsdom has no layout, so every element measures 0x0 and no label ever reads as
+ * truncated. These fake the two properties the truncation check looks at, for
+ * the label slot only, so the rest of the tree keeps its real (zero) sizes.
+ */
+let labelScrollWidth = 0;
+let labelClientWidth = 0;
+
+function isLabel(element: HTMLElement): boolean {
+ return element.dataset.slot === 'button-label';
+}
+
+/** Callbacks of every live ResizeObserver, so a resize can be replayed by hand. */
+const resizeCallbacks = new Set();
+
+/** Label wider than its box: the text is cut off. */
+export function truncate(): void {
+ labelScrollWidth = 300;
+ labelClientWidth = 100;
+}
+
+/** Sets the label measurements and replays them to every live observer. */
+export function resize(scrollWidth: number, clientWidth: number): void {
+ labelScrollWidth = scrollWidth;
+ labelClientWidth = clientWidth;
+
+ act(() => {
+ for (const callback of resizeCallbacks) {
+ callback([], {} as ResizeObserver);
+ }
+ });
+}
+
+/** Call from `beforeAll`: installs the label measurement and ResizeObserver fakes. */
+export function mockLabelMeasurement(): void {
+ const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth');
+ const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
+
+ Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
+ configurable: true,
+ get(this: HTMLElement) {
+ return isLabel(this) ? labelScrollWidth : (scrollWidth?.get?.call(this) ?? 0);
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
+ configurable: true,
+ get(this: HTMLElement) {
+ return isLabel(this) ? labelClientWidth : (clientWidth?.get?.call(this) ?? 0);
+ },
+ });
+
+ globalThis.ResizeObserver = class ResizeObserver {
+ constructor(private readonly callback: ResizeObserverCallback) {}
+
+ observe(): void {
+ resizeCallbacks.add(this.callback);
+ // A real ResizeObserver reports the initial size once observation starts,
+ // on the next frame. Delivering it inline keeps the tests synchronous.
+ this.callback([], this);
+ }
+
+ unobserve(): void {
+ resizeCallbacks.delete(this.callback);
+ }
+
+ disconnect(): void {
+ resizeCallbacks.delete(this.callback);
+ }
+ };
+}
+
+/** Call from `afterEach`: drops the measurements and observers of the last render. */
+export function resetLabelMeasurement(): void {
+ labelScrollWidth = 0;
+ labelClientWidth = 0;
+ resizeCallbacks.clear();
+}
+
+/**
+ * The tooltip popup, but only while it is open. Base UI keeps a closed popup
+ * mounted until its exit transition ends, and jsdom never runs one, so a closed
+ * popup can linger in the DOM long after the tooltip is gone on screen.
+ */
+export function queryOpenTooltip(): HTMLElement | null {
+ return document.querySelector('[data-slot="tooltip-content"][data-open]');
+}
diff --git a/packages/ui/src/button/button.forward-ref.test.tsx b/packages/ui/src/button/button.forward-ref.test.tsx
deleted file mode 100644
index a05252bc..00000000
--- a/packages/ui/src/button/button.forward-ref.test.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { render } from '@testing-library/react';
-import { createRef } from 'react';
-import { describe, expect, it } from 'vitest';
-
-import { Button } from './index.js';
-
-describe('Button forwardRef', () => {
- it('forwards ref', () => {
- const ref = createRef();
- render(Click );
- expect(ref.current).toBeInstanceOf(HTMLButtonElement);
- });
-});
diff --git a/packages/ui/src/button/button.module.scss b/packages/ui/src/button/button.module.scss
index 6d9c339d..da2e4623 100644
--- a/packages/ui/src/button/button.module.scss
+++ b/packages/ui/src/button/button.module.scss
@@ -1,9 +1,12 @@
.button {
display: var(--button-display, inline-flex);
+ flex-direction: var(--button-flex-direction, row);
align-items: var(--button-align-items, center);
justify-content: var(--button-justify-content, center);
+ gap: var(--button-gap, var(--button-internal-gap));
white-space: var(--button-white-space, nowrap);
- border-radius: var(--button-border-radius, calc(var(--radius) - 2px));
+ --button-internal-border-radius: var(--button-border-radius, var(--radius-1));
+ border-radius: var(--button-internal-border-radius);
transition: var(--button-transition,
background-color 150ms ease,
color 150ms ease,
@@ -11,233 +14,456 @@
cursor: var(--button-cursor, pointer);
background-color: var(--button-internal-background);
color: var(--button-internal-solid-foreground);
- line-height: var(--button-line-height, 100%);
font-variant-numeric: var(--button-font-variant-numeric, slashed-zero);
+ font-weight: var(--button-font-weight, var(--font-weight-medium));
+ // TODO: Convert this hardcoded value to semantic token
+ letter-spacing: var(--button-text-spacing, -0.005em);
border-color: var(--button-base-border-color, transparent);
border-width: var(--button-base-border-width, 0px);
+ width: var(--button-width, var(--button-internal-width, auto));
+ max-width: var(--button-max-width, var(--button-internal-max-width, 100%));
+
+ --button-internal-loading-duration: var(--button-loading-duration, 120ms);
+ --button-internal-loading-delay: var(--button-loading-delay, var(--button-internal-loading-duration));
+ --button-internal-loading-easing: var(--button-loading-easing, cubic-bezier(0.65, 0, 0.35, 1));
+ --button-internal-loading-travel: var(--button-loading-travel, 6px);
&[data-color='primary'] {
--button-internal-background: var(--button-primary-background, var(--primary-background));
- --button-internal-border-color: var(--button-primary-border-color, var(--primary-background));
--button-internal-solid-foreground: var(--button-primary-solid-foreground, var(--primary-foreground));
- --button-internal-outlined-foreground: var(--button-primary-outlined-foreground, var(--primary));
--button-internal-hover-background: var(--button-primary-hover-background, var(--primary-background-hover));
- --button-internal-link-hover-foreground: var(--button-primary-link-hover-foreground, var(--primary-background-hover));
- --button-internal-ghost-hover-foreground: var(--button-primary-ghost-hover-foreground, var(--primary-foreground-hover));
+ --button-internal-link-foreground: var(--button-primary-link-foreground, var(--primary-link));
+ --button-internal-link-hover-foreground: var(--button-primary-link-hover-foreground, var(--primary-hover));
+ }
+
+ &[data-color='secondary'] {
+ --button-internal-background: var(--button-secondary-background, var(--secondary-background));
+ --button-internal-solid-foreground: var(--button-secondary-solid-foreground, var(--secondary-foreground));
+ --button-internal-hover-background: var(--button-secondary-hover-background, var(--secondary-background-hover));
+ --button-internal-link-foreground: var(--button-secondary-link-foreground, var(--secondary-link));
+ --button-internal-link-hover-foreground: var(--button-secondary-link-hover-foreground, var(--secondary-link-hover));
}
- &[data-color='destructive'] {
- --button-internal-background: var(--button-destructive-background, var(--danger-background));
- --button-internal-border-color: var(--button-destructive-border-color, var(--danger-background));
- --button-internal-solid-foreground: var(--button-destructive-solid-foreground, var(--danger-foreground));
- --button-internal-outlined-foreground: var(--button-destructive-outlined-foreground, var(--danger-background));
- --button-internal-hover-background: var(--button-destructive-hover-background, var(--danger-background-hover));
- --button-internal-link-hover-foreground: var(--button-destructive-link-hover-foreground, var(--danger-background-hover));
- --button-internal-ghost-hover-foreground: var(--button-destructive-ghost-hover-foreground, var(--danger-foreground-hover));
+ &[data-color='success'] {
+ --button-internal-background: var(--button-success-background, var(--success-background));
+ --button-internal-solid-foreground: var(--button-success-solid-foreground, var(--success-foreground));
+ --button-internal-hover-background: var(--button-success-hover-background, var(--success-background-hover));
+ --button-internal-link-foreground: var(--button-success-link-foreground, var(--success-link));
+ --button-internal-link-hover-foreground: var(--button-success-link-hover-foreground, var(--success-link-hover));
+ }
+
+ &[data-color='danger'] {
+ --button-internal-background: var(--button-danger-background, var(--danger-background));
+ --button-internal-solid-foreground: var(--button-danger-solid-foreground, var(--danger-foreground));
+ --button-internal-hover-background: var(--button-danger-hover-background, var(--danger-background-hover));
+ --button-internal-link-foreground: var(--button-danger-link-foreground, var(--danger-link));
+ --button-internal-link-hover-foreground: var(--button-danger-link-hover-foreground, var(--danger-link-hover));
}
&[data-color='warning'] {
--button-internal-background: var(--button-warning-background, var(--warning-background));
- --button-internal-border-color: var(--button-warning-border-color, var(--warning-background));
--button-internal-solid-foreground: var(--button-warning-solid-foreground, var(--warning-foreground));
- --button-internal-outlined-foreground: var(--button-warning-outlined-foreground, var(--warning-background));
--button-internal-hover-background: var(--button-warning-hover-background, var(--warning-background-hover));
- --button-internal-link-hover-foreground: var(--button-warning-link-hover-foreground, var(--warning-background-hover));
- --button-internal-ghost-hover-foreground: var(--button-warning-ghost-hover-foreground, var(--warning-foreground-hover));
+ --button-internal-link-foreground: var(--button-warning-link-foreground, var(--warning-link));
+ --button-internal-link-hover-foreground: var(--button-warning-link-hover-foreground, var(--warning-link-hover));
}
- &[data-color='secondary'] {
- --button-internal-background: var(--button-secondary-background, var(--secondary-background));
- --button-internal-border-color: var(--button-secondary-border-color, var(--secondary-border));
- --button-internal-solid-foreground: var(--button-secondary-solid-foreground, var(--secondary-foreground));
- --button-internal-outlined-foreground: var(--button-secondary-outlined-foreground, var(--secondary-foreground));
- --button-internal-hover-background: var(--button-secondary-hover-background, var(--secondary-background-hover));
- --button-internal-link-hover-foreground: var(--button-secondary-link-hover-foreground, var(--secondary-foreground-hover));
- --button-internal-ghost-hover-foreground: var(--button-secondary-ghost-hover-foreground, var(--secondary-foreground-hover));
+ &[data-color='info'] {
+ --button-internal-background: var(--button-info-background, var(--bg-aqua-500));
+ --button-internal-solid-foreground: var(--button-info-solid-foreground, var(--text-ink-500));
+ --button-internal-hover-background: var(--button-info-hover-background, var(--bg-aqua-400));
+ --button-internal-link-foreground: var(--button-info-link-foreground, var(--bg-aqua-400));
+ --button-internal-link-hover-foreground: var(--button-info-link-hover-foreground, var(--bg-aqua-500));
}
- &[data-color='none'] {
- --button-internal-background: var(--button-none-background, var(--ghost-background));
- --button-internal-border-color: var(--button-none-border-color, var(--ghost-border));
- --button-internal-solid-foreground: var(--button-none-solid-foreground, var(--ghost-foreground));
- --button-internal-outlined-foreground: var(--button-none-outlined-foreground, var(--ghost-foreground));
- --button-internal-hover-background: var(--button-none-hover-background, var(--ghost-background-hover));
- --button-internal-link-hover-foreground: var(--button-none-link-hover-foreground, var(--ghost-foreground-hover));
- --button-internal-ghost-hover-foreground: var(--button-none-ghost-hover-foreground, var(--ghost-foreground-hover));
+ &[data-color='archive'] {
+ --button-internal-background: var(--button-archive-background, var(--bg-sienna-500));
+ --button-internal-solid-foreground: var(--button-archive-solid-foreground, var(--text-ink-500));
+ --button-internal-hover-background: var(--button-archive-hover-background, var(--bg-sienna-400));
+ --button-internal-link-foreground: var(--button-archive-link-foreground, var(--bg-sienna-400));
+ --button-internal-link-hover-foreground: var(--button-archive-link-hover-foreground, var(--bg-sienna-500));
}
- &[data-color='primary'],
- &[data-color='destructive'],
- &[data-color='warning'],
- &[data-color='none'],
- &[data-color='secondary'] {
- --button-internal-action-border: var(--button-action-border, var(--action-border));
- --button-internal-action-hover-border: var(--button-action-hover-border, var(--action-border-hover));
- --button-internal-action-text: var(--button-action-text, var(--action-foreground));
- --button-internal-action-hover-text: var(--button-action-hover-text, var(--action-foreground-hover));
- }
-
- &[data-background='ink-500'] {
- --button-internal-action-background: var(--button-action-ink-500-background, var(--action-background));
- --button-internal-action-border: var(--button-action-ink-500-border, var(--action-border));
- --button-internal-action-text: var(--button-action-ink-500-text, var(--action-foreground));
- --button-internal-action-hover-background: var(--button-action-ink-500-hover-background, var(--action-background-hover));
- --button-internal-action-hover-border: var(--button-action-ink-500-hover-border, var(--action-border-hover));
- --button-internal-action-hover-text: var(--button-action-ink-500-hover-text, var(--action-foreground-hover));
- }
-
- &[data-background='ink-400'] {
- --button-internal-action-background: var(--button-action-ink-400-background, var(--action-background));
- --button-internal-action-border: var(--button-action-ink-400-border, var(--action-border));
- --button-internal-action-text: var(--button-action-ink-400-text, var(--action-foreground));
- --button-internal-action-hover-background: var(--button-action-ink-400-hover-background, var(--action-background-hover));
- --button-internal-action-hover-border: var(--button-action-ink-400-hover-border, var(--action-border-hover));
- --button-internal-action-hover-text: var(--button-action-ink-400-hover-text, var(--action-foreground-hover));
- }
-
- &[data-background='vanilla-100'] {
- --button-internal-action-background: var(--button-action-vanilla-100-background, var(--action-background));
- --button-internal-action-border: var(--button-action-vanilla-100-border, var(--action-border));
- --button-internal-action-text: var(--button-action-vanilla-100-text, var(--action-foreground));
- --button-internal-action-hover-background: var(--button-action-vanilla-100-hover-background, var(--action-background-hover));
- --button-internal-action-hover-border: var(--button-action-vanilla-100-hover-border, var(--action-border-hover));
- --button-internal-action-hover-text: var(--button-action-vanilla-100-hover-text, var(--action-foreground-hover));
- }
-
- &[data-background='vanilla-200'] {
- --button-internal-action-background: var(--button-action-vanilla-200-background, var(--action-background));
- --button-internal-action-border: var(--button-action-vanilla-200-border, var(--action-border));
- --button-internal-action-text: var(--button-action-vanilla-200-text, var(--action-foreground));
- --button-internal-action-hover-background: var(--button-action-vanilla-200-hover-background, var(--action-background-hover));
- --button-internal-action-hover-border: var(--button-action-vanilla-200-hover-border, var(--action-border-hover));
- --button-internal-action-hover-text: var(--button-action-vanilla-200-hover-text, var(--action-foreground-hover));
+ &[data-color='highlight-danger'] {
+ --button-internal-background: var(--button-highlight-danger-background, var(--bg-sakura-500));
+ --button-internal-solid-foreground: var(--button-highlight-danger-solid-foreground, var(--text-ink-500));
+ --button-internal-hover-background: var(--button-highlight-danger-hover-background, var(--bg-sakura-400));
+ --button-internal-link-foreground: var(--button-highlight-danger-link-foreground, var(--bg-sakura-400));
+ --button-internal-link-hover-foreground: var(--button-highlight-danger-link-hover-foreground, var(--bg-sakura-500));
}
-}
-.button:focus-visible {
- outline-offset: var(--button-focus-visible-outline-offset, 2px);
- outline: var(--button-focus-visible-outline, var(--ring) solid 2px);
-}
+ &:focus-visible {
+ outline-offset: var(--button-focus-visible-outline-offset, 1px);
+ outline: var(--button-focus-visible-outline, var(--ring) solid 1px);
+ }
-.button:hover {
- background-color: var(--button-internal-hover-state-background-color, var(--button-internal-hover-background));
-}
+ &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) {
+ background-color: var(--button-hover-state-background-color, var(--button-internal-hover-background));
+ }
-.button:disabled {
- pointer-events: var(--button-disabled-pointer-events, none);
- opacity: var(--button-disabled-opacity, 0.6);
-}
+ &:disabled:not([aria-busy="true"]),
+ &[aria-disabled="true"]:not([aria-busy="true"]) {
+ opacity: var(--button-disabled-opacity, 0.6);
+ cursor: var(--button-disabled-cursor, not-allowed);
+ }
-.button--loading {
- cursor: var(--button-loading-cursor, wait);
-}
+ &[aria-busy="true"] {
+ opacity: var(--button-loading-opacity, 1);
+ cursor: var(--button-loading-cursor, wait);
+ }
-.button[data-variant='outlined'] {
- border: var(--button-variant-outlined-border, 1px solid var(--button-internal-border-color));
- background-color: var(--button-variant-outlined-background-color, transparent);
- color: var(--button-variant-outlined-color, var(--button-internal-outlined-foreground));
-}
+ &__suffix-slot {
+ display: contents;
+ }
+
+ &__label {
+ display: var(--button-label-display, block);
+ min-width: var(--button-label-min-width, 0);
+ overflow: var(--button-label-overflow, hidden);
+ white-space: var(--button-label-white-space, nowrap);
+
+ &-tooltip {
+ max-width: var(--button-label-tooltip-max-width, 20rem);
+ }
+ }
+
+ &[data-text-overflow='ellipsis'] .button__label {
+ text-overflow: var(--button-label-text-overflow, ellipsis);
+ }
+
+ &[data-variant='outlined'] {
+ border: var(--button-variant-outlined-border, 1px solid var(--secondary-border));
+ background-color: var(--button-variant-outlined-background-color, var(--secondary-background));
+ color: var(--button-variant-outlined-color, var(--secondary-foreground));
+
+ &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) {
+ background-color: var(--button-variant-outlined-hover-background-color, var(--secondary-background-hover));
+ color: var(--button-variant-outlined-hover-color, var(--secondary-foreground-hover));
+ }
+
+ &[aria-disabled="true"]:not([aria-busy="true"]) {
+ position: var(--button-variant-outlined-disabled-position, relative);
+ // Keeps the stripe bands inside the border radius.
+ overflow: var(--button-variant-outlined-disabled-overflow, hidden);
+
+ .button__prefix-wrapper,
+ .button__label,
+ .button__suffix {
+ position: var(--button-variant-outlined-disabled-content-position, relative);
+ z-index: var(--button-variant-outlined-disabled-content-z-index, 2);
+ }
+
+ &::before,
+ &::after {
+ content: var(--button-variant-outlined-disabled-stripe-content, "");
+ position: var(--button-variant-outlined-disabled-stripe-position, absolute);
+ top: var(--button-variant-outlined-disabled-stripe-top, 0);
+ bottom: var(--button-variant-outlined-disabled-stripe-bottom, 0);
+ width: var(--button-variant-outlined-disabled-stripe-width, 16px);
+ background-image: var(--button-variant-outlined-disabled-stripe-background-image,
+ repeating-linear-gradient(-45deg,
+ transparent,
+ transparent 3px,
+ var(--button-variant-outlined-disabled-stripe-color, var(--secondary-border)) 2px,
+ var(--button-variant-outlined-disabled-stripe-color, var(--secondary-border)) 5px));
+ z-index: var(--button-variant-outlined-disabled-stripe-z-index, 1);
+ pointer-events: var(--button-variant-outlined-disabled-stripe-pointer-events, none);
+ }
+
+ &::before {
+ left: var(--button-variant-outlined-disabled-stripe-before-left, 0);
+ mask-image: var(--button-variant-outlined-disabled-stripe-before-mask-image,
+ linear-gradient(to left, transparent 5%, black 100%));
+ }
+
+ &::after {
+ right: var(--button-variant-outlined-disabled-stripe-after-right, 0);
+ mask-image: var(--button-variant-outlined-disabled-stripe-after-mask-image,
+ linear-gradient(to right, transparent 5%, black 100%));
+ }
+ }
+ }
+
+ &[data-variant='dashed'] {
+ border: var(--button-variant-dashed-border, 1px solid transparent);
+ background-color: var(--button-variant-dashed-background-color, transparent);
+ color: var(--button-variant-dashed-color, var(--secondary-foreground));
+ position: var(--button-variant-dashed-position, relative);
+
+ --button-internal-dash-border-width: var(--button-dash-border-width, 1px);
+ --button-internal-dash-stroke-width: var(--button-dash-stroke-width, 1px);
+ --button-internal-dash-length: var(--button-dash-length, 3px);
+ --button-internal-dash-gap: var(--button-dash-gap, 3px);
+ --button-internal-dash-duration: var(--button-dash-duration, 200ms);
+ --button-internal-dash-stroke: var(--button-dash-stroke, var(--secondary-border));
+
+ &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) {
+ background-color: var(--button-variant-dashed-hover-background-color, transparent);
+ color: var(--button-variant-dashed-hover-color, var(--secondary-foreground-hover));
+ }
+
+ &:hover:not([aria-disabled="true"]),
+ &[aria-busy="true"] {
+ --button-internal-dash-stroke: var(--button-dash-hover-stroke,
+ var(--secondary-background-hover));
+ }
+ }
+
+ &__dash-border {
+ --button-internal-dash-offset: calc(var(--button-internal-dash-stroke-width) / 2 - var(--button-internal-dash-border-width));
+ position: absolute;
+ inset-inline-start: var(--button-internal-dash-offset);
+ inset-block-start: var(--button-internal-dash-offset);
+ inline-size: calc(100% - 2 * var(--button-internal-dash-offset));
+ block-size: calc(100% - 2 * var(--button-internal-dash-offset));
+ overflow: visible;
+ pointer-events: none;
+
+ rect {
+ fill: none;
+ stroke: var(--button-internal-dash-stroke);
+ stroke-width: var(--button-internal-dash-stroke-width);
+ stroke-dasharray: var(--button-internal-dash-length) var(--button-internal-dash-gap);
+ rx: var(--button-internal-border-radius);
+ ry: var(--button-internal-border-radius);
+ transition: stroke 150ms ease;
+ animation: button-dash-march var(--button-internal-dash-duration) linear infinite;
+ animation-play-state: paused;
+ }
+ }
-.button[data-variant='outlined']:hover {
- background-color: var(--button-variant-outlined-hover-background-color,
- var(--button-internal-border-color));
- color: var(--button-variant-outlined-hover-color, var(--button-internal-solid-foreground));
+ &[data-variant='ghost'] {
+ background-color: var(--button-variant-ghost-background-color, transparent);
+ color: var(--button-variant-ghost-color, var(--secondary-foreground));
+ position: var(--button-variant-ghost-position, relative);
+ overflow: var(--button-variant-ghost-overflow, hidden);
+
+ // on light, this used to be 12%, but since this is not controlled by semantic token
+ // I will keep as smooth as possible for any theme
+ --button-internal-ghost-glow-opacity: var(--button-ghost-glow-opacity, 22%);
+
+ .button__prefix-wrapper,
+ .button__label,
+ .button__suffix {
+ position: relative;
+ z-index: var(--button-ghost-content-z-index, 1);
+ }
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: var(--button-ghost-glow-inset, -4px);
+ z-index: var(--button-ghost-glow-z-index, 0);
+ pointer-events: none;
+ opacity: 0;
+ filter: blur(var(--button-ghost-glow-blur, 6px));
+ background: var(--button-ghost-glow-background,
+ linear-gradient(90deg,
+ color-mix(in srgb, var(--bg-cherry-400) var(--button-internal-ghost-glow-opacity), transparent),
+ color-mix(in srgb, var(--bg-amber-500) var(--button-internal-ghost-glow-opacity), transparent),
+ color-mix(in srgb, var(--bg-forest-400) var(--button-internal-ghost-glow-opacity), transparent),
+ color-mix(in srgb, var(--bg-aqua-400) var(--button-internal-ghost-glow-opacity), transparent),
+ color-mix(in srgb, var(--bg-robin-400) var(--button-internal-ghost-glow-opacity), transparent),
+ color-mix(in srgb, var(--bg-cherry-400) var(--button-internal-ghost-glow-opacity), transparent)));
+ // Twice the width so the gradient can slide a full period and wrap on itself.
+ background-size: var(--button-ghost-glow-background-size, 200% 100%);
+ transition: opacity var(--button-ghost-glow-fade, 600ms ease);
+ animation: button-ghost-shimmer var(--button-ghost-glow-duration, 2.5s) linear infinite;
+ animation-play-state: paused;
+ }
+
+ &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) {
+ background-color: var(--button-variant-ghost-hover-background-color, var(--secondary-background-hover));
+ color: var(--button-variant-ghost-hover-color, var(--secondary-foreground-hover));
+ }
+
+ &:active:not([aria-disabled="true"])::before,
+ &[aria-busy="true"]::before {
+ opacity: var(--button-ghost-glow-active-opacity, 1);
+ animation-play-state: running;
+ }
+ }
+
+ &[data-variant='link'] {
+ background-color: var(--button-variant-link-background-color, transparent);
+ font-weight: var(--button-variant-link-font-weight, var(--font-weight-medium));
+ color: var(--button-variant-link-color, var(--button-internal-link-foreground));
+
+ &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) {
+ background-color: var(--button-variant-link-hover-background-color, transparent);
+ color: var(--button-variant-link-hover-color, var(--button-internal-link-hover-foreground));
+ }
+ }
+
+ &[data-size='sm'] {
+ height: var(--button-height, 24px);
+ font-size: var(--button-font-size, var(--periscope-font-size-small));
+ line-height: var(--button-size-line-height, 14px);
+
+ --button-internal-icon-size: var(--button-icon-size, 12px);
+ --button-internal-gap: var(--spacing-3);
+
+ &:not([data-variant="link"]) {
+ &[data-icon="false"] {
+ padding: var(--button-padding, var(--spacing-2) var(--spacing-4));
+ }
+
+ &[data-icon="true"] {
+ padding: var(--button-padding, var(--spacing-2));
+ --button-internal-icon-size: var(--button-icon-size, 14px);
+ --button-internal-width: 24px;
+ --button-internal-max-width: 24px;
+ }
+ }
+ }
+
+ &[data-size='md'] {
+ height: var(--button-height, 32px);
+ font-size: var(--button-font-size, var(--periscope-font-size-base));
+ line-height: var(--button-size-line-height, 16px);
+
+ --button-internal-icon-size: var(--button-icon-size, 14px);
+ --button-internal-gap: var(--spacing-3);
+
+ &:not([data-variant="link"]) {
+ &[data-icon="false"] {
+ padding: var(--button-padding, var(--spacing-4) var(--spacing-6));
+ }
+
+ &[data-icon="true"] {
+ padding: var(--button-padding, var(--spacing-4));
+ --button-internal-icon-size: var(--button-icon-size, 16px);
+ --button-internal-width: 32px;
+ --button-internal-max-width: 32px;
+ }
+ }
+ }
}
-.button[data-variant='dashed'] {
- border: var(--button-variant-dashed-border, 1px dashed var(--button-internal-border-color));
- background-color: var(--button-variant-dashed-background-color, transparent);
- color: var(--button-variant-dashed-color, var(--button-internal-outlined-foreground));
+.button[data-variant='dashed']:hover:not([aria-disabled="true"]) .button__dash-border rect,
+.button[data-variant='dashed'][aria-busy="true"] .button__dash-border rect {
+ animation-play-state: running;
}
-.button[data-variant='dashed']:hover {
- background-color: var(--button-variant-dashed-hover-background-color,
- var(--button-internal-border-color));
- color: var(--button-variant-dashed-hover-color, var(--button-internal-solid-foreground));
+.button__prefix,
+.button__prefix > svg,
+.button__suffix,
+.button__suffix > svg {
+ flex-shrink: var(--button-affix-flex-shrink, 0);
+ inline-size: var(--button-internal-icon-size);
+ block-size: var(--button-internal-icon-size);
}
-.button[data-variant='ghost'] {
- background-color: var(--button-variant-ghost-background-color, transparent);
- color: var(--button-variant-ghost-color, var(--button-internal-outlined-foreground));
+.button__prefix-wrapper {
+ display: var(--button-prefix-wrapper-display, grid);
+ grid-template-columns: var(--button-prefix-wrapper-grid-template-columns, 1fr);
+ block-size: var(--button-prefix-wrapper-block-size, var(--button-internal-icon-size));
+ align-items: var(--button-prefix-wrapper-align-items, center);
+ justify-items: var(--button-prefix-wrapper-justify-items, center);
+ flex-shrink: var(--button-prefix-wrapper-flex-shrink, 0);
+ overflow: var(--button-prefix-wrapper-overflow, hidden);
+ transition: var(--button-prefix-wrapper-transition,
+ grid-template-columns var(--button-internal-loading-duration) var(--button-internal-loading-easing),
+ margin-inline-end var(--button-internal-loading-duration) var(--button-internal-loading-easing));
+ // Opening up for the spinner is the first beat of the sequence.
+ transition-delay: 0s;
}
-.button[data-variant='ghost']:hover {
- background-color: var(--button-variant-ghost-hover-background-color, var(--button-internal-hover-background));
- color: var(--button-variant-ghost-hover-color, var(--button-internal-solid-foreground));
+.button:not([aria-busy="true"]) .button__prefix-wrapper[data-empty='true'] {
+ grid-template-columns: var(--button-prefix-wrapper-collapsed-grid-template-columns, 0fr);
+ margin-inline-end: var(--button-prefix-wrapper-collapsed-margin-inline-end, calc(-1 * var(--button-internal-gap)));
+ // Closing back up is the last beat: wait for the spinner to leave first.
+ transition-delay: var(--button-internal-loading-delay);
}
-.button[data-variant='link'] {
- background-color: var(--button-variant-link-background-color, transparent);
- font-weight: var(--button-variant-link-font-weight, 500);
- color: var(--button-variant-link-color, var(--button-internal-outlined-foreground));
+.button__prefix-slot,
+.button__loader-slot {
+ grid-area: 1 / 1;
+ display: var(--button-slot-display, flex);
+ align-items: var(--button-slot-align-items, center);
+ justify-content: var(--button-slot-justify-content, center);
+ min-width: var(--button-slot-min-width, 0);
+ transition: var(--button-slot-transition,
+ opacity var(--button-internal-loading-duration) var(--button-internal-loading-easing),
+ transform var(--button-internal-loading-duration) var(--button-internal-loading-easing));
}
-.button[data-variant='link']:hover {
- background-color: var(--button-variant-link-hover-background-color, transparent);
- color: var(--button-variant-link-hover-color, var(--button-internal-link-hover-foreground));
+.button__prefix-slot {
+ opacity: var(--button-prefix-slot-visible-opacity, 1);
+ transform: var(--button-prefix-slot-visible-transform, translateY(0));
+ transition-delay: var(--button-internal-loading-delay);
}
-.button[data-variant='action'] {
- border: var(--button-variant-action-border, 1px solid var(--button-internal-action-border));
- background-color: var(--button-variant-action-background-color, var(--button-internal-action-background));
- color: var(--button-variant-action-color, var(--button-internal-action-text));
+.button__loader-slot {
+ // The ring sizes itself off the button's icon size, no shared stylesheet.
+ --spinner-size: var(--button-internal-icon-size);
+ opacity: var(--button-loader-slot-hidden-opacity, 0);
+ transform: var(--button-loader-slot-hidden-transform,
+ translateY(var(--button-internal-loading-travel)));
+ transition-delay: 0s;
}
-.button[data-variant='action']:hover {
- background-color: var(--button-variant-action-hover-background-color,
- var(--button-internal-action-hover-background));
- border-color: var(--button-variant-action-hover-border-color, var(--button-internal-action-hover-border));
- color: var(--button-variant-action-hover-color, var(--button-internal-action-hover-text));
+// The spinner stays mounted for the cross-fade, so the ring is frozen while the
+// button is idle instead of turning behind an invisible slot.
+.button:not([aria-busy="true"]) .button__loader-slot [data-slot='spinner'] {
+ animation-play-state: var(--button-loader-slot-idle-animation-play-state, paused);
}
-.button[data-size='sm'] {
- height: var(--button-height, 1.5rem);
- font-size: var(--button-font-size, var(--periscope-font-size-small, 11px));
- line-height: var(--button-size-sm-line-height, 1.5rem);
- gap: var(--button-gap, var(--spacing-3, 0.375rem));
+.button[aria-busy="true"] {
+ .button__prefix-slot {
+ opacity: var(--button-prefix-slot-hidden-opacity, 0);
+ transform: var(--button-prefix-slot-hidden-transform,
+ translateY(calc(-1 * var(--button-internal-loading-travel))));
+ transition-delay: 0s;
+ }
- &:not([data-variant="link"]) {
- padding: var(--button-padding, var(--spacing-3, 0.375rem) var(--spacing-4, 0.5rem));
+ .button__loader-slot {
+ opacity: var(--button-loader-slot-visible-opacity, 1);
+ transform: var(--button-loader-slot-visible-transform, translateY(0));
+ transition-delay: var(--button-internal-loading-delay);
}
}
-.button[data-size='md'] {
- height: var(--button-height, 2rem);
- font-size: var(--button-font-size, var(--periscope-font-size-small, 11px));
- gap: var(--button-gap, var(--spacing-4, 0.5rem));
-
- &:not([data-variant="link"]) {
- padding: var(--button-padding, var(--spacing-5, 0.625rem) var(--spacing-8, 1rem));
+@media (prefers-reduced-motion: reduce) {
+ .button__prefix-wrapper,
+ .button:not([aria-busy="true"]) .button__prefix-wrapper[data-empty='true'],
+ .button__prefix-slot,
+ .button__loader-slot {
+ transition-duration: 0s;
+ transition-delay: 0s;
}
-}
-.button[data-size='icon'] {
- height: var(--button-height, 2rem);
- width: var(--button-width, 2rem);
- font-size: var(--button-font-size, var(--periscope-font-size-small, 11px));
- gap: var(--button-gap, var(--spacing-4, 0.5rem));
+ .button[data-variant='dashed']:hover:not([aria-disabled="true"]) .button__dash-border rect,
+ .button[data-variant='dashed'][aria-busy="true"] .button__dash-border rect,
+ .button[data-variant='ghost']:active:not([aria-disabled="true"])::before,
+ .button[data-variant='ghost'][aria-busy="true"]::before {
+ animation-play-state: paused;
+ }
- &:not([data-variant="link"]) {
- padding: var(--button-padding, var(--spacing-4, 0.5rem));
+ .button[data-variant='ghost']::before {
+ transition-duration: 0s;
}
}
-.button__loader {
- flex-shrink: var(--button-loader-flex-shrink, 0);
-}
+// Travels exactly one dash + gap, so the end state is pixel-identical to the
+// start and the loop never shows a seam. Counts down to 0 rather than into the
+// negatives, which SVG 1.1 called an error.
+@keyframes button-dash-march {
+ from {
+ stroke-dashoffset: calc(var(--button-internal-dash-length) + var(--button-internal-dash-gap));
+ }
-.button__prefix,
-.button__suffix {
- flex-shrink: var(--button-affix-flex-shrink, 0);
+ to {
+ stroke-dashoffset: 0;
+ }
}
-.animate-fast-spin {
- --button-internal-animation-name: spin;
- animation: var(--button-animate-fast-spin, var(--button-internal-animation-name) 0.7s linear infinite);
-}
+@keyframes button-ghost-shimmer {
+ from {
+ background-position: 0% 50%;
+ }
-@keyframes spin {
to {
- transform: var(--button-spin-transform, rotate(360deg));
+ background-position: 200% 50%;
}
}
diff --git a/packages/ui/src/button/button.test.tsx b/packages/ui/src/button/button.test.tsx
deleted file mode 100644
index 57a64fa5..00000000
--- a/packages/ui/src/button/button.test.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { createRef } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-import { Button, ButtonBackground, ButtonColor, ButtonSize, ButtonVariant } from './button.js';
-import { ButtonGroup } from '../button-group/button-group';
-
-describe('Button', () => {
- it('renders as button with default props and children', () => {
- render(Label );
- const button = screen.getByRole('button', { name: 'Label' });
- expect(button).toBeInTheDocument();
- expect(button.tagName).toBe('BUTTON');
- expect(button).toHaveAttribute('data-color', 'primary');
- });
-
- it('applies testId to data-testid', () => {
- render(Click );
- expect(screen.getByTestId('my-button')).toBeInTheDocument();
- });
-
- it('disables the button when disabled is true', () => {
- const onClick = vi.fn();
-
- render(
-
- Disabled
- ,
- );
- expect(screen.getByRole('button')).toBeDisabled();
-
- fireEvent.click(screen.getByRole('button'));
- expect(onClick).toHaveBeenCalledTimes(0);
- });
-
- it('disables the button and shows spinner when loading', () => {
- const onClick = vi.fn();
-
- render(
-
- Loading
- ,
- );
- const button = screen.getByRole('button');
- expect(button).toBeDisabled();
-
- fireEvent.click(button);
- expect(onClick).toHaveBeenCalledTimes(0);
-
- expect(screen.getByTestId('loader-circle')).toBeInTheDocument();
- });
-
- it('hides prefix and suffix when loading', () => {
- render(
- } suffix={ }>
- Load
- ,
- );
- expect(screen.queryByTestId('prefix')).not.toBeInTheDocument();
- expect(screen.queryByTestId('suffix')).not.toBeInTheDocument();
- });
-
- it('renders prefix and suffix when not loading', () => {
- render(
- } suffix={ }>
- With icons
- ,
- );
- expect(screen.getByTestId('prefix')).toBeInTheDocument();
- expect(screen.getByTestId('suffix')).toBeInTheDocument();
- });
-
- it('renders as child element when asChild is true', () => {
- render(
-
- Link
- ,
- );
- const link = screen.getByTestId('link-btn');
- expect(link.tagName).toBe('A');
- expect(link).toHaveAttribute('href', '#');
- expect(link).toHaveAttribute('data-color', 'primary');
- });
-
- it('calls onClick when clicked', () => {
- const onClick = vi.fn();
- render(Click me );
- fireEvent.click(screen.getByRole('button'));
- expect(onClick).toHaveBeenCalledTimes(1);
- expect(onClick).toHaveBeenCalledWith(expect.any(Object));
- });
-
- it('calls onDoubleClick when double-clicked', () => {
- const onDoubleClick = vi.fn();
- render(Double );
- fireEvent.doubleClick(screen.getByRole('button'));
- expect(onDoubleClick).toHaveBeenCalledTimes(1);
- });
-
- it('forwards ref to the button element', () => {
- const ref = createRef();
- render(Ref );
- expect(ref.current).toBeInstanceOf(HTMLButtonElement);
- expect(ref.current).toBe(screen.getByRole('button'));
- });
-
- it('applies data-color for color prop', () => {
- render(Delete );
- expect(screen.getByRole('button')).toHaveAttribute('data-color', 'destructive');
- });
-
- it('applies data-background for Action variant with background', () => {
- render(
-
- Action
- ,
- );
- expect(screen.getByRole('button')).toHaveAttribute('data-background', 'vanilla-100');
- });
-
- it('merges custom className', () => {
- render(Custom );
- const button = screen.getByRole('button');
- expect(button).toHaveClass('custom-class');
- });
-
- it('applies type="submit" when specified', () => {
- render(Submit );
- expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
- });
-
- it('forwards native event handlers (onMouseEnter, onFocus, onKeyDown)', () => {
- const onMouseEnter = vi.fn();
- const onFocus = vi.fn();
- const onKeyDown = vi.fn();
- render(
-
- Hover
- ,
- );
- const btn = screen.getByRole('button');
- fireEvent.mouseEnter(btn);
- fireEvent.focus(btn);
- fireEvent.keyDown(btn, { key: 'Enter' });
- expect(onMouseEnter).toHaveBeenCalledTimes(1);
- expect(onFocus).toHaveBeenCalledTimes(1);
- expect(onKeyDown).toHaveBeenCalledTimes(1);
- });
-
- it('forwards arbitrary aria-* and data-* attributes to the button element', () => {
- render(
-
- X
- ,
- );
- const btn = screen.getByRole('button');
- expect(btn).toHaveAttribute('aria-label', 'Toolbar action');
- expect(btn).toHaveAttribute('data-foo', 'bar');
- expect(btn).toHaveAttribute('tabindex', '-1');
- });
-});
-
-describe('ButtonGroup', () => {
- it('renders a group with role=group', () => {
- render(
-
- A
- B
- ,
- );
- const group = screen.getByTestId('g');
- expect(group).toHaveAttribute('role', 'group');
- });
-
- it('propagates size to child buttons that do not override it', () => {
- render(
-
- A
-
- B
-
- ,
- );
- expect(screen.getByTestId('a')).toHaveAttribute('data-size', 'sm');
- expect(screen.getByTestId('b')).toHaveAttribute('data-size', 'md');
- });
-
- it('propagates variant and color to child buttons', () => {
- render(
-
- A
-
- B
-
- ,
- );
- const a = screen.getByTestId('a');
- const b = screen.getByTestId('b');
- expect(a).toHaveAttribute('data-variant', 'outlined');
- expect(a).toHaveAttribute('data-color', 'secondary');
- expect(b).toHaveAttribute('data-color', 'destructive');
- });
-
- it('forwards ref to the group element', () => {
- const ref = createRef();
- render(
-
- A
- ,
- );
- expect(ref.current).toBeInstanceOf(HTMLDivElement);
- });
-});
diff --git a/packages/ui/src/button/button.tsx b/packages/ui/src/button/button.tsx
index 25bccb2d..3105ed29 100644
--- a/packages/ui/src/button/button.tsx
+++ b/packages/ui/src/button/button.tsx
@@ -1,280 +1,380 @@
-import { Slot } from '@radix-ui/react-slot';
-import { LoaderCircle } from '@signozhq/icons';
-import type React from 'react';
-import { cloneElement, createContext, forwardRef, useContext } from 'react';
+import { Button as BaseUiButton } from '@base-ui/react/button';
+import {
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ type MouseEventHandler,
+ type ReactElement,
+ type RefAttributes,
+ useId,
+ useMemo,
+} from 'react';
import { cn } from '../lib/utils.js';
+import { Spinner } from '../spinner/spinner.js';
+import { TooltipProviderIfMissing } from '../tooltip/subcomponents/tooltip-provider.js';
+import { TooltipContent } from '../tooltip/subcomponents/tooltip-content.js';
+import { TooltipRoot } from '../tooltip/subcomponents/tooltip-root.js';
+import { TooltipStack } from '../tooltip/subcomponents/tooltip-stack.js';
+import { TooltipTrigger } from '../tooltip/subcomponents/tooltip-trigger.js';
+import {
+ hasTooltipContent,
+ type TooltipContentStackEntry,
+} from '../tooltip/tooltip-content-stack-context.js';
+import { useTooltipHandle } from '../tooltip/tooltip-handle.js';
import styles from './button.module.scss';
-
-export const ButtonVariant = {
- Solid: 'solid',
- Outlined: 'outlined',
- Dashed: 'dashed',
- Ghost: 'ghost',
- Link: 'link',
- Action: 'action',
-} as const;
-
-export const ButtonSize = {
- SM: 'sm',
- MD: 'md',
- Icon: 'icon',
-} as const;
-
-export const ButtonBackground = {
- Ink500: 'ink-500',
- Ink400: 'ink-400',
- Vanilla100: 'vanilla-100',
- Vanilla200: 'vanilla-200',
-} as const;
-
-export const ButtonColor = {
- Primary: 'primary',
- Destructive: 'destructive',
- Warning: 'warning',
- Secondary: 'secondary',
- None: 'none',
-} as const;
-
-export type ButtonVariantValue = (typeof ButtonVariant)[keyof typeof ButtonVariant];
-export type ButtonSizeValue = (typeof ButtonSize)[keyof typeof ButtonSize];
-export type ButtonBackgroundValue = (typeof ButtonBackground)[keyof typeof ButtonBackground];
-export type ButtonColorValue = (typeof ButtonColor)[keyof typeof ButtonColor] | (string & {});
-
-/**
- * Context used by `ButtonGroup` to propagate `size`, `variant`, and `color` to
- * descendant `Button`s. Children may still override any of these locally.
- */
-export interface ButtonGroupContextValue {
- size?: ButtonSizeValue;
- variant?: ButtonVariantValue;
- color?: ButtonColorValue;
- inGroup: boolean;
-}
-
-export const ButtonGroupContext = createContext(null);
+import { ButtonTextOverflow, ButtonVariant } from './constants.js';
+import type { ButtonProps, SizeType, ValidateButtonProps, VariantType } from './types.js';
+import { useIsLabelTruncated } from '../lib/useIsLabelTruncated.js';
+import { toCssLength } from '../lib/css-length';
/**
* Helper function to generate button class names for use in other components
* This replaces the old CVA-based buttonVariants function
+ *
+ * @deprecated
*/
export function buttonVariants({
variant: _variant = 'outlined',
size: _size = 'md',
className,
}: {
- variant?: ButtonVariantValue;
- size?: ButtonSizeValue;
+ variant?: VariantType;
+ size?: SizeType;
className?: string;
} = {}) {
return cn(styles['button'], className);
}
-export type ButtonProps = {
- /**
- * Visual style of the button.
- * @default 'solid'
- */
- variant?: ButtonVariantValue;
- /**
- * Height + padding token. `'icon'` produces a square button suitable for a single icon child.
- * @default 'md'
- */
- size?: ButtonSizeValue;
- /**
- * When `true`, render as the immediate child element (via Radix `Slot`) instead of a native
- * ``. Useful for turning a `Button` into a link. `loading`, `prefix`, and `suffix`
- * are not supported in this mode.
- * @default false
- */
- asChild?: boolean;
- /**
- * Color scheme applied to the variant. Maps to the button's `data-color` attribute and the
- * matching CSS custom properties.
- * @default 'primary'
- */
- color?: ButtonColorValue;
- /**
- * Element rendered before the button label. Sized + class-injected automatically when no
- * `size` prop is set on the element.
- */
- prefix?: React.ReactElement;
- /**
- * Element rendered after the button label. Sized + class-injected automatically when no
- * `size` prop is set on the element.
- */
- suffix?: React.ReactElement;
- /**
- * When `true`, replaces `prefix` (and hides `suffix`) with a spinner and disables the button.
- * @default false
- */
- loading?: boolean;
- /**
- * Background context for `variant="action"`. Only meaningful when `variant` is `"action"`.
- */
- background?: ButtonBackgroundValue;
- /**
- * Forwarded to the rendered element as `data-testid`.
- */
- testId?: string;
-} & Omit, 'prefix' | 'color'>;
+function ButtonPrefix({ prefix }: { prefix?: ReactElement }): ReactElement {
+ const clonedElement = isValidElement<{ className?: string }>(prefix)
+ ? cloneElement(prefix, { className: cn(prefix.props.className, styles['button__prefix']) })
+ : null;
+
+ return (
+
+
+ {clonedElement}
+
+
+
+
+
+ );
+}
+
+function ButtonDashedBorder(): ReactElement {
+ return (
+
+
+
+ );
+}
+
+function ButtonSuffix({ suffix }: { suffix?: ReactElement | null }): ReactElement | null {
+ if (!isValidElement<{ className?: string }>(suffix)) {
+ return null;
+ }
+
+ const clonedElement = cloneElement(suffix, {
+ className: cn(suffix.props.className, styles['button__suffix']),
+ });
+
+ return (
+
+ {clonedElement}
+
+ );
+}
+
+const ButtonImpl = forwardRef(function Button(
+ {
+ className,
+ variant,
+ color,
+ size,
+ icon = undefined,
+ prefix,
+ suffix,
+ disabled,
+ disabledTooltip,
+ loading = false,
+ loadingTooltip,
+ children,
+ testId,
+ onDoubleClick,
+ width,
+ maxWidth,
+ textOverflow,
+ style,
+ ...props
+ },
+ ref,
+) {
+ textOverflow ??= ButtonTextOverflow.Ellipsis;
+
+ const hasOverflowTooltip = icon !== true && textOverflow === ButtonTextOverflow.Ellipsis;
+ const [isLabelOverflowing, labelRef] = useIsLabelTruncated(hasOverflowTooltip);
+
+ const hasDisabledTooltip = Boolean(disabled && !loading && hasTooltipContent(disabledTooltip));
+ const hasLoadingTooltip = Boolean(loading && hasTooltipContent(loadingTooltip));
+
+ const tooltipContent = useMemo(() => {
+ const items: TooltipContentStackEntry[] = [];
+
+ if (hasLoadingTooltip) {
+ items.push({ id: 'loading-tooltip', content: loadingTooltip });
+ }
+
+ if (hasDisabledTooltip) {
+ items.push({ id: 'disabled-tooltip', content: disabledTooltip });
+ }
+
+ if (isLabelOverflowing) {
+ items.push({ id: 'label', content: children });
+ }
+
+ return items.length === 0 ? null : ;
+ }, [
+ hasLoadingTooltip,
+ loadingTooltip,
+ hasDisabledTooltip,
+ disabledTooltip,
+ isLabelOverflowing,
+ children,
+ ]);
+
+ const hasTooltip = hasOverflowTooltip || disabledTooltip != null || loadingTooltip != null;
+ const tooltipHandle = useTooltipHandle();
+ const tooltipContentId = useId();
+
+ // the only click event is not supported by default to disable by base-ui
+ // https://github.com/mui/base-ui/blob/e8526f762853350691ee3a9bd7ead9d3d6e95bd3/packages/react/src/internals/use-button/useButton.ts#L104
+ const onDoubleClickImpl: MouseEventHandler | undefined = onDoubleClick
+ ? (event) => {
+ if (disabled || loading) {
+ return;
+ }
+
+ onDoubleClick(event);
+ }
+ : undefined;
+
+ const buttonStyle = {
+ ...style,
+ ...(width != null && { '--button-internal-width': toCssLength(width) }),
+ ...(maxWidth != null && { '--button-internal-max-width': toCssLength(maxWidth) }),
+ };
+
+ const prefixEl = icon === true ? (children as ReactElement) : prefix;
+ const childrenEl =
+ icon === true ? null : (
+
+ {children}
+
+ );
+ const suffixEl = icon === true ? null : suffix;
+
+ const buttonEl = (
+
+ {variant === ButtonVariant.Dashed && }
+
+ {childrenEl}
+
+
+ );
+
+ if (!hasTooltip) {
+ return buttonEl;
+ }
+
+ return (
+
+
+ {buttonEl}
+
+ {tooltipContent != null && (
+
+
+ {tooltipContent}
+
+
+ )}
+
+ );
+});
/**
- * Versatile button that maps to a native `` (or any child via `asChild`).
- * Supports color/variant/size tokens, prefix/suffix slots, loading state, and forwards
- * every native `HTMLButtonElement` attribute + event handler.
+ * Renders a native `` (Base UI `Button`).
*
- * When nested inside `ButtonGroup`, `size`, `variant`, and `color` defaults are
- * inherited from the group; per-button props still win.
+ * Every native `HTMLButtonElement` attribute in {@link ButtonProps}, all `aria-*` and any
+ * `data-*` are forwarded.
*
- * @example
- * ```tsx
- * // Default — solid primary
- * Save
- * ```
+ * Visual values are `--button-*` custom properties, defaults in the `css-tokens` region of
+ * [./index.ts](./index.ts).
*
- * @example
- * ```tsx
- * // Outlined secondary with a leading icon
- * }>
- * Create alert
- *
- * ```
+ * ### Icon mode
+ *
+ * `icon` puts `children` in the prefix slot. There is no label and no `suffix`.
+ *
+ * Nothing is left to read, so give it an `aria-label`.
+ *
+ * ### Disabled and loading
+ *
+ * Both set `aria-disabled`, never the native `disabled` attribute (Base UI
+ * `focusableWhenDisabled`).
+ *
+ * So the button stays tabbable and hoverable, and its tooltip stays reachable.
+ *
+ * They swallow `onClick`, `onDoubleClick` and Enter/Space. Hover and focus events still fire.
+ *
+ * In tests `toBeDisabled()` fails. Assert `aria-disabled`.
+ *
+ * ### Loading
+ *
+ * Adds `aria-busy` and cross-fades the spinner over the prefix slot.
+ *
+ * Label and `suffix` stay visible.
+ *
+ * It suppresses `disabledTooltip`, even while `disabled` is true. `loadingTooltip` takes that
+ * place, and only while loading.
+ *
+ * ### Truncation
+ *
+ * `textOverflow="ellipsis"` (the default) measures the label and re-measures on resize.
+ *
+ * While it does not fit: `data-truncated`, plus a tooltip with the full text. The visible text
+ * is only clipped, so the accessible name is already the full label.
+ *
+ * `none` clips with no tooltip. Icon buttons have no label to measure.
+ *
+ * ### Its tooltips
+ *
+ * Three of them, and only one reason can apply at a time: `loadingTooltip` while loading,
+ * `disabledTooltip` while disabled and idle, plus the truncated label.
+ *
+ * When both apply, one popup holds both: reason first, then the label, split by a
+ * `tooltip-divider`.
+ *
+ * Inside a wrapping `` the button adds its entries to that popup instead of opening a
+ * second one.
+ *
+ * The trigger is always mounted, so the element never remounts when a tooltip appears. The
+ * tooltip root and popup mount only while there is something to show.
+ *
+ * ### Width
+ *
+ * `width` and `maxWidth` are written as inline `--button-internal-width` and
+ * `--button-internal-max-width`.
+ *
+ * So they compose with the tokens instead of overwriting `style.width`. Numbers are written as
+ * `px`, and any `style` you pass is kept.
+ *
+ * ### Asserting on it
+ *
+ * `testId` is `data-testid` and survives the tooltip trigger cloning the button. Otherwise use
+ * the data attributes, never the hashed class names.
+ *
+ * | root attribute | value |
+ * |---|---|
+ * | `data-slot` | `"button"` |
+ * | `data-variant`, `data-color`, `data-size` | mirrors the prop |
+ * | `data-icon` | `"true"` in icon mode, else `"false"` |
+ * | `data-text-overflow` | `ellipsis` (default) or `none` |
+ * | `data-truncated` | present only while the label does not fit |
+ *
+ * | `data-slot` | rendered |
+ * |---|---|
+ * | `button-prefix-wrapper` | always, `data-empty="true"` without a prefix or icon |
+ * | `button-prefix-slot` | always, holds the cloned `prefix` (or the icon `children`) |
+ * | `button-prefix-loading` | always, holds the `Spinner`, `aria-hidden`. The ring is paused while idle |
+ * | `button-label` | except in icon mode, this is the measured element |
+ * | `button-suffix-slot` | only when `suffix` is a valid element |
+ * | `button-dashed-border` | only for `variant="dashed"` |
*
* @example
* ```tsx
- * // Destructive ghost with a trailing icon
- * }>
+ * } onClick={remove}>
* Delete
*
* ```
*
* @example
* ```tsx
- * // Icon-only square button
- *
+ * // Icon mode: children are the icon, so give it a name
+ *
*
*
* ```
*
* @example
* ```tsx
- * // Loading state — disables the button and replaces the prefix with a spinner
- * }>Saving…
- * ```
- *
- * @example
- * ```tsx
- * // `asChild` — render as a link with button styling
- *
- * Read the docs
+ * // Truncates at 10rem and explains itself on hover, reason above the full label
+ *
+ * Edit every alert rule in this workspace
*
* ```
*
* @example
* ```tsx
- * // Native HTML submit button inside a form
- *
+ * // Says what it is waiting on while the request is in flight
+ *
+ * Save
+ *
* ```
*/
-const Button = forwardRef(
- (
- {
- className,
- variant,
- color,
- size,
- prefix,
- suffix,
- asChild = false,
- disabled,
- loading = false,
- background,
- children,
- testId,
- ...props
- },
- ref,
- ) => {
- const Comp = asChild ? Slot : 'button';
- const group = useContext(ButtonGroupContext);
-
- variant ??= group?.variant ?? ButtonVariant.Solid;
- size ??= group?.size ?? ButtonSize.MD;
- color ??= group?.color ?? ButtonColor.Primary;
-
- const iconSizes: Record = {
- [ButtonSize.SM]: 12,
- [ButtonSize.MD]: 14,
- [ButtonSize.Icon]: 16,
- };
-
- if (asChild) {
- if (loading || prefix || suffix) {
- console.warn('Loading, prefix, and suffix are not supported when using asChild');
- }
-
- return (
-
- {children}
-
- );
- }
-
- return (
-
- {loading ? (
-
- ) : (
- (prefix &&
- cloneElement(prefix, {
- ...(!prefix.props.size && {
- size: iconSizes[size],
- className: styles['button__prefix'],
- }),
- })) ||
- null
- )}
- {children}
- {(!loading &&
- suffix &&
- cloneElement(suffix, {
- ...(!suffix.props.size && {
- size: iconSizes[size],
- className: styles['button__suffix'],
- }),
- })) ||
- null}
-
- );
- },
-);
-Button.displayName = 'Button';
-
-export { Button };
+export const Button = ButtonImpl as (
+ props: T &
+ ValidateButtonProps &
+ // `T` is inferred from the call site, so `T extends ButtonProps` alone never runs excess
+ // property checks. Every key outside the props (a typo, a native attribute the button does
+ // not forward on purpose) is pinned to `never` instead.
+ Record>, never> &
+ RefAttributes,
+) => ReactElement;
diff --git a/packages/ui/src/button/constants.tsx b/packages/ui/src/button/constants.tsx
new file mode 100644
index 00000000..e061a089
--- /dev/null
+++ b/packages/ui/src/button/constants.tsx
@@ -0,0 +1,28 @@
+export const ButtonVariant = {
+ Solid: 'solid',
+ Outlined: 'outlined',
+ Dashed: 'dashed',
+ Ghost: 'ghost',
+ Link: 'link',
+} as const;
+
+export const ButtonSize = {
+ SM: 'sm',
+ MD: 'md',
+} as const;
+
+export const ButtonColor = {
+ Primary: 'primary',
+ Secondary: 'secondary',
+ Danger: 'danger',
+ Warning: 'warning',
+ Success: 'success',
+ Info: 'info',
+ Archive: 'archive',
+ HighlightDanger: 'highlight-danger',
+} as const;
+
+export const ButtonTextOverflow = {
+ None: 'none',
+ Ellipsis: 'ellipsis',
+} as const;
diff --git a/packages/ui/src/button/index.ts b/packages/ui/src/button/index.ts
index 28ba5fa4..a4220370 100644
--- a/packages/ui/src/button/index.ts
+++ b/packages/ui/src/button/index.ts
@@ -5,130 +5,183 @@
*
* | Token | Default |
* |-------|---------|
- * | `--button-action-border` | `var(--action-border)` |
- * | `--button-action-hover-border` | `var(--action-border-hover)` |
- * | `--button-action-hover-text` | `var(--action-foreground-hover)` |
- * | `--button-action-ink-400-background` | `var(--action-background)` |
- * | `--button-action-ink-400-border` | `var(--action-border)` |
- * | `--button-action-ink-400-hover-background` | `var(--action-background-hover)` |
- * | `--button-action-ink-400-hover-border` | `var(--action-border-hover)` |
- * | `--button-action-ink-400-hover-text` | `var(--action-foreground-hover)` |
- * | `--button-action-ink-400-text` | `var(--action-foreground)` |
- * | `--button-action-ink-500-background` | `var(--action-background)` |
- * | `--button-action-ink-500-border` | `var(--action-border)` |
- * | `--button-action-ink-500-hover-background` | `var(--action-background-hover)` |
- * | `--button-action-ink-500-hover-border` | `var(--action-border-hover)` |
- * | `--button-action-ink-500-hover-text` | `var(--action-foreground-hover)` |
- * | `--button-action-ink-500-text` | `var(--action-foreground)` |
- * | `--button-action-text` | `var(--action-foreground)` |
- * | `--button-action-vanilla-100-background` | `var(--action-background)` |
- * | `--button-action-vanilla-100-border` | `var(--action-border)` |
- * | `--button-action-vanilla-100-hover-background` | `var(--action-background-hover)` |
- * | `--button-action-vanilla-100-hover-border` | `var(--action-border-hover)` |
- * | `--button-action-vanilla-100-hover-text` | `var(--action-foreground-hover)` |
- * | `--button-action-vanilla-100-text` | `var(--action-foreground)` |
- * | `--button-action-vanilla-200-background` | `var(--action-background)` |
- * | `--button-action-vanilla-200-border` | `var(--action-border)` |
- * | `--button-action-vanilla-200-hover-background` | `var(--action-background-hover)` |
- * | `--button-action-vanilla-200-hover-border` | `var(--action-border-hover)` |
- * | `--button-action-vanilla-200-hover-text` | `var(--action-foreground-hover)` |
- * | `--button-action-vanilla-200-text` | `var(--action-foreground)` |
* | `--button-affix-flex-shrink` | `0` |
* | `--button-align-items` | `center` |
- * | `--button-animate-fast-spin` | `var(--button-internal-animation-name) 0.7s line...` |
+ * | `--button-archive-background` | `var(--bg-sienna-500)` |
+ * | `--button-archive-hover-background` | `var(--bg-sienna-400)` |
+ * | `--button-archive-link-foreground` | `var(--bg-sienna-400)` |
+ * | `--button-archive-link-hover-foreground` | `var(--bg-sienna-500)` |
+ * | `--button-archive-solid-foreground` | `var(--text-ink-500)` |
* | `--button-base-border-color` | `transparent` |
* | `--button-base-border-width` | `0px` |
- * | `--button-border-radius` | `calc(var(--radius) - 2px)` |
+ * | `--button-border-radius` | `var(--radius-1)` |
* | `--button-cursor` | `pointer` |
- * | `--button-destructive-background` | `var(--danger-background)` |
- * | `--button-destructive-border-color` | `var(--danger-background)` |
- * | `--button-destructive-ghost-hover-foreground` | `var(--danger-foreground-hover)` |
- * | `--button-destructive-hover-background` | `var(--danger-background-hover)` |
- * | `--button-destructive-link-hover-foreground` | `var(--danger-background-hover)` |
- * | `--button-destructive-outlined-foreground` | `var(--danger-background)` |
- * | `--button-destructive-solid-foreground` | `var(--danger-foreground)` |
+ * | `--button-danger-background` | `var(--danger-background)` |
+ * | `--button-danger-hover-background` | `var(--danger-background-hover)` |
+ * | `--button-danger-link-foreground` | `var(--danger-link)` |
+ * | `--button-danger-link-hover-foreground` | `var(--danger-link-hover)` |
+ * | `--button-danger-solid-foreground` | `var(--danger-foreground)` |
+ * | `--button-dash-border-width` | `1px` |
+ * | `--button-dash-duration` | `200ms` |
+ * | `--button-dash-gap` | `3px` |
+ * | `--button-dash-hover-stroke` | `var(--secondary-background-hover)` |
+ * | `--button-dash-length` | `3px` |
+ * | `--button-dash-stroke` | `var(--secondary-border)` |
+ * | `--button-dash-stroke-width` | `1px` |
+ * | `--button-disabled-cursor` | `not-allowed` |
* | `--button-disabled-opacity` | `0.6` |
- * | `--button-disabled-pointer-events` | `none` |
* | `--button-display` | `inline-flex` |
- * | `--button-focus-visible-outline` | `var(--ring) solid 2px` |
- * | `--button-focus-visible-outline-offset` | `2px` |
- * | `--button-font-size` | `var(--periscope-font-size-small, 11px)` |
+ * | `--button-flex-direction` | `row` |
+ * | `--button-focus-visible-outline` | `var(--ring) solid 1px` |
+ * | `--button-focus-visible-outline-offset` | `1px` |
+ * | `--button-font-size` | `var(--periscope-font-size-small)` |
* | `--button-font-variant-numeric` | `slashed-zero` |
- * | `--button-gap` | `var(--spacing-3, 0.375rem)` |
- * | `--button-height` | `1.5rem` |
+ * | `--button-font-weight` | `var(--font-weight-medium)` |
+ * | `--button-gap` | `var(--button-internal-gap)` |
+ * | `--button-ghost-content-z-index` | `1` |
+ * | `--button-ghost-glow-active-opacity` | `1` |
+ * | `--button-ghost-glow-background` | `linear-gradient(90deg, color-mix(in srgb...` |
+ * | `--button-ghost-glow-background-size` | `200% 100%` |
+ * | `--button-ghost-glow-blur` | `6px` |
+ * | `--button-ghost-glow-duration` | `2.5s` |
+ * | `--button-ghost-glow-fade` | `600ms ease` |
+ * | `--button-ghost-glow-inset` | `-4px` |
+ * | `--button-ghost-glow-opacity` | `22%` |
+ * | `--button-ghost-glow-z-index` | `0` |
+ * | `--button-height` | `24px` |
+ * | `--button-highlight-danger-background` | `var(--bg-sakura-500)` |
+ * | `--button-highlight-danger-hover-background` | `var(--bg-sakura-400)` |
+ * | `--button-highlight-danger-link-foreground` | `var(--bg-sakura-400)` |
+ * | `--button-highlight-danger-link-hover-foreground` | `var(--bg-sakura-500)` |
+ * | `--button-highlight-danger-solid-foreground` | `var(--text-ink-500)` |
+ * | `--button-hover-state-background-color` | `var(--button-internal-hover-background)` |
+ * | `--button-icon-size` | `12px` |
+ * | `--button-info-background` | `var(--bg-aqua-500)` |
+ * | `--button-info-hover-background` | `var(--bg-aqua-400)` |
+ * | `--button-info-link-foreground` | `var(--bg-aqua-400)` |
+ * | `--button-info-link-hover-foreground` | `var(--bg-aqua-500)` |
+ * | `--button-info-solid-foreground` | `var(--text-ink-500)` |
* | `--button-justify-content` | `center` |
- * | `--button-line-height` | `100%` |
- * | `--button-loader-flex-shrink` | `0` |
+ * | `--button-label-display` | `block` |
+ * | `--button-label-min-width` | `0` |
+ * | `--button-label-overflow` | `hidden` |
+ * | `--button-label-text-overflow` | `ellipsis` |
+ * | `--button-label-tooltip-max-width` | `20rem` |
+ * | `--button-label-white-space` | `nowrap` |
+ * | `--button-loader-slot-hidden-opacity` | `0` |
+ * | `--button-loader-slot-hidden-transform` | `translateY(var(--button-internal-loading-travel))` |
+ * | `--button-loader-slot-idle-animation-play-state` | `paused` |
+ * | `--button-loader-slot-visible-opacity` | `1` |
+ * | `--button-loader-slot-visible-transform` | `translateY(0)` |
* | `--button-loading-cursor` | `wait` |
- * | `--button-none-background` | `var(--ghost-background)` |
- * | `--button-none-border-color` | `var(--ghost-border)` |
- * | `--button-none-ghost-hover-foreground` | `var(--ghost-foreground-hover)` |
- * | `--button-none-hover-background` | `var(--ghost-background-hover)` |
- * | `--button-none-link-hover-foreground` | `var(--ghost-foreground-hover)` |
- * | `--button-none-outlined-foreground` | `var(--ghost-foreground)` |
- * | `--button-none-solid-foreground` | `var(--ghost-foreground)` |
- * | `--button-padding` | `var(--spacing-3, 0.375rem) var(--spacing-4, 0.5...` |
+ * | `--button-loading-delay` | `var(--button-internal-loading-duration)` |
+ * | `--button-loading-duration` | `120ms` |
+ * | `--button-loading-easing` | `cubic-bezier(0.65, 0, 0.35, 1)` |
+ * | `--button-loading-opacity` | `1` |
+ * | `--button-loading-travel` | `6px` |
+ * | `--button-max-width` | `var(--button-internal-max-width, 100%)` |
+ * | `--button-padding` | `var(--spacing-2) var(--spacing-4)` |
+ * | `--button-prefix-slot-hidden-opacity` | `0` |
+ * | `--button-prefix-slot-hidden-transform` | `translateY(calc(-1 * var(--button-internal-load...` |
+ * | `--button-prefix-slot-visible-opacity` | `1` |
+ * | `--button-prefix-slot-visible-transform` | `translateY(0)` |
+ * | `--button-prefix-wrapper-align-items` | `center` |
+ * | `--button-prefix-wrapper-block-size` | `var(--button-internal-icon-size)` |
+ * | `--button-prefix-wrapper-collapsed-grid-template-columns` | `0fr` |
+ * | `--button-prefix-wrapper-collapsed-margin-inline-end` | `calc(-1 * var(--button-internal-gap))` |
+ * | `--button-prefix-wrapper-display` | `grid` |
+ * | `--button-prefix-wrapper-flex-shrink` | `0` |
+ * | `--button-prefix-wrapper-grid-template-columns` | `1fr` |
+ * | `--button-prefix-wrapper-justify-items` | `center` |
+ * | `--button-prefix-wrapper-overflow` | `hidden` |
+ * | `--button-prefix-wrapper-transition` | `grid-template-columns var(--button-internal-loa...` |
* | `--button-primary-background` | `var(--primary-background)` |
- * | `--button-primary-border-color` | `var(--primary-background)` |
- * | `--button-primary-ghost-hover-foreground` | `var(--primary-foreground-hover)` |
* | `--button-primary-hover-background` | `var(--primary-background-hover)` |
- * | `--button-primary-link-hover-foreground` | `var(--primary-background-hover)` |
- * | `--button-primary-outlined-foreground` | `var(--primary)` |
+ * | `--button-primary-link-foreground` | `var(--primary-link)` |
+ * | `--button-primary-link-hover-foreground` | `var(--primary-hover)` |
* | `--button-primary-solid-foreground` | `var(--primary-foreground)` |
* | `--button-secondary-background` | `var(--secondary-background)` |
- * | `--button-secondary-border-color` | `var(--secondary-border)` |
- * | `--button-secondary-ghost-hover-foreground` | `var(--secondary-foreground-hover)` |
* | `--button-secondary-hover-background` | `var(--secondary-background-hover)` |
- * | `--button-secondary-link-hover-foreground` | `var(--secondary-foreground-hover)` |
- * | `--button-secondary-outlined-foreground` | `var(--secondary-foreground)` |
+ * | `--button-secondary-link-foreground` | `var(--secondary-link)` |
+ * | `--button-secondary-link-hover-foreground` | `var(--secondary-link-hover)` |
* | `--button-secondary-solid-foreground` | `var(--secondary-foreground)` |
- * | `--button-size-sm-line-height` | `1.5rem` |
- * | `--button-spin-transform` | `rotate(360deg)` |
+ * | `--button-size-line-height` | `14px` |
+ * | `--button-slot-align-items` | `center` |
+ * | `--button-slot-display` | `flex` |
+ * | `--button-slot-justify-content` | `center` |
+ * | `--button-slot-min-width` | `0` |
+ * | `--button-slot-transition` | `opacity var(--button-internal-loading-duration)...` |
+ * | `--button-success-background` | `var(--success-background)` |
+ * | `--button-success-hover-background` | `var(--success-background-hover)` |
+ * | `--button-success-link-foreground` | `var(--success-link)` |
+ * | `--button-success-link-hover-foreground` | `var(--success-link-hover)` |
+ * | `--button-success-solid-foreground` | `var(--success-foreground)` |
+ * | `--button-text-spacing` | `-0.005em` |
* | `--button-transition` | `background-color 150ms ease, color 150ms eas...` |
- * | `--button-variant-action-background-color` | `var(--button-internal-action-background)` |
- * | `--button-variant-action-border` | `1px solid var(--button-internal-action-border)` |
- * | `--button-variant-action-color` | `var(--button-internal-action-text)` |
- * | `--button-variant-action-hover-background-color` | `var(--button-internal-action-hover-background)` |
- * | `--button-variant-action-hover-border-color` | `var(--button-internal-action-hover-border)` |
- * | `--button-variant-action-hover-color` | `var(--button-internal-action-hover-text)` |
* | `--button-variant-dashed-background-color` | `transparent` |
- * | `--button-variant-dashed-border` | `1px dashed var(--button-internal-border-color)` |
- * | `--button-variant-dashed-color` | `var(--button-internal-outlined-foreground)` |
- * | `--button-variant-dashed-hover-background-color` | `var(--button-internal-border-color)` |
- * | `--button-variant-dashed-hover-color` | `var(--button-internal-solid-foreground)` |
+ * | `--button-variant-dashed-border` | `1px solid transparent` |
+ * | `--button-variant-dashed-color` | `var(--secondary-foreground)` |
+ * | `--button-variant-dashed-hover-background-color` | `transparent` |
+ * | `--button-variant-dashed-hover-color` | `var(--secondary-foreground-hover)` |
+ * | `--button-variant-dashed-position` | `relative` |
* | `--button-variant-ghost-background-color` | `transparent` |
- * | `--button-variant-ghost-color` | `var(--button-internal-outlined-foreground)` |
- * | `--button-variant-ghost-hover-background-color` | `var(--button-internal-hover-background)` |
- * | `--button-variant-ghost-hover-color` | `var(--button-internal-solid-foreground)` |
+ * | `--button-variant-ghost-color` | `var(--secondary-foreground)` |
+ * | `--button-variant-ghost-hover-background-color` | `var(--secondary-background-hover)` |
+ * | `--button-variant-ghost-hover-color` | `var(--secondary-foreground-hover)` |
+ * | `--button-variant-ghost-overflow` | `hidden` |
+ * | `--button-variant-ghost-position` | `relative` |
* | `--button-variant-link-background-color` | `transparent` |
- * | `--button-variant-link-color` | `var(--button-internal-outlined-foreground)` |
- * | `--button-variant-link-font-weight` | `500` |
+ * | `--button-variant-link-color` | `var(--button-internal-link-foreground)` |
+ * | `--button-variant-link-font-weight` | `var(--font-weight-medium)` |
* | `--button-variant-link-hover-background-color` | `transparent` |
* | `--button-variant-link-hover-color` | `var(--button-internal-link-hover-foreground)` |
- * | `--button-variant-outlined-background-color` | `transparent` |
- * | `--button-variant-outlined-border` | `1px solid var(--button-internal-border-color)` |
- * | `--button-variant-outlined-color` | `var(--button-internal-outlined-foreground)` |
- * | `--button-variant-outlined-hover-background-color` | `var(--button-internal-border-color)` |
- * | `--button-variant-outlined-hover-color` | `var(--button-internal-solid-foreground)` |
+ * | `--button-variant-outlined-background-color` | `var(--secondary-background)` |
+ * | `--button-variant-outlined-border` | `1px solid var(--secondary-border)` |
+ * | `--button-variant-outlined-color` | `var(--secondary-foreground)` |
+ * | `--button-variant-outlined-disabled-content-position` | `relative` |
+ * | `--button-variant-outlined-disabled-content-z-index` | `2` |
+ * | `--button-variant-outlined-disabled-overflow` | `hidden` |
+ * | `--button-variant-outlined-disabled-position` | `relative` |
+ * | `--button-variant-outlined-disabled-stripe-after-mask-image` | `linear-gradient(to right, transparent 5%, black...` |
+ * | `--button-variant-outlined-disabled-stripe-after-right` | `0` |
+ * | `--button-variant-outlined-disabled-stripe-background-image` | `repeating-linear-gradient(-45deg, trans...` |
+ * | `--button-variant-outlined-disabled-stripe-before-left` | `0` |
+ * | `--button-variant-outlined-disabled-stripe-before-mask-image` | `linear-gradient(to left, transparent 5%, black ...` |
+ * | `--button-variant-outlined-disabled-stripe-bottom` | `0` |
+ * | `--button-variant-outlined-disabled-stripe-color` | `var(--secondary-border)` |
+ * | `--button-variant-outlined-disabled-stripe-content` | `""` |
+ * | `--button-variant-outlined-disabled-stripe-pointer-events` | `none` |
+ * | `--button-variant-outlined-disabled-stripe-position` | `absolute` |
+ * | `--button-variant-outlined-disabled-stripe-top` | `0` |
+ * | `--button-variant-outlined-disabled-stripe-width` | `16px` |
+ * | `--button-variant-outlined-disabled-stripe-z-index` | `1` |
+ * | `--button-variant-outlined-hover-background-color` | `var(--secondary-background-hover)` |
+ * | `--button-variant-outlined-hover-color` | `var(--secondary-foreground-hover)` |
* | `--button-warning-background` | `var(--warning-background)` |
- * | `--button-warning-border-color` | `var(--warning-background)` |
- * | `--button-warning-ghost-hover-foreground` | `var(--warning-foreground-hover)` |
* | `--button-warning-hover-background` | `var(--warning-background-hover)` |
- * | `--button-warning-link-hover-foreground` | `var(--warning-background-hover)` |
- * | `--button-warning-outlined-foreground` | `var(--warning-background)` |
+ * | `--button-warning-link-foreground` | `var(--warning-link)` |
+ * | `--button-warning-link-hover-foreground` | `var(--warning-link-hover)` |
* | `--button-warning-solid-foreground` | `var(--warning-foreground)` |
* | `--button-white-space` | `nowrap` |
- * | `--button-width` | `2rem` |
+ * | `--button-width` | `var(--button-internal-width, auto)` |
*/
// #endregion css-tokens
-export type * from './button.js';
-export {
- Button,
- ButtonBackground,
- ButtonColor,
- ButtonGroupContext,
- ButtonSize,
- ButtonVariant,
- buttonVariants,
-} from './button.js';
+export { Button, buttonVariants } from './button.js';
+export { ButtonTextOverflow, ButtonColor, ButtonSize, ButtonVariant } from './constants.js';
+export type {
+ ButtonBaseProps,
+ ButtonProps,
+ ColoredVariantProps,
+ ColorType,
+ DisableType,
+ IconButtonProps,
+ IconPrefixSuffixType,
+ SecondaryOnlyVariantProps,
+ TextButtonProps,
+ TextOverflowType,
+ SizeType,
+ ValidateButtonProps,
+ VariantColorType,
+ VariantType,
+} from './types.js';
diff --git a/packages/ui/src/button/types.tsx b/packages/ui/src/button/types.tsx
new file mode 100644
index 00000000..2df01834
--- /dev/null
+++ b/packages/ui/src/button/types.tsx
@@ -0,0 +1,283 @@
+import type { ButtonColor, ButtonSize, ButtonTextOverflow, ButtonVariant } from './constants.js';
+import type {
+ AriaAttributes,
+ ButtonHTMLAttributes,
+ CSSProperties,
+ ReactElement,
+ ReactNode,
+} from 'react';
+
+export type SizeType = (typeof ButtonSize)[keyof typeof ButtonSize];
+export type VariantType = (typeof ButtonVariant)[keyof typeof ButtonVariant];
+export type ColorType = (typeof ButtonColor)[keyof typeof ButtonColor];
+export type TextOverflowType = (typeof ButtonTextOverflow)[keyof typeof ButtonTextOverflow];
+
+/**
+ * The variants that carry a colour of their own.
+ */
+export interface ColoredVariantProps {
+ /**
+ * The variants of solid/link are the only ones that accept custom colors.
+ */
+ variant: 'solid' | 'link';
+ /**
+ * The possible colors for this variant.
+ */
+ color: ColorType;
+}
+
+/**
+ * The variants that only exist in the secondary treatment.
+ */
+export interface SecondaryOnlyVariantProps {
+ /**
+ * The variant of outlined/ghost/dashed only support one type of color.
+ */
+ variant: 'outlined' | 'ghost' | 'dashed';
+ /**
+ * The possible colors for this variant.
+ */
+ color: 'secondary';
+}
+
+export type VariantColorType = ColoredVariantProps | SecondaryOnlyVariantProps;
+
+/**
+ * `disabled` and `disabledTooltip` travel together, but the pairing is enforced by
+ * {@link ValidateButtonProps} rather than by a union of the two shapes.
+ *
+ * A union here would be multiplied by the {@link VariantColorType} and
+ * {@link IconPrefixSuffixType} unions into an eight member cross product. TypeScript can only
+ * narrow such a union through a discriminant the call site actually writes, and `disabled` is
+ * absent from most call sites, so it would give up and blame whichever member came first. That is
+ * how a missing `aria-label` used to be reported as missing `disabled` and `disabledTooltip`.
+ */
+export type DisableType = {
+ /**
+ * When true, this will disable the button (will not trigger onClick and onDoubleClick), but will not prevent other events to be triggered.
+ *
+ * @note Requires `disabledTooltip`.
+ *
+ * @note While the tooltip is rendered, the button carries `aria-disabled` instead of the
+ * native `disabled` attribute, so it keeps receiving hover/focus and stays tabbable. A
+ * native `disabled` button gets no events at all, which would make the tooltip unreachable.
+ */
+ disabled?: boolean;
+ /**
+ * When disable is defined, you must define the possible reason of the button is disabled, only render the tooltip when disabled is true.
+ *
+ * @note Only allowed alongside `disabled`.
+ *
+ * @note This does not render when loading={true} and disabled={true} is defined at same time.
+ *
+ * @note Stacks above the `ellipsis` overflow tooltip: a disabled button with a truncated
+ * label shows the reason first, then the full label.
+ *
+ * @note Pass `undefined` explicitly when there is no reason to give, for example in a
+ * wrapper that only forwards `disabled`. Leaving the prop out is the type error.
+ */
+ disabledTooltip?: ReactNode;
+};
+
+/**
+ * The rules below are the ones a union cannot express without blowing up {@link ButtonProps} into a
+ * cross product. Each is an object whose single required key is the sentence the compiler should
+ * print, so a violation reads as `Property '' is missing ... but required in type
+ * ''` instead of pointing at an unrelated prop.
+ */
+interface ADisabledButtonMustSayWhy {
+ '`disabled` needs `disabledTooltip`, a disabled control has to tell the user why it cannot be used': never;
+}
+
+interface TheTestIdPropIsCalledTestId {
+ '`data-testid` is written as the `testId` prop, which survives the tooltip trigger cloning the button': never;
+}
+
+interface ADisabledReasonNeedsADisabledButton {
+ '`disabledTooltip` only renders while `disabled` is set, add `disabled` or drop the tooltip': never;
+}
+
+/**
+ * Extra constraints layered on top of {@link ButtonProps} at the call site.
+ *
+ * Resolves to `unknown` (which disappears from an intersection) while the props are valid, and to a
+ * rule object when they are not.
+ *
+ * The pairing rules look at which props the call site writes, not at their values: `disabled`
+ * typed `boolean | undefined` is still `disabled`, and `disabledTooltip={undefined}` is the
+ * explicit opt-out for a call site that has no reason to give.
+ *
+ * @note A wrapper that forwards the whole `ButtonProps` union is not checked: `T` is then the union
+ * itself, every branch of the conditional is taken at once, and `unknown` from the passing branches
+ * absorbs the rest. Such a wrapper is checked at its own call sites instead.
+ */
+export type ValidateButtonProps = (T extends { disabled: boolean | undefined }
+ ? T extends { disabledTooltip: ReactNode }
+ ? unknown
+ : ADisabledButtonMustSayWhy
+ : unknown) &
+ (T extends { disabledTooltip: ReactNode }
+ ? T extends { disabled: boolean | undefined }
+ ? unknown
+ : ADisabledReasonNeedsADisabledButton
+ : unknown) &
+ (T extends { 'data-testid': unknown } ? TheTestIdPropIsCalledTestId : unknown);
+
+/**
+ * A button that renders a text label, optionally flanked by a `prefix` and a `suffix`.
+ */
+export interface TextButtonProps {
+ /**
+ * When icon is not defined, you can use prefix/suffix/children.
+ */
+ icon?: never;
+ /**
+ * Element rendered before the button label. The sizing class is merged into the element's
+ * own `className`.
+ */
+ prefix?: ReactElement;
+ /**
+ * Element rendered after the button label. The sizing class is merged into the element's
+ * own `className`.
+ */
+ suffix?: ReactElement;
+}
+
+/**
+ * A button whose children are the icon itself.
+ *
+ * Named rather than inlined so that a missing `aria-label` is reported against
+ * `IconButtonProps`, which says what the call site was taken to be, instead of against the
+ * structural dump of the branch.
+ */
+export interface IconButtonProps {
+ /**
+ * Define this button should be rendered optimized for an icon
+ *
+ * @note In icon mode the children are the icon, so `prefix` and `suffix` are not allowed.
+ */
+ icon: true;
+ /**
+ * The prefix is not allowed when using icon
+ */
+ prefix?: never;
+ /**
+ * The suffix is not allowed when using icon
+ */
+ suffix?: never;
+ /**
+ * The text/children of this button.
+ *
+ * This field is mandatory, it's not allowed to have a empty button.
+ */
+ children: ReactElement;
+ /**
+ * The accessible name of the button. Required: the children are an icon, so there is no text
+ * for a screen reader to announce.
+ */
+ 'aria-label': string;
+}
+
+export type IconPrefixSuffixType = TextButtonProps | IconButtonProps;
+
+/**
+ * Everything that does not depend on which kind of button is being rendered.
+ *
+ * Named, and holding `size` and `children` itself, so that a call site missing one of them is told
+ * which type wanted it rather than being handed the structural dump of an anonymous object.
+ */
+export interface ButtonBaseProps
+ extends
+ Pick<
+ ButtonHTMLAttributes,
+ | 'id'
+ | 'className'
+ | 'style'
+ | 'tabIndex'
+ | 'autoFocus'
+ | 'type'
+ | 'onClick'
+ | 'onDoubleClick'
+ | 'onKeyDown'
+ | 'onKeyUp'
+ | 'onFocus'
+ | 'onBlur'
+ | 'onMouseEnter'
+ | 'onMouseLeave'
+ >,
+ AriaAttributes,
+ DisableType {
+ /**
+ * Height + padding token.
+ */
+ size: SizeType;
+ /**
+ * The text/children of this button.
+ *
+ * This field is mandatory, it's not allowed to have a empty button. `IconButtonProps` narrows
+ * it to a single element.
+ */
+ children: ReactNode;
+ /**
+ * Controls how the text inside the button will behave when it does not fit.
+ *
+ * `ellipsis` truncates the label and shows the full text in a tooltip on
+ * hover/focus, and only while the label is actually truncated. `none` clips the
+ * label at the button's edge, no marker and no tooltip.
+ *
+ * @note The tooltip is exclusive to `ellipsis`.
+ *
+ * @note A `disabled` button stacks its `disabledTooltip` above this one, reason first.
+ * `loading` buttons stay focusable and do show this one.
+ *
+ * @default ellipsis
+ */
+ textOverflow?: TextOverflowType;
+ /**
+ * When `true`, cross-fades a spinner over the `prefix` slot and stops the button from
+ * responding to clicks and keyboard activation. The label and `suffix` stay visible.
+ *
+ * @note Unlike `disabled`, the button stays focusable and carries `aria-disabled`/`aria-busy`
+ * instead of the native `disabled` attribute, so clicking it does not throw focus away.
+ *
+ * @note This does not cause the disabledTooltip to be shown to the user, we intentionally hide the tooltip even if you pass disabled={true}
+ *
+ * @default false
+ */
+ loading?: boolean;
+ /**
+ * What the button is busy with, shown in a tooltip while `loading` is true. Optional, a
+ * spinner alone is already a valid busy state.
+ *
+ * @note Only renders while `loading` is true, and it takes the place of `disabledTooltip`,
+ * which is suppressed for the whole time the button is loading.
+ *
+ * @note Stacks above the `ellipsis` overflow tooltip, same as `disabledTooltip`: a loading
+ * button with a truncated label shows what it is doing first, then the full label.
+ */
+ loadingTooltip?: ReactNode;
+ /**
+ * The width of this button. Written as the `--button-internal-width` custom property, so it
+ * composes with the tokens instead of overwriting `style.width`. Numbers are written as `px`.
+ * Without it the button sizes to its content.
+ */
+ width?: CSSProperties['width'];
+ /**
+ * The max-width of this button. Written as the `--button-internal-max-width` custom property,
+ * so it composes with the tokens. Numbers are written as `px`. Without it the button is capped
+ * at `100%` of its container.
+ */
+ maxWidth?: CSSProperties['maxWidth'];
+ /**
+ * Forwarded to the rendered element as `data-testid`. Survives the tooltip trigger cloning the
+ * button, which a raw `data-testid` prop does not.
+ */
+ testId?: string;
+ /**
+ * Any `data-*` attribute is forwarded to the rendered element, so consumers can hook
+ * styling/selectors on top of the button without wrapping it.
+ */
+ [dataAttribute: `data-${string}`]: unknown;
+}
+
+export type ButtonProps = ButtonBaseProps & VariantColorType & IconPrefixSuffixType;
diff --git a/packages/ui/src/calendar/calendar.tsx b/packages/ui/src/calendar/calendar.tsx
index 97b4bbae..7ac0b422 100644
--- a/packages/ui/src/calendar/calendar.tsx
+++ b/packages/ui/src/calendar/calendar.tsx
@@ -8,7 +8,13 @@ import {
type Formatters,
getDefaultClassNames,
} from 'react-day-picker';
-import { Button, ButtonColor, type ButtonColorValue, buttonVariants } from '../button/index.js';
+import {
+ Button,
+ ButtonColor,
+ type ColorType,
+ buttonVariants,
+ type VariantColorType,
+} from '../button/index.js';
import { cn } from '../lib/utils.js';
import styles from './calendar.module.scss';
@@ -224,10 +230,33 @@ export function Calendar({
);
}
-export type CalendarDayButtonProps = React.ComponentProps<
- Exclude
+/**
+ * The `DayButton` props react-day-picker actually passes, which are also the ones `Button`
+ * forwards. Picking them keeps the spread into `Button` type-checked.
+ */
+export type CalendarDayButtonProps = Pick<
+ React.ComponentProps,
+ | 'day'
+ | 'modifiers'
+ | 'children'
+ | 'className'
+ | 'style'
+ | 'type'
+ | 'disabled'
+ | 'tabIndex'
+ | 'aria-label'
+ | 'aria-disabled'
+ | 'onClick'
+ | 'onBlur'
+ | 'onFocus'
+ | 'onKeyDown'
+ | 'onMouseEnter'
+ | 'onMouseLeave'
> & {
- color?: ButtonColorValue;
+ /**
+ * The ghost variant of the day button only renders correctly with the secondary color.
+ */
+ color?: ColorType | (string & {});
suffix?: string;
prefix?: string;
};
@@ -242,6 +271,9 @@ export function CalendarDayButton({
modifiers,
suffix,
prefix,
+ color = ButtonColor.Secondary,
+ disabled,
+ children,
...props
}: CalendarDayButtonProps) {
const ref = React.useRef(null);
@@ -256,8 +288,10 @@ export function CalendarDayButton({
return (
{prefix}> : undefined}
suffix={suffix ? <>{suffix}> : undefined}
className={cn(styles['calendar__day-button'], defaultClassNames.day, className)}
{...props}
- />
+ >
+ {children}
+
);
}
diff --git a/packages/ui/src/date-picker/date-picker.tsx b/packages/ui/src/date-picker/date-picker.tsx
index 936de35f..82ed5fc9 100644
--- a/packages/ui/src/date-picker/date-picker.tsx
+++ b/packages/ui/src/date-picker/date-picker.tsx
@@ -3,12 +3,7 @@ import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import * as React from 'react';
-import {
- Button,
- type ButtonColorValue,
- type ButtonSizeValue,
- type ButtonVariantValue,
-} from '../button/index.js';
+import { Button, type ColorType, type SizeType, type VariantColorType } from '../button/index.js';
import { Calendar } from '../calendar/index.js';
import { ComboboxSimple, type ComboboxSimpleItem } from '../combobox/index.js';
import { Input } from '../input/index.js';
@@ -88,21 +83,11 @@ export type DatePickerProps = {
* Additional CSS classes for the popover content.
*/
popoverContentClassName?: string;
- /**
- * Button variant.
- * @default 'outlined'
- */
- buttonVariant?: ButtonVariantValue;
- /**
- * Button color.
- * @default 'secondary'
- */
- buttonColor?: ButtonColorValue;
/**
* Button size.
* @default 'md'
*/
- buttonSize?: ButtonSizeValue;
+ buttonSize?: SizeType;
/**
* Calendar props.
*/
@@ -129,7 +114,37 @@ export type DatePickerProps = {
* Test ID for the date picker.
*/
testId?: string;
-};
+} & DatePickerTriggerAppearance;
+
+/**
+ * Variant/color of the trigger button. Follows the same pairing rules as `Button`:
+ * `solid` and `link` accept every color, the other variants are secondary only.
+ */
+export type DatePickerTriggerAppearance =
+ | {
+ /**
+ * Button variant.
+ * @default 'outlined'
+ */
+ buttonVariant?: 'solid' | 'link';
+ /**
+ * Button color.
+ * @default 'secondary'
+ */
+ buttonColor?: ColorType;
+ }
+ | {
+ /**
+ * Button variant.
+ * @default 'outlined'
+ */
+ buttonVariant?: 'outlined' | 'ghost' | 'dashed';
+ /**
+ * Button color.
+ * @default 'secondary'
+ */
+ buttonColor?: 'secondary';
+ };
export const TIMEZONES = ALL_TIMEZONES.map((tz) => ({
value: tz,
@@ -325,9 +340,11 @@ export const DatePicker = React.forwardRef(
const defaultTrigger = (
{
* await api.doSomething();
* return true; // close dialog
@@ -140,7 +140,7 @@ export type ConfirmDialogProps = {
* {
* const ok = await deleteItem();
@@ -170,7 +170,7 @@ export const ConfirmDialog = React.forwardRef
@@ -231,6 +232,7 @@ export const ConfirmDialog = React.forwardRef {
+export type DialogCloseButtonProps = Omit<
+ ButtonProps,
+ | 'type'
+ | 'aria-label'
+ | 'children'
+ | 'icon'
+ | 'prefix'
+ | 'suffix'
+ | 'variant'
+ | 'size'
+ | 'color'
+ | 'disabled'
+ | 'disabledTooltip'
+> & {
/**
* Accessible label for screen readers.
* @default "Close"
@@ -14,8 +34,32 @@ export interface DialogCloseButtonProps extends Omit(
- ({ className, ariaLabel = 'Close', icon = , ...buttonProps }, ref) => {
+ (
+ {
+ className,
+ ariaLabel = 'Close',
+ icon = ,
+ variant = 'ghost',
+ size = 'md',
+ color = 'secondary',
+ disabled,
+ disabledTooltip,
+ ...buttonProps
+ },
+ ref,
+ ) => {
return (
{icon}
- {ariaLabel}
);
diff --git a/packages/ui/src/dialog/subcomponents/dialog-footer.tsx b/packages/ui/src/dialog/subcomponents/dialog-footer.tsx
index e90eebe3..a59b7f64 100644
--- a/packages/ui/src/dialog/subcomponents/dialog-footer.tsx
+++ b/packages/ui/src/dialog/subcomponents/dialog-footer.tsx
@@ -29,7 +29,7 @@ export type DialogFooterProps = Pick<
*
* Cancel
*
- *
+ *
* Delete Step
*
*
diff --git a/packages/ui/src/drawer/subcomponents/drawer-footer.tsx b/packages/ui/src/drawer/subcomponents/drawer-footer.tsx
index c6e571b0..c1b9c7f7 100644
--- a/packages/ui/src/drawer/subcomponents/drawer-footer.tsx
+++ b/packages/ui/src/drawer/subcomponents/drawer-footer.tsx
@@ -22,7 +22,7 @@ export type DrawerFooterProps = DialogFooterProps;
*
*
* Cancel
- * Delete
+ * Delete
*
*
*
diff --git a/packages/ui/src/input/input.tsx b/packages/ui/src/input/input.tsx
index b7739230..db49c2a6 100644
--- a/packages/ui/src/input/input.tsx
+++ b/packages/ui/src/input/input.tsx
@@ -230,21 +230,22 @@ const InputPassword = React.forwardRef(
suffix={
- ) : (
-
- )
- }
- />
+ disabled={props.disabled ?? false}
+ disabledTooltip={undefined}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
}
/>
);
diff --git a/packages/ui/src/lib/css-length.ts b/packages/ui/src/lib/css-length.ts
new file mode 100644
index 00000000..0f38e099
--- /dev/null
+++ b/packages/ui/src/lib/css-length.ts
@@ -0,0 +1,3 @@
+export function toCssLength(value: string | number): string {
+ return typeof value === 'number' ? `${value}px` : value;
+}
diff --git a/packages/ui/src/lib/useIsLabelTruncated.tsx b/packages/ui/src/lib/useIsLabelTruncated.tsx
new file mode 100644
index 00000000..b315ef26
--- /dev/null
+++ b/packages/ui/src/lib/useIsLabelTruncated.tsx
@@ -0,0 +1,47 @@
+import { type RefCallback, useCallback, useRef, useState } from 'react';
+
+// `scrollWidth`/`clientWidth` are rounded to integers, so a label that fits can
+// still report a one pixel overflow on fractional layouts. Anything above that
+// is a real truncation.
+const LABEL_OVERFLOW_TOLERANCE_PX = 1;
+
+function isLabelTruncated(label: HTMLElement): boolean {
+ return label.scrollWidth - label.clientWidth > LABEL_OVERFLOW_TOLERANCE_PX;
+}
+
+export function useIsLabelTruncated(enabled: boolean): [boolean, RefCallback] {
+ const [truncated, setTruncated] = useState(false);
+ const observers = useRef>([]);
+
+ const labelRef = useCallback(
+ (node: HTMLSpanElement | null): void => {
+ for (const observer of observers.current) {
+ observer.disconnect();
+ }
+
+ observers.current = [];
+
+ if (
+ !node ||
+ !enabled ||
+ typeof ResizeObserver === 'undefined' ||
+ typeof MutationObserver === 'undefined'
+ ) {
+ return;
+ }
+
+ const measure = (): void => setTruncated(isLabelTruncated(node));
+
+ const resizeObserver = new ResizeObserver(measure);
+ resizeObserver.observe(node);
+
+ const mutationObserver = new MutationObserver(measure);
+ mutationObserver.observe(node, { characterData: true, childList: true, subtree: true });
+
+ observers.current = [resizeObserver, mutationObserver];
+ },
+ [enabled],
+ );
+
+ return [enabled && truncated, labelRef];
+}
diff --git a/packages/ui/src/pagination/pagination.tsx b/packages/ui/src/pagination/pagination.tsx
index 43805868..576cdb8e 100644
--- a/packages/ui/src/pagination/pagination.tsx
+++ b/packages/ui/src/pagination/pagination.tsx
@@ -1,7 +1,14 @@
import { ChevronLeft, ChevronRight, Minus } from '@signozhq/icons';
import * as React from 'react';
import { type MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
-import { Button, type ButtonProps, ButtonSize } from '../button/index.js';
+import {
+ Button,
+ type ButtonProps,
+ ButtonSize,
+ type ColorType,
+ type VariantColorType,
+ type VariantType,
+} from '../button/index.js';
import { cn } from '../lib/utils.js';
import { SelectSimple } from '../select/index.js';
import styles from './pagination.module.scss';
@@ -145,26 +152,74 @@ export type PaginationLinkProps = {
* If the link is not active, the button will be styled as a ghost button.
*/
isActive?: boolean;
-} & ButtonProps;
+ /**
+ * Visual style of the button. Defaults to `solid` when active, `ghost` otherwise.
+ */
+ variant?: VariantType;
+ /**
+ * Height + padding token.
+ *
+ * @default md
+ */
+ size?: ButtonProps['size'];
+ /**
+ * Color scheme applied to the variant. Defaults to `primary` when active, `secondary` otherwise.
+ */
+ color?: ColorType;
+ /**
+ * Content of the button, usually the page number.
+ */
+ children?: React.ReactNode;
+ /**
+ * When true, the page cannot be selected.
+ */
+ disabled?: boolean;
+ /**
+ * Reason shown in a tooltip while the button is disabled.
+ */
+ disabledTooltip?: React.ReactNode;
+} & Omit<
+ ButtonProps,
+ 'variant' | 'size' | 'color' | 'children' | 'disabled' | 'disabledTooltip' | 'icon'
+>;
/**
* Button for a specific page number. Set `isActive` when it represents the
* current page. Accepts all `Button` props.
*/
export const PaginationLink = React.forwardRef(
- ({ className, testId, isActive, size = ButtonSize.Icon, disabled, children, ...props }, ref) => {
+ (
+ {
+ className,
+ testId,
+ isActive,
+ size = ButtonSize.MD,
+ variant,
+ color,
+ disabled,
+ disabledTooltip,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
return (
{children}
@@ -180,11 +235,11 @@ export type PaginationNavProps = Omit(
- ({ className, testId, disabled, size = 'icon', ...props }, ref) => {
+ ({ className, testId, disabled, size = ButtonSize.MD, ...props }, ref) => {
return (
(
- ({ className, testId, disabled, size, ...props }, ref) => {
+ ({ className, testId, disabled, size = ButtonSize.MD, ...props }, ref) => {
return (
(
handlePageChange(e, page)}
isActive={page === current}
- key={page}
>
{page}
diff --git a/packages/ui/src/table/data-table.tsx b/packages/ui/src/table/data-table.tsx
index b0e9a9e6..321b5dda 100644
--- a/packages/ui/src/table/data-table.tsx
+++ b/packages/ui/src/table/data-table.tsx
@@ -979,11 +979,14 @@ export function DataTable({
setGlobalFilter('')}
- suffix={ }
- />
+ >
+
+
)
}
/>
@@ -1189,10 +1192,14 @@ export function DataTable({
column.setFilterValue('')}
- suffix={ }
- />
+ >
+
+
) : null
}
/>