@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,94 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type SkeletonProps = {
|
|
4
|
+
className?: string;
|
|
5
|
+
style?: CSSProperties;
|
|
6
|
+
testId?: string;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Shimmer placeholder block - prototype `.sk` pattern (spec §23). */
|
|
11
|
+
export function Skeleton({
|
|
12
|
+
className = "",
|
|
13
|
+
style,
|
|
14
|
+
testId,
|
|
15
|
+
children,
|
|
16
|
+
}: SkeletonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
className={`animate-shimmer rounded-sm bg-[linear-gradient(90deg,var(--raised)_0%,var(--raised-2)_50%,var(--raised)_100%)] bg-[length:200%_100%] motion-reduce:animate-none ${className}`}
|
|
21
|
+
data-testid={testId}
|
|
22
|
+
style={style}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SkeletonListProps = {
|
|
30
|
+
rows?: number;
|
|
31
|
+
testId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Horizontal list-row skeletons for table/list surfaces. */
|
|
35
|
+
export function SkeletonList({ rows = 6, testId = "skeleton-list" }: SkeletonListProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col gap-sp-3" data-testid={testId} aria-busy="true" aria-live="polite">
|
|
38
|
+
{Array.from({ length: rows }).map((_, index) => (
|
|
39
|
+
<div
|
|
40
|
+
key={index}
|
|
41
|
+
className="flex items-center gap-sp-5 rounded-lg border border-[color:var(--border-subtle)] bg-raised px-sp-5 py-sp-5"
|
|
42
|
+
>
|
|
43
|
+
<Skeleton className="h-8 w-8 shrink-0 rounded-md" />
|
|
44
|
+
<div className="flex min-w-0 flex-1 flex-col gap-sp-3">
|
|
45
|
+
<Skeleton className="h-3 w-[85%]" />
|
|
46
|
+
<Skeleton className="h-2.5 w-[55%]" />
|
|
47
|
+
</div>
|
|
48
|
+
<Skeleton className="ml-auto h-8 w-20 shrink-0" />
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SkeletonCardGridProps = {
|
|
56
|
+
count?: number;
|
|
57
|
+
testId?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Card grid skeleton matching bookmark grid loading state in the prototype. */
|
|
61
|
+
export function SkeletonCardGrid({
|
|
62
|
+
count = 9,
|
|
63
|
+
testId = "skeleton-card-grid",
|
|
64
|
+
}: SkeletonCardGridProps) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className="grid gap-sp-4 sm:grid-cols-2 lg:grid-cols-3"
|
|
68
|
+
data-testid={testId}
|
|
69
|
+
aria-busy="true"
|
|
70
|
+
aria-live="polite"
|
|
71
|
+
>
|
|
72
|
+
{Array.from({ length: count }).map((_, index) => (
|
|
73
|
+
<div
|
|
74
|
+
key={index}
|
|
75
|
+
className="flex flex-col gap-sp-5 rounded-lg border border-[color:var(--border-subtle)] bg-raised p-sp-5"
|
|
76
|
+
>
|
|
77
|
+
<div className="flex gap-sp-4">
|
|
78
|
+
<Skeleton className="h-8 w-8 shrink-0 rounded-md" />
|
|
79
|
+
<div className="flex min-w-0 flex-1 flex-col gap-sp-3">
|
|
80
|
+
<Skeleton className="h-3 w-[85%]" />
|
|
81
|
+
<Skeleton className="h-2.5 w-[55%]" />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<Skeleton className="h-2.5 w-[70%]" />
|
|
85
|
+
<Skeleton className="h-[22px] w-[120px] rounded-sm" />
|
|
86
|
+
<div className="mt-sp-2 flex gap-sp-3">
|
|
87
|
+
<Skeleton className="h-2.5 w-[70px]" />
|
|
88
|
+
<Skeleton className="ml-auto h-4 w-10 rounded-sm" />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type SlugBadgeProps = {
|
|
4
|
+
slug: string;
|
|
5
|
+
icon?: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inline slug address pill - accent-subtle bg, mono small font.
|
|
11
|
+
* Matches prototype `.slug-line` pattern from app.css.
|
|
12
|
+
* Used on bookmark cards and table rows.
|
|
13
|
+
*/
|
|
14
|
+
export function SlugBadge({ slug, icon, className = "" }: SlugBadgeProps) {
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
className={`inline-flex max-w-full items-center gap-[6px] rounded-sm border border-[color:var(--accent-border)] bg-accent-subtle text-accent-text ${className}`}
|
|
18
|
+
style={{
|
|
19
|
+
height: "22px",
|
|
20
|
+
padding: "0 7px 0 6px",
|
|
21
|
+
fontFamily: "var(--font-mono)",
|
|
22
|
+
fontSize: "var(--text-small)",
|
|
23
|
+
fontWeight: "var(--weight-medium)",
|
|
24
|
+
lineHeight: "1",
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
{icon ? (
|
|
28
|
+
<span aria-hidden="true" className="shrink-0 opacity-80">
|
|
29
|
+
{icon}
|
|
30
|
+
</span>
|
|
31
|
+
) : null}
|
|
32
|
+
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{slug}</span>
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type TagProps = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tag pill - `#` prefix at 45% opacity, mono micro font.
|
|
10
|
+
* Matches prototype `.tag` pattern from app.css.
|
|
11
|
+
*/
|
|
12
|
+
export function Tag({ children, className = "" }: TagProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-sm border border-[color:var(--border-subtle)] bg-raised-2 text-fg-muted ${className}`}
|
|
16
|
+
style={{
|
|
17
|
+
height: "19px",
|
|
18
|
+
padding: "0 7px",
|
|
19
|
+
fontFamily: "var(--font-mono)",
|
|
20
|
+
fontSize: "var(--text-micro)",
|
|
21
|
+
fontWeight: "var(--weight-medium)",
|
|
22
|
+
lineHeight: "1",
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<span aria-hidden="true" style={{ opacity: 0.45 }}>
|
|
26
|
+
#
|
|
27
|
+
</span>
|
|
28
|
+
{children}
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ThemePreference } from "../theme/theme-types.js";
|
|
2
|
+
import { useTheme } from "../theme/theme-context.js";
|
|
3
|
+
|
|
4
|
+
export type ThemeSwitcherLabels = {
|
|
5
|
+
group: string;
|
|
6
|
+
light: string;
|
|
7
|
+
dark: string;
|
|
8
|
+
auto: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const OPTIONS: ThemePreference[] = ["light", "dark", "auto"];
|
|
12
|
+
|
|
13
|
+
export type ThemeSwitcherProps = {
|
|
14
|
+
labels: ThemeSwitcherLabels;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function ThemeSwitcher({ labels }: ThemeSwitcherProps) {
|
|
18
|
+
const { preference, setPreference } = useTheme();
|
|
19
|
+
|
|
20
|
+
const labelFor = (value: ThemePreference): string => {
|
|
21
|
+
switch (value) {
|
|
22
|
+
case "light":
|
|
23
|
+
return labels.light;
|
|
24
|
+
case "dark":
|
|
25
|
+
return labels.dark;
|
|
26
|
+
case "auto":
|
|
27
|
+
return labels.auto;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className="inline-flex rounded-md border border-[color:var(--border)] bg-raised p-sp-2"
|
|
34
|
+
role="group"
|
|
35
|
+
aria-label={labels.group}
|
|
36
|
+
data-testid="theme-switcher"
|
|
37
|
+
>
|
|
38
|
+
{OPTIONS.map((value) => {
|
|
39
|
+
const active = preference === value;
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={value}
|
|
43
|
+
type="button"
|
|
44
|
+
aria-pressed={active}
|
|
45
|
+
className={
|
|
46
|
+
active
|
|
47
|
+
? "rounded-sm bg-accent px-sp-5 py-sp-3 text-small font-medium text-accent-fg transition-micro"
|
|
48
|
+
: "rounded-sm px-sp-5 py-sp-3 text-small text-fg-muted transition-micro hover:bg-raised-2 hover:text-fg"
|
|
49
|
+
}
|
|
50
|
+
onClick={() => {
|
|
51
|
+
setPreference(value);
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{labelFor(value)}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { ToastProvider, useToast } from "./ToastProvider.js";
|
|
5
|
+
|
|
6
|
+
function ToastHarness() {
|
|
7
|
+
const { toast } = useToast();
|
|
8
|
+
return (
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
onClick={() => {
|
|
12
|
+
toast({ title: "Saved", description: "Profile updated", kind: "success" });
|
|
13
|
+
}}
|
|
14
|
+
>
|
|
15
|
+
Show toast
|
|
16
|
+
</button>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("ToastProvider", () => {
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
cleanup();
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows and dismisses toasts with keyboard-friendly controls", () => {
|
|
27
|
+
render(
|
|
28
|
+
<ToastProvider dismissLabel="Dismiss notification">
|
|
29
|
+
<ToastHarness />
|
|
30
|
+
</ToastProvider>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
fireEvent.click(screen.getByRole("button", { name: "Show toast" }));
|
|
34
|
+
expect(screen.getByRole("status")).toBeTruthy();
|
|
35
|
+
expect(screen.getByText("Saved")).toBeTruthy();
|
|
36
|
+
expect(screen.getByText("Profile updated")).toBeTruthy();
|
|
37
|
+
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Dismiss notification" }));
|
|
39
|
+
expect(screen.queryByRole("status")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("auto-dismisses after the default duration", () => {
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
render(
|
|
45
|
+
<ToastProvider dismissLabel="Dismiss notification">
|
|
46
|
+
<ToastHarness />
|
|
47
|
+
</ToastProvider>,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
fireEvent.click(screen.getByRole("button", { name: "Show toast" }));
|
|
51
|
+
expect(screen.getByText("Saved")).toBeTruthy();
|
|
52
|
+
|
|
53
|
+
act(() => {
|
|
54
|
+
vi.advanceTimersByTime(3200);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(screen.queryByText("Saved")).toBeNull();
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
export type ToastKind = "success" | "info" | "warning" | "danger";
|
|
13
|
+
|
|
14
|
+
export type ToastInput = {
|
|
15
|
+
title: ReactNode;
|
|
16
|
+
description?: ReactNode;
|
|
17
|
+
kind?: ToastKind;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ToastRecord = ToastInput & {
|
|
22
|
+
id: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ToastContextValue = {
|
|
26
|
+
toast: (input: ToastInput) => string;
|
|
27
|
+
dismiss: (id: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
const DEFAULT_DURATION_MS = 3200;
|
|
33
|
+
|
|
34
|
+
const kindClasses: Record<ToastKind, string> = {
|
|
35
|
+
success: "bg-[color:var(--success-subtle)] text-success-text",
|
|
36
|
+
info: "bg-[color:var(--accent-subtle)] text-accent-text",
|
|
37
|
+
warning: "bg-[color:var(--warning-subtle)] text-warning-text",
|
|
38
|
+
danger: "bg-[color:var(--danger-subtle)] text-danger-text",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ToastProviderProps = {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
dismissLabel: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function ToastViewport({
|
|
47
|
+
items,
|
|
48
|
+
onDismiss,
|
|
49
|
+
dismissLabel,
|
|
50
|
+
}: {
|
|
51
|
+
items: ToastRecord[];
|
|
52
|
+
onDismiss: (id: string) => void;
|
|
53
|
+
dismissLabel: string;
|
|
54
|
+
}) {
|
|
55
|
+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const timers = timersRef.current;
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
if (timers.has(item.id)) continue;
|
|
61
|
+
const duration = item.durationMs ?? DEFAULT_DURATION_MS;
|
|
62
|
+
timers.set(
|
|
63
|
+
item.id,
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
onDismiss(item.id);
|
|
66
|
+
timers.delete(item.id);
|
|
67
|
+
}, duration),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return () => {
|
|
71
|
+
for (const timer of timers.values()) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
timers.clear();
|
|
75
|
+
};
|
|
76
|
+
}, [items, onDismiss]);
|
|
77
|
+
|
|
78
|
+
if (items.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="pointer-events-none fixed bottom-sp-7 right-sp-7 z-[120] flex max-w-[min(100vw-2rem,360px)] flex-col gap-sp-4"
|
|
83
|
+
aria-live="polite"
|
|
84
|
+
aria-relevant="additions"
|
|
85
|
+
data-testid="toast-viewport"
|
|
86
|
+
>
|
|
87
|
+
{items.map((item) => {
|
|
88
|
+
const kind = item.kind ?? "success";
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
key={item.id}
|
|
92
|
+
className="pointer-events-auto flex animate-toast-in items-start gap-sp-4 rounded-lg border border-[color:var(--border-strong)] bg-overlay p-sp-5 shadow-overlay motion-reduce:animate-none"
|
|
93
|
+
data-testid={`toast-${item.id}`}
|
|
94
|
+
role="status"
|
|
95
|
+
>
|
|
96
|
+
<span
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className={`grid h-[26px] w-[26px] shrink-0 place-items-center rounded-md ${kindClasses[kind]}`}
|
|
99
|
+
>
|
|
100
|
+
<span className="h-2 w-2 rounded-full bg-current" />
|
|
101
|
+
</span>
|
|
102
|
+
<div className="min-w-0 flex-1 text-left">
|
|
103
|
+
<p
|
|
104
|
+
className="m-0 text-fg"
|
|
105
|
+
style={{ fontSize: "var(--text-body)", lineHeight: "var(--lh-body)" }}
|
|
106
|
+
>
|
|
107
|
+
{item.title}
|
|
108
|
+
</p>
|
|
109
|
+
{item.description ? (
|
|
110
|
+
<p
|
|
111
|
+
className="mt-sp-1 m-0 text-fg-subtle"
|
|
112
|
+
style={{ fontSize: "var(--text-small)", lineHeight: "var(--lh-small)" }}
|
|
113
|
+
>
|
|
114
|
+
{item.description}
|
|
115
|
+
</p>
|
|
116
|
+
) : null}
|
|
117
|
+
</div>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className="shrink-0 rounded-sm px-sp-2 py-sp-1 text-fg-subtle transition-colors hover:bg-raised hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
121
|
+
onClick={() => {
|
|
122
|
+
onDismiss(item.id);
|
|
123
|
+
}}
|
|
124
|
+
aria-label={dismissLabel}
|
|
125
|
+
>
|
|
126
|
+
×
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ToastProvider({ children, dismissLabel }: ToastProviderProps) {
|
|
136
|
+
const [items, setItems] = useState<ToastRecord[]>([]);
|
|
137
|
+
|
|
138
|
+
const dismiss = useCallback((id: string) => {
|
|
139
|
+
setItems((current) => current.filter((item) => item.id !== id));
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const toast = useCallback((input: ToastInput) => {
|
|
143
|
+
const id =
|
|
144
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
145
|
+
? crypto.randomUUID()
|
|
146
|
+
: `${String(Date.now())}-${Math.random().toString(16).slice(2)}`;
|
|
147
|
+
setItems((current) => [...current, { ...input, id }]);
|
|
148
|
+
return id;
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const value = useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<ToastContext.Provider value={value}>
|
|
155
|
+
{children}
|
|
156
|
+
<ToastViewport items={items} onDismiss={dismiss} dismissLabel={dismissLabel} />
|
|
157
|
+
</ToastContext.Provider>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useToast(): ToastContextValue {
|
|
162
|
+
const context = useContext(ToastContext);
|
|
163
|
+
if (!context) {
|
|
164
|
+
throw new Error("useToast must be used within ToastProvider");
|
|
165
|
+
}
|
|
166
|
+
return context;
|
|
167
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export const TooltipProvider = TooltipPrimitive.Provider;
|
|
5
|
+
export const Tooltip = TooltipPrimitive.Root;
|
|
6
|
+
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
7
|
+
|
|
8
|
+
export type TooltipContentProps = {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
testId?: string;
|
|
12
|
+
sideOffset?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function TooltipContent({
|
|
16
|
+
children,
|
|
17
|
+
className = "",
|
|
18
|
+
testId,
|
|
19
|
+
sideOffset = 4,
|
|
20
|
+
}: TooltipContentProps) {
|
|
21
|
+
return (
|
|
22
|
+
<TooltipPrimitive.Portal>
|
|
23
|
+
<TooltipPrimitive.Content
|
|
24
|
+
data-testid={testId}
|
|
25
|
+
sideOffset={sideOffset}
|
|
26
|
+
className={`z-[130] max-w-[280px] rounded-md border border-[color:var(--border-subtle)] bg-overlay px-sp-4 py-sp-3 text-small text-fg shadow-overlay ${className}`}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
<TooltipPrimitive.Arrow className="fill-[color:var(--overlay)]" />
|
|
30
|
+
</TooltipPrimitive.Content>
|
|
31
|
+
</TooltipPrimitive.Portal>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`AppShell > matches base layout snapshot 1`] = `
|
|
4
|
+
<div>
|
|
5
|
+
<div
|
|
6
|
+
class="grid h-screen overflow-hidden bg-canvas font-sans text-body text-fg"
|
|
7
|
+
data-testid="app-shell"
|
|
8
|
+
style="grid-template-columns: 248px 1fr;"
|
|
9
|
+
>
|
|
10
|
+
<aside
|
|
11
|
+
class="flex min-h-0 flex-col overflow-y-auto border-r border-[color:var(--border-subtle)] bg-base"
|
|
12
|
+
data-testid="app-shell-sidebar"
|
|
13
|
+
>
|
|
14
|
+
<div>
|
|
15
|
+
Sidebar content
|
|
16
|
+
</div>
|
|
17
|
+
</aside>
|
|
18
|
+
<div
|
|
19
|
+
class="flex min-h-0 min-w-0 flex-col overflow-hidden"
|
|
20
|
+
>
|
|
21
|
+
<header
|
|
22
|
+
class="flex shrink-0 items-center border-b border-[color:var(--border-subtle)]"
|
|
23
|
+
data-testid="app-shell-topbar"
|
|
24
|
+
style="height: 52px;"
|
|
25
|
+
>
|
|
26
|
+
<div>
|
|
27
|
+
TopBar content
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
<main
|
|
31
|
+
class="flex-1 overflow-y-auto bg-canvas p-sp-7"
|
|
32
|
+
data-testid="app-shell-main"
|
|
33
|
+
>
|
|
34
|
+
<p>
|
|
35
|
+
Content
|
|
36
|
+
</p>
|
|
37
|
+
</main>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AuthHeading, AuthShell, ErrorBanner } from "./auth-kit.js";
|
|
4
|
+
|
|
5
|
+
describe("AuthShell", () => {
|
|
6
|
+
it("renders brand copy and form children", () => {
|
|
7
|
+
render(
|
|
8
|
+
<AuthShell
|
|
9
|
+
brand={{
|
|
10
|
+
headline: "Headline",
|
|
11
|
+
subline: "Subline",
|
|
12
|
+
footer: "Footer",
|
|
13
|
+
}}
|
|
14
|
+
showSlugRows={false}
|
|
15
|
+
>
|
|
16
|
+
<AuthHeading title="Sign in" />
|
|
17
|
+
<ErrorBanner message="Invalid credentials" />
|
|
18
|
+
</AuthShell>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByRole("heading", { name: "Sign in" })).toBeTruthy();
|
|
22
|
+
expect(screen.getByRole("alert").textContent).toContain("Invalid credentials");
|
|
23
|
+
});
|
|
24
|
+
});
|