umbraco-sorter

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Umbraco Sorter

Umbraco 排序器

What is it?

什么是UmbSorterController?

The UmbSorterController provides drag-and-drop sorting functionality for lists of items in the Umbraco backoffice. It handles reordering items within a container, moving items between containers, and supports nested sorting scenarios. This is useful for block editors, content trees, and any UI that requires user-driven ordering.
UmbSorterController为Umbraco后台中的列表项提供拖拽排序功能。它支持在容器内重新排序项、在容器间移动项,还能处理嵌套排序场景。这一功能在块编辑器、内容树以及任何需要用户自定义排序的UI中都十分实用。

Documentation

文档参考

Reference Examples

参考示例

The Umbraco source includes working examples:
Nested Containers:
/Umbraco-CMS/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/
This example demonstrates nested sorting with items that can contain child items.
Two Containers:
/Umbraco-CMS/src/Umbraco.Web.UI.Client/examples/sorter-with-two-containers/
This example shows moving items between two separate containers.
Umbraco源码中包含可用的示例:
嵌套容器示例
/Umbraco-CMS/src/Umbraco.Web.UI.Client/examples/sorter-with-nested-containers/
该示例展示了支持包含子项的嵌套排序功能。
双容器示例
/Umbraco-CMS/src/Umbraco.Web.UI.Client/examples/sorter-with-two-containers/
该示例展示了如何在两个独立容器间移动项。

Related Foundation Skills

相关基础技能

  • State Management: For reactive updates when order changes
    • Reference skill:
      umbraco-state-management
  • Umbraco Element: For creating sortable item elements
    • Reference skill:
      umbraco-umbraco-element
  • 状态管理:用于在排序变化时实现响应式更新
    • 参考技能:
      umbraco-state-management
  • Umbraco元素:用于创建可排序的项元素
    • 参考技能:
      umbraco-umbraco-element

Workflow

实现流程

  1. Fetch docs - Use WebFetch on the URLs above
  2. Ask questions - Single or multiple containers? Nested items? What data model?
  3. Generate files - Create container element + item element + sorter setup
  4. Explain - Show what was created and how sorting works

  1. 获取文档 - 通过WebFetch访问上述URL获取文档
  2. 明确需求 - 是单容器还是多容器?是否有嵌套项?数据模型是什么?
  3. 生成文件 - 创建容器元素、项元素并完成排序器配置
  4. 解释说明 - 展示创建的内容及排序功能的工作原理

Basic Sorter Setup

基础排序器配置

typescript
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { html, customElement, property, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

interface MyItem {
  id: string;
  name: string;
}

@customElement('my-sortable-list')
export class MySortableListElement extends UmbLitElement {
  #sorter = new UmbSorterController<MyItem, HTMLElement>(this, {
    // Get unique identifier from DOM element
    getUniqueOfElement: (element) => {
      return element.getAttribute('data-id') ?? '';
    },
    // Get unique identifier from data model
    getUniqueOfModel: (modelEntry) => {
      return modelEntry.id;
    },
    // Identifier shared by all connected sorters (for cross-container dragging)
    identifier: 'my-sortable-list',
    // CSS selector for sortable items
    itemSelector: '.sortable-item',
    // CSS selector for the container
    containerSelector: '.sortable-container',
    // Called when order changes
    onChange: ({ model }) => {
      this._items = model;
      this.requestUpdate();
      this.dispatchEvent(new CustomEvent('change', { detail: { items: model } }));
    },
  });

  @property({ type: Array, attribute: false })
  public get items(): MyItem[] {
    return this._items;
  }
  public set items(value: MyItem[]) {
    this._items = value;
    this.#sorter.setModel(value);
    this.requestUpdate();
  }
  private _items: MyItem[] = [];

  override render() {
    return html`
      <div class="sortable-container">
        ${repeat(
          this._items,
          (item) => item.id,
          (item) => html`
            <div class="sortable-item" data-id=${item.id}>
              ${item.name}
            </div>
          `
        )}
      </div>
    `;
  }
}

