Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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',
},
});

/**
Expand All @@ -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);
Expand All @@ -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) の時だけフルスクリーンオーバーレイを表示して、
* 横向きにするよう案内するゲート。
Expand All @@ -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 タブでは
* ユーザーが手動で回転する必要がある。
Expand All @@ -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(
<div className={styles.overlay} role="alert" data-testid="mobile-orientation-gate">
<div className={styles.iconRow} aria-hidden="true">
Expand All @@ -92,10 +138,18 @@ const MobileOrientationGate = () => {
<div className={styles.note}>
<FormattedMessage {...messages.iosNote} />
</div>
<button
type="button"
className={styles.dismissButton}
onClick={handleDismiss}
data-testid="mobile-orientation-gate-dismiss"
>
<FormattedMessage {...messages.dismiss} />
</button>
</div>,
document.body,
);
};

export default MobileOrientationGate;
export { usePortraitOrientation };
export { DISMISS_STORAGE_KEY, usePortraitOrientation };
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja-Hira.js
Original file line number Diff line number Diff line change
Expand Up @@ -1016,4 +1016,5 @@ export default {
'gui.mobile.orientation.body': 'スマホでは よこむきで つかってください。',
'gui.mobile.orientation.iosNote':
'iPhone のばあいは、コントロールセンターから「がめんのむきロック」を かいじょしてから よこにしてください。',
'gui.mobile.orientation.dismiss': 'このまま つかう',
};
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -992,4 +992,5 @@ export default {
'gui.mobile.orientation.body': 'スマホでは横向きでお使いください。',
'gui.mobile.orientation.iosNote':
'iPhone の場合は、コントロールセンターから「画面の向きロック」を解除してから横にしてください。',
'gui.mobile.orientation.dismiss': 'このまま使う',
};
Original file line number Diff line number Diff line change
@@ -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` をテスト用に差し替えるヘルパ。
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(<MobileOrientationGate />);
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(<MobileOrientationGate />);
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(<MobileOrientationGate />);
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(<MobileOrientationGate />);
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(<MobileOrientationGate />);
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();
}
});
});
Loading