@ankhorage/surface 0.1.5 → 0.1.6
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 +6 -0
- package/dist/components/badge/Badge.js.map +1 -1
- package/dist/components/badge/index.js.map +1 -1
- package/dist/components/badge/types.js.map +1 -1
- package/dist/components/button/Button.js.map +1 -1
- package/dist/components/button/index.js.map +1 -1
- package/dist/components/button/types.js.map +1 -1
- package/dist/components/card/Card.js.map +1 -1
- package/dist/components/card/index.js.map +1 -1
- package/dist/components/card/types.js.map +1 -1
- package/dist/components/checkbox/Checkbox.js.map +1 -1
- package/dist/components/checkbox/index.js.map +1 -1
- package/dist/components/checkbox/types.js.map +1 -1
- package/dist/components/drawer/Drawer.js.map +1 -1
- package/dist/components/drawer/index.js.map +1 -1
- package/dist/components/drawer/types.js.map +1 -1
- package/dist/components/field/Field.js.map +1 -1
- package/dist/components/field/index.js.map +1 -1
- package/dist/components/field/types.js.map +1 -1
- package/dist/components/helper-text/HelperText.js.map +1 -1
- package/dist/components/helper-text/index.js.map +1 -1
- package/dist/components/helper-text/types.js.map +1 -1
- package/dist/components/icon-button/IconButton.js.map +1 -1
- package/dist/components/icon-button/index.js.map +1 -1
- package/dist/components/icon-button/types.js.map +1 -1
- package/dist/components/label/Label.js.map +1 -1
- package/dist/components/label/index.js.map +1 -1
- package/dist/components/label/types.js.map +1 -1
- package/dist/components/list-item/ListItem.js.map +1 -1
- package/dist/components/list-item/index.js.map +1 -1
- package/dist/components/list-item/types.js.map +1 -1
- package/dist/components/menu/Menu.js.map +1 -1
- package/dist/components/menu/index.js.map +1 -1
- package/dist/components/menu/navigation.js.map +1 -1
- package/dist/components/menu/types.js.map +1 -1
- package/dist/components/modal/Modal.js.map +1 -1
- package/dist/components/modal/index.js.map +1 -1
- package/dist/components/modal/types.js.map +1 -1
- package/dist/components/radio/Radio.js.map +1 -1
- package/dist/components/radio/index.js.map +1 -1
- package/dist/components/radio/types.js.map +1 -1
- package/dist/components/switch/Switch.js.map +1 -1
- package/dist/components/switch/index.js.map +1 -1
- package/dist/components/switch/types.js.map +1 -1
- package/dist/components/tabs/Tab.js.map +1 -1
- package/dist/components/tabs/TabList.js.map +1 -1
- package/dist/components/tabs/TabPanel.js.map +1 -1
- package/dist/components/tabs/Tabs.js.map +1 -1
- package/dist/components/tabs/a11y.js.map +1 -1
- package/dist/components/tabs/context.js.map +1 -1
- package/dist/components/tabs/index.js.map +1 -1
- package/dist/components/tabs/navigation.js.map +1 -1
- package/dist/components/tabs/types.js.map +1 -1
- package/dist/components/text-input/TextInput.js.map +1 -1
- package/dist/components/text-input/index.js.map +1 -1
- package/dist/components/text-input/types.js.map +1 -1
- package/dist/components/textarea/Textarea.js.map +1 -1
- package/dist/components/textarea/index.js.map +1 -1
- package/dist/components/textarea/types.js.map +1 -1
- package/dist/components/toast/Toast.js.map +1 -1
- package/dist/components/toast/ToastProvider.js.map +1 -1
- package/dist/components/toast/index.js.map +1 -1
- package/dist/components/toast/types.js.map +1 -1
- package/dist/components/tooltip/Tooltip.js.map +1 -1
- package/dist/components/tooltip/index.js.map +1 -1
- package/dist/components/tooltip/types.js.map +1 -1
- package/dist/context/FontContext.js.map +1 -1
- package/dist/context/TranslationContext.js.map +1 -1
- package/dist/core/responsive/ResponsiveProvider.js.map +1 -1
- package/dist/core/responsive/breakpoints.js.map +1 -1
- package/dist/core/responsive/getBreakpointFromWidth.js.map +1 -1
- package/dist/core/responsive/index.js.map +1 -1
- package/dist/core/responsive/resolve.js.map +1 -1
- package/dist/core/responsive/types.js.map +1 -1
- package/dist/core/responsive/useBreakpoint.js.map +1 -1
- package/dist/examples/DocsExamples.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/focus/FocusScope.js.map +1 -1
- package/dist/internal/focus/useFocusManager.js.map +1 -1
- package/dist/internal/overlay/OverlayProvider.js.map +1 -1
- package/dist/internal/overlay/Portal.js.map +1 -1
- package/dist/internal/overlay/useOverlayStack.js.map +1 -1
- package/dist/internal/resolvers/index.js.map +1 -1
- package/dist/internal/resolvers/resolveControlSize.js.map +1 -1
- package/dist/internal/resolvers/resolveFieldPresentation.js.map +1 -1
- package/dist/internal/resolvers/resolveFieldState.js.map +1 -1
- package/dist/internal/resolvers/resolveFocusRingStyles.js.map +1 -1
- package/dist/internal/resolvers/resolveIconSize.js.map +1 -1
- package/dist/internal/resolvers/resolveIndicatorSize.js.map +1 -1
- package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
- package/dist/internal/resolvers/resolveInteractiveState.js.map +1 -1
- package/dist/internal/resolvers/resolveOverlayAnimation.js.map +1 -1
- package/dist/internal/resolvers/resolveOverlayZIndex.js.map +1 -1
- package/dist/internal/resolvers/resolveSelectionControlBehavior.js.map +1 -1
- package/dist/internal/resolvers/resolveSelectionControlColors.js.map +1 -1
- package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
- package/dist/internal/resolvers/resolveTextStyles.js.map +1 -1
- package/dist/internal/resolvers/resolveTone.js.map +1 -1
- package/dist/internal/useControllableState.js.map +1 -1
- package/dist/layout/Box.js.map +1 -1
- package/dist/layout/Center.js.map +1 -1
- package/dist/layout/Container.js.map +1 -1
- package/dist/layout/Divider.js.map +1 -1
- package/dist/layout/Grid.js.map +1 -1
- package/dist/layout/Inline.js.map +1 -1
- package/dist/layout/Show.js.map +1 -1
- package/dist/layout/Spacer.js.map +1 -1
- package/dist/layout/Stack.js.map +1 -1
- package/dist/layout/Surface.js.map +1 -1
- package/dist/layout/Template.js.map +1 -1
- package/dist/layout/helpers.js.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/primitives/button-base/ButtonBase.js.map +1 -1
- package/dist/primitives/button-base/index.js.map +1 -1
- package/dist/primitives/button-base/types.js.map +1 -1
- package/dist/primitives/heading/Heading.js.map +1 -1
- package/dist/primitives/heading/index.js.map +1 -1
- package/dist/primitives/heading/resolveHeadingStyle.js.map +1 -1
- package/dist/primitives/heading/types.js.map +1 -1
- package/dist/primitives/icon/Icon.js.map +1 -1
- package/dist/primitives/icon/index.js.map +1 -1
- package/dist/primitives/icon/resolveExpoIconComponent.js.map +1 -1
- package/dist/primitives/text/Text.js.map +1 -1
- package/dist/primitives/text/index.js.map +1 -1
- package/dist/primitives/text/types.js.map +1 -1
- package/dist/theme/ThemeContext.js.map +1 -1
- package/dist/theme/colorEngine.js.map +1 -1
- package/dist/theme/createTheme.js.map +1 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/resolveToken.js.map +1 -1
- package/dist/theme/types.js.map +1 -1
- package/dist/utils/deepEqual.js.map +1 -1
- package/dist/utils/deepMerge.js.map +1 -1
- package/package.json +4 -1
- package/src/components/badge/Badge.tsx +47 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge/types.ts +13 -0
- package/src/components/button/Button.tsx +104 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/button/types.ts +26 -0
- package/src/components/card/Card.tsx +81 -0
- package/src/components/card/index.ts +2 -0
- package/src/components/card/types.ts +11 -0
- package/src/components/checkbox/Checkbox.tsx +111 -0
- package/src/components/checkbox/index.ts +2 -0
- package/src/components/checkbox/types.ts +19 -0
- package/src/components/drawer/Drawer.tsx +92 -0
- package/src/components/drawer/index.ts +2 -0
- package/src/components/drawer/types.ts +10 -0
- package/src/components/field/Field.tsx +43 -0
- package/src/components/field/index.ts +2 -0
- package/src/components/field/types.ts +13 -0
- package/src/components/helper-text/HelperText.tsx +12 -0
- package/src/components/helper-text/index.ts +2 -0
- package/src/components/helper-text/types.ts +9 -0
- package/src/components/icon-button/IconButton.tsx +60 -0
- package/src/components/icon-button/index.ts +2 -0
- package/src/components/icon-button/types.ts +19 -0
- package/src/components/label/Label.tsx +17 -0
- package/src/components/label/index.ts +2 -0
- package/src/components/label/types.ts +10 -0
- package/src/components/list-item/ListItem.tsx +72 -0
- package/src/components/list-item/index.ts +2 -0
- package/src/components/list-item/types.ts +11 -0
- package/src/components/menu/Menu.tsx +180 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/navigation.test.ts +21 -0
- package/src/components/menu/navigation.ts +34 -0
- package/src/components/menu/types.ts +16 -0
- package/src/components/modal/Modal.tsx +87 -0
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/types.ts +9 -0
- package/src/components/radio/Radio.tsx +116 -0
- package/src/components/radio/index.ts +2 -0
- package/src/components/radio/types.ts +19 -0
- package/src/components/switch/Switch.tsx +116 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/types.ts +19 -0
- package/src/components/tabs/Tab.tsx +82 -0
- package/src/components/tabs/TabList.tsx +51 -0
- package/src/components/tabs/TabPanel.tsx +29 -0
- package/src/components/tabs/Tabs.tsx +67 -0
- package/src/components/tabs/a11y.test.ts +15 -0
- package/src/components/tabs/a11y.ts +15 -0
- package/src/components/tabs/context.tsx +31 -0
- package/src/components/tabs/index.ts +5 -0
- package/src/components/tabs/navigation.test.ts +21 -0
- package/src/components/tabs/navigation.ts +32 -0
- package/src/components/tabs/types.ts +27 -0
- package/src/components/text-input/TextInput.tsx +116 -0
- package/src/components/text-input/index.ts +2 -0
- package/src/components/text-input/types.ts +32 -0
- package/src/components/textarea/Textarea.tsx +15 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/types.ts +5 -0
- package/src/components/toast/Toast.tsx +54 -0
- package/src/components/toast/ToastProvider.tsx +114 -0
- package/src/components/toast/index.ts +3 -0
- package/src/components/toast/types.ts +16 -0
- package/src/components/tooltip/Tooltip.tsx +109 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/types.ts +9 -0
- package/src/context/FontContext.tsx +59 -0
- package/src/context/TranslationContext.tsx +54 -0
- package/src/core/responsive/ResponsiveProvider.tsx +31 -0
- package/src/core/responsive/breakpoints.ts +9 -0
- package/src/core/responsive/getBreakpointFromWidth.test.ts +15 -0
- package/src/core/responsive/getBreakpointFromWidth.ts +10 -0
- package/src/core/responsive/index.ts +6 -0
- package/src/core/responsive/resolve.test.ts +25 -0
- package/src/core/responsive/resolve.ts +24 -0
- package/src/core/responsive/types.ts +10 -0
- package/src/core/responsive/useBreakpoint.ts +9 -0
- package/src/examples/DocsExamples.tsx +116 -0
- package/src/index.test.ts +64 -0
- package/src/index.ts +55 -0
- package/src/internal/focus/FocusScope.tsx +66 -0
- package/src/internal/focus/useFocusManager.test.ts +44 -0
- package/src/internal/focus/useFocusManager.ts +142 -0
- package/src/internal/overlay/OverlayProvider.tsx +74 -0
- package/src/internal/overlay/Portal.tsx +38 -0
- package/src/internal/overlay/useOverlayStack.test.ts +31 -0
- package/src/internal/overlay/useOverlayStack.ts +61 -0
- package/src/internal/resolvers/index.ts +15 -0
- package/src/internal/resolvers/resolveControlSize.test.ts +25 -0
- package/src/internal/resolvers/resolveControlSize.ts +45 -0
- package/src/internal/resolvers/resolveFieldPresentation.test.ts +31 -0
- package/src/internal/resolvers/resolveFieldPresentation.ts +30 -0
- package/src/internal/resolvers/resolveFieldState.test.ts +22 -0
- package/src/internal/resolvers/resolveFieldState.ts +36 -0
- package/src/internal/resolvers/resolveFocusRingStyles.ts +14 -0
- package/src/internal/resolvers/resolveIconSize.ts +6 -0
- package/src/internal/resolvers/resolveIndicatorSize.test.ts +19 -0
- package/src/internal/resolvers/resolveIndicatorSize.ts +47 -0
- package/src/internal/resolvers/resolveInteractiveColors.test.ts +57 -0
- package/src/internal/resolvers/resolveInteractiveColors.ts +134 -0
- package/src/internal/resolvers/resolveInteractiveState.test.ts +14 -0
- package/src/internal/resolvers/resolveInteractiveState.ts +15 -0
- package/src/internal/resolvers/resolveOverlayAnimation.test.ts +15 -0
- package/src/internal/resolvers/resolveOverlayAnimation.ts +24 -0
- package/src/internal/resolvers/resolveOverlayZIndex.test.ts +15 -0
- package/src/internal/resolvers/resolveOverlayZIndex.ts +13 -0
- package/src/internal/resolvers/resolveSelectionControlBehavior.test.ts +52 -0
- package/src/internal/resolvers/resolveSelectionControlBehavior.ts +23 -0
- package/src/internal/resolvers/resolveSelectionControlColors.test.ts +44 -0
- package/src/internal/resolvers/resolveSelectionControlColors.ts +81 -0
- package/src/internal/resolvers/resolveTextColor.test.ts +23 -0
- package/src/internal/resolvers/resolveTextColor.ts +40 -0
- package/src/internal/resolvers/resolveTextStyles.test.ts +27 -0
- package/src/internal/resolvers/resolveTextStyles.ts +95 -0
- package/src/internal/resolvers/resolveTone.ts +19 -0
- package/src/internal/useControllableState.ts +28 -0
- package/src/layout/Box.tsx +79 -0
- package/src/layout/Center.tsx +22 -0
- package/src/layout/Container.tsx +43 -0
- package/src/layout/Divider.tsx +26 -0
- package/src/layout/Grid.tsx +83 -0
- package/src/layout/Inline.tsx +9 -0
- package/src/layout/Show.tsx +15 -0
- package/src/layout/Spacer.tsx +22 -0
- package/src/layout/Stack.tsx +67 -0
- package/src/layout/Surface.tsx +70 -0
- package/src/layout/Template.tsx +85 -0
- package/src/layout/helpers.test.ts +71 -0
- package/src/layout/helpers.ts +208 -0
- package/src/layout/index.ts +22 -0
- package/src/primitives/button-base/ButtonBase.tsx +81 -0
- package/src/primitives/button-base/index.ts +2 -0
- package/src/primitives/button-base/types.ts +16 -0
- package/src/primitives/heading/Heading.tsx +60 -0
- package/src/primitives/heading/index.ts +2 -0
- package/src/primitives/heading/resolveHeadingStyle.test.ts +31 -0
- package/src/primitives/heading/resolveHeadingStyle.ts +17 -0
- package/src/primitives/heading/types.ts +13 -0
- package/src/primitives/icon/Icon.tsx +40 -0
- package/src/primitives/icon/index.ts +2 -0
- package/src/primitives/icon/resolveExpoIconComponent.test.ts +29 -0
- package/src/primitives/icon/resolveExpoIconComponent.ts +20 -0
- package/src/primitives/text/Text.tsx +66 -0
- package/src/primitives/text/index.ts +2 -0
- package/src/primitives/text/types.ts +18 -0
- package/src/theme/ThemeContext.tsx +95 -0
- package/src/theme/colorEngine.test.ts +114 -0
- package/src/theme/colorEngine.ts +480 -0
- package/src/theme/createTheme.ts +121 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/resolveToken.ts +32 -0
- package/src/theme/types.ts +188 -0
- package/src/utils/deepEqual.ts +34 -0
- package/src/utils/deepMerge.test.ts +117 -0
- package/src/utils/deepMerge.ts +29 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
Checkbox,
|
|
8
|
+
Drawer,
|
|
9
|
+
Field,
|
|
10
|
+
HelperText,
|
|
11
|
+
Label,
|
|
12
|
+
Menu,
|
|
13
|
+
Modal,
|
|
14
|
+
Radio,
|
|
15
|
+
Stack,
|
|
16
|
+
Switch,
|
|
17
|
+
Tab,
|
|
18
|
+
TabList,
|
|
19
|
+
TabPanel,
|
|
20
|
+
Tabs,
|
|
21
|
+
Text,
|
|
22
|
+
Textarea,
|
|
23
|
+
TextInput,
|
|
24
|
+
ThemeProvider,
|
|
25
|
+
} from '../index';
|
|
26
|
+
|
|
27
|
+
const docsThemeConfig = {
|
|
28
|
+
id: 'docs-example',
|
|
29
|
+
name: 'Docs Example',
|
|
30
|
+
light: {
|
|
31
|
+
harmony: 'monochromatic' as const,
|
|
32
|
+
primaryColor: '#2563eb',
|
|
33
|
+
systemTone: 'neutral' as const,
|
|
34
|
+
},
|
|
35
|
+
dark: {
|
|
36
|
+
harmony: 'monochromatic' as const,
|
|
37
|
+
primaryColor: '#2563eb',
|
|
38
|
+
systemTone: 'neutral' as const,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function ProviderExample() {
|
|
43
|
+
return (
|
|
44
|
+
<ThemeProvider initialConfig={docsThemeConfig}>
|
|
45
|
+
<Stack gap="m" p="l">
|
|
46
|
+
<Text variant="body">Surface starter</Text>
|
|
47
|
+
<Badge content="Foundation" tone="success" />
|
|
48
|
+
<Button tone="warning" variant="soft">
|
|
49
|
+
Continue
|
|
50
|
+
</Button>
|
|
51
|
+
</Stack>
|
|
52
|
+
</ThemeProvider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function FormAndOverlayExample() {
|
|
57
|
+
return (
|
|
58
|
+
<ThemeProvider initialConfig={docsThemeConfig}>
|
|
59
|
+
<Stack gap="m" p="l">
|
|
60
|
+
<Field helperText="We only use this for sign-in." label="Email">
|
|
61
|
+
<TextInput placeholder="name@example.com" />
|
|
62
|
+
</Field>
|
|
63
|
+
<Field errorText="Bio is required." invalid label="Bio">
|
|
64
|
+
<Textarea placeholder="Tell us a little about yourself" />
|
|
65
|
+
</Field>
|
|
66
|
+
<Stack gap="s">
|
|
67
|
+
<Label required>Preferences</Label>
|
|
68
|
+
<Checkbox defaultChecked>Weekly updates</Checkbox>
|
|
69
|
+
<Radio>Product announcements</Radio>
|
|
70
|
+
<Switch readOnly>Read-only setting</Switch>
|
|
71
|
+
<HelperText tone="danger">Invalid fields should reuse the same danger tone.</HelperText>
|
|
72
|
+
</Stack>
|
|
73
|
+
<Modal visible={false}>
|
|
74
|
+
<Text>Modal content</Text>
|
|
75
|
+
</Modal>
|
|
76
|
+
<Drawer visible={false}>
|
|
77
|
+
<Text>Drawer content</Text>
|
|
78
|
+
</Drawer>
|
|
79
|
+
</Stack>
|
|
80
|
+
</ThemeProvider>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function NavigationExample() {
|
|
85
|
+
return (
|
|
86
|
+
<ThemeProvider initialConfig={docsThemeConfig}>
|
|
87
|
+
<Stack gap="m" p="l">
|
|
88
|
+
<Tabs defaultValue="account">
|
|
89
|
+
<TabList>
|
|
90
|
+
<Tab value="account">Account</Tab>
|
|
91
|
+
<Tab disabled value="security">
|
|
92
|
+
Security
|
|
93
|
+
</Tab>
|
|
94
|
+
<Tab value="billing">Billing</Tab>
|
|
95
|
+
</TabList>
|
|
96
|
+
<TabPanel value="account">
|
|
97
|
+
<Text>Account settings</Text>
|
|
98
|
+
</TabPanel>
|
|
99
|
+
<TabPanel value="billing">
|
|
100
|
+
<Text>Billing settings</Text>
|
|
101
|
+
</TabPanel>
|
|
102
|
+
</Tabs>
|
|
103
|
+
<Menu
|
|
104
|
+
items={[
|
|
105
|
+
{ id: 'edit', label: 'Edit' },
|
|
106
|
+
{ disabled: true, id: 'archive', label: 'Archive' },
|
|
107
|
+
]}
|
|
108
|
+
trigger={<Text>Open menu</Text>}
|
|
109
|
+
/>
|
|
110
|
+
<Box>
|
|
111
|
+
<Text tone="success">Theme overrides can stay semantic.</Text>
|
|
112
|
+
</Box>
|
|
113
|
+
</Stack>
|
|
114
|
+
</ThemeProvider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
const indexSource = readFileSync(new URL('./index.ts', import.meta.url), 'utf8');
|
|
6
|
+
const packageJson = JSON.parse(
|
|
7
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
8
|
+
) as {
|
|
9
|
+
exports: Record<string, unknown>;
|
|
10
|
+
files: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const expectedRootExports = [
|
|
14
|
+
"export { Badge } from './components/badge';",
|
|
15
|
+
"export { Button } from './components/button';",
|
|
16
|
+
"export { Card } from './components/card';",
|
|
17
|
+
"export { Checkbox } from './components/checkbox';",
|
|
18
|
+
"export { Drawer } from './components/drawer';",
|
|
19
|
+
"export { Field } from './components/field';",
|
|
20
|
+
"export { HelperText } from './components/helper-text';",
|
|
21
|
+
"export { IconButton } from './components/icon-button';",
|
|
22
|
+
"export { Label } from './components/label';",
|
|
23
|
+
"export { ListItem } from './components/list-item';",
|
|
24
|
+
"export { Menu } from './components/menu';",
|
|
25
|
+
"export { Modal } from './components/modal';",
|
|
26
|
+
"export { Radio } from './components/radio';",
|
|
27
|
+
"export { Switch } from './components/switch';",
|
|
28
|
+
"export { Tab, TabList, TabPanel, Tabs } from './components/tabs';",
|
|
29
|
+
"export { TextInput } from './components/text-input';",
|
|
30
|
+
"export { Textarea } from './components/textarea';",
|
|
31
|
+
"export { Toast, ToastProvider, useToast } from './components/toast';",
|
|
32
|
+
"export { Tooltip } from './components/tooltip';",
|
|
33
|
+
"export * from './core/responsive';",
|
|
34
|
+
"export * from './layout';",
|
|
35
|
+
"export * from './theme';",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
describe('public package contract', () => {
|
|
39
|
+
it('keeps the intended package surface on the root barrel', () => {
|
|
40
|
+
expectedRootExports.forEach((line) => {
|
|
41
|
+
expect(indexSource).toContain(line);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps internal infrastructure off the public barrel', () => {
|
|
46
|
+
expect(indexSource).not.toContain("'./internal/");
|
|
47
|
+
expect(indexSource).not.toContain('resolveSelectionControlNextChecked');
|
|
48
|
+
expect(indexSource).not.toContain('resolveFieldPresentation');
|
|
49
|
+
expect(indexSource).not.toContain('FocusScope');
|
|
50
|
+
expect(indexSource).not.toContain('useFocusManager');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps package metadata aligned with the single-entry public surface', () => {
|
|
54
|
+
expect(packageJson.files).toEqual(['dist', 'README.md', 'CHANGELOG.md', 'LICENSE']);
|
|
55
|
+
expect(packageJson.exports).toEqual({
|
|
56
|
+
'.': {
|
|
57
|
+
default: './dist/index.js',
|
|
58
|
+
import: './dist/index.js',
|
|
59
|
+
types: './dist/index.d.ts',
|
|
60
|
+
},
|
|
61
|
+
'./package.json': './package.json',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type { BadgeProps } from './components/badge';
|
|
2
|
+
export { Badge } from './components/badge';
|
|
3
|
+
export type { ButtonIconSpec, ButtonProps } from './components/button';
|
|
4
|
+
export { Button } from './components/button';
|
|
5
|
+
export type { CardProps } from './components/card';
|
|
6
|
+
export { Card } from './components/card';
|
|
7
|
+
export type { CheckboxProps } from './components/checkbox';
|
|
8
|
+
export { Checkbox } from './components/checkbox';
|
|
9
|
+
export type { DrawerProps } from './components/drawer';
|
|
10
|
+
export { Drawer } from './components/drawer';
|
|
11
|
+
export type { FieldProps } from './components/field';
|
|
12
|
+
export { Field } from './components/field';
|
|
13
|
+
export type { HelperTextProps } from './components/helper-text';
|
|
14
|
+
export { HelperText } from './components/helper-text';
|
|
15
|
+
export type { IconButtonProps } from './components/icon-button';
|
|
16
|
+
export { IconButton } from './components/icon-button';
|
|
17
|
+
export type { LabelProps } from './components/label';
|
|
18
|
+
export { Label } from './components/label';
|
|
19
|
+
export type { ListItemProps } from './components/list-item';
|
|
20
|
+
export { ListItem } from './components/list-item';
|
|
21
|
+
export type { MenuItem, MenuProps } from './components/menu';
|
|
22
|
+
export { Menu } from './components/menu';
|
|
23
|
+
export type { ModalProps } from './components/modal';
|
|
24
|
+
export { Modal } from './components/modal';
|
|
25
|
+
export type { RadioProps } from './components/radio';
|
|
26
|
+
export { Radio } from './components/radio';
|
|
27
|
+
export type { SwitchProps } from './components/switch';
|
|
28
|
+
export { Switch } from './components/switch';
|
|
29
|
+
export type { TabListProps, TabPanelProps, TabProps, TabsProps } from './components/tabs';
|
|
30
|
+
export { Tab, TabList, TabPanel, Tabs } from './components/tabs';
|
|
31
|
+
export type { TextInputProps } from './components/text-input';
|
|
32
|
+
export { TextInput } from './components/text-input';
|
|
33
|
+
export type { TextareaProps } from './components/textarea';
|
|
34
|
+
export { Textarea } from './components/textarea';
|
|
35
|
+
export type { ToastOptions, ToastProps, ToastTone } from './components/toast';
|
|
36
|
+
export { Toast, ToastProvider, useToast } from './components/toast';
|
|
37
|
+
export type { TooltipProps } from './components/tooltip';
|
|
38
|
+
export { Tooltip } from './components/tooltip';
|
|
39
|
+
export type { FontRuntime } from './context/FontContext';
|
|
40
|
+
export { FontProvider, useFontContext } from './context/FontContext';
|
|
41
|
+
export type { I18nInstance, TranslationRuntime, Translator } from './context/TranslationContext';
|
|
42
|
+
export { TranslationProvider, useTranslationContext } from './context/TranslationContext';
|
|
43
|
+
export * from './core/responsive';
|
|
44
|
+
export * from './layout';
|
|
45
|
+
export type { ButtonBaseProps } from './primitives/button-base';
|
|
46
|
+
export { ButtonBase } from './primitives/button-base';
|
|
47
|
+
export type { HeadingLevel, HeadingProps } from './primitives/heading';
|
|
48
|
+
export { Heading } from './primitives/heading';
|
|
49
|
+
export type { IconProps, IconProvider } from './primitives/icon';
|
|
50
|
+
export { Icon } from './primitives/icon';
|
|
51
|
+
export type { TextProps } from './primitives/text';
|
|
52
|
+
export { Text } from './primitives/text';
|
|
53
|
+
export * from './theme';
|
|
54
|
+
export { isDeepEqual } from './utils/deepEqual';
|
|
55
|
+
export { deepMerge } from './utils/deepMerge';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useFocusManager } from './useFocusManager';
|
|
5
|
+
|
|
6
|
+
export interface FocusScopeProps {
|
|
7
|
+
active: boolean;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
onEscape?: (() => void) | undefined;
|
|
10
|
+
testID?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FocusScope({ active, children, onEscape, testID }: FocusScopeProps) {
|
|
14
|
+
const containerRef = React.useRef<View | null>(null);
|
|
15
|
+
const { bindKeydown, capturePreviousFocus, cycleFocus, focusFirst, restorePreviousFocus } =
|
|
16
|
+
useFocusManager();
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (!active) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
capturePreviousFocus();
|
|
24
|
+
const timeoutId = setTimeout(() => {
|
|
25
|
+
focusFirst(containerRef.current);
|
|
26
|
+
}, 0);
|
|
27
|
+
|
|
28
|
+
const unbind = bindKeydown((event) => {
|
|
29
|
+
if (event.key === 'Tab') {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
cycleFocus(containerRef.current, Boolean(event.shiftKey));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (event.key === 'Escape') {
|
|
35
|
+
onEscape?.();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
clearTimeout(timeoutId);
|
|
41
|
+
unbind();
|
|
42
|
+
restorePreviousFocus();
|
|
43
|
+
};
|
|
44
|
+
}, [
|
|
45
|
+
active,
|
|
46
|
+
bindKeydown,
|
|
47
|
+
capturePreviousFocus,
|
|
48
|
+
cycleFocus,
|
|
49
|
+
focusFirst,
|
|
50
|
+
onEscape,
|
|
51
|
+
restorePreviousFocus,
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
collapsable={false}
|
|
57
|
+
ref={containerRef}
|
|
58
|
+
testID={testID}
|
|
59
|
+
style={{
|
|
60
|
+
flex: 1,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { getFocusableElements } from './useFocusManager';
|
|
4
|
+
|
|
5
|
+
interface MockFocusableTarget {
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
focus: () => void;
|
|
8
|
+
getAttribute?: (name: string) => string | null;
|
|
9
|
+
hidden?: boolean;
|
|
10
|
+
tabIndex?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('getFocusableElements', () => {
|
|
14
|
+
it('filters disabled, hidden, and aria-hidden nodes from focus traps', () => {
|
|
15
|
+
const active: MockFocusableTarget = {
|
|
16
|
+
focus: () => undefined,
|
|
17
|
+
getAttribute: () => null,
|
|
18
|
+
};
|
|
19
|
+
const disabled: MockFocusableTarget = {
|
|
20
|
+
disabled: true,
|
|
21
|
+
focus: () => undefined,
|
|
22
|
+
getAttribute: () => null,
|
|
23
|
+
};
|
|
24
|
+
const ariaHidden: MockFocusableTarget = {
|
|
25
|
+
focus: () => undefined,
|
|
26
|
+
getAttribute: (name) => (name === 'aria-hidden' ? 'true' : null),
|
|
27
|
+
};
|
|
28
|
+
const untabbable: MockFocusableTarget = {
|
|
29
|
+
focus: () => undefined,
|
|
30
|
+
getAttribute: (name) => (name === 'tabindex' ? '-1' : null),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const container = {
|
|
34
|
+
querySelectorAll: () => [active, disabled, ariaHidden, untabbable],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect(getFocusableElements(container)).toEqual([active]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns an empty list when no DOM-like query API is available', () => {
|
|
41
|
+
expect(getFocusableElements(null)).toEqual([]);
|
|
42
|
+
expect(getFocusableElements({})).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface FocusableTarget {
|
|
4
|
+
focus: () => void;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
hidden?: boolean;
|
|
7
|
+
tabIndex?: number;
|
|
8
|
+
getAttribute?: (name: string) => string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FocusContainer {
|
|
12
|
+
contains: (node: unknown) => boolean;
|
|
13
|
+
querySelectorAll: (selector: string) => ArrayLike<FocusableTarget>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WebKeyboardEventLike {
|
|
17
|
+
key?: string;
|
|
18
|
+
preventDefault: () => void;
|
|
19
|
+
shiftKey?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WebDocumentLike {
|
|
23
|
+
activeElement: unknown;
|
|
24
|
+
addEventListener: (type: 'keydown', listener: (event: WebKeyboardEventLike) => void) => void;
|
|
25
|
+
removeEventListener: (type: 'keydown', listener: (event: WebKeyboardEventLike) => void) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FOCUSABLE_SELECTOR = 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])';
|
|
29
|
+
|
|
30
|
+
function getWebDocument(): WebDocumentLike | null {
|
|
31
|
+
const maybeDocument = (globalThis as { document?: unknown }).document;
|
|
32
|
+
|
|
33
|
+
if (!maybeDocument || typeof maybeDocument !== 'object') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return maybeDocument as WebDocumentLike;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isFocusableTarget(target: FocusableTarget): boolean {
|
|
41
|
+
if (target.disabled || target.hidden) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (typeof target.tabIndex === 'number' && target.tabIndex < 0) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ariaHidden = target.getAttribute?.('aria-hidden');
|
|
50
|
+
if (ariaHidden === 'true') {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tabIndexAttribute = target.getAttribute?.('tabindex');
|
|
55
|
+
if (tabIndexAttribute === '-1') {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getFocusableElements(container: unknown): FocusableTarget[] {
|
|
63
|
+
if (!container || typeof container !== 'object') {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const focusContainer = container as FocusContainer;
|
|
68
|
+
if (typeof focusContainer.querySelectorAll !== 'function') {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Array.from(focusContainer.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isFocusableTarget);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useFocusManager() {
|
|
76
|
+
const previousFocusRef = React.useRef<FocusableTarget | null>(null);
|
|
77
|
+
|
|
78
|
+
const capturePreviousFocus = React.useCallback(() => {
|
|
79
|
+
const documentRef = getWebDocument();
|
|
80
|
+
const activeElement = documentRef?.activeElement;
|
|
81
|
+
|
|
82
|
+
previousFocusRef.current =
|
|
83
|
+
activeElement && typeof activeElement === 'object' && 'focus' in activeElement
|
|
84
|
+
? (activeElement as FocusableTarget)
|
|
85
|
+
: null;
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const focusFirst = React.useCallback((container: unknown) => {
|
|
89
|
+
const [firstFocusable] = getFocusableElements(container);
|
|
90
|
+
|
|
91
|
+
firstFocusable?.focus();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const restorePreviousFocus = React.useCallback(() => {
|
|
95
|
+
previousFocusRef.current?.focus();
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const cycleFocus = React.useCallback((container: unknown, reverse = false) => {
|
|
99
|
+
const focusableElements = getFocusableElements(container);
|
|
100
|
+
if (focusableElements.length === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const documentRef = getWebDocument();
|
|
105
|
+
const activeElement = documentRef?.activeElement;
|
|
106
|
+
const currentIndex = focusableElements.findIndex((element) => element === activeElement);
|
|
107
|
+
|
|
108
|
+
if (currentIndex === -1) {
|
|
109
|
+
(reverse ? focusableElements[focusableElements.length - 1] : focusableElements[0])?.focus();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nextIndex = reverse
|
|
114
|
+
? (currentIndex - 1 + focusableElements.length) % focusableElements.length
|
|
115
|
+
: (currentIndex + 1) % focusableElements.length;
|
|
116
|
+
|
|
117
|
+
focusableElements[nextIndex]?.focus();
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const bindKeydown = React.useCallback((listener: (event: WebKeyboardEventLike) => void) => {
|
|
121
|
+
const documentRef = getWebDocument();
|
|
122
|
+
if (!documentRef) {
|
|
123
|
+
return () => {
|
|
124
|
+
/* no-op */
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
documentRef.addEventListener('keydown', listener);
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
documentRef.removeEventListener('keydown', listener);
|
|
132
|
+
};
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
bindKeydown,
|
|
137
|
+
capturePreviousFocus,
|
|
138
|
+
cycleFocus,
|
|
139
|
+
focusFirst,
|
|
140
|
+
restorePreviousFocus,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createOverlayEntry,
|
|
6
|
+
type OverlayDescriptor,
|
|
7
|
+
type OverlayEntry,
|
|
8
|
+
OverlayStackContext,
|
|
9
|
+
sortOverlayEntries,
|
|
10
|
+
} from './useOverlayStack';
|
|
11
|
+
|
|
12
|
+
export function OverlayProvider({ children }: { children: React.ReactNode }) {
|
|
13
|
+
const orderRef = React.useRef(0);
|
|
14
|
+
const [overlays, setOverlays] = React.useState<OverlayEntry[]>([]);
|
|
15
|
+
|
|
16
|
+
const setOverlay = React.useCallback((id: string, descriptor: OverlayDescriptor) => {
|
|
17
|
+
setOverlays((current) => {
|
|
18
|
+
const existing = current.find((entry) => entry.id === id);
|
|
19
|
+
const nextEntry = createOverlayEntry(id, existing?.order ?? orderRef.current++, descriptor);
|
|
20
|
+
|
|
21
|
+
if (!existing) {
|
|
22
|
+
return sortOverlayEntries([...current, nextEntry]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return sortOverlayEntries(current.map((entry) => (entry.id === id ? nextEntry : entry)));
|
|
26
|
+
});
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const removeOverlay = React.useCallback((id: string) => {
|
|
30
|
+
setOverlays((current) => current.filter((entry) => entry.id !== id));
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const value = React.useMemo(
|
|
34
|
+
() => ({
|
|
35
|
+
overlays,
|
|
36
|
+
removeOverlay,
|
|
37
|
+
setOverlay,
|
|
38
|
+
}),
|
|
39
|
+
[overlays, removeOverlay, setOverlay],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<OverlayStackContext.Provider value={value}>
|
|
44
|
+
{children}
|
|
45
|
+
<View
|
|
46
|
+
pointerEvents="box-none"
|
|
47
|
+
style={{
|
|
48
|
+
bottom: 0,
|
|
49
|
+
left: 0,
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
right: 0,
|
|
52
|
+
top: 0,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{overlays.map((overlay) => (
|
|
56
|
+
<View
|
|
57
|
+
key={overlay.id}
|
|
58
|
+
pointerEvents="box-none"
|
|
59
|
+
style={{
|
|
60
|
+
bottom: 0,
|
|
61
|
+
left: 0,
|
|
62
|
+
position: 'absolute',
|
|
63
|
+
right: 0,
|
|
64
|
+
top: 0,
|
|
65
|
+
zIndex: overlay.zIndex,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{overlay.node}
|
|
69
|
+
</View>
|
|
70
|
+
))}
|
|
71
|
+
</View>
|
|
72
|
+
</OverlayStackContext.Provider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { OverlayLayer } from '../resolvers/resolveOverlayZIndex';
|
|
4
|
+
import { useOverlayStack } from './useOverlayStack';
|
|
5
|
+
|
|
6
|
+
let portalCounter = 0;
|
|
7
|
+
|
|
8
|
+
export interface PortalProps {
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
layer?: OverlayLayer;
|
|
11
|
+
visible?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Portal({ children, layer = 'modal', visible = true }: PortalProps) {
|
|
15
|
+
const overlayStack = useOverlayStack();
|
|
16
|
+
const idRef = React.useRef(`surface-portal-${portalCounter++}`);
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (!overlayStack || !visible || children === undefined || children === null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
overlayStack.setOverlay(idRef.current, {
|
|
24
|
+
layer,
|
|
25
|
+
node: children,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
overlayStack.removeOverlay(idRef.current);
|
|
30
|
+
};
|
|
31
|
+
}, [children, layer, overlayStack, visible]);
|
|
32
|
+
|
|
33
|
+
if (!overlayStack) {
|
|
34
|
+
return visible ? <>{children}</> : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { type OverlayEntry, sortOverlayEntries } from './useOverlayStack';
|
|
4
|
+
|
|
5
|
+
describe('sortOverlayEntries', () => {
|
|
6
|
+
it('sorts overlays by z-index then registration order', () => {
|
|
7
|
+
const overlays: OverlayEntry[] = [
|
|
8
|
+
{ id: 'menu-1', layer: 'menu', node: null, order: 2, zIndex: 1200 },
|
|
9
|
+
{ id: 'modal-1', layer: 'modal', node: null, order: 0, zIndex: 1000 },
|
|
10
|
+
{ id: 'menu-0', layer: 'menu', node: null, order: 1, zIndex: 1200 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
expect(sortOverlayEntries(overlays).map((entry) => entry.id)).toEqual([
|
|
14
|
+
'modal-1',
|
|
15
|
+
'menu-0',
|
|
16
|
+
'menu-1',
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('recomputes per-layer stack indices to avoid z-index collisions after removals', () => {
|
|
21
|
+
const overlays: OverlayEntry[] = [
|
|
22
|
+
{ id: 'menu-0', layer: 'menu', node: null, order: 1, zIndex: 1200 },
|
|
23
|
+
{ id: 'menu-2', layer: 'menu', node: null, order: 3, zIndex: 1200 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const sorted = sortOverlayEntries(overlays);
|
|
27
|
+
|
|
28
|
+
expect(sorted[0]?.zIndex).toBe(1200);
|
|
29
|
+
expect(sorted[1]?.zIndex).toBe(1201);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { type OverlayLayer, resolveOverlayZIndex } from '../resolvers/resolveOverlayZIndex';
|
|
4
|
+
|
|
5
|
+
export interface OverlayEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
layer: OverlayLayer;
|
|
8
|
+
node: React.ReactNode;
|
|
9
|
+
order: number;
|
|
10
|
+
zIndex: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OverlayDescriptor {
|
|
14
|
+
layer: OverlayLayer;
|
|
15
|
+
node: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OverlayStackRuntime {
|
|
19
|
+
overlays: OverlayEntry[];
|
|
20
|
+
setOverlay: (id: string, overlay: OverlayDescriptor) => void;
|
|
21
|
+
removeOverlay: (id: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sortOverlayEntries(entries: OverlayEntry[]): OverlayEntry[] {
|
|
25
|
+
const perLayerCounts: Partial<Record<OverlayLayer, number>> = {};
|
|
26
|
+
const normalizedEntries = [...entries]
|
|
27
|
+
.sort((left, right) => left.order - right.order)
|
|
28
|
+
.map((entry) => {
|
|
29
|
+
const stackIndex = perLayerCounts[entry.layer] ?? 0;
|
|
30
|
+
perLayerCounts[entry.layer] = stackIndex + 1;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...entry,
|
|
34
|
+
zIndex: resolveOverlayZIndex(entry.layer, stackIndex),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return normalizedEntries.sort((left, right) =>
|
|
39
|
+
left.zIndex === right.zIndex ? left.order - right.order : left.zIndex - right.zIndex,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const OverlayStackContext = React.createContext<OverlayStackRuntime | null>(null);
|
|
44
|
+
|
|
45
|
+
export function useOverlayStack(): OverlayStackRuntime | null {
|
|
46
|
+
return React.useContext(OverlayStackContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createOverlayEntry(
|
|
50
|
+
id: string,
|
|
51
|
+
order: number,
|
|
52
|
+
descriptor: OverlayDescriptor,
|
|
53
|
+
): OverlayEntry {
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
layer: descriptor.layer,
|
|
57
|
+
node: descriptor.node,
|
|
58
|
+
order,
|
|
59
|
+
zIndex: resolveOverlayZIndex(descriptor.layer, 0),
|
|
60
|
+
};
|
|
61
|
+
}
|