@procaaso/alphinity-ui-components 1.13.0 → 1.15.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/dist/index.d.cts CHANGED
@@ -528,10 +528,29 @@ interface CustomAction {
528
528
  * Device control capabilities and commands
529
529
  */
530
530
  interface ControlCapabilities {
531
- /** Can the device be started? */
531
+ /**
532
+ * Can the device be started? When `canStart` or `canStop` is true, the
533
+ * panel shows BOTH the START and the STOP button, each in a fixed place
534
+ * (alphinity-tffi #3853). A false `canStart` then shows START disabled.
535
+ * It does not remove START and it does not let STOP take its place.
536
+ */
532
537
  canStart?: boolean;
533
- /** Can the device be stopped? */
538
+ /** Can the device be stopped? See `canStart`. */
534
539
  canStop?: boolean;
540
+ /**
541
+ * Does START apply now? Default: true (enabled). Set false to show START
542
+ * disabled, for example on a pump that runs already. Use this with
543
+ * `canStart: true` to keep "the device has a START" apart from "START
544
+ * applies now". A disabled START does not call `onStart`.
545
+ */
546
+ startEnabled?: boolean;
547
+ /**
548
+ * Does STOP apply now? Default: true (enabled). Set false to show STOP
549
+ * disabled, for example on a pump that is stopped. The panel itself never
550
+ * disables STOP for a pending command. A disabled STOP does not call
551
+ * `onStop`.
552
+ */
553
+ stopEnabled?: boolean;
535
554
  /** Can the device be paused? */
536
555
  canPause?: boolean;
537
556
  /** Can the device be reset? */
@@ -609,8 +628,11 @@ interface ControlState {
609
628
  * Current status. `unknown` marks an untrusted read (OPC UA StatusCode
610
629
  * severity Bad). This library never infers `unknown`; it only displays a
611
630
  * value the backend has already published as `unknown`.
631
+ *
632
+ * `forced` marks a device that a maintenance force holds. It resolves to
633
+ * `FORCED_MARK_COLOR`, the same violet as every other forced mark.
612
634
  */
613
- status?: 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown';
635
+ status?: 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown' | 'forced';
614
636
  /** Status message */
615
637
  statusMessage?: string;
616
638
  /** Last command timestamp */
@@ -775,12 +797,18 @@ interface DeviceControlPanelProps {
775
797
  /** Called when stop button clicked */
776
798
  onStop?: () => void;
777
799
  /**
778
- * Start command is in flight (awaiting ack). Shows a busy affordance on the
779
- * START button and disables it, without locking STOP — wire from the hook's
780
- * `isCommandPending(nodeId, 'start')`.
800
+ * Start command is in flight (awaiting ack). START shows "STARTING..." and
801
+ * is disabled, so a second tap cannot send a second start. STOP stays
802
+ * enabled: a pending start never disables STOP (alphinity-tffi #3853).
803
+ * Wire from the hook's `isCommandPending(nodeId, 'start')`, or from the
804
+ * host's own in-flight state.
781
805
  */
782
806
  startPending?: boolean;
783
- /** Stop command is in flight (awaiting ack). See `startPending`. */
807
+ /**
808
+ * Stop command is in flight (awaiting ack). STOP shows "STOPPING..." and
809
+ * stays enabled, so STOP is always one tap. Only `capabilities.canStop`
810
+ * or `capabilities.stopEnabled` can disable STOP.
811
+ */
784
812
  stopPending?: boolean;
785
813
  /** Called when custom action clicked */
786
814
  onCustomAction?: (actionId: string) => void;
@@ -1166,7 +1194,7 @@ declare const ThemeToggle: React__default.FC<ThemeToggleProps>;
1166
1194
  * from a number or a threshold. The library only displays an `unknown` value
1167
1195
  * that the backend has already published.
1168
1196
  */
1169
- type DeviceStatus = 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown';
1197
+ type DeviceStatus = 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown' | 'forced';
1170
1198
  /** Color entry covering both SVG node and overlay/panel needs */
1171
1199
  interface StatusColorEntry {
1172
1200
  /** Fill color for SVG node symbols */
@@ -1219,6 +1247,22 @@ declare const ISA_UNKNOWN_DARK: {
1219
1247
  readonly bg: "#23263a";
1220
1248
  readonly border: "#6f74a3";
1221
1249
  };
1250
+ /**
1251
+ * Maintenance-force marking color (#3607, R19). A forced value is not a
1252
+ * health status, and must never share a color with warning/alarm/fault or
1253
+ * read as one of them. Violet is unused elsewhere in this palette.
1254
+ *
1255
+ * `DEFAULT_STATUS_COLORS.forced` points at this same object (alphinity-tffi
1256
+ * #3771). A device that a maintenance force holds then gets the forced
1257
+ * violet on its status dot, and not the `off` grey fallback. The forced
1258
+ * entry is a marker, not a severity: it stays outside the
1259
+ * warning/alarm/fault colours.
1260
+ *
1261
+ * Light fill + dark text, not a solid vivid chip: reads as a calm marker
1262
+ * rather than an alarm sticker, while the vivid `border` shade still
1263
+ * outlines a forced value's box/ring so it stays findable at a glance.
1264
+ */
1265
+ declare const FORCED_MARK_COLOR: StatusColorEntry;
1222
1266
  /**
1223
1267
  * ISA-101 defaults.
1224
1268
  *
@@ -1231,6 +1275,8 @@ declare const ISA_UNKNOWN_DARK: {
1231
1275
  * - stopping: amber (transitional)
1232
1276
  * - unknown: periwinkle (untrusted read; OPC UA severity Bad). Derived
1233
1277
  * from ISA_UNKNOWN above, the shared token.
1278
+ * - forced: violet (a maintenance force holds the device). The same
1279
+ * object as FORCED_MARK_COLOR above.
1234
1280
  *
1235
1281
  * The unknown entry deliberately does NOT rely on its fill. The flat fill
1236
1282
  * sits a CIEDE2000 delta-E of 0.45 from the off fill after greyscale
@@ -1245,17 +1291,6 @@ declare const ISA_UNKNOWN_DARK: {
1245
1291
  * apart. That is why unknown never relies on colour anywhere.
1246
1292
  */
1247
1293
  declare const DEFAULT_STATUS_COLORS: Record<DeviceStatus, StatusColorEntry>;
1248
- /**
1249
- * Maintenance-force marking color (#3607, R19). Deliberately outside
1250
- * DEFAULT_STATUS_COLORS: a forced value is not a health status, and must
1251
- * never share a color with warning/alarm/fault or read as one of them.
1252
- * Violet is unused elsewhere in this palette.
1253
- *
1254
- * Light fill + dark text, not a solid vivid chip: reads as a calm marker
1255
- * rather than an alarm sticker, while the vivid `border` shade still
1256
- * outlines a forced value's box/ring so it stays findable at a glance.
1257
- */
1258
- declare const FORCED_MARK_COLOR: StatusColorEntry;
1259
1294
  /**
1260
1295
  * Normalize a status string to its canonical `DeviceStatus` key.
1261
1296
  *
package/dist/index.d.ts CHANGED
@@ -528,10 +528,29 @@ interface CustomAction {
528
528
  * Device control capabilities and commands
529
529
  */
530
530
  interface ControlCapabilities {
531
- /** Can the device be started? */
531
+ /**
532
+ * Can the device be started? When `canStart` or `canStop` is true, the
533
+ * panel shows BOTH the START and the STOP button, each in a fixed place
534
+ * (alphinity-tffi #3853). A false `canStart` then shows START disabled.
535
+ * It does not remove START and it does not let STOP take its place.
536
+ */
532
537
  canStart?: boolean;
533
- /** Can the device be stopped? */
538
+ /** Can the device be stopped? See `canStart`. */
534
539
  canStop?: boolean;
540
+ /**
541
+ * Does START apply now? Default: true (enabled). Set false to show START
542
+ * disabled, for example on a pump that runs already. Use this with
543
+ * `canStart: true` to keep "the device has a START" apart from "START
544
+ * applies now". A disabled START does not call `onStart`.
545
+ */
546
+ startEnabled?: boolean;
547
+ /**
548
+ * Does STOP apply now? Default: true (enabled). Set false to show STOP
549
+ * disabled, for example on a pump that is stopped. The panel itself never
550
+ * disables STOP for a pending command. A disabled STOP does not call
551
+ * `onStop`.
552
+ */
553
+ stopEnabled?: boolean;
535
554
  /** Can the device be paused? */
536
555
  canPause?: boolean;
537
556
  /** Can the device be reset? */
@@ -609,8 +628,11 @@ interface ControlState {
609
628
  * Current status. `unknown` marks an untrusted read (OPC UA StatusCode
610
629
  * severity Bad). This library never infers `unknown`; it only displays a
611
630
  * value the backend has already published as `unknown`.
631
+ *
632
+ * `forced` marks a device that a maintenance force holds. It resolves to
633
+ * `FORCED_MARK_COLOR`, the same violet as every other forced mark.
612
634
  */
613
- status?: 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown';
635
+ status?: 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown' | 'forced';
614
636
  /** Status message */
615
637
  statusMessage?: string;
616
638
  /** Last command timestamp */
@@ -775,12 +797,18 @@ interface DeviceControlPanelProps {
775
797
  /** Called when stop button clicked */
776
798
  onStop?: () => void;
777
799
  /**
778
- * Start command is in flight (awaiting ack). Shows a busy affordance on the
779
- * START button and disables it, without locking STOP — wire from the hook's
780
- * `isCommandPending(nodeId, 'start')`.
800
+ * Start command is in flight (awaiting ack). START shows "STARTING..." and
801
+ * is disabled, so a second tap cannot send a second start. STOP stays
802
+ * enabled: a pending start never disables STOP (alphinity-tffi #3853).
803
+ * Wire from the hook's `isCommandPending(nodeId, 'start')`, or from the
804
+ * host's own in-flight state.
781
805
  */
782
806
  startPending?: boolean;
783
- /** Stop command is in flight (awaiting ack). See `startPending`. */
807
+ /**
808
+ * Stop command is in flight (awaiting ack). STOP shows "STOPPING..." and
809
+ * stays enabled, so STOP is always one tap. Only `capabilities.canStop`
810
+ * or `capabilities.stopEnabled` can disable STOP.
811
+ */
784
812
  stopPending?: boolean;
785
813
  /** Called when custom action clicked */
786
814
  onCustomAction?: (actionId: string) => void;
@@ -1166,7 +1194,7 @@ declare const ThemeToggle: React__default.FC<ThemeToggleProps>;
1166
1194
  * from a number or a threshold. The library only displays an `unknown` value
1167
1195
  * that the backend has already published.
1168
1196
  */
1169
- type DeviceStatus = 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown';
1197
+ type DeviceStatus = 'normal' | 'warning' | 'alarm' | 'fault' | 'off' | 'starting' | 'stopping' | 'unknown' | 'forced';
1170
1198
  /** Color entry covering both SVG node and overlay/panel needs */
1171
1199
  interface StatusColorEntry {
1172
1200
  /** Fill color for SVG node symbols */
@@ -1219,6 +1247,22 @@ declare const ISA_UNKNOWN_DARK: {
1219
1247
  readonly bg: "#23263a";
1220
1248
  readonly border: "#6f74a3";
1221
1249
  };
1250
+ /**
1251
+ * Maintenance-force marking color (#3607, R19). A forced value is not a
1252
+ * health status, and must never share a color with warning/alarm/fault or
1253
+ * read as one of them. Violet is unused elsewhere in this palette.
1254
+ *
1255
+ * `DEFAULT_STATUS_COLORS.forced` points at this same object (alphinity-tffi
1256
+ * #3771). A device that a maintenance force holds then gets the forced
1257
+ * violet on its status dot, and not the `off` grey fallback. The forced
1258
+ * entry is a marker, not a severity: it stays outside the
1259
+ * warning/alarm/fault colours.
1260
+ *
1261
+ * Light fill + dark text, not a solid vivid chip: reads as a calm marker
1262
+ * rather than an alarm sticker, while the vivid `border` shade still
1263
+ * outlines a forced value's box/ring so it stays findable at a glance.
1264
+ */
1265
+ declare const FORCED_MARK_COLOR: StatusColorEntry;
1222
1266
  /**
1223
1267
  * ISA-101 defaults.
1224
1268
  *
@@ -1231,6 +1275,8 @@ declare const ISA_UNKNOWN_DARK: {
1231
1275
  * - stopping: amber (transitional)
1232
1276
  * - unknown: periwinkle (untrusted read; OPC UA severity Bad). Derived
1233
1277
  * from ISA_UNKNOWN above, the shared token.
1278
+ * - forced: violet (a maintenance force holds the device). The same
1279
+ * object as FORCED_MARK_COLOR above.
1234
1280
  *
1235
1281
  * The unknown entry deliberately does NOT rely on its fill. The flat fill
1236
1282
  * sits a CIEDE2000 delta-E of 0.45 from the off fill after greyscale
@@ -1245,17 +1291,6 @@ declare const ISA_UNKNOWN_DARK: {
1245
1291
  * apart. That is why unknown never relies on colour anywhere.
1246
1292
  */
1247
1293
  declare const DEFAULT_STATUS_COLORS: Record<DeviceStatus, StatusColorEntry>;
1248
- /**
1249
- * Maintenance-force marking color (#3607, R19). Deliberately outside
1250
- * DEFAULT_STATUS_COLORS: a forced value is not a health status, and must
1251
- * never share a color with warning/alarm/fault or read as one of them.
1252
- * Violet is unused elsewhere in this palette.
1253
- *
1254
- * Light fill + dark text, not a solid vivid chip: reads as a calm marker
1255
- * rather than an alarm sticker, while the vivid `border` shade still
1256
- * outlines a forced value's box/ring so it stays findable at a glance.
1257
- */
1258
- declare const FORCED_MARK_COLOR: StatusColorEntry;
1259
1294
  /**
1260
1295
  * Normalize a status string to its canonical `DeviceStatus` key.
1261
1296
  *
package/dist/index.js CHANGED
@@ -277,6 +277,13 @@ var ISA_UNKNOWN_DARK = {
277
277
  bg: "#23263a",
278
278
  border: "#6f74a3"
279
279
  };
280
+ var FORCED_MARK_COLOR = {
281
+ fill: "#ede9fe",
282
+ stroke: "#5b21b6",
283
+ bg: "#ede9fe",
284
+ text: "#5b21b6",
285
+ border: "#7c3aed"
286
+ };
280
287
  var DEFAULT_STATUS_COLORS = {
281
288
  normal: { fill: "#888888", stroke: "#6b6b6b", bg: "#888888", text: "#ffffff", border: "#6b6b6b" },
282
289
  warning: { fill: "#f59e0b", stroke: "#d97706", bg: "#FFD700", text: "#000000", border: "#d97706" },
@@ -285,14 +292,8 @@ var DEFAULT_STATUS_COLORS = {
285
292
  off: { fill: "#edeeef", stroke: "#000000", bg: "#666666", text: "#ffffff", border: "#4b5563" },
286
293
  starting: { fill: "#90EE90", stroke: "#22c55e", bg: "#90EE90", text: "#000000", border: "#22c55e" },
287
294
  stopping: { fill: "#FFD700", stroke: "#d97706", bg: "#FFD700", text: "#000000", border: "#d97706" },
288
- unknown: { fill: ISA_UNKNOWN.bg, stroke: ISA_UNKNOWN.border, bg: ISA_UNKNOWN.bg, text: ISA_UNKNOWN.fg, border: ISA_UNKNOWN.border }
289
- };
290
- var FORCED_MARK_COLOR = {
291
- fill: "#ede9fe",
292
- stroke: "#5b21b6",
293
- bg: "#ede9fe",
294
- text: "#5b21b6",
295
- border: "#7c3aed"
295
+ unknown: { fill: ISA_UNKNOWN.bg, stroke: ISA_UNKNOWN.border, bg: ISA_UNKNOWN.bg, text: ISA_UNKNOWN.fg, border: ISA_UNKNOWN.border },
296
+ forced: FORCED_MARK_COLOR
296
297
  };
297
298
  var STATUS_ALIASES = {
298
299
  running: "normal",
@@ -1138,6 +1139,10 @@ var NodeControlsPanel = ({
1138
1139
  // src/components/DeviceControlPanel/DeviceControlPanel.tsx
1139
1140
  import React6, { useState as useState5, useEffect as useEffect2 } from "react";
1140
1141
  import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
1142
+ var DISABLED_BUTTON_BG = "#e5e7eb";
1143
+ var DISABLED_BUTTON_TEXT = "#4b5563";
1144
+ var DISABLED_BUTTON_BORDER = "#6b7280";
1145
+ var DISABLED_BUTTON_HATCH = "repeating-linear-gradient(135deg, rgba(0,0,0,0.10) 0px, rgba(0,0,0,0.10) 4px, transparent 4px, transparent 10px)";
1141
1146
  function DeviceControlPanelImpl({
1142
1147
  binding,
1143
1148
  onClose,
@@ -1218,6 +1223,36 @@ function DeviceControlPanelImpl({
1218
1223
  const statusEntry = resolveStatusColor(binding.state.status, binding.theme?.statusColors);
1219
1224
  const statusColor = statusEntry.bg;
1220
1225
  const isUnknownStatus = normalizeDeviceStatus(binding.state.status) === "unknown";
1226
+ const startStopRowShown = binding.display?.showStartStop !== false && (activeCapabilities?.canStart === true || activeCapabilities?.canStop === true);
1227
+ const startEnabledNow = activeCapabilities?.canStart === true && activeCapabilities?.startEnabled !== false && !startPending;
1228
+ const stopEnabledNow = activeCapabilities?.canStop === true && activeCapabilities?.stopEnabled !== false;
1229
+ const startStopFontPx = Math.max(18, Math.round(18 * fontScale));
1230
+ const startStopButtonStyle = (enabled, enabledBg) => ({
1231
+ flex: 1,
1232
+ minWidth: 0,
1233
+ height: Math.max(touchSize, 56),
1234
+ borderRadius: "6px",
1235
+ fontSize: `${startStopFontPx}px`,
1236
+ fontWeight: 700,
1237
+ fontFamily: "Arial, sans-serif",
1238
+ letterSpacing: "0.5px",
1239
+ ...enabled ? {
1240
+ backgroundColor: enabledBg,
1241
+ color: "#ffffff",
1242
+ border: "2px solid transparent",
1243
+ cursor: "pointer",
1244
+ opacity: 1
1245
+ } : {
1246
+ // Disabled look that does not depend on colour (ISA-101): a dashed
1247
+ // border and a diagonal hatch. A grey fill alone is not enough.
1248
+ backgroundColor: DISABLED_BUTTON_BG,
1249
+ backgroundImage: DISABLED_BUTTON_HATCH,
1250
+ color: DISABLED_BUTTON_TEXT,
1251
+ border: `2px dashed ${DISABLED_BUTTON_BORDER}`,
1252
+ cursor: "not-allowed",
1253
+ opacity: 1
1254
+ }
1255
+ });
1221
1256
  const handleModeChange = (mode) => {
1222
1257
  onModeChange?.(mode);
1223
1258
  };
@@ -1403,6 +1438,7 @@ function DeviceControlPanelImpl({
1403
1438
  /* @__PURE__ */ jsx9(
1404
1439
  "div",
1405
1440
  {
1441
+ "data-testid": "device-status-dot",
1406
1442
  style: {
1407
1443
  width: "10px",
1408
1444
  height: "10px",
@@ -1988,50 +2024,45 @@ function DeviceControlPanelImpl({
1988
2024
  param.id
1989
2025
  ))
1990
2026
  ] }),
1991
- binding.display?.showStartStop !== false && (activeCapabilities?.canStart || activeCapabilities?.canStop) && /* @__PURE__ */ jsxs8("div", { style: { display: "flex", gap: spacing.gap, marginBottom: spacing.marginBottom }, children: [
1992
- activeCapabilities?.canStart && /* @__PURE__ */ jsx9(
1993
- "button",
1994
- {
1995
- onClick: onStart,
1996
- style: {
1997
- flex: 1,
1998
- height: touchSize,
1999
- backgroundColor: "#10b981",
2000
- color: "#ffffff",
2001
- border: "none",
2002
- borderRadius: "6px",
2003
- fontSize: "12px",
2004
- fontWeight: 700,
2005
- fontFamily: "Arial, sans-serif",
2006
- letterSpacing: "0.5px",
2007
- cursor: "pointer",
2008
- opacity: 1
2009
- },
2010
- children: startPending ? "STARTING\u2026" : "START"
2011
- }
2012
- ),
2013
- activeCapabilities?.canStop && /* @__PURE__ */ jsx9(
2014
- "button",
2015
- {
2016
- onClick: onStop,
2017
- style: {
2018
- flex: 1,
2019
- height: touchSize,
2020
- backgroundColor: "#ef4444",
2021
- color: "#ffffff",
2022
- border: "none",
2023
- borderRadius: "6px",
2024
- fontSize: "12px",
2025
- fontWeight: 700,
2026
- fontFamily: "Arial, sans-serif",
2027
- letterSpacing: "0.5px",
2028
- cursor: "pointer",
2029
- opacity: 1
2030
- },
2031
- children: stopPending ? "STOPPING\u2026" : "STOP"
2032
- }
2033
- )
2034
- ] }),
2027
+ startStopRowShown && /* @__PURE__ */ jsxs8(
2028
+ "div",
2029
+ {
2030
+ "data-testid": "start-stop-row",
2031
+ style: { display: "flex", gap: spacing.gap, marginBottom: spacing.marginBottom },
2032
+ children: [
2033
+ /* @__PURE__ */ jsx9(
2034
+ "button",
2035
+ {
2036
+ type: "button",
2037
+ "data-control": "start",
2038
+ "data-enabled": startEnabledNow ? "true" : "false",
2039
+ onClick: () => {
2040
+ if (startEnabledNow) onStart?.();
2041
+ },
2042
+ disabled: !startEnabledNow,
2043
+ "aria-busy": startPending || void 0,
2044
+ style: startStopButtonStyle(startEnabledNow, "#10b981"),
2045
+ children: startPending ? "STARTING\u2026" : "START"
2046
+ }
2047
+ ),
2048
+ /* @__PURE__ */ jsx9(
2049
+ "button",
2050
+ {
2051
+ type: "button",
2052
+ "data-control": "stop",
2053
+ "data-enabled": stopEnabledNow ? "true" : "false",
2054
+ onClick: () => {
2055
+ if (stopEnabledNow) onStop?.();
2056
+ },
2057
+ disabled: !stopEnabledNow,
2058
+ "aria-busy": stopPending || void 0,
2059
+ style: startStopButtonStyle(stopEnabledNow, "#ef4444"),
2060
+ children: stopPending ? "STOPPING\u2026" : "STOP"
2061
+ }
2062
+ )
2063
+ ]
2064
+ }
2065
+ ),
2035
2066
  activeCapabilities?.customActions && activeCapabilities.customActions.length > 0 && /* @__PURE__ */ jsx9("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px" }, children: activeCapabilities.customActions.map((action) => {
2036
2067
  const actionDisabled = action.disabled === true;
2037
2068
  return /* @__PURE__ */ jsxs8(