05住所自動補完ドロップダウン
住所を一文字ずつ打つと候補が下からスライドして現れ、矢印キーで選び、選んだ住所が地図の中心を実際に動かします。
使いどころ配送先や出発地を住所検索で指定させるとき
role=comboboxaria-activedescendanttyping keyframes

<main class="stage">
<div class="ac is-demo">
<div class="ac__map">
<span class="ac__pin" id="ac-pin" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</div>
<!-- 후보 목록은 입력칸 위로 열려 지도를 덮는다 — 절대배치라 뿌리 높이엔 안 잡히지만 뿌리 안쪽에 머문다 -->
<ul class="ac__list" id="ac-list" role="listbox" aria-label="住所の候補">
<li class="ac__opt" id="ac-o0" role="option" aria-selected="false" data-x="88%" data-y="30%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路152</span></li>
<li class="ac__opt" id="ac-o1" role="option" aria-selected="false" data-x="74%" data-y="58%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路427</span></li>
<li class="ac__opt" id="ac-o2" role="option" aria-selected="false" data-x="78%" data-y="72%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路キル21</span></li>
<li class="ac__opt" id="ac-o3" role="option" aria-selected="false" data-x="92%" data-y="46%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路87キル36</span></li>
</ul>
<div class="ac__field">
<span class="ac__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="ac__input" id="ac-input" type="text" role="combobox" aria-expanded="false" aria-controls="ac-list" aria-autocomplete="list" autocomplete="off" placeholder="住所を検索">
<span class="ac__ghost" aria-hidden="true">テヘラン路</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
// 목록은 입력칸 위(지도 쪽)로 열린다 — 뿌리 높이를 넘지 않으니 따로 비워 둘 아래 여백이 없다
padding-bottom: 0;
}
// ── 지도 ─────────────────────────────────────────────
.ac__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 32px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 24px),
radial-gradient(circle at 82% 78%, $map-park 0 26px, transparent 26px),
linear-gradient(-72deg, transparent 0 74%, $map-water 74% 86%, transparent 86%),
$map-land;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.ac__map::before { left: 12%; top: 16%; width: 14%; height: 32%; }
.ac__map::after { left: 46%; top: 62%; width: 17%; height: 26%; }
// 핀은 지도 오른쪽 띠에 산다 — 목록이 왼쪽 2/3 을 덮어도 핀이 옮겨가는 게 보여야 한다
.ac__pin {
position: absolute;
z-index: 2;
left: 82%; top: 38%;
display: block;
color: $color;
transform: translate(-50%, -100%);
transition: left $dur-base $easing, top $dur-base $easing;
}
// ── 후보 목록 (입력칸 바로 위로 슬라이드해 올라온다) ────────
.ac__list {
position: absolute;
z-index: 3;
left: 0; right: 34%; bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: $sp-1;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: bottom center; // 규칙8: 입력칸 위변에서 펼침
transform: translateY(10px) scale(.96);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
color: $subject-ink;
font-size: 11px; line-height: 14px; font-weight: 600;
cursor: pointer;
}
// 하이라이트 판(::before)이 절대배치라 글자보다 나중에 그려진다 — 글자도 positioned 로 올려 덮이지 않게 한다
.ac__opt > svg { position: relative; z-index: 1; flex: 0 0 auto; color: $color; }
.ac__opt > span { position: relative; z-index: 1; }
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.ac__opt.is-active::before { opacity: 1; }
// ── 검색 입력칸 ───────────────────────────────────────
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px; top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, .5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%; height: 100%;
padding: 0 $sp-3 0 $sp-8;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: $r-control;
background: rgba(255, 255, 255, .07);
color: $subject-cream;
font-family: inherit;
font-size: 12px; line-height: 26px; font-weight: 600;
}
.ac__input::placeholder { color: rgba(255, 255, 255, .38); }
.ac__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ac.is-open .ac__input { border-color: $color; }
// 자동 루프 때만 보이는 "타이핑" 유령 글자. 진짜 placeholder 와 겹치지 않게 투명으로 지운다
.ac.is-demo .ac__input::placeholder { color: transparent; }
.ac__ghost {
position: absolute;
left: 33px; top: 0;
color: $subject-cream;
font-size: 12px; line-height: 28px; font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~30% 한 자씩 타이핑 → 32~40% 목록 열림 → 40·50·58% 하이라이트 이동 → 66% 선택(핀 이동)
// → 80~90% 지우기 · 82~90% 목록 닫힘 · 84~90% 핀 복귀. 90% 에 처음 상태로 돌아온다.
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: $dur-loop;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before { animation-name: ac-hl-0; }
.ac.is-demo .ac__opt:nth-child(2)::before { animation-name: ac-hl-1; }
.ac.is-demo .ac__opt:nth-child(3)::before { animation-name: ac-hl-2; }
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% { clip-path: inset(0 100% 0 0); }
30% { clip-path: inset(0 0 0 0); }
80% { clip-path: inset(0 0 0 0); }
90% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes ac-list {
0% { opacity: 0; transform: translateY(10px) scale(.96); }
32% { opacity: 0; transform: translateY(10px) scale(.96); }
40% { opacity: 1; transform: translateY(0) scale(1); }
82% { opacity: 1; transform: translateY(0) scale(1); }
90% { opacity: 0; transform: translateY(10px) scale(.96); }
100% { opacity: 0; transform: translateY(10px) scale(.96); }
}
@keyframes ac-hl-0 { 0% { opacity: 0; } 40% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-1 { 0% { opacity: 0; } 50% { opacity: 1; } 58% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-2 { 0% { opacity: 0; } 58% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-pin {
0% { left: 82%; top: 38%; }
66% { left: 82%; top: 38%; }
74% { left: 78%; top: 72%; }
84% { left: 78%; top: 72%; }
90% { left: 82%; top: 38%; }
100% { left: 82%; top: 38%; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.ac { gap: $sp-1; }
.ac__list { bottom: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin { animation: none !important; }
.ac.is-demo .ac__ghost { opacity: 0; }
.ac__list, .ac__pin, .ac__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding-bottom: 0;
}
.ac__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 32px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 24px), radial-gradient(circle at 82% 78%, #c9e0bd 0 26px, transparent 26px), linear-gradient(-72deg, transparent 0 74%, #bcd9ea 74% 86%, transparent 86%), #efe9dc;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.ac__map::before {
left: 12%;
top: 16%;
width: 14%;
height: 32%;
}
.ac__map::after {
left: 46%;
top: 62%;
width: 17%;
height: 26%;
}
.ac__pin {
position: absolute;
z-index: 2;
left: 82%;
top: 38%;
display: block;
color: #2f6df6;
transform: translate(-50%, -100%);
transition: left 300ms cubic-bezier(0.2, 1.4, 0.4, 1), top 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac__list {
position: absolute;
z-index: 3;
left: 0;
right: 34%;
bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: 4px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: bottom center;
transform: translateY(10px) scale(0.96);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 600;
cursor: pointer;
}
.ac__opt > svg {
position: relative;
z-index: 1;
flex: 0 0 auto;
color: #2f6df6;
}
.ac__opt > span {
position: relative;
z-index: 1;
}
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.ac__opt.is-active::before {
opacity: 1;
}
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px;
top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, 0.5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 12px 0 32px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 26px;
font-weight: 600;
}
.ac__input::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.ac__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ac.is-open .ac__input {
border-color: #2f6df6;
}
.ac.is-demo .ac__input::placeholder {
color: transparent;
}
.ac__ghost {
position: absolute;
left: 33px;
top: 0;
color: #fff7e6;
font-size: 12px;
line-height: 28px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: 2s;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before {
animation-name: ac-hl-0;
}
.ac.is-demo .ac__opt:nth-child(2)::before {
animation-name: ac-hl-1;
}
.ac.is-demo .ac__opt:nth-child(3)::before {
animation-name: ac-hl-2;
}
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% {
clip-path: inset(0 100% 0 0);
}
30% {
clip-path: inset(0 0 0 0);
}
80% {
clip-path: inset(0 0 0 0);
}
90% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes ac-list {
0% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
32% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
40% {
opacity: 1;
transform: translateY(0) scale(1);
}
82% {
opacity: 1;
transform: translateY(0) scale(1);
}
90% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
}
@keyframes ac-hl-0 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-1 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-2 {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-pin {
0% {
left: 82%;
top: 38%;
}
66% {
left: 82%;
top: 38%;
}
74% {
left: 78%;
top: 72%;
}
84% {
left: 78%;
top: 72%;
}
90% {
left: 82%;
top: 38%;
}
100% {
left: 82%;
top: 38%;
}
}
@media (max-width: 360px) {
.ac {
gap: 4px;
}
.ac__list {
bottom: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin {
animation: none !important;
}
.ac.is-demo .ac__ghost {
opacity: 0;
}
.ac__list, .ac__pin, .ac__input {
transition: none;
}
}// 실제 동작: 글자를 치면 후보가 열리고, ↑↓ 로 옮기고, Enter(또는 클릭)로 고르면 지도 핀이 그 좌표로 간다.
var acRoot = document.querySelector('.ac');
var acInput = document.getElementById('ac-input');
var acList = document.getElementById('ac-list');
var acPin = document.getElementById('ac-pin');
var acOpts = [].slice.call(acList.querySelectorAll('.ac__opt'));
var acActive = 0;
function acPaint() {
acOpts.forEach(function (o, i) { o.classList.toggle('is-active', i === acActive); });
acInput.setAttribute('aria-activedescendant', acOpts[acActive].id);
}
function acOpen(on) {
acRoot.classList.remove('is-demo'); // 사람이 치면 자동 루프를 멈춘다
acRoot.classList.toggle('is-open', on);
acInput.setAttribute('aria-expanded', String(on));
if (on) acPaint(); else acInput.removeAttribute('aria-activedescendant');
}
function acPick(i) {
var o = acOpts[i];
acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
acInput.value = o.lastElementChild.textContent;
acPin.style.left = o.getAttribute('data-x');
acPin.style.top = o.getAttribute('data-y');
acOpen(false);
acInput.focus({ preventScroll: true });
}
acInput.addEventListener('input', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('focus', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { acActive = Math.min(acActive + 1, acOpts.length - 1); acOpen(true); e.preventDefault(); }
else if (e.key === 'ArrowUp') { acActive = Math.max(acActive - 1, 0); acOpen(true); e.preventDefault(); }
else if (e.key === 'Enter') { acPick(acActive); e.preventDefault(); }
else if (e.key === 'Escape') { acOpen(false); }
});
acOpts.forEach(function (o, i) {
o.addEventListener('mousemove', function () { acActive = i; acPaint(); });
o.addEventListener('click', function () { acPick(i); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AddressAuto.module.scss";
export type AddressItem = {
text: string;
/** 지도 안 좌표 (예: "58%") */
x: string;
y: string;
};
export type AddressAutoProps = {
placeholder?: string;
listLabel?: string;
items?: AddressItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
const DEFAULT_ITEMS: AddressItem[] = [
{ text: "Teheran-ro 152", x: "88%", y: "30%" },
{ text: "Teheran-ro 427", x: "74%", y: "58%" },
{ text: "Teheran-ro-gil 21", x: "78%", y: "72%" },
{ text: "Teheran-ro 87-gil 36", x: "92%", y: "46%" },
];
function PinIcon({ size }: { size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
export default function AddressAuto({
placeholder = "Search an address",
listLabel = "Address suggestions",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: AddressAutoProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [picked, setPicked] = useState(-1);
const [spot, setSpot] = useState({ left: "82%", top: "38%" });
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
function pick(i: number) {
const it = items[i];
setPicked(i);
setQuery(it.text);
setSpot({ left: it.x, top: it.y });
setOpen(false);
inputRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.ac} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<div className={styles.map}>
<span className={styles.pin} style={spot} aria-hidden="true"><PinIcon size={20} /></span>
</div>
<ul className={styles.list} id="ac-list" role="listbox" aria-label={listLabel}>
{items.map((it, i) => (
<li
key={it.text}
id={`ac-o${i}`}
className={`${styles.opt} ${i === active ? styles.isActive : ""}`}
role="option"
aria-selected={i === picked}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
>
<PinIcon size={13} />
<span>{it.text}</span>
</li>
))}
</ul>
<div className={styles.field}>
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
autoComplete="off"
placeholder={placeholder}
value={query}
aria-expanded={open}
aria-controls="ac-list"
aria-autocomplete="list"
aria-activedescendant={open ? `ac-o${active}` : undefined}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setOpen(e.target.value.length > 0); }}
onFocus={() => setOpen(query.length > 0)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { setActive((a) => Math.min(a + 1, items.length - 1)); setOpen(true); e.preventDefault(); }
else if (e.key === "ArrowUp") { setActive((a) => Math.max(a - 1, 0)); setOpen(true); e.preventDefault(); }
else if (e.key === "Enter") { pick(active); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); }
}}
/>
</div>
</div>
);
}05 住所自動補完ドロップダウン — 計測 累積面積 17.575% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · 地図 ui デザイン 9選 — クリックでピンが落ちる位置選択
このデモが属する記事
地図 ui デザイン 9選 — クリックでピンが落ちる位置選択地図 ui デザインとは、配達先や待ち合わせ場所を「住所を書かせる」のではなく地図の上で指して決めてもらう画面の作りです。9つとも地図 API を使わず、CSS で描いた盤の上だけで動きます。 この記事のデモをすべて見る


