@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 +82 -0
- package/package.json +2 -1
- package/src/next-config/index.ts +154 -0
- package/src/styles.css +4 -0
- package/src/ui/TextArea.tsx +62 -0
- package/src/ui/Toggle.tsx +55 -0
- package/src/ui/field.tsx +8 -2
- package/src/ui/index.ts +4 -0
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.
|
|
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
|
|
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:
|
|
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";
|