@astryxdesign/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  3. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  4. package/dist/ContextMenu/ContextMenu.js +2 -2
  5. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  6. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  7. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  8. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  9. package/dist/Layout/Layout.d.ts +10 -1
  10. package/dist/Layout/Layout.d.ts.map +1 -1
  11. package/dist/Layout/Layout.js +5 -1
  12. package/dist/Outline/Outline.d.ts +3 -2
  13. package/dist/Outline/Outline.d.ts.map +1 -1
  14. package/dist/Outline/Outline.js +23 -4
  15. package/dist/Outline/useScrollSpy.d.ts +14 -1
  16. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  17. package/dist/Outline/useScrollSpy.js +161 -50
  18. package/dist/Resizable/useResizable.d.ts.map +1 -1
  19. package/dist/Resizable/useResizable.js +1 -5
  20. package/dist/Selector/Selector.d.ts.map +1 -1
  21. package/dist/Selector/Selector.js +1 -1
  22. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  23. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  24. package/dist/ToggleButton/ToggleButton.js +64 -18
  25. package/dist/theme/Theme.js +1 -1
  26. package/dist/theme/defineTheme.d.ts +1 -1
  27. package/dist/theme/defineTheme.d.ts.map +1 -1
  28. package/dist/theme/defineTheme.js +1 -1
  29. package/dist/theme/index.d.ts +1 -1
  30. package/dist/theme/index.d.ts.map +1 -1
  31. package/dist/theme/index.js +1 -1
  32. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  33. package/dist/theme/tokens.d.ts +1 -1
  34. package/dist/theme/tokens.js +4 -4
  35. package/dist/theme/useTheme.d.ts +2 -2
  36. package/dist/utils/dateParser.d.ts.map +1 -1
  37. package/dist/utils/dateParser.js +15 -2
  38. package/package.json +2 -2
  39. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  40. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  41. package/src/ContextMenu/ContextMenu.tsx +2 -2
  42. package/src/DateInput/DateInput.test.tsx +68 -20
  43. package/src/Divider/Divider.doc.mjs +1 -1
  44. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  45. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  46. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  47. package/src/Icon/Icon.doc.mjs +4 -4
  48. package/src/Item/Item.doc.mjs +2 -2
  49. package/src/Layout/Layout.doc.mjs +2 -1
  50. package/src/Layout/Layout.tsx +15 -1
  51. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  52. package/src/Link/Link.doc.mjs +3 -3
  53. package/src/Link/LinkProvider.doc.mjs +3 -3
  54. package/src/Markdown/Markdown.doc.mjs +4 -4
  55. package/src/Outline/Outline.doc.mjs +1 -1
  56. package/src/Outline/Outline.test.tsx +76 -38
  57. package/src/Outline/Outline.tsx +23 -4
  58. package/src/Outline/useScrollSpy.ts +196 -63
  59. package/src/Resizable/Resizable.doc.mjs +2 -2
  60. package/src/Resizable/useResizable.ts +1 -7
  61. package/src/Selector/Selector.tsx +5 -6
  62. package/src/Table/Table.doc.mjs +3 -3
  63. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  64. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  65. package/src/ToggleButton/ToggleButton.tsx +83 -20
  66. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  67. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  68. package/src/hooks/useStreamingText.doc.mjs +3 -3
  69. package/src/theme/Theme.doc.mjs +2 -2
  70. package/src/theme/Theme.tsx +1 -1
  71. package/src/theme/defineTheme.ts +1 -1
  72. package/src/theme/index.ts +1 -1
  73. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  74. package/src/theme/tokens.ts +4 -4
  75. package/src/theme/useTheme.ts +2 -2
  76. package/src/utils/dateParser.test.ts +26 -0
  77. package/src/utils/dateParser.ts +16 -2
  78. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -20,7 +20,14 @@
20
20
  * - /packages/cli/templates/blocks/components/ToggleButton/ (showcase blocks)
21
21
  */
22
22
 
23
- import React, {useCallback, type ReactNode} from 'react';
23
+ import React, {
24
+ useCallback,
25
+ useEffect,
26
+ useOptimistic,
27
+ useState,
28
+ useTransition,
29
+ type ReactNode,
30
+ } from 'react';
24
31
  import * as stylex from '@stylexjs/stylex';
25
32
  import {colorVars, fontWeightVars} from '../theme/tokens.stylex';
26
33
 
