@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.
Files changed (108) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +2118 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +130 -0
  5. package/src/animations.css +437 -0
  6. package/src/components/data-display/accordion.stories.tsx +57 -0
  7. package/src/components/data-display/accordion.tsx +65 -0
  8. package/src/components/data-display/filter-chip.tsx +41 -0
  9. package/src/components/data-display/index.ts +13 -0
  10. package/src/components/data-display/table.stories.tsx +75 -0
  11. package/src/components/data-display/table.tsx +84 -0
  12. package/src/components/feedback/alert.stories.tsx +51 -0
  13. package/src/components/feedback/alert.tsx +60 -0
  14. package/src/components/feedback/empty-state.tsx +66 -0
  15. package/src/components/feedback/index.ts +6 -0
  16. package/src/components/feedback/progress.stories.tsx +37 -0
  17. package/src/components/feedback/progress.tsx +30 -0
  18. package/src/components/feedback/spinner.stories.tsx +52 -0
  19. package/src/components/feedback/spinner.tsx +48 -0
  20. package/src/components/forms/calendar.stories.tsx +30 -0
  21. package/src/components/forms/calendar.tsx +67 -0
  22. package/src/components/forms/date-picker.stories.tsx +48 -0
  23. package/src/components/forms/date-picker.tsx +60 -0
  24. package/src/components/forms/field.stories.tsx +62 -0
  25. package/src/components/forms/field.tsx +65 -0
  26. package/src/components/forms/field.types.ts +7 -0
  27. package/src/components/forms/index.ts +24 -0
  28. package/src/components/forms/input.stories.tsx +47 -0
  29. package/src/components/forms/input.tsx +20 -0
  30. package/src/components/forms/radio-group.stories.tsx +48 -0
  31. package/src/components/forms/radio-group.tsx +38 -0
  32. package/src/components/forms/select.stories.tsx +88 -0
  33. package/src/components/forms/select.tsx +147 -0
  34. package/src/components/forms/slider.stories.tsx +67 -0
  35. package/src/components/forms/slider.tsx +25 -0
  36. package/src/components/forms/stepper.stories.tsx +111 -0
  37. package/src/components/forms/stepper.tsx +66 -0
  38. package/src/components/forms/switch.stories.tsx +144 -0
  39. package/src/components/forms/switch.tsx +36 -0
  40. package/src/components/forms/text-field.stories.tsx +87 -0
  41. package/src/components/forms/text-field.tsx +40 -0
  42. package/src/components/forms/text-field.types.ts +9 -0
  43. package/src/components/forms/textarea.stories.tsx +150 -0
  44. package/src/components/forms/textarea.tsx +18 -0
  45. package/src/components/layout/index.ts +2 -0
  46. package/src/components/layout/scroll-area.stories.tsx +83 -0
  47. package/src/components/layout/scroll-area.tsx +40 -0
  48. package/src/components/navigation/app-navigation.stories.tsx +81 -0
  49. package/src/components/navigation/app-navigation.tsx +155 -0
  50. package/src/components/navigation/index.ts +3 -0
  51. package/src/components/navigation/tabs.stories.tsx +69 -0
  52. package/src/components/navigation/tabs.tsx +49 -0
  53. package/src/components/overlays/alert-dialog.stories.tsx +45 -0
  54. package/src/components/overlays/alert-dialog.tsx +119 -0
  55. package/src/components/overlays/command.stories.tsx +65 -0
  56. package/src/components/overlays/command.tsx +130 -0
  57. package/src/components/overlays/dialog.stories.tsx +111 -0
  58. package/src/components/overlays/dialog.tsx +162 -0
  59. package/src/components/overlays/dropdown-menu.stories.tsx +79 -0
  60. package/src/components/overlays/dropdown-menu.tsx +214 -0
  61. package/src/components/overlays/index.ts +65 -0
  62. package/src/components/overlays/popover.stories.tsx +39 -0
  63. package/src/components/overlays/popover.tsx +40 -0
  64. package/src/components/overlays/sheet.stories.tsx +68 -0
  65. package/src/components/overlays/sheet.tsx +98 -0
  66. package/src/components/primitives/avatar.stories.tsx +71 -0
  67. package/src/components/primitives/avatar.tsx +132 -0
  68. package/src/components/primitives/badge.stories.tsx +33 -0
  69. package/src/components/primitives/badge.tsx +44 -0
  70. package/src/components/primitives/button.stories.tsx +113 -0
  71. package/src/components/primitives/button.tsx +95 -0
  72. package/src/components/primitives/button.types.ts +10 -0
  73. package/src/components/primitives/card.stories.tsx +59 -0
  74. package/src/components/primitives/card.tsx +69 -0
  75. package/src/components/primitives/index.ts +13 -0
  76. package/src/components/primitives/label.stories.tsx +47 -0
  77. package/src/components/primitives/label.tsx +21 -0
  78. package/src/constants/chart-colors.ts +39 -0
  79. package/src/env.d.ts +9 -0
  80. package/src/hooks/index.ts +6 -0
  81. package/src/hooks/sort.types.ts +12 -0
  82. package/src/hooks/use-api-client.ts +122 -0
  83. package/src/hooks/use-debounced-value.ts +12 -0
  84. package/src/hooks/use-filter-state.ts +68 -0
  85. package/src/hooks/use-media-query.ts +65 -0
  86. package/src/hooks/use-mobile.tsx +19 -0
  87. package/src/index.ts +25 -0
  88. package/src/lib/auth.ts +5 -0
  89. package/src/lib/clipboard.ts +8 -0
  90. package/src/lib/create-memory-storage.ts +24 -0
  91. package/src/lib/index.ts +4 -0
  92. package/src/lib/utils.ts +6 -0
  93. package/src/storybook/controls.ts +117 -0
  94. package/src/storybook/options.ts +52 -0
  95. package/src/styles.css +1052 -0
  96. package/src/tokens/color-systems.stories.tsx +133 -0
  97. package/src/tokens/colors.ts +156 -0
  98. package/src/tokens/foundations.stories.tsx +151 -0
  99. package/src/tokens/index.ts +16 -0
  100. package/src/tokens/layout.stories.tsx +118 -0
  101. package/src/tokens/motion.ts +51 -0
  102. package/src/tokens/radii.ts +10 -0
  103. package/src/tokens/shadows.ts +34 -0
  104. package/src/tokens/spacing.ts +30 -0
  105. package/src/tokens/typography.ts +42 -0
  106. package/src/translations/en.ts +34 -0
  107. package/src/translations/index.ts +32 -0
  108. 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 };
@@ -0,0 +1,2 @@
1
+ export { ScrollArea } from "./scroll-area";
2
+ export type { ScrollAreaProps } from "./scroll-area";