@kahitsan/ksui 0.14.0 → 0.15.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
|
|
2
|
+
import { Portal } from "solid-js/web";
|
|
3
|
+
import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down";
|
|
4
|
+
import X from "lucide-solid/icons/x";
|
|
3
5
|
|
|
4
6
|
export interface SearchableOption {
|
|
5
7
|
value: string | number;
|
|
@@ -12,41 +14,226 @@ export interface SearchableSelectProps {
|
|
|
12
14
|
options: SearchableOption[];
|
|
13
15
|
onChange: (next: SearchableOption | null) => void | Promise<void>;
|
|
14
16
|
placeholder?: string;
|
|
17
|
+
searchPlaceholder?: string;
|
|
15
18
|
disabled?: boolean;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
allowClear?: boolean;
|
|
21
|
+
wrapperClass?: string;
|
|
22
|
+
triggerClass?: string;
|
|
23
|
+
triggerTestId?: string;
|
|
24
|
+
triggerLabelClass?: string;
|
|
25
|
+
emptyLabel?: string;
|
|
26
|
+
noMatchLabel?: string;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
const POPUP_MIN_WIDTH = 240;
|
|
30
|
+
const POPUP_MAX_HEIGHT = 320; // keep in sync with max-h-80 below
|
|
31
|
+
// Flip the popup above the trigger only when the space below can't fit a
|
|
32
|
+
// usable list. Below this many px we'd rather open upward (if there's more room
|
|
33
|
+
// there) than cramp the results.
|
|
34
|
+
const POPUP_FLIP_THRESHOLD = 200;
|
|
35
|
+
|
|
36
|
+
// Click-to-open combobox with inline search. The popup is rendered into a
|
|
37
|
+
// Portal and positioned with `position: fixed`, so it can escape any ancestor
|
|
38
|
+
// with `overflow: hidden` (e.g. the rounded table cards) and flip upward when
|
|
39
|
+
// there's not enough room below the trigger.
|
|
40
|
+
export default function SearchableSelect(props: SearchableSelectProps): JSX.Element {
|
|
41
|
+
const [open, setOpen] = createSignal(false);
|
|
42
|
+
const [query, setQuery] = createSignal("");
|
|
43
|
+
const [busy, setBusy] = createSignal(false);
|
|
44
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
45
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
46
|
+
let popupRef: HTMLDivElement | undefined;
|
|
47
|
+
let inputRef: HTMLInputElement | undefined;
|
|
48
|
+
|
|
49
|
+
const currentLabel = () => {
|
|
50
|
+
if (props.value == null || props.value === "") return undefined;
|
|
51
|
+
return props.options.find((o) => String(o.value) === String(props.value))?.label;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const filtered = createMemo(() => {
|
|
55
|
+
const q = query().trim().toLowerCase();
|
|
56
|
+
if (!q) return props.options;
|
|
57
|
+
return props.options.filter(
|
|
58
|
+
(o) =>
|
|
59
|
+
o.label.toLowerCase().includes(q) || (o.description?.toLowerCase().includes(q) ?? false),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const updatePosition = () => {
|
|
64
|
+
if (!triggerRef) return;
|
|
65
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
66
|
+
const vpHeight = window.innerHeight;
|
|
67
|
+
const vpWidth = window.innerWidth;
|
|
68
|
+
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
69
|
+
const spaceBelow = vpHeight - rect.bottom;
|
|
70
|
+
const spaceAbove = rect.top;
|
|
71
|
+
// Only flip upward when there isn't enough usable room below AND there's
|
|
72
|
+
// genuinely more room above. Anchor the flipped popup by its BOTTOM (hugging
|
|
73
|
+
// the trigger) instead of computing a top from POPUP_MAX_HEIGHT, so a short
|
|
74
|
+
// result list stays adjacent to the trigger rather than floating far above it.
|
|
75
|
+
const flipUp = spaceBelow < POPUP_FLIP_THRESHOLD && spaceAbove > spaceBelow;
|
|
76
|
+
const maxHeight = Math.max(
|
|
77
|
+
160,
|
|
78
|
+
Math.min(POPUP_MAX_HEIGHT, (flipUp ? spaceAbove : spaceBelow) - 12),
|
|
79
|
+
);
|
|
80
|
+
// Clamp horizontally so the popup doesn't overflow the viewport edges.
|
|
81
|
+
const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
|
|
82
|
+
setPopupStyle({
|
|
83
|
+
position: "fixed",
|
|
84
|
+
...(flipUp
|
|
85
|
+
? { bottom: `${Math.max(8, vpHeight - rect.top + 4)}px` }
|
|
86
|
+
: { top: `${rect.bottom + 4}px` }),
|
|
87
|
+
left: `${left}px`,
|
|
88
|
+
width: `${width}px`,
|
|
89
|
+
"max-height": `${maxHeight}px`,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
createEffect(() => {
|
|
94
|
+
if (!open()) return;
|
|
95
|
+
// Initial measurement + focus + listeners.
|
|
96
|
+
updatePosition();
|
|
97
|
+
queueMicrotask(() => inputRef?.focus());
|
|
98
|
+
|
|
99
|
+
const onDocClick = (e: MouseEvent) => {
|
|
100
|
+
const target = e.target as Node;
|
|
101
|
+
if (triggerRef?.contains(target)) return;
|
|
102
|
+
if (popupRef?.contains(target)) return;
|
|
103
|
+
setOpen(false);
|
|
104
|
+
setQuery("");
|
|
105
|
+
};
|
|
106
|
+
const onEsc = (e: KeyboardEvent) => {
|
|
107
|
+
if (e.key === "Escape") {
|
|
108
|
+
setOpen(false);
|
|
109
|
+
setQuery("");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const onReflow = () => updatePosition();
|
|
113
|
+
|
|
114
|
+
document.addEventListener("mousedown", onDocClick);
|
|
115
|
+
document.addEventListener("keydown", onEsc);
|
|
116
|
+
window.addEventListener("resize", onReflow);
|
|
117
|
+
// Capture phase so we catch scrolls in every ancestor.
|
|
118
|
+
window.addEventListener("scroll", onReflow, true);
|
|
119
|
+
onCleanup(() => {
|
|
120
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
121
|
+
document.removeEventListener("keydown", onEsc);
|
|
122
|
+
window.removeEventListener("resize", onReflow);
|
|
123
|
+
window.removeEventListener("scroll", onReflow, true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const emptyStateLabel = () => {
|
|
128
|
+
if (props.loading) return "Loading…";
|
|
129
|
+
if (query()) return props.noMatchLabel ?? "No matches";
|
|
130
|
+
return props.emptyLabel ?? "No options";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const select = async (opt: SearchableOption | null) => {
|
|
134
|
+
setBusy(true);
|
|
135
|
+
try {
|
|
136
|
+
await props.onChange(opt);
|
|
137
|
+
} finally {
|
|
138
|
+
setBusy(false);
|
|
139
|
+
setOpen(false);
|
|
140
|
+
setQuery("");
|
|
141
|
+
}
|
|
27
142
|
};
|
|
28
143
|
|
|
29
144
|
return (
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
145
|
+
<div class={props.wrapperClass ?? "relative inline-block"}>
|
|
146
|
+
<button
|
|
147
|
+
ref={triggerRef}
|
|
148
|
+
type="button"
|
|
149
|
+
data-testid={props.triggerTestId}
|
|
150
|
+
disabled={props.disabled || busy()}
|
|
151
|
+
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
152
|
+
class={
|
|
153
|
+
props.triggerClass ??
|
|
154
|
+
"inline-flex items-center gap-1.5 text-xs text-zinc-300 bg-zinc-800/60 hover:bg-zinc-800 border border-zinc-700/60 rounded px-2 py-1 cursor-pointer transition-colors"
|
|
155
|
+
}
|
|
156
|
+
classList={{ "cursor-not-allowed opacity-60": props.disabled }}
|
|
157
|
+
title={props.disabled ? undefined : "Click to select"}
|
|
158
|
+
>
|
|
159
|
+
<span class={props.triggerLabelClass ?? "truncate max-w-[180px]"}>
|
|
160
|
+
{busy()
|
|
161
|
+
? "…"
|
|
162
|
+
: (currentLabel() ?? (
|
|
163
|
+
<span class="text-zinc-500 italic">{props.placeholder ?? "Select…"}</span>
|
|
164
|
+
))}
|
|
165
|
+
</span>
|
|
166
|
+
<ChevronsUpDown size={12} class="text-zinc-500 shrink-0" />
|
|
167
|
+
</button>
|
|
168
|
+
<Show when={open()}>
|
|
169
|
+
<Portal>
|
|
170
|
+
<div
|
|
171
|
+
ref={popupRef}
|
|
172
|
+
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
173
|
+
style={popupStyle()}
|
|
174
|
+
>
|
|
175
|
+
<div class="px-2 py-1.5 border-b border-zinc-800">
|
|
176
|
+
<input
|
|
177
|
+
ref={inputRef}
|
|
178
|
+
type="text"
|
|
179
|
+
value={query()}
|
|
180
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
181
|
+
placeholder={props.searchPlaceholder ?? "Search…"}
|
|
182
|
+
class="w-full px-2 py-1 text-xs bg-zinc-950 border border-zinc-800 rounded text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-amber-500/50"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="flex-1 overflow-y-auto">
|
|
186
|
+
<Show
|
|
187
|
+
when={!props.loading && filtered().length > 0}
|
|
188
|
+
fallback={
|
|
189
|
+
<div class="px-3 py-3 text-xs text-zinc-500 text-center">{emptyStateLabel()}</div>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<For each={filtered()}>
|
|
193
|
+
{(opt) => {
|
|
194
|
+
const selected = () => String(opt.value) === String(props.value ?? "");
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => select(opt)}
|
|
199
|
+
class="w-full text-left px-3 py-2 text-xs hover:bg-amber-500/10 transition-colors flex items-center justify-between gap-2"
|
|
200
|
+
classList={{
|
|
201
|
+
"text-amber-400": selected(),
|
|
202
|
+
"text-zinc-200": !selected(),
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<span class="flex flex-col min-w-0">
|
|
206
|
+
<span class="font-medium truncate">{opt.label}</span>
|
|
207
|
+
<Show when={opt.description}>
|
|
208
|
+
<span class="text-[10px] text-zinc-500 truncate">
|
|
209
|
+
{opt.description}
|
|
210
|
+
</span>
|
|
211
|
+
</Show>
|
|
212
|
+
</span>
|
|
213
|
+
<Show when={selected()}>
|
|
214
|
+
<span class="text-amber-400 shrink-0">✓</span>
|
|
215
|
+
</Show>
|
|
216
|
+
</button>
|
|
217
|
+
);
|
|
218
|
+
}}
|
|
219
|
+
</For>
|
|
220
|
+
</Show>
|
|
221
|
+
</div>
|
|
222
|
+
<Show when={props.allowClear && props.value != null && props.value !== ""}>
|
|
223
|
+
<div class="border-t border-zinc-800">
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => select(null)}
|
|
227
|
+
class="w-full text-left px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-2"
|
|
228
|
+
>
|
|
229
|
+
<X size={12} />
|
|
230
|
+
<span>Clear selection</span>
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
</Show>
|
|
234
|
+
</div>
|
|
235
|
+
</Portal>
|
|
236
|
+
</Show>
|
|
237
|
+
</div>
|
|
51
238
|
);
|
|
52
239
|
}
|