@eifi1/ui-kit 0.4.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 (229) hide show
  1. package/README.md +219 -0
  2. package/dist/components/account-settings.d.ts +71 -0
  3. package/dist/components/account-settings.js +159 -0
  4. package/dist/components/account-settings.js.map +1 -0
  5. package/dist/components/alert-banner.d.ts +20 -0
  6. package/dist/components/alert-banner.js +39 -0
  7. package/dist/components/alert-banner.js.map +1 -0
  8. package/dist/components/amount-input.d.ts +117 -0
  9. package/dist/components/amount-input.js +243 -0
  10. package/dist/components/amount-input.js.map +1 -0
  11. package/dist/components/calculator.d.ts +42 -0
  12. package/dist/components/calculator.js +161 -0
  13. package/dist/components/calculator.js.map +1 -0
  14. package/dist/components/chart.d.ts +58 -0
  15. package/dist/components/chart.js +158 -0
  16. package/dist/components/chart.js.map +1 -0
  17. package/dist/components/combobox-core.d.ts +77 -0
  18. package/dist/components/combobox-core.js +263 -0
  19. package/dist/components/combobox-core.js.map +1 -0
  20. package/dist/components/combobox.d.ts +141 -0
  21. package/dist/components/combobox.js +550 -0
  22. package/dist/components/combobox.js.map +1 -0
  23. package/dist/components/currency-select.d.ts +37 -0
  24. package/dist/components/currency-select.js +131 -0
  25. package/dist/components/currency-select.js.map +1 -0
  26. package/dist/components/data-table-filter-popover.d.ts +28 -0
  27. package/dist/components/data-table-filter-popover.js +201 -0
  28. package/dist/components/data-table-filter-popover.js.map +1 -0
  29. package/dist/components/data-table-filters.d.ts +5 -0
  30. package/dist/components/data-table-filters.js +118 -0
  31. package/dist/components/data-table-filters.js.map +1 -0
  32. package/dist/components/data-table-labels.d.ts +33 -0
  33. package/dist/components/data-table-labels.js +53 -0
  34. package/dist/components/data-table-labels.js.map +1 -0
  35. package/dist/components/data-table-pagination.d.ts +18 -0
  36. package/dist/components/data-table-pagination.js +92 -0
  37. package/dist/components/data-table-pagination.js.map +1 -0
  38. package/dist/components/data-table-sort.d.ts +23 -0
  39. package/dist/components/data-table-sort.js +55 -0
  40. package/dist/components/data-table-sort.js.map +1 -0
  41. package/dist/components/data-table.d.ts +5 -0
  42. package/dist/components/data-table.js +926 -0
  43. package/dist/components/data-table.js.map +1 -0
  44. package/dist/components/date-picker.d.ts +85 -0
  45. package/dist/components/date-picker.js +253 -0
  46. package/dist/components/date-picker.js.map +1 -0
  47. package/dist/components/dropdown.d.ts +98 -0
  48. package/dist/components/dropdown.js +122 -0
  49. package/dist/components/dropdown.js.map +1 -0
  50. package/dist/components/entity-combobox.d.ts +49 -0
  51. package/dist/components/entity-combobox.js +115 -0
  52. package/dist/components/entity-combobox.js.map +1 -0
  53. package/dist/components/file-dropzone.d.ts +18 -0
  54. package/dist/components/file-dropzone.js +101 -0
  55. package/dist/components/file-dropzone.js.map +1 -0
  56. package/dist/components/full-bleed-dialog.d.ts +57 -0
  57. package/dist/components/full-bleed-dialog.js +68 -0
  58. package/dist/components/full-bleed-dialog.js.map +1 -0
  59. package/dist/components/grouped-picker.d.ts +37 -0
  60. package/dist/components/grouped-picker.js +87 -0
  61. package/dist/components/grouped-picker.js.map +1 -0
  62. package/dist/components/hover-menu.d.ts +16 -0
  63. package/dist/components/hover-menu.js +113 -0
  64. package/dist/components/hover-menu.js.map +1 -0
  65. package/dist/components/mini-calendar.d.ts +33 -0
  66. package/dist/components/mini-calendar.js +133 -0
  67. package/dist/components/mini-calendar.js.map +1 -0
  68. package/dist/components/modal.d.ts +76 -0
  69. package/dist/components/modal.js +156 -0
  70. package/dist/components/modal.js.map +1 -0
  71. package/dist/components/multi-entity-combobox.d.ts +47 -0
  72. package/dist/components/multi-entity-combobox.js +116 -0
  73. package/dist/components/multi-entity-combobox.js.map +1 -0
  74. package/dist/components/multi-select.d.ts +39 -0
  75. package/dist/components/multi-select.js +132 -0
  76. package/dist/components/multi-select.js.map +1 -0
  77. package/dist/components/number-input.d.ts +74 -0
  78. package/dist/components/number-input.js +114 -0
  79. package/dist/components/number-input.js.map +1 -0
  80. package/dist/components/numpad-sheet.d.ts +45 -0
  81. package/dist/components/numpad-sheet.js +102 -0
  82. package/dist/components/numpad-sheet.js.map +1 -0
  83. package/dist/components/picker-sheet.d.ts +44 -0
  84. package/dist/components/picker-sheet.js +78 -0
  85. package/dist/components/picker-sheet.js.map +1 -0
  86. package/dist/components/popover.d.ts +20 -0
  87. package/dist/components/popover.js +46 -0
  88. package/dist/components/popover.js.map +1 -0
  89. package/dist/components/search-field.d.ts +52 -0
  90. package/dist/components/search-field.js +64 -0
  91. package/dist/components/search-field.js.map +1 -0
  92. package/dist/components/settings-fields.d.ts +39 -0
  93. package/dist/components/settings-fields.js +17 -0
  94. package/dist/components/settings-fields.js.map +1 -0
  95. package/dist/components/swipeable-row.d.ts +60 -0
  96. package/dist/components/swipeable-row.js +115 -0
  97. package/dist/components/swipeable-row.js.map +1 -0
  98. package/dist/components/toggle-group.d.ts +34 -0
  99. package/dist/components/toggle-group.js +90 -0
  100. package/dist/components/toggle-group.js.map +1 -0
  101. package/dist/components/tooltip.d.ts +86 -0
  102. package/dist/components/tooltip.js +171 -0
  103. package/dist/components/tooltip.js.map +1 -0
  104. package/dist/components/ui.d.ts +239 -0
  105. package/dist/components/ui.js +512 -0
  106. package/dist/components/ui.js.map +1 -0
  107. package/dist/components/use-mobile-reveal.d.ts +31 -0
  108. package/dist/components/use-mobile-reveal.js +33 -0
  109. package/dist/components/use-mobile-reveal.js.map +1 -0
  110. package/dist/components/use-table-state.d.ts +54 -0
  111. package/dist/components/use-table-state.js +153 -0
  112. package/dist/components/use-table-state.js.map +1 -0
  113. package/dist/components/user-avatar.d.ts +23 -0
  114. package/dist/components/user-avatar.js +33 -0
  115. package/dist/components/user-avatar.js.map +1 -0
  116. package/dist/components/wizard-stepper.d.ts +14 -0
  117. package/dist/components/wizard-stepper.js +27 -0
  118. package/dist/components/wizard-stepper.js.map +1 -0
  119. package/dist/data-table-filters-aXyf0Xub.d.ts +261 -0
  120. package/dist/feedback/feedback-attachment.d.ts +63 -0
  121. package/dist/feedback/feedback-attachment.js +160 -0
  122. package/dist/feedback/feedback-attachment.js.map +1 -0
  123. package/dist/feedback/feedback-dialog.d.ts +72 -0
  124. package/dist/feedback/feedback-dialog.js +109 -0
  125. package/dist/feedback/feedback-dialog.js.map +1 -0
  126. package/dist/feedback/feedback-inbox.d.ts +226 -0
  127. package/dist/feedback/feedback-inbox.js +315 -0
  128. package/dist/feedback/feedback-inbox.js.map +1 -0
  129. package/dist/hooks/use-anchored-panel.d.ts +80 -0
  130. package/dist/hooks/use-anchored-panel.js +71 -0
  131. package/dist/hooks/use-anchored-panel.js.map +1 -0
  132. package/dist/hooks/use-anchored-rect.d.ts +24 -0
  133. package/dist/hooks/use-anchored-rect.js +40 -0
  134. package/dist/hooks/use-anchored-rect.js.map +1 -0
  135. package/dist/hooks/use-body-scroll-lock.d.ts +9 -0
  136. package/dist/hooks/use-body-scroll-lock.js +41 -0
  137. package/dist/hooks/use-body-scroll-lock.js.map +1 -0
  138. package/dist/hooks/use-close-transition.d.ts +41 -0
  139. package/dist/hooks/use-close-transition.js +36 -0
  140. package/dist/hooks/use-close-transition.js.map +1 -0
  141. package/dist/hooks/use-dismiss.d.ts +34 -0
  142. package/dist/hooks/use-dismiss.js +41 -0
  143. package/dist/hooks/use-dismiss.js.map +1 -0
  144. package/dist/hooks/use-media-query.d.ts +8 -0
  145. package/dist/hooks/use-media-query.js +18 -0
  146. package/dist/hooks/use-media-query.js.map +1 -0
  147. package/dist/hooks/use-overlay-history.d.ts +10 -0
  148. package/dist/hooks/use-overlay-history.js +99 -0
  149. package/dist/hooks/use-overlay-history.js.map +1 -0
  150. package/dist/hooks/use-row-swipe.d.ts +30 -0
  151. package/dist/hooks/use-row-swipe.js +128 -0
  152. package/dist/hooks/use-row-swipe.js.map +1 -0
  153. package/dist/index.d.ts +75 -0
  154. package/dist/index.js +90 -0
  155. package/dist/index.js.map +1 -0
  156. package/dist/lib/calc.d.ts +89 -0
  157. package/dist/lib/calc.js +180 -0
  158. package/dist/lib/calc.js.map +1 -0
  159. package/dist/lib/cn.d.ts +6 -0
  160. package/dist/lib/cn.js +7 -0
  161. package/dist/lib/cn.js.map +1 -0
  162. package/dist/lib/dates.d.ts +84 -0
  163. package/dist/lib/dates.js +108 -0
  164. package/dist/lib/dates.js.map +1 -0
  165. package/dist/lib/logger.d.ts +17 -0
  166. package/dist/lib/logger.js +30 -0
  167. package/dist/lib/logger.js.map +1 -0
  168. package/dist/lib/safe-storage.d.ts +23 -0
  169. package/dist/lib/safe-storage.js +20 -0
  170. package/dist/lib/safe-storage.js.map +1 -0
  171. package/dist/search/command-palette.d.ts +71 -0
  172. package/dist/search/command-palette.js +198 -0
  173. package/dist/search/command-palette.js.map +1 -0
  174. package/dist/shell/app-shell.d.ts +64 -0
  175. package/dist/shell/app-shell.js +237 -0
  176. package/dist/shell/app-shell.js.map +1 -0
  177. package/dist/shell/option-switcher-menu.d.ts +24 -0
  178. package/dist/shell/option-switcher-menu.js +44 -0
  179. package/dist/shell/option-switcher-menu.js.map +1 -0
  180. package/dist/shell/role-switcher.d.ts +19 -0
  181. package/dist/shell/role-switcher.js +28 -0
  182. package/dist/shell/role-switcher.js.map +1 -0
  183. package/dist/shell/top-bar.d.ts +15 -0
  184. package/dist/shell/top-bar.js +29 -0
  185. package/dist/shell/top-bar.js.map +1 -0
  186. package/dist/shell/topbar-action-menu.d.ts +44 -0
  187. package/dist/shell/topbar-action-menu.js +49 -0
  188. package/dist/shell/topbar-action-menu.js.map +1 -0
  189. package/dist/shell/topbar-controls.d.ts +54 -0
  190. package/dist/shell/topbar-controls.js +152 -0
  191. package/dist/shell/topbar-controls.js.map +1 -0
  192. package/dist/theme/chart-palette.d.ts +60 -0
  193. package/dist/theme/chart-palette.js +79 -0
  194. package/dist/theme/chart-palette.js.map +1 -0
  195. package/dist/theme/palette-presets.d.ts +39 -0
  196. package/dist/theme/palette-presets.js +440 -0
  197. package/dist/theme/palette-presets.js.map +1 -0
  198. package/dist/theme/palette-store.d.ts +45 -0
  199. package/dist/theme/palette-store.js +52 -0
  200. package/dist/theme/palette-store.js.map +1 -0
  201. package/dist/theme/theme-store.d.ts +57 -0
  202. package/dist/theme/theme-store.js +96 -0
  203. package/dist/theme/theme-store.js.map +1 -0
  204. package/dist/tour/tour.d.ts +104 -0
  205. package/dist/tour/tour.js +351 -0
  206. package/dist/tour/tour.js.map +1 -0
  207. package/dist/wizard/stepper-nav.d.ts +30 -0
  208. package/dist/wizard/stepper-nav.js +146 -0
  209. package/dist/wizard/stepper-nav.js.map +1 -0
  210. package/dist/wizard/types.d.ts +124 -0
  211. package/dist/wizard/types.js +24 -0
  212. package/dist/wizard/types.js.map +1 -0
  213. package/dist/wizard/use-wizard.d.ts +6 -0
  214. package/dist/wizard/use-wizard.js +222 -0
  215. package/dist/wizard/use-wizard.js.map +1 -0
  216. package/dist/wizard/validation.d.ts +23 -0
  217. package/dist/wizard/validation.js +13 -0
  218. package/dist/wizard/validation.js.map +1 -0
  219. package/dist/wizard/wizard-context.d.ts +19 -0
  220. package/dist/wizard/wizard-context.js +15 -0
  221. package/dist/wizard/wizard-context.js.map +1 -0
  222. package/dist/wizard/wizard-step.d.ts +11 -0
  223. package/dist/wizard/wizard-step.js +12 -0
  224. package/dist/wizard/wizard-step.js.map +1 -0
  225. package/dist/wizard/wizard-summary.d.ts +18 -0
  226. package/dist/wizard/wizard-summary.js +41 -0
  227. package/dist/wizard/wizard-summary.js.map +1 -0
  228. package/package.json +87 -0
  229. package/tokens.css +254 -0
