@ladder-ui/select 0.3.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, } from "./select";
2
- export type { SelectProps, SelectItemData, SelectContentProps, SelectGroupProps, SelectItemProps, SelectLabelProps, SelectScrollDownButtonProps, SelectScrollUpButtonProps, SelectSeparatorProps, SelectTriggerProps, SelectValueProps, } from "./select";
1
+ export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator, } from "./select";
2
+ export type { SelectProps, SelectTriggerProps, SelectValueProps, SelectItemProps, } from "./select";
3
+ export { Select as default } from "./select";
package/dist/index.js CHANGED
@@ -1,30 +1,22 @@
1
1
  'use strict';
2
2
 
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
3
5
  var jsxRuntime = require('react/jsx-runtime');
4
6
  var react = require('react');
5
7
  var reactDom = require('react-dom');
6
- var concatClassNames = require('@ladder-ui/core/concatClassNames');
8
+ var core = require('@ladder-ui/core');
9
+ var primitives = require('@ladder-ui/primitives');
7
10
 
8
- // useLayoutEffect runs synchronously after DOM mutations but before the browser
9
- // paints. This eliminates the double-paint flash that useEffect causes for
10
- // position-critical work like dropdown placement.
11
- // Falls back to useEffect in SSR environments (no DOM = no layout to measure).
12
11
  const useIsomorphicLayoutEffect = typeof window !== "undefined" ? react.useLayoutEffect : react.useEffect;
13
- // ─── Inline SVG icons ────────────────────────────────────────────────────────
14
- function ChevronDownIcon({ className }) {
15
- return (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsxRuntime.jsx("path", { d: "m6 9 6 6 6-6" }) }));
16
- }
17
- function ChevronUpIcon({ className }) {
18
- return (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsxRuntime.jsx("path", { d: "m18 15-6-6-6 6" }) }));
12
+ // ─── Icons ──────────────────────────────────────────────────────────────────
13
+ function ChevronDownIcon({ className, style }) {
14
+ return (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, style: style, children: jsxRuntime.jsx("path", { d: "m6 9 6 6 6-6" }) }));
19
15
  }
20
16
  function CheckIcon({ className }) {
21
17
  return (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsxRuntime.jsx("path", { d: "M20 6 9 17l-5-5" }) }));
22
18
  }
