@astryxdesign/core 0.1.1 → 0.1.2-canary.2581eb4

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 (189) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +119 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts +12 -2
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +51 -13
  12. package/dist/Calendar/Calendar.d.ts +5 -4
  13. package/dist/Calendar/Calendar.d.ts.map +1 -1
  14. package/dist/Calendar/Calendar.js +6 -1
  15. package/dist/Calendar/index.d.ts +1 -1
  16. package/dist/Calendar/index.d.ts.map +1 -1
  17. package/dist/DateTimeInput/DateTimeInput.d.ts +7 -2
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +5 -4
  20. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  21. package/dist/EmptyState/EmptyState.js +7 -1
  22. package/dist/HoverCard/HoverCard.d.ts +2 -2
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +18 -6
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +7 -3
  27. package/dist/Kbd/Kbd.d.ts.map +1 -1
  28. package/dist/Kbd/Kbd.js +1 -1
  29. package/dist/Layer/anchorName.d.ts +20 -0
  30. package/dist/Layer/anchorName.d.ts.map +1 -0
  31. package/dist/Layer/anchorName.js +36 -0
  32. package/dist/Layer/useLayer.d.ts +19 -0
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +15 -6
  35. package/dist/Link/Link.d.ts +1 -1
  36. package/dist/Link/Link.js +2 -2
  37. package/dist/Link/LinkProvider.d.ts +1 -0
  38. package/dist/Link/LinkProvider.d.ts.map +1 -1
  39. package/dist/Link/LinkProvider.js +1 -0
  40. package/dist/Markdown/Markdown.d.ts.map +1 -1
  41. package/dist/Markdown/Markdown.js +13 -3
  42. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  43. package/dist/MobileNav/MobileNav.js +13 -0
  44. package/dist/Pagination/Pagination.d.ts.map +1 -1
  45. package/dist/Pagination/Pagination.js +31 -27
  46. package/dist/Slider/Slider.js +2 -2
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +27 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/TableRow.d.ts +6 -1
  52. package/dist/Table/TableRow.d.ts.map +1 -1
  53. package/dist/Table/TableRow.js +15 -7
  54. package/dist/Table/index.d.ts +3 -1
  55. package/dist/Table/index.d.ts.map +1 -1
  56. package/dist/Table/index.js +1 -0
  57. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  58. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  60. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  61. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  62. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +369 -0
  63. package/dist/Table/types.d.ts +95 -5
  64. package/dist/Table/types.d.ts.map +1 -1
  65. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  66. package/dist/Table/useBaseTablePlugins.js +1 -1
  67. package/dist/Text/text.stylex.d.ts +2 -2
  68. package/dist/Text/text.stylex.js +2 -2
  69. package/dist/Timestamp/Timestamp.d.ts +1 -1
  70. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  71. package/dist/Timestamp/Timestamp.js +28 -3
  72. package/dist/ToggleButton/ToggleButton.d.ts +7 -9
  73. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  74. package/dist/ToggleButton/ToggleButton.js +34 -57
  75. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  76. package/dist/Tokenizer/Tokenizer.js +1 -1
  77. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  78. package/dist/Tooltip/useTooltip.js +1 -0
  79. package/dist/astryx.css +30 -1
  80. package/dist/astryx.umd.js +147 -0
  81. package/dist/astryx.umd.js.map +7 -0
  82. package/dist/theme/generateThemeRules.js +1 -1
  83. package/dist/theme/syntax/SyntaxTheme.d.ts +1 -1
  84. package/dist/theme/syntax/SyntaxTheme.js +1 -1
  85. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  86. package/dist/theme/syntax/defineSyntaxTheme.js +2 -2
  87. package/dist/theme/syntax/presets.d.ts +1 -1
  88. package/dist/theme/syntax/presets.d.ts.map +1 -1
  89. package/dist/theme/syntax/presets.js +1 -1
  90. package/dist/theme/syntax/tokens.d.ts +1 -1
  91. package/dist/theme/syntax/tokens.js +1 -1
  92. package/dist/theme/types.d.ts +1 -1
  93. package/dist/utils/dateTypes.d.ts +12 -0
  94. package/dist/utils/dateTypes.d.ts.map +1 -1
  95. package/dist/utils/dateTypes.js +41 -1
  96. package/package.json +6 -2
  97. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  98. package/src/Banner/Banner.doc.mjs +8 -0
  99. package/src/Banner/Banner.test.tsx +16 -7
  100. package/src/Banner/Banner.tsx +9 -2
  101. package/src/Button/Button.doc.mjs +6 -0
  102. package/src/Button/Button.test.tsx +105 -11
  103. package/src/Button/Button.tsx +73 -5
  104. package/src/Calendar/Calendar.doc.mjs +4 -4
  105. package/src/Calendar/Calendar.test.tsx +21 -26
  106. package/src/Calendar/Calendar.tsx +21 -5
  107. package/src/Calendar/index.ts +1 -0
  108. package/src/Chat/ChatComposer.doc.mjs +3 -0
  109. package/src/Chat/ChatComposerDrawer.doc.mjs +12 -0
  110. package/src/CheckboxInput/CheckboxInput.doc.mjs +8 -0
  111. package/src/ClickableCard/ClickableCard.doc.mjs +15 -0
  112. package/src/DateTimeInput/DateTimeInput.doc.mjs +11 -3
  113. package/src/DateTimeInput/DateTimeInput.test.tsx +23 -3
  114. package/src/DateTimeInput/DateTimeInput.tsx +13 -10
  115. package/src/Dialog/Dialog.doc.mjs +2 -2
  116. package/src/EmptyState/EmptyState.test.tsx +4 -2
  117. package/src/EmptyState/EmptyState.tsx +6 -2
  118. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  119. package/src/HoverCard/HoverCard.test.tsx +189 -2
  120. package/src/HoverCard/HoverCard.tsx +20 -16
  121. package/src/HoverCard/useHoverCard.tsx +13 -10
  122. package/src/Icon/Icon.doc.mjs +8 -0
  123. package/src/Kbd/Kbd.tsx +2 -9
  124. package/src/Layer/anchorName.test.ts +146 -0
  125. package/src/Layer/anchorName.ts +46 -0
  126. package/src/Layer/useLayer.doc.mjs +7 -2
  127. package/src/Layer/useLayer.tsx +33 -9
  128. package/src/Layout/Layout.doc.mjs +1 -1
  129. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  130. package/src/Link/Link.test.tsx +2 -2
  131. package/src/Link/Link.tsx +3 -3
  132. package/src/Link/LinkProvider.tsx +1 -0
  133. package/src/List/List.doc.mjs +7 -0
  134. package/src/Markdown/Markdown.doc.mjs +2 -0
  135. package/src/Markdown/Markdown.test.tsx +17 -26
  136. package/src/Markdown/Markdown.tsx +16 -6
  137. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  138. package/src/MobileNav/MobileNav.tsx +13 -0
  139. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  140. package/src/OverflowList/OverflowList.doc.mjs +17 -0
  141. package/src/Pagination/Pagination.test.tsx +137 -13
  142. package/src/Pagination/Pagination.tsx +33 -28
  143. package/src/ProgressBar/ProgressBar.test.tsx +13 -3
  144. package/src/Resizable/Resizable.doc.mjs +1 -1
  145. package/src/Section/Section.doc.mjs +11 -0
  146. package/src/SelectableCard/SelectableCard.doc.mjs +15 -0
  147. package/src/Selector/Selector.doc.mjs +4 -0
  148. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  149. package/src/Slider/Slider.doc.mjs +4 -0
  150. package/src/Slider/Slider.test.tsx +3 -16
  151. package/src/Slider/Slider.tsx +1 -1
  152. package/src/TabList/TabList.doc.mjs +6 -0
  153. package/src/Table/BaseTable.tsx +51 -24
  154. package/src/Table/Table.doc.mjs +12 -0
  155. package/src/Table/Table.test.tsx +29 -30
  156. package/src/Table/Table.tsx +22 -1
  157. package/src/Table/TableRow.tsx +37 -7
  158. package/src/Table/index.ts +3 -0
  159. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  160. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  161. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +407 -0
  162. package/src/Table/types.ts +101 -4
  163. package/src/Table/useBaseTablePlugins.ts +1 -0
  164. package/src/Table/useTableColumnResize.doc.mjs +96 -0
  165. package/src/Table/useTableStickyColumns.doc.mjs +59 -0
  166. package/src/Text/Heading.doc.mjs +15 -2
  167. package/src/Text/Text.doc.mjs +7 -1
  168. package/src/Text/text.stylex.ts +2 -2
  169. package/src/Timestamp/Timestamp.doc.mjs +9 -0
  170. package/src/Timestamp/Timestamp.test.tsx +44 -18
  171. package/src/Timestamp/Timestamp.tsx +30 -10
  172. package/src/ToggleButton/ToggleButton.doc.mjs +9 -3
  173. package/src/ToggleButton/ToggleButton.test.tsx +39 -18
  174. package/src/ToggleButton/ToggleButton.tsx +46 -83
  175. package/src/Tokenizer/Tokenizer.tsx +4 -1
  176. package/src/Toolbar/Toolbar.doc.mjs +65 -5
  177. package/src/Tooltip/Tooltip.test.tsx +13 -0
  178. package/src/Tooltip/useTooltip.tsx +2 -3
  179. package/src/hooks/useImageMode.test.ts +133 -0
  180. package/src/theme/derivedVarRegistry.test.ts +4 -0
  181. package/src/theme/generateThemeRules.test.ts +7 -8
  182. package/src/theme/generateThemeRules.ts +1 -1
  183. package/src/theme/syntax/SyntaxTheme.tsx +1 -1
  184. package/src/theme/syntax/defineSyntaxTheme.ts +20 -11
  185. package/src/theme/syntax/presets.ts +5 -2
  186. package/src/theme/syntax/tokens.ts +1 -1
  187. package/src/theme/types.ts +1 -1
  188. package/src/utils/dateTypes.test.ts +40 -0
  189. package/src/utils/dateTypes.ts +40 -0
