@scalewing/react 1.30.0 → 1.32.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 (100) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +23 -5
  3. package/dist/components/ActionMenu.d.ts +10 -1
  4. package/dist/components/ActionMenu.js +7 -2
  5. package/dist/components/AppHeader.d.ts +9 -1
  6. package/dist/components/AppHeader.js +6 -2
  7. package/dist/components/Box.d.ts +10 -2
  8. package/dist/components/Button.d.ts +27 -2
  9. package/dist/components/Button.js +5 -3
  10. package/dist/components/ButtonGroup.d.ts +9 -1
  11. package/dist/components/ButtonGroup.js +8 -2
  12. package/dist/components/Combobox.d.ts +14 -0
  13. package/dist/components/Combobox.js +125 -0
  14. package/dist/components/DateField.d.ts +9 -1
  15. package/dist/components/DateField.js +4 -3
  16. package/dist/components/DenominationGrid.d.ts +5 -3
  17. package/dist/components/DenominationGrid.js +21 -11
  18. package/dist/components/Dialog.d.ts +9 -1
  19. package/dist/components/Dialog.js +8 -4
  20. package/dist/components/Field.d.ts +25 -0
  21. package/dist/components/Field.js +56 -6
  22. package/dist/components/FieldLabelText.d.ts +21 -3
  23. package/dist/components/FieldLabelText.js +16 -4
  24. package/dist/components/FilterChips.d.ts +9 -1
  25. package/dist/components/FilterChips.js +4 -2
  26. package/dist/components/Nav.d.ts +14 -2
  27. package/dist/components/Nav.js +6 -2
  28. package/dist/components/SegmentedControl.d.ts +5 -2
  29. package/dist/components/SegmentedControl.js +2 -1
  30. package/dist/components/Select.d.ts +9 -1
  31. package/dist/components/Select.js +4 -3
  32. package/dist/components/Stepper.d.ts +13 -0
  33. package/dist/components/Stepper.js +8 -2
  34. package/dist/components/combobox/ComboboxPopup.d.ts +25 -0
  35. package/dist/components/combobox/ComboboxPopup.js +68 -0
  36. package/dist/components/combobox/entries.d.ts +18 -0
  37. package/dist/components/combobox/entries.js +34 -0
  38. package/dist/components/combobox/types.d.ts +71 -0
  39. package/dist/components/combobox/types.js +1 -0
  40. package/dist/components/combobox/validate.d.ts +3 -0
  41. package/dist/components/combobox/validate.js +44 -0
  42. package/dist/components/denomination-grid/DenominationStrip.d.ts +4 -2
  43. package/dist/components/denomination-grid/DenominationStrip.js +3 -2
  44. package/dist/components/denomination-grid/DenominationTiles.d.ts +1 -1
  45. package/dist/components/denomination-grid/EntryCell.js +1 -1
  46. package/dist/components/denomination-grid/StripRow.js +5 -2
  47. package/dist/components/denomination-grid/types.d.ts +35 -1
  48. package/dist/components/dialog/DialogTitle.d.ts +5 -3
  49. package/dist/components/dialog/DialogTitle.js +4 -4
  50. package/dist/components/field/SearchFrame.d.ts +16 -0
  51. package/dist/components/field/SearchFrame.js +32 -0
  52. package/dist/css/css-action-menu-trigger.d.ts +11 -0
  53. package/dist/css/css-action-menu-trigger.js +66 -0
  54. package/dist/css/css-action-menu.js +7 -2
  55. package/dist/css/css-app-header-bar.d.ts +10 -0
  56. package/dist/css/css-app-header-bar.js +26 -0
  57. package/dist/css/css-box-border.d.ts +6 -1
  58. package/dist/css/css-box-border.js +8 -4
  59. package/dist/css/css-button-group-attached.js +5 -2
  60. package/dist/css/css-button-group-track.d.ts +11 -0
  61. package/dist/css/css-button-group-track.js +48 -0
  62. package/dist/css/css-button.d.ts +19 -2
  63. package/dist/css/css-button.js +31 -1
  64. package/dist/css/css-chrome.js +14 -1
  65. package/dist/css/css-combobox.d.ts +2 -0
  66. package/dist/css/css-combobox.js +136 -0
  67. package/dist/css/css-components.js +6 -0
  68. package/dist/css/css-data.js +3 -2
  69. package/dist/css/css-date-field.js +2 -1
  70. package/dist/css/css-denomination-appearance.d.ts +21 -0
  71. package/dist/css/css-denomination-appearance.js +114 -0
  72. package/dist/css/css-denomination-cell-tones.d.ts +8 -0
  73. package/dist/css/css-denomination-cell-tones.js +20 -1
  74. package/dist/css/css-denomination-grid.js +4 -0
  75. package/dist/css/css-dialog-banded.d.ts +11 -0
  76. package/dist/css/css-dialog-banded.js +84 -0
  77. package/dist/css/css-field-search.d.ts +2 -0
  78. package/dist/css/css-field-search.js +118 -0
  79. package/dist/css/css-field.js +4 -0
  80. package/dist/css/css-multi-select.js +2 -1
  81. package/dist/css/css-nav-words.d.ts +13 -0
  82. package/dist/css/css-nav-words.js +51 -0
  83. package/dist/css/css-notice.d.ts +4 -11
  84. package/dist/css/css-notice.js +6 -22
  85. package/dist/css/css-segmented-raised.d.ts +3 -0
  86. package/dist/css/css-segmented-raised.js +72 -0
  87. package/dist/css/css-segmented.js +9 -3
  88. package/dist/css/css-select.js +2 -1
  89. package/dist/css/css-split.js +2 -1
  90. package/dist/css/css-stepper.js +7 -0
  91. package/dist/css/quiet-fill.d.ts +7 -0
  92. package/dist/css/quiet-fill.js +7 -0
  93. package/dist/css/stacking.js +6 -2
  94. package/dist/css/stylesheet.js +4 -0
  95. package/dist/css/tone-tint.d.ts +21 -0
  96. package/dist/css/tone-tint.js +23 -0
  97. package/dist/index.d.ts +13 -10
  98. package/dist/index.js +11 -8
  99. package/dist/styles.css +505 -12
  100. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { cx } from '../class-names.js';
