@scalewing/react 1.29.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +13 -3
  3. package/dist/components/ActionMenu.d.ts +14 -0
  4. package/dist/components/ActionMenu.js +4 -0
  5. package/dist/components/ButtonGroup.d.ts +9 -1
  6. package/dist/components/ButtonGroup.js +2 -2
  7. package/dist/components/Combobox.d.ts +14 -0
  8. package/dist/components/Combobox.js +125 -0
  9. package/dist/components/Field.d.ts +17 -0
  10. package/dist/components/Field.js +54 -5
  11. package/dist/components/FilterChips.d.ts +17 -1
  12. package/dist/components/FilterChips.js +11 -6
  13. package/dist/components/ScrollRegion.d.ts +6 -2
  14. package/dist/components/ScrollRegion.js +9 -3
  15. package/dist/components/SectionNav.d.ts +25 -2
  16. package/dist/components/SectionNav.js +13 -4
  17. package/dist/components/Table.d.ts +49 -5
  18. package/dist/components/Table.js +11 -4
  19. package/dist/components/action-menu/ActionMenuList.js +7 -7
  20. package/dist/components/combobox/ComboboxPopup.d.ts +25 -0
  21. package/dist/components/combobox/ComboboxPopup.js +68 -0
  22. package/dist/components/combobox/entries.d.ts +18 -0
  23. package/dist/components/combobox/entries.js +34 -0
  24. package/dist/components/combobox/types.d.ts +63 -0
  25. package/dist/components/combobox/types.js +1 -0
  26. package/dist/components/combobox/validate.d.ts +3 -0
  27. package/dist/components/combobox/validate.js +44 -0
  28. package/dist/components/field/SearchFrame.d.ts +16 -0
  29. package/dist/components/field/SearchFrame.js +32 -0
  30. package/dist/components/table/TableCell.d.ts +8 -0
  31. package/dist/components/table/TableCell.js +4 -2
  32. package/dist/components/table/TableRow.d.ts +39 -0
  33. package/dist/components/table/TableRow.js +8 -0
  34. package/dist/components/use-scroll-overflow.d.ts +8 -3
  35. package/dist/components/use-scroll-overflow.js +15 -4
  36. package/dist/css/css-action-menu.js +41 -0
  37. package/dist/css/css-button-group-attached.d.ts +2 -0
  38. package/dist/css/css-button-group-attached.js +70 -0
  39. package/dist/css/css-button.js +5 -1
  40. package/dist/css/css-combobox.d.ts +2 -0
  41. package/dist/css/css-combobox.js +135 -0
  42. package/dist/css/css-components.js +4 -0
  43. package/dist/css/css-field-search.d.ts +2 -0
  44. package/dist/css/css-field-search.js +118 -0
  45. package/dist/css/css-field.js +4 -0
  46. package/dist/css/css-filter-chips.js +26 -0
  47. package/dist/css/css-section-nav-filled.d.ts +6 -0
  48. package/dist/css/css-section-nav-filled.js +80 -0
  49. package/dist/css/css-section-nav.js +4 -0
  50. package/dist/css/css-table-footer.d.ts +2 -0
  51. package/dist/css/css-table-footer.js +57 -0
  52. package/dist/css/css-table-ledger.d.ts +10 -0
  53. package/dist/css/css-table-ledger.js +122 -0
  54. package/dist/css/css-table.js +8 -0
  55. package/dist/css/stacking.d.ts +9 -3
  56. package/dist/css/stacking.js +28 -8
  57. package/dist/css/stylesheet.js +2 -0
  58. package/dist/index.d.ts +4 -3
  59. package/dist/index.js +4 -3
  60. package/dist/styles.css +483 -7
  61. package/package.json +1 -1