@@ -20,14 +20,7 @@
20
20
  * - /packages/cli/templates/blocks/components/ToggleButton/ (showcase blocks)
21
21
  */
22
22
 
23
- import React, {
24
- useCallback,
25
- useEffect,
26
- useOptimistic,
27
- useState,
28
- useTransition,
29
- type ReactNode,
30
- } from 'react';
23
+ import React, {useOptimistic, type ReactNode} from 'react';
31
24
  import * as stylex from '@stylexjs/stylex';
32
25
  import {colorVars, fontWeightVars} from '../theme/tokens.stylex';
33
26
 
@@ -36,37 +29,6 @@ import {useToggleButtonGroup} from './ToggleButtonGroup';
36
29
  import type {BaseProps} from '../BaseProps';
37
30
  import {themeProps} from '../utils/themeProps';
38
31
 
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
-
70
32
  // =============================================================================
71
33
  // Styles
72
34
  // =============================================================================
@@ -122,23 +84,24 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
122
84
  isPressed?: boolean;
123
85
 
124
86
  /**
125
- * Called when the pressed state should change.
87
+ * Called when the pressed state should change. Receives the next pressed
88
+ * state and the originating click event. Call `event.preventDefault()` to
89
+ * opt out of running `pressedChangeAction` (e.g. to handle the toggle
90
+ * entirely in this callback).
126
91
  * When used inside ToggleButtonGroup, this is handled by the group
127
92
  * and this prop is ignored.
128
93
  */
