@scalewing/react 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +105 -3
  3. package/dist/app-shell.test-support.d.ts +5 -0
  4. package/dist/app-shell.test-support.js +45 -0
  5. package/dist/components/AppShell.d.ts +12 -0
  6. package/dist/components/AppShell.js +48 -0
  7. package/dist/components/Button.d.ts +14 -1
  8. package/dist/components/Button.js +5 -2
  9. package/dist/components/FileField.d.ts +18 -2
  10. package/dist/components/FileField.js +26 -22
  11. package/dist/components/ListGroup.d.ts +8 -3
  12. package/dist/components/ListGroup.js +13 -5
  13. package/dist/components/NavCount.d.ts +12 -5
  14. package/dist/components/NavCount.js +9 -4
  15. package/dist/components/SectionNav.d.ts +2 -1
  16. package/dist/components/SectionNav.js +3 -19
  17. package/dist/components/Select.d.ts +8 -5
  18. package/dist/components/Select.js +7 -4
  19. package/dist/components/app-shell/AppShellDrawer.d.ts +24 -0
  20. package/dist/components/app-shell/AppShellDrawer.js +17 -0
  21. package/dist/components/app-shell/AppShellLink.d.ts +14 -0
  22. package/dist/components/app-shell/AppShellLink.js +27 -0
  23. package/dist/components/app-shell/AppShellNav.d.ts +15 -0
  24. package/dist/components/app-shell/AppShellNav.js +29 -0
  25. package/dist/components/app-shell/AppShellSidebar.d.ts +18 -0
  26. package/dist/components/app-shell/AppShellSidebar.js +18 -0
  27. package/dist/components/app-shell/types.d.ts +87 -0
  28. package/dist/components/app-shell/types.js +1 -0
  29. package/dist/components/app-shell/validate.d.ts +6 -0
  30. package/dist/components/app-shell/validate.js +52 -0
  31. package/dist/components/file-field/ChosenFile.d.ts +45 -0
  32. package/dist/components/file-field/ChosenFile.js +48 -0
  33. package/dist/components/file-field/file-field-checks.d.ts +5 -2
  34. package/dist/components/file-field/file-field-checks.js +7 -2
  35. package/dist/components/nav-items.d.ts +21 -0
  36. package/dist/components/nav-items.js +26 -0
  37. package/dist/components/select/select-naming.d.ts +34 -0
  38. package/dist/components/select/select-naming.js +22 -0
  39. package/dist/css/css-app-shell-drawer.d.ts +2 -0
  40. package/dist/css/css-app-shell-drawer.js +91 -0
  41. package/dist/css/css-app-shell-nav.d.ts +3 -0
  42. package/dist/css/css-app-shell-nav.js +167 -0
  43. package/dist/css/css-app-shell.d.ts +4 -0
  44. package/dist/css/css-app-shell.js +150 -0
  45. package/dist/css/css-button-width.d.ts +11 -0
  46. package/dist/css/css-button-width.js +36 -0
  47. package/dist/css/css-button.js +3 -0
  48. package/dist/css/css-components.js +3 -0
  49. package/dist/css/css-count-pill.d.ts +7 -0
  50. package/dist/css/css-count-pill.js +42 -12
  51. package/dist/css/css-file-field-compact.d.ts +2 -0
  52. package/dist/css/css-file-field-compact.js +106 -0
  53. package/dist/css/css-file-field.js +4 -0
  54. package/dist/css/css-list-tiles.d.ts +6 -0
  55. package/dist/css/css-list-tiles.js +50 -0
  56. package/dist/css/css-list.js +5 -1
  57. package/dist/css/stylesheet.js +2 -0
  58. package/dist/index.d.ts +4 -1
  59. package/dist/index.js +3 -0
  60. package/dist/list-options.d.ts +12 -5
  61. package/dist/list-options.js +19 -5
  62. package/dist/styles.css +443 -7
  63. package/package.json +1 -1
@@ -1,8 +1,12 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
+ import { countToneClass } from '../css/css-count-pill.js';
3
4
  import { isOneOf } from '../is-one-of.js';
4
- /** The pill's colours: `danger` for work waiting, `accent` for news. */
5
- export const navCountTones = ['danger', 'accent'];
5
+ /**
6
+ * The pill's colours: `danger` for work waiting, `accent` for news,
7
+ * `warning` for work that needs the person but is not urgent.
8
+ */
9
+ export const navCountTones = ['danger', 'accent', 'warning'];
6
10
  /**
7
11
  * Fails closed on a count that is not a whole number, an empty label or an
8
12
  * unknown tone.
@@ -21,7 +25,8 @@ function assertNavCount({ count, countLabel, tone }) {
21
25
  * Button: `<a href="/tasks">Tasks<NavCount count={8} /></a>`. It is the
22
26
  * pill SectionNav's item count draws, and it is part of the destination's
23
27
  * accessible name, as there. `tone="accent"` draws it in the accent colour,
24
- * for news rather than work.
28
+ * for news rather than work; `tone="warning"` in amber, for work that needs
29
+ * the person but is not urgent.
25
30
  */