typescript
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { html, customElement, property, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

interface MyItem {
  id: string;
  name: string;
}

@customElement('my-sortable-list')
export class MySortableListElement extends UmbLitElement {
  #sorter = new UmbSorterController<MyItem, HTMLElement>(this, {
    // Get unique identifier from DOM element
    getUniqueOfElement: (element) => {
      return element.getAttribute('data-id') ?? '';
    },
    // Get unique identifier from data model
    getUniqueOfModel: (modelEntry) => {
      return modelEntry.id;
    },
    // Identifier shared by all connected sorters (for cross-container dragging)
    identifier: 'my-sortable-list',
    // CSS selector for sortable items
    itemSelector: '.sortable-item',
    // CSS selector for the container
    containerSelector: '.sortable-container',
    // Called when order changes
    onChange: ({ model }) => {
      this._items = model;
      this.requestUpdate();
      this.dispatchEvent(new CustomEvent('change', { detail: { items: model } }));
    },
  });

  @property({ type: Array, attribute: false })
  public get items(): MyItem[] {
    return this._items;
  }
  public set items(value: MyItem[]) {
    this._items = value;
    this.#sorter.setModel(value);
    this.requestUpdate();
  }
  private _items: MyItem[] = [];

  override render() {
    return html`
      <div class="sortable-container">
        ${repeat(
          this._items,
          (item) => item.id,
          (item) => html`
            <div class="sortable-item" data-id=${item.id}>
              ${item.name}
            </div>
          `
        )}
      </div>
    `;
  }
}

Nested Sorter (Items with Children)

嵌套排序器(包含子项的项)

typescript
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { html, customElement, property, repeat, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

export interface NestedItem {
  name: string;
  children?: NestedItem[];
}

@customElement('my-sorter-group')
export class MySorterGroupElement extends UmbLitElement {
  #sorter = new UmbSorterController<NestedItem, MySorterItemElement>(this, {
    getUniqueOfElement: (element) => element.name,
    getUniqueOfModel: (modelEntry) => modelEntry.name,
    // IMPORTANT: Same identifier allows items to move between all nested groups
    identifier: 'my-nested-sorter',
    itemSelector: 'my-sorter-item',
    containerSelector: '.sorter-container',
    onChange: ({ model }) => {
      const oldValue = this._value;
      this._value = model;
      this.requestUpdate('value', oldValue);
      this.dispatchEvent(new CustomEvent('change'));
    },
  });

  @property({ type: Array, attribute: false })
  public get value(): NestedItem[] {
    return this._value ?? [];
  }
  public set value(value: NestedItem[]) {
    this._value = value;
    this.#sorter.setModel(value);
    this.requestUpdate();
  }
  private _value?: NestedItem[];

  override render() {
    return html`
      <div class="sorter-container">
        ${repeat(
          this.value,
          (item) => item.name,
          (item) => html`
            <my-sorter-item .name=${item.name}>
              <!-- Recursive nesting -->
              <my-sorter-group
                .value=${item.children ?? []}
                @change=${(e: Event) => {
                  item.children = (e.target as MySorterGroupElement).value;
                }}
              ></my-sorter-group>
            </my-sorter-item>
          `
        )}
      </div>
    `;
  }

  static override styles = css`
    :host {
      display: block;
      min-height: 20px;
      border: 1px dashed rgba(122, 122, 122, 0.25);
      border-radius: var(--uui-border-radius);
      padding: var(--uui-size-space-1);
    }
  `;
}

typescript
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { html, customElement, property, repeat, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

export interface NestedItem {
  name: string;
  children?: NestedItem[];
}

@customElement('my-sorter-group')
export class MySorterGroupElement extends UmbLitElement {
  #sorter = new UmbSorterController<NestedItem, MySorterItemElement>(this, {
    getUniqueOfElement: (element) => element.name,
    getUniqueOfModel: (modelEntry) => modelEntry.name,
    // IMPORTANT: Same identifier allows items to move between all nested groups
    identifier: 'my-nested-sorter',
    itemSelector: 'my-sorter-item',
    containerSelector: '.sorter-container',
    onChange: ({ model }) => {
      const oldValue = this._value;
      this._value = model;
      this.requestUpdate('value', oldValue);
      this.dispatchEvent(new CustomEvent('change'));
    },
  });

  @property({ type: Array, attribute: false })
  public get value(): NestedItem[] {
    return this._value ?? [];
  }
  public set value(value: NestedItem[]) {
    this._value = value;
    this.#sorter.setModel(value);
    this.requestUpdate();
  }
  private _value?: NestedItem[];

  override render() {
    return html`
      <div class="sorter-container">
        ${repeat(
          this.value,
          (item) => item.name,
          (item) => html`
            <my-sorter-item .name=${item.name}>
              <!-- Recursive nesting -->
              <my-sorter-group
                .value=${item.children ?? []}
                @change=${(e: Event) => {
                  item.children = (e.target as MySorterGroupElement).value;
                }}
              ></my-sorter-group>
            </my-sorter-item>
          `
        )}
      </div>
    `;
  }

  static override styles = css`
    :host {
      display: block;
      min-height: 20px;
      border: 1px dashed rgba(122, 122, 122, 0.25);
      border-radius: var(--uui-border-radius);
      padding: var(--uui-size-space-1);
    }
  `;
}

