{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "shutter", "title": "Shutter", "description": "Base shutter component: drag, motor controls, variants, and window shapes.", "dependencies": [ "lucide-react", "framer-motion", "clsx", "tailwind-merge" ], "files": [ { "path": "components/shutter.tsx", "content": "\"use client\";\r\n\r\nimport { ChevronDown, ChevronUp, Square } from \"lucide-react\";\r\nimport { useEffect, useMemo, useRef, useState, type CSSProperties } from \"react\";\r\n\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nexport type ShutterVariant = \"classic\" | \"warm\" | \"dark\" | \"minimal\" | \"industrial\";\r\nexport type ShutterSize = \"sm\" | \"md\" | \"lg\" | \"xl\";\r\nexport type ShutterPanels = 1 | 2;\r\nexport type ShutterShape =\r\n | \"rectangle\"\r\n | \"arch\"\r\n | \"round\"\r\n | \"gothic\"\r\n | \"porthole\"\r\n | \"casement\"\r\n | \"sash\";\r\nexport type ShutterPanelMode = \"sync\" | \"independent\";\r\nexport type ShutterSlatSize = \"fine\" | \"medium\" | \"thick\" | \"chunky\";\r\nexport type ShutterHoleShape = \"circle\" | \"ellipse\" | \"rectangle\";\r\n\r\nexport interface ShutterProps {\r\n /** Single-value controlled level (0 = open, 100 = fully closed). */\r\n level?: number;\r\n /** Initial level when uncontrolled & single-panel / sync. */\r\n defaultLevel?: number;\r\n /** Called whenever the shared / first panel level changes. */\r\n onLevelChange?: (level: number) => void;\r\n /** Per-panel controlled levels (independent mode). */\r\n levels?: number[];\r\n /** Initial per-panel levels (independent mode). */\r\n defaultLevels?: number[];\r\n /** Called whenever any panel level changes (full array). */\r\n onLevelsChange?: (levels: number[]) => void;\r\n /** Visual style. */\r\n variant?: ShutterVariant;\r\n /** Visual size. */\r\n size?: ShutterSize;\r\n /** Window shape. Square / round / arched / gothic / porthole-with-rim. */\r\n shape?: ShutterShape;\r\n /** Number of panels rendered side-by-side in a single frame. */\r\n panels?: ShutterPanels;\r\n /**\r\n * How double panels behave:\r\n * - \"sync\": both panels always share the same level (default).\r\n * - \"independent\": each panel has its own level, dragged separately.\r\n */\r\n panelMode?: ShutterPanelMode;\r\n /** Delay (ms) between panels' motor starts when buttons trigger animation. */\r\n stagger?: number;\r\n /** Thickness of the individual slats. */\r\n slatSize?: ShutterSlatSize;\r\n /** Adds a cylindrical shading overlay so the shutter looks 3D. */\r\n curved?: boolean;\r\n /** Perforation shape. Default \"ellipse\" (typical modern shutter slot).\r\n * \"rectangle\" uses SVG for realistic square slots. \"circle\" is the classic round hole. */\r\n holeShape?: ShutterHoleShape;\r\n /**\r\n * Motor speed expressed as ms per percent. With 60ms/% a full\r\n * open→closed travel takes 6 seconds (linear, like a real motor).\r\n */\r\n motorSpeedMs?: number;\r\n /** Initial speed when uncontrolled. */\r\n defaultMotorSpeedMs?: number;\r\n /** Called whenever the motor speed changes. */\r\n onMotorSpeedChange?: (value: number) => void;\r\n /** Travel speed for 0-50% (ms/%). */\r\n travelMotorSpeedMs?: number;\r\n /** Seal speed for 50-100% (ms/%). */\r\n sealMotorSpeedMs?: number;\r\n /** Initial travel speed when uncontrolled (ms/%). */\r\n defaultTravelMotorSpeedMs?: number;\r\n /** Initial seal speed when uncontrolled (ms/%). */\r\n defaultSealMotorSpeedMs?: number;\r\n /** Called whenever travel speed changes. */\r\n onTravelMotorSpeedChange?: (value: number) => void;\r\n /** Called whenever seal speed changes. */\r\n onSealMotorSpeedChange?: (value: number) => void;\r\n /** Show open / stop / close buttons and motor speed UI. Default false (visual-only). */\r\n showControls?: boolean;\r\n /** Show the speed slider below the controls. Requires showControls. Default false. */\r\n showSpeedControl?: boolean;\r\n /** Optional readable name shown below the controls. */\r\n label?: string;\r\n /** Hide the level readout (for compact layouts). */\r\n hideReadout?: boolean;\r\n /**\r\n * Background visible behind the shutter when open (window recess).\r\n * @default \"#ffffff\"\r\n */\r\n paneBackground?: string;\r\n /**\r\n * Optional CSS `background-image` layered on the pane (e.g. a sky gradient).\r\n * Omit for a flat `paneBackground` only.\r\n */\r\n paneOverlay?: string;\r\n /** Wrapper class name. */\r\n className?: string;\r\n}\r\n\r\nexport const DEFAULT_PANE_BACKGROUND = \"#ffffff\";\r\n\r\ninterface VariantStyle {\r\n frame: string;\r\n inner: string;\r\n slatPrimary: string; // top of each slat\r\n slatSecondary: string; // shadow groove between slats\r\n glow: string;\r\n dark: string;\r\n mullion: string;\r\n /** Level (0-100) at which the shutter has fully covered the window. */\r\n breakpoint: number;\r\n}\r\n\r\nconst VARIANTS: Record = {\r\n classic: {\r\n frame:\r\n \"border border-zinc-400/70 bg-[linear-gradient(to_bottom,#ececec,#d4d4d8)] shadow-[0_1px_0_rgba(255,255,255,0.5)_inset]\",\r\n inner: \"border-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]\",\r\n slatPrimary: \"#9a9aa1\",\r\n slatSecondary: \"#7f7f87\",\r\n glow: \"rgba(255,245,210,0.65)\",\r\n dark: \"#000000\",\r\n mullion: \"#d6dbe3\",\r\n breakpoint: 70,\r\n },\r\n warm: {\r\n frame:\r\n \"border border-[#1a0f06] bg-[linear-gradient(to_bottom,#4a3020,#2e1a0c)] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]\",\r\n inner: \"border-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]\",\r\n slatPrimary: \"#8b5a3c\",\r\n slatSecondary: \"#663b22\",\r\n glow: \"rgba(255,220,160,0.7)\",\r\n dark: \"#1a0a02\",\r\n mullion: \"#d5b58c\",\r\n breakpoint: 65,\r\n },\r\n dark: {\r\n frame:\r\n \"border border-zinc-950 bg-[linear-gradient(to_bottom,#2a2a30,#141418)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]\",\r\n inner: \"border-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]\",\r\n slatPrimary: \"#3f3f46\",\r\n slatSecondary: \"#1f1f23\",\r\n glow: \"rgba(160,200,255,0.55)\",\r\n dark: \"#02030a\",\r\n mullion: \"#9ca6bd\",\r\n breakpoint: 70,\r\n },\r\n minimal: {\r\n frame: \"border border-zinc-300/90 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]\",\r\n inner: \"border-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]\",\r\n slatPrimary: \"#e4e4e7\",\r\n slatSecondary: \"#c4c4c8\",\r\n glow: \"transparent\",\r\n dark: \"#0a0a0a\",\r\n mullion: \"#cfd5df\",\r\n breakpoint: 80,\r\n },\r\n industrial: {\r\n frame:\r\n \"border border-zinc-800 bg-[linear-gradient(to_bottom,#5c5c66,#45454f)] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]\",\r\n inner: \"border-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]\",\r\n slatPrimary: \"#a1a1aa\",\r\n slatSecondary: \"#71717a\",\r\n glow: \"rgba(220,225,235,0.6)\",\r\n dark: \"#0d0d10\",\r\n mullion: \"#c1c9d5\",\r\n breakpoint: 75,\r\n },\r\n};\r\n\r\ninterface SlatSpec {\r\n body: number; // px height of the slat itself\r\n gap: number; // px height of the shadow groove\r\n}\r\n\r\nconst SLAT_SIZES: Record = {\r\n fine: { body: 6, gap: 2 },\r\n medium: { body: 8, gap: 3 },\r\n thick: { body: 12, gap: 4 },\r\n chunky: { body: 16, gap: 5 },\r\n};\r\n\r\nfunction buildSlats(v: VariantStyle, slat: SlatSpec) {\r\n const total = slat.body + slat.gap;\r\n return `repeating-linear-gradient(to bottom, ${v.slatPrimary} 0px, ${v.slatPrimary} ${slat.body}px, ${v.slatSecondary} ${slat.body}px, ${v.slatSecondary} ${total}px)`;\r\n}\r\n\r\nfunction buildHoleBackground(\r\n shape: ShutterHoleShape,\r\n color: string,\r\n slat: SlatSpec\r\n): { image: string; size: string; position: string } {\r\n const total = slat.body + slat.gap;\r\n const tile = `${total}px ${total}px`;\r\n const center = `${total / 2}px ${slat.body / 2}px`;\r\n\r\n if (shape === \"circle\") {\r\n return {\r\n image: `radial-gradient(circle, ${color} 1.1px, transparent 1.2px)`,\r\n size: tile,\r\n position: center,\r\n };\r\n }\r\n if (shape === \"ellipse\") {\r\n return {\r\n image: `radial-gradient(ellipse 2.6px 1.1px at center, ${color} 0, ${color} 60%, transparent 100%)`,\r\n size: tile,\r\n position: center,\r\n };\r\n }\r\n // rectangle: render via inline SVG so we get true rectangular slits.\r\n const rectW = Math.min(total * 0.55, 5);\r\n const rectH = Math.max(1.5, slat.body * 0.45);\r\n const rectX = (total - rectW) / 2;\r\n const rectY = (slat.body - rectH) / 2;\r\n const svg = ``;\r\n return {\r\n image: `url(\"data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}\")`,\r\n size: tile,\r\n position: \"0 0\",\r\n };\r\n}\r\n\r\ninterface SizeStyle {\r\n panel: string;\r\n squarePanel: string;\r\n frameRadius: string;\r\n innerRadius: string;\r\n framePadding: string;\r\n panelGap: string;\r\n}\r\n\r\nconst SIZES: Record = {\r\n sm: {\r\n panel: \"w-24 h-40\",\r\n squarePanel: \"size-32\",\r\n frameRadius: \"rounded-[1.5rem]\",\r\n innerRadius: \"rounded-[1.15rem]\",\r\n framePadding: \"p-1.5\",\r\n panelGap: \"gap-1.5\",\r\n },\r\n md: {\r\n panel: \"w-28 h-48\",\r\n squarePanel: \"size-40\",\r\n frameRadius: \"rounded-[1.75rem]\",\r\n innerRadius: \"rounded-[1.35rem]\",\r\n framePadding: \"p-1.5\",\r\n panelGap: \"gap-1.5\",\r\n },\r\n lg: {\r\n panel: \"w-36 h-60\",\r\n squarePanel: \"size-52\",\r\n frameRadius: \"rounded-[2rem]\",\r\n innerRadius: \"rounded-[1.6rem]\",\r\n framePadding: \"p-2\",\r\n panelGap: \"gap-2\",\r\n },\r\n xl: {\r\n panel: \"w-48 h-72\",\r\n squarePanel: \"size-72\",\r\n frameRadius: \"rounded-[2.25rem]\",\r\n innerRadius: \"rounded-[1.85rem]\",\r\n framePadding: \"p-2.5\",\r\n panelGap: \"gap-2.5\",\r\n },\r\n};\r\n\r\ninterface ShapeStyle {\r\n panelClass?: string;\r\n innerStyle?: React.CSSProperties;\r\n frameStyle?: React.CSSProperties;\r\n frameRadius?: string;\r\n innerRadius?: string;\r\n /** Override gap between panels on the frame row. */\r\n panelGap?: string;\r\n /** Decorative mullions (window crossbars) drawn over the shutter visual. */\r\n mullionVertical?: boolean;\r\n mullionHorizontal?: boolean;\r\n}\r\n\r\nconst SINGLE_ARCH_FRAME_STYLE: CSSProperties = {\r\n borderTopLeftRadius: \"100% 50%\",\r\n borderTopRightRadius: \"100% 50%\",\r\n borderBottomLeftRadius: \"0.9rem\",\r\n borderBottomRightRadius: \"0.9rem\",\r\n};\r\n\r\nconst SINGLE_ARCH_PANEL_STYLE: CSSProperties = {\r\n borderTopLeftRadius: \"100% 48%\",\r\n borderTopRightRadius: \"100% 48%\",\r\n borderBottomLeftRadius: \"0.5rem\",\r\n borderBottomRightRadius: \"0.5rem\",\r\n};\r\n\r\nfunction getSingleArchFrameShape(): ShapeStyle {\r\n return {\r\n frameStyle: SINGLE_ARCH_FRAME_STYLE,\r\n frameRadius: \"rounded-none\",\r\n };\r\n}\r\n\r\nfunction getSingleArchPanelShape(): ShapeStyle {\r\n return {\r\n innerStyle: SINGLE_ARCH_PANEL_STYLE,\r\n frameRadius: \"rounded-none\",\r\n innerRadius: \"rounded-none\",\r\n };\r\n}\r\n\r\nfunction getFrameShapeStyle(shape: ShutterShape, size: SizeStyle, panels: ShutterPanels): ShapeStyle {\r\n switch (shape) {\r\n case \"rectangle\":\r\n return {};\r\n case \"arch\":\r\n if (panels === 2) {\r\n return { panelGap: \"gap-1.5\" };\r\n }\r\n return getSingleArchFrameShape();\r\n case \"round\":\r\n return {\r\n frameStyle: { borderRadius: \"9999px\" },\r\n frameRadius: \"rounded-none\",\r\n };\r\n case \"porthole\":\r\n return {\r\n frameStyle: {\r\n borderRadius: \"9999px\",\r\n padding: \"0.625rem\",\r\n background:\r\n \"radial-gradient(circle at 30% 30%, rgba(255,255,255,0.6), rgba(0,0,0,0.05) 60%)\",\r\n boxShadow: \"inset 0 0 0 2px rgba(0,0,0,0.15)\",\r\n },\r\n frameRadius: \"rounded-none\",\r\n };\r\n case \"gothic\":\r\n return {\r\n frameStyle: {\r\n clipPath: \"polygon(0% 22%, 50% 0%, 100% 22%, 100% 100%, 0% 100%)\",\r\n },\r\n frameRadius: \"rounded-none\",\r\n };\r\n case \"casement\":\r\n // Classic European casement: sharp 90° corners, slim frame, with a\r\n // cross of mullions dividing the pane into 4 quadrants — the typical\r\n // Classic 4-pane casement look behind which a real shutter sits.\r\n return {\r\n frameRadius: \"rounded-sm\",\r\n innerRadius: \"rounded-none\",\r\n mullionVertical: true,\r\n mullionHorizontal: true,\r\n };\r\n case \"sash\":\r\n // Vertical sash window (upper / lower pane) — only a horizontal\r\n // mullion across the middle.\r\n return {\r\n frameRadius: \"rounded-sm\",\r\n innerRadius: \"rounded-none\",\r\n mullionHorizontal: true,\r\n };\r\n default:\r\n return {};\r\n }\r\n}\r\n\r\nfunction getPanelShapeStyle(\r\n shape: ShutterShape,\r\n size: SizeStyle,\r\n panels: ShutterPanels,\r\n panelIndex: number\r\n): ShapeStyle {\r\n switch (shape) {\r\n case \"rectangle\":\r\n return {};\r\n case \"arch\":\r\n return getSingleArchPanelShape();\r\n case \"round\":\r\n return {\r\n panelClass: size.squarePanel,\r\n innerStyle: { borderRadius: \"9999px\" },\r\n frameRadius: \"rounded-none\",\r\n innerRadius: \"rounded-none\",\r\n };\r\n case \"porthole\":\r\n return {\r\n panelClass: size.squarePanel,\r\n innerStyle: { borderRadius: \"9999px\" },\r\n frameRadius: \"rounded-none\",\r\n innerRadius: \"rounded-none\",\r\n };\r\n case \"gothic\":\r\n return {\r\n innerStyle: {\r\n clipPath: \"polygon(0% 25%, 50% 0%, 100% 25%, 100% 100%, 0% 100%)\",\r\n },\r\n frameRadius: \"rounded-none\",\r\n innerRadius: \"rounded-none\",\r\n };\r\n case \"casement\":\r\n return {\r\n frameRadius: \"rounded-sm\",\r\n innerRadius: \"rounded-none\",\r\n mullionVertical: true,\r\n mullionHorizontal: true,\r\n };\r\n case \"sash\":\r\n return {\r\n frameRadius: \"rounded-sm\",\r\n innerRadius: \"rounded-none\",\r\n mullionHorizontal: true,\r\n };\r\n default:\r\n return {};\r\n }\r\n}\r\n\r\nfunction smoothstep(edge0: number, edge1: number, x: number) {\r\n const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));\r\n return t * t * (3 - 2 * t);\r\n}\r\n\r\nfunction clamp(value: number, min = 0, max = 100) {\r\n return Math.max(min, Math.min(max, value));\r\n}\r\n\r\n/** ms per 1% level → seconds for a full 0→100% close (travel + seal, equal speeds). */\r\nfunction msPerPctToCloseSeconds(msPerPct: number) {\r\n return (msPerPct * 100) / 1000;\r\n}\r\n\r\nfunction closeSecondsToMsPerPct(seconds: number) {\r\n return (seconds * 1000) / 100;\r\n}\r\n\r\nconst CLOSE_DURATION_PRESETS = [\r\n { label: \"Fast\", seconds: 3 },\r\n { label: \"Normal\", seconds: 6 },\r\n { label: \"Slow\", seconds: 10 },\r\n] as const;\r\n\r\nconst CLOSE_DURATION_MIN = 2;\r\nconst CLOSE_DURATION_MAX = 14;\r\n\r\ninterface MotorSpeedControlProps {\r\n travelSpeedMs: number;\r\n sealSpeedMs: number;\r\n isTravelControlled: boolean;\r\n isSealControlled: boolean;\r\n motorSpeedControlled: boolean;\r\n onTravelMotorSpeedChange?: (value: number) => void;\r\n onSealMotorSpeedChange?: (value: number) => void;\r\n onMotorSpeedChange?: (value: number) => void;\r\n setInternalTravelSpeed: (value: number) => void;\r\n setInternalSealSpeed: (value: number) => void;\r\n}\r\n\r\nfunction MotorSpeedControl({\r\n travelSpeedMs,\r\n sealSpeedMs,\r\n isTravelControlled,\r\n isSealControlled,\r\n motorSpeedControlled,\r\n onTravelMotorSpeedChange,\r\n onSealMotorSpeedChange,\r\n onMotorSpeedChange,\r\n setInternalTravelSpeed,\r\n setInternalSealSpeed,\r\n}: MotorSpeedControlProps) {\r\n const [showAdvanced, setShowAdvanced] = useState(false);\r\n\r\n const applyMsPerPct = (msPerPct: number) => {\r\n const next = clamp(msPerPct, 30, 180);\r\n if (!isTravelControlled && !motorSpeedControlled) {\r\n setInternalTravelSpeed(next);\r\n }\r\n if (!isSealControlled && !motorSpeedControlled) {\r\n setInternalSealSpeed(next);\r\n }\r\n onTravelMotorSpeedChange?.(next);\r\n onSealMotorSpeedChange?.(next);\r\n if (!onTravelMotorSpeedChange && !onSealMotorSpeedChange && onMotorSpeedChange) {\r\n onMotorSpeedChange(next / 2);\r\n }\r\n };\r\n\r\n const unifiedMs = Math.round((travelSpeedMs + sealSpeedMs) / 2);\r\n const closeSeconds = clamp(\r\n msPerPctToCloseSeconds(unifiedMs),\r\n CLOSE_DURATION_MIN,\r\n CLOSE_DURATION_MAX\r\n );\r\n const totalCloseSeconds = (50 * travelSpeedMs + 50 * sealSpeedMs) / 1000;\r\n\r\n const presetClass = (active: boolean) =>\r\n cn(\r\n \"rounded-md border px-2.5 py-1 text-xs font-medium transition\",\r\n active\r\n ? \"border-foreground bg-foreground text-background\"\r\n : \"border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground\"\r\n );\r\n\r\n return (\r\n
\r\n
\r\n Motor speed\r\n \r\n ~{(showAdvanced ? totalCloseSeconds : closeSeconds).toFixed(1)}s close\r\n \r\n
\r\n\r\n {!showAdvanced && (\r\n <>\r\n
\r\n {CLOSE_DURATION_PRESETS.map((preset) => {\r\n const active = Math.abs(closeSeconds - preset.seconds) < 0.6;\r\n return (\r\n applyMsPerPct(closeSecondsToMsPerPct(preset.seconds))}\r\n >\r\n {preset.label}\r\n \r\n );\r\n })}\r\n
\r\n\r\n
\r\n
\r\n Faster\r\n Slower\r\n
\r\n applyMsPerPct(closeSecondsToMsPerPct(Number(event.target.value)))}\r\n aria-label=\"Time to fully close the shutter\"\r\n aria-valuetext={`${closeSeconds.toFixed(1)} seconds`}\r\n className=\"w-full accent-foreground\"\r\n />\r\n
\r\n \r\n )}\r\n\r\n setShowAdvanced((open) => !open)}\r\n >\r\n {showAdvanced ? \"Hide advanced\" : \"Advanced (two phases)\"}\r\n \r\n\r\n {showAdvanced && (\r\n
\r\n

\r\n First half lowers the shutter; second half seals the slats. Lower ms = faster.\r\n

\r\n
\r\n \r\n {\r\n const next = Number(event.target.value);\r\n if (!isTravelControlled && !motorSpeedControlled) {\r\n setInternalTravelSpeed(next);\r\n }\r\n onTravelMotorSpeedChange?.(next);\r\n if (!onTravelMotorSpeedChange && onMotorSpeedChange) {\r\n onMotorSpeedChange(next / 2);\r\n }\r\n }}\r\n aria-label=\"Lowering phase speed\"\r\n className=\"w-full accent-foreground\"\r\n />\r\n
\r\n
\r\n \r\n {\r\n const next = Number(event.target.value);\r\n if (!isSealControlled && !motorSpeedControlled) {\r\n setInternalSealSpeed(next);\r\n }\r\n onSealMotorSpeedChange?.(next);\r\n if (!onSealMotorSpeedChange && onMotorSpeedChange) {\r\n onMotorSpeedChange(next / 2);\r\n }\r\n }}\r\n aria-label=\"Sealing phase speed\"\r\n className=\"w-full accent-foreground\"\r\n />\r\n
\r\n
\r\n )}\r\n
\r\n );\r\n}\r\n\r\ninterface ShutterPanelProps {\r\n level: number;\r\n variant: VariantStyle;\r\n size: SizeStyle;\r\n shape: ShapeStyle;\r\n slat: SlatSpec;\r\n curved: boolean;\r\n holeShape: ShutterHoleShape;\r\n paneBackground: string;\r\n paneOverlay?: string;\r\n ariaLabel: string;\r\n onUserChange: (next: number) => void;\r\n onUserStart: () => void;\r\n}\r\n\r\nfunction ShutterPanel({\r\n level,\r\n variant,\r\n size,\r\n shape,\r\n slat,\r\n curved,\r\n holeShape,\r\n paneBackground,\r\n paneOverlay,\r\n ariaLabel,\r\n onUserChange,\r\n onUserStart,\r\n}: ShutterPanelProps) {\r\n const inputPct = clamp(level);\r\n const travelPct = inputPct <= 50 ? inputPct * 2 : 100;\r\n const openPct = 100 - travelPct;\r\n const sealProgress = inputPct <= 50 ? 0 : (inputPct - 50) / 50;\r\n const [panelHeightPx, setPanelHeightPx] = useState(0);\r\n const pitchPx = slat.body + slat.gap;\r\n const rowsCount = Math.max(1, Math.floor(panelHeightPx / pitchPx));\r\n const closedRows = Math.floor(sealProgress * rowsCount);\r\n const closedRowsPct = (closedRows / rowsCount) * 100;\r\n const darkness = sealProgress * 0.88;\r\n const terminalBarHeight = slat.body;\r\n\r\n const slatsImage = buildSlats(variant, slat);\r\n const hole = buildHoleBackground(holeShape, variant.glow, slat);\r\n\r\n const ref = useRef(null);\r\n const draggingRef = useRef(false);\r\n\r\n\r\n const setFromY = (clientY: number) => {\r\n const el = ref.current;\r\n if (!el) return;\r\n const rect = el.getBoundingClientRect();\r\n const ratio = (clientY - rect.top) / rect.height;\r\n onUserChange(clamp(ratio * 100));\r\n };\r\n\r\n useEffect(() => {\r\n const el = ref.current;\r\n if (!el) return;\r\n const update = () => setPanelHeightPx(el.clientHeight);\r\n update();\r\n const observer = new ResizeObserver(update);\r\n observer.observe(el);\r\n return () => observer.disconnect();\r\n }, []);\r\n\r\n return (\r\n {\r\n e.preventDefault();\r\n onUserStart();\r\n draggingRef.current = true;\r\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\r\n setFromY(e.clientY);\r\n }}\r\n onPointerMove={(e) => {\r\n if (!draggingRef.current) return;\r\n setFromY(e.clientY);\r\n }}\r\n onPointerUp={(e) => {\r\n draggingRef.current = false;\r\n (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);\r\n }}\r\n onPointerCancel={() => {\r\n draggingRef.current = false;\r\n }}\r\n onKeyDown={(e) => {\r\n if (e.key === \"ArrowDown\") {\r\n e.preventDefault();\r\n onUserStart();\r\n onUserChange(level + (e.shiftKey ? 10 : 1));\r\n } else if (e.key === \"ArrowUp\") {\r\n e.preventDefault();\r\n onUserStart();\r\n onUserChange(level - (e.shiftKey ? 10 : 1));\r\n } else if (e.key === \"Home\") {\r\n e.preventDefault();\r\n onUserStart();\r\n onUserChange(0);\r\n } else if (e.key === \"End\") {\r\n e.preventDefault();\r\n onUserStart();\r\n onUserChange(100);\r\n }\r\n }}\r\n className={cn(\r\n \"relative cursor-ns-resize touch-none select-none overflow-hidden border-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\r\n shape.panelClass ?? size.panel,\r\n shape.innerRadius ?? size.innerRadius,\r\n variant.inner\r\n )}\r\n style={{ backgroundColor: paneBackground, ...shape.innerStyle }}\r\n >\r\n
\r\n
\r\n\r\n \r\n {paneOverlay ? (\r\n \r\n ) : null}\r\n\r\n {/* Moving shutter sheet: full-size element translated upward while opening.\r\n This keeps slats constant and creates real \"scrolling up\" behavior. */}\r\n \r\n {/* Slats body */}\r\n \r\n\r\n {/* Perforation glow — fades out as holes close. The shape (circle /\r\n ellipse / rectangle slit) is configurable via the `holeShape` prop. */}\r\n {variant.glow !== \"transparent\" && (\r\n \r\n )}\r\n\r\n {/* Progressive closure of perforations: as it nears full close, solid\r\n rows rise from the bottom and gradually replace the holes. */}\r\n
\r\n \r\n
\r\n\r\n {/* Terminal slat (fondello) with end-stops. Same thickness as all slats. */}\r\n \r\n \r\n \r\n
\r\n\r\n {/* Cylindrical shading: dark on the sides, slight highlight in the\r\n middle, a touch of bottom shadow. Gives the shutter a 3D bulge. */}\r\n {curved && (\r\n <>\r\n \r\n \r\n \r\n )}\r\n\r\n {/* Darkness overlay */}\r\n \r\n
\r\n\r\n {/* Decorative mullions — sit on top of the shutter visual so the\r\n window grid stays visible at any opening level. */}\r\n {shape.mullionVertical && (\r\n \r\n )}\r\n {shape.mullionHorizontal && (\r\n \r\n )}\r\n \r\n );\r\n}\r\n\r\nexport function Shutter({\r\n level: levelProp,\r\n defaultLevel = 0,\r\n onLevelChange,\r\n levels: levelsProp,\r\n defaultLevels,\r\n onLevelsChange,\r\n variant = \"classic\",\r\n size = \"md\",\r\n shape = \"rectangle\",\r\n panels = 1,\r\n panelMode = \"sync\",\r\n stagger = 0,\r\n slatSize = \"medium\",\r\n curved = false,\r\n holeShape = \"ellipse\",\r\n motorSpeedMs: motorSpeedMsProp,\r\n defaultMotorSpeedMs = 60,\r\n onMotorSpeedChange,\r\n travelMotorSpeedMs: travelMotorSpeedMsProp,\r\n sealMotorSpeedMs: sealMotorSpeedMsProp,\r\n defaultTravelMotorSpeedMs,\r\n defaultSealMotorSpeedMs,\r\n onTravelMotorSpeedChange,\r\n onSealMotorSpeedChange,\r\n showControls = false,\r\n showSpeedControl = false,\r\n label,\r\n hideReadout = true,\r\n paneBackground = DEFAULT_PANE_BACKGROUND,\r\n paneOverlay,\r\n className,\r\n}: ShutterProps) {\r\n const isControlled = levelsProp !== undefined || levelProp !== undefined;\r\n const isTravelControlled = travelMotorSpeedMsProp !== undefined;\r\n const isSealControlled = sealMotorSpeedMsProp !== undefined;\r\n const baseDefaultSpeed = motorSpeedMsProp ?? defaultMotorSpeedMs;\r\n const [internalTravelSpeed, setInternalTravelSpeed] = useState(\r\n defaultTravelMotorSpeedMs ?? baseDefaultSpeed * 2\r\n );\r\n const [internalSealSpeed, setInternalSealSpeed] = useState(\r\n defaultSealMotorSpeedMs ?? baseDefaultSpeed * 2\r\n );\r\n const travelSpeedMs = isTravelControlled\r\n ? travelMotorSpeedMsProp!\r\n : motorSpeedMsProp !== undefined\r\n ? motorSpeedMsProp * 2\r\n : internalTravelSpeed;\r\n const sealSpeedMs = isSealControlled\r\n ? sealMotorSpeedMsProp!\r\n : motorSpeedMsProp !== undefined\r\n ? motorSpeedMsProp * 2\r\n : internalSealSpeed;\r\n\r\n const initialLevels = useMemo(() => {\r\n if (defaultLevels && defaultLevels.length > 0) {\r\n return Array.from({ length: panels }, (_, i) => defaultLevels[i] ?? defaultLevels[0] ?? 0);\r\n }\r\n return Array.from({ length: panels }, () => defaultLevel);\r\n // Initial-only: panels & defaults are not expected to mutate at runtime.\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n const [internalLevels, setInternalLevels] = useState(initialLevels);\r\n\r\n const levels: number[] = useMemo(() => {\r\n if (levelsProp) {\r\n return Array.from({ length: panels }, (_, i) => levelsProp[i] ?? levelsProp[0] ?? 0);\r\n }\r\n if (levelProp !== undefined) {\r\n return Array.from({ length: panels }, () => levelProp);\r\n }\r\n return internalLevels;\r\n }, [levelsProp, levelProp, internalLevels, panels]);\r\n\r\n const levelsRef = useRef(levels);\r\n useEffect(() => {\r\n levelsRef.current = levels;\r\n }, [levels]);\r\n\r\n const rafRefs = useRef<(number | null)[]>([null, null]);\r\n const animTokens = useRef([0, 0]);\r\n\r\n const cancelPanelAnim = (index: number) => {\r\n animTokens.current[index] += 1;\r\n if (rafRefs.current[index] !== null) {\r\n cancelAnimationFrame(rafRefs.current[index]!);\r\n rafRefs.current[index] = null;\r\n }\r\n };\r\n\r\n const cancelAllAnims = () => {\r\n for (let i = 0; i < rafRefs.current.length; i++) cancelPanelAnim(i);\r\n };\r\n\r\n useEffect(() => () => cancelAllAnims(), []);\r\n\r\n const commitLevels = (next: number[]) => {\r\n levelsRef.current = next;\r\n if (!isControlled) setInternalLevels(next);\r\n onLevelsChange?.(next);\r\n onLevelChange?.(next[0]);\r\n };\r\n\r\n const handlePanelChange = (index: number, value: number) => {\r\n const clamped = clamp(value);\r\n const current = levelsRef.current;\r\n let next: number[];\r\n if (panelMode === \"sync\") {\r\n next = current.map(() => clamped);\r\n } else {\r\n next = [...current];\r\n next[index] = clamped;\r\n }\r\n commitLevels(next);\r\n };\r\n\r\n const animatePanelTo = (index: number, target: number) => {\r\n cancelPanelAnim(index);\r\n const start = levelsRef.current[index];\r\n const distance = target - start;\r\n if (distance === 0) return;\r\n animTokens.current[index] += 1;\r\n const token = animTokens.current[index];\r\n const startLevel = clamp(start);\r\n const targetLevel = clamp(target);\r\n const segments: Array<{ from: number; to: number; duration: number }> = [];\r\n\r\n if (startLevel < 50 && targetLevel > 50) {\r\n segments.push({\r\n from: startLevel,\r\n to: 50,\r\n duration: (50 - startLevel) * travelSpeedMs,\r\n });\r\n segments.push({\r\n from: 50,\r\n to: targetLevel,\r\n duration: (targetLevel - 50) * sealSpeedMs,\r\n });\r\n } else if (startLevel > 50 && targetLevel < 50) {\r\n segments.push({\r\n from: startLevel,\r\n to: 50,\r\n duration: (startLevel - 50) * sealSpeedMs,\r\n });\r\n segments.push({\r\n from: 50,\r\n to: targetLevel,\r\n duration: (50 - targetLevel) * travelSpeedMs,\r\n });\r\n } else {\r\n segments.push({\r\n from: startLevel,\r\n to: targetLevel,\r\n duration:\r\n Math.abs(targetLevel - startLevel) * (startLevel <= 50 ? travelSpeedMs : sealSpeedMs),\r\n });\r\n }\r\n\r\n const runSegment = (idx: number) => {\r\n if (animTokens.current[index] !== token) return;\r\n const seg = segments[idx];\r\n if (!seg || seg.duration <= 0) {\r\n if (seg) handleSegmentCommit(seg.to);\r\n if (segments[idx + 1]) runSegment(idx + 1);\r\n return;\r\n }\r\n const t0 = performance.now();\r\n const tick = (now: number) => {\r\n if (animTokens.current[index] !== token) return;\r\n const t = Math.min(1, (now - t0) / seg.duration);\r\n const next = seg.from + (seg.to - seg.from) * t;\r\n handleSegmentCommit(next);\r\n if (t < 1) {\r\n rafRefs.current[index] = requestAnimationFrame(tick);\r\n } else {\r\n rafRefs.current[index] = null;\r\n if (segments[idx + 1]) runSegment(idx + 1);\r\n }\r\n };\r\n rafRefs.current[index] = requestAnimationFrame(tick);\r\n };\r\n\r\n const handleSegmentCommit = (next: number) => {\r\n const current = levelsRef.current;\r\n let arr: number[];\r\n if (panelMode === \"sync\") {\r\n arr = current.map(() => next);\r\n } else {\r\n arr = [...current];\r\n arr[index] = next;\r\n }\r\n commitLevels(arr);\r\n };\r\n\r\n runSegment(0);\r\n };\r\n\r\n const animateAllTo = (target: number) => {\r\n if (panelMode === \"sync\") {\r\n animatePanelTo(0, target);\r\n return;\r\n }\r\n for (let i = 0; i < panels; i++) {\r\n const delay = stagger * i;\r\n if (delay <= 0) {\r\n animatePanelTo(i, target);\r\n } else {\r\n const idx = i;\r\n window.setTimeout(() => animatePanelTo(idx, target), delay);\r\n }\r\n }\r\n };\r\n\r\n const v = VARIANTS[variant];\r\n const s = SIZES[size];\r\n const frameShape = getFrameShapeStyle(shape, s, panels);\r\n const singleArchFrame = getSingleArchFrameShape();\r\n const singleArchPanel = getSingleArchPanelShape();\r\n const slat = SLAT_SIZES[slatSize];\r\n const isDoubleArch = shape === \"arch\" && panels === 2;\r\n\r\n const buttonBase =\r\n \"grid size-9 place-items-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\r\n\r\n const baseLabel = label ? `Shutter ${label}` : \"Shutter\";\r\n const readout =\r\n panels === 2 && panelMode === \"independent\"\r\n ? `${Math.round(levels[0])}% / ${Math.round(levels[1])}%`\r\n : `${Math.round(levels[0])}%`;\r\n\r\n return (\r\n
\r\n {isDoubleArch ? (\r\n
\r\n {Array.from({ length: panels }, (_, i) => (\r\n \r\n handlePanelChange(i, value)}\r\n onUserStart={() => {\r\n if (panelMode === \"sync\") cancelAllAnims();\r\n else cancelPanelAnim(i);\r\n }}\r\n />\r\n
\r\n ))}\r\n
\r\n ) : (\r\n \r\n {Array.from({ length: panels }, (_, i) => (\r\n handlePanelChange(i, value)}\r\n onUserStart={() => {\r\n if (panelMode === \"sync\") cancelAllAnims();\r\n else cancelPanelAnim(i);\r\n }}\r\n />\r\n ))}\r\n \r\n )}\r\n\r\n {showControls && (\r\n
\r\n animateAllTo(0)}\r\n aria-label=\"Open shutter\"\r\n className={buttonBase}\r\n >\r\n \r\n \r\n \r\n \r\n \r\n animateAllTo(100)}\r\n aria-label=\"Close shutter\"\r\n className={buttonBase}\r\n >\r\n \r\n \r\n
\r\n )}\r\n\r\n {showControls && showSpeedControl && (\r\n \r\n )}\r\n\r\n {showControls && (label || !hideReadout) && (\r\n
\r\n {label &&

{label}

}\r\n {!hideReadout && (\r\n

Position: {readout}

\r\n )}\r\n
\r\n )}\r\n \r\n );\r\n}\r\n", "type": "registry:ui" }, { "path": "lib/utils.ts", "content": "import { clsx, type ClassValue } from \"clsx\"\r\nimport { twMerge } from \"tailwind-merge\"\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs))\r\n}\r\n", "type": "registry:lib" } ], "categories": [ "shutter" ], "type": "registry:ui" }