@duro-app/ui 0.15.1 → 0.16.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/Field/Field.d.ts +2 -1
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/FieldContext.d.ts +9 -1
- package/dist/components/Field/FieldContext.d.ts.map +1 -1
- package/dist/components/Form/Form.d.ts +11 -0
- package/dist/components/Form/Form.d.ts.map +1 -0
- package/dist/components/Form/Form.stories.d.ts +10 -0
- package/dist/components/Form/Form.stories.d.ts.map +1 -0
- package/dist/components/Form/FormContext.d.ts +3 -0
- package/dist/components/Form/FormContext.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +3 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +0 -1
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/PageShell/styles.css.d.ts +0 -1
- package/dist/components/PageShell/styles.css.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts +3 -1
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/styles.css.d.ts +0 -1
- package/dist/components/Textarea/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1527 -1426
- package/dist/index.js.map +1 -1
- package/package.json +19 -2
- package/src/components/Field/Field.tsx +56 -3
- package/src/components/Field/FieldContext.ts +10 -0
- package/src/components/Form/Form.stories.tsx +252 -0
- package/src/components/Form/Form.tsx +59 -0
- package/src/components/Form/FormContext.ts +7 -0
- package/src/components/Input/Input.tsx +18 -4
- package/src/components/Input/styles.css.ts +0 -1
- package/src/components/PageShell/styles.css.ts +0 -1
- package/src/components/Textarea/Textarea.tsx +20 -4
- package/src/components/Textarea/styles.css.ts +0 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -2
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,18 +33,35 @@
|
|
|
33
33
|
"./reset.css": "./src/global-reset.css"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
+
"@hookform/resolvers": "^5.0.0",
|
|
37
|
+
"effect": "^3.0.0",
|
|
36
38
|
"react": "^19.0.0",
|
|
37
39
|
"react-dom": "^19.0.0",
|
|
40
|
+
"react-hook-form": "^7.55.0",
|
|
38
41
|
"react-strict-dom": "^0.0.55"
|
|
39
42
|
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"react-hook-form": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@hookform/resolvers": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"effect": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
40
54
|
"dependencies": {
|
|
41
|
-
"@duro-app/tokens": "^0.
|
|
55
|
+
"@duro-app/tokens": "^0.16.0"
|
|
42
56
|
},
|
|
43
57
|
"devDependencies": {
|
|
44
58
|
"@babel/preset-typescript": "^7.28.0",
|
|
59
|
+
"@hookform/resolvers": "^5.0.0",
|
|
45
60
|
"@types/react": "^19.0.0",
|
|
46
61
|
"@types/react-dom": "^19.0.0",
|
|
47
62
|
"@vitejs/plugin-react": "^4.0.0",
|
|
63
|
+
"effect": "^3.0.0",
|
|
64
|
+
"react-hook-form": "^7.55.0",
|
|
48
65
|
"typescript": "^5.7.0",
|
|
49
66
|
"vite": "^6.0.0",
|
|
50
67
|
"vite-plugin-babel": "^1.5.0",
|
|
@@ -1,15 +1,67 @@
|
|
|
1
1
|
import {type ReactNode, useId, useMemo} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
|
+
import {useFormContext as useRHFFormContext, useController} from 'react-hook-form'
|
|
3
4
|
import {FieldContext, useFieldContext} from './FieldContext'
|
|
5
|
+
import {useFormContext} from '../Form/FormContext'
|
|
4
6
|
import {styles} from './styles.css'
|
|
5
7
|
|
|
6
8
|
// --- Root ---
|
|
7
9
|
interface RootProps {
|
|
10
|
+
name?: string
|
|
8
11
|
invalid?: boolean
|
|
9
12
|
children: ReactNode
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
function Root({
|
|
15
|
+
function Root({name, ...props}: RootProps) {
|
|
16
|
+
const insideForm = useFormContext()
|
|
17
|
+
if (name && insideForm) {
|
|
18
|
+
return <ControlledRoot name={name} {...props} />
|
|
19
|
+
}
|
|
20
|
+
return <StaticRoot {...props} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Always calls useController — no conditional hooks
|
|
24
|
+
function ControlledRoot({
|
|
25
|
+
name,
|
|
26
|
+
invalid: invalidProp = false,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
name: string
|
|
30
|
+
invalid?: boolean
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}) {
|
|
33
|
+
const id = useId()
|
|
34
|
+
const {control} = useRHFFormContext()
|
|
35
|
+
const {field, fieldState} = useController({control, name})
|
|
36
|
+
const invalid = invalidProp || !!fieldState.error
|
|
37
|
+
|
|
38
|
+
const ctx = useMemo(
|
|
39
|
+
() => ({
|
|
40
|
+
controlId: `${id}-control`,
|
|
41
|
+
descriptionId: `${id}-description`,
|
|
42
|
+
errorId: `${id}-error`,
|
|
43
|
+
invalid,
|
|
44
|
+
field: {
|
|
45
|
+
value: field.value,
|
|
46
|
+
onChange: field.onChange,
|
|
47
|
+
onBlur: field.onBlur,
|
|
48
|
+
ref: field.ref,
|
|
49
|
+
name: field.name,
|
|
50
|
+
},
|
|
51
|
+
errorMessage: fieldState.error?.message,
|
|
52
|
+
}),
|
|
53
|
+
[id, invalid, field.value, field.onChange, field.onBlur, field.ref, field.name, fieldState.error],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<FieldContext.Provider value={ctx}>
|
|
58
|
+
<html.div style={styles.root}>{children}</html.div>
|
|
59
|
+
</FieldContext.Provider>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Current behavior, no RHF dependency
|
|
64
|
+
function StaticRoot({invalid = false, children}: {invalid?: boolean; children: ReactNode}) {
|
|
13
65
|
const id = useId()
|
|
14
66
|
const ctx = useMemo(
|
|
15
67
|
() => ({
|
|
@@ -63,11 +115,12 @@ interface ErrorProps {
|
|
|
63
115
|
|
|
64
116
|
function Error({children}: ErrorProps) {
|
|
65
117
|
const ctx = useFieldContext()
|
|
66
|
-
|
|
118
|
+
const content = children ?? ctx?.errorMessage
|
|
119
|
+
if (!ctx?.invalid && !content) return null
|
|
67
120
|
|
|
68
121
|
return (
|
|
69
122
|
<html.span id={ctx?.errorId} role="alert" style={styles.error}>
|
|
70
|
-
{
|
|
123
|
+
{content}
|
|
71
124
|
</html.span>
|
|
72
125
|
)
|
|
73
126
|
}
|
|
@@ -5,8 +5,18 @@ interface FieldContextValue {
|
|
|
5
5
|
descriptionId: string
|
|
6
6
|
errorId: string
|
|
7
7
|
invalid: boolean
|
|
8
|
+
field?: {
|
|
9
|
+
value: unknown
|
|
10
|
+
onChange: (...event: unknown[]) => void
|
|
11
|
+
onBlur: () => void
|
|
12
|
+
ref: React.RefCallback<unknown>
|
|
13
|
+
name: string
|
|
14
|
+
}
|
|
15
|
+
errorMessage?: string
|
|
8
16
|
}
|
|
9
17
|
|
|
18
|
+
export type {FieldContextValue}
|
|
19
|
+
|
|
10
20
|
export const FieldContext = createContext<FieldContextValue | null>(null)
|
|
11
21
|
|
|
12
22
|
export function useFieldContext() {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn, userEvent, within} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {Schema} from 'effect'
|
|
5
|
+
import {Form} from './Form'
|
|
6
|
+
import {Field} from '../Field/Field'
|
|
7
|
+
import {Input} from '../Input/Input'
|
|
8
|
+
import {Textarea} from '../Textarea/Textarea'
|
|
9
|
+
import {Fieldset} from '../Fieldset/Fieldset'
|
|
10
|
+
import {Button} from '../Button/Button'
|
|
11
|
+
|
|
12
|
+
const meta: Meta = {
|
|
13
|
+
title: 'Components/Form',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj
|
|
18
|
+
|
|
19
|
+
// --- Schemas ---
|
|
20
|
+
|
|
21
|
+
const LoginSchema = Schema.Struct({
|
|
22
|
+
username: Schema.String.pipe(
|
|
23
|
+
Schema.minLength(3, {message: () => 'Username must be at least 3 characters'}),
|
|
24
|
+
),
|
|
25
|
+
password: Schema.String.pipe(
|
|
26
|
+
Schema.minLength(8, {message: () => 'Password must be at least 8 characters'}),
|
|
27
|
+
),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const FeedbackSchema = Schema.Struct({
|
|
31
|
+
email: Schema.String.pipe(
|
|
32
|
+
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {message: () => 'Enter a valid email address'}),
|
|
33
|
+
),
|
|
34
|
+
message: Schema.String.pipe(
|
|
35
|
+
Schema.minLength(10, {message: () => 'Message must be at least 10 characters'}),
|
|
36
|
+
),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const wrapStyles = css.create({
|
|
40
|
+
wrap: {maxWidth: 400},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// --- Stories ---
|
|
44
|
+
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: () => {
|
|
47
|
+
const handleSubmit = fn()
|
|
48
|
+
return (
|
|
49
|
+
<html.div style={wrapStyles.wrap}>
|
|
50
|
+
<Form
|
|
51
|
+
schema={LoginSchema}
|
|
52
|
+
defaultValues={{username: '', password: ''}}
|
|
53
|
+
onSubmit={handleSubmit}
|
|
54
|
+
>
|
|
55
|
+
{({formState}) => (
|
|
56
|
+
<Fieldset.Root gap="md">
|
|
57
|
+
<Field.Root name="username">
|
|
58
|
+
<Field.Label>Username</Field.Label>
|
|
59
|
+
<Input placeholder="Enter username" />
|
|
60
|
+
<Field.Description>3-32 characters</Field.Description>
|
|
61
|
+
<Field.Error />
|
|
62
|
+
</Field.Root>
|
|
63
|
+
|
|
64
|
+
<Field.Root name="password">
|
|
65
|
+
<Field.Label>Password</Field.Label>
|
|
66
|
+
<Input type="password" placeholder="Enter password" />
|
|
67
|
+
<Field.Error />
|
|
68
|
+
</Field.Root>
|
|
69
|
+
|
|
70
|
+
<Button type="submit" disabled={!formState.isValid}>
|
|
71
|
+
Log in
|
|
72
|
+
</Button>
|
|
73
|
+
</Fieldset.Root>
|
|
74
|
+
)}
|
|
75
|
+
</Form>
|
|
76
|
+
</html.div>
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
play: async ({canvas}) => {
|
|
80
|
+
// Submit button starts disabled (empty fields are invalid)
|
|
81
|
+
const button = canvas.getByRole('button', {name: 'Log in'})
|
|
82
|
+
await expect(button).toBeDisabled()
|
|
83
|
+
|
|
84
|
+
// Labels and inputs rendered
|
|
85
|
+
await expect(canvas.getByText('Username')).toBeInTheDocument()
|
|
86
|
+
await expect(canvas.getByText('Password')).toBeInTheDocument()
|
|
87
|
+
await expect(canvas.getByPlaceholderText('Enter username')).toBeInTheDocument()
|
|
88
|
+
await expect(canvas.getByPlaceholderText('Enter password')).toBeInTheDocument()
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const ValidationOnBlur: Story = {
|
|
93
|
+
name: 'Validation — on blur',
|
|
94
|
+
render: () => (
|
|
95
|
+
<html.div style={wrapStyles.wrap}>
|
|
96
|
+
<Form
|
|
97
|
+
schema={LoginSchema}
|
|
98
|
+
defaultValues={{username: '', password: ''}}
|
|
99
|
+
onSubmit={fn()}
|
|
100
|
+
>
|
|
101
|
+
<Fieldset.Root gap="md">
|
|
102
|
+
<Field.Root name="username">
|
|
103
|
+
<Field.Label>Username</Field.Label>
|
|
104
|
+
<Input placeholder="Enter username" />
|
|
105
|
+
<Field.Error />
|
|
106
|
+
</Field.Root>
|
|
107
|
+
|
|
108
|
+
<Field.Root name="password">
|
|
109
|
+
<Field.Label>Password</Field.Label>
|
|
110
|
+
<Input type="password" placeholder="Enter password" />
|
|
111
|
+
<Field.Error />
|
|
112
|
+
</Field.Root>
|
|
113
|
+
|
|
114
|
+
<Button type="submit">Log in</Button>
|
|
115
|
+
</Fieldset.Root>
|
|
116
|
+
</Form>
|
|
117
|
+
</html.div>
|
|
118
|
+
),
|
|
119
|
+
play: async ({canvas, step}) => {
|
|
120
|
+
const usernameInput = canvas.getByPlaceholderText('Enter username')
|
|
121
|
+
const passwordInput = canvas.getByPlaceholderText('Enter password')
|
|
122
|
+
|
|
123
|
+
await step('No errors before interaction', async () => {
|
|
124
|
+
expect(canvas.queryByRole('alert')).not.toBeInTheDocument()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await step('Type short username then blur — error appears', async () => {
|
|
128
|
+
await userEvent.type(usernameInput, 'ab')
|
|
129
|
+
await userEvent.tab() // blur
|
|
130
|
+
const alert = await canvas.findByRole('alert')
|
|
131
|
+
await expect(alert).toHaveTextContent('Username must be at least 3 characters')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await step('Fix username by typing more — error clears (reValidateMode: onChange)', async () => {
|
|
135
|
+
await userEvent.type(usernameInput, 'c') // now "abc"
|
|
136
|
+
expect(canvas.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await step('Blur empty password — error appears', async () => {
|
|
140
|
+
await userEvent.click(passwordInput)
|
|
141
|
+
await userEvent.tab()
|
|
142
|
+
const alerts = canvas.getAllByRole('alert')
|
|
143
|
+
const pwAlert = alerts.find((el) => el.textContent?.includes('Password'))
|
|
144
|
+
await expect(pwAlert).toBeDefined()
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const WithTextarea: Story = {
|
|
150
|
+
name: 'With Textarea',
|
|
151
|
+
render: () => (
|
|
152
|
+
<html.div style={wrapStyles.wrap}>
|
|
153
|
+
<Form
|
|
154
|
+
schema={FeedbackSchema}
|
|
155
|
+
defaultValues={{email: '', message: ''}}
|
|
156
|
+
onSubmit={fn()}
|
|
157
|
+
>
|
|
158
|
+
{({formState}) => (
|
|
159
|
+
<Fieldset.Root gap="md">
|
|
160
|
+
<Field.Root name="email">
|
|
161
|
+
<Field.Label>Email</Field.Label>
|
|
162
|
+
<Input type="email" placeholder="you@example.com" />
|
|
163
|
+
<Field.Error />
|
|
164
|
+
</Field.Root>
|
|
165
|
+
|
|
166
|
+
<Field.Root name="message">
|
|
167
|
+
<Field.Label>Message</Field.Label>
|
|
168
|
+
<Textarea placeholder="Tell us what you think..." rows={4} />
|
|
169
|
+
<Field.Description>At least 10 characters</Field.Description>
|
|
170
|
+
<Field.Error />
|
|
171
|
+
</Field.Root>
|
|
172
|
+
|
|
173
|
+
<Button type="submit" disabled={!formState.isValid}>
|
|
174
|
+
Send feedback
|
|
175
|
+
</Button>
|
|
176
|
+
</Fieldset.Root>
|
|
177
|
+
)}
|
|
178
|
+
</Form>
|
|
179
|
+
</html.div>
|
|
180
|
+
),
|
|
181
|
+
play: async ({canvas}) => {
|
|
182
|
+
await expect(canvas.getByPlaceholderText('you@example.com')).toBeInTheDocument()
|
|
183
|
+
await expect(canvas.getByPlaceholderText('Tell us what you think...')).toBeInTheDocument()
|
|
184
|
+
|
|
185
|
+
const button = canvas.getByRole('button', {name: 'Send feedback'})
|
|
186
|
+
await expect(button).toBeDisabled()
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const SuccessfulSubmit: Story = {
|
|
191
|
+
name: 'Successful submit',
|
|
192
|
+
render: () => {
|
|
193
|
+
const onSubmit = fn()
|
|
194
|
+
return (
|
|
195
|
+
<html.div style={wrapStyles.wrap}>
|
|
196
|
+
<Form
|
|
197
|
+
schema={LoginSchema}
|
|
198
|
+
defaultValues={{username: '', password: ''}}
|
|
199
|
+
onSubmit={onSubmit}
|
|
200
|
+
>
|
|
201
|
+
<Fieldset.Root gap="md">
|
|
202
|
+
<Field.Root name="username">
|
|
203
|
+
<Field.Label>Username</Field.Label>
|
|
204
|
+
<Input placeholder="Enter username" />
|
|
205
|
+
<Field.Error />
|
|
206
|
+
</Field.Root>
|
|
207
|
+
|
|
208
|
+
<Field.Root name="password">
|
|
209
|
+
<Field.Label>Password</Field.Label>
|
|
210
|
+
<Input type="password" placeholder="Enter password" />
|
|
211
|
+
<Field.Error />
|
|
212
|
+
</Field.Root>
|
|
213
|
+
|
|
214
|
+
<Button type="submit">Log in</Button>
|
|
215
|
+
</Fieldset.Root>
|
|
216
|
+
</Form>
|
|
217
|
+
</html.div>
|
|
218
|
+
)
|
|
219
|
+
},
|
|
220
|
+
play: async ({canvas}) => {
|
|
221
|
+
const usernameInput = canvas.getByPlaceholderText('Enter username')
|
|
222
|
+
const passwordInput = canvas.getByPlaceholderText('Enter password')
|
|
223
|
+
|
|
224
|
+
// Fill valid values
|
|
225
|
+
await userEvent.type(usernameInput, 'johndoe')
|
|
226
|
+
await userEvent.type(passwordInput, 'supersecret1')
|
|
227
|
+
|
|
228
|
+
// No errors should be present
|
|
229
|
+
expect(canvas.queryByRole('alert')).not.toBeInTheDocument()
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const StandaloneFieldStillWorks: Story = {
|
|
234
|
+
name: 'Standalone Field (no Form)',
|
|
235
|
+
render: () => (
|
|
236
|
+
<html.div style={wrapStyles.wrap}>
|
|
237
|
+
<Field.Root invalid>
|
|
238
|
+
<Field.Label>Email</Field.Label>
|
|
239
|
+
<Input variant="error" placeholder="Enter email" />
|
|
240
|
+
<Field.Error>Manual error — no Form wrapper needed.</Field.Error>
|
|
241
|
+
</Field.Root>
|
|
242
|
+
</html.div>
|
|
243
|
+
),
|
|
244
|
+
play: async ({canvas}) => {
|
|
245
|
+
// Standalone usage still works without Form context
|
|
246
|
+
const input = canvas.getByPlaceholderText('Enter email')
|
|
247
|
+
await expect(input).toHaveAttribute('aria-invalid', 'true')
|
|
248
|
+
|
|
249
|
+
const alert = canvas.getByRole('alert')
|
|
250
|
+
await expect(alert).toHaveTextContent('Manual error — no Form wrapper needed.')
|
|
251
|
+
},
|
|
252
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {type ReactNode, useCallback, useEffect, useRef} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {
|
|
4
|
+
useForm,
|
|
5
|
+
FormProvider,
|
|
6
|
+
type UseFormReturn,
|
|
7
|
+
type DefaultValues,
|
|
8
|
+
type FieldValues,
|
|
9
|
+
} from 'react-hook-form'
|
|
10
|
+
import {effectTsResolver} from '@hookform/resolvers/effect-ts'
|
|
11
|
+
import type {Schema} from 'effect'
|
|
12
|
+
import {FormContext} from './FormContext'
|
|
13
|
+
|
|
14
|
+
export interface FormProps<T extends FieldValues> {
|
|
15
|
+
schema: Schema.Schema<T>
|
|
16
|
+
defaultValues: DefaultValues<T>
|
|
17
|
+
onSubmit: (data: T) => void | Promise<void>
|
|
18
|
+
children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Form<T extends FieldValues>({
|
|
22
|
+
schema,
|
|
23
|
+
defaultValues,
|
|
24
|
+
onSubmit,
|
|
25
|
+
children,
|
|
26
|
+
}: FormProps<T>) {
|
|
27
|
+
const methods = useForm<T>({
|
|
28
|
+
resolver: effectTsResolver(schema),
|
|
29
|
+
defaultValues,
|
|
30
|
+
mode: 'onTouched',
|
|
31
|
+
reValidateMode: 'onChange',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const formRef = useRef<HTMLFormElement>(null)
|
|
35
|
+
const handleSubmit = useCallback(
|
|
36
|
+
(e: Event) => {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
void methods.handleSubmit(onSubmit)()
|
|
39
|
+
},
|
|
40
|
+
[methods, onSubmit],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const el = formRef.current
|
|
45
|
+
if (!el) return
|
|
46
|
+
el.addEventListener('submit', handleSubmit)
|
|
47
|
+
return () => el.removeEventListener('submit', handleSubmit)
|
|
48
|
+
}, [handleSubmit])
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<FormProvider {...methods}>
|
|
52
|
+
<FormContext.Provider value={true}>
|
|
53
|
+
<html.form ref={formRef}>
|
|
54
|
+
{typeof children === 'function' ? children(methods) : children}
|
|
55
|
+
</html.form>
|
|
56
|
+
</FormContext.Provider>
|
|
57
|
+
</FormProvider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -30,6 +30,8 @@ interface InputProps {
|
|
|
30
30
|
defaultValue?: string
|
|
31
31
|
disabled?: boolean
|
|
32
32
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
33
|
+
onBlur?: () => void
|
|
34
|
+
ref?: React.Ref<HTMLInputElement>
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function Input({
|
|
@@ -45,10 +47,20 @@ export function Input({
|
|
|
45
47
|
defaultValue,
|
|
46
48
|
disabled,
|
|
47
49
|
onChange,
|
|
50
|
+
onBlur,
|
|
51
|
+
ref,
|
|
48
52
|
}: InputProps) {
|
|
49
53
|
const ctx = useFieldContext()
|
|
50
54
|
const groupCtx = useInputGroupContext()
|
|
51
55
|
|
|
56
|
+
const fieldCtx = ctx?.field
|
|
57
|
+
const effectiveName = name ?? fieldCtx?.name
|
|
58
|
+
const effectiveValue = value ?? (fieldCtx ? String(fieldCtx.value ?? '') : undefined)
|
|
59
|
+
const effectiveOnChange = onChange ?? fieldCtx?.onChange
|
|
60
|
+
const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
|
|
61
|
+
const effectiveRef = ref ?? fieldCtx?.ref
|
|
62
|
+
const effectiveVariant = ctx?.invalid ? 'error' : variant
|
|
63
|
+
|
|
52
64
|
// react-strict-dom omits web-only `pattern` from its types, but the
|
|
53
65
|
// underlying DOM element supports it. Type-assert to pass it through.
|
|
54
66
|
const extraProps = pattern !== undefined ? {pattern} : undefined
|
|
@@ -57,20 +69,22 @@ export function Input({
|
|
|
57
69
|
<html.input
|
|
58
70
|
id={ctx?.controlId}
|
|
59
71
|
type={type}
|
|
60
|
-
name={
|
|
72
|
+
name={effectiveName}
|
|
61
73
|
placeholder={placeholder}
|
|
62
74
|
required={required}
|
|
63
75
|
minLength={minLength}
|
|
64
76
|
autoComplete={autoComplete}
|
|
65
|
-
value={
|
|
77
|
+
value={effectiveValue}
|
|
66
78
|
defaultValue={defaultValue}
|
|
67
79
|
disabled={disabled}
|
|
68
80
|
aria-describedby={
|
|
69
81
|
ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
|
|
70
82
|
}
|
|
71
83
|
aria-invalid={ctx?.invalid || variant === 'error' || undefined}
|
|
72
|
-
onChange={onChange}
|
|
73
|
-
|
|
84
|
+
onChange={effectiveOnChange as StrictInputProps['onChange']}
|
|
85
|
+
onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
|
|
86
|
+
ref={effectiveRef as React.Ref<HTMLInputElement>}
|
|
87
|
+
style={[styles.base, styles[effectiveVariant], groupCtx?.inGroup && styles.inGroup]}
|
|
74
88
|
{...(extraProps as Record<string, unknown>)}
|
|
75
89
|
/>
|
|
76
90
|
)
|
|
@@ -2,6 +2,8 @@ import {html} from 'react-strict-dom'
|
|
|
2
2
|
import {useFieldContext} from '../Field/FieldContext'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
|
|
5
|
+
type StrictTextareaProps = React.ComponentProps<typeof html.textarea>
|
|
6
|
+
|
|
5
7
|
export type TextareaVariant = 'default' | 'error'
|
|
6
8
|
|
|
7
9
|
interface TextareaProps {
|
|
@@ -14,6 +16,8 @@ interface TextareaProps {
|
|
|
14
16
|
defaultValue?: string
|
|
15
17
|
disabled?: boolean
|
|
16
18
|
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
|
19
|
+
onBlur?: () => void
|
|
20
|
+
ref?: React.Ref<HTMLTextAreaElement>
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export function Textarea({
|
|
@@ -26,25 +30,37 @@ export function Textarea({
|
|
|
26
30
|
defaultValue,
|
|
27
31
|
disabled,
|
|
28
32
|
onChange,
|
|
33
|
+
onBlur,
|
|
34
|
+
ref,
|
|
29
35
|
}: TextareaProps) {
|
|
30
36
|
const ctx = useFieldContext()
|
|
31
37
|
|
|
38
|
+
const fieldCtx = ctx?.field
|
|
39
|
+
const effectiveName = name ?? fieldCtx?.name
|
|
40
|
+
const effectiveValue = value ?? (fieldCtx ? String(fieldCtx.value ?? '') : undefined)
|
|
41
|
+
const effectiveOnChange = onChange ?? fieldCtx?.onChange
|
|
42
|
+
const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
|
|
43
|
+
const effectiveRef = ref ?? fieldCtx?.ref
|
|
44
|
+
const effectiveVariant = ctx?.invalid ? 'error' : variant
|
|
45
|
+
|
|
32
46
|
return (
|
|
33
47
|
<html.textarea
|
|
34
48
|
id={ctx?.controlId}
|
|
35
|
-
name={
|
|
49
|
+
name={effectiveName}
|
|
36
50
|
placeholder={placeholder}
|
|
37
51
|
required={required}
|
|
38
52
|
rows={rows}
|
|
39
|
-
value={
|
|
53
|
+
value={effectiveValue}
|
|
40
54
|
defaultValue={defaultValue}
|
|
41
55
|
disabled={disabled}
|
|
42
56
|
aria-describedby={
|
|
43
57
|
ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
|
|
44
58
|
}
|
|
45
59
|
aria-invalid={ctx?.invalid || variant === 'error' || undefined}
|
|
46
|
-
onChange={onChange}
|
|
47
|
-
|
|
60
|
+
onChange={effectiveOnChange as StrictTextareaProps['onChange']}
|
|
61
|
+
onBlur={effectiveOnBlur as StrictTextareaProps['onBlur']}
|
|
62
|
+
ref={effectiveRef as React.Ref<HTMLTextAreaElement>}
|
|
63
|
+
style={[styles.base, styles[effectiveVariant]]}
|
|
48
64
|
/>
|
|
49
65
|
)
|
|
50
66
|
}
|
|
@@ -119,7 +119,7 @@ export const Vertical: Story = {
|
|
|
119
119
|
</ToggleGroup>
|
|
120
120
|
),
|
|
121
121
|
play: async ({canvas}) => {
|
|
122
|
-
const group = canvas.getByRole('
|
|
122
|
+
const group = canvas.getByRole('toolbar')
|
|
123
123
|
await expect(group).toHaveAttribute('aria-orientation', 'vertical')
|
|
124
124
|
},
|
|
125
125
|
}
|
|
@@ -153,7 +153,7 @@ export const AllVariants: Story = {
|
|
|
153
153
|
</html.div>
|
|
154
154
|
),
|
|
155
155
|
play: async ({canvas}) => {
|
|
156
|
-
const groups = canvas.getAllByRole('
|
|
156
|
+
const groups = canvas.getAllByRole('toolbar')
|
|
157
157
|
await expect(groups.length).toBe(4)
|
|
158
158
|
},
|
|
159
159
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import './global-reset.css'
|
|
1
2
|
import './strict.css'
|
|
2
3
|
|
|
3
4
|
// Components
|
|
@@ -12,6 +13,7 @@ export {Checkbox} from './components/Checkbox/Checkbox'
|
|
|
12
13
|
export {EmptyState} from './components/EmptyState/EmptyState'
|
|
13
14
|
export {Heading, type HeadingVariant} from './components/Heading/Heading'
|
|
14
15
|
export {Field} from './components/Field/Field'
|
|
16
|
+
export {Form, type FormProps} from './components/Form/Form'
|
|
15
17
|
export {Fieldset, type FieldsetGap} from './components/Fieldset/Fieldset'
|
|
16
18
|
export {Input, type InputVariant} from './components/Input/Input'
|
|
17
19
|
export {InputGroup} from './components/InputGroup/InputGroup'
|