@bison-lab/payload-blocks 3.3.0 → 3.5.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 +118 -18
- package/dist/admin.d.mts +5 -2
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +362 -4
- package/dist/admin.mjs.map +1 -1
- package/dist/index.d.mts +101 -19
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +147 -23
- package/dist/index.mjs.map +1 -1
- package/dist/link-CnwLtm47.mjs +41 -0
- package/dist/link-CnwLtm47.mjs.map +1 -0
- package/dist/react.d.mts +19 -8
- package/dist/react.d.mts.map +1 -1
- package/dist/react.mjs +89 -86
- package/dist/react.mjs.map +1 -1
- package/dist/rich-text.d.mts +57 -14
- package/dist/rich-text.d.mts.map +1 -1
- package/dist/rich-text.mjs +99 -24
- package/dist/rich-text.mjs.map +1 -1
- package/dist/{seam-BDFZlV3F.mjs → seam-CmLb3BSo.mjs} +47 -2
- package/dist/seam-CmLb3BSo.mjs.map +1 -0
- package/dist/{types-D9rg0BoW.d.mts → types-ODMRyOpH.d.mts} +99 -17
- package/dist/types-ODMRyOpH.d.mts.map +1 -0
- package/package.json +1 -1
- package/dist/seam-BDFZlV3F.mjs.map +0 -1
- package/dist/types-D9rg0BoW.d.mts.map +0 -1
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,
|
|
27
|
-
| `@bison-lab/payload-blocks/admin` | `MinRowsArrayField`, the admin
|
|
26
|
+
| `@bison-lab/payload-blocks/rich-text` | The `richText` renderer, `internalDocToHrefFrom`, 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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
165
|
-
so a Next site's navigation
|
|
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,73 @@ 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. The helper
|
|
245
|
+
`internalDocToHrefFrom` unpacks that node into a page destination and calls
|
|
246
|
+
the site's `resolveLink`, so the page path is defined once. Pass that same
|
|
247
|
+
function to the factory (`richTextBlockRenderer({ resolveLink })`) and
|
|
248
|
+
register the result in place of `RichTextBlockRenderer`. Do it in a
|
|
249
|
+
`'use client'` module, like the adapters above: the `/rich-text` entry
|
|
250
|
+
carries the client banner, so the factory is a client reference a Server
|
|
251
|
+
Component can pass along but cannot call, and the resolver is a closure that
|
|
252
|
+
could not cross the boundary as a prop. A hand-written `internalDocToHref`
|
|
253
|
+
still works; if both options are passed, `internalDocToHref` wins.
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
'use client'
|
|
257
|
+
import { richTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'
|
|
258
|
+
import { resolveSiteLink } from './resolve-site-link'
|
|
259
|
+
|
|
260
|
+
export const SiteRichText = richTextBlockRenderer({
|
|
261
|
+
resolveLink: resolveSiteLink,
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Without a resolver an internal link still renders through the adapter, at `#`,
|
|
266
|
+
with a console error naming the option. A site whose editors link between
|
|
267
|
+
documents should not ship that way. An unpopulated or unpublished `doc`
|
|
268
|
+
follows `resolveLink`'s `null` rule and the helper maps that to `#` — it does
|
|
269
|
+
not invent a second unpublished policy.
|
|
270
|
+
|
|
271
|
+
One rich-text node still bypasses both seams: an *upload* an editor drops into
|
|
272
|
+
the prose renders through Payload's own converter, a file as a bare `<a>` and
|
|
273
|
+
an image as a raw `<img>`, until BIS-76 routes them through `linkComponent`
|
|
274
|
+
and `imageComponent`.
|
|
275
|
+
|
|
180
276
|
### The seam
|
|
181
277
|
|
|
182
278
|
One block floats across the join between two bands: the stats band with
|
|
@@ -299,7 +395,7 @@ renders nothing, and `headerItemsFromBlocks` drops it.
|
|
|
299
395
|
| Slug | Renders | Notes |
|
|
300
396
|
| --- | --- | --- |
|
|
301
397
|
| `hero` | plain markup | No `@bison-lab/ui` counterpart. Override this entry with your own. |
|
|
302
|
-
| `richText` | `RichText` (Lexical) | From `/rich-text`. |
|
|
398
|
+
| `richText` | `RichText` (Lexical) | From `/rich-text`. Links go through `linkComponent`; a link to another document needs `richTextBlockRenderer({ resolveLink })` (or a hand-written `internalDocToHref`). Uploads dropped into the prose do not use the seams yet (BIS-76). |
|
|
303
399
|
| `showcasePanels` | `ShowcasePanelsBlock` | 3–6 panels, each with a required image. Opens with three. |
|
|
304
400
|
| `processSteps` | `ProcessStepsBlock` | 3–6 ordered steps, numbered by position; advances on a timer. Opens with three. |
|
|
305
401
|
| `faqColumns` | `FAQColumnsBlock` | Answers are plain text, not Lexical — see the config for why. |
|
|
@@ -366,10 +462,14 @@ renderer's is here.
|
|
|
366
462
|
bundler, in `migrate`, `generate:types` and the admin server. An admin
|
|
367
463
|
component is referenced from a config by its import-map string
|
|
368
464
|
(`MIN_ROWS_ARRAY_FIELD`), never imported.
|
|
369
|
-
- **`src/admin.tsx` is the only entry that loads `@payloadcms/ui`.**
|
|
370
|
-
|
|
371
|
-
carries every upstream behaviour along.
|
|
372
|
-
|
|
465
|
+
- **`src/admin.tsx` is the only entry that loads `@payloadcms/ui`.** Where a
|
|
466
|
+
stock field does most of the job it is wrapped, not re-implemented
|
|
467
|
+
(`MinRowsArrayField`), so an upgrade carries every upstream behaviour along.
|
|
468
|
+
A control Payload has no stock form of (`LinkField`) is written against
|
|
469
|
+
Payload's hooks and theme variables and nothing else, so it reads in both
|
|
470
|
+
admin themes without a stylesheet of its own. Editor-facing copy, accessible
|
|
471
|
+
names and control text are not config fields: the `@bison-lab/ui` defaults
|
|
472
|
+
stand.
|
|
373
473
|
- **`cn()` is off limits.** It is a client-only export of `@bison-lab/ui`; use
|
|
374
474
|
the local `cx()`.
|
|
375
475
|
- **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
|
package/dist/admin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/min-rows-array-field.tsx"],"mappings":"
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|