@eifi1/ui-kit 0.7.0 → 0.8.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.
Files changed (233) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +5 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +5 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +19 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +56 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +26 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +5 -0
  60. package/dist/components/number-input.d.ts +5 -0
  61. package/dist/components/numpad-sheet.d.ts +5 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/settings-fields.d.ts +2 -0
  78. package/dist/components/skeleton.d.ts +36 -0
  79. package/dist/components/skeleton.js +35 -0
  80. package/dist/components/skeleton.js.map +1 -0
  81. package/dist/components/stat-tile.d.ts +60 -6
  82. package/dist/components/stat-tile.js +60 -26
  83. package/dist/components/stat-tile.js.map +1 -1
  84. package/dist/components/table.d.ts +83 -0
  85. package/dist/components/table.js +166 -0
  86. package/dist/components/table.js.map +1 -0
  87. package/dist/components/time-input.d.ts +2 -0
  88. package/dist/components/toggle-legend.d.ts +3 -2
  89. package/dist/components/toggle-legend.js +1 -1
  90. package/dist/components/toggle-legend.js.map +1 -1
  91. package/dist/components/tooltip.d.ts +10 -4
  92. package/dist/components/tooltip.js +23 -6
  93. package/dist/components/tooltip.js.map +1 -1
  94. package/dist/components/tree-view.d.ts +129 -0
  95. package/dist/components/tree-view.js +376 -0
  96. package/dist/components/tree-view.js.map +1 -0
  97. package/dist/components/treemap.d.ts +9 -2
  98. package/dist/components/treemap.js +2 -1
  99. package/dist/components/treemap.js.map +1 -1
  100. package/dist/components/ui.d.ts +92 -14
  101. package/dist/components/ui.js +112 -30
  102. package/dist/components/ui.js.map +1 -1
  103. package/dist/components/use-table-state.d.ts +1 -1
  104. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  105. package/dist/data-table.d.ts +2 -2
  106. package/dist/data-table.js.map +1 -1
  107. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  108. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  109. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  110. package/dist/hooks/use-debounce.d.ts +43 -0
  111. package/dist/hooks/use-debounce.js +56 -0
  112. package/dist/hooks/use-debounce.js.map +1 -0
  113. package/dist/hooks/use-file-drop.d.ts +72 -0
  114. package/dist/hooks/use-file-drop.js +57 -0
  115. package/dist/hooks/use-file-drop.js.map +1 -0
  116. package/dist/i18n/defaults.d.ts +5 -0
  117. package/dist/i18n/defaults.js +7 -1
  118. package/dist/i18n/defaults.js.map +1 -1
  119. package/dist/i18n/kit-labels.d.ts +17 -0
  120. package/dist/i18n/kit-labels.js +6 -2
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  123. package/dist/i18n/locales/de-CH-informal.js +8 -0
  124. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  125. package/dist/i18n/locales/de-CH.d.ts +5 -0
  126. package/dist/i18n/locales/de-CH.js +1 -14
  127. package/dist/i18n/locales/de-CH.js.map +1 -1
  128. package/dist/i18n/locales/de-informal.d.ts +64 -0
  129. package/dist/i18n/locales/de-informal.js +37 -0
  130. package/dist/i18n/locales/de-informal.js.map +1 -0
  131. package/dist/i18n/locales/de.d.ts +5 -0
  132. package/dist/i18n/locales/de.js +25 -0
  133. package/dist/i18n/locales/de.js.map +1 -1
  134. package/dist/i18n/locales/es.d.ts +5 -0
  135. package/dist/i18n/locales/es.js +25 -0
  136. package/dist/i18n/locales/es.js.map +1 -1
  137. package/dist/i18n/locales/fr.d.ts +5 -0
  138. package/dist/i18n/locales/fr.js +25 -0
  139. package/dist/i18n/locales/fr.js.map +1 -1
  140. package/dist/i18n/locales/hu.d.ts +5 -0
  141. package/dist/i18n/locales/hu.js +25 -0
  142. package/dist/i18n/locales/hu.js.map +1 -1
  143. package/dist/i18n/locales/it.d.ts +5 -0
  144. package/dist/i18n/locales/it.js +25 -0
  145. package/dist/i18n/locales/it.js.map +1 -1
  146. package/dist/i18n/locales/zh.d.ts +5 -0
  147. package/dist/i18n/locales/zh.js +25 -0
  148. package/dist/i18n/locales/zh.js.map +1 -1
  149. package/dist/i18n/swiss.d.ts +13 -0
  150. package/dist/i18n/swiss.js +19 -0
  151. package/dist/i18n/swiss.js.map +1 -0
  152. package/dist/index.d.ts +26 -11
  153. package/dist/index.js +22 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/lib/dates.d.ts +37 -1
  156. package/dist/lib/dates.js +19 -0
  157. package/dist/lib/dates.js.map +1 -1
  158. package/dist/rhf/form.d.ts +2 -0
  159. package/dist/rhf.d.ts +2 -0
  160. package/dist/search/command-palette.d.ts +15 -1
  161. package/dist/search/command-palette.js +10 -2
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/shell/app-shell.js +2 -2
  164. package/dist/shell/app-shell.js.map +1 -1
  165. package/dist/wizard/stepper-nav.d.ts +35 -2
  166. package/dist/wizard/stepper-nav.js +54 -22
  167. package/dist/wizard/stepper-nav.js.map +1 -1
  168. package/dist/wizard/types.d.ts +107 -5
  169. package/dist/wizard/types.js +2 -1
  170. package/dist/wizard/types.js.map +1 -1
  171. package/dist/wizard/use-wizard.js +141 -21
  172. package/dist/wizard/use-wizard.js.map +1 -1
  173. package/dist/wizard/wizard-summary.d.ts +10 -2
  174. package/dist/wizard/wizard-summary.js +3 -1
  175. package/dist/wizard/wizard-summary.js.map +1 -1
  176. package/dist/wizard.d.ts +4 -1
  177. package/dist/wizard.js.map +1 -1
  178. package/package.json +1 -1
  179. package/src/components/alert-banner.tsx +211 -20
  180. package/src/components/button-group.tsx +75 -0
  181. package/src/components/chart-zoom.tsx +158 -20
  182. package/src/components/chip.tsx +160 -9
  183. package/src/components/confirm-dialog.tsx +242 -0
  184. package/src/components/copy-button.tsx +158 -0
  185. package/src/components/data-table-pagination.tsx +19 -6
  186. package/src/components/data-table-sort.ts +49 -10
  187. package/src/components/data-table.tsx +293 -24
  188. package/src/components/date-picker.tsx +262 -57
  189. package/src/components/description-list.tsx +174 -0
  190. package/src/components/disclosure.tsx +58 -5
  191. package/src/components/file-button.tsx +22 -2
  192. package/src/components/file-dropzone.tsx +198 -100
  193. package/src/components/floating-panel.tsx +341 -0
  194. package/src/components/full-bleed-dialog.tsx +48 -5
  195. package/src/components/modal.tsx +9 -2
  196. package/src/components/progress-bar.tsx +163 -0
  197. package/src/components/scroll-area.tsx +129 -0
  198. package/src/components/separator.tsx +37 -0
  199. package/src/components/series-chart-ticks.ts +135 -0
  200. package/src/components/series-chart.tsx +934 -67
  201. package/src/components/skeleton.tsx +65 -0
  202. package/src/components/stat-tile.tsx +160 -27
  203. package/src/components/table.tsx +263 -0
  204. package/src/components/toggle-legend.tsx +4 -3
  205. package/src/components/tooltip.tsx +40 -12
  206. package/src/components/tree-view.tsx +589 -0
  207. package/src/components/treemap.tsx +10 -2
  208. package/src/components/ui.tsx +268 -42
  209. package/src/data-table.ts +2 -0
  210. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  211. package/src/hooks/use-debounce.ts +105 -0
  212. package/src/hooks/use-file-drop.ts +123 -0
  213. package/src/i18n/defaults.ts +6 -0
  214. package/src/i18n/kit-labels.tsx +19 -0
  215. package/src/i18n/locales/de-CH-informal.ts +10 -0
  216. package/src/i18n/locales/de-CH.ts +1 -16
  217. package/src/i18n/locales/de-informal.ts +61 -0
  218. package/src/i18n/locales/de.ts +25 -0
  219. package/src/i18n/locales/es.ts +25 -0
  220. package/src/i18n/locales/fr.ts +25 -0
  221. package/src/i18n/locales/hu.ts +25 -0
  222. package/src/i18n/locales/it.ts +25 -0
  223. package/src/i18n/locales/zh.ts +25 -0
  224. package/src/i18n/swiss.ts +25 -0
  225. package/src/index.ts +29 -0
  226. package/src/lib/dates.ts +48 -0
  227. package/src/search/command-palette.tsx +29 -3
  228. package/src/shell/app-shell.tsx +2 -2
  229. package/src/wizard/stepper-nav.tsx +102 -28
  230. package/src/wizard/types.ts +106 -4
  231. package/src/wizard/use-wizard.ts +211 -31
  232. package/src/wizard/wizard-summary.tsx +24 -12
  233. package/src/wizard.ts +3 -2
