@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/css/css-field.js
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { controlSurface } from './css-document.js';
|
|
2
3
|
const caption = typographyVariants.caption;
|
|
4
|
+
/*
|
|
5
|
+
* A prefix or suffix sits inside the control frame: the wrapper draws the
|
|
6
|
+
* canvas control surface and the focus ring, and the native input inside it
|
|
7
|
+
* drops its own frame so the text and the value read as one control.
|
|
8
|
+
*/
|
|
9
|
+
function adornmentRules() {
|
|
10
|
+
return `.sw-field-adorned {
|
|
11
|
+
${controlSurface}
|
|
12
|
+
align-items: center;
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
cursor: text;
|
|
15
|
+
display: flex;
|
|
16
|
+
gap: var(--sw-space-1);
|
|
17
|
+
min-height: var(--sw-control-md-min-height);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
[data-theme] .sw-field-adorned > input {
|
|
21
|
+
align-self: stretch;
|
|
22
|
+
background: transparent;
|
|
23
|
+
border: 0;
|
|
24
|
+
border-radius: 0;
|
|
25
|
+
flex: 1 1 auto;
|
|
26
|
+
min-height: 0;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
padding-inline: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
[data-theme] .sw-field-adorned > input:focus-visible { outline: none; }
|
|
32
|
+
|
|
33
|
+
.sw-field-adorned:focus-within {
|
|
34
|
+
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
35
|
+
outline-offset: var(--sw-focus-ring-offset);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.sw-field-prefix,
|
|
39
|
+
.sw-field-suffix {
|
|
40
|
+
color: var(--sw-color-muted);
|
|
41
|
+
flex: none;
|
|
42
|
+
user-select: none;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.sw-field-xs .sw-field-adorned {
|
|
47
|
+
font-size: ${caption.fontSize}px;
|
|
48
|
+
letter-spacing: ${caption.letterSpacing}px;
|
|
49
|
+
line-height: ${caption.lineHeight}px;
|
|
50
|
+
min-height: var(--sw-control-xs-min-height);
|
|
51
|
+
padding-inline: var(--sw-control-xs-padding-inline);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.sw-field-invalid .sw-field-adorned { border-color: var(--sw-color-danger); }`;
|
|
55
|
+
}
|
|
3
56
|
export function cssFieldClasses() {
|
|
4
57
|
return `.sw-field-required { color: var(--sw-color-danger); }
|
|
5
58
|
|
|
@@ -17,8 +70,12 @@ export function cssFieldClasses() {
|
|
|
17
70
|
border-color: var(--sw-color-danger);
|
|
18
71
|
}
|
|
19
72
|
|
|
73
|
+
${adornmentRules()}
|
|
74
|
+
|
|
20
75
|
@media (forced-colors: active) {
|
|
21
|
-
[data-theme] .sw-field-invalid :is(input, select, textarea)
|
|
76
|
+
[data-theme] .sw-field-invalid :is(input, select, textarea),
|
|
77
|
+
.sw-field-invalid .sw-field-adorned { border-color: Mark; }
|
|
78
|
+
.sw-field-prefix, .sw-field-suffix { color: CanvasText; }
|
|
22
79
|
.sw-field-error, .sw-field-required { color: Mark; }
|
|
23
80
|
}`;
|
|
24
81
|
}
|
|
@@ -29,5 +86,8 @@ export function fieldClassCatalog() {
|
|
|
29
86
|
'sw-field-description',
|
|
30
87
|
'sw-field-error',
|
|
31
88
|
'sw-field-invalid',
|
|
89
|
+
'sw-field-adorned',
|
|
90
|
+
'sw-field-prefix',
|
|
91
|
+
'sw-field-suffix',
|
|
32
92
|
];
|
|
33
93
|
}
|
package/dist/css/css-select.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { zIndex } from './stacking.js';
|
|
2
3
|
const caption = typographyVariants.caption;
|
|
3
4
|
const label = typographyVariants.label;
|
|
4
5
|
const chevronImage = `background-image:
|
|
@@ -88,7 +89,7 @@ export function cssSelectClasses() {
|
|
|
88
89
|
padding: var(--sw-space-1);
|
|
89
90
|
position: absolute;
|
|
90
91
|
width: max-content;
|
|
91
|
-
|
|
92
|
+
${zIndex('popup')}
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
.sw-select-option {
|
package/dist/css/css-tooltip.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { zIndex } from './stacking.js';
|
|
2
3
|
const caption = typographyVariants.caption;
|
|
3
4
|
export function cssTooltipClasses() {
|
|
4
5
|
return `.sw-tooltip-anchor {
|
|
@@ -21,7 +22,7 @@ export function cssTooltipClasses() {
|
|
|
21
22
|
padding: var(--sw-space-2);
|
|
22
23
|
position: absolute;
|
|
23
24
|
width: max-content;
|
|
24
|
-
|
|
25
|
+
${zIndex('popup')}
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
.sw-tooltip[hidden] { display: none; }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const stackingOrder: {
|
|
2
|
+
readonly stickyCell: 1;
|
|
3
|
+
readonly bottomChrome: 2;
|
|
4
|
+
readonly popupHost: 3;
|
|
5
|
+
readonly topChrome: 4;
|
|
6
|
+
readonly popup: 10;
|
|
7
|
+
};
|
|
8
|
+
export type StackingLayer = keyof typeof stackingOrder;
|
|
9
|
+
export declare function zIndex(layer: StackingLayer): string;
|
|
10
|
+
export declare function cssPopupHostRules(): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The one stacking order of the generated stylesheet. Every generated
|
|
3
|
+
* `z-index` reads a layer from here, lowest first:
|
|
4
|
+
*
|
|
5
|
+
* 1. `stickyCell`: a sticky table header cell or row label over the cells
|
|
6
|
+
* that scroll under it.
|
|
7
|
+
* 2. `bottomChrome`: the sticky ActionBar over the page flow it follows.
|
|
8
|
+
* 3. `popupHost`: a glass surface (glass Card, Accordion) while it holds an
|
|
9
|
+
* open popup. Backdrop blur makes that surface its own stacking context, so
|
|
10
|
+
* its popup can only paint as high as the surface does; lifting the surface
|
|
11
|
+
* over the ActionBar keeps an open list tappable where the bar would cover
|
|
12
|
+
* it, and over the next surface in the flow.
|
|
13
|
+
* 4. `topChrome`: the sticky AppHeader, over a lifted surface that scrolls up
|
|
14
|
+
* under it (the header is the page's way back, and a popup opens downward).
|
|
15
|
+
* 5. `popup`: a popup outside any lifted surface (Select list, ActionMenu
|
|
16
|
+
* list, Tooltip, the DateField and CalendarButton calendar) over
|
|
17
|
+
* everything in the page.
|
|
18
|
+
*
|
|
19
|
+
* Dialog and Toast sit on the browser's top layer, above all of these.
|
|
20
|
+
*/
|
|
21
|
+
export const stackingOrder = {
|
|
22
|
+
stickyCell: 1,
|
|
23
|
+
bottomChrome: 2,
|
|
24
|
+
popupHost: 3,
|
|
25
|
+
topChrome: 4,
|
|
26
|
+
popup: 10,
|
|
27
|
+
};
|
|
28
|
+
export function zIndex(layer) {
|
|
29
|
+
return `z-index: ${stackingOrder[layer]};`;
|
|
30
|
+
}
|
|
31
|
+
const openPopups = [
|
|
32
|
+
'.sw-select-list',
|
|
33
|
+
'.sw-action-menu-list:not([hidden])',
|
|
34
|
+
'.sw-tooltip:not([hidden])',
|
|
35
|
+
// DateField and CalendarButton share it. Rendered only while open; on the
|
|
36
|
+
// top layer where the popover API exists.
|
|
37
|
+
'.sw-date-field-calendar',
|
|
38
|
+
].join(', ');
|
|
39
|
+
const popupHosts = ['.sw-card-glass', '.sw-accordion'];
|
|
40
|
+
export function cssPopupHostRules() {
|
|
41
|
+
const selectors = popupHosts
|
|
42
|
+
.map((host) => `${host}:has(${openPopups})`)
|
|
43
|
+
.join(',\n');
|
|
44
|
+
return `/* Backdrop blur makes a glass surface its own stacking context, so it lifts while it holds an open popup (see stacking.ts). */
|
|
45
|
+
${selectors} {
|
|
46
|
+
position: relative;
|
|
47
|
+
${zIndex('popupHost')}
|
|
48
|
+
}`;
|
|
49
|
+
}
|
package/dist/css/stylesheet.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { cardVariants, darkTheme, lightTheme, typographyVariants, } from '@scalewing/tokens';
|
|
2
|
+
import { actionBarClassCatalog } from './css-action-bar.js';
|
|
2
3
|
import { actionMenuClassCatalog } from './css-action-menu.js';
|
|
3
4
|
import { accordionClassCatalog } from './css-accordion.js';
|
|
4
5
|
import { switchClassCatalog } from './css-switch.js';
|
|
5
6
|
import { dateFieldClassCatalog } from './css-date-field.js';
|
|
7
|
+
import { calendarButtonClassCatalog } from './css-calendar-button.js';
|
|
6
8
|
import { checkboxClassCatalog } from './css-checkbox.js';
|
|
7
9
|
import { radioGroupClassCatalog } from './css-radio-group.js';
|
|
8
10
|
import { spinnerClassCatalog } from './css-spinner.js';
|
|
@@ -17,6 +19,7 @@ import { gridClassCatalog } from './css-grid.js';
|
|
|
17
19
|
import { gridSpanClassCatalog } from './css-grid-span.js';
|
|
18
20
|
import { denominationGridClassCatalog } from './css-denomination-grid.js';
|
|
19
21
|
import { fieldClassCatalog } from './css-field.js';
|
|
22
|
+
import { boxBorderClassCatalog } from './css-box-border.js';
|
|
20
23
|
import { cssComponentClasses } from './css-components.js';
|
|
21
24
|
import { buttonClassCatalog } from './css-button.js';
|
|
22
25
|
import { chartClassCatalog } from './css-chart.js';
|
|
@@ -52,8 +55,10 @@ export function utilityClassCatalog() {
|
|
|
52
55
|
...accordionClassCatalog(),
|
|
53
56
|
...selectClassCatalog(),
|
|
54
57
|
...actionMenuClassCatalog(),
|
|
58
|
+
...actionBarClassCatalog(),
|
|
55
59
|
...switchClassCatalog(),
|
|
56
60
|
...dateFieldClassCatalog(),
|
|
61
|
+
...calendarButtonClassCatalog(),
|
|
57
62
|
...checkboxClassCatalog(),
|
|
58
63
|
...radioGroupClassCatalog(),
|
|
59
64
|
...spinnerClassCatalog(),
|
|
@@ -70,6 +75,7 @@ export function utilityClassCatalog() {
|
|
|
70
75
|
...splitClassCatalog(),
|
|
71
76
|
...toastClassCatalog(),
|
|
72
77
|
...responsiveClassCatalog(),
|
|
78
|
+
...boxBorderClassCatalog(),
|
|
73
79
|
];
|
|
74
80
|
}
|
|
75
81
|
export { generatePaletteStylesheet, paletteStylesheetIds, } from './css-palettes.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DateFieldLabels } from './date-field-labels.js';
|
|
2
|
+
/**
|
|
3
|
+
* The message a date entry reports through `setCustomValidity`, so a form
|
|
4
|
+
* will not submit while it shows something other than an allowed date, as
|
|
5
|
+
* a native date input's `badInput`, `rangeUnderflow` and `rangeOverflow`
|
|
6
|
+
* would block it. `parsed` is the date the text forms, '' when blank, or
|
|
7
|
+
* null when the text is not a date. Empty means valid; `required` covers a
|
|
8
|
+
* blank entry.
|
|
9
|
+
*/
|
|
10
|
+
export declare function dateEntryValidity(parsed: string | null, labels: Pick<DateFieldLabels, 'invalidEntry' | 'outOfRange'>, min?: string, max?: string): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { isOutsideDateRange } from './date-only.js';
|
|
2
|
+
/**
|
|
3
|
+
* The message a date entry reports through `setCustomValidity`, so a form
|
|
4
|
+
* will not submit while it shows something other than an allowed date, as
|
|
5
|
+
* a native date input's `badInput`, `rangeUnderflow` and `rangeOverflow`
|
|
6
|
+
* would block it. `parsed` is the date the text forms, '' when blank, or
|
|
7
|
+
* null when the text is not a date. Empty means valid; `required` covers a
|
|
8
|
+
* blank entry.
|
|
9
|
+
*/
|
|
10
|
+
export function dateEntryValidity(parsed, labels, min, max) {
|
|
11
|
+
if (parsed === null)
|
|
12
|
+
return labels.invalidEntry;
|
|
13
|
+
if (isOutsideDateRange(parsed, min, max))
|
|
14
|
+
return labels.outOfRange;
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type DateEntryPart = 'day' | 'month' | 'year';
|
|
2
|
+
/** How a locale writes a numeric date: field order and the separator. */
|
|
3
|
+
export type DateEntryPattern = {
|
|
4
|
+
order: readonly [DateEntryPart, DateEntryPart, DateEntryPart];
|
|
5
|
+
separator: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Reads the numeric field order and separator from `Intl` for `locale`, for
|
|
9
|
+
* example month/day/year with "/" for en-US and day.month.year for de.
|
|
10
|
+
*/
|
|
11
|
+
export declare function dateEntryPattern(locale: string): DateEntryPattern;
|
|
12
|
+
/** Zero-padded, Latin digits in the pattern's order; empty for no date. */
|
|
13
|
+
export declare function formatDateEntry(value: string, pattern: DateEntryPattern): string;
|
|
14
|
+
/** A hint such as "MM/DD/YYYY" from the consumer's per-part letters. */
|
|
15
|
+
export declare function dateEntryPlaceholder(pattern: DateEntryPattern, letters: Record<DateEntryPart, string>): string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether typed text is finished, so a date it forms can be committed while
|
|
18
|
+
* the person is still typing: eight bare digits, or three fields whose last
|
|
19
|
+
* one is at full width (four year digits, two for a trailing day or month).
|
|
20
|
+
* A shorter last field may still grow, as "2024-03-1" on the way to
|
|
21
|
+
* "2024-03-10", so it waits for blur or Enter instead.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isCompleteDateEntry(text: string, pattern: DateEntryPattern): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Parses typed text in the pattern's order. Day and month take one or two
|
|
26
|
+
* digits, the year exactly four, and any non-digit run separates fields.
|
|
27
|
+
* Eight bare digits split by field width in the same order (MMDDYYYY for
|
|
28
|
+
* en-US). A four-digit first field reads as ISO year-month-day in every
|
|
29
|
+
* locale, since no locale starts a day or month with four digits. Returns
|
|
30
|
+
* the YYYY-MM-DD value, '' for blank text, or null when it is not a date.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseDateEntry(text: string, pattern: DateEntryPattern): string | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { dateFormatter, formatterDate } from './date-formatters.js';
|
|
2
|
+
import { formatDateOnly, isValidDateParts, parseDateOnly, } from './date-only.js';
|
|
3
|
+
const FALLBACK = {
|
|
4
|
+
order: ['month', 'day', 'year'],
|
|
5
|
+
separator: '/',
|
|
6
|
+
};
|
|
7
|
+
function isEntryPart(type) {
|
|
8
|
+
return type === 'day' || type === 'month' || type === 'year';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads the numeric field order and separator from `Intl` for `locale`, for
|
|
12
|
+
* example month/day/year with "/" for en-US and day.month.year for de.
|
|
13
|
+
*/
|
|
14
|
+
export function dateEntryPattern(locale) {
|
|
15
|
+
const parts = dateFormatter(locale, {
|
|
16
|
+
day: '2-digit',
|
|
17
|
+
month: '2-digit',
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
}).formatToParts(formatterDate({ year: 2023, month: 12, day: 31 }));
|
|
20
|
+
const order = parts.map((part) => part.type).filter(isEntryPart);
|
|
21
|
+
const separator = parts.find((part) => part.type === 'literal')?.value;
|
|
22
|
+
if (order.length !== 3 || new Set(order).size !== 3 || !separator?.trim())
|
|
23
|
+
return FALLBACK;
|
|
24
|
+
return {
|
|
25
|
+
order: [order[0], order[1], order[2]],
|
|
26
|
+
separator: separator.trim(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const WIDTH = { day: 2, month: 2, year: 4 };
|
|
30
|
+
/** Zero-padded, Latin digits in the pattern's order; empty for no date. */
|
|
31
|
+
export function formatDateEntry(value, pattern) {
|
|
32
|
+
const parts = parseDateOnly(value);
|
|
33
|
+
if (!parts)
|
|
34
|
+
return '';
|
|
35
|
+
return pattern.order
|
|
36
|
+
.map((part) => String(parts[part]).padStart(WIDTH[part], '0'))
|
|
37
|
+
.join(pattern.separator);
|
|
38
|
+
}
|
|
39
|
+
/** A hint such as "MM/DD/YYYY" from the consumer's per-part letters. */
|
|
40
|
+
export function dateEntryPlaceholder(pattern, letters) {
|
|
41
|
+
return pattern.order.map((part) => letters[part]).join(pattern.separator);
|
|
42
|
+
}
|
|
43
|
+
function splitDigits(text, pattern) {
|
|
44
|
+
const groups = text.match(/\d+/g) ?? [];
|
|
45
|
+
if (groups.length === 3)
|
|
46
|
+
return groups;
|
|
47
|
+
// Eight digits with no separators, as typed on a numeric keypad.
|
|
48
|
+
const digits = groups[0];
|
|
49
|
+
if (groups.length !== 1 || digits?.length !== 8)
|
|
50
|
+
return null;
|
|
51
|
+
let start = 0;
|
|
52
|
+
return pattern.order.map((part) => {
|
|
53
|
+
const group = digits.slice(start, start + WIDTH[part]);
|
|
54
|
+
start += WIDTH[part];
|
|
55
|
+
return group;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const ISO_ORDER = ['year', 'month', 'day'];
|
|
59
|
+
/** The field order typed text uses: ISO when it starts with four digits. */
|
|
60
|
+
function typedOrder(groups, pattern) {
|
|
61
|
+
return groups[0]?.length === 4 ? ISO_ORDER : pattern.order;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether typed text is finished, so a date it forms can be committed while
|
|
65
|
+
* the person is still typing: eight bare digits, or three fields whose last
|
|
66
|
+
* one is at full width (four year digits, two for a trailing day or month).
|
|
67
|
+
* A shorter last field may still grow, as "2024-03-1" on the way to
|
|
68
|
+
* "2024-03-10", so it waits for blur or Enter instead.
|
|
69
|
+
*/
|
|
70
|
+
export function isCompleteDateEntry(text, pattern) {
|
|
71
|
+
const groups = text.match(/\d+/g) ?? [];
|
|
72
|
+
if (groups.length === 1)
|
|
73
|
+
return groups[0]?.length === 8;
|
|
74
|
+
if (groups.length !== 3)
|
|
75
|
+
return false;
|
|
76
|
+
const last = typedOrder(groups, pattern)[2];
|
|
77
|
+
return groups[2]?.length === WIDTH[last];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Parses typed text in the pattern's order. Day and month take one or two
|
|
81
|
+
* digits, the year exactly four, and any non-digit run separates fields.
|
|
82
|
+
* Eight bare digits split by field width in the same order (MMDDYYYY for
|
|
83
|
+
* en-US). A four-digit first field reads as ISO year-month-day in every
|
|
84
|
+
* locale, since no locale starts a day or month with four digits. Returns
|
|
85
|
+
* the YYYY-MM-DD value, '' for blank text, or null when it is not a date.
|
|
86
|
+
*/
|
|
87
|
+
export function parseDateEntry(text, pattern) {
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
if (trimmed === '')
|
|
90
|
+
return '';
|
|
91
|
+
if (/\p{L}/u.test(trimmed))
|
|
92
|
+
return null;
|
|
93
|
+
const groups = splitDigits(trimmed, pattern);
|
|
94
|
+
if (!groups)
|
|
95
|
+
return null;
|
|
96
|
+
const order = typedOrder(groups, pattern);
|
|
97
|
+
const date = { year: 0, month: 0, day: 0 };
|
|
98
|
+
for (const [index, part] of order.entries()) {
|
|
99
|
+
const digits = groups[index] ?? '';
|
|
100
|
+
const width = part === 'year' ? digits.length === 4 : digits.length <= 2;
|
|
101
|
+
if (!width)
|
|
102
|
+
return null;
|
|
103
|
+
date[part] = Number(digits);
|
|
104
|
+
}
|
|
105
|
+
return isValidDateParts(date) ? formatDateOnly(date) : null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Words the DateField control speaks or shows on its own. Products pass
|
|
3
|
+
* their translations; month and weekday names come from `Intl` instead.
|
|
4
|
+
*/
|
|
5
|
+
export type DateFieldLabels = {
|
|
6
|
+
/**
|
|
7
|
+
* Calendar button name; the field label describes the button. A page with
|
|
8
|
+
* several date fields may pass a distinct name per field.
|
|
9
|
+
*/
|
|
10
|
+
chooseDate: string;
|
|
11
|
+
previousMonth: string;
|
|
12
|
+
nextMonth: string;
|
|
13
|
+
/** Month selector name in the calendar header. */
|
|
14
|
+
month: string;
|
|
15
|
+
/** Year selector name in the calendar header. */
|
|
16
|
+
year: string;
|
|
17
|
+
/** Selects today's date and closes the calendar. */
|
|
18
|
+
today: string;
|
|
19
|
+
/** Empties an optional field and closes the calendar. */
|
|
20
|
+
clear: string;
|
|
21
|
+
/**
|
|
22
|
+
* Shown when typed text is not a date; also the entry's form validation
|
|
23
|
+
* message while its text is not a date.
|
|
24
|
+
*/
|
|
25
|
+
invalidEntry: string;
|
|
26
|
+
/**
|
|
27
|
+
* The entry's form validation message while its date, typed or given, is
|
|
28
|
+
* outside `min` and `max`.
|
|
29
|
+
*/
|
|
30
|
+
outOfRange: string;
|
|
31
|
+
/** Placeholder letters, arranged in the locale's field order. */
|
|
32
|
+
dayPlaceholder: string;
|
|
33
|
+
monthPlaceholder: string;
|
|
34
|
+
yearPlaceholder: string;
|
|
35
|
+
};
|
|
36
|
+
export declare const defaultDateFieldLabels: DateFieldLabels;
|
|
37
|
+
/** Consumer labels over the English defaults; empty strings fail closed. */
|
|
38
|
+
export declare function resolveDateFieldLabels(labels?: Partial<DateFieldLabels>): DateFieldLabels;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const defaultDateFieldLabels = {
|
|
2
|
+
chooseDate: 'Choose date',
|
|
3
|
+
previousMonth: 'Previous month',
|
|
4
|
+
nextMonth: 'Next month',
|
|
5
|
+
month: 'Month',
|
|
6
|
+
year: 'Year',
|
|
7
|
+
today: 'Today',
|
|
8
|
+
clear: 'Clear',
|
|
9
|
+
invalidEntry: 'Enter a valid date.',
|
|
10
|
+
outOfRange: 'Choose a date in the allowed range.',
|
|
11
|
+
dayPlaceholder: 'DD',
|
|
12
|
+
monthPlaceholder: 'MM',
|
|
13
|
+
yearPlaceholder: 'YYYY',
|
|
14
|
+
};
|
|
15
|
+
/** Consumer labels over the English defaults; empty strings fail closed. */
|
|
16
|
+
export function resolveDateFieldLabels(labels = {}) {
|
|
17
|
+
const resolved = { ...defaultDateFieldLabels, ...labels };
|
|
18
|
+
for (const [key, text] of Object.entries(resolved))
|
|
19
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
20
|
+
throw new RangeError(`labels.${key} must be non-empty text`);
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Used when neither the `locale` prop nor an ancestor `lang` names one. */
|
|
2
|
+
export declare const DEFAULT_DATE_LOCALE = "en-US";
|
|
3
|
+
/**
|
|
4
|
+
* The locale a date control formats with. An explicit `locale` must be a
|
|
5
|
+
* valid BCP 47 tag, or this throws. Otherwise the nearest `lang` attribute
|
|
6
|
+
* is used when the runtime supports it, then en-US.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveDateLocale(locale: string | undefined, documentLang: string | null | undefined): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Used when neither the `locale` prop nor an ancestor `lang` names one. */
|
|
2
|
+
export const DEFAULT_DATE_LOCALE = 'en-US';
|
|
3
|
+
function supported(tag) {
|
|
4
|
+
try {
|
|
5
|
+
return Intl.DateTimeFormat.supportedLocalesOf([tag]).length > 0;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The locale a date control formats with. An explicit `locale` must be a
|
|
13
|
+
* valid BCP 47 tag, or this throws. Otherwise the nearest `lang` attribute
|
|
14
|
+
* is used when the runtime supports it, then en-US.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveDateLocale(locale, documentLang) {
|
|
17
|
+
if (locale !== undefined) {
|
|
18
|
+
try {
|
|
19
|
+
return Intl.getCanonicalLocales(locale)[0] ?? DEFAULT_DATE_LOCALE;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new RangeError('locale must be a BCP 47 language tag');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const lang = documentLang?.trim();
|
|
26
|
+
return lang && supported(lang) ? lang : DEFAULT_DATE_LOCALE;
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DateParts } from './date-only.js';
|
|
2
|
+
/**
|
|
3
|
+
* One shared `Intl.DateTimeFormat` per locale and options, since building
|
|
4
|
+
* one is slow and a calendar formats dozens of dates per render. Every
|
|
5
|
+
* formatter reads UTC, so a cached one never meets a changed device time
|
|
6
|
+
* zone; pair it with `formatterDate`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function dateFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat;
|
|
9
|
+
/**
|
|
10
|
+
* Noon UTC on a calendar date, for `dateFormatter` only. `setUTCFullYear`
|
|
11
|
+
* keeps years below 100 literal instead of mapping them to the 1900s.
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatterDate({ year, month, day }: DateParts): Date;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const formatters = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* One shared `Intl.DateTimeFormat` per locale and options, since building
|
|
4
|
+
* one is slow and a calendar formats dozens of dates per render. Every
|
|
5
|
+
* formatter reads UTC, so a cached one never meets a changed device time
|
|
6
|
+
* zone; pair it with `formatterDate`.
|
|
7
|
+
*/
|
|
8
|
+
export function dateFormatter(locale, options) {
|
|
9
|
+
const key = `${locale}|${JSON.stringify(options)}`;
|
|
10
|
+
let formatter = formatters.get(key);
|
|
11
|
+
if (formatter === undefined) {
|
|
12
|
+
formatter = new Intl.DateTimeFormat(locale, {
|
|
13
|
+
...options,
|
|
14
|
+
timeZone: 'UTC',
|
|
15
|
+
});
|
|
16
|
+
formatters.set(key, formatter);
|
|
17
|
+
}
|
|
18
|
+
return formatter;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Noon UTC on a calendar date, for `dateFormatter` only. `setUTCFullYear`
|
|
22
|
+
* keeps years below 100 literal instead of mapping them to the 1900s.
|
|
23
|
+
*/
|
|
24
|
+
export function formatterDate({ year, month, day }) {
|
|
25
|
+
const date = new Date(Date.UTC(2000, 0, 1, 12));
|
|
26
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
27
|
+
return date;
|
|
28
|
+
}
|
package/dist/date-only.d.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/** A calendar date with no time and no zone. `month` is 1–12. */
|
|
2
|
+
export type DateParts = {
|
|
3
|
+
year: number;
|
|
4
|
+
month: number;
|
|
5
|
+
day: number;
|
|
6
|
+
};
|
|
7
|
+
/** The first and last dates a four-digit YYYY-MM-DD value can hold. */
|
|
8
|
+
export declare const EARLIEST_DATE_ONLY = "0001-01-01";
|
|
9
|
+
export declare const LATEST_DATE_ONLY = "9999-12-31";
|
|
10
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
11
|
+
export declare function isValidDateParts({ year, month, day }: DateParts): boolean;
|
|
12
|
+
export declare function parseDateOnly(value: string): DateParts | null;
|
|
1
13
|
export declare function isDateOnly(value: string): boolean;
|
|
14
|
+
export declare function formatDateOnly({ year, month, day }: DateParts): string;
|
|
2
15
|
export declare function assertDateOnly(name: string, value: string, empty?: boolean): void;
|
|
16
|
+
/** Validates a picker's value and bounds; an empty value means no selection. */
|
|
17
|
+
export declare function assertDateBounds(value: string, min?: string, max?: string): void;
|
|
18
|
+
/** Valid date-only strings compare lexicographically. */
|
|
3
19
|
export declare function isOutsideDateRange(value: string, min?: string, max?: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Moves a date-only value into `[min, max]` and into the four-digit years.
|
|
22
|
+
* Also accepts a zero-padded year 0 or 10000 string from day arithmetic.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clampDateOnly(value: string, min?: string, max?: string): string;
|
|
25
|
+
/** The date on the device's local wall calendar, never converted through UTC. */
|
|
26
|
+
export declare function todayDateOnly(now?: Date): string;
|
package/dist/date-only.js
CHANGED
|
@@ -1,31 +1,85 @@
|
|
|
1
1
|
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2
|
-
|
|
2
|
+
/** The first and last dates a four-digit YYYY-MM-DD value can hold. */
|
|
3
|
+
export const EARLIEST_DATE_ONLY = '0001-01-01';
|
|
4
|
+
export const LATEST_DATE_ONLY = '9999-12-31';
|
|
5
|
+
export function daysInMonth(year, month) {
|
|
3
6
|
if (month === 2) {
|
|
4
7
|
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
5
8
|
return leap ? 29 : 28;
|
|
6
9
|
}
|
|
7
10
|
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
8
11
|
}
|
|
9
|
-
export function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const day = Number(parts[3]);
|
|
16
|
-
return (year >= 1 &&
|
|
12
|
+
export function isValidDateParts({ year, month, day }) {
|
|
13
|
+
return (Number.isInteger(year) &&
|
|
14
|
+
Number.isInteger(month) &&
|
|
15
|
+
Number.isInteger(day) &&
|
|
16
|
+
year >= 1 &&
|
|
17
|
+
year <= 9999 &&
|
|
17
18
|
month >= 1 &&
|
|
18
19
|
month <= 12 &&
|
|
19
20
|
day >= 1 &&
|
|
20
21
|
day <= daysInMonth(year, month));
|
|
21
22
|
}
|
|
23
|
+
export function parseDateOnly(value) {
|
|
24
|
+
const parts = DATE_ONLY.exec(value);
|
|
25
|
+
if (!parts)
|
|
26
|
+
return null;
|
|
27
|
+
const date = {
|
|
28
|
+
year: Number(parts[1]),
|
|
29
|
+
month: Number(parts[2]),
|
|
30
|
+
day: Number(parts[3]),
|
|
31
|
+
};
|
|
32
|
+
return isValidDateParts(date) ? date : null;
|
|
33
|
+
}
|
|
34
|
+
export function isDateOnly(value) {
|
|
35
|
+
return parseDateOnly(value) !== null;
|
|
36
|
+
}
|
|
37
|
+
export function formatDateOnly({ year, month, day }) {
|
|
38
|
+
const yyyy = String(year).padStart(4, '0');
|
|
39
|
+
const mm = String(month).padStart(2, '0');
|
|
40
|
+
const dd = String(day).padStart(2, '0');
|
|
41
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
42
|
+
}
|
|
22
43
|
export function assertDateOnly(name, value, empty = false) {
|
|
23
44
|
if (empty && value === '')
|
|
24
45
|
return;
|
|
25
46
|
if (!isDateOnly(value))
|
|
26
47
|
throw new RangeError(`${name} must be a valid YYYY-MM-DD date`);
|
|
27
48
|
}
|
|
49
|
+
/** Validates a picker's value and bounds; an empty value means no selection. */
|
|
50
|
+
export function assertDateBounds(value, min, max) {
|
|
51
|
+
assertDateOnly('value', value, true);
|
|
52
|
+
if (min !== undefined)
|
|
53
|
+
assertDateOnly('min', min);
|
|
54
|
+
if (max !== undefined)
|
|
55
|
+
assertDateOnly('max', max);
|
|
56
|
+
if (min !== undefined && max !== undefined && min > max)
|
|
57
|
+
throw new RangeError('min must not be after max');
|
|
58
|
+
}
|
|
59
|
+
/** Valid date-only strings compare lexicographically. */
|
|
28
60
|
export function isOutsideDateRange(value, min, max) {
|
|
29
61
|
return (value !== '' &&
|
|
30
62
|
((min !== undefined && value < min) || (max !== undefined && value > max)));
|
|
31
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Moves a date-only value into `[min, max]` and into the four-digit years.
|
|
66
|
+
* Also accepts a zero-padded year 0 or 10000 string from day arithmetic.
|
|
67
|
+
*/
|
|
68
|
+
export function clampDateOnly(value, min = EARLIEST_DATE_ONLY, max = LATEST_DATE_ONLY) {
|
|
69
|
+
const low = min > EARLIEST_DATE_ONLY ? min : EARLIEST_DATE_ONLY;
|
|
70
|
+
const high = max < LATEST_DATE_ONLY ? max : LATEST_DATE_ONLY;
|
|
71
|
+
// A five-digit year sorts before '9999' as text, so length decides first.
|
|
72
|
+
if (value.length > 10 || value > high)
|
|
73
|
+
return high;
|
|
74
|
+
if (value < low)
|
|
75
|
+
return low;
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
/** The date on the device's local wall calendar, never converted through UTC. */
|
|
79
|
+
export function todayDateOnly(now = new Date()) {
|
|
80
|
+
return formatDateOnly({
|
|
81
|
+
year: now.getFullYear(),
|
|
82
|
+
month: now.getMonth() + 1,
|
|
83
|
+
day: now.getDate(),
|
|
84
|
+
});
|
|
85
|
+
}
|