@scalewing/react 1.8.0 → 1.9.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 (77) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +69 -2
  3. package/dist/anchored-position.d.ts +28 -0
  4. package/dist/anchored-position.js +17 -0
  5. package/dist/calendar-keys.d.ts +19 -0
  6. package/dist/calendar-keys.js +39 -0
  7. package/dist/calendar-labels.d.ts +12 -0
  8. package/dist/calendar-labels.js +32 -0
  9. package/dist/calendar-month.d.ts +36 -0
  10. package/dist/calendar-month.js +111 -0
  11. package/dist/calendar-years.d.ts +14 -0
  12. package/dist/calendar-years.js +20 -0
  13. package/dist/components/Accordion.d.ts +6 -1
  14. package/dist/components/Accordion.js +12 -3
  15. package/dist/components/ActionBar.d.ts +30 -0
  16. package/dist/components/ActionBar.js +13 -0
  17. package/dist/components/Box.d.ts +7 -3
  18. package/dist/components/Box.js +2 -2
  19. package/dist/components/DateField.d.ts +15 -1
  20. package/dist/components/DateField.js +58 -18
  21. package/dist/components/Field.d.ts +5 -1
  22. package/dist/components/Field.js +16 -4
  23. package/dist/components/FieldAdornment.d.ts +20 -0
  24. package/dist/components/FieldAdornment.js +34 -0
  25. package/dist/components/Grid.d.ts +4 -1
  26. package/dist/components/Grid.js +2 -2
  27. package/dist/components/Progress.d.ts +3 -1
  28. package/dist/components/Progress.js +2 -2
  29. package/dist/components/date-field/CalendarDialog.d.ts +26 -0
  30. package/dist/components/date-field/CalendarDialog.js +93 -0
  31. package/dist/components/date-field/CalendarGrid.d.ts +23 -0
  32. package/dist/components/date-field/CalendarGrid.js +36 -0
  33. package/dist/components/date-field/CalendarHeader.d.ts +18 -0
  34. package/dist/components/date-field/CalendarHeader.js +33 -0
  35. package/dist/components/date-field/DateFieldGlyph.d.ts +9 -0
  36. package/dist/components/date-field/DateFieldGlyph.js +15 -0
  37. package/dist/components/date-field/use-anchored-popover.d.ts +8 -0
  38. package/dist/components/date-field/use-anchored-popover.js +61 -0
  39. package/dist/components/date-field/use-date-entry.d.ts +20 -0
  40. package/dist/components/date-field/use-date-entry.js +61 -0
  41. package/dist/components/date-field/use-lang-locale.d.ts +7 -0
  42. package/dist/components/date-field/use-lang-locale.js +17 -0
  43. package/dist/css/css-accordion.js +55 -8
  44. package/dist/css/css-action-bar.d.ts +4 -0
  45. package/dist/css/css-action-bar.js +99 -0
  46. package/dist/css/css-action-menu.js +2 -1
  47. package/dist/css/css-box-border.d.ts +5 -0
  48. package/dist/css/css-box-border.js +24 -0
  49. package/dist/css/css-chrome.js +2 -1
  50. package/dist/css/css-components.js +10 -6
  51. package/dist/css/css-data.js +2 -1
  52. package/dist/css/css-date-field.js +241 -11
  53. package/dist/css/css-denomination-grid.js +2 -1
  54. package/dist/css/css-document.d.ts +2 -0
  55. package/dist/css/css-document.js +2 -1
  56. package/dist/css/css-field.js +61 -1
  57. package/dist/css/css-select.js +2 -1
  58. package/dist/css/css-tooltip.js +2 -1
  59. package/dist/css/stacking.d.ts +10 -0
  60. package/dist/css/stacking.js +47 -0
  61. package/dist/css/stylesheet.js +4 -0
  62. package/dist/date-entry-validity.d.ts +10 -0
  63. package/dist/date-entry-validity.js +16 -0
  64. package/dist/date-entry.d.ts +32 -0
  65. package/dist/date-entry.js +106 -0
  66. package/dist/date-field-labels.d.ts +38 -0
  67. package/dist/date-field-labels.js +22 -0
  68. package/dist/date-field-locale.d.ts +8 -0
  69. package/dist/date-field-locale.js +27 -0
  70. package/dist/date-formatters.d.ts +13 -0
  71. package/dist/date-formatters.js +28 -0
  72. package/dist/date-only.d.ts +23 -0
  73. package/dist/date-only.js +63 -9
  74. package/dist/index.d.ts +5 -3
  75. package/dist/index.js +3 -2
  76. package/dist/styles.css +388 -21
  77. package/package.json +1 -1
