@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.
Files changed (181) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-lint.log +4 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/dist/components/AppShell.d.ts +17 -0
  5. package/dist/components/AppShell.d.ts.map +1 -0
  6. package/dist/components/AppShell.js +11 -0
  7. package/dist/components/AppShell.js.map +1 -0
  8. package/dist/components/Badge.d.ts +13 -0
  9. package/dist/components/Badge.d.ts.map +1 -0
  10. package/dist/components/Badge.js +21 -0
  11. package/dist/components/Badge.js.map +1 -0
  12. package/dist/components/Button.d.ts +11 -0
  13. package/dist/components/Button.d.ts.map +1 -0
  14. package/dist/components/Button.js +21 -0
  15. package/dist/components/Button.js.map +1 -0
  16. package/dist/components/Checkbox.d.ts +16 -0
  17. package/dist/components/Checkbox.d.ts.map +1 -0
  18. package/dist/components/Checkbox.js +19 -0
  19. package/dist/components/Checkbox.js.map +1 -0
  20. package/dist/components/ColorPicker.d.ts +14 -0
  21. package/dist/components/ColorPicker.d.ts.map +1 -0
  22. package/dist/components/ColorPicker.js +29 -0
  23. package/dist/components/ColorPicker.js.map +1 -0
  24. package/dist/components/ConfirmDialog.d.ts +17 -0
  25. package/dist/components/ConfirmDialog.d.ts.map +1 -0
  26. package/dist/components/ConfirmDialog.js +8 -0
  27. package/dist/components/ConfirmDialog.js.map +1 -0
  28. package/dist/components/Dialog.d.ts +15 -0
  29. package/dist/components/Dialog.d.ts.map +1 -0
  30. package/dist/components/Dialog.js +12 -0
  31. package/dist/components/Dialog.js.map +1 -0
  32. package/dist/components/EmptyState.d.ts +12 -0
  33. package/dist/components/EmptyState.d.ts.map +1 -0
  34. package/dist/components/EmptyState.js +6 -0
  35. package/dist/components/EmptyState.js.map +1 -0
  36. package/dist/components/FieldError.d.ts +7 -0
  37. package/dist/components/FieldError.d.ts.map +1 -0
  38. package/dist/components/FieldError.js +5 -0
  39. package/dist/components/FieldError.js.map +1 -0
  40. package/dist/components/FolderGlyph.d.ts +8 -0
  41. package/dist/components/FolderGlyph.d.ts.map +1 -0
  42. package/dist/components/FolderGlyph.js +14 -0
  43. package/dist/components/FolderGlyph.js.map +1 -0
  44. package/dist/components/IconButton.d.ts +14 -0
  45. package/dist/components/IconButton.d.ts.map +1 -0
  46. package/dist/components/IconButton.js +15 -0
  47. package/dist/components/IconButton.js.map +1 -0
  48. package/dist/components/IconPicker.d.ts +16 -0
  49. package/dist/components/IconPicker.d.ts.map +1 -0
  50. package/dist/components/IconPicker.js +32 -0
  51. package/dist/components/IconPicker.js.map +1 -0
  52. package/dist/components/Input.d.ts +10 -0
  53. package/dist/components/Input.d.ts.map +1 -0
  54. package/dist/components/Input.js +33 -0
  55. package/dist/components/Input.js.map +1 -0
  56. package/dist/components/Kbd.d.ts +11 -0
  57. package/dist/components/Kbd.d.ts.map +1 -0
  58. package/dist/components/Kbd.js +18 -0
  59. package/dist/components/Kbd.js.map +1 -0
  60. package/dist/components/Label.d.ts +6 -0
  61. package/dist/components/Label.d.ts.map +1 -0
  62. package/dist/components/Label.js +5 -0
  63. package/dist/components/Label.js.map +1 -0
  64. package/dist/components/Popover.d.ts +14 -0
  65. package/dist/components/Popover.d.ts.map +1 -0
  66. package/dist/components/Popover.js +9 -0
  67. package/dist/components/Popover.js.map +1 -0
  68. package/dist/components/Skeleton.d.ts +22 -0
  69. package/dist/components/Skeleton.d.ts.map +1 -0
  70. package/dist/components/Skeleton.js +14 -0
  71. package/dist/components/Skeleton.js.map +1 -0
  72. package/dist/components/SlugBadge.d.ts +13 -0
  73. package/dist/components/SlugBadge.d.ts.map +1 -0
  74. package/dist/components/SlugBadge.js +17 -0
  75. package/dist/components/SlugBadge.js.map +1 -0
  76. package/dist/components/Tag.d.ts +11 -0
  77. package/dist/components/Tag.d.ts.map +1 -0
  78. package/dist/components/Tag.js +16 -0
  79. package/dist/components/Tag.js.map +1 -0
  80. package/dist/components/ThemeSwitcher.d.ts +11 -0
  81. package/dist/components/ThemeSwitcher.d.ts.map +1 -0
  82. package/dist/components/ThemeSwitcher.js +25 -0
  83. package/dist/components/ThemeSwitcher.js.map +1 -0
  84. package/dist/components/ToastProvider.d.ts +19 -0
  85. package/dist/components/ToastProvider.d.ts.map +1 -0
  86. package/dist/components/ToastProvider.js +62 -0
  87. package/dist/components/ToastProvider.js.map +1 -0
  88. package/dist/components/Tooltip.d.ts +13 -0
  89. package/dist/components/Tooltip.d.ts.map +1 -0
  90. package/dist/components/Tooltip.js +9 -0
  91. package/dist/components/Tooltip.js.map +1 -0
  92. package/dist/components/auth-kit.d.ts +59 -0
  93. package/dist/components/auth-kit.d.ts.map +1 -0
  94. package/dist/components/auth-kit.js +101 -0
  95. package/dist/components/auth-kit.js.map +1 -0
  96. package/dist/constants.d.ts +2 -0
  97. package/dist/constants.d.ts.map +1 -0
  98. package/dist/constants.js +2 -0
  99. package/dist/constants.js.map +1 -0
  100. package/dist/icons/folder-icon-names.d.ts +4 -0
  101. package/dist/icons/folder-icon-names.d.ts.map +1 -0
  102. package/dist/icons/folder-icon-names.js +164 -0
  103. package/dist/icons/folder-icon-names.js.map +1 -0
  104. package/dist/icons/lucide-icon.d.ts +8 -0
  105. package/dist/icons/lucide-icon.d.ts.map +1 -0
  106. package/dist/icons/lucide-icon.js +38 -0
  107. package/dist/icons/lucide-icon.js.map +1 -0
  108. package/dist/index.d.ts +32 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +31 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/tailwind/token-theme.d.ts +167 -0
  113. package/dist/tailwind/token-theme.d.ts.map +1 -0
  114. package/dist/tailwind/token-theme.js +109 -0
  115. package/dist/tailwind/token-theme.js.map +1 -0
  116. package/dist/theme/resolve-theme.d.ts +6 -0
  117. package/dist/theme/resolve-theme.d.ts.map +1 -0
  118. package/dist/theme/resolve-theme.js +24 -0
  119. package/dist/theme/resolve-theme.js.map +1 -0
  120. package/dist/theme/theme-context.d.ts +15 -0
  121. package/dist/theme/theme-context.d.ts.map +1 -0
  122. package/dist/theme/theme-context.js +51 -0
  123. package/dist/theme/theme-context.js.map +1 -0
  124. package/dist/theme/theme-types.d.ts +5 -0
  125. package/dist/theme/theme-types.d.ts.map +1 -0
  126. package/dist/theme/theme-types.js +3 -0
  127. package/dist/theme/theme-types.js.map +1 -0
  128. package/package.json +55 -0
  129. package/postcss.config.js +6 -0
  130. package/src/components/AppShell.spec.tsx +28 -0
  131. package/src/components/AppShell.tsx +49 -0
  132. package/src/components/Badge.tsx +41 -0
  133. package/src/components/Button.tsx +56 -0
  134. package/src/components/Checkbox.tsx +70 -0
  135. package/src/components/ColorPicker.spec.tsx +53 -0
  136. package/src/components/ColorPicker.tsx +87 -0
  137. package/src/components/ConfirmDialog.spec.tsx +44 -0
  138. package/src/components/ConfirmDialog.tsx +67 -0
  139. package/src/components/Dialog.tsx +63 -0
  140. package/src/components/EmptyState.spec.tsx +22 -0
  141. package/src/components/EmptyState.tsx +54 -0
  142. package/src/components/FieldError.tsx +19 -0
  143. package/src/components/FolderGlyph.tsx +38 -0
  144. package/src/components/IconButton.tsx +42 -0
  145. package/src/components/IconPicker.spec.tsx +54 -0
  146. package/src/components/IconPicker.tsx +132 -0
  147. package/src/components/Input.tsx +76 -0
  148. package/src/components/Kbd.tsx +30 -0
  149. package/src/components/Label.tsx +17 -0
  150. package/src/components/Popover.tsx +35 -0
  151. package/src/components/Skeleton.spec.tsx +18 -0
  152. package/src/components/Skeleton.tsx +94 -0
  153. package/src/components/SlugBadge.tsx +35 -0
  154. package/src/components/Tag.tsx +31 -0
  155. package/src/components/ThemeSwitcher.tsx +60 -0
  156. package/src/components/ToastProvider.spec.tsx +60 -0
  157. package/src/components/ToastProvider.tsx +167 -0
  158. package/src/components/Tooltip.tsx +33 -0
  159. package/src/components/__snapshots__/AppShell.spec.tsx.snap +41 -0
  160. package/src/components/auth-kit.spec.tsx +24 -0
  161. package/src/components/auth-kit.tsx +475 -0
  162. package/src/constants.ts +1 -0
  163. package/src/icons/folder-icon-names.ts +165 -0
  164. package/src/icons/lucide-icon.spec.ts +32 -0
  165. package/src/icons/lucide-icon.tsx +47 -0
  166. package/src/index.ts +100 -0
  167. package/src/styles/app.css +5 -0
  168. package/src/styles/colors-and-type.css +140 -0
  169. package/src/styles/ibm-plex-fonts.css +8 -0
  170. package/src/tailwind/token-theme.spec.ts +13 -0
  171. package/src/tailwind/token-theme.ts +114 -0
  172. package/src/theme/resolve-theme.spec.ts +17 -0
  173. package/src/theme/resolve-theme.ts +30 -0
  174. package/src/theme/theme-context.tsx +99 -0
  175. package/src/theme/theme-types.ts +7 -0
  176. package/tailwind.config.ts +11 -0
  177. package/tsconfig.build.json +4 -0
  178. package/tsconfig.build.tsbuildinfo +1 -0
  179. package/tsconfig.json +11 -0
  180. package/tsconfig.tsbuildinfo +1 -0
  181. 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
+ });