@qoretechnologies/reqraft 0.10.62-pr.142.g62c577f → 0.10.62

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 (24) hide show
  1. package/dist/components/form/engine/CompactRow.js +1 -1
  2. package/dist/components/form/engine/CompactRow.js.map +1 -1
  3. package/dist/components/form/fields/rich-text/RichText.d.ts +22 -1
  4. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  5. package/dist/components/form/fields/rich-text/RichText.js +135 -50
  6. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  7. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +3 -1
  8. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  9. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +2 -5
  10. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  11. package/dist/components/form/fields/template/TemplateField.js +1 -1
  12. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  13. package/dist/helpers/choiceChip.d.ts +98 -0
  14. package/dist/helpers/choiceChip.d.ts.map +1 -0
  15. package/dist/helpers/choiceChip.js +181 -0
  16. package/dist/helpers/choiceChip.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/components/form/engine/CompactRow.tsx +1 -1
  19. package/src/components/form/engine/FormEngine.stories.tsx +195 -0
  20. package/src/components/form/fields/rich-text/RichText.stories.tsx +178 -1
  21. package/src/components/form/fields/rich-text/RichText.tsx +180 -45
  22. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -5
  23. package/src/components/form/fields/template/TemplateField.tsx +1 -1
  24. package/src/helpers/choiceChip.ts +227 -0
@@ -1,5 +1,5 @@
1
1
  import { StoryObj } from '@storybook/react-vite';
2
- import { expect, fn, within } from 'storybook/test';
2
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
3
  import { useState } from 'react';
4
4
 
5
5
  import { StoryMeta } from '../../../../types';
@@ -49,6 +49,183 @@ export const WithValue: Story = {
49
49
  },
50
50
  };
51
51
 
