@eifi1/ui-kit 0.15.5 → 0.16.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 (140) hide show
  1. package/README.md +17 -12
  2. package/dist/chart.d.ts +3 -3
  3. package/dist/components/amount-input.d.ts +10 -3
  4. package/dist/components/amount-input.js +165 -156
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/authed-image.d.ts +14 -3
  7. package/dist/components/authed-image.js +32 -23
  8. package/dist/components/authed-image.js.map +1 -1
  9. package/dist/components/button-group.d.ts +3 -3
  10. package/dist/components/calculator.d.ts +3 -3
  11. package/dist/components/copy-button.d.ts +3 -3
  12. package/dist/components/facing-pair.d.ts +3 -3
  13. package/dist/components/file-button.d.ts +3 -3
  14. package/dist/components/file-button.js +67 -31
  15. package/dist/components/file-button.js.map +1 -1
  16. package/dist/components/file-dropzone.d.ts +3 -3
  17. package/dist/components/form-actions.d.ts +4 -4
  18. package/dist/components/form-actions.js +18 -6
  19. package/dist/components/form-actions.js.map +1 -1
  20. package/dist/components/line-items.d.ts +53 -2
  21. package/dist/components/line-items.js +73 -7
  22. package/dist/components/line-items.js.map +1 -1
  23. package/dist/components/list.d.ts +99 -5
  24. package/dist/components/list.js +103 -38
  25. package/dist/components/list.js.map +1 -1
  26. package/dist/components/loading-state.d.ts +19 -3
  27. package/dist/components/loading-state.js +14 -3
  28. package/dist/components/loading-state.js.map +1 -1
  29. package/dist/components/money-field.d.ts +3 -3
  30. package/dist/components/number-field.d.ts +3 -3
  31. package/dist/components/number-input.d.ts +3 -3
  32. package/dist/components/number-input.js +4 -2
  33. package/dist/components/number-input.js.map +1 -1
  34. package/dist/components/numpad-sheet.d.ts +3 -3
  35. package/dist/components/passkeys-setting.d.ts +30 -10
  36. package/dist/components/passkeys-setting.js +19 -11
  37. package/dist/components/passkeys-setting.js.map +1 -1
  38. package/dist/components/progress-bar.d.ts +12 -3
  39. package/dist/components/progress-bar.js +21 -11
  40. package/dist/components/progress-bar.js.map +1 -1
  41. package/dist/components/series-chart.d.ts +3 -3
  42. package/dist/components/settings-fields.d.ts +3 -3
  43. package/dist/components/signed-amount.d.ts +28 -4
  44. package/dist/components/signed-amount.js +17 -8
  45. package/dist/components/signed-amount.js.map +1 -1
  46. package/dist/components/stat-tile.d.ts +31 -2
  47. package/dist/components/stat-tile.js +37 -6
  48. package/dist/components/stat-tile.js.map +1 -1
  49. package/dist/components/status-dot.d.ts +4 -1
  50. package/dist/components/status-dot.js +20 -1
  51. package/dist/components/status-dot.js.map +1 -1
  52. package/dist/components/swatch-picker.d.ts +3 -1
  53. package/dist/components/swatch-picker.js.map +1 -1
  54. package/dist/components/table.d.ts +18 -13
  55. package/dist/components/table.js +20 -5
  56. package/dist/components/table.js.map +1 -1
  57. package/dist/components/text-link.d.ts +3 -3
  58. package/dist/components/time-input.d.ts +3 -3
  59. package/dist/components/toggle-legend.d.ts +12 -1
  60. package/dist/components/toggle-legend.js +6 -2
  61. package/dist/components/toggle-legend.js.map +1 -1
  62. package/dist/components/ui.d.ts +3 -3
  63. package/dist/components/ui.js +149 -45
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/feedback/feedback-attachment.d.ts +1 -1
  66. package/dist/feedback/feedback-attachment.js +24 -11
  67. package/dist/feedback/feedback-attachment.js.map +1 -1
  68. package/dist/feedback/feedback-dialog.d.ts +1 -1
  69. package/dist/feedback/feedback-dialog.js +6 -5
  70. package/dist/feedback/feedback-dialog.js.map +1 -1
  71. package/dist/feedback/feedback-inbox.d.ts +1 -1
  72. package/dist/feedback/feedback-thread.d.ts +1 -1
  73. package/dist/{feedback-attachment-BYAHJMOr.d.ts → feedback-attachment-WFiXJ8We.d.ts} +21 -8
  74. package/dist/feedback.d.ts +1 -1
  75. package/dist/hooks/use-file-drop.d.ts +3 -3
  76. package/dist/i18n/defaults.d.ts +3 -3
  77. package/dist/i18n/kit-labels.d.ts +2 -2
  78. package/dist/i18n/locales/de-CH-informal.d.ts +3 -3
  79. package/dist/i18n/locales/de-CH.d.ts +3 -3
  80. package/dist/i18n/locales/de-informal.d.ts +3 -3
  81. package/dist/i18n/locales/de.d.ts +3 -3
  82. package/dist/i18n/locales/de.js +1 -1
  83. package/dist/i18n/locales/de.js.map +1 -1
  84. package/dist/i18n/locales/es.d.ts +3 -3
  85. package/dist/i18n/locales/es.js +1 -1
  86. package/dist/i18n/locales/es.js.map +1 -1
  87. package/dist/i18n/locales/fr.d.ts +3 -3
  88. package/dist/i18n/locales/fr.js +1 -1
  89. package/dist/i18n/locales/fr.js.map +1 -1
  90. package/dist/i18n/locales/hu.d.ts +3 -3
  91. package/dist/i18n/locales/hu.js +1 -1
  92. package/dist/i18n/locales/hu.js.map +1 -1
  93. package/dist/i18n/locales/it.d.ts +3 -3
  94. package/dist/i18n/locales/it.js +1 -1
  95. package/dist/i18n/locales/it.js.map +1 -1
  96. package/dist/i18n/locales/zh.d.ts +3 -3
  97. package/dist/i18n/locales/zh.js +1 -1
  98. package/dist/i18n/locales/zh.js.map +1 -1
  99. package/dist/index.d.ts +10 -10
  100. package/dist/{kit-labels-BD2F7sOz.d.ts → kit-labels-DE--LmCk.d.ts} +153 -12
  101. package/dist/lib/format.d.ts +18 -3
  102. package/dist/lib/format.js +2 -1
  103. package/dist/lib/format.js.map +1 -1
  104. package/dist/rhf/fields.d.ts +3 -3
  105. package/dist/rhf/form.d.ts +3 -3
  106. package/dist/rhf/line-items.js.map +1 -1
  107. package/dist/rhf.d.ts +3 -3
  108. package/dist/shell/app-shell.d.ts +3 -3
  109. package/dist/shell/top-bar-brand.d.ts +3 -3
  110. package/dist/shell.d.ts +3 -3
  111. package/dist/wizard/stepper-nav.d.ts +3 -3
  112. package/dist/wizard.d.ts +3 -3
  113. package/package.json +1 -1
  114. package/src/components/amount-input.tsx +39 -15
  115. package/src/components/authed-image.tsx +20 -2
  116. package/src/components/file-button.tsx +85 -27
  117. package/src/components/form-actions.tsx +86 -8
  118. package/src/components/line-items.tsx +131 -14
  119. package/src/components/list.tsx +240 -50
  120. package/src/components/loading-state.tsx +35 -4
  121. package/src/components/number-input.tsx +18 -2
  122. package/src/components/passkeys-setting.tsx +45 -18
  123. package/src/components/progress-bar.tsx +46 -21
  124. package/src/components/signed-amount.tsx +47 -8
  125. package/src/components/stat-tile.tsx +76 -6
  126. package/src/components/status-dot.tsx +31 -0
  127. package/src/components/swatch-picker.tsx +3 -1
  128. package/src/components/table.tsx +42 -15
  129. package/src/components/toggle-legend.tsx +21 -4
  130. package/src/components/ui.tsx +273 -63
  131. package/src/feedback/feedback-attachment.tsx +45 -14
  132. package/src/feedback/feedback-dialog.tsx +31 -16
  133. package/src/i18n/locales/de.ts +1 -1
  134. package/src/i18n/locales/es.ts +1 -1
  135. package/src/i18n/locales/fr.ts +1 -1
  136. package/src/i18n/locales/hu.ts +1 -1
  137. package/src/i18n/locales/it.ts +1 -1
  138. package/src/i18n/locales/zh.ts +1 -1
  139. package/src/lib/format.ts +24 -3
  140. package/src/rhf/line-items.tsx +6 -0
@@ -1,4 +1,4 @@
1
- import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
1
+ import { createContext, forwardRef, useContext, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
3
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
@@ -657,6 +657,26 @@ const ICON_BUTTON_GLYPH_SIZES: Record<IconButtonGlyphSize, string> = {
657
657
  28: "[&_svg]:size-7",
658
658
  };
659
659
 
660
+ // `pending`'s spinner, the size of the glyph it replaces (the `[&_svg]` rules above
661
+ // size only an svg, and the spinner is a bordered span).
662
+ const ICON_BUTTON_SPINNER: Record<IconButtonSize, string> = {
663
+ "2xl": "size-7",
664
+ xl: "size-6",
665
+ lg: "size-5",
666
+ md: "size-5",
667
+ sm: "size-5",
668
+ xs: "size-4",
669
+ "2xs": "size-3.5",
670
+ };
671
+ const ICON_BUTTON_SPINNER_GLYPH: Record<IconButtonGlyphSize, string> = {
672
+ 12: "size-3",
673
+ 14: "size-3.5",
674
+ 16: "size-4",
675
+ 20: "size-5",
676
+ 24: "size-6",
677
+ 28: "size-7",
678
+ };
679
+
660
680
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
661
681
  /** Any {@link ButtonVariant}; `overlay` — a round translucent disc for use over
662
682
  * an image (see `ICON_BUTTON_OVERLAY`); or `shutter` — a camera's release, a ringed
@@ -804,6 +824,36 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
804
824
  tooltipSide?: TooltipSide;
805
825
  /** Passed to the `label` tooltip's `portal`. Left out, Tooltip decides (see there). */
806
826
  tooltipPortal?: boolean;
827
+ /**
828
+ * Mount the `label` bubble only while it is up — {@link Tooltip}'s `lazy`. Default
829
+ * `true` since 0.16.0; `false` is the always-mounted in-place bubble of before.
830
+ *
831
+ * Why the label bubble can be lazy when Tooltip's default is not: this bubble
832
+ * describes nothing. It is wrapped round the button in a fragment precisely so that
833
+ * it is NOT the button's `aria-describedby` (it would repeat the button's own name),
834
+ * so there is no description for a lazy mount to take away — the name is the
835
+ * `aria-label` either way, read in browse mode and on focus alike. What the
836
+ * always-mounted bubble did do was sit in the DOM as a `role="tooltip"` node full of
837
+ * text: every IconButton in a row added one to `getAllByRole("tooltip")` and its label
838
+ * to the row's `textContent`, and two keksdose tests tripped over that (0.15.5 P6).
839
+ * The cost is the one {@link Tooltip}'s "Lazy in place" note names — a test that
840
+ * looked the bubble up without hovering now has to hover (or focus) first; pass
841
+ * `false` while it is migrated. A `disabledReason` bubble is unaffected: that one
842
+ * stays mounted as it was.
843
+ */
844
+ tooltipLazy?: boolean;
845
+ /**
846
+ * Busy — the action is in flight: the glyph is swapped for a spinner of the same
847
+ * size, the button is `aria-busy`, and clicks (and the Enter/Space and form
848
+ * submission they stand for) are swallowed, so a second press cannot start the
849
+ * action twice. {@link Button}'s `pending`, with the same contract: `aria-disabled`
850
+ * rather than `disabled`, so the button that was pressed keeps its focus. The name
851
+ * (`label` / `aria-label`) stays — `aria-busy` is what says it is working. A SWAP
852
+ * rather than Button's overlay: the box is square and fixed, so there is no width
853
+ * for the glyph to hold, and a spinner drawn over a glyph would be two marks in one
854
+ * 20px square.
855
+ */
856
+ pending?: boolean;
807
857
  /**
808
858
  * Why the action is not available — {@link Button}'s `disabledReason`, on the icon
809
859
  * button: keksdose's accounts page cannot hide an account with a balance or delete
@@ -869,6 +919,7 @@ export interface IconButtonLinkProps
869
919
  | "tooltip"
870
920
  | "tooltipSide"
871
921
  | "tooltipPortal"
922
+ | "tooltipLazy"
872
923
  > {
873
924
  href: string;
874
925
  /** See {@link ButtonLinkProps.renderLink}. */
@@ -887,6 +938,7 @@ export interface IconButtonLinkProps
887
938
  pressed?: never;
888
939
  disabledReason?: never;
889
940
  disabledStyle?: never;
941
+ pending?: never;
890
942
  }
