@kennycorrea/ui 1.8.4 → 1.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0 — 2026-11-06
4
+
5
+ A searchable `Combobox`, sorting on a phone, table rows that are real links, loading tiles, and form controls with a real touch target.
6
+
7
+ - **`Combobox`**: a selection field with search, for lists too long to scan. One value, or several with `multiple`, shown as removable `Pill`s inside the field. Filtering ignores case and accents ("sao" finds "São"), or `onSearch` hands the text to a server and shows `options` as given; a chosen item keeps its label when a search replaces them. The ARIA combobox pattern: focus stays on the input and `aria-activedescendant` reports the active option, with arrows, Home, End, Enter, Escape, and Backspace on an empty field to remove the last pill. The list is in a portal, so a card's `overflow` cannot clip it, and it works inside a modal `Dialog`, where Escape closes the list first. Below 768px (`sheetBelow`) it is a bottom sheet with the search on top. Takes its label, error and `aria-describedby` from `FormField`. `icon` is decorative, as on `Select`. Built from Themes' own text field classes rather than `TextField.Root`, which has to put the input before the pills, so it matches `Input` at every size. Tests in `test/Combobox.test.tsx`; example in `examples/Comboboxes.tsx`.
8
+ - **`DataTable` sorting in the stacked layout.** The sort buttons live in the `thead`, which the stacked layout hides, so a phone had no way to sort. A compact "Sort by: Customer ↑" control now sits above the cards, beside the select-all. It lists the sortable columns by `header` and follows the headers' three states: a new column is ascending, the same one reverses, and once more clears the sort. Whether it exists is a container query, like the rest of the layout; only how its list opens depends on the viewport (a bottom sheet below 768px). The wide layout is unchanged. New UI strings `sortBy`, `ascending` and `descending`. Tests in `test/DataTable-sort.test.tsx`.
9
+ - **`DataTable` `getRowHref` and `renderLink`**, for lists that open a detail page. The row-header cell becomes a real link (the first cell, with no `isRowHeader` column), so a new tab, copying the address and the URL on hover all work. A plain click on the rest of the row goes through that link, so `renderLink` navigates without a reload; a modifier key or the middle button opens a new tab, at the address the link rendered, so a router's basename comes along. Links and buttons in other cells keep priority, and a drag that selects text does not navigate. `onRowClick` is unchanged, and `getRowHref` wins if both are given. Tests in `test/DataTable-links.test.tsx`.
10
+ - **`loading` on `StatTile` and `StatGroup`.** The value becomes a `Skeleton` on its own element, so it has that element's height at either density and nothing moves when the data comes (measured at 360px: the same group height and the same strip in both states). A known label stays visible; an unknown one is a skeleton too. `loadingCount` draws that many tiles while `items` is empty. The tiles announce nothing; the group has one `role="status"`. A tile that will have a sparkline has to say so while loading, by passing `sparkline`, or the group grows by its 36px. Tests in `test/StatTile-loading.test.tsx`.
11
+ - **Form controls are 44px tall on `pointer: coarse`.** `Input`, `Select`, `Textarea` (one line), `SearchField`, `PasswordInput`, `DateInput`, `DateRangePicker` and `Combobox`, at `md` and `lg`; `sm` fields stay as they are. `Button` at `sm` follows, and so does `md`, which was 40px and would have sat 4px short of the field beside it, as the "Filters" button does in `FilterBar`. Ghost buttons count their padding in that height, which Themes leaves out of it. Icon-only buttons keep their shape. Nothing changes with a mouse. Tests in `test/touch-fields.test.tsx`.
12
+ - **Fixed: choosing a row action also fired `onRowClick`.** The click reached the row by bubbling through React from the menu's portal. `getRowHref` would have inherited it and left the page.
13
+ - **Fixed: `Sheet` and `ActionSheet` dropped focus on `<body>` when they closed.** Radix returns focus to a `Dialog.Trigger` and neither has one, so a keyboard or screen-reader user went back to the top of the page. Focus now returns to whatever opened them, which also fixes `FilterPopover`, `DateInput`, `ActionMenu` and row actions on a phone.
14
+ - **Fixed: `StatGroup`'s scrollable region had no name.** It passed `aria-label={undefined}`, which overrode `ScrollStrip`'s default.
15
+ - **`StatTileProps` and `StatGroupItem` are type aliases now**, because `label` and `value` are only optional on a tile that is loading, and leaving `value` out of any other tile stays a compile error. `interface X extends StatTileProps` no longer compiles; `type X = StatTileProps & { … }` does.
16
+
17
+ ## 1.8.5 — 2026-11-04
18
+
19
+ Icons on `Select` options.
20
+
21
+ - **`icon` on `SelectOption`**: an optional `ReactNode` drawn before the label, in the list item and on the trigger once that option is chosen. It is `aria-hidden`, so the option is named by `label` alone, which stays a required `string`. An option without an icon renders exactly as before.
22
+ - **Themes' `Select` is re-exported from primitives as `RadixSelect`**, the same way `DropdownMenu` is. It is aliased, like `RadixSegmentedControl`, because the `Select` atom already uses the name.
23
+ - Tests in `test/Select-icon.test.tsx`. A combobox is never named from its content, so the trigger keeps its field label's name whatever the icon is (a titled SVG included). The tests also check that the icon is hidden and comes before the label text.
24
+
3
25
  ## 1.8.4 — 2026-10-30
4
26
 
5
27
  A `MentionTextarea` molecule.
package/README.md CHANGED
@@ -239,7 +239,23 @@ const table = useClientTable(orders, columns, { sort, page, pageSize }); // or y
239
239
  - **`DataTable`** is fully controlled: it never sorts, pages or selects by itself. It responds to
240
240
  its own width. `low`-priority columns hide below 640px of table width and `medium` ones below
241
241
  480px. Below `stackBelow` (`sm` 480 / `md` 640, the default / `lg` 800 / `false`) each row
242
- becomes a labelled card. `state` covers `loading`, `empty` and `error` inside the table.
242
+ becomes a labelled card, and a compact "Sort by" control above the cards stands in for the
243
+ hidden headers, with the same three states: ascending, descending, none. That is a container
244
+ query too; the list inside it opens as a dropdown, or a bottom sheet below 768px of viewport.
245
+ `state` covers `loading`, `empty` and `error` inside the table.
246
+ - **Rows that open a page:** give `DataTable` `getRowHref`, and `renderLink` for your router. The
247
+ row-header cell becomes a real link, so a new tab, copying the address and the URL on hover all
248
+ work, and clicking the rest of the row follows that link. A modifier key or the middle button
249
+ anywhere on the row opens a new tab; links and buttons in other cells keep their own click.
250
+ `onRowClick` stays for rows that do something else, and `getRowHref` wins if both are set.
251
+
252
+ ```tsx
253
+ <DataTable
254
+ getRowHref={(order) => `/orders/${order.id}`}
255
+ renderLink={(p) => <RouterLink to={p.href} {...p} />}
256
+ {...table}
257
+ />
258
+ ```
243
259
  - **`useClientTable`** / **`sortRows`**: sorting with `Intl.Collator` (numeric, in the provider's
244
260
  `locale`, empty values last) and paging, for data already in the browser.
245
261
  - **`Pagination`**: numbered pages, or "Page 2 of 10" when narrow; range text and rows-per-page.
@@ -248,7 +264,11 @@ const table = useClientTable(orders, columns, { sort, page, pageSize }); // or y
248
264
  - **`EmptyState`**, **`ErrorState`** (`role="alert"`, retry, details) and **`LoadingState`**
249
265
  (`spinner`, `rows`, `cards`).
250
266
  - **`StatGroup`** (StatTiles in a ScrollStrip), **`KeyValueList`** (Radix `DataList`),
251
- **`StatusIndicator`** (a dot and a word) and **`Pill`** (a removable token).
267
+ **`StatusIndicator`** (a dot and a word) and **`Pill`** (a removable token). `loading` on a
268
+ `StatTile` or a `StatGroup` draws the value as a skeleton at its final height, with one
269
+ `role="status"` for the group; `loadingCount` draws that many tiles while `items` is still empty.
270
+ A tile that will have a sparkline needs `sparkline` set while loading, or the group grows by its
271
+ 36px when the data comes: pass the items with `sparkline: true`.
252
272
  - **Templates:** `ListPage`, `DetailPage` (the aside sits beside the main content from 960px and
253
273
  comes after it in the DOM) and `StatusPage` (404/500, outside the shell).
254
274
 
