Files
agomizi1cmandClaude Opus 5.5 ed3d1e437e Show the seating editor as a centered popup
Add Fluid.Popup, a centered dialog with the same open/close API as
Sheet: it scales in like the confirm alert, fades out in place, and
closes on backdrop tap or Escape with focus trapped inside. The host's
seating editor now uses it instead of a bottom sheet; its list scrolls
inside the popup on short screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:10 +09:00

641 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 動きと手ざわりの共通部品。
// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
// - Popup: 画面の中央に出るポップアップ(背景を押す・Esc で閉じる)
// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
(function () {
'use strict';
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const reducedMotion = () => motionQuery.matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
// ---------- ばね ----------
const running = new Set();
let frame = 0;
let lastTime = 0;
function tick(now) {
// タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
const dt = clamp((now - lastTime) / 1000, 0, 0.05);
lastTime = now;
for (const s of [...running]) s.step(dt);
frame = running.size ? requestAnimationFrame(tick) : 0;
}
class Spring {
// opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
constructor(value = 0, opts = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.precision = opts.precision || 0.01;
this.onUpdate = opts.onUpdate || null;
this.onRest = null;
this.configure(opts.damping ?? 1, opts.response ?? 0.4);
}
// 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
// damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
configure(damping, response) {
this.damping = damping;
this.response = response;
this.stiffness = ((2 * Math.PI) / response) ** 2;
this.friction = (4 * Math.PI * damping) / response;
}
get animating() {
return running.has(this);
}
// 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
// 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
to(target, opts = {}) {
if (opts.damping != null || opts.response != null) {
this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
}
if (opts.velocity != null) this.velocity = opts.velocity;
this.target = target;
this.onRest = opts.onRest || null;
if (reducedMotion() && !opts.allowReduced) {
this.set(target);
this.rest();
return this;
}
running.add(this);
if (!frame) {
lastTime = performance.now();
frame = requestAnimationFrame(tick);
}
return this;
}
// すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
set(value) {
running.delete(this);
this.value = this.target = value;
this.velocity = 0;
if (this.onUpdate) this.onUpdate(value);
return this;
}
// 画面上のいまの位置で止める(動いている最中につかんだとき)
stop() {
running.delete(this);
this.target = this.value;
this.velocity = 0;
this.onRest = null;
return this;
}
step(dt) {
// 硬いばねでも発散しないよう 1/240 秒刻みで積分する
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i++) {
const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
this.velocity += force * h;
this.value += this.velocity * h;
}
const settled =
Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
if (settled) {
this.value = this.target;
this.velocity = 0;
running.delete(this);
}
if (this.onUpdate) this.onUpdate(this.value);
if (settled) this.rest();
}
rest() {
const fn = this.onRest;
this.onRest = null;
if (fn) fn();
}
}
// ---------- 指の動き ----------
// 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
class VelocityTracker {
constructor() {
this.samples = [];
}
reset() {
this.samples.length = 0;
}
add(x, y, t = performance.now()) {
this.samples.push({ x, y, t });
while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
}
velocity() {
const s = this.samples;
if (s.length < 2) return { x: 0, y: 0 };
const a = s[0];
const b = s[s.length - 1];
const dt = (b.t - a.t) / 1000;
return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
}
}
// 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
function project(velocity, decelerationRate = 0.998) {
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
}
// 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
function rubberband(overshoot, dimension, constant = 0.55) {
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
}
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
function trapFocus(box, e) {
const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (!box.contains(document.activeElement)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ---------- シート ----------
// root は背景(.sheet-backdrop)、中に .sheet。
// [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
// 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
// opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Sheet(root, opts = {}) {
const sheet = root.querySelector('.sheet');
const dismissible = opts.dismissible !== false;
const y = new Spring(0, { precision: 0.5 });
const tracker = new VelocityTracker();
let height = 0;
let isOpen = false;
let prevFocus = null;
let drag = null;
let downOnBackdrop = false;
const measure = () => {
height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
};
y.onUpdate = (v) => {
sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
};
function onKey(e) {
if (e.key === 'Escape' && dismissible) {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(sheet, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
if (root.hidden) {
root.hidden = false;
measure();
y.set(height);
}
y.to(0, { damping: 1, response: 0.38 });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? sheet.querySelector(opts.focus) : null;
(target || sheet).focus({ preventScroll: true });
}
function close(velocity) {
if (!isOpen) return;
isOpen = false;
document.removeEventListener('keydown', onKey);
measure();
y.to(height, {
damping: 1,
response: 0.3,
velocity: typeof velocity === 'number' ? velocity : undefined,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (dismissible && e.target === root && downOnBackdrop) close();
});
sheet.addEventListener('pointerdown', (e) => {
if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
if (!e.target.closest('[data-sheet-drag]')) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, e.clientY, e.timeStamp);
});
sheet.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
if (!drag.active) {
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (Math.hypot(dx, dy) < 8) return;
if (Math.abs(dx) > Math.abs(dy)) {
drag = null;
return;
}
// ここで初めてつかむ。動いている最中なら画面上のいまの位置から
drag.active = true;
drag.y = e.clientY;
y.stop();
drag.origin = y.value;
measure();
sheet.setPointerCapture(e.pointerId);
sheet.classList.add('dragging');
if (!isOpen) {
isOpen = true;
document.addEventListener('keydown', onKey);
}
}
const raw = drag.origin + (e.clientY - drag.y);
y.set(raw < 0 ? -rubberband(-raw, height) : raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
sheet.classList.remove('dragging');
if (!wasActive) return;
tracker.add(e.clientX, e.clientY, e.timeStamp);
const v = tracker.velocity().y;
if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
// 払った勢いがあるので少しだけ弾ませて戻す
else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
};
sheet.addEventListener('pointerup', end);
sheet.addEventListener('pointercancel', end);
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- ポップアップ ----------
// root は背景(.popup-backdrop)、中に .popup。画面の中央に少し大きい所から縮んで現れ、閉じるときはその場で薄くなる
// (App.confirm のアラートと同じ動き)。背景を押す・Esc で閉じる。
// opts: { focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
function Popup(root, opts = {}) {
const box = root.querySelector('.popup');
const appear = new Spring(0, { precision: 0.002 });
let isOpen = false;
let closing = false;
let prevFocus = null;
let downOnBackdrop = false;
appear.onUpdate = (v) => {
root.style.setProperty('--appear', v.toFixed(3));
box.style.opacity = v >= 1 ? '' : v.toFixed(3);
box.style.transform = closing || v >= 1 || reducedMotion() ? '' : `scale(${(1.08 - 0.08 * v).toFixed(4)})`;
};
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Tab') {
trapFocus(box, e);
}
}
function open() {
if (isOpen) return;
isOpen = true;
closing = false;
if (root.hidden) {
root.hidden = false;
appear.set(0);
}
root.style.pointerEvents = '';
appear.to(1, { damping: 1, response: 0.32, allowReduced: true });
prevFocus = document.activeElement;
document.addEventListener('keydown', onKey);
const target = opts.focus ? box.querySelector(opts.focus) : null;
(target || box).focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
closing = true;
document.removeEventListener('keydown', onKey);
root.style.pointerEvents = 'none'; // 消えている途中の背景で次の操作を止めない
appear.to(0, {
damping: 1,
response: 0.2,
allowReduced: true,
onRest: () => {
if (isOpen) return;
root.hidden = true;
if (opts.onClosed) opts.onClosed();
},
});
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
if (opts.onClose) opts.onClose();
}
// 背景を押したら閉じる(ポップアップの中から背景へのドラッグは除く)
root.addEventListener('pointerdown', (e) => {
downOnBackdrop = e.target === root;
});
root.addEventListener('click', (e) => {
if (e.target === root && downOnBackdrop) close();
});
return {
open,
close,
get isOpen() {
return isOpen;
},
};
}
// ---------- セグメント ----------
// container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
// 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
// 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
function Segmented(container) {
const thumb = document.createElement('span');
thumb.className = 'segmented-thumb';
thumb.setAttribute('aria-hidden', 'true');
container.prepend(thumb);
container.classList.add('has-thumb');
const x = new Spring(0, { precision: 0.1 });
const w = new Spring(0, { precision: 0.1 });
const press = new Spring(1, { precision: 0.001 });
const tracker = new VelocityTracker();
let placed = false;
let drag = null;
let suppressClick = false;
const paint = () => {
thumb.style.width = `${w.value.toFixed(2)}px`;
thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
};
x.onUpdate = w.onUpdate = press.onUpdate = paint;
const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
const isSelected = (el) =>
el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
const center = (el) => el.offsetLeft + el.offsetWidth / 2;
function sync(animate = true) {
if (drag && drag.active) return;
const el = items().find(isSelected);
thumb.hidden = !el;
if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
if (animate && placed) {
if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
} else {
x.set(el.offsetLeft);
w.set(el.offsetWidth);
}
placed = true;
}
function select(el) {
if (isSelected(el)) return;
if (el.matches('label')) {
const input = el.querySelector('input');
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.click();
}
}
container.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const el = items().find((i) => i.contains(e.target));
if (!el || !isSelected(el)) return;
drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
tracker.reset();
tracker.add(e.clientX, 0, e.timeStamp);
press.to(0.95, { damping: 1, response: 0.2 });
});
container.addEventListener('pointermove', (e) => {
if (!drag || e.pointerId !== drag.id) return;
tracker.add(e.clientX, 0, e.timeStamp);
if (!drag.active) {
if (Math.abs(e.clientX - drag.x) < 6) return;
drag.active = true;
drag.x = e.clientX;
x.stop();
drag.origin = x.value;
container.setPointerCapture(e.pointerId);
}
const list = items();
const last = list[list.length - 1];
const min = list[0].offsetLeft;
const max = last.offsetLeft + last.offsetWidth - w.value;
let raw = drag.origin + (e.clientX - drag.x);
if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
x.set(raw);
});
const end = (e) => {
if (!drag || e.pointerId !== drag.id) return;
const wasActive = drag.active;
drag = null;
press.to(1, { damping: 1, response: 0.25 });
if (!wasActive) return;
suppressClick = true;
setTimeout(() => {
suppressClick = false;
}, 0);
tracker.add(e.clientX, 0, e.timeStamp);
const v = tracker.velocity().x;
const aim = x.value + w.value / 2 + project(v, 0.99);
const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
w.to(best.offsetWidth, { damping: 1, response: 0.3 });
select(best);
};
container.addEventListener('pointerup', end);
container.addEventListener('pointercancel', end);
// ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
container.addEventListener(
'click',
(e) => {
if (!suppressClick) return;
e.preventDefault();
e.stopPropagation();
},
true
);
container.addEventListener('change', () => sync());
// ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
container.addEventListener('click', () => requestAnimationFrame(() => sync()));
if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
sync(false);
return { update: sync };
}
// ---------- 出し入れ ----------
// 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
// from: { scale, y }。視差効果を減らす設定では透明度だけ変える
function presence(el, show, from = {}) {
let p = el._presence;
if (!p) {
p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
el.hidden = !show;
}
const scale = from.scale ?? 0.96;
const dy = from.y ?? 8;
p.onUpdate = (v) => {
el.style.opacity = v >= 1 ? '' : v.toFixed(3);
el.style.transform =
v >= 1 || reducedMotion()
? ''
: `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
};
if (show) {
if (el.hidden) {
el.hidden = false;
p.set(0);
}
if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
} else if (!el.hidden && p.target !== 0) {
p.to(0, {
damping: 1,
response: 0.22,
allowReduced: true,
onRest: () => {
el.hidden = true;
},
});
}
}
// 数字をばねで変える(ポット・スタックなど)
function countTo(el, value, format) {
const show = (v) => {
el.textContent = format(Math.round(v));
};
let s = el._count;
if (!s) {
s = el._count = new Spring(value, { precision: 0.5 });
s.onUpdate = show;
show(value);
return;
}
s.onUpdate = show;
if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
}
// 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
function shake(el) {
if (reducedMotion()) return;
let s = el._shake;
if (!s) {
s = el._shake = new Spring(0, { precision: 0.05 });
s.onUpdate = (v) => {
el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
};
}
s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
}
// 振動(対応している端末だけ。画面に触れる前は振動できない)
function haptic(pattern = 10) {
try {
const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
if (navigator.vibrate && touched) navigator.vibrate(pattern);
} catch {
/* 振動できない環境 */
}
}
// ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
// 大きな見出しが隠れたらバーの中に小さな見出しを出す
function navBar(nav, largeTitle) {
if (!nav) return;
const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
update();
window.addEventListener('scroll', update, { passive: true });
const inline = nav.querySelector('.nav-title');
if (!largeTitle || !inline) return;
const mirror = () => {
inline.textContent = largeTitle.textContent;
};
mirror();
new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
if ('IntersectionObserver' in window) {
new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
}).observe(largeTitle);
}
}
// ---------- 拡大させない ----------
// iOS Safari は viewport の user-scalable=no を無視するので、ピンチは gesture* イベントと
// 2本指の touchmove を止めて防ぐ。ダブルタップの拡大は CSS の touch-action で止めている
// (JS で 2回目のタップを止めると、素早い連打のクリックまで消えてしまうため)
const stopZoom = (e) => e.preventDefault();
for (const type of ['gesturestart', 'gesturechange', 'gestureend']) {
document.addEventListener(type, stopZoom, { passive: false });
}
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length > 1) e.preventDefault();
},
{ passive: false },
);
window.Fluid = {
Spring,
VelocityTracker,
project,
rubberband,
clamp,
reducedMotion,
Sheet,
Popup,
Segmented,
presence,
countTo,
shake,
haptic,
navBar,
};
})();