@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 @@
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;AAsDjE,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,CA2FtD"}
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.ea693ac",
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';
@@ -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},
@@ -12,6 +12,7 @@ export const docs = {
12
12
  theming: {
13
13
  targets: [
14
14
  {className: 'astryx-field-status', visualProps: ['type', 'variant']},
15
+ {className: 'astryx-field-status-icon', visualProps: ['type']},
15
16
  ],
16
17
  },
17
18
  props: [
@@ -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
- borderBottomLeftRadius: radiusVars['--radius-element'],
60
- borderBottomRightRadius: radiusVars['--radius-element'],
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 icon={statusIconMap[type]} size="sm" color="inherit" />
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
- outline: {
123
- default: 'none',
124
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
125
- },
126
- outlineOffset: '0',
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
- {...stylex.props(
381
- styles.removeSlot,
382
- isHoverReveal && styles.removeOnHover,
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
- {...stylex.props(
414
- styles.imageContainer,
415
- isHoverReveal && thumbnailScope,
416
- isInteractive && styles.interactive,
417
- isInteractive && styles.overlay,
418
- isInteractive && styles.hoverOnPointer,
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
- maxWidth: 960,
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
  </>