@lilydesignsystem/angular-motion-picker 0.1.2 → 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.
@@ -1,310 +1,346 @@
1
- import { NgTemplateOutlet } from '@angular/common';
2
- import * as i0 from '@angular/core';
3
- import { Directive, input, model, output, contentChild, TemplateRef, viewChild, signal, computed, inject, ChangeDetectorRef, effect, ChangeDetectionStrategy, Component } from '@angular/core';
4
- import { IconButton, Listbox } from '@lilydesignsystem/angular-headless';
5
-
6
- /**
7
- * Resolve a motion slug to its display label: each hyphen-separated
8
- * word title-cased, so a slug like "no-preference" renders as
9
- * "No Preference". Mirrors `sizeName` in text-size-picker and
10
- * `themeName` in theme-picker.
11
- */
1
+ import { NgTemplateOutlet } from "@angular/common";
2
+ import * as i0 from "@angular/core";
3
+ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, TemplateRef, computed, contentChild, effect, inject, input, model, output, signal, viewChild } from "@angular/core";
4
+ import { IconButton, Listbox } from "@lilydesignsystem/angular-headless";
12
5
  function motionName(motion) {
13
- return motion
14
- .split("-")
15
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
16
- .join(" ");
6
+ return motion.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
17
7
  }
18
- /**
19
- * True when the platform reports a preference for reduced motion.
20
- * SSR-safe: `window`/`matchMedia` are absent on the server, so this
21
- * resolves to `false` there and the client re-derives it on mount.
22
- */
23
8
  function prefersReducedMotion() {
24
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
25
- return false;
26
- }
27
- try {
28
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
29
- }
30
- catch {
31
- return false;
32
- }
9
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
10
+ try {
11
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
12
+ } catch {
13
+ return false;
14
+ }
33
15
  }
34
16
  let uid = 0;
35
- /** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
36
17
  function nextMotionPickerId() {
37
- uid += 1;
38
- return `motion-picker-${uid}`;
39
- }
40
- /**
41
- * Optional marker for the projected icon template. Gives consumers typed
42
- * `let-` variables:
43
- *
44
- * ```html
45
- * <lily-motion-picker ...>
46
- * <ng-template lilyMotionPickerIcon let-args>{{ args.labelFor(args.value) }}</ng-template>
47
- * </lily-motion-picker>
48
- * ```
49
- *
50
- * The component queries any projected `<ng-template>`, so the marker is
51
- * for type-checking and readability, not for matching.
52
- */
53
- class MotionPickerIcon {
54
- static ngTemplateContextGuard(_dir, _ctx) {
55
- return true;
56
- }
57
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: MotionPickerIcon, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
58
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.7", type: MotionPickerIcon, isStandalone: true, selector: "ng-template[lilyMotionPickerIcon]", ngImport: i0 }); }
18
+ uid += 1;
19
+ return `motion-picker-${uid}`;
59
20
  }
