Repository: kitconcept/volto-light-theme
Pull request #491: Updated objectListWidget using JSONField as an array
Author: login: sneridagh
Created: 2025-03-13T12:03:19Z | Merged: 2025-03-14T10:07:24Z



<!-- readthedocs-preview volto-light-theme start -->
----
📚 Documentation preview 📚: https://volto-light-theme--491.org.readthedocs.build/

<!-- readthedocs-preview volto-light-theme end -->


Diff:
diff --git a/docs/reference/helpers.md b/docs/reference/helpers.md
index 02586c69b..7cbe5c32e 100644
--- a/docs/reference/helpers.md
+++ b/docs/reference/helpers.md
@@ -13,5 +13,58 @@ This chapter lists the helper utilities in Volto Light Theme.
 
 ## `DndSortableList`
 
-The successor of Volto's `DragDropList`, using `dnd-kit` library.
+`DndSortableList is the successor of Volto's `DragDropList`, using the `dnd-kit` library.
 
+Given an array of objects as `items`, and a `children` render prop function, you can drag and drop the items to reorder them.
+
+It has the following props.
+
+```ts
+interface DndSortableListProps {
+  /**
+   * An object containing all items, ideally with a `@id` key with the item's
+   * unique identifier (UID).
+   *
+   * If none provided, the item index in the array is used.
+   *
+   */
+  items: Array<Record<string, any>>;
+
+  /**
+   * A function to handle the end of a drag operation.
+   * @param {DragEndEvent} event - The drag end event.
+   */
+  handleDragEnd: (event: DragEndEvent) => void;
+
+  /**
+   * The currently active object being dragged.
+   */
+  activeObject: any;
+
+  /**
+   * A function to set the active object.
+   * @param {any} object - The object to set as active.
+   */
+  setActiveObject: (object: any) => void;
+
+  /**
+   * A render prop function to render each sortable item.
+   * @param {object} params - The parameters for rendering the item.
+   * @param {string} params.uid - The unique identifier of the item.
+   * @param {any} params.item - The item data.
+   * @param {number} params.index - The index of the item in the sorted list.
+   * @param {any} params.activeObject - The currently active object being dragged.
+   * @param {object} params.attributes - The attributes for the sortable item.
+   * @param {object} params.listeners - The listeners for the sortable item.
+   * @returns {React.ReactNode} - The rendered sortable item.
+   */
+  children: (params: {
+    uid: string;
+    item: any;
+    index: number;
+    activeObject: any;
+    attributes: any;
+    listeners: any;
+  }) => React.ReactNode;
+}
+```
diff --git a/docs/reference/widgets.md b/docs/reference/widgets.md
index 50ea6996e..d3c8a9e45 100644
--- a/docs/reference/widgets.md
+++ b/docs/reference/widgets.md
@@ -39,9 +39,10 @@ It uses the {file}`/src/components/widgets/ColorPicker` component.
 :alt: colorPicker
 ```
 
-## `blocksObject`
+## `object_list` (Volto widget override)
 
-The `blocksObject` widget is similar in functionality to Volto's `ObjectListWidget`, as configured in `config.widgets.widget.object_list`.
+The `object_list` widget overrides the Volto `object_list` widget.
+The widget is a drop-in replacement in functionality to Volto's `ObjectListWidget`.
 It allows to introduce a list of ordered objects with the same shape.
 It allows to reorder them using drag and drop.
 It uses `@plone/components`.
diff --git a/packages/volto-light-theme/news/486.feature.1 b/packages/volto-light-theme/news/486.feature.1
deleted file mode 100644
index 12f20edcf..000000000
--- a/packages/volto-light-theme/news/486.feature.1
+++ /dev/null
@@ -1 +0,0 @@
-New widget: `blocksObject` Allows to save a list of objects with the same shape, and reorder them using drag and drop. @sneridagh
diff --git a/packages/volto-light-theme/news/491.feature b/packages/volto-light-theme/news/491.feature
new file mode 100644
index 000000000..8547700a5
--- /dev/null
+++ b/packages/volto-light-theme/news/491.feature
@@ -0,0 +1 @@
+New widget: `object_list`. @sneridagh
diff --git a/packages/volto-light-theme/src/components/Widgets/BlocksObject.tsx b/packages/volto-light-theme/src/components/Widgets/BlocksObject.tsx
index 8b2c6a4b1..1affe43f4 100644
--- a/packages/volto-light-theme/src/components/Widgets/BlocksObject.tsx
+++ b/packages/volto-light-theme/src/components/Widgets/BlocksObject.tsx
@@ -251,6 +251,7 @@ const BlocksObjectWidget = (props: BlocksObjectWidgetProps) => {
         )}
       </FormFieldWrapper>
       <DndSortableList
