@lilydesignsystem/vue-motion-picker 0.1.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Default button icon: a bundled SVG (two pause bars), not a Unicode
3
+ * character. Reversed 2026-09-16 from the font-dependent-glyph
4
+ * convention (was U+23F8 PAUSE SIGN + U+FE0E, exported as
5
+ * `PAUSE_SIGN` — removed, not renamed). "Stop the moving parts" still
6
+ * reads directly from two bars; a bundled outline SVG matches the
7
+ * other four picker icons as one consistent visual family regardless
8
+ * of the consumer's fonts, where the old glyph depended on the
9
+ * platform's media-transport symbols defaulting to text presentation.
10
+ */
11
+ /** Arguments passed to the default scoped slot (the button icon). */
12
+ export type SlotArgs = {
13
+ /** Currently selected motion slug. */
14
+ value: string;
15
+ /** Is the listbox open? */
16
+ open: boolean;
17
+ /** Resolve a slug to its display label. */
18
+ labelFor: (motion: string) => string;
19
+ };
20
+ /** Alias matching the canonical Svelte helper's type name. */
21
+ export type ChildArgs = SlotArgs;
22
+ /** Public props for MotionPicker. See `spec/index.md` §4 for the contract. */
23
+ export type Props = {
24
+ /** Accessible name for the button and the listbox. */
25
+ label: string;
26
+ /** Available motion slugs, e.g. ["no-preference","reduce"]. */
27
+ motions: string[];
28
+ /** Currently selected motion slug. Two-way bindable via v-model:value. */
29
+ value?: string;
30
+ /** Initial motion when nothing else is supplied. */
31
+ defaultValue?: string;
32
+ /** If set, persist the selection to localStorage under this key. */
33
+ storageKey?: string;
34
+ /** `name` of the hidden input that carries the value in a form. */
35
+ name?: string;
36
+ /** Element that receives `data-motion`. Defaults to document.documentElement. */
37
+ target?: HTMLElement | null;
38
+ /** Optional pretty labels per slug. */
39
+ motionLabels?: Record<string, string>;
40
+ /** Extra CSS class on the root element. */
41
+ class?: string;
42
+ };
43
+ /**
44
+ * Resolve a motion slug to its display label: each hyphen-separated
45
+ * word title-cased, so "no-preference" renders as "No Preference".
46
+ * Mirrors `sizeName` in text-size-picker and `themeName` in
47
+ * theme-picker.
48
+ */
49
+ export declare function motionName(motion: string): string;
50
+ /**
51
+ * True when the platform reports a preference for reduced motion.
52
+ * SSR-safe: `window`/`matchMedia` are absent on the server, so this
53
+ * resolves to `false` there and the client re-derives it on mount.
54
+ */
55
+ export declare function prefersReducedMotion(): boolean;
56
+ /** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
57
+ export declare function nextMotionPickerId(): string;
58
+ declare const _default: typeof __VLS_export;
59
+ export default _default;
60
+ declare const __VLS_export: __VLS_WithSlots<import("vue").DefineComponent<Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
61
+ "update:value": (value: string) => any;
62
+ change: (value: string) => any;
63
+ }, string, import("vue").PublicProps, Readonly<Props> & Readonly<{
64
+ "onUpdate:value"?: ((value: string) => any) | undefined;
65
+ onChange?: ((value: string) => any) | undefined;
66
+ }>, {
67
+ value: string;
68
+ defaultValue: string;
69
+ storageKey: string;
70
+ name: string;
71
+ target: HTMLElement | null;
72
+ motionLabels: Record<string, string>;
73
+ class: string;
74
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>, {
75
+ default?: (props: {
76
+ value: string;
77
+ open: boolean;
78
+ labelFor: (motion: string) => string;
79
+ }) => any;
80
+ }>;
81
+ type __VLS_WithSlots<T, S> = T & {
82
+ new (): {
83
+ $slots: S;
84
+ };
85
+ };
@@ -0,0 +1,2 @@
1
+ export { default, default as MotionPicker, motionName, nextMotionPickerId, prefersReducedMotion, } from "./MotionPicker.vue";
2
+ export type { Props, SlotArgs, ChildArgs } from "./MotionPicker.vue";
package/dist/index.js ADDED
@@ -0,0 +1,232 @@
1
+ import { Fragment as e, createElementBlock as t, createElementVNode as n, defineComponent as r, guardReactiveProps as i, nextTick as a, normalizeClass as o, normalizeProps as s, onBeforeUnmount as c, onMounted as l, openBlock as u, ref as d, renderList as f, renderSlot as p, toDisplayString as m, watch as h } from "vue";
2
+ //#region lily-design-system-vue-motion-picker/MotionPicker.vue?vue&type=script&setup=true&lang.ts
3
+ var g = ["name", "value"], _ = ["aria-label", "aria-expanded"], v = [
4
+ "aria-label",
5
+ "aria-activedescendant",
6
+ "hidden"
7
+ ], y = [
8
+ "id",
9
+ "aria-selected",
10
+ "data-active",
11
+ "onClick"
12
+ ];
13
+ function b(e) {
14
+ return e.split("-").map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" ");
15
+ }
16
+ function x() {
17
+ if (typeof window > "u" || typeof window.matchMedia != "function") return !1;
18
+ try {
19
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
20
+ } catch {
21
+ return !1;
22
+ }
23
+ }
24
+ var S = 0;
25
+ function C() {
26
+ return S += 1, `motion-picker-${S}`;
27
+ }
28
+ //#endregion
29
+ //#region lily-design-system-vue-motion-picker/MotionPicker.vue
30
+ var w = /* @__PURE__ */ r({
31
+ __name: "MotionPicker",
32
+ props: {
33
+ label: {},
34
+ motions: {},
35
+ value: { default: "" },
36
+ defaultValue: { default: void 0 },
37
+ storageKey: { default: void 0 },
38
+ name: { default: "motion" },
39
+ target: { default: void 0 },
40
+ motionLabels: { default: () => ({}) },
41
+ class: { default: "" }
42
+ },
43
+ emits: ["update:value", "change"],
44
+ setup(r, { emit: S }) {
45
+ let w = r, T = S, E = C(), D = `${E}-list`, O = (e) => `${E}-option-${e}`, k = d(!1), A = d(-1), j = d(null), M = d(null), N = d(null), P = "", F;
46
+ function I(e) {
47
+ let t = w.motionLabels ?? {};
48
+ return e in t ? t[e] : b(e);
49
+ }
50
+ function L(e) {
51
+ if (!(typeof document > "u" || !e)) {
52
+ if ((w.target ?? document.documentElement).setAttribute("data-motion", e), w.storageKey) try {
53
+ localStorage.setItem(w.storageKey, e);
54
+ } catch {}
55
+ T("change", e);
56
+ }
57
+ }
58
+ let R = d(w.value ?? "");
59
+ function z(e) {
60
+ R.value = e, T("update:value", e);
61
+ }
62
+ h(() => w.value, (e) => {
63
+ e !== void 0 && e !== R.value && (R.value = e);
64
+ }), h(R, (e, t) => {
65
+ e && e !== t && L(e);
66
+ });
67
+ async function B(e) {
68
+ let t = w.motions.indexOf(R.value);
69
+ A.value = w.motions.length === 0 ? -1 : e ?? (t >= 0 ? t : 0), k.value = !0, await a(), M.value?.focus({ preventScroll: !0 }), U();
70
+ }
71
+ async function V(e = !0) {
72
+ k.value && (k.value = !1, A.value = -1, e && (await a(), j.value?.focus({ preventScroll: !0 })));
73
+ }
74
+ function H(e) {
75
+ let t = w.motions[e];
76
+ t && z(t), V();
77
+ }
78
+ function U() {
79
+ A.value < 0 || !M.value || document.getElementById(O(A.value))?.scrollIntoView?.({ block: "nearest" });
80
+ }
81
+ function W(e) {
82
+ if (w.motions.length === 0) return;
83
+ let t = Math.min(Math.max(A.value + e, 0), w.motions.length - 1);
84
+ A.value = t, U();
85
+ }
86
+ function G(e) {
87
+ let t = e.toLowerCase(), n = P === "" || [...P].every((e) => e === t);
88
+ P += t, clearTimeout(F), F = setTimeout(() => P = "", 500);
89
+ let r = n ? t : P, i = A.value < 0 ? 0 : A.value, a = n ? i + 1 : i;
90
+ for (let e = 0; e < w.motions.length; e++) {
91
+ let t = (a + e) % w.motions.length;
92
+ if (I(w.motions[t]).toLowerCase().startsWith(r)) {
93
+ A.value = t, U();
94
+ return;
95
+ }
96
+ }
97
+ }
98
+ function K() {
99
+ k.value ? V() : B();
100
+ }
101
+ function q(e) {
102
+ switch (e.key) {
103
+ case "ArrowDown":
104
+ case "Enter":
105
+ case " ":
106
+ e.preventDefault(), B();
107
+ break;
108
+ case "ArrowUp": e.preventDefault(), B(w.motions.length - 1);
109
+ }
110
+ }
111
+ function J(e) {
112
+ switch (e.key) {
113
+ case "ArrowDown":
114
+ e.preventDefault(), W(1);
115
+ break;
116
+ case "ArrowUp":
117
+ e.preventDefault(), W(-1);
118
+ break;
119
+ case "Home":
120
+ e.preventDefault(), A.value = 0, U();
121
+ break;
122
+ case "End":
123
+ e.preventDefault(), A.value = w.motions.length - 1, U();
124
+ break;
125
+ case "Enter":
126
+ case " ":
127
+ e.preventDefault(), A.value >= 0 && H(A.value);
128
+ break;
129
+ case "Escape":
130
+ e.preventDefault(), V();
131
+ break;
132
+ case "PageUp":
133
+ e.preventDefault(), W(-10);
134
+ break;
135
+ case "PageDown":
136
+ e.preventDefault(), W(10);
137
+ break;
138
+ case "Tab":
139
+ j.value?.focus?.({ preventScroll: !0 }), V(!1);
140
+ break;
141
+ default: e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && G(e.key);
142
+ }
143
+ }
144
+ function Y(e) {
145
+ let t = e.relatedTarget;
146
+ t && N.value?.contains(t) || V(!1);
147
+ }
148
+ function X(e) {
149
+ if (!k.value) return;
150
+ let t = e.target;
151
+ t && N.value && !N.value.contains(t) && V(!1);
152
+ }
153
+ return l(() => {
154
+ document.addEventListener("click", X);
155
+ let e = R.value;
156
+ if (!e && w.storageKey) try {
157
+ e = localStorage.getItem(w.storageKey) ?? "";
158
+ } catch {}
159
+ if (!e) {
160
+ let t = x() ? "reduce" : "no-preference";
161
+ e = w.defaultValue ?? (w.motions.includes(t) ? t : void 0) ?? w.motions[0] ?? "";
162
+ }
163
+ if (e && e !== R.value) {
164
+ R.value = e, T("update:value", e);
165
+ return;
166
+ }
167
+ e && L(e);
168
+ }), c(() => {
169
+ document.removeEventListener("click", X), clearTimeout(F);
170
+ }), (a, c) => (u(), t("div", {
171
+ ref_key: "rootEl",
172
+ ref: N,
173
+ class: o(`motion-picker ${w.class}`.trim()),
174
+ onFocusout: Y
175
+ }, [
176
+ n("input", {
177
+ type: "hidden",
178
+ name: r.name,
179
+ value: R.value
180
+ }, null, 8, g),
181
+ n("button", {
182
+ ref_key: "buttonEl",
183
+ ref: j,
184
+ type: "button",
185
+ class: "motion-picker-button",
186
+ "aria-label": r.label,
187
+ "aria-haspopup": "listbox",
188
+ "aria-expanded": k.value ? "true" : "false",
189
+ "aria-controls": D,
190
+ onClick: K,
191
+ onKeydown: q
192
+ }, [p(a.$slots, "default", s(i({
193
+ value: R.value,
194
+ open: k.value,
195
+ labelFor: I
196
+ })), () => [c[0] ||= n("svg", {
197
+ class: "motion-picker-icon",
198
+ viewBox: "0 0 16 16",
199
+ width: "1.05rem",
200
+ height: "1.05rem",
201
+ "aria-hidden": "true",
202
+ fill: "none",
203
+ stroke: "currentColor",
204
+ "stroke-width": "1.6",
205
+ "stroke-linecap": "round",
206
+ "stroke-linejoin": "round"
207
+ }, [n("path", { d: "M5 3v10M11 3v10" })], -1)])], 40, _),
208
+ n("ul", {
209
+ ref_key: "listEl",
210
+ ref: M,
211
+ class: "motion-picker-list",
212
+ id: D,
213
+ role: "listbox",
214
+ "aria-label": r.label,
215
+ "aria-activedescendant": k.value && A.value >= 0 ? O(A.value) : void 0,
216
+ tabindex: "-1",
217
+ hidden: !k.value || void 0,
218
+ onKeydown: J
219
+ }, [(u(!0), t(e, null, f(r.motions, (e, n) => (u(), t("li", {
220
+ key: e,
221
+ class: "motion-picker-option",
222
+ id: O(n),
223
+ role: "option",
224
+ "aria-selected": e === R.value ? "true" : "false",
225
+ "data-active": n === A.value ? "" : void 0,
226
+ onClick: (e) => H(n)
227
+ }, m(I(e)), 9, y))), 128))], 40, v)
228
+ ], 34));
229
+ }
230
+ });
231
+ //#endregion
232
+ export { w as MotionPicker, w as default, b as motionName, C as nextMotionPickerId, x as prefersReducedMotion };
package/index.md ADDED
@@ -0,0 +1,82 @@
1
+ # MotionPicker (Vue helper)
2
+
3
+ A reusable Vue 3 headless **motion (reduced-motion) picker** — an icon
4
+ button that opens a WAI-ARIA APG listbox of motion-preference slugs.
5
+ On every change it sets `data-motion="{slug}"` on a target element
6
+ (default `document.documentElement`), optionally persisting the choice
7
+ to `localStorage`. Ships no CSS — the consumer decides what
8
+ `data-motion="reduce"` actually suppresses, e.g.:
9
+
10
+ ```css
11
+ :root[data-motion="reduce"] * {
12
+ animation-duration: 0.001ms !important;
13
+ animation-iteration-count: 1 !important;
14
+ transition-duration: 0.001ms !important;
15
+ scroll-behavior: auto !important;
16
+ }
17
+ ```
18
+
19
+ Unlike its `theme-picker` and `text-size-picker` siblings, MotionPicker's
20
+ initial value defers to the platform's own
21
+ `(prefers-reduced-motion: reduce)` media query before falling back to
22
+ an arbitrary default.
23
+
24
+ ## Usage
25
+
26
+ ```vue
27
+ <script setup lang="ts">
28
+ import { ref } from "vue";
29
+ import MotionPicker from "@lilydesignsystem/vue-motion-picker";
30
+
31
+ const motion = ref("");
32
+ </script>
33
+
34
+ <template>
35
+ <MotionPicker
36
+ label="Motion"
37
+ :motions="['no-preference', 'reduce']"
38
+ v-model:value="motion"
39
+ storageKey="lily-motion"
40
+ />
41
+ </template>
42
+ ```
43
+
44
+ ## Props
45
+
46
+ | Prop | Type | Required | Description |
47
+ | -------------- | ------------------------ | -------- | ------------------------------------------------------------ |
48
+ | `label` | `string` | yes | Accessible name (`aria-label`) for the button + listbox. |
49
+ | `motions` | `string[]` | yes | Available motion slugs. |
50
+ | `value` | `string` | no | Selected slug. Two-way bindable via `v-model:value`. |
51
+ | `defaultValue` | `string` | no | Initial slug when nothing else is supplied. |
52
+ | `storageKey` | `string` | no | If set, persist the slug to `localStorage`. |
53
+ | `name` | `string` | no | `name` of the hidden input (default `"motion"`). |
54
+ | `target` | `HTMLElement \| null` | no | Element to receive `data-motion`. Default `<html>`. |
55
+ | `motionLabels` | `Record<string,string>` | no | Pretty labels per slug. |
56
+ | `class` | `string` | no | Extra CSS class on the root. |
57
+
58
+ Emits `change` (the applied slug) and `update:value` (for
59
+ `v-model:value`).
60
+
61
+ ## Behaviour
62
+
63
+ Initial value resolves from `value` > storage > `defaultValue` > the
64
+ platform's `(prefers-reduced-motion: reduce)` preference (mapped to
65
+ `"reduce"` / `"no-preference"` if either is in `motions`) > `motions[0]`.
66
+ All DOM writes happen inside `onMounted`/`watch`, so the component is
67
+ SSR-safe.
68
+
69
+ ## Accessibility
70
+
71
+ - WCAG 2.2 AAA target; directly supports 2.3.3 (Animation from
72
+ Interactions).
73
+ - APG listbox keyboard contract: arrows (clamped), `Home` / `End`,
74
+ `PageUp` / `PageDown` (by ten), typeahead with same-character
75
+ cycling, `Escape` discards, and `Tab` closes via the button so the
76
+ default Tab proceeds from the picker's position.
77
+ - `aria-label` carries the consumer-supplied accessible name.
78
+ - Default labels title-case the slug.
79
+
80
+ ---
81
+
82
+ Lily™ and Lily Design System™ are trademarks.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@lilydesignsystem/vue-motion-picker",
3
+ "version": "0.1.0",
4
+ "engines": {
5
+ "node": "=26"
6
+ },
7
+ "description": "Lily Design System - Vue 3 motion (reduced-motion) picker",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "index.md",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "prepublishOnly": "cd .. && npm run build"
25
+ },
26
+ "peerDependencies": {
27
+ "vue": "^3.0.0"
28
+ },
29
+ "keywords": [
30
+ "lily",
31
+ "design",
32
+ "system",
33
+ "vue",
34
+ "motion",
35
+ "reduced-motion",
36
+ "picker"
37
+ ],
38
+ "author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
39
+ "license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/LilyDesignSystem/lily-design-system-vue-helpers.git",
43
+ "directory": "lily-design-system-vue-motion-picker"
44
+ },
45
+ "homepage": "https://lilydesignsystem.com/",
46
+ "bugs": {
47
+ "url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
48
+ }
49
+ }