07メンションチップ入力
アットマークを打つとアバター付きの一覧が開き、上下キーでたどって選んだ人が入力欄の前にチップとして入り、削除ボタンで外れます。
使いどころコメント・タスクで人を指名する入力
aria-activedescendanttranslateYrole="listbox"
<main class="stage">
<section class="mc is-demo" aria-label="メンション入力のデモ">
<div class="mc__row">
<!-- 이미 들어가 있는 칩 한 개. 새 칩은 사람이 실제로 골랐을 때만 이 자리에 끼워진다 -->
<span class="mc__chip">
<span class="mc__av mc__av--slate" aria-hidden="true"></span>
<svg class="mc__at" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
<span class="mc__name">管理者</span>
<button class="mc__x" type="button" aria-label="メンションを削除">
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
</button>
</span>
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
placeholder="コメントを入力" autocomplete="off">
<!-- 절대배치 목록 — 줄(position:relative) 바로 아래에 열리고,
뿌리의 padding-bottom 이 열린 높이만큼 자리를 잡는다(top:100% 를 뿌리에 걸면
예약한 자리 '아래'로 내려가 320px 넘침이 생긴다 — 279 실측) -->
<ul class="mc__list" id="mc-list" role="listbox" aria-label="メンション先">
<li class="mc__opt" id="mc-opt-0" role="option" aria-selected="true">
<span class="mc__av mc__av--blue" aria-hidden="true"></span><span class="mc__name">編集者</span>
</li>
<li class="mc__opt" id="mc-opt-1" role="option" aria-selected="false">
<span class="mc__av mc__av--violet" aria-hidden="true"></span><span class="mc__name">デザイナー</span>
</li>
<li class="mc__opt" id="mc-opt-2" role="option" aria-selected="false">
<span class="mc__av mc__av--teal" aria-hidden="true"></span><span class="mc__name">開発者</span>
</li>
<li class="mc__opt" id="mc-opt-3" role="option" aria-selected="false">
<span class="mc__av mc__av--plum" aria-hidden="true"></span><span class="mc__name">プランナー</span>
</li>
<li class="mc__bar" aria-hidden="true"></li>
</ul>
</div>
<template id="mc-tpl">
<span class="mc__chip">
<span class="mc__av" aria-hidden="true"></span>
<svg class="mc__at" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
<span class="mc__name"></span>
<button class="mc__x" type="button" aria-label="メンションを削除">
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
</button>
</span>
</template>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-slate: #3b4a63; // 8.40:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 짚는 막대가 네 줄을 훑고 돌아오는 한 바퀴
$easing: $ease-out; // 줄 사이를 부드럽게 지난다
$color: $av-blue; // @ 표시·입력 초점 테두리
@include stage($stage-orange);
.mc {
position: relative;
box-sizing: border-box;
width: min(340px, 100%);
// 절대배치 목록은 부모 높이 계산에서 빠진다 — 열린 높이만큼 뿌리에 자리를 미리 잡는다(237 실측)
padding-bottom: calc(#{$sp-6} * 4 + #{$sp-1} * 3);
}
.mc__row {
position: relative;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
min-height: $sp-10;
padding: $sp-2;
border-radius: $r-sm;
background: $subject-cream; // 오렌지 무대 위 밝은 면 — 글자는 잉크
box-shadow: $shadow-raised;
}
.mc__chip {
display: inline-flex;
align-items: center;
gap: $sp-1;
height: $sp-6;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba($stage-ink, .08);
color: $stage-ink;
font-weight: 600;
font-size: 12px;
line-height: 1;
}
.mc__at { flex: none; color: $color; }
.mc__x {
display: grid;
place-items: center;
flex: none;
width: $sp-4;
height: $sp-4;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $stage-ink;
cursor: pointer;
}
.mc__x:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.mc__av {
display: grid;
place-items: center;
flex: none;
width: $sp-4;
height: $sp-4;
border-radius: 50%;
background: $av-slate;
color: $subject-cream;
font-weight: 700;
font-size: 10px;
line-height: 1;
}
.mc__av--blue { background: $av-blue; }
.mc__av--violet { background: $av-violet; }
.mc__av--teal { background: $av-teal; }
.mc__av--plum { background: $av-plum; }
.mc__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: $stage-ink;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.mc__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 루프 촬영 중에는 안내 문구가 겹쳐 보이지 않게 한다
.mc.is-demo .mc__input::placeholder { color: transparent; }
.mc__list {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
right: 0;
margin: 0;
padding: $sp-1;
list-style: none;
border-radius: $r-sm;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.mc__opt {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
height: $sp-6;
padding: 0 $sp-2;
border-radius: $r-xs;
color: $stage-ink;
font-weight: 500;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.mc__opt[hidden] { display: none; }
// 실제 조작에서의 짚은 줄 표시 — 루프에서는 .mc__bar 가 이 역할을 대신한다
.mc:not(.is-demo) .mc__opt[aria-selected="true"] { background: rgba($stage-ink, .1); }
// ── 격자 미리보기 루프 ────────────────────────────────
// 목록은 루프 내내 열려 있고, 짚는 막대가 네 줄을 연속 transform 으로 훑고 돌아온다.
// 칩이 생겼다 사라지는 연출은 넣지 않는다 — 퇴장 프레임이 poster 로 뽑히기 때문이다.
// 칩 삽입은 사람이 실제로 조작할 때만 일어난다.
.mc__bar {
position: absolute;
left: $sp-1;
right: $sp-1;
top: $sp-1;
height: $sp-6;
border-radius: $r-xs;
background: rgba($stage-ink, .14);
pointer-events: none;
}
.mc:not(.is-demo) .mc__bar { visibility: hidden; }
.mc.is-demo .mc__bar {
animation-name: mcBar;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 한 바퀴를 86% 안에 끝낸다 — 24프레임의 마지막 장이 95.8% 지점이라 그 뒤는 찍히지 않는다
@keyframes mcBar {
0% { transform: translateY(0); }
36% { transform: translateY(72px); }
54% { transform: translateY(72px); }
86%, 100% { transform: translateY(0); }
}
// 좁은 화면 — 줄 높이(막대 이동거리 72px 와 묶여 있다)는 그대로 두고 여백만 줄인다
@media (max-width: 340px), (max-height: 220px) {
.mc { width: 100%; }
.mc__row { min-height: $sp-8; }
.mc__chip { height: $sp-5; }
.mc__input { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .mc.is-demo .mc__bar (0,3,0) 를 이기려면 !important. 막대는 첫 줄에 멈춰 보인다
.mc__bar { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mc {
position: relative;
box-sizing: border-box;
width: min(340px, 100%);
padding-bottom: calc(24px * 4 + 4px * 3);
}
.mc__row {
position: relative;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 8px;
border-radius: 8px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mc__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1;
}
.mc__at {
flex: none;
color: #7c90be;
}
.mc__x {
display: grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #17141a;
cursor: pointer;
}
.mc__x:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 1px;
}
.mc__av {
display: grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b4a63;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1;
}
.mc__av--blue {
background: #7c90be;
}
.mc__av--violet {
background: #9791c9;
}
.mc__av--teal {
background: #479082;
}
.mc__av--plum {
background: #ab5488;
}
.mc__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.mc__input:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 2px;
}
.mc.is-demo .mc__input::placeholder {
color: transparent;
}
.mc__list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 8px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mc__opt {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.mc__opt[hidden] {
display: none;
}
.mc:not(.is-demo) .mc__opt[aria-selected=true] {
background: rgba(23, 20, 26, 0.1);
}
.mc__bar {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: 24px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
pointer-events: none;
}
.mc:not(.is-demo) .mc__bar {
visibility: hidden;
}
.mc.is-demo .mc__bar {
animation-name: mcBar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes mcBar {
0% {
transform: translateY(0);
}
36% {
transform: translateY(72px);
}
54% {
transform: translateY(72px);
}
86%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mc {
width: 100%;
}
.mc__row {
min-height: 32px;
}
.mc__chip {
height: 20px;
}
.mc__input {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mc__bar {
animation: none !important;
}
}(function () {
var root = document.querySelector('.mc');
var input = root.querySelector('.mc__input');
var list = root.querySelector('.mc__list');
var opts = [].slice.call(list.querySelectorAll('.mc__opt'));
var tpl = document.getElementById('mc-tpl');
var active = 0;
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name) {
var p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('') : p[0].charAt(0);
}
[].forEach.call(root.querySelectorAll('.mc__chip, .mc__opt'), function (el) {
el.querySelector('.mc__av').textContent = initialOf(el.querySelector('.mc__name').textContent);
});
function visible() { return opts.filter(function (o) { return !o.hidden; }); }
function open() { list.hidden = false; input.setAttribute('aria-expanded', 'true'); }
function close() { list.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); }
function setActive(i) {
var v = visible();
if (!v.length) return;
active = (i + v.length) % v.length;
v.forEach(function (o, k) { o.setAttribute('aria-selected', k === active ? 'true' : 'false'); });
input.setAttribute('aria-activedescendant', v[active].id);
}
// @ 를 치면 목록이 열리고, 이어 치는 글자로 이름을 걸러낸다
input.addEventListener('input', function () {
var at = input.value.lastIndexOf('@');
if (at < 0) { close(); return; }
open();
var q = input.value.slice(at + 1);
opts.forEach(function (o) { o.hidden = !o.querySelector('.mc__name').textContent.includes(q); });
setActive(0);
});
input.addEventListener('keydown', function (e) {
if (list.hidden) return;
if (e.key === 'ArrowDown') { setActive(active + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { setActive(active - 1); e.preventDefault(); }
else if (e.key === 'Enter') { pick(visible()[active]); e.preventDefault(); }
else if (e.key === 'Escape') { close(); }
});
// 고른 사람을 칩으로 입력칸 앞에 넣고 @질의를 지운다
function pick(opt) {
if (!opt) return;
var chip = tpl.content.firstElementChild.cloneNode(true);
chip.querySelector('.mc__av').className = opt.querySelector('.mc__av').className;
chip.querySelector('.mc__av').textContent = opt.querySelector('.mc__av').textContent;
chip.querySelector('.mc__name').textContent = opt.querySelector('.mc__name').textContent;
root.querySelector('.mc__row').insertBefore(chip, input);
input.value = '';
close();
}
list.addEventListener('click', function (e) { var li = e.target.closest('.mc__opt'); if (li) pick(li); });
root.addEventListener('click', function (e) { var btn = e.target.closest('.mc__x'); if (btn) btn.closest('.mc__chip').remove(); });
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarMentionChip.module.scss";
export type AvatarMentionChipProps = {
label?: string;
/** 목록에 올 사람(직무 명사) */
people?: string[];
/** 처음부터 들어가 있는 칩 */
initialChips?: string[];
placeholder?: string;
listLabel?: string;
removeLabel?: string;
/** 짚는 막대가 네 줄을 훑고 돌아오는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** @ 표시·입력 초점 테두리 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type InputEvt = { target: { value: string } };
type KeyEvt = { key: string; preventDefault(): void };
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name: string): string {
const p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("") : p[0].charAt(0);
}
const AtIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor" className={styles.at}>
<path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" />
<path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z" />
</svg>
);
const XIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2" />
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
);
export default function AvatarMentionChip({
label = "멘션 입력 시범",
people = ["편집자", "디자이너", "개발자", "기획자"],
initialChips = ["관리자"],
placeholder = "댓글을 입력하세요",
listLabel = "멘션할 사람",
removeLabel = "멘션 지우기",
duration = 2000,
easing = "ease-out",
color = "#2a5fd8",
}: AvatarMentionChipProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [value, setValue] = useState<string>("");
const [open, setOpen] = useState<boolean>(true);
const [active, setActive] = useState<number>(0);
const [chips, setChips] = useState<string[]>(initialChips);
// @ 를 치면 목록이 열리고, 이어 치는 글자로 이름을 걸러낸다
const at = value.lastIndexOf("@");
const visible = at < 0 ? [] : people.filter((n) => n.includes(value.slice(at + 1)));
const activeName = visible.length ? visible[Math.min(active, visible.length - 1)] : "";
const toneClasses: Record<string, string> = {
blue: styles.avBlue,
violet: styles.avViolet,
teal: styles.avTeal,
plum: styles.avPlum,
slate: styles.avSlate,
};
// 아바타 배경은 목록 순서로 붙고, 목록 밖 사람은 슬레이트
function toneOf(name: string): string {
const i = people.indexOf(name);
return i < 0 ? "slate" : ["blue", "violet", "teal", "plum", "slate"][i % 5];
}
function move(d: number) {
if (!visible.length) return;
setActive((a) => (a + d + visible.length) % visible.length);
}
// 고른 사람을 칩으로 입력칸 앞에 넣고 @질의를 지운다
function pick(name: string) {
setChips((c) => c.concat(name));
setValue("");
setOpen(false);
}
function onChange(e: InputEvt) {
setValue(e.target.value);
if (e.target.value.lastIndexOf("@") < 0) { setOpen(false); return; }
setOpen(true);
setActive(0);
}
function onKeyDown(e: KeyEvt) {
if (!open) return;
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); if (activeName) pick(activeName); }
else if (e.key === "Escape") { setOpen(false); }
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.mc} ${styles.isDemo}` : styles.mc}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.row}>
{chips.map((name) => (
<span className={styles.chip} key={name}>
<span className={`${styles.av} ${toneClasses[toneOf(name)]}`} aria-hidden="true">{initialOf(name)}</span>
<AtIcon />
<span className={styles.name}>{name}</span>
<button
className={styles.x}
type="button"
aria-label={removeLabel}
onClick={() => setChips((c) => c.filter((n) => n !== name))}
>
<XIcon />
</button>
</span>
))}
<input
className={styles.input}
type="text"
role="combobox"
aria-expanded={open ? "true" : "false"}
aria-controls="mc-list"
aria-autocomplete="list"
aria-activedescendant={activeName ? `mc-opt-${people.indexOf(activeName)}` : undefined}
placeholder={placeholder}
autoComplete="off"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
{/* 절대배치 목록 — 줄(position:relative) 바로 아래에 열리고 뿌리의 padding-bottom 이 자리를 잡는다 */}
<ul className={styles.list} id="mc-list" role="listbox" aria-label={listLabel} hidden={!open}>
{visible.map((name) => (
<li
key={name}
id={`mc-opt-${people.indexOf(name)}`}
role="option"
aria-selected={name === activeName ? "true" : "false"}
className={styles.opt}
onClick={() => pick(name)}
>
<span className={`${styles.av} ${toneClasses[toneOf(name)]}`} aria-hidden="true">{initialOf(name)}</span>
<span className={styles.name}>{name}</span>
</li>
))}
{/* 짚는 막대 — 격자 미리보기 루프에서만 움직인다 */}
<li className={styles.bar} aria-hidden="true" />
</ul>
</div>
</section>
);
}07 メンションチップ入力 — 計測 累積面積 21.972% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · プロフィール UI デザイン 9種 — 画像ファイルなし


