@scalewing/react 1.26.0 → 1.28.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 (81) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +147 -3
  3. package/dist/components/ActionBar.d.ts +14 -0
  4. package/dist/components/ActionBar.js +5 -2
  5. package/dist/components/DenominationGrid.d.ts +5 -2
  6. package/dist/components/DenominationGrid.js +11 -3
  7. package/dist/components/Dialog.d.ts +28 -4
  8. package/dist/components/Dialog.js +20 -2
  9. package/dist/components/Notice.d.ts +69 -0
  10. package/dist/components/Notice.js +48 -0
  11. package/dist/components/Progress.d.ts +3 -1
  12. package/dist/components/Progress.js +4 -0
  13. package/dist/components/Stepper.d.ts +44 -0
  14. package/dist/components/Stepper.js +77 -0
  15. package/dist/components/Steps.d.ts +18 -0
  16. package/dist/components/Steps.js +34 -0
  17. package/dist/components/Toast.d.ts +20 -4
  18. package/dist/components/Toast.js +44 -21
  19. package/dist/components/action-bar/ActionBarSummary.d.ts +22 -0
  20. package/dist/components/action-bar/ActionBarSummary.js +23 -0
  21. package/dist/components/denomination-grid/ColumnHeadText.d.ts +7 -0
  22. package/dist/components/denomination-grid/ColumnHeadText.js +13 -0
  23. package/dist/components/denomination-grid/DenominationStrip.d.ts +5 -2
  24. package/dist/components/denomination-grid/DenominationStrip.js +4 -8
  25. package/dist/components/denomination-grid/DenominationTiles.d.ts +5 -2
  26. package/dist/components/denomination-grid/DenominationTiles.js +4 -3
  27. package/dist/components/denomination-grid/EntryCell.d.ts +15 -0
  28. package/dist/components/denomination-grid/EntryCell.js +16 -0
  29. package/dist/components/denomination-grid/RowLabelLine.d.ts +3 -2
  30. package/dist/components/denomination-grid/RowLabelLine.js +5 -3
  31. package/dist/components/denomination-grid/StripRow.d.ts +13 -0
  32. package/dist/components/denomination-grid/StripRow.js +19 -0
  33. package/dist/components/denomination-grid/types.d.ts +53 -1
  34. package/dist/components/denomination-grid/validate-entry.d.ts +15 -0
  35. package/dist/components/denomination-grid/validate-entry.js +78 -0
  36. package/dist/components/dialog/DialogFooter.d.ts +14 -0
  37. package/dist/components/dialog/DialogFooter.js +11 -0
  38. package/dist/components/dialog/DialogTitle.d.ts +11 -4
  39. package/dist/components/dialog/DialogTitle.js +8 -6
  40. package/dist/components/steps/Step.d.ts +24 -0
  41. package/dist/components/steps/Step.js +62 -0
  42. package/dist/components/steps/step-status.d.ts +26 -0
  43. package/dist/components/steps/step-status.js +37 -0
  44. package/dist/components/steps/steps-context.d.ts +16 -0
  45. package/dist/components/steps/steps-context.js +3 -0
  46. package/dist/components/toast/ToastContent.d.ts +22 -0
  47. package/dist/components/toast/ToastContent.js +21 -0
  48. package/dist/components/toast/use-toast-hold.d.ts +28 -0
  49. package/dist/components/toast/use-toast-hold.js +53 -0
  50. package/dist/components/toast/use-toast-timer.d.ts +15 -0
  51. package/dist/components/toast/use-toast-timer.js +34 -0
  52. package/dist/css/css-action-bar-summary.d.ts +2 -0
  53. package/dist/css/css-action-bar-summary.js +48 -0
  54. package/dist/css/css-action-bar.js +4 -0
  55. package/dist/css/css-components.js +16 -0
  56. package/dist/css/css-denomination-entry.d.ts +2 -0
  57. package/dist/css/css-denomination-entry.js +78 -0
  58. package/dist/css/css-denomination-grid.js +8 -0
  59. package/dist/css/css-denomination-label.d.ts +10 -0
  60. package/dist/css/css-denomination-label.js +36 -0
  61. package/dist/css/css-dialog-frame.d.ts +2 -0
  62. package/dist/css/css-dialog-frame.js +83 -0
  63. package/dist/css/css-dialog.js +20 -4
  64. package/dist/css/css-notice.d.ts +22 -0
  65. package/dist/css/css-notice.js +168 -0
  66. package/dist/css/css-progress.d.ts +3 -0
  67. package/dist/css/css-progress.js +13 -6
  68. package/dist/css/css-stepper.d.ts +10 -0
  69. package/dist/css/css-stepper.js +126 -0
  70. package/dist/css/css-steps.d.ts +9 -0
  71. package/dist/css/css-steps.js +203 -0
  72. package/dist/css/css-toast-card.d.ts +5 -0
  73. package/dist/css/css-toast-card.js +122 -0
  74. package/dist/css/css-variables.js +1 -0
  75. package/dist/css/stylesheet.js +10 -0
  76. package/dist/index.d.ts +9 -6
  77. package/dist/index.js +8 -5
  78. package/dist/stepper-count.d.ts +37 -0
  79. package/dist/stepper-count.js +50 -0
  80. package/dist/styles.css +623 -1
  81. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ import { type DenominationGridColumn, type DenominationGridRow } from './types.js';
