@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 +21 -0
- package/README.md +139 -0
- package/dist/flying-menu.d.ts +94 -0
- package/dist/flying-menu.js +477 -0
- package/package.json +74 -0
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
|
+
}
|