harebear-ui 0.8.0 → 0.9.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 (52) hide show
  1. package/AGENTS.md +2 -2
  2. package/dist/components/admin/app-nav.d.ts +4 -0
  3. package/dist/components/admin/app-nav.js +5 -2
  4. package/dist/components/admin/avatar-upload.d.ts +32 -0
  5. package/dist/components/admin/avatar-upload.js +121 -0
  6. package/dist/components/admin/category.d.ts +17 -0
  7. package/dist/components/admin/category.js +35 -0
  8. package/dist/components/admin/celebration.d.ts +7 -0
  9. package/dist/components/admin/celebration.js +61 -0
  10. package/dist/components/admin/color-swatch-picker.d.ts +15 -0
  11. package/dist/components/admin/color-swatch-picker.js +68 -0
  12. package/dist/components/admin/date-time-picker.d.ts +20 -0
  13. package/dist/components/admin/date-time-picker.js +97 -0
  14. package/dist/components/admin/detail-sheet.d.ts +18 -0
  15. package/dist/components/admin/detail-sheet.js +80 -0
  16. package/dist/components/admin/markdown-editor.d.ts +15 -0
  17. package/dist/components/admin/markdown-editor.js +42 -0
  18. package/dist/components/admin/user-chip.d.ts +34 -3
  19. package/dist/components/admin/user-chip.js +66 -7
  20. package/dist/components/admin/user-menu.d.ts +4 -1
  21. package/dist/components/admin/user-menu.js +2 -1
  22. package/dist/components/ai/ask-box.d.ts +14 -2
  23. package/dist/components/ai/ask-box.js +90 -54
  24. package/dist/components/ai/assistant.d.ts +13 -3
  25. package/dist/components/ai/assistant.js +37 -7
  26. package/dist/components/ai/code-block.d.ts +10 -3
  27. package/dist/components/ai/code-block.js +54 -5
  28. package/dist/components/ai/conversation-list.d.ts +13 -3
  29. package/dist/components/ai/conversation-list.js +15 -26
  30. package/dist/components/auth/auth.js +4 -6
  31. package/dist/components/data/agenda-row.d.ts +26 -0
  32. package/dist/components/data/agenda-row.js +62 -0
  33. package/dist/components/data/day-strip.d.ts +22 -0
  34. package/dist/components/data/day-strip.js +68 -0
  35. package/dist/components/data/sectioned-list.d.ts +21 -6
  36. package/dist/components/data/sectioned-list.js +44 -21
  37. package/dist/components/data/task-row.d.ts +3 -0
  38. package/dist/components/data/task-row.js +6 -14
  39. package/dist/components/ui/avatar.js +1 -1
  40. package/dist/components/ui/kbd.js +1 -1
  41. package/dist/components/ui/password-input.d.ts +8 -0
  42. package/dist/components/ui/password-input.js +32 -0
  43. package/dist/index.d.ts +15 -5
  44. package/dist/index.js +13 -3
  45. package/dist/lib/date.js +8 -0
  46. package/dist/lib/toast-clearance.js +22 -0
  47. package/dist/styles.css +48 -0
  48. package/docs/COMPONENTS.md +165 -15
  49. package/docs/ROADMAP.md +12 -11
  50. package/llms.txt +8 -7
  51. package/package.json +1 -1
  52. package/skills/harebear-ui/SKILL.md +2 -1
package/AGENTS.md CHANGED
@@ -26,7 +26,7 @@ src/
26
26
  components/ui/ shadcn primitives (radix-nova style), restyled. Keep upstream structure.
27
27
  components/theme/ ThemeProvider, ThemeToggle, ThemeScript.
28
28
  components/admin/ Layout and dashboard composites (AppShell, AppNav, FocusLayout, SettingsSection, StatCard...).
29
- components/data/ DataTable, TrendChart, Sparkline, BarList, StatusBars, TaskRow, SectionedList.
29
+ components/data/ DataTable, TrendChart, Sparkline, BarList, StatusBars, TaskRow, SectionedList, AgendaRow, DayStrip.
30
30
  components/ai/ Chat composites (ChatThread, ChatMessage, PromptInput...) and the assistant layer (AssistantProvider, AssistantDock, AskBox, DictationButton, ActionReceipt).
31
31
  components/auth/ AuthLayout, LoginForm, SignupForm (parameterized shadcn login/signup blocks).
32
32
  components/layout/ Prop-driven layout and text (Stack, Text, Metric, ShowMore, Collapse).
