@kahitsan/ksui 0.38.0 → 0.38.2

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.38.0",
3
+ "version": "0.38.2",
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",
@@ -14,7 +14,7 @@ function mockPagedFetch(rows: VoucherOption[]) {
14
14
  const limit = Number(parsed.searchParams.get("limit") ?? "25");
15
15
  const search = (parsed.searchParams.get("search") ?? "").toLowerCase();
16
16
  const matched = search
17
- ? rows.filter((r) => r.code.toLowerCase().includes(search))
17
+ ? rows.filter((r) => `${r.code} ${r.notes ?? ""}`.toLowerCase().includes(search))
18
18
  : rows;
19
19
  const start = (page - 1) * limit;
20
20
  return {
@@ -112,10 +112,11 @@ describe("VoucherPicker dialog", () => {
112
112
  await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
113
113
  expect(getByTestId("voucher-picker-popup").closest("dialog")).not.toBeNull();
114
114
 
115
- // Staging a row must not reach the consumer yet.
115
+ // Staging a row must not reach the consumer yet; the footer no longer
116
+ // repeats the code (it's in the listing), so the confirm enabling is the signal.
116
117
  fireEvent.click(getByTestId("voucher-picker-result-1"));
117
118
  expect(onChange).not.toHaveBeenCalled();
118
- expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("SAVE20");
119
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(false);
119
120
 
120
121
  fireEvent.click(getByTestId("voucher-picker-confirm"));
121
122
  expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ id: 1, code: "SAVE20" }));
@@ -147,9 +148,10 @@ describe("VoucherPicker dialog", () => {
147
148
  await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
148
149
 
149
150
  fireEvent.click(getByTestId("voucher-picker-result-1"));
150
- expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("SAVE20");
151
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(false);
151
152
 
152
153
  fireEvent.click(getByTestId("voucher-picker-result-1"));
154
+ expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(true);
153
155
  expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("No voucher selected");
154
156
  });
155
157
 
@@ -175,6 +177,68 @@ describe("VoucherPicker dialog", () => {
175
177
  expect(mark!.textContent?.toLowerCase()).toBe("partner");
176
178
  });
177
179
 
180
+ it("shows the voucher description in the row and the footer draft", async () => {
181
+ mockPagedFetch([
182
+ voucher({ id: 50, code: "PARTNER_ACES", notes: "BISCAST ACES outreach discount" }),
183
+ ]);
184
+ const { getByTestId } = render(() => (
185
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
186
+ ));
187
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
188
+
189
+ await waitFor(() => expect(getByTestId("voucher-picker-result-50")).toBeTruthy());
190
+ expect(getByTestId("voucher-picker-result-50").textContent).toContain(
191
+ "BISCAST ACES outreach discount",
192
+ );
193
+
194
+ fireEvent.click(getByTestId("voucher-picker-result-50"));
195
+ // The footer shows the description under the summary, beside the buttons.
196
+ expect(getByTestId("voucher-picker-draft-description").textContent).toContain(
197
+ "BISCAST ACES outreach discount",
198
+ );
199
+ });
200
+
201
+ it("finds a voucher by its description and highlights the match in it", async () => {
202
+ const { calls } = mockPagedFetch([
203
+ voucher({ id: 51, code: "PARTNER_UAPGA", notes: "UAPGA Camarines Chapter discount" }),
204
+ voucher({ id: 52, code: "GENERIC" }),
205
+ ]);
206
+ const { getByTestId, queryByTestId } = render(() => (
207
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
208
+ ));
209
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
210
+ await waitFor(() => expect(getByTestId("voucher-picker-result-51")).toBeTruthy());
211
+
212
+ fireEvent.input(getByTestId("voucher-picker-search"), {
213
+ target: { value: "camarines" },
214
+ });
215
+
216
+ await waitFor(() => expect(calls.some((u) => u.includes("search=camarines"))).toBe(true));
217
+ await waitFor(() => expect(queryByTestId("voucher-picker-result-52")).toBeNull());
218
+
219
+ const row = getByTestId("voucher-picker-result-51");
220
+ const mark = row.querySelector("mark");
221
+ expect(mark).not.toBeNull();
222
+ expect(mark!.textContent?.toLowerCase()).toBe("camarines");
223
+ });
224
+
225
+ it("skips the description line for a blank description", async () => {
226
+ mockPagedFetch([
227
+ voucher({ id: 53, code: "PLAIN", notes: " " }),
228
+ voucher({ id: 54, code: "PLAIN" }),
229
+ ]);
230
+ const { getByTestId } = render(() => (
231
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
232
+ ));
233
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
234
+
235
+ await waitFor(() => expect(getByTestId("voucher-picker-result-53")).toBeTruthy());
236
+ // Identical rows — a whitespace-only description must render nothing extra.
237
+ expect(getByTestId("voucher-picker-result-53").textContent).toBe(
238
+ getByTestId("voucher-picker-result-54").textContent,
239
+ );
240
+ });
241
+
178
242
  it("shows when a voucher expires", async () => {
179
243
  const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
180
244
  mockPagedFetch([voucher({ id: 9, code: "ENDINGSOON", valid_until: soon })]);
@@ -31,6 +31,8 @@ export interface VoucherOption {
31
31
  usage_count?: number | null;
32
32
  /** Total redemptions allowed; null/absent means unlimited. */
33
33
  usage_limit_total?: number | null;
34
+ /** Free-text description; absent on endpoints that don't expose it. */
35
+ notes?: string | null;
34
36
  }
35
37
 
36
38
  const DEFAULT_FETCH_URL = "/api/vouchers?status=active&limit=200";
@@ -183,6 +185,10 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
183
185
  const [total, setTotal] = createSignal(0);
184
186
  // Staged choice. `props.selected` only changes on Confirm.
185
187
  const [draft, setDraft] = createSignal<VoucherOption | null>(null);
188
+ // Footer description popup: clamped to two lines, "More" only when it overflows.
189
+ const [descOpen, setDescOpen] = createSignal(false);
190
+ const [descOverflowing, setDescOverflowing] = createSignal(false);
191
+ let descEl: HTMLSpanElement | undefined;
186
192
 
187
193
  // Signal, not a plain ref: the sentinel mounts only once the first page
188
194
  // reveals there are more, which is after the observer effect first runs.
@@ -368,10 +374,29 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
368
374
  });
369
375
 
370
376
  const metaLine = (v: VoucherOption): string =>
371
- [formatVoucherDescription(v), formatExpiry(v.valid_until, today()), formatUsage(v)]
377
+ [formatExpiry(v.valid_until, today()), formatUsage(v)]
372
378
  .filter(Boolean)
373
379
  .join(" · ");
374
380
 
381
+ // Staged description, trimmed — the footer read it twice, and a signal call
382
+ // in JSX doesn't narrow across re-evaluations.
383
+ const draftNotes = createMemo(() => {
384
+ const n = draft()?.notes?.trim();
385
+ return n ? n : null;
386
+ });
387
+
388
+ // The footer clamps the description to two lines; offer "More" only when the
389
+ // text actually overflowed. Re-measured whenever the staged description changes.
390
+ createEffect(() => {
391
+ const notes = draftNotes();
392
+ const el = descEl;
393
+ if (!notes || !el) {
394
+ setDescOverflowing(false);
395
+ return;
396
+ }
397
+ setDescOverflowing(el.scrollHeight > el.clientHeight);
398
+ });
399
+
375
400
  const expiresSoon = (v: VoucherOption): boolean => {
376
401
  if (!v.valid_until) return false;
377
402
  const days = daysUntil(toDay(v.valid_until), today());
@@ -505,7 +530,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
505
530
  selected(),
506
531
  }}
