@sjolystinnovation/app-kit 0.1.0 → 0.2.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,62 @@ 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)`
95
+
96
+ `(error: unknown, fallback: string) => string`
97
+
98
+ Turns a failed request into one line a user can read. For an axios error it takes the first of
99
+ `message`, the first validation error, `detail` and `title` from the response body, which covers
100
+ plain JSON errors and ASP.NET Core problem details alike. Otherwise it uses the error's own message,
101
+ then `fallback`.
102
+
103
+ ```ts
104
+ try {
105
+ await api.post("/users", form);
106
+ } catch (error) {
107
+ setError(formatApiError(error, "Could not create the user."));
108
+ }
109
+ ```
110
+
111
+ ### `Alert`
112
+
113
+ ```tsx
114
+ import { Alert } from "@sjolystinnovation/app-kit/ui";
115
+
116
+ <Alert variant="error">{message}</Alert>;
117
+ ```
118
+
119
+ | Prop | Type | Default | Description |
120
+ | --- | --- | --- | --- |
121
+ | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
122
+ | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
123
+ | `children` | `ReactNode` | | Content. |
124
+
125
+ ### `PasswordInput`
126
+
127
+ A labelled password field with a button that shows and hides the value. Accepts every `<input>`
128
+ attribute as well.
129
+
130
+ ```tsx
131
+ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
132
+
133
+ <PasswordInput label="Password" name="password" required error={errors.password} />;
134
+ ```
135
+
136
+ | Prop | Type | Default | Description |
137
+ | --- | --- | --- | --- |
138
+ | `label` | `string` | | Visible label, tied to the input. |
139
+ | `error` | `string` | | Message shown under the field, which also turns the border red. |
140
+ | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
141
+ | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
142
+
143
+ Pass the two toggle labels in the app's language. The defaults are English.
144
+
86
145
  ## Requirements
87
146
 
88
147
  - 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 with a `primary` colour in the theme, which the
149
+ focus ring uses, and `@heroicons/react` 2.
89
150
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
90
151
  `node16` and `nodenext` reject.
91
152
 
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.2.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,29 @@
1
+ import axios from "axios";
2
+
3
+ interface ApiErrorBody {
4
+ message?: string;
5
+ detail?: string;
6
+ title?: string;
7
+ errors?: Record<string, string[]>;
8
+ }
9
+
10
+ /**
11
+ * Turns a failed request into one line a user can read. Takes the first of `message`, the first
12
+ * validation error, `detail` and `title` from the response body, so it reads both plain JSON
13
+ * errors and ASP.NET Core problem details. Falls back to the error's own message, then to
14
+ * `fallback`.
15
+ */
16
+ export function formatApiError(error: unknown, fallback: string): string {
17
+ if (axios.isAxiosError(error)) {
18
+ const data = error.response?.data as ApiErrorBody | undefined;
19
+ if (data?.message) return data.message;
20
+ if (data?.errors) {
21
+ const first = Object.values(data.errors)[0];
22
+ if (first && first.length) return first[0];
23
+ }
24
+ if (data?.detail) return data.detail;
25
+ if (data?.title) return data.title;
26
+ }
27
+ if (error instanceof Error) return error.message;
28
+ return fallback;
29
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { defineSessionConfig, resolveJwtSecret } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
+ export { formatApiError } from "./errors";
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ /* Adds this package's components to the files Tailwind scans for class names. */
2
+ @source ".";
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface AlertProps {
4
+ variant?: "error" | "success" | "info" | "warning";
5
+ /**
6
+ * Defaults to alert, which a screen reader interrupts for. Use status for text that keeps
7
+ * changing, such as a countdown, so it is not announced on every tick.
8
+ */
9
+ role?: "alert" | "status";
10
+ children: ReactNode;
11
+ }
12
+
13
+ const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
14
+ error: "bg-red-50 border-red-200 text-red-700",
15
+ success: "bg-green-50 border-green-200 text-green-700",
16
+ info: "bg-gray-50 border-gray-200 text-gray-700",
17
+ warning: "bg-amber-50 border-amber-200 text-amber-900",
18
+ };
19
+
20
+ export function Alert({ variant = "info", role = "alert", children }: AlertProps) {
21
+ return (
22
+ <div className={`rounded-lg border px-3 py-2 text-sm ${styles[variant]}`} role={role}>
23
+ {children}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
+ import { useId, useState, type InputHTMLAttributes } from "react";
5
+
6
+ export interface PasswordInputProps extends InputHTMLAttributes<HTMLInputElement> {
7
+ label: string;
8
+ error?: string;
9
+ /** Accessible name of the toggle while the password is hidden. */
10
+ showPasswordLabel?: string;
11
+ /** Accessible name of the toggle while the password is visible. */
12
+ hidePasswordLabel?: string;
13
+ }
14
+
15
+ export function PasswordInput({
16
+ label,
17
+ error,
18
+ id,
19
+ required,
20
+ showPasswordLabel = "Show password",
21
+ hidePasswordLabel = "Hide password",
22
+ ...props
23
+ }: PasswordInputProps) {
24
+ const [visible, setVisible] = useState(false);
25
+ // Fall back to a generated id so the label is always tied to its control. Without it, a call
26
+ // site that passes neither id nor name renders a label pointing at nothing, which leaves
27
+ // screen reader users with an unidentified field.
28
+ const generated = useId();
29
+ const inputId = id ?? props.name ?? generated;
30
+
31
+ return (
32
+ <div>
33
+ <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
34
+ {label}
35
+ {required && <span className="text-red-600"> *</span>}
36
+ </label>
37
+ <div className="relative">
38
+ <input
39
+ id={inputId}
40
+ type={visible ? "text" : "password"}
41
+ required={required}
42
+ aria-required={required || undefined}
43
+ className={`w-full rounded-lg border px-3 py-2 pr-10 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary ${
44
+ error ? "border-red-400" : "border-gray-300"
45
+ }`}
46
+ {...props}
47
+ />
48
+ <button
49
+ type="button"
50
+ onClick={() => setVisible((v) => !v)}
51
+ className="absolute inset-y-0 right-0 px-3 text-gray-400 hover:text-gray-600"
52
+ aria-label={visible ? hidePasswordLabel : showPasswordLabel}
53
+ >
54
+ {visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
55
+ </button>
56
+ </div>
57
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
58
+ </div>
59
+ );
60
+ }
@@ -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";