@skra/ui 0.2.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 (160) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +39 -0
  4. package/consumer-guide/README.md +84 -0
  5. package/consumer-guide/accessible-names.md +80 -0
  6. package/consumer-guide/forms-and-binding.md +149 -0
  7. package/consumer-guide/layout.md +86 -0
  8. package/consumer-guide/overlays.md +100 -0
  9. package/consumer-guide/setup-and-gotchas.md +103 -0
  10. package/consumer-guide/theming-and-encapsulation.md +115 -0
  11. package/consumer-guide/wrapping-patterns.md +90 -0
  12. package/fesm2022/skra-ui-accordion.mjs +333 -0
  13. package/fesm2022/skra-ui-accordion.mjs.map +1 -0
  14. package/fesm2022/skra-ui-alert.mjs +110 -0
  15. package/fesm2022/skra-ui-alert.mjs.map +1 -0
  16. package/fesm2022/skra-ui-avatar.mjs +83 -0
  17. package/fesm2022/skra-ui-avatar.mjs.map +1 -0
  18. package/fesm2022/skra-ui-badge.mjs +91 -0
  19. package/fesm2022/skra-ui-badge.mjs.map +1 -0
  20. package/fesm2022/skra-ui-button.mjs +67 -0
  21. package/fesm2022/skra-ui-button.mjs.map +1 -0
  22. package/fesm2022/skra-ui-checkbox.mjs +98 -0
  23. package/fesm2022/skra-ui-checkbox.mjs.map +1 -0
  24. package/fesm2022/skra-ui-chip.mjs +82 -0
  25. package/fesm2022/skra-ui-chip.mjs.map +1 -0
  26. package/fesm2022/skra-ui-combobox.mjs +335 -0
  27. package/fesm2022/skra-ui-combobox.mjs.map +1 -0
  28. package/fesm2022/skra-ui-core.mjs +529 -0
  29. package/fesm2022/skra-ui-core.mjs.map +1 -0
  30. package/fesm2022/skra-ui-counter.mjs +53 -0
  31. package/fesm2022/skra-ui-counter.mjs.map +1 -0
  32. package/fesm2022/skra-ui-datepicker.mjs +867 -0
  33. package/fesm2022/skra-ui-datepicker.mjs.map +1 -0
  34. package/fesm2022/skra-ui-dialog.mjs +133 -0
  35. package/fesm2022/skra-ui-dialog.mjs.map +1 -0
  36. package/fesm2022/skra-ui-field.mjs +293 -0
  37. package/fesm2022/skra-ui-field.mjs.map +1 -0
  38. package/fesm2022/skra-ui-icon.mjs +54 -0
  39. package/fesm2022/skra-ui-icon.mjs.map +1 -0
  40. package/fesm2022/skra-ui-input.mjs +42 -0
  41. package/fesm2022/skra-ui-input.mjs.map +1 -0
  42. package/fesm2022/skra-ui-kbd.mjs +44 -0
  43. package/fesm2022/skra-ui-kbd.mjs.map +1 -0
  44. package/fesm2022/skra-ui-layout.mjs +119 -0
  45. package/fesm2022/skra-ui-layout.mjs.map +1 -0
  46. package/fesm2022/skra-ui-link.mjs +29 -0
  47. package/fesm2022/skra-ui-link.mjs.map +1 -0
  48. package/fesm2022/skra-ui-loading-indicator.mjs +24 -0
  49. package/fesm2022/skra-ui-loading-indicator.mjs.map +1 -0
  50. package/fesm2022/skra-ui-menu.mjs +334 -0
  51. package/fesm2022/skra-ui-menu.mjs.map +1 -0
  52. package/fesm2022/skra-ui-multi-select.mjs +179 -0
  53. package/fesm2022/skra-ui-multi-select.mjs.map +1 -0
  54. package/fesm2022/skra-ui-native-select.mjs +123 -0
  55. package/fesm2022/skra-ui-native-select.mjs.map +1 -0
  56. package/fesm2022/skra-ui-popover.mjs +158 -0
  57. package/fesm2022/skra-ui-popover.mjs.map +1 -0
  58. package/fesm2022/skra-ui-progress.mjs +57 -0
  59. package/fesm2022/skra-ui-progress.mjs.map +1 -0
  60. package/fesm2022/skra-ui-radio.mjs +92 -0
  61. package/fesm2022/skra-ui-radio.mjs.map +1 -0
  62. package/fesm2022/skra-ui-rating-display.mjs +81 -0
  63. package/fesm2022/skra-ui-rating-display.mjs.map +1 -0
  64. package/fesm2022/skra-ui-select.mjs +151 -0
  65. package/fesm2022/skra-ui-select.mjs.map +1 -0
  66. package/fesm2022/skra-ui-skeleton.mjs +65 -0
  67. package/fesm2022/skra-ui-skeleton.mjs.map +1 -0
  68. package/fesm2022/skra-ui-star-rating.mjs +110 -0
  69. package/fesm2022/skra-ui-star-rating.mjs.map +1 -0
  70. package/fesm2022/skra-ui-status-dot.mjs +61 -0
  71. package/fesm2022/skra-ui-status-dot.mjs.map +1 -0
  72. package/fesm2022/skra-ui-switch.mjs +94 -0
  73. package/fesm2022/skra-ui-switch.mjs.map +1 -0
  74. package/fesm2022/skra-ui-tabs.mjs +356 -0
  75. package/fesm2022/skra-ui-tabs.mjs.map +1 -0
  76. package/fesm2022/skra-ui-textarea.mjs +81 -0
  77. package/fesm2022/skra-ui-textarea.mjs.map +1 -0
  78. package/fesm2022/skra-ui-toast.mjs +208 -0
  79. package/fesm2022/skra-ui-toast.mjs.map +1 -0
  80. package/fesm2022/skra-ui-tooltip.mjs +252 -0
  81. package/fesm2022/skra-ui-tooltip.mjs.map +1 -0
  82. package/fesm2022/skra-ui-typography.mjs +35 -0
  83. package/fesm2022/skra-ui-typography.mjs.map +1 -0
  84. package/fesm2022/skra-ui.mjs +4 -0
  85. package/fesm2022/skra-ui.mjs.map +1 -0
  86. package/package.json +175 -0
  87. package/types/skra-ui-accordion.d.ts +196 -0
  88. package/types/skra-ui-accordion.d.ts.map +1 -0
  89. package/types/skra-ui-alert.d.ts +81 -0
  90. package/types/skra-ui-alert.d.ts.map +1 -0
  91. package/types/skra-ui-avatar.d.ts +71 -0
  92. package/types/skra-ui-avatar.d.ts.map +1 -0
  93. package/types/skra-ui-badge.d.ts +73 -0
  94. package/types/skra-ui-badge.d.ts.map +1 -0
  95. package/types/skra-ui-button.d.ts +45 -0
  96. package/types/skra-ui-button.d.ts.map +1 -0
  97. package/types/skra-ui-checkbox.d.ts +52 -0
  98. package/types/skra-ui-checkbox.d.ts.map +1 -0
  99. package/types/skra-ui-chip.d.ts +60 -0
  100. package/types/skra-ui-chip.d.ts.map +1 -0
  101. package/types/skra-ui-combobox.d.ts +95 -0
  102. package/types/skra-ui-combobox.d.ts.map +1 -0
  103. package/types/skra-ui-core.d.ts +314 -0
  104. package/types/skra-ui-core.d.ts.map +1 -0
  105. package/types/skra-ui-counter.d.ts +33 -0
  106. package/types/skra-ui-counter.d.ts.map +1 -0
  107. package/types/skra-ui-datepicker.d.ts +164 -0
  108. package/types/skra-ui-datepicker.d.ts.map +1 -0
  109. package/types/skra-ui-dialog.d.ts +59 -0
  110. package/types/skra-ui-dialog.d.ts.map +1 -0
  111. package/types/skra-ui-field.d.ts +181 -0
  112. package/types/skra-ui-field.d.ts.map +1 -0
  113. package/types/skra-ui-icon.d.ts +38 -0
  114. package/types/skra-ui-icon.d.ts.map +1 -0
  115. package/types/skra-ui-input.d.ts +22 -0
  116. package/types/skra-ui-input.d.ts.map +1 -0
  117. package/types/skra-ui-kbd.d.ts +33 -0
  118. package/types/skra-ui-kbd.d.ts.map +1 -0
  119. package/types/skra-ui-layout.d.ts +86 -0
  120. package/types/skra-ui-layout.d.ts.map +1 -0
  121. package/types/skra-ui-link.d.ts +20 -0
  122. package/types/skra-ui-link.d.ts.map +1 -0
  123. package/types/skra-ui-loading-indicator.d.ts +9 -0
  124. package/types/skra-ui-loading-indicator.d.ts.map +1 -0
  125. package/types/skra-ui-menu.d.ts +116 -0
  126. package/types/skra-ui-menu.d.ts.map +1 -0
  127. package/types/skra-ui-multi-select.d.ts +47 -0
  128. package/types/skra-ui-multi-select.d.ts.map +1 -0
  129. package/types/skra-ui-native-select.d.ts +74 -0
  130. package/types/skra-ui-native-select.d.ts.map +1 -0
  131. package/types/skra-ui-popover.d.ts +57 -0
  132. package/types/skra-ui-popover.d.ts.map +1 -0
  133. package/types/skra-ui-progress.d.ts +41 -0
  134. package/types/skra-ui-progress.d.ts.map +1 -0
  135. package/types/skra-ui-radio.d.ts +54 -0
  136. package/types/skra-ui-radio.d.ts.map +1 -0
  137. package/types/skra-ui-rating-display.d.ts +56 -0
  138. package/types/skra-ui-rating-display.d.ts.map +1 -0
  139. package/types/skra-ui-select.d.ts +43 -0
  140. package/types/skra-ui-select.d.ts.map +1 -0
  141. package/types/skra-ui-skeleton.d.ts +42 -0
  142. package/types/skra-ui-skeleton.d.ts.map +1 -0
  143. package/types/skra-ui-star-rating.d.ts +71 -0
  144. package/types/skra-ui-star-rating.d.ts.map +1 -0
  145. package/types/skra-ui-status-dot.d.ts +62 -0
  146. package/types/skra-ui-status-dot.d.ts.map +1 -0
  147. package/types/skra-ui-switch.d.ts +54 -0
  148. package/types/skra-ui-switch.d.ts.map +1 -0
  149. package/types/skra-ui-tabs.d.ts +191 -0
  150. package/types/skra-ui-tabs.d.ts.map +1 -0
  151. package/types/skra-ui-textarea.d.ts +55 -0
  152. package/types/skra-ui-textarea.d.ts.map +1 -0
  153. package/types/skra-ui-toast.d.ts +140 -0
  154. package/types/skra-ui-toast.d.ts.map +1 -0
  155. package/types/skra-ui-tooltip.d.ts +90 -0
  156. package/types/skra-ui-tooltip.d.ts.map +1 -0
  157. package/types/skra-ui-typography.d.ts +17 -0
  158. package/types/skra-ui-typography.d.ts.map +1 -0
  159. package/types/skra-ui.d.ts +3 -0
  160. package/types/skra-ui.d.ts.map +1 -0
