@astryxdesign/core 0.2.0-canary.ea693ac → 0.2.0-canary.fc53ff7

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 (48) hide show
  1. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  2. package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
  3. package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
  4. package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
  5. package/dist/DropdownMenu/menuItemHover.js +1 -0
  6. package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
  7. package/dist/FieldStatus/FieldStatus.js +12 -2
  8. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  9. package/dist/MultiSelector/MultiSelector.js +1 -1
  10. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  11. package/dist/Thumbnail/Thumbnail.js +42 -43
  12. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  13. package/dist/TopNav/TopNavMegaMenu.js +9 -3
  14. package/dist/astryx.css +35 -5
  15. package/dist/astryx.umd.js +46 -46
  16. package/dist/astryx.umd.js.map +4 -4
  17. package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
  18. package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
  19. package/dist/hooks/containerReveal.pool.stylex.js +281 -0
  20. package/dist/hooks/index.d.ts +2 -0
  21. package/dist/hooks/index.d.ts.map +1 -1
  22. package/dist/hooks/index.js +2 -1
  23. package/dist/hooks/useContainerReveal.d.ts +83 -0
  24. package/dist/hooks/useContainerReveal.d.ts.map +1 -0
  25. package/dist/hooks/useContainerReveal.js +123 -0
  26. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  27. package/dist/hooks/useInputStatusIcon.js +11 -0
  28. package/package.json +1 -1
  29. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
  30. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
  31. package/src/DropdownMenu/menuItemHover.ts +1 -0
  32. package/src/Field/Field.doc.mjs +4 -0
  33. package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
  34. package/src/FieldStatus/FieldStatus.test.tsx +61 -0
  35. package/src/FieldStatus/FieldStatus.tsx +14 -3
  36. package/src/MultiSelector/MultiSelector.tsx +6 -5
  37. package/src/Thumbnail/Thumbnail.tsx +18 -32
  38. package/src/TopNav/TopNavMegaMenu.tsx +26 -2
  39. package/src/hooks/containerReveal.pool.stylex.ts +464 -0
  40. package/src/hooks/index.ts +7 -0
  41. package/src/hooks/useContainerReveal.test.tsx +116 -0
  42. package/src/hooks/useContainerReveal.ts +180 -0
  43. package/src/hooks/useInputStatusIcon.test.tsx +59 -0
  44. package/src/hooks/useInputStatusIcon.tsx +12 -1
  45. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
  46. package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
  47. package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
  48. package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