+        // TODO: adapt it to the new DndSortableList shape
         sortedItems={value?.blocks_layout?.items || []}
         items={value?.blocks}
         handleDragEnd={handleDragEnd}
diff --git a/packages/volto-light-theme/src/components/Widgets/ObjectList.tsx b/packages/volto-light-theme/src/components/Widgets/ObjectList.tsx
new file mode 100644
index 000000000..640635161
--- /dev/null
+++ b/packages/volto-light-theme/src/components/Widgets/ObjectList.tsx
@@ -0,0 +1,333 @@
+import { useState } from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import omit from 'lodash/omit';
+import { Button } from '@plone/components';
+import { Text } from 'react-aria-components';
+import Icon from '@plone/volto/components/theme/Icon/Icon';
+import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
+import { applySchemaDefaults } from '@plone/volto/helpers/Blocks/Blocks';
+import { reorderArray } from '@plone/volto/helpers/Utils/Utils';
+import AnimateHeight from 'react-animate-height';
+import ObjectWidget from '@plone/volto/components/manage/Widgets/ObjectWidget';
+import {
+  getBlocksFieldname,
+  getBlocksLayoutFieldname,
+} from '@plone/volto/helpers/Blocks/Blocks';
+import DndSortableList from '../../helpers/DndSortableList';
+import cx from 'classnames';
+import upSVG from '@plone/volto/icons/up-key.svg';
+import downSVG from '@plone/volto/icons/down-key.svg';
+import deleteSVG from '@plone/volto/icons/delete.svg';
+import addSVG from '@plone/volto/icons/add.svg';
+import dragSVG from '@plone/volto/icons/drag.svg';
+import { v4 as uuid } from 'uuid';
+import type { BlockConfigBase, Content, JSONSchema } from '@plone/types';
+import type { IntlShape } from 'react-intl';
+import config from '@plone/volto/registry';
+import isEmpty from 'lodash/isEmpty';
+
+const messages = defineMessages({
+  labelRemoveItem: {
+    id: 'Remove item',
+    defaultMessage: 'Remove item',
+  },
+  labelCollapseItem: {
+    id: 'Collapse item',
+    defaultMessage: 'Collapse item',
+  },
+  labelShowItem: {
+    id: 'Show item',
+    defaultMessage: 'Show item',
+  },
+  emptyObjectList: {
+    id: 'Empty object list',
+    defaultMessage: 'Empty object list',
+  },
+  add: {
+    id: 'Add (object list)',
+    defaultMessage: 'Add',
+  },
+});
+
+export type ObjectListWidgetSchema =
+  | (JSONSchema & { addMessage: string })
+  | ((props: ObjectListWidgetProps) => JSONSchema & { addMessage: string });
+
+export type ObjectListWidgetProps = {
+  /**
+   * The ID of the widget.
+   */
+  id: string;
+  /**
+   * The ID of the block this widget belongs to.
+   */
+  block: string;
+  /**
+   * The fieldset this widget belongs to.
+   */
+  fieldSet: string;
+  /**
+   * The title of the widget.
+   */
+  title: string;
+  /**
+   * The current value of the widget, which is BlocksData.
+   */
+  value?: Array<Record<string, any>>;
+  /**
+   * The default value for the widget. Can be a string or an object.
+   */
+  default?: string | object;
+  /**
+   * Whether the widget is required.
+   */
+  required?: boolean;
+  /**
+   * The value to use when the widget is missing a value.
+   */
+  missing_value?: unknown;
+  /**
+   * The CSS class name for the widget.
+   */
+  className?: string;
+  /**
+   * A callback function that is called when the value of the widget changes.
+   * @param id The ID of the widget.
+   * @param value The new value of the widget.
+   */
+  onChange: (id: string, value: any) => void;
+  /**
+   * The index of the currently active object.
+   */
+  activeObject: number;
+  /**
+   * A callback function that is called to set the active object.
+   * @param index The index of the object to set as active.
+   */
+  setActiveObject: (index: number) => void;
+  /**
+   * The schema for the ObjectListWidget.
+   */
+  schema: ObjectListWidgetSchema;
+  /**
+   * The name of the schema.
+   */
+  schemaName: string;
+  /**
+   * An optional function to enhance the schema.
+   * @param args An object containing the schema, form data, intl, navRoot, and contentType.
+   */
+  schemaEnhancer?: (args: {
+    schema: JSONSchema & { addMessage: string };
+    formData: BlockConfigBase;
+    intl: IntlShape;
+    navRoot: Content;
+    contentType: string;
+  }) => JSONSchema;
+};
+
+function deleteBlock(formData, blockId: string) {
+  const blocksFieldname = getBlocksFieldname(formData);
+  const blocksLayoutFieldname = getBlocksLayoutFieldname(formData);
+
+  let newFormData = {
+    ...formData,
+    [blocksLayoutFieldname]: {
+      items: formData[blocksLayoutFieldname].items.filter(
+        (value) => value !== blockId,
+      ),
+    },
+    [blocksFieldname]: omit(formData[blocksFieldname], [blockId]),
+  };
+
+  return newFormData;
+}
+
+const ObjectListWidget = (props: ObjectListWidgetProps) => {
+  const { block, fieldSet, id, value, onChange, schemaEnhancer, schemaName } =
+    props;
+
+  const schema =
+    config.getUtility({
+      type: 'schema',
+      name: schemaName,
+    }).method || props.schema;
+
+  const [localActiveObject, setLocalActiveObject] = useState(
+    props.activeObject ?? value.length - 1,
+  );
+
+  let activeObject: number, setActiveObject: (index: number) => void;
+  if (
+    (props.activeObject || props.activeObject === 0) &&
+    props.setActiveObject
+  ) {
+    activeObject = props.activeObject;
+    setActiveObject = props.setActiveObject;
+  } else {
+    activeObject = localActiveObject;
+    setActiveObject = setLocalActiveObject;
+  }
+
+  const intl = useIntl();
+
+  function handleChangeActiveObject(index) {
+    const newIndex = activeObject === index ? -1 : index;
+
+    setActiveObject(newIndex);
+  }
+
+  const objectSchema = typeof schema === 'function' ? schema(props) : schema;
+
+  function handleDragEnd(event) {
+    const { active, over } = event;
+
+    if (active.id !== over.id) {
+      const source = value.findIndex((item) => item['@id'] === active.id);
+      const destination = value.findIndex((item) => item['@id'] === over.id);
+
+      const newValue = reorderArray(value, source, destination);
+      onChange(id, newValue);
+    }
+  }
+
+  return (
+    <div className="object-list-widget">
+      <FormFieldWrapper {...props} noForInFieldLabel className="objectlist">
+        <div className="add-item-button-wrapper">
+          <Button
+            aria-label={
+              objectSchema.addMessage ||
+              `${intl.formatMessage(messages.add)} ${objectSchema.title}`
+            }
+            onPress={() => {
+              const data = {
+                '@id': uuid(),
+              };
+
+              const objSchema = schemaEnhancer
+                ? // @ts-ignore - TODO Make sure this continues to have sense
+                  schemaEnhancer({ schema: objectSchema, formData: data, intl })
+                : objectSchema;
+              const dataWithDefaults = applySchemaDefaults({
+                data,
+                schema: objSchema,
+                intl,
+              });
+
+              onChange(id, [...value, dataWithDefaults]);
+              setActiveObject(value.length);
+            }}
+          >
+            <Icon name={addSVG} size="18px" />
+            {/* Custom addMessage in schema, else default to English */}
+            <Text>
+              {objectSchema.addMessage ||
+                `${intl.formatMessage(messages.add)} ${objectSchema.title}`}
+            </Text>
+          </Button>
+        </div>
+        {value?.length === 0 && (
+          <input
+            aria-labelledby={`fieldset-${
+              fieldSet || 'default'
+            }-field-label-${id}`}
+            type="hidden"
+            value={intl.formatMessage(messages.emptyObjectList)}
+          />
+        )}
+      </FormFieldWrapper>
+      {!isEmpty(value) && (
+        <DndSortableList
+          items={value || []}
+          handleDragEnd={handleDragEnd}
+          activeObject={activeObject}
+          setActiveObject={setActiveObject}
+        >
+          {({ item, uid, index, attributes, listeners }) => {
+            return (
+              <div
+                className={cx('olw-item-wrapper', {
+                  active: activeObject === index,
+                })}
+                key={uid}
+              >
+                <div
+                  className="olw-item-title-bar"
+                  onClick={() => handleChangeActiveObject(index)}
+                  role="presentation"
+                  aria-label={`${
+                    activeObject === index
+                      ? intl.formatMessage(messages.labelCollapseItem)
+                      : intl.formatMessage(messages.labelShowItem)
+                  } #${index ? index + 1 : ''}`}
+                >
+                  <div className="drag handle" {...listeners} {...attributes}>
+                    <Icon name={dragSVG} size="18px" />
+                  </div>
+
+                  <div className="olw-item-title">
+                    {item.title ||
+                      `${objectSchema.title} #${index !== undefined ? index + 1 : ''}`}
+                  </div>
+                  <div className="olw-tools">
+                    <button
+                      aria-label={`${intl.formatMessage(
+                        messages.labelRemoveItem,
+                      )} #${index + 1}`}
+                      onClick={() => {
+                        onChange(id, deleteBlock(value, uid));
+                      }}
+                    >
+                      <Icon name={deleteSVG} size="20px" color="#e40166" />
+                    </button>
+                    {activeObject === index ? (
+                      <Icon name={upSVG} size="20px" />
+                    ) : (
+                      <Icon name={downSVG} size="20px" />
+                    )}
+                  </div>
+                </div>
+                <AnimateHeight
+                  animateOpacity
+                  duration={300}
+                  height={activeObject === index ? 'auto' : 0}
+                >
+                  <div
+                    className={cx('olw-item-content', {
+                      active: activeObject === index,
+                    })}
+                  >
+                    <ObjectWidget
+                      id={`${uid}`}
+                      key={`olw-${uid}`}
+                      block={block}
+                      schema={
+                        schemaEnhancer
+                          ? // @ts-ignore - TODO Make sure this continues to have sense
+                            schemaEnhancer({
+                              schema: objectSchema,
+                              formData: item,
+                              intl,
+                            })
+                          : objectSchema
+                      }
+                      value={item}
+                      onChange={(fieldId: string, fieldValue: any) => {
+                        const newvalue = value.map((v, i) =>
+                          i !== index ? v : fieldValue,
+                        );
+                        onChange(id, newvalue);
+                      }}
+                    />
+                  </div>
+                </AnimateHeight>
+              </div>
+            );
+          }}
+        </DndSortableList>
+      )}
+    </div>
+  );
+};
+export default ObjectListWidget;
diff --git a/packages/volto-light-theme/src/config/widgets.ts b/packages/volto-light-theme/src/config/widgets.ts
index 25ebfedbc..1ba5694aa 100644
--- a/packages/volto-light-theme/src/config/widgets.ts
+++ b/packages/volto-light-theme/src/config/widgets.ts
@@ -6,6 +6,7 @@ import Size from '../components/Widgets/Size';
 import ColorPicker from '../components/Widgets/ColorPicker';
 import ThemeColorSwatch from '../components/Widgets/ThemeColorSwatch';
 import BlocksObject from '../components/Widgets/BlocksObject';