23
- // ─── Label extraction helpers ────────────────────────────────────────────────
24
- //
25
- // We scan the JSX children tree in Select root to build a value→label map.
26
- // This works even when SelectContent renders null (closed state), because the
27
- // JSX element tree passed as `children` always contains the full structure.
19
+ // ─── Helper: Label Extraction ────────────────────────────────────────────────
28
20
  function extractTextContent(children) {
29
21
  if (typeof children === "string")
30
22
  return children;
@@ -32,303 +24,160 @@ function extractTextContent(children) {
32
24
  return String(children);
33
25
  if (Array.isArray(children))
34
26
  return children.map(extractTextContent).join("");
35
- if (react.isValidElement(children)) {
27
+ if (react.isValidElement(children))
36
28
  return extractTextContent(children.props.children);
37
- }
38
29
  return "";
39
30
  }
40
31
  function scanItemLabels(children, registry) {
41
32
  react.Children.forEach(children, (child) => {
42
33
  if (!react.isValidElement(child))
43
34
  return;
44
- const props = child.props;
45
- // Detect SelectItem by displayName on the element type function
46
- const dn = typeof child.type === "function"
47
- ? child.type.displayName
48
- : typeof child.type === "object"
49
- ? child.type.displayName
50
- : undefined;
51
- if (dn === "SelectItem" && (typeof props.value === "string" || props.value === null)) {
52
- // null is the Shadcn-style "no selection" sentinel — stored as "" internally
53
- const key = props.value ?? "";
54
- const label = extractTextContent(props.children);
35
+ // Check for SelectItem based on type or name
36
+ const type = child.type;
37
+ const dn = type.displayName || type.name;
38
+ if (dn === "SelectItem") {
39
+ const key = child.props.value ?? "";
40
+ const label = extractTextContent(child.props.children);
55
41
  if (label)
56
42
  registry.set(key, label);
57
43
  }
58
- // Recurse into children of non-item elements
59
- if (props.children) {
60
- scanItemLabels(props.children, registry);
44
+ if (child.props.children) {
45
+ scanItemLabels(child.props.children, registry);
61
46
  }
62
47
  });
63
48
  }
64
- const SelectContext = react.createContext({
65
- open: false,
66
- setOpen: () => { },
67
- value: undefined,
68
- displayLabel: "",
69
- triggerId: "",
70
- contentId: "",
71
- triggerRef: { current: null },
49
+ const SelectContext = react.createContext(null);
50
+ // ─── Variants ────────────────────────────────────────────────────────────────
51
+ const selectTriggerVariants = core.cva({
52
+ base: "lui-select-trigger",
53
+ variants: {
54
+ size: { sm: "lui-select-trigger--sm", md: "lui-select-trigger--md" },
55
+ status: { default: "", error: "lui-select-trigger--error" },
56
+ fullWidth: { true: "lui-select-trigger--full-width" },
57
+ },
58
+ defaultVariants: { size: "md", status: "default" },
72
59
  });
73
- function Select({ value: controlledValue, defaultValue, onValueChange, disabled, children, open: controlledOpen, onOpenChange, items, }) {
74
- const [internalOpen, setInternalOpen] = react.useState(false);
75
- const [internalValue, setInternalValue] = react.useState(defaultValue);
76
- const isOpenControlled = controlledOpen !== undefined;
77
- const isValueControlled = controlledValue !== undefined;
78
- const open = isOpenControlled ? controlledOpen : internalOpen;
79
- const value = isValueControlled ? controlledValue : internalValue;
80
- const triggerId = react.useId();
81
- const contentId = react.useId();
82
- const triggerRef = react.useRef(null);
83
- // Build label registry synchronously from the JSX children tree.
84
- // This works even when SelectContent renders null (closed), because the
85
- // JSX element tree always contains all SelectItem elements.
86
- const labelsMap = new Map();
87
- scanItemLabels(children, labelsMap);
88
- // Merge items prop — supports null values and data-driven usage (.map()).
89
- // Takes precedence over child-scanned labels for matching keys.
90
- if (items) {
91
- for (const item of items) {
92
- labelsMap.set(item.value ?? "", item.label);
93
- }
94
- }
95
- const displayLabel = (value !== undefined ? labelsMap.get(value) : undefined) ?? "";
96
- const setOpen = react.useCallback((next) => {
97
- if (!isOpenControlled)
98
- setInternalOpen(next);
99
- onOpenChange?.(next);
100
- }, [isOpenControlled, onOpenChange]);
101
- const handleValueChange = react.useCallback((newValue) => {
102
- if (!isValueControlled)
103
- setInternalValue(newValue);
104
- onValueChange?.(newValue);
105
- setOpen(false);
106
- }, [isValueControlled, onValueChange, setOpen]);
107
- return (jsxRuntime.jsx(SelectContext, { value: {
108
- open,
109
- setOpen,
110
- value,
111
- displayLabel,
112
- onValueChange: handleValueChange,
113
- disabled,
114
- triggerId,
115
- contentId,
116
- triggerRef,
117
- }, children: children }));
60
+ function Select({ children, items, ...props }) {
61
+ const select = primitives.useSelect(props);
62
+ const labelsMap = react.useMemo(() => {
63
+ const map = new Map();
64
+ // 1. Scan children (Static JSX)
65
+ scanItemLabels(children, map);
66
+ // 2. Add explicit items (Dynamic/SDUI)
67
+ items?.forEach(item => map.set(item.value, item.label));
68
+ return map;
69
+ }, [children, items]);
70
+ const displayLabel = (select.value !== undefined ? labelsMap.get(select.value) : undefined) ?? "";
71
+ return (jsxRuntime.jsx(SelectContext, { value: { ...select, displayLabel }, children: children }));
118
72
  }
119
73
  Select.displayName = "Select";
120
- function SelectTrigger({ ref, className, children, size = "default", fullWidth, disabled: propDisabled, onKeyDown, ...props }) {
74
+ const SelectTrigger = react.forwardRef(({ className, size, status, fullWidth, children, ...props }, ref) => {
121
75
  const ctx = react.use(SelectContext);
122
- const isDisabled = propDisabled || ctx.disabled;
123
- // Merge consumer ref + context triggerRef
124
- const setRef = (el) => {
76
+ if (!ctx)
77
+ return null;
78
+ const setRefs = (el) => {
125
79
  ctx.triggerRef.current = el;
126
- if (typeof ref === "function") {
80
+ if (typeof ref === "function")
127
81
  ref(el);
128
- }
129
- else if (ref) {
82
+ else if (ref)
130
83
  ref.current = el;
131
- }
132
- };
133
- const handleKeyDown = (e) => {
134
- if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
135
- e.preventDefault();
136
- ctx.setOpen(true);
137
- }
138
- onKeyDown?.(e);
139
84
  };
140
- const classes = concatClassNames("lui-select-trigger", size === "sm" ? "lui-select-trigger--sm" : undefined, fullWidth ? "lui-select-trigger--full-width" : undefined, className);
141
- return (jsxRuntime.jsxs("button", { ref: setRef, type: "button", role: "combobox", id: ctx.triggerId, "aria-expanded": ctx.open, "aria-haspopup": "listbox", "aria-controls": ctx.open ? ctx.contentId : undefined, "aria-disabled": isDisabled || undefined, disabled: isDisabled, "data-slot": "select-trigger", "data-size": size, "data-full-width": fullWidth || undefined, className: classes, onClick: () => ctx.setOpen(!ctx.open), onKeyDown: handleKeyDown, ...props, children: [children, jsxRuntime.jsx(ChevronDownIcon, { className: "lui-select-trigger__chevron" })] }));
142
- }
85
+ return (jsxRuntime.jsxs("button", { ref: setRefs, type: "button", role: "combobox", id: ctx.triggerId, "aria-expanded": ctx.open, "aria-haspopup": "listbox", "aria-controls": ctx.open ? ctx.contentId : undefined, disabled: ctx.disabled, className: selectTriggerVariants({ size, status, fullWidth, className }), onClick: (e) => {
86
+ ctx.setOpen(!ctx.open);
87
+ props.onClick?.(e);
88
+ }, onKeyDown: (e) => {
89
+ if (e.key === "Escape" && ctx.open) {
90
+ e.preventDefault();
91
+ ctx.setOpen(false);
92
+ }
93
+ if ((e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") && !ctx.open) {
94
+ e.preventDefault();
95
+ ctx.setOpen(true);
96
+ }
97
+ props.onKeyDown?.(e);
98
+ }, ...props, children: [children, jsxRuntime.jsx(ChevronDownIcon, { className: "lui-select-trigger__chevron" })] }));
99
+ });
143
100
  SelectTrigger.displayName = "SelectTrigger";
144
- function SelectValue({ placeholder, className }) {
101
+ const SelectValue = ({ placeholder, className }) => {
145
102
  const ctx = react.use(SelectContext);
146
- const classes = concatClassNames("lui-select-value", className);
147
- return (jsxRuntime.jsx("span", { "data-slot": "select-value", className: classes, children: ctx.displayLabel || placeholder || "" }));
148
- }
103
+ return (jsxRuntime.jsx("span", { className: core.concatClassNames("lui-select-value", className), "data-slot": "select-value", children: ctx?.displayLabel || placeholder }));
104
+ };
149
105
  SelectValue.displayName = "SelectValue";
150
- function SelectContent({ ref, className, children, position = "item-aligned", }) {
106
+ const SelectContent = react.forwardRef(({ children, className, ...props }, ref) => {
151
107
  const ctx = react.use(SelectContext);
152
108
  const contentRef = react.useRef(null);
153
- // Two-pass positioning:
154
- // Pass 1 — render with visibility:hidden so browser can measure item offsets.
155
- // Pass 2 — compute correct position, set isPositioned=true → visibility:visible.
156
- const [isPositioned, setIsPositioned] = react.useState(false);
157
109
  const [coords, setCoords] = react.useState({ top: 0, left: 0, width: 0 });
158
- // Pass 1→2: measure and position synchronously before browser paint
110
+ const [isPositioned, setIsPositioned] = react.useState(false);
111
+ const { addRef } = primitives.useOutsideClick(() => ctx?.setOpen(false), ctx?.open);
112
+ // Two-pass positioning strategy
159
113
  useIsomorphicLayoutEffect(() => {
160
- if (!ctx.open || !contentRef.current || !ctx.triggerRef.current)
114
+ if (!ctx?.open || !ctx.triggerRef.current || !contentRef.current) {
115
+ setIsPositioned(false);
161
116
  return;
117
+ }
162
118
  const triggerRect = ctx.triggerRef.current.getBoundingClientRect();
163
119
  const content = contentRef.current;
164
120
  const contentHeight = content.offsetHeight;
165
- const contentWidth = content.offsetWidth;
166
121
  const viewportHeight = window.innerHeight;
167
- const viewportWidth = window.innerWidth;
168
- let top;
169
- if (position === "item-aligned") {
170
- // Align the selected item (or first item) vertically with the trigger.
171
- // The item's center should coincide with the trigger's center.
172
- const selectedItem = content.querySelector('[role="option"][aria-selected="true"]');
173
- const firstItem = content.querySelector('[role="option"]');
174
- const targetItem = selectedItem ?? firstItem;
175
- if (targetItem) {
176
- // offsetTop is relative to the fixed content div (its offsetParent)
177
- const itemTop = targetItem.offsetTop;
178
- const itemHeight = targetItem.offsetHeight;
179
- // Center item with trigger: move content up so item.center = trigger.center
180
- top =
181
- triggerRect.top -
182
- itemTop +
183
- (triggerRect.height - itemHeight) / 2;
184
- }
185
- else {
186
- top = triggerRect.top;
187
- }
122
+ // Popper strategy: open below trigger
123
+ let top = triggerRect.bottom + window.scrollY + 4;
124
+ const left = triggerRect.left + window.scrollX;
125
+ // Flip if no space below
126
+ if (triggerRect.bottom + contentHeight + 8 > viewportHeight) {
127
+ top = triggerRect.top + window.scrollY - contentHeight - 4;
188
128
  }
189
- else {
190
- // Popper: open below trigger, fall back to above if insufficient space
191
- const spaceBelow = viewportHeight - triggerRect.bottom;
192
- if (spaceBelow < contentHeight + 8 && triggerRect.top > contentHeight + 8) {
193
- top = triggerRect.top - contentHeight - 4;
194
- }
195
- else {
196
- top = triggerRect.bottom + 4;
197
- }
198
- }
199
- // Clamp to viewport with 8 px margin
200
- top = Math.max(8, Math.min(top, viewportHeight - contentHeight - 8));
201
- const left = Math.max(8, Math.min(triggerRect.left, viewportWidth - contentWidth - 8));
202
129
  setCoords({ top, left, width: triggerRect.width });
203
130
  setIsPositioned(true);
204
- // Run after every open (component remounts each time, so state resets automatically)
205
- // eslint-disable-next-line react-hooks/exhaustive-deps
206
- }, [ctx.open]);
207
- // Focus management wait until content is visible to avoid jarring jump
208
- react.useEffect(() => {
209
- if (!ctx.open || !isPositioned || !contentRef.current)
210
- return;
211
- const selected = contentRef.current.querySelector('[role="option"][aria-selected="true"]');
212
- const first = contentRef.current.querySelector('[role="option"]:not([aria-disabled="true"])');
213
- (selected ?? first)?.focus();
214
- }, [ctx.open, isPositioned]);
215
- // Click-outside to close
216
- react.useEffect(() => {
217
- if (!ctx.open)
218
- return;
219
- const handlePointerDown = (e) => {
220
- const target = e.target;
221
- if (contentRef.current &&
222
- !contentRef.current.contains(target) &&
223
- ctx.triggerRef.current &&
224
- !ctx.triggerRef.current.contains(target)) {
225
- ctx.setOpen(false);
226
- }
227
- };
228
- document.addEventListener("pointerdown", handlePointerDown);
229
- return () => document.removeEventListener("pointerdown", handlePointerDown);
230
- }, [ctx.open, ctx.setOpen, ctx.triggerRef]);
231
- // Keyboard navigation within the listbox
232
- const handleKeyDown = (e) => {
233
- const items = contentRef.current?.querySelectorAll('[role="option"]:not([aria-disabled="true"])');
234
- if (!items || items.length === 0)
235
- return;
236
- const active = document.activeElement;
237
- const currentIndex = Array.from(items).indexOf(active);
238
- switch (e.key) {
239
- case "ArrowDown": {
240
- e.preventDefault();
241
- items[Math.min(currentIndex + 1, items.length - 1)]?.focus();
242
- break;
243
- }
244
- case "ArrowUp": {
245
- e.preventDefault();
246
- items[Math.max(currentIndex - 1, 0)]?.focus();
247
- break;
248
- }
249
- case "Home": {
250
- e.preventDefault();
251
- items[0]?.focus();
252
- break;
253
- }
254
- case "End": {
255
- e.preventDefault();
256
- items[items.length - 1]?.focus();
257
- break;
258
- }
259
- case "Escape":
260
- case "Tab": {
131
+ }, [ctx?.open]);
132
+ if (!ctx?.open)
133
+ return null;
134
+ return reactDom.createPortal(jsxRuntime.jsx("div", { ref: (node) => {
135
+ contentRef.current = node;
136
+ if (typeof ref === "function")
137
+ ref(node);
138
+ else if (ref)
139
+ ref.current = node;
140
+ if (node)
141
+ addRef({ current: node });
142
+ addRef(ctx.triggerRef);
143
+ }, id: ctx.contentId, role: "listbox", "aria-labelledby": ctx.triggerId, "data-slot": "select-content", className: core.concatClassNames("lui-select-content", className), style: {
144
+ position: "absolute",
145
+ top: coords.top,
146
+ left: coords.left,
147
+ width: coords.width,
148
+ minWidth: coords.width,
149
+ zIndex: 9999,
150
+ visibility: isPositioned ? "visible" : "hidden",
151
+ }, onKeyDown: (e) => {
152
+ if (e.key === "Escape") {
261
153
  ctx.setOpen(false);
262
154
  ctx.triggerRef.current?.focus();
263
- break;
264
155
  }
265
- }
266
- };
267
- if (!ctx.open)
268
- return null;
269
- const style = {
270
- position: "fixed",
271
- top: coords.top,
272
- left: coords.left,
273
- width: coords.width || undefined,
274
- minWidth: coords.width || undefined,
275
- zIndex: 9999,
276
- // Hidden during pass 1 to allow measurement without visual flash
277
- visibility: isPositioned ? "visible" : "hidden",
278
- };
279
- const setRef = (el) => {
280
- contentRef.current = el;
281
- if (typeof ref === "function") {
282
- ref(el);
283
- }
284
- else if (ref) {
285
- ref.current = el;
286
- }
287
- };
288
- const classes = concatClassNames("lui-select-content", className);
289
- return reactDom.createPortal(jsxRuntime.jsx("div", { ref: setRef, id: ctx.contentId, role: "listbox", "aria-labelledby": ctx.triggerId, "data-slot": "select-content", "data-state": "open", className: classes, style: style, onKeyDown: handleKeyDown, children: jsxRuntime.jsx("div", { className: "lui-select-content__viewport", children: children }) }), document.body);
290
- }
156
+ props.onKeyDown?.(e);
157
+ }, ...props, children: jsxRuntime.jsx("div", { className: "lui-select-content__viewport", children: children }) }), document.body);
158
+ });
291
159
  SelectContent.displayName = "SelectContent";
292
- function SelectGroup({ ref, className, children, ...props }) {
293
- return (jsxRuntime.jsx("div", { ref: ref, role: "group", "data-slot": "select-group", className: concatClassNames("lui-select-group", className), ...props, children: children }));
294
- }
295
- SelectGroup.displayName = "SelectGroup";
296
- function SelectLabel({ ref, className, children, ...props }) {
297
- return (jsxRuntime.jsx("div", { ref: ref, "data-slot": "select-label", className: concatClassNames("lui-select-label", className), ...props, children: children }));
298
- }
299
- SelectLabel.displayName = "SelectLabel";
300
- function SelectItem({ ref, className, children, value, disabled, onClick, onKeyDown, ...props }) {
160
+ const SelectItem = react.forwardRef(({ value, children, disabled, className, ...props }, ref) => {
301
161
  const ctx = react.use(SelectContext);
302
- // null is the Shadcn-style sentinel for "no selection" — normalize to ""
303
- const normalizedValue = value ?? "";
304
- const isSelected = ctx.value === normalizedValue;
305
- const handleSelect = () => {
306
- if (disabled)
307
- return;
308
- ctx.onValueChange?.(normalizedValue);
309
- };
310
- const handleKeyDown = (e) => {
311
- if (e.key === "Enter" || e.key === " ") {
312
- e.preventDefault();
313
- handleSelect();
314
- }
315
- onKeyDown?.(e);
316
- };
317
- const classes = concatClassNames("lui-select-item", isSelected ? "lui-select-item--selected" : undefined, disabled ? "lui-select-item--disabled" : undefined, className);
318
- return (jsxRuntime.jsxs("div", { ref: ref, role: "option", "aria-selected": isSelected, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, "data-slot": "select-item", "data-value": normalizedValue, className: classes, onClick: handleSelect, onKeyDown: handleKeyDown, ...props, children: [jsxRuntime.jsx("span", { className: "lui-select-item__indicator", "aria-hidden": "true", children: isSelected && jsxRuntime.jsx(CheckIcon, { className: "lui-select-item__check" }) }), jsxRuntime.jsx("span", { className: "lui-select-item__text", children: children })] }));
319
- }
162
+ const isSelected = ctx?.value === value;
163
+ return (jsxRuntime.jsxs("div", { ref: ref, role: "option", "aria-selected": isSelected, "aria-disabled": disabled, className: core.concatClassNames("lui-select-item", isSelected && "lui-select-item--selected", disabled && "lui-select-item--disabled", className), onClick: (e) => {
164
+ if (!disabled) {
165
+ ctx?.onValueChange(value === null ? "" : value);
166
+ ctx?.setOpen(false);
167
+ }
168
+ props.onClick?.(e);
169
+ }, ...props, children: [jsxRuntime.jsx("span", { className: "lui-select-item__indicator", "aria-hidden": "true", children: isSelected && jsxRuntime.jsx(CheckIcon, { className: "lui-select-item__check" }) }), jsxRuntime.jsx("span", { className: "lui-select-item__text", children: children })] }));
170
+ });
320
171
  SelectItem.displayName = "SelectItem";
321
- function SelectSeparator({ ref, className, ...props }) {
322
- return (jsxRuntime.jsx("hr", { ref: ref, "data-slot": "select-separator", className: concatClassNames("lui-select-separator", className), ...props }));
323
- }
172
+ const SelectGroup = react.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("div", { ref: ref, role: "group", className: core.concatClassNames("lui-select-group", className), ...props })));
173
+ SelectGroup.displayName = "SelectGroup";
174
+ const SelectLabel = react.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("div", { ref: ref, className: core.concatClassNames("lui-select-label", className), ...props })));
175
+ SelectLabel.displayName = "SelectLabel";
176
+ const SelectSeparator = react.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("hr", { ref: ref, className: core.concatClassNames("lui-select-separator", className), ...props })));
324
177
  SelectSeparator.displayName = "SelectSeparator";
325
- function SelectScrollUpButton({ ref, className, ...props }) {
326
- return (jsxRuntime.jsx("div", { ref: ref, "data-slot": "select-scroll-up-button", className: concatClassNames("lui-select-scroll-button", className), ...props, children: jsxRuntime.jsx(ChevronUpIcon, { className: "lui-select-scroll-button__icon" }) }));
327
- }
178
+ const SelectScrollUpButton = react.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("div", { ref: ref, className: core.concatClassNames("lui-select-scroll-button", className), "aria-hidden": "true", ...props, children: jsxRuntime.jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon", style: { transform: "rotate(180deg)" } }) })));
328
179
  SelectScrollUpButton.displayName = "SelectScrollUpButton";
329
- function SelectScrollDownButton({ ref, className, ...props }) {
330
- return (jsxRuntime.jsx("div", { ref: ref, "data-slot": "select-scroll-down-button", className: concatClassNames("lui-select-scroll-button", className), ...props, children: jsxRuntime.jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon" }) }));
331
- }
180
+ const SelectScrollDownButton = react.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("div", { ref: ref, className: core.concatClassNames("lui-select-scroll-button", className), "aria-hidden": "true", ...props, children: jsxRuntime.jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon" }) })));
332
181
  SelectScrollDownButton.displayName = "SelectScrollDownButton";
333
182
 
334
183
  exports.Select = Select;
@@ -336,8 +185,7 @@ exports.SelectContent = SelectContent;
336
185
  exports.SelectGroup = SelectGroup;
337
186
  exports.SelectItem = SelectItem;
338
187
  exports.SelectLabel = SelectLabel;
339
- exports.SelectScrollDownButton = SelectScrollDownButton;
340
- exports.SelectScrollUpButton = SelectScrollUpButton;
341
188
  exports.SelectSeparator = SelectSeparator;
342
189
  exports.SelectTrigger = SelectTrigger;
343
190
  exports.SelectValue = SelectValue;
191
+ exports.default = Select;
package/dist/index.mjs CHANGED
@@ -1,28 +1,18 @@
1
- import { jsx, jsxs } from 'react/jsx-runtime';
2
- import { createContext, useState, useId, useRef, useCallback, use, useEffect, Children, isValidElement, useLayoutEffect } from 'react';
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { createContext, forwardRef, use, useRef, useState, useLayoutEffect, useEffect, useMemo, Children, isValidElement } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import concatClassNames from '@ladder-ui/core/concatClassNames';
4
+ import { cva, concatClassNames } from '@ladder-ui/core';
5
+ import { useOutsideClick, useSelect } from '@ladder-ui/primitives';
5
6
 
6
- // useLayoutEffect runs synchronously after DOM mutations but before the browser
7
- // paints. This eliminates the double-paint flash that useEffect causes for
8
- // position-critical work like dropdown placement.
9
- // Falls back to useEffect in SSR environments (no DOM = no layout to measure).
10
7
  const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
11
- // ─── Inline SVG icons ────────────────────────────────────────────────────────
12
- function ChevronDownIcon({ className }) {
13
- return (jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsx("path", { d: "m6 9 6 6 6-6" }) }));
14
- }
15
- function ChevronUpIcon({ className }) {
16
- return (jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsx("path", { d: "m18 15-6-6-6 6" }) }));
8
+ // ─── Icons ──────────────────────────────────────────────────────────────────
9
+ function ChevronDownIcon({ className, style }) {
10
+ return (jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, style: style, children: jsx("path", { d: "m6 9 6 6 6-6" }) }));
17
11
  }
18
12
  function CheckIcon({ className }) {
19
13
  return (jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: className, children: jsx("path", { d: "M20 6 9 17l-5-5" }) }));
20
14
  }
21
- // ─── Label extraction helpers ────────────────────────────────────────────────
22
- //
23
- // We scan the JSX children tree in Select root to build a value→label map.
24
- // This works even when SelectContent renders null (closed state), because the
25
- // JSX element tree passed as `children` always contains the full structure.
15
+ // ─── Helper: Label Extraction ────────────────────────────────────────────────
26
16
  function extractTextContent(children) {
27
17
  if (typeof children === "string")
28
18
  return children;
@@ -30,303 +20,160 @@ function extractTextContent(children) {
30
20
  return String(children);
31
21
  if (Array.isArray(children))
32
22
  return children.map(extractTextContent).join("");
33
- if (isValidElement(children)) {
23
+ if (isValidElement(children))
34
24
  return extractTextContent(children.props.children);
35
- }
36
25
  return "";
37
26
  }
38
27
  function scanItemLabels(children, registry) {
39
28
  Children.forEach(children, (child) => {
40
29
  if (!isValidElement(child))
41
30
  return;
42
- const props = child.props;
43
- // Detect SelectItem by displayName on the element type function
44
- const dn = typeof child.type === "function"
45
- ? child.type.displayName
46
- : typeof child.type === "object"
47
- ? child.type.displayName
48
- : undefined;
49
- if (dn === "SelectItem" && (typeof props.value === "string" || props.value === null)) {
50
- // null is the Shadcn-style "no selection" sentinel — stored as "" internally
51
- const key = props.value ?? "";
52
- const label = extractTextContent(props.children);
31
+ // Check for SelectItem based on type or name
32
+ const type = child.type;
33
+ const dn = type.displayName || type.name;
34
+ if (dn === "SelectItem") {
35
+ const key = child.props.value ?? "";
36
+ const label = extractTextContent(child.props.children);
53
37
  if (label)
54
38
  registry.set(key, label);
55
39
  }
56
- // Recurse into children of non-item elements
57
- if (props.children) {
58
- scanItemLabels(props.children, registry);
40
+ if (child.props.children) {
41
+ scanItemLabels(child.props.children, registry);
59
42
  }
60
43
  });
61
44
  }
62
- const SelectContext = createContext({
63
- open: false,
64
- setOpen: () => { },
65
- value: undefined,
66
- displayLabel: "",
67
- triggerId: "",
68
- contentId: "",
69
- triggerRef: { current: null },
45
+ const SelectContext = createContext(null);
46
+ // ─── Variants ────────────────────────────────────────────────────────────────
47
+ const selectTriggerVariants = cva({
48
+ base: "lui-select-trigger",
49
+ variants: {
50
+ size: { sm: "lui-select-trigger--sm", md: "lui-select-trigger--md" },
51
+ status: { default: "", error: "lui-select-trigger--error" },
52
+ fullWidth: { true: "lui-select-trigger--full-width" },
53
+ },
54
+ defaultVariants: { size: "md", status: "default" },
70
55
  });
71
- function Select({ value: controlledValue, defaultValue, onValueChange, disabled, children, open: controlledOpen, onOpenChange, items, }) {
72
- const [internalOpen, setInternalOpen] = useState(false);
73
- const [internalValue, setInternalValue] = useState(defaultValue);
74
- const isOpenControlled = controlledOpen !== undefined;
75
- const isValueControlled = controlledValue !== undefined;
76
- const open = isOpenControlled ? controlledOpen : internalOpen;
77
- const value = isValueControlled ? controlledValue : internalValue;
78
- const triggerId = useId();
79
- const contentId = useId();
80
- const triggerRef = useRef(null);
81
- // Build label registry synchronously from the JSX children tree.
82
- // This works even when SelectContent renders null (closed), because the
83
- // JSX element tree always contains all SelectItem elements.
84
- const labelsMap = new Map();
85
- scanItemLabels(children, labelsMap);
86
- // Merge items prop — supports null values and data-driven usage (.map()).
87
- // Takes precedence over child-scanned labels for matching keys.
88
- if (items) {
89
- for (const item of items) {
90
- labelsMap.set(item.value ?? "", item.label);
91
- }
92
- }
93
- const displayLabel = (value !== undefined ? labelsMap.get(value) : undefined) ?? "";
94
- const setOpen = useCallback((next) => {
95
- if (!isOpenControlled)
96
- setInternalOpen(next);
97
- onOpenChange?.(next);
98
- }, [isOpenControlled, onOpenChange]);
99
- const handleValueChange = useCallback((newValue) => {
100
- if (!isValueControlled)
101
- setInternalValue(newValue);
102
- onValueChange?.(newValue);
103
- setOpen(false);
104
- }, [isValueControlled, onValueChange, setOpen]);
105
- return (jsx(SelectContext, { value: {
106
- open,
107
- setOpen,
108
- value,
109
- displayLabel,
110
- onValueChange: handleValueChange,
111
- disabled,
112
- triggerId,
113
- contentId,
114
- triggerRef,
115
- }, children: children }));
56
+ function Select({ children, items, ...props }) {
57
+ const select = useSelect(props);
58
+ const labelsMap = useMemo(() => {
59
+ const map = new Map();
60
+ // 1. Scan children (Static JSX)
61
+ scanItemLabels(children, map);
62
+ // 2. Add explicit items (Dynamic/SDUI)
63
+ items?.forEach(item => map.set(item.value, item.label));
64
+ return map;
65
+ }, [children, items]);
66
+ const displayLabel = (select.value !== undefined ? labelsMap.get(select.value) : undefined) ?? "";
67
+ return (jsx(SelectContext, { value: { ...select, displayLabel }, children: children }));
116
68
  }
117
69
  Select.displayName = "Select";
118
- function SelectTrigger({ ref, className, children, size = "default", fullWidth, disabled: propDisabled, onKeyDown, ...props }) {
70
+ const SelectTrigger = forwardRef(({ className, size, status, fullWidth, children, ...props }, ref) => {
119
71
  const ctx = use(SelectContext);
120
- const isDisabled = propDisabled || ctx.disabled;
121
- // Merge consumer ref + context triggerRef
122
- const setRef = (el) => {
72
+ if (!ctx)
73
+ return null;
74
+ const setRefs = (el) => {
123
75
  ctx.triggerRef.current = el;
124
- if (typeof ref === "function") {
76
+ if (typeof ref === "function")
125
77
  ref(el);
126
- }
127
- else if (ref) {
78
+ else if (ref)
128
79
  ref.current = el;
129
- }
130
- };
131
- const handleKeyDown = (e) => {
132
- if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
133
- e.preventDefault();
134
- ctx.setOpen(true);
135
- }
136
- onKeyDown?.(e);
137
80
  };
138
- const classes = concatClassNames("lui-select-trigger", size === "sm" ? "lui-select-trigger--sm" : undefined, fullWidth ? "lui-select-trigger--full-width" : undefined, className);
139
- return (jsxs("button", { ref: setRef, type: "button", role: "combobox", id: ctx.triggerId, "aria-expanded": ctx.open, "aria-haspopup": "listbox", "aria-controls": ctx.open ? ctx.contentId : undefined, "aria-disabled": isDisabled || undefined, disabled: isDisabled, "data-slot": "select-trigger", "data-size": size, "data-full-width": fullWidth || undefined, className: classes, onClick: () => ctx.setOpen(!ctx.open), onKeyDown: handleKeyDown, ...props, children: [children, jsx(ChevronDownIcon, { className: "lui-select-trigger__chevron" })] }));
140
- }
81
+ return (jsxs("button", { ref: setRefs, type: "button", role: "combobox", id: ctx.triggerId, "aria-expanded": ctx.open, "aria-haspopup": "listbox", "aria-controls": ctx.open ? ctx.contentId : undefined, disabled: ctx.disabled, className: selectTriggerVariants({ size, status, fullWidth, className }), onClick: (e) => {
82
+ ctx.setOpen(!ctx.open);
83
+ props.onClick?.(e);
84
+ }, onKeyDown: (e) => {
85
+ if (e.key === "Escape" && ctx.open) {
86
+ e.preventDefault();
87
+ ctx.setOpen(false);
88
+ }
89
+ if ((e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") && !ctx.open) {
90
+ e.preventDefault();
91
+ ctx.setOpen(true);
92
+ }
93
+ props.onKeyDown?.(e);
94
+ }, ...props, children: [children, jsx(ChevronDownIcon, { className: "lui-select-trigger__chevron" })] }));
95
+ });
141
96
  SelectTrigger.displayName = "SelectTrigger";
142
- function SelectValue({ placeholder, className }) {
97
+ const SelectValue = ({ placeholder, className }) => {
143
98
  const ctx = use(SelectContext);
144
- const classes = concatClassNames("lui-select-value", className);
145
- return (jsx("span", { "data-slot": "select-value", className: classes, children: ctx.displayLabel || placeholder || "" }));
146
- }
99
+ return (jsx("span", { className: concatClassNames("lui-select-value", className), "data-slot": "select-value", children: ctx?.displayLabel || placeholder }));
100
+ };
147
101
  SelectValue.displayName = "SelectValue";
148
- function SelectContent({ ref, className, children, position = "item-aligned", }) {
102
+ const SelectContent = forwardRef(({ children, className, ...props }, ref) => {
149
103
  const ctx = use(SelectContext);
150
104
  const contentRef = useRef(null);
151
- // Two-pass positioning:
152
- // Pass 1 — render with visibility:hidden so browser can measure item offsets.
153
- // Pass 2 — compute correct position, set isPositioned=true → visibility:visible.
154
- const [isPositioned, setIsPositioned] = useState(false);
155
105
  const [coords, setCoords] = useState({ top: 0, left: 0, width: 0 });
156
- // Pass 1→2: measure and position synchronously before browser paint
106
+ const [isPositioned, setIsPositioned] = useState(false);
107
+ const { addRef } = useOutsideClick(() => ctx?.setOpen(false), ctx?.open);
108
+ // Two-pass positioning strategy
157
109
  useIsomorphicLayoutEffect(() => {
158
- if (!ctx.open || !contentRef.current || !ctx.triggerRef.current)
110
+ if (!ctx?.open || !ctx.triggerRef.current || !contentRef.current) {
111
+ setIsPositioned(false);
159
112
  return;
113
+ }
160
114
  const triggerRect = ctx.triggerRef.current.getBoundingClientRect();
161
115
  const content = contentRef.current;
162
116
  const contentHeight = content.offsetHeight;
163
- const contentWidth = content.offsetWidth;
164
117
  const viewportHeight = window.innerHeight;
165
- const viewportWidth = window.innerWidth;
166
- let top;
167
- if (position === "item-aligned") {
168
- // Align the selected item (or first item) vertically with the trigger.
169
- // The item's center should coincide with the trigger's center.
170
- const selectedItem = content.querySelector('[role="option"][aria-selected="true"]');
171
- const firstItem = content.querySelector('[role="option"]');
172
- const targetItem = selectedItem ?? firstItem;
173
- if (targetItem) {
174
- // offsetTop is relative to the fixed content div (its offsetParent)
175
- const itemTop = targetItem.offsetTop;
176
- const itemHeight = targetItem.offsetHeight;
177
- // Center item with trigger: move content up so item.center = trigger.center
178
- top =
179
- triggerRect.top -
180
- itemTop +
181
- (triggerRect.height - itemHeight) / 2;
182
- }
183
- else {
184
- top = triggerRect.top;
185
- }
118
+ // Popper strategy: open below trigger
119
+ let top = triggerRect.bottom + window.scrollY + 4;
120
+ const left = triggerRect.left + window.scrollX;
121
+ // Flip if no space below
122
+ if (triggerRect.bottom + contentHeight + 8 > viewportHeight) {
123
+ top = triggerRect.top + window.scrollY - contentHeight - 4;
186
124
  }
187
- else {
188
- // Popper: open below trigger, fall back to above if insufficient space
189
- const spaceBelow = viewportHeight - triggerRect.bottom;
190
- if (spaceBelow < contentHeight + 8 && triggerRect.top > contentHeight + 8) {
191
- top = triggerRect.top - contentHeight - 4;
192
- }
193
- else {
194
- top = triggerRect.bottom + 4;
195
- }
196
- }
197
- // Clamp to viewport with 8 px margin
198
- top = Math.max(8, Math.min(top, viewportHeight - contentHeight - 8));
199
- const left = Math.max(8, Math.min(triggerRect.left, viewportWidth - contentWidth - 8));
200
125
  setCoords({ top, left, width: triggerRect.width });
201
126
  setIsPositioned(true);
202
- // Run after every open (component remounts each time, so state resets automatically)
203
- // eslint-disable-next-line react-hooks/exhaustive-deps
204
- }, [ctx.open]);
205
- // Focus management wait until content is visible to avoid jarring jump
206
- useEffect(() => {
207
- if (!ctx.open || !isPositioned || !contentRef.current)
208
- return;
209
- const selected = contentRef.current.querySelector('[role="option"][aria-selected="true"]');
210
- const first = contentRef.current.querySelector('[role="option"]:not([aria-disabled="true"])');
211
- (selected ?? first)?.focus();
212
- }, [ctx.open, isPositioned]);
213
- // Click-outside to close
214
- useEffect(() => {
215
- if (!ctx.open)
216
- return;
217
- const handlePointerDown = (e) => {
218
- const target = e.target;
219
- if (contentRef.current &&
220
- !contentRef.current.contains(target) &&
221
- ctx.triggerRef.current &&
222
- !ctx.triggerRef.current.contains(target)) {
223
- ctx.setOpen(false);
224
- }
225
- };
226
- document.addEventListener("pointerdown", handlePointerDown);
227
- return () => document.removeEventListener("pointerdown", handlePointerDown);
228
- }, [ctx.open, ctx.setOpen, ctx.triggerRef]);
229
- // Keyboard navigation within the listbox
230
- const handleKeyDown = (e) => {
231
- const items = contentRef.current?.querySelectorAll('[role="option"]:not([aria-disabled="true"])');
232
- if (!items || items.length === 0)
233
- return;
234
- const active = document.activeElement;
235
- const currentIndex = Array.from(items).indexOf(active);
236
- switch (e.key) {
237
- case "ArrowDown": {
238
- e.preventDefault();
239
- items[Math.min(currentIndex + 1, items.length - 1)]?.focus();
240
- break;
241
- }
242
- case "ArrowUp": {
243
- e.preventDefault();
244
- items[Math.max(currentIndex - 1, 0)]?.focus();
245
- break;
246
- }
247
- case "Home": {
248
- e.preventDefault();
249
- items[0]?.focus();
250
- break;
251
- }
252
- case "End": {
253
- e.preventDefault();
254
- items[items.length - 1]?.focus();
255
- break;
256
- }
257
- case "Escape":
258
- case "Tab": {
127
+ }, [ctx?.open]);
128
+ if (!ctx?.open)
129
+ return null;
130
+ return createPortal(jsx("div", { ref: (node) => {
131
+ contentRef.current = node;
132
+ if (typeof ref === "function")
133
+ ref(node);
134
+ else if (ref)
135
+ ref.current = node;
136
+ if (node)
137
+ addRef({ current: node });
138
+ addRef(ctx.triggerRef);
139
+ }, id: ctx.contentId, role: "listbox", "aria-labelledby": ctx.triggerId, "data-slot": "select-content", className: concatClassNames("lui-select-content", className), style: {
140
+ position: "absolute",
141
+ top: coords.top,
142
+ left: coords.left,
143
+ width: coords.width,
144
+ minWidth: coords.width,
145
+ zIndex: 9999,
146
+ visibility: isPositioned ? "visible" : "hidden",
147
+ }, onKeyDown: (e) => {
148
+ if (e.key === "Escape") {
259
149
  ctx.setOpen(false);
260
150
  ctx.triggerRef.current?.focus();
261
- break;
262
151
  }
263
- }
264
- };
265
- if (!ctx.open)
266
- return null;
267
- const style = {
268
- position: "fixed",
269
- top: coords.top,
270
- left: coords.left,
271
- width: coords.width || undefined,
272
- minWidth: coords.width || undefined,
273
- zIndex: 9999,
274
- // Hidden during pass 1 to allow measurement without visual flash
275
- visibility: isPositioned ? "visible" : "hidden",
276
- };
277
- const setRef = (el) => {
278
- contentRef.current = el;
279
- if (typeof ref === "function") {
280
- ref(el);
281
- }
282
- else if (ref) {
283
- ref.current = el;
284
- }
285
- };
286
- const classes = concatClassNames("lui-select-content", className);
287
- return createPortal(jsx("div", { ref: setRef, id: ctx.contentId, role: "listbox", "aria-labelledby": ctx.triggerId, "data-slot": "select-content", "data-state": "open", className: classes, style: style, onKeyDown: handleKeyDown, children: jsx("div", { className: "lui-select-content__viewport", children: children }) }), document.body);
288
- }
152
+ props.onKeyDown?.(e);
153
+ }, ...props, children: jsx("div", { className: "lui-select-content__viewport", children: children }) }), document.body);
154
+ });
289
155
  SelectContent.displayName = "SelectContent";
290
- function SelectGroup({ ref, className, children, ...props }) {
291
- return (jsx("div", { ref: ref, role: "group", "data-slot": "select-group", className: concatClassNames("lui-select-group", className), ...props, children: children }));
292
- }
293
- SelectGroup.displayName = "SelectGroup";
294
- function SelectLabel({ ref, className, children, ...props }) {
295
- return (jsx("div", { ref: ref, "data-slot": "select-label", className: concatClassNames("lui-select-label", className), ...props, children: children }));
296
- }
297
- SelectLabel.displayName = "SelectLabel";
298
- function SelectItem({ ref, className, children, value, disabled, onClick, onKeyDown, ...props }) {
156
+ const SelectItem = forwardRef(({ value, children, disabled, className, ...props }, ref) => {
299
157
  const ctx = use(SelectContext);
300
- // null is the Shadcn-style sentinel for "no selection" — normalize to ""
301
- const normalizedValue = value ?? "";
302
- const isSelected = ctx.value === normalizedValue;
303
- const handleSelect = () => {
304
- if (disabled)
305
- return;
306
- ctx.onValueChange?.(normalizedValue);
307
- };
308
- const handleKeyDown = (e) => {
309
- if (e.key === "Enter" || e.key === " ") {
310
- e.preventDefault();
311
- handleSelect();
312
- }
313
- onKeyDown?.(e);
314
- };
315
- const classes = concatClassNames("lui-select-item", isSelected ? "lui-select-item--selected" : undefined, disabled ? "lui-select-item--disabled" : undefined, className);
316
- return (jsxs("div", { ref: ref, role: "option", "aria-selected": isSelected, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : 0, "data-slot": "select-item", "data-value": normalizedValue, className: classes, onClick: handleSelect, onKeyDown: handleKeyDown, ...props, children: [jsx("span", { className: "lui-select-item__indicator", "aria-hidden": "true", children: isSelected && jsx(CheckIcon, { className: "lui-select-item__check" }) }), jsx("span", { className: "lui-select-item__text", children: children })] }));
317
- }
158
+ const isSelected = ctx?.value === value;
159
+ return (jsxs("div", { ref: ref, role: "option", "aria-selected": isSelected, "aria-disabled": disabled, className: concatClassNames("lui-select-item", isSelected && "lui-select-item--selected", disabled && "lui-select-item--disabled", className), onClick: (e) => {
160
+ if (!disabled) {
161
+ ctx?.onValueChange(value === null ? "" : value);
162
+ ctx?.setOpen(false);
163
+ }
164
+ props.onClick?.(e);
165
+ }, ...props, children: [jsx("span", { className: "lui-select-item__indicator", "aria-hidden": "true", children: isSelected && jsx(CheckIcon, { className: "lui-select-item__check" }) }), jsx("span", { className: "lui-select-item__text", children: children })] }));
166
+ });
318
167
  SelectItem.displayName = "SelectItem";
319
- function SelectSeparator({ ref, className, ...props }) {
320
- return (jsx("hr", { ref: ref, "data-slot": "select-separator", className: concatClassNames("lui-select-separator", className), ...props }));
321
- }
168
+ const SelectGroup = forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, role: "group", className: concatClassNames("lui-select-group", className), ...props })));
169
+ SelectGroup.displayName = "SelectGroup";
170
+ const SelectLabel = forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: concatClassNames("lui-select-label", className), ...props })));
171
+ SelectLabel.displayName = "SelectLabel";
172
+ const SelectSeparator = forwardRef(({ className, ...props }, ref) => (jsx("hr", { ref: ref, className: concatClassNames("lui-select-separator", className), ...props })));
322
173
  SelectSeparator.displayName = "SelectSeparator";