891
943
 
892
944
  /**
@@ -998,6 +1050,7 @@ function IconButtonLink({
998
1050
  tooltip = true,
999
1051
  tooltipSide,
1000
1052
  tooltipPortal,
1053
+ tooltipLazy = true,
1001
1054
  glyphSize,
1002
1055
  badge,
1003
1056
  className,
@@ -1083,7 +1136,13 @@ function IconButtonLink({
1083
1136
  if (!tooltip || label === undefined || label === "") return control;
1084
1137
  return (
1085
1138
  // A fragment, as on the button: the bubble says what `aria-label` already does.
1086
- <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
1139
+ <Tooltip
1140
+ label={label}
1141
+ side={tooltipSide}
1142
+ portal={tooltipPortal}
1143
+ lazy={tooltipLazy}
1144
+ className={stretch ? "self-stretch" : undefined}
1145
+ >
1087
1146
  <>{control}</>
1088
1147
  </Tooltip>
1089
1148
  );
@@ -1104,7 +1163,9 @@ function IconButtonElement(
1104
1163
  tooltip = true,
1105
1164
  tooltipSide,
1106
1165
  tooltipPortal,
1166
+ tooltipLazy = true,
1107
1167
  disabledReason,
1168
+ pending,
1108
1169
  glyphSize,
1109
1170
  badge,
1110
1171
  disabledStyle = "dim",
@@ -1112,6 +1173,7 @@ function IconButtonElement(
1112
1173
  style,
1113
1174
  onClick,
1114
1175
  onKeyDown,
1176
+ children,
1115
1177
  href: _href,
1116
1178
  renderLink: _renderLink,
1117
1179
  external: _external,
@@ -1125,21 +1187,25 @@ function IconButtonElement(
1125
1187
  const keep = disabledStyle === "keep";
1126
1188
  const reasonId = useId();
1127
1189
  const locked = hasContent(disabledReason);
1190
+ // Pending is inert like a locked button — focusable, every activation swallowed —
1191
+ // without the locked look: the spinner is the state.
1192
+ const inert = locked || Boolean(pending);
1128
1193
  const ownDescribedBy = rest["aria-describedby"];
1129
1194
  const button = (
1130
1195
  <button
1131
1196
  ref={ref}
1132
1197
  {...rest}
1133
- disabled={locked ? undefined : rest.disabled}
1134
- aria-disabled={locked || rest["aria-disabled"]}
1198
+ disabled={inert ? undefined : rest.disabled}
1199
+ aria-disabled={inert || rest["aria-disabled"]}
1200
+ aria-busy={pending || rest["aria-busy"]}
1135
1201
  aria-describedby={locked ? (ownDescribedBy ? `${ownDescribedBy} ${reasonId}` : reasonId) : ownDescribedBy}
1136
1202
  aria-label={rest["aria-label"] ?? label}
1137
1203
  aria-pressed={pressed ?? rest["aria-pressed"]}
1138
1204
  style={toneColorStyle(toneColor, style)}
1139
1205
  onClick={(e) => {
1140
1206
  if (stopPropagation) e.stopPropagation();
1141
- // `preventDefault` too: a locked submit must not submit its form.
1142
- if (locked) {
1207
+ // `preventDefault` too: a locked (or pending) submit must not submit its form.
1208
+ if (inert) {
1143
1209
  e.preventDefault();
1144
1210
  return;
1145
1211
  }
@@ -1157,9 +1223,24 @@ function IconButtonElement(
1157
1223
  // The disabled look for the focusable kind of disabled, as on Button.
1158
1224
  locked && (keep ? "cursor-default" : "cursor-not-allowed opacity-50"),
1159
1225
  keep && "disabled:cursor-default disabled:opacity-100",
1226
+ pending && "cursor-progress",
1160
1227
  className,
1161
1228
  )}
1162
- />
1229
+ >
1230
+ {pending ? (
1231
+ // Decorative, as on Button: `aria-busy` says it. Sized to the glyph it stands
1232
+ // in for, in the glyph's colour, so the swap does not change the button's look.
1233
+ <Spinner
1234
+ label={null}
1235
+ className={cn(
1236
+ "border-current/30 border-t-current",
1237
+ glyphSize !== undefined ? ICON_BUTTON_SPINNER_GLYPH[glyphSize] : ICON_BUTTON_SPINNER[size],
1238
+ )}
1239
+ />
1240
+ ) : (
1241
+ children
1242
+ )}
1243
+ </button>
1163
1244
  );
1164
1245
  const control = withIconButtonBadge(button, badge, stretch);
1165
1246
  if (locked) {
@@ -1184,8 +1265,14 @@ function IconButtonElement(
1184
1265
  if (!tooltip || label === undefined || label === "") return control;
1185
1266
  return (
1186
1267
  // A fragment, so Tooltip leaves the button's description alone: the bubble says
1187
- // exactly what `aria-label` already does.
1188
- <Tooltip label={label} side={tooltipSide} portal={tooltipPortal} className={stretch ? "self-stretch" : undefined}>
1268
+ // exactly what `aria-label` already does. Lazy by default — see `tooltipLazy`.
1269
+ <Tooltip
1270
+ label={label}
1271
+ side={tooltipSide}
1272
+ portal={tooltipPortal}
1273
+ lazy={tooltipLazy}
1274
+ className={stretch ? "self-stretch" : undefined}
1275
+ >
1189
1276
  <>{control}</>
1190
1277
  </Tooltip>
1191
1278
  );
@@ -1887,7 +1974,9 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
1887
1974
  * TEXT (a string or a number) is a caption instead, and goes UNDER the field
1888
1975
  * like every other field's hint, attached through `aria-describedby`: the label
1889
1976
  * line is 11px of strip shared with the label, and a sentence placed there was
1890
- * set in the value's type on top of both the label and the value. */
1977
+ * set in the value's type on top of both the label and the value. On an
1978
+ * UNLABELLED Select (no label line) a FieldHint sits at the field's end edge,
1979
+ * outside the box. */
1891
1980
  hint?: ReactNode;