Sortable Item Element

可排序项元素

typescript
import { html, customElement, property, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

@customElement('my-sorter-item')
export class MySorterItemElement extends UmbLitElement {
  @property({ type: String })
  name = '';

  override render() {
    return html`
      <div class="item-wrapper">
        <div class="drag-handle">
          <uui-icon name="icon-navigation"></uui-icon>
        </div>
        <div class="item-content">
          <span>${this.name}</span>
          <slot name="action"></slot>
        </div>
        <div class="children">
          <slot></slot>
        </div>
      </div>
    `;
  }

  static override styles = css`
    :host {
      display: block;
      background: var(--uui-color-surface);
      border: 1px solid var(--uui-color-border);
      border-radius: var(--uui-border-radius);
      margin: var(--uui-size-space-1) 0;
    }

    .item-wrapper {
      padding: var(--uui-size-space-3);
    }

    .drag-handle {
      cursor: grab;
      display: inline-block;
      margin-right: var(--uui-size-space-2);
    }

    .drag-handle:active {
      cursor: grabbing;
    }

    .children {
      margin-left: var(--uui-size-space-5);
      margin-top: var(--uui-size-space-2);
    }
  `;
}

declare global {
  interface HTMLElementTagNameMap {
    'my-sorter-item': MySorterItemElement;
  }
}

typescript
import { html, customElement, property, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

@customElement('my-sorter-item')
export class MySorterItemElement extends UmbLitElement {
  @property({ type: String })
  name = '';

  override render() {
    return html`
      <div class="item-wrapper">
        <div class="drag-handle">
          <uui-icon name="icon-navigation"></uui-icon>
        </div>
        <div class="item-content">
          <span>${this.name}</span>
          <slot name="action"></slot>
        </div>
        <div class="children">
          <slot></slot>
        </div>
      </div>
    `;
  }

  static override styles = css`
    :host {
      display: block;
      background: var(--uui-color-surface);
      border: 1px solid var(--uui-color-border);
      border-radius: var(--uui-border-radius);
      margin: var(--uui-size-space-1) 0;
    }

    .item-wrapper {
      padding: var(--uui-size-space-3);
    }

    .drag-handle {
      cursor: grab;
      display: inline-block;
      margin-right: var(--uui-size-space-2);
    }

    .drag-handle:active {
      cursor: grabbing;
    }

    .children {
      margin-left: var(--uui-size-space-5);
      margin-top: var(--uui-size-space-2);
    }
  `;
}

declare global {
  interface HTMLElementTagNameMap {
    'my-sorter-item': MySorterItemElement;
  }
}

Two Containers (Cross-Container Sorting)

双容器(跨容器排序)

typescript
@customElement('my-dual-sorter-dashboard')
export class MyDualSorterDashboard extends UmbLitElement {
  listOneItems: MyItem[] = [
    { id: '1', name: 'Apple' },
    { id: '2', name: 'Banana' },
  ];

  listTwoItems: MyItem[] = [
    { id: '3', name: 'Carrot' },
    { id: '4', name: 'Date' },
  ];

  override render() {
    return html`
      <div class="container">
        <my-sortable-list
          .items=${this.listOneItems}
          @change=${(e: CustomEvent) => {
            this.listOneItems = e.detail.items;
          }}
        ></my-sortable-list>

        <my-sortable-list
          .items=${this.listTwoItems}
          @change=${(e: CustomEvent) => {
            this.listTwoItems = e.detail.items;
          }}
        ></my-sortable-list>
      </div>
    `;
  }
}
Key: Both lists use the same
identifier
in their UmbSorterController to enable dragging between them.

typescript
@customElement('my-dual-sorter-dashboard')
export class MyDualSorterDashboard extends UmbLitElement {
  listOneItems: MyItem[] = [
    { id: '1', name: 'Apple' },
    { id: '2', name: 'Banana' },
  ];

  listTwoItems: MyItem[] = [
    { id: '3', name: 'Carrot' },
    { id: '4', name: 'Date' },
  ];

  override render() {
    return html`
      <div class="container">
        <my-sortable-list
          .items=${this.listOneItems}
          @change=${(e: CustomEvent) => {
            this.listOneItems = e.detail.items;
          }}
        ></my-sortable-list>

        <my-sortable-list
          .items=${this.listTwoItems}
          @change=${(e: CustomEvent) => {
            this.listTwoItems = e.detail.items;
          }}
        ></my-sortable-list>
      </div>
    `;
  }
}
关键说明:两个列表的UmbSorterController需使用相同的
identifier
,以支持跨容器拖拽。

UmbSorterController Options

UmbSorterController配置选项

OptionTypeDescription
identifier
string
Shared ID for connected sorters (enables cross-container dragging)
itemSelector
string
CSS selector for sortable items
containerSelector
string
CSS selector for the container
getUniqueOfElement
(element) => string
Extract unique ID from DOM element
getUniqueOfModel
(model) => string
Extract unique ID from data model
onChange
({ model }) => void
Called when order changes
onStart
() => void
Called when dragging starts
onEnd
() => void
Called when dragging ends

选项类型描述
identifier
string
关联排序器的共享ID(支持跨容器拖拽)
itemSelector
string
可排序项的CSS选择器
containerSelector
string
容器的CSS选择器
getUniqueOfElement
(element) => string
从DOM元素中提取唯一ID
getUniqueOfModel
(model) => string
从数据模型中提取唯一ID
onChange
({ model }) => void
排序变化时触发的回调
onStart
() => void
拖拽开始时触发的回调
onEnd
() => void
拖拽结束时触发的回调

Key Methods

核心方法

typescript
// Set the model (call when items change externally)
this.#sorter.setModel(items);

// Get current model
const currentItems = this.#sorter.getModel();

// Disable sorting temporarily
this.#sorter.disable();

// Re-enable sorting
this.#sorter.enable();

typescript
// Set the model (call when items change externally)
this.#sorter.setModel(items);

// Get current model
const currentItems = this.#sorter.getModel();

// Disable sorting temporarily
this.#sorter.disable();

// Re-enable sorting
this.#sorter.enable();

CSS Classes Applied During Drag

拖拽过程中应用的CSS类

ClassApplied ToWhen
.umb-sorter-dragging
ContainerWhile any item is being dragged
.umb-sorter-placeholder
Placeholder elementIndicates drop position

类名应用对象触发时机
.umb-sorter-dragging
容器当有项被拖拽时
.umb-sorter-placeholder
占位元素指示放置位置时

Best Practices

最佳实践

  1. Use unique identifiers - Each item must have a unique ID
  2. Match selectors carefully -
    itemSelector
    and
    containerSelector
    must match your DOM
  3. Share identifier - Use same
    identifier
    for connected sorters
  4. Handle nested updates - Propagate changes up through nested structures
  5. Use repeat directive - Always use
    repeat()
    with a key function for proper DOM diffing
  6. Provide visual feedback - Style drag handles and drop zones clearly
That's it! Always fetch fresh docs, keep examples minimal, generate complete working code.
  1. 使用唯一标识符 - 每个项必须拥有唯一ID
  2. 精准匹配选择器 -
    itemSelector
    containerSelector
    必须与DOM结构匹配
  3. 共享标识符 - 关联的排序器使用相同的
    identifier
  4. 处理嵌套更新 - 在嵌套结构中向上传递变化
  5. 使用repeat指令 - 始终配合键函数使用
    repeat()
    以实现正确的DOM差异对比
  6. 提供视觉反馈 - 清晰设置拖拽手柄和放置区域的样式
以上就是全部内容!请务必获取最新文档,保持示例简洁,生成完整可运行的代码。