@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.83 → 2.0.0-next.85

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 (36) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +1223 -493
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +642 -196
  4. package/dist/components/tab-item/tab-item.css.js +8 -1
  5. package/dist/components/tab-item/tab-item.css.js.map +1 -1
  6. package/dist/components/tab-item/tab-item.d.ts +44 -0
  7. package/dist/components/tab-item/tab-item.d.ts.map +1 -1
  8. package/dist/components/tab-item/tab-item.js +44 -25
  9. package/dist/components/tab-item/tab-item.js.map +1 -1
  10. package/dist/components/tab-row/tab-row.d.ts +23 -9
  11. package/dist/components/tab-row/tab-row.d.ts.map +1 -1
  12. package/dist/components/tab-row/tab-row.js +24 -7
  13. package/dist/components/tab-row/tab-row.js.map +1 -1
  14. package/dist/components/textbox/textbox.css.js +29 -5
  15. package/dist/components/textbox/textbox.css.js.map +1 -1
  16. package/dist/components/textbox/textbox.d.ts +5 -0
  17. package/dist/components/textbox/textbox.d.ts.map +1 -1
  18. package/dist/components/textbox/textbox.js +6 -1
  19. package/dist/components/textbox/textbox.js.map +1 -1
  20. package/dist/navigation-instruments/readout-list-item/readout-list-item.css.js +611 -230
  21. package/dist/navigation-instruments/readout-list-item/readout-list-item.css.js.map +1 -1
  22. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +280 -61
  23. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  24. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +529 -247
  25. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/tab-item/tab-item.css +8 -1
  28. package/src/components/tab-item/tab-item.stories.ts +30 -0
  29. package/src/components/tab-item/tab-item.ts +89 -31
  30. package/src/components/tab-row/tab-row.stories.ts +20 -10
  31. package/src/components/tab-row/tab-row.ts +54 -19
  32. package/src/components/textbox/textbox.css +27 -5
  33. package/src/components/textbox/textbox.ts +6 -0
  34. package/src/navigation-instruments/readout-list-item/readout-list-item.css +505 -238
  35. package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +1604 -356
  36. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +792 -295
@@ -1,425 +1,922 @@
1
- import {LitElement, html, nothing, unsafeCSS} from 'lit';
2
- import {property} from 'lit/decorators.js';
1
+ import {LitElement, html, nothing, unsafeCSS, type TemplateResult} from 'lit';
2
+ import {property, state} from 'lit/decorators.js';
3
3
  import {classMap} from 'lit/directives/class-map.js';
4
4
  import componentStyle from './readout-list-item.css?inline';
5
5
  import {customElement} from '../../decorator.js';
6
- import {ReadoutSetpointSize} from '../readout-setpoint/readout-setpoint.js';
7
- import {ReadoutSetpointValueTypography} from '../readout-setpoint/readout-setpoint.js';
8
- import {Priority} from '../types.js';
9
- import '../readout-setpoint/readout-setpoint.js';
6
+ import '../../components/textbox/textbox.js';
7
+ import {
8
+ ObcTextboxSize,
9
+ ObcTextboxFontWeight,
10
+ } from '../../components/textbox/textbox.js';
10
11
  import '../../icons/icon-input-right.js';
11
- import {ReadoutSetpointMode} from '../readout-setpoint/readout-setpoint.js';
12
+ import '../../icons/icon-notification-advice.js';
13
+ import {
14
+ formatNumericValue,
15
+ getHintZeros,
16
+ type ReadoutNumericFormatOptions,
17
+ } from '../readout/readout-formatters.js';
12
18
  import {
13
19
  type AlertFrameConfig,
14
20
  wrapWithAlertFrame,
15
21
  } from '../../components/alert-frame/alert-frame.js';
16
22
 
17
- export enum ReadoutListItemDataState {
18
- none = 'none',
19
- lowIntegrity = 'low-integrity',
20
- invalid = 'invalid',
21
- }
23
+ // The value weight maps straight to obc-textbox's font weights (regular /
24
+ // semibold / bold). Re-exported so consumers can set `valueOptions.weight`
25
+ // without a second import path.
26
+ export {ObcTextboxFontWeight} from '../../components/textbox/textbox.js';
22
27
 
28
+ /**
29
+ * Density/size scale of the readout row.
30
+ * - `small`: regular value typography (smallest, densest).
31
+ * - `medium`: medium value typography.
32
+ * - `large`: large value typography.
33
+ */
23
34
  export enum ReadoutListItemSize {
24
- base = 'base',
25
- priority = 'priority',
26
- enhanced = 'enhanced',
35
+ small = 'small',
36
+ medium = 'medium',
37
+ large = 'large',
27
38
  }
28
39
 
40
+ /**
41
+ * Placement of the unit/source relative to the label and value.
42
+ * - `trailing-unit`: unit after the value, source after a trailing divider.
43
+ * - `leading-unit`: unit beside/under the label.
44
+ * - `leading-src`: source beside/under the label (no trailing source).
45
+ */
29
46
  export enum ReadoutListItemStacking {
30
47
  trailingUnit = 'trailing-unit',
31
48
  leadingUnit = 'leading-unit',
32
49
  leadingSrc = 'leading-src',
33
50
  }
34
51
 
