@kahitsan/ksui 0.13.2 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
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",
@@ -0,0 +1,31 @@
1
+ import AccountAvatar, { type AvatarAccount } from "./AccountAvatar";
2
+
3
+ export interface AvatarProps {
4
+ name: string;
5
+ image?: string | null;
6
+ size?: "xs" | "sm" | "md" | "lg";
7
+ class?: string;
8
+ }
9
+
10
+ const sizeMap: Record<string, number> = {
11
+ xs: 20,
12
+ sm: 24,
13
+ md: 28,
14
+ lg: 40,
15
+ };
16
+
17
+ /**
18
+ * Generic person/user avatar — a thin, domain-free wrapper over {@link AccountAvatar}'s
19
+ * user variant that accepts a name + optional image and a t-shirt size. Use this for
20
+ * people; use AccountAvatar directly for financial accounts.
21
+ */
22
+ export default function Avatar(props: AvatarProps) {
23
+ const account: AvatarAccount = {
24
+ id: 0,
25
+ type: "user",
26
+ name: props.name,
27
+ image: props.image,
28
+ };
29
+
30
+ return <AccountAvatar account={account} size={sizeMap[props.size ?? "md"]} class={props.class} />;
31
+ }
@@ -0,0 +1,230 @@
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";
5
+
6
+ export interface SearchableOption {
7
+ value: string | number;
8
+ label: string;
9
+ description?: string;
10
+ }
11
+
12
+ export interface SearchableSelectProps {
13
+ value: string | number | null | undefined;
14
+ options: SearchableOption[];
15
+ onChange: (next: SearchableOption | null) => void | Promise<void>;
16
+ placeholder?: string;
17
+ searchPlaceholder?: string;
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;
27
+ }
28
+
29
+ const POPUP_MIN_WIDTH = 240;
30
+ const POPUP_MAX_HEIGHT = 320; // keep in sync with max-h-80 below
31
+
32
+ // Click-to-open combobox with inline search. The popup is rendered into a
33
+ // Portal and positioned with `position: fixed`, so it can escape any ancestor
34
+ // with `overflow: hidden` (e.g. the rounded table cards) and flip upward when
35
+ // there's not enough room below the trigger.
36
+ export default function SearchableSelect(props: SearchableSelectProps): JSX.Element {
37
+ const [open, setOpen] = createSignal(false);
38
+ const [query, setQuery] = createSignal("");
39
+ const [busy, setBusy] = createSignal(false);
40
+ const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
41
+ let triggerRef: HTMLButtonElement | undefined;
42
+ let popupRef: HTMLDivElement | undefined;
43
+ let inputRef: HTMLInputElement | undefined;
44
+
45
+ const currentLabel = () => {
46
+ if (props.value == null || props.value === "") return undefined;
47
+ return props.options.find((o) => String(o.value) === String(props.value))?.label;
48
+ };
49
+
50
+ const filtered = createMemo(() => {
51
+ const q = query().trim().toLowerCase();
52
+ if (!q) return props.options;
53
+ return props.options.filter(
54
+ (o) =>
55
+ o.label.toLowerCase().includes(q) || (o.description?.toLowerCase().includes(q) ?? false),
56
+ );
57
+ });
58
+
59
+ const updatePosition = () => {
60
+ if (!triggerRef) return;
61
+ const rect = triggerRef.getBoundingClientRect();
62
+ const vpHeight = window.innerHeight;
63
+ const vpWidth = window.innerWidth;
64
+ const width = Math.max(POPUP_MIN_WIDTH, rect.width);
65
+ const spaceBelow = vpHeight - rect.bottom;
66
+ const spaceAbove = rect.top;
67
+ const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
68
+ const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
69
+ const maxHeight = Math.max(
70
+ 160,
71
+ Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
72
+ );
73
+ // Clamp horizontally so the popup doesn't overflow the viewport edges.
74
+ const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
75
+ setPopupStyle({
76
+ position: "fixed",
77
+ top: `${top}px`,
78
+ left: `${left}px`,
79
+ width: `${width}px`,
80
+ "max-height": `${maxHeight}px`,
81
+ });
82
+ };
83
+
84
+ createEffect(() => {
85
+ if (!open()) return;
86
+ // Initial measurement + focus + listeners.
87
+ updatePosition();
88
+ queueMicrotask(() => inputRef?.focus());
89
+
90
+ const onDocClick = (e: MouseEvent) => {
91
+ const target = e.target as Node;
92
+ if (triggerRef?.contains(target)) return;
93
+ if (popupRef?.contains(target)) return;
94
+ setOpen(false);
95
+ setQuery("");
96
+ };
97
+ const onEsc = (e: KeyboardEvent) => {
98
+ if (e.key === "Escape") {
99
+ setOpen(false);
100
+ setQuery("");
101
+ }
102
+ };
103
+ const onReflow = () => updatePosition();
104
+
105
+ document.addEventListener("mousedown", onDocClick);
106
+ document.addEventListener("keydown", onEsc);
107
+ window.addEventListener("resize", onReflow);
108
+ // Capture phase so we catch scrolls in every ancestor.
109
+ window.addEventListener("scroll", onReflow, true);
110
+ onCleanup(() => {
111
+ document.removeEventListener("mousedown", onDocClick);
112
+ document.removeEventListener("keydown", onEsc);
113
+ window.removeEventListener("resize", onReflow);
114
+ window.removeEventListener("scroll", onReflow, true);
115
+ });
116
+ });
117
+
118
+ const emptyStateLabel = () => {
119
+ if (props.loading) return "Loading…";
120
+ if (query()) return props.noMatchLabel ?? "No matches";
121
+ return props.emptyLabel ?? "No options";
122
+ };
123
+
124
+ const select = async (opt: SearchableOption | null) => {
125
+ setBusy(true);
126
+ try {
127
+ await props.onChange(opt);
128
+ } finally {
129
+ setBusy(false);
130
+ setOpen(false);
131
+ setQuery("");
132
+ }
133
+ };
134
+
135
+ return (
136
+ <div class={props.wrapperClass ?? "relative inline-block"}>
137
+ <button
138
+ ref={triggerRef}
139
+ type="button"
140
+ data-testid={props.triggerTestId}
141
+ disabled={props.disabled || busy()}
142
+ onClick={() => !props.disabled && setOpen((o) => !o)}
143
+ class={
144
+ props.triggerClass ??
145
+ "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"
146
+ }
147
+ classList={{ "cursor-not-allowed opacity-60": props.disabled }}
148
+ title={props.disabled ? undefined : "Click to select"}
149
+ >
150
+ <span class={props.triggerLabelClass ?? "truncate max-w-[180px]"}>
151
+ {busy()
152
+ ? "…"
153
+ : (currentLabel() ?? (
154
+ <span class="text-zinc-500 italic">{props.placeholder ?? "Select…"}</span>
155
+ ))}
156
+ </span>
157
+ <ChevronsUpDown size={12} class="text-zinc-500 shrink-0" />
158
+ </button>
159
+ <Show when={open()}>
160
+ <Portal>
161
+ <div
162
+ ref={popupRef}
163
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
164
+ style={popupStyle()}
165
+ >
166
+ <div class="px-2 py-1.5 border-b border-zinc-800">
167
+ <input
168
+ ref={inputRef}
169
+ type="text"
170
+ value={query()}
171
+ onInput={(e) => setQuery(e.currentTarget.value)}
172
+ placeholder={props.searchPlaceholder ?? "Search…"}
173
+ 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"
174
+ />
175
+ </div>
176
+ <div class="flex-1 overflow-y-auto">
177
+ <Show
178
+ when={!props.loading && filtered().length > 0}
179
+ fallback={
180
+ <div class="px-3 py-3 text-xs text-zinc-500 text-center">{emptyStateLabel()}</div>
181
+ }
182
+ >
183
+ <For each={filtered()}>
184
+ {(opt) => {
185
+ const selected = () => String(opt.value) === String(props.value ?? "");
186
+ return (
187
+ <button
188
+ type="button"
189
+ onClick={() => select(opt)}
190
+ 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"
191
+ classList={{
192
+ "text-amber-400": selected(),
193
+ "text-zinc-200": !selected(),
194
+ }}
195
+ >
196
+ <span class="flex flex-col min-w-0">
197
+ <span class="font-medium truncate">{opt.label}</span>
198
+ <Show when={opt.description}>
199
+ <span class="text-[10px] text-zinc-500 truncate">
200
+ {opt.description}
201
+ </span>
202
+ </Show>
203
+ </span>
204
+ <Show when={selected()}>
205
+ <span class="text-amber-400 shrink-0">✓</span>
206
+ </Show>
207
+ </button>
208
+ );
209
+ }}
210
+ </For>
211
+ </Show>
212
+ </div>
213
+ <Show when={props.allowClear && props.value != null && props.value !== ""}>
214
+ <div class="border-t border-zinc-800">
215
+ <button
216
+ type="button"
217
+ onClick={() => select(null)}
218
+ 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"
219
+ >
220
+ <X size={12} />
221
+ <span>Clear selection</span>
222
+ </button>
223
+ </div>
224
+ </Show>
225
+ </div>
226
+ </Portal>
227
+ </Show>
228
+ </div>
229
+ );
230
+ }
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  buildInitialsSvg,
41
41
  } from "./components/base/AccountAvatar";
42
42
  export type { AvatarAccount } from "./components/base/AccountAvatar";
43
+ export { default as Avatar, type AvatarProps } from "./components/base/Avatar";
43
44
 
44
45
  export { default as ExistingAttachmentTile } from "./components/base/ExistingAttachmentTile";
45
46
  export type { ExistingAttachment } from "./components/base/ExistingAttachmentTile";
@@ -131,6 +132,11 @@ export type {
131
132
  ComboBoxSingleProps,
132
133
  ComboBoxMultiProps,
133
134
  } from "./components/composite/ComboBox";
135
+ export {
136
+ default as SearchableSelect,
137
+ type SearchableSelectProps,
138
+ type SearchableOption,
139
+ } from "./components/composite/SearchableSelect";
134
140
 
135
141
  // Shared domain option shapes for the common pickers, decoupled from any
136
142
  // component (still imported across transactions / counter / payees).