@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.112 → 2.0.0-next.113

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 (34) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +112 -13
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +101 -14
  4. package/dist/building-blocks/readout-block/readout-block.d.ts +29 -5
  5. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  6. package/dist/building-blocks/readout-block/readout-block.js +21 -7
  7. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  8. package/dist/navigation-instruments/readout/readout-formatters.d.ts +31 -0
  9. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  10. package/dist/navigation-instruments/readout/readout-formatters.js +55 -1
  11. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  12. package/dist/navigation-instruments/readout/readout.d.ts +26 -1
  13. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  14. package/dist/navigation-instruments/readout/readout.js +16 -3
  15. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  16. package/dist/navigation-instruments/readout-list/readout-list.d.ts +6 -1
  17. package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
  18. package/dist/navigation-instruments/readout-list/readout-list.js +21 -4
  19. package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
  20. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +26 -1
  21. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  22. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +15 -3
  23. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/building-blocks/readout-block/readout-block.stories.ts +112 -3
  26. package/src/building-blocks/readout-block/readout-block.ts +64 -10
  27. package/src/navigation-instruments/readout/readout-formatters.spec.ts +229 -0
  28. package/src/navigation-instruments/readout/readout-formatters.ts +105 -0
  29. package/src/navigation-instruments/readout/readout.stories.ts +167 -3
  30. package/src/navigation-instruments/readout/readout.ts +52 -4
  31. package/src/navigation-instruments/readout-list/readout-list.stories.ts +122 -1
  32. package/src/navigation-instruments/readout-list/readout-list.ts +54 -5
  33. package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +124 -4
  34. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +53 -4
@@ -20,6 +20,7 @@ import {
20
20
  type ReadoutAdviceOptions,
21
21
  type ReadoutReserverOptions,
22
22
  type ReadoutSourceOptions,
23
+ ReadoutValueType,
23
24
  } from './readout.js';
