@sjolystinnovation/app-kit 0.1.0 → 0.3.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
@@ -3,12 +3,11 @@
3
3
  [![npm](https://img.shields.io/npm/v/@sjolystinnovation/app-kit.svg)](https://www.npmjs.com/package/@sjolystinnovation/app-kit)
4
4
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
5
5
 
6
- > Session configuration for Next.js apps that sign in against a JWT API through next-auth
6
+ > Building blocks for Next.js apps that sign in against a JWT API through next-auth
7
7
 
8
- Several apps sharing one API also share the session plumbing around it: which environment variable
9
- holds the signing secret, where an expired session sends the browser, what prefixes the draft keys in
10
- `localStorage`. This package keeps those as one typed config, so the code around them can be shared
11
- instead of copied.
8
+ Several apps sharing one API also share the code around it: the session plumbing, how a failed
9
+ request becomes a message a user can read, and the form controls on the sign in page. This package
10
+ holds that code once, so the apps install it instead of each keeping a copy.
12
11
 
13
12
  ## Install
14
13
 
@@ -25,6 +24,15 @@ const nextConfig: NextConfig = {
25
24
  };
26
25
  ```
27
26
 
27
+ The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS. Import the
28
+ package's stylesheet after Tailwind:
29
+
30
+ ```css
31
+ /* src/app/globals.css */
32
+ @import "tailwindcss";
33
+ @import "@sjolystinnovation/app-kit/styles.css";
34
+ ```
35
+
28
36
  ## Usage
29
37
 
30
38
  Declare the values that differ between apps once, then pass that config wherever it is needed. One
@@ -83,9 +91,132 @@ resolveJwtSecret(sessionConfig, {});
83
91
  The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
84
92
  package that also runs in the browser.
85
93
 
94
+ ### `formatApiError(error, fallback, statusMessages?)`
95
+
96
+ `(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
97
+
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`.
118
+
119
+ ```ts
120
+ try {
121
+ await api.post("/users", form);
122
+ } catch (error) {
123
+ setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
124
+ }
125
+ ```
126
+
127
+ ### `Alert`
128
+
129
+ ```tsx
130
+ import { Alert } from "@sjolystinnovation/app-kit/ui";
131
+
132
+ <Alert variant="error">{message}</Alert>;
133
+ ```
134
+
135
+ | Prop | Type | Default | Description |
136
+ | --- | --- | --- | --- |
137
+ | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
138
+ | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
139
+ | `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. |
140
+ | `children` | `ReactNode` | | Content. |
141
+
142
+ ### `PasswordInput`
143
+
144
+ A labelled password field with a button that shows and hides the value. It also takes the other
145
+ `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
146
+ the outer element, see below.
147
+
148
+ ```tsx
149
+ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
150
+
151
+ <PasswordInput label="Password" name="password" required error={errors.password} />;
152
+ ```
153
+
154
+ | Prop | Type | Default | Description |
155
+ | --- | --- | --- | --- |
156
+ | `label` | `string` | | Visible label, tied to the input. |
157
+ | `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. |
158
+ | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
159
+ | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
160
+ | `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. |
161
+
162
+ Pass the two toggle labels in the app's language. The defaults are English.
163
+
164
+ ## Theming
165
+
166
+ The components draw every colour, corner radius and label size from theme variables with a light
167
+ default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
168
+ selector such as `.dark` for a scoped theme:
169
+
170
+ ```css
171
+ /* src/app/globals.css */
172
+ @import "tailwindcss";
173
+ @import "@sjolystinnovation/app-kit/styles.css";
174
+
175
+ @theme {
176
+ --color-alert-error-bg: color-mix(in oklab, var(--color-red-500) 10%, transparent);
177
+ --color-alert-error-text: var(--color-red-400);
178
+ --radius-alert: var(--radius-xl);
179
+ --color-input-focus-ring: var(--color-sky-500);
180
+ }
181
+ ```
182
+
183
+ | Variable | Default |
184
+ | --- | --- |
185
+ | `--radius-alert` | `var(--radius-lg)` |
186
+ | `--color-alert-error-bg`, `-border`, `-text` | red 50, 200, 700 |
187
+ | `--color-alert-success-bg`, `-border`, `-text` | green 50, 200, 700 |
188
+ | `--color-alert-info-bg`, `-border`, `-text` | gray 50, 200, 700 |
189
+ | `--color-alert-warning-bg`, `-border`, `-text` | amber 50, 200, 900 |
190
+ | `--text-field-label`, `--text-field-label--line-height` | `var(--text-sm)` and its line height. Set both together. |
191
+ | `--color-field-label-text` | gray 700 |
192
+ | `--spacing-field-label` | gap under the label, `calc(var(--spacing) * 1)` |
193
+ | `--color-field-required` | red 600, the asterisk on a required field |
194
+ | `--color-field-error` | red 600, the message under a field |
195
+ | `--radius-input` | `var(--radius-lg)` |
196
+ | `--spacing-input-y` | `calc(var(--spacing) * 2)` |
197
+ | `--color-input-bg` | `transparent` |
198
+ | `--color-input-border` | gray 300 |
199
+ | `--color-input-border-error` | red 400 |
200
+ | `--color-input-focus-border` | gray 300 |
201
+ | `--color-input-text` | gray 900 |
202
+ | `--color-input-placeholder` | gray 400 |
203
+ | `--color-input-focus-ring` | `var(--color-primary)` |
204
+ | `--input-focus-ring-width` | `2px` |
205
+ | `--color-input-toggle` | gray 400, the show password icon |
206
+ | `--color-input-toggle-hover` | gray 600 |
207
+
208
+ A default that points at another variable, such as `--color-input-focus-ring` at `--color-primary`,
209
+ is read once for the whole page. A scoped theme that changes `--color-primary` should set
210
+ `--color-input-focus-ring` in the same selector.
211
+
212
+ An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
213
+ with it and has to set the ones it uses itself.
214
+
86
215
  ## Requirements
87
216
 
88
217
  - Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
218
+ - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
219
+ the theme's `primary` colour unless `--color-input-focus-ring` is set.
89
220
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
90
221
  `node16` and `nodenext` reject.
91
222
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.1.0",
4
- "description": "Session configuration for Next.js apps that sign in against a JWT API through next-auth",
3
+ "version": "0.3.0",
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",
7
7
  "repository": {
@@ -11,9 +11,13 @@
11
11
  },
12
12
  "homepage": "https://github.com/sondresjolyst/si-web-packages#readme",
13
13
  "type": "module",
14
- "sideEffects": false,
14
+ "sideEffects": [
15
+ "*.css"
16
+ ],
15
17
  "exports": {
16
- ".": "./src/index.ts"
18
+ ".": "./src/index.ts",
19
+ "./ui": "./src/ui/index.ts",
20
+ "./styles.css": "./src/styles.css"
17
21
  },
18
22
  "files": [
19
23
  "src",
@@ -27,6 +31,7 @@
27
31
  "test": "vitest run"
28
32
  },
29
33
  "peerDependencies": {
34
+ "@heroicons/react": "^2",
30
35
  "axios": "^1",
31
36
  "next": "^16",
32
37
  "next-auth": "^4.24",
@@ -34,6 +39,7 @@
34
39
  "react-dom": "^19"
35
40
  },
36
41
  "devDependencies": {
42
+ "@heroicons/react": "^2.2.0",
37
43
  "@testing-library/jest-dom": "^7.0.1",
38
44
  "@testing-library/react": "^16.3.3",
39
45
  "@testing-library/user-event": "^14.6.7",
@@ -54,6 +60,7 @@
54
60
  "next-auth",
55
61
  "session",
56
62
  "jwt",
57
- "react"
63
+ "react",
64
+ "tailwindcss"
58
65
  ]
59
66
  }
package/src/errors.ts ADDED
@@ -0,0 +1,108 @@
1
+ import axios, { type AxiosResponse } from "axios";
2
+
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
+ ]);
25
+
26
+ /**
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.
38
+ */
39
+ export function formatApiError(
40
+ error: unknown,
41
+ fallback: string,
42
+ statusMessages: StatusMessages = {},
43
+ ): string {
44
+ if (axios.isCancel(error)) return fallback;
45
+ if (axios.isAxiosError(error)) {
46
+ const response = error.response;
47
+ if (!response) {
48
+ return NO_RESPONSE_CODES.has(error.code ?? "") ? (statusMessages[0] ?? fallback) : fallback;
49
+ }
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;
54
+ }
55
+ if (error instanceof Error) return singleLine(error.message) ?? fallback;
56
+ return fallback;
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
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { defineSessionConfig, resolveJwtSecret } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
+ export { formatApiError } from "./errors";
4
+ export type { StatusMessages } from "./errors";
package/src/styles.css ADDED
@@ -0,0 +1,43 @@
1
+ /* Adds this package's components to the files Tailwind scans for class names. */
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
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./cx";
3
+
4
+ export interface AlertProps {
5
+ variant?: "error" | "success" | "info" | "warning";
6
+ /**
7
+ * Defaults to alert, which a screen reader interrupts for. Use status for text that keeps
8
+ * changing, such as a countdown, so it is not announced on every tick.
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;
17
+ children: ReactNode;
18
+ }
19
+
20
+ const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
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",
25
+ };
26
+
27
+ export function Alert({ variant = "info", role = "alert", className, children }: AlertProps) {
28
+ return (
29
+ <div className={cx("rounded-alert border px-3 py-2 text-sm", styles[variant], className)} role={role}>
30
+ {children}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
+ import { useId, useState, type InputHTMLAttributes } from "react";
5
+ import { cx } from "./cx";
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"> {
9
+ label: string;
10
+ error?: string;
11
+ /** Accessible name of the toggle while the password is hidden. */
12
+ showPasswordLabel?: string;
13
+ /** Accessible name of the toggle while the password is visible. */
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;
20
+ }
21
+
22
+ export function PasswordInput({
23
+ label,
24
+ error,
25
+ id,
26
+ required,
27
+ className,
28
+ showPasswordLabel = "Show password",
29
+ hidePasswordLabel = "Hide password",
30
+ ...props
31
+ }: PasswordInputProps) {
32
+ const [visible, setVisible] = useState(false);
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.
35
+ const generated = useId();
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`;
39
+
40
+ return (
41
+ <div className={className}>
42
+ <label htmlFor={inputId} className="block text-field-label font-medium text-field-label-text mb-field-label">
43
+ {label}
44
+ {required && <span className="text-field-required"> *</span>}
45
+ </label>
46
+ <div className="relative">
47
+ <input
48
+ {...props}
49
+ id={inputId}
50
+ type={visible ? "text" : "password"}
51
+ required={required}
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
+ )}
59
+ />
60
+ <button
61
+ type="button"
62
+ onClick={() => setVisible((v) => !v)}
63
+ className="absolute inset-y-0 right-0 px-3 text-input-toggle hover:text-input-toggle-hover"
64
+ aria-label={visible ? hidePasswordLabel : showPasswordLabel}
65
+ >
66
+ {visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
67
+ </button>
68
+ </div>
69
+ {error && (
70
+ <p id={errorId} className="mt-1 text-xs text-field-error">
71
+ {error}
72
+ </p>
73
+ )}
74
+ </div>
75
+ );
76
+ }
package/src/ui/cx.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Joins class names, skipping empty ones. */
2
+ export function cx(...classes: Array<string | false | null | undefined>): string {
3
+ return classes.filter(Boolean).join(" ");
4
+ }
@@ -0,0 +1,4 @@
1
+ export { Alert } from "./Alert";
2
+ export type { AlertProps } from "./Alert";
3
+ export { PasswordInput } from "./PasswordInput";
4
+ export type { PasswordInputProps } from "./PasswordInput";