@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.
- package/dist/components/ButtonGroup/ButtonGroup.d.ts +14 -0
- package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts +3 -0
- package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +11 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -0
- package/dist/components/ButtonGroup/styles.css.d.ts +45 -0
- package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -0
- 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/List/List.d.ts.map +1 -1
- package/dist/components/List/List.meta.d.ts.map +1 -1
- package/dist/components/List/List.stories.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/ColumnFilter.d.ts +8 -0
- package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
- package/dist/components/Table/Pagination.d.ts +7 -0
- package/dist/components/Table/Pagination.d.ts.map +1 -0
- package/dist/components/Table/SortIndicator.d.ts +16 -0
- package/dist/components/Table/SortIndicator.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +7 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +4 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +11 -3
- package/dist/components/Table/styles.css.d.ts.map +1 -1
- package/dist/components/Table/useDataTable.d.ts +32 -0
- package/dist/components/Table/useDataTable.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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2863 -2026
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +32 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
- package/src/components/ButtonGroup/styles.css.ts +28 -0
- package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
- package/src/components/Combobox/Combobox.meta.ts +35 -0
- package/src/components/Combobox/Combobox.stories.tsx +119 -0
- package/src/components/Combobox/Combobox.tsx +247 -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 +60 -0
- package/src/components/Field/Field.tsx +97 -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 +73 -7
- package/src/components/Form/FormContext.ts +10 -1
- package/src/components/Input/Input.tsx +2 -1
- package/src/components/List/List.meta.ts +8 -2
- package/src/components/List/List.stories.tsx +35 -15
- package/src/components/List/List.tsx +1 -5
- package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
- package/src/components/RadioGroup/RadioGroup.tsx +1 -4
- package/src/components/Table/ColumnFilter.tsx +46 -0
- package/src/components/Table/Pagination.tsx +82 -0
- package/src/components/Table/SortIndicator.tsx +53 -0
- package/src/components/Table/Table.stories.tsx +397 -0
- package/src/components/Table/Table.tsx +58 -24
- package/src/components/Table/styles.css.ts +16 -6
- package/src/components/Table/useDataTable.ts +99 -0
- package/src/components/Textarea/Textarea.tsx +2 -1
- package/src/docs/helpers.tsx +2 -2
- package/src/index.ts +5 -0
- package/src/strict.css +5 -0
|
@@ -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,25 @@ 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
|
|
175
|
+
<html.label
|
|
176
|
+
for={ctx?.controlId}
|
|
177
|
+
style={[styles.label, isSide && styles.labelSide]}
|
|
178
|
+
>
|
|
101
179
|
{children}
|
|
180
|
+
{indicator === 'icon' && ctx?.required && (
|
|
181
|
+
<html.span style={styles.necessityIcon} aria-hidden={true}>
|
|
182
|
+
{' *'}
|
|
183
|
+
</html.span>
|
|
184
|
+
)}
|
|
185
|
+
{indicator === 'label' && (
|
|
186
|
+
<html.span style={styles.necessityLabel}>
|
|
187
|
+
{ctx?.required ? ' (required)' : ' (optional)'}
|
|
188
|
+
</html.span>
|
|
189
|
+
)}
|
|
102
190
|
</html.label>
|
|
103
191
|
)
|
|
104
192
|
}
|
|
@@ -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
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
1
2
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
3
|
import {expect, fn, userEvent, within} from 'storybook/test'
|
|
3
4
|
import {css, html} from 'react-strict-dom'
|
|
@@ -36,8 +37,19 @@ const FeedbackSchema = Schema.Struct({
|
|
|
36
37
|
),
|
|
37
38
|
})
|
|
38
39
|
|
|
40
|
+
const ProfileSchema = Schema.Struct({
|
|
41
|
+
name: Schema.String.pipe(
|
|
42
|
+
Schema.minLength(2, {message: () => 'Name must be at least 2 characters'}),
|
|
43
|
+
),
|
|
44
|
+
email: Schema.String.pipe(
|
|
45
|
+
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {message: () => 'Enter a valid email'}),
|
|
46
|
+
),
|
|
47
|
+
bio: Schema.String,
|
|
48
|
+
})
|
|
49
|
+
|
|
39
50
|
const wrapStyles = css.create({
|
|
40
51
|
wrap: {maxWidth: 400},
|
|
52
|
+
wrapWide: {maxWidth: 600},
|
|
41
53
|
})
|
|
42
54
|
|
|
43
55
|
// --- Stories ---
|
|
@@ -241,3 +253,266 @@ export const StandaloneFieldStillWorks: Story = {
|
|
|
241
253
|
await expect(alert).toHaveTextContent('Manual error — no Form wrapper needed.')
|
|
242
254
|
},
|
|
243
255
|
}
|
|
256
|
+
|
|
257
|
+
// --- New feature stories ---
|
|
258
|
+
|
|
259
|
+
export const NecessityIndicatorIcon: Story = {
|
|
260
|
+
name: 'Necessity indicator — icon',
|
|
261
|
+
render: () => (
|
|
262
|
+
<html.div style={wrapStyles.wrap}>
|
|
263
|
+
<Form
|
|
264
|
+
schema={ProfileSchema}
|
|
265
|
+
defaultValues={{name: '', email: '', bio: ''}}
|
|
266
|
+
onSubmit={fn()}
|
|
267
|
+
necessityIndicator="icon"
|
|
268
|
+
>
|
|
269
|
+
<Fieldset.Root gap="md">
|
|
270
|
+
<Field.Root name="name" required>
|
|
271
|
+
<Field.Label>Full name</Field.Label>
|
|
272
|
+
<Input placeholder="Jane Doe" />
|
|
273
|
+
<Field.Error />
|
|
274
|
+
</Field.Root>
|
|
275
|
+
|
|
276
|
+
<Field.Root name="email" required>
|
|
277
|
+
<Field.Label>Email</Field.Label>
|
|
278
|
+
<Input type="email" placeholder="you@example.com" />
|
|
279
|
+
<Field.Error />
|
|
280
|
+
</Field.Root>
|
|
281
|
+
|
|
282
|
+
<Field.Root name="bio">
|
|
283
|
+
<Field.Label>Bio</Field.Label>
|
|
284
|
+
<Textarea placeholder="Tell us about yourself..." rows={3} />
|
|
285
|
+
</Field.Root>
|
|
286
|
+
|
|
287
|
+
<Button type="submit">Save</Button>
|
|
288
|
+
</Fieldset.Root>
|
|
289
|
+
</Form>
|
|
290
|
+
</html.div>
|
|
291
|
+
),
|
|
292
|
+
play: async ({canvas}) => {
|
|
293
|
+
// Required fields show asterisk
|
|
294
|
+
const nameLabel = canvas.getByText('Full name')
|
|
295
|
+
await expect(nameLabel.closest('label')?.textContent).toContain('*')
|
|
296
|
+
|
|
297
|
+
const emailLabel = canvas.getByText('Email')
|
|
298
|
+
await expect(emailLabel.closest('label')?.textContent).toContain('*')
|
|
299
|
+
|
|
300
|
+
// Optional field has no asterisk
|
|
301
|
+
const bioLabel = canvas.getByText('Bio')
|
|
302
|
+
await expect(bioLabel.closest('label')?.textContent).not.toContain('*')
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const NecessityIndicatorLabel: Story = {
|
|
307
|
+
name: 'Necessity indicator — label',
|
|
308
|
+
render: () => (
|
|
309
|
+
<html.div style={wrapStyles.wrap}>
|
|
310
|
+
<Form
|
|
311
|
+
schema={ProfileSchema}
|
|
312
|
+
defaultValues={{name: '', email: '', bio: ''}}
|
|
313
|
+
onSubmit={fn()}
|
|
314
|
+
necessityIndicator="label"
|
|
315
|
+
>
|
|
316
|
+
<Fieldset.Root gap="md">
|
|
317
|
+
<Field.Root name="name" required>
|
|
318
|
+
<Field.Label>Full name</Field.Label>
|
|
319
|
+
<Input placeholder="Jane Doe" />
|
|
320
|
+
<Field.Error />
|
|
321
|
+
</Field.Root>
|
|
322
|
+
|
|
323
|
+
<Field.Root name="email" required>
|
|
324
|
+
<Field.Label>Email</Field.Label>
|
|
325
|
+
<Input type="email" placeholder="you@example.com" />
|
|
326
|
+
<Field.Error />
|
|
327
|
+
</Field.Root>
|
|
328
|
+
|
|
329
|
+
<Field.Root name="bio">
|
|
330
|
+
<Field.Label>Bio</Field.Label>
|
|
331
|
+
<Textarea placeholder="Tell us about yourself..." rows={3} />
|
|
332
|
+
</Field.Root>
|
|
333
|
+
|
|
334
|
+
<Button type="submit">Save</Button>
|
|
335
|
+
</Fieldset.Root>
|
|
336
|
+
</Form>
|
|
337
|
+
</html.div>
|
|
338
|
+
),
|
|
339
|
+
play: async ({canvas}) => {
|
|
340
|
+
// Required fields show "(required)" — there are two
|
|
341
|
+
const requiredIndicators = canvas.getAllByText('(required)')
|
|
342
|
+
await expect(requiredIndicators).toHaveLength(2)
|
|
343
|
+
|
|
344
|
+
// Optional field shows "(optional)"
|
|
345
|
+
await expect(canvas.getByText('(optional)')).toBeInTheDocument()
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const FocusFirstInvalid: Story = {
|
|
350
|
+
name: 'Focus first invalid on submit',
|
|
351
|
+
render: () => (
|
|
352
|
+
<html.div style={wrapStyles.wrap}>
|
|
353
|
+
<Form schema={LoginSchema} defaultValues={{username: '', password: ''}} onSubmit={fn()}>
|
|
354
|
+
<Fieldset.Root gap="md">
|
|
355
|
+
<Field.Root name="username">
|
|
356
|
+
<Field.Label>Username</Field.Label>
|
|
357
|
+
<Input placeholder="Enter username" />
|
|
358
|
+
<Field.Error />
|
|
359
|
+
</Field.Root>
|
|
360
|
+
|
|
361
|
+
<Field.Root name="password">
|
|
362
|
+
<Field.Label>Password</Field.Label>
|
|
363
|
+
<Input type="password" placeholder="Enter password" />
|
|
364
|
+
<Field.Error />
|
|
365
|
+
</Field.Root>
|
|
366
|
+
|
|
367
|
+
<Button type="submit">Log in</Button>
|
|
368
|
+
</Fieldset.Root>
|
|
369
|
+
</Form>
|
|
370
|
+
</html.div>
|
|
371
|
+
),
|
|
372
|
+
play: async ({canvas}) => {
|
|
373
|
+
// Submit the form with empty fields
|
|
374
|
+
const button = canvas.getByRole('button', {name: 'Log in'})
|
|
375
|
+
await userEvent.click(button)
|
|
376
|
+
|
|
377
|
+
// First invalid field (username) should be focused
|
|
378
|
+
const usernameInput = canvas.getByPlaceholderText('Enter username')
|
|
379
|
+
await expect(usernameInput).toHaveFocus()
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const FormDisabled: Story = {
|
|
384
|
+
name: 'Form disabled',
|
|
385
|
+
render: () => (
|
|
386
|
+
<html.div style={wrapStyles.wrap}>
|
|
387
|
+
<Form
|
|
388
|
+
schema={LoginSchema}
|
|
389
|
+
defaultValues={{username: 'johndoe', password: 'supersecret1'}}
|
|
390
|
+
onSubmit={fn()}
|
|
391
|
+
disabled
|
|
392
|
+
>
|
|
393
|
+
<Fieldset.Root gap="md">
|
|
394
|
+
<Field.Root name="username">
|
|
395
|
+
<Field.Label>Username</Field.Label>
|
|
396
|
+
<Input placeholder="Enter username" />
|
|
397
|
+
</Field.Root>
|
|
398
|
+
|
|
399
|
+
<Field.Root name="password">
|
|
400
|
+
<Field.Label>Password</Field.Label>
|
|
401
|
+
<Input type="password" placeholder="Enter password" />
|
|
402
|
+
</Field.Root>
|
|
403
|
+
|
|
404
|
+
<Button type="submit">Log in</Button>
|
|
405
|
+
</Fieldset.Root>
|
|
406
|
+
</Form>
|
|
407
|
+
</html.div>
|
|
408
|
+
),
|
|
409
|
+
play: async ({canvas}) => {
|
|
410
|
+
const usernameInput = canvas.getByPlaceholderText('Enter username')
|
|
411
|
+
const passwordInput = canvas.getByPlaceholderText('Enter password')
|
|
412
|
+
await expect(usernameInput).toBeDisabled()
|
|
413
|
+
await expect(passwordInput).toBeDisabled()
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export const LabelPositionSide: Story = {
|
|
418
|
+
name: 'Label position — side',
|
|
419
|
+
render: () => (
|
|
420
|
+
<html.div style={wrapStyles.wrapWide}>
|
|
421
|
+
<Form
|
|
422
|
+
schema={ProfileSchema}
|
|
423
|
+
defaultValues={{name: '', email: '', bio: ''}}
|
|
424
|
+
onSubmit={fn()}
|
|
425
|
+
labelPosition="side"
|
|
426
|
+
necessityIndicator="icon"
|
|
427
|
+
>
|
|
428
|
+
{({formState}) => (
|
|
429
|
+
<Fieldset.Root gap="md">
|
|
430
|
+
<Field.Root name="name" required>
|
|
431
|
+
<Field.Label>Full name</Field.Label>
|
|
432
|
+
<Input placeholder="Jane Doe" />
|
|
433
|
+
<Field.Error />
|
|
434
|
+
</Field.Root>
|
|
435
|
+
|
|
436
|
+
<Field.Root name="email" required>
|
|
437
|
+
<Field.Label>Email</Field.Label>
|
|
438
|
+
<Input type="email" placeholder="you@example.com" />
|
|
439
|
+
<Field.Description>We will never share your email.</Field.Description>
|
|
440
|
+
<Field.Error />
|
|
441
|
+
</Field.Root>
|
|
442
|
+
|
|
443
|
+
<Field.Root name="bio">
|
|
444
|
+
<Field.Label>Bio</Field.Label>
|
|
445
|
+
<Textarea placeholder="Tell us about yourself..." rows={3} />
|
|
446
|
+
</Field.Root>
|
|
447
|
+
|
|
448
|
+
<Button type="submit" disabled={!formState.isValid}>
|
|
449
|
+
Save profile
|
|
450
|
+
</Button>
|
|
451
|
+
</Fieldset.Root>
|
|
452
|
+
)}
|
|
453
|
+
</Form>
|
|
454
|
+
</html.div>
|
|
455
|
+
),
|
|
456
|
+
play: async ({canvas}) => {
|
|
457
|
+
await expect(canvas.getByPlaceholderText('Jane Doe')).toBeInTheDocument()
|
|
458
|
+
await expect(canvas.getByPlaceholderText('you@example.com')).toBeInTheDocument()
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export const ServerValidationErrors: Story = {
|
|
463
|
+
name: 'Server validation errors',
|
|
464
|
+
render: () => {
|
|
465
|
+
function ServerErrorDemo() {
|
|
466
|
+
const [serverErrors, setServerErrors] = useState<
|
|
467
|
+
Partial<Record<'username' | 'password', string>>
|
|
468
|
+
>({})
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<html.div style={wrapStyles.wrap}>
|
|
472
|
+
<Form
|
|
473
|
+
schema={LoginSchema}
|
|
474
|
+
defaultValues={{username: '', password: ''}}
|
|
475
|
+
onSubmit={() => {
|
|
476
|
+
// Simulate server response with errors
|
|
477
|
+
setServerErrors({username: 'This username is already taken'})
|
|
478
|
+
}}
|
|
479
|
+
validationErrors={serverErrors}
|
|
480
|
+
>
|
|
481
|
+
<Fieldset.Root gap="md">
|
|
482
|
+
<Field.Root name="username">
|
|
483
|
+
<Field.Label>Username</Field.Label>
|
|
484
|
+
<Input placeholder="Enter username" />
|
|
485
|
+
<Field.Error />
|
|
486
|
+
</Field.Root>
|
|
487
|
+
|
|
488
|
+
<Field.Root name="password">
|
|
489
|
+
<Field.Label>Password</Field.Label>
|
|
490
|
+
<Input type="password" placeholder="Enter password" />
|
|
491
|
+
<Field.Error />
|
|
492
|
+
</Field.Root>
|
|
493
|
+
|
|
494
|
+
<Button type="submit">Register</Button>
|
|
495
|
+
</Fieldset.Root>
|
|
496
|
+
</Form>
|
|
497
|
+
</html.div>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return <ServerErrorDemo />
|
|
502
|
+
},
|
|
503
|
+
play: async ({canvas}) => {
|
|
504
|
+
const usernameInput = canvas.getByPlaceholderText('Enter username')
|
|
505
|
+
const passwordInput = canvas.getByPlaceholderText('Enter password')
|
|
506
|
+
|
|
507
|
+
// Fill valid values and submit
|
|
508
|
+
await userEvent.type(usernameInput, 'johndoe')
|
|
509
|
+
await userEvent.type(passwordInput, 'supersecret1')
|
|
510
|
+
|
|
511
|
+
const button = canvas.getByRole('button', {name: 'Register'})
|
|
512
|
+
await userEvent.click(button)
|
|
513
|
+
|
|
514
|
+
// Server error appears
|
|
515
|
+
const alert = await canvas.findByRole('alert')
|
|
516
|
+
await expect(alert).toHaveTextContent('This username is already taken')
|
|
517
|
+
},
|
|
518
|
+
}
|
|
@@ -1,27 +1,48 @@
|
|
|
1
|
-
import {type ReactNode, useCallback, useEffect, useRef} from 'react'
|
|
2
|
-
import {html} from 'react-strict-dom'
|
|
1
|
+
import {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'
|
|
2
|
+
import {css, html} from 'react-strict-dom'
|
|
3
3
|
import {
|
|
4
4
|
useForm,
|
|
5
5
|
FormProvider,
|
|
6
6
|
type UseFormReturn,
|
|
7
7
|
type DefaultValues,
|
|
8
8
|
type FieldValues,
|
|
9
|
+
type FieldErrors,
|
|
10
|
+
type Path,
|
|
9
11
|
} from 'react-hook-form'
|
|
10
12
|
import {effectTsResolver} from '@hookform/resolvers/effect-ts'
|
|
11
13
|
import type {Schema} from 'effect'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
FormContext,
|
|
16
|
+
type LabelPosition,
|
|
17
|
+
type NecessityIndicator,
|
|
18
|
+
} from './FormContext'
|
|
13
19
|
|
|
14
20
|
export interface FormProps<T extends FieldValues> {
|
|
15
21
|
schema: Schema.Schema<T>
|
|
16
22
|
defaultValues: DefaultValues<T>
|
|
17
23
|
onSubmit: (data: T) => void | Promise<void>
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
labelPosition?: LabelPosition
|
|
26
|
+
necessityIndicator?: NecessityIndicator
|
|
27
|
+
validationErrors?: Partial<Record<Path<T>, string>>
|
|
18
28
|
children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
|
|
19
29
|
}
|
|
20
30
|
|
|
31
|
+
const formStyles = css.create({
|
|
32
|
+
disabled: {
|
|
33
|
+
opacity: 0.5,
|
|
34
|
+
pointerEvents: 'none',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
21
38
|
export function Form<T extends FieldValues>({
|
|
22
39
|
schema,
|
|
23
40
|
defaultValues,
|
|
24
41
|
onSubmit,
|
|
42
|
+
disabled = false,
|
|
43
|
+
labelPosition = 'top',
|
|
44
|
+
necessityIndicator = false,
|
|
45
|
+
validationErrors,
|
|
25
46
|
children,
|
|
26
47
|
}: FormProps<T>) {
|
|
27
48
|
const methods = useForm<T>({
|
|
@@ -31,13 +52,49 @@ export function Form<T extends FieldValues>({
|
|
|
31
52
|
reValidateMode: 'onChange',
|
|
32
53
|
})
|
|
33
54
|
|
|
55
|
+
// Sync server-side validation errors into RHF
|
|
56
|
+
const prevErrorKeysRef = useRef<string[]>([])
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const prevKeys = prevErrorKeysRef.current
|
|
59
|
+
const nextKeys = validationErrors ? Object.keys(validationErrors) : []
|
|
60
|
+
|
|
61
|
+
// Clear errors that were removed
|
|
62
|
+
for (const key of prevKeys) {
|
|
63
|
+
if (!nextKeys.includes(key)) {
|
|
64
|
+
methods.clearErrors(key as Path<T>)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Set new errors
|
|
69
|
+
if (validationErrors) {
|
|
70
|
+
for (const [key, message] of Object.entries(validationErrors)) {
|
|
71
|
+
if (message) {
|
|
72
|
+
methods.setError(key as Path<T>, {type: 'server', message: message as string})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
prevErrorKeysRef.current = nextKeys
|
|
78
|
+
}, [validationErrors, methods])
|
|
79
|
+
|
|
34
80
|
const formRef = useRef<HTMLFormElement>(null)
|
|
81
|
+
|
|
82
|
+
const onInvalid = useCallback(
|
|
83
|
+
(errors: FieldErrors<T>) => {
|
|
84
|
+
const firstField = Object.keys(errors)[0]
|
|
85
|
+
if (firstField) {
|
|
86
|
+
methods.setFocus(firstField as Path<T>)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[methods],
|
|
90
|
+
)
|
|
91
|
+
|
|
35
92
|
const handleSubmit = useCallback(
|
|
36
93
|
(e: Event) => {
|
|
37
94
|
e.preventDefault()
|
|
38
|
-
void methods.handleSubmit(onSubmit)()
|
|
95
|
+
void methods.handleSubmit(onSubmit, onInvalid)()
|
|
39
96
|
},
|
|
40
|
-
[methods, onSubmit],
|
|
97
|
+
[methods, onSubmit, onInvalid],
|
|
41
98
|
)
|
|
42
99
|
|
|
43
100
|
useEffect(() => {
|
|
@@ -47,10 +104,19 @@ export function Form<T extends FieldValues>({
|
|
|
47
104
|
return () => el.removeEventListener('submit', handleSubmit)
|
|
48
105
|
}, [handleSubmit])
|
|
49
106
|
|
|
107
|
+
const formCtx = useMemo(
|
|
108
|
+
() => ({disabled, labelPosition, necessityIndicator}),
|
|
109
|
+
[disabled, labelPosition, necessityIndicator],
|
|
110
|
+
)
|
|
111
|
+
|
|
50
112
|
return (
|
|
51
113
|
<FormProvider {...methods}>
|
|
52
|
-
<FormContext.Provider value={
|
|
53
|
-
<html.form
|
|
114
|
+
<FormContext.Provider value={formCtx}>
|
|
115
|
+
<html.form
|
|
116
|
+
ref={formRef}
|
|
117
|
+
aria-disabled={disabled || undefined}
|
|
118
|
+
style={disabled && formStyles.disabled}
|
|
119
|
+
>
|
|
54
120
|
{typeof children === 'function' ? children(methods) : children}
|
|
55
121
|
</html.form>
|
|
56
122
|
</FormContext.Provider>
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import {createContext, useContext} from 'react'
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type LabelPosition = 'top' | 'side'
|
|
4
|
+
export type NecessityIndicator = 'icon' | 'label' | false
|
|
5
|
+
|
|
6
|
+
export interface FormContextValue {
|
|
7
|
+
disabled: boolean
|
|
8
|
+
labelPosition: LabelPosition
|
|
9
|
+
necessityIndicator: NecessityIndicator
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FormContext = createContext<FormContextValue | null>(null)
|
|
4
13
|
|
|
5
14
|
export function useFormContext() {
|
|
6
15
|
return useContext(FormContext)
|
|
@@ -59,6 +59,7 @@ export function Input({
|
|
|
59
59
|
const effectiveOnChange = onChange ?? fieldCtx?.onChange
|
|
60
60
|
const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
|
|
61
61
|
const effectiveRef = ref ?? fieldCtx?.ref
|
|
62
|
+
const effectiveDisabled = disabled ?? ctx?.disabled
|
|
62
63
|
const effectiveVariant = ctx?.invalid ? 'error' : variant
|
|
63
64
|
|
|
64
65
|
// react-strict-dom omits web-only `pattern` from its types, but the
|
|
@@ -76,7 +77,7 @@ export function Input({
|
|
|
76
77
|
autoComplete={autoComplete}
|
|
77
78
|
value={effectiveValue}
|
|
78
79
|
defaultValue={defaultValue}
|
|
79
|
-
disabled={
|
|
80
|
+
disabled={effectiveDisabled}
|
|
80
81
|
aria-describedby={
|
|
81
82
|
ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
|
|
82
83
|
}
|