From f2b10d05865f45e47644bca2940e537fcdf02deb Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Fri, 12 Jun 2026 15:30:20 +0100 Subject: [PATCH] REFACTOR: Extract inline_list_editor ui-component --- .../frontend/src/model/object_list_editor.css | 29 --- .../frontend/src/model/object_list_editor.tsx | 182 ++------------ packages/ui-components/src/index.ts | 1 + .../src/inline_list_editor.module.css | 30 +++ .../src/inline_list_editor.stories.tsx | 130 ++++++++++ .../ui-components/src/inline_list_editor.tsx | 234 ++++++++++++++++++ 6 files changed, 415 insertions(+), 191 deletions(-) delete mode 100644 packages/frontend/src/model/object_list_editor.css create mode 100644 packages/ui-components/src/inline_list_editor.module.css create mode 100644 packages/ui-components/src/inline_list_editor.stories.tsx create mode 100644 packages/ui-components/src/inline_list_editor.tsx diff --git a/packages/frontend/src/model/object_list_editor.css b/packages/frontend/src/model/object_list_editor.css deleted file mode 100644 index f03ded083..000000000 --- a/packages/frontend/src/model/object_list_editor.css +++ /dev/null @@ -1,29 +0,0 @@ -.object-list { - display: flex; - flex-direction: row; - align-items: center; - list-style: none; - padding: 0; - - li { - display: flex; - flex-direction: row; - } - - .default-delimiter, - .default-separator { - color: var(--color-gray-800); - } - .default-delimiter { - transform: scale(1, 1.5); - } - - .empty-list-input { - background: transparent; - border: none; - outline: none; - width: 0.5ex; - margin: 0; - padding: 0; - } -} diff --git a/packages/frontend/src/model/object_list_editor.tsx b/packages/frontend/src/model/object_list_editor.tsx index a074aeb43..81224e7a5 100644 --- a/packages/frontend/src/model/object_list_editor.tsx +++ b/packages/frontend/src/model/object_list_editor.tsx @@ -1,16 +1,7 @@ -import { - batch, - createEffect, - Index, - type JSX, - mergeProps, - Show, - untrack, - useContext, -} from "solid-js"; +import { createEffect, type JSX, splitProps, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { type FocusHandle, type TextInputOptions, useChildFocus } from "catcolab-ui-components"; +import { InlineListEditor, type TextInputOptions } from "catcolab-ui-components"; import type { Ob, QualifiedName } from "catlog-wasm"; import { ObIdInput } from "../components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; @@ -18,8 +9,6 @@ import { LiveModelContext } from "./context"; import { buildObList, extractObList } from "./ob_operations"; import type { ObInputProps } from "./object_input"; -import "./object_list_editor.css"; - type ObListEditorProps = ObInputProps & TextInputOptions & { insertKey?: string; @@ -29,35 +18,12 @@ type ObListEditorProps = ObInputProps & }; /** Edits a list of objects of given type. */ -export function ObListEditor(originalProps: ObListEditorProps) { - const props = mergeProps( - { - insertKey: ",", - startDelimiter:
{"["}
, - endDelimiter:
{"]"}
, - separator: () =>
{","}
, - }, - originalProps, - ); +export function ObListEditor(allProps: ObListEditorProps) { + const [props, listProps] = splitProps(allProps, ["ob", "setOb", "obType", "placeholder"]); const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const parentFocus: FocusHandle = { - hasFocus: () => props.focus?.hasFocus() ?? !!props.isActive, - setFocused: (focused) => { - if (props.focus) { - props.focus.setFocused(focused); - } else if (focused) { - props.hasFocused?.(); - } - }, - }; - const focus = useChildFocus(parentFocus, { default: 0 }); - - // Track which indices have non-empty text (including incomplete input). - const inputTexts = new Map(); - const modeAppType = () => { if (props.obType.tag !== "ModeApp") { throw new Error(`Object type should be a list modality, received: ${props.obType}`); @@ -68,22 +34,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { const obList = (): Array => extractObList(props.ob); const setObList = (objects: Array) => { - props.setOb(buildObList(modeAppType().content.modality, objects)); - }; - - const updateObList = (f: (objects: Array) => void) => { - const objects = removeProxyAndCopy(obList()); - f(objects); - setObList(objects); - }; - - const insertNewOb = (i: number) => { - batch(() => { - updateObList((objects) => { - objects.splice(i, 0, null); - }); - focus.setActiveChild(i); - }); + props.setOb(buildObList(modeAppType().content.modality, removeProxyAndCopy(objects))); }; const completions = (): QualifiedName[] | undefined => @@ -96,114 +47,21 @@ export function ObListEditor(originalProps: ObListEditorProps) { } }); - // Insert into new object into empty list when focus is gained. - createEffect(() => { - if (parentFocus.hasFocus() && untrack(obList).length === 0) { - insertNewOb(0); - } - }); - - /** Clean up null placeholders that have no user-entered text. */ - const deactivate = () => { - const objects = obList().filter((ob, i) => ob !== null || (inputTexts.get(i) ?? "") !== ""); - if (objects.length !== obList().length) { - setObList(objects); - } - }; - - // Clean up when the component becomes inactive. - createEffect(() => { - if (!parentFocus.hasFocus()) { - untrack(() => deactivate()); - } - }); - return ( -
    { - if (obList().length === 0) { - insertNewOb(0); - parentFocus.setFocused(true); - evt.preventDefault(); - } - }} - > - {props.startDelimiter} - }> - {(ob, i) => ( -
  • - 0 && props.separator}>{(sep) => sep()(i)} - { - updateObList((objects) => { - objects[i] = ob; - }); - }} - onTextChange={(text) => inputTexts.set(i, text)} - placeholder={props.placeholder} - idToLabel={(id) => liveModel().elaboratedModel()?.obGeneratorLabel(id)} - labelToId={(label) => - liveModel().elaboratedModel()?.obGeneratorWithLabel(label) - } - completions={completions()} - focus={focus.childFocus(i)} - deleteBackward={() => - batch(() => { - updateObList((objects) => { - objects.splice(i, 1); - }); - if (i === 0) { - props.deleteBackward?.(); - } else { - focus.setActiveChild(i - 1); - } - }) - } - deleteForward={() => { - batch(() => { - updateObList((objects) => { - objects.splice(i, 1); - }); - if (i === 0) { - props.deleteForward?.(); - } - }); - }} - exitBackward={() => props.exitBackward?.()} - exitForward={() => props.exitForward?.()} - exitLeft={() => { - if (i === 0) { - props.exitLeft?.(); - } else { - focus.setActiveChild(i - 1); - } - }} - exitRight={() => { - if (i === obList().length - 1) { - props.exitRight?.(); - } else { - focus.setActiveChild(i + 1); - } - }} - interceptKeyDown={(evt) => { - if (evt.key === props.insertKey) { - insertNewOb(i + 1); - return true; - } else if (evt.key === "Home" && !evt.shiftKey) { - // TODO: Should move to beginning of input. - focus.setActiveChild(0); - } else if (evt.key === "End" && !evt.shiftKey) { - focus.setActiveChild(obList().length - 1); - } - return false; - }} - /> -
  • - )} -
    - {props.endDelimiter} -
+ + {(ob, setOb, options) => ( + liveModel().elaboratedModel()?.obGeneratorLabel(id)} + labelToId={(label) => + liveModel().elaboratedModel()?.obGeneratorWithLabel(label) + } + completions={completions()} + {...options} + /> + )} + ); } diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index cbff9ec8a..fe1609ddb 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -14,6 +14,7 @@ export * from "./form"; export * from "./history_navigator"; export * from "./icon_button"; export * from "./inline_input"; +export * from "./inline_list_editor"; export * from "./input_options"; export * from "./katex_display"; export * from "./model_file_icon"; diff --git a/packages/ui-components/src/inline_list_editor.module.css b/packages/ui-components/src/inline_list_editor.module.css new file mode 100644 index 000000000..fae302910 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.module.css @@ -0,0 +1,30 @@ +.inlineList { + display: flex; + flex-direction: row; + align-items: center; + list-style: none; + padding: 0; + + li { + display: flex; + flex-direction: row; + } +} + +.defaultDelimiter, +.defaultSeparator { + color: var(--color-gray-800); +} + +.defaultDelimiter { + transform: scale(1, 1.5); +} + +.emptyListInput { + background: transparent; + border: none; + outline: none; + width: 0.5ex; + margin: 0; + padding: 0; +} diff --git a/packages/ui-components/src/inline_list_editor.stories.tsx b/packages/ui-components/src/inline_list_editor.stories.tsx new file mode 100644 index 000000000..053c1fa73 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.stories.tsx @@ -0,0 +1,130 @@ +import { createSignal, splitProps } from "solid-js"; +import type { Meta, StoryObj } from "storybook-solidjs-vite"; + +import { InlineInput } from "./inline_input"; +import { InlineListEditor, type InlineListItemOptions } from "./inline_list_editor"; +import { rootFocus, useChildFocus } from "./util/focus"; + +const meta = { + title: "Forms & Inputs/InlineListEditor", + component: InlineListEditor, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Item input for a list of plain strings. */ +function StringItemInput( + allProps: { + item: string | null; + setItem: (item: string | null) => void; + } & InlineListItemOptions, +) { + const [props, inputOptions] = splitProps(allProps, ["item", "setItem", "onTextChange"]); + + const setText = (text: string) => { + props.onTextChange(text); + props.setItem(text === "" ? null : text); + }; + + return ( + + ); +} + +export const Summary: Story = { + render: () => { + const [items, setItems] = createSignal>(["alice", "bob"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
+ + {(item, setItem, options) => ( + + )} + +
+ ); + }, + tags: ["!autodocs", "!dev"], +}; + +export const Basic: Story = { + render: () => { + const [items, setItems] = createSignal>(["alice", "bob"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
+

+ Press , to insert an item, Backspace/Delete in + an empty item to remove it, and arrow keys or Home/End to + navigate. Empty items are pruned when focus moves elsewhere. +

+ + {(item, setItem, options) => ( + + )} + +
+

Focus here to deactivate the list:

+ {}} + placeholder="Outside input" + focus={focus.childFocus("outside")} + /> +
+

+ Items: {JSON.stringify(items().map((item) => item ?? "·"))} +

+
+ ); + }, +}; + +export const CustomDelimiters: Story = { + render: () => { + const [items, setItems] = createSignal>(["x", "y", "z"]); + const focus = useChildFocus<"list" | "outside">(rootFocus); + + return ( +
+

+ Tuple-style notation with parentheses, semicolon separators, and ; as + the insert key: +

+ "; "} + > + {(item, setItem, options) => ( + + )} + +
+ {}} + placeholder="Outside input" + focus={focus.childFocus("outside")} + /> +
+
+ ); + }, +}; diff --git a/packages/ui-components/src/inline_list_editor.tsx b/packages/ui-components/src/inline_list_editor.tsx new file mode 100644 index 000000000..28683d906 --- /dev/null +++ b/packages/ui-components/src/inline_list_editor.tsx @@ -0,0 +1,234 @@ +import { + type Accessor, + batch, + createEffect, + Index, + type JSX, + mergeProps, + Show, + untrack, +} from "solid-js"; + +import type { TextInputOptions } from "./text_input"; +import { type FocusHandle, useChildFocus } from "./util/focus"; + +import styles from "./inline_list_editor.module.css"; + +/** Options passed to each item input rendered by an `InlineListEditor`. + +These options should be spread onto a `TextInput`-like input component +rendering the item. They wire the item input into the list's focus management +and keyboard navigation. + */ +export type InlineListItemOptions = Pick< + TextInputOptions, + | "focus" + | "deleteBackward" + | "deleteForward" + | "exitBackward" + | "exitForward" + | "exitLeft" + | "exitRight" + | "interceptKeyDown" +> & { + /** Called when the displayed text of the item input changes. + + Tracking the text allows the list editor to avoid pruning empty placeholder + items that have incomplete, user-entered text. + */ + onTextChange: (text: string) => void; +}; + +type InlineListEditorProps = TextInputOptions & { + /** Items in the list, where `null` is an empty placeholder item. */ + items: Array; + + /** Handler to set a new list of items. */ + setItems: (items: Array) => void; + + /** Renders the input for a single item in the list. + + The supplied options should be spread onto the item's input component. + */ + children: ( + item: Accessor, + setItem: (item: T | null) => void, + options: InlineListItemOptions, + index: number, + ) => JSX.Element; + + /** Key that inserts a new item after the current one. Defaults to `","`. */ + insertKey?: string; + + /** Element displayed before the first item. */ + startDelimiter?: JSX.Element | string; + + /** Element displayed after the last item. */ + endDelimiter?: JSX.Element | string; + + /** Element displayed between consecutive items. */ + separator?: (index: number) => JSX.Element | string; +}; + +/** Edits an inline list of items. + +Items are rendered horizontally, surrounded by delimiters and punctuated by +separators, like elements of a list or tuple in mathematical notation. The +rendering of each item is delegated to the `children` render prop. + +The component manages focus across the item inputs and provides editing +actions: inserting an item with the insert key, deleting items with focus +repair, navigating with arrow keys and `Home`/`End`, and pruning empty +placeholder items when focus is lost. + */ +export function InlineListEditor(originalProps: InlineListEditorProps) { + const props = mergeProps( + { + insertKey: ",", + startDelimiter:
{"["}
, + endDelimiter:
{"]"}
, + separator: () =>
{","}
, + }, + originalProps, + ); + + const parentFocus: FocusHandle = { + hasFocus: () => props.focus?.hasFocus() ?? !!props.isActive, + setFocused: (focused) => { + if (props.focus) { + props.focus.setFocused(focused); + } else if (focused) { + props.hasFocused?.(); + } + }, + }; + const focus = useChildFocus(parentFocus, { default: 0 }); + + // Track which indices have non-empty text (including incomplete input). + const inputTexts = new Map(); + + const updateItems = (f: (items: Array) => void) => { + const items = [...props.items]; + f(items); + props.setItems(items); + }; + + const insertNewItem = (i: number) => { + batch(() => { + updateItems((items) => { + items.splice(i, 0, null); + }); + focus.setActiveChild(i); + }); + }; + + // Insert a new item into an empty list when focus is gained. + createEffect(() => { + if (parentFocus.hasFocus() && untrack(() => props.items).length === 0) { + insertNewItem(0); + } + }); + + /** Clean up null placeholders that have no user-entered text. */ + const deactivate = () => { + const items = props.items.filter( + (item, i) => item !== null || (inputTexts.get(i) ?? "") !== "", + ); + if (items.length !== props.items.length) { + props.setItems(items); + } + }; + + // Clean up when the component becomes inactive. + createEffect(() => { + if (!parentFocus.hasFocus()) { + untrack(() => deactivate()); + } + }); + + const itemOptions = (i: number): InlineListItemOptions => ({ + onTextChange: (text) => inputTexts.set(i, text), + focus: focus.childFocus(i), + deleteBackward: () => + batch(() => { + updateItems((items) => { + items.splice(i, 1); + }); + if (i === 0) { + props.deleteBackward?.(); + } else { + focus.setActiveChild(i - 1); + } + }), + deleteForward: () => + batch(() => { + updateItems((items) => { + items.splice(i, 1); + }); + if (i === 0) { + props.deleteForward?.(); + } + }), + exitBackward: () => props.exitBackward?.(), + exitForward: () => props.exitForward?.(), + exitLeft: () => { + if (i === 0) { + props.exitLeft?.(); + } else { + focus.setActiveChild(i - 1); + } + }, + exitRight: () => { + if (i === props.items.length - 1) { + props.exitRight?.(); + } else { + focus.setActiveChild(i + 1); + } + }, + interceptKeyDown: (evt) => { + if (evt.key === props.insertKey) { + insertNewItem(i + 1); + return true; + } else if (evt.key === "Home" && !evt.shiftKey) { + // TODO: Should move to beginning of input. + focus.setActiveChild(0); + } else if (evt.key === "End" && !evt.shiftKey) { + focus.setActiveChild(props.items.length - 1); + } + return false; + }, + }); + + return ( +
    { + if (props.items.length === 0) { + insertNewItem(0); + parentFocus.setFocused(true); + evt.preventDefault(); + } + }} + > + {props.startDelimiter} + }> + {(item, i) => ( +
  • + 0 && props.separator}>{(sep) => sep()(i)} + {props.children( + item, + (newItem) => { + updateItems((items) => { + items[i] = newItem; + }); + }, + itemOptions(i), + i, + )} +
  • + )} +
    + {props.endDelimiter} +
+ ); +}