@duro-app/ui 0.23.0 → 0.25.0

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 (100) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.d.ts +14 -0
  2. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  3. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts +3 -0
  4. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -0
  5. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +11 -0
  6. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -0
  7. package/dist/components/ButtonGroup/styles.css.d.ts +45 -0
  8. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -0
  9. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  10. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  11. package/dist/components/Combobox/Combobox.d.ts +44 -0
  12. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  14. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  15. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  16. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  17. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  18. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  19. package/dist/components/Combobox/styles.css.d.ts +112 -0
  20. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  21. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  22. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  23. package/dist/components/Field/Field.d.ts +4 -0
  24. package/dist/components/Field/Field.d.ts.map +1 -1
  25. package/dist/components/Field/Field.stories.d.ts +3 -0
  26. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  27. package/dist/components/Field/FieldContext.d.ts +5 -0
  28. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  29. package/dist/components/Field/styles.css.d.ts +26 -0
  30. package/dist/components/Field/styles.css.d.ts.map +1 -1
  31. package/dist/components/Form/Form.d.ts +7 -2
  32. package/dist/components/Form/Form.d.ts.map +1 -1
  33. package/dist/components/Form/Form.stories.d.ts +6 -0
  34. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  35. package/dist/components/Form/FormContext.d.ts +9 -2
  36. package/dist/components/Form/FormContext.d.ts.map +1 -1
  37. package/dist/components/Input/Input.d.ts.map +1 -1
  38. package/dist/components/List/List.d.ts.map +1 -1
  39. package/dist/components/List/List.meta.d.ts.map +1 -1
  40. package/dist/components/List/List.stories.d.ts.map +1 -1
  41. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  42. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  43. package/dist/components/Table/ColumnFilter.d.ts +8 -0
  44. package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
  45. package/dist/components/Table/Pagination.d.ts +7 -0
  46. package/dist/components/Table/Pagination.d.ts.map +1 -0
  47. package/dist/components/Table/SortIndicator.d.ts +16 -0
  48. package/dist/components/Table/SortIndicator.d.ts.map +1 -0
  49. package/dist/components/Table/Table.d.ts +7 -1
  50. package/dist/components/Table/Table.d.ts.map +1 -1
  51. package/dist/components/Table/Table.stories.d.ts +4 -0
  52. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  53. package/dist/components/Table/styles.css.d.ts +11 -3
  54. package/dist/components/Table/styles.css.d.ts.map +1 -1
  55. package/dist/components/Table/useDataTable.d.ts +32 -0
  56. package/dist/components/Table/useDataTable.d.ts.map +1 -0
  57. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  58. package/dist/index.css +1 -1
  59. package/dist/index.d.ts +3 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2863 -2026
  62. package/dist/index.js.map +1 -1
  63. package/package.json +3 -2
  64. package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
  65. package/src/components/ButtonGroup/ButtonGroup.meta.ts +32 -0
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
  68. package/src/components/ButtonGroup/styles.css.ts +28 -0
  69. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  70. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  71. package/src/components/Combobox/Combobox.meta.ts +35 -0
  72. package/src/components/Combobox/Combobox.stories.tsx +119 -0
  73. package/src/components/Combobox/Combobox.tsx +247 -0
  74. package/src/components/Combobox/ComboboxContext.ts +26 -0
  75. package/src/components/Combobox/styles.css.ts +119 -0
  76. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  77. package/src/components/Field/Field.stories.tsx +60 -0
  78. package/src/components/Field/Field.tsx +97 -9
  79. package/src/components/Field/FieldContext.ts +5 -0
  80. package/src/components/Field/styles.css.ts +26 -0
  81. package/src/components/Form/Form.stories.tsx +275 -0
  82. package/src/components/Form/Form.tsx +73 -7
  83. package/src/components/Form/FormContext.ts +10 -1
  84. package/src/components/Input/Input.tsx +2 -1
  85. package/src/components/List/List.meta.ts +8 -2
  86. package/src/components/List/List.stories.tsx +35 -15
  87. package/src/components/List/List.tsx +1 -5
  88. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  89. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  90. package/src/components/Table/ColumnFilter.tsx +46 -0
  91. package/src/components/Table/Pagination.tsx +82 -0
  92. package/src/components/Table/SortIndicator.tsx +53 -0
  93. package/src/components/Table/Table.stories.tsx +397 -0
  94. package/src/components/Table/Table.tsx +58 -24
  95. package/src/components/Table/styles.css.ts +16 -6
  96. package/src/components/Table/useDataTable.ts +99 -0
  97. package/src/components/Textarea/Textarea.tsx +2 -1
  98. package/src/docs/helpers.tsx +2 -2
  99. package/src/index.ts +5 -0
  100. package/src/strict.css +5 -0
