@kennycorrea/ui 1.8.5 → 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 +14 -0
- package/README.md +41 -2
- package/dist/atoms/ActionSheet/ActionSheet.js +3 -1
- package/dist/atoms/Input/Input.js +2 -1
- package/dist/atoms/Sheet/Sheet.d.ts +3 -3
- package/dist/atoms/Sheet/Sheet.js +6 -4
- package/dist/atoms/StatTile/StatTile.d.ts +19 -5
- package/dist/atoms/StatTile/StatTile.js +8 -2
- package/dist/atoms/TextLink/TextLink.d.ts +2 -2
- package/dist/atoms/Textarea/Textarea.d.ts +1 -1
- package/dist/hooks/useReturnFocus.d.ts +10 -0
- package/dist/hooks/useReturnFocus.js +22 -0
- package/dist/molecules/Combobox/Combobox.d.ts +72 -0
- package/dist/molecules/Combobox/Combobox.js +260 -0
- package/dist/molecules/Combobox/index.d.ts +1 -0
- package/dist/molecules/Combobox/index.js +1 -0
- package/dist/molecules/StatGroup/StatGroup.d.ts +13 -3
- package/dist/molecules/StatGroup/StatGroup.js +9 -3
- package/dist/molecules/index.d.ts +1 -0
- package/dist/molecules/index.js +1 -0
- package/dist/organisms/DataTable/DataTable.d.ts +15 -4
- package/dist/organisms/DataTable/DataTable.js +98 -15
- package/dist/strings.d.ts +4 -0
- package/dist/strings.js +3 -0
- package/package.json +1 -1
- package/tokens.css +62 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 1.8.5 — 2026-11-04
|
|
4
18
|
|
|
5
19
|
Icons on `Select` options.
|
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
|
|
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) {
|
|
@@ -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
|
|
56
|
-
* the
|
|
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
|
|
64
|
-
* the
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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';
|
package/dist/molecules/index.js
CHANGED
|
@@ -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
|
|
60
|
-
*
|
|
61
|
-
* otherwise erase that in some
|
|
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
|
|
20
|
-
*
|
|
21
|
-
* otherwise erase that in some
|
|
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
|
|
142
|
+
const sortable = isSortable(col) && !!onSortChange;
|
|
72
143
|
const sorted = sort?.id === col.id ? sort.direction : undefined;
|
|
73
|
-
const Icon = sorted
|
|
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
|
-
|
|
93
|
-
|
|
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",
|
|
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
|
}
|
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
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
|
-
|
|
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"]
|
|
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; }
|