@scalewing/react 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7bb0397: `DenominationGrid` row `cellTones` (`docs/requests/teisoro-denomination-grid.md`, 2026-10-02 follow-up, Teisoro F-007 task 1550, MOV-10): `cellTones?: readonly (DenominationGridTone | null)[]`, one entry per column, tones a single count in place. The count is set in the tone (over a signed row's color and a zero's quiet opacity), and in the tiles layout the tile's border takes the tone too, without moving or resizing the tile. `neutral` or `null` leaves a count as it is; a row's `tone` is unchanged. The wrong length or an unknown tone throws a `RangeError`. New generated classes `sw-denomination-cell-toned` and `sw-denomination-cell-tone-{accent,success,danger,warning}`. No new dependencies.
8
+ - 051e0ec: `DenominationGrid` `rowRole` (`docs/requests/teisoro-denomination-grid.md`, 2026-10-02 follow-up, Teisoro F-007 task 1550, CHG-4): `rowRole?: 'region' | 'group'`, default `'region'`. At `'group'` each named tiles row keeps its `aria-label` but is a `group`, not a landmark region, so a page with many grids does not list a region per row. The default keeps today's regions; the strip ignores the prop; an unknown value throws a `RangeError`. New exported type `DenominationGridRowRole`. No new dependencies.
9
+ - b810ce3: `Dialog` `titleLevel` (`docs/requests/teisoro-dialog-title-level.md`, Teisoro F-007 task 1550, CHG-13): `titleLevel?: 2 | 3`, default `3`, sets the heading level of the dialog's title. `titleLevel={2}` renders it as an `h2` in the same title style, so a dialog's own section labels can be `h3` headings that read as its parts rather than its peers. The default keeps the `h3` every existing dialog has. New exported type `DialogTitleLevel`. No new dependencies.
10
+ - 0a49a53: A wide `Table` (and `DenominationGrid` strip) shows that it scrolls (`docs/requests/teisoro-table.md`, 2026-10-02 follow-up, Teisoro F-007 task 1550, VLT-1 and HIS-3). While its content is scrolled out past an inline edge, the scroll region adds `sw-scroll-more-start` and/or `sw-scroll-more-end` and that edge draws a soft inset shade (the text color at 28%, spacing steps 6 and 5), mirrored right to left, under the content so nothing moves. A table that fits is unchanged: no class, no shadow. The region measures on mount, on scroll and on resize; before mount and on the server it reports no overflow. In forced colors the system scrollbar is the cue. New generated classes `sw-scroll-more-start` and `sw-scroll-more-end`; no prop changes and no new dependencies.
11
+
12
+ ### Patch Changes
13
+
14
+ - 1648c50: `DenominationGrid` writes a negative count with the typographic minus "−" (U+2212), not a hyphen-minus (`docs/requests/teisoro-denomination-grid.md`, 2026-10-02 follow-up, Teisoro F-007 task 1550, AUD-18). It applies to every negative count, in signed and unsigned rows and in both layouts, so a count matches the sign of the consumer's formatted money. The cell's text is the only change: a test that matched `-1` in a cell now matches `−1`. No API change and no new dependencies.
15
+
3
16
  ## 1.16.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -155,7 +155,7 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
155
155
 
156
156
  `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape and choosing a command both return focus to the trigger (before the command runs, so a `Dialog` it opens hands focus back to the trigger on close), and outside interaction dismisses the menu. The open menu sits on the popover layer a `space-1` gap below its trigger (above it when it only fits there), at least `space-2` from every viewport edge (a classic scrollbar is not part of the viewport), and aligned to the trigger's inline start (its right edge in right-to-left), or to its other edge when the trigger ends a row and the menu would not fit from its start. `align="end"` (default `"start"`) lines the menu up with the trigger's inline end even where the start would fit, for a trigger that ends a card or a list row, so the menu stays over that card instead of hanging past it; it takes the start when the end would leave the screen. The menu is as wide as its longest command up to the viewport less both insets, and a longer command wraps. The menu follows its trigger through scroll, window resize, and a change in the menu's or the trigger's size. The menu is in the DOM only while it is open. The trigger and its commands use the compact xs control height for a fine pointer; on a coarse pointer the trigger is at least 44 × 44 px (the md control height, as on `CalendarButton`) and every command is at least 44 px tall.
157
157
 
158
- `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true.
158
+ `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true. The `title` is an `h3` in the title style by default; `titleLevel={2}` renders it as an `h2` in the same style, for a dialog whose own section labels are `h3` headings, so they read as parts of the dialog and not as its peers. The default stays `3` so existing dialogs keep their outline; any value other than `2` renders an `h3`.
159
159
 
160
160
  `Toast` is an auto-dismiss confirmation on the popover layer, controlled by `open` and `onOpenChange`; it does not trap focus and closes after `timeoutMs` (default 800, or 6000 for a `warning` or `danger` toast, which must be read). `anchor` and `target` send it from a press to a destination. `tone` (`neutral` by default, `success`, `warning`, `danger`) tints its border and an optional consumer `icon` (a Lucide glyph, hidden from assistive technology); the message keeps the text color, so its words carry the meaning. A `danger` toast is announced as `role="alert"`, the others as `role="status"`, so do not wrap the message in another live region. A toast still dismisses itself: an error the person must act on belongs in the page. A toast is as wide as its message, up to the page's gutter (spacing step 4) on each side, so on a phone a long message wraps inside the gutter instead of running to the screen's edges.
161
161
 
@@ -218,7 +218,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
218
218
  />
219
219
  ```
220
220
 
221
- `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
221
+ `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. While the table is wider than the region, each inline edge with columns scrolled out past it draws a soft inset shade (`sw-scroll-more-start`, `sw-scroll-more-end`, mirrored right to left), so a phone, whose scrollbars stay hidden, still shows that the table goes on; a table that fits draws none, and in forced colors the system scrollbar is the cue. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
222
222
 
223
223
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
224
224
 
@@ -262,7 +262,7 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
262
262
  </Grid>
