@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,298 @@
1
+ "use client";
2
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
4
+ import { useVirtualizer } from "@tanstack/react-virtual";
5
+ import { cx, part } from "../internal.js";
6
+ import { useVirtualFocus } from "../virtual-focus.js";
7
+ import { motionStyle } from "../motion.js";
8
+ import type {
9
+ DataQuality,
10
+ Density,
11
+ EquipmentStatus,
12
+ MotionProps,
13
+ } from "../types.js";
14
+ import type { ForwardedRef, ReactElement, RefAttributes } from "react";
15
+
16
+ export interface HeatmapCell {
17
+ id: string;
18
+ label: string;
19
+ value: number | null;
20
+ color?: string;
21
+ quality?: DataQuality;
22
+ status?: EquipmentStatus;
23
+ disabled?: boolean;
24
+ }
25
+ export interface EquipmentHeatmapProps<T extends HeatmapCell = HeatmapCell>
26
+ extends
27
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onSelect">,
28
+ MotionProps {
29
+ cells: readonly T[];
30
+ columns: number;
31
+ children?: (
32
+ cell: T,
33
+ state: { selected: boolean; focused: boolean },
34
+ ) => React.ReactNode;
35
+ valueFormatter?: (cell: T) => string;
36
+ selectedId?: string | null;
37
+ defaultSelectedId?: string | null;
38
+ onSelectionChange?: (id: string) => void;
39
+ density?: Density;
40
+ cellSize?: number;
41
+ height?: number;
42
+ virtualized?: boolean;
43
+ empty?: React.ReactNode;
44
+ }
45
+ function Root<T extends HeatmapCell>(
46
+ {
47
+ cells,
48
+ columns,
49
+ children,
50
+ valueFormatter,
51
+ selectedId,
52
+ defaultSelectedId = null,
53
+ onSelectionChange,
54
+ density = "comfortable",
55
+ cellSize = density === "compact" ? 36 : 44,
56
+ height = 320,
57
+ virtualized = true,
58
+ empty,
59
+ motion,
60
+ style,
61
+ className,
62
+ onKeyDown,
63
+ ...props
64
+ }: EquipmentHeatmapProps<T>,
65
+ ref: ForwardedRef<HTMLDivElement>,
66
+ ) {
67
+ if (
68
+ !Number.isInteger(columns) ||
69
+ columns < 1 ||
70
+ !Number.isFinite(cellSize) ||
71
+ cellSize < 24 ||
72
+ !Number.isFinite(height) ||
73
+ height <= 0
74
+ )
75
+ throw new Error(
76
+ "EquipmentHeatmap requires positive columns/height and cellSize >= 24",
77
+ );
78
+ useMemo(() => {
79
+ const ids = new Set<string>();
80
+ for (const cell of cells) {
81
+ if (!cell.id || ids.has(cell.id))
82
+ throw new Error("Heatmap cell IDs must be nonempty and unique");
83
+ ids.add(cell.id);
84
+ }
85
+ }, [cells]);
86
+ const host = useRef<HTMLDivElement>(null);
87
+ const hostRef = useComposedRefs(host, ref);
88
+ const [internal, setInternal] = useState(defaultSelectedId);
89
+ const selected = selectedId === undefined ? internal : selectedId;
90
+ const [focused, setFocused] = useState(0);
91
+ const count = Math.ceil(cells.length / columns);
92
+ const rowHeight = cellSize + 4;
93
+ const virtualizer = useVirtualizer({
94
+ count,
95
+ getScrollElement: () => host.current,
96
+ estimateSize: () => rowHeight,
97
+ initialRect: { width: 0, height },
98
+ overscan: 3,
99
+ enabled: virtualized,
100
+ });
101
+ useEffect(() => {
102
+ virtualizer.measure();
103
+ }, [virtualizer, rowHeight]);
104
+ const focus = useVirtualFocus(host, (index) =>
105
+ virtualizer.scrollToIndex(Math.floor(index / columns), { align: "auto" }),
106
+ );
107
+ const rows = virtualized
108
+ ? virtualizer.getVirtualItems()
109
+ : Array.from({ length: count }, (_, index) => ({
110
+ index,
111
+ start: index * rowHeight,
112
+ }));
113
+ const mounted = rows.flatMap((row) =>
114
+ Array.from(
115
+ { length: Math.min(columns, cells.length - row.index * columns) },
116
+ (_, offset) => row.index * columns + offset,
117
+ ),
118
+ );
119
+ const tabIndex =
120
+ mounted.includes(focused) && !cells[focused]?.disabled
121
+ ? focused
122
+ : mounted.find((index) => !cells[index]?.disabled);
123
+ return (
124
+ <div
125
+ {...props}
126
+ ref={hostRef}
127
+ role={cells.length ? "grid" : "group"}
128
+ aria-rowcount={cells.length ? count : undefined}
129
+ aria-colcount={cells.length ? columns : undefined}
130
+ tabIndex={
131
+ props.tabIndex ??
132
+ (cells.length && tabIndex === undefined ? 0 : undefined)
133
+ }
134
+ data-slot="equipment-heatmap"
135
+ data-density={density}
136
+ className={cx("watt-root relative overflow-auto", className)}
137
+ style={{
138
+ height:
139
+ virtualized && cells.length
140
+ ? Math.min(height, Math.max(rowHeight, count * rowHeight))
141
+ : undefined,
142
+ ...style,
143
+ ...motionStyle(motion),
144
+ }}
145
+ onKeyDown={(event) => {
146
+ onKeyDown?.(event);
147
+ if (event.defaultPrevented || !cells.length) return;
148
+ const index = Number(
149
+ (event.target as HTMLElement).closest<HTMLElement>(
150
+ "[data-focus-index]",
151
+ )?.dataset.focusIndex ?? focused,
152
+ );
153
+ if (!Number.isInteger(index)) return;
154
+ let next: number;
155
+ switch (event.key) {
156
+ case "ArrowRight":
157
+ next = index + 1;
158
+ break;
159
+ case "ArrowLeft":
160
+ next = index - 1;
161
+ break;
162
+ case "ArrowDown":
163
+ next = index + columns;
164
+ break;
165
+ case "ArrowUp":
166
+ next = index - columns;
167
+ break;
168
+ case "Home":
169
+ next =
170
+ event.ctrlKey || event.metaKey
171
+ ? 0
172
+ : Math.floor(index / columns) * columns;
173
+ break;
174
+ case "End":
175
+ next =
176
+ event.ctrlKey || event.metaKey
177
+ ? cells.length - 1
178
+ : Math.min(
179
+ cells.length - 1,
180
+ (Math.floor(index / columns) + 1) * columns - 1,
181
+ );
182
+ break;
183
+ default:
184
+ return;
185
+ }
186
+ event.preventDefault();
187
+ next = Math.max(0, Math.min(cells.length - 1, next));
188
+ const step = next >= index ? 1 : -1;
189
+ while (next >= 0 && next < cells.length && cells[next]?.disabled)
190
+ next += step;
191
+ if (!cells[next]) return;
192
+ setFocused(next);
193
+ focus(next);
194
+ }}
195
+ >
196
+ {!cells.length ? (
197
+ empty
198
+ ) : (
199
+ <div
200
+ role="rowgroup"
201
+ style={{
202
+ position: "relative",
203
+ height: virtualized ? virtualizer.getTotalSize() : undefined,
204
+ minWidth: columns * (cellSize + 4) - 4,
205
+ }}
206
+ >
207
+ {rows.map((row) => (
208
+ <div
209
+ role="row"
210
+ key={row.index}
211
+ aria-rowindex={row.index + 1}
212
+ style={{
213
+ display: "grid",
214
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
215
+ gap: 4,
216
+ height: cellSize,
217
+ marginBottom: 4,
218
+ ...(virtualized
219
+ ? {
220
+ position: "absolute",
221
+ top: 0,
222
+ left: 0,
223
+ width: "100%",
224
+ transform: `translateY(${row.start}px)`,
225
+ }
226
+ : {}),
227
+ }}
228
+ >
229
+ {cells
230
+ .slice(row.index * columns, (row.index + 1) * columns)
231
+ .map((cell, offset) => {
232
+ const index = row.index * columns + offset;
233
+ const valid =
234
+ cell.value !== null && Number.isFinite(cell.value);
235
+ const text =
236
+ valueFormatter?.(cell) ??
237
+ (valid ? String(cell.value) : "—");
238
+ return (
239
+ <button
240
+ key={cell.id}
241
+ type="button"
242
+ role="gridcell"
243
+ aria-colindex={offset + 1}
244
+ aria-selected={selected === cell.id}
245
+ aria-label={[
246
+ cell.label,
247
+ valid ? text : "Unavailable",
248
+ cell.quality,
249
+ cell.status,
250
+ ]
251
+ .filter(Boolean)
252
+ .join(", ")}
253
+ disabled={cell.disabled}
254
+ tabIndex={index === tabIndex ? 0 : -1}
255
+ data-focus-index={index}
256
+ data-slot="equipment-heatmap-cell"
257
+ data-selected={selected === cell.id || undefined}
258
+ data-quality={cell.quality}
259
+ data-status={cell.status}
260
+ className="watt-motion watt-heatmap-cell rounded-sm border border-[var(--watt-border)] text-xs outline-offset-[-2px] focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)]"
261
+ style={{
262
+ backgroundColor: valid
263
+ ? (cell.color ?? "var(--watt-subtle)")
264
+ : "var(--watt-subtle)",
265
+ minWidth: cellSize,
266
+ height: cellSize,
267
+ }}
268
+ onFocus={() => setFocused(index)}
269
+ onClick={() => {
270
+ if (selectedId === undefined) setInternal(cell.id);
271
+ onSelectionChange?.(cell.id);
272
+ }}
273
+ >
274
+ {children
275
+ ? children(cell, {
276
+ selected: selected === cell.id,
277
+ focused: focused === index,
278
+ })
279
+ : text}
280
+ </button>
281
+ );
282
+ })}
283
+ </div>
284
+ ))}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ }
290
+ export const EquipmentHeatmap = {
291
+ Root: forwardRef(Root) as <T extends HeatmapCell>(
292
+ props: EquipmentHeatmapProps<T> & RefAttributes<HTMLDivElement>,
293
+ ) => ReactElement,
294
+ Legend: part(
295
+ "equipment-heatmap-legend",
296
+ "flex flex-wrap items-center gap-4 text-xs text-[var(--watt-muted)]",
297
+ ),
298
+ };
@@ -0,0 +1,302 @@
1
+ "use client";
2
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
4
+ import { useVirtualizer } from "@tanstack/react-virtual";
5
+ import { ArrowDown, ArrowUp } from "lucide-react";
6
+ import { cx } from "../internal.js";
7
+ import { MotionProvider, motionStyle } from "../motion.js";
8
+ import { useVirtualFocus } from "../virtual-focus.js";
9
+ import type { Density, MotionProps } from "../types.js";
10
+ import type { ForwardedRef, ReactElement, RefAttributes } from "react";
11
+ export interface EquipmentColumn<T> {
12
+ id: string;
13
+ header: React.ReactNode;
14
+ cell: (item: T) => React.ReactNode;
15
+ width?: string;
16
+ sortable?: boolean;
17
+ }
18
+ export interface TableSort {
19
+ columnId: string;
20
+ direction: "ascending" | "descending";
21
+ }
22
+ export interface EquipmentTableProps<T extends { id: string }>
23
+ extends
24
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onSelect">,
25
+ MotionProps {
26
+ items: readonly T[];
27
+ columns: readonly EquipmentColumn<T>[];
28
+ sort?: TableSort;
29
+ onSortChange?: (sort: TableSort) => void;
30
+ selectedId?: string | null;
31
+ onRowSelect?: (item: T) => void;
32
+ density?: Density;
33
+ height?: number;
34
+ rowHeight?: number;
35
+ virtualized?: boolean;
36
+ empty?: React.ReactNode;
37
+ }
38
+ function Root<T extends { id: string }>(
39
+ {
40
+ items,
41
+ columns,
42
+ sort,
43
+ onSortChange,
44
+ selectedId,
45
+ onRowSelect,
46
+ density = "comfortable",
47
+ height = 360,
48
+ rowHeight = density === "compact" ? 36 : 48,
49
+ virtualized = true,
50
+ empty,
51
+ motion,
52
+ style,
53
+ className,
54
+ onKeyDown,
55
+ ...props
56
+ }: EquipmentTableProps<T>,
57
+ ref: ForwardedRef<HTMLDivElement>,
58
+ ) {
59
+ if (
60
+ !Number.isFinite(height) ||
61
+ height <= 0 ||
62
+ !Number.isFinite(rowHeight) ||
63
+ rowHeight < 24
64
+ )
65
+ throw new Error(
66
+ "EquipmentTable requires a positive height and rowHeight >= 24",
67
+ );
68
+ useMemo(() => {
69
+ for (const list of [items, columns]) {
70
+ const ids = new Set<string>();
71
+ for (const item of list) {
72
+ if (!item.id || ids.has(item.id))
73
+ throw new Error(
74
+ "EquipmentTable row and column IDs must be nonempty and unique",
75
+ );
76
+ ids.add(item.id);
77
+ }
78
+ }
79
+ if (!columns.length)
80
+ throw new Error("EquipmentTable needs at least one column");
81
+ }, [items, columns]);
82
+ const host = useRef<HTMLDivElement>(null);
83
+ const hostRef = useComposedRefs(host, ref);
84
+ const [focused, setFocused] = useState(0);
85
+ const virtualizer = useVirtualizer({
86
+ count: items.length,
87
+ getScrollElement: () => host.current,
88
+ estimateSize: () => rowHeight,
89
+ initialRect: { width: 0, height },
90
+ overscan: 5,
91
+ enabled: virtualized,
92
+ getItemKey: (index) => items[index]!.id,
93
+ scrollMargin: rowHeight,
94
+ });
95
+ useEffect(() => {
96
+ virtualizer.measure();
97
+ }, [virtualizer, rowHeight]);
98
+ const focus = useVirtualFocus(host, (index) =>
99
+ virtualizer.scrollToIndex(index, { align: "auto" }),
100
+ );
101
+ const rows = virtualized
102
+ ? virtualizer.getVirtualItems()
103
+ : items.map((item, index) => ({
104
+ key: item.id,
105
+ index,
106
+ start: index * rowHeight,
107
+ }));
108
+ const tabIndex = rows.some((row) => row.index === focused)
109
+ ? focused
110
+ : rows[0]?.index;
111
+ const gridTemplateColumns = columns
112
+ .map((column) => column.width ?? "minmax(140px, 1fr)")
113
+ .join(" ");
114
+ return (
115
+ <MotionProvider motion={motion}>
116
+ <div
117
+ {...props}
118
+ ref={hostRef}
119
+ role={onRowSelect ? "grid" : "table"}
120
+ tabIndex={
121
+ props.tabIndex ?? (!onRowSelect || !items.length ? 0 : undefined)
122
+ }
123
+ aria-rowcount={items.length + 1}
124
+ aria-colcount={columns.length}
125
+ data-slot="equipment-table"
126
+ data-density={density}
127
+ className={cx("watt-root relative overflow-auto text-sm", className)}
128
+ style={{ maxHeight: height, ...style, ...motionStyle(motion) }}
129
+ onKeyDown={(event) => {
130
+ onKeyDown?.(event);
131
+ if (
132
+ event.defaultPrevented ||
133
+ !onRowSelect ||
134
+ event.target !== event.currentTarget.ownerDocument.activeElement
135
+ )
136
+ return;
137
+ const target = event.target as HTMLElement;
138
+ if (target.dataset.slot !== "equipment-table-row") return;
139
+ const index = Number(target.dataset.focusIndex);
140
+ let next: number;
141
+ switch (event.key) {
142
+ case "ArrowDown":
143
+ next = Math.min(items.length - 1, index + 1);
144
+ break;
145
+ case "ArrowUp":
146
+ next = Math.max(0, index - 1);
147
+ break;
148
+ case "Home":
149
+ next = 0;
150
+ break;
151
+ case "End":
152
+ next = items.length - 1;
153
+ break;
154
+ case "Enter":
155
+ case " ":
156
+ event.preventDefault();
157
+ onRowSelect(items[index]!);
158
+ return;
159
+ default:
160
+ return;
161
+ }
162
+ event.preventDefault();
163
+ setFocused(next);
164
+ focus(next);
165
+ }}
166
+ >
167
+ <div
168
+ role="rowgroup"
169
+ className="sticky top-0 z-10 bg-[var(--watt-surface)]"
170
+ >
171
+ <div
172
+ role="row"
173
+ aria-rowindex={1}
174
+ style={{
175
+ display: "grid",
176
+ gridTemplateColumns,
177
+ minWidth: "max-content",
178
+ minHeight: rowHeight,
179
+ }}
180
+ >
181
+ {columns.map((column) => (
182
+ <div
183
+ key={column.id}
184
+ role="columnheader"
185
+ aria-sort={
186
+ column.sortable
187
+ ? sort?.columnId === column.id
188
+ ? sort.direction
189
+ : "none"
190
+ : undefined
191
+ }
192
+ className="flex items-center border-b border-[var(--watt-border)] px-3 font-medium"
193
+ >
194
+ {column.sortable && onSortChange ? (
195
+ <button
196
+ type="button"
197
+ className="watt-choice inline-flex min-h-9 items-center gap-2 rounded-sm text-left focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)]"
198
+ onClick={() =>
199
+ onSortChange({
200
+ columnId: column.id,
201
+ direction:
202
+ sort?.columnId === column.id &&
203
+ sort.direction === "ascending"
204
+ ? "descending"
205
+ : "ascending",
206
+ })
207
+ }
208
+ >
209
+ {column.header}
210
+ {sort?.columnId === column.id &&
211
+ (sort.direction === "ascending" ? (
212
+ <ArrowUp size={14} />
213
+ ) : (
214
+ <ArrowDown size={14} />
215
+ ))}
216
+ </button>
217
+ ) : (
218
+ column.header
219
+ )}
220
+ </div>
221
+ ))}
222
+ </div>
223
+ </div>
224
+ {!items.length ? (
225
+ empty
226
+ ) : (
227
+ <div
228
+ role="rowgroup"
229
+ style={{
230
+ position: "relative",
231
+ height: virtualized ? virtualizer.getTotalSize() : undefined,
232
+ }}
233
+ >
234
+ {rows.map((row) => {
235
+ const item = items[row.index]!;
236
+ return (
237
+ <div
238
+ role="row"
239
+ key={item.id}
240
+ aria-rowindex={row.index + 2}
241
+ data-slot="equipment-table-row"
242
+ aria-selected={
243
+ onRowSelect ? selectedId === item.id : undefined
244
+ }
245
+ data-focus-index={row.index}
246
+ data-selected={selectedId === item.id || undefined}
247
+ tabIndex={
248
+ onRowSelect ? (tabIndex === row.index ? 0 : -1) : undefined
249
+ }
250
+ className="watt-motion border-b border-[var(--watt-border)] last:border-0 outline-offset-[-2px] data-[selected]:bg-[var(--watt-subtle)] focus-visible:outline-2 focus-visible:outline-[var(--watt-accent)]"
251
+ style={{
252
+ display: "grid",
253
+ gridTemplateColumns,
254
+ minWidth: "max-content",
255
+ height: rowHeight,
256
+ ...(virtualized
257
+ ? {
258
+ position: "absolute",
259
+ top: 0,
260
+ left: 0,
261
+ width: "100%",
262
+ transform: `translateY(${row.start - rowHeight}px)`,
263
+ }
264
+ : {}),
265
+ }}
266
+ onFocus={(event) => {
267
+ if (event.target === event.currentTarget)
268
+ setFocused(row.index);
269
+ }}
270
+ onClick={(event) => {
271
+ if (
272
+ (event.target as HTMLElement).closest(
273
+ "button,a,input,select,textarea",
274
+ )
275
+ )
276
+ return;
277
+ onRowSelect?.(item);
278
+ }}
279
+ >
280
+ {columns.map((column) => (
281
+ <div
282
+ role={onRowSelect ? "gridcell" : "cell"}
283
+ key={column.id}
284
+ className="flex min-w-0 items-center overflow-hidden px-3"
285
+ >
286
+ {column.cell(item)}
287
+ </div>
288
+ ))}
289
+ </div>
290
+ );
291
+ })}
292
+ </div>
293
+ )}
294
+ </div>
295
+ </MotionProvider>
296
+ );
297
+ }
298
+ export const EquipmentTable = {
299
+ Root: forwardRef(Root) as <T extends { id: string }>(
300
+ props: EquipmentTableProps<T> & RefAttributes<HTMLDivElement>,
301
+ ) => ReactElement,
302
+ };
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { createContext, forwardRef, useContext } from "react";
3
+ import { ArrowUpRight, ArrowDownRight, ArrowRight } from "lucide-react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cx, part } from "../internal.js";
6
+ import { MotionProvider, motionStyle } from "../motion.js";
7
+ import { PowerMetric, type MetricValueProps } from "./power-metric.js";
8
+ import type { RootProps, MotionProps, PartProps } from "../types.js";
9
+ export interface MetricTrendProps extends RootProps, MotionProps {
10
+ direction: "up" | "down" | "flat";
11
+ tone?: "neutral" | "positive" | "warning" | "critical";
12
+ }
13
+ const Context = createContext<MetricTrendProps["direction"]>("flat");
14
+ export const MetricTrend = {
15
+ Root: forwardRef<HTMLElement, MetricTrendProps>(function MetricTrendRoot(
16
+ {
17
+ direction,
18
+ tone = "neutral",
19
+ density = "comfortable",
20
+ motion,
21
+ style,
22
+ asChild,
23
+ className,
24
+ ...props
25
+ },
26
+ ref,
27
+ ) {
28
+ const Component = asChild ? Slot : "span";
29
+ return (
30
+ <MotionProvider motion={motion}>
31
+ <Context.Provider value={direction}>
32
+ <Component
33
+ {...props}
34
+ ref={ref as never}
35
+ data-slot="metric-trend"
36
+ data-direction={direction}
37
+ data-tone={tone}
38
+ data-density={density}
39
+ style={{ ...style, ...motionStyle(motion) }}
40
+ className={cx(
41
+ "watt-root inline-flex items-center gap-1.5 text-sm data-[tone=positive]:text-[var(--watt-positive)] data-[tone=warning]:text-[var(--watt-warning)] data-[tone=critical]:text-[var(--watt-critical)] data-[density=compact]:text-xs",
42
+ className,
43
+ )}
44
+ />
45
+ </Context.Provider>
46
+ </MotionProvider>
47
+ );
48
+ }),
49
+ Indicator: forwardRef<HTMLElement, PartProps>(function TrendIndicator(
50
+ { asChild, children, ...props },
51
+ ref,
52
+ ) {
53
+ const direction = useContext(Context);
54
+ const Icon =
55
+ direction === "up"
56
+ ? ArrowUpRight
57
+ : direction === "down"
58
+ ? ArrowDownRight
59
+ : ArrowRight;
60
+ const Component = asChild ? Slot : "span";
61
+ return (
62
+ <Component
63
+ {...props}
64
+ ref={ref as never}
65
+ data-slot="metric-trend-indicator"
66
+ >
67
+ {children ?? <Icon size={16} aria-hidden="true" />}
68
+ </Component>
69
+ );
70
+ }),
71
+ Value: forwardRef<HTMLElement, MetricValueProps>(function TrendValue(
72
+ { className, ...props },
73
+ ref,
74
+ ) {
75
+ return (
76
+ <PowerMetric.Value
77
+ {...props}
78
+ ref={ref}
79
+ className={cx(
80
+ "text-[length:inherit] font-medium tracking-normal",
81
+ className,
82
+ )}
83
+ />
84
+ );
85
+ }),
86
+ Label: part("metric-trend-label", "", "span"),
87
+ };