@watt-ui/react 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +9 -9
  3. package/dist/chart/category-engine.d.ts +2 -0
  4. package/dist/chart/category-engine.d.ts.map +1 -0
  5. package/dist/chart/category-engine.js +9 -0
  6. package/dist/chart/category-engine.js.map +1 -0
  7. package/dist/chart/category-options.d.ts +23 -0
  8. package/dist/chart/category-options.d.ts.map +1 -0
  9. package/dist/chart/category-options.js +120 -0
  10. package/dist/chart/category-options.js.map +1 -0
  11. package/dist/components/asset-tree.d.ts.map +1 -1
  12. package/dist/components/asset-tree.js +16 -2
  13. package/dist/components/asset-tree.js.map +1 -1
  14. package/dist/components/battery-soc.d.ts.map +1 -1
  15. package/dist/components/battery-soc.js +5 -1
  16. package/dist/components/battery-soc.js.map +1 -1
  17. package/dist/components/category-chart.d.ts +37 -0
  18. package/dist/components/category-chart.d.ts.map +1 -0
  19. package/dist/components/category-chart.js +251 -0
  20. package/dist/components/category-chart.js.map +1 -0
  21. package/dist/components/energy-flow.d.ts +2 -1
  22. package/dist/components/energy-flow.d.ts.map +1 -1
  23. package/dist/components/energy-flow.js +2 -2
  24. package/dist/components/energy-flow.js.map +1 -1
  25. package/dist/components/equipment-card.d.ts +12 -0
  26. package/dist/components/equipment-card.d.ts.map +1 -0
  27. package/dist/components/equipment-card.js +18 -0
  28. package/dist/components/equipment-card.js.map +1 -0
  29. package/dist/components/equipment-heatmap.d.ts +33 -0
  30. package/dist/components/equipment-heatmap.d.ts.map +1 -0
  31. package/dist/components/equipment-heatmap.js +161 -0
  32. package/dist/components/equipment-heatmap.js.map +1 -0
  33. package/dist/components/equipment-table.d.ts +34 -0
  34. package/dist/components/equipment-table.d.ts.map +1 -0
  35. package/dist/components/equipment-table.js +141 -0
  36. package/dist/components/equipment-table.js.map +1 -0
  37. package/dist/components/metric-trend.d.ts +13 -0
  38. package/dist/components/metric-trend.d.ts.map +1 -0
  39. package/dist/components/metric-trend.js +30 -0
  40. package/dist/components/metric-trend.js.map +1 -0
  41. package/dist/components/operating-timeline.d.ts +31 -0
  42. package/dist/components/operating-timeline.d.ts.map +1 -0
  43. package/dist/components/operating-timeline.js +61 -0
  44. package/dist/components/operating-timeline.js.map +1 -0
  45. package/dist/components/plant-fleet-map.d.ts +32 -0
  46. package/dist/components/plant-fleet-map.d.ts.map +1 -0
  47. package/dist/components/plant-fleet-map.js +56 -0
  48. package/dist/components/plant-fleet-map.js.map +1 -0
  49. package/dist/components/threshold-gauge.d.ts +19 -0
  50. package/dist/components/threshold-gauge.d.ts.map +1 -0
  51. package/dist/components/threshold-gauge.js +54 -0
  52. package/dist/components/threshold-gauge.js.map +1 -0
  53. package/dist/components/time-range-selector.d.ts +16 -0
  54. package/dist/components/time-range-selector.d.ts.map +1 -0
  55. package/dist/components/time-range-selector.js +47 -0
  56. package/dist/components/time-range-selector.js.map +1 -0
  57. package/dist/icons/index.d.ts +17 -1
  58. package/dist/icons/index.d.ts.map +1 -1
  59. package/dist/icons/index.js +98 -1
  60. package/dist/icons/index.js.map +1 -1
  61. package/dist/index.d.ts +19 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +9 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/map/land.d.ts +2 -0
  66. package/dist/map/land.d.ts.map +1 -0
  67. package/dist/map/land.js +3 -0
  68. package/dist/map/land.js.map +1 -0
  69. package/dist/server.d.ts +5 -0
  70. package/dist/server.d.ts.map +1 -1
  71. package/dist/server.js +21 -1
  72. package/dist/server.js.map +1 -1
  73. package/dist/styles.css +133 -4
  74. package/dist/virtual-focus.d.ts +3 -0
  75. package/dist/virtual-focus.d.ts.map +1 -0
  76. package/dist/virtual-focus.js +23 -0
  77. package/dist/virtual-focus.js.map +1 -0
  78. package/package.json +37 -1
  79. package/src/chart/category-engine.ts +8 -0
  80. package/src/chart/category-options.ts +158 -0
  81. package/src/components/asset-tree.tsx +21 -5
  82. package/src/components/battery-soc.tsx +8 -0
  83. package/src/components/category-chart.tsx +452 -0
  84. package/src/components/energy-flow.tsx +3 -1
  85. package/src/components/equipment-card.tsx +46 -0
  86. package/src/components/equipment-heatmap.tsx +298 -0
  87. package/src/components/equipment-table.tsx +302 -0
  88. package/src/components/metric-trend.tsx +87 -0
  89. package/src/components/operating-timeline.tsx +182 -0
  90. package/src/components/plant-fleet-map.tsx +173 -0
  91. package/src/components/threshold-gauge.tsx +152 -0
  92. package/src/components/time-range-selector.tsx +126 -0
  93. package/src/icons/index.tsx +107 -0
  94. package/src/index.ts +44 -0
  95. package/src/map/land.ts +2 -0
  96. package/src/server.ts +28 -1
  97. package/src/styles.css +133 -4
  98. package/src/virtual-focus.ts +27 -0