5
5
  import { assertStepperBounds, readStepperEntry, stepperMoves, stepperText, } from '../stepper-count.js';
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
7
  import { FieldLabelText } from './FieldLabelText.js';
8
+ export const stepperLimitAppearances = ['faded', 'plain'];
8
9
  /** Keeps focus where it was (the count, or nowhere) when a button is pressed. */
9
10
  function keepFocus(event) {
10
11
  event.preventDefault();
@@ -14,9 +15,11 @@ function keepFocus(event) {
14
15
  * tab stop (the count, a spin button that ArrowUp and ArrowDown step), with
15
16
  * the buttons as pointer targets beside it. − stops at `min`, + at `max`.
16
17
  */
17
- export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden = false, value, onChange, min = 0, max = Number.POSITIVE_INFINITY, step = 1, decrementLabel, incrementLabel, error, changed = false, disabled = false, readOnly = false, valueText, placeholder, name, width = 'fixed', }, ref) {
18
+ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden = false, value, onChange, min = 0, max = Number.POSITIVE_INFINITY, step = 1, decrementLabel, incrementLabel, error, changed = false, disabled = false, readOnly = false, valueText, placeholder, name, width = 'fixed', limitAppearance = 'faded', }, ref) {
18
19
  const bounds = { min, max, step };
19
20
  assertStepperBounds(bounds);
21
+ if (!stepperLimitAppearances.includes(limitAppearance))
22
+ throw new RangeError(`Unknown Stepper limitAppearance: ${String(limitAppearance)}`);
20
23
  if (value !== null && !Number.isSafeInteger(value))
21
24
  throw new RangeError('Stepper value must be a whole number or null');
22
25
  const inputId = useId();
@@ -70,7 +73,10 @@ export const Stepper = forwardRef(function Stepper({ label, labelVisuallyHidden
70
73
  move(target);
71
74
  }
72
75
  const invalid = Boolean(error);
73
- return (_jsxs("div", { className: cx('sw-stepper', width === 'full' && 'sw-stepper-full', invalid && 'sw-stepper-invalid', changed && !invalid && 'sw-stepper-changed', readOnly && 'sw-stepper-readonly'), children: [_jsx("label", { className: labelVisuallyHidden ? 'sw-sr-only' : undefined, htmlFor: inputId, id: labelId, children: _jsx(FieldLabelText, { label: label }) }), _jsxs("div", { "aria-labelledby": labelId, className: "sw-stepper-track", role: "group", children: [_jsx("button", { "aria-controls": inputId, "aria-label": decrementLabel, className: "sw-stepper-button", disabled: moves.down === null, onClick: () => move(moves.down), onMouseDown: keepFocus, tabIndex: -1, type: "button", children: _jsx("span", { "aria-hidden": "true", children: '−' }) }), _jsx("input", { ref: ref, "aria-describedby": invalid ? errorId : undefined, "aria-invalid": invalid || undefined, "aria-valuemax": Number.isFinite(max) ? max : undefined, "aria-valuemin": min, "aria-valuenow": value ?? undefined, "aria-valuetext": valueText, autoComplete: "off", className: "sw-stepper-input", disabled: disabled, id: inputId,
76
+ return (_jsxs("div", { className: cx('sw-stepper', width === 'full' && 'sw-stepper-full', invalid && 'sw-stepper-invalid', changed && !invalid && 'sw-stepper-changed', readOnly && 'sw-stepper-readonly', limitAppearance === 'plain' &&
77
+ !disabled &&
78
+ !readOnly &&
79
+ 'sw-stepper-limit-plain'), children: [_jsx("label", { className: labelVisuallyHidden ? 'sw-sr-only' : undefined, htmlFor: inputId, id: labelId, children: _jsx(FieldLabelText, { label: label }) }), _jsxs("div", { "aria-labelledby": labelId, className: "sw-stepper-track", role: "group", children: [_jsx("button", { "aria-controls": inputId, "aria-label": decrementLabel, className: "sw-stepper-button", disabled: moves.down === null, onClick: () => move(moves.down), onMouseDown: keepFocus, tabIndex: -1, type: "button", children: _jsx("span", { "aria-hidden": "true", children: '−' }) }), _jsx("input", { ref: ref, "aria-describedby": invalid ? errorId : undefined, "aria-invalid": invalid || undefined, "aria-valuemax": Number.isFinite(max) ? max : undefined, "aria-valuemin": min, "aria-valuenow": value ?? undefined, "aria-valuetext": valueText, autoComplete: "off", className: "sw-stepper-input", disabled: disabled, id: inputId,
74
80
  // iOS's numeric pad has no minus key, so a range below zero takes
75
81
  // the text keyboard.
76
82
  inputMode: allowsNegative ? 'text' : 'numeric', name: name, onBlur: () => setDraft(null), onChange: onType, onKeyDown: onKeyDown, placeholder: placeholder, readOnly: readOnly, role: "spinbutton", type: "text", value: text }), _jsx("button", { "aria-controls": inputId, "aria-label": incrementLabel, className: "sw-stepper-button", disabled: moves.up === null, onClick: () => move(moves.up), onMouseDown: keepFocus, tabIndex: -1, type: "button", children: _jsx("span", { "aria-hidden": "true", children: "+" }) })] }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
@@ -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,71 @@
1
+ import { type BadgeTone } from '../../css/css-data.js';
2
+ import { type RequiredMark } from '../FieldLabelText.js';
3
+ /** A short tag after an option's label, such as "Warning" or "Blocked". */
4
+ export type ComboboxBadge = {
5
+ label: string;
6
+ tone?: BadgeTone;
7
+ };
8
+ /** One suggestion in a Combobox. */
9
+ export type ComboboxOption = {
10
+ value: string;
11
+ label: string;
12
+ /** A muted line of detail after the label, such as "3 items · updated Sep 28". */
13
+ description?: string;
14
+ badge?: ComboboxBadge;
15
+ /** Shown, read as unavailable, and never chosen, such as an archived item. */
16
+ disabled?: boolean;
17
+ };
18
+ /** A run of suggestions under a heading, such as "All items". */
19
+ export type ComboboxGroup = {
20
+ /** The group's heading; a group without one has no heading. */
21
+ label?: string;
22
+ options: readonly ComboboxOption[];
23
+ };
24
+ /** A command at the end of the list, such as "+ New entry “acme”". */
25
+ export type ComboboxAction = {
26
+ label: string;
27
+ onPress: () => void;
28
+ badge?: ComboboxBadge;
29
+ };
30
+ export declare const comboboxSizes: readonly ["md", "xs"];
31
+ export type ComboboxSize = (typeof comboboxSizes)[number];
32
+ export type ComboboxProps = {
33
+ /** The input's name, drawn above it unless `labelVisuallyHidden`. */
34
+ label: string;
35
+ labelVisuallyHidden?: boolean;
36
+ /** The text in the input; the consumer filters `groups` from it. */
37
+ inputValue: string;
38
+ onInputChange: (value: string) => void;
39
+ groups: readonly ComboboxGroup[];
40
+ /** Called with the chosen option's value; the consumer sets `inputValue`. */
41
+ onSelect: (value: string) => void;
42
+ action?: ComboboxAction;
43
+ /**
44
+ * Shown in the list, and announced, when no group has an option, such as
45
+ * "Nothing matches".
46
+ */
47
+ emptyText: string;
48
+ /**
49
+ * The words announced for the number of options while the list is open,
50
+ * such as `(n) => \`${n} items\``; without it only the empty text is
51
+ * announced.
52
+ */
53
+ countText?: (count: number) => string;
54
+ /** The list's name when it differs from the input's, such as "Results". */
55
+ listLabel?: string;
56
+ /** A muted line under the suggestions, such as how to find more. */
57
+ note?: string;
58
+ placeholder?: string;
59
+ description?: string;
60
+ error?: string;
61
+ required?: boolean;
62
+ /**
63
+ * `hidden` leaves out the label's " *" while the input stays required
64
+ * (and announced so); `visible` (default) draws it.
65
+ * When hidden, say in visible text which fields must be filled; a
66
+ * placeholder alone is not enough.
67
+ */
68
+ requiredMark?: RequiredMark;
69
+ disabled?: boolean;
70
+ size?: ComboboxSize;
71
+ };
@@ -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
+ }
@@ -1,7 +1,9 @@
1
1
  import { type DenominationGridProps } from './types.js';
2
2
  type StripProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel'>> & Pick<DenominationGridProps, 'totalLabel' | 'labelWidth'> & {
3
- /** The row labels' appearance class, if any. */
3
+ /** The grid's appearance classes (labels, totals, cell tones), if any. */
4
4
  className?: string;
5
+ /** The `table` frame: a hairline around the region and a heading-row fill. */
6
+ framed: boolean;
5
7
  };
6
- export declare function DenominationStrip({ className, label, columns, rows, zeroLabel, totalLabel, labelWidth, }: StripProps): import("react").JSX.Element;
8
+ export declare function DenominationStrip({ className, framed, label, columns, rows, zeroLabel, totalLabel, labelWidth, }: StripProps): import("react").JSX.Element;
7
9
  export {};
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../../class-names.js';
3
3
  import { denominationLabelWidthClass } from '../../css/css-denomination-aligned.js';
4
+ import { denominationFramedClass, denominationScrollFramedClass, } from '../../css/css-denomination-appearance.js';
4
5
  import { ScrollRegion } from '../ScrollRegion.js';
5
6
  import { ColumnHeadText } from './ColumnHeadText.js';
6
7
  import { StripRow } from './StripRow.js';
@@ -16,7 +17,7 @@ function TotalHead({ totalLabel }) {
16
17
  return _jsx("td", { className: "sw-denomination-corner sw-denomination-total-head" });
17
18
  return (_jsx("th", { className: "sw-denomination-head sw-denomination-total-head", scope: "col", children: _jsx("span", { className: "sw-denomination-total-label", children: totalLabel }) }));
18
19
  }
19
- export function DenominationStrip({ className, label, columns, rows, zeroLabel, totalLabel, labelWidth, }) {
20
+ export function DenominationStrip({ className, framed, label, columns, rows, zeroLabel, totalLabel, labelWidth, }) {
20
21
  const hasTotals = rows.some((row) => row.total !== undefined);
21
22
  // An aligned strip sizes its columns from the region's width; it needs
22
23
  // the column count and whether a total column takes a share.
@@ -26,5 +27,5 @@ export function DenominationStrip({ className, label, columns, rows, zeroLabel,
26
27
  '--sw-denomination-columns': columns.length,
27
28
  '--sw-denomination-total': hasTotals ? 1 : 0,
28
29
  };
29
- return (_jsx(ScrollRegion, { "aria-label": label, className: cx('sw-denomination-scroll', aligned && 'sw-denomination-scroll-aligned'), children: _jsxs("table", { className: cx('sw-denomination-grid sw-denomination-strip', labelWidth && 'sw-denomination-strip-aligned', rows.some((row) => row.entry) && 'sw-denomination-strip-entry', labelWidth && denominationLabelWidthClass(labelWidth), className), style: aligned, children: [_jsx("caption", { className: "sw-sr-only", children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", { className: "sw-denomination-corner" }), columns.map((column) => (_jsx("th", { className: "sw-denomination-head", scope: "col", children: _jsx(ColumnHeadText, { column: column }) }, column.key))), hasTotals ? _jsx(TotalHead, { totalLabel: totalLabel }) : null] }) }), _jsx("tbody", { children: rows.map((row) => (_jsx(StripRow, { columns: columns, hasTotals: hasTotals, row: row, zeroLabel: zeroLabel }, row.id))) })] }) }));
30
+ return (_jsx(ScrollRegion, { "aria-label": label, className: cx('sw-denomination-scroll', aligned && 'sw-denomination-scroll-aligned', framed && denominationScrollFramedClass), children: _jsxs("table", { className: cx('sw-denomination-grid sw-denomination-strip', labelWidth && 'sw-denomination-strip-aligned', rows.some((row) => row.entry) && 'sw-denomination-strip-entry', labelWidth && denominationLabelWidthClass(labelWidth), framed && denominationFramedClass, className), style: aligned, children: [_jsx("caption", { className: "sw-sr-only", children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", { className: "sw-denomination-corner" }), columns.map((column) => (_jsx("th", { className: "sw-denomination-head", scope: "col", children: _jsx(ColumnHeadText, { column: column }) }, column.key))), hasTotals ? _jsx(TotalHead, { totalLabel: totalLabel }) : null] }) }), _jsx("tbody", { children: rows.map((row) => (_jsx(StripRow, { columns: columns, hasTotals: hasTotals, row: row, zeroLabel: zeroLabel }, row.id))) })] }) }));
30
31
  }
@@ -1,6 +1,6 @@
1
1
  import { type DenominationGridProps } from './types.js';
2
2
  type TilesProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel' | 'rowRole'>> & Pick<DenominationGridProps, 'subtotal'> & {
3
- /** The row labels' appearance class, if any. */
3
+ /** The grid's appearance classes (labels, totals), if any. */
4
4
  className?: string;
5
5
  };
6
6
  export declare function DenominationTiles({ className, label, columns, rows, rowRole, subtotal, zeroLabel, }: TilesProps): import("react").JSX.Element;
@@ -12,5 +12,5 @@ import { columnName } from './ColumnHeadText.js';
12
12
  export function EntryCell({ row, entry, column, index }) {
13
13
  const max = entry.max?.[index] ?? undefined;
14
14
  const tone = row.cellTones?.[index];
15
- return (_jsx("td", { className: cx('sw-denomination-entry', ...denominationCellToneClassNames(tone)), children: _jsx(Stepper, { changed: tone === 'accent', decrementLabel: entry.decrementLabel, disabled: row.disabled, error: entry.errors?.[index] ?? undefined, incrementLabel: entry.incrementLabel, label: `${row.label} ${columnName(column)}`, labelVisuallyHidden: true, max: max, onChange: (value) => entry.onChange(column.key, value), placeholder: entry.placeholder, value: row.cells[index] ?? null, width: "full" }) }));
15
+ return (_jsx("td", { className: cx('sw-denomination-entry', ...denominationCellToneClassNames(tone)), children: _jsx(Stepper, { changed: tone === 'accent', decrementLabel: entry.decrementLabel, disabled: row.disabled, error: entry.errors?.[index] ?? undefined, incrementLabel: entry.incrementLabel, label: `${row.label} ${columnName(column)}`, labelVisuallyHidden: true, limitAppearance: entry.limitAppearance, max: max, onChange: (value) => entry.onChange(column.key, value), placeholder: entry.placeholder, value: row.cells[index] ?? null, width: "full" }) }));
16
16
  }
@@ -4,10 +4,13 @@ import { denominationCellToneClassNames } from '../../css/css-denomination-cell-
4
4
  import { denominationCellView } from '../../denomination-cells.js';
5
5
  import { EntryCell } from './EntryCell.js';
6
6
  import { RowLabelLine } from './RowLabelLine.js';
7
- /** One count, read-only: the zero label, a signed delta, and the cell's tone. */
7
+ /**
8
+ * One count, read-only: the zero label, a signed delta, and the cell's tone.
9
+ * The value has its own box, which the `pill` tone appearance fills.
10
+ */
8
11
  function CountCell({ row, index, zeroLabel, }) {
9
12
  const view = denominationCellView(row.cells[index] ?? null, row.signed ?? false, zeroLabel);
10
- return (_jsx("td", { className: cx('sw-denomination-cell', `sw-denomination-cell-${view.state}`, ...denominationCellToneClassNames(row.cellTones?.[index])), children: view.text }));
13
+ return (_jsx("td", { className: cx('sw-denomination-cell', `sw-denomination-cell-${view.state}`, ...denominationCellToneClassNames(row.cellTones?.[index])), children: _jsx("span", { className: "sw-denomination-cell-value", children: view.text }) }));
11
14
  }
12
15
  /**
13
16
  * One strip row: its pinned label (with the phone total under it), a count
@@ -1,8 +1,11 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type BadgeTone } from '../../css/css-data.js';
3
+ import { type DenominationGridFrame, type DenominationTotalEmphasis } from '../../css/css-denomination-appearance.js';
4
+ import { type DenominationCellToneAppearance } from '../../css/css-denomination-cell-tones.js';
3
5
  import { type DenominationLabelAppearance } from '../../css/css-denomination-label.js';
4
6
  import { type DenominationLabelWidth } from '../../css/css-denomination-aligned.js';
5
- export type { DenominationLabelWidth };
7
+ import { type StepperLimitAppearance } from '../Stepper.js';
8
+ export type { DenominationCellToneAppearance, DenominationGridFrame, DenominationLabelWidth, DenominationTotalEmphasis, };
6
9
  export type DenominationGridTone = BadgeTone;
7
10
  export type DenominationGridLayout = 'strip' | 'tiles';
8
11
  /**
@@ -36,6 +39,11 @@ export type DenominationGridEntry = {
36
39
  errors?: readonly (string | null)[];
37
40
  /** What an empty box shows, such as "0". */
38
41
  placeholder?: string;
42
+ /**
43
+ * The Steppers' `limitAppearance`: `plain` keeps − at 0 (and + at a cap)
44
+ * in its usual look instead of faded; it is still disabled.
45
+ */
46
+ limitAppearance?: StepperLimitAppearance;
39
47
  };
40
48
  export type DenominationGridRow = {
41
49
  id: string;
@@ -79,6 +87,11 @@ export type DenominationGridProps = {
79
87
  rows: readonly DenominationGridRow[];
80
88
  layout?: DenominationGridLayout;
81
89
  subtotal?: (count: number, column: DenominationGridColumn) => string;
90
+ /**
91
+ * What a zero or empty read-only count shows, `—` by default. An empty
92
+ * string leaves those counts blank, so only the counts that are there
93
+ * stand out (the column header still names each cell).
94
+ */
82
95
  zeroLabel?: string;
83
96
  /**
84
97
  * Names the strip's total column, such as "Total": a column header over
@@ -111,4 +124,25 @@ export type DenominationGridProps = {
111
124
  * total.
112
125
  */
113
126
  labelAppearance?: DenominationLabelAppearance;
127
+ /**
128
+ * `table` frames the strip: its surface inside a hairline with rounded
129
+ * corners, the heading row on the subtle fill, a rule between columns,
130
+ * and the counts at label size, centred under their heads. `none` (the
131
+ * default) leaves open rows on the page's surface. Only the strip has a
132
+ * frame.
133
+ */
134
+ frame?: DenominationGridFrame;
135
+ /**
136
+ * `strong` sets the row totals in bold label type, a neutral row's in the
137
+ * text color and a toned row's in its tone; `default` keeps them in data
138
+ * type in the row's tone (a neutral row's muted).
139
+ */
140
+ totalEmphasis?: DenominationTotalEmphasis;
141
+ /**
142
+ * How a `cellTones` tone shows on a strip's read-only count: `text` (the
143
+ * default) sets the count in the tone; `pill` sets it on a rounded tint of
144
+ * the tone in the tone's ink, such as a difference in danger or a
145
+ * changed count in accent. Entry counts and tiles keep their tone border.
146
+ */
147
+ cellToneAppearance?: DenominationCellToneAppearance;
114
148
  };
@@ -11,12 +11,14 @@ type DialogTitleProps = {
11
11
  /** The close button's accessible name; without it there is no button. */
12
12
  closeLabel?: string;
13
13
  onClose: () => void;
14
+ /** A banded dialog's title is always a row, which draws the band. */
15
+ banded: boolean;
14
16
  };
15
17
  /**
16
18
  * The dialog's title, which names it through `aria-labelledby`. Alone it is
17
19
  * the heading, as it always was. With an icon, a description or a
18
- * `closeLabel` it is a row: the icon, the title over its description, and
19
- * an icon-only ghost close button at the end.
20
+ * `closeLabel`, or in a banded dialog, it is a row: the icon, the title over
21
+ * its description, and an icon-only ghost close button at the end.
20
22
  */
21
- export declare function DialogTitle({ closeLabel, description, descriptionId, icon, id, level, onClose, title, }: DialogTitleProps): import("react").JSX.Element;
23
+ export declare function DialogTitle({ banded, closeLabel, description, descriptionId, icon, id, level, onClose, title, }: DialogTitleProps): import("react").JSX.Element;
22
24
  export {};
@@ -5,12 +5,12 @@ import { Text } from '../Text.js';
5
5
  /**
6
6
  * The dialog's title, which names it through `aria-labelledby`. Alone it is
7
7
  * the heading, as it always was. With an icon, a description or a
8
- * `closeLabel` it is a row: the icon, the title over its description, and
9
- * an icon-only ghost close button at the end.
8
+ * `closeLabel`, or in a banded dialog, it is a row: the icon, the title over
9
+ * its description, and an icon-only ghost close button at the end.
10
10
  */
11
- export function DialogTitle({ closeLabel, description, descriptionId, icon, id, level, onClose, title, }) {
11
+ export function DialogTitle({ banded, closeLabel, description, descriptionId, icon, id, level, onClose, title, }) {
12
12
  const heading = (_jsx(Text, { as: level === 2 ? 'h2' : 'h3', id: id, variant: "title", children: title }));
13
- if (!closeLabel && !icon && !description)
13
+ if (!banded && !closeLabel && !icon && !description)
14
14
  return heading;
15
15
  return (_jsxs("div", { className: cx('sw-dialog-header', Boolean(icon || description) && 'sw-dialog-header-framed'), children: [icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-dialog-icon", children: icon })) : null, description ? (_jsxs("div", { className: "sw-dialog-titles", children: [heading, _jsx(Text, { as: "p", className: "sw-dialog-description", color: "muted", id: descriptionId, variant: "caption", children: description })] })) : (heading), closeLabel ? (_jsxs(Button, { className: "sw-dialog-close", onPress: onClose, size: "sm", variant: "ghost", children: [_jsx("span", { className: "sw-sr-only", children: closeLabel }), _jsx("span", { "aria-hidden": "true", className: "sw-dialog-close-glyph" })] })) : null] }));
16
16
  }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ export declare const actionMenuTriggerAppearances: readonly ["framed", "text"];
2
+ /**
3
+ * How an ActionMenu's trigger looks: `framed` (the default) is a small
4
+ * outlined control; `text` is its words in the text color with a chevron
5
+ * after them and no fill or outline until hovered or open, such as a
6
+ * person's name that opens their account menu in a header.
7
+ */
8
+ export type ActionMenuTriggerAppearance = (typeof actionMenuTriggerAppearances)[number];
9
+ export declare const actionMenuTriggerTextClass = "sw-action-menu-trigger-text";
10
+ export declare function cssActionMenuTriggerClasses(): string;
11
+ export declare function actionMenuTriggerClassCatalog(): string[];
@@ -0,0 +1,66 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { chevronStroke } from './chevron.js';
3
+ import { quietFill } from './quiet-fill.js';
4
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
5
+ const label = typographyVariants.label;
6
+ export const actionMenuTriggerAppearances = ['framed', 'text'];
7
+ export const actionMenuTriggerTextClass = 'sw-action-menu-trigger-text';
8
+ const text = `.sw-action-menu-trigger.${actionMenuTriggerTextClass}`;
9
+ /*
10
+ * A text trigger: no fill and a transparent border (kept, so forced colors
11
+ * can draw one), its words in label type at medium weight in the text
12
+ * color, the sm control height (the 44 px target on a coarse pointer), and
13
+ * the shared muted chevron pointing down after them, a space-2 gap apart.
14
+ * The quiet fill shows while the menu is open, so the open state is seen
15
+ * as well as announced, and on hover where the pointer can hover (a tap
16
+ * leaves no fill behind). Two classes, so it outranks the framed
17
+ * trigger's rules; the coarse-pointer target is restated after. Forced
18
+ * colors draw the kept border in the system's border color, the chevron
19
+ * in CanvasText as every chevron, and the open trigger's border in
20
+ * Highlight.
21
+ */
22
+ export function cssActionMenuTriggerClasses() {
23
+ return `${text} {
24
+ align-items: center;
25
+ background: transparent;
26
+ border-color: transparent;
27
+ display: inline-flex;
28
+ font-family: var(--sw-font-sans);
29
+ font-size: ${label.fontSize}px;
30
+ font-weight: 500;
31
+ gap: var(--sw-space-2);
32
+ letter-spacing: ${label.letterSpacing}px;
33
+ line-height: ${label.lineHeight}px;
34
+ min-height: var(--sw-control-sm-min-height);
35
+ padding-inline: var(--sw-space-3) var(--sw-space-2);
36
+ }
37
+
38
+ ${text}::after {
39
+ ${chevronStroke}
40
+ content: '';
41
+ flex: none;
42
+ transform: translateY(-25%) rotate(45deg);
43
+ }
44
+
45
+ ${text}[aria-expanded='true'] {
46
+ background: ${quietFill};
47
+ }
48
+
49
+ @media (hover: hover) {
50
+ ${text}:hover:not(:disabled) {
51
+ background: ${quietFill};
52
+ }
53
+ }
54
+
55
+ @media ${coarsePointerQuery} {
56
+ ${text} { min-height: ${touchTarget}; }
57
+ }
58
+
59
+ @media (forced-colors: active) {
60
+ ${text}::after { border-color: CanvasText; }
61
+ ${text}[aria-expanded='true'] { border-color: Highlight; }
62
+ }`;
63
+ }
64
+ export function actionMenuTriggerClassCatalog() {
65
+ return [actionMenuTriggerTextClass];
66
+ }