507
532
  >
508
- <Ticket size={18} class="shrink-0 text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
533
+ <span class="flex flex-col items-center gap-1 shrink-0">
534
+ <Ticket size={18} class="text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
535
+ <span class="text-[11px] leading-tight text-center text-[var(--ks-success-fg,#34d399)]">
536
+ {formatVoucherDescription(v)}
537
+ </span>
538
+ </span>
509
539
  <span class="flex-1 min-w-0">
510
540
  <span class="block text-sm font-medium text-[var(--ks-fg,#ffffff)] truncate">
511
541
  {highlightMatch(v.code, debouncedQuery())}
@@ -522,6 +552,11 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
522
552
  {metaLine(v)}
523
553
  </span>
524
554
  </span>
555
+ <Show when={v.notes && v.notes.trim()}>
556
+ <span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
557
+ {highlightMatch(v.notes!, debouncedQuery())}
558
+ </span>
559
+ </Show>
525
560
  </span>
526
561
  <span class="text-sm text-[var(--ks-success-fg,#34d399)] shrink-0 font-mono">
527
562
  {discountLabel(v)}
@@ -548,7 +583,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
548
583
  class="w-full text-left px-3 py-3 mb-1 rounded-lg border border-transparent flex items-center gap-3 opacity-60 cursor-not-allowed"
549
584
  aria-disabled="true"
550
585
  >
551
- <Ticket size={18} class="shrink-0 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
586
+ <span class="flex flex-col items-center gap-1 shrink-0">
587
+ <Ticket size={18} class="text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
588
+ <span class="text-[11px] leading-tight text-center text-[var(--ks-fg-subtle,#71717a)]">
589
+ {formatVoucherDescription(entry.voucher)}
590
+ </span>
591
+ </span>
552
592
  <span class="flex-1 min-w-0">
553
593
  <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">
