@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.148 → 2.0.0-next.150

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 (57) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +4561 -3882
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +535 -21
  4. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.css.js +94 -0
  5. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.css.js.map +1 -0
  6. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.d.ts +72 -0
  7. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.d.ts.map +1 -0
  8. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.js +396 -0
  9. package/dist/components/alert-list-details-experimental/alert-list-details-experimental.js.map +1 -0
  10. package/dist/components/icon-button/icon-button.d.ts +3 -0
  11. package/dist/components/icon-button/icon-button.d.ts.map +1 -1
  12. package/dist/components/icon-button/icon-button.js +6 -0
  13. package/dist/components/icon-button/icon-button.js.map +1 -1
  14. package/dist/components/table/table.css.js +43 -0
  15. package/dist/components/table/table.css.js.map +1 -1
  16. package/dist/components/table/table.d.ts +31 -1
  17. package/dist/components/table/table.d.ts.map +1 -1
  18. package/dist/components/table/table.js +124 -11
  19. package/dist/components/table/table.js.map +1 -1
  20. package/dist/components/top-bar/top-bar.d.ts.map +1 -1
  21. package/dist/components/top-bar/top-bar.js +8 -0
  22. package/dist/components/top-bar/top-bar.js.map +1 -1
  23. package/dist/generated/locales/es-419.d.ts +11 -1
  24. package/dist/generated/locales/es-419.d.ts.map +1 -1
  25. package/dist/generated/locales/es-419.js +11 -1
  26. package/dist/generated/locales/es-419.js.map +1 -1
  27. package/dist/generated/locales/fi-FI.d.ts +11 -1
  28. package/dist/generated/locales/fi-FI.d.ts.map +1 -1
  29. package/dist/generated/locales/fi-FI.js +11 -1
  30. package/dist/generated/locales/fi-FI.js.map +1 -1
  31. package/dist/integration-systems/integration-bar/integration-bar.d.ts +8 -8
  32. package/dist/integration-systems/integration-bar/integration-bar.d.ts.map +1 -1
  33. package/dist/integration-systems/integration-bar/integration-bar.js +13 -1
  34. package/dist/integration-systems/integration-bar/integration-bar.js.map +1 -1
  35. package/dist/integration-systems/integration-bar-dropdown/integration-bar-dropdown.d.ts +72 -7
  36. package/dist/integration-systems/integration-bar-dropdown/integration-bar-dropdown.d.ts.map +1 -1
  37. package/dist/integration-systems/integration-bar-dropdown/integration-bar-dropdown.js +99 -41
  38. package/dist/integration-systems/integration-bar-dropdown/integration-bar-dropdown.js.map +1 -1
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/alert-list-details-experimental/alert-list-details-experimental.css +70 -0
  44. package/src/components/alert-list-details-experimental/alert-list-details-experimental.stories.ts +510 -0
  45. package/src/components/alert-list-details-experimental/alert-list-details-experimental.ts +457 -0
  46. package/src/components/icon-button/icon-button.spec.ts +52 -0
  47. package/src/components/icon-button/icon-button.ts +7 -0
  48. package/src/components/table/table.css +42 -0
  49. package/src/components/table/table.stories.ts +279 -0
  50. package/src/components/table/table.ts +177 -13
  51. package/src/components/top-bar/top-bar.ts +10 -0
  52. package/src/generated/locales/es-419.ts +11 -1
  53. package/src/generated/locales/fi-FI.ts +11 -1
  54. package/src/integration-systems/integration-bar/integration-bar.ts +21 -9
  55. package/src/integration-systems/integration-bar-dropdown/integration-bar-dropdown.stories.ts +70 -3
  56. package/src/integration-systems/integration-bar-dropdown/integration-bar-dropdown.ts +138 -52
  57. package/src/types.ts +6 -4
