@ponti-studios/ui 0.1.2
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/README.md +27 -0
- package/dist/index.js +2118 -0
- package/dist/index.js.map +1 -0
- package/package.json +130 -0
- package/src/animations.css +437 -0
- package/src/components/data-display/accordion.stories.tsx +57 -0
- package/src/components/data-display/accordion.tsx +65 -0
- package/src/components/data-display/filter-chip.tsx +41 -0
- package/src/components/data-display/index.ts +13 -0
- package/src/components/data-display/table.stories.tsx +75 -0
- package/src/components/data-display/table.tsx +84 -0
- package/src/components/feedback/alert.stories.tsx +51 -0
- package/src/components/feedback/alert.tsx +60 -0
- package/src/components/feedback/empty-state.tsx +66 -0
- package/src/components/feedback/index.ts +6 -0
- package/src/components/feedback/progress.stories.tsx +37 -0
- package/src/components/feedback/progress.tsx +30 -0
- package/src/components/feedback/spinner.stories.tsx +52 -0
- package/src/components/feedback/spinner.tsx +48 -0
- package/src/components/forms/calendar.stories.tsx +30 -0
- package/src/components/forms/calendar.tsx +67 -0
- package/src/components/forms/date-picker.stories.tsx +48 -0
- package/src/components/forms/date-picker.tsx +60 -0
- package/src/components/forms/field.stories.tsx +62 -0
- package/src/components/forms/field.tsx +65 -0
- package/src/components/forms/field.types.ts +7 -0
- package/src/components/forms/index.ts +24 -0
- package/src/components/forms/input.stories.tsx +47 -0
- package/src/components/forms/input.tsx +20 -0
- package/src/components/forms/radio-group.stories.tsx +48 -0
- package/src/components/forms/radio-group.tsx +38 -0
- package/src/components/forms/select.stories.tsx +88 -0
- package/src/components/forms/select.tsx +147 -0
- package/src/components/forms/slider.stories.tsx +67 -0
- package/src/components/forms/slider.tsx +25 -0
- package/src/components/forms/stepper.stories.tsx +111 -0
- package/src/components/forms/stepper.tsx +66 -0
- package/src/components/forms/switch.stories.tsx +144 -0
- package/src/components/forms/switch.tsx +36 -0
- package/src/components/forms/text-field.stories.tsx +87 -0
- package/src/components/forms/text-field.tsx +40 -0
- package/src/components/forms/text-field.types.ts +9 -0
- package/src/components/forms/textarea.stories.tsx +150 -0
- package/src/components/forms/textarea.tsx +18 -0
- package/src/components/layout/index.ts +2 -0
- package/src/components/layout/scroll-area.stories.tsx +83 -0
- package/src/components/layout/scroll-area.tsx +40 -0
- package/src/components/navigation/app-navigation.stories.tsx +81 -0
- package/src/components/navigation/app-navigation.tsx +155 -0
- package/src/components/navigation/index.ts +3 -0
- package/src/components/navigation/tabs.stories.tsx +69 -0
- package/src/components/navigation/tabs.tsx +49 -0
- package/src/components/overlays/alert-dialog.stories.tsx +45 -0
- package/src/components/overlays/alert-dialog.tsx +119 -0
- package/src/components/overlays/command.stories.tsx +65 -0
- package/src/components/overlays/command.tsx +130 -0
- package/src/components/overlays/dialog.stories.tsx +111 -0
- package/src/components/overlays/dialog.tsx +162 -0
- package/src/components/overlays/dropdown-menu.stories.tsx +79 -0
- package/src/components/overlays/dropdown-menu.tsx +214 -0
- package/src/components/overlays/index.ts +65 -0
- package/src/components/overlays/popover.stories.tsx +39 -0
- package/src/components/overlays/popover.tsx +40 -0
- package/src/components/overlays/sheet.stories.tsx +68 -0
- package/src/components/overlays/sheet.tsx +98 -0
- package/src/components/primitives/avatar.stories.tsx +71 -0
- package/src/components/primitives/avatar.tsx +132 -0
- package/src/components/primitives/badge.stories.tsx +33 -0
- package/src/components/primitives/badge.tsx +44 -0
- package/src/components/primitives/button.stories.tsx +113 -0
- package/src/components/primitives/button.tsx +95 -0
- package/src/components/primitives/button.types.ts +10 -0
- package/src/components/primitives/card.stories.tsx +59 -0
- package/src/components/primitives/card.tsx +69 -0
- package/src/components/primitives/index.ts +13 -0
- package/src/components/primitives/label.stories.tsx +47 -0
- package/src/components/primitives/label.tsx +21 -0
- package/src/constants/chart-colors.ts +39 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/sort.types.ts +12 -0
- package/src/hooks/use-api-client.ts +122 -0
- package/src/hooks/use-debounced-value.ts +12 -0
- package/src/hooks/use-filter-state.ts +68 -0
- package/src/hooks/use-media-query.ts +65 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/index.ts +25 -0
- package/src/lib/auth.ts +5 -0
- package/src/lib/clipboard.ts +8 -0
- package/src/lib/create-memory-storage.ts +24 -0
- package/src/lib/index.ts +4 -0
- package/src/lib/utils.ts +6 -0
- package/src/storybook/controls.ts +117 -0
- package/src/storybook/options.ts +52 -0
- package/src/styles.css +1052 -0
- package/src/tokens/color-systems.stories.tsx +133 -0
- package/src/tokens/colors.ts +156 -0
- package/src/tokens/foundations.stories.tsx +151 -0
- package/src/tokens/index.ts +16 -0
- package/src/tokens/layout.stories.tsx +118 -0
- package/src/tokens/motion.ts +51 -0
- package/src/tokens/radii.ts +10 -0
- package/src/tokens/shadows.ts +34 -0
- package/src/tokens/spacing.ts +30 -0
- package/src/tokens/typography.ts +42 -0
- package/src/translations/en.ts +34 -0
- package/src/translations/index.ts +32 -0
- package/src/types/upload.ts +42 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { booleanControl, hiddenControl, numberControl } from "../../storybook/controls";
|
|
5
|
+
import { Stepper } from "./stepper";
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: "Forms/Stepper",
|
|
9
|
+
component: Stepper,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
argTypes: {
|
|
12
|
+
value: numberControl("Current value", { defaultValue: 1 }),
|
|
13
|
+
min: numberControl("Minimum allowed value", { defaultValue: 0 }),
|
|
14
|
+
max: numberControl("Maximum allowed value", { defaultValue: 100 }),
|
|
15
|
+
step: numberControl("Amount to change per click", { defaultValue: 1 }),
|
|
16
|
+
disabled: booleanControl("Prevents user interaction", false),
|
|
17
|
+
onChange: hiddenControl,
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof Stepper>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
function StepperPreview({
|
|
25
|
+
value,
|
|
26
|
+
min,
|
|
27
|
+
max,
|
|
28
|
+
step,
|
|
29
|
+
disabled,
|
|
30
|
+
format,
|
|
31
|
+
}: {
|
|
32
|
+
value: number;
|
|
33
|
+
min?: number;
|
|
34
|
+
max?: number;
|
|
35
|
+
step?: number;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
format?: (value: number) => string;
|
|
38
|
+
}) {
|
|
39
|
+
const [currentValue, setCurrentValue] = useState(value);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Stepper
|
|
43
|
+
value={currentValue}
|
|
44
|
+
min={min}
|
|
45
|
+
max={max}
|
|
46
|
+
step={step}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
format={format}
|
|
49
|
+
onChange={setCurrentValue}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Default: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
value: 1,
|
|
57
|
+
min: 0,
|
|
58
|
+
max: 10,
|
|
59
|
+
step: 1,
|
|
60
|
+
onChange: () => {},
|
|
61
|
+
},
|
|
62
|
+
render: (args) => (
|
|
63
|
+
<StepperPreview value={args.value} min={args.min} max={args.max} step={args.step} />
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const AtMin: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
value: 0,
|
|
70
|
+
min: 0,
|
|
71
|
+
max: 10,
|
|
72
|
+
onChange: () => {},
|
|
73
|
+
},
|
|
74
|
+
render: (args) => <StepperPreview value={args.value} min={args.min} max={args.max} />,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const AtMax: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
value: 10,
|
|
80
|
+
min: 0,
|
|
81
|
+
max: 10,
|
|
82
|
+
onChange: () => {},
|
|
83
|
+
},
|
|
84
|
+
render: (args) => <StepperPreview value={args.value} min={args.min} max={args.max} />,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Disabled: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
value: 3,
|
|
90
|
+
disabled: true,
|
|
91
|
+
onChange: () => {},
|
|
92
|
+
},
|
|
93
|
+
render: (args) => <StepperPreview value={args.value} disabled={args.disabled} />,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const CustomFormat: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
value: 5,
|
|
99
|
+
min: 0,
|
|
100
|
+
max: 20,
|
|
101
|
+
onChange: () => {},
|
|
102
|
+
},
|
|
103
|
+
render: (args) => (
|
|
104
|
+
<StepperPreview
|
|
105
|
+
value={args.value}
|
|
106
|
+
min={args.min}
|
|
107
|
+
max={args.max}
|
|
108
|
+
format={(value) => `${value} guests`}
|
|
109
|
+
/>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Minus, Plus } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { Button } from "../primitives/button";
|
|
5
|
+
|
|
6
|
+
interface StepperProps {
|
|
7
|
+
value: number;
|
|
8
|
+
min?: number;
|
|
9
|
+
max?: number;
|
|
10
|
+
step?: number;
|
|
11
|
+
format?: (value: number) => string;
|
|
12
|
+
onChange: (value: number) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
increaseLabel?: string;
|
|
16
|
+
decreaseLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Stepper({
|
|
20
|
+
value,
|
|
21
|
+
min = 0,
|
|
22
|
+
max = 100,
|
|
23
|
+
step = 1,
|
|
24
|
+
format,
|
|
25
|
+
onChange,
|
|
26
|
+
className,
|
|
27
|
+
disabled,
|
|
28
|
+
increaseLabel = "Increase",
|
|
29
|
+
decreaseLabel = "Decrease",
|
|
30
|
+
}: StepperProps) {
|
|
31
|
+
const atMin = value <= min;
|
|
32
|
+
const atMax = value >= max;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn("border-default bg-inset flex items-center rounded-lg border", className)}>
|
|
36
|
+
<Button
|
|
37
|
+
type="button"
|
|
38
|
+
variant="ghost"
|
|
39
|
+
onClick={() => onChange(Math.max(min, value - step))}
|
|
40
|
+
disabled={disabled || atMin}
|
|
41
|
+
className="border-default text-secondary rounded-none rounded-l-lg border-r"
|
|
42
|
+
aria-label={decreaseLabel}
|
|
43
|
+
>
|
|
44
|
+
<Minus className="size-3.5" />
|
|
45
|
+
</Button>
|
|
46
|
+
<div className="flex min-w-[3ch] flex-1 items-center justify-center px-3">
|
|
47
|
+
<span className="text-primary font-['Geist'] text-sm font-semibold tabular-nums">
|
|
48
|
+
{format ? format(value) : value}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
<Button
|
|
52
|
+
type="button"
|
|
53
|
+
variant="ghost"
|
|
54
|
+
onClick={() => onChange(Math.min(max, value + step))}
|
|
55
|
+
disabled={disabled || atMax}
|
|
56
|
+
className="border-default text-secondary rounded-none rounded-r-lg border-l"
|
|
57
|
+
aria-label={increaseLabel}
|
|
58
|
+
>
|
|
59
|
+
<Plus className="size-3.5" />
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { Stepper };
|
|
66
|
+
export type { StepperProps };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { booleanControl, selectControl } from "../../storybook/controls";
|
|
5
|
+
import { switchSizeOptions } from "../../storybook/options";
|
|
6
|
+
import { Switch } from "./switch";
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Forms/Switch",
|
|
10
|
+
component: Switch,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
argTypes: {
|
|
13
|
+
checked: booleanControl("Controlled on/off state of the switch", false),
|
|
14
|
+
disabled: booleanControl("Prevents user interaction and applies disabled styling", false),
|
|
15
|
+
size: selectControl(switchSizeOptions, "Size variant of the switch", {
|
|
16
|
+
defaultValue: "default",
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof Switch>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
function SwitchPreview({
|
|
25
|
+
checked,
|
|
26
|
+
disabled = false,
|
|
27
|
+
id,
|
|
28
|
+
label,
|
|
29
|
+
size = "default",
|
|
30
|
+
}: {
|
|
31
|
+
checked: boolean;
|
|
32
|
+
disabled: boolean;
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
size: "default" | "sm";
|
|
36
|
+
}) {
|
|
37
|
+
const [currentChecked, setCurrentChecked] = useState(checked);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setCurrentChecked(checked);
|
|
41
|
+
}, [checked]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex items-center space-x-2">
|
|
45
|
+
<Switch
|
|
46
|
+
id={id}
|
|
47
|
+
checked={currentChecked}
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
onCheckedChange={setCurrentChecked}
|
|
50
|
+
size={size}
|
|
51
|
+
/>
|
|
52
|
+
<label htmlFor={id} className="text-primary text-sm leading-none font-medium">
|
|
53
|
+
{label}
|
|
54
|
+
</label>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const Default: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
checked: false,
|
|
62
|
+
},
|
|
63
|
+
render: (args) => (
|
|
64
|
+
<SwitchPreview
|
|
65
|
+
checked={args.checked ?? false}
|
|
66
|
+
disabled={args.disabled ?? false}
|
|
67
|
+
id="switch-default"
|
|
68
|
+
label="Airplane Mode"
|
|
69
|
+
size={args.size ?? "default"}
|
|
70
|
+
/>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const WithLabel: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
checked: false,
|
|
77
|
+
size: "default",
|
|
78
|
+
},
|
|
79
|
+
render: (args) => (
|
|
80
|
+
<SwitchPreview
|
|
81
|
+
checked={args.checked ?? false}
|
|
82
|
+
disabled={args.disabled ?? false}
|
|
83
|
+
id="airplane-mode"
|
|
84
|
+
label="Airplane Mode"
|
|
85
|
+
size={args.size ?? "default"}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const Checked: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
checked: true,
|
|
93
|
+
},
|
|
94
|
+
render: (args) => (
|
|
95
|
+
<SwitchPreview
|
|
96
|
+
checked={args.checked ?? true}
|
|
97
|
+
disabled={args.disabled ?? false}
|
|
98
|
+
id="notifications"
|
|
99
|
+
label="Enable Notifications"
|
|
100
|
+
size={args.size ?? "default"}
|
|
101
|
+
/>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const Disabled: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
checked: false,
|
|
108
|
+
disabled: true,
|
|
109
|
+
},
|
|
110
|
+
render: (args) => (
|
|
111
|
+
<div className="flex flex-col gap-3">
|
|
112
|
+
<SwitchPreview
|
|
113
|
+
checked={args.checked ?? false}
|
|
114
|
+
disabled={args.disabled ?? false}
|
|
115
|
+
id="disabled-off"
|
|
116
|
+
label="Disabled off"
|
|
117
|
+
size={args.size ?? "default"}
|
|
118
|
+
/>
|
|
119
|
+
<SwitchPreview
|
|
120
|
+
checked
|
|
121
|
+
disabled={args.disabled ?? false}
|
|
122
|
+
id="disabled-on"
|
|
123
|
+
label="Disabled on"
|
|
124
|
+
size={args.size ?? "default"}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const Small: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
checked: false,
|
|
133
|
+
size: "sm",
|
|
134
|
+
},
|
|
135
|
+
render: (args) => (
|
|
136
|
+
<SwitchPreview
|
|
137
|
+
checked={args.checked ?? false}
|
|
138
|
+
disabled={args.disabled ?? false}
|
|
139
|
+
id="small"
|
|
140
|
+
label="Small switch"
|
|
141
|
+
size={args.size ?? "default"}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
function Switch({
|
|
7
|
+
className,
|
|
8
|
+
size = "default",
|
|
9
|
+
...props
|
|
10
|
+
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
|
11
|
+
size?: "sm" | "default";
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<SwitchPrimitive.Root
|
|
15
|
+
data-slot="switch"
|
|
16
|
+
data-size={size}
|
|
17
|
+
className={cn(
|
|
18
|
+
"peer data-[state=checked]:bg-accent data-[state=unchecked]:bg-inset focus-visible:ring-focus inline-flex shrink-0 items-center rounded-full border border-transparent p-px focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:justify-end data-[state=unchecked]:justify-start",
|
|
19
|
+
size === "default" ? "h-[1.15rem] w-8" : "h-3.5 w-6",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
<SwitchPrimitive.Thumb
|
|
25
|
+
data-slot="switch-thumb"
|
|
26
|
+
data-size={size}
|
|
27
|
+
className={cn(
|
|
28
|
+
"pointer-events-none block rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-all",
|
|
29
|
+
size === "default" ? "size-4" : "size-3",
|
|
30
|
+
)}
|
|
31
|
+
/>
|
|
32
|
+
</SwitchPrimitive.Root>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { Switch };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
|
|
4
|
+
import { booleanControl, selectControl, textControl } from "../../storybook/controls";
|
|
5
|
+
import { textFieldTypeOptions } from "../../storybook/options";
|
|
6
|
+
import { TextField } from "./text-field";
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Forms/TextField",
|
|
10
|
+
component: TextField,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
argTypes: {
|
|
13
|
+
label: textControl("Label displayed above the field"),
|
|
14
|
+
type: selectControl(textFieldTypeOptions, "Input type used by the field", {
|
|
15
|
+
defaultValue: "text",
|
|
16
|
+
}),
|
|
17
|
+
placeholder: textControl("Placeholder text shown when the field is empty"),
|
|
18
|
+
helpText: textControl("Supporting text shown below the field"),
|
|
19
|
+
error: textControl("Validation error text shown below the field"),
|
|
20
|
+
disabled: booleanControl("Prevents user interaction and applies disabled styling", false),
|
|
21
|
+
required: booleanControl("Marks the field as required for form submission", false),
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof TextField>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
label: "Email",
|
|
31
|
+
placeholder: "you@example.com",
|
|
32
|
+
type: "email",
|
|
33
|
+
},
|
|
34
|
+
play: async ({ canvasElement }) => {
|
|
35
|
+
const canvas = within(canvasElement);
|
|
36
|
+
const input = canvas.getByLabelText("Email");
|
|
37
|
+
|
|
38
|
+
await userEvent.click(input);
|
|
39
|
+
await userEvent.type(input, "you@example.com");
|
|
40
|
+
|
|
41
|
+
await expect(input).toHaveFocus();
|
|
42
|
+
await expect(input).toHaveValue("you@example.com");
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithHelpText: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
label: "Search",
|
|
49
|
+
helpText: "Press Enter to submit",
|
|
50
|
+
placeholder: "Search notes",
|
|
51
|
+
type: "search",
|
|
52
|
+
},
|
|
53
|
+
play: async ({ canvasElement }) => {
|
|
54
|
+
const canvas = within(canvasElement);
|
|
55
|
+
|
|
56
|
+
await expect(canvas.getByText("Press Enter to submit")).toBeInTheDocument();
|
|
57
|
+
await expect(canvas.getByLabelText("Search")).toBeEnabled();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const Error: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
label: "Password",
|
|
64
|
+
error: "Password is required",
|
|
65
|
+
type: "password",
|
|
66
|
+
},
|
|
67
|
+
play: async ({ canvasElement }) => {
|
|
68
|
+
const canvas = within(canvasElement);
|
|
69
|
+
const input = canvas.getByLabelText("Password");
|
|
70
|
+
|
|
71
|
+
await expect(input).toHaveAttribute("aria-invalid", "true");
|
|
72
|
+
await expect(canvas.getByText("Password is required")).toBeInTheDocument();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Disabled: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
disabled: true,
|
|
79
|
+
label: "Handle",
|
|
80
|
+
placeholder: "@hominem",
|
|
81
|
+
},
|
|
82
|
+
play: async ({ canvasElement }) => {
|
|
83
|
+
const canvas = within(canvasElement);
|
|
84
|
+
|
|
85
|
+
await expect(canvas.getByLabelText("Handle")).toBeDisabled();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { Field } from "./field";
|
|
4
|
+
import { Input } from "./input";
|
|
5
|
+
import type { TextFieldBaseProps, TextFieldType } from "./text-field.types";
|
|
6
|
+
|
|
7
|
+
interface TextFieldProps extends Omit<React.ComponentProps<"input">, "type">, TextFieldBaseProps {
|
|
8
|
+
type?: TextFieldType | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TextField — composed input with label, helper text, and error state.
|
|
13
|
+
* Replaces raw `<input>` + `<label>` + error span patterns in feature code.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <TextField label="Email" type="email" error={errors.email?.message} />
|
|
17
|
+
* <TextField label="Search" type="search" helpText="Press Enter to search" />
|
|
18
|
+
*/
|
|
19
|
+
function TextField({
|
|
20
|
+
id,
|
|
21
|
+
label,
|
|
22
|
+
helpText,
|
|
23
|
+
error,
|
|
24
|
+
className,
|
|
25
|
+
disabled,
|
|
26
|
+
type = "text",
|
|
27
|
+
...inputProps
|
|
28
|
+
}: TextFieldProps) {
|
|
29
|
+
if (!label && !helpText && !error) {
|
|
30
|
+
return <Input className={className} disabled={disabled} type={type} {...inputProps} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Field id={id} label={label} helpText={helpText} error={error}>
|
|
35
|
+
<Input className={className} disabled={disabled} type={type} {...inputProps} />
|
|
36
|
+
</Field>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { TextField, type TextFieldProps };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type TextFieldType = "text" | "email" | "password" | "search";
|
|
2
|
+
|
|
3
|
+
export interface TextFieldBaseProps {
|
|
4
|
+
disabled?: boolean | undefined;
|
|
5
|
+
error?: string | undefined;
|
|
6
|
+
helpText?: string | undefined;
|
|
7
|
+
label?: string | undefined;
|
|
8
|
+
id?: string | undefined;
|
|
9
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
|
|
5
|
+
import { booleanControl, numberControl, textControl } from "../../storybook/controls";
|
|
6
|
+
import { Textarea } from "./textarea";
|
|
7
|
+
|
|
8
|
+
function TextareaField({
|
|
9
|
+
error,
|
|
10
|
+
helpText,
|
|
11
|
+
id,
|
|
12
|
+
label,
|
|
13
|
+
...props
|
|
14
|
+
}: ComponentProps<typeof Textarea> & {
|
|
15
|
+
error?: string;
|
|
16
|
+
helpText?: string;
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
}) {
|
|
20
|
+
const helpId = helpText ? `${id}-help` : undefined;
|
|
21
|
+
const errorId = error ? `${id}-error` : undefined;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="grid gap-2">
|
|
25
|
+
<label htmlFor={id} className="text-primary text-sm leading-none font-medium">
|
|
26
|
+
{label}
|
|
27
|
+
</label>
|
|
28
|
+
<Textarea
|
|
29
|
+
id={id}
|
|
30
|
+
aria-describedby={[helpId, errorId].filter(Boolean).join(" ") || undefined}
|
|
31
|
+
aria-invalid={error ? true : undefined}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
{helpText ? (
|
|
35
|
+
<p id={helpId} className="text-secondary text-sm">
|
|
36
|
+
{helpText}
|
|
37
|
+
</p>
|
|
38
|
+
) : null}
|
|
39
|
+
{error ? (
|
|
40
|
+
<p id={errorId} className="text-destructive text-sm" role="alert">
|
|
41
|
+
{error}
|
|
42
|
+
</p>
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const meta = {
|
|
49
|
+
title: "Forms/Textarea",
|
|
50
|
+
component: Textarea,
|
|
51
|
+
tags: ["autodocs"],
|
|
52
|
+
argTypes: {
|
|
53
|
+
placeholder: textControl("Placeholder text shown when the textarea is empty"),
|
|
54
|
+
rows: numberControl("Number of visible text lines", { min: 1, max: 20, defaultValue: 4 }),
|
|
55
|
+
disabled: booleanControl("Prevents user interaction and applies disabled styling", false),
|
|
56
|
+
},
|
|
57
|
+
} satisfies Meta<typeof Textarea>;
|
|
58
|
+
|
|
59
|
+
export default meta;
|
|
60
|
+
type Story = StoryObj<typeof meta>;
|
|
61
|
+
|
|
62
|
+
export const Default: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
placeholder: "Enter your notes here",
|
|
65
|
+
rows: 4,
|
|
66
|
+
},
|
|
67
|
+
render: (args) => <TextareaField id="notes" label="Notes" {...args} />,
|
|
68
|
+
play: async ({ canvasElement }) => {
|
|
69
|
+
const canvas = within(canvasElement);
|
|
70
|
+
const textarea = canvas.getByLabelText("Notes") as HTMLTextAreaElement;
|
|
71
|
+
|
|
72
|
+
await userEvent.click(textarea);
|
|
73
|
+
await userEvent.type(textarea, "Sample note content");
|
|
74
|
+
|
|
75
|
+
await expect(textarea).toHaveValue("Sample note content");
|
|
76
|
+
await expect(textarea).toHaveFocus();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const WithHelpText: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
placeholder: "Write a detailed description...",
|
|
83
|
+
rows: 5,
|
|
84
|
+
},
|
|
85
|
+
render: (args) => (
|
|
86
|
+
<TextareaField
|
|
87
|
+
id="description"
|
|
88
|
+
label="Description"
|
|
89
|
+
helpText="Markdown formatting is supported"
|
|
90
|
+
{...args}
|
|
91
|
+
/>
|
|
92
|
+
),
|
|
93
|
+
play: async ({ canvasElement }) => {
|
|
94
|
+
const canvas = within(canvasElement);
|
|
95
|
+
|
|
96
|
+
await expect(canvas.getByText("Markdown formatting is supported")).toBeInTheDocument();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const Error: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
placeholder: "Write something...",
|
|
103
|
+
rows: 3,
|
|
104
|
+
},
|
|
105
|
+
render: (args) => (
|
|
106
|
+
<TextareaField
|
|
107
|
+
id="message"
|
|
108
|
+
label="Message"
|
|
109
|
+
error="Message is required and cannot be empty"
|
|
110
|
+
{...args}
|
|
111
|
+
/>
|
|
112
|
+
),
|
|
113
|
+
play: async ({ canvasElement }) => {
|
|
114
|
+
const canvas = within(canvasElement);
|
|
115
|
+
|
|
116
|
+
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
|
117
|
+
"Message is required and cannot be empty",
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const Disabled: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
disabled: true,
|
|
125
|
+
placeholder: "This field is disabled",
|
|
126
|
+
rows: 4,
|
|
127
|
+
},
|
|
128
|
+
render: (args) => <TextareaField id="archived-notes" label="Archived Notes" {...args} />,
|
|
129
|
+
play: async ({ canvasElement }) => {
|
|
130
|
+
const canvas = within(canvasElement);
|
|
131
|
+
const textarea = canvas.getByLabelText("Archived Notes");
|
|
132
|
+
|
|
133
|
+
await expect(textarea).toBeDisabled();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const LargeField: Story = {
|
|
138
|
+
args: {
|
|
139
|
+
placeholder: "Write as much as you need...",
|
|
140
|
+
rows: 8,
|
|
141
|
+
},
|
|
142
|
+
render: (args) => (
|
|
143
|
+
<TextareaField
|
|
144
|
+
id="long-form-content"
|
|
145
|
+
label="Long Form Content"
|
|
146
|
+
helpText="Use this field for extended writing"
|
|
147
|
+
{...args}
|
|
148
|
+
/>
|
|
149
|
+
),
|
|
150
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
6
|
+
return (
|
|
7
|
+
<textarea
|
|
8
|
+
data-slot="textarea"
|
|
9
|
+
className={cn(
|
|
10
|
+
"placeholder:text-secondary aria-invalid:ring-destructive/20 aria-invalid:border-destructive focus-visible:ring-focus flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Textarea };
|