@kahitsan/ksui 0.38.1 → 0.38.3

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.1",
3
+ "version": "0.38.3",
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",
@@ -673,14 +673,19 @@ export default function TransactionForm(props: TransactionFormProps) {
673
673
  }}
674
674
  />
675
675
  </FormField>
676
- <FormField label="Receipt / Ref #">
676
+ <FormField label={props.category === "sale" ? "Invoice ID" : "Receipt / Ref #"}>
677
677
  <input
678
678
  type="text"
679
679
  value={props.refNumber}
680
680
  onInput={(e) => props.setRefNumber(e.currentTarget.value)}
681
681
  class="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 focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)]"
682
- placeholder="OR#, SI#, or ref number"
682
+ placeholder={props.category === "sale" ? "Auto-assigned; enter to override" : "OR#, SI#, or ref number"}
683
683
  />
684
+ <Show when={props.category === "sale"}>
685
+ <p class="mt-1 text-[10px] text-[var(--ks-fg-subtle,#71717a)]">
686
+ Leave blank for automatic numbering. Backdated sales can use a custom ID.
687
+ </p>
688
+ </Show>
684
689
  </FormField>
685
690
  </div>
686
691
  </Show>
@@ -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,7 +177,7 @@ describe("VoucherPicker dialog", () => {
175
177
  expect(mark!.textContent?.toLowerCase()).toBe("partner");
176
178
  });
177
179
 
178
- it("shows the voucher description in the row and the staged summary", async () => {
180
+ it("shows the voucher description in the row and the footer draft", async () => {
179
181
  mockPagedFetch([
180
182
  voucher({ id: 50, code: "PARTNER_ACES", notes: "BISCAST ACES outreach discount" }),
181
183
  ]);
@@ -190,7 +192,8 @@ describe("VoucherPicker dialog", () => {
190
192
  );
191
193
 
192
194
  fireEvent.click(getByTestId("voucher-picker-result-50"));
193
- expect(getByTestId("voucher-picker-draft-summary").textContent).toContain(
195
+ // The footer shows the description under the summary, beside the buttons.
196
+ expect(getByTestId("voucher-picker-draft-description").textContent).toContain(
194
197
  "BISCAST ACES outreach discount",
195
198
  );
196
199
  });
@@ -185,6 +185,10 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
185
185
  const [total, setTotal] = createSignal(0);
186
186
  // Staged choice. `props.selected` only changes on Confirm.
187
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;
188
192
 
189
193
  // Signal, not a plain ref: the sentinel mounts only once the first page
190
194
  // reveals there are more, which is after the observer effect first runs.
@@ -370,7 +374,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
370
374
  });
371
375
 
372
376
  const metaLine = (v: VoucherOption): string =>
373
- [formatVoucherDescription(v), formatExpiry(v.valid_until, today()), formatUsage(v)]
377
+ [formatExpiry(v.valid_until, today()), formatUsage(v)]
374
378
  .filter(Boolean)
375
379
  .join(" · ");
376
380
 
@@ -381,6 +385,18 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
381
385
  return n ? n : null;
382
386
  });
383
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
+
384
400
  const expiresSoon = (v: VoucherOption): boolean => {
385
401
  if (!v.valid_until) return false;
386
402
  const days = daysUntil(toDay(v.valid_until), today());
@@ -514,7 +530,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
514
530
  selected(),
515
531
  }}
516
532
  >
517
- <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>
518
539
  <span class="flex-1 min-w-0">
519
540
  <span class="block text-sm font-medium text-[var(--ks-fg,#ffffff)] truncate">
520
541
  {highlightMatch(v.code, debouncedQuery())}
@@ -562,7 +583,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
562
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"
563
584
  aria-disabled="true"
564
585
  >
565
- <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>
566
592
  <span class="flex-1 min-w-0">
567
593
  <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">
568
594
  {highlightMatch(entry.voucher.code, debouncedQuery())}
@@ -605,20 +631,42 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
605
631
  {/* Bled to the card edges so the rule spans the full width, matching
606
632
  the header. */}
607
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">
608
- <span
609
- data-testid="voucher-picker-draft-summary"
610
- class="text-xs text-[var(--ks-fg-subtle,#71717a)] min-w-0 truncate"
611
- >
612
- <Show when={draft()} fallback="No voucher selected">
613
- <span class="text-[var(--ks-fg,#ffffff)]">{draft()!.code}</span>
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
+ >
614
646
  <Show when={draftNotes()}>
615
- {" · "}
616
- <span class="text-[var(--ks-fg-muted,#a1a1aa)]">{draftNotes()}</span>
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>
617
667
  </Show>
618
- {" · "}
619
- {discountLabel(draft()!).replace("−", "")} off
620
668
  </Show>
621
- </span>
669
+ </div>
622
670
  <div class="flex items-center gap-2 shrink-0">
623
671
  <Show when={props.selected && draft()}>
624
672
  <button
@@ -656,6 +704,43 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
656
704
  </div>
657
705
  </Modal>
658
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>
659
744
  </>
660
745
  );
661
746
  }