@watt-ui/react 0.1.0 → 0.1.2

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 (54) hide show
  1. package/README.md +2 -1
  2. package/dist/components/alarm-feed.d.ts +2 -2
  3. package/dist/components/alarm-feed.d.ts.map +1 -1
  4. package/dist/components/alarm-feed.js +38 -16
  5. package/dist/components/alarm-feed.js.map +1 -1
  6. package/dist/components/asset-tree.d.ts +6 -2
  7. package/dist/components/asset-tree.d.ts.map +1 -1
  8. package/dist/components/asset-tree.js +15 -8
  9. package/dist/components/asset-tree.js.map +1 -1
  10. package/dist/components/battery-soc.d.ts +2 -2
  11. package/dist/components/battery-soc.d.ts.map +1 -1
  12. package/dist/components/battery-soc.js +4 -3
  13. package/dist/components/battery-soc.js.map +1 -1
  14. package/dist/components/data-freshness.d.ts +2 -2
  15. package/dist/components/data-freshness.d.ts.map +1 -1
  16. package/dist/components/data-freshness.js +8 -3
  17. package/dist/components/data-freshness.js.map +1 -1
  18. package/dist/components/energy-flow.d.ts.map +1 -1
  19. package/dist/components/energy-flow.js +22 -1
  20. package/dist/components/energy-flow.js.map +1 -1
  21. package/dist/components/power-metric.d.ts +5 -3
  22. package/dist/components/power-metric.d.ts.map +1 -1
  23. package/dist/components/power-metric.js +10 -5
  24. package/dist/components/power-metric.js.map +1 -1
  25. package/dist/components/status-indicator.d.ts +2 -2
  26. package/dist/components/status-indicator.d.ts.map +1 -1
  27. package/dist/components/status-indicator.js +8 -3
  28. package/dist/components/status-indicator.js.map +1 -1
  29. package/dist/components/telemetry-chart.d.ts +5 -7
  30. package/dist/components/telemetry-chart.d.ts.map +1 -1
  31. package/dist/components/telemetry-chart.js +30 -21
  32. package/dist/components/telemetry-chart.js.map +1 -1
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/motion.d.ts +12 -0
  36. package/dist/motion.d.ts.map +1 -0
  37. package/dist/motion.js +90 -0
  38. package/dist/motion.js.map +1 -0
  39. package/dist/styles.css +39 -0
  40. package/dist/types.d.ts +10 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/alarm-feed.tsx +97 -62
  44. package/src/components/asset-tree.tsx +56 -31
  45. package/src/components/battery-soc.tsx +29 -14
  46. package/src/components/data-freshness.tsx +34 -17
  47. package/src/components/energy-flow.tsx +24 -1
  48. package/src/components/power-metric.tsx +32 -17
  49. package/src/components/status-indicator.tsx +34 -15
  50. package/src/components/telemetry-chart.tsx +60 -38
  51. package/src/index.ts +5 -1
  52. package/src/motion.tsx +117 -0
  53. package/src/styles.css +39 -0
  54. package/src/types.ts +30 -4
@@ -13,6 +13,7 @@ import {
13
13
  } from "react";
14
14
  import type { EChartsCoreOption, EChartsType } from "echarts/core";
15
15
  import { cx, part } from "../internal.js";
16
+ import { MotionProvider, motionStyle, useChangeFeedback } from "../motion.js";
16
17
  import { nearestIndex, prepareSeries, lowerBound } from "../chart/data.js";
