@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.
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
- package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
- package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
- package/dist/DropdownMenu/menuItemHover.js +1 -0
- package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
- package/dist/FieldStatus/FieldStatus.js +12 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +1 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +42 -43
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +9 -3
- package/dist/astryx.css +35 -5
- package/dist/astryx.umd.js +46 -46
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
- package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
- package/dist/hooks/containerReveal.pool.stylex.js +281 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +2 -1
- package/dist/hooks/useContainerReveal.d.ts +83 -0
- package/dist/hooks/useContainerReveal.d.ts.map +1 -0
- package/dist/hooks/useContainerReveal.js +123 -0
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +11 -0
- package/package.json +1 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
- package/src/DropdownMenu/menuItemHover.ts +1 -0
- package/src/Field/Field.doc.mjs +4 -0
- package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
- package/src/FieldStatus/FieldStatus.test.tsx +61 -0
- package/src/FieldStatus/FieldStatus.tsx +14 -3
- package/src/MultiSelector/MultiSelector.tsx +6 -5
- package/src/Thumbnail/Thumbnail.tsx +18 -32
- package/src/TopNav/TopNavMegaMenu.tsx +26 -2
- package/src/hooks/containerReveal.pool.stylex.ts +464 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useContainerReveal.test.tsx +116 -0
- package/src/hooks/useContainerReveal.ts +180 -0
- package/src/hooks/useInputStatusIcon.test.tsx +59 -0
- package/src/hooks/useInputStatusIcon.tsx +12 -1
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
- package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
- 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
|
|
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();
|