@yuuvis/client-framework 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/breadcrumb/index.d.ts +2 -0
  2. package/breadcrumb/lib/breadcrumb/breadcrumb.component.d.ts +94 -0
  3. package/breadcrumb/lib/models/breadcrumb-item.model.d.ts +14 -0
  4. package/breadcrumb/lib/models/index.d.ts +1 -0
  5. package/fesm2022/yuuvis-client-framework-breadcrumb.mjs +117 -0
  6. package/fesm2022/yuuvis-client-framework-breadcrumb.mjs.map +1 -0
  7. package/fesm2022/yuuvis-client-framework-list.mjs +365 -121
  8. package/fesm2022/yuuvis-client-framework-list.mjs.map +1 -1
  9. package/fesm2022/yuuvis-client-framework-object-details.mjs +28 -26
  10. package/fesm2022/yuuvis-client-framework-object-details.mjs.map +1 -1
  11. package/fesm2022/yuuvis-client-framework-object-form.mjs.map +1 -1
  12. package/fesm2022/yuuvis-client-framework-object-versions.mjs +1 -1
  13. package/fesm2022/yuuvis-client-framework-object-versions.mjs.map +1 -1
  14. package/fesm2022/yuuvis-client-framework-query-list.mjs +462 -127
  15. package/fesm2022/yuuvis-client-framework-query-list.mjs.map +1 -1
  16. package/fesm2022/yuuvis-client-framework-tile-list.mjs +695 -178
  17. package/fesm2022/yuuvis-client-framework-tile-list.mjs.map +1 -1
  18. package/lib/assets/i18n/ar.json +2 -1
  19. package/lib/assets/i18n/de.json +2 -1
  20. package/lib/assets/i18n/en.json +2 -1
  21. package/list/lib/list.component.d.ts +256 -44
  22. package/object-details/lib/object-details-header/object-details-header.component.d.ts +5 -2
  23. package/object-details/lib/object-details-shell/object-details-shell.component.d.ts +5 -2
  24. package/object-details/lib/object-details.component.d.ts +3 -1
  25. package/package.json +8 -4
  26. package/query-list/lib/query-list.component.d.ts +381 -86
  27. package/tile-list/lib/tile-list/tile-list.component.d.ts +527 -72
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, ElementRef, input, linkedSignal, HostListener, Input, Directive, contentChildren, effect, untracked, output, HostAttributeToken, ViewEncapsulation, Component, contentChild, NgModule } from '@angular/core';
2
+ import { inject, ElementRef, input, linkedSignal, HostListener, Input, Directive, contentChildren, output, HostAttributeToken, untracked, effect, ChangeDetectionStrategy, ViewEncapsulation, Component, contentChild, NgModule } from '@angular/core';
3
3
  import { Utils } from '@yuuvis/client-core';
4
4
  import { ActiveDescendantKeyManager, A11yModule } from '@angular/cdk/a11y';
5
5
  import { Directionality } from '@angular/cdk/bidi';
@@ -106,174 +106,319 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
106
106
  }] } });
107
107
 
108
108
  /**
109
- * Component rendering a simple list of items. It supports keyboard
110
- * navigation and takes care of accessibility. To create a list just wrap
111
- * `yuvListItem` elements into this component:
109
+ * Accessible list component with keyboard navigation, single- and multi-selection,
110
+ * and flexible delegation of click- and selection-handling to the parent.
112
111
  *
112
+ * The component renders as an ARIA `listbox` and delegates keyboard focus tracking
113
+ * to Angular CDK's `ActiveDescendantKeyManager`. Content is projected via
114
+ * `[yuvListItem]`-attributed children (see `ListItemDirective`).
115
+ *
116
+ * **Key Features:**
117
+ * - Single and multi-selection (with Shift / Ctrl modifier support)
118
+ * - Full keyboard navigation (Arrow keys, Space, Enter, Escape)
119
+ * - Horizontal and vertical layout via the `horizontal` host attribute
120
+ * - Auto-selection on initialization via the `autoSelect` input
121
+ * - Selection guarding via `preventChangeUntil` callback
122
+ * - Optional delegation of click and selection handling to the parent component
123
+ * - Accessible: `role="listbox"`, active-descendant focus management, `aria-selected` on items
124
+ *
125
+ * **Basic usage:**
113
126
  * ```html
114
- * <yuv-list (itemSelect)="itemSelected($event)">
127
+ * <yuv-list (itemSelect)="onItemSelect($event)">
115
128
  * <div yuvListItem>Entry #1</div>
116
129
  * <div yuvListItem>Entry #2</div>
117
130
  * </yuv-list>
118
131
  * ```
132
+ *
133
+ * **Multi-selection with Shift/Ctrl:**
134
+ * ```html
135
+ * <yuv-list [multiselect]="true" (itemSelect)="onSelect($event)">
136
+ * @for (item of items; track item.id) {
137
+ * <div yuvListItem>{{ item.label }}</div>
138
+ * }
139
+ * </yuv-list>
140
+ * ```
141
+ *
142
+ * **Host Attributes (set declaratively in the template):**
143
+ * - `selectOnEnter` — treat the Enter key as a selection trigger (in addition to Space)
144
+ * - `horizontal` — switch key navigation to left/right arrows instead of up/down
145
+ *
146
+ * @example
147
+ * ```html
148
+ * <!-- Horizontal list, Enter key selects -->
149
+ * <yuv-list horizontal selectOnEnter (itemSelect)="onSelect($event)">
150
+ * <button yuvListItem>A</button>
151
+ * <button yuvListItem>B</button>
152
+ * </yuv-list>
153
+ * ```
119
154
  */
