@watt-ui/react 0.0.0-stage → 0.1.1

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/LICENSE +21 -0
  2. package/NOTICE +43 -0
  3. package/README.md +48 -2
  4. package/dist/chart/data.d.ts +13 -0
  5. package/dist/chart/data.d.ts.map +1 -0
  6. package/dist/chart/data.js +92 -0
  7. package/dist/chart/data.js.map +1 -0
  8. package/dist/chart/engine.d.ts +2 -0
  9. package/dist/chart/engine.d.ts.map +1 -0
  10. package/dist/chart/engine.js +16 -0
  11. package/dist/chart/engine.js.map +1 -0
  12. package/dist/chart/options.d.ts +18 -0
  13. package/dist/chart/options.d.ts.map +1 -0
  14. package/dist/chart/options.js +136 -0
  15. package/dist/chart/options.js.map +1 -0
  16. package/dist/components/alarm-feed.d.ts +18 -0
  17. package/dist/components/alarm-feed.d.ts.map +1 -0
  18. package/dist/components/alarm-feed.js +45 -0
  19. package/dist/components/alarm-feed.js.map +1 -0
  20. package/dist/components/asset-tree.d.ts +33 -0
  21. package/dist/components/asset-tree.d.ts.map +1 -0
  22. package/dist/components/asset-tree.js +149 -0
  23. package/dist/components/asset-tree.js.map +1 -0
  24. package/dist/components/battery-soc.d.ts +11 -0
  25. package/dist/components/battery-soc.d.ts.map +1 -0
  26. package/dist/components/battery-soc.js +34 -0
  27. package/dist/components/battery-soc.js.map +1 -0
  28. package/dist/components/data-freshness.d.ts +26 -0
  29. package/dist/components/data-freshness.d.ts.map +1 -0
  30. package/dist/components/data-freshness.js +45 -0
  31. package/dist/components/data-freshness.js.map +1 -0
  32. package/dist/components/energy-flow.d.ts +39 -0
  33. package/dist/components/energy-flow.d.ts.map +1 -0
  34. package/dist/components/energy-flow.js +197 -0
  35. package/dist/components/energy-flow.js.map +1 -0
  36. package/dist/components/flow-geometry.d.ts +24 -0
  37. package/dist/components/flow-geometry.d.ts.map +1 -0
  38. package/dist/components/flow-geometry.js +57 -0
  39. package/dist/components/flow-geometry.js.map +1 -0
  40. package/dist/components/power-metric.d.ts +14 -0
  41. package/dist/components/power-metric.d.ts.map +1 -0
  42. package/dist/components/power-metric.js +25 -0
  43. package/dist/components/power-metric.js.map +1 -0
  44. package/dist/components/status-indicator.d.ts +10 -0
  45. package/dist/components/status-indicator.d.ts.map +1 -0
  46. package/dist/components/status-indicator.js +15 -0
  47. package/dist/components/status-indicator.js.map +1 -0
  48. package/dist/components/telemetry-chart.d.ts +50 -0
  49. package/dist/components/telemetry-chart.d.ts.map +1 -0
  50. package/dist/components/telemetry-chart.js +256 -0
  51. package/dist/components/telemetry-chart.js.map +1 -0
  52. package/dist/domain.d.ts +6 -0
  53. package/dist/domain.d.ts.map +1 -0
  54. package/dist/domain.js +43 -0
  55. package/dist/domain.js.map +1 -0
  56. package/dist/icons/index.d.ts +7 -0
  57. package/dist/icons/index.d.ts.map +1 -0
  58. package/dist/icons/index.js +38 -0
  59. package/dist/icons/index.js.map +1 -0
  60. package/dist/index.d.ts +19 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +10 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal.d.ts +6 -0
  65. package/dist/internal.d.ts.map +1 -0
  66. package/dist/internal.js +45 -0
  67. package/dist/internal.js.map +1 -0
  68. package/dist/server.d.ts +10 -0
  69. package/dist/server.d.ts.map +1 -0
  70. package/dist/server.js +32 -0
  71. package/dist/server.js.map +1 -0
  72. package/dist/styles.css +134 -0
  73. package/dist/theme/shadcn.css +14 -0
  74. package/dist/types.d.ts +61 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/types.js +2 -0
  77. package/dist/types.js.map +1 -0
  78. package/package.json +118 -4
  79. package/src/chart/data.ts +103 -0
  80. package/src/chart/engine.ts +19 -0
  81. package/src/chart/options.ts +178 -0
  82. package/src/components/alarm-feed.tsx +126 -0
  83. package/src/components/asset-tree.tsx +258 -0
  84. package/src/components/battery-soc.tsx +84 -0
  85. package/src/components/data-freshness.tsx +109 -0
  86. package/src/components/energy-flow.tsx +387 -0
  87. package/src/components/flow-geometry.ts +82 -0
  88. package/src/components/power-metric.tsx +71 -0
  89. package/src/components/status-indicator.tsx +31 -0
  90. package/src/components/telemetry-chart.tsx +476 -0
  91. package/src/domain.ts +64 -0
  92. package/src/icons/index.tsx +49 -0
  93. package/src/index.ts +37 -0
  94. package/src/internal.tsx +57 -0
  95. package/src/server.ts +47 -0
  96. package/src/styles.css +134 -0
  97. package/src/theme/shadcn.css +14 -0
  98. package/src/types.ts +64 -0
