@rovula/ui 0.1.20 → 0.1.21

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 (42) hide show
  1. package/dist/cjs/bundle.css +141 -17
  2. package/dist/cjs/bundle.js +3 -3
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.stories.d.ts +29 -30
  5. package/dist/cjs/types/components/Form/Form.d.ts +2 -1
  6. package/dist/cjs/types/components/Form/Form.stories.d.ts +4 -0
  7. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  8. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  9. package/dist/cjs/types/index.d.ts +1 -0
  10. package/dist/components/DropdownMenu/DropdownMenu.js +7 -9
  11. package/dist/components/DropdownMenu/DropdownMenu.stories.js +79 -91
  12. package/dist/components/Form/Form.js +11 -4
  13. package/dist/components/Form/Form.stories.js +27 -0
  14. package/dist/components/ScrollArea/ScrollArea.js +50 -0
  15. package/dist/components/ScrollArea/ScrollArea.stories.js +56 -0
  16. package/dist/esm/bundle.css +141 -17
  17. package/dist/esm/bundle.js +3 -3
  18. package/dist/esm/bundle.js.map +1 -1
  19. package/dist/esm/types/components/DropdownMenu/DropdownMenu.stories.d.ts +29 -30
  20. package/dist/esm/types/components/Form/Form.d.ts +2 -1
  21. package/dist/esm/types/components/Form/Form.stories.d.ts +4 -0
  22. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  23. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  24. package/dist/esm/types/index.d.ts +1 -0
  25. package/dist/index.d.ts +41 -2
  26. package/dist/index.js +1 -0
  27. package/dist/src/theme/global.css +196 -20
  28. package/package.json +1 -1
  29. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +482 -297
  30. package/src/components/DropdownMenu/DropdownMenu.tsx +7 -8
  31. package/src/components/Form/Form.stories.tsx +70 -0
  32. package/src/components/Form/Form.tsx +23 -0
  33. package/src/components/ScrollArea/ScrollArea.stories.tsx +229 -0
  34. package/src/components/ScrollArea/ScrollArea.tsx +72 -0
  35. package/src/index.ts +1 -0
  36. package/src/theme/global.css +84 -11
  37. package/src/theme/themes/xspector/baseline.css +1 -0
  38. package/src/theme/themes/xspector/components/dropdown-menu.css +2 -2
  39. package/src/theme/themes/xspector/components/scrollbar.css +12 -0
  40. package/src/theme/tokens/baseline.css +2 -1
  41. package/src/theme/tokens/components/dropdown-menu.css +1 -1
  42. package/src/theme/tokens/components/scrollbar.css +18 -0
@@ -27,8 +27,7 @@ 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-9 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)]",
@@ -52,7 +51,7 @@ 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",
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",
56
55
  className
57
56
  )}
58
57
  {...props}
@@ -74,7 +73,7 @@ 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",
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",
78
77
  className
79
78
  )}
80
79
  {...props}
@@ -96,7 +95,7 @@ const DropdownMenuItem = React.forwardRef<
96
95
  <DropdownMenuPrimitive.Item
97
96
  ref={ref}
98
97
  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",
98
+ "relative flex gap-4 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
99
  "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
101
100
  "active:opacity-75",
102
101
  "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
@@ -116,7 +115,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
116
115
  <DropdownMenuPrimitive.CheckboxItem
117
116
  ref={ref}
118
117
  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",
118
+ "relative flex gap-4 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",
120
119
  "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
121
120
  "active:opacity-75",
122
121
  "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
@@ -145,7 +144,7 @@ const DropdownMenuRadioItem = React.forwardRef<
145
144
  <DropdownMenuPrimitive.RadioItem
146
145
  ref={ref}
147
146
  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",
147
+ "relative flex gap-4 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
148
  "bg-[var(--dropdown-menu-default-bg)] text-[var(--dropdown-menu-default-text)]",
150
149
  "active:opacity-75",
151
150
  "focus:!bg-[var(--dropdown-menu-hover-bg)] focus:!text-[var(--dropdown-menu-hover-text)]",
@@ -174,7 +173,7 @@ const DropdownMenuLabel = React.forwardRef<
174
173
  <DropdownMenuPrimitive.Label
175
174
  ref={ref}
176
175
  className={cn(
177
- "px-3 py-2 typography-small4 text-text-g-contrast-medium",
176
+ "px-3 pt-4 pb-2 typography-small4 text-text-g-contrast-high",
178
177
  inset && "pl-8",
179
178
  className
180
179
  )}
