@igor-ganov/flying-menu 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 igor-ganov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # flying-menu
2
+
3
+ A **headless**, framework-agnostic [Lit](https://lit.dev) web component for a
4
+ draggable, corner-snapping floating menu — the reusable extraction of the
5
+ `useDraggableFab` + `MobileMenu` logic from `admin-website` / `public-website`.
6
+
7
+ - 🧲 **Drag the trigger** to any screen corner; it snaps to the nearest one on release.
8
+ - 📐 **Edge-aware menu** that anchors to the trigger's corner with correct offsets —
9
+ computed from measured geometry, so it's correct for **any** trigger/menu content size.
10
+ - 🎛 **Two slots, zero chrome** — you bring the button and the menu; the component owns
11
+ only behaviour and positioning.
12
+ - ♿ **Accessible** — ARIA wired onto your control, focus management, `Escape`,
13
+ outside-click, keyboard activation, `prefers-reduced-motion`.
14
+ - 💾 **Persists** the chosen corner to `localStorage`.
15
+
16
+ **▶ Live demo:** https://igor-ganov.github.io/flying-menu/
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ bun add @igor-ganov/flying-menu lit
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```html
27
+ <flying-menu>
28
+ <button slot="trigger" aria-label="Open menu">☰</button>
29
+ <nav slot="menu" aria-label="Primary">
30
+ <a href="/home">Home</a>
31
+ <a href="/docs">Docs</a>
32
+ </nav>
33
+ </flying-menu>
34
+
35
+ <script type="module">
36
+ import '@igor-ganov/flying-menu'
37
+ </script>
38
+ ```
39
+
40
+ The component renders **no visual styling**. Style your slotted content normally and
41
+ position/animate the wrappers through CSS parts:
42
+
43
+ ```css
44
+ flying-menu::part(menu) {
45
+ background: Canvas;
46
+ border: 1px solid CanvasText;
47
+ border-radius: 12px;
48
+ padding: 0.5rem;
49
+ /* Tall menus: cap height and scroll instead of overflowing the viewport. */
50
+ max-height: 80vh;
51
+ overflow: auto;
52
+ }
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### Attributes / properties
58
+
59
+ | Attribute | Property | Type | Default | Purpose |
60
+ |------------------|-----------------|--------|----------------------|--------------------------------------|
61
+ | `open` | `open` | bool | `false` | Menu visibility (reflected) |
62
+ | `corner` | `corner` | string | persisted / `bottom-right` | Resting corner (reflected) |
63
+ | `margin` | `margin` | number | `16` | Viewport edge inset (px) |
64
+ | `gap` | `gap` | number | `8` | Trigger↔menu gap (px) |
65
+ | `drag-threshold` | `dragThreshold` | number | `10` | Tap-vs-drag distance (px, Manhattan) |
66
+ | `storage-key` | `storageKey` | string | `flying-menu-corner` | `localStorage` key |
67
+ | `no-persist` | `noPersist` | bool | `false` | Disable persistence |
68
+
69
+ `corner` is one of `top-left | top-right | bottom-left | bottom-right`.
70
+
71
+ ### Methods
72
+
73
+ `openMenu()`, `closeMenu()`, `toggle()`.
74
+ (Named `openMenu`/`closeMenu` because `open` is a reflected attribute.)
75
+
76
+ ### Events
77
+
78
+ | Event | `detail` | Notes |
79
+ |----------------------|-----------------------|-----------------------------|
80
+ | `flying-menu-toggle` | `{ open: boolean }` | Cancelable — `preventDefault()` blocks the change |
81
+ | `flying-menu-corner` | `{ corner: Corner }` | Fired after a snap |
82
+
83
+ ### CSS parts, states & custom properties
84
+
85
+ - `::part(trigger)` — the fixed-positioned trigger wrapper.
86
+ - `::part(menu)` — the fixed-positioned menu wrapper.
87
+ - `:state(open)` — host custom state, present while the menu is open. Use it to drive
88
+ fully custom open/close animation from your own stylesheet.
89
+ - `--flying-menu-z-trigger` (default `1000`), `--flying-menu-z-menu` (default `999`),
90
+ `--flying-menu-transition` (default `200ms ease`).
91
+
92
+ ### Animation
93
+
94
+ The component owns **positioning**, not appearance, and it **never transitions the
95
+ menu's position** — the menu jumps to its corner anchor and only the entry/exit
96
+ ("pop-in") animates, so it never slides in from a previous corner.
97
+
98
+ You control motion from the outside:
99
+
100
+ ```css
101
+ /* Just retime the built-in pop-in. */
102
+ flying-menu { --flying-menu-transition: 160ms cubic-bezier(0.2, 0, 0, 1); }
103
+
104
+ /* Or replace it entirely via the part + host state. */
105
+ flying-menu::part(menu) { transition: opacity .15s, scale .15s; opacity: 0; scale: .96; }
106
+ flying-menu:state(open)::part(menu) { opacity: 1; scale: 1; }
107
+ ```
108
+
109
+ The built-in entry uses `@starting-style` + `transition-behavior: allow-discrete`, and
110
+ is disabled automatically under `prefers-reduced-motion: reduce`.
111
+
112
+ ## Accessibility notes
113
+
114
+ - ARIA (`aria-haspopup`, `aria-controls`, `aria-expanded`) is placed on your slotted
115
+ control when it is itself focusable (e.g. a `<button>`); otherwise the wrapper is
116
+ promoted to `role="button"`. Always provide an accessible name on your trigger.
117
+ - The menu content's role is **yours** to choose (`nav`, `menu`, `listbox`, …) — the
118
+ component only manages opening, focus entry, and dismissal.
119
+ - While open, **Tab / Shift+Tab cycle through the menu's focusable items** (wrapping at
120
+ the ends) and `Escape` leaves the menu. This works consistently across browsers,
121
+ including WebKit, which otherwise drops focus out of slotted shadow content. Arrow-key
122
+ navigation (for a `role="menu"`) remains yours to add.
123
+
124
+ ## Architecture
125
+
126
+ Functional-core / imperative-shell: all geometry and the drag state machine are pure,
127
+ unit-tested functions in `src/core`; `src/flying-menu.ts` is a thin Lit shell that
128
+ measures the DOM and applies the results. See `specs/flying-menu/` for the full spec
129
+ (requirements → design → tasks) and `src/README.md` for module details.
130
+
131
+ ## Develop
132
+
133
+ ```sh
134
+ bun install
135
+ bun run dev # demo at / (?scenario=big for a large trigger + tall menu)
136
+ bun run test # unit tests (Vitest)
137
+ bun run test:e2e # E2E (Playwright, Chromium/Firefox/WebKit)
138
+ bun run build # library build + d.ts
139
+ ```
@@ -0,0 +1,94 @@
1
+ import * as lit_html from 'lit-html';
2
+ import * as lit from 'lit';
3
+ import { LitElement, PropertyValues } from 'lit';
4
+
5
+ /** One of the four screen corners the trigger can rest in. */
6
+ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
7
+ /** A point in viewport coordinates (CSS pixels). */
8
+ interface Point {
9
+ readonly x: number;
10
+ readonly y: number;
11
+ }
12
+
13
+ /**
14
+ * Immutable state of a pointer drag gesture. The trigger's live top-left while
15
+ * dragging is `current`; `moved` flips once the gesture passes the threshold and
16
+ * decides tap-vs-drag at release.
17
+ */
18
+ interface DragState {
19
+ readonly dragging: boolean;
20
+ readonly moved: boolean;
21
+ readonly start: Point;
22
+ readonly origin: Point;
23
+ readonly current: Point;
24
+ }
25
+
26
+ /** Minimal storage abstraction so persistence is testable and failure-safe. */
27
+ interface StoragePort {
28
+ get(key: string): string | undefined;
29
+ set(key: string, value: string): void;
30
+ }
31
+
32
+ /** Non-reactive internal state and stable listener references for the element. */
33
+ interface Controller {
34
+ drag: DragState;
35
+ readonly internals: ElementInternals;
36
+ readonly storage: StoragePort;
37
+ cornerInitialized: boolean;
38
+ restoreFocusOnClose: boolean;
39
+ readonly onResize: () => void;
40
+ readonly onDocPointerDown: (e: Event) => void;
41
+ readonly onDocKeydown: (e: KeyboardEvent) => void;
42
+ }
43
+
44
+ /** Tag name of the custom element. */
45
+ declare const TAG_NAME = "flying-menu";
46
+
47
+ /**
48
+ * Headless, draggable, corner-snapping flying menu. Thin framework boundary: it
49
+ * holds reactive state and delegates all behaviour to `./element/*` functions.
50
+ *
51
+ * @fires flying-menu-toggle - `{ open: boolean }`, cancelable.
52
+ * @fires flying-menu-corner - `{ corner: Corner }`.
53
+ * @csspart trigger - The fixed-positioned trigger wrapper.
54
+ * @csspart menu - The fixed-positioned menu wrapper.
55
+ */
56
+ declare class FlyingMenu extends LitElement {
57
+ static readonly styles: lit.CSSResultArray;
58
+ /** Whether the menu is open. */
59
+ open: boolean;
60
+ /** Resting corner of the trigger. */
61
+ corner: Corner;
62
+ /** Edge inset in pixels. */
63
+ margin: number;
64
+ /** Gap between trigger and menu in pixels. */
65
+ gap: number;
66
+ /** Tap-vs-drag threshold in pixels (Manhattan). */
67
+ dragThreshold: number;
68
+ /** `localStorage` key used to persist the corner. */
69
+ storageKey: string;
70
+ /** Disable persistence entirely. */
71
+ noPersist: boolean;
72
+ _dragging: boolean;
73
+ _triggerEl: HTMLElement;
74
+ _menuEl: HTMLElement;
75
+ readonly _ctl: Controller;
76
+ connectedCallback(): void;
77
+ disconnectedCallback(): void;
78
+ /** Open the menu. */
79
+ openMenu(): void;
80
+ /** Close the menu. */
81
+ closeMenu(): void;
82
+ /** Toggle the menu. */
83
+ toggle(): void;
84
+ protected render(): lit_html.TemplateResult<1>;
85
+ protected firstUpdated(): void;
86
+ protected updated(changed: PropertyValues<this>): void;
87
+ }
88
+ declare global {
89
+ interface HTMLElementTagNameMap {
90
+ [TAG_NAME]: FlyingMenu;
91
+ }
92
+ }
93
+
94
+ export { FlyingMenu };
@@ -0,0 +1,477 @@
1
+ import { html as G, css as m, LitElement as Y } from "lit";
2
+ import { property as g, state as B, query as A, customElement as W } from "lit/decorators.js";
3
+ const H = 16, V = 8, X = 10, j = "flying-menu-corner", q = "bottom-right", y = { x: 0, y: 0 }, Z = {
4
+ dragging: !1,
5
+ moved: !1,
6
+ start: y,
7
+ origin: y,
8
+ current: y
9
+ }, J = (e, t) => ({
10
+ dragging: !0,
11
+ moved: !1,
12
+ start: t.pointer,
13
+ origin: t.origin,
14
+ current: t.origin
15
+ }), Q = (e, t) => {
16
+ const n = t.pointer.x - e.start.x, r = t.pointer.y - e.start.y, i = e.moved || Math.abs(n) + Math.abs(r) > t.threshold;
17
+ return { ...e, moved: i, current: { x: e.origin.x + n, y: e.origin.y + r } };
18
+ }, ee = (e, t) => {
19
+ switch (e.dragging) {
20
+ case !0:
21
+ return Q(e, t);
22
+ case !1:
23
+ return e;
24
+ }
25
+ }, te = (e) => ({ ...e, dragging: !1 }), ne = (e) => !e.moved, C = (e) => ({ _tag: "Some", value: e }), R = { _tag: "None" }, O = (e) => (t) => [t].filter(e).reduce((n, r) => C(r), R), re = {
26
+ "top-left": !0,
27
+ "top-right": !0,
28
+ "bottom-left": !0,
29
+ "bottom-right": !0
30
+ }, oe = (e) => e !== void 0 && e in re, ie = (e) => O(oe)(e), S = (e) => {
31
+ try {
32
+ return C(e());
33
+ } catch {
34
+ return R;
35
+ }
36
+ }, se = (e) => (t) => {
37
+ switch (t._tag) {
38
+ case "Some":
39
+ return e(t.value);
40
+ case "None":
41
+ return t;
42
+ }
43
+ }, D = (e) => (t) => {
44
+ switch (t._tag) {
45
+ case "Some":
46
+ return t.value;
47
+ case "None":
48
+ return e();
49
+ }
50
+ };
51
+ function M(e, ...t) {
52
+ return t.reduce((n, r) => r(n), e);
53
+ }
54
+ const ae = (e, t) => M(
55
+ S(() => e.get(t)),
56
+ se(ie),
57
+ D(() => q)
58
+ ), ce = (e, t, n) => {
59
+ S(() => {
60
+ e.set(t, n);
61
+ });
62
+ }, le = (e) => e != null, ge = (e) => O(le)(e), de = (e) => D(() => {
63
+ })(e), ue = () => ({
64
+ get: (e) => {
65
+ var t;
66
+ return M(ge((t = globalThis.localStorage) == null ? void 0 : t.getItem(e)), de);
67
+ },
68
+ set: (e, t) => {
69
+ var n;
70
+ (n = globalThis.localStorage) == null || n.setItem(e, t);
71
+ }
72
+ }), o = (e, t) => {
73
+ switch (e) {
74
+ case !0:
75
+ t();
76
+ break;
77
+ }
78
+ }, l = (e, t) => (n) => {
79
+ switch (n) {
80
+ case !0:
81
+ return e;
82
+ case !1:
83
+ return t;
84
+ }
85
+ }, P = (e, t, n, r) => ({
86
+ x: l(r.width - t.width - n, n)(e.endsWith("right")),
87
+ y: l(r.height - t.height - n, n)(e.startsWith("bottom"))
88
+ }), w = (e, t, n) => l(t, Math.min(Math.max(e, t), n))(n < t), pe = (e) => {
89
+ const { corner: t, triggerRect: n, menuSize: r, gap: i, margin: c, vp: u } = e, K = l(
90
+ n.x + n.width - r.width,
91
+ n.x
92
+ )(t.endsWith("right")), k = l(
93
+ n.y - i - r.height,
94
+ n.y + n.height + i
95
+ )(t.startsWith("bottom"));
96
+ return {
97
+ x: w(K, c, u.width - r.width - c),
98
+ y: w(k, c, u.height - r.height - c)
99
+ };
100
+ }, b = () => {
101
+ var t;
102
+ const e = (t = globalThis.document) == null ? void 0 : t.documentElement;
103
+ return {
104
+ width: (e == null ? void 0 : e.clientWidth) || globalThis.innerWidth,
105
+ height: (e == null ? void 0 : e.clientHeight) || globalThis.innerHeight
106
+ };
107
+ }, _ = (e) => ({
108
+ width: e.width,
109
+ height: e.height
110
+ }), d = (e, t) => {
111
+ const n = e._triggerEl;
112
+ o(n != null, () => {
113
+ const r = t ?? P(e.corner, _(n.getBoundingClientRect()), e.margin, b());
114
+ n.style.left = `${r.x}px`, n.style.top = `${r.y}px`;
115
+ });
116
+ }, me = (e, t) => {
117
+ const n = _(e._triggerEl.getBoundingClientRect()), r = P(e.corner, n, e.margin, t);
118
+ return { x: r.x, y: r.y, width: n.width, height: n.height };
119
+ }, h = (e) => {
120
+ const t = e._menuEl;
121
+ o(t != null && e._triggerEl != null, () => {
122
+ const n = b(), r = pe({
123
+ corner: e.corner,
124
+ triggerRect: me(e, n),
125
+ menuSize: _(t.getBoundingClientRect()),
126
+ gap: e.gap,
127
+ margin: e.margin,
128
+ vp: n
129
+ });
130
+ t.style.left = `${r.x}px`, t.style.top = `${r.y}px`;
131
+ });
132
+ }, ye = (e) => {
133
+ d(e), o(e.open, () => h(e));
134
+ }, fe = (e, t) => {
135
+ o(!t.composedPath().includes(e), () => e.closeMenu());
136
+ }, be = (e, t) => {
137
+ o(t.key === "Escape", () => {
138
+ e._ctl.restoreFocusOnClose = !0, e.closeMenu();
139
+ });
140
+ }, _e = (e) => {
141
+ globalThis.addEventListener("pointerdown", e._ctl.onDocPointerDown, !0), globalThis.addEventListener("keydown", e._ctl.onDocKeydown);
142
+ }, L = (e) => {
143
+ globalThis.removeEventListener("pointerdown", e._ctl.onDocPointerDown, !0), globalThis.removeEventListener("keydown", e._ctl.onDocKeydown);
144
+ }, he = (e) => ({
145
+ drag: Z,
146
+ internals: e.attachInternals(),
147
+ storage: ue(),
148
+ cornerInitialized: !1,
149
+ restoreFocusOnClose: !1,
150
+ onResize: () => ye(e),
151
+ onDocPointerDown: (t) => fe(e, t),
152
+ onDocKeydown: (t) => be(e, t)
153
+ }), I = [
154
+ "a[href]",
155
+ "button:not([disabled])",
156
+ "input:not([disabled])",
157
+ "select:not([disabled])",
158
+ "textarea:not([disabled])",
159
+ "[tabindex]"
160
+ ].join(","), Ee = (e) => e === null || Number(e) >= 0, N = (e) => !e.hasAttribute("disabled") && Ee(e.getAttribute("tabindex")), $ = (e) => e.matches(I) && N(e), ve = (e) => [e].filter(
161
+ (t) => t instanceof HTMLElement && $(t)
162
+ ), we = (e) => [...e.querySelectorAll(I)].filter(N), z = (e) => e.flatMap((t) => [...ve(t), ...we(t)]), xe = (e) => z(e)[0], p = (e, t, n) => {
163
+ switch (e) {
164
+ case !0:
165
+ t();
166
+ break;
167
+ case !1:
168
+ n();
169
+ break;
170
+ }
171
+ }, E = (e, t) => {
172
+ const n = e.renderRoot.querySelector(
173
+ `slot[name="${t}"]`
174
+ );
175
+ return (n == null ? void 0 : n.assignedElements({ flatten: !0 })) ?? [];
176
+ }, F = (e) => {
177
+ const [t] = E(e, "trigger");
178
+ return [t].filter((n) => n instanceof HTMLElement)[0];
179
+ }, Te = "trigger", Ae = "menu", U = "flying-menu-popup", Ce = "flying-menu-toggle", Re = "flying-menu-corner", Oe = "flying-menu", x = (e, t) => {
180
+ t == null || t.setAttribute("aria-haspopup", "menu"), t == null || t.setAttribute("aria-controls", U), t == null || t.setAttribute("aria-expanded", String(e.open));
181
+ }, T = (e) => {
182
+ for (const t of ["aria-haspopup", "aria-controls", "aria-expanded", "role"])
183
+ e == null || e.removeAttribute(t);
184
+ }, Se = (e) => {
185
+ e.setAttribute("role", "button"), o(!e.hasAttribute("tabindex"), () => e.setAttribute("tabindex", "0"));
186
+ }, v = (e) => {
187
+ const t = F(e);
188
+ p(
189
+ t !== void 0 && $(t),
190
+ () => {
191
+ T(e._triggerEl), x(e, t);
192
+ },
193
+ () => {
194
+ Se(e._triggerEl), T(t), x(e, e._triggerEl);
195
+ }
196
+ );
197
+ }, De = (e) => {
198
+ var t;
199
+ (t = F(e) ?? e._triggerEl) == null || t.focus();
200
+ }, Me = (e) => {
201
+ const t = e._menuEl;
202
+ o(t != null, () => {
203
+ const n = xe(E(e, "menu")) ?? t;
204
+ o(n === t && !t.hasAttribute("tabindex"), () => {
205
+ t.tabIndex = -1;
206
+ }), n.focus();
207
+ });
208
+ }, f = (e, t) => {
209
+ o(t !== e.open, () => {
210
+ const n = e.dispatchEvent(
211
+ new CustomEvent(Ce, {
212
+ detail: { open: t },
213
+ bubbles: !0,
214
+ composed: !0,
215
+ cancelable: !0
216
+ })
217
+ );
218
+ o(n, () => {
219
+ e.open = t;
220
+ });
221
+ });
222
+ }, Pe = (e) => {
223
+ L(e), o(e._ctl.restoreFocusOnClose, () => {
224
+ e._ctl.restoreFocusOnClose = !1, De(e);
225
+ });
226
+ }, Le = (e) => {
227
+ v(e), p(
228
+ e.open,
229
+ () => e._ctl.internals.states.add("open"),
230
+ () => e._ctl.internals.states.delete("open")
231
+ ), p(
232
+ e.open,
233
+ () => {
234
+ h(e), _e(e), Me(e);
235
+ },
236
+ () => Pe(e)
237
+ );
238
+ }, Ie = (e) => {
239
+ o(e.open, () => h(e)), e.dispatchEvent(
240
+ new CustomEvent(Re, {
241
+ detail: { corner: e.corner },
242
+ bubbles: !0,
243
+ composed: !0
244
+ })
245
+ ), o(!e.noPersist, () => ce(e._ctl.storage, e.storageKey, e.corner));
246
+ }, Ne = (e) => {
247
+ o(!e._ctl.cornerInitialized && !e.noPersist, () => {
248
+ e.corner = ae(e._ctl.storage, e.storageKey);
249
+ }), e._ctl.cornerInitialized = !0, globalThis.addEventListener("resize", e._ctl.onResize);
250
+ }, $e = (e) => {
251
+ globalThis.removeEventListener("resize", e._ctl.onResize), L(e);
252
+ }, ze = (e) => {
253
+ d(e), v(e);
254
+ }, Fe = (e, t) => {
255
+ o(t.has("corner") || t.has("margin"), () => {
256
+ d(e), o(t.has("corner"), () => Ie(e));
257
+ }), o(t.has("open"), () => Le(e));
258
+ }, Ue = (e, t) => e.findIndex((n) => n === t), Ke = (e, t, n) => (e + t + n) % n, ke = (e) => l(-1, 1)(e), Ge = {
259
+ Enter: !0,
260
+ " ": !0,
261
+ Spacebar: !0
262
+ }, Ye = (e) => e in Ge, Be = () => {
263
+ var t, n;
264
+ let e = ((t = globalThis.document) == null ? void 0 : t.activeElement) ?? void 0;
265
+ for (; (n = e == null ? void 0 : e.shadowRoot) != null && n.activeElement; ) e = e.shadowRoot.activeElement;
266
+ return e ?? void 0;
267
+ }, We = (e, t) => {
268
+ const n = z(E(e, "menu"));
269
+ o(n.length > 0, () => {
270
+ var i;
271
+ t.preventDefault();
272
+ const r = Ue(n, Be());
273
+ (i = n[Ke(r, ke(t.shiftKey), n.length)]) == null || i.focus();
274
+ });
275
+ }, He = (e, t) => {
276
+ o(Ye(t.key), () => {
277
+ t.preventDefault(), e.toggle();
278
+ });
279
+ }, Ve = (e, t) => {
280
+ o(t.key === "Tab", () => We(e, t));
281
+ }, Xe = l("bottom", "top"), je = l("right", "left"), qe = (e, t) => `${Xe(e.y > t.height / 2)}-${je(e.x > t.width / 2)}`, Ze = (e, t) => {
282
+ p(
283
+ t === e.corner,
284
+ () => d(e),
285
+ // same corner: re-anchor immediately
286
+ () => {
287
+ e.corner = t;
288
+ }
289
+ );
290
+ }, Je = (e, t) => {
291
+ const n = te(e._ctl.drag);
292
+ e._ctl.drag = n, e._dragging = !1, o(
293
+ e._triggerEl.hasPointerCapture(t.pointerId),
294
+ () => e._triggerEl.releasePointerCapture(t.pointerId)
295
+ ), p(
296
+ ne(n),
297
+ () => {
298
+ d(e), e.toggle();
299
+ },
300
+ () => Ze(e, qe({ x: t.clientX, y: t.clientY }, b()))
301
+ );
302
+ }, Qe = (e, t) => {
303
+ o(t.button === 0, () => {
304
+ const n = e._triggerEl.getBoundingClientRect();
305
+ e._ctl.drag = J(e._ctl.drag, {
306
+ pointer: { x: t.clientX, y: t.clientY },
307
+ origin: { x: n.x, y: n.y }
308
+ }), e._dragging = !0, e._triggerEl.setPointerCapture(t.pointerId);
309
+ });
310
+ }, et = (e, t) => {
311
+ o(e._ctl.drag.dragging, () => {
312
+ e._ctl.drag = ee(e._ctl.drag, {
313
+ pointer: { x: t.clientX, y: t.clientY },
314
+ threshold: e.dragThreshold
315
+ }), d(e, e._ctl.drag.current);
316
+ });
317
+ }, tt = (e, t) => {
318
+ o(e._ctl.drag.dragging, () => Je(e, t));
319
+ }, nt = (e) => {
320
+ d(e), v(e);
321
+ }, rt = (e) => G`
322
+ <div
323
+ part=${Te}
324
+ ?data-dragging=${e._dragging}
325
+ @pointerdown=${(t) => Qe(e, t)}
326
+ @pointermove=${(t) => et(e, t)}
327
+ @pointerup=${(t) => tt(e, t)}
328
+ @keydown=${(t) => He(e, t)}
329
+ >
330
+ <slot name="trigger" @slotchange=${() => nt(e)}></slot>
331
+ </div>
332
+ <div
333
+ id=${U}
334
+ part=${Ae}
335
+ ?data-open=${e.open}
336
+ @keydown=${(t) => Ve(e, t)}
337
+ >
338
+ <slot name="menu"></slot>
339
+ </div>
340
+ `, ot = m`
341
+ :host {
342
+ position: static;
343
+ display: contents;
344
+ }
345
+ `, it = m`
346
+ [part='menu'] {
347
+ position: fixed;
348
+ width: max-content;
349
+ height: max-content;
350
+ z-index: var(--flying-menu-z-menu, 999);
351
+ display: none;
352
+ opacity: 0;
353
+ transform: translateY(8px);
354
+ pointer-events: none;
355
+ transition: opacity var(--flying-menu-transition, 200ms ease),
356
+ transform var(--flying-menu-transition, 200ms ease),
357
+ overlay var(--flying-menu-transition, 200ms ease) allow-discrete,
358
+ display var(--flying-menu-transition, 200ms ease) allow-discrete;
359
+ }
360
+
361
+ [part='menu'][data-open] {
362
+ display: block;
363
+ opacity: 1;
364
+ transform: translateY(0);
365
+ pointer-events: auto;
366
+ }
367
+
368
+ @starting-style {
369
+ [part='menu'][data-open] {
370
+ opacity: 0;
371
+ transform: translateY(8px);
372
+ }
373
+ }
374
+ `, st = m`
375
+ @media (prefers-reduced-motion: reduce) {
376
+ [part='trigger'],
377
+ [part='menu'] {
378
+ transition: none;
379
+ }
380
+ }
381
+ `, at = m`
382
+ [part='trigger'] {
383
+ position: fixed;
384
+ width: max-content;
385
+ height: max-content;
386
+ z-index: var(--flying-menu-z-trigger, 1000);
387
+ touch-action: none;
388
+ -webkit-tap-highlight-color: transparent;
389
+ user-select: none;
390
+ transition: left var(--flying-menu-transition, 200ms ease),
391
+ top var(--flying-menu-transition, 200ms ease);
392
+ }
393
+
394
+ [part='trigger'][data-dragging] {
395
+ transition: none;
396
+ cursor: grabbing;
397
+ }
398
+ `, ct = [
399
+ ot,
400
+ at,
401
+ it,
402
+ st
403
+ ];
404
+ var lt = Object.defineProperty, gt = Object.getOwnPropertyDescriptor, a = (e, t, n, r) => {
405
+ for (var i = r > 1 ? void 0 : r ? gt(t, n) : t, c = e.length - 1, u; c >= 0; c--)
406
+ (u = e[c]) && (i = (r ? u(t, n, i) : u(i)) || i);
407
+ return r && i && lt(t, n, i), i;
408
+ };
409
+ let s = class extends Y {
410
+ constructor() {
411
+ super(...arguments), this.open = !1, this.corner = "bottom-right", this.margin = H, this.gap = V, this.dragThreshold = X, this.storageKey = j, this.noPersist = !1, this._dragging = !1, this._ctl = he(this);
412
+ }
413
+ connectedCallback() {
414
+ super.connectedCallback(), Ne(this);
415
+ }
416
+ disconnectedCallback() {
417
+ super.disconnectedCallback(), $e(this);
418
+ }
419
+ /** Open the menu. */
420
+ openMenu() {
421
+ f(this, !0);
422
+ }
423
+ /** Close the menu. */
424
+ closeMenu() {
425
+ f(this, !1);
426
+ }
427
+ /** Toggle the menu. */
428
+ toggle() {
429
+ f(this, !this.open);
430
+ }
431
+ render() {
432
+ return rt(this);
433
+ }
434
+ firstUpdated() {
435
+ ze(this);
436
+ }
437
+ updated(e) {
438
+ Fe(this, e);
439
+ }
440
+ };
441
+ s.styles = ct;
442
+ a([
443
+ g({ type: Boolean, reflect: !0 })
444
+ ], s.prototype, "open", 2);
445
+ a([
446
+ g({ type: String, reflect: !0 })
447
+ ], s.prototype, "corner", 2);
448
+ a([
449
+ g({ type: Number })
450
+ ], s.prototype, "margin", 2);
451
+ a([
452
+ g({ type: Number })
453
+ ], s.prototype, "gap", 2);
454
+ a([
455
+ g({ type: Number, attribute: "drag-threshold" })
456
+ ], s.prototype, "dragThreshold", 2);
457
+ a([
458
+ g({ type: String, attribute: "storage-key" })
459
+ ], s.prototype, "storageKey", 2);
460
+ a([
461
+ g({ type: Boolean, attribute: "no-persist" })
462
+ ], s.prototype, "noPersist", 2);
463
+ a([
464
+ B()
465
+ ], s.prototype, "_dragging", 2);
466
+ a([
467
+ A('[part="trigger"]')
468
+ ], s.prototype, "_triggerEl", 2);
469
+ a([
470
+ A('[part="menu"]')
471
+ ], s.prototype, "_menuEl", 2);
472
+ s = a([
473
+ W(Oe)
474
+ ], s);
475
+ export {
476
+ s as FlyingMenu
477
+ };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@igor-ganov/flying-menu",
3
+ "version": "0.1.0",
4
+ "description": "Headless, draggable corner-snapping flying menu as a Lit web component",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "igor-ganov",
8
+ "homepage": "https://igor-ganov.github.io/flying-menu/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/igor-ganov/flying-menu.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/igor-ganov/flying-menu/issues"
15
+ },
16
+ "keywords": [
17
+ "lit",
18
+ "web-component",
19
+ "custom-element",
20
+ "menu",
21
+ "headless",
22
+ "draggable",
23
+ "fab",
24
+ "accessible",
25
+ "a11y"
26
+ ],
27
+ "main": "./dist/flying-menu.js",
28
+ "module": "./dist/flying-menu.js",
29
+ "types": "./dist/flying-menu.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/flying-menu.d.ts",
33
+ "import": "./dist/flying-menu.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": ["dist"],
38
+ "sideEffects": ["./dist/flying-menu.js"],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "dev": "vite",
44
+ "dev:example": "vite --config vite.example.config.ts",
45
+ "build": "vite build && tsc -p tsconfig.build.json && rollup -c rollup.dts.config.js && bun run clean:types",
46
+ "clean:types": "node -e \"require('node:fs').rmSync('.types',{recursive:true,force:true})\"",
47
+ "build:example": "vite build --config vite.example.config.ts",
48
+ "preview:example": "vite preview --config vite.example.config.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "lint": "eslint src",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "test:e2e": "playwright test",
54
+ "prepublishOnly": "bun run typecheck && bun run lint && bun run test && bun run build"
55
+ },
56
+ "peerDependencies": {
57
+ "lit": "^3.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@axe-core/playwright": "^4.10.1",
61
+ "@playwright/test": "^1.49.1",
62
+ "eslint": "^10.4.1",
63
+ "eslint-plugin-functional": "^10.0.0",
64
+ "eslint-plugin-unicorn": "^64.0.0",
65
+ "happy-dom": "^16.5.3",
66
+ "lit": "^3.2.1",
67
+ "rollup": "^4.61.1",
68
+ "rollup-plugin-dts": "^6.4.1",
69
+ "typescript": "^5.7.3",
70
+ "typescript-eslint": "^8.60.1",
71
+ "vite": "^6.0.7",
72
+ "vitest": "^2.1.8"
73
+ }
74
+ }