@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 +156 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +1 -0
- package/package.json +44 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|