@@ -0,0 +1,180 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useContainerReveal.ts
7
+ * @input Uses React, the generated marker pool, useDevWarning
8
+ * @output Exports useContainerReveal — a headless hover/focus reveal primitive
9
+ * @position Core hook. Consumed by any component that reveals (or conceals)
10
+ * content when its container is hovered or focused — e.g. Thumbnail's remove
11
+ * button, TreeList row actions.
12
+ *
13
+ * Gives a container a scoped hover/focus-within trigger that reveals or
14
+ * conceals content inside it, entirely in CSS (no hover state in JS, no
15
+ * re-render on hover). The caller authors NO StyleX: the hook hands out a
16
+ * marker + matching reveal styles from a pre-compiled pool.
17
+ *
18
+ * ACCESSIBILITY (WCAG 2.2 by construction):
19
+ * - Revealed content is visually hidden at rest via the clip recipe, so it
20
+ * stays in the accessibility tree and tab order — never display:none.
21
+ * - Keyboard: revealed on :focus-within, so tabbing in shows it.
22
+ * - Touch: always visible on coarse pointers; never gated behind hover.
23
+ * - Concealed (inverted) content is a mouse-only visual swap: it ignores
24
+ * :focus-within (a keyboard user must never watch content vanish) and stays
25
+ * visible on touch and in the a11y tree.
26
+ * - Motion: honors prefers-reduced-motion.
27
+ *
28
+ * SYNC: When modified, update:
29
+ * - /packages/core/src/hooks/index.ts (export)
30
+ * - containerReveal.pool.stylex.ts (if the slot shape changes)
31
+ */
32
+
33
+ import {useState, useEffect, type CSSProperties} from 'react';
34
+ import * as stylex from '@stylexjs/stylex';
35
+ import {POOL, POOL_SIZE, type RevealSlot} from './containerReveal.pool.stylex';
36
+ import {useDevWarning} from './useDevWarning';
37
+
38
+ // Module-level free-list. Each mounted, enabled useContainerReveal claims a
39
+ // distinct pool slot so that any two CONCURRENTLY mounted containers — in
40
+ // particular a nested container inside another's revealed content — get
41
+ // different markers and cannot leak hover/focus into one another. Slots are
42
+ // returned on unmount. Sibling containers that would only ever collide when
43
+ // the pool is exhausted are harmless (a sibling is never an ancestor), so the
44
+ // pool only needs to cover the number of concurrently mounted containers, not
45
+ // instances over time.
46
+ const claimed: boolean[] = new Array(POOL_SIZE).fill(false);
47
+
48
+ interface Claim {
49
+ /** Pool index to use. */
50
+ index: number;
51
+ /** True when the pool was full and this claim fell back to a shared slot. */
52
+ isExhausted: boolean;
53
+ }
54
+
55
+ function claimSlot(): Claim {
56
+ for (let i = 0; i < POOL_SIZE; i++) {
57
+ if (!claimed[i]) {
58
+ claimed[i] = true;
59
+ return {index: i, isExhausted: false};
60
+ }
61
+ }
62
+ // Exhausted: fall back to slot 0. Safe for siblings (a sibling is never an
63
+ // ancestor); only nesting deeper than POOL_SIZE could reintroduce a leak.
64
+ // Surfaced via useDevWarning so the pool can be grown.
65
+ return {index: 0, isExhausted: true};
66
+ }
67
+
68
+ function releaseSlot(index: number, isExhausted: boolean): void {
69
+ // A fallback claim never owned the slot exclusively; leave it as-is.
70
+ if (!isExhausted) {
71
+ claimed[index] = false;
72
+ }
73
+ }
74
+
75
+ export interface UseContainerRevealOptions {
76
+ /**
77
+ * When false the hook is inert: no marker is applied and content getters
78
+ * return no styles, so content is always shown. Lets a component gate on its
79
+ * own prop (e.g. `revealOn === 'hover'`).
80
+ * @default true
81
+ */
82
+ isEnabled?: boolean;
83
+ }
84
+
85
+ export interface ContentRevealOptions {
86
+ /**
87
+ * Conceal-on-hover instead of reveal-on-hover: content is visible at rest
88
+ * and fades out while the container is hovered. Mouse-only and visual —
89
+ * stays in the a11y tree, ignores focus-within, stays visible on touch.
90
+ * @default false
91
+ */
92
+ isRevealInverted?: boolean;
93
+ /**
94
+ * Reserve the content's layout box while hidden (opacity-only) instead of
95
+ * collapsing it, to avoid layout shift when it appears.
96
+ * @default false
97
+ */
98
+ isLayoutPreserved?: boolean;
99
+ }
100
+
101
+ export interface UseContainerRevealReturn {
102
+ /** Spread onto the container whose hover/focus-within drives the reveal. */
103
+ getContainerProps: () => {className?: string; style?: CSSProperties};
104
+ /** Spread onto each revealed / concealed child. */
105
+ getContentRevealProps: (
106
+ options?: ContentRevealOptions,
107
+ ) => {className?: string; style?: CSSProperties};
108
+ }
109
+
110
+ const EMPTY = Object.freeze({});
111
+
112
+ /**
113
+ * Scoped, CSS-only hover/focus reveal for content inside a container.
114
+ *
115
+ * @example
116
+ * ```tsx
117
+ * const {getContainerProps, getContentRevealProps} = useContainerReveal({
118
+ * isEnabled: revealOn === 'hover',
119
+ * });
120
+ *
121
+ * <div {...mergeProps(getContainerProps(), stylex.props(styles.row))}>
122
+ * {label}
123
+ * <span {...mergeProps(getContentRevealProps(), stylex.props(styles.actions))}>
124
+ * {actions}
125
+ * </span>
126
+ * </div>
127
+ * ```
128
+ */
129
+ export function useContainerReveal(
130
+ options: UseContainerRevealOptions = {},
131
+ ): UseContainerRevealReturn {
132
+ const {isEnabled = true} = options;
133
+
134
+ // Claim a slot for this container's lifetime. Assigned in the initializer
135
+ // (not the render body) and released on unmount so the free-list stays
136
+ // effect-scoped and StrictMode-safe.
137
+ const [claim] = useState<Claim | null>(() =>
138
+ isEnabled ? claimSlot() : null,
139
+ );
140
+
141
+ useEffect(() => {
142
+ if (claim == null) {
143
+ return;
144
+ }
145
+ return () => releaseSlot(claim.index, claim.isExhausted);
146
+ }, [claim]);
147
+
148
+ useDevWarning(
149
+ 'useContainerReveal',
150
+ `More than ${POOL_SIZE} reveal containers are mounted at once; nested ` +
151
+ `containers beyond the pool may share a marker. Add markers to ` +
152
+ `containerReveal.pool.stylex.ts and raise POOL_SIZE.`,
153
+ claim?.isExhausted ?? false,
154
+ );
155
+
156
+ if (claim == null) {
157
+ return {
158
+ getContainerProps: () => EMPTY,
159
+ getContentRevealProps: () => EMPTY,
160
+ };
161
+ }
162
+
163
+ const slot: RevealSlot = POOL[claim.index];
164
+
165
+ return {
166
+ getContainerProps: () => stylex.props(slot.marker),
167
+ getContentRevealProps: (contentOptions: ContentRevealOptions = {}) => {
168
+ const {isRevealInverted = false, isLayoutPreserved = false} =
169
+ contentOptions;
170
+ const style = isRevealInverted
171
+ ? isLayoutPreserved
172
+ ? slot.concealLayoutPreserved
173
+ : slot.conceal
174
+ : isLayoutPreserved
175
+ ? slot.revealLayoutPreserved
176
+ : slot.reveal;
177
+ return stylex.props(style);
178
+ },
179
+ };
180
+ }
@@ -160,3 +160,62 @@ describe('useInputStatusIcon — no dangling aria-describedby (WCAG 1.3.1)', ()
160
160
  expectNoDanglingDescribedBy(container);
161
161
  });
