@lilydesignsystem/angular-motion-picker 0.1.2 → 0.3.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,365 @@
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 = model(false, ...ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []);
96
+ this.disabled = input(false, ...ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []);
97
+ this.closeWhenDisabled = effect(() => {
98
+ if (this.disabled() && this.open()) this.open.set(false);
99
+ }, ...ngDevMode ? [{ debugName: "closeWhenDisabled" }] : /* istanbul ignore next */ []);
100
+ this.tooltipId = `${this.baseId}-tooltip`;
101
+ this.hoverButton = signal(false, ...ngDevMode ? [{ debugName: "hoverButton" }] : /* istanbul ignore next */ []);
102
+ this.hoverTooltip = signal(false, ...ngDevMode ? [{ debugName: "hoverTooltip" }] : /* istanbul ignore next */ []);
103
+ this.focusButton = signal(false, ...ngDevMode ? [{ debugName: "focusButton" }] : /* istanbul ignore next */ []);
104
+ this.dismissed = signal(false, ...ngDevMode ? [{ debugName: "dismissed" }] : /* istanbul ignore next */ []);
105
+ this.tooltipVisible = computed(() => !this.open() && !this.dismissed() && (this.hoverButton() || this.hoverTooltip() || this.focusButton()), ...ngDevMode ? [{ debugName: "tooltipVisible" }] : /* istanbul ignore next */ []);
106
+ this.tooltipEscapeListener = effect((onCleanup) => {
107
+ if (!this.tooltipVisible() || typeof document === "undefined") return;
108
+ const onKey = (event) => {
109
+ if (event.key === "Escape") this.dismissed.set(true);
110
+ };
111
+ document.addEventListener("keydown", onKey);
112
+ onCleanup(() => document.removeEventListener("keydown", onKey));
113
+ }, ...ngDevMode ? [{ debugName: "tooltipEscapeListener" }] : /* istanbul ignore next */ []);
114
+ this.activeIndex = signal(-1, ...ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []);
115
+ this.childContext = computed(() => {
116
+ const args = {
117
+ value: this.value(),
118
+ open: this.open(),
119
+ labelFor: (motion) => this.labelFor(motion)
120
+ };
121
+ return {
122
+ $implicit: args,
123
+ ...args
124
+ };
125
+ }, ...ngDevMode ? [{ debugName: "childContext" }] : /* istanbul ignore next */ []);
126
+ this.initialised = false;
127
+ this.cdr = inject(ChangeDetectorRef);
128
+ this.scrollOnActiveIndexChange = effect(() => {
129
+ this.activeIndex();
130
+ this.scrollActiveIntoView();
131
+ }, ...ngDevMode ? [{ debugName: "scrollOnActiveIndexChange" }] : /* istanbul ignore next */ []);
132
+ effect(() => {
133
+ const current = this.value();
134
+ if (!this.initialised) {
135
+ this.initialised = true;
136
+ let initial = current;
137
+ const sk = this.storageKey();
138
+ if (!initial && sk) try {
139
+ initial = (typeof localStorage !== "undefined" ? localStorage.getItem(sk) : null) ?? "";
140
+ } catch {}
141
+ if (!initial) {
142
+ const motions = this.motions();
143
+ const dv = this.defaultValue();
144
+ const osPreferred = prefersReducedMotion() ? "reduce" : "no-preference";
145
+ initial = dv || (motions.includes(osPreferred) ? osPreferred : void 0) || motions[0] || "";
146
+ }
147
+ if (initial && initial !== current) {
148
+ this.value.set(initial);
149
+ return;
150
+ }
151
+ }
152
+ if (current) this.applyMotion(current);
153
+ });
154
+ }
155
+ optionId(index) {
156
+ return `${this.baseId}-option-${index}`;
157
+ }
158
+ labelFor(motion) {
159
+ const labels = this.motionLabels();
160
+ if (motion in labels) return labels[motion];
161
+ return motionName(motion);
162
+ }
163
+ toggle() {
164
+ if (this.open()) this.closeList();
165
+ else this.openList();
166
+ }
167
+ openList(startIndex) {
168
+ const selected = this.motions().indexOf(this.value());
169
+ this.activeIndex.set(this.motions().length === 0 ? -1 : startIndex ?? (selected >= 0 ? selected : 0));
170
+ this.open.set(true);
171
+ this.cdr.detectChanges();
172
+ queueMicrotask(() => {
173
+ this.listRef().focus({ preventScroll: true });
174
+ this.scrollActiveIntoView();
175
+ });
176
+ }
177
+ closeList(refocus = true) {
178
+ if (!this.open()) return;
179
+ this.open.set(false);
180
+ this.activeIndex.set(-1);
181
+ if (refocus) queueMicrotask(() => this.buttonRef().focus({ preventScroll: true }));
182
+ }
183
+ choose(index) {
184
+ const slug = this.motions()[index];
185
+ if (slug) this.value.set(slug);
186
+ this.closeList();
187
+ }
188
+ scrollActiveIntoView() {
189
+ const i = this.activeIndex();
190
+ if (i < 0) return;
191
+ document.getElementById(this.optionId(i))?.scrollIntoView?.({ block: "nearest" });
192
+ }
193
+ onButtonKeydown(event) {
194
+ switch (event.key) {
195
+ case "ArrowDown":
196
+ case "Enter":
197
+ case " ":
198
+ event.preventDefault();
199
+ this.openList();
200
+ break;
201
+ case "ArrowUp":
202
+ event.preventDefault();
203
+ this.openList(this.motions().length - 1);
204
+ }
205
+ }
206
+ onListTabOut() {
207
+ this.buttonRef().focus({ preventScroll: true });
208
+ this.closeList(false);
209
+ }
210
+ onRootFocusOut(event) {
211
+ const next = event.relatedTarget;
212
+ if (next && this.rootRef().nativeElement.contains(next)) return;
213
+ this.closeList(false);
214
+ }
215
+ onDocumentClick(event) {
216
+ if (!this.open()) return;
217
+ const t = event.target;
218
+ if (t && !this.rootRef().nativeElement.contains(t)) this.closeList(false);
219
+ }
220
+ applyMotion(slug) {
221
+ if (typeof document === "undefined" || !slug) return;
222
+ (this.target() ?? document.documentElement).setAttribute("data-motion", slug);
223
+ const sk = this.storageKey();
224
+ if (sk) try {
225
+ localStorage.setItem(sk, slug);
226
+ } catch {}
227
+ this.motionChange.emit(slug);
228
+ }
229
+ static {
230
+ this.ɵfac = i0.ɵɵngDeclareFactory({
231
+ minVersion: "12.0.0",
232
+ version: "22.2.1",
233
+ ngImport: i0,
234
+ type: MotionPicker,
235
+ deps: [],
236
+ target: i0.ɵɵFactoryTarget.Component
237
+ });
238
+ }
239
+ static {
240
+ this.ɵcmp = i0.ɵɵngDeclareComponent({
241
+ minVersion: "17.0.0",
242
+ version: "22.2.1",
243
+ type: MotionPicker,
244
+ isStandalone: true,
245
+ selector: "lily-motion-picker",
246
+ inputs: {
247
+ label: {
248
+ classPropertyName: "label",
249
+ publicName: "label",
250
+ isSignal: true,
251
+ isRequired: true,
252
+ transformFunction: null
253
+ },
254
+ motions: {
255
+ classPropertyName: "motions",
256
+ publicName: "motions",
257
+ isSignal: true,
258
+ isRequired: true,
259
+ transformFunction: null
260
+ },
261
+ value: {
262
+ classPropertyName: "value",
263
+ publicName: "value",
264
+ isSignal: true,
265
+ isRequired: false,
266
+ transformFunction: null
267
+ },
268
+ defaultValue: {
269
+ classPropertyName: "defaultValue",
270
+ publicName: "defaultValue",
271
+ isSignal: true,
272
+ isRequired: false,
273
+ transformFunction: null
274
+ },
275
+ storageKey: {
276
+ classPropertyName: "storageKey",
277
+ publicName: "storageKey",
278
+ isSignal: true,
279
+ isRequired: false,
280
+ transformFunction: null
281
+ },
282
+ name: {
283
+ classPropertyName: "name",
284
+ publicName: "name",
285
+ isSignal: true,
286
+ isRequired: false,
287
+ transformFunction: null
288
+ },
289
+ target: {
290
+ classPropertyName: "target",
291
+ publicName: "target",
292
+ isSignal: true,
293
+ isRequired: false,
294
+ transformFunction: null
295
+ },
296
+ motionLabels: {
297
+ classPropertyName: "motionLabels",
298
+ publicName: "motionLabels",
299
+ isSignal: true,
300
+ isRequired: false,
301
+ transformFunction: null
302
+ },
303
+ className: {
304
+ classPropertyName: "className",
305
+ publicName: "className",
306
+ isSignal: true,
307
+ isRequired: false,
308
+ transformFunction: null
309
+ },
310
+ open: {
311
+ classPropertyName: "open",
312
+ publicName: "open",
313
+ isSignal: true,
314
+ isRequired: false,
315
+ transformFunction: null
316
+ },
317
+ disabled: {
318
+ classPropertyName: "disabled",
319
+ publicName: "disabled",
320
+ isSignal: true,
321
+ isRequired: false,
322
+ transformFunction: null
323
+ }
324
+ },
325
+ outputs: {
326
+ value: "valueChange",
327
+ motionChange: "motionChange",
328
+ open: "openChange"
329
+ },
330
+ host: { listeners: { "document:click": "onDocumentClick($event)" } },
331
+ queries: [{
332
+ propertyName: "iconTemplate",
333
+ first: true,
334
+ predicate: TemplateRef,
335
+ descendants: true,
336
+ isSignal: true
337
+ }],
338
+ viewQueries: [
339
+ {
340
+ propertyName: "rootRef",
341
+ first: true,
342
+ predicate: ["rootEl"],
343
+ descendants: true,
344
+ isSignal: true
345
+ },
346
+ {
347
+ propertyName: "buttonRef",
348
+ first: true,
349
+ predicate: ["buttonEl"],
350
+ descendants: true,
351
+ isSignal: true
352
+ },
353
+ {
354
+ propertyName: "listRef",
355
+ first: true,
356
+ predicate: ["listEl"],
357
+ descendants: true,
358
+ isSignal: true
359
+ }
360
+ ],
361
+ ngImport: i0,
362
+ template: `
308
363
  <div
309
364
  #rootEl
310
365
  class="motion-picker {{ className() }}"
@@ -313,14 +368,19 @@ class MotionPicker {
313
368
  <input type="hidden" [name]="name()" [value]="value()" />
314
369
 
315
370
  <lily-icon-button
371
+ [disabled]="disabled()"
316
372
  #buttonEl
317
373
  [label]="label()"
318
374
  baseClass="motion-picker-button"
319
375
  ariaHaspopup="listbox"
320
376
  [ariaExpanded]="open()"
321
377
  [ariaControls]="listId"
322
- (click)="toggle()"
323
- (keydown)="onButtonKeydown($event)"
378
+ (click)="toggle(); hoverButton.set(false)"
379
+ (mouseenter)="onTooltipButtonEnter()"
380
+ (mouseleave)="hoverButton.set(false)"
381
+ (focusin)="onTooltipButtonFocusIn()"
382
+ (focusout)="onTooltipButtonFocusOut()"
383
+ (keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
324
384
  >
325
385
  @if (iconTemplate(); as tpl) {
326
386
  <ng-container
@@ -345,6 +405,18 @@ class MotionPicker {
345
405
  }
346
406
  </lily-icon-button>
347
407
 
408
+ <!-- Purely visual: the same text is already the button's aria-label,
409
+ so it is not wired with aria-describedby (that would announce
410
+ the name twice). -->
411
+ <div
412
+ class="motion-picker-tooltip"
413
+ role="tooltip"
414
+ [id]="tooltipId"
415
+ [attr.hidden]="tooltipVisible() ? null : ''"
416
+ (mouseenter)="hoverTooltip.set(true)"
417
+ (mouseleave)="hoverTooltip.set(false)"
418
+ >{{ label() }}</div>
419
+
348
420
  <lily-listbox
349
421
  #listEl
350
422
  [label]="label()"
@@ -374,19 +446,80 @@ class MotionPicker {
374
446
  }
375
447
  </lily-listbox>
376
448
  </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: `
449
+ `,
450
+ isInline: true,
451
+ dependencies: [
452
+ {
453
+ kind: "directive",
454
+ type: NgTemplateOutlet,
455
+ selector: "[ngTemplateOutlet]",
456
+ inputs: [
457
+ "ngTemplateOutletContext",
458
+ "ngTemplateOutlet",
459
+ "ngTemplateOutletInjector"
460
+ ]
461
+ },
462
+ {
463
+ kind: "component",
464
+ type: IconButton,
465
+ selector: "lily-icon-button",
466
+ inputs: [
467
+ "label",
468
+ "className",
469
+ "baseClass",
470
+ "ariaHaspopup",
471
+ "ariaExpanded",
472
+ "ariaControls",
473
+ "tabIndex",
474
+ "disabled"
475
+ ]
476
+ },
477
+ {
478
+ kind: "component",
479
+ type: Listbox,
480
+ selector: "lily-listbox",
481
+ inputs: [
482
+ "label",
483
+ "className",
484
+ "baseClass",
485
+ "elementId",
486
+ "hidden",
487
+ "navigation",
488
+ "clamp",
489
+ "typeahead",
490
+ "pageSize",
491
+ "activeIndex"
492
+ ],
493
+ outputs: [
494
+ "activeIndexChange",
495
+ "activate",
496
+ "escape",
497
+ "tabOut"
498
+ ]
499
+ }
500
+ ],
501
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
502
+ });
503
+ }
504
+ };
505
+ i0.ɵɵngDeclareClassMetadata({
506
+ minVersion: "12.0.0",
507
+ version: "22.2.1",
508
+ ngImport: i0,
509
+ type: MotionPicker,
510
+ decorators: [{
511
+ type: Component,
512
+ args: [{
513
+ selector: "lily-motion-picker",
514
+ standalone: true,
515
+ imports: [
516
+ NgTemplateOutlet,
517
+ IconButton,
518
+ Listbox
519
+ ],
520
+ changeDetection: ChangeDetectionStrategy.OnPush,
521
+ host: { "(document:click)": "onDocumentClick($event)" },
522
+ template: `
390
523
  <div
391
524
  #rootEl
392
525
  class="motion-picker {{ className() }}"
@@ -395,14 +528,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
395
528
  <input type="hidden" [name]="name()" [value]="value()" />
396
529
 
397
530
  <lily-icon-button
531
+ [disabled]="disabled()"
398
532
  #buttonEl
399
533
  [label]="label()"
400
534
  baseClass="motion-picker-button"
401
535
  ariaHaspopup="listbox"
402
536
  [ariaExpanded]="open()"
403
537
  [ariaControls]="listId"
404
- (click)="toggle()"
405
- (keydown)="onButtonKeydown($event)"
538
+ (click)="toggle(); hoverButton.set(false)"
539
+ (mouseenter)="onTooltipButtonEnter()"
540
+ (mouseleave)="hoverButton.set(false)"
541
+ (focusin)="onTooltipButtonFocusIn()"
542
+ (focusout)="onTooltipButtonFocusOut()"
543
+ (keydown)="onButtonKeydown($event); onTooltipKeydown($event)"
406
544
  >
407
545
  @if (iconTemplate(); as tpl) {
408
546
  <ng-container
@@ -427,6 +565,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
427
565
  }
428
566
  </lily-icon-button>
429
567
 
568
+ <!-- Purely visual: the same text is already the button's aria-label,
569
+ so it is not wired with aria-describedby (that would announce
570
+ the name twice). -->
571
+ <div
572
+ class="motion-picker-tooltip"
573
+ role="tooltip"
574
+ [id]="tooltipId"
575
+ [attr.hidden]="tooltipVisible() ? null : ''"
576
+ (mouseenter)="hoverTooltip.set(true)"
577
+ (mouseleave)="hoverTooltip.set(false)"
578
+ >{{ label() }}</div>
579
+
430
580
  <lily-listbox
431
581
  #listEl
432
582
  [label]="label()"
@@ -456,13 +606,127 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
456
606
  }
457
607
  </lily-listbox>
458
608
  </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
-
609
+ `
610
+ }]
611
+ }],
612
+ ctorParameters: () => [],
613
+ propDecorators: {
614
+ label: [{
615
+ type: i0.Input,
616
+ args: [{
617
+ isSignal: true,
618
+ alias: "label",
619
+ required: true
620
+ }]
621
+ }],
622
+ motions: [{
623
+ type: i0.Input,
624
+ args: [{
625
+ isSignal: true,
626
+ alias: "motions",
627
+ required: true
628
+ }]
629
+ }],
630
+ value: [{
631
+ type: i0.Input,
632
+ args: [{
633
+ isSignal: true,
634
+ alias: "value",
635
+ required: false
636
+ }]
637
+ }, {
638
+ type: i0.Output,
639
+ args: ["valueChange"]
640
+ }],
641
+ defaultValue: [{
642
+ type: i0.Input,
643
+ args: [{
644
+ isSignal: true,
645
+ alias: "defaultValue",
646
+ required: false
647
+ }]
648
+ }],
649
+ storageKey: [{
650
+ type: i0.Input,
651
+ args: [{
652
+ isSignal: true,
653
+ alias: "storageKey",
654
+ required: false
655
+ }]
656
+ }],
657
+ name: [{
658
+ type: i0.Input,
659
+ args: [{
660
+ isSignal: true,
661
+ alias: "name",
662
+ required: false
663
+ }]
664
+ }],
665
+ target: [{
666
+ type: i0.Input,
667
+ args: [{
668
+ isSignal: true,
669
+ alias: "target",
670
+ required: false
671
+ }]
672
+ }],
673
+ motionLabels: [{
674
+ type: i0.Input,
675
+ args: [{
676
+ isSignal: true,
677
+ alias: "motionLabels",
678
+ required: false
679
+ }]
680
+ }],
681
+ className: [{
682
+ type: i0.Input,
683
+ args: [{
684
+ isSignal: true,
685
+ alias: "className",
686
+ required: false
687
+ }]
688
+ }],
689
+ motionChange: [{
690
+ type: i0.Output,
691
+ args: ["motionChange"]
692
+ }],
693
+ iconTemplate: [{
694
+ type: i0.ContentChild,
695
+ args: [i0.forwardRef(() => TemplateRef), { isSignal: true }]
696
+ }],
697
+ rootRef: [{
698
+ type: i0.ViewChild,
699
+ args: ["rootEl", { isSignal: true }]
700
+ }],
701
+ buttonRef: [{
702
+ type: i0.ViewChild,
703
+ args: ["buttonEl", { isSignal: true }]
704
+ }],
705
+ listRef: [{
706
+ type: i0.ViewChild,
707
+ args: ["listEl", { isSignal: true }]
708
+ }],
709
+ open: [{
710
+ type: i0.Input,
711
+ args: [{
712
+ isSignal: true,
713
+ alias: "open",
714
+ required: false
715
+ }]
716
+ }, {
717
+ type: i0.Output,
718
+ args: ["openChange"]
719
+ }],
720
+ disabled: [{
721
+ type: i0.Input,
722
+ args: [{
723
+ isSignal: true,
724
+ alias: "disabled",
725
+ required: false
726
+ }]
727
+ }]
728
+ }
729
+ });
467
730
  export { MotionPicker, MotionPickerIcon, motionName, nextMotionPickerId, prefersReducedMotion };
468
- //# sourceMappingURL=lilydesignsystem-angular-motion-picker.mjs.map
731
+
732
+ //# sourceMappingURL=lilydesignsystem-angular-motion-picker.mjs.map