@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.
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +40 -14
- package/dist/index.es.js +310 -282
- package/package.json +1 -1
- package/src/__doc__/V2.mdx +37 -1
- package/src/components/accordion/accordion.test.tsx +117 -0
- package/src/components/alert-dialog/alert-dialog.test.tsx +208 -22
- package/src/components/alert-dialog/alert-dialog.tsx +4 -4
- package/src/components/avatar/avatar.test.tsx +166 -29
- package/src/components/combobox/combobox.tsx +1 -1
- package/src/components/dialog/dialog.tsx +1 -1
- package/src/components/input/input.stories.tsx +2 -3
- package/src/components/input/input.test.tsx +109 -0
- package/src/components/label/label.tsx +1 -1
- package/src/components/number-input/number-input.test.tsx +155 -48
- package/src/components/toast/toast.tsx +1 -1
- package/src/hooks/index.ts +4 -3
- package/src/hooks/use-clipboard/index.ts +1 -0
- package/src/hooks/use-clipboard/use-clipboard.stories.tsx +168 -0
- package/src/hooks/use-clipboard/use-clipboard.test.tsx +83 -0
- package/src/hooks/use-clipboard/use-clipboard.tsx +64 -0
- package/src/hooks/use-document-title/index.ts +1 -0
- package/src/hooks/use-document-title/use-document-title.stories.tsx +72 -0
- package/src/hooks/use-document-title/use-document-title.test.tsx +75 -0
- package/src/hooks/use-document-title/use-document-title.tsx +32 -0
- package/src/hooks/use-hover/index.ts +1 -0
- package/src/hooks/use-hover/use-hover.stories.tsx +90 -0
- package/src/hooks/use-hover/use-hover.test.tsx +93 -0
- package/src/hooks/use-hover/use-hover.tsx +45 -0
- package/src/hooks/use-on-mount/index.ts +1 -0
- package/src/hooks/use-on-mount/use-on-mount.stories.tsx +85 -0
- package/src/hooks/use-on-mount/use-on-mount.test.tsx +44 -0
- package/src/hooks/use-on-mount/use-on-mount.tsx +13 -0
- package/src/utils/form.tsx +0 -2
- package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +0 -43
- package/src/hooks/useClipboard/__test__/useClipboard.test.tsx +0 -19
- package/src/hooks/useClipboard/index.ts +0 -1
- package/src/hooks/useClipboard/useClipboard.tsx +0 -28
- package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +0 -26
- package/src/hooks/useDocumentTitle/index.ts +0 -1
- package/src/hooks/useDocumentTitle/useDocumentTitle.tsx +0 -11
- package/src/hooks/useHover/__doc__/useHover.stories.tsx +0 -41
- package/src/hooks/useHover/__test__/useHover.test.tsx +0 -45
- package/src/hooks/useHover/index.ts +0 -1
- 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="
|
|
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="
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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("
|
|
13
|
-
render(<NumberInput name="
|
|
14
|
-
|
|
15
|
-
expect(
|
|
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("
|
|
19
|
-
render(<NumberInput name="
|
|
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("
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expect(screen.
|
|
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("
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
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("
|
|
37
|
-
const
|
|
38
|
-
render(<NumberInput name="
|
|
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(
|
|
42
|
-
|
|
52
|
+
fireEvent.input(screen.getByRole("textbox"), { target: { value: "5" } });
|
|
53
|
+
|
|
54
|
+
expect(onValueChange).toHaveBeenCalled();
|
|
43
55
|
});
|
|
44
56
|
|
|
45
|
-
it("
|
|
46
|
-
render(<NumberInput name="
|
|
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
|
-
|
|
63
|
+
|
|
64
|
+
expect(input).toHaveValue("0");
|
|
52
65
|
});
|
|
53
66
|
|
|
54
|
-
it("
|
|
55
|
-
render(<NumberInput name="
|
|
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
|
-
|
|
73
|
+
|
|
74
|
+
expect(input).toHaveValue("100");
|
|
61
75
|
});
|
|
62
76
|
|
|
63
|
-
it("
|
|
64
|
-
render(<NumberInput name="
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
expect(input.value).toBe("6");
|
|
82
|
+
expect(screen.getByRole("textbox")).toHaveValue("6");
|
|
70
83
|
});
|
|
71
84
|
|
|
72
|
-
it("
|
|
73
|
-
render(<NumberInput name="
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
expect(input.value).toBe("4");
|
|
90
|
+
expect(screen.getByRole("textbox")).toHaveValue("4");
|
|
79
91
|
});
|
|
80
92
|
|
|
81
|
-
it("
|
|
82
|
-
render(<NumberInput name="
|
|
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="
|
|
376
|
+
aria-label="Cerrar"
|
|
377
377
|
>
|
|
378
378
|
<X className="h-3 w-3" />
|
|
379
379
|
</Toast.Close>
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 "./
|
|
5
|
+
export * from "./use-clipboard";
|
|
6
6
|
export * from "./useDebounceCallback";
|
|
7
7
|
export * from "./useDebounceValue";
|
|
8
8
|
export * from "./useDisclosure";
|
|
9
|
-
export * from "./
|
|
9
|
+
export * from "./use-document-title";
|
|
10
10
|
export * from "./useEventListener";
|
|
11
11
|
export * from "./useFocusTrap";
|
|
12
12
|
export * from "./useHotkey";
|
|
13
|
-
export * from "./
|
|
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
|
+
};
|