@@ -0,0 +1,247 @@
1
+ import {type ReactNode, useRef, useId, useEffect, useState} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {ComboboxContext, useCombobox} from './ComboboxContext'
5
+ import {useComboboxRoot} from './useComboboxRoot'
6
+
7
+ // --- Root ---
8
+ interface RootProps {
9
+ name?: string
10
+ defaultValue?: string
11
+ value?: string
12
+ onValueChange?: (value: string | null) => void
13
+ inputValue?: string
14
+ onInputChange?: (value: string) => void
15
+ initialLabels?: Record<string, string>
16
+ children: ReactNode
17
+ }
18
+
19
+ function Root({
20
+ name,
21
+ defaultValue,
22
+ value,
23
+ onValueChange,
24
+ inputValue,
25
+ onInputChange,
26
+ initialLabels,
27
+ children,
28
+ }: RootProps) {
29
+ const {ctx, rootRef} = useComboboxRoot({
30
+ defaultValue,
31
+ value,
32
+ onValueChange,
33
+ inputValue,
34
+ onInputChange,
35
+ initialLabels,
36
+ })
37
+
38
+ return (
39
+ <ComboboxContext.Provider value={ctx}>
40
+ <html.div ref={rootRef} style={styles.root}>
41
+ {name && <html.input type="hidden" name={name} value={ctx.value ?? ''} />}
42
+ {children}
43
+ </html.div>
44
+ </ComboboxContext.Provider>
45
+ )
46
+ }
47
+
48
+ // --- Input ---
49
+ function Input({placeholder, children}: {placeholder?: string; children?: ReactNode}) {
50
+ const {
51
+ open,
52
+ openPopup,
53
+ close,
54
+ inputValue,
55
+ setInputValue,
56
+ listboxId,
57
+ highlightedId,
58
+ inputRef,
59
+ } = useCombobox()
60
+ const localRef = useRef<HTMLInputElement>(null)
61
+ const [focused, setFocused] = useState(false)
62
+
63
+ useEffect(() => {
64
+ inputRef.current = localRef.current
65
+ })
66
+
67
+ return (
68
+ <html.div style={[styles.inputWrapper, focused && styles.inputWrapperFocused]}>
69
+ <html.input
70
+ ref={localRef}
71
+ type="text"
72
+ role={'combobox' as 'listbox'}
73
+ value={inputValue}
74
+ placeholder={placeholder}
75
+ aria-expanded={open}
76
+ aria-haspopup="listbox"
77
+ aria-controls={open ? listboxId : undefined}
78
+ aria-activedescendant={highlightedId ?? undefined}
79
+ aria-autocomplete={'list' as 'none'}
80
+ autoComplete="off"
81
+ style={styles.input}
82
+ onFocus={() => {
83
+ setFocused(true)
84
+ openPopup()
85
+ }}
86
+ onBlur={() => {
87
+ setFocused(false)
88
+ // Delay close to allow item click to register
89
+ setTimeout(close, 150)
90
+ }}
91
+ onChange={(e: any) => {
92
+ setInputValue(e.target.value)
93
+ if (!open) openPopup()
94
+ }}
95
+ />
96
+ {children}
97
+ </html.div>
98
+ )
99
+ }
100
+
101
+ // --- Trigger ---
102
+ function Trigger({children}: {children?: ReactNode}) {
103
+ const {open, openPopup, close, inputRef} = useCombobox()
104
+
105
+ return (
106
+ <html.button
107
+ type="button"
108
+ tabIndex={-1}
109
+ aria-label={open ? 'Close' : 'Open'}
110
+ onClick={() => {
111
+ if (open) {
112
+ close()
113
+ } else {
114
+ openPopup()
115
+ }
116
+ inputRef.current?.focus()
117
+ }}
118
+ style={styles.trigger}
119
+ >
120
+ {children ?? (
121
+ <svg
122
+ width="12"
123
+ height="12"
124
+ viewBox="0 0 24 24"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ strokeWidth="2"
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ aria-hidden="true"
131
+ >
132
+ <path d="M6 9l6 6 6-6" />
133
+ </svg>
134
+ )}
135
+ </html.button>
136
+ )
137
+ }
138
+
139
+ // --- Popup ---
140
+ function Popup({children}: {children: ReactNode}) {
141
+ const {open, listboxId} = useCombobox()
142
+
143
+ if (!open) return null
144
+
145
+ return (
146
+ <html.div
147
+ id={listboxId}
148
+ role="listbox"
149
+ style={styles.popup}
150
+ >
151
+ {children}
152
+ </html.div>
153
+ )
154
+ }
155
+
156
+ // --- Item ---
157
+ interface ItemProps {
158
+ value: string
159
+ children: ReactNode
160
+ }
161
+
162
+ function Item({value: itemValue, children}: ItemProps) {
163
+ const {
164
+ value: selectedValue,
165
+ setValue,
166
+ inputValue,
167
+ labels,
168
+ registerLabel,
169
+ highlightedId,
170
+ setHighlightedId,
171
+ registerItem,
172
+ } = useCombobox()
173
+ const id = useId()
174
+ const ref = useRef<HTMLDivElement>(null)
175
+ const isSelected = selectedValue === itemValue
176
+ const isHighlighted = highlightedId === id
177
+
178
+ // Filter: check if this item matches the input
179
+ const label = labels[itemValue] ?? itemValue
180
+ const query = inputValue.toLowerCase()
181
+ const isVisible = !query || label.toLowerCase().includes(query)
182
+
183
+ useEffect(() => {
184
+ const el = ref.current
185
+ if (!el) return
186
+ const text = el.textContent
187
+ if (text) registerLabel(itemValue, text)
188
+ }, [itemValue, registerLabel])
189
+
190
+ useEffect(() => {
191
+ const el = ref.current
192
+ if (!el) return
193
+ return registerItem(id, itemValue, el)
194
+ }, [id, itemValue, registerItem])
195
+
196
+ if (!isVisible) return null
197
+
198
+ return (
199
+ <html.div
200
+ ref={ref}
201
+ id={id}
202
+ role="option"
203
+ aria-selected={isSelected}
204
+ onClick={() => setValue(itemValue)}
205
+ onPointerEnter={() => setHighlightedId(id)}
206
+ style={[
207
+ styles.item,
208
+ isSelected && styles.itemSelected,
209
+ isHighlighted && styles.itemHighlighted,
210
+ ]}
211
+ >
212
+ {children}
213
+ </html.div>
214
+ )
215
+ }
216
+
217
+ // --- ItemText ---
218
+ function ItemText({children}: {children: ReactNode}) {
219
+ return <html.span>{children}</html.span>
220
+ }
221
+
222
+ // --- Empty ---
223
+ function Empty({children}: {children: ReactNode}) {
224
+ const {open, inputValue, labels} = useCombobox()
225
+
226
+ if (!open) return null
227
+
228
+ // Check if any items are visible
229
+ const query = inputValue.toLowerCase()
230
+ const hasVisibleItems = query
231
+ ? Object.values(labels).some((label) => label.toLowerCase().includes(query))
232
+ : true
233
+
234
+ if (hasVisibleItems) return null
235
+
236
+ return <html.div style={styles.empty}>{children}</html.div>
237
+ }
238
+
239
+ export const Combobox = {
240
+ Root,
241
+ Input,
242
+ Trigger,
243
+ Popup,
244
+ Item,
245
+ ItemText,
246
+ Empty,
247
+ }
@@ -0,0 +1,26 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface ComboboxContextValue {
4
+ open: boolean
5
+ openPopup: () => void
6
+ close: () => void
7
+ value: string | null
8
+ setValue: (value: string) => void
9
+ inputValue: string
10
+ setInputValue: (value: string) => void
11
+ labels: Record<string, string>
12
+ registerLabel: (value: string, label: string) => void
13
+ listboxId: string
14
+ highlightedId: string | null
15
+ setHighlightedId: (id: string | null) => void
16
+ registerItem: (id: string, value: string, element: HTMLElement) => () => void
17
+ inputRef: React.RefObject<HTMLInputElement | null>
18
+ }
19
+
20
+ export const ComboboxContext = createContext<ComboboxContextValue | null>(null)
21
+
22
+ export function useCombobox() {
23
+ const ctx = useContext(ComboboxContext)
24
+ if (!ctx) throw new Error('Combobox compound components must be used within Combobox.Root')
25
+ return ctx
26
+ }
@@ -0,0 +1,119 @@
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
+ inputWrapper: {
13
+ display: 'inline-flex',
14
+ alignItems: 'center',
15
+ gap: spacing.sm,
16
+ paddingLeft: spacing.md,
17
+ paddingRight: spacing.xs,
18
+ backgroundColor: colors.bgCard,
19
+ borderWidth: 1,
20
+ borderStyle: 'solid',
21
+ borderColor: {
22
+ default: colors.border,
23
+ ':hover': colors.accent,
24
+ },
25
+ borderRadius: radii.sm,
26
+ transitionProperty: 'border-color',
27
+ transitionDuration: '150ms',
28
+ },
29
+ inputWrapperFocused: {
30
+ borderColor: colors.accent,
31
+ },
32
+ input: {
33
+ flex: 1,
34
+ paddingTop: spacing.sm,
35
+ paddingBottom: spacing.sm,
36
+ fontFamily: typography.fontFamily,
37
+ fontSize: typography.fontSizeSm,
38
+ color: colors.text,
39
+ backgroundColor: 'transparent',
40
+ borderWidth: 0,
41
+ outline: 'none',
42
+ minWidth: 0,
43
+ },
44
+ trigger: {
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ paddingTop: spacing.xs,
49
+ paddingBottom: spacing.xs,
50
+ paddingLeft: spacing.xs,
51
+ paddingRight: spacing.xs,
52
+ backgroundColor: 'transparent',
53
+ borderWidth: 0,
54
+ color: colors.textMuted,
55
+ cursor: 'pointer',
56
+ },
57
+ backdrop: {
58
+ position: 'fixed',
59
+ top: 0,
60
+ left: 0,
61
+ right: 0,
62
+ bottom: 0,
63
+ zIndex: 49,
64
+ },
65
+ popup: {
66
+ position: 'absolute',
67
+ top: '100%',
68
+ left: 0,
69
+ right: 0,
70
+ marginTop: spacing.xs,
71
+ backgroundColor: colors.bgCard,
72
+ borderWidth: 1,
73
+ borderStyle: 'solid',
74
+ borderColor: colors.border,
75
+ borderRadius: radii.sm,
76
+ boxShadow: shadows.md,
77
+ paddingTop: spacing.xs,
78
+ paddingBottom: spacing.xs,
79
+ maxHeight: 200,
80
+ overflowY: 'auto',
81
+ zIndex: 50,
82
+ },
83
+ item: {
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ paddingTop: spacing.sm,
87
+ paddingBottom: spacing.sm,
88
+ paddingLeft: spacing.md,
89
+ paddingRight: spacing.md,
90
+ fontSize: typography.fontSizeSm,
91
+ fontFamily: typography.fontFamily,
92
+ color: colors.text,
93
+ cursor: 'pointer',
94
+ backgroundColor: 'transparent',
95
+ transitionProperty: 'background-color',
96
+ transitionDuration: '150ms',
97
+ },
98
+ itemSelected: {
99
+ color: colors.accent,
100
+ fontWeight: typography.fontWeightMedium,
101
+ },
102
+ itemHighlighted: {
103
+ backgroundColor: colors.bgCardHover,
104
+ },
105
+ itemHidden: {
106
+ display: 'none',
107
+ },
108
+ empty: {
109
+ paddingTop: spacing.sm,
110
+ paddingBottom: spacing.sm,
111
+ paddingLeft: spacing.md,
112
+ paddingRight: spacing.md,
113
+ fontSize: typography.fontSizeSm,
114
+ color: colors.textMuted,
115
+ },
116
+ hidden: {
117
+ display: 'none',
118
+ },
119
+ })
@@ -0,0 +1,235 @@
1
+ import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
2
+ import {useControllableValue} from '../../hooks/useControllableValue'
3
+ import type {ComboboxContextValue} from './ComboboxContext'
4
+
5
+ interface UseComboboxRootOptions {
6
+ defaultValue?: string
7
+ value?: string
8
+ onValueChange?: (value: string | null) => void
9
+ inputValue?: string
10
+ onInputChange?: (value: string) => void
11
+ initialLabels?: Record<string, string>
12
+ }
13
+
14
+ export function useComboboxRoot({
15
+ defaultValue,
16
+ value: controlledValue,
17
+ onValueChange,
18
+ inputValue: controlledInputValue,
19
+ onInputChange,
20
+ initialLabels,
21
+ }: UseComboboxRootOptions) {
22
+ const [value, setValue] = useControllableValue<string | null>(
23
+ controlledValue,
24
+ defaultValue ?? null,
25
+ onValueChange,
26
+ )
27
+ const [internalInputValue, setInternalInputValue] = useState('')
28
+ const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
29
+ const setInputValue = useCallback(
30
+ (v: string) => {
31
+ if (controlledInputValue === undefined) setInternalInputValue(v)
32
+ onInputChange?.(v)
33
+ },
34
+ [controlledInputValue, onInputChange],
35
+ )
36
+
37
+ const [open, setOpen] = useState(false)
38
+ const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
39
+ const [highlightedId, setHighlightedId] = useState<string | null>(null)
40
+ const listboxId = useId()
41
+ const rootRef = useRef<HTMLDivElement>(null)
42
+ const inputRef = useRef<HTMLInputElement | null>(null)
43
+ const itemsRef = useRef(new Map<string, {value: string; element: HTMLElement}>())
44
+ const orderRef = useRef<string[]>([])
45
+
46
+ const close = useCallback(() => {
47
+ setOpen(false)
48
+ setHighlightedId(null)
49
+ }, [])
50
+
51
+ const openPopup = useCallback(() => {
52
+ setOpen(true)
53
+ // Highlight the selected item or first visible item
54
+ const items = itemsRef.current
55
+ const order = orderRef.current
56
+ let found: string | null = null
57
+ for (const id of order) {
58
+ const item = items.get(id)
59
+ if (item && item.value === value) {
60
+ found = id
61
+ break
62
+ }
63
+ }
64
+ setHighlightedId(found ?? (order.length > 0 ? order[0] : null))
65
+ }, [value])
66
+
67
+ const selectValue = useCallback(
68
+ (itemValue: string) => {
69
+ setValue(itemValue)
70
+ // Set input to show the selected label
71
+ const label = labels[itemValue] ?? itemValue
72
+ setInputValue(label)
73
+ close()
74
+ inputRef.current?.focus()
75
+ },
76
+ [setValue, labels, setInputValue, close],
77
+ )
78
+
79
+ const registerLabel = useCallback((v: string, label: string) => {
80
+ setLabels((prev) => {
81
+ if (prev[v] === label) return prev
82
+ return {...prev, [v]: label}
83
+ })
84
+ }, [])
85
+
86
+ const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
87
+ itemsRef.current.set(id, {value: itemValue, element})
88
+ const map = itemsRef.current
89
+ const ids = [...map.keys()]
90
+ ids.sort((a, b) => {
91
+ const elA = map.get(a)?.element
92
+ const elB = map.get(b)?.element
93
+ if (!elA || !elB) return 0
94
+ return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
95
+ })
96
+ orderRef.current = ids
97
+ return () => {
98
+ itemsRef.current.delete(id)
99
+ orderRef.current = orderRef.current.filter((i) => i !== id)
100
+ }
101
+ }, [])
102
+
103
+ // Get visible items (those matching the filter)
104
+ const getVisibleOrder = useCallback(() => {
105
+ const query = inputValue.toLowerCase()
106
+ if (!query) return orderRef.current
107
+ return orderRef.current.filter((id) => {
108
+ const item = itemsRef.current.get(id)
109
+ if (!item) return false
110
+ const label = labels[item.value] ?? item.value
111
+ return label.toLowerCase().includes(query)
112
+ })
113
+ }, [inputValue, labels])
114
+
115
+ // Keyboard navigation
116
+ useEffect(() => {
117
+ const root = rootRef.current
118
+ if (!root || !open) return
119
+
120
+ function handleKeyDown(e: KeyboardEvent) {
121
+ const visibleOrder = getVisibleOrder()
122
+ if (visibleOrder.length === 0 && e.key !== 'Escape' && e.key !== 'Tab') return
123
+
124
+ switch (e.key) {
125
+ case 'ArrowDown': {
126
+ e.preventDefault()
127
+ if (!open) {
128
+ setOpen(true)
129
+ return
130
+ }
131
+ setHighlightedId((prev) => {
132
+ const idx = prev ? visibleOrder.indexOf(prev) : -1
133
+ return visibleOrder[(idx + 1) % visibleOrder.length] ?? null
134
+ })
135
+ break
136
+ }
137
+ case 'ArrowUp': {
138
+ e.preventDefault()
139
+ setHighlightedId((prev) => {
140
+ const idx = prev ? visibleOrder.indexOf(prev) : 0
141
+ return visibleOrder[(idx - 1 + visibleOrder.length) % visibleOrder.length] ?? null
142
+ })
143
+ break
144
+ }
145
+ case 'Home': {
146
+ e.preventDefault()
147
+ setHighlightedId(visibleOrder[0] ?? null)
148
+ break
149
+ }
150
+ case 'End': {
151
+ e.preventDefault()
152
+ setHighlightedId(visibleOrder[visibleOrder.length - 1] ?? null)
153
+ break
154
+ }
155
+ case 'Enter': {
156
+ e.preventDefault()
157
+ setHighlightedId((prev) => {
158
+ if (prev) {
159
+ const item = itemsRef.current.get(prev)
160
+ if (item) selectValue(item.value)
161
+ } else if (!inputValue.trim()) {
162
+ // Empty input + Enter = clear selection (reset to "All")
163
+ selectValue('')
164
+ }
165
+ return prev
166
+ })
167
+ break
168
+ }
169
+ case 'Escape': {
170
+ e.preventDefault()
171
+ close()
172
+ inputRef.current?.focus()
173
+ break
174
+ }
175
+ case 'Tab': {
176
+ close()
177
+ break
178
+ }
179
+ }
180
+ }
181
+
182
+ root.addEventListener('keydown', handleKeyDown)
183
+ return () => root.removeEventListener('keydown', handleKeyDown)
184
+ }, [open, close, selectValue, getVisibleOrder])
185
+
186
+ // Scroll highlighted item into view
187
+ useEffect(() => {
188
+ if (!highlightedId) return
189
+ const item = itemsRef.current.get(highlightedId)
190
+ item?.element.scrollIntoView({block: 'nearest'})
191
+ }, [highlightedId])
192
+
193
+ // Sync input value with selected label when value changes externally
194
+ useEffect(() => {
195
+ if (value && labels[value] && !open) {
196
+ setInputValue(labels[value])
197
+ }
198
+ }, [value, labels, open, setInputValue])
199
+
200
+ const ctx: ComboboxContextValue = useMemo(
201
+ () => ({
202
+ open,
203
+ openPopup,
204
+ close,
205
+ value,
206
+ setValue: selectValue,
207
+ inputValue,
208
+ setInputValue,
209
+ labels,
210
+ registerLabel,
211
+ listboxId,
212
+ highlightedId,
213
+ setHighlightedId,
214
+ registerItem,
215
+ inputRef,
216
+ }),
217
+ [
218
+ open,
219
+ openPopup,
220
+ close,
221
+ value,
222
+ selectValue,
223
+ inputValue,
224
+ setInputValue,
225
+ labels,
226
+ registerLabel,
227
+ listboxId,
228
+ highlightedId,
229
+ setHighlightedId,
230
+ registerItem,
231
+ ],
232
+ )
233
+
234
+ return {ctx, rootRef}
235
+ }
@@ -63,6 +63,7 @@ export const WithError: Story = {
63
63
 
64
64
  const stackStyles = css.create({
65
65
  stack: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 400},
66
+ stackWide: {display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 600},
66
67
  })
