@rovula/ui 0.1.20 → 0.1.22

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 (99) hide show
  1. package/dist/cjs/bundle.css +316 -43
  2. package/dist/cjs/bundle.js +675 -675
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/Badge/Badge.d.ts +40 -0
  5. package/dist/cjs/types/components/Badge/Badge.stories.d.ts +295 -0
  6. package/dist/cjs/types/components/Badge/Badge.styles.d.ts +7 -0
  7. package/dist/cjs/types/components/Badge/index.d.ts +2 -0
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +4 -8
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  10. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  11. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.stories.d.ts +45 -30
  12. package/dist/cjs/types/components/Form/Form.d.ts +2 -1
  13. package/dist/cjs/types/components/Form/Form.stories.d.ts +4 -0
  14. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  15. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  16. package/dist/cjs/types/index.d.ts +4 -1
  17. package/dist/cjs/types/patterns/menu/Menu.d.ts +70 -0
  18. package/dist/cjs/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +17 -10
  19. package/dist/cjs/types/utils/mergeRefs.d.ts +20 -0
  20. package/dist/components/Avatar/Avatar.styles.js +2 -2
  21. package/dist/components/Badge/Badge.js +36 -0
  22. package/dist/components/Badge/Badge.stories.js +51 -0
  23. package/dist/components/Badge/Badge.styles.js +62 -0
  24. package/dist/components/Badge/index.js +2 -0
  25. package/dist/components/Dropdown/Dropdown.js +54 -163
  26. package/dist/components/Dropdown/Dropdown.stories.js +29 -0
  27. package/dist/components/DropdownMenu/DropdownMenu.js +24 -13
  28. package/dist/components/DropdownMenu/DropdownMenu.stories.js +120 -88
  29. package/dist/components/Form/Form.js +11 -4
  30. package/dist/components/Form/Form.stories.js +27 -0
  31. package/dist/components/ScrollArea/ScrollArea.js +50 -0
  32. package/dist/components/ScrollArea/ScrollArea.stories.js +56 -0
  33. package/dist/components/TextInput/TextInput.js +6 -3
  34. package/dist/esm/bundle.css +316 -43
  35. package/dist/esm/bundle.js +1545 -1545
  36. package/dist/esm/bundle.js.map +1 -1
  37. package/dist/esm/types/components/Badge/Badge.d.ts +40 -0
  38. package/dist/esm/types/components/Badge/Badge.stories.d.ts +295 -0
  39. package/dist/esm/types/components/Badge/Badge.styles.d.ts +7 -0
  40. package/dist/esm/types/components/Badge/index.d.ts +2 -0
  41. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +4 -8
  42. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  43. package/dist/esm/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  44. package/dist/esm/types/components/DropdownMenu/DropdownMenu.stories.d.ts +45 -30
  45. package/dist/esm/types/components/Form/Form.d.ts +2 -1
  46. package/dist/esm/types/components/Form/Form.stories.d.ts +4 -0
  47. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  48. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  49. package/dist/esm/types/index.d.ts +4 -1
  50. package/dist/esm/types/patterns/menu/Menu.d.ts +70 -0
  51. package/dist/esm/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +17 -10
  52. package/dist/esm/types/utils/mergeRefs.d.ts +20 -0
  53. package/dist/index.d.ts +156 -74
  54. package/dist/index.js +3 -1
  55. package/dist/patterns/menu/Menu.js +95 -0
  56. package/dist/patterns/menu/Menu.stories.js +611 -0
  57. package/dist/src/theme/global.css +485 -57
  58. package/dist/utils/mergeRefs.js +42 -0
  59. package/package.json +1 -1
  60. package/src/components/Avatar/Avatar.styles.ts +2 -2
  61. package/src/components/Badge/Badge.stories.tsx +128 -0
  62. package/src/components/Badge/Badge.styles.ts +70 -0
  63. package/src/components/Badge/Badge.tsx +103 -0
  64. package/src/components/Badge/index.ts +3 -0
  65. package/src/components/Dropdown/Dropdown.stories.tsx +170 -1
  66. package/src/components/Dropdown/Dropdown.tsx +186 -276
  67. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +1375 -253
  68. package/src/components/DropdownMenu/DropdownMenu.tsx +118 -55
  69. package/src/components/Form/Form.stories.tsx +70 -0
  70. package/src/components/Form/Form.tsx +23 -0
  71. package/src/components/ScrollArea/ScrollArea.stories.tsx +229 -0
  72. package/src/components/ScrollArea/ScrollArea.tsx +72 -0
  73. package/src/components/TextInput/TextInput.tsx +6 -3
  74. package/src/index.ts +4 -1
  75. package/src/patterns/menu/Menu.stories.tsx +1100 -0
  76. package/src/patterns/menu/Menu.tsx +282 -0
  77. package/src/theme/global.css +84 -11
  78. package/src/theme/themes/xspector/baseline.css +1 -1
  79. package/src/theme/themes/xspector/components/scrollbar.css +12 -0
  80. package/src/theme/tokens/baseline.css +3 -1
  81. package/src/theme/tokens/components/badge.css +54 -0
  82. package/src/theme/tokens/components/dropdown-menu.css +16 -5
  83. package/src/theme/tokens/components/scrollbar.css +18 -0
  84. package/src/utils/mergeRefs.ts +46 -0
  85. package/dist/cjs/types/components/Menu/Menu.d.ts +0 -65
  86. package/dist/cjs/types/components/Menu/helpers.d.ts +0 -19
  87. package/dist/cjs/types/components/Menu/index.d.ts +0 -4
  88. package/dist/components/Menu/Menu.js +0 -64
  89. package/dist/components/Menu/Menu.stories.js +0 -406
  90. package/dist/components/Menu/helpers.js +0 -28
  91. package/dist/components/Menu/index.js +0 -3
  92. package/dist/esm/types/components/Menu/Menu.d.ts +0 -65
  93. package/dist/esm/types/components/Menu/helpers.d.ts +0 -19
  94. package/dist/esm/types/components/Menu/index.d.ts +0 -4
  95. package/src/components/Menu/Menu.stories.tsx +0 -586
  96. package/src/components/Menu/Menu.tsx +0 -235
  97. package/src/components/Menu/helpers.ts +0 -45
  98. package/src/components/Menu/index.ts +0 -7
  99. package/src/theme/themes/xspector/components/dropdown-menu.css +0 -28