52
+ /**
53
+ * Colour emphasis of the value.
54
+ * - `regular`: neutral.
55
+ * - `enhanced`: accented (in-command) colour.
56
+ */
35
57
  export enum ReadoutListItemPriority {
36
58
  regular = 'regular',
37
59
  enhanced = 'enhanced',
60
+ }
61
+
62
+ /**
63
+ * Measurement quality of the value. Orthogonal to the row-level `alert`
64
+ * – a low-integrity or invalid value can also sit inside an alert frame.
65
+ */
66
+ export enum ReadoutListItemDataQuality {
67
+ lowIntegrity = 'low-integrity',
68
+ invalid = 'invalid',
69
+ }
70
+
71
+ /**
72
+ * Corner style of the interactive (clickable) surface.
73
+ * - `squared` (default): no rounding (true rectangle).
74
+ * - `round-corners`: larger rounded corners.
75
+ * - `round`: fully rounded (pill).
76
+ */
77
+ export enum ReadoutListItemBorder {
78
+ squared = 'squared',
79
+ round = 'round',
80
+ roundCorners = 'round-corners',
81
+ }
82
+
83
+ export interface ReadoutListItemClickable {
84
+ border?: ReadoutListItemBorder;
85
+ }
86
+
87
+ /**
88
+ * Per-block state shared by value / setpoint / advice / src. Each is independent
89
+ * of (and nests inside) the row-level `dataQuality` / `alert` props. All
90
+ * combinations are allowed.
91
+ */
92
+ export interface ReadoutBlockState {
93
+ /** Per-block measurement quality (low-integrity / invalid). */
94
+ dataQuality?: ReadoutListItemDataQuality;
95
+ /** Per-block alert frame; nests inside any row-level alert frame. */
96
+ alert?: false | AlertFrameConfig;
97
+ }
98
+
99
+ export interface ReadoutValueOptions extends ReadoutBlockState {
100
+ /** Render the unfilled leading positions as muted zeroes (requires `maxDigits`). */
101
+ hintedZeros?: boolean;
102
+ /**
103
+ * Value font weight — `regular` (default), `semibold`, or `bold` (the
104
+ * obc-textbox weights). Affects weight only; it does NOT change the colour
105
+ * (colour is driven by `priority`).
106
+ */
107
+ weight?: ObcTextboxFontWeight;
108
+ /** Show the `value-icon` slot before the value. */
109
+ hasIcon?: boolean;
110
+ /**
111
+ * Longest value string to reserve width for (e.g. `"0000.0"`), so rows align
112
+ * across different value lengths / `fractionDigits` — set the same value on
113
+ * every row. Combined with the `maxDigits`/`fractionDigits`-derived reserve by
114
+ * taking whichever is **wider**, so it never reserves less than the formatted
115
+ * value needs.
116
+ */
117
+ spaceReserver?: string;
118
+ }
119
+
120
+ /**
121
+ * How the setpoint segment behaves relative to the value.
122
+ * - `always-visible` (default): the setpoint is always shown.
123
+ * - `flip-flop`: value and setpoint swap emphasis (size) as the value reaches
124
+ * the setpoint.
125
+ * - `pop-up`: the setpoint is shown only while the value has not reached it,
126
+ * then fades out (100ms) once value === setpoint.
127
+ */
128
+ export enum ReadoutListItemSetpointInteraction {
129
+ alwaysVisible = 'always-visible',
130
+ flipFlop = 'flip-flop',
131
+ popUp = 'pop-up',
132
+ }
133
+
134
+ export interface ReadoutSetpointOptions extends ReadoutBlockState {
135
+ hintedZeros?: boolean;
136
+ /** How the setpoint behaves relative to the value (default `always-visible`). */
137
+ interaction?: ReadoutListItemSetpointInteraction;
138
+ /**
139
+ * The user is physically interacting with (adjusting) the setpoint — the
140
+ * "focus" visual state. Same convention as `touching` on the instrument
141
+ * setpoint marker (`SetpointMixin` / `svghelpers/setpoint.ts`): keeps the
142
+ * setpoint visible and shows the lighter-blue focus triangle.
143
+ */
144
+ touching?: boolean;
145
+ /** Longest value string to reserve width for; see {@link ReadoutValueOptions.spaceReserver}. */
146
+ spaceReserver?: string;
147
+ }
148
+
149
+ export interface ReadoutAdviceOptions extends ReadoutBlockState {
150
+ hintedZeros?: boolean;
151
+ /** Longest value string to reserve width for; see {@link ReadoutValueOptions.spaceReserver}. */
152
+ spaceReserver?: string;
153
+ }
154
+
155
+ export interface ReadoutReserverOptions {
156
+ /** Longest expected string to reserve width for (aligns multiple rows), e.g. `"miles"`. */
157
+ spaceReserver?: string;
158
+ }
159
+
160
+ export interface ReadoutSrcOptions extends ReadoutBlockState {
161
+ /** Longest expected source string to reserve width for; see {@link ReadoutReserverOptions.spaceReserver}. */
162
+ spaceReserver?: string;
163
+ }
164
+
165
+ enum BlockRole {
166
+ value = 'value',
38
167
  setpoint = 'setpoint',
39
- setpointFlipFlop = 'setpoint-flip-flop',
168
+ advice = 'advice',
40
169
  }
41
170
 
42
171
  /**
43
- * `<obc-readout-list-item>` – A compact inline readout row for lists.
172
+ * `<obc-readout-list-item>` – A compact, dense readout row for lists and tables.
44
173
  *
45
- * Renders a compact label/value/unit composition with a dedicated size scale and stacking modes for unit and source placement. Use it when you need dense, consistent readout rows in tables or lists without bringing in the full `<obc-readout>` segment layout.
174
+ * Renders a label, an optional source, an optional unit, and up to three
175
+ * cap-height "readout building blocks" – advice, setpoint, and value – each a
176
+ * fixed-width-reservable numeric segment. Dynamic data is passed as top-level
177
+ * primitives (`value`, `setpoint`, `advice`, `label`, `unit`, `src`). Global
178
+ * layout/format is configured via top-level props (`size`, `priority`,
179
+ * `stacking`, `hasDegree`, `dataQuality`, `alert`, …) and per-block tweaks via one
180
+ * object per block (`valueOptions`, `setpointOptions`, `adviceOptions`,
181
+ * `unitOptions`, `srcOptions`).
46
182
  *
47
183
  * ### Features
48
- * - **Sizes:** `base`, `priority`, and `enhanced` typography/padding scales.
49
- * - **Stacking modes:** `trailing-unit`, `leading-unit`, and `leading-src` control where unit/source appear relative to the label/value.
50
- * - **Priority styling:** `priority` controls emphasis and setpoint presentation (`regular`, `enhanced`, `setpoint`, `setpoint-flip-flop`).
51
- * - **Data states:** Supports `dataState` styling for `low-integrity` and `invalid` data quality.
52
- * - **Alert frame:** Optional `alert` wrapper for caution, warning, alarm, and other alert-frame statuses.
53
- * - **Formatting:** Supports numeric formatting, fixed-length width templates, hinted zeros, and optional degree suffix (`°`).
184
+ * - **Building blocks:** value, optional setpoint, and optional advice segments,
185
+ * each cap-height-aligned and able to reserve a stable width.
186
+ * - **Sizes:** `small`, `medium`, `large` density scales.
187
+ * - **Stacking:** `trailing-unit`, `leading-unit`, `leading-src` placement.
188
+ * - **Priority:** `regular`/`enhanced` colour emphasis; per-value `weight`
189
+ * (`regular`/`semibold`/`bold`) is independent of colour.
190
+ * - **Setpoint flip-flop:** swaps emphasis between value and setpoint as the
191
+ * value reaches the setpoint.
192
+ * - **Data quality:** `low-integrity`/`invalid` styling, combinable with `alert`.
193
+ * - **Alert frame:** optional `alert` wrapper (caution/warning/alarm/level).
194
+ * - **Clickable:** optionally rendered as a focusable button with `squared`,
195
+ * `round-corners`, or `round` corners.
196
+ * - **Formatting:** shared `fractionDigits`, width reservation via `maxDigits`
197
+ * and per-segment `hintedZeros`; a `null` value renders a dash (`-`).
54
198
  *
55
199
  * ### Usage Guidelines
56
- * Use this component for dense readouts in list contexts. Prefer `<obc-readout>` when you need multi-segment advice/setpoint/source composition, rich layouts, or source picker/flyout behavior.
200
+ * Use for dense readout rows in lists/tables. Prefer `<obc-readout>` for rich
201
+ * multi-segment instrument layouts, source pickers, or flyout behaviour.
202
+ *
203
+ * @experimental This component is the pilot for the new primitives + per-block
204
+ * options Readout API; its API may change in a future release.
57
205
  *
58
206
  * ### Slots
59
- * | Slot Name | Renders When | Purpose |
60
- * |---------------|--------------------------|---------|
61
- * | leading-icon | `hasLeadingIcon` is true | Optional leading icon before the label. |
62
- * | value-icon | `hasValueIcon` is true | Optional icon next to the value. |
207
+ * | Slot Name | Renders When | Purpose |
208
+ * |---------------|-------------------------------|------------------------------------------|
209
+ * | leading-icon | `hasLeadingIcon` | Icon before the label. |
210
+ * | value-icon | `valueOptions.hasIcon` | Icon before the value. |
211
+ * | setpoint-icon | `hasSetpoint` | Overrides the default setpoint icon. |
212
+ * | advice-icon | `hasAdvice` | Overrides the default advice icon. |
63
213
  *
64
- * @slot leading-icon - Optional leading icon before the label.
65
- * @slot value-icon - Optional icon next to the value.
214
+ * @slot leading-icon - Icon before the label.
215
+ * @slot value-icon - Icon before the value.
216
+ * @slot setpoint-icon - Overrides the default setpoint icon.
217
+ * @slot advice-icon - Overrides the default advice icon.
66
218
  */
