@boxcustodia/library 2.0.0-alpha.16 → 2.0.0-alpha.18

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 (45) hide show
  1. package/dist/index.cjs.js +1 -1
  2. package/dist/index.d.ts +40 -14
  3. package/dist/index.es.js +310 -282
  4. package/package.json +1 -1
  5. package/src/__doc__/V2.mdx +37 -1
  6. package/src/components/accordion/accordion.test.tsx +117 -0
  7. package/src/components/alert-dialog/alert-dialog.test.tsx +208 -22
  8. package/src/components/alert-dialog/alert-dialog.tsx +4 -4
  9. package/src/components/avatar/avatar.test.tsx +166 -29
  10. package/src/components/combobox/combobox.tsx +1 -1
  11. package/src/components/dialog/dialog.tsx +1 -1
  12. package/src/components/input/input.stories.tsx +2 -3
  13. package/src/components/input/input.test.tsx +109 -0
  14. package/src/components/label/label.tsx +1 -1
  15. package/src/components/number-input/number-input.test.tsx +155 -48
  16. package/src/components/toast/toast.tsx +1 -1
  17. package/src/hooks/index.ts +4 -3
  18. package/src/hooks/use-clipboard/index.ts +1 -0
  19. package/src/hooks/use-clipboard/use-clipboard.stories.tsx +168 -0
  20. package/src/hooks/use-clipboard/use-clipboard.test.tsx +83 -0
  21. package/src/hooks/use-clipboard/use-clipboard.tsx +64 -0
  22. package/src/hooks/use-document-title/index.ts +1 -0
  23. package/src/hooks/use-document-title/use-document-title.stories.tsx +72 -0
  24. package/src/hooks/use-document-title/use-document-title.test.tsx +75 -0
  25. package/src/hooks/use-document-title/use-document-title.tsx +32 -0
  26. package/src/hooks/use-hover/index.ts +1 -0
  27. package/src/hooks/use-hover/use-hover.stories.tsx +90 -0
  28. package/src/hooks/use-hover/use-hover.test.tsx +93 -0
  29. package/src/hooks/use-hover/use-hover.tsx +45 -0
  30. package/src/hooks/use-on-mount/index.ts +1 -0
  31. package/src/hooks/use-on-mount/use-on-mount.stories.tsx +85 -0
  32. package/src/hooks/use-on-mount/use-on-mount.test.tsx +44 -0
  33. package/src/hooks/use-on-mount/use-on-mount.tsx +13 -0
  34. package/src/utils/form.tsx +0 -2
  35. package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +0 -43
  36. package/src/hooks/useClipboard/__test__/useClipboard.test.tsx +0 -19
  37. package/src/hooks/useClipboard/index.ts +0 -1
  38. package/src/hooks/useClipboard/useClipboard.tsx +0 -28
  39. package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +0 -26
  40. package/src/hooks/useDocumentTitle/index.ts +0 -1
  41. package/src/hooks/useDocumentTitle/useDocumentTitle.tsx +0 -11
  42. package/src/hooks/useHover/__doc__/useHover.stories.tsx +0 -41
  43. package/src/hooks/useHover/__test__/useHover.test.tsx +0 -45
  44. package/src/hooks/useHover/index.ts +0 -1
  45. package/src/hooks/useHover/useHover.tsx +0 -40