@@ -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;
@@ -0,0 +1,72 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/utils/cn";
3
+
4
+ export type ScrollbarSize = "m" | "s" | "xs";
5
+
6
+ export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ /**
8
+ * Scrollbar thickness.
9
+ * - `m` — 12 px (shows track border)
10
+ * - `s` — 6 px (default, shows no track border)
11
+ * - `xs` — 2 px (shows no track border)
12
+ */
13
+ scrollbarSize?: ScrollbarSize;
14
+ /**
15
+ * Direction(s) the area can scroll.
16
+ * Defaults to `"vertical"`.
17
+ */
18
+ direction?: "vertical" | "horizontal" | "both";
19
+ }
20
+
21
+ const sizeClass: Record<ScrollbarSize, string> = {
22
+ m: "ui-scrollbar ui-scrollbar-m",
23
+ s: "ui-scrollbar ui-scrollbar-s",
24
+ xs: "ui-scrollbar ui-scrollbar-xs",
25
+ };
26
+
27
+ const directionClass: Record<NonNullable<ScrollAreaProps["direction"]>, string> = {
28
+ vertical: "overflow-y-auto overflow-x-hidden",
29
+ horizontal: "overflow-x-auto overflow-y-hidden",
30
+ both: "overflow-auto",
31
+ };
32
+
33
+ /**
34
+ * ScrollArea
35
+ *
36
+ * A thin wrapper that applies the design-system scrollbar style to any
37
+ * scrollable container. Use `scrollbarSize` to pick the Figma size variant
38
+ * and `direction` to control which axis scrolls.
39
+ *
40
+ * **Client usage:**
41
+ * ```tsx
42
+ * <ScrollArea className="max-h-[270px]">
43
+ * {items.map(item => <div key={item.id}>{item.label}</div>)}
44
+ * </ScrollArea>
45
+ * ```
46
+ *
47
+ * For a double-scroll layout (two independent sections inside one dropdown),
48
+ * wrap each section individually:
49
+ * ```tsx
50
+ * <ScrollArea className="max-h-[160px]">...section A items...</ScrollArea>
51
+ * <ScrollArea className="max-h-[160px]">...section B items...</ScrollArea>
52
+ * ```
53
+ */
54
+ export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
55
+ (
56
+ { className, scrollbarSize = "s", direction = "vertical", children, ...props },
57
+ ref
58
+ ) => (
59
+ <div
60
+ ref={ref}
61
+ className={cn(
62
+ directionClass[direction],
63
+ sizeClass[scrollbarSize],
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ {children}
69
+ </div>
70
+ )
71
+ );
72
+ ScrollArea.displayName = "ScrollArea";
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export * from "./components/Toast/Toaster";
49
49
  export * from "./components/Toast/useToast";
50
50
  export * from "./components/Tree";
51
51
  export * from "./components/FocusedScrollView/FocusedScrollView";
52
+ export * from "./components/ScrollArea/ScrollArea";
52
53
  export * from "./components/RadioGroup/RadioGroup";
53
54
  export * from "./components/Form";
54
55
 
@@ -14,7 +14,37 @@
14
14
  @layer base {
15
15
  * {
16
16
  /* @apply border-border; */
17
+ scrollbar-width: thin;
18
+ scrollbar-color: var(--scrollbar-thumb-default-color) transparent;
17
19
  }
20
+
21
+ *::-webkit-scrollbar {
22
+ width: var(--scrollbar-s-thickness);
23
+ height: var(--scrollbar-s-thickness);
24
+ background: transparent;
25
+ }
26
+
27
+ *::-webkit-scrollbar-thumb {
28
+ border-radius: 12px;
29
+ background: var(--scrollbar-thumb-default-color);
30
+ }
31
+
32
+ *::-webkit-scrollbar-thumb:hover {
33
+ background: var(--scrollbar-thumb-hover-color);
34
+ }
35
+
36
+ *::-webkit-scrollbar-track:vertical {
37
+ border-left: var(--scrollbar-track-border-width) solid var(--scrollbar-track-border-color);
38
+ }
39
+
40
+ *::-webkit-scrollbar-track:horizontal {
41
+ border-top: var(--scrollbar-track-border-width) solid var(--scrollbar-track-border-color);
42
+ }
43
+
44
+ *::-webkit-scrollbar-corner {
45
+ background: transparent;
46
+ }
47
+
18
48
  body {
19
49
  /* @apply bg-background text-foreground; */
20
50
  /* @apply bg-[var(--background)] text-[var(--foreground)]; */
@@ -36,26 +66,69 @@
36
66
 
37
67
  @layer utilities {
38
68
 
69
+ /* ------------------------------------------------------------------ */
70
+ /* Scrollbar utility — applies the design-system scrollbar style */
71
+ /* Default size: S (6px thumb, matches Figma Size=S) */
72
+ /* Usage: add `ui-scrollbar` + optional size modifier to any */
73
+ /* overflow-auto / overflow-y-auto / overflow-x-auto container. */
74
+ /* ------------------------------------------------------------------ */
75
+
76
+ /* --- Track --- */
39
77
  .ui-scrollbar::-webkit-scrollbar {
40
- height: 6px;
41
- width: 6px;
42
- background: rgba(0, 0, 0, 0.08);
78
+ width: var(--scrollbar-s-thickness);
79
+ height: var(--scrollbar-s-thickness);
80
+ background: transparent;
43
81
  }
44
-
82
+
83
+ /* --- Thumb --- */
45
84
  .ui-scrollbar::-webkit-scrollbar-thumb {
46
- border-radius: 6px;
47
- background: rgba(121, 141, 150, 0.48);
48
- width: 6px;
85
+ border-radius: 12px;
86
+ background: var(--scrollbar-thumb-default-color);
49
87
  }
50
-
88
+
51
89
  .ui-scrollbar::-webkit-scrollbar-thumb:hover {
52
- background: rgba(251, 252, 253, 0.48);
90
+ background: var(--scrollbar-thumb-hover-color);
53
91
  cursor: pointer;
54
92
  }
55
93
 
94
+ /* --- Track border (vertical) --- */
95
+ .ui-scrollbar::-webkit-scrollbar-track:vertical {
96
+ border-left: var(--scrollbar-track-border-width) solid var(--scrollbar-track-border-color);
97
+ }
98
+
99
+ /* --- Track border (horizontal) --- */
100
+ .ui-scrollbar::-webkit-scrollbar-track:horizontal {
101
+ border-top: var(--scrollbar-track-border-width) solid var(--scrollbar-track-border-color);
102
+ }
103
+
56
104
  .ui-scrollbar::-webkit-scrollbar-corner {
57
- display: none;
58
- /* background: transparent; */
105
+ background: transparent;
106
+ }
107
+
108
+ /* --- Size variants --- */
109
+
110
+ /* Size M — 12px */
111
+ .ui-scrollbar-m::-webkit-scrollbar {
112
+ width: var(--scrollbar-m-thickness);
113
+ height: var(--scrollbar-m-thickness);
114
+ }
115
+
116
+ /* Size S — 6px (default, same as base .ui-scrollbar) */
117
+ .ui-scrollbar-s::-webkit-scrollbar {
118
+ width: var(--scrollbar-s-thickness);
119
+ height: var(--scrollbar-s-thickness);
120
+ }
121
+
122
+ /* Size XS — 2px */
123
+ .ui-scrollbar-xs::-webkit-scrollbar {
124
+ width: var(--scrollbar-xs-thickness);
125
+ height: var(--scrollbar-xs-thickness);
126
+ }
127
+
128
+ /* XS size has no track border */
129
+ .ui-scrollbar-xs::-webkit-scrollbar-track:vertical,
130
+ .ui-scrollbar-xs::-webkit-scrollbar-track:horizontal {
131
+ border: none;
59
132
  }
60
133
 
61
134
  }
@@ -2,3 +2,4 @@
2
2
  @import url(components/action-button.css);
3
3
  @import url(components/loading.css);
4
4
  @import url(components/dropdown-menu.css);
5
+ @import url(components/scrollbar.css);
@@ -18,11 +18,11 @@
18
18
  --dropdown-menu-hover-text: inherit;
19
19
 
20
20
  /* Selected State */
21
- --dropdown-menu-selected-bg: var(--ramps-grey-150);
21
+ --dropdown-menu-selected-bg: var(--modal-highlight);
22
22
  --dropdown-menu-selected-text: inherit;
23
23
 
24
24
  /* Disabled State */
25
25
  --dropdown-menu-disabled-bg: transparent;
26
- --dropdown-menu-disabled-text: var(--ramps-grey-140);
26
+ --dropdown-menu-disabled-text: var(--state-disable-solid);
27
27
 
28
28
  }
@@ -0,0 +1,12 @@
1
+ :root[data-theme="xspector"] {
2
+ /* ------------------------------------------------------------------ */
3
+ /* Scrollbar Component Tokens — Xspector Theme */
4
+ /* ------------------------------------------------------------------ */
5
+
6
+ /* Track border uses the same transparent-grey token as other surfaces */
7
+ --scrollbar-track-border-color: var(--transparent-grey-16, rgba(158, 158, 158, 0.16));
8
+
9
+ /* Thumb — inherits design token colours from baseline */
10
+ --scrollbar-thumb-default-color: color-mix(in srgb, var(--text-g-contrast-low) 48%, transparent);
11
+ --scrollbar-thumb-hover-color: color-mix(in srgb, var(--text-g-contrast-high) 48%, transparent);
12
+ }
@@ -7,4 +7,5 @@
7
7
  @import url(components/navbar.css);
8
8
  @import url(components/footer.css);
9
9
  @import url(components/dropdown-menu.css);
10
- @import url(components/switch.css);
10
+ @import url(components/switch.css);
11
+ @import url(components/scrollbar.css);
@@ -23,5 +23,5 @@
23
23
 
24
24
  /* Disabled State */
25
25
  --dropdown-menu-disabled-bg: transparent;
26
- --dropdown-menu-disabled-text: var(--state-disable-outline);
26
+ --dropdown-menu-disabled-text: var(--state-disable-solid);
27
27
  }