@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 @@
|
|
|
1
|
+
{"version":3,"file":"useContainerReveal.d.ts","sourceRoot":"","sources":["../../src/hooks/useContainerReveal.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAsB,KAAK,aAAa,EAAC,MAAM,OAAO,CAAC;AA0C9D,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,iBAAiB,EAAE,MAAM;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,aAAa,CAAA;KAAC,CAAC;IACrE,mDAAmD;IACnD,qBAAqB,EAAE,CACrB,OAAO,CAAC,EAAE,oBAAoB,KAC3B;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,aAAa,CAAA;KAAC,CAAC;CAClD;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,GAAE,yBAA8B,GACtC,wBAAwB,CAiD1B"}
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
import { useState, useEffect } from 'react';
|
|
33
|
+
import * as stylex from '@stylexjs/stylex';
|
|
34
|
+
import { POOL, POOL_SIZE } from "./containerReveal.pool.stylex.js";
|
|
35
|
+
import { useDevWarning } from "./useDevWarning.js";
|
|
36
|
+
|
|
37
|
+
// Module-level free-list. Each mounted, enabled useContainerReveal claims a
|
|
38
|
+
// distinct pool slot so that any two CONCURRENTLY mounted containers — in
|
|
39
|
+
// particular a nested container inside another's revealed content — get
|
|
40
|
+
// different markers and cannot leak hover/focus into one another. Slots are
|
|
41
|
+
// returned on unmount. Sibling containers that would only ever collide when
|
|
42
|
+
// the pool is exhausted are harmless (a sibling is never an ancestor), so the
|
|
43
|
+
// pool only needs to cover the number of concurrently mounted containers, not
|
|
44
|
+
// instances over time.
|
|
45
|
+
const claimed = new Array(POOL_SIZE).fill(false);
|
|
46
|
+
function claimSlot() {
|
|
47
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
48
|
+
if (!claimed[i]) {
|
|
49
|
+
claimed[i] = true;
|
|
50
|
+
return {
|
|
51
|
+
index: i,
|
|
52
|
+
isExhausted: false
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Exhausted: fall back to slot 0. Safe for siblings (a sibling is never an
|
|
57
|
+
// ancestor); only nesting deeper than POOL_SIZE could reintroduce a leak.
|
|
58
|
+
// Surfaced via useDevWarning so the pool can be grown.
|
|
59
|
+
return {
|
|
60
|
+
index: 0,
|
|
61
|
+
isExhausted: true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function releaseSlot(index, isExhausted) {
|
|
65
|
+
// A fallback claim never owned the slot exclusively; leave it as-is.
|
|
66
|
+
if (!isExhausted) {
|
|
67
|
+
claimed[index] = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const EMPTY = Object.freeze({});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scoped, CSS-only hover/focus reveal for content inside a container.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* const {getContainerProps, getContentRevealProps} = useContainerReveal({
|
|
78
|
+
* isEnabled: revealOn === 'hover',
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
* <div {...mergeProps(getContainerProps(), stylex.props(styles.row))}>
|
|
82
|
+
* {label}
|
|
83
|
+
* <span {...mergeProps(getContentRevealProps(), stylex.props(styles.actions))}>
|
|
84
|
+
* {actions}
|
|
85
|
+
* </span>
|
|
86
|
+
* </div>
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function useContainerReveal(options = {}) {
|
|
90
|
+
const {
|
|
91
|
+
isEnabled = true
|
|
92
|
+
} = options;
|
|
93
|
+
|
|
94
|
+
// Claim a slot for this container's lifetime. Assigned in the initializer
|
|
95
|
+
// (not the render body) and released on unmount so the free-list stays
|
|
96
|
+
// effect-scoped and StrictMode-safe.
|
|
97
|
+
const [claim] = useState(() => isEnabled ? claimSlot() : null);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (claim == null) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
return () => releaseSlot(claim.index, claim.isExhausted);
|
|
103
|
+
}, [claim]);
|
|
104
|
+
useDevWarning('useContainerReveal', `More than ${POOL_SIZE} reveal containers are mounted at once; nested ` + `containers beyond the pool may share a marker. Add markers to ` + `containerReveal.pool.stylex.ts and raise POOL_SIZE.`, claim?.isExhausted ?? false);
|
|
105
|
+
if (claim == null) {
|
|
106
|
+
return {
|
|
107
|
+
getContainerProps: () => EMPTY,
|
|
108
|
+
getContentRevealProps: () => EMPTY
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const slot = POOL[claim.index];
|
|
112
|
+
return {
|
|
113
|
+
getContainerProps: () => stylex.props(slot.marker),
|
|
114
|
+
getContentRevealProps: (contentOptions = {}) => {
|
|
115
|
+
const {
|
|
116
|
+
isRevealInverted = false,
|
|
117
|
+
isLayoutPreserved = false
|
|
118
|
+
} = contentOptions;
|
|
119
|
+
const style = isRevealInverted ? isLayoutPreserved ? slot.concealLayoutPreserved : slot.conceal : isLayoutPreserved ? slot.revealLayoutPreserved : slot.reveal;
|
|
120
|
+
return stylex.props(style);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useInputStatusIcon.d.ts","sourceRoot":"","sources":["../../src/hooks/useInputStatusIcon.tsx"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAErC,OAAO,EAAsB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAC3D,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAC,WAAW,EAAkB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"useInputStatusIcon.d.ts","sourceRoot":"","sources":["../../src/hooks/useInputStatusIcon.tsx"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AAErC,OAAO,EAAsB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAC3D,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAC,WAAW,EAAkB,MAAM,gBAAgB,CAAC;AAuDjE,MAAM,WAAW,yBAAyB;IACxC,2DAA2D;IAC3D,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,yDAAyD;IACzD,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC,+EAA+E;IAC/E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;OAOG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,MAAM,EACN,aAA0B,EAC1B,SAAiB,EACjB,IAAW,GACZ,EAAE,yBAAyB,GAAG,wBAAwB,CAqGtD"}
|
|
@@ -38,6 +38,7 @@ import { useTooltip } from "../Tooltip/index.js";
|
|
|
38
38
|
import { useTranslator } from "../i18n/index.js";
|
|
39
39
|
import "../theme/tokens.stylex.js";
|
|
40
40
|
import { colorVars, radiusVars } from "../theme/tokens.stylex.js";
|
|
41
|
+
import { themeProps } from "../utils/themeProps.js";
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Maps each status type to its glyph. Shared so every input shows the same icon
|
|
@@ -128,6 +129,16 @@ export function useInputStatusIcon({
|
|
|
128
129
|
icon: STATUS_ICON[status.type],
|
|
129
130
|
size: size,
|
|
130
131
|
color: status.type
|
|
132
|
+
// Stable theme target on the status glyph itself, so a theme can restyle
|
|
133
|
+
// just this icon (color, size) — and each status — via `defineTheme`.
|
|
134
|
+
// Same-element rules in @layer astryx-theme win over the icon's own base
|
|
135
|
+
// width/height/fontSize, which a field-level target could not reach.
|
|
136
|
+
// Shared by the attached and tooltip variants across all bordered inputs.
|
|
137
|
+
,
|
|
138
|
+
...themeProps('input-status-icon', {
|
|
139
|
+
size,
|
|
140
|
+
status: status.type
|
|
141
|
+
})
|
|
131
142
|
});
|
|
132
143
|
|
|
133
144
|
// Attached (and tooltip-without-message) variants: plain, non-interactive
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.2.0-canary.
|
|
3
|
+
"version": "0.2.0-canary.fc53ff7",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
10
10
|
import {useState} from 'react';
|
|
11
|
-
import {render, screen, waitFor} from '@testing-library/react';
|
|
11
|
+
import {render, screen, waitFor, fireEvent} from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
13
|
import {DropdownMenu} from './DropdownMenu';
|
|
14
14
|
import {DropdownMenuItem} from './DropdownMenuItem';
|
|
@@ -510,6 +510,34 @@ describe('DropdownMenuSubMenu accessibility (WCAG 2.2 / APG)', () => {
|
|
|
510
510
|
expect(onPick).toHaveBeenCalledWith('data');
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
// Regression: hovering the submenu trigger while a sibling item still holds
|
|
514
|
+
// focus must move the single focus-driven highlight onto the trigger — not
|
|
515
|
+
// leave two items highlighted at once (the trigger via :hover, the sibling
|
|
516
|
+
// via :focus). Mirrors DropdownMenuItem's hover-focus behavior.
|
|
517
|
+
it('moves focus to the submenu trigger on hover, keeping a single highlight', async () => {
|
|
518
|
+
const user = userEvent.setup();
|
|
519
|
+
render(<MoveMenu />);
|
|
520
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
521
|
+
|
|
522
|
+
const rename = screen.getByRole('menuitem', {
|
|
523
|
+
name: 'Rename',
|
|
524
|
+
hidden: true,
|
|
525
|
+
});
|
|
526
|
+
const trigger = screen.getByRole('menuitem', {
|
|
527
|
+
name: /Move to/,
|
|
528
|
+
hidden: true,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
rename.focus();
|
|
532
|
+
expect(rename).toHaveFocus();
|
|
533
|
+
|
|
534
|
+
// A mouse hover over the submenu trigger moves focus onto it, so the single
|
|
535
|
+
// focus-driven highlight follows the pointer instead of leaving two.
|
|
536
|
+
fireEvent.pointerMove(trigger, {pointerType: 'mouse'});
|
|
537
|
+
expect(trigger).toHaveFocus();
|
|
538
|
+
expect(rename).not.toHaveFocus();
|
|
539
|
+
});
|
|
540
|
+
|
|
513
541
|
// 1.4.13 Content on Hover or Focus: the flyout stays open while the pointer
|
|
514
542
|
// is over it (moving onto the flyout must not dismiss it).
|
|
515
543
|
it('keeps the flyout open while the pointer is over it', async () => {
|
|
@@ -44,6 +44,7 @@ import React, {
|
|
|
44
44
|
useMemo,
|
|
45
45
|
useRef,
|
|
46
46
|
useState,
|
|
47
|
+
type PointerEvent,
|
|
47
48
|
type ReactNode,
|
|
48
49
|
} from 'react';
|
|
49
50
|
import * as stylex from '@stylexjs/stylex';
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
useDropdownMenuContext,
|
|
79
80
|
type DropdownMenuContextValue,
|
|
80
81
|
} from './DropdownMenuContext';
|
|
82
|
+
import {focusMenuItemOnHover} from './menuItemHover';
|
|
81
83
|
|
|
82
84
|
const triggerStyles = stylex.create({
|
|
83
85
|
root: {
|
|
@@ -93,11 +95,6 @@ const triggerStyles = stylex.create({
|
|
|
93
95
|
default: 'transparent',
|
|
94
96
|
':focus': colorVars['--color-overlay-hover'],
|
|
95
97
|
},
|
|
96
|
-
':hover': {
|
|
97
|
-
'@media (hover: hover)': {
|
|
98
|
-
backgroundColor: colorVars['--color-overlay-hover'],
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
98
|
border: 'none',
|
|
102
99
|
cursor: 'pointer',
|
|
103
100
|
textAlign: 'start',
|
|
@@ -393,6 +390,16 @@ export function DropdownMenuSubMenu(
|
|
|
393
390
|
[isDisabled, open],
|
|
394
391
|
);
|
|
395
392
|
|
|
393
|
+
// Move the single focus-driven highlight onto the trigger as the pointer
|
|
394
|
+
// enters it, so a sibling item that still holds focus doesn't stay
|
|
395
|
+
// highlighted alongside the hovered trigger. This is separate from the
|
|
396
|
+
// hover-open intent (onMouseEnter/onMouseLeave) — the flyout still opens on
|
|
397
|
+
// the hover delay; this only keeps the highlight single.
|
|
398
|
+
const handlePointerMove = useCallback(
|
|
399
|
+
(e: PointerEvent<HTMLElement>) => focusMenuItemOnHover(e, isDisabled),
|
|
400
|
+
[isDisabled],
|
|
401
|
+
);
|
|
402
|
+
|
|
396
403
|
// Enter/Space activate the focused row; typeahead jumps by first character;
|
|
397
404
|
// the close key (Left, or Right in RTL) returns focus to the trigger;
|
|
398
405
|
// arrows/Home/End defer to useListFocus (RTL-aware).
|
|
@@ -494,6 +501,7 @@ export function DropdownMenuSubMenu(
|
|
|
494
501
|
data-testid={testId}
|
|
495
502
|
onMouseEnter={triggerProps.onMouseEnter}
|
|
496
503
|
onMouseLeave={triggerProps.onMouseLeave}
|
|
504
|
+
onPointerMove={handlePointerMove}
|
|
497
505
|
startContent={
|
|
498
506
|
icon
|
|
499
507
|
? renderIconSlot(icon, {size: 'sm', color: 'secondary'})
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - /packages/core/src/DropdownMenu/DropdownMenuItem.tsx
|
|
17
17
|
* - /packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx
|
|
18
18
|
* - /packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx
|
|
19
|
+
* - /packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type {PointerEvent} from 'react';
|
package/src/Field/Field.doc.mjs
CHANGED
|
@@ -30,6 +30,10 @@ export const docs = {
|
|
|
30
30
|
{className: 'astryx-field', visualProps: ['layout']},
|
|
31
31
|
{className: 'astryx-field-label'},
|
|
32
32
|
{className: 'astryx-field-status', visualProps: ['type', 'variant']},
|
|
33
|
+
{
|
|
34
|
+
className: 'astryx-input-status-icon',
|
|
35
|
+
visualProps: ['size', 'status'],
|
|
36
|
+
},
|
|
33
37
|
],
|
|
34
38
|
vars: [
|
|
35
39
|
{name: '--_field-radius', description: 'Border radius of input fields', default: 'var(--radius-element)', private: true},
|
|
@@ -372,6 +372,67 @@ describe('FieldStatus', () => {
|
|
|
372
372
|
});
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
+
describe('field-status-icon theme target', () => {
|
|
376
|
+
// The stable theme target lands on the detached message box's leading glyph
|
|
377
|
+
// itself, so a theme can restyle (e.g. resize) just this icon via
|
|
378
|
+
// `defineTheme`. It reflects the status type as a data attribute so themes
|
|
379
|
+
// can target per status, mirroring the parent astryx-field-status.
|
|
380
|
+
const getStatusIcon = (root: HTMLElement): HTMLElement => {
|
|
381
|
+
const icon = root.querySelector('.astryx-field-status-icon');
|
|
382
|
+
if (icon == null) {
|
|
383
|
+
throw new Error('status icon not found');
|
|
384
|
+
}
|
|
385
|
+
return icon as HTMLElement;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
it('renders the target on the detached leading icon', () => {
|
|
389
|
+
render(
|
|
390
|
+
<FieldStatus
|
|
391
|
+
type="error"
|
|
392
|
+
message="msg"
|
|
393
|
+
variant="detached"
|
|
394
|
+
data-testid="fs"
|
|
395
|
+
/>,
|
|
396
|
+
);
|
|
397
|
+
const icon = getStatusIcon(screen.getByTestId('fs'));
|
|
398
|
+
expect(icon).toHaveClass('astryx-field-status-icon');
|
|
399
|
+
expect(icon).toHaveClass('astryx-icon');
|
|
400
|
+
expect(icon).toHaveAttribute('data-type', 'error');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('reflects the status type per status', () => {
|
|
404
|
+
for (const type of ['error', 'warning', 'success'] as const) {
|
|
405
|
+
const {unmount} = render(
|
|
406
|
+
<FieldStatus
|
|
407
|
+
type={type}
|
|
408
|
+
message="msg"
|
|
409
|
+
variant="detached"
|
|
410
|
+
data-testid="fs"
|
|
411
|
+
/>,
|
|
412
|
+
);
|
|
413
|
+
expect(getStatusIcon(screen.getByTestId('fs'))).toHaveAttribute(
|
|
414
|
+
'data-type',
|
|
415
|
+
type,
|
|
416
|
+
);
|
|
417
|
+
unmount();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('does not render the target for the attached variant', () => {
|
|
422
|
+
render(
|
|
423
|
+
<FieldStatus
|
|
424
|
+
type="error"
|
|
425
|
+
message="msg"
|
|
426
|
+
variant="attached"
|
|
427
|
+
data-testid="fs"
|
|
428
|
+
/>,
|
|
429
|
+
);
|
|
430
|
+
expect(
|
|
431
|
+
screen.getByTestId('fs').querySelector('.astryx-field-status-icon'),
|
|
432
|
+
).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
375
436
|
describe('edge cases', () => {
|
|
376
437
|
it('renders an empty message without crashing', () => {
|
|
377
438
|
render(<FieldStatus type="error" message="" data-testid="fs" />);
|
|
@@ -56,8 +56,8 @@ const styles = stylex.create({
|
|
|
56
56
|
paddingBlockStart: `calc(${spacingVars['--spacing-1-5']} + ${spacingVars['--spacing-2']})`,
|
|
57
57
|
paddingBlockEnd: spacingVars['--spacing-2'],
|
|
58
58
|
paddingInline: spacingVars['--spacing-2'],
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
borderEndStartRadius: radiusVars['--radius-element'],
|
|
60
|
+
borderEndEndRadius: radiusVars['--radius-element'],
|
|
61
61
|
},
|
|
62
62
|
detached: {
|
|
63
63
|
marginTop: spacingVars['--spacing-1'],
|
|
@@ -216,7 +216,18 @@ export function FieldStatus({
|
|
|
216
216
|
{variant === 'detached' ? (
|
|
217
217
|
<span {...stylex.props(styles.detachedContent)}>
|
|
218
218
|
<span {...stylex.props(styles.detachedIcon)}>
|
|
219
|
-
<Icon
|
|
219
|
+
<Icon
|
|
220
|
+
icon={statusIconMap[type]}
|
|
221
|
+
size="sm"
|
|
222
|
+
color="inherit"
|
|
223
|
+
// Stable theme target on the detached message box's leading glyph
|
|
224
|
+
// itself, so a theme can restyle just this icon (color, size) —
|
|
225
|
+
// and each status — via `defineTheme`. Same-element rules in
|
|
226
|
+
// @layer astryx-theme win over the icon's own base
|
|
227
|
+
// width/height/fontSize, which a field-level target could not
|
|
228
|
+
// reach.
|
|
229
|
+
{...themeProps('field-status-icon', {type})}
|
|
230
|
+
/>
|
|
220
231
|
</span>
|
|
221
232
|
<span>{message}</span>
|
|
222
233
|
</span>
|
|
@@ -119,11 +119,12 @@ const styles = stylex.create({
|
|
|
119
119
|
lineHeight: 'inherit',
|
|
120
120
|
color: 'inherit',
|
|
121
121
|
cursor: 'pointer',
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
// The wrapper (inputWrapperStyles.base) renders the focus ring via
|
|
123
|
+
// :focus-within when this button is focused, matching
|
|
124
|
+
// TextInput/NumberInput/Selector. The button must not draw its own
|
|
125
|
+
// :focus-visible outline or the two stack into a doubled ring over the
|
|
126
|
+
// trigger.
|
|
127
|
+
outline: 'none',
|
|
127
128
|
borderRadius: radiusVars['--radius-element'],
|
|
128
129
|
},
|
|
129
130
|
triggerPlaceholder: {
|
|
@@ -39,11 +39,11 @@ import {Skeleton} from '../Skeleton';
|
|
|
39
39
|
import {Spinner} from '../Spinner';
|
|
40
40
|
import {Tooltip} from '../Tooltip/Tooltip';
|
|
41
41
|
import {useDevWarning} from '../hooks/useDevWarning';
|
|
42
|
+
import {useContainerReveal} from '../hooks/useContainerReveal';
|
|
42
43
|
import type {BaseProps} from '../BaseProps';
|
|
43
44
|
import {mergeProps} from '../utils';
|
|
44
45
|
import {themeProps} from '../utils/themeProps';
|
|
45
46
|
import {useTranslator} from '../i18n';
|
|
46
|
-
import {thumbnailScope} from './thumbnail.markers.stylex';
|
|
47
47
|
|
|
48
48
|
export interface ThumbnailProps extends BaseProps<HTMLDivElement> {
|
|
49
49
|
/** Ref forwarded to the root element */
|
|
@@ -236,28 +236,6 @@ const styles = stylex.create({
|
|
|
236
236
|
zIndex: 1,
|
|
237
237
|
lineHeight: 0,
|
|
238
238
|
},
|
|
239
|
-
// showRemoveOn="hover": the remove button is hidden at rest and revealed
|
|
240
|
-
// when the thumbnail is hovered or when focus enters it (keyboard). Only
|
|
241
|
-
// opacity is animated — the button stays mounted and focusable so tabbing
|
|
242
|
-
// to it triggers the :focus-within reveal. Any touch-capable device (incl.
|
|
243
|
-
// hybrid laptops that also report hover) keeps the button visible so the
|
|
244
|
-
// remove path is never gated behind an unavailable hover.
|
|
245
|
-
removeOnHover: {
|
|
246
|
-
opacity: {
|
|
247
|
-
default: 0,
|
|
248
|
-
[stylex.when.ancestor(':hover', thumbnailScope)]: {
|
|
249
|
-
'@media (hover: hover)': 1,
|
|
250
|
-
},
|
|
251
|
-
[stylex.when.ancestor(':focus-within', thumbnailScope)]: 1,
|
|
252
|
-
'@media (any-pointer: coarse)': 1,
|
|
253
|
-
},
|
|
254
|
-
transitionProperty: 'opacity',
|
|
255
|
-
transitionDuration: {
|
|
256
|
-
default: durationVars['--duration-fast'],
|
|
257
|
-
'@media (prefers-reduced-motion: reduce)': '0s',
|
|
258
|
-
},
|
|
259
|
-
transitionTimingFunction: easeVars['--ease-standard'],
|
|
260
|
-
},
|
|
261
239
|
});
|
|
262
240
|
|
|
263
241
|
// =============================================================================
|
|
@@ -326,6 +304,9 @@ export function Thumbnail({
|
|
|
326
304
|
const isInteractive = onClick != null && !isDisabled && !isLoading;
|
|
327
305
|
const hasRemove = onRemove != null && !isDisabled;
|
|
328
306
|
const isHoverReveal = hasRemove && showRemoveOn === 'hover';
|
|
307
|
+
const {getContainerProps, getContentRevealProps} = useContainerReveal({
|
|
308
|
+
isEnabled: isHoverReveal,
|
|
309
|
+
});
|
|
329
310
|
const accessibleName =
|
|
330
311
|
label && alt
|
|
331
312
|
? `${label} — ${alt}`
|
|
@@ -377,9 +358,12 @@ export function Thumbnail({
|
|
|
377
358
|
|
|
378
359
|
const removeButtonEl = hasRemove ? (
|
|
379
360
|
<div
|
|
380
|
-
{...
|
|
381
|
-
styles.removeSlot,
|
|
382
|
-
|
|
361
|
+
{...mergeProps(
|
|
362
|
+
stylex.props(styles.removeSlot),
|
|
363
|
+
// The remove button is absolutely positioned in the corner, so it is
|
|
364
|
+
// already out of flow — an opacity-only reveal (layout preserved)
|
|
365
|
+
// matches its overlay placement instead of the clip recipe.
|
|
366
|
+
isHoverReveal ? getContentRevealProps({isLayoutPreserved: true}) : {},
|
|
383
367
|
)}>
|
|
384
368
|
<Button
|
|
385
369
|
icon={<Icon icon="close" size="xsm" />}
|
|
@@ -410,12 +394,14 @@ export function Thumbnail({
|
|
|
410
394
|
)}
|
|
411
395
|
{...props}>
|
|
412
396
|
<div
|
|
413
|
-
{...
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
397
|
+
{...mergeProps(
|
|
398
|
+
stylex.props(
|
|
399
|
+
styles.imageContainer,
|
|
400
|
+
isInteractive && styles.interactive,
|
|
401
|
+
isInteractive && styles.overlay,
|
|
402
|
+
isInteractive && styles.hoverOnPointer,
|
|
403
|
+
),
|
|
404
|
+
isHoverReveal ? getContainerProps() : {},
|
|
419
405
|
)}>
|
|
420
406
|
{isInteractive ? (
|
|
421
407
|
<button
|
|
@@ -123,6 +123,18 @@ const styles = stylex.create({
|
|
|
123
123
|
transform: 'translateY(-4px)',
|
|
124
124
|
},
|
|
125
125
|
},
|
|
126
|
+
// Clamp the anchored layer to the space available below the nav so a tall
|
|
127
|
+
// menu never runs off the bottom of the viewport. The layer is positioned
|
|
128
|
+
// with position-area: self-block-end, so its containing block spans from
|
|
129
|
+
// the nav's block-end to the viewport edge — 100% is exactly that space.
|
|
130
|
+
// The layer is a flex column so panelContainer can shrink and scroll its
|
|
131
|
+
// own content, keeping the surface radius/shadow static at the edges.
|
|
132
|
+
// Internal scroll is a stopgap until the mobile bottom-sheet lands.
|
|
133
|
+
panelViewportFit: {
|
|
134
|
+
display: 'flex',
|
|
135
|
+
flexDirection: 'column',
|
|
136
|
+
maxHeight: `calc(100% - ${spacingVars['--spacing-3']})`,
|
|
137
|
+
},
|
|
126
138
|
// Visual styles for the panel content container.
|
|
127
139
|
panelContainer: {
|
|
128
140
|
backgroundColor: colorVars['--color-background-popover'],
|
|
@@ -132,6 +144,11 @@ const styles = stylex.create({
|
|
|
132
144
|
borderRadius: radiusVars['--radius-container'],
|
|
133
145
|
boxShadow: shadowVars['--shadow-low'],
|
|
134
146
|
overflow: 'hidden',
|
|
147
|
+
// Allow the container to shrink inside the height-clamped layer so its
|
|
148
|
+
// content (panelContent) can scroll rather than overflow the viewport.
|
|
149
|
+
display: 'flex',
|
|
150
|
+
flexDirection: 'column',
|
|
151
|
+
minHeight: 0,
|
|
135
152
|
},
|
|
136
153
|
panelContent: {
|
|
137
154
|
display: 'flex',
|
|
@@ -139,7 +156,14 @@ const styles = stylex.create({
|
|
|
139
156
|
gap: spacingVars['--spacing-6'],
|
|
140
157
|
paddingBlock: spacingVars['--spacing-3'],
|
|
141
158
|
paddingInline: spacingVars['--spacing-3'],
|
|
142
|
-
|
|
159
|
+
// Clamp to the viewport (minus a gutter) so the anchored panel never
|
|
160
|
+
// overflows the screen edge on narrow viewports; caps at 960px otherwise.
|
|
161
|
+
maxWidth: `min(960px, calc(100dvw - ${spacingVars['--spacing-4']}))`,
|
|
162
|
+
boxSizing: 'border-box',
|
|
163
|
+
// Scroll internally when the menu is taller than the available space
|
|
164
|
+
// below the nav (paired with panelViewportFit on the layer).
|
|
165
|
+
overflowY: 'auto',
|
|
166
|
+
overscrollBehavior: 'contain',
|
|
143
167
|
},
|
|
144
168
|
menuWrapper: {
|
|
145
169
|
flexGrow: 2,
|
|
@@ -484,7 +508,7 @@ function DefaultMegaMenu({
|
|
|
484
508
|
{
|
|
485
509
|
placement: 'below',
|
|
486
510
|
alignment: slot,
|
|
487
|
-
xstyle: styles.panelAnimation,
|
|
511
|
+
xstyle: [styles.panelAnimation, styles.panelViewportFit],
|
|
488
512
|
},
|
|
489
513
|
)}
|
|
490
514
|
</>
|