129
- onPressedChange?: (isPressed: boolean) => void;
94
+ onPressedChange?: (
95
+ isPressed: boolean,
96
+ event: React.MouseEvent<HTMLButtonElement>,
97
+ ) => void;
130
98
 
131
99
  /**
132
100
  * 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
101
+ * transition by Button. The button shows a loading spinner while the action
102
+ * is pending — whether it returns a promise or synchronously triggers a
135
103
  * suspending update (e.g. a router navigation that suspends on data).
136
104
  *
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.
141
- *
142
105
  * @example
143
106
  * ```
144
107
  * <ToggleButton
@@ -272,54 +235,52 @@ export function ToggleButton({
272
235
  const size = sizeProp ?? group?.size ?? 'md';
273
236
  const isDisabled = group?.isDisabled ?? isDisabledProp;
274
237
 
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.
238
+ // Track the pressed state optimistically so the button reflects the intended
239
+ // state immediately while an async action is pending. The optimistic update
240
+ // is applied inside Button's clickAction transition (below), satisfying
241
+ // React's "useOptimistic must run in a transition" rule.
279
242
  const [optimisticPressed, setOptimisticPressed] =
280
243
  useOptimistic(committedPressed);
281
244
  const isPressed = optimisticPressed;
282
245
 
246
+ // Next state derives from the *optimistic* value, not the committed one, so a
247
+ // re-click while an action is pending reads the in-flight intent and toggles
248
+ // (true -> false -> true) rather than recomputing from the stale committed
249
+ // state. handleClick and clickAction share this so the callback and the
250
+ // optimistic update always agree within a single click.
251
+ const nextPressed = !isPressed;
252
+
283
253
  const resolvedIcon = isPressed && pressedIcon ? pressedIcon : icon;
284
254
 
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
-
297
- const handleClick = useCallback(() => {
255
+ // Synchronous part of the toggle. Button calls onClick before clickAction and
256
+ // skips clickAction when the event is defaultPrevented, so calling
257
+ // preventDefault() inside onPressedChange opts out of pressedChangeAction.
258
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
298
259
  if (isDisabled) {
299
260
  return;
300
261
  }
301
-
302
262
  if (group && value != null) {
303
263
  // Group mode delegates selection to the group; no async-action path.
304
264
  group.toggle(value);
265
+ event.preventDefault();
305
266
  return;
306
267
  }
307
-
308
- const newState = !optimisticPressed;
309
- startTransition(async () => {
310
- setOptimisticPressed(newState);
311
- onPressedChangeProp?.(newState);
312
- await pressedChangeAction?.(newState);
313
- });
314
- }, [
315
- isDisabled,
316
- group,
317
- value,
318
- optimisticPressed,
319
- onPressedChangeProp,
320
- pressedChangeAction,
321
- setOptimisticPressed,
322
- ]);
268
+ onPressedChangeProp?.(nextPressed, event);
269
+ };
270
+
271
+ // Async part of the toggle, run inside Button's transition (so the optimistic
272
+ // update is valid and Button's isPending drives the pending spinner). Skipped
273
+ // automatically by Button when handleClick called preventDefault, and never
274
+ // wired in group mode (where there is no async action). Button is rendered
275
+ // interruptible, so it stays clickable while pending and a re-click starts a
276
+ // fresh transition that interrupts the previous one.
277
+ const clickAction =
278
+ group && value != null
279
+ ? undefined
280
+ : async () => {
281
+ setOptimisticPressed(nextPressed);
282
+ await pressedChangeAction?.(nextPressed);
283
+ };
323
284
 
324
285
  // isIconOnly prop is the source of truth for icon-only rendering.
325
286
  const labelContent =
@@ -352,7 +313,8 @@ export function ToggleButton({
352
313
  variant="ghost"
353
314
  size={size}
354
315
  isDisabled={isDisabled}
355
- isLoading={isLoadingState}
316
+ isLoading={isLoading}
317
+ isInterruptible
356
318
  isIconOnly={isIconOnly}
357
319
  aria-pressed={isPressed}
358
320
  icon={resolvedIcon}
@@ -363,6 +325,7 @@ export function ToggleButton({
363
325
  xstyle={[isPressed ? pressedStyles.background : undefined, xstyle]}
364
326
  style={style}
365
327
  onClick={handleClick}
328
+ clickAction={clickAction}
366
329
  {...props}>
367
330
  {labelContent}
368
331
  </Button>
@@ -222,7 +222,10 @@ const styles = stylex.create({
222
222
  },
223
223
  endSection: {
224
224
  position: 'absolute',
225
- insetInlineEnd: `calc(${spacingVars['--spacing-1']} - 1px)`,
225
+ // Match the field's inline padding (inputWrapperStyles.base uses
226
+ // spacing-2) so end content (clear button, resultCount) lines up with
227
+ // the text/start-icon inset instead of hugging the border at ~3px.
228
+ insetInlineEnd: spacingVars['--spacing-2'],
226
229
  display: 'flex',
227
230
  alignItems: 'center',
228
231
  gap: spacingVars['--spacing-2'],
@@ -7,6 +7,25 @@ export const docs = {
7
7
  displayName: 'Toolbar',
8
8
  category: 'Action',
9
9
  keywords: ['toolbar', 'nav', 'bar', 'actions', 'buttonbar', 'header', 'footer', 'action-bar', 'control-bar'],
10
+ playground: {
11
+ defaults: {
12
+ label: 'Table actions',
13
+ size: 'sm',
14
+ dividers: ['bottom'],
15
+ startContent: {
16
+ __element: 'TabList',
17
+ props: {value: 'overview'},
18
+ children: [
19
+ {__element: 'Tab', props: {label: 'Overview', value: 'overview'}},
20
+ {__element: 'Tab', props: {label: 'Activity', value: 'activity'}},
21
+ ],
22
+ },
23
+ endContent: [
24
+ {__element: 'Selector', props: {label: 'Status', isLabelHidden: true, placeholder: 'Status', size: 'sm', options: ['Open', 'In progress', 'Done']}},
25
+ {__element: 'Button', props: {label: 'New item', variant: 'primary', size: 'sm'}},
26
+ ],
27
+ },
28
+ },
10
29
  theming: {
11
30
  targets: [
12
31
  {className: 'astryx-toolbar', states: ['size']},
@@ -29,21 +48,62 @@ export const docs = {
29
48
  name: 'startContent',
30
49
  type: 'ReactNode',
31
50
  description: 'Content aligned to the start (left in LTR).',
32
- slotElements: [{__element: 'Icon', props: {icon: 'check', size: 'sm'}}],
51
+ slotElements: [
52
+ {__element: 'Text', props: {type: 'body', weight: 'bold'}, children: 'Title'},
53
+ {__element: 'Button', props: {label: 'Back', variant: 'ghost', size: 'sm'}},
54
+ {__element: 'IconButton', props: {label: 'Filter', icon: {__element: 'Icon', props: {icon: 'check', size: 'sm'}}, variant: 'ghost', size: 'sm'}},
55
+ {
56
+ __element: 'TabList',
57
+ props: {value: 'overview'},
58
+ children: [
59
+ {__element: 'Tab', props: {label: 'Overview', value: 'overview'}},
60
+ {__element: 'Tab', props: {label: 'Activity', value: 'activity'}},
61
+ ],
62
+ },
63
+ {
64
+ __element: 'SegmentedControl',
65
+ props: {label: 'View', value: 'list'},
66
+ children: [
67
+ {__element: 'SegmentedControlItem', props: {label: 'List', value: 'list'}},
68
+ {__element: 'SegmentedControlItem', props: {label: 'Grid', value: 'grid'}},
69
+ ],
70
+ },
71
+ {__element: 'Selector', props: {label: 'Status', isLabelHidden: true, placeholder: 'Status', size: 'sm', options: ['Open', 'In progress', 'Done']}},
72
+ ],
33
73
  },
34
74
  {
35
75
  name: 'centerContent',
36
76
  type: 'ReactNode',
37
77
  description:
38
78
  'Centered content. Switches layout to CSS grid (1fr auto 1fr).',
39
- slotElements: [{__element: 'Text', props: {type: 'body', weight: 'bold'}, children: 'Center'}],
79
+ slotElements: [
80
+ {__element: 'Text', props: {type: 'body', weight: 'bold'}, children: 'Center'},
81
+ {
82
+ __element: 'SegmentedControl',
83
+ props: {label: 'View', value: 'list'},
84
+ children: [
85
+ {__element: 'SegmentedControlItem', props: {label: 'List', value: 'list'}},
86
+ {__element: 'SegmentedControlItem', props: {label: 'Grid', value: 'grid'}},
87
+ ],
88
+ },
89
+ {
90
+ __element: 'TabList',
91
+ props: {value: 'overview'},
92
+ children: [
93
+ {__element: 'Tab', props: {label: 'Overview', value: 'overview'}},
94
+ {__element: 'Tab', props: {label: 'Activity', value: 'activity'}},
95
+ ],
96
+ },
97
+ ],
40
98
  },
41
99
  {
42
100
  name: 'endContent',
43
101
  type: 'ReactNode',
44
102
  description: 'Content aligned to the end (right in LTR).',
45
103
  slotElements: [
46
- {__element: 'Icon', props: {icon: 'chevronDown', size: 'sm'}},
104
+ {__element: 'Button', props: {label: 'Save', variant: 'primary', size: 'sm'}},
105
+ {__element: 'IconButton', props: {label: 'More', icon: {__element: 'Icon', props: {icon: 'chevronDown', size: 'sm'}}, variant: 'ghost', size: 'sm'}},
106
+ {__element: 'Selector', props: {label: 'Sort', isLabelHidden: true, placeholder: 'Sort by', size: 'sm', options: ['Newest', 'Oldest', 'A–Z']}},
47
107
  {__element: 'Badge', props: {label: '3'}},
48
108
  ],
49
109
  },
@@ -51,14 +111,14 @@ export const docs = {
51
111
  name: 'size',
52
112
  type: "'sm' | 'md' | 'lg'",
53
113
  description:
54
- 'Size of the toolbar. Controls minimum height and coordinates with Button, TextInput, TabList, and Selector — children inherit this size as their default via SizeContext.',
114
+ 'Size of the toolbar. Controls minimum height and coordinates with Button, TextInput, TabList, and Selector; children inherit this size as their default via SizeContext.',
55
115
  default: "'md'",
56
116
  },
57
117
  {
58
118
  name: 'gap',
59
119
  type: 'SpacingStep',
60
120
  description: 'Gap between items within each slot.',
61
- default: '2',
121
+ default: '1',
62
122
  },
63
123
  {
64
124
  name: 'orientation',
@@ -55,6 +55,19 @@ describe('Tooltip', () => {
55
55
  expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
56
56
  });
57
57
 
58
+ it('gives the tooltip layer role="tooltip" linked from the trigger', () => {
59
+ render(
60
+ <Tooltip content="Tooltip text">
61
+ <button type="button">Trigger</button>
62
+ </Tooltip>,
63
+ );
64
+ const layer = screen.getByRole('tooltip', {hidden: true});
65
+ expect(layer).toHaveTextContent('Tooltip text');
66
+ // ARIA tooltip pattern: trigger references the layer via aria-describedby.
67
+ const trigger = screen.getByRole('button', {name: 'Trigger'});
68
+ expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
69
+ });
70
+
58
71
  it('calls onOpenChange(true) when shown via hover', async () => {
59
72
  const onOpenChange = vi.fn();
60
73
  render(
@@ -233,9 +233,7 @@ function isFocusable(element: HTMLElement): boolean {
233
233
  * {tooltip.renderTooltip('Helpful tooltip text')}
234
234
  * ```
235
235
  */