323
- function SelectScrollUpButton({ ref, className, ...props }) {
324
- return (jsx("div", { ref: ref, "data-slot": "select-scroll-up-button", className: concatClassNames("lui-select-scroll-button", className), ...props, children: jsx(ChevronUpIcon, { className: "lui-select-scroll-button__icon" }) }));
325
- }
174
+ const SelectScrollUpButton = forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: concatClassNames("lui-select-scroll-button", className), "aria-hidden": "true", ...props, children: jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon", style: { transform: "rotate(180deg)" } }) })));
326
175
  SelectScrollUpButton.displayName = "SelectScrollUpButton";
327
- function SelectScrollDownButton({ ref, className, ...props }) {
328
- return (jsx("div", { ref: ref, "data-slot": "select-scroll-down-button", className: concatClassNames("lui-select-scroll-button", className), ...props, children: jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon" }) }));
329
- }
176
+ const SelectScrollDownButton = forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: concatClassNames("lui-select-scroll-button", className), "aria-hidden": "true", ...props, children: jsx(ChevronDownIcon, { className: "lui-select-scroll-button__icon" }) })));
330
177
  SelectScrollDownButton.displayName = "SelectScrollDownButton";
331
178
 
332
- export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue };
179
+ export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, Select as default };
package/dist/select.css CHANGED
@@ -26,7 +26,7 @@
26
26
  }