@@ -281,6 +301,22 @@ Full example with 500 rows: [examples/admin/Orders.tsx](examples/admin/Orders.ts
281
301
  (`noValidate`, so only your messages show).
282
302
  - **`SearchField`**: magnifier, clear button, Escape, optional `debounceMs` and a page-wide
283
303
  `shortcut` key.
304
+ - **`Combobox`**: a selection field with search, one value or several (`multiple`, as removable
305
+ pills inside the field). Typing ignores case and accents, so "sao" finds "São". The list is in a
306
+ portal and no card's `overflow` clips it; below 768px it is a bottom sheet with the search on top
307
+ (`sheetBelow`, as on `DateInput`). It takes its label and error from `FormField`.
308
+
309
+ ```tsx
310
+ <FormField label="Markets">
311
+ <Combobox multiple options={countries} value={markets} onValueChange={setMarkets} />
312
+ </FormField>
313
+ ```
314
+
315
+ Options are `{ value, label, detail?, icon? }`. With `onSearch` the options are shown as given,
316
+ for a server search; debounce it yourself, and pass `loading` meanwhile. Arrows, Home, End, Enter
317
+ and Escape work the list and focus never leaves the input; Backspace on an empty field removes
318
+ the last pill. One value is cleared by emptying the text, or by tapping the chosen row in the
319
+ sheet, unless `required`. Example: [examples/Comboboxes.tsx](examples/Comboboxes.tsx).
284
320
  - **`FilterPopover`**: a "Filters (n)" button opening a popover, or a bottom sheet below `sm`.
285
321
  With `onApply` the controls are a draft. **`FilterChips`** is a ChipGroup plus clear-all.
286
322
  **`FilterBar`** lays out search, filters and actions, with active filters as removable pills.
@@ -426,6 +462,9 @@ the stack can clear a bar: `<Toaster position="top-center" offset="var(--navbar-
426
462
  `prefers-reduced-motion`. Our hover styles are inside `@media (hover: hover)`.
427
463
  - `<Button size="touch">` has a 44px minimum height. `<Button iconOnly variant="ghost">` keeps its
428
464
  visual size but gets a 44px hit area on coarse pointers.
465
+ - On coarse pointers `Input`, `Select`, `Textarea`, `SearchField`, `PasswordInput`, `DateInput` and
466
+ `Combobox` are at least 44px tall, and `Button` at `sm` and `md` follows, so a field and a button
467
+ side by side still line up. The `sm` size of a field stays as it is. Nothing changes with a mouse.
429
468
  - `Input`, `Textarea` and `Select` render at 16px on coarse pointers so iOS does not zoom on focus.
430
469
  `inputMode` and `enterKeyHint` on `Input` / `Textarea` pass through to the native element:
431
470
  `<Input type="text" inputMode="decimal" enterKeyHint="done" />`.
@@ -3,6 +3,7 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog';
3
3
  import { Theme } from '@radix-ui/themes';
4
4
  import { minWidth } from '../../hooks/breakpoints.js';
5
5
  import { useMediaQuery } from '../../hooks/useMediaQuery.js';
6
+ import { useReturnFocus } from '../../hooks/useReturnFocus.js';
6
7
  import { useUiStrings } from '../../strings.js';
7
8
  import { cn } from '../../utils/cn.js';
8
9
  import { useThemePortalTarget } from '../../utils/themePortal.js';
@@ -23,11 +24,12 @@ export function ActionSheet({ open, onOpenChange, actions, title, message, cance
23
24
  const strings = useUiStrings();
24
25
  const mdUp = useMediaQuery(minWidth(breakpoint));
25
26
  const { anchorRef, target, resolved } = useThemePortalTarget(container);
27
+ const returnFocus = useReturnFocus(open);
26
28
  const cancel = cancelLabel ?? strings.cancel;
27
29
  const hasHeader = title != null || message != null;
28
30
  const header = hasHeader ? (_jsxs("div", { className: "px-4 py-3 text-center", children: [title != null ? (_jsx(AlertDialog.Title, { className: "m-0 text-sm font-medium text-ink", children: title })) : (_jsx(AlertDialog.Title, { className: "sr-only", children: cancel })), message != null ? (_jsx(AlertDialog.Description, { className: "m-0 mt-0.5 text-sm text-ink-muted", children: message })) : null] })) : null;
29
31
  const rows = actions.map((a) => (_jsxs(AlertDialog.Action, { onClick: a.onSelect, "data-tone": a.tone ?? 'neutral', className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center gap-2 border-t border-hairline px-4 text-base', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]', TONE[a.tone ?? 'neutral']), children: [a.icon ? _jsx("span", { "aria-hidden": "true", children: a.icon }) : null, a.label] }, a.label)));
30
32
  const cancelButton = (_jsx(AlertDialog.Cancel, { className: cn('flex min-h-[var(--tap-min,44px)] w-full items-center justify-center px-4 text-base font-medium text-ink', 'outline-none hover:bg-[var(--gray-a3)] focus-visible:bg-[var(--gray-a3)] active:bg-[var(--gray-a4)]'), children: cancel }));
31
- const body = mdUp ? (_jsxs(AlertDialog.Content, { "data-presentation": "dialog", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet-dialog fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-sheet text-ink shadow-2xl outline-none ring-1 ring-hairline", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows }), _jsx("div", { className: "border-t border-hairline", children: cancelButton })] })) : (_jsxs(AlertDialog.Content, { "data-presentation": "sheet", ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-lg flex-col gap-2 px-2 pb-[max(0.5rem,var(--safe-bottom,0px))] outline-none", children: [_jsxs("div", { className: "overflow-hidden rounded-xl bg-grouped", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows })] }), _jsx("div", { className: "overflow-hidden rounded-xl bg-grouped", children: cancelButton })] }));
33
+ const body = mdUp ? (_jsxs(AlertDialog.Content, { "data-presentation": "dialog", onCloseAutoFocus: returnFocus, ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet-dialog fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl bg-sheet text-ink shadow-2xl outline-none ring-1 ring-hairline", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows }), _jsx("div", { className: "border-t border-hairline", children: cancelButton })] })) : (_jsxs(AlertDialog.Content, { "data-presentation": "sheet", onCloseAutoFocus: returnFocus, ...(message == null ? { 'aria-describedby': undefined } : {}), className: "ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-lg flex-col gap-2 px-2 pb-[max(0.5rem,var(--safe-bottom,0px))] outline-none", children: [_jsxs("div", { className: "overflow-hidden rounded-xl bg-grouped", children: [header, _jsx("div", { className: cn(!hasHeader && '[&>*:first-child]:border-t-0'), children: rows })] }), _jsx("div", { className: "overflow-hidden rounded-xl bg-grouped", children: cancelButton })] }));
32
34
  return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), resolved ? (_jsx(AlertDialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsx(AlertDialog.Portal, { container: target ?? undefined, children: _jsxs(Theme, { hasBackground: false, children: [_jsx(AlertDialog.Overlay, { className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), body] }) }) })) : null] }));
33
35
  }
@@ -6,7 +6,8 @@ import { cn } from '../../utils/cn.js';
6
6
  * `inputMode` and `enterKeyHint` pass straight through to the `<input>` (they are native
7
7
  * attributes), and are worth setting on mobile: `inputMode="numeric"` / `"decimal"` / `"tel"` /
8
8
  * `"email"` pick the keyboard, `enterKeyHint="next" | "search" | "send" | "done"` labels the
9
- * return key. On coarse pointers the text is never below 16px, or iOS zooms the page on focus.
9
+ * return key. On coarse pointers the text is never below 16px, or iOS zooms the page on focus,
10
+ * and every size but `sm` is at least 44px tall.
10
11
  */
11
12
  const SIZES = { sm: '1', md: '2', lg: '3' };
12
13
  export const Input = forwardRef(function Input({ size = 'md', invalid = false, className, ...props }, ref) {
@@ -1,6 +1,13 @@
1
+ import { type ReactNode } from 'react';
1
2
  export interface SelectOption {
2
3
  value: string;
4
+ /** The option's text, and its accessible name — in the list and on the trigger. */
3
5
  label: string;
6
+ /**
7
+ * Drawn before the label, in the item and on the trigger once chosen. Decorative: it is
8
+ * hidden from assistive tech, so `label` alone names the option.
9
+ */
10
+ icon?: ReactNode;
4
11
  }
5
12
  export interface SelectProps {
6
13
  value?: string;
@@ -12,5 +12,5 @@ import { cn } from '../../utils/cn.js';
12
12
  */
13
13
  export const Select = forwardRef(function Select({ value, onValueChange, options, placeholder, invalid = false, disabled, required, id, name, className, ...props }, ref) {
14
14
  const strings = useUiStrings();
15
- return (_jsxs(ThemedSelect.Root, { value: value, onValueChange: onValueChange, disabled: disabled, required: required, name: name, children: [_jsx(ThemedSelect.Trigger, { ref: ref, id: id, placeholder: placeholder ?? strings.select, color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('ui-select-trigger w-full', className), ...props }), _jsx(ThemedSelect.Content, { position: "popper", children: options.map((option) => (_jsx(ThemedSelect.Item, { value: option.value, children: option.label }, option.value))) })] }));
15
+ return (_jsxs(ThemedSelect.Root, { value: value, onValueChange: onValueChange, disabled: disabled, required: required, name: name, children: [_jsx(ThemedSelect.Trigger, { ref: ref, id: id, placeholder: placeholder ?? strings.select, color: invalid ? 'red' : undefined, "aria-invalid": invalid || undefined, className: cn('ui-select-trigger w-full', className), ...props }), _jsx(ThemedSelect.Content, { position: "popper", children: options.map((option) => (_jsx(ThemedSelect.Item, { value: option.value, children: option.icon == null ? (option.label) : (_jsxs("span", { className: "ui-select-option inline-flex items-center gap-2", children: [_jsx("span", { "aria-hidden": "true", className: "inline-flex shrink-0", children: option.icon }), _jsx("span", { children: option.label })] })) }, option.value))) })] }));
16
16
  });
@@ -52,9 +52,9 @@ export interface SheetProps {
52
52
  /**
53
53
  * A native-feeling bottom sheet on Radix Dialog.
54
54
  *
55
- * Radix owns what must not be reimplemented: the focus trap and its return on close, Escape,
56
- * the scrim, `aria-modal`, and the page behind being hidden from assistive technology. This adds
57
- * the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
55
+ * Radix owns what must not be reimplemented: the focus trap, Escape, the scrim, `aria-modal`, and
56
+ * the page behind being hidden from assistive technology. Focus returns to whatever opened the
57
+ * sheet, which Radix only does for a `Dialog.Trigger` and this has none. This adds the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
58
58
  * its top and only then hands the gesture to the sheet, a footer that stays above the safe area
59
59
  * and the keyboard, and an optional iOS-style scaled page behind the `large` detent.
60
60
  *
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as RadixDialog from '@radix-ui/react-dialog';
3
3
  import { Theme } from '@radix-ui/themes';
4
4
  import { useCallback, useEffect, useRef, useState, } from 'react';
5
+ import { useReturnFocus } from '../../hooks/useReturnFocus.js';
5
6
  import { useUiStrings } from '../../strings.js';
6
7
  import { cn } from '../../utils/cn.js';
7
8
  import { useThemePortalTarget } from '../../utils/themePortal.js';
@@ -60,9 +61,9 @@ function useVisibleViewport(active, contentRef) {
60
61
  /**
61
62
  * A native-feeling bottom sheet on Radix Dialog.
62
63
  *
63
- * Radix owns what must not be reimplemented: the focus trap and its return on close, Escape,
64
- * the scrim, `aria-modal`, and the page behind being hidden from assistive technology. This adds
65
- * the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
64
+ * Radix owns what must not be reimplemented: the focus trap, Escape, the scrim, `aria-modal`, and
65
+ * the page behind being hidden from assistive technology. Focus returns to whatever opened the
66
+ * sheet, which Radix only does for a `Dialog.Trigger` and this has none. This adds the sheet: a drag handle, detents, velocity-based dismissal, a body that scrolls until it is at
66
67
  * its top and only then hands the gesture to the sheet, a footer that stays above the safe area
67
68
  * and the keyboard, and an optional iOS-style scaled page behind the `large` detent.
68
69
  *
@@ -80,6 +81,7 @@ export function Sheet({ open, onOpenChange, title, description, children, footer
80
81
  const rootRef = useRef(null);
81
82
  const { anchorRef, target, resolved } = useThemePortalTarget(container);
82
83
  const vv = useVisibleViewport(open, contentRef);
84
+ const returnFocus = useReturnFocus(open);
83
85
  const ladder = [...new Set([...(detents ?? []), detent])].sort((a, b) => RANK[a] - RANK[b]);
84
86
  const index = ladder.indexOf(detent);
85
87
  const higher = ladder[index + 1];
@@ -245,7 +247,7 @@ export function Sheet({ open, onOpenChange, title, description, children, footer
245
247
  : (e) => {
246
248
  e.preventDefault();
247
249
  };
248
- const content = (_jsxs("div", { ref: rootRef, "data-sheet-root": "", className: "contents", children: [_jsx(RadixDialog.Overlay, { "data-testid": "sheet-scrim", className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), _jsxs(RadixDialog.Content, { ref: contentRef, "data-detent": detent, "data-dragging": dragging ? '' : undefined, ...(description ? {} : { 'aria-describedby': undefined }), onEscapeKeyDown: dismissGuard, onPointerDownOutside: dismissGuard, onInteractOutside: dismissGuard,
250
+ const content = (_jsxs("div", { ref: rootRef, "data-sheet-root": "", className: "contents", children: [_jsx(RadixDialog.Overlay, { "data-testid": "sheet-scrim", className: "ui-sheet-scrim fixed inset-0 z-40 bg-scrim" }), _jsxs(RadixDialog.Content, { ref: contentRef, "data-detent": detent, "data-dragging": dragging ? '' : undefined, ...(description ? {} : { 'aria-describedby': undefined }), onEscapeKeyDown: dismissGuard, onPointerDownOutside: dismissGuard, onInteractOutside: dismissGuard, onCloseAutoFocus: returnFocus,
249
251
  // The sheet moves focus to its first control; for a sheet that is the handle or close
250
252
  // button, which is correct, so Radix's default is kept.
251
253
  style: style, className: cn('ui-sheet fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full flex-col overflow-hidden', 'rounded-t-2xl bg-sheet text-ink shadow-2xl outline-none', maxWidthClassName, className), children: [_jsxs("div", { className: "touch-none select-none px-5 pt-2", onPointerDown: (e) => begin(e, false), ...dragHandlers, children: [cycles ? (_jsx("button", { type: "button", "aria-label": raising ? (expandLabel ?? strings.expand) : (collapseLabel ?? strings.collapse), onClick: onHandleClick, className: "mx-auto flex h-6 w-16 cursor-grab items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-brand-700 active:cursor-grabbing", children: _jsx("span", { "aria-hidden": "true", className: "h-1.5 w-10 rounded-full bg-hairline" }) })) : (_jsx("div", { "aria-hidden": "true", className: "mx-auto flex h-6 w-16 items-center justify-center", children: _jsx("span", { className: "h-1.5 w-10 rounded-full bg-hairline" }) })), _jsxs("div", { className: "flex items-start gap-3 pb-3 pt-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(RadixDialog.Title, { className: "m-0 text-base font-medium text-brand-900", children: title }), description ? (_jsx(RadixDialog.Description, { className: "m-0 mt-1 text-sm text-ink-muted", children: description })) : null] }), showCloseButton && dismissible ? (_jsx(RadixDialog.Close, { "aria-label": closeLabel ?? strings.close, className: "-mr-1 flex size-8 shrink-0 items-center justify-center rounded-full text-ink-muted hover:bg-brand-700/8 focus-visible:outline-2 focus-visible:outline-brand-700", children: _jsx("svg", { "aria-hidden": "true", width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3.5 3.5l8 8m0-8l-8 8", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) }) })) : null] })] }), _jsx("div", { ref: bodyRef, "data-sheet-body": "", className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-4", onPointerDown: (e) => begin(e, true), ...dragHandlers, children: children }), footer ? (_jsx("div", { "data-sheet-footer": "", className: "flex shrink-0 justify-end gap-2 border-t border-hairline px-5 pt-3",
@@ -12,10 +12,7 @@ export interface StatDelta {
12
12
  /** Spoken context for the change, e.g. "versus last month". Visually hidden. */
13
13
  label?: string;
14
14
  }
15
- export interface StatTileProps {
16
- label: ReactNode;
17
- /** Already formatted; rendered with tabular numerals so a row of tiles lines up. */
18
- value: ReactNode;
15
+ interface StatTileOptions {
19
16
  delta?: StatDelta;
20
17
  /** A small chart, rendered under the value in a fixed-height slot. */
21
18
  sparkline?: ReactNode;
@@ -28,9 +25,26 @@ export interface StatTileProps {
28
25
  density?: Responsive<CardDensity>;
29
26
  className?: string;
30
27
  }
28
+ /** `label` and `value` may only be missing from a tile that says it is loading. */
29
+ export type StatTileProps = StatTileOptions & ({
30
+ label: ReactNode;
31
+ /** Already formatted; rendered with tabular numerals so a row of tiles lines up. */
32
+ value: ReactNode;
33
+ loading?: false;
34
+ } | {
35
+ /** Shown while loading if already known, otherwise a skeleton of its height. */
36
+ label?: ReactNode;
37
+ value?: ReactNode;
38
+ /**
39
+ * Draws the value as a skeleton at its final height, so nothing moves when it arrives.
40
+ * Silent on its own: `StatGroup` announces once for the group.
41
+ */
42
+ loading: boolean;
43
+ });
31
44
  /**
32
45
  * A compact KPI tile: label, value, optional delta and sparkline. Built on `Card`, so `variant`
33
46
  * and `density` mean what they mean there. With `href` or `onClick` the whole tile is the target
34
47
  * and a chevron says so.
35
48
  */
36
- export declare function StatTile({ label, value, delta, sparkline, href, linkProps, onClick, variant, density, className, }: StatTileProps): import("react").JSX.Element;
49
+ export declare function StatTile({ label, value, delta, sparkline, href, linkProps, onClick, variant, density, loading, className, }: StatTileProps): import("react").JSX.Element;
50
+ export {};
@@ -2,16 +2,22 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon } from '@radix-ui/react-icons';
3
3
  import { cn } from '../../utils/cn.js';
4
4
  import { Card } from '../Card/index.js';
5
+ import { Skeleton } from '../Skeleton/Skeleton.js';
5
6
  const ARROWS = { up: ArrowUpIcon, down: ArrowDownIcon };
7
+ /** The real element under a skeleton: same class, same line box, so the same height. */
8
+ function Placeholder({ className }) {
9
+ return (_jsx(Skeleton, { children: _jsx("span", { className: className, children: '\u00A0' }) }));
10
+ }
6
11
  /**
7
12
  * A compact KPI tile: label, value, optional delta and sparkline. Built on `Card`, so `variant`
8
13
  * and `density` mean what they mean there. With `href` or `onClick` the whole tile is the target
9
14
  * and a chevron says so.
10
15
  */
11
- export function StatTile({ label, value, delta, sparkline, href, linkProps, onClick, variant, density, className, }) {
16
+ export function StatTile({ label, value, delta, sparkline, href, linkProps, onClick, variant, density, loading = false, className, }) {
12
17
  const interactive = href !== undefined || onClick !== undefined;
13
18
  const direction = delta?.direction ?? 'flat';
14
19
  const Arrow = direction === 'flat' ? null : ARROWS[direction];
15
- const body = (_jsxs(_Fragment, { children: [_jsx("span", { className: "ui-stat-label", children: label }), _jsxs("span", { className: "ui-stat-row", children: [_jsx("span", { className: "ui-stat-value", children: value }), delta && (_jsxs("span", { className: "ui-stat-delta", "data-tone": delta.tone ?? 'neutral', children: [Arrow && _jsx(Arrow, { "aria-hidden": true }), delta.value, delta.label && _jsxs("span", { className: "sr-only", children: [" ", delta.label] })] }))] }), sparkline && (_jsx("span", { className: "ui-stat-spark", "aria-hidden": true, children: sparkline })), interactive && _jsx(ChevronRightIcon, { className: "ui-stat-chevron", "aria-hidden": true })] }));
20
+ const body = (_jsxs(_Fragment, { children: [loading && label == null ? (_jsx(Placeholder, { className: "ui-stat-label" })) : (_jsx("span", { className: "ui-stat-label", children: label })), _jsxs("span", { className: "ui-stat-row", children: [loading ? (_jsx(Placeholder, { className: "ui-stat-value" })) : (_jsx("span", { className: "ui-stat-value", children: value })), delta && !loading && (_jsxs("span", { className: "ui-stat-delta", "data-tone": delta.tone ?? 'neutral', children: [Arrow && _jsx(Arrow, { "aria-hidden": true }), delta.value, delta.label && _jsxs("span", { className: "sr-only", children: [" ", delta.label] })] }))] }), sparkline &&
21
+ (loading ? (_jsx(Placeholder, { className: "ui-stat-spark" })) : (_jsx("span", { className: "ui-stat-spark", "aria-hidden": true, children: sparkline }))), interactive && _jsx(ChevronRightIcon, { className: "ui-stat-chevron", "aria-hidden": true })] }));
16
22
  return (_jsx(Card, { variant: variant, density: density, className: cn('ui-stat-card', className), children: href !== undefined ? (_jsx("a", { ...linkProps, href: href, className: "ui-stat", "data-interactive": true, onClick: onClick, children: body })) : onClick ? (_jsx("button", { type: "button", className: "ui-stat", "data-interactive": true, onClick: onClick, children: body })) : (_jsx("div", { className: "ui-stat", children: body })) }));
17
23
  }
@@ -3,8 +3,8 @@ import type { AnchorHTMLAttributes, MouseEventHandler, ReactElement, ReactNode }
3
3
  * What a `render` function receives to build a router link. Spread it onto the element you
4
4
  * return, e.g. `render={(p) => <RouterLink to={p.href} {...p} />}`.
5
5
  *
6
- * Every component in this library that navigates (TextLink, NavItem, Breadcrumbs, PageTabs)
7
- * takes the same `render`, so the library never imports a router.
6
+ * Every component in this library that navigates (TextLink, NavItem, Breadcrumbs, PageTabs,
7
+ * and DataTable as `renderLink`) takes the same function, so the library never imports a router.
8
8
  */
9
9
  export interface LinkRenderProps {
10
10
  href: string;
@@ -2,7 +2,7 @@ import { TextArea } from '@radix-ui/themes';
2
2
  import { type ComponentPropsWithoutRef } from 'react';
3
3
  /**
4
4
  * `inputMode` and `enterKeyHint` pass through to the `<textarea>`. On coarse pointers the text is
5
- * never below 16px, or iOS zooms the page on focus.
5
+ * never below 16px, or iOS zooms the page on focus, and `rows={1}` is at least 44px tall.
6
6
  */
7
7
  export interface TextareaProps extends Omit<ComponentPropsWithoutRef<typeof TextArea>, 'size' | 'color'> {
8
8
  invalid?: boolean;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * An `onCloseAutoFocus` that puts focus back on whatever had it when `open` went true.
3
+ *
4
+ * Radix hands focus back to a `Dialog.Trigger` and to nothing else, so a dialog opened by state
5
+ * leaves focus on `<body>`, which for a keyboard or screen-reader user is the top of the page.
6
+ *
7
+ * It reads the element during render because that is the last moment it is still the opener:
8
+ * content mounting in the same commit can take focus before any effect here would run.
9
+ */
10
+ export declare function useReturnFocus(open: boolean): (event: Event) => void;
@@ -0,0 +1,22 @@
1
+ import { useCallback, useRef } from 'react';
2
+ /**
3
+ * An `onCloseAutoFocus` that puts focus back on whatever had it when `open` went true.
4
+ *
5
+ * Radix hands focus back to a `Dialog.Trigger` and to nothing else, so a dialog opened by state
6
+ * leaves focus on `<body>`, which for a keyboard or screen-reader user is the top of the page.
7
+ *
8
+ * It reads the element during render because that is the last moment it is still the opener:
9
+ * content mounting in the same commit can take focus before any effect here would run.
10
+ */
11
+ export function useReturnFocus(open) {
12
+ const opener = useRef(null);
13
+ const wasOpen = useRef(false);
14
+ if (open && !wasOpen.current && typeof document !== 'undefined') {
15
+ opener.current = document.activeElement;
16
+ }
17
+ wasOpen.current = open;
18
+ return useCallback((event) => {
19
+ event.preventDefault();
20
+ opener.current?.focus();
21
+ }, []);
22
+ }
@@ -0,0 +1,72 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type InputSize } from '../../atoms/Input/Input.js';
3
+ import { type Breakpoint } from '../../hooks/breakpoints.js';
4
+ export interface ComboboxOption {
5
+ value: string;
6
+ /** The option's text, its accessible name, and what typing is matched against. */
7
+ label: string;
8
+ /** Secondary text, shown muted beside the label. */
9
+ detail?: ReactNode;
10
+ /**
11
+ * Drawn before the label, in the list and once chosen. Decorative: it is hidden from assistive
12
+ * tech, so `label` alone names the option.
13
+ */
14
+ icon?: ReactNode;
15
+ }
16
+ interface ComboboxOptions {
17
+ options: ComboboxOption[];
18
+ /** Shown while nothing is chosen, and the sheet's title. Default: the `select` UI string. */
19
+ placeholder?: string;
20
+ /** Shown when nothing matches. Default: the `noResults` UI string. */
21
+ emptyText?: ReactNode;
22
+ /** A spinner in the field, and the `loading` UI string in place of an empty list. */
23
+ loading?: boolean;
24
+ /**
25
+ * For a server search: called as the text changes, and `options` are shown as given rather
26
+ * than filtered here, since the server may match on more than the label. Called with `''` when
27
+ * a search ends without a choice, so the next opening does not show its leftovers.
28
+ */
29
+ onSearch?: (query: string) => void;
30
+ invalid?: boolean;
31
+ /** Below this the list is a bottom Sheet with the search on top. Default `sm` (768px). */
32
+ sheetBelow?: Breakpoint | false;
33
+ size?: InputSize;
34
+ disabled?: boolean;
35
+ required?: boolean;
36
+ id?: string;
37
+ 'aria-describedby'?: string;
38
+ 'aria-invalid'?: boolean | 'true' | 'false';
39
+ 'aria-label'?: string;
40
+ 'aria-labelledby'?: string;
41
+ className?: string;
42
+ }
43
+ export interface ComboboxSingleProps extends ComboboxOptions {
44
+ multiple?: false;
45
+ /** `null` is nothing chosen. Leaving it out makes the component hold its own value. */
46
+ value?: string | null;
47
+ defaultValue?: string | null;
48
+ onValueChange?: (value: string | null) => void;
49
+ }
50
+ export interface ComboboxMultipleProps extends ComboboxOptions {
51
+ /** Chosen items become removable pills, and the list stays open between choices. */
52
+ multiple: true;
53
+ value?: string[];
54
+ defaultValue?: string[];
55
+ onValueChange?: (value: string[]) => void;
56
+ }
57
+ export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
58
+ /**
59
+ * A selection field with search: one value or several, from a list long enough that scanning it
60
+ * is the problem. The ARIA combobox pattern, with focus staying on the input and the active option
61
+ * reported through `aria-activedescendant`.
62
+ *
63
+ * The list is in a portal, so a card's `overflow` cannot clip it, and floats under the field. Below
64
+ * `sheetBelow` the field is a button that opens a bottom sheet with the search on top, because a
65
+ * list floating over the keyboard on a phone leaves no room for the list. Works inside `FormField`,
66
+ * which names it.
67
+ *
68
+ * With one value, emptying the text and leaving clears the choice, as does tapping the chosen row
69
+ * in the sheet. A `required` field keeps its value in both cases.
70
+ */
71
+ export declare const Combobox: import("react").ForwardRefExoticComponent<ComboboxProps & import("react").RefAttributes<HTMLElement>>;
72
+ export {};
@@ -0,0 +1,260 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { CheckIcon, ChevronDownIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
3
+ import { TextField, Popover as ThemedPopover, Spinner as ThemedSpinner } from '@radix-ui/themes';
4
+ import { forwardRef, useCallback, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
5
+ import { Input } from '../../atoms/Input/Input.js';
6
+ import { Pill } from '../../atoms/Pill/Pill.js';
7
+ import { Sheet } from '../../atoms/Sheet/Sheet.js';
8
+ import { minWidth } from '../../hooks/breakpoints.js';
9
+ import { useControllableState } from '../../hooks/useControllableState.js';
10
+ import { useMediaQuery } from '../../hooks/useMediaQuery.js';
11
+ import { useUiStrings } from '../../strings.js';
12
+ import { cn } from '../../utils/cn.js';
13
+ const SIZES = { sm: '1', md: '2', lg: '3' };
14
+ /** Case and accents fold away, so "sao" finds "São". */
15
+ function folded(text) {
16
+ return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
17
+ }
18
+ function asList(value) {
19
+ if (value === null || value === undefined)
20
+ return [];
21
+ return typeof value === 'string' ? [value] : [...value];
22
+ }
23
+ /**
24
+ * A selection field with search: one value or several, from a list long enough that scanning it
25
+ * is the problem. The ARIA combobox pattern, with focus staying on the input and the active option
26
+ * reported through `aria-activedescendant`.
27
+ *
28
+ * The list is in a portal, so a card's `overflow` cannot clip it, and floats under the field. Below
29
+ * `sheetBelow` the field is a button that opens a bottom sheet with the search on top, because a
30
+ * list floating over the keyboard on a phone leaves no room for the list. Works inside `FormField`,
31
+ * which names it.
32
+ *
33
+ * With one value, emptying the text and leaving clears the choice, as does tapping the chosen row
34
+ * in the sheet. A `required` field keeps its value in both cases.
35
+ */
36
+ export const Combobox = forwardRef(function Combobox(props, ref) {
37
+ const { options, multiple = false, placeholder, emptyText, loading = false, onSearch, invalid, sheetBelow = 'sm', size = 'md', disabled = false, required, id, 'aria-describedby': describedBy, 'aria-invalid': ariaInvalid, 'aria-label': ariaLabel, 'aria-labelledby': labelledBy, className, } = props;
38
+ const strings = useUiStrings();
39
+ const wide = useMediaQuery(minWidth(sheetBelow || 'xs'));
40
+ const sheet = sheetBelow !== false && !wide;
41
+ const isInvalid = invalid || ariaInvalid === true || ariaInvalid === 'true';
42
+ const autoId = useId();
43
+ const listId = `${id ?? autoId}-listbox`;
44
+ const optionId = (index) => `${listId}-${index}`;
45
+ const placeholderText = placeholder ?? strings.select;
46
+ const fieldRef = useRef(null);
47
+ const inputRef = useRef(null);
48
+ const triggerRef = useRef(null);
49
+ useImperativeHandle(ref, () => (sheet ? triggerRef.current : inputRef.current), [
50
+ sheet,
51
+ ]);
52
+ const [selected, setSelected] = useControllableState({
53
+ value: props.value === undefined ? undefined : asList(props.value),
54
+ defaultValue: asList(props.defaultValue),
55
+ onChange: (next) => {
56
+ if (props.multiple)
57
+ props.onValueChange?.(next);
58
+ else
59
+ props.onValueChange?.(next[0] ?? null);
60
+ },
61
+ });
62
+ // A server search replaces `options`, and a chosen item must not lose its label when it does.
63
+ const seen = useRef(new Map());
64
+ for (const option of options)
65
+ seen.current.set(option.value, option);
66
+ const optionFor = (value) => seen.current.get(value) ?? { value, label: value };
67
+ /** With one value, what the field shows while nothing is being typed. */
68
+ const chosen = !multiple && selected.length > 0 ? optionFor(selected[0]) : undefined;
69
+ const [open, setOpen] = useState(false);
70
+ /** `null` until something is typed: the field shows the choice and the list is unfiltered. */
71
+ const [query, setQuery] = useState(null);
72
+ const [active, setActive] = useState(0);
73
+ const [announcement, setAnnouncement] = useState('');
74
+ const needle = folded(query ?? '');
75
+ const shown = useMemo(() => (onSearch || !needle ? options : options.filter((o) => folded(o.label).includes(needle))), [options, needle, onSearch]);
76
+ // Hovering must not scroll the list under the pointer, so only the keyboard asks for this.
77
+ const reveal = useRef(false);
78
+ const revealActive = useCallback((el) => {
79
+ if (!el || !reveal.current)
80
+ return;
81
+ reveal.current = false;
82
+ el.scrollIntoView?.({ block: 'nearest' });
83
+ }, []);
84
+ const moveTo = (index) => {
85
+ reveal.current = true;
86
+ setActive(index);
87
+ };
88
+ const openList = (at) => {
89
+ if (disabled)
90
+ return;
91
+ const current = multiple ? -1 : shown.findIndex((o) => o.value === selected[0]);
92
+ moveTo(at ?? Math.max(current, 0));
93
+ setOpen(true);
94
+ };
95
+ const endSearch = () => {
96
+ if (query)
97
+ onSearch?.('');
98
+ setQuery(null);
99
+ };
100
+ const close = ({ clearIfEmptied = false } = {}) => {
101
+ setOpen(false);
102
+ if (clearIfEmptied && !multiple && !required && query === '' && selected.length > 0) {
103
+ setSelected([]);
104
+ }
105
+ endSearch();
106
+ };
107
+ const pick = (option) => {
108
+ const already = selected.includes(option.value);
109
+ if (multiple) {
110
+ if (query)
111
+ setActive(0);
112
+ setSelected(already ? selected.filter((v) => v !== option.value) : [...selected, option.value]);
113
+ }
114
+ else {
115
+ setOpen(false);
116
+ if (!already)
117
+ setSelected([option.value]);
118
+ else if (sheet && !required)
119
+ setSelected([]);
120
+ }
121
+ endSearch();
122
+ };
123
+ const remove = (value) => {
124
+ const next = selected.filter((v) => v !== value);
125
+ setSelected(next);
126
+ setAnnouncement(strings.selectedCount(next.length));
127
+ // The button that was just used is about to go, and focus must not go with it.
128
+ if (!open)
129
+ (sheet ? triggerRef : inputRef).current?.focus();
130
+ };
131
+ const handleChange = (event) => {
132
+ const text = event.target.value;
133
+ // After a pick the caret sits behind the label, and what comes next is a new search.
134
+ const shownLabel = query === null ? chosen?.label : undefined;
135
+ const next = shownLabel && text.startsWith(shownLabel) ? text.slice(shownLabel.length) : text;
136
+ setQuery(next);
137
+ moveTo(0);
138
+ setOpen(true);
139
+ onSearch?.(next);
140
+ };
141
+ const handleKeyDown = (event) => {
142
+ const last = shown.length - 1;
143
+ switch (event.key) {
144
+ case 'ArrowDown':
145
+ case 'ArrowUp': {
146
+ event.preventDefault();
147
+ const down = event.key === 'ArrowDown';
148
+ if (!open)
149
+ openList(down ? undefined : last);
150
+ else if (shown.length > 0)
151
+ moveTo((active + (down ? 1 : -1) + shown.length) % shown.length);
152
+ break;
153
+ }
154
+ case 'Home':
155
+ case 'End':
156
+ if (!open || last < 0 || event.shiftKey)
157
+ return;
158
+ event.preventDefault();
159
+ moveTo(event.key === 'Home' ? 0 : last);
160
+ break;
161
+ case 'Enter':
162
+ // Closed, it belongs to the form.
163
+ if (!open)
164
+ return;
165
+ event.preventDefault();
166
+ if (shown[active])
167
+ pick(shown[active]);
168
+ break;
169
+ case 'Escape':
170
+ // The sheet is a dialog and already answers Escape itself.
171
+ if (!open || sheet)
172
+ return;
173
+ event.preventDefault();
174
+ event.stopPropagation();
175
+ close();
176
+ break;
177
+ case 'Backspace':
178
+ if (multiple && !event.currentTarget.value && selected.length > 0) {
179
+ remove(selected[selected.length - 1]);
180
+ }
181
+ break;
182
+ default:
183
+ }
184
+ };
185
+ const handleFieldMouseDown = (event) => {
186
+ // Keeps focus in the input while a pill or the chevron is pressed.
187
+ if (!sheet && event.target !== inputRef.current)
188
+ event.preventDefault();
189
+ };
190
+ const handleFieldClick = (event) => {
191
+ if (disabled)
192
+ return;
193
+ const target = event.target;
194
+ const button = target.closest('button');
195
+ if (button && button !== triggerRef.current)
196
+ return;
197
+ if (sheet) {
198
+ // Safari does not focus a tapped button, and the sheet returns focus to what had it.
199
+ triggerRef.current?.focus();
200
+ }
201
+ else {
202
+ inputRef.current?.focus();
203
+ if (open && target.closest('[data-side="right"]')) {
204
+ close();
205
+ return;
206
+ }
207
+ if (!open)
208
+ inputRef.current?.select();
209
+ }
210
+ if (!open)
211
+ openList();
212
+ };
213
+ const comboboxProps = {
214
+ role: 'combobox',
215
+ 'aria-autocomplete': 'list',
216
+ 'aria-haspopup': 'listbox',
217
+ 'aria-expanded': open,
218
+ 'aria-controls': open ? listId : undefined,
219
+ 'aria-activedescendant': open && shown[active] ? optionId(active) : undefined,
220
+ autoComplete: 'off',
221
+ autoCorrect: 'off',
222
+ spellCheck: false,
223
+ onChange: handleChange,
224
+ onKeyDown: handleKeyDown,
225
+ };
226
+ const field = (_jsxs("div", { ref: fieldRef, "data-accent-color": isInvalid ? 'red' : undefined, className: `rt-TextFieldRoot rt-r-size-${SIZES[size]} rt-variant-surface ui-input ui-combobox__field`, onMouseDown: handleFieldMouseDown, onClick: handleFieldClick, children: [_jsxs("div", { className: "ui-combobox__values", "data-pills": multiple && selected.length > 0 ? '' : null, children: [multiple
227
+ ? selected.map((value) => {
228
+ const option = optionFor(value);
229
+ return (_jsx(Pill, { removeLabel: strings.remove(option.label), onRemove: disabled ? undefined : () => remove(value), children: _jsxs("span", { className: "ui-combobox__pill", children: [option.icon == null ? null : _jsx("span", { "aria-hidden": "true", children: option.icon }), option.label] }) }, value));
230
+ })
231
+ : null, chosen?.icon == null ? null : (_jsx("span", { "aria-hidden": "true", className: "ui-combobox__icon", children: chosen.icon })), sheet ? (_jsx("button", { ref: triggerRef, type: "button", role: "combobox", id: id, disabled: disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-required": required || undefined, "aria-invalid": isInvalid || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, "aria-labelledby": labelledBy, className: "ui-combobox__trigger", "data-empty": selected.length === 0 ? '' : undefined, onKeyDown: (event) => {
232
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
233
+ return;
234
+ event.preventDefault();
235
+ event.currentTarget.click();
236
+ }, children: multiple && selected.length > 0 ? (_jsx("span", { className: "ui-sr-only", children: strings.selectedCount(selected.length) })) : ((chosen?.label ?? placeholderText)) })) : (_jsx("input", { ...comboboxProps, ref: inputRef, id: id, className: "rt-reset rt-TextFieldInput", value: query ?? chosen?.label ?? '', placeholder: selected.length > 0 ? undefined : placeholderText, disabled: disabled, "aria-required": required || undefined, "aria-invalid": isInvalid || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, "aria-labelledby": labelledBy, onBlur: () => {
237
+ if (open)
238
+ close({ clearIfEmptied: true });
239
+ } }))] }), _jsx(TextField.Slot, { side: "right", className: "ui-combobox__end", children: loading ? _jsx(ThemedSpinner, { size: "1" }) : _jsx(ChevronDownIcon, { "aria-hidden": "true" }) })] }));
240
+ const list = (_jsxs(_Fragment, { children: [_jsx("div", { id: listId, role: "listbox", tabIndex: -1, "aria-label": strings.suggestions, "aria-multiselectable": multiple || undefined, "aria-busy": loading || undefined, className: "ui-combobox__list", "data-presentation": sheet ? 'sheet' : 'popover', children: shown.map((option, index) => (_jsxs("div", { ref: index === active ? revealActive : undefined, id: optionId(index), role: "option", tabIndex: -1, "aria-selected": selected.includes(option.value), "data-active": index === active ? '' : undefined, className: "ui-combobox__option", onMouseMove: () => setActive(index), onClick: () => pick(option), children: [option.icon == null ? null : (_jsx("span", { "aria-hidden": "true", className: "ui-combobox__option-icon", children: option.icon })), _jsx("span", { className: "ui-combobox__option-label", children: option.label }), ' ', option.detail == null ? null : (_jsx("span", { className: "ui-combobox__option-detail", children: option.detail })), _jsx(CheckIcon, { "aria-hidden": "true", className: "ui-combobox__option-check" })] }, option.value))) }), shown.length === 0 ? (_jsx("div", { role: "status", className: "ui-combobox__message", children: loading ? strings.loading : (emptyText ?? strings.noResults) })) : null] }));
241
+ return (_jsxs("div", { className: cn('ui-combobox', className), children: [sheet ? (_jsxs(_Fragment, { children: [field, _jsxs(Sheet, { open: open, onOpenChange: (next) => {
242
+ if (!next)
243
+ close();
244
+ }, title: ariaLabel ?? placeholderText, defaultDetent: "large", showCloseButton: true, children: [_jsx("div", { className: "ui-combobox__search", children: _jsx(Input, { ...comboboxProps,
245
+ // The sheet exists to be typed into, and Radix leaves focus alone when it finds
246
+ // some already inside.
247
+ autoFocus: true, value: query ?? '', placeholder: strings.search, "aria-label": strings.search, enterKeyHint: "search", children: _jsx(TextField.Slot, { children: _jsx(MagnifyingGlassIcon, { "aria-hidden": "true" }) }) }) }), list] })] })) : (_jsxs(ThemedPopover.Root, { open: open, onOpenChange: (next) => {
248
+ if (!next)
249
+ close({ clearIfEmptied: true });
250
+ }, children: [_jsx(ThemedPopover.Anchor, { virtualRef: fieldRef }), field, _jsx(ThemedPopover.Content
251
+ // Not a dialog: focus stays on the input, and what the popup holds is the listbox.
252
+ , {
253
+ // Not a dialog: focus stays on the input, and what the popup holds is the listbox.
254
+ role: "presentation", sideOffset: 4, collisionPadding: 8, className: "ui-combobox__popup", onOpenAutoFocus: (event) => event.preventDefault(), onCloseAutoFocus: (event) => event.preventDefault(),
255
+ // Escape belongs to the input, which also stops it reaching an enclosing dialog.
256
+ onEscapeKeyDown: (event) => event.preventDefault(), onInteractOutside: (event) => {
257
+ if (fieldRef.current?.contains(event.target))
258
+ event.preventDefault();
259
+ }, onMouseDown: (event) => event.preventDefault(), children: list })] })), multiple ? (_jsx("span", { role: "status", className: "ui-sr-only", children: announcement })) : null] }));
260
+ });
@@ -0,0 +1 @@
1
+ export * from './Combobox.js';
@@ -0,0 +1 @@
1
+ export * from './Combobox.js';
@@ -1,11 +1,18 @@
1
1
  import { type ScrollStripBreakpoint } from '../../atoms/ScrollStrip/ScrollStrip.js';
2
2
  import { type StatTileProps } from '../../atoms/StatTile/StatTile.js';
3
- export interface StatGroupItem extends StatTileProps {
3
+ export type StatGroupItem = StatTileProps & {
4
4
  /** React key. Defaults to the label when it is a string, else the index. */
5
5
  id?: string;
6
- }
6
+ };
7
7
  export interface StatGroupProps {
8
8
  items: StatGroupItem[];
9
+ /** Every tile shows a skeleton in place of its value, and the group says so once. */
10
+ loading?: boolean;
11
+ /**
12
+ * How many tiles to draw while `loading` and `items` is still empty. They hold a label and a
13
+ * value; a sparkline needs the item, because nothing else says the tile will have one.
14
+ */
15
+ loadingCount?: number;
9
16
  /** Grid columns from `gridFrom`. Default: the number of items, at most 4. */
10
17
  columns?: number;
11
18
  /** Below this the tiles scroll sideways; from it they are a grid. Default `md`. */
@@ -17,5 +24,8 @@ export interface StatGroupProps {
17
24
  /**
18
25
  * A row of KPI tiles: a swipeable strip on a phone, a grid from `gridFrom`. A `ScrollStrip` of
19
26
  * `StatTile`s, so both keep their own behaviour and accessibility.
27
+ *
28
+ * While `loading` it is the same strip with the same tiles, so nothing moves when the data comes.
29
+ * The tiles say nothing; the group's one `role="status"` does.
20
30
  */
21
- export declare function StatGroup({ items, columns, gridFrom, 'aria-label': ariaLabel, className, }: StatGroupProps): import("react").JSX.Element;
31
+ export declare function StatGroup({ items, loading, loadingCount, columns, gridFrom, 'aria-label': ariaLabel, className, }: StatGroupProps): import("react").JSX.Element;
@@ -1,10 +1,16 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ScrollStrip } from '../../atoms/ScrollStrip/ScrollStrip.js';
3
3
  import { StatTile } from '../../atoms/StatTile/StatTile.js';
4
+ import { useUiStrings } from '../../strings.js';
4
5
  /**
5
6
  * A row of KPI tiles: a swipeable strip on a phone, a grid from `gridFrom`. A `ScrollStrip` of
6
7
  * `StatTile`s, so both keep their own behaviour and accessibility.
8
+ *
9
+ * While `loading` it is the same strip with the same tiles, so nothing moves when the data comes.
10
+ * The tiles say nothing; the group's one `role="status"` does.
7
11
  */
8
- export function StatGroup({ items, columns, gridFrom = 'md', 'aria-label': ariaLabel, className, }) {
9
- return (_jsx(ScrollStrip, { gridFrom: gridFrom, columns: columns ?? Math.min(items.length, 4), "aria-label": ariaLabel, className: className, children: items.map(({ id, ...tile }, i) => (_jsx(StatTile, { ...tile }, id ?? (typeof tile.label === 'string' ? tile.label : `stat-${i}`)))) }));
12
+ export function StatGroup({ items, loading, loadingCount = 0, columns, gridFrom = 'md', 'aria-label': ariaLabel, className, }) {
13
+ const strings = useUiStrings();
14
+ const placeholders = loading && items.length === 0 ? loadingCount : 0;
15
+ return (_jsxs(_Fragment, { children: [_jsxs(ScrollStrip, { gridFrom: gridFrom, columns: columns ?? Math.min(items.length + placeholders, 4), "aria-label": ariaLabel ?? strings.scrollableRegion, "aria-busy": loading || undefined, className: className, children: [items.map(({ id, ...tile }, i) => (_jsx(StatTile, { ...tile, loading: loading || tile.loading === true }, id ?? (typeof tile.label === 'string' ? tile.label : `stat-${i}`)))), Array.from({ length: placeholders }, (_, i) => `loading-${i}`).map((key) => (_jsx(StatTile, { loading: true }, key)))] }), loading === undefined ? null : (_jsx("span", { role: "status", className: "ui-sr-only", children: loading ? strings.loading : null }))] }));
10
16
  }
@@ -1,6 +1,7 @@
1
1
  export * from './AccentPicker/index.js';
2
2
  export * from './Breadcrumbs/index.js';
3
3
  export * from './Calendar/index.js';
4
+ export * from './Combobox/index.js';
4
5
  export * from './DateInput/index.js';
5
6
  export * from './EmptyState/index.js';
6
7
  export * from './ErrorState/index.js';
@@ -1,6 +1,7 @@
1
1
  export * from './AccentPicker/index.js';
2
2
  export * from './Breadcrumbs/index.js';
3
3
  export * from './Calendar/index.js';
4
+ export * from './Combobox/index.js';
4
5
  export * from './DateInput/index.js';
5
6
  export * from './EmptyState/index.js';
6
7
  export * from './ErrorState/index.js';
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type ActionMenuItem } from '../../atoms/ActionMenu/ActionMenu.js';
3
+ import type { RenderLink } from '../../atoms/TextLink/TextLink.js';
3
4
  import { type PaginationProps } from '../../molecules/Pagination/Pagination.js';
4
5
  import type { DataTableColumn, SortState } from './types.js';
5
6
  export type DataTableState = 'ready' | 'loading' | 'empty' | 'error';
@@ -28,6 +29,15 @@ export interface DataTableProps<Row> {
28
29
  * keyboard and screen-reader users, so the action has a real control.
29
30
  */
30
31
  onRowClick?: (row: Row) => void;
32
+ /**
33
+ * Makes the row a real link: the row-header cell (the first cell, with no `isRowHeader`
34
+ * column) becomes an `<a>`, so a new tab, the address and the URL on hover all work. Clicking
35
+ * the rest of the row follows that link; with a modifier key or the middle button it opens a
36
+ * new tab. Links and buttons in other cells keep their own click. Wins over `onRowClick`.
37
+ */
38
+ getRowHref?: (row: Row) => string;
39
+ /** Builds that link with the app's router. Receives {@link LinkRenderProps}. */
40
+ renderLink?: RenderLink;
31
41
  /** Default `ready`; `empty` is also inferred from zero rows. */
32
42
  state?: DataTableState;
33
43
  /** Replaces the default EmptyState ("No results"). */
@@ -56,8 +66,9 @@ export interface DataTableProps<Row> {
56
66
  * come from a server as easily as from `useClientTable`.
57
67
  *
58
68
  * It responds to its own width (a container query), not the viewport's. Narrow, it drops
59
- * low-priority columns and then stacks each row into a labelled card. Explicit table roles
60
- * keep it a table to screen readers in the stacked layout, where `display: block` would
61
- * otherwise erase that in some browsers.
69
+ * low-priority columns and then stacks each row into a labelled card, with a sort control above
70
+ * the cards in place of the hidden headers. Explicit table roles keep it a table to screen
71
+ * readers in the stacked layout, where `display: block` would otherwise erase that in some
72
+ * browsers.
62
73
  */
63
- export declare function DataTable<Row>({ columns, rows, getRowId, caption, showCaption, getRowLabel, sort, onSortChange, selection, onSelectionChange, rowActions, onRowAction, onRowClick, state, empty, error, loadingRows, pagination, density, stackBelow, stickyFirstColumn, maxHeight, className, }: DataTableProps<Row>): import("react").JSX.Element;
74
+ export declare function DataTable<Row>({ columns, rows, getRowId, caption, showCaption, getRowLabel, sort, onSortChange, selection, onSelectionChange, rowActions, onRowAction, onRowClick, getRowHref, renderLink, state, empty, error, loadingRows, pagination, density, stackBelow, stickyFirstColumn, maxHeight, className, }: DataTableProps<Row>): import("react").JSX.Element;
@@ -4,23 +4,77 @@ import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, DotsHorizontalIcon, } from '
4
4
  import { IconButton, Skeleton as ThemedSkeleton } from '@radix-ui/themes';
5
5
  import { useId } from 'react';
6
6
  import { ActionMenu } from '../../atoms/ActionMenu/ActionMenu.js';
7
+ import { Button } from '../../atoms/Button/Button.js';
7
8
  import { Checkbox } from '../../atoms/Checkbox/Checkbox.js';
8
9
  import { EmptyState } from '../../molecules/EmptyState/EmptyState.js';
9
10
  import { ErrorState } from '../../molecules/ErrorState/ErrorState.js';
10
11
  import { Pagination } from '../../molecules/Pagination/Pagination.js';
11
12
  import { useUiStrings } from '../../strings.js';
12
- const INTERACTIVE = 'a, button, input, select, textarea, [role="checkbox"], [role="menuitem"]';
13
+ const INTERACTIVE = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"]';
14
+ /**
15
+ * Whether a click belongs to the row: not to a control inside it, and not to a portal (the
16
+ * row-actions menu) whose click reached the row by bubbling through React rather than the DOM.
17
+ */
18
+ function hitsRow(e) {
19
+ const target = e.target;
20
+ if (!e.currentTarget.contains(target))
21
+ return false;
22
+ const hit = target.closest(INTERACTIVE);
23
+ return !(hit && e.currentTarget.contains(hit));
24
+ }
25
+ /** The click that ends a drag to select text in the row is not a request to leave the page. */
26
+ function isSelectingText(row) {
27
+ const selection = window.getSelection();
28
+ return !!selection && !selection.isCollapsed && row.contains(selection.anchorNode);
29
+ }
30
+ const rowLinkOf = (row) => row.querySelector('[data-row-link] a');
31
+ /** The address as the link rendered it, so a router's basename comes along. */
32
+ const openInNewTab = (link) => window.open(link.href, '_blank', 'noopener');
33
+ /**
34
+ * The rest of the row goes through the link itself, so a plain click takes the router's path and
35
+ * a modified one opens what the link would have.
36
+ */
37
+ function followRowLink(e) {
38
+ if (!hitsRow(e) || isSelectingText(e.currentTarget))
39
+ return;
40
+ const link = rowLinkOf(e.currentTarget);
41
+ if (!link)
42
+ return;
43
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)
44
+ openInNewTab(link);
45
+ else
46
+ link.click();
47
+ }
48
+ /** Pressing the wheel over plain content would otherwise start autoscroll, not a click. */
49
+ function holdMiddleClick(e) {
50
+ if (e.button === 1 && hitsRow(e))
51
+ e.preventDefault();
52
+ }
53
+ function middleClickRowLink(e) {
54
+ const link = rowLinkOf(e.currentTarget);
55
+ if (e.button === 1 && link && hitsRow(e))
56
+ openInNewTab(link);
57
+ }
58
+ function isSortable(col) {
59
+ return col.sortable ?? !!col.sortValue;
60
+ }
61
+ function sortIcon(direction) {
62
+ if (direction === 'asc')
63
+ return ArrowUpIcon;
64
+ return direction === 'desc' ? ArrowDownIcon : CaretSortIcon;
65
+ }
13
66
  /**
14
67
  * A data table: sortable headers, row selection, row actions, loading/empty/error states and
15
68
  * pagination. Fully controlled: it never sorts, pages or selects on its own, so the data can
16
69
  * come from a server as easily as from `useClientTable`.
17
70
  *
18
71
  * It responds to its own width (a container query), not the viewport's. Narrow, it drops
19
- * low-priority columns and then stacks each row into a labelled card. Explicit table roles
20
- * keep it a table to screen readers in the stacked layout, where `display: block` would
21
- * otherwise erase that in some browsers.
72
+ * low-priority columns and then stacks each row into a labelled card, with a sort control above
73
+ * the cards in place of the hidden headers. Explicit table roles keep it a table to screen
74
+ * readers in the stacked layout, where `display: block` would otherwise erase that in some
75
+ * browsers.
22
76
  */
23
- export function DataTable({ columns, rows, getRowId, caption, showCaption = false, getRowLabel, sort = null, onSortChange, selection, onSelectionChange, rowActions, onRowAction, onRowClick, state = 'ready', empty, error, loadingRows = 5, pagination, density = 'comfortable', stackBelow = 'md', stickyFirstColumn = false, maxHeight, className, }) {
77
+ export function DataTable({ columns, rows, getRowId, caption, showCaption = false, getRowLabel, sort = null, onSortChange, selection, onSelectionChange, rowActions, onRowAction, onRowClick, getRowHref, renderLink, state = 'ready', empty, error, loadingRows = 5, pagination, density = 'comfortable', stackBelow = 'md', stickyFirstColumn = false, maxHeight, className, }) {
24
78
  const strings = useUiStrings();
25
79
  const captionId = useId();
26
80
  const selectAllId = useId();
@@ -28,6 +82,9 @@ export function DataTable({ columns, rows, getRowId, caption, showCaption = fals
28
82
  const hasActions = !!rowActions;
29
83
  const span = columns.length + (selectable ? 1 : 0) + (hasActions ? 1 : 0);
30
84
  const headerColumn = columns.find((c) => c.isRowHeader);
85
+ const linkColumn = getRowHref ? (headerColumn ?? columns[0]) : undefined;
86
+ const sortableColumns = onSortChange ? columns.filter(isSortable) : [];
87
+ const sortedColumn = sortableColumns.find((c) => c.id === sort?.id);
31
88
  const labelOf = (row) => {
32
89
  if (getRowLabel)
33
90
  return getRowLabel(row);
@@ -67,10 +124,24 @@ export function DataTable({ columns, rows, getRowId, caption, showCaption = fals
67
124
  return { id, direction: 'desc' };
68
125
  return null;
69
126
  };
127
+ const directionName = (direction) => direction === 'asc' ? strings.ascending : strings.descending;
128
+ const sortDirection = sortedColumn ? sort?.direction : undefined;
129
+ const SortMenuIcon = sortIcon(sortDirection);
130
+ // Rendered in every layout and hidden by a container query outside the stacked one, which is
131
+ // what keeps it a question of the table's width and not the viewport's.
132
+ const sortControl = sortableColumns.length > 0 && stackBelow ? (_jsx(ActionMenu, { title: strings.sortBy, align: "start", items: sortableColumns.map((col) => {
133
+ const direction = sort?.id === col.id ? sort.direction : undefined;
134
+ const Icon = sortIcon(direction);
135
+ return {
136
+ id: col.id,
137
+ label: direction ? `${col.header}, ${directionName(direction)}` : col.header,
138
+ icon: _jsx(Icon, {}),
139
+ };
140
+ }), onSelect: (id) => onSortChange?.(nextSort(id)), trigger: _jsxs(Button, { variant: "outline", size: "sm", className: "ui-table__sortmenu", children: [_jsxs("span", { className: "ui-table__sortlabel", children: [strings.sortBy, sortedColumn ? `: ${sortedColumn.header}` : null] }), sortDirection ? (_jsxs("span", { className: "ui-sr-only", children: [", ", directionName(sortDirection)] })) : null, _jsx(SortMenuIcon, { "aria-hidden": "true" })] }) })) : null;
70
141
  const header = (_jsx("thead", { role: "rowgroup", children: _jsxs("tr", { role: "row", children: [selectable ? (_jsx("th", { role: "columnheader", scope: "col", "data-col": "select", children: _jsx(Checkbox, { "aria-label": strings.selectAll, checked: allChecked, disabled: ids.length === 0, onCheckedChange: toggleAll }) })) : null, columns.map((col) => {
71
- const sortable = (col.sortable ?? !!col.sortValue) && !!onSortChange;
142
+ const sortable = isSortable(col) && !!onSortChange;
72
143
  const sorted = sort?.id === col.id ? sort.direction : undefined;
73
- const Icon = sorted === 'asc' ? ArrowUpIcon : sorted === 'desc' ? ArrowDownIcon : CaretSortIcon;
144
+ const Icon = sortIcon(sorted);
74
145
  return (_jsx("th", { role: "columnheader", scope: "col", "data-align": col.align, "data-priority": col.priority, style: col.width ? { inlineSize: col.width } : undefined, "aria-sort": sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined, children: sortable ? (_jsxs("button", { type: "button", className: "ui-table__sort", "data-sorted": sorted ? '' : undefined, onClick: () => onSortChange?.(nextSort(col.id)), children: [col.header, _jsx(Icon, { "aria-hidden": "true" })] })) : (col.header) }, col.id));
75
146
  }), hasActions ? (_jsx("th", { role: "columnheader", scope: "col", "data-col": "actions", children: _jsx("span", { className: "ui-sr-only", children: strings.moreActions }) })) : null] }) }));
76
147
  let body;
@@ -87,15 +158,27 @@ export function DataTable({ columns, rows, getRowId, caption, showCaption = fals
87
158
  const id = getRowId(row);
88
159
  const label = labelOf(row);
89
160
  const selected = selection?.has(id) ?? false;
161
+ const href = getRowHref?.(row);
162
+ const rowCell = (content) => {
163
+ if (href !== undefined) {
164
+ const link = {
165
+ href,
166
+ className: 'ui-table__rowlink',
167
+ children: content,
168
+ };
169
+ return renderLink ? renderLink(link) : _jsx("a", { ...link });
170
+ }
171
+ if (!onRowClick)
172
+ return content;
173
+ return (_jsx("button", { type: "button", className: "ui-table__rowbutton", onClick: () => onRowClick(row), children: content }));
174
+ };
90
175
  const onClick = onRowClick
91
176
  ? (e) => {
92
- const hit = e.target.closest(INTERACTIVE);
93
- if (hit && e.currentTarget.contains(hit))
94
- return;
95
- onRowClick(row);
177
+ if (hitsRow(e))
178
+ onRowClick(row);
96
179
  }
97
180
  : undefined;
98
- return (_jsxs("tr", { role: "row", "data-selected": selected ? '' : undefined, "data-clickable": onRowClick ? '' : undefined, onClick: onClick, children: [selectable ? (_jsx("td", { role: "cell", "data-col": "select", children: _jsx(Checkbox, { "aria-label": strings.selectRow(label), checked: selected, onCheckedChange: () => toggleOne(id) }) })) : null, columns.map((col) => {
181
+ return (_jsxs("tr", { role: "row", "data-selected": selected ? '' : undefined, "data-clickable": href !== undefined || onRowClick ? '' : undefined, onClick: href !== undefined ? followRowLink : onClick, onMouseDown: href !== undefined ? holdMiddleClick : undefined, onAuxClick: href !== undefined ? middleClickRowLink : undefined, children: [selectable ? (_jsx("td", { role: "cell", "data-col": "select", children: _jsx(Checkbox, { "aria-label": strings.selectRow(label), checked: selected, onCheckedChange: () => toggleOne(id) }) })) : null, columns.map((col) => {
99
182
  const content = col.cell(row);
100
183
  const common = {
101
184
  'data-align': col.align,
@@ -105,16 +188,16 @@ export function DataTable({ columns, rows, getRowId, caption, showCaption = fals
105
188
  : (col.mobileLabel ?? col.header),
106
189
  };
107
190
  if (col.isRowHeader) {
108
- return (_jsx("th", { role: "rowheader", scope: "row", ...common, children: onRowClick ? (_jsx("button", { type: "button", className: "ui-table__rowbutton", onClick: () => onRowClick(row), children: content })) : (content) }, col.id));
191
+ return (_jsx("th", { role: "rowheader", scope: "row", "data-row-link": col === linkColumn ? '' : undefined, ...common, children: rowCell(content) }, col.id));
109
192
  }
110
- return (_jsx("td", { role: "cell", ...common, children: content }, col.id));
193
+ return (_jsx("td", { role: "cell", "data-row-link": col === linkColumn ? '' : undefined, ...common, children: col === linkColumn ? rowCell(content) : content }, col.id));
111
194
  }), hasActions ? (_jsx("td", { role: "cell", "data-col": "actions", children: _jsx(ActionMenu, { items: rowActions(row), onSelect: (actionId) => onRowAction?.(actionId, row), title: label, trigger: _jsx(IconButton, { variant: "ghost", color: "gray", "aria-label": strings.actionsFor(label), children: _jsx(DotsHorizontalIcon, {}) }) }) })) : null] }, id));
112
195
  });
113
196
  }
114
197
  const regionStyle = maxHeight
115
198
  ? { maxBlockSize: maxHeight }
116
199
  : undefined;
117
- return (_jsxs("div", { className: ['ui-table', className].filter(Boolean).join(' '), "data-density": density, "data-stack": stackBelow || undefined, "data-sticky-first": stickyFirstColumn ? '' : undefined, children: [effective === 'loading' ? (_jsx("span", { role: "status", className: "ui-sr-only", children: strings.loading })) : null, selectable && effective === 'ready' ? (_jsxs("label", { className: "ui-table__selectall", htmlFor: selectAllId, children: [_jsx(Checkbox, { id: selectAllId, checked: allChecked, onCheckedChange: toggleAll }), strings.selectAll] })) : null, _jsx("section", { className: "ui-table__region", "aria-labelledby": captionId,
200
+ return (_jsxs("div", { className: ['ui-table', className].filter(Boolean).join(' '), "data-density": density, "data-stack": stackBelow || undefined, "data-sticky-first": stickyFirstColumn ? '' : undefined, children: [effective === 'loading' ? (_jsx("span", { role: "status", className: "ui-sr-only", children: strings.loading })) : null, sortControl || (selectable && effective === 'ready') ? (_jsxs("div", { className: "ui-table__stacktools", children: [selectable && effective === 'ready' ? (_jsxs("label", { className: "ui-table__selectall", htmlFor: selectAllId, children: [_jsx(Checkbox, { id: selectAllId, checked: allChecked, onCheckedChange: toggleAll }), strings.selectAll] })) : null, sortControl] })) : null, _jsx("section", { className: "ui-table__region", "aria-labelledby": captionId,
118
201
  // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrolling region must be reachable by keyboard to be scrolled.
119
202
  tabIndex: 0, style: regionStyle, children: _jsxs("table", { className: "ui-table__table", role: "table", "aria-busy": effective === 'loading' || undefined, children: [_jsx("caption", { id: captionId, className: showCaption ? 'ui-table__caption' : 'ui-sr-only', children: caption }), header, _jsx("tbody", { role: "rowgroup", children: body })] }) }), pagination && effective !== 'loading' ? _jsx(Pagination, { ...pagination }) : null] }));
120
203
  }
@@ -6,4 +6,4 @@
6
6
  * reaches for a `Table` or `Tabs` through this package rather than importing Themes beside it
7
7
  * and drifting from the version the atoms were built against.
8
8
  */
9
- export { AccessibleIcon, AlertDialog, AspectRatio, Blockquote, Box, Callout, CheckboxCards, CheckboxGroup, Code, Container, DataList, DropdownMenu, Em, Flex, Grid, Heading, HoverCard, IconButton, Inset, Kbd, Link, Portal, Quote, Radio, RadioCards, RadioGroup, Reset, ScrollArea, Section, SegmentedControl as RadixSegmentedControl, Slot, Slottable, Strong, Table, TabNav, Tabs, Text, TextField, Theme as ThemeRoot, ThemePanel, VisuallyHidden, } from '@radix-ui/themes';
9
+ export { AccessibleIcon, AlertDialog, AspectRatio, Blockquote, Box, Callout, CheckboxCards, CheckboxGroup, Code, Container, DataList, DropdownMenu, Em, Flex, Grid, Heading, HoverCard, IconButton, Inset, Kbd, Link, Portal, Quote, Radio, RadioCards, RadioGroup, Reset, ScrollArea, Section, SegmentedControl as RadixSegmentedControl, Select as RadixSelect, Slot, Slottable, Strong, Table, TabNav, Tabs, Text, TextField, Theme as ThemeRoot, ThemePanel, VisuallyHidden, } from '@radix-ui/themes';
@@ -6,4 +6,4 @@
6
6
  * reaches for a `Table` or `Tabs` through this package rather than importing Themes beside it
7
7
  * and drifting from the version the atoms were built against.
8
8
  */
9
- export { AccessibleIcon, AlertDialog, AspectRatio, Blockquote, Box, Callout, CheckboxCards, CheckboxGroup, Code, Container, DataList, DropdownMenu, Em, Flex, Grid, Heading, HoverCard, IconButton, Inset, Kbd, Link, Portal, Quote, Radio, RadioCards, RadioGroup, Reset, ScrollArea, Section, SegmentedControl as RadixSegmentedControl, Slot, Slottable, Strong, Table, TabNav, Tabs, Text, TextField, Theme as ThemeRoot, ThemePanel, VisuallyHidden, } from '@radix-ui/themes';
9
+ export { AccessibleIcon, AlertDialog, AspectRatio, Blockquote, Box, Callout, CheckboxCards, CheckboxGroup, Code, Container, DataList, DropdownMenu, Em, Flex, Grid, Heading, HoverCard, IconButton, Inset, Kbd, Link, Portal, Quote, Radio, RadioCards, RadioGroup, Reset, ScrollArea, Section, SegmentedControl as RadixSegmentedControl, Select as RadixSelect, Slot, Slottable, Strong, Table, TabNav, Tabs, Text, TextField, Theme as ThemeRoot, ThemePanel, VisuallyHidden, } from '@radix-ui/themes';
package/dist/strings.d.ts CHANGED
@@ -97,6 +97,10 @@ export interface UiStrings {
97
97
  selectRow: (row: string) => string;
98
98
  /** DataTable: the row-actions menu button's name. */
99
99
  actionsFor: (row: string) => string;
100
+ /** DataTable: the sort control that stands in for the header in the stacked layout. */
101
+ sortBy: string;
102
+ ascending: string;
103
+ descending: string;
100
104
  /** BulkActionBar: the toolbar's name, the selection count, and the clear button. */
101
105
  bulkActions: string;
102
106
  selectedCount: (count: number) => string;
package/dist/strings.js CHANGED
@@ -50,6 +50,9 @@ const DEFAULTS = {
50
50
  selectAll: 'Select all rows',
51
51
  selectRow: (row) => `Select ${row}`,
52
52
  actionsFor: (row) => `Actions for ${row}`,
53
+ sortBy: 'Sort by',
54
+ ascending: 'Ascending',
55
+ descending: 'Descending',
53
56
  bulkActions: 'Bulk actions',
54
57
  selectedCount: (count) => `${count} selected`,
55
58
  clearSelection: 'Clear selection',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "1.8.4",
3
+ "version": "1.9.0",
4
4
  "description": "A React + TypeScript component library built on Radix Themes: atoms, a Tailwind token bridge and theming.",
5
5
  "private": false,
6
6
  "type": "module",
package/tokens.css CHANGED
@@ -925,6 +925,9 @@
925
925
  .ui-stat-delta[data-tone="negative"] { color: var(--color-danger-ink); }
926
926
  .ui-stat-spark { display: block; height: 32px; margin-top: 4px; }
927
927
  .ui-stat-chevron { position: absolute; inset-inline-end: 12px; top: 50%; transform: translateY(-50%); color: var(--color-ink-muted); }
928
+ /* Loading. The heights come from the classes above; only these widths are invented. */
929
+ .ui-stat-label.rt-Skeleton { inline-size: 60%; }
930
+ .ui-stat-value.rt-Skeleton { inline-size: 5ch; }
928
931
 
929
932
  /* ---- ScrollStrip ---- */
930
933
  .ui-strip { --strip-item: 78%; display: flex; gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; padding-inline: var(--page-gutter, 16px); scroll-padding-inline: var(--page-gutter, 16px); scrollbar-width: none; -webkit-overflow-scrolling: touch; }
@@ -953,6 +956,15 @@
953
956
  .ui-button[data-icon-only]::before { content: ''; position: absolute; top: 50%; left: 50%; width: max(100%, var(--tap-min)); height: max(100%, var(--tap-min)); transform: translate(-50%, -50%); }
954
957
  /* iOS Safari zooms the page when a focused field is under 16px. */
955
958
  .ui-input, .ui-input input, .ui-textarea, .ui-textarea textarea, .ui-select-trigger { font-size: 16px; }
959
+ /*
960
+ * In a form on a phone the field is the main target. `sm` fields keep their size: choosing one
961
+ * is asking for density. Buttons follow so that one beside a field still lines up with it, and
962
+ * an icon-only button keeps its shape and has the ::before above instead.
963
+ */
964
+ .ui-input:not(.rt-r-size-1), .ui-textarea, .ui-select-trigger, .ui-date-input,
965
+ .ui-button:is([data-size='sm'], [data-size='md']):not([data-icon-only]) { min-block-size: var(--tap-min); }
966
+ /* Themes makes a ghost button content-box, so that minimum would land on top of its padding. */
967
+ .ui-button[data-variant='ghost'] { box-sizing: border-box; }
956
968
  }
957
969
  @media (prefers-reduced-motion: reduce) {
958
970
  .ui-button, .ui-list-row[data-interactive], .ui-tabbar__item, .ui-stat[data-interactive] { transition: none; }
@@ -1200,10 +1212,15 @@
1200
1212
  background: linear-gradient(var(--row-tint), var(--row-tint)), var(--color-panel-solid); }
1201
1213
  .ui-table__sort { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; font: inherit; color: inherit; cursor: pointer; }
1202
1214
  .ui-table__sort[data-sorted] { color: var(--color-ink); }
1203
- .ui-table__sort:focus-visible, .ui-table__rowbutton:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 2px; border-radius: var(--radius-1); }
1215
+ .ui-table__sort:focus-visible, .ui-table__rowbutton:focus-visible, .ui-table__rowlink:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 2px; border-radius: var(--radius-1); }
1204
1216
  .ui-table__rowbutton { padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: start; cursor: pointer; }
1205
- @media (hover: hover) { .ui-table__rowbutton:hover { text-decoration: underline; } }
1217
+ .ui-table__rowlink { color: inherit; text-decoration: none; }
1218
+ @media (hover: hover) { .ui-table__rowbutton:hover, .ui-table__rowlink:hover { text-decoration: underline; } }
1206
1219
  .ui-table__selectall { display: none; align-items: center; gap: 8px; padding-inline: 12px; font-size: 14px; font-weight: 500; cursor: pointer; }
1220
+ /* What the header row offers, for the layout that hides it. Only the @container blocks below show this. */
1221
+ .ui-table__stacktools { display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; }
1222
+ .ui-table__sortmenu { max-inline-size: 100%; }
1223
+ .ui-table__sortlabel { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1207
1224
  .ui-table__empty { padding: 0; }
1208
1225
  .ui-table__table .ui-table__empty { block-size: auto; }
1209
1226
  .ui-table__empty > * { flex: 1; }
@@ -1214,7 +1231,7 @@
1214
1231
  @container (width < 480px) {
1215
1232
  .ui-table[data-stack="sm"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1216
1233
  .ui-table[data-stack="sm"] thead { display: none; }
1217
- .ui-table[data-stack="sm"] .ui-table__selectall { display: flex; }
1234
+ .ui-table[data-stack="sm"] :is(.ui-table__stacktools, .ui-table__selectall) { display: flex; }
1218
1235
  .ui-table[data-stack="sm"] .ui-table__region { border: 0; background: none; overflow: visible; }
1219
1236
  .ui-table[data-stack="sm"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1220
1237
  .ui-table[data-stack="sm"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }
@@ -1226,7 +1243,7 @@
1226
1243
  @container (width < 640px) {
1227
1244
  .ui-table[data-stack="md"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1228
1245
  .ui-table[data-stack="md"] thead { display: none; }
1229
- .ui-table[data-stack="md"] .ui-table__selectall { display: flex; }
1246
+ .ui-table[data-stack="md"] :is(.ui-table__stacktools, .ui-table__selectall) { display: flex; }
1230
1247
  .ui-table[data-stack="md"] .ui-table__region { border: 0; background: none; overflow: visible; }
1231
1248
  .ui-table[data-stack="md"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1232
1249
  .ui-table[data-stack="md"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }
@@ -1238,7 +1255,7 @@
1238
1255
  @container (width < 800px) {
1239
1256
  .ui-table[data-stack="lg"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1240
1257
  .ui-table[data-stack="lg"] thead { display: none; }
1241
- .ui-table[data-stack="lg"] .ui-table__selectall { display: flex; }
1258
+ .ui-table[data-stack="lg"] :is(.ui-table__stacktools, .ui-table__selectall) { display: flex; }
1242
1259
  .ui-table[data-stack="lg"] .ui-table__region { border: 0; background: none; overflow: visible; }
1243
1260
  .ui-table[data-stack="lg"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1244
1261
  .ui-table[data-stack="lg"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }
@@ -1432,7 +1449,46 @@
1432
1449
  .rt-TextFieldRoot:has(> input[aria-invalid="true"]),
1433
1450
  .rt-TextAreaRoot:has(> textarea[aria-invalid="true"]),
1434
1451
  .rt-SelectTrigger[aria-invalid="true"],
1435
- .ui-date-input.rt-Button[aria-invalid="true"] { box-shadow: inset 0 0 0 1px var(--red-8); }
1452
+ .ui-date-input.rt-Button[aria-invalid="true"],
1453
+ .ui-combobox__field:has([role="combobox"][aria-invalid="true"]) { box-shadow: inset 0 0 0 1px var(--red-8); }
1454
+
1455
+ /*
1456
+ * Combobox. The field wears Themes' own text field classes on our markup, because the chips have
1457
+ * to come before the input and TextField.Root puts the input first. So size, focus ring and
1458
+ * disabled state are Input's, and only the wrapping is ours.
1459
+ */
1460
+ .ui-combobox { inline-size: 100%; min-inline-size: 0; }
1461
+ .ui-combobox__field.rt-TextFieldRoot { block-size: auto; min-block-size: var(--text-field-height); cursor: text; }
1462
+ .ui-combobox__field:has(.ui-combobox__trigger) { cursor: pointer; }
1463
+ .ui-combobox__values { display: flex; flex: 1 1 0; flex-wrap: wrap; align-items: center; gap: 2px 4px; min-inline-size: 0; }
1464
+ .ui-combobox__values[data-pills] { padding-block: 2px; padding-inline-start: 3px; }
1465
+ .ui-combobox__values .rt-TextFieldInput, .ui-combobox__trigger { flex: 1 1 4rem; align-self: stretch; min-inline-size: 0; }
1466
+ .ui-combobox__values .rt-TextFieldInput { inline-size: auto; }
1467
+ .ui-combobox__icon { display: inline-flex; flex: none; margin-inline-start: var(--text-field-padding); }
1468
+ .ui-combobox__icon + .rt-TextFieldInput { text-indent: 0; padding-inline-start: 6px; }
1469
+ .ui-combobox__values .ui-pill { max-inline-size: 100%; min-inline-size: 0; }
1470
+ .ui-combobox__pill { display: inline-flex; align-items: center; gap: 4px; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1471
+ .ui-combobox__trigger { box-sizing: border-box; padding: 0 var(--text-field-padding); overflow: hidden; border: 0; outline: 0; background: none; font: inherit; color: inherit; letter-spacing: inherit; text-align: start; text-overflow: ellipsis; white-space: nowrap; cursor: inherit; }
1472
+ .ui-combobox__trigger[data-empty] { color: var(--gray-a10); }
1473
+ .ui-combobox__icon + .ui-combobox__trigger { padding-inline-start: 6px; }
1474
+ /* Not .rt-TextFieldInput: a button matches :read-only, which would grey it as if it were disabled. */
1475
+ .ui-combobox__field:has(.ui-combobox__trigger:focus-visible) { outline: 2px solid var(--text-field-focus-color); outline-offset: -1px; }
1476
+ .ui-combobox__field:has(.ui-combobox__trigger:disabled) { background-image: linear-gradient(var(--gray-a2), var(--gray-a2)); box-shadow: inset 0 0 0 var(--text-field-border-width) var(--gray-a6); cursor: var(--cursor-disabled); }
1477
+ .ui-combobox__trigger:disabled { color: var(--gray-a11); }
1478
+ .ui-combobox__end { color: var(--gray-a11); }
1479
+ .ui-combobox__popup.rt-PopoverContent { box-sizing: border-box; inline-size: var(--radix-popover-trigger-width); max-block-size: min(20rem, var(--radix-popover-content-available-height, 20rem)); padding: 4px; overflow-y: auto; }
1480
+ .ui-combobox__search { position: sticky; inset-block-start: 0; z-index: 1; padding-block-end: 8px; background: var(--color-sheet); }
1481
+ .ui-combobox__option { display: flex; align-items: center; gap: 8px; box-sizing: border-box; min-block-size: 32px; padding: 6px 8px; border-radius: var(--radius-2); color: var(--gray-12); font-size: 14px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
1482
+ .ui-combobox__option[data-active] { background: var(--accent-a4); color: var(--accent-12); }
1483
+ .ui-combobox__option-icon { display: inline-flex; flex: none; }
1484
+ .ui-combobox__option-label { min-inline-size: 0; }
1485
+ .ui-combobox__option-detail { min-inline-size: 0; color: var(--gray-11); font-size: 12px; }
1486
+ .ui-combobox__option[data-active] .ui-combobox__option-detail { color: var(--accent-11); }
1487
+ .ui-combobox__option-check { flex: none; margin-inline-start: auto; visibility: hidden; }
1488
+ .ui-combobox__option[aria-selected="true"] .ui-combobox__option-check { visibility: visible; }
1489
+ .ui-combobox__message { padding: 12px 8px; color: var(--color-ink-muted); font-size: 14px; text-align: center; }
1490
+ .ui-combobox__list[data-presentation="sheet"] .ui-combobox__option { min-block-size: var(--tap-min); }
1491
+ @media (pointer: coarse) { .ui-combobox__option { min-block-size: var(--tap-min); } }
1436
1492
 
1437
1493
  /* Accordion: native <details>, its marker replaced by a chevron that turns when open. */
1438
1494
  .ui-accordion { display: flex; flex-direction: column; gap: 8px; }