@@ -0,0 +1,30 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type Breakpoint } from '../css/breakpoints.js';
3
+ export type ActionBarProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
4
+ /** The actions, usually two or three `Button`s. */
5
+ children: ReactNode;
6
+ /**
7
+ * One short line about the work, such as when it was last saved. It is a
8
+ * polite live region, so a new status is announced without moving focus.
9
+ */
10
+ status?: string;
11
+ /** Sticky only below this breakpoint; from it up the bar sits in page flow. */
12
+ stickyBelow?: Breakpoint;
13
+ };
14
+ /**
15
+ * The actions for a long page on a glass bar that sticks to the bottom of
16
+ * the viewport while the content it follows scrolls by. Place it last in
17
+ * that content: it stays stuck while its parent is on screen and then rests
18
+ * in its own place at the end.
19
+ */
20
+ export declare const ActionBar: import("react").ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
21
+ /** The actions, usually two or three `Button`s. */
22
+ children: ReactNode;
23
+ /**
24
+ * One short line about the work, such as when it was last saved. It is a
25
+ * polite live region, so a new status is announced without moving focus.
26
+ */
27
+ status?: string;
28
+ /** Sticky only below this breakpoint; from it up the bar sits in page flow. */
29
+ stickyBelow?: Breakpoint;
30
+ } & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cx } from '../class-names.js';
4
+ import { actionBarStickyClass } from '../css/css-action-bar.js';
5
+ /**
6
+ * The actions for a long page on a glass bar that sticks to the bottom of
7
+ * the viewport while the content it follows scrolls by. Place it last in
8
+ * that content: it stays stuck while its parent is on screen and then rests
9
+ * in its own place at the end.
10
+ */
11
+ export const ActionBar = forwardRef(function ActionBar({ children, className, status, stickyBelow, ...rest }, ref) {
12
+ return (_jsxs("div", { ref: ref, className: cx('sw-action-bar', actionBarStickyClass(stickyBelow), className), ...rest, children: [_jsx("p", { className: "sw-action-bar-status", role: "status", children: status }), _jsx("div", { className: "sw-action-bar-actions", children: children })] }));
13
+ });
@@ -1,5 +1,6 @@
1
1
  import { type RadiusStep, type SemanticColorKey } from '@scalewing/tokens';
2
2
  import { type AnchorHTMLAttributes, type HTMLAttributes } from 'react';
3
+ import { type BoxBorderValue } from '../css/css-box-border.js';
3
4
  import { type ColumnSpanProps } from '../column-span-classes.js';
4
5
  import { type SpacingProps } from '../spacing-classes.js';
5
6
  import { type VisibilityProps } from '../visibility-classes.js';
@@ -8,11 +9,14 @@ export type BoxProps = SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAt
8
9
  as?: BoxElement;
9
10
  background?: Extract<SemanticColorKey, 'background' | 'surface'>;
10
11
  radius?: RadiusStep;
11
- border?: boolean;
12
+ /** A hairline token border: `true` is solid, `'dashed'` marks a space to fill in by hand. */
13
+ border?: BoxBorder;
12
14
  };
13
- export declare const Box: import("react").ForwardRefExoticComponent<SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "download" | "href" | "rel" | "target"> & {
15
+ export type BoxBorder = BoxBorderValue;
16
+ export declare const Box: import("react").ForwardRefExoticComponent<SpacingProps & VisibilityProps & ColumnSpanProps & HTMLAttributes<HTMLElement> & Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "rel" | "download" | "href" | "target"> & {
14
17
  as?: BoxElement;
15
18
  background?: Extract<SemanticColorKey, "background" | "surface">;
16
19
  radius?: RadiusStep;
17
- border?: boolean;
20
+ /** A hairline token border: `true` is solid, `'dashed'` marks a space to fill in by hand. */
21
+ border?: BoxBorder;
18
22
  } & import("react").RefAttributes<HTMLElement>>;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef, } from 'react';
3
3
  import { cx } from '../class-names.js';
4
+ import { boxBorderClass } from '../css/css-box-border.js';
4
5
  import { columnSpanClassNames, } from '../column-span-classes.js';
5
6
  import { spacingClassNames } from '../spacing-classes.js';
6
7
  import { visibilityClassNames, } from '../visibility-classes.js';
@@ -14,11 +15,10 @@ export const Box = forwardRef(function Box({ as = 'div', background, border, cla
14
15
  paddingTop,
15
16
  paddingX,
16
17
  paddingY,
17
- }), ...visibilityClassNames({ hideBelow, hideFrom }), ...columnSpanClassNames({ columnSpan }), className), style: {
18
+ }), ...visibilityClassNames({ hideBelow, hideFrom }), ...columnSpanClassNames({ columnSpan }), boxBorderClass(border), className), style: {
18
19
  backgroundColor: background
19
20
  ? `var(--sw-color-${background})`
20
21
  : undefined,
21
- border: border ? '1px solid var(--sw-color-border)' : undefined,
22
22
  borderRadius: radius ? `var(--sw-radius-${radius})` : undefined,
23
23
  ...style,
24
24
  }, ...rest }));
@@ -1,6 +1,11 @@
1
+ import { type WeekStart } from '../calendar-month.js';
2
+ import { type DateFieldLabels } from '../date-field-labels.js';
3
+ export type { DateFieldLabels, WeekStart };
1
4
  export type DateFieldProps = {
2
5
  label: string;
6
+ /** Calendar date `YYYY-MM-DD`, or '' for no date. */
3
7
  value: string;
8
+ /** Receives `YYYY-MM-DD` or '', never converted through UTC. */
4
9
  onChange: (value: string) => void;
5
10
  min?: string;
6
11
  max?: string;
@@ -8,5 +13,14 @@ export type DateFieldProps = {
8
13
  required?: boolean;
9
14
  description?: string;
10
15
  error?: string;
16
+ /**
17
+ * BCP 47 tag for month and weekday names, spoken dates, and the typed
18
+ * field order. Defaults to the nearest `lang` attribute, then en-US.
19
+ */
20
+ locale?: string;
21
+ /** First column of the calendar: 0 Sunday (default) or 1 Monday. */
22
+ weekStartsOn?: WeekStart;
23
+ /** The control's own words; English by default. */
24
+ labels?: Partial<DateFieldLabels>;
11
25
  };
12
- export declare function DateField({ label, value, onChange, min, max, disabled, required, description, error, }: DateFieldProps): import("react").JSX.Element;
26
+ export declare function DateField({ label, value, onChange, min, max, disabled, required, description, error, locale, weekStartsOn, labels, }: DateFieldProps): import("react").JSX.Element;
@@ -1,30 +1,70 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId } from 'react';
4
- import { assertDateOnly, isDateOnly, isOutsideDateRange, } from '../date-only.js';
5
- export function DateField({ label, value, onChange, min, max, disabled = false, required = false, description, error, }) {
6
- assertDateOnly('value', value, true);
7
- if (min !== undefined)
8
- assertDateOnly('min', min);
9
- if (max !== undefined)
10
- assertDateOnly('max', max);
11
- if (min !== undefined && max !== undefined && min > max)
12
- throw new RangeError('min must not be after max');
3
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { resolveDateFieldLabels, } from '../date-field-labels.js';
5
+ import { dateEntryPattern, dateEntryPlaceholder } from '../date-entry.js';
6
+ import { dateEntryValidity } from '../date-entry-validity.js';
7
+ import { assertDateBounds, isOutsideDateRange, todayDateOnly, } from '../date-only.js';
8
+ import { CalendarDialog } from './date-field/CalendarDialog.js';
9
+ import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
10
+ import { useDateEntry } from './date-field/use-date-entry.js';
11
+ import { useLangLocale } from './date-field/use-lang-locale.js';
12
+ export function DateField({ label, value, onChange, min, max, disabled = false, required = false, description, error, locale, weekStartsOn = 0, labels, }) {
13
+ assertDateBounds(value, min, max);
14
+ if (weekStartsOn !== 0 && weekStartsOn !== 1)
15
+ throw new RangeError('weekStartsOn must be 0 or 1');
16
+ const words = resolveDateFieldLabels(labels);
17
+ const rootRef = useRef(null);
18
+ const controlRef = useRef(null);
19
+ const buttonRef = useRef(null);
20
+ const inputRef = useRef(null);
21
+ const resolvedLocale = useLangLocale(locale, rootRef);
22
+ const pattern = useMemo(() => dateEntryPattern(resolvedLocale), [resolvedLocale]);
23
+ const entry = useDateEntry(value, pattern, (next) => {
24
+ if (!disabled)
25
+ onChange(next);
26
+ });
27
+ const validity = dateEntryValidity(entry.parsed, words, min, max);
28
+ // Blocks form submission as the native date input's validity did.
29
+ useEffect(() => {
30
+ inputRef.current?.setCustomValidity(validity);
31
+ }, [validity]);
32
+ const [open, setOpen] = useState(false);
33
+ // Disabling the field closes its calendar for good, not just while disabled.
34
+ if (disabled && open)
35
+ setOpen(false);
36
+ const shown = open && !disabled;
13
37
  const inputId = useId();
38
+ const labelId = useId();
39
+ const dialogId = useId();
14
40
  const descriptionId = useId();
41
+ const entryErrorId = useId();
15
42
  const errorId = useId();
16
43
  const describedBy = [
17
44
  description ? descriptionId : null,
45
+ entry.invalid ? entryErrorId : null,
18
46
  error ? errorId : null,
19
47
  ]
20
48
  .filter((id) => id !== null)
21
49
  .join(' ');
22
- const invalid = Boolean(error) || isOutsideDateRange(value, min, max);
23
- return (_jsxs("div", { className: "sw-date-field", children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, children: label }), _jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, className: "sw-date-field-input", disabled: disabled, id: inputId, max: max, min: min, onChange: (event) => {
24
- const next = event.currentTarget.value;
25
- if (!disabled &&
26
- !event.currentTarget.validity.badInput &&
27
- (next === '' || isDateOnly(next)))
28
- onChange(next);
29
- }, required: required, type: "date", value: value }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, error ? (_jsx("span", { className: "sw-date-field-error", id: errorId, children: error })) : null] }));
50
+ const invalid = Boolean(error) || entry.invalid || isOutsideDateRange(value, min, max);
51
+ function close(restoreFocus) {
52
+ setOpen(false);
53
+ if (restoreFocus)
54
+ buttonRef.current?.focus();
55
+ }
56
+ function select(next) {
57
+ entry.reset();
58
+ close(true);
59
+ if (next !== value)
60
+ onChange(next);
61
+ }
62
+ return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: label }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
63
+ if (event.key === 'Enter')
64
+ entry.check();
65
+ }, placeholder: dateEntryPlaceholder(pattern, {
66
+ day: words.dayPlaceholder,
67
+ month: words.monthPlaceholder,
68
+ year: words.yearPlaceholder,
69
+ }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { "aria-controls": shown ? dialogId : undefined, "aria-expanded": shown, "aria-haspopup": "dialog", "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: () => (shown ? close(true) : setOpen(true)), ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, entry.invalid ? (_jsx("span", { className: "sw-date-field-error", id: entryErrorId, children: words.invalidEntry })) : null, error ? (_jsx("span", { className: "sw-date-field-error", id: errorId, children: error })) : null, shown ? (_jsx(CalendarDialog, { anchorRef: controlRef, buttonRef: buttonRef, id: dialogId, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, onClose: close, onSelect: select, required: required, today: todayDateOnly(), value: value, weekStartsOn: weekStartsOn })) : null] }));
30
70
  }
@@ -10,5 +10,9 @@ export type FieldProps = {
10
10
  description?: string;
11
11
  error?: string;
12
12
  required?: boolean;
13
+ /** Short text inside the control frame before the value, such as a currency sign. Not part of the value. */
14
+ prefix?: string;
15
+ /** Short text inside the control frame after the value, such as a unit. Not part of the value. */
16
+ suffix?: string;
13
17
  };
14
- export declare function Field({ children, gap, label, labelVisuallyHidden, size, description, error, required, }: FieldProps): import("react").JSX.Element;
18
+ export declare function Field({ children, gap, label, labelVisuallyHidden, size, description, error, required, prefix, suffix, }: FieldProps): import("react").JSX.Element;
@@ -1,13 +1,18 @@
1
1
  'use client';
2
2
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cloneElement, isValidElement, useId } from 'react';
4
+ import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
4
5
  import { Stack } from './Stack.js';
