@byline/admin 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/fields/array/array-field.d.ts +12 -2
  2. package/dist/fields/array/array-field.js +46 -20
  3. package/dist/fields/array/array-field.module.js +5 -1
  4. package/dist/fields/array/array-field_module.css +23 -0
  5. package/dist/fields/blocks/blocks-field.js +34 -12
  6. package/dist/fields/blocks/blocks-field.module.js +2 -0
  7. package/dist/fields/blocks/blocks-field_module.css +37 -0
  8. package/dist/fields/code/code-editor.d.ts +20 -0
  9. package/dist/fields/code/code-editor.js +239 -0
  10. package/dist/fields/code/code-field.d.ts +21 -0
  11. package/dist/fields/code/code-field.js +124 -0
  12. package/dist/fields/code/code-field.module.js +7 -0
  13. package/dist/fields/code/code-field_module.css +58 -0
  14. package/dist/fields/draggable-context-menu.js +2 -1
  15. package/dist/fields/field-admin.d.ts +15 -0
  16. package/dist/fields/field-admin.js +11 -0
  17. package/dist/fields/field-admin.test.node.d.ts +8 -0
  18. package/dist/fields/field-helpers.js +1 -0
  19. package/dist/fields/field-renderer.d.ts +11 -2
  20. package/dist/fields/field-renderer.js +21 -4
  21. package/dist/fields/group/group-field.d.ts +23 -2
  22. package/dist/fields/group/group-field.js +7 -3
  23. package/dist/fields/relation/relation-picker.js +8 -2
  24. package/dist/fields/select/select-field.js +4 -2
  25. package/dist/fields/select/select-field.module.js +1 -0
  26. package/dist/fields/select/select-field_module.css +4 -0
  27. package/dist/fields/sortable-item.d.ts +6 -0
  28. package/dist/fields/sortable-item.js +44 -25
  29. package/dist/fields/text/text-field_module.css +1 -0
  30. package/dist/fields/text-area/text-area-field_module.css +1 -0
  31. package/dist/forms/form-context.js +1 -1
  32. package/dist/forms/form-renderer.d.ts +1 -1
  33. package/dist/forms/form-renderer.js +3 -1
  34. package/dist/forms/tree-placement-widget.d.ts +1 -1
  35. package/package.json +23 -10
  36. package/src/fields/array/array-field.module.css +30 -0
  37. package/src/fields/array/array-field.tsx +74 -20
  38. package/src/fields/blocks/blocks-field.module.css +53 -0
  39. package/src/fields/blocks/blocks-field.tsx +38 -1
  40. package/src/fields/code/code-editor.tsx +246 -0
  41. package/src/fields/code/code-field.module.css +84 -0
  42. package/src/fields/code/code-field.tsx +191 -0
  43. package/src/fields/draggable-context-menu.tsx +9 -1
  44. package/src/fields/field-admin.test.node.ts +49 -0
  45. package/src/fields/field-admin.ts +39 -0
  46. package/src/fields/field-helpers.ts +1 -0
  47. package/src/fields/field-renderer.tsx +33 -2
  48. package/src/fields/field-services-types.ts +1 -1
  49. package/src/fields/group/group-field.tsx +29 -2
  50. package/src/fields/relation/relation-picker.tsx +11 -0
  51. package/src/fields/select/select-field.module.css +5 -0
  52. package/src/fields/select/select-field.tsx +9 -2
  53. package/src/fields/sortable-item.tsx +115 -34
  54. package/src/fields/text/text-field.module.css +1 -0
  55. package/src/fields/text-area/text-area-field.module.css +1 -0
  56. package/src/forms/form-context.tsx +9 -1
  57. package/src/forms/form-renderer.tsx +3 -1
  58. package/src/forms/tree-placement-widget.tsx +1 -1