@@ -1,5 +1,8 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type Breakpoint } from '../css/breakpoints.js';
3
+ import { type SectionNavAppearance } from '../css/css-section-nav-filled.js';
4
+ export type { SectionNavAppearance };
5
+ export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
3
6
  export type SectionNavItem = {
4
7
  id: string;
5
8
  label: string;
@@ -11,6 +14,20 @@ export type SectionNavItem = {
11
14
  current?: 'page' | 'location' | boolean;
12
15
  /** A decorative glyph before the label. */
13
16
  icon?: ReactNode;
17
+ /**
18
+ * How many things wait in the section, such as tasks to do: a filled pill
19
+ * after the label, read as part of the link's name ("Tasks 3"). A whole
20
+ * number; 0 (or none) shows no pill. There is no cap: a long number
21
+ * widens the link.
22
+ */
23
+ count?: number;
24
+ /**
25
+ * What the count means, in the consumer's words ("3 tasks to do"): read
26
+ * in the link's name in place of the bare number ("Tasks 3 tasks to
27
+ * do"; "Tasks 3 waiting" with "3 waiting"), the pill then hidden from
28
+ * assistive technology. Needs `count`.
29
+ */
30
+ countLabel?: string;
14
31
  };
15
32
  type SectionNavLabel = {
16
33
  'aria-label': string;
@@ -33,6 +50,13 @@ export type SectionNavProps = SectionNavLabel & {
33
50
  * marked by a bar at its start) instead of a row (marked by an underline).
34
51
  */
35
52
  verticalFrom?: Breakpoint;
53
+ /**
54
+ * How the current item is marked: `underline` (default) under a hairline
55
+ * row, or a bar at its start in a side list; `filled`, a quiet row of
56
+ * page buttons whose current one takes a soft fill, for an area's
57
+ * sub-navigation beside its name.
58
+ */
59
+ appearance?: SectionNavAppearance;
36
60
  };
37
61
  /**
38
62
  * A page's secondary navigation between the sections of one area, such as
@@ -40,5 +64,4 @@ export type SectionNavProps = SectionNavLabel & {
40
64
  * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
41
65
  * a side list. The current item carries `aria-current`.
42
66
  */
43
- export declare function SectionNav({ items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
44
- export {};
67
+ export declare function SectionNav({ appearance, items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
@@ -1,7 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
3
  import { breakpoints } from '../css/breakpoints.js';
4
4
  import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
+ import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
6
+ export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
5
7
  function plainPress(event) {
6
8
  return (event.button === 0 &&
7
9
  !event.defaultPrevented &&
@@ -20,6 +22,12 @@ function assertItems(items) {
20
22
  throw new RangeError('SectionNav items need nonempty ids and labels');
21
23
  if (new Set(items.map((item) => item.id)).size !== items.length)
22
24
  throw new RangeError('SectionNav items need unique ids');
25
+ if (items.some((item) => item.count !== undefined &&
26
+ (!Number.isInteger(item.count) || item.count < 0)))
27
+ throw new RangeError('SectionNav item counts are whole numbers from 0');
28
+ if (items.some((item) => item.countLabel !== undefined &&
29
+ (!item.countLabel.trim() || item.count === undefined)))
30
+ throw new RangeError('SectionNav item countLabel is nonempty and comes with a count');
23
31
  }
24
32
  function assertBreakpoint(verticalFrom) {
25
33
  if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
@@ -31,10 +39,11 @@ function assertBreakpoint(verticalFrom) {
31
39
  * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
32
40
  * a side list. The current item carries `aria-current`.
33
41
  */
34
- export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
42
+ export function SectionNav({ appearance = 'underline', items, onNavigate, verticalFrom, ...label }) {
35
43
  assertItems(items);
36
44
  assertBreakpoint(verticalFrom);
37
- return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom)), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
45
+ assertSectionNavAppearance(appearance);
46
+ return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
38
47
  // Only a given onNavigate attaches a handler, so the links
39
48
  // render from a server component without one.
40
49
  onClick: onNavigate
@@ -44,5 +53,5 @@ export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
44
53
  event.preventDefault();
45
54
  onNavigate(item);
46
55
  }
47
- : undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label] }) }, item.id))) }) }));
56
+ : undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label, item.count ? (_jsxs(_Fragment, { children: [item.countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${item.countLabel}` })) : (' '), _jsx("span", { "aria-hidden": item.countLabel ? true : undefined, className: "sw-section-nav-count", children: item.count })] })) : null] }) }, item.id))) }) }));
48
57
  }
@@ -1,10 +1,18 @@
1
1
  import { type HTMLAttributes } from 'react';
2
2
  import { type TableLayout, type TableVerticalAlign } from './table/table-layout.js';
3
3
  export { TableCell, type TableCellProps, type TableColumnWidth, type TableSort, } from './table/TableCell.js';
4
+ export { TableRow, type TableRowProps, type TableRowVariant, } from './table/TableRow.js';
5
+ export type { TableCellTone } from '../css/css-table-ledger.js';
6
+ export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
4
7
  export type { TableLayout, TableVerticalAlign };
5
8
  export type TableDensity = 'comfortable' | 'compact';
6
9
  export type TableProps = HTMLAttributes<HTMLTableElement> & {
7
10
  density?: TableDensity;
11
+ /**
12
+ * Pins the header row to the top of the table's own scroll region
13
+ * (default). In a table with a sticky `TableFooter` that fits its width
14
+ * the region does not scroll, so the header pins to the page's top.
15
+ */
8
16
  stickyHeader?: boolean;
9
17
  /**
10
18
  * `auto` (default) sizes each column by its content. `fixed` sizes the
@@ -23,10 +31,20 @@ export type TableProps = HTMLAttributes<HTMLTableElement> & {
23
31
  * should start on its first line.
24
32
  */
25
33
  verticalAlign?: TableVerticalAlign;
34
+ /**
35
+ * Draws a hairline between columns, at the inline start of every cell
36
+ * after a row's first, for a ledger read across as well as down.
37
+ */
38
+ columnRules?: boolean;
26
39
  };
27
40
  /** A wide table scrolls inside its own `ScrollRegion`, named after the table. */
28
41
  export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableElement> & {
29
42
  density?: TableDensity;
43
+ /**
44
+ * Pins the header row to the top of the table's own scroll region
45
+ * (default). In a table with a sticky `TableFooter` that fits its width
46
+ * the region does not scroll, so the header pins to the page's top.
47
+ */
30
48
  stickyHeader?: boolean;
31
49
  /**
32
50
  * `auto` (default) sizes each column by its content. `fixed` sizes the
@@ -45,14 +63,40 @@ export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttrib
45
63
  * should start on its first line.
46
64
  */
47
65
  verticalAlign?: TableVerticalAlign;
66
+ /**
67
+ * Draws a hairline between columns, at the inline start of every cell
68
+ * after a row's first, for a ledger read across as well as down.
69
+ */
70
+ columnRules?: boolean;
48
71
  } & import("react").RefAttributes<HTMLTableElement>>;
49
72
  export type TableHeaderProps = HTMLAttributes<HTMLTableSectionElement>;
50
73
  export declare const TableHeader: import("react").ForwardRefExoticComponent<TableHeaderProps & import("react").RefAttributes<HTMLTableSectionElement>>;
51
74
  export type TableBodyProps = HTMLAttributes<HTMLTableSectionElement>;
52
75
  export declare const TableBody: import("react").ForwardRefExoticComponent<TableBodyProps & import("react").RefAttributes<HTMLTableSectionElement>>;
53
- export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
54
- selected?: boolean;
76
+ export type TableFooterProps = HTMLAttributes<HTMLTableSectionElement> & {
77
+ /**
78
+ * Pins the footer, such as a total row, to the bottom edge while the rows
79
+ * scroll under it: to the page while the table fits its width (the
80
+ * sticky header then pins to the page's top too, under any sticky
81
+ * AppHeader, which covers it), or to the table's own region while the
82
+ * table scrolls sideways in it. A sticky ActionBar covers a pinned
83
+ * footer, and so does a Dialog's pinned footer, so do not pair them.
84
+ */
85
+ sticky?: boolean;
55
86
  };
56
- export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableRowElement> & {
57
- selected?: boolean;
58
- } & import("react").RefAttributes<HTMLTableRowElement>>;
87
+ /**
88
+ * The table's footer (`tfoot`): a total row, its cells at weight 600 under
89
+ * a heavier rule. A row header in it (`TableCell as="th" scope="row"`)
90
+ * names the total.
91
+ */
92
+ export declare const TableFooter: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableSectionElement> & {
93
+ /**
94
+ * Pins the footer, such as a total row, to the bottom edge while the rows
95
+ * scroll under it: to the page while the table fits its width (the
96
+ * sticky header then pins to the page's top too, under any sticky
97
+ * AppHeader, which covers it), or to the table's own region while the
98
+ * table scrolls sideways in it. A sticky ActionBar covers a pinned
99
+ * footer, and so does a Dialog's pinned footer, so do not pair them.
100
+ */
101
+ sticky?: boolean;
102
+ } & import("react").RefAttributes<HTMLTableSectionElement>>;
@@ -4,11 +4,13 @@ import { cx } from '../class-names.js';
4
4
  import { ScrollRegion } from './ScrollRegion.js';
5
5
  import { assertTableLayout, assertTableVerticalAlign, } from './table/table-layout.js';
6
6
  export { TableCell, } from './table/TableCell.js';
7
+ export { TableRow, } from './table/TableRow.js';
8
+ export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
7
9
  /** A wide table scrolls inside its own `ScrollRegion`, named after the table. */
8
- export const Table = forwardRef(function Table({ children, className, density = 'comfortable', layout = 'auto', stickyHeader = true, verticalAlign = 'middle', ...rest }, ref) {
10
+ export const Table = forwardRef(function Table({ children, className, columnRules = false, density = 'comfortable', layout = 'auto', stickyHeader = true, verticalAlign = 'middle', ...rest }, ref) {
9
11
  assertTableLayout(layout);
10
12
  assertTableVerticalAlign(verticalAlign);
11
- return (_jsx(ScrollRegion, { "aria-label": rest['aria-label'], "aria-labelledby": rest['aria-labelledby'], className: "sw-table-wrap", children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', layout === 'fixed' && 'sw-table-fixed', stickyHeader && 'sw-table-sticky', verticalAlign === 'top' && 'sw-table-top', className), ...rest, children: children }) }));
13
+ return (_jsx(ScrollRegion, { "aria-label": rest['aria-label'], "aria-labelledby": rest['aria-labelledby'], className: "sw-table-wrap", markFits: true, children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', layout === 'fixed' && 'sw-table-fixed', stickyHeader && 'sw-table-sticky', verticalAlign === 'top' && 'sw-table-top', columnRules && 'sw-table-column-rules', className), ...rest, children: children }) }));
12
14
  });
13
15
  export const TableHeader = forwardRef(function TableHeader({ className, ...rest }, ref) {
14
16
  return _jsx("thead", { ref: ref, className: className, ...rest });
@@ -16,6 +18,11 @@ export const TableHeader = forwardRef(function TableHeader({ className, ...rest
16
18
  export const TableBody = forwardRef(function TableBody({ className, ...rest }, ref) {
17
19
  return _jsx("tbody", { ref: ref, className: className, ...rest });
18
20
  });
19
- export const TableRow = forwardRef(function TableRow({ className, selected = false, ...rest }, ref) {
20
- return (_jsx("tr", { ref: ref, "aria-selected": selected || undefined, className: cx(selected && 'sw-table-row-selected', className), ...rest }));
21
+ /**
22
+ * The table's footer (`tfoot`): a total row, its cells at weight 600 under
23
+ * a heavier rule. A row header in it (`TableCell as="th" scope="row"`)
24
+ * names the total.
25
+ */
26
+ export const TableFooter = forwardRef(function TableFooter({ className, sticky = false, ...rest }, ref) {
27
+ return (_jsx("tfoot", { ref: ref, className: cx(sticky && 'sw-table-footer-sticky', className), ...rest }));
21
28
  });
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId, useLayoutEffect, useRef, } from 'react';
3
+ import { Fragment, useId, useLayoutEffect, useRef, } from 'react';
4
4
  import { cx } from '../../class-names.js';
5
5
  import { useAnchoredPopover } from '../use-anchored-popover.js';
6
6
  /**
@@ -26,12 +26,12 @@ export function ActionMenuList({ id, align, header, label, items, focusIndex, tr
26
26
  header !== null &&
27
27
  header !== false &&
28
28
  header !== '';
29
- const menu = (_jsx("div", { "aria-describedby": hasHeader ? headerId : undefined, "aria-label": label, className: hasHeader ? 'sw-action-menu-items' : 'sw-action-menu-list', id: hasHeader ? undefined : id, onKeyDown: onKeyDown, ref: hasHeader ? undefined : menuRef, role: "menu", children: items.map((item, index) => (_jsxs("button", { className: cx('sw-action-menu-item', item.destructive && 'sw-action-menu-item-danger'), disabled: item.disabled, onClick: () => onSelect(item), onFocus: () => onFocusIndex(index), onKeyDown: (event) => {
30
- if (event.key === 'Enter' || event.key === ' ') {
31
- event.preventDefault();
32
- onSelect(item);
33
- }
34
- }, role: "menuitem", tabIndex: index === focusIndex ? 0 : -1, type: "button", children: [item.icon ? _jsx("span", { "aria-hidden": "true", children: item.icon }) : null, _jsx("span", { lang: item.lang, children: item.label })] }, item.id))) }));
29
+ const menu = (_jsx("div", { "aria-describedby": hasHeader ? headerId : undefined, "aria-label": label, className: hasHeader ? 'sw-action-menu-items' : 'sw-action-menu-list', id: hasHeader ? undefined : id, onKeyDown: onKeyDown, ref: hasHeader ? undefined : menuRef, role: "menu", children: items.map((item, index) => (_jsxs(Fragment, { children: [item.separatorBefore && index > 0 ? (_jsx("div", { className: "sw-action-menu-separator", role: "separator" })) : null, _jsxs("button", { className: cx('sw-action-menu-item', item.destructive && 'sw-action-menu-item-danger'), disabled: item.disabled, onClick: () => onSelect(item), onFocus: () => onFocusIndex(index), onKeyDown: (event) => {
30
+ if (event.key === 'Enter' || event.key === ' ') {
31
+ event.preventDefault();
32
+ onSelect(item);
33
+ }
34
+ }, role: "menuitem", tabIndex: index === focusIndex ? 0 : -1, type: "button", children: [item.icon ? _jsx("span", { "aria-hidden": "true", children: item.icon }) : null, _jsx("span", { lang: item.lang, children: item.label }), item.detail ? (_jsxs("span", { className: "sw-action-menu-detail", children: [' ', _jsx("span", { lang: item.detailLang, children: item.detail }), _jsx("span", { "aria-hidden": "true", className: "sw-action-menu-chevron" })] })) : null] })] }, item.id))) }));
35
35
  if (!hasHeader)
36
36
  return menu;
37
37
  // The header is not a menu item: it sits above the menu in the same
@@ -0,0 +1,25 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ComboboxEntry } from './entries.js';
3
+ import { type ComboboxGroup } from './types.js';
4
+ type ComboboxPopupProps = {
5
+ listId: string;
6
+ listLabel: string;
7
+ groups: readonly ComboboxGroup[];
8
+ entries: readonly ComboboxEntry[];
9
+ activeId: string | undefined;
10
+ emptyText: string;
11
+ note?: string;
12
+ noteId: string;
13
+ inputRef: RefObject<HTMLInputElement | null>;
14
+ onActivate: (entry: ComboboxEntry) => void;
15
+ onChoose: (entry: ComboboxEntry) => void;
16
+ onDismiss: () => void;
17
+ };
18
+ /**
19
+ * The open list beside the input, on the popover layer and at least as
20
+ * wide as the input: a heading per group, each option's label, badge and
21
+ * description, then the action; the empty text when nothing matches, and
22
+ * the note under it all.
23
+ */
24
+ export declare function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }: ComboboxPopupProps): import("react").JSX.Element;
25
+ export {};
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Fragment, useLayoutEffect, useRef, } from 'react';
4
+ import { cx } from '../../class-names.js';
5
+ import { Badge } from '../Badge.js';
6
+ import { useAnchoredPopover } from '../use-anchored-popover.js';
7
+ function keepInputFocus(event) {
8
+ // A press in the list never takes focus from the input.
9
+ event.preventDefault();
10
+ }
11
+ function EntryBadge({ badge }) {
12
+ if (!badge)
13
+ return null;
14
+ return (_jsx(Badge, { size: "sm", tone: badge.tone ?? 'neutral', children: badge.label }));
15
+ }
16
+ /**
17
+ * The open list beside the input, on the popover layer and at least as
18
+ * wide as the input: a heading per group, each option's label, badge and
19
+ * description, then the action; the empty text when nothing matches, and
20
+ * the note under it all.
21
+ */
22
+ export function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }) {
23
+ const popupRef = useRef(null);
24
+ useAnchoredPopover(popupRef, inputRef, inputRef, onDismiss, {
25
+ align: 'start',
26
+ flipInline: true,
27
+ });
28
+ // At least as wide as the input, and kept so when the input resizes.
29
+ useLayoutEffect(() => {
30
+ const popup = popupRef.current;
31
+ const input = inputRef.current;
32
+ if (!popup || !input)
33
+ return;
34
+ const fit = () => {
35
+ popup.style.minWidth = `${input.offsetWidth}px`;
36
+ };
37
+ fit();
38
+ if (typeof ResizeObserver === 'undefined')
39
+ return;
40
+ const observer = new ResizeObserver(fit);
41
+ observer.observe(input);
42
+ return () => observer.disconnect();
43
+ }, [inputRef]);
44
+ useLayoutEffect(() => {
45
+ if (activeId)
46
+ document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
47
+ }, [activeId]);
48
+ const options = entries.filter((entry) => entry.kind === 'option');
49
+ const action = entries.find((entry) => entry.kind === 'action');
50
+ function renderEntry(entry) {
51
+ const disabled = entry.kind === 'option' && entry.option.disabled;
52
+ return (_jsx("div", { "aria-disabled": disabled || undefined, "aria-selected": entry.id === activeId, className: cx('sw-combobox-option', entry.kind === 'action' && 'sw-combobox-action'), id: entry.id, onClick: () => onChoose(entry), onMouseDown: keepInputFocus,
53
+ // The pointer moves the active option, as the arrow keys do.
54
+ onMouseMove: () => {
55
+ if (entry.id !== activeId)
56
+ onActivate(entry);
57
+ }, role: "option", children: entry.kind === 'option' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.option.label }), _jsx(EntryBadge, { badge: entry.option.badge }), entry.option.description ? (_jsx("span", { className: "sw-combobox-description", children: entry.option.description })) : null] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.action.label }), _jsx(EntryBadge, { badge: entry.action.badge })] })) }, entry.id));
58
+ }
59
+ return (_jsxs("div", { className: "sw-combobox-popup", onMouseDown: keepInputFocus, ref: popupRef, children: [entries.length > 0 ? (_jsxs("div", { "aria-label": listLabel, className: "sw-combobox-list", id: listId, role: "listbox", children: [groups.map((group, groupIndex) => {
60
+ const groupEntries = options.filter((entry) => entry.kind === 'option' && entry.groupIndex === groupIndex);
61
+ if (groupEntries.length === 0)
62
+ return null;
63
+ if (!group.label)
64
+ return (_jsx(Fragment, { children: groupEntries.map(renderEntry) }, `${listId}-group-${groupIndex}`));
65
+ const headingId = `${listId}-group-${groupIndex}`;
66
+ return (_jsxs("div", { "aria-labelledby": headingId, role: "group", children: [_jsx("div", { className: "sw-combobox-heading", id: headingId, role: "presentation", children: group.label }), groupEntries.map(renderEntry)] }, headingId));
67
+ }), action ? renderEntry(action) : null] })) : null, options.length === 0 ? (_jsx("div", { "aria-hidden": "true", className: "sw-combobox-empty", children: emptyText })) : null, note ? (_jsx("div", { className: "sw-combobox-note", id: noteId, children: note })) : null] }));
68
+ }
@@ -0,0 +1,18 @@
1
+ import { type ComboboxAction, type ComboboxGroup, type ComboboxOption } from './types.js';
2
+ /** One row the arrow keys move over: an option in a group, or the action. */
3
+ export type ComboboxEntry = {
4
+ kind: 'option';
5
+ key: string;
6
+ id: string;
7
+ index: number;
8
+ option: ComboboxOption;
9
+ groupIndex: number;
10
+ } | {
11
+ kind: 'action';
12
+ key: string;
13
+ id: string;
14
+ index: number;
15
+ action: ComboboxAction;
16
+ };
17
+ /** The list's rows in order, each with a stable key and a DOM id under `listId`. */
18
+ export declare function comboboxEntries(listId: string, groups: readonly ComboboxGroup[], action: ComboboxAction | undefined): ComboboxEntry[];
@@ -0,0 +1,34 @@
1
+ /*
2
+ * Each row's key and DOM id come from its value, not its place, so an
3
+ * option keeps its id (and stays the active one) when the consumer's
4
+ * results change under it, and a new option never inherits an old id.
5
+ */
6
+ const encoder = new TextEncoder();
7
+ /** The value as hex, so any value makes a unique, valid id part. */
8
+ function idPart(value) {
9
+ return Array.from(encoder.encode(value), (byte) => byte.toString(16).padStart(2, '0')).join('');
10
+ }
11
+ /** The list's rows in order, each with a stable key and a DOM id under `listId`. */
12
+ export function comboboxEntries(listId, groups, action) {
13
+ const entries = [];
14
+ groups.forEach((group, groupIndex) => {
15
+ for (const option of group.options)
16
+ entries.push({
17
+ kind: 'option',
18
+ key: `option:${option.value}`,
19
+ id: `${listId}-option-${idPart(option.value)}`,
20
+ index: entries.length,
21
+ option,
22
+ groupIndex,
23
+ });
24
+ });
25
+ if (action)
26
+ entries.push({
27
+ kind: 'action',
28
+ key: 'action',
29
+ id: `${listId}-action`,
30
+ index: entries.length,
31
+ action,
32
+ });
33
+ return entries;
34
+ }
@@ -0,0 +1,63 @@
1
+ import { type BadgeTone } from '../../css/css-data.js';
2
+ /** A short tag after an option's label, such as "Warning" or "Blocked". */
3
+ export type ComboboxBadge = {
4
+ label: string;
5
+ tone?: BadgeTone;
6
+ };
7
+ /** One suggestion in a Combobox. */
8
+ export type ComboboxOption = {
9
+ value: string;
10
+ label: string;
11
+ /** A muted line of detail after the label, such as "3 items · updated Sep 28". */
12
+ description?: string;
13
+ badge?: ComboboxBadge;
14
+ /** Shown, read as unavailable, and never chosen, such as an archived item. */
15
+ disabled?: boolean;
16
+ };
17
+ /** A run of suggestions under a heading, such as "All items". */
18
+ export type ComboboxGroup = {
19
+ /** The group's heading; a group without one has no heading. */
20
+ label?: string;
21
+ options: readonly ComboboxOption[];
22
+ };
23
+ /** A command at the end of the list, such as "+ New entry “acme”". */
24
+ export type ComboboxAction = {
25
+ label: string;
26
+ onPress: () => void;
27
+ badge?: ComboboxBadge;
28
+ };
29
+ export declare const comboboxSizes: readonly ["md", "xs"];
30
+ export type ComboboxSize = (typeof comboboxSizes)[number];
31
+ export type ComboboxProps = {
32
+ /** The input's name, drawn above it unless `labelVisuallyHidden`. */
33
+ label: string;
34
+ labelVisuallyHidden?: boolean;
35
+ /** The text in the input; the consumer filters `groups` from it. */
36
+ inputValue: string;
37
+ onInputChange: (value: string) => void;
38
+ groups: readonly ComboboxGroup[];
39
+ /** Called with the chosen option's value; the consumer sets `inputValue`. */
40
+ onSelect: (value: string) => void;
41
+ action?: ComboboxAction;
42
+ /**
43
+ * Shown in the list, and announced, when no group has an option, such as
44
+ * "Nothing matches".
45
+ */
46
+ emptyText: string;
47
+ /**
48
+ * The words announced for the number of options while the list is open,
49
+ * such as `(n) => \`${n} items\``; without it only the empty text is
50
+ * announced.
51
+ */
52
+ countText?: (count: number) => string;
53
+ /** The list's name when it differs from the input's, such as "Results". */
54
+ listLabel?: string;
55
+ /** A muted line under the suggestions, such as how to find more. */
56
+ note?: string;
57
+ placeholder?: string;
58
+ description?: string;
59
+ error?: string;
60
+ required?: boolean;
61
+ disabled?: boolean;
62
+ size?: ComboboxSize;
63
+ };
@@ -0,0 +1 @@
1
+ export const comboboxSizes = ['md', 'xs'];
@@ -0,0 +1,3 @@
1
+ import { type ComboboxProps } from './types.js';
2
+ /** Fails closed on blank words, duplicate values and unknown sizes. */
3
+ export declare function assertCombobox(props: ComboboxProps): void;
@@ -0,0 +1,44 @@
1
+ import { badgeTones } from '../../css/css-data.js';
2
+ import { comboboxSizes, } from './types.js';
3
+ function blank(text) {
4
+ return typeof text !== 'string' || !text.trim();
5
+ }
6
+ function assertBadge(badge) {
7
+ if (badge === undefined)
8
+ return;
9
+ if (blank(badge.label))
10
+ throw new RangeError('A Combobox badge needs a nonempty label');
11
+ if (badge.tone !== undefined && !badgeTones.includes(badge.tone))
12
+ throw new RangeError(`Unknown Combobox badge tone: ${String(badge.tone)}`);
13
+ }
14
+ /** Fails closed on blank words, duplicate values and unknown sizes. */
15
+ export function assertCombobox(props) {
16
+ if (blank(props.label))
17
+ throw new RangeError('Combobox needs a nonempty label');
18
+ if (blank(props.emptyText))
19
+ throw new RangeError('Combobox needs a nonempty emptyText');
20
+ if (props.listLabel !== undefined && blank(props.listLabel))
21
+ throw new RangeError('A Combobox listLabel must not be blank');
22
+ if (props.size !== undefined && !comboboxSizes.includes(props.size))
23
+ throw new RangeError(`Unknown Combobox size: ${String(props.size)}`);
24
+ const values = new Set();
25
+ for (const group of props.groups) {
26
+ if (group.label !== undefined && blank(group.label))
27
+ throw new RangeError('A Combobox group label must not be blank');
28
+ for (const option of group.options) {
29
+ if (blank(option.value) || blank(option.label))
30
+ throw new RangeError('Combobox options need nonempty values and labels');
31
+ if (values.has(option.value))
32
+ throw new RangeError(`Duplicate Combobox option: ${option.value}`);
33
+ values.add(option.value);
34
+ assertBadge(option.badge);
35
+ }
36
+ }
37
+ if (props.action) {
38
+ if (blank(props.action.label))
39
+ throw new RangeError('A Combobox action needs a nonempty label');
40
+ if (typeof props.action.onPress !== 'function')
41
+ throw new TypeError('A Combobox action needs onPress');
42
+ assertBadge(props.action.badge);
43
+ }
44
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react';
2
+ type SearchFrameProps = {
3
+ children: ReactNode;
4
+ leading?: ReactNode;
5
+ clearLabel: string;
6
+ /** Shown while the input holds text and is enabled. */
7
+ showClear: boolean;
8
+ onClear: () => void;
9
+ };
10
+ /**
11
+ * The search field's frame: the consumer's glyph at the start (hidden from
12
+ * assistive technology), the input, and a clear button at the end while
13
+ * there is text. Clearing calls `onClear` and puts focus back in the input.
14
+ */
15
+ export declare function SearchFrame({ children, clearLabel, leading, onClear, showClear, }: SearchFrameProps): import("react").JSX.Element;
16
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * A press on the frame's chrome (the glyph or the padding) focuses the
4
+ * input, as on an adorned field; a press on the input keeps the browser's
5
+ * own caret placement, and the clear button runs its own action.
6
+ */
7
+ function focusSearchInput(event) {
8
+ const input = event.currentTarget.querySelector('input');
9
+ const target = event.target;
10
+ if (!input || input.disabled || target === input)
11
+ return;
12
+ event.preventDefault();
13
+ // The clear button keeps focus in the input: no blur, and its click
14
+ // still runs.
15
+ if (target.closest('.sw-field-search-clear'))
16
+ return;
17
+ input.focus();
18
+ }
19
+ /**
20
+ * The search field's frame: the consumer's glyph at the start (hidden from
21
+ * assistive technology), the input, and a clear button at the end while
22
+ * there is text. Clearing calls `onClear` and puts focus back in the input.
23
+ */
24
+ export function SearchFrame({ children, clearLabel, leading, onClear, showClear, }) {
25
+ return (_jsxs("span", { className: "sw-field-adorned sw-field-search", onMouseDown: focusSearchInput, children: [leading != null && leading !== false ? (_jsx("span", { "aria-hidden": "true", className: "sw-field-search-leading", children: leading })) : null, children, showClear ? (_jsx("button", { "aria-label": clearLabel, className: "sw-field-search-clear", onClick: (event) => {
26
+ // Focus first, so a consumer that moves focus in onClear wins.
27
+ event.currentTarget.parentElement
28
+ ?.querySelector('input')
29
+ ?.focus();
30
+ onClear();
31
+ }, type: "button", children: _jsx("span", { "aria-hidden": "true", className: "sw-field-search-cross" }) })) : null] }));
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
2
+ import { type TableCellTone } from '../../css/css-table-ledger.js';
2
3
  import { type TableColumnWidth } from './table-layout.js';
3
4
  export type { TableColumnWidth };
4
5
  type TableCellAlign = 'start' | 'end';
@@ -38,5 +39,12 @@ export type TableCellProps = ((Omit<TdHTMLAttributes<HTMLTableCellElement>, 'wid
38
39
  * it as no width.
39
40
  */
40
41
  width?: TableColumnWidth;
42
+ /**
43
+ * Colours the cell's text: `success` and `danger` for signed figures
44
+ * (money in, a shortfall), `accent` for a figure to act on, `muted` for
45
+ * a quiet one, in the theme's colour as `Text color` uses it. A colour
46
+ * is never the only signal: keep the sign or the words.
47
+ */
48
+ tone?: TableCellTone;
41
49
  };
42
50
  export declare const TableCell: import("react").ForwardRefExoticComponent<TableCellProps & import("react").RefAttributes<HTMLTableCellElement>>;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, } from 'react';
3
3
  import { cx } from '../../class-names.js';
4
+ import { assertTableCellTone, } from '../../css/css-table-ledger.js';
4
5
  import { assertTableColumnWidth, tableColumnWidthClass, } from './table-layout.js';
5
6
  const tableSorts = ['ascending', 'descending', 'none'];
6
7
  /** `sort` and `onSort` come together, on a header cell, with a known value. */
@@ -14,14 +15,15 @@ function assertSort(as, sort, onSort) {
14
15
  if (!tableSorts.includes(sort))
15
16
  throw new RangeError(`Unknown TableCell sort: ${String(sort)}`);
16
17
  }
17
- export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onSort, sort, truncate = false, width, ...rest }, ref) {
18
+ export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onSort, sort, tone, truncate = false, width, ...rest }, ref) {
18
19
  assertSort(as, sort, onSort);
19
20
  assertTableColumnWidth(width);
21
+ assertTableCellTone(tone);
20
22
  const Component = as;
21
23
  const alignmentClass = numeric
22
24
  ? 'sw-table-numeric'
23
25
  : align === 'end'
24
26
  ? 'sw-table-end'
25
27
  : undefined;
26
- return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : (children) }));
28
+ return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : (children) }));
27
29
  });