@@ -0,0 +1,457 @@
1
+ import {LitElement, html, unsafeCSS} from 'lit';
2
+ import {customElement} from '../../decorator.js';
3
+ import compentStyle from './alert-list-details-experimental.css?inline';
4
+ import {msg} from '@lit/localize';
5
+ import {property, query, state} from 'lit/decorators.js';
6
+ import '../icon-button/icon-button.js';
7
+ import '../button/button.js';
8
+ import '../../icons/icon-silence-iec.js';
9
+ import '../../icons/icon-alerts.js';
10
+ import '../../icons/icon-alerts-shelf.js';
11
+ import '../../icons/icon-alerts-active.js';
12
+ import '../../icons/icon-alarm-rectified-iec.js';
13
+ import '../../icons/icon-unacknowledged.js';
14
+ import '../../icons/icon-alarm-noack-iec.js';
15
+ import '../../icons/icon-warning-noack-iec.js';
16
+ import '../alert-icon/alert-icon.js';
17
+ import {
18
+ Alert,
19
+ comparePriorityAlerts,
20
+ isActive,
21
+ isAcknowledged,
22
+ isBlocked,
23
+ isShelved,
24
+ } from '../../types.js';
25
+ import {
26
+ excludedFromUnackedFilter,
27
+ requiresAcknowledgement,
28
+ usesAlarmNoAckIcon,
29
+ } from '../../alert-severity.js';
30
+ import {
31
+ ObcTable,
32
+ ObcTableCellClickEvent,
33
+ ObcTableCellData,
34
+ ObcTableCellType,
35
+ ObcTableExpandToggleEvent,
36
+ ObcTableRowClickEvent,
37
+ ObcTableRow,
38
+ ObcTableColumn,
39
+ } from '../table/table.js';
40
+ import '../scrollbar/scrollbar.js';
41
+
42
+ export enum AlertListMode {
43
+ UNACKED = 'unacked',
44
+ ALL = 'all',
45
+ SHELVED = 'shelved',
46
+ BLOCKED = 'blocked',
47
+ RECTIFIED = 'rectified',
48
+ }
49
+
50
+ export type ObcAckClickEvent = CustomEvent<{
51
+ alert: Alert;
52
+ }>;
53
+
54
+ export type ObcRowClickEvent = CustomEvent<{
55
+ alert: Alert;
56
+ }>;
57
+
58
+ export function getAlertListModeData(selectedMode: AlertListMode) {
59
+ if (selectedMode === AlertListMode.ALL)
60
+ return {
61
+ name: AlertListMode.ALL,
62
+ title: msg('All'),
63
+ emptyTitle: msg('No active alerts'),
64
+ emptyIcon: html`<obi-alerts></obi-alerts>`,
65
+ filter: (alert: Alert) => !isShelved(alert) && isActive(alert),
66
+ };
67
+ else if (selectedMode === AlertListMode.UNACKED)
68
+ return {
69
+ name: AlertListMode.UNACKED,
70
+ title: msg('Unacked'),
71
+ emptyTitle: msg('No unacknowledged alerts'),
72
+ emptyIcon: html`<obi-unacknowledged></obi-unacknowledged>`,
73
+ filter: (alert: Alert) =>
74
+ !isAcknowledged(alert) &&
75
+ isActive(alert) &&
76
+ !excludedFromUnackedFilter(alert.type) &&
77
+ !isShelved(alert),
78
+ };
79
+ else if (selectedMode === AlertListMode.SHELVED)
80
+ return {
81
+ name: AlertListMode.SHELVED,
82
+ title: msg('Shelved'),
83
+ emptyTitle: msg('No shelved alerts'),
84
+ emptyIcon: html`<obi-alerts-shelf></obi-alerts-shelf>`,
85
+ filter: (alert: Alert) => isShelved(alert),
86
+ };
87
+ else if (selectedMode === AlertListMode.BLOCKED)
88
+ return {
89
+ name: AlertListMode.BLOCKED,
90
+ title: msg('Blocked'),
91
+ emptyTitle: msg('No blocked alerts'),
92
+ emptyIcon: html`<obi-alerts-active></obi-alerts-active>`,
93
+ filter: (alert: Alert) => isBlocked(alert),
94
+ };
95
+ else if (selectedMode === AlertListMode.RECTIFIED)
96
+ return {
97
+ name: AlertListMode.RECTIFIED,
98
+ title: msg('Rectified'),
99
+ emptyTitle: msg('No rectified alerts'),
100
+ emptyIcon: html`<obi-alarm-rectified-iec></obi-alarm-rectified-iec>`,
101
+ filter: (alert: Alert) => !isActive(alert),
102
+ };
103
+ else throw new Error('Invalid selected mode');
104
+ }
105
+
106
+ export function canAckFilter(filter: (alert: Alert) => boolean) {
107
+ return (alert: Alert) =>
108
+ !isAcknowledged(alert) &&
109
+ !alert.noAck &&
110
+ !excludedFromUnackedFilter(alert.type) &&
111
+ filter(alert);
112
+ }
113
+
114
+ /**
115
+ * @availableWhen timeFormatter showTime==true
116
+ * @property defaultExpanded - Whether groups start expanded. Set false to open the list collapsed.
117
+ * @fires {ObcAckClickEvent} ack-click - Fired when the user clicks the "ACK" button.
118
+ * @fires {ObcRowClickEvent} row-click - Fired when the user clicks a row.
119
+ * @experimental
120
+ */
121
+ @customElement('obc-alert-list-details-experimental')
122
+ export class ObcAlertListDetailsExperimental extends LitElement {
123
+ @property({type: String}) selectedMode: AlertListMode = AlertListMode.ALL;
124
+ @property({type: Array}) alerts: Alert[] = [];
125
+ @property({type: Boolean}) showTime: boolean = false;
126
+ @property({attribute: false}) timeFormatter: (time: Date) => string = (
127
+ time: Date
128
+ ) => time.toLocaleTimeString(undefined, {hour12: false});
129
+ @property({type: Boolean}) small: boolean = false;
130
+ @property({type: Boolean, attribute: false}) defaultExpanded: boolean = true;
131
+
132
+ @query('obc-table')
133
+ private alertList!: ObcTable;
134
+
135
+ @state() private expansionOverrides = new Map<string, boolean>();
136
+
137
+ private alertByRowId = new Map<string, Alert>();
138
+
139
+ public getVisibleAlerts(): Alert[] {
140
+ const seen = new Set<string>();
141
+ return this.alertList
142
+ .getAllVisibleRows()
143
+ .map((rowId) => this.alertByRowId.get(rowId))
144
+ .filter((alert): alert is Alert => alert !== undefined)
145
+ .filter((alert) => {
146
+ if (seen.has(alert.id)) {
147
+ return false;
148
+ }
149
+ seen.add(alert.id);
150
+ return true;
151
+ });
152
+ }
153
+
154
+ private onRowClick(e: ObcTableRowClickEvent) {
155
+ const row = this.alertByRowId.get(e.detail.row.id);
156
+ if (row) {
157
+ this.dispatchEvent(
158
+ new CustomEvent('row-click', {detail: {alert: row}}) as ObcRowClickEvent
159
+ );
160
+ }
161
+ }
162
+
163
+ private onCellButtonClick(e: ObcTableCellClickEvent) {
164
+ const row = this.alertByRowId.get(e.detail.rowId);
165
+ if (row) {
166
+ this.dispatchEvent(
167
+ new CustomEvent('ack-click', {
168
+ detail: {alert: row},
169
+ bubbles: false,
170
+ }) as ObcAckClickEvent
171
+ );
172
+ }
173
+ }
174
+
175
+ private onExpandToggle(e: ObcTableExpandToggleEvent) {
176
+ const overrides = new Map(this.expansionOverrides);
177
+ overrides.set(e.detail.rowId, e.detail.expanded);
178
+ this.expansionOverrides = overrides;
179
+ }
180
+
181
+ private isExpanded(rowId: string) {
182
+ return this.expansionOverrides.get(rowId) ?? this.defaultExpanded;
183
+ }
184
+
185
+ private get columns() {
186
+ if (this.small) {
187
+ const columns: ObcTableColumn<ObcTableCellData, ObcTableRow>[] = [
188
+ {
189
+ label: 'Status',
190
+ key: 'status',
191
+ sortDirection: 'desc',
192
+ sortable: true,
193
+ compareFunction: (_a, _b, aRow, bRow) => {
194
+ const aAlert = this.alertByRowId.get(aRow.id);
195
+ const bAlert = this.alertByRowId.get(bRow.id);
196
+ if (aAlert && bAlert) {
197
+ return comparePriorityAlerts(aAlert, bAlert);
198
+ }
199
+ return 0;
200
+ },
201
+ },
202
+ ];
203
+ if (this.showTime) {
204
+ columns.push({
205
+ label: 'Activated',
206
+ key: 'time',
207
+ });
208
+ }
209
+ columns.push({
210
+ label: 'ACK-status',
211
+ key: 'action',
212
+ });
213
+ return columns;
214
+ } else {
215
+ const columns: ObcTableColumn<ObcTableCellData, ObcTableRow>[] = [
216
+ {
217
+ label: 'Status',
218
+ key: 'status',
219
+ sortDirection: 'desc',
220
+ sortable: true,
221
+ compareFunction: (_a, _b, aRow, bRow) => {
222
+ const aAlert = this.alertByRowId.get(aRow.id);
223
+ const bAlert = this.alertByRowId.get(bRow.id);
224
+ if (aAlert && bAlert) {
225
+ return comparePriorityAlerts(aAlert, bAlert);
226
+ }
227
+ return 0;
228
+ },
229
+ },
230
+ {
231
+ label: 'ACK-status',
232
+ key: 'action',
233
+ dividerRight: true,
234
+ },
235
+ ];
236
+ if (this.showTime) {
237
+ columns.push({
238
+ label: 'Activated',
239
+ key: 'time',
240
+ sortable: true,
241
+ compareFunction: (_a, _b, aRow, bRow) => {
242
+ const aAlert = this.alertByRowId.get(aRow.id);
243
+ const bAlert = this.alertByRowId.get(bRow.id);
244
+ if (aAlert && bAlert) {
245
+ const aTime = new Date(aAlert.time);
246
+ const bTime = new Date(bAlert.time);
247
+ return aTime.getTime() - bTime.getTime();
248
+ }
249
+ return 0;
250
+ },
251
+ });
252
+ }
253
+ columns.push({
254
+ label: 'Tag ID',
255
+ key: 'tagId',
256
+ sortable: true,
257
+ compareFunction: (a, b) => {
258
+ const aText =
259
+ a?.type === ObcTableCellType.Regular ? String(a.text ?? '') : '';
260
+ const bText =
261
+ b?.type === ObcTableCellType.Regular ? String(b.text ?? '') : '';
262
+ return aText.localeCompare(bText);
263
+ },
264
+ });
265
+ return columns;
266
+ }
267
+ }
268
+
269
+ private get metadata() {
270
+ return getAlertListModeData(this.selectedMode);
271
+ }
272
+
273
+ private get filteredAlerts() {
274
+ return this.alerts.filter(this.metadata.filter);
275
+ }
276
+
277
+ private buildVisibleRows(): ObcTableRow[] {
278
+ const alerts = this.filteredAlerts;
279
+ const alertIds = new Set(alerts.map((alert) => alert.id));
280
+ const membersByGroupId = new Map<string, Alert[]>();
281
+ const roots: Alert[] = [];
282
+ for (const alert of alerts) {
283
+ const groupIds = (alert.memberOf ?? []).filter(
284
+ (groupId) => groupId !== alert.id && alertIds.has(groupId)
285
+ );
286
+ if (groupIds.length === 0) {
287
+ roots.push(alert);
288
+ continue;
289
+ }
290
+ for (const groupId of groupIds) {
291
+ const members = membersByGroupId.get(groupId) ?? [];
292
+ members.push(alert);
293
+ membersByGroupId.set(groupId, members);
294
+ }
295
+ }
296
+
297
+ const reachable = new Set<string>();
298
+ const markReachable = (alert: Alert) => {
299
+ if (reachable.has(alert.id)) {
300
+ return;
301
+ }
302
+ reachable.add(alert.id);
303
+ for (const member of membersByGroupId.get(alert.id) ?? []) {
304
+ markReachable(member);
305
+ }
306
+ };
307
+ roots.forEach(markReachable);
308
+ for (const alert of alerts) {
309
+ if (!reachable.has(alert.id)) {
310
+ roots.push(alert);
311
+ markReachable(alert);
312
+ }
313
+ }
314
+
315
+ const rows: ObcTableRow[] = [];
316
+ this.alertByRowId = new Map();
317
+ const visit = (
318
+ alert: Alert,
319
+ level: number,
320
+ parentRowId: string | undefined,
321
+ ancestors: Set<string>
322
+ ) => {
323
+ const segment = encodeURIComponent(alert.id);
324
+ const rowId =
325
+ parentRowId === undefined ? segment : `${parentRowId}/${segment}`;
326
+ const members = membersByGroupId.get(alert.id) ?? [];
327
+ // A member can name a group that is also its own descendant.
328
+ const expandableMembers = members.filter(
329
+ (member) => !ancestors.has(member.id)
330
+ );
331
+ const expanded = this.isExpanded(rowId);
332
+
333
+ this.alertByRowId.set(rowId, alert);
334
+ rows.push({
335
+ ...this.buildRowCells(alert),
336
+ id: rowId,
337
+ parentId: parentRowId,
338
+ level,
339
+ expandable: expandableMembers.length > 0,
340
+ expanded,
341
+ });
342
+
343
+ if (!expanded) {
344
+ return;
345
+ }
346
+ const nextAncestors = new Set(ancestors).add(alert.id);
347
+ for (const member of expandableMembers) {
348
+ visit(member, level + 1, rowId, nextAncestors);
349
+ }
350
+ };
351
+
352
+ for (const alert of roots) {
353
+ visit(alert, 0, undefined, new Set());
354
+ }
355
+ return rows;
356
+ }
357
+
358
+ private buildRowCells(
359
+ alert: Alert
360
+ ): Record<string, ObcTableCellData | undefined> {
361
+ let action: ObcTableCellData = {
362
+ type: ObcTableCellType.Regular,
363
+ };
364
+ if (
365
+ !isAcknowledged(alert) &&
366
+ isActive(alert) &&
367
+ requiresAcknowledgement(alert.type)
368
+ ) {
369
+ if (alert.noAck) {
370
+ const icon = usesAlarmNoAckIcon(alert.type)
371
+ ? html`<obi-alarm-noack-iec usecsscolor></obi-alarm-noack-iec>`
372
+ : html`<obi-warning-noack-iec usecsscolor></obi-warning-noack-iec>`;
373
+ action = {
374
+ type: ObcTableCellType.Regular,
375
+ largeIcon: true,
376
+ icon,
377
+ align: 'center',
378
+ };
379
+ } else {
380
+ action = {
381
+ type: ObcTableCellType.Button,
382
+ text: msg('ACK'),
383
+ };
384
+ }
385
+ }
386
+
387
+ const status: ObcTableCellData = {
388
+ type: ObcTableCellType.Regular,
389
+ largeIcon: true,
390
+ text: alert.text,
391
+ title: alert.source,
392
+ noWrap: true,
393
+ icon: html`<obc-alert-icon
394
+ .type=${alert.type}
395
+ .acknowledged=${isAcknowledged(alert)}
396
+ .active=${isActive(alert)}
397
+ ></obc-alert-icon>`,
398
+ };
399
+
400
+ const time: ObcTableCellData | undefined = this.showTime
401
+ ? {
402
+ type: ObcTableCellType.Regular,
403
+ text: this.timeFormatter(alert.time),
404
+ align: 'center',
405
+ neutral: true,
406
+ }
407
+ : undefined;
408
+
409
+ const tagId: ObcTableCellData | undefined = this.small
410
+ ? undefined
411
+ : {
412
+ type: ObcTableCellType.Regular,
413
+ text: '#' + alert.id,
414
+ align: 'right',
415
+ };
416
+ return {
417
+ status,
418
+ time,
419
+ action,
420
+ tagId,
421
+ };
422
+ }
423
+
424
+ override render() {
425
+ const selectedList = this.metadata;
426
+ const data = this.buildVisibleRows();
427
+
428
+ return html`
429
+ <div class="wrapper ${this.small ? 'small' : ''}">
430
+ ${data.length > 0
431
+ ? html` <obc-table
432
+ class="alert-list"
433
+ .data=${data}
434
+ .columns=${this.columns}
435
+ .striped=${true}
436
+ .showHeader=${!this.small}
437
+ @row-click=${this.onRowClick}
438
+ @cell-button-click=${this.onCellButtonClick}
439
+ @expand-toggle=${this.onExpandToggle}
440
+ ></obc-table>
441
+ <div class="spacer"></div>`
442
+ : html` <div class="empty-list">
443
+ <div class="icon">${selectedList.emptyIcon}</div>
444
+ <div class="empty-title">${selectedList.emptyTitle}</div>
445
+ </div>`}
446
+ </div>
447
+ `;
448
+ }
449
+
450
+ static override styles = unsafeCSS(compentStyle);
451
+ }
452
+
453
+ declare global {
454
+ interface HTMLElementTagNameMap {
455
+ 'obc-alert-list-details-experimental': ObcAlertListDetailsExperimental;
456
+ }
457
+ }
@@ -0,0 +1,52 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import './icon-button.js';
3
+ import {ObcIconButton} from './icon-button.js';
4
+ import {render} from 'vitest-browser-lit';
5
+ import {html} from 'lit';
6
+
7
+ describe('obc-icon-button accessible name', () => {
8
+ async function setup(template: ReturnType<typeof html>) {
9
+ const screen = render(template);
10
+ const el = screen.baseElement.querySelector(
11
+ 'obc-icon-button'
12
+ ) as ObcIconButton;
13
+ await el.updateComplete;
14
+ return el;
15
+ }
16
+
17
+ function shadowButton(el: ObcIconButton) {
18
+ return el.shadowRoot!.querySelector('button')!;
19
+ }
20
+
21
+ it('forwards the host aria-label to the shadow button', async () => {
22
+ const el = await setup(
23
+ html`<obc-icon-button aria-label="Play"></obc-icon-button>`
24
+ );
25
+
26
+ expect(shadowButton(el).getAttribute('aria-label')).toBe('Play');
27
+ });
28
+
29
+ it('follows later changes to the host aria-label', async () => {
30
+ const el = await setup(
31
+ html`<obc-icon-button aria-label="Play"></obc-icon-button>`
32
+ );
33
+
34
+ el.setAttribute('aria-label', 'Pause');
35
+ await el.updateComplete;
36
+
37
+ expect(shadowButton(el).getAttribute('aria-label')).toBe('Pause');
38
+ });
39
+
40
+ it('does not forward aria-labelledby, which cannot resolve across the shadow boundary', async () => {
41
+ const el = await setup(
42
+ html`<obc-icon-button
43
+ aria-label="Play"
44
+ aria-labelledby="external-label"
45
+ ></obc-icon-button>`
46
+ );
47
+
48
+ const button = shadowButton(el);
49
+ expect(button.hasAttribute('aria-labelledby')).toBe(false);
50
+ expect(button.getAttribute('aria-label')).toBe('Play');
51
+ });
52
+ });
@@ -2,6 +2,7 @@ import {LitElement, html, nothing, unsafeCSS} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
3
  import iconStyle from './icon-button.css?inline';
