@rovula/ui 0.1.18 → 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.
- package/dist/cjs/bundle.css +141 -17
- package/dist/cjs/bundle.js +3 -3
- package/dist/cjs/bundle.js.map +1 -1
- package/dist/cjs/types/components/DropdownMenu/DropdownMenu.stories.d.ts +29 -30
- package/dist/cjs/types/components/Form/Form.d.ts +2 -1
- package/dist/cjs/types/components/Form/Form.stories.d.ts +4 -0
- package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +38 -0
- package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
- package/dist/cjs/types/index.d.ts +1 -0
- package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.d.ts +1 -0
- package/dist/cjs/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +1 -0
- package/dist/components/DropdownMenu/DropdownMenu.js +7 -9
- package/dist/components/DropdownMenu/DropdownMenu.stories.js +79 -91
- package/dist/components/Form/Form.js +11 -4
- package/dist/components/Form/Form.stories.js +27 -0
- package/dist/components/ScrollArea/ScrollArea.js +50 -0
- package/dist/components/ScrollArea/ScrollArea.stories.js +56 -0
- package/dist/esm/bundle.css +141 -17
- package/dist/esm/bundle.js +3 -3
- package/dist/esm/bundle.js.map +1 -1
- package/dist/esm/types/components/DropdownMenu/DropdownMenu.stories.d.ts +29 -30
- package/dist/esm/types/components/Form/Form.d.ts +2 -1
- package/dist/esm/types/components/Form/Form.stories.d.ts +4 -0
- package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +38 -0
- package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
- package/dist/esm/types/index.d.ts +1 -0
- package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.d.ts +1 -0
- package/dist/esm/types/patterns/confirm-dialog/ConfirmDialog.stories.d.ts +1 -0
- package/dist/index.d.ts +42 -2
- package/dist/index.js +1 -0
- package/dist/patterns/confirm-dialog/ConfirmDialog.js +2 -2
- package/dist/patterns/form-dialog/FormDialog.js +1 -1
- package/dist/src/theme/global.css +196 -20
- package/package.json +1 -1
- package/src/components/DropdownMenu/DropdownMenu.stories.tsx +482 -297
- package/src/components/DropdownMenu/DropdownMenu.tsx +7 -8
- package/src/components/Form/Form.stories.tsx +70 -0
- package/src/components/Form/Form.tsx +23 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +229 -0
- package/src/components/ScrollArea/ScrollArea.tsx +72 -0
- package/src/index.ts +1 -0
- package/src/patterns/confirm-dialog/ConfirmDialog.tsx +4 -0
- package/src/patterns/form-dialog/FormDialog.tsx +5 -1
- package/src/theme/global.css +84 -11
- package/src/theme/themes/xspector/baseline.css +1 -0
- package/src/theme/themes/xspector/components/dropdown-menu.css +2 -2
- package/src/theme/themes/xspector/components/scrollbar.css +12 -0
- package/src/theme/tokens/baseline.css +2 -1
- package/src/theme/tokens/components/dropdown-menu.css +1 -1
- 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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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"><ScrollArea className="max-h-[...]"></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"><ScrollArea></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
|
|
|
@@ -21,6 +21,7 @@ export type ConfirmDialogProps = {
|
|
|
21
21
|
onOpenChange?: (open: boolean) => void;
|
|
22
22
|
title: string;
|
|
23
23
|
description?: string;
|
|
24
|
+
children?: React.ReactNode;
|
|
24
25
|
confirmLabel?: string;
|
|
25
26
|
cancelLabel?: string;
|
|
26
27
|
onConfirm?: () => void;
|
|
@@ -49,6 +50,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
49
50
|
onOpenChange,
|
|
50
51
|
title,
|
|
51
52
|
description,
|
|
53
|
+
children,
|
|
52
54
|
confirmLabel = "Confirm",
|
|
53
55
|
cancelLabel = "Cancel",
|
|
54
56
|
onConfirm,
|
|
@@ -117,6 +119,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
117
119
|
)}
|
|
118
120
|
</AlertDialogHeader>
|
|
119
121
|
|
|
122
|
+
{children}
|
|
123
|
+
|
|
120
124
|
{requiresInput && (
|
|
121
125
|
<FormRoot
|
|
122
126
|
id={formId}
|
|
@@ -76,7 +76,11 @@ export const FormDialog: React.FC<FormDialogProps> = ({
|
|
|
76
76
|
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
|
77
77
|
<DialogContent className={className} data-testid={testId}>
|
|
78
78
|
<DialogHeader>
|
|
79
|
-
|
|
79
|
+
{title && (
|
|
80
|
+
<DialogTitle data-testid={testId && `${testId}-title`}>
|
|
81
|
+
{title}
|
|
82
|
+
</DialogTitle>
|
|
83
|
+
)}
|
|
80
84
|
{description && (
|
|
81
85
|
<DialogDescription data-testid={testId && `${testId}-description`}>
|
|
82
86
|
{description}
|
package/src/theme/global.css
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
background:
|
|
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:
|
|
47
|
-
background:
|
|
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:
|
|
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
|
-
|
|
58
|
-
|
|
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
|
}
|