@ponti-studios/ui 0.7.1 → 1.0.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.
- package/README.md +13 -0
- package/package.json +22 -20
- package/src/components/data-display/accordion.tsx +1 -1
- package/src/components/data-display/sort-controls.tsx +3 -3
- package/src/components/feedback/index.ts +0 -2
- package/src/components/forms/date-month-select.tsx +12 -3
- package/src/components/forms/date-picker.tsx +16 -12
- package/src/components/forms/drop-zone.tsx +1 -1
- package/src/components/forms/index.ts +4 -8
- package/src/components/forms/passkey-management.tsx +4 -10
- package/src/components/forms/radio-group.tsx +8 -4
- package/src/components/forms/select.tsx +1 -1
- package/src/components/forms/text-field.tsx +40 -5
- package/src/components/navigation/app-navigation.stories.tsx +111 -21
- package/src/components/navigation/app-navigation.tsx +165 -137
- package/src/components/navigation/index.ts +8 -4
- package/src/components/navigation/pagination-controls.stories.tsx +1 -1
- package/src/components/navigation/pagination-controls.tsx +10 -14
- package/src/components/overlays/dropdown-menu.stories.tsx +2 -2
- package/src/components/primitives/card.tsx +6 -10
- package/src/components/primitives/label.stories.tsx +0 -18
- package/src/components/primitives/label.tsx +12 -12
- package/src/constants/chart-colors.ts +16 -16
- package/src/lib/slot.tsx +29 -0
- package/src/styles/animations.css +0 -22
- package/src/styles/index.css +60 -6
- package/src/styles/tokens/color-systems.stories.tsx +2 -1
- package/src/styles/tokens/generated/light.colors.generated.ts +3 -3
- package/src/styles/tokens/generated/light.css +3 -3
- package/src/styles/tokens/source/light.tokens.json +3 -3
- package/src/styles/tokens/style-dictionary.light-storybook.json +16 -0
- package/src/components/feedback/file-upload-status-badge.stories.tsx +0 -28
- package/src/components/feedback/file-upload-status-badge.tsx +0 -33
- package/src/components/forms/field.stories.tsx +0 -62
- package/src/components/forms/field.tsx +0 -65
- package/src/components/forms/field.types.ts +0 -7
- package/src/components/forms/group-by-select.stories.tsx +0 -26
- package/src/components/forms/group-by-select.tsx +0 -47
- package/src/components/navigation/route-link.stories.tsx +0 -30
- package/src/components/navigation/route-link.tsx +0 -42
- package/src/hooks/use-derived-filter-state.stories.tsx +0 -136
- package/src/hooks/use-filter-state.stories.tsx +0 -102
package/src/styles/index.css
CHANGED
|
@@ -19,6 +19,30 @@
|
|
|
19
19
|
@import "./tokens/generated/dark.css" (prefers-color-scheme: dark);
|
|
20
20
|
@import "./animations.css";
|
|
21
21
|
|
|
22
|
+
@theme {
|
|
23
|
+
--animate-accordion-down: accordion-down 200ms ease-out;
|
|
24
|
+
--animate-accordion-up: accordion-up 200ms ease-out;
|
|
25
|
+
--animate-in: fade-in 150ms ease-out, zoom-in 150ms ease-out;
|
|
26
|
+
--animate-out: fade-out 120ms ease-in, zoom-out 120ms ease-in;
|
|
27
|
+
--animate-fade-in: fade-in 150ms ease-out;
|
|
28
|
+
--animate-fade-out: fade-out 120ms ease-in;
|
|
29
|
+
--animate-zoom-in: zoom-in 150ms ease-out;
|
|
30
|
+
--animate-zoom-out: zoom-out 120ms ease-in;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@utility fade-in-0 {
|
|
34
|
+
animation-name: fade-in;
|
|
35
|
+
}
|
|
36
|
+
@utility fade-out-0 {
|
|
37
|
+
animation-name: fade-out;
|
|
38
|
+
}
|
|
39
|
+
@utility zoom-in-95 {
|
|
40
|
+
animation-name: zoom-in;
|
|
41
|
+
}
|
|
42
|
+
@utility zoom-out-95 {
|
|
43
|
+
animation-name: zoom-out;
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
/* shadcn New York semantic contract. Values are generated from the DTCG
|
|
23
47
|
* source; this file only names the roles consumed by web components. */
|
|
24
48
|
@theme inline {
|
|
@@ -101,22 +125,52 @@
|
|
|
101
125
|
* Default fade size: 16 px (scoped to the element via inline var).
|
|
102
126
|
* Disable with scroll-fade-none (sets --scroll-fade-mask: none). */
|
|
103
127
|
@utility scroll-fade-x {
|
|
104
|
-
mask-image: var(
|
|
128
|
+
mask-image: var(
|
|
129
|
+
--scroll-fade-mask,
|
|
130
|
+
linear-gradient(
|
|
131
|
+
to right,
|
|
132
|
+
transparent,
|
|
133
|
+
black var(--scroll-fade-size, 16px),
|
|
134
|
+
black calc(100% - var(--scroll-fade-size, 16px)),
|
|
135
|
+
transparent
|
|
136
|
+
)
|
|
137
|
+
);
|
|
105
138
|
}
|
|
106
139
|
@utility scroll-fade-y {
|
|
107
|
-
mask-image: var(
|
|
140
|
+
mask-image: var(
|
|
141
|
+
--scroll-fade-mask,
|
|
142
|
+
linear-gradient(
|
|
143
|
+
to bottom,
|
|
144
|
+
transparent,
|
|
145
|
+
black var(--scroll-fade-size, 16px),
|
|
146
|
+
black calc(100% - var(--scroll-fade-size, 16px)),
|
|
147
|
+
transparent
|
|
148
|
+
)
|
|
149
|
+
);
|
|
108
150
|
}
|
|
109
151
|
@utility scroll-fade-l {
|
|
110
|
-
mask-image: var(
|
|
152
|
+
mask-image: var(
|
|
153
|
+
--scroll-fade-mask,
|
|
154
|
+
linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px))
|
|
155
|
+
);
|
|
111
156
|
}
|
|
112
157
|
@utility scroll-fade-r {
|
|
113
|
-
mask-image: var(
|
|
158
|
+
mask-image: var(
|
|
159
|
+
--scroll-fade-mask,
|
|
160
|
+
linear-gradient(to left, transparent, black var(--scroll-fade-size, 16px))
|
|
161
|
+
);
|
|
114
162
|
}
|
|
115
163
|
@utility scroll-fade-t {
|
|
116
|
-
mask-image: var(
|
|
164
|
+
mask-image: var(
|
|
165
|
+
--scroll-fade-mask,
|
|
166
|
+
linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px))
|
|
167
|
+
);
|
|
117
168
|
}
|
|
118
169
|
@utility scroll-fade-b {
|
|
119
|
-
mask-image: var(
|
|
170
|
+
mask-image: var(
|
|
171
|
+
--scroll-fade-mask,
|
|
172
|
+
linear-gradient(to top, transparent, black var(--scroll-fade-size, 16px))
|
|
173
|
+
);
|
|
120
174
|
}
|
|
121
175
|
@utility scroll-fade-none {
|
|
122
176
|
--scroll-fade-mask: none;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
|
|
3
3
|
import { Button } from "../../components/primitives/button";
|
|
4
|
+
import { colors } from "./css-vars";
|
|
4
5
|
import { colorTokenNames } from "./index";
|
|
5
6
|
|
|
6
7
|
const meta: Meta = { title: "Foundations/Colors", parameters: { layout: "fullscreen" } };
|
|
@@ -28,7 +29,7 @@ export const SystemAppearance: Story = {
|
|
|
28
29
|
<div
|
|
29
30
|
key={token}
|
|
30
31
|
className="border-border grid min-h-20 content-end rounded-md border p-3"
|
|
31
|
-
style={{ backgroundColor:
|
|
32
|
+
style={{ backgroundColor: colors[token] }}
|
|
32
33
|
>
|
|
33
34
|
<span className="bg-background text-foreground w-fit rounded px-1 font-mono text-xs">
|
|
34
35
|
{token}
|
|
@@ -24,8 +24,8 @@ export default {
|
|
|
24
24
|
"chart-3": "#12a594",
|
|
25
25
|
"chart-4": "#f76b15",
|
|
26
26
|
"chart-5": "#8b8d98",
|
|
27
|
-
"chart-positive": "#
|
|
28
|
-
"chart-negative": "#
|
|
29
|
-
"chart-neutral": "#
|
|
27
|
+
"chart-positive": "#1f7a50",
|
|
28
|
+
"chart-negative": "#be2e33",
|
|
29
|
+
"chart-neutral": "#626a73",
|
|
30
30
|
"overlay-scrim": "#000000",
|
|
31
31
|
};
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
--chart-3: #12a594;
|
|
25
25
|
--chart-4: #f76b15;
|
|
26
26
|
--chart-5: #8b8d98;
|
|
27
|
-
--chart-positive: #
|
|
28
|
-
--chart-negative: #
|
|
29
|
-
--chart-neutral: #
|
|
27
|
+
--chart-positive: #1f7a50;
|
|
28
|
+
--chart-negative: #be2e33;
|
|
29
|
+
--chart-neutral: #626a73;
|
|
30
30
|
--overlay-scrim: #000000;
|
|
31
31
|
}
|
|
@@ -88,15 +88,15 @@
|
|
|
88
88
|
},
|
|
89
89
|
"chart-positive": {
|
|
90
90
|
"$type": "color",
|
|
91
|
-
"$value": "rgb(
|
|
91
|
+
"$value": "rgb(31, 122, 80)"
|
|
92
92
|
},
|
|
93
93
|
"chart-negative": {
|
|
94
94
|
"$type": "color",
|
|
95
|
-
"$value": "rgb(
|
|
95
|
+
"$value": "rgb(190, 46, 51)"
|
|
96
96
|
},
|
|
97
97
|
"chart-neutral": {
|
|
98
98
|
"$type": "color",
|
|
99
|
-
"$value": "rgb(
|
|
99
|
+
"$value": "rgb(98, 106, 115)"
|
|
100
100
|
},
|
|
101
101
|
"overlay-scrim": {
|
|
102
102
|
"$type": "color",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": ["src/styles/tokens/source/light.tokens.json"],
|
|
3
|
+
"platforms": {
|
|
4
|
+
"css": {
|
|
5
|
+
"transformGroup": "css",
|
|
6
|
+
"buildPath": ".storybook/generated/",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"destination": "light-theme-class.css",
|
|
10
|
+
"format": "css/variables",
|
|
11
|
+
"options": { "selector": ":root.storybook-theme-light" }
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { FileUploadStatusBadge } from "./file-upload-status-badge";
|
|
4
|
-
|
|
5
|
-
const meta = {
|
|
6
|
-
title: "Feedback/FileUploadStatusBadge",
|
|
7
|
-
component: FileUploadStatusBadge,
|
|
8
|
-
tags: ["autodocs"],
|
|
9
|
-
} satisfies Meta<typeof FileUploadStatusBadge>;
|
|
10
|
-
|
|
11
|
-
export default meta;
|
|
12
|
-
type Story = StoryObj<typeof meta>;
|
|
13
|
-
|
|
14
|
-
export const Default: Story = {
|
|
15
|
-
args: { status: "uploading" },
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export const AllStatuses: Story = {
|
|
19
|
-
render: () => (
|
|
20
|
-
<div className="flex flex-wrap gap-2">
|
|
21
|
-
<FileUploadStatusBadge status="uploading" />
|
|
22
|
-
<FileUploadStatusBadge status="processing" />
|
|
23
|
-
<FileUploadStatusBadge status="queued" />
|
|
24
|
-
<FileUploadStatusBadge status="done" />
|
|
25
|
-
<FileUploadStatusBadge status="error" />
|
|
26
|
-
</div>
|
|
27
|
-
),
|
|
28
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
-
|
|
5
|
-
export type FileUploadStatusValue = "uploading" | "processing" | "queued" | "done" | "error";
|
|
6
|
-
|
|
7
|
-
const STATUS_CONFIG: Record<FileUploadStatusValue, { bg: string; text: string; label: string }> = {
|
|
8
|
-
uploading: { bg: "bg-muted", text: "text-foreground", label: "Uploading" },
|
|
9
|
-
processing: { bg: "bg-muted", text: "text-foreground", label: "Processing" },
|
|
10
|
-
queued: { bg: "bg-secondary", text: "text-secondary-foreground", label: "Queued" },
|
|
11
|
-
done: { bg: "bg-muted", text: "text-muted-foreground", label: "Complete" },
|
|
12
|
-
error: { bg: "bg-destructive/10", text: "text-destructive-text", label: "Error" },
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export const FileUploadStatusBadge = memo(function FileUploadStatusBadge({
|
|
16
|
-
status,
|
|
17
|
-
}: {
|
|
18
|
-
status?: string | undefined;
|
|
19
|
-
}) {
|
|
20
|
-
if (!status) return null;
|
|
21
|
-
|
|
22
|
-
const config = STATUS_CONFIG[status as FileUploadStatusValue] ?? {
|
|
23
|
-
bg: "bg-muted",
|
|
24
|
-
text: "text-foreground",
|
|
25
|
-
label: status,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<span className={cn("px-2 py-1 text-xs font-medium", config.bg, config.text)}>
|
|
30
|
-
{config.label}
|
|
31
|
-
</span>
|
|
32
|
-
);
|
|
33
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
-
|
|
4
|
-
import { Field } from "./field";
|
|
5
|
-
import { Input } from "./input";
|
|
6
|
-
|
|
7
|
-
const meta = {
|
|
8
|
-
title: "Forms/Field",
|
|
9
|
-
component: Field,
|
|
10
|
-
tags: ["autodocs"],
|
|
11
|
-
args: {
|
|
12
|
-
children: <Input placeholder="you@example.com" />,
|
|
13
|
-
},
|
|
14
|
-
} satisfies Meta<typeof Field>;
|
|
15
|
-
|
|
16
|
-
export default meta;
|
|
17
|
-
type Story = StoryObj<typeof meta>;
|
|
18
|
-
|
|
19
|
-
export const Default: Story = {
|
|
20
|
-
render: () => (
|
|
21
|
-
<Field label="Email" helpText="We will never share your address.">
|
|
22
|
-
<Input placeholder="you@example.com" />
|
|
23
|
-
</Field>
|
|
24
|
-
),
|
|
25
|
-
play: async ({ canvasElement }) => {
|
|
26
|
-
const canvas = within(canvasElement);
|
|
27
|
-
const input = canvas.getByLabelText("Email");
|
|
28
|
-
|
|
29
|
-
await userEvent.type(input, "you@example.com");
|
|
30
|
-
|
|
31
|
-
await expect(input).toHaveValue("you@example.com");
|
|
32
|
-
await expect(canvas.getByText("We will never share your address.")).toBeInTheDocument();
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export const Error: Story = {
|
|
37
|
-
render: () => (
|
|
38
|
-
<Field label="Email" error="Email is required">
|
|
39
|
-
<Input placeholder="you@example.com" />
|
|
40
|
-
</Field>
|
|
41
|
-
),
|
|
42
|
-
play: async ({ canvasElement }) => {
|
|
43
|
-
const canvas = within(canvasElement);
|
|
44
|
-
|
|
45
|
-
await expect(canvas.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
|
|
46
|
-
await expect(canvas.getByRole("alert")).toHaveTextContent("Email is required");
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export const Textarea: Story = {
|
|
51
|
-
render: () => (
|
|
52
|
-
<Field label="Notes" helpText="Markdown supported">
|
|
53
|
-
<textarea rows={5} placeholder="Write something..." />
|
|
54
|
-
</Field>
|
|
55
|
-
),
|
|
56
|
-
play: async ({ canvasElement }) => {
|
|
57
|
-
const canvas = within(canvasElement);
|
|
58
|
-
|
|
59
|
-
await expect(canvas.getByLabelText("Notes")).toBeInTheDocument();
|
|
60
|
-
await expect(canvas.getByText("Markdown supported")).toBeInTheDocument();
|
|
61
|
-
},
|
|
62
|
-
};
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
-
import type { FieldBaseProps } from "./field.types";
|
|
5
|
-
|
|
6
|
-
interface FieldProps extends FieldBaseProps {
|
|
7
|
-
children: React.ReactNode;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Field — standalone label + control + helper/error slot.
|
|
12
|
-
* Wires label ↔ control association automatically via a generated id.
|
|
13
|
-
* Does NOT require react-hook-form — works with any controlled or uncontrolled input.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* <Field label="Email" error={errors.email}>
|
|
17
|
-
* <Input type="email" />
|
|
18
|
-
* </Field>
|
|
19
|
-
*/
|
|
20
|
-
function Field({ label, helpText, error, required, children, id: externalId }: FieldProps) {
|
|
21
|
-
const generatedId = React.useId();
|
|
22
|
-
const id = externalId ?? generatedId;
|
|
23
|
-
const descId = `${id}-desc`;
|
|
24
|
-
const errorId = `${id}-error`;
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<div className="flex flex-col gap-1.5">
|
|
28
|
-
{label && (
|
|
29
|
-
<label
|
|
30
|
-
htmlFor={id}
|
|
31
|
-
className={cn(
|
|
32
|
-
"text-foreground text-sm font-medium",
|
|
33
|
-
required && "after:text-destructive-text after:ml-0.5 after:content-['*']",
|
|
34
|
-
)}
|
|
35
|
-
>
|
|
36
|
-
{label}
|
|
37
|
-
</label>
|
|
38
|
-
)}
|
|
39
|
-
|
|
40
|
-
{/* Clone child to inject id and aria attributes */}
|
|
41
|
-
{React.isValidElement(children)
|
|
42
|
-
? React.cloneElement(
|
|
43
|
-
children as React.ReactElement<React.HTMLAttributes<HTMLElement>>,
|
|
44
|
-
{
|
|
45
|
-
id,
|
|
46
|
-
"aria-describedby": error ? errorId : helpText ? descId : undefined,
|
|
47
|
-
"aria-invalid": error ? true : undefined,
|
|
48
|
-
} as React.HTMLAttributes<HTMLElement>,
|
|
49
|
-
)
|
|
50
|
-
: children}
|
|
51
|
-
|
|
52
|
-
{error ? (
|
|
53
|
-
<p id={errorId} role="alert" className="text-destructive-text text-sm">
|
|
54
|
-
{error}
|
|
55
|
-
</p>
|
|
56
|
-
) : helpText ? (
|
|
57
|
-
<p id={descId} className="text-muted-foreground text-sm">
|
|
58
|
-
{helpText}
|
|
59
|
-
</p>
|
|
60
|
-
) : null}
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export { Field, type FieldProps };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
|
|
4
|
-
import { GroupBySelect } from "./group-by-select";
|
|
5
|
-
|
|
6
|
-
const meta = {
|
|
7
|
-
title: "Forms/GroupBySelect",
|
|
8
|
-
component: GroupBySelect,
|
|
9
|
-
tags: ["autodocs"],
|
|
10
|
-
} satisfies Meta<typeof GroupBySelect>;
|
|
11
|
-
|
|
12
|
-
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof meta>;
|
|
14
|
-
|
|
15
|
-
function GroupBySelectPreview() {
|
|
16
|
-
const [groupBy, setGroupBy] = useState<"month" | "week" | "day">("month");
|
|
17
|
-
return <GroupBySelect groupBy={groupBy} onGroupByChange={setGroupBy} />;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const Default: Story = {
|
|
21
|
-
args: {
|
|
22
|
-
groupBy: "month",
|
|
23
|
-
onGroupByChange: () => {},
|
|
24
|
-
},
|
|
25
|
-
render: () => <GroupBySelectPreview />,
|
|
26
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { useId } from "react";
|
|
2
|
-
|
|
3
|
-
import { Label } from "../primitives/label";
|
|
4
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
5
|
-
|
|
6
|
-
type GroupByOption = "month" | "week" | "day";
|
|
7
|
-
|
|
8
|
-
export interface GroupBySelectProps {
|
|
9
|
-
groupBy: GroupByOption;
|
|
10
|
-
onGroupByChange: (value: GroupByOption) => void;
|
|
11
|
-
label?: string;
|
|
12
|
-
className?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Month/week/day grouping select. */
|
|
16
|
-
export function GroupBySelect({
|
|
17
|
-
groupBy,
|
|
18
|
-
onGroupByChange,
|
|
19
|
-
label = "Group By",
|
|
20
|
-
className,
|
|
21
|
-
}: GroupBySelectProps) {
|
|
22
|
-
const id = useId();
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<div className={`space-y-2 ${className || ""}`}>
|
|
26
|
-
<Label htmlFor={id}>{label}</Label>
|
|
27
|
-
<Select
|
|
28
|
-
name="groupBy"
|
|
29
|
-
value={groupBy}
|
|
30
|
-
onValueChange={(value) => {
|
|
31
|
-
if (value === "month" || value === "week" || value === "day") {
|
|
32
|
-
onGroupByChange(value);
|
|
33
|
-
}
|
|
34
|
-
}}
|
|
35
|
-
>
|
|
36
|
-
<SelectTrigger id={id}>
|
|
37
|
-
<SelectValue />
|
|
38
|
-
</SelectTrigger>
|
|
39
|
-
<SelectContent>
|
|
40
|
-
<SelectItem value="month">Month</SelectItem>
|
|
41
|
-
<SelectItem value="week">Week</SelectItem>
|
|
42
|
-
<SelectItem value="day">Day</SelectItem>
|
|
43
|
-
</SelectContent>
|
|
44
|
-
</Select>
|
|
45
|
-
</div>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { RouteLink } from "./route-link";
|
|
4
|
-
|
|
5
|
-
const meta = {
|
|
6
|
-
title: "Navigation/RouteLink",
|
|
7
|
-
component: RouteLink,
|
|
8
|
-
tags: ["autodocs"],
|
|
9
|
-
} satisfies Meta<typeof RouteLink>;
|
|
10
|
-
|
|
11
|
-
export default meta;
|
|
12
|
-
type Story = StoryObj<typeof meta>;
|
|
13
|
-
|
|
14
|
-
export const Default: Story = {
|
|
15
|
-
args: {
|
|
16
|
-
children: "View accounts",
|
|
17
|
-
href: "#",
|
|
18
|
-
className: "text-accent underline",
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const WithOnNavigate: Story = {
|
|
23
|
-
name: "With onNavigate callback",
|
|
24
|
-
args: {
|
|
25
|
-
children: "Go to analytics",
|
|
26
|
-
href: "#",
|
|
27
|
-
className: "text-accent underline",
|
|
28
|
-
onNavigate: () => console.log("route loading started"),
|
|
29
|
-
},
|
|
30
|
-
};
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { ComponentPropsWithoutRef, ElementType, MouseEvent, ReactNode } from "react";
|
|
2
|
-
import { useCallback } from "react";
|
|
3
|
-
|
|
4
|
-
type RouteLinkOwnProps<T extends ElementType> = {
|
|
5
|
-
as?: T;
|
|
6
|
-
children: ReactNode;
|
|
7
|
-
/** Called before the click is forwarded — e.g. to flip a route-loading store. */
|
|
8
|
-
onNavigate?: () => void;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type RouteLinkProps<T extends ElementType = "a"> = RouteLinkOwnProps<T> &
|
|
12
|
-
Omit<ComponentPropsWithoutRef<T>, keyof RouteLinkOwnProps<T>>;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Polymorphic link that fires `onNavigate` before `onClick`. Pass your
|
|
16
|
-
* router's `Link` component as `as` for client-side navigation (e.g.
|
|
17
|
-
* `as={Link} to="/accounts"`); the host app owns any route-loading state via
|
|
18
|
-
* `onNavigate` instead of a hardcoded store.
|
|
19
|
-
*/
|
|
20
|
-
export function RouteLink<T extends ElementType = "a">({
|
|
21
|
-
as,
|
|
22
|
-
children,
|
|
23
|
-
onNavigate,
|
|
24
|
-
onClick,
|
|
25
|
-
...props
|
|
26
|
-
}: RouteLinkProps<T>) {
|
|
27
|
-
const Component = (as ?? "a") as ElementType;
|
|
28
|
-
|
|
29
|
-
const handleClick = useCallback(
|
|
30
|
-
(e: MouseEvent) => {
|
|
31
|
-
onNavigate?.();
|
|
32
|
-
(onClick as ((e: MouseEvent) => void) | undefined)?.(e);
|
|
33
|
-
},
|
|
34
|
-
[onNavigate, onClick],
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<Component onClick={handleClick} {...props}>
|
|
39
|
-
{children}
|
|
40
|
-
</Component>
|
|
41
|
-
);
|
|
42
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
-
import { expect, userEvent, within } from 'storybook/test';
|
|
3
|
-
import { useRef } from 'react';
|
|
4
|
-
|
|
5
|
-
import { useDerivedFilterState, type FilterConfig } from './use-derived-filter-state';
|
|
6
|
-
|
|
7
|
-
function createMemorySync(initial: Record<string, string>) {
|
|
8
|
-
const store = { ...initial };
|
|
9
|
-
return {
|
|
10
|
-
read: () => ({ ...store }),
|
|
11
|
-
write: (values: Record<string, string>) => Object.assign(store, values),
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const fields = {
|
|
16
|
-
status: { default: "", label: (v: string) => `Status: ${v}` } satisfies FilterConfig,
|
|
17
|
-
search: { default: "" } satisfies FilterConfig,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function Harness() {
|
|
21
|
-
const sync = useRef(createMemorySync({ status: "", search: "" }));
|
|
22
|
-
const { values, setValue, activeFilters, clearAll, hasActive } =
|
|
23
|
-
useDerivedFilterState({ fields, sync: sync.current });
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<div data-testid="harness">
|
|
27
|
-
<span data-testid="values">{JSON.stringify(values)}</span>
|
|
28
|
-
<span data-testid="active-filters">
|
|
29
|
-
{activeFilters.map((f) => f.label).join(',')}
|
|
30
|
-
</span>
|
|
31
|
-
<span data-testid="has-active">{String(hasActive)}</span>
|
|
32
|
-
<button
|
|
33
|
-
data-testid="set-status"
|
|
34
|
-
onClick={() => setValue('status', 'active')}
|
|
35
|
-
>
|
|
36
|
-
Set status
|
|
37
|
-
</button>
|
|
38
|
-
<button
|
|
39
|
-
data-testid="set-search"
|
|
40
|
-
onClick={() => setValue('search', 'hello')}
|
|
41
|
-
>
|
|
42
|
-
Set search
|
|
43
|
-
</button>
|
|
44
|
-
<button
|
|
45
|
-
data-testid="clear-status"
|
|
46
|
-
onClick={() => setValue('status', '')}
|
|
47
|
-
>
|
|
48
|
-
Clear status
|
|
49
|
-
</button>
|
|
50
|
-
<button data-testid="clear-all" onClick={() => clearAll()}>
|
|
51
|
-
Clear all
|
|
52
|
-
</button>
|
|
53
|
-
</div>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const meta: Meta<typeof Harness> = {
|
|
58
|
-
title: 'Internal/useDerivedFilterState',
|
|
59
|
-
component: Harness,
|
|
60
|
-
tags: ['autodocs'],
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export default meta;
|
|
64
|
-
type Story = StoryObj<typeof meta>;
|
|
65
|
-
|
|
66
|
-
export const InitialState: Story = {
|
|
67
|
-
play: async ({ canvasElement }) => {
|
|
68
|
-
const canvas = within(canvasElement);
|
|
69
|
-
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
70
|
-
'{"status":"","search":""}',
|
|
71
|
-
);
|
|
72
|
-
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
73
|
-
await expect(canvas.getByTestId('active-filters')).toHaveTextContent('');
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export const SetValueCreatesActiveFilter: Story = {
|
|
78
|
-
play: async ({ canvasElement }) => {
|
|
79
|
-
const canvas = within(canvasElement);
|
|
80
|
-
await userEvent.click(canvas.getByTestId('set-status'));
|
|
81
|
-
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
82
|
-
'{"status":"active","search":""}',
|
|
83
|
-
);
|
|
84
|
-
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
85
|
-
'Status: active',
|
|
86
|
-
);
|
|
87
|
-
await expect(canvas.getByTestId('has-active')).toHaveTextContent('true');
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export const ClearValueRemovesActiveFilter: Story = {
|
|
92
|
-
play: async ({ canvasElement }) => {
|
|
93
|
-
const canvas = within(canvasElement);
|
|
94
|
-
await userEvent.click(canvas.getByTestId('set-status'));
|
|
95
|
-
await userEvent.click(canvas.getByTestId('clear-status'));
|
|
96
|
-
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
97
|
-
'{"status":"","search":""}',
|
|
98
|
-
);
|
|
99
|
-
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export const ClearAllResetsEverything: Story = {
|
|
104
|
-
play: async ({ canvasElement }) => {
|
|
105
|
-
const canvas = within(canvasElement);
|
|
106
|
-
await userEvent.click(canvas.getByTestId('set-status'));
|
|
107
|
-
await userEvent.click(canvas.getByTestId('set-search'));
|
|
108
|
-
await userEvent.click(canvas.getByTestId('clear-all'));
|
|
109
|
-
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
110
|
-
'{"status":"","search":""}',
|
|
111
|
-
);
|
|
112
|
-
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
113
|
-
await expect(canvas.getByTestId('active-filters')).toHaveTextContent('');
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
export const ActiveFilterOnRemoveClearsValue: Story = {
|
|
118
|
-
play: async ({ canvasElement }) => {
|
|
119
|
-
const canvas = within(canvasElement);
|
|
120
|
-
await userEvent.click(canvas.getByTestId('set-status'));
|
|
121
|
-
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
122
|
-
'Status: active',
|
|
123
|
-
);
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export const MultipleActiveFilters: Story = {
|
|
128
|
-
play: async ({ canvasElement }) => {
|
|
129
|
-
const canvas = within(canvasElement);
|
|
130
|
-
await userEvent.click(canvas.getByTestId('set-status'));
|
|
131
|
-
await userEvent.click(canvas.getByTestId('set-search'));
|
|
132
|
-
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
133
|
-
'Status: active,search: hello',
|
|
134
|
-
);
|
|
135
|
-
},
|
|
136
|
-
};
|