@tristan2828/ui-foundation 4.19.0 → 4.20.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.
@@ -856,6 +856,32 @@ view, so a link in the notes is edited there, not followed, and the
856
856
  editor loads on every visit. Use it where the notes are what the page is
857
857
  for; elsewhere `autosave` alone keeps the read view at rest.
858
858
 
859
+ **Edited where it's read (`inline: true`, implies `alwaysEditable`)**,
860
+ Notion-style, for rich text: the editor from the start, drawn as the read
861
+ view rather than as a field (no border, no box, the text where the
862
+ section's text would be), so there's no mode change at all: click into
863
+ the words and type. Its control gets `inline: true` among its props;
864
+ pass it to `RichTextEditor`'s `inline`, with `toolbar="floating"`, which
865
+ keeps the toolbar over a selection (Alt+F10 reaches it) and the "/" menu
866
+ but drops the bar above the text. An empty one shows the editor's
867
+ `placeholder` (say "or type / for blocks" in it), not the view's "Not
868
+ set". It saves like any always-editable value (a pause after typing,
869
+ leaving the page saves first) and is otherwise the same: on the form the
870
+ control is the boxed editor with its fixed toolbar.
871
+
872
+ ```tsx
873
+ notes: editInPlace({
874
+ kind: 'long-text',
875
+ inline: true,
876
+ value: widget.notes,
877
+ schema: widgetFormSchema.shape.notes,
878
+ save: (notes) => editField.mutateAsync({ notes }),
879
+ // The form's own control, spreading what it gets onto RichTextEditor:
880
+ // inline={props.inline} toolbar={props.inline ? 'floating' : true}
881
+ control: (props) => <WidgetNotesEditor {...} />,
882
+ }),
883
+ ```
884
+
859
885
  ```tsx
860
886
  description: editInPlace({
861
887
  kind: 'long-text',
@@ -919,7 +945,9 @@ const price = editInPlace({
919
945
  beside it add a row or a column, and the toolbar's Table menu inserts
920
946
  rows and columns, aligns a column and deletes.
921
947
  `toolbar={false}` leaves out the toolbars and the slash menu where a
922
- value is short and the shortcuts are enough.
948
+ value is short and the shortcuts are enough; `toolbar="floating"` keeps
949
+ the floating one and the slash menu without the bar above (an `inline`
950
+ field's look).
923
951
  - **The save is the single-field save** (`useRecordUpdate` with
924
952
  `optimistic: false, toastOnError: false`, the entity's
925
953
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -24,6 +24,11 @@ export type EditControlProps<TDraft> = {
24
24
  describedBy: string;
25
25
  /** For a control that can't always produce a value to save (RichTextEditor's `onProblem`). */
26
26
  onProblem: (message: string | null) => void;
27
+ /**
28
+ * The field is `inline`: always editable, drawn as the text it edits.
29
+ * Give it to RichTextEditor's `inline` (with `toolbar="floating"`).
30
+ */
31
+ inline?: boolean;
27
32
  };
28
33
  /**
29
34
  * How the value saves, which follows from its control:
@@ -60,6 +65,7 @@ export type EditInPlace = {
60
65
  isEqual?: (a: unknown, b: unknown) => boolean;
61
66
  autosave?: boolean;
62
67
  alwaysEditable?: boolean;
68
+ inline?: boolean;
63
69
  };
64
70
  export type EditInPlaceOptions<TDraft, TValue> = {
65
71
  kind: EditKind;
@@ -100,6 +106,15 @@ export type EditInPlaceOptions<TDraft, TValue> = {
100
106
  * in rich text is edited there, not followed.
101
107
  */
102
108
  alwaysEditable?: boolean;
109
+ /**
110
+ * For long text: always editable (implies `alwaysEditable`), and drawn
111
+ * as the read view, not as a field: no border, no box, typed into where
112
+ * it's read (Notion-style). The control gets `inline: true`; pass it to
113
+ * RichTextEditor's `inline`, with `toolbar="floating"` (the toolbar over
114
+ * a selection and the slash menu, no bar above the text). An empty one
115
+ * shows the editor's `placeholder`, not the view's "Not set".
116
+ */
117
+ inline?: boolean;
103
118
  };
104
119
  /** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
105
120
  export type EditListItemProps<TItem> = EditControlProps<TItem> & {
@@ -16,6 +16,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
16
16
  // Ctrl/Cmd+Enter and Esc save what's left now and close it.
17
17
  // - `alwaysEditable`: the control is shown at rest, with no pencil and no
18
18
  // click to open, and never closes. Ctrl/Cmd+Enter and Esc save now.
19
+ // - `inline`: always editable, and the control is told to look like the
20
+ // value it edits (`inline` among its props).
19
21
  import { CheckIcon, PencilIcon } from 'lucide-react';
20
22
  import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
21
23
  import { useBlocker } from 'react-router';
@@ -58,10 +60,13 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
58
60
  const key = useId();
59
61
  const controlId = `${key}-control`;
60
62
  const describedBy = `${key}-status`;
61
- // The latest config, read by the store whenever it needs it.
63
+ const always = Boolean(edit.alwaysEditable || edit.inline);
64
+ const autosave = always || Boolean(edit.autosave);
65
+ // The latest config, read by the store whenever it needs it (`inline`
66
+ // is always editable to it).
62
67
  const config = useRef(edit);
63
68
  useLayoutEffect(() => {
64
- config.current = edit;
69
+ config.current = always && !edit.alwaysEditable ? { ...edit, alwaysEditable: true } : edit;
65
70
  });
66
71
  useEffect(() => store.register(key, () => config.current), [store, key]);
67
72
  const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
@@ -72,8 +77,6 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
72
77
  // Set by a keyboard save or Esc: the caret goes back to the edit button.
73
78
  const refocus = useRef(false);
74
79
  const blurTimer = useRef(undefined);
75
- const always = Boolean(edit.alwaysEditable);
76
- const autosave = always || Boolean(edit.autosave);
77
80
  // An always-editable control starts over (it's uncontrolled: a rich-text
78
81
  // editor reads its value once) when the value changed elsewhere while
79
82
  // nothing of its own was unsaved and the caret wasn't in it.
@@ -209,7 +212,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
209
212
  const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
210
213
  const saving = open.status === 'saving';
211
214
  const autosaving = open.autosave === 'saving';
212
- return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
215
+ return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem), inline: Boolean(edit.inline) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
213
216
  !open.error && (_jsxs(_Fragment, { children: [_jsx(CheckIcon, { "aria-hidden": "true", className: "size-3.5" }), "Saved"] }))) })] })) : saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.error && _jsx(FieldError, { className: "type-caption", children: open.error })) })] }));
214
217
  }
215
218
  /**
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
@@ -33,11 +33,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
33
33
  // length as it nears `maxLength`.
34
34
  // - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
35
35
  // floating one over selected words (rich-text-floating-toolbar.tsx),
36
- // unless `toolbar={false}`. Alt+F10 reaches the floating one when it
37
- // shows, else the fixed one.
36
+ // unless `toolbar={false}`; `toolbar="floating"` leaves out the fixed
37
+ // one. Alt+F10 reaches the floating one when it shows, else the fixed one.
38
38
  // - A slash menu (rich-text-slash.ts, rich-text-slash-menu.tsx): "/" at a
39
39
  // line's start lists the blocks it can become, also unless
40
40
  // `toolbar={false}`.
41
+ // - `inline`: the field drawn as the text it edits (no border, no box),
42
+ // for notes that are always editable where they're read.
41
43
  import { useEffect, useId, useRef, useState } from 'react';
42
44
  import { createPortal } from 'react-dom';
43
45
  import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
@@ -238,7 +240,7 @@ function applyLink(view, target, href) {
238
240
  }
239
241
  view.dispatch(tr.scrollIntoView());
240
242
  }
241
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
243
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
242
244
  const rootRef = useRef(null);
243
245
  const editorRef = useRef(null);
244
246
  const [linkBox, setLinkBox] = useState(null);
@@ -273,6 +275,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
273
275
  useEffect(() => {
274
276
  latest.current = { onChange, onProblem, readOnly, placeholder };
275
277
  });
278
+ // The fixed bar above the text, and the menus over it (the floating
279
+ // toolbar and the slash menu), which `'floating'` keeps on their own.
280
+ const fixedToolbar = toolbar === true;
281
+ const menus = toolbar !== false;
276
282
  const ids = useId();
277
283
  const counterId = `${ids}-count`;
278
284
  // The editable element's id: the app's, or one of its own, so the
@@ -297,11 +303,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
297
303
  attributes['aria-invalid'] = 'true';
298
304
  if (placeholder)
299
305
  attributes['aria-placeholder'] = placeholder;
300
- if (toolbar)
306
+ if (menus)
301
307
  attributes['aria-keyshortcuts'] = 'Alt+F10';
302
308
  // The slash menu: open while a "/" typed at a line's start is followed
303
309
  // by something some block answers to, and the field has focus.
304
- const slash = toolbar && !readOnly && focused && editorState ? slashQuery(editorState) : null;
310
+ const slash = menus && !readOnly && focused && editorState ? slashQuery(editorState) : null;
305
311
  const slashMatches = slash ? slashItems(slash.query) : [];
306
312
  const slashOpen = slash !== null && slashMatches.length > 0;
307
313
  const slashMenuKey = slash ? `${slash.from}:${slash.query}` : '';
@@ -674,7 +680,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
674
680
  // The floating toolbar: words selected, focus in the field, the mouse up,
675
681
  // and nothing else open over the text.
676
682
  const selection = editorState?.selection;
677
- const floatingOpen = Boolean(toolbar &&
683
+ const floatingOpen = Boolean(menus &&
678
684
  !readOnly &&
679
685
  editorView &&
680
686
  focused &&
@@ -696,15 +702,19 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
696
702
  setLinkBox(null);
697
703
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
698
704
  };
699
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
705
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
706
+ // Inline, it's the text itself: the caret shows where it's
707
+ // edited, and a refusal's reason under it says what's wrong.
708
+ !inline &&
709
+ 'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
700
710
  // Only once the editor is made: until then the editable element,
701
711
  // and its id, aren't in the page (axe: aria-valid-attr-value).
702
- controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
712
+ controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
703
713
  // Read-only text is muted; the toolbar's buttons say so by being disabled.
704
714
  'has-[[aria-readonly=true]]:text-muted-foreground',
705
715
  // The same type roles <Markdown> uses, so text reads the same
706
716
  // being edited as being read.
707
- '[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
717
+ '[&_.ProseMirror]:flex [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', !inline && '[&_.ProseMirror]:min-h-20',
708
718
  // ProseMirror's own required style (prosemirror-view's CSS): spaces
709
719
  // as typed, so one at the end of a line isn't kept as &nbsp;.
710
720
  '[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary',
@@ -23,10 +23,19 @@ export type RichTextEditorProps = {
23
23
  * lists, link, …), each button with its shortcut, and one floating over
24
24
  * selected words (bold to link). Alt+F10 reaches them from the text.
25
25
  * With them, "/" at a line's start lists the blocks the line can become
26
- * (the slash menu). On by default; `false` leaves the text alone, its
27
- * shortcuts still working.
26
+ * (the slash menu). On by default; `'floating'` keeps the floating one
27
+ * and the slash menu but no bar above the text (Notion-style, with
28
+ * `inline`); `false` leaves the text alone, its shortcuts still working.
28
29
  */
29
- toolbar?: boolean;
30
+ toolbar?: boolean | 'floating';
31
+ /**
32
+ * Looks like the text it edits: no border, no box, no padding, so it
33
+ * sits where `<Markdown>` would and reads the same. For notes that are
34
+ * always editable in place (`editInPlace`'s `inline`, which the control
35
+ * gets as `props.inline`); give it `toolbar="floating"` and a
36
+ * `placeholder`, which is all an empty one shows.
37
+ */
38
+ inline?: boolean;
30
39
  className?: string;
31
40
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
32
41
  'aria-label'?: string;
@@ -21,5 +21,5 @@ import { cn } from 'cn';
21
21
  import { Skeleton } from '../ui/skeleton.js';
22
22
  const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
23
23
  export function RichTextEditor(props) {
24
- return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col rounded-lg border border-input', props.className), children: [props.toolbar !== false && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: "flex min-h-24 flex-col gap-2 px-2.5 py-2", children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
24
+ return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.19.0",
3
+ "version": "4.20.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {