@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.82 → 2.0.0-next.84

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.
@@ -1,11 +1,21 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {html, nothing} from 'lit';
3
- import {useArgs} from 'storybook/preview-api';
3
+ import {userEvent} from 'storybook/test';
4
+ import {gsap} from 'gsap';
4
5
  import {
5
6
  ReadoutListItemSize,
6
7
  ReadoutListItemStacking,
7
- ReadoutListItemDataState,
8
8
  ReadoutListItemPriority,
9
+ ReadoutListItemDataQuality,
10
+ ReadoutListItemBorder,
11
+ ReadoutListItemSetpointInteraction,
12
+ ObcTextboxFontWeight,
13
+ type ReadoutListItemClickable,
14
+ type ReadoutValueOptions,
15
+ type ReadoutSetpointOptions,
16
+ type ReadoutAdviceOptions,
17
+ type ReadoutReserverOptions,
18
+ type ReadoutSrcOptions,
9
19
  } from './readout-list-item.js';
10
20
  import type {AlertFrameConfig} from '../../components/alert-frame/alert-frame.js';
11
21
  import {
@@ -15,216 +25,207 @@ import {
15
25
  import {AlertType} from '../../types.js';
16
26
  import './readout-list-item.js';
17
27
  import '../../icons/icon-placeholder.js';
28
+ import '../azimuth-thruster/azimuth-thruster.js';
29
+ import {InstrumentState, Priority} from '../types.js';
30
+
31
+ const NONE = 'none';
18
32
 
19
33
  type ReadoutListItemStoryArgs = {
20
- size: ReadoutListItemSize;
21
- stacking: ReadoutListItemStacking;
22
- priority: ReadoutListItemPriority;
23
- dataState: ReadoutListItemDataState;
24
- alert: AlertFrameConfig | false;
25
- hasSetpoint: boolean;
26
- setpointValue: number;
27
- hasLabel: boolean;
28
- hasDegree: boolean;
29
- hasUnit: boolean;
30
- hasSource: boolean;
31
- hasLeadingIcon: boolean;
32
- hasValueIcon: boolean;
33
- minValueLength: number;
34
- hasHintedZeros: boolean;
34
+ // Primitives
35
35
  label: string;
36
36
  unit: string;
37
37
  src: string;
38
+ hasValue: boolean;
38
39
  value: number;
39
- fractionDigits: number;
40
- showZeroPadding: boolean;
41
- labelOnly: boolean;
40
+ off: boolean;
41
+ hasSetpoint: boolean;
42
+ setpoint: number;
43
+ hasAdvice: boolean;
44
+ advice: number;
45
+ // Flattened options
46
+ 'options.size': ReadoutListItemSize;
47
+ 'options.priority': ReadoutListItemPriority;
48
+ 'options.stacking': ReadoutListItemStacking;
49
+ 'options.clickable': boolean;
50
+ 'options.clickable.border': ReadoutListItemBorder;
51
+ 'options.hasLeadingIcon': boolean;
52
+ 'options.hasDegree': boolean;
53
+ 'options.hasDegreeSpacer': boolean;
54
+ 'options.fractionDigits': number;
55
+ 'options.maxDigits': number;
56
+ 'options.dataQuality': ReadoutListItemDataQuality | typeof NONE;
57
+ 'options.value.weight': ObcTextboxFontWeight;
58
+ 'options.value.hasIcon': boolean;
59
+ 'options.value.hintedZeros': boolean;
60
+ 'options.setpoint.interaction': ReadoutListItemSetpointInteraction;
61
+ 'options.setpoint.touching': boolean;
62
+ 'options.unit.spaceReserver': string;
42
63
  };
43
64
 
44
- type ReadoutShowcaseCase = {
45
- label: string;
46
- args: Partial<ReadoutListItemStoryArgs>;
65
+ // Authoring convenience for the stories only: the component's API is flat
66
+ // (global props + per-block `*Options`), but grouping them under one `options`
67
+ // object keeps the showcase cases compact. `renderItem` spreads this onto the
68
+ // element's real props.
69
+ type StoryOptions = {
70
+ size?: ReadoutListItemSize;
71
+ priority?: ReadoutListItemPriority;
72
+ stacking?: ReadoutListItemStacking;
73
+ clickable?: boolean | ReadoutListItemClickable;
74
+ hasLeadingIcon?: boolean;
75
+ hasDegree?: boolean;
76
+ hasDegreeSpacer?: boolean;
77
+ fractionDigits?: number;
78
+ maxDigits?: number;
79
+ dataQuality?: ReadoutListItemDataQuality;
80
+ alert?: false | AlertFrameConfig;
81
+ value?: ReadoutValueOptions;
82
+ setpoint?: ReadoutSetpointOptions;
83
+ advice?: ReadoutAdviceOptions;
84
+ unit?: ReadoutReserverOptions;
85
+ src?: ReadoutSrcOptions;
47
86
  };
48
87
 
49
- type ReadoutShowcaseSection = {
88
+ type ReadoutItemConfig = {
89
+ label?: string;
90
+ unit?: string;
91
+ src?: string;
92
+ hasValue?: boolean;
93
+ value?: number | null;
94
+ off?: boolean;
95
+ hasSetpoint?: boolean;
96
+ setpoint?: number;
97
+ hasAdvice?: boolean;
98
+ advice?: number;
99
+ options?: StoryOptions;
100
+ hasLeadingIcon?: boolean;
101
+ hasValueIcon?: boolean;
102
+ };
103
+
104
+ type ShowcaseCase = {label: string; config: ReadoutItemConfig};
105
+ // `columns` pins the grid to a fixed column count so cases group logically
106
+ // (e.g. one row per size, columns = the state variants) instead of `auto-fit`
107
+ // packing different sizes onto the same line. Omit for a free-flowing grid.
108
+ type ShowcaseSection = {
50
109
  title: string;
51
- cases: ReadoutShowcaseCase[];
110
+ cases: ShowcaseCase[];
111
+ columns?: number;
52
112
  };
53
113
 
54
- const centeredCanvasDecorator = (story: () => unknown) => {
55
- return html`
114
+ const centeredCanvasDecorator = (story: () => unknown) => html`
115
+ <div
116
+ style="min-height: 100vh; width: 100%; display: flex; align-items: center; padding: 24px;"
117
+ >
56
118
  <div
57
- style="
58
- min-height: 100vh;
59
- width: 100%;
60
- display: flex;
61
- align-items: center;
62
- justify-content: center;
63
- padding: 24px;
64
- "
119
+ data-obc-theme="day"
120
+ style="background: var(--container-background-color); padding: 24px; width: 100%; box-sizing: border-box;"
65
121
  >
66
- <div
67
- data-obc-theme="day"
68
- style="
69
- background: var(--container-background-color);
70
- padding: 24px;
71
- width: 100%;
72
- max-width: none;
73
- display: block;
74
- box-sizing: border-box;
75
- "
76
- >
77
- ${story()}
78
- </div>
122
+ ${story()}
79
123
  </div>
80
- `;
81
- };
82
-
83
- const readoutShowcaseStyle = `
84
- .obc-readout-list-item-sections {
85
- display: flex;
86
- flex-direction: column;
87
- align-items: stretch;
88
- gap: 32px;
89
- width: 100%;
90
- }
91
-
92
- .obc-readout-list-item-section {
93
- display: flex;
94
- flex-direction: column;
95
- align-items: stretch;
96
- gap: 16px;
97
- width: 100%;
98
- }
124
+ </div>
125
+ `;
99
126
 
100
- .obc-readout-list-item-section-title {
101
- margin: 0;
102
- font: 12px/1.2 var(--global-typography-ui-label-font-family, inherit);
103
- text-transform: uppercase;
104
- letter-spacing: 0.06em;
105
- color: var(--element-neutral-color, #777);
127
+ const showcaseStyle = `
128
+ .rli-sections { display: flex; flex-direction: column; gap: 32px; width: 100%; }
129
+ .rli-section { display: flex; flex-direction: column; gap: 16px; width: 100%; }
130
+ .rli-section-title {
131
+ margin: 0; font: 12px/1.2 var(--global-typography-ui-label-font-family, inherit);
132
+ text-transform: uppercase; letter-spacing: 0.06em; color: var(--element-neutral-color, #777);
106
133
  }
107
-
108
- .obc-readout-list-item-grid {
109
- display: grid;
110
- grid-template-columns: repeat(auto-fit, minmax(240px, max-content));
111
- gap: 20px;
112
- width: 100%;
113
- align-items: start;
114
- justify-content: center;
115
- justify-items: center;
134
+ .rli-grid {
135
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, max-content));
136
+ gap: 20px; width: 100%; align-items: start; justify-items: stretch;
116
137
  }
117
-
118
- .obc-readout-list-item-card {
119
- display: flex;
120
- flex-direction: column;
121
- align-items: center;
122
- gap: 10px;
123
- width: 240px;
124
- padding: 12px;
125
- border-radius: 8px;
126
- background: rgba(0, 0, 0, 0.03);
138
+ .rli-card {
139
+ display: flex; flex-direction: column; gap: 10px; padding: 12px;
140
+ border-radius: 8px; background: rgba(0, 0, 0, 0.03);
127
141
  }
128
-
129
- .obc-readout-list-item-title {
142
+ .rli-card-title {
130
143
  font: 10px/1.2 var(--global-typography-ui-label-font-family, inherit);
131
- text-transform: uppercase;
132
- letter-spacing: 0.06em;
133
- color: var(--element-neutral-color, #777);
134
- text-align: center;
144
+ text-transform: uppercase; letter-spacing: 0.06em; color: var(--element-neutral-color, #777);
135
145
  }
136
146
  `;
137
147
 
138
- const defaultArgs: ReadoutListItemStoryArgs = {
139
- size: ReadoutListItemSize.base,
140
- stacking: ReadoutListItemStacking.trailingUnit,
141
- priority: ReadoutListItemPriority.regular,
142
- dataState: ReadoutListItemDataState.none,
143
- alert: false,
144
- hasSetpoint: false,
145
- setpointValue: 123,
146
- hasDegree: true,
147
- hasUnit: true,
148
- hasSource: false,
149
- hasLeadingIcon: false,
150
- hasValueIcon: false,
151
- minValueLength: 0,
152
- hasHintedZeros: false,
153
- hasLabel: true,
154
- label: 'Label',
155
- unit: 'Unit',
156
- src: 'SRC',
157
- value: 123,
158
- fractionDigits: 0,
159
- showZeroPadding: false,
160
- labelOnly: false,
161
- };
162
-
163
- function renderReadoutListItem(args: Partial<ReadoutListItemStoryArgs>) {
164
- const resolved = {...defaultArgs, ...args};
148
+ // Faithful render: data primitives pass through as-is, so an emptied Storybook
149
+ // control (undefined) shows the nil state — value/setpoint/advice render a dash,
150
+ // label/unit/src are omitted (and the row keeps its height). Used by the
151
+ // Playground and the alignment column.
152
+ function renderItem(config: ReadoutItemConfig) {
153
+ const o = config.options ?? {};
165
154
  return html`
166
155
  <obc-readout-list-item
167
- .size=${resolved.size}
168
- .stacking=${resolved.stacking}
169
- .priority=${resolved.priority}
170
- .dataState=${resolved.dataState}
171
- .alert=${resolved.alert}
172
- .hasSetpoint=${resolved.hasSetpoint}
173
- .setpointValue=${resolved.setpointValue}
174
- .hasLabel=${resolved.hasLabel}
175
- .hasDegree=${resolved.hasDegree}
176
- .hasUnit=${resolved.hasUnit}
177
- .hasSource=${resolved.hasSource}
178
- .hasLeadingIcon=${resolved.hasLeadingIcon}
179
- .hasValueIcon=${resolved.hasValueIcon}
180
- .minValueLength=${resolved.minValueLength}
181
- .hasHintedZeros=${resolved.hasHintedZeros}
182
- .label=${resolved.label}
183
- .unit=${resolved.unit}
184
- .src=${resolved.src}
185
- .value=${resolved.value}
186
- .fractionDigits=${resolved.fractionDigits}
187
- .showZeroPadding=${resolved.showZeroPadding}
188
- .labelOnly=${resolved.labelOnly}
156
+ .label=${config.label}
157
+ .unit=${config.unit}
158
+ .src=${config.src}
159
+ .hasValue=${config.hasValue ?? true}
160
+ .value=${config.value ?? null}
161
+ .off=${config.off ?? false}
162
+ .hasSetpoint=${config.hasSetpoint ?? false}
163
+ .setpoint=${config.setpoint}
164
+ .hasAdvice=${config.hasAdvice ?? false}
165
+ .advice=${config.advice}
166
+ .size=${o.size}
167
+ .priority=${o.priority}
168
+ .stacking=${o.stacking}
169
+ .clickable=${o.clickable ?? false}
170
+ .hasLeadingIcon=${o.hasLeadingIcon ?? false}
171
+ .hasDegree=${o.hasDegree ?? false}
172
+ .hasDegreeSpacer=${o.hasDegreeSpacer ?? false}
173
+ .fractionDigits=${o.fractionDigits ?? 0}
174
+ .maxDigits=${o.maxDigits ?? 0}
175
+ .dataQuality=${o.dataQuality}
176
+ .alert=${o.alert ?? false}
177
+ .valueOptions=${o.value}
178
+ .setpointOptions=${o.setpoint}
179
+ .adviceOptions=${o.advice}
180
+ .unitOptions=${o.unit}
181
+ .srcOptions=${o.src}
189
182
  >
190
- ${resolved.hasLeadingIcon
191
- ? html`<span
192
- slot="leading-icon"
193
- style="display:block; width:100%; height:100%;"
194
- >
195
- <obi-placeholder></obi-placeholder>
196
- </span>`
183
+ ${config.hasLeadingIcon
184
+ ? html`<obi-placeholder slot="leading-icon"></obi-placeholder>`
197
185
  : nothing}
198
- ${resolved.hasValueIcon
199
- ? html`<span
200
- slot="value-icon"
201
- style="display:block; width:100%; height:100%;"
202
- >
203
- <obi-placeholder></obi-placeholder>
204
- </span>`
186
+ ${config.hasValueIcon
187
+ ? html`<obi-placeholder slot="value-icon"></obi-placeholder>`
205
188
  : nothing}
206
189
  </obc-readout-list-item>
207
190
  `;
208
191
  }
209
192
 
210
- function renderShowcaseSections(sections: ReadoutShowcaseSection[]) {
193
+ // Showcase render: fills demo defaults (Label / Unit / SRC / 123 / 120 / 118)
194
+ // for the static preset cards that omit those fields. The Playground uses
195
+ // renderItem directly so emptying a control surfaces the nil state.
196
+ function showcaseItem(config: ReadoutItemConfig) {
197
+ return renderItem({
198
+ ...config,
199
+ label: config.label ?? 'Label',
200
+ unit: config.unit ?? 'Unit',
201
+ src: config.src ?? 'SRC',
202
+ value: config.value === undefined ? 123 : config.value,
203
+ setpoint: config.setpoint ?? 120,
204
+ advice: config.advice ?? 118,
205
+ });
206
+ }
207
+
208
+ function renderShowcase(sections: ShowcaseSection[]) {
211
209
  return html`
212
210
  <style>
213
- ${readoutShowcaseStyle}
211
+ ${showcaseStyle}
214
212
  </style>
215
- <div class="obc-readout-list-item-sections">
213
+ <div class="rli-sections">
216
214
  ${sections.map(
217
215
  (section) => html`
218
- <section class="obc-readout-list-item-section">
219
- <h3 class="obc-readout-list-item-section-title">
220
- ${section.title}
221
- </h3>
222
- <div class="obc-readout-list-item-grid">
216
+ <section class="rli-section">
217
+ <h3 class="rli-section-title">${section.title}</h3>
218
+ <div
219
+ class="rli-grid"
220
+ style=${section.columns
221
+ ? `grid-template-columns: repeat(${section.columns}, max-content);`
222
+ : nothing}
223
+ >
223
224
  ${section.cases.map(
224
225
  (item) => html`
225
- <div class="obc-readout-list-item-card">
226
- <div class="obc-readout-list-item-title">${item.label}</div>
227
- ${renderReadoutListItem(item.args)}
226
+ <div class="rli-card">
227
+ <div class="rli-card-title">${item.label}</div>
228
+ ${showcaseItem(item.config)}
228
229
  </div>
229
230
  `
230
231
  )}
@@ -236,123 +237,198 @@ function renderShowcaseSections(sections: ReadoutShowcaseSection[]) {
236
237
  `;
237
238
  }
238
239
 
240
+ const defaultArgs: ReadoutListItemStoryArgs = {
241
+ label: 'Label',
242
+ unit: 'Unit',
243
+ src: 'SRC',
244
+ hasValue: true,
245
+ value: 123,
246
+ off: false,
247
+ hasSetpoint: false,
248
+ setpoint: 120,
249
+ hasAdvice: false,
250
+ advice: 118,
251
+ 'options.size': ReadoutListItemSize.small,
252
+ 'options.priority': ReadoutListItemPriority.regular,
253
+ 'options.stacking': ReadoutListItemStacking.trailingUnit,
254
+ 'options.clickable': false,
255
+ 'options.clickable.border': ReadoutListItemBorder.squared,
256
+ 'options.hasLeadingIcon': false,
257
+ 'options.hasDegree': true,
258
+ 'options.hasDegreeSpacer': false,
259
+ 'options.fractionDigits': 0,
260
+ 'options.maxDigits': 0,
261
+ 'options.dataQuality': NONE,
262
+ 'options.value.weight': ObcTextboxFontWeight.regular,
263
+ 'options.value.hasIcon': false,
264
+ 'options.value.hintedZeros': false,
265
+ 'options.setpoint.interaction':
266
+ ReadoutListItemSetpointInteraction.alwaysVisible,
267
+ 'options.setpoint.touching': false,
268
+ 'options.unit.spaceReserver': '',
269
+ };
270
+
271
+ function argsToOptions(args: ReadoutListItemStoryArgs): StoryOptions {
272
+ return {
273
+ size: args['options.size'],
274
+ priority: args['options.priority'],
275
+ stacking: args['options.stacking'],
276
+ clickable: args['options.clickable']
277
+ ? {border: args['options.clickable.border']}
278
+ : false,
279
+ hasLeadingIcon: args['options.hasLeadingIcon'],
280
+ hasDegree: args['options.hasDegree'],
281
+ hasDegreeSpacer: args['options.hasDegreeSpacer'],
282
+ fractionDigits: args['options.fractionDigits'],
283
+ maxDigits: args['options.maxDigits'],
284
+ dataQuality:
285
+ args['options.dataQuality'] === NONE
286
+ ? undefined
287
+ : args['options.dataQuality'],
288
+ value: {
289
+ weight: args['options.value.weight'],
290
+ hasIcon: args['options.value.hasIcon'],
291
+ hintedZeros: args['options.value.hintedZeros'],
292
+ },
293
+ setpoint: {
294
+ interaction: args['options.setpoint.interaction'],
295
+ touching: args['options.setpoint.touching'],
296
+ },
297
+ unit: {spaceReserver: args['options.unit.spaceReserver'] || undefined},
298
+ };
299
+ }
300
+
239
301
  const meta = {
240
302
  title: 'Instruments/Readout List Item',
241
- tags: ['autodocs', '6.0'],
303
+ tags: ['autodocs', '6.0', 'wip'],
242
304
  component: 'obc-readout-list-item',
243
305
  decorators: [centeredCanvasDecorator],
244
- render: (args) => {
245
- const [, updateArgs] = useArgs<ReadoutListItemStoryArgs>();
246
- if (args.minValueLength <= 1 && args.hasHintedZeros) {
247
- updateArgs({hasHintedZeros: false});
248
- }
249
- return html`<div style="display:flex; justify-content:center; width:100%;">
250
- ${renderReadoutListItem(args)}
251
- </div>`;
252
- },
306
+ render: (args) =>
307
+ html`<div style="display:flex; width:100%;">
308
+ ${renderItem({
309
+ label: args.label,
310
+ unit: args.unit,
311
+ src: args.src,
312
+ hasValue: args.hasValue,
313
+ value: args.value,
314
+ off: args.off,
315
+ hasSetpoint: args.hasSetpoint,
316
+ setpoint: args.setpoint,
317
+ hasAdvice: args.hasAdvice,
318
+ advice: args.advice,
319
+ options: argsToOptions(args),
320
+ hasLeadingIcon: args['options.hasLeadingIcon'],
321
+ hasValueIcon: args['options.value.hasIcon'],
322
+ })}
323
+ </div>`,
253
324
  args: defaultArgs,
254
325
  argTypes: {
255
- size: {
326
+ label: {name: 'Label', control: {type: 'text'}, table: {category: 'Data'}},
327
+ unit: {name: 'Unit', control: {type: 'text'}, table: {category: 'Data'}},
328
+ src: {name: 'Source', control: {type: 'text'}, table: {category: 'Data'}},
329
+ hasValue: {name: 'Has Value', table: {category: 'Data'}},
330
+ value: {
331
+ name: 'Value',
332
+ control: {type: 'number'},
333
+ table: {category: 'Data'},
334
+ },
335
+ off: {name: 'Off', table: {category: 'Data'}},
336
+ hasSetpoint: {name: 'Has Setpoint', table: {category: 'Data'}},
337
+ setpoint: {
338
+ name: 'Setpoint',
339
+ control: {type: 'number'},
340
+ if: {arg: 'hasSetpoint', truthy: true},
341
+ table: {category: 'Data'},
342
+ },
343
+ hasAdvice: {name: 'Has Advice', table: {category: 'Data'}},
344
+ advice: {
345
+ name: 'Advice',
346
+ control: {type: 'number'},
347
+ if: {arg: 'hasAdvice', truthy: true},
348
+ table: {category: 'Data'},
349
+ },
350
+ 'options.size': {
256
351
  name: 'Size',
257
352
  control: {type: 'select'},
258
353
  options: Object.values(ReadoutListItemSize),
259
- table: {category: 'Readout'},
260
- },
261
- stacking: {
262
- name: 'Stacking',
263
- control: {type: 'select'},
264
- options: Object.values(ReadoutListItemStacking),
265
- table: {category: 'Readout'},
354
+ table: {category: 'Layout'},
266
355
  },
267
- priority: {
356
+ 'options.priority': {
268
357
  name: 'Priority',
269
358
  control: {type: 'select'},
270
359
  options: Object.values(ReadoutListItemPriority),
271
- table: {category: 'Readout'},
360
+ table: {category: 'Layout'},
272
361
  },
273
- dataState: {
274
- name: 'Data State',
362
+ 'options.stacking': {
363
+ name: 'Stacking',
275
364
  control: {type: 'select'},
276
- options: Object.values(ReadoutListItemDataState),
277
- table: {category: 'Readout'},
278
- },
279
- hasSetpoint: {
280
- name: 'Has Setpoint',
281
- table: {category: 'Readout'},
282
- },
283
- setpointValue: {
284
- name: 'Setpoint Value',
285
- control: {type: 'number'},
286
- if: {
287
- arg: 'hasSetpoint',
288
- truthy: true,
289
- },
290
- table: {category: 'Value'},
291
- },
292
- hasDegree: {
293
- name: 'Has Degree',
294
- table: {category: 'Content'},
295
- },
296
- hasLabel: {
297
- name: 'Has Label',
298
- table: {category: 'Content'},
299
- },
300
- hasUnit: {
301
- name: 'Has Unit',
302
- table: {category: 'Content'},
365
+ options: Object.values(ReadoutListItemStacking),
366
+ table: {category: 'Layout'},
303
367
  },
304
- hasSource: {
305
- name: 'Has Source',
306
- table: {category: 'Content'},
368
+ 'options.clickable': {name: 'Clickable', table: {category: 'Layout'}},
369
+ 'options.clickable.border': {
370
+ name: 'Clickable Border',
371
+ control: {type: 'select'},
372
+ options: Object.values(ReadoutListItemBorder),
373
+ if: {arg: 'options.clickable', truthy: true},
374
+ table: {category: 'Layout'},
307
375
  },
308
- hasLeadingIcon: {
376
+ 'options.hasLeadingIcon': {
309
377
  name: 'Has Leading Icon',
310
- table: {category: 'Slots'},
378
+ table: {category: 'Layout'},
311
379
  },
312
- hasValueIcon: {
313
- name: 'Has Value Icon',
314
- table: {category: 'Slots'},
380
+ 'options.hasDegree': {name: 'Has Degree', table: {category: 'Format'}},
381
+ 'options.hasDegreeSpacer': {
382
+ name: 'Has Degree Spacer',
383
+ table: {category: 'Format'},
315
384
  },
316
- label: {
317
- name: 'Label',
318
- control: {type: 'text'},
319
- table: {category: 'Content'},
385
+ 'options.fractionDigits': {
386
+ name: 'Fraction Digits',
387
+ control: {type: 'number', min: 0, step: 1},
388
+ table: {category: 'Format'},
320
389
  },
321
- unit: {
322
- name: 'Unit',
323
- control: {type: 'text'},
324
- if: {arg: 'hasUnit', truthy: true},
325
- table: {category: 'Content'},
390
+ 'options.maxDigits': {
391
+ name: 'Max Digits',
392
+ control: {type: 'number', min: 0, step: 1},
393
+ table: {category: 'Format'},
326
394
  },
327
- src: {
328
- name: 'Source',
329
- control: {type: 'text'},
330
- if: {arg: 'hasSource', truthy: true},
331
- table: {category: 'Content'},
395
+ 'options.dataQuality': {
396
+ name: 'Data Quality',
397
+ control: {type: 'select'},
398
+ options: [NONE, ...Object.values(ReadoutListItemDataQuality)],
399
+ table: {category: 'State'},
332
400
  },
333
- value: {
334
- name: 'Value',
335
- control: {type: 'number'},
401
+ 'options.value.weight': {
402
+ name: 'Value Weight',
403
+ control: {type: 'select'},
404
+ options: Object.values(ObcTextboxFontWeight),
336
405
  table: {category: 'Value'},
337
406
  },
338
- fractionDigits: {
339
- name: 'Fraction Digits',
340
- control: {type: 'number', min: 0, step: 1},
341
- table: {category: 'Formatting'},
407
+ 'options.value.hasIcon': {
408
+ name: 'Value Has Icon',
409
+ table: {category: 'Value'},
342
410
  },
343
- showZeroPadding: {
344
- name: 'Show Zero Padding',
345
- table: {category: 'Formatting'},
411
+ 'options.value.hintedZeros': {
412
+ name: 'Value Hinted Zeros',
413
+ if: {arg: 'options.maxDigits', truthy: true},
414
+ table: {category: 'Value'},
346
415
  },
347
- minValueLength: {
348
- name: 'Min Value Length',
349
- control: {type: 'number', min: 0, step: 1},
350
- table: {category: 'Formatting'},
416
+ 'options.setpoint.interaction': {
417
+ name: 'Setpoint Interaction',
418
+ control: {type: 'select'},
419
+ options: Object.values(ReadoutListItemSetpointInteraction),
420
+ if: {arg: 'hasSetpoint', truthy: true},
421
+ table: {category: 'Setpoint'},
351
422
  },
352
- hasHintedZeros: {
353
- name: 'Has Hinted Zeros',
354
- if: {arg: 'minValueLength', gt: 1},
355
- table: {category: 'Formatting'},
423
+ 'options.setpoint.touching': {
424
+ name: 'Setpoint Touching',
425
+ if: {arg: 'hasSetpoint', truthy: true},
426
+ table: {category: 'Setpoint'},
427
+ },
428
+ 'options.unit.spaceReserver': {
429
+ name: 'Unit Space Reserver',
430
+ control: {type: 'text'},
431
+ table: {category: 'Format'},
356
432
  },
357
433
  },
358
434
  } satisfies Meta<ReadoutListItemStoryArgs>;
@@ -362,130 +438,1302 @@ type Story = StoryObj<ReadoutListItemStoryArgs>;
362
438
 
363
439
  export const Playground: Story = {};
364
440
 
365
- function renderStackingMatrix({
366
- stacking,
367
- hasUnit,
368
- hasSource,
369
- }: {
370
- stacking: ReadoutListItemStacking;
371
- hasUnit: boolean;
372
- hasSource: boolean;
373
- }) {
374
- const sizes = [
375
- ReadoutListItemSize.base,
376
- ReadoutListItemSize.priority,
377
- ReadoutListItemSize.enhanced,
378
- ] as const;
379
- const priorities = [
380
- ReadoutListItemPriority.regular,
381
- ReadoutListItemPriority.enhanced,
382
- ReadoutListItemPriority.setpoint,
383
- ReadoutListItemPriority.setpointFlipFlop,
384
- ] as const;
385
- const hasSetpoints = [false, true] as const;
386
-
387
- const cases: ReadoutShowcaseCase[] = [];
388
-
389
- for (const size of sizes) {
390
- for (const priority of priorities) {
391
- for (const hasSetpoint of hasSetpoints) {
392
- cases.push({
393
- label: `${size} / ${priority} / hasSetpoint=${hasSetpoint}`,
394
- args: {
395
- size,
396
- stacking,
397
- priority,
398
- hasSetpoint,
399
- dataState: ReadoutListItemDataState.none,
400
- hasUnit,
401
- hasSource,
402
- value: 123,
403
- setpointValue: 123,
404
- hasDegree: true,
405
- hasLabel: true,
441
+ export const Off: Story = {
442
+ render: () =>
443
+ renderShowcase([
444
+ {
445
+ title: 'Off (value only, setpoint and advice remain)',
446
+ columns: 3,
447
+ cases: [
448
+ {label: 'value off', config: {off: true}},
449
+ {
450
+ label: 'value off + setpoint',
451
+ config: {off: true, hasSetpoint: true, setpoint: 120},
406
452
  },
407
- });
408
- }
409
- }
410
- }
453
+ {
454
+ label: 'value off + advice',
455
+ config: {off: true, hasAdvice: true, advice: 118},
456
+ },
457
+ ],
458
+ },
459
+ ]),
460
+ };
461
+
462
+ const SIZES = [
463
+ ReadoutListItemSize.small,
464
+ ReadoutListItemSize.medium,
465
+ ReadoutListItemSize.large,
466
+ ] as const;
411
467
 
412
- return renderShowcaseSections([{title: `Stacking: ${stacking}`, cases}]);
468
+ export const Sizes: Story = {
469
+ render: () =>
470
+ renderShowcase([
471
+ {
472
+ title: 'Sizes × Priority',
473
+ columns: 2,
474
+ cases: SIZES.flatMap((size) =>
475
+ [
476
+ ReadoutListItemPriority.regular,
477
+ ReadoutListItemPriority.enhanced,
478
+ ].map((priority) => ({
479
+ label: `${size} / ${priority}`,
480
+ config: {options: {size, priority, hasDegree: true}},
481
+ }))
482
+ ),
483
+ },
484
+ ]),
485
+ };
486
+
487
+ function stackingCases(stacking: ReadoutListItemStacking): ShowcaseCase[] {
488
+ return SIZES.flatMap((size) => [
489
+ {
490
+ label: `${size} / value`,
491
+ config: {options: {size, stacking, hasDegree: true}},
492
+ },
493
+ {
494
+ label: `${size} / value + setpoint`,
495
+ config: {
496
+ hasSetpoint: true,
497
+ setpoint: 120,
498
+ options: {size, stacking, hasDegree: true},
499
+ },
500
+ },
501
+ {
502
+ label: `${size} / value + advice + setpoint`,
503
+ config: {
504
+ hasAdvice: true,
505
+ advice: 118,
506
+ hasSetpoint: true,
507
+ setpoint: 120,
508
+ options: {size, stacking, hasDegree: true},
509
+ },
510
+ },
511
+ ]);
413
512
  }
414
513
 
415
514
  export const TrailingUnit: Story = {
416
515
  render: () =>
417
- renderStackingMatrix({
418
- stacking: ReadoutListItemStacking.trailingUnit,
419
- hasUnit: true,
420
- hasSource: false,
421
- }),
516
+ renderShowcase([
517
+ {
518
+ title: 'Stacking: trailing-unit',
519
+ columns: 3,
520
+ cases: stackingCases(ReadoutListItemStacking.trailingUnit),
521
+ },
522
+ ]),
422
523
  };
423
524
 
424
525
  export const LeadingUnit: Story = {
425
526
  render: () =>
426
- renderStackingMatrix({
427
- stacking: ReadoutListItemStacking.leadingUnit,
428
- hasUnit: true,
429
- hasSource: false,
430
- }),
527
+ renderShowcase([
528
+ {
529
+ title: 'Stacking: leading-unit',
530
+ columns: 3,
531
+ cases: stackingCases(ReadoutListItemStacking.leadingUnit),
532
+ },
533
+ ]),
431
534
  };
432
535
 
433
536
  export const LeadingSrc: Story = {
434
537
  render: () =>
435
- renderStackingMatrix({
436
- stacking: ReadoutListItemStacking.leadingSrc,
437
- hasUnit: true,
438
- hasSource: true,
439
- }),
538
+ renderShowcase([
539
+ {
540
+ title: 'Stacking: leading-src',
541
+ columns: 3,
542
+ cases: stackingCases(ReadoutListItemStacking.leadingSrc),
543
+ },
544
+ ]),
440
545
  };
441
546
 
442
- export const DataStates: Story = {
443
- render: () => {
444
- const dataStates = Object.values(ReadoutListItemDataState);
445
- const cases: ReadoutShowcaseCase[] = dataStates.map((dataState) => ({
446
- label: `${dataState}`,
447
- args: {
448
- size: ReadoutListItemSize.base,
449
- stacking: ReadoutListItemStacking.trailingUnit,
450
- priority: ReadoutListItemPriority.regular,
451
- dataState,
452
- value: 123,
547
+ export const SetpointFlipFlop: Story = {
548
+ render: () =>
549
+ renderShowcase([
550
+ {
551
+ title: 'Setpoint flip-flop (value vs. setpoint focus)',
552
+ columns: 2,
553
+ cases: SIZES.flatMap((size) => [
554
+ {
555
+ label: `${size} / not at setpoint`,
556
+ config: {
557
+ value: 123,
558
+ hasSetpoint: true,
559
+ setpoint: 120,
560
+ options: {
561
+ size,
562
+ setpoint: {
563
+ interaction: ReadoutListItemSetpointInteraction.flipFlop,
564
+ },
565
+ hasDegree: true,
566
+ },
567
+ },
568
+ },
569
+ {
570
+ label: `${size} / at setpoint`,
571
+ config: {
572
+ value: 120,
573
+ hasSetpoint: true,
574
+ setpoint: 120,
575
+ options: {
576
+ size,
577
+ setpoint: {
578
+ interaction: ReadoutListItemSetpointInteraction.flipFlop,
579
+ },
580
+ hasDegree: true,
581
+ },
582
+ },
583
+ },
584
+ ]),
453
585
  },
454
- }));
586
+ ]),
587
+ };
455
588
 
456
- return renderShowcaseSections([{title: 'Data States', cases}]);
589
+ // The four Figma "states" (sandbox file g9gUFzN6MzzvNudv4XOqMT), each with a
590
+ // setpoint, mapped onto the component API. Value 123 ≠ setpoint 120 so the
591
+ // flip-flop emphasises the setpoint. Locks the both-gray/both-blue colour rule
592
+ // and the setpoint emphasis (SemiBold only when emphasised; value stays Regular).
593
+ const STATE_VARIANTS: {label: string; options: StoryOptions}[] = [
594
+ {label: 'regular', options: {priority: ReadoutListItemPriority.regular}},
595
+ {label: 'enhanced', options: {priority: ReadoutListItemPriority.enhanced}},
596
+ {
597
+ label: 'input (adjusting)',
598
+ options: {
599
+ priority: ReadoutListItemPriority.enhanced,
600
+ setpoint: {touching: true},
601
+ },
602
+ },
603
+ {
604
+ label: 'input flip-flop',
605
+ options: {
606
+ priority: ReadoutListItemPriority.enhanced,
607
+ setpoint: {interaction: ReadoutListItemSetpointInteraction.flipFlop},
608
+ },
457
609
  },
610
+ ];
611
+
612
+ export const States: Story = {
613
+ render: () =>
614
+ renderShowcase([
615
+ {
616
+ title: 'States (value + setpoint) — both neutral or both enhanced',
617
+ columns: 4,
618
+ cases: SIZES.flatMap((size) =>
619
+ STATE_VARIANTS.map((variant) => ({
620
+ label: `${size} / ${variant.label}`,
621
+ config: {
622
+ value: 123,
623
+ hasSetpoint: true,
624
+ setpoint: 120,
625
+ options: {size, hasDegree: true, ...variant.options},
626
+ },
627
+ }))
628
+ ),
629
+ },
630
+ ]),
458
631
  };
459
632
 
460
- export const Alarm: Story = {
461
- args: {
462
- value: 123,
463
- alert: {
464
- status: AlertType.Alarm,
465
- mode: ObcAlertFrameMode.ackedActive,
466
- type: ObcAlertFrameType.Regular,
633
+ // `flip-flop` (value/setpoint swap) and `pop-up` (setpoint fades out once
634
+ // reached) only read clearly in motion, and they need `value === setpoint`
635
+ // transitions rather than a single control — so each gets a focused static
636
+ // story plus a GSAP-driven animated one below.
637
+
638
+ export const SetpointPopUp: Story = {
639
+ render: () =>
640
+ renderShowcase([
641
+ {
642
+ title: 'Pop-up — setpoint shown only until the value reaches it',
643
+ columns: 2,
644
+ cases: [
645
+ {
646
+ label: 'value ≠ setpoint → shown',
647
+ config: {
648
+ value: 123,
649
+ hasSetpoint: true,
650
+ setpoint: 120,
651
+ options: {
652
+ setpoint: {
653
+ interaction: ReadoutListItemSetpointInteraction.popUp,
654
+ },
655
+ },
656
+ },
657
+ },
658
+ {
659
+ label: 'value = setpoint → fades out (space kept)',
660
+ config: {
661
+ value: 120,
662
+ hasSetpoint: true,
663
+ setpoint: 120,
664
+ options: {
665
+ setpoint: {
666
+ interaction: ReadoutListItemSetpointInteraction.popUp,
667
+ },
668
+ },
669
+ },
670
+ },
671
+ ],
672
+ },
673
+ ]),
674
+ };
675
+
676
+ export const SetpointTouch: Story = {
677
+ render: () =>
678
+ renderShowcase([
679
+ {
680
+ title:
681
+ 'Touch (focus) — the setpoint triangle is highlighted while adjusting',
682
+ columns: 2,
683
+ cases: [
684
+ {
685
+ label: 'normal',
686
+ config: {
687
+ value: 123,
688
+ hasSetpoint: true,
689
+ setpoint: 120,
690
+ options: {setpoint: {}},
691
+ },
692
+ },
693
+ {
694
+ label: 'touching (focus)',
695
+ config: {
696
+ value: 123,
697
+ hasSetpoint: true,
698
+ setpoint: 120,
699
+ options: {setpoint: {touching: true}},
700
+ },
701
+ },
702
+ ],
703
+ },
704
+ ]),
705
+ };
706
+
707
+ // Shared scaffold for the GSAP-driven interaction demos: a single readout whose
708
+ // `value` is swept toward / away from the setpoint so the flip-flop swap and the
709
+ // pop-up show/hide can be seen in motion. Tagged `skip-test` (the animation makes
710
+ // snapshots flaky).
711
+ function animatedInteractionStory(config: {
712
+ name: string;
713
+ interaction: ReadoutListItemSetpointInteraction;
714
+ intro: string;
715
+ awayLabel: string;
716
+ reachLabel: string;
717
+ }): Story {
718
+ const SETPOINT = 120;
719
+ const AWAY = 124;
720
+ return {
721
+ name: config.name,
722
+ render: () => html`
723
+ <div
724
+ style="display:flex; flex-direction:column; gap:16px; width:360px; padding:24px;"
725
+ >
726
+ <div style="font-size:14px; color:var(--element-neutral-color, #888);">
727
+ ${config.intro}
728
+ </div>
729
+ <div
730
+ style="padding:8px; border:1px dashed var(--border-divider-color, #ccc); border-radius:8px;"
731
+ >
732
+ <obc-readout-list-item
733
+ id="anim-demo"
734
+ .label=${'Heading'}
735
+ .value=${AWAY}
736
+ .hasSetpoint=${true}
737
+ .setpoint=${SETPOINT}
738
+ .size=${ReadoutListItemSize.medium}
739
+ .hasDegree=${true}
740
+ .setpointOptions=${{interaction: config.interaction}}
741
+ ></obc-readout-list-item>
742
+ </div>
743
+ <div style="display:flex; gap:12px; flex-wrap:wrap;">
744
+ <button id="btn-away" style="padding:8px 16px; cursor:pointer;">
745
+ ${config.awayLabel}
746
+ </button>
747
+ <button id="btn-reach" style="padding:8px 16px; cursor:pointer;">
748
+ ${config.reachLabel}
749
+ </button>
750
+ </div>
751
+ <div
752
+ id="anim-status"
753
+ style="font:12px/1.4 monospace; color:var(--element-neutral-color, #666);"
754
+ >
755
+ value=${AWAY}, setpoint=${SETPOINT}
756
+ </div>
757
+ </div>
758
+ `,
759
+ play: async ({canvasElement}) => {
760
+ await new Promise((r) => setTimeout(r, 300));
761
+ const el = canvasElement.querySelector('#anim-demo') as
762
+ | (HTMLElement & {
763
+ value: number;
764
+ setpointOptions: ReadoutSetpointOptions;
765
+ })
766
+ | null;
767
+ const status = canvasElement.querySelector(
768
+ '#anim-status'
769
+ ) as HTMLElement | null;
770
+ const btnAway = canvasElement.querySelector(
771
+ '#btn-away'
772
+ ) as HTMLButtonElement | null;
773
+ const btnReach = canvasElement.querySelector(
774
+ '#btn-reach'
775
+ ) as HTMLButtonElement | null;
776
+ if (!el || !status || !btnAway || !btnReach) return;
777
+
778
+ const anim = {value: AWAY};
779
+ // Focus/touch state — on while actively adjusting (sweeping toward the
780
+ // setpoint), off once settled. Rebuild the object so Lit re-renders.
781
+ const setTouch = (on: boolean) => {
782
+ el.setpointOptions = {interaction: config.interaction, touching: on};
783
+ };
784
+ const setVal = (v: number) => {
785
+ el.value = v;
786
+ status.textContent = `value=${Math.round(v)}, setpoint=${SETPOINT}`;
787
+ };
788
+ // Snap to the exact target on complete so `value === setpoint` holds (the
789
+ // flip-flop swap and pop-up hide both key off strict equality).
790
+ const tweenTo = (target: number) => {
791
+ gsap.killTweensOf(anim);
792
+ setTouch(true);
793
+ gsap.to(anim, {
794
+ value: target,
795
+ duration: 0.8,
796
+ ease: 'power2.inOut',
797
+ onUpdate: () => setVal(anim.value),
798
+ onComplete: () => {
799
+ setVal(target);
800
+ setTouch(false);
801
+ },
802
+ });
803
+ };
804
+ btnAway.onclick = () => tweenTo(AWAY);
805
+ btnReach.onclick = () => tweenTo(SETPOINT);
806
+
807
+ // Auto-play a couple of cycles for the "Play" button.
808
+ for (let i = 0; i < 2; i++) {
809
+ await userEvent.click(btnReach);
810
+ await new Promise((r) => setTimeout(r, 1500));
811
+ await userEvent.click(btnAway);
812
+ await new Promise((r) => setTimeout(r, 1500));
813
+ }
467
814
  },
815
+ };
816
+ }
817
+
818
+ // `tags: ['skip-test']` must sit on the export literal (not inside the helper) —
819
+ // Storybook's CSF indexer reads tags statically, so a tag returned from a
820
+ // function call would not exclude the story from the visual snapshot run.
821
+ export const SetpointFlipFlopAnimated: Story = {
822
+ ...animatedInteractionStory({
823
+ name: 'Flip-Flop (Animated)',
824
+ interaction: ReadoutListItemSetpointInteraction.flipFlop,
825
+ intro:
826
+ 'Flip-flop: as the value reaches the setpoint (120) the emphasis swaps from the setpoint to the value (100ms). Press Play, or use the buttons.',
827
+ awayLabel: 'Away (124)',
828
+ reachLabel: 'Reach setpoint (120)',
829
+ }),
830
+ tags: ['skip-test'],
831
+ };
832
+
833
+ export const SetpointPopUpAnimated: Story = {
834
+ ...animatedInteractionStory({
835
+ name: 'Pop-Up (Animated)',
836
+ interaction: ReadoutListItemSetpointInteraction.popUp,
837
+ intro:
838
+ 'Pop-up: the setpoint shows while the value differs, then fades out (100ms) once the value reaches it. Press Play, or use the buttons.',
839
+ awayLabel: 'Leave setpoint (124)',
840
+ reachLabel: 'Reach setpoint (120)',
841
+ }),
842
+ tags: ['skip-test'],
843
+ };
844
+
845
+ // Demo durations (s) for the synced azimuth-thruster flow below — the vessel
846
+ // response after "confirm": thrust catches up faster than the heavier angle.
847
+ const SYNC_THRUST_DURATION = 2;
848
+ const SYNC_ANGLE_DURATION = 10;
849
+
850
+ // Reserve integer-digit width so the value / setpoint don't shift left↔right as
851
+ // they cross the 2↔3 digit boundary during the sweep (angle 30↔120, up to 360°;
852
+ // power up to 100%). `maxDigits` reserves the width for the value AND the
853
+ // setpoint block at once, without printing leading zeros.
854
+ const SYNC_MAX_DIGITS = 3;
855
+
856
+ // The readouts' flip-flop setpoint options; only `touch` changes during the flow
857
+ // (size/maxDigits are static flat props on the elements).
858
+ function syncSetpointOptions(touching: boolean): ReadoutSetpointOptions {
859
+ return {
860
+ interaction: ReadoutListItemSetpointInteraction.flipFlop,
861
+ touching,
862
+ };
863
+ }
864
+
865
+ /**
866
+ * **Synced with an instrument (Azimuth Thruster)** — replays the exact setpoint
867
+ * adjustment flow from `Building Blocks/Setpoint → Setpoint Azimuth Thruster
868
+ * Flow`, with two `obc-readout-list-item`s wired to the same demo state below the
869
+ * instrument.
870
+ *
871
+ * These are **two independent components driven by one shared animation** (synced
872
+ * manually, for this demo only): the `obc-azimuth-thruster` instrument and the
873
+ * `obc-readout-list-item` rows. `obc-azimuth-thruster-labeled` pairs the thruster
874
+ * with the older `obc-readout`; this shows the new readout list item mirroring the
875
+ * same angle/thrust adjustment — the setpoint triangle is highlighted while
876
+ * touching, the setpoint value slides during "move", and the value catches up
877
+ * (with the flip-flop emphasis swap) on "confirm".
878
+ *
879
+ * Press "Play", or use the buttons, to run reset → initiate → move → confirm on
880
+ * both at once.
881
+ */
882
+ export const SyncedWithAzimuthThruster: Story = {
883
+ name: 'Synced With Azimuth Thruster (Interactive)',
884
+ tags: ['skip-test'],
885
+ render: () => html`
886
+ <div
887
+ style="display:flex; flex-direction:column; gap:24px; width:360px; padding:24px;"
888
+ >
889
+ <div style="font-size:14px; color:var(--element-neutral-color, #888);">
890
+ Two independent components driven by one shared demo state (synced
891
+ manually for this demo): the azimuth-thruster instrument and two
892
+ <code>obc-readout-list-item</code> rows below it. Press "Play", or use
893
+ the buttons, to run the reset → initiate → move → confirm setpoint
894
+ adjustment on both at once.
895
+ </div>
896
+
897
+ <div style="width:280px; height:280px; align-self:center;">
898
+ <obc-azimuth-thruster
899
+ id="azimuth-demo"
900
+ .angle=${30}
901
+ .angleSetpoint=${30}
902
+ .thrustSetpoint=${25}
903
+ .thrust=${25}
904
+ .state=${InstrumentState.active}
905
+ .priority=${Priority.enhanced}
906
+ .animateSetpoint=${true}
907
+ .primaryTickmarkInterval=${45}
908
+ .secondaryTickmarkInterval=${5}
909
+ .tertiaryTickmarkInterval=${1}
910
+ .showLabels=${true}
911
+ .tickmarksInside=${true}
912
+ ></obc-azimuth-thruster>
913
+ </div>
914
+
915
+ <div
916
+ style="display:flex; flex-direction:column; gap:8px; padding:8px; border:1px dashed var(--border-divider-color, #ccc); border-radius:8px;"
917
+ >
918
+ <obc-readout-list-item
919
+ id="rli-angle"
920
+ .label=${'Angle'}
921
+ .unit=${'DEG'}
922
+ .value=${30}
923
+ .hasSetpoint=${true}
924
+ .setpoint=${30}
925
+ .size=${ReadoutListItemSize.medium}
926
+ .hasDegree=${true}
927
+ .maxDigits=${SYNC_MAX_DIGITS}
928
+ .setpointOptions=${syncSetpointOptions(false)}
929
+ ></obc-readout-list-item>
930
+ <obc-readout-list-item
931
+ id="rli-power"
932
+ .label=${'Power'}
933
+ .unit=${'%'}
934
+ .value=${25}
935
+ .hasSetpoint=${true}
936
+ .setpoint=${25}
937
+ .size=${ReadoutListItemSize.medium}
938
+ .maxDigits=${SYNC_MAX_DIGITS}
939
+ .setpointOptions=${syncSetpointOptions(false)}
940
+ ></obc-readout-list-item>
941
+ </div>
942
+
943
+ <div style="display:flex; gap:12px; flex-wrap:wrap;">
944
+ <button id="btn-at-reset" style="padding:8px 16px; cursor:pointer;">
945
+ Reset (t=0)
946
+ </button>
947
+ <button id="btn-at-initiate" style="padding:8px 16px; cursor:pointer;">
948
+ Initiate (t=1)
949
+ </button>
950
+ <button id="btn-at-move" style="padding:8px 16px; cursor:pointer;">
951
+ Move (t=2)
952
+ </button>
953
+ <button id="btn-at-confirm" style="padding:8px 16px; cursor:pointer;">
954
+ Confirm (t=3)
955
+ </button>
956
+ </div>
957
+
958
+ <div
959
+ id="at-status"
960
+ style="font:12px/1.4 monospace; color:var(--element-neutral-color, #666);"
961
+ >
962
+ State: t=0 (at setpoint) | angle=30°, angleSP=30°, thrust=25%,
963
+ thrustSP=25%
964
+ </div>
965
+ </div>
966
+ `,
967
+ play: async ({canvasElement}) => {
968
+ await new Promise((r) => setTimeout(r, 500));
969
+
970
+ const at = canvasElement.querySelector('#azimuth-demo') as
971
+ | (HTMLElement & {
972
+ angle: number;
973
+ angleSetpoint: number | undefined;
974
+ newAngleSetpoint: number | undefined;
975
+ thrust: number;
976
+ thrustSetpoint: number | undefined;
977
+ touching: boolean;
978
+ })
979
+ | null;
980
+ const status = canvasElement.querySelector(
981
+ '#at-status'
982
+ ) as HTMLElement | null;
983
+ const angleReadout = canvasElement.querySelector('#rli-angle') as
984
+ | (HTMLElement & {
985
+ value: number;
986
+ setpoint: number;
987
+ setpointOptions: ReadoutSetpointOptions;
988
+ })
989
+ | null;
990
+ const powerReadout = canvasElement.querySelector('#rli-power') as
991
+ | (HTMLElement & {
992
+ value: number;
993
+ setpoint: number;
994
+ setpointOptions: ReadoutSetpointOptions;
995
+ })
996
+ | null;
997
+ const btnReset = canvasElement.querySelector(
998
+ '#btn-at-reset'
999
+ ) as HTMLButtonElement | null;
1000
+ const btnInitiate = canvasElement.querySelector(
1001
+ '#btn-at-initiate'
1002
+ ) as HTMLButtonElement | null;
1003
+ const btnMove = canvasElement.querySelector(
1004
+ '#btn-at-move'
1005
+ ) as HTMLButtonElement | null;
1006
+ const btnConfirm = canvasElement.querySelector(
1007
+ '#btn-at-confirm'
1008
+ ) as HTMLButtonElement | null;
1009
+
1010
+ if (
1011
+ !at ||
1012
+ !status ||
1013
+ !angleReadout ||
1014
+ !powerReadout ||
1015
+ !btnReset ||
1016
+ !btnInitiate ||
1017
+ !btnMove ||
1018
+ !btnConfirm
1019
+ ) {
1020
+ return;
1021
+ }
1022
+
1023
+ const ANGLE_FROM = 30;
1024
+ const ANGLE_TO = 120;
1025
+ const THRUST_FROM = 25;
1026
+ const THRUST_TO = 70;
1027
+
1028
+ const anim = {
1029
+ angle: ANGLE_FROM,
1030
+ thrust: THRUST_FROM,
1031
+ newAngle: ANGLE_FROM,
1032
+ newThrust: THRUST_FROM,
1033
+ };
1034
+
1035
+ // Toggle the readouts' touch (focus) state together. Lit re-renders on a
1036
+ // fresh `setpointOptions` reference (the value / setpoint primitives and the
1037
+ // static size/maxDigits props update independently).
1038
+ const setReadoutTouch = (touch: boolean) => {
1039
+ angleReadout.setpointOptions = syncSetpointOptions(touch);
1040
+ powerReadout.setpointOptions = syncSetpointOptions(touch);
1041
+ };
1042
+
1043
+ const updateStatus = (state: string) => {
1044
+ const nAngle = at.newAngleSetpoint;
1045
+ status.textContent = `State: ${state} | angle=${Math.round(anim.angle)}°, angleSP=${Math.round(at.angleSetpoint ?? 0)}°, newAngleSP=${nAngle !== undefined ? Math.round(nAngle) + '°' : 'n/a'}, thrust=${Math.round(anim.thrust)}%, thrustSP=${Math.round(at.thrustSetpoint ?? 0)}%`;
1046
+ };
1047
+
1048
+ const killAll = () => gsap.killTweensOf(anim);
1049
+
1050
+ // t=0 — vessel at setpoint, no adjustment in progress.
1051
+ btnReset.onclick = () => {
1052
+ killAll();
1053
+ anim.angle = ANGLE_FROM;
1054
+ anim.thrust = THRUST_FROM;
1055
+ anim.newAngle = ANGLE_FROM;
1056
+ anim.newThrust = THRUST_FROM;
1057
+ at.angle = ANGLE_FROM;
1058
+ at.angleSetpoint = ANGLE_FROM;
1059
+ at.newAngleSetpoint = undefined;
1060
+ at.thrust = THRUST_FROM;
1061
+ at.thrustSetpoint = THRUST_FROM;
1062
+ (at as unknown as {newThrustSetpoint?: number}).newThrustSetpoint =
1063
+ undefined;
1064
+ at.touching = false;
1065
+ setReadoutTouch(false);
1066
+ angleReadout.value = ANGLE_FROM;
1067
+ angleReadout.setpoint = ANGLE_FROM;
1068
+ powerReadout.value = THRUST_FROM;
1069
+ powerReadout.setpoint = THRUST_FROM;
1070
+ updateStatus('t=0 (at setpoint)');
1071
+ };
1072
+
1073
+ // t=1 — start adjusting: new setpoint appears at the current position, the
1074
+ // readouts enter the touch (focus) state.
1075
+ btnInitiate.onclick = () => {
1076
+ killAll();
1077
+ anim.newAngle = at.angleSetpoint ?? ANGLE_FROM;
1078
+ anim.newThrust = at.thrustSetpoint ?? THRUST_FROM;
1079
+ at.newAngleSetpoint = anim.newAngle;
1080
+ (at as unknown as {newThrustSetpoint?: number}).newThrustSetpoint =
1081
+ anim.newThrust;
1082
+ at.touching = true;
1083
+ setReadoutTouch(true);
1084
+ updateStatus('t=1 (initiate)');
1085
+ };
1086
+
1087
+ // t=2 — slide the new setpoint; the readouts' setpoint value follows while
1088
+ // the value (vessel) stays put, so flip-flop emphasises the setpoint.
1089
+ btnMove.onclick = () => {
1090
+ killAll();
1091
+ if (at.newAngleSetpoint === undefined) {
1092
+ at.newAngleSetpoint = at.angleSetpoint ?? ANGLE_FROM;
1093
+ anim.newAngle = at.newAngleSetpoint;
1094
+ (at as unknown as {newThrustSetpoint?: number}).newThrustSetpoint =
1095
+ at.thrustSetpoint ?? THRUST_FROM;
1096
+ anim.newThrust = at.thrustSetpoint ?? THRUST_FROM;
1097
+ at.touching = true;
1098
+ setReadoutTouch(true);
1099
+ }
1100
+ gsap.to(anim, {
1101
+ newAngle: ANGLE_TO,
1102
+ newThrust: THRUST_TO,
1103
+ duration: 1.5,
1104
+ ease: 'power2.inOut',
1105
+ onUpdate: () => {
1106
+ at.newAngleSetpoint = anim.newAngle;
1107
+ (at as unknown as {newThrustSetpoint?: number}).newThrustSetpoint =
1108
+ anim.newThrust;
1109
+ angleReadout.setpoint = Math.round(anim.newAngle);
1110
+ powerReadout.setpoint = Math.round(anim.newThrust);
1111
+ updateStatus('t=2 (move)');
1112
+ },
1113
+ });
1114
+ };
1115
+
1116
+ // t=3 — confirm: setpoint commits, focus clears, the value catches up
1117
+ // (thrust faster than the heavier angle); flip-flop swaps back to the value.
1118
+ btnConfirm.onclick = () => {
1119
+ killAll();
1120
+ const targetAngle = Math.round(at.newAngleSetpoint ?? ANGLE_TO);
1121
+ const targetThrust = Math.round(anim.newThrust);
1122
+
1123
+ at.angleSetpoint = targetAngle;
1124
+ at.newAngleSetpoint = undefined;
1125
+ at.thrustSetpoint = targetThrust;
1126
+ (at as unknown as {newThrustSetpoint?: number}).newThrustSetpoint =
1127
+ undefined;
1128
+ at.touching = false;
1129
+
1130
+ anim.angle = at.angle;
1131
+ anim.thrust = at.thrust;
1132
+
1133
+ setReadoutTouch(false);
1134
+ angleReadout.setpoint = targetAngle;
1135
+ powerReadout.setpoint = targetThrust;
1136
+
1137
+ gsap.to(anim, {
1138
+ thrust: targetThrust,
1139
+ duration: SYNC_THRUST_DURATION,
1140
+ ease: 'sine.inOut',
1141
+ onUpdate: () => {
1142
+ at.thrust = anim.thrust;
1143
+ powerReadout.value = Math.round(anim.thrust);
1144
+ updateStatus('t=3 (confirm)');
1145
+ },
1146
+ });
1147
+
1148
+ gsap.to(anim, {
1149
+ angle: targetAngle,
1150
+ duration: SYNC_ANGLE_DURATION,
1151
+ ease: 'sine.inOut',
1152
+ onUpdate: () => {
1153
+ at.angle = anim.angle;
1154
+ angleReadout.value = Math.round(anim.angle);
1155
+ updateStatus('t=3 (confirm)');
1156
+ },
1157
+ });
1158
+ };
1159
+
1160
+ // ── Auto-play sequence ──
1161
+ await userEvent.click(btnReset);
1162
+ await new Promise((r) => setTimeout(r, 1000));
1163
+ await userEvent.click(btnInitiate);
1164
+ await new Promise((r) => setTimeout(r, 1200));
1165
+ await userEvent.click(btnMove);
1166
+ await new Promise((r) => setTimeout(r, 3000));
1167
+ await userEvent.click(btnConfirm);
1168
+ await new Promise((r) => setTimeout(r, SYNC_ANGLE_DURATION * 1000 + 1000));
468
1169
  },
469
1170
  };
470
1171
 
471
- export const LevelCritical: Story = {
472
- args: {
473
- value: 123,
474
- alert: {
475
- status: AlertType.LevelCritical,
476
- mode: ObcAlertFrameMode.unackedActive,
477
- type: ObcAlertFrameType.Regular,
478
- },
1172
+ export const DataQuality: Story = {
1173
+ render: () =>
1174
+ renderShowcase([
1175
+ {
1176
+ title: 'Data quality',
1177
+ columns: 4,
1178
+ cases: [
1179
+ {label: 'nominal', config: {options: {}}},
1180
+ {
1181
+ label: 'low-integrity',
1182
+ config: {
1183
+ options: {dataQuality: ReadoutListItemDataQuality.lowIntegrity},
1184
+ },
1185
+ },
1186
+ {
1187
+ label: 'invalid',
1188
+ config: {
1189
+ options: {dataQuality: ReadoutListItemDataQuality.invalid},
1190
+ },
1191
+ },
1192
+ {
1193
+ label: 'null value (dash)',
1194
+ config: {value: null, options: {}},
1195
+ },
1196
+ ],
1197
+ },
1198
+ ]),
1199
+ };
1200
+
1201
+ export const PerBlockState: Story = {
1202
+ render: () =>
1203
+ renderShowcase([
1204
+ {
1205
+ title: 'Per-block data quality (independent of the row)',
1206
+ cases: [
1207
+ {
1208
+ label: 'value low-integrity',
1209
+ config: {
1210
+ hasSetpoint: true,
1211
+ hasAdvice: true,
1212
+ options: {
1213
+ value: {dataQuality: ReadoutListItemDataQuality.lowIntegrity},
1214
+ },
1215
+ },
1216
+ },
1217
+ {
1218
+ label: 'setpoint invalid',
1219
+ config: {
1220
+ hasSetpoint: true,
1221
+ options: {
1222
+ setpoint: {dataQuality: ReadoutListItemDataQuality.invalid},
1223
+ },
1224
+ },
1225
+ },
1226
+ {
1227
+ label: 'advice low-integrity',
1228
+ config: {
1229
+ hasAdvice: true,
1230
+ options: {
1231
+ advice: {dataQuality: ReadoutListItemDataQuality.lowIntegrity},
1232
+ },
1233
+ },
1234
+ },
1235
+ {
1236
+ label: 'src invalid',
1237
+ config: {
1238
+ options: {src: {dataQuality: ReadoutListItemDataQuality.invalid}},
1239
+ },
1240
+ },
1241
+ {
1242
+ label: 'row invalid + value low-integrity (nested)',
1243
+ config: {
1244
+ options: {
1245
+ dataQuality: ReadoutListItemDataQuality.invalid,
1246
+ value: {dataQuality: ReadoutListItemDataQuality.lowIntegrity},
1247
+ },
1248
+ },
1249
+ },
1250
+ ],
1251
+ },
1252
+ {
1253
+ title: 'Per-block alert frames (nest inside the row)',
1254
+ columns: 3,
1255
+ cases: [
1256
+ {
1257
+ label: 'value alert',
1258
+ config: {
1259
+ options: {
1260
+ value: {
1261
+ alert: {
1262
+ status: AlertType.Warning,
1263
+ mode: ObcAlertFrameMode.unackedActive,
1264
+ type: ObcAlertFrameType.Regular,
1265
+ },
1266
+ },
1267
+ },
1268
+ },
1269
+ },
1270
+ {
1271
+ label: 'setpoint alert',
1272
+ config: {
1273
+ hasSetpoint: true,
1274
+ options: {
1275
+ setpoint: {
1276
+ alert: {
1277
+ status: AlertType.Caution,
1278
+ mode: ObcAlertFrameMode.ackedActive,
1279
+ type: ObcAlertFrameType.Regular,
1280
+ },
1281
+ },
1282
+ },
1283
+ },
1284
+ },
1285
+ {
1286
+ label: 'row alarm + value warning (nested)',
1287
+ config: {
1288
+ options: {
1289
+ alert: {
1290
+ status: AlertType.Alarm,
1291
+ mode: ObcAlertFrameMode.ackedActive,
1292
+ type: ObcAlertFrameType.Regular,
1293
+ },
1294
+ value: {
1295
+ alert: {
1296
+ status: AlertType.Warning,
1297
+ mode: ObcAlertFrameMode.unackedActive,
1298
+ type: ObcAlertFrameType.Regular,
1299
+ },
1300
+ },
1301
+ },
1302
+ },
1303
+ },
1304
+ ],
1305
+ },
1306
+ {
1307
+ // `dataQuality` (the surface bg + outline) is orthogonal to `alert` (the
1308
+ // surrounding frame), so they stack: the low-integrity / invalid styling
1309
+ // sits INSIDE the alert frame. A side-flip frame type shows the alert
1310
+ // category badge in the flap for extra emphasis.
1311
+ title: 'Data quality inside an alert frame (combined)',
1312
+ columns: 2,
1313
+ cases: [
1314
+ {
1315
+ label: 'low-integrity + warning frame (badge flap)',
1316
+ config: {
1317
+ hasSetpoint: true,
1318
+ setpoint: 120,
1319
+ options: {
1320
+ dataQuality: ReadoutListItemDataQuality.lowIntegrity,
1321
+ alert: {
1322
+ status: AlertType.Warning,
1323
+ mode: ObcAlertFrameMode.unackedActive,
1324
+ type: ObcAlertFrameType.SmallSideFlip,
1325
+ showAlertCategoryIcon: true,
1326
+ },
1327
+ },
1328
+ },
1329
+ },
1330
+ {
1331
+ label: 'invalid + alarm frame (large badge flap)',
1332
+ config: {
1333
+ value: null,
1334
+ options: {
1335
+ dataQuality: ReadoutListItemDataQuality.invalid,
1336
+ alert: {
1337
+ status: AlertType.Alarm,
1338
+ mode: ObcAlertFrameMode.unackedActive,
1339
+ type: ObcAlertFrameType.LargeSideFlip,
1340
+ showAlertCategoryIcon: true,
1341
+ },
1342
+ },
1343
+ },
1344
+ },
1345
+ ],
1346
+ },
1347
+ ]),
1348
+ };
1349
+
1350
+ export const Clickable: Story = {
1351
+ render: () =>
1352
+ renderShowcase([
1353
+ {
1354
+ title: 'Clickable borders',
1355
+ columns: 3,
1356
+ cases: [
1357
+ {
1358
+ label: 'squared',
1359
+ config: {
1360
+ options: {clickable: {border: ReadoutListItemBorder.squared}},
1361
+ },
1362
+ },
1363
+ {
1364
+ label: 'round-corners',
1365
+ config: {
1366
+ options: {
1367
+ clickable: {border: ReadoutListItemBorder.roundCorners},
1368
+ },
1369
+ },
1370
+ },
1371
+ {
1372
+ label: 'round',
1373
+ config: {
1374
+ options: {clickable: {border: ReadoutListItemBorder.round}},
1375
+ },
1376
+ },
1377
+ ],
1378
+ },
1379
+ ]),
1380
+ };
1381
+
1382
+ const alertConfig = (alert: AlertFrameConfig): StoryOptions => ({
1383
+ alert,
1384
+ });
1385
+
1386
+ export const Alarm: Story = {
1387
+ render: () =>
1388
+ showcaseItem({
1389
+ options: alertConfig({
1390
+ status: AlertType.Alarm,
1391
+ mode: ObcAlertFrameMode.ackedActive,
1392
+ type: ObcAlertFrameType.Regular,
1393
+ }),
1394
+ }),
1395
+ };
1396
+
1397
+ // Each row drops a different part; the per-row outline makes it obvious that the
1398
+ // row height and the bottom baseline stay constant regardless of what's missing.
1399
+ const MISSING_PARTS_ROWS: {title: string; config: ReadoutItemConfig}[] = [
1400
+ {
1401
+ title: 'all parts',
1402
+ config: {label: 'Heading', value: 123, unit: 'kn', src: 'GPS'},
479
1403
  },
1404
+ {title: 'no label', config: {value: 123, unit: 'kn', src: 'GPS'}},
1405
+ {
1406
+ title: 'no value (hasValue false)',
1407
+ config: {label: 'Heading', hasValue: false, unit: 'kn', src: 'GPS'},
1408
+ },
1409
+ {
1410
+ title: 'null value (dash)',
1411
+ config: {label: 'Heading', value: null, unit: 'kn', src: 'GPS'},
1412
+ },
1413
+ {title: 'no unit', config: {label: 'Heading', value: 123, src: 'GPS'}},
1414
+ {title: 'no src', config: {label: 'Heading', value: 123, unit: 'kn'}},
1415
+ {title: 'label only', config: {label: 'Heading', hasValue: false}},
1416
+ {title: 'value only', config: {value: 123}},
1417
+ {
1418
+ title: 'off',
1419
+ config: {label: 'Heading', off: true, unit: 'kn', src: 'GPS'},
1420
+ },
1421
+ ];
1422
+
1423
+ export const MissingParts: Story = {
1424
+ render: () => html`
1425
+ <style>
1426
+ .rli-mp {
1427
+ display: flex;
1428
+ flex-direction: column;
1429
+ gap: 4px;
1430
+ width: 380px;
1431
+ }
1432
+ .rli-mp-row {
1433
+ display: flex;
1434
+ align-items: center;
1435
+ gap: 12px;
1436
+ }
1437
+ .rli-mp-tag {
1438
+ width: 150px;
1439
+ flex: none;
1440
+ font: 10px/1.2 var(--global-typography-ui-label-font-family, sans-serif);
1441
+ text-transform: uppercase;
1442
+ letter-spacing: 0.06em;
1443
+ color: var(--element-neutral-color, #777);
1444
+ }
1445
+ .rli-mp-item {
1446
+ flex: 1;
1447
+ outline: 1px solid rgba(0, 0, 0, 0.12);
1448
+ }
1449
+ </style>
1450
+ <div class="rli-mp">
1451
+ ${MISSING_PARTS_ROWS.map(
1452
+ (row) => html`
1453
+ <div class="rli-mp-row">
1454
+ <div class="rli-mp-tag">${row.title}</div>
1455
+ <div class="rli-mp-item">
1456
+ ${renderItem({
1457
+ ...row.config,
1458
+ options: {
1459
+ ...row.config.options,
1460
+ size: ReadoutListItemSize.medium,
1461
+ },
1462
+ })}
1463
+ </div>
1464
+ </div>
1465
+ `
1466
+ )}
1467
+ </div>
1468
+ `,
480
1469
  };
481
1470
 
482
- export const LevelLowUnackedRectified: Story = {
483
- args: {
484
- value: 123,
485
- alert: {
486
- status: AlertType.LevelLow,
487
- mode: ObcAlertFrameMode.unackedRectified,
1471
+ /**
1472
+ * Layout / alignment stress test — many rows stacked in a fixed-width column,
1473
+ * deliberately combining as much of the API as can coexist in one aligned
1474
+ * column: degree / no-degree, short and long units (`C`, `Pa`, `m/s`, `miles`),
1475
+ * large/small/negative numbers, fractional values, a `null` (dash) value,
1476
+ * hinted zeros, `low-integrity` and `invalid` data quality, an `enhanced`
1477
+ * priority and a `bold` value weight, and a "kitchen-sink" row (Depth) with a
1478
+ * setpoint, advice and both leading + value icons.
1479
+ *
1480
+ * Each `obc-readout-list-item` is its own custom element, so cross-row column
1481
+ * alignment is **not** automatic — it is driven per-row by the width reservers.
1482
+ * The first column sets none, so the unit column and value right edge drift.
1483
+ * The second column gives every row the same reservers — the longest value
1484
+ * (`"0000.0"` via `options.value/setpoint/advice.spaceReserver`) and the longest
1485
+ * unit (`"miles"` via `options.unit.spaceReserver`) — plus reserves the degree
1486
+ * column (`hasDegreeSpacer`) on non-degree rows. So every block reserves the
1487
+ * same width regardless of each row's own value length / `fractionDigits`, and
1488
+ * the columns line up.
1489
+ *
1490
+ * (Source/stacking variations are exercised in the `LeadingSrc` / `LeadingUnit`
1491
+ * stories; mixing them here would move the unit out of the rightmost column.)
1492
+ *
1493
+ * The last two rows use `size=medium` / `size=large`. Their value digit edges do
1494
+ * NOT fully align with the small rows (~8px stagger): the `°` column scales with
1495
+ * the value size (6 / 8 / 12px), so a degree row's value edge sits `degree-width`
1496
+ * to the left of the unit. This is a genuine cross-size trade-off (#7) — for
1497
+ * degree rows of different sizes you can align the unit column (as here) OR the
1498
+ * value digit edges, not both. Aligning the value edges would need either a
1499
+ * constant degree reserve (which widens the smaller rows' `°` — visually
1500
+ * undesirable) or pinning the value edge and letting the unit column stagger;
1501
+ * left for the designer. Two rows also carry per-block (nested) state — a value
1502
+ * `invalid` chip and a value alert frame — to confirm per-block state does not
1503
+ * shift the value edge.
1504
+ */
1505
+ type AlignmentRow = {
1506
+ label: string;
1507
+ value: number | null;
1508
+ unit: string;
1509
+ size?: ReadoutListItemSize;
1510
+ hasDegree?: boolean;
1511
+ fractionDigits?: number;
1512
+ priority?: ReadoutListItemPriority;
1513
+ weight?: ObcTextboxFontWeight;
1514
+ hintedZeros?: boolean;
1515
+ dataQuality?: ReadoutListItemDataQuality;
1516
+ hasSetpoint?: boolean;
1517
+ setpoint?: number;
1518
+ hasAdvice?: boolean;
1519
+ advice?: number;
1520
+ hasLeadingIcon?: boolean;
1521
+ hasValueIcon?: boolean;
1522
+ valueDataQuality?: ReadoutListItemDataQuality;
1523
+ valueAlert?: AlertFrameConfig;
1524
+ };
1525
+
1526
+ const ALIGNMENT_ROWS: AlignmentRow[] = [
1527
+ // degree + enhanced priority (accent value)
1528
+ {
1529
+ label: 'Temperature',
1530
+ value: 45,
1531
+ unit: 'C',
1532
+ hasDegree: true,
1533
+ priority: ReadoutListItemPriority.enhanced,
1534
+ },
1535
+ // degree + hinted zeros (small value padded to maxDigits in the aligned column)
1536
+ {label: 'Heading', value: 8, unit: 'T', hasDegree: true, hintedZeros: true},
1537
+ // advice + setpoint (no icons) — to check that advice/setpoint also
1538
+ // column-align across rows (they should: the value block is width-reserved,
1539
+ // so the blocks left of it sit at fixed offsets)
1540
+ {
1541
+ label: 'Pressure',
1542
+ value: 1013,
1543
+ unit: 'Pa',
1544
+ fractionDigits: 1,
1545
+ hasSetpoint: true,
1546
+ setpoint: 1015,
1547
+ hasAdvice: true,
1548
+ advice: 1008,
1549
+ },
1550
+ // negative value + fraction + low-integrity data quality
1551
+ {
1552
+ label: 'Flow speed',
1553
+ value: -12.5,
1554
+ unit: 'm/s',
1555
+ fractionDigits: 1,
1556
+ dataQuality: ReadoutListItemDataQuality.lowIntegrity,
1557
+ },
1558
+ // long unit + advice + setpoint (the "miles" row)
1559
+ {
1560
+ label: 'Distance',
1561
+ value: 4.2,
1562
+ unit: 'miles',
1563
+ fractionDigits: 1,
1564
+ hasSetpoint: true,
1565
+ setpoint: 4.5,
1566
+ hasAdvice: true,
1567
+ advice: 4,
1568
+ },
1569
+ // kitchen sink: value + setpoint + advice + leading icon + value icon
1570
+ {
1571
+ label: 'Depth',
1572
+ value: 1013.7,
1573
+ unit: 'm',
1574
+ fractionDigits: 1,
1575
+ hasSetpoint: true,
1576
+ setpoint: 1015,
1577
+ hasAdvice: true,
1578
+ advice: 1010,
1579
+ hasLeadingIcon: true,
1580
+ hasValueIcon: true,
1581
+ },
1582
+ // bold value weight — heavier weight only, no colour change (stays neutral)
1583
+ {
1584
+ label: 'Speed',
1585
+ value: 18,
1586
+ unit: 'kn',
1587
+ fractionDigits: 1,
1588
+ weight: ObcTextboxFontWeight.bold,
1589
+ },
1590
+ // null (dash) + invalid data quality
1591
+ {
1592
+ label: 'Wind',
1593
+ value: null,
1594
+ unit: 'kn',
1595
+ dataQuality: ReadoutListItemDataQuality.invalid,
1596
+ },
1597
+ // per-block (nested) value invalid — the chip uses outline, so the value edge
1598
+ // must stay aligned with the other rows
1599
+ {
1600
+ label: 'Battery',
1601
+ value: 11.8,
1602
+ unit: 'V',
1603
+ fractionDigits: 1,
1604
+ valueDataQuality: ReadoutListItemDataQuality.invalid,
1605
+ },
1606
+ // per-block (nested) value alert frame — must not shift the value edge
1607
+ {
1608
+ label: 'Fuel',
1609
+ value: 42,
1610
+ unit: '%',
1611
+ valueAlert: {
1612
+ status: AlertType.Warning,
1613
+ mode: ObcAlertFrameMode.unackedActive,
488
1614
  type: ObcAlertFrameType.Regular,
489
1615
  },
490
1616
  },
1617
+ // size variants — value digit edges do NOT fully align cross-size (the degree
1618
+ // column scales 6/8/12px with the value size); see the ColumnAlignment doc.
1619
+
1620
+ {
1621
+ label: 'Course',
1622
+ value: 287,
1623
+ unit: 'T',
1624
+ hasDegree: true,
1625
+ size: ReadoutListItemSize.medium,
1626
+ },
1627
+ {
1628
+ label: 'COG',
1629
+ value: 92,
1630
+ unit: 'T',
1631
+ hasDegree: true,
1632
+ size: ReadoutListItemSize.large,
1633
+ },
1634
+ ];
1635
+
1636
+ const LONGEST_UNIT = 'miles';
1637
+ const MAX_INTEGER_DIGITS = 4;
1638
+ // Longest value string in the column (4 integer digits + 1 fraction). Passed to
1639
+ // every row's value/setpoint/advice spaceReserver so they all reserve the same
1640
+ // width regardless of each row's own fractionDigits — like LONGEST_UNIT does for
1641
+ // the unit column.
1642
+ const VALUE_RESERVER = `${'0'.repeat(MAX_INTEGER_DIGITS)}.0`;
1643
+
1644
+ const alignmentStyle = `
1645
+ .rli-align-wrap { display: flex; flex-direction: column; gap: 24px; width: 100%; }
1646
+ .rli-align-section { display: flex; flex-direction: column; gap: 8px; }
1647
+ .rli-align-section-title {
1648
+ margin: 0; font: 12px/1.2 var(--global-typography-ui-label-font-family, inherit);
1649
+ text-transform: uppercase; letter-spacing: 0.06em; color: var(--element-neutral-color, #777);
1650
+ }
1651
+ .rli-align-col {
1652
+ display: flex; flex-direction: column; gap: 2px; width: 480px;
1653
+ padding: 8px; border: 1px dashed var(--border-divider-color, #ccc); border-radius: 8px;
1654
+ }
1655
+ /* Debug aid (temporary): outline each readout building block (red) and the
1656
+ degree column (blue) so reserver widths / alignment are visible. Scoped to
1657
+ this story only via .rli-align-wrap — safe to delete when no longer needed. */
1658
+ .rli-align-wrap obc-readout-list-item::part(block) {
1659
+ outline: 1px solid rgba(220, 0, 0, 0.7); outline-offset: -1px;
1660
+ }
1661
+ .rli-align-wrap obc-readout-list-item::part(degree) {
1662
+ outline: 1px solid rgba(0, 0, 220, 0.7); outline-offset: -1px;
1663
+ }
1664
+ .rli-align-wrap obc-readout-list-item::part(degree-spacer) {
1665
+ outline: 1px solid rgba(0, 160, 0, 0.8); outline-offset: -1px;
1666
+ }
1667
+ `;
1668
+
1669
+ function renderAlignmentColumn(aligned: boolean) {
1670
+ return html`
1671
+ <div class="rli-align-col">
1672
+ ${ALIGNMENT_ROWS.map((row) =>
1673
+ renderItem({
1674
+ label: row.label,
1675
+ unit: row.unit,
1676
+ src: '',
1677
+ value: row.value,
1678
+ hasSetpoint: row.hasSetpoint,
1679
+ setpoint: row.setpoint,
1680
+ hasAdvice: row.hasAdvice,
1681
+ advice: row.advice,
1682
+ hasLeadingIcon: row.hasLeadingIcon,
1683
+ hasValueIcon: row.hasValueIcon,
1684
+ options: {
1685
+ size: row.size ?? ReadoutListItemSize.small,
1686
+ hasDegree: row.hasDegree ?? false,
1687
+ fractionDigits: row.fractionDigits ?? 0,
1688
+ priority: row.priority,
1689
+ dataQuality: row.dataQuality,
1690
+ hasLeadingIcon: row.hasLeadingIcon,
1691
+ value: {
1692
+ hasIcon: row.hasValueIcon,
1693
+ weight: row.weight,
1694
+ hintedZeros: row.hintedZeros,
1695
+ dataQuality: row.valueDataQuality,
1696
+ alert: row.valueAlert,
1697
+ // Same value reserver on every row -> uniform value-block width,
1698
+ // independent of each row's own fractionDigits.
1699
+ ...(aligned ? {spaceReserver: VALUE_RESERVER} : {}),
1700
+ },
1701
+ ...(aligned
1702
+ ? {
1703
+ // Non-degree rows reserve the degree column so their digits
1704
+ // and units line up with the degree rows.
1705
+ hasDegreeSpacer: !(row.hasDegree ?? false),
1706
+ maxDigits: MAX_INTEGER_DIGITS,
1707
+ unit: {spaceReserver: LONGEST_UNIT},
1708
+ setpoint: {spaceReserver: VALUE_RESERVER},
1709
+ advice: {spaceReserver: VALUE_RESERVER},
1710
+ }
1711
+ : {}),
1712
+ },
1713
+ })
1714
+ )}
1715
+ </div>
1716
+ `;
1717
+ }
1718
+
1719
+ export const ColumnAlignment: Story = {
1720
+ render: () => html`
1721
+ <style>
1722
+ ${alignmentStyle}
1723
+ </style>
1724
+ <div class="rli-align-wrap">
1725
+ <section class="rli-align-section">
1726
+ <h3 class="rli-align-section-title">
1727
+ Without reservers — columns drift
1728
+ </h3>
1729
+ ${renderAlignmentColumn(false)}
1730
+ </section>
1731
+ <section class="rli-align-section">
1732
+ <h3 class="rli-align-section-title">
1733
+ With shared reservers — unit column &amp; value right edge aligned
1734
+ </h3>
1735
+ ${renderAlignmentColumn(true)}
1736
+ </section>
1737
+ </div>
1738
+ `,
491
1739
  };