1892
1981
  /**
1893
1982
  * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
@@ -1973,38 +2062,59 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
1973
2062
  // whole rows, at any width.
1974
2063
  const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
1975
2064
  if (label === undefined) {
2065
+ const select = (
2066
+ <select
2067
+ ref={ref}
2068
+ // `id` is destructured out of the props to feed `fieldId`, and this branch
2069
+ // never put it back — so an UNLABELLED Select swallowed it and the
2070
+ // consumer's own `<label for="…">` pointed at nothing. The control stayed
2071
+ // in the tab order with no accessible name at all: reachable, and silent
2072
+ // when it was reached. Input and Textarea both forward it here; this is
2073
+ // the third one doing the same thing.
2074
+ id={id}
2075
+ size={nativeSize}
2076
+ {...rest}
2077
+ // OR-ed with the spread for the reason spelled out on Input's copy: this
2078
+ // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
2079
+ // to a Select — which is what a caller does when the validity is
2080
+ // `"grammar"` or `"spelling"`, or when the paint is not wanted — had the
2081
+ // attribute silently dropped. Input has never done that.
2082
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2083
+ aria-describedby={describedBy}
2084
+ className={cn(
2085
+ FIELD_BASE,
2086
+ dress,
2087
+ listBox && "py-0",
2088
+ small && SELECT_SM,
2089
+ selectClassName,
2090
+ isInvalid && FIELD_INVALID,
2091
+ )}
2092
+ >
2093
+ {children}
2094
+ </select>
2095
+ );
2096
+ // A FieldHint with no label line to ride (0.15.5 P8): an unlabelled Select — named
2097
+ // by `aria-label` or a `<label>` of the caller's — dropped a non-text `hint`
2098
+ // without a trace, so the "?" a caller passed never rendered and a test looking
2099
+ // for its button found none. It goes at the end of the field instead, outside the
2100
+ // box, as the label line's own hint goes at the end of the label.
2101
+ if (hasContent(hint) && !textHint) {
2102
+ return (
2103
+ <FieldGroup errorEl={below}>
2104
+ <div className={cn("flex items-center gap-1.5", className)}>
2105
+ <div className="relative min-w-0 flex-1">
2106
+ {select}
2107
+ {chevron}
2108
+ </div>
2109
+ <span className="flex shrink-0 items-center">{hint}</span>
2110
+ </div>
2111
+ </FieldGroup>
2112
+ );
2113
+ }
1976
2114
  return (
1977
2115
  <FieldGroup errorEl={below}>
1978
2116
  <div className={cn("relative", className)}>
1979
- <select
1980
- ref={ref}
1981
- // `id` is destructured out of the props to feed `fieldId`, and this branch
1982
- // never put it back — so an UNLABELLED Select swallowed it and the
1983
- // consumer's own `<label for="…">` pointed at nothing. The control stayed
1984
- // in the tab order with no accessible name at all: reachable, and silent
1985
- // when it was reached. Input and Textarea both forward it here; this is
1986
- // the third one doing the same thing.
1987
- id={id}
1988
- size={nativeSize}
1989
- {...rest}
1990
- // OR-ed with the spread for the reason spelled out on Input's copy: this
1991
- // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
1992
- // to a Select — which is what a caller does when the validity is
1993
- // `"grammar"` or `"spelling"`, or when the paint is not wanted — had the
1994
- // attribute silently dropped. Input has never done that.
1995
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
1996
- aria-describedby={describedBy}
1997
- className={cn(
1998
- FIELD_BASE,
1999
- dress,
2000
- listBox && "py-0",
2001
- small && SELECT_SM,
2002
- selectClassName,
2003
- isInvalid && FIELD_INVALID,
2004
- )}
2005
- >
2006
- {children}
2007
- </select>
2117
+ {select}
2008
2118
  {chevron}
2009
2119
  </div>
2010
2120
  </FieldGroup>
@@ -2124,6 +2234,13 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
2124
2234
  * Left out, the default card keeps no padding (its CardHeader / CardContent own the
2125
2235
  * rhythm) and `inset` keeps its `p-3`; passing it sets theirs too. A caller's `p-*`
2126
2236
  * in `className` still wins.
2237
+ *
2238
+ * Whenever the card carries a padding — this prop, or the `inset` / `outline` one —
2239
+ * CardHeader, CardContent and CardFooter drop their own `px-6` / `pt-6` / `pb-6`, so
2240
+ * the card's padding is the only one. Before 0.16 they kept it regardless and a
2241
+ * `padding="md"` card with parts was padded twice (40px a side); keksdose's settings
2242
+ * cards zeroed all three parts by hand (`<CardHeader className="p-0">`). A padding set
2243
+ * only through `className` cannot be seen from the parts: use the prop.
2127
2244
  */
