Vanilla JS — 두 목록 사이 이동
항목을 같은 목록 안에서 재정렬하거나 다른 목록으로 이동합니다. 두 영역은 같은 scope와 group="tasks"를 공유하고 각 areaId는 고유합니다. 항목 ID는 이동 후에도 유지됩니다.
직접 실행
섹션 제목: “직접 실행”항목 왼쪽 손잡이를 잡고 오른쪽 빈 목록에 놓으십시오. 완료 수가 늘어나고 왼쪽 목록에서 해당 항목이 제거됩니다. 초기화는 모든 항목을 원래 순서로 되돌립니다. 이 예제의 이동 조작은 포인터 기반입니다.
진행 예정3
문서 설계
예제 구현
코드 검토
완료0
완료: 0 / 3
---const { lang = 'ko' } = Astro.props;const ko = lang === 'ko';const items = [{ id: 'plan', label: ko ? '문서 설계' : 'Design the docs' }, { id: 'build', label: ko ? '예제 구현' : 'Build an example' }, { id: 'review', label: ko ? '코드 검토' : 'Review the code' }];---<vanilla-transfer class="demo" data-demo="sortable-vanilla" data-language={lang}> <div class="demo-toolbar"><span>{ko ? '손잡이를 잡고 다른 목록으로 이동하세요.' : 'Drag a handle into the other list.'}</span><button data-reset>{ko ? '초기화' : 'Reset'}</button></div> <div class="transfer-board"> <section class="transfer-column"><div class="list-heading">{ko ? '진행 예정' : 'To do'}<span data-count="todo">3</span></div> <div class="task-list" data-list="todo" aria-label={ko ? '진행 예정' : 'To do'}> {items.map(item => <div class="task" data-task={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 ? '완료' : 'Done'}<span data-count="done">0</span></div><div class="task-list" data-list="done" aria-label={ko ? '완료' : 'Done'}></div></section> </div> <p role="status" class="demo-status">{ko ? '완료' : 'Done'}: 0 / 3</p></vanilla-transfer><script> import { createSortable, type Sortable } from 'comins-sortable'; class VanillaTransfer extends HTMLElement { instance?: Sortable; unregister?: () => void; cleanup?: () => void; connectedCallback() { const todo = this.querySelector<HTMLElement>('[data-list="todo"]')!; const done = this.querySelector<HTMLElement>('[data-list="done"]')!; const initial = [...todo.children]; const count = (list: HTMLElement) => list.querySelectorAll(':scope > [data-sortable-id]:not([data-comins-sortable-placeholder])').length; const update = () => { this.querySelector('[data-count="todo"]')!.textContent = String(count(todo)); this.querySelector('[data-count="done"]')!.textContent = String(count(done)); this.querySelector('[role="status"]')!.textContent = `${this.dataset.language === 'ko' ? '완료' : 'Done'}: ${count(done)} / 3`; }; this.instance = createSortable(todo, { areaId: 'todo', group: 'tasks', item: ':scope > [data-sortable-id]', handle: '.task-handle', onChange: update, onAfterDrag: update }); this.unregister = this.instance.registerArea(done, { areaId: 'done', group: 'tasks', item: ':scope > [data-sortable-id]', handle: '.task-handle' }); const reset = () => { this.instance?.cancel(); for (const item of initial) todo.append(item); this.instance?.refreshArea('todo'); this.instance?.refreshArea('done'); update(); }; const button = this.querySelector('[data-reset]')!; button.addEventListener('click', reset); this.cleanup = () => button.removeEventListener('click', reset); } disconnectedCallback() { this.cleanup?.(); this.unregister?.(); this.instance?.destroy(); } } if (!customElements.get('vanilla-transfer')) customElements.define('vanilla-transfer', VanillaTransfer);</script>Vanilla JS 상태 계약
섹션 제목: “Vanilla JS 상태 계약”패키지 root의 createSortable로 첫 목록을 등록하고 같은 인스턴스의 registerArea로 두 번째 목록을 등록합니다. 어댑터가 DOM 이동을 적용한 뒤 onChange를 호출합니다. 별도 데이터 모델을 가진 앱은 그 callback에서 모델도 반영해야 합니다. 이 예제는 DOM이 원본 상태입니다. 페이지 해제 시 unregister와 destroy로 등록을 해제합니다.
각 핸들의 touch-action: none과 user-select: none은 드래그 중 터치 스크롤·텍스트 선택 충돌을 줄입니다.
onChange 시점에는 임시 placeholder DOM이 남아 있을 수 있습니다. children.length 대신 실제 항목 selector로 개수를 계산합니다. onAfterDrag에서도 다시 표시하여 취소·복원 결과를 반영합니다.
검색어
섹션 제목: “검색어”다른 목록으로 이동, 목록 간 이동, 항목 옮기기, 드래그 앤 드롭, 재정렬, 공유 scope, transfer.