backtop —— 上に戻る
長いページを最後までスクロールしたユーザーが先頭に戻るには、ホイールを狂ったように回すしかありません。backtop は右下のフローティングボタンで、一定距離スクロールしたときだけ現れ、クリックすると滑らかに先頭へ戻ります。ロジックは複雑でありませんが、スクロール API の互換性の落とし穴が特に多いです——スクロールのルートは誰か、スムーズスクロールをどう降格させるか、スクロールの終了をどう検出するか。core/backtop がこれらをすべて引き受けます。
- 各端末で
<LhBackTop>を実装するケース:長いリスト、長いドキュメント、管理画面データテーブルの先頭戻り入口。 - ページの高さが明らかに 1 画面を超える(通常 2〜3 画面以上)ときに表示する価値があります。
- 使うべきでないケース:短いページ(1 画面に満たない。ボタンは永遠に現れず、コードの無駄);モバイルのネイティブページでホイール / ジェスチャーで素早く戻れるもの(モバイルのシステムジェスチャーがすでにカバーしている。h5 のインラインページはケースバイケース)。
インポート方法
Section titled “インポート方法”import { LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, LH_BACKTOP_SCROLLEND_DELAY, getBackTopClassNames, resolveScrollTarget, getScrollTop, backToTop, isBackTopVisible, supportsScrollEnd,} from '@lh-ui/share/core/backtop'スクロールルートの優先順位:document.scrollingElement を document.documentElement より優先します。これは潔癖症ではありません——一部のページ(body { overflow } を使っているもの)で本当にスクロールしているのは body で、documentElement.scrollTop は常に 0 になり、可視性の判定が完全に失效します。scrollingElement は W3C がこの混乱を統一するために発明した標準プロパティです。
getScrollTop は instanceof Window ではなく instanceof HTMLElement を使う:コメントには「realm をまたぐ安全性」と書かれています——ページに iframe がある場合、iframe が渡してきた Window オブジェクトは現在の realm の Window コンストラクタと等しくなく、x instanceof Window は誤って false になります。HTMLElement で判定すれば(要素にはこの問題がない)回避できます。非常に細かいですが、現実に存在する落とし穴です。
スムーズスクロールの 2 段階の降格:① prefers-reduced-motion が有効な場合は auto(アニメーションなし)に降格します——前庭機能障害のユーザーは継続するスクロールでめまいを感じます。これはアクセシビリティの必須要件です;② 対象要素に scrollTo メソッドがない(古い jsdom などの環境)場合は scrollTop = 0 を直接書き込みます。
supportsScrollEnd の機能検出:scrollend イベントは Chromium 114+ / Safari 18.2+ にしかありません——これを使えばスクロールが本当に静止したときに正確に可視状態を更新できます(スクロール中に頻繁に scrollTop を読むとパフォーマンスコストがかかる)。サポートされていない場合は、呼び出し側が scroll イベント + LH_BACKTOP_SCROLLEND_DELAY(150ms)のデバウンスにフォールバックして擬似的に再現します。
可視性は純関数:isBackTopVisible(scrollTop, height) は数値を比較するだけです——UI 層が自分でスクロールを監視し、自分で scrollTop を格納します。core はいかなるリアクティブな状態も持ちません。これにより単体テストは数値の境界を直接 assert できます。
| 関数 / 定数 | シグネチャ | 戻り値 | 役割 |
|---|---|---|---|
getBackTopClassNames |
(state: { visible }) => string[] |
クラス名 | 可視状態が表示/非表示アニメーションを駆動 |
resolveScrollTarget |
(target?: string | HTMLElement) => HTMLElement | Window |
スクロール対象 | セレクターの不一致時は window にフォールバック |
getScrollTop |
(target) => number |
スクロール距離 | コンテナ / ビューポートに適応 |
backToTop |
(target, smooth) => void |
なし | スムーズに先頭へ(降格を含む) |
isBackTopVisible |
(scrollTop, visibilityHeight) => boolean |
可視かどうか | 閾値の判定 |
supportsScrollEnd |
(target) => boolean |
サポートするか | 機能検出 |
定数:LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT = 200、LH_BACKTOP_SCROLLEND_DELAY = 150。
export interface LhBackTopState { visible: boolean // 現在可視かどうか(表示/非表示のクラス名を駆動) target?: string | HTMLElement visibilityHeight?: number smooth?: boolean // スムーズスクロールするか(デフォルト true)}アクセシビリティ
Section titled “アクセシビリティ”// ボタンのアクセシビリティ属性は UI 層で組み立てる:const aria = { type: 'button', 'aria-label': '上に戻る', // アイコンボタンにテキストはない。必ずラベルを与える}type="button":先頭戻りボタンが<form>の中にあると、デフォルトのtype="submit"がフォームの送信を引き起こします——これが最もよくある小バグです。- aria-label は必須:純粋なアイコンボタン(上矢印)はスクリーンリーダーにとって名無しです。
aria-label="上に戻る"があれば読み上げられます。 - aria-hidden と可視性の同期:不可視のときはアクセシビリティツリーから取り除きます(
aria-hidden="true"またはv-ifでアンマウント)。スクリーンリーダーが見えないボタンにナビゲートするのを防ぎます。 - キーボードで到達可能:ボタンはネイティブの
<button>です。Tab でフォーカス + Enter/Space でトリガーでき、天然でサポートします。<div @click>で作らないでください。 - prefers-reduced-motion の二重の意味:アクセシビリティの要件(前庭の安全)であると同時に、低スペック端末でのスムーズスクロールのもたつきを避けるものです。
backToTopの内部で処理済みで、UI 層は判定する必要がありません。
import { resolveScrollTarget, getScrollTop, isBackTopVisible, backToTop, supportsScrollEnd, LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, LH_BACKTOP_SCROLLEND_DELAY,} from '@lh-ui/share/core/backtop'
isBackTopVisible(200, 200) // true(>= 閾値)isBackTopVisible(199, 200) // falseisBackTopVisible(0, 200) // false
supportsScrollEnd(window) // モダンブラウザでは truesupportsScrollEnd(someEl) // Chromium 114+ / Safari 18.2+ でのみ true// 監視戦略:scrollend があれば静止時にだけ判定し、なければ scroll + デバウンスconst target = resolveScrollTarget(props.target)let visible = ref(false)
function refresh() { visible.value = isBackTopVisible( getScrollTop(target), props.visibilityHeight ?? LH_BACKTOP_VISIBILITY_HEIGHT_DEFAULT, )}
if (supportsScrollEnd(target)) { target.addEventListener('scrollend', refresh)} else { let timer: ReturnType<typeof setTimeout> | undefined target.addEventListener('scroll', () => { clearTimeout(timer) timer = setTimeout(refresh, LH_BACKTOP_SCROLLEND_DELAY) })}
function onClick() { backToTop(target, props.smooth ?? true)}<!-- Vue3 --><template> <button type="button" class="lh-backtop" :class="getBackTopClassNames({ visible })" aria-label="上に戻る" :aria-hidden="!visible" @click="backToTop(target, true)" > <LhIcon name="arrow-up" /> </button></template>// React:useEffect でスクロール監視を登録 / 解除(target と visibilityHeight に依存);// smooth は props で制御し、reduced-motion は core の内部で処理// uniapp:<button open-type?> は適用不可。<view> + @tap を使う;// ミニプログラム側のページスクロールは onPageScroll(uniapp 組み込み)、// 要素のスクロールは scroll-view の scroll イベントを使うスタイルとトークン
Section titled “スタイルとトークン”| クラス名 | 意味 |
|---|---|
.lh-backtop |
ボタンコンテナ(fixed 右下) |
.lh-backtop--visible |
可視(フェードイン / 上にスライドする入場アニメーションを駆動) |
デフォルトの非表示は opacity: 0; visibility: hidden(display ではなく visibility を使い、トランジションアニメーションを保持);円形ボタン --lh-backtop-size(例:40px);背景色 --lh-bg-color + シャドウ --lh-box-shadow-base;z-index --lh-z-index-backtop。
- スクロール監視は解除する:コンポーネントが破棄されるときに
removeEventListenerしないと、すでに取り除かれた DOM を参照したままになりメモリリークします。Vue のonUnmounted/ React の useEffect のクリーンアップを使います。 window監視の特殊性:scrollイベントは window ではパッシブかつ高頻度で、必ずデバウンスするか scrollend を使わなければなりません。毎回getScrollTopを読むと、極端に長いページではひっかかります。- 対象要素自身がスクロールするシーン:テーブル内部のスクロール(ページのスクロールではない)では
targetをテーブルコンテナに向けます——そうしないとボタンの可視性がページスクロールに連動し、実際に先頭に戻りたいコンテナと無関係になります。 - スムーズスクロールが CSS で上書きされる:ページに
html { scroll-behavior: smooth }があると、scrollTo({ behavior: 'auto' })が CSS に上書きされます——backToTopの reduced-motion の降格が効きません。厳密に降格させる必要がある場合、UI 層は一時的にその CSS を取り除きます。 - iOS Safari のスクロール慣性:iOS のスクロールには慣性があり、scrollend は慣性が終わったときにだけトリガーされます;デバウンスのフォールバック 150ms は高速にスワイプしたときに可視状態を早めに更新する(ボタンが一瞬ちらつく)可能性があります。これは既知の妥協です。
- 可視閾値 200 の意味:約半画面の高さ(ビューポートの高さによる)。ドキュメント系のページでは小さく(100)、ツール系のページでは大きく(500)できます——
visibilityHeightprop がそのためのものです。 - ボタンがコンテンツを遮らないように:fixed 配置 + 右下。モバイルではボトムナビゲーションバーを避けてください(安全域の高さを空ける)。
@lh-ui/share/core/backtop → isBackTopVisible( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue