@scalewing/react 1.8.0 → 1.10.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 +153 -0
- package/README.md +93 -2
- package/dist/anchored-position.d.ts +28 -0
- package/dist/anchored-position.js +17 -0
- package/dist/calendar-button-labels.d.ts +19 -0
- package/dist/calendar-button-labels.js +10 -0
- package/dist/calendar-keys.d.ts +19 -0
- package/dist/calendar-keys.js +39 -0
- package/dist/calendar-labels.d.ts +19 -0
- package/dist/calendar-labels.js +41 -0
- package/dist/calendar-month.d.ts +38 -0
- package/dist/calendar-month.js +116 -0
- package/dist/calendar-years.d.ts +14 -0
- package/dist/calendar-years.js +20 -0
- package/dist/components/Accordion.d.ts +6 -1
- package/dist/components/Accordion.js +12 -3
- package/dist/components/ActionBar.d.ts +30 -0
- package/dist/components/ActionBar.js +13 -0
- package/dist/components/Box.d.ts +7 -3
- package/dist/components/Box.js +2 -2
- package/dist/components/CalendarButton.d.ts +43 -0
- package/dist/components/CalendarButton.js +47 -0
- package/dist/components/DateField.d.ts +15 -1
- package/dist/components/DateField.js +47 -18
- package/dist/components/Field.d.ts +5 -1
- package/dist/components/Field.js +16 -4
- package/dist/components/FieldAdornment.d.ts +20 -0
- package/dist/components/FieldAdornment.js +34 -0
- package/dist/components/Grid.d.ts +4 -1
- package/dist/components/Grid.js +2 -2
- package/dist/components/Progress.d.ts +3 -1
- package/dist/components/Progress.js +2 -2
- package/dist/components/date-field/CalendarDialog.d.ts +26 -0
- package/dist/components/date-field/CalendarDialog.js +93 -0
- package/dist/components/date-field/CalendarGrid.d.ts +23 -0
- package/dist/components/date-field/CalendarGrid.js +36 -0
- package/dist/components/date-field/CalendarHeader.d.ts +18 -0
- package/dist/components/date-field/CalendarHeader.js +33 -0
- package/dist/components/date-field/DateFieldGlyph.d.ts +9 -0
- package/dist/components/date-field/DateFieldGlyph.js +15 -0
- package/dist/components/date-field/use-anchored-popover.d.ts +8 -0
- package/dist/components/date-field/use-anchored-popover.js +61 -0
- package/dist/components/date-field/use-calendar-popup.d.ts +36 -0
- package/dist/components/date-field/use-calendar-popup.js +43 -0
- package/dist/components/date-field/use-date-entry.d.ts +20 -0
- package/dist/components/date-field/use-date-entry.js +61 -0
- package/dist/components/date-field/use-lang-locale.d.ts +7 -0
- package/dist/components/date-field/use-lang-locale.js +17 -0
- package/dist/css/css-accordion.js +55 -8
- package/dist/css/css-action-bar.d.ts +4 -0
- package/dist/css/css-action-bar.js +99 -0
- package/dist/css/css-action-menu.js +2 -1
- package/dist/css/css-box-border.d.ts +5 -0
- package/dist/css/css-box-border.js +24 -0
- package/dist/css/css-calendar-button.d.ts +2 -0
- package/dist/css/css-calendar-button.js +33 -0
- package/dist/css/css-chrome.js +2 -1
- package/dist/css/css-components.js +13 -6
- package/dist/css/css-data.js +2 -1
- package/dist/css/css-date-field.js +241 -11
- package/dist/css/css-denomination-grid.js +2 -1
- package/dist/css/css-document.d.ts +2 -0
- package/dist/css/css-document.js +2 -1
- package/dist/css/css-field.js +61 -1
- package/dist/css/css-select.js +2 -1
- package/dist/css/css-tooltip.js +2 -1
- package/dist/css/stacking.d.ts +10 -0
- package/dist/css/stacking.js +49 -0
- package/dist/css/stylesheet.js +6 -0
- package/dist/date-entry-validity.d.ts +10 -0
- package/dist/date-entry-validity.js +16 -0
- package/dist/date-entry.d.ts +32 -0
- package/dist/date-entry.js +106 -0
- package/dist/date-field-labels.d.ts +38 -0
- package/dist/date-field-labels.js +22 -0
- package/dist/date-field-locale.d.ts +8 -0
- package/dist/date-field-locale.js +27 -0
- package/dist/date-formatters.d.ts +13 -0
- package/dist/date-formatters.js +28 -0
- package/dist/date-only.d.ts +23 -0
- package/dist/date-only.js +63 -9
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/styles.css +410 -21
- package/package.json +1 -1
package/dist/components/Grid.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type DateFieldGlyphName = 'calendar' | 'previous' | 'next';
|
|
2
|
+
/**
|
|
3
|
+
* Private control chrome for DateField and CalendarButton. Stroke, size,
|
|
4
|
+
* and color come from the generated `sw-date-field-glyph` rule; the
|
|
5
|
+
* chevrons mirror in right-to-left text.
|
|
6
|
+
*/
|
|
7
|
+
export declare function DateFieldGlyph({ name }: {
|
|
8
|
+
name: DateFieldGlyphName;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../../class-names.js';
|
|
3
|
+
const PATHS = {
|
|
4
|
+
calendar: 'M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5zM4 10h16M8.5 2.5v3M15.5 2.5v3',
|
|
5
|
+
previous: 'M14.5 6l-6 6 6 6',
|
|
6
|
+
next: 'M9.5 6l6 6-6 6',
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Private control chrome for DateField and CalendarButton. Stroke, size,
|
|
10
|
+
* and color come from the generated `sw-date-field-glyph` rule; the
|
|
11
|
+
* chevrons mirror in right-to-left text.
|
|
12
|
+
*/
|
|
13
|
+
export function DateFieldGlyph({ name }) {
|
|
14
|
+
return (_jsx("svg", { "aria-hidden": "true", className: cx('sw-date-field-glyph', name !== 'calendar' && 'sw-date-field-glyph-directional'), focusable: "false", viewBox: "0 0 24 24", children: _jsx("path", { d: PATHS[name] }) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Shows a mounted element on the top layer as a manual popover, keeps it
|
|
4
|
+
* beside `anchorRef` through scroll and resize, and calls `onDismiss` for a
|
|
5
|
+
* press outside it and outside `ignoreRef` (the button that toggles it).
|
|
6
|
+
* Browsers without the popover API get the same fixed placement.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useAnchoredPopover(popoverRef: RefObject<HTMLElement | null>, anchorRef: RefObject<HTMLElement | null>, ignoreRef: RefObject<HTMLElement | null>, onDismiss: () => void): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spacingScale } from '@scalewing/tokens';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { anchoredPosition } from '../../anchored-position.js';
|
|
4
|
+
function placePopover(node, anchor) {
|
|
5
|
+
const position = anchoredPosition(anchor.getBoundingClientRect(), { width: node.offsetWidth, height: node.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }, {
|
|
6
|
+
gap: spacingScale[1],
|
|
7
|
+
inset: spacingScale[2],
|
|
8
|
+
rtl: getComputedStyle(anchor).direction === 'rtl',
|
|
9
|
+
});
|
|
10
|
+
node.style.left = `${position.left}px`;
|
|
11
|
+
node.style.top = `${position.top}px`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Shows a mounted element on the top layer as a manual popover, keeps it
|
|
15
|
+
* beside `anchorRef` through scroll and resize, and calls `onDismiss` for a
|
|
16
|
+
* press outside it and outside `ignoreRef` (the button that toggles it).
|
|
17
|
+
* Browsers without the popover API get the same fixed placement.
|
|
18
|
+
*/
|
|
19
|
+
export function useAnchoredPopover(popoverRef, anchorRef, ignoreRef, onDismiss) {
|
|
20
|
+
const dismissRef = useRef(onDismiss);
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
dismissRef.current = onDismiss;
|
|
23
|
+
});
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const node = popoverRef.current;
|
|
26
|
+
const anchor = anchorRef.current;
|
|
27
|
+
if (!node || !anchor)
|
|
28
|
+
return;
|
|
29
|
+
const supportsPopover = typeof node.showPopover === 'function';
|
|
30
|
+
// Set only where supported: an unsupported runtime would hide a
|
|
31
|
+
// `[popover]` element that it can never show.
|
|
32
|
+
if (supportsPopover) {
|
|
33
|
+
node.setAttribute('popover', 'manual');
|
|
34
|
+
if (!node.matches(':popover-open'))
|
|
35
|
+
node.showPopover();
|
|
36
|
+
}
|
|
37
|
+
placePopover(node, anchor);
|
|
38
|
+
function onPointerDown(event) {
|
|
39
|
+
const target = event.target;
|
|
40
|
+
if (!(target instanceof Node))
|
|
41
|
+
return;
|
|
42
|
+
if (node?.contains(target) || ignoreRef.current?.contains(target))
|
|
43
|
+
return;
|
|
44
|
+
dismissRef.current();
|
|
45
|
+
}
|
|
46
|
+
function onMove() {
|
|
47
|
+
if (node && anchor)
|
|
48
|
+
placePopover(node, anchor);
|
|
49
|
+
}
|
|
50
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
51
|
+
window.addEventListener('resize', onMove);
|
|
52
|
+
window.addEventListener('scroll', onMove, true);
|
|
53
|
+
return () => {
|
|
54
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
55
|
+
window.removeEventListener('resize', onMove);
|
|
56
|
+
window.removeEventListener('scroll', onMove, true);
|
|
57
|
+
if (supportsPopover && node.matches(':popover-open'))
|
|
58
|
+
node.hidePopover();
|
|
59
|
+
};
|
|
60
|
+
}, [anchorRef, ignoreRef, popoverRef]);
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export type CalendarPopupOptions = {
|
|
3
|
+
value: string;
|
|
4
|
+
disabled: boolean;
|
|
5
|
+
onChange: (value: string) => void;
|
|
6
|
+
/** Runs before a chosen date closes the calendar, such as a text reset. */
|
|
7
|
+
beforeSelect?: () => void;
|
|
8
|
+
};
|
|
9
|
+
export type CalendarPopup = {
|
|
10
|
+
/** The button that toggles the calendar and takes focus back. */
|
|
11
|
+
buttonRef: RefObject<HTMLButtonElement | null>;
|
|
12
|
+
shown: boolean;
|
|
13
|
+
toggle: () => void;
|
|
14
|
+
/** ARIA for the toggling button, as the WAI-ARIA date picker asks. */
|
|
15
|
+
trigger: {
|
|
16
|
+
'aria-controls': string | undefined;
|
|
17
|
+
'aria-expanded': boolean;
|
|
18
|
+
'aria-haspopup': 'dialog';
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* CalendarDialog props that belong to the open and close cycle; pass
|
|
22
|
+
* `buttonRef` beside them.
|
|
23
|
+
*/
|
|
24
|
+
dialog: {
|
|
25
|
+
id: string;
|
|
26
|
+
onClose: (restoreFocus: boolean) => void;
|
|
27
|
+
onSelect: (value: string) => void;
|
|
28
|
+
today: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The open state of a calendar dialog toggled by one button, shared by
|
|
33
|
+
* DateField and CalendarButton. Disabling closes it for good; choosing a
|
|
34
|
+
* date closes it, returns focus to the button, and reports a changed date.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useCalendarPopup({ value, disabled, onChange, beforeSelect, }: CalendarPopupOptions): CalendarPopup;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useId, useRef, useState } from 'react';
|
|
2
|
+
import { todayDateOnly } from '../../date-only.js';
|
|
3
|
+
/**
|
|
4
|
+
* The open state of a calendar dialog toggled by one button, shared by
|
|
5
|
+
* DateField and CalendarButton. Disabling closes it for good; choosing a
|
|
6
|
+
* date closes it, returns focus to the button, and reports a changed date.
|
|
7
|
+
*/
|
|
8
|
+
export function useCalendarPopup({ value, disabled, onChange, beforeSelect, }) {
|
|
9
|
+
const buttonRef = useRef(null);
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
// Disabling closes the calendar for good, not just while disabled.
|
|
12
|
+
if (disabled && open)
|
|
13
|
+
setOpen(false);
|
|
14
|
+
const shown = open && !disabled;
|
|
15
|
+
const id = useId();
|
|
16
|
+
function close(restoreFocus) {
|
|
17
|
+
setOpen(false);
|
|
18
|
+
if (restoreFocus)
|
|
19
|
+
buttonRef.current?.focus();
|
|
20
|
+
}
|
|
21
|
+
function select(next) {
|
|
22
|
+
beforeSelect?.();
|
|
23
|
+
close(true);
|
|
24
|
+
if (next !== value)
|
|
25
|
+
onChange(next);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
buttonRef,
|
|
29
|
+
shown,
|
|
30
|
+
toggle: () => (shown ? close(true) : setOpen(true)),
|
|
31
|
+
trigger: {
|
|
32
|
+
'aria-controls': shown ? id : undefined,
|
|
33
|
+
'aria-expanded': shown,
|
|
34
|
+
'aria-haspopup': 'dialog',
|
|
35
|
+
},
|
|
36
|
+
dialog: {
|
|
37
|
+
id,
|
|
38
|
+
onClose: close,
|
|
39
|
+
onSelect: select,
|
|
40
|
+
today: todayDateOnly(),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DateEntryPattern } from '../../date-entry.js';
|
|
2
|
+
export type DateEntry = {
|
|
3
|
+
text: string;
|
|
4
|
+
/** The date the text forms: YYYY-MM-DD, '' when blank, null when not one. */
|
|
5
|
+
parsed: string | null;
|
|
6
|
+
/** Typed text that is not a date, after the person left the field. */
|
|
7
|
+
invalid: boolean;
|
|
8
|
+
change: (text: string) => void;
|
|
9
|
+
check: () => void;
|
|
10
|
+
reset: () => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Typed entry over a controlled date-only value. A keystroke that finishes
|
|
14
|
+
* a date (see `isCompleteDateEntry`) or blanks the text calls `onCommit`;
|
|
15
|
+
* a date whose last field may still grow commits on `check` (blur or
|
|
16
|
+
* Enter). Text that is not (yet) a date stays on screen and leaves the value
|
|
17
|
+
* alone. The text also stays when the parent keeps the old value; when
|
|
18
|
+
* `value` changes from outside to any other date, the text follows it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useDateEntry(value: string, pattern: DateEntryPattern, onCommit: (value: string) => void): DateEntry;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { formatDateEntry, isCompleteDateEntry, parseDateEntry, } from '../../date-entry.js';
|
|
3
|
+
/**
|
|
4
|
+
* Typed entry over a controlled date-only value. A keystroke that finishes
|
|
5
|
+
* a date (see `isCompleteDateEntry`) or blanks the text calls `onCommit`;
|
|
6
|
+
* a date whose last field may still grow commits on `check` (blur or
|
|
7
|
+
* Enter). Text that is not (yet) a date stays on screen and leaves the value
|
|
8
|
+
* alone. The text also stays when the parent keeps the old value; when
|
|
9
|
+
* `value` changes from outside to any other date, the text follows it.
|
|
10
|
+
*/
|
|
11
|
+
export function useDateEntry(value, pattern, onCommit) {
|
|
12
|
+
const [draft, setDraft] = useState(null);
|
|
13
|
+
const current = draft !== null && (draft.value === value || draft.committed === value)
|
|
14
|
+
? draft
|
|
15
|
+
: null;
|
|
16
|
+
// The parent adopted the commit: tie the text to it, so a later outside
|
|
17
|
+
// change back to the old value replaces the text.
|
|
18
|
+
if (current !== null && current.value !== value)
|
|
19
|
+
setDraft({ ...current, value, committed: null });
|
|
20
|
+
const text = current ? current.text : formatDateEntry(value, pattern);
|
|
21
|
+
const parsed = parseDateEntry(text, pattern);
|
|
22
|
+
function change(next) {
|
|
23
|
+
const nextValue = parseDateEntry(next, pattern);
|
|
24
|
+
const ready = nextValue === '' ||
|
|
25
|
+
(nextValue !== null && isCompleteDateEntry(next, pattern));
|
|
26
|
+
const committed = ready && nextValue !== value ? nextValue : null;
|
|
27
|
+
setDraft({ text: next, value, committed, checked: false });
|
|
28
|
+
if (committed !== null)
|
|
29
|
+
onCommit(committed);
|
|
30
|
+
}
|
|
31
|
+
function check() {
|
|
32
|
+
if (current === null)
|
|
33
|
+
return;
|
|
34
|
+
if (parsed === null) {
|
|
35
|
+
setDraft({ ...current, checked: true });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (parsed === value) {
|
|
39
|
+
// Show the value in the locale's padded form.
|
|
40
|
+
setDraft(null);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Commit once; a parent that keeps the old value keeps the padded text.
|
|
44
|
+
if (parsed !== current.committed)
|
|
45
|
+
onCommit(parsed);
|
|
46
|
+
setDraft({
|
|
47
|
+
text: formatDateEntry(parsed, pattern),
|
|
48
|
+
value,
|
|
49
|
+
committed: parsed,
|
|
50
|
+
checked: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
text,
|
|
55
|
+
parsed,
|
|
56
|
+
invalid: current !== null && current.checked && parsed === null,
|
|
57
|
+
change,
|
|
58
|
+
check,
|
|
59
|
+
reset: () => setDraft(null),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The `locale` prop when given; otherwise the `lang` of the nearest ancestor
|
|
4
|
+
* of `ref`, read when the field mounts, then en-US. A product that switches
|
|
5
|
+
* language without remounting passes `locale`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useLangLocale(locale: string | undefined, ref: RefObject<HTMLElement | null>): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { resolveDateLocale } from '../../date-field-locale.js';
|
|
3
|
+
/**
|
|
4
|
+
* The `locale` prop when given; otherwise the `lang` of the nearest ancestor
|
|
5
|
+
* of `ref`, read when the field mounts, then en-US. A product that switches
|
|
6
|
+
* language without remounting passes `locale`.
|
|
7
|
+
*/
|
|
8
|
+
export function useLangLocale(locale, ref) {
|
|
9
|
+
const [lang, setLang] = useState(null);
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (locale !== undefined)
|
|
12
|
+
return;
|
|
13
|
+
const nearest = ref.current?.closest('[lang]')?.getAttribute('lang');
|
|
14
|
+
setLang(nearest ?? null);
|
|
15
|
+
}, [locale, ref]);
|
|
16
|
+
return resolveDateLocale(locale, lang);
|
|
17
|
+
}
|
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
function markerRules() {
|
|
2
|
+
return `/* A token chevron replaces the user-agent triangle: it points to the inline end when closed and down when open. */
|
|
3
|
+
.sw-accordion-summary::-webkit-details-marker { display: none; }
|
|
4
|
+
|
|
5
|
+
.sw-accordion-marker {
|
|
6
|
+
border-bottom: 2px solid var(--sw-color-muted);
|
|
7
|
+
border-right: 2px solid var(--sw-color-muted);
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
flex: none;
|
|
10
|
+
height: var(--sw-space-2);
|
|
11
|
+
margin-inline-end: var(--sw-space-1);
|
|
12
|
+
transform: rotate(-45deg);
|
|
13
|
+
transition: transform var(--sw-motion-default) var(--sw-motion-easing);
|
|
14
|
+
width: var(--sw-space-2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.sw-accordion-marker:dir(rtl) { transform: rotate(135deg); }
|
|
18
|
+
|
|
19
|
+
.sw-accordion[open] > .sw-accordion-summary .sw-accordion-marker {
|
|
20
|
+
transform: rotate(45deg);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@media (prefers-reduced-motion: reduce) {
|
|
24
|
+
.sw-accordion-marker { transition: none; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@media (forced-colors: active) {
|
|
28
|
+
.sw-accordion-marker { border-color: CanvasText; }
|
|
29
|
+
}`;
|
|
30
|
+
}
|
|
1
31
|
export function cssAccordionClasses() {
|
|
2
32
|
return `.sw-accordion {
|
|
3
33
|
background: var(--sw-glass-fill);
|
|
@@ -9,25 +39,42 @@ export function cssAccordionClasses() {
|
|
|
9
39
|
color: var(--sw-color-text);
|
|
10
40
|
}
|
|
11
41
|
|
|
42
|
+
/* A nested disclosure sits inside a larger surface, so its corners are smaller. */
|
|
43
|
+
.sw-accordion-sm { border-radius: var(--sw-radius-md); }
|
|
44
|
+
|
|
12
45
|
.sw-accordion-summary {
|
|
46
|
+
align-items: center;
|
|
47
|
+
box-sizing: border-box;
|
|
13
48
|
cursor: pointer;
|
|
14
|
-
display:
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: var(--sw-space-3);
|
|
51
|
+
list-style: none;
|
|
15
52
|
min-height: var(--sw-control-md-min-height);
|
|
16
53
|
}
|
|
17
54
|
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
-
}
|
|
55
|
+
/* A nested header is an sm control, still a comfortable pointer and touch target. */
|
|
56
|
+
.sw-accordion-sm > .sw-accordion-summary { min-height: var(--sw-control-sm-min-height); }
|
|
21
57
|
|
|
22
|
-
.sw-accordion-
|
|
23
|
-
|
|
58
|
+
.sw-accordion-heading {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex: 1 1 auto;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
min-width: 0;
|
|
24
63
|
}
|
|
25
64
|
|
|
26
65
|
.sw-accordion-summary:focus-visible {
|
|
27
66
|
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
28
67
|
outline-offset: var(--sw-focus-ring-offset);
|
|
29
|
-
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
${markerRules()}`;
|
|
30
71
|
}
|
|
31
72
|
export function accordionClassCatalog() {
|
|
32
|
-
return [
|
|
73
|
+
return [
|
|
74
|
+
'sw-accordion',
|
|
75
|
+
'sw-accordion-sm',
|
|
76
|
+
'sw-accordion-summary',
|
|
77
|
+
'sw-accordion-heading',
|
|
78
|
+
'sw-accordion-marker',
|
|
79
|
+
];
|
|
33
80
|
}
|