@sjolystinnovation/app-kit 0.8.0 → 0.10.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
@@ -309,6 +309,45 @@ export const POST = createRevalidateRoute({
309
309
  });
310
310
  ```
311
311
 
312
+ ### `@sjolystinnovation/app-kit/next-config`
313
+
314
+ For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
315
+
316
+ `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
317
+ in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
318
+ the incremental cache in memory, and the security headers on every page. `extra` is merged over it,
319
+ with `transpilePackages`, `images`, `headers` and `rewrites` combined rather than replaced.
320
+
321
+ ```ts
322
+ // next.config.ts
323
+ import { defineAppConfig } from "@sjolystinnovation/app-kit/next-config";
324
+
325
+ export default defineAppConfig({
326
+ apiUrl: process.env.NEXT_PUBLIC_API_URL,
327
+ dev: process.env.NODE_ENV !== "production",
328
+ contentImages: true,
329
+ });
330
+ ```
331
+
332
+ | Option | Type | Description |
333
+ | --- | --- | --- |
334
+ | `apiUrl` | `string \| undefined` | The API URL. Its origin is allowed in `connect-src`. |
335
+ | `dev` | `boolean` | Development builds also allow `'unsafe-eval'`, which hot reload needs. |
336
+ | `websocket` | `boolean` | Also allow `ws:` and `wss:` to the API origin, for SignalR. |
337
+ | `imgFromApi` | `boolean` | Also allow images from the API origin. Not needed with `contentImages`. |
338
+ | `contentImages` | `boolean` | Proxy `/content-images/*` to the API's `/content-images/*`, so the browser never contacts the API host. |
339
+ | `connectSrc`, `imgSrc` | `string[]` | Extra sources for those directives. |
340
+ | `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
341
+ | `pathHeaders` | `{ source, headers }[]` | Headers for particular paths, such as a file download. They come after the page headers and replace a page header of the same name. Every path keeps the rest. |
342
+ | `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
343
+ | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
344
+ | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
345
+
346
+ The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(options)`,
347
+ `contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
348
+ `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
349
+ rendered page dynamic.
350
+
312
351
  ### `@sjolystinnovation/app-kit/session`
313
352
 
314
353
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -566,6 +605,46 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
566
605
  <TextInput label="Email" name="email" type="email" required error={errors.email} />
567
606
  ```
568
607
 
608
+ ### `TextArea`
609
+
610
+ From `@sjolystinnovation/app-kit/ui`. A labelled multi-line field with the same look, label and
611
+ error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
612
+ or uncontrolled.
613
+
614
+ ```tsx
615
+ <TextArea label="Description" name="description" rows={4} maxRows={10} error={errors.description} />
616
+ ```
617
+
618
+ | Prop | Type | Default | Description |
619
+ | --- | --- | --- | --- |
620
+ | `label` | `string` | | Visible label, tied to the text area. |
621
+ | `error` | `string` | | Message shown under the field, read out with it by screen readers. |
622
+ | `autoGrow` | `boolean` | `true` | Grows with its text from `rows` lines up to `maxRows`, and has no resize handle. With `false` it keeps its size and shows the handle. |
623
+ | `rows` | `number` | `3` | Lines shown when empty. |
624
+ | `maxRows` | `number` | `12` | The most lines an auto-growing box shows before it scrolls. |
625
+ | `className` | `string` | | Classes for placing the field, on the outer element. |
626
+
627
+ ### `Toggle`
628
+
629
+ From `@sjolystinnovation/app-kit/ui`. An on and off switch for a setting that takes effect at once.
630
+ It takes the other `<button>` attributes too.
631
+
632
+ ```tsx
633
+ <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
634
+ <Toggle aria-label="VAT" checked={vat} onChange={setVat} disabled={saving} />
635
+ ```
636
+
637
+ | Prop | Type | Description |
638
+ | --- | --- | --- |
639
+ | `checked` | `boolean` | Whether the switch is on. |
640
+ | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
641
+ | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
642
+ | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
643
+ | `disabled` | `boolean` | Greys the switch out and ignores clicks. |
644
+ | `className` | `string` | Classes for placing it, on the outer element. |
645
+
646
+ An `onClick` that calls `event.preventDefault()` stops the change.
647
+
569
648
  ### `Alert`
570
649
 
