@scalewing/react 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +48 -4
- package/dist/components/ActionMenu.d.ts +14 -0
- package/dist/components/ActionMenu.js +4 -0
- package/dist/components/ButtonGroup.d.ts +9 -1
- package/dist/components/ButtonGroup.js +2 -2
- package/dist/components/Checkbox.js +5 -4
- package/dist/components/FilterChips.d.ts +17 -1
- package/dist/components/FilterChips.js +11 -6
- package/dist/components/GlyphTile.d.ts +21 -0
- package/dist/components/GlyphTile.js +17 -0
- package/dist/components/MultiSelect.d.ts +10 -0
- package/dist/components/MultiSelect.js +62 -0
- package/dist/components/ScrollRegion.d.ts +6 -2
- package/dist/components/ScrollRegion.js +9 -3
- package/dist/components/SectionNav.d.ts +25 -2
- package/dist/components/SectionNav.js +13 -4
- package/dist/components/SegmentedControl.d.ts +8 -0
- package/dist/components/SegmentedControl.js +4 -3
- package/dist/components/Table.d.ts +49 -5
- package/dist/components/Table.js +11 -4
- package/dist/components/action-menu/ActionMenuList.js +7 -7
- package/dist/components/checkbox/CheckboxControl.d.ts +9 -0
- package/dist/components/checkbox/CheckboxControl.js +9 -0
- package/dist/components/multi-select/MultiSelectPanel.d.ts +25 -0
- package/dist/components/multi-select/MultiSelectPanel.js +41 -0
- package/dist/components/multi-select/types.d.ts +35 -0
- package/dist/components/multi-select/types.js +1 -0
- package/dist/components/multi-select/validate.d.ts +3 -0
- package/dist/components/multi-select/validate.js +25 -0
- package/dist/components/table/TableCell.d.ts +8 -0
- package/dist/components/table/TableCell.js +4 -2
- package/dist/components/table/TableRow.d.ts +39 -0
- package/dist/components/table/TableRow.js +8 -0
- package/dist/components/use-scroll-overflow.d.ts +8 -3
- package/dist/components/use-scroll-overflow.js +15 -4
- package/dist/css/css-action-menu.js +41 -0
- package/dist/css/css-button-group-attached.d.ts +2 -0
- package/dist/css/css-button-group-attached.js +70 -0
- package/dist/css/css-button.js +5 -1
- package/dist/css/css-components.js +8 -0
- package/dist/css/css-filter-chips.js +26 -0
- package/dist/css/css-glyph-tile.d.ts +5 -0
- package/dist/css/css-glyph-tile.js +57 -0
- package/dist/css/css-multi-select.d.ts +2 -0
- package/dist/css/css-multi-select.js +156 -0
- package/dist/css/css-section-nav-filled.d.ts +6 -0
- package/dist/css/css-section-nav-filled.js +80 -0
- package/dist/css/css-section-nav.js +4 -0
- package/dist/css/css-segmented.js +58 -0
- package/dist/css/css-table-footer.d.ts +2 -0
- package/dist/css/css-table-footer.js +57 -0
- package/dist/css/css-table-ledger.d.ts +10 -0
- package/dist/css/css-table-ledger.js +122 -0
- package/dist/css/css-table.js +8 -0
- package/dist/css/css-variables.js +11 -1
- package/dist/css/stacking.d.ts +9 -3
- package/dist/css/stacking.js +29 -9
- package/dist/css/stylesheet.js +4 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/styles.css +501 -7
- package/package.json +2 -2
|
@@ -2,6 +2,14 @@ import { type ReactNode } from 'react';
|
|
|
2
2
|
export type SegmentedItem = {
|
|
3
3
|
id: string;
|
|
4
4
|
label: ReactNode;
|
|
5
|
+
/**
|
|
6
|
+
* A consumer mark before the label, such as a `GlyphTile` or a Lucide
|
|
7
|
+
* glyph, hidden from assistive technology (the label names the item).
|
|
8
|
+
* When any item has one, every item of a compact track takes the `md`
|
|
9
|
+
* control height and label type on a subtle track; a filled track keeps
|
|
10
|
+
* its look and gains the marks. A falsy value (`0`, `''`) is no icon.
|
|
11
|
+
*/
|
|
12
|
+
icon?: ReactNode;
|
|
5
13
|
};
|
|
6
14
|
type SegmentedLabel = {
|
|
7
15
|
'aria-label': string;
|
|
@@ -5,6 +5,7 @@ import { cx } from '../class-names.js';
|
|
|
5
5
|
import { FieldErrorRegion } from './FieldErrorRegion.js';
|
|
6
6
|
export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, error, items, onChange, required = false, value, variant = 'compact', ...labelProps }, ref) {
|
|
7
7
|
const errorId = useId();
|
|
8
|
+
const withIcons = items.some((item) => item.icon);
|
|
8
9
|
function onKeyDown(event) {
|
|
9
10
|
if (disabled || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft')) {
|
|
10
11
|
return;
|
|
@@ -22,12 +23,12 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled
|
|
|
22
23
|
}
|
|
23
24
|
// The wrapper is always rendered, so an error that appears later swaps
|
|
24
25
|
// text into a live region that already exists and the group keeps focus.
|
|
25
|
-
return (_jsxs("div", { className: cx('sw-segmented-field', variant === 'filled' && 'sw-segmented-field-filled'), children: [_jsx("div", { ref: ref, "aria-describedby": error ? errorId : undefined, "aria-disabled": disabled || undefined, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
|
|
26
|
+
return (_jsxs("div", { className: cx('sw-segmented-field', variant === 'filled' && 'sw-segmented-field-filled'), children: [_jsx("div", { ref: ref, "aria-describedby": error ? errorId : undefined, "aria-disabled": disabled || undefined, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', withIcons && 'sw-segmented-with-icons', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
|
|
26
27
|
const selected = item.id === value;
|
|
27
|
-
return (
|
|
28
|
+
return (_jsxs("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
|
|
28
29
|
if (!selected) {
|
|
29
30
|
onChange?.(item.id);
|
|
30
31
|
}
|
|
31
|
-
}, role: "radio", type: "button", children: item.label }, item.id));
|
|
32
|
+
}, role: "radio", type: "button", children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-segmented-item-icon", children: item.icon })) : null, item.label] }, item.id));
|
|
32
33
|
}) }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
|
|
33
34
|
});
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { type HTMLAttributes } from 'react';
|
|
2
2
|
import { type TableLayout, type TableVerticalAlign } from './table/table-layout.js';
|
|
3
3
|
export { TableCell, type TableCellProps, type TableColumnWidth, type TableSort, } from './table/TableCell.js';
|
|
4
|
+
export { TableRow, type TableRowProps, type TableRowVariant, } from './table/TableRow.js';
|
|
5
|
+
export type { TableCellTone } from '../css/css-table-ledger.js';
|
|
6
|
+
export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
|
|
4
7
|
export type { TableLayout, TableVerticalAlign };
|
|
5
8
|
export type TableDensity = 'comfortable' | 'compact';
|
|
6
9
|
export type TableProps = HTMLAttributes<HTMLTableElement> & {
|
|
7
10
|
density?: TableDensity;
|
|
11
|
+
/**
|
|
12
|
+
* Pins the header row to the top of the table's own scroll region
|
|
13
|
+
* (default). In a table with a sticky `TableFooter` that fits its width
|
|
14
|
+
* the region does not scroll, so the header pins to the page's top.
|
|
15
|
+
*/
|
|
8
16
|
stickyHeader?: boolean;
|
|
9
17
|
/**
|
|
10
18
|
* `auto` (default) sizes each column by its content. `fixed` sizes the
|
|
@@ -23,10 +31,20 @@ export type TableProps = HTMLAttributes<HTMLTableElement> & {
|
|
|
23
31
|
* should start on its first line.
|
|
24
32
|
*/
|
|
25
33
|
verticalAlign?: TableVerticalAlign;
|
|
34
|
+
/**
|
|
35
|
+
* Draws a hairline between columns, at the inline start of every cell
|
|
36
|
+
* after a row's first, for a ledger read across as well as down.
|
|
37
|
+
*/
|
|
38
|
+
columnRules?: boolean;
|
|
26
39
|
};
|
|
27
40
|
/** A wide table scrolls inside its own `ScrollRegion`, named after the table. */
|
|
28
41
|
export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableElement> & {
|
|
29
42
|
density?: TableDensity;
|
|
43
|
+
/**
|
|
44
|
+
* Pins the header row to the top of the table's own scroll region
|
|
45
|
+
* (default). In a table with a sticky `TableFooter` that fits its width
|
|
46
|
+
* the region does not scroll, so the header pins to the page's top.
|
|
47
|
+
*/
|
|
30
48
|
stickyHeader?: boolean;
|
|
31
49
|
/**
|
|
32
50
|
* `auto` (default) sizes each column by its content. `fixed` sizes the
|
|
@@ -45,14 +63,40 @@ export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttrib
|
|
|
45
63
|
* should start on its first line.
|
|
46
64
|
*/
|
|
47
65
|
verticalAlign?: TableVerticalAlign;
|
|
66
|
+
/**
|
|
67
|
+
* Draws a hairline between columns, at the inline start of every cell
|
|
68
|
+
* after a row's first, for a ledger read across as well as down.
|
|
69
|
+
*/
|
|
70
|
+
columnRules?: boolean;
|
|
48
71
|
} & import("react").RefAttributes<HTMLTableElement>>;
|
|
49
72
|
export type TableHeaderProps = HTMLAttributes<HTMLTableSectionElement>;
|
|
50
73
|
export declare const TableHeader: import("react").ForwardRefExoticComponent<TableHeaderProps & import("react").RefAttributes<HTMLTableSectionElement>>;
|
|
51
74
|
export type TableBodyProps = HTMLAttributes<HTMLTableSectionElement>;
|
|
52
75
|
export declare const TableBody: import("react").ForwardRefExoticComponent<TableBodyProps & import("react").RefAttributes<HTMLTableSectionElement>>;
|
|
53
|
-
export type
|
|
54
|
-
|
|
76
|
+
export type TableFooterProps = HTMLAttributes<HTMLTableSectionElement> & {
|
|
77
|
+
/**
|
|
78
|
+
* Pins the footer, such as a total row, to the bottom edge while the rows
|
|
79
|
+
* scroll under it: to the page while the table fits its width (the
|
|
80
|
+
* sticky header then pins to the page's top too, under any sticky
|
|
81
|
+
* AppHeader, which covers it), or to the table's own region while the
|
|
82
|
+
* table scrolls sideways in it. A sticky ActionBar covers a pinned
|
|
83
|
+
* footer, and so does a Dialog's pinned footer, so do not pair them.
|
|
84
|
+
*/
|
|
85
|
+
sticky?: boolean;
|
|
55
86
|
};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The table's footer (`tfoot`): a total row, its cells at weight 600 under
|
|
89
|
+
* a heavier rule. A row header in it (`TableCell as="th" scope="row"`)
|
|
90
|
+
* names the total.
|
|
91
|
+
*/
|
|
92
|
+
export declare const TableFooter: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableSectionElement> & {
|
|
93
|
+
/**
|
|
94
|
+
* Pins the footer, such as a total row, to the bottom edge while the rows
|
|
95
|
+
* scroll under it: to the page while the table fits its width (the
|
|
96
|
+
* sticky header then pins to the page's top too, under any sticky
|
|
97
|
+
* AppHeader, which covers it), or to the table's own region while the
|
|
98
|
+
* table scrolls sideways in it. A sticky ActionBar covers a pinned
|
|
99
|
+
* footer, and so does a Dialog's pinned footer, so do not pair them.
|
|
100
|
+
*/
|
|
101
|
+
sticky?: boolean;
|
|
102
|
+
} & import("react").RefAttributes<HTMLTableSectionElement>>;
|
package/dist/components/Table.js
CHANGED
|
@@ -4,11 +4,13 @@ import { cx } from '../class-names.js';
|
|
|
4
4
|
import { ScrollRegion } from './ScrollRegion.js';
|
|
5
5
|
import { assertTableLayout, assertTableVerticalAlign, } from './table/table-layout.js';
|
|
6
6
|
export { TableCell, } from './table/TableCell.js';
|
|
7
|
+
export { TableRow, } from './table/TableRow.js';
|
|
8
|
+
export { tableCellTones, tableRowVariants } from '../css/css-table-ledger.js';
|
|
7
9
|
/** A wide table scrolls inside its own `ScrollRegion`, named after the table. */
|
|
8
|
-
export const Table = forwardRef(function Table({ children, className, density = 'comfortable', layout = 'auto', stickyHeader = true, verticalAlign = 'middle', ...rest }, ref) {
|
|
10
|
+
export const Table = forwardRef(function Table({ children, className, columnRules = false, density = 'comfortable', layout = 'auto', stickyHeader = true, verticalAlign = 'middle', ...rest }, ref) {
|
|
9
11
|
assertTableLayout(layout);
|
|
10
12
|
assertTableVerticalAlign(verticalAlign);
|
|
11
|
-
return (_jsx(ScrollRegion, { "aria-label": rest['aria-label'], "aria-labelledby": rest['aria-labelledby'], className: "sw-table-wrap", children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', layout === 'fixed' && 'sw-table-fixed', stickyHeader && 'sw-table-sticky', verticalAlign === 'top' && 'sw-table-top', className), ...rest, children: children }) }));
|
|
13
|
+
return (_jsx(ScrollRegion, { "aria-label": rest['aria-label'], "aria-labelledby": rest['aria-labelledby'], className: "sw-table-wrap", markFits: true, children: _jsx("table", { ref: ref, className: cx('sw-table', density === 'compact' && 'sw-table-compact', layout === 'fixed' && 'sw-table-fixed', stickyHeader && 'sw-table-sticky', verticalAlign === 'top' && 'sw-table-top', columnRules && 'sw-table-column-rules', className), ...rest, children: children }) }));
|
|
12
14
|
});
|
|
13
15
|
export const TableHeader = forwardRef(function TableHeader({ className, ...rest }, ref) {
|
|
14
16
|
return _jsx("thead", { ref: ref, className: className, ...rest });
|
|
@@ -16,6 +18,11 @@ export const TableHeader = forwardRef(function TableHeader({ className, ...rest
|
|
|
16
18
|
export const TableBody = forwardRef(function TableBody({ className, ...rest }, ref) {
|
|
17
19
|
return _jsx("tbody", { ref: ref, className: className, ...rest });
|
|
18
20
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The table's footer (`tfoot`): a total row, its cells at weight 600 under
|
|
23
|
+
* a heavier rule. A row header in it (`TableCell as="th" scope="row"`)
|
|
24
|
+
* names the total.
|
|
25
|
+
*/
|
|
26
|
+
export const TableFooter = forwardRef(function TableFooter({ className, sticky = false, ...rest }, ref) {
|
|
27
|
+
return (_jsx("tfoot", { ref: ref, className: cx(sticky && 'sw-table-footer-sticky', className), ...rest }));
|
|
21
28
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useId, useLayoutEffect, useRef, } from 'react';
|
|
3
|
+
import { Fragment, useId, useLayoutEffect, useRef, } from 'react';
|
|
4
4
|
import { cx } from '../../class-names.js';
|
|
5
5
|
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
6
6
|
/**
|
|
@@ -26,12 +26,12 @@ export function ActionMenuList({ id, align, header, label, items, focusIndex, tr
|
|
|
26
26
|
header !== null &&
|
|
27
27
|
header !== false &&
|
|
28
28
|
header !== '';
|
|
29
|
-
const menu = (_jsx("div", { "aria-describedby": hasHeader ? headerId : undefined, "aria-label": label, className: hasHeader ? 'sw-action-menu-items' : 'sw-action-menu-list', id: hasHeader ? undefined : id, onKeyDown: onKeyDown, ref: hasHeader ? undefined : menuRef, role: "menu", children: items.map((item, index) => (_jsxs("button", { className: cx('sw-action-menu-item', item.destructive && 'sw-action-menu-item-danger'), disabled: item.disabled, onClick: () => onSelect(item), onFocus: () => onFocusIndex(index), onKeyDown: (event) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
const menu = (_jsx("div", { "aria-describedby": hasHeader ? headerId : undefined, "aria-label": label, className: hasHeader ? 'sw-action-menu-items' : 'sw-action-menu-list', id: hasHeader ? undefined : id, onKeyDown: onKeyDown, ref: hasHeader ? undefined : menuRef, role: "menu", children: items.map((item, index) => (_jsxs(Fragment, { children: [item.separatorBefore && index > 0 ? (_jsx("div", { className: "sw-action-menu-separator", role: "separator" })) : null, _jsxs("button", { className: cx('sw-action-menu-item', item.destructive && 'sw-action-menu-item-danger'), disabled: item.disabled, onClick: () => onSelect(item), onFocus: () => onFocusIndex(index), onKeyDown: (event) => {
|
|
30
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
onSelect(item);
|
|
33
|
+
}
|
|
34
|
+
}, role: "menuitem", tabIndex: index === focusIndex ? 0 : -1, type: "button", children: [item.icon ? _jsx("span", { "aria-hidden": "true", children: item.icon }) : null, _jsx("span", { lang: item.lang, children: item.label }), item.detail ? (_jsxs("span", { className: "sw-action-menu-detail", children: [' ', _jsx("span", { lang: item.detailLang, children: item.detail }), _jsx("span", { "aria-hidden": "true", className: "sw-action-menu-chevron" })] })) : null] })] }, item.id))) }));
|
|
35
35
|
if (!hasHeader)
|
|
36
36
|
return menu;
|
|
37
37
|
// The header is not a menu item: it sits above the menu in the same
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type InputHTMLAttributes } from 'react';
|
|
2
|
+
type CheckboxControlProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'type'>;
|
|
3
|
+
/**
|
|
4
|
+
* The native checkbox and its drawn mark, the one markup Checkbox's
|
|
5
|
+
* generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
|
|
6
|
+
* and MultiSelect's rows so the two cannot drift apart.
|
|
7
|
+
*/
|
|
8
|
+
export declare function CheckboxControl(props: CheckboxControlProps): import("react").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The native checkbox and its drawn mark, the one markup Checkbox's
|
|
4
|
+
* generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
|
|
5
|
+
* and MultiSelect's rows so the two cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export function CheckboxControl(props) {
|
|
8
|
+
return (_jsxs("span", { className: "sw-checkbox-control", children: [_jsx("input", { ...props, className: "sw-checkbox-input", type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type MultiSelectAlign, type MultiSelectOption } from './types.js';
|
|
3
|
+
type MultiSelectPanelProps = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
align: MultiSelectAlign;
|
|
7
|
+
options: readonly MultiSelectOption[];
|
|
8
|
+
checked: ReadonlySet<string>;
|
|
9
|
+
allLabel: string;
|
|
10
|
+
clearLabel: string;
|
|
11
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
12
|
+
onToggle: (value: string, checked: boolean) => void;
|
|
13
|
+
onAll: () => void;
|
|
14
|
+
onClear: () => void;
|
|
15
|
+
/** Closes the panel, returning focus to the trigger when asked. */
|
|
16
|
+
onClose: (restoreFocus: boolean) => void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The open panel: a group named by the label on the popover layer beside
|
|
20
|
+
* the trigger, with All and Clear over one checkbox per option. The first
|
|
21
|
+
* checkbox takes focus as it opens; Escape closes it and returns focus to
|
|
22
|
+
* the trigger; focus or a press moving outside it closes it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function MultiSelectPanel({ align, allLabel, checked, clearLabel, id, label, onAll, onClear, onClose, onToggle, options, triggerRef, }: MultiSelectPanelProps): import("react").JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useLayoutEffect, useRef, } from 'react';
|
|
4
|
+
import { Button } from '../Button.js';
|
|
5
|
+
import { CheckboxControl } from '../checkbox/CheckboxControl.js';
|
|
6
|
+
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
7
|
+
/**
|
|
8
|
+
* The open panel: a group named by the label on the popover layer beside
|
|
9
|
+
* the trigger, with All and Clear over one checkbox per option. The first
|
|
10
|
+
* checkbox takes focus as it opens; Escape closes it and returns focus to
|
|
11
|
+
* the trigger; focus or a press moving outside it closes it.
|
|
12
|
+
*/
|
|
13
|
+
export function MultiSelectPanel({ align, allLabel, checked, clearLabel, id, label, onAll, onClear, onClose, onToggle, options, triggerRef, }) {
|
|
14
|
+
const panelRef = useRef(null);
|
|
15
|
+
useAnchoredPopover(panelRef, triggerRef, triggerRef, () => onClose(false), {
|
|
16
|
+
align,
|
|
17
|
+
flipInline: true,
|
|
18
|
+
});
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
panelRef.current
|
|
21
|
+
?.querySelector('.sw-multi-select-option input')
|
|
22
|
+
?.focus();
|
|
23
|
+
}, []);
|
|
24
|
+
function onKeyDown(event) {
|
|
25
|
+
if (event.key !== 'Escape')
|
|
26
|
+
return;
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
onClose(true);
|
|
29
|
+
}
|
|
30
|
+
function onBlur(event) {
|
|
31
|
+
const next = event.relatedTarget;
|
|
32
|
+
if (!(next instanceof Node))
|
|
33
|
+
return;
|
|
34
|
+
if (panelRef.current?.contains(next) || triggerRef.current?.contains(next))
|
|
35
|
+
return;
|
|
36
|
+
onClose(false);
|
|
37
|
+
}
|
|
38
|
+
return (_jsxs("div", { "aria-label": label, className: "sw-multi-select-panel", id: id, onBlur: onBlur, onKeyDown: onKeyDown, ref: panelRef, role: "group", children: [_jsxs("div", { className: "sw-multi-select-commands", children: [_jsx(Button, { onPress: onAll, size: "sm", variant: "ghost", children: allLabel }), _jsx(Button, { onPress: onClear, size: "sm", variant: "ghost", children: clearLabel })] }), options.map((option) => (_jsx("div", { className: option.separatorBefore
|
|
39
|
+
? 'sw-multi-select-row sw-multi-select-row-separated'
|
|
40
|
+
: 'sw-multi-select-row', children: _jsxs("label", { className: "sw-checkbox-label sw-multi-select-option", children: [_jsx(CheckboxControl, { checked: checked.has(option.value), onChange: (event) => onToggle(option.value, event.currentTarget.checked) }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-multi-select-icon", children: option.icon })) : null, _jsx("span", { className: "sw-multi-select-text", children: option.label })] }) }, option.value)))] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type InlineAlign } from '../../anchored-position.js';
|
|
3
|
+
/** One choice in a MultiSelect. */
|
|
4
|
+
export type MultiSelectOption = {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** A consumer mark before the label, such as a `GlyphTile`; hidden from assistive technology. */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** Draws a hairline before this option, to set a group of options apart. */
|
|
10
|
+
separatorBefore?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type MultiSelectAlign = InlineAlign;
|
|
13
|
+
export type MultiSelectProps = {
|
|
14
|
+
/**
|
|
15
|
+
* What is chosen, such as "Show": the muted lead of the trigger and the
|
|
16
|
+
* name of the panel of checkboxes.
|
|
17
|
+
*/
|
|
18
|
+
label: string;
|
|
19
|
+
options: readonly MultiSelectOption[];
|
|
20
|
+
/** The checked values; `onChange` reports them in the options' order. */
|
|
21
|
+
value: readonly string[];
|
|
22
|
+
onChange: (value: string[]) => void;
|
|
23
|
+
/** The command that checks every option, such as "All". */
|
|
24
|
+
allLabel: string;
|
|
25
|
+
/** The command that clears every option, such as "Clear". */
|
|
26
|
+
clearLabel: string;
|
|
27
|
+
/**
|
|
28
|
+
* The trigger's words after the label, naming what is chosen, such as
|
|
29
|
+
* "All entries" or "2 services". The consumer writes it.
|
|
30
|
+
*/
|
|
31
|
+
summary: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
/** The trigger edge the panel lines up with (default start). */
|
|
34
|
+
align?: MultiSelectAlign;
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type MultiSelectProps } from './types.js';
|
|
2
|
+
/** Fails closed on words or options a MultiSelect cannot show. */
|
|
3
|
+
export declare function assertMultiSelect({ allLabel, align, clearLabel, label, options, summary, value, }: Pick<MultiSelectProps, 'allLabel' | 'align' | 'clearLabel' | 'label' | 'options' | 'summary' | 'value'>): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function isBlank(text) {
|
|
2
|
+
return typeof text !== 'string' || !text.trim();
|
|
3
|
+
}
|
|
4
|
+
/** Fails closed on words or options a MultiSelect cannot show. */
|
|
5
|
+
export function assertMultiSelect({ allLabel, align, clearLabel, label, options, summary, value, }) {
|
|
6
|
+
if (isBlank(label) || isBlank(summary))
|
|
7
|
+
throw new RangeError('MultiSelect needs a label and a summary');
|
|
8
|
+
if (isBlank(allLabel) || isBlank(clearLabel))
|
|
9
|
+
throw new RangeError('MultiSelect needs an allLabel and a clearLabel');
|
|
10
|
+
if (options.length === 0)
|
|
11
|
+
throw new RangeError('MultiSelect needs at least one option');
|
|
12
|
+
const values = new Set();
|
|
13
|
+
for (const option of options) {
|
|
14
|
+
if (isBlank(option.value) || isBlank(option.label))
|
|
15
|
+
throw new RangeError('MultiSelect options need a value and a label');
|
|
16
|
+
if (values.has(option.value))
|
|
17
|
+
throw new RangeError(`Duplicate MultiSelect value: ${option.value}`);
|
|
18
|
+
values.add(option.value);
|
|
19
|
+
}
|
|
20
|
+
for (const checked of value)
|
|
21
|
+
if (!values.has(checked))
|
|
22
|
+
throw new RangeError(`Unknown MultiSelect value: ${checked}`);
|
|
23
|
+
if (align !== undefined && align !== 'start' && align !== 'end')
|
|
24
|
+
throw new RangeError(`Unknown MultiSelect align: ${String(align)}`);
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
|
|
2
|
+
import { type TableCellTone } from '../../css/css-table-ledger.js';
|
|
2
3
|
import { type TableColumnWidth } from './table-layout.js';
|
|
3
4
|
export type { TableColumnWidth };
|
|
4
5
|
type TableCellAlign = 'start' | 'end';
|
|
@@ -38,5 +39,12 @@ export type TableCellProps = ((Omit<TdHTMLAttributes<HTMLTableCellElement>, 'wid
|
|
|
38
39
|
* it as no width.
|
|
39
40
|
*/
|
|
40
41
|
width?: TableColumnWidth;
|
|
42
|
+
/**
|
|
43
|
+
* Colours the cell's text: `success` and `danger` for signed figures
|
|
44
|
+
* (money in, a shortfall), `accent` for a figure to act on, `muted` for
|
|
45
|
+
* a quiet one, in the theme's colour as `Text color` uses it. A colour
|
|
46
|
+
* is never the only signal: keep the sign or the words.
|
|
47
|
+
*/
|
|
48
|
+
tone?: TableCellTone;
|
|
41
49
|
};
|
|
42
50
|
export declare const TableCell: import("react").ForwardRefExoticComponent<TableCellProps & import("react").RefAttributes<HTMLTableCellElement>>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef, } from 'react';
|
|
3
3
|
import { cx } from '../../class-names.js';
|
|
4
|
+
import { assertTableCellTone, } from '../../css/css-table-ledger.js';
|
|
4
5
|
import { assertTableColumnWidth, tableColumnWidthClass, } from './table-layout.js';
|
|
5
6
|
const tableSorts = ['ascending', 'descending', 'none'];
|
|
6
7
|
/** `sort` and `onSort` come together, on a header cell, with a known value. */
|
|
@@ -14,14 +15,15 @@ function assertSort(as, sort, onSort) {
|
|
|
14
15
|
if (!tableSorts.includes(sort))
|
|
15
16
|
throw new RangeError(`Unknown TableCell sort: ${String(sort)}`);
|
|
16
17
|
}
|
|
17
|
-
export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onSort, sort, truncate = false, width, ...rest }, ref) {
|
|
18
|
+
export const TableCell = forwardRef(function TableCell({ align = 'start', as = 'td', children, className, numeric = false, onSort, sort, tone, truncate = false, width, ...rest }, ref) {
|
|
18
19
|
assertSort(as, sort, onSort);
|
|
19
20
|
assertTableColumnWidth(width);
|
|
21
|
+
assertTableCellTone(tone);
|
|
20
22
|
const Component = as;
|
|
21
23
|
const alignmentClass = numeric
|
|
22
24
|
? 'sw-table-numeric'
|
|
23
25
|
: align === 'end'
|
|
24
26
|
? 'sw-table-end'
|
|
25
27
|
: undefined;
|
|
26
|
-
return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : (children) }));
|
|
28
|
+
return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : (children) }));
|
|
27
29
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type HTMLAttributes } from 'react';
|
|
2
|
+
import { type TableRowVariant } from '../../css/css-table-ledger.js';
|
|
3
|
+
export type { TableRowVariant };
|
|
4
|
+
export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
|
|
5
|
+
selected?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `sub` sets the row under the one above it, as an item of a batch: its
|
|
8
|
+
* row header (`TableCell as="th" scope="row"`) is indented with a guide
|
|
9
|
+
* bar at the indent; a sub row without a row header is not indented.
|
|
10
|
+
* `default` is a row of its own.
|
|
11
|
+
*/
|
|
12
|
+
variant?: TableRowVariant;
|
|
13
|
+
/**
|
|
14
|
+
* A voided row kept on the record: the subtle fill and the text muted
|
|
15
|
+
* and struck through, toned cells included. A line-through is not read
|
|
16
|
+
* aloud, so say why in the row, such as a `Badge` "Voided" in the
|
|
17
|
+
* title's line (a Badge in a line of text is not struck; one that is a
|
|
18
|
+
* flex item, a direct child of `Inline`, is). Works with either variant.
|
|
19
|
+
*/
|
|
20
|
+
struck?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableRowElement> & {
|
|
23
|
+
selected?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `sub` sets the row under the one above it, as an item of a batch: its
|
|
26
|
+
* row header (`TableCell as="th" scope="row"`) is indented with a guide
|
|
27
|
+
* bar at the indent; a sub row without a row header is not indented.
|
|
28
|
+
* `default` is a row of its own.
|
|
29
|
+
*/
|
|
30
|
+
variant?: TableRowVariant;
|
|
31
|
+
/**
|
|
32
|
+
* A voided row kept on the record: the subtle fill and the text muted
|
|
33
|
+
* and struck through, toned cells included. A line-through is not read
|
|
34
|
+
* aloud, so say why in the row, such as a `Badge` "Voided" in the
|
|
35
|
+
* title's line (a Badge in a line of text is not struck; one that is a
|
|
36
|
+
* flex item, a direct child of `Inline`, is). Works with either variant.
|
|
37
|
+
*/
|
|
38
|
+
struck?: boolean;
|
|
39
|
+
} & import("react").RefAttributes<HTMLTableRowElement>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import { cx } from '../../class-names.js';
|
|
4
|
+
import { assertTableRowVariant, } from '../../css/css-table-ledger.js';
|
|
5
|
+
export const TableRow = forwardRef(function TableRow({ className, selected = false, struck = false, variant = 'default', ...rest }, ref) {
|
|
6
|
+
assertTableRowVariant(variant);
|
|
7
|
+
return (_jsx("tr", { ref: ref, "aria-selected": selected || undefined, className: cx(selected && 'sw-table-row-selected', variant === 'sub' && 'sw-table-row-sub', struck && 'sw-table-row-struck', className), ...rest }));
|
|
8
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { type RefObject } from 'react';
|
|
2
2
|
import { type ScrollOverflow } from '../scroll-overflow.js';
|
|
3
|
+
/** A box's overflow, and whether it has been measured yet. */
|
|
4
|
+
export type MeasuredScrollOverflow = ScrollOverflow & {
|
|
5
|
+
measured: boolean;
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* Follows which inline edges of a scroll box have more content past them:
|
|
5
9
|
* on mount, on every scroll, and whenever the box or its content changes
|
|
6
|
-
* size. Before mount (and on the server) it reports no overflow
|
|
7
|
-
* box renders as it always has until it is
|
|
10
|
+
* size. Before mount (and on the server) it reports no overflow and
|
|
11
|
+
* `measured: false`, so the box renders as it always has until it is
|
|
12
|
+
* measured; a measured box with neither edge set fits its content.
|
|
8
13
|
*/
|
|
9
|
-
export declare function useScrollOverflow(ref: RefObject<HTMLElement | null>):
|
|
14
|
+
export declare function useScrollOverflow(ref: RefObject<HTMLElement | null>): MeasuredScrollOverflow;
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { noScrollOverflow, sameScrollOverflow, scrollOverflow, } from '../scroll-overflow.js';
|
|
4
|
+
const unmeasured = {
|
|
5
|
+
...noScrollOverflow,
|
|
6
|
+
measured: false,
|
|
7
|
+
};
|
|
4
8
|
/**
|
|
5
9
|
* Follows which inline edges of a scroll box have more content past them:
|
|
6
10
|
* on mount, on every scroll, and whenever the box or its content changes
|
|
7
|
-
* size. Before mount (and on the server) it reports no overflow
|
|
8
|
-
* box renders as it always has until it is
|
|
11
|
+
* size. Before mount (and on the server) it reports no overflow and
|
|
12
|
+
* `measured: false`, so the box renders as it always has until it is
|
|
13
|
+
* measured; a measured box with neither edge set fits its content.
|
|
9
14
|
*/
|
|
10
15
|
export function useScrollOverflow(ref) {
|
|
11
|
-
const [overflow, setOverflow] = useState(
|
|
16
|
+
const [overflow, setOverflow] = useState(unmeasured);
|
|
12
17
|
useEffect(() => {
|
|
13
18
|
const box = ref.current;
|
|
14
19
|
if (!box)
|
|
15
20
|
return;
|
|
21
|
+
// The metrics hold on a box that does not scroll (a fitting table
|
|
22
|
+
// region set to overflow: visible): scrollWidth still reports content
|
|
23
|
+
// past its edge, so the box turns back into a scroll box as it widens.
|
|
24
|
+
// Keep it that way; a scroll-container-only check would pin it open.
|
|
16
25
|
function measure() {
|
|
17
26
|
if (!box)
|
|
18
27
|
return;
|
|
19
28
|
const next = scrollOverflow(box.scrollLeft, box.scrollWidth, box.clientWidth);
|
|
20
|
-
setOverflow((current) => sameScrollOverflow(current, next)
|
|
29
|
+
setOverflow((current) => current.measured && sameScrollOverflow(current, next)
|
|
30
|
+
? current
|
|
31
|
+
: { ...next, measured: true });
|
|
21
32
|
}
|
|
22
33
|
measure();
|
|
23
34
|
box.addEventListener('scroll', measure, { passive: true });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { chevronStroke } from './chevron.js';
|
|
2
3
|
import { zIndex } from './stacking.js';
|
|
3
4
|
import { coarsePointerQuery, touchTarget } from './touch-target.js';
|
|
4
5
|
const caption = typographyVariants.caption;
|
|
@@ -114,6 +115,43 @@ export function cssActionMenuClasses() {
|
|
|
114
115
|
color: var(--sw-color-danger);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/*
|
|
119
|
+
* A command's detail: its current value at the inline end in the muted
|
|
120
|
+
* colour, then a chevron pointing to the inline end (mirrored right to
|
|
121
|
+
* left). A long value wraps between words, never inside one (the command
|
|
122
|
+
* itself may break anywhere), and the chevron stays last.
|
|
123
|
+
*/
|
|
124
|
+
.sw-action-menu-detail {
|
|
125
|
+
align-items: center;
|
|
126
|
+
color: var(--sw-color-muted);
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
gap: var(--sw-space-2);
|
|
129
|
+
margin-inline-start: auto;
|
|
130
|
+
overflow-wrap: normal;
|
|
131
|
+
padding-inline-start: var(--sw-space-3);
|
|
132
|
+
text-align: end;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.sw-action-menu-chevron {
|
|
136
|
+
${chevronStroke}
|
|
137
|
+
flex: none;
|
|
138
|
+
transform: rotate(-45deg);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.sw-action-menu-chevron:dir(rtl) {
|
|
142
|
+
transform: rotate(135deg);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* A hairline that sets a command apart from the ones above. */
|
|
146
|
+
.sw-action-menu-separator {
|
|
147
|
+
border-top: 1px solid var(--sw-color-border);
|
|
148
|
+
margin-block: var(--sw-space-1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (forced-colors: active) {
|
|
152
|
+
.sw-action-menu-chevron { border-color: CanvasText; }
|
|
153
|
+
}
|
|
154
|
+
|
|
117
155
|
/* A coarse pointer gets the full touch target: the trigger at least square, and every command as tall. */
|
|
118
156
|
@media ${coarsePointerQuery} {
|
|
119
157
|
.sw-action-menu-trigger {
|
|
@@ -135,5 +173,8 @@ export function actionMenuClassCatalog() {
|
|
|
135
173
|
'sw-action-menu-items',
|
|
136
174
|
'sw-action-menu-item',
|
|
137
175
|
'sw-action-menu-item-danger',
|
|
176
|
+
'sw-action-menu-detail',
|
|
177
|
+
'sw-action-menu-chevron',
|
|
178
|
+
'sw-action-menu-separator',
|
|
138
179
|
];
|
|
139
180
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
2
|
+
import { joinedZIndex } from './stacking.js';
|
|
3
|
+
/*
|
|
4
|
+
* An attached ButtonGroup is one joined control, such as Previous, Today
|
|
5
|
+
* and Next: its direct Button children side by side with no gap and one
|
|
6
|
+
* height, their borders overlapping into single dividers, the inner
|
|
7
|
+
* corners square and the outer ones the sm radius. It keeps its row on a
|
|
8
|
+
* phone (it never stacks) and sizes to its buttons, even in a Stack that
|
|
9
|
+
* would stretch it, so `justify` does not apply.
|
|
10
|
+
*
|
|
11
|
+
* A borderless variant (primary, tertiary, ghost, danger) draws its
|
|
12
|
+
* divider in its own text colour at low strength, so a joined row of them
|
|
13
|
+
* still reads as separate buttons; forced colours draw every border.
|
|
14
|
+
*
|
|
15
|
+
* The group is its own stacking context: a pressed button's ring paints
|
|
16
|
+
* over its neighbours and the focused button's standard outer ring over
|
|
17
|
+
* both, so neither is covered by the next button.
|
|
18
|
+
*/
|
|
19
|
+
export function cssButtonGroupAttachedClasses() {
|
|
20
|
+
const group = '.sw-button-group.sw-button-group-attached';
|
|
21
|
+
return `${group} {
|
|
22
|
+
align-items: stretch;
|
|
23
|
+
display: inline-flex;
|
|
24
|
+
flex-wrap: nowrap;
|
|
25
|
+
gap: 0;
|
|
26
|
+
isolation: isolate;
|
|
27
|
+
max-width: 100%;
|
|
28
|
+
width: fit-content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
${group} > .sw-button {
|
|
32
|
+
border-radius: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
${group} > .sw-button + .sw-button {
|
|
36
|
+
margin-inline-start: -1px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
${group} > .sw-button:first-child {
|
|
40
|
+
border-end-start-radius: var(--sw-radius-sm);
|
|
41
|
+
border-start-start-radius: var(--sw-radius-sm);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
${group} > .sw-button:last-child {
|
|
45
|
+
border-end-end-radius: var(--sw-radius-sm);
|
|
46
|
+
border-start-end-radius: var(--sw-radius-sm);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
${group} > .sw-button + .sw-button:not(.sw-button-secondary) {
|
|
50
|
+
border-inline-start-color: color-mix(in srgb, currentColor 30%, transparent);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
${group} > .sw-button[aria-pressed='true'] {
|
|
54
|
+
position: relative;
|
|
55
|
+
${joinedZIndex('pressed')}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
${group} > .sw-button:focus-visible {
|
|
59
|
+
position: relative;
|
|
60
|
+
${joinedZIndex('focused')}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@media ${breakpointQuery('below', 'md')} {
|
|
64
|
+
${group} { align-items: stretch; flex-direction: row; }
|
|
65
|
+
${group} > * { width: auto; }
|
|
66
|
+
}`;
|
|
67
|
+
}
|
|
68
|
+
export function buttonGroupAttachedClassCatalog() {
|
|
69
|
+
return ['sw-button-group-attached'];
|
|
70
|
+
}
|