@kahitsan/ksui 0.39.4 → 0.40.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 +1 -1
- package/src/components/base/ExistingAttachmentTile.test.tsx +54 -0
- package/src/components/base/ExistingAttachmentTile.tsx +43 -8
- package/src/components/base/ImageViewer.test.tsx +25 -0
- package/src/components/base/ImageViewer.tsx +15 -5
- package/src/components/composite/AccountRadioPicker.tsx +6 -22
- package/src/components/composite/MultiPeriodPicker.test.tsx +36 -0
- package/src/components/composite/MultiPeriodPicker.tsx +252 -0
- package/src/components/composite/TransactionAccountFields.tsx +0 -3
- package/src/components/composite/TransactionForm.tsx +70 -66
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@solidjs/testing-library";
|
|
3
|
+
import { Suspense } from "solid-js";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import ExistingAttachmentTile from "./ExistingAttachmentTile";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("ExistingAttachmentTile", () => {
|
|
13
|
+
it("keeps its parent rendered while private attachment bytes load", async () => {
|
|
14
|
+
let resolveFetch!: (response: Response) => void;
|
|
15
|
+
vi.stubGlobal(
|
|
16
|
+
"fetch",
|
|
17
|
+
vi.fn(
|
|
18
|
+
() =>
|
|
19
|
+
new Promise<Response>((resolve) => {
|
|
20
|
+
resolveFetch = resolve;
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
vi.stubGlobal("URL", {
|
|
25
|
+
...URL,
|
|
26
|
+
createObjectURL: vi.fn(() => "blob:attachment"),
|
|
27
|
+
revokeObjectURL: vi.fn(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
render(() => (
|
|
31
|
+
<Suspense fallback={<p>Modal fallback</p>}>
|
|
32
|
+
<div data-testid="modal-shell">
|
|
33
|
+
<ExistingAttachmentTile
|
|
34
|
+
attachment={{
|
|
35
|
+
id: 1,
|
|
36
|
+
file_name: "receipt.jpg",
|
|
37
|
+
mime_type: "image/jpeg",
|
|
38
|
+
s3_link: null,
|
|
39
|
+
}}
|
|
40
|
+
rawHref="/api/attachments/1/raw"
|
|
41
|
+
testId="attachment"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</Suspense>
|
|
45
|
+
));
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("modal-shell")).toBeTruthy();
|
|
48
|
+
expect(screen.queryByText("Modal fallback")).toBeNull();
|
|
49
|
+
expect(screen.getByText("Loading")).toBeTruthy();
|
|
50
|
+
|
|
51
|
+
resolveFetch(new Response(new Blob(["image"]), { status: 200 }));
|
|
52
|
+
expect(await screen.findByAltText("receipt.jpg")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
// route and render the resulting blob: (the proxy/blob pattern). s3_link is then
|
|
11
11
|
// ignored for rendering; a spinner shows while the bytes stream.
|
|
12
12
|
|
|
13
|
-
import { Show, createSignal, type Component } from "solid-js";
|
|
13
|
+
import { Show, createEffect, createSignal, onCleanup, type Component } from "solid-js";
|
|
14
14
|
import Paperclip from "lucide-solid/icons/paperclip";
|
|
15
15
|
import X from "lucide-solid/icons/x";
|
|
16
16
|
import TriangleAlert from "lucide-solid/icons/triangle-alert";
|
|
17
17
|
import Loader2 from "lucide-solid/icons/loader-2";
|
|
18
18
|
import { confirm } from "../../utils/confirm";
|
|
19
19
|
import { attachmentUrl, isResolvableAttachment } from "../../utils/attachments";
|
|
20
|
-
import { createObjectUrlResource } from "../../utils/object-url-resource";
|
|
21
20
|
import ImageViewer from "./ImageViewer";
|
|
22
21
|
|
|
23
22
|
export interface ExistingAttachment {
|
|
@@ -40,17 +39,53 @@ interface Props {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
export default function ExistingAttachmentTile(props: Props) {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
// Keep attachment loading local. A pending createResource promise can suspend
|
|
43
|
+
// the nearest boundary and briefly unmount the transaction modal.
|
|
44
|
+
const [blob, setBlob] = createSignal<string | null>(null);
|
|
45
|
+
const [blobLoading, setBlobLoading] = createSignal(false);
|
|
46
|
+
let requestId = 0;
|
|
47
|
+
let currentUrl: string | null = null;
|
|
48
|
+
const revokeUrl = () => {
|
|
49
|
+
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
|
50
|
+
currentUrl = null;
|
|
51
|
+
};
|
|
52
|
+
createEffect(() => {
|
|
53
|
+
const href = props.rawHref ?? null;
|
|
54
|
+
const id = ++requestId;
|
|
55
|
+
revokeUrl();
|
|
56
|
+
setBlob(null);
|
|
57
|
+
if (!href) {
|
|
58
|
+
setBlobLoading(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setBlobLoading(true);
|
|
62
|
+
void fetch(href, { credentials: "include", ...props.rawInit })
|
|
63
|
+
.then(async (res) => (res.ok ? URL.createObjectURL(await res.blob()) : null))
|
|
64
|
+
.then((url) => {
|
|
65
|
+
if (id !== requestId) {
|
|
66
|
+
if (url) URL.revokeObjectURL(url);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
currentUrl = url;
|
|
70
|
+
setBlob(url);
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
if (id === requestId) setBlob(null);
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
if (id === requestId) setBlobLoading(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
onCleanup(() => {
|
|
80
|
+
requestId++;
|
|
81
|
+
revokeUrl();
|
|
82
|
+
});
|
|
48
83
|
const isBlobMode = () => props.rawHref != null;
|
|
49
84
|
const url = (): string | undefined =>
|
|
50
85
|
isBlobMode() ? (blob() ?? undefined) : attachmentUrl(props.attachment.s3_link);
|
|
51
86
|
const resolvable = () =>
|
|
52
87
|
isBlobMode() ? blob() != null : isResolvableAttachment(props.attachment.s3_link);
|
|
53
|
-
const loading = () => (isBlobMode() ?
|
|
88
|
+
const loading = () => (isBlobMode() ? blobLoading() : false);
|
|
54
89
|
const [viewerOpen, setViewerOpen] = createSignal(false);
|
|
55
90
|
|
|
56
91
|
const FallbackIcon = () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render } from "@solidjs/testing-library";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import ImageViewer from "./ImageViewer";
|
|
4
|
+
|
|
5
|
+
describe("ImageViewer", () => {
|
|
6
|
+
afterEach(() => cleanup());
|
|
7
|
+
|
|
8
|
+
it("reserves viewer space before the image loads", () => {
|
|
9
|
+
const showModal = vi.fn();
|
|
10
|
+
Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: showModal });
|
|
11
|
+
Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: vi.fn() });
|
|
12
|
+
const view = render(() => <ImageViewer src="blob:test" alt="Receipt" onClose={vi.fn()} />);
|
|
13
|
+
const frame = view.container.querySelector<HTMLElement>(".ksui-imgviewer-frame");
|
|
14
|
+
const image = view.container.querySelector<HTMLImageElement>("img");
|
|
15
|
+
|
|
16
|
+
expect(frame).toBeTruthy();
|
|
17
|
+
expect(frame?.className).toContain("ksui-imgviewer-frame");
|
|
18
|
+
expect(image?.style.opacity).toBe("0");
|
|
19
|
+
expect(showModal).toHaveBeenCalledTimes(1);
|
|
20
|
+
|
|
21
|
+
fireEvent.load(image!);
|
|
22
|
+
expect(image?.style.opacity).toBe("1");
|
|
23
|
+
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// with unscoped `ksui-imgviewer-*` class names. Mount === open, unmount === closed
|
|
14
14
|
// (wrap in `<Show when={…}>`).
|
|
15
15
|
|
|
16
|
-
import { onCleanup, onMount } from "solid-js";
|
|
16
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
17
17
|
import X from "lucide-solid/icons/x";
|
|
18
18
|
import { lockPullToRefresh, unlockPullToRefresh } from "../../utils/dom";
|
|
19
19
|
import { injectCSS } from "../../utils/inject-css";
|
|
@@ -26,9 +26,10 @@ const STYLE_CSS = `
|
|
|
26
26
|
.ksui-imgviewer{position:fixed;inset:0;z-index:60;background:transparent;padding:0;margin:0;max-width:none;max-height:none;width:100vw;height:100vh;border:0;}
|
|
27
27
|
.ksui-imgviewer[open]{display:flex;align-items:center;justify-content:center;}
|
|
28
28
|
.ksui-imgviewer::backdrop{background:var(--ks-overlay, rgba(0,0,0,0.7));backdrop-filter:blur(2px);}
|
|
29
|
-
.ksui-imgviewer-
|
|
30
|
-
.ksui-imgviewer-
|
|
31
|
-
.ksui-imgviewer-close:
|
|
29
|
+
.ksui-imgviewer-frame{display:grid;place-items:center;width:96vw;height:96vh;}
|
|
30
|
+
.ksui-imgviewer-img{display:block;max-width:100%;max-height:100%;object-fit:contain;border-radius:0.25rem;box-shadow:0 25px 50px -12px color-mix(in srgb,var(--ks-overlay,rgba(0,0,0,0.7)) 80%,transparent);}
|
|
31
|
+
.ksui-imgviewer-close{position:absolute;top:1rem;right:1rem;display:flex;width:2.5rem;height:2.5rem;align-items:center;justify-content:center;border-radius:9999px;background:color-mix(in srgb,var(--ks-fg,#ffffff) 12%,transparent);color:var(--ks-fg,#ffffff);border:0;cursor:pointer;}
|
|
32
|
+
.ksui-imgviewer-close:hover{background:color-mix(in srgb,var(--ks-fg,#ffffff) 22%,transparent);}
|
|
32
33
|
`;
|
|
33
34
|
|
|
34
35
|
interface Props {
|
|
@@ -43,6 +44,7 @@ interface Props {
|
|
|
43
44
|
export default function ImageViewer(props: Props) {
|
|
44
45
|
injectCSS(STYLE_ID, STYLE_CSS);
|
|
45
46
|
let dialogEl: HTMLDialogElement | undefined;
|
|
47
|
+
const [loaded, setLoaded] = createSignal(false);
|
|
46
48
|
|
|
47
49
|
lockPullToRefresh();
|
|
48
50
|
onCleanup(unlockPullToRefresh);
|
|
@@ -89,7 +91,15 @@ export default function ImageViewer(props: Props) {
|
|
|
89
91
|
>
|
|
90
92
|
<X size={22} />
|
|
91
93
|
</button>
|
|
92
|
-
<
|
|
94
|
+
<div class="ksui-imgviewer-frame" aria-busy={!loaded()}>
|
|
95
|
+
<img
|
|
96
|
+
src={props.src}
|
|
97
|
+
alt={props.alt || ""}
|
|
98
|
+
class="ksui-imgviewer-img"
|
|
99
|
+
style={{ opacity: loaded() ? 1 : 0 }}
|
|
100
|
+
onLoad={() => setLoaded(true)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
93
103
|
</dialog>
|
|
94
104
|
);
|
|
95
105
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createEffect, Show, For } from "solid-js";
|
|
2
2
|
import AccountAvatar from "../base/AccountAvatar";
|
|
3
|
-
import Button from "../base/Button";
|
|
4
3
|
import type { PaymentAccountOption } from "./PaymentAccountPicker";
|
|
5
4
|
|
|
6
5
|
export interface AccountRadioPickerProps {
|
|
@@ -10,8 +9,6 @@ export interface AccountRadioPickerProps {
|
|
|
10
9
|
ariaLabel: string;
|
|
11
10
|
excludeId?: string;
|
|
12
11
|
autoDefault?: boolean;
|
|
13
|
-
compact?: boolean;
|
|
14
|
-
tone?: "income" | "expense" | "transfer";
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
@@ -85,37 +82,24 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
85
82
|
aria-label={props.ariaLabel}
|
|
86
83
|
tabIndex={-1}
|
|
87
84
|
class="grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2"
|
|
88
|
-
classList={{ "ks-transaction-account-picker": props.compact }}
|
|
89
85
|
onKeyDown={onKeyDown}
|
|
90
86
|
>
|
|
91
87
|
<For each={visible()}>
|
|
92
88
|
{(a, i) => {
|
|
93
89
|
const selected = () => props.value === a.id.toString();
|
|
94
|
-
const isTabStop = () => selected() || (
|
|
90
|
+
const isTabStop = () => selected() || currentIndex() === i();
|
|
95
91
|
return (
|
|
96
|
-
<
|
|
97
|
-
ref={(el
|
|
92
|
+
<button
|
|
93
|
+
ref={(el) => (buttonRefs[i()] = el)}
|
|
98
94
|
type="button"
|
|
99
95
|
role="radio"
|
|
100
96
|
aria-checked={selected()}
|
|
101
97
|
tabIndex={isTabStop() ? 0 : -1}
|
|
102
|
-
intent="secondary"
|
|
103
|
-
variant="clip1"
|
|
104
|
-
size="sm"
|
|
105
|
-
noGlow
|
|
106
|
-
noRipple
|
|
107
|
-
noScanline
|
|
108
98
|
onClick={() => props.onChange(a.id.toString())}
|
|
109
|
-
class="group flex items-center gap-2 px-3 py-3 text-left text-sm transition-colors cursor-pointer"
|
|
99
|
+
class="group flex items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm transition-colors cursor-pointer ks-hud-clip-top-left-bottom-right"
|
|
110
100
|
classList={{
|
|
111
101
|
"border-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_10%,transparent)] text-[var(--ks-accent-hover,#fcd34d)]":
|
|
112
|
-
selected()
|
|
113
|
-
"border-[color-mix(in_srgb,var(--ks-success,#10b981)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-success,#10b981)_10%,transparent)] text-[var(--ks-success-fg,#34d399)]":
|
|
114
|
-
selected() && props.compact && props.tone === "income",
|
|
115
|
-
"border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] text-[var(--ks-danger-fg,#f87171)]":
|
|
116
|
-
selected() && props.compact && props.tone === "expense",
|
|
117
|
-
"border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)] text-[var(--ks-info,#38bdf8)]":
|
|
118
|
-
selected() && props.compact && props.tone === "transfer",
|
|
102
|
+
selected(),
|
|
119
103
|
"border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[var(--ks-border-strong,#3f3f46)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
|
|
120
104
|
!selected(),
|
|
121
105
|
}}
|
|
@@ -130,7 +114,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
130
114
|
}
|
|
131
115
|
/>
|
|
132
116
|
<span class="truncate">{a.name}</span>
|
|
133
|
-
</
|
|
117
|
+
</button>
|
|
134
118
|
);
|
|
135
119
|
}}
|
|
136
120
|
</For>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import MultiPeriodPicker from "./MultiPeriodPicker";
|
|
5
|
+
|
|
6
|
+
const NOW = new Date("2026-09-10T12:00:00Z");
|
|
7
|
+
|
|
8
|
+
describe("MultiPeriodPicker", () => {
|
|
9
|
+
it("supports disjoint tokens and removes them independently", async () => {
|
|
10
|
+
const onSelectedChange = vi.fn();
|
|
11
|
+
const view = render(() => <MultiPeriodPicker granularity="month" selected={["2026-01", "2026-03"]} onGranularityChange={() => {}} onSelectedChange={onSelectedChange} now={NOW} />);
|
|
12
|
+
await fireEvent.click(view.getByRole("button", { name: "2 months" }));
|
|
13
|
+
expect(screen.getByRole("button", { name: "Remove January 2026" })).toBeTruthy();
|
|
14
|
+
await fireEvent.click(screen.getByRole("button", { name: "Remove January 2026" }));
|
|
15
|
+
expect(onSelectedChange).toHaveBeenCalledWith(["2026-03"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("changes granularity and emits selected token", async () => {
|
|
19
|
+
const onGranularityChange = vi.fn();
|
|
20
|
+
const onSelectedChange = vi.fn();
|
|
21
|
+
const [granularity, setGranularity] = createSignal<"day" | "week" | "month" | "year">("month");
|
|
22
|
+
const view = render(() => <MultiPeriodPicker granularity={granularity()} selected={[]} onGranularityChange={(value) => { onGranularityChange(value); setGranularity(value); }} onSelectedChange={onSelectedChange} now={NOW} />);
|
|
23
|
+
await fireEvent.click(view.getByRole("tab", { name: "Year" }));
|
|
24
|
+
expect(onGranularityChange).toHaveBeenCalledWith("year");
|
|
25
|
+
await fireEvent.click(view.getByRole("button", { name: "Pick periods" }));
|
|
26
|
+
await fireEvent.click(screen.getByRole("button", { name: "2026" }));
|
|
27
|
+
expect(onSelectedChange).toHaveBeenCalledWith(["2026"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("uses injected labels and token renderer", async () => {
|
|
31
|
+
const view = render(() => <MultiPeriodPicker granularity="year" selected={[]} onGranularityChange={() => {}} onSelectedChange={() => {}} now={NOW} labels={{ year: "Fiscal year", pick: "Choose periods" }} renderToken={(token) => <strong data-testid={`token-${token.token}`}>FY {token.label}</strong>} />);
|
|
32
|
+
expect(view.getByRole("tab", { name: "Fiscal year" })).toBeTruthy();
|
|
33
|
+
await fireEvent.click(view.getByRole("button", { name: "Choose periods" }));
|
|
34
|
+
expect(screen.getByTestId("token-2026").textContent).toBe("FY 2026");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createMemo, createSignal, For, Show, type JSX } from "solid-js";
|
|
2
|
+
import { Portal } from "solid-js/web";
|
|
3
|
+
import CalendarDays from "lucide-solid/icons/calendar-days";
|
|
4
|
+
import ChevronDown from "lucide-solid/icons/chevron-down";
|
|
5
|
+
import ChevronLeft from "lucide-solid/icons/chevron-left";
|
|
6
|
+
import ChevronRight from "lucide-solid/icons/chevron-right";
|
|
7
|
+
import X from "lucide-solid/icons/x";
|
|
8
|
+
import { injectCSS } from "../../utils/inject-css";
|
|
9
|
+
|
|
10
|
+
export type MultiPeriodGranularity = "day" | "week" | "month" | "year";
|
|
11
|
+
|
|
12
|
+
export interface MultiPeriodToken {
|
|
13
|
+
token: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MultiPeriodTokenRenderProps extends MultiPeriodToken {
|
|
18
|
+
selected: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MultiPeriodPickerProps {
|
|
22
|
+
granularity: MultiPeriodGranularity;
|
|
23
|
+
onGranularityChange: (granularity: MultiPeriodGranularity) => void;
|
|
24
|
+
selected: string[];
|
|
25
|
+
onSelectedChange: (tokens: string[]) => void;
|
|
26
|
+
/** Optional labels for tabs, trigger summary units, and picker headings. */
|
|
27
|
+
labels?: Partial<{
|
|
28
|
+
day: string;
|
|
29
|
+
week: string;
|
|
30
|
+
month: string;
|
|
31
|
+
year: string;
|
|
32
|
+
selected: string;
|
|
33
|
+
period: string;
|
|
34
|
+
pick: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Replaces token button contents without coupling the picker to a domain. */
|
|
37
|
+
renderToken?: (token: MultiPeriodTokenRenderProps) => JSX.Element;
|
|
38
|
+
/** Controls the civil date used for the initial view and year list. */
|
|
39
|
+
now?: Date;
|
|
40
|
+
/** Optional IANA zone used only to derive the current civil date. */
|
|
41
|
+
timeZone?: string;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
class?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STYLE_ID = "ksui-multi-period-picker-style";
|
|
47
|
+
const CSS = `
|
|
48
|
+
.ksui-mpp{position:relative;display:flex;flex-direction:column;gap:.5rem}
|
|
49
|
+
.ksui-mpp-tabs{display:flex;flex-wrap:wrap;gap:.25rem}
|
|
50
|
+
.ksui-mpp-tab,.ksui-mpp-trigger,.ksui-mpp-nav,.ksui-mpp-token,.ksui-mpp-chip{border:1px solid var(--ksui-mpp-border,var(--ks-border,rgba(39,39,42,0.5)));background:transparent;color:var(--ksui-mpp-muted,var(--ks-fg-muted,#a1a1aa));cursor:pointer;transition:background-color .15s ease,color .15s ease,border-color .15s ease}
|
|
51
|
+
.ksui-mpp-tab{border-radius:.375rem;padding:.4rem .65rem;font-size:.75rem}
|
|
52
|
+
.ksui-mpp-tab:hover,.ksui-mpp-nav:hover,.ksui-mpp-token:hover,.ksui-mpp-chip:hover{background:var(--ksui-mpp-hover,var(--ks-surface-raised,#1a1a1a));color:var(--ksui-mpp-fg,var(--ks-fg,#ffffff))}
|
|
53
|
+
.ksui-mpp-selected{border-color:var(--ksui-mpp-accent,var(--ks-accent,#fbbf24));background:color-mix(in srgb, var(--ks-primary, #c9a961) 18%, transparent);color:var(--ksui-mpp-accent,var(--ks-accent,#fbbf24));font-weight:600}
|
|
54
|
+
.ksui-mpp-trigger{display:inline-flex;align-items:center;gap:.5rem;border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;text-align:left}
|
|
55
|
+
.ksui-mpp-trigger:disabled{cursor:not-allowed;opacity:.5}
|
|
56
|
+
.ksui-mpp-popover{position:fixed;z-index:100;overflow-y:auto;border:1px solid var(--ksui-mpp-border,var(--ks-border,rgba(39,39,42,0.5)));border-radius:.75rem;background:var(--ksui-mpp-bg,var(--ks-input-bg,#18181b));box-shadow:var(--ks-shadow-lg,0 12px 32px rgba(0,0,0,0.6));padding:.75rem}
|
|
57
|
+
.ksui-mpp-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem;color:var(--ksui-mpp-fg,var(--ks-fg,#ffffff));font-size:.75rem;font-weight:600}
|
|
58
|
+
.ksui-mpp-nav{display:inline-flex;border:0;border-radius:.25rem;padding:.25rem}
|
|
59
|
+
.ksui-mpp-grid{display:grid;gap:.375rem}
|
|
60
|
+
.ksui-mpp-grid-days{grid-template-columns:repeat(7,minmax(0,1fr))}
|
|
61
|
+
.ksui-mpp-grid-months{grid-template-columns:repeat(4,minmax(0,1fr))}
|
|
62
|
+
.ksui-mpp-grid-years{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
63
|
+
.ksui-mpp-token{border-radius:.375rem;padding:.4rem .45rem;font-size:.7rem;text-align:center}
|
|
64
|
+
.ksui-mpp-chip{display:inline-flex;align-items:center;gap:.25rem;border-radius:.375rem;padding:.25rem .4rem;font-size:.68rem}
|
|
65
|
+
.ksui-mpp-chips{display:flex;flex-wrap:wrap;gap:.25rem;border-top:1px solid var(--ksui-mpp-border,var(--ks-border,rgba(39,39,42,0.5)));padding-top:.6rem;margin-top:.7rem}
|
|
66
|
+
.ksui-mpp-week-label{margin:.6rem 0 .35rem;color:var(--ksui-mpp-muted,var(--ks-fg-subtle,#71717a));font-size:.65rem;text-transform:uppercase;letter-spacing:.12em}
|
|
67
|
+
.ksui-mpp-months{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:.25rem;margin-bottom:.6rem}
|
|
68
|
+
.ksui-mpp-month{border:1px solid var(--ksui-mpp-border,var(--ks-border,rgba(39,39,42,0.5)));border-radius:.3rem;background:transparent;color:var(--ksui-mpp-muted,var(--ks-fg-muted,#a1a1aa));cursor:pointer;padding:.3rem .1rem;font-size:.68rem}
|
|
69
|
+
.ksui-mpp-month:hover{color:var(--ksui-mpp-fg,var(--ks-fg,#ffffff))}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
function ensureStyle() { injectCSS(STYLE_ID, CSS); }
|
|
73
|
+
|
|
74
|
+
function dateParts(now: Date, timeZone?: string) {
|
|
75
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
...(timeZone ? { timeZone } : {}),
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
}).formatToParts(now);
|
|
81
|
+
return {
|
|
82
|
+
year: Number(parts.find((part) => part.type === "year")?.value),
|
|
83
|
+
month: Number(parts.find((part) => part.type === "month")?.value),
|
|
84
|
+
day: Number(parts.find((part) => part.type === "day")?.value),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function dayToken(year: number, month: number, day: number) {
|
|
89
|
+
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function weekStart(year: number, month: number, day: number) {
|
|
93
|
+
const value = new Date(Date.UTC(year, month - 1, day));
|
|
94
|
+
value.setUTCDate(value.getUTCDate() - value.getUTCDay());
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function weekToken(year: number, month: number, day: number) {
|
|
99
|
+
const start = weekStart(year, month, day);
|
|
100
|
+
const anchorYear = start.getUTCFullYear();
|
|
101
|
+
const first = weekStart(anchorYear, 1, 1);
|
|
102
|
+
const number = Math.floor((start.getTime() - first.getTime()) / 604800000) + 1;
|
|
103
|
+
return `${anchorYear}-W${String(number).padStart(2, "0")}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatDay(token: string) {
|
|
107
|
+
const [year, month, day] = token.split("-").map(Number);
|
|
108
|
+
return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString("en-US", {
|
|
109
|
+
timeZone: "UTC", month: "short", day: "numeric", year: "numeric",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatMonth(token: string) {
|
|
114
|
+
const [year, month] = token.split("-").map(Number);
|
|
115
|
+
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString("en-US", {
|
|
116
|
+
timeZone: "UTC", month: "long", year: "numeric",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatWeek(token: string) {
|
|
121
|
+
const match = /^(\d{4})-W(\d{2})$/.exec(token);
|
|
122
|
+
if (!match) return token;
|
|
123
|
+
const start = weekStart(Number(match[1]), 1, 1);
|
|
124
|
+
start.setUTCDate(start.getUTCDate() + (Number(match[2]) - 1) * 7);
|
|
125
|
+
const end = new Date(start);
|
|
126
|
+
end.setUTCDate(end.getUTCDate() + 6);
|
|
127
|
+
const format = (date: Date) => date.toLocaleDateString("en-US", { timeZone: "UTC", month: "short", day: "numeric" });
|
|
128
|
+
return `${format(start)} – ${format(end)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatToken(granularity: MultiPeriodGranularity, token: string) {
|
|
132
|
+
if (granularity === "day") return formatDay(token);
|
|
133
|
+
if (granularity === "week") return formatWeek(token);
|
|
134
|
+
if (granularity === "month") return formatMonth(token);
|
|
135
|
+
return token;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function visibleWeeks(year: number, month: number): MultiPeriodToken[] {
|
|
139
|
+
const first = new Date(Date.UTC(year, month - 1, 1));
|
|
140
|
+
const cursor = weekStart(year, month, 1);
|
|
141
|
+
const result: MultiPeriodToken[] = [];
|
|
142
|
+
for (let index = 0; index < 6; index += 1) {
|
|
143
|
+
const start = new Date(cursor);
|
|
144
|
+
start.setUTCDate(start.getUTCDate() + index * 7);
|
|
145
|
+
if (start.getUTCFullYear() > year || (start.getUTCFullYear() === year && start.getUTCMonth() + 1 > month)) break;
|
|
146
|
+
if (start.getUTCFullYear() !== year || start.getUTCMonth() + 1 !== month) continue;
|
|
147
|
+
const token = weekToken(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
|
|
148
|
+
result.push({ token, label: formatWeek(token) });
|
|
149
|
+
}
|
|
150
|
+
void first;
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function defaultLabel(granularity: MultiPeriodGranularity, labels: MultiPeriodPickerProps["labels"]) {
|
|
155
|
+
return labels?.[granularity] ?? granularity[0].toUpperCase() + granularity.slice(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default function MultiPeriodPicker(props: MultiPeriodPickerProps): JSX.Element {
|
|
159
|
+
ensureStyle();
|
|
160
|
+
const now = props.now ?? new Date();
|
|
161
|
+
const current = dateParts(now, props.timeZone);
|
|
162
|
+
const [open, setOpen] = createSignal(false);
|
|
163
|
+
const [viewYear, setViewYear] = createSignal(current.year);
|
|
164
|
+
const [viewMonth, setViewMonth] = createSignal(current.month);
|
|
165
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
166
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
167
|
+
let popupRef: HTMLDivElement | undefined;
|
|
168
|
+
|
|
169
|
+
const labels = () => props.labels ?? {};
|
|
170
|
+
const tabOptions: MultiPeriodGranularity[] = ["day", "week", "month", "year"];
|
|
171
|
+
const summary = createMemo(() => {
|
|
172
|
+
if (props.selected.length === 0) return labels().pick ?? "Pick periods";
|
|
173
|
+
if (props.selected.length === 1) return formatToken(props.granularity, props.selected[0]);
|
|
174
|
+
const unit = labels()[props.granularity] ?? props.granularity;
|
|
175
|
+
return `${props.selected.length} ${unit}${props.selected.length === 1 ? "" : "s"}`;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const toggle = (token: string) => {
|
|
179
|
+
props.onSelectedChange(props.selected.includes(token)
|
|
180
|
+
? props.selected.filter((value) => value !== token)
|
|
181
|
+
: [...props.selected, token]);
|
|
182
|
+
};
|
|
183
|
+
const tokenContent = (item: MultiPeriodToken) => props.renderToken
|
|
184
|
+
? props.renderToken({ ...item, selected: props.selected.includes(item.token) })
|
|
185
|
+
: item.label;
|
|
186
|
+
const shiftMonth = (delta: number) => {
|
|
187
|
+
const next = new Date(Date.UTC(viewYear(), viewMonth() - 1 + delta, 1));
|
|
188
|
+
setViewYear(next.getUTCFullYear());
|
|
189
|
+
setViewMonth(next.getUTCMonth() + 1);
|
|
190
|
+
};
|
|
191
|
+
const updatePosition = () => {
|
|
192
|
+
if (!triggerRef) return;
|
|
193
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
194
|
+
setPopupStyle({ top: `${Math.min(rect.bottom + 4, window.innerHeight - 500)}px`, left: `${Math.min(rect.left, window.innerWidth - 336)}px`, width: "320px", "max-height": "480px" });
|
|
195
|
+
};
|
|
196
|
+
const openPicker = () => { setOpen((value) => !value); queueMicrotask(updatePosition); };
|
|
197
|
+
|
|
198
|
+
const days = createMemo<MultiPeriodToken[]>(() => {
|
|
199
|
+
const year = viewYear();
|
|
200
|
+
const month = viewMonth();
|
|
201
|
+
const firstDow = new Date(Date.UTC(year, month - 1, 1)).getUTCDay();
|
|
202
|
+
const count = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
203
|
+
const result: MultiPeriodToken[] = [];
|
|
204
|
+
for (let offset = firstDow - 1; offset >= 0; offset -= 1) {
|
|
205
|
+
const date = new Date(Date.UTC(year, month - 1, -offset));
|
|
206
|
+
const token = dayToken(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
|
|
207
|
+
result.push({ token, label: String(date.getUTCDate()) });
|
|
208
|
+
}
|
|
209
|
+
for (let day = 1; day <= count; day += 1) result.push({ token: dayToken(year, month, day), label: String(day) });
|
|
210
|
+
return result;
|
|
211
|
+
});
|
|
212
|
+
const monthOptions = createMemo(() => Array.from({ length: 12 }, (_, index) => ({ token: `${viewYear()}-${String(index + 1).padStart(2, "0")}`, label: new Date(Date.UTC(viewYear(), index, 1)).toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }) })));
|
|
213
|
+
const yearOptions = createMemo(() => Array.from({ length: 10 }, (_, index) => { const year = current.year - index; return { token: String(year), label: String(year) }; }));
|
|
214
|
+
const weekOptions = createMemo(() => visibleWeeks(viewYear(), viewMonth()));
|
|
215
|
+
const selectedLabel = (token: string) => formatToken(props.granularity, token);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div class={`ksui-mpp ${props.class ?? ""}`} data-testid="multi-period-picker">
|
|
219
|
+
<div class="ksui-mpp-tabs" role="tablist" aria-label={labels().period ?? "Period"}>
|
|
220
|
+
<For each={tabOptions}>{(granularity) => (
|
|
221
|
+
<button type="button" role="tab" aria-selected={props.granularity === granularity} classList={{ "ksui-mpp-selected": props.granularity === granularity }} class="ksui-mpp-tab" onClick={() => { props.onGranularityChange(granularity); setViewYear(current.year); setViewMonth(current.month); }}>
|
|
222
|
+
{defaultLabel(granularity, labels())}
|
|
223
|
+
</button>
|
|
224
|
+
)}</For>
|
|
225
|
+
<button ref={triggerRef} type="button" class="ksui-mpp-trigger" disabled={props.disabled} aria-haspopup="dialog" aria-expanded={open()} onClick={openPicker}>
|
|
226
|
+
<CalendarDays size={14} aria-hidden="true" /> <span>{summary()}</span><ChevronDown size={14} aria-hidden="true" />
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
<Show when={open()}>
|
|
230
|
+
<Portal>
|
|
231
|
+
<div ref={popupRef} class="ksui-mpp-popover" style={popupStyle()} role="dialog" aria-label={labels().period ?? "Pick periods"} tabindex={-1}>
|
|
232
|
+
<Show when={props.granularity === "day"}>
|
|
233
|
+
<div class="ksui-mpp-heading"><button class="ksui-mpp-nav" type="button" aria-label="Previous month" onClick={() => shiftMonth(-1)}><ChevronLeft size={14} /></button><span>{new Date(Date.UTC(viewYear(), viewMonth() - 1, 1)).toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC" })}</span><button class="ksui-mpp-nav" type="button" aria-label="Next month" onClick={() => shiftMonth(1)}><ChevronRight size={14} /></button></div>
|
|
234
|
+
<div class="ksui-mpp-grid ksui-mpp-grid-days"><For each={["S", "M", "T", "W", "T", "F", "S"]}>{(label) => <span class="ksui-mpp-token">{label}</span>}</For><For each={days()}>{(item) => <button type="button" classList={{ "ksui-mpp-selected": props.selected.includes(item.token) }} class="ksui-mpp-token" aria-pressed={props.selected.includes(item.token)} onClick={() => toggle(item.token)}>{tokenContent(item)}</button>}</For></div>
|
|
235
|
+
</Show>
|
|
236
|
+
<Show when={props.granularity === "week"}>
|
|
237
|
+
<div class="ksui-mpp-heading"><button class="ksui-mpp-nav" type="button" aria-label="Previous year" onClick={() => setViewYear((year) => year - 1)}><ChevronLeft size={14} /></button><span>{viewYear()}</span><button class="ksui-mpp-nav" type="button" aria-label="Next year" onClick={() => setViewYear((year) => year + 1)}><ChevronRight size={14} /></button></div>
|
|
238
|
+
<div class="ksui-mpp-months"><For each={Array.from({ length: 12 }, (_, index) => index + 1)}>{(month) => <button type="button" class="ksui-mpp-month" classList={{ "ksui-mpp-selected": month === viewMonth() }} onClick={() => setViewMonth(month)}>{new Date(Date.UTC(2000, month - 1, 1)).toLocaleDateString("en-US", { month: "short", timeZone: "UTC" })}</button>}</For></div>
|
|
239
|
+
<div class="ksui-mpp-week-label">Weeks</div><div class="ksui-mpp-grid"><For each={weekOptions()}>{(item) => <button type="button" classList={{ "ksui-mpp-selected": props.selected.includes(item.token) }} class="ksui-mpp-token" aria-pressed={props.selected.includes(item.token)} onClick={() => toggle(item.token)}>{tokenContent(item)}</button>}</For></div>
|
|
240
|
+
</Show>
|
|
241
|
+
<Show when={props.granularity === "month"}>
|
|
242
|
+
<div class="ksui-mpp-heading"><button class="ksui-mpp-nav" type="button" aria-label="Previous year" onClick={() => setViewYear((year) => year - 1)}><ChevronLeft size={14} /></button><span>{viewYear()}</span><button class="ksui-mpp-nav" type="button" aria-label="Next year" onClick={() => setViewYear((year) => year + 1)}><ChevronRight size={14} /></button></div>
|
|
243
|
+
<div class="ksui-mpp-grid ksui-mpp-grid-months"><For each={monthOptions()}>{(item) => <button type="button" classList={{ "ksui-mpp-selected": props.selected.includes(item.token) }} class="ksui-mpp-token" aria-pressed={props.selected.includes(item.token)} onClick={() => toggle(item.token)}>{tokenContent(item)}</button>}</For></div>
|
|
244
|
+
</Show>
|
|
245
|
+
<Show when={props.granularity === "year"}><div class="ksui-mpp-grid ksui-mpp-grid-years"><For each={yearOptions()}>{(item) => <button type="button" classList={{ "ksui-mpp-selected": props.selected.includes(item.token) }} class="ksui-mpp-token" aria-pressed={props.selected.includes(item.token)} onClick={() => toggle(item.token)}>{tokenContent(item)}</button>}</For></div></Show>
|
|
246
|
+
<Show when={props.selected.length > 0}><div class="ksui-mpp-chips"><For each={props.selected}>{(token) => <button type="button" class="ksui-mpp-chip ksui-mpp-selected" aria-label={`Remove ${selectedLabel(token)}`} onClick={() => toggle(token)}><span>{selectedLabel(token)}</span><X size={11} aria-hidden="true" /></button>}</For></div></Show>
|
|
247
|
+
</div>
|
|
248
|
+
</Portal>
|
|
249
|
+
</Show>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -23,7 +23,6 @@ export interface TransactionAccountFieldsProps {
|
|
|
23
23
|
transferFeeAmount: string;
|
|
24
24
|
transferFeeEnabled: boolean;
|
|
25
25
|
allowTransferFee: boolean;
|
|
26
|
-
compact?: boolean;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
/**
|
|
@@ -44,8 +43,6 @@ export default function TransactionAccountFields(props: TransactionAccountFields
|
|
|
44
43
|
value={
|
|
45
44
|
props.category === "sale" ? props.destAccount : props.sourceAccount
|
|
46
45
|
}
|
|
47
|
-
compact={props.compact}
|
|
48
|
-
tone={props.category === "sale" ? "income" : "expense"}
|
|
49
46
|
onChange={(v) => {
|
|
50
47
|
if (props.category === "sale") {
|
|
51
48
|
props.setDestAccount(v);
|
|
@@ -7,13 +7,7 @@
|
|
|
7
7
|
// advanced-fields toggle entirely, for a caller that locks `category` to one
|
|
8
8
|
// value and never needs EWT/sharing (e.g. a "record my own expense" surface).
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
createEffect,
|
|
12
|
-
createResource,
|
|
13
|
-
createSignal,
|
|
14
|
-
Show,
|
|
15
|
-
For,
|
|
16
|
-
} from "solid-js";
|
|
10
|
+
import { createEffect, createSignal, Show, For } from "solid-js";
|
|
17
11
|
import X from "lucide-solid/icons/x";
|
|
18
12
|
import Upload from "lucide-solid/icons/upload";
|
|
19
13
|
import FileIcon from "lucide-solid/icons/file";
|
|
@@ -306,6 +300,9 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
306
300
|
.ks-transaction-compact-type-switcher{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.375rem;margin:0 1.25rem .25rem;padding:.375rem;border-radius:.75rem;background:color-mix(in srgb,var(--ks-fg,#ffffff) 5%,transparent)}
|
|
307
301
|
.ks-transaction-compact-type-switcher>button{min-height:2.25rem;border:0;border-radius:.5rem;padding:.5rem .25rem}
|
|
308
302
|
.ks-transaction-compact-type-switcher>button.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-primary,#c9a961) 16%,transparent);color:var(--ks-primary,#c9a961)}
|
|
303
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-sale.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-success,#10b981) 16%,transparent);color:var(--ks-success-fg,#34d399)}
|
|
304
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-expense.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-danger,#ef4444) 16%,transparent);color:var(--ks-danger-fg,#f87171)}
|
|
305
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-business.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-info,#38bdf8) 16%,transparent);color:var(--ks-info,#38bdf8)}
|
|
309
306
|
.ks-transaction-compact-body{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;width:100%!important;min-height:0!important;overflow-x:hidden!important;overflow-y:auto!important;padding:1rem 1.25rem 1.5rem!important;gap:1rem}
|
|
310
307
|
.ks-transaction-compact-body>div{width:100%!important;flex:0 0 auto!important}
|
|
311
308
|
.ks-transaction-compact-footer{flex:0 0 auto!important}
|
|
@@ -360,23 +357,43 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
360
357
|
return "expense";
|
|
361
358
|
return null;
|
|
362
359
|
};
|
|
363
|
-
const [subcategoryOptions] =
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return data.subcategories;
|
|
360
|
+
const [subcategoryOptions, setSubcategoryOptions] = createSignal<
|
|
361
|
+
{ id: number; name: string }[] | undefined
|
|
362
|
+
>(undefined);
|
|
363
|
+
const subcategoryCache = new Map<
|
|
364
|
+
"income" | "expense",
|
|
365
|
+
{ id: number; name: string }[]
|
|
366
|
+
>();
|
|
367
|
+
let subcategoryRequestId = 0;
|
|
368
|
+
createEffect(() => {
|
|
369
|
+
const appliesTo = subcategoryAppliesTo();
|
|
370
|
+
const requestId = ++subcategoryRequestId;
|
|
371
|
+
if (!appliesTo) {
|
|
372
|
+
setSubcategoryOptions(undefined);
|
|
373
|
+
return;
|
|
378
374
|
}
|
|
379
|
-
|
|
375
|
+
const cached = subcategoryCache.get(appliesTo);
|
|
376
|
+
if (cached) {
|
|
377
|
+
setSubcategoryOptions(cached);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
setSubcategoryOptions(undefined);
|
|
381
|
+
void fetch(`/api/transactions/subcategories?applies_to=${appliesTo}`, {
|
|
382
|
+
credentials: "include",
|
|
383
|
+
})
|
|
384
|
+
.then(async (res) => {
|
|
385
|
+
if (!res.ok) return [] as { id: number; name: string }[];
|
|
386
|
+
const data = (await res.json()) as {
|
|
387
|
+
subcategories: { id: number; name: string }[];
|
|
388
|
+
};
|
|
389
|
+
return data.subcategories;
|
|
390
|
+
})
|
|
391
|
+
.catch(() => [] as { id: number; name: string }[])
|
|
392
|
+
.then((options) => {
|
|
393
|
+
subcategoryCache.set(appliesTo, options);
|
|
394
|
+
if (requestId === subcategoryRequestId) setSubcategoryOptions(options);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
380
397
|
|
|
381
398
|
// True once the async resource has resolved at least once. Gates the
|
|
382
399
|
// SearchableSelect mount so the loading-state placeholder shows while the
|
|
@@ -471,7 +488,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
471
488
|
</div>
|
|
472
489
|
</Show>
|
|
473
490
|
|
|
474
|
-
<Show when={
|
|
491
|
+
<Show when={!props.simpleMode}>
|
|
475
492
|
<div class="ks-transaction-compact-type-switcher" role="tablist" aria-label="Transaction type">
|
|
476
493
|
<For each={categoryOptions()}>
|
|
477
494
|
{(cat) => (
|
|
@@ -726,48 +743,36 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
726
743
|
|
|
727
744
|
<Show when={subcategoryAppliesTo() !== null}>
|
|
728
745
|
<FormField label="Category">
|
|
729
|
-
<
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
label: opt.name,
|
|
749
|
-
}));
|
|
750
|
-
list.unshift({ value: "", label: "— Uncategorised —" });
|
|
751
|
-
if (
|
|
752
|
-
props.subcategory &&
|
|
753
|
-
!list.some((o) => o.value === props.subcategory)
|
|
754
|
-
) {
|
|
755
|
-
list.push({
|
|
756
|
-
value: props.subcategory,
|
|
757
|
-
label: props.subcategory,
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
return list;
|
|
761
|
-
})()}
|
|
762
|
-
onChange={(opt) =>
|
|
763
|
-
props.setSubcategory(opt ? String(opt.value) : "")
|
|
746
|
+
<SearchableSelect
|
|
747
|
+
triggerTestId="subcategory-select"
|
|
748
|
+
wrapperClass="relative w-full"
|
|
749
|
+
value={props.subcategory}
|
|
750
|
+
loading={!subcategoryOptionsReady()}
|
|
751
|
+
options={(() => {
|
|
752
|
+
const list = (subcategoryOptions() || []).map((opt) => ({
|
|
753
|
+
value: opt.name,
|
|
754
|
+
label: opt.name,
|
|
755
|
+
}));
|
|
756
|
+
list.unshift({ value: "", label: "— Uncategorised —" });
|
|
757
|
+
if (
|
|
758
|
+
props.subcategory &&
|
|
759
|
+
!list.some((o) => o.value === props.subcategory)
|
|
760
|
+
) {
|
|
761
|
+
list.push({
|
|
762
|
+
value: props.subcategory,
|
|
763
|
+
label: props.subcategory,
|
|
764
|
+
});
|
|
764
765
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
766
|
+
return list;
|
|
767
|
+
})()}
|
|
768
|
+
onChange={(opt) =>
|
|
769
|
+
props.setSubcategory(opt ? String(opt.value) : "")
|
|
770
|
+
}
|
|
771
|
+
placeholder="— Uncategorised —"
|
|
772
|
+
searchPlaceholder="Search categories…"
|
|
773
|
+
triggerClass="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button cursor-pointer focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)] flex items-center justify-between gap-2"
|
|
774
|
+
triggerLabelClass="truncate text-left flex-1 min-w-0"
|
|
775
|
+
/>
|
|
771
776
|
<p class="text-[10px] text-[var(--ks-fg-subtle,#71717a)] mt-0.5">
|
|
772
777
|
Optional. Used for tax-prep classification.
|
|
773
778
|
</p>
|
|
@@ -803,7 +808,6 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
803
808
|
transferFeeAmount={props.transferFeeAmount}
|
|
804
809
|
transferFeeEnabled={props.transferFeeEnabled}
|
|
805
810
|
allowTransferFee={props.allowTransferFee}
|
|
806
|
-
compact={compact()}
|
|
807
811
|
/>
|
|
808
812
|
</div>
|
|
809
813
|
|
package/src/index.ts
CHANGED
|
@@ -87,6 +87,14 @@ export { default as Modal, type ModalProps, type ModalSize, type ModalTone } fro
|
|
|
87
87
|
// DataTable's date filter renders this picker.
|
|
88
88
|
export { default as DatePicker, type DatePickerProps, type DateRangeValue } from "./components/base/DatePicker";
|
|
89
89
|
|
|
90
|
+
export {
|
|
91
|
+
default as MultiPeriodPicker,
|
|
92
|
+
type MultiPeriodPickerProps,
|
|
93
|
+
type MultiPeriodGranularity,
|
|
94
|
+
type MultiPeriodToken,
|
|
95
|
+
type MultiPeriodTokenRenderProps,
|
|
96
|
+
} from "./components/composite/MultiPeriodPicker";
|
|
97
|
+
|
|
90
98
|
// Server-side / client-side data table with debounced search, column sort,
|
|
91
99
|
// pagination + "Show more" load mode, a filters slot, optional date filter, and
|
|
92
100
|
// an onRefetch handle. Ported from the host kit; injects its own CSS, no Tailwind.
|