@@ -0,0 +1,334 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, ElementRef, ViewEncapsulation, ChangeDetectionStrategy, Component, input, contentChildren, Renderer2, effect, afterNextRender, DestroyRef } from '@angular/core';
3
+ import { _attachPositioner } from '@skra/ui/core';
4
+ import { FocusKeyManager } from '@angular/cdk/a11y';
5
+
6
+ /**
7
+ * SkraMenuKeyboard — the WAI-ARIA APG Menu keyboard model, confined to ONE file.
8
+ *
9
+ * This is the **only** module in Forge that imports `@angular/cdk`. It wraps CDK `FocusKeyManager`
10
+ * (roving REAL focus — the APG-canonical menu model, NOT `aria-activedescendant`) so the engine is an
11
+ * implementation detail: swapping it (e.g. to a hand-rolled controller) is a one-file change with zero
12
+ * impact on `SkraMenu` / `SkraMenuItem` — exactly as `SkraPositioner` wraps `@floating-ui/dom`
13
+ * (ADR 0044 §3 rule 5; recorded in §7, 2026-06-07).
14
+ *
15
+ * Owns: Down/Up roving (wrap), Home/End, type-ahead, and Tab→dismiss. Enter/Space (native button
16
+ * activation) and Escape + light-dismiss (`popover="auto"`) stay platform-owned — the controller never
17
+ * touches them.
18
+ */
19
+ class SkraMenuKeyboard {
20
+ manager;
21
+ items = [];
22
+ tabOutHandler;
23
+ /** Register the APG "Tab dismisses the menu" callback (fired by `FocusKeyManager.tabOut`). */
24
+ onTabOut(handler) {
25
+ this.tabOutHandler = handler;
26
+ }
27
+ /**
28
+ * (Re)build the manager from the current projected item set. Called from an `effect` on the menu's
29
+ * signal `contentChildren` — a passed array is a static snapshot (it does not auto-track like a live
30
+ * `QueryList`), so the manager is rebuilt whenever the item set changes (the documented signal↔CDK seam).
31
+ */
32
+ setItems(items) {
33
+ this.items = [...items];
34
+ this.manager?.destroy();
35
+ this.manager = new FocusKeyManager(this.items)
36
+ .withWrap()
37
+ .withTypeAhead()
38
+ .withHomeAndEnd()
39
+ // Disabled items stay in the roving order (APG focusable-but-inert — discoverable); activation is
40
+ // blocked on the item itself. `() => false` also stops `FocusKeyManager` from reading `item.disabled`
41
+ // (which on our signal-input component would be a truthy function).
42
+ .skipPredicate(() => false);
43
+ this.manager.tabOut.subscribe(() => this.tabOutHandler?.());
44
+ }
45
+ /** Delegate Up/Down/Home/End/type-ahead. Enter/Space/Escape are intentionally left to the platform. */
46
+ onKeydown(event) {
47
+ this.manager?.onKeydown(event);
48
+ }
49
+ /** Move roving focus to the first ENABLED item (APG: focus enters the menu on open). */
50
+ focusFirst() {
51
+ if (!this.manager || this.items.length === 0)
52
+ return;
53
+ const firstEnabled = this.items.findIndex((item) => !item.isDisabled());
54
+ this.manager.setActiveItem(firstEnabled >= 0 ? firstEnabled : 0);
55
+ }
56
+ destroy() {
57
+ this.manager?.destroy();
58
+ this.manager = undefined;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * DI token a `skra-menu` provides (`useExisting`) and a `button[skraMenuItem]` injects. A token
64
+ * (not the `SkraMenu` class) so the item file never imports the menu file — no runtime import cycle.
65
+ */
66
+ const SKRA_MENU = new InjectionToken('SKRA_MENU');
67
+
68
+ /**
69
+ * SkraMenuItem — a single command in a `skra-menu`, a Component on the native command host
70
+ * `button[skraMenuItem]` (ADR 012/040: a `<button>` is the right semantic — Enter/Space→click +
71
+ * focusability for free; its implicit role is overridden to `role="menuitem"` per WAI-ARIA APG).
72
+ *
73
+ * Roving focus: `tabindex="-1"` (the keyboard controller moves real focus). Implements
74
+ * `SkraMenuKeyboardItem` (`focus`/`getLabel`/`isDisabled`) so `SkraMenuKeyboard` (CDK `FocusKeyManager`)
75
+ * can drive it. On activation it asks the menu to close (native `hidePopover()` → focus returns to the
76
+ * trigger).
77
+ *
78
+ * **Disabled is native-attr-first (boundary #9, ADR 039 precedent): the consumer sets
79
+ * `aria-disabled="true"` directly — Forge OBSERVES it, exposing NO `disabled` input.** This (a) keeps a
80
+ * disabled item **focusable/discoverable** in the roving order (APG) — native `disabled` would remove it
81
+ * from focus, and (b) avoids a Forge input shadowing the native `disabled` attribute. Styling keys off
82
+ * `:host([aria-disabled="true"])`; activation is blocked here.
83
+ *
84
+ * Full contract: adr/specs/component-menu-spec.md.
85
+ */
86
+ class SkraMenuItem {
87
+ host = inject(ElementRef);
88
+ menu = inject(SKRA_MENU, { optional: true });
89
+ constructor() {
90
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
91
+ if (!this.menu) {
92
+ console.warn('[skraMenuItem] has no <skra-menu> ancestor — it is not registered with any keyboard ' +
93
+ 'controller and cannot close a menu on activation. Project it inside a <skra-menu>.');
94
+ }
95
+ }
96
+ }
97
+ // ── SkraMenuKeyboardItem (driven by SkraMenuKeyboard / CDK FocusKeyManager) ──
98
+ focus() {
99
+ this.host.nativeElement.focus();
100
+ }
101
+ getLabel() {
102
+ return (this.host.nativeElement.textContent ?? '').trim();
103
+ }
104
+ /** Soft-disabled = the consumer's `aria-disabled="true"` (native-attr-first); read live (no input). */
105
+ isDisabled() {
106
+ return this.host.nativeElement.getAttribute('aria-disabled') === 'true';
107
+ }
108
+ onActivate(event) {
109
+ if (this.isDisabled()) {
110
+ // Soft-disabled: block the native default AND the consumer's own (click) on this same host
111
+ // (the host listener runs first — Material's MatMenuItem precedent).
112
+ event.preventDefault();
113
+ event.stopImmediatePropagation();
114
+ return;
115
+ }
116
+ // Activating a command closes the menu (APG). The consumer's (click) handler runs independently.
117
+ this.menu?.requestClose();
118
+ }
119
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMenuItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
120
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.7", type: SkraMenuItem, isStandalone: true, selector: "button[skraMenuItem]", host: { attributes: { "role": "menuitem", "type": "button", "tabindex": "-1" }, listeners: { "click": "onActivate($event)" } }, exportAs: ["skraMenuItem"], ngImport: i0, template: '<ng-content></ng-content>', isInline: true, styles: ["@charset \"UTF-8\";:host{display:flex;align-items:center;gap:var(--skra-comp-menu-item-gap);inline-size:100%;min-block-size:var(--skra-comp-menu-item-min-block-size);margin:0;box-sizing:border-box;padding-block:var(--skra-comp-menu-item-padding-block);padding-inline:var(--skra-comp-menu-item-padding-inline);appearance:none;background:transparent;border:0;color:var(--skra-comp-menu-item-foreground);text-align:start;cursor:pointer;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing);transition:background-color var(--skra-comp-menu-motion-duration) var(--skra-comp-menu-motion-easing)}:host(:hover:not([aria-disabled=true])),:host(:focus-visible:not([aria-disabled=true])){background-color:var(--skra-comp-menu-item-background-hover)}:host(:focus-visible){outline:var(--skra-comp-menu-focus-ring-width) solid var(--skra-comp-menu-focus-ring-color);outline-offset:calc(-1 * var(--skra-comp-menu-focus-ring-width))}:host([aria-disabled=true]){color:var(--skra-comp-menu-item-foreground-disabled);cursor:default}@media(prefers-reduced-motion:reduce){:host{transition-duration:0ms}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
121
+ }
122
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMenuItem, decorators: [{
123
+ type: Component,
124
+ args: [{ selector: 'button[skraMenuItem]', standalone: true, exportAs: 'skraMenuItem', template: '<ng-content></ng-content>', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, host: {
125
+ role: 'menuitem',
126
+ type: 'button', // never an implicit submit if the menu sits inside a <form>
127
+ tabindex: '-1', // roving — focus is moved programmatically by SkraMenuKeyboard
128
+ '(click)': 'onActivate($event)',
129
+ }, styles: ["@charset \"UTF-8\";:host{display:flex;align-items:center;gap:var(--skra-comp-menu-item-gap);inline-size:100%;min-block-size:var(--skra-comp-menu-item-min-block-size);margin:0;box-sizing:border-box;padding-block:var(--skra-comp-menu-item-padding-block);padding-inline:var(--skra-comp-menu-item-padding-inline);appearance:none;background:transparent;border:0;color:var(--skra-comp-menu-item-foreground);text-align:start;cursor:pointer;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing);transition:background-color var(--skra-comp-menu-motion-duration) var(--skra-comp-menu-motion-easing)}:host(:hover:not([aria-disabled=true])),:host(:focus-visible:not([aria-disabled=true])){background-color:var(--skra-comp-menu-item-background-hover)}:host(:focus-visible){outline:var(--skra-comp-menu-focus-ring-width) solid var(--skra-comp-menu-focus-ring-color);outline-offset:calc(-1 * var(--skra-comp-menu-focus-ring-width))}:host([aria-disabled=true]){color:var(--skra-comp-menu-item-foreground-disabled);cursor:default}@media(prefers-reduced-motion:reduce){:host{transition-duration:0ms}}\n"] }]
130
+ }], ctorParameters: () => [] });
131
+
132
+ /**
133
+ * SkraMenu — a WAI-ARIA APG **Menu Button** menu: a homogeneous set of commands anchored to a
134
+ * trigger, navigated as one composite widget with arrow keys, where activating a command closes it.
135
+ * Rendered in the browser TOP LAYER via the native Popover API (`popover="auto"`), per ADR 0044.
136
+ *
137
+ * SHAPE (scope 2026-06-07): a Component on the PANEL with projected `button[skraMenuItem]` children
138
+ * (the Popover model, ADR 040) — NOT a directive on the trigger, NOT a data-driven `[items]` array.
139
+ * The trigger is the consumer's own button linked by the native `popovertarget` attribute; toggle,
140
+ * light-dismiss and Escape are platform-owned. The consumer MUST give `<skra-menu>` an `id`.
141
+ *
142
+ * <button skraButton popovertarget="actions" aria-haspopup="menu">Actions</button>
143
+ * <skra-menu id="actions">
144
+ * <button skraMenuItem (click)="edit()">Edit</button>
145
+ * <button skraMenuItem disabled (click)="archive()">Archive</button>
146
+ * <button skraMenuItem (click)="remove()">Delete</button>
147
+ * </skra-menu>
148
+ *
149
+ * Forge adds, on top of the Popover substrate: the `role="menu"` keyboard model via `SkraMenuKeyboard`
150
+ * (CDK `FocusKeyManager` — Down/Up roving + wrap, Home/End, type-ahead, Tab-dismiss), **focus moving
151
+ * INTO the menu on open** (a composite widget — unlike non-modal Popover; the per-component ADR 0044
152
+ * §3 rule-8 decision), and the trigger's disclosure ARIA (`aria-haspopup="menu"` / `aria-controls` /
153
+ * `aria-expanded`). Side effects on open/close: bind the native `(toggle)` — there is no Forge output
154
+ * (native-first; contrast Dialog, whose `<dialog>` has no open event). Context menus (right-click) are
155
+ * a recipe with a visible button, never a trigger mode (SRP + WCAG 2.1.1).
156
+ *
157
+ * Full contract: adr/specs/component-menu-spec.md.
158
+ */
159
+ class SkraMenu {
160
+ /** Placement intent; `flip`/`shift` adjust at runtime. */
161
+ placement = input('bottom', ...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
162
+ menuItems = contentChildren(SkraMenuItem, { ...(ngDevMode ? { debugName: "menuItems" } : /* istanbul ignore next */ {}), descendants: true });
163
+ host = inject(ElementRef);
164
+ renderer = inject(Renderer2);
165
+ keyboard = new SkraMenuKeyboard();
166
+ /** The consumer's `[popovertarget]` button, resolved once (lazily-idempotent via `resolved`). */
167
+ trigger = null;
168
+ resolved = false;
169
+ positioner;
170
+ constructor() {
171
+ // Feed the controller whenever the projected item set changes (signal → array snapshot rebuild).
172
+ effect(() => this.keyboard.setItems(this.menuItems()));
173
+ // APG: Tab/Shift+Tab dismisses the menu; native Tab then proceeds from the trigger.
174
+ this.keyboard.onTabOut(() => this.host.nativeElement.hidePopover?.());
175
+ // All imperative DOM wiring lives in afterNextRender — a browser-only hook, so the native listeners
176
+ // and the trigger resolution never run on the server (SSR-safe by construction; O-18). The listeners
177
+ // attach right after first paint, long before any user interaction can fire them.
178
+ afterNextRender(() => {
179
+ const el = this.host.nativeElement;
180
+ // Gate disabled items at the CAPTURE phase: a component host listener registers AFTER the parent
181
+ // template's `(click)` on the same button, so a bubble-phase guard cannot stop the consumer's own
182
+ // handler. A capture listener on the menu host fires before the target's listeners — preventing both
183
+ // the consumer's `(click)` and the item's `onActivate` for an `aria-disabled` item.
184
+ el.addEventListener('click', this.onCaptureClick, true);
185
+ // Imperative (NOT host-metadata) so a consumer `(toggle)` binding does not shadow them — see host {}.
186
+ el.addEventListener('toggle', this.onToggleNative);
187
+ el.addEventListener('keydown', this.onKeydownNative);
188
+ // Resolve + wire the trigger after the view exists (it may be a later sibling), so the disclosure
189
+ // relationship (aria-haspopup + aria-controls + aria-expanded="false") is present before first open.
190
+ this.ensureTrigger();
191
+ if (typeof ngDevMode === 'undefined' || ngDevMode)
192
+ this.warnIfEmpty();
193
+ });
194
+ inject(DestroyRef).onDestroy(() => this.teardown());
195
+ }
196
+ /** SKRA_MENU contract — close on item activation (native focus-return to the trigger). */
197
+ requestClose() {
198
+ this.host.nativeElement.hidePopover?.();
199
+ }
200
+ /** Capture-phase guard: block clicks on an `aria-disabled` item before they reach its listeners. */
201
+ onCaptureClick = (event) => {
202
+ const item = event.target?.closest?.('[skraMenuItem]');
203
+ if (item?.getAttribute('aria-disabled') === 'true') {
204
+ event.preventDefault();
205
+ event.stopPropagation();
206
+ }
207
+ };
208
+ // ── Toggle / keydown (imperative native listeners — see host {} note) ─────────
209
+ onToggleNative = (event) => {
210
+ if (event.newState === 'open')
211
+ this.onOpen();
212
+ else
213
+ this.onClose();
214
+ };
215
+ onKeydownNative = (event) => {
216
+ this.keyboard.onKeydown(event);
217
+ };
218
+ onOpen() {
219
+ const anchor = this.ensureTrigger(); // safety net if afterNextRender has not yet run
220
+ if (anchor) {
221
+ this.renderer.setAttribute(anchor, 'aria-expanded', 'true');
222
+ this.positioner?.destroy();
223
+ this.positioner = _attachPositioner(anchor, this.host.nativeElement, () => ({
224
+ placement: this.placement(),
225
+ offset: this.cssLenPx('--skra-comp-menu-offset'),
226
+ viewportPadding: this.cssLenPx('--skra-comp-menu-viewport-padding'),
227
+ }));
228
+ }
229
+ // Focus enters the menu (APG Menu Button) regardless of how it opened — unlike non-modal Popover.
230
+ this.keyboard.focusFirst();
231
+ }
232
+ onClose() {
233
+ if (this.trigger) {
234
+ this.renderer.setAttribute(this.trigger, 'aria-expanded', 'false');
235
+ }
236
+ this.positioner?.destroy();
237
+ this.positioner = undefined;
238
+ }
239
+ /**
240
+ * Resolve the `[popovertarget]` trigger and establish the menu disclosure ARIA on it (sanctioned a11y
241
+ * wiring, ADR 013-style — not style mutation). Idempotent. Returns the trigger (or null for an orphan
242
+ * menu, dev-warned once).
243
+ */
244
+ ensureTrigger() {
245
+ if (this.resolved)
246
+ return this.trigger;
247
+ this.resolved = true;
248
+ const id = this.host.nativeElement.id;
249
+ if (!id) {
250
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
251
+ console.warn('[skra-menu] has no `id` — it cannot be targeted by a `popovertarget` trigger and the ' +
252
+ 'disclosure ARIA (aria-haspopup/aria-controls/aria-expanded) cannot be wired. Give the ' +
253
+ 'menu an id and point a button at it: ' +
254
+ '<button popovertarget="my-id" aria-haspopup="menu">…</button> <skra-menu id="my-id">…');
255
+ }
256
+ return null;
257
+ }
258
+ // Match by attribute value (no escaping; robust to special chars; `CSS.escape` is not universal).
259
+ this.trigger =
260
+ Array.from(document.querySelectorAll('[popovertarget]')).find((el) => el.getAttribute('popovertarget') === id) ?? null;
261
+ if (!this.trigger) {
262
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
263
+ console.warn(`[skra-menu] no trigger found with [popovertarget="${id}"] — the menu is unreachable and ` +
264
+ 'has no element to carry aria-haspopup/aria-controls/aria-expanded. Add a focusable trigger ' +
265
+ 'button with the matching popovertarget.');
266
+ }
267
+ return null;
268
+ }
269
+ // Establish the Menu Button relationship before the first open (APG; aria-haspopup distinguishes a
270
+ // menu from Popover's generic disclosure).
271
+ this.renderer.setAttribute(this.trigger, 'aria-haspopup', 'menu');
272
+ this.renderer.setAttribute(this.trigger, 'aria-controls', id);
273
+ this.renderer.setAttribute(this.trigger, 'aria-expanded', 'false');
274
+ return this.trigger;
275
+ }
276
+ /** Read a length comp token (px/rem) off the host computed style for the positioner. */
277
+ cssLenPx(prop) {
278
+ const raw = getComputedStyle(this.host.nativeElement)
279
+ .getPropertyValue(prop)
280
+ .trim();
281
+ if (raw.endsWith('px'))
282
+ return parseFloat(raw);
283
+ if (raw.endsWith('rem')) {
284
+ const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
285
+ return parseFloat(raw) * rootFontSize;
286
+ }
287
+ return parseFloat(raw) || 0;
288
+ }
289
+ /** Dev-only nudge: a role="menu" with no menuitems is invalid — a link-list/content panel is a Popover. */
290
+ warnIfEmpty() {
291
+ if (this.menuItems().length === 0) {
292
+ console.warn('[skra-menu] has no `button[skraMenuItem]` children — a `role="menu"` with no menu items is ' +
293
+ 'invalid. If this is a panel of arbitrary content or a list of navigation links, use ' +
294
+ '<skra-popover> (disclosure) instead — see the Menu-vs-Popover boundary in the spec.');
295
+ }
296
+ }
297
+ teardown() {
298
+ const el = this.host.nativeElement;
299
+ el.removeEventListener('click', this.onCaptureClick, true);
300
+ el.removeEventListener('toggle', this.onToggleNative);
301
+ el.removeEventListener('keydown', this.onKeydownNative);
302
+ this.keyboard.destroy();
303
+ this.positioner?.destroy();
304
+ if (this.trigger) {
305
+ this.renderer.removeAttribute(this.trigger, 'aria-haspopup');
306
+ this.renderer.removeAttribute(this.trigger, 'aria-expanded');
307
+ this.renderer.removeAttribute(this.trigger, 'aria-controls');
308
+ }
309
+ }
310
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMenu, deps: [], target: i0.ɵɵFactoryTarget.Component });
311
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.7", type: SkraMenu, isStandalone: true, selector: "skra-menu", inputs: { placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "menu", "popover": "auto" } }, providers: [{ provide: SKRA_MENU, useExisting: SkraMenu }], queries: [{ propertyName: "menuItems", predicate: SkraMenuItem, descendants: true, isSignal: true }], exportAs: ["skraMenu"], ngImport: i0, template: '<ng-content></ng-content>', isInline: true, styles: ["@charset \"UTF-8\";:host{position:fixed;margin:0;inset:auto;box-sizing:border-box;background:var(--skra-comp-menu-background);color:var(--skra-comp-menu-foreground);border-color:var(--skra-comp-menu-border-color);border-width:var(--skra-comp-menu-border-width);border-style:var(--skra-comp-menu-border-style);border-radius:var(--skra-comp-menu-radius);padding-block:var(--skra-comp-menu-padding-block);padding-inline:var(--skra-comp-menu-padding-inline);box-shadow:var(--skra-comp-menu-shadow);inline-size:max-content;max-inline-size:var(--skra-comp-menu-max-inline-size);flex-direction:column;opacity:0;transition:opacity var(--skra-comp-menu-motion-duration) var(--skra-comp-menu-motion-easing),display var(--skra-comp-menu-motion-duration) allow-discrete,overlay var(--skra-comp-menu-motion-duration) allow-discrete}:host(:popover-open){display:flex;opacity:1}@starting-style{:host(:popover-open){opacity:0}}@media(prefers-reduced-motion:reduce){:host{transition-duration:0ms}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
312
+ }
313
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMenu, decorators: [{
314
+ type: Component,
315
+ args: [{ selector: 'skra-menu', standalone: true, exportAs: 'skraMenu', template: '<ng-content></ng-content>', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, providers: [{ provide: SKRA_MENU, useExisting: SkraMenu }], host: {
316
+ role: 'menu',
317
+ // Native top layer + platform-owned toggle / light-dismiss / Escape / stacking (ADR 0044).
318
+ popover: 'auto',
319
+ // NOTE: `toggle` and `keydown` are wired IMPERATIVELY in the constructor, NOT as host listeners.
320
+ // A consumer template binding `(toggle)` on <skra-menu> (the documented side-effect path) SHADOWS a
321
+ // host-metadata `(toggle)` listener for the same event — which would silently break positioning +
322
+ // aria-expanded. A raw `addEventListener` is independent of Angular's host/template listener slot, so
323
+ // it always fires alongside the consumer's binding.
324
+ }, styles: ["@charset \"UTF-8\";:host{position:fixed;margin:0;inset:auto;box-sizing:border-box;background:var(--skra-comp-menu-background);color:var(--skra-comp-menu-foreground);border-color:var(--skra-comp-menu-border-color);border-width:var(--skra-comp-menu-border-width);border-style:var(--skra-comp-menu-border-style);border-radius:var(--skra-comp-menu-radius);padding-block:var(--skra-comp-menu-padding-block);padding-inline:var(--skra-comp-menu-padding-inline);box-shadow:var(--skra-comp-menu-shadow);inline-size:max-content;max-inline-size:var(--skra-comp-menu-max-inline-size);flex-direction:column;opacity:0;transition:opacity var(--skra-comp-menu-motion-duration) var(--skra-comp-menu-motion-easing),display var(--skra-comp-menu-motion-duration) allow-discrete,overlay var(--skra-comp-menu-motion-duration) allow-discrete}:host(:popover-open){display:flex;opacity:1}@starting-style{:host(:popover-open){opacity:0}}@media(prefers-reduced-motion:reduce){:host{transition-duration:0ms}}\n"] }]
325
+ }], ctorParameters: () => [], propDecorators: { placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], menuItems: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SkraMenuItem), { ...{
326
+ descendants: true,
327
+ }, isSignal: true }] }] } });
328
+
329
+ /**
330
+ * Generated bundle index. Do not edit.
331
+ */
332
+
333
+ export { SkraMenu, SkraMenuItem };
334
+ //# sourceMappingURL=skra-ui-menu.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skra-ui-menu.mjs","sources":["../../../../libs/ui/menu/src/lib/menu/menu-keyboard.ts","../../../../libs/ui/menu/src/lib/menu/menu-types.ts","../../../../libs/ui/menu/src/lib/menu/menu-item.ts","../../../../libs/ui/menu/src/lib/menu/menu.ts","../../../../libs/ui/menu/src/skra-ui-menu.ts"],"sourcesContent":["import { FocusKeyManager } from '@angular/cdk/a11y';\n\nimport type { SkraMenuKeyboardItem } from './menu-types';\n\n/**\n * SkraMenuKeyboard — the WAI-ARIA APG Menu keyboard model, confined to ONE file.\n *\n * This is the **only** module in Forge that imports `@angular/cdk`. It wraps CDK `FocusKeyManager`\n * (roving REAL focus — the APG-canonical menu model, NOT `aria-activedescendant`) so the engine is an\n * implementation detail: swapping it (e.g. to a hand-rolled controller) is a one-file change with zero\n * impact on `SkraMenu` / `SkraMenuItem` — exactly as `SkraPositioner` wraps `@floating-ui/dom`\n * (ADR 0044 §3 rule 5; recorded in §7, 2026-06-07).\n *\n * Owns: Down/Up roving (wrap), Home/End, type-ahead, and Tab→dismiss. Enter/Space (native button\n * activation) and Escape + light-dismiss (`popover=\"auto\"`) stay platform-owned — the controller never\n * touches them.\n */\nexport class SkraMenuKeyboard {\n private manager?: FocusKeyManager<SkraMenuKeyboardItem>;\n private items: SkraMenuKeyboardItem[] = [];\n private tabOutHandler?: () => void;\n\n /** Register the APG \"Tab dismisses the menu\" callback (fired by `FocusKeyManager.tabOut`). */\n onTabOut(handler: () => void): void {\n this.tabOutHandler = handler;\n }\n\n /**\n * (Re)build the manager from the current projected item set. Called from an `effect` on the menu's\n * signal `contentChildren` — a passed array is a static snapshot (it does not auto-track like a live\n * `QueryList`), so the manager is rebuilt whenever the item set changes (the documented signal↔CDK seam).\n */\n setItems(items: readonly SkraMenuKeyboardItem[]): void {\n this.items = [...items];\n this.manager?.destroy();\n this.manager = new FocusKeyManager<SkraMenuKeyboardItem>(this.items)\n .withWrap()\n .withTypeAhead()\n .withHomeAndEnd()\n // Disabled items stay in the roving order (APG focusable-but-inert — discoverable); activation is\n // blocked on the item itself. `() => false` also stops `FocusKeyManager` from reading `item.disabled`\n // (which on our signal-input component would be a truthy function).\n .skipPredicate(() => false);\n this.manager.tabOut.subscribe(() => this.tabOutHandler?.());\n }\n\n /** Delegate Up/Down/Home/End/type-ahead. Enter/Space/Escape are intentionally left to the platform. */\n onKeydown(event: KeyboardEvent): void {\n this.manager?.onKeydown(event);\n }\n\n /** Move roving focus to the first ENABLED item (APG: focus enters the menu on open). */\n focusFirst(): void {\n if (!this.manager || this.items.length === 0) return;\n const firstEnabled = this.items.findIndex((item) => !item.isDisabled());\n this.manager.setActiveItem(firstEnabled >= 0 ? firstEnabled : 0);\n }\n\n destroy(): void {\n this.manager?.destroy();\n this.manager = undefined;\n }\n}\n","import { InjectionToken } from '@angular/core';\n\n/**\n * The item shape the menu keyboard controller drives. Structurally a CDK `FocusableOption`\n * (`focus()`), plus the menu's own `getLabel()` (type-ahead) and `isDisabled()` (focusable-but-inert\n * roving). Declared HERE — cdk-free — so the only `@angular/cdk` importer stays `menu-keyboard.ts`.\n */\nexport interface SkraMenuKeyboardItem {\n focus(): void;\n getLabel(): string;\n isDisabled(): boolean;\n}\n\n/** What a `skraMenuItem` needs from its `skra-menu` — narrow contract that breaks the menu↔item cycle. */\nexport interface SkraMenuPanel {\n /** Dismiss the menu after an item activates (native `hidePopover()` → focus returns to the trigger). */\n requestClose(): void;\n}\n\n/**\n * DI token a `skra-menu` provides (`useExisting`) and a `button[skraMenuItem]` injects. A token\n * (not the `SkraMenu` class) so the item file never imports the menu file — no runtime import cycle.\n */\nexport const SKRA_MENU = new InjectionToken<SkraMenuPanel>('SKRA_MENU');\n","import {\n ChangeDetectionStrategy,\n Component,\n ElementRef,\n ViewEncapsulation,\n inject,\n} from '@angular/core';\n\nimport { SKRA_MENU, type SkraMenuKeyboardItem } from './menu-types';\n\ndeclare const ngDevMode: boolean | undefined;\n\n/**\n * SkraMenuItem — a single command in a `skra-menu`, a Component on the native command host\n * `button[skraMenuItem]` (ADR 012/040: a `<button>` is the right semantic — Enter/Space→click +\n * focusability for free; its implicit role is overridden to `role=\"menuitem\"` per WAI-ARIA APG).\n *\n * Roving focus: `tabindex=\"-1\"` (the keyboard controller moves real focus). Implements\n * `SkraMenuKeyboardItem` (`focus`/`getLabel`/`isDisabled`) so `SkraMenuKeyboard` (CDK `FocusKeyManager`)\n * can drive it. On activation it asks the menu to close (native `hidePopover()` → focus returns to the\n * trigger).\n *\n * **Disabled is native-attr-first (boundary #9, ADR 039 precedent): the consumer sets\n * `aria-disabled=\"true\"` directly — Forge OBSERVES it, exposing NO `disabled` input.** This (a) keeps a\n * disabled item **focusable/discoverable** in the roving order (APG) — native `disabled` would remove it\n * from focus, and (b) avoids a Forge input shadowing the native `disabled` attribute. Styling keys off\n * `:host([aria-disabled=\"true\"])`; activation is blocked here.\n *\n * Full contract: adr/specs/component-menu-spec.md.\n */\n@Component({\n selector: 'button[skraMenuItem]',\n standalone: true,\n exportAs: 'skraMenuItem',\n template: '<ng-content></ng-content>',\n styleUrl: './menu-item.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n encapsulation: ViewEncapsulation.Emulated,\n host: {\n role: 'menuitem',\n type: 'button', // never an implicit submit if the menu sits inside a <form>\n tabindex: '-1', // roving — focus is moved programmatically by SkraMenuKeyboard\n '(click)': 'onActivate($event)',\n },\n})\nexport class SkraMenuItem implements SkraMenuKeyboardItem {\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly menu = inject(SKRA_MENU, { optional: true });\n\n constructor() {\n if (typeof ngDevMode === 'undefined' || ngDevMode) {\n if (!this.menu) {\n console.warn(\n '[skraMenuItem] has no <skra-menu> ancestor — it is not registered with any keyboard ' +\n 'controller and cannot close a menu on activation. Project it inside a <skra-menu>.',\n );\n }\n }\n }\n\n // ── SkraMenuKeyboardItem (driven by SkraMenuKeyboard / CDK FocusKeyManager) ──\n focus(): void {\n this.host.nativeElement.focus();\n }\n getLabel(): string {\n return (this.host.nativeElement.textContent ?? '').trim();\n }\n /** Soft-disabled = the consumer's `aria-disabled=\"true\"` (native-attr-first); read live (no input). */\n isDisabled(): boolean {\n return this.host.nativeElement.getAttribute('aria-disabled') === 'true';\n }\n\n protected onActivate(event: MouseEvent): void {\n if (this.isDisabled()) {\n // Soft-disabled: block the native default AND the consumer's own (click) on this same host\n // (the host listener runs first — Material's MatMenuItem precedent).\n event.preventDefault();\n event.stopImmediatePropagation();\n return;\n }\n // Activating a command closes the menu (APG). The consumer's (click) handler runs independently.\n this.menu?.requestClose();\n }\n}\n","import {\n ChangeDetectionStrategy,\n Component,\n DestroyRef,\n ElementRef,\n Renderer2,\n ViewEncapsulation,\n afterNextRender,\n contentChildren,\n effect,\n inject,\n input,\n} from '@angular/core';\n\nimport {\n _attachPositioner,\n type SkraPlacement,\n type _PositionerHandle,\n} from '@skra/ui/core';\nimport { SkraMenuKeyboard } from './menu-keyboard';\nimport { SkraMenuItem } from './menu-item';\nimport { SKRA_MENU, type SkraMenuPanel } from './menu-types';\n\ndeclare const ngDevMode: boolean | undefined;\n\n/**\n * SkraMenu — a WAI-ARIA APG **Menu Button** menu: a homogeneous set of commands anchored to a\n * trigger, navigated as one composite widget with arrow keys, where activating a command closes it.\n * Rendered in the browser TOP LAYER via the native Popover API (`popover=\"auto\"`), per ADR 0044.\n *\n * SHAPE (scope 2026-06-07): a Component on the PANEL with projected `button[skraMenuItem]` children\n * (the Popover model, ADR 040) — NOT a directive on the trigger, NOT a data-driven `[items]` array.\n * The trigger is the consumer's own button linked by the native `popovertarget` attribute; toggle,\n * light-dismiss and Escape are platform-owned. The consumer MUST give `<skra-menu>` an `id`.\n *\n * <button skraButton popovertarget=\"actions\" aria-haspopup=\"menu\">Actions</button>\n * <skra-menu id=\"actions\">\n * <button skraMenuItem (click)=\"edit()\">Edit</button>\n * <button skraMenuItem disabled (click)=\"archive()\">Archive</button>\n * <button skraMenuItem (click)=\"remove()\">Delete</button>\n * </skra-menu>\n *\n * Forge adds, on top of the Popover substrate: the `role=\"menu\"` keyboard model via `SkraMenuKeyboard`\n * (CDK `FocusKeyManager` — Down/Up roving + wrap, Home/End, type-ahead, Tab-dismiss), **focus moving\n * INTO the menu on open** (a composite widget — unlike non-modal Popover; the per-component ADR 0044\n * §3 rule-8 decision), and the trigger's disclosure ARIA (`aria-haspopup=\"menu\"` / `aria-controls` /\n * `aria-expanded`). Side effects on open/close: bind the native `(toggle)` — there is no Forge output\n * (native-first; contrast Dialog, whose `<dialog>` has no open event). Context menus (right-click) are\n * a recipe with a visible button, never a trigger mode (SRP + WCAG 2.1.1).\n *\n * Full contract: adr/specs/component-menu-spec.md.\n */\n@Component({\n selector: 'skra-menu',\n standalone: true,\n exportAs: 'skraMenu',\n template: '<ng-content></ng-content>',\n styleUrl: './menu.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n encapsulation: ViewEncapsulation.Emulated,\n providers: [{ provide: SKRA_MENU, useExisting: SkraMenu }],\n host: {\n role: 'menu',\n // Native top layer + platform-owned toggle / light-dismiss / Escape / stacking (ADR 0044).\n popover: 'auto',\n // NOTE: `toggle` and `keydown` are wired IMPERATIVELY in the constructor, NOT as host listeners.\n // A consumer template binding `(toggle)` on <skra-menu> (the documented side-effect path) SHADOWS a\n // host-metadata `(toggle)` listener for the same event — which would silently break positioning +\n // aria-expanded. A raw `addEventListener` is independent of Angular's host/template listener slot, so\n // it always fires alongside the consumer's binding.\n },\n})\nexport class SkraMenu implements SkraMenuPanel {\n /** Placement intent; `flip`/`shift` adjust at runtime. */\n readonly placement = input<SkraPlacement>('bottom');\n\n private readonly menuItems = contentChildren(SkraMenuItem, {\n descendants: true,\n });\n\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly renderer = inject(Renderer2);\n private readonly keyboard = new SkraMenuKeyboard();\n\n /** The consumer's `[popovertarget]` button, resolved once (lazily-idempotent via `resolved`). */\n private trigger: HTMLElement | null = null;\n private resolved = false;\n private positioner?: _PositionerHandle;\n\n constructor() {\n // Feed the controller whenever the projected item set changes (signal → array snapshot rebuild).\n effect(() => this.keyboard.setItems(this.menuItems()));\n // APG: Tab/Shift+Tab dismisses the menu; native Tab then proceeds from the trigger.\n this.keyboard.onTabOut(() => this.host.nativeElement.hidePopover?.());\n\n // All imperative DOM wiring lives in afterNextRender — a browser-only hook, so the native listeners\n // and the trigger resolution never run on the server (SSR-safe by construction; O-18). The listeners\n // attach right after first paint, long before any user interaction can fire them.\n afterNextRender(() => {\n const el = this.host.nativeElement;\n // Gate disabled items at the CAPTURE phase: a component host listener registers AFTER the parent\n // template's `(click)` on the same button, so a bubble-phase guard cannot stop the consumer's own\n // handler. A capture listener on the menu host fires before the target's listeners — preventing both\n // the consumer's `(click)` and the item's `onActivate` for an `aria-disabled` item.\n el.addEventListener('click', this.onCaptureClick, true);\n // Imperative (NOT host-metadata) so a consumer `(toggle)` binding does not shadow them — see host {}.\n el.addEventListener('toggle', this.onToggleNative);\n el.addEventListener('keydown', this.onKeydownNative);\n\n // Resolve + wire the trigger after the view exists (it may be a later sibling), so the disclosure\n // relationship (aria-haspopup + aria-controls + aria-expanded=\"false\") is present before first open.\n this.ensureTrigger();\n if (typeof ngDevMode === 'undefined' || ngDevMode) this.warnIfEmpty();\n });\n inject(DestroyRef).onDestroy(() => this.teardown());\n }\n\n /** SKRA_MENU contract — close on item activation (native focus-return to the trigger). */\n requestClose(): void {\n this.host.nativeElement.hidePopover?.();\n }\n\n /** Capture-phase guard: block clicks on an `aria-disabled` item before they reach its listeners. */\n private readonly onCaptureClick = (event: Event): void => {\n const item = (event.target as HTMLElement | null)?.closest?.(\n '[skraMenuItem]',\n );\n if (item?.getAttribute('aria-disabled') === 'true') {\n event.preventDefault();\n event.stopPropagation();\n }\n };\n\n // ── Toggle / keydown (imperative native listeners — see host {} note) ─────────\n private readonly onToggleNative = (event: Event): void => {\n if ((event as ToggleEvent).newState === 'open') this.onOpen();\n else this.onClose();\n };\n\n private readonly onKeydownNative = (event: Event): void => {\n this.keyboard.onKeydown(event as KeyboardEvent);\n };\n\n private onOpen(): void {\n const anchor = this.ensureTrigger(); // safety net if afterNextRender has not yet run\n if (anchor) {\n this.renderer.setAttribute(anchor, 'aria-expanded', 'true');\n this.positioner?.destroy();\n this.positioner = _attachPositioner(\n anchor,\n this.host.nativeElement,\n () => ({\n placement: this.placement(),\n offset: this.cssLenPx('--skra-comp-menu-offset'),\n viewportPadding: this.cssLenPx('--skra-comp-menu-viewport-padding'),\n }),\n );\n }\n // Focus enters the menu (APG Menu Button) regardless of how it opened — unlike non-modal Popover.\n this.keyboard.focusFirst();\n }\n\n private onClose(): void {\n if (this.trigger) {\n this.renderer.setAttribute(this.trigger, 'aria-expanded', 'false');\n }\n this.positioner?.destroy();\n this.positioner = undefined;\n }\n\n /**\n * Resolve the `[popovertarget]` trigger and establish the menu disclosure ARIA on it (sanctioned a11y\n * wiring, ADR 013-style — not style mutation). Idempotent. Returns the trigger (or null for an orphan\n * menu, dev-warned once).\n */\n private ensureTrigger(): HTMLElement | null {\n if (this.resolved) return this.trigger;\n this.resolved = true;\n\n const id = this.host.nativeElement.id;\n if (!id) {\n if (typeof ngDevMode === 'undefined' || ngDevMode) {\n console.warn(\n '[skra-menu] has no `id` — it cannot be targeted by a `popovertarget` trigger and the ' +\n 'disclosure ARIA (aria-haspopup/aria-controls/aria-expanded) cannot be wired. Give the ' +\n 'menu an id and point a button at it: ' +\n '<button popovertarget=\"my-id\" aria-haspopup=\"menu\">…</button> <skra-menu id=\"my-id\">…',\n );\n }\n return null;\n }\n\n // Match by attribute value (no escaping; robust to special chars; `CSS.escape` is not universal).\n this.trigger =\n Array.from(\n document.querySelectorAll<HTMLElement>('[popovertarget]'),\n ).find((el) => el.getAttribute('popovertarget') === id) ?? null;\n if (!this.trigger) {\n if (typeof ngDevMode === 'undefined' || ngDevMode) {\n console.warn(\n `[skra-menu] no trigger found with [popovertarget=\"${id}\"] — the menu is unreachable and ` +\n 'has no element to carry aria-haspopup/aria-controls/aria-expanded. Add a focusable trigger ' +\n 'button with the matching popovertarget.',\n );\n }\n return null;\n }\n\n // Establish the Menu Button relationship before the first open (APG; aria-haspopup distinguishes a\n // menu from Popover's generic disclosure).\n this.renderer.setAttribute(this.trigger, 'aria-haspopup', 'menu');\n this.renderer.setAttribute(this.trigger, 'aria-controls', id);\n this.renderer.setAttribute(this.trigger, 'aria-expanded', 'false');\n return this.trigger;\n }\n\n /** Read a length comp token (px/rem) off the host computed style for the positioner. */\n private cssLenPx(prop: string): number {\n const raw = getComputedStyle(this.host.nativeElement)\n .getPropertyValue(prop)\n .trim();\n if (raw.endsWith('px')) return parseFloat(raw);\n if (raw.endsWith('rem')) {\n const rootFontSize =\n parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n return parseFloat(raw) * rootFontSize;\n }\n return parseFloat(raw) || 0;\n }\n\n /** Dev-only nudge: a role=\"menu\" with no menuitems is invalid — a link-list/content panel is a Popover. */\n private warnIfEmpty(): void {\n if (this.menuItems().length === 0) {\n console.warn(\n '[skra-menu] has no `button[skraMenuItem]` children — a `role=\"menu\"` with no menu items is ' +\n 'invalid. If this is a panel of arbitrary content or a list of navigation links, use ' +\n '<skra-popover> (disclosure) instead — see the Menu-vs-Popover boundary in the spec.',\n );\n }\n }\n\n private teardown(): void {\n const el = this.host.nativeElement;\n el.removeEventListener('click', this.onCaptureClick, true);\n el.removeEventListener('toggle', this.onToggleNative);\n el.removeEventListener('keydown', this.onKeydownNative);\n this.keyboard.destroy();\n this.positioner?.destroy();\n if (this.trigger) {\n this.renderer.removeAttribute(this.trigger, 'aria-haspopup');\n this.renderer.removeAttribute(this.trigger, 'aria-expanded');\n this.renderer.removeAttribute(this.trigger, 'aria-controls');\n }\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,179 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { input, output, computed, afterRenderEffect, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
4
+ import { _SkraSelectBase, SKRA_LISTBOX_PANEL } from '@skra/ui/core';
5
+ export { SkraOption, SkraOptionDetail, SkraOptionLeading, SkraSelectTrigger } from '@skra/ui/core';
6
+
7
+ /**
8
+ * SkraMultiSelect — a custom MULTI-select listbox: the WAI-ARIA APG multi-selectable listbox combobox.
9
+ * The multi-cardinality sibling of `skra-select` over the shared `_SkraSelectBase` (**ADR 0046** —
10
+ * cardinality split: a separate component with an HONEST `readonly T[]` value contract, NOT a `multiple`
11
+ * flag). The trigger (`role="combobox"`) opens a top-layer listbox panel (`popover="auto"`, ADR 0044,
12
+ * `aria-multiselectable="true"`) of the SAME projected `skra-option` children — reused verbatim; the
13
+ * option swaps its indicator to a checkbox via the `SkraListboxPanel.multiple` hint.
14
+ *
15
+ * - **Value (ADR 037):** `value = input<readonly T[]>([])` + `valueChange = output<readonly T[]>()` —
16
+ * consumer-owned, no `@angular/forms`. Selection is membership (`compareWith` per element).
17
+ * - **Toggle (ADR 0046):** picking an option toggles its membership and KEEPS THE PANEL OPEN (the one
18
+ * behavioural divergence from single-select, which closes).
19
+ * - **Trigger summary (ADR 0045 / 033):** comma-joined `getLabel()` of selected options — the VALUE
20
+ * display, never the accessible name. Count-style / chips → the opt-in `[skraSelectTrigger]` recipe.
21
+ *
22
+ * Full contract: adr/specs/component-multi-select-spec.md.
23
+ */
24
+ class SkraMultiSelect extends _SkraSelectBase {
25
+ /** Consumer-owned ARRAY value channel (ADR 037) — default `[]` (never undefined). */
26
+ value = input([], ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
27
+ /** Fires ONLY on user toggle — emits a NEW array; never on a programmatic `value()` change (ADR 037). */
28
+ valueChange = output();
29
+ /** Cardinality hint (ADR 0046) — multi-select (drives the option checkbox indicator + aria-multiselectable). */
30
+ multiple = true;
31
+ /** The options whose value is a member of `value()` — feeds the trigger summary + seed. */
32
+ selectedOptions = computed(() => this.options().filter((o) => this.isSelected(o.value())), ...(ngDevMode ? [{ debugName: "selectedOptions" }] : /* istanbul ignore next */ []));
33
+ /** Comma-joined identity labels of the selected options (value display, ADR 0045 — language-neutral). */
34
+ summary = computed(() => this.selectedOptions()
35
+ .map((o) => o.getLabel())
36
+ .join(', '), ...(ngDevMode ? [{ debugName: "summary" }] : /* istanbul ignore next */ []));
37
+ triggerContext = computed(() => ({
38
+ $implicit: this.value(),
39
+ options: this.selectedOptions(),
40
+ }), ...(ngDevMode ? [{ debugName: "triggerContext" }] : /* istanbul ignore next */ []));
41
+ constructor() {
42
+ super();
43
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
44
+ // Dev-only consumer warning. Uses afterRenderEffect (NOT effect) so the projected
45
+ // options' required `value` inputs are read only AFTER each render applies their
46
+ // bindings — an eager read during the initial CD (SSR / first render) OR a runtime
47
+ // re-projection would throw NG0950. afterRender hooks never run on the server, so
48
+ // this stays SSR-safe. Covers F-13 (initial render) + F-16 (runtime re-projection).
49
+ afterRenderEffect(() => {
50
+ const opts = this.options();
51
+ const unmatched = this.value().filter((v) => !opts.some((o) => this.compareWith()(o.value(), v)));
52
+ if (unmatched.length) {
53
+ console.warn('[skra-multi-select] value contains entries with no matching <skra-option> — they are ' +
54
+ 'dropped from the trigger summary (likely a compareWith mismatch for object values).');
55
+ }
56
+ });
57
+ }
58
+ }
59
+ // ── SkraListboxPanel seam ──
60
+ isSelected(value) {
61
+ return this.value().some((x) => this.compareWith()(x, value));
62
+ }
63
+ selectValue(value) {
64
+ const present = this.isSelected(value);
65
+ const next = present
66
+ ? this.value().filter((x) => !this.compareWith()(x, value))
67
+ : [...this.value(), value];
68
+ this.valueChange.emit(next);
69
+ // NO close() — multi-select keeps the panel open for further toggling (ADR 0046).
70
+ }
71
+ activeSeedItem() {
72
+ return this.selectedOptions()[0] ?? null;
73
+ }
74
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
75
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.7", type: SkraMultiSelect, isStandalone: true, selector: "skra-multi-select", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueChange: "valueChange" }, providers: [{ provide: SKRA_LISTBOX_PANEL, useExisting: SkraMultiSelect }], exportAs: ["skraMultiSelect"], usesInheritance: true, ngImport: i0, template: `
76
+ <button
77
+ #trigger
78
+ class="skra-select-trigger"
79
+ type="button"
80
+ role="combobox"
81
+ aria-haspopup="listbox"
82
+ [attr.id]="triggerId()"
83
+ [attr.popovertarget]="panelId"
84
+ [attr.aria-controls]="panelId"
85
+ [attr.aria-expanded]="isOpen()"
86
+ [attr.aria-activedescendant]="activeOptionId()"
87
+ [attr.aria-describedby]="describedBy()"
88
+ [attr.aria-invalid]="hasError() ? 'true' : null"
89
+ [attr.aria-required]="isRequired() ? 'true' : null"
90
+ [attr.aria-readonly]="isReadonly() ? 'true' : null"
91
+ [disabled]="isDisabled()"
92
+ (click)="onTriggerClick($event)"
93
+ (keydown)="onTriggerKeydown($event)"
94
+ >
95
+ <span class="skra-select-trigger-value">
96
+ @if (triggerTemplate(); as tpl) {
97
+ <ng-container
98
+ [ngTemplateOutlet]="tpl"
99
+ [ngTemplateOutletContext]="triggerContext()"
100
+ ></ng-container>
101
+ } @else if (selectedOptions().length) {
102
+ {{ summary() }}
103
+ } @else {
104
+ <span class="skra-select-placeholder">{{ placeholder() }}</span>
105
+ }
106
+ </span>
107
+ <span class="skra-select-arrow" aria-hidden="true"></span>
108
+ </button>
109
+
110
+ <div
111
+ #panel
112
+ class="skra-select-listbox"
113
+ [id]="panelId"
114
+ role="listbox"
115
+ aria-multiselectable="true"
116
+ popover="auto"
117
+ (toggle)="onPanelToggle($event)"
118
+ >
119
+ <ng-content></ng-content>
120
+ </div>
121
+ `, isInline: true, styles: ["@charset \"UTF-8\";:host{display:block;box-sizing:border-box;margin:0}.skra-select-trigger{display:flex;align-items:center;gap:var(--skra-comp-select-gap);inline-size:100%;min-block-size:var(--skra-comp-select-min-block-size);box-sizing:border-box;margin:0;padding-block:var(--skra-comp-select-padding-block);padding-inline:var(--skra-comp-select-padding-inline);text-align:start;cursor:pointer;background:var(--skra-comp-select-background);color:var(--skra-comp-select-foreground);border-color:var(--skra-comp-select-border-color);border-width:var(--skra-comp-select-border-width);border-style:var(--skra-comp-select-border-style);border-radius:var(--skra-comp-select-radius);box-shadow:var(--skra-comp-select-shadow);font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing);transition:background-color var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing),border-color var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing)}.skra-select-trigger:hover:not(:disabled):not([aria-readonly=true]){background:var(--skra-comp-select-background-hover)}.skra-select-trigger:focus-visible{outline:var(--skra-comp-select-focus-ring-width) solid var(--skra-comp-select-focus-ring-color);outline-offset:.125rem}.skra-select-trigger:disabled{background:var(--skra-comp-select-background-disabled);color:var(--skra-comp-select-foreground-disabled);cursor:not-allowed}.skra-select-trigger[aria-readonly=true]{background:var(--skra-comp-select-background-readonly);cursor:default}.skra-select-trigger[aria-invalid=true]{border-color:var(--skra-comp-select-border-color-validation-invalid)}.skra-select-trigger-value{flex:1 1 auto;min-inline-size:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--skra-comp-icon-size: var(--skra-comp-select-icon-size)}.skra-select-placeholder{color:var(--skra-comp-select-foreground-disabled)}.skra-select-arrow{flex:none;inline-size:var(--skra-comp-select-arrow-icon-size);block-size:var(--skra-comp-select-arrow-icon-size);background-color:var(--skra-comp-select-arrow-color);mask:url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M6%209l6%206%206-6'%20fill='none'%20stroke='%23000'%20stroke-width='2'/%3E%3C/svg%3E\") no-repeat center/contain;-webkit-mask:url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M6%209l6%206%206-6'%20fill='none'%20stroke='%23000'%20stroke-width='2'/%3E%3C/svg%3E\") no-repeat center/contain;transition:transform var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing)}.skra-select-trigger[aria-expanded=true] .skra-select-arrow{transform:rotate(180deg)}.skra-select-listbox{position:fixed;margin:0;inset:auto;box-sizing:border-box;padding-block:var(--skra-comp-select-panel-padding-block);padding-inline:var(--skra-comp-select-panel-padding-inline);background:var(--skra-comp-select-panel-background);color:var(--skra-comp-select-panel-foreground);border:var(--skra-comp-select-border-width) solid var(--skra-comp-select-panel-border-color);border-radius:var(--skra-comp-select-panel-radius);box-shadow:var(--skra-comp-select-panel-shadow);inline-size:max-content;max-block-size:var(--skra-comp-select-panel-max-block-size);overflow-y:auto;flex-direction:column;opacity:0;transition:opacity var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing),display var(--skra-comp-select-motion-duration) allow-discrete,overlay var(--skra-comp-select-motion-duration) allow-discrete}.skra-select-listbox:popover-open{display:flex;opacity:1}@starting-style{.skra-select-listbox:popover-open{opacity:0}}@media(prefers-reduced-motion:reduce){.skra-select-trigger,.skra-select-arrow,.skra-select-listbox{transition-duration:0ms}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
122
+ }
123
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: SkraMultiSelect, decorators: [{
124
+ type: Component,
125
+ args: [{ selector: 'skra-multi-select', standalone: true, exportAs: 'skraMultiSelect', imports: [NgTemplateOutlet], template: `
126
+ <button
127
+ #trigger
128
+ class="skra-select-trigger"
129
+ type="button"
130
+ role="combobox"
131
+ aria-haspopup="listbox"
132
+ [attr.id]="triggerId()"
133
+ [attr.popovertarget]="panelId"
134
+ [attr.aria-controls]="panelId"
135
+ [attr.aria-expanded]="isOpen()"
136
+ [attr.aria-activedescendant]="activeOptionId()"
137
+ [attr.aria-describedby]="describedBy()"
138
+ [attr.aria-invalid]="hasError() ? 'true' : null"
139
+ [attr.aria-required]="isRequired() ? 'true' : null"
140
+ [attr.aria-readonly]="isReadonly() ? 'true' : null"
141
+ [disabled]="isDisabled()"
142
+ (click)="onTriggerClick($event)"
143
+ (keydown)="onTriggerKeydown($event)"
144
+ >
145
+ <span class="skra-select-trigger-value">
146
+ @if (triggerTemplate(); as tpl) {
147
+ <ng-container
148
+ [ngTemplateOutlet]="tpl"
149
+ [ngTemplateOutletContext]="triggerContext()"
150
+ ></ng-container>
151
+ } @else if (selectedOptions().length) {
152
+ {{ summary() }}
153
+ } @else {
154
+ <span class="skra-select-placeholder">{{ placeholder() }}</span>
155
+ }
156
+ </span>
157
+ <span class="skra-select-arrow" aria-hidden="true"></span>
158
+ </button>
159
+
160
+ <div
161
+ #panel
162
+ class="skra-select-listbox"
163
+ [id]="panelId"
164
+ role="listbox"
165
+ aria-multiselectable="true"
166
+ popover="auto"
167
+ (toggle)="onPanelToggle($event)"
168
+ >
169
+ <ng-content></ng-content>
170
+ </div>
171
+ `, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, providers: [{ provide: SKRA_LISTBOX_PANEL, useExisting: SkraMultiSelect }], styles: ["@charset \"UTF-8\";:host{display:block;box-sizing:border-box;margin:0}.skra-select-trigger{display:flex;align-items:center;gap:var(--skra-comp-select-gap);inline-size:100%;min-block-size:var(--skra-comp-select-min-block-size);box-sizing:border-box;margin:0;padding-block:var(--skra-comp-select-padding-block);padding-inline:var(--skra-comp-select-padding-inline);text-align:start;cursor:pointer;background:var(--skra-comp-select-background);color:var(--skra-comp-select-foreground);border-color:var(--skra-comp-select-border-color);border-width:var(--skra-comp-select-border-width);border-style:var(--skra-comp-select-border-style);border-radius:var(--skra-comp-select-radius);box-shadow:var(--skra-comp-select-shadow);font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing);transition:background-color var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing),border-color var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing)}.skra-select-trigger:hover:not(:disabled):not([aria-readonly=true]){background:var(--skra-comp-select-background-hover)}.skra-select-trigger:focus-visible{outline:var(--skra-comp-select-focus-ring-width) solid var(--skra-comp-select-focus-ring-color);outline-offset:.125rem}.skra-select-trigger:disabled{background:var(--skra-comp-select-background-disabled);color:var(--skra-comp-select-foreground-disabled);cursor:not-allowed}.skra-select-trigger[aria-readonly=true]{background:var(--skra-comp-select-background-readonly);cursor:default}.skra-select-trigger[aria-invalid=true]{border-color:var(--skra-comp-select-border-color-validation-invalid)}.skra-select-trigger-value{flex:1 1 auto;min-inline-size:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--skra-comp-icon-size: var(--skra-comp-select-icon-size)}.skra-select-placeholder{color:var(--skra-comp-select-foreground-disabled)}.skra-select-arrow{flex:none;inline-size:var(--skra-comp-select-arrow-icon-size);block-size:var(--skra-comp-select-arrow-icon-size);background-color:var(--skra-comp-select-arrow-color);mask:url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M6%209l6%206%206-6'%20fill='none'%20stroke='%23000'%20stroke-width='2'/%3E%3C/svg%3E\") no-repeat center/contain;-webkit-mask:url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M6%209l6%206%206-6'%20fill='none'%20stroke='%23000'%20stroke-width='2'/%3E%3C/svg%3E\") no-repeat center/contain;transition:transform var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing)}.skra-select-trigger[aria-expanded=true] .skra-select-arrow{transform:rotate(180deg)}.skra-select-listbox{position:fixed;margin:0;inset:auto;box-sizing:border-box;padding-block:var(--skra-comp-select-panel-padding-block);padding-inline:var(--skra-comp-select-panel-padding-inline);background:var(--skra-comp-select-panel-background);color:var(--skra-comp-select-panel-foreground);border:var(--skra-comp-select-border-width) solid var(--skra-comp-select-panel-border-color);border-radius:var(--skra-comp-select-panel-radius);box-shadow:var(--skra-comp-select-panel-shadow);inline-size:max-content;max-block-size:var(--skra-comp-select-panel-max-block-size);overflow-y:auto;flex-direction:column;opacity:0;transition:opacity var(--skra-comp-select-motion-duration) var(--skra-comp-select-motion-easing),display var(--skra-comp-select-motion-duration) allow-discrete,overlay var(--skra-comp-select-motion-duration) allow-discrete}.skra-select-listbox:popover-open{display:flex;opacity:1}@starting-style{.skra-select-listbox:popover-open{opacity:0}}@media(prefers-reduced-motion:reduce){.skra-select-trigger,.skra-select-arrow,.skra-select-listbox{transition-duration:0ms}}\n"] }]
172
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }] } });
173
+
174
+ /**
175
+ * Generated bundle index. Do not edit.
176
+ */
177
+
178
+ export { SkraMultiSelect };
179
+ //# sourceMappingURL=skra-ui-multi-select.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skra-ui-multi-select.mjs","sources":["../../../../libs/ui/multi-select/src/lib/multi-select/multi-select.ts","../../../../libs/ui/multi-select/src/skra-ui-multi-select.ts"],"sourcesContent":["import { NgTemplateOutlet } from '@angular/common';\nimport {\n ChangeDetectionStrategy,\n Component,\n ViewEncapsulation,\n afterRenderEffect,\n computed,\n input,\n output,\n} from '@angular/core';\n\nimport { _SkraSelectBase } from '@skra/ui/core';\nimport {\n SKRA_LISTBOX_PANEL,\n type SkraListboxKeyboardItem,\n} from '@skra/ui/core';\n\ndeclare const ngDevMode: boolean | undefined;\n\n/**\n * SkraMultiSelect — a custom MULTI-select listbox: the WAI-ARIA APG multi-selectable listbox combobox.\n * The multi-cardinality sibling of `skra-select` over the shared `_SkraSelectBase` (**ADR 0046** —\n * cardinality split: a separate component with an HONEST `readonly T[]` value contract, NOT a `multiple`\n * flag). The trigger (`role=\"combobox\"`) opens a top-layer listbox panel (`popover=\"auto\"`, ADR 0044,\n * `aria-multiselectable=\"true\"`) of the SAME projected `skra-option` children — reused verbatim; the\n * option swaps its indicator to a checkbox via the `SkraListboxPanel.multiple` hint.\n *\n * - **Value (ADR 037):** `value = input<readonly T[]>([])` + `valueChange = output<readonly T[]>()` —\n * consumer-owned, no `@angular/forms`. Selection is membership (`compareWith` per element).\n * - **Toggle (ADR 0046):** picking an option toggles its membership and KEEPS THE PANEL OPEN (the one\n * behavioural divergence from single-select, which closes).\n * - **Trigger summary (ADR 0045 / 033):** comma-joined `getLabel()` of selected options — the VALUE\n * display, never the accessible name. Count-style / chips → the opt-in `[skraSelectTrigger]` recipe.\n *\n * Full contract: adr/specs/component-multi-select-spec.md.\n */\n@Component({\n selector: 'skra-multi-select',\n standalone: true,\n exportAs: 'skraMultiSelect',\n imports: [NgTemplateOutlet],\n template: `\n <button\n #trigger\n class=\"skra-select-trigger\"\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.id]=\"triggerId()\"\n [attr.popovertarget]=\"panelId\"\n [attr.aria-controls]=\"panelId\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-activedescendant]=\"activeOptionId()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-invalid]=\"hasError() ? 'true' : null\"\n [attr.aria-required]=\"isRequired() ? 'true' : null\"\n [attr.aria-readonly]=\"isReadonly() ? 'true' : null\"\n [disabled]=\"isDisabled()\"\n (click)=\"onTriggerClick($event)\"\n (keydown)=\"onTriggerKeydown($event)\"\n >\n <span class=\"skra-select-trigger-value\">\n @if (triggerTemplate(); as tpl) {\n <ng-container\n [ngTemplateOutlet]=\"tpl\"\n [ngTemplateOutletContext]=\"triggerContext()\"\n ></ng-container>\n } @else if (selectedOptions().length) {\n {{ summary() }}\n } @else {\n <span class=\"skra-select-placeholder\">{{ placeholder() }}</span>\n }\n </span>\n <span class=\"skra-select-arrow\" aria-hidden=\"true\"></span>\n </button>\n\n <div\n #panel\n class=\"skra-select-listbox\"\n [id]=\"panelId\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n popover=\"auto\"\n (toggle)=\"onPanelToggle($event)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n styleUrl: './multi-select.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n encapsulation: ViewEncapsulation.Emulated,\n providers: [{ provide: SKRA_LISTBOX_PANEL, useExisting: SkraMultiSelect }],\n})\nexport class SkraMultiSelect<T = unknown> extends _SkraSelectBase<T> {\n /** Consumer-owned ARRAY value channel (ADR 037) — default `[]` (never undefined). */\n readonly value = input<readonly T[]>([]);\n /** Fires ONLY on user toggle — emits a NEW array; never on a programmatic `value()` change (ADR 037). */\n readonly valueChange = output<readonly T[]>();\n /** Cardinality hint (ADR 0046) — multi-select (drives the option checkbox indicator + aria-multiselectable). */\n readonly multiple = true;\n\n /** The options whose value is a member of `value()` — feeds the trigger summary + seed. */\n protected readonly selectedOptions = computed(() =>\n this.options().filter((o) => this.isSelected(o.value())),\n );\n\n /** Comma-joined identity labels of the selected options (value display, ADR 0045 — language-neutral). */\n protected readonly summary = computed(() =>\n this.selectedOptions()\n .map((o) => o.getLabel())\n .join(', '),\n );\n\n protected readonly triggerContext = computed(() => ({\n $implicit: this.value(),\n options: this.selectedOptions(),\n }));\n\n constructor() {\n super();\n if (typeof ngDevMode === 'undefined' || ngDevMode) {\n // Dev-only consumer warning. Uses afterRenderEffect (NOT effect) so the projected\n // options' required `value` inputs are read only AFTER each render applies their\n // bindings — an eager read during the initial CD (SSR / first render) OR a runtime\n // re-projection would throw NG0950. afterRender hooks never run on the server, so\n // this stays SSR-safe. Covers F-13 (initial render) + F-16 (runtime re-projection).\n afterRenderEffect(() => {\n const opts = this.options();\n const unmatched = this.value().filter(\n (v) => !opts.some((o) => this.compareWith()(o.value() as T, v)),\n );\n if (unmatched.length) {\n console.warn(\n '[skra-multi-select] value contains entries with no matching <skra-option> — they are ' +\n 'dropped from the trigger summary (likely a compareWith mismatch for object values).',\n );\n }\n });\n }\n }\n\n // ── SkraListboxPanel seam ──\n isSelected(value: unknown): boolean {\n return this.value().some((x) => this.compareWith()(x, value as T));\n }\n\n selectValue(value: unknown): void {\n const present = this.isSelected(value);\n const next = present\n ? this.value().filter((x) => !this.compareWith()(x, value as T))\n : [...this.value(), value as T];\n this.valueChange.emit(next);\n // NO close() — multi-select keeps the panel open for further toggling (ADR 0046).\n }\n\n protected activeSeedItem(): SkraListboxKeyboardItem | null {\n return this.selectedOptions()[0] ?? null;\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}