67
68
 
68
69
  export const FormExample: Story = {
@@ -96,3 +97,62 @@ export const FormExample: Story = {
96
97
  await expect(alert).toHaveTextContent('Passwords do not match.')
97
98
  },
98
99
  }
100
+
101
+ export const WithNecessityIcon: Story = {
102
+ name: 'With necessity indicator (standalone)',
103
+ render: () => (
104
+ <html.div style={stackStyles.stack}>
105
+ <Field.Root required>
106
+ <Field.Label>Email</Field.Label>
107
+ <Input placeholder="you@example.com" />
108
+ <Field.Description>Required field with no Form context — indicator needs Form.</Field.Description>
109
+ </Field.Root>
110
+ </html.div>
111
+ ),
112
+ play: async ({canvas}) => {
113
+ // Without Form context, no indicator shows (necessityIndicator comes from Form)
114
+ const label = canvas.getByText('Email')
115
+ await expect(label).toBeInTheDocument()
116
+ },
117
+ }
118
+
119
+ export const LabelSide: Story = {
120
+ name: 'Label position — side (standalone)',
121
+ render: () => (
122
+ <html.div style={stackStyles.stackWide}>
123
+ <Field.Root labelPosition="side">
124
+ <Field.Label>Username</Field.Label>
125
+ <Input placeholder="Enter username" />
126
+ <Field.Description>3-32 characters, letters and numbers only.</Field.Description>
127
+ </Field.Root>
128
+
129
+ <Field.Root labelPosition="side" invalid>
130
+ <Field.Label>Email</Field.Label>
131
+ <Input variant="error" placeholder="Enter email" />
132
+ <Field.Error>Please enter a valid email address.</Field.Error>
133
+ </Field.Root>
134
+ </html.div>
135
+ ),
136
+ play: async ({canvas}) => {
137
+ await expect(canvas.getByPlaceholderText('Enter username')).toBeInTheDocument()
138
+ await expect(canvas.getByPlaceholderText('Enter email')).toBeInTheDocument()
139
+ await expect(canvas.getByRole('alert')).toHaveTextContent('Please enter a valid email address.')
140
+ },
141
+ }
142
+
143
+ export const Disabled: Story = {
144
+ name: 'Disabled field (standalone)',
145
+ render: () => (
146
+ <html.div style={stackStyles.stack}>
147
+ <Field.Root disabled>
148
+ <Field.Label>Username</Field.Label>
149
+ <Input placeholder="Enter username" />
150
+ <Field.Description>This field is disabled.</Field.Description>
151
+ </Field.Root>
152
+ </html.div>
153
+ ),
154
+ play: async ({canvas}) => {
155
+ const input = canvas.getByPlaceholderText('Enter username')
156
+ await expect(input).toBeDisabled()
157
+ },
158
+ }