5
6
  import { Text } from './Text.js';
6
- export function Field({ children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, required = false, }) {
7
+ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, required = false, prefix, suffix, }) {
7
8
  const controlId = useId();
8
9
  const messageId = useId();
10
+ const labelId = useId();
11
+ const prefixId = useId();
12
+ const suffixId = useId();
9
13
  const message = error || description;
10
- const needsControl = Boolean(message || required);
14
+ const adorned = Boolean(prefix || suffix);
15
+ const needsControl = Boolean(message || required || adorned);
11
16
  const className = [
12
17
  'sw-field',
13
18
  size === 'xs' && 'sw-field-xs',
@@ -20,11 +25,17 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
20
25
  typeof children.type !== 'string' ||
21
26
  !['input', 'select', 'textarea'].includes(children.type)) {
22
27
  if (needsControl)
23
- throw new TypeError('Field validation requires one native input, select, or textarea child');
28
+ throw new TypeError('Field validation and adornments require one native input, select, or textarea child');
24
29
  return (_jsxs(Stack, { as: "label", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [labelText, children] }));
25
30
  }
31
+ if (adorned && children.type !== 'input')
32
+ throw new TypeError('Field prefix and suffix require a native input child');
33
+ const adornment = adorned
34
+ ? adornedControlAria(children.props, labelId, prefix ? prefixId : undefined, suffix ? suffixId : undefined)
35
+ : { labelledBy: children.props['aria-labelledby'] };
26
36
  const describedBy = [
27
37
  children.props['aria-describedby'],
38
+ adornment.describedBy,
28
39
  message ? messageId : null,
29
40
  ]
30
41
  .filter(Boolean)
@@ -33,7 +44,8 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
33
44
  id: children.props.id || controlId,
34
45
  'aria-describedby': describedBy || undefined,
35
46
  'aria-invalid': error ? true : children.props['aria-invalid'],
47
+ 'aria-labelledby': adornment.labelledBy,
36
48
  required: required || children.props.required,
37
49
  });
38
- return (_jsxs(Stack, { as: "div", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [_jsx("label", { htmlFor: children.props.id || controlId, children: labelText }), control, message ? (_jsx("span", { className: error ? 'sw-field-error' : 'sw-field-description', id: messageId, role: error ? 'alert' : undefined, children: message })) : null] }));
50
+ return (_jsxs(Stack, { as: "div", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [_jsx("label", { htmlFor: children.props.id || controlId, id: labelId, children: labelText }), adorned ? (_jsx(FieldAdornment, { prefix: prefix, prefixId: prefixId, suffix: suffix, suffixId: suffixId, children: control })) : (control), message ? (_jsx("span", { className: error ? 'sw-field-error' : 'sw-field-description', id: messageId, role: error ? 'alert' : undefined, children: message })) : null] }));
39
51
  }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react';
