Vanilla JS — Copy items
Create copies with unique IDs in Vanilla JS while retaining the source, and verify cancellation and reverse rejection.
Steps and expected results
Section titled “Steps and expected results”- Drag the Note handle from the catalog into the empty board. Both source items remain and one copy appears.
- Copy the same Note again. The board contains two copies with distinct IDs.
- Dragging a copy back into the catalog is refused; this example permits delivery only from catalog to board.
- Start a source drag and cancel with Escape. List membership remains unchanged.
- Reset restores the source order and clears the board. The ID sequence is not reused.
Dragging uses pointer input. Inspect the onAfterDrag status and displayed IDs to verify the result.
Copy policies
Section titled “Copy policies”| Area | Setting | Meaning |
|---|---|---|
| Source | group: { name: "templates", pull: "copy", put: false } |
Copies out and refuses incoming items from other areas. |
| Destination | group: { name: "templates", pull: false } |
Accepts the same group and does not export items. |
| Both | Different areaId values |
Distinguishes area identity in the shared scope. |
pull: "copy" retains source items during cross-area delivery. It does not automatically disable reordering within that same area. The source remains visible during a drag while preview and placeholder provide separate feedback.
Vanilla JS state contract
Section titled “Vanilla JS state contract”Create the source with createSortable, then register the destination on that same instance. copyElement returns a detached element with a new data-sortable-id. Do not append it yourself; let the package validate and insert it.
copyElement?: (source: Element, context: CopyItemContext) => Element;The copy must be detached and match the destination item selector. The destination’s getItemId must return the new ID; the default resolver reads data-sortable-id. cloneNode does not copy separately registered application listeners or state.
Completion and failure
Section titled “Completion and failure”Check onAfterDrag.status === "dropped"; a list update notification alone does not confirm completion. Distinguish Escape, outside drops, and policy rejection from a committed copy. Missing or duplicate IDs and malformed copy results are not valid copies.
The ID counter is local to one demo instance. Persistence or multi-client use needs an application-wide collision-free identity policy. Alt/Option copying, custom previews, and exhaustive malformed-input testing are separate scenarios.
Related documentation
Section titled “Related documentation”Copy guide · Core lifecycle · Vanilla instance · Transfer with this adapter
Try it live
Section titled “Try it live”Ready to copy
---const { lang = 'ko' } = Astro.props;const ko = lang === 'ko';const initial = [{ id: 'template-note', label: ko ? '메모' : 'Note' }, { id: 'template-chart', label: ko ? '차트' : 'Chart' }];---<vanilla-copy class="demo" data-demo="copy-vanilla" data-language={lang}> <div class="demo-toolbar"><span>{ko ? '왼쪽 항목을 오른쪽으로 여러 번 드래그하십시오.' : 'Drag an item from the catalog to the board more than once.'}</span><button data-reset>{ko ? '초기화' : 'Reset'}</button></div> <div class="transfer-board"> <section class="transfer-column"><div class="list-heading">{ko ? '원본' : 'Catalog'}<span data-count="catalog">2</span></div> <div class="task-list copy-catalog" data-copy-area="catalog"> {initial.map(item => <div class="task" data-copy-id={item.id} data-sortable-id={item.id}><button class="task-handle" aria-label={`${ko ? '드래그' : 'Drag'}: ${item.label}`}>⠿</button><span>{item.label}</span></div>)} </div> </section> <section class="transfer-column"><div class="list-heading">{ko ? '복제본' : 'Copies'}<span data-count="board">0</span></div><div class="task-list copy-board" data-copy-area="board"></div></section> </div> <p role="status">{ko ? '복제 대기' : 'Ready to copy'}</p> <pre data-copy-state></pre></vanilla-copy><script> import { createSortable } from 'comins-sortable'; import 'comins-sortable/styles.css'; class VanillaCopy extends HTMLElement { cleanup?: () => void; connectedCallback() { if (this.cleanup) return; const catalog = this.querySelector<HTMLElement>('[data-copy-area="catalog"]')!; const board = this.querySelector<HTMLElement>('[data-copy-area="board"]')!; const initial = [...catalog.children]; let sequence = 0; const ids = (area: HTMLElement) => Array.from(area.querySelectorAll(':scope > [data-sortable-id]:not([data-comins-sortable-placeholder])'), item => item.getAttribute('data-sortable-id')); const update = () => { const state = { catalog: ids(catalog), board: ids(board) }; this.querySelector('[data-copy-state]')!.textContent = JSON.stringify(state, null, 2); this.querySelector('[data-count="catalog"]')!.textContent = String(state.catalog.length); this.querySelector('[data-count="board"]')!.textContent = String(state.board.length); }; const sortable = createSortable(catalog, { areaId: 'catalog', item: ':scope > .task', handle: '.task-handle', group: { name: 'templates', pull: 'copy', put: false }, copyElement(source) { const copy = source.cloneNode(true) as HTMLElement; const id = `copy-${++sequence}`; copy.dataset.sortableId = id; copy.dataset.copyId = id; return copy; // Detached; the adapter inserts it after validation. }, onAfterDrag: event => { update(); this.querySelector('[role="status"]')!.textContent = `${event.status}: ${event.reason}`; }, }); const unregister = sortable.registerArea(board, { areaId: 'board', item: ':scope > .task', handle: '.task-handle', group: { name: 'templates', pull: false } }); const reset = () => { sortable.cancel(); board.replaceChildren(); catalog.replaceChildren(...initial); sortable.refreshArea('catalog'); sortable.refreshArea('board'); update(); this.querySelector('[role="status"]')!.textContent = this.dataset.language === 'ko' ? '복제 대기' : 'Ready to copy'; }; const button = this.querySelector('[data-reset]')!; button.addEventListener('click', reset); update(); this.cleanup = () => { button.removeEventListener('click', reset); unregister(); sortable.destroy(); }; } disconnectedCallback() { this.cleanup?.(); this.cleanup = undefined; } } if (!customElements.get('vanilla-copy')) customElements.define('vanilla-copy', VanillaCopy);</script>