@eifi1/ui-kit 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +4 -3
  2. package/dist/components/amount-input.d.ts +8 -0
  3. package/dist/components/amount-input.js +10 -4
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/error-boundary.js +2 -1
  6. package/dist/components/error-boundary.js.map +1 -1
  7. package/dist/components/money-field.d.ts +92 -0
  8. package/dist/components/money-field.js +40 -0
  9. package/dist/components/money-field.js.map +1 -0
  10. package/dist/components/number-field.d.ts +12 -1
  11. package/dist/components/number-field.js +4 -0
  12. package/dist/components/number-field.js.map +1 -1
  13. package/dist/components/page-header.d.ts +3 -1
  14. package/dist/components/page-header.js +68 -31
  15. package/dist/components/page-header.js.map +1 -1
  16. package/dist/components/text-link.js +2 -1
  17. package/dist/components/text-link.js.map +1 -1
  18. package/dist/feedback/feedback-dialog.d.ts +10 -1
  19. package/dist/feedback/feedback-dialog.js +13 -3
  20. package/dist/feedback/feedback-dialog.js.map +1 -1
  21. package/dist/i18n/locales/de.js +1 -0
  22. package/dist/i18n/locales/de.js.map +1 -1
  23. package/dist/i18n/locales/es.js +1 -0
  24. package/dist/i18n/locales/es.js.map +1 -1
  25. package/dist/i18n/locales/fr.js +1 -0
  26. package/dist/i18n/locales/fr.js.map +1 -1
  27. package/dist/i18n/locales/hu.js +1 -0
  28. package/dist/i18n/locales/hu.js.map +1 -1
  29. package/dist/i18n/locales/it.js +1 -0
  30. package/dist/i18n/locales/it.js.map +1 -1
  31. package/dist/i18n/locales/zh.js +1 -0
  32. package/dist/i18n/locales/zh.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/lib/document-navigation.d.ts +15 -0
  37. package/dist/lib/document-navigation.js +13 -0
  38. package/dist/lib/document-navigation.js.map +1 -0
  39. package/package.json +7 -4
  40. package/src/components/amount-input.tsx +18 -4
  41. package/src/components/error-boundary.tsx +2 -1
  42. package/src/components/money-field.tsx +75 -0
  43. package/src/components/number-field.tsx +14 -0
  44. package/src/components/page-header.tsx +65 -12
  45. package/src/components/text-link.tsx +2 -1
  46. package/src/feedback/feedback-dialog.tsx +20 -2
  47. package/src/i18n/locales/de.ts +1 -0
  48. package/src/i18n/locales/es.ts +1 -0
  49. package/src/i18n/locales/fr.ts +1 -0
  50. package/src/i18n/locales/hu.ts +1 -0
  51. package/src/i18n/locales/it.ts +1 -0
  52. package/src/i18n/locales/zh.ts +1 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/document-navigation.ts +17 -0
@@ -1,3 +1,4 @@
1
+ import { useLayoutEffect, useRef, useState } from "react";
1
2
  import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
3
  import { cn } from "../lib/cn";
3
4
  import { SECTION_LABEL_CLASS } from "./text";
