@eifi1/ui-kit 0.12.0 → 0.13.1

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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +23 -1
  3. package/dist/components/amount-input.js +78 -13
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +225 -21
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +165 -12
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +470 -30
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +17 -3
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +47 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +32 -7
  190. package/src/rhf/fields.tsx +99 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -1,13 +1,13 @@
1
1
  import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
- import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
3
+ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
7
  import { Tooltip, type TooltipSide } from "./tooltip";
8
8
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
9
9
  import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
10
- import { pickLinkRenderer } from "./text-link";
10
+ import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
11
11
 
12
12
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
13
13
 
@@ -214,6 +214,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
214
214
  href?: never;
215
215
  renderLink?: never;
216
216
  external?: never;
217
+ replace?: never;
218
+ reloadDocument?: never;
217
219
  }
218
220
 
219
221
  /**
@@ -243,6 +245,21 @@ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEle
243
245
  * router link, and "(opens in a new tab)" read after the label — billing-history's
244
246
  * Stripe PDF. A `target="_blank"` of your own is announced the same way. */
245
247
  external?: boolean;
248
+ /**
249
+ * Navigate by replacing the current history entry, so Back does not return here —
250
+ * keksdose F1: the "Continue" out of a one-shot page (an emailed confirmation link, a
251
+ * payment return URL) that must not be landed on again. Handed to the router link as
252
+ * `replace` (see `KitLinkProps.replace` for mapping it); on a plain `<a>` a plain click
253
+ * becomes `location.replace(href)`. Ignored when `external`.
254
+ */
255
+ replace?: boolean;
256
+ /**
257
+ * A plain `<a>` for an in-app `href`, so the browser loads the whole document instead
258
+ * of the router swapping the view — keksdose F1: a route the SPA does not own (a
259
+ * server-rendered export, a logout that must drop every in-memory cache). Wins over
260
+ * `renderLink` and the provider's `linkComponent`.
261
+ */
262
+ reloadDocument?: boolean;
246
263
  /** A link cannot be `disabled`, so this renders an `<a>` with NO `href` —
247
264
  * `role="link"` and `aria-disabled`, out of the tab order like a disabled button,
248
265
  * with the disabled look. The router link is not used: it needs somewhere to go. */
