원래 제작 기록에는 위쪽 도면 선택은 되는데 아래쪽은 움직이지 않는다는 문제가 있다. 같은 버튼을 반복했어도 실제 선택 상태와 연결이 따로 남으면 이런 차이가 생긴다. 나는 그 문제에서 하나의 자료 ID를 여러 조작부가 함께 읽는 패턴을 떼어내 새 모듈로 만들었다.
이 코드는 원래 고객 슬라이더를 복원한 것이 아니다. 명시적으로 선언한 자료 패널과 버튼을 연결하고, 어떤 자료가 선택됐는지를 함께 표시한다. 합성 도식을 고르는 예제에서 위아래 버튼과 실제 보이는 자료를 확인할 수 있다. 다운로드에는 새 코드와 한영 예제, 스타일과 분리된 실행 검사를 넣었다.
선택한 자료의 ID를 하나로 둔다
createSelection은 자료 ID 목록과 시작 ID를 받아 current와 select를 제공한다. 자료의 이름이나 버튼 순서가 바뀌어도 같은 ID를 쓰면 선택의 뜻을 유지할 수 있다. 같은 자료를 다시 고르면 변경이 없다고 돌려주고, 모르는 ID는 현재 선택을 바꾸지 않은 채 오류로 처리한다. 입력 목록을 프로그램이 고쳐 쓰지도 않는다.
ID는 고유하고 비어 있지 않은 문자열이다. 새 예제는 최대 이백 개, 각 ID는 최대 백이십 글자로 제한했다. 숫자 위치를 실제 자료라고 추측하거나 이미지의 내용에서 타입을 알아내는 기능은 없다. 실제 자료와 ID가 어떻게 연결되는지 호출자가 명시해야 한다. 큰 자료 목록에는 다른 탐색 구성이 필요할 수 있다.
같은 선택을 여러 버튼과 하나의 패널 표시로 연결한다
attachSharedPicker는 한 영역의 data-panel-id 패널과 data-pick-id 버튼을 읽는다. 패널의 ID는 하나씩이고, 여러 버튼 그룹이 같은 ID를 반복해도 된다. 선택하면 해당 자료만 보이고 모든 버튼의 aria-pressed가 같은 기준으로 바뀐다. 각 패널에 대응하는 버튼이 있어야 하며 알려지지 않은 버튼 ID는 연결 전에 거절한다.
버튼은 원래 HTML button이며 Enter와 Space는 브라우저의 기본 버튼 동작을 쓴다. 화살표 키를 가진 탭 UI 전체를 구현했다고 설명하지 않는다. 아래 예제는 작은 화면에서 아래쪽 그룹을 숨기지만 자료나 선택 ID를 지우지 않는다. 버튼의 개수와 자료의 상태를 다른 책임으로 둔 것이다.
새 코드와 패키지에 들어 있는 것
아래 JavaScript는 공유 ZIP의 shared_picker.mjs와 같다. 상태를 바꾸는 핵심과 DOM 연결을 나누고 외부 슬라이더, 고객 자산, 연락처나 서버 호출을 넣지 않았다. 선택 변경은 shared-picker:change 이벤트로 ID를 돌려준다. 그 이벤트에 어떤 실제 사이트 기능을 연결할지는 별도로 정하고 검사한다.
ZIP에는 코드, CSS, 한국어·영어 합성 예제와 Node 검사, 사용 안내의 여섯 파일이 있다. node checks.mjs로 포함 검사를 실행할 수 있다. 예제를 열 때는 폴더를 로컬 HTTP로 제공하는 방법을 안내에 적었다. 파일을 바로 더블클릭하는 방식과 ES 모듈을 제공하는 실행 환경을 혼동하지 않도록 했다.
코드와 구현 살펴보기
새 독립 JavaScript · 하나의 자료 ID와 여러 조작부
// New independent selection state and DOM connector. No storage or network.
const mounted = new WeakMap();
export function createSelection(ids, initialId = ids?.[0]) {
if (!Array.isArray(ids) || !ids.length || ids.length > 200 ||
ids.some(id => typeof id !== 'string' || !id.trim() || id.length > 120) ||
new Set(ids).size !== ids.length) throw new TypeError('Invalid material IDs');
const known = new Set(ids);
if (!known.has(initialId)) throw new TypeError('Unknown initial material');
let current = initialId;
return Object.freeze({
get current() { return current; },
select(id) {
if (!known.has(id)) throw new TypeError('Unknown material');
const changed = current !== id;
current = id;
return changed;
}
});
}
export function attachSharedPicker(root, {initialId} = {}) {
if (!root?.querySelectorAll || !root.addEventListener) throw new TypeError('Root required');
const panels = [...root.querySelectorAll('[data-panel-id]')];
const buttons = [...root.querySelectorAll('button[data-pick-id]')];
const ids = panels.map(panel => panel.dataset.panelId);
const model = createSelection(ids, initialId ?? ids[0]);
const known = new Set(ids);
if (!buttons.length || buttons.some(button => !known.has(button.dataset.pickId)) ||
ids.some(id => !buttons.some(button => button.dataset.pickId === id))) {
throw new TypeError('Controls and material IDs disagree');
}
// Invalid new markup/initial selection leaves an existing connector intact.
mounted.get(root)?.destroy();
const priorPanels = panels.map(panel => panel.hidden);
const priorPressed = buttons.map(button => button.getAttribute('aria-pressed'));
const ownedButtons = new Set(buttons);
let active = true;
const render = () => {
panels.forEach(panel => { panel.hidden = panel.dataset.panelId !== model.current; });
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.pickId === model.current)));
};
const select = id => {
if (!active) throw new Error('Picker is detached');
const changed = model.select(id);
render();
if (changed) root.dispatchEvent(new root.ownerDocument.defaultView.CustomEvent(
'shared-picker:change', {bubbles: true, detail: {id: model.current}}
));
return changed;
};
const click = event => {
const button = event.target.closest?.('button[data-pick-id]');
if (ownedButtons.has(button) && !button.disabled) select(button.dataset.pickId);
};
const api = Object.freeze({
get selectedId() { return model.current; }, select,
destroy() {
if (!active) return;
active = false; root.removeEventListener('click', click);
panels.forEach((panel, i) => { panel.hidden = priorPanels[i]; });
buttons.forEach((button, i) => {
if (priorPressed[i] === null) button.removeAttribute('aria-pressed');
else button.setAttribute('aria-pressed', priorPressed[i]);
});
if (mounted.get(root) === api) mounted.delete(root);
}
});
root.addEventListener('click', click); render(); mounted.set(root, api);
return api;
}
다시 연결하기와 해제하기도 계약에 넣는다
같은 영역에 다시 연결할 때는 이전 클릭 리스너를 정리한다. 현재 선택을 보존하고 싶다면 예제처럼 기존 selectedId를 새 연결의 initialId로 넘긴다. 전달하지 않으면 첫 자료를 시작값으로 삼는다. 자료나 버튼 노드를 교체한 경우에도 변경한 구성을 검토한 뒤 다시 연결한다. 이 모듈은 비동기 DOM을 자동으로 관찰하지 않는다.
destroy는 리스너를 없애고 연결 전의 hidden과 aria-pressed 값을 복원한다. 이미 해제한 객체로 선택을 바꾸려 하면 오류를 낸다. 반대로 새 연결의 구성이 잘못되었다면 기존 연결을 지우기 전에 거절한다. 화면을 다시 만드는 작업에서 기존 기능이 조용히 깨지지 않게 책임의 끝을 분명히 둔 것이다.
합성 검사와 실제 적용 검사는 따로 남긴다
새 코드의 스물두 검사는 상태 변경, 모르는 ID, 중복 자료, 위아래 동기화, 변경 이벤트, 비활성 버튼, 재연결과 원래 속성 복원을 분리된 합성 DOM으로 확인했다. 이는 코드의 작은 계약 검사이며 원래 고객 홈페이지나 실제 도면, 예약 접수의 검증이 아니다. 이 페이지에서 보이는 예제도 사실 자료를 담고 있지 않다.
사이트에 붙일 때는 실제 버튼의 키보드 동작과 화면 배치, 숨겨지는 패널 안의 초점, 기존 이벤트 연결을 살펴야 한다. 외부 슬라이더를 쓴다면 이 코드만 붙이는 것으로 연결이 완성되지 않는다. 자료 ID와 해당 프로그램의 실제 상태를 맞추는 별도 어댑터가 필요하다. 나는 이 모듈의 재사용 범위를 한 선택의 일관성으로 정했다.
다른 홈페이지에서 바꾸어 쓸 경계
자료 ID와 패널의 내용, 버튼 이름과 디자인은 사이트에 맞춰 바꿀 수 있다. 작은 화면에서 아래 그룹을 숨기는 기준도 예제의 시작값이다. 중요한 정보를 그 그룹에만 넣으면 안 되며, 핵심 자료와 필요한 조작은 실제 흐름에서 확인한다. 서로 독립적인 선택을 억지로 같은 상태로 합치는 데 쓰지는 않는다.
이 구현은 값을 저장하거나 외부로 전송하지 않는다. 호출한 ID는 그대로 이벤트에 남기므로 개인정보를 지우는 프로그램도 아니다. 공개 예제에는 일반 ID를 쓰고 실제 고객 자료의 사용 조건은 따로 검토한다. 읽는 사람이 자신의 화면에서 하나씩 붙여 보고 연결 관계를 검사할 수 있는 작은 기능을 남기려 했다.