@stll/ui 0.59.1 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +120 -4
  2. package/dist/components/accordion.d.ts +17 -3
  3. package/dist/components/accordion.js +17 -3
  4. package/dist/components/button.d.ts +56 -5
  5. package/dist/components/button.js +97 -15
  6. package/dist/components/button.native-classes.d.ts +17 -0
  7. package/dist/components/button.native-classes.js +176 -0
  8. package/dist/components/button.native.d.ts +5 -2
  9. package/dist/components/button.native.js +17 -134
  10. package/dist/components/button.shared.d.ts +24 -2
  11. package/dist/components/button.shared.js +15 -1
  12. package/dist/components/collapsible.d.ts +26 -0
  13. package/dist/components/collapsible.js +40 -0
  14. package/dist/components/combobox.d.ts +3 -1
  15. package/dist/components/combobox.js +3 -2
  16. package/dist/components/date-picker-popover.d.ts +2 -0
  17. package/dist/components/date-picker-popover.js +5 -3
  18. package/dist/components/details-grid.d.ts +21 -2
  19. package/dist/components/details-grid.js +75 -20
  20. package/dist/components/field.d.ts +6 -0
  21. package/dist/components/field.js +6 -0
  22. package/dist/components/form.d.ts +43 -3
  23. package/dist/components/form.js +53 -3
  24. package/dist/components/input-control.d.ts +9 -0
  25. package/dist/components/input-control.js +10 -1
  26. package/dist/components/input.d.ts +7 -1
  27. package/dist/components/input.js +4 -4
  28. package/dist/components/label.d.ts +24 -2
  29. package/dist/components/label.js +21 -2
  30. package/dist/components/number-input.d.ts +9 -2
  31. package/dist/components/number-input.js +18 -6
  32. package/dist/components/search-field.js +1 -0
  33. package/dist/components/segmented-icon-toggle.d.ts +2 -1
  34. package/dist/components/segmented-icon-toggle.js +18 -19
  35. package/dist/components/switch.d.ts +21 -2
  36. package/dist/components/switch.js +44 -5
  37. package/dist/components/table.d.ts +16 -2
  38. package/dist/components/table.js +5 -4
  39. package/dist/components/textarea.d.ts +14 -1
  40. package/dist/components/textarea.js +11 -5
  41. package/dist/components/toggle-group.d.ts +32 -0
  42. package/dist/components/toggle-group.js +45 -0
  43. package/dist/components/toggle.d.ts +26 -0
  44. package/dist/components/toggle.js +26 -0
  45. package/dist/index.d.ts +14 -7
  46. package/dist/index.js +9 -3
  47. package/dist/kanban/column-bands.js +1 -1
  48. package/dist/kanban/index.d.ts +5 -2
  49. package/dist/kanban/index.js +4 -1
  50. package/dist/kanban/sortable-edge.d.ts +39 -0
  51. package/dist/kanban/sortable-edge.js +27 -0
  52. package/dist/kanban/sortable-interactions.d.ts +145 -0
  53. package/dist/kanban/sortable-interactions.js +503 -0
  54. package/dist/kanban/sortable-interactions.logic.d.ts +79 -0
  55. package/dist/kanban/sortable-interactions.logic.js +210 -0
  56. package/dist/kanban/subgroup-board.d.ts +27 -2
  57. package/dist/kanban/subgroup-board.js +26 -1
  58. package/dist/kanban/virtual-cell.d.ts +18 -1
  59. package/dist/kanban/virtual-cell.js +70 -17
  60. package/package.json +17 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ cannot reach into product code by accident. Lint enforces that boundary; the
11
11
  export map below is what "part of the design system" means.
12
12
 
13
13
  Peer dependencies: `react`, `react-dom`, `@base-ui/react`, `tailwindcss` (v4),
14
- and `@tanstack/react-virtual`.
14
+ `@dnd-kit/core`, `@dnd-kit/sortable`, and `@tanstack/react-virtual`.
15
15
 