2128
2245
  padding?: "none" | "sm" | "md";
2129
2246
  /**
@@ -2137,6 +2254,21 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
2137
2254
  * selectors.
2138
2255
  */
2139
2256
  tone?: CardTone;
2257
+ /**
2258
+ * How loud the `tone` frame is. `"strong"`: a 2px border in the tone's strong
2259
+ * colour — `--danger-border-strong` for `danger`, the tone's own text colour
2260
+ * (`--warning`, `--info`, `--success`) for the others, which have no
2261
+ * `-border-strong` token of their own. For the one tile on a page that guards
2262
+ * something destructive or security-relevant: keksdose's password / delete-account
2263
+ * tile (shared/components/password-danger-card.tsx) wrote `toneFrameClass(tone)` over
2264
+ * the Card by hand for the 2px rule, and `p-[15px]` under it so the content did not
2265
+ * move. Here the extra pixel comes off the padding the same way (`padding` or the
2266
+ * variant's own), so a strong card's content sits exactly where a soft one's does.
2267
+ *
2268
+ * Default `"soft"`: the 1px `-border` it has always been. No effect without `tone`,
2269
+ * nor on `inset`, which has no border to make louder (its tone is a fill).
2270
+ */
2271
+ toneStrength?: "soft" | "strong";
2140
2272
  }
2141
2273
 
2142
2274
  export type CardTone = "warning" | "danger" | "info" | "success";
@@ -2172,13 +2304,49 @@ const CARD_PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
2172
2304
  md: "p-4",
2173
2305
  };
2174
2306
 
