@astryxdesign/core 0.4.1 → 0.4.2-canary.01592f7
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/CHANGELOG.md +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +96 -35
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +40 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- 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 +2 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +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/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +16 -5
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +44 -48
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +153 -32
- package/src/ComplexSelector/index.ts +3 -1
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +57 -51
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- package/src/theme/useTheme.test.tsx +18 -0
|
@@ -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, fireEvent} from '@testing-library/react';
|
|
11
|
+
import {render, screen, waitFor, fireEvent, act} 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';
|
|
@@ -708,3 +708,74 @@ describe('DropdownMenuSubMenu theming slots', () => {
|
|
|
708
708
|
).toBeInTheDocument();
|
|
709
709
|
});
|
|
710
710
|
});
|
|
711
|
+
|
|
712
|
+
describe('DropdownMenuSubMenu hover/click guard', () => {
|
|
713
|
+
it('keeps the flyout open when a hover-open is immediately clicked', async () => {
|
|
714
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
715
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
716
|
+
render(<MoveMenu />);
|
|
717
|
+
|
|
718
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
719
|
+
const trigger = screen.getByRole('menuitem', {
|
|
720
|
+
name: /Move to/,
|
|
721
|
+
hidden: true,
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await user.hover(trigger);
|
|
725
|
+
act(() => {
|
|
726
|
+
vi.advanceTimersByTime(300);
|
|
727
|
+
});
|
|
728
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
729
|
+
|
|
730
|
+
await user.click(trigger);
|
|
731
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
732
|
+
expect(
|
|
733
|
+
screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
|
|
734
|
+
).toHaveFocus();
|
|
735
|
+
|
|
736
|
+
vi.useRealTimers();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('closes on a click that lands well after the hover-open', async () => {
|
|
740
|
+
vi.useFakeTimers({shouldAdvanceTime: true});
|
|
741
|
+
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
|
|
742
|
+
render(<MoveMenu />);
|
|
743
|
+
|
|
744
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
745
|
+
const trigger = screen.getByRole('menuitem', {
|
|
746
|
+
name: /Move to/,
|
|
747
|
+
hidden: true,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await user.hover(trigger);
|
|
751
|
+
act(() => {
|
|
752
|
+
vi.advanceTimersByTime(300);
|
|
753
|
+
});
|
|
754
|
+
act(() => {
|
|
755
|
+
vi.advanceTimersByTime(1200);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await user.click(trigger);
|
|
759
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
760
|
+
|
|
761
|
+
vi.useRealTimers();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('moves focus into the flyout synchronously on a click-open', async () => {
|
|
765
|
+
const user = userEvent.setup();
|
|
766
|
+
render(<MoveMenu />);
|
|
767
|
+
|
|
768
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
769
|
+
const trigger = screen.getByRole('menuitem', {
|
|
770
|
+
name: /Move to/,
|
|
771
|
+
hidden: true,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await user.click(trigger);
|
|
775
|
+
|
|
776
|
+
// No waitFor: a deferred (rAF) focus would fail here.
|
|
777
|
+
expect(
|
|
778
|
+
screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
|
|
779
|
+
).toHaveFocus();
|
|
780
|
+
});
|
|
781
|
+
});
|
|
@@ -299,12 +299,16 @@ export function DropdownMenuSubMenu(
|
|
|
299
299
|
|
|
300
300
|
// Hover-intent: entering the trigger opens after a short delay; leaving
|
|
301
301
|
// either surface closes after a delay. Hover-open does not steal focus.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
302
|
+
// Hover intent and the shared hover→click guard only: this level owns its own
|
|
303
|
+
// click handling, roving focus and typeahead. popover="manual", so the
|
|
304
|
+
// invoker wiring other consumers need does not apply.
|
|
305
|
+
const {triggerProps, contentProps, confirmHoverOpen} =
|
|
306
|
+
useMenuHover<HTMLDivElement>({
|
|
307
|
+
show: showLayer,
|
|
308
|
+
hide: hideLayer,
|
|
309
|
+
isOpen,
|
|
310
|
+
isEnabled: canOpen,
|
|
311
|
+
});
|
|
308
312
|
|
|
309
313
|
const open = useCallback(
|
|
310
314
|
(options?: {focusFirst?: boolean}) => {
|
|
@@ -313,18 +317,12 @@ export function DropdownMenuSubMenu(
|
|
|
313
317
|
}
|
|
314
318
|
layer.show();
|
|
315
319
|
if (options?.focusFirst) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// arrow keys rove the parent while the empty flyout stays open.
|
|
323
|
-
const focusedItem = focusFirst();
|
|
324
|
-
if (!focusedItem) {
|
|
325
|
-
menuRef.current?.focus();
|
|
326
|
-
}
|
|
327
|
-
});
|
|
320
|
+
// Synchronous by design — see the focus note in useMenuHover. A
|
|
321
|
+
// still-loading flyout has no focusable item, so fall back to the
|
|
322
|
+
// container: keyboard ownership must leave the parent list either way.
|
|
323
|
+
if (!focusFirst()) {
|
|
324
|
+
menuRef.current?.focus();
|
|
325
|
+
}
|
|
328
326
|
}
|
|
329
327
|
},
|
|
330
328
|
[canOpen, layer, focusFirst, menuRef],
|
|
@@ -354,13 +352,19 @@ export function DropdownMenuSubMenu(
|
|
|
354
352
|
if (isDisabled) {
|
|
355
353
|
return;
|
|
356
354
|
}
|
|
357
|
-
//
|
|
355
|
+
// Toggles, except for the click that follows a hover-open (#3121).
|
|
358
356
|
if (isOpen) {
|
|
357
|
+
if (confirmHoverOpen()) {
|
|
358
|
+
if (!focusFirst()) {
|
|
359
|
+
menuRef.current?.focus();
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
359
363
|
close({focusTrigger: true});
|
|
360
364
|
} else {
|
|
361
365
|
open({focusFirst: true});
|
|
362
366
|
}
|
|
363
|
-
}, [isDisabled, isOpen, open, close]);
|
|
367
|
+
}, [isDisabled, isOpen, open, close, confirmHoverOpen, focusFirst, menuRef]);
|
|
364
368
|
|
|
365
369
|
const handleTriggerKeyDown = useCallback(
|
|
366
370
|
(e: React.KeyboardEvent) => {
|
|
@@ -114,7 +114,7 @@ export const docs = {
|
|
|
114
114
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
115
115
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
116
116
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
117
|
-
{ guidance:
|
|
117
|
+
{ guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
|
|
118
118
|
],
|
|
119
119
|
anatomy: [
|
|
120
120
|
{name: 'Trigger', required: true, description: 'The element that opens the hover card on hover or focus: a button, link, or inline text.'},
|
|
@@ -229,7 +229,7 @@ export const docsZh = {
|
|
|
229
229
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
230
230
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
231
231
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
232
|
-
{ guidance:
|
|
232
|
+
{ guidance: true, description: 'Prefer placing HoverCard in a block context rather than directly in a <p>, heading, or link. Those placements are supported when necessary through a corrective portal, but the DOM and tab order may differ.' },
|
|
233
233
|
],
|
|
234
234
|
},
|
|
235
235
|
};
|
|
@@ -247,7 +247,7 @@ export const docsDense = {
|
|
|
247
247
|
{ guidance: false, description: 'Place critical actions or required information inside a hover card; users may miss content that only appears on hover.' },
|
|
248
248
|
{ guidance: false, description: 'Use a hover card when a simple Tooltip or Popover would suffice.' },
|
|
249
249
|
{ guidance: false, description: 'Use a HoverCard for content the user must interact with; it disappears when the cursor leaves.' },
|
|
250
|
-
{ guidance:
|
|
250
|
+
{ guidance: true, description: 'Prefer a block context over a direct <p>, heading, or link placement. Those placements work when necessary through a corrective portal, but DOM/tab order may differ.' },
|
|
251
251
|
],
|
|
252
252
|
},
|
|
253
253
|
components: [
|
|
@@ -9,11 +9,21 @@
|
|
|
9
9
|
* SYNC: When HoverCard.tsx changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
describe,
|
|
14
|
+
it,
|
|
15
|
+
expect,
|
|
16
|
+
vi,
|
|
17
|
+
beforeAll,
|
|
18
|
+
beforeEach,
|
|
19
|
+
afterAll,
|
|
20
|
+
} from 'vitest';
|
|
13
21
|
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
14
22
|
import {renderToString} from 'react-dom/server';
|
|
15
23
|
import {hydrateRoot} from 'react-dom/client';
|
|
16
24
|
import {StrictMode} from 'react';
|
|
25
|
+
import {Button} from '../Button/Button';
|
|
26
|
+
import {Theme, defineTheme} from '../theme';
|
|
17
27
|
import {HoverCard} from './HoverCard';
|
|
18
28
|
|
|
19
29
|
// Store original matches to restore later
|
|
@@ -48,6 +58,11 @@ afterAll(() => {
|
|
|
48
58
|
(HTMLElement.prototype as any).matches = originalMatches;
|
|
49
59
|
});
|
|
50
60
|
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockClear();
|
|
63
|
+
vi.mocked(HTMLElement.prototype.hidePopover).mockClear();
|
|
64
|
+
});
|
|
65
|
+
|
|
51
66
|
describe('HoverCard', () => {
|
|
52
67
|
it('renders trigger element', () => {
|
|
53
68
|
render(
|
|
@@ -58,31 +73,40 @@ describe('HoverCard', () => {
|
|
|
58
73
|
expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
|
|
59
74
|
});
|
|
60
75
|
|
|
61
|
-
it('exposes the floating layer as role="group" when no label is provided', () => {
|
|
76
|
+
it('exposes the floating layer as role="group" when no label is provided', async () => {
|
|
62
77
|
render(
|
|
63
|
-
<HoverCard content={<span>Card content</span>}>
|
|
78
|
+
<HoverCard content={<span>Card content</span>} delay={0}>
|
|
64
79
|
<button type="button">Trigger</button>
|
|
65
80
|
</HoverCard>,
|
|
66
81
|
);
|
|
67
82
|
// A group may validly be unnamed; an unnamed dialog may not. Without a
|
|
68
83
|
// label the layer must not claim the dialog role.
|
|
69
|
-
expect(screen.
|
|
70
|
-
'Card content',
|
|
71
|
-
);
|
|
84
|
+
expect(screen.queryByRole('group', {hidden: true})).toBeNull();
|
|
72
85
|
expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
|
|
86
|
+
|
|
87
|
+
fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(screen.getByRole('group', {hidden: true})).toHaveTextContent(
|
|
91
|
+
'Card content',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
73
94
|
});
|
|
74
95
|
|
|
75
|
-
it('exposes the floating layer as a named dialog when label is provided', () => {
|
|
96
|
+
it('exposes the floating layer as a named dialog when label is provided', async () => {
|
|
76
97
|
render(
|
|
77
|
-
<HoverCard
|
|
98
|
+
<HoverCard
|
|
99
|
+
content={<span>Card content</span>}
|
|
100
|
+
label="Profile preview"
|
|
101
|
+
delay={0}>
|
|
78
102
|
<button type="button">Trigger</button>
|
|
79
103
|
</HoverCard>,
|
|
80
104
|
);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const dialog = screen.
|
|
105
|
+
expect(screen.queryByRole('dialog', {hidden: true})).toBeNull();
|
|
106
|
+
|
|
107
|
+
fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
|
|
108
|
+
|
|
109
|
+
const dialog = await screen.findByRole('dialog', {hidden: true});
|
|
86
110
|
expect(dialog).toHaveAttribute('aria-label', 'Profile preview');
|
|
87
111
|
expect(dialog).toHaveTextContent('Card content');
|
|
88
112
|
expect(screen.queryByRole('group', {hidden: true})).toBeNull();
|
|
@@ -104,52 +128,236 @@ describe('HoverCard', () => {
|
|
|
104
128
|
|
|
105
129
|
expect(trigger.parentElement?.tagName).toBe('SPAN');
|
|
106
130
|
expect(paragraph?.querySelector('div')).toBeNull();
|
|
131
|
+
expect(paragraph?.querySelector('template')).not.toBeNull();
|
|
107
132
|
});
|
|
108
133
|
|
|
109
|
-
it('
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
it('portals block content before showing and restores the marker after hiding', async () => {
|
|
135
|
+
let contentWasPresentAtShow = false;
|
|
136
|
+
vi.mocked(HTMLElement.prototype.showPopover).mockImplementationOnce(
|
|
137
|
+
function (this: HTMLElement) {
|
|
138
|
+
contentWasPresentAtShow =
|
|
139
|
+
this.textContent?.includes('Block card content') ?? false;
|
|
140
|
+
popoverOpenState.set(this, true);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
const consoleErrorSpy = vi
|
|
144
|
+
.spyOn(console, 'error')
|
|
145
|
+
.mockImplementation(() => {});
|
|
146
|
+
|
|
114
147
|
const {container} = render(
|
|
115
148
|
<p>
|
|
116
149
|
Before{' '}
|
|
117
|
-
<HoverCard
|
|
118
|
-
<
|
|
150
|
+
<HoverCard
|
|
151
|
+
content={<div>Block card content</div>}
|
|
152
|
+
delay={0}
|
|
153
|
+
hideDelay={0}>
|
|
154
|
+
Trigger
|
|
119
155
|
</HoverCard>{' '}
|
|
120
156
|
after
|
|
121
157
|
</p>,
|
|
122
158
|
);
|
|
123
159
|
|
|
124
160
|
const paragraph = container.querySelector('p');
|
|
125
|
-
const
|
|
161
|
+
const trigger = screen.getByText('Trigger');
|
|
126
162
|
|
|
127
|
-
expect(
|
|
128
|
-
expect(
|
|
129
|
-
// The whole layer subtree lives inside the paragraph with no block boxes.
|
|
130
|
-
expect(paragraph?.contains(layer as Node)).toBe(true);
|
|
163
|
+
expect(screen.queryByText('Block card content')).toBeNull();
|
|
164
|
+
expect(paragraph?.querySelector('template')).not.toBeNull();
|
|
131
165
|
expect(paragraph?.querySelector('div')).toBeNull();
|
|
166
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
167
|
+
|
|
168
|
+
fireEvent.mouseEnter(trigger);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
const content = screen.getByText('Block card content');
|
|
172
|
+
const layer = content.closest('[popover]');
|
|
173
|
+
|
|
174
|
+
expect(layer?.tagName).toBe('DIV');
|
|
175
|
+
expect(layer?.parentElement).toBe(container);
|
|
176
|
+
expect(paragraph?.contains(content)).toBe(false);
|
|
177
|
+
expect(contentWasPresentAtShow).toBe(true);
|
|
178
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
179
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
fireEvent.mouseLeave(trigger);
|
|
183
|
+
|
|
184
|
+
await waitFor(() => {
|
|
185
|
+
expect(screen.queryByText('Block card content')).toBeNull();
|
|
186
|
+
expect(paragraph?.querySelector('template')).not.toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
consoleErrorSpy.mockRestore();
|
|
132
190
|
});
|
|
133
191
|
|
|
134
|
-
it('
|
|
192
|
+
it('hosts the floating layer outside a wrapping link', async () => {
|
|
193
|
+
// Interactive ancestors capture the layer's own interactions: a card left
|
|
194
|
+
// inside an <a> puts its links and buttons inside that link, so clicking
|
|
195
|
+
// one navigates.
|
|
135
196
|
render(
|
|
197
|
+
<a href="#profile">
|
|
198
|
+
<HoverCard
|
|
199
|
+
content={
|
|
200
|
+
<span>
|
|
201
|
+
<a href="#inner">Inner link</a>
|
|
202
|
+
</span>
|
|
203
|
+
}
|
|
204
|
+
delay={0}>
|
|
205
|
+
Trigger
|
|
206
|
+
</HoverCard>
|
|
207
|
+
</a>,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const link = screen.getByRole('link', {name: /Trigger/});
|
|
211
|
+
expect(screen.queryByText('Inner link')).toBeNull();
|
|
212
|
+
fireEvent.mouseEnter(screen.getByText('Trigger'));
|
|
213
|
+
|
|
214
|
+
const layer = (await screen.findByText('Inner link')).closest('[popover]');
|
|
215
|
+
|
|
216
|
+
expect(layer).not.toBeNull();
|
|
217
|
+
expect(link.contains(layer as Node)).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('keeps a safe layer inline at its JSX position', async () => {
|
|
221
|
+
const {container} = render(
|
|
222
|
+
<>
|
|
223
|
+
<HoverCard content={<div>Block card content</div>} delay={0}>
|
|
224
|
+
<button type="button">Trigger</button>
|
|
225
|
+
</HoverCard>
|
|
226
|
+
<button type="button">Following control</button>
|
|
227
|
+
</>,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(screen.queryByText('Block card content')).toBeNull();
|
|
231
|
+
fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
|
|
232
|
+
|
|
233
|
+
await waitFor(() => {
|
|
234
|
+
const layer = screen.getByText('Block card content').closest('[popover]');
|
|
235
|
+
const following = screen.getByRole('button', {name: 'Following control'});
|
|
236
|
+
|
|
237
|
+
expect(layer?.parentElement).toBe(container);
|
|
238
|
+
expect(layer?.nextElementSibling).toBe(following);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('does not render content initially', () => {
|
|
243
|
+
const {container} = render(
|
|
136
244
|
<HoverCard content={<span>Card content</span>}>
|
|
137
245
|
<button type="button">Trigger</button>
|
|
138
246
|
</HoverCard>,
|
|
139
247
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(
|
|
248
|
+
expect(screen.queryByText('Card content')).toBeNull();
|
|
249
|
+
expect(container.querySelector('[popover]')).toBeNull();
|
|
250
|
+
expect(container.querySelector('template')).not.toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('keeps a paragraph portal inside the nearest nested theme scope', async () => {
|
|
254
|
+
const outerTheme = defineTheme({name: 'hovercard-outer-test'});
|
|
255
|
+
const innerTheme = defineTheme({
|
|
256
|
+
name: 'hovercard-inner-test',
|
|
257
|
+
components: {
|
|
258
|
+
hovercard: {base: {borderWidth: '7px'}},
|
|
259
|
+
button: {base: {fontWeight: '700'}},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const {container} = render(
|
|
264
|
+
<Theme theme={outerTheme}>
|
|
265
|
+
<Theme theme={innerTheme}>
|
|
266
|
+
<p>
|
|
267
|
+
<HoverCard
|
|
268
|
+
content={<Button label="View profile">View profile</Button>}
|
|
269
|
+
delay={0}>
|
|
270
|
+
Trigger
|
|
271
|
+
</HoverCard>
|
|
272
|
+
</p>
|
|
273
|
+
</Theme>
|
|
274
|
+
</Theme>,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
fireEvent.mouseEnter(screen.getByText('Trigger'));
|
|
278
|
+
|
|
279
|
+
await waitFor(() => {
|
|
280
|
+
const button = screen.getByText('View profile').closest('button');
|
|
281
|
+
const layer = button?.closest('[popover]') ?? null;
|
|
282
|
+
const innerThemeScope = container.querySelector(
|
|
283
|
+
'[data-astryx-theme="hovercard-inner-test"]',
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(innerThemeScope).not.toBeNull();
|
|
287
|
+
expect(button).not.toBeNull();
|
|
288
|
+
expect(layer?.parentElement).toBe(innerThemeScope);
|
|
289
|
+
expect(innerThemeScope?.contains(layer)).toBe(true);
|
|
290
|
+
expect(container.querySelector('p')?.contains(layer)).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does not freeze computed CSS variables on a paragraph portal', async () => {
|
|
295
|
+
const variables = new Map([
|
|
296
|
+
['--color-neutral', 'rgb(1, 2, 3)'],
|
|
297
|
+
['--color-text-primary', 'rgb(250, 251, 252)'],
|
|
298
|
+
]);
|
|
299
|
+
const getComputedStyleSpy = vi
|
|
300
|
+
.spyOn(window, 'getComputedStyle')
|
|
301
|
+
.mockImplementation(
|
|
302
|
+
element =>
|
|
303
|
+
({
|
|
304
|
+
length: variables.size,
|
|
305
|
+
item: (index: number) => Array.from(variables.keys())[index] ?? '',
|
|
306
|
+
getPropertyValue: (property: string) =>
|
|
307
|
+
variables.get(property) ?? '',
|
|
308
|
+
direction:
|
|
309
|
+
element.tagName.toLowerCase() === 'template' ? 'rtl' : 'ltr',
|
|
310
|
+
writingMode:
|
|
311
|
+
element.tagName.toLowerCase() === 'template'
|
|
312
|
+
? 'vertical-rl'
|
|
313
|
+
: 'horizontal-tb',
|
|
314
|
+
}) as CSSStyleDeclaration,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const {container} = render(
|
|
318
|
+
<p>
|
|
319
|
+
<HoverCard
|
|
320
|
+
content={<Button label="View profile">View profile</Button>}
|
|
321
|
+
delay={0}>
|
|
322
|
+
Trigger
|
|
323
|
+
</HoverCard>
|
|
324
|
+
</p>,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
expect(screen.queryByRole('button', {name: 'View profile'})).toBeNull();
|
|
328
|
+
fireEvent.mouseEnter(screen.getByText('Trigger'));
|
|
329
|
+
|
|
330
|
+
await waitFor(() => {
|
|
331
|
+
const button = screen.getByRole('button', {name: 'View profile'});
|
|
332
|
+
const layer = button.closest('[popover]');
|
|
333
|
+
|
|
334
|
+
expect(layer?.parentElement).toBe(container);
|
|
335
|
+
expect(container.querySelector('p')?.contains(button)).toBe(false);
|
|
336
|
+
expect(layer).not.toBeNull();
|
|
337
|
+
expect(
|
|
338
|
+
(layer as HTMLElement).style.getPropertyValue('--color-neutral'),
|
|
339
|
+
).toBe('');
|
|
340
|
+
expect(
|
|
341
|
+
(layer as HTMLElement).style.getPropertyValue('--color-text-primary'),
|
|
342
|
+
).toBe('');
|
|
343
|
+
expect((layer as HTMLElement).style.direction).toBe('rtl');
|
|
344
|
+
expect((layer as HTMLElement).style.writingMode).toBe('vertical-rl');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
getComputedStyleSpy.mockRestore();
|
|
143
348
|
});
|
|
144
349
|
|
|
145
|
-
it('applies the theme body font to the floating layer', () => {
|
|
350
|
+
it('applies the theme body font to the floating layer', async () => {
|
|
146
351
|
render(
|
|
147
|
-
<HoverCard content={<span>Card content</span>}>
|
|
352
|
+
<HoverCard content={<span>Card content</span>} delay={0}>
|
|
148
353
|
<button type="button">Trigger</button>
|
|
149
354
|
</HoverCard>,
|
|
150
355
|
);
|
|
151
356
|
|
|
152
|
-
|
|
357
|
+
fireEvent.mouseEnter(screen.getByRole('button', {name: 'Trigger'}));
|
|
358
|
+
const layer = (await screen.findByText('Card content')).closest(
|
|
359
|
+
'[popover]',
|
|
360
|
+
);
|
|
153
361
|
expect(layer).not.toBeNull();
|
|
154
362
|
expect(getComputedStyle(layer as Element).fontFamily).toBe(
|
|
155
363
|
'var(--font-family-body)',
|
|
@@ -435,41 +643,57 @@ describe('HoverCard', () => {
|
|
|
435
643
|
// `typeof document !== 'undefined'` gate: the server rendered nothing while
|
|
436
644
|
// the first client render emitted the portal, so the two trees disagreed.
|
|
437
645
|
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
646
|
+
// Context layers now render the same inert <template> marker on the server
|
|
647
|
+
// and the first client render. Arbitrary content mounts only when the card
|
|
648
|
+
// is requested and its final inline/portal position is known.
|
|
441
649
|
|
|
442
|
-
it('renders the
|
|
443
|
-
const
|
|
650
|
+
it('renders only the inert marker before and after hydration', async () => {
|
|
651
|
+
const tree = (
|
|
444
652
|
<HoverCard content={<span>Card content</span>}>
|
|
445
653
|
<button type="button">Trigger</button>
|
|
446
|
-
</HoverCard
|
|
654
|
+
</HoverCard>
|
|
447
655
|
);
|
|
448
656
|
|
|
449
|
-
|
|
450
|
-
expect(
|
|
451
|
-
expect(
|
|
452
|
-
|
|
453
|
-
|
|
657
|
+
const serverHTML = renderToString(tree);
|
|
658
|
+
expect(serverHTML).not.toContain('popover=');
|
|
659
|
+
expect(serverHTML).not.toContain('Card content');
|
|
660
|
+
expect(serverHTML).toContain('<template');
|
|
661
|
+
|
|
662
|
+
const container = document.createElement('div');
|
|
663
|
+
container.innerHTML = serverHTML;
|
|
664
|
+
document.body.appendChild(container);
|
|
665
|
+
|
|
666
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
667
|
+
await act(async () => {
|
|
668
|
+
root = hydrateRoot(container, tree);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(container.querySelector('[popover]')).toBeNull();
|
|
672
|
+
expect(container.querySelector('template')).not.toBeNull();
|
|
673
|
+
|
|
674
|
+
await act(async () => {
|
|
675
|
+
root.unmount();
|
|
676
|
+
});
|
|
677
|
+
container.remove();
|
|
454
678
|
});
|
|
455
679
|
|
|
456
|
-
it('
|
|
680
|
+
it('emits only a valid marker inside a paragraph', () => {
|
|
457
681
|
const html = renderToString(
|
|
458
682
|
<p>
|
|
459
683
|
Before{' '}
|
|
460
|
-
<HoverCard content={<
|
|
684
|
+
<HoverCard content={<div>Block card content</div>}>
|
|
461
685
|
<a href="#trigger">Trigger</a>
|
|
462
686
|
</HoverCard>{' '}
|
|
463
687
|
after
|
|
464
688
|
</p>,
|
|
465
689
|
);
|
|
466
690
|
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
// the browser parser will not reparent (which would itself desync
|
|
470
|
-
// hydration).
|
|
691
|
+
// <template> is inert phrasing/script-supporting content, so the parser
|
|
692
|
+
// has no block layer or consumer content to reparent out of the <p>.
|
|
471
693
|
expect(html).not.toContain('<div');
|
|
472
|
-
expect(html).
|
|
694
|
+
expect(html).not.toContain('popover=');
|
|
695
|
+
expect(html).not.toContain('Block card content');
|
|
696
|
+
expect(html).toContain('<template');
|
|
473
697
|
});
|
|
474
698
|
|
|
475
699
|
it('server markup matches the first client render (no hydration mismatch)', async () => {
|
|
@@ -477,7 +701,7 @@ describe('HoverCard', () => {
|
|
|
477
701
|
<StrictMode>
|
|
478
702
|
<p>
|
|
479
703
|
Glossary:{' '}
|
|
480
|
-
<HoverCard content={<
|
|
704
|
+
<HoverCard content={<div>Definition</div>}>
|
|
481
705
|
<a href="#term">term</a>
|
|
482
706
|
</HoverCard>
|
|
483
707
|
.
|
|
@@ -515,6 +739,8 @@ describe('HoverCard', () => {
|
|
|
515
739
|
|
|
516
740
|
expect(hydrationErrors).toEqual([]);
|
|
517
741
|
expect(recoverableErrors).toEqual([]);
|
|
742
|
+
expect(container.querySelector('[popover]')).toBeNull();
|
|
743
|
+
expect(container.querySelector('template')).not.toBeNull();
|
|
518
744
|
|
|
519
745
|
await act(async () => {
|
|
520
746
|
root.unmount();
|
|
@@ -533,10 +759,10 @@ describe('HoverCard', () => {
|
|
|
533
759
|
);
|
|
534
760
|
|
|
535
761
|
const serverHTML = renderToString(tree);
|
|
536
|
-
// isDefaultOpen must not leak the
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
expect(serverHTML).toContain('
|
|
762
|
+
// isDefaultOpen must not leak the final layer into SSR markup. The marker
|
|
763
|
+
// hydrates first; the effect then requests and opens the real popover.
|
|
764
|
+
expect(serverHTML).not.toContain('popover=');
|
|
765
|
+
expect(serverHTML).toContain('<template');
|
|
540
766
|
|
|
541
767
|
const container = document.createElement('div');
|
|
542
768
|
container.innerHTML = serverHTML;
|
|
@@ -568,6 +794,8 @@ describe('HoverCard', () => {
|
|
|
568
794
|
await waitFor(() => {
|
|
569
795
|
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
570
796
|
});
|
|
797
|
+
expect(container.querySelector('[popover]')).not.toBeNull();
|
|
798
|
+
expect(container).toHaveTextContent('Default open');
|
|
571
799
|
|
|
572
800
|
await act(async () => {
|
|
573
801
|
root.unmount();
|