@@ -50,7 +50,7 @@ test/ Vitest: API snapshot, docs coverage, token integrity, be
50
50
  4. Floors: checks 24px, phone touch targets 44px, no text under 12px, icon-only buttons have `aria-label`.
51
51
  5. `pnpm gen`, then add the component to `docs/COMPONENTS.md` (the docs test fails otherwise) and to the index in `llms.txt`. Use a heading like ``### `Name` `` or a table row with the name in backticks, so `npx harebear-ui docs Name` finds it.
52
52
  6. Use it in a playground screen if it is visual, and look at it in light and dark mode and at 375px width.
53
- 7. Add a behavior test in `test/` if it has logic (`components.test.tsx`, `building-blocks.test.tsx`, `household.test.tsx` for the household and assistant layer). `pnpm test -u` to accept the new export in the snapshot.
53
+ 7. Add a behavior test in `test/` if it has logic (`components.test.tsx`, `building-blocks.test.tsx`, `household.test.tsx` for the household and assistant layer, `people.test.tsx` (avatars, category colours), `forms.test.tsx` (form fields, DetailSheet, Prose tips)). `pnpm test -u` to accept the new export in the snapshot.
54
54
  8. `pnpm check`.
55
55
 
56
56
  ## Looking at your work
@@ -6,6 +6,8 @@ export interface AppNavPlace extends Omit<AppNavItem, "badge"> {
6
6
  count?: number;
7
7
  /** Show in the phone tab bar. Default true; the first 5 that are not false are shown. */
8
8
  tab?: boolean;
9
+ /** Show in the desktop sidebar. Default true; false makes a tab-bar-only place, e.g. Lists when the sidebar has a Lists group. */
10
+ sidebar?: boolean;
9
11
  }