@@ -29,6 +36,37 @@ import {useToggleButtonGroup} from './ToggleButtonGroup';
29
36
  import type {BaseProps} from '../BaseProps';
30
37
  import {themeProps} from '../utils/themeProps';
31
38
 
39
+ // =============================================================================
40
+ // Constants & helpers
41
+ // =============================================================================
42
+
43
+ /**
44
+ * The spinner only appears once the action has been pending for this long.
45
+ * A fast action shows the optimistic pressed state immediately with no spinner
46
+ * flash, and rapid re-clicks can interrupt the in-flight action before the
47
+ * button locks behind the spinner.
48
+ */
49
+ const PENDING_SPINNER_DELAY_MS = 150;
50
+
51
+ /**
52
+ * Returns `true` only once `active` has stayed `true` for `delayMs`.
53
+ * Used to debounce the loading spinner so the optimistic state shows first.
54
+ */
55
+ function useDelayed(active: boolean, delayMs: number): boolean {
56
+ const [delayed, setDelayed] = useState(false);
57
+ useEffect(() => {
58
+ if (!active) {
59
+ return undefined;
60
+ }
61
+ const timer = setTimeout(() => setDelayed(true), delayMs);
62
+ return () => {
63
+ clearTimeout(timer);
64
+ setDelayed(false);
65
+ };
66
+ }, [active, delayMs]);
67
+ return active && delayed;
68
+ }
69
+
32
70
  // =============================================================================
33
71
  // Styles
34
72
  // =============================================================================
@@ -91,8 +129,15 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
91
129
  onPressedChange?: (isPressed: boolean) => void;
92
130
 
93
131
  /**
94
- * Async action handler for API-backed toggles.
95
- * The button shows a loading spinner while the promise is pending.
132
+ * Action handler for API- or navigation-backed toggles, run inside a
133
+ * transition. The button shows a loading spinner while the action is
134
+ * pending — whether it returns a promise or synchronously triggers a
135
+ * suspending update (e.g. a router navigation that suspends on data).
136
+ *
137
+ * Because it runs in a transition, the toggle is *interruptible*: clicking
138
+ * again while an action is pending starts a new transition with the next
139
+ * optimistic state, so the action reflects the latest intent rather than
140
+ * being dropped.
96
141
  *
97
142
  * @example
98
143
  * ```
@@ -106,7 +151,7 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
106
151
  * />
107
152
  * ```
108
153
  */
109
- pressedChangeAction?: (isPressed: boolean) => Promise<void>;
154
+ pressedChangeAction?: (isPressed: boolean) => void | Promise<void>;
110
155
 
