@scalewing/react 1.28.0 → 1.30.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 (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +48 -4
  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/Checkbox.js +5 -4
  8. package/dist/components/FilterChips.d.ts +17 -1
  9. package/dist/components/FilterChips.js +11 -6
  10. package/dist/components/GlyphTile.d.ts +21 -0
  11. package/dist/components/GlyphTile.js +17 -0
  12. package/dist/components/MultiSelect.d.ts +10 -0
  13. package/dist/components/MultiSelect.js +62 -0
  14. package/dist/components/ScrollRegion.d.ts +6 -2
  15. package/dist/components/ScrollRegion.js +9 -3
  16. package/dist/components/SectionNav.d.ts +25 -2
  17. package/dist/components/SectionNav.js +13 -4
  18. package/dist/components/SegmentedControl.d.ts +8 -0
  19. package/dist/components/SegmentedControl.js +4 -3
  20. package/dist/components/Table.d.ts +49 -5
  21. package/dist/components/Table.js +11 -4
  22. package/dist/components/action-menu/ActionMenuList.js +7 -7
  23. package/dist/components/checkbox/CheckboxControl.d.ts +9 -0
  24. package/dist/components/checkbox/CheckboxControl.js +9 -0
  25. package/dist/components/multi-select/MultiSelectPanel.d.ts +25 -0
  26. package/dist/components/multi-select/MultiSelectPanel.js +41 -0
  27. package/dist/components/multi-select/types.d.ts +35 -0
  28. package/dist/components/multi-select/types.js +1 -0
  29. package/dist/components/multi-select/validate.d.ts +3 -0
  30. package/dist/components/multi-select/validate.js +25 -0
  31. package/dist/components/table/TableCell.d.ts +8 -0
  32. package/dist/components/table/TableCell.js +4 -2
  33. package/dist/components/table/TableRow.d.ts +39 -0
  34. package/dist/components/table/TableRow.js +8 -0
  35. package/dist/components/use-scroll-overflow.d.ts +8 -3
  36. package/dist/components/use-scroll-overflow.js +15 -4
  37. package/dist/css/css-action-menu.js +41 -0
  38. package/dist/css/css-button-group-attached.d.ts +2 -0
  39. package/dist/css/css-button-group-attached.js +70 -0
  40. package/dist/css/css-button.js +5 -1
  41. package/dist/css/css-components.js +8 -0
  42. package/dist/css/css-filter-chips.js +26 -0
  43. package/dist/css/css-glyph-tile.d.ts +5 -0
  44. package/dist/css/css-glyph-tile.js +57 -0
  45. package/dist/css/css-multi-select.d.ts +2 -0
  46. package/dist/css/css-multi-select.js +156 -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-segmented.js +58 -0
  51. package/dist/css/css-table-footer.d.ts +2 -0
  52. package/dist/css/css-table-footer.js +57 -0
  53. package/dist/css/css-table-ledger.d.ts +10 -0
  54. package/dist/css/css-table-ledger.js +122 -0
  55. package/dist/css/css-table.js +8 -0
  56. package/dist/css/css-variables.js +11 -1
  57. package/dist/css/stacking.d.ts +9 -3
  58. package/dist/css/stacking.js +29 -9
  59. package/dist/css/stylesheet.js +4 -0
  60. package/dist/index.d.ts +4 -2
  61. package/dist/index.js +4 -2
  62. package/dist/styles.css +501 -7
  63. package/package.json +2 -2
@@ -2,6 +2,14 @@ import { type ReactNode } from 'react';
2
2
  export type SegmentedItem = {
3
3
  id: string;
4
4
  label: ReactNode;
5
+ /**
6
+ * A consumer mark before the label, such as a `GlyphTile` or a Lucide
7
+ * glyph, hidden from assistive technology (the label names the item).
8
+ * When any item has one, every item of a compact track takes the `md`
9
+ * control height and label type on a subtle track; a filled track keeps
10
+ * its look and gains the marks. A falsy value (`0`, `''`) is no icon.
11
+ */
12
+ icon?: ReactNode;
5
13
  };
6
14
  type SegmentedLabel = {
7
15
  'aria-label': string;
@@ -5,6 +5,7 @@ import { cx } from '../class-names.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
6
  export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, error, items, onChange, required = false, value, variant = 'compact', ...labelProps }, ref) {
7
7
  const errorId = useId();
8
+ const withIcons = items.some((item) => item.icon);
8
9
  function onKeyDown(event) {
9
10
  if (disabled || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft')) {
10
11
  return;
@@ -22,12 +23,12 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled
22
23
  }
23
24
  // The wrapper is always rendered, so an error that appears later swaps
24
25
  // text into a live region that already exists and the group keeps focus.
25
- return (_jsxs("div", { className: cx('sw-segmented-field', variant === 'filled' && 'sw-segmented-field-filled'), children: [_jsx("div", { ref: ref, "aria-describedby": error ? errorId : undefined, "aria-disabled": disabled || undefined, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
26
+ return (_jsxs("div", { className: cx('sw-segmented-field', variant === 'filled' && 'sw-segmented-field-filled'), children: [_jsx("div", { ref: ref, "aria-describedby": error ? errorId : undefined, "aria-disabled": disabled || undefined, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', withIcons && 'sw-segmented-with-icons', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
26
27
  const selected = item.id === value;
27
- return (_jsx("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
28
+ return (_jsxs("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
28
29
  if (!selected) {
29
30
  onChange?.(item.id);
30
31
  }
31
- }, role: "radio", type: "button", children: item.label }, item.id));
32
+ }, role: "radio", type: "button", children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-segmented-item-icon", children: item.icon })) : null, item.label] }, item.id));
32
33
  }) }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
33
34
  });
@@ -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,9 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ type CheckboxControlProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'type'>;
3
+ /**
4
+ * The native checkbox and its drawn mark, the one markup Checkbox's
5
+ * generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
6
+ * and MultiSelect's rows so the two cannot drift apart.
7
+ */
8
+ export declare function CheckboxControl(props: CheckboxControlProps): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The native checkbox and its drawn mark, the one markup Checkbox's
4
+ * generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
5
+ * and MultiSelect's rows so the two cannot drift apart.
6
+ */
7
+ export function CheckboxControl(props) {
8
+ return (_jsxs("span", { className: "sw-checkbox-control", children: [_jsx("input", { ...props, className: "sw-checkbox-input", type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }));
9
+ }
@@ -0,0 +1,25 @@
1
+ import { type RefObject } from 'react';
2
+ import { type MultiSelectAlign, type MultiSelectOption } from './types.js';
3
+ type MultiSelectPanelProps = {
4
+ id: string;
5
+ label: string;
6
+ align: MultiSelectAlign;
7
+ options: readonly MultiSelectOption[];
8
+ checked: ReadonlySet<string>;
9
+ allLabel: string;
10
+ clearLabel: string;
11
+ triggerRef: RefObject<HTMLButtonElement | null>;
12
+ onToggle: (value: string, checked: boolean) => void;
13
+ onAll: () => void;
14
+ onClear: () => void;
15
+ /** Closes the panel, returning focus to the trigger when asked. */
16
+ onClose: (restoreFocus: boolean) => void;
17
+ };
18
+ /**
19
+ * The open panel: a group named by the label on the popover layer beside
20
+ * the trigger, with All and Clear over one checkbox per option. The first
21
+ * checkbox takes focus as it opens; Escape closes it and returns focus to
22
+ * the trigger; focus or a press moving outside it closes it.
23
+ */
24
+ export declare function MultiSelectPanel({ align, allLabel, checked, clearLabel, id, label, onAll, onClear, onClose, onToggle, options, triggerRef, }: MultiSelectPanelProps): import("react").JSX.Element;
25
+ export {};
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useLayoutEffect, useRef, } from 'react';
4
+ import { Button } from '../Button.js';
5
+ import { CheckboxControl } from '../checkbox/CheckboxControl.js';
6
+ import { useAnchoredPopover } from '../use-anchored-popover.js';
7
+ /**
8
+ * The open panel: a group named by the label on the popover layer beside
9
+ * the trigger, with All and Clear over one checkbox per option. The first
10
+ * checkbox takes focus as it opens; Escape closes it and returns focus to
11
+ * the trigger; focus or a press moving outside it closes it.
12
+ */
13
+ export function MultiSelectPanel({ align, allLabel, checked, clearLabel, id, label, onAll, onClear, onClose, onToggle, options, triggerRef, }) {
14
+ const panelRef = useRef(null);
15
+ useAnchoredPopover(panelRef, triggerRef, triggerRef, () => onClose(false), {
16
+ align,
17
+ flipInline: true,
18
+ });
19
+ useLayoutEffect(() => {
20
+ panelRef.current
21
+ ?.querySelector('.sw-multi-select-option input')
22
+ ?.focus();
23
+ }, []);
24
+ function onKeyDown(event) {
25
+ if (event.key !== 'Escape')
26
+ return;
27
+ event.preventDefault();
28
+ onClose(true);
29
+ }
30
+ function onBlur(event) {
31
+ const next = event.relatedTarget;
32
+ if (!(next instanceof Node))
33
+ return;
34
+ if (panelRef.current?.contains(next) || triggerRef.current?.contains(next))
35
+ return;
36
+ onClose(false);
37
+ }
38
+ return (_jsxs("div", { "aria-label": label, className: "sw-multi-select-panel", id: id, onBlur: onBlur, onKeyDown: onKeyDown, ref: panelRef, role: "group", children: [_jsxs("div", { className: "sw-multi-select-commands", children: [_jsx(Button, { onPress: onAll, size: "sm", variant: "ghost", children: allLabel }), _jsx(Button, { onPress: onClear, size: "sm", variant: "ghost", children: clearLabel })] }), options.map((option) => (_jsx("div", { className: option.separatorBefore
39
+ ? 'sw-multi-select-row sw-multi-select-row-separated'
40
+ : 'sw-multi-select-row', children: _jsxs("label", { className: "sw-checkbox-label sw-multi-select-option", children: [_jsx(CheckboxControl, { checked: checked.has(option.value), onChange: (event) => onToggle(option.value, event.currentTarget.checked) }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-multi-select-icon", children: option.icon })) : null, _jsx("span", { className: "sw-multi-select-text", children: option.label })] }) }, option.value)))] }));
41
+ }
@@ -0,0 +1,35 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type InlineAlign } from '../../anchored-position.js';
3
+ /** One choice in a MultiSelect. */
4
+ export type MultiSelectOption = {
5
+ value: string;
6
+ label: string;
7
+ /** A consumer mark before the label, such as a `GlyphTile`; hidden from assistive technology. */
8
+ icon?: ReactNode;
9
+ /** Draws a hairline before this option, to set a group of options apart. */
10
+ separatorBefore?: boolean;
11
+ };
12
+ export type MultiSelectAlign = InlineAlign;
13
+ export type MultiSelectProps = {
14
+ /**
15
+ * What is chosen, such as "Show": the muted lead of the trigger and the
16
+ * name of the panel of checkboxes.
17
+ */
18
+ label: string;
19
+ options: readonly MultiSelectOption[];
20
+ /** The checked values; `onChange` reports them in the options' order. */
21
+ value: readonly string[];
22
+ onChange: (value: string[]) => void;
23
+ /** The command that checks every option, such as "All". */
24
+ allLabel: string;
25
+ /** The command that clears every option, such as "Clear". */
26
+ clearLabel: string;
27
+ /**
28
+ * The trigger's words after the label, naming what is chosen, such as
29
+ * "All entries" or "2 services". The consumer writes it.
30
+ */
31
+ summary: string;
32
+ disabled?: boolean;
33
+ /** The trigger edge the panel lines up with (default start). */
34
+ align?: MultiSelectAlign;
35
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type MultiSelectProps } from './types.js';
2
+ /** Fails closed on words or options a MultiSelect cannot show. */
3
+ export declare function assertMultiSelect({ allLabel, align, clearLabel, label, options, summary, value, }: Pick<MultiSelectProps, 'allLabel' | 'align' | 'clearLabel' | 'label' | 'options' | 'summary' | 'value'>): void;
@@ -0,0 +1,25 @@
1
+ function isBlank(text) {
2
+ return typeof text !== 'string' || !text.trim();
3
+ }
4
+ /** Fails closed on words or options a MultiSelect cannot show. */
5
+ export function assertMultiSelect({ allLabel, align, clearLabel, label, options, summary, value, }) {
6
+ if (isBlank(label) || isBlank(summary))
7
+ throw new RangeError('MultiSelect needs a label and a summary');
8
+ if (isBlank(allLabel) || isBlank(clearLabel))
9
+ throw new RangeError('MultiSelect needs an allLabel and a clearLabel');
10
+ if (options.length === 0)
11
+ throw new RangeError('MultiSelect needs at least one option');
12
+ const values = new Set();
13
+ for (const option of options) {
14
+ if (isBlank(option.value) || isBlank(option.label))
15
+ throw new RangeError('MultiSelect options need a value and a label');
16
+ if (values.has(option.value))
17
+ throw new RangeError(`Duplicate MultiSelect value: ${option.value}`);
18
+ values.add(option.value);
19
+ }
20
+ for (const checked of value)
21
+ if (!values.has(checked))
22
+ throw new RangeError(`Unknown MultiSelect value: ${checked}`);
23
+ if (align !== undefined && align !== 'start' && align !== 'end')
24
+ throw new RangeError(`Unknown MultiSelect align: ${String(align)}`);
25
+ }
@@ -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
  });
@@ -0,0 +1,39 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ import { type TableRowVariant } from '../../css/css-table-ledger.js';
3
+ export type { TableRowVariant };
4
+ export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
5
+ selected?: boolean;
6
+ /**
7
+ * `sub` sets the row under the one above it, as an item of a batch: its
8
+ * row header (`TableCell as="th" scope="row"`) is indented with a guide
9
+ * bar at the indent; a sub row without a row header is not indented.
10
+ * `default` is a row of its own.
11
+ */
12
+ variant?: TableRowVariant;
13
+ /**
14
+ * A voided row kept on the record: the subtle fill and the text muted
15
+ * and struck through, toned cells included. A line-through is not read
16
+ * aloud, so say why in the row, such as a `Badge` "Voided" in the
17
+ * title's line (a Badge in a line of text is not struck; one that is a
18
+ * flex item, a direct child of `Inline`, is). Works with either variant.
19
+ */
20
+ struck?: boolean;
21
+ };
22
+ export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableRowElement> & {
23
+ selected?: boolean;
24
+ /**
25
+ * `sub` sets the row under the one above it, as an item of a batch: its
26
+ * row header (`TableCell as="th" scope="row"`) is indented with a guide
27
+ * bar at the indent; a sub row without a row header is not indented.
28
+ * `default` is a row of its own.
29
+ */
30
+ variant?: TableRowVariant;
31
+ /**
32
+ * A voided row kept on the record: the subtle fill and the text muted
33
+ * and struck through, toned cells included. A line-through is not read
34
+ * aloud, so say why in the row, such as a `Badge` "Voided" in the
35
+ * title's line (a Badge in a line of text is not struck; one that is a
36
+ * flex item, a direct child of `Inline`, is). Works with either variant.
37
+ */
38
+ struck?: boolean;
39
+ } & import("react").RefAttributes<HTMLTableRowElement>>;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cx } from '../../class-names.js';
4
+ import { assertTableRowVariant, } from '../../css/css-table-ledger.js';
5
+ export const TableRow = forwardRef(function TableRow({ className, selected = false, struck = false, variant = 'default', ...rest }, ref) {
6
+ assertTableRowVariant(variant);
7
+ return (_jsx("tr", { ref: ref, "aria-selected": selected || undefined, className: cx(selected && 'sw-table-row-selected', variant === 'sub' && 'sw-table-row-sub', struck && 'sw-table-row-struck', className), ...rest }));
8
+ });
@@ -1,9 +1,14 @@
1
1
  import { type RefObject } from 'react';
2
2
  import { type ScrollOverflow } from '../scroll-overflow.js';
3
+ /** A box's overflow, and whether it has been measured yet. */
4
+ export type MeasuredScrollOverflow = ScrollOverflow & {
5
+ measured: boolean;
6
+ };
3
7
  /**
4
8
  * Follows which inline edges of a scroll box have more content past them:
5
9
  * on mount, on every scroll, and whenever the box or its content changes
6
- * size. Before mount (and on the server) it reports no overflow, so the
7
- * box renders as it always has until it is measured.
10
+ * size. Before mount (and on the server) it reports no overflow and
11
+ * `measured: false`, so the box renders as it always has until it is
12
+ * measured; a measured box with neither edge set fits its content.
8
13
  */
9
- export declare function useScrollOverflow(ref: RefObject<HTMLElement | null>): ScrollOverflow;
14
+ export declare function useScrollOverflow(ref: RefObject<HTMLElement | null>): MeasuredScrollOverflow;
@@ -1,23 +1,34 @@
1
1
  'use client';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { noScrollOverflow, sameScrollOverflow, scrollOverflow, } from '../scroll-overflow.js';
4
+ const unmeasured = {
5
+ ...noScrollOverflow,
6
+ measured: false,
7
+ };
4
8
  /**
5
9
  * Follows which inline edges of a scroll box have more content past them:
6
10
  * on mount, on every scroll, and whenever the box or its content changes
7
- * size. Before mount (and on the server) it reports no overflow, so the
8
- * box renders as it always has until it is measured.
11
+ * size. Before mount (and on the server) it reports no overflow and
12
+ * `measured: false`, so the box renders as it always has until it is
13
+ * measured; a measured box with neither edge set fits its content.
9
14
  */
10
15
  export function useScrollOverflow(ref) {
11
- const [overflow, setOverflow] = useState(noScrollOverflow);
16
+ const [overflow, setOverflow] = useState(unmeasured);
12
17
  useEffect(() => {
13
18
  const box = ref.current;
14
19
  if (!box)
15
20
  return;
21
+ // The metrics hold on a box that does not scroll (a fitting table
22
+ // region set to overflow: visible): scrollWidth still reports content
23
+ // past its edge, so the box turns back into a scroll box as it widens.
24
+ // Keep it that way; a scroll-container-only check would pin it open.
16
25
  function measure() {
17
26
  if (!box)
18
27
  return;
19
28
  const next = scrollOverflow(box.scrollLeft, box.scrollWidth, box.clientWidth);
20
- setOverflow((current) => sameScrollOverflow(current, next) ? current : next);
29
+ setOverflow((current) => current.measured && sameScrollOverflow(current, next)
30
+ ? current
31
+ : { ...next, measured: true });
21
32
  }
22
33
  measure();
23
34
  box.addEventListener('scroll', measure, { passive: true });
@@ -1,4 +1,5 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
+ import { chevronStroke } from './chevron.js';
2
3
  import { zIndex } from './stacking.js';
3
4
  import { coarsePointerQuery, touchTarget } from './touch-target.js';
4
5
  const caption = typographyVariants.caption;
@@ -114,6 +115,43 @@ export function cssActionMenuClasses() {
114
115
  color: var(--sw-color-danger);
115
116
  }
116
117
 
118
+ /*
119
+ * A command's detail: its current value at the inline end in the muted
120
+ * colour, then a chevron pointing to the inline end (mirrored right to
121
+ * left). A long value wraps between words, never inside one (the command
122
+ * itself may break anywhere), and the chevron stays last.
123
+ */
124
+ .sw-action-menu-detail {
125
+ align-items: center;
126
+ color: var(--sw-color-muted);
127
+ display: inline-flex;
128
+ gap: var(--sw-space-2);
129
+ margin-inline-start: auto;
130
+ overflow-wrap: normal;
131
+ padding-inline-start: var(--sw-space-3);
132
+ text-align: end;
133
+ }
134
+
135
+ .sw-action-menu-chevron {
136
+ ${chevronStroke}
137
+ flex: none;
138
+ transform: rotate(-45deg);
139
+ }
140
+
141
+ .sw-action-menu-chevron:dir(rtl) {
142
+ transform: rotate(135deg);
143
+ }
144
+
145
+ /* A hairline that sets a command apart from the ones above. */
146
+ .sw-action-menu-separator {
147
+ border-top: 1px solid var(--sw-color-border);
148
+ margin-block: var(--sw-space-1);
149
+ }
150
+
151
+ @media (forced-colors: active) {
152
+ .sw-action-menu-chevron { border-color: CanvasText; }
153
+ }
154
+
117
155
  /* A coarse pointer gets the full touch target: the trigger at least square, and every command as tall. */
118
156
  @media ${coarsePointerQuery} {
119
157
  .sw-action-menu-trigger {
@@ -135,5 +173,8 @@ export function actionMenuClassCatalog() {
135
173
  'sw-action-menu-items',
136
174
  'sw-action-menu-item',
137
175
  'sw-action-menu-item-danger',
176
+ 'sw-action-menu-detail',
177
+ 'sw-action-menu-chevron',
178
+ 'sw-action-menu-separator',
138
179
  ];
139
180
  }
@@ -0,0 +1,2 @@
1
+ export declare function cssButtonGroupAttachedClasses(): string;
2
+ export declare function buttonGroupAttachedClassCatalog(): string[];
@@ -0,0 +1,70 @@
1
+ import { breakpointQuery } from './breakpoints.js';
2
+ import { joinedZIndex } from './stacking.js';
3
+ /*
4
+ * An attached ButtonGroup is one joined control, such as Previous, Today
5
+ * and Next: its direct Button children side by side with no gap and one
6
+ * height, their borders overlapping into single dividers, the inner
7
+ * corners square and the outer ones the sm radius. It keeps its row on a
8
+ * phone (it never stacks) and sizes to its buttons, even in a Stack that
9
+ * would stretch it, so `justify` does not apply.
10
+ *
11
+ * A borderless variant (primary, tertiary, ghost, danger) draws its
12
+ * divider in its own text colour at low strength, so a joined row of them
13
+ * still reads as separate buttons; forced colours draw every border.
14
+ *
15
+ * The group is its own stacking context: a pressed button's ring paints
16
+ * over its neighbours and the focused button's standard outer ring over
17
+ * both, so neither is covered by the next button.
18
+ */
19
+ export function cssButtonGroupAttachedClasses() {
20
+ const group = '.sw-button-group.sw-button-group-attached';
21
+ return `${group} {
22
+ align-items: stretch;
23
+ display: inline-flex;
24
+ flex-wrap: nowrap;
25
+ gap: 0;
26
+ isolation: isolate;
27
+ max-width: 100%;
28
+ width: fit-content;
29
+ }
30
+
31
+ ${group} > .sw-button {
32
+ border-radius: 0;
33
+ }
34
+
35
+ ${group} > .sw-button + .sw-button {
36
+ margin-inline-start: -1px;
37
+ }
38
+
39
+ ${group} > .sw-button:first-child {
40
+ border-end-start-radius: var(--sw-radius-sm);
41
+ border-start-start-radius: var(--sw-radius-sm);
42
+ }
43
+
44
+ ${group} > .sw-button:last-child {
45
+ border-end-end-radius: var(--sw-radius-sm);
46
+ border-start-end-radius: var(--sw-radius-sm);
47
+ }
48
+
49
+ ${group} > .sw-button + .sw-button:not(.sw-button-secondary) {
50
+ border-inline-start-color: color-mix(in srgb, currentColor 30%, transparent);
51
+ }
52
+
53
+ ${group} > .sw-button[aria-pressed='true'] {
54
+ position: relative;
55
+ ${joinedZIndex('pressed')}
56
+ }
57
+
58
+ ${group} > .sw-button:focus-visible {
59
+ position: relative;
60
+ ${joinedZIndex('focused')}
61
+ }
62
+
63
+ @media ${breakpointQuery('below', 'md')} {
64
+ ${group} { align-items: stretch; flex-direction: row; }
65
+ ${group} > * { width: auto; }
66
+ }`;
67
+ }
68
+ export function buttonGroupAttachedClassCatalog() {
69
+ return ['sw-button-group-attached'];
70
+ }