@@ -0,0 +1,512 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId, useState } from "react";
3
+ import { ChevronDown, Eye, EyeOff, HelpCircle } from "lucide-react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { useMediaQuery } from "../hooks/use-media-query.js";
6
+ import { Tooltip } from "./tooltip.js";
7
+ const BUTTON_BASE = "inline-flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
8
+ const buttonVariantClasses = {
9
+ primary: "border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-[var(--border)] focus:ring-[var(--brand)]",
10
+ secondary: "border border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]",
11
+ ghost: "bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]",
12
+ danger: "bg-red-600 text-white hover:bg-red-500 focus:ring-red-300",
13
+ brand: "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)] focus:ring-[var(--brand)]"
14
+ };
15
+ function buttonClasses(variant = "primary", className) {
16
+ return cn(BUTTON_BASE, buttonVariantClasses[variant], className);
17
+ }
18
+ function Button({
19
+ variant = "primary",
20
+ stretch,
21
+ className,
22
+ ...rest
23
+ }) {
24
+ return /* @__PURE__ */ jsx(
25
+ "button",
26
+ {
27
+ ...rest,
28
+ className: cn(
29
+ BUTTON_BASE,
30
+ // In a flex row next to a taller labelled field, `stretch` makes the button
31
+ // fill the field's height so the two line up (self-stretch overrides the row's
32
+ // align-items). No effect outside a flex row / when it's already the tallest.
33
+ stretch && "self-stretch",
34
+ buttonVariantClasses[variant],
35
+ className
36
+ )
37
+ }
38
+ );
39
+ }
40
+ const ICON_BUTTON_BASE = "inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:size-5";
41
+ const IconButton = forwardRef(function IconButton2({ variant = "ghost", size = "md", className, ...rest }, ref) {
42
+ return /* @__PURE__ */ jsx(
43
+ "button",
44
+ {
45
+ ref,
46
+ ...rest,
47
+ className: cn(
48
+ ICON_BUTTON_BASE,
49
+ size === "sm" ? "size-8" : "size-9",
50
+ buttonVariantClasses[variant],
51
+ className
52
+ )
53
+ }
54
+ );
55
+ });
56
+ IconButton.displayName = "IconButton";
57
+ const FIELD_BASE = "block w-full rounded-md border border-[var(--border)] bg-white px-3 py-2 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-[var(--brand)] focus:ring-[var(--brand)] dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 disabled:cursor-default disabled:bg-slate-50 disabled:text-slate-500 dark:disabled:bg-slate-800/60 dark:disabled:text-slate-400 [&[readonly]]:bg-slate-50 [&[readonly]]:text-slate-500 dark:[&[readonly]]:bg-slate-800/60 dark:[&[readonly]]:text-slate-400";
58
+ const FIELD_WRITABLE_LOOK = "[&[readonly]]:bg-white [&[readonly]]:text-slate-900 dark:[&[readonly]]:bg-slate-900 dark:[&[readonly]]:text-slate-100";
59
+ const FIELD_FLOATING_PAD = "pt-4 pb-1";
60
+ const FIELD_INVALID = "border-rose-400 ring-1 ring-rose-400 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/80 dark:ring-rose-500/80 dark:focus:border-rose-400";
61
+ const FLOATING_INPUT_CLASS = cn(FIELD_BASE, FIELD_FLOATING_PAD, "peer placeholder:text-transparent");
62
+ const PHONE_QUERY = "(max-width: 767px)";
63
+ const FIELD_DISPLAY = "block w-full border-x-0 border-t-0 border-b border-[var(--border)] bg-transparent px-0 pt-0 pb-1 text-[var(--text-primary)] shadow-none placeholder:text-slate-400 focus:border-[var(--brand)] focus:outline-none focus:ring-0 disabled:opacity-60 dark:placeholder:text-slate-500";
64
+ const FIELD_TRIGGER = cn(
65
+ FIELD_BASE,
66
+ "relative flex items-center justify-between gap-2 text-left hover:bg-slate-50 dark:hover:bg-slate-800"
67
+ );
68
+ function FieldChevron({ className }) {
69
+ return /* @__PURE__ */ jsx(
70
+ ChevronDown,
71
+ {
72
+ "aria-hidden": true,
73
+ className: cn(
74
+ "pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-slate-400 dark:text-slate-500",
75
+ className
76
+ )
77
+ }
78
+ );
79
+ }
80
+ const FLOATING_LABEL_CLASS = cn(
81
+ "pointer-events-none absolute left-3 top-2.5 text-sm text-slate-400 transition-all",
82
+ "max-w-[calc(100%-1.5rem)] truncate",
83
+ "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-slate-600",
84
+ "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-slate-600",
85
+ "dark:text-slate-500 dark:peer-focus:text-slate-300 dark:peer-[:not(:placeholder-shown)]:text-slate-300",
86
+ "peer-disabled:opacity-50"
87
+ );
88
+ const FLOATING_ROW_CLASS = cn(
89
+ "pointer-events-none absolute left-3 top-2.5 flex items-center gap-1 transition-all",
90
+ "max-w-[calc(100%-3rem)] text-sm text-slate-400",
91
+ "peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-slate-600",
92
+ "peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-slate-600",
93
+ "dark:text-slate-500 dark:peer-focus:text-slate-300 dark:peer-[:not(:placeholder-shown)]:text-slate-300",
94
+ "peer-disabled:opacity-50"
95
+ );
96
+ const STATIC_LABEL_TYPE = "text-[11px] leading-tight text-slate-500 dark:text-slate-400 peer-disabled:opacity-50";
97
+ const FLOATING_LABEL_STATIC = cn(
98
+ "pointer-events-none absolute left-3 top-1",
99
+ STATIC_LABEL_TYPE,
100
+ "max-w-[calc(100%-1.5rem)] truncate"
101
+ );
102
+ function FloatingField({
103
+ className,
104
+ htmlFor,
105
+ label,
106
+ staticLabel,
107
+ srOnlyLabel,
108
+ hint,
109
+ children
110
+ }) {
111
+ const withHint = hint !== void 0 && !srOnlyLabel;
112
+ const atEnd = withHint && staticLabel;
113
+ const labelEl = label !== void 0 && /* @__PURE__ */ jsx(
114
+ "label",
115
+ {
116
+ htmlFor,
117
+ className: srOnlyLabel ? "sr-only" : atEnd ? cn("pointer-events-none min-w-0 truncate", STATIC_LABEL_TYPE) : withHint ? "pointer-events-none min-w-0 truncate" : staticLabel ? FLOATING_LABEL_STATIC : FLOATING_LABEL_CLASS,
118
+ children: label
119
+ }
120
+ );
121
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
122
+ children,
123
+ withHint ? (
124
+ // Static: `inset-x-3` rather than `left-3`, so a long label truncates at
125
+ // the field's own right padding instead of running under the chevron.
126
+ // Animated: the row floats with the label and is only as wide as it needs
127
+ // to be. Either way the hint keeps its width (`shrink-0`) and the label
128
+ // is the one that gives way.
129
+ /* @__PURE__ */ jsxs(
130
+ "div",
131
+ {
132
+ className: atEnd ? "pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1" : FLOATING_ROW_CLASS,
133
+ children: [
134
+ labelEl,
135
+ /* @__PURE__ */ jsx("span", { className: "pointer-events-auto flex shrink-0 items-center", children: hint })
136
+ ]
137
+ }
138
+ )
139
+ ) : labelEl
140
+ ] });
141
+ }
142
+ function FieldHint({ label, side = "left" }) {
143
+ return /* @__PURE__ */ jsx(Tooltip, { label, side, portal: true, children: /* @__PURE__ */ jsx(
144
+ "button",
145
+ {
146
+ type: "button",
147
+ "aria-label": label,
148
+ onClick: (e) => e.preventDefault(),
149
+ className: "flex text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200",
150
+ children: /* @__PURE__ */ jsx(HelpCircle, { className: "size-3.5" })
151
+ }
152
+ ) });
153
+ }
154
+ function FieldLabel({ children, className }) {
155
+ return /* @__PURE__ */ jsx("span", { className: cn(FLOATING_LABEL_STATIC, "z-10", className), children });
156
+ }
157
+ const PICKER_TYPES = /* @__PURE__ */ new Set(["date", "datetime-local", "month", "time", "week"]);
158
+ const Input = forwardRef(function Input2({ className, inputClassName, label, id, placeholder, type, variant = "field", invalid, ...rest }, ref) {
159
+ const generated = useId();
160
+ const fieldId = id ?? generated;
161
+ const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
162
+ const isPassword = type === "password";
163
+ const [revealed, setRevealed] = useState(false);
164
+ const effectiveType = isPassword && revealed ? "text" : type;
165
+ const handleClick = PICKER_TYPES.has(type ?? "") ? (e) => {
166
+ rest.onClick?.(e);
167
+ try {
168
+ e.currentTarget.showPicker?.();
169
+ } catch {
170
+ }
171
+ } : rest.onClick;
172
+ const revealToggle = isPassword ? /* @__PURE__ */ jsx(
173
+ "button",
174
+ {
175
+ type: "button",
176
+ tabIndex: -1,
177
+ onClick: () => setRevealed((v) => !v),
178
+ "aria-label": revealed ? "Hide password" : "Show password",
179
+ "aria-pressed": revealed,
180
+ className: "absolute inset-y-0 right-0 flex items-center px-2.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300",
181
+ children: revealed ? /* @__PURE__ */ jsx(EyeOff, { className: "size-4" }) : /* @__PURE__ */ jsx(Eye, { className: "size-4" })
182
+ }
183
+ ) : null;
184
+ if (label === void 0) {
185
+ if (!isPassword) {
186
+ return /* @__PURE__ */ jsx(
187
+ "input",
188
+ {
189
+ ref,
190
+ id,
191
+ type,
192
+ placeholder,
193
+ ...rest,
194
+ "aria-invalid": invalid || rest["aria-invalid"] || void 0,
195
+ onClick: handleClick,
196
+ className: cn(FIELD_BASE, className, inputClassName, invalid && FIELD_INVALID)
197
+ }
198
+ );
199
+ }
200
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
201
+ /* @__PURE__ */ jsx(
202
+ "input",
203
+ {
204
+ ref,
205
+ id,
206
+ type: effectiveType,
207
+ placeholder,
208
+ ...rest,
209
+ "aria-invalid": invalid || rest["aria-invalid"] || void 0,
210
+ className: cn(FIELD_BASE, "pr-9", inputClassName, invalid && FIELD_INVALID)
211
+ }
212
+ ),
213
+ revealToggle
214
+ ] });
215
+ }
216
+ return /* @__PURE__ */ jsxs(FloatingField, { className, htmlFor: fieldId, label, srOnlyLabel: asDisplay, children: [
217
+ /* @__PURE__ */ jsx(
218
+ "input",
219
+ {
220
+ ref,
221
+ id: fieldId,
222
+ type: effectiveType,
223
+ placeholder: asDisplay ? placeholder ?? (typeof label === "string" ? label : " ") : " ",
224
+ ...rest,
225
+ "aria-invalid": invalid || rest["aria-invalid"] || void 0,
226
+ onClick: handleClick,
227
+ className: cn(
228
+ asDisplay ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug") : FLOATING_INPUT_CLASS,
229
+ isPassword && "pr-9",
230
+ inputClassName,
231
+ invalid && FIELD_INVALID
232
+ )
233
+ }
234
+ ),
235
+ revealToggle
236
+ ] });
237
+ });
238
+ Input.displayName = "Input";
239
+ const Select = forwardRef(function Select2({ className, label, id, children, invalid, hint, ...rest }, ref) {
240
+ const generated = useId();
241
+ const fieldId = id ?? generated;
242
+ const chevron = rest.disabled ? null : /* @__PURE__ */ jsx(FieldChevron, {});
243
+ if (label === void 0) {
244
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
245
+ /* @__PURE__ */ jsx(
246
+ "select",
247
+ {
248
+ ref,
249
+ ...rest,
250
+ "aria-invalid": invalid || void 0,
251
+ className: cn(FIELD_BASE, "appearance-none pr-9", invalid && FIELD_INVALID),
252
+ children
253
+ }
254
+ ),
255
+ chevron
256
+ ] });
257
+ }
258
+ return /* @__PURE__ */ jsxs(FloatingField, { className, htmlFor: fieldId, label, staticLabel: true, hint, children: [
259
+ /* @__PURE__ */ jsx(
260
+ "select",
261
+ {
262
+ ref,
263
+ id: fieldId,
264
+ ...rest,
265
+ "aria-invalid": invalid || void 0,
266
+ className: cn(
267
+ FIELD_BASE,
268
+ FIELD_FLOATING_PAD,
269
+ "peer appearance-none pr-9",
270
+ invalid && FIELD_INVALID
271
+ ),
272
+ children
273
+ }
274
+ ),
275
+ chevron
276
+ ] });
277
+ });
278
+ Select.displayName = "Select";
279
+ const Textarea = forwardRef(function Textarea2({ className, label, id, placeholder, invalid, ...rest }, ref) {
280
+ const generated = useId();
281
+ const fieldId = id ?? generated;
282
+ if (label === void 0) {
283
+ return /* @__PURE__ */ jsx(
284
+ "textarea",
285
+ {
286
+ ref,
287
+ id,
288
+ placeholder,
289
+ ...rest,
290
+ "aria-invalid": invalid || rest["aria-invalid"] || void 0,
291
+ className: cn(FIELD_BASE, className, invalid && FIELD_INVALID)
292
+ }
293
+ );
294
+ }
295
+ return /* @__PURE__ */ jsx(FloatingField, { className, htmlFor: fieldId, label, children: /* @__PURE__ */ jsx(
296
+ "textarea",
297
+ {
298
+ ref,
299
+ id: fieldId,
300
+ placeholder: " ",
301
+ ...rest,
302
+ "aria-invalid": invalid || rest["aria-invalid"] || void 0,
303
+ className: cn(FLOATING_INPUT_CLASS, invalid && FIELD_INVALID)
304
+ }
305
+ ) });
306
+ });
307
+ Textarea.displayName = "Textarea";
308
+ function Card({
309
+ className,
310
+ children,
311
+ flush
312
+ }) {
313
+ return /* @__PURE__ */ jsx(
314
+ "div",
315
+ {
316
+ className: cn(
317
+ // Surface + border are theme tokens so the palette switcher (feedback
318
+ // #307) can re-skin every card; a caller's own bg-*/border-* override
319
+ // still wins via tailwind-merge.
320
+ "bg-[var(--bg-surface)]",
321
+ flush ? "border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm" : "rounded-lg border border-[var(--border)] shadow-sm",
322
+ className
323
+ ),
324
+ children
325
+ }
326
+ );
327
+ }
328
+ function CardHeader({ className, ...props }) {
329
+ return /* @__PURE__ */ jsx(
330
+ "div",
331
+ {
332
+ "data-slot": "card-header",
333
+ className: cn(
334
+ // Grid (not flex) so CardAction can occupy a top-right column; with no
335
+ // action it collapses to one column and title/description stack.
336
+ "grid auto-rows-min items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
337
+ className
338
+ ),
339
+ ...props
340
+ }
341
+ );
342
+ }
343
+ function CardTitle({ className, ...props }) {
344
+ return /* @__PURE__ */ jsx(
345
+ "div",
346
+ {
347
+ "data-slot": "card-title",
348
+ className: cn("font-semibold leading-none", className),
349
+ ...props
350
+ }
351
+ );
352
+ }
353
+ function CardDescription({ className, ...props }) {
354
+ return /* @__PURE__ */ jsx(
355
+ "div",
356
+ {
357
+ "data-slot": "card-description",
358
+ className: cn("text-sm text-[var(--money-neutral)]", className),
359
+ ...props
360
+ }
361
+ );
362
+ }
363
+ function CardAction({ className, ...props }) {
364
+ return /* @__PURE__ */ jsx(
365
+ "div",
366
+ {
367
+ "data-slot": "card-action",
368
+ className: cn("col-start-2 row-start-1 row-span-2 self-start justify-self-end", className),
369
+ ...props
370
+ }
371
+ );
372
+ }
373
+ function CardContent({ className, ...props }) {
374
+ return /* @__PURE__ */ jsx("div", { "data-slot": "card-content", className: cn("px-6 last:pb-6", className), ...props });
375
+ }
376
+ function CardFooter({ className, ...props }) {
377
+ return /* @__PURE__ */ jsx(
378
+ "div",
379
+ {
380
+ "data-slot": "card-footer",
381
+ className: cn("flex items-center px-6 pb-6", className),
382
+ ...props
383
+ }
384
+ );
385
+ }
386
+ function Spinner({ className }) {
387
+ return /* @__PURE__ */ jsx(
388
+ "span",
389
+ {
390
+ className: cn(
391
+ "inline-block h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200",
392
+ className
393
+ )
394
+ }
395
+ );
396
+ }
397
+ function EmptyState({
398
+ title,
399
+ hint,
400
+ className
401
+ }) {
402
+ return /* @__PURE__ */ jsxs(
403
+ "div",
404
+ {
405
+ className: cn(
406
+ "flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50/50 px-4 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400",
407
+ className
408
+ ),
409
+ children: [
410
+ /* @__PURE__ */ jsx("div", { className: "font-medium text-slate-700 dark:text-slate-200", children: title }),
411
+ hint && /* @__PURE__ */ jsx("div", { className: "mt-1 text-xs", children: hint })
412
+ ]
413
+ }
414
+ );
415
+ }
416
+ const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-slate-200 dark:border-slate-800";
417
+ const TABLIST_WRAP_CLASSES = "flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-slate-200 md:dark:border-slate-800";
418
+ const TAB_CLASSES = "whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-slate-300";
419
+ const TAB_ACTIVE_CLASSES = "border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100";
420
+ const TAB_INACTIVE_CLASSES = "border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:text-slate-400 dark:hover:text-slate-200";
421
+ const TAB_WRAP_CLASSES = "whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-300 md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm";
422
+ const TAB_WRAP_ACTIVE_CLASSES = "bg-[var(--brand)] text-[var(--brand-contrast)] md:border-slate-900 md:text-slate-900 md:dark:border-slate-100 md:dark:text-slate-100";
423
+ const TAB_WRAP_INACTIVE_CLASSES = "bg-[var(--bg-surface)] text-[var(--text-primary)] md:border-transparent md:text-slate-500 md:hover:border-slate-300 md:hover:text-slate-700 md:dark:text-slate-400 md:dark:hover:text-slate-200";
424
+ function Tabs({ tabs, active, onChange, className, wrap = false, label }) {
425
+ const onListKeyDown = (e) => {
426
+ const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
427
+ if (step === 0 && e.key !== "Home" && e.key !== "End") return;
428
+ const items = Array.from(e.currentTarget.querySelectorAll('[role="tab"]'));
429
+ const from = items.indexOf(document.activeElement);
430
+ if (from === -1) return;
431
+ e.preventDefault();
432
+ const to = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : (from + step + items.length) % items.length;
433
+ items[to]?.focus();
434
+ };
435
+ return /* @__PURE__ */ jsx(
436
+ "div",
437
+ {
438
+ role: "tablist",
439
+ "aria-label": label,
440
+ onKeyDown: onListKeyDown,
441
+ className: cn(wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES, className),
442
+ children: tabs.map((tab) => {
443
+ const isActive = tab.id === active;
444
+ const shared = {
445
+ role: "tab",
446
+ "aria-selected": isActive,
447
+ className: cn(
448
+ wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
449
+ wrap ? isActive ? TAB_WRAP_ACTIVE_CLASSES : TAB_WRAP_INACTIVE_CLASSES : isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES
450
+ )
451
+ };
452
+ const inner = /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5", children: [
453
+ tab.label,
454
+ tab.badge != null ? tab.badge : null
455
+ ] });
456
+ return tab.href ? /* @__PURE__ */ jsx(
457
+ "a",
458
+ {
459
+ ...shared,
460
+ href: tab.href,
461
+ draggable: false,
462
+ onClick: (e) => {
463
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
464
+ e.preventDefault();
465
+ onChange(tab.id);
466
+ },
467
+ onKeyDown: (e) => {
468
+ if (e.key !== " ") return;
469
+ e.preventDefault();
470
+ onChange(tab.id);
471
+ },
472
+ children: inner
473
+ },
474
+ tab.id
475
+ ) : /* @__PURE__ */ jsx("button", { ...shared, onClick: () => onChange(tab.id), children: inner }, tab.id);
476
+ })
477
+ }
478
+ );
479
+ }
480
+ export {
481
+ Button,
482
+ Card,
483
+ CardAction,
484
+ CardContent,
485
+ CardDescription,
486
+ CardFooter,
487
+ CardHeader,
488
+ CardTitle,
489
+ EmptyState,
490
+ FIELD_BASE,
491
+ FIELD_DISPLAY,
492
+ FIELD_FLOATING_PAD,
493
+ FIELD_INVALID,
494
+ FIELD_TRIGGER,
495
+ FIELD_WRITABLE_LOOK,
496
+ FLOATING_INPUT_CLASS,
497
+ FLOATING_LABEL_CLASS,
498
+ FLOATING_LABEL_STATIC,
499
+ FieldChevron,
500
+ FieldHint,
501
+ FieldLabel,
502
+ FloatingField,
503
+ IconButton,
504
+ Input,
505
+ PHONE_QUERY,
506
+ Select,
507
+ Spinner,
508
+ Tabs,
509
+ Textarea,
510
+ buttonClasses
511
+ };
512
+ //# sourceMappingURL=ui.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/ui.tsx"],"sourcesContent":["import { forwardRef, useId, useState } from \"react\";\nimport { ChevronDown, Eye, EyeOff, HelpCircle } from \"lucide-react\";\nimport type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { Tooltip } from \"./tooltip\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"danger\" | \"brand\";\n\n// Shared base ring for every button-styled element. Kept as a named const so the\n// <Button> component and the {@link buttonClasses} helper draw from one source and\n// can never drift apart.\nconst BUTTON_BASE =\n \"inline-flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed\";\n\n// Warm, palette-token-driven so buttons blend with the fields + cards in every theme.\n// Actions default to a warm bordered look (primary = filled warm chip, secondary =\n// outline); `brand` stays the solid accent for the rare strong CTA; danger stays red\n// (destructive semantics). All focus rings use the brand accent.\nconst buttonVariantClasses: Record<ButtonVariant, string> = {\n primary:\n \"border border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-primary)] hover:bg-[var(--border)] focus:ring-[var(--brand)]\",\n secondary:\n \"border border-[var(--border)] bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]\",\n ghost:\n \"bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-surface-2)] focus:ring-[var(--brand)]\",\n danger: \"bg-red-600 text-white hover:bg-red-500 focus:ring-red-300\",\n brand:\n \"bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)] focus:ring-[var(--brand)]\",\n};\n\n/**\n * Button classes for the rare case where the styling must land on a non-`<button>`\n * element that {@link Button} can't render — e.g. a router `<Link>` or a Radix\n * AlertDialog Action/Cancel (which must stay the Radix element). Everywhere a real\n * button works, prefer `<Button>`. Draws from the same base + variant maps as\n * `<Button>`, so the two stay in lockstep.\n */\nexport function buttonClasses(variant: ButtonVariant = \"primary\", className?: string): string {\n return cn(BUTTON_BASE, buttonVariantClasses[variant], className);\n}\n\nexport function Button({\n variant = \"primary\",\n stretch,\n className,\n ...rest\n}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; stretch?: boolean }) {\n return (\n <button\n {...rest}\n className={cn(\n BUTTON_BASE,\n // In a flex row next to a taller labelled field, `stretch` makes the button\n // fill the field's height so the two line up (self-stretch overrides the row's\n // align-items). No effect outside a flex row / when it's already the tallest.\n stretch && \"self-stretch\",\n buttonVariantClasses[variant],\n className,\n )}\n />\n );\n}\n\n// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2\n// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,\n// text-shaped box. This fixes a square box instead and, crucially, forces the\n// child icon to 20px via `[&_svg]:size-5` so a caller cannot under-size it. Use\n// it wherever an action is a bare icon (edit/delete/tools) so they all match the\n// top-bar icon buttons and can never drift apart again.\n//\n// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the\n// two re-skin together with the palette.\nconst ICON_BUTTON_BASE =\n \"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:size-5\";\n\nexport const IconButton = forwardRef<\n HTMLButtonElement,\n ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: ButtonVariant;\n /** Box size: md = 36px (matches the top bar), sm = 32px. The icon stays 20px. */\n size?: \"sm\" | \"md\";\n }\n>(function IconButton({ variant = \"ghost\", size = \"md\", className, ...rest }, ref) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(\n ICON_BUTTON_BASE,\n size === \"sm\" ? \"size-8\" : \"size-9\",\n buttonVariantClasses[variant],\n className,\n )}\n />\n );\n});\nIconButton.displayName = \"IconButton\";\n\nexport const FIELD_BASE =\n \"block w-full rounded-md border border-[var(--border)] bg-white px-3 py-2 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-[var(--brand)] focus:ring-[var(--brand)] dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 \" +\n // A field the user cannot change has to LOOK settled. Without this, `disabled`\n // dimmed the floating label and nothing else — FIELD_BASE's own `text-slate-900`\n // overrides the browser's grey — so a read-only value sat there in full-strength\n // black, indistinguishable from one you could retype (Keksdose dev#455/#474).\n \"disabled:cursor-default disabled:bg-slate-50 disabled:text-slate-500 dark:disabled:bg-slate-800/60 dark:disabled:text-slate-400 \" +\n // Same for a field that takes focus but refuses keys: `readOnly` is not\n // `disabled`, and a value that cannot be edited should not claim it can be. Opt\n // OUT with {@link FIELD_WRITABLE_LOOK} in the one case where `readOnly` does not\n // mean that — see the two-factor code field, which uses it to block an autofill\n // and drops it the moment you focus the field.\n //\n // ⚠️ The ATTRIBUTE, never the `:read-only` pseudo-class (Keksdose dev#477).\n // `:read-only` does not mean \"was marked readonly\" — per Selectors 4 it matches\n // everything that is not `:read-write`, and only editable inputs/textareas and\n // contenteditable elements are `:read-write`. So `read-only:` matched every\n // `<select>` in the app and every field-styled `<button>` built on this base —\n // the native selects, the date trigger, the entity/currency/multi pickers — and\n // painted them all in the \"you may not edit this\" grey. On one transaction form\n // that made three different-looking families out of one field style: white\n // inputs, grey selects, grey trigger buttons.\n \"[&[readonly]]:bg-slate-50 [&[readonly]]:text-slate-500 dark:[&[readonly]]:bg-slate-800/60 dark:[&[readonly]]:text-slate-400\";\n\n/** Cancels FIELD_BASE's read-only treatment for a field that is `readOnly` for a\n * reason other than \"you may not edit this\". Attribute-scoped for the same reason\n * FIELD_BASE is — the two have to cancel on the identical selector or twMerge\n * cannot make the later one win. */\nexport const FIELD_WRITABLE_LOOK =\n \"[&[readonly]]:bg-white [&[readonly]]:text-slate-900 dark:[&[readonly]]:bg-slate-900 dark:[&[readonly]]:text-slate-100\";\n\n// Extra top padding leaves room for a label that floats INSIDE the field (the\n// \"filled\" pattern) — the label sits in the top strip, the value below it. Used by\n// every labelled field (native + custom-dropdown triggers). twMerge lets pt/pb win\n// over FIELD_BASE's py-2.\nexport const FIELD_FLOATING_PAD = \"pt-4 pb-1\";\n\n// Error/required highlight for a field that is missing a value — a rose border\n// and matching focus ring so the control itself shows what's wrong, not just a\n// note beside it (feedback #235). Layered after FIELD_BASE so twMerge wins.\n//\n// The `ring-1` is not decoration; it is what makes the highlight SURVIVE display\n// scaling (Keksdose live #295 — *\"Account select boundary. It is not highlighted on\n// the sides.\"*). At 125%, the browser's usual setting on a 2560px screen, a 1px CSS\n// border is 1.25 device pixels: the horizontal edges land on whole rows and paint\n// solid, while one of the two VERTICAL edges lands across a pixel boundary and is\n// spread over two columns at partial coverage. Measured on the receipt's required\n// account field, dark theme, full rose = rgb(208,30,78): left/top/bottom all 208,\n// right 160 then 115 — the side that is supposed to shout, at 55–77% of the others.\n// A border plus a ring is 2 CSS px, so whatever the fraction there is always one\n// fully covered device pixel on every side (re-measured: worst side 241).\n//\n// A ring rather than `border-2`: a box-shadow adds no layout, so an invalid field\n// stays exactly the size of a valid one and nothing beside it moves when the value\n// arrives.\nexport const FIELD_INVALID =\n \"border-rose-400 ring-1 ring-rose-400 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/80 dark:ring-rose-500/80 dark:focus:border-rose-400\";\n\nexport const FLOATING_INPUT_CLASS = cn(FIELD_BASE, FIELD_FLOATING_PAD, \"peer placeholder:text-transparent\");\n\n/** The phone breakpoint the display treatment below keys off — the same one the\n * numpad sheet uses, kept in one place so the two can't drift apart. */\nexport const PHONE_QUERY = \"(max-width: 767px)\";\n\n/**\n * The PHONE display treatment (Keksdose feedback #176, carried across the entry\n * forms by #179): the field chrome removed so the ONE input a form is actually\n * about reads as the thing itself, not as another boxed row in a stack.\n *\n * Every control that takes `variant=\"display\"` — {@link Input}, `NumberInput`,\n * `AmountInput` — means exactly the same thing by it: the treatment applies below\n * {@link PHONE_QUERY} and the field is untouched above it, so a caller never has\n * to ask the viewport, and a form can't end up half-treated across breakpoints.\n *\n * What stays, deliberately:\n * - A hairline baseline. With the box gone something still has to say \"you can\n * type here\"; it takes the brand colour on focus, where a bordered field would\n * light its whole outline. Each edge is set exactly once (`border-x-0\n * border-t-0 border-b`) so the rule can't hinge on utility order.\n * - The muted placeholder colour, because a display field is usually empty at\n * the moment it matters most and has nothing else to show.\n * - The label, moved to `sr-only` rather than dropped: display type is legible\n * to the eye, not to a screen reader.\n *\n * Size and weight are NOT here — an amount wants display type, a subject line\n * wants a heading — so each control adds its own on top.\n */\nexport const FIELD_DISPLAY =\n \"block w-full border-x-0 border-t-0 border-b border-[var(--border)] bg-transparent px-0 pt-0 pb-1 text-[var(--text-primary)] shadow-none placeholder:text-slate-400 focus:border-[var(--brand)] focus:outline-none focus:ring-0 disabled:opacity-60 dark:placeholder:text-slate-500\";\n\n// A field-styled button trigger for the custom dropdown controls (MultiSelect,\n// CurrencySelect) — the field look (border/bg) as a flex row for the value + chevron,\n// so they don't hand-copy the field classes. Add FIELD_FLOATING_PAD only when the\n// trigger carries a label (unlabelled triggers stay normal height to match buttons /\n// adjacent controls). Pair the labelled case with a static FieldLabel.\n// `relative` so the trigger can host an absolutely-centred FieldChevron / clear\n// button the way the native Select does.\nexport const FIELD_TRIGGER = cn(\n FIELD_BASE,\n \"relative flex items-center justify-between gap-2 text-left hover:bg-slate-50 dark:hover:bg-slate-800\",\n);\n\n/** The dropdown chevron, shared by the native {@link Select} and every custom\n * {@link FIELD_TRIGGER} control.\n *\n * Absolutely positioned and centred on the FIELD box. As an ordinary flex child\n * it centres on the *content* box instead, and `FIELD_FLOATING_PAD` (pt-4 pb-1)\n * pushes that box's midline down — so a labelled currency/multi-select chevron\n * sat visibly lower than the native select's right beside it (feedback #400).\n * Pair it with `pr-9` on the trigger so the value can't run underneath. */\nexport function FieldChevron({ className }: { className?: string }) {\n return (\n <ChevronDown\n aria-hidden\n className={cn(\n \"pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-slate-400 dark:text-slate-500\",\n className,\n )}\n />\n );\n}\n\n// Animated label that starts centred (as a placeholder) in an empty field and\n// floats up INSIDE the top strip on focus or once the field has a value. Sits on\n// the white field, so no background chip and nothing to mismatch the card.\nexport const FLOATING_LABEL_CLASS = cn(\n \"pointer-events-none absolute left-3 top-2.5 text-sm text-slate-400 transition-all\",\n \"max-w-[calc(100%-1.5rem)] truncate\",\n \"peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-slate-600\",\n \"peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-slate-600\",\n \"dark:text-slate-500 dark:peer-focus:text-slate-300 dark:peer-[:not(:placeholder-shown)]:text-slate-300\",\n \"peer-disabled:opacity-50\",\n);\n\n// The animated label again, as a ROW that the label and its \"?\" share — same\n// placement, same float, same type, but with the type on the row so the label\n// INHERITS it. It has to be that way round: `peer-focus:` compiles to a sibling\n// selector, so the classes have to sit on the element that is actually a sibling\n// of the input, and a label nested inside a wrapper is not one.\n//\n// Wider right clearance than the label alone takes (`3rem` rather than `1.5rem`),\n// because the controls that carry an animated label are the ones with something\n// at the right edge of the field — NumberInput's calculator is the case this was\n// written for. The label truncates a little sooner; the alternative was the \"?\"\n// sitting on top of a button (steering-design feedback #48).\nconst FLOATING_ROW_CLASS = cn(\n \"pointer-events-none absolute left-3 top-2.5 flex items-center gap-1 transition-all\",\n \"max-w-[calc(100%-3rem)] text-sm text-slate-400\",\n \"peer-focus:top-1 peer-focus:text-[11px] peer-focus:leading-tight peer-focus:text-slate-600\",\n \"peer-[:not(:placeholder-shown)]:top-1 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:leading-tight peer-[:not(:placeholder-shown)]:text-slate-600\",\n \"dark:text-slate-500 dark:peer-focus:text-slate-300 dark:peer-[:not(:placeholder-shown)]:text-slate-300\",\n \"peer-disabled:opacity-50\",\n);\n\n// The TYPE of the small static label, without any placement. Split out so the\n// label and anything sharing its line (see `hint` on {@link FloatingField}) are\n// laid out by one flex row instead of by two absolute offsets guessing at the\n// same baseline — which is what put dev#468's \"?\" three pixels above the word it\n// belongs to.\nconst STATIC_LABEL_TYPE =\n \"text-[11px] leading-tight text-slate-500 dark:text-slate-400 peer-disabled:opacity-50\";\n\n// A field that always has a value (select / dropdown trigger) keeps the label\n// permanently in the floated position — small, in the top strip, value below.\nexport const FLOATING_LABEL_STATIC = cn(\n \"pointer-events-none absolute left-3 top-1\",\n STATIC_LABEL_TYPE,\n \"max-w-[calc(100%-1.5rem)] truncate\",\n);\n\n/**\n * The one place that assembles a labelled field: a `relative` wrapper around the\n * control (`children`) plus a floating label inside the top strip. Pass `staticLabel`\n * for controls that always have a value (selects); omit it for free-text fields whose\n * label animates from centred→up. Used by Input/Select/Textarea/NumberInput; the\n * dropdown controls that need a ref + menu keep their own wrapper but the same label\n * (via {@link FieldLabel}) and trigger ({@link FIELD_TRIGGER}) styles.\n */\nexport function FloatingField({\n className,\n htmlFor,\n label,\n staticLabel,\n srOnlyLabel,\n hint,\n children,\n}: {\n className?: string;\n htmlFor?: string;\n label?: ReactNode;\n staticLabel?: boolean;\n /** Keep the label in the accessibility tree but out of the layout — what\n * {@link FIELD_DISPLAY} needs, since a floating label inside a field with no\n * field left would have nothing to float in. */\n srOnlyLabel?: boolean;\n /** Something interactive that belongs to the LABEL rather than to the value —\n * in practice a {@link FieldHint} \"?\" (dev#468). It is rendered in a flex row\n * with the label, so it is centred on the label's line by the layout instead\n * of by a hand-tuned `top-…`, and it can never drift when the type changes.\n *\n * It rides an ANIMATED label too, and the two cases place it differently on\n * purpose. A static label has a field-wide strip to itself, so the hint sits\n * at the far end of it and a long label truncates into the gap. An animated\n * one belongs to a control with something at the right edge of the field — a\n * calculator, a stepper — so the hint follows the label instead, and it is\n * the label that gives way (steering-design feedback #48). */\n hint?: ReactNode;\n children: ReactNode;\n}) {\n const withHint = hint !== undefined && !srOnlyLabel;\n const atEnd = withHint && staticLabel;\n const labelEl = label !== undefined && (\n <label\n htmlFor={htmlFor}\n className={\n srOnlyLabel\n ? \"sr-only\"\n : atEnd\n ? cn(\"pointer-events-none min-w-0 truncate\", STATIC_LABEL_TYPE)\n : withHint\n ? \"pointer-events-none min-w-0 truncate\"\n : staticLabel\n ? FLOATING_LABEL_STATIC\n : FLOATING_LABEL_CLASS\n }\n >\n {label}\n </label>\n );\n return (\n <div className={cn(\"relative\", className)}>\n {children}\n {withHint ? (\n // Static: `inset-x-3` rather than `left-3`, so a long label truncates at\n // the field's own right padding instead of running under the chevron.\n // Animated: the row floats with the label and is only as wide as it needs\n // to be. Either way the hint keeps its width (`shrink-0`) and the label\n // is the one that gives way.\n <div\n className={\n atEnd\n ? \"pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1\"\n : FLOATING_ROW_CLASS\n }\n >\n {labelEl}\n <span className=\"pointer-events-auto flex shrink-0 items-center\">{hint}</span>\n </div>\n ) : (\n labelEl\n )}\n </div>\n );\n}\n\n/**\n * The \"?\" that explains a field, on the field's own label line (dev#468).\n *\n * Pass it to a labelled {@link Select} / {@link Input} as `hint`. It exists as a\n * component rather than as a snippet each form repeats because the previous\n * version was exactly that snippet — an absolutely-positioned button whose\n * `top-1.5` was one guess at where an 11px label sits — and the reporter's\n * follow-up was *\"question mark is not centered. Is it part of the hoc?\n * positioning problems seem quite frequently.\"* It was not part of the HOC. Now\n * it is, and there is one place left where the answer can be wrong.\n *\n * A `<button>` rather than a bare icon: hover alone puts the explanation out of\n * reach of a keyboard and of every touch device, and the tooltip shows on focus\n * too. The text is also its accessible name, so a screen reader gets it without\n * the bubble ever opening.\n */\nexport function FieldHint({ label, side = \"left\" }: { label: string; side?: \"left\" | \"right\" | \"top\" | \"bottom\" }) {\n return (\n <Tooltip label={label} side={side} portal>\n <button\n type=\"button\"\n aria-label={label}\n // Nothing to activate: the tooltip opens on hover and on focus, and a\n // click that did something as well would be a second, undiscoverable\n // behaviour on the same target.\n onClick={(e) => e.preventDefault()}\n className=\"flex text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200\"\n >\n <HelpCircle className=\"size-3.5\" />\n </button>\n </Tooltip>\n );\n}\n\n/**\n * The floating label for a custom-dropdown trigger (a `<span>`, since the trigger is a\n * button not a labelable input). Same placement as {@link FloatingField}'s static label,\n * so every labelled field lines up. Render inside a `relative` wrapper, before the trigger.\n */\nexport function FieldLabel({ children, className }: { children: ReactNode; className?: string }) {\n return (\n <span className={cn(FLOATING_LABEL_STATIC, \"z-10\", className)}>\n {children}\n </span>\n );\n}\n\n// Native date/time inputs only reveal the calendar via the tiny trailing icon;\n// open the picker on a click anywhere in the field instead (feedback #224).\nconst PICKER_TYPES = new Set([\"date\", \"datetime-local\", \"month\", \"time\", \"week\"]);\n\nexport const Input = forwardRef<\n HTMLInputElement,\n InputHTMLAttributes<HTMLInputElement> & {\n label?: ReactNode;\n /** Classes for the `<input>` itself, as distinct from `className`, which\n * styles the field WRAPPER once a `label` turns this into a FloatingField.\n * Without it a labelled Input had no way to reach its own element — so\n * `tabular-nums` on a numeric text field, which NumberInput has supported\n * all along through the identically named prop, was simply unavailable. */\n inputClassName?: string;\n /** {@link FIELD_DISPLAY} — on a phone, drop the chrome and set the value as a\n * heading. For the one field a form is about (a feedback subject, an account\n * name), never for a stack of them. Labelled fields only: the label is what\n * the placeholder falls back to once it goes `sr-only`. */\n variant?: \"field\" | \"display\";\n /** The field is required and unanswered, or holds something that cannot be\n * saved — {@link FIELD_INVALID}, the same rose border/ring `Select` has worn\n * since feedback #235.\n *\n * It exists here because writing `aria-invalid` by hand did NOT do this. The\n * attribute spreads onto the element and nothing styles it — there is no\n * `[aria-invalid]` rule in this package or in either consumer's stylesheet —\n * so three Keksdose dialogs flagged a mismatched passphrase to a screen reader\n * and painted the field exactly as if it were fine. Setting the prop sets the\n * attribute too, so the two can no longer be spelled separately. */\n invalid?: boolean;\n }\n>(function Input(\n { className, inputClassName, label, id, placeholder, type, variant = \"field\", invalid, ...rest },\n ref,\n) {\n const generated = useId();\n const fieldId = id ?? generated;\n const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === \"display\";\n // Password fields get a reveal toggle so users can check what they typed.\n const isPassword = type === \"password\";\n const [revealed, setRevealed] = useState(false);\n const effectiveType = isPassword && revealed ? \"text\" : type;\n const handleClick = PICKER_TYPES.has(type ?? \"\")\n ? (e: MouseEvent<HTMLInputElement>) => {\n rest.onClick?.(e);\n try {\n // showPicker throws if unsupported or not user-activated — a click is\n // a valid activation, so this is safe; guard for older browsers.\n (e.currentTarget as HTMLInputElement & { showPicker?: () => void }).showPicker?.();\n } catch {\n /* ignore */\n }\n }\n : rest.onClick;\n const revealToggle = isPassword ? (\n <button\n type=\"button\"\n tabIndex={-1}\n onClick={() => setRevealed((v) => !v)}\n aria-label={revealed ? \"Hide password\" : \"Show password\"}\n aria-pressed={revealed}\n className=\"absolute inset-y-0 right-0 flex items-center px-2.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300\"\n >\n {revealed ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n </button>\n ) : null;\n if (label === undefined) {\n if (!isPassword) {\n return (\n <input\n ref={ref}\n id={id}\n type={type}\n placeholder={placeholder}\n {...rest}\n // AFTER the spread, so the prop wins — but OR-ed with whatever the spread\n // carried, or setting `invalid` would have quietly deleted a caller's own\n // `aria-invalid`. The prop is the one that also paints; a bare attribute\n // still announces, which is all it ever did.\n aria-invalid={invalid || rest[\"aria-invalid\"] || undefined}\n onClick={handleClick}\n className={cn(FIELD_BASE, className, inputClassName, invalid && FIELD_INVALID)}\n />\n );\n }\n return (\n <div className={cn(\"relative\", className)}>\n <input\n ref={ref}\n id={id}\n type={effectiveType}\n placeholder={placeholder}\n {...rest}\n aria-invalid={invalid || rest[\"aria-invalid\"] || undefined}\n className={cn(FIELD_BASE, \"pr-9\", inputClassName, invalid && FIELD_INVALID)}\n />\n {revealToggle}\n </div>\n );\n }\n return (\n <FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>\n <input\n ref={ref}\n id={fieldId}\n type={effectiveType}\n // A labelled field's placeholder is normally a single space, feeding the\n // floating label's peer-placeholder-shown trick. With the label sr-only\n // there is no float left to drive, and an empty borderless line would say\n // nothing at all — so the label text becomes the placeholder.\n placeholder={asDisplay ? (placeholder ?? (typeof label === \"string\" ? label : \" \")) : \" \"}\n {...rest}\n aria-invalid={invalid || rest[\"aria-invalid\"] || undefined}\n onClick={handleClick}\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-xl font-semibold leading-snug\")\n : FLOATING_INPUT_CLASS,\n isPassword && \"pr-9\",\n inputClassName,\n invalid && FIELD_INVALID,\n )}\n />\n {revealToggle}\n </FloatingField>\n );\n});\nInput.displayName = \"Input\";\n\nexport const Select = forwardRef<\n HTMLSelectElement,\n SelectHTMLAttributes<HTMLSelectElement> & {\n label?: ReactNode;\n invalid?: boolean;\n /** A {@link FieldHint} for the label line — see {@link FloatingField}. */\n hint?: ReactNode;\n }\n>(function Select({ className, label, id, children, invalid, hint, ...rest }, ref) {\n const generated = useId();\n const fieldId = id ?? generated;\n // Custom chevron (native arrow hidden via appearance-none) so it sits a touch\n // in from the right border and matches both themes — feedback #223. A DISABLED\n // select has no menu to drop, so it drops the chevron too: the arrow is the one\n // thing on the control that promises a choice (Keksdose dev#474, where the\n // account type became read-only and still looked exactly like a picker).\n const chevron = rest.disabled ? null : <FieldChevron />;\n if (label === undefined) {\n return (\n <div className={cn(\"relative\", className)}>\n <select\n ref={ref}\n {...rest}\n aria-invalid={invalid || undefined}\n className={cn(FIELD_BASE, \"appearance-none pr-9\", invalid && FIELD_INVALID)}\n >\n {children}\n </select>\n {chevron}\n </div>\n );\n }\n return (\n <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={hint}>\n <select\n ref={ref}\n id={fieldId}\n {...rest}\n aria-invalid={invalid || undefined}\n className={cn(\n FIELD_BASE,\n FIELD_FLOATING_PAD,\n \"peer appearance-none pr-9\",\n invalid && FIELD_INVALID,\n )}\n >\n {children}\n </select>\n {chevron}\n </FloatingField>\n );\n});\nSelect.displayName = \"Select\";\n\nexport const Textarea = forwardRef<\n HTMLTextAreaElement,\n TextareaHTMLAttributes<HTMLTextAreaElement> & {\n label?: ReactNode;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n }\n>(function Textarea({ className, label, id, placeholder, invalid, ...rest }, ref) {\n const generated = useId();\n const fieldId = id ?? generated;\n if (label === undefined) {\n return (\n <textarea\n ref={ref}\n id={id}\n placeholder={placeholder}\n {...rest}\n aria-invalid={invalid || rest[\"aria-invalid\"] || undefined}\n className={cn(FIELD_BASE, className, invalid && FIELD_INVALID)}\n />\n );\n }\n return (\n <FloatingField className={className} htmlFor={fieldId} label={label}>\n <textarea\n ref={ref}\n id={fieldId}\n placeholder=\" \"\n {...rest}\n aria-invalid={invalid || rest[\"aria-invalid\"] || undefined}\n className={cn(FLOATING_INPUT_CLASS, invalid && FIELD_INVALID)}\n />\n </FloatingField>\n );\n});\nTextarea.displayName = \"Textarea\";\n\nexport function Card({\n className,\n children,\n flush,\n}: {\n className?: string;\n children: ReactNode;\n /**\n * When true, drop the card chrome (border, rounded corners, shadow) on mobile so the\n * card spans edge-to-edge. Chrome reappears at the `md` breakpoint. Use for primary\n * content cards on data pages; leave off for centered dialog/panel cards.\n */\n flush?: boolean;\n}) {\n return (\n <div\n className={cn(\n // Surface + border are theme tokens so the palette switcher (feedback\n // #307) can re-skin every card; a caller's own bg-*/border-* override\n // still wins via tailwind-merge.\n \"bg-[var(--bg-surface)]\",\n flush\n ? \"border-y border-[var(--border)] md:rounded-lg md:border md:shadow-sm\"\n : \"rounded-lg border border-[var(--border)] shadow-sm\",\n className,\n )}\n >\n {children}\n </div>\n );\n}\n\n// Composed shadcn-style Card sub-parts. `Card` stays padding-less (callers set\n// their own padding via className), so these own the padding/rhythm. Token-driven\n// so they re-skin with the palette. Use CardHeader → CardTitle/CardDescription\n// (+ optional CardAction, top-right) → CardContent → CardFooter.\nexport function CardHeader({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n return (\n <div\n data-slot=\"card-header\"\n className={cn(\n // Grid (not flex) so CardAction can occupy a top-right column; with no\n // action it collapses to one column and title/description stack.\n \"grid auto-rows-min items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport function CardTitle({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n return (\n <div\n data-slot=\"card-title\"\n className={cn(\"font-semibold leading-none\", className)}\n {...props}\n />\n );\n}\n\nexport function CardDescription({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n return (\n <div\n data-slot=\"card-description\"\n className={cn(\"text-sm text-[var(--money-neutral)]\", className)}\n {...props}\n />\n );\n}\n\nexport function CardAction({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n return (\n <div\n data-slot=\"card-action\"\n className={cn(\"col-start-2 row-start-1 row-span-2 self-start justify-self-end\", className)}\n {...props}\n />\n );\n}\n\nexport function CardContent({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n // `last:pb-6`, not a plain `pb-6`: CardHeader owns the top padding and CardFooter\n // owns the bottom one, so a card WITHOUT a footer had nothing closing it off and\n // its last field sat flush against the card edge (kastlan feedback: the language\n // input touching the card bottom on /profile). Scoping to `:last-child` fixes that\n // case and leaves a footered card's rhythm exactly as it was.\n return <div data-slot=\"card-content\" className={cn(\"px-6 last:pb-6\", className)} {...props} />;\n}\n\nexport function CardFooter({ className, ...props }: ComponentPropsWithoutRef<\"div\">) {\n return (\n <div\n data-slot=\"card-footer\"\n className={cn(\"flex items-center px-6 pb-6\", className)}\n {...props}\n />\n );\n}\n\nexport function Spinner({ className }: { className?: string }) {\n return (\n <span\n className={cn(\n \"inline-block h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200\",\n className,\n )}\n />\n );\n}\n\nexport function EmptyState({\n title,\n hint,\n className,\n}: {\n title: string;\n hint?: string;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50/50 px-4 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400\",\n className,\n )}\n >\n <div className=\"font-medium text-slate-700 dark:text-slate-200\">{title}</div>\n {hint && <div className=\"mt-1 text-xs\">{hint}</div>}\n </div>\n );\n}\n\nexport interface TabsProps<T extends string> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: { id: T; label: ReactNode; badge?: ReactNode; href?: string }[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /** Let the strip WRAP onto as many rows as it needs below `md`, instead of\n * scrolling sideways — for a strip carrying more tabs than fit on a phone row.\n * Keksdose live #262: ten reports on a 406px screen showed about three, and the\n * round-2 answer (a <select> below `md`) cost a tap to open and a tap to choose.\n * *\"I would rather keep the tabs but have multirow tabs depending on the screen\n * size, I will not loose the function to see all reports at once and navigate by\n * single click rather by double click.\"*\n *\n * The active MARKER has to change with the layout, which is why this cannot be\n * done from a call site with a `className`: the default marker is a `border-b-2`\n * underline riding the container's own bottom rule, and a tab sitting in a row\n * that does not touch that rule cannot wear it — every row but the last would\n * show a stray line floating mid-strip, and `-mb-px` would pull each chip a\n * pixel into the row beneath it. A wrapped strip marks the active tab with a\n * filled brand chip instead, which is also the only thing findable at a glance\n * among ten same-weight labels on three rows.\n *\n * From `md` up NOTHING changes: same single-line underline strip, same paddings,\n * same colours as every other strip in the app.\n *\n * Opt-in on purpose. The three-tab strips (invoices, statements, bank imports)\n * already fit a phone row, and turning those into chips would be an unrequested\n * redesign of three other pages. */\n wrap?: boolean;\n /** Accessible name for the `role=\"tablist\"` container. Every tab carries its own\n * text, so this names the GROUP, not the tabs; leave it unset where a visible\n * heading immediately above already does that job. */\n label?: string;\n}\n\n// The two shapes are written out as whole strings rather than as one base plus a\n// pile of `md:` overrides. The unwrapped pair is character-for-character what this\n// component has always emitted, so the strips that do not opt in cannot drift; the\n// wrapped pair is authored from scratch, so no unprefixed utility has to be beaten\n// by its own `md:` twin through tailwind-merge. The only ordering this relies on is\n// Tailwind's own: unprefixed utilities are emitted first, then `md:`, then\n// `md:dark:` — so from 768px up the wrapped strip resolves to exactly the paint of\n// the default one, in both themes.\nconst TABLIST_CLASSES =\n \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-slate-200 dark:border-slate-800\";\nconst TABLIST_WRAP_CLASSES =\n \"flex flex-wrap gap-1.5 md:flex-nowrap md:gap-1 md:overflow-x-auto md:overflow-y-hidden md:border-b md:border-slate-200 md:dark:border-slate-800\";\n\nconst TAB_CLASSES =\n \"whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-slate-300\";\nconst TAB_ACTIVE_CLASSES = \"border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100\";\nconst TAB_INACTIVE_CLASSES =\n \"border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:text-slate-400 dark:hover:text-slate-200\";\n\n// Below `md`, a chip: `py-2` on `text-xs` is exactly 32px tall, and ten German\n// report labels then land in three rows on a 406px screen (four at `text-sm`). The\n// active fill is the app's own selected pair, `--brand` / `--brand-contrast`: the\n// palette store writes both halves of it together for every preset and both themes,\n// so the contrast holds everywhere (5.1:1 at its worst preset) without a hard-coded\n// colour. Inactive chips take the raised card surface with full-strength body text —\n// all ten have to stay readable; it is the FILL, not the text weight, that says\n// which one is open.\nconst TAB_WRAP_CLASSES =\n \"whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-300 md:rounded-none md:border-b-2 md:-mb-px md:bg-transparent md:px-3 md:py-2 md:text-sm\";\nconst TAB_WRAP_ACTIVE_CLASSES =\n \"bg-[var(--brand)] text-[var(--brand-contrast)] md:border-slate-900 md:text-slate-900 md:dark:border-slate-100 md:dark:text-slate-100\";\nconst TAB_WRAP_INACTIVE_CLASSES =\n \"bg-[var(--bg-surface)] text-[var(--text-primary)] md:border-transparent md:text-slate-500 md:hover:border-slate-300 md:hover:text-slate-700 md:dark:text-slate-400 md:dark:hover:text-slate-200\";\n\nexport function Tabs<T extends string>({ tabs, active, onChange, className, wrap = false, label }: TabsProps<T>) {\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern), which is what keeps\n // a WRAPPED strip navigable: the rows flow in DOM order too, so Right off the end\n // of row one lands on the first chip of row two rather than nowhere. Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n const onListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n const step = e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowLeft\" ? -1 : 0;\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(document.activeElement as HTMLElement);\n if (from === -1) return;\n e.preventDefault();\n const to =\n e.key === \"Home\"\n ? 0\n : e.key === \"End\"\n ? items.length - 1\n : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n return (\n <div\n role=\"tablist\"\n aria-label={label}\n onKeyDown={onListKeyDown}\n className={cn(wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES, className)}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n className: cn(\n wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,\n wrap\n ? isActive\n ? TAB_WRAP_ACTIVE_CLASSES\n : TAB_WRAP_INACTIVE_CLASSES\n : isActive\n ? TAB_ACTIVE_CLASSES\n : TAB_INACTIVE_CLASSES,\n ),\n };\n const inner = (\n <span className=\"inline-flex items-center gap-1.5\">\n {tab.label}\n {tab.badge != null ? tab.badge : null}\n </span>\n );\n return tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n onKeyDown={(e) => {\n if (e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":"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","names":["IconButton","Input","Select","Textarea"]}
@@ -0,0 +1,31 @@
1
+ import * as react from 'react';
2
+
3
+ /**
4
+ * The phone's endless scroll, in place of the desktop pager (feedback #232).
5
+ *
6
+ * A client-side table already holds every row, so there is nothing to fetch — it simply
7
+ * reveals more of the sorted list as a sentinel scrolls into view. A SERVER-paginated
8
+ * table keeps its pager instead, because the rest of the rows genuinely are not here.
9
+ *
10
+ * The reveal count only ever grows and self-caps at the (re-filtered) list length, which
11
+ * is why nothing resets it: narrowing a filter cannot leave the limit pointing past the
12
+ * end, and widening one does not have to re-reveal what the user already scrolled past.
13
+ *
14
+ * Lifted out of `DataTable` with the rest of its state; the sentinel ref belongs to the
15
+ * caller's `<li>`.
16
+ */
17
+ declare function useMobileReveal<T>({ rows, slice, isServer, isMdUp, defaultPageSize, }: {
18
+ /** The whole sorted list — what there is to reveal. */
19
+ rows: T[];
20
+ /** What a server-paginated table shows instead. */
21
+ slice: T[];
22
+ isServer: boolean;
23
+ isMdUp: boolean;
24
+ defaultPageSize: number;
25
+ }): {
26
+ mobileSlice: T[];
27
+ canRevealMoreMobile: boolean;
28
+ loadMoreRef: react.RefObject<HTMLLIElement | null>;
29
+ };
30
+
31
+ export { useMobileReveal };