27
27
  .lui-select-trigger:focus-visible {
28
28
  border-color: var(--lui-select-focus-border);
29
- box-shadow: 0 0 0 2px var(--lui-surface), 0 0 0 4px var(--lui-select-focus-ring-color);
29
+ box-shadow: 0 0 0 2px var(--lui-bg-surface), 0 0 0 4px var(--lui-select-focus-ring-color);
30
30
  }
31
31
  .lui-select-trigger:disabled, .lui-select-trigger[aria-disabled=true] {
32
32
  opacity: var(--lui-disabled-opacity);
@@ -91,8 +91,6 @@
91
91
  font-family: var(--lui-font-family);
92
92
  }
93
93
  .lui-select-item {
94
- content-visibility: auto;
95
- contain-intrinsic-size: auto 36px;
96
94
  position: relative;
97
95
  display: flex;
98
96
  align-items: center;
@@ -109,12 +107,11 @@
109
107
  font-family: var(--lui-font-family);
110
108
  }
111
109
  .lui-select-item:hover:not(.lui-select-item--disabled) {
112
- background-color: color-mix(in srgb, var(--lui-select-item-hover-bg) 50%, transparent);
110
+ background-color: var(--lui-select-item-hover-bg);
113
111
  color: var(--lui-select-item-hover-text);
114
112
  }