67
219
  @customElement('obc-readout-list-item')
68
220
  export class ObcReadoutListItem extends LitElement {
69
- @property({type: String}) size: ReadoutListItemSize =
70
- ReadoutListItemSize.base;
71
- @property({type: String})
72
- stacking: ReadoutListItemStacking = ReadoutListItemStacking.trailingUnit;
73
- @property({type: String})
74
- priority: ReadoutListItemPriority = ReadoutListItemPriority.regular;
75
- @property({type: String})
76
- dataState: ReadoutListItemDataState = ReadoutListItemDataState.none;
221
+ // Primitives (dynamic data)
222
+ @property({type: String}) label?: string;
223
+ @property({type: String}) unit?: string;
224
+ @property({type: String}) src?: string;
77
225
 
78
- @property({type: Object}) alert: AlertFrameConfig | boolean = false;
79
-
80
- @property({type: String}) label = '';
81
- @property({type: String}) unit = '';
82
- @property({type: String}) src = '';
83
-
84
- @property({type: Number}) value: number | undefined = undefined;
85
- @property({type: Number}) setpointValue: number | undefined = undefined;
226
+ @property({type: Boolean, attribute: false}) hasValue = true;
227
+ @property({type: Number}) value: number | null = null;
228
+ /** Render the value as the literal "OFF" (e.g. equipment powered down). Affects the value only. */
229
+ @property({type: Boolean}) off = false;
86
230
 
87
231
  @property({type: Boolean}) hasSetpoint = false;
88
-
89
- @property({type: Boolean}) hasDegree = false;
90
- @property({type: Boolean}) hasUnit = false;
91
- @property({type: Boolean}) hasLabel = false;
92
- @property({type: Boolean}) hasSource = false;
232
+ /** @availableWhen hasSetpoint==true */
233
+ @property({type: Number}) setpoint?: number;
234
+
235
+ @property({type: Boolean}) hasAdvice = false;
236
+ /** @availableWhen hasAdvice==true */
237
+ @property({type: Number}) advice?: number;
238
+
239
+ // Global layout/format (each defaults via its `resolved*` getter where useful).
240
+ @property({type: String}) size?: ReadoutListItemSize;
241
+ @property({type: String}) priority?: ReadoutListItemPriority;
242
+ @property({type: String}) stacking?: ReadoutListItemStacking;
243
+ @property({type: Object}) clickable: boolean | ReadoutListItemClickable =
244
+ false;
93
245
  @property({type: Boolean}) hasLeadingIcon = false;
94
- @property({type: Boolean}) hasValueIcon = false;
95
-
246
+ @property({type: Boolean}) hasDegree = false;
247
+ @property({type: Boolean}) hasDegreeSpacer = false;
96
248
  @property({type: Number}) fractionDigits = 0;
97
- @property({type: Boolean}) showZeroPadding = false;
249
+ @property({type: Number}) maxDigits = 0;
250
+ @property({type: String}) dataQuality?: ReadoutListItemDataQuality;
251
+ // `boolean | …` (not `false | …`): the generated Angular wrapper widens a
252
+ // literal-`false` union to `boolean`, which then won't assign back to a
253
+ // `false`-typed element property. `wrapWithAlertFrame` treats any non-object
254
+ // (incl. `true`) as "no frame", so accepting `boolean` is harmless.
255
+ @property({type: Object}) alert: boolean | AlertFrameConfig = false;
256
+
257
+ // Per-block configuration — one object per block (see the Readout*Options types).
258
+ @property({type: Object}) valueOptions?: ReadoutValueOptions;
259
+ @property({type: Object}) setpointOptions?: ReadoutSetpointOptions;
260
+ @property({type: Object}) adviceOptions?: ReadoutAdviceOptions;
261
+ @property({type: Object}) unitOptions?: ReadoutReserverOptions;
262
+ @property({type: Object}) srcOptions?: ReadoutSrcOptions;
263
+
264
+ /** Pop-up deferred-hide phase for the setpoint (see {@link updated}). */
265
+ @state() private deferredSetpointHidePhase: 'none' | 'hiding' | 'hidden' =
266
+ 'none';
267
+ private deferredSetpointHideTimer?: number;
268
+ private hasCompletedFirstUpdate = false;
269
+
270
+ private get resolvedSize(): ReadoutListItemSize {
271
+ return this.size ?? ReadoutListItemSize.small;
272
+ }
98
273
 
99
- @property({type: Number}) minValueLength = 0;
100
- @property({type: Boolean}) hasHintedZeros = false;
274
+ private get resolvedStacking(): ReadoutListItemStacking {
275
+ return this.stacking ?? ReadoutListItemStacking.trailingUnit;
276
+ }
101
277
 
102
- @property({type: Boolean}) labelOnly = false;
278
+ private get resolvedPriority(): ReadoutListItemPriority {
279
+ return this.priority ?? ReadoutListItemPriority.regular;
280
+ }
103
281
 
104
- private get resolvedMainValueSize(): ReadoutSetpointSize {
105
- return this.size === ReadoutListItemSize.enhanced
106
- ? ReadoutSetpointSize.large
107
- : this.size === ReadoutListItemSize.priority
108
- ? ReadoutSetpointSize.medium
109
- : ReadoutSetpointSize.regular;
282
+ private get resolvedFractionDigits(): number {
283
+ return this.fractionDigits ?? 0;
110
284
  }
111
285
 
112
- private get resolvedValueSize(): ReadoutSetpointSize {
113
- if (this.priority === ReadoutListItemPriority.setpointFlipFlop) {
114
- if (this.size === ReadoutListItemSize.priority) {
115
- return ReadoutSetpointSize.small;
116
- }
286
+ private get resolvedMaxDigits(): number {
287
+ return this.maxDigits ?? 0;
288
+ }
117
289
 
118
- if (this.size === ReadoutListItemSize.enhanced) {
119
- return ReadoutSetpointSize.regular;
120
- }
290
+ private get resolvedClickable(): false | Required<ReadoutListItemClickable> {
291
+ const clickable = this.clickable;
292
+ if (!clickable) {
293
+ return false;
121
294
  }
122
-
123
- if (this.size === ReadoutListItemSize.enhanced) {
124
- return ReadoutSetpointSize.large;
295
+ if (clickable === true) {
296
+ return {border: ReadoutListItemBorder.squared};
125
297
  }
126
- return ReadoutSetpointSize.regular;
298
+ return {border: clickable.border ?? ReadoutListItemBorder.squared};
127
299
  }
128
300
 
129
- private get resolvedValueTypography():
130
- | ReadoutSetpointValueTypography
131
- | undefined {
301
+ private get isAtSetpoint(): boolean {
132
302
  if (
133
- this.priority === ReadoutListItemPriority.setpointFlipFlop &&
134
- this.resolvedValueSize === ReadoutSetpointSize.small
303
+ !this.hasSetpoint ||
304
+ this.value === null ||
305
+ this.setpoint === undefined
135
306
  ) {
136
- return undefined;
307
+ return false;
137
308
  }
309
+ // Compare what is DISPLAYED (rounded to fractionDigits), not the raw values,
310
+ // so e.g. 29.999 and 30 at fractionDigits=0 both read "30" → at setpoint.
311
+ const formatOptions = this.numericFormatOptions(this.resolvedMaxDigits);
312
+ return (
313
+ formatNumericValue(this.value, formatOptions) ===
314
+ formatNumericValue(this.setpoint, formatOptions)
315
+ );
316
+ }
138
317
 
139
- if (
140
- this.priority === ReadoutListItemPriority.setpointFlipFlop &&
141
- this.size === ReadoutListItemSize.enhanced
142
- ) {
143
- return ReadoutSetpointValueTypography.regular;
144
- }
318
+ private get resolvedSetpointInteraction(): ReadoutListItemSetpointInteraction {
319
+ return (
320
+ this.setpointOptions?.interaction ??
321
+ ReadoutListItemSetpointInteraction.alwaysVisible
322
+ );
323
+ }
145
324
 
146
- switch (this.size) {
147
- case ReadoutListItemSize.enhanced:
148
- return ReadoutSetpointValueTypography.large;
149
- case ReadoutListItemSize.priority:
150
- return ReadoutSetpointValueTypography.medium;
151
- case ReadoutListItemSize.base:
152
- default:
153
- return ReadoutSetpointValueTypography.regular;
154
- }
325
+ private get isFlipFlop(): boolean {
326
+ return (
327
+ this.resolvedSetpointInteraction ===
328
+ ReadoutListItemSetpointInteraction.flipFlop
329
+ );
330
+ }
331
+
332
+ private get isPopUp(): boolean {
333
+ return (
334
+ this.resolvedSetpointInteraction ===
335
+ ReadoutListItemSetpointInteraction.popUp
336
+ );
337
+ }
338
+
339
+ private get setpointTouching(): boolean {
340
+ return this.setpointOptions?.touching ?? false;
155
341
  }
156
342
 
157
- private get resolvedSetpointSize(): ReadoutSetpointSize {
343
+ /**
344
+ * The setpoint is rendered "emphasised" (primary size + SemiBold weight) when
345
+ * it is the focus of attention: while actively adjusting (`touching`), or while
346
+ * a flip-flop has the value away from the setpoint. Otherwise it is a secondary
347
+ * (smaller, regular-weight) reference next to the value.
348
+ */
349
+ private get isSetpointEmphasized(): boolean {
158
350
  if (!this.hasSetpoint) {
159
- return ReadoutSetpointSize.small;
351
+ return false;
160
352
  }
161
-
162
- if (
163
- this.priority === ReadoutListItemPriority.setpoint ||
164
- this.priority === ReadoutListItemPriority.setpointFlipFlop
165
- ) {
166
- return this.resolvedMainValueSize;
353
+ if (this.setpointTouching) {
354
+ return true;
167
355
  }
356
+ return this.isFlipFlop && !this.isAtSetpoint;
357
+ }
168
358
 
169
- return ReadoutSetpointSize.small;
359
+ /**
360
+ * The row's enhanced (in-command) colour state, applied uniformly to BOTH the
361
+ * value and the setpoint — they are always either both neutral or both enhanced
362
+ * (never a blue setpoint next to a grey value). Driven by `priority` only;
363
+ * `valueOptions.weight` changes weight, not colour.
364
+ */
365
+ private get rowEnhanced(): boolean {
366
+ return this.resolvedPriority === ReadoutListItemPriority.enhanced;
170
367
  }
171
368
 
172
- private get resolvedActualPriority(): Priority {
173
- if (
174
- this.priority === ReadoutListItemPriority.enhanced ||
175
- (this.priority === ReadoutListItemPriority.setpoint &&
176
- !this.hasSetpoint) ||
177
- this.priority === ReadoutListItemPriority.setpointFlipFlop
178
- ) {
179
- return Priority.enhanced;
369
+ /** Primary value-typography size for the current density tier. */
370
+ private get primarySize(): ObcTextboxSize {
371
+ switch (this.resolvedSize) {
372
+ case ReadoutListItemSize.large:
373
+ return ObcTextboxSize.l;
374
+ case ReadoutListItemSize.medium:
375
+ return ObcTextboxSize.m;
376
+ default:
377
+ return ObcTextboxSize.s;
180
378
  }
181
-
182
- return Priority.regular;
183
379
  }
184
380
 
185
- private get resolvedActualMode(): ReadoutSetpointMode {
186
- return this.priority === ReadoutListItemPriority.enhanced
187
- ? ReadoutSetpointMode.setpoint
188
- : ReadoutSetpointMode.display;
381
+ /** Secondary (de-emphasised) value-typography size for the current density tier. */
382
+ private get secondarySize(): ObcTextboxSize {
383
+ switch (this.resolvedSize) {
384
+ case ReadoutListItemSize.large:
385
+ return ObcTextboxSize.s;
386
+ case ReadoutListItemSize.medium:
387
+ return ObcTextboxSize.s;
388
+ default:
389
+ return ObcTextboxSize.xs;
390
+ }
189
391
  }
190
392
 
191
- private get resolvedSetpointPriority(): Priority {
192
- if (
193
- !this.hasSetpoint ||
194
- this.priority === ReadoutListItemPriority.regular
195
- ) {
196
- return Priority.regular;
393
+ private get valueSize(): ObcTextboxSize {
394
+ // The value de-emphasises (secondary size) whenever the setpoint is the
395
+ // focus — while actively adjusting (`touching`) or while a flip-flop holds
396
+ // the value away from the setpoint. So "grab the setpoint" shrinks the value for
397
+ // the whole adjustment (initiate + move read the same: setpoint big, value
398
+ // small), mirroring the flip-flop convention.
399
+ if (this.isSetpointEmphasized) {
400
+ return this.secondarySize;
197
401
  }
402
+ return this.primarySize;
403
+ }
198
404
 
199
- return Priority.enhanced;
405
+ private get setpointSize(): ObcTextboxSize {
406
+ return this.isSetpointEmphasized ? this.primarySize : this.secondarySize;
200
407
  }
201
408
 
202
- private get resolvedSetpointMode(): ReadoutSetpointMode {
203
- if (
204
- this.hasSetpoint &&
205
- this.priority === ReadoutListItemPriority.setpoint
206
- ) {
207
- return ReadoutSetpointMode.setpoint;
208
- }
209
- return ReadoutSetpointMode.display;
409
+ /** Value font weight passes straight to obc-textbox; regular when unset. Colour is separate. */
410
+ private get valueWeight(): ObcTextboxFontWeight {
411
+ return this.valueOptions?.weight ?? ObcTextboxFontWeight.regular;
210
412
  }
211
413
 
212
- private get showsTrailingSource(): boolean {
213
- return (
214
- this.hasSource && this.stacking !== ReadoutListItemStacking.leadingSrc
215
- );
414
+ /** Setpoint is SemiBold only while emphasised, otherwise regular weight. */
415
+ private get setpointWeight(): ObcTextboxFontWeight {
416
+ return this.isSetpointEmphasized
417
+ ? ObcTextboxFontWeight.semibold
418
+ : ObcTextboxFontWeight.regular;
216
419
  }
217
420
 
218
- private get stacksLeadingUnitVertically(): boolean {
219
- return (
220
- this.stacking === ReadoutListItemStacking.leadingUnit &&
221
- this.size === ReadoutListItemSize.enhanced
222
- );
421
+ private numericFormatOptions(maxDigits: number): ReadoutNumericFormatOptions {
422
+ return {
423
+ showZeroPadding: false,
424
+ minValueLength: maxDigits,
425
+ fractionDigits: this.resolvedFractionDigits,
426
+ };
223
427
  }
224
428
 
225
- private get stacksLeadingSrcVertically(): boolean {
226
- return (
227
- this.stacking === ReadoutListItemStacking.leadingSrc &&
228
- this.size === ReadoutListItemSize.enhanced
229
- );
429
+ /** Widest possible value string for width reservation (e.g. `"000.0"`). */
430
+ private get reserverText(): string {
431
+ const maxDigits = this.resolvedMaxDigits;
432
+ if (maxDigits <= 0) {
433
+ return '';
434
+ }
435
+ const fractionDigits = this.resolvedFractionDigits;
436
+ const integer = '0'.repeat(Math.max(maxDigits, 1));
437
+ return fractionDigits > 0
438
+ ? `${integer}.${'0'.repeat(fractionDigits)}`
439
+ : integer;
230
440
  }
231
441
 
232
- private renderLabelContainer() {
233
- if (!this.hasLabel) {
234
- return nothing;
442
+ /**
443
+ * Effective width reserver for a numeric block: the wider of the explicit
444
+ * `spaceReserver` and the `maxDigits`/`fractionDigits`-derived reserve, so an
445
+ * explicit reserver can never reserve *less* than the formatted value needs.
446
+ * Under tabular-nums the rendered width is proportional to character count, so
447
+ * "wider" compares string length.
448
+ */
449
+ private widerReserver(explicit: string | undefined, derived: string): string {
450
+ if (!explicit) {
451
+ return derived;
452
+ }
453
+ if (!derived) {
454
+ return explicit;
235
455
  }
456
+ return explicit.length >= derived.length ? explicit : derived;
457
+ }
236
458
 
237
- const showsLeadingUnit =
238
- this.stacking === ReadoutListItemStacking.leadingUnit && this.hasUnit;
239
- const showsLeadingSrc =
240
- this.stacking === ReadoutListItemStacking.leadingSrc && this.hasSource;
459
+ /** classMap fragment for a block / source carrying per-block data quality. */
460
+ private dataQualityClasses(
461
+ dataQuality: ReadoutListItemDataQuality | undefined
462
+ ): Record<string, boolean> {
463
+ return {
464
+ 'data-low-integrity':
465
+ dataQuality === ReadoutListItemDataQuality.lowIntegrity,
466
+ 'data-invalid': dataQuality === ReadoutListItemDataQuality.invalid,
467
+ };
468
+ }
241
469
 
242
- if (showsLeadingUnit && !this.stacksLeadingUnitVertically) {
243
- return html`
244
- <div class="label-inline" part="label-inline">
245
- <div class="label" part="label">${this.label}</div>
246
- <div class="unit unit-leading" part="unit-leading">${this.unit}</div>
247
- </div>
248
- `;
470
+ private renderIcon(role: BlockRole): TemplateResult | typeof nothing {
471
+ if (role === BlockRole.value) {
472
+ if (!this.valueOptions?.hasIcon) {
473
+ return nothing;
474
+ }
475
+ return html`<span class="block-icon" aria-hidden="true"
476
+ ><slot name="value-icon"></slot
477
+ ></span>`;
249
478
  }
250
-
251
- if (showsLeadingSrc && !this.stacksLeadingSrcVertically) {
252
- return html`
253
- <div class="label-inline" part="label-inline">
254
- <div class="label" part="label">${this.label}</div>
255
- <div class="source source-inline" part="source-inline">
256
- ${this.src}
257
- </div>
258
- </div>
259
- `;
479
+ if (role === BlockRole.setpoint) {
480
+ return html`<span class="block-icon" aria-hidden="true">
481
+ <slot name="setpoint-icon"><obi-input-right></obi-input-right></slot>
482
+ </span>`;
260
483
  }
484
+ return html`<span class="block-icon" aria-hidden="true">
485
+ <slot name="advice-icon"
486
+ ><obi-notification-advice></obi-notification-advice
487
+ ></slot>
488
+ </span>`;
489
+ }
261
490
 
262
- return html`
263
- <div class="label-stack" part="label-stack">
264
- <div class="label" part="label">${this.label}</div>
265
- ${showsLeadingUnit
266
- ? html`<div class="unit unit-leading" part="unit-leading">
267
- ${this.unit}
268
- </div>`
269
- : nothing}
270
- ${showsLeadingSrc
271
- ? html`<div class="source source-inline" part="source-inline">
272
- ${this.src}
273
- </div>`
274
- : nothing}
491
+ private renderBlock(config: {
492
+ role: BlockRole;
493
+ value: number | null | undefined;
494
+ size: ObcTextboxSize;
495
+ enhanced: boolean;
496
+ weight: ObcTextboxFontWeight;
497
+ hintedZeros: boolean;
498
+ spaceReserver?: string;
499
+ off?: boolean;
500
+ hasDegree?: boolean;
501
+ extraClasses?: Record<string, boolean>;
502
+ dataQuality?: ReadoutListItemDataQuality;
503
+ alert?: false | AlertFrameConfig;
504
+ }): TemplateResult {
505
+ const formatOptions = this.numericFormatOptions(this.resolvedMaxDigits);
506
+ const valueForFormat = config.value ?? undefined;
507
+ const text = config.off
508
+ ? 'OFF'
509
+ : formatNumericValue(valueForFormat, formatOptions);
510
+ // `maxDigits` reserves INTEGER digits only (see `reserverText`), but
511
+ // getHintZeros measures total digits (it subtracts just the decimal point),
512
+ // so pad its target by `fractionDigits` to hint the right number of integer
513
+ // zeros (e.g. value 1.2, maxDigits 3, fractionDigits 1 → "001.2", not "01.2").
514
+ const hinted =
515
+ config.off || !config.hintedZeros
516
+ ? ''
517
+ : getHintZeros(valueForFormat, {
518
+ ...formatOptions,
519
+ minValueLength:
520
+ formatOptions.minValueLength + formatOptions.fractionDigits,
521
+ });
522
+ const reserver = this.widerReserver(
523
+ config.spaceReserver,
524
+ this.reserverText
525
+ );
526
+
527
+ const block = html`
528
+ <div
529
+ class=${classMap({
530
+ block: true,
531
+ [`block-${config.role}`]: true,
532
+ 'tone-enhanced': config.enhanced,
533
+ ...this.dataQualityClasses(config.dataQuality),
534
+ ...(config.extraClasses ?? {}),
535
+ })}
536
+ part="block block-${config.role}"
537
+ >
538
+ ${this.renderIcon(config.role)}
539
+ <span class="block-content">
540
+ <obc-textbox
541
+ class="block-text"
542
+ .size=${config.size}
543
+ .fontWeight=${config.weight}
544
+ .tabularNums=${true}
545
+ >
546
+ ${hinted
547
+ ? html`<span class="hinted-zero" aria-hidden="true"
548
+ >${hinted}</span
549
+ >`
550
+ : nothing}${text}
551
+ ${reserver ? html`<span slot="length">${reserver}</span>` : nothing}
552
+ </obc-textbox>
553
+ ${config.hasDegree
554
+ ? this.renderDegreeGlyph(config.size, {inherit: true})
555
+ : nothing}
556
+ </span>
275
557
  </div>
276
558
  `;
559
+ return wrapWithAlertFrame(config.alert ?? false, block);
560
+ }
561
+
562
+ /**
563
+ * A cap-height `°` column whose width scales with the value size. Used after
564
+ * the value (as the value↔unit boundary, via {@link renderValueUnitGap}) and
565
+ * inside the setpoint / advice blocks. `inherit` makes the glyph take the
566
+ * surrounding block's colour (setpoint/advice); otherwise it uses the value
567
+ * colour, optionally `enhanced`.
568
+ */
569
+ private renderDegreeGlyph(
570
+ size: ObcTextboxSize,
571
+ opts: {enhanced?: boolean; inherit?: boolean} = {}
572
+ ): TemplateResult {
573
+ return html`
574
+ <span
575
+ class=${classMap({
576
+ 'degree-column': true,
577
+ [`degree-${size}`]: true,
578
+ 'tone-enhanced': !opts.inherit && Boolean(opts.enhanced),
579
+ 'degree-inherit': Boolean(opts.inherit),
580
+ })}
581
+ part="degree"
582
+ >
583
+ <obc-textbox class="degree-glyph" .size=${size} alignment="center"
584
+ >°</obc-textbox
585
+ >
586
+ </span>
587
+ `;
277
588
  }
278
589
 
279
- private renderValueIconSlot() {
280
- if (!this.hasValueIcon) {
590
+ /**
591
+ * The gap rendered between the value digits and the unit.
592
+ *
593
+ * - `hasDegree`: a cap-height `°` column whose width scales with the value
594
+ * size (the `°` replaces the default gap).
595
+ * - otherwise: the default 2px gap (only when a trailing unit follows).
596
+ *
597
+ * `hasDegreeSpacer` deliberately does NOT add anything here — it keeps the 2px
598
+ * gap and instead widens the unit column via {@link renderDegreeSpacer} (a
599
+ * spacer AFTER the unit). That way a non-degree row's value digits stay
600
+ * aligned with degree rows (degree column width = spacer width + 2px gap)
601
+ * while its unit shifts left. Mirrors Figma `1:2920` (spacer) / `1:2970`
602
+ * (degree).
603
+ *
604
+ * TODO(designer): cross-size alignment is deferred. Degree rows of different
605
+ * value sizes have different `°` column widths (6/8/12px), so their value digit
606
+ * edges stagger by `degree-width`. For degree rows of mixed sizes you cannot
607
+ * align the value digit edges AND keep the unit column aligned — resolving it
608
+ * needs a design decision (a constant degree reserve, which widens the smaller
609
+ * rows' `°`, OR pinning the value edge and letting the unit column stagger).
610
+ */
611
+ private renderValueUnitGap(): TemplateResult | typeof nothing {
612
+ if (!this.hasValue) {
281
613
  return nothing;
282
614
  }
283
- return html`<span class="value-icon" slot="icon" aria-hidden="true">
284
- <slot name="value-icon"></slot>
285
- </span>`;
615
+ const hasTrailingUnit =
616
+ Boolean(this.unit) &&
617
+ this.resolvedStacking !== ReadoutListItemStacking.leadingUnit;
618
+
619
+ if ((this.hasDegree ?? false) && !this.off) {
620
+ return this.renderDegreeGlyph(this.valueSize, {
621
+ enhanced: this.rowEnhanced,
622
+ });
623
+ }
624
+ if (hasTrailingUnit) {
625
+ return html`<span class="value-unit-gap" aria-hidden="true"></span>`;
626
+ }
627
+ return nothing;
286
628
  }
287
629
 
288
- private renderSetpoint() {
289
- if (!this.hasSetpoint) {
630
+ /**
631
+ * A spacer rendered AFTER the unit when `hasDegreeSpacer` is set on a
632
+ * non-degree row. Its width (`degree-compensation-padding`) = the degree
633
+ * column width minus the 2px gap, so the row's value digits align with degree
634
+ * rows in the same column while its unit shifts left. See
635
+ * {@link renderValueUnitGap}.
636
+ */
637
+ private renderDegreeSpacer(): TemplateResult | typeof nothing {
638
+ const hasDegree = this.hasDegree ?? false;
639
+ const hasDegreeSpacer = this.hasDegreeSpacer ?? false;
640
+ if (hasDegree || !hasDegreeSpacer) {
290
641
  return nothing;
291
642
  }
643
+ return html`<span
644
+ class="degree-spacer"
645
+ part="degree-spacer"
646
+ aria-hidden="true"
647
+ ></span>`;
648
+ }
292
649
 
293
- return html`
294
- <obc-readout-setpoint
295
- .variant=${'setpoint'}
296
- .readoutStyle=${'regular'}
297
- .direction=${'horizontal'}
298
- .size=${this.resolvedSetpointSize}
299
- .priority=${this.resolvedSetpointPriority}
300
- .mode=${this.resolvedSetpointMode}
301
- .hugContent=${true}
302
- .value=${this.setpointValue}
303
- .showZeroPadding=${this.showZeroPadding}
304
- .fractionDigits=${this.fractionDigits}
305
- .minValueLength=${this.minValueLength}
306
- .hasHintedZeros=${this.hasHintedZeros}
307
- .hasDegree=${this.hasDegree}
650
+ private renderTextbox(
651
+ role: 'label' | 'unit' | 'source',
652
+ text: string,
653
+ reserver?: string,
654
+ state?: ReadoutBlockState
655
+ ): TemplateResult {
656
+ const weight =
657
+ role === 'label'
658
+ ? ObcTextboxFontWeight.semibold
659
+ : ObcTextboxFontWeight.regular;
660
+ const box = html`
661
+ <obc-textbox
662
+ class=${classMap({
663
+ [role]: true,
664
+ ...this.dataQualityClasses(state?.dataQuality),
665
+ })}
666
+ part=${role}
667
+ .size=${ObcTextboxSize.xs}
668
+ .fontWeight=${weight}
669
+ alignment="left"
308
670
  >
309
- <obi-input-right slot="icon"></obi-input-right>
310
- </obc-readout-setpoint>
671
+ ${text}
672
+ ${reserver ? html`<span slot="length">${reserver}</span>` : nothing}
673
+ </obc-textbox>
311
674
  `;
675
+ return wrapWithAlertFrame(state?.alert ?? false, box);
312
676
  }
313
677
 
314
- private renderActualValue() {
678
+ private renderValueCluster(): TemplateResult {
679
+ const popUpAtSetpoint =
680
+ this.isPopUp && this.isAtSetpoint && !this.setpointTouching;
681
+ const setpointExtraClasses = {
682
+ 'is-hiding':
683
+ popUpAtSetpoint && this.deferredSetpointHidePhase === 'hiding',
684
+ 'is-hidden':
685
+ popUpAtSetpoint && this.deferredSetpointHidePhase === 'hidden',
686
+ touching: this.setpointTouching,
687
+ };
315
688
  return html`
316
- <obc-readout-setpoint
317
- .variant=${'value'}
318
- .readoutStyle=${'regular'}
319
- .direction=${'horizontal'}
320
- .size=${this.resolvedValueSize}
321
- .valueTypography=${this.resolvedValueTypography ?? undefined}
322
- .priority=${this.resolvedActualPriority}
323
- .mode=${this.resolvedActualMode}
324
- .hugContent=${true}
325
- .value=${this.value}
326
- .showZeroPadding=${this.showZeroPadding}
327
- .fractionDigits=${this.fractionDigits}
328
- .minValueLength=${this.minValueLength}
329
- .hasHintedZeros=${this.hasHintedZeros}
330
- .hasDegree=${this.hasDegree}
331
- >
332
- ${this.renderValueIconSlot()}
333
- </obc-readout-setpoint>
689
+ <div class="value-cluster" part="value-cluster">
690
+ ${this.hasAdvice
691
+ ? this.renderBlock({
692
+ role: BlockRole.advice,
693
+ value: this.advice,
694
+ size: this.secondarySize,
695
+ enhanced: false,
696
+ weight: ObcTextboxFontWeight.regular,
697
+ hintedZeros: this.adviceOptions?.hintedZeros ?? false,
698
+ spaceReserver: this.adviceOptions?.spaceReserver,
699
+ hasDegree: this.hasDegree ?? false,
700
+ dataQuality: this.adviceOptions?.dataQuality,
701
+ alert: this.adviceOptions?.alert,
702
+ })
703
+ : nothing}
704
+ ${this.hasSetpoint
705
+ ? this.renderBlock({
706
+ role: BlockRole.setpoint,
707
+ value: this.setpoint,
708
+ size: this.setpointSize,
709
+ // Value and setpoint share the enhanced colour state (both neutral
710
+ // or both enhanced); the setpoint is bold only while emphasised.
711
+ enhanced: this.rowEnhanced,
712
+ weight: this.setpointWeight,
713
+ hintedZeros: this.setpointOptions?.hintedZeros ?? false,
714
+ spaceReserver: this.setpointOptions?.spaceReserver,
715
+ hasDegree: this.hasDegree ?? false,
716
+ extraClasses: setpointExtraClasses,
717
+ dataQuality: this.setpointOptions?.dataQuality,
718
+ alert: this.setpointOptions?.alert,
719
+ })
720
+ : nothing}
721
+ ${this.hasValue
722
+ ? this.renderBlock({
723
+ role: BlockRole.value,
724
+ value: this.value,
725
+ size: this.valueSize,
726
+ enhanced: this.rowEnhanced,
727
+ weight: this.valueWeight,
728
+ hintedZeros: this.valueOptions?.hintedZeros ?? false,
729
+ spaceReserver: this.valueOptions?.spaceReserver,
730
+ off: this.off,
731
+ dataQuality: this.valueOptions?.dataQuality,
732
+ alert: this.valueOptions?.alert,
733
+ })
734
+ : nothing}
735
+ </div>
334
736
  `;
335
737
  }
336
738
 
337
- private renderValue() {
739
+ private renderLabelContainer(): TemplateResult {
740
+ const stacking = this.resolvedStacking;
741
+ const showLeadingUnit =
742
+ stacking === ReadoutListItemStacking.leadingUnit && Boolean(this.unit);
743
+ const showLeadingSrc =
744
+ stacking === ReadoutListItemStacking.leadingSrc && Boolean(this.src);
745
+
338
746
  return html`
339
- <div class="value-wrap" part="value-wrap">
340
- ${this.hasSetpoint
341
- ? html`<div class="value-cluster" part="value-cluster">
342
- ${this.renderSetpoint()} ${this.renderActualValue()}
343
- </div>`
344
- : this.renderActualValue()}
747
+ <div class="label-container" part="label-container">
748
+ ${this.hasLeadingIcon
749
+ ? html`<span class="leading-icon" aria-hidden="true"
750
+ ><slot name="leading-icon"></slot
751
+ ></span>`
752
+ : nothing}
753
+ <div class="label-stack" part="label-stack">
754
+ ${this.label ? this.renderTextbox('label', this.label) : nothing}
755
+ ${showLeadingUnit
756
+ ? this.renderTextbox(
757
+ 'unit',
758
+ this.unit ?? '',
759
+ this.unitOptions?.spaceReserver
760
+ )
761
+ : nothing}
762
+ ${showLeadingSrc
763
+ ? this.renderTextbox(
764
+ 'source',
765
+ this.src ?? '',
766
+ this.srcOptions?.spaceReserver,
767
+ this.srcOptions
768
+ )
769
+ : nothing}
770
+ </div>
345
771
  </div>
346
772
  `;
347
773
  }
348
774
 
349
- private renderTrailingUnit() {
775
+ private renderTrailingUnit(): TemplateResult | typeof nothing {
350
776
  if (
351
- !this.hasUnit ||
352
- this.stacking === ReadoutListItemStacking.leadingUnit
777
+ this.resolvedStacking === ReadoutListItemStacking.leadingUnit ||
778
+ !this.unit
353
779
  ) {
354
780
  return nothing;
355
781
  }
356
-
357
- return html`<div class="unit unit-trailing" part="unit-trailing">
358
- ${this.unit}
359
- </div>`;
782
+ return this.renderTextbox(
783
+ 'unit',
784
+ this.unit,
785
+ this.unitOptions?.spaceReserver
786
+ );
360
787
  }
361
788
 
362
- private renderTrailingSource() {
363
- if (!this.showsTrailingSource) {
789
+ private renderTrailingSource(): TemplateResult | typeof nothing {
790
+ if (
791
+ this.resolvedStacking === ReadoutListItemStacking.leadingSrc ||
792
+ !this.src
793
+ ) {
364
794
  return nothing;
365
795
  }
366
-
367
796
  return html`
368
797
  <div class="divider" part="divider" aria-hidden="true"></div>
369
- <div class="source source-trailing" part="source-trailing">
370
- ${this.src}
371
- </div>
798
+ ${this.renderTextbox(
799
+ 'source',
800
+ this.src,
801
+ this.srcOptions?.spaceReserver,
802
+ this.srcOptions
803
+ )}
372
804
  `;
373
805
  }
374
806
 
375
- override render() {
376
- return wrapWithAlertFrame(
377
- this.alert,
378
- html`
379
- <div
380
- class=${classMap({
381
- root: true,
382
- [`size-${this.size}`]: true,
383
- [`stacking-${this.stacking}`]: true,
384
- 'priority-enhanced':
385
- this.priority === ReadoutListItemPriority.enhanced,
386
- 'priority-setpoint':
387
- this.priority === ReadoutListItemPriority.setpoint,
388
- 'priority-setpoint-flip-flop':
389
- this.priority === ReadoutListItemPriority.setpointFlipFlop,
390
- 'data-none': this.dataState === ReadoutListItemDataState.none,
391
- 'data-low-integrity':
392
- this.dataState === ReadoutListItemDataState.lowIntegrity,
393
- 'data-invalid': this.dataState === ReadoutListItemDataState.invalid,
394
- 'has-leading-icon': this.hasLeadingIcon,
395
- 'has-value-icon': this.hasValueIcon,
396
- })}
397
- part="root"
398
- >
399
- <div class="content" part="content">
400
- <div class="label-container" part="label-container">
401
- ${this.hasLeadingIcon
402
- ? html`<span class="leading-icon" aria-hidden="true"
403
- ><slot name="leading-icon"></slot
404
- ></span>`
405
- : nothing}
406
- ${this.renderLabelContainer()}
407
- </div>
408
-
409
- ${this.labelOnly
410
- ? nothing
411
- : html`
412
- <div class="value-container" part="value-container">
413
- ${this.renderValue()} ${this.renderTrailingUnit()}
414
- </div>
415
-
416
- ${this.renderTrailingSource()}
417
- `}
807
+ private renderContent(): TemplateResult {
808
+ return html`
809
+ <div class="content" part="content">
810
+ ${this.renderLabelContainer()}
811
+ <div class="value-area" part="value-area">
812
+ ${this.renderValueCluster()} ${this.renderValueUnitGap()}
813
+ <div class="unit-area" part="unit-area">
814
+ ${this.renderTrailingUnit()} ${this.renderDegreeSpacer()}
418
815
  </div>
419
816
  </div>
420
- `,
421
- true
422
- );
817
+ ${this.renderTrailingSource()}
818
+ </div>
819
+ `;
820
+ }
821
+
822
+ override updated(changed: Map<string, unknown>): void {
823
+ super.updated(changed);
824
+
825
+ const firstUpdate = !this.hasCompletedFirstUpdate;
826
+ this.hasCompletedFirstUpdate = true;
827
+
828
+ // Pop-up: hide the setpoint shortly after the value reaches it. `touching`
829
+ // and the non-pop-up modes keep the setpoint visible.
830
+ if (!this.isPopUp || this.setpointTouching) {
831
+ this.clearDeferredSetpointHide();
832
+ return;
833
+ }
834
+
835
+ const shouldHide = this.hasSetpoint && this.isAtSetpoint;
836
+
837
+ if (firstUpdate) {
838
+ // Settle to the resting state on mount without animating.
839
+ this.deferredSetpointHidePhase = shouldHide ? 'hidden' : 'none';
840
+ return;
841
+ }
842
+
843
+ if (!shouldHide) {
844
+ this.clearDeferredSetpointHide();
845
+ return;
846
+ }
847
+
848
+ if (this.deferredSetpointHidePhase !== 'none') {
849
+ return;
850
+ }
851
+
852
+ this.deferredSetpointHidePhase = 'hiding';
853
+ window.clearTimeout(this.deferredSetpointHideTimer);
854
+ this.deferredSetpointHideTimer = window.setTimeout(() => {
855
+ this.deferredSetpointHidePhase = 'hidden';
856
+ this.deferredSetpointHideTimer = undefined;
857
+ }, 100);
858
+ }
859
+
860
+ private clearDeferredSetpointHide(): void {
861
+ if (this.deferredSetpointHidePhase !== 'none') {
862
+ this.deferredSetpointHidePhase = 'none';
863
+ }
864
+ window.clearTimeout(this.deferredSetpointHideTimer);
865
+ this.deferredSetpointHideTimer = undefined;
866
+ }
867
+
868
+ override disconnectedCallback(): void {
869
+ window.clearTimeout(this.deferredSetpointHideTimer);
870
+ this.deferredSetpointHideTimer = undefined;
871
+ // Settle a mid-flight hide to its end state. Without this, disconnecting
872
+ // during the 100ms window leaves the phase stuck at 'hiding' (the timer that
873
+ // would advance it to 'hidden' is gone), so a later reconnect never resolves.
874
+ if (this.deferredSetpointHidePhase === 'hiding') {
875
+ this.deferredSetpointHidePhase = 'hidden';
876
+ }
877
+ super.disconnectedCallback();
878
+ }
879
+
880
+ override render() {
881
+ const clickable = this.resolvedClickable;
882
+ const dataQuality = this.dataQuality;
883
+ const classes = classMap({
884
+ root: true,
885
+ [`size-${this.resolvedSize}`]: true,
886
+ [`stacking-${this.resolvedStacking}`]: true,
887
+ [`priority-${this.resolvedPriority}`]: true,
888
+ 'data-low-integrity':
889
+ dataQuality === ReadoutListItemDataQuality.lowIntegrity,
890
+ 'data-invalid': dataQuality === ReadoutListItemDataQuality.invalid,
891
+ 'flip-flop': this.isFlipFlop,
892
+ clickable: Boolean(clickable),
893
+ [`border-${clickable ? clickable.border : ReadoutListItemBorder.squared}`]:
894
+ Boolean(clickable),
895
+ });
896
+
897
+ const surface = html`<div class="surface" part="surface">
898
+ ${this.renderContent()}
899
+ </div>`;
900
+
901
+ // No `aria-label` here: it would override the button's accessible name and
902
+ // hide the dynamic readout text (value / unit / source) from screen readers.
903
+ // The visible content (label + value + unit + source) already forms a
904
+ // complete accessible name; icons/reservers are aria-hidden / visibility-clipped.
905
+ const root = clickable
906
+ ? html`<button class=${classes} part="root" type="button">
907
+ ${surface}
908
+ </button>`
909
+ : html`<div class=${classes} part="root">${surface}</div>`;
910
+
911
+ // `alert` accepts `boolean` (so the generated Angular wrapper's widened
912
+ // `boolean` type assigns cleanly), but `wrapWithAlertFrame` ignores non-object
913
+ // truthy values. Normalise `true` → a default frame `{}` (like `clickable:
914
+ // true`) so it isn't a silent no-op; `false`/object pass through.
915
+ // fullWidth=true: the row-level alert frame stretches to the readout's full
916
+ // width (PR #1001) rather than hugging it. Per-block / src alert frames keep
917
+ // the default (hug) so they stay inline.
918
+ const alert = this.alert === true ? {} : this.alert;
919
+ return wrapWithAlertFrame(alert, root, true);
423
920
  }
424
921
 
425
922
  static override styles = unsafeCSS(componentStyle);