@bison-lab/payload-blocks 3.3.0 → 3.4.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
@@ -23,8 +23,8 @@ Payload site already has).
23
23
  | --- | --- | --- |
24
24
  | `@bison-lab/payload-blocks` | Block configs, field builders, row types, `resolveMedia` | Node. This is what `payload.config.ts` imports, and it touches no React. |
25
25
  | `@bison-lab/payload-blocks/react` | Renderers, `RenderBlocks`, the image and link seams, the rendering types | Client (`"use client"`). |
26
- | `@bison-lab/payload-blocks/rich-text` | The `richText` renderer, alone | Client. Split out because it is the only thing that needs `@payloadcms/richtext-lexical`. |
27
- | `@bison-lab/payload-blocks/admin` | `MinRowsArrayField`, the admin field the configs reference by path | Client, inside the Payload admin. Resolved through the site's import map, never imported by hand. |
26
+ | `@bison-lab/payload-blocks/rich-text` | The `richText` renderer, and `richTextBlockRenderer()` to build one that resolves internal links | Client. Split out because it is the only thing that needs `@payloadcms/richtext-lexical`. |
27
+ | `@bison-lab/payload-blocks/admin` | `MinRowsArrayField` and `LinkField`, the admin fields the configs reference by path | Client, inside the Payload admin. Resolved through the site's import map, never imported by hand. |
28
28
 
29
29
  The renderers are client components because the blocks they render are: every
30
30
  `@bison-lab/ui` export is a client reference, and these blocks are interactive