554
594
  {highlightMatch(entry.voucher.code, debouncedQuery())}
@@ -556,6 +596,11 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
556
596
  <span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
557
597
  {metaLine(entry.voucher)}
558
598
  </span>
599
+ <Show when={entry.voucher.notes && entry.voucher.notes.trim()}>
600
+ <span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
601
+ {highlightMatch(entry.voucher.notes!, debouncedQuery())}
602
+ </span>
603
+ </Show>
559
604
  </span>
560
605
  <span class="text-xs text-[var(--ks-warning-fg,#fbbf24)] shrink-0 text-right max-w-[45%] truncate">
561
606
  {entry.reason}
@@ -586,16 +631,42 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
586
631
  {/* Bled to the card edges so the rule spans the full width, matching
587
632
  the header. */}
588
633
  <div class="-mx-6 -mb-6 mt-4 px-5 sm:px-6 py-3 border-t border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] flex items-center justify-between gap-3 shrink-0">
589
- <span
590
- data-testid="voucher-picker-draft-summary"
591
- class="text-xs text-[var(--ks-fg-subtle,#71717a)] min-w-0 truncate"
592
- >
593
- <Show when={draft()} fallback="No voucher selected">
594
- <span class="text-[var(--ks-fg,#ffffff)]">{draft()!.code}</span>
595
- {" · "}
596
- {discountLabel(draft()!).replace("−", "")} off
634
+ <div class="min-w-0 flex flex-col gap-1">
635
+ <Show
636
+ when={draft()}
637
+ fallback={
638
+ <span
639
+ data-testid="voucher-picker-draft-summary"
640
+ class="text-xs text-[var(--ks-fg-subtle,#71717a)]"
641
+ >
642
+ No voucher selected
643
+ </span>
644
+ }
645
+ >
646
+ <Show when={draftNotes()}>
647
+ <span class="flex flex-col items-start gap-0.5 min-w-0">
648
+ <span
649
+ ref={descEl}
650
+ data-testid="voucher-picker-draft-description"
651
+ class="min-w-0 text-xs text-[var(--ks-fg-muted,#a1a1aa)] whitespace-pre-line line-clamp-2"
652
+ >
653
+ {draftNotes()}
654
+ </span>
655
+ <Show when={descOverflowing()}>
656
+ <button
657
+ type="button"
658
+ data-testid="voucher-picker-expand-description"
659
+ onClick={() => setDescOpen(true)}
660
+ class="text-xs leading-tight text-[var(--ks-primary,#c9a961)] hover:underline transition-colors cursor-pointer"
661
+ aria-label="Show full description"
662
+ >
663
+ See more
664
+ </button>
665
+ </Show>
666
+ </span>
667
+ </Show>
597
668
  </Show>
598
- </span>
669
+ </div>
599
670
  <div class="flex items-center gap-2 shrink-0">
600
671
  <Show when={props.selected && draft()}>
601
672
  <button
@@ -633,6 +704,43 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
633
704
  </div>
634
705
  </Modal>
635
706
  </Show>
707
+
708
+ {/* Full-description popup: "More" in the footer only appears when the
709
+ clamped text overflowed, but the popup itself is reachable whenever a
710
+ staged description exists. */}
711
+ <Show when={descOpen() && draftNotes()}>
712
+ <Modal onClose={() => setDescOpen(false)} size="md" ariaLabel="Voucher description">
713
+ <div data-testid="voucher-picker-description-popup" class="flex flex-col gap-4">
714
+ <div class="flex items-center justify-between gap-3">
715
+ <h2 class="m-0 text-base font-semibold text-[var(--ks-fg,#ffffff)]">
716
+ Voucher description
717
+ </h2>
718
+ <button
719
+ type="button"
720
+ data-testid="voucher-picker-close-description"
721
+ onClick={() => setDescOpen(false)}
722
+ class="w-8 h-8 flex items-center justify-center rounded text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] transition-colors cursor-pointer shrink-0"
723
+ aria-label="Close"
724
+ >
725
+ <X size={16} />
726
+ </button>
727
+ </div>
728
+ <p class="m-0 text-sm text-[var(--ks-fg,#ffffff)] whitespace-pre-line">
729
+ {draftNotes()}
730
+ </p>
731
+ <div class="flex justify-end">
732
+ <button
733
+ type="button"
734
+ data-testid="voucher-picker-description-close"
735
+ onClick={() => setDescOpen(false)}
736
+ class="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--ks-primary,#c9a961)] text-[var(--ks-fg-on-accent,#0a0a0a)] hover:opacity-90 transition-opacity cursor-pointer"
737
+ >
738
+ Close
739
+ </button>
740
+ </div>
741
+ </div>
742
+ </Modal>
743
+ </Show>
636
744
  </>
637
745
  );
638
746
  }