@eifi1/ui-kit 0.29.0 → 0.30.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 (187) hide show
  1. package/README.md +26 -4
  2. package/dist/account/account-parts.d.ts +21 -0
  3. package/dist/account/account-parts.js +40 -0
  4. package/dist/account/account-parts.js.map +1 -0
  5. package/dist/account/data-export-setting.d.ts +73 -0
  6. package/dist/account/data-export-setting.js +113 -0
  7. package/dist/account/data-export-setting.js.map +1 -0
  8. package/dist/account/delete-account-setting.d.ts +91 -0
  9. package/dist/account/delete-account-setting.js +199 -0
  10. package/dist/account/delete-account-setting.js.map +1 -0
  11. package/dist/account/email-change-setting.d.ts +136 -0
  12. package/dist/account/email-change-setting.js +277 -0
  13. package/dist/account/email-change-setting.js.map +1 -0
  14. package/dist/account/sessions-setting.d.ts +110 -0
  15. package/dist/account/sessions-setting.js +146 -0
  16. package/dist/account/sessions-setting.js.map +1 -0
  17. package/dist/admin/admin-action-confirm.d.ts +91 -0
  18. package/dist/admin/admin-action-confirm.js +276 -0
  19. package/dist/admin/admin-action-confirm.js.map +1 -0
  20. package/dist/admin/admin-action-log.d.ts +105 -0
  21. package/dist/admin/admin-action-log.js +153 -0
  22. package/dist/admin/admin-action-log.js.map +1 -0
  23. package/dist/admin/admin-parts.d.ts +40 -0
  24. package/dist/admin/admin-parts.js +45 -0
  25. package/dist/admin/admin-parts.js.map +1 -0
  26. package/dist/admin/invitations-panel.d.ts +91 -0
  27. package/dist/admin/invitations-panel.js +289 -0
  28. package/dist/admin/invitations-panel.js.map +1 -0
  29. package/dist/admin/reviewer-scope-editor.d.ts +91 -0
  30. package/dist/admin/reviewer-scope-editor.js +134 -0
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -0
  32. package/dist/admin/role-select.d.ts +91 -0
  33. package/dist/admin/role-select.js +128 -0
  34. package/dist/admin/role-select.js.map +1 -0
  35. package/dist/admin/transfer-ownership-dialog.d.ts +91 -0
  36. package/dist/admin/transfer-ownership-dialog.js +83 -0
  37. package/dist/admin/transfer-ownership-dialog.js.map +1 -0
  38. package/dist/admin/user-roster.d.ts +91 -0
  39. package/dist/admin/user-roster.js +285 -0
  40. package/dist/admin/user-roster.js.map +1 -0
  41. package/dist/auth/auth-errors.d.ts +85 -6
  42. package/dist/auth/auth-errors.js +62 -2
  43. package/dist/auth/auth-errors.js.map +1 -1
  44. package/dist/auth/forgot-password-form.d.ts +10 -3
  45. package/dist/auth/forgot-password-form.js +6 -3
  46. package/dist/auth/forgot-password-form.js.map +1 -1
  47. package/dist/auth/register-form.d.ts +17 -12
  48. package/dist/auth/register-form.js +6 -4
  49. package/dist/auth/register-form.js.map +1 -1
  50. package/dist/auth/reset-password-form.d.ts +10 -3
  51. package/dist/auth/reset-password-form.js +6 -3
  52. package/dist/auth/reset-password-form.js.map +1 -1
  53. package/dist/auth/sign-in-form.d.ts +61 -9
  54. package/dist/auth/sign-in-form.js +94 -13
  55. package/dist/auth/sign-in-form.js.map +1 -1
  56. package/dist/auth/verify-email.d.ts +7 -2
  57. package/dist/auth/verify-email.js +44 -8
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/chart.d.ts +16 -11
  60. package/dist/components/account-chips.d.ts +47 -7
  61. package/dist/components/account-chips.js +19 -3
  62. package/dist/components/account-chips.js.map +1 -1
  63. package/dist/components/account-settings-labels.d.ts +12 -1
  64. package/dist/components/account-settings-labels.js +4 -1
  65. package/dist/components/account-settings-labels.js.map +1 -1
  66. package/dist/components/account-settings.d.ts +61 -6
  67. package/dist/components/account-settings.js +115 -15
  68. package/dist/components/account-settings.js.map +1 -1
  69. package/dist/components/amount-input.d.ts +17 -12
  70. package/dist/components/button-group.d.ts +17 -12
  71. package/dist/components/calculator.d.ts +17 -12
  72. package/dist/components/confirm-dialog.d.ts +17 -12
  73. package/dist/components/copy-button.d.ts +14 -9
  74. package/dist/components/danger-confirm.d.ts +17 -12
  75. package/dist/components/facing-pair.d.ts +17 -12
  76. package/dist/components/file-button.d.ts +17 -12
  77. package/dist/components/file-dropzone.d.ts +17 -12
  78. package/dist/components/form-actions.d.ts +17 -12
  79. package/dist/components/iban-input.d.ts +17 -12
  80. package/dist/components/language-select.d.ts +17 -12
  81. package/dist/components/legal-page.d.ts +17 -12
  82. package/dist/components/legal.d.ts +17 -12
  83. package/dist/components/money-field.d.ts +17 -12
  84. package/dist/components/number-field.d.ts +17 -12
  85. package/dist/components/number-input.d.ts +17 -12
  86. package/dist/components/numpad-sheet.d.ts +17 -12
  87. package/dist/components/phone-input.d.ts +17 -12
  88. package/dist/components/series-chart.d.ts +17 -12
  89. package/dist/components/settings-fields.d.ts +16 -11
  90. package/dist/components/share-card.d.ts +16 -11
  91. package/dist/components/text-link.d.ts +17 -12
  92. package/dist/components/time-input.d.ts +17 -12
  93. package/dist/components/ui.d.ts +13 -8
  94. package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-CGVMckMw.d.ts} +6616 -5645
  95. package/dist/feedback/feedback-attachment.d.ts +17 -12
  96. package/dist/feedback/feedback-context.d.ts +17 -12
  97. package/dist/feedback/feedback-crash.d.ts +17 -12
  98. package/dist/feedback/feedback-dialog.d.ts +17 -12
  99. package/dist/feedback/feedback-inbox.d.ts +17 -12
  100. package/dist/feedback/feedback-labels.d.ts +17 -12
  101. package/dist/feedback/feedback-menu.d.ts +17 -12
  102. package/dist/feedback/feedback-record.d.ts +17 -12
  103. package/dist/feedback/feedback-row-detail.d.ts +17 -12
  104. package/dist/feedback/feedback-status-undo.d.ts +17 -12
  105. package/dist/feedback/feedback-submit.d.ts +17 -12
  106. package/dist/feedback/feedback-swipe.d.ts +17 -12
  107. package/dist/feedback/feedback-table.d.ts +14 -9
  108. package/dist/feedback/feedback-thread.d.ts +17 -12
  109. package/dist/feedback.d.ts +17 -12
  110. package/dist/hooks/use-anchored-panel.d.ts +6 -2
  111. package/dist/hooks/use-anchored-panel.js +2 -1
  112. package/dist/hooks/use-anchored-panel.js.map +1 -1
  113. package/dist/hooks/use-file-drop.d.ts +17 -12
  114. package/dist/i18n/defaults.d.ts +17 -12
  115. package/dist/i18n/defaults.js +22 -0
  116. package/dist/i18n/defaults.js.map +1 -1
  117. package/dist/i18n/german.d.ts +17 -12
  118. package/dist/i18n/german.js +215 -11
  119. package/dist/i18n/german.js.map +1 -1
  120. package/dist/i18n/kit-labels.d.ts +10 -5
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/languages.d.ts +11 -6
  123. package/dist/i18n/locales/de-CH.d.ts +17 -12
  124. package/dist/i18n/locales/en.d.ts +17 -12
  125. package/dist/i18n/locales/es.d.ts +17 -12
  126. package/dist/i18n/locales/es.js +207 -9
  127. package/dist/i18n/locales/es.js.map +1 -1
  128. package/dist/i18n/locales/fr.d.ts +17 -12
  129. package/dist/i18n/locales/fr.js +209 -6
  130. package/dist/i18n/locales/fr.js.map +1 -1
  131. package/dist/i18n/locales/hu.d.ts +17 -12
  132. package/dist/i18n/locales/hu.js +207 -11
  133. package/dist/i18n/locales/hu.js.map +1 -1
  134. package/dist/i18n/locales/it.d.ts +17 -12
  135. package/dist/i18n/locales/it.js +209 -6
  136. package/dist/i18n/locales/it.js.map +1 -1
  137. package/dist/i18n/locales/zh.d.ts +17 -12
  138. package/dist/i18n/locales/zh.js +200 -6
  139. package/dist/i18n/locales/zh.js.map +1 -1
  140. package/dist/i18n/review.d.ts +17 -12
  141. package/dist/i18n/review.js +37 -1
  142. package/dist/i18n/review.js.map +1 -1
  143. package/dist/index.d.ts +8 -3
  144. package/dist/index.js +53 -0
  145. package/dist/index.js.map +1 -1
  146. package/dist/rhf/fields.d.ts +17 -12
  147. package/dist/rhf/form.d.ts +17 -12
  148. package/dist/rhf.d.ts +17 -12
  149. package/dist/shell/app-shell.d.ts +17 -12
  150. package/dist/shell/top-bar-brand.d.ts +17 -12
  151. package/dist/shell.d.ts +11 -6
  152. package/dist/wizard/stepper-nav.d.ts +17 -12
  153. package/dist/wizard.d.ts +17 -12
  154. package/package.json +1 -1
  155. package/src/account/account-parts.ts +67 -0
  156. package/src/account/data-export-setting.tsx +218 -0
  157. package/src/account/delete-account-setting.tsx +362 -0
  158. package/src/account/email-change-setting.tsx +457 -0
  159. package/src/account/sessions-setting.tsx +283 -0
  160. package/src/admin/admin-action-confirm.tsx +495 -0
  161. package/src/admin/admin-action-log.tsx +297 -0
  162. package/src/admin/admin-parts.ts +82 -0
  163. package/src/admin/invitations-panel.tsx +516 -0
  164. package/src/admin/reviewer-scope-editor.tsx +234 -0
  165. package/src/admin/role-select.tsx +286 -0
  166. package/src/admin/transfer-ownership-dialog.tsx +172 -0
  167. package/src/admin/user-roster.tsx +573 -0
  168. package/src/auth/auth-errors.ts +165 -8
  169. package/src/auth/forgot-password-form.tsx +21 -5
  170. package/src/auth/register-form.tsx +24 -6
  171. package/src/auth/reset-password-form.tsx +19 -5
  172. package/src/auth/sign-in-form.tsx +207 -35
  173. package/src/auth/verify-email.tsx +59 -8
  174. package/src/components/account-chips.tsx +64 -7
  175. package/src/components/account-settings-labels.ts +15 -1
  176. package/src/components/account-settings.tsx +199 -23
  177. package/src/hooks/use-anchored-panel.ts +14 -3
  178. package/src/i18n/defaults.ts +22 -0
  179. package/src/i18n/german.ts +241 -5
  180. package/src/i18n/kit-labels.tsx +33 -0
  181. package/src/i18n/locales/es.ts +230 -3
  182. package/src/i18n/locales/fr.ts +232 -0
  183. package/src/i18n/locales/hu.ts +224 -5
  184. package/src/i18n/locales/it.ts +230 -0
  185. package/src/i18n/locales/zh.ts +205 -0
  186. package/src/i18n/review.ts +36 -0
  187. package/src/index.ts +93 -0
@@ -118,25 +118,55 @@ export function RoleChip<R extends string = string>({
118
118
  * - `registered` — an allowlist row whose address has an account now (Kurvenschmiede).
119
119
  * - `unverified` — the email address is not confirmed (keksdose).
120
120
  * - `passwordChange` — must set a new password at the next sign-in (keksdose).
121
+ * - `deletion` — 0.30.0: the user asked for the account to be deleted
122
+ * (docs/user-admin-harmonization.md §2.1, §6.4). It is deactivated at once and erased
123
+ * on a date (`after_days`: keksdose, Kurvenschmiede) or by an operator (kastlan). The
124
+ * chip says the date when there is one ({@link AccountStateChipProps.date}), and
125
+ * "Deletion requested" when there is none. It stands IN PLACE of `inactive`: an
126
+ * account marked for deletion is always deactivated, and two chips would say it twice.
121
127
  *
122
128
  * An account can be several at once (inactive AND unverified): render one chip per
123
129
  * state, as keksdose's state column does.
124
130
  */
125
- export type AccountState = "active" | "inactive" | "invited" | "registered" | "unverified" | "passwordChange";
131
+ export type AccountState =
132
+ | "active"
133
+ | "inactive"
134
+ | "invited"
135
+ | "registered"
136
+ | "unverified"
137
+ | "passwordChange"
138
+ | "deletion";
126
139
 
127
- export type AccountStateLabels = Record<AccountState, string>;
140
+ /**
141
+ * The `accountState` namespace. One word per state, plus the two forms of `deletion`.
142
+ *
143
+ * The 0.30 keys are OPTIONAL, like every key added to a namespace an app annotates
144
+ * (`const L: AccountStateLabels = {…}`): a new required key would be a compile error in
145
+ * each app on its next update. Left out, they fall back to the provider's, then English.
146
+ */
147
+ export interface AccountStateLabels extends Record<Exclude<AccountState, "deletion">, string> {
148
+ /** 0.30.0: an account marked for deletion with no date — kastlan's `operator` mode,
149
+ * where an operator erases it (§6.4). "Deletion requested". */
150
+ deletion?: string;
151
+ /** 0.30.0: an account that will be erased on `date`, already formatted in the
152
+ * reader's locale — the `after_days` mode. "Deletion on 6 Nov 2026". */
153
+ deletionOn?: (date: string) => string;
154
+ }
128
155
 
129
- export const DEFAULT_ACCOUNT_STATE_LABELS: AccountStateLabels = {
156
+ export const DEFAULT_ACCOUNT_STATE_LABELS = {
130
157
  active: "Active",
131
158
  inactive: "Inactive",
132
159
  invited: "Invited",
133
160
  registered: "Registered",
134
161
  unverified: "Unverified",
135
162
  passwordChange: "Must change password",
136
- };
163
+ deletion: "Deletion requested",
164
+ deletionOn: (date: string) => `Deletion on ${date}`,
165
+ } satisfies Required<AccountStateLabels>;
137
166
 
138
- /** The default tone of each state: green for the normal case, red for switched off,
139
- * amber for something the user still has to do, blue for an open invitation. */
167
+ /** The default tone of each state: green for the normal case, red for switched off
168
+ * (and for going, `deletion`), amber for something the user still has to do, blue for
169
+ * an open invitation. */
140
170
  export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
141
171
  active: "success",
142
172
  inactive: "danger",
@@ -144,10 +174,22 @@ export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
144
174
  registered: "neutral",
145
175
  unverified: "warning",
146
176
  passwordChange: "warning",
177
+ // Red like `inactive`, which it replaces: the account cannot sign in, and it is going.
178
+ deletion: "danger",
147
179
  };
148
180
 
149
181
  export interface AccountStateChipProps extends AccountChipLook {
150
182
  state: AccountState;
183
+ /**
184
+ * 0.30.0, `state="deletion"` only: the day the account will be erased — the row's
185
+ * `deletion_scheduled_at` (§3.2, §6.4). Formatted as a date in the reader's locale
186
+ * ("Deletion on 6 Nov 2026"), through the provider's `formatDate` when the app set
187
+ * one, as {@link DateMark} does. Missing or `null` — an operator erases it, so there
188
+ * is no date — the chip says "Deletion requested". Ignored for every other state.
189
+ */
190
+ date?: DateInput;
191
+ /** Over the provider's locale, for the `deletion` date. */
192
+ locale?: string;
151
193
  /** Over {@link ACCOUNT_STATE_TONES} — Kurvenschmiede draws `inactive` neutral. */
152
194
  tone?: ChipTone;
153
195
  /** Over the label, for an app whose word for the state is its own ("Deactivated"). */
@@ -162,6 +204,8 @@ export interface AccountStateChipProps extends AccountChipLook {
162
204
  */
163
205
  export function AccountStateChip({
164
206
  state,
207
+ date,
208
+ locale: localeProp,
165
209
  tone,
166
210
  children,
167
211
  labels: labelsProp,
@@ -175,10 +219,23 @@ export function AccountStateChip({
175
219
  // An `undefined` in the prop is skipped (the provider's merge does that), so an
176
220
  // optional value cannot blank a default.
177
221
  const labels = useKitLabels("accountState", DEFAULT_ACCOUNT_STATE_LABELS, labelsProp);
222
+ const locale = useKitLocale(localeProp);
223
+ const fromProvider = useKitDateFormatter();
224
+ const when = state === "deletion" ? toDate(date ?? null) : null;
225
+ let text: ReactNode;
226
+ if (state !== "deletion") text = labels[state];
227
+ else if (when) {
228
+ // A date in a SENTENCE, so no weekday (keksdose's rule, KitDateFormatContext).
229
+ const day =
230
+ (fromProvider && fromProvider(toLocalIso(when), { unit: "day", source: "dateMark", locale, weekday: false })) ||
231
+ formatDate(when, "medium", { locale });
232
+ text = (labels.deletionOn ?? DEFAULT_ACCOUNT_STATE_LABELS.deletionOn)(day);
233
+ } else text = labels.deletion ?? DEFAULT_ACCOUNT_STATE_LABELS.deletion;
178
234
  return (
179
235
  <Chip
180
236
  {...rest}
181
237
  data-state={state}
238
+ data-date={when ? toLocalIso(when) : undefined}
182
239
  tone={tone ?? ACCOUNT_STATE_TONES[state]}
183
240
  size={size}
184
241
  variant={variant}
@@ -186,7 +243,7 @@ export function AccountStateChip({
186
243
  caps={caps}
187
244
  className={className}
188
245
  >
189
- {children ?? labels[state]}
246
+ {children ?? text}
190
247
  </Chip>
191
248
  );
192
249
  }
@@ -20,8 +20,19 @@ export interface ProfileSettingLabels {
20
20
  email: string;
21
21
  role: string;
22
22
  memberSince: string;
23
+ /** The single name field — the card without `firstName` / `lastName`. */
23
24
  displayName: string;
24
25
  save: string;
26
+ /**
27
+ * 0.30.0 (docs/user-admin-harmonization.md §6.1): the two fields that replace
28
+ * `displayName` once the card is given `firstName` / `lastName`. OPTIONAL, like
29
+ * `TwoFactorSettingLabels.qrAlt` and for its reason — this interface is annotated at
30
+ * call sites — and filled from the provider's `accountSettings.profile`, then English.
31
+ */
32
+ firstName?: string;
33
+ lastName?: string;
34
+ /** Why Save is held while a name is blank: both are required (§3.1). */
35
+ nameRequired?: string;
25
36
  }
26
37
 
27
38
  export interface PasswordSettingLabels {
@@ -104,7 +115,7 @@ export interface PasskeysSettingLabels {
104
115
  }
105
116
 
106
117
  export interface AccountSettingsLabels {
107
- profile: ProfileSettingLabels;
118
+ profile: Required<ProfileSettingLabels>;
108
119
  password: PasswordSettingLabels;
109
120
  // `Required` here, optional on the section interfaces: the namespace is what a full
110
121
  // catalogue (`@eifi1/ui-kit/i18n/<code>`, `missingKitLabels`) is written against, so
@@ -121,6 +132,9 @@ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
121
132
  memberSince: "Member since",
122
133
  displayName: "Display name",
123
134
  save: "Save",
135
+ firstName: "First name",
136
+ lastName: "Last name",
137
+ nameRequired: "Enter both a first and a last name.",
124
138
  },
125
139
  password: {
126
140
  title: "Change password",
@@ -1,10 +1,12 @@
1
1
  import { useId, useState } from "react";
2
- import type { KeyboardEvent, ReactNode } from "react";
2
+ import type { FormEvent, KeyboardEvent, ReactNode } from "react";
3
3
  import { Button, Card, Input } from "./ui";
4
4
  import { UserAvatar } from "./user-avatar";
5
5
  import { CopyButton } from "./copy-button";
6
6
  import { QrCode } from "./qr-code";
7
7
  import { OneTimeCodeInput } from "./one-time-code-input";
8
+ import { usePromisePending } from "./danger-confirm";
9
+ import { personNameOk, PERSON_NAME_MAX_LENGTH } from "../auth/form-rules";
8
10
  import {
9
11
  DEFAULT_PASSWORD_STRENGTH_LABELS,
10
12
  passwordByteLength,
@@ -40,38 +42,90 @@ export { DEFAULT_ACCOUNT_SETTINGS_LABELS } from "./account-settings-labels";
40
42
  * `accountSettings` namespace, then English (see account-settings-labels.ts).
41
43
  */
42
44
 
43
- export function ProfileSetting({
44
- name,
45
- email,
46
- role,
47
- memberSince,
48
- value,
49
- onChange,
50
- onSave,
51
- saving,
52
- labels: labelsProp,
53
- }: {
45
+ /** What {@link ProfileSetting} hands `onSave` in its two-field form (0.30.0): both
46
+ * trimmed, 1–120 characters each — the `PATCH /auth/me` body's `first_name` /
47
+ * `last_name` (docs/user-admin-harmonization.md §6.1). */
48
+ export interface ProfileNameValues {
49
+ firstName: string;
50
+ lastName: string;
51
+ }
52
+
53
+ /** What both forms of {@link ProfileSetting} take. */
54
+ interface ProfileSettingCommonProps {
54
55
  name?: string | null;
55
56
  email?: string | null;
56
57
  role?: ReactNode;
57
58
  memberSince?: ReactNode;
58
- value: string;
59
- onChange: (value: string) => void;
60
- onSave: () => void;
61
59
  saving?: boolean;
62
60
  /** Prop > `<UiKitProvider labels={{ accountSettings: { profile } }}>` > English. */
63
61
  labels?: Partial<ProfileSettingLabels>;
64
- }) {
62
+ }
63
+
64
+ /**
65
+ * The single display name, as before 0.30.0 — controlled through `value` / `onChange`,
66
+ * saved by `onSave()`. Kept unchanged for an app that has not moved to first and last
67
+ * name yet.
68
+ */
69
+ export interface ProfileSettingDisplayNameProps extends ProfileSettingCommonProps {
70
+ value: string;
71
+ onChange: (value: string) => void;
72
+ onSave: () => void;
73
+ firstName?: undefined;
74
+ lastName?: undefined;
75
+ }
76
+
77
+ /**
78
+ * First and last name (0.30.0, docs/user-admin-harmonization.md §6.1): the saved
79
+ * values in, the edited ones out through `onSave`. The card keeps the drafts itself.
80
+ */
81
+ export interface ProfileSettingNamesProps extends ProfileSettingCommonProps {
82
+ /** The saved given name — `first_name`. `null` reads as empty. */
83
+ firstName: string | null;
84
+ /** The saved family name — `last_name`. `null` (a migrated account, §3.3) reads as
85
+ * empty, and Save then waits for one. */
86
+ lastName: string | null;
87
+ /**
88
+ * Save the two names (`PATCH /auth/me`, which also clears `name_incomplete`). Return
89
+ * a promise and Save is busy until it settles. Refresh `firstName` / `lastName` from
90
+ * the answer: the card compares its drafts with them, so Save turns off once the new
91
+ * names come back. A rejection keeps what was typed — the app says why (a toast), as
92
+ * for `PasswordSetting`.
93
+ */
94
+ onSave: (values: ProfileNameValues) => void | Promise<unknown>;
95
+ value?: undefined;
96
+ onChange?: undefined;
97
+ }
98
+
99
+ export type ProfileSettingProps = ProfileSettingDisplayNameProps | ProfileSettingNamesProps;
100
+
101
+ /**
102
+ * The profile section: avatar, address, role, member since, and the name.
103
+ *
104
+ * **Two forms.** Given `firstName` / `lastName` (0.30.0, §6.1) it edits first and last
105
+ * name in two fields, side by side where the card is wide enough — the order and the
106
+ * 1–120-character rule of `RegisterForm` (auth §3.1), so the name a person signed up
107
+ * with is edited the way it was typed. Without them it is the single `displayName`
108
+ * field it always was, `value` / `onChange` / `onSave()` unchanged, for an app that has
109
+ * not moved yet (kastlan split the one name at its first space on save; Kurvenschmiede
110
+ * built its own card instead).
111
+ *
112
+ * The two-field form owns its drafts, starting from the saved names and following them
113
+ * when they change; Save waits for a change, holds — saying why — while a name is
114
+ * blank, and is a `commit` (a {@link WriteLockProvider} locks it, keksdose's demo).
115
+ */
116
+ export function ProfileSetting(props: ProfileSettingProps) {
117
+ const { name, email, role, memberSince, labels: labelsProp } = props;
65
118
  const labels = useAccountSettingsLabels("profile", labelsProp);
66
- const dirty = value.trim().length > 0 && value.trim() !== (name ?? "");
67
- // Generated, not the literal "display-name" it was: two of these on one page (an
68
- // admin editing someone else's profile beside their own) shared an id, and the
69
- // second label pointed at the first field.
70
- const inputId = useId();
119
+ const names = props.firstName !== undefined || props.lastName !== undefined;
71
120
  return (
72
121
  <Card className="p-4 space-y-3">
73
122
  <div className="flex items-center gap-3">
74
- <UserAvatar name={name} email={email} size="lg" />
123
+ <UserAvatar
124
+ name={name}
125
+ email={email}
126
+ person={names ? { first: props.firstName, last: props.lastName } : undefined}
127
+ size="lg"
128
+ />
75
129
  <div className="min-w-0">
76
130
  <div className="text-sm font-medium">{labels.title}</div>
77
131
  <div className="truncate font-mono text-xs text-[var(--text-muted)]">
@@ -88,6 +142,51 @@ export function ProfileSetting({
88
142
  <span className="text-[var(--text-muted)]">{labels.memberSince}</span>
89
143
  <span>{memberSince ?? "—"}</span>
90
144
  </div>
145
+ {names ? (
146
+ <PersonNameFields
147
+ firstName={props.firstName ?? ""}
148
+ lastName={props.lastName ?? ""}
149
+ onSave={(props as ProfileSettingNamesProps).onSave}
150
+ saving={props.saving}
151
+ labels={labels}
152
+ />
153
+ ) : (
154
+ <DisplayNameField
155
+ name={name}
156
+ value={(props as ProfileSettingDisplayNameProps).value}
157
+ onChange={(props as ProfileSettingDisplayNameProps).onChange}
158
+ onSave={(props as ProfileSettingDisplayNameProps).onSave}
159
+ saving={props.saving}
160
+ labels={labels}
161
+ />
162
+ )}
163
+ </Card>
164
+ );
165
+ }
166
+
167
+ /** The single-name form — exactly the 0.29 card's field and button. */
168
+ function DisplayNameField({
169
+ name,
170
+ value,
171
+ onChange,
172
+ onSave,
173
+ saving,
174
+ labels,
175
+ }: {
176
+ name?: string | null;
177
+ value: string;
178
+ onChange: (value: string) => void;
179
+ onSave: () => void;
180
+ saving?: boolean;
181
+ labels: Required<ProfileSettingLabels>;
182
+ }) {
183
+ const dirty = value.trim().length > 0 && value.trim() !== (name ?? "");
184
+ // Generated, not the literal "display-name" it was: two of these on one page (an
185
+ // admin editing someone else's profile beside their own) shared an id, and the
186
+ // second label pointed at the first field.
187
+ const inputId = useId();
188
+ return (
189
+ <>
91
190
  <Input
92
191
  id={inputId}
93
192
  label={labels.displayName}
@@ -98,7 +197,84 @@ export function ProfileSetting({
98
197
  <Button onClick={onSave} disabled={!dirty || saving}>
99
198
  {labels.save}
100
199
  </Button>
101
- </Card>
200
+ </>
201
+ );
202
+ }
203
+
204
+ /** The first-and-last form (0.30.0, §6.1). */
205
+ function PersonNameFields({
206
+ firstName,
207
+ lastName,
208
+ onSave,
209
+ saving,
210
+ labels,
211
+ }: {
212
+ firstName: string;
213
+ lastName: string;
214
+ onSave: (values: ProfileNameValues) => void | Promise<unknown>;
215
+ saving?: boolean;
216
+ labels: Required<ProfileSettingLabels>;
217
+ }) {
218
+ const [first, setFirst] = useState(firstName);
219
+ const [last, setLast] = useState(lastName);
220
+ // The saved names moved (the save came back, another tab renamed): the drafts follow
221
+ // them. During render, as DangerConfirm resets its fields, so no frame shows the old
222
+ // draft beside the new name.
223
+ const [saved, setSaved] = useState({ firstName, lastName });
224
+ if (saved.firstName !== firstName || saved.lastName !== lastName) {
225
+ setSaved({ firstName, lastName });
226
+ setFirst(firstName);
227
+ setLast(lastName);
228
+ }
229
+ const { pending, run } = usePromisePending();
230
+ const busy = Boolean(saving) || pending;
231
+ const valid = personNameOk(first) && personNameOk(last);
232
+ const dirty = first.trim() !== firstName.trim() || last.trim() !== lastName.trim();
233
+
234
+ const submit = (e: FormEvent) => {
235
+ e.preventDefault();
236
+ if (!valid || !dirty || busy) return;
237
+ // A rejection keeps the drafts; the app reports it, as PasswordSetting's does.
238
+ run(onSave({ firstName: first.trim(), lastName: last.trim() }), () => {});
239
+ };
240
+
241
+ return (
242
+ <form className="@container space-y-3" onSubmit={submit} noValidate>
243
+ {/* The order and the rule of RegisterForm's two fields (auth §3.1). */}
244
+ <div className="grid gap-3 @xs:grid-cols-2">
245
+ <Input
246
+ name="given-name"
247
+ autoComplete="given-name"
248
+ label={labels.firstName}
249
+ value={first}
250
+ onChange={(e) => setFirst(e.target.value)}
251
+ maxLength={PERSON_NAME_MAX_LENGTH}
252
+ readOnly={busy}
253
+ required
254
+ />
255
+ <Input
256
+ name="family-name"
257
+ autoComplete="family-name"
258
+ label={labels.lastName}
259
+ value={last}
260
+ onChange={(e) => setLast(e.target.value)}
261
+ maxLength={PERSON_NAME_MAX_LENGTH}
262
+ readOnly={busy}
263
+ required
264
+ />
265
+ </div>
266
+ <Button
267
+ type="submit"
268
+ commit
269
+ pending={busy}
270
+ // A blank name is a reason the person can act on; an unchanged one is not —
271
+ // there is nothing to save, and the button is simply off.
272
+ disabled={valid && !dirty}
273
+ disabledReason={valid ? undefined : labels.nameRequired}
274
+ >
275
+ {labels.save}
276
+ </Button>
277
+ </form>
102
278
  );
103
279
  }
104
280
 
@@ -55,13 +55,21 @@ export interface AnchoredPanel {
55
55
  /** Absolute floor: below this a list is useless, so overflow the margin instead. */
56
56
  const FLOOR = 96;
57
57
 
58
+ /** How much more room above must offer, when neither side fits the preferred height,
59
+ * before the panel changes sides — so it never jumps for a few pixels' gain. */
60
+ const FLIP_GAIN = 48;
61
+
58
62
  /**
59
63
  * The pure half of {@link useAnchoredPanel}: where a panel goes, given the trigger's
60
64
  * rect and the region actually on screen.
61
65
  *
62
66
  * Prefers below the trigger — the conventional direction, and the one that keeps the
63
- * trigger's own value visible. Flips above only when below cannot show a usable list
64
- * AND above is roomier, so a panel never jumps sides for a few pixels' gain. The
67
+ * trigger's own value visible — whenever the panel's preferred height fits there. When it
68
+ * does not, the panel opens above if the full height fits there, or if above is clearly
69
+ * roomier (by {@link FLIP_GAIN}); below an unusable `minHeight` it flips to any roomier
70
+ * side, as before. It used to flip only below `minHeight`, so a select with 160–319 px
71
+ * under it opened downward as a cramped list with 600 px free above (keksdose live #384,
72
+ * the currency select of the new-budget form near the bottom of the screen). The
65
73
  * returned `maxHeight` is what makes the flip sufficient rather than merely different:
66
74
  * without it a tall panel placed above just runs off the top instead of the bottom.
67
75
  *
@@ -77,7 +85,10 @@ export function anchoredPanelPlacement(
77
85
  const viewBottom = viewport.top + viewport.height - margin;
78
86
  const spaceBelow = viewBottom - (rect.bottom + gap);
79
87
  const spaceAbove = rect.top - gap - viewTop;
80
- const above = spaceBelow < minHeight && spaceAbove > spaceBelow;
88
+ const above =
89
+ spaceAbove > spaceBelow &&
90
+ (spaceBelow < minHeight ||
91
+ (spaceBelow < preferredHeight && (spaceAbove >= preferredHeight || spaceAbove - spaceBelow >= FLIP_GAIN)));
81
92
  const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);
82
93
  const maxHeight = Math.min(preferredHeight, space);
83
94
  return {
@@ -70,6 +70,17 @@ import { DEFAULT_VERIFY_EMAIL_LABELS } from "../auth/verify-email";
70
70
  import { DEFAULT_NOT_FOUND_LABELS } from "../auth/not-found-page";
71
71
  import { DEFAULT_ACCEPT_INVITATION_LABELS } from "../auth/accept-invitation";
72
72
  import { DEFAULT_COMPANY_SWITCHER_LABELS } from "../shell/company-switcher";
73
+ import { DEFAULT_EMAIL_CHANGE_LABELS } from "../account/email-change-setting";
74
+ import { DEFAULT_SESSIONS_LABELS } from "../account/sessions-setting";
75
+ import { DEFAULT_DELETE_ACCOUNT_LABELS } from "../account/delete-account-setting";
76
+ import { DEFAULT_DATA_EXPORT_LABELS } from "../account/data-export-setting";
77
+ import { DEFAULT_USER_ROSTER_LABELS } from "../admin/user-roster";
78
+ import { DEFAULT_ROLE_SELECT_LABELS } from "../admin/role-select";
79
+ import { DEFAULT_REVIEWER_SCOPE_LABELS } from "../admin/reviewer-scope-editor";
80
+ import { DEFAULT_ADMIN_ACTION_LABELS } from "../admin/admin-action-confirm";
81
+ import { DEFAULT_ADMIN_ACTION_LOG_LABELS } from "../admin/admin-action-log";
82
+ import { DEFAULT_TRANSFER_OWNERSHIP_LABELS } from "../admin/transfer-ownership-dialog";
83
+ import { DEFAULT_INVITATIONS_LABELS } from "../admin/invitations-panel";
73
84
  import { DEFAULT_ACCOUNT_STATE_LABELS } from "../components/account-chips";
74
85
  import { DEFAULT_SHARE_CARD_LABELS } from "../components/share-card";
75
86
  import { DEFAULT_REAUTH_DIALOG_LABELS } from "../components/reauth-dialog";
@@ -186,6 +197,17 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
186
197
  notFound: DEFAULT_NOT_FOUND_LABELS,
187
198
  acceptInvitation: DEFAULT_ACCEPT_INVITATION_LABELS,
188
199
  companySwitcher: DEFAULT_COMPANY_SWITCHER_LABELS,
200
+ emailChange: DEFAULT_EMAIL_CHANGE_LABELS,
201
+ sessions: DEFAULT_SESSIONS_LABELS,
202
+ deleteAccount: DEFAULT_DELETE_ACCOUNT_LABELS,
203
+ dataExport: DEFAULT_DATA_EXPORT_LABELS,
204
+ userRoster: DEFAULT_USER_ROSTER_LABELS,
205
+ roleSelect: DEFAULT_ROLE_SELECT_LABELS,
206
+ reviewerScope: DEFAULT_REVIEWER_SCOPE_LABELS,
207
+ adminAction: DEFAULT_ADMIN_ACTION_LABELS,
208
+ adminActionLog: DEFAULT_ADMIN_ACTION_LOG_LABELS,
209
+ transferOwnership: DEFAULT_TRANSFER_OWNERSHIP_LABELS,
210
+ invitations: DEFAULT_INVITATIONS_LABELS,
189
211
  characterCount: DEFAULT_CHARACTER_COUNT_LABELS,
190
212
  countrySelect: DEFAULT_COUNTRY_SELECT_LABELS,
191
213
  inlineEdit: DEFAULT_INLINE_EDIT_LABELS,