@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.14.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 ComboBox from "./ComboBox";
2
- import Search from "lucide-solid/icons/search";
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
- * Simple single-select dropdown with client-side fuzzy search over a static option
20
- * list a domain-free convenience wrapper over {@link ComboBox}. For richer behavior
21
- * (async search, create-on-the-fly, multi-select) use ComboBox directly.
22
- */
23
- export default function SearchableSelect(props: SearchableSelectProps) {
24
- const selected = () => {
25
- if (props.value == null || props.value === "") return null;
26
- return props.options.find((o) => String(o.value) === String(props.value)) ?? null;
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
- <ComboBox<SearchableOption>
31
- search={async (q) => {
32
- const trimmed = q.trim().toLowerCase();
33
- if (!trimmed) return props.options;
34
- return props.options.filter(
35
- (o) =>
36
- o.label.toLowerCase().includes(trimmed) ||
37
- (o.description?.toLowerCase().includes(trimmed) ?? false),
38
- );
39
- }}
40
- selected={selected()}
41
- onChange={(next) => props.onChange(next)}
42
- idOf={(o) => o.value}
43
- labelOf={(o) => o.label}
44
- secondaryOf={(o) => o.description ?? null}
45
- icon={Search}
46
- noun="option"
47
- placeholder={props.placeholder ?? "Select..."}
48
- disabled={props.disabled}
49
- testIdPrefix="searchable-select"
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
  }