@@ -107,7 +107,7 @@ export function DialogPopup({
107
107
  {children}
108
108
  {!hideClose && (
109
109
  <DialogBase.Close
110
- aria-label="Close"
110
+ aria-label="Cerrar"
111
111
  className={cn("absolute right-2 top-2", classNames?.closeButton)}
112
112
  render={<Button size="icon" variant="ghost" />}
113
113
  >
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { useState } from "react";
3
+ import { action } from "storybook/actions";
3
4
  import { Input, InputPrimitive } from "./input";
4
5
 
5
6
  /**
@@ -13,9 +14,7 @@ const meta: Meta<typeof Input> = {
13
14
  parameters: { layout: "centered" },
14
15
  args: {
15
16
  placeholder: "Placeholder...",
16
- },
17
- argTypes: {
18
- onValueChange: { control: false },
17
+ onValueChange: action("onValueChange"),
19
18
  },
20
19
  };
21
20
 
@@ -0,0 +1,109 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { Input, InputPrimitive } from "../../components";
4
+ import { type } from "../../utils/tests";
5
+
6
+ describe("Input composite", () => {
7
+ it("renders an input element with data-slot", () => {
8
+ render(<Input placeholder="Escribí algo" />);
9
+
10
+ const input = screen.getByPlaceholderText("Escribí algo");
11
+ expect(input).toBeInTheDocument();
12
+ expect(input.tagName).toBe("INPUT");
13
+ expect(input).toHaveAttribute("data-slot", "input");
14
+ });
15
+
16
+ it("forwards the value prop", () => {
17
+ render(<Input value="hola" onValueChange={() => {}} />);
18
+
19
+ expect(screen.getByDisplayValue("hola")).toBeInTheDocument();
20
+ });
21
+
22
+ it("calls onValueChange with the current value and the original event", () => {
23
+ const onValueChange = vi.fn();
24
+ render(<Input onValueChange={onValueChange} placeholder="Escribí" />);
25
+
26
+ type(screen.getByPlaceholderText("Escribí"), "abc");
27
+
28
+ expect(onValueChange).toHaveBeenCalledOnce();
29
+ expect(onValueChange).toHaveBeenCalledWith(
30
+ "abc",
31
+ expect.objectContaining({ target: expect.any(HTMLInputElement) }),
32
+ );
33
+ });
34
+
35
+ it("respects the disabled prop", () => {
36
+ render(<Input disabled placeholder="Disabled" />);
37
+
38
+ expect(screen.getByPlaceholderText("Disabled")).toBeDisabled();
39
+ });
40
+
41
+ it("merges a custom className with the base classes", () => {
42
+ render(<Input className="custom-class" placeholder="Estilado" />);
43
+
44
+ const input = screen.getByPlaceholderText("Estilado");
45
+ expect(input).toHaveClass("custom-class");
46
+ expect(input).toHaveClass("rounded-md");
47
+ });
48
+
49
+ it("sets autoComplete to off by default", () => {
50
+ render(<Input placeholder="Off" />);
51
+
52
+ expect(screen.getByPlaceholderText("Off")).toHaveAttribute(
53
+ "autocomplete",
54
+ "off",
55
+ );
56
+ });
57
+
58
+ it("applies number-input specific classes when type is number", () => {
59
+ render(<Input type="number" placeholder="Número" />);
60
+
61
+ expect(screen.getByPlaceholderText("Número")).toHaveClass(
62
+ "[&::-webkit-inner-spin-button]:appearance-none",
63
+ );
64
+ });
65
+
66
+ it("applies search-input specific classes when type is search", () => {
67
+ render(<Input type="search" placeholder="Buscar" />);
68
+
69
+ expect(screen.getByPlaceholderText("Buscar")).toHaveClass(
70
+ "[&::-webkit-search-cancel-button]:appearance-none",
71
+ );
72
+ });
73
+
74
+ it("applies file-input specific classes when type is file", () => {
75
+ render(<Input type="file" data-testid="file-input" />);
76
+
77
+ expect(screen.getByTestId("file-input")).toHaveClass(
78
+ "text-muted-foreground",
79
+ );
80
+ });
81
+
82
+ it("renders a plain input when nativeInput is true", () => {
83
+ const onValueChange = vi.fn();
84
+ render(
85
+ <Input nativeInput placeholder="Native" onValueChange={onValueChange} />,
86
+ );
87
+
88
+ const input = screen.getByPlaceholderText("Native");
89
+ expect(input).toHaveAttribute("data-slot", "input");
90
+
91
+ type(input, "x");
92
+ expect(onValueChange).toHaveBeenCalledWith(
93
+ "x",
94
+ expect.objectContaining({ target: expect.any(HTMLInputElement) }),
95
+ );
96
+ });
97
+ });
98
+
99
+ describe("Input primitive", () => {
100
+ it("exposes the Base UI Input via InputPrimitive", () => {
101
+ expect(InputPrimitive).toBeDefined();
102
+ });
103
+
104
+ it("can be rendered directly", () => {
105
+ render(<InputPrimitive placeholder="Primitive" />);
106
+
107
+ expect(screen.getByPlaceholderText("Primitive")).toBeInTheDocument();
108
+ });
109
+ });
@@ -40,7 +40,7 @@ export function Label({
40
40
  <Tooltip content={tooltip}>
41
41
  <button
42
42
  type="button"
43
- aria-label="More information"
43
+ aria-label="Más información"
44
44
  className={cn(
45
45
  "inline-flex cursor-default items-center text-muted-foreground hover:text-foreground transition-all ml-1",
46
46
  classNames?.tooltip,
@@ -1,87 +1,194 @@
1
1
  import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { createRef } from "react";
3
3
  import { describe, expect, it, vi } from "vitest";
4
- import { NumberInput } from "../../components";
5
-
6
- describe("NumberInput component", () => {
7
- it("se renderiza correctamente", () => {
8
- render(<NumberInput name="cantidad" />);
4
+ import {
5
+ NumberInput,
6
+ NumberInputDecrement,
7
+ NumberInputGroup,
8
+ NumberInputIncrement,
9
+ NumberInputInput,
10
+ NumberInputPrimitive,
11
+ NumberInputRoot,
12
+ } from "../../components";
13
+ import { click } from "../../utils/tests";
14
+
15
+ describe("NumberInput composite", () => {
16
+ it("renders an input with the group role", () => {
17
+ render(<NumberInput name="amount" />);
18
+
19
+ expect(screen.getByRole("group")).toBeInTheDocument();
9
20
  expect(screen.getByRole("textbox")).toBeInTheDocument();
10
21
  });
11
22
 
12
- it("acepta y muestra el valor inicial correctamente", () => {
13
- render(<NumberInput name="cantidad" value={10} />);
14
- const input = screen.getByRole("textbox") as HTMLInputElement;
15
- expect(input.value).toBe("10");
23
+ it("displays the controlled value", () => {
24
+ render(<NumberInput name="amount" value={10} />);
25
+
26
+ expect(screen.getByRole("textbox")).toHaveValue("10");
16
27
  });
17
28
 
18
- it("deshabilita el input cuando la prop disabled es true", () => {
19
- render(<NumberInput name="cantidad" disabled />);
29
+ it("respects the disabled prop", () => {
30
+ render(<NumberInput name="amount" disabled />);
31
+
20
32
  expect(screen.getByRole("textbox")).toBeDisabled();
21
33
  });
22
34
 
23
- it("acepta la prop className en el contenedor visual", () => {
24
- const TEST_CLASS = "mi-clase-personalizada";
25
- render(<NumberInput name="cantidad" className={TEST_CLASS} />);
26
- expect(screen.getByRole("group")).toHaveClass(TEST_CLASS);
35
+ it("forwards placeholder to the underlying input", () => {
36
+ render(<NumberInput name="amount" placeholder="Cantidad" />);
37
+
38
+ expect(screen.getByPlaceholderText("Cantidad")).toBeInTheDocument();
27
39
  });
28
40
 
29
- it("acepta la prop ref al input visible via inputProps", () => {
30
- const ref = createRef<HTMLInputElement>();
31
- render(<NumberInput name="cantidad" inputProps={{ ref }} />);
32
- expect(ref.current).not.toBeNull();
33
- expect(ref.current).toBeInstanceOf(HTMLInputElement);
41
+ it("marks the input as invalid when invalid is true", () => {
42
+ render(<NumberInput name="amount" invalid />);
43
+
44
+ expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
45
+ expect(screen.getByRole("group")).toHaveAttribute("aria-invalid", "true");
34
46
  });
35
47
 
36
- it("llama a onValueChange cuando el valor cambia", () => {
37
- const handleChange = vi.fn();
38
- render(<NumberInput name="cantidad" onValueChange={handleChange} />);
39
- const input = screen.getByRole("textbox");
48
+ it("calls onValueChange when the value changes", () => {
49
+ const onValueChange = vi.fn();
50
+ render(<NumberInput name="amount" onValueChange={onValueChange} />);
40
51
 
41
- fireEvent.input(input, { target: { value: "5" } });
42
- expect(handleChange).toHaveBeenCalledTimes(1);
52
+ fireEvent.input(screen.getByRole("textbox"), { target: { value: "5" } });
53
+
54
+ expect(onValueChange).toHaveBeenCalled();
43
55
  });
44
56
 
45
- it("respeta el valor mínimo", () => {
46
- render(<NumberInput name="cantidad" min={0} />);
47
- const input = screen.getByRole("textbox") as HTMLInputElement;
57
+ it("clamps to the min value on blur", () => {
58
+ render(<NumberInput name="amount" min={0} />);
48
59
 
60
+ const input = screen.getByRole("textbox");
49
61
  fireEvent.input(input, { target: { value: "-5" } });
50
62
  fireEvent.blur(input);
51
- expect(input.value).toBe("0");
63
+
64
+ expect(input).toHaveValue("0");
52
65
  });
53
66
 
54
- it("respeta el valor máximo", () => {
55
- render(<NumberInput name="cantidad" max={100} />);
56
- const input = screen.getByRole("textbox") as HTMLInputElement;
67
+ it("clamps to the max value on blur", () => {
68
+ render(<NumberInput name="amount" max={100} />);
57
69
 
70
+ const input = screen.getByRole("textbox");
58
71
  fireEvent.input(input, { target: { value: "150" } });
59
72
  fireEvent.blur(input);
60
- expect(input.value).toBe("100");
73
+
74
+ expect(input).toHaveValue("100");
61
75
  });
62
76
 
63
- it("incrementa el valor correctamente con los botones", () => {
64
- render(<NumberInput name="cantidad" defaultValue={5} />);
65
- const input = screen.getByRole("textbox") as HTMLInputElement;
66
- const incrementButton = screen.getByTestId("increment-trigger");
77
+ it("increments the value via the increment button", () => {
78
+ render(<NumberInput name="amount" defaultValue={5} />);
79
+
80
+ click(screen.getByTestId("increment-trigger"));
67
81
 
68
- fireEvent.click(incrementButton);
69
- expect(input.value).toBe("6");
82
+ expect(screen.getByRole("textbox")).toHaveValue("6");
70
83
  });
71
84
 
72
- it("decrementa el valor correctamente con los botones", () => {
73
- render(<NumberInput name="cantidad" defaultValue={5} />);
74
- const input = screen.getByRole("textbox") as HTMLInputElement;
75
- const decrementButton = screen.getByTestId("decrement-trigger");
85
+ it("decrements the value via the decrement button", () => {
86
+ render(<NumberInput name="amount" defaultValue={5} />);
87
+
88
+ click(screen.getByTestId("decrement-trigger"));
76
89
 
77
- fireEvent.click(decrementButton);
78
- expect(input.value).toBe("4");
90
+ expect(screen.getByRole("textbox")).toHaveValue("4");
79
91
  });
80
92
 
81
- it("oculta los botones cuando hideControls es true", () => {
82
- render(<NumberInput name="cantidad" hideControls />);
93
+ it("hides the increment and decrement controls when hideControls is true", () => {
94
+ render(<NumberInput name="amount" hideControls />);
95
+
83
96
  expect(screen.queryByTestId("increment-trigger")).not.toBeInTheDocument();
84
97
  expect(screen.queryByTestId("decrement-trigger")).not.toBeInTheDocument();
85
98
  expect(screen.getByRole("textbox")).toBeInTheDocument();
86
99
  });
100
+
101
+ it("applies className to the group and classNames to each slot", () => {
102
+ render(
103
+ <NumberInput
104
+ name="amount"
105
+ className="group-class"
106
+ classNames={{
107
+ input: "input-class",
108
+ controls: "controls-class",
109
+ increment: "increment-class",
110
+ decrement: "decrement-class",
111
+ }}
112
+ />,
113
+ );
114
+
115
+ expect(
116
+ document.querySelector('[data-slot="number-input-group"]'),
117
+ ).toHaveClass("group-class");
118
+ expect(
119
+ document.querySelector('[data-slot="number-input-input"]'),
120
+ ).toHaveClass("input-class");
121
+ expect(screen.getByTestId("increment-trigger")).toHaveClass(
122
+ "increment-class",
123
+ );
124
+ expect(screen.getByTestId("decrement-trigger")).toHaveClass(
125
+ "decrement-class",
126
+ );
127
+ expect(screen.getByTestId("increment-trigger").parentElement).toHaveClass(
128
+ "controls-class",
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("NumberInput primitives", () => {
134
+ const setup = (
135
+ children?: React.ReactNode,
136
+ rootProps?: React.ComponentProps<typeof NumberInputRoot>,
137
+ ) =>
138
+ render(
139
+ <NumberInputRoot name="amount" {...rootProps}>
140
+ <NumberInputGroup>
141
+ <NumberInputDecrement data-testid="decrement">-</NumberInputDecrement>
142
+ <NumberInputInput data-testid="input" />
143
+ <NumberInputIncrement data-testid="increment">+</NumberInputIncrement>
144
+ {children}
145
+ </NumberInputGroup>
146
+ </NumberInputRoot>,
147
+ );
148
+
149
+ it("composes Root, Group, Input, Increment, and Decrement directly", () => {
150
+ setup(undefined, { defaultValue: 2 });
151
+
152
+ expect(screen.getByTestId("input")).toHaveValue("2");
153
+
154
+ click(screen.getByTestId("increment"));
155
+ expect(screen.getByTestId("input")).toHaveValue("3");
156
+
157
+ click(screen.getByTestId("decrement"));
158
+ expect(screen.getByTestId("input")).toHaveValue("2");
159
+ });
160
+
161
+ it("forwards a ref to the underlying input via NumberInputInput", () => {
162
+ const ref = createRef<HTMLInputElement>();
163
+ render(
164
+ <NumberInputRoot name="amount">
165
+ <NumberInputGroup>
166
+ <NumberInputInput ref={ref} />
167
+ </NumberInputGroup>
168
+ </NumberInputRoot>,
169
+ );
170
+
171
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
172
+ });
173
+
174
+ it("is controlled by the value prop on the Root", () => {
175
+ const onValueChange = vi.fn();
176
+ render(
177
+ <NumberInputRoot name="amount" value={7} onValueChange={onValueChange}>
178
+ <NumberInputGroup>
179
+ <NumberInputInput data-testid="input" />
180
+ </NumberInputGroup>
181
+ </NumberInputRoot>,
182
+ );
183
+
184
+ expect(screen.getByTestId("input")).toHaveValue("7");
185
+ });
186
+
187
+ it("exposes the Base UI namespace via NumberInputPrimitive", () => {
188
+ expect(NumberInputPrimitive).toBeDefined();
189
+ expect(NumberInputPrimitive.Root).toBeDefined();
190
+ expect(NumberInputPrimitive.Input).toBeDefined();
191
+ expect(NumberInputPrimitive.Increment).toBeDefined();
192
+ expect(NumberInputPrimitive.Decrement).toBeDefined();
193
+ });
87
194
  });
@@ -373,7 +373,7 @@ function ToastList() {
373
373
  )}
374
374
  <Toast.Close
375
375
  className="absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded border-none bg-transparent text-current/50 hover:bg-foreground/10 hover:text-current"
376
- aria-label="Close"
376
+ aria-label="Cerrar"
377
377
  >
378
378
  <X className="h-3 w-3" />
379
379
  </Toast.Close>
@@ -2,21 +2,22 @@ export { useControllableState } from "@radix-ui/react-use-controllable-state";
2
2
  export * from "./useArray";
3
3
  export * from "./useAsync";
4
4
  export * from "./useClickOutside";
5
- export * from "./useClipboard";
5
+ export * from "./use-clipboard";
6
6
  export * from "./useDebounceCallback";
7
7
  export * from "./useDebounceValue";
8
8
  export * from "./useDisclosure";
9
- export * from "./useDocumentTitle";
9
+ export * from "./use-document-title";
10
10
  export * from "./useEventListener";
11
11
  export * from "./useFocusTrap";
12
12
  export * from "./useHotkey";
13
- export * from "./useHover";
13
+ export * from "./use-hover";
14
14
  export * from "./useIsVisible";
15
15
  export * from "./useLocalStorage";
16
16
  export * from "./useMediaQuery";
17
17
  export * from "./useMemoizedFn";
18
18
  export * from "./useMutation";
19
19
  export * from "./useObject";
20
+ export * from "./use-on-mount";
20
21
  export * from "./usePagination";
21
22
  export * from "./usePortal";
22
23
  export * from "./usePreventCloseWindow";
@@ -0,0 +1 @@
1
+ export * from "./use-clipboard";
@@ -0,0 +1,168 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { Button } from "../../components/button";
4
+ import { Input } from "../../components/input";
5
+ import { Stack } from "../../components/stack";
6
+ import { useClipboard } from "./use-clipboard";
7
+
8
+ /**
9
+ * Copy text to the clipboard without writing the same boilerplate every time.
10
+ *
11
+ * The hook gives you a `copied` flag that flips to `true` for a moment after a
12
+ * successful copy and then flips back on its own — perfect for the classic
13
+ * "Copy → Copied!" button. It also surfaces `error` so you can react when the
14
+ * browser blocks the operation.
15
+ *
16
+ * ```tsx
17
+ * const { copy, copied } = useClipboard();
18
+ *
19
+ * <Button onClick={() => copy("hello")}>
20
+ * {copied ? "Copied!" : "Copy"}
21
+ * </Button>
22
+ * ```
23
+ *
24
+ * Good to know:
25
+ * - `copy(value)` is fire-and-forget. It will never throw — check `error` if
26
+ * you need to handle failures (denied permissions, insecure context, etc.).
27
+ * - `copied` flips back to `false` automatically after `timeout` ms (default
28
+ * `2000`). Pass `useClipboard({ timeout })` to change the duration.
29
+ * - Need to reset early? `reset()` clears both `copied` and `error` right away.
30
+ * - Unmount-safe: the internal timeout is cleared on cleanup, so you can use
31
+ * it inside dialogs or conditionally rendered components without worrying.
32
+ */
33
+ const meta: Meta<typeof useClipboard> = {
34
+ title: "hooks/useClipboard",
35
+ parameters: { layout: "centered" },
36
+ tags: ["beta"],
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof useClipboard>;
41
+
42
+ /**
43
+ * The happy path: click the button and the label flips for two seconds before
44
+ * going back. That's the whole point of the hook in one story.
45
+ */
46
+ export const Default: Story = {
47
+ render: () => {
48
+ const { copy, copied } = useClipboard();
49
+
50
+ return (
51
+ <Button onClick={() => copy("Hello from useClipboard")}>
52
+ {copied ? "Copied!" : "Copy greeting"}
53
+ </Button>
54
+ );
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Real-world usage: copying whatever the user typed in. The button label
60
+ * reflects the latest copy — every click re-arms the timeout, so the "copied"
61
+ * feedback always lines up with the most recent action.
62
+ */
63
+ export const CopyFromInput: Story = {
64
+ render: () => {
65
+ const [value, setValue] = useState("npm i @boxcustodia/library@next");
66
+ const { copy, copied } = useClipboard();
67
+
68
+ return (
69
+ <Stack direction="vertical" gap={12} style={{ minWidth: 320 }}>
70
+ <Input
71
+ value={value}
72
+ onValueChange={(next) => setValue(next)}
73
+ placeholder="Type something to copy"
74
+ />
75
+ <Button onClick={() => copy(value)} disabled={!value}>
76
+ {copied ? "Copied to clipboard" : "Copy value"}
77
+ </Button>
78
+ </Stack>
79
+ );
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Two seconds is great for short labels, but for long snippets you may want
85
+ * the confirmation to stick around. Pass `timeout` in milliseconds to extend
86
+ * (or shorten) the window.
87
+ */
88
+ export const CustomTimeout: Story = {
89
+ render: () => {
90
+ const { copy, copied } = useClipboard({ timeout: 5000 });
91
+
92
+ return (
93
+ <Stack direction="vertical" gap={8} align="center">
94
+ <Button onClick={() => copy("Stays copied for 5 seconds")}>
95
+ {copied ? "Copied (5s)" : "Copy with 5s timeout"}
96
+ </Button>
97
+ <span className="text-xs text-muted-foreground">
98
+ The label sticks for 5000 ms instead of the default 2000 ms.
99
+ </span>
100
+ </Stack>
101
+ );
102
+ },
103
+ };
104
+
105
+ /**
106
+ * Sometimes you don't want to wait for the timeout — for example, when the
107
+ * user takes a follow-up action. Call `reset()` and the state goes back to
108
+ * idle right away.
109
+ */
110
+ export const ManualReset: Story = {
111
+ render: () => {
112
+ const { copy, copied, reset } = useClipboard({ timeout: 60_000 });
113
+
114
+ return (
115
+ <Stack gap={8}>
116
+ <Button onClick={() => copy("Persistent until reset")}>
117
+ {copied ? "Copied" : "Copy"}
118
+ </Button>
119
+ <Button variant="outline" onClick={reset} disabled={!copied}>
120
+ Reset
121
+ </Button>
122
+ </Stack>
123
+ );
124
+ },
125
+ };
126
+
127
+ /**
128
+ * Browsers can refuse to copy — denied permissions, an insecure origin, an
129
+ * older browser without the Clipboard API. When that happens `error` carries
130
+ * the reason and `copied` stays `false`, so you can branch your UI safely.
131
+ *
132
+ * ```tsx
133
+ * const { copy, copied, error } = useClipboard();
134
+ *
135
+ * <Button onClick={() => copy(value)}>Copy</Button>
136
+ * {error && <span className="text-error">{error.message}</span>}
137
+ * {copied && <span className="text-success">Copied!</span>}
138
+ * ```
139
+ */
140
+ export const ErrorHandling: Story = {
141
+ render: () => {
142
+ const { copy, copied, error, reset } = useClipboard();
143
+
144
+ return (
145
+ <Stack direction="vertical" gap={8} style={{ minWidth: 320 }}>
146
+ <Stack gap={8}>
147
+ <Button onClick={() => copy("Try copying me")}>Copy</Button>
148
+ <Button
149
+ variant="outline"
150
+ onClick={reset}
151
+ disabled={!copied && !error}
152
+ >
153
+ Reset
154
+ </Button>
155
+ </Stack>
156
+ {copied && (
157
+ <span className="text-xs text-success">Copied to clipboard.</span>
158
+ )}
159
+ {error && <span className="text-xs text-error">{error.message}</span>}
160
+ {!copied && !error && (
161
+ <span className="text-xs text-muted-foreground">
162
+ Click Copy. If the browser blocks it, you'll see the error here.
163
+ </span>
164
+ )}
165
+ </Stack>
166
+ );
167
+ },
168
+ };