571
650
  ```tsx
@@ -674,6 +753,9 @@ selector such as `.dark` for a scoped theme:
674
753
  | `--input-focus-ring-width` | `2px` |
675
754
  | `--color-input-toggle` | gray 400, the show password icon |
676
755
  | `--color-input-toggle-hover` | gray 600 |
756
+ | `--color-toggle-on` | `var(--color-primary)`, the track of a `Toggle` that is on |
757
+ | `--color-toggle-off` | gray 200, the track of a `Toggle` that is off |
758
+ | `--color-toggle-thumb` | white |
677
759
  | `--radius-button` | `var(--radius-lg)` |
678
760
  | `--color-button-primary-bg` | `var(--color-primary)` |
679
761
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -25,6 +25,7 @@
25
25
  "./ui": "./src/ui/index.ts",
26
26
  "./toast": "./src/toast/index.ts",
27
27
  "./validation": "./src/validation/index.ts",
28
+ "./next-config": "./src/next-config/index.ts",
28
29
  "./styles.css": "./src/styles.css"
29
30
  },
30
31
  "files": [
@@ -0,0 +1,154 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ /** A response header, as `next.config.ts` lists them. */
4
+ export interface Header {
5
+ key: string;
6
+ value: string;
7
+ }
8
+
9
+ /** The API's origin, such as `https://api.example.com`. Empty when the URL is missing or invalid. */
10
+ export function apiOrigin(apiUrl: string | undefined): string {
11
+ if (!apiUrl) return "";
12
+ try {
13
+ return new URL(apiUrl).origin;
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ /** The API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Empty when missing or invalid. */
20
+ export function apiBaseUrl(apiUrl: string | undefined): string {
21
+ if (!apiUrl) return "";
22
+ try {
23
+ return new URL(apiUrl).toString().replace(/\/$/, "");
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ export interface CspOptions {
30
+ /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. Its origin may be fetched from. */
31
+ apiUrl: string | undefined;
32
+ /** Development builds also allow `'unsafe-eval'`, which hot reload needs. */
33
+ dev: boolean;
34
+ /** Also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
35
+ websocket?: boolean | undefined;
36
+ /** Also allow images from the API origin. Not needed when images go through the content images rewrite. */
37
+ imgFromApi?: boolean | undefined;
38
+ /** Extra sources for `connect-src`. */
39
+ connectSrc?: readonly string[] | undefined;
40
+ /** Extra sources for `img-src`. */
41
+ imgSrc?: readonly string[] | undefined;
42
+ /** Sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
43
+ frameSrc?: readonly string[] | undefined;
44
+ /** Sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
45
+ objectSrc?: readonly string[] | undefined;
46
+ }
47
+
48
+ const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
49
+
50
+ /**
51
+ * The Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a nonce
52
+ * would make every statically rendered page dynamic.
53
+ */
54
+ export function contentSecurityPolicy(o: CspOptions): string {
55
+ const origin = apiOrigin(o.apiUrl);
56
+ const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
57
+ return [
58
+ "default-src 'self'",
59
+ o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
60
+ "style-src 'self' 'unsafe-inline'",
61
+ `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
62
+ `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
63
+ `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
64
+ `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
65
+ "font-src 'self'",
66
+ "base-uri 'self'",
67
+ "form-action 'self'",
68
+ "frame-ancestors 'none'",
69
+ "upgrade-insecure-requests",
70
+ ].join("; ");
71
+ }
72
+
73
+ /** The security headers for an app's pages, including the Content-Security-Policy. */
74
+ export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
75
+ return [
76
+ { key: "X-Content-Type-Options", value: "nosniff" },
77
+ { key: "X-Frame-Options", value: "DENY" },
78
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
79
+ { key: "X-DNS-Prefetch-Control", value: "on" },
80
+ { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
81
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
82
+ { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Content images, proxied so the browser never contacts the API host. A rewrite rather than a route
88
+ * handler, so the query string and the Accept header, which webp needs, pass through. No rewrite
89
+ * when the API URL is missing or invalid.
90
+ */
91
+ export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
92
+ const base = apiBaseUrl(apiUrl);
93
+ if (!base) return [];
94
+ const source = o.source ?? "/content-images";
95
+ const upstream = o.upstreamPath ?? "/content-images";
96
+ return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
97
+ }
98
+
99
+ export interface AppConfigOptions extends CspOptions {
100
+ /** Proxy `/content-images/*` to the API. See `contentImagesRewrite`. */
101
+ contentImages?: boolean | undefined;
102
+ /**
103
+ * Headers for particular paths. Every path gets the page headers, and these come after them, so
104
+ * they replace a page header of the same name.
105
+ */
106
+ pathHeaders?: readonly { source: string; headers: Header[] }[] | undefined;
107
+ /** Replaces the default Permissions-Policy. */
108
+ permissionsPolicy?: string | undefined;
109
+ /**
110
+ * Seconds a cache may go on serving a page after it goes stale. Next's default is a year, long
111
+ * enough for a browser to hand back an old page and fetch the current one behind it. Defaults to
112
+ * 300, matching the client router cache's stale time.
113
+ */
114
+ expireTime?: number | undefined;
115
+ /**
116
+ * Write revalidated pages to disk. Off by default: with a read-only root filesystem Next cannot
117
+ * write to `.next/server/app`, so the incremental cache stays in memory.
118
+ */
119
+ isrFlushToDisk?: boolean | undefined;
120
+ }
121
+
122
+ /**
123
+ * The shared Next config for an app: the standalone build, app-kit transpiled, no `X-Powered-By`,
124
+ * the security headers on every page, and optionally the content images rewrite. `extra` is merged
125
+ * over it, with `transpilePackages` and `images` combined.
126
+ */
127
+ export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): NextConfig {
128
+ const pageHeaders = securityHeaders(o);
129
+ const paths = o.pathHeaders ?? [];
130
+
131
+ const { transpilePackages, images, experimental, headers: extraHeaders, rewrites: extraRewrites, ...rest } = extra;
132
+ return {
133
+ output: "standalone",
134
+ poweredByHeader: false,
135
+ expireTime: o.expireTime ?? 300,
136
+ ...rest,
137
+ transpilePackages: [...new Set(["@sjolystinnovation/app-kit", ...(transpilePackages ?? [])])],
138
+ images: { qualities: [75, 100], ...images },
139
+ experimental: { isrFlushToDisk: o.isrFlushToDisk ?? false, ...experimental },
140
+ async headers() {
141
+ // Every path gets the page headers. Next lets a later rule replace a header of the same name,
142
+ // so a path's own headers come after them.
143
+ const own = [{ source: "/:path*", headers: pageHeaders }, ...paths.map(p => ({ source: p.source, headers: p.headers }))];
144
+ return [...own, ...((await extraHeaders?.()) ?? [])];
145
+ },
146
+ async rewrites() {
147
+ const own = o.contentImages ? contentImagesRewrite(o.apiUrl) : [];
148
+ const more = await extraRewrites?.();
149
+ if (!more) return own;
150
+ if (Array.isArray(more)) return [...own, ...more];
151
+ return { ...more, afterFiles: [...own, ...(more.afterFiles ?? [])] };
152
+ },
153
+ };
154
+ }
package/src/styles.css CHANGED
@@ -41,6 +41,10 @@
41
41
  --color-input-toggle: var(--color-gray-400);
42
42
  --color-input-toggle-hover: var(--color-gray-600);
43
43
 
44
+ --color-toggle-on: var(--color-primary);
45
+ --color-toggle-off: var(--color-gray-200);
46
+ --color-toggle-thumb: var(--color-white);
47
+
44
48
  --radius-button: var(--radius-lg);
45
49
  --color-button-primary-bg: var(--color-primary);
46
50
  --color-button-primary-text: var(--color-primary-foreground);
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, type TextareaHTMLAttributes } from "react";
4
+ import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
+
6
+ export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
7
+ label: string;
8
+ error?: string | undefined;
9
+ /**
10
+ * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
+ * label, the text area and the error. Restyle the field through the theme variables.
12
+ */
13
+ className?: string | undefined;
14
+ /** Grows with its text, from `rows` lines up to `maxRows` lines. On by default. */
15
+ autoGrow?: boolean | undefined;
16
+ /** The most lines an auto-growing box shows before it scrolls. Defaults to 12. */
17
+ maxRows?: number | undefined;
18
+ }
19
+
20
+ export function TextArea({ label, error, className, autoGrow = true, maxRows = 12, rows = 3, onInput, ...props }: TextAreaProps) {
21
+ const field = useField(props, error);
22
+ const ref = useRef<HTMLTextAreaElement>(null);
23
+
24
+ const resize = useCallback(() => {
25
+ const el = ref.current;
26
+ if (!el || !autoGrow) return;
27
+ const style = getComputedStyle(el);
28
+ const lineHeight = parseFloat(style.lineHeight) || 20;
29
+ const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
30
+ // scrollHeight covers the content and padding. A border-box height also needs the border.
31
+ const border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
32
+ const boxFor = (lines: number) => lineHeight * lines + padding + border;
33
+ el.style.height = "auto";
34
+ el.style.height = `${Math.min(Math.max(el.scrollHeight + border, boxFor(Number(rows))), boxFor(maxRows))}px`;
35
+ }, [autoGrow, rows, maxRows]);
36
+
37
+ // A controlled box grows when its value changes. An uncontrolled one grows as it is typed in.
38
+ useEffect(resize, [resize, props.value]);
39
+
40
+ const handleInput = (event: Parameters<NonNullable<TextAreaProps["onInput"]>>[0]) => {
41
+ onInput?.(event);
42
+ resize();
43
+ };
44
+
45
+ return (
46
+ <div className={className}>
47
+ <FieldLabel htmlFor={field.inputId} required={props.required}>
48
+ {label}
49
+ </FieldLabel>
50
+ <textarea
51
+ {...props}
52
+ {...field.inputProps}
53
+ ref={ref}
54
+ rows={rows}
55
+ onInput={handleInput}
56
+ // An auto-growing box has no resize handle. The next keystroke would undo the drag.
57
+ className={inputClass(error, autoGrow ? "leading-relaxed resize-none" : "leading-relaxed resize-y")}
58
+ />
59
+ <FieldError id={field.errorId} error={error} />
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import type { ButtonHTMLAttributes, MouseEvent } from "react";
4
+ import { cx } from "./cx";
5
+
6
+ type ToggleBase = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "role" | "type" | "aria-checked"> & {
7
+ checked: boolean;
8
+ /** Called with the new state when the switch is flipped. */
9
+ onChange: (checked: boolean) => void;
10
+ };
11
+
12
+ /** A switch needs a name. Either a visible `label`, or an `aria-label` when there is none. */
13
+ export type ToggleProps = ToggleBase & ({ label: string } | { label?: undefined; "aria-label": string });
14
+
15
+ /** An on and off switch, for a setting that takes effect at once. */
16
+ export function Toggle({ checked, onChange, label, className, disabled, onClick, ...props }: ToggleProps) {
17
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
18
+ onClick?.(event);
19
+ if (!event.defaultPrevented) onChange(!checked);
20
+ };
21
+
22
+ const button = (
23
+ <button
24
+ {...props}
25
+ type="button"
26
+ role="switch"
27
+ aria-checked={checked}
28
+ disabled={disabled}
29
+ onClick={handleClick}
30
+ className={cx(
31
+ "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-hidden focus-visible:ring-(length:--input-focus-ring-width) focus-visible:ring-input-focus-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
32
+ checked ? "bg-toggle-on" : "bg-toggle-off",
33
+ label === undefined && className,
34
+ )}
35
+ >
36
+ <span
37
+ aria-hidden="true"
38
+ className={cx(
39
+ "inline-block h-5 w-5 rounded-full bg-toggle-thumb shadow-sm transition-transform motion-reduce:transition-none",
40
+ checked ? "translate-x-5" : "translate-x-0.5",
41
+ )}
42
+ />
43
+ </button>
44
+ );
45
+
46
+ if (label === undefined) return button;
47
+
48
+ // The label wraps the switch, so the text names it and a click on the text flips it.
49
+ return (
50
+ <label className={cx("inline-flex items-center gap-2.5 text-field-label font-medium text-field-label-text select-none", disabled ? "cursor-not-allowed" : "cursor-pointer", className)}>
51
+ {button}
52
+ {label}
53
+ </label>
54
+ );
55
+ }
package/src/ui/field.tsx CHANGED
@@ -1,10 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { useId, type InputHTMLAttributes, type ReactNode } from "react";
3
+ import { useId, type AriaAttributes, type ReactNode } from "react";
4
4
  import { cx } from "./cx";
5
5
 
6
+ /** The props of a field control that useField reads. An `<input>` and a `<textarea>` both have them. */
7
+ export type FieldControlProps = Pick<AriaAttributes, "aria-required" | "aria-invalid" | "aria-describedby"> & {
8
+ id?: string | undefined;
9
+ required?: boolean | undefined;
10
+ };
11
+
6
12
  /** The ids and aria attributes a labelled input needs, from the caller's own props. */
7
- export function useField(props: Pick<InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "aria-required" | "aria-invalid" | "aria-describedby">, error: string | undefined) {
13
+ export function useField(props: FieldControlProps, error: string | undefined) {
8
14
  // A generated id ties the label to its own control, even when two fields on a page share a name.
9
15
  const generated = useId();
10
16
  const inputId = props.id ?? generated;
package/src/ui/index.ts CHANGED
@@ -6,6 +6,10 @@ export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsForm
6
6
  export type { CredentialsFormStrings } from "./credentialsFormStrings";
7
7
  export { PasswordInput } from "./PasswordInput";
8
8
  export type { PasswordInputProps } from "./PasswordInput";
9
+ export { TextArea } from "./TextArea";
10
+ export type { TextAreaProps } from "./TextArea";
9
11
  export { TextInput } from "./TextInput";
10
12
  export type { TextInputProps } from "./TextInput";
13
+ export { Toggle } from "./Toggle";
14
+ export type { ToggleProps } from "./Toggle";
11
15
  export type { TextOverrides } from "./withDefaults";