From ede3ec27c6a1562a30ac266e919fef5d8e5dbbc2 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:32:37 +0800 Subject: [PATCH 01/14] feat: add labels --- app/page.tsx | 198 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 184 insertions(+), 14 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index cee53de..d3c46e8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,21 +1,165 @@ "use client"; -import { useState } from "react"; +import { cn, generateRandomColor } from "@/lib/utils"; +import { useEffect, useRef, useState } from "react"; + +interface Label { + id: number; + name: string; + color: string; +} interface Todo { id: number; text: string; done: boolean; + labelIds: number[]; +} + +interface LabelDropdownProps { + labels: Label[]; + selectedIds: number[]; + onAddLabel: (label: Label) => void; + onRemoveLabel: (id: number) => void; + onCreateLabel: (name: string) => Label; +} + +function LabelDropdown({ + labels, + selectedIds, + onAddLabel, + onRemoveLabel, + onCreateLabel, +}: LabelDropdownProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const searchLower = search.trim().toLowerCase(); + const filteredLabels = searchLower + ? labels.filter((l) => l.name.toLowerCase().includes(searchLower)) + : labels; + const canCreate = search.trim().length > 0 && !labels.some((l) => l.name.toLowerCase() === searchLower); + const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); + + return ( +
+ {selectedLabels.length > 0 && ( +
+ {selectedLabels.map((label) => ( + + {label.name} + + + ))} +
+ )} + + + {open && ( +
+ setSearch(e.target.value)} + placeholder="Search or create..." + className="w-full px-2 py-1.5 text-sm text-black border-b border-gray-100 outline-none placeholder:text-gray-500" + /> +
+ {canCreate && ( + + )} + {filteredLabels.map((label) => { + const isSelected = selectedIds.includes(label.id); + return ( + + ); + })} + {filteredLabels.length === 0 && !canCreate && ( +

No labels

+ )} +
+
+ )} +
+ ); } export default function Home() { const [todos, setTodos] = useState([]); + const [labels, setLabels] = useState([]); const [input, setInput] = useState(""); function addTodo() { const text = input.trim(); if (!text) return; - setTodos([...todos, { id: Date.now(), text, done: false }]); + setTodos([...todos, { id: Date.now(), text, done: false, labelIds: [] }]); setInput(""); } @@ -27,6 +171,22 @@ export default function Home() { setTodos(todos.filter((t) => t.id !== id)); } + function addLabelToTodo(todoId: number, label: Label) { + setTodos(todos.map((t) => (t.id === todoId ? { ...t, labelIds: [...t.labelIds, label.id] } : t))); + } + + function removeLabelFromTodo(todoId: number, labelId: number) { + setTodos(todos.map((t) => (t.id === todoId ? { ...t, labelIds: t.labelIds.filter((id) => id !== labelId) } : t))); + } + + function createLabel(name: string): Label { + const existing = labels.find((l) => l.name.toLowerCase() === name.toLowerCase()); + if (existing) return existing; + const label: Label = { id: Date.now(), name, color: generateRandomColor() }; + setLabels([...labels, label]); + return label; + } + return (

SADo App

@@ -51,19 +211,29 @@ export default function Home() { {todos.map((todo) => (
  • - toggleTodo(todo.id)} - /> - - {todo.text} - - +
    + toggleTodo(todo.id)} + className="shrink-0" + /> + + {todo.text} + + addLabelToTodo(todo.id, label)} + onRemoveLabel={(id) => removeLabelFromTodo(todo.id, id)} + onCreateLabel={createLabel} + /> + +
  • ))} From 774ba5571a26edb3775e345fae24744fd1a758f0 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:36:26 +0800 Subject: [PATCH 02/14] feat: add lable filter --- app/page.tsx | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index d3c46e8..9a79246 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -155,6 +155,18 @@ export default function Home() { const [todos, setTodos] = useState([]); const [labels, setLabels] = useState([]); const [input, setInput] = useState(""); + const [filterLabelIds, setFilterLabelIds] = useState([]); + + const filteredTodos = + filterLabelIds.length === 0 + ? todos + : todos.filter((t) => filterLabelIds.some((id) => t.labelIds.includes(id))); + + function toggleFilterLabel(labelId: number) { + setFilterLabelIds((prev) => + prev.includes(labelId) ? prev.filter((id) => id !== labelId) : [...prev, labelId] + ); + } function addTodo() { const text = input.trim(); @@ -205,10 +217,67 @@ export default function Home() { + {labels.length > 0 && ( +
    +

    Filter by label

    +
    + + {labels.map((label) => { + const active = filterLabelIds.includes(label.id); + return ( + + ); + })} +
    +
    + )} + {todos.length === 0 &&

    No todos yet.

    } + {todos.length > 0 && filteredTodos.length === 0 && ( +

    No todos match the selected labels.

    + )}
      - {todos.map((todo) => ( + {filteredTodos.map((todo) => (
    • Date: Mon, 9 Mar 2026 10:39:08 +0800 Subject: [PATCH 03/14] feat: add delete label --- app/page.tsx | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 9a79246..0a2f73a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -199,6 +199,14 @@ export default function Home() { return label; } + function deleteLabel(labelId: number) { + setLabels((prev) => prev.filter((l) => l.id !== labelId)); + setTodos((prev) => + prev.map((t) => ({ ...t, labelIds: t.labelIds.filter((id) => id !== labelId) })) + ); + setFilterLabelIds((prev) => prev.filter((id) => id !== labelId)); + } + return (

      SADo App

      @@ -236,10 +244,9 @@ export default function Home() { {labels.map((label) => { const active = filterLabelIds.includes(label.id); return ( - + + + ); })} From 404448884c494339492ce79af1e4e71ebb0d4080 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:44:55 +0800 Subject: [PATCH 04/14] feat: add confirm modal --- app/page.tsx | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 0a2f73a..15804b0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -151,11 +151,65 @@ function LabelDropdown({ ); } +type PendingDelete = { type: "label"; id: number; name?: string } | { type: "todo"; id: number; text?: string }; + +function ConfirmModal({ + pending, + onConfirm, + onCancel, +}: { + pending: PendingDelete | null; + onConfirm: () => void; + onCancel: () => void; +}) { + if (!pending) return null; + const isLabel = pending.type === "label"; + const message = isLabel + ? `Delete label "${pending.name ?? "this label"}"? This will remove it from all todos.` + : `Delete todo "${pending.text ?? "this item"}"?`; + return ( +
      +
      e.stopPropagation()} + > +

      + Confirm delete +

      +

      {message}

      +
      + + +
      +
      +
      + ); +} + export default function Home() { const [todos, setTodos] = useState([]); const [labels, setLabels] = useState([]); const [input, setInput] = useState(""); const [filterLabelIds, setFilterLabelIds] = useState([]); + const [pendingDelete, setPendingDelete] = useState(null); const filteredTodos = filterLabelIds.length === 0 @@ -207,8 +261,23 @@ export default function Home() { setFilterLabelIds((prev) => prev.filter((id) => id !== labelId)); } + function confirmDelete() { + if (!pendingDelete) return; + if (pendingDelete.type === "label") { + deleteLabel(pendingDelete.id); + } else { + deleteTodo(pendingDelete.id); + } + setPendingDelete(null); + } + return (
      + setPendingDelete(null)} + />

      SADo App

      @@ -281,7 +350,7 @@ export default function Home() { type="button" onClick={(e) => { e.stopPropagation(); - deleteLabel(label.id); + setPendingDelete({ type: "label", id: label.id, name: label.name }); }} className="ml-0.5 hover:opacity-70 shrink-0 cursor-pointer" aria-label={`Delete ${label.name}`} @@ -323,7 +392,10 @@ export default function Home() { onRemoveLabel={(id) => removeLabelFromTodo(todo.id, id)} onCreateLabel={createLabel} /> -
      From b00fdfbc0eabbeda32a8d02f3b0e9911aca08c39 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:47:04 +0800 Subject: [PATCH 05/14] feat: prevent duplicate colors --- app/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index 15804b0..8ca5ed7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -248,7 +248,8 @@ export default function Home() { function createLabel(name: string): Label { const existing = labels.find((l) => l.name.toLowerCase() === name.toLowerCase()); if (existing) return existing; - const label: Label = { id: Date.now(), name, color: generateRandomColor() }; + const existingColors = labels.map((l) => l.color); + const label: Label = { id: Date.now(), name, color: generateRandomColor(existingColors) }; setLabels([...labels, label]); return label; } From 3dc71081572f5340a543dff1fdb85469bb16070c Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:50:35 +0800 Subject: [PATCH 06/14] feat: better color --- lib/utils.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/utils.ts diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..8405485 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,31 @@ +export function cn(...inputs: (string | undefined | false | null)[]) { + return inputs.filter(Boolean).join(" "); +} + +// Rules of thumb for human-friendly colors: +// - Use HSL: hue varies (0-360), saturation 65-85% (vibrant, not gray), lightness 45-55% (readable, not muddy/neon) +function hslToHex(h: number, s: number, l: number): string { + s /= 100; + l /= 100; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + const c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * c) + .toString(16) + .padStart(2, "0"); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +export function generateRandomColor(excludeColors: string[] = []): string { + const exclude = new Set(excludeColors.map((c) => c.toLowerCase())); + let color: string; + do { + const h = Math.floor(Math.random() * 360); // full spectrum + const s = 72 + Math.floor(Math.random() * 13); // 72-84%: rich saturation + const l = 52 + Math.floor(Math.random() * 5); // 48-52%: sweet spot lightness + color = hslToHex(h, s, l); + } while (exclude.has(color.toLowerCase())); + return color; +} From 7f0342d2e7f3269520b2fb553fa909f6687749db Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 10:55:31 +0800 Subject: [PATCH 07/14] feat: add length and count max cap --- app/page.tsx | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 8ca5ed7..88e0d8a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,12 +16,16 @@ interface Todo { labelIds: number[]; } +const MAX_LABEL_LENGTH = 10; +const MAX_LABELS_PER_TODO = 3; + interface LabelDropdownProps { labels: Label[]; selectedIds: number[]; onAddLabel: (label: Label) => void; onRemoveLabel: (id: number) => void; - onCreateLabel: (name: string) => Label; + onCreateLabel: (name: string) => Label | null; + maxLabelCount?: number; } function LabelDropdown({ @@ -30,7 +34,9 @@ function LabelDropdown({ onAddLabel, onRemoveLabel, onCreateLabel, + maxLabelCount = MAX_LABELS_PER_TODO, }: LabelDropdownProps) { + const canAddMore = selectedIds.length < maxLabelCount; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const inputRef = useRef(null); @@ -56,7 +62,10 @@ function LabelDropdown({ const filteredLabels = searchLower ? labels.filter((l) => l.name.toLowerCase().includes(searchLower)) : labels; - const canCreate = search.trim().length > 0 && !labels.some((l) => l.name.toLowerCase() === searchLower); + const canCreate = + canAddMore && + search.trim().length > 0 && + !labels.some((l) => l.name.toLowerCase() === searchLower); const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); return ( @@ -109,18 +118,23 @@ function LabelDropdown({ className="w-full px-2 py-1.5 text-sm text-black border-b border-gray-100 outline-none placeholder:text-gray-500" />
      + {selectedIds.length >= maxLabelCount && ( +

      Max {maxLabelCount} labels

      + )} {canCreate && ( )} {filteredLabels.map((label) => { @@ -129,8 +143,13 @@ function LabelDropdown({ {open && ( From 45b3bafb6e0867c1218c37599cb98547bdcb7b54 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 11:48:59 +0800 Subject: [PATCH 11/14] upd: increase max label count --- app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index 3d3e82a..4269017 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,7 +17,7 @@ interface Todo { } const MAX_LABEL_LENGTH = 10; -const MAX_LABELS_PER_TODO = 3; +const MAX_LABELS_PER_TODO = 5; interface LabelDropdownProps { labels: Label[]; From fc0baa992383430ee6ab3caf52e1b711752705d1 Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 11:52:11 +0800 Subject: [PATCH 12/14] fix: adjust compact display dynamically --- app/page.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 4269017..0561ee9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -67,14 +67,22 @@ function LabelDropdown({ search.trim().length > 0 && !labels.some((l) => l.name.toLowerCase() === searchLower); const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); - const totalLabelChars = selectedLabels.reduce((sum, l) => sum + l.name.length, 0); - const useCompact = selectedLabels.length >= 3 && totalLabelChars >= 30; + const MAX_DISPLAY_CHARS = 20; + let displayCharCount = 0; + let displayCount = 0; + for (const label of selectedLabels) { + if (displayCharCount + label.name.length > MAX_DISPLAY_CHARS && displayCount > 0) break; + displayCharCount += label.name.length; + displayCount++; + } + const displayedLabels = selectedLabels.slice(0, displayCount); + const useCompact = displayCount < selectedLabels.length; return (
      {selectedLabels.length > 0 && (
      - {(useCompact ? selectedLabels.slice(0, 2) : selectedLabels).map((label) => ( + {displayedLabels.map((label) => ( - +{selectedLabels.length - 2} + +{selectedLabels.length - displayCount} more )}
      From 294c29083bbb43c5be900b338d71e9a812236bac Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 11:57:28 +0800 Subject: [PATCH 13/14] feat: add hover tooltip showing all labels --- app/layout.tsx | 3 +- app/page.tsx | 18 +- package-lock.json | 454 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 4 files changed, 469 insertions(+), 7 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..bcdd23a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; const geistSans = Geist({ @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/app/page.tsx b/app/page.tsx index 0561ee9..3ac069e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,10 @@ "use client"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn, generateRandomColor } from "@/lib/utils"; import { useEffect, useRef, useState } from "react"; @@ -104,9 +109,16 @@ function LabelDropdown({ ))} {useCompact && ( - - +{selectedLabels.length - displayCount} more - + + + + +{selectedLabels.length - displayCount} more + + + +

      {selectedLabels.map((l) => l.name).join(", ")}

      +
      +
      )}
      )} diff --git a/package-lock.json b/package-lock.json index e745208..d2dca3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sado-app", "version": "0.1.0", "dependencies": { + "@radix-ui/react-tooltip": "^1.1.4", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" @@ -453,6 +454,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1226,6 +1265,415 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1559,7 +2007,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1569,7 +2017,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2658,7 +3106,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { diff --git a/package.json b/package.json index 3e7c9c6..74d64df 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@radix-ui/react-tooltip": "^1.1.4", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3" From 8e17e6e8a8b0b61114f3a2c29317eac40d8d963d Mon Sep 17 00:00:00 2001 From: Ruby Date: Mon, 9 Mar 2026 11:57:33 +0800 Subject: [PATCH 14/14] feat: add hover tooltip showing all labels --- components/ui/tooltip.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 components/ui/tooltip.tsx diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..cf8fb48 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };