@eifi1/ui-kit 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +83 -4
  2. package/dist/chart.d.ts +13 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +13 -1
  10. package/dist/components/button-group.d.ts +13 -1
  11. package/dist/components/calculator.d.ts +13 -1
  12. package/dist/components/chip.d.ts +25 -1
  13. package/dist/components/chip.js +143 -6
  14. package/dist/components/chip.js.map +1 -1
  15. package/dist/components/confirm-dialog.d.ts +42 -1
  16. package/dist/components/confirm-dialog.js +36 -5
  17. package/dist/components/confirm-dialog.js.map +1 -1
  18. package/dist/components/copy-button.d.ts +13 -1
  19. package/dist/components/danger-confirm.d.ts +55 -4
  20. package/dist/components/danger-confirm.js +67 -30
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/data-table-cells.d.ts +1 -1
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table.d.ts +1 -1
  26. package/dist/components/data-table.js +2 -0
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/components/facing-pair.d.ts +13 -1
  29. package/dist/components/file-button.d.ts +13 -1
  30. package/dist/components/file-dropzone.d.ts +13 -1
  31. package/dist/components/floating-panel.d.ts +4 -1
  32. package/dist/components/floating-panel.js +15 -1
  33. package/dist/components/floating-panel.js.map +1 -1
  34. package/dist/components/form-actions.d.ts +13 -1
  35. package/dist/components/form-actions.js +3 -0
  36. package/dist/components/form-actions.js.map +1 -1
  37. package/dist/components/legal.d.ts +84 -0
  38. package/dist/components/legal.js +55 -0
  39. package/dist/components/legal.js.map +1 -0
  40. package/dist/components/money-field.d.ts +13 -1
  41. package/dist/components/number-field.d.ts +14 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +13 -1
  45. package/dist/components/numpad-sheet.d.ts +13 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +15 -1
  50. package/dist/components/progress-bar.js +8 -1
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +13 -1
  56. package/dist/components/server-wake.d.ts +60 -0
  57. package/dist/components/server-wake.js +79 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +13 -1
  60. package/dist/components/share-card.d.ts +64 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/status-dot.d.ts +2 -0
  64. package/dist/components/text-link.d.ts +13 -1
  65. package/dist/components/time-input.d.ts +13 -1
  66. package/dist/components/toast.d.ts +10 -1
  67. package/dist/components/toast.js +42 -2
  68. package/dist/components/toast.js.map +1 -1
  69. package/dist/components/toggle-group.js +5 -2
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/translation-review-editor.d.ts +56 -0
  72. package/dist/components/translation-review-editor.js +159 -0
  73. package/dist/components/translation-review-editor.js.map +1 -0
  74. package/dist/components/translation-review-labels.d.ts +92 -0
  75. package/dist/components/translation-review-labels.js +67 -0
  76. package/dist/components/translation-review-labels.js.map +1 -0
  77. package/dist/components/translation-review-parts.d.ts +97 -0
  78. package/dist/components/translation-review-parts.js +163 -0
  79. package/dist/components/translation-review-parts.js.map +1 -0
  80. package/dist/components/translation-review.d.ts +94 -0
  81. package/dist/components/translation-review.js +344 -0
  82. package/dist/components/translation-review.js.map +1 -0
  83. package/dist/components/ui.d.ts +13 -1
  84. package/dist/components/ui.js +43 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/components/use-table-state.d.ts +1 -1
  87. package/dist/components/user-avatar.d.ts +2 -0
  88. package/dist/components/write-lock.d.ts +68 -0
  89. package/dist/components/write-lock.js +33 -0
  90. package/dist/components/write-lock.js.map +1 -0
  91. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  92. package/dist/data-table.d.ts +1 -1
  93. package/dist/hooks/use-file-drop.d.ts +13 -1
  94. package/dist/hooks/use-overlay-history.js +16 -0
  95. package/dist/hooks/use-overlay-history.js.map +1 -1
  96. package/dist/i18n/defaults.d.ts +13 -1
  97. package/dist/i18n/defaults.js +15 -1
  98. package/dist/i18n/defaults.js.map +1 -1
  99. package/dist/i18n/german.d.ts +81 -0
  100. package/dist/i18n/{locales/de.js → german.js} +110 -9
  101. package/dist/i18n/german.js.map +1 -0
  102. package/dist/i18n/kit-labels.d.ts +13 -1
  103. package/dist/i18n/kit-labels.js +5 -0
  104. package/dist/i18n/kit-labels.js.map +1 -1
  105. package/dist/i18n/languages.d.ts +243 -0
  106. package/dist/i18n/languages.js +70 -0
  107. package/dist/i18n/languages.js.map +1 -0
  108. package/dist/i18n/locales/de-CH.d.ts +26 -8
  109. package/dist/i18n/locales/de-CH.js +7 -3
  110. package/dist/i18n/locales/de-CH.js.map +1 -1
  111. package/dist/i18n/locales/en.d.ts +13 -1
  112. package/dist/i18n/locales/en.js +8 -0
  113. package/dist/i18n/locales/en.js.map +1 -1
  114. package/dist/i18n/locales/es.d.ts +13 -1
  115. package/dist/i18n/locales/es.js +127 -27
  116. package/dist/i18n/locales/es.js.map +1 -1
  117. package/dist/i18n/locales/fr.d.ts +13 -1
  118. package/dist/i18n/locales/fr.js +101 -1
  119. package/dist/i18n/locales/fr.js.map +1 -1
  120. package/dist/i18n/locales/hu.d.ts +13 -1
  121. package/dist/i18n/locales/hu.js +105 -1
  122. package/dist/i18n/locales/hu.js.map +1 -1
  123. package/dist/i18n/locales/it.d.ts +13 -1
  124. package/dist/i18n/locales/it.js +134 -34
  125. package/dist/i18n/locales/it.js.map +1 -1
  126. package/dist/i18n/locales/zh.d.ts +13 -1
  127. package/dist/i18n/locales/zh.js +101 -1
  128. package/dist/i18n/locales/zh.js.map +1 -1
  129. package/dist/i18n/review.d.ts +102 -0
  130. package/dist/i18n/review.js +158 -0
  131. package/dist/i18n/review.js.map +1 -0
  132. package/dist/i18n/swiss.d.ts +3 -3
  133. package/dist/i18n/swiss.js.map +1 -1
  134. package/dist/index.d.ts +17 -4
  135. package/dist/index.js +97 -1
  136. package/dist/index.js.map +1 -1
  137. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
  138. package/dist/lib/floating-stack.d.ts +31 -0
  139. package/dist/lib/floating-stack.js +42 -0
  140. package/dist/lib/floating-stack.js.map +1 -0
  141. package/dist/lib/server-wake.d.ts +146 -0
  142. package/dist/lib/server-wake.js +122 -0
  143. package/dist/lib/server-wake.js.map +1 -0
  144. package/dist/lib/translation-review.d.ts +232 -0
  145. package/dist/lib/translation-review.js +194 -0
  146. package/dist/lib/translation-review.js.map +1 -0
  147. package/dist/rhf/fields.d.ts +13 -1
  148. package/dist/rhf/form.d.ts +13 -1
  149. package/dist/rhf.d.ts +13 -1
  150. package/dist/search/global-search.d.ts +3 -1
  151. package/dist/search/global-search.js.map +1 -1
  152. package/dist/shell/app-shell.d.ts +13 -1
  153. package/dist/shell/top-bar-brand.d.ts +13 -1
  154. package/dist/shell/topbar-action-menu.d.ts +2 -0
  155. package/dist/shell.d.ts +13 -1
  156. package/dist/wizard/stepper-nav.d.ts +13 -1
  157. package/dist/wizard.d.ts +13 -1
  158. package/package.json +1 -1
  159. package/src/components/account-chips.tsx +302 -0
  160. package/src/components/account-settings-labels.ts +16 -2
  161. package/src/components/chip.tsx +239 -6
  162. package/src/components/confirm-dialog.tsx +82 -7
  163. package/src/components/danger-confirm.tsx +118 -33
  164. package/src/components/data-table.tsx +20 -0
  165. package/src/components/floating-panel.tsx +19 -1
  166. package/src/components/form-actions.tsx +22 -3
  167. package/src/components/legal.tsx +159 -0
  168. package/src/components/number-field.tsx +32 -26
  169. package/src/components/passkeys-setting.tsx +68 -7
  170. package/src/components/progress-bar.tsx +21 -1
  171. package/src/components/reauth-dialog.tsx +273 -0
  172. package/src/components/server-wake.tsx +156 -0
  173. package/src/components/share-card.tsx +615 -0
  174. package/src/components/toast.tsx +72 -2
  175. package/src/components/toggle-group.tsx +5 -2
  176. package/src/components/translation-review-editor.tsx +261 -0
  177. package/src/components/translation-review-labels.ts +152 -0
  178. package/src/components/translation-review-parts.tsx +269 -0
  179. package/src/components/translation-review.tsx +502 -0
  180. package/src/components/ui.tsx +89 -32
  181. package/src/components/write-lock.tsx +96 -0
  182. package/src/hooks/use-overlay-history.ts +51 -0
  183. package/src/i18n/defaults.ts +14 -0
  184. package/src/i18n/{locales/de.ts → german.ts} +120 -13
  185. package/src/i18n/kit-labels.tsx +29 -2
  186. package/src/i18n/languages.ts +285 -0
  187. package/src/i18n/locales/de-CH.ts +17 -8
  188. package/src/i18n/locales/en.ts +8 -0
  189. package/src/i18n/locales/es.ts +128 -26
  190. package/src/i18n/locales/fr.ts +103 -0
  191. package/src/i18n/locales/hu.ts +107 -0
  192. package/src/i18n/locales/it.ts +135 -33
  193. package/src/i18n/locales/zh.ts +102 -0
  194. package/src/i18n/review.ts +193 -0
  195. package/src/i18n/swiss.ts +3 -3
  196. package/src/index.ts +86 -1
  197. package/src/lib/floating-stack.ts +66 -0
  198. package/src/lib/server-wake.ts +291 -0
  199. package/src/lib/translation-review.ts +465 -0
  200. package/src/search/global-search.tsx +3 -1
  201. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  202. package/dist/i18n/locales/de-CH-informal.js +0 -8
  203. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  204. package/dist/i18n/locales/de-informal.d.ts +0 -82
  205. package/dist/i18n/locales/de-informal.js +0 -37
  206. package/dist/i18n/locales/de-informal.js.map +0 -1
  207. package/dist/i18n/locales/de.d.ts +0 -66
  208. package/dist/i18n/locales/de.js.map +0 -1
  209. package/src/i18n/locales/de-CH-informal.ts +0 -10
  210. package/src/i18n/locales/de-informal.ts +0 -61
@@ -0,0 +1,302 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { LucideIcon } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import { formatDate, formatRelativeTime, toDate, EMPTY_FORMATTED_VALUE } from "../lib/format";
5
+ import type { DateInput, FormatDateStyle, FormatRelativeTimeOptions } from "../lib/format";
6
+ import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
7
+ import { Chip } from "./chip";
8
+ import type { ChipShape, ChipSize, ChipTone, ChipVariant } from "./chip";
9
+ import { Tooltip } from "./tooltip";
10
+ import type { DataTableColumn } from "./data-table";
11
+
12
+ /**
13
+ * The parts an admin roster repeats (0.18.0) — and only those.
14
+ *
15
+ * Kurvenschmiede (features/admin/users-panel.tsx, allowlist-panel.tsx) and keksdose
16
+ * (features/admin/users-panel.tsx, the allowlist card in admin-page.tsx) each built the
17
+ * same table: name over email, a role, whether the account is active, created, last
18
+ * login, a row of actions, and an allowlist with a note. What they share is not the
19
+ * table — the columns around it (keksdose's plan and privacy mode, Kurvenschmiede's
20
+ * owned-work counts and its role SELECT), the actions and every rule about who may do
21
+ * what are each app's own. What they share is the vocabulary: both spelled the same
22
+ * square, uppercase status chip by hand (keksdose's `Badge` with its own tone map,
23
+ * Kurvenschmiede's inline `<Chip size="xs" shape="square" caps>`), and both formatted
24
+ * their two date columns by hand (`toLocaleDateString` in one, a "3 days ago" helper
25
+ * in the other). So: {@link RoleChip}, {@link AccountStateChip} and
26
+ * {@link dateColumn}, and the roster stays the app's `DataTable`.
27
+ */
28
+
29
+ /** The chip look both rosters use: a square, uppercase status tag. Each can be changed. */
30
+ interface AccountChipLook {
31
+ /** Default `sm` — keksdose's roster; Kurvenschmiede's denser lists pass `xs`. */
32
+ size?: ChipSize;
33
+ /** Default `soft`. `dot` for a column of them that should not outshout the names. */
34
+ variant?: ChipVariant;
35
+ /** Default `square`: a chip in a table cell, beside square controls. */
36
+ shape?: ChipShape;
37
+ /** Default true — the status-badge type. */
38
+ caps?: boolean;
39
+ className?: string;
40
+ [key: `data-${string}`]: string | number | boolean | undefined;
41
+ }
42
+
43
+ /* ── RoleChip ──────────────────────────────────────────────────────────────── */
44
+
45
+ /** How one role is drawn. `label` is the app's, translated. */
46
+ export interface RoleDefinition {
47
+ label: string;
48
+ tone?: ChipTone;
49
+ /** A lucide component or an element — Kurvenschmiede marks its admins with a shield. */
50
+ icon?: LucideIcon | ReactElement;
51
+ }
52
+
53
+ /**
54
+ * Every role an app has, keyed by the value its API sends. Annotate the app's table
55
+ * with it (`const ROLES: RoleVocabulary<UserRole> = {…}`) and TypeScript asks for a
56
+ * label when a role is added.
57
+ */
58
+ export type RoleVocabulary<R extends string = string> = Readonly<Record<R, RoleDefinition>>;
59
+
60
+ export interface RoleChipProps<R extends string = string> extends AccountChipLook {
61
+ /**
62
+ * The role key as the API sends it. `null` / `undefined` renders nothing. Named
63
+ * `value`, not `role`, because `role` on a JSX element is the ARIA role — the lint
64
+ * rule and every reader would take it for one.
65
+ */
66
+ value: R | null | undefined;
67
+ /** What each key is called and how it is toned. A key missing here is shown as the
68
+ * key itself, in `neutral`: an unknown role is still a fact the admin should see. */
69
+ roles: Readonly<Partial<Record<R, RoleDefinition>>>;
70
+ }
71
+
72
+ /**
73
+ * A role as a chip: the API's key looked up in the app's {@link RoleVocabulary}.
74
+ *
75
+ * The vocabulary is a prop and not kit English, because the roles are the app's —
76
+ * Kurvenschmiede has admin, member and customer, keksdose admin and user, kastlan its
77
+ * own — and so are their names. Carries `data-role` with the key, for a test or a
78
+ * style hook.
79
+ */
80
+ export function RoleChip<R extends string = string>({
81
+ value: role,
82
+ roles,
83
+ size = "sm",
84
+ variant,
85
+ shape = "square",
86
+ caps = true,
87
+ className,
88
+ ...rest
89
+ }: RoleChipProps<R>) {
90
+ if (role === null || role === undefined || role === "") return null;
91
+ const def = roles[role];
92
+ return (
93
+ <Chip
94
+ {...rest}
95
+ data-role={role}
96
+ tone={def?.tone ?? "neutral"}
97
+ icon={def?.icon}
98
+ size={size}
99
+ variant={variant}
100
+ shape={shape}
101
+ caps={caps}
102
+ className={className}
103
+ >
104
+ {def?.label ?? role}
105
+ </Chip>
106
+ );
107
+ }
108
+
109
+ /* ── AccountStateChip ──────────────────────────────────────────────────────── */
110
+
111
+ /**
112
+ * The states both rosters show, each as the app's own flag maps onto it:
113
+ *
114
+ * - `active` / `inactive` — the account may sign in, or an admin switched it off
115
+ * (both apps' `is_active`).
116
+ * - `invited` — on the allowlist and mailed, not registered yet (an allowlist row).
117
+ * - `registered` — an allowlist row whose address has an account now (Kurvenschmiede).
118
+ * - `unverified` — the email address is not confirmed (keksdose).
119
+ * - `passwordChange` — must set a new password at the next sign-in (keksdose).
120
+ *
121
+ * An account can be several at once (inactive AND unverified): render one chip per
122
+ * state, as keksdose's state column does.
123
+ */
124
+ export type AccountState = "active" | "inactive" | "invited" | "registered" | "unverified" | "passwordChange";
125
+
126
+ export type AccountStateLabels = Record<AccountState, string>;
127
+
128
+ export const DEFAULT_ACCOUNT_STATE_LABELS: AccountStateLabels = {
129
+ active: "Active",
130
+ inactive: "Inactive",
131
+ invited: "Invited",
132
+ registered: "Registered",
133
+ unverified: "Unverified",
134
+ passwordChange: "Must change password",
135
+ };
136
+
137
+ /** The default tone of each state: green for the normal case, red for switched off,
138
+ * amber for something the user still has to do, blue for an open invitation. */
139
+ export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
140
+ active: "success",
141
+ inactive: "danger",
142
+ invited: "info",
143
+ registered: "neutral",
144
+ unverified: "warning",
145
+ passwordChange: "warning",
146
+ };
147
+
148
+ export interface AccountStateChipProps extends AccountChipLook {
149
+ state: AccountState;
150
+ /** Over {@link ACCOUNT_STATE_TONES} — Kurvenschmiede draws `inactive` neutral. */
151
+ tone?: ChipTone;
152
+ /** Over the label, for an app whose word for the state is its own ("Deactivated"). */
153
+ children?: ReactNode;
154
+ /** Prop > `<UiKitProvider labels={{ accountState }}>` > English. */
155
+ labels?: Partial<AccountStateLabels>;
156
+ }
157
+
158
+ /**
159
+ * An account's state as a chip, in the kit's words and tones. Carries `data-state`
160
+ * with the key.
161
+ */
162
+ export function AccountStateChip({
163
+ state,
164
+ tone,
165
+ children,
166
+ labels: labelsProp,
167
+ size = "sm",
168
+ variant,
169
+ shape = "square",
170
+ caps = true,
171
+ className,
172
+ ...rest
173
+ }: AccountStateChipProps) {
174
+ // An `undefined` in the prop is skipped (the provider's merge does that), so an
175
+ // optional value cannot blank a default.
176
+ const labels = useKitLabels("accountState", DEFAULT_ACCOUNT_STATE_LABELS, labelsProp);
177
+ return (
178
+ <Chip
179
+ {...rest}
180
+ data-state={state}
181
+ tone={tone ?? ACCOUNT_STATE_TONES[state]}
182
+ size={size}
183
+ variant={variant}
184
+ shape={shape}
185
+ caps={caps}
186
+ className={className}
187
+ >
188
+ {children ?? labels[state]}
189
+ </Chip>
190
+ );
191
+ }
192
+
193
+ /* ── Date columns ──────────────────────────────────────────────────────────── */
194
+
195
+ export interface DateMarkProps {
196
+ value: DateInput;
197
+ /**
198
+ * `"date"` (default): the date in `dateStyle` — "Created". `"relative"`: how long
199
+ * ago — "Last login", where the question is "recently?" — with the full date and
200
+ * time in a tooltip (keksdose's last-seen column).
201
+ */
202
+ display?: "date" | "relative";
203
+ /** Default `medium`. Under `"relative"`, the style of the tooltip's date (default
204
+ * `dateTime`) and of the date `relative.absoluteAfterDays` switches to. */
205
+ dateStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;
206
+ /** `formatRelativeTime` options: `numeric`, `style`, `absoluteAfterDays`, `now`. */
207
+ relative?: Omit<FormatRelativeTimeOptions, "locale" | "empty" | "absoluteStyle">;
208
+ /** What a missing value reads as. Default "—"; a last-login column passes its
209
+ * translated "Never". */
210
+ empty?: ReactNode;
211
+ /** Over the provider's locale. */
212
+ locale?: string;
213
+ className?: string;
214
+ }
215
+
216
+ /**
217
+ * A date in a table cell: a `<time>` with a machine-readable `dateTime`, nowrap and
218
+ * tabular figures, in the provider's locale — or `empty` when there is none.
219
+ */
220
+ export function DateMark({
221
+ value,
222
+ display = "date",
223
+ dateStyle,
224
+ relative,
225
+ empty = EMPTY_FORMATTED_VALUE,
226
+ locale: localeProp,
227
+ className,
228
+ }: DateMarkProps) {
229
+ const locale = useKitLocale(localeProp);
230
+ const date = toDate(value);
231
+ const cls = cn("whitespace-nowrap tabular-nums", className);
232
+ if (!date) return <span className={cn(cls, "text-[var(--text-muted)]")}>{empty}</span>;
233
+ if (display === "date") {
234
+ return (
235
+ <time dateTime={date.toISOString()} className={cls}>
236
+ {formatDate(date, dateStyle ?? "medium", { locale })}
237
+ </time>
238
+ );
239
+ }
240
+ const text = formatRelativeTime(date, { ...relative, locale, absoluteStyle: dateStyle ?? "medium" });
241
+ return (
242
+ <Tooltip label={formatDate(date, dateStyle ?? "dateTime", { locale })} lazy>
243
+ {/* Not a tab stop: a roster of fifty rows would be fifty more of them, and the
244
+ relative words already answer the column's question; the exact time is extra. */}
245
+ <time dateTime={date.toISOString()} className={cls}>
246
+ {text}
247
+ </time>
248
+ </Tooltip>
249
+ );
250
+ }
251
+
252
+ export interface DateColumnOptions<T> extends Omit<DateMarkProps, "value" | "className"> {
253
+ key: string;
254
+ header: ReactNode;
255
+ /** The value of this row: an ISO string, epoch ms, a Date, or null for none. */
256
+ value: (row: T) => DateInput;
257
+ /** Sortable by the instant; rows with no date sort last either way. Default true. */
258
+ sortable?: boolean;
259
+ /** Default `desc` — a date column is sorted to find the newest. */
260
+ firstSort?: "asc" | "desc";
261
+ /** Add the table's from–to date filter over the column. Default false. */
262
+ filter?: boolean;
263
+ /** Further column settings (`mobileHidden`, `headClassName`, …), merged over. */
264
+ column?: Partial<DataTableColumn<T>>;
265
+ }
266
+
267
+ /**
268
+ * A {@link DataTableColumn} for a date: {@link DateMark} cells, sorted by the instant
269
+ * (newest first on the first click), optionally the date filter. The roster's
270
+ * "Created" is `dateColumn({ key, header, value })`; its "Last login" adds
271
+ * `display: "relative", empty: t("never")`.
272
+ */
273
+ export function dateColumn<T>({
274
+ key,
275
+ header,
276
+ value,
277
+ sortable = true,
278
+ firstSort = "desc",
279
+ filter = false,
280
+ column,
281
+ ...mark
282
+ }: DateColumnOptions<T>): DataTableColumn<T> {
283
+ return {
284
+ key,
285
+ header,
286
+ cell: (row) => <DateMark value={value(row)} {...mark} />,
287
+ sortBy: sortable ? (row) => toDate(value(row))?.getTime() ?? null : undefined,
288
+ firstSort: sortable ? firstSort : undefined,
289
+ filter: filter
290
+ ? {
291
+ type: "date",
292
+ // The filter compares the first ten characters, so hand it an ISO string.
293
+ getValue: (row) => {
294
+ const raw = value(row);
295
+ if (typeof raw === "string") return raw;
296
+ return toDate(raw)?.toISOString() ?? null;
297
+ },
298
+ }
299
+ : undefined,
300
+ ...column,
301
+ };
302
+ }
@@ -63,8 +63,16 @@ export interface TwoFactorSettingLabels {
63
63
 
64
64
  export interface PasskeysSettingLabels {
65
65
  title: string;
66
- /** The line under the title saying what a passkey is for. */
66
+ /** The line under the title saying what a passkey is for — `mode="instead"` (the
67
+ * default): the passkey stands in for the password. */
67
68
  description: string;
69
+ /**
70
+ * The same line for `mode="alongside"` (0.18.0): the password keeps working and the
71
+ * passkey is a second way in. OPTIONAL for the reason `TwoFactorSettingLabels.qrAlt`
72
+ * is — this interface is annotated at call sites — and filled from the provider's
73
+ * `accountSettings.passkeys`, then English.
74
+ */
75
+ descriptionAlongside?: string;
68
76
  empty: string;
69
77
  loading: string;
70
78
  /** Accessible name of the list. */
@@ -98,8 +106,11 @@ export interface PasskeysSettingLabels {
98
106
  export interface AccountSettingsLabels {
99
107
  profile: ProfileSettingLabels;
100
108
  password: PasswordSettingLabels;
109
+ // `Required` here, optional on the section interfaces: the namespace is what a full
110
+ // catalogue (`@eifi1/ui-kit/i18n/<code>`, `missingKitLabels`) is written against, so
111
+ // it must name the late keys; the section interfaces are annotated at call sites.
101
112
  twoFactor: Required<TwoFactorSettingLabels>;
102
- passkeys: PasskeysSettingLabels;
113
+ passkeys: Required<PasskeysSettingLabels>;
103
114
  }
104
115
 
105
116
  export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
@@ -139,6 +150,9 @@ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
139
150
  passkeys: {
140
151
  title: "Passkeys",
141
152
  description: "Sign in with your fingerprint, face or device PIN instead of a password.",
153
+ // Kurvenschmiede overrode `description` because "instead of" is false where the
154
+ // password stays; this is the sentence for those apps, picked by `mode`.
155
+ descriptionAlongside: "Sign in with your fingerprint, face or device PIN. Your password keeps working too.",
142
156
  empty: "No passkeys yet",
143
157
  loading: "Loading passkeys…",
144
158
  list: "Your passkeys",
@@ -1,4 +1,4 @@
1
- import { forwardRef, isValidElement, useId, useRef, useState } from "react";
1
+ import { forwardRef, isValidElement, useCallback, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { Check, X } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
@@ -282,6 +282,208 @@ const SIZE: Record<
282
282
  },
283
283
  };
284
284
 
285
+ /* ── Edge snapping (fractional device-pixel ratios) ──────────────────────────
286
+ *
287
+ * WHY. At a device-pixel ratio of 1.25 (a 125 % Windows display, or 125 % browser
288
+ * zoom) Chrome snaps a box to whole CSS pixels and only then scales it, so a 1px
289
+ * border is 1.25 device pixels wide and starts at a quarter-pixel phase that depends
290
+ * on where the edge lands. A chip's left edge follows whatever stands before it, its
291
+ * right edge follows its own text, so the two land on unrelated phases: one rasterises
292
+ * as a crisp pixel, the other as a two-pixel smear at half strength — and a 60 %-alpha
293
+ * border (`--danger-border`) makes the smeared side read as a THINNER line (keksdose
294
+ * dev #576).
295
+ *
296
+ * WHAT FIXES IT. The two edges rasterise as mirror images exactly when their device
297
+ * positions sum to a whole number: `(2·left + width) · dpr` integral, `left` being the
298
+ * edge's snapped CSS x. Rounding the WIDTH alone to the ratio's grid (4px at 1.25)
299
+ * makes the phases EQUAL, which is not the same thing: measured with keksdose's
300
+ * playwright harness across every left phase at 1.25, width-only rounding mirrored the
301
+ * edges at two of the four phases and left the other two as lopsided as before (mean
302
+ * peak asymmetry 16.5, worse than the unsnapped 10.9 a random width gets by luck); the
303
+ * position-aware width below took it to 0 at every phase. Inset rings, whole-pixel
304
+ * starts, compositing hints and fractional border widths were measured by keksdose
305
+ * and do not help — Chrome rounds a sub-pixel border back up to 1px.
306
+ *
307
+ * So the fix needs the chip's position, which is why it is opt-in (`snapEdges`): it
308
+ * reads layout. Cheaply — every snapped chip on the page shares one ResizeObserver,
309
+ * one `resize` listener and one resolution media query, and a pass is batched into a
310
+ * frame as read-all / write-all, not a forced layout per chip. At an integer ratio,
311
+ * and at a half one (1.5, 2.5: grid 2 — measured symmetric already, both edges a
312
+ * pixel and a half on the same pattern), there is nothing to fix and a pass
313
+ * writes nothing.
314
+ */
315
+
316
+ /** The smallest whole CSS-pixel count `n` with `n × dpr` whole — 4 at 1.25, 2 at 1.5,
317
+ * 1 at an integer ratio. 0 for a ratio no step up to 4px fits (110 % zoom wants 10px;
318
+ * a chip growing by up to 9px is a layout change, not a hairline fix). */
319
+ function chipEdgeGrid(dpr: number): number {
320
+ for (let n = 1; n <= 4; n++) if (Math.abs(n * dpr - Math.round(n * dpr)) < 1e-3) return n;
321
+ return 0;
322
+ }
323
+
324
+ /**
325
+ * The width (whole CSS px, at least `natural`) that mirrors a box's two edges at
326
+ * `dpr`, for a box whose left edge sits at CSS x `left`. `natural` back when the ratio
327
+ * needs nothing.
328
+ */
329
+ function snappedChipEdgeWidth(natural: number, left: number, dpr: number): number {
330
+ const n = chipEdgeGrid(dpr);
331
+ if (n <= 2) return natural;
332
+ const l = Math.round(left);
333
+ // A hair under a whole pixel is that pixel: 59.999 is a 60px chip, not a 61px one.
334
+ let w = Math.ceil(natural - 0.01);
335
+ while ((((2 * l + w) % n) + n) % n !== 0) w++;
336
+ return w;
337
+ }
338
+
339
+ const snapped = new Set<HTMLElement>();
340
+ /** The min-width each chip was given by a pass — so a pass only ever clears its own. */
341
+ const written = new WeakMap<HTMLElement, string>();
342
+ let snapFrame = 0;
343
+ let snapObserver: ResizeObserver | null = null;
344
+ let ratioQuery: MediaQueryList | null = null;
345
+
346
+ function clearOwn(el: HTMLElement) {
347
+ const own = written.get(el);
348
+ if (own !== undefined && el.style.minWidth === own) el.style.minWidth = "";
349
+ written.delete(el);
350
+ }
351
+
352
+ /**
353
+ * Re-snap every chip rendered with `snapEdges`, now. The kit already re-snaps when a
354
+ * chip renders, resizes, or the window or the ratio changes; call this after moving
355
+ * chips some other way (a side panel sliding in beside a table of status chips),
356
+ * since a move is not something an observer can see.
357
+ */
358
+ export function refreshChipEdges(): void {
359
+ if (snapFrame) {
360
+ cancelAnimationFrame(snapFrame);
361
+ snapFrame = 0;
362
+ }
363
+ if (typeof window === "undefined" || !snapped.size) return;
364
+ const dpr = window.devicePixelRatio || 1;
365
+ const els = [...snapped];
366
+ // Write: back to the natural width, so what is read below is the chip, not our pin.
367
+ for (const el of els) clearOwn(el);
368
+ const n = chipEdgeGrid(dpr);
369
+ if (n <= 2) return;
370
+ // A chip whose own end decides where it starts (RTL, `justify-end`) moves when it
371
+ // grows, so a pass re-reads what it wrote and steps on by a pixel until the edges
372
+ // agree — `n` steps cover every residue. Each pass is ONE layout for every chip
373
+ // together. A chip that never agrees (a centred one moves both edges at once) is
374
+ // given its natural width back rather than kept wider for nothing.
375
+ let pending = els;
376
+ for (let pass = 0; pass <= n && pending.length; pass++) {
377
+ const reads = pending.map((el) => {
378
+ const r = el.getBoundingClientRect();
379
+ const label = el.querySelector<HTMLElement>(".truncate");
380
+ // Not laid out (a hidden ancestor, jsdom), or truncated at its container's edge,
381
+ // where a wider chip would overflow: leave it alone.
382
+ if (!r.width || (label && label.scrollWidth > label.clientWidth)) return null;
383
+ const width = Math.round(r.width * 64) / 64;
384
+ const target = snappedChipEdgeWidth(width, r.left + window.scrollX, dpr);
385
+ return target === width ? null : target;
386
+ });
387
+ const next: HTMLElement[] = [];
388
+ pending.forEach((el, i) => {
389
+ const target = reads[i];
390
+ if (target == null) return;
391
+ if (pass === n) {
392
+ clearOwn(el);
393
+ return;
394
+ }
395
+ const own = written.get(el);
396
+ const value = `${pass > 0 && own !== undefined ? parseFloat(own) + 1 : target}px`;
397
+ el.style.minWidth = value;
398
+ written.set(el, value);
399
+ next.push(el);
400
+ });
401
+ pending = next;
402
+ }
403
+ }
404
+
405
+ function scheduleChipEdges() {
406
+ if (snapFrame || typeof requestAnimationFrame === "undefined") return;
407
+ snapFrame = requestAnimationFrame(() => {
408
+ snapFrame = 0;
409
+ refreshChipEdges();
410
+ });
411
+ }
412
+
413
+ /** The resolution query for the CURRENT ratio: it stops matching when the ratio
414
+ * changes (a window dragged to another display), which a `resize` need not fire. */
415
+ function watchRatio() {
416
+ ratioQuery?.removeEventListener("change", onRatioChange);
417
+ ratioQuery =
418
+ typeof window.matchMedia === "function"
419
+ ? window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
420
+ : null;
421
+ ratioQuery?.addEventListener("change", onRatioChange);
422
+ }
423
+
424
+ function onRatioChange() {
425
+ watchRatio();
426
+ scheduleChipEdges();
427
+ }
428
+
429
+ function registerSnap(el: HTMLElement) {
430
+ if (snapped.has(el)) return;
431
+ if (!snapped.size) {
432
+ if (typeof ResizeObserver !== "undefined") snapObserver = new ResizeObserver(scheduleChipEdges);
433
+ window.addEventListener("resize", scheduleChipEdges);
434
+ watchRatio();
435
+ }
436
+ snapped.add(el);
437
+ snapObserver?.observe(el);
438
+ scheduleChipEdges();
439
+ }
440
+
441
+ function unregisterSnap(el: HTMLElement) {
442
+ if (!snapped.delete(el)) return;
443
+ snapObserver?.unobserve(el);
444
+ clearOwn(el);
445
+ if (snapped.size) return;
446
+ snapObserver?.disconnect();
447
+ snapObserver = null;
448
+ window.removeEventListener("resize", scheduleChipEdges);
449
+ ratioQuery?.removeEventListener("change", onRatioChange);
450
+ ratioQuery = null;
451
+ }
452
+
453
+ /** The ref a chip hangs on its visible pill, plus a re-snap after every render (its
454
+ * label or its neighbours may have changed). */
455
+ function useSnapEdges(on: boolean) {
456
+ const node = useRef<HTMLElement | null>(null);
457
+ const ref = useCallback(
458
+ (el: HTMLElement | null) => {
459
+ if (el === node.current) return;
460
+ const prev = node.current;
461
+ node.current = el;
462
+ // React detaches and re-attaches a ref whose callback changed identity (a
463
+ // consumer's inline ref) — the same element, a moment apart. Unregistering on
464
+ // the detach would clear the pin and paint one frame unsnapped, so the drop
465
+ // waits a microtask and is skipped if the element came straight back.
466
+ if (prev) queueMicrotask(() => node.current !== prev && unregisterSnap(prev));
467
+ if (el && on) registerSnap(el);
468
+ },
469
+ [on],
470
+ );
471
+ useLayoutEffect(() => {
472
+ const el = node.current;
473
+ if (!el) return;
474
+ if (on) {
475
+ registerSnap(el);
476
+ scheduleChipEdges();
477
+ } else unregisterSnap(el);
478
+ });
479
+ return ref;
480
+ }
481
+
482
+ function assignRef<T>(target: Ref<T> | undefined, value: T | null) {
483
+ if (typeof target === "function") target(value);
484
+ else if (target) (target as { current: T | null }).current = value;
485
+ }
486
+
285
487
  const CHIP_PILL = "inline-flex max-w-full items-center rounded-full border transition-colors";
286
488
  // `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
287
489
  // ChipInput moves focus onto one after a removal) and a ring that appears on a
@@ -372,6 +574,23 @@ interface ChipBaseProps {
372
574
  id?: string;
373
575
  /** A native tooltip. For anything a user must read, prefer the kit's `Tooltip`. */
374
576
  title?: string;
577
+ /**
578
+ * Even hairlines on fractional-ratio displays (0.18.0). At a device-pixel ratio of
579
+ * 1.25 — a 125 % Windows display, or 125 % zoom — Chrome draws a chip's two 1px
580
+ * side borders on different sub-pixel phases, one crisp and one a half-strength
581
+ * smear that reads as a thinner line (worst on the 60 %-alpha `danger` border).
582
+ * `snapEdges` widens the chip by under 4px, from where it sits, so the two edges
583
+ * rasterise as mirror images, and centres its content in the extra.
584
+ *
585
+ * Off by default: it reads the chip's position (batched for every snapped chip into
586
+ * one pass a frame, through one shared observer), and the fix is only as fresh as the
587
+ * last pass — it re-snaps on render, resize, zoom and ratio change, but not when
588
+ * something else moves the chip; call {@link refreshChipEdges} then. At an integer
589
+ * ratio, and at 1.5, it writes nothing. Turn it on for status chips that sit in a
590
+ * column and are compared side by side; a chip with no visible border (`dot`) has
591
+ * nothing to snap.
592
+ */
593
+ snapEdges?: boolean;
375
594
  }
376
595
 
377
596
  /**
@@ -458,6 +677,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
458
677
  role,
459
678
  onKeyDown,
460
679
  onFocus,
680
+ snapEdges = false,
461
681
  ...rest
462
682
  },
463
683
  ref,
@@ -476,6 +696,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
476
696
  surfaceOf(tone, variant, selected),
477
697
  disabled && "cursor-default opacity-50",
478
698
  );
699
+ const snap = snapEdges && !dot;
700
+ const snapRef = useSnapEdges(snap);
701
+ // The visible pill carries the snap: the whole chip, or the wrapper of a split one
702
+ // (whose body gets the forwarded ref instead).
703
+ const ownRef = useCallback(
704
+ (el: HTMLElement | null) => {
705
+ snapRef(el);
706
+ assignRef(ref, el);
707
+ },
708
+ [snapRef, ref],
709
+ );
479
710
  const look = cn(
480
711
  CHIP_BASE,
481
712
  s.body,
@@ -485,6 +716,8 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
485
716
  interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
486
717
  // An inert dot chip is text in a column: no inset, so it aligns with the header.
487
718
  dot && !interactive && "px-0",
719
+ // The under-4px a snap adds is split between the two sides, not stacked at the end.
720
+ snap && "justify-center",
488
721
  className,
489
722
  );
490
723
 
@@ -568,7 +801,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
568
801
 
569
802
  // ── The combined shape: one pill, two interactive siblings inside it.
570
803
  const pill = (inner: ReactNode) => (
571
- <span className={cn(CHIP_PILL, s.tail, radius, surface, className)}>
804
+ <span ref={snap ? snapRef : undefined} className={cn(CHIP_PILL, s.tail, radius, surface, snap && "justify-center", className)}>
572
805
  {inner}
573
806
  {remove}
574
807
  </span>
@@ -591,7 +824,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
591
824
  const onLinkClick = onClick as ChipLinkProps["onClick"];
592
825
  const linkProps: ChipLinkProps = {
593
826
  ...rest,
594
- ref: ref as Ref<HTMLAnchorElement>,
827
+ ref: (remove ? ref : ownRef) as Ref<HTMLAnchorElement>,
595
828
  href,
596
829
  onClick: onLinkClick,
597
830
  "aria-current": selected ? "true" : undefined,
@@ -605,7 +838,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
605
838
  <RenderedLink render={render} {...linkProps} />
606
839
  ) : (
607
840
  <a
608
- ref={ref as React.Ref<HTMLAnchorElement>}
841
+ ref={(remove ? ref : ownRef) as React.Ref<HTMLAnchorElement>}
609
842
  href={href}
610
843
  aria-current={selected ? "true" : undefined}
611
844
  onClick={onLinkClick}
@@ -624,7 +857,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
624
857
  const onButtonClick = onClick as (event: MouseEvent<HTMLButtonElement>) => void;
625
858
  const button = (
626
859
  <button
627
- ref={ref as React.Ref<HTMLButtonElement>}
860
+ ref={(remove ? ref : ownRef) as React.Ref<HTMLButtonElement>}
628
861
  type="button"
629
862
  onClick={onButtonClick}
630
863
  disabled={disabled}
@@ -650,7 +883,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
650
883
  the roving-focus hooks, live only when the container passes `role` and `tabIndex`
651
884
  (ChipInput does); the role is a prop, so the rule cannot see it. */
652
885
  <span
653
- ref={ref as React.Ref<HTMLSpanElement>}
886
+ ref={ownRef as React.Ref<HTMLSpanElement>}
654
887
  className={look}
655
888
  tabIndex={tabIndex}
656
889
  role={role}