@scalewing/react 1.29.0 → 1.31.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 +54 -0
- package/README.md +13 -3
- 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/Combobox.d.ts +14 -0
- package/dist/components/Combobox.js +125 -0
- package/dist/components/Field.d.ts +17 -0
- package/dist/components/Field.js +54 -5
- package/dist/components/FilterChips.d.ts +17 -1
- package/dist/components/FilterChips.js +11 -6
- 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/Table.d.ts +49 -5
- package/dist/components/Table.js +11 -4
- package/dist/components/action-menu/ActionMenuList.js +7 -7
- package/dist/components/combobox/ComboboxPopup.d.ts +25 -0
- package/dist/components/combobox/ComboboxPopup.js +68 -0
- package/dist/components/combobox/entries.d.ts +18 -0
- package/dist/components/combobox/entries.js +34 -0
- package/dist/components/combobox/types.d.ts +63 -0
- package/dist/components/combobox/types.js +1 -0
- package/dist/components/combobox/validate.d.ts +3 -0
- package/dist/components/combobox/validate.js +44 -0
- package/dist/components/field/SearchFrame.d.ts +16 -0
- package/dist/components/field/SearchFrame.js +32 -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-combobox.d.ts +2 -0
- package/dist/css/css-combobox.js +135 -0
- package/dist/css/css-components.js +4 -0
- package/dist/css/css-field-search.d.ts +2 -0
- package/dist/css/css-field-search.js +118 -0
- package/dist/css/css-field.js +4 -0
- package/dist/css/css-filter-chips.js +26 -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-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/stacking.d.ts +9 -3
- package/dist/css/stacking.js +28 -8
- package/dist/css/stylesheet.js +2 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/styles.css +483 -7
- package/package.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type Breakpoint } from '../css/breakpoints.js';
|
|
3
|
+
import { type SectionNavAppearance } from '../css/css-section-nav-filled.js';
|
|
4
|
+
export type { SectionNavAppearance };
|
|
5
|
+
export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
|
|
3
6
|
export type SectionNavItem = {
|
|
4
7
|
id: string;
|
|
5
8
|
label: string;
|
|
@@ -11,6 +14,20 @@ export type SectionNavItem = {
|
|
|
11
14
|
current?: 'page' | 'location' | boolean;
|
|
12
15
|
/** A decorative glyph before the label. */
|
|
13
16
|
icon?: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* How many things wait in the section, such as tasks to do: a filled pill
|
|
19
|
+
* after the label, read as part of the link's name ("Tasks 3"). A whole
|
|
20
|
+
* number; 0 (or none) shows no pill. There is no cap: a long number
|
|
21
|
+
* widens the link.
|
|
22
|
+
*/
|
|
23
|
+
count?: number;
|
|
24
|
+
/**
|
|
25
|
+
* What the count means, in the consumer's words ("3 tasks to do"): read
|
|
26
|
+
* in the link's name in place of the bare number ("Tasks 3 tasks to
|
|
27
|
+
* do"; "Tasks 3 waiting" with "3 waiting"), the pill then hidden from
|
|
28
|
+
* assistive technology. Needs `count`.
|
|
29
|
+
*/
|
|
30
|
+
countLabel?: string;
|
|
14
31
|
};
|
|
15
32
|
type SectionNavLabel = {
|
|
16
33
|
'aria-label': string;
|
|
@@ -33,6 +50,13 @@ export type SectionNavProps = SectionNavLabel & {
|
|
|
33
50
|
* marked by a bar at its start) instead of a row (marked by an underline).
|
|
34
51
|
*/
|
|
35
52
|
verticalFrom?: Breakpoint;
|
|
53
|
+
/**
|
|
54
|
+
* How the current item is marked: `underline` (default) under a hairline
|
|
55
|
+
* row, or a bar at its start in a side list; `filled`, a quiet row of
|
|
56
|
+
* page buttons whose current one takes a soft fill, for an area's
|
|
57
|
+
* sub-navigation beside its name.
|
|
58
|
+
*/
|
|
59
|
+
appearance?: SectionNavAppearance;
|
|
36
60
|
};
|
|
37
61
|
/**
|
|
38
62
|
* A page's secondary navigation between the sections of one area, such as
|
|
@@ -40,5 +64,4 @@ export type SectionNavProps = SectionNavLabel & {
|
|
|
40
64
|
* workspace's own navigation, in a row that wraps or, from `verticalFrom`,
|
|
41
65
|
* a side list. The current item carries `aria-current`.
|
|
42
66
|
*/
|
|
43
|
-
export declare function SectionNav({ items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
|
|
44
|
-
export {};
|
|
67
|
+
export declare function SectionNav({ appearance, items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../class-names.js';
|
|
3
3
|
import { breakpoints } from '../css/breakpoints.js';
|
|
4
4
|
import { sectionNavVerticalClass } from '../css/css-section-nav.js';
|
|
5
|
+
import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
|
|
6
|
+
export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
|
|
5
7
|
function plainPress(event) {
|
|
6
8
|
return (event.button === 0 &&
|
|
7
9
|
!event.defaultPrevented &&
|
|
@@ -20,6 +22,12 @@ function assertItems(items) {
|
|
|
20
22
|
throw new RangeError('SectionNav items need nonempty ids and labels');
|
|
21
23
|
if (new Set(items.map((item) => item.id)).size !== items.length)
|
|
22
24
|
throw new RangeError('SectionNav items need unique ids');
|
|
25
|
+
if (items.some((item) => item.count !== undefined &&
|
|
26
|
+
(!Number.isInteger(item.count) || item.count < 0)))
|
|
27
|
+
throw new RangeError('SectionNav item counts are whole numbers from 0');
|
|
28
|
+
if (items.some((item) => item.countLabel !== undefined &&
|
|
29
|
+
(!item.countLabel.trim() || item.count === undefined)))
|
|
30
|
+
throw new RangeError('SectionNav item countLabel is nonempty and comes with a count');
|
|
23
31
|
}
|
|
24
32
|
function assertBreakpoint(verticalFrom) {
|
|
25
33
|
if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
|
|
@@ -31,10 +39,11 @@ function assertBreakpoint(verticalFrom) {
|
|
|
31
39
|
* workspace's own navigation, in a row that wraps or, from `verticalFrom`,
|
|
32
40
|
* a side list. The current item carries `aria-current`.
|
|
33
41
|
*/
|
|
34
|
-
export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
|
|
42
|
+
export function SectionNav({ appearance = 'underline', items, onNavigate, verticalFrom, ...label }) {
|
|
35
43
|
assertItems(items);
|
|
36
44
|
assertBreakpoint(verticalFrom);
|
|
37
|
-
|
|
45
|
+
assertSectionNavAppearance(appearance);
|
|
46
|
+
return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
|
|
38
47
|
// Only a given onNavigate attaches a handler, so the links
|
|
39
48
|
// render from a server component without one.
|
|
40
49
|
onClick: onNavigate
|
|
@@ -44,5 +53,5 @@ export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
|
|
|
44
53
|
event.preventDefault();
|
|
45
54
|
onNavigate(item);
|
|
46
55
|
}
|
|
47
|
-
: undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label] }) }, item.id))) }) }));
|
|
56
|
+
: undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label, item.count ? (_jsxs(_Fragment, { children: [item.countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${item.countLabel}` })) : (' '), _jsx("span", { "aria-hidden": item.countLabel ? true : undefined, className: "sw-section-nav-count", children: item.count })] })) : null] }) }, item.id))) }) }));
|
|
48
57
|
}
|
|
@@ -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,25 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type ComboboxEntry } from './entries.js';
|
|
3
|
+
import { type ComboboxGroup } from './types.js';
|
|
4
|
+
type ComboboxPopupProps = {
|
|
5
|
+
listId: string;
|
|
6
|
+
listLabel: string;
|
|
7
|
+
groups: readonly ComboboxGroup[];
|
|
8
|
+
entries: readonly ComboboxEntry[];
|
|
9
|
+
activeId: string | undefined;
|
|
10
|
+
emptyText: string;
|
|
11
|
+
note?: string;
|
|
12
|
+
noteId: string;
|
|
13
|
+
inputRef: RefObject<HTMLInputElement | null>;
|
|
14
|
+
onActivate: (entry: ComboboxEntry) => void;
|
|
15
|
+
onChoose: (entry: ComboboxEntry) => void;
|
|
16
|
+
onDismiss: () => void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The open list beside the input, on the popover layer and at least as
|
|
20
|
+
* wide as the input: a heading per group, each option's label, badge and
|
|
21
|
+
* description, then the action; the empty text when nothing matches, and
|
|
22
|
+
* the note under it all.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }: ComboboxPopupProps): import("react").JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment, useLayoutEffect, useRef, } from 'react';
|
|
4
|
+
import { cx } from '../../class-names.js';
|
|
5
|
+
import { Badge } from '../Badge.js';
|
|
6
|
+
import { useAnchoredPopover } from '../use-anchored-popover.js';
|
|
7
|
+
function keepInputFocus(event) {
|
|
8
|
+
// A press in the list never takes focus from the input.
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
}
|
|
11
|
+
function EntryBadge({ badge }) {
|
|
12
|
+
if (!badge)
|
|
13
|
+
return null;
|
|
14
|
+
return (_jsx(Badge, { size: "sm", tone: badge.tone ?? 'neutral', children: badge.label }));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The open list beside the input, on the popover layer and at least as
|
|
18
|
+
* wide as the input: a heading per group, each option's label, badge and
|
|
19
|
+
* description, then the action; the empty text when nothing matches, and
|
|
20
|
+
* the note under it all.
|
|
21
|
+
*/
|
|
22
|
+
export function ComboboxPopup({ activeId, emptyText, entries, groups, inputRef, listId, listLabel, note, noteId, onActivate, onChoose, onDismiss, }) {
|
|
23
|
+
const popupRef = useRef(null);
|
|
24
|
+
useAnchoredPopover(popupRef, inputRef, inputRef, onDismiss, {
|
|
25
|
+
align: 'start',
|
|
26
|
+
flipInline: true,
|
|
27
|
+
});
|
|
28
|
+
// At least as wide as the input, and kept so when the input resizes.
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
const popup = popupRef.current;
|
|
31
|
+
const input = inputRef.current;
|
|
32
|
+
if (!popup || !input)
|
|
33
|
+
return;
|
|
34
|
+
const fit = () => {
|
|
35
|
+
popup.style.minWidth = `${input.offsetWidth}px`;
|
|
36
|
+
};
|
|
37
|
+
fit();
|
|
38
|
+
if (typeof ResizeObserver === 'undefined')
|
|
39
|
+
return;
|
|
40
|
+
const observer = new ResizeObserver(fit);
|
|
41
|
+
observer.observe(input);
|
|
42
|
+
return () => observer.disconnect();
|
|
43
|
+
}, [inputRef]);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
if (activeId)
|
|
46
|
+
document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
|
|
47
|
+
}, [activeId]);
|
|
48
|
+
const options = entries.filter((entry) => entry.kind === 'option');
|
|
49
|
+
const action = entries.find((entry) => entry.kind === 'action');
|
|
50
|
+
function renderEntry(entry) {
|
|
51
|
+
const disabled = entry.kind === 'option' && entry.option.disabled;
|
|
52
|
+
return (_jsx("div", { "aria-disabled": disabled || undefined, "aria-selected": entry.id === activeId, className: cx('sw-combobox-option', entry.kind === 'action' && 'sw-combobox-action'), id: entry.id, onClick: () => onChoose(entry), onMouseDown: keepInputFocus,
|
|
53
|
+
// The pointer moves the active option, as the arrow keys do.
|
|
54
|
+
onMouseMove: () => {
|
|
55
|
+
if (entry.id !== activeId)
|
|
56
|
+
onActivate(entry);
|
|
57
|
+
}, role: "option", children: entry.kind === 'option' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.option.label }), _jsx(EntryBadge, { badge: entry.option.badge }), entry.option.description ? (_jsx("span", { className: "sw-combobox-description", children: entry.option.description })) : null] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "sw-combobox-label", children: entry.action.label }), _jsx(EntryBadge, { badge: entry.action.badge })] })) }, entry.id));
|
|
58
|
+
}
|
|
59
|
+
return (_jsxs("div", { className: "sw-combobox-popup", onMouseDown: keepInputFocus, ref: popupRef, children: [entries.length > 0 ? (_jsxs("div", { "aria-label": listLabel, className: "sw-combobox-list", id: listId, role: "listbox", children: [groups.map((group, groupIndex) => {
|
|
60
|
+
const groupEntries = options.filter((entry) => entry.kind === 'option' && entry.groupIndex === groupIndex);
|
|
61
|
+
if (groupEntries.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
if (!group.label)
|
|
64
|
+
return (_jsx(Fragment, { children: groupEntries.map(renderEntry) }, `${listId}-group-${groupIndex}`));
|
|
65
|
+
const headingId = `${listId}-group-${groupIndex}`;
|
|
66
|
+
return (_jsxs("div", { "aria-labelledby": headingId, role: "group", children: [_jsx("div", { className: "sw-combobox-heading", id: headingId, role: "presentation", children: group.label }), groupEntries.map(renderEntry)] }, headingId));
|
|
67
|
+
}), action ? renderEntry(action) : null] })) : null, options.length === 0 ? (_jsx("div", { "aria-hidden": "true", className: "sw-combobox-empty", children: emptyText })) : null, note ? (_jsx("div", { className: "sw-combobox-note", id: noteId, children: note })) : null] }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ComboboxAction, type ComboboxGroup, type ComboboxOption } from './types.js';
|
|
2
|
+
/** One row the arrow keys move over: an option in a group, or the action. */
|
|
3
|
+
export type ComboboxEntry = {
|
|
4
|
+
kind: 'option';
|
|
5
|
+
key: string;
|
|
6
|
+
id: string;
|
|
7
|
+
index: number;
|
|
8
|
+
option: ComboboxOption;
|
|
9
|
+
groupIndex: number;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'action';
|
|
12
|
+
key: string;
|
|
13
|
+
id: string;
|
|
14
|
+
index: number;
|
|
15
|
+
action: ComboboxAction;
|
|
16
|
+
};
|
|
17
|
+
/** The list's rows in order, each with a stable key and a DOM id under `listId`. */
|
|
18
|
+
export declare function comboboxEntries(listId: string, groups: readonly ComboboxGroup[], action: ComboboxAction | undefined): ComboboxEntry[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Each row's key and DOM id come from its value, not its place, so an
|
|
3
|
+
* option keeps its id (and stays the active one) when the consumer's
|
|
4
|
+
* results change under it, and a new option never inherits an old id.
|
|
5
|
+
*/
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
/** The value as hex, so any value makes a unique, valid id part. */
|
|
8
|
+
function idPart(value) {
|
|
9
|
+
return Array.from(encoder.encode(value), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
10
|
+
}
|
|
11
|
+
/** The list's rows in order, each with a stable key and a DOM id under `listId`. */
|
|
12
|
+
export function comboboxEntries(listId, groups, action) {
|
|
13
|
+
const entries = [];
|
|
14
|
+
groups.forEach((group, groupIndex) => {
|
|
15
|
+
for (const option of group.options)
|
|
16
|
+
entries.push({
|
|
17
|
+
kind: 'option',
|
|
18
|
+
key: `option:${option.value}`,
|
|
19
|
+
id: `${listId}-option-${idPart(option.value)}`,
|
|
20
|
+
index: entries.length,
|
|
21
|
+
option,
|
|
22
|
+
groupIndex,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
if (action)
|
|
26
|
+
entries.push({
|
|
27
|
+
kind: 'action',
|
|
28
|
+
key: 'action',
|
|
29
|
+
id: `${listId}-action`,
|
|
30
|
+
index: entries.length,
|
|
31
|
+
action,
|
|
32
|
+
});
|
|
33
|
+
return entries;
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type BadgeTone } from '../../css/css-data.js';
|
|
2
|
+
/** A short tag after an option's label, such as "Warning" or "Blocked". */
|
|
3
|
+
export type ComboboxBadge = {
|
|
4
|
+
label: string;
|
|
5
|
+
tone?: BadgeTone;
|
|
6
|
+
};
|
|
7
|
+
/** One suggestion in a Combobox. */
|
|
8
|
+
export type ComboboxOption = {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** A muted line of detail after the label, such as "3 items · updated Sep 28". */
|
|
12
|
+
description?: string;
|
|
13
|
+
badge?: ComboboxBadge;
|
|
14
|
+
/** Shown, read as unavailable, and never chosen, such as an archived item. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** A run of suggestions under a heading, such as "All items". */
|
|
18
|
+
export type ComboboxGroup = {
|
|
19
|
+
/** The group's heading; a group without one has no heading. */
|
|
20
|
+
label?: string;
|
|
21
|
+
options: readonly ComboboxOption[];
|
|
22
|
+
};
|
|
23
|
+
/** A command at the end of the list, such as "+ New entry “acme”". */
|
|
24
|
+
export type ComboboxAction = {
|
|
25
|
+
label: string;
|
|
26
|
+
onPress: () => void;
|
|
27
|
+
badge?: ComboboxBadge;
|
|
28
|
+
};
|
|
29
|
+
export declare const comboboxSizes: readonly ["md", "xs"];
|
|
30
|
+
export type ComboboxSize = (typeof comboboxSizes)[number];
|
|
31
|
+
export type ComboboxProps = {
|
|
32
|
+
/** The input's name, drawn above it unless `labelVisuallyHidden`. */
|
|
33
|
+
label: string;
|
|
34
|
+
labelVisuallyHidden?: boolean;
|
|
35
|
+
/** The text in the input; the consumer filters `groups` from it. */
|
|
36
|
+
inputValue: string;
|
|
37
|
+
onInputChange: (value: string) => void;
|
|
38
|
+
groups: readonly ComboboxGroup[];
|
|
39
|
+
/** Called with the chosen option's value; the consumer sets `inputValue`. */
|
|
40
|
+
onSelect: (value: string) => void;
|
|
41
|
+
action?: ComboboxAction;
|
|
42
|
+
/**
|
|
43
|
+
* Shown in the list, and announced, when no group has an option, such as
|
|
44
|
+
* "Nothing matches".
|
|
45
|
+
*/
|
|
46
|
+
emptyText: string;
|
|
47
|
+
/**
|
|
48
|
+
* The words announced for the number of options while the list is open,
|
|
49
|
+
* such as `(n) => \`${n} items\``; without it only the empty text is
|
|
50
|
+
* announced.
|
|
51
|
+
*/
|
|
52
|
+
countText?: (count: number) => string;
|
|
53
|
+
/** The list's name when it differs from the input's, such as "Results". */
|
|
54
|
+
listLabel?: string;
|
|
55
|
+
/** A muted line under the suggestions, such as how to find more. */
|
|
56
|
+
note?: string;
|
|
57
|
+
placeholder?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
error?: string;
|
|
60
|
+
required?: boolean;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
size?: ComboboxSize;
|
|
63
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const comboboxSizes = ['md', 'xs'];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { badgeTones } from '../../css/css-data.js';
|
|
2
|
+
import { comboboxSizes, } from './types.js';
|
|
3
|
+
function blank(text) {
|
|
4
|
+
return typeof text !== 'string' || !text.trim();
|
|
5
|
+
}
|
|
6
|
+
function assertBadge(badge) {
|
|
7
|
+
if (badge === undefined)
|
|
8
|
+
return;
|
|
9
|
+
if (blank(badge.label))
|
|
10
|
+
throw new RangeError('A Combobox badge needs a nonempty label');
|
|
11
|
+
if (badge.tone !== undefined && !badgeTones.includes(badge.tone))
|
|
12
|
+
throw new RangeError(`Unknown Combobox badge tone: ${String(badge.tone)}`);
|
|
13
|
+
}
|
|
14
|
+
/** Fails closed on blank words, duplicate values and unknown sizes. */
|
|
15
|
+
export function assertCombobox(props) {
|
|
16
|
+
if (blank(props.label))
|
|
17
|
+
throw new RangeError('Combobox needs a nonempty label');
|
|
18
|
+
if (blank(props.emptyText))
|
|
19
|
+
throw new RangeError('Combobox needs a nonempty emptyText');
|
|
20
|
+
if (props.listLabel !== undefined && blank(props.listLabel))
|
|
21
|
+
throw new RangeError('A Combobox listLabel must not be blank');
|
|
22
|
+
if (props.size !== undefined && !comboboxSizes.includes(props.size))
|
|
23
|
+
throw new RangeError(`Unknown Combobox size: ${String(props.size)}`);
|
|
24
|
+
const values = new Set();
|
|
25
|
+
for (const group of props.groups) {
|
|
26
|
+
if (group.label !== undefined && blank(group.label))
|
|
27
|
+
throw new RangeError('A Combobox group label must not be blank');
|
|
28
|
+
for (const option of group.options) {
|
|
29
|
+
if (blank(option.value) || blank(option.label))
|
|
30
|
+
throw new RangeError('Combobox options need nonempty values and labels');
|
|
31
|
+
if (values.has(option.value))
|
|
32
|
+
throw new RangeError(`Duplicate Combobox option: ${option.value}`);
|
|
33
|
+
values.add(option.value);
|
|
34
|
+
assertBadge(option.badge);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (props.action) {
|
|
38
|
+
if (blank(props.action.label))
|
|
39
|
+
throw new RangeError('A Combobox action needs a nonempty label');
|
|
40
|
+
if (typeof props.action.onPress !== 'function')
|
|
41
|
+
throw new TypeError('A Combobox action needs onPress');
|
|
42
|
+
assertBadge(props.action.badge);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
type SearchFrameProps = {
|
|
3
|
+
children: ReactNode;
|
|
4
|
+
leading?: ReactNode;
|
|
5
|
+
clearLabel: string;
|
|
6
|
+
/** Shown while the input holds text and is enabled. */
|
|
7
|
+
showClear: boolean;
|
|
8
|
+
onClear: () => void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The search field's frame: the consumer's glyph at the start (hidden from
|
|
12
|
+
* assistive technology), the input, and a clear button at the end while
|
|
13
|
+
* there is text. Clearing calls `onClear` and puts focus back in the input.
|
|
14
|
+
*/
|
|
15
|
+
export declare function SearchFrame({ children, clearLabel, leading, onClear, showClear, }: SearchFrameProps): import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/*
|
|
3
|
+
* A press on the frame's chrome (the glyph or the padding) focuses the
|
|
4
|
+
* input, as on an adorned field; a press on the input keeps the browser's
|
|
5
|
+
* own caret placement, and the clear button runs its own action.
|
|
6
|
+
*/
|
|
7
|
+
function focusSearchInput(event) {
|
|
8
|
+
const input = event.currentTarget.querySelector('input');
|
|
9
|
+
const target = event.target;
|
|
10
|
+
if (!input || input.disabled || target === input)
|
|
11
|
+
return;
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
// The clear button keeps focus in the input: no blur, and its click
|
|
14
|
+
// still runs.
|
|
15
|
+
if (target.closest('.sw-field-search-clear'))
|
|
16
|
+
return;
|
|
17
|
+
input.focus();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The search field's frame: the consumer's glyph at the start (hidden from
|
|
21
|
+
* assistive technology), the input, and a clear button at the end while
|
|
22
|
+
* there is text. Clearing calls `onClear` and puts focus back in the input.
|
|
23
|
+
*/
|
|
24
|
+
export function SearchFrame({ children, clearLabel, leading, onClear, showClear, }) {
|
|
25
|
+
return (_jsxs("span", { className: "sw-field-adorned sw-field-search", onMouseDown: focusSearchInput, children: [leading != null && leading !== false ? (_jsx("span", { "aria-hidden": "true", className: "sw-field-search-leading", children: leading })) : null, children, showClear ? (_jsx("button", { "aria-label": clearLabel, className: "sw-field-search-clear", onClick: (event) => {
|
|
26
|
+
// Focus first, so a consumer that moves focus in onClear wins.
|
|
27
|
+
event.currentTarget.parentElement
|
|
28
|
+
?.querySelector('input')
|
|
29
|
+
?.focus();
|
|
30
|
+
onClear();
|
|
31
|
+
}, type: "button", children: _jsx("span", { "aria-hidden": "true", className: "sw-field-search-cross" }) })) : null] }));
|
|
32
|
+
}
|
|
@@ -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
|
});
|