+import ObjectList from '../components/Widgets/ObjectList';
 import { headerActionsSchema } from '../components/Widgets/schema/headerActionsSchema';
 import { footerLogosSchema } from '../components/Widgets/schema/footerLogosSchema';
 import { footerLinksSchema } from '../components/Widgets/schema/footerLinksSchema';
@@ -28,9 +29,12 @@ export default function install(config: ConfigType) {
   // ToDo: Rename it in Volto 19
   config.widgets.widget.color_picker = ColorSwatch;
 
+  // ObjectList widget override - use our own non-semanticUI widget
+  // it uses also dnd-kit for drag and drop
+  config.widgets.widget.object_list = ObjectList;
+
   config.widgets.widget.blockWidth = BlockWidth;
   config.widgets.widget.blockAlignment = BlockAlignment;
-  config.widgets.widget.blocksObject = BlocksObject;
   config.widgets.widget.colorPicker = ColorPicker;
   config.widgets.widget.size = Size;
   config.widgets.widget.themeColorSwatch = ThemeColorSwatch;
diff --git a/packages/volto-light-theme/src/helpers/DndSortableList.tsx b/packages/volto-light-theme/src/helpers/DndSortableList.tsx
index 1e9d9c8e8..ef2fdffa1 100644
--- a/packages/volto-light-theme/src/helpers/DndSortableList.tsx
+++ b/packages/volto-light-theme/src/helpers/DndSortableList.tsx
@@ -37,14 +37,13 @@ const SortableItem = (props) => {
 
 interface DndSortableListProps {
   /**
-   * An object containing all items, where the key is the item's unique identifier (UID).
+   * An object containing all items, ideally with a `@id` key with the item's
+   * unique identifier (UID).
+   *
+   * If none provided, the item index in the array is used.
+   *
    */
-  items: { [uid: string]: any };
-
-  /**
-   * An array of UIDs representing the sorted order of the items.
-   */
-  sortedItems: string[];
+  items: Array<Record<string, any>>;
 
   /**
    * A function to handle the end of a drag operation.
@@ -84,15 +83,16 @@ interface DndSortableListProps {
   }) => React.ReactNode;
 }
 
+const arrayToObject = (arr: Record<string, any>[]): Record<string, any> => {
+  return arr.reduce((acc, item) => {
+    acc[item['@id']] = item;
+    return acc;
+  }, {});
+};
+
 const DndSortableList = (props: DndSortableListProps) => {
-  const {
-    items,
-    sortedItems,
-    children,
-    handleDragEnd,
-    activeObject,
-    setActiveObject,
-  } = props;
+  const { items, children, handleDragEnd, activeObject, setActiveObject } =
+    props;
 
   const sensors = useSensors(
     useSensor(PointerSensor),
@@ -105,6 +105,9 @@ const DndSortableList = (props: DndSortableListProps) => {
     setActiveObject(null);
   }
 
+  const itemIds = items ? items.map((item, index) => item['@id'] || index) : [];
+  const itemsByUid = items ? arrayToObject(items) : {};
+
   return (
     <DndContext
       sensors={sensors}
@@ -112,12 +115,9 @@ const DndSortableList = (props: DndSortableListProps) => {
       onDragStart={handleDragStart}
       onDragEnd={handleDragEnd}
     >
-      <SortableContext
-        items={sortedItems}
-        strategy={verticalListSortingStrategy}
-      >
-        {sortedItems.map((uid, index) => {
-          const item = items[uid];
+      <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
+        {itemIds.map((uid, index) => {
+          const item = itemsByUid[uid];
           return (
             <SortableItem
               key={uid}
diff --git a/packages/volto-light-theme/src/theme/_widgets.scss b/packages/volto-light-theme/src/theme/_widgets.scss
index e37a25935..0d8f796f9 100644
--- a/packages/volto-light-theme/src/theme/_widgets.scss
+++ b/packages/volto-light-theme/src/theme/_widgets.scss
@@ -120,7 +120,7 @@
   }
 }
 
-.blocks-object-widget {
+.object-list-widget {
   .field.inline {
     .add-item-button-wrapper {
       display: flex;
@@ -137,14 +137,14 @@
     }
   }
 
-  .bow-item-wrapper {
+  .olw-item-wrapper {
     padding: 0 10px;
     border: 1px solid var(--quanta-smoke);
     border-radius: 10px;
     margin-bottom: 10px;
   }
 
-  .bow-item-title-bar {
+  .olw-item-title-bar {
     display: flex;
     justify-content: space-between;
     padding: 10px 0;
@@ -156,7 +156,7 @@
     }
   }
 
-  .bow-tools {
+  .olw-tools {
     display: flex;
     align-items: center;
     button {


Reviews:
user: login: stevepiercy
body: Just docs review.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T12:27:06Z
user: login: davisagli
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T23:55:53Z
user: login: sneridagh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:27:12Z
user: login: sneridagh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:28:07Z

Review comments:
diff_hunk: @@ -15,3 +15,56 @@ This chapter lists the helper utilities in Volto Light Theme.
 
 The successor of Volto's `DragDropList`, using `dnd-kit` library.
path: docs/reference/helpers.md
user: login: stevepiercy
body: ```suggestion
`DndSortableList is the successor of Volto's `DragDropList`, using the `dnd-kit` library.
```
created_at: 2025-03-13T12:25:02Z
author_association: COLLABORATOR
diff_hunk: @@ -15,3 +15,56 @@ This chapter lists the helper utilities in Volto Light Theme.
 
 The successor of Volto's `DragDropList`, using `dnd-kit` library.
 
+Given a `items` array of objects, and a children render prop function, it allows you to reorder them using drag and drop.
path: docs/reference/helpers.md
user: login: stevepiercy
body: ```suggestion
Given an array of objects as `items`, and a `children` render prop function, you can drag and drop the items to reorder them.
```
created_at: 2025-03-13T12:25:54Z
author_association: COLLABORATOR
diff_hunk: @@ -15,3 +15,56 @@ This chapter lists the helper utilities in Volto Light Theme.
 
 The successor of Volto's `DragDropList`, using `dnd-kit` library.
 
+Given a `items` array of objects, and a children render prop function, it allows you to reorder them using drag and drop.
+
+It has the following props:
path: docs/reference/helpers.md
user: login: stevepiercy
body: ```suggestion
It has the following props.
```
created_at: 2025-03-13T12:26:02Z
author_association: COLLABORATOR
diff_hunk: @@ -105,6 +105,11 @@ const DndSortableList = (props: DndSortableListProps) => {
     setActiveObject(null);
   }
 
+  const sortedItems = items
+    ? items.map((item, index) => item['@id'] || index)
+    : [];
+  const list = items ? arrayToObject(items) : [];
path: packages/volto-light-theme/src/helpers/DndSortableList.tsx
user: login: davisagli
body: Can we call it itemsByUid instead of list? Calling something `list` when it's an `object` is very confusing to my Python-focused brain. Also it should default to `{}` rather than `[]` if there are no items. Doesn't TypeScript catch that sort of thing? (Probably it technically works because an Array is also an object, but geez...)
created_at: 2025-03-13T23:44:56Z
author_association: MEMBER
diff_hunk: @@ -105,6 +105,11 @@ const DndSortableList = (props: DndSortableListProps) => {
     setActiveObject(null);
   }
 
+  const sortedItems = items
path: packages/volto-light-theme/src/helpers/DndSortableList.tsx
user: login: davisagli
body: Sorted is the wrong name for this, because there's no sort(). It looks like it's an array of the item ids in order, so maybe call it `order` or `itemIds`?
created_at: 2025-03-13T23:54:55Z
author_association: MEMBER
diff_hunk: @@ -105,6 +105,11 @@ const DndSortableList = (props: DndSortableListProps) => {
     setActiveObject(null);
   }
 
+  const sortedItems = items
+    ? items.map((item, index) => item['@id'] || index)
+    : [];
+  const list = items ? arrayToObject(items) : [];
path: packages/volto-light-theme/src/helpers/DndSortableList.tsx
user: login: sneridagh
body: `arrayToObject` was not fully typed correctly, and the fact that is returning any[] didn't help.

I fixed it.
created_at: 2025-03-14T09:27:12Z
author_association: MEMBER
diff_hunk: @@ -105,6 +105,11 @@ const DndSortableList = (props: DndSortableListProps) => {
     setActiveObject(null);
   }
 
+  const sortedItems = items
path: packages/volto-light-theme/src/helpers/DndSortableList.tsx
user: login: sneridagh
body: Fixed.
created_at: 2025-03-14T09:28:07Z
author_association: MEMBER

PR comments:
user: login: sneridagh
created_at: 2025-03-14T09:30:16Z
body: @davisagli FYI, I tried what we talked about removing the `@id` on save, but I could not make it work. Not even with your workaround in DLR, I guess the fact that you did it "outside" the widget, then the own widget lifecycle does not get in the way. Problem that happened: every time you typed something, it looses the focus in the widget field...

If you want to take a look, go ahead. 
author_association: MEMBER
user: login: sneridagh
created_at: 2025-03-14T10:07:16Z
body: @davisagli merging for now too.
author_association: MEMBER
user: login: davisagli
created_at: 2025-03-14T18:52:28Z
body: @sneridagh I saw about the `@id`. It could also be a breaking change to remove it, so it's okay to keep it as long as it's not causing problems.
author_association: MEMBER
user: login: sneridagh
created_at: 2025-03-19T12:31:41Z
body: @davisagli yeah, I thought that also, this way it's a drop-in replacement, and works everywhere.
author_association: MEMBER