@@ -69,14 +69,16 @@ export const Pages: CollectionConfig = {
69
69
  Then `payload generate:types`, `payload generate:importmap` and
70
70
  `payload migrate:create`.
71
71
 
72
- The import map step is what wires the admin field. Every array in this package
73
- with a `minRows` opens with that many empty rows and names
74
- `@bison-lab/payload-blocks/admin#MinRowsArrayField` as its field component,
75
- which refuses to remove a row once the count is down to the minimum (Payload
76
- gates Add on `maxRows` but never Remove on `minRows`). Until the import map
77
- has the entry Payload logs the miss and renders its stock array field, so an
78
- upgrade that forgets the step degrades to the old behaviour rather than
79
- breaking. A site can put the same field on its own arrays:
72
+ The import map step is what wires the admin fields, and it is not optional:
73
+ for a field whose custom component is missing from the import map, Payload
74
+ logs the miss and renders the field as nothing (an empty element stands in
75
+ for the component; the stock field does not come back). Two fields need it.
76
+
77
+ Every array in this package with a `minRows` opens with that many empty rows
78
+ and names `@bison-lab/payload-blocks/admin#MinRowsArrayField` as its field
79
+ component, which refuses to remove a row once the count is down to the
80
+ minimum (Payload gates Add on `maxRows` but never Remove on `minRows`). A site
81
+ can put the same field on its own arrays:
80
82
 
81
83
  ```ts
82
84
  import { MIN_ROWS_ARRAY_FIELD, emptyRows } from '@bison-lab/payload-blocks'
@@ -91,6 +93,23 @@ import { MIN_ROWS_ARRAY_FIELD, emptyRows } from '@bison-lab/payload-blocks'
91
93
  }
92
94
  ```
93
95
 
96
+ Every link a block asks for names `@bison-lab/payload-blocks/admin#LinkField`
97
+ on the row that holds its `type`, `page` and `href`, and the picker replaces
98
+ those three inputs with one box: type to search the pages collection by its
99
+ title field (published pages only, filtered by the server on every keystroke,
100
+ each result shown with its path), or paste anything that can only be a
101
+ destination — `http`, `mailto:`, `tel:`, or a site path starting with `/` —
102
+ and the one offer is to link to it. The chosen state is a Page or External
103
+ chip with a clear button; a page that was unpublished or deleted after it was
104
+ picked shows a warning there, and Payload refuses to publish the document
105
+ until it is picked again or republished, since the relationship only accepts
106
+ published rows. The picker writes the same three fields the stock inputs
107
+ would, so the REST API, `generate:types` and a site that reaches the row
108
+ without the picker all see one shape: `page` shows for `type: page`, `href`
109
+ for `type: external`, and `required` binds whichever is active. A site's own
110
+ block takes the same field through `linkFields()` or `linkField()`, and can
111
+ point it at another collection with `pagesCollection`.
112
+
94
113
  **2. Own the registry.**
95
114
 
96
115
  The registry is a parameter, not an export, so the compile-time layout lock
@@ -140,6 +159,15 @@ renderer. Never widen that type to get past the error; add the entry.
140
159
  band wrapper and resets the library block's own `max-w-*` / `px-*` so the two
141
160
  cannot fight.
142
161
 
162
+ Every block renders inside an unstyled `<div data-better-editor-id={row.id}>`
163
+ (`BLOCK_ID_ATTRIBUTE`). That is the hook
164
+ [payload-better-editor](https://www.npmjs.com/package/payload-better-editor)
165
+ needs to turn a click in its preview iframe into the clicked row's fields, so a
166
+ site adopting that editor has nothing to add per block. A row without an `id`
167
+ gets no attribute. It also means each band sits one level below the render root:
168
+ a test or a stylesheet that reaches the root's direct children (`container.children`,
169
+ `:scope > section`) meets the wrapper, not the band.
170
+
143
171
  `imageComponent` is how images get optimised. The package has no `next`
144
172
  dependency — `next/image` needs per-site configuration this package cannot
145
173
  supply — so a Next site writes one adapter and passes it once:
@@ -160,9 +188,10 @@ Uploads are served from `/api/media/file/…`, so widen `next.config.ts`
160
188
  renders.
161
189
 
162
190
  `linkComponent` is the same seam for links. No renderer writes an `<a>`
163
- itself: every `href` a block emits — a hero call to action, a showcase panel's
164
- corner action, a NAP `tel:` link, a menu goes through the component you pass,
165
- so a Next site's navigation stays client-side. `newTab` arrives as a flag
191
+ itself (one rich-text node excepted, below): every `href` a block emits — a hero call to action, a showcase panel's
192
+ corner action, a NAP `tel:` link, a menu, a link typed into a `richText`
193
+ section — goes through the component you pass, so a Next site's navigation
194
+ stays client-side. `newTab` arrives as a flag
166
195
  **and** as `target`/`rel` already expanded from it, so the adapter only has to
167
196
  drop the flag before spreading:
168
197
 
@@ -177,6 +206,74 @@ export const NextBlockLink: BlockLinkComponent = ({ newTab, ...props }) => <Link
177
206
  Without one, `DefaultBlockLink` renders a plain anchor and sets `target` and
178
207
  `rel` together whenever `newTab` is on.
179
208
 
209
+ `resolveLink` is how a page link becomes a path. Every link a block asks for
210
+ (`linkFields()`: a hero call to action, the testimonial link, the FAQ call to
211
+ action, a menu link) is stored as `type` (`page` or `external`), `page` (a
212
+ relationship into the site's pages collection, published rows only) and
213
+ `href`. A `depth: 1` page read populates `page`, and the renderer hands the
214
+ link to `resolveLink` for its `href`. The default makes it `/<slug>`; a site
215
+ whose routes differ passes its own, exported from a client module like the
216
+ adapters above (a Server Component cannot hand a closure across the boundary):
217
+
218
+ ```tsx
219
+ 'use client'
220
+ import { resolveLink, type ResolveLink } from '@bison-lab/payload-blocks/react'
221
+ import { pagePath } from '@/lib/routes' // your site's route helper
222
+
223
+ export const resolveSiteLink: ResolveLink = (link) =>
224
+ link.type === 'page' && typeof link.page === 'object' && link.page?.slug
225
+ ? pagePath(link.page.slug)
226
+ : resolveLink(link)
227
+ ```
228
+
229
+ A page link whose page is missing or unpublished resolves to `null`, and the
230
+ renderer shows the label as text rather than an anchor that points nowhere
231
+ (Payload hands the public read the bare id when the reader may not see the
232
+ page, which is what unpublished looks like from the site). A menu drops such a
233
+ link instead: an item that goes nowhere is worse than one fewer item. An
234
+ external link's `href` is never rewritten. A row saved before links had a
235
+ `type` carries only an `href`, and every reader (`resolveLink`, the stock
236
+ fields' conditions, the picker) takes that as an external link: the site keeps
237
+ rendering it and the admin shows it as an External chip. A site's migration to
238
+ `type: external` only makes the stored row say so.
239
+
240
+ Links in a `richText` section go through `linkComponent` too, the ones Lexical
241
+ auto-detects included. One kind needs more: a link an editor makes to another
242
+ *document* has no URL, only the document, and the route is the site's to know.
243
+ That is the same knowledge `resolveLink` carries, but Lexical hands the
244
+ renderer a link *node* rather than one of the link rows above, so it is a
245
+ separate resolver taking a separate shape. Build that site's renderer with
246
+ `richTextBlockRenderer({ internalDocToHref })` and register it in place of
247
+ `RichTextBlockRenderer`. Do it in a `'use client'` module, like the adapters
248
+ above: the `/rich-text` entry carries the client banner, so the factory is a
249
+ client reference a Server Component can pass along but cannot call, and the
250
+ resolver is a closure that could not cross the boundary as a prop.
251
+
252
+ ```tsx
253
+ 'use client'
254
+ import { richTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'
255
+
256
+ export const SiteRichText = richTextBlockRenderer({
257
+ internalDocToHref: ({ linkNode }) => {
258
+ const doc = linkNode.fields.doc
259
+ if (!doc) return '#'
260
+ // `value` is the related document when the page was fetched deep enough
261
+ // to populate it, otherwise its id. Fetch pages at depth 1 or more.
262
+ const slug = typeof doc.value === 'object' ? doc.value.slug : doc.value
263
+ return doc.relationTo === 'pages' ? `/${slug}` : `/${doc.relationTo}/${slug}`
264
+ },
265
+ })
266
+ ```
267
+
268
+ Without a resolver an internal link still renders through the adapter, at `#`,
269
+ with a console error naming the option. A site whose editors link between
270
+ documents should not ship that way.
271
+
272
+ One rich-text node still bypasses both seams: an *upload* an editor drops into
273
+ the prose renders through Payload's own converter, a file as a bare `<a>` and
274
+ an image as a raw `<img>`, until BIS-76 routes them through `linkComponent`
275
+ and `imageComponent`.
276
+
180
277
  ### The seam
181
278
 
182
279
  One block floats across the join between two bands: the stats band with
@@ -299,7 +396,7 @@ renders nothing, and `headerItemsFromBlocks` drops it.
299
396
  | Slug | Renders | Notes |
300
397
  | --- | --- | --- |
301
398
  | `hero` | plain markup | No `@bison-lab/ui` counterpart. Override this entry with your own. |
302
- | `richText` | `RichText` (Lexical) | From `/rich-text`. |
399
+ | `richText` | `RichText` (Lexical) | From `/rich-text`. Links go through `linkComponent`; a link to another document needs `richTextBlockRenderer({ internalDocToHref })`. Uploads dropped into the prose do not use the seams yet (BIS-76). |
303
400
  | `showcasePanels` | `ShowcasePanelsBlock` | 3–6 panels, each with a required image. Opens with three. |
304
401
  | `processSteps` | `ProcessStepsBlock` | 3–6 ordered steps, numbered by position; advances on a timer. Opens with three. |
305
402
  | `faqColumns` | `FAQColumnsBlock` | Answers are plain text, not Lexical — see the config for why. |
@@ -366,10 +463,14 @@ renderer's is here.
366
463
  bundler, in `migrate`, `generate:types` and the admin server. An admin
367
464
  component is referenced from a config by its import-map string
368
465
  (`MIN_ROWS_ARRAY_FIELD`), never imported.
369
- - **`src/admin.tsx` is the only entry that loads `@payloadcms/ui`.** It wraps
370
- Payload's stock fields rather than re-implementing them, so an upgrade
371
- carries every upstream behaviour along. Editor-facing copy, accessible names
372
- and control text are not config fields: the `@bison-lab/ui` defaults stand.
466
+ - **`src/admin.tsx` is the only entry that loads `@payloadcms/ui`.** Where a
467
+ stock field does most of the job it is wrapped, not re-implemented
468
+ (`MinRowsArrayField`), so an upgrade carries every upstream behaviour along.
469
+ A control Payload has no stock form of (`LinkField`) is written against
470
+ Payload's hooks and theme variables and nothing else, so it reads in both
471
+ admin themes without a stylesheet of its own. Editor-facing copy, accessible
472
+ names and control text are not config fields: the `@bison-lab/ui` defaults
473
+ stand.
373
474
  - **`cn()` is off limits.** It is a client-only export of `@bison-lab/ui`; use
374
475
  the local `cx()`.
375
476
  - **Changing a field is a schema change in every consuming site.** Update
package/dist/admin.d.mts CHANGED
@@ -1,6 +1,9 @@
1
1
 
2
- import { ArrayFieldClientComponent } from "payload";
2
+ import { ArrayFieldClientComponent, RowFieldClientComponent } from "payload";
3
3
 
4
+ //#region src/admin/link-field.d.ts
5
+ declare const LinkField: RowFieldClientComponent;
6
+ //#endregion
4
7
  //#region src/admin/min-rows-array-field.d.ts
5
8
  /**
6
9
  * Payload's array field, with *Remove* refused once the rows are down to
@@ -23,5 +26,5 @@ import { ArrayFieldClientComponent } from "payload";
23
26
  */
24
27
  declare const MinRowsArrayField: ArrayFieldClientComponent;
25
28
  //#endregion
26
- export { MinRowsArrayField };
29
+ export { LinkField, MinRowsArrayField };
27
30
  //# sourceMappingURL=admin.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/min-rows-array-field.tsx"],"mappings":";;;;;;AAuBA;;;;;;;;;;;;;;;;;cAAa,iBAAA,EAAmB,yBAAA"}
1
+ {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/link-field.tsx","../src/admin/min-rows-array-field.tsx"],"mappings":";;;;cAuEa,SAAA,EAAW,uBAAA;;;;;AAAxB;;;;;;;;AChDA;;;;;;;;;cAAa,iBAAA,EAAmB,yBAAA"}
package/dist/admin.mjs CHANGED
@@ -1,7 +1,365 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
3
- import { ArrayField, toast, useField, useTranslation } from "@payloadcms/ui";
4
- import { useCallback, useRef } from "react";
2
+ import { t as linkTypeOf } from "./link-CnwLtm47.mjs";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { ArrayField, FieldDescription, FieldError, FieldLabel, toast, useConfig, useField, useFieldPath, useTranslation } from "@payloadcms/ui";
5
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
6
+ //#region src/admin/link-field.tsx
7
+ /**
8
+ * The link picker: one box in place of a link's `type`, `page` and `href`.
9
+ *
10
+ * Type to search the pages collection by its title field; each result shows
11
+ * its path. Paste anything that can only be a destination (`http`, `mailto:`,
12
+ * `tel:`, or a site path starting with `/`) and the one offer is to link to
13
+ * it. Choosing writes the stored shape through Payload's `useField`, so the
14
+ * document saves exactly what the stock fields would have saved, and the
15
+ * chosen state shows a Page or External chip with a clear button.
16
+ *
17
+ * The search asks the collection's REST endpoint for `_status: published`
18
+ * rows, so the draft rule is the server's and is applied on every keystroke.
19
+ * A stored page is fetched by id without that filter, which is how the
20
+ * picker knows to warn that a page chosen earlier has since been unpublished
21
+ * or deleted.
22
+ *
23
+ * Painted with Payload's own theme variables and nothing else, so it reads
24
+ * in both admin themes without a stylesheet of its own; the rules ride along
25
+ * in a `<style href precedence>`, which React 19 hoists into the head and
26
+ * dedupes across instances. Payload 3's admin is React 19 (`@payloadcms/ui`
27
+ * peers `react ^19`), so that is the only React this entry runs under.
28
+ */
29
+ const LABEL = "Goes to";
30
+ const PLACEHOLDER = "Search pages, or paste a URL";
31
+ const DESCRIPTION = "Published pages only. Paste a URL, or a path like /find-a-doctor, to link anywhere else.";
32
+ const NO_MATCH = "No published page matches. Paste a URL to link outside the site.";
33
+ const UNPUBLISHED = "This page is no longer published. The site shows this link as plain text, and this document cannot be published until you pick another page or publish that one again.";
34
+ const MISSING = "This page no longer exists. Pick another page or paste a URL before publishing.";
35
+ const SEARCH_DELAY_MS = 200;
36
+ const SEARCH_LIMIT = 8;
37
+ /** Text an editor could only mean as a destination, never as a page title. */
38
+ const URL_LIKE = /^(https?:\/\/|mailto:|tel:|\/)/i;
39
+ /**
40
+ * A row has no name, so its `path` ends in `_index-N` and its fields sit at
41
+ * the parent's level, the way Payload's `getFieldPaths` places them.
42
+ */
43
+ function siblingPaths(rowPath) {
44
+ const base = rowPath.split(".").filter((segment) => !segment.startsWith("_index-")).join(".");
45
+ const at = (name) => base ? `${base}.${name}` : name;
46
+ return {
47
+ type: at("type"),
48
+ page: at("page"),
49
+ href: at("href")
50
+ };
51
+ }
52
+ function pagePath(page) {
53
+ return page.slug ? `/${page.slug}` : "";
54
+ }
55
+ const LinkField = ({ field, path, readOnly }) => {
56
+ const rowPath = useFieldPath() ?? path;
57
+ const paths = useMemo(() => siblingPaths(rowPath), [rowPath]);
58
+ const type = useField({ path: paths.type });
59
+ const page = useField({ path: paths.page });
60
+ const href = useField({ path: paths.href });
61
+ const pageField = field.fields.find((f) => f.type === "relationship" && f.name === "page");
62
+ const collection = typeof pageField?.relationTo === "string" ? pageField.relationTo : "pages";
63
+ const required = Boolean(pageField?.required);
64
+ const { config } = useConfig();
65
+ const titleKey = config.collections.find((c) => c.slug === collection)?.admin?.useAsTitle ?? "id";
66
+ const endpoint = `${config.serverURL}${config.routes.api}/${collection}`;
67
+ const id = useId();
68
+ const inputId = `${id}-input`;
69
+ const listId = `${id}-list`;
70
+ const inputRef = useRef(null);
71
+ const [query, setQuery] = useState("");
72
+ const [open, setOpen] = useState(false);
73
+ const [loading, setLoading] = useState(false);
74
+ const [results, setResults] = useState([]);
75
+ const [active, setActive] = useState(-1);
76
+ const [focusNext, setFocusNext] = useState(false);
77
+ /** The stored page, looked up; `missing` when the id no longer answers. */
78
+ const [stored, setStored] = useState(null);
79
+ /** Pages seen in search results, so choosing one needs no second request. */
80
+ const known = useRef(/* @__PURE__ */ new Map());
81
+ const searchSeq = useRef(0);
82
+ const linkType = linkTypeOf({
83
+ type: type.value,
84
+ page: page.value,
85
+ href: href.value
86
+ });
87
+ const kind = linkType === "external" ? href.value ? "external" : null : page.value ? "page" : null;
88
+ const pageValue = page.value;
89
+ const pageId = pageValue && typeof pageValue === "object" ? null : pageValue;
90
+ useEffect(() => {
91
+ if (kind !== "page") {
92
+ setStored(null);
93
+ return;
94
+ }
95
+ if (pageValue && typeof pageValue === "object") {
96
+ setStored({
97
+ doc: pageValue,
98
+ missing: false
99
+ });
100
+ return;
101
+ }
102
+ const cached = known.current.get(String(pageId));
103
+ if (cached) {
104
+ setStored({
105
+ doc: cached,
106
+ missing: false
107
+ });
108
+ return;
109
+ }
110
+ let cancelled = false;
111
+ fetch(`${endpoint}/${pageId}?depth=0`, { credentials: "include" }).then(async (response) => {
112
+ if (cancelled) return;
113
+ if (!response.ok) {
114
+ setStored({
115
+ doc: null,
116
+ missing: true
117
+ });
118
+ return;
119
+ }
120
+ const doc = await response.json();
121
+ known.current.set(String(doc.id), doc);
122
+ if (!cancelled) setStored({
123
+ doc,
124
+ missing: false
125
+ });
126
+ }).catch(() => {
127
+ if (!cancelled) setStored({
128
+ doc: null,
129
+ missing: true
130
+ });
131
+ });
132
+ return () => {
133
+ cancelled = true;
134
+ };
135
+ }, [
136
+ kind,
137
+ pageValue,
138
+ pageId,
139
+ endpoint
140
+ ]);
141
+ const trimmed = query.trim();
142
+ const urlLike = URL_LIKE.test(trimmed);
143
+ useEffect(() => {
144
+ if (!open || urlLike) return;
145
+ const seq = searchSeq.current += 1;
146
+ setLoading(true);
147
+ const timer = setTimeout(() => {
148
+ const params = new URLSearchParams({
149
+ depth: "0",
150
+ limit: String(SEARCH_LIMIT),
151
+ sort: titleKey,
152
+ "where[_status][equals]": "published"
153
+ });
154
+ if (trimmed) params.set(`where[${titleKey}][like]`, trimmed);
155
+ fetch(`${endpoint}?${params.toString()}`, { credentials: "include" }).then((response) => response.ok ? response.json() : { docs: [] }).then((body) => {
156
+ if (seq !== searchSeq.current) return;
157
+ const docs = body.docs ?? [];
158
+ for (const doc of docs) known.current.set(String(doc.id), doc);
159
+ setResults(docs);
160
+ setLoading(false);
161
+ }).catch(() => {
162
+ if (seq !== searchSeq.current) return;
163
+ setResults([]);
164
+ setLoading(false);
165
+ });
166
+ }, SEARCH_DELAY_MS);
167
+ return () => clearTimeout(timer);
168
+ }, [
169
+ open,
170
+ urlLike,
171
+ trimmed,
172
+ endpoint,
173
+ titleKey
174
+ ]);
175
+ useEffect(() => {
176
+ if (focusNext && inputRef.current) {
177
+ inputRef.current.focus();
178
+ setFocusNext(false);
179
+ }
180
+ }, [focusNext, kind]);
181
+ const options = urlLike ? [{
182
+ kind: "url",
183
+ href: trimmed
184
+ }] : results.map((doc) => ({
185
+ kind: "page",
186
+ page: doc
187
+ }));
188
+ function choose(option) {
189
+ if (option.kind === "page") {
190
+ known.current.set(String(option.page.id), option.page);
191
+ type.setValue("page");
192
+ page.setValue(option.page.id);
193
+ href.setValue(null);
194
+ } else {
195
+ type.setValue("external");
196
+ href.setValue(option.href);
197
+ page.setValue(null);
198
+ }
199
+ setQuery("");
200
+ setOpen(false);
201
+ setActive(-1);
202
+ }
203
+ function clear() {
204
+ type.setValue("page");
205
+ page.setValue(null);
206
+ href.setValue(null);
207
+ setStored(null);
208
+ setFocusNext(true);
209
+ }
210
+ function onKeyDown(event) {
211
+ if (event.key === "ArrowDown") {
212
+ event.preventDefault();
213
+ if (!open) {
214
+ setOpen(true);
215
+ return;
216
+ }
217
+ setActive((i) => options.length ? (i + 1) % options.length : -1);
218
+ } else if (event.key === "ArrowUp") {
219
+ event.preventDefault();
220
+ setActive((i) => options.length ? i <= 0 ? options.length - 1 : i - 1 : -1);
221
+ } else if (event.key === "Enter") {
222
+ if (!open) return;
223
+ event.preventDefault();
224
+ const pick = options[active] ?? options[0];
225
+ if (pick) choose(pick);
226
+ } else if (event.key === "Escape") {
227
+ if (!open) return;
228
+ event.preventDefault();
229
+ event.stopPropagation();
230
+ setOpen(false);
231
+ setActive(-1);
232
+ }
233
+ }
234
+ const error = linkType === "external" ? href : page;
235
+ const activeId = active >= 0 && open ? `${id}-option-${active}` : void 0;
236
+ return /* @__PURE__ */ jsxs("div", {
237
+ className: "field-type bl-link-field",
238
+ children: [
239
+ /* @__PURE__ */ jsx("style", {
240
+ href: "bl-link-field",
241
+ precedence: "default",
242
+ children: CSS
243
+ }),
244
+ /* @__PURE__ */ jsx(FieldLabel, {
245
+ htmlFor: inputId,
246
+ label: LABEL,
247
+ required
248
+ }),
249
+ /* @__PURE__ */ jsxs("div", {
250
+ className: "bl-link-field__control",
251
+ children: [
252
+ kind === "page" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
253
+ className: "bl-link-field__kind",
254
+ children: "Page"
255
+ }), /* @__PURE__ */ jsx("span", {
256
+ className: "bl-link-field__value",
257
+ children: stored?.doc ? /* @__PURE__ */ jsxs(Fragment, { children: [String(stored.doc[titleKey] ?? stored.doc.id), /* @__PURE__ */ jsx("span", {
258
+ className: "bl-link-field__path",
259
+ children: pagePath(stored.doc)
260
+ })] }) : stored?.missing ? `Page ${String(pageId)}` : "…"
261
+ })] }) : kind === "external" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
262
+ className: "bl-link-field__kind",
263
+ children: "External"
264
+ }), /* @__PURE__ */ jsx("span", {
265
+ className: "bl-link-field__value",
266
+ children: href.value
267
+ })] }) : /* @__PURE__ */ jsx("input", {
268
+ ref: inputRef,
269
+ id: inputId,
270
+ type: "text",
271
+ role: "combobox",
272
+ className: "bl-link-field__input",
273
+ placeholder: PLACEHOLDER,
274
+ autoComplete: "off",
275
+ disabled: readOnly,
276
+ value: query,
277
+ "aria-expanded": open,
278
+ "aria-controls": listId,
279
+ "aria-autocomplete": "list",
280
+ "aria-activedescendant": activeId,
281
+ onChange: (event) => {
282
+ setQuery(event.target.value);
283
+ setActive(-1);
284
+ setOpen(true);
285
+ },
286
+ onFocus: () => setOpen(true),
287
+ onBlur: () => setOpen(false),
288
+ onKeyDown
289
+ }),
290
+ kind && !readOnly ? /* @__PURE__ */ jsx("button", {
291
+ type: "button",
292
+ className: "bl-link-field__clear",
293
+ "aria-label": "Clear",
294
+ onClick: clear,
295
+ children: "×"
296
+ }) : null,
297
+ open && !kind ? /* @__PURE__ */ jsxs("ul", {
298
+ id: listId,
299
+ role: "listbox",
300
+ className: "bl-link-field__menu",
301
+ children: [options.map((option, i) => /* @__PURE__ */ jsx("li", {
302
+ id: `${id}-option-${i}`,
303
+ role: "option",
304
+ "aria-selected": i === active,
305
+ className: "bl-link-field__option",
306
+ onMouseDown: (event) => {
307
+ event.preventDefault();
308
+ choose(option);
309
+ },
310
+ onMouseEnter: () => setActive(i),
311
+ children: option.kind === "page" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children: String(option.page[titleKey] ?? option.page.id) }), /* @__PURE__ */ jsx("span", {
312
+ className: "bl-link-field__path",
313
+ children: pagePath(option.page)
314
+ })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children: "Link to this URL" }), /* @__PURE__ */ jsx("span", {
315
+ className: "bl-link-field__path",
316
+ children: option.href
317
+ })] })
318
+ }, option.kind === "page" ? String(option.page.id) : option.href)), options.length === 0 && !loading ? /* @__PURE__ */ jsx("li", {
319
+ role: "presentation",
320
+ className: "bl-link-field__empty",
321
+ children: NO_MATCH
322
+ }) : null]
323
+ }) : null
324
+ ]
325
+ }),
326
+ kind === "page" && stored && (stored.missing || stored.doc?._status === "draft") ? /* @__PURE__ */ jsx("div", {
327
+ role: "status",
328
+ className: "bl-link-field__warning",
329
+ children: stored.missing ? MISSING : UNPUBLISHED
330
+ }) : null,
331
+ /* @__PURE__ */ jsx(FieldDescription, {
332
+ description: DESCRIPTION,
333
+ path: paths.page
334
+ }),
335
+ /* @__PURE__ */ jsx(FieldError, {
336
+ path: error.path,
337
+ showError: error.showError,
338
+ message: error.errorMessage
339
+ })
340
+ ]
341
+ });
342
+ };
343
+ /** Theme variables only: a literal colour would be right in one theme and wrong in the other. */
344
+ const CSS = `
345
+ .bl-link-field__control{position:relative;display:flex;align-items:center;gap:calc(var(--base) * .4);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-body)}
346
+ .bl-link-field__control:focus-within{border-color:var(--theme-elevation-400);box-shadow:0 0 0 1px var(--theme-elevation-400)}
347
+ .bl-link-field__input{flex:1;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
348
+ .bl-link-field__input::placeholder{color:var(--theme-elevation-400)}
349
+ .bl-link-field__input:disabled{color:var(--theme-elevation-400)}
350
+ .bl-link-field__kind{flex:none;padding:2px 6px;border-radius:var(--style-radius-s);background:var(--theme-elevation-100);color:var(--theme-elevation-600);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase}
351
+ .bl-link-field__value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
352
+ .bl-link-field__path{margin-left:calc(var(--base) * .4);color:var(--theme-elevation-500);font-family:var(--font-mono);font-size:11.5px;font-weight:400}
353
+ .bl-link-field__clear{flex:none;width:calc(var(--base) * 1.2);height:calc(var(--base) * 1.2);border:0;border-radius:var(--style-radius-s);background:transparent;color:var(--theme-elevation-500);font-size:18px;line-height:1;cursor:pointer}
354
+ .bl-link-field__clear:hover,.bl-link-field__clear:focus-visible{background:var(--theme-elevation-100);color:var(--theme-elevation-800);outline:none}
355
+ .bl-link-field__menu{position:absolute;left:-1px;right:-1px;top:calc(100% + 4px);z-index:10;margin:0;padding:calc(var(--base) * .2) 0;list-style:none;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)}
356
+ .bl-link-field__option{display:flex;justify-content:space-between;gap:calc(var(--base) * .6);padding:calc(var(--base) * .35) calc(var(--base) * .6);cursor:pointer}
357
+ .bl-link-field__option[aria-selected="true"]{background:var(--theme-elevation-100)}
358
+ .bl-link-field__empty{padding:calc(var(--base) * .5) calc(var(--base) * .6);color:var(--theme-elevation-500)}
359
+ .bl-link-field__warning{display:flex;align-items:baseline;gap:calc(var(--base) * .3);margin-top:calc(var(--base) * .3);color:var(--theme-warning-600);font-size:12px}
360
+ .bl-link-field__warning::before{content:"";flex:none;width:8px;height:8px;border-radius:50%;background:var(--theme-warning-500);transform:translateY(-1px)}
361
+ `;
362
+ //#endregion
5
363
  //#region src/admin/min-rows-array-field.tsx
6
364
  /**
7
365
  * Payload's array field, with *Remove* refused once the rows are down to
@@ -77,6 +435,6 @@ function staticLabel(label, language) {
77
435
  }
78
436
  }
79
437
  //#endregion
80
- export { MinRowsArrayField };
438
+ export { LinkField, MinRowsArrayField };
81
439
 
82
440
  //# sourceMappingURL=admin.mjs.map