26
31
  export function NavCount({ count, countLabel, tone = 'danger', }) {
27
32
  assertNavCount({ count, countLabel, tone });
@@ -29,5 +34,5 @@ export function NavCount({ count, countLabel, tone = 'danger', }) {
29
34
  return null;
30
35
  // The space is its own text node, so every browser and the name's
31
36
  // computation keep it between the words and the count.
32
- return (_jsxs(_Fragment, { children: [' ', countLabel ? _jsx("span", { className: "sw-sr-only", children: countLabel }) : null, _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-nav-count', tone === 'accent' && 'sw-nav-count-accent'), children: count })] }));
37
+ return (_jsxs(_Fragment, { children: [' ', countLabel ? _jsx("span", { className: "sw-sr-only", children: countLabel }) : null, _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-nav-count', tone !== 'danger' && countToneClass('sw-nav-count', tone)), children: count })] }));
33
38
  }
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import { type AccessibleName } from '../accessible-name.js';
3
3
  import { type Breakpoint } from '../css/breakpoints.js';
4
4
  import { type SectionNavAppearance } from '../css/css-section-nav-filled.js';
5
+ import { type NavItemCurrent } from './nav-items.js';
5
6
  export type { SectionNavAppearance };
6
7
  export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
7
8
  export type SectionNavItem = {
@@ -12,7 +13,7 @@ export type SectionNavItem = {
12
13
  * `page` when the item is this page, `location` (or `true`) when the page
13
14
  * is inside the item's section, such as a detail page under its list.
14
15
  */
15
- current?: 'page' | 'location' | boolean;
16
+ current?: NavItemCurrent;
16
17
  /** A decorative glyph before the label. */
17
18
  icon?: ReactNode;
18
19
  /**
@@ -5,24 +5,8 @@ import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
5
  import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
6
6
  import { tabStop } from '../tab-stop.js';
7
7
  import { plainPress } from './link-press.js';
8
+ import { assertNavItems, navItemAriaCurrent, } from './nav-items.js';
8
9
  export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
9
- function ariaCurrent(current) {
10
- if (current === true)
11
- return 'true';
12
- return current || undefined;
13
- }
14
- function assertItems(items) {
15
- if (items.some((item) => !item.id.trim() || !item.label.trim()))
16
- throw new RangeError('SectionNav items need nonempty ids and labels');
17
- if (new Set(items.map((item) => item.id)).size !== items.length)
18
- throw new RangeError('SectionNav items need unique ids');
19
- if (items.some((item) => item.count !== undefined &&
20
- (!Number.isInteger(item.count) || item.count < 0)))
21
- throw new RangeError('SectionNav item counts are whole numbers from 0');
22
- if (items.some((item) => item.countLabel !== undefined &&
23
- (!item.countLabel.trim() || item.count === undefined)))
24
- throw new RangeError('SectionNav item countLabel is nonempty and comes with a count');
25
- }
26
10
  function assertBreakpoint(verticalFrom) {
27
11
  if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
28
12
  throw new RangeError(`SectionNav verticalFrom must be one of ${breakpoints.join(', ')}`);
@@ -34,10 +18,10 @@ function assertBreakpoint(verticalFrom) {
34
18
  * a side list. The current item carries `aria-current`.
35
19
  */
36
20
  export function SectionNav({ appearance = 'underline', items, onNavigate, verticalFrom, ...label }) {
37
- assertItems(items);
21
+ assertNavItems('SectionNav', items);
38
22
  assertBreakpoint(verticalFrom);
39
23
  assertSectionNavAppearance(appearance);
40
- 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, tabIndex: tabStop,
24
+ 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": navItemAriaCurrent(item.current), className: "sw-section-nav-link", href: item.href, tabIndex: tabStop,
41
25
  // Only a given onNavigate attaches a handler, so the links
42
26
  // render from a server component without one.
43
27
  onClick: onNavigate
@@ -1,6 +1,7 @@
1
1
  import { type SelectOption } from '../select-list.js';
2
2
  import { type FieldSize } from './Field.js';
3
3
  import { type RequiredMark } from './FieldLabelText.js';
4
+ import { type SelectNaming } from './select/select-naming.js';
4
5
  export type { SelectOption };
5
6
  /**
6
7
  * `content` (the default) sizes the field to its longest option, as a native
@@ -12,10 +13,9 @@ export type SelectAction = {
12
13
  label: string;
13
14
  onPress: () => void;
14
15
  };
15
- export type SelectProps = {
16
+ export type { SelectNaming };
17
+ export type SelectProps = SelectNaming & {
16
18
  action?: SelectAction;
17
- label: string;
18
- labelVisuallyHidden?: boolean;
19
19
  onChange: (value: string) => void;
20
20
  options: readonly SelectOption[];
21
21
  size?: FieldSize;
@@ -25,7 +25,10 @@ export type SelectProps = {
25
25
  * (such as ''). It is not an option and never becomes the value.
26
26
  */
27
27
  placeholder?: string;
28
- /** Marks the label as Field does and sets `aria-required`. */
28
+ /**
29
+ * Marks the label as Field does and sets `aria-required` (named by
30
+ * `aria-labelledby`, only `aria-required`: the page draws the words).
31
+ */
29
32
  required?: boolean;
30
33
  /**
31
34
  * `hidden` leaves out the label's " *" while the control stays required
@@ -43,4 +46,4 @@ export type SelectProps = {
43
46
  */
44
47
  width?: SelectWidth;
45
48
  };
46
- export declare function Select({ action, label, labelVisuallyHidden, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
49
+ export declare function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
@@ -5,16 +5,19 @@ import { cx } from '../class-names.js';
5
5
  import { selectedSelectIndex } from '../select-list.js';
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
7
  import { assertRequiredMark, FieldLabelText, } from './FieldLabelText.js';
8
+ import { assertSelectNaming, } from './select/select-naming.js';
8
9
  import { SelectListbox } from './select/SelectListbox.js';
9
10
  import { SelectValue } from './select/SelectValue.js';
10
11
  import { useSelectList } from './select/use-select-list.js';
11
12
  import { Stack } from './Stack.js';
12
13
  import { tabStop } from '../tab-stop.js';
13
- export function Select({ action, label, labelVisuallyHidden = false, onChange, options, size = 'md', value, placeholder, required = false, requiredMark = 'visible', error, width = 'content', }) {
14
- assertRequiredMark(requiredMark);
14
+ export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size = 'md', value, placeholder, required = false, requiredMark, error, width = 'content', }) {
15
+ assertSelectNaming({ label, labelVisuallyHidden, labelledBy, requiredMark });
16
+ assertRequiredMark(requiredMark ?? 'visible');
15
17
  if (placeholder !== undefined && !placeholder.trim())
16
18
  throw new RangeError('placeholder must not be empty');
17
- const labelId = useId();
19
+ const ownLabelId = useId();
20
+ const labelId = labelledBy ?? ownLabelId;
18
21
  const triggerId = useId();
19
22
  const listId = useId();
20
23
  const optionIdPrefix = useId();
@@ -24,7 +27,7 @@ export function Select({ action, label, labelVisuallyHidden = false, onChange, o
24
27
  const showsPlaceholder = placeholder !== undefined &&
25
28
  !options.some((option) => option.value === value);
26
29
  const { commit, highlight, onTriggerKeyDown, open, openList, setHighlight, setOpen, } = useSelectList({ action, onChange, options, rootRef, value });
27
- return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: labelVisuallyHidden ? 0 : 1, children: [_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark, size: size, visuallyHidden: labelVisuallyHidden }) }), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
30
+ return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: label === undefined || labelVisuallyHidden ? 0 : 1, children: [label === undefined ? null : (_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark ?? 'visible', size: size, visuallyHidden: labelVisuallyHidden ?? false }) })), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
28
31
  const next = event.relatedTarget;
29
32
  if (next instanceof Node && rootRef.current?.contains(next)) {
30
33
  return;
@@ -0,0 +1,24 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
3
+ export type AppShellDrawerProps = {
4
+ open: boolean;
5
+ /** Asked on every close request: Escape, a press outside, the close button. */
6
+ onClose: () => void;
7
+ /** Asked after a plain press on a destination, which also closes it. */
8
+ onChoose: () => void;
9
+ closeLabel: string;
10
+ navLabel: string;
11
+ groups: readonly AppShellNavGroup[];
12
+ onNavigate?: (item: AppShellNavItem) => void;
13
+ header?: ReactNode;
14
+ footer?: ReactNode;
15
+ };
16
+ /**
17
+ * The menu below `lg`: a modal `dialog` on the top layer, full height at
18
+ * the inline start, named by the navigation's label. Dialog's close-request
19
+ * mechanics make Escape, a backdrop press and a platform close request one
20
+ * `onClose`, after which focus returns to the menu button. Its close button
21
+ * sits where the menu button was, so it is the first control. The content
22
+ * is drawn only while open, so the page holds one navigation at a time.
23
+ */
24
+ export declare function AppShellDrawer({ closeLabel, footer, groups, header, navLabel, onChoose, onClose, onNavigate, open, }: AppShellDrawerProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button } from '../Button.js';
4
+ import { useDialogCloseRequests } from '../dialog-close-requests.js';
5
+ import { AppShellNav } from './AppShellNav.js';
6
+ /**
7
+ * The menu below `lg`: a modal `dialog` on the top layer, full height at
8
+ * the inline start, named by the navigation's label. Dialog's close-request
9
+ * mechanics make Escape, a backdrop press and a platform close request one
10
+ * `onClose`, after which focus returns to the menu button. Its close button
11
+ * sits where the menu button was, so it is the first control. The content
12
+ * is drawn only while open, so the page holds one navigation at a time.
13
+ */
14
+ export function AppShellDrawer({ closeLabel, footer, groups, header, navLabel, onChoose, onClose, onNavigate, open, }) {
15
+ const { nodeRef, handlers } = useDialogCloseRequests({ open, onClose });
16
+ return (_jsx("dialog", { ref: nodeRef, "aria-label": navLabel, className: "sw-app-shell-drawer", ...handlers, children: open ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sw-app-shell-drawer-top", children: [_jsxs(Button, { className: "sw-app-shell-close", onPress: onClose, size: "sm", variant: "ghost", children: [_jsx("span", { className: "sw-sr-only", children: closeLabel }), _jsx("span", { "aria-hidden": "true", className: "sw-dialog-close-glyph" })] }), header ? (_jsx("div", { className: "sw-app-shell-header", children: header })) : null] }), _jsx(AppShellNav, { groups: groups, navLabel: navLabel, onChoose: onChoose, onNavigate: onNavigate }), footer ? _jsx("div", { className: "sw-app-shell-footer", children: footer }) : null] })) : null }));
17
+ }
@@ -0,0 +1,14 @@
1
+ import { type MouseEvent } from 'react';
2
+ import { type AppShellNavItem } from './types.js';
3
+ export type AppShellLinkProps = {
4
+ item: AppShellNavItem;
5
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
6
+ /**
7
+ * The sidebar's links sit in a Tooltip with their label, shown only while
8
+ * `tooltip` is true (the rail, where the label is hidden). Left out (the
9
+ * drawer) there is no Tooltip at all.
10
+ */
11
+ tooltip?: boolean;
12
+ };
13
+ /** One destination in its list item: the glyph, the label and the count. */
14
+ export declare function AppShellLink({ item, onClick, tooltip }: AppShellLinkProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
3
+ import { countToneClass } from '../../css/css-count-pill.js';
4
+ import { tabStop } from '../../tab-stop.js';
5
+ import { navItemAriaCurrent } from '../nav-items.js';
6
+ import { Tooltip } from '../Tooltip.js';
7
+ /*
8
+ * The destination's link. A Tooltip around it passes its references in;
9
+ * only `aria-labelledby` is kept. Its `aria-describedby` is left out on
10
+ * purpose: the tooltip's words are the label the link is already named by,
11
+ * so they would be read twice.
12
+ */
13
+ function AppShellAnchor({ 'aria-labelledby': labelledBy, children, item, onClick, }) {
14
+ return (_jsx("a", { "aria-current": navItemAriaCurrent(item.current), "aria-labelledby": labelledBy, className: "sw-app-shell-link", href: item.href, onClick: onClick, tabIndex: tabStop, children: children }));
15
+ }
16
+ /** The count after the label, read in the link's name as SectionNav's is. */
17
+ function AppShellCount({ count, countLabel, countTone = 'danger', }) {
18
+ if (!count)
19
+ return null;
20
+ return (_jsxs(_Fragment, { children: [countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${countLabel}` })) : (' '), _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-app-shell-count', countTone !== 'danger' &&
21
+ countToneClass('sw-app-shell-count', countTone)), children: count })] }));
22
+ }
23
+ /** One destination in its list item: the glyph, the label and the count. */
24
+ export function AppShellLink({ item, onClick, tooltip }) {
25
+ const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "sw-app-shell-icon", children: item.icon }), _jsx("span", { className: "sw-app-shell-label", children: item.label }), _jsx(AppShellCount, { count: item.count, countLabel: item.countLabel, countTone: item.countTone })] }));
26
+ return (_jsx("li", { className: "sw-app-shell-item", children: tooltip === undefined ? (_jsx(AppShellAnchor, { item: item, onClick: onClick, children: content })) : (_jsx(Tooltip, { content: item.label, disabled: !tooltip, trigger: _jsx(AppShellAnchor, { item: item, onClick: onClick, children: content }) })) }));
27
+ }
@@ -0,0 +1,15 @@
1
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
2
+ export type AppShellNavProps = {
3
+ navLabel: string;
4
+ groups: readonly AppShellNavGroup[];
5
+ onNavigate?: (item: AppShellNavItem) => void;
6
+ /** The drawer's: a plain press on a destination also closes it. */
7
+ onChoose?: () => void;
8
+ /** The sidebar's: whether the rail's tooltips show (see AppShellLink). */
9
+ tooltip?: boolean;
10
+ };
11
+ /**
12
+ * The shell's navigation landmark: each group a list named by its label,
13
+ * the same in the sidebar and the drawer.
14
+ */
15
+ export declare function AppShellNav({ groups, navLabel, onChoose, onNavigate, tooltip, }: AppShellNavProps): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { plainPress } from '../link-press.js';
4
+ import { AppShellLink } from './AppShellLink.js';
5
+ /**
6
+ * The shell's navigation landmark: each group a list named by its label,
7
+ * the same in the sidebar and the drawer.
8
+ */
9
+ export function AppShellNav({ groups, navLabel, onChoose, onNavigate, tooltip, }) {
10
+ const idPrefix = useId();
11
+ // Only a given onNavigate or onChoose attaches a handler.
12
+ function pressHandler(item) {
13
+ if (!onNavigate && !onChoose)
14
+ return undefined;
15
+ return (event) => {
16
+ if (!plainPress(event))
17
+ return;
18
+ if (onNavigate) {
19
+ event.preventDefault();
20
+ onNavigate(item);
21
+ }
22
+ onChoose?.();
23
+ };
24
+ }
25
+ return (_jsx("nav", { "aria-label": navLabel, className: "sw-app-shell-nav", children: groups.map((group, index) => {
26
+ const labelId = `${idPrefix}-group-${index}`;
27
+ return (_jsxs("div", { className: "sw-app-shell-group", children: [group.label ? (_jsx("p", { className: "sw-app-shell-group-label", id: labelId, children: group.label })) : null, _jsx("ul", { "aria-labelledby": group.label ? labelId : undefined, className: "sw-app-shell-list", children: group.items.map((item) => (_jsx(AppShellLink, { item: item, onClick: pressHandler(item), tooltip: tooltip }, item.id))) })] }, group.id));
28
+ }) }));
29
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
3
+ export type AppShellSidebarProps = {
4
+ navLabel: string;
5
+ groups: readonly AppShellNavGroup[];
6
+ onNavigate?: (item: AppShellNavItem) => void;
7
+ header?: ReactNode;
8
+ footer?: ReactNode;
9
+ railHeader?: ReactNode;
10
+ railFooter?: ReactNode;
11
+ /** Whether the window is in the rail's range, where tooltips show. */
12
+ rail: boolean;
13
+ };
14
+ /**
15
+ * The pinned column from `lg` up: a rail to 75rem, the full sidebar from
16
+ * there. Hidden below `lg`, where the drawer holds the same parts.
17
+ */
18
+ export declare function AppShellSidebar({ footer, groups, header, navLabel, onNavigate, rail, railFooter, railHeader, }: AppShellSidebarProps): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AppShellNav } from './AppShellNav.js';
3
+ /*
4
+ * A slot drawn in the full sidebar (`sw-app-shell-slot`) or only in the
5
+ * rail (`sw-app-shell-rail-slot`); the generated media queries pick one.
6
+ */
7
+ function Slot({ children, part, rail, }) {
8
+ if (!children)
9
+ return null;
10
+ return (_jsx("div", { className: `sw-app-shell-${part} ${rail ? 'sw-app-shell-rail-slot' : 'sw-app-shell-slot'}`, children: children }));
11
+ }
12
+ /**
13
+ * The pinned column from `lg` up: a rail to 75rem, the full sidebar from
14
+ * there. Hidden below `lg`, where the drawer holds the same parts.
15
+ */
16
+ export function AppShellSidebar({ footer, groups, header, navLabel, onNavigate, rail, railFooter, railHeader, }) {
17
+ return (_jsxs("div", { className: "sw-app-shell-sidebar", children: [_jsx(Slot, { part: "header", rail: false, children: header }), _jsx(Slot, { part: "header", rail: true, children: railHeader }), _jsx(AppShellNav, { groups: groups, navLabel: navLabel, onNavigate: onNavigate, tooltip: rail }), _jsx(Slot, { part: "footer", rail: false, children: footer }), _jsx(Slot, { part: "footer", rail: true, children: railFooter })] }));
18
+ }
@@ -0,0 +1,87 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type NavCountTone } from '../NavCount.js';
3
+ import { type NavItemCurrent } from '../nav-items.js';
4
+ export type AppShellNavItem = {
5
+ id: string;
6
+ label: string;
7
+ href: string;
8
+ /**
9
+ * The destination's glyph (a consumer Lucide icon or mark), hidden from
10
+ * assistive technology. Required: in the rail it is all the item shows.
11
+ */
12
+ icon: ReactNode;
13
+ /**
14
+ * How many things wait there, such as work to do: the shared count pill
15
+ * (danger) after the label, or on the glyph in the rail, read in the
16
+ * link's name. A whole number; 0 (or none) shows no pill.
17
+ */
18
+ count?: number;
19
+ /**
20
+ * What the count means, in the consumer's words ("3 to confirm"): read in
21
+ * the name in place of the bare number, the pill then hidden from
22
+ * assistive technology. Needs `count`.
23
+ */
24
+ countLabel?: string;
25
+ /**
26
+ * The pill's colour, as NavCount's `tone`: `danger` (the default) for
27
+ * work waiting, `warning` (amber) for work that needs the person but is
28
+ * not urgent, `accent` for news. Needs `count`; say what it is in a
29
+ * `countLabel`, never by its colour alone.
30
+ */
31
+ countTone?: NavCountTone;
32
+ /**
33
+ * `page` when the item is this page, `location` (or `true`) when the page
34
+ * is inside the item's area, such as a detail page under its list.
35
+ */
36
+ current?: NavItemCurrent;
37
+ };
38
+ export type AppShellNavGroup = {
39
+ id: string;
40
+ /**
41
+ * A small muted caption over the group's destinations that names their
42
+ * list. In the rail it is hidden (still naming the list) and the group
43
+ * starts under a hairline.
44
+ */
45
+ label?: string;
46
+ items: readonly AppShellNavItem[];
47
+ };
48
+ /** The shell's own words: the menu button's name and the drawer's close. */
49
+ export type AppShellLabels = {
50
+ openMenu: string;
51
+ closeMenu: string;
52
+ };
53
+ export type AppShellProps = {
54
+ /** The navigation landmark's name, also the drawer's. */
55
+ navLabel: string;
56
+ groups: readonly AppShellNavGroup[];
57
+ /**
58
+ * Called for a plain press on a destination (the main button, no
59
+ * modifier key), after the browser's own navigation is prevented, so a
60
+ * client router moves instead. A press with a modifier key, or a middle
61
+ * press, keeps the browser's behaviour. Without it the links navigate.
62
+ */
63
+ onNavigate?: (item: AppShellNavItem) => void;
64
+ /**
65
+ * The sidebar's top, such as a workspace switcher; also the drawer's.
66
+ * While the drawer is open it is mounted twice (the sidebar's copy is
67
+ * hidden), so each copy keeps its own state: put no fixed DOM `id` in it
68
+ * (use `useId`), and keep state that must be shared in the consumer.
69
+ * The same holds for `footer`, `railHeader` and `railFooter`.
70
+ */
71
+ header?: ReactNode;
72
+ /** The sidebar's bottom, such as the account menu; also the drawer's (see `header`). */
73
+ footer?: ReactNode;
74
+ /** A compact `header` for the rail. Left out, the rail shows none. */
75
+ railHeader?: ReactNode;
76
+ /** A compact `footer` for the rail. Left out, the rail shows none. */
77
+ railFooter?: ReactNode;
78
+ /** The compact top bar's title, below `lg`. */
79
+ barTitle: ReactNode;
80
+ /** Controls at the top bar's end, below `lg`. */
81
+ barEnd?: ReactNode;
82
+ /** The consumer's glyph for the menu button (Scalewing has no icon catalog). */
83
+ menuIcon: ReactNode;
84
+ labels: AppShellLabels;
85
+ /** The page, rendered in `main`. */
86
+ children: ReactNode;
87
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import { type AppShellProps } from './types.js';
2
+ /**
3
+ * Fails closed on the shell's words, its groups and their items (ids unique
4
+ * across every group, each with an icon, which is all the rail shows).
5
+ */
6
+ export declare function assertAppShellProps({ groups, labels, navLabel, }: Pick<AppShellProps, 'groups' | 'labels' | 'navLabel'>): void;
@@ -0,0 +1,52 @@
1
+ import { isOneOf } from '../../is-one-of.js';
2
+ import { navCountTones } from '../NavCount.js';
3
+ import { assertNavItems } from '../nav-items.js';
4
+ /**
5
+ * An icon that is no glyph: nothing, a boolean or an empty string (which
6
+ * React draws as nothing), or 0 (which it draws as the text "0").
7
+ */
8
+ function drawsNothing(node) {
9
+ return (node === undefined ||
10
+ node === null ||
11
+ typeof node === 'boolean' ||
12
+ node === '' ||
13
+ node === 0);
14
+ }
15
+ function isBlank(words) {
16
+ return typeof words !== 'string' || !words.trim();
17
+ }
18
+ function assertGroups(groups) {
19
+ if (groups.length === 0)
20
+ throw new RangeError('AppShell needs at least one group');
21
+ if (groups.some((group) => isBlank(group.id)))
22
+ throw new RangeError('AppShell groups need nonempty ids');
23
+ if (new Set(groups.map((group) => group.id)).size !== groups.length)
24
+ throw new RangeError('AppShell groups need unique ids');
25
+ if (groups.some((group) => group.label !== undefined && isBlank(group.label)))
26
+ throw new RangeError('AppShell group labels are nonempty when given');
27
+ if (groups.some((group) => group.items.length === 0))
28
+ throw new RangeError('AppShell groups need at least one item');
29
+ }
30
+ /**
31
+ * Fails closed on the shell's words, its groups and their items (ids unique
32
+ * across every group, each with an icon, which is all the rail shows).
33
+ */
34
+ export function assertAppShellProps({ groups, labels, navLabel, }) {
35
+ if (isBlank(navLabel))
36
+ throw new RangeError('AppShell navLabel must be nonempty');
37
+ if (isBlank(labels?.openMenu) || isBlank(labels?.closeMenu))
38
+ throw new RangeError('AppShell labels openMenu and closeMenu are nonempty');
39
+ assertGroups(groups);
40
+ const items = groups.flatMap((group) => group.items);
41
+ assertNavItems('AppShell', items);
42
+ if (items.some((item) => drawsNothing(item.icon)))
43
+ throw new RangeError('AppShell items need an icon, all the rail shows');
44
+ for (const item of items) {
45
+ if (item.countTone === undefined)
46
+ continue;
47
+ if (!isOneOf(navCountTones, item.countTone))
48
+ throw new RangeError(`AppShell item countTone must be one of ${navCountTones.join(', ')}`);
49
+ if (item.count === undefined)
50
+ throw new RangeError('AppShell item countTone comes with a count');
51
+ }
52
+ }
@@ -0,0 +1,45 @@
1
+ import { type ReactNode, type Ref } from 'react';
2
+ import type { FileFieldValue } from '../FileField.js';
3
+ /** The ids a chosen file's parts take, from the field's. */
4
+ export type ChosenFileIds = {
5
+ label: string;
6
+ button: string;
7
+ name: string;
8
+ detail: string;
9
+ remove: string;
10
+ };
11
+ /** The change button's naming and description, as the field wires them. */
12
+ export type ChosenFileNaming = {
13
+ 'aria-labelledby': string;
14
+ 'aria-describedby': string | undefined;
15
+ id: string;
16
+ };
17
+ /** Remove beside a chosen file: its words and what it does. */
18
+ export type ChosenFileRemove = {
19
+ label: string;
20
+ onRemove: () => void;
21
+ };
22
+ type ChosenFileProps = {
23
+ value: FileFieldValue;
24
+ icon: ReactNode;
25
+ ids: ChosenFileIds;
26
+ naming: ChosenFileNaming;
27
+ changeLabel: string;
28
+ remove: ChosenFileRemove | null;
29
+ disabled: boolean;
30
+ onChange: () => void;
31
+ buttonRef: Ref<HTMLButtonElement>;
32
+ };
33
+ /**
34
+ * The chosen file in the full field: its mark, its name over its detail,
35
+ * then the change button (and Remove after it) at the row's end.
36
+ */
37
+ export declare function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
38
+ /**
39
+ * The chosen file in a compact field: one row at a text field's height.
40
+ * The mark and the name (cut to one line) are the change button, named by
41
+ * the field's label and the change words, described by the name and the
42
+ * detail (which is not drawn); Remove, quiet, ends the row.
43
+ */
44
+ export declare function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
45
+ export {};
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { tabStop } from '../../tab-stop.js';
4
+ import { Button } from '../Button.js';
5
+ /** Whether the consumer passed a mark to draw (not empty JSX). */
6
+ function hasMark(icon) {
7
+ return (icon !== undefined &&
8
+ icon !== null &&
9
+ icon !== '' &&
10
+ typeof icon !== 'boolean');
11
+ }
12
+ /** Before a chosen file's name: its photo's thumbnail, else the consumer's mark. */
13
+ function ChosenMark({ icon, preview }) {
14
+ if (preview !== undefined)
15
+ return _jsx("img", { alt: "", className: "sw-file-field-preview", src: preview });
16
+ if (!hasMark(icon))
17
+ return null;
18
+ return (_jsx("span", { "aria-hidden": "true", className: "sw-file-field-icon", children: icon }));
19
+ }
20
+ /**
21
+ * Remove: a quiet button named by its words and the field's label
22
+ * ("Remove Cover photo"), so two fields' Removes are told apart. Disabled,
23
+ * the neutral one greys as the change button does; the ghost one, which
24
+ * has no fill, fades as the compact row's pick button does.
25
+ */
26
+ function RemoveButton({ ids, remove, disabled, variant, }) {
27
+ return (_jsx(Button
28
+ // Named by its own words, then the label: the self-reference is
29
+ // deliberate and needs the id below on the button itself.
30
+ , { "aria-labelledby": `${ids.remove} ${ids.label}`, className: "sw-file-field-remove", disabled: disabled, disabledAppearance: variant === 'neutral' ? 'neutral' : 'faded', id: ids.remove, onPress: remove.onRemove, size: variant === 'ghost' ? 'sm' : 'md', variant: variant, children: remove.label }));
31
+ }
32
+ /**
33
+ * The chosen file in the full field: its mark, its name over its detail,
34
+ * then the change button (and Remove after it) at the row's end.
35
+ */
36
+ export function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
37
+ const change = (_jsx(Button, { ...naming, disabled: disabled, disabledAppearance: "neutral", onPress: onChange, ref: buttonRef, variant: "neutral", children: changeLabel }));
38
+ return (_jsxs(_Fragment, { children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsxs("span", { className: "sw-file-field-file", children: [_jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-file-field-detail", id: ids.detail, children: value.detail })) : null] }), remove === null ? (change) : (_jsxs("span", { className: "sw-file-field-actions", children: [change, _jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "neutral" })] }))] }));
39
+ }
40
+ /**
41
+ * The chosen file in a compact field: one row at a text field's height.
42
+ * The mark and the name (cut to one line) are the change button, named by
43
+ * the field's label and the change words, described by the name and the
44
+ * detail (which is not drawn); Remove, quiet, ends the row.
45
+ */
46
+ export function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
47
+ return (_jsxs(_Fragment, { children: [_jsxs("button", { ...naming, className: "sw-file-field-pick", disabled: disabled, onClick: onChange, ref: buttonRef, tabIndex: tabStop, type: "button", children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-sr-only", id: ids.detail, children: value.detail })) : null, _jsx("span", { className: "sw-sr-only", id: `${ids.button}-words`, children: changeLabel })] }), remove === null ? null : (_jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "ghost" }))] }));
48
+ }
@@ -1,8 +1,9 @@
1
1
  import type { FileFieldValue } from '../FileField.js';
2
2
  /**
3
3
  * Fails closed on a FileField it cannot name or show: an empty label,
4
- * choose or change words, a blank drop hint, or a chosen file with no name,
5
- * a blank detail or a blank preview address.
4
+ * choose or change words, a blank drop hint, `onRemove` without its
5
+ * words (or words without it), blank Remove words, or a chosen file with
6
+ * no name, a blank detail or a blank preview address.
6
7
  */
7
8
  export declare function assertFileField(props: {
8
9
  label: string;
@@ -10,4 +11,6 @@ export declare function assertFileField(props: {
10
11
  changeLabel: string;
11
12
  dropLabel: string | undefined;
12
13
  value: FileFieldValue | null;
14
+ onRemove: (() => void) | undefined;
15
+ removeLabel: string | undefined;
13
16
  }): void;
@@ -3,8 +3,9 @@ function isBlank(text) {
3
3
  }
4
4
  /**
5
5
  * Fails closed on a FileField it cannot name or show: an empty label,
6
- * choose or change words, a blank drop hint, or a chosen file with no name,
7
- * a blank detail or a blank preview address.
6
+ * choose or change words, a blank drop hint, `onRemove` without its
7
+ * words (or words without it), blank Remove words, or a chosen file with
8
+ * no name, a blank detail or a blank preview address.
8
9
  */
9
10
  export function assertFileField(props) {
10
11
  for (const key of ['label', 'chooseLabel', 'changeLabel'])
@@ -12,6 +13,10 @@ export function assertFileField(props) {
12
13
  throw new RangeError(`FileField ${key} must be non-empty text`);
13
14
  if (props.dropLabel !== undefined && isBlank(props.dropLabel))
14
15
  throw new RangeError('FileField dropLabel must be non-empty text');
16
+ if ((props.onRemove === undefined) !== (props.removeLabel === undefined))
17
+ throw new RangeError('FileField onRemove and removeLabel go together');
18
+ if (props.removeLabel !== undefined && isBlank(props.removeLabel))
19
+ throw new RangeError('FileField removeLabel must be non-empty text');
15
20
  if (props.value === null)
16
21
  return;
17
22
  if (isBlank(props.value.name))