115
- .lui-select-item:focus-visible:not(.lui-select-item--disabled) {
116
- background-color: var(--lui-select-item-focus-bg);
117
- color: var(--lui-select-item-hover-text);
113
+ .lui-select-item--selected {
114
+ color: var(--lui-select-item-selected-text);
118
115
  }
119
116
  .lui-select-item--disabled {
120
117
  opacity: var(--lui-disabled-opacity);
@@ -147,16 +144,4 @@
147
144
  border-top: 1px solid var(--lui-select-separator-color);
148
145
  margin: 0.25rem -0.25rem;
149
146
  }
150
- .lui-select-scroll-button {
151
- display: flex;
152
- align-items: center;
153
- justify-content: center;
154
- padding: 0.25rem;
155
- cursor: default;
156
- color: var(--lui-select-label-text);
157
- }
158
- .lui-select-scroll-button__icon {
159
- width: 1rem;
160
- height: 1rem;
161
- }
162
147
  }
package/dist/select.d.ts CHANGED
@@ -1,119 +1,62 @@
1
- import type { Ref } from "react";
2
- /** A single item entry for the data-driven `items` prop on `Select`. */
1
+ import type { Ref, HTMLAttributes, ReactNode } from "react";
2
+ import { type VariantProps } from "@ladder-ui/core";
3
+ declare const selectTriggerVariants: (props?: (import("packages/core/dist/cva").ConfigVariants<{
4
+ size: {
5
+ sm: string;
6
+ md: string;
7
+ };
8
+ status: {
9
+ default: string;
10
+ error: string;
11
+ };
12
+ fullWidth: {
13
+ true: string;
14
+ };
15
+ }> & {
16
+ className?: string;
17
+ }) | undefined) => string;
3
18
  export interface SelectItemData {
4
19
  label: string;
5
- /** Pass `null` for the "no selection" reset item (equivalent to `value=""`). */
6
- value: string | null;
20
+ value: string;
7
21
  }
