From 05379f01b6e572bbbbb62ed323d2a2870ad1651c Mon Sep 17 00:00:00 2001 From: Lorant Date: Sat, 8 Nov 2025 16:04:37 +0100 Subject: [PATCH 01/16] docs: update badge design --- apps/docs/src/app/page.tsx | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx index cf4330b..18ede22 100644 --- a/apps/docs/src/app/page.tsx +++ b/apps/docs/src/app/page.tsx @@ -13,7 +13,9 @@ import { Meta, Schema, MatrixFx, - Background + Background, + Pulse, + ShineFx } from "@once-ui-system/core"; import { baseURL, meta, schema, changelog, roadmap, layout } from "@/resources"; import { formatDate } from "./utils/formatDate"; @@ -89,7 +91,7 @@ export default function Home() { top="0" left="0" flicker - colors={["brand-solid-strong"]} + colors={["brand-solid-strong"]} bulge={{ type: "wave", duration: 3, @@ -101,20 +103,27 @@ export default function Home() { - NEW - - Once UI 1.5 — Curiosity in code - + + + + Once UI 1.5 Curiosity in code + New + + Once UI Docs From 52105c759532fbb1ccd1c6c7cebbf9155aa16cc9 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sat, 8 Nov 2025 16:04:50 +0100 Subject: [PATCH 02/16] feat: add lightning to WeatherFx --- .../src/content/once-ui/effects/weatherFx.mdx | 151 +++++++- packages/core/src/components/WeatherFx.tsx | 330 +++++++++++++++++- 2 files changed, 474 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/once-ui/effects/weatherFx.mdx b/apps/docs/src/content/once-ui/effects/weatherFx.mdx index 9070958..a48ad08 100644 --- a/apps/docs/src/content/once-ui/effects/weatherFx.mdx +++ b/apps/docs/src/content/once-ui/effects/weatherFx.mdx @@ -1,6 +1,6 @@ --- title: "WeatherFx" -summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves) using native Canvas 2D API." +summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves, lightning) using native Canvas 2D API." updatedAt: "2025-10-25" docs: "once-ui/effects/WeatherFx.mdx" github: "components/WeatherFx.tsx" @@ -10,7 +10,7 @@ navTag: "New" navTagVariant: "cyan" --- -The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, and falling leaves with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas. +The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, falling leaves, and lightning with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas. Leaves + + + Lightning + + } codes={[ @@ -130,6 +141,13 @@ Switch between different weather effects: rain, snow, and falling leaves. colors={["warning-solid-strong", "danger-solid-strong"]} intensity={40} speed={0.7} +/> + +`, language: "tsx", label: "Types" @@ -669,6 +687,131 @@ Leaves feature gradient colors, rotation animation, and tumbling motion for an a ]} /> +## Lightning Effect + +Lightning features highly realistic bolt generation with chaotic, organic branching at all angles. Each bolt creates dense networks of branches spreading in multiple directions with up to 4 levels of hierarchy. The effect uses small, erratic segments and high branching probability to create the natural, fractal-like appearance of real lightning. Bolts animate from top to bottom with progressively thinner branches. The `intensity` prop controls both the frequency and number of simultaneous strikes. + + + + + Thunderstorm + + + Dynamic lightning with branching bolts + + + + } + codes={[ + { + code: +` + + + Thunderstorm + + + Dynamic lightning with branching bolts + + +`, + language: "tsx", + label: "Lightning Effect" + } + ]} +/> + +## Lightning Intensity + +For lightning, the `intensity` prop controls strike frequency and bolt patterns. The effect alternates between single and multi-bolt strikes for natural variation. Low intensity (1-15) creates single, rare strikes. Medium intensity (16-30) occasionally spawns double bolts. High intensity (31-60) alternates between single and 2-3 bolt strikes. Very high intensity (61+) creates dramatic storms alternating between single and 2-4 simultaneous bolts. + + + + + Rare (5) + + + + + Frequent (20) + + + + + Storm (50) + + + + } + codes={[ + { + code: +` + + + +`, + language: "tsx", + label: "Lightning Intensity" + } + ]} +/> + ## Duration Control how long particles are emitted with the `duration` prop (in seconds). After the duration, existing particles finish their animation but no new ones spawn. @@ -783,7 +926,7 @@ Use `trigger="hover"` to only emit particles when hovering over the element. { type?: WeatherType; @@ -58,6 +58,26 @@ interface Leaf { depth: number; } +interface LightningBranch { + startIndex: number; + segments: { x: number; y: number }[]; + thickness: number; + children: LightningBranch[]; // Hierarchical sub-branches +} + +interface Lightning { + x: number; + y: number; + segments: { x: number; y: number }[]; + color: string; + opacity: number; + thickness: number; + lifetime: number; + age: number; + branches: LightningBranch[]; + revealDuration: number; // Duration for the reveal animation +} + const WeatherFx = React.forwardRef( ( { @@ -76,11 +96,13 @@ const WeatherFx = React.forwardRef( const containerRef = useRef(null); const canvasRef = useRef(null); const animationRef = useRef(undefined); - const particlesRef = useRef<(RainDrop | Snowflake | Leaf)[]>([]); + const particlesRef = useRef<(RainDrop | Snowflake | Leaf | Lightning)[]>([]); const timeRef = useRef(0); const isEmittingRef = useRef(trigger === "mount"); const emitStartTimeRef = useRef(Date.now()); const isHoveredRef = useRef(false); + const lastLightningTimeRef = useRef(0); + const lastBoltCountRef = useRef(0); useEffect(() => { if (forwardedRef) { @@ -230,8 +252,164 @@ const WeatherFx = React.forwardRef( return particles; }; + // Recursive function to generate chaotic, organic branches + const generateBranch = ( + startX: number, + startY: number, + baseSegmentLength: number, + branchLevel: number, + maxLevel: number, + angle: number, + parentThickness: number + ): { segments: { x: number; y: number }[]; children: LightningBranch[] } => { + const segments: { x: number; y: number }[] = []; + const children: LightningBranch[] = []; + + // Longer branches that decrease more gradually with depth + const lengthMultiplier = Math.pow(0.7, branchLevel); + const branchLength = 3 + Math.floor(Math.random() * 4); // 3-6 segments + + let currentX = startX; + let currentY = startY; + let currentAngle = angle; + + for (let i = 0; i < branchLength; i++) { + // Larger segments for longer branches + const segmentLength = baseSegmentLength * lengthMultiplier * (0.6 + Math.random() * 0.6); + + // Add angular variation for organic chaos + currentAngle += (Math.random() - 0.5) * 0.8; + + // Move in the current angle direction + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + segments.push({ x: currentX, y: currentY }); + + // Moderate probability of branching + if (branchLevel < maxLevel && i > 0) { + // Only primary branches get multiple attempts + const branchAttempts = branchLevel === 1 ? 1 : 1; + + for (let attempt = 0; attempt < branchAttempts; attempt++) { + // Lower probability: 35% at level 1, 25% at level 2, etc. + const branchProbability = 0.35 - branchLevel * 0.1; + + if (Math.random() < branchProbability) { + // Random angle deviation from current direction (-90 to +90 degrees) + const angleDeviation = (Math.random() - 0.5) * Math.PI; + const childAngle = currentAngle + angleDeviation; + + const childBranch = generateBranch( + currentX, + currentY, + baseSegmentLength, + branchLevel + 1, + maxLevel, + childAngle, + parentThickness + ); + + if (childBranch.segments.length > 0) { + children.push({ + startIndex: i, + segments: childBranch.segments, + thickness: parentThickness * Math.pow(0.6, branchLevel + 1), + children: childBranch.children, + }); + } + } + } + } + } + + return { segments, children }; + }; + + // Generate lightning bolt with chaotic branches + const generateLightningBolt = (startX: number, startY: number, endY: number): Lightning => { + const segments: { x: number; y: number }[] = []; + const branches: LightningBranch[] = []; + + let currentX = startX; + let currentY = startY; + const baseSegmentLength = 12 + Math.random() * 15; // Smaller segments for detail + const mainThickness = 2.5 + Math.random() * 1.5; + + segments.push({ x: currentX, y: currentY }); + + // Main bolt with more erratic path + let currentAngle = Math.PI / 2; // Start going down (90 degrees) + + while (currentY < endY) { + // Vary segment length significantly + const segmentLength = baseSegmentLength * (0.5 + Math.random() * 0.8); + + // Add significant angular variation for jagged appearance + currentAngle += (Math.random() - 0.5) * 0.6; + + // Ensure we're generally moving downward + if (currentAngle < Math.PI / 4) currentAngle = Math.PI / 4; // Min 45 degrees + if (currentAngle > (3 * Math.PI) / 4) currentAngle = (3 * Math.PI) / 4; // Max 135 degrees + + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + segments.push({ x: currentX, y: currentY }); + + // Moderate chance of branching (35%) for balanced appearance + if (Math.random() < 0.35 && segments.length > 2) { + // Random angle deviation (-120 to +120 degrees from current) + const angleDeviation = (Math.random() - 0.5) * (2 * Math.PI / 3); + const branchAngle = currentAngle + angleDeviation; + + const primaryBranch = generateBranch( + currentX, + currentY, + baseSegmentLength, + 1, + 3, // Max 3 levels to reduce chaos + branchAngle, + mainThickness + ); + + if (primaryBranch.segments.length > 0) { + branches.push({ + startIndex: segments.length - 1, + segments: primaryBranch.segments, + thickness: mainThickness * 0.6, + children: primaryBranch.children, + }); + } + } + } + + return { + x: startX, + y: startY, + segments, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.8 + Math.random() * 0.2, + thickness: mainThickness, + lifetime: 0.4 + Math.random() * 0.2, + age: 0, + branches, + revealDuration: 0.08 + Math.random() * 0.04, + }; + }; + + // Initialize lightning (empty array, lightning spawns dynamically) + const initializeLightning = () => { + return [] as Lightning[]; + }; + // Initialize particles - particlesRef.current = type === "rain" ? initializeRain() : type === "snow" ? initializeSnow() : type === "leaves" ? initializeLeaves() : []; + particlesRef.current = + type === "rain" ? initializeRain() : + type === "snow" ? initializeSnow() : + type === "leaves" ? initializeLeaves() : + type === "lightning" ? initializeLightning() : + []; // Animation loop const angleRad = (angle * Math.PI) / 180; // Convert angle to radians @@ -400,6 +578,152 @@ const WeatherFx = React.forwardRef( ctx.restore(); }); + } else if (type === "lightning") { + // Lightning spawning logic + const currentTime = Date.now(); + const timeSinceLastLightning = (currentTime - lastLightningTimeRef.current) / 1000; + + // Reduced frequency: intensity 1 = ~15s, intensity 10 = ~3s, intensity 50 = ~0.6s, intensity 100 = ~0.3s + const spawnInterval = Math.max(0.3, 15 / intensity); + + if (shouldEmit && timeSinceLastLightning > spawnInterval) { + // Calculate number of simultaneous bolts based on intensity + // After a multi-bolt strike, force a single bolt for variation + let boltCount = 1; + + if (lastBoltCountRef.current > 1) { + // Previous strike was multi-bolt, do single bolt this time + boltCount = 1; + } else { + // Previous was single, can do multi-bolt based on intensity + if (intensity > 60) { + // 60% chance of multi-bolt at high intensity + boltCount = Math.random() < 0.6 ? (2 + Math.floor(Math.random() * 3)) : 1; // 2-4 bolts or 1 + } else if (intensity > 30) { + // 50% chance of multi-bolt at medium intensity + boltCount = Math.random() < 0.5 ? (2 + Math.floor(Math.random() * 2)) : 1; // 2-3 bolts or 1 + } else if (intensity > 15) { + // 30% chance of double bolt at low-medium intensity + boltCount = Math.random() < 0.3 ? 2 : 1; + } + // intensity <= 15: always single bolt + } + + // Store bolt count for next iteration + lastBoltCountRef.current = boltCount; + + // Spawn multiple bolts with slight time offset for realism + for (let i = 0; i < boltCount; i++) { + const startX = Math.random() * canvasWidth; + const bolt = generateLightningBolt(startX, 0, canvasHeight); + // Add slight age offset for staggered appearance + bolt.age = -i * 0.025; // 25ms offset between bolts + (particlesRef.current as Lightning[]).push(bolt); + } + + lastLightningTimeRef.current = currentTime; + } + + // Update and draw lightning bolts + (particlesRef.current as Lightning[]).forEach((bolt, index) => { + bolt.age += 0.016; // Increment age + + // Skip if not yet started (negative age for staggered bolts) + if (bolt.age < 0) return; + + // Remove expired bolts + if (bolt.age > bolt.lifetime) { + (particlesRef.current as Lightning[]).splice(index, 1); + return; + } + + // Calculate reveal progress (how much of the bolt to draw) + const revealProgress = Math.min(1, bolt.age / bolt.revealDuration); + + // Calculate opacity fade (flash effect) + const lifeProgress = bolt.age / bolt.lifetime; + let currentOpacity = bolt.opacity; + + // During reveal: full brightness + // After reveal: hold briefly, then fade out + if (lifeProgress < bolt.revealDuration / bolt.lifetime) { + currentOpacity *= revealProgress; // Fade in during reveal + } else if (lifeProgress < 0.3) { + currentOpacity *= 1; // Hold at full brightness + } else { + // Fade out in the remaining time + const fadeProgress = (lifeProgress - 0.3) / 0.7; + currentOpacity *= (1 - fadeProgress); + } + + // Calculate how many segments to draw based on reveal progress + const totalSegments = bolt.segments.length; + const segmentsToDraw = Math.ceil(totalSegments * revealProgress); + + // Draw main bolt with reveal effect + ctx.globalAlpha = currentOpacity; + ctx.strokeStyle = bolt.color; + ctx.lineWidth = bolt.thickness; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // Draw glow effect + ctx.shadowBlur = 15; + ctx.shadowColor = bolt.color; + + ctx.beginPath(); + for (let i = 0; i < segmentsToDraw; i++) { + const segment = bolt.segments[i]; + if (i === 0) { + ctx.moveTo(segment.x, segment.y); + } else { + ctx.lineTo(segment.x, segment.y); + } + } + ctx.stroke(); + + // Recursive function to draw hierarchical branches + const drawBranch = ( + branch: LightningBranch, + parentSegments: { x: number; y: number }[], + parentSegmentsToDraw: number + ) => { + // Only draw if parent bolt has reached this branch point + if (branch.startIndex < parentSegmentsToDraw) { + const startSegment = parentSegments[branch.startIndex]; + + // Calculate how much of this branch to reveal + const segmentsPastBranch = parentSegmentsToDraw - branch.startIndex; + const branchRevealProgress = Math.min(1, segmentsPastBranch / 3); + const branchSegmentsToDraw = Math.ceil(branch.segments.length * branchRevealProgress); + + // Draw this branch + ctx.beginPath(); + ctx.moveTo(startSegment.x, startSegment.y); + for (let i = 0; i < branchSegmentsToDraw; i++) { + const segment = branch.segments[i]; + ctx.lineTo(segment.x, segment.y); + } + ctx.lineWidth = branch.thickness; + ctx.stroke(); + + // Recursively draw child branches + if (branch.children && branch.children.length > 0) { + branch.children.forEach((childBranch) => { + drawBranch(childBranch, branch.segments, branchSegmentsToDraw); + }); + } + } + }; + + // Draw all primary branches and their hierarchies + bolt.branches.forEach((branch) => { + drawBranch(branch, bolt.segments, segmentsToDraw); + }); + + // Reset shadow + ctx.shadowBlur = 0; + }); } ctx.globalAlpha = 1; From f1aa73bc7a83dfb7019c265163a8f36471c6e2f7 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sat, 15 Nov 2025 22:05:14 +0100 Subject: [PATCH 03/16] feat: RadialGauge mvp --- apps/docs/src/content/once-ui/data/meta.json | 3 +- .../src/content/once-ui/data/radialGauge.mdx | 306 ++++++++++++++++++ apps/docs/src/product/RadialGaugeExample.tsx | 59 ++++ apps/docs/src/product/index.ts | 3 +- .../src/modules/data/RadialGauge.module.css | 21 ++ .../core/src/modules/data/RadialGauge.tsx | 155 +++++++++ packages/core/src/modules/data/index.ts | 1 + packages/core/src/modules/index.ts | 1 + 8 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 apps/docs/src/content/once-ui/data/radialGauge.mdx create mode 100644 apps/docs/src/product/RadialGaugeExample.tsx create mode 100644 packages/core/src/modules/data/RadialGauge.module.css create mode 100644 packages/core/src/modules/data/RadialGauge.tsx diff --git a/apps/docs/src/content/once-ui/data/meta.json b/apps/docs/src/content/once-ui/data/meta.json index 0637bbf..3bf69aa 100644 --- a/apps/docs/src/content/once-ui/data/meta.json +++ b/apps/docs/src/content/once-ui/data/meta.json @@ -7,6 +7,7 @@ "barChart": 3, "groupedBarChart": 4, "lineBarChart": 5, - "pieChart": 6 + "pieChart": 6, + "radialGauge": 7 } } \ No newline at end of file diff --git a/apps/docs/src/content/once-ui/data/radialGauge.mdx b/apps/docs/src/content/once-ui/data/radialGauge.mdx new file mode 100644 index 0000000..ba5f299 --- /dev/null +++ b/apps/docs/src/content/once-ui/data/radialGauge.mdx @@ -0,0 +1,306 @@ +--- +title: "RadialGauge" +summary: "A radial, tick-based gauge for visualizing health metrics and real-time values using circular or arc-based layouts." +updatedAt: "2025-11-15" +docs: "once-ui/data/radialGauge.mdx" +github: "modules/data/RadialGauge.tsx" +navLabel: "RadialGauge" +navIcon: "radialGauge" +--- + +The `RadialGauge` component renders a tick-based circular or arc-shaped gauge that is ideal for health metrics, KPIs, and other real-time values. It supports full circles, semicircles, and custom arcs with configurable angles, direction, density, and visual health states. + +## Usage + +### Basic usage + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +### Health status gauge + +Use the `health` prop to quickly change the color ramp of active ticks to represent different states. + + + + + + + } + codes={[ + { + code: +` + + + +`, + language: "tsx", + label: "States" + } + ]} +/> + +### Semicircular gauge + +You can create semicircular or arc gauges by combining `startAngle`, `sweepAngle`, and `edgePad`. The angle system is **intuitive**: + +- `0`° = left +- `90`° = top +- `180`° = right +- `270`° = bottom + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Angles" + } + ]} +/> + +### Fine-grained tick density + +Control how many ticks are rendered and how long they are using `lineCount`, `lineWidth`, and `lineLength`. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Density" + } + ]} +/> + +### Animated speed meter + +For interactive demos or dashboards, you can animate the `value` over time on the client. The example below simulates accelerating and braking. + + + } + codes={[ + { + code: +`"use client"; + +import React, { useEffect, useState } from "react"; +import { RadialGauge, Column, Text } from "@once-ui-system/core"; + +export function RadialGaugeSpeedDemo() { + const [value, setValue] = useState(20); + const [phase, setPhase] = useState<"idle" | "accelerate" | "brake">("idle"); + + useEffect(() => { + let timeout: ReturnType | undefined; + let interval: ReturnType | undefined; + + const startAccelerate = () => { + setPhase("accelerate"); + interval = setInterval(() => { + setValue(prev => { + const next = Math.min(prev + 6, 96); + if (next >= 96) { + clearInterval(interval); + timeout = setTimeout(startBrake, 600); + } + return next; + }); + }, 90); + }; + + const startBrake = () => { + setPhase("brake"); + interval = setInterval(() => { + setValue(prev => { + const next = Math.max(prev - 8, 18); + if (next <= 18) { + clearInterval(interval); + timeout = setTimeout(startAccelerate, 800); + } + return next; + }); + }, 90); + }; + + timeout = setTimeout(startAccelerate, 400); + + return () => { + if (interval) clearInterval(interval); + if (timeout) clearTimeout(timeout); + }; + }, []); + + const label = phase === "accelerate" ? "Accelerating" : phase === "brake" ? "Braking" : "Idle"; + + return ( + + + + {label} + + + ); +}`, + language: "tsx", + label: "Animated speed meter" + } + ]} +/> + +## Props + +The `RadialGauge` component accepts all layout props from `Column` (except `direction`) plus the following gauge-specific props: + + + +Use `RadialGauge` when you need a compact, highly legible representation of a single metric that can be scanned quickly, such as system health, SLO burn rate, or real-time utilization. diff --git a/apps/docs/src/product/RadialGaugeExample.tsx b/apps/docs/src/product/RadialGaugeExample.tsx new file mode 100644 index 0000000..e8c9883 --- /dev/null +++ b/apps/docs/src/product/RadialGaugeExample.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { RadialGauge } from "@once-ui-system/core"; + +// Animated speed meter demo for RadialGauge docs +export function RadialGaugeSpeedDemo() { + const [value, setValue] = useState(20); + const [phase, setPhase] = useState<"accelerate" | "brake">("accelerate"); + + useEffect(() => { + let cooldown = 0; + + const interval = window.setInterval(() => { + setValue((prev) => { + // Simple pause when switching phases + if (cooldown > 0) { + cooldown -= 1; + return prev; + } + + if (phase === "accelerate") { + const next = prev + 2; + if (next >= 96) { + setPhase("brake"); + cooldown = 8; + return 96; + } + return next; + } else { + const next = prev - 3; + if (next <= 18) { + setPhase("accelerate"); + cooldown = 8; + return 18; + } + return next; + } + }); + }, 40); + + return () => { + window.clearInterval(interval); + }; + }, [phase]); + + return ( + + ); +} diff --git a/apps/docs/src/product/index.ts b/apps/docs/src/product/index.ts index 2f32099..fc43330 100644 --- a/apps/docs/src/product/index.ts +++ b/apps/docs/src/product/index.ts @@ -27,4 +27,5 @@ export * from "./SwitchExample"; export * from "./TextareaExamples"; export * from "./ToastExample"; export * from "./TypeFxCustomExample"; -export * from "./Products"; \ No newline at end of file +export * from "./Products"; +export * from "./RadialGaugeExample"; \ No newline at end of file diff --git a/packages/core/src/modules/data/RadialGauge.module.css b/packages/core/src/modules/data/RadialGauge.module.css new file mode 100644 index 0000000..399206b --- /dev/null +++ b/packages/core/src/modules/data/RadialGauge.module.css @@ -0,0 +1,21 @@ +.svg { + display: block; +} + +.activeLine { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.inactiveLine { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Glow animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} \ No newline at end of file diff --git a/packages/core/src/modules/data/RadialGauge.tsx b/packages/core/src/modules/data/RadialGauge.tsx new file mode 100644 index 0000000..3290694 --- /dev/null +++ b/packages/core/src/modules/data/RadialGauge.tsx @@ -0,0 +1,155 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Column, CountFx, Text } from "../../"; +import styles from "./RadialGauge.module.css"; + +interface RadialGaugeProps extends Omit, 'direction'> { + width?: number; + height?: number; + lineCount?: number; + lineWidth?: number; + lineLength?: number; + unit?: React.ReactNode; + value?: number; + startAngle?: number; // 0=left, 90=top, 180=right, 270=bottom (intuitive coordinate system) + sweepAngle?: number; // arc span in degrees (e.g., 180 for semicircle, 240 for gauge) + direction?: 'cw' | 'ccw';// default 'cw' (arc rotation direction) + edgePad?: number; // default 0, number of ticks to trim at both ends + children?: React.ReactNode; + health?: "good" | "normal" | "bad"; +} + +export const RadialGauge = ({ + width = 300, + height = 300, + lineCount = 60, + lineWidth = 3, + lineLength = 40, + value = 7, + startAngle = 0, // 0=left; use 90 for top-centered arcs + sweepAngle = 360, + direction = 'cw', + edgePad = 0, + unit, + children, + health = "good", + ...flex +}: RadialGaugeProps) => { + const pad = 4; + + // For semicircles (sweepAngle ~180), use width or height as the diameter + // For full circles, use the smaller dimension + let radius: number; + let cx: number; + let cy: number; + + if (sweepAngle <= 180) { + // Semicircle: span the full width, center horizontally + radius = width / 2 - pad; + cx = width / 2; + cy = height; // bottom edge for top semicircle with startAngle=-90 + } else { + // Full or large arc: use smaller dimension + radius = Math.min(width, height) / 2 - pad; + cx = width / 2; + cy = height / 2; + } + + const ticks = Math.max(0, lineCount - edgePad * 2); + + // Animate active tick count so ticks light up one by one when the value changes + const [activeLines, setActiveLines] = useState(() => Math.floor((value / 100) * ticks)); + + useEffect(() => { + const target = Math.floor((value / 100) * ticks); + + if (target === activeLines) return; + + let current = activeLines; + const step = target > current ? 1 : -1; + const interval = window.setInterval(() => { + current += step; + setActiveLines(current); + + if (current === target) { + window.clearInterval(interval); + } + }, 20); // small delay for a smooth, sequential tick animation + + return () => { + window.clearInterval(interval); + }; + }, [value, ticks, activeLines]); + const dir = direction === 'cw' ? 1 : -1; + + // Transform user angles to intuitive system: 0°=left, 90°=top, 180°=right + // SVG rotation: 0°=up, 90°=right, so user's angle - 90 maps correctly + const internalStartAngle = startAngle - 90; + + const renderLines = () => { + const lines = []; + for (let j = 0; j < ticks; j++) { + // map j∈[0,ticks-1] to angle ∈ [startAngle, startAngle + sweepAngle] + const t = ticks > 1 ? j / (ticks - 1) : 0; + const angle = internalStartAngle + dir * (t * sweepAngle); + const isActive = j < activeLines; + + const gradientPosition = t; // 0..1 across the arc + + // Determine hue ramp based on health + const [startHue, endHue] = + health === 'bad' + ? [0, 30] // red -> orange + : health === 'normal' + ? [30, 60] // orange -> yellow + : [200, 120]; // blue -> green (good) + + const hue = startHue + (endHue - startHue) * gradientPosition; + + lines.push( + + ); + } + return lines; + }; + + return ( + + + + + + + + + {renderLines()} + + + + {children || ( + + {unit} + + )} + + + ); +}; \ No newline at end of file diff --git a/packages/core/src/modules/data/index.ts b/packages/core/src/modules/data/index.ts index 47172fd..a24bbc3 100644 --- a/packages/core/src/modules/data/index.ts +++ b/packages/core/src/modules/data/index.ts @@ -4,6 +4,7 @@ export * from "./LineChart"; export * from "./BarChart"; export * from "./PieChart"; export * from "./LineBarChart"; +export * from "./RadialGauge"; export * from "./ChartHeader"; export * from "./ChartStatus"; diff --git a/packages/core/src/modules/index.ts b/packages/core/src/modules/index.ts index 0c46494..a9956d3 100644 --- a/packages/core/src/modules/index.ts +++ b/packages/core/src/modules/index.ts @@ -20,3 +20,4 @@ export { PieChart } from "./data/PieChart"; export { LineBarChart } from "./data/LineBarChart"; export { DataTooltip } from "./data/DataTooltip"; export { Legend } from "./data/Legend"; +export { RadialGauge } from "./data/RadialGauge"; From 4dcddc7b0babcf77e7f38c5d183661ba71f7fd3b Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 15:21:59 +0100 Subject: [PATCH 04/16] gauge: update components and docs --- .../src/content/once-ui/data/linearGauge.mdx | 307 ++++++++++++++++++ .../src/content/once-ui/data/radialGauge.mdx | 259 +++++++++------ apps/docs/src/product/RadialGaugeExample.tsx | 59 ++-- packages/core/src/icons.ts | 3 + ...adialGauge.module.css => Gauge.module.css} | 8 +- .../core/src/modules/data/LinearGauge.tsx | 149 +++++++++ .../core/src/modules/data/RadialGauge.tsx | 80 +++-- packages/core/src/modules/data/index.ts | 1 + packages/core/src/modules/index.ts | 20 +- 9 files changed, 697 insertions(+), 189 deletions(-) create mode 100644 apps/docs/src/content/once-ui/data/linearGauge.mdx rename packages/core/src/modules/data/{RadialGauge.module.css => Gauge.module.css} (75%) create mode 100644 packages/core/src/modules/data/LinearGauge.tsx diff --git a/apps/docs/src/content/once-ui/data/linearGauge.mdx b/apps/docs/src/content/once-ui/data/linearGauge.mdx new file mode 100644 index 0000000..7f0f1a2 --- /dev/null +++ b/apps/docs/src/content/once-ui/data/linearGauge.mdx @@ -0,0 +1,307 @@ +--- +title: "LinearGauge" +summary: "A horizontal, tick-based gauge for visualizing progress, metrics, and health with flexible label configurations." +updatedAt: "2025-11-16" +docs: "once-ui/data/linearGauge.mdx" +github: "modules/data/LinearGauge.tsx" +navLabel: "LinearGauge" +navIcon: "linearGauge" +navTag: "New" +navTagVariant: "cyan" +--- + +The `LinearGauge` component renders a horizontal tick-based gauge ideal for progress indicators, metrics dashboards, and system health displays. It supports flexible label configurations including none, percentage, or custom values. + +## Usage + +### Basic usage + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +### Percentage labels + +Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%). + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Percentage" + } + ]} +/> + +### Custom labels + +Provide an array of custom labels distributed evenly across the gauge. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Custom" + } + ]} +/> + +### Reverse order + +Flip the order of labels and gauge by using `direction="column-reverse"`. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Reverse" + } + ]} +/> + +### Health status + +Use the `hue` prop to represent different states with color gradients. + + + + + + + } + codes={[ + { + code: +` + + + +`, + language: "tsx", + label: "States" + } + ]} +/> + +### Custom hue gradient + +Provide a custom hue range as a tuple `[startHue, endHue]` for fine-grained color control. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Custom hue" + } + ]} +/> + +### Tick density + +Customize tick count, width, and length for different visual densities. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Density" + } + ]} +/> + +## Props + +The `LinearGauge` component accepts all layout props from `Flex` (except `direction`) plus the following gauge-specific props: + + + +Use `LinearGauge` for horizontal metrics displays, progress tracking, or any scenario where a linear representation is more appropriate than a radial one. diff --git a/apps/docs/src/content/once-ui/data/radialGauge.mdx b/apps/docs/src/content/once-ui/data/radialGauge.mdx index ba5f299..3fa015f 100644 --- a/apps/docs/src/content/once-ui/data/radialGauge.mdx +++ b/apps/docs/src/content/once-ui/data/radialGauge.mdx @@ -6,26 +6,31 @@ docs: "once-ui/data/radialGauge.mdx" github: "modules/data/RadialGauge.tsx" navLabel: "RadialGauge" navIcon: "radialGauge" +navTag: "New" +navTagVariant: "cyan" --- The `RadialGauge` component renders a tick-based circular or arc-shaped gauge that is ideal for health metrics, KPIs, and other real-time values. It supports full circles, semicircles, and custom arcs with configurable angles, direction, density, and visual health states. -## Usage - -### Basic usage - + + + } codes={[ { @@ -42,75 +47,96 @@ The `RadialGauge` component renders a tick-based circular or arc-shaped gauge th ]} /> -### Health status gauge +## Hue -Use the `health` prop to quickly change the color ramp of active ticks to represent different states. +Use the `hue` prop to quickly change the color ramp of active ticks to represent different states. - - - - + + + + + + + + + + + + + + } codes={[ { code: -` - - + - + -`, + hue="danger" +/> +`, language: "tsx", - label: "States" + label: "Hue" } ]} /> -### Semicircular gauge +## Angles -You can create semicircular or arc gauges by combining `startAngle`, `sweepAngle`, and `edgePad`. The angle system is **intuitive**: +You can create semicircular or arc gauges by combining `angle` and `edgePad`. The angle system is **intuitive**: - `0`° = left - `90`° = top @@ -122,37 +148,56 @@ You can create semicircular or arc gauges by combining `startAngle`, `sweepAngle marginBottom="24" previewPadding="none" fullscreenButton={true} - highlight="6-8" + highlight="9-12" preview={ - + + + + 68% + + + } codes={[ { code: ``, + width={300} + height={150} + value={68} + line={{ + count: 24, + }} + angle={{ + start: 0, + sweep: 180, + }} +> + + 68% + +`, language: "tsx", label: "Angles" } ]} /> -### Fine-grained tick density +## Line tick Control how many ticks are rendered and how long they are using `lineCount`, `lineWidth`, and `lineLength`. @@ -163,27 +208,33 @@ Control how many ticks are rendered and how long they are using `lineCount`, `li fullscreenButton={true} highlight="5-7" preview={ - + + + } codes={[ { code: ``, language: "tsx", label: "Density" @@ -191,7 +242,7 @@ Control how many ticks are rendered and how long they are using `lineCount`, `li ]} /> -### Animated speed meter +## Animation For interactive demos or dashboards, you can animate the `value` over time on the client. The example below simulates accelerating and braking. @@ -199,9 +250,12 @@ For interactive demos or dashboards, you can animate the `value` over time on th marginTop="16" marginBottom="24" previewPadding="none" + codeHeight={24} fullscreenButton={true} preview={ - + + + } codes={[ { @@ -264,8 +318,10 @@ export function RadialGaugeSpeedDemo() { height={260} value={value} unit="km/h" - startAngle={-120} - sweepAngle={240} + angle={{ + start: -120, + sweep: 240, + }} edgePad={4} /> @@ -288,17 +344,14 @@ The `RadialGauge` component accepts all layout props from `Column` (except `dire content={[ ["width", "number", "300"], ["height", "number", "300"], - ["lineCount", "number", "60"], - ["lineWidth", "number", "3"], - ["lineLength", "number", "40"], - ["value", "number (0–100)", "7"], + ["line", "object", "{{ count: 60, width: 3, length: 40 }}"], + ["value", "0–100", "7"], ["unit", "ReactNode"], - ["startAngle", "number", "0"], - ["sweepAngle", "number", "360"], - ["direction", "\"cw\" | \"ccw\"", "\"cw\""], + ["angle", "object", "{{ start: 0, sweep: 360 }}"], + ["direction", ["cw", "ccw"], "cw"], ["edgePad", "number", "0"], - ["health", "\"good\" | \"normal\" | \"bad\"", "\"good\""], - ["children", "ReactNode"], + ["hue", ["success", "neutral", "danger"], "success"], + ["children"], ["...flex"], ]} /> diff --git a/apps/docs/src/product/RadialGaugeExample.tsx b/apps/docs/src/product/RadialGaugeExample.tsx index e8c9883..d0410d7 100644 --- a/apps/docs/src/product/RadialGaugeExample.tsx +++ b/apps/docs/src/product/RadialGaugeExample.tsx @@ -5,55 +5,40 @@ import { RadialGauge } from "@once-ui-system/core"; // Animated speed meter demo for RadialGauge docs export function RadialGaugeSpeedDemo() { - const [value, setValue] = useState(20); - const [phase, setPhase] = useState<"accelerate" | "brake">("accelerate"); + const [value, setValue] = useState(4); useEffect(() => { - let cooldown = 0; - - const interval = window.setInterval(() => { - setValue((prev) => { - // Simple pause when switching phases - if (cooldown > 0) { - cooldown -= 1; - return prev; - } - - if (phase === "accelerate") { - const next = prev + 2; - if (next >= 96) { - setPhase("brake"); - cooldown = 8; - return 96; - } - return next; - } else { - const next = prev - 3; - if (next <= 18) { - setPhase("accelerate"); - cooldown = 8; - return 18; - } - return next; - } - }); - }, 40); + const timeouts: NodeJS.Timeout[] = []; + + const cycle = () => { + timeouts.push(setTimeout(() => setValue(96), 500)); + timeouts.push(setTimeout(() => setValue(4), 2500)); + timeouts.push(setTimeout(cycle, 4500)); + }; + + cycle(); return () => { - window.clearInterval(interval); + timeouts.forEach(clearTimeout); }; - }, [phase]); + }, []); return ( ); } diff --git a/packages/core/src/icons.ts b/packages/core/src/icons.ts index 845fb6b..7956752 100644 --- a/packages/core/src/icons.ts +++ b/packages/core/src/icons.ts @@ -49,6 +49,7 @@ import { IoPawOutline, } from "react-icons/io5"; import { LuChevronsLeftRight, LuTextCursorInput } from "react-icons/lu"; +import { PiBatteryFull, PiGauge } from "react-icons/pi"; export const iconLibrary: Record = { chevronUp: HiChevronUp, @@ -97,6 +98,8 @@ export const iconLibrary: Record = { pause: HiOutlinePause, screen: HiOutlineComputerDesktop, document: HiOutlineBars3BottomLeft, + radialGauge: PiGauge, + linearGauge: PiBatteryFull }; export type IconLibrary = typeof iconLibrary; diff --git a/packages/core/src/modules/data/RadialGauge.module.css b/packages/core/src/modules/data/Gauge.module.css similarity index 75% rename from packages/core/src/modules/data/RadialGauge.module.css rename to packages/core/src/modules/data/Gauge.module.css index 399206b..9adc465 100644 --- a/packages/core/src/modules/data/RadialGauge.module.css +++ b/packages/core/src/modules/data/Gauge.module.css @@ -1,5 +1,6 @@ .svg { display: block; + overflow: visible; } .activeLine { @@ -10,6 +11,11 @@ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } +.label { + font-family: var(--font-code); + user-select: none; +} + /* Glow animation */ @keyframes pulse { 0%, 100% { @@ -18,4 +24,4 @@ 50% { opacity: 0.8; } -} \ No newline at end of file +} diff --git a/packages/core/src/modules/data/LinearGauge.tsx b/packages/core/src/modules/data/LinearGauge.tsx new file mode 100644 index 0000000..967d589 --- /dev/null +++ b/packages/core/src/modules/data/LinearGauge.tsx @@ -0,0 +1,149 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Column, Flex, Row, Text } from "../../"; +import styles from "./Gauge.module.css"; + +interface LinearGaugeProps extends React.ComponentProps { + width?: number; + height?: number; + line?: { + count?: number; + width?: number; + length?: number; + }; + value?: number; + labels?: "none" | "percentage" | string[]; + hue?: "success" | "neutral" | "danger" | [number, number]; + color?: string; +} + +const resolveHueRange = (hue: LinearGaugeProps["hue"]): [number, number] => { + if (hue && typeof hue !== "string") { + const [start = 200, end = 120] = hue; + return [start, end]; + } + + if (hue === "danger") return [0, 30]; + if (hue === "neutral") return [30, 60]; + if (hue === "success") return [200, 120]; + + return [200, 120]; +}; + +export const LinearGauge = ({ + width = 400, + height = 80, + line, + value = 50, + labels = "none", + hue, + color = "contrast", + ...flex +}: LinearGaugeProps) => { + const pad = 8; + + // Destructure line with individual defaults + const lineCount = line?.count ?? 48; + const lineWidth = line?.width ?? 3; + const lineLength = line?.length ?? 40; + + // Animate active tick count + const [activeLines, setActiveLines] = useState(() => Math.floor((value / 100) * lineCount)); + + useEffect(() => { + const target = Math.floor((value / 100) * lineCount); + + if (target === activeLines) return; + + let current = activeLines; + const step = target > current ? 1 : -1; + const interval = window.setInterval(() => { + current += step; + setActiveLines(current); + + if (current === target) { + window.clearInterval(interval); + } + }, 20); + + return () => { + window.clearInterval(interval); + }; + }, [value, lineCount, activeLines]); + + const hasHue = hue !== undefined; + const [startHue, endHue] = resolveHueRange(hue); + + const renderLines = () => { + const lines = []; + const spacing = (width - pad * 2) / (lineCount - 1); + + for (let j = 0; j < lineCount; j++) { + const x = pad + j * spacing; + const isActive = j < activeLines; + const gradientPosition = lineCount > 1 ? j / (lineCount - 1) : 0; + const finalHue = startHue + (endHue - startHue) * gradientPosition; + + lines.push( + + ); + } + return lines; + }; + + const renderLabels = () => { + if (labels === "none") return null; + + let labelValues: (string | number)[] = []; + + if (labels === "percentage") { + labelValues = [0, 25, 50, 75, 100].map(p => `${p}%`); + } else if (Array.isArray(labels)) { + labelValues = labels; + } + + return ( + + {labelValues.map((label, i) => ( + + {label} + + ))} + + ); + }; + + return ( + + {renderLabels()} + + + {renderLines()} + + + + ); +}; diff --git a/packages/core/src/modules/data/RadialGauge.tsx b/packages/core/src/modules/data/RadialGauge.tsx index 3290694..2be4c45 100644 --- a/packages/core/src/modules/data/RadialGauge.tsx +++ b/packages/core/src/modules/data/RadialGauge.tsx @@ -2,49 +2,73 @@ import React, { useEffect, useState } from "react"; import { Column, CountFx, Text } from "../../"; -import styles from "./RadialGauge.module.css"; +import styles from "./Gauge.module.css"; interface RadialGaugeProps extends Omit, 'direction'> { width?: number; height?: number; - lineCount?: number; - lineWidth?: number; - lineLength?: number; + line?: { + count?: number; + width?: number; + length?: number; + }; unit?: React.ReactNode; value?: number; - startAngle?: number; // 0=left, 90=top, 180=right, 270=bottom (intuitive coordinate system) - sweepAngle?: number; // arc span in degrees (e.g., 180 for semicircle, 240 for gauge) + angle?: { + start: number; + sweep: number; + }; direction?: 'cw' | 'ccw';// default 'cw' (arc rotation direction) edgePad?: number; // default 0, number of ticks to trim at both ends children?: React.ReactNode; - health?: "good" | "normal" | "bad"; + hue?: "success" | "neutral" | "danger" | [number, number]; + color?: string; } +const resolveHueRange = (hue: RadialGaugeProps["hue"]): [number, number] => { + if (hue && typeof hue !== "string") { + const [start = 200, end = 120] = hue; + return [start, end]; + } + + if (hue === "danger") return [0, 30]; + if (hue === "neutral") return [30, 60]; + if (hue === "success") return [200, 120]; + + return [200, 120]; +}; + export const RadialGauge = ({ width = 300, height = 300, - lineCount = 60, - lineWidth = 3, - lineLength = 40, + line, value = 7, - startAngle = 0, // 0=left; use 90 for top-centered arcs - sweepAngle = 360, + angle = { + start: 0, + sweep: 360, + }, direction = 'cw', edgePad = 0, unit, children, - health = "good", + hue, + color = "contrast", ...flex }: RadialGaugeProps) => { const pad = 4; + // Destructure line with individual defaults + const lineCount = line?.count ?? 48; + const lineWidth = line?.width ?? 3; + const lineLength = line?.length ?? 40; + // For semicircles (sweepAngle ~180), use width or height as the diameter // For full circles, use the smaller dimension let radius: number; let cx: number; let cy: number; - if (sweepAngle <= 180) { + if (angle.sweep <= 180) { // Semicircle: span the full width, center horizontally radius = width / 2 - pad; cx = width / 2; @@ -85,45 +109,39 @@ export const RadialGauge = ({ // Transform user angles to intuitive system: 0°=left, 90°=top, 180°=right // SVG rotation: 0°=up, 90°=right, so user's angle - 90 maps correctly - const internalStartAngle = startAngle - 90; + const internalStartAngle = angle.start - 90; + const hasHue = hue !== undefined; + const [startHue, endHue] = resolveHueRange(hue); const renderLines = () => { const lines = []; for (let j = 0; j < ticks; j++) { // map j∈[0,ticks-1] to angle ∈ [startAngle, startAngle + sweepAngle] const t = ticks > 1 ? j / (ticks - 1) : 0; - const angle = internalStartAngle + dir * (t * sweepAngle); + const finalAngle = internalStartAngle + dir * (t * angle.sweep); const isActive = j < activeLines; const gradientPosition = t; // 0..1 across the arc - // Determine hue ramp based on health - const [startHue, endHue] = - health === 'bad' - ? [0, 30] // red -> orange - : health === 'normal' - ? [30, 60] // orange -> yellow - : [200, 120]; // blue -> green (good) - - const hue = startHue + (endHue - startHue) * gradientPosition; + const finalHue = startHue + (endHue - startHue) * gradientPosition; lines.push( ); diff --git a/packages/core/src/modules/data/index.ts b/packages/core/src/modules/data/index.ts index a24bbc3..0f47e4b 100644 --- a/packages/core/src/modules/data/index.ts +++ b/packages/core/src/modules/data/index.ts @@ -5,6 +5,7 @@ export * from "./BarChart"; export * from "./PieChart"; export * from "./LineBarChart"; export * from "./RadialGauge"; +export * from "./LinearGauge"; export * from "./ChartHeader"; export * from "./ChartStatus"; diff --git a/packages/core/src/modules/index.ts b/packages/core/src/modules/index.ts index a9956d3..570388f 100644 --- a/packages/core/src/modules/index.ts +++ b/packages/core/src/modules/index.ts @@ -3,21 +3,7 @@ export { MediaUpload } from "./media/MediaUpload"; export { Meta } from "./seo/Meta"; export { Schema } from "./seo/Schema"; -export { HeadingNav } from "./navigation/HeadingNav"; -export { HeadingLink } from "./navigation/HeadingLink"; -export { MegaMenu } from "./navigation/MegaMenu"; -export { MobileMegaMenu } from "./navigation/MobileMegaMenu"; -export { Kbar } from "./navigation/Kbar"; +export { Kbar, MobileMegaMenu, MegaMenu, HeadingLink, HeadingNav } from "./navigation"; -export { ChartHeader } from "./data/ChartHeader"; -export { ChartStatus } from "./data/ChartStatus"; -export type { ChartProps, ChartVariant, ChartMode, DataPoint } from "./data/interfaces"; -export { LinearGradient } from "./data/Gradient"; -export { RadialGradient } from "./data/Gradient"; -export { BarChart } from "./data/BarChart"; -export { LineChart } from "./data/LineChart"; -export { PieChart } from "./data/PieChart"; -export { LineBarChart } from "./data/LineBarChart"; -export { DataTooltip } from "./data/DataTooltip"; -export { Legend } from "./data/Legend"; -export { RadialGauge } from "./data/RadialGauge"; +export { ChartHeader, ChartStatus, LinearGradient, RadialGradient, BarChart, LineChart, PieChart, LineBarChart, DataTooltip, Legend, RadialGauge, LinearGauge } from "./data"; +export type { ChartProps, ChartVariant, ChartMode, DataPoint } from "./data"; From 43ad6e1dcf200810b418fb3ce5469db5eb5aae41 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 15:22:13 +0100 Subject: [PATCH 05/16] ui: move tooltip on CodeBlock action to bottom --- packages/core/src/modules/code/CodeBlock.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/modules/code/CodeBlock.tsx b/packages/core/src/modules/code/CodeBlock.tsx index ca2944c..cd9234e 100644 --- a/packages/core/src/modules/code/CodeBlock.tsx +++ b/packages/core/src/modules/code/CodeBlock.tsx @@ -660,7 +660,7 @@ const CodeBlock: React.FC = ({ = ({ = ({ Date: Sun, 16 Nov 2025 15:22:30 +0100 Subject: [PATCH 06/16] feat: add sharp style to StylePanel --- packages/core/src/components/StylePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/components/StylePanel.tsx b/packages/core/src/components/StylePanel.tsx index 71249d9..59f53be 100644 --- a/packages/core/src/components/StylePanel.tsx +++ b/packages/core/src/components/StylePanel.tsx @@ -23,7 +23,7 @@ interface StylePanelProps extends React.ComponentProps { className?: string; } -const shapes = ["conservative", "playful", "rounded"]; +const shapes = ["sharp", "conservative", "playful", "rounded"]; const colorOptions = { brand: [...schemes], From 682d232b31db362aa50a23aa1111395535706bb7 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 15:22:49 +0100 Subject: [PATCH 07/16] feat: CelebrationFx component and docs --- .../content/once-ui/effects/celebrationFx.mdx | 390 ++++++++++++++++ .../src/content/once-ui/effects/meta.json | 1 + .../docs/src/product/CelebrationFxExample.tsx | 31 ++ apps/docs/src/product/index.ts | 3 +- .../core/src/components/CelebrationFx.tsx | 422 ++++++++++++++++++ packages/core/src/components/index.ts | 1 + 6 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/content/once-ui/effects/celebrationFx.mdx create mode 100644 apps/docs/src/product/CelebrationFxExample.tsx create mode 100644 packages/core/src/components/CelebrationFx.tsx diff --git a/apps/docs/src/content/once-ui/effects/celebrationFx.mdx b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx new file mode 100644 index 0000000..ba77cd3 --- /dev/null +++ b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx @@ -0,0 +1,390 @@ +--- +title: "CelebrationFx" +summary: "The CelebrationFx component creates animated celebration effects like confetti and fireworks using native Canvas 2D API." +updatedAt: "2025-11-16" +docs: "once-ui/effects/celebrationFx.mdx" +github: "components/CelebrationFx.tsx" +navLabel: "CelebrationFx" +navIcon: "sparkle" +navTag: "New" +navTagVariant: "cyan" +--- + +The `CelebrationFx` component creates animated celebration effects using the native Canvas 2D API. Choose between confetti or fireworks animations with multiple trigger modes, customizable colors, and intensity controls. Perfect for success states, achievements, milestones, or interactive celebrations. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +## Celebration types + +Choose between `confetti` (falling particles) or `fireworks` (explosive bursts). + + + + + + } + codes={[ + { + code: +` +`, + language: "tsx", + label: "Types" + } + ]} +/> + +## Trigger + +Control when the celebration starts with `mount` (automatic), `hover` (on mouse enter), `click` (fires from click position), or `manual` (controlled via `active` prop). + + + + Hover me! + + + } + codes={[ + { + code: +` + + Hover me! + +`, + language: "tsx", + label: "Hover" + } + ]} +/> + +Use `trigger="click"` to fire celebrations from exactly where users click. Perfect for interactive celebrations! + + + + + Click for fireworks! + + + + + Click for confetti! + + + + } + codes={[ + { + code: +` + + Click for fireworks! + + + + + Click for confetti! + +`, + language: "tsx", + label: "Click" + } + ]} +/> + +## Color + +Customize celebration colors using design tokens or any valid CSS color. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Colors" + } + ]} +/> + +### Intensity & speed + +Control particle density with `intensity` and animation speed with `speed`. + + + + + + } + codes={[ + { + code: +` +`, + language: "tsx", + label: "Intensity" + } + ]} +/> + +Control the celebration programmatically using `trigger="manual"` and the `active` prop. + + + } + codes={[ + { + code: +`"use client"; + +import React, { useState } from "react"; +import { CelebrationFx, Button, Column } from "@once-ui-system/core"; + +export function demo() { + const [active, setActive] = useState(false); + + return ( + + + + + ); +}`, + language: "tsx", + label: "Manual" + } + ]} +/> + +### Custom content + +Use `CelebrationFx` as a wrapper to overlay celebrations behind any content. + + + + + Achievement Unlocked! + + You've completed all challenges + + + + } + codes={[ + { + code: +` + + + Achievement Unlocked! + + You've completed all challenges + + +`, + language: "tsx", + label: "Overlay" + } + ]} +/> + +## Props + +The `CelebrationFx` component extends all layout props from `Flex` plus the following celebration-specific props: + + + +Use `CelebrationFx` to add delightful, performant celebration animations to success states, milestones, achievements, or any moment worth celebrating in your application. diff --git a/apps/docs/src/content/once-ui/effects/meta.json b/apps/docs/src/content/once-ui/effects/meta.json index 73f7a9f..14d0e79 100644 --- a/apps/docs/src/content/once-ui/effects/meta.json +++ b/apps/docs/src/content/once-ui/effects/meta.json @@ -2,6 +2,7 @@ "title": "Effects", "order": 7, "pages": { + "celebrationFx": 0, "matrixFx": 1, "weatherFx": 2, "countdownFx": 3, diff --git a/apps/docs/src/product/CelebrationFxExample.tsx b/apps/docs/src/product/CelebrationFxExample.tsx new file mode 100644 index 0000000..2ebcfda --- /dev/null +++ b/apps/docs/src/product/CelebrationFxExample.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React, { useState } from "react"; +import { CelebrationFx, Button, Column } from "@once-ui-system/core"; + +export function CelebrationFxExample() { + const [active, setActive] = useState(false); + + return ( + + + + + ); +} diff --git a/apps/docs/src/product/index.ts b/apps/docs/src/product/index.ts index fc43330..2ef1d37 100644 --- a/apps/docs/src/product/index.ts +++ b/apps/docs/src/product/index.ts @@ -28,4 +28,5 @@ export * from "./TextareaExamples"; export * from "./ToastExample"; export * from "./TypeFxCustomExample"; export * from "./Products"; -export * from "./RadialGaugeExample"; \ No newline at end of file +export * from "./RadialGaugeExample"; +export * from "./CelebrationFxExample"; diff --git a/packages/core/src/components/CelebrationFx.tsx b/packages/core/src/components/CelebrationFx.tsx new file mode 100644 index 0000000..87564c0 --- /dev/null +++ b/packages/core/src/components/CelebrationFx.tsx @@ -0,0 +1,422 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { Flex } from "."; + +type CelebrationType = "confetti" | "fireworks"; + +interface CelebrationFxProps extends React.ComponentProps { + type?: CelebrationType; + speed?: number; + colors?: string[]; + intensity?: number; + duration?: number; + trigger?: "mount" | "hover" | "manual" | "click"; + active?: boolean; // For manual trigger + children?: React.ReactNode; +} + +interface ConfettiPiece { + x: number; + y: number; + width: number; + height: number; + color: string; + rotation: number; + rotationSpeed: number; + velocityX: number; + velocityY: number; + gravity: number; + opacity: number; + shape: "rectangle" | "circle" | "triangle"; +} + +interface Firework { + x: number; + y: number; + targetY: number; + velocityY: number; + color: string; + exploded: boolean; + particles: FireworkParticle[]; +} + +interface FireworkParticle { + x: number; + y: number; + velocityX: number; + velocityY: number; + color: string; + opacity: number; + size: number; + life: number; + maxLife: number; +} + +const CelebrationFx = React.forwardRef( + ( + { + type = "confetti", + speed = 1, + colors = ["brand-solid-medium", "accent-solid-medium"], + intensity = 50, + duration, + trigger = "mount", + active = true, + children, + ...rest + }, + forwardedRef, + ) => { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const animationRef = useRef(undefined); + const particlesRef = useRef<(ConfettiPiece | Firework)[]>([]); + const isEmittingRef = useRef(trigger === "mount"); + const emitStartTimeRef = useRef(Date.now()); + const isHoveredRef = useRef(false); + const fireworkTimerRef = useRef(0); + const clickPositionRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (forwardedRef) { + if ("current" in forwardedRef) { + forwardedRef.current = containerRef.current; + } else if (typeof forwardedRef === "function") { + forwardedRef(containerRef.current); + } + } + }, [forwardedRef]); + + // Handle manual trigger + useEffect(() => { + if (trigger === "manual") { + if (active) { + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else { + isEmittingRef.current = false; + } + } + }, [trigger, active]); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Set canvas size + let canvasWidth = 0; + let canvasHeight = 0; + + const updateSize = () => { + const rect = container.getBoundingClientRect(); + canvasWidth = rect.width; + canvasHeight = rect.height; + canvas.width = rect.width * 2; // 2x for retina + canvas.height = rect.height * 2; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.scale(2, 2); // Scale for retina + }; + + updateSize(); + window.addEventListener("resize", updateSize); + + // Parse colors - convert token names to CSS variables + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + // Initialize confetti + const initializeConfetti = () => { + const particles: ConfettiPiece[] = []; + const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"]; + + for (let i = 0; i < intensity; i++) { + // Stagger particles more dramatically over a larger vertical range + const stagger = (i / intensity) * canvasHeight * 2; + particles.push({ + x: Math.random() * canvasWidth, + y: -canvasHeight - Math.random() * canvasHeight - stagger, + width: 8 + Math.random() * 8, + height: 6 + Math.random() * 6, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.2, + velocityX: (Math.random() - 0.5) * 3, + velocityY: (0.5 + Math.random() * 2.5) * speed, + gravity: (0.12 + Math.random() * 0.06) * speed, + opacity: 0.8 + Math.random() * 0.2, + shape: shapes[Math.floor(Math.random() * shapes.length)], + }); + } + + return particles; + }; + + // Create a new firework (explodes immediately at random or click position) + const createFirework = (clickPos?: { x: number; y: number }) => { + const firework: Firework = { + x: clickPos ? clickPos.x : Math.random() * canvasWidth, + y: clickPos ? clickPos.y : canvasHeight * (0.2 + Math.random() * 0.4), + targetY: 0, // Not used anymore + velocityY: 0, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + exploded: true, // Start already exploded + particles: [], + }; + // Explode immediately + explodeFirework(firework); + return firework; + }; + + // Explode firework into particles + const explodeFirework = (firework: Firework) => { + const particleCount = 30 + Math.floor(Math.random() * 20); + for (let i = 0; i < particleCount; i++) { + const angle = (Math.PI * 2 * i) / particleCount; + const velocity = 1 + Math.random() * 3; + firework.particles.push({ + x: firework.x, + y: firework.y, + velocityX: Math.cos(angle) * velocity * speed, + velocityY: Math.sin(angle) * velocity * speed, + color: firework.color, + opacity: 1, + size: 1 + Math.random(), + life: 0, + maxLife: 60 + Math.random() * 40, + }); + } + firework.exploded = true; + }; + + // Initialize particles only if not already initialized + if (particlesRef.current.length === 0 && type === "confetti") { + particlesRef.current = initializeConfetti(); + } + + // Animation loop + const animate = () => { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // Check if we should still be emitting new particles + const shouldEmit = + trigger === "hover" ? isHoveredRef.current : + trigger === "manual" ? active : + trigger === "click" ? clickPositionRef.current !== null : + isEmittingRef.current; + + // Check duration limit + if (duration && isEmittingRef.current && trigger !== "manual") { + const elapsed = (Date.now() - emitStartTimeRef.current) / 1000; + if (elapsed > duration) { + isEmittingRef.current = false; + } + } + + if (type === "confetti") { + // Update and draw confetti + (particlesRef.current as ConfettiPiece[]).forEach((piece) => { + // Update physics + piece.velocityY += piece.gravity; + piece.x += piece.velocityX; + piece.y += piece.velocityY; + piece.rotation += piece.rotationSpeed; + + // Add some air resistance + piece.velocityX *= 0.99; + + // Only respawn if we're still emitting + if (piece.y > canvasHeight + 50 && shouldEmit) { + piece.y = -20 - Math.random() * canvasHeight * 0.5; + piece.x = Math.random() * canvasWidth; + piece.velocityX = (Math.random() - 0.5) * 3; + piece.velocityY = (0.5 + Math.random() * 2.5) * speed; + piece.gravity = (0.12 + Math.random() * 0.06) * speed; + } + + // Only draw if still on screen + if (piece.y < canvasHeight + 100) { + // Draw confetti piece + ctx.save(); + ctx.translate(piece.x, piece.y); + ctx.rotate(piece.rotation); + ctx.globalAlpha = piece.opacity; + ctx.fillStyle = piece.color; + + if (piece.shape === "rectangle") { + ctx.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height); + } else if (piece.shape === "circle") { + ctx.beginPath(); + ctx.arc(0, 0, piece.width / 2, 0, Math.PI * 2); + ctx.fill(); + } else if (piece.shape === "triangle") { + ctx.beginPath(); + ctx.moveTo(0, -piece.height / 2); + ctx.lineTo(piece.width / 2, piece.height / 2); + ctx.lineTo(-piece.width / 2, piece.height / 2); + ctx.closePath(); + ctx.fill(); + } + + ctx.restore(); + } + }); + } else if (type === "fireworks") { + // Launch new fireworks + if (trigger === "click" && clickPositionRef.current) { + // Fire from click position + particlesRef.current.push(createFirework(clickPositionRef.current)); + clickPositionRef.current = null; + } else { + fireworkTimerRef.current++; + if (shouldEmit && fireworkTimerRef.current > 20 / speed) { + // Launch new firework every ~20 frames + fireworkTimerRef.current = 0; + particlesRef.current.push(createFirework()); + } + } + + // Update and draw fireworks (explosion particles only) + (particlesRef.current as Firework[]).forEach((firework) => { + // Update and draw explosion particles + firework.particles.forEach((particle) => { + particle.life++; + + if (particle.life < particle.maxLife) { + // Update position + particle.x += particle.velocityX; + particle.y += particle.velocityY; + particle.velocityY += 0.1; // Gravity + + // Fade out + particle.opacity = 1 - particle.life / particle.maxLife; + + // Draw particle + ctx.globalAlpha = particle.opacity; + ctx.fillStyle = particle.color; + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + ctx.fill(); + } + }); + }); + + // Clean up dead fireworks (only when not emitting) + if (!shouldEmit) { + particlesRef.current = (particlesRef.current as Firework[]).filter((firework) => { + // Keep if any particles are still alive + return firework.particles.some(p => p.life < p.maxLife); + }); + } + } + + ctx.globalAlpha = 1; + animationRef.current = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + window.removeEventListener("resize", updateSize); + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [type, colors, speed, intensity, duration, trigger]); + + const handleMouseEnter = () => { + if (trigger === "hover" && !isHoveredRef.current) { + isHoveredRef.current = true; + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } + }; + + const handleMouseLeave = () => { + if (trigger === "hover" && isHoveredRef.current) { + isHoveredRef.current = false; + } + }; + + const handleClick = (e: React.MouseEvent) => { + if (trigger === "click" && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (type === "confetti") { + // For confetti, add burst of particles from click position + const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"]; + const canvas = canvasRef.current; + if (!canvas) return; + + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(containerRef.current!).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + for (let i = 0; i < intensity; i++) { + const angle = (Math.PI * 2 * i) / intensity; + const velocity = 2 + Math.random() * 3; + (particlesRef.current as ConfettiPiece[]).push({ + x, + y, + width: 8 + Math.random() * 8, + height: 6 + Math.random() * 6, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.2, + velocityX: Math.cos(angle) * velocity, + velocityY: Math.sin(angle) * velocity - 2, // Slight upward bias + gravity: (0.12 + Math.random() * 0.06) * speed, + opacity: 0.8 + Math.random() * 0.2, + shape: shapes[Math.floor(Math.random() * shapes.length)], + }); + } + } else { + // For fireworks, store click position + clickPositionRef.current = { x, y }; + } + } + }; + + return ( + + + {children} + + ); + }, +); + +CelebrationFx.displayName = "CelebrationFx"; +export { CelebrationFx }; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 16e0148..7bc1675 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -106,3 +106,4 @@ export * from "./ThemeSwitcher"; export * from "./User"; export * from "./UserMenu"; export * from "./Pulse"; +export * from "./CelebrationFx"; \ No newline at end of file From 82bf7f57abf5b7c70ab2efcc17a7512cfe513dbe Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 15:26:19 +0100 Subject: [PATCH 08/16] feat: custom icon in Avatar --- .../src/content/once-ui/components/avatar.mdx | 47 +++++++++++++++++++ packages/core/src/components/Avatar.tsx | 6 ++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/content/once-ui/components/avatar.mdx b/apps/docs/src/content/once-ui/components/avatar.mdx index 90869b5..0572951 100644 --- a/apps/docs/src/content/once-ui/components/avatar.mdx +++ b/apps/docs/src/content/once-ui/components/avatar.mdx @@ -6,6 +6,8 @@ docs: "once-ui/components/avatar.mdx" github: "components/Avatar.tsx" navLabel: "Avatar" navIcon: "components" +navTag: "Update" +navTagVariant: "indigo" --- A versatile component for displaying user profile images, initials, or fallback icons in a circular container. Commonly used in user interfaces to represent people. @@ -34,6 +36,50 @@ A versatile component for displaying user profile images, initials, or fallback ]} /> +## Empty + +Use the `empty` prop to render an empty avatar. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Empty" + } + ]} +/> + +## Icon + +Use the `icon` prop to render an icon in the avatar. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Icon" + } + ]} +/> + ## Sizes Use the T-shirt size model or set sizes with numbers (REM). @@ -143,6 +189,7 @@ Use the T-shirt size model or set sizes with numbers (REM). ["size", ["xs", "s", "m", "l", "xl", "number"], "m"], ["value", "string"], ["src", "string"], + ["icon", "string"], ["loading", "boolean", "false"], ["empty", "boolean", "false"], ["statusIndicator", "object"], diff --git a/packages/core/src/components/Avatar.tsx b/packages/core/src/components/Avatar.tsx index a20c32a..2476eaa 100644 --- a/packages/core/src/components/Avatar.tsx +++ b/packages/core/src/components/Avatar.tsx @@ -4,6 +4,7 @@ import React, { forwardRef } from "react"; import { Skeleton, Icon, Text, StatusIndicator, Flex, Media } from "."; import styles from "./Avatar.module.scss"; +import { IconName } from "@/icons"; interface AvatarProps extends React.ComponentProps { size?: "xs" | "s" | "m" | "l" | "xl" | number; @@ -11,6 +12,7 @@ interface AvatarProps extends React.ComponentProps { src?: string; loading?: boolean; empty?: boolean; + icon?: IconName; statusIndicator?: { color: "green" | "yellow" | "red" | "gray"; }; @@ -36,7 +38,7 @@ const statusIndicatorSizeMapping: Record<"xs" | "s" | "m" | "l" | "xl", "s" | "m const Avatar = forwardRef( ( - { size = "m", value, src, loading, empty, statusIndicator, className, style = {}, ...rest }, + { size = "m", value, src, loading, empty, icon, statusIndicator, className, style = {}, ...rest }, ref, ) => { const sizeInRem = typeof size === "number" ? `${size}rem` : undefined; @@ -75,7 +77,7 @@ const Avatar = forwardRef( return ( Date: Sun, 16 Nov 2025 15:34:29 +0100 Subject: [PATCH 09/16] feat: inverse ShineFx --- .../src/content/once-ui/effects/shineFx.mdx | 44 +++++++++++++++---- .../core/src/components/ShineFx.module.scss | 19 +++++++- packages/core/src/components/ShineFx.tsx | 5 ++- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/apps/docs/src/content/once-ui/effects/shineFx.mdx b/apps/docs/src/content/once-ui/effects/shineFx.mdx index 2a031a1..3d72441 100644 --- a/apps/docs/src/content/once-ui/effects/shineFx.mdx +++ b/apps/docs/src/content/once-ui/effects/shineFx.mdx @@ -41,11 +41,11 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an marginBottom="24" preview={ - - Fast shine (2s) + + Fast shine - default (1s) - Default shine (5s) + Medium shine (5s) Slow shine (10s) @@ -55,16 +55,16 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an codes={[ { code: -` - Fast shine (2s) +` + Fast shine - default (1s) - - Default shine (5s) + + Medium shine (3s) - - Slow shine (10s) + + Slow shine (5s) `, language: "tsx", label: "Speed" @@ -72,6 +72,32 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an ]} /> +## Inverse + +Use the `inverse` prop to invert the shine effect. + + + + Inverse shine + + + } + codes={[ + { + code: +` + Inverse shine +`, + language: "tsx", + label: "Inverse" + } + ]} +/> + ## Base opacity Control the base text visibility with the `baseOpacity` prop (0-1). Higher values improve readability and accessibility, while lower values create a more subtle effect. diff --git a/packages/core/src/components/ShineFx.module.scss b/packages/core/src/components/ShineFx.module.scss index 12d1acd..8083cee 100644 --- a/packages/core/src/components/ShineFx.module.scss +++ b/packages/core/src/components/ShineFx.module.scss @@ -15,6 +15,23 @@ animation: shine 5s linear infinite; } +.inverse { + --shine-base-opacity: 0.3; + + display: inline-block; + -webkit-text-fill-color: transparent; + background: linear-gradient( + 120deg, + currentColor 40%, + color-mix(in srgb, currentColor, transparent calc((1 - var(--shine-base-opacity)) * 100%)) 50%, + currentColor 60% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + animation: shine 5s linear infinite; +} + @keyframes shine { 0% { background-position: 100%; @@ -30,4 +47,4 @@ background: none; -webkit-background-clip: unset; background-clip: unset; -} +} \ No newline at end of file diff --git a/packages/core/src/components/ShineFx.tsx b/packages/core/src/components/ShineFx.tsx index 944e06a..2082a7a 100644 --- a/packages/core/src/components/ShineFx.tsx +++ b/packages/core/src/components/ShineFx.tsx @@ -3,10 +3,12 @@ import React from "react"; import { Text } from "."; import styles from "./ShineFx.module.scss"; +import classNames from "classnames"; export interface ShineFxProps extends React.ComponentProps { speed?: number; disabled?: boolean; + inverse?: boolean; baseOpacity?: number; children?: React.ReactNode; } @@ -14,6 +16,7 @@ export interface ShineFxProps extends React.ComponentProps { const ShineFx: React.FC = ({ speed = 1, disabled = false, + inverse = false, baseOpacity = 0.3, children, className, @@ -25,7 +28,7 @@ const ShineFx: React.FC = ({ return ( Date: Sun, 16 Nov 2025 16:18:54 +0100 Subject: [PATCH 10/16] feat: rework SegmentedControl design and add compact prop --- .../once-ui/components/segmentedControl.mdx | 86 ++++++++++++++++++- .../core/src/components/SegmentedControl.tsx | 16 +++- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/once-ui/components/segmentedControl.mdx b/apps/docs/src/content/once-ui/components/segmentedControl.mdx index 91bdb4d..483c6b1 100644 --- a/apps/docs/src/content/once-ui/components/segmentedControl.mdx +++ b/apps/docs/src/content/once-ui/components/segmentedControl.mdx @@ -6,6 +6,8 @@ docs: "once-ui/components/segmentedControl.mdx" github: "components/SegmentedControl.tsx" navLabel: "Segmented Control" navIcon: "components" +navTag: "Update" +navTagVariant: "indigo" --- Use this component for toggle-style selection between discrete options. It supports keyboard interaction, styling, and controlled or uncontrolled usage. @@ -41,9 +43,48 @@ Use this component for toggle-style selection between discrete options. It suppo ]} /> -## Default selected +## External state -Use `defaultSelected` to define an initial active button in uncontrolled mode. +Use `selected` to manage the selected state from the parent. + + + + + } + codes={[ + { + code: +` console.log(value)} +/>`, + language: "tsx", + label: "External state" + } + ]} +/> + +## Internal state + +Use `defaultSelected` to define an initial active button in uncontrolled mode. Don't use it along with the `selected` prop. console.log(value)} />`, language: "tsx", - label: "Default selected" + label: "Internal state" + } + ]} +/> + +## Compact + +Use `compact` to create a compact variant. + + + + + } + codes={[ + { + code: +` console.log(value)} +/>`, + language: "tsx", + label: "Compact" } ]} /> diff --git a/packages/core/src/components/SegmentedControl.tsx b/packages/core/src/components/SegmentedControl.tsx index d369bc4..f8c164d 100644 --- a/packages/core/src/components/SegmentedControl.tsx +++ b/packages/core/src/components/SegmentedControl.tsx @@ -13,6 +13,7 @@ interface SegmentedControlProps extends Omit = ({ defaultSelected, fillWidth = true, selected, + compact = false, className, style, ...scrollerProps @@ -103,21 +105,27 @@ const SegmentedControl: React.FC = ({ aria-orientation="horizontal" onKeyDown={handleKeyDown} > - + {buttons.map((button, index) => { return ( { buttonRefs.current[index] = el as HTMLButtonElement; }} - variant="outline" - radius={index === 0 ? "left" : index === buttons.length - 1 ? "right" : "none"} + variant={compact ? "outline" : "ghost"} + radius={compact ? (index === 0 ? "left" : index === buttons.length - 1 ? "right" : "none") : undefined} key={button.value} selected={index === selectedIndex} onClick={(event) => handleButtonClick(button, event)} role="tab" className={className} - style={style} + style={{opacity: (index !== selectedIndex && !compact) ? 0.6 : 1, ...style}} aria-selected={index === selectedIndex} aria-controls={`panel-${button.value}`} tabIndex={index === selectedIndex ? 0 : -1} From 10658131e1c420ffd4c3a91c54f6a8bfb0d0a684 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 17:47:00 +0100 Subject: [PATCH 11/16] refact: improve MegaMenu animation --- .../core/src/modules/navigation/MegaMenu.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/modules/navigation/MegaMenu.tsx b/packages/core/src/modules/navigation/MegaMenu.tsx index 79f114b..e052424 100644 --- a/packages/core/src/modules/navigation/MegaMenu.tsx +++ b/packages/core/src/modules/navigation/MegaMenu.tsx @@ -251,8 +251,10 @@ export const MegaMenu: React.FC = ({ menuGroups, className, ...re {dropdownGroups.map((group, groupIndex) => { const isActive = activeDropdown === group.id; const wasActive = previousDropdownRef.current === group.id; - // Animate when switching between dropdowns (not when first opening or returning to same) - const shouldAnimate = isActive && !wasActive && previousDropdownRef.current !== null; + // Exiting: was active previously but not active now + const isExiting = wasActive && !isActive; + // Animate only when switching between dropdowns (not when first opening or returning to same) + const shouldAnimate = (isActive || isExiting) && previousDropdownRef.current !== null; // Update previous ref when active changes if (isActive && !wasActive) { @@ -271,11 +273,15 @@ export const MegaMenu: React.FC = ({ menuGroups, className, ...re contentRefs.current[group.id] = el; }} style={{ + zIndex: isExiting ? 3 : isActive ? 2 : 1, transform: isActive ? "scale(1)" : "scale(0.9)", - opacity: isActive ? 1 : 0, + opacity: isActive ? 1 : isExiting ? 0 : 0, pointerEvents: isActive ? "auto" : "none", - transition: shouldAnimate ? "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)" : "opacity 0.2s ease-in", - visibility: isActive ? "visible" : "hidden", + transition: shouldAnimate + ? "opacity 240ms ease, transform 240ms cubic-bezier(0.4, 0, 0.2, 1)" + : "opacity 200ms ease", + transitionDelay: shouldAnimate ? (isActive ? "120ms" : "0ms") : "0ms", + visibility: isActive || isExiting ? "visible" : "hidden", }} > {/* Render custom content if provided, otherwise render sections */} From db1fbb2e700ab8b9adb74b50b2b70b4e7ca3c429 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 17:47:28 +0100 Subject: [PATCH 12/16] feat: add reverse for X and Y axis for charts --- .../src/content/once-ui/data/linearGauge.mdx | 144 +++++++---------- packages/core/src/modules/data/BarChart.tsx | 145 ++++++++++-------- .../core/src/modules/data/LineBarChart.tsx | 117 ++++++++------ packages/core/src/modules/data/LineChart.tsx | 143 +++++++++-------- 4 files changed, 294 insertions(+), 255 deletions(-) diff --git a/apps/docs/src/content/once-ui/data/linearGauge.mdx b/apps/docs/src/content/once-ui/data/linearGauge.mdx index 7f0f1a2..0b57734 100644 --- a/apps/docs/src/content/once-ui/data/linearGauge.mdx +++ b/apps/docs/src/content/once-ui/data/linearGauge.mdx @@ -12,10 +12,6 @@ navTagVariant: "cyan" The `LinearGauge` component renders a horizontal tick-based gauge ideal for progress indicators, metrics dashboards, and system health displays. It supports flexible label configurations including none, percentage, or custom values. -## Usage - -### Basic usage - `, language: "tsx", label: "Usage" @@ -42,7 +38,7 @@ The `LinearGauge` component renders a horizontal tick-based gauge ideal for prog ]} /> -### Percentage labels +### Labels Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%). @@ -64,10 +60,10 @@ Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%). { code: ``, language: "tsx", label: "Percentage" @@ -75,8 +71,6 @@ Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%). ]} /> -### Custom labels - Provide an array of custom labels distributed evenly across the gauge. `, language: "tsx", label: "Custom" @@ -131,11 +125,11 @@ Flip the order of labels and gauge by using `direction="column-reverse"`. { code: ``, language: "tsx", label: "Reverse" @@ -151,9 +145,10 @@ Use the `hue` prop to represent different states with color gradients. marginTop="16" marginBottom="24" previewPadding="l" + codeHeight={24} fullscreenButton={true} preview={ - + + } codes={[ { code: ` - - - -`, - language: "tsx", - label: "States" - } - ]} -/> - -### Custom hue gradient - -Provide a custom hue range as a tuple `[startHue, endHue]` for fine-grained color control. - - + + - } - codes={[ - { - code: -``, +`, language: "tsx", - label: "Custom hue" + label: "States" } ]} /> -### Tick density +## Ticks Customize tick count, width, and length for different visual densities. @@ -271,15 +245,15 @@ Customize tick count, width, and length for different visual densities. { code: ``, language: "tsx", label: "Density" diff --git a/packages/core/src/modules/data/BarChart.tsx b/packages/core/src/modules/data/BarChart.tsx index a90c0ce..1bcff28 100644 --- a/packages/core/src/modules/data/BarChart.tsx +++ b/packages/core/src/modules/data/BarChart.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { formatDate } from "./utils/formatDate"; import { BarChart as RechartsBarChart, @@ -31,6 +31,8 @@ import { RadiusSize } from "@/types"; interface BarChartProps extends ChartProps { barWidth?: barWidth; hover?: boolean; + reverseY?: boolean; + reverseX?: boolean; "data-viz-style"?: string; } @@ -52,6 +54,8 @@ const BarChart: React.FC = ({ variant: variantProp, barWidth = "l", hover = false, + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -86,20 +90,27 @@ const BarChart: React.FC = ({ } }, [date?.start, date?.end]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); const seriesArray = Array.isArray(series) ? series : series ? [series] : []; const seriesKeys = seriesArray.map((s) => s.key); const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); - const coloredSeriesArray = seriesArray.map((s, index) => ({ - ...s, - color: s.color || getDistributedColor(index, seriesArray.length), - })); + const coloredSeriesArray = useMemo( + () => + seriesArray.map((s, index) => ({ + ...s, + color: s.color || getDistributedColor(index, seriesArray.length), + })), + [seriesArray], + ); const xAxisKey = Object.keys(data[0] || {}).find((key) => !seriesKeys.includes(key)) || "name"; @@ -114,6 +125,63 @@ const BarChart: React.FC = ({ const barColors = autoSeries.map((s) => `var(--data-${s.color})`); + const legendContent = useCallback( + (props: any) => { + const customPayload = autoSeries.map((series, index) => ({ + value: series.key, + color: barColors[index], + })); + + return ( + + ); + }, + [autoSeries, barColors, variant, axis, legend.position, legend.direction], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); + const filteredData = React.useMemo(() => { if (!selectedDateRange || !data || data.length === 0) { return data; @@ -140,7 +208,7 @@ const BarChart: React.FC = ({ return ( = ({ {legend.display && ( { - const customPayload = autoSeries.map((series, index) => ({ - value: series.key, - color: barColors[index], - })); - - return ( - - ); - }} - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ allowDataOverflow width={64} padding={{ top: 40 }} + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, diff --git a/packages/core/src/modules/data/LineBarChart.tsx b/packages/core/src/modules/data/LineBarChart.tsx index 14b65cd..50b10c5 100644 --- a/packages/core/src/modules/data/LineBarChart.tsx +++ b/packages/core/src/modules/data/LineBarChart.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { isWithinInterval, parseISO } from "date-fns"; import { formatDate } from "./utils/formatDate"; import { @@ -33,6 +33,8 @@ import { RadiusSize } from "@/types"; interface LineBarChartProps extends ChartProps { barWidth?: barWidth; curve?: curveType; + reverseY?: boolean; + reverseX?: boolean; "data-viz-style"?: string; } @@ -54,6 +56,8 @@ const LineBarChart: React.FC = ({ variant: variantProp, barWidth = "l", curve = "natural", + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -120,12 +124,15 @@ const LineBarChart: React.FC = ({ return data; }, [data, selectedDateRange, xAxisKey]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); const chartSeriesArray = Array.isArray(series) ? series : series ? [series] : []; if (chartSeriesArray.length < 2) { @@ -141,6 +148,56 @@ const LineBarChart: React.FC = ({ const finalLineColor = `var(--data-${lineColor})`; const finalBarColor = `var(--data-${barColor})`; + const legendContent = useMemo( + () => ( + + ), + [variant, finalLineColor, finalBarColor, axis, legend.position, legend.direction], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); + const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); return ( @@ -191,48 +248,8 @@ const LineBarChart: React.FC = ({ {legend.display && ( - } - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ tickMargin={6} dataKey={xAxisKey} hide={!(axis === "x" || axis === "both")} + orientation={reverseX ? "top" : "bottom"} axisLine={{ stroke: axisLineStroke, }} @@ -258,6 +276,7 @@ const LineBarChart: React.FC = ({ width={64} padding={{ top: 40 }} allowDataOverflow + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, diff --git a/packages/core/src/modules/data/LineChart.tsx b/packages/core/src/modules/data/LineChart.tsx index 5abf53f..71c427b 100644 --- a/packages/core/src/modules/data/LineChart.tsx +++ b/packages/core/src/modules/data/LineChart.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { isWithinInterval, parseISO } from "date-fns"; import { formatDate } from "./utils/formatDate"; import { @@ -31,6 +31,8 @@ import { useDataTheme } from "../../contexts/DataThemeProvider"; interface LineChartProps extends ChartProps { curve?: curveType; + reverseY?: boolean; + reverseX?: boolean; "data-viz-style"?: string; } @@ -51,6 +53,8 @@ const LineChart: React.FC = ({ border = "neutral-alpha-weak", variant: variantProp, curve = "natural", + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -92,10 +96,14 @@ const LineChart: React.FC = ({ // Generate a unique ID for this chart instance const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); - const coloredSeriesArray = seriesArray.map((s, index) => ({ - ...s, - color: s.color || getDistributedColor(index, seriesArray.length), - })); + const coloredSeriesArray = useMemo( + () => + seriesArray.map((s, index) => ({ + ...s, + color: s.color || getDistributedColor(index, seriesArray.length), + })), + [seriesArray], + ); const autoKeys = Object.keys(data[0] || {}).filter((key) => !seriesKeys.includes(key)); const autoSeries = @@ -135,12 +143,72 @@ const LineChart: React.FC = ({ return data; }, [data, selectedDateRange, xAxisKey]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); + + const legendContent = useCallback( + (props: any) => { + const customPayload = autoSeries.map(({ key, color }, index) => ({ + value: key, + color: `var(--data-${color || schemes[index % schemes.length]})`, + })); + + return ( + + ); + }, + [autoSeries, axis, legend.position, legend.direction, variant], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); return ( = ({ {legend.display && ( { - const customPayload = autoSeries.map(({ key, color }, index) => ({ - value: key, - color: `var(--data-${color || schemes[index % schemes.length]})`, - })); - - return ( - - ); - }} - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ tickMargin={6} dataKey={xAxisKey} hide={!(axis === "x" || axis === "both")} + orientation={reverseX ? "top" : "bottom"} axisLine={{ stroke: axisLineStroke, }} @@ -264,6 +286,7 @@ const LineChart: React.FC = ({ width={64} padding={{ top: 40 }} allowDataOverflow + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, From 89f0aa1feff69dcdfbfd6c83f6113c32ada96150 Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 19:02:34 +0100 Subject: [PATCH 13/16] feat: better handling for enlarged Media --- apps/docs/src/content/once-ui/components/media.mdx | 5 +++++ packages/core/src/components/Media.tsx | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/docs/src/content/once-ui/components/media.mdx b/apps/docs/src/content/once-ui/components/media.mdx index 6792f5b..12198d0 100644 --- a/apps/docs/src/content/once-ui/components/media.mdx +++ b/apps/docs/src/content/once-ui/components/media.mdx @@ -16,6 +16,7 @@ navIcon: "components" preview={ `, @@ -49,6 +51,7 @@ Show a skeleton block while content is loading. Only works when `aspectRatio` is loading radius="l" aspectRatio="16/9" + sizes="(max-width: 768px) 100vw, 768px" src="/images/docs/once-ui/vibe-coding-light.jpg" /> } @@ -79,6 +82,7 @@ The `caption` prop renders a `ReactNode` below the image, making it easy to prov diff --git a/packages/core/src/components/Media.tsx b/packages/core/src/components/Media.tsx index 3ea5bd1..23d3a71 100644 --- a/packages/core/src/components/Media.tsx +++ b/packages/core/src/components/Media.tsx @@ -45,8 +45,12 @@ const Media: React.FC = ({ const imageRef = useRef(null); const handleImageClick = () => { - if (enlarge && !isEnlarged) { - setIsEnlarged(true); + if (enlarge) { + if (!isEnlarged) { + setIsEnlarged(true); + } else { + setIsEnlarged(false); + } } }; @@ -159,7 +163,6 @@ const Media: React.FC = ({ overflow="hidden" zIndex={0} margin="0" - cursor={enlarge ? "interactive" : undefined} style={{ outline: "none", isolation: "isolate", @@ -170,7 +173,7 @@ const Media: React.FC = ({ ...style, }} onClick={handleImageClick} - className={classNames(enlarge && !isEnlarged ? "cursor-interactive" : undefined, className)} + className={classNames(enlarge && !isEnlarged ? "cursor-zoom-in" : enlarge && isEnlarged ? "cursor-zoom-out" : undefined, className)} {...rest} > {loading && } @@ -205,7 +208,7 @@ const Media: React.FC = ({ {alt} Date: Sun, 16 Nov 2025 19:52:20 +0100 Subject: [PATCH 14/16] feat: triggers to WeatherFx and MatrixFx --- .../src/content/once-ui/effects/matrixFx.mdx | 101 ++++++-- .../src/content/once-ui/effects/weatherFx.mdx | 77 +++++- .../src/content/once-ui/modules/meta.json | 3 +- apps/docs/src/product/MatrixFxExample.tsx | 17 ++ apps/docs/src/product/WeatherFxExample.tsx | 18 ++ apps/docs/src/product/index.ts | 3 + packages/core/src/components/MatrixFx.tsx | 186 +++++++++++---- packages/core/src/components/WeatherFx.tsx | 225 +++++++++++++++++- 8 files changed, 551 insertions(+), 79 deletions(-) create mode 100644 apps/docs/src/product/MatrixFxExample.tsx create mode 100644 apps/docs/src/product/WeatherFxExample.tsx diff --git a/apps/docs/src/content/once-ui/effects/matrixFx.mdx b/apps/docs/src/content/once-ui/effects/matrixFx.mdx index 15cdefb..cf11f36 100644 --- a/apps/docs/src/content/once-ui/effects/matrixFx.mdx +++ b/apps/docs/src/content/once-ui/effects/matrixFx.mdx @@ -95,9 +95,9 @@ Use multiple colors for variety. Supports CSS variables and any valid color form ``, language: "tsx", @@ -271,10 +271,10 @@ Add dynamic wave effects to create fluid, organic motion. Choose between `ripple height={24} colors={["accent-solid-strong"]} bulge={{ - type: "ripple", // Circular wave from center - duration: 4, // Wave duration in seconds - intensity: 15, // Displacement strength - repeat: true // Continuous cycles + type: "ripple", // Circular wave from center + duration: 4, // Wave duration in seconds + intensity: 15, // Displacement strength + repeat: true // Continuous cycles }} />`, language: "tsx", @@ -308,10 +308,10 @@ The `wave` type creates an organic, flowing S-curve that travels diagonally from height={24} colors={["success-solid-strong"]} bulge={{ - type: "wave", // S-curve diagonal flow - duration: 3, - intensity: 20, - repeat: true + type: "wave", // S-curve diagonal flow + duration: 3, + intensity: 20, + repeat: true }} />`, language: "tsx", @@ -345,9 +345,9 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b height={24} colors={["danger-solid-strong"]} bulge={{ - duration: 3, - intensity: 12, - repeat: false // Single wave only + duration: 3, + intensity: 12, + repeat: false // Single wave only }} />`, language: "tsx", @@ -356,14 +356,14 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b ]} /> -## Hover +## Trigger Use `trigger="hover"` for native hover support. Dots progressively appear on hover and smoothly reverse when you move away. @@ -528,6 +528,72 @@ Use `trigger="hover"` for native hover support. Dots progressively appear on hov ]} /> +Use `trigger="click"` to toggle the effect on click. + + + + Click to toggle + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Click Trigger" + } + ]} +/> + +Use `trigger="manual"` with the `active` prop to control the effect from parent state. + + + } + codes={[ + { + code: +`"use client"; + +import React from "react"; +import { MatrixFx, Row, ToggleButton } from "@once-ui-system/core"; + +export function Demo() { + const [on, setOn] = React.useState(false); + return ( + + setOn((v) => !v)}> + {on ? "Stop" : "Start"} + + + + ); +} +`, + language: "tsx", + label: "Manual Trigger" + } + ]} +/> + ## API reference -## Hover Trigger +## Trigger Use `trigger="hover"` to only emit particles when hovering over the element. @@ -922,6 +922,78 @@ Use `trigger="hover"` to only emit particles when hovering over the element. ]} /> +Use `trigger="click"` to toggle emission on click. + + + + Click to toggle rain + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Click Trigger" + } + ]} +/> + +Use `trigger="manual"` with the `active` prop to control emission from parent state. + + + } + codes={[ + { + code: +`"use client"; + +import React from "react"; +import { WeatherFx, Row, ToggleButton } from "@once-ui-system/core"; + +export function Demo() { + const [on, setOn] = React.useState(false); + + return ( + + setOn((v) => !v)}> + {on ? "Stop" : "Start"} + + + + ); +} +`, + language: "tsx", + label: "Manual Trigger" + } + ]} +/> + ## API reference + + + + ); +} diff --git a/apps/docs/src/product/WeatherFxExample.tsx b/apps/docs/src/product/WeatherFxExample.tsx new file mode 100644 index 0000000..09920f3 --- /dev/null +++ b/apps/docs/src/product/WeatherFxExample.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Button, Row } from "@once-ui-system/core"; +import { WeatherFx } from "@once-ui-system/core"; +import React from "react"; + +export function WeatherFxExample() { + const [on, setOn] = React.useState(false); + + return ( + + + + + ); +} \ No newline at end of file diff --git a/apps/docs/src/product/index.ts b/apps/docs/src/product/index.ts index 2ef1d37..9a1d50f 100644 --- a/apps/docs/src/product/index.ts +++ b/apps/docs/src/product/index.ts @@ -30,3 +30,6 @@ export * from "./TypeFxCustomExample"; export * from "./Products"; export * from "./RadialGaugeExample"; export * from "./CelebrationFxExample"; +export * from "./WeatherFxExample"; +export * from "./MatrixFxExample"; + diff --git a/packages/core/src/components/MatrixFx.tsx b/packages/core/src/components/MatrixFx.tsx index 5c484f6..d52c4c9 100644 --- a/packages/core/src/components/MatrixFx.tsx +++ b/packages/core/src/components/MatrixFx.tsx @@ -17,7 +17,8 @@ interface MatrixFxProps extends React.ComponentProps { size?: number; spacing?: number; revealFrom?: "center" | "top" | "bottom" | "left" | "right"; - trigger?: "hover" | "instant" | "mount"; + trigger?: "hover" | "instant" | "mount" | "click" | "manual"; + active?: boolean; flicker?: boolean; bulge?: BulgeConfig; children?: React.ReactNode; @@ -32,6 +33,7 @@ const MatrixFx = React.forwardRef( spacing = 3, revealFrom = "center", trigger = "instant", + active = false, flicker = false, bulge, children, @@ -49,6 +51,7 @@ const MatrixFx = React.forwardRef( const isHoveredRef = useRef(false); const mountAnimationCompleteRef = useRef(false); const bulgeStartTimeRef = useRef(Date.now()); + const dotsRef = useRef([]); useEffect(() => { if (forwardedRef) { @@ -114,55 +117,72 @@ const MatrixFx = React.forwardRef( flickerSpeed: number; } - const dots: Dot[] = []; - - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const x = col * totalSize + size / 2 - maxDisplacement; - const y = row * totalSize + size / 2 - maxDisplacement; - - // Calculate distance from reveal origin - let distanceFromOrigin = 0; - const centerX = canvasWidth / 2; - const centerY = canvasHeight / 2; - - switch (revealFrom) { - case "center": - const dx = x - centerX; - const dy = y - centerY; - distanceFromOrigin = Math.sqrt(dx * dx + dy * dy); - break; - case "top": - distanceFromOrigin = y; - break; - case "bottom": - distanceFromOrigin = canvasHeight - y; - break; - case "left": - distanceFromOrigin = x; - break; - case "right": - distanceFromOrigin = canvasWidth - x; - break; - } + // Only create new dots if grid doesn't exist or dimensions/size changed + let dots: Dot[] = dotsRef.current; + let maxDistance = 0; + + if (dots.length === 0 || dots[0]?.gridSize !== totalSize || dots[0]?.canvasW !== canvasWidth || dots[0]?.canvasH !== canvasHeight) { + // Create new dot grid + dots = []; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * totalSize + size / 2 - maxDisplacement; + const y = row * totalSize + size / 2 - maxDisplacement; + + // Calculate distance from reveal origin + let distanceFromOrigin = 0; + const centerX = canvasWidth / 2; + const centerY = canvasHeight / 2; + + switch (revealFrom) { + case "center": + const dx = x - centerX; + const dy = y - centerY; + distanceFromOrigin = Math.sqrt(dx * dx + dy * dy); + break; + case "top": + distanceFromOrigin = y; + break; + case "bottom": + distanceFromOrigin = canvasHeight - y; + break; + case "left": + distanceFromOrigin = x; + break; + case "right": + distanceFromOrigin = canvasWidth - x; + break; + } - dots.push({ - x, - y, - gridX: col, - gridY: row, - color: parsedColors[Math.floor(Math.random() * parsedColors.length)], - baseOpacity: 0.3 + Math.random() * 0.7, - distanceFromOrigin, - randomOffset: Math.random() * 0.3, - flickerPhase: Math.random() * Math.PI * 2, // Random starting point - flickerSpeed: 0.8 + Math.random() * 0.4, // Random speed between 0.8 and 1.2 - }); + dots.push({ + x, + y, + gridX: col, + gridY: row, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + baseOpacity: 0.3 + Math.random() * 0.7, + distanceFromOrigin, + randomOffset: Math.random() * 0.3, + flickerPhase: Math.random() * Math.PI * 2, + flickerSpeed: 0.8 + Math.random() * 0.4, + gridSize: totalSize, // Store for comparison + canvasW: canvasWidth, // Store for comparison + canvasH: canvasHeight, // Store for comparison + } as any); + } } - } - // Find max distance for normalization - const maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + // Find max distance for normalization + maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + dotsRef.current = dots; + } else { + // Update colors on existing dots + dots.forEach((dot) => { + dot.color = parsedColors[Math.floor(Math.random() * parsedColors.length)]; + }); + maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + } // Animation loop @@ -475,9 +495,10 @@ const MatrixFx = React.forwardRef( return; } - // For hover trigger with animation - if (isHoveredRef.current) { - // Revealing animation with explosive easing + // For hover, click, and manual triggers with animation + if (trigger === "hover" || trigger === "click" || trigger === "manual") { + if (isHoveredRef.current) { + // Revealing animation with explosive easing const now = Date.now(); const elapsed = (now - revealStartTimeRef.current) / 1000; // Cubic easing: starts very slow, then explodes @@ -622,6 +643,7 @@ const MatrixFx = React.forwardRef( hideStartProgressRef.current = 0; } } + } } ctx.globalAlpha = 1; @@ -638,6 +660,39 @@ const MatrixFx = React.forwardRef( }; }, [colors, size, spacing, speed, revealFrom, trigger, flicker, bulge]); + // Manual trigger control via `active` prop + useEffect(() => { + if (trigger !== "manual") return; + const now = Date.now(); + if (active) { + // Mimic mouse enter + if (hideStartProgressRef.current > 0) { + const hideElapsed = (now - hideStartTimeRef.current) / 1000; + const hideSpeed = speed * 6; + const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed; + const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress); + const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3); + const simulatedStartTime = now - effectiveElapsed * 1000; + revealStartTimeRef.current = simulatedStartTime; + } else { + revealStartTimeRef.current = now; + } + if (bulge && !bulge.repeat) { + bulgeStartTimeRef.current = now; + } + isHoveredRef.current = true; + hideStartProgressRef.current = 0; + } else { + // Mimic mouse leave + if (isHoveredRef.current) { + const currentProgress = maxRevealProgressRef.current; + hideStartTimeRef.current = Date.now(); + hideStartProgressRef.current = currentProgress; + isHoveredRef.current = false; + } + } + }, [active, trigger, speed, bulge]); + const handleMouseEnter = () => { if (trigger === "hover" && !isHoveredRef.current) { const now = Date.now(); @@ -686,6 +741,36 @@ const MatrixFx = React.forwardRef( } }; + const handleClick = () => { + if (trigger !== "click") return; + if (!isHoveredRef.current) { + // Enter + const now = Date.now(); + if (hideStartProgressRef.current > 0) { + const hideElapsed = (now - hideStartTimeRef.current) / 1000; + const hideSpeed = speed * 6; + const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed; + const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress); + const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3); + const simulatedStartTime = now - effectiveElapsed * 1000; + revealStartTimeRef.current = simulatedStartTime; + } else { + revealStartTimeRef.current = now; + } + if (bulge && !bulge.repeat) { + bulgeStartTimeRef.current = now; + } + isHoveredRef.current = true; + hideStartProgressRef.current = 0; + } else { + // Leave + const currentProgress = maxRevealProgressRef.current; + hideStartTimeRef.current = Date.now(); + hideStartProgressRef.current = currentProgress; + isHoveredRef.current = false; + } + }; + return ( ( onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onClick={handleClick} {...rest} > { intensity?: number; angle?: number; duration?: number; - trigger?: "mount" | "hover"; + trigger?: "mount" | "hover" | "click" | "manual"; + active?: boolean; children?: React.ReactNode; } @@ -88,6 +89,7 @@ const WeatherFx = React.forwardRef( angle = 0, duration, trigger = "mount", + active = false, children, ...rest }, @@ -98,7 +100,7 @@ const WeatherFx = React.forwardRef( const animationRef = useRef(undefined); const particlesRef = useRef<(RainDrop | Snowflake | Leaf | Lightning)[]>([]); const timeRef = useRef(0); - const isEmittingRef = useRef(trigger === "mount"); + const isEmittingRef = useRef(trigger === "mount" || (trigger === "manual" && active)); const emitStartTimeRef = useRef(Date.now()); const isHoveredRef = useRef(false); const lastLightningTimeRef = useRef(0); @@ -403,13 +405,18 @@ const WeatherFx = React.forwardRef( return [] as Lightning[]; }; - // Initialize particles - particlesRef.current = - type === "rain" ? initializeRain() : - type === "snow" ? initializeSnow() : - type === "leaves" ? initializeLeaves() : - type === "lightning" ? initializeLightning() : - []; + // Initialize particles only for mount trigger (manual/click handled by their respective handlers) + // Only initialize if particles don't already exist to preserve active particles across re-renders + if (particlesRef.current.length === 0) { + const shouldInitialize = trigger === "mount"; + particlesRef.current = shouldInitialize + ? (type === "rain" ? initializeRain() : + type === "snow" ? initializeSnow() : + type === "leaves" ? initializeLeaves() : + type === "lightning" ? initializeLightning() : + []) + : []; + } // Animation loop const angleRad = (angle * Math.PI) / 180; // Convert angle to radians @@ -740,6 +747,106 @@ const WeatherFx = React.forwardRef( }; }, [type, colors, speed, intensity, angle, duration, trigger]); + // Respond to external control in manual mode + useEffect(() => { + if (trigger !== "manual") return; + if (active && !isEmittingRef.current) { + // Initialize particles if starting emission + if (particlesRef.current.length === 0) { + const canvas = canvasRef.current; + const container = containerRef.current; + if (canvas && container) { + const canvasWidth = canvas.width / 2; + const canvasHeight = canvas.height / 2; + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + if (type === "rain") { + const particles: RainDrop[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + length: 10 + Math.random() * 20, + speed: (2 + Math.random() * 3) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.3 + Math.random() * 0.5, + thickness: 1 + Math.random() * 1.5, + }); + } + particlesRef.current = particles; + } else if (type === "snow") { + const particles: Snowflake[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const size = 2 + depth * 4; + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + size, + speed: (0.3 + depth * 0.7) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.4 + depth * 0.5, + swayAmplitude: 20 + Math.random() * 30, + swaySpeed: 0.5 + Math.random() * 1, + swayOffset: Math.random() * Math.PI * 2, + depth, + }); + } + particlesRef.current = particles; + } else if (type === "leaves") { + const particles: Leaf[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const width = 8 + depth * 12; + const height = width * (0.6 + Math.random() * 0.4); + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + const colorIndex = Math.floor(Math.random() * parsedColors.length); + const color1 = parsedColors[colorIndex]; + const color2 = parsedColors[Math.min(colorIndex + 1, parsedColors.length - 1)]; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + width, + height, + speed: (0.4 + depth * 0.8) * speed, + color1, + color2, + opacity: 0.6 + depth * 0.3, + swayAmplitude: 30 + Math.random() * 50, + swaySpeed: 0.3 + Math.random() * 0.7, + swayOffset: Math.random() * Math.PI * 2, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.08, + rotation3D: Math.random() * Math.PI * 2, + rotation3DSpeed: (Math.random() - 0.5) * 0.06, + depth, + }); + } + particlesRef.current = particles; + } + } + } + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else if (!active && isEmittingRef.current) { + isEmittingRef.current = false; + } + }, [active, trigger]); + const handleMouseEnter = () => { if (trigger === "hover" && !isHoveredRef.current) { isHoveredRef.current = true; @@ -754,6 +861,105 @@ const WeatherFx = React.forwardRef( } }; + const handleClick = () => { + if (trigger !== "click") return; + if (!isEmittingRef.current) { + // Initialize particles if starting emission + if (particlesRef.current.length === 0) { + const canvas = canvasRef.current; + const container = containerRef.current; + if (canvas && container) { + const canvasWidth = canvas.width / 2; + const canvasHeight = canvas.height / 2; + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + if (type === "rain") { + const particles: RainDrop[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + length: 10 + Math.random() * 20, + speed: (2 + Math.random() * 3) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.3 + Math.random() * 0.5, + thickness: 1 + Math.random() * 1.5, + }); + } + particlesRef.current = particles; + } else if (type === "snow") { + const particles: Snowflake[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const size = 2 + depth * 4; + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + size, + speed: (0.3 + depth * 0.7) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.4 + depth * 0.5, + swayAmplitude: 20 + Math.random() * 30, + swaySpeed: 0.5 + Math.random() * 1, + swayOffset: Math.random() * Math.PI * 2, + depth, + }); + } + particlesRef.current = particles; + } else if (type === "leaves") { + const particles: Leaf[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const width = 8 + depth * 12; + const height = width * (0.6 + Math.random() * 0.4); + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + const colorIndex = Math.floor(Math.random() * parsedColors.length); + const color1 = parsedColors[colorIndex]; + const color2 = parsedColors[Math.min(colorIndex + 1, parsedColors.length - 1)]; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + width, + height, + speed: (0.4 + depth * 0.8) * speed, + color1, + color2, + opacity: 0.6 + depth * 0.3, + swayAmplitude: 30 + Math.random() * 50, + swaySpeed: 0.3 + Math.random() * 0.7, + swayOffset: Math.random() * Math.PI * 2, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.08, + rotation3D: Math.random() * Math.PI * 2, + rotation3DSpeed: (Math.random() - 0.5) * 0.06, + depth, + }); + } + particlesRef.current = particles; + } + } + } + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else { + isEmittingRef.current = false; + } + }; + return ( ( overflow="hidden" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onClick={handleClick} {...rest} > Date: Sun, 16 Nov 2025 19:52:32 +0100 Subject: [PATCH 15/16] realease: update package version --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index bce62ff..a89ab50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@once-ui-system/core", - "version": "1.5.2", + "version": "1.5.3", "description": "Once UI for Next.js NPM package", "keywords": [ "once-ui", From e6a65298d3a73b404540eced1c2d479a3c7e898e Mon Sep 17 00:00:00 2001 From: Lorant Date: Sun, 16 Nov 2025 19:58:59 +0100 Subject: [PATCH 16/16] fix: type errors in MatrixFx --- packages/core/src/components/MatrixFx.tsx | 39 ++++++++++++----------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/core/src/components/MatrixFx.tsx b/packages/core/src/components/MatrixFx.tsx index d52c4c9..e6a7389 100644 --- a/packages/core/src/components/MatrixFx.tsx +++ b/packages/core/src/components/MatrixFx.tsx @@ -11,6 +11,22 @@ interface BulgeConfig { delay?: number; } +interface Dot { + x: number; + y: number; + gridX: number; + gridY: number; + color: string; + baseOpacity: number; + distanceFromOrigin: number; + randomOffset: number; + flickerPhase: number; + flickerSpeed: number; + gridSize?: number; + canvasW?: number; + canvasH?: number; +} + interface MatrixFxProps extends React.ComponentProps { speed?: number; colors?: string[]; @@ -51,7 +67,7 @@ const MatrixFx = React.forwardRef( const isHoveredRef = useRef(false); const mountAnimationCompleteRef = useRef(false); const bulgeStartTimeRef = useRef(Date.now()); - const dotsRef = useRef([]); + const dotsRef = useRef([]); useEffect(() => { if (forwardedRef) { @@ -104,19 +120,6 @@ const MatrixFx = React.forwardRef( const cols = Math.ceil(paddedWidth / totalSize); const rows = Math.ceil(paddedHeight / totalSize); - interface Dot { - x: number; - y: number; - gridX: number; - gridY: number; - color: string; - baseOpacity: number; - distanceFromOrigin: number; - randomOffset: number; - flickerPhase: number; - flickerSpeed: number; - } - // Only create new dots if grid doesn't exist or dimensions/size changed let dots: Dot[] = dotsRef.current; let maxDistance = 0; @@ -166,10 +169,10 @@ const MatrixFx = React.forwardRef( randomOffset: Math.random() * 0.3, flickerPhase: Math.random() * Math.PI * 2, flickerSpeed: 0.8 + Math.random() * 0.4, - gridSize: totalSize, // Store for comparison - canvasW: canvasWidth, // Store for comparison - canvasH: canvasHeight, // Store for comparison - } as any); + gridSize: totalSize, + canvasW: canvasWidth, + canvasH: canvasHeight, + }); } }