@gopherium/react-auth 0.16.1 → 0.18.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.
package/README.md CHANGED
@@ -86,6 +86,28 @@ const password = usePasswordField({ length: 24, userInputs: [name, email] })
86
86
  <PasswordField password={password} generateFirst labelPosition="top" readOnly={sending} />
87
87
  ```
88
88
 
89
+ `required` and `customValidity` take the shapes `@wordpress/ui`'s
90
+ `ValidatedInputControl` takes. `required` adds "(Required)" to the
91
+ label. A message shows under the field and its meter, in red with the
92
+ warning icon, once the field is left, an `invalid` event fires or the
93
+ form's `reportValidity()` runs. While it is `invalid` the field has
94
+ `aria-invalid` and the browser holds the form back. When the app sets
95
+ a message after a send, it calls the form's `reportValidity()` in an
96
+ effect that runs after that render, so the message shows at once.
97
+
98
+ An Add User form leaves `required` out, as WordPress's Add User shows
99
+ no "(required)" by the password it always generates. It catches an
100
+ empty password with `customValidity`, as it does for Email and Name.
101
+
102
+ ```tsx
103
+ <PasswordField
104
+ password={password}
105
+ generateFirst
106
+ labelPosition="top"
107
+ customValidity={message === undefined ? undefined : { type: 'invalid', message }}
108
+ />
109
+ ```
110
+
89
111
  ## Users page
90
112
 
91
113
  The app holds the view in its address with godmin's `useListView`, as
@@ -110,8 +132,9 @@ const list = useListView<View>(usersListDefaults({ perPage: settings?.pageSize }
110
132
  size or `sizesFailed`.
111
133
  - A row the server marks `editable: false`, and the reader's own row,
112
134
  offer no action.
113
- - `errorMessages` gives words to the error codes only the app's
114
- transport answers. The codes authkit answers carry WordPress's words.
135
+ - `errorMessages` gives words to the error codes only the app answers,
136
+ from its server or its own transport. The codes authkit answers carry
137
+ WordPress's words.
115
138
  - `columns` adds a column after Role as data: a label and, for each
116
139
  account id, the text, the words a screen reader hears and an optional
117
140
  link element. Pass its id to `usersListDefaults` as well, to show it.
@@ -130,7 +153,15 @@ const list = useListView<View>(usersListDefaults({ perPage: settings?.pageSize }
130
153
  `@gopherium/gottext`, `@wordpress/dataviews`, `@wordpress/components`
131
154
  and `@wordpress/date`, and godmin's `Toaster` above the page, whose
132
155
  toasts show each name whole. The app loads the DataViews and
133
- components stylesheets.
156
+ components stylesheets, and `@gopherium/react-auth/wpds/style.css`,
157
+ which clips a long email under the name and sizes `RoleSelect` on a
158
+ phone.
159
+ - `/testing` loads by `import` only. `installTestEnvironment` puts
160
+ Node's `File` and `FormData` on the global, so under jsdom
161
+ `new FormData(form)`, which a React form action calls, and
162
+ `FileReader` on a `File` throw a `TypeError`, `XMLHttpRequest` sends
163
+ a form or a `File` as placeholder text, and a `File` does not mix
164
+ with a `Blob`.
134
165
  - The account step loads zxcvbn, about 400 kB gzipped, as its own chunk
135
166
  the first time it renders. Vite reports that chunk as larger than
136
167
  500 kB.
@@ -48,6 +48,16 @@ function typedError(refused, errors = roleErrors) {
48
48
  const typed = errors.get(refused.code ?? '');
49
49
  return typed && new typed(refused.message);
50
50
  }
51
+ /**
52
+ * Returns the plain error naming a refused write's status, with the server's code when it sent one.
53
+ * @param refused - The code and message the response carries.
54
+ * @param failed - The name the error gives the write and the status the server answered.
55
+ * @returns The error.
56
+ */
57
+ function plainError(refused, failed) {
58
+ const error = new Error(`${failed.write} failed with status ${failed.status}`);
59
+ return refused.code === undefined ? error : Object.assign(error, { code: refused.code });
60
+ }
51
61
  /**
52
62
  * Returns the error a refused account write throws.
53
63
  * @param response - The refused HTTP response.
@@ -63,7 +73,7 @@ async function writeError(response, post) {
63
73
  if (response.status === 422) {
64
74
  return new ValidationError(refused.message, refused);
65
75
  }
66
- return new Error(`${post.write} failed with status ${response.status}`);
76
+ return plainError(refused, { write: post.write, status: response.status });
67
77
  }
68
78
  /**
69
79
  * Posts an account write over REST and returns the body it answers.
@@ -166,7 +176,8 @@ async function restSetUserDisabled(id, disabled) {
166
176
  }
167
177
  if (!response.ok) {
168
178
  const refused = await refusalOf(response, __('the account could not be updated', DOMAIN));
169
- throw typedError(refused, disableErrors) ?? new Error(`updating user failed with status ${response.status}`);
179
+ const failed = { write: 'updating user', status: response.status };
180
+ throw typedError(refused, disableErrors) ?? plainError(refused, failed);
170
181
  }
171
182
  }
172
183
  /**
@@ -187,7 +198,8 @@ async function restSetUserRole(id, role) {
187
198
  throw new UnauthorizedError('session expired');
188
199
  }
189
200
  const refused = await refusalOf(response, __('the account could not be updated', DOMAIN));
190
- throw typedError(refused, roleChangeErrors) ?? new Error(`updating role failed with status ${response.status}`);
201
+ const failed = { write: 'updating role', status: response.status };
202
+ throw typedError(refused, roleChangeErrors) ?? plainError(refused, failed);
191
203
  }
192
204
  /**
193
205
  * Returns every user account.
@@ -7,7 +7,7 @@ export { createdAccount, firstAccountClosed, firstAccountCodeRefused, firstAccou
7
7
  export { disabledOk, disabledRefused, resendDelivered, resendRefused, resendUndelivered, roleRefused, usersFailure, usersOk, } from './users.js';
8
8
  export declare const server: import("msw/node").SetupServer;
9
9
  /**
10
- * Installs the msw server lifecycle and DOM cleanup as vitest hooks.
10
+ * Installs Node's file classes, plus the msw server lifecycle and DOM cleanup as vitest hooks.
11
11
  */
12
12
  export declare function installTestEnvironment(): void;
13
13
  /**
@@ -1,4 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
+ import { File as NodeFile } from 'node:buffer';
3
+ import { setTimeout as realTimeout } from 'node:timers';
2
4
  import '@testing-library/jest-dom/vitest';
3
5
  import { cleanup } from '@testing-library/react';
4
6
  import { HttpResponse, http } from 'msw';
@@ -9,13 +11,28 @@ export { http, HttpResponse } from 'msw';
9
11
  export { createdAccount, firstAccountClosed, firstAccountCodeRefused, firstAccountOk, firstAccountRateLimited, firstAccountStarting, setupCodeClosed, setupCodeOk, setupCodeRateLimited, setupCodeRefused, setupCodeStarting, setupStatus, } from './setup.js';
10
12
  export { disabledOk, disabledRefused, resendDelivered, resendRefused, resendUndelivered, roleRefused, usersFailure, usersOk, } from './users.js';
11
13
  export const server = setupServer();
14
+ /** NodeFormData is the FormData class Node's fetch sends a file from. */
15
+ const { constructor: NodeFormData } = await new Response(new URLSearchParams()).formData();
16
+ /** isNodeFile reports whether a value is one of Node's files. */
17
+ const isNodeFile = Function.prototype[Symbol.hasInstance].bind(NodeFile);
12
18
  /**
13
- * Installs the msw server lifecycle and DOM cleanup as vitest hooks.
19
+ * Waits one event loop turn on the real clock.
20
+ * @returns A promise that settles after the turn.
21
+ */
22
+ function realTurn() {
23
+ return new Promise((resolve) => realTimeout(resolve));
24
+ }
25
+ /**
26
+ * Installs Node's file classes, plus the msw server lifecycle and DOM cleanup as vitest hooks.
14
27
  */
15
28
  export function installTestEnvironment() {
16
- beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
17
- afterEach(() => {
29
+ Object.defineProperty(File, Symbol.hasInstance, { value: isNodeFile });
30
+ Object.assign(globalThis, { File: NodeFile, FormData: NodeFormData });
31
+ beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
32
+ afterEach(async () => {
18
33
  cleanup();
34
+ await realTurn();
35
+ await realTurn();
19
36
  server.resetHandlers();
20
37
  });
21
38
  afterAll(() => server.close());
@@ -1,7 +1,10 @@
1
- import type { ReactNode } from 'react';
1
+ import { ControlWithError } from '@wordpress/ui';
2
+ import type { ComponentProps, ReactNode } from 'react';
2
3
  import type { PasswordFieldState } from './usePasswordField.js';
3
4
  /** Where a label sits as DataForm names it: beside its field as the installer's, or above it as a godmin form's. */
4
5
  type LabelPosition = 'side' | 'top';
6
+ /** The message about the password, in the shape the WPDS ValidatedInputControl takes it. */
7
+ type CustomValidity = ComponentProps<typeof ControlWithError>['customValidity'];
5
8
  /**
6
9
  * Renders the Password row with its meter and show or hide button, then the weak password row when one is needed.
7
10
  * @param password - The password and its setters, from usePasswordField.
@@ -9,13 +12,17 @@ type LabelPosition = 'side' | 'top';
9
12
  * @param readOnly - Whether the password and its confirmation stay as they are, such as while the form is sent.
10
13
  * @param help - The line under the field, such as the installer's line asking to store the password.
11
14
  * @param labelPosition - Each label beside its field as the installer's, the default, or above it as a godmin form's.
15
+ * @param required - Marks the label (Required) and holds the form back while the field is empty.
16
+ * @param customValidity - The message under the field, an invalid one holding the form back.
12
17
  * @returns The password rows.
13
18
  */
14
- export declare function PasswordField({ password, generateFirst, readOnly, help, labelPosition, }: {
19
+ export declare function PasswordField({ password, generateFirst, readOnly, help, labelPosition, required, customValidity, }: {
15
20
  password: PasswordFieldState;
16
21
  generateFirst?: boolean;
17
22
  readOnly?: boolean;
18
23
  help?: ReactNode;
19
24
  labelPosition?: LabelPosition;
25
+ required?: boolean;
26
+ customValidity?: CustomValidity;
20
27
  }): import("react").JSX.Element;
21
28
  export {};
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { __ } from '@wordpress/i18n';
4
- import { Button, CheckboxControl, Field, Input, Stack, Text } from '@wordpress/ui';
5
- import { useId, useState } from 'react';
4
+ import { Button, CheckboxControl, ControlWithError, Field, Input, Stack, Text } from '@wordpress/ui';
5
+ import { useId, useRef, useState } from 'react';
6
6
  import { DOMAIN } from '../domain.js';
7
7
  import { levelOf } from './strength.js';
8
8
  import { needsConfirmation, toneOf } from './usePasswordField.js';
@@ -35,6 +35,15 @@ function meterWord(strength) {
35
35
  function withTone(block, tone) {
36
36
  return tone === undefined ? block : `${block} ${block}--${tone}`;
37
37
  }
38
+ /**
39
+ * Returns the classes of the meter box, as WordPress marks its own.
40
+ * @param strength - What the meter knows of the password.
41
+ * @returns The box class with the empty mark for an empty field, or with the level's tone once scored.
42
+ */
43
+ function meterClass(strength) {
44
+ const box = withTone('gopherium-setup__meter', toneOf(strength));
45
+ return strength === 'empty' ? `${box} gopherium-setup__meter--empty` : box;
46
+ }
38
47
  /**
39
48
  * Reports whether a key types a character, the only keys Caps Lock is read from.
40
49
  * @param event - The key the person pressed.
@@ -70,7 +79,7 @@ function MaskToggle({ masked, onToggle }) {
70
79
  * @returns The meter box element.
71
80
  */
72
81
  function MeterBox({ id, strength }) {
73
- return (_jsx("div", { id: id, "aria-live": "polite", className: withTone('gopherium-setup__meter', toneOf(strength)), children: meterWord(strength) }));
82
+ return (_jsx("div", { id: id, "aria-live": "polite", className: meterClass(strength), children: meterWord(strength) }));
74
83
  }
75
84
  /**
76
85
  * Renders the line that warns while Caps Lock is on, empty while it is off.
@@ -81,23 +90,27 @@ function CapsLockLine({ on }) {
81
90
  return (_jsx("div", { "aria-live": "assertive", className: "gopherium-setup__caps", children: on ? (_jsxs(_Fragment, { children: [CAPS_ARROW, _jsx("span", { className: "gopherium-setup__caps-text", children: __('Caps lock is on.', DOMAIN) })] })) : null }));
82
91
  }
83
92
  /**
84
- * Renders the password row with the meter box, the Caps Lock line and the help line when it has one.
93
+ * Renders the label and the field column of the password row: the meter box, the Caps Lock line and the help line.
94
+ * @param label - The label, which ControlWithError marks (Required) when the field is required.
95
+ * @param required - Whether the field must hold a password, set by ControlWithError.
96
+ * @param invalid - Whether the message about the password says it is invalid.
97
+ * @param inputRef - Takes the password input, the element ControlWithError checks.
85
98
  * @param password - The password and its setters.
86
99
  * @param generateFirst - Whether the Generate password button comes before the field.
87
100
  * @param readOnly - Whether the password stays as it is.
88
101
  * @param help - The line under the field, if any.
89
102
  * @param layout - How the row lays out its label and field.
90
- * @returns The row element.
103
+ * @returns The label and the field column.
91
104
  */
92
- function PasswordRow({ password, generateFirst, readOnly, help, layout, }) {
105
+ function PasswordCells({ label, required, invalid, inputRef, password, generateFirst, readOnly, help, layout, }) {
93
106
  const [capsLock, setCapsLock] = useState(false);
94
107
  const meterId = useId();
95
108
  const helpId = useId();
96
- return (_jsxs(Field.Root, { className: layout.row, render: layout.root, children: [_jsx(Field.Label, { className: layout.label, children: __('Password', DOMAIN) }), _jsxs("div", { children: [generateFirst ? _jsx(GenerateButton, { disabled: readOnly, onGenerate: password.generate }) : null, _jsxs("div", { className: "gopherium-setup__password", children: [_jsxs("div", { className: "gopherium-setup__password-field", children: [_jsx(Input, { type: password.masked ? 'password' : 'text', className: withTone('gopherium-setup__input', toneOf(password.strength)), value: password.value, readOnly: readOnly, onChange: (event) => password.change(event.target.value), onKeyDown: (event) => {
109
+ return (_jsxs(_Fragment, { children: [_jsx(Field.Label, { className: layout.label, children: label }), _jsxs("div", { children: [generateFirst ? _jsx(GenerateButton, { disabled: readOnly, onGenerate: password.generate }) : null, _jsxs("div", { className: "gopherium-setup__password", children: [_jsxs("div", { className: "gopherium-setup__password-field", children: [_jsx(Input, { ref: inputRef, type: password.masked ? 'password' : 'text', className: withTone('gopherium-setup__input', toneOf(password.strength)), value: password.value, readOnly: readOnly, required: required, onChange: (event) => password.change(event.target.value), onKeyDown: (event) => {
97
110
  if (isPrintable(event)) {
98
111
  setCapsLock(event.getModifierState('CapsLock'));
99
112
  }
100
- }, autoComplete: "new-password", spellCheck: false, "aria-describedby": help === undefined ? meterId : `${meterId} ${helpId}` }), _jsx(MeterBox, { id: meterId, strength: password.strength }), _jsx(CapsLockLine, { on: capsLock })] }), _jsx(MaskToggle, { masked: password.masked, onToggle: () => password.mask(!password.masked) })] }), help === undefined ? null : _jsx(Text, { render: _jsx("p", { id: helpId, className: "gopherium-setup__help" }), children: help })] })] }));
113
+ }, autoComplete: "new-password", spellCheck: false, "aria-invalid": invalid || undefined, "aria-describedby": help === undefined ? meterId : `${meterId} ${helpId}` }), _jsx(MeterBox, { id: meterId, strength: password.strength }), _jsx(CapsLockLine, { on: capsLock })] }), _jsx(MaskToggle, { masked: password.masked, onToggle: () => password.mask(!password.masked) })] }), help === undefined ? null : _jsx(Text, { render: _jsx("p", { id: helpId, className: "gopherium-setup__help" }), children: help })] })] }));
101
114
  }
102
115
  /**
103
116
  * Renders the row asking to confirm the use of a weak password.
@@ -122,9 +135,12 @@ function ConfirmRow({ checked, readOnly, onCheckedChange, layout, }) {
122
135
  * @param readOnly - Whether the password and its confirmation stay as they are, such as while the form is sent.
123
136
  * @param help - The line under the field, such as the installer's line asking to store the password.
124
137
  * @param labelPosition - Each label beside its field as the installer's, the default, or above it as a godmin form's.
138
+ * @param required - Marks the label (Required) and holds the form back while the field is empty.
139
+ * @param customValidity - The message under the field, an invalid one holding the form back.
125
140
  * @returns The password rows.
126
141
  */
127
- export function PasswordField({ password, generateFirst = false, readOnly = false, help, labelPosition = 'side', }) {
142
+ export function PasswordField({ password, generateFirst = false, readOnly = false, help, labelPosition = 'side', required, customValidity, }) {
128
143
  const layout = ROW_LAYOUTS[labelPosition];
129
- return (_jsxs(_Fragment, { children: [_jsx(PasswordRow, { password: password, generateFirst: generateFirst, readOnly: readOnly, help: help, layout: layout }), needsConfirmation(password.strength) ? (_jsx(ConfirmRow, { checked: password.confirmed, readOnly: readOnly, onCheckedChange: password.confirm, layout: layout })) : null] }));
144
+ const input = useRef(null);
145
+ return (_jsxs(_Fragment, { children: [_jsx(ControlWithError, { required: required, customValidity: customValidity, getValidityTarget: () => input.current, render: _jsx(Field.Root, { className: layout.row, render: layout.root }), children: _jsx(PasswordCells, { label: __('Password', DOMAIN), invalid: customValidity?.type === 'invalid', inputRef: input, password: password, generateFirst: generateFirst, readOnly: readOnly, help: help, layout: layout }) }), needsConfirmation(password.strength) ? (_jsx(ConfirmRow, { checked: password.confirmed, readOnly: readOnly, onCheckedChange: password.confirm, layout: layout })) : null] }));
130
146
  }
@@ -81,6 +81,7 @@
81
81
  .gopherium-setup__row {
82
82
  display: grid;
83
83
  grid-template-columns: 115px minmax(0, 1fr);
84
+ row-gap: 4px;
84
85
  column-gap: 20px;
85
86
  padding: 10px 20px 10px 0;
86
87
  }
@@ -96,6 +97,10 @@
96
97
  font-size: var(--wpds-typography-font-size-lg);
97
98
  }
98
99
 
100
+ .gopherium-setup__password + .gopherium-setup__help {
101
+ margin-top: var(--wpds-dimension-gap-sm);
102
+ }
103
+
99
104
  .gopherium-setup .gopherium-setup__help {
100
105
  margin: 4px 0 0;
101
106
  color: var(--wpds-color-foreground-content-neutral-weak);
@@ -146,11 +151,12 @@
146
151
  .gopherium-setup__meter {
147
152
  box-sizing: border-box;
148
153
  width: 100%;
149
- margin: -1px 0 5px;
154
+ margin: -1px 0 0;
150
155
  padding: 3px 5px;
151
156
  border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
152
157
  border-radius: var(--wpds-border-radius-sm);
153
158
  color: var(--wpds-color-foreground-content-neutral);
159
+ font-size: var(--wpds-typography-font-size-md);
154
160
  font-weight: normal;
155
161
  line-height: 1.3;
156
162
  text-align: center;
@@ -181,23 +187,34 @@
181
187
  opacity: 1;
182
188
  }
183
189
 
190
+ .gopherium-setup__meter.gopherium-setup__meter--empty:not(.gopherium-setup__row *) {
191
+ position: absolute;
192
+ width: 1px;
193
+ height: 1px;
194
+ margin: -1px;
195
+ padding: 0;
196
+ border: 0;
197
+ overflow: hidden;
198
+ clip-path: inset(50%);
199
+ }
200
+
184
201
  .gopherium-setup__input input {
185
202
  font-family: var(--wpds-typography-font-family-mono);
186
203
  }
187
204
 
188
- .gopherium-setup__input--short:not(:focus-within) {
205
+ .gopherium-setup__input--short:not(:focus-within, :has(:invalid[data-validity-visible])) {
189
206
  border-color: var(--wpds-color-stroke-surface-error-strong);
190
207
  }
191
208
 
192
- .gopherium-setup__input--bad:not(:focus-within) {
209
+ .gopherium-setup__input--bad:not(:focus-within, :has(:invalid[data-validity-visible])) {
193
210
  border-color: var(--wpds-color-stroke-surface-error);
194
211
  }
195
212
 
196
- .gopherium-setup__input--good:not(:focus-within) {
213
+ .gopherium-setup__input--good:not(:focus-within, :has(:invalid[data-validity-visible])) {
197
214
  border-color: var(--wpds-color-stroke-surface-caution);
198
215
  }
199
216
 
200
- .gopherium-setup__input--strong:not(:focus-within) {
217
+ .gopherium-setup__input--strong:not(:focus-within, :has(:invalid[data-validity-visible])) {
201
218
  border-color: var(--wpds-color-stroke-surface-success);
202
219
  }
203
220
 
@@ -207,13 +224,14 @@
207
224
 
208
225
  .gopherium-setup__caps {
209
226
  position: relative;
210
- top: -8px;
227
+ top: -3px;
211
228
  display: flex;
212
229
  align-items: flex-start;
213
230
  padding: 6px 10px;
214
231
  border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-caution);
215
232
  background-color: var(--wpds-color-background-surface-caution-weak);
216
233
  color: var(--wpds-color-foreground-content-neutral);
234
+ font-size: var(--wpds-typography-font-size-md);
217
235
  line-height: 1.3;
218
236
  }
219
237
 
@@ -222,6 +240,18 @@
222
240
  font-size: 14px;
223
241
  }
224
242
 
243
+ .gopherium-setup__row .gopherium-setup__meter {
244
+ margin-bottom: 5px;
245
+ }
246
+
247
+ .gopherium-setup__row .gopherium-setup__caps {
248
+ top: -8px;
249
+ }
250
+
251
+ .gopherium-setup__meter--empty:not(.gopherium-setup__row *) + .gopherium-setup__caps {
252
+ top: -1px;
253
+ }
254
+
225
255
  .gopherium-setup__caps-icon {
226
256
  display: flex;
227
257
  flex: none;
@@ -237,6 +267,28 @@
237
267
  text-align: start;
238
268
  }
239
269
 
270
+ .gopherium-user-field__email {
271
+ display: block;
272
+ max-width: 100%;
273
+ overflow: hidden;
274
+ text-overflow: ellipsis;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .gopherium-setup__row > p {
279
+ grid-column: -2 / -1;
280
+ }
281
+
282
+ .gopherium-role-select [role="combobox"] {
283
+ font-size: max(var(--wpds-typography-font-size-md), 16px);
284
+ }
285
+
286
+ @media (min-width: 600px) {
287
+ .gopherium-role-select [role="combobox"] {
288
+ font-size: var(--wpds-typography-font-size-md);
289
+ }
290
+ }
291
+
240
292
  @media screen and (max-width: 782px) {
241
293
  .gopherium-setup__row {
242
294
  grid-template-columns: minmax(0, 1fr);
@@ -14,5 +14,5 @@ export function RoleSelect({ grantable, value, onChange, roleLabels, placeholder
14
14
  .reverse()
15
15
  .map((role) => ({ value: role, label: roleLabelOf(role, roleLabels) }));
16
16
  const items = placeholder === undefined ? roles : [{ value: null, label: placeholder }, ...roles];
17
- return (_jsx(SelectControl, { label: __('Role', DOMAIN), items: items, value: items.find((item) => (item.value ?? '') === value) ?? null, placeholder: placeholder, onValueChange: (item) => onChange(item?.value ?? '') }));
17
+ return (_jsx(SelectControl, { className: "gopherium-role-select", label: __('Role', DOMAIN), items: items, value: items.find((item) => (item.value ?? '') === value) ?? null, placeholder: placeholder, onValueChange: (item) => onChange(item?.value ?? '') }));
18
18
  }
@@ -6,8 +6,8 @@
6
6
  */
7
7
  export function usersListDefaults({ perPage, columns = [] } = {}) {
8
8
  return {
9
- fields: ['status', 'role', ...columns, 'created'],
10
- phoneFields: ['status', 'role', 'created'],
9
+ fields: ['status', 'role', ...columns],
10
+ phoneFields: ['status', 'role'],
11
11
  titleField: 'name',
12
12
  descriptionField: 'email',
13
13
  mediaField: 'avatar',
@@ -1,9 +1,9 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { InitialsAvatar } from '@gopherium/godmin';
4
4
  import { listDate } from '@gopherium/godmin/dates';
5
5
  import { __, _x } from '@wordpress/i18n';
6
- import { Badge, VisuallyHidden } from '@wordpress/ui';
6
+ import { Badge, Tooltip, VisuallyHidden } from '@wordpress/ui';
7
7
  import { cloneElement } from 'react';
8
8
  import { DOMAIN } from '../../domain.js';
9
9
  import { roleCellOf, roleLabelOf } from './words.js';
@@ -55,11 +55,18 @@ function nameField() {
55
55
  };
56
56
  }
57
57
  /**
58
- * Returns the email field, the line under the name.
58
+ * Returns the email field, the line under the name, clipped with an ellipsis at the column edge, whole in a tooltip.
59
59
  * @returns The field.
60
60
  */
61
61
  function emailField() {
62
- return { id: 'email', label: __('Email', DOMAIN), type: 'text', enableGlobalSearch: true, filterBy: false };
62
+ return {
63
+ id: 'email',
64
+ label: __('Email', DOMAIN),
65
+ type: 'text',
66
+ enableGlobalSearch: true,
67
+ filterBy: false,
68
+ render: ({ item }) => (_jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { render: _jsx("span", { className: "gopherium-user-field__email", children: item.email }) }), _jsx(Tooltip.Popup, { children: item.email })] })),
69
+ };
63
70
  }
64
71
  /**
65
72
  * Returns the avatar field, the picture the server sends or the initial of the name.
@@ -42,7 +42,7 @@ function codeMessages() {
42
42
  };
43
43
  }
44
44
  /**
45
- * Returns the code an error carries, as every typed error of the admin calls does.
45
+ * Returns the code an error carries, as every typed admin error does, and a plain write error when the server sent one.
46
46
  * @param error - What a write failed with.
47
47
  * @returns The code, none for an error carrying no code.
48
48
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gopherium/react-auth",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Session authentication for React apps backed by gouncer's authkit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -64,9 +64,9 @@
64
64
  "@wordpress/icons": ">=18.0.0 <19.0.0",
65
65
  "@wordpress/theme": ">=2.3.0 <3.0.0",
66
66
  "@wordpress/ui": ">=0.24.0 <0.25.0",
67
- "msw": "^2.15.0",
67
+ "msw": "^3.0.0",
68
68
  "react": "^19.2.7",
69
- "vitest": "^4.1.10 || ^5.0.0"
69
+ "vitest": "^5.0.4"
70
70
  },
71
71
  "peerDependenciesMeta": {
72
72
  "@gopherium/godmin": {
@@ -115,10 +115,11 @@
115
115
  "@testing-library/react": "^16.3.2",
116
116
  "@testing-library/user-event": "^14.6.1",
117
117
  "@types/gettext-parser": "9.0.0",
118
+ "@types/node": "^26.1.1",
118
119
  "@types/react": "^19.2.17",
119
120
  "@types/zxcvbn": "4.4.5",
120
121
  "@vitejs/plugin-react": "^6.0.3",
121
- "@vitest/coverage-v8": "^5.0.3",
122
+ "@vitest/coverage-v8": "^5.0.5",
122
123
  "@wordpress/a11y": "4.57.0",
123
124
  "@wordpress/components": "42.0.0",
124
125
  "@wordpress/dataviews": "20.0.0",
@@ -135,13 +136,13 @@
135
136
  "gettext-extractor": "4.0.6",
136
137
  "gettext-parser": "9.1.1",
137
138
  "jsdom": "^30.1.2",
138
- "msw": "^2.15.0",
139
+ "msw": "^3.0.3",
139
140
  "oxlint": "^1.71.0",
140
141
  "react": "^19.2.7",
141
142
  "react-dom": "^19.2.7",
142
143
  "typescript": "~6.0.2",
143
144
  "typescript-eslint": "^8.46.4",
144
145
  "vite": "^8.1.4",
145
- "vitest": "^5.0.3"
146
+ "vitest": "^5.0.5"
146
147
  }
147
148
  }