8
22
  export interface SelectProps {
9
- /** Controlled selected value. Pair with `onValueChange`. */
10
23
  value?: string;
11
- /** Uncontrolled initial selected value. */
12
24
  defaultValue?: string;
13
- /** Called when the user selects a different item. */
14
25
  onValueChange?: (value: string) => void;
15
- /** Disables the entire select control. */
16
- disabled?: boolean;
17
- children?: React.ReactNode;
18
- /** Controlled open state. Pair with `onOpenChange`. */
19
26
  open?: boolean;
20
- /** Called when open state changes. */
21
27
  onOpenChange?: (open: boolean) => void;
22
- /**
23
- * Optional flat list of items for label extraction.
24
- * Useful when items are rendered via `.map()` and the label map
25
- * cannot be built from static JSX children alone.
26
- * Supports `value: null` for the "no selection" reset item.
27
- */
28
+ disabled?: boolean;
29
+ children?: ReactNode;
28
30
  items?: SelectItemData[];
29
31
  }
30
- export declare function Select({ value: controlledValue, defaultValue, onValueChange, disabled, children, open: controlledOpen, onOpenChange, items, }: SelectProps): import("react/jsx-runtime").JSX.Element;
32
+ export declare function Select({ children, items, ...props }: SelectProps): import("react/jsx-runtime").JSX.Element;
31
33
  export declare namespace Select {
32
34
  var displayName: string;
33
35
  }