263
263
  ```
264
264
 
265
- `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start; the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. A row's icon and its label words stay on one line in both layouts (`sw-denomination-label-line`): a long label wraps its words beside the icon, never under it, and on a phone the strip's total goes on its own line under them. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
265
+ `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start and the same edge shade as `Table` while counts are scrolled out of view (the start shade is cast from the pinned labels' edge, since they cover the region's own); the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. `rowRole="group"` (default `"region"`) keeps every named row's name but makes it a `group` instead of a landmark region, for a page with many grids (one per order card, say) whose rows would otherwise fill the landmark list; the strip ignores it, since its rows are table rows, and an unknown `rowRole` throws a `RangeError` in either layout. A row's icon and its label words stay on one line in both layouts (`sw-denomination-label-line`): a long label wraps its words beside the icon, never under it, and on a phone the strip's total goes on its own line under them. A row's `cellTones` (one Badge tone or `null` per column) tones a single count in place: the count is set in the tone over its signed or zero look, and in the tiles layout its tile's border takes the tone too, so a short bill turns red without leaving its row; `neutral` or `null` leaves a count as it is, a row's `tone` still colors only its label, and a `cellTones` that is not one known tone or `null` per column throws. The tone never says why on its own; put that in words beside the grid. Every negative count, signed or not and in either layout, is written with the typographic minus ("−1"), never a hyphen. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
266
266
 
267
267
  `FilterChips` presents a long, wrapping single-choice filter set. Provide a localized group `label`, controlled `value`, `onChange`, and options with stable values and localized labels. An optional non-negative integer `count` renders as a tabular chicklet after the label and is part of the option's accessible name; a zero count quiets the chip until it is selected: it drops its glass fill and sets its label in the muted color (4.5:1 in every palette), without fading, since it can still be chosen. Native radios provide arrow and Space navigation; disabled options are skipped.
268
268
 
@@ -1,3 +1,3 @@
1
1
  import { type DenominationGridProps } from './denomination-grid/types.js';
2
- export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridTone, } from './denomination-grid/types.js';
3
- export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, totalLabel, }: DenominationGridProps): import("react").JSX.Element;
2
+ export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridRowRole, DenominationGridTone, } from './denomination-grid/types.js';
3
+ export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, totalLabel, rowRole, }: DenominationGridProps): import("react").JSX.Element;
@@ -2,13 +2,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { DenominationStrip } from './denomination-grid/DenominationStrip.js';
3
3
  import { DenominationTiles } from './denomination-grid/DenominationTiles.js';
4
4
  import { assertDenominationGrid } from './denomination-grid/validate.js';
5
- export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', totalLabel, }) {
5
+ export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', totalLabel, rowRole = 'region', }) {
6
6
  assertDenominationGrid(label, columns, rows);
7
7
  if (!zeroLabel)
8
8
  throw new RangeError('zeroLabel must not be empty');
9
9
  if (totalLabel !== undefined && !totalLabel.trim())
10
10
  throw new RangeError('totalLabel must not be empty');
11
+ if (rowRole !== 'region' && rowRole !== 'group')
12
+ throw new RangeError("rowRole must be 'region' or 'group'");
11
13
  if (layout === 'tiles')
12
- return (_jsx(DenominationTiles, { columns: columns, label: label, rows: rows, subtotal: subtotal, zeroLabel: zeroLabel }));
14
+ return (_jsx(DenominationTiles, { columns: columns, label: label, rowRole: rowRole, rows: rows, subtotal: subtotal, zeroLabel: zeroLabel }));
13
15
  return (_jsx(DenominationStrip, { columns: columns, label: label, rows: rows, totalLabel: totalLabel, zeroLabel: zeroLabel }));
14
16
  }
@@ -4,6 +4,11 @@ import { type DialogHTMLAttributes, type ReactNode } from 'react';
4
4
  * (56rem) holds a row of six fields or a data grid without folding it.
5
5
  */
6
6
  export type DialogSize = 'md' | 'lg';
7
+ /**
8
+ * The heading level of the dialog's title. It changes only the element (and
9
+ * so the outline a screen reader reads), never the title's look.
10
+ */
11
+ export type DialogTitleLevel = 2 | 3;
7
12
  export type DialogProps = Omit<DialogHTMLAttributes<HTMLDialogElement>, 'onClose' | 'open' | 'title' | 'children'> & {
8
13
  /** Whether the dialog is shown. The element follows it and nothing else. */
9
14
  open: boolean;
@@ -19,6 +24,13 @@ export type DialogProps = Omit<DialogHTMLAttributes<HTMLDialogElement>, 'onClose
19
24
  onClose: () => void;
20
25
  size?: DialogSize;
21
26
  title: string;
27
+ /**
28
+ * The title's heading level, 3 (an `h3`) by default. Pass 2 when the
29
+ * dialog's own sections are `h3` headings, so they read as parts of the
30
+ * dialog rather than as its peers. The title's style does not change.
31
+ * Any value other than 2 (from an untyped caller) renders an `h3`.
32
+ */
33
+ titleLevel?: DialogTitleLevel;
22
34
  children: ReactNode;
23
35
  };
24
- export declare function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size, title, ...rest }: DialogProps): import("react").JSX.Element;
36
+ export declare function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size, title, titleLevel, ...rest }: DialogProps): import("react").JSX.Element;
@@ -6,7 +6,7 @@ import { spacingClassNames } from '../spacing-classes.js';
6
6
  import { useDialogCloseRequests } from './dialog-close-requests.js';
7
7
  import { Stack } from './Stack.js';
8
8
  import { Text } from './Text.js';
9
- export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size = 'md', title, ...rest }) {
9
+ export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size = 'md', title, titleLevel = 3, ...rest }) {
10
10
  const titleId = useId();
11
11
  const { nodeRef, handlers } = useDialogCloseRequests({
12
12
  open,
@@ -16,5 +16,5 @@ export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPo
16
16
  onPointerDown,
17
17
  onSubmit,
18
18
  });
19
- return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', ...spacingClassNames({ padding: 5 }), className), ...handlers, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { id: titleId, variant: "title", children: title }), children] }) }));
19
+ return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', ...spacingClassNames({ padding: 5 }), className), ...handlers, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { as: titleLevel === 2 ? 'h2' : 'h3', id: titleId, variant: "title", children: title }), children] }) }));
20
20
  }
@@ -9,7 +9,10 @@ type ScrollRegionProps = {
9
9
  * Private wrapper that scrolls wide content inside its own box (see
10
10
  * `scrollRegionRules`). It is a keyboard stop named after its content, so a
11
11
  * wide table with no focusable cell can still be scrolled sideways from the
12
- * keyboard and is announced as one group.
12
+ * keyboard and is announced as one group. While content is scrolled out past
13
+ * an inline edge, that edge draws a shade (`sw-scroll-more-start`,
14
+ * `sw-scroll-more-end`), so a phone, whose scrollbars hide, still shows that
15
+ * the table goes on.
13
16
  */
14
17
  export declare function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }: ScrollRegionProps): import("react").JSX.Element;
15
18
  export {};
@@ -1,10 +1,19 @@
1
+ 'use client';
1
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRef } from 'react';
4
+ import { cx } from '../class-names.js';
5
+ import { useScrollOverflow } from './use-scroll-overflow.js';
2
6
  /**
3
7
  * Private wrapper that scrolls wide content inside its own box (see
4
8
  * `scrollRegionRules`). It is a keyboard stop named after its content, so a
5
9
  * wide table with no focusable cell can still be scrolled sideways from the
6
- * keyboard and is announced as one group.
10
+ * keyboard and is announced as one group. While content is scrolled out past
11
+ * an inline edge, that edge draws a shade (`sw-scroll-more-start`,
12
+ * `sw-scroll-more-end`), so a phone, whose scrollbars hide, still shows that
13
+ * the table goes on.
7
14
  */
8
15
  export function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }) {
9
- return (_jsx("div", { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: className, role: "group", tabIndex: 0, children: children }));
16
+ const ref = useRef(null);
17
+ const overflow = useScrollOverflow(ref);
18
+ return (_jsx("div", { ref: ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: cx(className, overflow.start && 'sw-scroll-more-start', overflow.end && 'sw-scroll-more-end'), role: "group", tabIndex: 0, children: children }));
10
19
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../../class-names.js';
3
+ import { denominationCellToneClassNames } from '../../css/css-denomination-cell-tones.js';
3
4
  import { denominationCellView } from '../../denomination-cells.js';
4
5
  import { ScrollRegion } from '../ScrollRegion.js';
5
6
  import { RowLabelLine } from './RowLabelLine.js';
@@ -17,6 +18,6 @@ export function DenominationStrip({ label, columns, rows, zeroLabel, totalLabel,
17
18
  const hasTotals = rows.some((row) => row.total !== undefined);
18
19
  return (_jsx(ScrollRegion, { "aria-label": label, className: "sw-denomination-scroll", children: _jsxs("table", { className: "sw-denomination-grid sw-denomination-strip", 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: column.label }, column.key))), hasTotals ? _jsx(TotalHead, { totalLabel: totalLabel }) : null] }) }), _jsx("tbody", { children: rows.map((row) => (_jsxs("tr", { className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`, row.signed && 'sw-denomination-row-signed'), 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) => {
19
20
  const view = denominationCellView(row.cells[index] ?? null, row.signed ?? false, zeroLabel);
20
- return (_jsx("td", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }, column.key));
21
+ return (_jsx("td", { className: cx('sw-denomination-cell', `sw-denomination-cell-${view.state}`, ...denominationCellToneClassNames(row.cellTones?.[index])), children: view.text }, column.key));
21
22
  }), hasTotals ? (_jsx("td", { className: "sw-denomination-total", children: _jsx("span", { className: "sw-denomination-total-value", children: row.total ?? '' }) })) : null] }, row.id))) })] }) }));