@@ -12,7 +12,7 @@ import {
12
12
  } from "../i18n/kit-labels";
13
13
  import { splitTriggerAria } from "./trigger-aria";
14
14
  import type { TriggerAria } from "./trigger-aria";
15
- import { FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
15
+ import { Button, FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
16
16
  import { MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
17
17
  import { Popover } from "./popover";
18
18
  import { Tooltip } from "./tooltip";
@@ -553,23 +553,252 @@ export function DatePicker({
553
553
  }
554
554
 
555
555
  export interface DateRangePickerPreset {
556
+ /**
557
+ * The preset's identity, independent of its dates — `"last_3_months"`, not two ISO
558
+ * strings. Optional for the 0.x callers that pass anonymous pairs.
559
+ *
560
+ * keksdose's report range field (report-range-field.tsx, "Why this is app-level")
561
+ * is why it exists: its URL stores `?preset=last_3_months` and recomputes the dates
562
+ * from today on every read, so a preset that is only its dates froze "last 3 months"
563
+ * at whatever day the panel rendered, and two presets that happen to cover the same
564
+ * days (this week and today, on a Monday) could not be told apart. With an `id` the
565
+ * choice is reported to `onChange` and shown as selected for as long as the range
566
+ * still matches it. Recompute `from`/`to` on every render (`calendarMonthPresets()`,
567
+ * `dateRangePresets()`) and pass the key as the id.
568
+ */
569
+ id?: string;
556
570
  label: ReactNode;
557
571
  from: string;
558
572
  to: string;
559
573
  }
560
574
 
575
+ /**
576
+ * When the picker hands its range to `onChange`.
577
+ *
578
+ * - `"immediate"` (the default, and all of 0.7): a preset click commits and closes,
579
+ * and each calendar click is reported as it happens — the half-made `(from, "")`
580
+ * included. The right shape for quick ranges on a filter, where one click is one
581
+ * answer.
582
+ * - `"apply"`: the panel works on a DRAFT. A preset ARMS the calendar instead of
583
+ * committing, days can then be nudged ("last 3 months, but end it last Friday"),
584
+ * and nothing reaches `onChange` until Apply — which stays disabled until both ends
585
+ * exist. Cancel, Escape and an outside click discard the draft, and every open
586
+ * starts again from the committed value. keksdose's report range asked for exactly
587
+ * this: each commit there re-runs a whole page of charts, so an intermediate state
588
+ * must never escape the panel.
589
+ */
590
+ export type DateRangeCommit = "immediate" | "apply";
591
+
561
592
  export interface DateRangePickerProps extends DatePickerBaseProps {
562
593
  from: string;
563
594
  to: string;
564
- onChange: (from: string, to: string) => void;
595
+ /** The committed range. `presetId` is the chosen preset's `id` when a preset
596
+ * produced it (and the range was not nudged since), `undefined` for days picked by
597
+ * hand. A 0.x two-argument handler simply ignores it. */
598
+ onChange: (from: string, to: string, presetId?: string) => void;
565
599
  /** Separator between the two formatted dates in the trigger (default " – "). */
566
600
  separator?: string;
567
- /** Optional named ranges rendered as a left column (last month / YTD / …). */
601
+ /** Optional named ranges rendered as a start-side column (last month / YTD / …). */
568
602
  presets?: DateRangePickerPreset[];
603
+ /**
604
+ * The active preset's `id`, controlled — e.g. straight from keksdose's `?preset=`.
605
+ * `null` (or an id no preset has, such as `"custom"`) says "no preset": nothing in
606
+ * the column is marked even if a preset happens to cover the same days. A preset is
607
+ * marked only while its dates still equal `from`/`to`.
608
+ *
609
+ * Leave it `undefined` and the picker remembers the last preset it committed
610
+ * itself; with no memory at all it falls back to the first preset whose dates match.
611
+ */
612
+ preset?: string | null;
613
+ /** See {@link DateRangeCommit}. Default `"immediate"`. */
614
+ commit?: DateRangeCommit;
615
+ }
616
+
617
+ interface RangeDraft {
618
+ from: string;
619
+ to: string;
620
+ presetId: string | undefined;
621
+ }
622
+
623
+ /** Which preset (by index) the column marks, or -1. See `DateRangePickerProps.preset`. */
624
+ function markedPreset(
625
+ presets: readonly DateRangePickerPreset[],
626
+ from: string,
627
+ to: string,
628
+ activeId: string | null | undefined,
629
+ controlled: boolean,
630
+ ): number {
631
+ const matches = (p: DateRangePickerPreset) => p.from === from && p.to === to;
632
+ if (activeId != null) {
633
+ const byId = presets.findIndex((p) => p.id === activeId && matches(p));
634
+ if (byId !== -1 || controlled) return byId;
635
+ } else if (controlled) {
636
+ return -1;
637
+ }
638
+ return presets.findIndex(matches);
639
+ }
640
+
641
+ /**
642
+ * The preset column: a named group of toggle buttons, `aria-pressed` on the marked one.
643
+ * Pressed-state rather than colour alone, because "which range is this" is the one
644
+ * thing a screen-reader user opening the panel needs to hear.
645
+ */
646
+ function PresetColumn({
647
+ presets,
648
+ marked,
649
+ label,
650
+ onPick,
651
+ }: {
652
+ presets: readonly DateRangePickerPreset[];
653
+ marked: number;
654
+ label: string;
655
+ onPick: (p: DateRangePickerPreset) => void;
656
+ }) {
657
+ return (
658
+ // `border-e`/`pe`, not `-r`: the preset column is on the START side, and the rule
659
+ // between it and the calendar has to follow it in a right-to-left UI.
660
+ <div
661
+ role="group"
662
+ aria-label={label}
663
+ className="flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2"
664
+ >
665
+ {presets.map((p, i) => {
666
+ const selected = i === marked;
667
+ return (
668
+ <button
669
+ key={p.id ?? i}
670
+ type="button"
671
+ aria-pressed={selected}
672
+ data-preset={p.id}
673
+ onClick={() => onPick(p)}
674
+ className={cn(
675
+ "rounded px-2 py-1.5 text-start text-xs",
676
+ selected
677
+ ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]"
678
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
679
+ )}
680
+ >
681
+ {p.label}
682
+ </button>
683
+ );
684
+ })}
685
+ </div>
686
+ );
687
+ }
688
+
689
+ /**
690
+ * The panel's body. A component of its own so `commit="apply"` can keep its draft in
691
+ * state that is born when the panel opens: `Popover` unmounts its children on close,
692
+ * so the draft is re-seeded from the committed value on every open, and an abandoned
693
+ * half-selection cannot survive into the next one.
694
+ */
695
+ function RangePanel({
696
+ from,
697
+ to,
698
+ presets,
699
+ activeId,
700
+ controlled,
701
+ commit,
702
+ close,
703
+ commitRange,
704
+ calendarProps,
705
+ labels,
706
+ }: {
707
+ from: string;
708
+ to: string;
709
+ presets: readonly DateRangePickerPreset[] | undefined;
710
+ activeId: string | null | undefined;
711
+ controlled: boolean;
712
+ commit: DateRangeCommit;
713
+ close: () => void;
714
+ commitRange: (from: string, to: string, presetId: string | undefined) => void;
715
+ calendarProps: Pick<MiniCalendarProps, "locale" | "min" | "max" | "labels">;
716
+ labels: DatePickerLabels;
717
+ }) {
718
+ const drafting = commit === "apply";
719
+ const [draft, setDraft] = useState<RangeDraft>({
720
+ from,
721
+ to,
722
+ presetId: activeId ?? undefined,
723
+ });
724
+ // What the panel SHOWS: the draft while drafting, the committed value otherwise.
725
+ const shown = drafting ? draft : { from, to, presetId: activeId ?? undefined };
726
+ const marked = presets
727
+ ? markedPreset(presets, shown.from, shown.to, drafting ? draft.presetId : activeId, drafting || controlled)
728
+ : -1;
729
+ const complete = Boolean(draft.from && draft.to);
730
+
731
+ const calendar = (
732
+ <MiniCalendar
733
+ {...calendarProps}
734
+ focusOnOpen
735
+ from={shown.from}
736
+ to={shown.to}
737
+ onSelect={(f, t) => {
738
+ if (drafting) {
739
+ setDraft({ from: f, to: t, presetId: undefined });
740
+ return;
741
+ }
742
+ commitRange(f, t, undefined);
743
+ // Two-click range: only dismiss once both ends are chosen.
744
+ if (f && t) close();
745
+ }}
746
+ />
747
+ );
748
+
749
+ const body =
750
+ presets && presets.length > 0 ? (
751
+ <div className="flex gap-3">
752
+ <PresetColumn
753
+ presets={presets}
754
+ marked={marked}
755
+ label={labels.presets}
756
+ onPick={(p) => {
757
+ if (drafting) {
758
+ setDraft({ from: p.from, to: p.to, presetId: p.id });
759
+ return;
760
+ }
761
+ commitRange(p.from, p.to, p.id);
762
+ close();
763
+ }}
764
+ />
765
+ {/* `flex-1`: the calendar takes the rest of the panel. Content-sized it was
766
+ seven tiny cells beside a wide preset column. */}
767
+ <div className="min-w-0 flex-1">{calendar}</div>
768
+ </div>
769
+ ) : (
770
+ calendar
771
+ );
772
+
773
+ if (!drafting) return body;
774
+ return (
775
+ <div className="flex flex-col gap-2">
776
+ {body}
777
+ <div className="flex items-center justify-end gap-2 border-t border-[var(--border)] pt-2">
778
+ <Button type="button" variant="ghost" onClick={close}>
779
+ {labels.cancel}
780
+ </Button>
781
+ <Button
782
+ type="button"
783
+ // A half-made `(from, "")` would commit a window with no end — a blank
784
+ // report, one click into a two-click gesture.
785
+ disabled={!complete}
786
+ onClick={() => {
787
+ if (!complete) return;
788
+ commitRange(draft.from, draft.to, draft.presetId);
789
+ close();
790
+ }}
791
+ >
792
+ {labels.apply}
793
+ </Button>
794
+ </div>
795
+ </div>
796
+ );
569
797
  }
570
798
 
571
799
  /** Two-date range picker: click a start then an end; closes once both are set.
572
- * With `presets`, a left column of named ranges is shown beside the calendar. */
800
+ * With `presets`, a column of named ranges is shown beside the calendar; with
801
+ * `commit="apply"` the panel drafts and commits on Apply (see {@link DateRangeCommit}). */
573
802
  export function DateRangePicker({
574
803
  from,
575
804
  to,
@@ -582,6 +811,8 @@ export function DateRangePicker({
582
811
  formatValue,
583
812
  separator = " – ",
584
813
  presets,
814
+ preset,
815
+ commit = "immediate",
585
816
  calendarLabels,
586
817
  label,
587
818
  clearable,
@@ -593,6 +824,11 @@ export function DateRangePicker({
593
824
  const locale = useKitLocale(localeProp);
594
825
  const text = useDatePickerLabels(clearLabel, undefined, undefined);
595
826
  const render = formatValue ?? ((iso: string) => formatDate(iso, locale, formatOptions));
827
+ // The preset this picker committed itself, for a caller that does not control
828
+ // `preset`. Cleared by a hand-picked range or a clear.
829
+ const [ownPreset, setOwnPreset] = useState<string | undefined>(undefined);
830
+ const controlled = preset !== undefined;
831
+ const activeId = controlled ? preset : ownPreset;
596
832
  const a = from ? render(from) : "";
597
833
  const b = to ? render(to) : "";
598
834
  const triggerText = from
@@ -601,22 +837,13 @@ export function DateRangePicker({
601
837
  : `${a}${separator}…`
602
838
  : (placeholder ?? "");
603
839
  const hasPresets = Boolean(presets && presets.length > 0);
604
- const calendar = (close: () => void) => (
605
- <MiniCalendar
606
- focusOnOpen
607
- from={from}
608
- to={to}
609
- locale={locale}
610
- min={min}
611
- max={max}
612
- labels={calendarLabels}
613
- onSelect={(f, t) => {
614
- onChange(f, t);
615
- // Two-click range: only dismiss once both ends are chosen.
616
- if (f && t) close();
617
- }}
618
- />
619
- );
840
+ const commitRange = (f: string, t: string, presetId: string | undefined) => {
841
+ setOwnPreset(presetId);
842
+ // Two arguments when no preset is involved — exactly the 0.7 call, so a caller's
843
+ // `toHaveBeenCalledWith(from, to)` and a variadic handler see no difference.
844
+ if (presetId === undefined) onChange(f, t);
845
+ else onChange(f, t, presetId);
846
+ };
620
847
  return (
621
848
  <DateField
622
849
  {...rest}
@@ -630,44 +857,22 @@ export function DateRangePicker({
630
857
  width={hasPresets ? 440 : undefined}
631
858
  hasValue={Boolean(from || to)}
632
859
  triggerText={triggerText}
633
- onClear={() => onChange("", "")}
860
+ onClear={() => commitRange("", "", undefined)}
634
861
  >
635
- {(close) =>
636
- hasPresets ? (
637
- <div className="flex gap-3">
638
- {/* `border-e`/`pe`, not `-r`: the preset column is on the START side, and the
639
- rule between it and the calendar has to follow it in a right-to-left UI. */}
640
- <div className="flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2">
641
- {presets!.map((p, i) => {
642
- const selected = p.from === from && p.to === to;
643
- return (
644
- <button
645
- key={i}
646
- type="button"
647
- onClick={() => {
648
- onChange(p.from, p.to);
649
- close();
650
- }}
651
- className={cn(
652
- "rounded px-2 py-1.5 text-start text-xs",
653
- selected
654
- ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]"
655
- : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
656
- )}
657
- >
658
- {p.label}
659
- </button>
660
- );
661
- })}
662
- </div>
663
- {/* `flex-1`: the calendar takes the rest of the panel. Content-sized it was
664
- seven tiny cells beside a wide preset column. */}
665
- <div className="min-w-0 flex-1">{calendar(close)}</div>
666
- </div>
667
- ) : (
668
- calendar(close)
669
- )
670
- }
862
+ {(close) => (
863
+ <RangePanel
864
+ from={from}
865
+ to={to}
866
+ presets={presets}
867
+ activeId={activeId}
868
+ controlled={controlled}
869
+ commit={commit}
870
+ close={close}
871
+ commitRange={commitRange}
872
+ calendarProps={{ locale, min, max, labels: calendarLabels }}
873
+ labels={text}
874
+ />
875
+ )}
671
876
  </DateField>
672
877
  );
673
878
  }
@@ -0,0 +1,174 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { FieldHint } from "./ui";
5
+
6
+ export type DescriptionListLayout = "rows" | "cards";
7
+ export type DescriptionListDensity = "comfortable" | "compact";
8
+
9
+ interface ListContextValue {
10
+ layout: DescriptionListLayout;
11
+ numeric: boolean;
12
+ density: DescriptionListDensity;
13
+ }
14
+
15
+ const ListContext = createContext<ListContextValue>({ layout: "rows", numeric: false, density: "comfortable" });
16
+
17
+ export interface DescriptionListProps extends ComponentPropsWithoutRef<"dl"> {
18
+ /**
19
+ * `rows` (default): term beside detail, a two-column list that stacks the pair
20
+ * when the LIST is narrower than 24rem — a container query, so a detail panel
21
+ * beside a sidebar stacks where it is actually cramped, not where the viewport is.
22
+ * `cards`: a grid of small bordered cards, term above body — keksdose's overview
23
+ * figures, lenkbank's account facts.
24
+ */
25
+ layout?: DescriptionListLayout;
26
+ /** Every detail is a figure: tabular digits, end-aligned. A `numeric` list of
27
+ * rows does not stack on a phone — a figure is short, and putting it under its
28
+ * term would double the list's height to gain no width. Per item: see
29
+ * {@link DescriptionItemProps.numeric}. */
30
+ numeric?: boolean;
31
+ /** `compact` tightens the rhythm for a dense side panel. */
32
+ density?: DescriptionListDensity;
33
+ /** `cards` only: the narrowest a card may get before the grid drops a column. */
34
+ minCardWidth?: string;
35
+ }
36
+
37
+ /**
38
+ * A real `<dl>` — terms and their details, which is what keksdose's eight and
39
+ * lenkbank's four hand-built "label: value" blocks were, and none of them said so.
40
+ * As a `<dl>` a reader announces "list, 6 items" and pairs each term with its detail;
41
+ * as a stack of `<div>`s it read one undifferentiated run of text.
42
+ *
43
+ * Composed from {@link DescriptionItem} children rather than taking an `items`
44
+ * array. The call sites this replaces are mostly CONDITIONAL — "IBAN, if there is
45
+ * one", "closing date, once closed" — and `{iban && <DescriptionItem …>}` reads as
46
+ * what it means, where an array wants `.filter(Boolean)` and a cast. Children also
47
+ * give each item its own props (a numeric figure in an otherwise textual list, a
48
+ * `data-private` detail) with no parallel config object. A data-driven list loses
49
+ * nothing: `items.map((i) => <DescriptionItem key={i.term} {...i} />)`.
50
+ *
51
+ * Each pair is wrapped in a `<div>`, which HTML permits inside a `<dl>` for exactly
52
+ * this: styling a pair as a unit (a row, a card) without breaking the list.
53
+ */
54
+ export function DescriptionList({
55
+ layout = "rows",
56
+ numeric = false,
57
+ density = "comfortable",
58
+ minCardWidth = "10rem",
59
+ className,
60
+ style,
61
+ ...rest
62
+ }: DescriptionListProps) {
63
+ const cards = layout === "cards";
64
+ const grid: CSSProperties | undefined = cards
65
+ ? { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}), 1fr))` }
66
+ : undefined;
67
+ return (
68
+ <ListContext.Provider value={{ layout, numeric, density }}>
69
+ <dl
70
+ {...rest}
71
+ data-layout={layout}
72
+ style={grid ? { ...grid, ...style } : style}
73
+ className={cn(
74
+ cards
75
+ ? cn("grid", density === "compact" ? "gap-2" : "gap-3")
76
+ : "@container divide-y divide-[var(--border)]",
77
+ density === "compact" ? "text-xs" : "text-sm",
78
+ className,
79
+ )}
80
+ />
81
+ </ListContext.Provider>
82
+ );
83
+ }
84
+
85
+ export interface DescriptionItemProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
86
+ /** What is described: "Opened", "IBAN", "Net rent". */
87
+ term: ReactNode;
88
+ /** The description itself. */
89
+ children?: ReactNode;
90
+ /** The explanation behind a "?" beside the term (a {@link FieldHint}) — the same
91
+ * meaning `hint` has on `StatTile` and the labelled fields. */
92
+ hint?: string;
93
+ /** This detail is a figure (see {@link DescriptionListProps.numeric}); overrides
94
+ * the list's setting either way. */
95
+ numeric?: boolean;
96
+ /** Classes for the `<dd>`. */
97
+ detailClassName?: string;
98
+ }
99
+
100
+ const ROW_PAD: Record<DescriptionListDensity, string> = { comfortable: "py-2.5", compact: "py-1.5" };
101
+ const CARD_PAD: Record<DescriptionListDensity, string> = { comfortable: "px-3 py-2.5", compact: "px-2.5 py-1.5" };
102
+
103
+ /** One term and its detail inside a {@link DescriptionList}. */
104
+ export function DescriptionItem({
105
+ term,
106
+ children,
107
+ hint,
108
+ numeric: numericProp,
109
+ detailClassName,
110
+ className,
111
+ ...rest
112
+ }: DescriptionItemProps) {
113
+ const { layout, numeric: listNumeric, density } = useContext(ListContext);
114
+ const numeric = numericProp ?? listNumeric;
115
+ const cards = layout === "cards";
116
+
117
+ const termNode = (
118
+ <dt
119
+ className={cn(
120
+ "flex min-w-0 items-center gap-1 text-[var(--text-muted)]",
121
+ cards && "text-xs uppercase tracking-wide",
122
+ )}
123
+ >
124
+ <span className="min-w-0 break-words">{term}</span>
125
+ {hint && <FieldHint label={hint} side="top" className="shrink-0" />}
126
+ </dt>
127
+ );
128
+ const detail = (
129
+ <dd
130
+ className={cn(
131
+ "min-w-0 break-words text-[var(--text-primary)]",
132
+ numeric && "tabular-nums text-end",
133
+ cards && "mt-0.5 font-medium",
134
+ detailClassName,
135
+ )}
136
+ >
137
+ {children}
138
+ </dd>
139
+ );
140
+
141
+ if (cards) {
142
+ return (
143
+ <div
144
+ {...rest}
145
+ className={cn(
146
+ "min-w-0 rounded-md border border-[var(--border)] bg-[var(--bg-surface)]",
147
+ CARD_PAD[density],
148
+ className,
149
+ )}
150
+ >
151
+ {termNode}
152
+ {detail}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ return (
158
+ <div
159
+ {...rest}
160
+ className={cn(
161
+ "grid gap-x-4 gap-y-0.5",
162
+ numeric
163
+ ? // Never stacks: a figure is short (see DescriptionListProps.numeric).
164
+ "grid-cols-[minmax(0,1fr)_auto] items-baseline"
165
+ : "grid-cols-1 @sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @sm:items-baseline",
166
+ ROW_PAD[density],
167
+ className,
168
+ )}
169
+ >
170
+ {termNode}
171
+ {detail}
172
+ </div>
173
+ );
174
+ }
@@ -104,6 +104,17 @@ export function Collapse({ open, keepMounted = false, children, className, style
104
104
  );
105
105
  }
106
106
 
107
+ /**
108
+ * What {@link DisclosureProps.triggerProps} may put on the header button: its own
109
+ * attributes and handlers, and any `data-*`. Not the ones the disclosure owns —
110
+ * `aria-expanded`, `aria-controls`, `disabled`, `onClick`, `type` and the content — nor
111
+ * `className`, which is {@link DisclosureProps.headerClassName}.
112
+ */
113
+ export type DisclosureTriggerProps = Omit<
114
+ ComponentPropsWithoutRef<"button">,
115
+ "type" | "aria-expanded" | "aria-controls" | "disabled" | "onClick" | "children" | "className"
116
+ > & { [data: `data-${string}`]: string | number | boolean | undefined };
117
+
107
118
  /**
108
119
  * `title` is omitted from the div's own props because this component already owns the
109
120
  * name: here it is the header's content (and a ReactNode), not the browser's tooltip.
@@ -135,6 +146,16 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
135
146
  * its own surface.
136
147
  */
137
148
  variant?: "card" | "bare";
149
+ /**
150
+ * Where a `bare` disclosure draws its chevron. `start` (default) is the leading
151
+ * chevron that turns from the reading direction to down. `end` puts it at the far
152
+ * end of the row, pointing down and turning up as the card's does — keksdose's
153
+ * account menu, whose language sub-list reads "Language 🇩🇪 ⌄" with the chevron
154
+ * after the flag, like every other menu row with a sub-list. With `trailing`, the
155
+ * chevron follows it, as on a card. A `card` always has it at the end, so this is
156
+ * ignored there.
157
+ */
158
+ chevronPosition?: "start" | "end";
138
159
  /**
139
160
  * Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
140
161
  * puts the button INSIDE the heading when the disclosure titles a section, so the
@@ -147,6 +168,22 @@ export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "
147
168
  disabled?: boolean;
148
169
  /** Extra classes for the header button. */
149
170
  headerClassName?: string;
171
+ /**
172
+ * Attributes for the header BUTTON — the outer props go to the wrapping div — such as
173
+ * an `aria-label`, an `id` or a `data-*` test hook. A function of the open state
174
+ * when they depend on it: keksdose's budget table names each group's toggle
175
+ * "Expand group Food" / "Collapse group Food", because the visible title is only the
176
+ * group's name:
177
+ *
178
+ * ```tsx
179
+ * triggerProps={(open) => ({ "aria-label": t(open ? "collapseGroup" : "expandGroup", { name }) })}
180
+ * ```
181
+ *
182
+ * An `aria-label` REPLACES the title as the button's name, so it must still contain
183
+ * the title's words (WCAG 2.5.3, label in name). See {@link DisclosureTriggerProps}
184
+ * for what the disclosure keeps for itself.
185
+ */
186
+ triggerProps?: DisclosureTriggerProps | ((open: boolean) => DisclosureTriggerProps);
150
187
  /** Extra classes for the body's wrapper — where its padding and spacing live. */
151
188
  bodyClassName?: string;
152
189
  /**
@@ -193,10 +230,12 @@ export function Disclosure({
193
230
  defaultOpen = false,
194
231
  onOpenChange,
195
232
  variant = "card",
233
+ chevronPosition = "start",
196
234
  headingAs: Heading,
197
235
  keepMounted,
198
236
  disabled,
199
237
  headerClassName,
238
+ triggerProps,
200
239
  bodyClassName,
201
240
  trailing,
202
241
  controls,
@@ -208,6 +247,8 @@ export function Disclosure({
208
247
  const open = controlled ?? own;
209
248
  const bodyId = useId();
210
249
  const card = variant === "card";
250
+ // The card's chevron trails always; a bare one only when asked to.
251
+ const chevronAtEnd = card || chevronPosition === "end";
211
252
  const triggerOnly = controls !== undefined;
212
253
  // The card's header squares its lower corners only when a body opens under it.
213
254
  const joined = open && !triggerOnly;
@@ -219,8 +260,11 @@ export function Disclosure({
219
260
  onOpenChange?.(next);
220
261
  };
221
262
 
263
+ const extraTriggerProps = typeof triggerProps === "function" ? triggerProps(open) : triggerProps;
264
+
222
265
  const button = (
223
266
  <button
267
+ {...extraTriggerProps}
224
268
  type="button"
225
269
  aria-expanded={open}
226
270
  aria-controls={triggerOnly ? controls : bodyId}
@@ -249,7 +293,7 @@ export function Disclosure({
249
293
  headerClassName,
250
294
  )}
251
295
  >
252
- {!card && <Chevron open={open} leading />}
296
+ {!chevronAtEnd && <Chevron open={open} leading />}
253
297
  {/* `flex-1`: the title takes the row, so whatever a caller puts at its end sits
254
298
  at the header's far edge. */}
255
299
  <span className="min-w-0 flex-1">
@@ -258,7 +302,7 @@ export function Disclosure({
258
302
  <span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
259
303
  )}
260
304
  </span>
261
- {card && !hasTrailing && <Chevron open={open} />}
305
+ {chevronAtEnd && !hasTrailing && <Chevron open={open} small={!card} />}
262
306
  </button>
263
307
  );
264
308
 
@@ -286,7 +330,7 @@ export function Disclosure({
286
330
  {/* Positioned, so it paints over the stretched button and takes its own
287
331
  clicks; the chevron is not, so a click on it lands on the button. */}
288
332
  <div className="relative flex shrink-0 items-center gap-2">{trailing}</div>
289
- {card && <Chevron open={open} />}
333
+ {chevronAtEnd && <Chevron open={open} small={!card} />}
290
334
  </div>
291
335
  ) : (
292
336
  header
@@ -307,13 +351,22 @@ export function Disclosure({
307
351
  * header's convention. Rotation, not a mirrored glyph, so RTL needs one opposite angle
308
352
  * and no `scale` composing with the turn.
309
353
  */
310
- function Chevron({ open, leading = false }: { open: boolean; leading?: boolean }) {
354
+ function Chevron({
355
+ open,
356
+ leading = false,
357
+ small = leading,
358
+ }: {
359
+ open: boolean;
360
+ leading?: boolean;
361
+ /** The bare variant's size, which a bare chevron keeps at either end of its row. */
362
+ small?: boolean;
363
+ }) {
311
364
  return (
312
365
  <ChevronDown
313
366
  aria-hidden
314
367
  className={cn(
315
368
  "shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none",
316
- leading ? "size-3.5" : "size-4",
369
+ small ? "size-3.5" : "size-4",
317
370
  leading ? !open && "-rotate-90 rtl:rotate-90" : open && "rotate-180",
318
371
  )}
319
372
  />