@@ -42,11 +43,15 @@ const ROW: Record<PageHeaderMobileLayout, string> = {
42
43
  */
43
44
  export type PageHeaderActionsAlign = "start" | "center" | "end";
44
45
 
45
- /** Per layout, because `stacked` is a column on a phone — a `self-center` there would
46
- * centre the actions HORIZONTALLY — so it only aligns from `sm` up. */
47
- const ACTIONS_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {
48
- stacked: { start: "sm:self-start", center: "sm:self-center", end: "sm:self-end" },
49
- inline: { start: "self-start", center: "self-center", end: "self-end" },
46
+ /** Applied to the ROW, so the title block and the actions are aligned against each
47
+ * other on one cross axis. 0.14.0 put `self-*` on the actions box alone, which moved
48
+ * nothing when the actions were the taller item — they already filled the row, and
49
+ * the title stayed at the top (keksdose H2, reports-page's two-line labelled
50
+ * CurrencySelect). Per layout, because `stacked` is a column on a phone, where
51
+ * `items-center` would centre everything HORIZONTALLY — so it aligns from `sm` up. */
52
+ const ROW_ALIGN: Record<PageHeaderMobileLayout, Record<PageHeaderActionsAlign, string>> = {
53
+ stacked: { start: "sm:items-start", center: "sm:items-center", end: "sm:items-end" },
54
+ inline: { start: "items-start", center: "items-center", end: "items-end" },
50
55
  };
51
56
 
52
57
  export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
@@ -106,7 +111,9 @@ export interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "
106
111
  * `CurrencySelect` as its action: a field with its label on top is taller than the
107
112
  * title, and `stacked`'s default top alignment hangs it from the title's cap height
108
113
  * with its control well below the title's line. `center` sets it on the title's
109
- * middle, as that page's hand-written `items-center` row did.
114
+ * middle, as that page's hand-written `items-center` row did. It aligns the whole row
115
+ * (title block and actions against each other), since 0.14.1: 0.14.0 moved only the
116
+ * actions box, which did nothing when the actions were the taller item (keksdose H2).
110
117
  */
111
118
  actionsAlign?: PageHeaderActionsAlign;
112
119
  /**
@@ -155,7 +162,44 @@ export function PageHeader({
155
162
  const Heading = as as ElementType;
156
163
  const hasSecondary = secondaryActions != null;
157
164
  const wrapsSecondary = hasSecondary && mobileLayout === "inline";
158
- const align = actionsAlign != null ? ACTIONS_ALIGN[mobileLayout][actionsAlign] : undefined;
165
+ const align = actionsAlign != null ? ROW_ALIGN[mobileLayout][actionsAlign] : undefined;
166
+ // keksdose H1: in `inline` the title block could shrink below its longest word, and
167
+ // `break-words` then split "Monatsbudget" into "Monatsbudg" / "et" beside the actions.
168
+ // Unless it truncates, the title keeps its longest word (min-content) and the ROW
169
+ // wraps instead: actions that do not fit go to a line of their own, as the app's old
170
+ // `flex-wrap` header did.
171
+ const keepsWords = mobileLayout === "inline" && !truncateTitle;
172
+ // keksdose, after H1: once the actions have wrapped below the title, the phone's
173
+ // header was THREE rows — title / actions / secondary — where the app's old header
174
+ // shared the second row between the month nav and the toggles (~48px of phone
175
+ // height). So the secondary group's full-width basis (G6b: a row of its own) is
176
+ // dropped exactly when the actions already sit below the title; then it joins their
177
+ // row if it fits, and wraps under them if not. While the actions share the title's
178
+ // row, nothing changes: the secondary group never climbs onto the title's row.
179
+ //
180
+ // CSS cannot see "the previous item wrapped", so it is measured — by a
181
+ // ResizeObserver, whose first callback runs on `observe`. It is stable: the
182
+ // secondary group comes after the actions, so its basis cannot move them. No
183
+ // ResizeObserver (jsdom) or no layout keeps the basis, the 0.14.0 layout.
184
+ const measuresWrap = keepsWords && hasSecondary && actions != null;
185
+ const titleRef = useRef<HTMLDivElement>(null);
186
+ const actionsRef = useRef<HTMLDivElement>(null);
187
+ const [actionsWrapped, setActionsWrapped] = useState(false);
188
+ useLayoutEffect(() => {
189
+ const titleBox = titleRef.current;
190
+ const actionsBox = actionsRef.current;
191
+ if (!measuresWrap || !titleBox || !actionsBox || typeof ResizeObserver === "undefined") return;
192
+ const observer = new ResizeObserver(() => {
193
+ const t = titleBox.getBoundingClientRect();
194
+ const a = actionsBox.getBoundingClientRect();
195
+ // Unmeasured (display: none, no layout): keep the full-width basis.
196
+ setActionsWrapped(t.height > 0 && a.height > 0 && a.top >= t.bottom - 1);
197
+ });
198
+ observer.observe(titleBox);
199
+ observer.observe(actionsBox);
200
+ if (titleBox.parentElement) observer.observe(titleBox.parentElement);
201
+ return () => observer.disconnect();
202
+ }, [measuresWrap]);
159
203
  return (
160
204
  <div {...rest} className={cn("flex min-w-0 flex-col gap-2", className)}>
161
205
  {breadcrumbs}
@@ -164,8 +208,15 @@ export function PageHeader({
164
208
  basis would size a column item's HEIGHT); from `sm` up the row stops wrapping
165
209
  and the order utilities seat it between the title and the actions. Without
166
210
  one, nothing changes. */}
167
- <div className={cn("flex min-w-0", ROW[mobileLayout], wrapsSecondary && "flex-wrap sm:flex-nowrap")}>
168
- <div className="min-w-0 flex-1">
211
+ <div
212
+ className={cn(
213
+ "flex min-w-0",
214
+ ROW[mobileLayout],
215
+ keepsWords ? "flex-wrap" : wrapsSecondary && "flex-wrap sm:flex-nowrap",
216
+ align,
217
+ )}
218
+ >
219
+ <div ref={titleRef} className={cn("flex-1", keepsWords ? "min-w-min" : "min-w-0")}>
169
220
  {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, "mb-1")}>{eyebrow}</p>}
