@sjolystinnovation/app-kit 0.2.0 → 0.4.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 +229 -14
- package/package.json +4 -1
- package/src/config.ts +22 -2
- package/src/errors.ts +99 -20
- package/src/forms/index.ts +2 -0
- package/src/forms/useFormDraft.ts +133 -0
- package/src/index.ts +1 -0
- package/src/session/expiry.ts +66 -0
- package/src/session/index.ts +9 -0
- package/src/session/react/index.ts +2 -0
- package/src/session/react/useSessionGate.ts +63 -0
- package/src/styles.css +41 -0
- package/src/ui/Alert.tsx +13 -6
- package/src/ui/PasswordInput.tsx +31 -15
- package/src/ui/cx.ts +4 -0
package/README.md
CHANGED
|
@@ -64,14 +64,14 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
|
|
|
64
64
|
| --- | --- | --- |
|
|
65
65
|
| `jwtSecretEnvVar` | `string` | Name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. |
|
|
66
66
|
| `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
|
|
67
|
-
| `draftStoragePrefix` | `string` |
|
|
67
|
+
| `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
|
|
68
68
|
|
|
69
69
|
### `defineSessionConfig(config)`
|
|
70
70
|
|
|
71
71
|
`(config: SessionConfig) => SessionConfig`
|
|
72
72
|
|
|
73
73
|
Returns the config unchanged, so TypeScript checks its shape where it is declared rather than at
|
|
74
|
-
every call site.
|
|
74
|
+
every call site. Throws when `draftStoragePrefix` is empty or contains a colon.
|
|
75
75
|
|
|
76
76
|
### `resolveJwtSecret(config, env)`
|
|
77
77
|
|
|
@@ -91,23 +91,184 @@ resolveJwtSecret(sessionConfig, {});
|
|
|
91
91
|
The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
|
|
92
92
|
package that also runs in the browser.
|
|
93
93
|
|
|
94
|
-
### `formatApiError(error, fallback)`
|
|
94
|
+
### `formatApiError(error, fallback, statusMessages?)`
|
|
95
95
|
|
|
96
|
-
`(error: unknown, fallback: string) => string`
|
|
96
|
+
`(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
|
|
97
97
|
|
|
98
|
-
Turns a failed request into one line a user can read.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
Turns a failed request into one line a user can read.
|
|
99
|
+
|
|
100
|
+
On a 4xx response it uses the first of:
|
|
101
|
+
|
|
102
|
+
1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
|
|
103
|
+
or `message`, the first validation error or `detail` from a JSON body.
|
|
104
|
+
2. The entry in `statusMessages` for the status.
|
|
105
|
+
3. `fallback`.
|
|
106
|
+
|
|
107
|
+
On a 5xx response the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
|
|
108
|
+
body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
|
|
109
|
+
|
|
110
|
+
A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
|
|
111
|
+
Request" or "An error occurred while processing your request.", which says less than `fallback`.
|
|
112
|
+
|
|
113
|
+
Key `0` in `statusMessages` covers a request that got no response, because of a dropped connection
|
|
114
|
+
or a timeout. Only text on a single line counts as a message. Axios's own message, such as "Request
|
|
115
|
+
failed with status code 500", is never shown, and a cancelled request gives `fallback`. Any other
|
|
116
|
+
`Error` gives its own message. A body requested as an `arraybuffer` or a `blob`, as for a file
|
|
117
|
+
download, is not read, so a failed download gets `statusMessages` or `fallback`.
|
|
102
118
|
|
|
103
119
|
```ts
|
|
104
120
|
try {
|
|
105
121
|
await api.post("/users", form);
|
|
106
122
|
} catch (error) {
|
|
107
|
-
setError(formatApiError(error, "Could not create the user."));
|
|
123
|
+
setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `@sjolystinnovation/app-kit/session`
|
|
128
|
+
|
|
129
|
+
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
|
130
|
+
callbacks.
|
|
131
|
+
|
|
132
|
+
The gate reads `session.error`. The app's `jwt` callback sets `token.error`, and its `session`
|
|
133
|
+
callback copies it across:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
callbacks: {
|
|
137
|
+
session({ session, token }) {
|
|
138
|
+
session.error = token.error;
|
|
139
|
+
return session;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
|
|
145
|
+
the app's existing next-auth type declarations, or create them:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// src/types/next-auth.d.ts
|
|
149
|
+
import "next-auth";
|
|
150
|
+
import "next-auth/jwt";
|
|
151
|
+
|
|
152
|
+
declare module "next-auth" {
|
|
153
|
+
interface Session {
|
|
154
|
+
error?: string | undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
declare module "next-auth/jwt" {
|
|
159
|
+
interface JWT {
|
|
160
|
+
error?: string | undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
|
|
166
|
+
recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
|
|
167
|
+
is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
|
|
168
|
+
refresh failure keeps the session.
|
|
169
|
+
|
|
170
|
+
The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
|
|
171
|
+
|
|
172
|
+
| Function | Description |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
|
|
175
|
+
| `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
|
|
176
|
+
| `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
|
|
177
|
+
| `getSessionPromptOpen()` | Whether the prompt is showing. |
|
|
178
|
+
|
|
179
|
+
Copies of the package installed side by side share the one flag, as long as their releases use the
|
|
180
|
+
same prompt state format. A release that changes the format says so in its changelog. An app's own
|
|
181
|
+
copy of these functions does not, so an app moving to them moves every import at once: the
|
|
182
|
+
interceptor, the prompt and every gate.
|
|
183
|
+
|
|
184
|
+
### `useSessionGate()`
|
|
185
|
+
|
|
186
|
+
From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
|
|
187
|
+
reads `useSession`, so it needs a `SessionProvider` above it.
|
|
188
|
+
|
|
189
|
+
A page that rendered on a healthy session stays rendered when the session gets a terminal error,
|
|
190
|
+
while the session is re-read and while the prompt recovers it, so an open form keeps its values. A
|
|
191
|
+
session that turns signed out with no prompt open blanks the page. A page the user opens
|
|
192
|
+
on a dead session does not render. Use `mayRender` in every gate and layout on the way down, so
|
|
193
|
+
none of them blanks a page that another one kept.
|
|
194
|
+
|
|
195
|
+
The hook does not navigate. The gate sends the user to the login page itself, and never while the
|
|
196
|
+
prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
|
|
197
|
+
for the current locale:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
"use client";
|
|
201
|
+
|
|
202
|
+
import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
|
|
203
|
+
import { useRouter } from "next/navigation";
|
|
204
|
+
import { useEffect } from "react";
|
|
205
|
+
|
|
206
|
+
export function ProtectedGate({
|
|
207
|
+
loginHref,
|
|
208
|
+
children,
|
|
209
|
+
}: {
|
|
210
|
+
loginHref: string;
|
|
211
|
+
children: React.ReactNode;
|
|
212
|
+
}) {
|
|
213
|
+
const router = useRouter();
|
|
214
|
+
const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (promptOpen) return;
|
|
218
|
+
if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
|
|
219
|
+
router.push(loginHref);
|
|
220
|
+
}
|
|
221
|
+
}, [status, usable, wasUsable, promptOpen, router, loginHref]);
|
|
222
|
+
|
|
223
|
+
return mayRender ? children : <p>Loading…</p>;
|
|
108
224
|
}
|
|
109
225
|
```
|
|
110
226
|
|
|
227
|
+
| Field | Description |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `mayRender` | Whether the page may render. |
|
|
230
|
+
| `session`, `status` | As `useSession` returns them. |
|
|
231
|
+
| `usable` | The session is signed in and has no terminal error. |
|
|
232
|
+
| `wasUsable` | This page has rendered on a usable session. |
|
|
233
|
+
| `promptOpen` | The re-sign-in prompt is showing. |
|
|
234
|
+
| `recovering` | The prompt is showing over a page that was usable. |
|
|
235
|
+
|
|
236
|
+
`SessionGate` is the type of the result.
|
|
237
|
+
|
|
238
|
+
### `useFormDraft(config, options)`
|
|
239
|
+
|
|
240
|
+
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
241
|
+
reload or closed tab does not lose them. Values are written half a second after the last change, so
|
|
242
|
+
only that last half second is at risk.
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// userId is the signed-in user's id, from wherever the app's session keeps it.
|
|
246
|
+
const draft = useFormDraft(sessionConfig, {
|
|
247
|
+
owner: userId,
|
|
248
|
+
scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
|
|
249
|
+
value: form,
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Option | Type | Description |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| `owner` | `string \| undefined` | The signed-in user's id. Drafts are stored per user, and nothing is stored until it is known. The last known owner is kept when the session stops reporting one. |
|
|
256
|
+
| `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
|
|
257
|
+
| `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
|
|
258
|
+
|
|
259
|
+
Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
|
|
260
|
+
the values it first sees under a key as the untouched form, so values that arrive later count as an
|
|
261
|
+
edit and are written over the draft being offered.
|
|
262
|
+
|
|
263
|
+
It returns `pending`, the stored draft, which the page offers instead of applying. It is read when the
|
|
264
|
+
owner is first known and again whenever the owner or `scope` changes, so it can appear after the form mounts. It
|
|
265
|
+
also returns `dismiss()` to stop offering it, and `clear()` to drop the stored draft after a
|
|
266
|
+
successful save.
|
|
267
|
+
|
|
268
|
+
Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
|
|
269
|
+
`draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
|
|
270
|
+
removed when its form next reads it.
|
|
271
|
+
|
|
111
272
|
### `Alert`
|
|
112
273
|
|
|
113
274
|
```tsx
|
|
@@ -120,12 +281,14 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
120
281
|
| --- | --- | --- | --- |
|
|
121
282
|
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
|
|
122
283
|
| `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
|
|
284
|
+
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables instead, since whether a clashing class wins depends on the order Tailwind emits them in. |
|
|
123
285
|
| `children` | `ReactNode` | | Content. |
|
|
124
286
|
|
|
125
287
|
### `PasswordInput`
|
|
126
288
|
|
|
127
|
-
A labelled password field with a button that shows and hides the value.
|
|
128
|
-
|
|
289
|
+
A labelled password field with a button that shows and hides the value. It also takes the other
|
|
290
|
+
`<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
|
|
291
|
+
the outer element, see below.
|
|
129
292
|
|
|
130
293
|
```tsx
|
|
131
294
|
import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
@@ -136,17 +299,69 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
|
136
299
|
| Prop | Type | Default | Description |
|
|
137
300
|
| --- | --- | --- | --- |
|
|
138
301
|
| `label` | `string` | | Visible label, tied to the input. |
|
|
139
|
-
| `error` | `string` | | Message shown under the field
|
|
302
|
+
| `error` | `string` | | Message shown under the field. It turns the border red, marks the input invalid and is read out with it by screen readers. |
|
|
140
303
|
| `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
|
|
141
304
|
| `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
|
|
305
|
+
| `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables instead. |
|
|
142
306
|
|
|
143
307
|
Pass the two toggle labels in the app's language. The defaults are English.
|
|
144
308
|
|
|
309
|
+
## Theming
|
|
310
|
+
|
|
311
|
+
The components draw every colour, corner radius and label size from theme variables with a light
|
|
312
|
+
default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
|
|
313
|
+
selector such as `.dark` for a scoped theme:
|
|
314
|
+
|
|
315
|
+
```css
|
|
316
|
+
/* src/app/globals.css */
|
|
317
|
+
@import "tailwindcss";
|
|
318
|
+
@import "@sjolystinnovation/app-kit/styles.css";
|
|
319
|
+
|
|
320
|
+
@theme {
|
|
321
|
+
--color-alert-error-bg: color-mix(in oklab, var(--color-red-500) 10%, transparent);
|
|
322
|
+
--color-alert-error-text: var(--color-red-400);
|
|
323
|
+
--radius-alert: var(--radius-xl);
|
|
324
|
+
--color-input-focus-ring: var(--color-sky-500);
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
| Variable | Default |
|
|
329
|
+
| --- | --- |
|
|
330
|
+
| `--radius-alert` | `var(--radius-lg)` |
|
|
331
|
+
| `--color-alert-error-bg`, `-border`, `-text` | red 50, 200, 700 |
|
|
332
|
+
| `--color-alert-success-bg`, `-border`, `-text` | green 50, 200, 700 |
|
|
333
|
+
| `--color-alert-info-bg`, `-border`, `-text` | gray 50, 200, 700 |
|
|
334
|
+
| `--color-alert-warning-bg`, `-border`, `-text` | amber 50, 200, 900 |
|
|
335
|
+
| `--text-field-label`, `--text-field-label--line-height` | `var(--text-sm)` and its line height. Set both together. |
|
|
336
|
+
| `--color-field-label-text` | gray 700 |
|
|
337
|
+
| `--spacing-field-label` | gap under the label, `calc(var(--spacing) * 1)` |
|
|
338
|
+
| `--color-field-required` | red 600, the asterisk on a required field |
|
|
339
|
+
| `--color-field-error` | red 600, the message under a field |
|
|
340
|
+
| `--radius-input` | `var(--radius-lg)` |
|
|
341
|
+
| `--spacing-input-y` | `calc(var(--spacing) * 2)` |
|
|
342
|
+
| `--color-input-bg` | `transparent` |
|
|
343
|
+
| `--color-input-border` | gray 300 |
|
|
344
|
+
| `--color-input-border-error` | red 400 |
|
|
345
|
+
| `--color-input-focus-border` | gray 300 |
|
|
346
|
+
| `--color-input-text` | gray 900 |
|
|
347
|
+
| `--color-input-placeholder` | gray 400 |
|
|
348
|
+
| `--color-input-focus-ring` | `var(--color-primary)` |
|
|
349
|
+
| `--input-focus-ring-width` | `2px` |
|
|
350
|
+
| `--color-input-toggle` | gray 400, the show password icon |
|
|
351
|
+
| `--color-input-toggle-hover` | gray 600 |
|
|
352
|
+
|
|
353
|
+
A default that points at another variable, such as `--color-input-focus-ring` at `--color-primary`,
|
|
354
|
+
is read once for the whole page. A scoped theme that changes `--color-primary` should set
|
|
355
|
+
`--color-input-focus-ring` in the same selector.
|
|
356
|
+
|
|
357
|
+
An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
|
|
358
|
+
with it and has to set the ones it uses itself.
|
|
359
|
+
|
|
145
360
|
## Requirements
|
|
146
361
|
|
|
147
362
|
- Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
|
|
148
|
-
- For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4
|
|
149
|
-
|
|
363
|
+
- For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
|
|
364
|
+
the theme's `primary` colour unless `--color-input-focus-ring` is set.
|
|
150
365
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
151
366
|
`node16` and `nodenext` reject.
|
|
152
367
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Sjølyst Innovation AS",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
],
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
|
+
"./session": "./src/session/index.ts",
|
|
20
|
+
"./session/react": "./src/session/react/index.ts",
|
|
21
|
+
"./forms": "./src/forms/index.ts",
|
|
19
22
|
"./ui": "./src/ui/index.ts",
|
|
20
23
|
"./styles.css": "./src/styles.css"
|
|
21
24
|
},
|
package/src/config.ts
CHANGED
|
@@ -13,15 +13,35 @@ export interface SessionConfig {
|
|
|
13
13
|
* something like "/en/login", one without uses "/login".
|
|
14
14
|
*/
|
|
15
15
|
loginRoute: string;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Prefix for the localStorage keys holding form drafts, usually the app's name, such as
|
|
18
|
+
* "example". Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
|
|
19
|
+
* has no trailing colon.
|
|
20
|
+
*/
|
|
17
21
|
draftStoragePrefix: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Declares an app's config as a typed constant. Throws when `draftStoragePrefix` is not a bare name,
|
|
26
|
+
* so a bad value fails when the app starts rather than when a form first opens.
|
|
27
|
+
*/
|
|
21
28
|
export function defineSessionConfig(config: SessionConfig): SessionConfig {
|
|
29
|
+
draftPrefix(config);
|
|
22
30
|
return config;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The key prefix for form drafts. A colon or an empty name builds keys such as "app:draft::draft:",
|
|
35
|
+
* which match none of the drafts already saved under "app:draft:".
|
|
36
|
+
*/
|
|
37
|
+
export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
|
|
38
|
+
const name = config.draftStoragePrefix;
|
|
39
|
+
if (!name || name.includes(":")) {
|
|
40
|
+
throw new Error(`draftStoragePrefix must be a bare name such as "example", not "${name}".`);
|
|
41
|
+
}
|
|
42
|
+
return `${name}:draft:`;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/**
|
|
26
46
|
* Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
|
|
27
47
|
* missing secret silently breaks token verification at runtime instead of at boot.
|
package/src/errors.ts
CHANGED
|
@@ -1,29 +1,108 @@
|
|
|
1
|
-
import axios from "axios";
|
|
1
|
+
import axios, { type AxiosResponse } from "axios";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Messages to show for a status code. On a 4xx they are used when the response carries no specific
|
|
5
|
+
* message of its own. On a 5xx they win over the body. Key 0 covers a request that got no response,
|
|
6
|
+
* because of a dropped connection or a timeout.
|
|
7
|
+
*/
|
|
8
|
+
export type StatusMessages = Partial<Record<number, string>>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Axios codes for a request that never got a response from the network. A browser reports every
|
|
12
|
+
* network failure as ERR_NETWORK. On the server, Node reports the cause instead.
|
|
13
|
+
*/
|
|
14
|
+
const NO_RESPONSE_CODES = new Set([
|
|
15
|
+
"ERR_NETWORK",
|
|
16
|
+
"ECONNABORTED",
|
|
17
|
+
"ETIMEDOUT",
|
|
18
|
+
"ECONNREFUSED",
|
|
19
|
+
"ECONNRESET",
|
|
20
|
+
"ENOTFOUND",
|
|
21
|
+
"EAI_AGAIN",
|
|
22
|
+
"EHOSTUNREACH",
|
|
23
|
+
"ENETUNREACH",
|
|
24
|
+
]);
|
|
9
25
|
|
|
10
26
|
/**
|
|
11
|
-
* Turns a failed request into one line a user can read.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
27
|
+
* Turns a failed request into one line a user can read.
|
|
28
|
+
*
|
|
29
|
+
* On a 4xx response the body's own message comes first: a text body, then `message`, the first
|
|
30
|
+
* validation error and `detail` from a JSON body. After that come `statusMessages` for the status
|
|
31
|
+
* and `fallback`. On a 5xx the caller's status message comes first, because the body may be a
|
|
32
|
+
* proxy's "Bad Gateway" rather than the API's own words. A problem details `title` is never used:
|
|
33
|
+
* ASP.NET Core fills it with generic text such as "Bad Request", which says less than `fallback`.
|
|
34
|
+
*
|
|
35
|
+
* Only single line text counts as a message. Axios's own message, such as "Request failed with
|
|
36
|
+
* status code 500", is never shown, and a cancelled request gives `fallback`. Any other `Error`
|
|
37
|
+
* gives its message.
|
|
15
38
|
*/
|
|
16
|
-
export function formatApiError(
|
|
39
|
+
export function formatApiError(
|
|
40
|
+
error: unknown,
|
|
41
|
+
fallback: string,
|
|
42
|
+
statusMessages: StatusMessages = {},
|
|
43
|
+
): string {
|
|
44
|
+
if (axios.isCancel(error)) return fallback;
|
|
17
45
|
if (axios.isAxiosError(error)) {
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
const first = Object.values(data.errors)[0];
|
|
22
|
-
if (first && first.length) return first[0];
|
|
46
|
+
const response = error.response;
|
|
47
|
+
if (!response) {
|
|
48
|
+
return NO_RESPONSE_CODES.has(error.code ?? "") ? (statusMessages[0] ?? fallback) : fallback;
|
|
23
49
|
}
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
const message = readBody(response);
|
|
51
|
+
const forStatus = statusMessages[response.status];
|
|
52
|
+
if (response.status >= 500) return forStatus ?? message ?? fallback;
|
|
53
|
+
return message ?? forStatus ?? fallback;
|
|
26
54
|
}
|
|
27
|
-
if (error instanceof Error) return error.message;
|
|
55
|
+
if (error instanceof Error) return singleLine(error.message) ?? fallback;
|
|
28
56
|
return fallback;
|
|
29
57
|
}
|
|
58
|
+
|
|
59
|
+
/** The message the body carries for this failure, if it carries one. */
|
|
60
|
+
function readBody(response: AxiosResponse): string | undefined {
|
|
61
|
+
const data = parseIfJson(response.data);
|
|
62
|
+
if (typeof data === "string") return singleLine(data);
|
|
63
|
+
// Only a parsed JSON object has fields to read. A Document, Blob or ArrayBuffer does not.
|
|
64
|
+
if (!isPlainObject(data)) return undefined;
|
|
65
|
+
return firstText(data.message) ?? firstValidationError(data.errors) ?? singleLine(data.detail);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Axios leaves a JSON body as a string when the request asked for text, so parse it here. Plain text
|
|
70
|
+
* such as "Invalid email or password." is not valid JSON and stays as it is.
|
|
71
|
+
*/
|
|
72
|
+
function parseIfJson(data: unknown): unknown {
|
|
73
|
+
if (typeof data !== "string") return data;
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(data);
|
|
76
|
+
} catch {
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
if (!value || typeof value !== "object") return false;
|
|
83
|
+
const prototype = Object.getPrototypeOf(value);
|
|
84
|
+
return prototype === Object.prototype || prototype === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reads the first message from `errors`, whether its values are string arrays or strings. */
|
|
88
|
+
function firstValidationError(errors: unknown): string | undefined {
|
|
89
|
+
if (!errors || typeof errors !== "object") return undefined;
|
|
90
|
+
for (const value of Object.values(errors)) {
|
|
91
|
+
const message = firstText(value);
|
|
92
|
+
if (message) return message;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A single line of text, or the first entry when the value is a list of them. */
|
|
98
|
+
function firstText(value: unknown): string | undefined {
|
|
99
|
+
return singleLine(Array.isArray(value) ? value[0] : value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The trimmed text, when it is one line of text and not HTML. */
|
|
103
|
+
function singleLine(value: unknown): string | undefined {
|
|
104
|
+
if (typeof value !== "string") return undefined;
|
|
105
|
+
const text = value.trim();
|
|
106
|
+
if (!text || /[\r\n\u2028\u2029]/.test(text) || text.startsWith("<")) return undefined;
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { draftPrefix, type SessionConfig } from "../config";
|
|
5
|
+
|
|
6
|
+
const WRITE_DELAY_MS = 500;
|
|
7
|
+
|
|
8
|
+
// How long a draft is offered. Long enough to outlive an expired session or a closed tab, short
|
|
9
|
+
// enough that week-old work is not offered as if it were current. An expired draft is removed when
|
|
10
|
+
// its form next reads it.
|
|
11
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
12
|
+
|
|
13
|
+
interface Envelope<T> {
|
|
14
|
+
savedAt: number;
|
|
15
|
+
value: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function read<T>(storageKey: string): T | null {
|
|
19
|
+
try {
|
|
20
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
21
|
+
if (raw == null) return null;
|
|
22
|
+
|
|
23
|
+
const envelope = JSON.parse(raw) as Envelope<T> | null;
|
|
24
|
+
const fresh =
|
|
25
|
+
typeof envelope?.savedAt === "number" && Date.now() - envelope.savedAt <= MAX_AGE_MS;
|
|
26
|
+
if (!fresh || envelope?.value === undefined) {
|
|
27
|
+
window.localStorage.removeItem(storageKey);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return envelope.value;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FormDraftOptions<T> {
|
|
37
|
+
/**
|
|
38
|
+
* The signed-in user's id. A browser profile can be shared, so the draft is stored per user.
|
|
39
|
+
* Without that, the next person to sign in is offered the previous one's work. The last known
|
|
40
|
+
* owner is kept, because a lost cookie reports nobody and that is the moment the draft matters
|
|
41
|
+
* most. Nothing is stored until an owner is known.
|
|
42
|
+
*/
|
|
43
|
+
owner: string | undefined;
|
|
44
|
+
/** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
|
|
45
|
+
scope: string;
|
|
46
|
+
/** The form's current values. They must survive `JSON.stringify`. */
|
|
47
|
+
value: T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FormDraft<T> {
|
|
51
|
+
/**
|
|
52
|
+
* The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
|
|
53
|
+
* known and again whenever the owner or scope changes, so it can appear after mount.
|
|
54
|
+
*/
|
|
55
|
+
pending: T | null;
|
|
56
|
+
/** Stop offering the draft. The caller applies the values it wants. */
|
|
57
|
+
dismiss: () => void;
|
|
58
|
+
/** Drop the stored draft after a successful save. */
|
|
59
|
+
clear: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
|
|
64
|
+
* Values are written half a second after the last change, so only that last half second is at risk.
|
|
65
|
+
*
|
|
66
|
+
* A draft found at mount is offered for restore rather than applied, so an edit form never silently
|
|
67
|
+
* overwrites what the API returned. Saving starts immediately either way. The offer is held in
|
|
68
|
+
* memory, so the newest work is always the thing in storage.
|
|
69
|
+
*/
|
|
70
|
+
export function useFormDraft<T>(
|
|
71
|
+
config: Pick<SessionConfig, "draftStoragePrefix">,
|
|
72
|
+
{ owner, scope, value }: FormDraftOptions<T>,
|
|
73
|
+
): FormDraft<T> {
|
|
74
|
+
// Adjusted during render rather than in an effect, which is the supported way to derive state
|
|
75
|
+
// from changing inputs.
|
|
76
|
+
const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
|
|
77
|
+
if (owner && owner !== lastOwner) setLastOwner(owner);
|
|
78
|
+
|
|
79
|
+
// Checked before the owner is known, so a bad prefix fails on the first render, signed in or not.
|
|
80
|
+
const prefix = draftPrefix(config);
|
|
81
|
+
const key = lastOwner ? `${prefix}${lastOwner}:${scope}` : null;
|
|
82
|
+
|
|
83
|
+
const [pending, setPending] = useState<T | null>(null);
|
|
84
|
+
const json = JSON.stringify(value);
|
|
85
|
+
const untouched = useRef(json);
|
|
86
|
+
const writeTimer = useRef<number | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
// Re-base on a key change too. The values on screen belong to the old key, and writing them
|
|
90
|
+
// under the new one would put this form's work in another form's draft.
|
|
91
|
+
untouched.current = json;
|
|
92
|
+
// Reading the store is an external-system read, which is what an effect is for.
|
|
93
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
94
|
+
setPending(key == null ? null : read<T>(key));
|
|
95
|
+
// Keyed on the draft key alone on purpose. Re-reading whenever the values change would
|
|
96
|
+
// re-offer a draft the user has already answered.
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [key]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (key == null) return;
|
|
102
|
+
// Write only once the form differs from how it opened. Writing the pristine form would
|
|
103
|
+
// overwrite the draft being offered before the user has answered.
|
|
104
|
+
if (json === untouched.current) return;
|
|
105
|
+
const timer = window.setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
window.localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), value }));
|
|
108
|
+
} catch {
|
|
109
|
+
// A full or blocked store must not break the form.
|
|
110
|
+
}
|
|
111
|
+
}, WRITE_DELAY_MS);
|
|
112
|
+
writeTimer.current = timer;
|
|
113
|
+
return () => window.clearTimeout(timer);
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [key, json]);
|
|
116
|
+
|
|
117
|
+
const clear = useCallback(() => {
|
|
118
|
+
if (key == null) return;
|
|
119
|
+
// Cancel a write that is already scheduled, and treat the current values as the new baseline.
|
|
120
|
+
// Otherwise a save is followed by the draft being written back.
|
|
121
|
+
if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
|
|
122
|
+
untouched.current = json;
|
|
123
|
+
try {
|
|
124
|
+
window.localStorage.removeItem(key);
|
|
125
|
+
} catch {
|
|
126
|
+
// The draft is a convenience, not state anything depends on.
|
|
127
|
+
}
|
|
128
|
+
}, [key, json]);
|
|
129
|
+
|
|
130
|
+
const dismiss = useCallback(() => setPending(null), []);
|
|
131
|
+
|
|
132
|
+
return { pending, dismiss, clear };
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
|
|
3
|
+
* transient refresh failure, such as a network error or an API restart, sets no error, so the
|
|
4
|
+
* session keeps working and a later session read retries the refresh.
|
|
5
|
+
*/
|
|
6
|
+
export const SESSION_ERRORS = {
|
|
7
|
+
absoluteExpiry: "AbsoluteSessionExpired",
|
|
8
|
+
refreshRejected: "RefreshTokenRejected",
|
|
9
|
+
noRefreshToken: "NoRefreshToken",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
/** One of the values in `SESSION_ERRORS`. */
|
|
13
|
+
export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
|
|
14
|
+
|
|
15
|
+
const TERMINAL: ReadonlySet<string> = new Set(Object.values(SESSION_ERRORS));
|
|
16
|
+
|
|
17
|
+
/** Whether a session error means the user has to sign in again. */
|
|
18
|
+
export function isTerminalSessionError(error: string | undefined): boolean {
|
|
19
|
+
return error != null && TERMINAL.has(error);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Whether the re-sign-in prompt is showing. It lives outside React because the axios interceptor
|
|
23
|
+
// that raises it is a plain module, and because the page gate has to know: it must not redirect to
|
|
24
|
+
// the login page while the prompt is recovering the session in place.
|
|
25
|
+
//
|
|
26
|
+
// Kept on globalThis under a registered symbol, so two copies of this module, such as two installed
|
|
27
|
+
// versions of the package, still share one prompt. Separate state would let a prompt raised through
|
|
28
|
+
// one copy go unseen by a gate reading the other, and the gate would redirect away from the form.
|
|
29
|
+
interface PromptState {
|
|
30
|
+
open: boolean;
|
|
31
|
+
listeners: Set<() => void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The shape of PromptState is a contract between releases. A release that changes it must also
|
|
35
|
+
// change this key, so it never reads state in a shape it does not expect.
|
|
36
|
+
const STATE_KEY = Symbol.for("@sjolystinnovation/app-kit/session-prompt/v1");
|
|
37
|
+
const shared = globalThis as typeof globalThis & { [STATE_KEY]?: PromptState };
|
|
38
|
+
const state: PromptState = (shared[STATE_KEY] ??= { open: false, listeners: new Set() });
|
|
39
|
+
|
|
40
|
+
function emit(): void {
|
|
41
|
+
for (const listener of [...state.listeners]) listener();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ask the user to sign in again without leaving the page. */
|
|
45
|
+
export function openSessionPrompt(): void {
|
|
46
|
+
if (state.open) return;
|
|
47
|
+
state.open = true;
|
|
48
|
+
emit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function closeSessionPrompt(): void {
|
|
52
|
+
if (!state.open) return;
|
|
53
|
+
state.open = false;
|
|
54
|
+
emit();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function subscribeSessionPrompt(listener: () => void): () => void {
|
|
58
|
+
state.listeners.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
state.listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSessionPromptOpen(): boolean {
|
|
65
|
+
return state.open;
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSession } from "next-auth/react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useState, useSyncExternalStore } from "react";
|
|
6
|
+
import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt } from "../expiry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a protected page may render, and whether it must stay rendered.
|
|
10
|
+
*
|
|
11
|
+
* Every gate on the way down should use this, so they agree. A nested layout that decides on its
|
|
12
|
+
* own to blank the page undoes the in-place recovery the re-sign-in prompt exists for, and the user
|
|
13
|
+
* loses the form anyway.
|
|
14
|
+
*/
|
|
15
|
+
export function useSessionGate() {
|
|
16
|
+
const { data: session, status } = useSession();
|
|
17
|
+
const pathname = usePathname();
|
|
18
|
+
const promptOpen = useSyncExternalStore(
|
|
19
|
+
subscribeSessionPrompt,
|
|
20
|
+
getSessionPromptOpen,
|
|
21
|
+
() => false,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The jwt callback copies its error onto the session. Read it without requiring the app to have
|
|
25
|
+
// added `error` to next-auth's Session type.
|
|
26
|
+
const error = (session as { error?: string } | null)?.error;
|
|
27
|
+
const usable = status === "authenticated" && !isTerminalSessionError(error);
|
|
28
|
+
|
|
29
|
+
// Which page last rendered on a healthy session. Keyed by path, so each page starts the check
|
|
30
|
+
// again and a session that died on the previous page cannot carry a stale pass into a fresh form.
|
|
31
|
+
// Adjusted during render rather than in an effect, which is the supported way to derive state
|
|
32
|
+
// from changing inputs.
|
|
33
|
+
// Undefined until then rather than null, because usePathname can itself return null.
|
|
34
|
+
const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
|
|
35
|
+
if (usable && usableAt !== pathname) setUsableAt(pathname);
|
|
36
|
+
const wasUsable = usableAt === pathname;
|
|
37
|
+
|
|
38
|
+
// The page is already open and the prompt is recovering the session in place.
|
|
39
|
+
const recovering = promptOpen && wasUsable;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
session,
|
|
43
|
+
status,
|
|
44
|
+
promptOpen,
|
|
45
|
+
usable,
|
|
46
|
+
wasUsable,
|
|
47
|
+
recovering,
|
|
48
|
+
/**
|
|
49
|
+
* The one answer every gate on the way down uses. A layout that decides this again can blank
|
|
50
|
+
* the page that the gate above it chose to keep.
|
|
51
|
+
*/
|
|
52
|
+
mayRender:
|
|
53
|
+
recovering ||
|
|
54
|
+
!(
|
|
55
|
+
(status === "loading" && !wasUsable) ||
|
|
56
|
+
status === "unauthenticated" ||
|
|
57
|
+
(!usable && !wasUsable)
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `useSessionGate` returns. */
|
|
63
|
+
export type SessionGate = ReturnType<typeof useSessionGate>;
|
package/src/styles.css
CHANGED
|
@@ -1,2 +1,43 @@
|
|
|
1
1
|
/* Adds this package's components to the files Tailwind scans for class names. */
|
|
2
2
|
@source ".";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Design tokens for the components. Each default matches a stock Tailwind value, so an app that
|
|
6
|
+
* sets none of them gets the look below. Set any of them in the app's own @theme to restyle, or in
|
|
7
|
+
* a selector such as .dark for a scoped theme.
|
|
8
|
+
*/
|
|
9
|
+
@theme default {
|
|
10
|
+
--radius-alert: var(--radius-lg);
|
|
11
|
+
--color-alert-error-bg: var(--color-red-50);
|
|
12
|
+
--color-alert-error-border: var(--color-red-200);
|
|
13
|
+
--color-alert-error-text: var(--color-red-700);
|
|
14
|
+
--color-alert-success-bg: var(--color-green-50);
|
|
15
|
+
--color-alert-success-border: var(--color-green-200);
|
|
16
|
+
--color-alert-success-text: var(--color-green-700);
|
|
17
|
+
--color-alert-info-bg: var(--color-gray-50);
|
|
18
|
+
--color-alert-info-border: var(--color-gray-200);
|
|
19
|
+
--color-alert-info-text: var(--color-gray-700);
|
|
20
|
+
--color-alert-warning-bg: var(--color-amber-50);
|
|
21
|
+
--color-alert-warning-border: var(--color-amber-200);
|
|
22
|
+
--color-alert-warning-text: var(--color-amber-900);
|
|
23
|
+
|
|
24
|
+
--text-field-label: var(--text-sm);
|
|
25
|
+
--text-field-label--line-height: var(--text-sm--line-height);
|
|
26
|
+
--color-field-label-text: var(--color-gray-700);
|
|
27
|
+
--spacing-field-label: calc(var(--spacing) * 1);
|
|
28
|
+
--color-field-required: var(--color-red-600);
|
|
29
|
+
--color-field-error: var(--color-red-600);
|
|
30
|
+
|
|
31
|
+
--radius-input: var(--radius-lg);
|
|
32
|
+
--spacing-input-y: calc(var(--spacing) * 2);
|
|
33
|
+
--color-input-bg: transparent;
|
|
34
|
+
--color-input-border: var(--color-gray-300);
|
|
35
|
+
--color-input-border-error: var(--color-red-400);
|
|
36
|
+
--color-input-focus-border: var(--color-gray-300);
|
|
37
|
+
--color-input-text: var(--color-gray-900);
|
|
38
|
+
--color-input-placeholder: var(--color-gray-400);
|
|
39
|
+
--color-input-focus-ring: var(--color-primary);
|
|
40
|
+
--input-focus-ring-width: 2px;
|
|
41
|
+
--color-input-toggle: var(--color-gray-400);
|
|
42
|
+
--color-input-toggle-hover: var(--color-gray-600);
|
|
43
|
+
}
|
package/src/ui/Alert.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { cx } from "./cx";
|
|
2
3
|
|
|
3
4
|
export interface AlertProps {
|
|
4
5
|
variant?: "error" | "success" | "info" | "warning";
|
|
@@ -7,19 +8,25 @@ export interface AlertProps {
|
|
|
7
8
|
* changing, such as a countdown, so it is not announced on every tick.
|
|
8
9
|
*/
|
|
9
10
|
role?: "alert" | "status";
|
|
11
|
+
/**
|
|
12
|
+
* Classes for placing the alert, such as a margin. Restyle it through the theme variables
|
|
13
|
+
* instead: whether a class that clashes with the alert's own wins depends on the order Tailwind
|
|
14
|
+
* emits them in.
|
|
15
|
+
*/
|
|
16
|
+
className?: string;
|
|
10
17
|
children: ReactNode;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
|
|
14
|
-
error: "bg-
|
|
15
|
-
success: "bg-
|
|
16
|
-
info: "bg-
|
|
17
|
-
warning: "bg-
|
|
21
|
+
error: "bg-alert-error-bg border-alert-error-border text-alert-error-text",
|
|
22
|
+
success: "bg-alert-success-bg border-alert-success-border text-alert-success-text",
|
|
23
|
+
info: "bg-alert-info-bg border-alert-info-border text-alert-info-text",
|
|
24
|
+
warning: "bg-alert-warning-bg border-alert-warning-border text-alert-warning-text",
|
|
18
25
|
};
|
|
19
26
|
|
|
20
|
-
export function Alert({ variant = "info", role = "alert", children }: AlertProps) {
|
|
27
|
+
export function Alert({ variant = "info", role = "alert", className, children }: AlertProps) {
|
|
21
28
|
return (
|
|
22
|
-
<div className={
|
|
29
|
+
<div className={cx("rounded-alert border px-3 py-2 text-sm", styles[variant], className)} role={role}>
|
|
23
30
|
{children}
|
|
24
31
|
</div>
|
|
25
32
|
);
|
package/src/ui/PasswordInput.tsx
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
|
|
4
4
|
import { useId, useState, type InputHTMLAttributes } from "react";
|
|
5
|
+
import { cx } from "./cx";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/** The input's type is left out: the component switches it to show and hide the value. */
|
|
8
|
+
export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
7
9
|
label: string;
|
|
8
10
|
error?: string;
|
|
9
11
|
/** Accessible name of the toggle while the password is hidden. */
|
|
10
12
|
showPasswordLabel?: string;
|
|
11
13
|
/** Accessible name of the toggle while the password is visible. */
|
|
12
14
|
hidePasswordLabel?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Classes for placing the field, such as a margin. They go on the outer element, which holds the
|
|
17
|
+
* label, the input and the error. Restyle the field through the theme variables instead.
|
|
18
|
+
*/
|
|
19
|
+
className?: string;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function PasswordInput({
|
|
@@ -17,44 +24,53 @@ export function PasswordInput({
|
|
|
17
24
|
error,
|
|
18
25
|
id,
|
|
19
26
|
required,
|
|
27
|
+
className,
|
|
20
28
|
showPasswordLabel = "Show password",
|
|
21
29
|
hidePasswordLabel = "Hide password",
|
|
22
30
|
...props
|
|
23
31
|
}: PasswordInputProps) {
|
|
24
32
|
const [visible, setVisible] = useState(false);
|
|
25
|
-
// Fall back to a generated id so the label is always tied to its control.
|
|
26
|
-
//
|
|
27
|
-
// screen reader users with an unidentified field.
|
|
33
|
+
// Fall back to a generated id so the label is always tied to its own control. Not the name: two
|
|
34
|
+
// fields named "password" on one page, such as sign in next to sign up, would share an id.
|
|
28
35
|
const generated = useId();
|
|
29
|
-
const inputId = id ??
|
|
36
|
+
const inputId = id ?? generated;
|
|
37
|
+
// Built from useId rather than the name, since an id list in aria-describedby splits on spaces.
|
|
38
|
+
const errorId = `${generated}-error`;
|
|
30
39
|
|
|
31
40
|
return (
|
|
32
|
-
<div>
|
|
33
|
-
<label htmlFor={inputId} className="block text-
|
|
41
|
+
<div className={className}>
|
|
42
|
+
<label htmlFor={inputId} className="block text-field-label font-medium text-field-label-text mb-field-label">
|
|
34
43
|
{label}
|
|
35
|
-
{required && <span className="text-
|
|
44
|
+
{required && <span className="text-field-required"> *</span>}
|
|
36
45
|
</label>
|
|
37
46
|
<div className="relative">
|
|
38
47
|
<input
|
|
48
|
+
{...props}
|
|
39
49
|
id={inputId}
|
|
40
50
|
type={visible ? "text" : "password"}
|
|
41
51
|
required={required}
|
|
42
|
-
aria-required={required
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
aria-required={required ? true : props["aria-required"]}
|
|
53
|
+
aria-invalid={error ? true : props["aria-invalid"]}
|
|
54
|
+
aria-describedby={cx(props["aria-describedby"], error && errorId) || undefined}
|
|
55
|
+
className={cx(
|
|
56
|
+
"w-full rounded-input border px-3 py-input-y pr-10 text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
|
|
57
|
+
error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
|
|
58
|
+
)}
|
|
47
59
|
/>
|
|
48
60
|
<button
|
|
49
61
|
type="button"
|
|
50
62
|
onClick={() => setVisible((v) => !v)}
|
|
51
|
-
className="absolute inset-y-0 right-0 px-3 text-
|
|
63
|
+
className="absolute inset-y-0 right-0 px-3 text-input-toggle hover:text-input-toggle-hover"
|
|
52
64
|
aria-label={visible ? hidePasswordLabel : showPasswordLabel}
|
|
53
65
|
>
|
|
54
66
|
{visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
|
55
67
|
</button>
|
|
56
68
|
</div>
|
|
57
|
-
{error &&
|
|
69
|
+
{error && (
|
|
70
|
+
<p id={errorId} className="mt-1 text-xs text-field-error">
|
|
71
|
+
{error}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
58
74
|
</div>
|
|
59
75
|
);
|
|
60
76
|
}
|
package/src/ui/cx.ts
ADDED