22
23
  }
@@ -1,4 +1,4 @@
1
1
  import { type DenominationGridProps } from './types.js';
2
- type TilesProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel'>> & Pick<DenominationGridProps, 'subtotal'>;
3
- export declare function DenominationTiles({ label, columns, rows, subtotal, zeroLabel, }: TilesProps): import("react").JSX.Element;
2
+ type TilesProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel' | 'rowRole'>> & Pick<DenominationGridProps, 'subtotal'>;
3
+ export declare function DenominationTiles({ label, columns, rows, rowRole, subtotal, zeroLabel, }: TilesProps): import("react").JSX.Element;
4
4
  export {};
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../../class-names.js';
3
+ import { denominationCellToneClassNames } from '../../css/css-denomination-cell-tones.js';
3
4
  import { denominationCellView } from '../../denomination-cells.js';
4
5
  import { RowLabelLine } from './RowLabelLine.js';
5
6
  /**
@@ -10,24 +11,28 @@ function showsRowLabel(rowCount, row) {
10
11
  return rowCount > 1 || Boolean(row.icon) || row.total !== undefined;
11
12
  }
12
13
  /**
13
- * A row is a region named by its label, except a lone row without its label
14
- * line whose label repeats the grid's: that region would only say the
15
- * group's name a second time (Teisoro SDAY-31). A lone row with its own
16
- * words keeps them, since nothing else on screen says them.
14
+ * A row is named by its label (a region, or a group at `rowRole="group"`),
15
+ * except a lone row without its label line whose label repeats the grid's:
16
+ * that name would only say the group's name a second time (Teisoro
17
+ * SDAY-31). A lone row with its own words keeps them, since nothing else on
18
+ * screen says them.
17
19
  */
18
20
  function namesRow(rowCount, row, gridLabel) {
19
21
  return showsRowLabel(rowCount, row) || row.label !== gridLabel;
20
22
  }