2175
- export function Card({ className, children, flush, variant = "default", tone, padding, ...rest }: CardProps) {
2307
+ // The same padding one pixel short, for a 2px `toneStrength="strong"` frame: the
2308
+ // border grows inwards by that pixel, so the content stays where the 1px card had it.
2309
+ const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
2310
+ none: "p-0",
2311
+ sm: "p-[11px]",
2312
+ md: "p-[15px]",
2313
+ };
2314
+
2315
+ const CARD_TONES_STRONG: Record<CardTone, string> = {
2316
+ warning: "border-2 border-[var(--warning)]",
2317
+ danger: "border-2 border-[var(--danger-border-strong)]",
2318
+ info: "border-2 border-[var(--info)]",
2319
+ success: "border-2 border-[var(--success)]",
2320
+ };
2321
+
2322
+ /**
2323
+ * Whether the {@link Card} round the parts carries the padding itself — so CardHeader,
2324
+ * CardContent and CardFooter must not add theirs on top. `false` (no Card, or a default
2325
+ * card with no `padding`) keeps the parts' own `px-6` rhythm, as it always was.
2326
+ */
2327
+ const CardPaddedContext = createContext(false);
2328
+
2329
+ export function Card({
2330
+ className,
2331
+ children,
2332
+ flush,
2333
+ variant = "default",
2334
+ tone,
2335
+ toneStrength = "soft",
2336
+ padding,
2337
+ ...rest
2338
+ }: CardProps) {
2176
2339
  const toned = tone ? CARD_TONES[tone] : undefined;
2177
2340
  // An outline card is bordered like the default one, so a tone recolours its border.
2178
2341
  const bordered = variant !== "inset";
2342
+ const strong = toned !== undefined && bordered && toneStrength === "strong";
2343
+ // The padding the card itself carries: the prop, else the variant's own (`inset`
2344
+ // p-3, `outline` p-4). A default card with no prop carries none — its parts do.
2345
+ const ownPadding = padding ?? (variant === "inset" ? "sm" : variant === "outline" ? "md" : undefined);
2179
2346
  return (
2180
2347
  <div
2181
2348
  data-tone={tone}
2349
+ data-tone-strength={strong ? "strong" : undefined}
2182
2350
  {...rest}
2183
2351
  className={cn(
2184
2352
  variant === "inset"
@@ -2196,39 +2364,80 @@ export function Card({ className, children, flush, variant = "default", tone, pa
2196
2364
  : "rounded-lg border border-[var(--border)] shadow-sm",
2197
2365
  ),
2198
2366
  toned && (bordered ? toned.border : toned.fill),
2367
+ strong && tone && CARD_TONES_STRONG[tone],
2199
2368
  toned?.title,
2200
- padding && CARD_PADDING[padding],
2369
+ ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
2201
2370
  className,
2202
2371
  )}
2203
2372
  >
2204
- {children}
2373
+ <CardPaddedContext.Provider value={ownPadding !== undefined}>{children}</CardPaddedContext.Provider>
2205
2374
  </div>
2206
2375
  );
2207
2376
  }
2208
2377
 
2209
- // Composed shadcn-style Card sub-parts. `Card` stays padding-less (callers set
2210
- // their own padding via className), so these own the padding/rhythm. Token-driven
2211
- // so they re-skin with the palette. Use CardHeader → CardTitle/CardDescription
2212
- // (+ optional CardAction, top-right) → CardContent → CardFooter.
2378
+ // Composed shadcn-style Card sub-parts. A default `Card` with no `padding` stays
2379
+ // padding-less, so these own the padding/rhythm (`px-6`, the header's `pt-6`, the
2380
+ // last part's `pb-6`). A Card that carries a padding of its own — the `padding` prop,
2381
+ // or `inset` / `outline`, which have one built in — tells them so through context and
2382
+ // they add none on top (see CardPaddedContext). Token-driven so they re-skin with the
2383
+ // palette. Use CardHeader → CardTitle/CardDescription (+ optional CardAction,
2384
+ // top-right) → CardContent → CardFooter.
2213
2385
  /** The sub-parts add nothing to a `<div>`'s props — they are the SAME element with a
2214
- * `data-slot` and a padding rhythm — so each name is an alias rather than an empty
2386
+ * `data-slot` and a padding rhythm — so most names are aliases rather than an empty
2215
2387
  * interface pretending to be more. They exist so a consumer's own wrapper can say
2216
2388
  * `CardHeaderProps` instead of `ComponentProps<typeof CardHeader>`. */
2217
- export type CardHeaderProps = ComponentPropsWithoutRef<"div">;
2218
- export type CardTitleProps = ComponentPropsWithoutRef<"div">;
2389
+ export interface CardHeaderProps extends ComponentPropsWithoutRef<"div"> {
2390
+ /**
2391
+ * Below the `sm` breakpoint, put the {@link CardAction} UNDER the title and
2392
+ * description instead of beside them. For an action that is wider than an icon —
2393
+ * a "Set up two-factor" button, a checkbox and a filter — which, kept in the
2394
+ * top-end column on a 390px phone, squeezed the title into a ribbon two words wide
2395
+ * (keksdose's settings cards). Off by default: an icon action (the wizard summary's
2396
+ * pencil) is narrow enough to stay beside the title at every width.
2397
+ */
2398
+ stackAction?: boolean;
2399
+ }
2400
+ export type CardTitleLevel = "div" | "h2" | "h3" | "h4";
2401
+ export interface CardTitleProps extends ComponentPropsWithoutRef<"div"> {
2402
+ /**
2403
+ * The element the title is: `"div"` (default) or a heading level. Five of keksdose's
2404
+ * settings cards nested their own `<h2>` / `<h3>` INSIDE CardTitle to put the card in
2405
+ * the page's outline — a heading in a div that looks like a heading. The heading
2406
+ * takes the title's look unchanged (the reset leaves `h2`–`h4` at inherited size and
2407
+ * weight), so only the outline changes.
2408
+ */
2409
+ as?: CardTitleLevel;
2410
+ }
2219
2411
  export type CardDescriptionProps = ComponentPropsWithoutRef<"div">;
2220
2412
  export type CardActionProps = ComponentPropsWithoutRef<"div">;
2221
2413
  export type CardContentProps = ComponentPropsWithoutRef<"div">;
2222
2414
  export type CardFooterProps = ComponentPropsWithoutRef<"div">;
2223
2415
 
2224
- export function CardHeader({ className, ...props }: CardHeaderProps) {
2416
+ export function CardHeader({ className, stackAction = false, ...props }: CardHeaderProps) {
2417
+ const padded = useContext(CardPaddedContext);
2225
2418
  return (
2226
2419
  <div
2227
2420
  data-slot="card-header"
2228
2421
  className={cn(
2229
2422
  // Grid (not flex) so CardAction can occupy a top-right column; with no
2230
2423
  // action it collapses to one column and title/description stack.
2231
- "grid auto-rows-min items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
2424
+ //
2425
+ // The two-column switch sets a VARIABLE, not `grid-template-columns`. As
2426
+ // `has-data-[slot=card-action]:grid-cols-[1fr_auto]` it was two classes of
2427
+ // specificity (the class plus the attribute inside `:has()`), and a caller's
2428
+ // `max-sm:grid-cols-1` — one class inside a media query — lost to it at every
2429
+ // width, so the action could never be stacked on a phone from outside. Now the
2430
+ // property itself is set by a plain one-class utility that any `grid-cols-*`
2431
+ // variant of the caller's outranks by coming later in the sheet; the `:has()`
2432
+ // rule only picks the value. The base `1fr` resets the variable so a header
2433
+ // never inherits an ancestor header's two columns.
2434
+ "grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
2435
+ padded ? "px-0 pt-0" : "px-6 pt-6",
2436
+ // One column under `sm`, and the action back into the flow (DOM order, so
2437
+ // after the description when it is written after it), at the start edge.
2438
+ // The descendant selector outranks CardAction's own placement classes.
2439
+ stackAction &&
2440
+ "max-sm:grid-cols-1 max-sm:[&>[data-slot=card-action]]:col-start-1 max-sm:[&>[data-slot=card-action]]:row-span-1 max-sm:[&>[data-slot=card-action]]:row-start-auto max-sm:[&>[data-slot=card-action]]:justify-self-start",
2232
2441
  className,
2233
2442
  )}
2234
2443
  {...props}
@@ -2236,21 +2445,19 @@ export function CardHeader({ className, ...props }: CardHeaderProps) {
2236
2445
  );
2237
2446
  }
2238
2447
 
2239
- export function CardTitle({ className, ...props }: CardTitleProps) {
2240
- return (
2241
- <div
2242
- data-slot="card-title"
2243
- className={cn("font-semibold leading-none", className)}
2244
- {...props}
2245
- />
2246
- );
2448
+ export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
2449
+ return <Tag data-slot="card-title" className={cn("font-semibold leading-none", className)} {...props} />;
2247
2450
  }
2248
2451
 
2249
2452
  export function CardDescription({ className, ...props }: CardDescriptionProps) {
2250
2453
  return (
2251
2454
  <div
2252
2455
  data-slot="card-description"
2253
- className={cn("text-sm text-[var(--money-neutral)]", className)}
2456
+ // `--text-muted`, the kit's secondary-line colour (StatTile's description, the
2457
+ // field captions). It was `--money-neutral`, a MONEY token that happened to be
2458
+ // a similar grey — and a palette that re-tunes the money colours moved every
2459
+ // card's description with them.
2460
+ className={cn("text-sm text-[var(--text-muted)]", className)}
2254
2461
  {...props}
2255
2462
  />
2256
2463
  );
@@ -2267,19 +2474,22 @@ export function CardAction({ className, ...props }: CardActionProps) {
2267
2474
  }
2268
2475
 
2269
2476
  export function CardContent({ className, ...props }: CardContentProps) {
2477
+ const padded = useContext(CardPaddedContext);
2270
2478
  // `last:pb-6`, not a plain `pb-6`: CardHeader owns the top padding and CardFooter
2271
2479
  // owns the bottom one, so a card WITHOUT a footer had nothing closing it off and
2272
2480
  // its last field sat flush against the card edge (kastlan feedback: the language
2273
2481
  // input touching the card bottom on /profile). Scoping to `:last-child` fixes that
2274
- // case and leaves a footered card's rhythm exactly as it was.
2275
- return <div data-slot="card-content" className={cn("px-6 last:pb-6", className)} {...props} />;
2482
+ // case and leaves a footered card's rhythm exactly as it was. Inside a padded Card
2483
+ // the card closes itself off, so there is nothing to add.
2484
+ return <div data-slot="card-content" className={cn(!padded && "px-6 last:pb-6", className)} {...props} />;
2276
2485
  }
2277
2486
 
2278
2487
  export function CardFooter({ className, ...props }: CardFooterProps) {
2488
+ const padded = useContext(CardPaddedContext);
2279
2489
  return (
2280
2490
  <div
2281
2491
  data-slot="card-footer"
2282
- className={cn("flex items-center px-6 pb-6", className)}
2492
+ className={cn("flex items-center", !padded && "px-6 pb-6", className)}
2283
2493
  {...props}
2284
2494
  />
2285
2495
  );
@@ -1,5 +1,5 @@
1
1
  import { cn } from "../lib/cn";
2
- import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type RefObject } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type RefObject } from "react";
3
3
  import { Camera, FileText, Paperclip, X } from "lucide-react";
4
4
  import { Button } from "../components/ui";
5
5
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
@@ -23,15 +23,21 @@ export const DEFAULT_MAX_ATTACHMENTS = 5;
23
23
  * that relied on its provider got "Capture screenshot" under a German form.
24
24
  *
25
25
  * The three keys added in 0.15.5 for `multiple` mode are optional, so a complete
26
- * `UiKitLabels` typed before it still compiles; they fall back to English.
26
+ * `UiKitLabels` typed before it still compiles; they fall back to English. So is
27
+ * `attachmentScreenshot` (0.16.0).
27
28
  */
28
29
  export interface FeedbackAttachmentFieldLabels {
29
30
  attachmentAdd: string;
30
31
  attachmentCapture: string;
31
32
  attachmentPaste: string;
32
33
  attachmentRemove: string;
33
- /** `multiple` mode: the accessible name of the list of chosen files. */
34
+ /** `multiple` mode: the accessible name of the list of chosen files — and, since
35
+ * 0.16.0, the heading `<FeedbackDialog attachments="multiple">` puts over it. */
34
36
  attachmentList?: string;
37
+ /** `multiple` mode (0.16.0): what the screenshot slot's chip is called — "Screenshot"
38
+ * rather than the capture's file name ("screenshot.webp"), which says nothing a
39
+ * reporter chose. The file name stays as the chip's second line. */
40
+ attachmentScreenshot?: string;
35
41
  /** `multiple` mode: one chip's remove button — "Remove photo.jpg". Named per file,
36
42
  * because five buttons all called "Remove attachment" say nothing about which. */
37
43
  attachmentRemoveFile?: (name: string) => string;
@@ -45,6 +51,7 @@ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels =
45
51
  attachmentPaste: "…or paste a screenshot from the clipboard.",
46
52
  attachmentRemove: "Remove attachment",
47
53
  attachmentList: "Attachments",
54
+ attachmentScreenshot: "Screenshot",
48
55
  attachmentRemoveFile: (name) => `Remove ${name}`,
49
56
  attachmentLimit: (max) =>
50
57
  `Up to ${max} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
@@ -300,6 +307,23 @@ function MultipleField({
300
307
  const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
301
308
  const removeLabel = text.attachmentRemoveFile ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentRemoveFile!;
302
309
  const limitLabel = text.attachmentLimit ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentLimit!;
310
+ const screenshotLabel = text.attachmentScreenshot ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentScreenshot!;
311
+
312
+ // What an add or a remove builds on: the `value` prop, plus whatever this field has
313
+ // already handed to `onChange` since the parent last rendered. The field is
314
+ // controlled, so between an `onChange` and the re-render that brings it back, `value`
315
+ // is stale — and two adds in that gap (two pastes, a paste and a pick, delivered by
316
+ // native listeners whose updates React batches) each spread the SAME old list, and
317
+ // the second dropped the first. Re-synced to the prop after every commit, so a
318
+ // parent that refuses a change (or edits the list itself) is what wins.
319
+ const latest = useRef(value);
320
+ useLayoutEffect(() => {
321
+ latest.current = value;
322
+ });
323
+ const commit = (next: File[]) => {
324
+ latest.current = next;
325
+ onChange(next);
326
+ };
303
327
 
304
328
  const hasSlot = onScreenshotChange !== undefined;
305
329
  const room = Math.max(0, max - value.length);
@@ -308,8 +332,9 @@ function MultipleField({
308
332
  const canCapture = !!onCaptureScreenshot && (hasSlot ? !screenshot : room > 0);
309
333
 
310
334
  const add = (incoming: File[], pasted = false) => {
335
+ const current = latest.current;
311
336
  const accepted: File[] = [];
312
- const taken = new Set([...value, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
337
+ const taken = new Set([...current, ...(screenshot ? [screenshot] : [])].map((f) => f.name));
313
338
  for (const raw of incoming) {
314
339
  const problem = rejection(raw, accept, maxBytes);
315
340
  if (problem) {
@@ -322,12 +347,13 @@ function MultipleField({
322
347
  const file = pasted ? renamed(raw, uniqueName(pastedName(raw.type), taken)) : raw;
323
348
  taken.add(file.name);
324
349
  // The same File object twice is one file chosen twice, not two.
325
- if (!value.includes(file)) accepted.push(file);
350
+ if (!current.includes(file)) accepted.push(file);
326
351
  }
327
352
  if (accepted.length === 0) return;
328
- if (accepted.length > room) onError?.("count");
329
- const kept = accepted.slice(0, room);
330
- if (kept.length > 0) onChange([...value, ...kept]);
353
+ const space = Math.max(0, max - current.length);
354
+ if (accepted.length > space) onError?.("count");
355
+ const kept = accepted.slice(0, space);
356
+ if (kept.length > 0) commit([...current, ...kept]);
331
357
  };
332
358
 
333
359
  const onPaste = usePaste((images) => add(images, true), documentPaste, pasteFrom);
@@ -360,14 +386,15 @@ function MultipleField({
360
386
  target?.focus();
361
387
  }, [value, screenshot]);
362
388
 
363
- const chips: Array<{ file: File; screenshot: boolean; remove: () => void }> = [
389
+ const chips: Array<{ file: File; screenshot: boolean; name: string; remove: () => void }> = [
364
390
  ...(hasSlot && screenshot
365
- ? [{ file: screenshot, screenshot: true, remove: () => onScreenshotChange(null) }]
391
+ ? [{ file: screenshot, screenshot: true, name: screenshotLabel, remove: () => onScreenshotChange(null) }]
366
392
  : []),
367
393
  ...value.map((file) => ({
368
394
  file,
369
395
  screenshot: false,
370
- remove: () => onChange(value.filter((f) => f !== file)),
396
+ name: file.name,
397
+ remove: () => commit(latest.current.filter((f) => f !== file)),
371
398
  })),
372
399
  ];
373
400
 
@@ -386,8 +413,12 @@ function MultipleField({
386
413
  >
387
414
  <ChipPreview file={chip.file} screenshot={chip.screenshot} />
388
415
  <div className="min-w-0 flex-1">
389
- <div className="truncate text-sm text-[var(--text-secondary)]">{chip.file.name}</div>
390
- <div className="text-xs text-[var(--text-muted)]">{fileText.size(chip.file.size)}</div>
416
+ <div className="truncate text-sm text-[var(--text-secondary)]">{chip.name}</div>
417
+ <div className="truncate text-xs text-[var(--text-muted)]">
418
+ {/* The screenshot's file name moves down here: still there for the
419
+ reporter who wants to know what will be sent, not its title. */}
420
+ {chip.screenshot ? `${chip.file.name} · ${fileText.size(chip.file.size)}` : fileText.size(chip.file.size)}
421
+ </div>
391
422
  </div>
392
423
  <button
393
424
  type="button"
@@ -396,7 +427,7 @@ function MultipleField({
396
427
  pendingFocus.current = index;
397
428
  chip.remove();
398
429
  }}
399
- aria-label={removeLabel(chip.file.name)}
430
+ aria-label={removeLabel(chip.name)}
400
431
  className="shrink-0 rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
401
432
  >
402
433
  <X className="size-4" />