@phsa.tec/design-system-react 0.1.0

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 (201) hide show
  1. package/.eslintrc.json +7 -0
  2. package/.storybook/main.ts +16 -0
  3. package/.storybook/preview.ts +15 -0
  4. package/README.md +36 -0
  5. package/components.json +21 -0
  6. package/jest.config.ts +25 -0
  7. package/next.config.ts +7 -0
  8. package/package.json +88 -0
  9. package/postcss.config.mjs +8 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/vercel.svg +1 -0
  14. package/public/window.svg +1 -0
  15. package/src/app/columns.tsx +178 -0
  16. package/src/app/favicon.ico +0 -0
  17. package/src/app/fonts/GeistMonoVF.woff +0 -0
  18. package/src/app/fonts/GeistVF.woff +0 -0
  19. package/src/app/globals.css +94 -0
  20. package/src/app/layout.tsx +35 -0
  21. package/src/app/page.tsx +7 -0
  22. package/src/components/actions/AlertDialog/AlertDialog.tsx +45 -0
  23. package/src/components/actions/AlertDialog/alert-dialog.stories.tsx +21 -0
  24. package/src/components/actions/AlertDialog/index.ts +1 -0
  25. package/src/components/actions/Button/Button.stories.ts +38 -0
  26. package/src/components/actions/Button/Button.tsx +23 -0
  27. package/src/components/actions/Button/index.ts +1 -0
  28. package/src/components/actions/Collapsible/index.ts +1 -0
  29. package/src/components/actions/Dialog/Dialog.stories.tsx +70 -0
  30. package/src/components/actions/Dialog/Dialog.tsx +87 -0
  31. package/src/components/actions/Dialog/components/DialogWithActions/index.tsx +40 -0
  32. package/src/components/actions/Dialog/index.ts +1 -0
  33. package/src/components/actions/Steps/Steps.stories.tsx +25 -0
  34. package/src/components/actions/Steps/Steps.tsx +51 -0
  35. package/src/components/actions/Steps/index.ts +1 -0
  36. package/src/components/actions/index.ts +5 -0
  37. package/src/components/dataDisplay/Avatar/Avatar.stories.tsx +22 -0
  38. package/src/components/dataDisplay/Avatar/Avatar.tsx +21 -0
  39. package/src/components/dataDisplay/Avatar/index.ts +2 -0
  40. package/src/components/dataDisplay/Badge/Badge.stories.tsx +36 -0
  41. package/src/components/dataDisplay/Badge/index.ts +1 -0
  42. package/src/components/dataDisplay/Card/Card.stories.tsx +24 -0
  43. package/src/components/dataDisplay/Card/Card.tsx +34 -0
  44. package/src/components/dataDisplay/Card/index.ts +1 -0
  45. package/src/components/dataDisplay/DataPairList/DataPairList.tsx +56 -0
  46. package/src/components/dataDisplay/DataPairList/data-pair-list.stories.tsx +87 -0
  47. package/src/components/dataDisplay/DataPairList/index.ts +2 -0
  48. package/src/components/dataDisplay/DataPairList/types.ts +10 -0
  49. package/src/components/dataDisplay/DropDownMenu/index.ts +1 -0
  50. package/src/components/dataDisplay/ErrorMessage/ErrorMessage.tsx +6 -0
  51. package/src/components/dataDisplay/ErrorMessage/index.ts +1 -0
  52. package/src/components/dataDisplay/Icon/Icon.stories.tsx +21 -0
  53. package/src/components/dataDisplay/Icon/Icon.tsx +47 -0
  54. package/src/components/dataDisplay/Icon/index.ts +1 -0
  55. package/src/components/dataDisplay/Icon/types.ts +6 -0
  56. package/src/components/dataDisplay/Label/Label.stories.tsx +21 -0
  57. package/src/components/dataDisplay/Label/Label.tsx +10 -0
  58. package/src/components/dataDisplay/Label/index.ts +1 -0
  59. package/src/components/dataDisplay/Table/Table.tsx +173 -0
  60. package/src/components/dataDisplay/Table/columns.tsx +223 -0
  61. package/src/components/dataDisplay/Table/components/DynamicTable/data-table-column-header.tsx +72 -0
  62. package/src/components/dataDisplay/Table/components/DynamicTable/data-table-pagination.tsx +91 -0
  63. package/src/components/dataDisplay/Table/components/DynamicTable/data-table-toolbar.tsx +17 -0
  64. package/src/components/dataDisplay/Table/components/DynamicTable/data-table-view-options.tsx +58 -0
  65. package/src/components/dataDisplay/Table/components/DynamicTable/data-table.stories.tsx +118 -0
  66. package/src/components/dataDisplay/Table/components/DynamicTable/index.tsx +136 -0
  67. package/src/components/dataDisplay/Table/components/DynamicTable/types.ts +43 -0
  68. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-column-header.tsx +71 -0
  69. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-faceted-filter.tsx +147 -0
  70. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-pagination.tsx +97 -0
  71. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-row-actions.tsx +78 -0
  72. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-toolbar.tsx +60 -0
  73. package/src/components/dataDisplay/Table/custom/CustomTable/data-table-view-options.tsx +59 -0
  74. package/src/components/dataDisplay/Table/custom/CustomTable/data-table.tsx +145 -0
  75. package/src/components/dataDisplay/Table/custom/CustomTable/data.ts +71 -0
  76. package/src/components/dataDisplay/Table/custom/CustomTable/index.tsx +34 -0
  77. package/src/components/dataDisplay/Table/custom/CustomTable/schema.ts +11 -0
  78. package/src/components/dataDisplay/Table/index.ts +2 -0
  79. package/src/components/dataDisplay/Table/table.stories.tsx +147 -0
  80. package/src/components/dataDisplay/Table/types.ts +15 -0
  81. package/src/components/dataDisplay/Tabs/Tabs.stories.tsx +34 -0
  82. package/src/components/dataDisplay/Tabs/Tabs.tsx +53 -0
  83. package/src/components/dataDisplay/Tabs/index.ts +1 -0
  84. package/src/components/dataDisplay/Text/Text.stories.tsx +66 -0
  85. package/src/components/dataDisplay/Text/Text.tsx +56 -0
  86. package/src/components/dataDisplay/Text/index.ts +1 -0
  87. package/src/components/dataDisplay/index.ts +8 -0
  88. package/src/components/dataInput/Input/components/Input/Input.stories.tsx +99 -0
  89. package/src/components/dataInput/Input/components/Input/InputBase.tsx +50 -0
  90. package/src/components/dataInput/Input/components/Input/__tests__/Input.test.tsx +100 -0
  91. package/src/components/dataInput/Input/components/Input/index.tsx +257 -0
  92. package/src/components/dataInput/Input/components/InputBase/__tests__/InputBase.test.tsx +120 -0
  93. package/src/components/dataInput/Input/components/InputBase/index.tsx +89 -0
  94. package/src/components/dataInput/Input/components/MaskInput/__tests__/mask-input.test.tsx +67 -0
  95. package/src/components/dataInput/Input/components/MaskInput/index.ts +1 -0
  96. package/src/components/dataInput/Input/components/MaskInput/mask-input.stories.tsx +59 -0
  97. package/src/components/dataInput/Input/components/MaskInput/mask-input.tsx +43 -0
  98. package/src/components/dataInput/Input/components/MultipleInput/MultipleInput.tsx +36 -0
  99. package/src/components/dataInput/Input/components/MultipleInput/MultipleInputBase.tsx +100 -0
  100. package/src/components/dataInput/Input/components/MultipleInput/MultipleMaskInput.tsx +35 -0
  101. package/src/components/dataInput/Input/components/MultipleInput/MultipleNumberInput.tsx +35 -0
  102. package/src/components/dataInput/Input/components/MultipleInput/index.ts +2 -0
  103. package/src/components/dataInput/Input/components/MultipleInput/multiple-input.stories.tsx +71 -0
  104. package/src/components/dataInput/Input/components/NumberInput/__tests__/number-input.test.tsx +95 -0
  105. package/src/components/dataInput/Input/components/NumberInput/index.ts +1 -0
  106. package/src/components/dataInput/Input/components/NumberInput/number-input.stories.tsx +76 -0
  107. package/src/components/dataInput/Input/components/NumberInput/number-input.tsx +68 -0
  108. package/src/components/dataInput/Input/index.ts +4 -0
  109. package/src/components/dataInput/Select/MultiSelect/MultiSelect.stories.tsx +119 -0
  110. package/src/components/dataInput/Select/MultiSelect/MultiSelectBase.tsx +135 -0
  111. package/src/components/dataInput/Select/MultiSelect/index.tsx +75 -0
  112. package/src/components/dataInput/Select/Select.stories.tsx +61 -0
  113. package/src/components/dataInput/Select/Select.tsx +73 -0
  114. package/src/components/dataInput/Select/SelectBase.tsx +58 -0
  115. package/src/components/dataInput/Select/index.ts +2 -0
  116. package/src/components/dataInput/Switch/Switch.stories.tsx +75 -0
  117. package/src/components/dataInput/Switch/Switch.tsx +52 -0
  118. package/src/components/dataInput/Switch/index.ts +1 -0
  119. package/src/components/dataInput/checkbox/Checkbox.tsx +57 -0
  120. package/src/components/dataInput/checkbox/Checkbox_old.tsx +58 -0
  121. package/src/components/dataInput/checkbox/Checkout.stories.tsx +62 -0
  122. package/src/components/dataInput/checkbox/index.ts +1 -0
  123. package/src/components/dataInput/form/Form.tsx +47 -0
  124. package/src/components/dataInput/form/index.ts +3 -0
  125. package/src/components/dataInput/index.ts +5 -0
  126. package/src/components/feedback/Spinner/index.ts +1 -0
  127. package/src/components/feedback/Toast/Toast.stories.tsx +45 -0
  128. package/src/components/feedback/Toast/index.ts +2 -0
  129. package/src/components/feedback/index.ts +2 -0
  130. package/src/components/index.ts +6 -0
  131. package/src/components/layout/Crud/components/Table/index.tsx +183 -0
  132. package/src/components/layout/Crud/components/Table/types.ts +15 -0
  133. package/src/components/layout/Crud/crud.stories.tsx +317 -0
  134. package/src/components/layout/Crud/hook/useCrudLayout/index.tsx +94 -0
  135. package/src/components/layout/Crud/hook/useRequest/index.tsx +156 -0
  136. package/src/components/layout/Crud/index.tsx +295 -0
  137. package/src/components/layout/Crud/store/CrudLayoutStore.ts +75 -0
  138. package/src/components/layout/Crud/types.ts +14 -0
  139. package/src/components/layout/Drawer/CustomDrawer/index.tsx +33 -0
  140. package/src/components/layout/Drawer/Drawer.stories.tsx +80 -0
  141. package/src/components/layout/Drawer/index.ts +2 -0
  142. package/src/components/layout/PageLayout/PageLayout.stories.tsx +42 -0
  143. package/src/components/layout/PageLayout/index.tsx +28 -0
  144. package/src/components/layout/Separator/index.ts +1 -0
  145. package/src/components/layout/Sheet/Sheet.stories.tsx +28 -0
  146. package/src/components/layout/Sheet/Sheet.tsx +22 -0
  147. package/src/components/layout/Sheet/index.ts +1 -0
  148. package/src/components/layout/Sidebar/Sidebar.stories.tsx +116 -0
  149. package/src/components/layout/Sidebar/Sidebar.tsx +50 -0
  150. package/src/components/layout/Sidebar/components/app-sidebar.tsx +203 -0
  151. package/src/components/layout/Sidebar/components/footer-sidebar.tsx +17 -0
  152. package/src/components/layout/Sidebar/components/header-sidebar.tsx +90 -0
  153. package/src/components/layout/Sidebar/components/menus.tsx +55 -0
  154. package/src/components/layout/Sidebar/components/nav-projects.tsx +88 -0
  155. package/src/components/layout/Sidebar/components/nav-user.tsx +114 -0
  156. package/src/components/layout/Sidebar/components/team-switcher.tsx +85 -0
  157. package/src/components/layout/Sidebar/index.ts +2 -0
  158. package/src/components/layout/Sidebar/provider/index.tsx +51 -0
  159. package/src/components/layout/Tabs/Tabs.tsx +51 -0
  160. package/src/components/layout/Tabs/index.ts +1 -0
  161. package/src/components/layout/Tabs/tabs.stories.tsx +57 -0
  162. package/src/components/layout/index.ts +6 -0
  163. package/src/components/navigation/Breadcrumbs/Breadcrumbs.tsx +66 -0
  164. package/src/components/navigation/Breadcrumbs/index.ts +2 -0
  165. package/src/components/navigation/index.ts +1 -0
  166. package/src/components/ui/alert-dialog.tsx +141 -0
  167. package/src/components/ui/alert.tsx +59 -0
  168. package/src/components/ui/avatar.tsx +50 -0
  169. package/src/components/ui/badge.tsx +40 -0
  170. package/src/components/ui/breadcrumb.tsx +115 -0
  171. package/src/components/ui/button.tsx +57 -0
  172. package/src/components/ui/card.tsx +83 -0
  173. package/src/components/ui/checkbox.tsx +34 -0
  174. package/src/components/ui/collapsible.tsx +11 -0
  175. package/src/components/ui/command.tsx +153 -0
  176. package/src/components/ui/dialog.tsx +124 -0
  177. package/src/components/ui/drawer.tsx +117 -0
  178. package/src/components/ui/dropdown-menu.tsx +201 -0
  179. package/src/components/ui/form.tsx +179 -0
  180. package/src/components/ui/input.tsx +24 -0
  181. package/src/components/ui/label.tsx +30 -0
  182. package/src/components/ui/popover.tsx +33 -0
  183. package/src/components/ui/select.tsx +161 -0
  184. package/src/components/ui/separator.tsx +31 -0
  185. package/src/components/ui/sheet.tsx +140 -0
  186. package/src/components/ui/sidebar.tsx +763 -0
  187. package/src/components/ui/skeleton.tsx +15 -0
  188. package/src/components/ui/sonner.tsx +31 -0
  189. package/src/components/ui/spinner.tsx +54 -0
  190. package/src/components/ui/switch.tsx +33 -0
  191. package/src/components/ui/table.tsx +120 -0
  192. package/src/components/ui/tabs.tsx +55 -0
  193. package/src/components/ui/toast.tsx +130 -0
  194. package/src/components/ui/toaster.tsx +35 -0
  195. package/src/components/ui/tooltip.tsx +32 -0
  196. package/src/hooks/use-mobile.tsx +19 -0
  197. package/src/hooks/use-toast.ts +191 -0
  198. package/src/index.ts +1 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/tailwind.config.ts +83 -0
  201. package/tsconfig.json +27 -0
