@kahitsan/ksui 0.4.0 → 0.6.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/README.md +16 -4
- package/package.json +1 -1
- package/src/components/base/DateTile.tsx +84 -0
- package/src/components/composite/PayeePicker.tsx +362 -0
- package/src/index.ts +4 -0
package/README.md
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# @kahitsan/ksui
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@kahitsan/ksui)
|
|
4
|
+
[](https://www.npmjs.com/package/@kahitsan/ksui)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
3
7
|
A set of UI components for SolidJS apps, built and used by the KahitSan team.
|
|
4
8
|
|
|
9
|
+
On npm: [@kahitsan/ksui](https://www.npmjs.com/package/@kahitsan/ksui). Docs:
|
|
10
|
+
[ksui.kahitsan.com](https://ksui.kahitsan.com/).
|
|
11
|
+
|
|
5
12
|
## What this is
|
|
6
13
|
|
|
7
14
|
`@kahitsan/ksui` is a small library of ready-made user interface pieces for
|
|
@@ -15,7 +22,7 @@ the docs (see below), so this README stays short on purpose.
|
|
|
15
22
|
|
|
16
23
|
## Where you can use it
|
|
17
24
|
|
|
18
|
-
There are two ways to use
|
|
25
|
+
There are two ways to use KSUI.
|
|
19
26
|
|
|
20
27
|
1. **In your own SolidJS project.** Install it from npm and use the components
|
|
21
28
|
like any other dependency. It is MIT licensed, so you are free to use it in
|
|
@@ -46,7 +53,7 @@ We do not list every component here on purpose, so this README does not fall out
|
|
|
46
53
|
of date as the set grows. The complete, current catalog, with a live example and
|
|
47
54
|
the props for each one, is in the docs:
|
|
48
55
|
|
|
49
|
-
**https://kahitsan.
|
|
56
|
+
**https://ksui.kahitsan.com/**
|
|
50
57
|
|
|
51
58
|
## Install
|
|
52
59
|
|
|
@@ -114,7 +121,7 @@ to mock the host kit and stub those calls so you can see every component render.
|
|
|
114
121
|
The full documentation site, with a component-by-component guide and setup steps,
|
|
115
122
|
will be at:
|
|
116
123
|
|
|
117
|
-
https://kahitsan.
|
|
124
|
+
https://ksui.kahitsan.com/
|
|
118
125
|
|
|
119
126
|
## Contributing
|
|
120
127
|
|
|
@@ -123,6 +130,11 @@ up the repo, the code style we follow, and how to send a change.
|
|
|
123
130
|
|
|
124
131
|
## License
|
|
125
132
|
|
|
126
|
-
MIT. See [LICENSE](./LICENSE). You are free to use
|
|
133
|
+
MIT. See [LICENSE](./LICENSE). You are free to use KSUI in your own personal or
|
|
127
134
|
commercial SolidJS projects, as long as you keep the MIT license notice. Source
|
|
128
135
|
lives at https://github.com/KahitSan/ksui.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
Created with ♥ by [Luis Edward Miranda](https://github.com/llupRisinglll) for
|
|
140
|
+
KahitSan Corp.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. 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 solid-js + @kserp/host-ui externalized to the host runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// DateTile — a compact calendar day cell.
|
|
2
|
+
//
|
|
3
|
+
// A 60x68 tile with three stacked parts: a small top band (e.g. a month
|
|
4
|
+
// label), a big primary value (e.g. the day-of-month), and an optional
|
|
5
|
+
// muted sub-label (e.g. hours worked, a count, a short note). It is the
|
|
6
|
+
// visual unit a caller repeats into a ledger-style row of days — scanned
|
|
7
|
+
// like a register, not rendered as a full month grid.
|
|
8
|
+
//
|
|
9
|
+
// Domain-free by design: it knows nothing about timesheets, payroll, or any
|
|
10
|
+
// data shape. The caller formats every string it shows (`topLabel`,
|
|
11
|
+
// `value`, `subLabel`) and owns selection state. Pass `onToggle` to make the
|
|
12
|
+
// tile an interactive, selectable button (with `aria-pressed`); omit it for a
|
|
13
|
+
// static, read-only cell that renders dimmed so a row can show which tiles are
|
|
14
|
+
// "in" vs "out" of a selection at a glance.
|
|
15
|
+
|
|
16
|
+
import { Show, type JSX } from "solid-js";
|
|
17
|
+
import { Dynamic } from "solid-js/web";
|
|
18
|
+
|
|
19
|
+
export interface DateTileProps {
|
|
20
|
+
/** The big primary value — typically a day-of-month number, but any short
|
|
21
|
+
* string works (the caller formats it). */
|
|
22
|
+
value: number | string;
|
|
23
|
+
/** Small uppercase band above the value, e.g. a month label ("MAY"). */
|
|
24
|
+
topLabel?: string;
|
|
25
|
+
/** Muted line below the value, e.g. "10h" or a short count. */
|
|
26
|
+
subLabel?: string;
|
|
27
|
+
/** Selected state. For interactive tiles this drives the amber accent +
|
|
28
|
+
* `aria-pressed`; ignored for read-only tiles (they always render "on"). */
|
|
29
|
+
selected?: boolean;
|
|
30
|
+
/** Make the tile a selectable button. When omitted the tile is a static,
|
|
31
|
+
* non-interactive cell. */
|
|
32
|
+
onToggle?: () => void;
|
|
33
|
+
/** Optional test id, applied only to interactive tiles. */
|
|
34
|
+
testId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function DateTile(props: DateTileProps): JSX.Element {
|
|
38
|
+
const interactive = (): boolean => !!props.onToggle;
|
|
39
|
+
const on = (): boolean => (interactive() ? props.selected ?? false : true);
|
|
40
|
+
return (
|
|
41
|
+
<Dynamic
|
|
42
|
+
component={interactive() ? "button" : "div"}
|
|
43
|
+
type={interactive() ? "button" : undefined}
|
|
44
|
+
onClick={props.onToggle}
|
|
45
|
+
aria-pressed={interactive() ? on() : undefined}
|
|
46
|
+
data-testid={interactive() ? props.testId : undefined}
|
|
47
|
+
style={{ width: "60px", height: "68px", flex: "0 0 auto" }}
|
|
48
|
+
class={`${interactive() ? "cursor-pointer" : ""} flex flex-col rounded-md overflow-hidden border transition-colors`}
|
|
49
|
+
classList={{
|
|
50
|
+
"border-amber-500/25 bg-zinc-800/40": on(),
|
|
51
|
+
"border-zinc-800/60 bg-zinc-900/40 opacity-50 hover:opacity-80": !on(),
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
style={{ height: "18px" }}
|
|
56
|
+
class="flex items-center justify-center text-[9px] font-semibold uppercase tracking-[0.15em] bg-zinc-800/70 text-zinc-500"
|
|
57
|
+
>
|
|
58
|
+
{props.topLabel ?? ""}
|
|
59
|
+
</div>
|
|
60
|
+
<div class="flex-1 flex flex-col items-center justify-center">
|
|
61
|
+
<div
|
|
62
|
+
class="text-xl font-semibold leading-none tabular-nums"
|
|
63
|
+
classList={{
|
|
64
|
+
"text-amber-400/90": on(),
|
|
65
|
+
"text-zinc-500": !on(),
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{props.value}
|
|
69
|
+
</div>
|
|
70
|
+
<Show when={props.subLabel != null && props.subLabel !== ""}>
|
|
71
|
+
<div
|
|
72
|
+
class="text-[9px] mt-0.5 tabular-nums"
|
|
73
|
+
classList={{
|
|
74
|
+
"text-zinc-500": on(),
|
|
75
|
+
"text-zinc-700": !on(),
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{props.subLabel}
|
|
79
|
+
</div>
|
|
80
|
+
</Show>
|
|
81
|
+
</div>
|
|
82
|
+
</Dynamic>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// PayeePicker — a searchable combobox for the "Paid to" / "Received from" /
|
|
2
|
+
// "Payable to" field. The canonical copy lives here in @kahitsan/ksui (a
|
|
3
|
+
// composite ERP picker, sibling to ClientPicker / VoucherPicker); plugins
|
|
4
|
+
// import it instead of vendoring their own.
|
|
5
|
+
//
|
|
6
|
+
// Fetches the SIBLING payees plugin's public API at /api/payees and degrades
|
|
7
|
+
// gracefully — when the payees plugin isn't deployed the popup shows a
|
|
8
|
+
// "couldn't load" notice but the free-text fallback (selectedName) keeps the
|
|
9
|
+
// trigger usable, and the consuming API can persist payee as a plain string
|
|
10
|
+
// regardless, so the form still saves.
|
|
11
|
+
|
|
12
|
+
import { Portal } from "solid-js/web";
|
|
13
|
+
import { createEffect, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
|
|
14
|
+
import Store from "lucide-solid/icons/store";
|
|
15
|
+
import UserPlus from "lucide-solid/icons/user-plus";
|
|
16
|
+
import Search from "lucide-solid/icons/search";
|
|
17
|
+
import X from "lucide-solid/icons/x";
|
|
18
|
+
import Loader2 from "lucide-solid/icons/loader-2";
|
|
19
|
+
|
|
20
|
+
export type PayeeKind = "vendor" | "customer" | "both";
|
|
21
|
+
|
|
22
|
+
export interface PayeeOption {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
kind: PayeeKind;
|
|
26
|
+
default_subcategory?: string | null;
|
|
27
|
+
notes?: string | null;
|
|
28
|
+
is_active?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PayeePickerProps {
|
|
32
|
+
selected: PayeeOption | null;
|
|
33
|
+
selectedName?: string | null;
|
|
34
|
+
kind?: PayeeKind;
|
|
35
|
+
createAsKind?: PayeeKind;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
onChange: (next: PayeeOption | null) => void;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
testIdPrefix?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const POPUP_MAX_HEIGHT = 360;
|
|
43
|
+
const POPUP_MIN_WIDTH = 320;
|
|
44
|
+
const SEARCH_DEBOUNCE_MS = 200;
|
|
45
|
+
|
|
46
|
+
export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
47
|
+
const [open, setOpen] = createSignal(false);
|
|
48
|
+
const [query, setQuery] = createSignal("");
|
|
49
|
+
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
50
|
+
const [results, setResults] = createSignal<PayeeOption[]>([]);
|
|
51
|
+
const [loading, setLoading] = createSignal(false);
|
|
52
|
+
const [creating, setCreating] = createSignal(false);
|
|
53
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
54
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
55
|
+
|
|
56
|
+
const tid = (suffix: string) => `${props.testIdPrefix ?? "payee-picker"}-${suffix}`;
|
|
57
|
+
|
|
58
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
59
|
+
let popupRef: HTMLDivElement | undefined;
|
|
60
|
+
let inputRef: HTMLInputElement | undefined;
|
|
61
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
62
|
+
let activeFetchToken = 0;
|
|
63
|
+
|
|
64
|
+
createEffect(() => {
|
|
65
|
+
const q = query();
|
|
66
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
67
|
+
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
68
|
+
onCleanup(() => {
|
|
69
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
createEffect(() => {
|
|
74
|
+
if (!open()) return;
|
|
75
|
+
const q = debouncedQuery().trim();
|
|
76
|
+
const token = ++activeFetchToken;
|
|
77
|
+
setLoading(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
const params = new URLSearchParams({ status: "active", limit: "20" });
|
|
80
|
+
if (q) params.set("search", q);
|
|
81
|
+
if (props.kind) params.set("kind", props.kind);
|
|
82
|
+
fetch(`/api/payees?${params.toString()}`, { credentials: "include" })
|
|
83
|
+
.then((r) => {
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
throw new Error(
|
|
86
|
+
r.status === 403
|
|
87
|
+
? "Permission denied"
|
|
88
|
+
: r.status === 404
|
|
89
|
+
? "Payees module isn't available — type a name instead"
|
|
90
|
+
: "Failed to load",
|
|
91
|
+
);
|
|
92
|
+
return r.json();
|
|
93
|
+
})
|
|
94
|
+
.then((json) => {
|
|
95
|
+
if (token !== activeFetchToken) return;
|
|
96
|
+
setResults((json.data || []) as PayeeOption[]);
|
|
97
|
+
})
|
|
98
|
+
.catch((e) => {
|
|
99
|
+
if (token !== activeFetchToken) return;
|
|
100
|
+
setError(e instanceof Error ? e.message : "Failed to load");
|
|
101
|
+
setResults([]);
|
|
102
|
+
})
|
|
103
|
+
.finally(() => {
|
|
104
|
+
if (token !== activeFetchToken) return;
|
|
105
|
+
setLoading(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const updatePosition = () => {
|
|
110
|
+
if (!triggerRef) return;
|
|
111
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
112
|
+
const vpHeight = window.innerHeight;
|
|
113
|
+
const vpWidth = window.innerWidth;
|
|
114
|
+
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
115
|
+
const spaceBelow = vpHeight - rect.bottom;
|
|
116
|
+
const spaceAbove = rect.top;
|
|
117
|
+
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
118
|
+
const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
|
|
119
|
+
const maxHeight = Math.max(
|
|
120
|
+
200,
|
|
121
|
+
Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
|
|
122
|
+
);
|
|
123
|
+
const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
|
|
124
|
+
setPopupStyle({
|
|
125
|
+
position: "fixed",
|
|
126
|
+
top: `${top}px`,
|
|
127
|
+
left: `${left}px`,
|
|
128
|
+
width: `${width}px`,
|
|
129
|
+
"max-height": `${maxHeight}px`,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
createEffect(() => {
|
|
134
|
+
if (!open()) return;
|
|
135
|
+
updatePosition();
|
|
136
|
+
queueMicrotask(() => inputRef?.focus());
|
|
137
|
+
|
|
138
|
+
const onDocClick = (e: MouseEvent) => {
|
|
139
|
+
const t = e.target as Node;
|
|
140
|
+
if (triggerRef?.contains(t)) return;
|
|
141
|
+
if (popupRef?.contains(t)) return;
|
|
142
|
+
close();
|
|
143
|
+
};
|
|
144
|
+
const onEsc = (e: KeyboardEvent) => {
|
|
145
|
+
if (e.key === "Escape") {
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
close();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const onReflow = () => updatePosition();
|
|
151
|
+
|
|
152
|
+
document.addEventListener("mousedown", onDocClick);
|
|
153
|
+
document.addEventListener("keydown", onEsc, true);
|
|
154
|
+
window.addEventListener("resize", onReflow);
|
|
155
|
+
window.addEventListener("scroll", onReflow, true);
|
|
156
|
+
onCleanup(() => {
|
|
157
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
158
|
+
document.removeEventListener("keydown", onEsc, true);
|
|
159
|
+
window.removeEventListener("resize", onReflow);
|
|
160
|
+
window.removeEventListener("scroll", onReflow, true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const close = () => {
|
|
165
|
+
setOpen(false);
|
|
166
|
+
setQuery("");
|
|
167
|
+
setDebouncedQuery("");
|
|
168
|
+
setResults([]);
|
|
169
|
+
setError(null);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const trimmedQuery = () => query().trim();
|
|
173
|
+
|
|
174
|
+
const hasExactMatch = () => {
|
|
175
|
+
const q = trimmedQuery().toLowerCase();
|
|
176
|
+
if (!q) return true;
|
|
177
|
+
return results().some((r) => r.name.trim().toLowerCase() === q);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const showCreateOption = () => trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
181
|
+
|
|
182
|
+
const select = (p: PayeeOption) => {
|
|
183
|
+
props.onChange(p);
|
|
184
|
+
close();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const createAndSelect = async () => {
|
|
188
|
+
const name = trimmedQuery();
|
|
189
|
+
if (!name || creating()) return;
|
|
190
|
+
setCreating(true);
|
|
191
|
+
setError(null);
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch("/api/payees", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
credentials: "include",
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ name, kind: props.createAsKind ?? props.kind ?? "vendor" }),
|
|
198
|
+
});
|
|
199
|
+
if (!res.ok && res.status !== 200) {
|
|
200
|
+
const body = await res.json().catch(() => ({ error: "Failed to create payee" }));
|
|
201
|
+
throw new Error(body.error || "Failed to create payee");
|
|
202
|
+
}
|
|
203
|
+
const created = (await res.json()) as PayeeOption;
|
|
204
|
+
props.onChange(created);
|
|
205
|
+
close();
|
|
206
|
+
} catch (e) {
|
|
207
|
+
setError(e instanceof Error ? e.message : "Failed to create payee");
|
|
208
|
+
} finally {
|
|
209
|
+
setCreating(false);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const clear = (e: MouseEvent) => {
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
props.onChange(null);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const triggerLabel = () => {
|
|
219
|
+
if (props.selected) return props.selected.name;
|
|
220
|
+
if (props.selectedName && props.selectedName.trim()) return props.selectedName.trim();
|
|
221
|
+
return null;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const placeholder = () => props.placeholder ?? "Tap to pick a payee";
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<>
|
|
228
|
+
<button
|
|
229
|
+
ref={triggerRef}
|
|
230
|
+
type="button"
|
|
231
|
+
data-testid={tid("trigger")}
|
|
232
|
+
disabled={props.disabled}
|
|
233
|
+
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
234
|
+
class="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
|
|
235
|
+
aria-haspopup="listbox"
|
|
236
|
+
aria-expanded={open()}
|
|
237
|
+
>
|
|
238
|
+
<Store size={16} class="shrink-0 text-zinc-400" />
|
|
239
|
+
<Show when={triggerLabel()} fallback={<span class="text-zinc-500 italic">{placeholder()}</span>}>
|
|
240
|
+
<span class="flex-1 min-w-0">
|
|
241
|
+
<span class="block truncate text-zinc-100 font-medium">{triggerLabel()}</span>
|
|
242
|
+
<Show when={props.selected && !props.selected.id}>
|
|
243
|
+
<span class="block text-[11px] text-zinc-500">unlinked (legacy)</span>
|
|
244
|
+
</Show>
|
|
245
|
+
</span>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
data-testid={tid("clear")}
|
|
249
|
+
onClick={clear}
|
|
250
|
+
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
251
|
+
title="Clear"
|
|
252
|
+
aria-label="Clear payee"
|
|
253
|
+
>
|
|
254
|
+
<X size={14} />
|
|
255
|
+
</button>
|
|
256
|
+
</Show>
|
|
257
|
+
</button>
|
|
258
|
+
|
|
259
|
+
<Show when={open()}>
|
|
260
|
+
<Portal>
|
|
261
|
+
<div
|
|
262
|
+
ref={popupRef}
|
|
263
|
+
data-testid={tid("popup")}
|
|
264
|
+
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
265
|
+
style={popupStyle()}
|
|
266
|
+
>
|
|
267
|
+
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
268
|
+
<Search size={14} class="text-zinc-500 shrink-0 ml-1" />
|
|
269
|
+
<input
|
|
270
|
+
ref={inputRef}
|
|
271
|
+
type="text"
|
|
272
|
+
data-testid={tid("input")}
|
|
273
|
+
role="combobox"
|
|
274
|
+
aria-expanded={open()}
|
|
275
|
+
aria-controls={`${tid("listbox")}`}
|
|
276
|
+
aria-autocomplete="list"
|
|
277
|
+
aria-label="Search payees"
|
|
278
|
+
value={query()}
|
|
279
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
280
|
+
placeholder="Search or add a new payee…"
|
|
281
|
+
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
282
|
+
/>
|
|
283
|
+
<Show when={loading()}>
|
|
284
|
+
<Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
|
|
285
|
+
</Show>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="flex-1 overflow-y-auto">
|
|
288
|
+
<Show when={error()}>
|
|
289
|
+
<div role="status" class="px-3 py-2 text-xs text-red-400">
|
|
290
|
+
{error()}
|
|
291
|
+
</div>
|
|
292
|
+
</Show>
|
|
293
|
+
<Show when={!loading() && results().length === 0 && !showCreateOption() && !error()}>
|
|
294
|
+
<div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
|
|
295
|
+
{trimmedQuery() ? "No matches" : "Start typing or pick from your list…"}
|
|
296
|
+
</div>
|
|
297
|
+
</Show>
|
|
298
|
+
<Show when={results().length > 0}>
|
|
299
|
+
<ul
|
|
300
|
+
id={tid("listbox")}
|
|
301
|
+
data-testid={tid("listbox")}
|
|
302
|
+
role="listbox"
|
|
303
|
+
aria-label="Payee search results"
|
|
304
|
+
class="m-0 p-0 list-none"
|
|
305
|
+
>
|
|
306
|
+
<For each={results()}>
|
|
307
|
+
{(p) => (
|
|
308
|
+
<li role="option" aria-selected={props.selected?.id === p.id}>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
data-testid={`${tid("result")}-${p.id}`}
|
|
312
|
+
onClick={() => select(p)}
|
|
313
|
+
class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
|
|
314
|
+
>
|
|
315
|
+
<Store size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
316
|
+
<span class="flex-1 min-w-0">
|
|
317
|
+
<span class="block text-sm text-zinc-100 truncate">{p.name}</span>
|
|
318
|
+
<Show when={p.default_subcategory || p.kind !== "vendor"}>
|
|
319
|
+
<span class="block text-[11px] text-zinc-500 truncate">
|
|
320
|
+
{[p.kind === "vendor" ? null : p.kind, p.default_subcategory]
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
.join(" · ")}
|
|
323
|
+
</span>
|
|
324
|
+
</Show>
|
|
325
|
+
</span>
|
|
326
|
+
<Show when={props.selected?.id === p.id}>
|
|
327
|
+
<span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
|
|
328
|
+
</Show>
|
|
329
|
+
</button>
|
|
330
|
+
</li>
|
|
331
|
+
)}
|
|
332
|
+
</For>
|
|
333
|
+
</ul>
|
|
334
|
+
</Show>
|
|
335
|
+
<Show when={showCreateOption()}>
|
|
336
|
+
<div class="border-t border-zinc-800">
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
data-testid={tid("create")}
|
|
340
|
+
onClick={createAndSelect}
|
|
341
|
+
disabled={creating()}
|
|
342
|
+
class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
|
343
|
+
>
|
|
344
|
+
<Show
|
|
345
|
+
when={!creating()}
|
|
346
|
+
fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
|
|
347
|
+
>
|
|
348
|
+
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
349
|
+
</Show>
|
|
350
|
+
<span class="text-sm text-emerald-300">
|
|
351
|
+
New payee "<span class="font-medium">{trimmedQuery()}</span>"
|
|
352
|
+
</span>
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
</Show>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</Portal>
|
|
359
|
+
</Show>
|
|
360
|
+
</>
|
|
361
|
+
);
|
|
362
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ export { default as KpiCard, type KpiCardProps, type KpiTone } from "./component
|
|
|
63
63
|
export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
|
|
64
64
|
export { default as FormErrorBanner } from "./components/base/FormErrorBanner";
|
|
65
65
|
export { default as TagPill } from "./components/base/TagPill";
|
|
66
|
+
export { default as DateTile, type DateTileProps } from "./components/base/DateTile";
|
|
66
67
|
|
|
67
68
|
// ---------------------------------------------------------------------------
|
|
68
69
|
// Composite components
|
|
@@ -90,6 +91,9 @@ export type { MarkdownNotesProps } from "./components/composite/MarkdownNotes";
|
|
|
90
91
|
export { default as ClientPicker } from "./components/composite/ClientPicker";
|
|
91
92
|
export type { ClientOption } from "./components/composite/ClientPicker";
|
|
92
93
|
|
|
94
|
+
export { default as PayeePicker } from "./components/composite/PayeePicker";
|
|
95
|
+
export type { PayeeOption, PayeeKind } from "./components/composite/PayeePicker";
|
|
96
|
+
|
|
93
97
|
export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
|
|
94
98
|
export type { VoucherOption } from "./components/composite/VoucherPicker";
|
|
95
99
|
|