@duro-app/ui 0.12.2 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
  2. package/dist/components/PageShell/PageShell.d.ts +15 -0
  3. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  4. package/dist/components/PageShell/index.d.ts +3 -0
  5. package/dist/components/PageShell/index.d.ts.map +1 -0
  6. package/dist/components/PageShell/styles.css.d.ts +41 -0
  7. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  8. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  9. package/dist/index.css +1 -1
  10. package/dist/index.d.ts +1 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3203 -3355
  13. package/dist/index.js.map +1 -1
  14. package/package.json +4 -4
  15. package/src/components/Alert/Alert.stories.tsx +76 -0
  16. package/src/components/Alert/Alert.tsx +45 -0
  17. package/src/components/Alert/styles.css.ts +50 -0
  18. package/src/components/Badge/Badge.stories.tsx +94 -0
  19. package/src/components/Badge/Badge.tsx +21 -0
  20. package/src/components/Badge/styles.css.ts +51 -0
  21. package/src/components/Button/Button.stories.tsx +130 -0
  22. package/src/components/Button/Button.tsx +48 -0
  23. package/src/components/Button/styles.css.ts +107 -0
  24. package/src/components/Callout/Callout.stories.tsx +97 -0
  25. package/src/components/Callout/Callout.tsx +39 -0
  26. package/src/components/Callout/index.ts +1 -0
  27. package/src/components/Callout/styles.css.ts +45 -0
  28. package/src/components/Card/Card.stories.tsx +119 -0
  29. package/src/components/Card/Card.tsx +35 -0
  30. package/src/components/Card/styles.css.ts +67 -0
  31. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  32. package/src/components/Checkbox/Checkbox.tsx +73 -0
  33. package/src/components/Checkbox/styles.css.ts +57 -0
  34. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  35. package/src/components/Cluster/Cluster.tsx +43 -0
  36. package/src/components/Cluster/styles.css.ts +25 -0
  37. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  38. package/src/components/EmptyState/EmptyState.tsx +19 -0
  39. package/src/components/EmptyState/styles.css.ts +25 -0
  40. package/src/components/Field/Field.stories.tsx +92 -0
  41. package/src/components/Field/Field.tsx +80 -0
  42. package/src/components/Field/FieldContext.ts +14 -0
  43. package/src/components/Field/styles.css.ts +25 -0
  44. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  45. package/src/components/Fieldset/Fieldset.tsx +49 -0
  46. package/src/components/Fieldset/index.ts +1 -0
  47. package/src/components/Fieldset/styles.css.ts +33 -0
  48. package/src/components/Grid/Grid.stories.tsx +107 -0
  49. package/src/components/Grid/Grid.tsx +41 -0
  50. package/src/components/Grid/styles.css.ts +25 -0
  51. package/src/components/Heading/Heading.tsx +48 -0
  52. package/src/components/Heading/styles.css.ts +26 -0
  53. package/src/components/Icon/Icon.tsx +168 -0
  54. package/src/components/Icon/index.ts +2 -0
  55. package/src/components/Inline/Inline.stories.tsx +88 -0
  56. package/src/components/Inline/Inline.tsx +45 -0
  57. package/src/components/Inline/styles.css.ts +27 -0
  58. package/src/components/Input/Input.stories.tsx +89 -0
  59. package/src/components/Input/Input.tsx +77 -0
  60. package/src/components/Input/styles.css.ts +60 -0
  61. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  62. package/src/components/InputGroup/InputGroup.tsx +60 -0
  63. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  64. package/src/components/InputGroup/styles.css.ts +61 -0
  65. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  66. package/src/components/LinkButton/LinkButton.tsx +42 -0
  67. package/src/components/LinkButton/styles.css.ts +56 -0
  68. package/src/components/Menu/Menu.stories.tsx +146 -0
  69. package/src/components/Menu/Menu.tsx +151 -0
  70. package/src/components/Menu/MenuContext.ts +20 -0
  71. package/src/components/Menu/styles.css.ts +89 -0
  72. package/src/components/Menu/useMenuRoot.ts +136 -0
  73. package/src/components/PageShell/PageShell.tsx +45 -0
  74. package/src/components/PageShell/index.ts +2 -0
  75. package/src/components/PageShell/styles.css.ts +26 -0
  76. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  77. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  78. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  79. package/src/components/ScrollArea/styles.css.ts +81 -0
  80. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  81. package/src/components/Select/Select.stories.tsx +144 -0
  82. package/src/components/Select/Select.tsx +183 -0
  83. package/src/components/Select/SelectContext.ts +24 -0
  84. package/src/components/Select/styles.css.ts +97 -0
  85. package/src/components/Select/useSelectRoot.ts +178 -0
  86. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  87. package/src/components/SideNav/SideNav.tsx +172 -0
  88. package/src/components/SideNav/SideNavContext.ts +18 -0
  89. package/src/components/SideNav/styles.css.ts +95 -0
  90. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  91. package/src/components/Spinner/Spinner.tsx +24 -0
  92. package/src/components/Spinner/styles.css.ts +47 -0
  93. package/src/components/Stack/Stack.stories.tsx +103 -0
  94. package/src/components/Stack/Stack.tsx +33 -0
  95. package/src/components/Stack/styles.css.ts +21 -0
  96. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  97. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  98. package/src/components/StatusIcon/styles.css.ts +27 -0
  99. package/src/components/Switch/Switch.stories.tsx +88 -0
  100. package/src/components/Switch/Switch.tsx +78 -0
  101. package/src/components/Switch/styles.css.ts +71 -0
  102. package/src/components/Table/Table.stories.tsx +308 -0
  103. package/src/components/Table/Table.tsx +179 -0
  104. package/src/components/Table/styles.css.ts +97 -0
  105. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  106. package/src/components/Tabs/Tabs.tsx +210 -0
  107. package/src/components/Tabs/TabsContext.ts +20 -0
  108. package/src/components/Tabs/styles.css.ts +98 -0
  109. package/src/components/Tabs/useTabsRoot.ts +42 -0
  110. package/src/components/Text/Text.tsx +52 -0
  111. package/src/components/Text/styles.css.ts +57 -0
  112. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  113. package/src/components/Textarea/Textarea.tsx +50 -0
  114. package/src/components/Textarea/styles.css.ts +56 -0
  115. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  116. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  117. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  118. package/src/components/Toggle/Toggle.tsx +85 -0
  119. package/src/components/Toggle/styles.css.ts +66 -0
  120. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  121. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  122. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  123. package/src/components/ToggleGroup/styles.css.ts +17 -0
  124. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  125. package/src/components/Tooltip/Tooltip.tsx +97 -0
  126. package/src/components/Tooltip/styles.css.ts +56 -0
  127. package/src/docs/Spacing.mdx +80 -0
  128. package/src/docs/Spacing.stories.tsx +202 -0
  129. package/src/docs/Typography.mdx +93 -0
  130. package/src/docs/Typography.stories.tsx +211 -0
  131. package/src/docs/helpers.tsx +135 -0
  132. package/src/hooks/useContainerQuery.ts +54 -0
  133. package/src/hooks/useControllableValue.ts +18 -0
  134. package/src/index.ts +56 -0
  135. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,146 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {Menu} from './Menu'