34
- export interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
36
+ export interface SelectTriggerProps extends Omit<HTMLAttributes<HTMLButtonElement>, "size">, VariantProps<typeof selectTriggerVariants> {
35
37
  ref?: Ref<HTMLButtonElement>;
36
- /** Size variant of the trigger button. */
37
- size?: "sm" | "default";
38
- /** When true, the trigger expands to fill its container width. */
38
+ size?: "sm" | "md";
39
+ status?: "default" | "error";
39
40
  fullWidth?: boolean;
40
41
  }
41
- export declare function SelectTrigger({ ref, className, children, size, fullWidth, disabled: propDisabled, onKeyDown, ...props }: SelectTriggerProps): import("react/jsx-runtime").JSX.Element;
42
- export declare namespace SelectTrigger {
43
- var displayName: string;
44
- }
42
+ export declare const SelectTrigger: import("react").ForwardRefExoticComponent<Omit<SelectTriggerProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
45
43
  export interface SelectValueProps {
46
- /** Text shown when no value is selected. */
47
44
  placeholder?: string;
48
45
  className?: string;
49
46
  }
50
- export declare function SelectValue({ placeholder, className }: SelectValueProps): import("react/jsx-runtime").JSX.Element;
51
- export declare namespace SelectValue {
52
- var displayName: string;
53
- }
54
- export interface SelectContentProps {
55
- ref?: Ref<HTMLDivElement>;
56
- className?: string;
57
- children?: React.ReactNode;
58
- /**
59
- * Positioning strategy.
60
- * - `"item-aligned"` (default): the dropdown opens so the selected item
61
- * is visually aligned with the trigger — matching Shadcn/Radix behaviour.
62
- * - `"popper"`: the dropdown opens below (or above) the trigger.
63
- */
64
- position?: "item-aligned" | "popper";
65
- }
66
- export declare function SelectContent({ ref, className, children, position, }: SelectContentProps): import("react").ReactPortal | null;
67
- export declare namespace SelectContent {
68
- var displayName: string;
69
- }
70
- export interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {
71
- ref?: Ref<HTMLDivElement>;
72
- }
73
- export declare function SelectGroup({ ref, className, children, ...props }: SelectGroupProps): import("react/jsx-runtime").JSX.Element;
74
- export declare namespace SelectGroup {
75
- var displayName: string;
76
- }
77
- export interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {
78
- ref?: Ref<HTMLDivElement>;
79
- }
80
- export declare function SelectLabel({ ref, className, children, ...props }: SelectLabelProps): import("react/jsx-runtime").JSX.Element;
81
- export declare namespace SelectLabel {
82
- var displayName: string;
83
- }
84
- export interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
85
- ref?: Ref<HTMLDivElement>;
86
- /**
87
- * The value this item represents when selected. Required.
88
- * Pass `null` for the "no selection" reset item (Shadcn-style).
89
- * Internally treated as `""` — `onValueChange` is called with `""`.
90
- */
91
- value: string | null;
92
- /** Prevents selection of this item. */
47
+ export declare const SelectValue: {
48
+ ({ placeholder, className }: SelectValueProps): import("react/jsx-runtime").JSX.Element;
49
+ displayName: string;
50
+ };
51
+ export declare const SelectContent: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
52
+ export interface SelectItemProps extends Omit<HTMLAttributes<HTMLDivElement>, "value"> {
53
+ value: string;
93
54
  disabled?: boolean;
94
55
  }