17
18
  import {
18
19
  chartOption,
@@ -24,6 +25,7 @@ import type {
24
25
  TelemetrySeries,
25
26
  TimeRange,
26
27
  DataQuality,
28
+ MotionProps,
27
29
  } from "../types.js";
28
30
  export interface ChartInspection {
29
31
  timestamp: number;
@@ -36,7 +38,9 @@ export interface ChartInspection {
36
38
  quality?: DataQuality;
37
39
  }[];
38
40
  }
39
- export interface TelemetryChartProps extends Omit<RootProps, "onError"> {
41
+ export interface TelemetryChartProps
42
+ extends Omit<RootProps, "onError">,
43
+ MotionProps {
40
44
  series: readonly TelemetrySeries[];
41
45
  timeZone: string;
42
46
  locale?: string;
@@ -83,6 +87,8 @@ const Root = forwardRef<HTMLDivElement, TelemetryChartProps>(
83
87
  asChild,
84
88
  children,
85
89
  className,
90
+ style,
91
+ motion,
86
92
  ...props
87
93
  },
88
94
  ref,
@@ -142,34 +148,37 @@ const Root = forwardRef<HTMLDivElement, TelemetryChartProps>(
142
148
  }, [options, prepared, range, timeZone]);
143
149
  const Component = asChild ? Slot : "div";
144
150
  return (
145
- <Context.Provider
146
- value={{
147
- series: prepared,
148
- timeZone,
149
- locale,
150
- range,
151
- onRangeChange,
152
- sampling,
153
- options,
154
- events,
155
- inspection,
156
- inspect,
157
- hidden,
158
- toggle,
159
- onReady,
160
- onError,
161
- }}
162
- >
163
- <Component
164
- {...props}
165
- ref={ref}
166
- data-slot="telemetry-chart"
167
- data-density={density}
168
- className={cx("watt-root min-w-0", className)}
151
+ <MotionProvider motion={motion}>
152
+ <Context.Provider
153
+ value={{
154
+ series: prepared,
155
+ timeZone,
156
+ locale,
157
+ range,
158
+ onRangeChange,
159
+ sampling,
160
+ options,
161
+ events,
162
+ inspection,
163
+ inspect,
164
+ hidden,
165
+ toggle,
166
+ onReady,
167
+ onError,
168
+ }}
169
169
  >
170
- {children}
171
- </Component>
172
- </Context.Provider>
170
+ <Component
171
+ {...props}
172
+ ref={ref}
173
+ data-slot="telemetry-chart"
174
+ data-density={density}
175
+ style={{ ...style, ...motionStyle(motion) }}
176
+ className={cx("watt-root min-w-0", className)}
177
+ >
178
+ {children}
179
+ </Component>
180
+ </Context.Provider>
181
+ </MotionProvider>
173
182
  );
174
183
  },
175
184
  );
@@ -432,11 +441,18 @@ const Legend = forwardRef<
432
441
  type="button"
433
442
  aria-pressed={!ctx.hidden.has(s.id)}
434
443
  onClick={() => ctx.toggle(s.id)}
435
- className="inline-flex items-center gap-2"
444
+ className="watt-chart-legend-toggle inline-flex items-center gap-2"
436
445
  >
437
446
  <span
438
- className="h-2 w-2 rounded-full"
439
- style={{ background: s.color ?? "var(--watt-accent)" }}
447
+ aria-hidden="true"
448
+ data-slot="telemetry-chart-legend-marker"
449
+ className="watt-motion h-2 w-2 rounded-full border border-current"
450
+ style={{
451
+ color: s.color ?? "var(--watt-accent)",
452
+ background: ctx.hidden.has(s.id)
453
+ ? "transparent"
454
+ : "currentColor",
455
+ }}
440
456
  />
441
457
  {s.label}
442
458
  </button>
@@ -446,24 +462,30 @@ const Legend = forwardRef<
446
462
  </div>
447
463
  );
448
464
  });