236
- export function useTooltip(
237
- options: TooltipOptions = {},
238
- ): TooltipReturn {
236
+ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
239
237
  const {
240
238
  placement = 'above',
241
239
  alignment = 'center',
@@ -429,6 +427,7 @@ export function useTooltip(
429
427
  const renderProps = {
430
428
  placement: renderPlacement,
431
429
  alignment: props?.alignment ?? alignment,
430
+ role: 'tooltip',
432
431
  xstyle: [popoverXstyle, layerAnimations[renderPlacement]],
433
432
  className: themeProps('tooltip').className,
434
433
  };
@@ -0,0 +1,133 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useImageMode.test.ts
5
+ * @input Uses vitest, @testing-library/react, useImageMode hook
6
+ * @output Unit tests for useImageMode hook
7
+ * @position Testing; validates useImageMode.ts pixel-sampling + CORS behavior
8
+ *
9
+ * SYNC: When useImageMode.ts changes, update tests to match new behavior
10
+ */
11
+
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
+ import {renderHook, waitFor, act} from '@testing-library/react';
14
+ import {useImageMode} from './useImageMode';
15
+
16
+ // jsdom has no fetch/createImageBitmap/OffscreenCanvas — stub them per test so
17
+ // we can exercise the sampling path and the cross-origin failure path.
18
+
19
+ /** Fill the sampled canvas with a flat RGB so perceptualLightness is deterministic. */
20
+ function stubSamplingPipeline(rgb: [number, number, number]) {
21
+ // fetch resolves to a blob (same-origin / CORS-enabled image)
22
+ vi.stubGlobal(
23
+ 'fetch',
24
+ vi.fn(async () => ({blob: async () => ({}) as Blob})),
25
+ );
26
+ vi.stubGlobal(
27
+ 'createImageBitmap',
28
+ vi.fn(async () => ({width: 100, height: 100}) as ImageBitmap),
29
+ );
30
+ const [r, g, b] = rgb;
31
+ class FakeOffscreenCanvas {
32
+ constructor(
33
+ public width: number,
34
+ public height: number,
35
+ ) {}
36
+ getContext() {
37
+ return {
38
+ drawImage: vi.fn(),
39
+ getImageData: (_x: number, _y: number, w: number, h: number) => {
40
+ const data = new Uint8ClampedArray(w * h * 4);
41
+ for (let i = 0; i < data.length; i += 4) {
42
+ data[i] = r;
43
+ data[i + 1] = g;
44
+ data[i + 2] = b;
45
+ data[i + 3] = 255;
46
+ }
47
+ return {data};
48
+ },
49
+ };
50
+ }
51
+ }
52
+ vi.stubGlobal('OffscreenCanvas', FakeOffscreenCanvas);
53
+ }
54
+
55
+ /** Simulate a cross-origin image with no CORS headers: fetch rejects. */
56
+ function stubCorsBlockedFetch() {
57
+ vi.stubGlobal(
58
+ 'fetch',
59
+ vi.fn(async () => {
60
+ throw new TypeError(
61
+ 'Failed to fetch: No Access-Control-Allow-Origin header',
62
+ );
63
+ }),
64
+ );
65
+ vi.stubGlobal('createImageBitmap', vi.fn());
66
+ vi.stubGlobal('OffscreenCanvas', class {});
67
+ }
68
+
69
+ describe('useImageMode', () => {
70
+ beforeEach(() => {
71
+ vi.restoreAllMocks();
72
+ });
73
+ afterEach(() => {
74
+ vi.unstubAllGlobals();
75
+ });
76
+
77
+ it('returns the fallback while no src is provided', () => {
78
+ const {result} = renderHook(() => useImageMode(null, {fallback: 'dark'}));
79
+ expect(result.current).toBe('dark');
80
+ });
81
+
82
+ it('samples a dark image and reports "dark"', async () => {
83
+ stubSamplingPipeline([10, 10, 10]);
84
+ const {result} = renderHook(() =>
85
+ useImageMode('data:image/png;base64,AAAA'),
86
+ );
87
+ await waitFor(() => expect(result.current).toBe('dark'));
88
+ });
89
+
90
+ it('samples a light image and reports "light"', async () => {
91
+ stubSamplingPipeline([240, 240, 240]);
92
+ const {result} = renderHook(() =>
93
+ useImageMode('data:image/png;base64,AAAA'),
94
+ );
95
+ await waitFor(() => expect(result.current).toBe('light'));
96
+ });
97
+
98
+ // ── Reproduction for BB-001 (cluster C6) ────────────────────────────────
99
+ // A cross-origin image with no `Access-Control-Allow-Origin` header cannot be
100
+ // fetched with `mode: 'cors'`, so the APCA sampling pipeline never runs and the
101
+ // hook silently stays on its fallback. This is why image-backed Thumbnail
102
+ // overlay/remove-button contrast is unreliable for hosted demo examples that
103
+ // point `src` at a non-CORS CDN URL.
104
+ it('stays on fallback when a cross-origin image cannot be sampled (CORS)', async () => {
105
+ stubCorsBlockedFetch();
106
+ const {result} = renderHook(() =>
107
+ useImageMode(
108
+ 'https://lookaside.facebook.com/assets/astryx/moody-scene-vertical-2.png',
109
+ {fallback: null},
110
+ ),
111
+ );
112
+ // Give the rejected fetch + state update a chance to flush.
113
+ await act(async () => {
114
+ await new Promise(r => setTimeout(r, 0));
115
+ });
116
+ expect(result.current).toBeNull();
117
+ });
118
+
119
+ it('requests the image with CORS mode (the constraint that fails for non-CORS hosts)', async () => {
120
+ const fetchSpy = vi.fn(async () => {
121
+ throw new TypeError('CORS');
122
+ });
123
+ vi.stubGlobal('fetch', fetchSpy);
124
+ vi.stubGlobal('createImageBitmap', vi.fn());
125
+ vi.stubGlobal('OffscreenCanvas', class {});
126
+ renderHook(() => useImageMode('https://cross-origin.example/x.png'));
127
+ await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
128
+ expect(fetchSpy).toHaveBeenCalledWith(
129
+ 'https://cross-origin.example/x.png',
130
+ {mode: 'cors'},
131
+ );
132
+ });
133
+ });
@@ -64,6 +64,10 @@ const STRUCTURAL_VARS = new Set([
64
64
  '--indicator-color',
65
65
  '--indicator-width',
66
66
  '--table-resize-height',
67
+ // sticky-columns plugin: opaque backdrop (overridable) + the row overlay it
68
+ // replays on pinned cells. Structural/runtime, not themeable design tokens.
69
+ '--table-sticky-background',
70
+ '--table-row-overlay',
67
71
  '--separator-display',
68
72
  '--astryx-section-padding',
69
73
  ]);
@@ -101,7 +101,9 @@ describe('generateThemeRules', () => {
101
101
 
102
102
  it('includes .astryx-heading.level-* rules for all 6 levels', () => {
103
103
  for (let level = 1; level <= 6; level++) {
104
- const rule = rules.find(r => r.includes(`.astryx-heading.level-${level}`));
104
+ const rule = rules.find(r =>
105
+ r.includes(`.astryx-heading.level-${level}`),
106
+ );
105
107
  expect(rule).toBeDefined();
106
108
  expect(rule).toContain('font-family');
107
109
  expect(rule).toContain(`var(--text-heading-${level}-size)`);
@@ -137,9 +139,7 @@ describe('generateThemeRules', () => {
137
139
  });
138
140
  const pseudoRules = generateThemeRules(pseudoTheme);
139
141
  expect(
140
- pseudoRules.some(rule =>
141
- rule.includes('.astryx-button:hover'),
142
- ),
142
+ pseudoRules.some(rule => rule.includes('.astryx-button:hover')),
143
143
  ).toBe(true);
144
144
  });
145
145
 
@@ -186,7 +186,8 @@ describe('generateThemeRules', () => {
186
186
  expect(rules.some(r => r.includes('.astryx-text.secondary'))).toBe(true);
187
187
  expect(rules.some(r => r.includes('.astryx-heading.primary'))).toBe(true);
188
188
  expect(rules.some(r => r.includes('.astryx-heading.disabled'))).toBe(true);
189
- expect(rules.some(r => r.includes('.astryx-text.active'))).toBe(true);
189
+ expect(rules.some(r => r.includes('.astryx-text.active'))).toBe(false);
190
+ expect(rules.some(r => r.includes('.astryx-text.accent'))).toBe(true);
190
191
  });
191
192
 
192
193
  // --- Consistency ---
@@ -194,9 +195,7 @@ describe('generateThemeRules', () => {
194
195
  it('generateThemeCSS returns prose and component blocks with @scope', () => {
195
196
  const {prose, component} = generateThemeCSS(theme);
196
197
  const combined = prose + component;
197
- expect(combined).toContain(
198
- '@scope ([data-astryx-theme="default"])',
199
- );
198
+ expect(combined).toContain('@scope ([data-astryx-theme="default"])');
200
199
  expect(combined).toContain('to ([data-astryx-theme])');
201
200
  // Every rule from generateThemeRules should appear in one of the blocks
202
201
  for (const rule of rules) {
@@ -472,7 +472,7 @@ function generateColorOverrides(
472
472
  secondary: 'var(--color-text-secondary)',
473
473
  disabled: 'var(--color-text-disabled)',
474
474
  placeholder: 'var(--color-text-secondary)',
475
- active: 'var(--color-accent)',
475
+ accent: 'var(--color-text-accent)',
476
476
  };
477
477
 
478
478
  const touchesText = 'text' in components;
@@ -8,7 +8,7 @@
8
8
  * @output SyntaxTheme provider, useSyntaxTheme hook
9
9
  * @position Provider component; wraps code surfaces to apply syntax coloring
10
10
  *
11
- * @see https://github.com/facebookexperimental/xds/issues/1148
11
+ * @see https://github.com/facebook/astryx/issues/1148
12
12
  */
13
13
 
14
14
  import React, {createContext, use, useMemo} from 'react';
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- "use strict";
3
+ 'use strict';
4
4
 
5
5
  /**
6
6
  * @file defineSyntaxTheme.ts
@@ -8,7 +8,7 @@
8
8
  * @output defineSyntaxTheme, SyntaxTheme, syntaxThemeStyle
9
9
  * @position Syntax theme definition API; consumed by presets, SyntaxTheme, defineTheme
10
10
  *
11
- * @see https://github.com/facebookexperimental/xds/issues/1148
11
+ * @see https://github.com/facebook/astryx/issues/1148
12
12
  */
13
13
 
14
14
  import {syntaxTokenDefaults, type SyntaxTokenName} from './tokens';
@@ -41,7 +41,10 @@ export type SyntaxThemeTokenKey =
41
41
  export type SyntaxTokenValue = string | [light: string, dark: string];
42
42
 
43
43
  /** Token map for defineSyntaxTheme input — values can be strings or tuples. */
44
- export type SyntaxThemeTokenInput = Record<SyntaxThemeTokenKey, SyntaxTokenValue>;
44
+ export type SyntaxThemeTokenInput = Record<
45
+ SyntaxThemeTokenKey,
46
+ SyntaxTokenValue
47
+ >;
45
48
 
46
49
  /** Resolved token map — all values are CSS strings (tuples resolved to light-dark()). */
47
50
  export type SyntaxThemeTokenMap = Record<SyntaxThemeTokenKey, string>;
@@ -73,8 +76,9 @@ function toCSSProperty(key: SyntaxThemeTokenKey): SyntaxTokenName {
73
76
  }
74
77
 
75
78
  /** All valid human-readable token keys, derived from the defaults. */
76
- export const ALL_SYNTAX_KEYS: SyntaxThemeTokenKey[] = Object.keys(syntaxTokenDefaults)
77
- .map(k => k.replace(CSS_PREFIX, '') as SyntaxThemeTokenKey);
79
+ export const ALL_SYNTAX_KEYS: SyntaxThemeTokenKey[] = Object.keys(
80
+ syntaxTokenDefaults,
81
+ ).map(k => k.replace(CSS_PREFIX, '') as SyntaxThemeTokenKey);
78
82
 
79
83
  // =============================================================================
80
84
  // Helpers
@@ -130,12 +134,17 @@ export function resolveSyntaxTokenForMode(
130
134
  * },
131
135
  * });
132
136
  */
133
- export function defineSyntaxTheme(input: SyntaxThemeInput): SyntaxThemeDefinition {
137
+ export function defineSyntaxTheme(
138
+ input: SyntaxThemeInput,
139
+ ): SyntaxThemeDefinition {
134
140
  const missing = ALL_SYNTAX_KEYS.filter(key => !(key in input.tokens));
135
141
  if (missing.length > 0) {
136
142
  console.warn(
137
- '[Astryx] defineSyntaxTheme("' + input.name + '"): missing tokens: ' +
138
- missing.join(', ') + '. All 14 syntax tokens are required.',
143
+ '[Astryx] defineSyntaxTheme("' +
144
+ input.name +
145
+ '"): missing tokens: ' +
146
+ missing.join(', ') +
147
+ '. All 14 syntax tokens are required.',
139
148
  );
140
149
  }
141
150
 
@@ -169,7 +178,7 @@ export function syntaxThemeStyle(
169
178
 
170
179
  /** Convert a syntax theme to CSS declarations (no selector wrapper). */
171
180
  export function syntaxThemeToCSS(theme: SyntaxThemeDefinition): string {
172
- return ALL_SYNTAX_KEYS
173
- .map(key => toCSSProperty(key) + ': ' + theme.tokens[key] + ';')
174
- .join('\n ');
181
+ return ALL_SYNTAX_KEYS.map(
182
+ key => toCSSProperty(key) + ': ' + theme.tokens[key] + ';',
183
+ ).join('\n ');
175
184
  }
@@ -8,10 +8,13 @@
8
8
  * All original themes are MIT licensed. Only color values are extracted.
9
9
  * See THIRD_PARTY_LICENSES.md for full attribution.
10
10
  *
11
- * @see https://github.com/facebookexperimental/xds/issues/1148
11
+ * @see https://github.com/facebook/astryx/issues/1148
12
12
  */
13
13
 
14
- import {defineSyntaxTheme, type SyntaxThemeDefinition} from './defineSyntaxTheme';
14
+ import {
15
+ defineSyntaxTheme,
16
+ type SyntaxThemeDefinition,
17
+ } from './defineSyntaxTheme';
15
18
 
16
19
  /** One Dark Pro (Binaryify) — MIT */
17
20
  export const oneDarkPro: SyntaxThemeDefinition = defineSyntaxTheme({
@@ -16,7 +16,7 @@
16
16
  * 14-token architecture validated against 11 community code themes.
17
17
  * All themes map cleanly to these 14 slots.
18
18
  *
19
- * @see https://github.com/facebookexperimental/xds/issues/1148
19
+ * @see https://github.com/facebook/astryx/issues/1148
20
20
  */
21
21
 
22
22
  export const syntaxTokenDefaults = {