@@ -0,0 +1,182 @@
1
+ "use client";
2
+ import { forwardRef, useMemo, useState } from "react";
3
+ import { cx, part } from "../internal.js";
4
+ import { parseTimestamp } from "../domain.js";
5
+ import { motionStyle } from "../motion.js";
6
+ import type { Timestamp, TimeRange, Density, MotionProps } from "../types.js";
7
+ import type { ForwardedRef, ReactElement, RefAttributes } from "react";
8
+ export interface TimelineTrack {
9
+ id: string;
10
+ label: React.ReactNode;
11
+ }
12
+ export interface TimelineEvent {
13
+ id: string;
14
+ trackId: string;
15
+ start: Timestamp;
16
+ end: Timestamp;
17
+ label: string;
18
+ color?: string;
19
+ }
20
+ export interface OperatingTimelineProps<T extends TimelineEvent = TimelineEvent>
21
+ extends
22
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onSelect">,
23
+ MotionProps {
24
+ events: readonly T[];
25
+ tracks: readonly TimelineTrack[];
26
+ range: TimeRange;
27
+ timeZone: string;
28
+ selectedId?: string | null;
29
+ onEventSelect?: (event: T) => void;
30
+ children?: (event: T) => React.ReactNode;
31
+ density?: Density;
32
+ timeFormatter?: (timestamp: number) => string;
33
+ empty?: React.ReactNode;
34
+ }
35
+ function Root<T extends TimelineEvent>(
36
+ {
37
+ events,
38
+ tracks,
39
+ range,
40
+ timeZone,
41
+ selectedId,
42
+ onEventSelect,
43
+ children,
44
+ density = "comfortable",
45
+ timeFormatter,
46
+ empty,
47
+ motion,
48
+ style,
49
+ className,
50
+ ...props
51
+ }: OperatingTimelineProps<T>,
52
+ ref: ForwardedRef<HTMLDivElement>,
53
+ ) {
54
+ const [internal, setInternal] = useState<string | null>(null);
55
+ const selected = selectedId === undefined ? internal : selectedId;
56
+ const grouped = useMemo(() => {
57
+ if (
58
+ !Number.isFinite(range[0]) ||
59
+ !Number.isFinite(range[1]) ||
60
+ range[1] <= range[0]
61
+ )
62
+ throw new Error("OperatingTimeline requires finite start < end");
63
+ const groups = new Map(
64
+ tracks.map((track) => [
65
+ track.id,
66
+ [] as { event: T; start: number; end: number }[],
67
+ ]),
68
+ );
69
+ if (groups.size !== tracks.length || tracks.some((track) => !track.id))
70
+ throw new Error("Timeline track IDs must be nonempty and unique");
71
+ const ids = new Set<string>();
72
+ for (const event of events) {
73
+ const start = parseTimestamp(event.start),
74
+ end = parseTimestamp(event.end);
75
+ if (
76
+ !event.id ||
77
+ ids.has(event.id) ||
78
+ start === null ||
79
+ end === null ||
80
+ end < start ||
81
+ !groups.has(event.trackId)
82
+ )
83
+ throw new Error(
84
+ "Timeline events need unique IDs, a known track and valid start <= end",
85
+ );
86
+ ids.add(event.id);
87
+ if (end >= range[0] && start <= range[1])
88
+ groups.get(event.trackId)!.push({ event, start, end });
89
+ }
90
+ return groups;
91
+ }, [events, tracks, range]);
92
+ const label =
93
+ timeFormatter ??
94
+ new Intl.DateTimeFormat("en-GB", {
95
+ timeZone,
96
+ dateStyle: "short",
97
+ timeStyle: "short",
98
+ hour12: false,
99
+ }).format;
100
+ const rowHeight = density === "compact" ? 40 : 52;
101
+ return (
102
+ <div
103
+ {...props}
104
+ ref={ref}
105
+ data-slot="operating-timeline"
106
+ tabIndex={props.tabIndex ?? 0}
107
+ data-density={density}
108
+ className={cx("watt-root overflow-auto text-sm", className)}
109
+ style={{ ...style, ...motionStyle(motion) }}
110
+ >
111
+ <div className="min-w-[280px]">
112
+ <div
113
+ data-slot="operating-timeline-range"
114
+ className="mb-3 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2 pl-28 text-xs text-[var(--watt-muted)]"
115
+ >
116
+ <span>{label(range[0])}</span>
117
+ <span>{timeZone}</span>
118
+ <span className="text-right">{label(range[1])}</span>
119
+ </div>
120
+ {!events.length && empty}
121
+ {tracks.map((track) => (
122
+ <div
123
+ key={track.id}
124
+ data-slot="operating-timeline-track"
125
+ className="grid grid-cols-[7rem_minmax(0,1fr)] items-center border-b border-[var(--watt-border)] last:border-0"
126
+ style={{ minHeight: rowHeight }}
127
+ >
128
+ <span className="pr-3 text-xs">{track.label}</span>
129
+ <div
130
+ role="list"
131
+ aria-label={
132
+ typeof track.label === "string" ? track.label : track.id
133
+ }
134
+ className="relative"
135
+ style={{ height: rowHeight }}
136
+ >
137
+ {grouped.get(track.id)!.map(({ event, start, end }) => (
138
+ <div
139
+ key={event.id}
140
+ role="listitem"
141
+ className="watt-motion watt-timeline-event absolute inset-y-2"
142
+ style={{
143
+ left: `clamp(0px, ${((Math.max(start, range[0]) - range[0]) / (range[1] - range[0])) * 100}%, calc(100% - 16px))`,
144
+ minWidth: 16,
145
+ width: `${Math.max(0.6, ((Math.min(end, range[1]) - Math.max(start, range[0])) / (range[1] - range[0])) * 100)}%`,
146
+ }}
147
+ >
148
+ <button
149
+ type="button"
150
+ data-slot="operating-timeline-event"
151
+ data-selected={selected === event.id || undefined}
152
+ aria-pressed={selected === event.id}
153
+ aria-label={`${event.label}, ${label(start)}–${label(end)} ${timeZone}`}
154
+ className="h-full w-full overflow-hidden rounded-sm border border-[var(--watt-border)] px-1.5 text-left text-xs whitespace-nowrap outline-offset-2 data-[selected]:outline-2 data-[selected]:outline-[var(--watt-accent)] focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)]"
155
+ style={{
156
+ backgroundColor: event.color ?? "var(--watt-subtle)",
157
+ }}
158
+ onClick={() => {
159
+ if (selectedId === undefined) setInternal(event.id);
160
+ onEventSelect?.(event);
161
+ }}
162
+ >
163
+ {children ? children(event) : event.label}
164
+ </button>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+ export const OperatingTimeline = {
175
+ Root: forwardRef(Root) as <T extends TimelineEvent>(
176
+ props: OperatingTimelineProps<T> & RefAttributes<HTMLDivElement>,
177
+ ) => ReactElement,
178
+ Legend: part(
179
+ "operating-timeline-legend",
180
+ "flex flex-wrap gap-4 text-xs text-[var(--watt-muted)]",
181
+ ),
182
+ };
@@ -0,0 +1,173 @@
1
+ "use client";
2
+ import { forwardRef, useMemo, useState } from "react";
3
+ import { cx, part } from "../internal.js";
4
+ import { motionStyle } from "../motion.js";
5
+ import { landPath } from "../map/land.js";
6
+ import type {
7
+ DataQuality,
8
+ Density,
9
+ EquipmentStatus,
10
+ MotionProps,
11
+ } from "../types.js";
12
+ import type { ForwardedRef, ReactElement, RefAttributes } from "react";
13
+ export interface FleetSite {
14
+ id: string;
15
+ label: string;
16
+ longitude: number;
17
+ latitude: number;
18
+ status?: EquipmentStatus;
19
+ quality?: DataQuality;
20
+ }
21
+ export type MapBounds = readonly [
22
+ west: number,
23
+ south: number,
24
+ east: number,
25
+ north: number,
26
+ ];
27
+ export interface PlantFleetMapProps<T extends FleetSite = FleetSite>
28
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">, MotionProps {
29
+ sites: readonly T[];
30
+ density?: Density;
31
+ bounds?: MapBounds;
32
+ selectedId?: string | null;
33
+ defaultSelectedId?: string | null;
34
+ onSelectionChange?: (site: T) => void;
35
+ children?: (site: T, selected: boolean) => React.ReactNode;
36
+ background?: React.ReactNode;
37
+ empty?: React.ReactNode;
38
+ }
39
+ const WORLD: MapBounds = [-180, -75, 180, 85];
40
+ function Root<T extends FleetSite>(
41
+ {
42
+ sites,
43
+ bounds = WORLD,
44
+ selectedId,
45
+ defaultSelectedId = null,
46
+ onSelectionChange,
47
+ children,
48
+ background,
49
+ empty,
50
+ density = "comfortable",
51
+ motion,
52
+ style,
53
+ className,
54
+ ...props
55
+ }: PlantFleetMapProps<T>,
56
+ ref: ForwardedRef<HTMLDivElement>,
57
+ ) {
58
+ const [internal, setInternal] = useState(defaultSelectedId);
59
+ const selected = selectedId === undefined ? internal : selectedId;
60
+ const visible = useMemo(() => {
61
+ const [west, south, east, north] = bounds;
62
+ if (
63
+ !bounds.every(Number.isFinite) ||
64
+ west < -180 ||
65
+ east > 180 ||
66
+ south < -90 ||
67
+ north > 90 ||
68
+ west >= east ||
69
+ south >= north
70
+ )
71
+ throw new Error(
72
+ "Map bounds must be finite west < east and south < north within longitude/latitude limits",
73
+ );
74
+ const ids = new Set<string>();
75
+ for (const site of sites) {
76
+ if (
77
+ !site.id ||
78
+ ids.has(site.id) ||
79
+ !Number.isFinite(site.longitude) ||
80
+ Math.abs(site.longitude) > 180 ||
81
+ !Number.isFinite(site.latitude) ||
82
+ Math.abs(site.latitude) > 90
83
+ )
84
+ throw new Error(
85
+ "Map sites need unique IDs and valid longitude/latitude",
86
+ );
87
+ ids.add(site.id);
88
+ }
89
+ return sites.filter(
90
+ (site) =>
91
+ site.longitude >= west &&
92
+ site.longitude <= east &&
93
+ site.latitude >= south &&
94
+ site.latitude <= north,
95
+ );
96
+ }, [sites, bounds]);
97
+ return (
98
+ <div
99
+ {...props}
100
+ ref={ref}
101
+ data-slot="plant-fleet-map"
102
+ data-density={density}
103
+ className={cx(
104
+ "watt-root relative isolate overflow-hidden rounded-sm bg-[var(--watt-subtle)]",
105
+ className,
106
+ )}
107
+ style={{
108
+ aspectRatio: (bounds[2] - bounds[0]) / (bounds[3] - bounds[1]),
109
+ ...style,
110
+ ...motionStyle(motion),
111
+ }}
112
+ >
113
+ {background ?? (
114
+ <svg
115
+ aria-hidden="true"
116
+ className="absolute inset-0 h-full w-full"
117
+ preserveAspectRatio="none"
118
+ viewBox={`${(bounds[0] + 180) * 2} ${(90 - bounds[3]) * 2} ${(bounds[2] - bounds[0]) * 2} ${(bounds[3] - bounds[1]) * 2}`}
119
+ >
120
+ <path
121
+ d={landPath}
122
+ fill="var(--watt-surface)"
123
+ stroke="var(--watt-border)"
124
+ strokeWidth=".7"
125
+ vectorEffect="non-scaling-stroke"
126
+ />
127
+ </svg>
128
+ )}
129
+ {!visible.length && <div className="relative p-6 text-sm">{empty}</div>}
130
+ {visible.map((site) => (
131
+ <button
132
+ type="button"
133
+ key={site.id}
134
+ data-slot="plant-fleet-map-site"
135
+ data-selected={selected === site.id || undefined}
136
+ data-status={site.status}
137
+ data-quality={site.quality}
138
+ aria-label={[site.label, site.status, site.quality]
139
+ .filter(Boolean)
140
+ .join(", ")}
141
+ aria-pressed={selected === site.id}
142
+ title={site.label}
143
+ className="watt-motion watt-choice absolute z-10 inline-flex min-h-9 min-w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)] data-[selected]:outline-2 data-[selected]:outline-[var(--watt-accent)]"
144
+ style={{
145
+ left: `${((site.longitude - bounds[0]) / (bounds[2] - bounds[0])) * 100}%`,
146
+ top: `${((bounds[3] - site.latitude) / (bounds[3] - bounds[1])) * 100}%`,
147
+ }}
148
+ onClick={() => {
149
+ if (selectedId === undefined) setInternal(site.id);
150
+ onSelectionChange?.(site);
151
+ }}
152
+ >
153
+ {children ? (
154
+ children(site, selected === site.id)
155
+ ) : (
156
+ <span className="watt-status" data-status={site.status}>
157
+ <span className="watt-status-dot" />
158
+ </span>
159
+ )}
160
+ </button>
161
+ ))}
162
+ </div>
163
+ );
164
+ }
165
+ export const PlantFleetMap = {
166
+ Root: forwardRef(Root) as <T extends FleetSite>(
167
+ props: PlantFleetMapProps<T> & RefAttributes<HTMLDivElement>,
168
+ ) => ReactElement,
169
+ Legend: part(
170
+ "plant-fleet-map-legend",
171
+ "flex flex-wrap gap-4 text-xs text-[var(--watt-muted)]",
172
+ ),
173
+ };
@@ -0,0 +1,152 @@
1
+ "use client";
2
+ import { createContext, forwardRef, useContext } from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cx, part } from "../internal.js";
5
+ import { MotionProvider, motionStyle } from "../motion.js";
6
+ import type { RootProps, MotionProps, PartProps } from "../types.js";
7
+ export interface ThresholdGaugeProps extends RootProps, MotionProps {
8
+ value: number | null;
9
+ min?: number;
10
+ max: number;
11
+ valueText?: string;
12
+ tone?: "neutral" | "positive" | "warning" | "critical";
13
+ }
14
+ interface GaugeState {
15
+ min: number;
16
+ max: number;
17
+ value: number | null;
18
+ percent: number;
19
+ valueText?: string;
20
+ }
21
+ const Context = createContext<GaugeState | null>(null);
22
+ function useGauge() {
23
+ const context = useContext(Context);
24
+ if (!context)
25
+ throw new Error("ThresholdGauge parts require ThresholdGauge.Root");
26
+ return context;
27
+ }
28
+ export const ThresholdGauge = {
29
+ Root: forwardRef<HTMLElement, ThresholdGaugeProps>(function GaugeRoot(
30
+ {
31
+ value,
32
+ min = 0,
33
+ max,
34
+ valueText,
35
+ tone = "neutral",
36
+ density = "comfortable",
37
+ motion,
38
+ style,
39
+ asChild,
40
+ className,
41
+ ...props
42
+ },
43
+ ref,
44
+ ) {
45
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
46
+ throw new Error("ThresholdGauge requires finite min < max");
47
+ const valid =
48
+ value !== null && Number.isFinite(value) && value >= min && value <= max;
49
+ const Component = asChild ? Slot : "div";
50
+ return (
51
+ <MotionProvider motion={motion}>
52
+ <Context.Provider
53
+ value={{
54
+ min,
55
+ max,
56
+ value: valid ? value : null,
57
+ valueText,
58
+ percent: valid ? ((value - min) / (max - min)) * 100 : 0,
59
+ }}
60
+ >
61
+ <Component
62
+ {...props}
63
+ ref={ref as never}
64
+ data-slot="threshold-gauge"
65
+ data-density={density}
66
+ data-tone={tone}
67
+ data-invalid={!valid || undefined}
68
+ style={{ ...style, ...motionStyle(motion) }}
69
+ className={cx("watt-root", className)}
70
+ />
71
+ </Context.Provider>
72
+ </MotionProvider>
73
+ );
74
+ }),
75
+ Track: forwardRef<HTMLElement, PartProps>(function GaugeTrack(
76
+ { asChild, className, ...props },
77
+ ref,
78
+ ) {
79
+ const { value, min, max, valueText } = useGauge();
80
+ const Component = asChild ? Slot : "div";
81
+ return (
82
+ <Component
83
+ {...props}
84
+ ref={ref as never}
85
+ data-slot="threshold-gauge-track"
86
+ role={value === null ? "img" : "meter"}
87
+ aria-valuemin={value === null ? undefined : min}
88
+ aria-valuemax={value === null ? undefined : max}
89
+ aria-valuenow={value ?? undefined}
90
+ aria-valuetext={
91
+ value === null ? undefined : (props["aria-valuetext"] ?? valueText)
92
+ }
93
+ aria-description={
94
+ value === null
95
+ ? (props["aria-valuetext"] ?? valueText ?? "Unavailable")
96
+ : props["aria-description"]
97
+ }
98
+ className={cx(
99
+ "relative h-2.5 rounded-sm bg-[var(--watt-subtle)]",
100
+ className,
101
+ )}
102
+ />
103
+ );
104
+ }),
105
+ Fill: forwardRef<HTMLElement, PartProps>(function GaugeFill(
106
+ { asChild, className, style, ...props },
107
+ ref,
108
+ ) {
109
+ const { percent, value } = useGauge();
110
+ const Component = asChild ? Slot : "div";
111
+ return (
112
+ <Component
113
+ {...props}
114
+ ref={ref as never}
115
+ data-slot="threshold-gauge-fill"
116
+ aria-hidden="true"
117
+ className={cx(
118
+ "watt-motion watt-gauge-fill h-full rounded-sm bg-[var(--watt-accent)]",
119
+ className,
120
+ )}
121
+ style={{
122
+ width: `${percent}%`,
123
+ ...(value === null ? { transitionDuration: "0ms" } : {}),
124
+ ...style,
125
+ }}
126
+ />
127
+ );
128
+ }),
129
+ Marker: forwardRef<HTMLElement, PartProps & { value: number }>(
130
+ function GaugeMarker({ value, asChild, style, className, ...props }, ref) {
131
+ const { min, max } = useGauge();
132
+ if (!Number.isFinite(value) || value < min || value > max)
133
+ throw new Error("Gauge marker must be within min and max");
134
+ const Component = asChild ? Slot : "span";
135
+ return (
136
+ <Component
137
+ {...props}
138
+ ref={ref as never}
139
+ data-slot="threshold-gauge-marker"
140
+ className={cx("absolute inset-y-0 w-px bg-current", className)}
141
+ style={{ left: `${((value - min) / (max - min)) * 100}%`, ...style }}
142
+ />
143
+ );
144
+ },
145
+ ),
146
+ Label: part("threshold-gauge-label", "text-sm", "span"),
147
+ Description: part(
148
+ "threshold-gauge-description",
149
+ "text-sm text-[var(--watt-muted)]",
150
+ "span",
151
+ ),
152
+ };
@@ -0,0 +1,126 @@
1
+ "use client";
2
+ import { createContext, forwardRef, useContext, useId, useState } from "react";
3
+ import { cx } from "../internal.js";
4
+ import { motionStyle } from "../motion.js";
5
+ import type { MotionProps, TimeRange, Density } from "../types.js";
6
+ interface RangeContext {
7
+ name: string;
8
+ value: TimeRange | null;
9
+ change: (range: TimeRange | null) => void;
10
+ }
11
+ const Context = createContext<RangeContext | null>(null);
12
+ function validate(range: TimeRange | null | undefined) {
13
+ if (
14
+ range &&
15
+ (!Number.isFinite(range[0]) ||
16
+ !Number.isFinite(range[1]) ||
17
+ range[0] >= range[1])
18
+ )
19
+ throw new Error("TimeRangeSelector requires a finite start < end");
20
+ }
21
+ export interface TimeRangeSelectorProps
22
+ extends
23
+ Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue">,
24
+ MotionProps {
25
+ value?: TimeRange | null;
26
+ defaultValue?: TimeRange | null;
27
+ onValueChange?: (range: TimeRange | null) => void;
28
+ orientation?: "horizontal" | "vertical";
29
+ density?: Density;
30
+ }
31
+ export interface TimeRangeOptionProps extends Omit<
32
+ React.InputHTMLAttributes<HTMLInputElement>,
33
+ "value" | "type" | "checked" | "defaultChecked" | "name"
34
+ > {
35
+ range: TimeRange | null;
36
+ }
37
+ export const TimeRangeSelector = {
38
+ Root: forwardRef<HTMLDivElement, TimeRangeSelectorProps>(function RangeRoot(
39
+ {
40
+ value,
41
+ defaultValue = null,
42
+ onValueChange,
43
+ orientation = "horizontal",
44
+ density = "comfortable",
45
+ motion,
46
+ style,
47
+ className,
48
+ ...props
49
+ },
50
+ ref,
51
+ ) {
52
+ const name = useId();
53
+ const [internal, setInternal] = useState(defaultValue);
54
+ const current = value === undefined ? internal : value;
55
+ validate(current);
56
+ return (
57
+ <Context.Provider
58
+ value={{
59
+ name,
60
+ value: current,
61
+ change: (next) => {
62
+ if (value === undefined) setInternal(next);
63
+ onValueChange?.(next);
64
+ },
65
+ }}
66
+ >
67
+ <div
68
+ {...props}
69
+ ref={ref}
70
+ role="radiogroup"
71
+ aria-orientation={orientation}
72
+ data-slot="time-range-selector"
73
+ data-density={density}
74
+ style={{ ...style, ...motionStyle(motion) }}
75
+ className={cx(
76
+ "watt-root flex flex-wrap gap-1",
77
+ orientation === "vertical" && "flex-col",
78
+ className,
79
+ )}
80
+ />
81
+ </Context.Provider>
82
+ );
83
+ }),
84
+ Option: forwardRef<HTMLInputElement, TimeRangeOptionProps>(
85
+ function RangeOption(
86
+ { range, className, children, onChange, style, ...props },
87
+ ref,
88
+ ) {
89
+ const context = useContext(Context);
90
+ if (!context) throw new Error("TimeRangeSelector.Option requires Root");
91
+ validate(range);
92
+ const checked =
93
+ context.value === null
94
+ ? range === null
95
+ : range !== null &&
96
+ range[0] === context.value[0] &&
97
+ range[1] === context.value[1];
98
+ return (
99
+ <label
100
+ data-slot="time-range-selector-option"
101
+ data-checked={checked || undefined}
102
+ data-disabled={props.disabled || undefined}
103
+ style={style}
104
+ className={cx(
105
+ "watt-motion watt-choice inline-flex min-h-9 cursor-pointer items-center rounded-md px-3 py-1.5 text-sm data-[checked]:bg-[var(--watt-subtle)] data-[checked]:font-medium data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--watt-accent)]",
106
+ className,
107
+ )}
108
+ >
109
+ <input
110
+ {...props}
111
+ ref={ref}
112
+ type="radio"
113
+ name={context.name}
114
+ checked={checked}
115
+ className="sr-only"
116
+ onChange={(event) => {
117
+ onChange?.(event);
118
+ if (!event.defaultPrevented) context.change(range);
119
+ }}
120
+ />
121
+ {children}
122
+ </label>
123
+ );
124
+ },
125
+ ),
126
+ };