@@ -0,0 +1,95 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { NumberInput } from "../number-input";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ describe("NumberInput", () => {
7
+ it("deve renderizar o input com label", () => {
8
+ render(<NumberInput label="Valor" data-testid="test" />);
9
+
10
+ expect(screen.getByTestId("test-number-input")).toBeInTheDocument();
11
+ });
12
+
13
+ it("deve renderizar a descrição quando fornecida", () => {
14
+ render(<NumberInput description="Digite um número" />);
15
+
16
+ expect(screen.getByText("Digite um número")).toBeInTheDocument();
17
+ });
18
+
19
+ it("deve exibir mensagem de erro quando fornecida", () => {
20
+ render(<NumberInput error="Valor inválido" />);
21
+
22
+ expect(screen.getByText("Valor inválido")).toBeInTheDocument();
23
+ });
24
+
25
+ it("deve chamar onValueChange com o valor numérico correto", async () => {
26
+ const onValueChange = jest.fn();
27
+ render(<NumberInput onChange={onValueChange} />);
28
+
29
+ const input = screen.getByRole("textbox");
30
+ await userEvent.type(input, "123.45");
31
+
32
+ expect(onValueChange).toHaveBeenCalledWith(123.45);
33
+ });
34
+
35
+ it("deve aceitar apenas números e separador decimal", async () => {
36
+ const onValueChange = jest.fn();
37
+ render(<NumberInput onChange={onValueChange} />);
38
+
39
+ const input = screen.getByRole("textbox");
40
+ await userEvent.type(input, "abc123.45def");
41
+
42
+ expect(input).toHaveValue("123.45");
43
+ });
44
+
45
+ it("deve renderizar componente adicional quando fornecido", () => {
46
+ render(
47
+ <NumberInput
48
+ component={<button data-testid="extra-component">Extra</button>}
49
+ />
50
+ );
51
+
52
+ expect(screen.getByTestId("extra-component")).toBeInTheDocument();
53
+ });
54
+
55
+ it("deve aplicar className customizada", () => {
56
+ render(<NumberInput className="custom-class" />);
57
+
58
+ expect(
59
+ screen.getByRole("textbox").parentElement?.parentElement
60
+ ).toHaveClass("custom-class");
61
+ });
62
+
63
+ it("deve formatar o valor inicial corretamente", () => {
64
+ render(
65
+ <NumberInput
66
+ value={1234.56}
67
+ data-testid="test"
68
+ thousandSeparator="."
69
+ decimalSeparator=","
70
+ fixedDecimalScale
71
+ decimalScale={2}
72
+ />
73
+ );
74
+
75
+ expect(screen.getByTestId("test-number-input")).toHaveValue("1.234,56");
76
+ });
77
+
78
+ it("deve formatar o valor conforme digitação", async () => {
79
+ const user = userEvent.setup();
80
+ render(
81
+ <NumberInput
82
+ data-testid="test"
83
+ thousandSeparator="."
84
+ decimalSeparator=","
85
+ fixedDecimalScale
86
+ decimalScale={2}
87
+ />
88
+ );
89
+
90
+ const input = screen.getByTestId("test-number-input");
91
+ await user.type(input, "123456");
92
+
93
+ expect(input).toHaveValue("123.456,00");
94
+ });
95
+ });
@@ -0,0 +1 @@
1
+ export * from "./number-input";
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs";
2
+ import { NumberInput } from "./number-input";
3
+ import { useForm } from "react-hook-form";
4
+ import { Form } from "../../../../../components/ui/form";
5
+
6
+ const meta = {
7
+ title: "Data Input/Input/NumberInput",
8
+ component: NumberInput,
9
+ parameters: {
10
+ layout: "centered",
11
+ },
12
+ tags: ["autodocs"],
13
+ } satisfies Meta<typeof NumberInput>;
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ export const Default: Story = {
19
+ args: {
20
+ placeholder: "0,00",
21
+ label: "Valor",
22
+ },
23
+ };
24
+
25
+ export const Currency: Story = {
26
+ args: {
27
+ placeholder: "0,00",
28
+ label: "Preço",
29
+ prefix: "R$ ",
30
+ decimalScale: 2,
31
+ fixedDecimalScale: false,
32
+ value: 1234.56,
33
+ },
34
+ };
35
+
36
+ export const Percentage: Story = {
37
+ args: {
38
+ placeholder: "0,00",
39
+ label: "Porcentagem",
40
+ suffix: "%",
41
+ decimalScale: 1,
42
+ fixedDecimalScale: true,
43
+ value: 85.5,
44
+ },
45
+ };
46
+
47
+ export const CustomFormat: Story = {
48
+ args: {
49
+ placeholder: "0",
50
+ label: "Número",
51
+ thousandSeparator: ",",
52
+ decimalSeparator: ".",
53
+ value: 1234567.89,
54
+ },
55
+ };
56
+
57
+ export const WithForm = () => {
58
+ const form = useForm({
59
+ defaultValues: {
60
+ price: 99.99,
61
+ },
62
+ });
63
+
64
+ return (
65
+ <Form {...form}>
66
+ <form className="space-y-6">
67
+ <NumberInput
68
+ name="price"
69
+ label="Preço"
70
+ prefix="R$ "
71
+ description="Digite o preço do produto"
72
+ />
73
+ </form>
74
+ </Form>
75
+ );
76
+ };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { NumericFormat, NumericFormatProps } from "react-number-format";
5
+ import { InputBase, InputBaseProps } from "../InputBase";
6
+ import { Input } from "../../../../ui/input";
7
+
8
+ export type NumberInputProps = Omit<
9
+ NumericFormatProps,
10
+ "onChange" | "onValueChange"
11
+ > &
12
+ Omit<InputBaseProps, "children"> & {
13
+ onChange?: (value: number) => void;
14
+ "data-testid"?: string;
15
+ component?: React.ReactNode;
16
+ };
17
+
18
+ export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
19
+ (props, ref) => {
20
+ const {
21
+ name,
22
+ label,
23
+ error,
24
+ className,
25
+ withoutForm,
26
+ onChange,
27
+ "data-testid": testId,
28
+ component,
29
+ ...inputProps
30
+ } = props;
31
+
32
+ const baseTestId = testId || name || "";
33
+
34
+ return (
35
+ <InputBase
36
+ label={label}
37
+ error={error}
38
+ className={className}
39
+ name={name}
40
+ withoutForm={withoutForm}
41
+ data-testid={baseTestId}
42
+ >
43
+ {({ onChange: onBaseChange, value }) => (
44
+ <div
45
+ className="flex w-full gap-3"
46
+ data-testid={`number-input-wrapper-${baseTestId}`}
47
+ >
48
+ <NumericFormat
49
+ value={value as number}
50
+ customInput={Input}
51
+ getInputRef={ref}
52
+ onValueChange={({ floatValue }) => {
53
+ const numberValue = floatValue;
54
+ onBaseChange?.(numberValue);
55
+ onChange?.(numberValue as number);
56
+ }}
57
+ data-testid={`${baseTestId}-number-input`}
58
+ {...inputProps}
59
+ />
60
+ {component}
61
+ </div>
62
+ )}
63
+ </InputBase>
64
+ );
65
+ }
66
+ );
67
+
68
+ NumberInput.displayName = "NumberInput";
@@ -0,0 +1,4 @@
1
+ export * from "./components/Input";
2
+ export * from "./components/NumberInput";
3
+ export * from "./components/MaskInput";
4
+ export * from "./components/MultipleInput";
@@ -0,0 +1,119 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs";
2
+ import { MultiSelect } from "./index";
3
+ import { useState } from "react";
4
+ import { Form } from "../../form";
5
+ import { useForm } from "react-hook-form";
6
+
7
+ const meta: Meta<typeof MultiSelect> = {
8
+ title: "Data Input/MultiSelect",
9
+ component: MultiSelect,
10
+ tags: ["autodocs"],
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof MultiSelect>;
15
+
16
+ const options = [
17
+ { label: "Next.js", value: "nextjs" },
18
+ { label: "SvelteKit", value: "sveltekit" },
19
+ { label: "Nuxt.js", value: "nuxtjs" },
20
+ { label: "Remix", value: "remix" },
21
+ { label: "Astro", value: "astro" },
22
+ ];
23
+
24
+ const MultiSelectWithHooks = () => {
25
+ const [selected, setSelected] = useState<string[]>([]);
26
+
27
+ return (
28
+ <MultiSelect
29
+ label="MultiSelect"
30
+ options={options}
31
+ selected={selected}
32
+ onChange={setSelected}
33
+ placeholder="Select frameworks..."
34
+ />
35
+ );
36
+ };
37
+
38
+ export const Default: Story = {
39
+ render: () => <MultiSelectWithHooks />,
40
+ };
41
+
42
+ export const Preselected: Story = {
43
+ render: () => {
44
+ const [selected, setSelected] = useState<string[]>(["nextjs", "nuxtjs"]);
45
+
46
+ return (
47
+ <MultiSelect
48
+ options={options}
49
+ selected={selected}
50
+ onChange={setSelected}
51
+ placeholder="Select frameworks..."
52
+ />
53
+ );
54
+ },
55
+ };
56
+
57
+ export const CustomPlaceholder: Story = {
58
+ render: () => {
59
+ const [selected, setSelected] = useState<string[]>([]);
60
+
61
+ return (
62
+ <MultiSelect
63
+ options={options}
64
+ selected={selected}
65
+ onChange={setSelected}
66
+ placeholder="Choose your favorite frameworks..."
67
+ />
68
+ );
69
+ },
70
+ };
71
+
72
+ export const ManyOptions: Story = {
73
+ render: () => {
74
+ const [selected, setSelected] = useState<string[]>([]);
75
+ const manyOptions = [
76
+ ...options,
77
+ { label: "React", value: "react" },
78
+ { label: "Vue", value: "vue" },
79
+ { label: "Angular", value: "angular" },
80
+ { label: "Solid", value: "solid" },
81
+ { label: "Qwik", value: "qwik" },
82
+ { label: "Ember", value: "ember" },
83
+ { label: "Preact", value: "preact" },
84
+ { label: "Alpine", value: "alpine" },
85
+ ];
86
+
87
+ return (
88
+ <MultiSelect
89
+ options={manyOptions}
90
+ selected={selected}
91
+ onChange={setSelected}
92
+ placeholder="Select frameworks..."
93
+ />
94
+ );
95
+ },
96
+ };
97
+
98
+ export const WithForm: Story = {
99
+ args: {
100
+ name: "multiSelect",
101
+ options: [{ label: "Next.js", value: "nextjs" }],
102
+ label: "MultiSelect",
103
+ required: true,
104
+ },
105
+ decorators: [
106
+ (Story: React.ComponentType) => {
107
+ const form = useForm({
108
+ defaultValues: {
109
+ multiSelect: ["nextjs"],
110
+ },
111
+ });
112
+ return (
113
+ <Form {...form}>
114
+ <Story />
115
+ </Form>
116
+ );
117
+ },
118
+ ],
119
+ };
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Search, X } from "lucide-react";
5
+ import { cn } from "../../../../lib/utils";
6
+ import * as Popover from "@radix-ui/react-popover";
7
+
8
+ export type MultiSelectBaseProps = {
9
+ options: { label: string; value: string }[];
10
+ selected?: string[];
11
+ onChange?: (values: string[]) => void;
12
+ placeholder?: string;
13
+ className?: string;
14
+ };
15
+
16
+ export function MultiSelectBase({
17
+ options = [],
18
+ selected = [],
19
+ onChange = () => {},
20
+ placeholder = "Select frameworks...",
21
+ className,
22
+ }: MultiSelectBaseProps) {
23
+ const [open, setOpen] = React.useState(false);
24
+ const [inputValue, setInputValue] = React.useState("");
25
+ const inputRef = React.useRef<HTMLInputElement>(null);
26
+
27
+ const handleRemoveItem = (valueToRemove: string) => {
28
+ onChange(selected.filter((value) => value !== valueToRemove));
29
+ };
30
+
31
+ const handleSelectItem = (value: string) => {
32
+ if (selected.includes(value)) {
33
+ onChange(selected.filter((v) => v !== value));
34
+ } else {
35
+ onChange([...selected, value]);
36
+ }
37
+ };
38
+
39
+ const filteredOptions = options.filter((option) =>
40
+ option.label.toLowerCase().includes(inputValue.toLowerCase())
41
+ );
42
+
43
+ return (
44
+ <Popover.Root open={open} onOpenChange={setOpen}>
45
+ <Popover.Trigger asChild>
46
+ <div
47
+ className={cn(
48
+ "flex min-h-[40px] w-full flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
49
+ className
50
+ )}
51
+ >
52
+ {selected.map((value) => {
53
+ const option = options.find((o) => o.value === value);
54
+ if (!option) return null;
55
+
56
+ return (
57
+ <div
58
+ key={value}
59
+ className="flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-sm text-secondary-foreground"
60
+ >
61
+ {option.label}
62
+ <button
63
+ type="button"
64
+ onClick={(e) => {
65
+ e.stopPropagation();
66
+ handleRemoveItem(value);
67
+ }}
68
+ className="rounded-full hover:bg-secondary-foreground/20"
69
+ >
70
+ <X className="h-3 w-3" />
71
+ <span className="sr-only">Remove {option.label}</span>
72
+ </button>
73
+ </div>
74
+ );
75
+ })}
76
+ <div className="flex-1">
77
+ {selected.length === 0 && (
78
+ <span className="text-muted-foreground">{placeholder}</span>
79
+ )}
80
+ </div>
81
+ </div>
82
+ </Popover.Trigger>
83
+
84
+ <Popover.Portal>
85
+ <Popover.Content
86
+ className="w-[--radix-popover-trigger-width] z-50 mt-1 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
87
+ align="start"
88
+ sideOffset={4}
89
+ >
90
+ <div className="flex items-center border-b border-border px-3 py-2">
91
+ <Search className="h-4 w-4 text-muted-foreground" />
92
+ <input
93
+ ref={inputRef}
94
+ value={inputValue}
95
+ onChange={(e) => setInputValue(e.target.value)}
96
+ placeholder="Search..."
97
+ className="flex-1 bg-transparent px-2 outline-none placeholder:text-muted-foreground"
98
+ />
99
+ </div>
100
+ <div className="max-h-[200px] overflow-auto">
101
+ {filteredOptions.length === 0 ? (
102
+ <div className="px-2 py-4 text-center text-sm text-muted-foreground">
103
+ No results found.
104
+ </div>
105
+ ) : (
106
+ filteredOptions.map((option) => (
107
+ <div
108
+ key={option.value}
109
+ onClick={() => handleSelectItem(option.value)}
110
+ className={cn(
111
+ "flex cursor-pointer items-center gap-2 px-4 py-2 text-sm hover:bg-accent",
112
+ selected.includes(option.value) && "bg-accent"
113
+ )}
114
+ >
115
+ <div
116
+ className={cn(
117
+ "flex h-4 w-4 items-center justify-center rounded border border-primary",
118
+ selected.includes(option.value) &&
119
+ "bg-primary text-primary-foreground"
120
+ )}
121
+ >
122
+ {selected.includes(option.value) && (
123
+ <span className="text-[10px]">✓</span>
124
+ )}
125
+ </div>
126
+ {option.label}
127
+ </div>
128
+ ))
129
+ )}
130
+ </div>
131
+ </Popover.Content>
132
+ </Popover.Portal>
133
+ </Popover.Root>
134
+ );
135
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import React from "react";
3
+ import { Label } from "../../../../components/dataDisplay/Label";
4
+ import { MultiSelectBase, MultiSelectBaseProps } from "./MultiSelectBase";
5
+ import {
6
+ FormControl,
7
+ FormField,
8
+ FormItem,
9
+ FormLabel,
10
+ FormMessage,
11
+ } from "../../../../components/ui/form";
12
+ import { useFormContext } from "react-hook-form";
13
+
14
+ type MultiSelectProps = MultiSelectBaseProps & {
15
+ label?: string;
16
+ name?: string;
17
+ withoutForm?: boolean;
18
+ className?: string;
19
+ required?: boolean;
20
+ "data-testid"?: string;
21
+ };
22
+
23
+ export function MultiSelect({
24
+ label,
25
+ name,
26
+ withoutForm,
27
+ className,
28
+ required,
29
+ "data-testid": testId,
30
+ ...props
31
+ }: MultiSelectProps) {
32
+ const form = useFormContext();
33
+ const hasForm = !withoutForm && !!form && !!name;
34
+
35
+ if (!hasForm)
36
+ return (
37
+ <div className="grid w-full items-center gap-3">
38
+ {label && <Label htmlFor={name}>{label}</Label>}
39
+ <MultiSelectBase {...props} />
40
+ </div>
41
+ );
42
+
43
+ return (
44
+ <FormField
45
+ control={form.control}
46
+ name={name}
47
+ render={({ field: { value, ...rest } }) => {
48
+ return (
49
+ <FormItem
50
+ className={className}
51
+ data-testid={testId ? `form-item-${testId}` : undefined}
52
+ >
53
+ {label && (
54
+ <FormLabel
55
+ htmlFor={name}
56
+ data-testid={testId ? `form-label-${testId}` : undefined}
57
+ >
58
+ {`${label}${required ? " *" : ""}`}
59
+ </FormLabel>
60
+ )}
61
+ <FormControl>
62
+ <div className="flex w-full items-center space-x-2">
63
+ <MultiSelectBase {...props} {...rest} selected={value} />
64
+ </div>
65
+ </FormControl>
66
+ <FormMessage
67
+ role="alert"
68
+ data-testid={testId ? `form-message-${testId}` : undefined}
69
+ />
70
+ </FormItem>
71
+ );
72
+ }}
73
+ />
74
+ );
75
+ }
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs";
2
+ import { Select } from "./index";
3
+ import { useForm } from "react-hook-form";
4
+ import { Button } from "../../../components/actions";
5
+ import { Form } from "../form";
6
+
7
+ const meta: Meta<typeof Select> = {
8
+ title: "Data Input/Select",
9
+ component: Select,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ layout: "centered",
13
+ },
14
+ argTypes: {
15
+ options: { control: "object" },
16
+ placeholder: { control: "text" },
17
+ label: { control: "text" },
18
+ value: { control: "text" },
19
+ onChange: { action: "changed" },
20
+ },
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof Select>;
25
+
26
+ export const Default: Story = {
27
+ args: {
28
+ options: [
29
+ { value: "option1", label: "Option 1" },
30
+ { value: "option2", label: "Option 2" },
31
+ ],
32
+ placeholder: "Select an option",
33
+ label: "Select Label",
34
+ },
35
+ };
36
+
37
+ export const WithForm: Story = {
38
+ args: {
39
+ options: [
40
+ { value: "option1", label: "Option 1" },
41
+ { value: "option2", label: "Option 2" },
42
+ ],
43
+ placeholder: "Select an option",
44
+ label: "Select Label",
45
+ name: "select",
46
+ },
47
+ render: (args) => {
48
+ const form = useForm();
49
+ const onSubmit = (data: unknown) => console.log(data);
50
+ return (
51
+ <Form {...form}>
52
+ <form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
53
+ <Select {...args} />
54
+ <Button type="button" onClick={() => console.log(form.getValues())}>
55
+ Submit
56
+ </Button>
57
+ </form>
58
+ </Form>
59
+ );
60
+ },
61
+ };