60
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: MotionPickerIcon, decorators: [{
61
- type: Directive,
62
- args: [{
63
- selector: "ng-template[lilyMotionPickerIcon]",
64
- standalone: true,
65
- }]
66
- }] });
67
- /**
68
- * MotionPicker — `data-motion` reduced-motion picker.
69
- *
70
- * Renders an icon button that opens a WAI-ARIA APG listbox of motion
71
- * slugs. On every change the component sets `data-motion="{slug}"` on
72
- * the document root (or on a consumer-supplied target), with optional
73
- * `localStorage` persistence. Its initial value defers to the
74
- * platform's `(prefers-reduced-motion: reduce)` media query before
75
- * falling back to a fixed default — the one behaviour difference from
76
- * its `theme-picker`/`text-size-picker` siblings. Ships no CSS; the
77
- * consumer decides what `[data-motion="reduce"]` actually suppresses.
78
- * See `spec/index.md` for the full contract.
79
- */
80
- class MotionPicker {
81
- constructor() {
82
- this.label = input.required(/* @ts-ignore */
83
- ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
84
- this.motions = input.required(/* @ts-ignore */
85
- ...(ngDevMode ? [{ debugName: "motions" }] : /* istanbul ignore next */ []));
86
- this.value = model("", /* @ts-ignore */
87
- ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
88
- this.defaultValue = input("", /* @ts-ignore */
89
- ...(ngDevMode ? [{ debugName: "defaultValue" }] : /* istanbul ignore next */ []));
90
- this.storageKey = input("", /* @ts-ignore */
91
- ...(ngDevMode ? [{ debugName: "storageKey" }] : /* istanbul ignore next */ []));
92
- this.name = input("motion", /* @ts-ignore */
93
- ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
94
- this.target = input(null, /* @ts-ignore */
95
- ...(ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []));
96
- this.motionLabels = input({}, /* @ts-ignore */
97
- ...(ngDevMode ? [{ debugName: "motionLabels" }] : /* istanbul ignore next */ []));
98
- this.className = input("", /* @ts-ignore */
99
- ...(ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []));
100
- this.motionChange = output();
101
- /** Projected icon template; replaces the default icon when supplied. */
102
- this.iconTemplate = contentChild(TemplateRef, /* @ts-ignore */
103
- ...(ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []));
104
- this.rootRef = viewChild.required("rootEl", /* @ts-ignore */
105
- ...(ngDevMode ? [{ debugName: "rootRef" }] : /* istanbul ignore next */ []));
106
- // Angular resolves a template-ref-variable on a component tag to the
107
- // component INSTANCE by default (not its ElementRef) — exactly what's
108
- // needed to call the headless components' own public `focus()` methods.
109
- this.buttonRef = viewChild.required("buttonEl", /* @ts-ignore */
110
- ...(ngDevMode ? [{ debugName: "buttonRef" }] : /* istanbul ignore next */ []));
111
- this.listRef = viewChild.required("listEl", /* @ts-ignore */
112
- ...(ngDevMode ? [{ debugName: "listRef" }] : /* istanbul ignore next */ []));
113
- this.baseId = nextMotionPickerId();
114
- this.listId = `${this.baseId}-list`;
115
- this.open = signal(false, /* @ts-ignore */
116
- ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
117
- this.activeIndex = signal(-1, /* @ts-ignore */
118
- ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
119
- this.childContext = computed(() => {
120
- const args = {
121
- value: this.value(),
122
- open: this.open(),
123
- labelFor: (motion) => this.labelFor(motion),
124
- };
125
- return { $implicit: args, ...args };
126
- }, /* @ts-ignore */
127
- ...(ngDevMode ? [{ debugName: "childContext" }] : /* istanbul ignore next */ []));
128
- this.initialised = false;
129
- // ---------------------------------------------------------------
130
- // Open / close
131
- // ---------------------------------------------------------------
132
- /** Under zoneless change detection the signal write that un-hides the
133
- * list has not reached the DOM when a microtask runs, so focusing the
134
- * still-hidden list fails silently and Escape lands on the button.
135
- * Flushing detection first is the Angular equivalent of Svelte's
136
- * synchronous update (same fix the date-time-picker port recorded). */
137
- this.cdr = inject(ChangeDetectorRef);
138
- // Arrow/Home/End/PageUp/PageDown/typeahead/Escape/Tab keyboard handling
139
- // inside the open list is owned by the composed `lily-listbox`'s
140
- // `navigation="active-descendant"` mode (see
141
- // @lilydesignsystem/angular-headless); this component only decides
142
- // what open/close/choose/scroll mean, and keeps the highlighted option
143
- // in view whenever activeIndex changes.
144
- this.scrollOnActiveIndexChange = effect(() => {
145
- this.activeIndex();
146
- this.scrollActiveIntoView();
147
- }, /* @ts-ignore */
148
- ...(ngDevMode ? [{ debugName: "scrollOnActiveIndexChange" }] : /* istanbul ignore next */ []));
149
- effect(() => {
150
- const current = this.value();
151
- if (!this.initialised) {
152
- this.initialised = true;
153
- let initial = current;
154
- const sk = this.storageKey();
155
- if (!initial && sk) {
156
- try {
157
- initial =
158
- (typeof localStorage !== "undefined"
159
- ? localStorage.getItem(sk)
160
- : null) ?? "";
161
- }
162
- catch {
163
- // ignore privacy errors
164
- }
165
- }
166
- if (!initial) {
167
- // Unlike text-size-picker's "medium" default, motion has a
168
- // real external signal to defer to: the platform's own
169
- // (prefers-reduced-motion: reduce) media query.
170
- const motions = this.motions();
171
- const dv = this.defaultValue();
172
- const osPreferred = prefersReducedMotion() ? "reduce" : "no-preference";
173
- initial =
174
- dv ||
175
- (motions.includes(osPreferred) ? osPreferred : undefined) ||
176
- motions[0] ||
177
- "";
178
- }
179
- if (initial && initial !== current) {
180
- this.value.set(initial);
181
- return;
182
- }
183
- }
184
- if (current)
185
- this.applyMotion(current);
186
- });
187
- }
188
- optionId(index) {
189
- return `${this.baseId}-option-${index}`;
190
- }
191
- labelFor(motion) {
192
- const labels = this.motionLabels();
193
- if (motion in labels)
194
- return labels[motion];
195
- return motionName(motion);
196
- }
197
- toggle() {
198
- if (this.open())
199
- this.closeList();
200
- else
201
- this.openList();
202
- }
203
- /** Open the listbox, activating `startIndex` (default: the selection). */
204
- openList(startIndex) {
205
- const selected = this.motions().indexOf(this.value());
206
- // An empty list has no option to activate; -1 keeps
207
- // aria-activedescendant off rather than pointing at an id that
208
- // does not exist.
209
- this.activeIndex.set(this.motions().length === 0
210
- ? -1
211
- : (startIndex ?? (selected >= 0 ? selected : 0)));
212
- this.open.set(true);
213
- // Focus moves to the listbox; the active option is conveyed via
214
- // aria-activedescendant, per the APG listbox pattern.
215
- this.cdr.detectChanges();
216
- queueMicrotask(() => {
217
- this.listRef().focus({ preventScroll: true });
218
- this.scrollActiveIntoView();
219
- });
220
- }
221
- /** Close the listbox; `refocus` returns focus to the button. */
222
- closeList(refocus = true) {
223
- if (!this.open())
224
- return;
225
- this.open.set(false);
226
- this.activeIndex.set(-1);
227
- if (refocus)
228
- queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));
229
- }
230
- choose(index) {
231
- const slug = this.motions()[index];
232
- if (slug)
233
- this.value.set(slug);
234
- this.closeList();
235
- }
236
- scrollActiveIntoView() {
237
- const i = this.activeIndex();
238
- if (i < 0)
239
- return;
240
- // getElementById rather than reaching into the composed Listbox's
241
- // DOM: Listbox exposes behaviour (focus()) and state (activeIndex),
242
- // not its rendered children, the same "consumer owns the option
243
- // elements" division of responsibility the headless component
244
- // documents for itself.
245
- const el = document.getElementById(this.optionId(i));
246
- // jsdom does not implement scrollIntoView; call it only if present.
247
- el?.scrollIntoView?.({ block: "nearest" });
248
- }
249
- // ---------------------------------------------------------------
250
- // Keyboard
251
- // ---------------------------------------------------------------
252
- onButtonKeydown(event) {
253
- switch (event.key) {
254
- case "ArrowDown":
255
- case "Enter":
256
- case " ":
257
- event.preventDefault();
258
- this.openList();
259
- break;
260
- case "ArrowUp":
261
- event.preventDefault();
262
- this.openList(this.motions().length - 1);
263
- break;
264
- }
265
- }
266
- /** `lily-listbox` Tab handling never prevents the key — focus goes to
267
- * the button FIRST so the browser's default Tab move computes from
268
- * the picker's position rather than from <body> (hiding the focused
269
- * list drops focus there, and the browser would otherwise compute the
270
- * default Tab move from the top of the document). */
271
- onListTabOut() {
272
- this.buttonRef().focus({ preventScroll: true });
273
- this.closeList(false);
274
- }
275
- onRootFocusOut(event) {
276
- const next = event.relatedTarget;
277
- if (next && this.rootRef().nativeElement.contains(next))
278
- return;
279
- this.closeList(false);
280
- }
281
- onDocumentClick(event) {
282
- if (!this.open())
283
- return;
284
- const t = event.target;
285
- if (t && !this.rootRef().nativeElement.contains(t))
286
- this.closeList(false);
287
- }
288
- // ---------------------------------------------------------------
289
- // Apply
290
- // ---------------------------------------------------------------
291
- applyMotion(slug) {
292
- if (typeof document === "undefined" || !slug)
293
- return;
294
- (this.target() ?? document.documentElement).setAttribute("data-motion", slug);
295
- const sk = this.storageKey();
296
- if (sk) {
297
- try {
298
- localStorage.setItem(sk, slug);
299
- }
300
- catch {
301
- // ignore quota / privacy errors
302
- }
303
- }
304
- this.motionChange.emit(slug);
305
- }
306
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: MotionPicker, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
307
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: MotionPicker, isStandalone: true, selector: "lily-motion-picker", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, motions: { classPropertyName: "motions", publicName: "motions", isSignal: true, isRequired: true, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, defaultValue: { classPropertyName: "defaultValue", publicName: "defaultValue", isSignal: true, isRequired: false, transformFunction: null }, storageKey: { classPropertyName: "storageKey", publicName: "storageKey", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, target: { classPropertyName: "target", publicName: "target", isSignal: true, isRequired: false, transformFunction: null }, motionLabels: { classPropertyName: "motionLabels", publicName: "motionLabels", isSignal: true, isRequired: false, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", motionChange: "motionChange" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, queries: [{ propertyName: "iconTemplate", first: true, predicate: TemplateRef, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "rootRef", first: true, predicate: ["rootEl"], descendants: true, isSignal: true }, { propertyName: "buttonRef", first: true, predicate: ["buttonEl"], descendants: true, isSignal: true }, { propertyName: "listRef", first: true, predicate: ["listEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
21
+ var MotionPickerIcon = class MotionPickerIcon {
22
+ static ngTemplateContextGuard(_dir, _ctx) {
23
+ return true;
24
+ }
25
+ static {
26
+ this.ɵfac = i0.ɵɵngDeclareFactory({
27
+ minVersion: "12.0.0",
28
+ version: "22.2.1",
29
+ ngImport: i0,
30
+ type: MotionPickerIcon,
31
+ deps: [],
32
+ target: i0.ɵɵFactoryTarget.Directive
33
+ });
34
+ }
35
+ static {
36
+ this.ɵdir = i0.ɵɵngDeclareDirective({
37
+ minVersion: "14.0.0",
38
+ version: "22.2.1",
39
+ type: MotionPickerIcon,
40
+ isStandalone: true,
41
+ selector: "ng-template[lilyMotionPickerIcon]",
42
+ ngImport: i0
43
+ });
44
+ }
45
+ };
46
+ i0.ɵɵngDeclareClassMetadata({
47
+ minVersion: "12.0.0",
48
+ version: "22.2.1",
49
+ ngImport: i0,
50
+ type: MotionPickerIcon,
51
+ decorators: [{
52
+ type: Directive,
53
+ args: [{
54
+ selector: "ng-template[lilyMotionPickerIcon]",
55
+ standalone: true
56
+ }]
57
+ }]
58
+ });
59
+ var MotionPicker = class MotionPicker {
60
+ onTooltipButtonEnter() {
61
+ this.hoverButton.set(true);
62
+ this.dismissed.set(false);
63
+ }
64
+ onTooltipButtonFocusIn() {
65
+ try {
66
+ this.focusButton.set(this.buttonRef().element?.matches(":focus-visible") ?? false);
67
+ } catch {
68
+ this.focusButton.set(true);
69
+ }
70
+ }
71
+ onTooltipButtonFocusOut() {
72
+ this.focusButton.set(false);
73
+ this.dismissed.set(false);
74
+ }
75
+ onTooltipKeydown(event) {
76
+ if (event.key === "Escape" && this.tooltipVisible()) this.dismissed.set(true);
77
+ }
78
+ constructor() {
79
+ this.label = input.required(...ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []);
80
+ this.motions = input.required(...ngDevMode ? [{ debugName: "motions" }] : /* istanbul ignore next */ []);
81
+ this.value = model("", ...ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []);
82
+ this.defaultValue = input("", ...ngDevMode ? [{ debugName: "defaultValue" }] : /* istanbul ignore next */ []);
83
+ this.storageKey = input("", ...ngDevMode ? [{ debugName: "storageKey" }] : /* istanbul ignore next */ []);
84
+ this.name = input("motion", ...ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []);
85
+ this.target = input(null, ...ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []);
86
+ this.motionLabels = input({}, ...ngDevMode ? [{ debugName: "motionLabels" }] : /* istanbul ignore next */ []);
87
+ this.className = input("", ...ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []);
88
+ this.motionChange = output();
89
+ this.iconTemplate = contentChild(TemplateRef, ...ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []);
90
+ this.rootRef = viewChild.required("rootEl", ...ngDevMode ? [{ debugName: "rootRef" }] : /* istanbul ignore next */ []);
91
+ this.buttonRef = viewChild.required("buttonEl", ...ngDevMode ? [{ debugName: "buttonRef" }] : /* istanbul ignore next */ []);
92
+ this.listRef = viewChild.required("listEl", ...ngDevMode ? [{ debugName: "listRef" }] : /* istanbul ignore next */ []);
93
+ this.baseId = nextMotionPickerId();
94
+ this.listId = `${this.baseId}-list`;
95
+ this.open = signal(false, ...ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []);
96
+ this.tooltipId = `${this.baseId}-tooltip`;
97
+ this.hoverButton = signal(false, ...ngDevMode ? [{ debugName: "hoverButton" }] : /* istanbul ignore next */ []);
98
+ this.hoverTooltip = signal(false, ...ngDevMode ? [{ debugName: "hoverTooltip" }] : /* istanbul ignore next */ []);
99
+ this.focusButton = signal(false, ...ngDevMode ? [{ debugName: "focusButton" }] : /* istanbul ignore next */ []);
100
+ this.dismissed = signal(false, ...ngDevMode ? [{ debugName: "dismissed" }] : /* istanbul ignore next */ []);
101
+ this.tooltipVisible = computed(() => !this.open() && !this.dismissed() && (this.hoverButton() || this.hoverTooltip() || this.focusButton()), ...ngDevMode ? [{ debugName: "tooltipVisible" }] : /* istanbul ignore next */ []);
102
+ this.tooltipEscapeListener = effect((onCleanup) => {
103
+ if (!this.tooltipVisible() || typeof document === "undefined") return;
104
+ const onKey = (event) => {
105
+ if (event.key === "Escape") this.dismissed.set(true);
106
+ };
107
+ document.addEventListener("keydown", onKey);
108
+ onCleanup(() => document.removeEventListener("keydown", onKey));
109
+ }, ...ngDevMode ? [{ debugName: "tooltipEscapeListener" }] : /* istanbul ignore next */ []);
110
+ this.activeIndex = signal(-1, ...ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []);
111
+ this.childContext = computed(() => {
112
+ const args = {
113
+ value: this.value(),
114
+ open: this.open(),
115
+ labelFor: (motion) => this.labelFor(motion)
116
+ };
117
+ return {
118
+ $implicit: args,
119
+ ...args
120
+ };
121
+ }, ...ngDevMode ? [{ debugName: "childContext" }] : /* istanbul ignore next */ []);
122
+ this.initialised = false;
123
+ this.cdr = inject(ChangeDetectorRef);
124
+ this.scrollOnActiveIndexChange = effect(() => {
125
+ this.activeIndex();
126
+ this.scrollActiveIntoView();
127
+ }, ...ngDevMode ? [{ debugName: "scrollOnActiveIndexChange" }] : /* istanbul ignore next */ []);
128
+ effect(() => {
129
+ const current = this.value();
130
+ if (!this.initialised) {
131
+ this.initialised = true;
132
+ let initial = current;
133
+ const sk = this.storageKey();
134
+ if (!initial && sk) try {
135
+ initial = (typeof localStorage !== "undefined" ? localStorage.getItem(sk) : null) ?? "";
136
+ } catch {}
137
+ if (!initial) {
138
+ const motions = this.motions();
139
+ const dv = this.defaultValue();
140
+ const osPreferred = prefersReducedMotion() ? "reduce" : "no-preference";
141
+ initial = dv || (motions.includes(osPreferred) ? osPreferred : void 0) || motions[0] || "";
142
+ }
143
+ if (initial && initial !== current) {
144
+ this.value.set(initial);
145
+ return;
146
+ }
147
+ }
148
+ if (current) this.applyMotion(current);
149
+ });
150
+ }
151
+ optionId(index) {
152
+ return `${this.baseId}-option-${index}`;
153
+ }
154
+ labelFor(motion) {
155
+ const labels = this.motionLabels();
156
+ if (motion in labels) return labels[motion];
157
+ return motionName(motion);
158
+ }
159
+ toggle() {
160
+ if (this.open()) this.closeList();
161
+ else this.openList();
162
+ }
163
+ openList(startIndex) {
164
+ const selected = this.motions().indexOf(this.value());
165
+ this.activeIndex.set(this.motions().length === 0 ? -1 : startIndex ?? (selected >= 0 ? selected : 0));
166
+ this.open.set(true);
167
+ this.cdr.detectChanges();
168
+ queueMicrotask(() => {
169
+ this.listRef().focus({ preventScroll: true });
170
+ this.scrollActiveIntoView();
171
+ });
172
+ }
173
+ closeList(refocus = true) {
174
+ if (!this.open()) return;
175
+ this.open.set(false);
176
+ this.activeIndex.set(-1);
177
+ if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));
178
+ }
179
+ choose(index) {
180
+ const slug = this.motions()[index];
181
+ if (slug) this.value.set(slug);
182
+ this.closeList();
183
+ }
184
+ scrollActiveIntoView() {
185
+ const i = this.activeIndex();
186
+ if (i < 0) return;
187
+ document.getElementById(this.optionId(i))?.scrollIntoView?.({ block: "nearest" });
188
+ }
189
+ onButtonKeydown(event) {
190
+ switch (event.key) {
191
+ case "ArrowDown":
192
+ case "Enter":
193
+ case " ":
194
+ event.preventDefault();
195
+ this.openList();
196
+ break;
197
+ case "ArrowUp":
198
+ event.preventDefault();
199
+ this.openList(this.motions().length - 1);
200
+ }
201
+ }
202
+ onListTabOut() {
203
+ this.buttonRef().focus({ preventScroll: true });
204
+ this.closeList(false);
205
+ }
206
+ onRootFocusOut(event) {
207
+ const next = event.relatedTarget;
208
+ if (next && this.rootRef().nativeElement.contains(next)) return;
209
+ this.closeList(false);
210
+ }
211
+ onDocumentClick(event) {
212
+ if (!this.open()) return;
213
+ const t = event.target;
214
+ if (t && !this.rootRef().nativeElement.contains(t)) this.closeList(false);
215
+ }
216
+ applyMotion(slug) {
217
+ if (typeof document === "undefined" || !slug) return;
218
+ (this.target() ?? document.documentElement).setAttribute("data-motion", slug);
219
+ const sk = this.storageKey();
220
+ if (sk) try {
221
+ localStorage.setItem(sk, slug);
222
+ } catch {}
223
+ this.motionChange.emit(slug);
224
+ }
225
+ static {
226
+ this.ɵfac = i0.ɵɵngDeclareFactory({
227
+ minVersion: "12.0.0",
228
+ version: "22.2.1",
229
+ ngImport: i0,
230
+ type: MotionPicker,
231
+ deps: [],
232
+ target: i0.ɵɵFactoryTarget.Component
233
+ });
234
+ }
235
+ static {
236
+ this.ɵcmp = i0.ɵɵngDeclareComponent({
237
+ minVersion: "17.0.0",
238
+ version: "22.2.1",
239
+ type: MotionPicker,
240
+ isStandalone: true,
241
+ selector: "lily-motion-picker",
242
+ inputs: {
243
+ label: {
244
+ classPropertyName: "label",
245
+ publicName: "label",
246
+ isSignal: true,
247
+ isRequired: true,
248
+ transformFunction: null
249
+ },
250
+ motions: {
251
+ classPropertyName: "motions",
252
+ publicName: "motions",
253
+ isSignal: true,
254
+ isRequired: true,
255
+ transformFunction: null
256
+ },
257
+ value: {
258
+ classPropertyName: "value",
259
+ publicName: "value",
260
+ isSignal: true,
261
+ isRequired: false,
262
+ transformFunction: null
263
+ },
264
+ defaultValue: {
265
+ classPropertyName: "defaultValue",
266
+ publicName: "defaultValue",
267
+ isSignal: true,
268
+ isRequired: false,
269
+ transformFunction: null
270
+ },
271
+ storageKey: {
272
+ classPropertyName: "storageKey",
273
+ publicName: "storageKey",
274
+ isSignal: true,
275
+ isRequired: false,
276
+ transformFunction: null
277
+ },
278
+ name: {
279
+ classPropertyName: "name",
280
+ publicName: "name",
281
+ isSignal: true,
282
+ isRequired: false,
283
+ transformFunction: null
284
+ },
285
+ target: {
286
+ classPropertyName: "target",
287
+ publicName: "target",
288
+ isSignal: true,
289
+ isRequired: false,
290
+ transformFunction: null
291
+ },
292
+ motionLabels: {
293
+ classPropertyName: "motionLabels",
294
+ publicName: "motionLabels",
295
+ isSignal: true,
296
+ isRequired: false,
297
+ transformFunction: null
298
+ },
299
+ className: {
300
+ classPropertyName: "className",
301
+ publicName: "className",
302
+ isSignal: true,
303
+ isRequired: false,
304
+ transformFunction: null
305
+ }
306
+ },
307
+ outputs: {
308
+ value: "valueChange",
309
+ motionChange: "motionChange"
310
+ },
311
+ host: { listeners: { "document:click": "onDocumentClick($event)" } },
312
+ queries: [{
313
+ propertyName: "iconTemplate",
314
+ first: true,
315
+ predicate: TemplateRef,
316
+ descendants: true,
317
+ isSignal: true
318
+ }],
319
+ viewQueries: [
320
+ {
321
+ propertyName: "rootRef",
322
+ first: true,
323
+ predicate: ["rootEl"],
324
+ descendants: true,
325
+ isSignal: true
326
+ },
327
+ {
328
+ propertyName: "buttonRef",
329
+ first: true,
330
+ predicate: ["buttonEl"],
331
+ descendants: true,
332
+ isSignal: true
333
+ },
334
+ {
335
+ propertyName: "listRef",
336
+ first: true,
337
+ predicate: ["listEl"],
338
+ descendants: true,
339
+ isSignal: true
340
+ }
341
+ ],
342
+ ngImport: i0,
343
+ template: `
308
344
  <div
309
345
  #rootEl
310
346
  class="motion-picker {{ className() }}"
@@ -319,8 +355,12 @@ class MotionPicker {
319
355
  ariaHaspopup="listbox"
320
356
  [ariaExpanded]="open()"
321
357
  [ariaControls]="listId"
322
- (click)="toggle()"
323
- (keydown)="onButtonKeydown($event)"
358
+ (click)="toggle(); hoverButton.set(false)"
359
+ (mouseenter)="onTooltipButtonEnter()"
360
+ (mouseleave)="hoverButton.set(false)"
361
+ (focusin)="onTooltipButtonFocusIn()"
362
+ (focusout)="onTooltipButtonFocusOut()"
363
+ (keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
324
364
  >
325
365
  @if (iconTemplate(); as tpl) {
326
366
  <ng-container
@@ -345,6 +385,18 @@ class MotionPicker {
345
385
  }
346
386
  </lily-icon-button>
347
387
 
388
+ <!-- Purely visual: the same text is already the button's aria-label,
389
+ so it is not wired with aria-describedby (that would announce
390
+ the name twice). -->
391
+ <div
392
+ class="motion-picker-tooltip"
393
+ role="tooltip"
394
+ [id]="tooltipId"
395
+ [attr.hidden]="tooltipVisible() ? null : ''"
396
+ (mouseenter)="hoverTooltip.set(true)"
397
+ (mouseleave)="hoverTooltip.set(false)"
398
+ >{{ label() }}</div>
399
+
348
400
  <lily-listbox
349
401
  #listEl
350
402
  [label]="label()"
@@ -374,19 +426,80 @@ class MotionPicker {
374
426
  }
375
427
  </lily-listbox>
376
428
  </div>
377
- `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: IconButton, selector: "lily-icon-button", inputs: ["label", "className", "baseClass", "ariaHaspopup", "ariaExpanded", "ariaControls", "tabIndex", "disabled"] }, { kind: "component", type: Listbox, selector: "lily-listbox", inputs: ["label", "className", "baseClass", "elementId", "hidden", "navigation", "clamp", "typeahead", "pageSize", "activeIndex"], outputs: ["activeIndexChange", "activate", "escape", "tabOut"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
378
- }
379
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: MotionPicker, decorators: [{
380
- type: Component,
381
- args: [{
382
- selector: "lily-motion-picker",
383
- standalone: true,
384
- imports: [NgTemplateOutlet, IconButton, Listbox],
385
- changeDetection: ChangeDetectionStrategy.OnPush,
386
- host: {
387
- "(document:click)": "onDocumentClick($event)",
388
- },
389
- template: `
429
+ `,
430
+ isInline: true,
431
+ dependencies: [
432
+ {
433
+ kind: "directive",
434
+ type: NgTemplateOutlet,
435
+ selector: "[ngTemplateOutlet]",
436
+ inputs: [
437
+ "ngTemplateOutletContext",
438
+ "ngTemplateOutlet",
439
+ "ngTemplateOutletInjector"
440
+ ]
441
+ },
442
+ {
443
+ kind: "component",
444
+ type: IconButton,
445
+ selector: "lily-icon-button",
446
+ inputs: [
447
+ "label",
448
+ "className",
449
+ "baseClass",
450
+ "ariaHaspopup",
451
+ "ariaExpanded",
452
+ "ariaControls",
453
+ "tabIndex",
454
+ "disabled"
455
+ ]
456
+ },
457
+ {
458
+ kind: "component",
459
+ type: Listbox,
460
+ selector: "lily-listbox",
461
+ inputs: [
462
+ "label",
463
+ "className",
464
+ "baseClass",
465
+ "elementId",
466
+ "hidden",
467
+ "navigation",
468
+ "clamp",
469
+ "typeahead",
470
+ "pageSize",
471
+ "activeIndex"
472
+ ],
473
+ outputs: [
474
+ "activeIndexChange",
475
+ "activate",
476
+ "escape",
477
+ "tabOut"
478
+ ]
479
+ }
480
+ ],
481
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
482
+ });
483
+ }
484
+ };
485
+ i0.ɵɵngDeclareClassMetadata({
486
+ minVersion: "12.0.0",
487
+ version: "22.2.1",
488
+ ngImport: i0,
489
+ type: MotionPicker,
490
+ decorators: [{
491
+ type: Component,
492
+ args: [{
493
+ selector: "lily-motion-picker",
494
+ standalone: true,
495
+ imports: [
496
+ NgTemplateOutlet,
497
+ IconButton,
498
+ Listbox
499
+ ],
500
+ changeDetection: ChangeDetectionStrategy.OnPush,
501
+ host: { "(document:click)": "onDocumentClick($event)" },
502
+ template: `
390
503
  <div
391
504
  #rootEl
392
505
  class="motion-picker {{ className() }}"
@@ -401,8 +514,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
401
514
  ariaHaspopup="listbox"
402
515
  [ariaExpanded]="open()"
403
516
  [ariaControls]="listId"
404
- (click)="toggle()"
405
- (keydown)="onButtonKeydown($event)"
517
+ (click)="toggle(); hoverButton.set(false)"
518
+ (mouseenter)="onTooltipButtonEnter()"
519
+ (mouseleave)="hoverButton.set(false)"
520
+ (focusin)="onTooltipButtonFocusIn()"
521
+ (focusout)="onTooltipButtonFocusOut()"
522
+ (keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
406
523
  >
407
524
  @if (iconTemplate(); as tpl) {
408
525
  <ng-container
@@ -427,6 +544,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
427
544
  }
428
545
  </lily-icon-button>
429
546
 
547
+ <!-- Purely visual: the same text is already the button's aria-label,
548
+ so it is not wired with aria-describedby (that would announce
549
+ the name twice). -->
550
+ <div
551
+ class="motion-picker-tooltip"
552
+ role="tooltip"
553
+ [id]="tooltipId"
554
+ [attr.hidden]="tooltipVisible() ? null : ''"
555
+ (mouseenter)="hoverTooltip.set(true)"
556
+ (mouseleave)="hoverTooltip.set(false)"
557
+ >{{ label() }}</div>
558
+
430
559
  <lily-listbox
431
560
  #listEl
432
561
  [label]="label()"
@@ -456,13 +585,108 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
456
585
  }
457
586
  </lily-listbox>
458
587
  </div>
459
- `,
460
- }]
461
- }], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], motions: [{ type: i0.Input, args: [{ isSignal: true, alias: "motions", required: true }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], defaultValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultValue", required: false }] }], storageKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "storageKey", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], target: [{ type: i0.Input, args: [{ isSignal: true, alias: "target", required: false }] }], motionLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "motionLabels", required: false }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], motionChange: [{ type: i0.Output, args: ["motionChange"] }], iconTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }], rootRef: [{ type: i0.ViewChild, args: ["rootEl", { isSignal: true }] }], buttonRef: [{ type: i0.ViewChild, args: ["buttonEl", { isSignal: true }] }], listRef: [{ type: i0.ViewChild, args: ["listEl", { isSignal: true }] }] } });
462
-
463
- /**
464
- * Generated bundle index. Do not edit.
465
- */
466
-
588
+ `
589
+ }]
590
+ }],
591
+ ctorParameters: () => [],
592
+ propDecorators: {
593
+ label: [{
594
+ type: i0.Input,
595
+ args: [{
596
+ isSignal: true,
597
+ alias: "label",
598
+ required: true
599
+ }]
600
+ }],
601
+ motions: [{
602
+ type: i0.Input,
603
+ args: [{
604
+ isSignal: true,
605
+ alias: "motions",
606
+ required: true
607
+ }]
608
+ }],
609
+ value: [{
610
+ type: i0.Input,
611
+ args: [{
612
+ isSignal: true,
613
+ alias: "value",
614
+ required: false
615
+ }]
616
+ }, {
617
+ type: i0.Output,
618
+ args: ["valueChange"]
619
+ }],
620
+ defaultValue: [{
621
+ type: i0.Input,
622
+ args: [{
623
+ isSignal: true,
624
+ alias: "defaultValue",
625
+ required: false
626
+ }]
627
+ }],
628
+ storageKey: [{
629
+ type: i0.Input,
630
+ args: [{
631
+ isSignal: true,
632
+ alias: "storageKey",
633
+ required: false
634
+ }]
635
+ }],
636
+ name: [{
637
+ type: i0.Input,
638
+ args: [{
639
+ isSignal: true,
640
+ alias: "name",
641
+ required: false
642
+ }]
643
+ }],
644
+ target: [{
645
+ type: i0.Input,
646
+ args: [{
647
+ isSignal: true,
648
+ alias: "target",
649
+ required: false
650
+ }]
651
+ }],
652
+ motionLabels: [{
653
+ type: i0.Input,
654
+ args: [{
655
+ isSignal: true,
656
+ alias: "motionLabels",
657
+ required: false
658
+ }]
659
+ }],
660
+ className: [{
661
+ type: i0.Input,
662
+ args: [{
663
+ isSignal: true,
664
+ alias: "className",
665
+ required: false
666
+ }]
667
+ }],
668
+ motionChange: [{
669
+ type: i0.Output,
670
+ args: ["motionChange"]
671
+ }],
672
+ iconTemplate: [{
673
+ type: i0.ContentChild,
674
+ args: [i0.forwardRef(() => TemplateRef), { isSignal: true }]
675
+ }],
676
+ rootRef: [{
677
+ type: i0.ViewChild,
678
+ args: ["rootEl", { isSignal: true }]
679
+ }],
680
+ buttonRef: [{
681
+ type: i0.ViewChild,
682
+ args: ["buttonEl", { isSignal: true }]
683
+ }],
684
+ listRef: [{
685
+ type: i0.ViewChild,
686
+ args: ["listEl", { isSignal: true }]
687
+ }]
688
+ }
689
+ });
467
690
  export { MotionPicker, MotionPickerIcon, motionName, nextMotionPickerId, prefersReducedMotion };
468
- //# sourceMappingURL=lilydesignsystem-angular-motion-picker.mjs.map
691
+
692
+ //# sourceMappingURL=lilydesignsystem-angular-motion-picker.mjs.map