24
25
  import {
25
26
  type AlertFrameConfig,
@@ -36,7 +37,8 @@ type ReadoutStoryArgs = {
36
37
  unit: string;
37
38
  src: string;
38
39
  hasValue: boolean;
39
- value: number;
40
+ value: number | string;
41
+ valueType: ReadoutValueType;
40
42
  off: boolean;
41
43
  hasSetpoint: boolean;
42
44
  setpoint: number;
@@ -90,7 +92,8 @@ type ReadoutConfig = {
90
92
  unit?: string;
91
93
  src?: string;
92
94
  hasValue?: boolean;
93
- value?: number | null;
95
+ value?: number | string | null;
96
+ valueType?: ReadoutValueType;
94
97
  off?: boolean;
95
98
  hasSetpoint?: boolean;
96
99
  setpoint?: number;
@@ -159,6 +162,7 @@ function renderReadout(config: ReadoutConfig) {
159
162
  .src=${config.src}
160
163
  .hasValue=${config.hasValue ?? true}
161
164
  .value=${config.value ?? null}
165
+ .valueType=${config.valueType ?? ReadoutValueType.number}
162
166
  .off=${config.off ?? false}
163
167
  .hasSetpoint=${config.hasSetpoint ?? false}
164
168
  .setpoint=${config.setpoint}
@@ -241,6 +245,7 @@ const defaultArgs: ReadoutStoryArgs = {
241
245
  src: '',
242
246
  hasValue: true,
243
247
  value: 123,
248
+ valueType: ReadoutValueType.number,
244
249
  off: false,
245
250
  hasSetpoint: false,
246
251
  setpoint: 120,
@@ -306,6 +311,7 @@ const meta = {
306
311
  src: args.src,
307
312
  hasValue: args.hasValue,
308
313
  value: args.value,
314
+ valueType: args.valueType,
309
315
  off: args.off,
310
316
  hasSetpoint: args.hasSetpoint,
311
317
  setpoint: args.setpoint,
@@ -322,7 +328,16 @@ const meta = {
322
328
  hasValue: {name: 'Has Value', table: {category: 'Data'}},
323
329
  value: {
324
330
  name: 'Value',
325
- control: {type: 'number'},
331
+ // Text control (not number) so both value types are exercisable. Under
332
+ // valueType=number a numeric string resolves back to a number; entering
333
+ // non-numeric text there throws, which is the intended contract.
334
+ control: {type: 'text'},
335
+ table: {category: 'Data'},
336
+ },
337
+ valueType: {
338
+ name: 'Value Type',
339
+ control: {type: 'inline-radio'},
340
+ options: Object.values(ReadoutValueType),
326
341
  table: {category: 'Data'},
327
342
  },
328
343
  off: {name: 'Off', table: {category: 'Data'}},
@@ -1153,6 +1168,141 @@ export const DebugOverlay: StoryObj<
1153
1168
  `,
1154
1169
  };
1155
1170
 
1171
+ /**
1172
+ * **Text values** — `valueType="text"` renders `value` verbatim instead of
1173
+ * formatting it as a number, for readings that are states rather than
1174
+ * quantities ("Auto", "Thermo On", "Standby").
1175
+ *
1176
+ * The numeric format options (`fractionDigits`, `maxDigits`, `hintedZeros`) are
1177
+ * ignored in this mode; an explicit `spaceReserver` still applies. Passing text
1178
+ * while `valueType` is `number` throws a `TypeError` rather than rendering
1179
+ * `NaN` — a numeric-looking string such as `"12.4"` is still accepted and
1180
+ * parsed, so plain-HTML `value="12.4"` keeps working.
1181
+ */
1182
+ export const TextValue: Story = {
1183
+ render: () =>
1184
+ renderShowcase([
1185
+ {
1186
+ title: 'Text Value — Vertical',
1187
+ columns: 3,
1188
+ cases: [
1189
+ {
1190
+ label: 'text',
1191
+ config: {
1192
+ label: 'Mode',
1193
+ unit: '',
1194
+ value: 'Auto',
1195
+ valueType: ReadoutValueType.text,
1196
+ },
1197
+ },
1198
+ {
1199
+ label: 'text + source',
1200
+ config: {
1201
+ label: 'Thruster',
1202
+ unit: '',
1203
+ value: 'Standby',
1204
+ valueType: ReadoutValueType.text,
1205
+ src: 'CTRL',
1206
+ },
1207
+ },
1208
+ {
1209
+ label: 'text + stacked meta',
1210
+ config: {
1211
+ label: 'Status',
1212
+ unit: '',
1213
+ value: 'Normal',
1214
+ valueType: ReadoutValueType.text,
1215
+ options: {stacking: ReadoutStacking.stacked},
1216
+ },
1217
+ },
1218
+ ],
1219
+ },
1220
+ {
1221
+ title: 'Text Value — Horizontal',
1222
+ columns: 3,
1223
+ cases: [
1224
+ {
1225
+ label: 'text',
1226
+ config: {
1227
+ label: 'Mode',
1228
+ unit: '',
1229
+ value: 'Auto',
1230
+ valueType: ReadoutValueType.text,
1231
+ options: {direction: ReadoutDirection.horizontal},
1232
+ },
1233
+ },
1234
+ {
1235
+ label: 'text + source',
1236
+ config: {
1237
+ label: 'Thruster',
1238
+ unit: '',
1239
+ value: 'Standby',
1240
+ valueType: ReadoutValueType.text,
1241
+ src: 'CTRL',
1242
+ options: {direction: ReadoutDirection.horizontal},
1243
+ },
1244
+ },
1245
+ {
1246
+ label: 'numeric (contrast)',
1247
+ config: {
1248
+ label: 'SOG',
1249
+ unit: 'kn',
1250
+ value: 12.4,
1251
+ options: {
1252
+ direction: ReadoutDirection.horizontal,
1253
+ fractionDigits: 1,
1254
+ },
1255
+ },
1256
+ },
1257
+ ],
1258
+ },
1259
+ {
1260
+ title: 'Value Resolution — Sizes and Edge Cases',
1261
+ columns: 4,
1262
+ cases: [
1263
+ {
1264
+ label: 'verbatim "1.50" (trailing zero kept)',
1265
+ config: {
1266
+ label: 'Bearing',
1267
+ unit: '',
1268
+ value: '1.50',
1269
+ valueType: ReadoutValueType.text,
1270
+ options: {fractionDigits: 1},
1271
+ },
1272
+ },
1273
+ {
1274
+ label: 'null in text mode (dash)',
1275
+ config: {
1276
+ label: 'Mode',
1277
+ unit: '',
1278
+ value: null,
1279
+ valueType: ReadoutValueType.text,
1280
+ },
1281
+ },
1282
+ {
1283
+ label: 'numeric string in number mode → 12.4',
1284
+ config: {
1285
+ label: 'SOG',
1286
+ unit: 'kn',
1287
+ value: '12.4',
1288
+ options: {fractionDigits: 1},
1289
+ },
1290
+ },
1291
+ {
1292
+ label: 'text + degree (hasDegree honoured)',
1293
+ config: {
1294
+ label: 'Mode',
1295
+ unit: '',
1296
+ value: 'Auto',
1297
+ valueType: ReadoutValueType.text,
1298
+ options: {hasDegree: true},
1299
+ },
1300
+ },
1301
+ ],
1302
+ },
1303
+ ]),
1304
+ };
1305
+
1156
1306
  export const TestCases: Story = {
1157
1307
  render: () => {
1158
1308
  return html` <style>
@@ -1174,6 +1324,20 @@ export const TestCases: Story = {
1174
1324
  .value=${123}
1175
1325
  .unit=${'kn'}
1176
1326
  ></obc-readout>
1327
+ <obc-readout
1328
+ .size=${ReadoutSize.small}
1329
+ .direction=${ReadoutDirection.horizontal}
1330
+ .value=${'Thermo On'}
1331
+ .valueType=${ReadoutValueType.text}
1332
+ .label=${'Operating mode'}
1333
+ ></obc-readout>
1334
+ <obc-readout
1335
+ .size=${ReadoutSize.small}
1336
+ .direction=${ReadoutDirection.horizontal}
1337
+ .value=${'Normal'}
1338
+ .valueType=${ReadoutValueType.text}
1339
+ .label=${'Status'}
1340
+ ></obc-readout>
1177
1341
  </div>`;
1178
1342
  },
1179
1343
  };
@@ -25,7 +25,12 @@ import {
25
25
  type ReadoutSrcOptions,
26
26
  } from '../readout-list-item/readout-list-item.js';
27
27
  import {Priority} from '../types.js';
28
- import {type ReadoutNumericFormatOptions} from './readout-formatters.js';
28
+ import {
29
+ assertReadoutValueType,
30
+ resolveReadoutNumericValue,
31
+ ReadoutValueType,
32
+ type ReadoutNumericFormatOptions,
33
+ } from './readout-formatters.js';
29
34
  import {
30
35
  isDisplayedAtSetpoint,
31
36
  readoutNumericFormatOptions,
@@ -58,6 +63,7 @@ import '../../icons/icon-drop-down-google.js';
58
63
  // purpose: the two components are layout variants of the same primitives +
59
64
  // per-block options API and may merge in a future release.
60
65
  export {ObcTextboxFontWeight} from '../../components/textbox/textbox.js';
66
+ export {ReadoutValueType} from './readout-formatters.js';
61
67
  export type {
62
68
  ReadoutBlockState,
63
69
  ReadoutValueOptions,
@@ -202,6 +208,8 @@ export interface ReadoutSourceOptions extends ReadoutSrcOptions {
202
208
  * `flyout` interactivity via `srcOptions.interaction`.
203
209
  * - **Formatting:** shared `fractionDigits`, width reservation via
204
210
  * `maxDigits` and per-segment `hintedZeros`; a `null` value renders a dash.
211
+ * - **Text values:** `valueType="text"` renders `value` verbatim (e.g.
212
+ * `"Auto"`) instead of formatting it as a number.
205
213
  *
206
214
  * ### Usage Guidelines
207
215
  * Use for stand-alone instrument readouts and readouts embedded in
@@ -242,7 +250,18 @@ export class ObcReadout extends LitElement {
242
250
  * value block at full size, so the layout does not shift when data arrives.
243
251
  */
244
252
  @property({type: Boolean, attribute: false}) hasValue = true;
245
- @property({type: Number}) value: number | null = null;
253
+ /**
254
+ * The value; `null` renders a dash. A number by default, or text when
255
+ * {@link valueType} is `text`.
256
+ */
257
+ @property({type: String}) value: number | string | null = null;
258
+ /**
259
+ * How {@link value} is interpreted. `number` (default) formats it via
260
+ * `fractionDigits`; `text` renders it verbatim and ignores the numeric
261
+ * format options. Passing text while this is `number` throws.
262
+ */
263
+ @property({type: String}) valueType: ReadoutValueType =
264
+ ReadoutValueType.number;
246
265
  /** Render the value as `offText` (e.g. equipment powered down). Affects the value only. */
247
266
  @property({type: Boolean}) off = false;
248
267
  /** Text shown in place of the value when `off` is true. @availableWhen off==true */
@@ -267,7 +286,17 @@ export class ObcReadout extends LitElement {
267
286
  @property({type: Boolean}) hasDegree = false;
268
287
  /** @availableWhen hasDegree==false */
269
288
  @property({type: Boolean}) hasDegreeSpacer = false;
289
+ /**
290
+ * Also formats the numeric setpoint / advice blocks, which stay numeric
291
+ * even when the value is text.
292
+ * @availableWhen valueType==number || hasSetpoint==true || hasAdvice==true
293
+ */
270
294
  @property({type: Number}) fractionDigits = 0;
295
+ /**
296
+ * Also formats the numeric setpoint / advice blocks, which stay numeric
297
+ * even when the value is text.
298
+ * @availableWhen valueType==number || hasSetpoint==true || hasAdvice==true
299
+ */
271
300
  @property({type: Number}) maxDigits = 0;
272
301
  @property({type: String}) dataQuality?: ReadoutDataQuality;
273
302
  // `boolean | …` (not `false | …`): the generated Angular wrapper widens a
@@ -361,8 +390,10 @@ export class ObcReadout extends LitElement {
361
390
  if (!this.hasSetpoint) {
362
391
  return false;
363
392
  }
393
+ // A text value never compares equal to a setpoint, so flip-flop / pop-up
394
+ // stay dormant for `valueType="text"`.
364
395
  return isDisplayedAtSetpoint(
365
- this.value,
396
+ resolveReadoutNumericValue(this.value, this.valueType) ?? null,
366
397
  this.setpoint,
367
398
  this.numericFormatOptions(this.resolvedMaxDigits)
368
399
  );
@@ -489,7 +520,9 @@ export class ObcReadout extends LitElement {
489
520
 
490
521
  private renderBlock(config: {
491
522
  variant: ReadoutBlockVariant;
492
- value: number | null | undefined;
523
+ value: number | string | null | undefined;
524
+ /** Only the value block is ever text; setpoint / advice stay numeric. */
525
+ valueType?: ReadoutValueType;
493
526
  valueSize: ObcTextboxSize;
494
527
  enhanced: boolean;
495
528
  weight: ObcTextboxFontWeight;
@@ -518,6 +551,7 @@ export class ObcReadout extends LitElement {
518
551
  exportparts="block, block-content, block-text, block-icon, degree"
519
552
  .variant=${config.variant}
520
553
  .value=${config.value ?? null}
554
+ .valueType=${config.valueType ?? ReadoutValueType.number}
521
555
  .size=${this.resolvedSize}
522
556
  .valueSize=${config.valueSize}
523
557
  .enhanced=${config.enhanced}
@@ -676,6 +710,7 @@ export class ObcReadout extends LitElement {
676
710
  ${this.renderBlock({
677
711
  variant: ReadoutBlockVariant.value,
678
712
  value: this.value,
713
+ valueType: this.valueType,
679
714
  valueSize: this.valueSize,
680
715
  enhanced: this.rowEnhanced,
681
716
  weight: this.valueWeight,
@@ -1017,6 +1052,7 @@ export class ObcReadout extends LitElement {
1017
1052
  : html`${this.renderBlock({
1018
1053
  variant: ReadoutBlockVariant.value,
1019
1054
  value: this.value,
1055
+ valueType: this.valueType,
1020
1056
  valueSize: this.primarySize,
1021
1057
  enhanced: false,
1022
1058
  weight: this.valueWeight,
@@ -1089,6 +1125,18 @@ export class ObcReadout extends LitElement {
1089
1125
  `;
1090
1126
  }
1091
1127
 
1128
+ protected override willUpdate(changed: Map<string, unknown>): void {
1129
+ super.willUpdate(changed);
1130
+ // Validated on EVERY update, deliberately NOT gated on `value`/`valueType`
1131
+ // appearing in `changed`. When this assertion throws, Lit's `performUpdate`
1132
+ // catch calls `__markUpdated()`, which clears the changed-properties map. A
1133
+ // later update driven by any OTHER property — inside `obc-readout-list`,
1134
+ // `align()` writing the shared reservers — would then see no `value` in
1135
+ // `changed`, skip the check, and render the invalid value as a plain dash:
1136
+ // exactly the silent failure this assertion exists to prevent.
1137
+ assertReadoutValueType('obc-readout', this.value, this.valueType);
1138
+ }
1139
+
1092
1140
  override updated(changed: Map<string, unknown>): void {
1093
1141
  super.updated(changed);
1094
1142
 
@@ -7,6 +7,7 @@ import {
7
7
  ReadoutListItemPriority,
8
8
  ReadoutListItemDataQuality,
9
9
  type ReadoutValueOptions,
10
+ ReadoutValueType,
10
11
  } from '../readout-list-item/readout-list-item.js';
11
12
  import '../readout-list-item/readout-list-item.js';
12
13
  import type {AlertFrameConfig} from '../../components/alert-frame/alert-frame.js';
@@ -22,7 +23,8 @@ type ListArgs = {
22
23
 
23
24
  type Row = {
24
25
  label: string;
25
- value: number | null;
26
+ value: number | string | null;
27
+ valueType?: ReadoutValueType;
26
28
  unit: string;
27
29
  size?: ReadoutListItemSize;
28
30
  hasDegree?: boolean;
@@ -55,6 +57,7 @@ function renderRow(row: Row) {
55
57
  .label=${row.label}
56
58
  .unit=${row.unit}
57
59
  .value=${row.value}
60
+ .valueType=${row.valueType ?? ReadoutValueType.number}
58
61
  .size=${row.size ?? ReadoutListItemSize.small}
59
62
  .hasDegree=${row.hasDegree ?? false}
60
63
  .fractionDigits=${row.fractionDigits ?? 0}
@@ -112,6 +115,49 @@ export const Default: Story = {
112
115
  render: (args) => renderList(MIXED_ROWS, args.showDebugOverlay),
113
116
  };
114
117
 
118
+ /**
119
+ * **Mixed text and numeric rows.** Rows with `valueType="text"` render their
120
+ * value verbatim and are **excluded from the auto-computed numeric reserver in
121
+ * both directions**:
122
+ *
123
+ * - they do not *contribute* to it, so a long string like "Thermo On" cannot
124
+ * inflate the shared value column for every numeric row;
125
+ * - they do not *receive* it either, so a short string like "Auto" is not padded
126
+ * out to a digit width. Each text row's value block sizes to its own content.
127
+ *
128
+ * A text row's setpoint / advice blocks stay numeric and are reserved normally.
129
+ */
130
+ const TEXT_ROWS: Row[] = [
131
+ {
132
+ label: 'Operating mode',
133
+ value: 'Thermo On',
134
+ valueType: ReadoutValueType.text,
135
+ unit: '',
136
+ },
137
+ {
138
+ label: 'Status',
139
+ value: 'Normal',
140
+ valueType: ReadoutValueType.text,
141
+ unit: '',
142
+ },
143
+ // Deliberately shorter than the numeric reserve ("0000.0"), so this row
144
+ // demonstrates that a text value hugs its content instead of being padded
145
+ // out to the digit column's width.
146
+ {
147
+ label: 'Mode',
148
+ value: 'Auto',
149
+ valueType: ReadoutValueType.text,
150
+ unit: '',
151
+ },
152
+ {label: 'Temperature', value: 45, unit: 'C', hasDegree: true},
153
+ {label: 'Pressure', value: 1013, unit: 'Pa', fractionDigits: 1},
154
+ {label: 'Speed', value: 18.4, unit: 'kn', fractionDigits: 1},
155
+ ];
156
+
157
+ export const TextValues: Story = {
158
+ render: (args) => renderList(TEXT_ROWS, args.showDebugOverlay),
159
+ };
160
+
115
161
  const DEGREE_ROWS: Row[] = [
116
162
  {label: 'Heading', value: 287, unit: 'T', hasDegree: true},
117
163
  {label: 'COG', value: 92, unit: 'T', hasDegree: true},
@@ -182,6 +228,81 @@ export const WithSetpoints: Story = {
182
228
  ),
183
229
  };
184
230
 
231
+ /**
232
+ * Attribute-driven regression test for text rows. Uses plain HTML attributes
233
+ * (not property bindings) so it covers the path where `value` arrives as a raw
234
+ * string: `valuetype="text"` must render verbatim and stay out of the shared
235
+ * numeric reserver, while a numeric `value="1013"` under the default value type
236
+ * must still resolve to a number and drive the reserver.
237
+ *
238
+ * Note the attribute is `valuetype`, all lowercase — Lit lowercases a property
239
+ * name to derive its attribute rather than kebab-casing it.
240
+ */
241
+ export const TestTextRowAttributes: Story = {
242
+ render: () => html`
243
+ <div
244
+ data-obc-theme="day"
245
+ style="background: var(--container-background-color); padding: 16px; width: 360px; box-sizing: border-box;"
246
+ >
247
+ <obc-readout-list>
248
+ <obc-readout-list-item
249
+ id="text-row"
250
+ label="Operating mode"
251
+ value="Thermo On"
252
+ valuetype="text"
253
+ ></obc-readout-list-item>
254
+ <obc-readout-list-item
255
+ id="short-text-row"
256
+ label="Mode"
257
+ value="Auto"
258
+ valuetype="text"
259
+ maxdigits="8"
260
+ fractiondigits="3"
261
+ ></obc-readout-list-item>
262
+ <obc-readout-list-item
263
+ id="numeric-row"
264
+ label="Pressure"
265
+ unit="Pa"
266
+ value="1013"
267
+ ></obc-readout-list-item>
268
+ </obc-readout-list>
269
+ </div>
270
+ `,
271
+ play: async ({canvasElement}) => {
272
+ await new Promise((resolve) => requestAnimationFrame(resolve));
273
+ await new Promise((resolve) => requestAnimationFrame(resolve));
274
+
275
+ const textRow = canvasElement.querySelector('#text-row') as HTMLElement & {
276
+ valueType: ReadoutValueType;
277
+ valueOptions?: ReadoutValueOptions;
278
+ };
279
+ const shortTextRow = canvasElement.querySelector(
280
+ '#short-text-row'
281
+ ) as HTMLElement & {valueOptions?: ReadoutValueOptions};
282
+ const numericRow = canvasElement.querySelector(
283
+ '#numeric-row'
284
+ ) as HTMLElement & {valueOptions?: ReadoutValueOptions};
285
+
286
+ // The attribute reached the property as the enum value.
287
+ await expect(textRow.valueType).toBe(ReadoutValueType.text);
288
+ // Text renders verbatim. The value lives in the nested obc-readout-block's
289
+ // shadow root, so reach through it rather than the row's own.
290
+ const block = textRow.shadowRoot?.querySelector('obc-readout-block');
291
+ await expect(block?.shadowRoot?.textContent).toContain('Thermo On');
292
+
293
+ // The reserve is driven by the 4-digit numeric row only. Neither the long
294
+ // text ("Thermo On") nor the text row's own maxdigits=8 / fractiondigits=3
295
+ // inflate it — a text block ignores those, so counting them would pad every
296
+ // numeric row for nothing.
297
+ await expect(numericRow.valueOptions?.spaceReserver).toBe('0000');
298
+
299
+ // Text rows do not RECEIVE the numeric reserve either: it is a width in
300
+ // digits, so it would pad "Auto" out to the numeric column's width.
301
+ await expect(textRow.valueOptions?.spaceReserver).toBeUndefined();
302
+ await expect(shortTextRow.valueOptions?.spaceReserver).toBeUndefined();
303
+ },
304
+ };
305
+
185
306
  export const TestDynamicRow: Story = {
186
307
  render: (args) => renderList(MIXED_ROWS, args.showDebugOverlay),
187
308
  play: async ({canvasElement}) => {
@@ -4,16 +4,31 @@ import componentStyle from './readout-list.css?inline';
4
4
  import {customElement} from '../../decorator.js';
5
5
  import '../readout-list-item/readout-list-item.js';
6
6
  import {ObcReadoutListItem} from '../readout-list-item/readout-list-item.js';
7
+ import {
8
+ resolveReadoutNumericValue,
9
+ ReadoutValueType,
10
+ } from '../readout/readout-formatters.js';
7
11
 
8
12
  const ITEM_TAG = 'obc-readout-list-item';
9
13
 
14
+ // Lit lowercases a property name to derive its attribute (it does not
15
+ // kebab-case), so multi-word properties are observed as one lowercase word —
16
+ // `valueType` becomes `valuetype`.
17
+ //
18
+ // The kebab-cased entries below are therefore INERT: the real attributes are
19
+ // `maxdigits`, `fractiondigits`, `hasdegree`, `hassetpoint` and `hasadvice`, so
20
+ // changing any of them does not currently re-trigger alignment. They predate
21
+ // this list and are left as-is rather than silently activating five code paths
22
+ // that have never run; see the follow-up issue before adding more entries.
10
23
  /** Child attributes whose change should re-trigger alignment (HTML-attribute usage). */
11
24
  const OBSERVED_ATTRIBUTES = [
12
25
  'unit',
13
26
  'src',
14
27
  'value',
28
+ 'valuetype',
15
29
  'setpoint',
16
30
  'advice',
31
+ // ⚠ inert — see the note above; the real names have no hyphens.
17
32
  'max-digits',
18
33
  'fraction-digits',
19
34
  'has-degree',
@@ -21,6 +36,11 @@ const OBSERVED_ATTRIBUTES = [
21
36
  'has-advice',
22
37
  ];
23
38
 
39
+ /** Whether a row renders its value as verbatim text rather than a number. */
40
+ function isTextValueRow(item: ObcReadoutListItem): boolean {
41
+ return item.valueType === ReadoutValueType.text;
42
+ }
43
+
24
44
  /** Integer-digit count of a numeric value (sign and fraction excluded). */
25
45
  function integerDigitCount(value: number | null | undefined): number {
26
46
  if (value === null || value === undefined || Number.isNaN(value)) {
@@ -45,7 +65,12 @@ function integerDigitCount(value: number | null | undefined): number {
45
65
  * - **Value / setpoint / advice:** the widest numeric width (max integer digits +
46
66
  * max fraction digits across rows, derived from each row's `maxDigits` /
47
67
  * `fractionDigits` / current values) is reserved on every row's numeric blocks.
48
- * Reserving off digit counts keeps it stable as live values update.
68
+ * Reserving off digit counts keeps it stable as live values update. A row with
69
+ * `valueType="text"` is excluded in both directions — it neither contributes
70
+ * to the reserve (so a long text value cannot inflate the numeric column) nor
71
+ * receives it (so short text is not padded to a digit width); its value block
72
+ * sizes to its own content. Its setpoint / advice blocks stay numeric and are
73
+ * reserved normally.
49
74
  * - **Source:** the longest `src` becomes every row's source space-reserver.
50
75
  * - **Degree:** if any row has a degree, non-degree rows reserve the degree column
51
76
  * (`hasDegreeSpacer`) so their digits line up with the degree rows; the spacer is
@@ -123,11 +148,31 @@ export class ObcReadoutList extends LitElement {
123
148
  let anyDegree = false;
124
149
 
125
150
  for (const item of items) {
126
- maxFractionDigits = Math.max(maxFractionDigits, item.fractionDigits ?? 0);
151
+ // A text value renders verbatim, so it contributes nothing to the numeric
152
+ // reserve: resolving it yields `undefined` and `integerDigitCount` returns
153
+ // 0. Its row's `maxDigits` / `fractionDigits` are skipped too — they are
154
+ // ignored by a text block, so counting them would let a text row inflate
155
+ // every other row's numeric column. They DO still count when the row also
156
+ // has a setpoint or advice block, which stay numeric and are formatted by
157
+ // them. Numeric rows driven by HTML attributes resolve back to numbers
158
+ // here, so they take part as usual.
159
+ const hasNumericBlock =
160
+ !isTextValueRow(item) || item.hasSetpoint || item.hasAdvice;
161
+ if (hasNumericBlock) {
162
+ maxFractionDigits = Math.max(
163
+ maxFractionDigits,
164
+ item.fractionDigits ?? 0
165
+ );
166
+ maxIntegerDigits = Math.max(maxIntegerDigits, item.maxDigits ?? 0);
167
+ }
127
168
  maxIntegerDigits = Math.max(
128
169
  maxIntegerDigits,
129
- item.maxDigits ?? 0,
130
- integerDigitCount(item.value),
170
+ integerDigitCount(
171
+ resolveReadoutNumericValue(
172
+ item.value,
173
+ item.valueType ?? ReadoutValueType.number
174
+ )
175
+ ),
131
176
  item.hasSetpoint ? integerDigitCount(item.setpoint) : 0,
132
177
  item.hasAdvice ? integerDigitCount(item.advice) : 0
133
178
  );
@@ -155,9 +200,13 @@ export class ObcReadoutList extends LitElement {
155
200
  // Recompute every reserver / spacer on every pass (do not gate on a value
156
201
  // being present), so stale state clears when rows change — e.g. when the
157
202
  // last degree row, the last unit, or the last source is removed.
203
+ // The numeric reserve is a width in DIGITS, so applying it to a text
204
+ // block would pad short text ("Auto" padded out to "0000.0"'s width).
205
+ // Text rows hug their own content instead. Setpoint / advice stay
206
+ // numeric and keep the shared reserve even on a text row.
158
207
  item.valueOptions = {
159
208
  ...item.valueOptions,
160
- spaceReserver: numericReserver,
209
+ spaceReserver: isTextValueRow(item) ? undefined : numericReserver,
161
210
  };
162
211
  item.setpointOptions = {
163
212
  ...item.setpointOptions,