08保存先コレクションを選ぶポップオーバー
長押しすると保存可能な一覧名が小さなパネルで開き、一つを選ぶと折りたたまれて閉じる。
使いどころレシピサイトのフォルダ別保存ボタン
LONGPRESS_MSPOPOVER_STAGGER_MSselectCollection
<main class="stage">
<div class="cp is-demo" id="cp">
<p class="cp__caption">フォルダに保存</p>
<div class="cp__scene" id="cpScene">
<button class="cp__btn" id="cpBtn" type="button" aria-pressed="false" aria-expanded="false" aria-haspopup="true">
<svg class="cp__icon" viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
<path class="cp__shape" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</svg>
</button>
<div class="cp__panel" id="cpPanel" role="menu">
<p class="cp__panelTitle">保存するリストを選ぶ</p>
<button class="cp__item" type="button" role="menuitem" data-name="リーディングリスト">リーディングリスト</button>
<button class="cp__item" type="button" role="menuitem" data-name="あとで読む">あとで読む</button>
<button class="cp__item" type="button" role="menuitem" data-name="料理レシピ">料理レシピ</button>
</div>
</div>
<p class="cp__status" id="cpStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.cp {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
height: max-content;
cursor: pointer;
color: $color;
box-shadow: $shadow-raised;
}
.cp__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed="true"] .cp__shape {
fill: $color;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: $sp-2;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 $sp-1;
font-size: 11px;
font-weight: 700;
color: $subject-blue-deep;
}
.cp__item {
border: 0;
border-radius: $r-sm;
background: $subject-sky;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: $subject-blue-light;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene { width: 200px; height: 100px; }
.cp__btn { padding: $sp-3; }
.cp__panel {
top: 42px;
width: 180px;
padding: $sp-2;
display: flex;
flex-wrap: wrap;
gap: $sp-1;
justify-content: center;
}
.cp__panelTitle { display: none; }
.cp__item { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__item {
transition: none;
}
}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);
}
.cp {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
height: max-content;
cursor: pointer;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cp__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed=true] .cp__shape {
fill: #0a4fe8;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 4px;
font-size: 11px;
font-weight: 700;
color: #0a4fe8;
}
.cp__item {
border: 0;
border-radius: 8px;
background: #6ec8ff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #17141a;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: #5b8cf8;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene {
width: 200px;
height: 100px;
}
.cp__btn {
padding: 12px;
}
.cp__panel {
top: 42px;
width: 180px;
padding: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.cp__panelTitle {
display: none;
}
.cp__item {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__item {
transition: none;
}
}(function () {
"use strict";
var LONGPRESS_MS = 420;
var POPOVER_STAGGER_MS = 45;
var POPOVER_ITEM_MS = 210;
var CYCLE_S = 2;
var root = document.getElementById("cp");
var btn = document.getElementById("cpBtn");
var panel = document.getElementById("cpPanel");
var items = Array.prototype.slice.call(panel.querySelectorAll(".cp__item"));
var status = document.getElementById("cpStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var open = false;
var pressStart = null;
var pressing = false;
function setOpen(v, now) {
open = v;
btn.setAttribute("aria-expanded", String(v));
panel.classList.toggle("cp__panel--open", v);
if (v) { openStart = now; }
else { openStart = null; }
}
var openStart = null;
// 값 하나: 목록 항목 인덱스 × POPOVER_STAGGER_MS = 그 항목이 열리기 시작하는 지연.
function renderPanel(now) {
if (openStart === null && !open) {
items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
return;
}
var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
items.forEach(function (el, i) {
var delay = i * POPOVER_STAGGER_MS;
var raw = (elapsed - delay) / POPOVER_ITEM_MS;
var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
var eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(name) {
status.textContent = "保存済み";
btn.setAttribute("aria-pressed", "true");
root.classList.add("cp__scene--picked");
setOpen(false);
}
function focusItem(i) {
var idx = ((i % items.length) + items.length) % items.length;
items[idx].focus({ preventScroll: true });
}
items.forEach(function (el, i) {
el.addEventListener("click", function () {
root.classList.remove("is-demo");
selectCollection(el.getAttribute("data-name"));
btn.focus({ preventScroll: true });
});
el.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); focusItem(i + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusItem(i - 1); }
else if (e.key === "Escape") {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
btn.focus({ preventScroll: true });
}
});
});
function shortSave() {
var v = btn.getAttribute("aria-pressed") !== "true";
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "保存済み" : "未保存";
}
btn.addEventListener("pointerdown", function () {
root.classList.remove("is-demo");
pressing = true;
pressStart = null;
});
btn.addEventListener("pointerup", function () {
// longpress 임계(LONGPRESS_MS)를 못 넘기고 뗀 경우에만 짧은 저장 토글로 처리한다
if (pressing && !open) { shortSave(); }
pressing = false;
pressStart = null;
});
btn.addEventListener("pointerleave", function () { pressing = false; pressStart = null; });
btn.addEventListener("keydown", function (e) {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (e.key === "ArrowDown" || (e.key === "Enter" && e.shiftKey)) {
e.preventDefault();
root.classList.remove("is-demo");
if (!open) { setOpen(true, performance.now()); }
focusItem(0);
} else if (e.key === "Escape" && open) {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
}
});
btn.addEventListener("click", function (e) {
// pointerdown/up longpress 경로와 중복되지 않도록: longpress 로 이미 열렸으면 click 무시
if (open) { e.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (e.detail === 0) { root.classList.remove("is-demo"); shortSave(); }
});
var start = null;
var prevT = 0;
var cycleOpened = false;
var cyclePicked = false;
function loop(now) {
if (start === null) start = now;
if (pressing && pressStart === null) { pressStart = now; }
if (pressing && pressStart !== null && !open && (now - pressStart) > LONGPRESS_MS) {
setOpen(true, now);
}
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection("リーディングリスト"); }
}
renderPanel(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// CollectionPickerPopoverBookmark — vanilla 판(run/416/demo/08-bookmark-btn-collection-picker-popover/index.html) 의 React 포트.
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
import { useEffect, useRef, useState } from "react";
import styles from "./CollectionPickerPopoverBookmark.module.scss";
type Box<T> = { current: T };
const LONGPRESS_MS = 420;
const POPOVER_STAGGER_MS = 45;
const POPOVER_ITEM_MS = 210;
const CYCLE_S = 2;
const NAMES = ["읽기 목록", "나중에 볼 글", "요리 레시피"];
export default function CollectionPickerPopoverBookmark() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const openStartRef = useRef<number | null>(null);
const pressStartRef = useRef<number | null>(null);
const pressingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [status, setStatus] = useState("저장 전");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setOpen(v: boolean, now: number): void {
if (btnRef.current) btnRef.current.setAttribute("aria-expanded", String(v));
if (panelRef.current) panelRef.current.classList.toggle(styles.panelOpen, v);
openStartRef.current = v ? now : null;
}
function renderPanel(now: number): void {
const items = itemRefs.current;
if (openStartRef.current === null) {
items.forEach((el) => {
if (!el) return;
el.style.opacity = "0";
el.style.transform = "translateY(6px)";
});
return;
}
const elapsed = now - openStartRef.current;
items.forEach((el, i) => {
if (!el) return;
const delay = i * POPOVER_STAGGER_MS;
const raw = (elapsed - delay) / POPOVER_ITEM_MS;
const p = Math.max(0, Math.min(1, reduce ? 1 : raw));
const eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(now: number): void {
setStatus("저장됨");
setPressed(true);
setOpen(false, now);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let cycleOpened = false;
let cyclePicked = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pressingRef.current && pressStartRef.current === null) { pressStartRef.current = now; }
if (pressingRef.current && pressStartRef.current !== null && openStartRef.current === null && (now - pressStartRef.current) > LONGPRESS_MS) {
setOpen(true, now);
}
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && demo) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection(now); }
}
renderPanel(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onItemClick(now: number): void {
setDemo(false);
selectCollection(now);
btnRef.current?.focus({ preventScroll: true });
}
function focusItem(i: number): void {
const items = itemRefs.current;
const idx = ((i % items.length) + items.length) % items.length;
items[idx]?.focus({ preventScroll: true });
}
function shortSave(): void {
const v = !pressed;
setPressed(v);
setStatus(v ? "저장됨" : "저장 전");
}
function onPointerDown(): void {
setDemo(false);
pressingRef.current = true;
pressStartRef.current = null;
}
function onPointerUp(): void {
if (pressingRef.current && openStartRef.current === null) { shortSave(); }
pressingRef.current = false;
pressStartRef.current = null;
}
function onPointerLeave(): void {
pressingRef.current = false;
pressStartRef.current = null;
}
function onBtnClick(event: { detail: number; preventDefault: () => void }): void {
if (openStartRef.current !== null) { event.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (event.detail === 0) { setDemo(false); shortSave(); }
}
function onBtnKeyDown(event: { key: string; shiftKey: boolean; preventDefault: () => void }): void {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (event.key === "ArrowDown" || (event.key === "Enter" && event.shiftKey)) {
event.preventDefault();
setDemo(false);
if (openStartRef.current === null) { setOpen(true, performance.now()); }
focusItem(0);
} else if (event.key === "Escape" && openStartRef.current !== null) {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
}
}
function onItemKeyDown(i: number, event: { key: string; preventDefault: () => void }): void {
if (event.key === "ArrowDown") { event.preventDefault(); focusItem(i + 1); }
else if (event.key === "ArrowUp") { event.preventDefault(); focusItem(i - 1); }
else if (event.key === "Escape") {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
btnRef.current?.focus({ preventScroll: true });
}
}
return (
<main className={styles.stage}>
<div className={styles.cp}>
<p className={styles.caption}>폴더에 저장</p>
<div className={styles.scene}>
<button
className={styles.btn}
ref={btnRef}
type="button"
aria-pressed={pressed}
aria-expanded={false}
aria-haspopup="true"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
onClick={onBtnClick}
onKeyDown={onBtnKeyDown}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={28} height={28} aria-hidden="true" focusable="false">
<path className={styles.shape} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</svg>
</button>
<div className={styles.panel} ref={panelRef} role="menu">
<p className={styles.panelTitle}>저장할 목록 고르기</p>
{NAMES.map((name, i) => (
<button
key={name}
className={styles.item}
type="button"
role="menuitem"
ref={(el: HTMLButtonElement | null) => { itemRefs.current[i] = el; }}
onClick={() => onItemClick(performance.now())}
onKeyDown={(event: { key: string; preventDefault: () => void }) => onItemKeyDown(i, event)}
>
{name}
</button>
))}
</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}08 保存先コレクションを選ぶポップオーバー — 計測 累積面積 31.662% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · ブックマーク ボタン html 実装 9パターン


