@fragments-sdk/ui 0.2.3 → 0.4.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 (133) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +9 -4
  3. package/src/components/Accordion/Accordion.fragment.tsx +186 -0
  4. package/src/components/Accordion/Accordion.module.scss +111 -0
  5. package/src/components/Accordion/index.tsx +271 -0
  6. package/src/components/Alert/Alert.fragment.tsx +66 -41
  7. package/src/components/Alert/Alert.module.scss +31 -21
  8. package/src/components/Alert/index.tsx +202 -73
  9. package/src/components/AppShell/AppShell.fragment.tsx +315 -0
  10. package/src/components/AppShell/AppShell.module.scss +213 -0
  11. package/src/components/AppShell/index.tsx +398 -0
  12. package/src/components/Avatar/index.tsx +8 -9
  13. package/src/components/Badge/Badge.module.scss +16 -10
  14. package/src/components/Badge/index.tsx +20 -6
  15. package/src/components/Box/Box.fragment.tsx +168 -0
  16. package/src/components/Box/Box.module.scss +84 -0
  17. package/src/components/Box/index.tsx +78 -0
  18. package/src/components/Button/Button.module.scss +42 -0
  19. package/src/components/Button/index.tsx +67 -33
  20. package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
  21. package/src/components/ButtonGroup/index.tsx +40 -0
  22. package/src/components/Card/Card.fragment.tsx +51 -25
  23. package/src/components/Card/Card.module.scss +52 -5
  24. package/src/components/Card/index.tsx +154 -53
  25. package/src/components/Checkbox/Checkbox.module.scss +4 -4
  26. package/src/components/Checkbox/index.tsx +3 -4
  27. package/src/components/CodeBlock/CodeBlock.fragment.tsx +201 -0
  28. package/src/components/CodeBlock/CodeBlock.module.scss +224 -0
  29. package/src/components/CodeBlock/index.tsx +385 -0
  30. package/src/components/ColorChip/ColorChip.module.scss +165 -0
  31. package/src/components/ColorChip/index.tsx +157 -0
  32. package/src/components/ColorPicker/ColorPicker.module.scss +109 -0
  33. package/src/components/ColorPicker/index.tsx +107 -0
  34. package/src/components/Dialog/Dialog.fragment.tsx +9 -0
  35. package/src/components/Dialog/Dialog.module.scss +26 -7
  36. package/src/components/Dialog/index.tsx +12 -15
  37. package/src/components/EmptyState/EmptyState.fragment.tsx +54 -71
  38. package/src/components/EmptyState/EmptyState.module.scss +9 -9
  39. package/src/components/EmptyState/index.tsx +104 -69
  40. package/src/components/Field/Field.fragment.tsx +165 -0
  41. package/src/components/Field/Field.module.scss +31 -0
  42. package/src/components/Field/index.tsx +143 -0
  43. package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
  44. package/src/components/Fieldset/Fieldset.module.scss +22 -0
  45. package/src/components/Fieldset/index.tsx +47 -0
  46. package/src/components/Form/Form.fragment.tsx +286 -0
  47. package/src/components/Form/Form.module.scss +8 -0
  48. package/src/components/Form/index.tsx +53 -0
  49. package/src/components/Grid/Grid.fragment.tsx +17 -17
  50. package/src/components/Grid/index.tsx +6 -1
  51. package/src/components/Header/Header.fragment.tsx +192 -0
  52. package/src/components/Header/Header.module.scss +209 -0
  53. package/src/components/Header/index.tsx +363 -0
  54. package/src/components/Icon/Icon.fragment.tsx +138 -0
  55. package/src/components/Icon/Icon.module.scss +38 -0
  56. package/src/components/Icon/index.tsx +58 -0
  57. package/src/components/Image/Image.fragment.tsx +195 -0
  58. package/src/components/Image/Image.module.scss +77 -0
  59. package/src/components/Image/index.tsx +95 -0
  60. package/src/components/Input/Input.module.scss +75 -2
  61. package/src/components/Input/index.tsx +60 -21
  62. package/src/components/Link/Link.fragment.tsx +132 -0
  63. package/src/components/Link/Link.module.scss +67 -0
  64. package/src/components/Link/index.tsx +57 -0
  65. package/src/components/List/List.fragment.tsx +152 -0
  66. package/src/components/List/List.module.scss +71 -0
  67. package/src/components/List/index.tsx +106 -0
  68. package/src/components/Listbox/Listbox.fragment.tsx +191 -0
  69. package/src/components/Listbox/Listbox.module.scss +97 -0
  70. package/src/components/Listbox/index.tsx +121 -0
  71. package/src/components/Menu/Menu.fragment.tsx +9 -0
  72. package/src/components/Menu/Menu.module.scss +17 -1
  73. package/src/components/Menu/index.tsx +3 -3
  74. package/src/components/Popover/Popover.fragment.tsx +9 -0
  75. package/src/components/Popover/Popover.module.scss +33 -10
  76. package/src/components/Popover/index.tsx +9 -11
  77. package/src/components/Progress/Progress.module.scss +11 -11
  78. package/src/components/Progress/index.tsx +34 -7
  79. package/src/components/Prompt/Prompt.fragment.tsx +231 -0
  80. package/src/components/Prompt/Prompt.module.scss +243 -0
  81. package/src/components/Prompt/index.tsx +439 -0
  82. package/src/components/RadioGroup/RadioGroup.module.scss +3 -3
  83. package/src/components/RadioGroup/index.tsx +3 -4
  84. package/src/components/Select/Select.fragment.tsx +9 -0
  85. package/src/components/Select/index.tsx +6 -7
  86. package/src/components/Separator/index.tsx +7 -3
  87. package/src/components/Sidebar/Sidebar.fragment.tsx +783 -0
  88. package/src/components/Sidebar/Sidebar.module.scss +586 -0
  89. package/src/components/Sidebar/index.tsx +1013 -0
  90. package/src/components/Skeleton/Skeleton.fragment.tsx +5 -5
  91. package/src/components/Skeleton/Skeleton.module.scss +11 -0
  92. package/src/components/Slider/Slider.module.scss +87 -0
  93. package/src/components/Slider/index.tsx +88 -0
  94. package/src/components/Stack/Stack.module.scss +120 -0
  95. package/src/components/Stack/index.tsx +148 -0
  96. package/src/components/Table/Table.fragment.tsx +7 -0
  97. package/src/components/Table/Table.module.scss +57 -0
  98. package/src/components/Table/index.tsx +44 -6
  99. package/src/components/Tabs/Tabs.fragment.tsx +9 -0
  100. package/src/components/Tabs/Tabs.module.scss +25 -10
  101. package/src/components/Tabs/index.tsx +11 -8
  102. package/src/components/Text/Text.module.scss +82 -0
  103. package/src/components/Text/index.tsx +58 -0
  104. package/src/components/Textarea/index.tsx +3 -7
  105. package/src/components/Theme/Theme.fragment.tsx +128 -0
  106. package/src/components/Theme/ThemeToggle.module.scss +82 -0
  107. package/src/components/Theme/index.tsx +343 -0
  108. package/src/components/Toast/Toast.fragment.tsx +5 -5
  109. package/src/components/Toast/Toast.module.scss +16 -1
  110. package/src/components/Toast/index.tsx +27 -11
  111. package/src/components/Toggle/Toggle.module.scss +25 -10
  112. package/src/components/Toggle/index.tsx +12 -0
  113. package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
  114. package/src/components/ToggleGroup/index.tsx +144 -0
  115. package/src/components/Tooltip/Tooltip.module.scss +4 -4
  116. package/src/components/Tooltip/index.tsx +4 -2
  117. package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
  118. package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
  119. package/src/components/VisuallyHidden/index.tsx +29 -0
  120. package/src/index.ts +241 -3
  121. package/src/recipes/AppShell.recipe.ts +175 -0
  122. package/src/recipes/CardGrid.recipe.ts +6 -2
  123. package/src/recipes/ChatInterface.recipe.ts +87 -0
  124. package/src/recipes/CodeExamples.recipe.ts +66 -0
  125. package/src/recipes/DashboardLayout.recipe.ts +46 -12
  126. package/src/recipes/DashboardNav.recipe.ts +183 -0
  127. package/src/recipes/LoginForm.recipe.ts +8 -1
  128. package/src/recipes/SettingsPage.recipe.ts +37 -20
  129. package/src/styles/globals.scss +31 -0
  130. package/src/tokens/_index.scss +3 -0
  131. package/src/tokens/_mixins.scss +54 -1
  132. package/src/tokens/_variables.scss +429 -64
  133. package/src/utils/a11y.tsx +439 -0