2
+ export type FieldAdornmentProps = {
3
+ children: ReactNode;
4
+ prefix?: string;
5
+ prefixId: string;
6
+ suffix?: string;
7
+ suffixId: string;
8
+ };
9
+ export declare function FieldAdornment({ children, prefix, prefixId, suffix, suffixId, }: FieldAdornmentProps): import("react").JSX.Element;
10
+ export type AdornedControlNaming = {
11
+ 'aria-label'?: string;
12
+ 'aria-labelledby'?: string;
13
+ };
14
+ export type AdornedControlAria = {
15
+ /** The input's `aria-labelledby`; undefined keeps the input's own name. */
16
+ labelledBy?: string;
17
+ /** Adornment ids to add to the input's `aria-describedby`, if any. */
18
+ describedBy?: string;
19
+ };
20
+ export declare function adornedControlAria(own: AdornedControlNaming, labelId: string, prefixId: string | undefined, suffixId: string | undefined): AdornedControlAria;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * A press on the frame's own chrome (the prefix, the suffix or the padding)
4
+ * focuses the input, as the text cursor over the frame promises. A press on
5
+ * the input itself keeps the browser's own caret placement and selection.
6
+ */
7
+ function focusAdornedInput(event) {
8
+ const input = event.currentTarget.querySelector('input');
9
+ if (!input || input.disabled || event.target === input)
10
+ return;
11
+ event.preventDefault();
12
+ input.focus();
13
+ }
14
+ /*
15
+ * Draws the control frame around a native input with short text before or
16
+ * after it. The text is hidden from the reading order so it is not read twice:
17
+ * the input names or describes itself with this text by id (see
18
+ * `adornedControlAria`), which still reads a hidden node it references directly.
19
+ */
20
+ export function FieldAdornment({ children, prefix, prefixId, suffix, suffixId, }) {
21
+ return (_jsxs("span", { className: "sw-field-adorned", onMouseDown: focusAdornedInput, children: [prefix ? (_jsx("span", { "aria-hidden": "true", className: "sw-field-prefix", id: prefixId, children: prefix })) : null, children, suffix ? (_jsx("span", { "aria-hidden": "true", className: "sw-field-suffix", id: suffixId, children: suffix })) : null] }));
22
+ }
23
+ /*
24
+ * An adorned input is named by its label plus the adornment ("Drop amount $").
25
+ * When the input already names itself with `aria-label` or `aria-labelledby`,
26
+ * that name stands and the adornment describes the input instead, so the unit
27
+ * is still read.
28
+ */
29
+ export function adornedControlAria(own, labelId, prefixId, suffixId) {
30
+ const adornment = [prefixId, suffixId].filter(Boolean).join(' ');
31
+ if (own['aria-label'] || own['aria-labelledby'])
32
+ return { labelledBy: own['aria-labelledby'], describedBy: adornment };
33
+ return { labelledBy: [labelId, adornment].join(' ') };
34
+ }
@@ -2,10 +2,13 @@ import { type SpacingStep } from '@scalewing/tokens';
2
2
  import { type Breakpoint } from '../css/breakpoints.js';
3
3
  import { type GridColumns } from '../css/css-grid.js';
4
4
  import { type BoxProps } from './Box.js';
5
+ import { type Align } from './Stack.js';
5
6
  export type { GridColumns };
6
7
  export type GridProps = BoxProps & {
8
+ /** Where each child sits in the height of its row; unset, children stretch. */
9
+ align?: Align;
7
10
  columns?: GridColumns;
8
11
  columnsBelow?: Partial<Record<Breakpoint, GridColumns>>;
9
12
  gap?: SpacingStep;
10
13
  };
11
- export declare function Grid({ className, columns, columnsBelow, gap, ...rest }: GridProps): import("react").JSX.Element;
14
+ export declare function Grid({ align, className, columns, columnsBelow, gap, ...rest }: GridProps): import("react").JSX.Element;
@@ -5,7 +5,7 @@ import { gridColumnsBelowClass, gridColumnsClass, } from '../css/css-grid.js';
5
5
  import { spacingClass } from '../css/spacing-classes.js';
6
6
  import { assertGridCount } from '../grid-counts.js';
7
7
  import { Box } from './Box.js';
