@astryxdesign/core 0.4.0 → 0.4.1-canary.0ad35c3
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 +39 -0
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +1 -2
- package/dist/AspectRatio/index.d.ts.map +1 -1
- package/dist/AspectRatio/index.js +1 -2
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +1 -2
- package/dist/Badge/index.d.ts.map +1 -1
- package/dist/Badge/index.js +1 -2
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +17 -3
- package/dist/Blockquote/Blockquote.d.ts.map +1 -1
- package/dist/Blockquote/Blockquote.js +1 -2
- package/dist/Blockquote/index.d.ts.map +1 -1
- package/dist/Blockquote/index.js +1 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -9
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +1 -2
- package/dist/Card/index.d.ts.map +1 -1
- package/dist/Card/index.js +1 -2
- package/dist/Center/Center.d.ts.map +1 -1
- package/dist/Center/Center.js +1 -2
- package/dist/Center/index.d.ts.map +1 -1
- package/dist/Center/index.js +1 -2
- 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/Code/Code.d.ts.map +1 -1
- package/dist/Code/Code.js +1 -2
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +5 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +7 -8
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +23 -10
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +44 -16
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +32 -13
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
- package/dist/Field/PanelSearchInput.d.ts +81 -0
- package/dist/Field/PanelSearchInput.d.ts.map +1 -0
- package/dist/Field/PanelSearchInput.js +151 -0
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +21 -10
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +1 -2
- package/dist/Grid/index.d.ts.map +1 -1
- package/dist/Grid/index.js +1 -2
- 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/Icon/globalIconRegistry.d.ts +5 -4
- package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
- package/dist/Icon/globalIconRegistry.js +5 -4
- package/dist/Indicator/index.d.ts +1 -1
- package/dist/Indicator/index.d.ts.map +1 -1
- package/dist/Indicator/types.d.ts +10 -0
- package/dist/Indicator/types.d.ts.map +1 -1
- 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 +155 -29
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +24 -50
- package/dist/MultiSelector/MultiSelector.d.ts +8 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +134 -102
- 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/Popover/usePopover.d.ts +15 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +14 -2
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -3
- package/dist/Section/Section.d.ts.map +1 -1
- package/dist/Section/Section.js +1 -2
- package/dist/Section/index.d.ts.map +1 -1
- package/dist/Section/index.js +1 -2
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +135 -95
- 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 +70 -24
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +114 -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/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +1 -2
- package/dist/Skeleton/index.d.ts.map +1 -1
- package/dist/Skeleton/index.js +1 -2
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +2 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +3 -3
- package/dist/Table/TableRow.d.ts +1 -1
- package/dist/Table/TableRow.d.ts.map +1 -1
- package/dist/Table/TableRow.js +4 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +7 -2
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +14 -3
- package/dist/Timestamp/formatInstant.d.ts +11 -1
- package/dist/Timestamp/formatInstant.d.ts.map +1 -1
- package/dist/Timestamp/formatInstant.js +3 -1
- package/dist/TopNav/TopNavMegaMenu.js +1 -1
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +2 -2
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
- package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
- package/dist/VisuallyHidden/index.d.ts.map +1 -1
- package/dist/VisuallyHidden/index.js +1 -2
- package/dist/astryx.css +23 -24
- package/dist/astryx.umd.js +47 -47
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +17 -4
- package/dist/theme/defineTheme.d.ts +2 -2
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +2 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/tokens.stylex.d.ts +12 -0
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +23 -0
- package/dist/utils/focusOutline.stylex.d.ts +34 -19
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +46 -33
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +5 -1
- package/dist/utils/interactionModality.d.ts +34 -0
- package/dist/utils/interactionModality.d.ts.map +1 -0
- package/dist/utils/interactionModality.js +70 -0
- package/locales/en.json +44 -0
- package/locales/pseudo.json +33 -0
- package/package.json +3 -3
- package/src/AppShell/AppShell.doc.mjs +3 -0
- package/src/AspectRatio/AspectRatio.tsx +0 -2
- package/src/AspectRatio/index.ts +0 -2
- package/src/Badge/Badge.tsx +0 -2
- package/src/Badge/index.ts +0 -2
- package/src/Banner/Banner.test.tsx +58 -1
- package/src/Banner/Banner.tsx +24 -5
- package/src/Blockquote/Blockquote.tsx +0 -2
- package/src/Blockquote/index.ts +0 -2
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.tsx +10 -7
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.tsx +0 -2
- package/src/Card/index.ts +0 -2
- package/src/Center/Center.tsx +0 -2
- package/src/Center/index.ts +0 -2
- 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/CheckboxInput/CheckboxInput.test.tsx +8 -3
- package/src/Code/Code.tsx +0 -2
- package/src/CommandPalette/CommandPalette.test.tsx +54 -0
- package/src/CommandPalette/CommandPalette.tsx +5 -0
- package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
- package/src/ComplexSelector/ComplexSelector.tsx +6 -10
- package/src/DateInput/DateInput.test.tsx +19 -0
- package/src/DateInput/DateInput.tsx +3 -6
- package/src/DateRangeInput/DateRangeInput.tsx +3 -9
- package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
- package/src/DateTimeInput/DateTimeInput.tsx +23 -9
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
- package/src/Field/PanelSearchInput.tsx +271 -0
- package/src/FileInput/FileInput.tsx +21 -8
- package/src/Grid/Grid.tsx +0 -2
- package/src/Grid/index.ts +0 -2
- 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/Icon/globalIconRegistry.tsx +5 -4
- package/src/Indicator/Indicator.doc.mjs +1 -1
- package/src/Indicator/index.ts +1 -0
- package/src/Indicator/types.ts +11 -0
- package/src/InputGroup/groupStyles.ts +7 -8
- 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 +318 -7
- package/src/Layer/useLayer.tsx +229 -35
- package/src/Lightbox/Lightbox.tsx +2 -11
- package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
- package/src/MultiSelector/MultiSelector.test.tsx +135 -5
- package/src/MultiSelector/MultiSelector.tsx +170 -127
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/Popover/Popover.doc.mjs +2 -0
- package/src/Popover/usePopover.tsx +37 -4
- package/src/ProgressBar/ProgressBar.tsx +2 -8
- package/src/Section/Section.tsx +0 -2
- package/src/Section/index.ts +0 -2
- package/src/Selector/Selector.doc.mjs +10 -0
- package/src/Selector/Selector.test.tsx +386 -2
- package/src/Selector/Selector.tsx +182 -122
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +771 -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 +28 -10
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +92 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Skeleton/Skeleton.tsx +0 -2
- package/src/Skeleton/index.ts +0 -2
- package/src/Slider/Slider.tsx +2 -11
- package/src/Switch/Switch.tsx +10 -2
- package/src/Table/Table.test.tsx +31 -0
- package/src/Table/TableRow.tsx +6 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/TimeInput/TimeInput.test.tsx +54 -0
- package/src/TimeInput/TimeInput.tsx +17 -2
- package/src/Timestamp/Timestamp.test.tsx +125 -30
- package/src/Timestamp/Timestamp.tsx +14 -2
- package/src/Timestamp/formatInstant.ts +16 -1
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +4 -1
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +8 -2
- package/src/TreeList/TreeList.test.tsx +3 -2
- package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
- package/src/VisuallyHidden/index.ts +0 -2
- package/src/hooks/index.ts +5 -1
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useInputStatusIcon.tsx +8 -8
- package/src/serverSafeComponents.test.ts +665 -0
- package/src/theme/defineTheme.ts +3 -0
- package/src/theme/generateThemeRules.test.ts +17 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +18 -0
- package/src/theme/tokens.test.ts +37 -0
- package/src/utils/focusOutline.stylex.ts +37 -23
- package/src/utils/index.ts +4 -0
- package/src/utils/interactionModality.ts +74 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file layerHost.test.ts
|
|
5
|
+
* @input Tests resolveLayerPortalTarget
|
|
6
|
+
* @output Coverage for when and where a layer is portaled
|
|
7
|
+
* @position Test for /packages/core/src/Layer/layerHost.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {describe, it, expect, afterEach} from 'vitest';
|
|
11
|
+
import {resolveLayerPortalTarget} from './layerHost';
|
|
12
|
+
|
|
13
|
+
function mount(html: string): HTMLElement {
|
|
14
|
+
const root = document.createElement('div');
|
|
15
|
+
root.id = 'root';
|
|
16
|
+
root.innerHTML = html;
|
|
17
|
+
document.body.appendChild(root);
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.getElementById('root')?.remove();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('resolveLayerPortalTarget', () => {
|
|
26
|
+
it('returns null without an inline parent', () => {
|
|
27
|
+
expect(resolveLayerPortalTarget(null)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns null when the intended inline position is already safe', () => {
|
|
31
|
+
const root = mount(
|
|
32
|
+
'<div id="host"><template id="marker"></template></div>',
|
|
33
|
+
);
|
|
34
|
+
const target = resolveLayerPortalTarget(root.querySelector('#host'));
|
|
35
|
+
|
|
36
|
+
expect(target).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
['paragraph', '<p><template></template></p>'],
|
|
41
|
+
['heading', '<h2><template></template></h2>'],
|
|
42
|
+
['link', '<a href="#x"><template></template></a>'],
|
|
43
|
+
['button', '<button><template></template></button>'],
|
|
44
|
+
['label', '<label><template></template></label>'],
|
|
45
|
+
['data', '<data><template></template></data>'],
|
|
46
|
+
['definition', '<dfn><template></template></dfn>'],
|
|
47
|
+
['meter', '<meter><template></template></meter>'],
|
|
48
|
+
['output', '<output><template></template></output>'],
|
|
49
|
+
['progress', '<progress><template></template></progress>'],
|
|
50
|
+
[
|
|
51
|
+
'nested inline formatting',
|
|
52
|
+
'<p><em><b><template id="marker"></template></b></em></p>',
|
|
53
|
+
],
|
|
54
|
+
])('walks out of a %s', (_name, markup) => {
|
|
55
|
+
const root = mount(`<div id="host">${markup}</div>`);
|
|
56
|
+
const marker = root.querySelector('template');
|
|
57
|
+
const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
|
|
58
|
+
|
|
59
|
+
expect(target).toBe(root.querySelector('#host'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('walks past a safe wrapper nested inside an unsafe ancestor', () => {
|
|
63
|
+
const root = mount(
|
|
64
|
+
'<div id="host"><a href="#x"><div id="inline"><template></template></div></a></div>',
|
|
65
|
+
);
|
|
66
|
+
const target = resolveLayerPortalTarget(root.querySelector('#inline'));
|
|
67
|
+
|
|
68
|
+
expect(target).toBe(root.querySelector('#host'));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each([
|
|
72
|
+
[
|
|
73
|
+
'table row',
|
|
74
|
+
'<table><tbody><tr><template></template></tr></tbody></table>',
|
|
75
|
+
],
|
|
76
|
+
['list', '<ul><template></template></ul>'],
|
|
77
|
+
['select', '<select><template></template></select>'],
|
|
78
|
+
['heading group', '<hgroup><template></template></hgroup>'],
|
|
79
|
+
])('walks out of a structural %s', (_name, markup) => {
|
|
80
|
+
const root = mount(`<div id="host">${markup}</div>`);
|
|
81
|
+
const marker = root.querySelector('template');
|
|
82
|
+
const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
|
|
83
|
+
|
|
84
|
+
expect(target).toBe(root.querySelector('#host'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('stops at the nearest safe ancestor rather than the body', () => {
|
|
88
|
+
const root = mount(
|
|
89
|
+
'<section id="outer"><li id="host"><p><span id="t">t</span></p></li></section>',
|
|
90
|
+
);
|
|
91
|
+
const marker = root.querySelector('#t');
|
|
92
|
+
const target = resolveLayerPortalTarget(marker?.parentElement ?? null);
|
|
93
|
+
|
|
94
|
+
// The nearest safe ancestor keeps the layer inside the trigger's theme
|
|
95
|
+
// scope and next to it in the tab order.
|
|
96
|
+
expect(target).toBe(root.querySelector('#host'));
|
|
97
|
+
expect(target).not.toBe(document.body);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file layerHost.ts
|
|
5
|
+
* @input Uses the layer's intended inline parent element
|
|
6
|
+
* @output Exports resolveLayerPortalTarget, the corrective portal target
|
|
7
|
+
* @position Layer utility; used by useLayer to place the popover in the DOM
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When modified, update:
|
|
10
|
+
* - /packages/core/src/Layer/layerHost.test.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ancestors a layer must not be hosted inside.
|
|
15
|
+
*
|
|
16
|
+
* Two overlapping reasons, both verified in Chrome:
|
|
17
|
+
*
|
|
18
|
+
* 1. The HTML parser owns server markup. `<p>` and the heading-ish elements
|
|
19
|
+
* take phrasing content only, so a block element inside one makes the
|
|
20
|
+
* parser close the paragraph and reparent the rest — the layer's content
|
|
21
|
+
* ends up in the page instead of the popover. A nested `<a>` triggers the
|
|
22
|
+
* same tearing through the adoption agency algorithm.
|
|
23
|
+
* 2. Interactive ancestors capture the layer's own interactions. A card
|
|
24
|
+
* hosted inside an `<a>` or `<button>` puts its links and buttons inside
|
|
25
|
+
* that control: clicking one navigates the wrapping link, and every
|
|
26
|
+
* focusable in the card joins the control's own tab stop.
|
|
27
|
+
*
|
|
28
|
+
* Inline formatting elements are on the list for a third, milder reason: a
|
|
29
|
+
* layer hosted in one inherits its typography (font-size, text-align), so a
|
|
30
|
+
* card in a 13px centered paragraph renders 13px and centered.
|
|
31
|
+
*/
|
|
32
|
+
const UNSAFE_HOSTS = new Set([
|
|
33
|
+
// Phrasing-content-only containers
|
|
34
|
+
'p',
|
|
35
|
+
'h1',
|
|
36
|
+
'h2',
|
|
37
|
+
'h3',
|
|
38
|
+
'h4',
|
|
39
|
+
'h5',
|
|
40
|
+
'h6',
|
|
41
|
+
'dt',
|
|
42
|
+
'pre',
|
|
43
|
+
'legend',
|
|
44
|
+
'data',
|
|
45
|
+
'dfn',
|
|
46
|
+
'meter',
|
|
47
|
+
'output',
|
|
48
|
+
'progress',
|
|
49
|
+
'option',
|
|
50
|
+
'optgroup',
|
|
51
|
+
// Structural containers whose direct children are restricted. An inert
|
|
52
|
+
// <template> marker is valid script-supporting content in these positions,
|
|
53
|
+
// but the eventual div/span layer is not.
|
|
54
|
+
'table',
|
|
55
|
+
'thead',
|
|
56
|
+
'tbody',
|
|
57
|
+
'tfoot',
|
|
58
|
+
'tr',
|
|
59
|
+
'colgroup',
|
|
60
|
+
'ul',
|
|
61
|
+
'ol',
|
|
62
|
+
'menu',
|
|
63
|
+
'dl',
|
|
64
|
+
'select',
|
|
65
|
+
'datalist',
|
|
66
|
+
'picture',
|
|
67
|
+
'hgroup',
|
|
68
|
+
'ruby',
|
|
69
|
+
'rt',
|
|
70
|
+
'rp',
|
|
71
|
+
// Interactive containers
|
|
72
|
+
'a',
|
|
73
|
+
'button',
|
|
74
|
+
'label',
|
|
75
|
+
'summary',
|
|
76
|
+
// Inline formatting context
|
|
77
|
+
'span',
|
|
78
|
+
'em',
|
|
79
|
+
'strong',
|
|
80
|
+
'b',
|
|
81
|
+
'i',
|
|
82
|
+
'u',
|
|
83
|
+
's',
|
|
84
|
+
'small',
|
|
85
|
+
'mark',
|
|
86
|
+
'code',
|
|
87
|
+
'kbd',
|
|
88
|
+
'samp',
|
|
89
|
+
'var',
|
|
90
|
+
'sub',
|
|
91
|
+
'sup',
|
|
92
|
+
'abbr',
|
|
93
|
+
'cite',
|
|
94
|
+
'q',
|
|
95
|
+
'time',
|
|
96
|
+
'bdi',
|
|
97
|
+
'bdo',
|
|
98
|
+
'ins',
|
|
99
|
+
'del',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return the corrective portal target for a layer's intended inline parent.
|
|
104
|
+
*
|
|
105
|
+
* A null result means the inline parent and all of its ancestors are safe, so
|
|
106
|
+
* the layer should stay at its JSX position. Otherwise the result is the
|
|
107
|
+
* nearest element outside every unsafe ancestor around that position.
|
|
108
|
+
*
|
|
109
|
+
* Walking up from the actual render position (rather than the trigger, or
|
|
110
|
+
* portaling to `document.body`) keeps the two things a layer inherits there:
|
|
111
|
+
*
|
|
112
|
+
* - **Theme.** Theme scopes live on DOM ancestors. Staying as close as
|
|
113
|
+
* possible to the intended render position preserves their component rules
|
|
114
|
+
* and custom properties; `document.body` may sit outside a nested scope.
|
|
115
|
+
* - **Tab order.** Sequential focus follows DOM order, so a host near the
|
|
116
|
+
* trigger keeps the layer's focusables next to it. (`show()` also passes
|
|
117
|
+
* the trigger as the popover's invoker `source`, which pins focus order to
|
|
118
|
+
* the invoker in browsers that support it.)
|
|
119
|
+
*
|
|
120
|
+
* The outermost unsafe ancestor matters. A safe div may itself sit inside an
|
|
121
|
+
* anchor; stopping at that div would still put the layer's buttons inside the
|
|
122
|
+
* link. Walking the whole chain ensures the target is outside both.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveLayerPortalTarget(
|
|
125
|
+
inlineParent: HTMLElement | null,
|
|
126
|
+
): HTMLElement | null {
|
|
127
|
+
if (!inlineParent) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let outermostUnsafe: HTMLElement | null = null;
|
|
132
|
+
let node: HTMLElement | null = inlineParent;
|
|
133
|
+
while (node) {
|
|
134
|
+
if (UNSAFE_HOSTS.has(node.tagName.toLowerCase())) {
|
|
135
|
+
outermostUnsafe = node;
|
|
136
|
+
}
|
|
137
|
+
node = node.parentElement;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return outermostUnsafe?.parentElement ?? null;
|
|
141
|
+
}
|
|
@@ -42,6 +42,13 @@ export const docs = {
|
|
|
42
42
|
'Whether clicking outside should dismiss the layer using native popover light-dismiss behavior.',
|
|
43
43
|
default: 'false',
|
|
44
44
|
},
|
|
45
|
+
{
|
|
46
|
+
name: 'lazyMount',
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
description:
|
|
49
|
+
'Context mode only. Wait until show() to resolve the inline/portal position and mount content; hide unmounts the content while the inert marker remains.',
|
|
50
|
+
default: 'false',
|
|
51
|
+
},
|
|
45
52
|
],
|
|
46
53
|
returns: [
|
|
47
54
|
{
|
|
@@ -79,7 +86,7 @@ export const docs = {
|
|
|
79
86
|
name: 'render',
|
|
80
87
|
type: '(children: ReactNode, props: ContextRenderProps | FixedRenderProps) => ReactNode',
|
|
81
88
|
description:
|
|
82
|
-
'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. Placement/alignment are logical: they map to the self-* position-area keyword family, which resolves against the popover\'s own inherited direction, so RTL contexts mirror automatically in pure CSS. Pass `positioning: "custom"` in context mode to author position styles yourself via `style` (e.g. explicit anchor() insets or an anchor-size() cover): the hook keeps the popover behavior and position-anchor wiring but derives no position styles, including the automatic RTL mirroring, which becomes your responsibility. Pass `offset` (a CSS length; a number is px) in context mode for clearance from the anchor: it applies to both edges of the placement axis, so the gap survives a flip. Layers are flush by default.
|
|
89
|
+
'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. Placement/alignment are logical: they map to the self-* position-area keyword family, which resolves against the popover\'s own inherited direction, so RTL contexts mirror automatically in pure CSS. Pass `positioning: "custom"` in context mode to author position styles yourself via `style` (e.g. explicit anchor() insets or an anchor-size() cover): the hook keeps the popover behavior and position-anchor wiring but derives no position styles, including the automatic RTL mirroring, which becomes your responsibility. Pass `offset` (a CSS length; a number is px) in context mode for clearance from the anchor: it applies to both edges of the placement axis, so the gap survives a flip. Layers are flush by default. Context mode first renders an inert `<template>` marker in matching server and client markup. The final layer stays at that JSX position if its parent is safe; otherwise it is portaled to the nearest ancestor outside paragraphs, links, buttons, inline formatting, and structurally restricted containers. The nearest safe host keeps CSS custom properties inheriting live, while the layer preserves direction and writing mode from its JSX position. By default this resolution occurs after hydration so closed-layer DOM remains available; `lazyMount` defers it until `show()` and unmounts the content again on hide while the marker remains. The Popover API promotes the layer to the top layer when shown, so it escapes ancestor clipping and stacking wherever it is hosted. When the layer would overflow the viewport, position-try fallbacks flip it to the opposite side; centered layers additionally slide along the alignment axis (span fallbacks) so they stay on-screen near viewport edges.',
|
|
83
90
|
},
|
|
84
91
|
],
|
|
85
92
|
usage: {
|
|
@@ -99,7 +106,7 @@ export const docs = {
|
|
|
99
106
|
{
|
|
100
107
|
guidance: true,
|
|
101
108
|
description:
|
|
102
|
-
|
|
109
|
+
"Rely on the Popover API top layer to escape ancestor clipping and stacking, and host the layer near its trigger rather than in the body so it inherits the trigger's theme cascade and keeps a natural focus order.",
|
|
103
110
|
},
|
|
104
111
|
{
|
|
105
112
|
guidance: false,
|
|
@@ -123,6 +130,8 @@ export const docsDense = {
|
|
|
123
130
|
onShow: 'fires when layer becomes visible.',
|
|
124
131
|
onHide: 'fires when layer hides.',
|
|
125
132
|
lightDismiss: 'whether native outside-click light-dismiss is enabled.',
|
|
133
|
+
lazyMount:
|
|
134
|
+
'context only: defer position resolution/content mounting until show; unmount on hide.',
|
|
126
135
|
},
|
|
127
136
|
returnDescriptions: {
|
|
128
137
|
ref: 'trigger ref for context mode; undefined in fixed mode.',
|
|
@@ -131,7 +140,8 @@ export const docsDense = {
|
|
|
131
140
|
hide: 'hide layer.',
|
|
132
141
|
isOpen: 'whether layer is open.',
|
|
133
142
|
id: 'unique ARIA id.',
|
|
134
|
-
render:
|
|
143
|
+
render:
|
|
144
|
+
'renders popover element; pass placement/alignment or x/y. Placement/alignment logical: mapped to self-* position-area keywords resolved against the popover\'s inherited direction (RTL mirrors in pure CSS). `positioning: "custom"` (context mode) = author position styles yourself via `style`; keeps popover behavior + position-anchor wiring, derives nothing (incl. RTL mirroring, which becomes yours). `offset` (context mode) = clearance from the anchor as a CSS length (number = px), applied to both edges of the placement axis so it survives a flip; layers are flush by default. Context mode begins with an inert `<template>` marker for stable SSR/hydration, then keeps the final layer inline at a safe JSX position or portals it to the nearest safe ancestor; CSS custom properties keep inheriting from that host while direction and writing mode are preserved from the JSX position. `lazyMount` waits for show and unmounts content on hide while the marker remains. The Popover API top layer escapes clipping/stacking wherever it is hosted. Viewport overflow: flips to opposite side; centered layers also slide along the alignment axis (span fallbacks).',
|
|
135
145
|
},
|
|
136
146
|
usage: {
|
|
137
147
|
description:
|
|
@@ -150,7 +160,7 @@ export const docsDense = {
|
|
|
150
160
|
{
|
|
151
161
|
guidance: true,
|
|
152
162
|
description:
|
|
153
|
-
'Rely on the Popover API top layer to escape clipping/stacking
|
|
163
|
+
'Rely on the Popover API top layer to escape clipping/stacking; host the layer near its trigger (not in the body) to inherit the trigger theme cascade and natural focus order.',
|
|
154
164
|
},
|
|
155
165
|
{
|
|
156
166
|
guidance: false,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, afterEach} from 'vitest';
|
|
13
|
-
import {render, act} from '@testing-library/react';
|
|
13
|
+
import {render, act, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
15
|
import {useLayer, getPositionTryFallbacks} from './useLayer';
|
|
16
16
|
import type {
|
|
@@ -19,6 +19,12 @@ import type {
|
|
|
19
19
|
ContextRenderProps,
|
|
20
20
|
} from './useLayer';
|
|
21
21
|
|
|
22
|
+
function mockCSSStyleDeclaration(
|
|
23
|
+
styles: Partial<CSSStyleDeclaration>,
|
|
24
|
+
): CSSStyleDeclaration {
|
|
25
|
+
return styles as CSSStyleDeclaration;
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/**
|
|
23
29
|
* Minimal harness that exposes the imperative show/hide callbacks and renders
|
|
24
30
|
* the layer element so tests can assert on visibility.
|
|
@@ -47,7 +53,7 @@ function ContextLayerHarness({
|
|
|
47
53
|
const layer = useLayer({mode: 'context'});
|
|
48
54
|
return (
|
|
49
55
|
<>
|
|
50
|
-
<button type="button" ref={layer.ref}>
|
|
56
|
+
<button type="button" ref={layer.ref} onClick={layer.show}>
|
|
51
57
|
Trigger
|
|
52
58
|
</button>
|
|
53
59
|
{layer.render(<span>Layer content</span>, {placement, alignment})}
|
|
@@ -55,6 +61,82 @@ function ContextLayerHarness({
|
|
|
55
61
|
);
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function ContextHostingHarness({
|
|
65
|
+
unsafe,
|
|
66
|
+
themeColor,
|
|
67
|
+
direction,
|
|
68
|
+
writingMode,
|
|
69
|
+
}: {
|
|
70
|
+
unsafe?: boolean;
|
|
71
|
+
themeColor?: string;
|
|
72
|
+
direction?: React.CSSProperties['direction'];
|
|
73
|
+
writingMode?: React.CSSProperties['writingMode'];
|
|
74
|
+
}) {
|
|
75
|
+
const layer = useLayer({mode: 'context', lazyMount: true});
|
|
76
|
+
const contents = (
|
|
77
|
+
<>
|
|
78
|
+
<button type="button" ref={layer.ref} onClick={layer.show}>
|
|
79
|
+
Trigger
|
|
80
|
+
</button>
|
|
81
|
+
{layer.render(<button type="button">Layer action</button>)}
|
|
82
|
+
<button type="button">Following control</button>
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const hostStyle = {
|
|
87
|
+
...(themeColor ? {'--test-layer-color': themeColor} : {}),
|
|
88
|
+
direction,
|
|
89
|
+
writingMode,
|
|
90
|
+
} as React.CSSProperties;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div data-testid="host" style={hostStyle}>
|
|
94
|
+
{unsafe ? <p>{contents}</p> : contents}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function RelocatingContextHostingHarness({unsafe}: {unsafe: boolean}) {
|
|
100
|
+
const layer = useLayer({mode: 'context'});
|
|
101
|
+
const renderedLayer = layer.render(<span>Layer content</span>);
|
|
102
|
+
|
|
103
|
+
return unsafe ? (
|
|
104
|
+
<div data-testid="unsafe-host">
|
|
105
|
+
<p>{renderedLayer}</p>
|
|
106
|
+
</div>
|
|
107
|
+
) : (
|
|
108
|
+
<section data-testid="render-position">{renderedLayer}</section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function RelocatingOpenContextHostingHarness({
|
|
113
|
+
unsafe,
|
|
114
|
+
onShow,
|
|
115
|
+
}: {
|
|
116
|
+
unsafe: boolean;
|
|
117
|
+
onShow?: () => void;
|
|
118
|
+
}) {
|
|
119
|
+
const layer = useLayer({mode: 'context', lazyMount: true, onShow});
|
|
120
|
+
const renderedLayer = layer.render(
|
|
121
|
+
<button type="button">Layer action</button>,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<button type="button" ref={layer.ref} onClick={layer.show}>
|
|
127
|
+
Trigger
|
|
128
|
+
</button>
|
|
129
|
+
{unsafe ? (
|
|
130
|
+
<div data-testid="unsafe-host">
|
|
131
|
+
<p>{renderedLayer}</p>
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<section data-testid="safe-host">{renderedLayer}</section>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
58
140
|
describe('getPositionTryFallbacks (issue #3671)', () => {
|
|
59
141
|
const FLIPS = 'flip-block, flip-inline, flip-block flip-inline';
|
|
60
142
|
|
|
@@ -130,10 +212,12 @@ describe('getPositionTryFallbacks (issue #3671)', () => {
|
|
|
130
212
|
}
|
|
131
213
|
});
|
|
132
214
|
|
|
133
|
-
it('updates the fallback list when placement/alignment props change on re-render', () => {
|
|
215
|
+
it('updates the fallback list when placement/alignment props change on re-render', async () => {
|
|
216
|
+
const user = userEvent.setup();
|
|
134
217
|
const {container, rerender} = render(
|
|
135
218
|
<ContextLayerHarness placement="above" alignment="center" />,
|
|
136
219
|
);
|
|
220
|
+
await user.click(container.querySelector('button')!);
|
|
137
221
|
const layerEl = container.querySelector('[popover]') as HTMLElement;
|
|
138
222
|
expect(layerEl.style.positionTryFallbacks).toContain('top span-left');
|
|
139
223
|
|
|
@@ -158,8 +242,10 @@ describe('getPositionTryFallbacks (issue #3671)', () => {
|
|
|
158
242
|
expect(layerEl.style.top).toBe('20px');
|
|
159
243
|
});
|
|
160
244
|
|
|
161
|
-
it('applies span fallbacks to the rendered popover for the default (above/center) layer', () => {
|
|
245
|
+
it('applies span fallbacks to the rendered popover for the default (above/center) layer', async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
162
247
|
const {container} = render(<ContextLayerHarness />);
|
|
248
|
+
await user.click(container.querySelector('button')!);
|
|
163
249
|
const layerEl = container.querySelector('[popover]') as HTMLElement;
|
|
164
250
|
expect(layerEl).not.toBeNull();
|
|
165
251
|
expect(layerEl.style.positionTryFallbacks).toBe(
|
|
@@ -188,6 +274,231 @@ describe('useLayer', () => {
|
|
|
188
274
|
}
|
|
189
275
|
});
|
|
190
276
|
|
|
277
|
+
describe('context hosting', () => {
|
|
278
|
+
it('keeps closed content mounted by default for existing consumers', () => {
|
|
279
|
+
const {container} = render(<ContextLayerHarness />);
|
|
280
|
+
|
|
281
|
+
expect(container.querySelector('template')).not.toBeNull();
|
|
282
|
+
expect(container.querySelector('[popover]')).not.toBeNull();
|
|
283
|
+
expect(container).toHaveTextContent('Layer content');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('renders only an inert marker until show is requested', () => {
|
|
287
|
+
const {container} = render(<ContextHostingHarness />);
|
|
288
|
+
|
|
289
|
+
const sentinel = container.querySelector('template');
|
|
290
|
+
expect(sentinel).not.toBeNull();
|
|
291
|
+
expect(sentinel).not.toHaveAttribute('id');
|
|
292
|
+
expect(container.querySelector('[popover]')).toBeNull();
|
|
293
|
+
expect(container).not.toHaveTextContent('Layer action');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('keeps the final layer inline when the JSX position is safe', async () => {
|
|
297
|
+
HTMLElement.prototype.showPopover = vi.fn();
|
|
298
|
+
const user = userEvent.setup();
|
|
299
|
+
const {container, getByRole} = render(<ContextHostingHarness />);
|
|
300
|
+
|
|
301
|
+
await user.click(getByRole('button', {name: 'Trigger'}));
|
|
302
|
+
|
|
303
|
+
const layer = container.querySelector('[popover]');
|
|
304
|
+
const following = getByRole('button', {name: 'Following control'});
|
|
305
|
+
expect(layer?.parentElement).toBe(container.firstElementChild);
|
|
306
|
+
expect(layer?.nextElementSibling).toBe(following);
|
|
307
|
+
expect(container.querySelector('template')).not.toBeNull();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('portals out of an unsafe parent and preserves its logical writing context', async () => {
|
|
311
|
+
const getComputedStyleSpy = vi
|
|
312
|
+
.spyOn(window, 'getComputedStyle')
|
|
313
|
+
.mockImplementation(() =>
|
|
314
|
+
mockCSSStyleDeclaration({
|
|
315
|
+
getPropertyValue: () => '',
|
|
316
|
+
direction: 'rtl',
|
|
317
|
+
writingMode: 'vertical-rl',
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
const showSpy = vi.fn();
|
|
321
|
+
HTMLElement.prototype.showPopover = showSpy;
|
|
322
|
+
const user = userEvent.setup();
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const {container, getByRole} = render(<ContextHostingHarness unsafe />);
|
|
326
|
+
const trigger = getByRole('button', {name: 'Trigger'});
|
|
327
|
+
|
|
328
|
+
await user.click(trigger);
|
|
329
|
+
|
|
330
|
+
const host = container.querySelector('[data-testid="host"]');
|
|
331
|
+
const paragraph = container.querySelector('p');
|
|
332
|
+
const layer = container.querySelector('[popover]') as HTMLElement;
|
|
333
|
+
expect(layer.parentElement).toBe(host);
|
|
334
|
+
expect(paragraph?.contains(layer)).toBe(false);
|
|
335
|
+
expect(layer).toHaveStyle({
|
|
336
|
+
direction: 'rtl',
|
|
337
|
+
writingMode: 'vertical-rl',
|
|
338
|
+
});
|
|
339
|
+
expect(showSpy).toHaveBeenCalledWith({source: trigger});
|
|
340
|
+
} finally {
|
|
341
|
+
getComputedStyleSpy.mockRestore();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('inherits custom properties from the corrective host without freezing an inline snapshot', async () => {
|
|
346
|
+
const originalGetComputedStyle = window.getComputedStyle.bind(window);
|
|
347
|
+
const getComputedStyleSpy = vi
|
|
348
|
+
.spyOn(window, 'getComputedStyle')
|
|
349
|
+
.mockImplementation(element => {
|
|
350
|
+
if (element.tagName.toLowerCase() !== 'template') {
|
|
351
|
+
return originalGetComputedStyle(element);
|
|
352
|
+
}
|
|
353
|
+
return mockCSSStyleDeclaration({
|
|
354
|
+
length: 1,
|
|
355
|
+
item: () => '--test-layer-color',
|
|
356
|
+
getPropertyValue: property =>
|
|
357
|
+
property === '--test-layer-color' ? 'rgb(1, 2, 3)' : '',
|
|
358
|
+
direction: 'ltr',
|
|
359
|
+
writingMode: 'horizontal-tb',
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
HTMLElement.prototype.showPopover = vi.fn();
|
|
363
|
+
const user = userEvent.setup();
|
|
364
|
+
try {
|
|
365
|
+
const {container, getByRole, rerender} = render(
|
|
366
|
+
<ContextHostingHarness unsafe themeColor="rgb(1, 2, 3)" />,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
await user.click(getByRole('button', {name: 'Trigger'}));
|
|
370
|
+
|
|
371
|
+
let host = container.querySelector(
|
|
372
|
+
'[data-testid="host"]',
|
|
373
|
+
) as HTMLElement;
|
|
374
|
+
let layer = container.querySelector('[popover]') as HTMLElement;
|
|
375
|
+
expect(layer.parentElement).toBe(host);
|
|
376
|
+
expect(layer.style.getPropertyValue('--test-layer-color')).toBe('');
|
|
377
|
+
expect(host.style.getPropertyValue('--test-layer-color')).toBe(
|
|
378
|
+
'rgb(1, 2, 3)',
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
rerender(<ContextHostingHarness unsafe themeColor="rgb(4, 5, 6)" />);
|
|
382
|
+
|
|
383
|
+
host = container.querySelector('[data-testid="host"]') as HTMLElement;
|
|
384
|
+
layer = container.querySelector('[popover]') as HTMLElement;
|
|
385
|
+
expect(layer.parentElement).toBe(host);
|
|
386
|
+
expect(layer.style.getPropertyValue('--test-layer-color')).toBe('');
|
|
387
|
+
expect(host.style.getPropertyValue('--test-layer-color')).toBe(
|
|
388
|
+
'rgb(4, 5, 6)',
|
|
389
|
+
);
|
|
390
|
+
} finally {
|
|
391
|
+
getComputedStyleSpy.mockRestore();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('keeps a shared writing context inheriting live from the corrective host', async () => {
|
|
396
|
+
const getComputedStyleSpy = vi
|
|
397
|
+
.spyOn(window, 'getComputedStyle')
|
|
398
|
+
.mockImplementation(element => {
|
|
399
|
+
const htmlElement = element as HTMLElement;
|
|
400
|
+
const host = htmlElement.matches('[data-testid="host"]')
|
|
401
|
+
? htmlElement
|
|
402
|
+
: htmlElement.closest<HTMLElement>('[data-testid="host"]');
|
|
403
|
+
return mockCSSStyleDeclaration({
|
|
404
|
+
getPropertyValue: () => '',
|
|
405
|
+
direction: host?.style.direction || 'ltr',
|
|
406
|
+
writingMode: host?.style.writingMode || 'horizontal-tb',
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
HTMLElement.prototype.showPopover = vi.fn();
|
|
410
|
+
const user = userEvent.setup();
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const {container, getByRole, rerender} = render(
|
|
414
|
+
<ContextHostingHarness
|
|
415
|
+
unsafe
|
|
416
|
+
direction="rtl"
|
|
417
|
+
writingMode="vertical-rl"
|
|
418
|
+
/>,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await user.click(getByRole('button', {name: 'Trigger'}));
|
|
422
|
+
|
|
423
|
+
let layer = container.querySelector('[popover]') as HTMLElement;
|
|
424
|
+
expect(layer.style.direction).toBe('');
|
|
425
|
+
expect(layer.style.writingMode).toBe('');
|
|
426
|
+
expect(window.getComputedStyle(layer).direction).toBe('rtl');
|
|
427
|
+
|
|
428
|
+
rerender(
|
|
429
|
+
<ContextHostingHarness
|
|
430
|
+
unsafe
|
|
431
|
+
direction="ltr"
|
|
432
|
+
writingMode="horizontal-tb"
|
|
433
|
+
/>,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
layer = container.querySelector('[popover]') as HTMLElement;
|
|
437
|
+
expect(layer.style.direction).toBe('');
|
|
438
|
+
expect(layer.style.writingMode).toBe('');
|
|
439
|
+
expect(window.getComputedStyle(layer).direction).toBe('ltr');
|
|
440
|
+
} finally {
|
|
441
|
+
getComputedStyleSpy.mockRestore();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('re-resolves the host when a persistent render call moves', () => {
|
|
446
|
+
const {container, rerender} = render(
|
|
447
|
+
<RelocatingContextHostingHarness unsafe />,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const host = container.querySelector('[data-testid="unsafe-host"]');
|
|
451
|
+
const paragraph = container.querySelector('p');
|
|
452
|
+
let layer = container.querySelector('[popover]');
|
|
453
|
+
expect(layer?.parentElement).toBe(host);
|
|
454
|
+
expect(paragraph?.contains(layer)).toBe(false);
|
|
455
|
+
|
|
456
|
+
rerender(<RelocatingContextHostingHarness unsafe={false} />);
|
|
457
|
+
|
|
458
|
+
const section = container.querySelector('section');
|
|
459
|
+
layer = container.querySelector('[popover]');
|
|
460
|
+
expect(layer?.parentElement).toBe(section);
|
|
461
|
+
expect(section?.querySelector('template')).not.toBeNull();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('reopens an open lazy layer after its render call moves', async () => {
|
|
465
|
+
const showSpy = vi.fn(function (this: HTMLElement) {
|
|
466
|
+
if (!this.isConnected) {
|
|
467
|
+
throw new Error('showPopover called on a detached layer');
|
|
468
|
+
}
|
|
469
|
+
this.dataset.open = 'true';
|
|
470
|
+
});
|
|
471
|
+
const onShow = vi.fn();
|
|
472
|
+
HTMLElement.prototype.showPopover = showSpy;
|
|
473
|
+
const {container, getByRole, rerender} = render(
|
|
474
|
+
<RelocatingOpenContextHostingHarness unsafe onShow={onShow} />,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
fireEvent.click(getByRole('button', {name: 'Trigger'}));
|
|
478
|
+
|
|
479
|
+
await waitFor(() => {
|
|
480
|
+
expect(container.querySelector('[popover]')).toHaveAttribute(
|
|
481
|
+
'data-open',
|
|
482
|
+
'true',
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
rerender(
|
|
487
|
+
<RelocatingOpenContextHostingHarness unsafe={false} onShow={onShow} />,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await waitFor(() => {
|
|
491
|
+
const layer = container.querySelector('[popover]');
|
|
492
|
+
expect(layer?.parentElement).toBe(
|
|
493
|
+
container.querySelector('[data-testid="safe-host"]'),
|
|
494
|
+
);
|
|
495
|
+
expect(layer).toHaveAttribute('data-open', 'true');
|
|
496
|
+
});
|
|
497
|
+
expect(showSpy).toHaveBeenCalledTimes(2);
|
|
498
|
+
expect(onShow).toHaveBeenCalledOnce();
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
191
502
|
describe('when the Popover API is supported', () => {
|
|
192
503
|
it('calls showPopover/hidePopover on show/hide', () => {
|
|
193
504
|
const showSpy = vi.fn();
|
|
@@ -485,9 +796,9 @@ describe('useLayer context positioning', () => {
|
|
|
485
796
|
const NONE = {blockStart: '', blockEnd: '', inlineStart: '', inlineEnd: ''};
|
|
486
797
|
|
|
487
798
|
it('is flush by default', async () => {
|
|
488
|
-
expect(
|
|
489
|
-
|
|
490
|
-
);
|
|
799
|
+
expect(
|
|
800
|
+
await openAndGetOffsets(<OffsetHarness placement="below" />),
|
|
801
|
+
).toEqual(NONE);
|
|
491
802
|
});
|
|
492
803
|
|
|
493
804
|
// Both edges of the axis, so the gap survives a position-try-fallbacks
|