@astryxdesign/core 0.6.1 → 0.6.2
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 +33 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +16 -9
- package/dist/Dialog/DialogHeader.d.ts +1 -1
- package/dist/Dialog/DialogHeader.d.ts.map +1 -1
- package/dist/Dialog/DialogHeader.js +10 -7
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +9 -3
- package/dist/Markdown/Markdown.d.ts +10 -2
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +58 -14
- package/dist/Markdown/index.d.ts +1 -1
- package/dist/Markdown/index.d.ts.map +1 -1
- package/dist/Markdown/parser.d.ts +126 -12
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +369 -34
- package/dist/Markdown/utils.d.ts +1 -1
- package/dist/Markdown/utils.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +5 -2
- package/dist/Spinner/Spinner.d.ts +1 -1
- package/dist/Spinner/Spinner.d.ts.map +1 -1
- package/dist/Spinner/Spinner.js +23 -15
- package/dist/astryx.css +2 -1
- package/locales/en.json +16 -0
- package/locales/pseudo.json +12 -0
- package/package.json +6 -4
- package/scripts/agent-doc-state.mjs +1 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
- package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
- package/src/DateRangeInput/DateRangeInput.tsx +29 -20
- package/src/Dialog/Dialog.doc.mjs +3 -0
- package/src/Dialog/Dialog.spec.md +1 -1
- package/src/Dialog/DialogHeader.doc.mjs +38 -0
- package/src/Dialog/DialogHeader.test.tsx +49 -0
- package/src/Dialog/DialogHeader.tsx +23 -4
- package/src/Dialog/modules/DialogHeader.spec.md +152 -0
- package/src/FileInput/FileInput.doc.mjs +2 -0
- package/src/FileInput/FileInput.spec.md +199 -0
- package/src/FileInput/FileInput.test.tsx +14 -0
- package/src/FileInput/FileInput.tsx +13 -3
- package/src/Markdown/Markdown.doc.mjs +167 -42
- package/src/Markdown/Markdown.public.test.ts +157 -0
- package/src/Markdown/Markdown.spec.md +149 -70
- package/src/Markdown/Markdown.test.tsx +107 -3
- package/src/Markdown/Markdown.tsx +116 -35
- package/src/Markdown/incremental.test.ts +175 -7
- package/src/Markdown/index.ts +6 -0
- package/src/Markdown/parser.perf.test.ts +3 -1
- package/src/Markdown/parser.test.ts +122 -0
- package/src/Markdown/parser.ts +609 -81
- package/src/Markdown/utils.ts +6 -0
- package/src/Slider/Slider.doc.mjs +16 -0
- package/src/Slider/Slider.spec.md +61 -47
- package/src/Slider/Slider.test.tsx +18 -0
- package/src/Slider/Slider.tsx +12 -6
- package/src/Spinner/Spinner.doc.mjs +6 -3
- package/src/Spinner/Spinner.test.tsx +37 -0
- package/src/Spinner/Spinner.tsx +31 -14
- package/src/theme/derivedVarRegistry.test.ts +6 -4
package/dist/Spinner/Spinner.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Spinner.tsx
|
|
7
|
-
* @input Uses React, StyleX, SVG rendering
|
|
7
|
+
* @input Uses React, i18n (useTranslator), StyleX, SVG rendering
|
|
8
8
|
* @output Exports Spinner component, SpinnerProps, SpinnerSize, SpinnerShade types
|
|
9
9
|
* @position Core implementation of spinner loading indicator
|
|
10
10
|
*
|
|
@@ -21,6 +21,7 @@ import "../theme/tokens.stylex.js";
|
|
|
21
21
|
import { colorVars, durationVars, spacingVars } from "../theme/tokens.stylex.js";
|
|
22
22
|
import { Text } from "../Text/Text.js";
|
|
23
23
|
import { mergeProps } from "../utils/index.js";
|
|
24
|
+
import { useTranslator } from "../i18n/index.js";
|
|
24
25
|
import { themeProps } from "../utils/themeProps.js";
|
|
25
26
|
|
|
26
27
|
// =============================================================================
|
|
@@ -28,21 +29,17 @@ import { themeProps } from "../utils/themeProps.js";
|
|
|
28
29
|
// =============================================================================
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
*
|
|
32
|
-
* swept 135deg, not the 270deg its constant's comment claimed.
|
|
32
|
+
* Default fraction of the ring the moving arc covers. The canvas ring this
|
|
33
|
+
* replaces swept 135deg, not the 270deg its constant's comment claimed.
|
|
34
|
+
*
|
|
35
|
+
* Themeable via `--spinner-arc-fraction`, declared alongside the other public
|
|
36
|
+
* vars in `sizeStyles` below. Only the inline `strokeDasharray` attribute
|
|
37
|
+
* (the pre-stylesheet render — see its own comment) still reads this
|
|
38
|
+
* constant directly; the CSS side composes the dash from the live var.
|
|
33
39
|
*/
|
|
34
40
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
35
41
|
const ARC_FRACTION = 0.375;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The dash pattern, per unit of diameter: one arc, then the gap that closes
|
|
39
|
-
* the circle. The circumference is `pi x diameter`, so multiplying the
|
|
40
|
-
* resolved diameter by these two constants gives exactly the lengths the
|
|
41
|
-
* default render has always used, and scales them with a themed diameter.
|
|
42
|
-
*/
|
|
43
42
|
const PI = 3.141592653589793;
|
|
44
|
-
const ARC_DASH = PI * ARC_FRACTION;
|
|
45
|
-
const ARC_GAP = PI * (1 - ARC_FRACTION);
|
|
46
43
|
const SIZES = {
|
|
47
44
|
sm: {
|
|
48
45
|
diameter: 10,
|
|
@@ -269,25 +266,33 @@ const styles = {
|
|
|
269
266
|
// cost of its own — with nothing declaring the var,
|
|
270
267
|
// `theme-var-reachability.js` cannot find an element to check, so a documented
|
|
271
268
|
// var reads as unreachable.
|
|
269
|
+
// Arc fraction is not itself size-dependent, but it declares alongside the
|
|
270
|
+
// two vars that are, on the same per-size condition (`!hasLabel` gate at the
|
|
271
|
+
// call site) — it needs a home on whichever element carries the theme
|
|
272
|
+
// target, and this is the object already wired to be there.
|
|
272
273
|
const sizeStyles = {
|
|
273
274
|
sm: {
|
|
274
275
|
"--spinner-diameter": "x11wm0hx",
|
|
275
276
|
"--spinner-stroke-width": "xls98ul",
|
|
277
|
+
"--spinner-arc-fraction": "x7o5821",
|
|
276
278
|
$$css: true
|
|
277
279
|
},
|
|
278
280
|
md: {
|
|
279
281
|
"--spinner-diameter": "x15pu9g6",
|
|
280
282
|
"--spinner-stroke-width": "xr0wkrm",
|
|
283
|
+
"--spinner-arc-fraction": "x7o5821",
|
|
281
284
|
$$css: true
|
|
282
285
|
},
|
|
283
286
|
lg: {
|
|
284
287
|
"--spinner-diameter": "x1w424tr",
|
|
285
288
|
"--spinner-stroke-width": "xr0wkrm",
|
|
289
|
+
"--spinner-arc-fraction": "x7o5821",
|
|
286
290
|
$$css: true
|
|
287
291
|
},
|
|
288
292
|
xl: {
|
|
289
293
|
"--spinner-diameter": "x1orj1z9",
|
|
290
294
|
"--spinner-stroke-width": "x7y2bof",
|
|
295
|
+
"--spinner-arc-fraction": "x7o5821",
|
|
291
296
|
$$css: true
|
|
292
297
|
}
|
|
293
298
|
};
|
|
@@ -377,6 +382,7 @@ export function Spinner({
|
|
|
377
382
|
const arcLength = circumference * ARC_FRACTION;
|
|
378
383
|
const hasLabel = label != null;
|
|
379
384
|
const labelId = useId();
|
|
385
|
+
const t = useTranslator();
|
|
380
386
|
|
|
381
387
|
// When a visible string label renders (and no explicit aria-label is set),
|
|
382
388
|
// name the status element from the visible Text via aria-labelledby instead
|
|
@@ -384,8 +390,10 @@ export function Spinner({
|
|
|
384
390
|
// announced twice by screen readers (WCAG 4.1.2).
|
|
385
391
|
const namedByVisibleLabel = hasLabel && typeof label === 'string' && ariaLabel == null;
|
|
386
392
|
|
|
387
|
-
// Resolve accessible name: explicit aria-label > string label >
|
|
388
|
-
|
|
393
|
+
// Resolve accessible name: explicit aria-label > string label > the
|
|
394
|
+
// localized default. The fallback is AT-facing text, so it goes through the
|
|
395
|
+
// translation runtime like visible text does.
|
|
396
|
+
const resolvedAriaLabel = ariaLabel ?? (typeof label === 'string' ? label : undefined) ?? t('@astryx.spinner.loading');
|
|
389
397
|
const spinner = /*#__PURE__*/_jsx("span", {
|
|
390
398
|
ref: hasLabel ? undefined : ref,
|
|
391
399
|
role: "status",
|
|
@@ -440,7 +448,7 @@ export function Spinner({
|
|
|
440
448
|
,
|
|
441
449
|
strokeDasharray: `${arcLength} ${circumference - arcLength}`,
|
|
442
450
|
...{
|
|
443
|
-
className: "xbh8q5q x1764fhq x1g0ag68 x1owpc8m xio8zfp xgw3ha0 xtve3lm x9tu13d
|
|
451
|
+
className: "xbh8q5q x1764fhq x1g0ag68 x1owpc8m xio8zfp xgw3ha0 xtve3lm x9tu13d xdv9ggb"
|
|
444
452
|
}
|
|
445
453
|
})]
|
|
446
454
|
})
|
package/dist/astryx.css
CHANGED
|
@@ -333,6 +333,7 @@
|
|
|
333
333
|
.xnpej1e{--radius-element:var(--radius-full)}
|
|
334
334
|
.x11rj6l1{--selectable-card-ring-color:var(--color-accent)}
|
|
335
335
|
.xkce8z9{--separator-display:flex}
|
|
336
|
+
.x7o5821{--spinner-arc-fraction:.375}
|
|
336
337
|
.x1uzk0gl{--spinner-color:currentColor}
|
|
337
338
|
.xt1b8mc{--spinner-color:var(--color-accent)}
|
|
338
339
|
.x13u6jys{--spinner-color:var(--color-on-dark)}
|
|
@@ -1173,7 +1174,7 @@
|
|
|
1173
1174
|
.x24f09q:not(#\#):not(#\#):not(#\#){scrollbar-gutter:auto}
|
|
1174
1175
|
.xosa0jh:not(#\#):not(#\#):not(#\#){scrollbar-width:auto}
|
|
1175
1176
|
.x1rohswg:not(#\#):not(#\#):not(#\#){scrollbar-width:none}
|
|
1176
|
-
.
|
|
1177
|
+
.xdv9ggb:not(#\#):not(#\#):not(#\#){stroke-dasharray:calc(var(--_spinner-ring-diameter) * 3.141592653589793 * var(--spinner-arc-fraction)) calc(var(--_spinner-ring-diameter) * 3.141592653589793 * (1 - var(--spinner-arc-fraction)))}
|
|
1177
1178
|
.x1owpc8m:not(#\#):not(#\#):not(#\#){stroke-linecap:round}
|
|
1178
1179
|
.x7bo2k:not(#\#):not(#\#):not(#\#){stroke-opacity:.3}
|
|
1179
1180
|
.x1smxkh6:not(#\#):not(#\#):not(#\#){stroke-opacity:.302}
|
package/locales/en.json
CHANGED
|
@@ -447,6 +447,18 @@
|
|
|
447
447
|
"defaultMessage": "Next",
|
|
448
448
|
"description": "Screen-reader-only label on the right-arrow button in a Lightbox (navigates to next media item). Pairs with `lightbox.previous`."
|
|
449
449
|
},
|
|
450
|
+
"@astryx.chatTypingIndicator.one": {
|
|
451
|
+
"defaultMessage": "{name} is typing…",
|
|
452
|
+
"description": "Politely announced status naming the single person currently typing in a chat. `name` is a display name supplied by the app. Present-progressive form; the trailing ellipsis is the single … character."
|
|
453
|
+
},
|
|
454
|
+
"@astryx.chatTypingIndicator.many": {
|
|
455
|
+
"defaultMessage": "{names} are typing…",
|
|
456
|
+
"description": "Politely announced status when more than one person is typing in a chat. `names` is already joined for the locale by Intl.ListFormat, so translations must not add their own conjunction — place `{names}` where the joined list belongs."
|
|
457
|
+
},
|
|
458
|
+
"@astryx.chatTypingIndicator.others": {
|
|
459
|
+
"defaultMessage": "{count, number} {count, plural, one {other} other {others}}",
|
|
460
|
+
"description": "Overflow element for the chat typing status when three or more people type: the first name is shown and the rest collapse into this phrase, which is then joined to the name by Intl.ListFormat. Renders as the last list item, e.g. \"Ana and 2 others\"."
|
|
461
|
+
},
|
|
450
462
|
"@astryx.listInput.emptyTitle": {
|
|
451
463
|
"defaultMessage": "No {itemName}s yet",
|
|
452
464
|
"description": "EmptyState title shown inside a lab ListInput when its collection has no records. `{itemName}` is the consumer's singular noun for one record (e.g. \"guest\"); the source appends a literal \"s\" to pluralize it, which only works for regular English plurals. If your language cannot pluralize an interpolated noun this way, rephrase around `{itemName}` instead (e.g. \"No {itemName} added yet\")."
|
|
@@ -935,6 +947,10 @@
|
|
|
935
947
|
"defaultMessage": "Loading",
|
|
936
948
|
"description": "Screen-reader-only live-region announcement while a Button is in its loading state (spinner shown, action in flight). Progressive sense (\"work in progress\"), not a noun. Kept separate from `typeahead.loading` — translations may diverge."
|
|
937
949
|
},
|
|
950
|
+
"@astryx.spinner.loading": {
|
|
951
|
+
"defaultMessage": "Loading",
|
|
952
|
+
"description": "Screen-reader-only default name for a standalone Spinner's role=\"status\" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge."
|
|
953
|
+
},
|
|
938
954
|
"@astryx.chatComposerDrawer.expand": {
|
|
939
955
|
"defaultMessage": "Expand {label}",
|
|
940
956
|
"description": "Screen-reader-only label on the ChatComposerDrawer toggle when the drawer is collapsed. `{label}` is the drawer's visible name — example: `Expand Attachments`. Pairs with `collapse`."
|
package/locales/pseudo.json
CHANGED
|
@@ -335,6 +335,15 @@
|
|
|
335
335
|
"@astryx.lightbox.next": {
|
|
336
336
|
"defaultMessage": "⟦Ñéẋţ⟧"
|
|
337
337
|
},
|
|
338
|
+
"@astryx.chatTypingIndicator.one": {
|
|
339
|
+
"defaultMessage": "⟦{name} íš ţýþíñĝ…⟧"
|
|
340
|
+
},
|
|
341
|
+
"@astryx.chatTypingIndicator.many": {
|
|
342
|
+
"defaultMessage": "⟦{names} àřé ţýþíñĝ…⟧"
|
|
343
|
+
},
|
|
344
|
+
"@astryx.chatTypingIndicator.others": {
|
|
345
|
+
"defaultMessage": "⟦{count, number} {count, plural, one {other} other {others}}⟧"
|
|
346
|
+
},
|
|
338
347
|
"@astryx.listInput.emptyTitle": {
|
|
339
348
|
"defaultMessage": "⟦Ñó {itemName}š ýéţ⟧"
|
|
340
349
|
},
|
|
@@ -701,6 +710,9 @@
|
|
|
701
710
|
"@astryx.button.loading": {
|
|
702
711
|
"defaultMessage": "⟦Łóàðíñĝ⟧"
|
|
703
712
|
},
|
|
713
|
+
"@astryx.spinner.loading": {
|
|
714
|
+
"defaultMessage": "⟦Łóàðíñĝ⟧"
|
|
715
|
+
},
|
|
704
716
|
"@astryx.chatComposerDrawer.expand": {
|
|
705
717
|
"defaultMessage": "⟦Éẋþàñð {label}⟧"
|
|
706
718
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -672,16 +672,18 @@
|
|
|
672
672
|
"react-dom": ">=19.0.0"
|
|
673
673
|
},
|
|
674
674
|
"devDependencies": {
|
|
675
|
-
"@babel/cli": "^
|
|
675
|
+
"@babel/cli": "^7.29.7",
|
|
676
676
|
"@babel/core": "^7.29.7",
|
|
677
|
-
"@babel/preset-react": "^
|
|
677
|
+
"@babel/preset-react": "^7.29.7",
|
|
678
678
|
"@babel/preset-typescript": "^7.29.7",
|
|
679
|
+
"@heroicons/react": "^2.2.0",
|
|
679
680
|
"@stylexjs/babel-plugin": "^0.19.0",
|
|
680
681
|
"@testing-library/dom": "^10.0.0",
|
|
681
682
|
"@testing-library/jest-dom": "^6.6.0",
|
|
682
683
|
"@testing-library/react": "^16.3.2",
|
|
684
|
+
"@types/babel__core": "^7.20.5",
|
|
683
685
|
"@astryxdesign/a11y-spec": "0.0.0",
|
|
684
|
-
"@astryxdesign/cli": "0.6.
|
|
686
|
+
"@astryxdesign/cli": "0.6.2"
|
|
685
687
|
},
|
|
686
688
|
"dependencies": {
|
|
687
689
|
"intl-messageformat": "^11.2.9"
|
|
@@ -57,7 +57,7 @@ export const LEGACY_MARKER_END = '<!-- XDS:END -->';
|
|
|
57
57
|
* targets are user-chosen and not enumerable here.)
|
|
58
58
|
*/
|
|
59
59
|
export const AGENT_DOC_PATHS = [
|
|
60
|
-
AGENTS_MD, // Codex / ChatGPT / generic
|
|
60
|
+
AGENTS_MD, // Codex / ChatGPT / Muse / generic
|
|
61
61
|
CLAUDE_MD, // Claude Code (root)
|
|
62
62
|
CLAUDE_DIR_MD, // Claude Code (.claude/CLAUDE.md)
|
|
63
63
|
CURSOR_RULES, // Cursor
|
|
@@ -114,7 +114,7 @@ export const docs = {
|
|
|
114
114
|
name: 'presets',
|
|
115
115
|
type: 'Array<DateRangePreset>',
|
|
116
116
|
description:
|
|
117
|
-
'Preset ranges shown as quick-select options beside the calendar.',
|
|
117
|
+
'Preset ranges shown as quick-select options beside the calendar. A preset is disabled when either endpoint violates min, max, or dateConstraints, or when its span violates minRangeSpan or maxRangeSpan.',
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
name: 'hasClear',
|
|
@@ -178,9 +178,21 @@ export const docs = {
|
|
|
178
178
|
],
|
|
179
179
|
theming: {
|
|
180
180
|
targets: [
|
|
181
|
-
{
|
|
181
|
+
{
|
|
182
|
+
className: 'astryx-date-range-input',
|
|
183
|
+
visualProps: ['size', 'status'],
|
|
184
|
+
states: ['disabled'],
|
|
185
|
+
},
|
|
182
186
|
{className: 'astryx-date-range-input-toggle-icon', states: ['state']},
|
|
183
|
-
{
|
|
187
|
+
{
|
|
188
|
+
className: 'astryx-date-range-input-clear-icon',
|
|
189
|
+
deprecatedFor: 'input-clear-icon',
|
|
190
|
+
},
|
|
191
|
+
{className: 'astryx-date-range-input-presets'},
|
|
192
|
+
{
|
|
193
|
+
className: 'astryx-date-range-input-preset',
|
|
194
|
+
states: ['selected', 'disabled'],
|
|
195
|
+
},
|
|
184
196
|
],
|
|
185
197
|
},
|
|
186
198
|
usage: {
|
|
@@ -230,6 +242,12 @@ export const docs = {
|
|
|
230
242
|
description:
|
|
231
243
|
'Text above the trigger describing what date range is expected.',
|
|
232
244
|
},
|
|
245
|
+
{
|
|
246
|
+
name: 'Field surface',
|
|
247
|
+
required: true,
|
|
248
|
+
description:
|
|
249
|
+
'Bordered control containing the calendar toggle, range trigger, and end affordances.',
|
|
250
|
+
},
|
|
233
251
|
{
|
|
234
252
|
name: 'Trigger button',
|
|
235
253
|
required: true,
|
|
@@ -252,6 +270,12 @@ export const docs = {
|
|
|
252
270
|
required: false,
|
|
253
271
|
description: 'A list of preset range options beside the calendar.',
|
|
254
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: 'Preset button',
|
|
275
|
+
required: false,
|
|
276
|
+
description:
|
|
277
|
+
'A quick-select action for one preset range, reflecting current and disabled states.',
|
|
278
|
+
},
|
|
255
279
|
{
|
|
256
280
|
name: 'Clear button',
|
|
257
281
|
required: false,
|
|
@@ -320,7 +344,8 @@ export const docsDense = {
|
|
|
320
344
|
isDisabled: 'disable trigger+picker',
|
|
321
345
|
disabledMessage:
|
|
322
346
|
'reason shown in a tooltip on hover/focus when disabled; keeps trigger focusable via aria-disabled',
|
|
323
|
-
value:
|
|
347
|
+
value:
|
|
348
|
+
'selected range {start, end} or null; import DateRange type from @astryxdesign/core/DateRangeInput (do not redeclare)',
|
|
324
349
|
onChange: 'callback on range change; null on clear',
|
|
325
350
|
min: 'min selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
326
351
|
max: 'max selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
@@ -329,15 +354,18 @@ export const docsDense = {
|
|
|
329
354
|
'max days a range may span, both endpoints counted (7 = a 7-day window); caps the window from the picked start. Selection-only; does not rewrite an over-wide value',
|
|
330
355
|
minRangeSpan:
|
|
331
356
|
'min days a range must span, both endpoints counted (2 forbids a single-day range); repeated start click commits one day when allowed, otherwise cancels; default 1',
|
|
332
|
-
presets:
|
|
357
|
+
presets:
|
|
358
|
+
'preset ranges as quick-select options; disabled when an endpoint or span violates the corresponding constraints',
|
|
333
359
|
hasClear: 'clear button when range is set (default true)',
|
|
334
360
|
placeholder: 'placeholder when empty',
|
|
335
361
|
size: 'trigger size',
|
|
336
362
|
status: 'error/warning/success status',
|
|
337
|
-
statusVariant:
|
|
363
|
+
statusVariant:
|
|
364
|
+
'How status message is placed: attached overlaps below input; detached floats below w/ spacing; tooltip hides the box and shows it on the status icon.',
|
|
338
365
|
labelTooltip: 'tooltip via info icon at label end',
|
|
339
366
|
numberOfMonths: 'months in calendar (default 2)',
|
|
340
|
-
weekStartsOn:
|
|
367
|
+
weekStartsOn:
|
|
368
|
+
'first day of week in calendar (0=Sunday, or name e.g. "mon")',
|
|
341
369
|
changeAction:
|
|
342
370
|
'async action fired after onChange; drives optimistic UI updates via useTransition',
|
|
343
371
|
isLoading: 'loading state; disables interaction + shows a spinner',
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 4
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:DateRangeInput
|
|
6
|
+
authority: current
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-09-14
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [theming]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/DateRangeInput/DateRangeInput.test.tsx,
|
|
16
|
+
packages/core/src/theme/themingTargets.test.ts,
|
|
17
|
+
scripts/check-knowledge.mjs,
|
|
18
|
+
]
|
|
19
|
+
modules: []
|
|
20
|
+
families: [family:input-fields, family:overlay-dismissal]
|
|
21
|
+
design_specs: []
|
|
22
|
+
architecture: [architecture:component-theming-surface]
|
|
23
|
+
contributing: []
|
|
24
|
+
system_specs: []
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# DateRangeInput component contract
|
|
28
|
+
|
|
29
|
+
## Intent
|
|
30
|
+
|
|
31
|
+
DateRangeInput presents one controlled date range through a labeled field surface.
|
|
32
|
+
Its trigger opens a Calendar-backed Popover and may include a list of quick-select
|
|
33
|
+
presets beside that calendar.
|
|
34
|
+
|
|
35
|
+
## Compatibility and migration
|
|
36
|
+
|
|
37
|
+
- Released default preserved: `yes`
|
|
38
|
+
- Compatibility class: additive theme targets plus corrected preset constraint
|
|
39
|
+
enforcement; default appearance, DOM semantics, and public props remain unchanged
|
|
40
|
+
- Controlled/uncontrolled behavior: unchanged; DateRangeInput remains controlled
|
|
41
|
+
- Migration decision: none
|
|
42
|
+
|
|
43
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
44
|
+
|
|
45
|
+
## Ownership boundary
|
|
46
|
+
|
|
47
|
+
**Owns**
|
|
48
|
+
|
|
49
|
+
- The composite date-range field surface and its range-display trigger.
|
|
50
|
+
- The optional preset list, each preset action, and reflection of a preset's current
|
|
51
|
+
and disabled states.
|
|
52
|
+
- Converting a selected Calendar range or preset into `onChange` and
|
|
53
|
+
`changeAction` output.
|
|
54
|
+
|
|
55
|
+
**Does not own / non-goals**
|
|
56
|
+
|
|
57
|
+
- Label, description, and status presentation — owned by `component:Field` and
|
|
58
|
+
`component:FieldStatus`.
|
|
59
|
+
- Calendar-grid rendering and date-cell interaction — owned by
|
|
60
|
+
`component:Calendar`.
|
|
61
|
+
- Layer hosting and dismissal — owned by `component:Popover` and
|
|
62
|
+
`family:overlay-dismissal`.
|
|
63
|
+
- Shared clear-button presentation — owned by `component:Field`.
|
|
64
|
+
|
|
65
|
+
## Public concepts
|
|
66
|
+
|
|
67
|
+
No public prop or value domain is added. This contract records the existing
|
|
68
|
+
preset-list anatomy and its additive theming surface.
|
|
69
|
+
|
|
70
|
+
## Behavioral and layout contract
|
|
71
|
+
|
|
72
|
+
| ID | Candidate invariant | Basis | Review state |
|
|
73
|
+
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------- |
|
|
74
|
+
| FR1 | DateRangeInput MUST present the controlled `value` and emit range changes without maintaining a competing selected range. | Current source, docs, and focused tests | Verified current behavior |
|
|
75
|
+
| FR2 | When presets are present, each preset remains an independent button in one labeled group; the applied preset reflects current state and a preset whose endpoint violates `min`, `max`, or `dateConstraints`, or whose range violates `minRangeSpan` or `maxRangeSpan`, reflects disabled state. | Current source, accessibility comments, and focused tests | Verified current behavior |
|
|
76
|
+
| FR3 | The preset group and each preset button expose stable theme targets; selected and disabled are states of the preset-button target rather than separate targets. | `architecture:component-theming-surface`; #5417 demand | Approved additive contract |
|
|
77
|
+
| FR4 | Adding theme targets MUST NOT change the Popover, Calendar, button, focus, or selection semantics those elements already own. | Composition boundary and compatibility goal | Approved additive contract |
|
|
78
|
+
|
|
79
|
+
### Allowed variation
|
|
80
|
+
|
|
81
|
+
- **AV1 — Preset content.** A caller may omit presets or provide any number of
|
|
82
|
+
labeled ranges.
|
|
83
|
+
- **AV2 — Calendar layout.** Calendar month count, date constraints, and range
|
|
84
|
+
bounds may vary without changing preset target identity.
|
|
85
|
+
- **AV3 — Theme output.** Themes may restyle the preset group and buttons while
|
|
86
|
+
the component's button semantics and state remain unchanged.
|
|
87
|
+
|
|
88
|
+
### Representative states
|
|
89
|
+
|
|
90
|
+
| State | Required invariant | Allowed variation |
|
|
91
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
|
92
|
+
| No presets | No preset group or preset-button target renders. | Calendar configuration |
|
|
93
|
+
| Presets, no match | Every button carries the preset target with no selected state. | Preset count and labels |
|
|
94
|
+
| Applied preset | The matching button carries `aria-current="true"` and the target's selected state. | Selected range |
|
|
95
|
+
| Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is natively disabled and carries the target's disabled state. | Constraint source |
|
|
96
|
+
|
|
97
|
+
### Transformation and precedence order
|
|
98
|
+
|
|
99
|
+
- **ORD1 — Preset state.** Resolve each preset's range once, compare it with the
|
|
100
|
+
controlled value, evaluate `min`, `max`, and `dateConstraints` against both
|
|
101
|
+
endpoints, evaluate `minRangeSpan` and `maxRangeSpan` against the full range,
|
|
102
|
+
then reflect selected and disabled state on the same preset-button target.
|
|
103
|
+
- **ORD2 — Preset activation.** An enabled preset emits the already-resolved range;
|
|
104
|
+
activation does not resolve the preset again.
|
|
105
|
+
|
|
106
|
+
Endpoint constraints match Calendar selection: they apply to the preset's start
|
|
107
|
+
and end, not every date between them.
|
|
108
|
+
|
|
109
|
+
### Performance and resources
|
|
110
|
+
|
|
111
|
+
No new performance or resource constraint is introduced.
|
|
112
|
+
|
|
113
|
+
## Accessibility contract
|
|
114
|
+
|
|
115
|
+
- **AR1 — Preset semantics.** Presets remain native buttons in one labeled group,
|
|
116
|
+
navigated independently by Tab.
|
|
117
|
+
- **AR2 — Current state.** The applied preset remains exposed with
|
|
118
|
+
`aria-current="true"`; theme state reflection is additive.
|
|
119
|
+
- **AR3 — Disabled state.** A preset that violates an endpoint date constraint or
|
|
120
|
+
a range-span constraint remains natively disabled; theme state reflection does
|
|
121
|
+
not replace that behavior.
|
|
122
|
+
|
|
123
|
+
## Design relationships
|
|
124
|
+
|
|
125
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
126
|
+
| ---------------- | --------------------------------------------------------------- | ------------------------------------- | -------------- | ------------------ |
|
|
127
|
+
| Field surface | Presents one coherent input boundary. | Current source and input-field family | Prominent | FR1 |
|
|
128
|
+
| Calendar popover | Provides range selection without changing field ownership. | Popover and Calendar components | Prominent | FR1, FR4 |
|
|
129
|
+
| Preset sidebar | Groups optional shortcuts beside the calendar. | Current source and public docs | Supporting | FR2, FR3 |
|
|
130
|
+
| Preset button | Presents one quick-select range and its current/disabled state. | Current source and public docs | Supporting | FR2–FR4, AR1–AR3 |
|
|
131
|
+
|
|
132
|
+
### Theming anatomy
|
|
133
|
+
|
|
134
|
+
<!-- anatomy-theming:v1 -->
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"Label": {
|
|
139
|
+
"delegatesTo": {"owner": "component:Field", "target": "field-label"}
|
|
140
|
+
},
|
|
141
|
+
"Field surface": {"target": "date-range-input"},
|
|
142
|
+
"Trigger button": {
|
|
143
|
+
"none": {
|
|
144
|
+
"reason": "unsettled: The composite field surface has a target, but the inner range-display button has no separate current target."
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"Calendar icon": {"target": "date-range-input-toggle-icon"},
|
|
148
|
+
"Calendar popover": {
|
|
149
|
+
"delegatesTo": {"owner": "component:Popover", "target": "popover"}
|
|
150
|
+
},
|
|
151
|
+
"Preset sidebar": {"target": "date-range-input-presets"},
|
|
152
|
+
"Preset button": {"target": "date-range-input-preset"},
|
|
153
|
+
"Clear button": {
|
|
154
|
+
"delegatesTo": {"owner": "component:Field", "target": "input-clear-button"}
|
|
155
|
+
},
|
|
156
|
+
"Status message": {
|
|
157
|
+
"delegatesTo": {"owner": "component:FieldStatus", "target": "field-status"}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The deprecated `date-range-input-clear-icon` alias remains compatibility metadata;
|
|
163
|
+
the shared `input-clear-icon` target owns the current glyph contract.
|
|
164
|
+
|
|
165
|
+
## Family and system relationships
|
|
166
|
+
|
|
167
|
+
- `family:input-fields` owns field sizing, status placement, loading, disabled
|
|
168
|
+
reasons, and end-control geometry.
|
|
169
|
+
- `family:overlay-dismissal` owns the Popover dismissal stack.
|
|
170
|
+
- `architecture:component-theming-surface` owns target admission, state
|
|
171
|
+
reflection, and anatomy mapping.
|
|
172
|
+
|
|
173
|
+
## Verification map
|
|
174
|
+
|
|
175
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
176
|
+
| ------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ |
|
|
177
|
+
| FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx` | no match, applied preset, endpoint-invalid preset, span-invalid preset | Semantics or state attributes disappear | `audit:DateRangeInput/presets` |
|
|
178
|
+
| FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | group target, selected button, disabled button | Target class/state is missing, undocumented, or placed on the wrong element | `audit:DateRangeInput/theming` |
|
|
179
|
+
| Theming anatomy map | `scripts/check-knowledge.mjs` | all documented anatomy | Target ownership or anatomy names drift | `audit:DateRangeInput/theming` |
|
|
180
|
+
|
|
181
|
+
## Decision log
|
|
182
|
+
|
|
183
|
+
### DEC-1 — Preset group and buttons are public theme anatomy
|
|
184
|
+
|
|
185
|
+
**Reference:** `component:DateRangeInput/DEC-1`
|
|
186
|
+
**Decider:** cixzhang, 2026-09-14
|
|
187
|
+
|
|
188
|
+
The optional preset sidebar and each quick-select button are stable,
|
|
189
|
+
consumer-recognizable parts. `date-range-input-presets` belongs on the group
|
|
190
|
+
that owns sidebar presentation; `date-range-input-preset` belongs on each
|
|
191
|
+
button, with selected and disabled reflected as states rather than separate
|
|
192
|
+
targets. The target additions preserve default visuals and button semantics;
|
|
193
|
+
preset constraint enforcement follows FR2 and ORD1–ORD2.
|
|
194
|
+
|
|
195
|
+
## Open questions
|
|
196
|
+
|
|
197
|
+
- **OQ1 — Should the inner range-display trigger receive its own target, or remain
|
|
198
|
+
represented only by the composite field surface?** (`human-api`)
|
|
199
|
+
|
|
200
|
+
## Content boundary
|
|
201
|
+
|
|
202
|
+
This file does not duplicate the consumer prop table, usage examples, Calendar or
|
|
203
|
+
Popover contracts, current audit results, or downstream theme implementation.
|
|
@@ -370,10 +370,15 @@ describe('DateRangeInput', () => {
|
|
|
370
370
|
expect(
|
|
371
371
|
screen.queryByRole('listbox', {hidden: true}),
|
|
372
372
|
).not.toBeInTheDocument();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
373
|
+
const group = screen.getByRole('group', {
|
|
374
|
+
name: 'Preset date ranges',
|
|
375
|
+
hidden: true,
|
|
376
|
+
});
|
|
377
|
+
expect(group).toBeInTheDocument();
|
|
378
|
+
expect(group).toHaveClass('astryx-date-range-input-presets');
|
|
379
|
+
expect(getButton('Last 7 days')).toHaveClass(
|
|
380
|
+
'astryx-date-range-input-preset',
|
|
381
|
+
);
|
|
377
382
|
});
|
|
378
383
|
|
|
379
384
|
it('marks the applied preset with aria-current, not aria-selected', () => {
|
|
@@ -386,11 +391,99 @@ describe('DateRangeInput', () => {
|
|
|
386
391
|
/>,
|
|
387
392
|
);
|
|
388
393
|
const active = getButton('Last 7 days');
|
|
394
|
+
expect(active).toHaveClass('astryx-date-range-input-preset');
|
|
395
|
+
expect(active).toHaveAttribute('data-selected', 'selected');
|
|
389
396
|
expect(active).toHaveAttribute('aria-current', 'true');
|
|
390
397
|
expect(active).not.toHaveAttribute('aria-selected');
|
|
391
398
|
const inactive = getButton('This month');
|
|
399
|
+
expect(inactive).toHaveClass('astryx-date-range-input-preset');
|
|
400
|
+
expect(inactive).not.toHaveAttribute('data-selected');
|
|
401
|
+
expect(inactive).not.toHaveAttribute('data-disabled');
|
|
392
402
|
expect(inactive).not.toHaveAttribute('aria-current');
|
|
393
403
|
});
|
|
404
|
+
|
|
405
|
+
it('disables a preset when its start is before min', () => {
|
|
406
|
+
const handleChange = vi.fn();
|
|
407
|
+
render(
|
|
408
|
+
<DateRangeInput
|
|
409
|
+
label="Range"
|
|
410
|
+
value={null}
|
|
411
|
+
onChange={handleChange}
|
|
412
|
+
min="2026-03-02"
|
|
413
|
+
presets={[presets[0]]}
|
|
414
|
+
/>,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const preset = getButton('Last 7 days');
|
|
418
|
+
expect(preset).toBeDisabled();
|
|
419
|
+
fireEvent.click(preset);
|
|
420
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('disables a preset when its end is after max', () => {
|
|
424
|
+
const handleChange = vi.fn();
|
|
425
|
+
render(
|
|
426
|
+
<DateRangeInput
|
|
427
|
+
label="Range"
|
|
428
|
+
value={null}
|
|
429
|
+
onChange={handleChange}
|
|
430
|
+
max="2026-03-06"
|
|
431
|
+
presets={[presets[0]]}
|
|
432
|
+
/>,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const preset = getButton('Last 7 days');
|
|
436
|
+
expect(preset).toBeDisabled();
|
|
437
|
+
fireEvent.click(preset);
|
|
438
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('disables a preset when either endpoint fails dateConstraints', () => {
|
|
442
|
+
const handleChange = vi.fn();
|
|
443
|
+
render(
|
|
444
|
+
<DateRangeInput
|
|
445
|
+
label="Range"
|
|
446
|
+
value={null}
|
|
447
|
+
onChange={handleChange}
|
|
448
|
+
dateConstraints={[date => date.getDate() !== 7]}
|
|
449
|
+
presets={[presets[0]]}
|
|
450
|
+
/>,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const preset = getButton('Last 7 days');
|
|
454
|
+
expect(preset).toBeDisabled();
|
|
455
|
+
fireEvent.click(preset);
|
|
456
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('commits an enabled preset using its already-resolved range', () => {
|
|
460
|
+
const range = {start: '2026-03-01', end: '2026-03-07'} as const;
|
|
461
|
+
const getRange = vi.fn(() => range);
|
|
462
|
+
let getRangeCallsAtChange = 0;
|
|
463
|
+
const handleChange = vi.fn(() => {
|
|
464
|
+
getRangeCallsAtChange = getRange.mock.calls.length;
|
|
465
|
+
});
|
|
466
|
+
render(
|
|
467
|
+
<DateRangeInput
|
|
468
|
+
label="Range"
|
|
469
|
+
value={null}
|
|
470
|
+
onChange={handleChange}
|
|
471
|
+
min="2026-03-01"
|
|
472
|
+
max="2026-03-31"
|
|
473
|
+
dateConstraints={[date => date.getDate() !== 13]}
|
|
474
|
+
minRangeSpan={2}
|
|
475
|
+
maxRangeSpan={7}
|
|
476
|
+
presets={[{label: 'Allowed range', getRange}]}
|
|
477
|
+
/>,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const getRangeCallsBeforeClick = getRange.mock.calls.length;
|
|
481
|
+
const preset = getButton('Allowed range');
|
|
482
|
+
expect(preset).not.toBeDisabled();
|
|
483
|
+
fireEvent.click(preset);
|
|
484
|
+
expect(handleChange).toHaveBeenCalledWith(range);
|
|
485
|
+
expect(getRangeCallsAtChange).toBe(getRangeCallsBeforeClick);
|
|
486
|
+
});
|
|
394
487
|
});
|
|
395
488
|
describe('disabledMessage', () => {
|
|
396
489
|
// jsdom does not implement the Popover API used by the tooltip, so mock
|
|
@@ -831,7 +924,10 @@ describe('DateRangeInput range-span forwarding', () => {
|
|
|
831
924
|
const withinCap = getButton('Last 3 days');
|
|
832
925
|
const overCap = getButton('Last 30 days');
|
|
833
926
|
expect(withinCap).not.toBeDisabled();
|
|
927
|
+
expect(withinCap).not.toHaveAttribute('data-disabled');
|
|
834
928
|
expect(overCap).toBeDisabled();
|
|
929
|
+
expect(overCap).toHaveClass('astryx-date-range-input-preset');
|
|
930
|
+
expect(overCap).toHaveAttribute('data-disabled', 'disabled');
|
|
835
931
|
|
|
836
932
|
fireEvent.click(overCap);
|
|
837
933
|
expect(handleChange).not.toHaveBeenCalled();
|