@duro-app/ui 0.15.2 → 0.17.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/ActionBar/ActionBar.d.ts +3 -1
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/Button/Button.meta.d.ts +3 -0
- package/dist/components/Button/Button.meta.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.meta.d.ts +3 -0
- package/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -0
- package/dist/components/Cluster/Cluster.meta.d.ts +3 -0
- package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts +2 -1
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/Field.meta.d.ts +3 -0
- package/dist/components/Field/Field.meta.d.ts.map +1 -0
- package/dist/components/Field/FieldContext.d.ts +9 -1
- package/dist/components/Field/FieldContext.d.ts.map +1 -1
- package/dist/components/Fieldset/Fieldset.meta.d.ts +3 -0
- package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -0
- package/dist/components/Form/Form.d.ts +11 -0
- package/dist/components/Form/Form.d.ts.map +1 -0
- package/dist/components/Form/Form.meta.d.ts +3 -0
- package/dist/components/Form/Form.meta.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/Grid/Grid.meta.d.ts +3 -0
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -0
- package/dist/components/Inline/Inline.meta.d.ts +3 -0
- package/dist/components/Inline/Inline.meta.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/Input.meta.d.ts +3 -0
- package/dist/components/Input/Input.meta.d.ts.map +1 -0
- package/dist/components/LinkButton/LinkButton.meta.d.ts +3 -0
- package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -0
- package/dist/components/Menu/Menu.meta.d.ts +3 -0
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -0
- package/dist/components/PageShell/PageShell.meta.d.ts +3 -0
- package/dist/components/PageShell/PageShell.meta.d.ts.map +1 -0
- package/dist/components/ScrollArea/ScrollArea.meta.d.ts +3 -0
- package/dist/components/ScrollArea/ScrollArea.meta.d.ts.map +1 -0
- package/dist/components/Select/Select.meta.d.ts +3 -0
- package/dist/components/Select/Select.meta.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.meta.d.ts +3 -0
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -0
- package/dist/components/Stack/Stack.meta.d.ts +3 -0
- package/dist/components/Stack/Stack.meta.d.ts.map +1 -0
- package/dist/components/Switch/Switch.meta.d.ts +3 -0
- package/dist/components/Switch/Switch.meta.d.ts.map +1 -0
- package/dist/components/Table/Table.meta.d.ts +3 -0
- package/dist/components/Table/Table.meta.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.meta.d.ts +3 -0
- package/dist/components/Tabs/Tabs.meta.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts +3 -1
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.meta.d.ts +3 -0
- package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -0
- package/dist/components/Toggle/Toggle.meta.d.ts +3 -0
- package/dist/components/Toggle/Toggle.meta.d.ts.map +1 -0
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts +3 -0
- package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -0
- package/dist/components/Tooltip/Tooltip.meta.d.ts +3 -0
- package/dist/components/Tooltip/Tooltip.meta.d.ts.map +1 -0
- package/dist/components/component-meta.d.ts +15 -0
- package/dist/components/component-meta.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1500 -1400
- package/dist/index.js.map +1 -1
- package/package.json +19 -2
- package/src/components/ActionBar/ActionBar.tsx +20 -15
- package/src/components/Button/Button.meta.ts +23 -0
- package/src/components/Checkbox/Checkbox.meta.ts +21 -0
- package/src/components/Cluster/Cluster.meta.ts +25 -0
- package/src/components/Field/Field.meta.ts +37 -0
- package/src/components/Field/Field.tsx +56 -3
- package/src/components/Field/FieldContext.ts +10 -0
- package/src/components/Fieldset/Fieldset.meta.ts +38 -0
- package/src/components/Form/Form.meta.ts +38 -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/Grid/Grid.meta.ts +30 -0
- package/src/components/Inline/Inline.meta.ts +23 -0
- package/src/components/Input/Input.meta.ts +23 -0
- package/src/components/Input/Input.tsx +18 -4
- package/src/components/LinkButton/LinkButton.meta.ts +24 -0
- package/src/components/Menu/Menu.meta.ts +33 -0
- package/src/components/PageShell/PageShell.meta.ts +24 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +27 -0
- package/src/components/Select/Select.meta.ts +34 -0
- package/src/components/SideNav/SideNav.meta.ts +31 -0
- package/src/components/Stack/Stack.meta.ts +22 -0
- package/src/components/Switch/Switch.meta.ts +25 -0
- package/src/components/Table/Table.meta.ts +36 -0
- package/src/components/Tabs/Tabs.meta.ts +31 -0
- package/src/components/Textarea/Textarea.meta.ts +22 -0
- package/src/components/Textarea/Textarea.tsx +20 -4
- package/src/components/Toggle/Toggle.meta.ts +30 -0
- package/src/components/ToggleGroup/ToggleGroup.meta.ts +30 -0
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -2
- package/src/components/Tooltip/Tooltip.meta.ts +22 -0
- package/src/components/component-meta.ts +8 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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.17.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",
|
|
@@ -13,6 +13,8 @@ export interface ActionBarProps {
|
|
|
13
13
|
isEmphasized?: boolean
|
|
14
14
|
/** Called when the clear/close button is pressed. */
|
|
15
15
|
onClearSelection: () => void
|
|
16
|
+
/** Whether the bar can be dismissed. When false, the close button is hidden. @default true */
|
|
17
|
+
dismissible?: boolean
|
|
16
18
|
/** Action buttons to display. */
|
|
17
19
|
children: ReactNode
|
|
18
20
|
}
|
|
@@ -22,6 +24,7 @@ function ActionBarInner({
|
|
|
22
24
|
selectedLabel,
|
|
23
25
|
isEmphasized = false,
|
|
24
26
|
onClearSelection,
|
|
27
|
+
dismissible = true,
|
|
25
28
|
children,
|
|
26
29
|
}: ActionBarProps) {
|
|
27
30
|
const container = useOverlayContainer()
|
|
@@ -42,21 +45,23 @@ function ActionBarInner({
|
|
|
42
45
|
</html.span>
|
|
43
46
|
<html.div style={[styles.separator, isEmphasized && styles.separatorEmphasized]} />
|
|
44
47
|
<html.div style={styles.actions}>{children}</html.div>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
{dismissible && (
|
|
49
|
+
<html.button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label="Clear selection"
|
|
52
|
+
onClick={onClearSelection}
|
|
53
|
+
style={[styles.closeButton, isEmphasized && styles.closeButtonEmphasized]}
|
|
54
|
+
>
|
|
55
|
+
<svg width={10} height={10} viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
56
|
+
<path
|
|
57
|
+
d="M1 1l8 8M9 1l-8 8"
|
|
58
|
+
stroke="currentColor"
|
|
59
|
+
strokeWidth={1.5}
|
|
60
|
+
strokeLinecap="round"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
</html.button>
|
|
64
|
+
)}
|
|
60
65
|
</html.div>
|
|
61
66
|
)
|
|
62
67
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Standard interactive button. For navigation (links styled as buttons), use LinkButton instead.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Triggering actions: submit, save, delete, open dialog',
|
|
8
|
+
'Primary and secondary actions in forms and toolbars',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Navigating to a URL — use LinkButton (renders <a>)',
|
|
12
|
+
'Toggling state — use Toggle',
|
|
13
|
+
],
|
|
14
|
+
relatedTo: [
|
|
15
|
+
{component: 'LinkButton', relationship: 'LinkButton for navigation; Button for actions'},
|
|
16
|
+
{component: 'Toggle', relationship: 'Toggle for pressed/unpressed state'},
|
|
17
|
+
],
|
|
18
|
+
example: `<Inline gap="sm">
|
|
19
|
+
<Button variant="primary" type="submit">Save</Button>
|
|
20
|
+
<Button variant="secondary">Cancel</Button>
|
|
21
|
+
<Button variant="danger">Delete</Button>
|
|
22
|
+
</Inline>`,
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Checkbox input with optional visible label. Supports controlled and uncontrolled modes.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Boolean opt-in/opt-out (terms acceptance, feature toggle)',
|
|
8
|
+
'Multi-select from a list (inside a Fieldset)',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Binary toggle in settings — prefer Switch for on/off semantics',
|
|
12
|
+
'Selecting one of several options — use Select or ToggleGroup',
|
|
13
|
+
],
|
|
14
|
+
relatedTo: [
|
|
15
|
+
{component: 'Switch', relationship: 'Switch for on/off settings; Checkbox for opt-in/selection'},
|
|
16
|
+
{component: 'Toggle', relationship: 'Toggle for toolbar actions; Checkbox for form input'},
|
|
17
|
+
],
|
|
18
|
+
example: `<Checkbox name="terms" onChange={(e) => console.log(e.target.checked)}>
|
|
19
|
+
I agree to the terms and conditions
|
|
20
|
+
</Checkbox>`,
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Horizontal flex layout that WRAPS to the next line when items overflow.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Tags, badges, or chips that should wrap when they exceed the container width',
|
|
8
|
+
'Any set of inline elements with unknown/dynamic count',
|
|
9
|
+
'Filter pills, keyword lists, avatar groups',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Items that must stay on one line — use Inline',
|
|
13
|
+
'Vertical stacking — use Stack',
|
|
14
|
+
],
|
|
15
|
+
relatedTo: [
|
|
16
|
+
{component: 'Inline', relationship: 'Same but does NOT wrap (nowrap)'},
|
|
17
|
+
{component: 'Stack', relationship: 'Vertical equivalent'},
|
|
18
|
+
],
|
|
19
|
+
example: `<Cluster gap="xs">
|
|
20
|
+
<Badge>React</Badge>
|
|
21
|
+
<Badge>TypeScript</Badge>
|
|
22
|
+
<Badge>Design Systems</Badge>
|
|
23
|
+
<Badge>Accessibility</Badge>
|
|
24
|
+
</Cluster>`,
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Compound form field with label, description, and error display. Inside Form, auto-binds to react-hook-form via name prop. Works standalone with manual invalid prop.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Any form input that needs a label and/or error message',
|
|
8
|
+
'Standalone labeled input (without Form) with manual error display',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Bare input without label — use Input directly (rare)',
|
|
12
|
+
],
|
|
13
|
+
anatomy: {
|
|
14
|
+
required: ['Root'],
|
|
15
|
+
optional: ['Label', 'Description', 'Error'],
|
|
16
|
+
},
|
|
17
|
+
relatedTo: [
|
|
18
|
+
{component: 'Form', relationship: 'Field.Root auto-binds when name matches schema key'},
|
|
19
|
+
{component: 'Input', relationship: 'Place Input inside Field.Root'},
|
|
20
|
+
{component: 'Textarea', relationship: 'Place Textarea inside Field.Root'},
|
|
21
|
+
{component: 'Select', relationship: 'Place Select inside Field.Root'},
|
|
22
|
+
],
|
|
23
|
+
example: `// Inside Form (auto-binds validation)
|
|
24
|
+
<Field.Root name="email">
|
|
25
|
+
<Field.Label>Email</Field.Label>
|
|
26
|
+
<Input type="email" placeholder="you@example.com" />
|
|
27
|
+
<Field.Description>We'll never share your email.</Field.Description>
|
|
28
|
+
<Field.Error />
|
|
29
|
+
</Field.Root>
|
|
30
|
+
|
|
31
|
+
// Standalone (manual error)
|
|
32
|
+
<Field.Root invalid>
|
|
33
|
+
<Field.Label>Email</Field.Label>
|
|
34
|
+
<Input variant="error" />
|
|
35
|
+
<Field.Error>This email is already taken.</Field.Error>
|
|
36
|
+
</Field.Root>`,
|
|
37
|
+
}
|
|
@@ -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,38 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Groups related form controls with consistent gap spacing and an optional legend. Supports disabled cascade.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Grouping related fields within a form (e.g., "Personal Info", "Address")',
|
|
8
|
+
'Applying consistent vertical gap between form fields',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Non-form content grouping — use Stack or Card',
|
|
12
|
+
],
|
|
13
|
+
anatomy: {
|
|
14
|
+
required: ['Root'],
|
|
15
|
+
optional: ['Legend'],
|
|
16
|
+
},
|
|
17
|
+
relatedTo: [
|
|
18
|
+
{component: 'Form', relationship: 'Fieldset.Root goes inside Form'},
|
|
19
|
+
{component: 'Field', relationship: 'Field.Root goes inside Fieldset.Root'},
|
|
20
|
+
{component: 'Stack', relationship: 'Similar vertical spacing, but Fieldset has form semantics'},
|
|
21
|
+
],
|
|
22
|
+
example: `<Form schema={schema} defaultValues={defaults} onSubmit={onSubmit}>
|
|
23
|
+
<Fieldset.Root gap="md">
|
|
24
|
+
<Fieldset.Legend>Account Details</Fieldset.Legend>
|
|
25
|
+
<Field.Root name="email">
|
|
26
|
+
<Field.Label>Email</Field.Label>
|
|
27
|
+
<Input type="email" />
|
|
28
|
+
<Field.Error />
|
|
29
|
+
</Field.Root>
|
|
30
|
+
<Field.Root name="password">
|
|
31
|
+
<Field.Label>Password</Field.Label>
|
|
32
|
+
<Input type="password" />
|
|
33
|
+
<Field.Error />
|
|
34
|
+
</Field.Root>
|
|
35
|
+
<Button type="submit">Create account</Button>
|
|
36
|
+
</Fieldset.Root>
|
|
37
|
+
</Form>`,
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Any form that needs validation',
|
|
8
|
+
'Multi-field forms with submit handling',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Single standalone field without validation — use Field.Root directly',
|
|
12
|
+
'Non-form interactive UIs — use individual components',
|
|
13
|
+
],
|
|
14
|
+
relatedTo: [
|
|
15
|
+
{component: 'Field', relationship: 'Field.Root auto-binds to Form via name prop'},
|
|
16
|
+
{component: 'Fieldset', relationship: 'Groups fields visually within a Form'},
|
|
17
|
+
],
|
|
18
|
+
example: `import {Schema} from 'effect'
|
|
19
|
+
|
|
20
|
+
const MySchema = Schema.Struct({
|
|
21
|
+
email: Schema.String.pipe(
|
|
22
|
+
Schema.pattern(/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/, {message: () => 'Invalid email'}),
|
|
23
|
+
),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
<Form schema={MySchema} defaultValues={{email: ''}} onSubmit={handleSubmit}>
|
|
27
|
+
{({formState}) => (
|
|
28
|
+
<Fieldset.Root gap="md">
|
|
29
|
+
<Field.Root name="email">
|
|
30
|
+
<Field.Label>Email</Field.Label>
|
|
31
|
+
<Input type="email" />
|
|
32
|
+
<Field.Error />
|
|
33
|
+
</Field.Root>
|
|
34
|
+
<Button type="submit" disabled={!formState.isValid}>Submit</Button>
|
|
35
|
+
</Fieldset.Root>
|
|
36
|
+
)}
|
|
37
|
+
</Form>`,
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|