@duro-app/ui 0.24.0 → 0.26.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.
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts +44 -0
- package/dist/components/Combobox/Combobox.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
- package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
- package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
- package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
- package/dist/components/Combobox/styles.css.d.ts +112 -0
- package/dist/components/Combobox/styles.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts +4 -0
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts +3 -0
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/Field/FieldContext.d.ts +5 -0
- package/dist/components/Field/FieldContext.d.ts.map +1 -1
- package/dist/components/Field/styles.css.d.ts +26 -0
- package/dist/components/Field/styles.css.d.ts.map +1 -1
- package/dist/components/Form/Form.d.ts +7 -2
- package/dist/components/Form/Form.d.ts.map +1 -1
- package/dist/components/Form/Form.stories.d.ts +6 -0
- package/dist/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/components/Form/FormContext.d.ts +9 -2
- package/dist/components/Form/FormContext.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
- package/dist/components/Table/SortIndicator.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/useDataTable.d.ts.map +1 -1
- package/dist/components/Tag/Tag.d.ts +18 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/Tag.meta.d.ts +3 -0
- package/dist/components/Tag/Tag.meta.d.ts.map +1 -0
- package/dist/components/Tag/Tag.stories.d.ts +11 -0
- package/dist/components/Tag/Tag.stories.d.ts.map +1 -0
- package/dist/components/Tag/styles.css.d.ts +80 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.d.ts +30 -0
- package/dist/components/TagGroup/TagGroup.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.meta.d.ts +3 -0
- package/dist/components/TagGroup/TagGroup.meta.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.stories.d.ts +11 -0
- package/dist/components/TagGroup/TagGroup.stories.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroupContext.d.ts +17 -0
- package/dist/components/TagGroup/TagGroupContext.d.ts.map +1 -0
- package/dist/components/TagGroup/styles.css.d.ts +75 -0
- package/dist/components/TagGroup/styles.css.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3256 -2318
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +10 -2
- package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
- package/src/components/Combobox/Combobox.meta.ts +41 -0
- package/src/components/Combobox/Combobox.stories.tsx +157 -0
- package/src/components/Combobox/Combobox.tsx +235 -0
- package/src/components/Combobox/ComboboxContext.ts +26 -0
- package/src/components/Combobox/styles.css.ts +119 -0
- package/src/components/Combobox/useComboboxRoot.ts +235 -0
- package/src/components/Field/Field.stories.tsx +62 -0
- package/src/components/Field/Field.tsx +94 -9
- package/src/components/Field/FieldContext.ts +5 -0
- package/src/components/Field/styles.css.ts +26 -0
- package/src/components/Form/Form.stories.tsx +275 -0
- package/src/components/Form/Form.tsx +69 -7
- package/src/components/Form/FormContext.ts +10 -1
- package/src/components/Input/Input.tsx +2 -1
- package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
- package/src/components/RadioGroup/RadioGroup.tsx +1 -4
- package/src/components/Table/SortIndicator.tsx +1 -4
- package/src/components/Table/Table.stories.tsx +190 -84
- package/src/components/Table/Table.tsx +10 -5
- package/src/components/Table/useDataTable.ts +34 -48
- package/src/components/Tag/Tag.meta.ts +22 -0
- package/src/components/Tag/Tag.stories.tsx +92 -0
- package/src/components/Tag/Tag.tsx +139 -0
- package/src/components/Tag/styles.css.ts +99 -0
- package/src/components/TagGroup/TagGroup.meta.ts +27 -0
- package/src/components/TagGroup/TagGroup.stories.tsx +193 -0
- package/src/components/TagGroup/TagGroup.tsx +302 -0
- package/src/components/TagGroup/TagGroupContext.ts +22 -0
- package/src/components/TagGroup/styles.css.ts +97 -0
- package/src/components/Textarea/Textarea.tsx +2 -1
- package/src/index.ts +4 -0
- package/src/strict.css +1 -1
|
@@ -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,64 @@ 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>
|
|
109
|
+
Required field with no Form context — indicator needs Form.
|
|
110
|
+
</Field.Description>
|
|
111
|
+
</Field.Root>
|
|
112
|
+
</html.div>
|
|
113
|
+
),
|
|
114
|
+
play: async ({canvas}) => {
|
|
115
|
+
// Without Form context, no indicator shows (necessityIndicator comes from Form)
|
|
116
|
+
const label = canvas.getByText('Email')
|
|
117
|
+
await expect(label).toBeInTheDocument()
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const LabelSide: Story = {
|
|
122
|
+
name: 'Label position — side (standalone)',
|
|
123
|
+
render: () => (
|
|
124
|
+
<html.div style={stackStyles.stackWide}>
|
|
125
|
+
<Field.Root labelPosition="side">
|
|
126
|
+
<Field.Label>Username</Field.Label>
|
|
127
|
+
<Input placeholder="Enter username" />
|
|
128
|
+
<Field.Description>3-32 characters, letters and numbers only.</Field.Description>
|
|
129
|
+
</Field.Root>
|
|
130
|
+
|
|
131
|
+
<Field.Root labelPosition="side" invalid>
|
|
132
|
+
<Field.Label>Email</Field.Label>
|
|
133
|
+
<Input variant="error" placeholder="Enter email" />
|
|
134
|
+
<Field.Error>Please enter a valid email address.</Field.Error>
|
|
135
|
+
</Field.Root>
|
|
136
|
+
</html.div>
|
|
137
|
+
),
|
|
138
|
+
play: async ({canvas}) => {
|
|
139
|
+
await expect(canvas.getByPlaceholderText('Enter username')).toBeInTheDocument()
|
|
140
|
+
await expect(canvas.getByPlaceholderText('Enter email')).toBeInTheDocument()
|
|
141
|
+
await expect(canvas.getByRole('alert')).toHaveTextContent('Please enter a valid email address.')
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const Disabled: Story = {
|
|
146
|
+
name: 'Disabled field (standalone)',
|
|
147
|
+
render: () => (
|
|
148
|
+
<html.div style={stackStyles.stack}>
|
|
149
|
+
<Field.Root disabled>
|
|
150
|
+
<Field.Label>Username</Field.Label>
|
|
151
|
+
<Input placeholder="Enter username" />
|
|
152
|
+
<Field.Description>This field is disabled.</Field.Description>
|
|
153
|
+
</Field.Root>
|
|
154
|
+
</html.div>
|
|
155
|
+
),
|
|
156
|
+
play: async ({canvas}) => {
|
|
157
|
+
const input = canvas.getByPlaceholderText('Enter username')
|
|
158
|
+
await expect(input).toBeDisabled()
|
|
159
|
+
},
|
|
160
|
+
}
|
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
import {type ReactNode, useId, useMemo} from 'react'
|
|
1
|
+
import {type ReactNode, Children, isValidElement, useId, useMemo} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useFormContext as useRHFFormContext, useController} from 'react-hook-form'
|
|
4
4
|
import {FieldContext, useFieldContext} from './FieldContext'
|
|
5
5
|
import {useFormContext} from '../Form/FormContext'
|
|
6
|
+
import type {LabelPosition, NecessityIndicator} from '../Form/FormContext'
|
|
6
7
|
import {styles} from './styles.css'
|
|
7
8
|
|
|
8
9
|
// --- Root ---
|
|
9
10
|
interface RootProps {
|
|
10
11
|
name?: string
|
|
11
12
|
invalid?: boolean
|
|
13
|
+
required?: boolean
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
labelPosition?: LabelPosition
|
|
12
16
|
children: ReactNode
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
function Root({name, ...props}: RootProps) {
|
|
16
|
-
const
|
|
17
|
-
if (name &&
|
|
18
|
-
return <ControlledRoot name={name} {...props} />
|
|
20
|
+
const formCtx = useFormContext()
|
|
21
|
+
if (name && formCtx) {
|
|
22
|
+
return <ControlledRoot name={name} formCtx={formCtx} {...props} />
|
|
19
23
|
}
|
|
20
24
|
return <StaticRoot {...props} />
|
|
21
25
|
}
|
|
@@ -23,11 +27,19 @@ function Root({name, ...props}: RootProps) {
|
|
|
23
27
|
// Always calls useController — no conditional hooks
|
|
24
28
|
function ControlledRoot({
|
|
25
29
|
name,
|
|
30
|
+
formCtx,
|
|
26
31
|
invalid: invalidProp = false,
|
|
32
|
+
required,
|
|
33
|
+
disabled,
|
|
34
|
+
labelPosition,
|
|
27
35
|
children,
|
|
28
36
|
}: {
|
|
29
37
|
name: string
|
|
38
|
+
formCtx: NonNullable<ReturnType<typeof useFormContext>>
|
|
30
39
|
invalid?: boolean
|
|
40
|
+
required?: boolean
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
labelPosition?: LabelPosition
|
|
31
43
|
children: ReactNode
|
|
32
44
|
}) {
|
|
33
45
|
const id = useId()
|
|
@@ -35,12 +47,20 @@ function ControlledRoot({
|
|
|
35
47
|
const {field, fieldState} = useController({control, name})
|
|
36
48
|
const invalid = invalidProp || !!fieldState.error
|
|
37
49
|
|
|
50
|
+
const effectiveDisabled = disabled ?? formCtx.disabled
|
|
51
|
+
const effectiveLabelPosition = labelPosition ?? formCtx.labelPosition
|
|
52
|
+
const effectiveNecessityIndicator = formCtx.necessityIndicator
|
|
53
|
+
|
|
38
54
|
const ctx = useMemo(
|
|
39
55
|
() => ({
|
|
40
56
|
controlId: `${id}-control`,
|
|
41
57
|
descriptionId: `${id}-description`,
|
|
42
58
|
errorId: `${id}-error`,
|
|
43
59
|
invalid,
|
|
60
|
+
required,
|
|
61
|
+
disabled: effectiveDisabled,
|
|
62
|
+
labelPosition: effectiveLabelPosition,
|
|
63
|
+
necessityIndicator: effectiveNecessityIndicator,
|
|
44
64
|
field: {
|
|
45
65
|
value: field.value,
|
|
46
66
|
onChange: field.onChange,
|
|
@@ -53,6 +73,10 @@ function ControlledRoot({
|
|
|
53
73
|
[
|
|
54
74
|
id,
|
|
55
75
|
invalid,
|
|
76
|
+
required,
|
|
77
|
+
effectiveDisabled,
|
|
78
|
+
effectiveLabelPosition,
|
|
79
|
+
effectiveNecessityIndicator,
|
|
56
80
|
field.value,
|
|
57
81
|
field.onChange,
|
|
58
82
|
field.onBlur,
|
|
@@ -64,13 +88,25 @@ function ControlledRoot({
|
|
|
64
88
|
|
|
65
89
|
return (
|
|
66
90
|
<FieldContext.Provider value={ctx}>
|
|
67
|
-
<
|
|
91
|
+
<FieldLayout labelPosition={effectiveLabelPosition}>{children}</FieldLayout>
|
|
68
92
|
</FieldContext.Provider>
|
|
69
93
|
)
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
// Current behavior, no RHF dependency
|
|
73
|
-
function StaticRoot({
|
|
97
|
+
function StaticRoot({
|
|
98
|
+
invalid = false,
|
|
99
|
+
required,
|
|
100
|
+
disabled,
|
|
101
|
+
labelPosition = 'top',
|
|
102
|
+
children,
|
|
103
|
+
}: {
|
|
104
|
+
invalid?: boolean
|
|
105
|
+
required?: boolean
|
|
106
|
+
disabled?: boolean
|
|
107
|
+
labelPosition?: LabelPosition
|
|
108
|
+
children: ReactNode
|
|
109
|
+
}) {
|
|
74
110
|
const id = useId()
|
|
75
111
|
const ctx = useMemo(
|
|
76
112
|
() => ({
|
|
@@ -78,17 +114,53 @@ function StaticRoot({invalid = false, children}: {invalid?: boolean; children: R
|
|
|
78
114
|
descriptionId: `${id}-description`,
|
|
79
115
|
errorId: `${id}-error`,
|
|
80
116
|
invalid,
|
|
117
|
+
required,
|
|
118
|
+
disabled,
|
|
119
|
+
labelPosition,
|
|
81
120
|
}),
|
|
82
|
-
[id, invalid],
|
|
121
|
+
[id, invalid, required, disabled, labelPosition],
|
|
83
122
|
)
|
|
84
123
|
|
|
85
124
|
return (
|
|
86
125
|
<FieldContext.Provider value={ctx}>
|
|
87
|
-
<
|
|
126
|
+
<FieldLayout labelPosition={labelPosition}>{children}</FieldLayout>
|
|
88
127
|
</FieldContext.Provider>
|
|
89
128
|
)
|
|
90
129
|
}
|
|
91
130
|
|
|
131
|
+
// --- FieldLayout ---
|
|
132
|
+
// When labelPosition is 'side', separates Label from other children
|
|
133
|
+
// and wraps the non-label children in a content column.
|
|
134
|
+
function FieldLayout({
|
|
135
|
+
labelPosition,
|
|
136
|
+
children,
|
|
137
|
+
}: {
|
|
138
|
+
labelPosition: LabelPosition
|
|
139
|
+
children: ReactNode
|
|
140
|
+
}) {
|
|
141
|
+
if (labelPosition !== 'side') {
|
|
142
|
+
return <html.div style={styles.root}>{children}</html.div>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let labelNode: ReactNode = null
|
|
146
|
+
const rest: ReactNode[] = []
|
|
147
|
+
|
|
148
|
+
Children.forEach(children, (child) => {
|
|
149
|
+
if (isValidElement(child) && child.type === Label && !labelNode) {
|
|
150
|
+
labelNode = child
|
|
151
|
+
} else {
|
|
152
|
+
rest.push(child)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<html.div style={styles.rootSide}>
|
|
158
|
+
{labelNode}
|
|
159
|
+
<html.div style={styles.fieldContent}>{rest}</html.div>
|
|
160
|
+
</html.div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
92
164
|
// --- Label ---
|
|
93
165
|
interface LabelProps {
|
|
94
166
|
children: ReactNode
|
|
@@ -96,9 +168,22 @@ interface LabelProps {
|
|
|
96
168
|
|
|
97
169
|
function Label({children}: LabelProps) {
|
|
98
170
|
const ctx = useFieldContext()
|
|
171
|
+
const isSide = ctx?.labelPosition === 'side'
|
|
172
|
+
const indicator = ctx?.necessityIndicator
|
|
173
|
+
|
|
99
174
|
return (
|
|
100
|
-
<html.label for={ctx?.controlId} style={styles.label}>
|
|
175
|
+
<html.label for={ctx?.controlId} style={[styles.label, isSide && styles.labelSide]}>
|
|
101
176
|
{children}
|
|
177
|
+
{indicator === 'icon' && ctx?.required && (
|
|
178
|
+
<html.span style={styles.necessityIcon} aria-hidden={true}>
|
|
179
|
+
{' *'}
|
|
180
|
+
</html.span>
|
|
181
|
+
)}
|
|
182
|
+
{indicator === 'label' && (
|
|
183
|
+
<html.span style={styles.necessityLabel}>
|
|
184
|
+
{ctx?.required ? ' (required)' : ' (optional)'}
|
|
185
|
+
</html.span>
|
|
186
|
+
)}
|
|
102
187
|
</html.label>
|
|
103
188
|
)
|
|
104
189
|
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import {createContext, useContext} from 'react'
|
|
2
|
+
import type {LabelPosition, NecessityIndicator} from '../Form/FormContext'
|
|
2
3
|
|
|
3
4
|
interface FieldContextValue {
|
|
4
5
|
controlId: string
|
|
5
6
|
descriptionId: string
|
|
6
7
|
errorId: string
|
|
7
8
|
invalid: boolean
|
|
9
|
+
required?: boolean
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
labelPosition?: LabelPosition
|
|
12
|
+
necessityIndicator?: NecessityIndicator
|
|
8
13
|
field?: {
|
|
9
14
|
value: unknown
|
|
10
15
|
onChange: (...event: unknown[]) => void
|
|
@@ -9,11 +9,22 @@ export const styles = css.create({
|
|
|
9
9
|
flexDirection: 'column',
|
|
10
10
|
gap: spacing.xs,
|
|
11
11
|
},
|
|
12
|
+
rootSide: {
|
|
13
|
+
display: 'flex',
|
|
14
|
+
flexDirection: 'row',
|
|
15
|
+
alignItems: 'flex-start',
|
|
16
|
+
gap: spacing.md,
|
|
17
|
+
},
|
|
12
18
|
label: {
|
|
13
19
|
fontSize: typography.fontSizeSm,
|
|
14
20
|
fontWeight: typography.fontWeightMedium,
|
|
15
21
|
color: colors.text,
|
|
16
22
|
},
|
|
23
|
+
labelSide: {
|
|
24
|
+
minWidth: 120,
|
|
25
|
+
paddingTop: spacing.sm,
|
|
26
|
+
flexShrink: 0,
|
|
27
|
+
},
|
|
17
28
|
description: {
|
|
18
29
|
fontSize: typography.fontSizeXs,
|
|
19
30
|
color: colors.textMuted,
|
|
@@ -22,4 +33,19 @@ export const styles = css.create({
|
|
|
22
33
|
fontSize: typography.fontSizeXs,
|
|
23
34
|
color: colors.error,
|
|
24
35
|
},
|
|
36
|
+
fieldContent: {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
gap: spacing.xs,
|
|
40
|
+
flex: 1,
|
|
41
|
+
minWidth: 0,
|
|
42
|
+
},
|
|
43
|
+
necessityIcon: {
|
|
44
|
+
color: colors.error,
|
|
45
|
+
},
|
|
46
|
+
necessityLabel: {
|
|
47
|
+
color: colors.textMuted,
|
|
48
|
+
fontWeight: typography.fontWeightNormal,
|
|
49
|
+
fontSize: typography.fontSizeXs,
|
|
50
|
+
},
|
|
25
51
|
})
|