16
16
  Derived in part from [coss ui](https://github.com/cosscom/ui) (MIT); see
17
17
  `THIRD_PARTY_NOTICES`.
@@ -104,11 +104,101 @@ unavailable state.
104
104
  minor. They will be removed after that; the export guard checks that both
105
105
  spellings land on the same module for as long as they both exist.
106
106
 
107
- ## Kanban boards
107
+ ## Sortable boards
108
108
 
109
109
  `@stll/ui/kanban` provides board matrices, subgroup swimlanes, bounded virtual
110
- cells, and column bands. The caller owns card rendering, permissions, and
111
- persisted mutations.
110
+ cells, and input/accessibility primitives. The caller keeps domain identifiers,
111
+ card rendering, permissions, and persisted mutations. Wrap sortable boards in
112
+ `KanbanSortableBoard`, render items through `useKanbanSortable`, and attach the
113
+ returned bindings to `KanbanDragHandle` or `KanbanCardDragSurface`. This public
114
+ contract supports external consumers with keyboard navigation across virtual rows
115
+ and matrix cells, touch scrolling and long-press dragging, and host-controlled
116
+ collapsed-band peeking. It remains supported independently of which interaction
117
+ implementation the application board uses.
118
+
119
+ ```tsx
120
+ import {
121
+ KanbanDragHandle,
122
+ KanbanSortableBoard,
123
+ KanbanVirtualCell,
124
+ useKanbanSortable,
125
+ } from "@stll/ui/kanban";
126
+
127
+ const Card = ({ id }: { id: string }) => {
128
+ const { activator, setNodeRef, style } = useKanbanSortable({
129
+ activation: { type: "handle" },
130
+ id,
131
+ });
132
+ if (activator.type !== "handle") {
133
+ return null;
134
+ }
135
+ return (
136
+ <article ref={setNodeRef} style={style}>
137
+ <KanbanDragHandle bindings={activator.bindings} label="Move card" />
138
+ </article>
139
+ );
140
+ };
141
+
142
+ <KanbanSortableBoard onDragEnd={handleDragEnd}>
143
+ <KanbanVirtualCell
144
+ rows={cardIds}
145
+ getRowKey={(id) => id}
146
+ renderRow={(id) => <Card id={id} />}
147
+ pagination={{ type: "none" }}
148
+ sortable={{
149
+ dropTarget: { id: "column", position: { column: 0, lane: 0 } },
150
+ getRowId: (id) => id,
151
+ }}
152
+ />
153
+ </KanbanSortableBoard>;
154
+ ```
155
+
156
+ For collapsed subgroup bands, the host maps each registered drop target to its
157
+ band and clears the drag state on both drop and cancellation. Populate
158
+ `bandIdByDropTargetId` from the same cell/slot registrations that supply the
159
+ sortable target IDs (including folded-band slots).
160
+
161
+ ```tsx
162
+ const [dragState, setDragState] = useState<
163
+ { type: "idle" } | { type: "dragging"; bandId: string | null }
164
+ >({ type: "idle" });
165
+
166
+ <KanbanSortableBoard
167
+ onDragStart={() => setDragState({ type: "dragging", bandId: null })}
168
+ onDragOver={({ over }) => {
169
+ setDragState({
170
+ type: "dragging",
171
+ bandId:
172
+ over === null ? null : (bandIdByDropTargetId.get(over.id) ?? null),
173
+ });
174
+ }}
175
+ onDragEnd={(event) => {
176
+ setDragState({ type: "idle" });
177
+ handleDragEnd(event);
178
+ }}
179
+ onDragCancel={() => setDragState({ type: "idle" })}
180
+ >
181
+ <KanbanSubgroupBoard
182
+ {...boardProps}
183
+ isDragging={dragState.type === "dragging"}
184
+ dragOverBandId={dragState.type === "dragging" ? dragState.bandId : null}
185
+ />
186
+ </KanbanSortableBoard>;
187
+ ```
188
+
189
+ The default sensors are mouse (8px movement), touch (150ms delay with 8px
190
+ tolerance), and keyboard. Scroll containers retain `touch-action: auto`.
191
+ `KanbanDragHandle` and `{ type: "item" }` activation disable touch panning on
192
+ their activation surfaces. Use `{ type: "card" }` with
193
+ `KanbanCardDragSurface` to preserve scrolling from the card while supporting
194
+ long-press dragging. `KanbanSortableBoard` also accepts
195
+ custom `sensors`, `accessibility`, collision detection, keyboard coordinates,
196
+ auto-scroll options, and an `overlay` render function.
197
+
198
+ `getKanbanHorizontalEdge` takes `input: "pointer"` with a current client-x and
199
+ `direction: "ltr" | "rtl"` for mouse and touch input. Keyboard calls use
200
+ `input: "keyboard"` and require source and target indices, so every move has a
201
+ logical edge without relying on ambiguous geometry.
112
202
 
113
203
  For Group/Sub-group boards, build one canonical matrix and render it with the
114
204
  installable layout and virtual cell:
@@ -145,6 +235,32 @@ const matrix = buildKanbanBoardMatrix({
145
235
  />;
146
236
  ```
147
237
 
238
+ When rows inside a virtual cell are sortable, give the cell a unique drop target
239
+ and its ordered matrix position. The cell registers its actual scroll element,
240
+ including when `rows` is empty, and owns the matching `SortableContext`.
241
+
242
+ ```tsx
243
+ <KanbanVirtualCell
244
+ getRowKey={(row) => row.id}
245
+ pagination={{ type: "none" }}
246
+ renderRow={(row) => <Card row={row} />}
247
+ rows={cell.rows}
248
+ sortable={{
249
+ dropTarget: {
250
+ id: cellId,
251
+ position: { column: columnIndex, lane: laneIndex },
252
+ },
253
+ getRowId: (row) => row.id,
254
+ }}
255
+ />
256
+ ```
257
+
258
+ The default keyboard coordinates move through items in their cell before
259
+ crossing into adjacent columns or subgroup lanes; empty cells remain reachable.
260
+ Pointer and touch drops outside registered board targets cancel. Choose an
261
+ explicit `{ type: "handle" }` activation for the 44px handle, or `{ type:
262
+ "item" }` when the whole item is the intended activation surface.
263
+
148
264
  ### Column bands
149
265
 
150
266
  Columns whose options carry a `band` (`KanbanColumnBand`, usually a status
@@ -2,11 +2,25 @@ import { Accordion as Accordion$1 } from "@base-ui/react/accordion";
2
2
  //#region src/components/accordion.d.ts
3
3
  declare const Accordion: <Value = unknown>(props: Accordion$1.Root.Props<Value>) => import("react").JSX.Element;
4
4
  declare const AccordionItem: ({ className, ...props }: Accordion$1.Item.Props) => import("react").JSX.Element;
5
- declare const AccordionTrigger: ({ className, children, size, ...props }: Accordion$1.Trigger.Props & {
6
- size?: "default" | "compact";
5
+ /**
6
+ * - `default`: a section heading of a page or panel.
7
+ * - `sm`: body-size type at a 44px row, for a disclosure inside a dialog or
8
+ * card ("Show all permissions", advanced options).
9
+ * - `compact`: small type in a dense list.
10
+ */
11
+ type AccordionSize = "default" | "sm" | "compact";
12
+ /**
13
+ * - `default`: the trigger reads as a heading.
14
+ * - `muted`: a quiet "show more" line in muted body weight that brightens on
15
+ * hover, for a disclosure that is secondary to what is around it.
16
+ */
17
+ type AccordionTriggerVariant = "default" | "muted";
18
+ declare const AccordionTrigger: ({ className, children, size, variant, ...props }: Accordion$1.Trigger.Props & {
19
+ size?: AccordionSize;
20
+ variant?: AccordionTriggerVariant;
7
21
  }) => import("react").JSX.Element;
8
22
  declare const AccordionPanel: ({ className, children, size, ...props }: Accordion$1.Panel.Props & {
9
- size?: "default" | "compact";
23
+ size?: AccordionSize;
10
24
  }) => import("react").JSX.Element;
11
25
  //#endregion
12
26
  export { Accordion, AccordionPanel as AccordionContent, AccordionPanel, AccordionItem, AccordionTrigger };
@@ -13,10 +13,24 @@ const AccordionItem = ({ className, ...props }) => /* @__PURE__ */ jsx(Accordion
13
13
  "data-slot": "accordion-item",
14
14
  ...props
15
15
  });
16
- const AccordionTrigger = ({ className, children, size = "default", ...props }) => /* @__PURE__ */ jsx(Accordion$1.Header, {
16
+ const TRIGGER_SIZE_CLASS_NAME = {
17
+ compact: "py-2 text-xs",
18
+ default: "py-4 text-sm",
19
+ sm: "min-h-11 items-center py-2 text-sm"
20
+ };
21
+ const PANEL_SIZE_CLASS_NAME = {
22
+ compact: "pb-3",
23
+ default: "pb-4",
24
+ sm: "pb-2"
25
+ };
26
+ const TRIGGER_VARIANT_CLASS_NAME = {
27
+ default: "font-medium",
28
+ muted: "text-muted-foreground hover:text-foreground font-normal"
29
+ };
30
+ const AccordionTrigger = ({ className, children, size = "default", variant = "default", ...props }) => /* @__PURE__ */ jsx(Accordion$1.Header, {
17
31
  className: "flex",
18
32
  children: /* @__PURE__ */ jsxs(Accordion$1.Trigger, {
19
- className: cn("focus-visible:ring-ring flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md text-start font-medium outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180", size === "compact" ? "py-2 text-xs" : "py-4 text-sm", className),
33
+ className: cn("focus-visible:ring-ring flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md text-start outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180", TRIGGER_SIZE_CLASS_NAME[size], TRIGGER_VARIANT_CLASS_NAME[variant], className),
20
34
  "data-slot": "accordion-trigger",
21
35
  ...props,
22
36
  children: [children, /* @__PURE__ */ jsx(ChevronDownIcon, {
@@ -30,7 +44,7 @@ const AccordionPanel = ({ className, children, size = "default", ...props }) =>
30
44
  "data-slot": "accordion-panel",
31
45
  ...props,
32
46
  children: /* @__PURE__ */ jsx("div", {
33
- className: cn("pt-0", size === "compact" ? "pb-3" : "pb-4", className),
47
+ className: cn("pt-0", PANEL_SIZE_CLASS_NAME[size], className),
34
48
  children
35
49
  })
36
50
  });
@@ -1,4 +1,4 @@
1
- import { ButtonContract } from "./button.shared.js";
1
+ import { ButtonContract, ButtonSize, ButtonVariant } from "./button.shared.js";
2
2
  import { ReactNode } from "react";
3
3
  import { useRender } from "@base-ui/react/use-render";
4
4
  //#region src/components/button.d.ts
@@ -12,10 +12,31 @@ import { useRender } from "@base-ui/react/use-render";
12
12
  * swaps the cursor so the control still reads as unavailable on hover.
13
13
  */
14
14
  declare const buttonAccessibleDisabledClass = "cursor-not-allowed opacity-64";
15
+ declare const BUTTON_LAYOUT_CLASS_NAMES: {
16
+ readonly wrap: "h-auto min-h-11 items-start justify-start py-2 text-start whitespace-normal sm:h-auto";
17
+ };
15
18
  declare const buttonVariants: (props?: ({
16
- size?: "chip" | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "row" | "sm" | "xl" | "xs" | null | undefined;
17
- variant?: "default" | "destructive" | "destructive-ghost" | "destructive-outline" | "ghost" | "link" | "muted" | "outline" | "secondary" | null | undefined;
19
+ size?: "chip" | "default" | "icon" | "icon-2xs" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "inline" | "lg" | "row" | "sm" | "xl" | "xs" | null | undefined;
20
+ layout?: "wrap" | null | undefined;
21
+ variant?: "default" | "destructive" | "destructive-ghost" | "destructive-outline" | "ghost" | "link" | "muted" | "muted-destructive" | "outline" | "secondary" | null | undefined;
18
22
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
23
+ /**
24
+ * The outline a plain button's focus ring follows, matching its content:
25
+ * `box` for a card, row or block of text (the container radius), `round` for
26
+ * an avatar, a dot or a pill.
27
+ */
28
+ declare const BUTTON_PLAIN_SHAPE_CLASS: {
29
+ readonly box: "rounded-lg";
30
+ readonly round: "rounded-full";
31
+ };
32
+ type ButtonPlainShape = keyof typeof BUTTON_PLAIN_SHAPE_CLASS;
33
+ /**
34
+ * Where the focus ring is drawn. `outside` (the default) rings the box with a
35
+ * gap; `inside` draws it within the box, for a target that fills a clipping
36
+ * container (an `overflow-hidden` card, a table cell, a thumbnail grid) where
37
+ * an outside ring would be cut off.
38
+ */
39
+ type ButtonFocusRing = "outside" | "inside";
19
40
  /** What a button's box holds: a glyph alone, or a visible label. */
20
41
  declare const BUTTON_CONTENT: {
21
42
  readonly icon: "icon";
@@ -39,17 +60,47 @@ declare const BUTTON_SIZE_CONTENT: {
39
60
  readonly "icon-lg": "icon";
40
61
  readonly "icon-sm": "icon";
41
62
  readonly "icon-xl": "icon";
63
+ readonly "icon-2xs": "icon";
42
64
  readonly "icon-xs": "icon";
65
+ readonly inline: "text";
43
66
  readonly lg: "text";
44
67
  readonly row: "text";
45
68
  readonly sm: "text";
46
69
  readonly xl: "text";
47
70
  readonly xs: "text";
48
71
  };
72
+ /**
73
+ * `selected` (./button.shared.ts) sets `data-pressed`, which every variant
74
+ * paints. The ARIA state (`aria-pressed`, `aria-checked`, `aria-current` or
75
+ * `aria-selected`) stays the caller's. For a set of mutually exclusive
76
+ * options prefer `ToggleGroup`, which owns that state.
77
+ */
49
78
  type ButtonProps = Omit<ButtonContract, "children" | "disabled"> & {
79
+ layout?: keyof typeof BUTTON_LAYOUT_CLASS_NAMES;
50
80
  tooltip?: ReactNode;
81
+ focusRing?: ButtonFocusRing | undefined;
82
+ /** `variant="plain"` only: the outline its focus ring follows. */
83
+ shape?: ButtonPlainShape | undefined;
51
84
  "data-accessible-disabled"?: "";
52
85
  } & useRender.ComponentProps<"button">;
53
- declare function Button({ className, variant, size, render, loading, icon, children, disabled, tooltip, "data-accessible-disabled": accessibleDisabled, ...props }: ButtonProps): import("react").JSX.Element;
86
+ declare function Button({ className, variant: variantProp, size, render, layout, loading, icon, children, disabled, tooltip, selected, focusRing, shape, "data-accessible-disabled": accessibleDisabled, ...props }: ButtonProps): import("react").JSX.Element;
87
+ /**
88
+ * A run with no box can anchor no tooltip or popup, and carries no variant,
89
+ * size or icon: its text is its look.
90
+ */
91
+ type ButtonTextRunProps = Omit<ButtonProps, "children" | "className" | "focusRing" | "icon" | "loading" | "render" | "selected" | "shape" | "size" | "tooltip" | "variant"> & {
92
+ children: ReactNode;
93
+ className?: string | undefined;
94
+ };
95
+ /**
96
+ * A long run of prose that acts as one button: a citation or a
97
+ * cross-reference in reader text. A <button> is an atomic box even at
98
+ * `display: inline`, so its label cannot break across the paragraph's lines;
99
+ * here the text sits in a native <label>, an inline box that wraps and copies
100
+ * with the prose, and the label forwards a press anywhere on the run to the
101
+ * <button> it holds, which takes its accessible name from the label. Use
102
+ * `Button size="inline"` for a short action.
103
+ */
104
+ declare const ButtonTextRun: ({ children, className, disabled, ...props }: ButtonTextRunProps) => import("react").JSX.Element;
54
105
  //#endregion
55
- export { BUTTON_CONTENT, BUTTON_SIZE_CONTENT, Button, buttonAccessibleDisabledClass, buttonVariants };
106
+ export { BUTTON_CONTENT, BUTTON_SIZE_CONTENT, Button, type ButtonFocusRing, type ButtonPlainShape, type ButtonProps, type ButtonSize, ButtonTextRun, type ButtonVariant, buttonAccessibleDisabledClass, buttonVariants };
@@ -3,7 +3,7 @@ import { cn } from "../lib/utils.js";
3
3
  import { renderTooltipTrigger } from "./tooltip-trigger-helper.js";
4
4
  import { BUTTON_DISPOSITION, blockDisabledActivation, blockDisabledKeyActivation, resolveButtonDisposition } from "../lib/button-disposition.js";
5
5
  import { MENU_ROW_CLASS_NAME } from "../lib/menu-row.js";
6
- import { BUTTON_VARIANT_TOKENS } from "./button.shared.js";
6
+ import { BUTTON_PLAIN_VARIANT, BUTTON_VARIANT_TOKENS } from "./button.shared.js";
7
7
  import { Loader } from "./loader.js";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  import { mergeProps } from "@base-ui/react/merge-props";
@@ -44,7 +44,9 @@ const BUTTON_SIZE_CLASS_NAMES = {
44
44
  "icon-lg": "size-10 sm:size-9",
45
45
  "icon-sm": "size-8 sm:size-7",
46
46
  "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
47
+ "icon-2xs": "size-5 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-4 [&_svg:not([class*='size-'])]:size-3.5 sm:[&_svg:not([class*='size-'])]:size-3",
47
48
  "icon-xs": "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
49
+ inline: "inline h-auto rounded-sm border-0 p-0 text-start text-[length:inherit] leading-[inherit] font-normal whitespace-normal select-text before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[length:inherit] [&_svg]:mx-0 [&_svg]:inline [&_svg]:align-[-0.125em] [&_svg:not(:last-child)]:me-1",
48
50
  lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
49
51
  row: `${MENU_ROW_CLASS_NAME} [&_svg]:mx-0`,
50
52
  sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] text-sm sm:h-7 sm:text-xs",
@@ -52,26 +54,68 @@ const BUTTON_SIZE_CLASS_NAMES = {
52
54
  xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5"
53
55
  };
54
56
  const BUTTON_VARIANT_CLASS_NAMES = {
55
- default: `${tokens("default")} shadow-primary/24 [:hover,[data-pressed]]:bg-primary/90 shadow-xs not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none`,
56
- destructive: `${tokens("destructive")} shadow-destructive/8 [:hover,[data-pressed]]:border-destructive/40 [:hover,[data-pressed]]:bg-destructive/14 [:active,[data-pressed]]:bg-destructive/18 shadow-xs not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] [:disabled,:active,[data-pressed]]:shadow-none`,
57
- "destructive-outline": `${tokens("destructive-outline")} dark:bg-input/32 [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4 shadow-xs/5 not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none`,
58
- "destructive-ghost": `${tokens("destructive-ghost")} data-pressed:bg-destructive/10 [:hover,[data-pressed]]:bg-destructive/10`,
59
- ghost: `${tokens("ghost")} data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent`,
60
- link: `${tokens("link")} underline-offset-4 [:hover,[data-pressed]]:underline`,
61
- muted: `${tokens("muted")} data-pressed:bg-accent data-pressed:text-foreground [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground`,
62
- outline: `${tokens("outline")} dark:bg-input/32 [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64 shadow-xs/5 not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none`,
63
- secondary: `${tokens("secondary")} [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90`
57
+ default: `${tokens("default")} shadow-primary/24 data-pressed:bg-primary/80 [:hover:not([data-pressed])]:bg-primary/90 shadow-xs not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none`,
58
+ destructive: `${tokens("destructive")} shadow-destructive/8 data-pressed:border-destructive/56 data-pressed:bg-destructive/20 [:hover:not([data-pressed])]:border-destructive/40 [:hover:not([data-pressed])]:bg-destructive/14 active:bg-destructive/18 shadow-xs not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] [:disabled,:active,[data-pressed]]:shadow-none`,
59
+ "destructive-outline": `${tokens("destructive-outline")} dark:bg-input/32 data-pressed:border-destructive/56 data-pressed:bg-destructive/8 [:hover:not([data-pressed])]:border-destructive/32 [:hover:not([data-pressed])]:bg-destructive/4 shadow-xs/5 not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none`,
60
+ "destructive-ghost": `${tokens("destructive-ghost")} data-pressed:bg-destructive/14 data-pressed:inset-ring-destructive/32 [:hover:not([data-pressed])]:bg-destructive/10 data-pressed:inset-ring`,
61
+ ghost: `${tokens("ghost")} data-pressed:bg-accent data-pressed:inset-ring-input [:hover:not([data-pressed])]:bg-accent data-pressed:shadow-xs/5 data-pressed:inset-ring`,
62
+ link: `${tokens("link")} underline-offset-4 data-pressed:underline data-pressed:decoration-2 [:hover:not([data-pressed])]:underline`,
63
+ "muted-destructive": `${tokens("muted-destructive")} data-pressed:bg-destructive/14 data-pressed:text-destructive data-pressed:inset-ring-destructive/32 [:hover:not([data-pressed])]:bg-destructive/10 [:hover:not([data-pressed])]:text-destructive data-pressed:inset-ring`,
64
+ muted: `${tokens("muted")} data-pressed:bg-accent data-pressed:text-foreground data-pressed:inset-ring-input [:hover:not([data-pressed])]:bg-accent [:hover:not([data-pressed])]:text-foreground data-pressed:shadow-xs/5 data-pressed:inset-ring`,
65
+ outline: `${tokens("outline")} dark:bg-input/32 data-pressed:border-primary/48 data-pressed:bg-accent dark:data-pressed:bg-input/64 [:hover:not([data-pressed])]:bg-accent/50 dark:[:hover:not([data-pressed])]:bg-input/64 shadow-xs/5 not-dark:bg-clip-padding not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none`,
66
+ secondary: `${tokens("secondary")} data-pressed:bg-background data-pressed:inset-ring-input active:bg-secondary/80 [:hover:not([data-pressed])]:bg-secondary/90 data-pressed:shadow-xs/5 data-pressed:inset-ring`
64
67
  };
65
68
  const buttonVariants = cva("focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border text-base font-medium whitespace-nowrap outline-none select-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-64 sm:text-sm pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", {
69
+ compoundVariants: [{
70
+ className: "[:hover:not([data-pressed])]:bg-transparent",
71
+ size: "inline",
72
+ variant: [
73
+ "destructive-ghost",
74
+ "ghost",
75
+ "link",
76
+ "muted",
77
+ "muted-destructive"
78
+ ]
79
+ }, {
80
+ className: "underline",
81
+ size: "inline",
82
+ variant: "link"
83
+ }],
66
84
  defaultVariants: {
67
85
  size: BUTTON_DEFAULT_SIZE,
68
86
  variant: "default"
69
87
  },
70
88
  variants: {
71
89
  size: BUTTON_SIZE_CLASS_NAMES,
90
+ layout: { wrap: "h-auto min-h-11 items-start justify-start py-2 text-start whitespace-normal sm:h-auto" },
72
91
  variant: BUTTON_VARIANT_CLASS_NAMES
73
92
  }
74
93
  });
94
+ /**
95
+ * `plain` (./button.shared.ts) on the web. It bypasses `buttonVariants` and
96
+ * its box, border, type and press motion instead of undoing them class by
97
+ * class, and keeps the disabled disposition, the tooltip and the focus ring.
98
+ * It lays out like a native button (`inline-block`, content flowing from the
99
+ * start), so a card's children stack as they would in a `div`; `className`
100
+ * places it (`w-full`, `block`). `shape` gives it the outline its focus ring
101
+ * follows. Like a native button it does not dim when disabled: the content
102
+ * says it is unavailable.
103
+ *
104
+ * Selected, it draws the inset edge the other variants draw, as an outline
105
+ * inside the box: an outline paints above the content, so a card's own
106
+ * surface cannot cover it the way it would cover an inset shadow.
107
+ */
108
+ const BUTTON_PLAIN_CLASS = "focus-visible:ring-ring focus-visible:ring-offset-background data-pressed:outline-primary/48 relative inline-block cursor-pointer text-start outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-default data-pressed:outline-2 data-pressed:-outline-offset-2 data-pressed:outline-solid";
109
+ /**
110
+ * The outline a plain button's focus ring follows, matching its content:
111
+ * `box` for a card, row or block of text (the container radius), `round` for
112
+ * an avatar, a dot or a pill.
113
+ */
114
+ const BUTTON_PLAIN_SHAPE_CLASS = {
115
+ box: "rounded-lg",
116
+ round: "rounded-full"
117
+ };
118
+ const BUTTON_FOCUS_RING_INSIDE_CLASS = "focus-visible:ring-inset focus-visible:ring-offset-0";
75
119
  /** What a button's box holds: a glyph alone, or a visible label. */
76
120
  const BUTTON_CONTENT = {
77
121
  icon: "icon",
@@ -95,7 +139,9 @@ const BUTTON_SIZE_CONTENT = {
95
139
  "icon-lg": BUTTON_CONTENT.icon,
96
140
  "icon-sm": BUTTON_CONTENT.icon,
97
141
  "icon-xl": BUTTON_CONTENT.icon,
142
+ "icon-2xs": BUTTON_CONTENT.icon,
98
143
  "icon-xs": BUTTON_CONTENT.icon,
144
+ inline: BUTTON_CONTENT.text,
99
145
  lg: BUTTON_CONTENT.text,
100
146
  row: BUTTON_CONTENT.text,
101
147
  sm: BUTTON_CONTENT.text,
@@ -110,7 +156,8 @@ const implicitTooltip = (size, accessibleName) => {
110
156
  default: return panic(`Unhandled button content: ${String(content)}`);
111
157
  }
112
158
  };
113
- function Button({ className, variant, size, render, loading, icon, children, disabled, tooltip, "data-accessible-disabled": accessibleDisabled, ...props }) {
159
+ function Button({ className, variant: variantProp, size, render, layout, loading, icon, children, disabled, tooltip, selected, focusRing = "outside", shape = "box", "data-accessible-disabled": accessibleDisabled, ...props }) {
160
+ const variant = variantProp ?? (size === "inline" ? "link" : void 0);
114
161
  const typeValue = render ? void 0 : "button";
115
162
  const tooltipContent = tooltip ?? implicitTooltip(size, props["aria-label"] ?? props.title);
116
163
  const disposition = resolveButtonDisposition({
@@ -128,12 +175,14 @@ function Button({ className, variant, size, render, loading, icon, children, dis
128
175
  size: "sm",
129
176
  variant: "decorative"
130
177
  }) : icon, children] }),
131
- className: cn(disposition === BUTTON_DISPOSITION.enabled && !isActivationPreservingDisabled && size !== "row" && variant !== "link" && buttonPressClass, buttonVariants({
178
+ className: cn(variant === "plain" ? cn(BUTTON_PLAIN_CLASS, BUTTON_PLAIN_SHAPE_CLASS[shape], className) : cn(disposition === BUTTON_DISPOSITION.enabled && !isActivationPreservingDisabled && size !== "row" && size !== "inline" && variant !== "link" && buttonPressClass, buttonVariants({
132
179
  className,
133
180
  size,
134
- variant
135
- }), (isAccessibleDisabled || isActivationPreservingDisabled) && "cursor-not-allowed opacity-64"),
181
+ variant,
182
+ layout
183
+ })), focusRing === "inside" && BUTTON_FOCUS_RING_INSIDE_CLASS, (isAccessibleDisabled || isActivationPreservingDisabled) && "cursor-not-allowed opacity-64"),
136
184
  "data-slot": "button",
185
+ "data-pressed": selected === true ? "" : void 0,
137
186
  "aria-busy": loading || void 0,
138
187
  ...isAccessibleDisabled || isActivationPreservingDisabled ? {
139
188
  "aria-disabled": true,
@@ -159,5 +208,38 @@ function Button({ className, variant, size, render, loading, icon, children, dis
159
208
  trigger: button
160
209
  });
161
210
  }
211
+ /**
212
+ * The run is the visible target: underlined like a link, ringed while its
213
+ * button has keyboard focus (per line, so a wrapped run rings each line).
214
+ */
215
+ const BUTTON_TEXT_RUN_CLASS = "has-focus-visible:ring-ring cursor-pointer rounded-xs underline underline-offset-2 box-decoration-clone select-text hover:decoration-2 has-focus-visible:ring-2 has-disabled:cursor-not-allowed has-disabled:opacity-64";
216
+ const BUTTON_TEXT_RUN_TARGET_CLASS = "absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]";
217
+ /**
218
+ * A long run of prose that acts as one button: a citation or a
219
+ * cross-reference in reader text. A <button> is an atomic box even at
220
+ * `display: inline`, so its label cannot break across the paragraph's lines;
221
+ * here the text sits in a native <label>, an inline box that wraps and copies
222
+ * with the prose, and the label forwards a press anywhere on the run to the
223
+ * <button> it holds, which takes its accessible name from the label. Use
224
+ * `Button size="inline"` for a short action.
225
+ */
226
+ const ButtonTextRun = ({ children, className, disabled, ...props }) => /* @__PURE__ */ jsxs("label", {
227
+ className: cn(BUTTON_TEXT_RUN_CLASS, className),
228
+ "data-slot": "button-text-run",
229
+ children: [/* @__PURE__ */ jsx(Button, {
230
+ ...props,
231
+ className: BUTTON_TEXT_RUN_TARGET_CLASS,
232
+ disabled,
233
+ focusRing: "outside",
234
+ icon: void 0,
235
+ loading: false,
236
+ render: void 0,
237
+ selected: false,
238
+ shape: "box",
239
+ size: void 0,
240
+ tooltip: void 0,
241
+ variant: BUTTON_PLAIN_VARIANT
242
+ }), children]
243
+ });
162
244
  //#endregion
163
- export { BUTTON_CONTENT, BUTTON_SIZE_CONTENT, Button, buttonAccessibleDisabledClass, buttonVariants };
245
+ export { BUTTON_CONTENT, BUTTON_SIZE_CONTENT, Button, ButtonTextRun, buttonAccessibleDisabledClass, buttonVariants };
@@ -0,0 +1,17 @@
1
+ import { ButtonSize, ButtonVariant } from "./button.shared.js";
2
+ //#region src/components/button.native-classes.d.ts
3
+ type NativeButtonClassNamesOptions = {
4
+ className?: string | undefined;
5
+ inactive: boolean;
6
+ selected: boolean;
7
+ size?: ButtonSize | null | undefined;
8
+ variant?: ButtonVariant | null | undefined;
9
+ };
10
+ /** The Pressable's and the label's classes, and the touch slop, for a button. */
11
+ export declare const nativeButtonClassNames: ({ className, inactive, selected, size, variant }: NativeButtonClassNamesOptions) => {
12
+ label: string;
13
+ pressable: string;
14
+ slop: 0 | 6 | 8 | 12;
15
+ };
16
+ //#endregion
17
+ export {};