162
162
  });
163
+
164
+ describe('useInputStatusIcon — input-status-icon theme target', () => {
165
+ // The stable theme target lands on the on-field status glyph itself, so a
166
+ // theme can restyle (e.g. resize) just this icon via `defineTheme`. It is
167
+ // rendered by the attached and tooltip variants; detached suppresses the
168
+ // on-field icon in favour of the detached message box's leading glyph.
169
+ const getStatusIcon = (root: HTMLElement): HTMLElement => {
170
+ const icon = root.querySelector('.astryx-input-status-icon');
171
+ if (icon == null) {
172
+ throw new Error('status icon not found');
173
+ }
174
+ return icon as HTMLElement;
175
+ };
176
+
177
+ for (const variant of ['attached', 'tooltip'] as const) {
178
+ it(`renders the target on the on-field icon (statusVariant="${variant}")`, () => {
179
+ const {container} = render(
180
+ <TextInput
181
+ label="Field"
182
+ value=""
183
+ onChange={() => {}}
184
+ status={{type: 'warning', message: 'Message'}}
185
+ statusVariant={variant}
186
+ />,
187
+ );
188
+ const icon = getStatusIcon(container);
189
+ expect(icon).toHaveClass('astryx-input-status-icon');
190
+ expect(icon).toHaveClass('astryx-icon');
191
+ expect(icon).toHaveAttribute('data-size', 'md');
192
+ expect(icon).toHaveAttribute('data-status', 'warning');
193
+ });
194
+ }
195
+
196
+ it('reflects the status type per status', () => {
197
+ const {container} = render(
198
+ <TextInput
199
+ label="Field"
200
+ value=""
201
+ onChange={() => {}}
202
+ status={{type: 'success', message: 'Message'}}
203
+ statusVariant="attached"
204
+ />,
205
+ );
206
+ expect(getStatusIcon(container)).toHaveAttribute('data-status', 'success');
207
+ });
208
+
209
+ it('does not render the on-field target for the detached variant', () => {
210
+ const {container} = render(
211
+ <TextInput
212
+ label="Field"
213
+ value=""
214
+ onChange={() => {}}
215
+ status={{type: 'error', message: 'Message'}}
216
+ statusVariant="detached"
217
+ />,
218
+ );
219
+ expect(container.querySelector('.astryx-input-status-icon')).toBeNull();
220
+ });
221
+ });
@@ -41,6 +41,7 @@ import type {InputStatus, InputStatusType} from '../Field/types';
41
41
  import {useTooltip} from '../Tooltip';
