diff --git a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.css b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.css index a10a49b4b64..4a7e9e9cc34 100644 --- a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.css +++ b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.css @@ -66,3 +66,36 @@ color: rgba(255, 255, 255, 0.85); max-width: 320px; } + +.dismiss-button { + margin-top: 1.75rem; + /* + * フィッツの法則のタッチターゲット最低 44x44px を確保。 + * 紫グラデーション背景に対して白枠 + 白テキストで視認性を確保する。 + */ + min-height: 44px; + min-width: 44px; + padding: 0.625rem 1.5rem; + background: rgba(255, 255, 255, 0.12); + color: white; + font-size: 1rem; + font-weight: bold; + border: 2px solid rgba(255, 255, 255, 0.9); + border-radius: 999px; + cursor: pointer; + font-family: inherit; + line-height: 1.2; + transition: + background-color 0.15s ease, + transform 0.15s ease; +} + +.dismiss-button:hover, +.dismiss-button:focus { + background: rgba(255, 255, 255, 0.22); + outline: none; +} + +.dismiss-button:active { + transform: scale(0.97); +} diff --git a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx index 371978eed53..ac887a9f6ab 100644 --- a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx +++ b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx @@ -1,10 +1,20 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { defineMessages, FormattedMessage } from 'react-intl'; import styles from './mobile-orientation-gate.css'; const PORTRAIT_QUERY = '(orientation: portrait)'; +/** + * sessionStorage key — true なら現在のブラウザセッション中は警告を表示しない。 + * + * sessionStorage を使う理由: PC でウィンドウを縦長にリサイズしただけで警告が + * 出るユースケースで dismiss できるようにしつつ、実機スマホで誤タップしても + * リロード or 次回起動で復活させたい。永続化 (localStorage) は意図せず警告が + * 消えたままになるのを避けるため採用しない。 + */ +const DISMISS_STORAGE_KEY = 'smalruby:mobileOrientationGateDismissed'; + const messages = defineMessages({ title: { defaultMessage: 'Please rotate your device', @@ -21,6 +31,11 @@ const messages = defineMessages({ description: 'Mobile orientation gate note for iOS users about orientation lock', id: 'gui.mobile.orientation.iosNote', }, + dismiss: { + defaultMessage: 'Use as is', + description: 'Mobile orientation gate button to dismiss the warning for the current session', + id: 'gui.mobile.orientation.dismiss', + }, }); /** @@ -37,7 +52,7 @@ const usePortraitOrientation = () => { useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}; const mql = window.matchMedia(PORTRAIT_QUERY); - const handler = event => setIsPortrait(event.matches); + const handler = (event) => setIsPortrait(event.matches); if (typeof mql.addEventListener === 'function') { mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); @@ -49,6 +64,21 @@ const usePortraitOrientation = () => { return isPortrait; }; +/** + * sessionStorage に保持された dismiss 状態を読む。SSR / sessionStorage が + * 使えない環境では false を返す。 + * @returns {boolean} dismiss 済みなら true + */ +const readDismissedFromStorage = () => { + if (typeof window === 'undefined' || !window.sessionStorage) return false; + try { + return window.sessionStorage.getItem(DISMISS_STORAGE_KEY) === 'true'; + } catch (e) { + // sessionStorage が disable な環境 (Safari private mode 等) では throw する + return false; + } +}; + /** * 縦向き (portrait) の時だけフルスクリーンオーバーレイを表示して、 * 横向きにするよう案内するゲート。 @@ -58,10 +88,14 @@ const usePortraitOrientation = () => { * どうしてもボタンが画面外にはみ出してしまう (524px / 600px+ の min-width * 群が編集機能の核なので削れない) * - スマホは横向き運用に割り切ることで開発コストを抑える + * - PC ブラウザでウィンドウを縦長にリサイズした場合も発火するので、ユーザーが + * 「このまま使う」で当該セッション中は閉じられるようにしている * * 動作: * - `(orientation: portrait)` メディアクエリで横向き / 縦向きをリアルタイム検出 * - 縦向き → オーバーレイ表示、横向き → オーバーレイ消失 + * - 「このまま使う」ボタン押下で sessionStorage に dismiss フラグを書き、 + * 同一セッション中は再表示しない (リロードで復活) * - 自動回転: PWA (manifest `orientation: landscape-primary`) ホーム画面起動時のみ * 有効 (Android Chrome / iOS の home screen)。通常の Safari タブでは * ユーザーが手動で回転する必要がある。 @@ -76,8 +110,20 @@ const usePortraitOrientation = () => { */ const MobileOrientationGate = () => { const isPortrait = usePortraitOrientation(); + const [dismissed, setDismissed] = useState(readDismissedFromStorage); + const handleDismiss = useCallback(() => { + setDismissed(true); + if (typeof window !== 'undefined' && window.sessionStorage) { + try { + window.sessionStorage.setItem(DISMISS_STORAGE_KEY, 'true'); + } catch (e) { + // sessionStorage に書けなくても dismiss 自体は state で機能する + } + } + }, []); if (typeof document === 'undefined') return null; if (!isPortrait) return null; + if (dismissed) return null; return createPortal(
, document.body, ); }; export default MobileOrientationGate; -export { usePortraitOrientation }; +export { DISMISS_STORAGE_KEY, usePortraitOrientation }; diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 69bd73e44e6..e69bdf98323 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -1016,4 +1016,5 @@ export default { 'gui.mobile.orientation.body': 'スマホでは よこむきで つかってください。', 'gui.mobile.orientation.iosNote': 'iPhone のばあいは、コントロールセンターから「がめんのむきロック」を かいじょしてから よこにしてください。', + 'gui.mobile.orientation.dismiss': 'このまま つかう', }; diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 0bc1e08feff..0a018d67650 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -992,4 +992,5 @@ export default { 'gui.mobile.orientation.body': 'スマホでは横向きでお使いください。', 'gui.mobile.orientation.iosNote': 'iPhone の場合は、コントロールセンターから「画面の向きロック」を解除してから横にしてください。', + 'gui.mobile.orientation.dismiss': 'このまま使う', }; diff --git a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx index ce5c9f26d1b..7435c8d2cf7 100644 --- a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx @@ -1,9 +1,11 @@ /* eslint-env jest */ import '@testing-library/jest-dom'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import React from 'react'; import { IntlProvider } from 'react-intl'; -import MobileOrientationGate from '../../../src/components/mobile-orientation-gate/mobile-orientation-gate.jsx'; +import MobileOrientationGate, { + DISMISS_STORAGE_KEY, +} from '../../../src/components/mobile-orientation-gate/mobile-orientation-gate.jsx'; /** * `(orientation: portrait)` の `matchMedia` をテスト用に差し替えるヘルパ。 @@ -46,6 +48,10 @@ const renderWithIntl = (ui) => ); describe('MobileOrientationGate', () => { + afterEach(() => { + window.sessionStorage.clear(); + }); + test('does not render the overlay when in landscape', () => { const mm = installMatchMedia(); try { @@ -82,4 +88,66 @@ describe('MobileOrientationGate', () => { mm.restore(); } }); + + test('shows a dismiss button when in portrait', () => { + const mm = installMatchMedia(); + mm.setMatches(true); + try { + const { getByTestId } = renderWithIntl(); + expect(getByTestId('mobile-orientation-gate-dismiss')).toBeInTheDocument(); + } finally { + mm.restore(); + } + }); + + test('hides the overlay when the dismiss button is clicked', () => { + const mm = installMatchMedia(); + mm.setMatches(true); + try { + const { getByTestId, queryByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-orientation-gate-dismiss')); + expect(queryByTestId('mobile-orientation-gate')).not.toBeInTheDocument(); + } finally { + mm.restore(); + } + }); + + test('persists the dismiss in sessionStorage', () => { + const mm = installMatchMedia(); + mm.setMatches(true); + try { + const { getByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-orientation-gate-dismiss')); + expect(window.sessionStorage.getItem(DISMISS_STORAGE_KEY)).toBe('true'); + } finally { + mm.restore(); + } + }); + + test('does not render the overlay when sessionStorage has the dismiss flag', () => { + window.sessionStorage.setItem(DISMISS_STORAGE_KEY, 'true'); + const mm = installMatchMedia(); + mm.setMatches(true); + try { + const { queryByTestId } = renderWithIntl(); + expect(queryByTestId('mobile-orientation-gate')).not.toBeInTheDocument(); + } finally { + mm.restore(); + } + }); + + test('stays dismissed when orientation toggles after a dismiss click', () => { + const mm = installMatchMedia(); + mm.setMatches(true); + try { + const { getByTestId, queryByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-orientation-gate-dismiss')); + // rotate to landscape, then back to portrait + act(() => mm.setMatches(false)); + act(() => mm.setMatches(true)); + expect(queryByTestId('mobile-orientation-gate')).not.toBeInTheDocument(); + } finally { + mm.restore(); + } + }); });