Vanilla JS — 항목 복제
Vanilla JS에서 원본을 유지하며 고유 ID의 복제본을 생성하고, 취소·역방향 거부를 확인합니다.
조작 순서와 예상 결과
섹션 제목: “조작 순서와 예상 결과”- 원본의 메모 손잡이를 잡고 오른쪽 빈 영역에 놓습니다. 원본 2개는 유지되고 복제본 1개가 생깁니다.
- 같은 메모를 다시 복사합니다. 복제본은 2개가 되며 두 ID는 달라야 합니다.
- 복제본을 원본으로 되돌리려는 드래그는 거부합니다. 이 예제는 원본에서 복제본으로만 전달합니다.
- 원본을 드래그하다 Escape로 취소하면 목록 구성은 유지합니다.
- 초기화하면 원본 순서를 복원하고 복제본을 비웁니다. ID 순번은 재사용하지 않습니다.
이 예제의 드래그는 포인터 입력을 사용합니다. onAfterDrag 상태와 아래 ID 목록으로 결과를 확인하십시오.
복사 정책
섹션 제목: “복사 정책”| 위치 | 설정 | 의미 |
|---|---|---|
| 원본 | group: { name: "templates", pull: "copy", put: false } |
외부로 복사하며 다른 영역의 항목을 받지 않습니다. |
| 목적지 | group: { name: "templates", pull: false } |
같은 group을 받고 다른 영역으로 내보내지 않습니다. |
| 두 영역 | 서로 다른 areaId |
scope 내 영역 identity를 구분합니다. |
pull: "copy"는 영역 간 전달에서 원본을 유지합니다. 동일 영역 안의 재정렬을 자동으로 금지하는 설정은 아닙니다. 드래그 중 원본은 남고 preview와 placeholder가 별도로 표시됩니다.
Vanilla JS 상태 계약
섹션 제목: “Vanilla JS 상태 계약”createSortable로 원본을 등록한 뒤 같은 인스턴스의 registerArea로 목적지를 등록합니다. copyElement가 detached element를 반환하며 data-sortable-id를 새 ID로 지정합니다. 직접 append하지 않고 패키지가 검증 후 삽입하도록 둡니다.
copyElement?: (source: Element, context: CopyItemContext) => Element;복제 요소는 연결되지 않은 상태여야 하고 목적지 item selector와 일치해야 합니다. 목적지의 getItemId가 반환하는 값도 새 ID와 일치해야 합니다. 기본 ID resolver는 data-sortable-id를 읽습니다. cloneNode는 애플리케이션에서 별도로 등록한 listener나 상태를 복제하지 않습니다.
완료와 실패 처리
섹션 제목: “완료와 실패 처리”목록 갱신 알림만으로 완료를 확정하지 않고 onAfterDrag.status === "dropped"를 확인합니다. Escape·영역 밖 드롭·정책 거부는 완료와 구분합니다. 새 ID 누락·중복 또는 잘못된 복사 결과는 유효한 복제로 취급하지 않습니다.
이 예제의 ID 순번은 한 인스턴스 안의 데모용입니다. 서버 저장이나 여러 클라이언트 간 공유에는 애플리케이션 전체에서 충돌하지 않는 ID 정책이 필요합니다. Alt/Option 복제, 사용자 정의 preview, 실패 입력 전수 검증은 별도 시나리오입니다.
관련 문서
섹션 제목: “관련 문서”복제 가이드 · Core 생명주기 · Vanilla 인스턴스 · 같은 어댑터의 이동 예제
직접 실행
섹션 제목: “직접 실행”복제 대기
---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>