2
+ type StripRowProps = {
3
+ row: DenominationGridRow;
4
+ columns: readonly DenominationGridColumn[];
5
+ zeroLabel: string;
6
+ hasTotals: boolean;
7
+ };
8
+ /**
9
+ * One strip row: its pinned label (with the phone total under it), a count
10
+ * per column, read-only or entered, and its total.
11
+ */
12
+ export declare function StripRow({ row, columns, zeroLabel, hasTotals, }: StripRowProps): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
3
+ import { denominationCellToneClassNames } from '../../css/css-denomination-cell-tones.js';
4
+ import { denominationCellView } from '../../denomination-cells.js';
5
+ import { EntryCell } from './EntryCell.js';
6
+ import { RowLabelLine } from './RowLabelLine.js';
7
+ /** One count, read-only: the zero label, a signed delta, and the cell's tone. */
8
+ function CountCell({ row, index, zeroLabel, }) {
9
+ 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 }));
11
+ }
12
+ /**
13
+ * One strip row: its pinned label (with the phone total under it), a count
14
+ * per column, read-only or entered, and its total.
15
+ */
16
+ export function StripRow({ row, columns, zeroLabel, hasTotals, }) {
17
+ const { entry } = row;
18
+ return (_jsxs("tr", { "aria-disabled": row.disabled || undefined, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`, row.signed && 'sw-denomination-row-signed', row.emphasis === 'quiet' && 'sw-denomination-row-quiet', row.disabled && 'sw-denomination-row-disabled'), children: [_jsx("th", { className: "sw-denomination-label", scope: "row", children: _jsxs("span", { className: "sw-denomination-label-body", children: [_jsx(RowLabelLine, { row: row }), row.total !== undefined ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-total-inline", children: row.total })) : null] }) }), columns.map((column, index) => entry ? (_jsx(EntryCell, { column: column, entry: entry, index: index, row: row }, column.key)) : (_jsx(CountCell, { index: index, row: row, zeroLabel: zeroLabel }, column.key))), hasTotals ? (_jsx("td", { className: "sw-denomination-total", children: _jsx("span", { className: "sw-denomination-total-value", children: row.total ?? '' }) })) : null] }));
19
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type BadgeTone } from '../../css/css-data.js';
3
+ import { type DenominationLabelAppearance } from '../../css/css-denomination-label.js';
3
4
  import { type DenominationLabelWidth } from '../../css/css-denomination-aligned.js';
4
5
  export type { DenominationLabelWidth };
5
6
  export type DenominationGridTone = BadgeTone;
@@ -12,6 +13,29 @@ export type DenominationGridRowRole = 'region' | 'group';
12
13
  export type DenominationGridColumn = {
13
14
  key: string;
14
15
  label: string;
16
+ /** A muted note after the label, such as "× 100" for a strap of $1 bills. */
17
+ caption?: string;
18
+ };
19
+ /** How a row sits among the others: `quiet` sets it on the subtle fill, for a row to compare against. */
20
+ export type DenominationGridRowEmphasis = 'default' | 'quiet';
21
+ /**
22
+ * Makes a strip row's counts editable: one `Stepper` per column, holding the
23
+ * row's `cells` (`null` is an empty box), named by the row label and the
24
+ * column's label and caption ("Counted $1 × 100"). The minimum is 0. The
25
+ * grid does no arithmetic: recompute the row's `total` in `onChange`.
26
+ */
27
+ export type DenominationGridEntry = {
28
+ onChange: (columnKey: string, value: number | null) => void;
29
+ /** The − buttons' name, such as "One less". */
30
+ decrementLabel: string;
31
+ /** The + buttons' name, such as "One more". */
32
+ incrementLabel: string;
33
+ /** A cap per column (`null` for none), such as the bills the drawer holds. */
34
+ max?: readonly (number | null)[];
35
+ /** A message per column (`null` for none), shown under that count. */
36
+ errors?: readonly (string | null)[];
37
+ /** What an empty box shows, such as "0". */
38
+ placeholder?: string;
15
39
  };
16
40
  export type DenominationGridRow = {
17
41
  id: string;
@@ -25,9 +49,29 @@ export type DenominationGridRow = {
25
49
  * A tone per count, one entry per column (null for none), for a single
26
50
  * count that needs attention, such as a bill the vault is short of. The
27
51
  * count is set in the tone and, in the tiles layout, its tile's border
28
- * too, in place. The tone never says why alone: put that in words.
52
+ * too, in place; an entry count's border takes it. The tone never says
53
+ * why alone: put that in words.
29
54
  */
30
55
  cellTones?: readonly (DenominationGridTone | null)[];
56
+ /** A muted line under the label, such as "from the customer". */
57
+ caption?: string;
58
+ /** The `strip` layout only. */
59
+ emphasis?: DenominationGridRowEmphasis;
60
+ /**
61
+ * A row that does not apply now (the `strip` layout only): its entry is
62
+ * disabled, its label, counts and total fade, and the row is
63
+ * `aria-disabled`.
64
+ */
65
+ disabled?: boolean;
66
+ /**
67
+ * Count entry for this row (the `strip` layout only). Its counts are zero
68
+ * or more, it is not `signed`, and its label is its own among the entry
69
+ * rows, as each column's label and caption are, so every count has its
70
+ * own name. A cell tone draws the count's border in that tone (an
71
+ * `accent` one is the `Stepper`'s changed look); a message in
72
+ * `entry.errors` wins.
73
+ */
74
+ entry?: DenominationGridEntry;
31
75
  };
32
76
  export type DenominationGridProps = {
33
77
  label: string;
@@ -59,4 +103,12 @@ export type DenominationGridProps = {
59
103
  * strip has a label column.
60
104
  */
61
105
  labelWidth?: DenominationLabelWidth;
106
+ /**
107
+ * `tone` (the default) marks each row label with its row's tone: a
108
+ * stripe at its start and the words in the tone. `plain` sets every row
109
+ * label in the text color at label weight with no stripe, for a grid
110
+ * whose rows need no color to tell apart; a row's tone still colors its
111
+ * total.
112
+ */
113
+ labelAppearance?: DenominationLabelAppearance;
62
114
  };
@@ -0,0 +1,15 @@
1
+ import { type DenominationGridColumn, type DenominationGridLayout, type DenominationGridRow } from './types.js';
2
+ /**
3
+ * The parts of a row and column that only label or set a row apart: a
4
+ * caption is never blank, an emphasis is a known one, `disabled` is a
5
+ * boolean, and a quiet or disabled row is a strip row (tiles have no row
6
+ * fill to set apart).
7
+ */
8
+ export declare function assertDenominationExtras(layout: DenominationGridLayout, columns: readonly DenominationGridColumn[], rows: readonly DenominationGridRow[]): void;
9
+ /**
10
+ * Entry rows: the strip layout only, named buttons, counts of zero or more,
11
+ * no signed deltas, one cap and one message (or null) per column with caps
12
+ * that are whole counts of zero or more, and one name per count: every
13
+ * entry row label and every column name (label and caption) is its own.
14
+ */
15
+ export declare function assertDenominationEntries(layout: DenominationGridLayout, columns: readonly DenominationGridColumn[], rows: readonly DenominationGridRow[]): void;
@@ -0,0 +1,78 @@
1
+ import { columnName } from './ColumnHeadText.js';
2
+ const emphases = ['default', 'quiet'];
3
+ /**
4
+ * The parts of a row and column that only label or set a row apart: a
5
+ * caption is never blank, an emphasis is a known one, `disabled` is a
6
+ * boolean, and a quiet or disabled row is a strip row (tiles have no row
7
+ * fill to set apart).
8
+ */
9
+ export function assertDenominationExtras(layout, columns, rows) {
10
+ if (columns.some((column) => isBlank(column.caption)))
11
+ throw new RangeError('a column caption must not be empty');
12
+ if (rows.some((row) => isBlank(row.caption)))
13
+ throw new RangeError('a row caption must not be empty');
14
+ if (rows.some((row) => row.emphasis !== undefined && !emphases.includes(row.emphasis)))
15
+ throw new RangeError("emphasis must be 'default' or 'quiet'");
16
+ if (rows.some((row) => row.disabled !== undefined && typeof row.disabled !== 'boolean'))
17
+ throw new RangeError('disabled must be a boolean');
18
+ if (layout !== 'strip' &&
19
+ rows.some((row) => row.emphasis === 'quiet' || row.disabled))
20
+ throw new RangeError('quiet and disabled rows need the strip layout');
21
+ }
22
+ /**
23
+ * Entry rows: the strip layout only, named buttons, counts of zero or more,
24
+ * no signed deltas, one cap and one message (or null) per column with caps
25
+ * that are whole counts of zero or more, and one name per count: every
26
+ * entry row label and every column name (label and caption) is its own.
27
+ */
28
+ export function assertDenominationEntries(layout, columns, rows) {
29
+ const entryRows = rows.filter((row) => row.entry);
30
+ if (entryRows.length === 0)
31
+ return;
32
+ if (layout !== 'strip')
33
+ throw new RangeError('entry rows need the strip layout');
34
+ if (!allDistinct(entryRows.map((row) => row.label)))
35
+ throw new RangeError('entry rows need distinct labels');
36
+ if (!allDistinct(columns.map(columnName)))
37
+ throw new RangeError('entry rows need columns with distinct labels and captions');
38
+ for (const row of entryRows) {
39
+ if (row.signed)
40
+ throw new RangeError('an entry row cannot be signed');
41
+ if (row.cells.some((cell) => cell !== null && cell < 0))
42
+ throw new RangeError('an entry row counts zero or more');
43
+ assertEntry(row.entry, columns.length);
44
+ }
45
+ }
46
+ function assertEntry(entry, columnCount) {
47
+ if (typeof entry.onChange !== 'function')
48
+ throw new RangeError('entry needs an onChange');
49
+ if (!entry.decrementLabel.trim() || !entry.incrementLabel.trim())
50
+ throw new RangeError('entry needs decrementLabel and incrementLabel');
51
+ if (!fitsColumns(entry.max, columnCount, isCap))
52
+ throw new RangeError('entry.max needs one whole count of zero or more, or null, per column');
53
+ if (!fitsColumns(entry.errors, columnCount, isMessage))
54
+ throw new RangeError('entry.errors needs one message or null per column');
55
+ }
56
+ function allDistinct(names) {
57
+ return new Set(names).size === names.length;
58
+ }
59
+ function isBlank(text) {
60
+ return text !== undefined && !text.trim();
61
+ }
62
+ function isCap(cap) {
63
+ return cap === null || (Number.isSafeInteger(cap) && cap >= 0);
64
+ }
65
+ function isMessage(message) {
66
+ return message === null || typeof message === 'string';
67
+ }
68
+ function fitsColumns(values, columnCount, fits) {
69
+ if (values === undefined)
70
+ return true;
71
+ if (!Array.isArray(values) || values.length !== columnCount)
72
+ return false;
73
+ // An index loop, not every(): every() skips the holes of a sparse array.
74
+ for (let index = 0; index < columnCount; index += 1)
75
+ if (!fits(values[index]))
76
+ return false;
77
+ return true;
78
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ActionBarSummary } from '../action-bar/ActionBarSummary.js';
3
+ type DialogFooterProps = {
4
+ summary?: ActionBarSummary;
5
+ children?: ReactNode;
6
+ };
7
+ /**
8
+ * The dialog's pinned footer: the summary figure (in a polite live region,
9
+ * as in ActionBar) at the start, the actions at the end. Rendered only when
10
+ * the dialog has a footer or a summary, and then always with its status
11
+ * region, so a new figure is announced.
12
+ */
13
+ export declare function DialogFooter({ children, summary }: DialogFooterProps): import("react").JSX.Element;
14
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ActionBarSummaryLine, } from '../action-bar/ActionBarSummary.js';
3
+ /**
4
+ * The dialog's pinned footer: the summary figure (in a polite live region,
5
+ * as in ActionBar) at the start, the actions at the end. Rendered only when
6
+ * the dialog has a footer or a summary, and then always with its status
7
+ * region, so a new figure is announced.
8
+ */
9
+ export function DialogFooter({ children, summary }) {
10
+ return (_jsxs("div", { className: "sw-dialog-footer", children: [_jsx("div", { className: "sw-dialog-footer-status", role: "status", children: summary ? _jsx(ActionBarSummaryLine, { summary: summary }) : null }), children ? (_jsx("div", { className: "sw-dialog-footer-actions", children: children })) : null] }));
11
+ }
@@ -1,15 +1,22 @@
1
+ import { type ReactNode } from 'react';
1
2
  type DialogTitleProps = {
2
3
  id: string;
3
4
  level: 2 | 3;
4
5
  title: string;
6
+ /** A muted line under the title, which describes the dialog. */
7
+ description?: string;
8
+ descriptionId?: string;
9
+ /** A consumer glyph or tile before the title, hidden from assistive technology. */
10
+ icon?: ReactNode;
5
11
  /** The close button's accessible name; without it there is no button. */
6
12
  closeLabel?: string;
7
13
  onClose: () => void;
8
14
  };
9
15
  /**
10
- * The dialog's title, which names it through `aria-labelledby`. With a
11
- * `closeLabel` it shares a row with an icon-only ghost close button at its
12
- * end; without one it is the title alone, as it always was.
16
+ * The dialog's title, which names it through `aria-labelledby`. Alone it is
17
+ * 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.
13
20
  */
14
- export declare function DialogTitle({ closeLabel, id, level, onClose, title, }: DialogTitleProps): import("react").JSX.Element;
21
+ export declare function DialogTitle({ closeLabel, description, descriptionId, icon, id, level, onClose, title, }: DialogTitleProps): import("react").JSX.Element;
15
22
  export {};
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
2
3
  import { Button } from '../Button.js';
3
4
  import { Text } from '../Text.js';
4
5
  /**
5
- * The dialog's title, which names it through `aria-labelledby`. With a
6
- * `closeLabel` it shares a row with an icon-only ghost close button at its
7
- * end; without one it is the title alone, as it always was.
6
+ * The dialog's title, which names it through `aria-labelledby`. Alone it is
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
10
  */
9
- export function DialogTitle({ closeLabel, id, level, onClose, title, }) {
11
+ export function DialogTitle({ closeLabel, description, descriptionId, icon, id, level, onClose, title, }) {
10
12
  const heading = (_jsx(Text, { as: level === 2 ? 'h2' : 'h3', id: id, variant: "title", children: title }));
11
- if (!closeLabel)
13
+ if (!closeLabel && !icon && !description)
12
14
  return heading;
13
- return (_jsxs("div", { className: "sw-dialog-header", children: [heading, _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" })] })] }));
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] }));
14
16
  }
@@ -0,0 +1,24 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type StepHeadingLevel, type StepStatus } from './step-status.js';
3
+ export type StepProps = {
4
+ status: StepStatus;
5
+ title: string;
6
+ /** One line beside a done step's title, such as "Cash · $480.00"; truncated. */
7
+ summary?: ReactNode;
8
+ /** Reopens a done step; needs `editLabel`. */
9
+ onEdit?: () => void;
10
+ /** The Edit button's words; its name adds the step's title ("Edit The sale"). */
11
+ editLabel?: string;
12
+ /** Muted words after a skipped step's title, such as "not needed". */
13
+ skippedReason?: string;
14
+ /** Overrides the list's heading level for this step's title. */
15
+ headingLevel?: StepHeadingLevel;
16
+ /** The step's fields, shown while it is current or in error. */
17
+ children?: ReactNode;
18
+ };
19
+ /**
20
+ * One step of a `Steps` list: a numbered marker, the title as a heading,
21
+ * and, by status, its fields (current, error) or one folded line (a done
22
+ * step's summary and Edit; an upcoming or skipped step's title alone).
23
+ */
24
+ export declare function Step(props: StepProps): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Children, useContext, useEffect, useId, useRef, } from 'react';
4
+ import { cx } from '../../class-names.js';
5
+ import { Button } from '../Button.js';
6
+ import { isOpenStep, stepHeadingLevels, stepStatuses, stepStatusWord, stepTakesFocus, } from './step-status.js';
7
+ import { StepPlaceContext } from './steps-context.js';
8
+ function assertStep(props, level) {
9
+ if (!props.title.trim())
10
+ throw new RangeError('Step title must not be empty');
11
+ if (!stepStatuses.includes(props.status))
12
+ throw new RangeError(`Unknown Step status: ${String(props.status)}`);
13
+ if (!stepHeadingLevels.includes(level))
14
+ throw new RangeError('Step headingLevel must be 2, 3 or 4');
15
+ if (props.onEdit && !props.editLabel?.trim())
16
+ throw new RangeError('Step onEdit needs an editLabel');
17
+ if (props.skippedReason !== undefined && !props.skippedReason.trim())
18
+ throw new RangeError('Step skippedReason must not be empty');
19
+ }
20
+ /**
21
+ * Moves focus to the heading when the step becomes current from a folded
22
+ * state, unless an earlier step of the list already took it in this commit
23
+ * (effects run in list order, so the first one wins; the claim is released
24
+ * after the flush).
25
+ */
26
+ function useFocusWhenOpened(status, heading, focusClaimRef) {
27
+ const previous = useRef(undefined);
28
+ useEffect(() => {
29
+ if (stepTakesFocus(previous.current, status) && !focusClaimRef.current) {
30
+ focusClaimRef.current = true;
31
+ heading.current?.focus();
32
+ // One commit's effects run in one flush: release the claim after it,
33
+ // whichever component re-rendered.
34
+ queueMicrotask(() => {
35
+ focusClaimRef.current = false;
36
+ });
37
+ }
38
+ previous.current = status;
39
+ }, [status, heading, focusClaimRef]);
40
+ }
41
+ /**
42
+ * One step of a `Steps` list: a numbered marker, the title as a heading,
43
+ * and, by status, its fields (current, error) or one folded line (a done
44
+ * step's summary and Edit; an upcoming or skipped step's title alone).
45
+ */
46
+ export function Step(props) {
47
+ const place = useContext(StepPlaceContext);
48
+ if (!place)
49
+ throw new RangeError('Step must be a child of Steps');
50
+ const level = props.headingLevel ?? place.headingLevel;
51
+ assertStep(props, level);
52
+ const { status, title, summary, onEdit, editLabel, skippedReason, children } = props;
53
+ const heading = useRef(null);
54
+ const titleId = useId();
55
+ const editId = useId();
56
+ useFocusWhenOpened(status, heading, place.focusClaimRef);
57
+ const Heading = `h${level}`;
58
+ const reason = status === 'skipped' ? skippedReason : undefined;
59
+ const word = stepStatusWord(status, place.labels, reason !== undefined);
60
+ const done = status === 'done';
61
+ return (_jsxs("li", { "aria-current": status === 'current' ? 'step' : undefined, className: cx('sw-steps-item', `sw-steps-item-${status}`), children: [_jsxs("div", { className: "sw-steps-header", children: [_jsx("span", { "aria-hidden": "true", className: "sw-steps-marker", children: status === 'error' ? '!' : done ? null : place.number }), _jsxs(Heading, { ref: heading, className: "sw-steps-title", tabIndex: -1, children: [_jsx("span", { id: titleId, children: title }), reason ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-steps-reason", children: reason })] })) : null, word ? _jsxs("span", { className: "sw-sr-only", children: [", ", word] }) : null] }), done && summary ? (_jsx("span", { className: "sw-steps-summary", children: summary })) : null, done && onEdit ? (_jsx(Button, { "aria-labelledby": `${editId} ${titleId}`, className: "sw-steps-edit", id: editId, onPress: onEdit, size: "sm", variant: "ghost", children: editLabel })) : null] }), isOpenStep(status) && Children.toArray(children).length > 0 ? (_jsx("div", { className: "sw-steps-body", children: children })) : null] }));
62
+ }
@@ -0,0 +1,26 @@
1
+ /** Where a step stands in its form. */
2
+ export declare const stepStatuses: readonly ["upcoming", "current", "done", "error", "skipped"];
3
+ export type StepStatus = (typeof stepStatuses)[number];
4
+ /** A step title's heading level. */
5
+ export type StepHeadingLevel = 2 | 3 | 4;
6
+ export declare const stepHeadingLevels: readonly StepHeadingLevel[];
7
+ /** The words assistive technology hears after a folded step's title. */
8
+ export type StepsLabels = {
9
+ done: string;
10
+ error: string;
11
+ skipped: string;
12
+ upcoming: string;
13
+ };
14
+ export declare const defaultStepsLabels: StepsLabels;
15
+ /** A current or error step shows its fields; the others fold to one line. */
16
+ export declare function isOpenStep(status: StepStatus): boolean;
17
+ /**
18
+ * Whether a step that was `previous` and is now `next` should take focus: it
19
+ * became current from a folded state (upcoming, done, skipped). A first
20
+ * render (`previous` undefined), a move between current and error, and a
21
+ * step that turns to error keep focus where it is: after a refused submit
22
+ * the consumer focuses the first invalid field, as `Field` asks.
23
+ */
24
+ export declare function stepTakesFocus(previous: StepStatus | undefined, next: StepStatus): boolean;
25
+ /** The status word read after a step's title, or none (current, or a skipped step that shows its reason). */
26
+ export declare function stepStatusWord(status: StepStatus, labels: StepsLabels, hasReason: boolean): string | null;
@@ -0,0 +1,37 @@
1
+ /** Where a step stands in its form. */
2
+ export const stepStatuses = [
3
+ 'upcoming',
4
+ 'current',
5
+ 'done',
6
+ 'error',
7
+ 'skipped',
8
+ ];
9
+ export const stepHeadingLevels = [2, 3, 4];
10
+ export const defaultStepsLabels = {
11
+ done: 'Done',
12
+ error: 'Needs attention',
13
+ skipped: 'Not needed',
14
+ upcoming: 'Not started',
15
+ };
16
+ /** A current or error step shows its fields; the others fold to one line. */
17
+ export function isOpenStep(status) {
18
+ return status === 'current' || status === 'error';
19
+ }
20
+ /**
21
+ * Whether a step that was `previous` and is now `next` should take focus: it
22
+ * became current from a folded state (upcoming, done, skipped). A first
23
+ * render (`previous` undefined), a move between current and error, and a
24
+ * step that turns to error keep focus where it is: after a refused submit
25
+ * the consumer focuses the first invalid field, as `Field` asks.
26
+ */
27
+ export function stepTakesFocus(previous, next) {
28
+ return previous !== undefined && !isOpenStep(previous) && next === 'current';
29
+ }
30
+ /** The status word read after a step's title, or none (current, or a skipped step that shows its reason). */
31
+ export function stepStatusWord(status, labels, hasReason) {
32
+ if (status === 'current')
33
+ return null;
34
+ if (status === 'skipped' && hasReason)
35
+ return null;
36
+ return labels[status];
37
+ }
@@ -0,0 +1,16 @@
1
+ import { type RefObject } from 'react';
2
+ import { type StepHeadingLevel, type StepsLabels } from './step-status.js';
3
+ /**
4
+ * What a `Step` learns from its `Steps`: its number, its heading level, the
5
+ * status words, and the list's focus claim for this commit (the first step
6
+ * to open in a commit takes focus and releases the claim after that
7
+ * commit's effects).
8
+ */
9
+ export type StepPlace = {
10
+ number: number;
11
+ headingLevel: StepHeadingLevel;
12
+ labels: StepsLabels;
13
+ focusClaimRef: RefObject<boolean>;
14
+ };
15
+ /** Null outside `Steps`: a `Step` must be one of a list's steps. */
16
+ export declare const StepPlaceContext: import("react").Context<StepPlace | null>;
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+ /** Null outside `Steps`: a `Step` must be one of a list's steps. */
3
+ export const StepPlaceContext = createContext(null);
@@ -0,0 +1,22 @@
1
+ import { type ReactNode } from 'react';
2
+ /** One press in the toast, such as "Open" for what was just saved. */
3
+ export type ToastAction = {
4
+ label: string;
5
+ onPress: () => void;
6
+ };
7
+ type ToastContentProps = {
8
+ title?: string;
9
+ icon?: ReactNode;
10
+ action?: ToastAction;
11
+ /** Runs the action, then closes the toast. */
12
+ onAction: () => void;
13
+ children: ReactNode;
14
+ };
15
+ /**
16
+ * What the toast says. Children alone render as they always have; with a
17
+ * title, an icon or an action they go in a row: the icon, the bold title
18
+ * over the children as a muted detail, and the action at the end.
19
+ */
20
+ export declare function ToastContent({ action, children, icon, onAction, title, }: ToastContentProps): import("react").JSX.Element;
21
+ export declare function assertToastContent(title: string | undefined, action: ToastAction | undefined): void;
22
+ export {};
@@ -0,0 +1,21 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from '../Button.js';
3
+ /**
4
+ * What the toast says. Children alone render as they always have; with a
5
+ * title, an icon or an action they go in a row: the icon, the bold title
6
+ * over the children as a muted detail, and the action at the end.
7
+ */
8
+ export function ToastContent({ action, children, icon, onAction, title, }) {
9
+ if (!icon && !title && !action)
10
+ return _jsx(_Fragment, { children: children });
11
+ return (_jsxs("div", { className: "sw-toast-row", children: [icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-toast-icon", children: icon })) : null, _jsxs("div", { className: "sw-toast-body", children: [title ? _jsx("strong", { className: "sw-toast-title", children: title }) : null, title ? _jsx("div", { className: "sw-toast-detail", children: children }) : children] }), action ? (_jsx(Button, { className: "sw-toast-action", onPress: onAction, size: "sm", variant: "ghost", children: action.label })) : null] }));
12
+ }
13
+ export function assertToastContent(title, action) {
14
+ if (title !== undefined && (typeof title !== 'string' || !title.trim()))
15
+ throw new RangeError('Toast title must not be empty');
16
+ if (action !== undefined &&
17
+ (typeof action.label !== 'string' ||
18
+ !action.label.trim() ||
19
+ typeof action.onPress !== 'function'))
20
+ throw new RangeError('Toast action needs a label and an onPress');
21
+ }
@@ -0,0 +1,28 @@
1
+ import { type FocusEvent, type PointerEvent, type FocusEventHandler, type PointerEventHandler } from 'react';
2
+ type ToastHoldOptions = {
3
+ open: boolean;
4
+ /** Only a toast that takes the pointer (an action or the line) holds. */
5
+ enabled: boolean;
6
+ onBlur?: FocusEventHandler<HTMLDivElement>;
7
+ onFocus?: FocusEventHandler<HTMLDivElement>;
8
+ onPointerEnter?: PointerEventHandler<HTMLDivElement>;
9
+ onPointerLeave?: PointerEventHandler<HTMLDivElement>;
10
+ };
11
+ /**
12
+ * Whether the toast's time is held: the pointer is over it or focus is
13
+ * inside it. Each time it opens or closes it starts unheld (a toast that
14
+ * closes under the pointer gets no pointerleave). It also remembers where
15
+ * focus came from, so `returnFocus` can give it back when the toast closes
16
+ * with focus inside (its own action, or the consumer closing it).
17
+ */
18
+ export declare function useToastHold({ enabled, onBlur, onFocus, onPointerEnter, onPointerLeave, open, }: ToastHoldOptions): {
19
+ paused: boolean;
20
+ returnFocus: (node: HTMLElement) => void;
21
+ handlers: {
22
+ onBlur: (event: FocusEvent<HTMLDivElement>) => void;
23
+ onFocus: (event: FocusEvent<HTMLDivElement>) => void;
24
+ onPointerEnter: (event: PointerEvent<HTMLDivElement>) => void;
25
+ onPointerLeave: (event: PointerEvent<HTMLDivElement>) => void;
26
+ };
27
+ };
28
+ export {};
@@ -0,0 +1,53 @@
1
+ import { useCallback, useRef, useState, } from 'react';
2
+ /**
3
+ * Whether the toast's time is held: the pointer is over it or focus is
4
+ * inside it. Each time it opens or closes it starts unheld (a toast that
5
+ * closes under the pointer gets no pointerleave). It also remembers where
6
+ * focus came from, so `returnFocus` can give it back when the toast closes
7
+ * with focus inside (its own action, or the consumer closing it).
8
+ */
9
+ export function useToastHold({ enabled, onBlur, onFocus, onPointerEnter, onPointerLeave, open, }) {
10
+ const [hovered, setHovered] = useState(false);
11
+ const [focused, setFocused] = useState(false);
12
+ const [wasOpen, setWasOpen] = useState(open);
13
+ const cameFromRef = useRef(null);
14
+ if (wasOpen !== open) {
15
+ setWasOpen(open);
16
+ setHovered(false);
17
+ setFocused(false);
18
+ }
19
+ const returnFocus = useCallback((node) => {
20
+ const cameFrom = cameFromRef.current;
21
+ cameFromRef.current = null;
22
+ if (!node.contains(node.ownerDocument.activeElement))
23
+ return;
24
+ if (cameFrom?.isConnected)
25
+ cameFrom.focus();
26
+ }, []);
27
+ return {
28
+ paused: enabled && (hovered || focused),
29
+ returnFocus,
30
+ handlers: {
31
+ onBlur: (event) => {
32
+ onBlur?.(event);
33
+ if (!event.currentTarget.contains(event.relatedTarget))
34
+ setFocused(false);
35
+ },
36
+ onFocus: (event) => {
37
+ onFocus?.(event);
38
+ const from = event.relatedTarget;
39
+ if (from instanceof HTMLElement && !event.currentTarget.contains(from))
40
+ cameFromRef.current = from;
41
+ setFocused(true);
42
+ },
43
+ onPointerEnter: (event) => {
44
+ onPointerEnter?.(event);
45
+ setHovered(true);
46
+ },
47
+ onPointerLeave: (event) => {
48
+ onPointerLeave?.(event);
49
+ setHovered(false);
50
+ },
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,15 @@
1
+ type ToastTimerOptions = {
2
+ /** The timer runs only while the toast is open and does not travel. */
3
+ running: boolean;
4
+ /** While true, the time left is held, as on hover or focus. */
5
+ paused: boolean;
6
+ /** The whole time the toast stays, in ms. */
7
+ duration: number;
8
+ onExpire: () => void;
9
+ };
10
+ /**
11
+ * Closes the toast once `duration` ms have run, counting only the time it is
12
+ * not paused. Opening it again (or a new duration) starts the count over.
13
+ */
14
+ export declare function useToastTimer({ duration, onExpire, paused, running, }: ToastTimerOptions): void;
15
+ export {};