@sjolystinnovation/app-kit 0.2.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 +82 -12
- package/package.json +1 -1
- package/src/errors.ts +99 -20
- package/src/index.ts +1 -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
|
@@ -91,20 +91,36 @@ 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." }));
|
|
108
124
|
}
|
|
109
125
|
```
|
|
110
126
|
|
|
@@ -120,12 +136,14 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
120
136
|
| --- | --- | --- | --- |
|
|
121
137
|
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
|
|
122
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. |
|
|
123
140
|
| `children` | `ReactNode` | | Content. |
|
|
124
141
|
|
|
125
142
|
### `PasswordInput`
|
|
126
143
|
|
|
127
|
-
A labelled password field with a button that shows and hides the value.
|
|
128
|
-
|
|
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.
|
|
129
147
|
|
|
130
148
|
```tsx
|
|
131
149
|
import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
@@ -136,17 +154,69 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
|
136
154
|
| Prop | Type | Default | Description |
|
|
137
155
|
| --- | --- | --- | --- |
|
|
138
156
|
| `label` | `string` | | Visible label, tied to the input. |
|
|
139
|
-
| `error` | `string` | | Message shown under the field
|
|
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. |
|
|
140
158
|
| `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
|
|
141
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. |
|
|
142
161
|
|
|
143
162
|
Pass the two toggle labels in the app's language. The defaults are English.
|
|
144
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
|
+
|
|
145
215
|
## Requirements
|
|
146
216
|
|
|
147
217
|
- 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
|
-
|
|
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.
|
|
150
220
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
151
221
|
`node16` and `nodenext` reject.
|
|
152
222
|
|
package/package.json
CHANGED
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
|
+
}
|
package/src/index.ts
CHANGED
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