@scalewing/react 1.11.0 → 1.12.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 +71 -0
- package/README.md +28 -6
- package/dist/anchored-position.d.ts +14 -6
- package/dist/anchored-position.js +10 -9
- package/dist/components/ActionMenu.d.ts +9 -1
- package/dist/components/ActionMenu.js +2 -2
- package/dist/components/Checkbox.js +2 -1
- package/dist/components/DateField.js +4 -3
- package/dist/components/DenominationGrid.d.ts +1 -1
- package/dist/components/DenominationGrid.js +4 -2
- package/dist/components/Field.js +6 -3
- package/dist/components/FieldErrorRegion.d.ts +14 -0
- package/dist/components/FieldErrorRegion.js +13 -0
- package/dist/components/RadioGroup.js +2 -1
- package/dist/components/Select.d.ts +10 -1
- package/dist/components/Select.js +17 -86
- package/dist/components/Toast.d.ts +15 -1
- package/dist/components/Toast.js +15 -5
- package/dist/components/action-menu/ActionMenuList.d.ts +6 -4
- package/dist/components/action-menu/ActionMenuList.js +5 -4
- package/dist/components/denomination-grid/DenominationStrip.d.ts +2 -2
- package/dist/components/denomination-grid/DenominationStrip.js +13 -3
- package/dist/components/denomination-grid/DenominationTiles.js +8 -1
- package/dist/components/denomination-grid/types.d.ts +5 -0
- package/dist/components/select/SelectListbox.d.ts +20 -0
- package/dist/components/select/SelectListbox.js +16 -0
- package/dist/components/select/SelectValue.d.ts +15 -0
- package/dist/components/select/SelectValue.js +16 -0
- package/dist/components/select/use-select-list.d.ts +26 -0
- package/dist/components/select/use-select-list.js +85 -0
- package/dist/components/use-anchored-popover.d.ts +5 -2
- package/dist/components/use-anchored-popover.js +6 -5
- package/dist/css/chevron.d.ts +5 -0
- package/dist/css/chevron.js +9 -0
- package/dist/css/css-accordion.js +2 -5
- package/dist/css/css-button.d.ts +1 -0
- package/dist/css/css-button.js +19 -4
- package/dist/css/css-checkbox.js +1 -0
- package/dist/css/css-data.js +18 -1
- package/dist/css/css-date-field.js +2 -1
- package/dist/css/css-denomination-grid.js +11 -1
- package/dist/css/css-field.js +3 -0
- package/dist/css/css-filter-chips.js +8 -2
- package/dist/css/css-radio-group.js +3 -2
- package/dist/css/css-select.js +57 -19
- package/dist/css/css-toast.d.ts +2 -0
- package/dist/css/css-toast.js +42 -1
- package/dist/css/css-utilities.d.ts +2 -0
- package/dist/css/css-utilities.js +3 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/styles.css +113 -29
- package/package.json +1 -1
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { type HTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
import { toastTones, type ToastTone } from '../css/css-toast.js';
|
|
3
|
+
export { toastTones };
|
|
4
|
+
export type { ToastTone };
|
|
2
5
|
export type ToastProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'popover'> & {
|
|
3
6
|
open: boolean;
|
|
4
7
|
onOpenChange: (open: boolean) => void;
|
|
8
|
+
/**
|
|
9
|
+
* How long the toast stays open, in ms. Defaults to 800 for a neutral or
|
|
10
|
+
* success toast and 6000 for a warning or danger one, which must be read.
|
|
11
|
+
*/
|
|
5
12
|
timeoutMs?: number;
|
|
6
13
|
anchor?: Element | null;
|
|
7
14
|
target?: Element | null;
|
|
15
|
+
/**
|
|
16
|
+
* `success`, `warning` and `danger` tint the border and `icon`; `danger`
|
|
17
|
+
* is announced as an alert, the others as a status. Default `neutral`.
|
|
18
|
+
*/
|
|
19
|
+
tone?: ToastTone;
|
|
20
|
+
/** A consumer glyph before the message, hidden from assistive technology. */
|
|
21
|
+
icon?: ReactNode;
|
|
8
22
|
children: ReactNode;
|
|
9
23
|
};
|
|
10
|
-
export declare function Toast({ anchor, children, className, onOpenChange, open, target, timeoutMs, ...rest }: ToastProps): import("react").JSX.Element;
|
|
24
|
+
export declare function Toast({ anchor, children, className, onOpenChange, open, target, timeoutMs, tone, icon, ...rest }: ToastProps): import("react").JSX.Element;
|
package/dist/components/Toast.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useLayoutEffect, useEffect, useRef, } from 'react';
|
|
4
4
|
import { cx } from '../class-names.js';
|
|
5
|
+
import { toastTones } from '../css/css-toast.js';
|
|
5
6
|
import { spacingClassNames } from '../spacing-classes.js';
|
|
6
7
|
import { applyTravelVars, clearTravelVars, travelTranslate, } from '../toast-travel.js';
|
|
8
|
+
export { toastTones };
|
|
7
9
|
const DEFAULT_TIMEOUT_MS = 800;
|
|
10
|
+
/** A warning or an error must be read, not glimpsed. */
|
|
11
|
+
const URGENT_TIMEOUT_MS = 6000;
|
|
12
|
+
function defaultTimeout(tone) {
|
|
13
|
+
return tone === 'warning' || tone === 'danger'
|
|
14
|
+
? URGENT_TIMEOUT_MS
|
|
15
|
+
: DEFAULT_TIMEOUT_MS;
|
|
16
|
+
}
|
|
8
17
|
function travels(anchor, target) {
|
|
9
18
|
return Boolean(anchor && target);
|
|
10
19
|
}
|
|
11
|
-
export function Toast({ anchor = null, children, className, onOpenChange, open, target = null, timeoutMs =
|
|
20
|
+
export function Toast({ anchor = null, children, className, onOpenChange, open, target = null, timeoutMs, tone = 'neutral', icon, ...rest }) {
|
|
12
21
|
const nodeRef = useRef(null);
|
|
22
|
+
const dismissAfter = timeoutMs ?? defaultTimeout(tone);
|
|
13
23
|
const moving = travels(anchor, target);
|
|
14
24
|
useLayoutEffect(() => {
|
|
15
25
|
const node = nodeRef.current;
|
|
@@ -46,16 +56,16 @@ export function Toast({ anchor = null, children, className, onOpenChange, open,
|
|
|
46
56
|
}
|
|
47
57
|
const timer = window.setTimeout(() => {
|
|
48
58
|
onOpenChange(false);
|
|
49
|
-
},
|
|
59
|
+
}, dismissAfter);
|
|
50
60
|
return () => {
|
|
51
61
|
window.clearTimeout(timer);
|
|
52
62
|
};
|
|
53
|
-
}, [moving, open, onOpenChange
|
|
63
|
+
}, [dismissAfter, moving, open, onOpenChange]);
|
|
54
64
|
function onAnimationEnd(event) {
|
|
55
65
|
if (event.target !== event.currentTarget || !moving) {
|
|
56
66
|
return;
|
|
57
67
|
}
|
|
58
68
|
onOpenChange(false);
|
|
59
69
|
}
|
|
60
|
-
return (_jsx("div", { ...rest, ref: nodeRef, className: cx('sw-toast', moving && 'sw-toast-travel', ...spacingClassNames({ padding: 3 }), className), onAnimationEnd: onAnimationEnd, popover: "manual", role:
|
|
70
|
+
return (_jsx("div", { ...rest, ref: nodeRef, className: cx('sw-toast', tone !== 'neutral' && `sw-toast-${tone}`, moving && 'sw-toast-travel', ...spacingClassNames({ padding: 3 }), className), onAnimationEnd: onAnimationEnd, popover: "manual", role: tone === 'danger' ? 'alert' : 'status', children: icon ? (_jsxs("div", { className: "sw-toast-row", children: [_jsx("span", { "aria-hidden": "true", className: "sw-toast-icon", children: icon }), _jsx("div", { className: "sw-toast-body", children: children })] })) : (children) }));
|
|
61
71
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type KeyboardEvent, type RefObject } from 'react';
|
|
2
|
+
import { type InlineAlign } from '../../anchored-position.js';
|
|
2
3
|
import type { ActionMenuItem } from '../ActionMenu.js';
|
|
3
4
|
type ActionMenuListProps = {
|
|
4
5
|
id: string;
|
|
6
|
+
align: InlineAlign;
|
|
5
7
|
label: string;
|
|
6
8
|
items: readonly ActionMenuItem[];
|
|
7
9
|
focusIndex: number;
|
|
@@ -13,9 +15,9 @@ type ActionMenuListProps = {
|
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
15
17
|
* The open menu: on the popover layer beside the trigger (a gap below or
|
|
16
|
-
* above it, inset from the viewport edges,
|
|
17
|
-
*
|
|
18
|
-
* holding DOM focus.
|
|
18
|
+
* above it, inset from the viewport edges, lined up with the trigger's start
|
|
19
|
+
* or, with `align="end"`, its end, and with the other edge when that one
|
|
20
|
+
* would not fit), with the focused command holding DOM focus.
|
|
19
21
|
*/
|
|
20
|
-
export declare function ActionMenuList({ id, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }: ActionMenuListProps): import("react").JSX.Element;
|
|
22
|
+
export declare function ActionMenuList({ id, align, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }: ActionMenuListProps): import("react").JSX.Element;
|
|
21
23
|
export {};
|
|
@@ -5,13 +5,14 @@ import { cx } from '../../class-names.js';
|
|
|
5
5
|
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
6
6
|
/**
|
|
7
7
|
* The open menu: on the popover layer beside the trigger (a gap below or
|
|
8
|
-
* above it, inset from the viewport edges,
|
|
9
|
-
*
|
|
10
|
-
* holding DOM focus.
|
|
8
|
+
* above it, inset from the viewport edges, lined up with the trigger's start
|
|
9
|
+
* or, with `align="end"`, its end, and with the other edge when that one
|
|
10
|
+
* would not fit), with the focused command holding DOM focus.
|
|
11
11
|
*/
|
|
12
|
-
export function ActionMenuList({ id, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }) {
|
|
12
|
+
export function ActionMenuList({ id, align, label, items, focusIndex, triggerRef, onDismiss, onFocusIndex, onKeyDown, onSelect, }) {
|
|
13
13
|
const menuRef = useRef(null);
|
|
14
14
|
useAnchoredPopover(menuRef, triggerRef, triggerRef, onDismiss, {
|
|
15
|
+
align,
|
|
15
16
|
flipInline: true,
|
|
16
17
|
});
|
|
17
18
|
useLayoutEffect(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type DenominationGridProps } from './types.js';
|
|
2
|
-
type StripProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel'
|
|
3
|
-
export declare function DenominationStrip({ label, columns, rows, zeroLabel, }: StripProps): import("react").JSX.Element;
|
|
2
|
+
type StripProps = Required<Pick<DenominationGridProps, 'label' | 'columns' | 'rows' | 'zeroLabel'>> & Pick<DenominationGridProps, 'totalLabel'>;
|
|
3
|
+
export declare function DenominationStrip({ label, columns, rows, zeroLabel, totalLabel, }: StripProps): import("react").JSX.Element;
|
|
4
4
|
export {};
|
|
@@ -2,10 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { cx } from '../../class-names.js';
|
|
3
3
|
import { denominationCellView } from '../../denomination-cells.js';
|
|
4
4
|
import { ScrollRegion } from '../ScrollRegion.js';
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The total column's header: a visually hidden `th` when the consumer names
|
|
7
|
+
* the column, otherwise the empty corner it has always been. Both carry
|
|
8
|
+
* `sw-denomination-total-head`, so below md the column takes no width.
|
|
9
|
+
*/
|
|
10
|
+
function TotalHead({ totalLabel }) {
|
|
11
|
+
if (totalLabel === undefined)
|
|
12
|
+
return _jsx("td", { className: "sw-denomination-corner sw-denomination-total-head" });
|
|
13
|
+
return (_jsx("th", { className: "sw-denomination-total-head", scope: "col", children: _jsx("span", { className: "sw-sr-only", children: totalLabel }) }));
|
|
14
|
+
}
|
|
15
|
+
export function DenominationStrip({ label, columns, rows, zeroLabel, totalLabel, }) {
|
|
6
16
|
const hasTotals = rows.some((row) => row.total !== undefined);
|
|
7
|
-
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(
|
|
17
|
+
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: [row.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-icon", children: row.icon })) : null, _jsx("span", { className: "sw-denomination-label-text", children: row.label }), row.total !== undefined ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-total-inline", children: row.total })) : null] }) }), columns.map((column, index) => {
|
|
8
18
|
const view = denominationCellView(row.cells[index] ?? null, row.signed ?? false, zeroLabel);
|
|
9
19
|
return (_jsx("td", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }, column.key));
|
|
10
|
-
}), hasTotals ? (_jsx("td", { className: "sw-denomination-total", children: row.total ?? '' })) : null] }, row.id))) })] }) }));
|
|
20
|
+
}), hasTotals ? (_jsx("td", { className: "sw-denomination-total", children: _jsx("span", { className: "sw-denomination-total-value", children: row.total ?? '' }) })) : null] }, row.id))) })] }) }));
|
|
11
21
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../../class-names.js';
|
|
3
3
|
import { denominationCellView } from '../../denomination-cells.js';
|
|
4
|
+
/**
|
|
5
|
+
* A lone row is named by the grid's own label, so its label line shows only
|
|
6
|
+
* when it adds something: an icon or a total. Several rows always show it.
|
|
7
|
+
*/
|
|
8
|
+
function showsRowLabel(rowCount, row) {
|
|
9
|
+
return rowCount > 1 || Boolean(row.icon) || row.total !== undefined;
|
|
10
|
+
}
|
|
4
11
|
export function DenominationTiles({ label, columns, rows, subtotal, zeroLabel, }) {
|
|
5
|
-
return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => (_jsxs("section", { "aria-label": row.label, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), children: [rows.length
|
|
12
|
+
return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => (_jsxs("section", { "aria-label": row.label, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), children: [showsRowLabel(rows.length, row) ? (_jsxs("span", { className: "sw-denomination-label", children: [row.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-icon", children: row.icon })) : null, _jsx("span", { className: "sw-denomination-label-text", children: row.label }), 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) => {
|
|
6
13
|
const cell = row.cells[index] ?? null;
|
|
7
14
|
const view = denominationCellView(cell, row.signed ?? false, zeroLabel);
|
|
8
15
|
const amount = subtotal && cell !== null && cell !== 0
|
|
@@ -22,4 +22,9 @@ export type DenominationGridProps = {
|
|
|
22
22
|
layout?: DenominationGridLayout;
|
|
23
23
|
subtotal?: (count: number, column: DenominationGridColumn) => string;
|
|
24
24
|
zeroLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Names the strip's total column for assistive technology (a visually
|
|
27
|
+
* hidden column header), such as "Total". Only the strip has that column.
|
|
28
|
+
*/
|
|
29
|
+
totalLabel?: string;
|
|
25
30
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type SelectOption } from '../../select-list.js';
|
|
2
|
+
type SelectListboxProps = {
|
|
3
|
+
/** The trailing command's label, after the options; never the value. */
|
|
4
|
+
actionLabel?: string;
|
|
5
|
+
highlight: number;
|
|
6
|
+
id: string;
|
|
7
|
+
labelId: string;
|
|
8
|
+
optionIdPrefix: string;
|
|
9
|
+
options: readonly SelectOption[];
|
|
10
|
+
value: string;
|
|
11
|
+
onCommit: (index: number) => void;
|
|
12
|
+
onHighlight: (index: number) => void;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The open list: one option per value, then the optional action as the last
|
|
16
|
+
* item (index `options.length`). The trigger keeps DOM focus and points at
|
|
17
|
+
* the highlighted item with `aria-activedescendant`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function SelectListbox({ actionLabel, highlight, id, labelId, optionIdPrefix, options, value, onCommit, onHighlight, }: SelectListboxProps): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../../class-names.js';
|
|
3
|
+
function SelectListOption({ action = false, active, children, id, onCommit, onHighlight, selected, }) {
|
|
4
|
+
return (_jsx("div", { "aria-selected": selected, className: cx('sw-select-option', action && 'sw-select-action'), "data-active": active ? 'true' : undefined, id: id, onClick: onCommit, onPointerDown: (event) => {
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
}, onPointerMove: onHighlight, role: "option", children: children }));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The open list: one option per value, then the optional action as the last
|
|
10
|
+
* item (index `options.length`). The trigger keeps DOM focus and points at
|
|
11
|
+
* the highlighted item with `aria-activedescendant`.
|
|
12
|
+
*/
|
|
13
|
+
export function SelectListbox({ actionLabel, highlight, id, labelId, optionIdPrefix, options, value, onCommit, onHighlight, }) {
|
|
14
|
+
const actionIndex = options.length;
|
|
15
|
+
return (_jsxs("div", { "aria-labelledby": labelId, className: "sw-select-list", id: id, role: "listbox", children: [options.map((option, index) => (_jsx(SelectListOption, { active: index === highlight, id: `${optionIdPrefix}-${index}`, onCommit: () => onCommit(index), onHighlight: () => onHighlight(index), selected: option.value === value, children: option.label }, option.value))), actionLabel === undefined ? null : (_jsx(SelectListOption, { action: true, active: highlight === actionIndex, id: `${optionIdPrefix}-${actionIndex}`, onCommit: () => onCommit(actionIndex), onHighlight: () => onHighlight(actionIndex), selected: false, children: actionLabel }))] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type SelectOption } from '../../select-list.js';
|
|
2
|
+
/**
|
|
3
|
+
* The closed trigger's text: the current label, stacked in one grid cell
|
|
4
|
+
* over a hidden copy of every option's label, so the trigger is as wide as
|
|
5
|
+
* its longest option, as a native select is, and does not change width when
|
|
6
|
+
* the value changes, including to or from the placeholder. The copies are
|
|
7
|
+
* drawn from `data-label` by generated CSS, so they are neither text content
|
|
8
|
+
* nor in the accessibility tree.
|
|
9
|
+
*/
|
|
10
|
+
export declare function SelectValue({ options, placeholder, text, textIsPlaceholder, }: {
|
|
11
|
+
options: readonly SelectOption[];
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
text: string;
|
|
14
|
+
textIsPlaceholder: boolean;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../../class-names.js';
|
|
3
|
+
/**
|
|
4
|
+
* The closed trigger's text: the current label, stacked in one grid cell
|
|
5
|
+
* over a hidden copy of every option's label, so the trigger is as wide as
|
|
6
|
+
* its longest option, as a native select is, and does not change width when
|
|
7
|
+
* the value changes, including to or from the placeholder. The copies are
|
|
8
|
+
* drawn from `data-label` by generated CSS, so they are neither text content
|
|
9
|
+
* nor in the accessibility tree.
|
|
10
|
+
*/
|
|
11
|
+
export function SelectValue({ options, placeholder, text, textIsPlaceholder, }) {
|
|
12
|
+
const labels = options.map((option) => option.label);
|
|
13
|
+
if (placeholder !== undefined)
|
|
14
|
+
labels.push(placeholder);
|
|
15
|
+
return (_jsxs("span", { className: "sw-select-value", children: [_jsx("span", { className: cx('sw-select-value-text', textIsPlaceholder && 'sw-select-placeholder'), children: text }), labels.map((sizerLabel, index) => (_jsx("span", { "aria-hidden": "true", className: "sw-select-value-sizer", "data-label": sizerLabel }, index)))] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type KeyboardEvent, type RefObject } from 'react';
|
|
2
|
+
import { type SelectOption } from '../../select-list.js';
|
|
3
|
+
import type { SelectAction } from '../Select.js';
|
|
4
|
+
type SelectListOptions = {
|
|
5
|
+
action?: SelectAction;
|
|
6
|
+
onChange: (value: string) => void;
|
|
7
|
+
options: readonly SelectOption[];
|
|
8
|
+
rootRef: RefObject<HTMLElement | null>;
|
|
9
|
+
value: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The open state, highlighted item and keyboard model of a Select: the
|
|
13
|
+
* trigger keeps focus, arrows move the highlight over the options and the
|
|
14
|
+
* trailing action (index `options.length`), Enter or Space commits, Escape
|
|
15
|
+
* and a press outside the root close.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useSelectList({ action, onChange, options, rootRef, value, }: SelectListOptions): {
|
|
18
|
+
commit: (index: number) => void;
|
|
19
|
+
highlight: number;
|
|
20
|
+
onTriggerKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
|
|
21
|
+
open: boolean;
|
|
22
|
+
openList: () => void;
|
|
23
|
+
setHighlight: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
24
|
+
setOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { selectIndexForKey, selectedSelectIndex, } from '../../select-list.js';
|
|
3
|
+
/**
|
|
4
|
+
* The open state, highlighted item and keyboard model of a Select: the
|
|
5
|
+
* trigger keeps focus, arrows move the highlight over the options and the
|
|
6
|
+
* trailing action (index `options.length`), Enter or Space commits, Escape
|
|
7
|
+
* and a press outside the root close.
|
|
8
|
+
*/
|
|
9
|
+
export function useSelectList({ action, onChange, options, rootRef, value, }) {
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
const [highlight, setHighlight] = useState(0);
|
|
12
|
+
const selectedIndex = selectedSelectIndex(options, value);
|
|
13
|
+
const actionIndex = options.length;
|
|
14
|
+
const itemCount = action ? actionIndex + 1 : actionIndex;
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!open) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
function onPointerDown(event) {
|
|
20
|
+
if (!rootRef.current?.contains(event.target)) {
|
|
21
|
+
setOpen(false);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
27
|
+
};
|
|
28
|
+
}, [open, rootRef]);
|
|
29
|
+
function openList() {
|
|
30
|
+
if (itemCount === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setHighlight(selectedIndex);
|
|
34
|
+
setOpen(true);
|
|
35
|
+
}
|
|
36
|
+
function commit(index) {
|
|
37
|
+
if (action && index === actionIndex) {
|
|
38
|
+
action.onPress();
|
|
39
|
+
setOpen(false);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const option = options[index];
|
|
43
|
+
if (option && option.value !== value) {
|
|
44
|
+
onChange(option.value);
|
|
45
|
+
}
|
|
46
|
+
setOpen(false);
|
|
47
|
+
}
|
|
48
|
+
function onTriggerKeyDown(event) {
|
|
49
|
+
if (event.key === 'Escape' && open) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
event.stopPropagation();
|
|
52
|
+
setOpen(false);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!open) {
|
|
56
|
+
if (event.key === 'ArrowDown' ||
|
|
57
|
+
event.key === 'ArrowUp' ||
|
|
58
|
+
event.key === 'Enter' ||
|
|
59
|
+
event.key === ' ') {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
openList();
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
commit(highlight);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const next = selectIndexForKey(event.key, highlight, itemCount);
|
|
71
|
+
if (next !== null) {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setHighlight(next);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
commit,
|
|
78
|
+
highlight,
|
|
79
|
+
onTriggerKeyDown,
|
|
80
|
+
open,
|
|
81
|
+
openList,
|
|
82
|
+
setHighlight,
|
|
83
|
+
setOpen,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type RefObject } from 'react';
|
|
2
|
+
import { type InlineAlign } from '../anchored-position.js';
|
|
2
3
|
export type AnchoredPopoverPlacement = {
|
|
3
|
-
/**
|
|
4
|
+
/** The anchor edge the popover prefers to line up with (default start). */
|
|
5
|
+
align?: InlineAlign;
|
|
6
|
+
/** Line up with the anchor's other edge when the preferred one overflows. */
|
|
4
7
|
flipInline?: boolean;
|
|
5
8
|
};
|
|
6
9
|
/**
|
|
@@ -11,4 +14,4 @@ export type AnchoredPopoverPlacement = {
|
|
|
11
14
|
* outside it and outside `ignoreRef` (the button that toggles it).
|
|
12
15
|
* Browsers without the popover API get the same fixed placement.
|
|
13
16
|
*/
|
|
14
|
-
export declare function useAnchoredPopover(popoverRef: RefObject<HTMLElement | null>, anchorRef: RefObject<HTMLElement | null>, ignoreRef: RefObject<HTMLElement | null>, onDismiss: () => void, { flipInline }?: AnchoredPopoverPlacement): void;
|
|
17
|
+
export declare function useAnchoredPopover(popoverRef: RefObject<HTMLElement | null>, anchorRef: RefObject<HTMLElement | null>, ignoreRef: RefObject<HTMLElement | null>, onDismiss: () => void, { align, flipInline }?: AnchoredPopoverPlacement): void;
|
|
@@ -13,11 +13,12 @@ function layoutViewport() {
|
|
|
13
13
|
height: root.clientHeight || window.innerHeight,
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
function placePopover(node, anchor, { flipInline = false }) {
|
|
16
|
+
function placePopover(node, anchor, { align = 'start', flipInline = false }) {
|
|
17
17
|
const position = anchoredPosition(anchor.getBoundingClientRect(), { width: node.offsetWidth, height: node.offsetHeight }, layoutViewport(), {
|
|
18
18
|
gap: spacingScale[1],
|
|
19
19
|
inset: spacingScale[2],
|
|
20
20
|
rtl: getComputedStyle(anchor).direction === 'rtl',
|
|
21
|
+
align,
|
|
21
22
|
flipInline,
|
|
22
23
|
});
|
|
23
24
|
node.style.left = `${position.left}px`;
|
|
@@ -31,7 +32,7 @@ function placePopover(node, anchor, { flipInline = false }) {
|
|
|
31
32
|
* outside it and outside `ignoreRef` (the button that toggles it).
|
|
32
33
|
* Browsers without the popover API get the same fixed placement.
|
|
33
34
|
*/
|
|
34
|
-
export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss, { flipInline = false } = {}) {
|
|
35
|
+
export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss, { align = 'start', flipInline = false } = {}) {
|
|
35
36
|
const dismissRef = useRef(onDismiss);
|
|
36
37
|
useLayoutEffect(() => {
|
|
37
38
|
dismissRef.current = onDismiss;
|
|
@@ -49,7 +50,7 @@ export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss,
|
|
|
49
50
|
if (!node.matches(':popover-open'))
|
|
50
51
|
node.showPopover();
|
|
51
52
|
}
|
|
52
|
-
placePopover(node, anchor, { flipInline });
|
|
53
|
+
placePopover(node, anchor, { align, flipInline });
|
|
53
54
|
function onPointerDown(event) {
|
|
54
55
|
const target = event.target;
|
|
55
56
|
if (!(target instanceof Node))
|
|
@@ -60,7 +61,7 @@ export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss,
|
|
|
60
61
|
}
|
|
61
62
|
function onMove() {
|
|
62
63
|
if (node && anchor)
|
|
63
|
-
placePopover(node, anchor, { flipInline });
|
|
64
|
+
placePopover(node, anchor, { align, flipInline });
|
|
64
65
|
}
|
|
65
66
|
// A popover or anchor that changes size while open (new commands, a
|
|
66
67
|
// month with another row of weeks, a relabelled trigger) moves too.
|
|
@@ -79,5 +80,5 @@ export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss,
|
|
|
79
80
|
if (supportsPopover && node.matches(':popover-open'))
|
|
80
81
|
node.hidePopover();
|
|
81
82
|
};
|
|
82
|
-
}, [anchorRef, flipInline, ignoreRef, popoverRef]);
|
|
83
|
+
}, [align, anchorRef, flipInline, ignoreRef, popoverRef]);
|
|
83
84
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stroked chevron Accordion and Select share: two muted sides of a
|
|
3
|
+
* `space-2` square, turned by each surface to point where it needs.
|
|
4
|
+
*/
|
|
5
|
+
export declare const chevronStroke = "border-bottom: 2px solid var(--sw-color-muted);\n border-right: 2px solid var(--sw-color-muted);\n box-sizing: border-box;\n height: var(--sw-space-2);\n width: var(--sw-space-2);";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stroked chevron Accordion and Select share: two muted sides of a
|
|
3
|
+
* `space-2` square, turned by each surface to point where it needs.
|
|
4
|
+
*/
|
|
5
|
+
export const chevronStroke = `border-bottom: 2px solid var(--sw-color-muted);
|
|
6
|
+
border-right: 2px solid var(--sw-color-muted);
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
height: var(--sw-space-2);
|
|
9
|
+
width: var(--sw-space-2);`;
|
|
@@ -1,17 +1,14 @@
|
|
|
1
|
+
import { chevronStroke } from './chevron.js';
|
|
1
2
|
function markerRules() {
|
|
2
3
|
return `/* A token chevron replaces the user-agent triangle: it points to the inline end when closed and down when open. */
|
|
3
4
|
.sw-accordion-summary::-webkit-details-marker { display: none; }
|
|
4
5
|
|
|
5
6
|
.sw-accordion-marker {
|
|
6
|
-
|
|
7
|
-
border-right: 2px solid var(--sw-color-muted);
|
|
8
|
-
box-sizing: border-box;
|
|
7
|
+
${chevronStroke}
|
|
9
8
|
flex: none;
|
|
10
|
-
height: var(--sw-space-2);
|
|
11
9
|
margin-inline-end: var(--sw-space-1);
|
|
12
10
|
transform: rotate(-45deg);
|
|
13
11
|
transition: transform var(--sw-motion-default) var(--sw-motion-easing);
|
|
14
|
-
width: var(--sw-space-2);
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
.sw-accordion-marker:dir(rtl) { transform: rotate(135deg); }
|
package/dist/css/css-button.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare function buttonClassNames(options: {
|
|
|
3
3
|
size: ButtonSize;
|
|
4
4
|
variant: ButtonVariant;
|
|
5
5
|
}): string[];
|
|
6
|
+
export declare const pressedRingWidth = 4;
|
|
6
7
|
export declare function cssButtonClasses(): string;
|
|
7
8
|
export declare const buttonGroupJustifies: readonly ["start", "end", "between"];
|
|
8
9
|
export declare function buttonClassCatalog(): string[];
|
package/dist/css/css-button.js
CHANGED
|
@@ -17,6 +17,24 @@ function variantRules() {
|
|
|
17
17
|
`.sw-button-danger { background: var(--sw-color-danger); border-color: transparent; color: var(--sw-color-onDanger); }`,
|
|
18
18
|
].join('\n');
|
|
19
19
|
}
|
|
20
|
+
/*
|
|
21
|
+
* A toggle button's pressed state is an accent ring drawn outside the fill,
|
|
22
|
+
* past a 2 px gap in the page background: the ring meets the background on
|
|
23
|
+
* both sides, so it keeps the accent's contrast on the canvas (4.5:1 or more
|
|
24
|
+
* in every palette, above WCAG 1.4.11's 3:1) whatever the variant's fill.
|
|
25
|
+
* A focused pressed button moves its focus outline out past the ring, so the
|
|
26
|
+
* two stay apart. The unpressed button keeps its full label contrast: no
|
|
27
|
+
* opacity. Forced colors, which drop box shadows, fill the pressed button
|
|
28
|
+
* with the system highlight, as a checked FilterChips chip or Checkbox is.
|
|
29
|
+
*/
|
|
30
|
+
export const pressedRingWidth = 4;
|
|
31
|
+
function pressedRules() {
|
|
32
|
+
return `.sw-button[aria-pressed='true'] { box-shadow: 0 0 0 2px var(--sw-color-background), 0 0 0 ${pressedRingWidth}px var(--sw-color-accent); }
|
|
33
|
+
.sw-button[aria-pressed='true']:focus-visible { outline-offset: calc(var(--sw-focus-ring-offset) + ${pressedRingWidth}px); }
|
|
34
|
+
@media (forced-colors: active) {
|
|
35
|
+
.sw-button[aria-pressed='true'] { background: Highlight; border-color: Highlight; color: HighlightText; }
|
|
36
|
+
}`;
|
|
37
|
+
}
|
|
20
38
|
function sizeRules() {
|
|
21
39
|
return buttonSizes
|
|
22
40
|
.map((size) => {
|
|
@@ -54,11 +72,8 @@ export function cssButtonClasses() {
|
|
|
54
72
|
opacity: var(--sw-disabled-opacity);
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
.sw-button[aria-pressed='false'] {
|
|
58
|
-
opacity: var(--sw-quiet-opacity);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
75
|
${variantRules()}
|
|
76
|
+
${pressedRules()}
|
|
62
77
|
${sizeRules()}
|
|
63
78
|
${groupRules()}`;
|
|
64
79
|
}
|
package/dist/css/css-checkbox.js
CHANGED
|
@@ -100,6 +100,7 @@ export function cssCheckboxClasses() {
|
|
|
100
100
|
|
|
101
101
|
.sw-checkbox-description { color: var(--sw-color-muted); }
|
|
102
102
|
.sw-checkbox-error { color: var(--sw-color-danger); }
|
|
103
|
+
.sw-checkbox-error:empty { position: absolute; }
|
|
103
104
|
|
|
104
105
|
@media (prefers-reduced-transparency: reduce) {
|
|
105
106
|
.sw-checkbox-mark { background: var(--sw-color-surface); }
|
package/dist/css/css-data.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { trackInset, typographyVariants } from '@scalewing/tokens';
|
|
1
|
+
import { buttonVariants, trackInset, typographyVariants, } from '@scalewing/tokens';
|
|
2
2
|
import { scrollRegionRules } from './css-scroll-region.js';
|
|
3
3
|
import { zIndex } from './stacking.js';
|
|
4
4
|
export const badgeTones = [
|
|
@@ -15,6 +15,21 @@ const body = typographyVariants.body;
|
|
|
15
15
|
export function badgeClassNames(tone, size = 'md') {
|
|
16
16
|
return ['sw-badge', `sw-badge-${tone}`, `sw-badge-${size}`];
|
|
17
17
|
}
|
|
18
|
+
/*
|
|
19
|
+
* A badge inside a filled button would otherwise draw its tone on the
|
|
20
|
+
* button's fill (a warning badge on the accent is about 1.1:1), so it takes
|
|
21
|
+
* the surface and sets its words in the text color, which is 4.5:1 on the
|
|
22
|
+
* surface in every palette; its tone stays on its border (3:1 or more), as
|
|
23
|
+
* some palettes' tones are under 4.5:1 as text on the surface. A ghost
|
|
24
|
+
* button has no fill.
|
|
25
|
+
*/
|
|
26
|
+
function badgeOnFilledButtonRules() {
|
|
27
|
+
const filled = buttonVariants
|
|
28
|
+
.filter((variant) => variant !== 'ghost')
|
|
29
|
+
.map((variant) => `.sw-button-${variant}`)
|
|
30
|
+
.join(', ');
|
|
31
|
+
return `:is(${filled}) .sw-badge { background: var(--sw-color-surface); color: var(--sw-color-text); }`;
|
|
32
|
+
}
|
|
18
33
|
export function cssDataClasses() {
|
|
19
34
|
return `.sw-badge {
|
|
20
35
|
align-items: center;
|
|
@@ -68,6 +83,8 @@ export function cssDataClasses() {
|
|
|
68
83
|
color: var(--sw-color-warning);
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
${badgeOnFilledButtonRules()}
|
|
87
|
+
|
|
71
88
|
.sw-segmented {
|
|
72
89
|
background: var(--sw-glass-fill);
|
|
73
90
|
border: 1px solid var(--sw-glass-border);
|
|
@@ -123,7 +123,8 @@ ${input}:disabled,
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
.sw-date-field-description { color: var(--sw-color-muted); }
|
|
126
|
-
.sw-date-field-error { color: var(--sw-color-danger); }
|
|
126
|
+
.sw-date-field-error { color: var(--sw-color-danger); }
|
|
127
|
+
.sw-date-field-error:empty { position: absolute; }`;
|
|
127
128
|
}
|
|
128
129
|
function calendarRules() {
|
|
129
130
|
return `.sw-date-field-calendar {
|
|
@@ -2,6 +2,7 @@ import { typographyVariants } from '@scalewing/tokens';
|
|
|
2
2
|
import { breakpointQuery } from './breakpoints.js';
|
|
3
3
|
import { badgeTones } from './css-data.js';
|
|
4
4
|
import { scrollRegionRules } from './css-scroll-region.js';
|
|
5
|
+
import { visuallyHiddenDeclarations } from './css-utilities.js';
|
|
5
6
|
import { zIndex } from './stacking.js';
|
|
6
7
|
const caption = typographyVariants.caption;
|
|
7
8
|
const data = typographyVariants.data;
|
|
@@ -209,8 +210,15 @@ ${scrollRegionRules('.sw-denomination-scroll')}
|
|
|
209
210
|
text-align: center;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/*
|
|
214
|
+
* Below md the aria-hidden copy under each row label is what shows; the
|
|
215
|
+
* total cell stays in the table, visually hidden, so a screen reader still
|
|
216
|
+
* reads each total in its row and under its column header.
|
|
217
|
+
*/
|
|
212
218
|
@media ${breakpointQuery('below', 'md')} {
|
|
213
|
-
.sw-denomination-strip .sw-denomination-total
|
|
219
|
+
.sw-denomination-strip .sw-denomination-total,
|
|
220
|
+
.sw-denomination-strip .sw-denomination-total-head { padding: 0; position: relative; }
|
|
221
|
+
.sw-denomination-total-value { ${visuallyHiddenDeclarations} }
|
|
214
222
|
.sw-denomination-total-inline { display: block; }
|
|
215
223
|
.sw-denomination-label-body { flex-direction: column; align-items: flex-start; gap: var(--sw-space-1); }
|
|
216
224
|
.sw-denomination-strip th,
|
|
@@ -249,6 +257,8 @@ export function denominationGridClassCatalog() {
|
|
|
249
257
|
'sw-denomination-cell-negative',
|
|
250
258
|
'sw-denomination-total',
|
|
251
259
|
'sw-denomination-total-inline',
|
|
260
|
+
'sw-denomination-total-head',
|
|
261
|
+
'sw-denomination-total-value',
|
|
252
262
|
'sw-denomination-tiles',
|
|
253
263
|
'sw-denomination-tile-list',
|
|
254
264
|
'sw-denomination-tile',
|
package/dist/css/css-field.js
CHANGED
|
@@ -66,6 +66,9 @@ export function cssFieldClasses() {
|
|
|
66
66
|
.sw-field-description { color: var(--sw-color-muted); }
|
|
67
67
|
.sw-field-error { color: var(--sw-color-danger); }
|
|
68
68
|
|
|
69
|
+
/* An error region is always rendered as a live region; empty, it leaves the layout (and its gap) but stays in the accessibility tree. */
|
|
70
|
+
.sw-field-error:empty { position: absolute; }
|
|
71
|
+
|
|
69
72
|
[data-theme] .sw-field-invalid :is(input, select, textarea) {
|
|
70
73
|
border-color: var(--sw-color-danger);
|
|
71
74
|
}
|