@data-slot/dropdown-menu 0.2.2

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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @data-slot/dropdown-menu
2
+
3
+ Headless dropdown menu component with full keyboard navigation and ARIA support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @data-slot/dropdown-menu
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### HTML Structure
14
+
15
+ ```html
16
+ <div data-slot="dropdown-menu">
17
+ <button data-slot="dropdown-menu-trigger">Options</button>
18
+ <div data-slot="dropdown-menu-content">
19
+ <div data-slot="dropdown-menu-group">
20
+ <div data-slot="dropdown-menu-label">Actions</div>
21
+ <button data-slot="dropdown-menu-item">
22
+ Edit
23
+ <span data-slot="dropdown-menu-shortcut">Ctrl+E</span>
24
+ </button>
25
+ <button data-slot="dropdown-menu-item" data-variant="destructive">Delete</button>
26
+ </div>
27
+ <div data-slot="dropdown-menu-separator"></div>
28
+ <button data-slot="dropdown-menu-item" data-disabled>Disabled</button>
29
+ </div>
30
+ </div>
31
+ ```
32
+
33
+ ### JavaScript
34
+
35
+ ```js
36
+ import { create, createDropdownMenu } from "@data-slot/dropdown-menu";
37
+
38
+ // Auto-bind all dropdown menus in the document
39
+ const controllers = create();
40
+
41
+ // Or bind a specific element
42
+ const root = document.querySelector('[data-slot="dropdown-menu"]');
43
+ const controller = createDropdownMenu(root, {
44
+ onOpenChange: (open) => console.log("Menu open:", open),
45
+ onSelect: (value) => console.log("Selected:", value),
46
+ });
47
+
48
+ // Programmatic control
49
+ controller.open();
50
+ controller.close();
51
+ controller.toggle();
52
+ controller.destroy();
53
+ ```
54
+
55
+ ## Slots
56
+
57
+ | Slot | Description |
58
+ |------|-------------|
59
+ | `dropdown-menu` | Root container |
60
+ | `dropdown-menu-trigger` | Button that opens the menu |
61
+ | `dropdown-menu-content` | The menu panel |
62
+ | `dropdown-menu-group` | Groups related items |
63
+ | `dropdown-menu-label` | Non-interactive label for groups |
64
+ | `dropdown-menu-item` | Clickable menu item |
65
+ | `dropdown-menu-separator` | Visual divider |
66
+ | `dropdown-menu-shortcut` | Keyboard shortcut hint |
67
+
68
+ ## Data Attributes
69
+
70
+ | Attribute | Values | Description |
71
+ |-----------|--------|-------------|
72
+ | `data-state` | `open`, `closed` | Current menu state (on root and content) |
73
+ | `data-side` | `top`, `right`, `bottom`, `left` | Computed side after collision avoidance (may flip) |
74
+ | `data-align` | `start`, `center`, `end` | Requested alignment (position may shift to fit viewport) |
75
+ | `data-variant` | `default`, `destructive` | Item variant for styling |
76
+ | `data-inset` | - | Adds left padding for alignment |
77
+ | `data-disabled` | - | Disables the item |
78
+ | `data-highlighted` | - | Currently focused item |
79
+ | `data-value` | string | Optional value for item selection |
80
+
81
+ ## Keyboard Navigation
82
+
83
+ | Key | Action |
84
+ |-----|--------|
85
+ | `Enter` / `Space` | Open menu (on trigger) or activate item |
86
+ | `ArrowDown` | Open menu (on trigger) or move to next item |
87
+ | `ArrowUp` | Move to previous item |
88
+ | `Home` | Move to first item |
89
+ | `End` | Move to last item |
90
+ | `Escape` | Close menu |
91
+ | `A-Z` | Jump to item starting with letter (typeahead) |
92
+
93
+ ## Events
94
+
95
+ | Event | Detail | Description |
96
+ |-------|--------|-------------|
97
+ | `dropdown-menu:change` | `{ open: boolean }` | Fired when menu opens or closes |
98
+ | `dropdown-menu:select` | `{ value: string }` | Fired when an item is selected |
99
+
100
+ ## Options
101
+
102
+ ```ts
103
+ interface DropdownMenuOptions {
104
+ /** Initial open state */
105
+ defaultOpen?: boolean;
106
+ /** Callback when open state changes */
107
+ onOpenChange?: (open: boolean) => void;
108
+ /** Callback when an item is selected */
109
+ onSelect?: (value: string) => void;
110
+ /** Close when clicking outside (default: true) */
111
+ closeOnClickOutside?: boolean;
112
+ /** Close when pressing Escape (default: true) */
113
+ closeOnEscape?: boolean;
114
+ /** Close when an item is selected (default: true) */
115
+ closeOnSelect?: boolean;
116
+
117
+ // Positioning options (Radix-compatible)
118
+ /** Preferred side of trigger: "top" | "right" | "bottom" | "left" (default: "bottom") */
119
+ side?: "top" | "right" | "bottom" | "left";
120
+ /** Alignment against trigger: "start" | "center" | "end" (default: "start") */
121
+ align?: "start" | "center" | "end";
122
+ /** Distance from trigger in px (default: 4) */
123
+ sideOffset?: number;
124
+ /** Offset from alignment edge in px (default: 0) */
125
+ alignOffset?: number;
126
+ /** Flip/shift to stay in viewport (default: true) */
127
+ avoidCollisions?: boolean;
128
+ /** Viewport edge padding in px (default: 8) */
129
+ collisionPadding?: number;
130
+ }
131
+ ```
132
+
133
+ ## Positioning
134
+
135
+ The dropdown menu uses `position: fixed` and automatically positions itself relative to the trigger. It supports all standard placement options:
136
+
137
+ ```js
138
+ createDropdownMenu(root, {
139
+ side: "bottom", // top, right, bottom, left
140
+ align: "start", // start, center, end
141
+ sideOffset: 4, // gap from trigger
142
+ alignOffset: 0, // shift along alignment axis
143
+ avoidCollisions: true,
144
+ collisionPadding: 8,
145
+ });
146
+ ```
147
+
148
+ When `avoidCollisions` is enabled (default), the menu will:
149
+ - Flip to the opposite side if it would overflow the viewport
150
+ - Shift/clamp to stay within the viewport with the specified padding
151
+
152
+ The content element receives `data-side` (computed, may flip) and `data-align` (requested, position may shift) attributes, useful for animations.
153
+
154
+ ## License
155
+
156
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ const e=(e,t)=>e.querySelector(`[data-slot="${t}"]`),t=(e,t)=>[...e.querySelectorAll(`[data-slot="${t}"]`)],n=(e,t)=>[...e.querySelectorAll(`[data-slot="${t}"]`)];let r=0;const i=(e,t)=>e.id||=`${t}-${++r}`,a=(e,t,n)=>{n===null?e.removeAttribute(`aria-${t}`):e.setAttribute(`aria-${t}`,String(n))};function o(e,t,n,r){return e.addEventListener(t,n,r),()=>e.removeEventListener(t,n,r)}const s=(e,t,n)=>e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:n})),c={top:`bottom`,bottom:`top`,left:`right`,right:`left`};function l(n,r={}){let{defaultOpen:l=!1,onOpenChange:d,onSelect:f,closeOnClickOutside:p=!0,closeOnEscape:m=!0,closeOnSelect:h=!0,side:g=`bottom`,align:_=`start`,sideOffset:v=4,alignOffset:y=0,avoidCollisions:b=!0,collisionPadding:x=8}=r,S=e(n,`dropdown-menu-trigger`),C=e(n,`dropdown-menu-content`);if(!S||!C)throw Error(`DropdownMenu requires trigger and content slots`);let w=!1,T=null,E=-1,D=``,O=null,k=!1,A=[],j=[],M=[],N=new Map,P=null,F=[],I=e=>e.hasAttribute(`disabled`)||e.hasAttribute(`data-disabled`)||e.getAttribute(`aria-disabled`)===`true`,L=i(S,`dropdown-menu-trigger`),R=i(C,`dropdown-menu-content`);S.setAttribute(`aria-haspopup`,`menu`),S.setAttribute(`aria-controls`,R),C.setAttribute(`role`,`menu`),C.setAttribute(`aria-labelledby`,L),C.tabIndex=-1;let z=()=>{j=t(C,`dropdown-menu-item`);for(let e of j)e.setAttribute(`role`,`menuitem`),e.hasAttribute(`data-disabled`)||e.hasAttribute(`disabled`)?e.setAttribute(`aria-disabled`,`true`):e.removeAttribute(`aria-disabled`),e.tabIndex=-1;M=j.filter(e=>!I(e)),N=new Map(M.map((e,t)=>[e,t]))},B=(e,t,n,r)=>{let i=0,a=0;return e===`top`?a=n.top-r.height-v:e===`bottom`?a=n.bottom+v:i=e===`left`?n.left-r.width-v:n.right+v,e===`top`||e===`bottom`?i=t===`start`?n.left+y:t===`center`?n.left+n.width/2-r.width/2+y:n.right-r.width-y:a=t===`start`?n.top+y:t===`center`?n.top+n.height/2-r.height/2+y:n.bottom-r.height-y,{x:i,y:a}},V=()=>{let e=S.getBoundingClientRect(),t=C.getBoundingClientRect(),n=window.innerWidth,r=window.innerHeight,i=g,a=B(i,_,e,t);if(b){let o=(e,i)=>e===`top`?i.y<x:e===`bottom`?i.y+t.height>r-x:e===`left`?i.x<x:i.x+t.width>n-x;if(o(i,a)){let n=c[i],r=B(n,_,e,t);o(n,r)||(i=n,a=r)}a.x<x?a.x=x:a.x+t.width>n-x&&(a.x=n-t.width-x),a.y<x?a.y=x:a.y+t.height>r-x&&(a.y=r-t.height-x)}C.style.position=`fixed`,C.style.top=`${a.y}px`,C.style.left=`${a.x}px`,C.style.margin=`0`,C.setAttribute(`data-side`,i),C.setAttribute(`data-align`,_)},H=()=>{P===null&&(P=requestAnimationFrame(()=>{P=null,w&&V()}))},U=()=>{P!==null&&(cancelAnimationFrame(P),P=null),F.forEach(e=>e()),F.length=0},W=()=>{if(F.length>0)return;let e=()=>H();window.addEventListener(`resize`,e),window.addEventListener(`scroll`,e,!0),F.push(()=>window.removeEventListener(`resize`,e),()=>window.removeEventListener(`scroll`,e,!0));let t=new ResizeObserver(e);t.observe(S),t.observe(C),F.push(()=>t.disconnect())},G=(e,t=!0)=>{for(let n=0;n<M.length;n++){let r=M[n];n===e?(r.setAttribute(`data-highlighted`,``),t&&r.focus()):r.removeAttribute(`data-highlighted`)}E=e},K=()=>{for(let e of j)e.removeAttribute(`data-highlighted`);E=-1},q=e=>{n.setAttribute(`data-state`,e),C.setAttribute(`data-state`,e)},J=e=>{w!==e&&(e?(T=document.activeElement,w=!0,a(S,`expanded`,!0),C.hidden=!1,q(`open`),z(),k=!1,K(),W(),V(),C.focus()):(w=!1,a(S,`expanded`,!1),C.hidden=!0,q(`closed`),K(),D=``,k=!1,U(),requestAnimationFrame(()=>{T&&document.contains(T)?T.focus():S&&document.contains(S)&&S.focus(),T=null})),s(n,`dropdown-menu:change`,{open:w}),d?.(w))},Y=e=>{if(I(e))return;let t=e.dataset.value||e.textContent?.trim()||``;s(n,`dropdown-menu:select`,{value:t}),f?.(t),h&&J(!1)},X=e=>{let t=M.length;if(t!==0)switch(e.key){case`ArrowDown`:e.preventDefault(),k=!0,G(E===-1?0:(E+1)%t);break;case`ArrowUp`:e.preventDefault(),k=!0,G(E===-1?t-1:(E-1+t)%t);break;case`Home`:e.preventDefault(),k=!0,G(0);break;case`End`:e.preventDefault(),k=!0,G(t-1);break;case`Enter`:case` `:e.preventDefault(),E>=0&&Y(M[E]);break;case`Tab`:J(!1);break;default:e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.preventDefault(),Z(e.key.toLowerCase()))}},Z=e=>{O&&clearTimeout(O),O=setTimeout(()=>{D=``},500),D+=e;let t=M.findIndex(e=>(e.textContent?.trim().toLowerCase()||``).startsWith(D));if(t===-1&&D.length===1){let n=E+1;for(let r=0;r<M.length;r++){let i=(n+r)%M.length;if((M[i].textContent?.trim().toLowerCase()||``).startsWith(e)){t=i;break}}}t!==-1&&(k=!0,G(t))};a(S,`expanded`,!1),C.hidden=!0,q(`closed`),A.push(o(S,`click`,()=>J(!w)),o(S,`keydown`,e=>{(e.key===`Enter`||e.key===` `||e.key===`ArrowDown`)&&!w&&(e.preventDefault(),J(!0))})),A.push(o(C,`keydown`,X),o(C,`click`,e=>{let t=e.target.closest?.(`[data-slot="dropdown-menu-item"]`);t&&Y(t)}),o(C,`pointermove`,e=>{let t=e.target.closest?.(`[data-slot="dropdown-menu-item"]`);if(!(k&&(k=!1,t&&N.get(t)===E))&&t&&!I(t)){let e=N.get(t);e!==void 0&&e!==E&&G(e,!1)}})),p&&A.push(o(document,`pointerdown`,e=>{let t=e.target;w&&!n.contains(t)&&!C.contains(t)&&J(!1)})),m&&A.push(o(document,`keydown`,e=>{w&&e.key===`Escape`&&(e.preventDefault(),J(!1))}));let Q={open:()=>J(!0),close:()=>J(!1),toggle:()=>J(!w),get isOpen(){return w},destroy:()=>{O&&clearTimeout(O),U(),A.forEach(e=>e()),A.length=0,u.delete(n)}};return l&&J(!0),Q}const u=new WeakSet;function d(e=document){let t=[];for(let r of n(e,`dropdown-menu`)){if(u.has(r))continue;u.add(r),t.push(l(r))}return t}exports.create=d,exports.createDropdownMenu=l;
@@ -0,0 +1,88 @@
1
+ //#region src/index.d.ts
2
+ /** Side of the trigger to place the content */
3
+ type Side = "top" | "right" | "bottom" | "left";
4
+ /** Alignment of the content relative to the trigger */
5
+ type Align = "start" | "center" | "end";
6
+ interface DropdownMenuOptions {
7
+ /** Initial open state */
8
+ defaultOpen?: boolean;
9
+ /** Callback when open state changes */
10
+ onOpenChange?: (open: boolean) => void;
11
+ /** Callback when an item is selected */
12
+ onSelect?: (value: string) => void;
13
+ /** Close when clicking outside */
14
+ closeOnClickOutside?: boolean;
15
+ /** Close when pressing Escape */
16
+ closeOnEscape?: boolean;
17
+ /** Close when an item is selected */
18
+ closeOnSelect?: boolean;
19
+ /**
20
+ * The preferred side of the trigger to render against.
21
+ * Will be reversed when collisions occur and `avoidCollisions` is enabled.
22
+ * @default "bottom"
23
+ */
24
+ side?: Side;
25
+ /**
26
+ * The preferred alignment against the trigger.
27
+ * May change when collisions occur.
28
+ * @default "start"
29
+ */
30
+ align?: Align;
31
+ /**
32
+ * The distance in pixels from the trigger.
33
+ * @default 4
34
+ */
35
+ sideOffset?: number;
36
+ /**
37
+ * An offset in pixels from the "start" or "end" alignment options.
38
+ * @default 0
39
+ */
40
+ alignOffset?: number;
41
+ /**
42
+ * When true, overrides side/align preferences to prevent collisions with viewport edges.
43
+ * @default true
44
+ */
45
+ avoidCollisions?: boolean;
46
+ /**
47
+ * The padding between the content and the viewport edges when avoiding collisions.
48
+ * @default 8
49
+ */
50
+ collisionPadding?: number;
51
+ }
52
+ interface DropdownMenuController {
53
+ /** Open the dropdown menu */
54
+ open(): void;
55
+ /** Close the dropdown menu */
56
+ close(): void;
57
+ /** Toggle the dropdown menu */
58
+ toggle(): void;
59
+ /** Current open state */
60
+ readonly isOpen: boolean;
61
+ /** Cleanup all event listeners */
62
+ destroy(): void;
63
+ }
64
+ /**
65
+ * Create a dropdown menu controller for a root element.
66
+ *
67
+ * Supports Radix-compatible positioning props for precise placement:
68
+ * - `side`: "top" | "right" | "bottom" | "left" (default: "bottom")
69
+ * - `align`: "start" | "center" | "end" (default: "start")
70
+ * - `sideOffset`: distance from trigger in px (default: 4)
71
+ * - `alignOffset`: offset from alignment edge in px (default: 0)
72
+ * - `avoidCollisions`: flip/shift to stay in viewport (default: true)
73
+ * - `collisionPadding`: viewport edge padding in px (default: 8)
74
+ *
75
+ * ## Events
76
+ * - **Outbound** `dropdown-menu:change` (on root): Fires when menu opens/closes.
77
+ * `event.detail: { open: boolean }`
78
+ * - **Outbound** `dropdown-menu:select` (on root): Fires when an item is selected.
79
+ * `event.detail: { value: string }`
80
+ */
81
+ declare function createDropdownMenu(root: Element, options?: DropdownMenuOptions): DropdownMenuController;
82
+ /**
83
+ * Find and bind all dropdown menu components in a scope
84
+ * Returns array of controllers for programmatic access
85
+ */
86
+ declare function create(scope?: ParentNode): DropdownMenuController[];
87
+ //#endregion
88
+ export { Align, DropdownMenuController, DropdownMenuOptions, Side, create, createDropdownMenu };
@@ -0,0 +1,88 @@
1
+ //#region src/index.d.ts
2
+ /** Side of the trigger to place the content */
3
+ type Side = "top" | "right" | "bottom" | "left";
4
+ /** Alignment of the content relative to the trigger */
5
+ type Align = "start" | "center" | "end";
6
+ interface DropdownMenuOptions {
7
+ /** Initial open state */
8
+ defaultOpen?: boolean;
9
+ /** Callback when open state changes */
10
+ onOpenChange?: (open: boolean) => void;
11
+ /** Callback when an item is selected */
12
+ onSelect?: (value: string) => void;
13
+ /** Close when clicking outside */
14
+ closeOnClickOutside?: boolean;
15
+ /** Close when pressing Escape */
16
+ closeOnEscape?: boolean;
17
+ /** Close when an item is selected */
18
+ closeOnSelect?: boolean;
19
+ /**
20
+ * The preferred side of the trigger to render against.
21
+ * Will be reversed when collisions occur and `avoidCollisions` is enabled.
22
+ * @default "bottom"
23
+ */
24
+ side?: Side;
25
+ /**
26
+ * The preferred alignment against the trigger.
27
+ * May change when collisions occur.
28
+ * @default "start"
29
+ */
30
+ align?: Align;
31
+ /**
32
+ * The distance in pixels from the trigger.
33
+ * @default 4
34
+ */
35
+ sideOffset?: number;
36
+ /**
37
+ * An offset in pixels from the "start" or "end" alignment options.
38
+ * @default 0
39
+ */
40
+ alignOffset?: number;
41
+ /**
42
+ * When true, overrides side/align preferences to prevent collisions with viewport edges.
43
+ * @default true
44
+ */
45
+ avoidCollisions?: boolean;
46
+ /**
47
+ * The padding between the content and the viewport edges when avoiding collisions.
48
+ * @default 8
49
+ */
50
+ collisionPadding?: number;
51
+ }
52
+ interface DropdownMenuController {
53
+ /** Open the dropdown menu */
54
+ open(): void;
55
+ /** Close the dropdown menu */
56
+ close(): void;
57
+ /** Toggle the dropdown menu */
58
+ toggle(): void;
59
+ /** Current open state */
60
+ readonly isOpen: boolean;
61
+ /** Cleanup all event listeners */
62
+ destroy(): void;
63
+ }
64
+ /**
65
+ * Create a dropdown menu controller for a root element.
66
+ *
67
+ * Supports Radix-compatible positioning props for precise placement:
68
+ * - `side`: "top" | "right" | "bottom" | "left" (default: "bottom")
69
+ * - `align`: "start" | "center" | "end" (default: "start")
70
+ * - `sideOffset`: distance from trigger in px (default: 4)
71
+ * - `alignOffset`: offset from alignment edge in px (default: 0)
72
+ * - `avoidCollisions`: flip/shift to stay in viewport (default: true)
73
+ * - `collisionPadding`: viewport edge padding in px (default: 8)
74
+ *
75
+ * ## Events
76
+ * - **Outbound** `dropdown-menu:change` (on root): Fires when menu opens/closes.
77
+ * `event.detail: { open: boolean }`
78
+ * - **Outbound** `dropdown-menu:select` (on root): Fires when an item is selected.
79
+ * `event.detail: { value: string }`
80
+ */
81
+ declare function createDropdownMenu(root: Element, options?: DropdownMenuOptions): DropdownMenuController;
82
+ /**
83
+ * Find and bind all dropdown menu components in a scope
84
+ * Returns array of controllers for programmatic access
85
+ */
86
+ declare function create(scope?: ParentNode): DropdownMenuController[];
87
+ //#endregion
88
+ export { Align, DropdownMenuController, DropdownMenuOptions, Side, create, createDropdownMenu };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ const e=(e,t)=>e.querySelector(`[data-slot="${t}"]`),t=(e,t)=>[...e.querySelectorAll(`[data-slot="${t}"]`)],n=(e,t)=>[...e.querySelectorAll(`[data-slot="${t}"]`)];let r=0;const i=(e,t)=>e.id||=`${t}-${++r}`,a=(e,t,n)=>{n===null?e.removeAttribute(`aria-${t}`):e.setAttribute(`aria-${t}`,String(n))};function o(e,t,n,r){return e.addEventListener(t,n,r),()=>e.removeEventListener(t,n,r)}const s=(e,t,n)=>e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:n})),c={top:`bottom`,bottom:`top`,left:`right`,right:`left`};function l(n,r={}){let{defaultOpen:l=!1,onOpenChange:d,onSelect:f,closeOnClickOutside:p=!0,closeOnEscape:m=!0,closeOnSelect:h=!0,side:g=`bottom`,align:_=`start`,sideOffset:v=4,alignOffset:y=0,avoidCollisions:b=!0,collisionPadding:x=8}=r,S=e(n,`dropdown-menu-trigger`),C=e(n,`dropdown-menu-content`);if(!S||!C)throw Error(`DropdownMenu requires trigger and content slots`);let w=!1,T=null,E=-1,D=``,O=null,k=!1,A=[],j=[],M=[],N=new Map,P=null,F=[],I=e=>e.hasAttribute(`disabled`)||e.hasAttribute(`data-disabled`)||e.getAttribute(`aria-disabled`)===`true`,L=i(S,`dropdown-menu-trigger`),R=i(C,`dropdown-menu-content`);S.setAttribute(`aria-haspopup`,`menu`),S.setAttribute(`aria-controls`,R),C.setAttribute(`role`,`menu`),C.setAttribute(`aria-labelledby`,L),C.tabIndex=-1;let z=()=>{j=t(C,`dropdown-menu-item`);for(let e of j)e.setAttribute(`role`,`menuitem`),e.hasAttribute(`data-disabled`)||e.hasAttribute(`disabled`)?e.setAttribute(`aria-disabled`,`true`):e.removeAttribute(`aria-disabled`),e.tabIndex=-1;M=j.filter(e=>!I(e)),N=new Map(M.map((e,t)=>[e,t]))},B=(e,t,n,r)=>{let i=0,a=0;return e===`top`?a=n.top-r.height-v:e===`bottom`?a=n.bottom+v:i=e===`left`?n.left-r.width-v:n.right+v,e===`top`||e===`bottom`?i=t===`start`?n.left+y:t===`center`?n.left+n.width/2-r.width/2+y:n.right-r.width-y:a=t===`start`?n.top+y:t===`center`?n.top+n.height/2-r.height/2+y:n.bottom-r.height-y,{x:i,y:a}},V=()=>{let e=S.getBoundingClientRect(),t=C.getBoundingClientRect(),n=window.innerWidth,r=window.innerHeight,i=g,a=B(i,_,e,t);if(b){let o=(e,i)=>e===`top`?i.y<x:e===`bottom`?i.y+t.height>r-x:e===`left`?i.x<x:i.x+t.width>n-x;if(o(i,a)){let n=c[i],r=B(n,_,e,t);o(n,r)||(i=n,a=r)}a.x<x?a.x=x:a.x+t.width>n-x&&(a.x=n-t.width-x),a.y<x?a.y=x:a.y+t.height>r-x&&(a.y=r-t.height-x)}C.style.position=`fixed`,C.style.top=`${a.y}px`,C.style.left=`${a.x}px`,C.style.margin=`0`,C.setAttribute(`data-side`,i),C.setAttribute(`data-align`,_)},H=()=>{P===null&&(P=requestAnimationFrame(()=>{P=null,w&&V()}))},U=()=>{P!==null&&(cancelAnimationFrame(P),P=null),F.forEach(e=>e()),F.length=0},W=()=>{if(F.length>0)return;let e=()=>H();window.addEventListener(`resize`,e),window.addEventListener(`scroll`,e,!0),F.push(()=>window.removeEventListener(`resize`,e),()=>window.removeEventListener(`scroll`,e,!0));let t=new ResizeObserver(e);t.observe(S),t.observe(C),F.push(()=>t.disconnect())},G=(e,t=!0)=>{for(let n=0;n<M.length;n++){let r=M[n];n===e?(r.setAttribute(`data-highlighted`,``),t&&r.focus()):r.removeAttribute(`data-highlighted`)}E=e},K=()=>{for(let e of j)e.removeAttribute(`data-highlighted`);E=-1},q=e=>{n.setAttribute(`data-state`,e),C.setAttribute(`data-state`,e)},J=e=>{w!==e&&(e?(T=document.activeElement,w=!0,a(S,`expanded`,!0),C.hidden=!1,q(`open`),z(),k=!1,K(),W(),V(),C.focus()):(w=!1,a(S,`expanded`,!1),C.hidden=!0,q(`closed`),K(),D=``,k=!1,U(),requestAnimationFrame(()=>{T&&document.contains(T)?T.focus():S&&document.contains(S)&&S.focus(),T=null})),s(n,`dropdown-menu:change`,{open:w}),d?.(w))},Y=e=>{if(I(e))return;let t=e.dataset.value||e.textContent?.trim()||``;s(n,`dropdown-menu:select`,{value:t}),f?.(t),h&&J(!1)},X=e=>{let t=M.length;if(t!==0)switch(e.key){case`ArrowDown`:e.preventDefault(),k=!0,G(E===-1?0:(E+1)%t);break;case`ArrowUp`:e.preventDefault(),k=!0,G(E===-1?t-1:(E-1+t)%t);break;case`Home`:e.preventDefault(),k=!0,G(0);break;case`End`:e.preventDefault(),k=!0,G(t-1);break;case`Enter`:case` `:e.preventDefault(),E>=0&&Y(M[E]);break;case`Tab`:J(!1);break;default:e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.preventDefault(),Z(e.key.toLowerCase()))}},Z=e=>{O&&clearTimeout(O),O=setTimeout(()=>{D=``},500),D+=e;let t=M.findIndex(e=>(e.textContent?.trim().toLowerCase()||``).startsWith(D));if(t===-1&&D.length===1){let n=E+1;for(let r=0;r<M.length;r++){let i=(n+r)%M.length;if((M[i].textContent?.trim().toLowerCase()||``).startsWith(e)){t=i;break}}}t!==-1&&(k=!0,G(t))};a(S,`expanded`,!1),C.hidden=!0,q(`closed`),A.push(o(S,`click`,()=>J(!w)),o(S,`keydown`,e=>{(e.key===`Enter`||e.key===` `||e.key===`ArrowDown`)&&!w&&(e.preventDefault(),J(!0))})),A.push(o(C,`keydown`,X),o(C,`click`,e=>{let t=e.target.closest?.(`[data-slot="dropdown-menu-item"]`);t&&Y(t)}),o(C,`pointermove`,e=>{let t=e.target.closest?.(`[data-slot="dropdown-menu-item"]`);if(!(k&&(k=!1,t&&N.get(t)===E))&&t&&!I(t)){let e=N.get(t);e!==void 0&&e!==E&&G(e,!1)}})),p&&A.push(o(document,`pointerdown`,e=>{let t=e.target;w&&!n.contains(t)&&!C.contains(t)&&J(!1)})),m&&A.push(o(document,`keydown`,e=>{w&&e.key===`Escape`&&(e.preventDefault(),J(!1))}));let Q={open:()=>J(!0),close:()=>J(!1),toggle:()=>J(!w),get isOpen(){return w},destroy:()=>{O&&clearTimeout(O),U(),A.forEach(e=>e()),A.length=0,u.delete(n)}};return l&&J(!0),Q}const u=new WeakSet;function d(e=document){let t=[];for(let r of n(e,`dropdown-menu`)){if(u.has(r))continue;u.add(r),t.push(l(r))}return t}export{d as create,l as createDropdownMenu};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@data-slot/dropdown-menu",
3
+ "version": "0.2.2",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsdown"
26
+ },
27
+ "devDependencies": {
28
+ "@data-slot/core": "workspace:*"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/bejamas/data-slot",
33
+ "directory": "packages/dropdown-menu"
34
+ },
35
+ "keywords": [
36
+ "headless",
37
+ "ui",
38
+ "dropdown-menu",
39
+ "menu",
40
+ "vanilla",
41
+ "data-slot"
42
+ ],
43
+ "license": "MIT"
44
+ }