95
- export declare function SelectItem({ ref, className, children, value, disabled, onClick, onKeyDown, ...props }: SelectItemProps): import("react/jsx-runtime").JSX.Element;
96
- export declare namespace SelectItem {
97
- var displayName: string;
98
- }
99
- export interface SelectSeparatorProps extends React.HTMLAttributes<HTMLHRElement> {
100
- ref?: Ref<HTMLHRElement>;
101
- }
102
- export declare function SelectSeparator({ ref, className, ...props }: SelectSeparatorProps): import("react/jsx-runtime").JSX.Element;
103
- export declare namespace SelectSeparator {
104
- var displayName: string;
105
- }
106
- export interface SelectScrollUpButtonProps extends React.HTMLAttributes<HTMLDivElement> {
107
- ref?: Ref<HTMLDivElement>;
108
- }
109
- export declare function SelectScrollUpButton({ ref, className, ...props }: SelectScrollUpButtonProps): import("react/jsx-runtime").JSX.Element;
110
- export declare namespace SelectScrollUpButton {
111
- var displayName: string;
112
- }
113
- export interface SelectScrollDownButtonProps extends React.HTMLAttributes<HTMLDivElement> {
114
- ref?: Ref<HTMLDivElement>;
115
- }
116
- export declare function SelectScrollDownButton({ ref, className, ...props }: SelectScrollDownButtonProps): import("react/jsx-runtime").JSX.Element;
117
- export declare namespace SelectScrollDownButton {
118
- var displayName: string;
119
- }
56
+ export declare const SelectItem: import("react").ForwardRefExoticComponent<SelectItemProps & import("react").RefAttributes<HTMLDivElement>>;
57
+ export declare const SelectGroup: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
58
+ export declare const SelectLabel: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
59
+ export declare const SelectSeparator: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLHRElement> & import("react").RefAttributes<HTMLHRElement>>;
60
+ export declare const SelectScrollUpButton: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
61
+ export declare const SelectScrollDownButton: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
62
+ export {};
@@ -1,28 +1,28 @@
1
1
  :root {
2
- --lui-select-trigger-bg: var(--lui-surface);
3
- --lui-select-trigger-text: var(--lui-surface-text);
4
- --lui-select-trigger-border: var(--lui-surface-border);
2
+ --lui-select-trigger-bg: var(--lui-bg-surface);
3
+ --lui-select-trigger-text: var(--lui-text-primary);
4
+ --lui-select-trigger-border: var(--lui-border-default);
5
5
  --lui-select-trigger-radius: var(--lui-radius-sm);
6
6
  --lui-select-trigger-height: 2.25rem;
7
7
  --lui-select-trigger-height-sm: 2rem;
8
8
  --lui-select-trigger-px: 0.75rem;
9
9
  --lui-select-trigger-py: 0.5rem;
10
- --lui-select-focus-border: var(--lui-primary);
11
- --lui-select-focus-ring-color: color-mix(in srgb, var(--lui-primary) 40%, transparent);
12
- --lui-select-content-bg: var(--lui-surface);
13
- --lui-select-content-border: var(--lui-surface-border);
10
+ --lui-select-focus-border: var(--lui-bg-interactive);
11
+ --lui-select-focus-ring-color: color-mix(in srgb, var(--lui-bg-interactive) 40%, transparent);
12
+ --lui-select-content-bg: var(--lui-bg-surface);
13
+ --lui-select-content-border: var(--lui-border-default);
14
14
  --lui-select-content-radius: var(--lui-radius-sm);
15
15
  --lui-select-content-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08);
16
16
  --lui-select-content-max-height: 20rem;
17
- --lui-select-item-text: var(--lui-surface-text);
18
- --lui-select-item-hover-bg: color-mix(in srgb, var(--lui-primary) 10%, transparent);
19
- --lui-select-item-hover-text: var(--lui-surface-text);
20
- --lui-select-item-selected-text: var(--lui-primary);
21
- --lui-select-item-focus-bg: color-mix(in srgb, var(--lui-primary) 10%, transparent);
17
+ --lui-select-item-text: var(--lui-text-primary);
18
+ --lui-select-item-hover-bg: color-mix(in srgb, var(--lui-bg-interactive) 10%, transparent);
19
+ --lui-select-item-hover-text: var(--lui-text-primary);
20
+ --lui-select-item-selected-text: var(--lui-bg-interactive);
21
+ --lui-select-item-focus-bg: color-mix(in srgb, var(--lui-bg-interactive) 10%, transparent);
22
22
  --lui-select-item-radius: var(--lui-radius-sm);
23
23
  --lui-select-item-px: 0.5rem;
24
24
  --lui-select-item-py: 0.375rem;
25
25
  --lui-select-item-indicator-size: 1rem;
26
- --lui-select-label-text: color-mix(in srgb, var(--lui-surface-text) 60%, transparent);
27
- --lui-select-separator-color: var(--lui-surface-border);
26
+ --lui-select-label-text: color-mix(in srgb, var(--lui-text-primary) 60%, transparent);
27
+ --lui-select-separator-color: var(--lui-border-default);
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ladder-ui/select",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -17,48 +17,31 @@
17
17
  "files": [
18
18
  "dist"
19
19
  ],
20
- "keywords": [
21
- "nodejs",
22
- "react",
23
- "ui",
24
- "components",
25
- "library",
26
- "select",
27
- "dropdown",
28
- "form"
29
- ],
30
- "author": "Ivan Avila <ivelaval@gmail.com> - https://www.vennet.dev",
31
- "license": "ISC",
32
- "repository": {
33
- "type": "git",
34
- "url": "git+ssh://git@github.com/ivelaval/ladder-ui.git"
35
- },
36
- "bugs": {
37
- "url": "https://github.com/ivelaval/ladder-ui/issues"
38
- },
39
- "homepage": "https://github.com/ivelaval/ladder-ui#readme",
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
20
  "devDependencies": {
44
21
  "@rollup/plugin-typescript": "^11.1.6",
45
22
  "@types/react": "^19.0.0",
46
- "@types/react-dom": "^19.0.0",
23
+ "@types/react-dom": "^19.2.3",
47
24
  "rollup": "^4.59.0",
48
25
  "rollup-plugin-postcss": "^4.0.2",
49
26
  "sass": "^1.90.0",
50
27
  "tslib": "^2.6.2",
51
28
  "typescript": "^5.3.3",
52
- "@ladder-ui/core": "0.3.0"
29
+ "@ladder-ui/primitives": "0.4.0",
30
+ "@ladder-ui/layout": "0.4.0",
31
+ "@ladder-ui/core": "0.4.0"
53
32
  },
54
33
  "peerDependencies": {
55
34
  "@ladder-ui/core": ">=0.0.0",
56
- "react": ">=18.0.0",
57
- "react-dom": ">=18.0.0"
35
+ "react": ">=18.0.0"
58
36
  },
59
37
  "sideEffects": [
60
38
  "**/*.css"
61
39
  ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/ivelaval/ladder-ui.git",
43
+ "directory": "packages/select"
44
+ },
62
45
  "scripts": {
63
46
  "build": "pnpm clean && rollup -c",
64
47
  "dev": "rollup -c -w",