170
221
  <Heading
171
222
  className={cn(
@@ -179,7 +230,10 @@ export function PageHeader({
179
230
  {description != null && <p className="mt-1 text-sm text-[var(--text-muted)]">{description}</p>}
180
231
  </div>
181
232
  {actions != null && (
182
- <div className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2", align)}>
233
+ <div
234
+ ref={actionsRef}
235
+ className={cn("flex shrink-0 flex-wrap items-center gap-2", hasSecondary && "sm:order-2")}
236
+ >
183
237
  {actions}
184
238
  </div>
185
239
  )}
@@ -187,8 +241,7 @@ export function PageHeader({
187
241
  <div
188
242
  className={cn(
189
243
  "flex flex-wrap items-center gap-2 sm:order-1 sm:shrink-0",
190
- wrapsSecondary && "basis-full sm:basis-auto",
191
- align,
244
+ wrapsSecondary && (measuresWrap && actionsWrapped ? "sm:basis-auto" : "basis-full sm:basis-auto"),
192
245
  )}
193
246
  >
194
247
  {secondaryActions}
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
6
6
  import type { KitLinkComponent } from "../i18n/kit-labels";
7
+ import { documentNavigation } from "../lib/document-navigation";
7
8
 
8
9
  /* ── Choosing what draws a link ──────────────────────────────────────────── */
9
10
 
@@ -98,7 +99,7 @@ export function replacingClick(
98
99
  onClick?.(event);
99
100
  if (!isPlainNavigationClick(event)) return;
100
101
  event.preventDefault();
101
- window.location.replace(href);
102
+ documentNavigation.replace(href);
102
103
  };
103
104
  }
104
105
 
@@ -53,6 +53,9 @@ export interface FeedbackDialogTextLabels {
53
53
  category: string;
54
54
  subject: string;
55
55
  body: string;
56
+ /** The body's label when `requireBody={false}`: "What happened? (optional)".
57
+ * Optional, so a complete `UiKitLabels` typed before 0.14.2 still compiles. */
58
+ bodyOptional?: string;
56
59
  /** The heading over the attachment buttons. */
57
60
  attachment: string;
58
61
  /** The line beside the buttons naming the shortcut. */
@@ -66,6 +69,7 @@ export const DEFAULT_FEEDBACK_DIALOG_LABELS: FeedbackDialogTextLabels = {
66
69
  category: "Category",
67
70
  subject: "Subject",
68
71
  body: "What happened?",
72
+ bodyOptional: "What happened? (optional)",
69
73
  attachment: "Screenshot",
70
74
  submitHint: "Ctrl/⌘ + Enter to send",
71
75
  cancel: "Cancel",
@@ -108,6 +112,7 @@ export function FeedbackDialog({
108
112
  maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
109
113
  onAttachmentError,
110
114
  onCaptureScreenshot,
115
+ requireBody = true,
111
116
  }: {
112
117
  open: boolean;
113
118
  onClose: () => void;
@@ -129,6 +134,12 @@ export function FeedbackDialog({
129
134
  * attachment"; the returned file is fed through the same validation + preview.
130
135
  */
131
136
  onCaptureScreenshot?: () => Promise<File | null>;
137
+ /**
138
+ * `false`: a report with only a subject can be sent, and the body's label says it is
139
+ * optional (`bodyOptional`, unless `labels.body` is passed). Default `true`, the
140
+ * dialog's behaviour until 0.14.2 (keksdose K1: its backend takes title-only reports).
141
+ */
142
+ requireBody?: boolean;
132
143
  }) {
133
144
  const [title, setTitle] = useState("");
134
145
  const [body, setBody] = useState("");
@@ -151,7 +162,9 @@ export function FeedbackDialog({
151
162
  }
152
163
  }
153
164
 
154
- const canSubmit = !!title && !!body && !submitting;
165
+ // Trimmed: a subject or body of spaces is empty (keksdose's API answers a blank title
166
+ // with a 422, and a blank report is no report).
167
+ const canSubmit = !!title.trim() && (!requireBody || !!body.trim()) && !submitting;
155
168
  const trySubmit = () => {
156
169
  if (canSubmit) void onSubmit({ title, body, category, attachment });
157
170
  };
@@ -174,7 +187,12 @@ export function FeedbackDialog({
174
187
  <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant="display" />
175
188
  );
176
189
  const bodyField = (
177
- <Textarea rows={5} label={labels.body} value={body} onChange={(e) => setBody(e.target.value)} />
190
+ <Textarea
191
+ rows={5}
192
+ label={requireBody ? labels.body : (labelsProp?.body ?? labels.bodyOptional ?? labels.body)}
193
+ value={body}
194
+ onChange={(e) => setBody(e.target.value)}
195
+ />
178
196
  );
179
197
  const attachmentField = (
180
198
  <FeedbackAttachmentField
@@ -26,6 +26,7 @@ export function uiKitLabelsDe(numberLocale = "de-DE"): UiKitLabels {
26
26
  category: "Kategorie",
27
27
  subject: "Betreff",
28
28
  body: "Was ist passiert?",
29
+ bodyOptional: "Was ist passiert? (optional)",
29
30
  attachment: "Screenshot",
30
31
  submitHint: "Strg/⌘ + Enter zum Senden",
31
32
  cancel: "Abbrechen",
@@ -27,6 +27,7 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
27
27
  category: "Categoría",
28
28
  subject: "Asunto",
29
29
  body: "¿Qué ha pasado?",
30
+ bodyOptional: "¿Qué ha pasado? (opcional)",
30
31
  attachment: "Captura de pantalla",
31
32
  submitHint: "Ctrl/⌘ + Intro para enviar",
32
33
  cancel: "Cancelar",
@@ -27,6 +27,7 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
27
27
  category: "Catégorie",
28
28
  subject: "Objet",
29
29
  body: "Que s’est-il passé\u202f?",
30
+ bodyOptional: "Que s’est-il passé\u202f? (facultatif)",
30
31
  attachment: "Capture d’écran",
31
32
  submitHint: "Ctrl/⌘ + Entrée pour envoyer",
32
33
  cancel: "Annuler",
@@ -26,6 +26,7 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
26
26
  category: "Kategória",
27
27
  subject: "Tárgy",
28
28
  body: "Mi történt?",
29
+ bodyOptional: "Mi történt? (nem kötelező)",
29
30
  attachment: "Képernyőkép",
30
31
  submitHint: "Ctrl/⌘ + Enter a küldéshez",
31
32
  cancel: "Mégse",
@@ -27,6 +27,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
27
27
  category: "Categoria",
28
28
  subject: "Oggetto",
29
29
  body: "Cosa è successo?",
30
+ bodyOptional: "Cosa è successo? (facoltativo)",
30
31
  attachment: "Screenshot",
31
32
  submitHint: "Ctrl/⌘ + Invio per inviare",
32
33
  cancel: "Annulla",
@@ -26,6 +26,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
26
26
  category: "类别",
27
27
  subject: "主题",
28
28
  body: "发生了什么?",
29
+ bodyOptional: "发生了什么?(可选)",
29
30
  attachment: "截图",
30
31
  submitHint: "Ctrl/⌘ + Enter 发送",
31
32
  cancel: "取消",
package/src/index.ts CHANGED
@@ -83,6 +83,7 @@ export * from "./components/numpad-sheet";
83
83
  export * from "./components/number-input";
84
84
  export * from "./components/currency-select";
85
85
  export * from "./components/amount-input";
86
+ export * from "./components/money-field";
86
87
  export * from "./components/combobox";
87
88
  export * from "./components/picker-sheet";
88
89
  export * from "./components/entity-combobox";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The full-document navigations the kit makes, behind one object the tests can spy on.
3
+ *
4
+ * jsdom implements neither `location.reload` nor `location.replace`, and its
5
+ * `window.location` is unforgeable: a test cannot stub it by redefining the property
6
+ * once the suite runs each file in its own VM context (`pool: "vmThreads"`, which took
7
+ * the suite from ~3 minutes to ~1). `vi.spyOn(documentNavigation, "reload")` works in
8
+ * every pool. Internal, not part of the package's surface.
9
+ */
10
+ export const documentNavigation = {
11
+ reload(): void {
12
+ window.location.reload();
13
+ },
14
+ replace(href: string): void {
15
+ window.location.replace(href);
16
+ },
17
+ };