@@ -0,0 +1,246 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useEffect, useRef } from 'react'
10
+
11
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
12
+ import { bracketMatching, HighlightStyle, syntaxHighlighting } from '@codemirror/language'
13
+ import type { Extension } from '@codemirror/state'
14
+ import { Compartment, EditorState } from '@codemirror/state'
15
+ import { EditorView, keymap, lineNumbers } from '@codemirror/view'
16
+ import { tags } from '@lezer/highlight'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // CodeEditor — the CodeMirror 6 half of the `code` field widget.
20
+ //
21
+ // This module owns EVERY CodeMirror import and is loaded through
22
+ // `React.lazy` from `code-field.tsx`, so none of it lands in the main admin
23
+ // chunk — the consuming app's bundler splits it out and fetches it the first
24
+ // time a code field actually renders. Keep it dependency-tight: anything
25
+ // imported here ships in the lazy chunk.
26
+ //
27
+ // Theming: one theme + one highlight style, both defined entirely in terms
28
+ // of `--byline-code-*` CSS custom properties (declared with light/dark
29
+ // values in `code-field.module.css`). The admin theme contract is a
30
+ // `.dark`/`.light` class on <html>, so the editor follows theme flips live
31
+ // with zero JS involvement.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Per-language lazy loaders. Each grammar stays in its own chunk — adding a
36
+ * code field to a document only fetches the grammar it actually uses.
37
+ * Unknown languages resolve to `null` → plain text (no language extension).
38
+ */
39
+ const LANGUAGE_LOADERS: Record<string, () => Promise<Extension>> = {
40
+ javascript: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
41
+ jsx: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ jsx: true })),
42
+ typescript: () =>
43
+ import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
44
+ tsx: () =>
45
+ import('@codemirror/lang-javascript').then((m) =>
46
+ m.javascript({ jsx: true, typescript: true })
47
+ ),
48
+ json: () => import('@codemirror/lang-json').then((m) => m.json()),
49
+ html: () => import('@codemirror/lang-html').then((m) => m.html()),
50
+ css: () => import('@codemirror/lang-css').then((m) => m.css()),
51
+ markdown: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
52
+ python: () => import('@codemirror/lang-python').then((m) => m.python()),
53
+ sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
54
+ yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
55
+ }
56
+
57
+ /** Common aliases → canonical loader keys. */
58
+ const LANGUAGE_ALIASES: Record<string, string> = {
59
+ js: 'javascript',
60
+ ts: 'typescript',
61
+ md: 'markdown',
62
+ py: 'python',
63
+ yml: 'yaml',
64
+ }
65
+
66
+ const resolveLanguageLoader = (language: string | undefined): (() => Promise<Extension>) | null => {
67
+ if (!language) return null
68
+ const key = LANGUAGE_ALIASES[language] ?? language
69
+ return LANGUAGE_LOADERS[key] ?? null
70
+ }
71
+
72
+ const bylineCodeTheme = EditorView.theme({
73
+ '&': {
74
+ backgroundColor: 'var(--byline-code-bg)',
75
+ color: 'var(--byline-code-fg)',
76
+ fontSize: 'var(--byline-code-font-size, 13px)',
77
+ border: '1px solid var(--byline-code-border)',
78
+ borderRadius: 'var(--byline-code-radius, 4px)',
79
+ },
80
+ '&.cm-focused': {
81
+ outline: '2px solid var(--byline-code-focus-ring)',
82
+ outlineOffset: '-1px',
83
+ },
84
+ '.cm-content': {
85
+ fontFamily: 'var(--byline-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)',
86
+ caretColor: 'var(--byline-code-caret)',
87
+ minHeight: 'var(--byline-code-min-height, 6rem)',
88
+ },
89
+ '.cm-scroller': {
90
+ fontFamily: 'inherit',
91
+ maxHeight: 'var(--byline-code-max-height, 32rem)',
92
+ },
93
+ '.cm-gutters': {
94
+ backgroundColor: 'var(--byline-code-gutter-bg)',
95
+ color: 'var(--byline-code-gutter-fg)',
96
+ border: 'none',
97
+ borderRight: '1px solid var(--byline-code-border)',
98
+ },
99
+ '.cm-activeLine': { backgroundColor: 'var(--byline-code-active-line)' },
100
+ '.cm-activeLineGutter': { backgroundColor: 'var(--byline-code-active-line)' },
101
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
102
+ backgroundColor: 'var(--byline-code-selection) !important',
103
+ },
104
+ '.cm-cursor': { borderLeftColor: 'var(--byline-code-caret)' },
105
+ })
106
+
107
+ const bylineHighlightStyle = HighlightStyle.define([
108
+ { tag: tags.keyword, color: 'var(--byline-code-keyword)' },
109
+ { tag: [tags.string, tags.special(tags.string)], color: 'var(--byline-code-string)' },
110
+ { tag: tags.comment, color: 'var(--byline-code-comment)', fontStyle: 'italic' },
111
+ { tag: [tags.number, tags.bool, tags.null], color: 'var(--byline-code-number)' },
112
+ {
113
+ tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
114
+ color: 'var(--byline-code-function)',
115
+ },
116
+ { tag: [tags.typeName, tags.className, tags.tagName], color: 'var(--byline-code-type)' },
117
+ { tag: [tags.propertyName, tags.attributeName], color: 'var(--byline-code-property)' },
118
+ { tag: [tags.operator, tags.definitionKeyword], color: 'var(--byline-code-operator)' },
119
+ ])
120
+
121
+ export interface CodeEditorProps {
122
+ id?: string
123
+ /** Initial document. External updates sync only while the editor is unfocused. */
124
+ value: string
125
+ /** Highlight language (canonical name or alias). Unknown → plain text. */
126
+ language?: string
127
+ readOnly?: boolean
128
+ onChange?: (value: string) => void
129
+ ariaInvalid?: boolean
130
+ ariaDescribedBy?: string
131
+ }
132
+
133
+ const CodeEditor = ({
134
+ id,
135
+ value,
136
+ language,
137
+ readOnly,
138
+ onChange,
139
+ ariaInvalid,
140
+ ariaDescribedBy,
141
+ }: CodeEditorProps) => {
142
+ const containerRef = useRef<HTMLDivElement>(null)
143
+ const viewRef = useRef<EditorView | null>(null)
144
+ // Keep the latest onChange without recreating the EditorView.
145
+ const onChangeRef = useRef(onChange)
146
+ onChangeRef.current = onChange
147
+ const languageCompartment = useRef(new Compartment())
148
+ const readOnlyCompartment = useRef(new Compartment())
149
+ const ariaCompartment = useRef(new Compartment())
150
+ const initialValueRef = useRef(value)
151
+
152
+ // Create the view once per mount. The form store lives above the tab
153
+ // layout, so remounts (tab switches, conditional visibility) re-seed from
154
+ // the store-backed `value` — same contract the other widgets follow.
155
+ useEffect(() => {
156
+ if (containerRef.current == null) return
157
+ const state = EditorState.create({
158
+ doc: initialValueRef.current,
159
+ extensions: [
160
+ lineNumbers(),
161
+ history(),
162
+ bracketMatching(),
163
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
164
+ EditorView.lineWrapping,
165
+ bylineCodeTheme,
166
+ syntaxHighlighting(bylineHighlightStyle),
167
+ languageCompartment.current.of([]),
168
+ readOnlyCompartment.current.of([]),
169
+ ariaCompartment.current.of([]),
170
+ EditorView.updateListener.of((update) => {
171
+ if (update.docChanged) {
172
+ onChangeRef.current?.(update.state.doc.toString())
173
+ }
174
+ }),
175
+ ],
176
+ })
177
+ const view = new EditorView({ state, parent: containerRef.current })
178
+ viewRef.current = view
179
+ return () => {
180
+ view.destroy()
181
+ viewRef.current = null
182
+ }
183
+ }, [])
184
+
185
+ // External value sync — only while unfocused, so we never fight the
186
+ // editor's own keystrokes (our own onChange echoes back as an equal doc).
187
+ useEffect(() => {
188
+ const view = viewRef.current
189
+ if (view == null || view.hasFocus) return
190
+ const current = view.state.doc.toString()
191
+ if (current !== value) {
192
+ view.dispatch({ changes: { from: 0, to: current.length, insert: value } })
193
+ }
194
+ }, [value])
195
+
196
+ // Language switching through a compartment: each grammar loads lazily the
197
+ // first time it's requested and reconfigures in place (no view recreation).
198
+ useEffect(() => {
199
+ let cancelled = false
200
+ const view = viewRef.current
201
+ if (view == null) return
202
+ const loader = resolveLanguageLoader(language)
203
+ if (loader == null) {
204
+ view.dispatch({ effects: languageCompartment.current.reconfigure([]) })
205
+ return
206
+ }
207
+ loader()
208
+ .then((extension) => {
209
+ if (!cancelled && viewRef.current != null) {
210
+ viewRef.current.dispatch({
211
+ effects: languageCompartment.current.reconfigure(extension),
212
+ })
213
+ }
214
+ })
215
+ .catch(() => {
216
+ // Grammar chunk failed to load — degrade to plain text.
217
+ })
218
+ return () => {
219
+ cancelled = true
220
+ }
221
+ }, [language])
222
+
223
+ useEffect(() => {
224
+ viewRef.current?.dispatch({
225
+ effects: readOnlyCompartment.current.reconfigure([
226
+ EditorState.readOnly.of(readOnly === true),
227
+ EditorView.editable.of(readOnly !== true),
228
+ ]),
229
+ })
230
+ }, [readOnly])
231
+
232
+ // ARIA lives on CodeMirror's own contenteditable (which already carries
233
+ // role="textbox"), not on the wrapper div.
234
+ useEffect(() => {
235
+ const attributes: Record<string, string> = {}
236
+ if (ariaInvalid) attributes['aria-invalid'] = 'true'
237
+ if (ariaDescribedBy) attributes['aria-describedby'] = ariaDescribedBy
238
+ viewRef.current?.dispatch({
239
+ effects: ariaCompartment.current.reconfigure(EditorView.contentAttributes.of(attributes)),
240
+ })
241
+ }, [ariaInvalid, ariaDescribedBy])
242
+
243
+ return <div ref={containerRef} id={id} className="byline-code-editor" />
244
+ }
245
+
246
+ export default CodeEditor
@@ -0,0 +1,84 @@
1
+ /**
2
+ * CodeField — CodeMirror-backed source-code input.
3
+ *
4
+ * Override handles:
5
+ * .byline-field-code — the wrapper div (also carries the
6
+ * --byline-code-* theme variables)
7
+ * .byline-field-code-label-row — the label + locale-badge row
8
+ * .byline-field-code-loading — the Suspense fallback textarea
9
+ * .byline-code-editor — the CodeMirror mount point
10
+ *
11
+ * Theming: the CodeMirror theme (see code-editor.tsx) is written entirely in
12
+ * terms of the --byline-code-* custom properties declared here, with dark
13
+ * values scoped under the admin's `.dark` root class — the editor follows
14
+ * theme flips live with zero JS. Consumers can re-skin the editor by
15
+ * overriding these variables on `.byline-field-code`.
16
+ */
17
+
18
+ .label-row,
19
+ :global(.byline-field-code-label-row) {
20
+ display: flex;
21
+ align-items: center;
22
+ margin-bottom: 0.25rem;
23
+ }
24
+
25
+ :global(.byline-field-code) {
26
+ /* Chrome */
27
+ --byline-code-bg: var(--canvas-50, #fafafa);
28
+ --byline-code-fg: var(--canvas-950, #1c1c1e);
29
+ --byline-code-border: var(--canvas-300, #d6d6d6);
30
+ --byline-code-gutter-bg: var(--canvas-100, #f2f2f2);
31
+ --byline-code-gutter-fg: var(--canvas-500, #8b8b90);
32
+ --byline-code-active-line: rgba(0, 0, 0, 0.04);
33
+ --byline-code-selection: rgba(59, 130, 246, 0.18);
34
+ --byline-code-caret: var(--canvas-950, #1c1c1e);
35
+ --byline-code-focus-ring: rgba(59, 130, 246, 0.6);
36
+
37
+ /* Syntax */
38
+ --byline-code-keyword: #7c3aed;
39
+ --byline-code-string: #15803d;
40
+ --byline-code-comment: #767680;
41
+ --byline-code-number: #c2410c;
42
+ --byline-code-function: #1d4ed8;
43
+ --byline-code-type: #0f766e;
44
+ --byline-code-property: #a16207;
45
+ --byline-code-operator: #52525b;
46
+ }
47
+
48
+ :is([data-theme="dark"], :global(.dark)) {
49
+ :global(.byline-field-code) {
50
+ /* Chrome */
51
+ --byline-code-bg: var(--canvas-900, #1b1b1f);
52
+ --byline-code-fg: var(--canvas-100, #e6e6ea);
53
+ --byline-code-border: var(--canvas-700, #3f3f46);
54
+ --byline-code-gutter-bg: var(--canvas-800, #232327);
55
+ --byline-code-gutter-fg: var(--canvas-500, #77777f);
56
+ --byline-code-active-line: rgba(255, 255, 255, 0.05);
57
+ --byline-code-selection: rgba(96, 165, 250, 0.28);
58
+ --byline-code-caret: var(--canvas-100, #e6e6ea);
59
+ --byline-code-focus-ring: rgba(96, 165, 250, 0.55);
60
+
61
+ /* Syntax */
62
+ --byline-code-keyword: #c4b5fd;
63
+ --byline-code-string: #86efac;
64
+ --byline-code-comment: #8e8e96;
65
+ --byline-code-number: #fdba74;
66
+ --byline-code-function: #93c5fd;
67
+ --byline-code-type: #5eead4;
68
+ --byline-code-property: #fde047;
69
+ --byline-code-operator: #a1a1aa;
70
+ }
71
+ }
72
+
73
+ .loading,
74
+ :global(.byline-field-code-loading) {
75
+ width: 100%;
76
+ resize: none;
77
+ font-family: var(--byline-code-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
78
+ font-size: var(--byline-code-font-size, 13px);
79
+ color: var(--byline-code-fg);
80
+ background-color: var(--byline-code-bg);
81
+ border: 1px solid var(--byline-code-border);
82
+ border-radius: var(--byline-code-radius, 4px);
83
+ padding: 0.5rem;
84
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import React, { Suspense, useCallback } from 'react'
10
+
11
+ import type { Field, FieldComponentSlots, CodeField as FieldType } from '@byline/core'
12
+ import { ErrorText, HelpText, Label } from '@byline/ui/react'
13
+ import cx from 'classnames'
14
+
15
+ import { useFieldError, useFieldValue } from '../../forms/form-context'
16
+ import { LocaleBadge } from '../locale-badge'
17
+ import styles from './code-field.module.css'
18
+
19
+ // The CodeMirror half of the widget. `React.lazy` is the bundle-splitting
20
+ // boundary: `code-editor.tsx` owns every CodeMirror import, so the editor
21
+ // (and its per-language grammars) stays out of the main admin chunk and is
22
+ // fetched the first time a code field actually renders.
23
+ const CodeEditor = React.lazy(() => import('./code-editor'))
24
+
25
+ /**
26
+ * Resolve the form-store path of a sibling field (same group/block/array
27
+ * item scope). `content[0].code` + `language` → `content[0].language`;
28
+ * a top-level `code` + `language` → `language`.
29
+ */
30
+ const siblingFieldPath = (fieldPath: string, siblingName: string): string => {
31
+ const lastDot = fieldPath.lastIndexOf('.')
32
+ return lastDot === -1 ? siblingName : `${fieldPath.slice(0, lastDot + 1)}${siblingName}`
33
+ }
34
+
35
+ export const CodeField = ({
36
+ field,
37
+ value,
38
+ defaultValue,
39
+ onChange,
40
+ id,
41
+ path,
42
+ locale,
43
+ components,
44
+ }: {
45
+ field: FieldType
46
+ value?: string
47
+ defaultValue?: string
48
+ onChange?: (value: string) => void
49
+ id?: string
50
+ path?: string
51
+ /** When provided, renders a LocaleBadge next to the field label. */
52
+ locale?: string
53
+ /** Optional UI component slot overrides from the admin config. */
54
+ components?: FieldComponentSlots
55
+ }) => {
56
+ const fieldPath = path ?? field.name
57
+ const fieldError = useFieldError(fieldPath)
58
+ const fieldValue = useFieldValue<string | undefined>(fieldPath)
59
+ const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
60
+ const htmlId = id ?? fieldPath
61
+
62
+ // Effective highlight language: a sibling `languageField` selection (e.g.
63
+ // a `select` next to the code field) wins over the schema's static
64
+ // `language` hint. The hook subscribes to the sibling path, so switching
65
+ // the select re-highlights live. When no `languageField` is declared we
66
+ // subscribe to our own path — a stable no-op (hooks must run
67
+ // unconditionally).
68
+ const siblingPath = field.languageField
69
+ ? siblingFieldPath(fieldPath, field.languageField)
70
+ : fieldPath
71
+ const siblingLanguage = useFieldValue<string | undefined>(siblingPath)
72
+ const effectiveLanguage = (field.languageField ? siblingLanguage : undefined) || field.language
73
+
74
+ const handleChange = useCallback(
75
+ (value: string) => {
76
+ if (onChange) {
77
+ onChange(value)
78
+ }
79
+ },
80
+ [onChange]
81
+ )
82
+
83
+ // Custom component slots (from admin config)
84
+ const slots = components
85
+ const CustomLabel = slots?.Label
86
+ const CustomHelpText = slots?.HelpText
87
+ const CustomField = slots?.Field
88
+ const BeforeField = slots?.beforeField
89
+ const AfterField = slots?.afterField
90
+
91
+ // Shared props available to every slot component
92
+ const slotBaseProps = {
93
+ field: field as Field,
94
+ path: fieldPath,
95
+ value: incomingValue,
96
+ error: fieldError,
97
+ id: htmlId,
98
+ }
99
+
100
+ const showBadge = !!locale && !!field.label
101
+ const hasCustomLabel = !!CustomLabel
102
+
103
+ const labelRowClass = cx('byline-field-code-label-row', styles['label-row'])
104
+
105
+ // ── Label rendering ──────────────────────────────────────────
106
+ const renderLabel = () => {
107
+ if (hasCustomLabel) {
108
+ return (
109
+ <div className={labelRowClass}>
110
+ <CustomLabel {...slotBaseProps} label={field.label} required={!field.optional} />
111
+ {showBadge && <LocaleBadge locale={locale!} />}
112
+ </div>
113
+ )
114
+ }
115
+ if (field.label) {
116
+ return (
117
+ <div className={labelRowClass}>
118
+ <Label
119
+ id={`${htmlId}-label`}
120
+ htmlFor={htmlId}
121
+ label={field.label}
122
+ required={!field.optional}
123
+ />
124
+ {showBadge && <LocaleBadge locale={locale!} />}
125
+ </div>
126
+ )
127
+ }
128
+ return null
129
+ }
130
+
131
+ // ── Field input rendering ────────────────────────────────────
132
+ const renderInput = () => {
133
+ if (CustomField) {
134
+ return (
135
+ <CustomField
136
+ {...slotBaseProps}
137
+ onChange={handleChange}
138
+ defaultValue={defaultValue}
139
+ placeholder={field.placeholder}
140
+ />
141
+ )
142
+ }
143
+ return (
144
+ <Suspense
145
+ fallback={
146
+ // Read-only monospace textarea: keeps the layout stable and the
147
+ // content visible while the editor chunk loads.
148
+ <textarea
149
+ className={cx('byline-field-code-loading', styles.loading)}
150
+ readOnly
151
+ value={incomingValue}
152
+ rows={6}
153
+ aria-label={field.label}
154
+ />
155
+ }
156
+ >
157
+ <CodeEditor
158
+ id={htmlId}
159
+ value={incomingValue}
160
+ language={effectiveLanguage}
161
+ onChange={handleChange}
162
+ readOnly={field.readOnly === true}
163
+ ariaInvalid={fieldError != null}
164
+ ariaDescribedBy={
165
+ fieldError != null
166
+ ? `error-for-${htmlId}`
167
+ : field.helpText
168
+ ? `help-for-${htmlId}`
169
+ : undefined
170
+ }
171
+ />
172
+ </Suspense>
173
+ )
174
+ }
175
+
176
+ return (
177
+ <div className={`byline-field-code ${field.name}`}>
178
+ {renderLabel()}
179
+ {BeforeField && <BeforeField {...slotBaseProps} />}
180
+ {renderInput()}
181
+ {AfterField && <AfterField {...slotBaseProps} />}
182
+ {fieldError != null && <ErrorText id={`error-for-${htmlId}`} text={fieldError} />}
183
+ {CustomHelpText ? (
184
+ <CustomHelpText {...slotBaseProps} helpText={field.helpText} />
185
+ ) : (
186
+ fieldError == null &&
187
+ field.helpText && <HelpText id={`help-for-${htmlId}`} text={field.helpText} />
188
+ )}
189
+ </div>
190
+ )
191
+ }
@@ -37,7 +37,15 @@ export function DraggableContextMenu({
37
37
 
38
38
  return (
39
39
  <DropdownMenu.Root modal={false}>
40
- <DropdownMenu.Trigger render={<IconButton variant="text" size="sm" />}>
40
+ <DropdownMenu.Trigger
41
+ render={
42
+ <IconButton
43
+ variant="text"
44
+ size="sm"
45
+ aria-label={t('fields.draggableMenu.triggerAriaLabel')}
46
+ />
47
+ }
48
+ >
41
49
  <EllipsisIcon width="16px" height="16px" />
42
50
  </DropdownMenu.Trigger>
43
51
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { sliceFieldAdmin } from './field-admin.js'
12
+
13
+ describe('sliceFieldAdmin', () => {
14
+ const answer = { editor: (() => null) as any }
15
+ const question = { components: {} }
16
+
17
+ it('returns undefined for an undefined map', () => {
18
+ expect(sliceFieldAdmin(undefined, 'faq')).toBeUndefined()
19
+ })
20
+
21
+ it('returns undefined when no entry addresses a descendant of the child', () => {
22
+ expect(sliceFieldAdmin({ faq: question, other: question }, 'faq')).toBeUndefined()
23
+ })
24
+
25
+ it('strips the child prefix from descendant keys', () => {
26
+ expect(sliceFieldAdmin({ 'faq.answer': answer, 'faq.question': question }, 'faq')).toEqual({
27
+ answer,
28
+ question,
29
+ })
30
+ })
31
+
32
+ it('keeps deeper paths dotted for the next level to slice', () => {
33
+ const map = { 'files.filesGroup.publicationFile': question }
34
+ const level1 = sliceFieldAdmin(map, 'files')
35
+ expect(level1).toEqual({ 'filesGroup.publicationFile': question })
36
+ expect(sliceFieldAdmin(level1, 'filesGroup')).toEqual({ publicationFile: question })
37
+ })
38
+
39
+ it('does not match on name prefixes that are not path segments', () => {
40
+ // 'faqExtra.answer' must not be sliced by child 'faq'.
41
+ expect(sliceFieldAdmin({ 'faqExtra.answer': answer }, 'faq')).toBeUndefined()
42
+ })
43
+
44
+ it('excludes the child’s own exact-name entry from the slice', () => {
45
+ expect(sliceFieldAdmin({ faq: question, 'faq.answer': answer }, 'faq')).toEqual({
46
+ answer,
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,39 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { FieldAdminConfig } from '@byline/core'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Field admin map slicing — `fields{}` override maps (CollectionAdminConfig /
13
+ // BlockAdminConfig) are keyed by dotted, index-free schema paths relative to
14
+ // the map's root ('title', 'faq.answer'). Structural widgets thread the map
15
+ // down one level at a time: a child field takes its own entry by exact name,
16
+ // and receives the descendant entries re-keyed relative to itself.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Entries of `map` addressing descendants of `childName`, re-keyed with the
21
+ * `childName.` prefix stripped — the sub-map a structural child (group /
22
+ * array) threads to its own children. Returns `undefined` when `map` has no
23
+ * descendant entries for the child, so leaf widgets aren't handed empty maps.
24
+ */
25
+ export function sliceFieldAdmin(
26
+ map: Record<string, FieldAdminConfig> | undefined,
27
+ childName: string
28
+ ): Record<string, FieldAdminConfig> | undefined {
29
+ if (map == null) return undefined
30
+ const prefix = `${childName}.`
31
+ let sliced: Record<string, FieldAdminConfig> | undefined
32
+ for (const [key, value] of Object.entries(map)) {
33
+ if (key.startsWith(prefix)) {
34
+ sliced ??= {}
35
+ sliced[key.slice(prefix.length)] = value
36
+ }
37
+ }
38
+ return sliced
39
+ }
@@ -30,6 +30,7 @@ export const placeholderForField = (f: Field): any => {
30
30
  switch (f.type) {
31
31
  case 'text':
32
32
  case 'textArea':
33
+ case 'code':
33
34
  return ''
34
35
  case 'checkbox':
35
36
  return false