@@ -294,6 +311,8 @@ function ButtonLink({
294
311
  href,
295
312
  renderLink,
296
313
  external,
314
+ replace,
315
+ reloadDocument,
297
316
  disabled,
298
317
  variant = "primary",
299
318
  size = "md",
@@ -311,7 +330,8 @@ function ButtonLink({
311
330
  // The same rule as every kit link (`pickLinkRenderer`): an external `href` or an
312
331
  // in-page `#anchor` is never handed to the provider's router link, even without
313
332
  // `external`.
314
- const Link = pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
333
+ // `reloadDocument` is the caller saying "not the router" for this one link.
334
+ const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
315
335
  const newTab = !disabled && (external || target === "_blank");
316
336
  const cls = cn(
317
337
  buttonLook(variant, size, stretch, tone),
@@ -346,7 +366,7 @@ function ButtonLink({
346
366
  href={href}
347
367
  target={external ? "_blank" : target}
348
368
  rel={external ? (rel ?? "noopener noreferrer") : rel}
349
- onClick={onClick}
369
+ onClick={external ? onClick : replacingClick(onClick, href, replace, target)}
350
370
  className={cls}
351
371
  >
352
372
  {body}
@@ -357,6 +377,7 @@ function ButtonLink({
357
377
  <RenderedButtonLink
358
378
  render={Link}
359
379
  {...rest}
380
+ {...routerLinkNavigation(replace)}
360
381
  href={href}
361
382
  target={target}
362
383
  rel={rel}
@@ -382,6 +403,8 @@ function ButtonElement({
382
403
  href: _href,
383
404
  renderLink: _renderLink,
384
405
  external: _external,
406
+ replace: _replace,
407
+ reloadDocument: _reloadDocument,
385
408
  ...rest
386
409
  }: ButtonProps) {
387
410
  const reasonId = useId();
@@ -687,8 +710,12 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
687
710
  * state re-colours the glyph, its hover fill and its focus ring together. Passing it
688
711
  * implies `tone="custom"`. Or leave it out and set the variable yourself from a class
689
712
  * (`className="[--icon-button-tone:var(--success)]"`).
713
+ *
714
+ * Or a `{ light, dark }` pair, one colour per theme — keksdose: a raw colour that reads
715
+ * on the light surface (`#be123c`) is too dark on the dark one, and an inline style
716
+ * cannot say "in dark mode". See {@link IconButtonToneColor}.
690
717
  */
691
- toneColor?: string;
718
+ toneColor?: IconButtonToneColor;
692
719
  /** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
693
720
  * hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
694
721
  * remove/delete that repeats down a list. `warning`: amber at rest — a flag that
@@ -796,9 +823,272 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
796
823
  * reason nobody can see is not one. Wins over `disabled`, as on Button.
797
824
  */
798
825
  disabledReason?: ReactNode;
826
+ /** The `<button>` element — a prop in React 19, as on {@link Button}. */
827
+ ref?: Ref<HTMLButtonElement>;
828
+ /** Only on the link form — see {@link IconButtonLinkProps}. */
829
+ href?: never;
830
+ renderLink?: never;
831
+ external?: never;
832
+ replace?: never;
833
+ reloadDocument?: never;
834
+ }
835
+
836
+ /**
837
+ * `<IconButton href>`: an icon-only LINK — keksdose F2 and kastlan 45, whose icon
838
+ * actions that navigate (a row's "open", a card's "edit" that goes to the edit page, a
839
+ * top bar's settings cog) were `<IconButton onClick={() => navigate(…)}>`: a button that
840
+ * changes the page, with no middle click, no "open in new tab" and no URL on hover.
841
+ *
842
+ * The same rule as {@link Button}'s `href` (see {@link ButtonLinkProps}): an in-app
843
+ * `href` goes through `renderLink`, else the provider's `linkComponent`; an external one
844
+ * or an in-page `#anchor` stays a plain `<a>`; `external` opens a new tab and says so to
845
+ * a screen reader; `replace` and `reloadDocument` as on Button. The look, the `label` →
846
+ * `aria-label` + Tooltip, the badge, `toneColor` and `stopPropagation` are the button's.
847
+ *
848
+ * `disabled` renders an inert `<a>` with no `href` — `role="link"`, `aria-disabled`, out
849
+ * of the tab order, dimmed — like Button's disabled link. `pressed`, `disabledReason` and
850
+ * `disabledStyle` are a button's and are typed out: a link is not a toggle, and a link
851
+ * that explains why it cannot be followed is a job for a disabled BUTTON.
852
+ */
853
+ export interface IconButtonLinkProps
854
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type" | "href">,
855
+ Pick<
856
+ IconButtonProps,
857
+ | "variant"
858
+ | "size"
859
+ | "glyphSize"
860
+ | "badge"
861
+ | "stretch"
862
+ | "shape"
863
+ | "toneColor"
864
+ | "tone"
865
+ | "quiet"
866
+ | "stopPropagation"
867
+ | "label"
868
+ | "tooltip"
869
+ | "tooltipSide"
870
+ | "tooltipPortal"
871
+ > {
872
+ href: string;
873
+ /** See {@link ButtonLinkProps.renderLink}. */
874
+ renderLink?: KitLinkComponent;
875
+ /** See {@link ButtonLinkProps.external}. */
876
+ external?: boolean;
877
+ /** See {@link ButtonLinkProps.replace}. */
878
+ replace?: boolean;
879
+ /** See {@link ButtonLinkProps.reloadDocument}. */
880
+ reloadDocument?: boolean;
881
+ /** See {@link ButtonLinkProps.disabled}. */
882
+ disabled?: boolean;
883
+ ref?: Ref<HTMLAnchorElement>;
884
+ type?: never;
885
+ form?: never;
886
+ pressed?: never;
887
+ disabledReason?: never;
888
+ disabledStyle?: never;
889
+ }
890
+
891
+ /**
892
+ * `toneColor`: one CSS colour, or one per theme.
893
+ *
894
+ * The pair is two custom properties on the element — `--icon-button-tone-light` and
895
+ * `--icon-button-tone-dark` — and a class that points `--icon-button-tone` at one or the
896
+ * other by the kit's `dark` variant (`.dark` on an ancestor, tokens.css), so the colour
897
+ * follows a theme switch in CSS alone, with no re-render and no JS reading the theme.
898
+ * Both values are any CSS colour; a token that already flips (`var(--warning)`) needs no
899
+ * pair.
900
+ */
901
+ export type IconButtonToneColor = string | { light: string; dark: string };
902
+
903
+ const ICON_BUTTON_TONE_PAIR =
904
+ "[--icon-button-tone:var(--icon-button-tone-light)] dark:[--icon-button-tone:var(--icon-button-tone-dark)]";
905
+
906
+ /** The inline custom properties `toneColor` sets over the caller's `style`. */
907
+ function toneColorStyle(toneColor: IconButtonToneColor | undefined, style: CSSProperties | undefined) {
908
+ if (toneColor === undefined) return style;
909
+ const vars: Record<string, string> =
910
+ typeof toneColor === "string"
911
+ ? { "--icon-button-tone": toneColor }
912
+ : { "--icon-button-tone-light": toneColor.light, "--icon-button-tone-dark": toneColor.dark };
913
+ return { ...style, ...vars } as CSSProperties;
914
+ }
915
+
916
+ /** Everything but the element: the look both forms of IconButton share. */
917
+ function iconButtonLook({
918
+ variant,
919
+ size,
920
+ stretch,
921
+ glyphSize,
922
+ shape,
923
+ tone,
924
+ quiet,
925
+ toneColor,
926
+ }: {
927
+ variant: IconButtonVariant;
928
+ size: IconButtonSize;
929
+ stretch: boolean | undefined;
930
+ glyphSize: IconButtonGlyphSize | undefined;
931
+ shape: "square" | "round";
932
+ tone: IconButtonTone;
933
+ quiet: boolean | undefined;
934
+ toneColor: IconButtonToneColor | undefined;
935
+ }) {
936
+ return cn(
937
+ ICON_BUTTON_BASE,
938
+ stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
939
+ glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
940
+ // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
941
+ variant === "overlay"
942
+ ? ICON_BUTTON_OVERLAY
943
+ : variant === "shutter"
944
+ ? ICON_BUTTON_SHUTTER
945
+ : buttonVariantClasses[variant],
946
+ shape === "round" && "rounded-full",
947
+ ICON_BUTTON_DISABLED_REST[variant],
948
+ iconButtonToneClass(tone, quiet),
949
+ toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
950
+ );
951
+ }
952
+
953
+ /** The badge beside the control, not in it — see `badge`. The wrapper is only a
954
+ * positioning box: the control inside it keeps its own focus ring and hit area. */
955
+ function withIconButtonBadge(control: ReactElement, badge: ReactNode, stretch: boolean | undefined) {
956
+ if (!hasContent(badge)) return control;
957
+ return (
958
+ <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
959
+ {control}
960
+ <span
961
+ aria-hidden
962
+ data-slot="icon-button-badge"
963
+ className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
964
+ >
965
+ {badge}
966
+ </span>
967
+ </span>
968
+ );
969
+ }
970
+
971
+ // Declared link-first, as Button is, so `<IconButton href>` picks the link props and
972
+ // `ComponentProps<typeof IconButton>` (the LAST signature) is still the button's.
973
+ export function IconButton(props: IconButtonLinkProps): ReactElement;
974
+ export function IconButton(props: IconButtonProps): ReactElement;
975
+ export function IconButton(props: IconButtonProps | IconButtonLinkProps): ReactElement {
976
+ if (props.href !== undefined) return <IconButtonLink {...(props as IconButtonLinkProps)} />;
977
+ return <IconButtonElement {...(props as IconButtonProps)} />;
978
+ }
979
+ IconButton.displayName = "IconButton";
980
+
981
+ function IconButtonLink({
982
+ href,
983
+ renderLink,
984
+ external,
985
+ replace,
986
+ reloadDocument,
987
+ disabled,
988
+ variant = "ghost",
989
+ size = "md",
990
+ tone: toneProp,
991
+ quiet,
992
+ stopPropagation,
993
+ stretch,
994
+ shape = "square",
995
+ toneColor,
996
+ label,
997
+ tooltip = true,
998
+ tooltipSide,
999
+ tooltipPortal,
1000
+ glyphSize,
1001
+ badge,
1002
+ className,
1003
+ style,
1004
+ target,
1005
+ rel,
1006
+ onClick,
1007
+ onKeyDown,
1008
+ children,
1009
+ ref,
1010
+ ...rest
1011
+ }: IconButtonLinkProps) {
1012
+ const kitLink = useKitLink();
1013
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
1014
+ const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1015
+ // Button's rule, word for word: `reloadDocument` bypasses the router, an external
1016
+ // href or an in-page `#anchor` never reaches it.
1017
+ const Link = reloadDocument ? undefined : pickLinkRenderer<KitLinkProps>(renderLink, kitLink, href);
1018
+ const newTab = !disabled && (external || target === "_blank");
1019
+ const name = rest["aria-label"] ?? label;
1020
+ // "(opens in a new tab)" joins the NAME here, not the body: an icon link has no text
1021
+ // for an sr-only span to follow, and `aria-label` would override one anyway.
1022
+ const ariaLabel = name !== undefined && newTab ? `${name} (${common.opensInNewTab})` : name;
1023
+ const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
1024
+ if (stopPropagation) e.stopPropagation();
1025
+ onClick?.(e);
1026
+ };
1027
+ const handleKeyDown = (e: KeyboardEvent<HTMLAnchorElement>) => {
1028
+ // A link activates on Enter only; that key still bubbles to a clickable row.
1029
+ if (stopPropagation && e.key === "Enter") e.stopPropagation();
1030
+ onKeyDown?.(e);
1031
+ };
1032
+ const cls = cn(
1033
+ iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
1034
+ // BASE's `disabled:` classes never match an `<a>` — Button's disabled link look.
1035
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
1036
+ className,
1037
+ );
1038
+ const look = { style: toneColorStyle(toneColor, style), className: cls };
1039
+ let control: ReactElement;
1040
+ if (disabled) {
1041
+ control = (
1042
+ <a {...rest} {...look} ref={ref} role="link" aria-disabled="true" aria-label={ariaLabel}>
1043
+ {children}
1044
+ </a>
1045
+ );
1046
+ } else if (external || !Link) {
1047
+ control = (
1048
+ <a
1049
+ {...rest}
1050
+ {...look}
1051
+ ref={ref}
1052
+ href={href}
1053
+ aria-label={ariaLabel}
1054
+ target={external ? "_blank" : target}
1055
+ rel={external ? (rel ?? "noopener noreferrer") : rel}
1056
+ onClick={external ? handleClick : replacingClick(handleClick, href, replace, target)}
1057
+ onKeyDown={handleKeyDown}
1058
+ >
1059
+ {children}
1060
+ </a>
1061
+ );
1062
+ } else {
1063
+ control = (
1064
+ <RenderedButtonLink
1065
+ render={Link}
1066
+ {...rest}
1067
+ {...look}
1068
+ {...routerLinkNavigation(replace)}
1069
+ ref={ref}
1070
+ href={href}
1071
+ aria-label={ariaLabel}
1072
+ target={target}
1073
+ rel={rel}
1074
+ onClick={handleClick}
1075
+ onKeyDown={handleKeyDown}
1076
+ >
1077
+ {children}
1078
+ </RenderedButtonLink>
1079
+ );
1080
+ }
1081
+ control = withIconButtonBadge(control, badge, stretch);
1082
+ if (!tooltip || label === undefined || label === "") return control;
1083
+ return (
1084
+ // A fragment, as on the button: the bubble says what `aria-label` already does.
1085
+ <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
1086
+ <>{control}</>
1087
+ </Tooltip>
1088
+ );
799
1089
  }
800
1090
 
801
- export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
1091
+ function IconButtonElement(
802
1092
  {
803
1093
  variant = "ghost",
804
1094
  size = "md",
@@ -821,9 +1111,14 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
821
1111
  style,
822
1112
  onClick,
823
1113
  onKeyDown,
1114
+ href: _href,
1115
+ renderLink: _renderLink,
1116
+ external: _external,
1117
+ replace: _replace,
1118
+ reloadDocument: _reloadDocument,
1119
+ ref,
824
1120
  ...rest
825
- },
826
- ref,
1121
+ }: IconButtonProps,
827
1122
  ) {
828
1123
  const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
829
1124
  const keep = disabledStyle === "keep";
@@ -839,7 +1134,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
839
1134
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
840
1135
  aria-label={rest["aria-label"] ?? label}
841
1136
  aria-pressed={pressed ?? rest["aria-pressed"]}
842
- style={toneColor !== undefined ? { ...style, ["--icon-button-tone" as string]: toneColor } : style}
1137
+ style={toneColorStyle(toneColor, style)}
843
1138
  onClick={(e) => {
844
1139
  if (stopPropagation) e.stopPropagation();
845
1140
  // `preventDefault` too: a locked submit must not submit its form.
@@ -856,18 +1151,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
856
1151
  onKeyDown?.(e);
857
1152
  }}
858
1153
  className={cn(
859
- ICON_BUTTON_BASE,
860
- stretch ? ICON_BUTTON_STRETCH_SIZES[size] : ICON_BUTTON_SIZES[size],
861
- glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
862
- // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
863
- variant === "overlay"
864
- ? ICON_BUTTON_OVERLAY
865
- : variant === "shutter"
866
- ? ICON_BUTTON_SHUTTER
867
- : buttonVariantClasses[variant],
868
- shape === "round" && "rounded-full",
869
- ICON_BUTTON_DISABLED_REST[variant],
870
- iconButtonToneClass(tone, quiet),
1154
+ iconButtonLook({ variant, size, stretch, glyphSize, shape, tone, quiet, toneColor }),
871
1155
  pressed && ICON_BUTTON_PRESSED,
872
1156
  // The disabled look for the focusable kind of disabled, as on Button.
873
1157
  locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
@@ -876,22 +1160,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
876
1160
  )}
877
1161
  />
878
1162
  );
879
- // The badge beside the button, not in it — see `badge`. The wrapper is only a
880
- // positioning box: the button inside it keeps its own focus ring and hit area.
881
- const control = hasContent(badge) ? (
882
- <span className={cn("relative inline-flex shrink-0", stretch && "self-stretch")}>
883
- {button}
884
- <span
885
- aria-hidden
886
- data-slot="icon-button-badge"
887
- className="pointer-events-none absolute -end-0.5 -bottom-0.5 inline-flex rounded-full bg-[var(--bg-surface)] [&_svg]:size-3"
888
- >
889
- {badge}
890
- </span>
891
- </span>
892
- ) : (
893
- button
894
- );
1163
+ const control = withIconButtonBadge(button, badge, stretch);
895
1164
  if (locked) {
896
1165
  return (
897
1166
  // Button's shape: the button and the hidden reason in a FRAGMENT, so Tooltip does
@@ -919,8 +1188,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
919
1188
  <>{control}</>
920
1189
  </Tooltip>
921
1190
  );
922
- });
923
- IconButton.displayName = "IconButton";
1191
+ }
924
1192
 
925
1193
  export const FIELD_BASE =
926
1194
  "block w-full rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-sm placeholder:text-[var(--text-placeholder)] focus:border-[var(--brand)] focus:ring-[var(--brand)] " +
@@ -94,7 +94,10 @@ export function FeedbackAttachmentField({
94
94
  pasteFrom?: RefObject<HTMLElement | null>;
95
95
  className?: string;
96
96
  }) {
97
- const [preview, setPreview] = useState<string | null>(null);
97
+ // The object URL is keyed to the file it was made for, so a stale one (from the
98
+ // previous file, or after `value` is cleared) is never shown — no reset needed.
99
+ const [previewFor, setPreviewFor] = useState<{ file: File; url: string } | null>(null);
100
+ const preview = value && previewFor?.file === value ? previewFor.url : null;
98
101
  const [capturing, setCapturing] = useState(false);
99
102
  const fileInputRef = useRef<HTMLInputElement>(null);
100
103
  // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
@@ -103,12 +106,13 @@ export function FeedbackAttachmentField({
103
106
 
104
107
  // Object-URL preview lifecycle (create on change, revoke on cleanup).
105
108
  useEffect(() => {
106
- if (!value) {
107
- setPreview(null);
108
- return;
109
- }
109
+ if (!value) return;
110
110
  const url = URL.createObjectURL(value);
111
- setPreview(url);
111
+ // The URL is an external resource that must be created and revoked in step
112
+ // with the effect (a render-time `createObjectURL` would leak, and StrictMode's
113
+ // remount would revoke a memoised one out from under the <img>).
114
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- publishing an external resource's handle
115
+ setPreviewFor({ file: value, url });
112
116
  return () => URL.revokeObjectURL(url);
113
117
  }, [value]);
114
118
 
@@ -161,7 +165,6 @@ export function FeedbackAttachmentField({
161
165
  };
162
166
  target.addEventListener("paste", onPaste);
163
167
  return () => target.removeEventListener("paste", onPaste);
164
- // eslint-disable-next-line react-hooks/exhaustive-deps -- takeImage reads `latest`, which is a ref
165
168
  }, [documentPaste, pasteFrom]);
166
169
  const listensElsewhere = documentPaste || pasteFrom !== undefined;
167
170
 
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Button, Input, PHONE_QUERY, Select, Textarea } from "../components/ui";
4
4
  import { Modal } from "../components/modal";
@@ -139,14 +139,17 @@ export function FeedbackDialog({
139
139
  // prop's (so a caller's `attachmentAdd` still wins) plus the resolved heading.
140
140
  const attachmentLabels = { ...labelsProp, attachment: labels.attachment };
141
141
 
142
- // Reset the form whenever the dialog is (re)opened.
143
- useEffect(() => {
142
+ // Reset the form whenever the dialog is (re)opened — during render, on the
143
+ // closed-to-open transition, so the old draft never paints for a frame.
144
+ const [wasOpen, setWasOpen] = useState(open);
145
+ if (open !== wasOpen) {
146
+ setWasOpen(open);
144
147
  if (open) {
145
148
  setTitle("");
146
149
  setBody("");
147
150
  setAttachment(null);
148
151
  }
149
- }, [open]);
152
+ }
150
153
 
151
154
  const canSubmit = !!title && !!body && !submitting;
152
155
  const trySubmit = () => {
@@ -360,6 +360,10 @@ export function FeedbackStatusTransitions({
360
360
  className?: string;
361
361
  }) {
362
362
  return (
363
+ // Not a control: it only stops clicks on the buttons inside from bubbling to
364
+ // the row. The buttons are the keyboard path, and Enter/Space on them fires
365
+ // their own click, which lands here the same way.
366
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- propagation guard around real buttons
363
367
  <div
364
368
  className={cn(variant === "icon" ? "flex items-center gap-0.5" : "flex flex-wrap gap-1.5", className)}
365
369
  // A status control inside a clickable row must not also open the row.
@@ -511,6 +515,9 @@ export function FeedbackNoteEditor({
511
515
  const root = useRef<HTMLDivElement>(null);
512
516
  const submit = () => onSave(draft, file);
513
517
  return (
518
+ // Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,
519
+ // which is what holds focus. The save button is the non-shortcut path.
520
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- delegated keyboard shortcut for the focused field
514
521
  <div
515
522
  ref={root}
516
523
  className="space-y-2"