111
156
  /**
112
157
  * The size of the toggle button.
@@ -218,46 +263,64 @@ export function ToggleButton({
218
263
  style,
219
264
  ...props
220
265
  }: ToggleButtonProps): ReactNode {
221
- // Read group context if inside a group
222
266
  const group = useToggleButtonGroup();
223
267
 
224
- // Resolve state from group or props
225
- const isPressed =
268
+ const committedPressed =
226
269
  group && value != null
227
270
  ? group.selectedValues.has(value)
228
271
  : (isPressedProp ?? false);
229
272
  const size = sizeProp ?? group?.size ?? 'md';
230
273
  const isDisabled = group?.isDisabled ?? isDisabledProp;
231
274
 
275
+ // Track the pressed state optimistically. While an action is pending, the
276
+ // button reflects the intended (optimistic) state immediately, and a click
277
+ // mid-flight derives its next state from this value — so rapid toggles read
278
+ // true -> false -> true rather than stalling on the last committed value.
279
+ const [optimisticPressed, setOptimisticPressed] =
280
+ useOptimistic(committedPressed);
281
+ const isPressed = optimisticPressed;
282
+
232
283
  const resolvedIcon = isPressed && pressedIcon ? pressedIcon : icon;
233
284
 
285
+ // Run the toggle inside a transition. The action is interruptible: clicking
286
+ // again while it is pending starts a fresh transition with the next
287
+ // optimistic state instead of being dropped, so there is no re-entry guard.
288
+ // Both onPressedChange and pressedChangeAction run inside the transition,
289
+ // which means a synchronous-but-suspending handler (e.g. a router navigation
290
+ // that suspends on data) also drives the pending state — not just promises.
291
+ const [isPending, startTransition] = useTransition();
292
+ // Debounce the spinner so a fast action shows the optimistic state without a
293
+ // spinner flash, and rapid re-clicks can interrupt before the button locks.
294
+ const showSpinner = useDelayed(isPending, PENDING_SPINNER_DELAY_MS);
295
+ const isLoadingState = isLoading || showSpinner;
296
+
234
297
  const handleClick = useCallback(() => {
235
- if (isDisabled || isLoading) {
298
+ if (isDisabled) {
236
299
  return;
237
300
  }
238
301
 
239
302
  if (group && value != null) {
240
- // Delegate to group context
303
+ // Group mode delegates selection to the group; no async-action path.
241
304
  group.toggle(value);
242
- } else if (onPressedChangeProp) {
243
- // Standalone toggle
244
- const newState = !isPressed;
245
- onPressedChangeProp(newState);
246
- if (pressedChangeAction) {
247
- void pressedChangeAction(newState);
248
- }
305
+ return;
249
306
  }
307
+
308
+ const newState = !optimisticPressed;
309
+ startTransition(async () => {
310
+ setOptimisticPressed(newState);
311
+ onPressedChangeProp?.(newState);
312
+ await pressedChangeAction?.(newState);
313
+ });
250
314
  }, [
251
315
  isDisabled,
252
- isLoading,
253
316
  group,
254
317
  value,
318
+ optimisticPressed,
255
319
  onPressedChangeProp,
256
320
  pressedChangeAction,
257
- isPressed,
321
+ setOptimisticPressed,
258
322
  ]);
259
323
 
260
- // Label with font weight shift and width reservation
261
324
  // isIconOnly prop is the source of truth for icon-only rendering.
262
325
  const labelContent =
263
326
  children != null ? (
@@ -289,7 +352,7 @@ export function ToggleButton({
289
352
  variant="ghost"
290
353
  size={size}
291
354
  isDisabled={isDisabled}
292
- isLoading={isLoading}
355
+ isLoading={isLoadingState}
293
356
  isIconOnly={isIconOnly}
294
357
  aria-pressed={isPressed}
295
358
  icon={resolvedIcon}
@@ -23,7 +23,7 @@ export const docs = {
23
23
  ],
24
24
  usage: {
25
25
  description:
26
- 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses XDS motion tokens (duration, easing) for consistent animation timing. Requires "use client"; does not support SSR.',
26
+ 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing. Requires "use client"; does not support SSR.',
27
27
  bestPractices: [
28
28
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, or expanding sections.' },
29
29
  { guidance: true, description: 'Spread the returned style into stylex.props() alongside other styles.' },
@@ -39,7 +39,7 @@ export const docs = {
39
39
  /** @type {import('../docs-types').HookTranslationDoc} */