8
- export function Grid({ className, columns = 1, columnsBelow, gap = 0, ...rest }) {
8
+ export function Grid({ align, className, columns = 1, columnsBelow, gap = 0, ...rest }) {
9
9
  assertGridCount('columns', columns);
10
10
  const belowClasses = breakpoints.flatMap((breakpoint) => {
11
11
  const count = columnsBelow?.[breakpoint];
@@ -14,5 +14,5 @@ export function Grid({ className, columns = 1, columnsBelow, gap = 0, ...rest })
14
14
  assertGridCount('columnsBelow', count);
15
15
  return [gridColumnsBelowClass(breakpoint, count)];
16
16
  });
17
- return (_jsx(Box, { className: cx('sw-grid', gridColumnsClass(columns), ...belowClasses, spacingClass('gap', 'all', gap), className), ...rest }));
17
+ return (_jsx(Box, { className: cx('sw-grid', gridColumnsClass(columns), ...belowClasses, spacingClass('gap', 'all', gap), align && `sw-align-${align}`, className), ...rest }));
18
18
  }
@@ -4,5 +4,7 @@ export type ProgressProps = {
4
4
  value: number;
5
5
  max: number;
6
6
  tone?: ProgressTone;
7
+ /** Shows the muted "value / max" count beside the label. Turn it off when the page shows its own count. */
8
+ showCount?: boolean;
7
9
  };
8
- export declare function Progress({ label, value, max, tone, }: ProgressProps): import("react").JSX.Element;
10
+ export declare function Progress({ label, value, max, tone, showCount, }: ProgressProps): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
- export function Progress({ label, value, max, tone = 'accent', }) {
4
+ export function Progress({ label, value, max, tone = 'accent', showCount = true, }) {
5
5
  if (!label.trim())
6
6
  throw new RangeError('label must not be empty');
7
7
  if (!Number.isFinite(max) || max <= 0)
@@ -9,5 +9,5 @@ export function Progress({ label, value, max, tone = 'accent', }) {
9
9
  if (!Number.isFinite(value) || value < 0 || value > max)
10
10
  throw new RangeError('value must be finite and within 0..max');
11
11
  const labelId = useId();
12
- return (_jsxs("div", { className: `sw-progress sw-progress-${tone}`, children: [_jsxs("div", { className: "sw-progress-heading", children: [_jsx("span", { className: "sw-progress-label", id: labelId, children: label }), _jsxs("span", { "aria-hidden": "true", className: "sw-progress-count", children: [value, " / ", max] })] }), _jsx("progress", { "aria-labelledby": labelId, className: "sw-progress-bar", max: max, value: value })] }));
12
+ return (_jsxs("div", { className: `sw-progress sw-progress-${tone}`, children: [_jsxs("div", { className: "sw-progress-heading", children: [_jsx("span", { className: "sw-progress-label", id: labelId, children: label }), showCount ? (_jsxs("span", { "aria-hidden": "true", className: "sw-progress-count", children: [value, " / ", max] })) : null] }), _jsx("progress", { "aria-labelledby": labelId, className: "sw-progress-bar", max: max, value: value })] }));
13
13
  }
@@ -0,0 +1,26 @@
1
+ import { type RefObject } from 'react';
2
+ import { type WeekStart } from '../../calendar-month.js';
3
+ import { type DateFieldLabels } from '../../date-field-labels.js';
4
+ export type CalendarDialogProps = {
5
+ anchorRef: RefObject<HTMLElement | null>;
6
+ buttonRef: RefObject<HTMLElement | null>;
7
+ id: string;
8
+ labelId: string;
9
+ labels: DateFieldLabels;
10
+ locale: string;
11
+ max?: string;
12
+ min?: string;
13
+ /** `true` returns focus to the calendar button. */
14
+ onClose: (restoreFocus: boolean) => void;
15
+ onSelect: (value: string) => void;
16
+ required: boolean;
17
+ today: string;
18
+ value: string;
19
+ weekStartsOn: WeekStart;
20
+ };
21
+ /**
22
+ * The calendar popover: a labelled modal dialog anchored under the field,
23
+ * following the WAI-ARIA date picker dialog pattern. It opens on the
24
+ * selected day, or today, kept inside `[min, max]`.
25
+ */
26
+ export declare function CalendarDialog({ anchorRef, buttonRef, id, labelId, labels, locale, max, min, onClose, onSelect, required, today, value, weekStartsOn, }: CalendarDialogProps): import("react").JSX.Element;
@@ -0,0 +1,93 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useLayoutEffect, useRef, useState, } from 'react';
3
+ import { monthOf, } from '../../calendar-month.js';
4
+ import { clampDateOnly, daysInMonth, formatDateOnly, isOutsideDateRange, parseDateOnly, } from '../../date-only.js';
5
+ import { Button } from '../Button.js';
6
+ import { CalendarGrid } from './CalendarGrid.js';
7
+ import { CalendarHeader } from './CalendarHeader.js';
8
+ import { useAnchoredPopover } from './use-anchored-popover.js';
9
+ /** The dialog's tab stops: enabled buttons and the grid's roving day. */
10
+ function isTabStop(node) {
11
+ if (node instanceof HTMLButtonElement)
12
+ return !node.disabled;
13
+ return node.getAttribute('tabindex') === '0';
14
+ }
15
+ /** Keeps Tab and Shift+Tab inside the dialog, as a modal dialog does. */
16
+ function wrapTab(event) {
17
+ // One universal selector keeps document order in every DOM implementation.
18
+ const items = [
19
+ ...event.currentTarget.querySelectorAll('*'),
20
+ ].filter(isTabStop);
21
+ const first = items[0];
22
+ const last = items[items.length - 1];
23
+ if (!first || !last)
24
+ return;
25
+ if (event.shiftKey && document.activeElement === first) {
26
+ event.preventDefault();
27
+ last.focus();
28
+ }
29
+ else if (!event.shiftKey && document.activeElement === last) {
30
+ event.preventDefault();
31
+ first.focus();
32
+ }
33
+ }
34
+ function sameDayIn(month, focused) {
35
+ const day = parseDateOnly(focused)?.day ?? 1;
36
+ return formatDateOnly({
37
+ ...month,
38
+ day: Math.min(day, daysInMonth(month.year, month.month)),
39
+ });
40
+ }
41
+ /**
42
+ * The calendar popover: a labelled modal dialog anchored under the field,
43
+ * following the WAI-ARIA date picker dialog pattern. It opens on the
44
+ * selected day, or today, kept inside `[min, max]`.
45
+ */
46
+ export function CalendarDialog({ anchorRef, buttonRef, id, labelId, labels, locale, max, min, onClose, onSelect, required, today, value, weekStartsOn, }) {
47
+ const titleId = useId();
48
+ const dialogRef = useRef(null);
49
+ const [rawFocusDate, setFocusDate] = useState(value || today);
50
+ // A new value while open moves the calendar to it.
51
+ const [shownValue, setShownValue] = useState(value);
52
+ if (value !== shownValue) {
53
+ setShownValue(value);
54
+ if (value !== '')
55
+ setFocusDate(value);
56
+ }
57
+ // Clamped on every render, so new bounds while open move it inside.
58
+ const focusDate = clampDateOnly(rawFocusDate, min, max);
59
+ const month = monthOf(focusDate);
60
+ // Opening and grid keys move DOM focus to the focus date; header controls
61
+ // and presses change the date without taking focus from where it is.
62
+ const focusGrid = useRef(true);
63
+ useAnchoredPopover(dialogRef, anchorRef, buttonRef, () => onClose(false));
64
+ // Whether a day holds DOM focus. Removing that day fires no blur, so this
65
+ // stays true when a new value or bounds move the grid out from under it.
66
+ const gridFocused = useRef(false);
67
+ useLayoutEffect(() => {
68
+ if (!focusGrid.current && !gridFocused.current)
69
+ return;
70
+ focusGrid.current = false;
71
+ dialogRef.current
72
+ ?.querySelector(`[role="grid"] [data-date="${focusDate}"]`)
73
+ ?.focus({ preventScroll: true });
74
+ }, [focusDate]);
75
+ function onKeyDown(event) {
76
+ if (event.key === 'Escape') {
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+ onClose(true);
80
+ }
81
+ else if (event.key === 'Tab')
82
+ wrapTab(event);
83
+ }
84
+ const todayUnavailable = isOutsideDateRange(today, min, max);
85
+ return (_jsxs("div", { "aria-labelledby": labelId, "aria-modal": "true", className: "sw-date-field-calendar", id: id, onBlur: () => {
86
+ gridFocused.current = false;
87
+ }, onFocus: (event) => {
88
+ gridFocused.current = event.target.closest('[role="grid"]') !== null;
89
+ }, onKeyDown: onKeyDown, ref: dialogRef, role: "dialog", children: [_jsx(CalendarHeader, { labels: labels, locale: locale, max: max, min: min, month: month, onMonthChange: (next) => setFocusDate(sameDayIn(next, focusDate)), titleId: titleId, todayYear: monthOf(today).year }), _jsx(CalendarGrid, { focusDate: focusDate, locale: locale, max: max, min: min, month: month, onFocusDate: setFocusDate, onMoveFocus: (next) => {
90
+ focusGrid.current = true;
91
+ setFocusDate(next);
92
+ }, onSelect: onSelect, selected: value, titleId: titleId, today: today, weekStartsOn: weekStartsOn }), _jsxs("div", { className: "sw-date-field-footer", children: [required ? null : (_jsx(Button, { disabled: value === '', onPress: () => onSelect(''), size: "sm", variant: "ghost", children: labels.clear })), _jsx(Button, { disabled: todayUnavailable, onPress: () => onSelect(today), size: "sm", variant: "ghost", children: labels.today })] })] }));
93
+ }
@@ -0,0 +1,23 @@
1
+ import { type CalendarMonth, type WeekStart } from '../../calendar-month.js';
2
+ export type CalendarGridProps = {
3
+ focusDate: string;
4
+ locale: string;
5
+ max?: string;
6
+ min?: string;
7
+ month: CalendarMonth;
8
+ /** A cell took focus: follow it without moving focus. */
9
+ onFocusDate: (value: string) => void;
10
+ /** A grid key chose a day: focus it. */
11
+ onMoveFocus: (value: string) => void;
12
+ onSelect: (value: string) => void;
13
+ selected: string;
14
+ titleId: string;
15
+ today: string;
16
+ weekStartsOn: WeekStart;
17
+ };
18
+ /**
19
+ * The month as a WAI-ARIA grid with one roving tab stop. Days outside
20
+ * `[min, max]` stay focusable for orientation but are `aria-disabled`;
21
+ * padding past the four-digit years is blank and inert.
22
+ */
23
+ export declare function CalendarGrid({ focusDate, locale, max, min, month, onFocusDate, onMoveFocus, onSelect, selected, titleId, today, weekStartsOn, }: CalendarGridProps): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
3
+ import { calendarKeyTarget } from '../../calendar-keys.js';
4
+ import { formatDateLabel, weekdayLabels } from '../../calendar-labels.js';
5
+ import { monthGrid, } from '../../calendar-month.js';
6
+ import { isOutsideDateRange } from '../../date-only.js';
7
+ /**
8
+ * The month as a WAI-ARIA grid with one roving tab stop. Days outside
9
+ * `[min, max]` stay focusable for orientation but are `aria-disabled`;
10
+ * padding past the four-digit years is blank and inert.
11
+ */
12
+ export function CalendarGrid({ focusDate, locale, max, min, month, onFocusDate, onMoveFocus, onSelect, selected, titleId, today, weekStartsOn, }) {
13
+ function choose(value) {
14
+ if (!isOutsideDateRange(value, min, max))
15
+ onSelect(value);
16
+ }
17
+ function onKeyDown(event) {
18
+ if (event.key === 'Enter' || event.key === ' ') {
19
+ event.preventDefault();
20
+ choose(focusDate);
21
+ return;
22
+ }
23
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
24
+ const target = calendarKeyTarget({ key: event.key, shiftKey: event.shiftKey }, focusDate, { weekStartsOn, min, max, rtl });
25
+ if (target === null)
26
+ return;
27
+ event.preventDefault();
28
+ onMoveFocus(target);
29
+ }
30
+ return (_jsxs("table", { "aria-labelledby": titleId, className: "sw-date-field-grid", onKeyDown: onKeyDown, role: "grid", children: [_jsx("thead", { children: _jsx("tr", { children: weekdayLabels(locale, weekStartsOn).map((weekday) => (_jsx("th", { abbr: weekday.long, scope: "col", children: weekday.short }, weekday.long))) }) }), _jsx("tbody", { children: monthGrid(month, weekStartsOn).map((row, rowIndex) => (_jsx("tr", { children: row.map((cell, column) => cell === null ? (_jsx("td", { "aria-disabled": "true", className: "sw-date-field-day sw-date-field-day-outside", role: "gridcell" }, `blank-${column}`)) : (_jsx("td", { "aria-current": cell.value === today ? 'date' : undefined, "aria-disabled": isOutsideDateRange(cell.value, min, max) || undefined, "aria-label": formatDateLabel(cell.value, locale), "aria-selected": cell.value === selected, className: cx('sw-date-field-day', !cell.inMonth && 'sw-date-field-day-outside'), "data-date": cell.value, onClick: () => choose(cell.value), onFocus: () => {
31
+ // A press on a neighboring month's day selects it; moving
32
+ // the grid first would pull the day out from under it.
33
+ if (cell.inMonth && cell.value !== focusDate)
34
+ onFocusDate(cell.value);
35
+ }, role: "gridcell", tabIndex: cell.value === focusDate ? 0 : -1, children: cell.day }, cell.value))) }, rowIndex))) })] }));
36
+ }
@@ -0,0 +1,18 @@
1
+ import { type CalendarMonth } from '../../calendar-month.js';
2
+ import { type DateFieldLabels } from '../../date-field-labels.js';
3
+ export type CalendarHeaderProps = {
4
+ labels: DateFieldLabels;
5
+ locale: string;
6
+ max?: string;
7
+ min?: string;
8
+ month: CalendarMonth;
9
+ onMonthChange: (month: CalendarMonth) => void;
10
+ titleId: string;
11
+ todayYear: number;
12
+ };
13
+ /**
14
+ * Month stepping plus month and year selectors for jumping far, both cut to
15
+ * `min` and `max`, over a visually hidden title that announces the shown
16
+ * month when it changes.
17
+ */
18
+ export declare function CalendarHeader({ labels, locale, max, min, month, onMonthChange, titleId, todayYear, }: CalendarHeaderProps): import("react").JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { monthNames, monthTitle } from '../../calendar-labels.js';
3
+ import { monthInRange, monthsInRange, shiftMonth, } from '../../calendar-month.js';
4
+ import { calendarYearRange, yearsIn } from '../../calendar-years.js';
5
+ import { Select } from '../Select.js';
6
+ import { DateFieldGlyph } from './DateFieldGlyph.js';
7
+ function MonthStep({ delta, label, max, min, month, onMonthChange, }) {
8
+ const target = shiftMonth(month, delta);
9
+ const unavailable = target.year < 1 || target.year > 9999 || !monthInRange(target, min, max);
10
+ return (_jsx("button", { "aria-disabled": unavailable || undefined, "aria-label": label, className: "sw-date-field-step", onClick: () => {
11
+ if (!unavailable)
12
+ onMonthChange(target);
13
+ }, type: "button", children: _jsx(DateFieldGlyph, { name: delta < 0 ? 'previous' : 'next' }) }));
14
+ }
15
+ /**
16
+ * Month stepping plus month and year selectors for jumping far, both cut to
17
+ * `min` and `max`, over a visually hidden title that announces the shown
18
+ * month when it changes.
19
+ */
20
+ export function CalendarHeader({ labels, locale, max, min, month, onMonthChange, titleId, todayYear, }) {
21
+ const range = calendarYearRange(month.year, todayYear, min, max);
22
+ const names = monthNames(locale);
23
+ const stepProps = { max, min, month, onMonthChange };
24
+ return (_jsxs("div", { className: "sw-date-field-header", children: [_jsx("h2", { "aria-live": "polite", className: "sw-sr-only", id: titleId, children: monthTitle(month, locale) }), _jsx(MonthStep, { ...stepProps, delta: -1, label: labels.previousMonth }), _jsxs("div", { className: "sw-date-field-jump", children: [_jsx(Select, { label: labels.month, labelVisuallyHidden: true, onChange: (next) => onMonthChange({ ...month, month: Number(next) }),
25
+ // Only months with a day inside the bounds; the shown one always is.
26
+ options: monthsInRange(month.year, min, max).map((number) => ({
27
+ value: String(number),
28
+ label: names[number - 1] ?? String(number),
29
+ })), size: "xs", value: String(month.month) }), _jsx(Select, { label: labels.year, labelVisuallyHidden: true, onChange: (next) => onMonthChange({ ...month, year: Number(next) }), options: yearsIn(range).map((year) => ({
30
+ value: String(year),
31
+ label: String(year),
32
+ })), size: "xs", value: String(month.year) })] }), _jsx(MonthStep, { ...stepProps, delta: 1, label: labels.nextMonth })] }));
33
+ }