4
+
5
+ const meta: Meta = {
6
+ title: 'Components/Menu',
7
+ }
8
+
9
+ export default meta
10
+ type Story = StoryObj
11
+
12
+ export const Default: Story = {
13
+ render: () => {
14
+ const handleSettings = fn()
15
+ const handleProfile = fn()
16
+ return (
17
+ <Menu.Root>
18
+ <Menu.Trigger>
19
+ Options{' '}
20
+ <svg
21
+ width="12"
22
+ height="12"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth="2"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ aria-hidden="true"
30
+ style={{verticalAlign: 'middle'}}
31
+ >
32
+ <path d="M6 9l6 6 6-6" />
33
+ </svg>
34
+ </Menu.Trigger>
35
+ <Menu.Popup>
36
+ <Menu.Item onClick={handleSettings}>Settings</Menu.Item>
37
+ <Menu.Item onClick={handleProfile}>Profile</Menu.Item>
38
+ <Menu.Item>Logout</Menu.Item>
39
+ </Menu.Popup>
40
+ </Menu.Root>
41
+ )
42
+ },
43
+ play: async ({canvas}) => {
44
+ const trigger = canvas.getByRole('button', {name: /Options/})
45
+ await expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
46
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
47
+ await expect(canvas.queryByRole('menu')).not.toBeInTheDocument()
48
+ },
49
+ }
50
+
51
+ export const OpenClose: Story = {
52
+ render: () => (
53
+ <Menu.Root>
54
+ <Menu.Trigger>Options</Menu.Trigger>
55
+ <Menu.Popup>
56
+ <Menu.Item>Settings</Menu.Item>
57
+ <Menu.Item>Profile</Menu.Item>
58
+ <Menu.Item>Logout</Menu.Item>
59
+ </Menu.Popup>
60
+ </Menu.Root>
61
+ ),
62
+ play: async ({canvas, userEvent}) => {
63
+ const trigger = canvas.getByRole('button', {name: /Options/})
64
+
65
+ await userEvent.click(trigger)
66
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
67
+ const items = canvas.getAllByRole('menuitem')
68
+ await expect(items.length).toBe(3)
69
+
70
+ await userEvent.keyboard('{Escape}')
71
+ await expect(canvas.queryByRole('menu')).not.toBeInTheDocument()
72
+ },
73
+ }
74
+
75
+ export const KeyboardNavigation: Story = {
76
+ render: () => (
77
+ <Menu.Root>
78
+ <Menu.Trigger>
79
+ Navigate{' '}
80
+ <svg
81
+ width="12"
82
+ height="12"
83
+ viewBox="0 0 24 24"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ strokeWidth="2"
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ aria-hidden="true"
90
+ style={{verticalAlign: 'middle'}}
91
+ >
92
+ <path d="M6 9l6 6 6-6" />
93
+ </svg>
94
+ </Menu.Trigger>
95
+ <Menu.Popup>
96
+ <Menu.Item>First</Menu.Item>
97
+ <Menu.Item>Second</Menu.Item>
98
+ <Menu.Item>Third</Menu.Item>
99
+ </Menu.Popup>
100
+ </Menu.Root>
101
+ ),
102
+ play: async ({canvas, userEvent}) => {
103
+ const trigger = canvas.getByRole('button', {name: /Navigate/})
104
+ await userEvent.click(trigger)
105
+ await expect(canvas.getByRole('menu')).toBeInTheDocument()
106
+ },
107
+ }
108
+
109
+ export const WithLinks: Story = {
110
+ render: () => (
111
+ <Menu.Root>
112
+ <Menu.Trigger>
113
+ Account{' '}
114
+ <svg
115
+ width="12"
116
+ height="12"
117
+ viewBox="0 0 24 24"
118
+ fill="none"
119
+ stroke="currentColor"
120
+ strokeWidth="2"
121
+ strokeLinecap="round"
122
+ strokeLinejoin="round"
123
+ aria-hidden="true"
124
+ style={{verticalAlign: 'middle'}}
125
+ >
126
+ <path d="M6 9l6 6 6-6" />
127
+ </svg>
128
+ </Menu.Trigger>
129
+ <Menu.Popup>
130
+ <Menu.LinkItem href="#admin">Admin</Menu.LinkItem>
131
+ <Menu.LinkItem href="#settings">Settings</Menu.LinkItem>
132
+ <Menu.LinkItem href="#logout">Logout</Menu.LinkItem>
133
+ </Menu.Popup>
134
+ </Menu.Root>
135
+ ),
136
+ play: async ({canvas, userEvent}) => {
137
+ await userEvent.click(canvas.getByRole('button', {name: /Account/}))
138
+
139
+ const items = canvas.getAllByRole('menuitem')
140
+ await expect(items.length).toBe(3)
141
+
142
+ // Link items should be anchor elements with href
143
+ await expect(items[0].tagName).toBe('A')
144
+ await expect(items[0]).toHaveAttribute('href', '#admin')
145
+ },
146
+ }
@@ -0,0 +1,151 @@
1
+ import {type ReactNode, useRef, useId, useEffect} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {MenuContext, useMenu} from './MenuContext'
5
+ import {useMenuRoot} from './useMenuRoot'
6
+
7
+ // --- Root ---
8
+ interface RootProps {
9
+ children: ReactNode
10
+ }
11
+
12
+ function Root({children}: RootProps) {
13
+ const {ctx, rootRef} = useMenuRoot()
14
+
15
+ return (
16
+ <MenuContext.Provider value={ctx}>
17
+ <html.div ref={rootRef} style={styles.root}>
18
+ {children}
19
+ </html.div>
20
+ </MenuContext.Provider>
21
+ )
22
+ }
23
+
24
+ // --- Trigger ---
25
+ function Trigger({children}: {children: ReactNode}) {
26
+ const {open, toggle, menuId, triggerRef} = useMenu()
27
+ const localRef = useRef<HTMLButtonElement>(null)
28
+
29
+ // Sync local ref to context triggerRef
30
+ useEffect(() => {
31
+ triggerRef.current = localRef.current
32
+ })
33
+
34
+ return (
35
+ <html.button
36
+ ref={localRef}
37
+ type="button"
38
+ onClick={toggle}
39
+ aria-expanded={open}
40
+ aria-haspopup="menu"
41
+ aria-controls={open ? menuId : undefined}
42
+ style={styles.trigger}
43
+ >
44
+ {children}
45
+ </html.button>
46
+ )
47
+ }
48
+
49
+ // --- Popup ---
50
+ interface PopupProps {
51
+ children: ReactNode
52
+ align?: 'start' | 'end'
53
+ }
54
+
55
+ function Popup({children, align = 'start'}: PopupProps) {
56
+ const {open, close, menuId, highlightedId} = useMenu()
57
+
58
+ if (!open) return null
59
+
60
+ return (
61
+ <>
62
+ <html.div style={styles.backdrop} onClick={close} />
63
+ <html.div
64
+ id={menuId}
65
+ role="menu"
66
+ aria-activedescendant={highlightedId ?? undefined}
67
+ style={[styles.popup, align === 'end' && styles.popupEnd]}
68
+ >
69
+ {children}
70
+ </html.div>
71
+ </>
72
+ )
73
+ }
74
+
75
+ // --- Item ---
76
+ interface ItemProps {
77
+ onClick?: () => void
78
+ children: ReactNode
79
+ }
80
+
81
+ function Item({onClick, children}: ItemProps) {
82
+ const {close, highlightedId, setHighlightedId, registerItem} = useMenu()
83
+ const id = useId()
84
+ const ref = useRef<HTMLDivElement>(null)
85
+ const isHighlighted = highlightedId === id
86
+
87
+ useEffect(() => {
88
+ const el = ref.current
89
+ if (!el) return
90
+ return registerItem(id, el)
91
+ }, [id, registerItem])
92
+
93
+ const handleClick = () => {
94
+ onClick?.()
95
+ close()
96
+ }
97
+
98
+ return (
99
+ <html.div
100
+ ref={ref}
101
+ id={id}
102
+ role="menuitem"
103
+ onClick={handleClick}
104
+ onPointerEnter={() => setHighlightedId(id)}
105
+ style={[styles.item, isHighlighted && styles.itemHighlighted]}
106
+ >
107
+ {children}
108
+ </html.div>
109
+ )
110
+ }
111
+
112
+ // --- LinkItem ---
113
+ interface LinkItemProps {
114
+ href: string
115
+ children: ReactNode
116
+ }
117
+
118
+ function LinkItem({href, children}: LinkItemProps) {
119
+ const {close, highlightedId, setHighlightedId, registerItem} = useMenu()
120
+ const id = useId()
121
+ const ref = useRef<HTMLAnchorElement>(null)
122
+ const isHighlighted = highlightedId === id
123
+
124
+ useEffect(() => {
125
+ const el = ref.current
126
+ if (!el) return
127
+ return registerItem(id, el)
128
+ }, [id, registerItem])
129
+
130
+ return (
131
+ <html.a
132
+ ref={ref}
133
+ id={id}
134
+ href={href}
135
+ onClick={close}
136
+ role="menuitem"
137
+ onPointerEnter={() => setHighlightedId(id)}
138
+ style={[styles.item, styles.linkItem, isHighlighted && styles.itemHighlighted]}
139
+ >
140
+ {children}
141
+ </html.a>
142
+ )
143
+ }
144
+
145
+ export const Menu = {
146
+ Root,
147
+ Trigger,
148
+ Popup,
149
+ Item,
150
+ LinkItem,
151
+ }
@@ -0,0 +1,20 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface MenuContextValue {
4
+ open: boolean
5
+ toggle: () => void
6
+ close: () => void
7
+ menuId: string
8
+ highlightedId: string | null
9
+ setHighlightedId: (id: string | null) => void
10
+ registerItem: (id: string, element: HTMLElement) => () => void
11
+ triggerRef: React.RefObject<HTMLButtonElement | null>
12
+ }
13
+
14
+ export const MenuContext = createContext<MenuContextValue | null>(null)
15
+
16
+ export function useMenu() {
17
+ const ctx = useContext(MenuContext)
18
+ if (!ctx) throw new Error('Menu compound components must be used within Menu.Root')
19
+ return ctx
20
+ }
@@ -0,0 +1,89 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+
7
+ export const styles = css.create({
8
+ root: {
9
+ position: 'relative',
10
+ display: 'inline-flex',
11
+ },
12
+ trigger: {
13
+ display: 'inline-flex',
14
+ alignItems: 'center',
15
+ gap: spacing.sm,
16
+ paddingTop: spacing.sm,
17
+ paddingBottom: spacing.sm,
18
+ paddingLeft: spacing.md,
19
+ paddingRight: spacing.md,
20
+ fontFamily: typography.fontFamily,
21
+ fontSize: typography.fontSizeSm,
22
+ color: colors.text,
23
+ backgroundColor: {
24
+ default: 'transparent',
25
+ ':hover': colors.bgCardHover,
26
+ },
27
+ borderWidth: 1,
28
+ borderStyle: 'solid',
29
+ borderColor: colors.border,
30
+ borderRadius: radii.sm,
31
+ cursor: 'pointer',
32
+ transitionProperty: 'background-color, border-color',
33
+ transitionDuration: '150ms',
34
+ },
35
+ backdrop: {
36
+ position: 'fixed',
37
+ top: 0,
38
+ left: 0,
39
+ right: 0,
40
+ bottom: 0,
41
+ zIndex: 49,
42
+ },
43
+ popup: {
44
+ position: 'absolute',
45
+ top: '100%',
46
+ left: 0,
47
+ marginTop: spacing.xs,
48
+ backgroundColor: colors.bgCard,
49
+ borderWidth: 1,
50
+ borderStyle: 'solid',
51
+ borderColor: colors.border,
52
+ borderRadius: radii.sm,
53
+ boxShadow: shadows.md,
54
+ paddingTop: spacing.xs,
55
+ paddingBottom: spacing.xs,
56
+ minWidth: 160,
57
+ zIndex: 50,
58
+ },
59
+ popupEnd: {
60
+ left: 'auto',
61
+ right: 0,
62
+ },
63
+ item: {
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ paddingTop: spacing.sm,
67
+ paddingBottom: spacing.sm,
68
+ paddingLeft: spacing.md,
69
+ paddingRight: spacing.md,
70
+ fontSize: typography.fontSizeSm,
71
+ fontFamily: typography.fontFamily,
72
+ color: colors.text,
73
+ borderRadius: radii.sm,
74
+ cursor: 'pointer',
75
+ backgroundColor: 'transparent',
76
+ transitionProperty: 'background-color',
77
+ transitionDuration: '150ms',
78
+ },
79
+ itemHighlighted: {
80
+ backgroundColor: colors.bgCardHover,
81
+ },
82
+ linkItem: {
83
+ textDecoration: 'none',
84
+ color: {
85
+ default: colors.text,
86
+ ':hover': colors.text,
87
+ },
88
+ },
89
+ })
@@ -0,0 +1,136 @@
1
+ import {useState, useCallback, useRef, useId, useEffect} from 'react'
2
+ import type {MenuContextValue} from './MenuContext'
3
+
4
+ export function useMenuRoot() {
5
+ const [open, setOpen] = useState(false)
6
+ const [highlightedId, setHighlightedId] = useState<string | null>(null)
7
+ const menuId = useId()
8
+ const rootRef = useRef<HTMLDivElement>(null)
9
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
10
+ const itemsRef = useRef(new Map<string, HTMLElement>())
11
+ const orderRef = useRef<string[]>([])
12
+ const needsInitialHighlightRef = useRef(false)
13
+
14
+ const close = useCallback(() => {
15
+ setOpen(false)
16
+ setHighlightedId(null)
17
+ needsInitialHighlightRef.current = false
18
+ triggerRef.current?.focus()
19
+ }, [])
20
+
21
+ const toggle = useCallback(() => {
22
+ setOpen((prev) => {
23
+ if (!prev) {
24
+ needsInitialHighlightRef.current = true
25
+ } else {
26
+ setHighlightedId(null)
27
+ needsInitialHighlightRef.current = false
28
+ }
29
+ return !prev
30
+ })
31
+ }, [])
32
+
33
+ // Highlight the first item after items register on open.
34
+ // Child effects (item registration) run before this parent effect,
35
+ // so orderRef is populated by the time this runs.
36
+ useEffect(() => {
37
+ if (open && needsInitialHighlightRef.current) {
38
+ needsInitialHighlightRef.current = false
39
+ const order = orderRef.current
40
+ if (order.length > 0) {
41
+ setHighlightedId(order[0])
42
+ }
43
+ }
44
+ }, [open])
45
+
46
+ const registerItem = useCallback((id: string, element: HTMLElement) => {
47
+ itemsRef.current.set(id, element)
48
+ const map = itemsRef.current
49
+ const ids = [...map.keys()]
50
+ ids.sort((a, b) => {
51
+ const elA = map.get(a)
52
+ const elB = map.get(b)
53
+ if (!elA || !elB) return 0
54
+ return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
55
+ })
56
+ orderRef.current = ids
57
+ return () => {
58
+ itemsRef.current.delete(id)
59
+ orderRef.current = orderRef.current.filter((i) => i !== id)
60
+ }
61
+ }, [])
62
+
63
+ // Native keydown for full KeyboardEvent access (preventDefault)
64
+ useEffect(() => {
65
+ const root = rootRef.current
66
+ if (!root || !open) return
67
+
68
+ function handleKeyDown(e: KeyboardEvent) {
69
+ const order = orderRef.current
70
+ if (order.length === 0) return
71
+
72
+ switch (e.key) {
73
+ case 'ArrowDown': {
74
+ e.preventDefault()
75
+ setHighlightedId((prev) => {
76
+ const idx = prev ? order.indexOf(prev) : -1
77
+ return order[(idx + 1) % order.length]
78
+ })
79
+ break
80
+ }
81
+ case 'ArrowUp': {
82
+ e.preventDefault()
83
+ setHighlightedId((prev) => {
84
+ const idx = prev ? order.indexOf(prev) : 0
85
+ return order[(idx - 1 + order.length) % order.length]
86
+ })
87
+ break
88
+ }
89
+ case 'Home': {
90
+ e.preventDefault()
91
+ setHighlightedId(order[0])
92
+ break
93
+ }
94
+ case 'End': {
95
+ e.preventDefault()
96
+ setHighlightedId(order[order.length - 1])
97
+ break
98
+ }
99
+ case 'Enter':
100
+ case ' ': {
101
+ e.preventDefault()
102
+ const items = itemsRef.current
103
+ setHighlightedId((prev) => {
104
+ if (prev) {
105
+ const el = items.get(prev)
106
+ el?.click()
107
+ }
108
+ return prev
109
+ })
110
+ break
111
+ }
112
+ case 'Escape':
113
+ case 'Tab': {
114
+ close()
115
+ break
116
+ }
117
+ }
118
+ }
119
+
120
+ root.addEventListener('keydown', handleKeyDown)
121
+ return () => root.removeEventListener('keydown', handleKeyDown)
122
+ }, [open, close])
123
+
124
+ const ctx: MenuContextValue = {
125
+ open,
126
+ toggle,
127
+ close,
128
+ menuId,
129
+ highlightedId,
130
+ setHighlightedId,
131
+ registerItem,
132
+ triggerRef,
133
+ }
134
+
135
+ return {ctx, rootRef}
136
+ }
@@ -0,0 +1,45 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ export type PageShellMaxWidth = 'sm' | 'md' | 'lg' | 'full'
6
+ export type PageShellPadding = 'sm' | 'md' | 'lg'
7
+
8
+ interface PageShellProps {
9
+ /** Max-width preset: sm (600), md (800), lg (1200), full (none). Default: 'lg' */
10
+ maxWidth?: PageShellMaxWidth
11
+ /** Horizontal padding preset. Default: 'md' (24px) */
12
+ padding?: PageShellPadding
13
+ /** Header element rendered above the main content with same constraints */
14
+ header?: ReactNode
15
+ children: ReactNode
16
+ }
17
+
18
+ const maxWidthMap = {
19
+ sm: styles.maxSm,
20
+ md: styles.maxMd,
21
+ lg: styles.maxLg,
22
+ full: null,
23
+ } as const
24
+
25
+ const paddingMap = {
26
+ sm: styles.padSm,
27
+ md: styles.padMd,
28
+ lg: styles.padLg,
29
+ } as const
30
+
31
+ export function PageShell({maxWidth = 'lg', padding = 'md', header, children}: PageShellProps) {
32
+ const mw = maxWidthMap[maxWidth]
33
+ const pad = paddingMap[padding]
34
+
35
+ return (
36
+ <html.div style={styles.root}>
37
+ {header != null && (
38
+ <html.header style={[styles.container, styles.maxLg, pad, styles.headerPadding]}>
39
+ {header}
40
+ </html.header>
41
+ )}
42
+ <html.main style={[styles.container, mw, pad, styles.mainPadding]}>{children}</html.main>
43
+ </html.div>
44
+ )
45
+ }
@@ -0,0 +1,2 @@
1
+ export {PageShell} from './PageShell'
2
+ export type {PageShellMaxWidth, PageShellPadding} from './PageShell'
@@ -0,0 +1,26 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {layoutSpacing} from '@duro-app/tokens/tokens/layout-spacing.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+
5
+ export const styles = css.create({
6
+ root: {
7
+ width: '100%',
8
+ },
9
+ container: {
10
+ width: '100%',
11
+ marginLeft: 'auto',
12
+ marginRight: 'auto',
13
+ boxSizing: 'border-box',
14
+ },
15
+ // Max-width presets
16
+ maxSm: {maxWidth: 600},
17
+ maxMd: {maxWidth: 800},
18
+ maxLg: {maxWidth: 1200},
19
+ // Horizontal padding
20
+ padSm: {paddingLeft: layoutSpacing.containerSm, paddingRight: layoutSpacing.containerSm},
21
+ padMd: {paddingLeft: layoutSpacing.containerMd, paddingRight: layoutSpacing.containerMd},
22
+ padLg: {paddingLeft: layoutSpacing.containerLg, paddingRight: layoutSpacing.containerLg},
23
+ // Vertical spacing
24
+ headerPadding: {paddingTop: spacing.lg},
25
+ mainPadding: {paddingTop: spacing.xl, paddingBottom: spacing.xl},
26
+ })
@@ -0,0 +1,82 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {ScrollArea} from './ScrollArea'
5
+
6
+ const meta: Meta = {
7
+ title: 'Components/ScrollArea',
8
+ }
9
+
10
+ export default meta
11
+ type Story = StoryObj
12
+
13
+ const demoStyles = css.create({
14
+ container: {
15
+ width: 300,
16
+ borderWidth: 1,
17
+ borderStyle: 'solid',
18
+ borderColor: '#333333',
19
+ borderRadius: 8,
20
+ },
21
+ item: {
22
+ paddingTop: 12,
23
+ paddingBottom: 12,
24
+ paddingLeft: 16,
25
+ paddingRight: 16,
26
+ borderBottomWidth: 1,
27
+ borderBottomStyle: 'solid',
28
+ borderBottomColor: '#1a1a1a',
29
+ fontSize: 14,
30
+ color: '#e5e5e5',
31
+ },
32
+ })
33
+
34
+ const items = Array.from({length: 30}, (_, i) => `Item ${i + 1}`)
35
+
36
+ export const Default: Story = {
37
+ render: () => (
38
+ <html.div style={demoStyles.container}>
39
+ <ScrollArea.Root>
40
+ <ScrollArea.Viewport maxHeight={300}>
41
+ <ScrollArea.Content>
42
+ {items.map((item) => (
43
+ <html.div key={item} style={demoStyles.item}>
44
+ {item}
45
+ </html.div>
46
+ ))}
47
+ </ScrollArea.Content>
48
+ </ScrollArea.Viewport>
49
+ <ScrollArea.Scrollbar orientation="vertical">
50
+ <ScrollArea.Thumb />
51
+ </ScrollArea.Scrollbar>
52
+ </ScrollArea.Root>
53
+ </html.div>
54
+ ),
55
+ play: async ({canvas}) => {
56
+ // Content renders
57
+ await expect(canvas.getByText('Item 1')).toBeInTheDocument()
58
+ await expect(canvas.getByText('Item 10')).toBeInTheDocument()
59
+ },
60
+ }
61
+
62
+ export const ShortContent: Story = {
63
+ render: () => (
64
+ <html.div style={demoStyles.container}>
65
+ <ScrollArea.Root>
66
+ <ScrollArea.Viewport maxHeight={300}>
67
+ <ScrollArea.Content>
68
+ <html.div style={demoStyles.item}>Only one item</html.div>
69
+ <html.div style={demoStyles.item}>Two items</html.div>
70
+ </ScrollArea.Content>
71
+ </ScrollArea.Viewport>
72
+ <ScrollArea.Scrollbar orientation="vertical">
73
+ <ScrollArea.Thumb />
74
+ </ScrollArea.Scrollbar>
75
+ </ScrollArea.Root>
76
+ </html.div>
77
+ ),
78
+ play: async ({canvas}) => {
79
+ await expect(canvas.getByText('Only one item')).toBeInTheDocument()
80
+ await expect(canvas.getByText('Two items')).toBeInTheDocument()
81
+ },
82
+ }