@@ -0,0 +1,178 @@
1
+ import type { EChartsCoreOption } from "echarts/core";
2
+ import type { PreparedSeries } from "./data.js";
3
+ import { samplePoints } from "./data.js";
4
+ import type { TimeRange } from "../types.js";
5
+ export interface ChartConfig {
6
+ series: PreparedSeries[];
7
+ range?: TimeRange;
8
+ sampling?: "minmax" | "none";
9
+ timeZone: string;
10
+ locale?: string;
11
+ options?: EChartsCoreOption;
12
+ }
13
+ const record = (value: unknown): Record<string, unknown> =>
14
+ value && typeof value === "object" && !Array.isArray(value)
15
+ ? (value as Record<string, unknown>)
16
+ : {};
17
+ const at = (value: unknown, index = 0) =>
18
+ record(Array.isArray(value) ? value[index] : index === 0 ? value : undefined);
19
+ function seriesOption(value: unknown, id: string, index: number) {
20
+ return Array.isArray(value)
21
+ ? record(value.find((v) => record(v).id === id) ?? value[index])
22
+ : at(value, index);
23
+ }
24
+ export function hasManagedConflict(config: ChartConfig): boolean {
25
+ const o = config.options;
26
+ if (!o) return false;
27
+ const x = at(o.xAxis),
28
+ zoom = at(o.dataZoom),
29
+ tooltip = at(o.tooltip);
30
+ if (
31
+ (x.type !== undefined && x.type !== "time") ||
32
+ (config.range &&
33
+ ((x.min !== undefined && x.min !== config.range[0]) ||
34
+ (x.max !== undefined && x.max !== config.range[1]) ||
35
+ (zoom.startValue !== undefined &&
36
+ zoom.startValue !== config.range[0]) ||
37
+ (zoom.endValue !== undefined && zoom.endValue !== config.range[1])))
38
+ )
39
+ return true;
40
+ if (
41
+ at(o.legend).show === true ||
42
+ tooltip.showContent === true ||
43
+ (tooltip.trigger !== undefined && tooltip.trigger !== "axis")
44
+ )
45
+ return true;
46
+ return config.series.some((s, index) => {
47
+ const custom = seriesOption(o.series, s.id, index),
48
+ y = at(o.yAxis, s.axis ?? 0);
49
+ return (
50
+ "data" in custom ||
51
+ (custom.type !== undefined && custom.type !== "line") ||
52
+ custom.connectNulls === true ||
53
+ (custom.yAxisIndex !== undefined &&
54
+ custom.yAxisIndex !== (s.axis ?? 0)) ||
55
+ (custom.name !== undefined && custom.name !== s.label) ||
56
+ (y.type !== undefined && y.type !== "value") ||
57
+ (y.name !== undefined && y.name !== s.unit)
58
+ );
59
+ });
60
+ }
61
+ export function chartOption(
62
+ config: ChartConfig,
63
+ width = 800,
64
+ colors = { text: "#667085", border: "#e7e9ee", accent: "#355dff" },
65
+ ): EChartsCoreOption {
66
+ const {
67
+ series,
68
+ range,
69
+ sampling = "minmax",
70
+ timeZone,
71
+ locale,
72
+ options,
73
+ } = config;
74
+ if (
75
+ range &&
76
+ (!range.every(
77
+ (value) => Number.isFinite(value) && Math.abs(value) <= 8.64e15,
78
+ ) ||
79
+ range[0] >= range[1])
80
+ )
81
+ throw new Error(
82
+ "Chart range must contain two increasing epoch milliseconds",
83
+ );
84
+ const timeFormat = new Intl.DateTimeFormat(locale ?? "en-GB", {
85
+ timeZone,
86
+ hour: "2-digit",
87
+ minute: "2-digit",
88
+ });
89
+ const maxAxis = series.some((s) => s.axis === 1) ? 1 : 0;
90
+ const passthrough = { ...options };
91
+ delete passthrough.legend;
92
+ const x = at(options?.xAxis),
93
+ zoom = at(options?.dataZoom);
94
+ return {
95
+ animation: false,
96
+ ...passthrough,
97
+ grid: {
98
+ left: 64,
99
+ right: maxAxis ? 64 : 20,
100
+ top: 30,
101
+ bottom: 44,
102
+ ...at(options?.grid),
103
+ },
104
+ tooltip: {
105
+ ...at(options?.tooltip),
106
+ trigger: "axis",
107
+ showContent: false,
108
+ animation: false,
109
+ },
110
+ xAxis: {
111
+ axisLine: { lineStyle: { color: colors.border } },
112
+ splitLine: { show: false },
113
+ axisPointer: { show: true, label: { show: false } },
114
+ ...x,
115
+ type: "time",
116
+ ...(range ? { min: range[0], max: range[1] } : {}),
117
+ axisLabel: {
118
+ color: colors.text,
119
+ formatter: (time: number) => timeFormat.format(time),
120
+ ...record(x.axisLabel),
121
+ },
122
+ },
123
+ yAxis: Array.from({ length: maxAxis + 1 }, (_, axis) => {
124
+ const custom = at(options?.yAxis, axis);
125
+ return {
126
+ nameTextStyle: { color: colors.text },
127
+ splitLine: { lineStyle: { color: colors.border } },
128
+ scale: true,
129
+ ...custom,
130
+ type: "value",
131
+ name: series.find((s) => (s.axis ?? 0) === axis)?.unit ?? "",
132
+ axisLabel: { color: colors.text, ...record(custom.axisLabel) },
133
+ };
134
+ }),
135
+ dataZoom: [
136
+ {
137
+ zoomOnMouseWheel: "ctrl",
138
+ moveOnMouseMove: true,
139
+ preventDefaultMouseMove: false,
140
+ ...zoom,
141
+ type: "inside",
142
+ xAxisIndex: 0,
143
+ filterMode: "none",
144
+ ...(range ? { startValue: range[0], endValue: range[1] } : {}),
145
+ },
146
+ ],
147
+ series: series.map((s, index) => {
148
+ const custom = seriesOption(options?.series, s.id, index);
149
+ const line = record(custom.lineStyle),
150
+ item = record(custom.itemStyle);
151
+ const color =
152
+ s.color ?? line.color ?? (index ? "#12a594" : colors.accent);
153
+ return {
154
+ showSymbol: false,
155
+ clip: true,
156
+ ...custom,
157
+ id: s.id,
158
+ name: s.label,
159
+ type: "line",
160
+ yAxisIndex: s.axis ?? 0,
161
+ connectNulls: false,
162
+ sampling: "none",
163
+ lineStyle: { width: 2, ...line, color },
164
+ itemStyle: { ...item, color: s.color ?? item.color ?? color },
165
+ areaStyle:
166
+ s.area === undefined
167
+ ? custom.areaStyle
168
+ : s.area
169
+ ? { opacity: 0.08, ...record(custom.areaStyle) }
170
+ : undefined,
171
+ data:
172
+ sampling === "none"
173
+ ? s.points
174
+ : samplePoints(s.points, Math.max(100, width * 2), range),
175
+ };
176
+ }),
177
+ };
178
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
3
+ import type { ForwardedRef, RefAttributes, ReactElement } from "react";
4
+ import { forwardRef } from "react";
5
+ import { useVirtualizer } from "@tanstack/react-virtual";
6
+ import { useRef } from "react";
7
+ import { cx, part } from "../internal.js";
8
+ import type { AlarmEvent, Density } from "../types.js";
9
+ export interface AlarmFeedProps<T extends AlarmEvent = AlarmEvent> extends Omit<
10
+ React.HTMLAttributes<HTMLDivElement>,
11
+ "children"
12
+ > {
13
+ items: readonly T[];
14
+ children: (item: T, index: number) => React.ReactNode;
15
+ density?: Density;
16
+ virtualized?: boolean;
17
+ rowHeight?: number;
18
+ height?: number;
19
+ empty?: React.ReactNode;
20
+ }
21
+ function Root<T extends AlarmEvent>(
22
+ {
23
+ items,
24
+ children,
25
+ density = "comfortable",
26
+ virtualized = false,
27
+ rowHeight = 80,
28
+ height = 400,
29
+ empty,
30
+ className,
31
+ style,
32
+ ...props
33
+ }: AlarmFeedProps<T>,
34
+ forwardedRef: ForwardedRef<HTMLDivElement>,
35
+ ) {
36
+ const host = useRef<HTMLDivElement>(null);
37
+ const hostRef = useComposedRefs(host, forwardedRef);
38
+ const virtualizer = useVirtualizer({
39
+ count: items.length,
40
+ initialRect: { width: 0, height: virtualized ? height : 0 },
41
+ getScrollElement: () => host.current,
42
+ estimateSize: () => rowHeight,
43
+ overscan: 4,
44
+ enabled: virtualized,
45
+ getItemKey: (index) => items[index]!.id,
46
+ });
47
+ const rows = virtualized
48
+ ? virtualizer.getVirtualItems()
49
+ : items.map((item, index) => ({ key: item.id, index, start: 0 }));
50
+ return (
51
+ <div
52
+ {...props}
53
+ ref={hostRef}
54
+ tabIndex={props.tabIndex ?? (virtualized ? 0 : undefined)}
55
+ data-slot="alarm-feed"
56
+ data-density={density}
57
+ className={cx("watt-root overflow-auto", className)}
58
+ style={{ ...(virtualized ? { height } : {}), ...style }}
59
+ >
60
+ {!items.length ? (
61
+ empty
62
+ ) : (
63
+ <div
64
+ role="list"
65
+ style={
66
+ virtualized
67
+ ? { height: virtualizer.getTotalSize(), position: "relative" }
68
+ : undefined
69
+ }
70
+ >
71
+ {rows.map((row) => {
72
+ const item = items[row.index]!;
73
+ return (
74
+ <div
75
+ role="listitem"
76
+ key={row.key}
77
+ data-index={row.index}
78
+ ref={virtualized ? virtualizer.measureElement : undefined}
79
+ aria-posinset={row.index + 1}
80
+ aria-setsize={items.length}
81
+ data-slot="alarm-feed-item"
82
+ data-severity={item.severity}
83
+ data-state={item.status}
84
+ className={cx(
85
+ "border-b border-[var(--watt-border)] last:border-0",
86
+ density === "compact" ? "py-2.5" : "py-4",
87
+ )}
88
+ style={
89
+ virtualized
90
+ ? {
91
+ position: "absolute",
92
+ top: 0,
93
+ left: 0,
94
+ width: "100%",
95
+ transform: `translateY(${row.start}px)`,
96
+ }
97
+ : undefined
98
+ }
99
+ >
100
+ {children(item, row.index)}
101
+ </div>
102
+ );
103
+ })}
104
+ </div>
105
+ )}
106
+ </div>
107
+ );
108
+ }
109
+ const Severity = forwardRef<
110
+ HTMLSpanElement,
111
+ React.HTMLAttributes<HTMLSpanElement>
112
+ >((props, ref) => (
113
+ <span {...props} ref={ref} data-slot="alarm-feed-severity" />
114
+ ));
115
+ export const AlarmFeed = {
116
+ Root: forwardRef(Root) as <T extends AlarmEvent = AlarmEvent>(
117
+ props: AlarmFeedProps<T> & RefAttributes<HTMLDivElement>,
118
+ ) => ReactElement,
119
+ Severity,
120
+ Message: part("alarm-feed-message", "font-medium", "span"),
121
+ Description: part(
122
+ "alarm-feed-description",
123
+ "text-sm text-[var(--watt-muted)]",
124
+ "span",
125
+ ),
126
+ };
@@ -0,0 +1,258 @@
1
+ "use client";
2
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
3
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useTree } from "@headless-tree/react";
5
+ import {
6
+ syncDataLoaderFeature,
7
+ selectionFeature,
8
+ hotkeysCoreFeature,
9
+ type ItemInstance,
10
+ type FeatureImplementation,
11
+ } from "@headless-tree/core";
12
+ import { useVirtualizer } from "@tanstack/react-virtual";
13
+ import { cx } from "../internal.js";
14
+ import type { ForwardedRef, RefAttributes, ReactElement } from "react";
15
+ import type { Density, PlantAsset } from "../types.js";
16
+ export interface AssetTreeRow<T> {
17
+ item: T;
18
+ level: number;
19
+ expanded: boolean;
20
+ selected: boolean;
21
+ focused: boolean;
22
+ hasChildren: boolean;
23
+ toggleExpanded: () => void;
24
+ }
25
+ // Include heterogeneous descendants so leaf rows do not inherit a parent's required fields.
26
+ export type AssetTreeItem<T extends PlantAsset> =
27
+ | T
28
+ | (T extends { children?: readonly (infer C extends PlantAsset)[] }
29
+ ? C extends T
30
+ ? never
31
+ : AssetTreeItem<C>
32
+ : never);
33
+ export interface AssetTreeProps<T extends PlantAsset = PlantAsset> extends Omit<
34
+ React.HTMLAttributes<HTMLDivElement>,
35
+ "children"
36
+ > {
37
+ items: readonly T[];
38
+ children: (row: AssetTreeRow<AssetTreeItem<T>>) => React.ReactNode;
39
+ expandedIds?: string[];
40
+ defaultExpandedIds?: string[];
41
+ onExpandedChange?: (ids: string[]) => void;
42
+ selectedIds?: string[];
43
+ defaultSelectedIds?: string[];
44
+ onSelectionChange?: (ids: string[]) => void;
45
+ onActivate?: (item: AssetTreeItem<T>) => void;
46
+ density?: Density;
47
+ height?: number;
48
+ rowHeight?: number;
49
+ empty?: React.ReactNode;
50
+ }
51
+ const ROOT = "\u0000watt-tree-root";
52
+ function Root<T extends PlantAsset>(
53
+ {
54
+ items,
55
+ children,
56
+ expandedIds,
57
+ defaultExpandedIds = [],
58
+ onExpandedChange,
59
+ selectedIds,
60
+ defaultSelectedIds = [],
61
+ onSelectionChange,
62
+ onActivate,
63
+ density = "comfortable",
64
+ height = 360,
65
+ rowHeight = density === "compact" ? 36 : 48,
66
+ empty,
67
+ className,
68
+ style,
69
+ ...props
70
+ }: AssetTreeProps<T>,
71
+ forwardedRef: ForwardedRef<HTMLDivElement>,
72
+ ) {
73
+ const host = useRef<HTMLDivElement>(null);
74
+ const indexed = useMemo(() => {
75
+ const map = new Map<string, AssetTreeItem<T>>();
76
+ const stack = [...items] as AssetTreeItem<T>[];
77
+ while (stack.length) {
78
+ const item = stack.pop()!;
79
+ if (!item.id || item.id === ROOT || map.has(item.id))
80
+ throw new Error(
81
+ "AssetTree IDs must be nonempty and unique, with no cycles",
82
+ );
83
+ map.set(item.id, item);
84
+ if (item.children)
85
+ for (const child of item.children)
86
+ stack.push(child as AssetTreeItem<T>);
87
+ }
88
+ return map;
89
+ }, [items]);
90
+ const virtualRef = useRef<ReturnType<
91
+ typeof useVirtualizer<HTMLDivElement, HTMLDivElement>
92
+ > | null>(null);
93
+ const focusFrame = useRef(0);
94
+ const virtualFocus = useMemo<FeatureImplementation>(
95
+ () => ({
96
+ key: "watt-virtual-focus",
97
+ treeInstance: {
98
+ updateDomFocus: ({ tree }) => {
99
+ const item = tree.getFocusedItem();
100
+ if (!item) return;
101
+ const id = item.getId();
102
+ const started = performance.now();
103
+ virtualRef.current?.scrollToIndex(item.getItemMeta().index, {
104
+ behavior: "instant",
105
+ });
106
+ cancelAnimationFrame(focusFrame.current);
107
+ const focus = () => {
108
+ if (tree.getFocusedItem()?.getId() !== id) return;
109
+ const active = document.activeElement;
110
+ if (
111
+ active &&
112
+ active !== document.body &&
113
+ !host.current?.contains(active)
114
+ )
115
+ return;
116
+ const element = item.getElement();
117
+ // Wait for the virtual row, then focus without a second native scroll that can unmount it.
118
+ if (element) element.focus({ preventScroll: true });
119
+ else if (performance.now() - started < 1000)
120
+ focusFrame.current = requestAnimationFrame(focus);
121
+ };
122
+ focusFrame.current = requestAnimationFrame(focus);
123
+ },
124
+ },
125
+ }),
126
+ [],
127
+ );
128
+ useEffect(() => () => cancelAnimationFrame(focusFrame.current), []);
129
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpandedIds);
130
+ const [internalSelected, setInternalSelected] = useState(defaultSelectedIds);
131
+ const tree = useTree<AssetTreeItem<T>>({
132
+ rootItemId: ROOT,
133
+ getItemName: (item) => item.getItemData().label,
134
+ isItemFolder: (item) => !!item.getItemData().children?.length,
135
+ dataLoader: {
136
+ getItem: (id) => indexed.get(id)!,
137
+ getChildren: (id) =>
138
+ id === ROOT
139
+ ? items.map((item) => item.id)
140
+ : (indexed.get(id)?.children?.map((item) => item.id) ?? []),
141
+ },
142
+ state: {
143
+ expandedItems: expandedIds ?? internalExpanded,
144
+ selectedItems: selectedIds ?? internalSelected,
145
+ },
146
+ setExpandedItems: (updater) => {
147
+ const next =
148
+ typeof updater === "function"
149
+ ? updater(tree.getState().expandedItems)
150
+ : updater;
151
+ if (expandedIds === undefined) setInternalExpanded(next);
152
+ onExpandedChange?.(next);
153
+ },
154
+ setSelectedItems: (updater) => {
155
+ const next =
156
+ typeof updater === "function"
157
+ ? updater(tree.getState().selectedItems)
158
+ : updater;
159
+ if (selectedIds === undefined) setInternalSelected(next);
160
+ onSelectionChange?.(next);
161
+ },
162
+ onPrimaryAction: (item) => onActivate?.(item.getItemData()),
163
+ scrollToItem: (item) =>
164
+ virtualRef.current?.scrollToIndex(item.getItemMeta().index, {
165
+ behavior: "instant",
166
+ }),
167
+ features: [
168
+ syncDataLoaderFeature,
169
+ selectionFeature,
170
+ hotkeysCoreFeature,
171
+ virtualFocus,
172
+ ],
173
+ });
174
+ useEffect(() => {
175
+ tree.rebuildTree();
176
+ const focused = tree.getState().focusedItem;
177
+ if (focused && !tree.getItems().some((item) => item.getId() === focused))
178
+ tree.applySubStateUpdate("focusedItem", null);
179
+ }, [items, expandedIds, tree]);
180
+ const visible = tree.getItems();
181
+ const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
182
+ count: visible.length,
183
+ getScrollElement: () => host.current,
184
+ estimateSize: () => rowHeight,
185
+ overscan: 5,
186
+ initialRect: { width: 0, height },
187
+ getItemKey: (index) => visible[index]!.getId(),
188
+ });
189
+ virtualRef.current = virtualizer;
190
+ const containerProps = tree.getContainerProps(props["aria-label"]);
191
+ const hostRef = useComposedRefs(host, tree.registerElement, forwardedRef);
192
+ const renderRow = (
193
+ item: ItemInstance<AssetTreeItem<T>>,
194
+ index: number,
195
+ start: number,
196
+ ) => {
197
+ const rowProps = item.getProps();
198
+ return (
199
+ <div
200
+ {...rowProps}
201
+ aria-label={undefined}
202
+ key={item.getId()}
203
+ data-slot="asset-tree-row"
204
+ data-index={index}
205
+ data-selected={item.isSelected() || undefined}
206
+ className="absolute top-0 left-0 flex w-full items-center border-b border-[var(--watt-border)] outline-offset-[-2px] data-[selected]:bg-[var(--watt-subtle)] focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)]"
207
+ style={{
208
+ height: rowHeight,
209
+ paddingInlineStart: item.getItemMeta().level * 20,
210
+ transform: `translateY(${start}px)`,
211
+ }}
212
+ >
213
+ {children({
214
+ item: item.getItemData(),
215
+ level: item.getItemMeta().level,
216
+ expanded: item.isExpanded(),
217
+ selected: item.isSelected(),
218
+ focused: item.isFocused(),
219
+ hasChildren: item.isFolder(),
220
+ toggleExpanded: () =>
221
+ item.isExpanded() ? item.collapse() : item.expand(),
222
+ })}
223
+ </div>
224
+ );
225
+ };
226
+ return (
227
+ <div
228
+ {...containerProps}
229
+ {...props}
230
+ ref={hostRef}
231
+ data-slot="asset-tree"
232
+ data-density={density}
233
+ className={cx("watt-root overflow-auto text-sm", className)}
234
+ style={{ height, ...style }}
235
+ >
236
+ {!items.length ? (
237
+ empty
238
+ ) : (
239
+ <div
240
+ role="presentation"
241
+ style={{ height: virtualizer.getTotalSize(), position: "relative" }}
242
+ >
243
+ {virtualizer.getVirtualItems().map((row) => {
244
+ const item = visible[row.index];
245
+ return item && indexed.has(item.getId())
246
+ ? renderRow(item, row.index, row.start)
247
+ : null;
248
+ })}
249
+ </div>
250
+ )}
251
+ </div>
252
+ );
253
+ }
254
+ export const AssetTree = {
255
+ Root: forwardRef(Root) as <T extends PlantAsset = PlantAsset>(
256
+ props: AssetTreeProps<T> & RefAttributes<HTMLDivElement>,
257
+ ) => ReactElement,
258
+ };
@@ -0,0 +1,84 @@
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 type { PartProps, RootProps, Density } from "../types.js";
6
+ const Context = createContext<{
7
+ value: number | null;
8
+ density: Density;
9
+ } | null>(null);
10
+ function useSOC() {
11
+ const value = useContext(Context);
12
+ if (value === null)
13
+ throw new Error("BatterySOC parts require BatterySOC.Root");
14
+ return value;
15
+ }
16
+ export interface BatterySOCProps extends RootProps {
17
+ value: number | null;
18
+ }
19
+ const Root = forwardRef<HTMLElement, BatterySOCProps>(
20
+ ({ value, asChild, density = "comfortable", className, ...props }, ref) => {
21
+ const Component = asChild ? Slot : "div";
22
+ const valid =
23
+ value !== null && Number.isFinite(value) && value >= 0 && value <= 100;
24
+ return (
25
+ <Context.Provider value={{ value: valid ? value : null, density }}>
26
+ <Component
27
+ {...props}
28
+ ref={ref as never}
29
+ data-slot="battery-soc"
30
+ data-invalid={!valid || undefined}
31
+ data-density={density}
32
+ className={cx("watt-root flex flex-col gap-3", className)}
33
+ />
34
+ </Context.Provider>
35
+ );
36
+ },
37
+ );
38
+ const Track = forwardRef<HTMLElement, PartProps>(
39
+ ({ asChild, className, ...props }, ref) => {
40
+ const { value, density } = useSOC();
41
+ const Component = asChild ? Slot : "div";
42
+ return (
43
+ <Component
44
+ {...props}
45
+ ref={ref as never}
46
+ role={value === null ? "img" : "meter"}
47
+ aria-valuemin={value === null ? undefined : 0}
48
+ aria-valuemax={value === null ? undefined : 100}
49
+ aria-valuenow={value ?? undefined}
50
+ data-slot="battery-soc-track"
51
+ className={cx(
52
+ density === "compact" ? "h-2" : "h-4",
53
+ "overflow-hidden rounded-sm bg-[var(--watt-subtle)]",
54
+ className,
55
+ )}
56
+ />
57
+ );
58
+ },
59
+ );
60
+ const Fill = forwardRef<HTMLElement, PartProps>(
61
+ ({ asChild, className, style, ...props }, ref) => {
62
+ const { value } = useSOC();
63
+ const Component = asChild ? Slot : "div";
64
+ return (
65
+ <Component
66
+ {...props}
67
+ ref={ref as never}
68
+ aria-hidden="true"
69
+ data-slot="battery-soc-fill"
70
+ className={cx(
71
+ "h-full bg-[var(--watt-accent)] transition-[width] duration-500 motion-reduce:transition-none",
72
+ className,
73
+ )}
74
+ style={{ width: `${value ?? 0}%`, ...style }}
75
+ />
76
+ );
77
+ },
78
+ );
79
+ export const BatterySOC = {
80
+ Root,
81
+ Track,
82
+ Fill,
83
+ Label: part("battery-soc-label", "text-sm", "span"),
84
+ };