4
4
  import {classMap} from 'lit/directives/class-map.js';
5
+ import {ifDefined} from 'lit/directives/if-defined.js';
5
6
  import {customElement} from '../../decorator.js';
6
7
  import {degToRad} from '../../svghelpers/math.js';
7
8
 
@@ -96,6 +97,7 @@ export enum IconButtonVariant {
96
97
  * If undefined, no progress indicator is shown.
97
98
  * @property hasLabel - If true, displays a label below the icon using the `label` slot.
98
99
  * @property showDivider - If false, and cornerLeft or cornerRight is true, the divider is not shown.
100
+ * @property ariaLabel - Accessible name forwarded to the inner `<button>`, mapped to the `aria-label` attribute. `aria-labelledby` is not supported: ID references cannot cross the shadow boundary.
99
101
  * @slot - Icon slot (default): Place an icon such as <obi-search> here.
100
102
  * @slot label - Optional label shown below the icon when `hasLabel` is true.
101
103
  * @fires click - Fired when the button is clicked (if not disabled).
@@ -130,6 +132,10 @@ export class ObcIconButton extends LitElement {
130
132
 
131
133
  @property({type: Boolean, attribute: false}) showDivider = true;
132
134
 
135
+ // Reactive so a consumer swapping the name (Play → Pause) re-renders the shadow button.
136
+ @property({type: String, attribute: 'aria-label'})
137
+ override ariaLabel: string | null = null;
138
+
133
139
  get progressSpinner() {
134
140
  if (this.progress === undefined) {
135
141
  return nothing;
@@ -200,6 +206,7 @@ export class ObcIconButton extends LitElement {
200
206
  'hide-divider': !this.showDivider,
201
207
  })}
202
208
  ?disabled=${this.disabled}
209
+ aria-label=${ifDefined(this.ariaLabel ?? undefined)}
203
210
  part="wrapper"
204
211
  >
205
212
  ${this.progress !== undefined ? this.progressSpinner : nothing}
@@ -197,6 +197,48 @@
197
197
  flex-shrink: 0;
198
198
  }
199
199
 
200
+ /* ---- Hierarchy ---- */
201
+
202
+ /*
203
+ * Indent gutter for a hierarchical row. One step per level plus one for
204
+ * the chevron itself, so a leaf and a group at the same level align.
205
+ */
206
+ .row-expander {
207
+ --row-indent-step: var(
208
+ --menu-navigation-components-table-item-icon-size
209
+ );
210
+ /* Depth of this row; the row's inline style overrides it. */
211
+ --row-level: 0;
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: flex-end;
215
+ flex-shrink: 0;
216
+ align-self: stretch;
217
+ width: calc((var(--row-level) + 1) * var(--row-indent-step));
218
+ }
219
+
220
+ .row-expander .chevron {
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ /* Pointer target floor, and the gutter is only one step wide. */
225
+ min-width: var(--row-indent-step);
226
+ min-height: var(--row-indent-step);
227
+ align-self: stretch;
228
+ color: var(--on-flat-neutral-color);
229
+ transition: transform 100ms ease-in-out;
230
+ }
231
+
232
+ .row-expander .chevron.expanded {
233
+ transform: rotate(90deg);
234
+ }
235
+
236
+ @media (prefers-reduced-motion: reduce) {
237
+ .row-expander .chevron {
238
+ transition: none;
239
+ }
240
+ }
241
+
200
242
  &.large-icon .icon {
201
243
  min-width: var(--menu-navigation-components-table-item-icon-size-large);
202
244
  min-height: var(