21
- export function DenominationTiles({ label, columns, rows, subtotal, zeroLabel, }) {
23
+ export function DenominationTiles({ label, columns, rows, rowRole, subtotal, zeroLabel, }) {
22
24
  return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => {
23
25
  const labelled = showsRowLabel(rows.length, row);
24
- return (_jsxs("section", { "aria-label": namesRow(rows.length, row, label) ? row.label : undefined, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), children: [labelled ? (_jsxs("span", { className: "sw-denomination-label", children: [_jsx(RowLabelLine, { row: row }), row.total !== undefined ? (_jsx("span", { className: "sw-denomination-total", children: row.total })) : null] })) : null, _jsx("ul", { className: "sw-denomination-tile-list", children: columns.map((column, index) => {
26
+ const named = namesRow(rows.length, row, label);
27
+ // A named section is a region; role="group" keeps the name without
28
+ // the landmark. An unnamed row stays a generic section either way.
29
+ return (_jsxs("section", { "aria-label": named ? row.label : undefined, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), role: named && rowRole === 'group' ? 'group' : undefined, children: [labelled ? (_jsxs("span", { className: "sw-denomination-label", children: [_jsx(RowLabelLine, { row: row }), row.total !== undefined ? (_jsx("span", { className: "sw-denomination-total", children: row.total })) : null] })) : null, _jsx("ul", { className: "sw-denomination-tile-list", children: columns.map((column, index) => {
25
30
  const cell = row.cells[index] ?? null;
26
31
  const view = denominationCellView(cell, row.signed ?? false, zeroLabel);
27
32
  const amount = subtotal && cell !== null && cell !== 0
28
33
  ? subtotal(cell, column)
29
34
  : null;
30
- return (_jsxs("li", { className: "sw-denomination-tile", children: [_jsx("span", { className: "sw-denomination-head", children: column.label }), _jsx("span", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }), subtotal ? (_jsx("span", { className: "sw-denomination-subtotal", children: amount ?? zeroLabel })) : null] }, column.key));
35
+ return (_jsxs("li", { className: cx('sw-denomination-tile', ...denominationCellToneClassNames(row.cellTones?.[index])), children: [_jsx("span", { className: "sw-denomination-head", children: column.label }), _jsx("span", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }), subtotal ? (_jsx("span", { className: "sw-denomination-subtotal", children: amount ?? zeroLabel })) : null] }, column.key));
31
36
  }) })] }, row.id));
32
37
  }) }));
33
38
  }
@@ -2,6 +2,11 @@ import { type ReactNode } from 'react';
2
2
  import { type BadgeTone } from '../../css/css-data.js';
3
3
  export type DenominationGridTone = BadgeTone;
4
4
  export type DenominationGridLayout = 'strip' | 'tiles';
5
+ /**
6
+ * What a named tiles row is to assistive technology: a `region` (a landmark,
7
+ * the default) or a `group` (named, but not in the landmark list).
8
+ */
9
+ export type DenominationGridRowRole = 'region' | 'group';
5
10
  export type DenominationGridColumn = {
6
11
  key: string;
7
12
  label: string;
@@ -14,6 +19,13 @@ export type DenominationGridRow = {
14
19
  tone?: DenominationGridTone;
15
20
  signed?: boolean;
16
21
  total?: string;
22
+ /**
23
+ * A tone per count, one entry per column (null for none), for a single
24
+ * count that needs attention, such as a bill the vault is short of. The
25
+ * count is set in the tone and, in the tiles layout, its tile's border
26
+ * too, in place. The tone never says why alone: put that in words.
27
+ */
28
+ cellTones?: readonly (DenominationGridTone | null)[];
17
29
  };
18
30
  export type DenominationGridProps = {
19
31
  label: string;
@@ -27,4 +39,11 @@ export type DenominationGridProps = {
27
39
  * hidden column header), such as "Total". Only the strip has that column.
28
40
  */
29
41
  totalLabel?: string;
42
+ /**
43
+ * The role of each named row in the tiles layout: `region` (the default,
44
+ * a landmark named by the row's label) or `group` (the same name, not a
45
+ * landmark), for a page with many grids whose rows should not fill the
46
+ * landmark list. Only the tiles layout has row containers.
47
+ */
48
+ rowRole?: DenominationGridRowRole;
30
49
  };
@@ -1,3 +1,4 @@
1
+ import { badgeTones } from '../../css/css-data.js';
1
2
  import { isDenominationCount } from '../../denomination-cells.js';
2
3
  export function assertDenominationGrid(label, columns, rows) {
3
4
  if (!label.trim())
@@ -15,4 +16,20 @@ export function assertDenominationGrid(label, columns, rows) {
15
16
  throw new RangeError('each row needs one cell per column');
16
17
  if (rows.some((row) => row.cells.some((cell) => !isDenominationCount(cell))))
17
18
  throw new RangeError('cells must be integers or null');
19
+ if (rows.some((row) => !hasValidCellTones(row, columns.length)))
20
+ throw new RangeError('cellTones needs one tone or null per column, from the Badge tones');
21
+ }
22
+ function hasValidCellTones(row, columnCount) {
23
+ const tones = row.cellTones;
24
+ if (tones === undefined)
25
+ return true;
26
+ if (!Array.isArray(tones) || tones.length !== columnCount)
27
+ return false;
28
+ // An index loop, not every(): every() skips the holes of a sparse array.
29
+ for (let index = 0; index < columnCount; index += 1) {
30
+ const tone = tones[index];
31
+ if (tone !== null && !badgeTones.includes(tone))
32
+ return false;
33
+ }
34
+ return true;
18
35
  }
@@ -0,0 +1,9 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ScrollOverflow } from '../scroll-overflow.js';
3
+ /**
4
+ * Follows which inline edges of a scroll box have more content past them:
5
+ * 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.
8
+ */
9
+ export declare function useScrollOverflow(ref: RefObject<HTMLElement | null>): ScrollOverflow;
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { noScrollOverflow, sameScrollOverflow, scrollOverflow, } from '../scroll-overflow.js';
4
+ /**
5
+ * Follows which inline edges of a scroll box have more content past them:
6
+ * 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.
9
+ */
10
+ export function useScrollOverflow(ref) {
11
+ const [overflow, setOverflow] = useState(noScrollOverflow);
12
+ useEffect(() => {
13
+ const box = ref.current;
14
+ if (!box)
15
+ return;
16
+ function measure() {
17
+ if (!box)
18
+ return;
19
+ const next = scrollOverflow(box.scrollLeft, box.scrollWidth, box.clientWidth);
20
+ setOverflow((current) => sameScrollOverflow(current, next) ? current : next);
21
+ }
22
+ measure();
23
+ box.addEventListener('scroll', measure, { passive: true });
24
+ const resize = typeof ResizeObserver === 'undefined'
25
+ ? null
26
+ : new ResizeObserver(measure);
27
+ if (resize) {
28
+ resize.observe(box);
29
+ for (const child of Array.from(box.children))
30
+ resize.observe(child);
31
+ }
32
+ else {
33
+ window.addEventListener('resize', measure);
34
+ }
35
+ return () => {
36
+ box.removeEventListener('scroll', measure);
37
+ if (resize)
38
+ resize.disconnect();
39
+ else
40
+ window.removeEventListener('resize', measure);
41
+ };
42
+ }, [ref]);
43
+ return overflow;
44
+ }
@@ -0,0 +1,9 @@
1
+ import { type BadgeTone } from './css-data.js';
2
+ /**
3
+ * The classes that tone one count: a marker that applies the shared rules and
4
+ * the tone that sets their color. A strip cell (`td`) and a tile (`li`) take
5
+ * the same pair; null or `neutral` leaves the cell untoned.
6
+ */
7
+ export declare function denominationCellToneClassNames(tone: BadgeTone | null | undefined): string[];
8
+ export declare function cssDenominationCellToneClasses(): string;
9
+ export declare function denominationCellToneClassCatalog(): string[];
@@ -0,0 +1,52 @@
1
+ import { badgeTones } from './css-data.js';
2
+ /**
3
+ * The tones a single count can take. `neutral` is the cell's own look, so it
4
+ * adds no class: a cell is toned only by a tone that says something.
5
+ */
6
+ const cellTones = badgeTones.filter((tone) => tone !== 'neutral');
7
+ /**
8
+ * The classes that tone one count: a marker that applies the shared rules and
9
+ * the tone that sets their color. A strip cell (`td`) and a tile (`li`) take
10
+ * the same pair; null or `neutral` leaves the cell untoned.
11
+ */
12
+ export function denominationCellToneClassNames(tone) {
13
+ if (!tone || tone === 'neutral')
14
+ return [];
15
+ return ['sw-denomination-cell-toned', `sw-denomination-cell-tone-${tone}`];
16
+ }
17
+ /*
18
+ * A toned count is set in its tone, over a row's signed coloring and a
19
+ * zero's quiet opacity, and a toned tile draws its border in the tone, one
20
+ * hairline thicker with an inset shadow so no tile moves. The tone never
21
+ * carries meaning alone: the consumer's words say why a count is toned.
22
+ * Forced colors replace the tone with the system text color, and a toned
23
+ * zero keeps the zero's GrayText there, so it does not read as a count.
24
+ */
25
+ export function cssDenominationCellToneClasses() {
26
+ const toneRules = cellTones
27
+ .map((tone) => `.sw-denomination-cell-tone-${tone} { --sw-denomination-cell-tone: var(--sw-color-${tone}); }`)
28
+ .join('\n');
29
+ return `${toneRules}
30
+
31
+ .sw-denomination-grid .sw-denomination-cell.sw-denomination-cell-toned,
32
+ .sw-denomination-grid .sw-denomination-cell-toned .sw-denomination-cell {
33
+ color: var(--sw-denomination-cell-tone);
34
+ opacity: 1;
35
+ }
36
+
37
+ .sw-denomination-tile.sw-denomination-cell-toned {
38
+ border-color: var(--sw-denomination-cell-tone);
39
+ box-shadow: inset 0 0 0 1px var(--sw-denomination-cell-tone);
40
+ }
41
+
42
+ @media (forced-colors: active) {
43
+ .sw-denomination-grid .sw-denomination-cell-zero.sw-denomination-cell-toned,
44
+ .sw-denomination-grid .sw-denomination-cell-toned .sw-denomination-cell-zero { color: GrayText; }
45
+ }`;
46
+ }
47
+ export function denominationCellToneClassCatalog() {
48
+ return [
49
+ 'sw-denomination-cell-toned',
50
+ ...cellTones.map((tone) => `sw-denomination-cell-tone-${tone}`),
51
+ ];
52
+ }
@@ -1,7 +1,8 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { breakpointQuery } from './breakpoints.js';
3
3
  import { badgeTones } from './css-data.js';
4
- import { scrollRegionRules } from './css-scroll-region.js';
4
+ import { cssDenominationCellToneClasses, denominationCellToneClassCatalog, } from './css-denomination-cell-tones.js';
5
+ import { pinnedStartShadeRules, scrollRegionRules, } from './css-scroll-region.js';
5
6
  import { visuallyHiddenDeclarations } from './css-utilities.js';
6
7
  import { zIndex } from './stacking.js';
7
8
  const caption = typographyVariants.caption;
@@ -25,6 +26,11 @@ ${toneRules()}
25
26
 
26
27
  ${scrollRegionRules('.sw-denomination-scroll')}
27
28
 
29
+ ${pinnedStartShadeRules('.sw-denomination-scroll', [
30
+ '.sw-denomination-strip .sw-denomination-label',
31
+ '.sw-denomination-strip thead .sw-denomination-corner:first-child',
32
+ ])}
33
+
28
34
  .sw-denomination-strip {
29
35
  border-collapse: collapse;
30
36
  width: 100%;
@@ -239,6 +245,8 @@ ${scrollRegionRules('.sw-denomination-scroll')}
239
245
  .sw-denomination-tile-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
240
246
  }
241
247
 
248
+ ${cssDenominationCellToneClasses()}
249
+
242
250
  @media (forced-colors: active) {
243
251
  .sw-denomination-label { border-inline-start-color: CanvasText; color: CanvasText; }
244
252
  .sw-denomination-strip .sw-denomination-label::before { background: CanvasText; forced-color-adjust: none; }
@@ -276,5 +284,6 @@ export function denominationGridClassCatalog() {
276
284
  'sw-denomination-tile-list',
277
285
  'sw-denomination-tile',
278
286
  'sw-denomination-subtotal',
287
+ ...denominationCellToneClassCatalog(),
279
288
  ];
280
289
  }
@@ -2,7 +2,21 @@
2
2
  * A scroll region is a full-width box that scrolls its own content when the
3
3
  * content is wider (or taller) than the box, so a wide table never widens the
4
4
  * page. It is a keyboard stop, so its focus ring is drawn inside its edge
5
- * where an ancestor's clipping cannot hide it. Table and the DenominationGrid
6
- * strip share it.
5
+ * where an ancestor's clipping cannot hide it. While its content is scrolled
6
+ * out past an inline edge, `ScrollRegion` adds `sw-scroll-more-start` or
7
+ * `sw-scroll-more-end`, and that edge draws a shade: the cue that the content
8
+ * goes on, which overlay scrollbars (phones, macOS) do not give. In forced
9
+ * colors the shade is not drawn and the system's scrollbar is the cue. Table
10
+ * and the DenominationGrid strip share it.
7
11
  */
8
12
  export declare function scrollRegionRules(selector: string): string;
13
+ export declare function scrollRegionClassCatalog(): string[];
14
+ /**
15
+ * A region whose start column is pinned (sticky, on an opaque fill) hides
16
+ * its own start shade under that column, so the column casts the shade
17
+ * instead: a gradient a spacing step wide just past its inline end, drawn by
18
+ * its `::after` (the pinned cell is positioned, so it holds the gradient).
19
+ * `pinned` lists the pinned cells' selectors inside the region. Forced colors
20
+ * draw no shade here either.
21
+ */
22
+ export declare function pinnedStartShadeRules(region: string, pinned: readonly string[]): string;
@@ -1,9 +1,40 @@
1
+ /*
2
+ * The shade an edge draws while content is scrolled out past it: an inset
3
+ * shadow a spacing step wide in the text color at low strength, so it reads
4
+ * on every palette and in dark mode. It is the box's own shadow, painted
5
+ * under the content, so no cell moves. A box scrolled to its middle shades
6
+ * both edges. The rules name the shadows outright rather than through
7
+ * inherited variables, so a scroll region inside another never takes its
8
+ * ancestor's shade.
9
+ */
10
+ const shadeColor = 'color-mix(in srgb, var(--sw-color-text) 28%, transparent)';
11
+ function edgeShade(side) {
12
+ const offset = side === 'left' ? 'var(--sw-space-6)' : 'calc(-1 * var(--sw-space-6))';
13
+ return `inset ${offset} 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) ${shadeColor}`;
14
+ }
15
+ /** The start edge is the left in a left-to-right box and the right in RTL. */
16
+ function edgeShadeRules(selector) {
17
+ const more = (edge) => `${selector}.sw-scroll-more-${edge}`;
18
+ const both = `${more('start')}.sw-scroll-more-end`;
19
+ return [
20
+ `${more('start')} { box-shadow: ${edgeShade('left')}; }`,
21
+ `${more('end')} { box-shadow: ${edgeShade('right')}; }`,
22
+ `${more('start')}:dir(rtl) { box-shadow: ${edgeShade('right')}; }`,
23
+ `${more('end')}:dir(rtl) { box-shadow: ${edgeShade('left')}; }`,
24
+ // Last: it ties the :dir(rtl) rules on specificity and must win them.
25
+ `${both} { box-shadow: ${edgeShade('left')}, ${edgeShade('right')}; }`,
26
+ ].join('\n');
27
+ }
1
28
  /**
2
29
  * A scroll region is a full-width box that scrolls its own content when the
3
30
  * content is wider (or taller) than the box, so a wide table never widens the
4
31
  * page. It is a keyboard stop, so its focus ring is drawn inside its edge
5
- * where an ancestor's clipping cannot hide it. Table and the DenominationGrid
6
- * strip share it.
32
+ * where an ancestor's clipping cannot hide it. While its content is scrolled
33
+ * out past an inline edge, `ScrollRegion` adds `sw-scroll-more-start` or
34
+ * `sw-scroll-more-end`, and that edge draws a shade: the cue that the content
35
+ * goes on, which overlay scrollbars (phones, macOS) do not give. In forced
36
+ * colors the shade is not drawn and the system's scrollbar is the cue. Table
37
+ * and the DenominationGrid strip share it.
7
38
  */
8
39
  export function scrollRegionRules(selector) {
9
40
  return `${selector} {
@@ -14,5 +45,40 @@ export function scrollRegionRules(selector) {
14
45
  ${selector}:focus-visible {
15
46
  outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
16
47
  outline-offset: calc(-1 * var(--sw-focus-ring-width));
48
+ }
49
+
50
+ ${edgeShadeRules(selector)}`;
51
+ }
52
+ export function scrollRegionClassCatalog() {
53
+ return ['sw-scroll-more-start', 'sw-scroll-more-end'];
54
+ }
55
+ /**
56
+ * A region whose start column is pinned (sticky, on an opaque fill) hides
57
+ * its own start shade under that column, so the column casts the shade
58
+ * instead: a gradient a spacing step wide just past its inline end, drawn by
59
+ * its `::after` (the pinned cell is positioned, so it holds the gradient).
60
+ * `pinned` lists the pinned cells' selectors inside the region. Forced colors
61
+ * draw no shade here either.
62
+ */
63
+ export function pinnedStartShadeRules(region, pinned) {
64
+ const after = (direction) => pinned
65
+ .map((cell) => `${region}.sw-scroll-more-start${direction} ${cell}::after`)
66
+ .join(',\n');
67
+ return `${after('')} {
68
+ background: linear-gradient(to right, ${shadeColor}, transparent);
69
+ content: '';
70
+ inset-block: 0;
71
+ inset-inline-start: 100%;
72
+ pointer-events: none;
73
+ position: absolute;
74
+ width: var(--sw-space-5);
75
+ }
76
+
77
+ ${after(':dir(rtl)')} {
78
+ background: linear-gradient(to left, ${shadeColor}, transparent);
79
+ }
80
+
81
+ @media (forced-colors: active) {
82
+ ${after('').replaceAll('\n', '\n ')} { display: none; }
17
83
  }`;
18
84
  }
@@ -18,6 +18,7 @@ import { filterChipsClassCatalog } from './css-filter-chips.js';
18
18
  import { gridClassCatalog } from './css-grid.js';
19
19
  import { gridSpanClassCatalog } from './css-grid-span.js';
20
20
  import { denominationGridClassCatalog } from './css-denomination-grid.js';
21
+ import { scrollRegionClassCatalog } from './css-scroll-region.js';
21
22
  import { fieldClassCatalog } from './css-field.js';
22
23
  import { boxBorderClassCatalog } from './css-box-border.js';
23
24
  import { cssComponentClasses } from './css-components.js';
@@ -53,6 +54,7 @@ export function utilityClassCatalog() {
53
54
  ...dataClassCatalog(),
54
55
  ...segmentedClassCatalog(),
55
56
  ...tableClassCatalog(),
57
+ ...scrollRegionClassCatalog(),
56
58
  ...chartClassCatalog(),
57
59
  ...chromeClassCatalog(),
58
60
  ...dialogClassCatalog(),
@@ -6,7 +6,9 @@ export type DenominationCellView = {
6
6
  /**
7
7
  * Classifies one count for display. A null or zero count renders the zero
8
8
  * label; a signed row prefixes positive counts with "+" so movement reads as
9
- * a delta. Formatting stays plain digits: the consumer owns locale grouping.
9
+ * a delta, and every negative count takes the typographic minus "−", the
10
+ * sign a consumer's formatted money uses, never a hyphen. Formatting stays
11
+ * plain digits: the consumer owns locale grouping.
10
12
  */
11
13
  export declare function denominationCellView(count: number | null, signed: boolean, zeroLabel: string): DenominationCellView;
12
14
  export declare function isDenominationCount(count: number | null): boolean;
@@ -1,13 +1,17 @@
1
+ /** The typographic minus (U+2212), not the hyphen-minus a number prints. */
2
+ const minusSign = '\u2212';
1
3
  /**
2
4
  * Classifies one count for display. A null or zero count renders the zero
3
5
  * label; a signed row prefixes positive counts with "+" so movement reads as
4
- * a delta. Formatting stays plain digits: the consumer owns locale grouping.
6
+ * a delta, and every negative count takes the typographic minus "−", the
7
+ * sign a consumer's formatted money uses, never a hyphen. Formatting stays
8
+ * plain digits: the consumer owns locale grouping.
5
9
  */
6
10
  export function denominationCellView(count, signed, zeroLabel) {
7
11
  if (count === null || count === 0)
8
12
  return { state: 'zero', text: zeroLabel };
9
13
  if (count < 0)
10
- return { state: 'negative', text: String(count) };
14
+ return { state: 'negative', text: `${minusSign}${Math.abs(count)}` };
11
15
  return { state: 'positive', text: signed ? `+${count}` : String(count) };
12
16
  }
13
17
  export function isDenominationCount(count) {
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export { Button, type ButtonProps, type ButtonSize, type ButtonVariant, } from '
17
17
  export { Box, type BoxBorder, type BoxElement, type BoxProps, } from './components/Box.js';
18
18
  export { ButtonGroup, type ButtonGroupJustify, type ButtonGroupProps, } from './components/ButtonGroup.js';
19
19
  export { Card, type CardProps, type CardVariant } from './components/Card.js';
20
- export { Dialog, type DialogProps, type DialogSize, } from './components/Dialog.js';
20
+ export { Dialog, type DialogProps, type DialogSize, type DialogTitleLevel, } from './components/Dialog.js';
21
21
  export { StatTile, type StatTileEmphasis, type StatTileProps, type StatTileTone, statTileTones, } from './components/StatTile.js';
22
22
  export { TabPanel, type TabItem, type TabPanelProps, Tabs, type TabsProps, tabId, tabPanelId, } from './components/Tabs.js';
23
23
  export { Toast, toastTones } from './components/Toast.js';
@@ -44,6 +44,6 @@ export type { GridColumns, GridProps } from './components/Grid.js';
44
44
  export type { GridColumnSpan } from './css/css-grid-span.js';
45
45
  export type { ColumnSpanProps } from './column-span-classes.js';
46
46
  export { DenominationGrid } from './components/DenominationGrid.js';
47
- export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridTone, } from './components/DenominationGrid.js';
47
+ export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridRowRole, DenominationGridTone, } from './components/DenominationGrid.js';
48
48
  export { FilterChips } from './components/FilterChips.js';
49
49
  export type { FilterChipOption, FilterChipsProps, } from './components/FilterChips.js';
@@ -0,0 +1,14 @@
1
+ /** Which inline edges of a scroll box have content scrolled out past them. */
2
+ export type ScrollOverflow = {
3
+ start: boolean;
4
+ end: boolean;
5
+ };
6
+ export declare const noScrollOverflow: ScrollOverflow;
7
+ /**
8
+ * Reads a box's horizontal overflow from its scroll metrics. `scrollLeft` is
9
+ * 0 at the inline start and grows toward the end in a left-to-right box; in
10
+ * a right-to-left box it is 0 at the start and negative toward the end, so
11
+ * the distance travelled is its magnitude in both.
12
+ */
13
+ export declare function scrollOverflow(scrollLeft: number, scrollWidth: number, clientWidth: number): ScrollOverflow;
14
+ export declare function sameScrollOverflow(a: ScrollOverflow, b: ScrollOverflow): boolean;
@@ -0,0 +1,26 @@
1
+ export const noScrollOverflow = { start: false, end: false };
2
+ /**
3
+ * Sub-pixel layout can leave a box a fraction of a pixel wider than its
4
+ * content, or a scroll position a fraction short of its end; within this
5
+ * many pixels an edge counts as reached.
6
+ */
7
+ const edgeTolerance = 1;
8
+ /**
9
+ * Reads a box's horizontal overflow from its scroll metrics. `scrollLeft` is
10
+ * 0 at the inline start and grows toward the end in a left-to-right box; in
11
+ * a right-to-left box it is 0 at the start and negative toward the end, so
12
+ * the distance travelled is its magnitude in both.
13
+ */
14
+ export function scrollOverflow(scrollLeft, scrollWidth, clientWidth) {
15
+ const hidden = scrollWidth - clientWidth;
16
+ if (hidden <= edgeTolerance)
17
+ return noScrollOverflow;
18
+ const travelled = Math.abs(scrollLeft);
19
+ return {
20
+ start: travelled > edgeTolerance,
21
+ end: travelled < hidden - edgeTolerance,
22
+ };
23
+ }
24
+ export function sameScrollOverflow(a, b) {
25
+ return a.start === b.start && a.end === b.end;
26
+ }
package/dist/styles.css CHANGED
@@ -1363,6 +1363,12 @@ body {
1363
1363
  outline-offset: calc(-1 * var(--sw-focus-ring-width));
1364
1364
  }
1365
1365
 
1366
+ .sw-table-wrap.sw-scroll-more-start { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
1367
+ .sw-table-wrap.sw-scroll-more-end { box-shadow: inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
1368
+ .sw-table-wrap.sw-scroll-more-start:dir(rtl) { box-shadow: inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
1369
+ .sw-table-wrap.sw-scroll-more-end:dir(rtl) { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
1370
+ .sw-table-wrap.sw-scroll-more-start.sw-scroll-more-end { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent), inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
1371
+
1366
1372
  .sw-table {
1367
1373
  border-collapse: collapse;
1368
1374
  width: 100%;
@@ -2854,6 +2860,33 @@ body {
2854
2860
  outline-offset: calc(-1 * var(--sw-focus-ring-width));
2855
2861
  }
2856
2862
 
2863
+ .sw-denomination-scroll.sw-scroll-more-start { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
2864
+ .sw-denomination-scroll.sw-scroll-more-end { box-shadow: inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
2865
+ .sw-denomination-scroll.sw-scroll-more-start:dir(rtl) { box-shadow: inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
2866
+ .sw-denomination-scroll.sw-scroll-more-end:dir(rtl) { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
2867
+ .sw-denomination-scroll.sw-scroll-more-start.sw-scroll-more-end { box-shadow: inset var(--sw-space-6) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent), inset calc(-1 * var(--sw-space-6)) 0 var(--sw-space-5) calc(-1 * var(--sw-space-5)) color-mix(in srgb, var(--sw-color-text) 28%, transparent); }
2868
+
2869
+ .sw-denomination-scroll.sw-scroll-more-start .sw-denomination-strip .sw-denomination-label::after,
2870
+ .sw-denomination-scroll.sw-scroll-more-start .sw-denomination-strip thead .sw-denomination-corner:first-child::after {
2871
+ background: linear-gradient(to right, color-mix(in srgb, var(--sw-color-text) 28%, transparent), transparent);
2872
+ content: '';
2873
+ inset-block: 0;
2874
+ inset-inline-start: 100%;
2875
+ pointer-events: none;
2876
+ position: absolute;
2877
+ width: var(--sw-space-5);
2878
+ }
2879
+
2880
+ .sw-denomination-scroll.sw-scroll-more-start:dir(rtl) .sw-denomination-strip .sw-denomination-label::after,
2881
+ .sw-denomination-scroll.sw-scroll-more-start:dir(rtl) .sw-denomination-strip thead .sw-denomination-corner:first-child::after {
2882
+ background: linear-gradient(to left, color-mix(in srgb, var(--sw-color-text) 28%, transparent), transparent);
2883
+ }
2884
+
2885
+ @media (forced-colors: active) {
2886
+ .sw-denomination-scroll.sw-scroll-more-start .sw-denomination-strip .sw-denomination-label::after,
2887
+ .sw-denomination-scroll.sw-scroll-more-start .sw-denomination-strip thead .sw-denomination-corner:first-child::after { display: none; }
2888
+ }
2889
+
2857
2890
  .sw-denomination-strip {
2858
2891
  border-collapse: collapse;
2859
2892
  width: 100%;
@@ -3068,6 +3101,27 @@ body {
3068
3101
  .sw-denomination-tile-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
3069
3102
  }
3070
3103
 
3104
+ .sw-denomination-cell-tone-accent { --sw-denomination-cell-tone: var(--sw-color-accent); }
3105
+ .sw-denomination-cell-tone-success { --sw-denomination-cell-tone: var(--sw-color-success); }
3106
+ .sw-denomination-cell-tone-danger { --sw-denomination-cell-tone: var(--sw-color-danger); }
3107
+ .sw-denomination-cell-tone-warning { --sw-denomination-cell-tone: var(--sw-color-warning); }
3108
+
3109
+ .sw-denomination-grid .sw-denomination-cell.sw-denomination-cell-toned,
3110
+ .sw-denomination-grid .sw-denomination-cell-toned .sw-denomination-cell {
3111
+ color: var(--sw-denomination-cell-tone);
3112
+ opacity: 1;
3113
+ }
3114
+
3115
+ .sw-denomination-tile.sw-denomination-cell-toned {
3116
+ border-color: var(--sw-denomination-cell-tone);
3117
+ box-shadow: inset 0 0 0 1px var(--sw-denomination-cell-tone);
3118
+ }
3119
+
3120
+ @media (forced-colors: active) {
3121
+ .sw-denomination-grid .sw-denomination-cell-zero.sw-denomination-cell-toned,
3122
+ .sw-denomination-grid .sw-denomination-cell-toned .sw-denomination-cell-zero { color: GrayText; }
3123
+ }
3124
+
3071
3125
  @media (forced-colors: active) {
3072
3126
  .sw-denomination-label { border-inline-start-color: CanvasText; color: CanvasText; }
3073
3127
  .sw-denomination-strip .sw-denomination-label::before { background: CanvasText; forced-color-adjust: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",