@astryxdesign/core 0.4.2-canary.791f395 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/astryx.css +27 -1
- package/dist/astryx.umd.js +26 -26
- package/dist/astryx.umd.js.map +3 -3
- package/dist/hooks/containerReveal.stylex.d.ts +71 -4
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +57 -5
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.d.ts +60 -3
- package/dist/hooks/useContainerReveal.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.js +27 -5
- package/package.json +3 -3
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file containerReveal.stylex.ts
|
|
5
5
|
* @input Uses StyleX, theme tokens
|
|
6
|
-
* @output The container style that publishes the reveal state,
|
|
7
|
-
*
|
|
8
|
-
* variant) that read it.
|
|
6
|
+
* @output The container style that publishes the reveal state, the suspended
|
|
7
|
+
* and hover-delay container variants, and the four content style blocks
|
|
8
|
+
* (reveal / conceal, each with a layout-preserved variant) that read it.
|
|
9
9
|
* @position Internal to useContainerReveal.
|
|
10
10
|
*
|
|
11
11
|
* HOW THE SCOPING WORKS: the container declares its own reveal state as
|
|
@@ -22,8 +22,14 @@ import {durationVars, easeVars} from '../theme/tokens.stylex';
|
|
|
22
22
|
|
|
23
23
|
const REST_DELAY = '0s, ' + durationVars['--duration-fast'];
|
|
24
24
|
|
|
25
|
+
// The hover-intent gate: how long the pointer must dwell before the hover
|
|
26
|
+
// branch takes effect. Declared on every container so a nested one never
|
|
27
|
+
// inherits its ancestor's dwell.
|
|
28
|
+
const HOVER_DELAY = 'var(--_hover-delay, 0s)';
|
|
29
|
+
|
|
25
30
|
export const styles = stylex.create({
|
|
26
31
|
container: {
|
|
32
|
+
'--_hover-delay': '0s',
|
|
27
33
|
'--_reveal-opacity': {
|
|
28
34
|
default: 0,
|
|
29
35
|
':hover': {'@media (hover: hover)': 1},
|
|
@@ -37,13 +43,21 @@ export const styles = stylex.create({
|
|
|
37
43
|
'@media (any-pointer: coarse)': 'static',
|
|
38
44
|
},
|
|
39
45
|
// The position flip is discrete, so it transitions with allow-discrete and
|
|
40
|
-
// a state-conditional delay:
|
|
41
|
-
// fades in) and the fade duration on exit (stays in flow
|
|
42
|
-
// finishes, then snaps out) — without
|
|
43
|
-
// at full opacity and flicker.
|
|
46
|
+
// a state-conditional delay: the dwell on entry (flips into flow when the
|
|
47
|
+
// gate opens, then fades in) and the fade duration on exit (stays in flow
|
|
48
|
+
// until the fade finishes, then snaps out) — without the exit half the
|
|
49
|
+
// content would snap out of flow at full opacity and flicker.
|
|
44
50
|
'--_reveal-delay': {
|
|
45
51
|
default: REST_DELAY,
|
|
46
|
-
':hover': {'@media (hover: hover)': '
|
|
52
|
+
':hover': {'@media (hover: hover)': HOVER_DELAY + ', ' + HOVER_DELAY},
|
|
53
|
+
':focus-within': '0s, 0s',
|
|
54
|
+
'@media (any-pointer: coarse)': '0s, 0s',
|
|
55
|
+
},
|
|
56
|
+
// Reduced motion drops the exit sequencing (there is no fade left to wait
|
|
57
|
+
// for) but keeps the dwell: an intent gate is timing, not motion.
|
|
58
|
+
'--_reveal-delay-reduced': {
|
|
59
|
+
default: '0s, 0s',
|
|
60
|
+
':hover': {'@media (hover: hover)': HOVER_DELAY + ', ' + HOVER_DELAY},
|
|
47
61
|
':focus-within': '0s, 0s',
|
|
48
62
|
'@media (any-pointer: coarse)': '0s, 0s',
|
|
49
63
|
},
|
|
@@ -54,7 +68,98 @@ export const styles = stylex.create({
|
|
|
54
68
|
default: 1,
|
|
55
69
|
':hover': {'@media (hover: hover)': 0},
|
|
56
70
|
},
|
|
71
|
+
// Single-value dwell for the opacity-only blocks, which have no discrete
|
|
72
|
+
// position to sequence.
|
|
73
|
+
'--_fade-delay': {
|
|
74
|
+
default: '0s',
|
|
75
|
+
':hover': {'@media (hover: hover)': HOVER_DELAY},
|
|
76
|
+
':focus-within': '0s',
|
|
77
|
+
'@media (any-pointer: coarse)': '0s',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
// stateInactive is `container` with the hover branch pinned to its rest
|
|
81
|
+
// value, so the pointer stops driving the reveal while a caller holds the
|
|
82
|
+
// container inactive. Keyboard focus and coarse pointers keep their branches:
|
|
83
|
+
// an inactive container must never hide content from a keyboard or touch
|
|
84
|
+
// user. Every property repeats the full condition shape of `container` —
|
|
85
|
+
// StyleX replaces styles per property AND condition, so a plain `default`
|
|
86
|
+
// here would lose to the earlier block's `:hover` rule.
|
|
87
|
+
stateInactive: {
|
|
88
|
+
'--_reveal-opacity': {
|
|
89
|
+
default: 0,
|
|
90
|
+
':hover': {'@media (hover: hover)': 0},
|
|
91
|
+
':focus-within': 1,
|
|
92
|
+
'@media (any-pointer: coarse)': 1,
|
|
93
|
+
},
|
|
94
|
+
'--_reveal-position': {
|
|
95
|
+
default: 'absolute',
|
|
96
|
+
':hover': {'@media (hover: hover)': 'absolute'},
|
|
97
|
+
':focus-within': 'static',
|
|
98
|
+
'@media (any-pointer: coarse)': 'static',
|
|
99
|
+
},
|
|
100
|
+
'--_reveal-delay': {
|
|
101
|
+
default: REST_DELAY,
|
|
102
|
+
':hover': {'@media (hover: hover)': REST_DELAY},
|
|
103
|
+
':focus-within': '0s, 0s',
|
|
104
|
+
'@media (any-pointer: coarse)': '0s, 0s',
|
|
105
|
+
},
|
|
106
|
+
'--_reveal-delay-reduced': {
|
|
107
|
+
default: '0s, 0s',
|
|
108
|
+
':hover': {'@media (hover: hover)': '0s, 0s'},
|
|
109
|
+
':focus-within': '0s, 0s',
|
|
110
|
+
'@media (any-pointer: coarse)': '0s, 0s',
|
|
111
|
+
},
|
|
112
|
+
'--_conceal-opacity': {
|
|
113
|
+
default: 1,
|
|
114
|
+
':hover': {'@media (hover: hover)': 1},
|
|
115
|
+
},
|
|
116
|
+
'--_fade-delay': {
|
|
117
|
+
default: '0s',
|
|
118
|
+
':hover': {'@media (hover: hover)': '0s'},
|
|
119
|
+
':focus-within': '0s',
|
|
120
|
+
'@media (any-pointer: coarse)': '0s',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
// stateActive pins the other end: every branch reads as pointed-at, so
|
|
124
|
+
// revealed content stays in and inverted content stays out, with no dwell to
|
|
125
|
+
// wait through.
|
|
126
|
+
stateActive: {
|
|
127
|
+
'--_reveal-opacity': {
|
|
128
|
+
default: 1,
|
|
129
|
+
':hover': {'@media (hover: hover)': 1},
|
|
130
|
+
':focus-within': 1,
|
|
131
|
+
'@media (any-pointer: coarse)': 1,
|
|
132
|
+
},
|
|
133
|
+
'--_reveal-position': {
|
|
134
|
+
default: 'static',
|
|
135
|
+
':hover': {'@media (hover: hover)': 'static'},
|
|
136
|
+
':focus-within': 'static',
|
|
137
|
+
'@media (any-pointer: coarse)': 'static',
|
|
138
|
+
},
|
|
139
|
+
'--_reveal-delay': {
|
|
140
|
+
default: '0s, 0s',
|
|
141
|
+
':hover': {'@media (hover: hover)': '0s, 0s'},
|
|
142
|
+
':focus-within': '0s, 0s',
|
|
143
|
+
'@media (any-pointer: coarse)': '0s, 0s',
|
|
144
|
+
},
|
|
145
|
+
'--_reveal-delay-reduced': {
|
|
146
|
+
default: '0s, 0s',
|
|
147
|
+
':hover': {'@media (hover: hover)': '0s, 0s'},
|
|
148
|
+
':focus-within': '0s, 0s',
|
|
149
|
+
'@media (any-pointer: coarse)': '0s, 0s',
|
|
150
|
+
},
|
|
151
|
+
'--_conceal-opacity': {
|
|
152
|
+
default: 0,
|
|
153
|
+
':hover': {'@media (hover: hover)': 0},
|
|
154
|
+
},
|
|
155
|
+
'--_fade-delay': {
|
|
156
|
+
default: '0s',
|
|
157
|
+
':hover': {'@media (hover: hover)': '0s'},
|
|
158
|
+
':focus-within': '0s',
|
|
159
|
+
'@media (any-pointer: coarse)': '0s',
|
|
160
|
+
},
|
|
57
161
|
},
|
|
162
|
+
hoverDelay: (delay: string) => ({'--_hover-delay': delay}),
|
|
58
163
|
// The fallbacks make content spread outside a reveal container fail visible
|
|
59
164
|
// rather than invisible.
|
|
60
165
|
reveal: {
|
|
@@ -67,7 +172,8 @@ export const styles = stylex.create({
|
|
|
67
172
|
transitionBehavior: 'allow-discrete',
|
|
68
173
|
transitionDelay: {
|
|
69
174
|
default: 'var(--_reveal-delay, 0s, 0s)',
|
|
70
|
-
'@media (prefers-reduced-motion: reduce)':
|
|
175
|
+
'@media (prefers-reduced-motion: reduce)':
|
|
176
|
+
'var(--_reveal-delay-reduced, 0s, 0s)',
|
|
71
177
|
},
|
|
72
178
|
opacity: 'var(--_reveal-opacity, 1)',
|
|
73
179
|
position: 'var(--_reveal-position, static)',
|
|
@@ -79,6 +185,7 @@ export const styles = stylex.create({
|
|
|
79
185
|
'@media (prefers-reduced-motion: reduce)': '0s',
|
|
80
186
|
},
|
|
81
187
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
188
|
+
transitionDelay: 'var(--_fade-delay, 0s)',
|
|
82
189
|
opacity: 'var(--_reveal-opacity, 1)',
|
|
83
190
|
},
|
|
84
191
|
conceal: {
|
|
@@ -88,6 +195,7 @@ export const styles = stylex.create({
|
|
|
88
195
|
'@media (prefers-reduced-motion: reduce)': '0s',
|
|
89
196
|
},
|
|
90
197
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
198
|
+
transitionDelay: 'var(--_fade-delay, 0s)',
|
|
91
199
|
opacity: 'var(--_conceal-opacity, 1)',
|
|
92
200
|
},
|
|
93
201
|
concealLayoutPreserved: {
|
|
@@ -97,6 +205,29 @@ export const styles = stylex.create({
|
|
|
97
205
|
'@media (prefers-reduced-motion: reduce)': '0s',
|
|
98
206
|
},
|
|
99
207
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
208
|
+
transitionDelay: 'var(--_fade-delay, 0s)',
|
|
100
209
|
opacity: 'var(--_conceal-opacity, 1)',
|
|
101
210
|
},
|
|
211
|
+
// Per-element overrides. These read no container state at all — they are the
|
|
212
|
+
// caller saying what THIS element looks like, whatever the container is
|
|
213
|
+
// doing, so they are plain values rather than custom properties.
|
|
214
|
+
contentShown: {
|
|
215
|
+
opacity: 1,
|
|
216
|
+
position: 'static',
|
|
217
|
+
transitionDelay: '0s',
|
|
218
|
+
},
|
|
219
|
+
// Hidden yields to focus: a forced-hidden element is still mounted and still
|
|
220
|
+
// tabbable, so it has to reappear when focus lands inside it — otherwise a
|
|
221
|
+
// keyboard user tabs into something they cannot see.
|
|
222
|
+
contentHidden: {
|
|
223
|
+
opacity: {default: 0, ':focus-within': 1},
|
|
224
|
+
position: {default: 'absolute', ':focus-within': 'static'},
|
|
225
|
+
transitionDelay: '0s',
|
|
226
|
+
},
|
|
227
|
+
// Layout-preserved content has no discrete position to flip, so its hidden
|
|
228
|
+
// variant is opacity alone.
|
|
229
|
+
contentHiddenLayoutPreserved: {
|
|
230
|
+
opacity: {default: 0, ':focus-within': 1},
|
|
231
|
+
transitionDelay: '0s',
|
|
232
|
+
},
|
|
102
233
|
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -23,13 +23,13 @@ export const docs = {
|
|
|
23
23
|
returns: [
|
|
24
24
|
{
|
|
25
25
|
name: 'getContainerProps',
|
|
26
|
-
type: '() => {className?: string; style?: CSSProperties}',
|
|
27
|
-
description: 'Spread onto the container whose hover/focus-within drives the reveal.',
|
|
26
|
+
type: '(options?: ContainerRevealOptions) => {className?: string; style?: CSSProperties}',
|
|
27
|
+
description: 'Spread onto the container whose hover/focus-within drives the reveal. Accepts hoverDelay (ms the pointer must dwell before the reveal starts — a hover-intent gate like Tooltip\'s and HoverCard\'s delay, so a cursor sweeping across a list leaves nothing painted behind it) and forceState ("active" | "inactive") to pin the trigger state when a caller owns it — a motion gate, a scroll, a row whose menu is open. "inactive" still yields to keyboard focus and coarse pointers.',
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
name: 'getContentRevealProps',
|
|
31
31
|
type: '(options?: ContentRevealOptions) => {className?: string; style?: CSSProperties}',
|
|
32
|
-
description: 'Spread onto each revealed
|
|
32
|
+
description: 'Spread onto each revealed / concealed child. Accepts isRevealInverted to conceal-on-hover instead of reveal-on-hover, isLayoutPreserved to reserve the layout box while hidden (opacity-only) and avoid layout shift, and forceVisibility ("shown" | "hidden") to pin this one element\'s appearance whatever the container is doing. "hidden" yields to focus.',
|
|
33
33
|
},
|
|
34
34
|
],
|
|
35
35
|
usage: {
|
|
@@ -40,6 +40,9 @@ export const docs = {
|
|
|
40
40
|
{ guidance: true, description: 'Use for secondary affordances: reveal-on-hover row actions (edit/copy/remove on list or table rows) and overlay controls on a card or media tile (e.g. Thumbnail\'s remove button).' },
|
|
41
41
|
{ guidance: true, description: 'Gate the reveal with isEnabled when a consumer prop decides whether content is revealed on hover or always shown; it can change at any time.' },
|
|
42
42
|
{ guidance: true, description: 'Pass isLayoutPreserved for absolutely-positioned or overlay content to reserve its box and avoid layout shift when it appears.' },
|
|
43
|
+
{ guidance: true, description: 'Set a hoverDelay (100-250ms) on rows in a long list, so a cursor travelling across the list does not light up every row it passes; keyboard and touch still reveal immediately.' },
|
|
44
|
+
{ guidance: true, description: 'Reach for forceState when something other than the pointer owns the interaction (a drag, a scroll or motion gate, an open row menu), and forceVisibility when just one element should ignore the container.' },
|
|
45
|
+
{ guidance: false, description: 'Reach past the API into the hook\'s private custom properties (--_reveal-opacity and friends) to suppress a reveal; use forceState / forceVisibility, which survive a rename.' },
|
|
43
46
|
{ guidance: false, description: 'Use it to hide content that must always be discoverable; keep essential actions visible instead of gating them behind hover.' },
|
|
44
47
|
],
|
|
45
48
|
},
|
|
@@ -58,8 +61,8 @@ export const docsDense = {
|
|
|
58
61
|
'options.isEnabled': 'when false hook is inert: no container styles, content getters return no styles, content always shown. Read every render, so it can flip after mount.',
|
|
59
62
|
},
|
|
60
63
|
returnDescriptions: {
|
|
61
|
-
getContainerProps: 'spread onto container whose hover/focus-within drives reveal.',
|
|
62
|
-
getContentRevealProps: 'spread onto each revealed / concealed child. Accepts isRevealInverted (conceal-on-hover)
|
|
64
|
+
getContainerProps: 'spread onto container whose hover/focus-within drives reveal. Accepts hoverDelay (ms dwell before reveal starts — hover-intent gate like Tooltip / HoverCard delay) + forceState ("active" | "inactive") to pin trigger state when a caller owns it. "inactive" yields to keyboard focus + coarse pointers.',
|
|
65
|
+
getContentRevealProps: 'spread onto each revealed / concealed child. Accepts isRevealInverted (conceal-on-hover), isLayoutPreserved (reserve layout box while hidden) + forceVisibility ("shown" | "hidden") to pin this element regardless of container. "hidden" yields to focus.',
|
|
63
66
|
},
|
|
64
67
|
usage: {
|
|
65
68
|
description:
|
|
@@ -69,6 +72,9 @@ export const docsDense = {
|
|
|
69
72
|
{ guidance: true, description: 'Use for secondary affordances: reveal-on-hover row actions (edit/copy/remove on list / table rows) + overlay controls on card / media tile (e.g. Thumbnail remove button).' },
|
|
70
73
|
{ guidance: true, description: 'Gate reveal w/ isEnabled when a consumer prop decides revealed-on-hover vs always shown; can change at any time.' },
|
|
71
74
|
{ guidance: true, description: 'Pass isLayoutPreserved for absolutely-positioned / overlay content to reserve its box + avoid layout shift.' },
|
|
75
|
+
{ guidance: true, description: 'Set hoverDelay (100-250ms) on rows in a long list so a travelling cursor does not light up every row; keyboard + touch still reveal immediately.' },
|
|
76
|
+
{ guidance: true, description: 'forceState when something else owns the interaction (drag, scroll / motion gate, open row menu); forceVisibility when one element should ignore the container.' },
|
|
77
|
+
{ guidance: false, description: 'Reach past the API into private custom properties (--_reveal-opacity etc) to suppress a reveal; use forceState / forceVisibility.' },
|
|
72
78
|
{ guidance: false, description: 'Use to hide content that must always be discoverable; keep essential actions visible instead of gating behind hover.' },
|
|
73
79
|
],
|
|
74
80
|
},
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* @file useContainerReveal.test.tsx
|
|
5
5
|
* @input Uses vitest, @testing-library/react, useContainerReveal
|
|
6
6
|
* @output Unit tests for the enabled/disabled contract, the dynamic isEnabled
|
|
7
|
-
* prop, the
|
|
8
|
-
*
|
|
7
|
+
* prop, the container options (hoverDelay, forceState), the per-element
|
|
8
|
+
* option → style-block mapping, and the promise that a large flat list
|
|
9
|
+
* mounts without dev warnings.
|
|
9
10
|
* @position Testing; validates useContainerReveal.ts.
|
|
10
11
|
*
|
|
11
12
|
* Nesting isolation is a cascade behavior jsdom does not implement, so it is
|
|
12
13
|
* verified in a real browser (Storybook's NestedIsolation story) rather than
|
|
13
|
-
* asserted here.
|
|
14
|
+
* asserted here. The same goes for what the dwell and the forced states
|
|
15
|
+
* actually paint: these tests assert the wiring, the browser proves the pixels
|
|
16
|
+
* (HoverIntentDelay, ForcedVisibility).
|
|
14
17
|
*
|
|
15
18
|
* SYNC: When useContainerReveal.ts changes, update these tests.
|
|
16
19
|
*/
|
|
@@ -74,6 +77,70 @@ describe('useContainerReveal', () => {
|
|
|
74
77
|
expect(clipped).not.toBe(preserved);
|
|
75
78
|
});
|
|
76
79
|
|
|
80
|
+
it('forceState pins each end of the container to its own style block', () => {
|
|
81
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
82
|
+
const auto = result.current.getContainerProps().className;
|
|
83
|
+
const inactive = result.current.getContainerProps({
|
|
84
|
+
forceState: 'inactive',
|
|
85
|
+
}).className;
|
|
86
|
+
const active = result.current.getContainerProps({
|
|
87
|
+
forceState: 'active',
|
|
88
|
+
}).className;
|
|
89
|
+
expect(new Set([auto, inactive, active]).size).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('forceVisibility pins one element, independent of its reveal mode', () => {
|
|
93
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
94
|
+
const auto = result.current.getContentRevealProps().className;
|
|
95
|
+
const shown = result.current.getContentRevealProps({
|
|
96
|
+
forceVisibility: 'shown',
|
|
97
|
+
}).className;
|
|
98
|
+
const hidden = result.current.getContentRevealProps({
|
|
99
|
+
forceVisibility: 'hidden',
|
|
100
|
+
}).className;
|
|
101
|
+
expect(new Set([auto, shown, hidden]).size).toBe(3);
|
|
102
|
+
|
|
103
|
+
// The layout-preserved variant has no position to flip, so hidden maps to
|
|
104
|
+
// its own opacity-only block.
|
|
105
|
+
expect(
|
|
106
|
+
result.current.getContentRevealProps({
|
|
107
|
+
forceVisibility: 'hidden',
|
|
108
|
+
isLayoutPreserved: true,
|
|
109
|
+
}).className,
|
|
110
|
+
).not.toBe(hidden);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('hoverDelay publishes the dwell as an inline custom property', () => {
|
|
114
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
115
|
+
const {style} = result.current.getContainerProps({hoverDelay: 120});
|
|
116
|
+
expect(Object.values(style ?? {})).toContain('120ms');
|
|
117
|
+
expect(result.current.getContainerProps({hoverDelay: 0}).style).toEqual(
|
|
118
|
+
result.current.getContainerProps().style,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('hoverDelay and forceState compose on one container', () => {
|
|
123
|
+
const {result} = renderHook(() => useContainerReveal());
|
|
124
|
+
const props = result.current.getContainerProps({
|
|
125
|
+
hoverDelay: 120,
|
|
126
|
+
forceState: 'inactive',
|
|
127
|
+
});
|
|
128
|
+
expect(Object.values(props.style ?? {})).toContain('120ms');
|
|
129
|
+
expect(props.className).not.toBe(
|
|
130
|
+
result.current.getContainerProps({hoverDelay: 120}).className,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ignores container options while disabled', () => {
|
|
135
|
+
const {result} = renderHook(() => useContainerReveal({isEnabled: false}));
|
|
136
|
+
expect(
|
|
137
|
+
result.current.getContainerProps({
|
|
138
|
+
hoverDelay: 120,
|
|
139
|
+
forceState: 'inactive',
|
|
140
|
+
}),
|
|
141
|
+
).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
|
|
77
144
|
it('mounts a large flat list without a dev warning', () => {
|
|
78
145
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
79
146
|
function Row() {
|
|
@@ -20,10 +20,21 @@
|
|
|
20
20
|
* container shadows its ancestor's state for its own subtree. See
|
|
21
21
|
* containerReveal.stylex.ts.
|
|
22
22
|
*
|
|
23
|
+
* Two levers sit on top of the pointer, both still CSS-only. On the container:
|
|
24
|
+
* `hoverDelay` (dwell before the reveal starts — the Tooltip / HoverCard
|
|
25
|
+
* intent gate applied to a reveal) and `forceState` (pin the trigger state a
|
|
26
|
+
* caller owns: a motion gate, a scroll, an open menu). On a single piece of
|
|
27
|
+
* content: `forceVisibility`, which pins how THAT element looks. State belongs
|
|
28
|
+
* to the container because one container feeds children whose looks are
|
|
29
|
+
* opposite; appearance belongs to the element, where it is unambiguous.
|
|
30
|
+
* Neither lever can hide content from a keyboard user — see ACCESSIBILITY.
|
|
31
|
+
*
|
|
23
32
|
* ACCESSIBILITY (WCAG 2.2 by construction):
|
|
24
33
|
* - Revealed content is visually hidden at rest via position + opacity, so it
|
|
25
34
|
* stays in the accessibility tree and tab order — never display:none.
|
|
26
|
-
* - Keyboard: revealed on :focus-within, so tabbing in shows it
|
|
35
|
+
* - Keyboard: revealed on :focus-within, so tabbing in shows it — with no dwell
|
|
36
|
+
* to wait through, and neither an inactive container nor a forced-hidden
|
|
37
|
+
* element can keep it dark.
|
|
27
38
|
* - Touch: always visible on coarse pointers; never gated behind hover.
|
|
28
39
|
* - Concealed (inverted) content is a mouse-only visual swap: it ignores
|
|
29
40
|
* :focus-within (a keyboard user must never watch content vanish) and stays
|
|
@@ -50,6 +61,36 @@ export interface UseContainerRevealOptions {
|
|
|
50
61
|
isEnabled?: boolean;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
export interface ContainerRevealOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Pin the container's trigger state instead of letting the pointer drive it.
|
|
67
|
+
* `'active'` reads as pointed-at and `'inactive'` as at rest; omit it — the
|
|
68
|
+
* default — to leave the container on hover and focus.
|
|
69
|
+
*
|
|
70
|
+
* State, not appearance: what each child then looks like is the child's own
|
|
71
|
+
* business (revealed content fades in on `'active'`, inverted content fades
|
|
72
|
+
* out). This is the lever for state a caller owns — a motion gate over a
|
|
73
|
+
* list, a scroll in progress, a row whose menu is open and must stay lit.
|
|
74
|
+
*
|
|
75
|
+
* `'inactive'` never overrides keyboard focus or a coarse pointer: the
|
|
76
|
+
* container still reveals on :focus-within and stays revealed on touch, so
|
|
77
|
+
* it cannot hide content from a keyboard or touch user.
|
|
78
|
+
*/
|
|
79
|
+
forceState?: 'active' | 'inactive';
|
|
80
|
+
/**
|
|
81
|
+
* Hover-intent gate, in milliseconds: how long the pointer must rest on the
|
|
82
|
+
* container before the reveal starts. A pointer that passes through leaves
|
|
83
|
+
* nothing painted behind it, which is what keeps a list of rows quiet while
|
|
84
|
+
* the cursor sweeps across it.
|
|
85
|
+
*
|
|
86
|
+
* Mouse-only, like Tooltip's and HoverCard's `delay`: keyboard focus and
|
|
87
|
+
* touch reveal immediately. It survives `prefers-reduced-motion` — an intent
|
|
88
|
+
* gate is timing, not motion.
|
|
89
|
+
* @default 0
|
|
90
|
+
*/
|
|
91
|
+
hoverDelay?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
export interface ContentRevealOptions {
|
|
54
95
|
/**
|
|
55
96
|
* Conceal-on-hover instead of reveal-on-hover: content is visible at rest
|
|
@@ -64,11 +105,27 @@ export interface ContentRevealOptions {
|
|
|
64
105
|
* @default false
|
|
65
106
|
*/
|
|
66
107
|
isLayoutPreserved?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Pin THIS element's appearance, whatever the container's state: `'shown'`
|
|
110
|
+
* keeps it visible, `'hidden'` keeps it out. Omit it — the default — to
|
|
111
|
+
* follow the container.
|
|
112
|
+
*
|
|
113
|
+
* Appearance, not state: it says how one element looks, so it is unambiguous
|
|
114
|
+
* where the container's `forceState` cannot be (a container feeds revealed
|
|
115
|
+
* and inverted children at once).
|
|
116
|
+
*
|
|
117
|
+
* `'hidden'` yields to focus — a forced-hidden element is still mounted and
|
|
118
|
+
* tabbable, so it reappears when focus lands inside it.
|
|
119
|
+
*/
|
|
120
|
+
forceVisibility?: 'shown' | 'hidden';
|
|
67
121
|
}
|
|
68
122
|
|
|
69
123
|
export interface UseContainerRevealReturn {
|
|
70
124
|
/** Spread onto the container whose hover/focus-within drives the reveal. */
|
|
71
|
-
getContainerProps: () => {
|
|
125
|
+
getContainerProps: (options?: ContainerRevealOptions) => {
|
|
126
|
+
className?: string;
|
|
127
|
+
style?: CSSProperties;
|
|
128
|
+
};
|
|
72
129
|
/** Spread onto each revealed / concealed child. */
|
|
73
130
|
getContentRevealProps: (options?: ContentRevealOptions) => {
|
|
74
131
|
className?: string;
|
|
@@ -87,7 +144,11 @@ const EMPTY = Object.freeze({});
|
|
|
87
144
|
* isEnabled: revealOn === 'hover',
|
|
88
145
|
* });
|
|
89
146
|
*
|
|
90
|
-
* <div
|
|
147
|
+
* <div
|
|
148
|
+
* {...mergeProps(
|
|
149
|
+
* getContainerProps({hoverDelay: 120, forceState: gateState}),
|
|
150
|
+
* stylex.props(styles.row),
|
|
151
|
+
* )}>
|
|
91
152
|
* {label}
|
|
92
153
|
* <span {...mergeProps(getContentRevealProps(), stylex.props(styles.actions))}>
|
|
93
154
|
* {actions}
|
|
@@ -108,10 +169,21 @@ export function useContainerReveal(
|
|
|
108
169
|
};
|
|
109
170
|
}
|
|
110
171
|
return {
|
|
111
|
-
getContainerProps: () =>
|
|
172
|
+
getContainerProps: (containerOptions: ContainerRevealOptions = {}) => {
|
|
173
|
+
const {forceState, hoverDelay = 0} = containerOptions;
|
|
174
|
+
return stylex.props(
|
|
175
|
+
styles.container,
|
|
176
|
+
hoverDelay > 0 && styles.hoverDelay(`${hoverDelay}ms`),
|
|
177
|
+
forceState === 'inactive' && styles.stateInactive,
|
|
178
|
+
forceState === 'active' && styles.stateActive,
|
|
179
|
+
);
|
|
180
|
+
},
|
|
112
181
|
getContentRevealProps: (contentOptions: ContentRevealOptions = {}) => {
|
|
113
|
-
const {
|
|
114
|
-
|
|
182
|
+
const {
|
|
183
|
+
isRevealInverted = false,
|
|
184
|
+
isLayoutPreserved = false,
|
|
185
|
+
forceVisibility,
|
|
186
|
+
} = contentOptions;
|
|
115
187
|
const style = isRevealInverted
|
|
116
188
|
? isLayoutPreserved
|
|
117
189
|
? styles.concealLayoutPreserved
|
|
@@ -119,7 +191,14 @@ export function useContainerReveal(
|
|
|
119
191
|
: isLayoutPreserved
|
|
120
192
|
? styles.revealLayoutPreserved
|
|
121
193
|
: styles.reveal;
|
|
122
|
-
return stylex.props(
|
|
194
|
+
return stylex.props(
|
|
195
|
+
style,
|
|
196
|
+
forceVisibility === 'shown' && styles.contentShown,
|
|
197
|
+
forceVisibility === 'hidden' &&
|
|
198
|
+
(isLayoutPreserved
|
|
199
|
+
? styles.contentHiddenLayoutPreserved
|
|
200
|
+
: styles.contentHidden),
|
|
201
|
+
);
|
|
123
202
|
},
|
|
124
203
|
};
|
|
125
204
|
}, [isEnabled]);
|