@slugbase/ui 0.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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/components/AppShell.d.ts +17 -0
- package/dist/components/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell.js +11 -0
- package/dist/components/AppShell.js.map +1 -0
- package/dist/components/Badge.d.ts +13 -0
- package/dist/components/Badge.d.ts.map +1 -0
- package/dist/components/Badge.js +21 -0
- package/dist/components/Badge.js.map +1 -0
- package/dist/components/Button.d.ts +11 -0
- package/dist/components/Button.d.ts.map +1 -0
- package/dist/components/Button.js +21 -0
- package/dist/components/Button.js.map +1 -0
- package/dist/components/Checkbox.d.ts +16 -0
- package/dist/components/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox.js +19 -0
- package/dist/components/Checkbox.js.map +1 -0
- package/dist/components/ColorPicker.d.ts +14 -0
- package/dist/components/ColorPicker.d.ts.map +1 -0
- package/dist/components/ColorPicker.js +29 -0
- package/dist/components/ColorPicker.js.map +1 -0
- package/dist/components/ConfirmDialog.d.ts +17 -0
- package/dist/components/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog.js +8 -0
- package/dist/components/ConfirmDialog.js.map +1 -0
- package/dist/components/Dialog.d.ts +15 -0
- package/dist/components/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog.js +12 -0
- package/dist/components/Dialog.js.map +1 -0
- package/dist/components/EmptyState.d.ts +12 -0
- package/dist/components/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState.js +6 -0
- package/dist/components/EmptyState.js.map +1 -0
- package/dist/components/FieldError.d.ts +7 -0
- package/dist/components/FieldError.d.ts.map +1 -0
- package/dist/components/FieldError.js +5 -0
- package/dist/components/FieldError.js.map +1 -0
- package/dist/components/FolderGlyph.d.ts +8 -0
- package/dist/components/FolderGlyph.d.ts.map +1 -0
- package/dist/components/FolderGlyph.js +14 -0
- package/dist/components/FolderGlyph.js.map +1 -0
- package/dist/components/IconButton.d.ts +14 -0
- package/dist/components/IconButton.d.ts.map +1 -0
- package/dist/components/IconButton.js +15 -0
- package/dist/components/IconButton.js.map +1 -0
- package/dist/components/IconPicker.d.ts +16 -0
- package/dist/components/IconPicker.d.ts.map +1 -0
- package/dist/components/IconPicker.js +32 -0
- package/dist/components/IconPicker.js.map +1 -0
- package/dist/components/Input.d.ts +10 -0
- package/dist/components/Input.d.ts.map +1 -0
- package/dist/components/Input.js +33 -0
- package/dist/components/Input.js.map +1 -0
- package/dist/components/Kbd.d.ts +11 -0
- package/dist/components/Kbd.d.ts.map +1 -0
- package/dist/components/Kbd.js +18 -0
- package/dist/components/Kbd.js.map +1 -0
- package/dist/components/Label.d.ts +6 -0
- package/dist/components/Label.d.ts.map +1 -0
- package/dist/components/Label.js +5 -0
- package/dist/components/Label.js.map +1 -0
- package/dist/components/Popover.d.ts +14 -0
- package/dist/components/Popover.d.ts.map +1 -0
- package/dist/components/Popover.js +9 -0
- package/dist/components/Popover.js.map +1 -0
- package/dist/components/Skeleton.d.ts +22 -0
- package/dist/components/Skeleton.d.ts.map +1 -0
- package/dist/components/Skeleton.js +14 -0
- package/dist/components/Skeleton.js.map +1 -0
- package/dist/components/SlugBadge.d.ts +13 -0
- package/dist/components/SlugBadge.d.ts.map +1 -0
- package/dist/components/SlugBadge.js +17 -0
- package/dist/components/SlugBadge.js.map +1 -0
- package/dist/components/Tag.d.ts +11 -0
- package/dist/components/Tag.d.ts.map +1 -0
- package/dist/components/Tag.js +16 -0
- package/dist/components/Tag.js.map +1 -0
- package/dist/components/ThemeSwitcher.d.ts +11 -0
- package/dist/components/ThemeSwitcher.d.ts.map +1 -0
- package/dist/components/ThemeSwitcher.js +25 -0
- package/dist/components/ThemeSwitcher.js.map +1 -0
- package/dist/components/ToastProvider.d.ts +19 -0
- package/dist/components/ToastProvider.d.ts.map +1 -0
- package/dist/components/ToastProvider.js +62 -0
- package/dist/components/ToastProvider.js.map +1 -0
- package/dist/components/Tooltip.d.ts +13 -0
- package/dist/components/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip.js +9 -0
- package/dist/components/Tooltip.js.map +1 -0
- package/dist/components/auth-kit.d.ts +59 -0
- package/dist/components/auth-kit.d.ts.map +1 -0
- package/dist/components/auth-kit.js +101 -0
- package/dist/components/auth-kit.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -0
- package/dist/icons/folder-icon-names.d.ts +4 -0
- package/dist/icons/folder-icon-names.d.ts.map +1 -0
- package/dist/icons/folder-icon-names.js +164 -0
- package/dist/icons/folder-icon-names.js.map +1 -0
- package/dist/icons/lucide-icon.d.ts +8 -0
- package/dist/icons/lucide-icon.d.ts.map +1 -0
- package/dist/icons/lucide-icon.js +38 -0
- package/dist/icons/lucide-icon.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/tailwind/token-theme.d.ts +167 -0
- package/dist/tailwind/token-theme.d.ts.map +1 -0
- package/dist/tailwind/token-theme.js +109 -0
- package/dist/tailwind/token-theme.js.map +1 -0
- package/dist/theme/resolve-theme.d.ts +6 -0
- package/dist/theme/resolve-theme.d.ts.map +1 -0
- package/dist/theme/resolve-theme.js +24 -0
- package/dist/theme/resolve-theme.js.map +1 -0
- package/dist/theme/theme-context.d.ts +15 -0
- package/dist/theme/theme-context.d.ts.map +1 -0
- package/dist/theme/theme-context.js +51 -0
- package/dist/theme/theme-context.js.map +1 -0
- package/dist/theme/theme-types.d.ts +5 -0
- package/dist/theme/theme-types.d.ts.map +1 -0
- package/dist/theme/theme-types.js +3 -0
- package/dist/theme/theme-types.js.map +1 -0
- package/package.json +55 -0
- package/postcss.config.js +6 -0
- package/src/components/AppShell.spec.tsx +28 -0
- package/src/components/AppShell.tsx +49 -0
- package/src/components/Badge.tsx +41 -0
- package/src/components/Button.tsx +56 -0
- package/src/components/Checkbox.tsx +70 -0
- package/src/components/ColorPicker.spec.tsx +53 -0
- package/src/components/ColorPicker.tsx +87 -0
- package/src/components/ConfirmDialog.spec.tsx +44 -0
- package/src/components/ConfirmDialog.tsx +67 -0
- package/src/components/Dialog.tsx +63 -0
- package/src/components/EmptyState.spec.tsx +22 -0
- package/src/components/EmptyState.tsx +54 -0
- package/src/components/FieldError.tsx +19 -0
- package/src/components/FolderGlyph.tsx +38 -0
- package/src/components/IconButton.tsx +42 -0
- package/src/components/IconPicker.spec.tsx +54 -0
- package/src/components/IconPicker.tsx +132 -0
- package/src/components/Input.tsx +76 -0
- package/src/components/Kbd.tsx +30 -0
- package/src/components/Label.tsx +17 -0
- package/src/components/Popover.tsx +35 -0
- package/src/components/Skeleton.spec.tsx +18 -0
- package/src/components/Skeleton.tsx +94 -0
- package/src/components/SlugBadge.tsx +35 -0
- package/src/components/Tag.tsx +31 -0
- package/src/components/ThemeSwitcher.tsx +60 -0
- package/src/components/ToastProvider.spec.tsx +60 -0
- package/src/components/ToastProvider.tsx +167 -0
- package/src/components/Tooltip.tsx +33 -0
- package/src/components/__snapshots__/AppShell.spec.tsx.snap +41 -0
- package/src/components/auth-kit.spec.tsx +24 -0
- package/src/components/auth-kit.tsx +475 -0
- package/src/constants.ts +1 -0
- package/src/icons/folder-icon-names.ts +165 -0
- package/src/icons/lucide-icon.spec.ts +32 -0
- package/src/icons/lucide-icon.tsx +47 -0
- package/src/index.ts +100 -0
- package/src/styles/app.css +5 -0
- package/src/styles/colors-and-type.css +140 -0
- package/src/styles/ibm-plex-fonts.css +8 -0
- package/src/tailwind/token-theme.spec.ts +13 -0
- package/src/tailwind/token-theme.ts +114 -0
- package/src/theme/resolve-theme.spec.ts +17 -0
- package/src/theme/resolve-theme.ts +30 -0
- package/src/theme/theme-context.tsx +99 -0
- package/src/theme/theme-types.ts +7 -0
- package/tailwind.config.ts +11 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Button } from "./Button.js";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogClose,
|
|
7
|
+
DialogContent,
|
|
8
|
+
} from "./Dialog.js";
|
|
9
|
+
|
|
10
|
+
export type ConfirmDialogProps = {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
confirmLabel: string;
|
|
16
|
+
cancelLabel: string;
|
|
17
|
+
onConfirm: () => void;
|
|
18
|
+
destructive?: boolean;
|
|
19
|
+
busy?: boolean;
|
|
20
|
+
testId?: string;
|
|
21
|
+
body?: ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Keyboard-friendly destructive / high-impact confirmation (Radix dialog + focus trap). */
|
|
25
|
+
export function ConfirmDialog({
|
|
26
|
+
open,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
confirmLabel,
|
|
31
|
+
cancelLabel,
|
|
32
|
+
onConfirm,
|
|
33
|
+
destructive = false,
|
|
34
|
+
busy = false,
|
|
35
|
+
testId = "confirm-dialog",
|
|
36
|
+
body,
|
|
37
|
+
}: ConfirmDialogProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
40
|
+
<DialogContent title={title} description={description} testId={testId}>
|
|
41
|
+
{body ? (
|
|
42
|
+
<div
|
|
43
|
+
className="px-sp-8 py-sp-6"
|
|
44
|
+
style={{ fontSize: "var(--text-body)", lineHeight: "var(--lh-body)" }}
|
|
45
|
+
>
|
|
46
|
+
{body}
|
|
47
|
+
</div>
|
|
48
|
+
) : null}
|
|
49
|
+
<div className="flex flex-wrap justify-end gap-sp-3 border-t border-[color:var(--border-subtle)] px-sp-8 py-sp-6">
|
|
50
|
+
<DialogClose asChild>
|
|
51
|
+
<Button variant="ghost" disabled={busy} type="button">
|
|
52
|
+
{cancelLabel}
|
|
53
|
+
</Button>
|
|
54
|
+
</DialogClose>
|
|
55
|
+
<Button
|
|
56
|
+
variant={destructive ? "danger" : "primary"}
|
|
57
|
+
disabled={busy}
|
|
58
|
+
onClick={onConfirm}
|
|
59
|
+
type="button"
|
|
60
|
+
>
|
|
61
|
+
{confirmLabel}
|
|
62
|
+
</Button>
|
|
63
|
+
</div>
|
|
64
|
+
</DialogContent>
|
|
65
|
+
</Dialog>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export const Dialog = DialogPrimitive.Root;
|
|
5
|
+
export const DialogTrigger = DialogPrimitive.Trigger;
|
|
6
|
+
export const DialogClose = DialogPrimitive.Close;
|
|
7
|
+
|
|
8
|
+
export type DialogContentProps = {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
testId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function DialogOverlay({
|
|
17
|
+
className = "",
|
|
18
|
+
...props
|
|
19
|
+
}: DialogPrimitive.DialogOverlayProps) {
|
|
20
|
+
return (
|
|
21
|
+
<DialogPrimitive.Overlay
|
|
22
|
+
className={`fixed inset-0 z-[110] bg-[color:var(--overlay-scrim)] backdrop-blur-[3px] ${className}`}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DialogContent({
|
|
29
|
+
children,
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
className = "",
|
|
33
|
+
testId,
|
|
34
|
+
}: DialogContentProps) {
|
|
35
|
+
return (
|
|
36
|
+
<DialogPrimitive.Portal>
|
|
37
|
+
<DialogOverlay />
|
|
38
|
+
<DialogPrimitive.Content
|
|
39
|
+
data-testid={testId}
|
|
40
|
+
{...(description ? {} : { "aria-describedby": undefined })}
|
|
41
|
+
className={`fixed left-1/2 top-1/2 z-[120] flex max-h-[min(90vh,720px)] w-[min(560px,92vw)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[color:var(--border-strong)] bg-overlay shadow-overlay focus:outline-none ${className}`}
|
|
42
|
+
>
|
|
43
|
+
<div className="border-b border-[color:var(--border-subtle)] px-sp-8 py-sp-6">
|
|
44
|
+
<DialogPrimitive.Title
|
|
45
|
+
className="text-fg font-semibold"
|
|
46
|
+
style={{ fontSize: "var(--text-h2)", lineHeight: "var(--lh-h2)" }}
|
|
47
|
+
>
|
|
48
|
+
{title}
|
|
49
|
+
</DialogPrimitive.Title>
|
|
50
|
+
{description ? (
|
|
51
|
+
<DialogPrimitive.Description
|
|
52
|
+
className="mt-sp-3 text-fg-muted"
|
|
53
|
+
style={{ fontSize: "var(--text-body)", lineHeight: "var(--lh-body)" }}
|
|
54
|
+
>
|
|
55
|
+
{description}
|
|
56
|
+
</DialogPrimitive.Description>
|
|
57
|
+
) : null}
|
|
58
|
+
</div>
|
|
59
|
+
{children}
|
|
60
|
+
</DialogPrimitive.Content>
|
|
61
|
+
</DialogPrimitive.Portal>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { EmptyState } from "./EmptyState.js";
|
|
5
|
+
|
|
6
|
+
describe("EmptyState", () => {
|
|
7
|
+
it("renders title, description, and actions with accessible structure", () => {
|
|
8
|
+
render(
|
|
9
|
+
<EmptyState
|
|
10
|
+
title="No bookmarks yet"
|
|
11
|
+
description="Save your first link."
|
|
12
|
+
actions={<button type="button">New bookmark</button>}
|
|
13
|
+
testId="bookmarks-empty"
|
|
14
|
+
/>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByTestId("bookmarks-empty")).toBeTruthy();
|
|
18
|
+
expect(screen.getByRole("heading", { name: "No bookmarks yet" })).toBeTruthy();
|
|
19
|
+
expect(screen.getByText("Save your first link.")).toBeTruthy();
|
|
20
|
+
expect(screen.getByRole("button", { name: "New bookmark" })).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type EmptyStateProps = {
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
description?: ReactNode;
|
|
6
|
+
illustration?: ReactNode;
|
|
7
|
+
actions?: ReactNode;
|
|
8
|
+
testId?: string;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Centered empty state - prototype `.empty` pattern (spec §23). */
|
|
13
|
+
export function EmptyState({
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
illustration,
|
|
17
|
+
actions,
|
|
18
|
+
testId = "empty-state",
|
|
19
|
+
compact = false,
|
|
20
|
+
}: EmptyStateProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={`mx-auto flex max-w-[420px] flex-col items-center justify-center text-center ${
|
|
24
|
+
compact ? "gap-sp-4 px-sp-6 py-sp-8" : "gap-sp-5 px-sp-6 py-sp-10"
|
|
25
|
+
}`}
|
|
26
|
+
data-testid={testId}
|
|
27
|
+
>
|
|
28
|
+
{illustration ? (
|
|
29
|
+
<div className="mb-sp-3 text-fg-faint" aria-hidden="true">
|
|
30
|
+
{illustration}
|
|
31
|
+
</div>
|
|
32
|
+
) : null}
|
|
33
|
+
<h3
|
|
34
|
+
className="m-0 font-semibold text-fg"
|
|
35
|
+
style={{ fontSize: "var(--text-h2)", lineHeight: "var(--lh-h2)" }}
|
|
36
|
+
>
|
|
37
|
+
{title}
|
|
38
|
+
</h3>
|
|
39
|
+
{description ? (
|
|
40
|
+
<p
|
|
41
|
+
className="m-0 text-fg-subtle"
|
|
42
|
+
style={{ fontSize: "var(--text-body-lg)", lineHeight: "1.5" }}
|
|
43
|
+
>
|
|
44
|
+
{description}
|
|
45
|
+
</p>
|
|
46
|
+
) : null}
|
|
47
|
+
{actions ? (
|
|
48
|
+
<div className="mt-sp-3 flex flex-wrap items-center justify-center gap-sp-4">
|
|
49
|
+
{actions}
|
|
50
|
+
</div>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FieldErrorProps = {
|
|
4
|
+
id?: string;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function FieldError({ id, children }: FieldErrorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<p
|
|
11
|
+
id={id}
|
|
12
|
+
role="alert"
|
|
13
|
+
className="text-danger-text"
|
|
14
|
+
style={{ fontSize: "var(--text-small)", lineHeight: "var(--lh-small)" }}
|
|
15
|
+
>
|
|
16
|
+
{children}
|
|
17
|
+
</p>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { LucideIcon } from "../icons/lucide-icon.js";
|
|
2
|
+
|
|
3
|
+
export type FolderGlyphProps = {
|
|
4
|
+
icon: string | null;
|
|
5
|
+
color: string | null;
|
|
6
|
+
size?: number;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function FolderGlyph({
|
|
11
|
+
icon,
|
|
12
|
+
color,
|
|
13
|
+
size = 22,
|
|
14
|
+
className = "",
|
|
15
|
+
}: FolderGlyphProps) {
|
|
16
|
+
const iconSize = Math.round(size * 0.59);
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
aria-hidden
|
|
20
|
+
className={[
|
|
21
|
+
"inline-grid shrink-0 place-items-center rounded-md",
|
|
22
|
+
className,
|
|
23
|
+
].join(" ")}
|
|
24
|
+
style={{
|
|
25
|
+
width: size,
|
|
26
|
+
height: size,
|
|
27
|
+
background: color ?? "var(--accent-subtle)",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<LucideIcon
|
|
31
|
+
name={icon ?? "folder"}
|
|
32
|
+
size={iconSize}
|
|
33
|
+
className="text-[color:var(--accent-fg)]"
|
|
34
|
+
strokeWidth={2}
|
|
35
|
+
/>
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type IconButtonSize = "default" | "sm";
|
|
4
|
+
|
|
5
|
+
export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
6
|
+
size?: IconButtonSize;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
"aria-label": string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sizeClasses: Record<IconButtonSize, string> = {
|
|
13
|
+
default: "h-[34px] w-[34px]",
|
|
14
|
+
sm: "h-[28px] w-[28px]",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Icon-only action button - matches prototype `.icon-btn` pattern from app.css.
|
|
19
|
+
* Requires `aria-label` for accessibility.
|
|
20
|
+
*/
|
|
21
|
+
export function IconButton({
|
|
22
|
+
size = "default",
|
|
23
|
+
active = false,
|
|
24
|
+
className = "",
|
|
25
|
+
type = "button",
|
|
26
|
+
children,
|
|
27
|
+
...props
|
|
28
|
+
}: IconButtonProps) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type={type}
|
|
32
|
+
className={`inline-grid cursor-pointer place-items-center rounded-md border border-transparent bg-transparent transition-[background,color,border-color] duration-micro ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
33
|
+
active
|
|
34
|
+
? "bg-accent-subtle text-accent-text"
|
|
35
|
+
: "text-fg-muted hover:bg-raised hover:text-fg"
|
|
36
|
+
} ${sizeClasses[size]} ${className}`}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { IconPicker } from "./IconPicker.js";
|
|
5
|
+
|
|
6
|
+
const labels = {
|
|
7
|
+
searchPlaceholder: "Search icons…",
|
|
8
|
+
noResults: "No icons match your search",
|
|
9
|
+
clear: "Remove icon",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("IconPicker", () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
});
|
|
16
|
+
it("filters icons by search query and selects an icon", () => {
|
|
17
|
+
const onChange = vi.fn();
|
|
18
|
+
|
|
19
|
+
render(<IconPicker value={null} onChange={onChange} labels={labels} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("icon-picker-option-palette")).toBeTruthy();
|
|
22
|
+
expect(screen.queryByTestId("icon-picker-option-folder")).toBeTruthy();
|
|
23
|
+
|
|
24
|
+
fireEvent.change(screen.getByTestId("icon-picker-search"), {
|
|
25
|
+
target: { value: "palette" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(screen.getByTestId("icon-picker-option-palette")).toBeTruthy();
|
|
29
|
+
expect(screen.queryByTestId("icon-picker-option-folder")).toBeNull();
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByTestId("icon-picker-option-palette"));
|
|
32
|
+
expect(onChange).toHaveBeenCalledWith("palette");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("shows no-results message when search matches nothing", () => {
|
|
36
|
+
render(<IconPicker value={null} onChange={vi.fn()} labels={labels} />);
|
|
37
|
+
|
|
38
|
+
fireEvent.change(screen.getByTestId("icon-picker-search"), {
|
|
39
|
+
target: { value: "xyzzy404" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(screen.getByTestId("icon-picker-no-results").textContent).toBe(labels.noResults);
|
|
43
|
+
expect(screen.queryByTestId("icon-picker-grid")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("clears the selected icon", () => {
|
|
47
|
+
const onChange = vi.fn();
|
|
48
|
+
|
|
49
|
+
render(<IconPicker value="palette" onChange={onChange} labels={labels} />);
|
|
50
|
+
|
|
51
|
+
fireEvent.click(screen.getByTestId("icon-picker-clear"));
|
|
52
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { CheckIcon, SearchIcon, XIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { FOLDER_ICON_NAMES } from "../icons/folder-icon-names.js";
|
|
5
|
+
import { LucideIcon } from "../icons/lucide-icon.js";
|
|
6
|
+
|
|
7
|
+
export type IconPickerLabels = {
|
|
8
|
+
searchPlaceholder: string;
|
|
9
|
+
noResults: string;
|
|
10
|
+
clear: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type IconPickerProps = {
|
|
14
|
+
value: string | null;
|
|
15
|
+
onChange: (icon: string | null) => void;
|
|
16
|
+
labels: IconPickerLabels;
|
|
17
|
+
id?: string;
|
|
18
|
+
testId?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function filterIconNames(query: string): readonly string[] {
|
|
22
|
+
const normalized = query.trim().toLowerCase();
|
|
23
|
+
if (!normalized) return FOLDER_ICON_NAMES;
|
|
24
|
+
return FOLDER_ICON_NAMES.filter((name) => name.includes(normalized));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function IconPicker({
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
labels,
|
|
31
|
+
id,
|
|
32
|
+
testId = "icon-picker",
|
|
33
|
+
}: IconPickerProps) {
|
|
34
|
+
const [query, setQuery] = useState("");
|
|
35
|
+
const matches = useMemo(() => filterIconNames(query), [query]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-col gap-sp-3" data-testid={testId}>
|
|
39
|
+
<div className="relative">
|
|
40
|
+
<SearchIcon
|
|
41
|
+
size={15}
|
|
42
|
+
className="pointer-events-none absolute left-sp-3 top-1/2 -translate-y-1/2 text-fg-muted"
|
|
43
|
+
aria-hidden
|
|
44
|
+
/>
|
|
45
|
+
<input
|
|
46
|
+
id={id}
|
|
47
|
+
type="search"
|
|
48
|
+
value={query}
|
|
49
|
+
onChange={(event) => {
|
|
50
|
+
setQuery(event.target.value);
|
|
51
|
+
}}
|
|
52
|
+
placeholder={labels.searchPlaceholder}
|
|
53
|
+
className="w-full rounded-md border border-[color:var(--border)] bg-[color:var(--base)] py-sp-3 pl-sp-9 pr-sp-4 text-fg focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
|
|
54
|
+
style={{ fontSize: "var(--text-body)" }}
|
|
55
|
+
data-testid={`${testId}-search`}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{value ? (
|
|
60
|
+
<div className="flex items-center justify-between gap-sp-3">
|
|
61
|
+
<span className="inline-flex items-center gap-sp-3 text-fg-muted" style={{ fontSize: "var(--text-small)" }}>
|
|
62
|
+
<LucideIcon name={value} size={18} aria-hidden />
|
|
63
|
+
<span className="font-mono">{value}</span>
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
className="inline-flex items-center gap-sp-2 text-fg-muted transition-colors hover:text-fg"
|
|
68
|
+
style={{ fontSize: "var(--text-small)" }}
|
|
69
|
+
onClick={() => {
|
|
70
|
+
onChange(null);
|
|
71
|
+
}}
|
|
72
|
+
data-testid={`${testId}-clear`}
|
|
73
|
+
>
|
|
74
|
+
<XIcon size={14} aria-hidden />
|
|
75
|
+
{labels.clear}
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{matches.length === 0 ? (
|
|
81
|
+
<p
|
|
82
|
+
className="py-sp-5 text-center text-fg-subtle"
|
|
83
|
+
style={{ fontSize: "var(--text-small)" }}
|
|
84
|
+
data-testid={`${testId}-no-results`}
|
|
85
|
+
>
|
|
86
|
+
{labels.noResults}
|
|
87
|
+
</p>
|
|
88
|
+
) : (
|
|
89
|
+
<div
|
|
90
|
+
className="grid max-h-[200px] grid-cols-8 gap-sp-2 overflow-y-auto rounded-md border border-[color:var(--border-subtle)] bg-[color:var(--base)] p-sp-3"
|
|
91
|
+
role="listbox"
|
|
92
|
+
aria-label={labels.searchPlaceholder}
|
|
93
|
+
data-testid={`${testId}-grid`}
|
|
94
|
+
>
|
|
95
|
+
{matches.map((name) => {
|
|
96
|
+
const selected = value === name;
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
key={name}
|
|
100
|
+
type="button"
|
|
101
|
+
role="option"
|
|
102
|
+
aria-selected={selected}
|
|
103
|
+
title={name}
|
|
104
|
+
className={[
|
|
105
|
+
"relative inline-grid h-9 w-9 place-items-center rounded-md border transition-colors",
|
|
106
|
+
selected
|
|
107
|
+
? "border-[color:var(--accent)] bg-[color:var(--accent-subtle)] text-[color:var(--accent-text)]"
|
|
108
|
+
: "border-transparent text-fg-muted hover:border-[color:var(--border)] hover:bg-[color:var(--raised-2)] hover:text-fg",
|
|
109
|
+
].join(" ")}
|
|
110
|
+
onClick={() => {
|
|
111
|
+
onChange(selected ? null : name);
|
|
112
|
+
}}
|
|
113
|
+
data-testid={`${testId}-option-${name}`}
|
|
114
|
+
>
|
|
115
|
+
<LucideIcon name={name} size={18} aria-hidden />
|
|
116
|
+
{selected ? (
|
|
117
|
+
<CheckIcon
|
|
118
|
+
size={10}
|
|
119
|
+
className="absolute right-0.5 top-0.5 text-[color:var(--accent-text)]"
|
|
120
|
+
aria-hidden
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { filterIconNames };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type InputSize = "default" | "lg";
|
|
4
|
+
|
|
5
|
+
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
|
|
6
|
+
invalid?: boolean;
|
|
7
|
+
size?: InputSize;
|
|
8
|
+
startIcon?: ReactNode;
|
|
9
|
+
endIcon?: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sizeHeight: Record<InputSize, string> = {
|
|
13
|
+
default: "34px",
|
|
14
|
+
lg: "42px",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const sizeFontSize: Record<InputSize, string> = {
|
|
18
|
+
default: "var(--text-body)",
|
|
19
|
+
lg: "var(--text-body-lg)",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function Input({
|
|
23
|
+
invalid = false,
|
|
24
|
+
size = "default",
|
|
25
|
+
startIcon,
|
|
26
|
+
endIcon,
|
|
27
|
+
className = "",
|
|
28
|
+
...props
|
|
29
|
+
}: InputProps) {
|
|
30
|
+
const borderColor = invalid
|
|
31
|
+
? "var(--danger)"
|
|
32
|
+
: "var(--border)";
|
|
33
|
+
const focusBorderColor = invalid
|
|
34
|
+
? "var(--danger)"
|
|
35
|
+
: "var(--accent-border)";
|
|
36
|
+
const focusShadow = invalid
|
|
37
|
+
? "0 0 0 3px var(--danger-subtle)"
|
|
38
|
+
: "0 0 0 3px var(--accent-subtle)";
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className={`flex w-full items-center gap-sp-4 rounded-md border bg-raised px-sp-4 transition-[border-color,box-shadow] duration-micro ease-in-out focus-within:outline-none has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ${className}`}
|
|
43
|
+
style={{
|
|
44
|
+
height: sizeHeight[size],
|
|
45
|
+
borderColor,
|
|
46
|
+
}}
|
|
47
|
+
onFocus={(e) => {
|
|
48
|
+
e.currentTarget.style.borderColor = focusBorderColor;
|
|
49
|
+
e.currentTarget.style.boxShadow = focusShadow;
|
|
50
|
+
}}
|
|
51
|
+
onBlur={(e) => {
|
|
52
|
+
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
53
|
+
e.currentTarget.style.borderColor = borderColor;
|
|
54
|
+
e.currentTarget.style.boxShadow = "";
|
|
55
|
+
}
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{startIcon ? (
|
|
59
|
+
<span aria-hidden="true" className="shrink-0 text-fg-subtle">
|
|
60
|
+
{startIcon}
|
|
61
|
+
</span>
|
|
62
|
+
) : null}
|
|
63
|
+
<input
|
|
64
|
+
className="min-w-0 flex-1 bg-transparent text-fg placeholder:text-fg-faint focus:outline-none disabled:cursor-not-allowed"
|
|
65
|
+
aria-invalid={invalid || undefined}
|
|
66
|
+
style={{ fontSize: sizeFontSize[size], lineHeight: "var(--lh-body)" }}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
{endIcon ? (
|
|
70
|
+
<span aria-hidden="true" className="shrink-0 text-fg-subtle">
|
|
71
|
+
{endIcon}
|
|
72
|
+
</span>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type KbdProps = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keyboard hint badge - 11px mono, canvas bg, 3D bottom border.
|
|
10
|
+
* Matches prototype `.kbd` from app.css.
|
|
11
|
+
*/
|
|
12
|
+
export function Kbd({ children, className = "" }: KbdProps) {
|
|
13
|
+
return (
|
|
14
|
+
<kbd
|
|
15
|
+
className={[
|
|
16
|
+
"inline-flex min-h-[18px] min-w-[18px] items-center justify-center gap-[2px] rounded-sm",
|
|
17
|
+
"border border-[color:var(--border)] bg-canvas",
|
|
18
|
+
"font-mono font-medium leading-none text-fg-muted",
|
|
19
|
+
className,
|
|
20
|
+
].join(" ")}
|
|
21
|
+
style={{
|
|
22
|
+
fontSize: "11px",
|
|
23
|
+
padding: "3px 5px",
|
|
24
|
+
borderBottomWidth: "2px",
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</kbd>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LabelHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function Label({ className = "", children, ...props }: LabelProps) {
|
|
8
|
+
return (
|
|
9
|
+
<label
|
|
10
|
+
className={`font-medium text-fg-muted ${className}`}
|
|
11
|
+
style={{ fontSize: "var(--text-small)", lineHeight: "var(--lh-small)" }}
|
|
12
|
+
{...props}
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</label>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export const Popover = PopoverPrimitive.Root;
|
|
5
|
+
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
6
|
+
export const PopoverClose = PopoverPrimitive.Close;
|
|
7
|
+
|
|
8
|
+
export type PopoverContentProps = {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
testId?: string;
|
|
12
|
+
align?: "start" | "center" | "end";
|
|
13
|
+
sideOffset?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function PopoverContent({
|
|
17
|
+
children,
|
|
18
|
+
className = "",
|
|
19
|
+
testId,
|
|
20
|
+
align = "start",
|
|
21
|
+
sideOffset = 4,
|
|
22
|
+
}: PopoverContentProps) {
|
|
23
|
+
return (
|
|
24
|
+
<PopoverPrimitive.Portal>
|
|
25
|
+
<PopoverPrimitive.Content
|
|
26
|
+
data-testid={testId}
|
|
27
|
+
align={align}
|
|
28
|
+
sideOffset={sideOffset}
|
|
29
|
+
className={`z-[130] max-h-[min(360px,70vh)] w-[min(320px,92vw)] overflow-y-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--overlay)] p-sp-4 shadow-lg focus:outline-none ${className}`}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</PopoverPrimitive.Content>
|
|
33
|
+
</PopoverPrimitive.Portal>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { SkeletonCardGrid, SkeletonList } from "./Skeleton.js";
|
|
5
|
+
|
|
6
|
+
describe("Skeleton", () => {
|
|
7
|
+
it("exposes busy list skeleton for assistive tech", () => {
|
|
8
|
+
render(<SkeletonList rows={2} testId="list-skeleton" />);
|
|
9
|
+
const root = screen.getByTestId("list-skeleton");
|
|
10
|
+
expect(root.getAttribute("aria-busy")).toBe("true");
|
|
11
|
+
expect(root.getAttribute("aria-live")).toBe("polite");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders card grid placeholders", () => {
|
|
15
|
+
render(<SkeletonCardGrid count={3} testId="grid-skeleton" />);
|
|
16
|
+
expect(screen.getByTestId("grid-skeleton").children.length).toBe(3);
|
|
17
|
+
});
|
|
18
|
+
});
|