@bison-lab/payload-core 0.1.0 → 3.8.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 +132 -18
- package/dist/admin.d.mts +28 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +433 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/index.d.mts +57 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +189 -1
- package/dist/index.mjs.map +1 -1
- package/dist/metadata.d.mts +1 -1
- package/dist/react.d.mts +32 -0
- package/dist/react.d.mts.map +1 -0
- package/dist/react.mjs +132 -0
- package/dist/react.mjs.map +1 -0
- package/dist/theme.d.mts +38 -0
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +39 -0
- package/dist/theme.mjs.map +1 -0
- package/dist/{title-Wut0nzJQ.d.mts → title-B2-gWQZd.d.mts} +1 -1
- package/dist/{title-Wut0nzJQ.d.mts.map → title-B2-gWQZd.d.mts.map} +1 -1
- package/dist/types-DWwL-JEr.mjs +76 -0
- package/dist/types-DWwL-JEr.mjs.map +1 -0
- package/dist/types-pFpmDeSA.d.mts +91 -0
- package/dist/types-pFpmDeSA.d.mts.map +1 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
# `@bison-lab/payload-core`
|
|
2
2
|
|
|
3
|
-
Site-agnostic Payload CMS configuration for Bison Lab sites
|
|
4
|
-
|
|
5
|
-
rest of the shared CMS layer (access matrix, collection
|
|
6
|
-
joins it here as it is extracted.
|
|
3
|
+
Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab on
|
|
4
|
+
a collection, the Theme Global, and the readers a page route and a root
|
|
5
|
+
layout use. The rest of the shared CMS layer (access matrix, collection
|
|
6
|
+
factories, slug field) joins it here as it is extracted.
|
|
7
|
+
|
|
8
|
+
Full guide: <https://payload.bisonlab.ai/site/seo/>
|
|
7
9
|
|
|
8
10
|
```bash
|
|
9
|
-
pnpm add @bison-lab/payload-core @payloadcms/plugin-seo
|
|
11
|
+
pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @bison-lab/tokens @bison-lab/fonts
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
Peers: `payload` and `@payloadcms/plugin-seo
|
|
13
|
-
|
|
14
|
+
Peers: `payload` and `@payloadcms/plugin-seo` are required. `@bison-lab/tokens`
|
|
15
|
+
and `@bison-lab/fonts` are needed to adopt the Theme Global; `@payloadcms/ui`
|
|
16
|
+
and `react` are needed for its admin fields; `@bison-lab/ui` and
|
|
17
|
+
`@payloadcms/live-preview-react` are needed for the live theme preview.
|
|
18
|
+
Pin the plugin to the same version as `payload`; Payload releases them in
|
|
19
|
+
lockstep.
|
|
14
20
|
|
|
15
|
-
##
|
|
21
|
+
## Five entry points, and why
|
|
16
22
|
|
|
17
|
-
| Import | Contents
|
|
18
|
-
| ---------------------------------- |
|
|
19
|
-
| `@bison-lab/payload-core` | `seoPlugin`, `
|
|
20
|
-
| `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types
|
|
23
|
+
| Import | Contents | Runs where |
|
|
24
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
|
25
|
+
| `@bison-lab/payload-core` | `seoPlugin`, `createTheme`, `seedTheme`, import-map strings, title and text helpers, types | Node. What `payload.config.ts` imports; it loads the plugin. |
|
|
26
|
+
| `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
|
|
27
|
+
| `@bison-lab/payload-core/theme` | `getPublishedTheme`, `themeConfigFromDoc`, `themeHead` | Server. What a root layout imports; it does not load the plugin or React. |
|
|
28
|
+
| `@bison-lab/payload-core/admin` | `ColorField`, `FontField`, `ContrastReport` | Admin. Referenced by import-map string; `generate:importmap` writes it. |
|
|
29
|
+
| `@bison-lab/payload-core/react` | `ThemePreview` | Client. What the theme-preview route imports; it loads ui and live-preview. |
|
|
21
30
|
|
|
22
31
|
## What editors get
|
|
23
32
|
|
|
@@ -114,15 +123,120 @@ carry through Next's metadata merge.
|
|
|
114
123
|
|
|
115
124
|
The sitemap is the site's: filter `meta.noIndex` out of it.
|
|
116
125
|
|
|
126
|
+
## Adopting the Theme global
|
|
127
|
+
|
|
128
|
+
Settings → Theme: the five brand colours, grey scale, radius, shadow, motion,
|
|
129
|
+
density, default theme, body and heading fonts, and an optional logo. Whoever
|
|
130
|
+
holds brand rights edits a draft; publish is deliberate. Contrast is measured
|
|
131
|
+
as they type and shown; it is never enforced. `bison.config.json` is the seed
|
|
132
|
+
a site starts from, not the source of truth.
|
|
133
|
+
|
|
134
|
+
**1. Register the Global.** Access is required — the predicates live in the
|
|
135
|
+
site's `src/platform` until they move here. Pass `canManageBrand` as
|
|
136
|
+
`update`.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// payload.config.ts
|
|
140
|
+
import { createTheme } from "@bison-lab/payload-core";
|
|
141
|
+
import bisonConfig from "../bison.config.json";
|
|
142
|
+
import { canManageBrand, isAuthenticated } from "@/platform/access";
|
|
143
|
+
|
|
144
|
+
export default buildConfig({
|
|
145
|
+
globals: [
|
|
146
|
+
createTheme({
|
|
147
|
+
access: { read: isAuthenticated, update: canManageBrand },
|
|
148
|
+
seed: bisonConfig,
|
|
149
|
+
previewPath: "/theme-preview",
|
|
150
|
+
logo: { collection: "brand-assets" },
|
|
151
|
+
onPublish: async () => {
|
|
152
|
+
revalidateTag("theme");
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Then `payload generate:importmap` (the colour, font and contrast fields
|
|
160
|
+
resolve from `@bison-lab/payload-core/admin`), `payload generate:types`, and
|
|
161
|
+
`payload migrate:create`.
|
|
162
|
+
|
|
163
|
+
**2. Seed the row on deploy.** A migration `up()` writes a published version
|
|
164
|
+
so the site renders what `bison-theme.css` rendered before anyone opens the
|
|
165
|
+
admin:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { seedTheme } from "@bison-lab/payload-core";
|
|
169
|
+
import bisonConfig from "../bison.config.json";
|
|
170
|
+
|
|
171
|
+
export async function up({ payload }) {
|
|
172
|
+
await seedTheme(payload, bisonConfig);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**3. Serve the catalogue.** A Next route around `serveFont` from
|
|
177
|
+
`@bison-lab/fonts/server`, listed in `serverExternalPackages`, at the path
|
|
178
|
+
you pass as `fontsBaseUrl` (default `/fonts`).
|
|
179
|
+
|
|
180
|
+
**4. Render the published theme in the root layout.**
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// app/layout.tsx
|
|
184
|
+
import { getPublishedTheme, themeHead } from "@bison-lab/payload-core/theme";
|
|
185
|
+
import bisonConfig from "../bison.config.json";
|
|
186
|
+
|
|
187
|
+
const theme = await getPublishedTheme(payload, bisonConfig);
|
|
188
|
+
const { css, preloads } = themeHead(theme);
|
|
189
|
+
|
|
190
|
+
// <link rel="preload" as="font" type="font/woff2" crossOrigin="" href={href} />
|
|
191
|
+
// <style dangerouslySetInnerHTML={{ __html: css }} />
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If the admin layout renders the same head, the admin panel restyles too.
|
|
195
|
+
|
|
196
|
+
`getPublishedTheme` reads `draft: false` and falls back to the seed when
|
|
197
|
+
nothing has been published. A newer draft never reaches the public site.
|
|
198
|
+
|
|
199
|
+
**5. Own the preview route.** The path is code-owned (in the site's
|
|
200
|
+
`ENDPOINTS`), never a nav link, never in the sitemap, reserved from CMS
|
|
201
|
+
slugs, and gated to an authenticated user. It renders `ThemePreview` with
|
|
202
|
+
no site chrome. `createTheme({ previewPath })` points Payload's live-preview
|
|
203
|
+
pane at that path; omit `previewPath` and the pane stays closed.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// app/theme-preview/page.tsx
|
|
207
|
+
import { ThemePreview } from "@bison-lab/payload-core/react";
|
|
208
|
+
import { getPublishedTheme } from "@bison-lab/payload-core/theme";
|
|
209
|
+
import bisonConfig from "../../../bison.config.json";
|
|
210
|
+
|
|
211
|
+
const theme = await getPublishedTheme(payload, bisonConfig);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<ThemePreview
|
|
215
|
+
theme={theme}
|
|
216
|
+
seed={bisonConfig}
|
|
217
|
+
fontsBaseUrl="/fonts"
|
|
218
|
+
serverURL={absoluteSiteUrl}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`ThemePreview` rebuilds the stylesheet in the browser from each live-preview
|
|
224
|
+
message. A half-typed hex keeps the last complete theme so the iframe does
|
|
225
|
+
not go blank. The light / dark toggle sets `data-theme` on the preview root,
|
|
226
|
+
not the document. Pages that later grow a live-preview pane should import
|
|
227
|
+
`THEME_PREVIEW_BREAKPOINTS` from `@bison-lab/payload-core` so the toolbars
|
|
228
|
+
match.
|
|
229
|
+
|
|
117
230
|
## No generated types
|
|
118
231
|
|
|
119
232
|
The package cannot import a site's `payload-types`, so the shapes it reads
|
|
120
|
-
(`SeoMeta`, `SeoImageDoc`, `SeoPage`) are hand-written and
|
|
121
|
-
generated `Page
|
|
122
|
-
signature, since an interface will not assign
|
|
233
|
+
(`SeoMeta`, `SeoImageDoc`, `SeoPage`, `ThemeDoc`) are hand-written and
|
|
234
|
+
structural. A generated `Page`, `Media` or Theme Global is assignable to
|
|
235
|
+
them; nothing carries an index signature, since an interface will not assign
|
|
236
|
+
to a type that has one.
|
|
123
237
|
|
|
124
238
|
## Changing a field
|
|
125
239
|
|
|
126
|
-
`noIndexField
|
|
127
|
-
site. A change to them is a schema change: say
|
|
128
|
-
in the changeset.
|
|
240
|
+
`noIndexField`, the plugin's own fields, and the Theme Global fields are
|
|
241
|
+
columns in every consuming site. A change to them is a schema change: say
|
|
242
|
+
"run `payload migrate:create`" in the changeset.
|
package/dist/admin.d.mts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
import { SelectFieldClientComponent, TextFieldClientComponent, UIFieldClientComponent } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/admin/color-field.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Swatch, native colour input, and hex text, kept in step. `#rrggbb` on
|
|
7
|
+
* blur; the field's `validate` refuses anything else on save.
|
|
8
|
+
*/
|
|
9
|
+
declare const ColorField: TextFieldClientComponent;
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/admin/font-field.d.ts
|
|
12
|
+
/**
|
|
13
|
+
* Listbox over the catalogue ids in `admin.custom.ids`. Each option is the
|
|
14
|
+
* family name in that face, with the catalogue hint beneath. Faces load
|
|
15
|
+
* from the site's font route the first time the picker opens.
|
|
16
|
+
*/
|
|
17
|
+
declare const FontField: SelectFieldClientComponent;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/admin/contrast-report.d.ts
|
|
20
|
+
/**
|
|
21
|
+
* Reads the five brand colours and grey scale from the form, runs
|
|
22
|
+
* `auditTheme` client-side, and paints its own rows. Warn only: nothing
|
|
23
|
+
* here touches validity.
|
|
24
|
+
*/
|
|
25
|
+
declare const ContrastReport: UIFieldClientComponent;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { ColorField, ContrastReport, FontField };
|
|
28
|
+
//# sourceMappingURL=admin.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/color-field.tsx","../src/admin/font-field.tsx","../src/admin/contrast-report.tsx"],"mappings":";;;;;;AAWA;;cAAa,UAAA,EAAY,wBAAA;;;;;AAAzB;;;cCGa,SAAA,EAAW,0BAAA;;;;;ADHxB;;;cEqEa,cAAA,EAAgB,sBAAA"}
|
package/dist/admin.mjs
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { FieldDescription, FieldError, FieldLabel, useField, useFormFields } from "@payloadcms/ui";
|
|
3
|
+
import { auditTheme, contrastLevelLabel, contrastVerdict, isThemeHex } from "@bison-lab/tokens";
|
|
4
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { findFont, fontFaceCss } from "@bison-lab/fonts";
|
|
7
|
+
//#region src/admin/color-field.tsx
|
|
8
|
+
const HEX_ERROR = "Enter a six-digit hex colour like #1e3a5f";
|
|
9
|
+
/**
|
|
10
|
+
* Swatch, native colour input, and hex text, kept in step. `#rrggbb` on
|
|
11
|
+
* blur; the field's `validate` refuses anything else on save.
|
|
12
|
+
*/
|
|
13
|
+
const ColorField = ({ field, path, readOnly }) => {
|
|
14
|
+
const { value, setValue, showError, errorMessage } = useField({ path });
|
|
15
|
+
const [text, setText] = useState(value ?? "");
|
|
16
|
+
const [localError, setLocalError] = useState(null);
|
|
17
|
+
const id = useId();
|
|
18
|
+
const hexId = `${id}-hex`;
|
|
19
|
+
const pickerId = `${id}-picker`;
|
|
20
|
+
const required = Boolean(field.required);
|
|
21
|
+
const valid = isThemeHex(value);
|
|
22
|
+
const message = localError ?? errorMessage;
|
|
23
|
+
const show = Boolean(localError) || showError;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setText(value ?? "");
|
|
26
|
+
}, [value]);
|
|
27
|
+
function commit(next) {
|
|
28
|
+
const trimmed = next.trim();
|
|
29
|
+
if (isThemeHex(trimmed)) {
|
|
30
|
+
setValue(trimmed);
|
|
31
|
+
setLocalError(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setLocalError(HEX_ERROR);
|
|
35
|
+
}
|
|
36
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
37
|
+
className: "field-type bl-color-field",
|
|
38
|
+
children: [
|
|
39
|
+
/* @__PURE__ */ jsx("style", {
|
|
40
|
+
href: "bl-color-field",
|
|
41
|
+
precedence: "default",
|
|
42
|
+
children: CSS$2
|
|
43
|
+
}),
|
|
44
|
+
/* @__PURE__ */ jsx(FieldLabel, {
|
|
45
|
+
htmlFor: hexId,
|
|
46
|
+
label: field.label,
|
|
47
|
+
required
|
|
48
|
+
}),
|
|
49
|
+
/* @__PURE__ */ jsxs("div", {
|
|
50
|
+
className: "bl-color-field__control",
|
|
51
|
+
children: [
|
|
52
|
+
/* @__PURE__ */ jsx("span", {
|
|
53
|
+
className: "bl-color-field__swatch",
|
|
54
|
+
"aria-hidden": "true",
|
|
55
|
+
style: { background: valid ? value : "transparent" }
|
|
56
|
+
}),
|
|
57
|
+
/* @__PURE__ */ jsx("input", {
|
|
58
|
+
id: pickerId,
|
|
59
|
+
type: "color",
|
|
60
|
+
className: "bl-color-field__picker",
|
|
61
|
+
disabled: readOnly,
|
|
62
|
+
value: valid ? value : "#000000",
|
|
63
|
+
"aria-label": `${typeof field.label === "string" ? field.label : "Colour"} picker`,
|
|
64
|
+
onChange: (event) => {
|
|
65
|
+
setValue(event.target.value);
|
|
66
|
+
setText(event.target.value);
|
|
67
|
+
setLocalError(null);
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
/* @__PURE__ */ jsx("input", {
|
|
71
|
+
id: hexId,
|
|
72
|
+
type: "text",
|
|
73
|
+
className: "bl-color-field__hex",
|
|
74
|
+
spellCheck: false,
|
|
75
|
+
autoComplete: "off",
|
|
76
|
+
disabled: readOnly,
|
|
77
|
+
value: text,
|
|
78
|
+
onChange: (event) => {
|
|
79
|
+
setText(event.target.value);
|
|
80
|
+
if (isThemeHex(event.target.value.trim())) {
|
|
81
|
+
setValue(event.target.value.trim());
|
|
82
|
+
setLocalError(null);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onBlur: () => commit(text)
|
|
86
|
+
})
|
|
87
|
+
]
|
|
88
|
+
}),
|
|
89
|
+
/* @__PURE__ */ jsx(FieldDescription, {
|
|
90
|
+
description: field.admin?.description,
|
|
91
|
+
path
|
|
92
|
+
}),
|
|
93
|
+
/* @__PURE__ */ jsx(FieldError, {
|
|
94
|
+
path,
|
|
95
|
+
showError: show,
|
|
96
|
+
message
|
|
97
|
+
})
|
|
98
|
+
]
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const CSS$2 = `
|
|
102
|
+
.bl-color-field__control{display:flex;align-items:center;gap:calc(var(--base) * .4)}
|
|
103
|
+
.bl-color-field__swatch{flex:none;width:calc(var(--base) * 1.6);height:calc(var(--base) * 1.6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-50)}
|
|
104
|
+
.bl-color-field__picker{flex:none;width:calc(var(--base) * 2);height:calc(var(--base) * 2);padding:0;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:transparent;cursor:pointer}
|
|
105
|
+
.bl-color-field__picker:disabled{cursor:not-allowed;opacity:.6}
|
|
106
|
+
.bl-color-field__hex{flex:1;min-width:0;min-height:calc(var(--base) * 2);padding:calc(var(--base) * .25) calc(var(--base) * .6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-input-bg);color:var(--theme-elevation-800);font-family:var(--font-mono);font-size:13px}
|
|
107
|
+
.bl-color-field__hex:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}
|
|
108
|
+
.bl-color-field__hex:disabled{color:var(--theme-elevation-400)}
|
|
109
|
+
`;
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/admin/font-field.tsx
|
|
112
|
+
/**
|
|
113
|
+
* Listbox over the catalogue ids in `admin.custom.ids`. Each option is the
|
|
114
|
+
* family name in that face, with the catalogue hint beneath. Faces load
|
|
115
|
+
* from the site's font route the first time the picker opens.
|
|
116
|
+
*/
|
|
117
|
+
const FontField = ({ field, path, readOnly }) => {
|
|
118
|
+
const { value, setValue, showError, errorMessage } = useField({ path });
|
|
119
|
+
const custom = field.admin?.custom ?? {};
|
|
120
|
+
const ids = custom.ids ?? [];
|
|
121
|
+
const fontsBaseUrl = custom.fontsBaseUrl ?? "/fonts";
|
|
122
|
+
const sameAsBody = Boolean(custom.sameAsBody);
|
|
123
|
+
const options = useMemo(() => {
|
|
124
|
+
const fromCatalogue = ids.flatMap((id) => {
|
|
125
|
+
const font = findFont(id);
|
|
126
|
+
if (!font) return [];
|
|
127
|
+
return [{
|
|
128
|
+
value: font.id,
|
|
129
|
+
family: font.family,
|
|
130
|
+
hint: font.hint,
|
|
131
|
+
specimen: true
|
|
132
|
+
}];
|
|
133
|
+
});
|
|
134
|
+
if (!sameAsBody) return fromCatalogue;
|
|
135
|
+
return [{
|
|
136
|
+
value: "",
|
|
137
|
+
family: "Same as body",
|
|
138
|
+
hint: "Use the body family for headings",
|
|
139
|
+
specimen: false
|
|
140
|
+
}, ...fromCatalogue];
|
|
141
|
+
}, [ids, sameAsBody]);
|
|
142
|
+
const [open, setOpen] = useState(false);
|
|
143
|
+
const [faces, setFaces] = useState(false);
|
|
144
|
+
const [active, setActive] = useState(-1);
|
|
145
|
+
const typed = useRef("");
|
|
146
|
+
const typedAt = useRef(0);
|
|
147
|
+
const id = useId();
|
|
148
|
+
const buttonId = `${id}-button`;
|
|
149
|
+
const listId = `${id}-list`;
|
|
150
|
+
const required = Boolean(field.required);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (open) setFaces(true);
|
|
153
|
+
}, [open]);
|
|
154
|
+
const selected = options.find((option) => option.value === (value ?? "")) ?? options[0];
|
|
155
|
+
function choose(option) {
|
|
156
|
+
setValue(option.value);
|
|
157
|
+
setOpen(false);
|
|
158
|
+
setActive(-1);
|
|
159
|
+
}
|
|
160
|
+
function onKeyDown(event) {
|
|
161
|
+
if (event.key === "ArrowDown") {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
if (!open) {
|
|
164
|
+
setOpen(true);
|
|
165
|
+
setActive(0);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setActive((i) => options.length ? (i + 1) % options.length : -1);
|
|
169
|
+
} else if (event.key === "ArrowUp") {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
if (!open) {
|
|
172
|
+
setOpen(true);
|
|
173
|
+
setActive(options.length - 1);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setActive((i) => options.length ? i <= 0 ? options.length - 1 : i - 1 : -1);
|
|
177
|
+
} else if (event.key === "Enter") {
|
|
178
|
+
if (!open) return;
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
const pick = options[active] ?? options[0];
|
|
181
|
+
if (pick) choose(pick);
|
|
182
|
+
} else if (event.key === "Escape") {
|
|
183
|
+
if (!open) return;
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
event.stopPropagation();
|
|
186
|
+
setOpen(false);
|
|
187
|
+
setActive(-1);
|
|
188
|
+
} else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
typed.current = now - typedAt.current > 600 ? event.key : typed.current + event.key;
|
|
191
|
+
typedAt.current = now;
|
|
192
|
+
const needle = typed.current.toLowerCase();
|
|
193
|
+
const index = options.findIndex((option) => option.family.toLowerCase().startsWith(needle));
|
|
194
|
+
if (index >= 0) {
|
|
195
|
+
if (!open) setOpen(true);
|
|
196
|
+
setActive(index);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const activeId = active >= 0 && open ? `${id}-option-${active}` : void 0;
|
|
201
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
202
|
+
className: "field-type bl-font-field",
|
|
203
|
+
children: [
|
|
204
|
+
/* @__PURE__ */ jsx("style", {
|
|
205
|
+
href: "bl-font-field",
|
|
206
|
+
precedence: "default",
|
|
207
|
+
children: CSS$1
|
|
208
|
+
}),
|
|
209
|
+
faces ? /* @__PURE__ */ jsx("style", {
|
|
210
|
+
"data-bl-font-faces": "",
|
|
211
|
+
children: fontFaceCss(ids, fontsBaseUrl)
|
|
212
|
+
}) : null,
|
|
213
|
+
/* @__PURE__ */ jsx(FieldLabel, {
|
|
214
|
+
htmlFor: buttonId,
|
|
215
|
+
label: field.label,
|
|
216
|
+
required
|
|
217
|
+
}),
|
|
218
|
+
/* @__PURE__ */ jsxs("div", {
|
|
219
|
+
className: "bl-font-field__control",
|
|
220
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
221
|
+
id: buttonId,
|
|
222
|
+
type: "button",
|
|
223
|
+
className: "bl-font-field__trigger",
|
|
224
|
+
disabled: readOnly,
|
|
225
|
+
"aria-haspopup": "listbox",
|
|
226
|
+
"aria-expanded": open,
|
|
227
|
+
"aria-controls": listId,
|
|
228
|
+
"aria-activedescendant": activeId,
|
|
229
|
+
onClick: () => setOpen((was) => !was),
|
|
230
|
+
onBlur: () => setOpen(false),
|
|
231
|
+
onKeyDown,
|
|
232
|
+
children: selected?.family ?? "Choose a font"
|
|
233
|
+
}), open ? /* @__PURE__ */ jsx("ul", {
|
|
234
|
+
id: listId,
|
|
235
|
+
role: "listbox",
|
|
236
|
+
className: "bl-font-field__menu",
|
|
237
|
+
children: options.map((option, i) => /* @__PURE__ */ jsxs("li", {
|
|
238
|
+
id: `${id}-option-${i}`,
|
|
239
|
+
role: "option",
|
|
240
|
+
"aria-selected": option.value === (value ?? ""),
|
|
241
|
+
className: "bl-font-field__option",
|
|
242
|
+
onMouseDown: (event) => {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
choose(option);
|
|
245
|
+
},
|
|
246
|
+
onMouseEnter: () => setActive(i),
|
|
247
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
248
|
+
className: "bl-font-field__family",
|
|
249
|
+
style: option.specimen ? { fontFamily: `"${option.family}"` } : void 0,
|
|
250
|
+
children: option.family
|
|
251
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
252
|
+
className: "bl-font-field__hint",
|
|
253
|
+
children: option.hint
|
|
254
|
+
})]
|
|
255
|
+
}, option.value || "same"))
|
|
256
|
+
}) : null]
|
|
257
|
+
}),
|
|
258
|
+
/* @__PURE__ */ jsx(FieldDescription, {
|
|
259
|
+
description: field.admin?.description,
|
|
260
|
+
path
|
|
261
|
+
}),
|
|
262
|
+
/* @__PURE__ */ jsx(FieldError, {
|
|
263
|
+
path,
|
|
264
|
+
showError,
|
|
265
|
+
message: errorMessage
|
|
266
|
+
})
|
|
267
|
+
]
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
const CSS$1 = `
|
|
271
|
+
.bl-font-field__control{position:relative}
|
|
272
|
+
.bl-font-field__trigger{display:flex;align-items:center;width:100%;min-height:calc(var(--base) * 2);padding:calc(var(--base) * .25) calc(var(--base) * .6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-input-bg);color:var(--theme-elevation-800);font:inherit;text-align:left;cursor:pointer}
|
|
273
|
+
.bl-font-field__trigger:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}
|
|
274
|
+
.bl-font-field__trigger:disabled{color:var(--theme-elevation-400);cursor:not-allowed}
|
|
275
|
+
.bl-font-field__menu{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:10;margin:0;padding:calc(var(--base) * .2) 0;list-style:none;max-height:18rem;overflow:auto;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-0);box-shadow:0 8px 24px var(--theme-overlay)}
|
|
276
|
+
.bl-font-field__option{display:flex;flex-direction:column;gap:2px;padding:calc(var(--base) * .35) calc(var(--base) * .6);cursor:pointer}
|
|
277
|
+
.bl-font-field__option[aria-selected="true"],.bl-font-field__option:hover{background:var(--theme-elevation-100)}
|
|
278
|
+
.bl-font-field__family{font-weight:500}
|
|
279
|
+
.bl-font-field__hint{color:var(--theme-elevation-500);font-size:12px}
|
|
280
|
+
`;
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/admin/contrast-report.tsx
|
|
283
|
+
function fieldValue(fields, path) {
|
|
284
|
+
return fields[path]?.value;
|
|
285
|
+
}
|
|
286
|
+
function isCompleteBrand(values) {
|
|
287
|
+
return isThemeHex(values.primary) && isThemeHex(values.secondary) && isThemeHex(values.accent) && isThemeHex(values.highlight) && isThemeHex(values.success) && typeof values.greyScale === "string";
|
|
288
|
+
}
|
|
289
|
+
function LevelIcon({ level }) {
|
|
290
|
+
if (level === "fail") return /* @__PURE__ */ jsx("svg", {
|
|
291
|
+
viewBox: "0 0 16 16",
|
|
292
|
+
width: "14",
|
|
293
|
+
height: "14",
|
|
294
|
+
"aria-hidden": "true",
|
|
295
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
296
|
+
fill: "currentColor",
|
|
297
|
+
d: "M8 1.5 1.5 14h13L8 1.5zm0 4.2c.4 0 .7.3.7.8v3.2c0 .4-.3.8-.7.8s-.8-.4-.8-.8V6.5c0-.5.4-.8.8-.8zm0 6.3a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"
|
|
298
|
+
})
|
|
299
|
+
});
|
|
300
|
+
if (level === "aa-large") return /* @__PURE__ */ jsx("svg", {
|
|
301
|
+
viewBox: "0 0 16 16",
|
|
302
|
+
width: "14",
|
|
303
|
+
height: "14",
|
|
304
|
+
"aria-hidden": "true",
|
|
305
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
306
|
+
fill: "currentColor",
|
|
307
|
+
d: "M3 8h10v1.5H3z"
|
|
308
|
+
})
|
|
309
|
+
});
|
|
310
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
311
|
+
viewBox: "0 0 16 16",
|
|
312
|
+
width: "14",
|
|
313
|
+
height: "14",
|
|
314
|
+
"aria-hidden": "true",
|
|
315
|
+
children: /* @__PURE__ */ jsx("path", {
|
|
316
|
+
fill: "currentColor",
|
|
317
|
+
d: "M8 1.2a6.8 6.8 0 1 1 0 13.6A6.8 6.8 0 0 1 8 1.2zm3.1 4.3L7.2 9.4 5 7.2l-.9.9 3.1 3.1 4.8-4.8-.9-.9z"
|
|
318
|
+
})
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Reads the five brand colours and grey scale from the form, runs
|
|
323
|
+
* `auditTheme` client-side, and paints its own rows. Warn only: nothing
|
|
324
|
+
* here touches validity.
|
|
325
|
+
*/
|
|
326
|
+
const ContrastReport = ({ field }) => {
|
|
327
|
+
const target = Number((field.admin?.custom)?.target) || 4.5;
|
|
328
|
+
const values = useFormFields(([fields]) => {
|
|
329
|
+
const record = fields;
|
|
330
|
+
return {
|
|
331
|
+
primary: fieldValue(record, "brand.primary"),
|
|
332
|
+
secondary: fieldValue(record, "brand.secondary"),
|
|
333
|
+
accent: fieldValue(record, "brand.accent"),
|
|
334
|
+
highlight: fieldValue(record, "brand.highlight"),
|
|
335
|
+
success: fieldValue(record, "brand.success"),
|
|
336
|
+
greyScale: fieldValue(record, "greyScale")
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
const last = useRef(null);
|
|
340
|
+
const complete = isCompleteBrand(values);
|
|
341
|
+
if (complete) last.current = auditTheme({
|
|
342
|
+
brandPrimary: values.primary,
|
|
343
|
+
brandSecondary: values.secondary,
|
|
344
|
+
brandAccent: values.accent,
|
|
345
|
+
brandHighlight: values.highlight,
|
|
346
|
+
brandSuccess: values.success,
|
|
347
|
+
greyScale: values.greyScale
|
|
348
|
+
}, { target });
|
|
349
|
+
const checks = last.current;
|
|
350
|
+
const stale = Boolean(checks) && !complete;
|
|
351
|
+
const failing = checks?.filter((check) => !check.meetsTarget).length ?? 0;
|
|
352
|
+
const summary = !checks ? "Enter five hex colours to measure contrast." : failing === 0 ? `Every pairing meets ${target}:1` : `${failing} of ${checks.length} pairings below ${target}:1`;
|
|
353
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
354
|
+
className: "field-type bl-contrast-report",
|
|
355
|
+
children: [
|
|
356
|
+
/* @__PURE__ */ jsx("style", {
|
|
357
|
+
href: "bl-contrast-report",
|
|
358
|
+
precedence: "default",
|
|
359
|
+
children: CSS
|
|
360
|
+
}),
|
|
361
|
+
/* @__PURE__ */ jsx("p", {
|
|
362
|
+
className: "bl-contrast-report__summary",
|
|
363
|
+
children: summary
|
|
364
|
+
}),
|
|
365
|
+
stale ? /* @__PURE__ */ jsx("p", {
|
|
366
|
+
className: "bl-contrast-report__stale",
|
|
367
|
+
children: "Showing the last complete audit while a colour is being typed."
|
|
368
|
+
}) : null,
|
|
369
|
+
checks ? /* @__PURE__ */ jsx("ul", {
|
|
370
|
+
className: "bl-contrast-report__list",
|
|
371
|
+
children: checks.flatMap((check) => ["light", "dark"].map((mode) => {
|
|
372
|
+
const sample = check[mode];
|
|
373
|
+
const failingRow = sample.ratio < check.target;
|
|
374
|
+
const verdict = contrastVerdict(sample, check.target);
|
|
375
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
376
|
+
className: "bl-contrast-report__row",
|
|
377
|
+
"data-failing": failingRow ? "" : void 0,
|
|
378
|
+
children: [
|
|
379
|
+
/* @__PURE__ */ jsx("span", {
|
|
380
|
+
"aria-hidden": "true",
|
|
381
|
+
className: "bl-contrast-report__swatch",
|
|
382
|
+
style: {
|
|
383
|
+
color: `hsl(${sample.foreground})`,
|
|
384
|
+
backgroundColor: `hsl(${sample.background})`
|
|
385
|
+
},
|
|
386
|
+
children: "Aa"
|
|
387
|
+
}),
|
|
388
|
+
/* @__PURE__ */ jsxs("span", {
|
|
389
|
+
className: "bl-contrast-report__ratio",
|
|
390
|
+
children: [sample.ratio.toFixed(2), ":1"]
|
|
391
|
+
}),
|
|
392
|
+
/* @__PURE__ */ jsxs("span", {
|
|
393
|
+
className: "bl-contrast-report__level",
|
|
394
|
+
children: [/* @__PURE__ */ jsx(LevelIcon, { level: sample.level }), contrastLevelLabel(sample.level)]
|
|
395
|
+
}),
|
|
396
|
+
/* @__PURE__ */ jsx("span", {
|
|
397
|
+
className: "bl-contrast-report__verdict",
|
|
398
|
+
children: verdict.text
|
|
399
|
+
}),
|
|
400
|
+
/* @__PURE__ */ jsxs("span", {
|
|
401
|
+
className: "bl-contrast-report__label",
|
|
402
|
+
children: [
|
|
403
|
+
check.label,
|
|
404
|
+
" (",
|
|
405
|
+
mode === "light" ? "Light" : "Dark",
|
|
406
|
+
")"
|
|
407
|
+
]
|
|
408
|
+
})
|
|
409
|
+
]
|
|
410
|
+
}, `${check.id}-${mode}`);
|
|
411
|
+
}))
|
|
412
|
+
}) : null
|
|
413
|
+
]
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
const CSS = `
|
|
417
|
+
.bl-contrast-report{margin:calc(var(--base) * .6) 0}
|
|
418
|
+
.bl-contrast-report__summary{margin:0 0 calc(var(--base) * .3);font-weight:600}
|
|
419
|
+
.bl-contrast-report__stale{margin:0 0 calc(var(--base) * .3);color:var(--theme-elevation-500);font-size:12px}
|
|
420
|
+
.bl-contrast-report__list{margin:0;padding:0;list-style:none;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-0)}
|
|
421
|
+
.bl-contrast-report__row{display:flex;flex-wrap:wrap;align-items:center;gap:calc(var(--base) * .4);padding:calc(var(--base) * .35) calc(var(--base) * .6);border-top:1px solid var(--theme-elevation-100)}
|
|
422
|
+
.bl-contrast-report__row:first-child{border-top:0}
|
|
423
|
+
.bl-contrast-report__row[data-failing]{background:var(--theme-error-50)}
|
|
424
|
+
.bl-contrast-report__swatch{flex:none;display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:1.75rem;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);font-weight:600}
|
|
425
|
+
.bl-contrast-report__ratio{width:4.5rem;font-variant-numeric:tabular-nums}
|
|
426
|
+
.bl-contrast-report__level{display:inline-flex;align-items:center;gap:4px;width:5.5rem}
|
|
427
|
+
.bl-contrast-report__verdict{width:6rem;font-weight:600}
|
|
428
|
+
.bl-contrast-report__label{flex:1;min-width:12rem;color:var(--theme-elevation-500)}
|
|
429
|
+
`;
|
|
430
|
+
//#endregion
|
|
431
|
+
export { ColorField, ContrastReport, FontField };
|
|
432
|
+
|
|
433
|
+
//# sourceMappingURL=admin.mjs.map
|