10
12
  export interface AppNavGroup {
11
13
  /** Group heading, e.g. "Lists". */
@@ -22,6 +24,8 @@ export interface AppNavAction {
22
24
  label: string;
23
25
  icon: React.ReactNode;
24
26
  onSelect: () => void;
27
+ /** Show the button in the desktop sidebar. Default true; false keeps only the round phone button. */
28
+ sidebar?: boolean;
25
29
  }
26
30
  export interface AppNavProps {
27
31
  /** Product mark at the top of the sidebar, usually <AppBrand />. */
@@ -4,6 +4,7 @@ import { Badge } from "../ui/badge.js";
4
4
  import { Button } from "../ui/button.js";
5
5
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
6
6
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar.js";
7
+ import { useToastClearance } from "../../lib/toast-clearance.js";
7
8
  import { cn } from "../../lib/utils.js";
8
9
  import * as React from "react";
9
10
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -18,6 +19,8 @@ const TAB_LIMIT = 5;
18
19
  */
19
20
  function AppNav({ brand, places, group, footer, action, label = "Main", linkComponent = "a", collapsible, storageKey, defaultOpen, width = "15.75rem", className, children }) {
20
21
  const tabs = places.filter((p) => p.tab !== false).slice(0, TAB_LIMIT);
22
+ useToastClearance("--hb-toast-nav", "calc(4.5rem + env(safe-area-inset-bottom))", tabs.length > 0);
23
+ useToastClearance("--hb-toast-fab", "4.5rem", Boolean(action));
21
24
  return /* @__PURE__ */ jsxs(SidebarLayout, {
22
25
  brand,
23
26
  collapsible,
@@ -25,7 +28,7 @@ function AppNav({ brand, places, group, footer, action, label = "Main", linkComp
25
28
  defaultOpen,
26
29
  width,
27
30
  sidebarFooter: footer,
28
- sidebarActions: action && /* @__PURE__ */ jsxs(Button, {
31
+ sidebarActions: action && action.sidebar !== false && /* @__PURE__ */ jsxs(Button, {
29
32
  type: "button",
30
33
  "aria-label": action.label,
31
34
  onClick: action.onSelect,
@@ -41,7 +44,7 @@ function AppNav({ brand, places, group, footer, action, label = "Main", linkComp
41
44
  className: "flex flex-col gap-5",
42
45
  children: [/* @__PURE__ */ jsx(SidebarMenu, {
43
46
  className: "gap-1 group-data-[collapsible=icon]:items-center",
44
- children: places.map((place) => /* @__PURE__ */ jsx(PlaceItem, {
47
+ children: places.filter((p) => p.sidebar !== false).map((place) => /* @__PURE__ */ jsx(PlaceItem, {
45
48
  place,
46
49
  Link: linkComponent
47
50
  }, place.label))
@@ -0,0 +1,32 @@
1
+ import { CategoryColor } from "./category.js";
2
+ import * as React from "react";
3
+ /**
4
+ * Crops an image to its centred square and scales it to `size` (default 256) pixels, as a JPEG. With `maxBytes`,
5
+ * steps the quality down until it fits, or rejects with "That photo is too big.".
6
+ */
7
+ export declare function cropImageToSquare(file: Blob, { size, maxBytes }?: {
8
+ size?: number;
9
+ maxBytes?: number;
10
+ }): Promise<Blob>;
11
+ export interface AvatarUploadProps extends Omit<React.ComponentProps<"div">, "onChange" | "onError" | "color"> {
12
+ /** Whose photo; gives the initials shown without one. */
13
+ name: string;
14
+ /** Category colour behind the initials. */
15
+ color?: CategoryColor;
16
+ /** The current photo. */
17
+ avatarUrl?: string;
18
+ /** Called with a 256x256 JPEG after a photo is chosen, or `null` after Remove photo. */
19
+ onChange: (photo: Blob | null) => void;
20
+ /** Largest photo to hand back, in bytes; JPEG quality steps down to fit. */
21
+ maxBytes?: number;
22
+ /** Called when the file is not an image or cannot be made small enough. */
23
+ onError?: (error: Error) => void;
24
+ /** A line under the buttons. */
25
+ description?: React.ReactNode;
26
+ }
27
+ /**
28
+ * Profile photo picker: a large avatar, "Upload a photo" / "Change photo" and "Remove photo". The chosen image is
29
+ * cropped to a centred square in the browser and previewed right away.
30
+ */
31
+ declare function AvatarUpload({ name, color, avatarUrl, onChange, maxBytes, onError, description, className, ...props }: AvatarUploadProps): React.JSX.Element;
32
+ export { AvatarUpload };
@@ -0,0 +1,121 @@
1
+ "use client";
2
+ import { MemberAvatar } from "./user-chip.js";
3
+ import { Button } from "../ui/button.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ import * as React from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ // src/components/admin/avatar-upload.tsx
8
+ /**
9
+ * Crops an image to its centred square and scales it to `size` (default 256) pixels, as a JPEG. With `maxBytes`,
10
+ * steps the quality down until it fits, or rejects with "That photo is too big.".
11
+ */
12
+ async function cropImageToSquare(file, { size = 256, maxBytes } = {}) {
13
+ const image = await createImageBitmap(file).catch(() => {
14
+ throw new Error("Could not read that image.");
15
+ });
16
+ const side = Math.min(image.width, image.height);
17
+ const canvas = document.createElement("canvas");
18
+ canvas.width = size;
19
+ canvas.height = size;
20
+ const ctx = canvas.getContext("2d");
21
+ if (!ctx) throw new Error("Could not read that image.");
22
+ ctx.fillStyle = "#fff";
23
+ ctx.fillRect(0, 0, size, size);
24
+ ctx.imageSmoothingQuality = "high";
25
+ ctx.drawImage(image, (image.width - side) / 2, (image.height - side) / 2, side, side, 0, 0, size, size);
26
+ image.close();
27
+ for (const quality of [
28
+ .9,
29
+ .8,
30
+ .7,
31
+ .6,
32
+ .5
33
+ ]) {
34
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
35
+ if (!blob) throw new Error("Could not read that image.");
36
+ if (!maxBytes || blob.size <= maxBytes) return blob;
37
+ }
38
+ throw new Error("That photo is too big.");
39
+ }
40
+ /**
41
+ * Profile photo picker: a large avatar, "Upload a photo" / "Change photo" and "Remove photo". The chosen image is
42
+ * cropped to a centred square in the browser and previewed right away.
43
+ */
44
+ function AvatarUpload({ name, color, avatarUrl, onChange, maxBytes, onError, description, className, ...props }) {
45
+ const input = React.useRef(null);
46
+ const [preview, setPreview] = React.useState();
47
+ const [busy, setBusy] = React.useState(false);
48
+ const shown = preview === void 0 ? avatarUrl : preview ?? void 0;
49
+ React.useEffect(() => {
50
+ if (!preview) return;
51
+ return () => URL.revokeObjectURL(preview);
52
+ }, [preview]);
53
+ React.useEffect(() => setPreview(void 0), [avatarUrl]);
54
+ async function choose(file) {
55
+ setBusy(true);
56
+ try {
57
+ const blob = await cropImageToSquare(file, { maxBytes });
58
+ setPreview(URL.createObjectURL(blob));
59
+ onChange(blob);
60
+ } catch (error) {
61
+ onError?.(error);
62
+ } finally {
63
+ setBusy(false);
64
+ }
65
+ }
66
+ return /* @__PURE__ */ jsxs("div", {
67
+ "data-slot": "avatar-upload",
68
+ className: cn("flex flex-wrap items-center gap-5", className),
69
+ ...props,
70
+ children: [/* @__PURE__ */ jsx(MemberAvatar, {
71
+ name,
72
+ avatarUrl: shown,
73
+ color,
74
+ size: "lg",
75
+ "aria-label": shown ? `${name}'s photo` : name,
76
+ className: "data-[size=lg]:size-18 *:data-[slot=avatar-fallback]:text-2xl"
77
+ }), /* @__PURE__ */ jsxs("div", {
78
+ className: "flex min-w-0 flex-[1_1_14rem] flex-col gap-2",
79
+ children: [/* @__PURE__ */ jsxs("div", {
80
+ className: "flex flex-wrap gap-2",
81
+ children: [
82
+ /* @__PURE__ */ jsx("input", {
83
+ ref: input,
84
+ type: "file",
85
+ accept: "image/*",
86
+ hidden: true,
87
+ "data-slot": "avatar-upload-input",
88
+ onChange: (e) => {
89
+ const file = e.target.files?.[0];
90
+ e.target.value = "";
91
+ if (file) choose(file);
92
+ }
93
+ }),
94
+ /* @__PURE__ */ jsx(Button, {
95
+ type: "button",
96
+ variant: "outline",
97
+ className: "max-md:h-11",
98
+ loading: busy,
99
+ onClick: () => input.current?.click(),
100
+ children: shown ? "Change photo" : "Upload a photo"
101
+ }),
102
+ shown && /* @__PURE__ */ jsx(Button, {
103
+ type: "button",
104
+ variant: "ghost",
105
+ className: "max-md:h-11",
106
+ disabled: busy,
107
+ onClick: () => {
108
+ setPreview(null);
109
+ onChange(null);
110
+ },
111
+ children: "Remove photo"
112
+ })
113
+ ]
114
+ }), description && /* @__PURE__ */ jsx("p", {
115
+ className: "text-[13px] text-muted-foreground",
116
+ children: description
117
+ })]
118
+ })]
119
+ });
120
+ }
121
+ export { AvatarUpload, cropImageToSquare };
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+ /** One of the six category colours (`category-1..6`) for members, lists and calendars. */
3
+ export type CategoryColor = 1 | 2 | 3 | 4 | 5 | 6;
4
+ /** `bg-category-N` per colour, as complete class names so Tailwind generates them. */
5
+ export declare const categoryBg: Record<CategoryColor, string>;
6
+ export interface CategoryDotProps extends Omit<React.ComponentProps<"span">, "color"> {
7
+ color: CategoryColor;
8
+ /** Accessible name when the colour means something on its own, e.g. whose event it is. Decorative without it. */
9
+ label?: string;
10
+ /** Adds a lock after the dot, for something only you can see. */
11
+ locked?: boolean;
12
+ /** Accessible name of the lock. Default "Only you". */
13
+ lockLabel?: string;
14
+ }
15
+ /** A small dot in a category colour, e.g. before a list name in a sidebar, with an optional lock for private ones. */
16
+ declare function CategoryDot({ color, label, locked, lockLabel, className, ...props }: CategoryDotProps): React.JSX.Element;
17
+ export { CategoryDot };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+ import { cn } from "../../lib/utils.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { LockIcon } from "lucide-react";
5
+ // src/components/admin/category.tsx
6
+ /** `bg-category-N` per colour, as complete class names so Tailwind generates them. */
7
+ const categoryBg = {
8
+ 1: "bg-category-1",
9
+ 2: "bg-category-2",
10
+ 3: "bg-category-3",
11
+ 4: "bg-category-4",
12
+ 5: "bg-category-5",
13
+ 6: "bg-category-6"
14
+ };
15
+ /** A small dot in a category colour, e.g. before a list name in a sidebar, with an optional lock for private ones. */
16
+ function CategoryDot({ color, label, locked, lockLabel = "Only you", className, ...props }) {
17
+ return /* @__PURE__ */ jsxs("span", {
18
+ "data-slot": "category-dot",
19
+ className: cn("inline-flex shrink-0 items-center gap-1.5", className),
20
+ ...props,
21
+ children: [label ? /* @__PURE__ */ jsx("span", {
22
+ role: "img",
23
+ "aria-label": label,
24
+ className: cn("size-2.5 shrink-0 rounded-full", categoryBg[color])
25
+ }) : /* @__PURE__ */ jsx("span", {
26
+ "aria-hidden": "true",
27
+ className: cn("size-2.5 shrink-0 rounded-full", categoryBg[color])
28
+ }), locked && /* @__PURE__ */ jsx(LockIcon, {
29
+ role: "img",
30
+ "aria-label": lockLabel,
31
+ className: "size-3.5 text-muted-foreground"
32
+ })]
33
+ });
34
+ }
35
+ export { CategoryDot, categoryBg };
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ /** Plays one confetti burst on the mounted `<Celebration />`. Does nothing under prefers-reduced-motion. */
3
+ declare function celebrate(): void;
4
+ export type CelebrationProps = React.ComponentProps<"div">;
5
+ /** Confetti layer for `celebrate()`. Mount once near the root, like Toaster. Renders nothing between bursts. */
6
+ declare function Celebration({ className, ...props }: CelebrationProps): React.JSX.Element | null;
7
+ export { Celebration, celebrate };
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { cn } from "../../lib/utils.js";
3
+ import * as React from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ // src/components/admin/celebration.tsx
6
+ const DURATION_MS = 1600;
7
+ const COLORS = [
8
+ "bg-category-1",
9
+ "bg-category-2",
10
+ "bg-category-3",
11
+ "bg-category-4",
12
+ "bg-category-5",
13
+ "bg-category-6"
14
+ ];
15
+ const PIECES = Array.from({ length: 36 }, (_, i) => {
16
+ const angle = i / 36 * Math.PI * 2 + i % 3 * .3;
17
+ const reach = 120 + i * 47 % 160;
18
+ return {
19
+ color: COLORS[i % COLORS.length],
20
+ style: {
21
+ "--hb-x": `${Math.round(Math.cos(angle) * reach)}px`,
22
+ "--hb-y": `${Math.round(Math.sin(angle) * reach * .8 - 120)}px`,
23
+ "--hb-r": `${i * 137 % 720 - 360}deg`,
24
+ animationDelay: `${i % 6 * 25}ms`
25
+ }
26
+ };
27
+ });
28
+ const listeners = /* @__PURE__ */ new Set();
29
+ /** Plays one confetti burst on the mounted `<Celebration />`. Does nothing under prefers-reduced-motion. */
30
+ function celebrate() {
31
+ if (typeof window === "undefined" || window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
32
+ for (const listener of listeners) listener();
33
+ }
34
+ /** Confetti layer for `celebrate()`. Mount once near the root, like Toaster. Renders nothing between bursts. */
35
+ function Celebration({ className, ...props }) {
36
+ const [burst, setBurst] = React.useState(0);
37
+ React.useEffect(() => {
38
+ const listener = () => setBurst((b) => b + 1);
39
+ listeners.add(listener);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ };
43
+ }, []);
44
+ React.useEffect(() => {
45
+ if (!burst) return;
46
+ const t = setTimeout(() => setBurst(0), DURATION_MS);
47
+ return () => clearTimeout(t);
48
+ }, [burst]);
49
+ if (!burst) return null;
50
+ return /* @__PURE__ */ jsx("div", {
51
+ "data-slot": "celebration",
52
+ "aria-hidden": "true",
53
+ className: cn("pointer-events-none fixed inset-0 z-[100] overflow-hidden motion-reduce:hidden", className),
54
+ ...props,
55
+ children: PIECES.map((p, i) => /* @__PURE__ */ jsx("span", {
56
+ className: cn("absolute top-1/2 left-1/2 h-3 w-2 rounded-[2px] opacity-0 animate-confetti", p.color),
57
+ style: p.style
58
+ }, i))
59
+ }, burst);
60
+ }
61
+ export { Celebration, celebrate };
@@ -0,0 +1,15 @@
1
+ import { CategoryColor } from "./category.js";
2
+ import * as React from "react";
3
+ import { RadioGroup } from "radix-ui";
4
+ export interface ColorSwatchPickerProps extends Omit<React.ComponentProps<typeof RadioGroup.Root>, "value" | "defaultValue" | "onValueChange" | "children"> {
5
+ value?: CategoryColor;
6
+ onValueChange?: (color: CategoryColor) => void;
7
+ /** Colours someone else already uses, as colour -> their name. Shown with their initial and disabled. */
8
+ taken?: Partial<Record<CategoryColor, string>>;
9
+ }
10
+ /**
11
+ * The six category colours as a radio group (arrow keys move between free colours). Name it with `aria-label` or
12
+ * `aria-labelledby`. 32px swatches with 44px touch targets; the chosen one shows a check and a ring.
13
+ */
14
+ declare function ColorSwatchPicker({ value, onValueChange, taken, className, ...props }: ColorSwatchPickerProps): React.JSX.Element;
15
+ export { ColorSwatchPicker };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { categoryBg } from "./category.js";
3
+ import { cn } from "../../lib/utils.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ import { CheckIcon } from "lucide-react";
6
+ import { RadioGroup } from "radix-ui";
7
+ // src/components/admin/color-swatch-picker.tsx
8
+ const colors = [
9
+ {
10
+ value: 1,
11
+ name: "Blue"
12
+ },
13
+ {
14
+ value: 2,
15
+ name: "Teal"
16
+ },
17
+ {
18
+ value: 3,
19
+ name: "Violet"
20
+ },
21
+ {
22
+ value: 4,
23
+ name: "Rose"
24
+ },
25
+ {
26
+ value: 5,
27
+ name: "Slate"
28
+ },
29
+ {
30
+ value: 6,
31
+ name: "Cyan"
32
+ }
33
+ ];
34
+ /**
35
+ * The six category colours as a radio group (arrow keys move between free colours). Name it with `aria-label` or
36
+ * `aria-labelledby`. 32px swatches with 44px touch targets; the chosen one shows a check and a ring.
37
+ */
38
+ function ColorSwatchPicker({ value, onValueChange, taken, className, ...props }) {
39
+ return /* @__PURE__ */ jsx(RadioGroup.Root, {
40
+ "data-slot": "color-swatch-picker",
41
+ value: value ? String(value) : "",
42
+ onValueChange: (v) => onValueChange?.(Number(v)),
43
+ className: cn("flex flex-wrap gap-3", className),
44
+ ...props,
45
+ children: colors.map((c) => {
46
+ const by = taken?.[c.value];
47
+ return /* @__PURE__ */ jsx(RadioGroup.Item, {
48
+ value: String(c.value),
49
+ disabled: !!by,
50
+ "aria-label": by ? `${c.name}, taken by ${by}` : c.name,
51
+ title: by ? `${c.name}, taken by ${by}` : void 0,
52
+ className: cn("relative grid size-8 shrink-0 place-items-center rounded-full text-category-foreground outline-none after:absolute after:-inset-1.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary focus-visible:outline-solid disabled:cursor-not-allowed disabled:opacity-50 data-checked:ring-2 data-checked:ring-foreground data-checked:ring-offset-2 data-checked:ring-offset-card", categoryBg[c.value]),
53
+ children: by ? /* @__PURE__ */ jsx("span", {
54
+ "aria-hidden": "true",
55
+ className: "text-xs leading-none font-semibold",
56
+ children: by.trim()[0]?.toUpperCase()
57
+ }) : /* @__PURE__ */ jsx(RadioGroup.Indicator, {
58
+ className: "grid place-items-center",
59
+ children: /* @__PURE__ */ jsx(CheckIcon, {
60
+ className: "size-4",
61
+ strokeWidth: 3
62
+ })
63
+ })
64
+ }, c.value);
65
+ })
66
+ });
67
+ }
68
+ export { ColorSwatchPicker };
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+ /** A floating local date and optional time, e.g. `{ date: "2026-11-12", time: "09:30" }`. No time zone. */
3
+ export interface DateTimeValue {
4
+ /** `YYYY-MM-DD` */
5
+ date: string;
6
+ /** `HH:MM`, 24-hour. */
7
+ time?: string;
8
+ }
9
+ export interface DateTimePickerProps extends Omit<React.ComponentProps<"button">, "value" | "onChange"> {
10
+ value: DateTimeValue | null;
11
+ /** Called with the new value, or null when cleared. */
12
+ onValueChange: (value: DateTimeValue | null) => void;
13
+ /** Show a time field under the calendar. */
14
+ withTime?: boolean;
15
+ /** Trigger text when there is no value. Default "Pick a date". */
16
+ placeholder?: string;
17
+ }
18
+ /** Date field: a button that opens a Calendar in a Popover, with an optional time field and Clear. */
19
+ declare function DateTimePicker({ value, onValueChange, withTime, placeholder, className, ...props }: DateTimePickerProps): React.JSX.Element;
20
+ export { DateTimePicker };
@@ -0,0 +1,97 @@
1
+ "use client";
2
+ import { Button } from "../ui/button.js";
3
+ import { Calendar } from "../ui/calendar.js";
4
+ import { Input } from "../ui/input.js";
5
+ import { Label } from "../ui/label.js";
6
+ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
7
+ import { parseLocalDate } from "../../lib/date.js";
8
+ import { cn } from "../../lib/utils.js";
9
+ import * as React from "react";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ import { CalendarIcon } from "lucide-react";
12
+ // src/components/admin/date-time-picker.tsx
13
+ const pad = (n) => String(n).padStart(2, "0");
14
+ const toISODate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
15
+ function formatValue({ date, time }) {
16
+ const at = parseLocalDate(date, time);
17
+ const day = at.toLocaleDateString(void 0, {
18
+ weekday: "short",
19
+ day: "numeric",
20
+ month: "short",
21
+ year: "numeric"
22
+ });
23
+ return time ? `${day}, ${at.toLocaleTimeString(void 0, {
24
+ hour: "numeric",
25
+ minute: "2-digit"
26
+ })}` : day;
27
+ }
28
+ /** Date field: a button that opens a Calendar in a Popover, with an optional time field and Clear. */
29
+ function DateTimePicker({ value, onValueChange, withTime, placeholder = "Pick a date", className, ...props }) {
30
+ const [open, setOpen] = React.useState(false);
31
+ const timeId = React.useId();
32
+ const selected = value ? parseLocalDate(value.date) : void 0;
33
+ return /* @__PURE__ */ jsxs(Popover, {
34
+ open,
35
+ onOpenChange: setOpen,
36
+ children: [/* @__PURE__ */ jsx(PopoverTrigger, {
37
+ asChild: true,
38
+ children: /* @__PURE__ */ jsxs("button", {
39
+ type: "button",
40
+ "data-slot": "date-time-picker",
41
+ "data-empty": value ? void 0 : "",
42
+ className: cn("flex h-10 w-full min-w-0 items-center gap-2.5 rounded-md border border-transparent bg-muted px-3.5 text-left text-base transition-[border-color,box-shadow,background-color] outline-none focus-visible:border-primary focus-visible:bg-card focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 data-empty:text-muted-foreground md:text-sm [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground", className),
43
+ ...props,
44
+ children: [/* @__PURE__ */ jsx(CalendarIcon, { "aria-hidden": "true" }), /* @__PURE__ */ jsx("span", {
45
+ className: "truncate",
46
+ children: value ? formatValue(value) : placeholder
47
+ })]
48
+ })
49
+ }), /* @__PURE__ */ jsxs(PopoverContent, {
50
+ align: "start",
51
+ className: "w-auto gap-3 p-3",
52
+ children: [
53
+ /* @__PURE__ */ jsx(Calendar, {
54
+ mode: "single",
55
+ required: true,
56
+ selected,
57
+ defaultMonth: selected,
58
+ onSelect: (d) => {
59
+ onValueChange({
60
+ date: toISODate(d),
61
+ ...value?.time ? { time: value.time } : {}
62
+ });
63
+ if (!withTime) setOpen(false);
64
+ },
65
+ className: "p-0 [--cell-size:--spacing(11)] md:[--cell-size:--spacing(9)]"
66
+ }),
67
+ withTime && /* @__PURE__ */ jsxs("div", {
68
+ className: "flex items-center gap-3",
69
+ children: [/* @__PURE__ */ jsx(Label, {
70
+ htmlFor: timeId,
71
+ className: "shrink-0",
72
+ children: "Time"
73
+ }), /* @__PURE__ */ jsx(Input, {
74
+ id: timeId,
75
+ type: "time",
76
+ disabled: !value,
77
+ value: value?.time ?? "",
78
+ onChange: (e) => value && onValueChange({
79
+ date: value.date,
80
+ ...e.target.value ? { time: e.target.value } : {}
81
+ })
82
+ })]
83
+ }),
84
+ value && /* @__PURE__ */ jsx(Button, {
85
+ variant: "ghost",
86
+ className: "max-md:h-11",
87
+ onClick: () => {
88
+ onValueChange(null);
89
+ setOpen(false);
90
+ },
91
+ children: "Clear"
92
+ })
93
+ ]
94
+ })]
95
+ });
96
+ }
97
+ export { DateTimePicker };
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ export interface DetailSheetProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ /** Heading and accessible name, e.g. "To-do". */
6
+ title: React.ReactNode;
7
+ description?: React.ReactNode;
8
+ /** Pinned under the scrolling body, e.g. a Delete button. */
9
+ footer?: React.ReactNode;
10
+ className?: string;
11
+ children: React.ReactNode;
12
+ }
13
+ /**
14
+ * A record's details: a floating side sheet from 768px up, a full-screen drawer on phones.
15
+ * Both have a close button and a scrolling body.
16
+ */
17
+ declare function DetailSheet({ open, onOpenChange, title, description, footer, className, children }: DetailSheetProps): React.JSX.Element;
18
+ export { DetailSheet };
@@ -0,0 +1,80 @@
1
+ "use client";
2
+ import { Button } from "../ui/button.js";
3
+ import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle } from "../ui/drawer.js";
4
+ import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet.js";
5
+ import { useMediaQuery } from "../../hooks/use-media-query.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { XIcon } from "lucide-react";
9
+ // src/components/admin/detail-sheet.tsx
10
+ /**
11
+ * A record's details: a floating side sheet from 768px up, a full-screen drawer on phones.
12
+ * Both have a close button and a scrolling body.
13
+ */
14
+ function DetailSheet({ open, onOpenChange, title, description, footer, className, children }) {
15
+ const desktop = useMediaQuery("(min-width: 768px)");
16
+ const noDescription = description ? {} : { "aria-describedby": void 0 };
17
+ const body = (padBottom) => /* @__PURE__ */ jsx("div", {
18
+ className: cn("min-h-0 flex-1 overflow-y-auto px-6 pb-6", !footer && padBottom),
19
+ children
20
+ });
21
+ const foot = (padBottom) => footer && /* @__PURE__ */ jsx("div", {
22
+ className: cn("flex flex-wrap items-center gap-2 border-t px-6 py-4", padBottom),
23
+ children: footer
24
+ });
25
+ if (desktop) return /* @__PURE__ */ jsx(Sheet, {
26
+ open,
27
+ onOpenChange,
28
+ children: /* @__PURE__ */ jsxs(SheetContent, {
29
+ "data-slot": "detail-sheet",
30
+ "data-layout": "sheet",
31
+ ...noDescription,
32
+ className: cn("gap-0 data-[side=right]:inset-y-4 data-[side=right]:right-4 data-[side=right]:h-auto data-[side=right]:w-[min(420px,calc(100%-32px))] data-[side=right]:rounded-xl data-[side=right]:border data-[side=right]:sm:max-w-none", className),
33
+ children: [
34
+ /* @__PURE__ */ jsxs("div", {
35
+ className: "flex flex-col gap-1 p-6 pr-14",
36
+ children: [/* @__PURE__ */ jsx(SheetTitle, {
37
+ className: "text-lg font-semibold",
38
+ children: title
39
+ }), description && /* @__PURE__ */ jsx(SheetDescription, { children: description })]
40
+ }),
41
+ body(),
42
+ foot()
43
+ ]
44
+ })
45
+ });
46
+ return /* @__PURE__ */ jsx(Drawer, {
47
+ open,
48
+ onOpenChange,
49
+ children: /* @__PURE__ */ jsxs(DrawerContent, {
50
+ "data-slot": "detail-sheet",
51
+ "data-layout": "drawer",
52
+ ...noDescription,
53
+ className: cn("h-dvh data-[vaul-drawer-direction=bottom]:mt-0 data-[vaul-drawer-direction=bottom]:max-h-none data-[vaul-drawer-direction=bottom]:rounded-none data-[vaul-drawer-direction=bottom]:border-t-0", className),
54
+ children: [
55
+ /* @__PURE__ */ jsxs("div", {
56
+ className: "flex items-start gap-2 px-6 pt-[max(0.5rem,env(safe-area-inset-top))] pb-4",
57
+ children: [/* @__PURE__ */ jsxs("div", {
58
+ className: "flex min-w-0 flex-1 flex-col gap-1 pt-2.5",
59
+ children: [/* @__PURE__ */ jsx(DrawerTitle, {
60
+ className: "text-lg font-semibold",
61
+ children: title
62
+ }), description && /* @__PURE__ */ jsx(DrawerDescription, { children: description })]
63
+ }), /* @__PURE__ */ jsx(DrawerClose, {
64
+ asChild: true,
65
+ children: /* @__PURE__ */ jsx(Button, {
66
+ variant: "ghost",
67
+ size: "icon-lg",
68
+ "aria-label": "Close",
69
+ className: "-mr-2.5",
70
+ children: /* @__PURE__ */ jsx(XIcon, {})
71
+ })
72
+ })]
73
+ }),
74
+ body("pb-[max(1.5rem,env(safe-area-inset-bottom))]"),
75
+ foot("pb-[max(1rem,env(safe-area-inset-bottom))]")
76
+ ]
77
+ })
78
+ });
79
+ }
80
+ export { DetailSheet };