42
42
  import {useTranslator} from '../i18n';
43
43
  import {colorVars, radiusVars} from '../theme/tokens.stylex';
44
+ import {themeProps} from '../utils/themeProps';
44
45
 
45
46
  /**
46
47
  * Maps each status type to its glyph. Shared so every input shows the same icon
@@ -194,7 +195,17 @@ export function useInputStatusIcon({
194
195
  }
195
196
 
196
197
  const icon = (
197
- <Icon icon={STATUS_ICON[status.type]} size={size} color={status.type} />
198
+ <Icon
199
+ icon={STATUS_ICON[status.type]}
200
+ size={size}
201
+ color={status.type}
202
+ // Stable theme target on the status glyph itself, so a theme can restyle
203
+ // just this icon (color, size) — and each status — via `defineTheme`.
204
+ // Same-element rules in @layer astryx-theme win over the icon's own base
205
+ // width/height/fontSize, which a field-level target could not reach.
206
+ // Shared by the attached and tooltip variants across all bordered inputs.
207
+ {...themeProps('input-status-icon', {size, status: status.type})}
208
+ />
198
209
  );
199
210
 
200
211
  // Attached (and tooltip-without-message) variants: plain, non-interactive
@@ -1,9 +0,0 @@
1
- import * as stylex from '@stylexjs/stylex';
2
- /**
3
- * Scoped marker for Thumbnail ancestor selectors. Applied to the image
4
- * container so the remove button's hover/focus reveal (`showRemoveOn="hover"`)
5
- * responds only to this thumbnail — not to hover/focus state from an outer
6
- * container the thumbnail happens to sit inside.
7
- */
8
- export declare const thumbnailScope: ReturnType<typeof stylex.defineMarker>;
9
- //# sourceMappingURL=thumbnail.markers.stylex.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"thumbnail.markers.stylex.d.ts","sourceRoot":"","sources":["../../src/Thumbnail/thumbnail.markers.stylex.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAE3C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,EAAE,UAAU,CAAC,OAAO,MAAM,CAAC,YAAY,CAC3C,CAAC"}
@@ -1,14 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- import * as stylex from '@stylexjs/stylex';
4
-
5
- /**
6
- * Scoped marker for Thumbnail ancestor selectors. Applied to the image
7
- * container so the remove button's hover/focus reveal (`showRemoveOn="hover"`)
8
- * responds only to this thumbnail — not to hover/focus state from an outer
9
- * container the thumbnail happens to sit inside.
10
- */
11
- export const thumbnailScope = {
12
- x9jk0ni: "x9jk0ni",
13
- $$css: true
14
- };
@@ -1,12 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- import * as stylex from '@stylexjs/stylex';
4
-
5
- /**
6
- * Scoped marker for Thumbnail ancestor selectors. Applied to the image
7
- * container so the remove button's hover/focus reveal (`showRemoveOn="hover"`)
8
- * responds only to this thumbnail — not to hover/focus state from an outer
9
- * container the thumbnail happens to sit inside.
10
- */
11
- export const thumbnailScope: ReturnType<typeof stylex.defineMarker> =
12
- stylex.defineMarker();