@data-slot/select 0.2.14

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,198 @@
1
+ # @data-slot/select
2
+
3
+ A headless, accessible select component for choosing a single value from a dropdown list.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @data-slot/select
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### HTML Structure
14
+
15
+ ```html
16
+ <div data-slot="select" data-placeholder="Choose a fruit...">
17
+ <button data-slot="select-trigger">
18
+ <span data-slot="select-value"></span>
19
+ <!-- Add your own chevron icon -->
20
+ </button>
21
+ <div data-slot="select-content" hidden>
22
+ <div data-slot="select-group">
23
+ <div data-slot="select-label">Fruits</div>
24
+ <div data-slot="select-item" data-value="apple">Apple</div>
25
+ <div data-slot="select-item" data-value="banana">Banana</div>
26
+ <div data-slot="select-item" data-value="orange">Orange</div>
27
+ </div>
28
+ <div data-slot="select-separator"></div>
29
+ <div data-slot="select-item" data-value="other">Other</div>
30
+ </div>
31
+ </div>
32
+ ```
33
+
34
+ ### JavaScript
35
+
36
+ ```javascript
37
+ import { create, createSelect } from '@data-slot/select';
38
+
39
+ // Auto-discover and bind all selects
40
+ const controllers = create();
41
+
42
+ // Or bind a specific element
43
+ const root = document.querySelector('[data-slot="select"]');
44
+ const controller = createSelect(root, {
45
+ defaultValue: 'apple',
46
+ onValueChange: (value) => console.log('Selected:', value),
47
+ });
48
+
49
+ // Programmatic control
50
+ controller.open();
51
+ controller.close();
52
+ controller.select('banana');
53
+ console.log(controller.value); // 'banana'
54
+
55
+ // Cleanup
56
+ controller.destroy();
57
+ ```
58
+
59
+ ## Slots
60
+
61
+ | Slot | Description |
62
+ |------|-------------|
63
+ | `select` | Root container |
64
+ | `select-trigger` | Button that opens the popup |
65
+ | `select-value` | Displays selected value (inside trigger) |
66
+ | `select-content` | Popup container for options |
67
+ | `select-item` | Individual selectable option |
68
+ | `select-group` | Groups related items |
69
+ | `select-label` | Label for a group |
70
+ | `select-separator` | Visual divider between items/groups |
71
+
72
+ ## Options
73
+
74
+ Options can be passed via JavaScript or data attributes (JS takes precedence).
75
+
76
+ | Option | Data Attribute | Type | Default | Description |
77
+ |--------|---------------|------|---------|-------------|
78
+ | `defaultValue` | `data-default-value` | `string` | `null` | Initial selected value |
79
+ | `placeholder` | `data-placeholder` | `string` | `""` | Text when no value selected |
80
+ | `disabled` | `data-disabled` | `boolean` | `false` | Disable interaction |
81
+ | `required` | `data-required` | `boolean` | `false` | Form validation required |
82
+ | `name` | `data-name` | `string` | - | Form field name (creates hidden input) |
83
+ | `position` | `data-position` | `"item-aligned" \| "popper"` | `"item-aligned"` | Positioning mode (see below) |
84
+ | `avoidCollisions` | `data-avoid-collisions` | `boolean` | `true` | Adjust to stay in viewport |
85
+ | `collisionPadding` | `data-collision-padding` | `number` | `8` | Viewport edge padding (px) |
86
+
87
+ ### Positioning Modes
88
+
89
+ **`item-aligned` (default)**: The popup positions itself so the selected item aligns with the trigger, similar to native `<select>` elements. The popup width matches the trigger width.
90
+
91
+ **`popper`**: The popup appears below or above the trigger like a dropdown menu. Additional options apply:
92
+
93
+ | Option | Data Attribute | Type | Default | Description |
94
+ |--------|---------------|------|---------|-------------|
95
+ | `side` | `data-side` | `"top" \| "bottom"` | `"bottom"` | Popup placement |
96
+ | `align` | `data-align` | `"start" \| "center" \| "end"` | `"start"` | Popup alignment |
97
+ | `sideOffset` | `data-side-offset` | `number` | `4` | Distance from trigger (px) |
98
+ | `alignOffset` | `data-align-offset` | `number` | `0` | Offset from alignment edge (px) |
99
+
100
+ ### Callbacks
101
+
102
+ | Callback | Type | Description |
103
+ |----------|------|-------------|
104
+ | `onValueChange` | `(value: string \| null) => void` | Called when selection changes |
105
+ | `onOpenChange` | `(open: boolean) => void` | Called when popup opens/closes |
106
+
107
+ ## Controller API
108
+
109
+ ```typescript
110
+ interface SelectController {
111
+ readonly value: string | null; // Current selected value
112
+ readonly isOpen: boolean; // Current open state
113
+ select(value: string): void; // Select a value
114
+ open(): void; // Open the popup
115
+ close(): void; // Close the popup
116
+ destroy(): void; // Cleanup
117
+ }
118
+ ```
119
+
120
+ ## Events
121
+
122
+ ### Outbound Events (component emits)
123
+
124
+ ```javascript
125
+ root.addEventListener('select:change', (e) => {
126
+ console.log('Value changed:', e.detail.value);
127
+ });
128
+
129
+ root.addEventListener('select:open-change', (e) => {
130
+ console.log('Open state:', e.detail.open);
131
+ });
132
+ ```
133
+
134
+ ### Inbound Events (component listens)
135
+
136
+ ```javascript
137
+ // Set value
138
+ root.dispatchEvent(new CustomEvent('select:set', {
139
+ detail: { value: 'apple' }
140
+ }));
141
+
142
+ // Set open state
143
+ root.dispatchEvent(new CustomEvent('select:set', {
144
+ detail: { open: true }
145
+ }));
146
+ ```
147
+
148
+ ## Data Attributes (State)
149
+
150
+ The component sets these attributes to reflect state:
151
+
152
+ | Attribute | Element | Values | Description |
153
+ |-----------|---------|--------|-------------|
154
+ | `data-state` | root, trigger, content | `"open" \| "closed"` | Open state |
155
+ | `data-value` | root | `string` | Current selected value |
156
+ | `data-selected` | item | (presence) | Selected item |
157
+ | `data-highlighted` | item | (presence) | Keyboard-focused item |
158
+ | `data-placeholder` | trigger | (presence) | When showing placeholder |
159
+ | `data-label` | item | `string` | Display text for trigger (optional, falls back to textContent) |
160
+
161
+ ## Keyboard Navigation
162
+
163
+ | Key | Action |
164
+ |-----|--------|
165
+ | `Enter`, `Space`, `ArrowDown`, `ArrowUp` | Open popup (when trigger focused) |
166
+ | `ArrowDown` | Move to next item |
167
+ | `ArrowUp` | Move to previous item |
168
+ | `Home` | Move to first item |
169
+ | `End` | Move to last item |
170
+ | `Enter`, `Space` | Select highlighted item |
171
+ | `Escape` | Close popup |
172
+ | `Tab` | Close popup and move focus |
173
+ | Type characters | Jump to matching item |
174
+
175
+ ## Accessibility
176
+
177
+ - Trigger: `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded`, `aria-controls`
178
+ - Content: `role="listbox"`, `aria-labelledby`
179
+ - Item: `role="option"`, `aria-selected`, `aria-disabled`
180
+ - Group: `role="group"`, `aria-labelledby`
181
+ - Disabled items are skipped during keyboard navigation
182
+
183
+ ## Form Integration
184
+
185
+ When `name` is provided, a hidden input is automatically created for form submission:
186
+
187
+ ```html
188
+ <form>
189
+ <div data-slot="select" data-name="fruit">
190
+ <!-- ... -->
191
+ </div>
192
+ <button type="submit">Submit</button>
193
+ </form>
194
+ ```
195
+
196
+ ## License
197
+
198
+ 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}"]`)],r=new WeakMap;function i(e,t,n){if(typeof process<`u`&&process.env?.NODE_ENV===`production`)return;let i=r.get(e);i||(i=new Set,r.set(e,i)),!i.has(t)&&(i.add(t),console.warn(`[@data-slot] ${n}`))}function a(e){let t=`data-${e.replace(/([A-Z])/g,`-$1`).toLowerCase()}`,n=`data-${e}`;return t===n?[t]:[t,n]}function o(e,t){for(let n of a(t))if(e.hasAttribute(n))return e.getAttribute(n);return null}function s(e,t){return a(t).some(t=>e.hasAttribute(t))}const c=new Set([``,`true`,`1`,`yes`]),l=new Set([`false`,`0`,`no`]);function u(e,t){if(!s(e,t))return;let n=o(e,t);if(n===null)return;let r=n.toLowerCase();if(c.has(r))return!0;if(l.has(r))return!1;i(e,t,`Invalid boolean value "${n}" for data-${t}. Expected: true/false/1/0/yes/no or empty.`)}function d(e,t){let n=o(e,t);if(n===null||n===``)return;let r=Number(n);if(Number.isNaN(r)||!Number.isFinite(r)){i(e,t,`Invalid number value "${n}" for data-${t}.`);return}return r}function f(e,t){if(s(e,t))return o(e,t)??void 0}function p(e,t,n){let r=o(e,t);if(r!==null){if(n.includes(r))return r;i(e,t,`Invalid value "${r}" for data-${t}. Expected one of: ${n.join(`, `)}.`)}}let m=0;const h=(e,t)=>e.id||=`${t}-${++m}`,g=(e,t,n)=>{n===null?e.removeAttribute(`aria-${t}`):e.setAttribute(`aria-${t}`,String(n))};function _(e,t,n,r){return e.addEventListener(t,n,r),()=>e.removeEventListener(t,n,r)}const v=(e,t,n)=>e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:n})),y=[`top`,`bottom`],b=[`start`,`center`,`end`],ee=[`item-aligned`,`popper`],te={top:`bottom`,bottom:`top`};function x(n,r={}){let i=e(n,`select-trigger`),a=e(n,`select-content`),o=e(n,`select-value`);if(!i||!a)throw Error(`Select requires trigger and content slots`);let s=r.defaultValue??f(n,`defaultValue`)??null,c=r.defaultOpen??u(n,`defaultOpen`)??!1,l=r.placeholder??f(n,`placeholder`)??``,m=r.disabled??u(n,`disabled`)??!1,x=r.required??u(n,`required`)??!1,C=r.name??f(n,`name`)??null,ne=r.onValueChange,re=r.onOpenChange,w=r.position??p(n,`position`,ee)??`item-aligned`,ie=r.side??p(a,`side`,y)??p(n,`side`,y)??`bottom`,T=r.align??p(a,`align`,b)??p(n,`align`,b)??`start`,E=r.sideOffset??d(a,`sideOffset`)??d(n,`sideOffset`)??4,D=r.alignOffset??d(a,`alignOffset`)??d(n,`alignOffset`)??0,O=r.avoidCollisions??u(a,`avoidCollisions`)??u(n,`avoidCollisions`)??!0,k=r.collisionPadding??d(a,`collisionPadding`)??d(n,`collisionPadding`)??8,A=!1,j=s,M=null,N=-1,P=``,F=null,I=!1,L=[],R=[],z=[],B=new Map,V=null,H=[],U=null,W=e=>e.hasAttribute(`disabled`)||e.hasAttribute(`data-disabled`)||e.getAttribute(`aria-disabled`)===`true`,ae=h(i,`select-trigger`),oe=h(a,`select-content`);i.setAttribute(`role`,`combobox`),i.setAttribute(`aria-haspopup`,`listbox`),i.setAttribute(`aria-controls`,oe),i.hasAttribute(`type`)||i.setAttribute(`type`,`button`),a.setAttribute(`role`,`listbox`),a.setAttribute(`aria-labelledby`,ae),a.tabIndex=-1,m&&(i.setAttribute(`aria-disabled`,`true`),i.setAttribute(`data-disabled`,``)),x&&i.setAttribute(`aria-required`,`true`),C&&(U=document.createElement(`input`),U.type=`hidden`,U.name=C,U.value=j??``,n.appendChild(U));let G=()=>{R=t(a,`select-item`);for(let e of R){e.setAttribute(`role`,`option`),e.hasAttribute(`data-disabled`)||e.hasAttribute(`disabled`)?e.setAttribute(`aria-disabled`,`true`):e.removeAttribute(`aria-disabled`),e.tabIndex=-1;let t=e.dataset.value;t===j?(g(e,`selected`,!0),e.setAttribute(`data-selected`,``)):(g(e,`selected`,!1),e.removeAttribute(`data-selected`))}z=R.filter(e=>!W(e)),B=new Map(z.map((e,t)=>[e,t]));let n=t(a,`select-group`);for(let t of n){t.setAttribute(`role`,`group`);let n=e(t,`select-label`);if(n){let e=h(n,`select-label`);t.setAttribute(`aria-labelledby`,e)}}},K=(e,t,n,r)=>{let i=0,a=0;return a=e===`top`?n.top-r.height-E:n.bottom+E,i=t===`start`?n.left+D:t===`center`?n.left+n.width/2-r.width/2+D:n.right-r.width-D,{x:i,y:a}},se=(e,t)=>{let n=R.find(e=>e.dataset.value===j),r=e.left,i,a=0;if(n){let r=n.getBoundingClientRect();a=r.top-t.top,i=e.top+e.height/2-r.height/2-a}else i=e.top;return{x:r,y:i,itemOffsetTop:a}},q=()=>{let e=i.getBoundingClientRect(),t=window.innerWidth,n=window.innerHeight;a.style.minWidth=`${e.width}px`;let r=a.getBoundingClientRect(),o,s=`bottom`;if(w===`item-aligned`){let i=se(e,r);o={x:i.x,y:i.y},O&&(o.y<k?o.y=k:o.y+r.height>n-k&&(o.y=n-r.height-k),o.x<k?o.x=k:o.x+r.width>t-k&&(o.x=t-r.width-k)),s=o.y<e.top?`top`:`bottom`}else if(s=ie,o=K(s,T,e,r),O){let i=(e,t)=>e===`top`?t.y<k:t.y+r.height>n-k;if(i(s,o)){let t=te[s],n=K(t,T,e,r);i(t,n)||(s=t,o=n)}o.x<k?o.x=k:o.x+r.width>t-k&&(o.x=t-r.width-k),o.y<k?o.y=k:o.y+r.height>n-k&&(o.y=n-r.height-k)}a.style.position=`fixed`,a.style.top=`${o.y}px`,a.style.left=`${o.x}px`,a.style.margin=`0`,a.setAttribute(`data-side`,s),a.setAttribute(`data-align`,w===`item-aligned`?`center`:T)},ce=()=>{V===null&&(V=requestAnimationFrame(()=>{V=null,A&&q()}))},J=()=>{V!==null&&(cancelAnimationFrame(V),V=null),H.forEach(e=>e()),H.length=0},le=()=>{if(H.length>0)return;let e=()=>ce();window.addEventListener(`resize`,e),window.addEventListener(`scroll`,e,!0),H.push(()=>window.removeEventListener(`resize`,e),()=>window.removeEventListener(`scroll`,e,!0));let t=new ResizeObserver(e);t.observe(i),t.observe(a),H.push(()=>t.disconnect())},Y=(e,t=!0)=>{for(let n=0;n<z.length;n++){let r=z[n];n===e?(r.setAttribute(`data-highlighted`,``),t&&r.focus()):r.removeAttribute(`data-highlighted`)}N=e},X=()=>{for(let e of R)e.removeAttribute(`data-highlighted`);N=-1},Z=e=>{n.setAttribute(`data-state`,e),i.setAttribute(`data-state`,e),a.setAttribute(`data-state`,e)},ue=()=>{if(o)if(j===null)o.textContent=l,i.setAttribute(`data-placeholder`,``);else{let e=R.find(e=>e.dataset.value===j),t=e?.dataset.label??e?.textContent?.trim()??j;o.textContent=t,i.removeAttribute(`data-placeholder`)}},Q=(e,t=!1)=>{if(A!==e&&!(m&&e)){if(e){M=document.activeElement,A=!0,g(i,`expanded`,!0),a.hidden=!1,Z(`open`),G(),I=!1;let e=z.findIndex(e=>e.dataset.value===j);e>=0?Y(e,!1):X(),le(),q(),a.focus()}else A=!1,g(i,`expanded`,!1),a.hidden=!0,Z(`closed`),X(),P=``,I=!1,J(),t?M=null:requestAnimationFrame(()=>{M&&document.contains(M)?M.focus():i&&document.contains(i)&&i.focus(),M=null});v(n,`select:open-change`,{open:A}),re?.(A)}},$=(e,t=!1)=>{if(j===e&&!t)return;let r=j;j=e,U&&(U.value=e??``),e===null?n.removeAttribute(`data-value`):n.setAttribute(`data-value`,e);for(let t of R){let n=t.dataset.value;n===e?(g(t,`selected`,!0),t.setAttribute(`data-selected`,``)):(g(t,`selected`,!1),t.removeAttribute(`data-selected`))}ue(),!t&&r!==e&&(v(n,`select:change`,{value:e}),ne?.(e))},de=e=>{if(W(e))return;let t=e.dataset.value;t!==void 0&&($(t),Q(!1))},fe=e=>{let t=z.length;if(t!==0)switch(e.key){case`ArrowDown`:e.preventDefault(),I=!0,Y(N===-1?0:(N+1)%t);break;case`ArrowUp`:e.preventDefault(),I=!0,Y(N===-1?t-1:(N-1+t)%t);break;case`Home`:e.preventDefault(),I=!0,Y(0);break;case`End`:e.preventDefault(),I=!0,Y(t-1);break;case`Enter`:case` `:e.preventDefault(),N>=0&&de(z[N]);break;case`Tab`:Q(!1,!0);break;case`Escape`:e.preventDefault(),Q(!1);break;default:e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.preventDefault(),pe(e.key.toLowerCase()))}},pe=e=>{F&&clearTimeout(F),F=setTimeout(()=>{P=``},500),P+=e;let t=z.findIndex(e=>(e.textContent?.trim().toLowerCase()||``).startsWith(P));if(t===-1&&P.length===1){let n=N+1;for(let r=0;r<z.length;r++){let i=(n+r)%z.length;if((z[i].textContent?.trim().toLowerCase()||``).startsWith(e)){t=i;break}}}t!==-1&&(I=!0,Y(t))},me=e=>{if(!m)switch(e.key){case`Enter`:case` `:case`ArrowDown`:case`ArrowUp`:e.preventDefault(),Q(!0);break}};g(i,`expanded`,!1),a.hidden=!0,Z(`closed`),G(),$(j,!0),L.push(_(i,`click`,()=>{m||Q(!A)}),_(i,`keydown`,me)),L.push(_(a,`keydown`,fe),_(a,`click`,e=>{let t=e.target.closest?.(`[data-slot="select-item"]`);t&&de(t)}),_(a,`pointermove`,e=>{let t=e.target.closest?.(`[data-slot="select-item"]`);if(!(I&&(I=!1,t&&B.get(t)===N))&&t&&!W(t)){let e=B.get(t);e!==void 0&&e!==N&&Y(e,!1)}}),_(a,`pointerleave`,()=>{I||X()})),L.push(_(document,`pointerdown`,e=>{let t=e.target;A&&!n.contains(t)&&!a.contains(t)&&Q(!1)})),L.push(_(n,`select:set`,e=>{let t=e.detail;t?.value!==void 0&&$(t.value),t?.open!==void 0&&Q(t.open)}));let he={get value(){return j},get isOpen(){return A},select:e=>$(e),open:()=>Q(!0),close:()=>Q(!1),destroy:()=>{F&&clearTimeout(F),J(),L.forEach(e=>e()),L.length=0,U&&U.parentNode&&U.parentNode.removeChild(U),S.delete(n)}};return c&&Q(!0),he}const S=new WeakSet;function C(e=document){let t=[];for(let r of n(e,`select`)){if(S.has(r))continue;S.add(r),t.push(x(r))}return t}exports.create=C,exports.createSelect=x;
@@ -0,0 +1,105 @@
1
+ //#region src/index.d.ts
2
+ /** Side of the trigger to place the content */
3
+ type Side = "top" | "bottom";
4
+ /** Alignment of the content relative to the trigger */
5
+ type Align = "start" | "center" | "end";
6
+ /** Positioning mode for the content */
7
+ type Position = "item-aligned" | "popper";
8
+ interface SelectOptions {
9
+ /** Initial selected value */
10
+ defaultValue?: string;
11
+ /** Callback when value changes */
12
+ onValueChange?: (value: string | null) => void;
13
+ /** Initial open state */
14
+ defaultOpen?: boolean;
15
+ /** Callback when open state changes */
16
+ onOpenChange?: (open: boolean) => void;
17
+ /** Placeholder text when no value selected */
18
+ placeholder?: string;
19
+ /** Disable interaction */
20
+ disabled?: boolean;
21
+ /** Form validation required */
22
+ required?: boolean;
23
+ /** Form field name (auto-creates hidden input) */
24
+ name?: string;
25
+ /**
26
+ * Positioning mode for the content.
27
+ * - "item-aligned": Positions content so selected item aligns with trigger (like native select)
28
+ * - "popper": Positions content below/above trigger like a dropdown
29
+ * @default "item-aligned"
30
+ */
31
+ position?: Position;
32
+ /**
33
+ * The preferred side of the trigger to render against.
34
+ * Will be reversed when collisions occur and `avoidCollisions` is enabled.
35
+ * @default "bottom"
36
+ */
37
+ side?: Side;
38
+ /**
39
+ * The preferred alignment against the trigger.
40
+ * May change when collisions occur.
41
+ * @default "start"
42
+ */
43
+ align?: Align;
44
+ /**
45
+ * The distance in pixels from the trigger.
46
+ * @default 4
47
+ */
48
+ sideOffset?: number;
49
+ /**
50
+ * An offset in pixels from the "start" or "end" alignment options.
51
+ * @default 0
52
+ */
53
+ alignOffset?: number;
54
+ /**
55
+ * When true, overrides side/align preferences to prevent collisions with viewport edges.
56
+ * @default true
57
+ */
58
+ avoidCollisions?: boolean;
59
+ /**
60
+ * The padding between the content and the viewport edges when avoiding collisions.
61
+ * @default 8
62
+ */
63
+ collisionPadding?: number;
64
+ }
65
+ interface SelectController {
66
+ /** Current selected value */
67
+ readonly value: string | null;
68
+ /** Current open state */
69
+ readonly isOpen: boolean;
70
+ /** Select a value programmatically */
71
+ select(value: string): void;
72
+ /** Open the popup */
73
+ open(): void;
74
+ /** Close the popup */
75
+ close(): void;
76
+ /** Cleanup all event listeners */
77
+ destroy(): void;
78
+ }
79
+ /**
80
+ * Create a select controller for a root element.
81
+ *
82
+ * Supports Radix-compatible positioning props for precise placement:
83
+ * - `side`: "top" | "bottom" (default: "bottom")
84
+ * - `align`: "start" | "center" | "end" (default: "start")
85
+ * - `sideOffset`: distance from trigger in px (default: 4)
86
+ * - `alignOffset`: offset from alignment edge in px (default: 0)
87
+ * - `avoidCollisions`: flip/shift to stay in viewport (default: true)
88
+ * - `collisionPadding`: viewport edge padding in px (default: 8)
89
+ *
90
+ * ## Events
91
+ * - **Outbound** `select:change` (on root): Fires when value changes.
92
+ * `event.detail: { value: string | null }`
93
+ * - **Outbound** `select:open-change` (on root): Fires when popup opens/closes.
94
+ * `event.detail: { open: boolean }`
95
+ * - **Inbound** `select:set` (on root): Set value or open state.
96
+ * `event.detail: { value: string } | { open: boolean }`
97
+ */
98
+ declare function createSelect(root: Element, options?: SelectOptions): SelectController;
99
+ /**
100
+ * Find and bind all select components in a scope
101
+ * Returns array of controllers for programmatic access
102
+ */
103
+ declare function create(scope?: ParentNode): SelectController[];
104
+ //#endregion
105
+ export { Align, Position, SelectController, SelectOptions, Side, create, createSelect };
@@ -0,0 +1,105 @@
1
+ //#region src/index.d.ts
2
+ /** Side of the trigger to place the content */
3
+ type Side = "top" | "bottom";
4
+ /** Alignment of the content relative to the trigger */
5
+ type Align = "start" | "center" | "end";
6
+ /** Positioning mode for the content */
7
+ type Position = "item-aligned" | "popper";
8
+ interface SelectOptions {
9
+ /** Initial selected value */
10
+ defaultValue?: string;
11
+ /** Callback when value changes */
12
+ onValueChange?: (value: string | null) => void;
13
+ /** Initial open state */
14
+ defaultOpen?: boolean;
15
+ /** Callback when open state changes */
16
+ onOpenChange?: (open: boolean) => void;
17
+ /** Placeholder text when no value selected */
18
+ placeholder?: string;
19
+ /** Disable interaction */
20
+ disabled?: boolean;
21
+ /** Form validation required */
22
+ required?: boolean;
23
+ /** Form field name (auto-creates hidden input) */
24
+ name?: string;
25
+ /**
26
+ * Positioning mode for the content.
27
+ * - "item-aligned": Positions content so selected item aligns with trigger (like native select)
28
+ * - "popper": Positions content below/above trigger like a dropdown
29
+ * @default "item-aligned"
30
+ */
31
+ position?: Position;
32
+ /**
33
+ * The preferred side of the trigger to render against.
34
+ * Will be reversed when collisions occur and `avoidCollisions` is enabled.
35
+ * @default "bottom"
36
+ */
37
+ side?: Side;
38
+ /**
39
+ * The preferred alignment against the trigger.
40
+ * May change when collisions occur.
41
+ * @default "start"
42
+ */
43
+ align?: Align;
44
+ /**
45
+ * The distance in pixels from the trigger.
46
+ * @default 4
47
+ */
48
+ sideOffset?: number;
49
+ /**
50
+ * An offset in pixels from the "start" or "end" alignment options.
51
+ * @default 0
52
+ */
53
+ alignOffset?: number;
54
+ /**
55
+ * When true, overrides side/align preferences to prevent collisions with viewport edges.
56
+ * @default true
57
+ */
58
+ avoidCollisions?: boolean;
59
+ /**
60
+ * The padding between the content and the viewport edges when avoiding collisions.
61
+ * @default 8
62
+ */
63
+ collisionPadding?: number;
64
+ }
65
+ interface SelectController {
66
+ /** Current selected value */
67
+ readonly value: string | null;
68
+ /** Current open state */
69
+ readonly isOpen: boolean;
70
+ /** Select a value programmatically */
71
+ select(value: string): void;
72
+ /** Open the popup */
73
+ open(): void;
74
+ /** Close the popup */
75
+ close(): void;
76
+ /** Cleanup all event listeners */
77
+ destroy(): void;
78
+ }
79
+ /**
80
+ * Create a select controller for a root element.
81
+ *
82
+ * Supports Radix-compatible positioning props for precise placement:
83
+ * - `side`: "top" | "bottom" (default: "bottom")
84
+ * - `align`: "start" | "center" | "end" (default: "start")
85
+ * - `sideOffset`: distance from trigger in px (default: 4)
86
+ * - `alignOffset`: offset from alignment edge in px (default: 0)
87
+ * - `avoidCollisions`: flip/shift to stay in viewport (default: true)
88
+ * - `collisionPadding`: viewport edge padding in px (default: 8)
89
+ *
90
+ * ## Events
91
+ * - **Outbound** `select:change` (on root): Fires when value changes.
92
+ * `event.detail: { value: string | null }`
93
+ * - **Outbound** `select:open-change` (on root): Fires when popup opens/closes.
94
+ * `event.detail: { open: boolean }`
95
+ * - **Inbound** `select:set` (on root): Set value or open state.
96
+ * `event.detail: { value: string } | { open: boolean }`
97
+ */
98
+ declare function createSelect(root: Element, options?: SelectOptions): SelectController;
99
+ /**
100
+ * Find and bind all select components in a scope
101
+ * Returns array of controllers for programmatic access
102
+ */
103
+ declare function create(scope?: ParentNode): SelectController[];
104
+ //#endregion
105
+ export { Align, Position, SelectController, SelectOptions, Side, create, createSelect };
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}"]`)],r=new WeakMap;function i(e,t,n){if(typeof process<`u`&&process.env?.NODE_ENV===`production`)return;let i=r.get(e);i||(i=new Set,r.set(e,i)),!i.has(t)&&(i.add(t),console.warn(`[@data-slot] ${n}`))}function a(e){let t=`data-${e.replace(/([A-Z])/g,`-$1`).toLowerCase()}`,n=`data-${e}`;return t===n?[t]:[t,n]}function o(e,t){for(let n of a(t))if(e.hasAttribute(n))return e.getAttribute(n);return null}function s(e,t){return a(t).some(t=>e.hasAttribute(t))}const c=new Set([``,`true`,`1`,`yes`]),l=new Set([`false`,`0`,`no`]);function u(e,t){if(!s(e,t))return;let n=o(e,t);if(n===null)return;let r=n.toLowerCase();if(c.has(r))return!0;if(l.has(r))return!1;i(e,t,`Invalid boolean value "${n}" for data-${t}. Expected: true/false/1/0/yes/no or empty.`)}function d(e,t){let n=o(e,t);if(n===null||n===``)return;let r=Number(n);if(Number.isNaN(r)||!Number.isFinite(r)){i(e,t,`Invalid number value "${n}" for data-${t}.`);return}return r}function f(e,t){if(s(e,t))return o(e,t)??void 0}function p(e,t,n){let r=o(e,t);if(r!==null){if(n.includes(r))return r;i(e,t,`Invalid value "${r}" for data-${t}. Expected one of: ${n.join(`, `)}.`)}}let m=0;const h=(e,t)=>e.id||=`${t}-${++m}`,g=(e,t,n)=>{n===null?e.removeAttribute(`aria-${t}`):e.setAttribute(`aria-${t}`,String(n))};function _(e,t,n,r){return e.addEventListener(t,n,r),()=>e.removeEventListener(t,n,r)}const v=(e,t,n)=>e.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:n})),y=[`top`,`bottom`],b=[`start`,`center`,`end`],ee=[`item-aligned`,`popper`],te={top:`bottom`,bottom:`top`};function x(n,r={}){let i=e(n,`select-trigger`),a=e(n,`select-content`),o=e(n,`select-value`);if(!i||!a)throw Error(`Select requires trigger and content slots`);let s=r.defaultValue??f(n,`defaultValue`)??null,c=r.defaultOpen??u(n,`defaultOpen`)??!1,l=r.placeholder??f(n,`placeholder`)??``,m=r.disabled??u(n,`disabled`)??!1,x=r.required??u(n,`required`)??!1,C=r.name??f(n,`name`)??null,ne=r.onValueChange,re=r.onOpenChange,w=r.position??p(n,`position`,ee)??`item-aligned`,ie=r.side??p(a,`side`,y)??p(n,`side`,y)??`bottom`,T=r.align??p(a,`align`,b)??p(n,`align`,b)??`start`,E=r.sideOffset??d(a,`sideOffset`)??d(n,`sideOffset`)??4,D=r.alignOffset??d(a,`alignOffset`)??d(n,`alignOffset`)??0,O=r.avoidCollisions??u(a,`avoidCollisions`)??u(n,`avoidCollisions`)??!0,k=r.collisionPadding??d(a,`collisionPadding`)??d(n,`collisionPadding`)??8,A=!1,j=s,M=null,N=-1,P=``,F=null,I=!1,L=[],R=[],z=[],B=new Map,V=null,H=[],U=null,W=e=>e.hasAttribute(`disabled`)||e.hasAttribute(`data-disabled`)||e.getAttribute(`aria-disabled`)===`true`,ae=h(i,`select-trigger`),oe=h(a,`select-content`);i.setAttribute(`role`,`combobox`),i.setAttribute(`aria-haspopup`,`listbox`),i.setAttribute(`aria-controls`,oe),i.hasAttribute(`type`)||i.setAttribute(`type`,`button`),a.setAttribute(`role`,`listbox`),a.setAttribute(`aria-labelledby`,ae),a.tabIndex=-1,m&&(i.setAttribute(`aria-disabled`,`true`),i.setAttribute(`data-disabled`,``)),x&&i.setAttribute(`aria-required`,`true`),C&&(U=document.createElement(`input`),U.type=`hidden`,U.name=C,U.value=j??``,n.appendChild(U));let G=()=>{R=t(a,`select-item`);for(let e of R){e.setAttribute(`role`,`option`),e.hasAttribute(`data-disabled`)||e.hasAttribute(`disabled`)?e.setAttribute(`aria-disabled`,`true`):e.removeAttribute(`aria-disabled`),e.tabIndex=-1;let t=e.dataset.value;t===j?(g(e,`selected`,!0),e.setAttribute(`data-selected`,``)):(g(e,`selected`,!1),e.removeAttribute(`data-selected`))}z=R.filter(e=>!W(e)),B=new Map(z.map((e,t)=>[e,t]));let n=t(a,`select-group`);for(let t of n){t.setAttribute(`role`,`group`);let n=e(t,`select-label`);if(n){let e=h(n,`select-label`);t.setAttribute(`aria-labelledby`,e)}}},K=(e,t,n,r)=>{let i=0,a=0;return a=e===`top`?n.top-r.height-E:n.bottom+E,i=t===`start`?n.left+D:t===`center`?n.left+n.width/2-r.width/2+D:n.right-r.width-D,{x:i,y:a}},se=(e,t)=>{let n=R.find(e=>e.dataset.value===j),r=e.left,i,a=0;if(n){let r=n.getBoundingClientRect();a=r.top-t.top,i=e.top+e.height/2-r.height/2-a}else i=e.top;return{x:r,y:i,itemOffsetTop:a}},q=()=>{let e=i.getBoundingClientRect(),t=window.innerWidth,n=window.innerHeight;a.style.minWidth=`${e.width}px`;let r=a.getBoundingClientRect(),o,s=`bottom`;if(w===`item-aligned`){let i=se(e,r);o={x:i.x,y:i.y},O&&(o.y<k?o.y=k:o.y+r.height>n-k&&(o.y=n-r.height-k),o.x<k?o.x=k:o.x+r.width>t-k&&(o.x=t-r.width-k)),s=o.y<e.top?`top`:`bottom`}else if(s=ie,o=K(s,T,e,r),O){let i=(e,t)=>e===`top`?t.y<k:t.y+r.height>n-k;if(i(s,o)){let t=te[s],n=K(t,T,e,r);i(t,n)||(s=t,o=n)}o.x<k?o.x=k:o.x+r.width>t-k&&(o.x=t-r.width-k),o.y<k?o.y=k:o.y+r.height>n-k&&(o.y=n-r.height-k)}a.style.position=`fixed`,a.style.top=`${o.y}px`,a.style.left=`${o.x}px`,a.style.margin=`0`,a.setAttribute(`data-side`,s),a.setAttribute(`data-align`,w===`item-aligned`?`center`:T)},ce=()=>{V===null&&(V=requestAnimationFrame(()=>{V=null,A&&q()}))},J=()=>{V!==null&&(cancelAnimationFrame(V),V=null),H.forEach(e=>e()),H.length=0},le=()=>{if(H.length>0)return;let e=()=>ce();window.addEventListener(`resize`,e),window.addEventListener(`scroll`,e,!0),H.push(()=>window.removeEventListener(`resize`,e),()=>window.removeEventListener(`scroll`,e,!0));let t=new ResizeObserver(e);t.observe(i),t.observe(a),H.push(()=>t.disconnect())},Y=(e,t=!0)=>{for(let n=0;n<z.length;n++){let r=z[n];n===e?(r.setAttribute(`data-highlighted`,``),t&&r.focus()):r.removeAttribute(`data-highlighted`)}N=e},X=()=>{for(let e of R)e.removeAttribute(`data-highlighted`);N=-1},Z=e=>{n.setAttribute(`data-state`,e),i.setAttribute(`data-state`,e),a.setAttribute(`data-state`,e)},ue=()=>{if(o)if(j===null)o.textContent=l,i.setAttribute(`data-placeholder`,``);else{let e=R.find(e=>e.dataset.value===j),t=e?.dataset.label??e?.textContent?.trim()??j;o.textContent=t,i.removeAttribute(`data-placeholder`)}},Q=(e,t=!1)=>{if(A!==e&&!(m&&e)){if(e){M=document.activeElement,A=!0,g(i,`expanded`,!0),a.hidden=!1,Z(`open`),G(),I=!1;let e=z.findIndex(e=>e.dataset.value===j);e>=0?Y(e,!1):X(),le(),q(),a.focus()}else A=!1,g(i,`expanded`,!1),a.hidden=!0,Z(`closed`),X(),P=``,I=!1,J(),t?M=null:requestAnimationFrame(()=>{M&&document.contains(M)?M.focus():i&&document.contains(i)&&i.focus(),M=null});v(n,`select:open-change`,{open:A}),re?.(A)}},$=(e,t=!1)=>{if(j===e&&!t)return;let r=j;j=e,U&&(U.value=e??``),e===null?n.removeAttribute(`data-value`):n.setAttribute(`data-value`,e);for(let t of R){let n=t.dataset.value;n===e?(g(t,`selected`,!0),t.setAttribute(`data-selected`,``)):(g(t,`selected`,!1),t.removeAttribute(`data-selected`))}ue(),!t&&r!==e&&(v(n,`select:change`,{value:e}),ne?.(e))},de=e=>{if(W(e))return;let t=e.dataset.value;t!==void 0&&($(t),Q(!1))},fe=e=>{let t=z.length;if(t!==0)switch(e.key){case`ArrowDown`:e.preventDefault(),I=!0,Y(N===-1?0:(N+1)%t);break;case`ArrowUp`:e.preventDefault(),I=!0,Y(N===-1?t-1:(N-1+t)%t);break;case`Home`:e.preventDefault(),I=!0,Y(0);break;case`End`:e.preventDefault(),I=!0,Y(t-1);break;case`Enter`:case` `:e.preventDefault(),N>=0&&de(z[N]);break;case`Tab`:Q(!1,!0);break;case`Escape`:e.preventDefault(),Q(!1);break;default:e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.preventDefault(),pe(e.key.toLowerCase()))}},pe=e=>{F&&clearTimeout(F),F=setTimeout(()=>{P=``},500),P+=e;let t=z.findIndex(e=>(e.textContent?.trim().toLowerCase()||``).startsWith(P));if(t===-1&&P.length===1){let n=N+1;for(let r=0;r<z.length;r++){let i=(n+r)%z.length;if((z[i].textContent?.trim().toLowerCase()||``).startsWith(e)){t=i;break}}}t!==-1&&(I=!0,Y(t))},me=e=>{if(!m)switch(e.key){case`Enter`:case` `:case`ArrowDown`:case`ArrowUp`:e.preventDefault(),Q(!0);break}};g(i,`expanded`,!1),a.hidden=!0,Z(`closed`),G(),$(j,!0),L.push(_(i,`click`,()=>{m||Q(!A)}),_(i,`keydown`,me)),L.push(_(a,`keydown`,fe),_(a,`click`,e=>{let t=e.target.closest?.(`[data-slot="select-item"]`);t&&de(t)}),_(a,`pointermove`,e=>{let t=e.target.closest?.(`[data-slot="select-item"]`);if(!(I&&(I=!1,t&&B.get(t)===N))&&t&&!W(t)){let e=B.get(t);e!==void 0&&e!==N&&Y(e,!1)}}),_(a,`pointerleave`,()=>{I||X()})),L.push(_(document,`pointerdown`,e=>{let t=e.target;A&&!n.contains(t)&&!a.contains(t)&&Q(!1)})),L.push(_(n,`select:set`,e=>{let t=e.detail;t?.value!==void 0&&$(t.value),t?.open!==void 0&&Q(t.open)}));let he={get value(){return j},get isOpen(){return A},select:e=>$(e),open:()=>Q(!0),close:()=>Q(!1),destroy:()=>{F&&clearTimeout(F),J(),L.forEach(e=>e()),L.length=0,U&&U.parentNode&&U.parentNode.removeChild(U),S.delete(n)}};return c&&Q(!0),he}const S=new WeakSet;function C(e=document){let t=[];for(let r of n(e,`select`)){if(S.has(r))continue;S.add(r),t.push(x(r))}return t}export{C as create,x as createSelect};
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@data-slot/select",
3
+ "version": "0.2.14",
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/select"
34
+ },
35
+ "keywords": [
36
+ "headless",
37
+ "ui",
38
+ "select",
39
+ "dropdown",
40
+ "combobox",
41
+ "vanilla",
42
+ "data-slot"
43
+ ],
44
+ "license": "MIT"
45
+ }