@@ -27,14 +27,13 @@ const DropdownMenuSubTrigger = React.forwardRef<
27
27
  <DropdownMenuPrimitive.SubTrigger
28
28
  ref={ref}
29
29
  className={cn(
30
- // "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-primary data-[state=open]:bg-primary",
31
- "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-9 pr-4 typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
30
+ "relative flex gap-4 cursor-pointer select-none box-border items-center py-4 pl-4 pr-4 typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
32
31
  "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
33
32
  "active:opacity-75",
34
33
  "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
35
34
  "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-disabled-text)]",
36
35
  inset && "pl-8",
37
- className
36
+ className,
38
37
  )}
39
38
  {...props}
40
39
  >
@@ -52,8 +51,8 @@ const DropdownMenuSubContent = React.forwardRef<
52
51
  <DropdownMenuPrimitive.SubContent
53
52
  ref={ref}
54
53
  className={cn(
55
- "z-50 min-w-[154px] overflow-hidden rounded-md bg-modal-surface text-text-contrast-low data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
56
- className
54
+ "z-50 min-w-[154px] overflow-hidden rounded-lg bg-modal-surface text-text-contrast-low data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
55
+ className,
57
56
  )}
58
57
  {...props}
59
58
  style={{
@@ -74,8 +73,8 @@ const DropdownMenuContent = React.forwardRef<
74
73
  ref={ref}
75
74
  sideOffset={sideOffset}
76
75
  className={cn(
77
- "z-50 min-w-[154px] overflow-hidden rounded-md bg-modal-surface text-text-contrast-low data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
78
- className
76
+ "z-50 min-w-[154px] overflow-hidden rounded-lg bg-modal-surface text-text-contrast-low data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
77
+ className,
79
78
  )}
80
79
  {...props}
81
80
  style={{
@@ -91,45 +90,88 @@ const DropdownMenuItem = React.forwardRef<
91
90
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
92
91
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
93
92
  inset?: boolean;
93
+ selected?: boolean;
94
+ icon?: React.ReactNode;
94
95
  }
95
- >(({ className, inset, ...props }, ref) => (
96
- <DropdownMenuPrimitive.Item
97
- ref={ref}
98
- className={cn(
99
- "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-9 pr-xxl typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
100
- "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
101
- "active:opacity-75",
102
- "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
103
- "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-disabled-text)]",
104
- inset && "pl-8",
105
- className
106
- )}
107
- {...props}
108
- />
109
- ));
96
+ >(({ className, inset, selected, icon, children, ...props }, ref) => {
97
+ const hasIcon = !!icon;
98
+ return (
99
+ <DropdownMenuPrimitive.Item
100
+ ref={ref}
101
+ className={cn(
102
+ "relative flex cursor-pointer select-none box-border items-center py-4 pl-4 pr-8 typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
103
+ "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
104
+ "active:opacity-75",
105
+ "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
106
+ selected &&
107
+ "bg-[var(--dropdown-menu-selected-bg)] text-[var(--dropdown-menu-selected-text)] typography-subtitle5",
108
+ "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-disabled-text)]",
109
+ inset && "pl-8",
110
+ className,
111
+ hasIcon ? "gap-4" : "gap-1",
112
+ )}
113
+ {...props}
114
+ >
115
+ <div className="flex shrink-0 flex-row gap-1">
116
+ <span className="size-4 flex items-center justify-center">
117
+ {selected && (
118
+ <Icon
119
+ type="heroicons"
120
+ name="check"
121
+ className="size-4 text-[var(--dropdown-menu-selected-text)]"
122
+ />
123
+ )}
124
+ </span>
125
+ {icon}
126
+ </div>
127
+ {children}
128
+ </DropdownMenuPrimitive.Item>
129
+ );
130
+ });
110
131
  DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
111
132
 
112
133
  const DropdownMenuCheckboxItem = React.forwardRef<
113
134
  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
114
135
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
115
- >(({ className, children, checked, ...props }, ref) => (
136
+ >(({ className, children, checked, disabled, ...props }, ref) => (
116
137
  <DropdownMenuPrimitive.CheckboxItem
117
138
  ref={ref}
118
139
  className={cn(
119
- "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-9 pr-xxl typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
140
+ "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-4 pr-8 typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
120
141
  "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
121
142
  "active:opacity-75",
122
143
  "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
123
- "data-[state='checked']:bg-[var(--dropdown-menu-selected-bg)] data-[state='checked']:text-[var(--dropdown-menu-selected-text)] data-[state='checked']:typography-subtitle5",
144
+ "data-[state='checked']:bg-[var(--dropdown-menu-selected-bg)] data-[state='checked']:text-[var(--dropdown-menu-selected-text)]",
124
145
  "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-disabled-text)]",
125
- className
146
+ className,
126
147
  )}
127
148
  checked={checked}
149
+ disabled={disabled}
128
150
  {...props}
129
151
  >
130
- <span className="absolute left-4 flex items-center justify-center">
152
+ <span
153
+ className={cn(
154
+ "shrink-0 size-4 rounded-[2px] border flex items-center justify-center transition-all overflow-hidden",
155
+ checked &&
156
+ !disabled &&
157
+ "bg-[var(--dropdown-menu-checkbox-checked-bg)] border-[var(--dropdown-menu-checkbox-checked-bg)]",
158
+ checked &&
159
+ disabled &&
160
+ "bg-[var(--dropdown-menu-checkbox-disabled-checked-bg)] border-transparent",
161
+ !checked &&
162
+ disabled &&
163
+ "border-[var(--dropdown-menu-checkbox-disabled-border)]",
164
+ !checked &&
165
+ !disabled &&
166
+ "border-[var(--dropdown-menu-checkbox-border)]",
167
+ )}
168
+ >
131
169
  <DropdownMenuPrimitive.ItemIndicator>
132
- <Icon type="heroicons" name="check" className="size-4" />
170
+ <Icon
171
+ type="heroicons"
172
+ name="check"
173
+ className="size-3 text-[var(--dropdown-menu-checkbox-checked-icon)]"
174
+ />
133
175
  </DropdownMenuPrimitive.ItemIndicator>
134
176
  </span>
135
177
  {children}
@@ -140,29 +182,50 @@ DropdownMenuCheckboxItem.displayName =
140
182
 
141
183
  const DropdownMenuRadioItem = React.forwardRef<
142
184
  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
143
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
144
- >(({ className, children, ...props }, ref) => (
145
- <DropdownMenuPrimitive.RadioItem
146
- ref={ref}
147
- className={cn(
148
- "relative flex gap-3 cursor-pointer select-none box-border items-center py-4 pl-9 pr-xxl typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
149
- "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
150
- "active:opacity-75",
151
- "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
152
- "data-[state='checked']:bg-[var(--dropdown-menu-selected-bg)] data-[state='checked']:text-[var(--dropdown-menu-selected-text)] data-[state='checked']:typography-subtitle5",
153
- "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-disabled-text)]",
154
- className
155
- )}
156
- {...props}
157
- >
158
- <span className="absolute left-4 flex items-center justify-center">
159
- <DropdownMenuPrimitive.ItemIndicator>
160
- <Icon type="heroicons" name="circle" className="h-2 w-2 fill-current" />
161
- </DropdownMenuPrimitive.ItemIndicator>
162
- </span>
163
- {children}
164
- </DropdownMenuPrimitive.RadioItem>
165
- ));
185
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
186
+ icon?: React.ReactNode;
187
+ }
188
+ >(({ className, children, disabled, icon, ...props }, ref) => {
189
+ const hasIconSlot = !!icon;
190
+
191
+ return (
192
+ <DropdownMenuPrimitive.RadioItem
193
+ ref={ref}
194
+ className={cn(
195
+ "relative flex cursor-pointer select-none box-border items-center py-4 pl-4 pr-8 typography-subtitle4 outline-none transition-colors data-[disabled]:pointer-events-none",
196
+ "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
197
+ "active:opacity-75",
198
+ "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
199
+ "data-[state='checked']:bg-[var(--dropdown-menu-selected-bg)] data-[state='checked']:text-[var(--dropdown-menu-selected-text)]",
200
+ "data-[disabled]:!bg-[var(--dropdown-menu-disabled-bg)] data-[disabled]:!text-[var(--dropdown-menu-radio-disabled-text)]",
201
+ "data-[state='checked']:data-[disabled]:!text-[var(--dropdown-menu-radio-selected-disabled-text)]",
202
+ className,
203
+ hasIconSlot ? "gap-4" : "gap-1",
204
+ )}
205
+ {...props}
206
+ disabled={disabled}
207
+ >
208
+ <div className="flex shrink-0 flex-row gap-1">
209
+ <span className="size-4">
210
+ <DropdownMenuPrimitive.ItemIndicator className="shrink-0">
211
+ <Icon
212
+ type="heroicons"
213
+ name="check"
214
+ className={cn(
215
+ "size-4",
216
+ disabled
217
+ ? "text-[var(--dropdown-menu-radio-selected-disabled-text)]"
218
+ : "text-[var(--dropdown-menu-selected-text)]",
219
+ )}
220
+ />
221
+ </DropdownMenuPrimitive.ItemIndicator>
222
+ </span>
223
+ {icon}
224
+ </div>
225
+ {children}
226
+ </DropdownMenuPrimitive.RadioItem>
227
+ );
228
+ });
166
229
  DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
167
230
 
168
231
  const DropdownMenuLabel = React.forwardRef<
@@ -174,9 +237,9 @@ const DropdownMenuLabel = React.forwardRef<
174
237
  <DropdownMenuPrimitive.Label
175
238
  ref={ref}
176
239
  className={cn(
177
- "px-3 py-2 typography-small4 text-text-g-contrast-medium",
240
+ "px-3 py-2 typography-small4 text-text-g-contrast-high",
178
241
  inset && "pl-8",
179
- className
242
+ className,
180
243
  )}
181
244
  {...props}
182
245
  />
@@ -190,8 +253,8 @@ const DropdownMenuSeparator = React.forwardRef<
190
253
  <DropdownMenuPrimitive.Separator
191
254
  ref={ref}
192
255
  className={cn(
193
- "-mx-2 my-2 h-px bg-[var(--dropdown-menu-seperator-bg)]",
194
- className
256
+ "h-px bg-[var(--dropdown-menu-seperator-bg)]",
257
+ className,
195
258
  )}
196
259
  {...props}
197
260
  />
@@ -362,6 +362,76 @@ export const RenderPropsCodeControl = {
362
362
  },
363
363
  } satisfies Story;
364
364
 
365
+ export const FormStateChangeCallback = {
366
+ args: {},
367
+ render: () => {
368
+ const [isFormValid, setIsFormValid] = useState(false);
369
+ const [isDirty, setIsDirty] = useState(false);
370
+ const [submitCount, setSubmitCount] = useState(0);
371
+
372
+ const schema = yup.object({
373
+ title: yup.string().required("Title is required"),
374
+ description: yup
375
+ .string()
376
+ .required("Description is required")
377
+ .min(10, "Description must be at least 10 characters"),
378
+ });
379
+
380
+ return (
381
+ <div className="flex flex-col gap-4">
382
+ {/* Simulates a parent component (e.g. modal footer) that controls the submit button */}
383
+ <div className="rounded-md border border-bg-stroke1 bg-bg-bg2 p-3 text-xs text-text-g-contrast-medium space-y-1">
384
+ <div>
385
+ Validity:{" "}
386
+ <span className={isFormValid ? "text-state-success-default" : "text-state-error-default"}>
387
+ {isFormValid ? "valid" : "invalid"}
388
+ </span>
389
+ </div>
390
+ <div>Dirty: {isDirty ? "yes" : "no"}</div>
391
+ <div>Submit count: {submitCount}</div>
392
+ </div>
393
+
394
+ <Form<CodeControlFormValues>
395
+ className="flex flex-col gap-3"
396
+ defaultValues={{ title: "", description: "" }}
397
+ mode="onTouched"
398
+ reValidateMode="onChange"
399
+ validationSchema={schema}
400
+ onSubmit={(values) => {
401
+ setSubmitCount((prev) => prev + 1);
402
+ // eslint-disable-next-line no-console
403
+ console.log("Submitted:", values);
404
+ }}
405
+ onFormStateChange={(formState) => {
406
+ setIsFormValid(formState.isValid);
407
+ setIsDirty(formState.isDirty);
408
+ }}
409
+ >
410
+ <Field<CodeControlFormValues, "title">
411
+ name="title"
412
+ component={TextInput}
413
+ componentProps={{ label: "Title", required: true }}
414
+ />
415
+ <Field<CodeControlFormValues, "description">
416
+ name="description"
417
+ component={TextInput}
418
+ componentProps={{
419
+ label: "Description",
420
+ helperText: "At least 10 characters",
421
+ required: true,
422
+ }}
423
+ />
424
+
425
+ {/* Submit button lives inside Form — receives isFormValid from parent state */}
426
+ <Button type="submit" disabled={!isFormValid}>
427
+ Submit
428
+ </Button>
429
+ </Form>
430
+ </div>
431
+ );
432
+ },
433
+ } satisfies Story;
434
+
365
435
  export const HigherLayerCodeControl = {
366
436
  args: {},
367
437
  render: () => {
@@ -3,11 +3,13 @@ import {
3
3
  DefaultValues,
4
4
  FieldValues,
5
5
  FormProvider,
6
+ FormState,
6
7
  Mode,
7
8
  Resolver,
8
9
  SubmitErrorHandler,
9
10
  SubmitHandler,
10
11
  useForm,
12
+ useFormContext,
11
13
  UseFormReturn,
12
14
  } from "react-hook-form";
13
15
  import { yupResolver } from "@hookform/resolvers/yup";
@@ -35,6 +37,7 @@ export type FormProps<TFieldValues extends FieldValues> = Omit<
35
37
  controllerRef?: React.MutableRefObject<FormController<TFieldValues> | null>;
36
38
  onSubmit: SubmitHandler<TFieldValues>;
37
39
  onInvalidSubmit?: SubmitErrorHandler<TFieldValues>;
40
+ onFormStateChange?: (formState: FormState<TFieldValues>) => void;
38
41
  resolver?: Resolver<TFieldValues>;
39
42
  validationSchema?: yup.ObjectSchema<any>;
40
43
  mode?: Mode;
@@ -132,6 +135,20 @@ export const useControlledForm = <TFieldValues extends FieldValues>({
132
135
  };
133
136
  };
134
137
 
138
+ const FormStateObserver = <TFieldValues extends FieldValues>({
139
+ onFormStateChange,
140
+ }: {
141
+ onFormStateChange: (formState: FormState<TFieldValues>) => void;
142
+ }) => {
143
+ const { formState } = useFormContext<TFieldValues>();
144
+
145
+ React.useEffect(() => {
146
+ onFormStateChange(formState);
147
+ }, [formState, onFormStateChange]);
148
+
149
+ return null;
150
+ };
151
+
135
152
  const FormInner = <TFieldValues extends FieldValues>(
136
153
  {
137
154
  children,
@@ -140,6 +157,7 @@ const FormInner = <TFieldValues extends FieldValues>(
140
157
  controllerRef,
141
158
  onSubmit,
142
159
  onInvalidSubmit,
160
+ onFormStateChange,
143
161
  resolver,
144
162
  validationSchema,
145
163
  mode = "onSubmit",
@@ -195,6 +213,11 @@ const FormInner = <TFieldValues extends FieldValues>(
195
213
  noValidate={noValidate}
196
214
  onSubmit={methods.handleSubmit(onSubmit, onInvalidSubmit)}
197
215
  >
216
+ {onFormStateChange && (
217
+ <FormStateObserver<TFieldValues>
218
+ onFormStateChange={onFormStateChange}
219
+ />
220
+ )}
198
221
  {typeof children === "function" ? children(methods) : children}
199
222
  </form>
200
223
  </FormProvider>
@@ -0,0 +1,229 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { ScrollArea } from "./ScrollArea";
4
+
5
+ const meta = {
6
+ title: "Components/ScrollArea",
7
+ component: ScrollArea,
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ },
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="p-10 flex gap-8 flex-wrap bg-workspace-surface min-h-screen">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ } satisfies Meta<typeof ScrollArea>;
19
+
20
+ export default meta;
21
+
22
+ const ITEMS = Array.from({ length: 12 }, (_, i) => `Option Description ${i + 1}`);
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Size variants (Figma: Size=M / S / XS)
26
+ // ---------------------------------------------------------------------------
27
+ export const Sizes = {
28
+ name: "Size Variants",
29
+ render: () => (
30
+ <div className="flex gap-10 items-start">
31
+ {(["m", "s", "xs"] as const).map((size) => (
32
+ <div key={size}>
33
+ <p className="typography-small4 text-text-g-contrast-medium mb-2 uppercase">
34
+ Size {size}
35
+ </p>
36
+ <ScrollArea
37
+ scrollbarSize={size}
38
+ className="max-h-[270px] w-[200px] rounded-lg bg-modal-surface"
39
+ >
40
+ {ITEMS.map((label) => (
41
+ <div
42
+ key={label}
43
+ className="px-4 py-3 typography-subtitle4 text-text-g-contrast-high"
44
+ >
45
+ {label}
46
+ </div>
47
+ ))}
48
+ </ScrollArea>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ ),
53
+ } satisfies StoryObj;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Vertical scroll (Figma: Can Scroll)
57
+ // ---------------------------------------------------------------------------
58
+ export const VerticalScroll = {
59
+ name: "Vertical Scroll",
60
+ render: () => (
61
+ <div>
62
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
63
+ Single scrollable list — use{" "}
64
+ <code className="text-xs">&lt;ScrollArea className="max-h-[...]"&gt;</code>
65
+ </p>
66
+ <ScrollArea className="max-h-[270px] w-[230px] rounded-lg bg-modal-surface">
67
+ {ITEMS.map((label) => (
68
+ <div
69
+ key={label}
70
+ className="px-4 py-3 typography-subtitle4 text-text-g-contrast-high"
71
+ >
72
+ {label}
73
+ </div>
74
+ ))}
75
+ </ScrollArea>
76
+ </div>
77
+ ),
78
+ } satisfies StoryObj;
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Horizontal scroll (Figma: Horizontal=Yes)
82
+ // ---------------------------------------------------------------------------
83
+ export const HorizontalScroll = {
84
+ name: "Horizontal Scroll",
85
+ render: () => (
86
+ <div>
87
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
88
+ Horizontal — pass{" "}
89
+ <code className="text-xs">direction="horizontal"</code>
90
+ </p>
91
+ <ScrollArea
92
+ direction="horizontal"
93
+ className="max-w-[300px] rounded-lg bg-modal-surface"
94
+ >
95
+ <div className="flex">
96
+ {ITEMS.map((label) => (
97
+ <div
98
+ key={label}
99
+ className="shrink-0 w-[140px] px-4 py-3 typography-subtitle4 text-text-g-contrast-high border-r border-[var(--dropdown-menu-seperator-bg)]"
100
+ >
101
+ {label}
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </ScrollArea>
106
+ </div>
107
+ ),
108
+ } satisfies StoryObj;
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Double scroll (Figma: Double Scroll — two independent sections)
112
+ // ---------------------------------------------------------------------------
113
+ export const DoubleScroll = {
114
+ name: "Double Scroll",
115
+ render: () => (
116
+ <div>
117
+ <p className="typography-small4 text-text-g-contrast-medium mb-4">
118
+ Two independent scrollable sections inside one container.
119
+ <br />
120
+ Wrap each section in its own{" "}
121
+ <code className="text-xs">&lt;ScrollArea&gt;</code>.
122
+ </p>
123
+ <div className="w-[230px] rounded-lg bg-modal-surface overflow-hidden">
124
+ {/* Section A */}
125
+ <div className="px-3 pt-4 pb-2 typography-small4 text-text-g-contrast-high">
126
+ Section A
127
+ </div>
128
+ <ScrollArea className="max-h-[160px]">
129
+ {ITEMS.slice(0, 8).map((label) => (
130
+ <div
131
+ key={label}
132
+ className="px-4 py-3 typography-subtitle4 text-text-g-contrast-high"
133
+ >
134
+ {label}
135
+ </div>
136
+ ))}
137
+ </ScrollArea>
138
+
139
+ {/* Separator */}
140
+ <div className="h-px bg-[var(--dropdown-menu-seperator-bg)] my-2" />
141
+
142
+ {/* Section B */}
143
+ <div className="px-3 pt-2 pb-2 typography-small4 text-text-g-contrast-high">
144
+ Section B
145
+ </div>
146
+ <ScrollArea className="max-h-[160px]">
147
+ {ITEMS.slice(0, 8).map((label) => (
148
+ <div
149
+ key={label}
150
+ className="px-4 py-3 typography-subtitle4 text-text-g-contrast-high"
151
+ >
152
+ {label}
153
+ </div>
154
+ ))}
155
+ </ScrollArea>
156
+ </div>
157
+ </div>
158
+ ),
159
+ } satisfies StoryObj;
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Usage with DropdownMenu (Figma: Can Scroll / Double Scroll in dropdown)
163
+ // ---------------------------------------------------------------------------
164
+ export const WithDropdownMenu = {
165
+ name: "Usage with DropdownMenu",
166
+ render: () => {
167
+ // Inline-import to avoid circular storybook deps — client should import from "@core/ui"
168
+ const {
169
+ DropdownMenu,
170
+ DropdownMenuContent,
171
+ DropdownMenuItem,
172
+ DropdownMenuLabel,
173
+ DropdownMenuSeparator,
174
+ DropdownMenuTrigger,
175
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
176
+ } = require("../DropdownMenu/DropdownMenu");
177
+ const Button = require("../Button/Button").default;
178
+
179
+ return (
180
+ <div className="flex gap-8 items-start flex-wrap">
181
+ {/* Single scrollable dropdown */}
182
+ <div>
183
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
184
+ Can Scroll
185
+ </p>
186
+ <DropdownMenu>
187
+ <DropdownMenuTrigger asChild>
188
+ <Button variant="outline">Open Menu</Button>
189
+ </DropdownMenuTrigger>
190
+ <DropdownMenuContent>
191
+ <ScrollArea className="max-h-[270px]">
192
+ {ITEMS.map((label) => (
193
+ <DropdownMenuItem key={label}>{label}</DropdownMenuItem>
194
+ ))}
195
+ </ScrollArea>
196
+ </DropdownMenuContent>
197
+ </DropdownMenu>
198
+ </div>
199
+
200
+ {/* Double scroll dropdown */}
201
+ <div>
202
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
203
+ Double Scroll
204
+ </p>
205
+ <DropdownMenu>
206
+ <DropdownMenuTrigger asChild>
207
+ <Button variant="outline">Open Menu</Button>
208
+ </DropdownMenuTrigger>
209
+ <DropdownMenuContent>
210
+ <DropdownMenuLabel>Section A</DropdownMenuLabel>
211
+ <ScrollArea className="max-h-[160px]">
212
+ {ITEMS.slice(0, 8).map((label) => (
213
+ <DropdownMenuItem key={label}>{label}</DropdownMenuItem>
214
+ ))}
215
+ </ScrollArea>
216
+ <DropdownMenuSeparator />
217
+ <DropdownMenuLabel>Section B</DropdownMenuLabel>
218
+ <ScrollArea className="max-h-[160px]">
219
+ {ITEMS.slice(0, 8).map((label) => (
220
+ <DropdownMenuItem key={label}>{label}</DropdownMenuItem>
221
+ ))}
222
+ </ScrollArea>
223
+ </DropdownMenuContent>
224
+ </DropdownMenu>
225
+ </div>
226
+ </div>
227
+ );
228
+ },
229
+ } satisfies StoryObj;