52
+ export const WithChoiceValue: Story = {
53
+ args: {
54
+ value: 'C07ERBFS0P6',
55
+ allowed_values: [
56
+ {
57
+ display_name: 'general',
58
+ short_desc: 'Company-wide announcements',
59
+ value: { type: 'richtext', value: 'C07ERBFS0P6' },
60
+ },
61
+ { display_name: 'random', value: { type: 'richtext', value: 'C07RANDOM01' } },
62
+ ] as never,
63
+ },
64
+ parameters: {
65
+ docs: {
66
+ description: {
67
+ story:
68
+ 'Renders the RichText field holding the value of one of its own choices — the Slack channel id C07ERBFS0P6 — drawn as one chip named "general"; the id itself is not shown, and stays the stored value.',
69
+ },
70
+ },
71
+ },
72
+ async play({ canvasElement }) {
73
+ const canvas = within(canvasElement);
74
+ await expect(canvas.getByText('general')).toBeInTheDocument();
75
+ await expect(canvas.queryByText('C07ERBFS0P6')).not.toBeInTheDocument();
76
+ },
77
+ };
78
+
79
+ export const WithChoiceValueAsDocument: Story = {
80
+ args: {
81
+ ...WithChoiceValue.args,
82
+ value: [{ type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] }] as never,
83
+ },
84
+ parameters: {
85
+ // Interaction-only: the play assertions are the guard, and the end state
86
+ // is already pictured by `WithChoiceValue`.
87
+ qlip: { skip: true },
88
+ docs: {
89
+ description: {
90
+ story:
91
+ 'Renders the RichText field holding a document that is nothing but the id of one of its choices — what an edit of the chip stores — drawn as the chip "general"; the id is not shown, and nothing is emitted for it, since the document already stands for the choice.',
92
+ },
93
+ },
94
+ },
95
+ async play({ args, canvasElement }) {
96
+ const canvas = within(canvasElement);
97
+ await expect(canvas.getByText('general')).toBeInTheDocument();
98
+ await expect(canvas.queryByText('C07ERBFS0P6')).not.toBeInTheDocument();
99
+ await new Promise((resolve) => setTimeout(resolve, 400));
100
+ await expect(args.onChange).not.toHaveBeenCalled();
101
+ },
102
+ };
103
+
104
+ export const RetypingTheChoiceReturnsItsChip: Story = {
105
+ args: WithChoiceValue.args,
106
+ parameters: {
107
+ // Interaction-only: the play assertions are the guard, and the end state
108
+ // is already pictured by `WithChoiceValue`.
109
+ qlip: { skip: true },
110
+ docs: {
111
+ description: {
112
+ story:
113
+ 'Renders the RichText field holding a choice drawn as the chip "general". A click opens it into C07ERBFS0P6 as text; Backspace emits C07ERBFS0P, and a typed 6 emits C07ERBFS0P6 again, each as a document and still shown as text while the author types; leaving the field with Tab draws it as the chip "general" once more and emits nothing further, since that document already stands for the choice.',
114
+ },
115
+ },
116
+ },
117
+ async play({ args, canvasElement }) {
118
+ const canvas = within(canvasElement);
119
+ const editor = canvas.getByRole('textbox');
120
+
121
+ await userEvent.click(canvas.getByText('general'));
122
+ await waitFor(() => {
123
+ expect(editor.querySelector('.reqore-tag')).toBeNull();
124
+ expect(editor.contains(document.activeElement)).toBe(true);
125
+ });
126
+
127
+ // One key at a time, each emitted before the next: typed within the
128
+ // field's debounce, the two would settle as the value the field started
129
+ // with, and nothing would be emitted at all.
130
+ await userEvent.keyboard('{Backspace}');
131
+ await waitFor(() =>
132
+ expect(args.onChange).toHaveBeenLastCalledWith([
133
+ { type: 'paragraph', children: [{ text: 'C07ERBFS0P' }] },
134
+ ])
135
+ );
136
+ await userEvent.keyboard('6');
137
+ await waitFor(() =>
138
+ expect(args.onChange).toHaveBeenLastCalledWith([
139
+ { type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] },
140
+ ])
141
+ );
142
+ await expect(editor.querySelector('.reqore-tag')).toBeNull();
143
+
144
+ await userEvent.tab();
145
+ await waitFor(() => {
146
+ expect(editor.querySelector('.reqore-tag')?.textContent).toContain('general');
147
+ expect(editor.textContent).not.toContain('C07ERBFS0P6');
148
+ });
149
+ // Leaving the field changed nothing: the chip stands for what was emitted.
150
+ await expect(args.onChange).toHaveBeenLastCalledWith([
151
+ { type: 'paragraph', children: [{ text: 'C07ERBFS0P6' }] },
152
+ ]);
153
+ },
154
+ };
155
+
156
+ export const ClickOpensTheChoiceChip: Story = {
157
+ args: WithChoiceValue.args,
158
+ parameters: {
159
+ // Interaction-only: the play assertions are the guard, and the end state
160
+ // is already pictured by a plain editor holding text.
161
+ qlip: { skip: true },
162
+ docs: {
163
+ description: {
164
+ story:
165
+ 'Renders the RichText field holding a choice drawn as the chip "general", with the default chip behaviour. A click on the chip opens it into its value as editable text: the editor shows C07ERBFS0P6 and no chip, the cursor at its end, so Backspace and a typed 7 make it C07ERBFS0P7, which is what the field emits.',
166
+ },
167
+ },
168
+ },
169
+ async play({ args, canvasElement }) {
170
+ const canvas = within(canvasElement);
171
+ const editor = canvas.getByRole('textbox');
172
+
173
+ await userEvent.click(canvas.getByText('general'));
174
+ await waitFor(() => {
175
+ expect(editor.querySelector('.reqore-tag')).toBeNull();
176
+ expect(editor.textContent).toBe('C07ERBFS0P6');
177
+ expect(editor.contains(document.activeElement)).toBe(true);
178
+ });
179
+
180
+ await userEvent.keyboard('{Backspace}7');
181
+ await waitFor(() => expect(editor.textContent).toBe('C07ERBFS0P7'));
182
+ await waitFor(() =>
183
+ expect(args.onChange).toHaveBeenLastCalledWith([
184
+ { type: 'paragraph', children: [{ text: 'C07ERBFS0P7' }] },
185
+ ])
186
+ );
187
+ },
188
+ };
189
+
190
+ export const BackspaceOpensTheChoiceChip: Story = {
191
+ args: {
192
+ ...WithChoiceValue.args,
193
+ choiceChip: { click: 'cursor' },
194
+ },
195
+ parameters: {
196
+ // Interaction-only: the play assertions are the guard, and the end state
197
+ // is already pictured by a plain editor holding text.
198
+ qlip: { skip: true },
199
+ docs: {
200
+ description: {
201
+ story:
202
+ 'Renders the RichText field holding a choice drawn as the chip "general", configured so that a click on the chip only places the cursor after it. Backspace then opens the chip into its value as editable text: the editor shows C07ERBFS0P6 and no chip, the cursor at its end, so one more Backspace and a typed 7 make it C07ERBFS0P7, which is what the field emits.',
203
+ },
204
+ },
205
+ },
206
+ async play({ args, canvasElement }) {
207
+ const canvas = within(canvasElement);
208
+ const editor = canvas.getByRole('textbox');
209
+
210
+ await userEvent.click(canvas.getByText('general'));
211
+ await waitFor(() => expect(editor.contains(document.activeElement)).toBe(true));
212
+
213
+ await userEvent.keyboard('{Backspace}');
214
+ await waitFor(() => {
215
+ expect(editor.querySelector('.reqore-tag')).toBeNull();
216
+ expect(editor.textContent).toBe('C07ERBFS0P6');
217
+ });
218
+
219
+ await userEvent.keyboard('{Backspace}7');
220
+ await waitFor(() => expect(editor.textContent).toBe('C07ERBFS0P7'));
221
+ await waitFor(() =>
222
+ expect(args.onChange).toHaveBeenLastCalledWith([
223
+ { type: 'paragraph', children: [{ text: 'C07ERBFS0P7' }] },
224
+ ])
225
+ );
226
+ },
227
+ };
228
+
52
229
  export const Empty: Story = {
53
230
  parameters: {
54
231
  docs: {
@@ -1,14 +1,29 @@
1
1
  import {
2
2
  IReqoreRichTextEditorProps,
3
3
  ReqoreRichTextEditor,
4
+ TReqoreRichTextEditorRef,
4
5
  } from '@qoretechnologies/reqore/dist/components/RichTextEditor';
5
6
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
6
7
  import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag';
7
8
  import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global';
9
+ import { IQorusAllowedValue } from '@qoretechnologies/ts-toolkit';
8
10
  import { isEqual, size } from 'lodash';
9
- import { KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
+ import { FocusEvent, KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
12
  import { useDebounce } from 'react-use';
13
+ import { Path } from 'slate';
14
+ import { ReactEditor } from 'slate-react';
11
15
  import { useEmittedValues } from '../emittedValues';
16
+ import {
17
+ choiceChipAtCursor,
18
+ choiceForValue,
19
+ choiceToNodes,
20
+ choiceValue,
21
+ choiceValueOfNodes,
22
+ DEFAULT_CHOICE_CHIP_BEHAVIOR,
23
+ IChoiceChipBehavior,
24
+ pathOfNode,
25
+ unwrapChoiceChip,
26
+ } from '../../../../helpers/choiceChip';
12
27
  import { flattenToSingleLine, hasLineBreak } from '../../../../helpers/singleLineString';
13
28
  import {
14
29
  renderTemplateItemDescriptions,
@@ -57,6 +72,25 @@ export interface IRichTextFormFieldProps extends Omit<
57
72
  valueFormat?: 'richtext' | 'text';
58
73
  /** With `valueFormat: 'text'`: the value holds one line, so Enter adds none. */
59
74
  singleLine?: boolean;
75
+ /**
76
+ * The choices the field offers, when it offers any. A value that IS one of
77
+ * them — the whole string, as picked from the field's suggestions, or a
78
+ * document of nothing but that string, as an edit of it stores — is drawn as
79
+ * one chip named from the choice (`general` rather than `C07ERBFS0P6`), and
80
+ * is still emitted as that value: the chip's own value is the choice's, and a
81
+ * document that is nothing but the chip reads back as it. A field spreads
82
+ * its schema here, so this is the schema's own key.
83
+ */
84
+ allowed_values?: IQorusAllowedValue[];
85
+ /**
86
+ * What a choice chip does when the author acts on it — Backspace right after
87
+ * it, a click on it. By default each opens the chip into its value as plain
88
+ * text, the cursor at the end, so one letter of what was chosen can be
89
+ * changed; see `IChoiceChipBehavior` for the alternatives. Text that is again
90
+ * exactly one of the choices becomes its chip when the author leaves the
91
+ * field — not while typing, where a choice can be the start of a longer value.
92
+ */
93
+ choiceChip?: IChoiceChipBehavior;
60
94
  /**
61
95
  * With `valueFormat: 'text'`: the token grammar the value's references are
62
96
  * WRITTEN in, when the host speaks one of its own.
@@ -111,18 +145,6 @@ export const getChipTooltipDescriptions = (
111
145
  return out;
112
146
  };
113
147
 
114
- /* The chip references a document holds, in order, as one comparable string. Two documents that stand for
115
- the same text but chip different references differ here; two that chip the same references do not — so a
116
- re-parse that recognises nothing new is not taken for a change (which would move the cursor). */
117
- const chipValuesOf = (nodes?: ReadonlyArray<{ children?: ReadonlyArray<Record<string, any>> }>): string =>
118
- JSON.stringify(
119
- (nodes ?? []).flatMap((paragraph) =>
120
- (paragraph?.children ?? [])
121
- .filter((node) => node?.type === 'tag')
122
- .map((node) => String(node?.value ?? ''))
123
- )
124
- );
125
-
126
148
  export const RichTextFormField = memo(({
127
149
  value,
128
150
  onChange,
@@ -131,12 +153,39 @@ export const RichTextFormField = memo(({
131
153
  valueFormat = 'richtext',
132
154
  singleLine,
133
155
  templateToken,
156
+ allowed_values,
157
+ choiceChip,
134
158
  ...rest
135
159
  }: IRichTextFormFieldProps) => {
136
160
  const isText = valueFormat === 'text';
137
- const [localValue, setLocalValue] = useState<any>(() =>
138
- isText ? templateTextToNodes(String(value ?? ''), templates, templateToken) : value
139
- );
161
+ /* The Slate editor behind Reqore's, for the two chip actions below: both
162
+ rewrite the document in place, which the editor's props cannot. */
163
+ const editorRef = useRef<TReqoreRichTextEditorRef>(null);
164
+ const chipBehavior = { ...DEFAULT_CHOICE_CHIP_BEHAVIOR, ...choiceChip };
165
+ /* The document the editor shows for a value: a choice by its chip, a text
166
+ with its references as chips, a document as it is. */
167
+ const toLocal = (next: unknown): any => {
168
+ if (isText) {
169
+ const text = String(next ?? '');
170
+ const choice = choiceForValue(text, allowed_values);
171
+ return choice ? choiceToNodes(choice) : templateTextToNodes(text, templates, templateToken);
172
+ }
173
+ const choice = choiceForValue(next, allowed_values);
174
+ return choice ? choiceToNodes(choice) : next;
175
+ };
176
+ const [localValue, setLocalValue] = useState<any>(() => toLocal(value));
177
+ /* What a value stands for: the choice's value when the value is that choice
178
+ by any spelling — the plain string, the chip, a document of just the text —
179
+ else the value itself. Values are compared by this, so opening a chip, or
180
+ typing its id back, is not a change to the value. */
181
+ const standsFor = (next: unknown): unknown => {
182
+ const chip = choiceValueOfNodes(next);
183
+ if (chip !== undefined) {
184
+ return chip;
185
+ }
186
+ const choice = choiceForValue(next, allowed_values);
187
+ return choice ? choiceValue(choice) : next;
188
+ };
140
189
  /* In text mode, the string the editor's document stands for. The value that
141
190
  comes back from the parent is usually that same string, and rebuilding the
142
191
  document from it would hand the editor a new tree on every keystroke —
@@ -155,31 +204,34 @@ export const RichTextFormField = memo(({
155
204
  const text = String(value ?? '');
156
205
  if (text !== lastTextRef.current) {
157
206
  lastTextRef.current = text;
158
- setLocalValue(templateTextToNodes(text, templates, templateToken));
207
+ setLocalValue(toLocal(text));
159
208
  }
160
209
  return;
161
210
  }
162
- if (JSON.stringify(value) !== JSON.stringify(localValue)) {
163
- setLocalValue(value);
211
+ // Compared as what each stands for: a lone choice chip IS the value it
212
+ // draws, so the value coming back is not a change to it.
213
+ if (!isEqual(standsFor(value), standsFor(localValue))) {
214
+ setLocalValue(toLocal(value));
164
215
  }
165
216
  }, [JSON.stringify(value)]);
166
217
 
167
- /* A reference in a string is drawn as a chip only once something can name it: the field's own grammar
168
- (`templateToken`), or — for a reference neither grammar matches, a DPQL `@create` say — the catalogue
169
- that NAMES it. Both can arrive AFTER the first render: the catalogue is fetched, and the effect above
170
- rebuilds the document only when the stored string changes. So a preset reference the catalogue had not
171
- yet named stayed raw text until the author re-picked it (qorus#646). Re-chip the string in hand when
172
- what recognises its references changes — text mode only, and only when a chip is actually gained or
173
- lost, so a document being edited is left exactly as it is while nothing new is recognised. */
218
+ /* The choices usually arrive after the value — they are fetched — so a
219
+ value already shown as text becomes its chip once they do. Only while the
220
+ editor still shows the stored value as it is: an edit in progress is
221
+ never redrawn from under the author. */
222
+ const choiceKey = JSON.stringify(
223
+ allowed_values?.map((choice) => [choice.value, choice.display_name]) ?? null
224
+ );
174
225
  useEffect(() => {
175
- if (!isText) {
226
+ if (!allowed_values?.length) {
176
227
  return;
177
228
  }
178
- const rebuilt = templateTextToNodes(lastTextRef.current, templates, templateToken);
179
- setLocalValue((current: any) =>
180
- chipValuesOf(current) === chipValuesOf(rebuilt) ? current : rebuilt
181
- );
182
- }, [JSON.stringify(templates), String(templateToken)]);
229
+ const shown = isText ? templateNodesToText(localValue) : (choiceValueOfNodes(localValue) ?? localValue);
230
+ const stored = isText ? String(value ?? '') : value;
231
+ if (isEqual(shown, stored) && choiceForValue(stored, allowed_values)) {
232
+ setLocalValue(toLocal(stored));
233
+ }
234
+ }, [choiceKey]);
183
235
 
184
236
  useDebounce(
185
237
  () => {
@@ -190,9 +242,12 @@ export const RichTextFormField = memo(({
190
242
  }
191
243
  return;
192
244
  }
193
- if (!isEqual(localValue, value)) {
194
- emitted.record(localValue);
195
- onChange?.(localValue);
245
+ // A document that is nothing but one choice chip is emitted as the
246
+ // choice's value — what was stored and what the server resolves.
247
+ const next = choiceValueOfNodes(localValue) ?? localValue;
248
+ if (!isEqual(standsFor(next), standsFor(value))) {
249
+ emitted.record(next);
250
+ onChange?.(next);
196
251
  }
197
252
  },
198
253
  100,
@@ -211,7 +266,7 @@ export const RichTextFormField = memo(({
211
266
  // stopped below. Flattened text is shown as the one line it now is.
212
267
  if (singleLine && hasLineBreak(text)) {
213
268
  lastTextRef.current = flattenToSingleLine(text);
214
- setLocalValue(templateTextToNodes(lastTextRef.current, templates, templateToken));
269
+ setLocalValue(toLocal(lastTextRef.current));
215
270
  return;
216
271
  }
217
272
  lastTextRef.current = text;
@@ -233,6 +288,43 @@ export const RichTextFormField = memo(({
233
288
  JSON.stringify(templates),
234
289
  ]);
235
290
 
291
+ /* Opens a choice chip into its value as text and puts the cursor there.
292
+ A click on the chip — a non-editable node — need not have given the
293
+ editor focus, so focus is asked of Slate, which places the DOM caret where
294
+ the selection is and focuses after; but only once the text the chip became
295
+ is rendered (the effect below), since Slate resolves the caret through the
296
+ rendered DOM. Asked any earlier it lost: a DOM `focus()` before the
297
+ transform let the browser put the caret at the editor's start and the
298
+ `selectionchange` it raised beat the render, so the next key typed landed
299
+ there; asked on the next frame, the frame came before React's commit
300
+ often enough. An editor that already has focus (Backspace came from it)
301
+ is returned at once, and its own render puts the caret where the
302
+ selection is. */
303
+ const focusOnceRendered = useRef(false);
304
+ const openChoiceChip = useCallback((path: Path | undefined): void => {
305
+ const editor = editorRef.current;
306
+
307
+ if (editor && path && unwrapChoiceChip(editor, path)) {
308
+ focusOnceRendered.current = true;
309
+ }
310
+ }, []);
311
+
312
+ useEffect(() => {
313
+ const editor = editorRef.current;
314
+
315
+ if (!focusOnceRendered.current || !editor) {
316
+ return;
317
+ }
318
+ focusOnceRendered.current = false;
319
+
320
+ try {
321
+ ReactEditor.toDOMNode(editor, editor);
322
+ ReactEditor.focus(editor);
323
+ } catch {
324
+ // Not mounted in a document — nothing to focus.
325
+ }
326
+ }, [localValue]);
327
+
236
328
  const handleGetTagProps = useCallback(
237
329
  (tag: any): IReqoreTagProps => {
238
330
  const tagValue = tag.value?.toString();
@@ -241,6 +333,21 @@ export const RichTextFormField = memo(({
241
333
  return {};
242
334
  }
243
335
 
336
+ /* One of the field's own choices, drawn by the name it was picked by;
337
+ what it stands for on hover. */
338
+ if (tag.metadata?.allowedValue) {
339
+ return {
340
+ icon: 'ListCheck',
341
+ label: tag.label ?? tagValue,
342
+ tooltip: tag.metadata?.description,
343
+ /* The editor hands the document's own node here, so the chip is
344
+ found by identity; a chip already gone is a click on nothing. */
345
+ ...(chipBehavior.click === 'unwrap' ?
346
+ { onClick: () => openChoiceChip(editorRef.current ? pathOfNode(editorRef.current, tag) : undefined) }
347
+ : {}),
348
+ };
349
+ }
350
+
244
351
  /* A reference in a string is drawn exactly as the collapsed row draws the
245
352
  same value (`ReadOnlyTemplateTag`): named from the catalogue, its
246
353
  description on hover. Read at render, so a catalogue that arrives
@@ -253,10 +360,6 @@ export const RichTextFormField = memo(({
253
360
  leftIconProps: { image: metadata?.image },
254
361
  label: templateChipLabel(templates, tagValue),
255
362
  tooltip: templateTooltip(templates, tagValue),
256
- // The same chip the read summary (`TemplateText`) and the read-only tag draw: a `tiny` box whose
257
- // label takes the font of the text it sits in, so a reference is the same size in every view.
258
- size: 'tiny',
259
- style: { fontSize: 'inherit' },
260
363
  ...getTemplateTagStyle(metadata),
261
364
  };
262
365
  }
@@ -296,16 +399,29 @@ export const RichTextFormField = memo(({
296
399
  }
297
400
  : descriptionByValue[tagValue]
298
401
  : undefined,
299
- // The same chip the read summary and the read-only tag draw — see the `isText` branch above.
300
- size: 'tiny',
301
- style: { fontSize: 'inherit' },
302
402
  ...getTemplateTagStyle(tag.metadata),
303
403
  };
304
404
  }
305
405
 
306
406
  return {};
307
407
  },
308
- [descriptionByValue, renderMarkdown, isText, templates]
408
+ [descriptionByValue, renderMarkdown, isText, templates, chipBehavior.click, openChoiceChip]
409
+ );
410
+
411
+ /* Leaving the field: text that is exactly one of the choices becomes its
412
+ chip again, as the picker would have drawn it. Only here, never while
413
+ typing — a choice can be the start of a longer value, and a chip drawn
414
+ under the cursor would swallow it. */
415
+ const handleBlur = useCallback(
416
+ (event: FocusEvent<HTMLTextAreaElement>): void => {
417
+ const shown = isText ? lastTextRef.current : localValue;
418
+
419
+ if (choiceForValue(shown, allowed_values)) {
420
+ setLocalValue(toLocal(shown));
421
+ }
422
+ (rest as { onBlur?: (event: FocusEvent<HTMLTextAreaElement>) => void }).onBlur?.(event);
423
+ },
424
+ [isText, localValue, choiceKey, (rest as { onBlur?: unknown }).onBlur]
309
425
  );
310
426
 
311
427
  const handleKeyDown = useCallback(
@@ -313,9 +429,26 @@ export const RichTextFormField = memo(({
313
429
  if (isText && singleLine && event.key === 'Enter') {
314
430
  event.preventDefault();
315
431
  }
432
+ /* Backspace right after a choice chip opens it rather than deleting it,
433
+ when so configured. Stopped here, at keydown, so the browser raises no
434
+ `beforeinput` for the editor to delete the chip by. */
435
+ if (event.key === 'Backspace' && chipBehavior.backspace === 'unwrap' && editorRef.current) {
436
+ const path = choiceChipAtCursor(editorRef.current);
437
+
438
+ if (path) {
439
+ event.preventDefault();
440
+ openChoiceChip(path);
441
+ }
442
+ }
316
443
  (rest as { onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void }).onKeyDown?.(event);
317
444
  },
318
- [isText, singleLine, (rest as { onKeyDown?: unknown }).onKeyDown]
445
+ [
446
+ isText,
447
+ singleLine,
448
+ chipBehavior.backspace,
449
+ openChoiceChip,
450
+ (rest as { onKeyDown?: unknown }).onKeyDown,
451
+ ]
319
452
  );
320
453
 
321
454
  const tags = useMemo<IReqoreRichTextEditorProps['tags']>((): IReqoreRichTextEditorProps['tags'] => {
@@ -366,6 +499,7 @@ export const RichTextFormField = memo(({
366
499
 
367
500
  return (
368
501
  <ReqoreRichTextEditor
502
+ ref={editorRef}
369
503
  value={formattedValue}
370
504
  onChange={handleChange}
371
505
  /* `rest` is spread BEFORE everything this component computes. It used to
@@ -391,6 +525,7 @@ export const RichTextFormField = memo(({
391
525
  panelProps={{ fluid: true, style: { minWidth: '150px', ...rest.panelProps?.style } }}
392
526
  readOnly={readOnly}
393
527
  onKeyDown={handleKeyDown}
528
+ onBlur={handleBlur}
394
529
  /* A form field carries no document toolbar. Styling was already off, so
395
530
  the bar held only undo and redo — drawn by Reqore as a panel as wide as
396
531
  the tag list, which under a field reads as an empty 600px menu with two
@@ -1,5 +1,6 @@
1
1
  import { ReqoreTag, ReqoreTagGroup } from '@qoretechnologies/reqore';
2
2
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
3
+ import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
3
4
  import { memo } from 'react';
4
5
  import {
5
6
  getTemplateTagStyle,
@@ -13,6 +14,7 @@ export interface IReadOnlyTemplateTagProps {
13
14
  value: string;
14
15
  /** Template catalogue, for resolving the display name + image + metadata. */
15
16
  templates?: IReqoreFormTemplates;
17
+ size?: TSizes;
16
18
  }
17
19
 
18
20
  /**
@@ -25,19 +27,16 @@ export interface IReadOnlyTemplateTagProps {
25
27
  * it's shown non-editably.
26
28
  */
27
29
  export const ReadOnlyTemplateTag = memo(
28
- ({ value, templates }: IReadOnlyTemplateTagProps) => {
30
+ ({ value, templates, size }: IReadOnlyTemplateTagProps) => {
29
31
  const { label, item } = describeTemplateReference(templates, value);
30
32
  const metadata = item?.metadata as TTemplateMeta | undefined;
31
33
  return (
32
- // The same chip the read summary (`TemplateText`) and the expression editor draw: a `tiny` box whose
33
- // label takes the font of the text it sits in, so a reference is the same size in every view.
34
- <ReqoreTagGroup size='tiny'>
34
+ <ReqoreTagGroup size={size}>
35
35
  <ReqoreTag
36
36
  icon='ExchangeDollarLine'
37
37
  leftIconProps={{ image: metadata?.image }}
38
38
  label={label}
39
39
  tooltip={templateTooltip(templates, value)}
40
- style={{ fontSize: 'inherit' }}
41
40
  {...getTemplateTagStyle(metadata)}
42
41
  />
43
42
  </ReqoreTagGroup>
@@ -1595,7 +1595,7 @@ const TemplateFieldImpl = memo(
1595
1595
  // here; reqraft upgrades the read-only template to a proper picker chip —
1596
1596
  // the $-dollar icon + resolved display name + app image, coloured by the
1597
1597
  // IDE's intent scheme. Shared with the compact read-first row.
1598
- return <ReadOnlyTemplateTag value={templateValue} templates={templates} />;
1598
+ return <ReadOnlyTemplateTag value={templateValue} templates={templates} size={rest.size} />;
1599
1599
  }
1600
1600
 
1601
1601
  return (