@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.
Files changed (38) hide show
  1. package/dist/components/Field/Field.d.ts +2 -1
  2. package/dist/components/Field/Field.d.ts.map +1 -1
  3. package/dist/components/Field/FieldContext.d.ts +9 -1
  4. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  5. package/dist/components/Form/Form.d.ts +11 -0
  6. package/dist/components/Form/Form.d.ts.map +1 -0
  7. package/dist/components/Form/Form.stories.d.ts +10 -0
  8. package/dist/components/Form/Form.stories.d.ts.map +1 -0
  9. package/dist/components/Form/FormContext.d.ts +3 -0
  10. package/dist/components/Form/FormContext.d.ts.map +1 -0
  11. package/dist/components/Input/Input.d.ts +3 -1
  12. package/dist/components/Input/Input.d.ts.map +1 -1
  13. package/dist/components/Input/styles.css.d.ts +0 -1
  14. package/dist/components/Input/styles.css.d.ts.map +1 -1
  15. package/dist/components/PageShell/styles.css.d.ts +0 -1
  16. package/dist/components/PageShell/styles.css.d.ts.map +1 -1
  17. package/dist/components/Textarea/Textarea.d.ts +3 -1
  18. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  19. package/dist/components/Textarea/styles.css.d.ts +0 -1
  20. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  21. package/dist/index.css +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1527 -1426
  25. package/dist/index.js.map +1 -1
  26. package/package.json +19 -2
  27. package/src/components/Field/Field.tsx +56 -3
  28. package/src/components/Field/FieldContext.ts +10 -0
  29. package/src/components/Form/Form.stories.tsx +252 -0
  30. package/src/components/Form/Form.tsx +59 -0
  31. package/src/components/Form/FormContext.ts +7 -0
  32. package/src/components/Input/Input.tsx +18 -4
  33. package/src/components/Input/styles.css.ts +0 -1
  34. package/src/components/PageShell/styles.css.ts +0 -1
  35. package/src/components/Textarea/Textarea.tsx +20 -4
  36. package/src/components/Textarea/styles.css.ts +0 -1
  37. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -2
  38. 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.15.1",
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.15.1"
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({invalid = false, children}: RootProps) {
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
- if (!ctx?.invalid && !children) return null
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
- {children}
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
+ }
@@ -0,0 +1,7 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export const FormContext = createContext(false)
4
+
5
+ export function useFormContext() {
6
+ return useContext(FormContext)
7
+ }
@@ -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={name}
72
+ name={effectiveName}
61
73
  placeholder={placeholder}
62
74
  required={required}
63
75
  minLength={minLength}
64
76
  autoComplete={autoComplete}
65
- value={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
- style={[styles.base, styles[variant], groupCtx?.inGroup && styles.inGroup]}
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
  )
@@ -5,7 +5,6 @@ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
 
6
6
  export const styles = css.create({
7
7
  base: {
8
- boxSizing: 'border-box',
9
8
  width: '100%',
10
9
  paddingTop: spacing.sm,
11
10
  paddingBottom: spacing.sm,
@@ -10,7 +10,6 @@ export const styles = css.create({
10
10
  width: '100%',
11
11
  marginLeft: 'auto',
12
12
  marginRight: 'auto',
13
- boxSizing: 'border-box',
14
13
  },
15
14
  // Max-width presets
16
15
  maxSm: {maxWidth: 600},
@@ -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={name}
49
+ name={effectiveName}
36
50
  placeholder={placeholder}
37
51
  required={required}
38
52
  rows={rows}
39
- value={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
- style={[styles.base, styles[variant]]}
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
  }
@@ -5,7 +5,6 @@ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
 
6
6
  export const styles = css.create({
7
7
  base: {
8
- boxSizing: 'border-box',
9
8
  width: '100%',
10
9
  paddingTop: spacing.sm,
11
10
  paddingBottom: spacing.sm,
@@ -119,7 +119,7 @@ export const Vertical: Story = {
119
119
  </ToggleGroup>
120
120
  ),
121
121
  play: async ({canvas}) => {
122
- const group = canvas.getByRole('group')
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('group')
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'