40
40
  export const docsDense = {
41
41
  description:
42
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses XDS motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
42
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
43
43
  paramDescriptions: {
44
44
  preset: 'animation preset applied on mount.',
45
45
  },
@@ -48,7 +48,7 @@ export const docsDense = {
48
48
  },
49
49
  usage: {
50
50
  description:
51
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses XDS motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
51
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
52
52
  bestPractices: [
53
53
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, expanding sections.' },
54
54
  { guidance: true, description: 'Spread returned style into stylex.props() alongside other styles.' },
@@ -24,7 +24,7 @@ export const docs = {
24
24
  description: 'SSR-safe media query hook that subscribes to window.matchMedia changes. Returns whether the given media query matches. Always returns false on first render for SSR compatibility.',
25
25
  bestPractices: [
26
26
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
27
- { guidance: true, description: 'Prefer XDS responsive tokens and component props over manual breakpoint logic when possible.' },
27
+ { guidance: true, description: 'Prefer Astryx responsive tokens and component props over manual breakpoint logic when possible.' },
28
28
  { guidance: false, description: 'Use for server-rendered content that must match on first paint; the hook always returns false initially.' },
29
29
  ],
30
30
  },
@@ -47,7 +47,7 @@ export const docsDense = {
47
47
  description: 'SSR-safe media query hook subscribing to window.matchMedia changes. Returns whether given media query matches. Always returns false on first render for SSR compatibility.',
48
48
  bestPractices: [
49
49
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
50
- { guidance: true, description: 'Prefer XDS responsive tokens + component props over manual breakpoint logic when possible.' },
50
+ { guidance: true, description: 'Prefer Astryx responsive tokens + component props over manual breakpoint logic when possible.' },
51
51
  { guidance: false, description: 'Use for server-rendered content that must match on first paint; hook always returns false initially.' },
52
52
  ],
53
53
  },
@@ -41,7 +41,7 @@ export const docs = {
41
41
  ],
42
42
  usage: {
43
43
  description:
44
- 'Smooths bursty streamed text into a steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word and syntax boundaries to avoid slicing mid-markdown or mid-word, preventing visual glitches with markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, with sensible fallbacks outside a theme provider. Snaps to full text when isStreaming becomes false.',
44
+ 'Smooths bursty streamed text into a steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word and syntax boundaries to avoid slicing mid-markdown or mid-word, preventing visual glitches with markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, with sensible fallbacks outside a theme provider. Snaps to full text when isStreaming becomes false.',
45
45
  bestPractices: [
46
46
  { guidance: true, description: 'Pass the accumulated text (not individual chunks) as targetText; the hook handles incremental reveal internally.' },
47
47
  { guidance: true, description: 'Set isStreaming to false when the stream completes to snap to the final text.' },
@@ -58,7 +58,7 @@ export const docs = {
58
58
  /** @type {import('../docs-types').HookTranslationDoc} */
59
59
  export const docsDense = {
60
60
  description:
61
- 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
61
+ 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
62
62
  paramDescriptions: {
63
63
  targetText: 'full target text to reveal. As new chunks arrive, update this value w/ accumulated text.',
64
64
  isStreaming: 'whether text currently being streamed. When false, hook returns full targetText immediately.',
@@ -70,7 +70,7 @@ export const docsDense = {
70
70
  },
71
71
  usage: {
72
72
  description:
73
- 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
73
+ 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
74
74
  bestPractices: [
75
75
  { guidance: true, description: 'Pass accumulated text (not individual chunks) as targetText; hook handles incremental reveal internally.' },
76
76
  { guidance: true, description: 'Set isStreaming to false when stream completes to snap to final text.' },
@@ -39,7 +39,7 @@ export const docs = {
39
39
  },
40
40
  usage: {
41
41
  description:
42
- 'Wraps a subtree with a specific XDS theme. For static production themes, use `npx astryx theme build` and import the generated CSS plus built theme object for first-paint and SSR performance. Use runtime `defineTheme()` when themes are dynamic or for prototyping.\n\n`defineTheme` accepts a `tokens` object whose keys are CSS custom property names (always prefixed with `--`). Common token names include `--color-accent`, `--color-background-surface`, `--color-background-body`, `--color-text-primary`, `--color-text-secondary`, `--radius-container`, `--spacing-1` through `--spacing-6`. Values can be a string (same for light/dark) or a `[light, dark]` tuple.\n\nExample:\n```ts\nimport {defineTheme} from \'@astryxdesign/core/theme\';\nconst myTheme = defineTheme({\n name: \'ocean\',\n tokens: {\n \'--color-accent\': [\'#0077B6\', \'#48CAE4\'],\n \'--color-background-surface\': [\'#F0F8FF\', \'#0A1628\'],\n \'--color-text-primary\': [\'#0A1317\', \'#FFFFFF\'],\n \'--radius-container\': \'16px\',\n },\n});\n```',
42
+ 'Wraps a subtree with a specific Astryx theme. For static production themes, use `npx astryx theme build` and import the generated CSS plus built theme object for first-paint and SSR performance. Use runtime `defineTheme()` when themes are dynamic or for prototyping.\n\n`defineTheme` accepts a `tokens` object whose keys are CSS custom property names (always prefixed with `--`). Common token names include `--color-accent`, `--color-background-surface`, `--color-background-body`, `--color-text-primary`, `--color-text-secondary`, `--radius-container`, `--spacing-1` through `--spacing-6`. Values can be a string (same for light/dark) or a `[light, dark]` tuple.\n\nExample:\n```ts\nimport {defineTheme} from \'@astryxdesign/core/theme\';\nconst myTheme = defineTheme({\n name: \'ocean\',\n tokens: {\n \'--color-accent\': [\'#0077B6\', \'#48CAE4\'],\n \'--color-background-surface\': [\'#F0F8FF\', \'#0A1628\'],\n \'--color-text-primary\': [\'#0A1317\', \'#FFFFFF\'],\n \'--radius-container\': \'16px\',\n },\n});\n```',
43
43
  bestPractices: [
44
44
  {
45
45
  guidance: true,
@@ -90,7 +90,7 @@ export const docs = {
90
90
  export const docsDense = {
91
91
  usage: {
92
92
  description:
93
- 'Wraps subtree w/ specific XDS theme. For static production themes, use `npx astryx theme build` + generated CSS + built theme object for first-paint/SSR performance. Use runtime `defineTheme()` for dynamic themes or prototyping. Token names always start with `--` (e.g. `--color-accent`, `--color-background-surface`).',
93
+ 'Wraps subtree w/ specific Astryx theme. For static production themes, use `npx astryx theme build` + generated CSS + built theme object for first-paint/SSR performance. Use runtime `defineTheme()` for dynamic themes or prototyping. Token names always start with `--` (e.g. `--color-accent`, `--color-background-surface`).',
94
94
  bestPractices: [
95
95
  {
96
96
  guidance: true,
@@ -123,7 +123,7 @@ function useThemeStyleInjection(theme: DefinedTheme): void {
123
123
  if (!warnedThemes.has(theme.name)) {
124
124
  warnedThemes.add(theme.name);
125
125
  console.warn(
126
- `[XDS] Theme "${theme.name}" is using runtime style injection. ` +
126
+ `[Astryx] Theme "${theme.name}" is using runtime style injection. ` +
127
127
  `For better performance, use the pre-built theme:\n\n` +
128
128
  ` import {${theme.name}Theme} from '@astryxdesign/theme-${theme.name}/built';\n` +
129
129
  ` import '@astryxdesign/theme-${theme.name}/theme.css';\n\n` +
@@ -361,7 +361,7 @@ export interface DefinedTheme {
361
361
  // =============================================================================
362
362
 
363
363
  /** All Astryx token defaults as a flat map. Useful for resolving full token sets. */
364
- export const xdsTokenDefaults: Record<string, string> = {
364
+ export const tokenDefaults: Record<string, string> = {
365
365
  ...colorDefaults,
366
366
  ...spacingDefaults,
367
367
  ...sizeDefaults,
@@ -28,7 +28,7 @@ export {
28
28
  type ThemeCSSOutput,
29
29
  type ThemeRulesSplit,
30
30
  isDefinedTheme,
31
- xdsTokenDefaults,
31
+ tokenDefaults,
32
32
  } from './defineTheme';
33
33
  export type {
34
34
  DefineThemeInput,
@@ -134,7 +134,7 @@ export function defineSyntaxTheme(input: SyntaxThemeInput): SyntaxThemeDefinitio
134
134
  const missing = ALL_SYNTAX_KEYS.filter(key => !(key in input.tokens));
135
135
  if (missing.length > 0) {
136
136
  console.warn(
137
- '[XDS] defineSyntaxTheme("' + input.name + '"): missing tokens: ' +
137
+ '[Astryx] defineSyntaxTheme("' + input.name + '"): missing tokens: ' +
138
138
  missing.join(', ') + '. All 14 syntax tokens are required.',
139
139
  );
140
140
  }
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import {
19
- xdsTokenDefaults,
19
+ tokenDefaults,
20
20
  type DefinedTheme,
21
21
  type TokenName,
22
22
  type TokenValue,
@@ -60,7 +60,7 @@ export function tokenVar(name: TokenName | (string & {})): string {
60
60
 
61
61
  /** Flat map of every known Astryx token name to its `var(--token-name)` reference. */
62
62
  export const tokenVars: Record<TokenName, string> = Object.fromEntries(
63
- Object.keys(xdsTokenDefaults).map(name => [name, tokenVar(name)]),
63
+ Object.keys(tokenDefaults).map(name => [name, tokenVar(name)]),
64
64
  ) as Record<TokenName, string>;
65
65
 
66
66
  /**
@@ -147,7 +147,7 @@ function resolveXDSTokenValue(
147
147
  /**
148
148
  * Resolve all Astryx token values for a theme and effective color mode.
149
149
  *
150
- * The result starts with `xdsTokenDefaults`, applies `theme.tokens`, then
150
+ * The result starts with `tokenDefaults`, applies `theme.tokens`, then
151
151
  * reapplies `theme.__inputTokens` when available so explicit tuple overrides
152
152
  * retain their original light/dark sides instead of relying on CSS parsing.
153
153
  * This mirrors the token resolution used by `useTheme()` but does not need
@@ -162,7 +162,7 @@ export function resolveThemeTokens(
162
162
  const {mode} = options;
163
163
  const resolved: Record<string, string> = {};
164
164
 
165
- for (const [key, value] of Object.entries(xdsTokenDefaults)) {
165
+ for (const [key, value] of Object.entries(tokenDefaults)) {
166
166
  resolved[key] = resolveXDSTokenValue(value, mode);
167
167
  }
168
168
 
@@ -63,7 +63,7 @@ export interface UseThemeReturn {
63
63
  * For tokens with [light, dark] tuples, returns the value matching
64
64
  * the current mode. For single-value tokens, returns the value as-is.
65
65
  *
66
- * Falls back to xdsTokenDefaults if the token isn't overridden by the theme.
66
+ * Falls back to tokenDefaults if the token isn't overridden by the theme.
67
67
  *
68
68
  * @example
69
69
  * ```
@@ -75,7 +75,7 @@ export interface UseThemeReturn {
75
75
  /**
76
76
  * All tokens resolved for the current color mode.
77
77
  *
78
- * Merges xdsTokenDefaults with the theme's overrides, resolving
78
+ * Merges tokenDefaults with the theme's overrides, resolving
79
79
  * light-dark() values based on the effective color mode.
80
80
  *
81
81
  * Memoized — stable reference unless theme or mode changes.
@@ -320,5 +320,31 @@ describe('parseDateInput', () => {
320
320
  it('rejects mixed separators', () => {
321
321
  expect(parseDateInput('1/25.2026')).toBeNull();
322
322
  });
323
+
324
+ it('treats a single typed digit as incomplete, not a date', () => {
325
+ // A user starting to type a month (e.g. "0" or "1" for January) should
326
+ // not produce a date. Native Date parsing would otherwise coerce these
327
+ // into arbitrary dates (and a year of 0 in some engines).
328
+ expect(parseDateInput('0')).toBeNull();
329
+ expect(parseDateInput('1')).toBeNull();
330
+ });
331
+
332
+ it('treats bare numeric input as incomplete, not a date', () => {
333
+ expect(parseDateInput('00')).toBeNull();
334
+ expect(parseDateInput('01')).toBeNull();
335
+ expect(parseDateInput('12')).toBeNull();
336
+ expect(parseDateInput('2026')).toBeNull();
337
+ });
338
+
339
+ it('never returns an out-of-range date for partial input', () => {
340
+ // Regression: partial input must never yield a date with year < 1,
341
+ // which would throw when later re-parsed and crash the page.
342
+ for (const input of ['0', '1', '01', '00', '9', '99']) {
343
+ const result = parseDateInput(input);
344
+ if (result !== null) {
345
+ expect(result.year).toBeGreaterThanOrEqual(1);
346
+ }
347
+ }
348
+ });
323
349
  });
324
350
  });
@@ -124,10 +124,24 @@ export function parseDateInput(input: string): PlainDate | null {
124
124
  return parseNumericDate(+first, +second, currentYear);
125
125
  }
126
126
 
127
- // 6. Fall back to native Date parsing for other formats
127
+ // 6. Fall back to native Date parsing for other formats.
128
+ //
129
+ // Skip bare numeric input (e.g. "0", "1", "01", "2026"). These are
130
+ // in-progress values a user is still typing, not complete dates. Native
131
+ // `Date` parsing coerces them into arbitrary dates ("0" -> year 2000 in V8,
132
+ // year 0 in some engines), which is both surprising and — when the year
133
+ // resolves to 0 — produces an out-of-range date that throws downstream.
134
+ // Treat them as not-yet-a-valid-date instead.
135
+ if (/^\d+$/.test(trimmed)) {
136
+ return null;
137
+ }
138
+
128
139
  const parsed = new Date(trimmed);
129
140
  if (!isNaN(parsed.getTime())) {
130
- return plainDateFromDate(parsed);
141
+ const fromDate = plainDateFromDate(parsed);
142
+ // Validate the result so we never return an out-of-range date (e.g. a
143
+ // year of 0), which would throw when later re-parsed.
144
+ return tryCreatePlainDate(fromDate.year, fromDate.month, fromDate.day);
131
145
  }
132
146
 
133
147
  return null;
@@ -1 +0,0 @@
1
- {"version":3,"file":"renderXDSDropdownItems.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/renderXDSDropdownItems.tsx"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAUrC,OAAO,KAAK,EAEV,kBAAkB,EAEnB,MAAM,gBAAgB,CAAC;AAyBxB;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,EAAE,GAC1B,SAAS,CA8CX"}