@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.
- package/NOTICE +4 -0
- package/README.md +9 -9
- package/dist/chart/category-engine.d.ts +2 -0
- package/dist/chart/category-engine.d.ts.map +1 -0
- package/dist/chart/category-engine.js +9 -0
- package/dist/chart/category-engine.js.map +1 -0
- package/dist/chart/category-options.d.ts +23 -0
- package/dist/chart/category-options.d.ts.map +1 -0
- package/dist/chart/category-options.js +120 -0
- package/dist/chart/category-options.js.map +1 -0
- package/dist/components/asset-tree.d.ts.map +1 -1
- package/dist/components/asset-tree.js +16 -2
- package/dist/components/asset-tree.js.map +1 -1
- package/dist/components/battery-soc.d.ts.map +1 -1
- package/dist/components/battery-soc.js +5 -1
- package/dist/components/battery-soc.js.map +1 -1
- package/dist/components/category-chart.d.ts +37 -0
- package/dist/components/category-chart.d.ts.map +1 -0
- package/dist/components/category-chart.js +251 -0
- package/dist/components/category-chart.js.map +1 -0
- package/dist/components/energy-flow.d.ts +2 -1
- package/dist/components/energy-flow.d.ts.map +1 -1
- package/dist/components/energy-flow.js +2 -2
- package/dist/components/energy-flow.js.map +1 -1
- package/dist/components/equipment-card.d.ts +12 -0
- package/dist/components/equipment-card.d.ts.map +1 -0
- package/dist/components/equipment-card.js +18 -0
- package/dist/components/equipment-card.js.map +1 -0
- package/dist/components/equipment-heatmap.d.ts +33 -0
- package/dist/components/equipment-heatmap.d.ts.map +1 -0
- package/dist/components/equipment-heatmap.js +161 -0
- package/dist/components/equipment-heatmap.js.map +1 -0
- package/dist/components/equipment-table.d.ts +34 -0
- package/dist/components/equipment-table.d.ts.map +1 -0
- package/dist/components/equipment-table.js +141 -0
- package/dist/components/equipment-table.js.map +1 -0
- package/dist/components/metric-trend.d.ts +13 -0
- package/dist/components/metric-trend.d.ts.map +1 -0
- package/dist/components/metric-trend.js +30 -0
- package/dist/components/metric-trend.js.map +1 -0
- package/dist/components/operating-timeline.d.ts +31 -0
- package/dist/components/operating-timeline.d.ts.map +1 -0
- package/dist/components/operating-timeline.js +61 -0
- package/dist/components/operating-timeline.js.map +1 -0
- package/dist/components/plant-fleet-map.d.ts +32 -0
- package/dist/components/plant-fleet-map.d.ts.map +1 -0
- package/dist/components/plant-fleet-map.js +56 -0
- package/dist/components/plant-fleet-map.js.map +1 -0
- package/dist/components/threshold-gauge.d.ts +19 -0
- package/dist/components/threshold-gauge.d.ts.map +1 -0
- package/dist/components/threshold-gauge.js +54 -0
- package/dist/components/threshold-gauge.js.map +1 -0
- package/dist/components/time-range-selector.d.ts +16 -0
- package/dist/components/time-range-selector.d.ts.map +1 -0
- package/dist/components/time-range-selector.js +47 -0
- package/dist/components/time-range-selector.js.map +1 -0
- package/dist/icons/index.d.ts +17 -1
- package/dist/icons/index.d.ts.map +1 -1
- package/dist/icons/index.js +98 -1
- package/dist/icons/index.js.map +1 -1
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/map/land.d.ts +2 -0
- package/dist/map/land.d.ts.map +1 -0
- package/dist/map/land.js +3 -0
- package/dist/map/land.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -1
- package/dist/server.js.map +1 -1
- package/dist/styles.css +133 -4
- package/dist/virtual-focus.d.ts +3 -0
- package/dist/virtual-focus.d.ts.map +1 -0
- package/dist/virtual-focus.js +23 -0
- package/dist/virtual-focus.js.map +1 -0
- package/package.json +37 -1
- package/src/chart/category-engine.ts +8 -0
- package/src/chart/category-options.ts +158 -0
- package/src/components/asset-tree.tsx +21 -5
- package/src/components/battery-soc.tsx +8 -0
- package/src/components/category-chart.tsx +452 -0
- package/src/components/energy-flow.tsx +3 -1
- package/src/components/equipment-card.tsx +46 -0
- package/src/components/equipment-heatmap.tsx +298 -0
- package/src/components/equipment-table.tsx +302 -0
- package/src/components/metric-trend.tsx +87 -0
- package/src/components/operating-timeline.tsx +182 -0
- package/src/components/plant-fleet-map.tsx +173 -0
- package/src/components/threshold-gauge.tsx +152 -0
- package/src/components/time-range-selector.tsx +126 -0
- package/src/icons/index.tsx +107 -0
- package/src/index.ts +44 -0
- package/src/map/land.ts +2 -0
- package/src/server.ts +28 -1
- package/src/styles.css +133 -4
- 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
|
+
};
|