{ "name": "gauge", "type": "registry:ui", "files": [ { "path": "ui/gauge.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst GAUGE_NAME = \"Gauge\";\nconst INDICATOR_NAME = \"GaugeIndicator\";\nconst TRACK_NAME = \"GaugeTrack\";\nconst RANGE_NAME = \"GaugeRange\";\nconst VALUE_TEXT_NAME = \"GaugeValueText\";\nconst LABEL_NAME = \"GaugeLabel\";\n\nconst DEFAULT_MAX = 100;\nconst DEFAULT_START_ANGLE = 0;\nconst DEFAULT_END_ANGLE = 360;\n\ntype GaugeState = \"indeterminate\" | \"complete\" | \"loading\";\n\ninterface DivProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\ninterface PathProps extends React.ComponentProps<\"path\"> {}\n\nfunction getGaugeState(\n value: number | undefined | null,\n maxValue: number,\n): GaugeState {\n return value == null\n ? \"indeterminate\"\n : value === maxValue\n ? \"complete\"\n : \"loading\";\n}\n\nfunction getIsValidNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction getIsValidMaxNumber(max: unknown): max is number {\n return getIsValidNumber(max) && max > 0;\n}\n\nfunction getIsValidValueNumber(\n value: unknown,\n min: number,\n max: number,\n): value is number {\n return getIsValidNumber(value) && value <= max && value >= min;\n}\n\nfunction getDefaultValueText(value: number, min: number, max: number): string {\n const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100;\n return Math.round(percentage).toString();\n}\n\nfunction getInvalidValueError(\n propValue: string,\n componentName: string,\n): string {\n return `Invalid prop \\`value\\` of value \\`${propValue}\\` supplied to \\`${componentName}\\`. The \\`value\\` prop must be a number between \\`min\\` and \\`max\\` (inclusive), or \\`null\\`/\\`undefined\\` for indeterminate state. The value will be clamped to the valid range.`;\n}\n\nfunction getInvalidMaxError(propValue: string, componentName: string): string {\n return `Invalid prop \\`max\\` of value \\`${propValue}\\` supplied to \\`${componentName}\\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`;\n}\n\nfunction getNormalizedAngle(angle: number) {\n return ((angle % 360) + 360) % 360;\n}\n\nfunction polarToCartesian(\n centerX: number,\n centerY: number,\n radius: number,\n angleInDegrees: number,\n) {\n const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;\n return {\n x: centerX + radius * Math.cos(angleInRadians),\n y: centerY + radius * Math.sin(angleInRadians),\n };\n}\n\nfunction describeArc(\n x: number,\n y: number,\n radius: number,\n startAngle: number,\n endAngle: number,\n) {\n const angleDiff = endAngle - startAngle;\n\n // For full circles (360 degrees), draw as two semi-circles\n if (Math.abs(angleDiff) >= 360) {\n const start = polarToCartesian(x, y, radius, startAngle);\n const mid = polarToCartesian(x, y, radius, startAngle + 180);\n return [\n \"M\",\n start.x,\n start.y,\n \"A\",\n radius,\n radius,\n 0,\n 0,\n 1,\n mid.x,\n mid.y,\n \"A\",\n radius,\n radius,\n 0,\n 0,\n 1,\n start.x,\n start.y,\n ].join(\" \");\n }\n\n const start = polarToCartesian(x, y, radius, startAngle);\n const end = polarToCartesian(x, y, radius, endAngle);\n const largeArcFlag = angleDiff <= 180 ? \"0\" : \"1\";\n\n return [\n \"M\",\n start.x,\n start.y,\n \"A\",\n radius,\n radius,\n 0,\n largeArcFlag,\n 1,\n end.x,\n end.y,\n ].join(\" \");\n}\n\ninterface GaugeContextValue {\n value: number | null;\n valueText: string | undefined;\n max: number;\n min: number;\n state: GaugeState;\n radius: number;\n thickness: number;\n size: number;\n center: number;\n percentage: number | null;\n startAngle: number;\n endAngle: number;\n arcLength: number;\n arcCenterY: number;\n valueTextId?: string;\n labelId?: string;\n}\n\nconst GaugeContext = React.createContext(null);\n\nfunction useGaugeContext(consumerName: string) {\n const context = React.useContext(GaugeContext);\n if (!context) {\n throw new Error(\n `\\`${consumerName}\\` must be used within \\`${GAUGE_NAME}\\``,\n );\n }\n return context;\n}\n\ninterface GaugeProps extends DivProps {\n value?: number | null | undefined;\n getValueText?(value: number, min: number, max: number): string;\n min?: number;\n max?: number;\n size?: number;\n thickness?: number;\n startAngle?: number;\n endAngle?: number;\n}\n\nfunction Gauge(props: GaugeProps) {\n const {\n value: valueProp = null,\n getValueText = getDefaultValueText,\n min: minProp = 0,\n max: maxProp,\n size = 120,\n thickness = 8,\n startAngle = DEFAULT_START_ANGLE,\n endAngle = DEFAULT_END_ANGLE,\n render,\n className,\n children,\n ...rootProps\n } = props;\n\n if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.error(getInvalidMaxError(`${maxProp}`, GAUGE_NAME));\n }\n }\n\n const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX;\n const min = getIsValidNumber(minProp) ? minProp : 0;\n const max = rawMax <= min ? min + 1 : rawMax;\n\n if (process.env.NODE_ENV !== \"production\" && thickness >= size) {\n console.warn(\n `Gauge: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,\n );\n }\n\n if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.error(getInvalidValueError(`${valueProp}`, GAUGE_NAME));\n }\n }\n\n const value = getIsValidValueNumber(valueProp, min, max)\n ? valueProp\n : getIsValidNumber(valueProp) && valueProp > max\n ? max\n : getIsValidNumber(valueProp) && valueProp < min\n ? min\n : null;\n\n const valueText = getIsValidNumber(value)\n ? getValueText(value, min, max)\n : undefined;\n const state = getGaugeState(value, max);\n const radius = Math.max(0, (size - thickness) / 2);\n const center = size / 2;\n\n const angleDiff = Math.abs(endAngle - startAngle);\n const arcLength = (Math.min(angleDiff, 360) / 360) * (2 * Math.PI * radius);\n\n const percentage = getIsValidNumber(value)\n ? max === min\n ? 1\n : (value - min) / (max - min)\n : null;\n\n // Calculate the visual center Y of the arc for text positioning\n // For full circles, use geometric center. For partial arcs, calculate based on bounding box\n const angleDiffDeg = Math.abs(endAngle - startAngle);\n const isFullCircle = angleDiffDeg >= 360;\n\n let arcCenterY = center;\n if (!isFullCircle) {\n const startRad = (startAngle * Math.PI) / 180;\n const endRad = (endAngle * Math.PI) / 180;\n\n const startY = center - radius * Math.cos(startRad);\n const endY = center - radius * Math.cos(endRad);\n\n let minY = Math.min(startY, endY);\n let maxY = Math.max(startY, endY);\n\n const normStart = getNormalizedAngle(startAngle);\n const normEnd = getNormalizedAngle(endAngle);\n\n const includesTop =\n normStart > normEnd\n ? normStart <= 270 || normEnd >= 270\n : normStart <= 270 && normEnd >= 270;\n const includesBottom =\n normStart > normEnd\n ? normStart <= 90 || normEnd >= 90\n : normStart <= 90 && normEnd >= 90;\n\n if (includesTop) minY = Math.min(minY, center - radius);\n if (includesBottom) maxY = Math.max(maxY, center + radius);\n\n arcCenterY = (minY + maxY) / 2;\n }\n\n const labelId = React.useId();\n const valueTextId = React.useId();\n\n const contextValue = React.useMemo(\n () => ({\n value,\n valueText,\n max,\n min,\n state,\n radius,\n thickness,\n size,\n center,\n percentage,\n startAngle,\n endAngle,\n arcLength,\n arcCenterY,\n valueTextId,\n labelId,\n }),\n [\n value,\n valueText,\n max,\n min,\n state,\n radius,\n thickness,\n size,\n center,\n percentage,\n startAngle,\n endAngle,\n arcLength,\n arcCenterY,\n valueTextId,\n labelId,\n ],\n );\n\n return (\n \n {useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"meter\",\n \"aria-describedby\": valueText ? valueTextId : undefined,\n \"aria-labelledby\": labelId,\n \"aria-valuemax\": max,\n \"aria-valuemin\": min,\n \"aria-valuenow\": getIsValidNumber(value) ? value : undefined,\n \"aria-valuetext\": valueText,\n className: cn(\n \"relative inline-flex w-fit flex-col items-center justify-center\",\n className,\n ),\n children,\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"gauge\",\n state,\n value: value ?? undefined,\n max,\n min,\n percentage,\n },\n })}\n \n );\n}\n\nfunction GaugeIndicator(props: React.ComponentProps<\"svg\">) {\n const { className, ...indicatorProps } = props;\n\n const { size, state, value, max, min, percentage } =\n useGaugeContext(INDICATOR_NAME);\n\n return (\n \n );\n}\n\nfunction GaugeTrack(props: PathProps) {\n const { className, ...trackProps } = props;\n\n const { center, radius, startAngle, endAngle, thickness, state } =\n useGaugeContext(TRACK_NAME);\n\n const pathData = describeArc(center, center, radius, startAngle, endAngle);\n\n return (\n \n );\n}\n\nfunction GaugeRange(props: PathProps) {\n const { className, ...rangeProps } = props;\n\n const {\n center,\n radius,\n startAngle,\n endAngle,\n value,\n max,\n min,\n state,\n thickness,\n arcLength,\n percentage,\n } = useGaugeContext(RANGE_NAME);\n\n const pathData = describeArc(center, center, radius, startAngle, endAngle);\n\n const strokeDasharray = arcLength;\n const strokeDashoffset =\n state === \"indeterminate\"\n ? 0\n : percentage !== null\n ? arcLength - percentage * arcLength\n : arcLength;\n\n return (\n \n );\n}\n\nfunction GaugeValueText(props: DivProps) {\n const { render, className, children, style, ...valueTextProps } = props;\n\n const { valueTextId, state, arcCenterY, valueText } =\n useGaugeContext(VALUE_TEXT_NAME);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n id: valueTextId,\n style: {\n top: `${arcCenterY}px`,\n ...style,\n },\n className: cn(\n \"absolute right-0 left-0 flex -translate-y-1/2 items-center justify-center font-semibold text-2xl\",\n className,\n ),\n children: children ?? valueText,\n },\n valueTextProps,\n ),\n render,\n state: {\n slot: \"gauge-value-text\",\n state,\n },\n });\n}\n\nfunction GaugeLabel(props: DivProps) {\n const { render, className, ...labelProps } = props;\n\n const { labelId, state } = useGaugeContext(LABEL_NAME);\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n id: labelId,\n className: cn(\n \"mt-2 font-medium text-muted-foreground text-sm\",\n className,\n ),\n },\n labelProps,\n ),\n render,\n state: {\n slot: \"gauge-label\",\n state,\n },\n });\n}\n\nfunction GaugeCombined(props: GaugeProps) {\n return (\n \n \n \n \n \n \n \n );\n}\n\nexport {\n Gauge,\n GaugeCombined,\n GaugeIndicator,\n GaugeLabel,\n type GaugeProps,\n GaugeRange,\n GaugeTrack,\n GaugeValueText,\n};\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "dependencies": [ "@base-ui/react" ] }