UNPKG

@gfazioli/mantine-clock

Version:

React Clock components and hooks for Mantine with timezone support, countdown timers, customization options, and real-time updates.

721 lines (718 loc) 23.4 kB
'use client'; import dayjs from 'dayjs'; import timezonePlugin from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import React, { useState, useRef, useEffect } from 'react'; import { createVarsResolver, px, getSize, parseThemeColor, factory, useProps, useStyles, Box, Text } from '@mantine/core'; import classes from './Clock.module.css.mjs'; dayjs.extend(utc); dayjs.extend(timezonePlugin); const defaultProps = { size: 400, hourHandSize: 0.017, minuteHandSize: 0.011, secondHandSize: 6e-3, hourHandLength: 0.4, minuteHandLength: 0.57, secondHandLength: 0.68, secondHandOpacity: 1, minuteHandOpacity: 1, hourHandOpacity: 1, running: true }; const defaultClockSizes = { xs: 100, sm: 200, md: 400, lg: 480, xl: 512 }; const varsResolver = createVarsResolver( (theme, { size, color, hourTicksColor, hourTicksOpacity, minuteTicksColor, minuteTicksOpacity, primaryNumbersColor, primaryNumbersOpacity, secondaryNumbersColor, secondaryNumbersOpacity, secondHandColor, minuteHandColor, hourHandColor, secondsArcColor, minutesArcColor, hoursArcColor }) => { const sizeValue = size || "md"; const clockSize = typeof sizeValue === "string" && sizeValue in defaultClockSizes ? defaultClockSizes[sizeValue] : sizeValue; const effectiveSize = Math.round(px(getSize(clockSize, "clock-size"))); return { root: { "--clock-size": `${effectiveSize}px`, "--clock-color": parseThemeColor({ color: color || "", theme }).value, "--clock-hour-ticks-color": parseThemeColor({ color: hourTicksColor || "", theme }).value, "--clock-hour-ticks-opacity": (Math.round((hourTicksOpacity || 1) * 100) / 100).toString(), "--clock-minute-ticks-color": parseThemeColor({ color: minuteTicksColor || "", theme }).value, "--clock-minute-ticks-opacity": (Math.round((minuteTicksOpacity || 1) * 100) / 100).toString(), "--clock-primary-numbers-color": parseThemeColor({ color: primaryNumbersColor || "", theme }).value, "--clock-primary-numbers-opacity": (Math.round((primaryNumbersOpacity || 1) * 100) / 100).toString(), "--clock-secondary-numbers-color": parseThemeColor({ color: secondaryNumbersColor || "", theme }).value, "--clock-secondary-numbers-opacity": (Math.round((secondaryNumbersOpacity || 1) * 100) / 100).toString(), "--clock-second-hand-color": parseThemeColor({ color: secondHandColor || "", theme }).value, "--clock-minute-hand-color": parseThemeColor({ color: minuteHandColor || "", theme }).value, "--clock-hour-hand-color": parseThemeColor({ color: hourHandColor || "", theme }).value, "--clock-seconds-arc-color": parseThemeColor({ color: secondsArcColor || secondHandColor || "", theme }).value, "--clock-minutes-arc-color": parseThemeColor({ color: minutesArcColor || minuteHandColor || "", theme }).value, "--clock-hours-arc-color": parseThemeColor({ color: hoursArcColor || hourHandColor || "", theme }).value } }; } ); const parseTimeValue = (value) => { if (!value) { return null; } if (value instanceof Date) { return value; } if (dayjs.isDayjs(value)) { return value.toDate(); } if (typeof value === "string") { const timeRegex = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/; const match = value.match(timeRegex); if (match) { const hours = parseInt(match[1], 10); const minutes = parseInt(match[2], 10); const seconds = parseInt(match[3] || "0", 10); const date = /* @__PURE__ */ new Date(); date.setHours(hours, minutes, seconds, 0); return date; } const parsed = new Date(value); if (!isNaN(parsed.getTime())) { return parsed; } } return null; }; const RealClock = (props) => { const { time, timezone: timezone2, getStyles, effectiveSize, hourHandSize, minuteHandSize, secondHandSize, hourHandLength, minuteHandLength, secondHandLength, secondHandBehavior, secondHandOpacity, minuteHandOpacity, hourHandOpacity, hourTicksOpacity, minuteTicksOpacity, primaryNumbersOpacity, secondaryNumbersOpacity, hourNumbersDistance = 0.75, // Default distance for hour numbers primaryNumbersProps, secondaryNumbersProps, withSecondsArc, secondsArcFrom, secondsArcDirection = "clockwise", withMinutesArc, minutesArcFrom, minutesArcDirection = "clockwise", withHoursArc, hoursArcFrom, hoursArcDirection = "clockwise", secondsArcOpacity, minutesArcOpacity, hoursArcOpacity } = props; const timezoneTime = timezone2 && timezone2 !== "" ? dayjs(time).tz(timezone2) : dayjs(time); const hours = timezoneTime.hour() % 12; const minutes = timezoneTime.minute(); const seconds = timezoneTime.second(); const milliseconds = timezoneTime.millisecond(); const hourAngle = hours * 30 + minutes * 0.5; const minuteAngle = minutes * 6; let secondAngle = 0; switch (secondHandBehavior) { case "tick": secondAngle = seconds * 6; break; case "tick-half": secondAngle = (seconds + Math.floor(milliseconds / 500) * 0.5) * 6; break; case "tick-high-freq": secondAngle = (seconds + Math.floor(milliseconds / 125) * 0.125) * 6; break; case "smooth": default: secondAngle = (seconds + milliseconds / 1e3) * 6; break; } const size = effectiveSize; const clockRadius = Math.round(size / 2); const numberRadius = Math.round(clockRadius * hourNumbersDistance); const calculatedHourHandLength = Math.round( clockRadius * (hourHandLength ?? defaultProps.hourHandLength) ); const calculatedMinuteHandLength = Math.round( clockRadius * (minuteHandLength ?? defaultProps.minuteHandLength) ); const calculatedSecondHandLength = Math.round( clockRadius * (secondHandLength ?? defaultProps.secondHandLength) ); const centerSize = Math.round(size * 0.034); const tickOffset = Math.round(size * 0.028); const toClockAngle = (deg) => (deg % 360 + 360) % 360; const secAngleFromDate = (d) => { if (!d) { return 0; } const dt = timezone2 && timezone2 !== "" ? dayjs(d).tz(timezone2) : dayjs(d); const s = dt.second(); const ms = dt.millisecond(); return toClockAngle((s + ms / 1e3) * 6); }; const minAngleFromDate = (d) => { if (!d) { return 0; } const dt = timezone2 && timezone2 !== "" ? dayjs(d).tz(timezone2) : dayjs(d); const m = dt.minute(); return toClockAngle(m * 6); }; const hourAngleFromDate = (d) => { if (!d) { return 0; } const dt = timezone2 && timezone2 !== "" ? dayjs(d).tz(timezone2) : dayjs(d); const h = dt.hour() % 12; const m = dt.minute(); return toClockAngle(h * 30 + m * 0.5); }; const describeSector = (cx, cy, r, startDeg, endDeg, direction) => { const start = toClockAngle(startDeg); const end = toClockAngle(endDeg); let delta = 0; if (direction === "clockwise") { delta = end - start; if (delta < 0) { delta += 360; } } else { delta = start - end; if (delta < 0) { delta += 360; } } const largeArc = delta >= 180 ? 1 : 0; const sweep = direction === "clockwise" ? 1 : 0; const aStart = start * Math.PI / 180; const aEnd = end * Math.PI / 180; const x1 = cx + r * Math.sin(aStart); const y1 = cy - r * Math.cos(aStart); const x2 = cx + r * Math.sin(aEnd); const y2 = cy - r * Math.cos(aEnd); const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : "0"; return `M ${fmt(cx)} ${fmt(cy)} L ${fmt(x1)} ${fmt(y1)} A ${fmt(r)} ${fmt(r)} 0 ${largeArc} ${sweep} ${fmt(x2)} ${fmt(y2)} Z`; }; const showSecArc = withSecondsArc === true && (secondsArcOpacity ?? 1) !== 0; const showMinArc = withMinutesArc === true && (minutesArcOpacity ?? 1) !== 0; const showHrArc = withHoursArc === true && (hoursArcOpacity ?? 1) !== 0; return /* @__PURE__ */ React.createElement(Box, { ...getStyles("clockContainer") }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("glassWrapper") }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("clockFace") }, (showSecArc || showMinArc || showHrArc) && /* @__PURE__ */ React.createElement( "svg", { ...getStyles("arcsLayer", { style: { width: size, height: size } }), viewBox: `0 0 ${size} ${size}` }, showHrArc && /* @__PURE__ */ React.createElement( "path", { d: describeSector( clockRadius, clockRadius, calculatedHourHandLength, hourAngleFromDate(parseTimeValue(hoursArcFrom) ?? null), hourAngle, hoursArcDirection ), fill: "var(--clock-hours-arc-color-resolved)", fillOpacity: Math.round((hoursArcOpacity ?? 1) * 100) / 100 } ), showMinArc && /* @__PURE__ */ React.createElement( "path", { d: describeSector( clockRadius, clockRadius, calculatedMinuteHandLength, minAngleFromDate(parseTimeValue(minutesArcFrom) ?? null), minuteAngle, minutesArcDirection ), fill: "var(--clock-minutes-arc-color-resolved)", fillOpacity: Math.round((minutesArcOpacity ?? 1) * 100) / 100 } ), showSecArc && /* @__PURE__ */ React.createElement( "path", { d: describeSector( clockRadius, clockRadius, calculatedSecondHandLength, secAngleFromDate(parseTimeValue(secondsArcFrom) ?? null), secondAngle, secondsArcDirection ), fill: "var(--clock-seconds-arc-color-resolved)", fillOpacity: Math.round((secondsArcOpacity ?? 1) * 100) / 100 } ) ), /* @__PURE__ */ React.createElement(Box, { ...getStyles("hourMarks") }, hourTicksOpacity !== 0 && Array.from({ length: 12 }, (_, i) => /* @__PURE__ */ React.createElement( Box, { key: `hour-tick-${i}`, ...getStyles("hourTick", { style: { top: tickOffset, left: "50%", transformOrigin: `50% ${clockRadius - tickOffset}px`, transform: `translateX(-50%) rotate(${i * 30}deg)` } }) } )), minuteTicksOpacity !== 0 && Array.from({ length: 60 }, (_, i) => { if (i % 5 === 0) { return null; } return /* @__PURE__ */ React.createElement( Box, { key: `minute-tick-${i}`, ...getStyles("minuteTick", { style: { top: tickOffset, left: "50%", transformOrigin: `50% ${clockRadius - tickOffset}px`, transform: `translateX(-50%) rotate(${i * 6}deg)` } }) } ); }), primaryNumbersOpacity !== 0 && [12, 3, 6, 9].map((num) => { const i = num === 12 ? 0 : num; const angle = (i * 30 - 90) * (Math.PI / 180); const x = Math.round(clockRadius + Math.cos(angle) * numberRadius); const y = Math.round(clockRadius + Math.sin(angle) * numberRadius); return /* @__PURE__ */ React.createElement( Text, { key: `primary-number-${num}`, ...primaryNumbersProps, ...getStyles("primaryNumber", { className: getStyles("number").className, style: { left: x, top: y } }) }, num ); }), secondaryNumbersOpacity !== 0 && [1, 2, 4, 5, 7, 8, 10, 11].map((num) => { const i = num; const angle = (i * 30 - 90) * (Math.PI / 180); const x = Math.round(clockRadius + Math.cos(angle) * numberRadius); const y = Math.round(clockRadius + Math.sin(angle) * numberRadius); return /* @__PURE__ */ React.createElement( Text, { key: `secondary-number-${num}`, ...secondaryNumbersProps, ...getStyles("secondaryNumber", { className: getStyles("number").className, style: { left: x, top: y } }) }, num ); })), (hourHandOpacity ?? defaultProps.hourHandOpacity) !== 0 && /* @__PURE__ */ React.createElement( Box, { ...getStyles("hand", { className: getStyles("hourHand").className, style: { width: Math.round(size * (hourHandSize ?? defaultProps.hourHandSize) * 100) / 100, height: calculatedHourHandLength, opacity: Math.round((hourHandOpacity ?? defaultProps.hourHandOpacity) * 100) / 100, bottom: clockRadius, left: clockRadius, marginLeft: Math.round(-(size * (hourHandSize ?? defaultProps.hourHandSize)) / 2 * 100) / 100, borderRadius: `${Math.round(size * (hourHandSize ?? defaultProps.hourHandSize) * 100) / 100}px`, transform: `rotate(${Math.round(hourAngle * 100) / 100}deg)` } }) } ), (minuteHandOpacity ?? defaultProps.minuteHandOpacity) !== 0 && /* @__PURE__ */ React.createElement( Box, { ...getStyles("hand", { className: getStyles("minuteHand").className, style: { width: Math.round(size * (minuteHandSize ?? defaultProps.minuteHandSize) * 100) / 100, height: calculatedMinuteHandLength, opacity: Math.round((minuteHandOpacity ?? defaultProps.minuteHandOpacity) * 100) / 100, bottom: clockRadius, left: clockRadius, marginLeft: Math.round( -(size * (minuteHandSize ?? defaultProps.minuteHandSize)) / 2 * 100 ) / 100, borderRadius: `${Math.round(size * (minuteHandSize ?? defaultProps.minuteHandSize) * 100) / 100}px`, transform: `rotate(${Math.round(minuteAngle * 100) / 100}deg)` } }) } ), (secondHandOpacity ?? defaultProps.secondHandOpacity) !== 0 && /* @__PURE__ */ React.createElement( Box, { ...getStyles("secondHandContainer", { style: { width: Math.round(size * (secondHandSize ?? defaultProps.secondHandSize) * 100) / 100, height: calculatedSecondHandLength, top: clockRadius - calculatedSecondHandLength, left: clockRadius, marginLeft: Math.round( -(size * (secondHandSize ?? defaultProps.secondHandSize)) / 2 * 100 ) / 100, transformOrigin: `${Math.round(size * (secondHandSize ?? defaultProps.secondHandSize) / 2 * 100) / 100}px ${calculatedSecondHandLength}px`, transform: `rotate(${Math.round(secondAngle * 100) / 100}deg)` } }) }, /* @__PURE__ */ React.createElement( Box, { ...getStyles("secondHand", { style: { width: Math.round(size * (secondHandSize ?? defaultProps.secondHandSize) * 100) / 100, height: calculatedSecondHandLength, opacity: Math.round((secondHandOpacity ?? defaultProps.secondHandOpacity) * 100) / 100 } }) } ), /* @__PURE__ */ React.createElement( Box, { ...getStyles("secondHandCounterweight", { style: { width: Math.round(size * 6e-3 * 3 * 100) / 100, opacity: Math.round((secondHandOpacity ?? defaultProps.secondHandOpacity) * 100) / 100, left: Math.round( size * (secondHandSize ?? defaultProps.secondHandSize) / 2 - size * 6e-3 * 3 / 2 ) } }) } ) ), /* @__PURE__ */ React.createElement(Box, { ...getStyles("centerBlur") }), /* @__PURE__ */ React.createElement( Box, { ...getStyles("centerDot", { style: { width: centerSize, height: centerSize, opacity: Math.round((secondHandOpacity ?? defaultProps.secondHandOpacity) * 100) / 100, top: Math.round(clockRadius - centerSize / 2), left: Math.round(clockRadius - centerSize / 2) } }) } )))); }; const Clock = factory((_props, ref) => { const props = useProps("Clock", defaultProps, _props); const [time, setTime] = useState(/* @__PURE__ */ new Date()); const [hasMounted, setHasMounted] = useState(false); const intervalRef = useRef(null); const startTimeRef = useRef(null); const realStartTimeRef = useRef(null); const { // Clock-specific props that should not be passed to DOM size, color, hourTicksColor, hourTicksOpacity, minuteTicksColor, minuteTicksOpacity, primaryNumbersColor, primaryNumbersOpacity, secondaryNumbersColor, secondaryNumbersOpacity, secondHandBehavior, secondHandColor, secondHandOpacity, secondHandLength, secondHandSize, minuteHandColor, minuteHandOpacity, minuteHandSize, minuteHandLength, hourHandColor, hourHandOpacity, hourHandSize, hourHandLength, hourTicksOpacity: _hourTicksOpacity, minuteTicksOpacity: _minuteTicksOpacity, hourNumbersDistance, primaryNumbersProps, secondaryNumbersProps, timezone: timezone2, running, value, withSecondsArc, secondsArcFrom, secondsArcDirection, secondsArcColor, secondsArcOpacity, withMinutesArc, minutesArcFrom, minutesArcDirection, minutesArcColor, minutesArcOpacity, withHoursArc, hoursArcFrom, hoursArcDirection, hoursArcColor, hoursArcOpacity, // Styles API props classNames, style, styles, unstyled, vars, className, ...others } = props; const getStyles = useStyles({ name: "Clock", props, classes, className, style, classNames, styles, unstyled, vars, varsResolver }); const effectiveSize = Math.round( px( getSize( typeof size === "string" && size in defaultClockSizes ? defaultClockSizes[size] : size || defaultProps.size, "clock-size" ) ) ); useEffect(() => { setHasMounted(true); }, []); const getEffectiveTime = () => { const parsedValue = parseTimeValue(value); if (!running) { if (parsedValue) { return parsedValue; } return time; } if (parsedValue && startTimeRef.current && realStartTimeRef.current) { const now = /* @__PURE__ */ new Date(); const elapsed = now.getTime() - realStartTimeRef.current.getTime(); return new Date(startTimeRef.current.getTime() + elapsed); } return time; }; useEffect(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } if (!running) { return; } const parsedValue = parseTimeValue(value); if (parsedValue) { startTimeRef.current = parsedValue; realStartTimeRef.current = /* @__PURE__ */ new Date(); } else { startTimeRef.current = null; realStartTimeRef.current = null; } let interval = 1e3; switch (secondHandBehavior) { case "smooth": interval = 16; break; case "tick-half": interval = 500; break; case "tick-high-freq": interval = 125; break; case "tick": default: interval = 1e3; break; } const updateTime = () => { setTime(/* @__PURE__ */ new Date()); }; updateTime(); intervalRef.current = setInterval(updateTime, interval); return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [running, value, secondHandBehavior, secondHandOpacity]); if (!hasMounted) { return /* @__PURE__ */ React.createElement(Box, { ...getStyles("root"), ref, ...others }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("clockContainer") }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("glassWrapper") }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("clockFace") }, /* @__PURE__ */ React.createElement(Box, { ...getStyles("hourMarks") }, (hourTicksOpacity || 1) !== 0 && Array.from({ length: 12 }, (_, i) => /* @__PURE__ */ React.createElement( Box, { key: `hour-tick-${i}`, ...getStyles("hourTick", { style: { top: Math.round(effectiveSize * 0.028), left: "50%", transformOrigin: `50% ${Math.round(effectiveSize / 2) - Math.round(effectiveSize * 0.028)}px`, transform: `translateX(-50%) rotate(${i * 30}deg)` } }) } )), (minuteTicksOpacity || 1) !== 0 && Array.from({ length: 60 }, (_, i) => { if (i % 5 === 0) { return null; } return /* @__PURE__ */ React.createElement( Box, { key: `minute-tick-${i}`, ...getStyles("minuteTick", { style: { top: Math.round(effectiveSize * 0.028), left: "50%", transformOrigin: `50% ${Math.round(effectiveSize / 2) - Math.round(effectiveSize * 0.028)}px`, transform: `translateX(-50%) rotate(${i * 6}deg)` } }) } ); }), (primaryNumbersOpacity || 1) !== 0 && [12, 3, 6, 9].map((num) => { const i = num === 12 ? 0 : num; const angle = (i * 30 - 90) * (Math.PI / 180); const clockRadius = Math.round(effectiveSize / 2); const numberRadius = Math.round(clockRadius * (hourNumbersDistance || 0.75)); const x = Math.round(clockRadius + Math.cos(angle) * numberRadius); const y = Math.round(clockRadius + Math.sin(angle) * numberRadius); return /* @__PURE__ */ React.createElement( Text, { key: `primary-number-${num}`, ...primaryNumbersProps, ...getStyles("primaryNumber", { className: getStyles("number").className, style: { left: x, top: y } }) }, num ); }), (secondaryNumbersOpacity || 1) !== 0 && [1, 2, 4, 5, 7, 8, 10, 11].map((num) => { const i = num; const angle = (i * 30 - 90) * (Math.PI / 180); const clockRadius = Math.round(effectiveSize / 2); const numberRadius = Math.round(clockRadius * (hourNumbersDistance || 0.75)); const x = Math.round(clockRadius + Math.cos(angle) * numberRadius); const y = Math.round(clockRadius + Math.sin(angle) * numberRadius); return /* @__PURE__ */ React.createElement( Text, { key: `secondary-number-${num}`, ...secondaryNumbersProps, ...getStyles("secondaryNumber", { className: getStyles("number").className, style: { left: x, top: y } }) }, num ); })))))); } const effectiveTime = getEffectiveTime(); return /* @__PURE__ */ React.createElement(Box, { ...getStyles("root"), ref, ...others }, /* @__PURE__ */ React.createElement( RealClock, { time: effectiveTime, getStyles, effectiveSize, ...props } )); }); Clock.classes = classes; Clock.displayName = "Clock"; export { Clock, defaultProps }; //# sourceMappingURL=Clock.mjs.map