120
155
  class ListComponent {
156
+ //#region Dependencies
157
+ #dir;
158
+ #elRef;
159
+ #keyManager;
160
+ #selection;
161
+ #lastSelection;
162
+ //#endregion
163
+ //#region Lifecycle Hooks
121
164
  constructor() {
165
+ //#region Dependencies
122
166
  this.#dir = inject(Directionality);
123
167
  this.#elRef = inject(ElementRef);
168
+ //#endregion
169
+ //#region Angular Stuff
170
+ /**
171
+ * All `[yuvListItem]` children projected into this list.
172
+ *
173
+ * Queried reactively via `contentChildren` — every time the projected content
174
+ * changes (items added, removed, or reordered), the `#itemsEffect` re-runs,
175
+ * rebuilds the key manager, and re-attaches click handlers.
176
+ */
124
177
  this.items = contentChildren(ListItemDirective);
125
- this.#itemsEffect = effect(() => {
126
- const items = this.items();
127
- if (this.#keyManager)
128
- this.#keyManager.destroy();
129
- untracked(() => {
130
- this.#keyManager = this.horizontal
131
- ? new ActiveDescendantKeyManager(items).withWrap().withHorizontalOrientation(this.#dir.value)
132
- : new ActiveDescendantKeyManager(items).withWrap();
133
- this.#keyManager.change.subscribe((activeIndex) => {
134
- if (activeIndex !== null) {
135
- this.#updateActiveItemState();
136
- this.itemFocus.emit(activeIndex);
137
- }
138
- });
139
- if (!this.selfHandleClick()) {
140
- items.forEach((item, index) => {
141
- item.onClick = (evt) => {
142
- this.select(index, evt.shiftKey, evt.ctrlKey);
143
- };
144
- item.activeInput.set(false);
145
- });
146
- }
147
- if (this.#lastSelection !== undefined && this.#lastSelection <= items.length) {
148
- this.select(this.#lastSelection);
149
- }
150
- this.#preselectItem();
151
- });
152
- });
153
- this.#selection = [];
154
178
  /**
155
- * Function that returns `true` if selection changes should be prevented.
156
- * This can be used to temporarily block selection changes, e.g. while
157
- * there is a pending change inside another component that refers to the
158
- * current selection.
179
+ * Guard function that temporarily blocks all selection changes.
180
+ *
181
+ * Provide a factory that returns a predicate; as long as that predicate
182
+ * returns `true`, any attempt to change the selection (via click, keyboard,
183
+ * or programmatic API) is silently ignored. Useful when the parent has
184
+ * unsaved changes tied to the current selection and needs to prevent the user
185
+ * from accidentally navigating away.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * // Block selection while a save is in progress
190
+ * preventChangeUntil = () => () => this.isSaving();
191
+ * ```
192
+ *
193
+ * @default () => false (never prevents)
159
194
  */
160
195
  this.preventChangeUntil = input(() => false);
161
196
  /**
162
- * If `true`, multiple items can be selected at once.
197
+ * Enables multi-selection mode.
198
+ *
199
+ * When `true`, the user can hold **Shift** to range-select items between the last
200
+ * selected item and the clicked one, or hold **Ctrl** to toggle individual items
201
+ * in and out of the selection. The programmatic `multiSelect()` method is also
202
+ * only effective when this input is `true`.
203
+ *
163
204
  * @default false
164
205
  */
165
206
  this.multiselect = input(false);
166
207
  /**
167
- * If `true`, the component will handle selection itself. This means that
168
- * the parent component will be responsible for styling the selected and
169
- * focused items. If `false`, the component will take care of visualizing
170
- * the selection and focus states.
208
+ * Delegates visual selection and focus state rendering to the parent component.
209
+ *
210
+ * When `false` (default), `yuv-list` applies `.selected` and `.active` CSS classes
211
+ * to items via `ListItemDirective` signals, so the list stylesheet controls the look.
212
+ * When `true`, those signals are still updated but the host gets the
213
+ * `self-handle-selection` CSS class, which the parent can use to opt into its own
214
+ * visual treatment — useful when item templates have custom selected/focused styling.
215
+ *
171
216
  * @default false
172
217
  */
173
218
  this.selfHandleSelection = input(false);
174
219
  /**
175
- * By default the list handles click events on its items to select them.
176
- * If this input is set to `true`, the parent component has to handle
177
- * click events itself and call the `select()` method accordingly.
220
+ * Disables the built-in click-to-select behavior, letting the parent component
221
+ * decide when `select()` is called.
222
+ *
223
+ * By default the list attaches an `onClick` handler to every `ListItemDirective`
224
+ * that calls `select()` with the correct Shift/Ctrl modifier flags. Set this
225
+ * input to `true` to suppress that wiring — the parent must then call `select()`
226
+ * imperatively in response to whatever interaction it chooses (e.g. a single-click
227
+ * that is distinguished from a double-click by `ClickDoubleDirective`).
178
228
  *
179
- * If you for example use the `ClickDoubleDirective` on the list items,
180
- * you have to set this input to `true` to prevent the list from
181
- * selecting items on single click.
182
229
  * @default false
183
230
  */
184
231
  this.selfHandleClick = input(false);
185
232
  /**
186
- * If `true`, the list will select an item automatically on initialization.
187
- * First, list will search for an item item that has the "selected"-attribute
188
- * and is not disabled. If no such item exists, the first item will be selected.
233
+ * Automatically selects an item when the list is first rendered.
234
+ *
235
+ * The selection logic runs once per content-children change (see `#itemsEffect`)
236
+ * and follows this priority order:
237
+ * 1. The first non-disabled item that already carries the `selected` attribute.
238
+ * 2. If no such item exists and `autoSelect` is `true`, the item at index 0.
239
+ *
240
+ * Accepts any truthy string value in addition to a real boolean because the
241
+ * input is also consumable as a plain HTML attribute (`<yuv-list autoSelect>`).
242
+ *
189
243
  * @default false
190
244
  */
191
- this.autoSelect = input(false, { transform: (value) => coerceBooleanProperty(value) });
245
+ this.autoSelect = input(false, {
246
+ transform: (value) => coerceBooleanProperty(value)
247
+ });
192
248
  /**
193
- * Emits the selected items indices.
249
+ * Emits the current selection as an array of zero-based item indices whenever
250
+ * the selection changes — via click, keyboard, or programmatic API calls.
251
+ *
252
+ * An empty array signals that the selection has been cleared.
253
+ *
254
+ * @example
255
+ * ```ts
256
+ * onItemSelect(indices: number[]): void {
257
+ * this.selectedItems = indices.map(i => this.items[i]);
258
+ * }
259
+ * ```
194
260
  */
195
261
  this.itemSelect = output();
196
262
  /**
197
- * Emits the index of the item that has focus.
198
- * @type {output<number>}
263
+ * Emits the zero-based index of the item that received keyboard focus
264
+ * (i.e. the active descendant managed by `ActiveDescendantKeyManager`).
265
+ *
266
+ * Note that focus and selection are independent: navigating with arrow keys
267
+ * moves focus without changing the selection until Space (or Enter when
268
+ * `selectOnEnter` is set) is pressed.
199
269
  */
200
270
  this.itemFocus = output();
201
- this.selectOnEnter = (inject(new HostAttributeToken('selectOnEnter'), { optional: true }) || 'false') === 'true';
202
- this.horizontal = (inject(new HostAttributeToken('horizontal'), { optional: true }) || 'false') === 'true';
203
271
  /**
204
- * If `true`, the list will not allow selection of items.
205
- * This is useful for lists that are used for display purposes only.
272
+ * Puts the list into a display-only mode where no item can be selected.
273
+ *
274
+ * When `true`, all selection paths are blocked: clicks, keyboard shortcuts
275
+ * (`Space`, `Enter`, `Escape`), and programmatic calls to `select()`,
276
+ * `multiSelect()`, and `clear()` are all no-ops. The list still renders
277
+ * normally and keyboard navigation still moves the focus indicator.
278
+ *
279
+ * @default false
206
280
  */
207
281
  this.disableSelection = input(false);
282
+ //#endregion
283
+ //#region Properties
284
+ /**
285
+ * When `true`, pressing **Enter** triggers item selection in addition to **Space**.
286
+ *
287
+ * Resolved once at construction time from the static `selectOnEnter` host attribute.
288
+ * Useful in contexts where Enter has a conventional "confirm" meaning (e.g. search
289
+ * result lists, command palettes).
290
+ *
291
+ * Set declaratively in the template: `<yuv-list selectOnEnter>`.
292
+ */
293
+ this.selectOnEnter = (inject(new HostAttributeToken('selectOnEnter'), { optional: true }) || 'false') === 'true';
294
+ /**
295
+ * When `true`, switches the `ActiveDescendantKeyManager` to horizontal mode so that
296
+ * the **Left** / **Right** arrow keys navigate between items instead of **Up** / **Down**.
297
+ *
298
+ * Resolved once at construction time from the static `horizontal` host attribute.
299
+ * The text direction of the document is respected automatically (RTL-aware via CDK
300
+ * `Directionality`).
301
+ *
302
+ * Set declaratively in the template: `<yuv-list horizontal>`.
303
+ */
304
+ this.horizontal = (inject(new HostAttributeToken('horizontal'), { optional: true }) || 'false') === 'true';
305
+ this.#selection = [];
306
+ //#endregion
307
+ //#region Effects
308
+ this.#itemsEffect = () => {
309
+ const items = this.items();
310
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
311
+ if (this.#keyManager)
312
+ this.#keyManager.destroy();
313
+ untracked(() => {
314
+ this.#keyManager = this.horizontal
315
+ ? new ActiveDescendantKeyManager(items).withWrap().withHorizontalOrientation(this.#dir.value)
316
+ : new ActiveDescendantKeyManager(items).withWrap();
317
+ this.#keyManager.change.subscribe((activeIndex) => {
318
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
319
+ if (activeIndex !== null) {
320
+ this.#updateActiveItemState();
321
+ this.itemFocus.emit(activeIndex);
322
+ }
323
+ });
324
+ if (!this.selfHandleClick()) {
325
+ items.forEach((item, index) => {
326
+ item.onClick = (evt) => {
327
+ this.select(index, evt.shiftKey, evt.ctrlKey);
328
+ };
329
+ item.activeInput.set(false);
330
+ });
331
+ }
332
+ if (this.#lastSelection !== undefined && this.#lastSelection <= items.length) {
333
+ this.select(this.#lastSelection);
334
+ }
335
+ this.#preselectItem();
336
+ });
337
+ };
338
+ effect(this.#itemsEffect);
208
339
  }
209
- #dir;
210
- #elRef;
211
- onKeydown(event) {
212
- if (this.disableSelection())
213
- return;
214
- if (event.code === 'Escape') {
215
- this.clear();
216
- }
217
- if (event.code === 'Space' || (this.selectOnEnter && event.code === 'Enter')) {
218
- // prevent default behavior of space in scroll environments
219
- if (this.#preventEmit())
220
- return;
221
- event.preventDefault();
222
- const aii = this.#keyManager.activeItemIndex !== null ? this.#keyManager.activeItemIndex : -1;
223
- if (aii >= 0) {
224
- this.#select(aii);
225
- this.#emitSelection();
226
- }
227
- }
228
- else
229
- this.#keyManager?.onKeydown(event);
230
- }
231
- onFocus() {
232
- // set timeout to check if the focus is coming from an item being clicked
233
- setTimeout(() => {
234
- // if there already is an active item, we do not want to set the focus again
235
- if (this.#keyManager.activeItemIndex === -1 && this.items().length > 0) {
236
- const indexToFocus = this.#selection.length > 0 ? this.#selection[0] : 0;
237
- this.#keyManager.setActiveItem(indexToFocus);
238
- this.itemFocus.emit(indexToFocus);
239
- this.#updateActiveItemState();
240
- }
241
- }, 300);
340
+ ngOnDestroy() {
341
+ this.#keyManager.destroy();
242
342
  }
243
- #itemsEffect;
244
- #keyManager;
245
- #selection;
246
- #lastSelection;
343
+ //#endregion
344
+ //#region Public
247
345
  /**
248
- * Sets the active (focused) item by index. This updates the visual focus
249
- * indicator and scrolls the item into view, without changing the selection.
346
+ * Moves keyboard focus to the item at the given index and selects it.
347
+ *
348
+ * Combines three operations in one call: updating the CDK key manager's active
349
+ * descendant (which drives the ARIA active-descendant attribute and the visual focus ring),
350
+ * selecting the item, and transferring DOM focus to the list host element so that
351
+ * subsequent keyboard events are processed correctly.
352
+ *
353
+ * Safe to call before the key manager is initialized — the guard at the top of
354
+ * the method prevents errors during early lifecycle phases.
355
+ *
356
+ * @param index Zero-based index of the item to activate.
250
357
  */
251
358
  setActiveItem(index) {
359
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
252
360
  if (this.#keyManager) {
253
361
  this.#keyManager.setActiveItem(index);
254
362
  this.#select(index);
255
363
  this.focus();
256
364
  }
257
365
  }
366
+ /**
367
+ * Transfers DOM focus to the list host element.
368
+ *
369
+ * Calling this ensures that subsequent keyboard events (arrow keys, Space, etc.)
370
+ * are routed to the list's `(keydown)` handler. Called internally by
371
+ * `setActiveItem()`, but also useful from the parent when programmatic focus
372
+ * management is needed (e.g. after closing a dialog that was triggered from a
373
+ * list item).
374
+ */
258
375
  focus() {
259
376
  this.#elRef.nativeElement.focus();
260
377
  }
378
+ /**
379
+ * Smoothly scrolls the list container back to the top.
380
+ *
381
+ * Useful after programmatically refreshing the list contents when the user may
382
+ * have scrolled far down and the new result set should be shown from the beginning.
383
+ */
261
384
  scrollToTop() {
262
385
  this.#elRef.nativeElement.scrollTo({ top: 0, behavior: 'smooth' });
263
386
  }
264
387
  /**
265
- * Shift the current selection by the given offset. Used for dynamically
266
- * adding items to the list without losing the current selection.
267
- * @param offset Number of items to shift the selection by.
388
+ * Shifts all selected and focused item indices by the given offset.
389
+ *
390
+ * Use this when items are prepended or inserted at the beginning of the list so
391
+ * that the existing selection and keyboard-focus position stay attached to the
392
+ * same logical items after the index space shifts. A positive offset moves
393
+ * indices forward (items were inserted before the selection); a negative offset
394
+ * moves them backward (items were removed before the selection).
395
+ *
396
+ * Does **not** emit `itemSelect` — the selection indices change structurally,
397
+ * not because the user chose different items.
398
+ *
399
+ * @param offset Number of positions to shift every selected index by.
268
400
  */
269
401
  shiftSelectionBy(offset) {
270
- if (this.#keyManager?.activeItemIndex)
402
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
403
+ if (this.#keyManager?.activeItemIndex !== null && this.#keyManager?.activeItemIndex !== undefined) {
271
404
  this.#keyManager.setActiveItem(this.#keyManager.activeItemIndex + offset);
405
+ }
272
406
  this.#selection = this.#selection.map((i) => i + offset);
407
+ if (this.#lastSelection !== undefined)
408
+ this.#lastSelection += offset;
273
409
  this.items().forEach((item, i) => item.selectedInput.set(this.#selection.includes(i)));
274
410
  }
275
411
  /**
276
- * Select multiple items by their indices.
412
+ * Programmatically replaces the entire selection with the given indices.
413
+ *
414
+ * Only effective when `multiselect` is `true` and `disableSelection` is `false`.
415
+ * Out-of-range indices are silently discarded. The resulting selection is sorted
416
+ * ascending before being applied and emitted.
417
+ *
418
+ * Use this when the parent needs to restore a previously saved multi-selection
419
+ * state, e.g. after navigation or on component re-initialization.
420
+ *
421
+ * @param index Array of zero-based item indices to select.
277
422
  */
278
423
  multiSelect(index) {
279
424
  if (this.#preventEmit())
@@ -286,10 +431,25 @@ class ListComponent {
286
431
  this.#emitSelection();
287
432
  }
288
433
  /**
289
- * Selects a single item by its index.
290
- * @param index Index of the item to select.
291
- * @param shiftKey If `true`, selection will be extended from the last selected item to the given index.
292
- * @param ctrlKey If `true`, the item at the given index will be toggled in the selection.
434
+ * Selects the item at the given index, optionally extending or toggling
435
+ * the existing selection using modifier-key semantics.
436
+ *
437
+ * - **No modifiers** (default): replaces the current selection with the single item.
438
+ * - **`shiftKey = true`** (`multiselect` only): range-selects all items between the
439
+ * last selected index and `index` (inclusive of neither endpoint, matching typical
440
+ * OS list behavior).
441
+ * - **`ctrlKey = true`** (`multiselect` only): toggles `index` in the selection
442
+ * without affecting the rest.
443
+ *
444
+ * Index is clamped to `[0, items.length - 1]`. Negative values and calls while
445
+ * `disableSelection` is `true` are ignored. The `preventChangeUntil` guard is
446
+ * also respected.
447
+ *
448
+ * Emits `itemSelect` after updating the visual state.
449
+ *
450
+ * @param index Zero-based index of the item to select.
451
+ * @param shiftKey Extend the selection from the last selected item to `index`.
452
+ * @param ctrlKey Toggle `index` in or out of the current selection.
293
453
  */
294
454
  select(index, shiftKey = false, ctrlKey = false) {
295
455
  if (this.#preventEmit())
@@ -299,17 +459,23 @@ class ListComponent {
299
459
  if (index >= this.items().length)
300
460
  index = this.items().length - 1;
301
461
  this.#select(index, shiftKey, ctrlKey);
462
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
302
463
  if (this.#keyManager)
303
464
  this.#keyManager.setActiveItem(index);
304
465
  this.#emitSelection();
305
466
  }
306
- #preventEmit() {
307
- const preventUntilIsTrue = this.preventChangeUntil();
308
- return preventUntilIsTrue();
309
- }
310
467
  /**
311
- * Clear the current selection.
312
- * @param silent If `true`, the `itemSelect` event will not be emitted.
468
+ * Clears the current selection and resets the active keyboard-focus index.
469
+ *
470
+ * If the selection is already empty, the method is a no-op (no event emitted,
471
+ * no state update). The `preventChangeUntil` guard is respected — if the guard
472
+ * returns `true`, the clear is blocked entirely.
473
+ *
474
+ * Also triggered internally when the user presses **Escape**.
475
+ *
476
+ * @param silent When `true`, skips emitting `itemSelect` after clearing. Use this
477
+ * when the parent needs to reset state programmatically without
478
+ * triggering downstream reactions.
313
479
  */
314
480
  clear(silent = false) {
315
481
  if (this.#preventEmit())
@@ -321,6 +487,84 @@ class ListComponent {
321
487
  this.#emitSelection();
322
488
  }
323
489
  }
490
+ //#endregion
491
+ //#region UI Methods
492
+ /**
493
+ * Host `keydown` handler — routes keyboard events to selection or navigation logic.
494
+ *
495
+ * Handled keys:
496
+ * - **Escape**: clears the selection.
497
+ * - **Space** (always) / **Enter** (when `selectOnEnter` is set): selects the
498
+ * currently focused item. `preventDefault()` is called to stop the browser
499
+ * from scrolling the container when Space is pressed.
500
+ * - **All other keys**: forwarded to `ActiveDescendantKeyManager` so arrow keys,
501
+ * Home, End, etc. move the focus indicator without changing the selection.
502
+ *
503
+ * When `disableSelection` is `true`, only navigation keys are forwarded to the
504
+ * key manager — selection keys (Space, Enter, Escape) are suppressed.
505
+ *
506
+ * Bound via the `host` metadata as `(keydown)`.
507
+ *
508
+ * @param event The keyboard event originating from the list host element.
509
+ */
510
+ onKeydown(event) {
511
+ if (this.disableSelection()) {
512
+ this.#keyManager?.onKeydown(event);
513
+ return;
514
+ }
515
+ if (event.code === 'Escape') {
516
+ this.clear();
517
+ }
518
+ if (event.code === 'Space' || (this.selectOnEnter && event.code === 'Enter')) {
519
+ // prevent default behavior of space in scroll environments
520
+ if (this.#preventEmit())
521
+ return;
522
+ event.preventDefault();
523
+ const aii = this.#keyManager.activeItemIndex !== null ? this.#keyManager.activeItemIndex : -1;
524
+ if (aii >= 0) {
525
+ this.#select(aii);
526
+ this.#emitSelection();
527
+ }
528
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
529
+ }
530
+ else
531
+ this.#keyManager?.onKeydown(event);
532
+ }
533
+ /**
534
+ * Host `focus` handler — restores a sensible focus position when the list
535
+ * host element receives DOM focus without an item already being active.
536
+ *
537
+ * The logic runs inside a 300 ms timeout so that focus events triggered by
538
+ * an item being clicked (which also bubbles a focus event up to the host)
539
+ * have already resolved their own active-item state before this handler acts.
540
+ * If an active item already exists when the timeout fires, nothing happens.
541
+ *
542
+ * Focus target priority:
543
+ * 1. The first index in the current selection (so the user lands back on the
544
+ * previously selected item when tabbing into the list).
545
+ * 2. Index 0 as the fallback when there is no selection.
546
+ *
547
+ * Bound via the `host` metadata as `(focus)`.
548
+ */
549
+ onFocus() {
550
+ // set timeout to check if the focus is coming from an item being clicked
551
+ setTimeout(() => {
552
+ // if there already is an active item, we do not want to set the focus again
553
+ if (this.#keyManager.activeItemIndex === -1 && this.items().length > 0) {
554
+ const indexToFocus = this.#selection.length > 0 ? this.#selection[0] : 0;
555
+ this.#keyManager.setActiveItem(indexToFocus);
556
+ this.itemFocus.emit(indexToFocus);
557
+ this.#updateActiveItemState();
558
+ }
559
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
560
+ }, 300);
561
+ }
562
+ //#endregion
563
+ //#region Utilities
564
+ #preventEmit() {
565
+ const preventUntilIsTrue = this.preventChangeUntil();
566
+ return preventUntilIsTrue();
567
+ }
324
568
  #select(index, shiftKey = false, ctrlKey = false) {
325
569
  if (index === -1)
326
570
  this.#selection = [];
@@ -378,22 +622,22 @@ class ListComponent {
378
622
  this.select(itemIndexToSelect);
379
623
  }
380
624
  }
381
- ngOnDestroy() {
382
- this.#keyManager?.destroy();
383
- }
625
+ //#endregion
626
+ //#region Effects
627
+ #itemsEffect;
384
628
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: ListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
385
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.20", type: ListComponent, isStandalone: true, selector: "yuv-list", inputs: { preventChangeUntil: { classPropertyName: "preventChangeUntil", publicName: "preventChangeUntil", isSignal: true, isRequired: false, transformFunction: null }, multiselect: { classPropertyName: "multiselect", publicName: "multiselect", isSignal: true, isRequired: false, transformFunction: null }, selfHandleSelection: { classPropertyName: "selfHandleSelection", publicName: "selfHandleSelection", isSignal: true, isRequired: false, transformFunction: null }, selfHandleClick: { classPropertyName: "selfHandleClick", publicName: "selfHandleClick", isSignal: true, isRequired: false, transformFunction: null }, autoSelect: { classPropertyName: "autoSelect", publicName: "autoSelect", isSignal: true, isRequired: false, transformFunction: null }, disableSelection: { classPropertyName: "disableSelection", publicName: "disableSelection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemSelect: "itemSelect", itemFocus: "itemFocus" }, host: { attributes: { "role": "listbox", "tabindex": "0" }, listeners: { "focus": "onFocus()", "keydown": "onKeydown($event)" }, properties: { "class.self-handle-selection": "selfHandleSelection()" } }, queries: [{ propertyName: "items", predicate: ListItemDirective, isSignal: true }], ngImport: i0, template: '<ng-content></ng-content>', isInline: true, styles: ["yuv-list{display:block;max-height:100%;overflow-y:auto;outline:none}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)){position:relative;cursor:pointer;transition:background-color .2s ease,color .2s ease,border-color .2s ease}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)):hover,yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-current=true]{background-color:var(--ymt-focus-background);color:var(--ymt-on-focus-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-selected=true]{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background);font-weight:500}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile):hover:not([aria-selected=true]) .tile{--_tile-background: var(--ymt-hover-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-selected=true] .tile{--_tile-background: var(--ymt-selection-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-current=true] .tile{--_tile-background: var(--ymt-focus-background)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: A11yModule }], encapsulation: i0.ViewEncapsulation.None }); }
629
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.20", type: ListComponent, isStandalone: true, selector: "yuv-list", inputs: { preventChangeUntil: { classPropertyName: "preventChangeUntil", publicName: "preventChangeUntil", isSignal: true, isRequired: false, transformFunction: null }, multiselect: { classPropertyName: "multiselect", publicName: "multiselect", isSignal: true, isRequired: false, transformFunction: null }, selfHandleSelection: { classPropertyName: "selfHandleSelection", publicName: "selfHandleSelection", isSignal: true, isRequired: false, transformFunction: null }, selfHandleClick: { classPropertyName: "selfHandleClick", publicName: "selfHandleClick", isSignal: true, isRequired: false, transformFunction: null }, autoSelect: { classPropertyName: "autoSelect", publicName: "autoSelect", isSignal: true, isRequired: false, transformFunction: null }, disableSelection: { classPropertyName: "disableSelection", publicName: "disableSelection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemSelect: "itemSelect", itemFocus: "itemFocus" }, host: { attributes: { "role": "listbox", "tabindex": "0" }, listeners: { "focus": "onFocus()", "keydown": "onKeydown($event)" }, properties: { "class.self-handle-selection": "selfHandleSelection()" } }, queries: [{ propertyName: "items", predicate: ListItemDirective, isSignal: true }], ngImport: i0, template: '<ng-content />', isInline: true, styles: ["yuv-list{display:block;max-height:100%;overflow-y:auto;outline:none}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)){position:relative;cursor:pointer;transition:background-color .2s ease,color .2s ease,border-color .2s ease}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)):hover,yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-current=true]{background-color:var(--ymt-focus-background);color:var(--ymt-on-focus-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-selected=true]{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background);font-weight:500}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile):hover:not([aria-selected=true]) .tile{--_tile-background: var(--ymt-hover-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-selected=true] .tile{--_tile-background: var(--ymt-selection-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-current=true] .tile{--_tile-background: var(--ymt-focus-background)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: A11yModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
386
630
  }
387
631
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: ListComponent, decorators: [{
388
632
  type: Component,
389
- args: [{ selector: 'yuv-list', standalone: true, imports: [CommonModule, A11yModule], template: '<ng-content></ng-content>', encapsulation: ViewEncapsulation.None, host: {
633
+ args: [{ selector: 'yuv-list', standalone: true, imports: [CommonModule, A11yModule], template: '<ng-content />', encapsulation: ViewEncapsulation.None, host: {
390
634
  role: 'listbox',
391
635
  tabindex: '0',
392
636
  '[class.self-handle-selection]': 'selfHandleSelection()',
393
637
  '(focus)': 'onFocus()',
394
638
  '(keydown)': 'onKeydown($event)'
395
- }, styles: ["yuv-list{display:block;max-height:100%;overflow-y:auto;outline:none}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)){position:relative;cursor:pointer;transition:background-color .2s ease,color .2s ease,border-color .2s ease}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)):hover,yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-current=true]{background-color:var(--ymt-focus-background);color:var(--ymt-on-focus-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-selected=true]{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background);font-weight:500}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile):hover:not([aria-selected=true]) .tile{--_tile-background: var(--ymt-hover-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-selected=true] .tile{--_tile-background: var(--ymt-selection-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-current=true] .tile{--_tile-background: var(--ymt-focus-background)}\n"] }]
396
- }] });
639
+ }, changeDetection: ChangeDetectionStrategy.OnPush, styles: ["yuv-list{display:block;max-height:100%;overflow-y:auto;outline:none}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)){position:relative;cursor:pointer;transition:background-color .2s ease,color .2s ease,border-color .2s ease}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile)):hover,yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-current=true]{background-color:var(--ymt-focus-background);color:var(--ymt-on-focus-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:not(:has(yuv-list-tile))[aria-selected=true]{background-color:var(--ymt-selection-background);color:var(--ymt-on-selection-background);font-weight:500}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile):hover:not([aria-selected=true]) .tile{--_tile-background: var(--ymt-hover-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-selected=true] .tile{--_tile-background: var(--ymt-selection-background)}yuv-list:not(.self-handle-selection) [yuvListItem]:has(yuv-list-tile)[aria-current=true] .tile{--_tile-background: var(--ymt-focus-background)}\n"] }]
640
+ }], ctorParameters: () => [] });
397
641
 
398
642
  class ListTileComponent {
399
643
  constructor() {