@@ -1,80 +1,115 @@
1
1
  import * as React from 'react';
2
- import { Button } from '../Button';
3
2
  import styles from './EmptyState.module.scss';
4
3
  // Import globals to ensure CSS variables are defined
5
4
  import '../../styles/globals.scss';
6
5
 
7
- export interface EmptyStateAction {
8
- /** Button label */
9
- label: string;
10
- /** Click handler */
11
- onClick: () => void;
12
- /** Button variant */
13
- variant?: 'primary' | 'secondary';
14
- }
6
+ // ============================================
7
+ // Types
8
+ // ============================================
15
9
 
16
- export interface EmptyStateProps {
17
- /** Main heading */
18
- title: string;
19
- /** Supporting description */
20
- description?: string;
21
- /** Optional icon element */
22
- icon?: React.ReactNode;
23
- /** Primary action */
24
- action?: EmptyStateAction;
25
- /** Secondary action */
26
- secondaryAction?: EmptyStateAction;
27
- /** Size variant */
10
+ export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ children: React.ReactNode;
28
12
  size?: 'sm' | 'md' | 'lg';
29
- /** Additional class name */
13
+ }
14
+
15
+ export interface EmptyStateIconProps {
16
+ children: React.ReactNode;
17
+ className?: string;
18
+ }
19
+
20
+ export interface EmptyStateTitleProps {
21
+ children: React.ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ export interface EmptyStateDescriptionProps {
26
+ children: React.ReactNode;
30
27
  className?: string;
31
28
  }
32
29
 
33
- export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
34
- function EmptyState(
35
- {
36
- title,
37
- description,
38
- icon,
39
- action,
40
- secondaryAction,
41
- size = 'md',
42
- className,
43
- },
44
- ref
45
- ) {
46
- const classes = [styles.emptyState, styles[size], className]
47
- .filter(Boolean)
48
- .join(' ');
49
-
50
- return (
51
- <div ref={ref} className={classes}>
52
- {icon && <div className={styles.icon}>{icon}</div>}
53
- <h3 className={styles.title}>{title}</h3>
54
- {description && <p className={styles.description}>{description}</p>}
55
- {(action || secondaryAction) && (
56
- <div className={styles.actions}>
57
- {action && (
58
- <Button
59
- variant={action.variant ?? 'primary'}
60
- onClick={action.onClick}
61
- size={size === 'sm' ? 'sm' : 'md'}
62
- >
63
- {action.label}
64
- </Button>
65
- )}
66
- {secondaryAction && (
67
- <Button
68
- variant={secondaryAction.variant ?? 'secondary'}
69
- onClick={secondaryAction.onClick}
70
- size={size === 'sm' ? 'sm' : 'md'}
71
- >
72
- {secondaryAction.label}
73
- </Button>
74
- )}
75
- </div>
76
- )}
77
- </div>
78
- );
30
+ export interface EmptyStateActionsProps {
31
+ children: React.ReactNode;
32
+ className?: string;
33
+ }
34
+
35
+ // ============================================
36
+ // Context
37
+ // ============================================
38
+
39
+ interface EmptyStateContextValue {
40
+ size: 'sm' | 'md' | 'lg';
41
+ }
42
+
43
+ const EmptyStateContext = React.createContext<EmptyStateContextValue | null>(null);
44
+
45
+ function useEmptyStateContext() {
46
+ const context = React.useContext(EmptyStateContext);
47
+ if (!context) {
48
+ throw new Error('EmptyState compound components must be used within an EmptyState');
79
49
  }
80
- );
50
+ return context;
51
+ }
52
+
53
+ // ============================================
54
+ // Components
55
+ // ============================================
56
+
57
+ function EmptyStateRoot({
58
+ children,
59
+ size = 'md',
60
+ className,
61
+ ...htmlProps
62
+ }: EmptyStateProps) {
63
+ const classes = [styles.emptyState, styles[size], className]
64
+ .filter(Boolean)
65
+ .join(' ');
66
+
67
+ const contextValue: EmptyStateContextValue = { size };
68
+
69
+ return (
70
+ <EmptyStateContext.Provider value={contextValue}>
71
+ <div {...htmlProps} className={classes}>{children}</div>
72
+ </EmptyStateContext.Provider>
73
+ );
74
+ }
75
+
76
+ function EmptyStateIcon({ children, className }: EmptyStateIconProps) {
77
+ const classes = [styles.icon, className].filter(Boolean).join(' ');
78
+ return <div className={classes}>{children}</div>;
79
+ }
80
+
81
+ function EmptyStateTitle({ children, className }: EmptyStateTitleProps) {
82
+ const classes = [styles.title, className].filter(Boolean).join(' ');
83
+ return <h3 className={classes}>{children}</h3>;
84
+ }
85
+
86
+ function EmptyStateDescription({ children, className }: EmptyStateDescriptionProps) {
87
+ const classes = [styles.description, className].filter(Boolean).join(' ');
88
+ return <p className={classes}>{children}</p>;
89
+ }
90
+
91
+ function EmptyStateActions({ children, className }: EmptyStateActionsProps) {
92
+ const classes = [styles.actions, className].filter(Boolean).join(' ');
93
+ return <div className={classes}>{children}</div>;
94
+ }
95
+
96
+ // ============================================
97
+ // Export compound component
98
+ // ============================================
99
+
100
+ export const EmptyState = Object.assign(EmptyStateRoot, {
101
+ Icon: EmptyStateIcon,
102
+ Title: EmptyStateTitle,
103
+ Description: EmptyStateDescription,
104
+ Actions: EmptyStateActions,
105
+ });
106
+
107
+ // Re-export individual components for tree-shaking
108
+ export {
109
+ EmptyStateRoot,
110
+ EmptyStateIcon,
111
+ EmptyStateTitle,
112
+ EmptyStateDescription,
113
+ EmptyStateActions,
114
+ useEmptyStateContext,
115
+ };
@@ -0,0 +1,165 @@
1
+ import React from 'react';
2
+ import { defineSegment } from '@fragments/core';
3
+ import { Field } from './index.js';
4
+ import { Input } from '../Input/index.js';
5
+ import { Grid } from '../Grid/index.js';
6
+
7
+ export default defineSegment({
8
+ component: Field,
9
+
10
+ meta: {
11
+ name: 'Field',
12
+ description: 'Compositional form field wrapper providing validation, labels, descriptions, and error messages. Use for advanced form needs beyond baked-in Input/Textarea props.',
13
+ category: 'forms',
14
+ status: 'stable',
15
+ tags: ['form', 'field', 'validation', 'label', 'error', 'input', 'accessible'],
16
+ since: '0.4.0',
17
+ },
18
+
19
+ usage: {
20
+ when: [
21
+ 'You need granular validation with match-based error messages',
22
+ 'Custom form controls need accessible labels and descriptions',
23
+ 'Server-side errors need to be distributed to specific fields',
24
+ 'You need dirty/touched tracking or custom validation logic',
25
+ ],
26
+ whenNot: [
27
+ 'Simple inputs with basic label and helper text (use Input with label prop)',
28
+ 'Standalone selects or textareas with built-in error display',
29
+ ],
30
+ guidelines: [
31
+ 'Always provide a Field.Label for accessibility',
32
+ 'Wrap any form control in Field.Control to connect it to the field context',
33
+ 'Use match prop on Field.Error for granular native validation messages',
34
+ 'Wrap in Form to enable server-side error distribution by field name',
35
+ ],
36
+ accessibility: [
37
+ 'Label automatically linked to control via aria-labelledby',
38
+ 'Description linked via aria-describedby',
39
+ 'Error messages announced to screen readers',
40
+ 'Supports data-disabled and data-invalid attributes for styling',
41
+ ],
42
+ },
43
+
44
+ props: {
45
+ name: {
46
+ type: 'string',
47
+ description: 'Field name, used for error distribution from Form',
48
+ },
49
+ disabled: {
50
+ type: 'boolean',
51
+ description: 'Disables the field and its control',
52
+ },
53
+ invalid: {
54
+ type: 'boolean',
55
+ description: 'Marks the field as invalid',
56
+ },
57
+ validate: {
58
+ type: 'function',
59
+ description: 'Custom validation function returning error string(s) or null',
60
+ },
61
+ validationMode: {
62
+ type: 'enum',
63
+ description: 'When to trigger validation',
64
+ values: ['onSubmit', 'onBlur', 'onChange'],
65
+ },
66
+ validationDebounceTime: {
67
+ type: 'number',
68
+ description: 'Debounce time in ms for onChange validation',
69
+ },
70
+ className: {
71
+ type: 'string',
72
+ description: 'Additional CSS class',
73
+ },
74
+ },
75
+
76
+ relations: [
77
+ { component: 'Input', relationship: 'alternative', note: 'Use Input for simple fields with built-in label/error' },
78
+ { component: 'Form', relationship: 'parent', note: 'Wrap in Form for server-side error distribution' },
79
+ { component: 'Fieldset', relationship: 'sibling', note: 'Use Fieldset to group related fields' },
80
+ ],
81
+
82
+ contract: {
83
+ propsSummary: [
84
+ 'name: string - field name for error distribution',
85
+ 'validate: (value) => string | null - custom validation',
86
+ 'validationMode: onSubmit|onBlur|onChange - validation trigger',
87
+ 'Field.Control: wraps any form component (Input, Textarea, etc.)',
88
+ 'Field.Error match: valueMissing|typeMismatch|customError|... - granular errors',
89
+ ],
90
+ scenarioTags: [
91
+ 'form.field',
92
+ 'form.validation',
93
+ 'form.accessible',
94
+ ],
95
+ a11yRules: ['A11Y_FIELD_LABEL', 'A11Y_FIELD_ERROR', 'A11Y_FIELD_DESCRIPTION'],
96
+ },
97
+
98
+ variants: [
99
+ {
100
+ name: 'Single field',
101
+ description: 'A single field with label, control, and description',
102
+ render: () => (
103
+ <Field name="email">
104
+ <Field.Label>Email address</Field.Label>
105
+ <Field.Control>
106
+ <Input type="email" placeholder="jane@example.com" />
107
+ </Field.Control>
108
+ <Field.Description>We will never share your email.</Field.Description>
109
+ </Field>
110
+ ),
111
+ },
112
+ {
113
+ name: 'Two-column layout',
114
+ description: 'Fields arranged in a two-column grid',
115
+ render: () => (
116
+ <Grid columns={2} gap="md">
117
+ <Field name="firstName">
118
+ <Field.Label>First Name</Field.Label>
119
+ <Field.Control>
120
+ <Input placeholder="Jane" />
121
+ </Field.Control>
122
+ </Field>
123
+ <Field name="lastName">
124
+ <Field.Label>Last Name</Field.Label>
125
+ <Field.Control>
126
+ <Input placeholder="Doe" />
127
+ </Field.Control>
128
+ </Field>
129
+ <Grid.Item colSpan="full">
130
+ <Field name="email">
131
+ <Field.Label>Email</Field.Label>
132
+ <Field.Control>
133
+ <Input type="email" placeholder="jane@example.com" />
134
+ </Field.Control>
135
+ <Field.Error match="typeMismatch">Enter a valid email address</Field.Error>
136
+ </Field>
137
+ </Grid.Item>
138
+ </Grid>
139
+ ),
140
+ },
141
+ {
142
+ name: 'Custom validation',
143
+ description: 'Field with custom validate function',
144
+ render: () => (
145
+ <Field
146
+ name="age"
147
+ validate={(value) => {
148
+ const num = Number(value);
149
+ if (isNaN(num) || num < 18) return 'Must be 18 or older';
150
+ return null;
151
+ }}
152
+ validationMode="onChange"
153
+ validationDebounceTime={500}
154
+ >
155
+ <Field.Label>Age</Field.Label>
156
+ <Field.Control>
157
+ <Input type="number" placeholder="18" />
158
+ </Field.Control>
159
+ <Field.Description>You must be at least 18 years old.</Field.Description>
160
+ <Field.Error match="customError" />
161
+ </Field>
162
+ ),
163
+ },
164
+ ],
165
+ });
@@ -0,0 +1,31 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ .root {
5
+ display: flex;
6
+ flex-direction: column;
7
+ }
8
+
9
+ .label {
10
+ @include label-text;
11
+ margin-bottom: var(--fui-space-1, $fui-space-1);
12
+
13
+ &[data-disabled] {
14
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
15
+ }
16
+ }
17
+
18
+ .control {
19
+ // Minimal styles — users bring styled controls via render prop
20
+ }
21
+
22
+ .description {
23
+ @include helper-text;
24
+ margin-top: var(--fui-space-1, $fui-space-1);
25
+ }
26
+
27
+ .error {
28
+ @include helper-text;
29
+ margin-top: var(--fui-space-1, $fui-space-1);
30
+ color: var(--fui-color-danger, $fui-color-danger);
31
+ }
@@ -0,0 +1,143 @@
1
+ import * as React from 'react';
2
+ import { Field as BaseField, type FieldValidityState } from '@base-ui/react/field';
3
+ import styles from './Field.module.scss';
4
+ import '../../styles/globals.scss';
5
+
6
+ // ============================================
7
+ // Types
8
+ // ============================================
9
+
10
+ export interface FieldProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ children: React.ReactNode;
12
+ name?: string;
13
+ disabled?: boolean;
14
+ invalid?: boolean;
15
+ validate?: (value: unknown) => string | string[] | null | Promise<string | string[] | null>;
16
+ validationMode?: 'onSubmit' | 'onBlur' | 'onChange';
17
+ validationDebounceTime?: number;
18
+ }
19
+
20
+ export interface FieldLabelProps {
21
+ children: React.ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ export interface FieldControlProps {
26
+ children: React.ReactElement;
27
+ className?: string;
28
+ }
29
+
30
+ export interface FieldDescriptionProps {
31
+ children: React.ReactNode;
32
+ className?: string;
33
+ }
34
+
35
+ export interface FieldErrorProps {
36
+ children?: React.ReactNode;
37
+ match?: 'valueMissing' | 'typeMismatch' | 'tooShort' | 'tooLong' | 'patternMismatch' | 'customError' | boolean;
38
+ className?: string;
39
+ }
40
+
41
+ export interface FieldValidityProps {
42
+ children: (state: FieldValidityState) => React.ReactNode;
43
+ }
44
+
45
+ export type { FieldValidityState };
46
+
47
+ // ============================================
48
+ // Components
49
+ // ============================================
50
+
51
+ function FieldRoot({
52
+ children,
53
+ name,
54
+ disabled,
55
+ invalid,
56
+ validate,
57
+ validationMode,
58
+ validationDebounceTime,
59
+ className,
60
+ ...htmlProps
61
+ }: FieldProps) {
62
+ const classes = [styles.root, className].filter(Boolean).join(' ');
63
+
64
+ return (
65
+ <BaseField.Root
66
+ {...htmlProps}
67
+ name={name}
68
+ disabled={disabled}
69
+ invalid={invalid}
70
+ validate={validate}
71
+ validationMode={validationMode}
72
+ validationDebounceTime={validationDebounceTime}
73
+ className={classes}
74
+ >
75
+ {children}
76
+ </BaseField.Root>
77
+ );
78
+ }
79
+
80
+ function FieldLabel({ children, className }: FieldLabelProps) {
81
+ const classes = [styles.label, className].filter(Boolean).join(' ');
82
+ return <BaseField.Label className={classes}>{children}</BaseField.Label>;
83
+ }
84
+
85
+ /**
86
+ * Connects any child component to the Field context.
87
+ * Wraps the child with Base UI's Field.Control via the `render` prop,
88
+ * which merges aria attributes and field state onto the child element.
89
+ *
90
+ * Works with Input, Textarea, Checkbox, Toggle, Select, or any element.
91
+ */
92
+ function FieldControl({ children, className }: FieldControlProps) {
93
+ const classes = [styles.control, className].filter(Boolean).join(' ');
94
+ return (
95
+ <BaseField.Control
96
+ className={classes}
97
+ render={children}
98
+ />
99
+ );
100
+ }
101
+
102
+ function FieldDescription({ children, className }: FieldDescriptionProps) {
103
+ const classes = [styles.description, className].filter(Boolean).join(' ');
104
+ return (
105
+ <BaseField.Description className={classes}>
106
+ {children}
107
+ </BaseField.Description>
108
+ );
109
+ }
110
+
111
+ function FieldError({ children, match, className }: FieldErrorProps) {
112
+ const classes = [styles.error, className].filter(Boolean).join(' ');
113
+ return (
114
+ <BaseField.Error match={match} className={classes}>
115
+ {children}
116
+ </BaseField.Error>
117
+ );
118
+ }
119
+
120
+ function FieldValidity({ children }: FieldValidityProps) {
121
+ return <BaseField.Validity>{children}</BaseField.Validity>;
122
+ }
123
+
124
+ // ============================================
125
+ // Export compound component
126
+ // ============================================
127
+
128
+ export const Field = Object.assign(FieldRoot, {
129
+ Label: FieldLabel,
130
+ Control: FieldControl,
131
+ Description: FieldDescription,
132
+ Error: FieldError,
133
+ Validity: FieldValidity,
134
+ });
135
+
136
+ export {
137
+ FieldRoot,
138
+ FieldLabel,
139
+ FieldControl,
140
+ FieldDescription,
141
+ FieldError,
142
+ FieldValidity,
143
+ };
@@ -0,0 +1,166 @@
1
+ import React from 'react';
2
+ import { defineSegment } from '@fragments/core';
3
+ import { Fieldset } from './index.js';
4
+ import { Field } from '../Field/index.js';
5
+ import { Input } from '../Input/index.js';
6
+ import { Textarea } from '../Textarea/index.js';
7
+ import { Select } from '../Select/index.js';
8
+ import { Checkbox } from '../Checkbox/index.js';
9
+ import { Grid } from '../Grid/index.js';
10
+
11
+ export default defineSegment({
12
+ component: Fieldset,
13
+
14
+ meta: {
15
+ name: 'Fieldset',
16
+ description: 'Groups related form fields with an accessible legend. Use to organize forms into logical sections.',
17
+ category: 'forms',
18
+ status: 'stable',
19
+ tags: ['form', 'fieldset', 'group', 'legend', 'accessible'],
20
+ since: '0.4.0',
21
+ },
22
+
23
+ usage: {
24
+ when: [
25
+ 'Grouping related fields in a form (e.g., address, personal info)',
26
+ 'Disabling a group of fields together',
27
+ 'Providing an accessible group label for screen readers',
28
+ ],
29
+ whenNot: [
30
+ 'Generic visual grouping (use Card)',
31
+ 'Single field wrapping (use Field)',
32
+ ],
33
+ guidelines: [
34
+ 'Always include a Fieldset.Legend for accessibility',
35
+ 'Use disabled prop to disable all fields within the group',
36
+ 'Use Grid inside Fieldset for multi-column layouts',
37
+ ],
38
+ accessibility: [
39
+ 'Renders semantic fieldset element',
40
+ 'Legend provides accessible group label',
41
+ 'Disabled state propagates to all child fields',
42
+ ],
43
+ },
44
+
45
+ props: {
46
+ disabled: {
47
+ type: 'boolean',
48
+ description: 'Disables all fields within the fieldset',
49
+ },
50
+ className: {
51
+ type: 'string',
52
+ description: 'Additional CSS class',
53
+ },
54
+ },
55
+
56
+ relations: [
57
+ { component: 'Field', relationship: 'sibling', note: 'Contains Field components' },
58
+ { component: 'Form', relationship: 'parent', note: 'Used within a Form' },
59
+ { component: 'Card', relationship: 'alternative', note: 'Use Card for non-form visual grouping' },
60
+ ],
61
+
62
+ contract: {
63
+ propsSummary: [
64
+ 'disabled: boolean - disables all child fields',
65
+ 'Fieldset.Legend: accessible group label',
66
+ ],
67
+ scenarioTags: ['form.group', 'form.fieldset'],
68
+ a11yRules: ['A11Y_FIELDSET_LEGEND'],
69
+ },
70
+
71
+ variants: [
72
+ {
73
+ name: 'Two-column layout',
74
+ description: 'Fieldset with Grid for side-by-side fields',
75
+ render: () => (
76
+ <Fieldset>
77
+ <Fieldset.Legend>Personal Information</Fieldset.Legend>
78
+ <Grid columns={2} gap="md">
79
+ <Field name="firstName">
80
+ <Field.Label>First Name</Field.Label>
81
+ <Field.Control>
82
+ <Input placeholder="Jane" />
83
+ </Field.Control>
84
+ </Field>
85
+ <Field name="lastName">
86
+ <Field.Label>Last Name</Field.Label>
87
+ <Field.Control>
88
+ <Input placeholder="Doe" />
89
+ </Field.Control>
90
+ </Field>
91
+ <Grid.Item colSpan="full">
92
+ <Field name="email">
93
+ <Field.Label>Email</Field.Label>
94
+ <Field.Control>
95
+ <Input type="email" placeholder="jane@example.com" />
96
+ </Field.Control>
97
+ </Field>
98
+ </Grid.Item>
99
+ </Grid>
100
+ </Fieldset>
101
+ ),
102
+ },
103
+ {
104
+ name: 'Mixed controls',
105
+ description: 'Fieldset with textarea, select, and checkbox',
106
+ render: () => (
107
+ <Fieldset>
108
+ <Fieldset.Legend>Preferences</Fieldset.Legend>
109
+ <Field name="bio">
110
+ <Field.Label>Bio</Field.Label>
111
+ <Field.Control>
112
+ <Textarea placeholder="Tell us about yourself" rows={3} />
113
+ </Field.Control>
114
+ <Field.Description>Brief description for your profile.</Field.Description>
115
+ </Field>
116
+ <Field name="role">
117
+ <Field.Label>Role</Field.Label>
118
+ <Field.Control>
119
+ <Select placeholder="Select a role">
120
+ <Select.Trigger />
121
+ <Select.Content>
122
+ <Select.Item value="admin">Admin</Select.Item>
123
+ <Select.Item value="editor">Editor</Select.Item>
124
+ <Select.Item value="viewer">Viewer</Select.Item>
125
+ </Select.Content>
126
+ </Select>
127
+ </Field.Control>
128
+ </Field>
129
+ <Field name="newsletter">
130
+ <Field.Control>
131
+ <Checkbox label="Subscribe to newsletter" />
132
+ </Field.Control>
133
+ </Field>
134
+ </Fieldset>
135
+ ),
136
+ },
137
+ {
138
+ name: 'Disabled',
139
+ description: 'Disabled fieldset prevents interaction with all children',
140
+ render: () => (
141
+ <Fieldset disabled>
142
+ <Fieldset.Legend>Locked Section</Fieldset.Legend>
143
+ <Grid columns={2} gap="md">
144
+ <Field name="lockedFirst">
145
+ <Field.Label>First Name</Field.Label>
146
+ <Field.Control>
147
+ <Input defaultValue="Jane" />
148
+ </Field.Control>
149
+ </Field>
150
+ <Field name="lockedLast">
151
+ <Field.Label>Last Name</Field.Label>
152
+ <Field.Control>
153
+ <Input defaultValue="Doe" />
154
+ </Field.Control>
155
+ </Field>
156
+ </Grid>
157
+ <Field name="lockedCheck">
158
+ <Field.Control>
159
+ <Checkbox label="Cannot toggle" defaultChecked />
160
+ </Field.Control>
161
+ </Field>
162
+ </Fieldset>
163
+ ),
164
+ },
165
+ ],
166
+ });