From d31cab585ebc0ac06f320f3b7d388c3497fefe72 Mon Sep 17 00:00:00 2001 From: "sharad.hkr" Date: Sun, 7 Jun 2026 13:35:29 +0530 Subject: [PATCH] feat: add time scale filter component --- apps/www/config/docs.ts | 6 + .../docs/components/time-scale-filter.mdx | 58 ++ apps/www/public/llms-full.txt | 580 ++++++++++++++++++ apps/www/public/llms.txt | 2 + apps/www/public/r/registry.json | 27 + apps/www/public/r/time-scale-filter-demo.json | 16 + apps/www/public/r/time-scale-filter.json | 15 + apps/www/public/registry.json | 27 + apps/www/registry.json | 27 + apps/www/registry/__index__.tsx | 34 + .../example/time-scale-filter-demo.tsx | 242 ++++++++ .../registry/magicui/time-scale-filter.tsx | 324 ++++++++++ apps/www/registry/registry-examples.ts | 12 + apps/www/registry/registry-ui.ts | 14 + 14 files changed, 1384 insertions(+) create mode 100644 apps/www/content/docs/components/time-scale-filter.mdx create mode 100644 apps/www/public/r/time-scale-filter-demo.json create mode 100644 apps/www/public/r/time-scale-filter.json create mode 100644 apps/www/registry/example/time-scale-filter-demo.tsx create mode 100644 apps/www/registry/magicui/time-scale-filter.tsx diff --git a/apps/www/config/docs.ts b/apps/www/config/docs.ts index 92df27973..f32ffe878 100644 --- a/apps/www/config/docs.ts +++ b/apps/www/config/docs.ts @@ -182,6 +182,12 @@ export const docsConfig: DocsConfig = { { title: "Components", items: [ + { + title: "Time Scale Filter", + href: "/docs/components/time-scale-filter", + items: [], + label: "New", + }, { title: "Marquee", href: `/docs/components/marquee`, diff --git a/apps/www/content/docs/components/time-scale-filter.mdx b/apps/www/content/docs/components/time-scale-filter.mdx new file mode 100644 index 000000000..9aeeabb6a --- /dev/null +++ b/apps/www/content/docs/components/time-scale-filter.mdx @@ -0,0 +1,58 @@ +--- +title: Time Scale Filter +date: 2026-06-07 +description: Analog timeline date filtering component +author: magicui +published: true +--- + + + +## Installation + + + + + CLI + Manual + + + + +```bash +npx shadcn@latest add @magicui/time-scale-filter +``` + + + + + + + + + + + +## Usage + +```tsx + } + initialDate={new Date()} +/> +``` + +## Props + +| Prop | Type | Description | +| ---------------- | ---------------------- | ---------------------------------------------------- | +| data | T[] | Array of items to filter | +| dateField | keyof T | Date field used for filtering | +| renderItem | (item: T) => ReactNode | Render function for each filtered item | +| timelineDays | number | Number of days shown in the timeline | +| emptyMessage | string | Message shown when no items match | +| className | string | Optional wrapper class name | +| contentClassName | string | Optional class name for the rendered content section | +| initialDate | Date | Optional timeline start date | diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index ec3bbf83f..69d299d0d 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -19124,6 +19124,586 @@ export default function TextRevealDemo() { +===== COMPONENT: time-scale-filter ===== +Title: Time Scale Filter +Description: An analog timeline-based date filter for browsing and filtering data by date. + +--- file: magicui/time-scale-filter.tsx --- +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +export interface TimeScaleFilterProps { + value?: string + onValueChange?: (value: string) => void + className?: string + timelineDays?: number + initialDate?: Date +} + +const BOX_WIDTH = 52 + +export function TimeScaleFilter({ + value, + onValueChange, + className, + timelineDays = 90, + initialDate, +}: TimeScaleFilterProps) { + const today = useMemo(() => { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d + }, []) + + const todayId = today.toLocaleDateString("en-CA") + + const timeline = useMemo(() => { + return Array.from({ length: timelineDays }, (_, i) => { + const d = new Date(today) + d.setDate(d.getDate() - Math.floor(timelineDays / 2) + i) + const id = d.toLocaleDateString("en-CA") + return { + id, + dayNum: d.getDate(), + dayName: d.toLocaleDateString("en-US", { weekday: "short" }), + monthName: d.toLocaleDateString("en-US", { month: "short" }), + full: d.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }), + isFirst: d.getDate() === 1, + isToday: id === todayId, + } + }) + }, [timelineDays, today, todayId]) + + const defaultId = (initialDate ?? today).toLocaleDateString("en-CA") + const [internalValue, setInternalValue] = useState(defaultId) + const selectedId = value ?? internalValue + + const scrollRef = useRef(null) + const pinShaftRef = useRef(null) + const isDragging = useRef(false) + const startX = useRef(0) + const scrollLeft = useRef(0) + const rafId = useRef(null) + + const getIndex = useCallback( + (id: string) => timeline.findIndex((t) => t.id === id), + [timeline] + ) + + const scrollToIndex = useCallback((index: number, smooth = true) => { + const scrollEl = scrollRef.current + if (!scrollEl) return + const targetLeft = index * BOX_WIDTH + scrollEl.scrollTo({ + left: targetLeft, + behavior: smooth ? "smooth" : "instant", + }) + }, []) + + const punchPin = useCallback(() => { + const el = pinShaftRef.current + if (!el) return + el.style.transition = "none" + el.style.transform = "scaleY(0.65)" + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.style.transition = "transform 0.4s cubic-bezier(.34,1.56,.64,1)" + el.style.transform = "scaleY(1)" + }) + }) + }, []) + + const commitIndex = useCallback( + (index: number, smooth = true) => { + const clamped = Math.max(0, Math.min(index, timeline.length - 1)) + const item = timeline[clamped] + if (!item) return + + setInternalValue(item.id) + onValueChange?.(item.id) + scrollToIndex(clamped, smooth) + punchPin() + }, + [timeline, onValueChange, scrollToIndex, punchPin] + ) + + // Initial mount + useEffect(() => { + const idx = getIndex(selectedId) + if (idx !== -1) scrollToIndex(idx, false) + const item = timeline.find((t) => t.id === selectedId) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // External value change + useEffect(() => { + if (value === undefined) return + const idx = getIndex(value) + if (idx !== -1) { + scrollToIndex(idx, true) + const item = timeline.find((t) => t.id === value) + } + }, [value, getIndex, scrollToIndex, timeline]) + + const handleScroll = useCallback(() => { + if (isDragging.current) return + if (rafId.current) cancelAnimationFrame(rafId.current) + + rafId.current = requestAnimationFrame(() => { + const scrollEl = scrollRef.current + if (!scrollEl) return + + const idx = Math.round(scrollEl.scrollLeft / BOX_WIDTH) + const item = timeline[Math.max(0, Math.min(idx, timeline.length - 1))] + + if (item && item.id !== selectedId) { + setInternalValue(item.id) + onValueChange?.(item.id) + punchPin() + } + }) + }, [timeline, selectedId, onValueChange, punchPin]) + + // Mouse drag + const handleMouseDown = useCallback((e: React.MouseEvent) => { + isDragging.current = true + startX.current = e.pageX + scrollLeft.current = scrollRef.current?.scrollLeft ?? 0 + if (scrollRef.current) scrollRef.current.style.scrollBehavior = "auto" + }, []) + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDragging.current || !scrollRef.current) return + e.preventDefault() + const walk = (e.pageX - startX.current) * 1.6 // tuned for better wheel feel + scrollRef.current.scrollLeft = scrollLeft.current - walk + }, []) + + const stopDragging = useCallback(() => { + if (!isDragging.current) return + isDragging.current = false + + const scrollEl = scrollRef.current + if (scrollEl) scrollEl.style.scrollBehavior = "smooth" + + const idx = Math.round((scrollEl?.scrollLeft ?? 0) / BOX_WIDTH) + commitIndex(idx, true) + }, [commitIndex]) + + // Touch support (for better mobile wheel feel) + const handleTouchStart = useCallback((e: React.TouchEvent) => { + isDragging.current = true + startX.current = e.touches[0].pageX + scrollLeft.current = scrollRef.current?.scrollLeft ?? 0 + if (scrollRef.current) scrollRef.current.style.scrollBehavior = "auto" + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!isDragging.current || !scrollRef.current) return + const walk = (e.touches[0].pageX - startX.current) * 1.6 + scrollRef.current.scrollLeft = scrollLeft.current - walk + }, []) + + const handleTouchEnd = stopDragging + + const selectedIdx = getIndex(selectedId) + + const neighborClass = (i: number) => { + const d = Math.abs(i - selectedIdx) + if (d === 0) + return "scale-[1.13] !bg-foreground !text-background !border-transparent shadow-xl" + if (d === 1) return "scale-[1.04] text-foreground/80 border-border/70" + if (d === 2) return "scale-[1.02]" + return "" + } + + const tickNeighborClass = (i: number) => { + const d = Math.abs(i - selectedIdx) + if (d === 0) return "!h-7 !bg-red-500 shadow-[0_0_8px_rgba(226,75,74,.4)]" + if (d === 1) return "!h-[22px] !bg-foreground/70" + if (d === 2) return "!h-[19px] !bg-border" + return "" + } + + return ( +
+ + +
+ {/* Pin */} +
+
+
+
+ + {/* Fades */} +
+
+ +
+ {/* Date cards */} +
+ {timeline.map((item, i) => ( +
+ {item.isFirst && ( + + {item.monthName} + + )} +
+ + {item.dayName} + + + {item.dayNum} + +
+
+ ))} +
+ + {/* Ticks */} +
+ {timeline.map((item, i) => ( +
+
+
+
+ {[0, 1, 2, 3].map((s) => ( +
+ ))} +
+
+
+ ))} +
+
+
+
+ ) +} + +export default TimeScaleFilter + + +===== EXAMPLE: time-scale-filter-demo ===== +Title: time-scale-filter-demo + +--- file: example/time-scale-filter-demo.tsx --- +"use client" + +import { useMemo, useState } from "react" +import { Calendar, CheckSquare, CloudMoon, TrendingUp } from "lucide-react" + +import TimeScaleFilter from "@/registry/magicui/time-scale-filter" + +const today = new Date() + +const getDate = (offset: number) => { + const d = new Date(today) + d.setDate(d.getDate() + offset) + return d.toLocaleDateString("en-CA") +} + +const data = [ + { + id: 1, + type: "weather", + createdAt: getDate(0), + }, + { + id: 2, + type: "tasks", + createdAt: getDate(0), + }, + { + id: 3, + type: "events", + createdAt: getDate(0), + }, + { + id: 4, + type: "analytics", + createdAt: getDate(-1), + }, + { + id: 5, + type: "tasks", + createdAt: getDate(1), + }, + { + id: 6, + type: "weather", + createdAt: getDate(2), + }, +] + +function WidgetCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export default function TimeScaleFilterDemo() { + const [selectedDate, setSelectedDate] = useState( + new Date().toLocaleDateString("en-CA") + ) + + const filteredData = useMemo(() => { + return data.filter( + (item) => + new Date(item.createdAt).toLocaleDateString("en-CA") === selectedDate + ) + }, [selectedDate]) + + return ( +
+ + + {/* Summary */} +
+
+

+ Selected Date +

+ +

+ {new Date(selectedDate).toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+
+ +
+

+ {filteredData.length} +

+ +

Filtered Items

+
+
+ + {/* Widgets */} +
+ {filteredData.map((item) => { + switch (item.type) { + case "weather": + return ( + +
+
+ +
+ +

+ Cloudy 25° +

+ +

+ Perfect weather for productivity +

+
+
+ ) + + case "tasks": + return ( + +
+
+ +
+ +
+

+ 9 +

+ +

+ Today's Tasks +

+
+
+ +
+ {[ + "Clean Workspace", + "Financial Report", + "Launch Campaign", + ].map((task) => ( +
+
+ + + {task} + +
+ ))} +
+ + ) + + case "events": + return ( + +
+
+ +
+ +
+

+ 3 +

+ +

+ Today's Events +

+
+
+ +
+
+
+ +
+

+ Morning Sync +

+ +

07:00 – 08:00

+
+
+ +
+
+ +
+

+ Focus Session +

+ +

09:00 – 10:00

+
+
+
+ + ) + + case "analytics": + return ( + +
+
+ +
+ +
+

+ +28% +

+ +

Weekly Growth

+
+
+ +
+ + ) + + default: + return null + } + })} +
+ + {filteredData.length === 0 && ( +
+

No records available for this date.

+
+ )} +
+ ) +} + + + ===== COMPONENT: tweet-card ===== Title: Tweet Card Description: A card that displays a tweet with the author's name, handle, and profile picture. diff --git a/apps/www/public/llms.txt b/apps/www/public/llms.txt index dd7e1290c..30fe9ffc3 100644 --- a/apps/www/public/llms.txt +++ b/apps/www/public/llms.txt @@ -77,6 +77,7 @@ This file provides LLM-friendly entry points to documentation and examples. - [Text 3D Flip](https://magicui.design/docs/components/text-3d-flip): A text effect that flips each letter in 3D with a staggered animation on hover. - [Text Animate](https://magicui.design/docs/components/text-animate): A text animation component that animates text using a variety of different animations. - [Text Reveal](https://magicui.design/docs/components/text-reveal): Fade in text as you scroll down the page. +- [Time Scale Filter](https://magicui.design/docs/components/time-scale-filter): An analog timeline-based date filter for browsing and filtering data by date. - [Tweet Card](https://magicui.design/docs/components/tweet-card): A card that displays a tweet with the author's name, handle, and profile picture. - [Typing Animation](https://magicui.design/docs/components/typing-animation): Characters appearing in typed animation - [Video Text](https://magicui.design/docs/components/video-text): A component that displays text with a video playing in the background. @@ -125,6 +126,7 @@ This file provides LLM-friendly entry points to documentation and examples. - [Hero Video Dialog Demo](https://github.com/magicuidesign/magicui/blob/main/example/hero-video-dialog-demo.tsx): Example usage - [Hero Video Dialog Top In Bottom Out Demo](https://github.com/magicuidesign/magicui/blob/main/example/hero-video-dialog-demo-top-in-bottom-out.tsx): Example usage - [Code Comparison Demo](https://github.com/magicuidesign/magicui/blob/main/example/code-comparison-demo.tsx): Example usage +- [time-scale-filter-demo](https://github.com/magicuidesign/magicui/blob/main/example/time-scale-filter-demo.tsx): Example usage - [Marquee Demo](https://github.com/magicuidesign/magicui/blob/main/example/marquee-demo.tsx): Example usage - [Marquee Vertical Demo](https://github.com/magicuidesign/magicui/blob/main/example/marquee-demo-vertical.tsx): Example usage - [Marquee Logos](https://github.com/magicuidesign/magicui/blob/main/example/marquee-logos.tsx): Example usage diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 499944e16..ebade3dd2 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -18,6 +18,19 @@ "files": [], "cssVars": {} }, + { + "name": "time-scale-filter", + "type": "registry:ui", + "title": "Time Scale Filter", + "description": "An analog timeline-based date filter for browsing and filtering data by date.", + "dependencies": [], + "files": [ + { + "path": "registry/magicui/time-scale-filter.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1964,6 +1977,20 @@ } ] }, + { + "name": "time-scale-filter-demo", + "type": "registry:example", + "description": "Time Scale Filter demo", + "registryDependencies": [ + "@magicui/time-scale-filter" + ], + "files": [ + { + "path": "registry/example/time-scale-filter-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "marquee-demo", "type": "registry:example", diff --git a/apps/www/public/r/time-scale-filter-demo.json b/apps/www/public/r/time-scale-filter-demo.json new file mode 100644 index 000000000..22eff5328 --- /dev/null +++ b/apps/www/public/r/time-scale-filter-demo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "time-scale-filter-demo", + "type": "registry:example", + "description": "Time Scale Filter demo", + "registryDependencies": [ + "@magicui/time-scale-filter" + ], + "files": [ + { + "path": "registry/example/time-scale-filter-demo.tsx", + "content": "\"use client\"\n\nimport { useMemo, useState } from \"react\"\nimport { Calendar, CheckSquare, CloudMoon, TrendingUp } from \"lucide-react\"\n\nimport TimeScaleFilter from \"@/registry/magicui/time-scale-filter\"\n\nconst today = new Date()\n\nconst getDate = (offset: number) => {\n const d = new Date(today)\n d.setDate(d.getDate() + offset)\n return d.toLocaleDateString(\"en-CA\")\n}\n\nconst data = [\n {\n id: 1,\n type: \"weather\",\n createdAt: getDate(0),\n },\n {\n id: 2,\n type: \"tasks\",\n createdAt: getDate(0),\n },\n {\n id: 3,\n type: \"events\",\n createdAt: getDate(0),\n },\n {\n id: 4,\n type: \"analytics\",\n createdAt: getDate(-1),\n },\n {\n id: 5,\n type: \"tasks\",\n createdAt: getDate(1),\n },\n {\n id: 6,\n type: \"weather\",\n createdAt: getDate(2),\n },\n]\n\nfunction WidgetCard({ children }: { children: React.ReactNode }) {\n return (\n
\n {children}\n
\n )\n}\n\nexport default function TimeScaleFilterDemo() {\n const [selectedDate, setSelectedDate] = useState(\n new Date().toLocaleDateString(\"en-CA\")\n )\n\n const filteredData = useMemo(() => {\n return data.filter(\n (item) =>\n new Date(item.createdAt).toLocaleDateString(\"en-CA\") === selectedDate\n )\n }, [selectedDate])\n\n return (\n
\n \n\n {/* Summary */}\n
\n
\n

\n Selected Date\n

\n\n

\n {new Date(selectedDate).toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"long\",\n day: \"numeric\",\n })}\n

\n
\n\n
\n

\n {filteredData.length}\n

\n\n

Filtered Items

\n
\n
\n\n {/* Widgets */}\n
\n {filteredData.map((item) => {\n switch (item.type) {\n case \"weather\":\n return (\n \n
\n
\n \n
\n\n

\n Cloudy 25°\n

\n\n

\n Perfect weather for productivity\n

\n
\n
\n )\n\n case \"tasks\":\n return (\n \n
\n
\n \n
\n\n
\n

\n 9\n

\n\n

\n Today's Tasks\n

\n
\n
\n\n
\n {[\n \"Clean Workspace\",\n \"Financial Report\",\n \"Launch Campaign\",\n ].map((task) => (\n \n
\n\n \n {task}\n \n
\n ))}\n
\n
\n )\n\n case \"events\":\n return (\n \n
\n
\n \n
\n\n
\n

\n 3\n

\n\n

\n Today's Events\n

\n
\n
\n\n
\n
\n
\n\n
\n

\n Morning Sync\n

\n\n

07:00 – 08:00

\n
\n
\n\n
\n
\n\n
\n

\n Focus Session\n

\n\n

09:00 – 10:00

\n
\n
\n
\n \n )\n\n case \"analytics\":\n return (\n \n
\n
\n \n
\n\n
\n

\n +28%\n

\n\n

Weekly Growth

\n
\n
\n\n
\n \n )\n\n default:\n return null\n }\n })}\n
\n\n {filteredData.length === 0 && (\n
\n

No records available for this date.

\n
\n )}\n
\n )\n}\n", + "type": "registry:example" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/time-scale-filter.json b/apps/www/public/r/time-scale-filter.json new file mode 100644 index 000000000..d3dc26842 --- /dev/null +++ b/apps/www/public/r/time-scale-filter.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "time-scale-filter", + "type": "registry:ui", + "title": "Time Scale Filter", + "description": "An analog timeline-based date filter for browsing and filtering data by date.", + "dependencies": [], + "files": [ + { + "path": "registry/magicui/time-scale-filter.tsx", + "content": "\"use client\"\n\nimport React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TimeScaleFilterProps {\n value?: string\n onValueChange?: (value: string) => void\n className?: string\n timelineDays?: number\n initialDate?: Date\n}\n\nconst BOX_WIDTH = 52\n\nexport function TimeScaleFilter({\n value,\n onValueChange,\n className,\n timelineDays = 90,\n initialDate,\n}: TimeScaleFilterProps) {\n const today = useMemo(() => {\n const d = new Date()\n d.setHours(0, 0, 0, 0)\n return d\n }, [])\n\n const todayId = today.toLocaleDateString(\"en-CA\")\n\n const timeline = useMemo(() => {\n return Array.from({ length: timelineDays }, (_, i) => {\n const d = new Date(today)\n d.setDate(d.getDate() - Math.floor(timelineDays / 2) + i)\n const id = d.toLocaleDateString(\"en-CA\")\n return {\n id,\n dayNum: d.getDate(),\n dayName: d.toLocaleDateString(\"en-US\", { weekday: \"short\" }),\n monthName: d.toLocaleDateString(\"en-US\", { month: \"short\" }),\n full: d.toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"long\",\n day: \"numeric\",\n year: \"numeric\",\n }),\n isFirst: d.getDate() === 1,\n isToday: id === todayId,\n }\n })\n }, [timelineDays, today, todayId])\n\n const defaultId = (initialDate ?? today).toLocaleDateString(\"en-CA\")\n const [internalValue, setInternalValue] = useState(defaultId)\n const selectedId = value ?? internalValue\n\n const scrollRef = useRef(null)\n const pinShaftRef = useRef(null)\n const isDragging = useRef(false)\n const startX = useRef(0)\n const scrollLeft = useRef(0)\n const rafId = useRef(null)\n\n const getIndex = useCallback(\n (id: string) => timeline.findIndex((t) => t.id === id),\n [timeline]\n )\n\n const scrollToIndex = useCallback((index: number, smooth = true) => {\n const scrollEl = scrollRef.current\n if (!scrollEl) return\n const targetLeft = index * BOX_WIDTH\n scrollEl.scrollTo({\n left: targetLeft,\n behavior: smooth ? \"smooth\" : \"instant\",\n })\n }, [])\n\n const punchPin = useCallback(() => {\n const el = pinShaftRef.current\n if (!el) return\n el.style.transition = \"none\"\n el.style.transform = \"scaleY(0.65)\"\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n el.style.transition = \"transform 0.4s cubic-bezier(.34,1.56,.64,1)\"\n el.style.transform = \"scaleY(1)\"\n })\n })\n }, [])\n\n const commitIndex = useCallback(\n (index: number, smooth = true) => {\n const clamped = Math.max(0, Math.min(index, timeline.length - 1))\n const item = timeline[clamped]\n if (!item) return\n\n setInternalValue(item.id)\n onValueChange?.(item.id)\n scrollToIndex(clamped, smooth)\n punchPin()\n },\n [timeline, onValueChange, scrollToIndex, punchPin]\n )\n\n // Initial mount\n useEffect(() => {\n const idx = getIndex(selectedId)\n if (idx !== -1) scrollToIndex(idx, false)\n const item = timeline.find((t) => t.id === selectedId)\n }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n // External value change\n useEffect(() => {\n if (value === undefined) return\n const idx = getIndex(value)\n if (idx !== -1) {\n scrollToIndex(idx, true)\n const item = timeline.find((t) => t.id === value)\n }\n }, [value, getIndex, scrollToIndex, timeline])\n\n const handleScroll = useCallback(() => {\n if (isDragging.current) return\n if (rafId.current) cancelAnimationFrame(rafId.current)\n\n rafId.current = requestAnimationFrame(() => {\n const scrollEl = scrollRef.current\n if (!scrollEl) return\n\n const idx = Math.round(scrollEl.scrollLeft / BOX_WIDTH)\n const item = timeline[Math.max(0, Math.min(idx, timeline.length - 1))]\n\n if (item && item.id !== selectedId) {\n setInternalValue(item.id)\n onValueChange?.(item.id)\n punchPin()\n }\n })\n }, [timeline, selectedId, onValueChange, punchPin])\n\n // Mouse drag\n const handleMouseDown = useCallback((e: React.MouseEvent) => {\n isDragging.current = true\n startX.current = e.pageX\n scrollLeft.current = scrollRef.current?.scrollLeft ?? 0\n if (scrollRef.current) scrollRef.current.style.scrollBehavior = \"auto\"\n }, [])\n\n const handleMouseMove = useCallback((e: React.MouseEvent) => {\n if (!isDragging.current || !scrollRef.current) return\n e.preventDefault()\n const walk = (e.pageX - startX.current) * 1.6 // tuned for better wheel feel\n scrollRef.current.scrollLeft = scrollLeft.current - walk\n }, [])\n\n const stopDragging = useCallback(() => {\n if (!isDragging.current) return\n isDragging.current = false\n\n const scrollEl = scrollRef.current\n if (scrollEl) scrollEl.style.scrollBehavior = \"smooth\"\n\n const idx = Math.round((scrollEl?.scrollLeft ?? 0) / BOX_WIDTH)\n commitIndex(idx, true)\n }, [commitIndex])\n\n // Touch support (for better mobile wheel feel)\n const handleTouchStart = useCallback((e: React.TouchEvent) => {\n isDragging.current = true\n startX.current = e.touches[0].pageX\n scrollLeft.current = scrollRef.current?.scrollLeft ?? 0\n if (scrollRef.current) scrollRef.current.style.scrollBehavior = \"auto\"\n }, [])\n\n const handleTouchMove = useCallback((e: React.TouchEvent) => {\n if (!isDragging.current || !scrollRef.current) return\n const walk = (e.touches[0].pageX - startX.current) * 1.6\n scrollRef.current.scrollLeft = scrollLeft.current - walk\n }, [])\n\n const handleTouchEnd = stopDragging\n\n const selectedIdx = getIndex(selectedId)\n\n const neighborClass = (i: number) => {\n const d = Math.abs(i - selectedIdx)\n if (d === 0)\n return \"scale-[1.13] !bg-foreground !text-background !border-transparent shadow-xl\"\n if (d === 1) return \"scale-[1.04] text-foreground/80 border-border/70\"\n if (d === 2) return \"scale-[1.02]\"\n return \"\"\n }\n\n const tickNeighborClass = (i: number) => {\n const d = Math.abs(i - selectedIdx)\n if (d === 0) return \"!h-7 !bg-red-500 shadow-[0_0_8px_rgba(226,75,74,.4)]\"\n if (d === 1) return \"!h-[22px] !bg-foreground/70\"\n if (d === 2) return \"!h-[19px] !bg-border\"\n return \"\"\n }\n\n return (\n \n \n\n
\n {/* Pin */}\n
\n \n
\n
\n\n {/* Fades */}\n
\n
\n\n \n {/* Date cards */}\n
\n {timeline.map((item, i) => (\n \n {item.isFirst && (\n \n {item.monthName}\n \n )}\n \n \n {item.dayName}\n \n \n {item.dayNum}\n \n
\n
\n ))}\n
\n\n {/* Ticks */}\n
\n {timeline.map((item, i) => (\n \n
\n \n
\n {[0, 1, 2, 3].map((s) => (\n \n ))}\n
\n
\n
\n ))}\n
\n
\n
\n
\n )\n}\n\nexport default TimeScaleFilter\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 499944e16..ebade3dd2 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -18,6 +18,19 @@ "files": [], "cssVars": {} }, + { + "name": "time-scale-filter", + "type": "registry:ui", + "title": "Time Scale Filter", + "description": "An analog timeline-based date filter for browsing and filtering data by date.", + "dependencies": [], + "files": [ + { + "path": "registry/magicui/time-scale-filter.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1964,6 +1977,20 @@ } ] }, + { + "name": "time-scale-filter-demo", + "type": "registry:example", + "description": "Time Scale Filter demo", + "registryDependencies": [ + "@magicui/time-scale-filter" + ], + "files": [ + { + "path": "registry/example/time-scale-filter-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "marquee-demo", "type": "registry:example", diff --git a/apps/www/registry.json b/apps/www/registry.json index 499944e16..ebade3dd2 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -18,6 +18,19 @@ "files": [], "cssVars": {} }, + { + "name": "time-scale-filter", + "type": "registry:ui", + "title": "Time Scale Filter", + "description": "An analog timeline-based date filter for browsing and filtering data by date.", + "dependencies": [], + "files": [ + { + "path": "registry/magicui/time-scale-filter.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1964,6 +1977,20 @@ } ] }, + { + "name": "time-scale-filter-demo", + "type": "registry:example", + "description": "Time Scale Filter demo", + "registryDependencies": [ + "@magicui/time-scale-filter" + ], + "files": [ + { + "path": "registry/example/time-scale-filter-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "marquee-demo", "type": "registry:example", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index 8c1517ae8..aa10dddcb 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -15,6 +15,23 @@ export const Index: Record = { component: null, meta: undefined, }, + "time-scale-filter": { + name: "time-scale-filter", + description: "An analog timeline-based date filter for browsing and filtering data by date.", + type: "registry:ui", + registryDependencies: undefined, + files: [{ + path: "registry/magicui/time-scale-filter.tsx", + type: "registry:ui", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/magicui/time-scale-filter.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name + return { default: mod.default ?? mod[exportName] } + }), + meta: undefined, + }, "magic-card": { name: "magic-card", description: "A spotlight effect that follows your mouse cursor and highlights borders on hover.", @@ -1987,6 +2004,23 @@ export const Index: Record = { }), meta: undefined, }, + "time-scale-filter-demo": { + name: "time-scale-filter-demo", + description: "Time Scale Filter demo", + type: "registry:example", + registryDependencies: ["@magicui/time-scale-filter"], + files: [{ + path: "registry/example/time-scale-filter-demo.tsx", + type: "registry:example", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/example/time-scale-filter-demo.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name + return { default: mod.default ?? mod[exportName] } + }), + meta: undefined, + }, "marquee-demo": { name: "marquee-demo", description: "Example showing an infinite scrolling component.", diff --git a/apps/www/registry/example/time-scale-filter-demo.tsx b/apps/www/registry/example/time-scale-filter-demo.tsx new file mode 100644 index 000000000..efe14741f --- /dev/null +++ b/apps/www/registry/example/time-scale-filter-demo.tsx @@ -0,0 +1,242 @@ +"use client" + +import { useMemo, useState } from "react" +import { Calendar, CheckSquare, CloudMoon, TrendingUp } from "lucide-react" + +import TimeScaleFilter from "@/registry/magicui/time-scale-filter" + +const today = new Date() + +const getDate = (offset: number) => { + const d = new Date(today) + d.setDate(d.getDate() + offset) + return d.toLocaleDateString("en-CA") +} + +const data = [ + { + id: 1, + type: "weather", + createdAt: getDate(0), + }, + { + id: 2, + type: "tasks", + createdAt: getDate(0), + }, + { + id: 3, + type: "events", + createdAt: getDate(0), + }, + { + id: 4, + type: "analytics", + createdAt: getDate(-1), + }, + { + id: 5, + type: "tasks", + createdAt: getDate(1), + }, + { + id: 6, + type: "weather", + createdAt: getDate(2), + }, +] + +function WidgetCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export default function TimeScaleFilterDemo() { + const [selectedDate, setSelectedDate] = useState( + new Date().toLocaleDateString("en-CA") + ) + + const filteredData = useMemo(() => { + return data.filter( + (item) => + new Date(item.createdAt).toLocaleDateString("en-CA") === selectedDate + ) + }, [selectedDate]) + + return ( +
+ + + {/* Summary */} +
+
+

+ Selected Date +

+ +

+ {new Date(selectedDate).toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+
+ +
+

+ {filteredData.length} +

+ +

Filtered Items

+
+
+ + {/* Widgets */} +
+ {filteredData.map((item) => { + switch (item.type) { + case "weather": + return ( + +
+
+ +
+ +

+ Cloudy 25° +

+ +

+ Perfect weather for productivity +

+
+
+ ) + + case "tasks": + return ( + +
+
+ +
+ +
+

+ 9 +

+ +

+ Today's Tasks +

+
+
+ +
+ {[ + "Clean Workspace", + "Financial Report", + "Launch Campaign", + ].map((task) => ( +
+
+ + + {task} + +
+ ))} +
+ + ) + + case "events": + return ( + +
+
+ +
+ +
+

+ 3 +

+ +

+ Today's Events +

+
+
+ +
+
+
+ +
+

+ Morning Sync +

+ +

07:00 – 08:00

+
+
+ +
+
+ +
+

+ Focus Session +

+ +

09:00 – 10:00

+
+
+
+ + ) + + case "analytics": + return ( + +
+
+ +
+ +
+

+ +28% +

+ +

Weekly Growth

+
+
+ +
+ + ) + + default: + return null + } + })} +
+ + {filteredData.length === 0 && ( +
+

No records available for this date.

+
+ )} +
+ ) +} diff --git a/apps/www/registry/magicui/time-scale-filter.tsx b/apps/www/registry/magicui/time-scale-filter.tsx new file mode 100644 index 000000000..171dcaecc --- /dev/null +++ b/apps/www/registry/magicui/time-scale-filter.tsx @@ -0,0 +1,324 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +export interface TimeScaleFilterProps { + value?: string + onValueChange?: (value: string) => void + className?: string + timelineDays?: number + initialDate?: Date +} + +const BOX_WIDTH = 52 + +export function TimeScaleFilter({ + value, + onValueChange, + className, + timelineDays = 90, + initialDate, +}: TimeScaleFilterProps) { + const today = useMemo(() => { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d + }, []) + + const todayId = today.toLocaleDateString("en-CA") + + const timeline = useMemo(() => { + return Array.from({ length: timelineDays }, (_, i) => { + const d = new Date(today) + d.setDate(d.getDate() - Math.floor(timelineDays / 2) + i) + const id = d.toLocaleDateString("en-CA") + return { + id, + dayNum: d.getDate(), + dayName: d.toLocaleDateString("en-US", { weekday: "short" }), + monthName: d.toLocaleDateString("en-US", { month: "short" }), + full: d.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }), + isFirst: d.getDate() === 1, + isToday: id === todayId, + } + }) + }, [timelineDays, today, todayId]) + + const defaultId = (initialDate ?? today).toLocaleDateString("en-CA") + const [internalValue, setInternalValue] = useState(defaultId) + const selectedId = value ?? internalValue + + const scrollRef = useRef(null) + const pinShaftRef = useRef(null) + const isDragging = useRef(false) + const startX = useRef(0) + const scrollLeft = useRef(0) + const rafId = useRef(null) + + const getIndex = useCallback( + (id: string) => timeline.findIndex((t) => t.id === id), + [timeline] + ) + + const scrollToIndex = useCallback((index: number, smooth = true) => { + const scrollEl = scrollRef.current + if (!scrollEl) return + const targetLeft = index * BOX_WIDTH + scrollEl.scrollTo({ + left: targetLeft, + behavior: smooth ? "smooth" : "instant", + }) + }, []) + + const punchPin = useCallback(() => { + const el = pinShaftRef.current + if (!el) return + el.style.transition = "none" + el.style.transform = "scaleY(0.65)" + requestAnimationFrame(() => { + requestAnimationFrame(() => { + el.style.transition = "transform 0.4s cubic-bezier(.34,1.56,.64,1)" + el.style.transform = "scaleY(1)" + }) + }) + }, []) + + const commitIndex = useCallback( + (index: number, smooth = true) => { + const clamped = Math.max(0, Math.min(index, timeline.length - 1)) + const item = timeline[clamped] + if (!item) return + + setInternalValue(item.id) + onValueChange?.(item.id) + scrollToIndex(clamped, smooth) + punchPin() + }, + [timeline, onValueChange, scrollToIndex, punchPin] + ) + + // Initial mount + useEffect(() => { + const idx = getIndex(selectedId) + if (idx !== -1) scrollToIndex(idx, false) + const item = timeline.find((t) => t.id === selectedId) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // External value change + useEffect(() => { + if (value === undefined) return + const idx = getIndex(value) + if (idx !== -1) { + scrollToIndex(idx, true) + const item = timeline.find((t) => t.id === value) + } + }, [value, getIndex, scrollToIndex, timeline]) + + const handleScroll = useCallback(() => { + if (isDragging.current) return + if (rafId.current) cancelAnimationFrame(rafId.current) + + rafId.current = requestAnimationFrame(() => { + const scrollEl = scrollRef.current + if (!scrollEl) return + + const idx = Math.round(scrollEl.scrollLeft / BOX_WIDTH) + const item = timeline[Math.max(0, Math.min(idx, timeline.length - 1))] + + if (item && item.id !== selectedId) { + setInternalValue(item.id) + onValueChange?.(item.id) + punchPin() + } + }) + }, [timeline, selectedId, onValueChange, punchPin]) + + // Mouse drag + const handleMouseDown = useCallback((e: React.MouseEvent) => { + isDragging.current = true + startX.current = e.pageX + scrollLeft.current = scrollRef.current?.scrollLeft ?? 0 + if (scrollRef.current) scrollRef.current.style.scrollBehavior = "auto" + }, []) + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isDragging.current || !scrollRef.current) return + e.preventDefault() + const walk = (e.pageX - startX.current) * 1.6 // tuned for better wheel feel + scrollRef.current.scrollLeft = scrollLeft.current - walk + }, []) + + const stopDragging = useCallback(() => { + if (!isDragging.current) return + isDragging.current = false + + const scrollEl = scrollRef.current + if (scrollEl) scrollEl.style.scrollBehavior = "smooth" + + const idx = Math.round((scrollEl?.scrollLeft ?? 0) / BOX_WIDTH) + commitIndex(idx, true) + }, [commitIndex]) + + // Touch support (for better mobile wheel feel) + const handleTouchStart = useCallback((e: React.TouchEvent) => { + isDragging.current = true + startX.current = e.touches[0].pageX + scrollLeft.current = scrollRef.current?.scrollLeft ?? 0 + if (scrollRef.current) scrollRef.current.style.scrollBehavior = "auto" + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!isDragging.current || !scrollRef.current) return + const walk = (e.touches[0].pageX - startX.current) * 1.6 + scrollRef.current.scrollLeft = scrollLeft.current - walk + }, []) + + const handleTouchEnd = stopDragging + + const selectedIdx = getIndex(selectedId) + + const neighborClass = (i: number) => { + const d = Math.abs(i - selectedIdx) + if (d === 0) + return "scale-[1.13] !bg-foreground !text-background !border-transparent shadow-xl" + if (d === 1) return "scale-[1.04] text-foreground/80 border-border/70" + if (d === 2) return "scale-[1.02]" + return "" + } + + const tickNeighborClass = (i: number) => { + const d = Math.abs(i - selectedIdx) + if (d === 0) return "!h-7 !bg-red-500 shadow-[0_0_8px_rgba(226,75,74,.4)]" + if (d === 1) return "!h-[22px] !bg-foreground/70" + if (d === 2) return "!h-[19px] !bg-border" + return "" + } + + return ( +
+ + +
+ {/* Pin */} +
+
+
+
+ + {/* Fades */} +
+
+ +
+ {/* Date cards */} +
+ {timeline.map((item, i) => ( +
+ {item.isFirst && ( + + {item.monthName} + + )} +
+ + {item.dayName} + + + {item.dayNum} + +
+
+ ))} +
+ + {/* Ticks */} +
+ {timeline.map((item, i) => ( +
+
+
+
+ {[0, 1, 2, 3].map((s) => ( +
+ ))} +
+
+
+ ))} +
+
+
+
+ ) +} + +export default TimeScaleFilter diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts index a112db472..f43f88cec 100644 --- a/apps/www/registry/registry-examples.ts +++ b/apps/www/registry/registry-examples.ts @@ -543,6 +543,18 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "time-scale-filter-demo", + type: "registry:example", + description: "Time Scale Filter demo", + registryDependencies: ["@magicui/time-scale-filter"], + files: [ + { + path: "example/time-scale-filter-demo.tsx", + type: "registry:example", + }, + ], + }, { name: "marquee-demo", type: "registry:example", diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts index 0969e85f4..a25b98510 100644 --- a/apps/www/registry/registry-ui.ts +++ b/apps/www/registry/registry-ui.ts @@ -1,6 +1,20 @@ import { type Registry } from "shadcn/schema" export const ui: Registry["items"] = [ + { + name: "time-scale-filter", + type: "registry:ui", + title: "Time Scale Filter", + description: + "An analog timeline-based date filter for browsing and filtering data by date.", + dependencies: [], + files: [ + { + path: "magicui/time-scale-filter.tsx", + type: "registry:ui", + }, + ], + }, { name: "magic-card", type: "registry:ui",