449
- function Tooltip({
450
- children,
451
- ...props
452
- }: Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
453
- children: (inspection: ChartInspection | null) => React.ReactNode;
454
- }) {
465
+ const Tooltip = forwardRef<
466
+ HTMLDivElement,
467
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
468
+ children: (inspection: ChartInspection | null) => React.ReactNode;
469
+ }
470
+ >(function Tooltip({ children, className, ...props }, ref) {
455
471
  const { inspection } = useChart();
472
+ const host = useRef<HTMLDivElement>(null);
473
+ const hostRef = useComposedRefs(host, ref);
474
+ useChangeFeedback(host, inspection !== null);
456
475
  return (
457
476
  <div
458
477
  {...props}
478
+ ref={hostRef}
459
479
  data-slot="telemetry-chart-tooltip"
480
+ data-state={inspection ? "active" : "idle"}
481
+ className={cx("watt-motion", className)}
460
482
  aria-live={props["aria-live"] ?? "polite"}
461
483
  aria-atomic={props["aria-atomic"] ?? true}
462
484
  >
463
485
  {children(inspection)}
464
486
  </div>
465
487
  );
466
- }
488
+ });
467
489
  export const TelemetryChart = {
468
490
  Root,
469
491
  Canvas,
package/src/index.ts CHANGED
@@ -8,7 +8,10 @@ export { AlarmFeed } from "./components/alarm-feed.js";
8
8
  export { AssetTree } from "./components/asset-tree.js";
9
9
  export { formatNumber, formatTime, parseTimestamp } from "./domain.js";
10
10
  export type * from "./types.js";
11
- export type { MetricValueProps } from "./components/power-metric.js";
11
+ export type {
12
+ PowerMetricProps,
13
+ MetricValueProps,
14
+ } from "./components/power-metric.js";
12
15
  export type { StatusIndicatorProps } from "./components/status-indicator.js";
13
16
  export type { BatterySOCProps } from "./components/battery-soc.js";
14
17
  export type {
@@ -34,4 +37,5 @@ export type {
34
37
  AssetTreeProps,
35
38
  AssetTreeRow,
36
39
  AssetTreeItem,
40
+ AssetTreeIndicatorProps,
37
41
  } from "./components/asset-tree.js";
package/src/motion.tsx ADDED
@@ -0,0 +1,117 @@
1
+ "use client";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ type RefObject,
11
+ } from "react";
12
+ import type { Motion, MotionProps } from "./types.js";
13
+
14
+ const Context = createContext<Motion>(true);
15
+ const useMotionEffect =
16
+ typeof document === "undefined" ? useEffect : useLayoutEffect;
17
+ const noop = () => {};
18
+ export function useMotion(own?: Motion): Motion {
19
+ const parent = useContext(Context);
20
+ return own ?? parent;
21
+ }
22
+
23
+ export function MotionProvider({
24
+ motion,
25
+ children,
26
+ }: MotionProps & { children: ReactNode }) {
27
+ const parent = useContext(Context);
28
+ return (
29
+ <Context.Provider value={motion ?? parent}>{children}</Context.Provider>
30
+ );
31
+ }
32
+
33
+ export function motionStyle(motion?: Motion): CSSProperties {
34
+ if (motion === undefined) return {};
35
+ const duration =
36
+ motion === false ? 0 : motion === true ? 240 : (motion.duration ?? 240);
37
+ if (!Number.isFinite(duration) || duration < 0)
38
+ throw new Error(
39
+ "Motion duration must be finite and nonnegative milliseconds",
40
+ );
41
+ return {
42
+ "--watt-motion-duration": `${duration}ms`,
43
+ "--watt-motion-property": duration === 0 ? "none" : "initial",
44
+ "--watt-motion-easing":
45
+ typeof motion === "object"
46
+ ? (motion.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)")
47
+ : "cubic-bezier(0.16, 1, 0.3, 1)",
48
+ } as CSSProperties;
49
+ }
50
+
51
+ /** Bounded feedback only: never changes text, data, row geometry or DOM identity. */
52
+ export function animateMotion(
53
+ element: HTMLElement,
54
+ frames: Keyframe[],
55
+ ): () => void {
56
+ if (!element.animate || document.hidden || !element.getClientRects().length)
57
+ return noop;
58
+ const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
59
+ if (preference.matches) return noop;
60
+ const styles = getComputedStyle(element);
61
+ const time = styles.transitionDuration.split(",")[0] ?? "0s";
62
+ const duration = parseFloat(time) * (time.trim().endsWith("ms") ? 1 : 1000);
63
+ if (!(duration > 0)) return noop;
64
+ let animation: Animation;
65
+ try {
66
+ animation = element.animate(frames, {
67
+ duration,
68
+ easing: styles.transitionTimingFunction,
69
+ });
70
+ } catch {
71
+ return noop; // A consumer's unsupported CSS easing must not block live readings.
72
+ }
73
+ const detach = () => {
74
+ preference.removeEventListener("change", stop);
75
+ document.removeEventListener("visibilitychange", onVisibility);
76
+ };
77
+ const stop = () => {
78
+ animation.cancel();
79
+ detach();
80
+ };
81
+ const onVisibility = () => {
82
+ if (document.hidden) stop();
83
+ };
84
+ preference.addEventListener("change", stop);
85
+ document.addEventListener("visibilitychange", onVisibility);
86
+ animation.addEventListener("finish", detach, { once: true });
87
+ animation.addEventListener("cancel", detach, { once: true });
88
+ return stop;
89
+ }
90
+
91
+ export function highlightChange(element: HTMLElement): () => void {
92
+ const styles = getComputedStyle(element);
93
+ const background = styles.backgroundColor;
94
+ const accent = styles.getPropertyValue("--watt-accent").trim() || "#355dff";
95
+ return animateMotion(element, [
96
+ { backgroundColor: `color-mix(in srgb, ${accent} 10%, ${background})` },
97
+ { backgroundColor: background },
98
+ ]);
99
+ }
100
+
101
+ export function useChangeFeedback(
102
+ ref: RefObject<HTMLElement | null>,
103
+ value: unknown,
104
+ ownMotion?: Motion,
105
+ ) {
106
+ const motion = useMotion(ownMotion);
107
+ const duration = typeof motion === "object" ? motion.duration : undefined;
108
+ const easing = typeof motion === "object" ? motion.easing : undefined;
109
+ const previous = useRef(value);
110
+ useMotionEffect(() => {
111
+ const changed = !Object.is(previous.current, value);
112
+ previous.current = value;
113
+ if (changed && motion !== false && ref.current)
114
+ return highlightChange(ref.current);
115
+ }, [value, ref, motion === false, duration, easing]);
116
+ }
117
+
package/src/styles.css CHANGED
@@ -22,6 +22,8 @@
22
22
  --watt-warning: #996009;
23
23
  --watt-critical: #c43b40;
24
24
  --watt-radius: 12px;
25
+ --watt-motion-duration: 240ms;
26
+ --watt-motion-easing: cubic-bezier(0.16, 1, 0.3, 1);
25
27
  }
26
28
  .dark,
27
29
  [data-theme="dark"] {
@@ -39,6 +41,36 @@
39
41
  color: var(--watt-foreground);
40
42
  font-variant-numeric: tabular-nums;
41
43
  }
44
+ .watt-motion {
45
+ transition-property: none;
46
+ transition-duration: var(--watt-motion-duration, 240ms);
47
+ transition-timing-function: var(--watt-motion-easing, ease-out);
48
+ }
49
+ .watt-motion.watt-status {
50
+ transition-property: var(--watt-motion-property, color);
51
+ }
52
+ .watt-soc-fill {
53
+ transition-property: var(--watt-motion-property, width);
54
+ }
55
+ [data-slot="battery-soc"][data-invalid] .watt-soc-fill {
56
+ transition-duration: 0s;
57
+ }
58
+ [data-slot="asset-tree-row"] {
59
+ transition-property: var(--watt-motion-property, background-color);
60
+ }
61
+ [data-slot="asset-tree-indicator"] {
62
+ transition-property: var(--watt-motion-property, transform);
63
+ }
64
+ [data-slot="telemetry-chart-legend-marker"] {
65
+ transition-property: var(--watt-motion-property, background-color, scale);
66
+ }
67
+ .watt-chart-legend-toggle[aria-pressed="false"] {
68
+ text-decoration: line-through;
69
+ }
70
+ .watt-chart-legend-toggle[aria-pressed="false"]
71
+ [data-slot="telemetry-chart-legend-marker"] {
72
+ scale: 0.75;
73
+ }
42
74
  .watt-status {
43
75
  color: var(--watt-muted);
44
76
  }
@@ -120,6 +152,13 @@
120
152
  }
121
153
  }
122
154
  @media (prefers-reduced-motion: reduce) {
155
+ .watt-motion {
156
+ transition-duration: 0s !important;
157
+ transition-property: none !important;
158
+ }
159
+ .watt-status {
160
+ transition: none;
161
+ }
123
162
  .watt-flow-dash,
124
163
  .watt-flow-pulse {
125
164
  animation: none !important;
package/src/types.ts CHANGED
@@ -1,15 +1,41 @@
1
- import type { HTMLAttributes } from "react";
1
+ import type { CSSProperties, HTMLAttributes } from "react";
2
2
 
3
3
  export type PowerUnit = "W" | "kW" | "MW";
4
4
  export type EnergyUnit = "Wh" | "kWh" | "MWh";
5
5
  export type MeasurementUnit =
6
- PowerUnit | EnergyUnit | "V" | "A" | "Hz" | "°C" | "%" | "W/m²";
6
+ | PowerUnit
7
+ | EnergyUnit
8
+ | "V"
9
+ | "A"
10
+ | "Hz"
11
+ | "°C"
12
+ | "%"
13
+ | "W/m²";
7
14
  export type Timestamp = string | number;
8
15
  export type DataQuality =
9
- "good" | "uncertain" | "stale" | "missing" | "bad" | "offline";
16
+ | "good"
17
+ | "uncertain"
18
+ | "stale"
19
+ | "missing"
20
+ | "bad"
21
+ | "offline";
10
22
  export type EquipmentStatus =
11
- "normal" | "standby" | "stopped" | "warning" | "fault" | "offline";
23
+ | "normal"
24
+ | "standby"
25
+ | "stopped"
26
+ | "warning"
27
+ | "fault"
28
+ | "offline";
12
29
  export type Density = "compact" | "comfortable";
30
+ export interface MotionOptions {
31
+ /** Duration in milliseconds; zero disables transitions. */
32
+ duration?: number;
33
+ easing?: CSSProperties["transitionTimingFunction"];
34
+ }
35
+ export type Motion = boolean | MotionOptions;
36
+ export interface MotionProps {
37
+ motion?: Motion;
38
+ }
13
39
  export interface PartProps extends HTMLAttributes<HTMLElement> {
14
40
  asChild?: boolean;
15
41
  }