diff --git a/docs/maintenance/smalruby-markers-gui.md b/docs/maintenance/smalruby-markers-gui.md index ea58b1fc7ce..0c51cf28160 100644 --- a/docs/maintenance/smalruby-markers-gui.md +++ b/docs/maintenance/smalruby-markers-gui.md @@ -62,6 +62,8 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/components/menu-bar/menu-bar.jsx` | classroom button | クラスルームボタンの import、レンダリング、Redux 接続 | | `src/components/menu-bar/menu-bar.jsx` | welcome tooltip | About (`?`) ボタンの左隣にウェルカムバルーンを描画。`buildAboutMenu` 内に `WelcomeTooltip` 配置 + `position: relative` 化、`handleClickWelcomeTooltip` ハンドラ追加、`onShowWelcomeModal` 用 mapDispatchToProps 追加 | | `src/components/menu-bar/settings-menu.jsx` | classroom management menu | クラス管理メニューアイテムの import、レンダリング、Redux 接続 | +| `src/components/menu-bar/settings-menu.jsx` | display mode menu | 表示モード (自動/PC/スマホ) 切替。テーマ/Ruby と同じ `PreferenceMenu` サブメニュー形式 + 専用アイコン。`useDisplayMode` hook + `persistDisplayMode` の import、ハンドラ、Redux (open/close) 接続、レンダリング (Issue #865) | +| `src/reducers/menus.js` | display mode menu | `PreferenceMenu` サブメニューの開閉用 Redux state (`displayModeMenu`)。定数/rootMenu への登録/initialState/open・close・selector の追加 (Issue #865) | | `webpack.config.js` | classroom API | CLASSROOM_API_ENDPOINT 環境変数注入 | | `webpack.config.js` | scratch api proxy endpoint | SCRATCH_API_PROXY_ENDPOINT 環境変数注入 | | `eslint.config.mjs` | react lifecycle typo detection | `react/no-typos` を error にして getDerivedStateFromProps/Error の static 抜け等を lint で検出 | diff --git a/docs/mobile-ui/playwright.md b/docs/mobile-ui/playwright.md index b8d8e33bd01..1df1e436c50 100644 --- a/docs/mobile-ui/playwright.md +++ b/docs/mobile-ui/playwright.md @@ -103,8 +103,20 @@ active 状態は `[data-active="true"]` 属性で表現される。 | `mobile-drawer-toggle-${key}` | button | アコーディオン開閉 (`language` / `ruby-version` など) | | `mobile-drawer-locale-${code}` | button | locale 選択 (`ja` / `ja-Hira` / `en` ほか) | | `mobile-drawer-ruby-version-${version}` | button | Ruby version 切替 (`1` / `2`) | +| `mobile-drawer-switch-to-desktop` | button | PC モードに切り替える (表示モードを desktop に固定, #865) | -### 3.3 MobileBottomTabs (`mobile-bottom-tabs.jsx`) +### 3.3 MobileModeNotice (`mobile-mode-notice.jsx`) + +スマホモードで一度だけ表示される案内バー (#865)。「いまはスマホ用の画面である / PC とは違う / メニューから PC モードへ切り替えられる」ことを伝える。閉じると `localStorage['smalruby:mobileModeNoticeDismissed']='true'` に記録し以後出さない。 + +| data-testid | 要素 | 役割 | +| ------------------------------ | ------ | ------------------------------------------------- | +| `mobile-mode-notice` | div | 案内バー本体 (dismiss 済みなら DOM に無い) | +| `mobile-mode-notice-switch` | button | PC モードに切り替える (desktop に固定 + 閉じる) | +| `mobile-mode-notice-dismiss` | button | 案内を閉じる (スマホモードのまま) | +| `mobile-mode-notice-close` | button | × で閉じる (dismiss と同じ) | + +### 3.4 MobileBottomTabs (`mobile-bottom-tabs.jsx`) 旧構成で使っていたボトムタブ。現在は MobileSideRail に統合済みで MobileGui からはレンダリングされないが、コンポーネント本体は残っているため data-testid も保持されている。 @@ -113,7 +125,7 @@ active 状態は `[data-active="true"]` 属性で表現される。 | `mobile-bottom-tabs` | div | ボトムタブコンテナ | | `mobile-bottom-tabs-${tab.key}` | button | 各タブボタン | -### 3.4 MobileTopBar (`mobile-top-bar.jsx`) +### 3.5 MobileTopBar (`mobile-top-bar.jsx`) 旧フェーズで使っていた上部バー。同じく現在は MobileSideRail 統合済み。 @@ -124,7 +136,7 @@ active 状態は `[data-active="true"]` 属性で表現される。 | `mobile-top-bar-title` | span | プロジェクト名表示 | | `mobile-top-bar-play` | button | 実行/停止 (現在 MobileSideRail に移動) | -### 3.5 MobileSpritePanel (`mobile-sprite-panel.jsx`) +### 3.6 MobileSpritePanel (`mobile-sprite-panel.jsx`) | data-testid | 要素 | 役割 | | ----------------------- | ---- | ------------------------------ | @@ -132,19 +144,19 @@ active 状態は `[data-active="true"]` 属性で表現される。 中身は upstream ``。スプライト一覧・追加 FAB・ステージ列は upstream のセレクタで指す。 -### 3.6 MobileOrientationGate (`mobile-orientation-gate.jsx`) +### 3.7 MobileOrientationGate (`mobile-orientation-gate.jsx`) | data-testid | 要素 | 役割 | | ---------------------------- | ---- | ------------------------ | | `mobile-orientation-gate` | div | 縦持ち警告オーバーレイ | -### 3.7 MobilePaintToolbarToggle (`mobile-paint-toolbar-toggle.jsx`) +### 3.8 MobilePaintToolbarToggle (`mobile-paint-toolbar-toggle.jsx`) | data-testid | 要素 | 役割 | | --------------------------------- | ------ | ------------------------------------- | | `mobile-paint-toolbar-toggle` | button | コスチュームタブの ▼/▲ トグル | -### 3.8 RubyToolbar (`ruby-toolbar.jsx`) +### 3.9 RubyToolbar (`ruby-toolbar.jsx`) ルビータブの上部ツールバー。SP/desktop で同じセレクタが使える。 diff --git a/docs/mobile-ui/ui-ux.md b/docs/mobile-ui/ui-ux.md index cd07cc1f562..7e687ac5785 100644 --- a/docs/mobile-ui/ui-ux.md +++ b/docs/mobile-ui/ui-ux.md @@ -13,14 +13,14 @@ ## 1. 切り替えロジック (どのモードがいつ出るか) -切り替えは **viewport ベースで自動**。URL パラメータでのオプトインは設けない。`useIsNarrowScreen` の `matchMedia` がリアルタイムに反応するので、ブラウザリサイズ・端末回転に追従する。 +切り替えは **viewport ベースで自動**。URL パラメータでのオプトインは設けない。`useIsNarrowScreen` の `matchMedia` がリアルタイムに反応するので、ブラウザリサイズ・端末回転に追従する。ただし、ユーザーが表示モードを明示指定した場合はそれを優先する (下記「ユーザーによる表示モードの固定」)。 ### 切り替え式 `packages/scratch-gui/src/lib/use-is-narrow-screen.js`: ```js -const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-height: 500px)'; +const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-width: 950px) and (max-height: 500px)'; ``` `packages/scratch-gui/src/lib/responsive-gui.jsx` がこの hook の結果で `` と `` (upstream desktop) を出し分ける。 @@ -29,8 +29,9 @@ const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-height: 500px)'; | Viewport (例) | 短辺幅 | 高さ | 出るモード | スクリーンショット | | ------------------------- | -------- | ------- | ------------------------------- | ------------------ | -| iPhone 14 横 (844×390) | 844 | 390 | **MobileGui** (高さ ≤ 500 で発火) | 02〜11 | +| iPhone 14 横 (844×390) | 844 | 390 | **MobileGui** (幅 ≤ 950 かつ 高さ ≤ 500 で発火) | 02〜11 | | iPhone 14 縦 (390×844) | 390 | 844 | **MobileGui** + 縦持ち警告 | 11 | +| Chromebook ズーム (1380×480) | 1380 | 480 | **desktop GUI** (幅 > 950 なので高さ条件は無効) | — | | iPad mini portrait (744×1133) | 744 | 1133 | desktop GUI (iPad 調整) | 23 | | iPad portrait (768×1024) | 768 | 1024 | desktop GUI (iPad 調整) | 20 | | iPad landscape (1024×768) | 1024 | 768 | desktop GUI (高さ調整あり) | 21、22 | @@ -38,8 +39,8 @@ const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-height: 500px)'; ### しきい値の根拠 -- **幅 743px** は iPad mini portrait (744) を **MobileGui に落とさない** ための境界。MobileGui は横向き専用 UI なので、iPad portrait のような縦長を強制的に MobileGui で見せると逆に使いにくい。 -- **高さ 500px** はスマホ横持ち (390 高さ) を確実に拾う保険。デスクトップで縦を 500 以下に縮めるのは一般的でないので副作用は小さい。 +- **幅 743px** は iPad mini portrait (744) を **MobileGui に落とさない** ための境界。MobileGui は横向き専用 UI なので、iPad portrait のような縦長を強制的に MobileGui で見せると逆に使いにくい。iPhone 縦持ちのように横が極端に狭い端末をスマホモードにする主条件。 +- **高さ 500px は幅 950px 以下に限定する**。スマホ横持ち (390 高さ) を確実に拾いつつ、**Chromebook をズームして高さだけ縮んだ広い画面 (例 1380×480) を誤ってスマホモードにしない** ため (Issue #865)。以前は `(max-height: 500px)` を無条件で OR していたため、Chromebook のズームで高さが 500px 以下になると意図せずスマホモードに入る不具合があった。950px はスマホ横持ちの最大級 (iPhone Pro Max 系 ~932px) を含み、Chromebook / ノート PC の一般的な幅 (≥1280px) を確実に除外する。 ### iPad モードでの追加調整 (744〜1023px の desktop GUI) @@ -64,6 +65,26 @@ const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-height: 500px)'; - **MobileGui は別コンポーネントに分離**: desktop GUI の一部だけを CSS で隠すアプローチでは「右ペインも見えないのに React tree には残る → 余計なリスナーや HMR コスト」が累積するため、MobileGui を独立したコンポーネントにして一度差し替える方式にした。 - **iPad は desktop GUI のまま**: iPad は横幅 768〜1024px 程度あり、MobileGui の縦タブ集約より desktop の従来 UI のほうが学習コストが低い。なので iPad は desktop GUI に CSS/レイアウト調整を当てる方針。 +### ユーザーによる表示モードの固定 (Issue #865) + +viewport 自動判定に加えて、**ユーザーが表示モードを明示指定して固定できる**。Chromebook のズーム等で意図せずスマホモードに入ってしまったユーザーが、自力で PC モードへ抜け出すための逃げ道。 + +| モード | 値 | 挙動 | +| ------ | -- | ---- | +| 自動 | `auto` (既定) | viewport 自動判定 (上記の切り替え式) | +| PC モード | `desktop` | viewport に関係なく常に desktop GUI | +| スマホモード | `mobile` | viewport に関係なく常に MobileGui | + +- 設定は **localStorage `smalruby:displayMode`** に保存され、そのマシンでは以後ずっと維持される (`auto` はキー削除 = 未設定と等価)。 +- `persistDisplayMode()` が `smalruby:displayModeChanged` イベントを発火し、`useDisplayMode` hook 経由で `ResponsiveGui` がリアルタイムに切り替える (リロード不要)。 +- **切り替え口**: + - スマホモード中: モバイルドロワー (☰) の目立つ「PCモードに切り替える」ボタン (`mobile-drawer-switch-to-desktop`)。 + - どちらのモードでも: 設定メニュー → 「表示モード」。テーマ / Ruby バージョンと同じ **`PreferenceMenu` サブメニュー**形式で、専用アイコン付き。クリックすると 自動 / PCモード / スマホモード のサブメニューが開き、現在値にチェックが付く。 +- **スマホモードの気付き**: スマホモードに入ると、一度きりの案内バー **`MobileModeNotice`** (`mobile-mode-notice`) が出て「いまはスマホ用の画面である / PC とは表示が違う / ここから PC モードへ切り替えられる」ことを伝える。閉じると localStorage (`smalruby:mobileModeNoticeDismissed`) に記録し、そのマシンでは以後出さない。§4「PC 表示が崩れている警告バナーを置かない」原則とは異なり、これは *スマホモード側で* 現在の状態と脱出口を知らせる中立的な案内であり、警告ではない。 +- 関連ファイル: `src/lib/settings/display-mode/`、`src/lib/use-display-mode.js`、`src/lib/responsive-gui.jsx`、`src/components/mobile-drawer/mobile-drawer.jsx`、`src/components/mobile-mode-notice/mobile-mode-notice.jsx`、`src/components/menu-bar/settings-menu.jsx`、`src/reducers/menus.js`。 + +> **設計原則との関係**: これは「URL オプトインフラグ」ではなく **UI から操作する永続ユーザー設定** であり、§4「オプトインフラグを増やさない」に反しない。既定はあくまで viewport 自動判定で、明示指定したユーザーだけが固定される。 + --- ## 2. MobileGui (スマホ横向き) diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index a6f205342fd..4263525a55c 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -47,6 +47,7 @@ src/components/* !src/components/mobile-bottom-tabs/ !src/components/mobile-drawer/ !src/components/mobile-gui/ +!src/components/mobile-mode-notice/ !src/components/mobile-orientation-gate/ !src/components/mobile-paint-toolbar-toggle/ !src/components/mobile-side-rail/ @@ -179,6 +180,7 @@ src/lib/* !src/lib/url-loader.js !src/lib/url-params.js !src/lib/url-parser.js +!src/lib/use-display-mode.js !src/lib/use-is-narrow-screen.js !src/lib/version-checker.js @@ -293,6 +295,7 @@ test/unit/components/* !test/unit/components/mesh-self-sensor-notice.test.jsx !test/unit/components/mobile-bottom-tabs.test.jsx !test/unit/components/mobile-drawer.test.jsx +!test/unit/components/mobile-mode-notice.test.jsx !test/unit/components/mobile-orientation-gate.test.jsx !test/unit/components/mobile-side-rail.test.jsx !test/unit/components/mobile-sprite-panel.test.jsx @@ -361,7 +364,9 @@ test/unit/lib/* !test/unit/lib/make-toolbox-xml.test.js !test/unit/lib/mesh-v2-classroom-binding.test.js !test/unit/lib/mesh-v2-sensor-collision.test.js +!test/unit/lib/display-mode-persistence.test.js !test/unit/lib/responsive-gui.test.jsx +!test/unit/lib/use-display-mode.test.js !test/unit/lib/use-is-narrow-screen.test.js !test/unit/lib/module-sync.test.js !test/unit/lib/prism-parser.test.js diff --git a/packages/scratch-gui/src/components/menu-bar/icon--display-mode.svg b/packages/scratch-gui/src/components/menu-bar/icon--display-mode.svg new file mode 100644 index 00000000000..9021ebc4509 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/icon--display-mode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx index 489e2cb3d23..a686dc35144 100644 --- a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx @@ -8,6 +8,12 @@ import LanguageMenu from './language-menu.jsx'; import MenuBarMenu from './menu-bar-menu.jsx'; import {MenuSection, MenuItem} from '../menu/menu.jsx'; import PreferenceMenu from './preference-menu.jsx'; +// === Smalruby: Start of display mode menu === +import useDisplayMode from '../../lib/use-display-mode.js'; +import {displayModeMap, messages as displayModeMessages} from '../../lib/settings/display-mode/index.js'; +import {persistDisplayMode} from '../../lib/settings/display-mode/persistence.js'; +import displayModeIcon from './icon--display-mode.svg'; +// === Smalruby: End of display mode menu === import {DEFAULT_MODE, HIGH_CONTRAST_MODE, colorModeMap} from '../../lib/settings/color-mode/index.js'; import {themeMap} from '../../lib/settings/theme/index.js'; @@ -36,7 +42,11 @@ import { rubyVersionMenuOpen, openColorModeMenu, openThemeMenu, - openRubyVersionMenu + openRubyVersionMenu, + // === Smalruby: Start of display mode menu === + displayModeMenuOpen, + openDisplayModeMenu + // === Smalruby: End of display mode menu === } from '../../reducers/menus.js'; const enabledColorModes = [DEFAULT_MODE, HIGH_CONTRAST_MODE]; @@ -51,6 +61,7 @@ const SettingsMenu = ({ isColorModeMenuOpen, isThemeMenuOpen, isRubyVersionMenuOpen, + isDisplayModeMenuOpen, activeColorMode, activeRubyVersion, onChangeColorMode, @@ -58,6 +69,7 @@ const SettingsMenu = ({ onRequestOpenColorMode, onRequestOpenTheme, onRequestOpenRubyVersion, + onRequestOpenDisplayMode, onOpenBlockDisplayModal, onOpenTeacherModal, activeTheme, @@ -67,6 +79,17 @@ const SettingsMenu = ({ settingsMenuOpen }) => { const intl = useIntl(); + // === Smalruby: Start of display mode menu === + // 表示モード (auto / PC / スマホ) の現在値と切替ハンドラ (Issue #865)。 + // 他の設定メニュー (テーマ / Ruby バージョン) と同じ PreferenceMenu の + // サブメニューとして出す。表示モードは Redux ではなく localStorage 駆動 + // (useDisplayMode で購読) なので、選択時は persistDisplayMode するだけ。 + const activeDisplayMode = useDisplayMode(); + const handleChangeDisplayMode = useCallback(mode => { + persistDisplayMode(mode); + onRequestClose(); + }, [onRequestClose]); + // === Smalruby: End of display mode menu === const enabledColorModesMap = useMemo(() => Object.keys(colorModeMap).reduce((acc, colorMode) => { if (enabledColorModes.includes(colorMode)) { acc[colorMode] = colorModeMap[colorMode]; @@ -176,6 +199,25 @@ const SettingsMenu = ({ onRequestCloseSettings={onRequestClose} onRequestOpen={onRequestOpenRubyVersion} /> + {/* === Smalruby: Start of display mode menu === */} + {/* + * 表示モード切替 (Issue #865)。auto / PC / スマホ を選べる。 + * テーマ / Ruby バージョンと同じ PreferenceMenu サブメニュー形式。 + * Chromebook 等で意図せずスマホモードに入ったユーザーが、ここから + * いつでも PC モードへ固定できる (localStorage に保存)。 + */} + + {/* === Smalruby: End of display mode menu === */}
({ activeRubyVersion: state.scratchGui.settings.rubyVersion, isColorModeMenuOpen: colorModeMenuOpen(state), isThemeMenuOpen: themeMenuOpen(state), - isRubyVersionMenuOpen: rubyVersionMenuOpen(state) + isRubyVersionMenuOpen: rubyVersionMenuOpen(state), + // === Smalruby: Start of display mode menu === + isDisplayModeMenuOpen: displayModeMenuOpen(state) + // === Smalruby: End of display mode menu === }); const mapDispatchToProps = (dispatch, ownProps) => ({ @@ -265,6 +312,11 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ onRequestOpenRubyVersion: () => { dispatch(openRubyVersionMenu()); }, + // === Smalruby: Start of display mode menu === + onRequestOpenDisplayMode: () => { + dispatch(openDisplayModeMenu()); + }, + // === Smalruby: End of display mode menu === onOpenBlockDisplayModal: () => { ownProps.onOpenBlockDisplayModal(); }, diff --git a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.css b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.css index 96278699416..41a24aef438 100644 --- a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.css +++ b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.css @@ -183,3 +183,21 @@ color: #855cd6; font-weight: bold; } + +/* + * PC モードへ切り替える項目 (Issue #865)。意図せずスマホモードに入った + * ユーザーが見つけやすいよう、他の項目より目立つ塗りつぶしにする。 + */ +.switch-to-desktop { + margin: 0.5rem 0.75rem; + width: auto; + border-radius: 0.5rem; + background-color: #855cd6; + color: #fff; + font-weight: bold; + text-align: center; +} + +.switch-to-desktop:active { + background-color: #714fc0; +} diff --git a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx index f89c0d63d5b..48b84340baa 100644 --- a/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx +++ b/packages/scratch-gui/src/components/mobile-drawer/mobile-drawer.jsx @@ -19,6 +19,8 @@ import { VERSION_1, } from '../../lib/settings/ruby-version/index.js'; import { persistRubyVersion } from '../../lib/settings/ruby-version/persistence.js'; +import { DISPLAY_MODE_DESKTOP } from '../../lib/settings/display-mode/index.js'; +import { persistDisplayMode } from '../../lib/settings/display-mode/persistence.js'; import sharedMessages from '../../lib/shared-messages'; import { isBugReportConfigured } from '../../lib/bug-report-api.js'; import { openBugReportModal } from '../../reducers/bug-report.js'; @@ -152,6 +154,12 @@ const messages = defineMessages({ description: 'Mobile drawer item that opens the program bug report modal', id: 'gui.smalruby3.gui.bugReport', }, + switchToDesktop: { + defaultMessage: 'Switch to PC mode', + description: + 'Mobile drawer item that switches from the smartphone layout to the PC (desktop) layout and remembers it', + id: 'gui.mobile.drawer.switchToDesktop', + }, }); /** @@ -366,6 +374,14 @@ const MobileDrawerComponent = ({ onClose(); }, [onReportBug, onClose]); + // PC モードへ切り替える (Issue #865)。localStorage に desktop 固定を保存し、 + // ResponsiveGui がイベントを受けて desktop GUI に切り替える。このマシンでは + // 以後ずっと PC モード (設定メニューからいつでも変更可能)。 + const handleClickSwitchToDesktop = useCallback(() => { + persistDisplayMode(DISPLAY_MODE_DESKTOP); + onClose(); + }, [onClose]); + if (typeof document === 'undefined') { return null; } @@ -568,6 +584,24 @@ const MobileDrawerComponent = ({ + {/* ===== 表示モード (PC モードへ切り替え, Issue #865) ===== */} + {/* + * Chromebook 等で意図せずスマホモードに入ってしまったユーザーが + * PC モードへ抜け出すための単独項目。切り替えると localStorage に + * 保存され、そのマシンでは以後ずっと PC モードになる (設定メニュー + * からいつでも戻せる)。 + */} +
  • + +
  • + {/* ===== クラス / メッシュ (単独項目) ===== */} {/* * これらは「セクション」ではなく単独のトップレベル項目なので、 diff --git a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx index ae931a8beed..f4c94739341 100644 --- a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx +++ b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx @@ -9,6 +9,7 @@ import { saveBackpackObject } from '../../lib/backpack-api.js'; import ConnectedIntlProvider from '../../lib/connected-intl-provider.jsx'; import { COSTUMES_TAB_INDEX } from '../../reducers/editor-tab.js'; import MobileDrawer from '../mobile-drawer/mobile-drawer.jsx'; +import MobileModeNotice from '../mobile-mode-notice/mobile-mode-notice.jsx'; import MobileOrientationGate from '../mobile-orientation-gate/mobile-orientation-gate.jsx'; import MobilePaintToolbarToggle from '../mobile-paint-toolbar-toggle/mobile-paint-toolbar-toggle.jsx'; import MobileSideRail from '../mobile-side-rail/mobile-side-rail.jsx'; @@ -294,6 +295,12 @@ const MobileGui = ({ activeTabIndex, isFullScreen, vm, ...props }) => { onToggleBackpack={handleToggleBackpack} /> + {/* + * スマホモードであることと PC モードへ切り替えられることを + * 伝える一度きりの案内 (Issue #865)。閉じたら localStorage に + * 記録して以後出さない。 + */} + {/* * コスチュームタブで上部ツールバーを出し入れするトグル。 diff --git a/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.css b/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.css new file mode 100644 index 00000000000..e8c38c1da7f --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.css @@ -0,0 +1,101 @@ +.notice { + position: fixed; + top: max(8px, env(safe-area-inset-top, 8px)); + /* 左 56px のサイドレールを避けて、その右側に配置する。 */ + left: calc(56px + 8px); + right: max(8px, env(safe-area-inset-right, 8px)); + /* + * サイドレール (9200) より上、ドロワー (9501) / 各種モーダル overlay (10000) + * より下に重ねる。案内であって操作を塞がないので overlay にはしない。 + */ + z-index: 9300; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + background: #4c97ff; + color: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + font-size: 0.8rem; + line-height: 1.4; +} + +.body { + flex: 1 1 auto; + min-width: 0; +} + +.actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 0 0 auto; +} + +.switch-button { + /* フィッツの法則: タッチターゲット最低 44x44px を確保。 */ + min-height: 36px; + padding: 0.375rem 0.75rem; + background: white; + color: #4c97ff; + font-size: 0.8rem; + font-weight: bold; + border: 0; + border-radius: 999px; + cursor: pointer; + font-family: inherit; + white-space: nowrap; +} + +.switch-button:hover, +.switch-button:focus { + background: rgba(255, 255, 255, 0.9); + outline: none; +} + +.dismiss-button { + min-height: 36px; + padding: 0.375rem 0.625rem; + background: transparent; + color: white; + font-size: 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 999px; + cursor: pointer; + font-family: inherit; + white-space: nowrap; +} + +.dismiss-button:hover, +.dismiss-button:focus { + background: rgba(255, 255, 255, 0.15); + outline: none; +} + +.close-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: 0 0 auto; + background: transparent; + border: 0; + border-radius: 50%; + cursor: pointer; + padding: 0; +} + +.close-button:hover, +.close-button:focus { + background: rgba(255, 255, 255, 0.15); + outline: none; +} + +.close-icon { + width: 14px; + height: 14px; + /* icon--close.svg は暗色線画なので白背景バー上で反転して白くする。 */ + filter: brightness(0) invert(1); +} diff --git a/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.jsx b/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.jsx new file mode 100644 index 00000000000..224309f69e8 --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-mode-notice/mobile-mode-notice.jsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; + +import intlShape from '../../lib/intlShape'; +import { DISPLAY_MODE_DESKTOP } from '../../lib/settings/display-mode/index.js'; +import { persistDisplayMode } from '../../lib/settings/display-mode/persistence.js'; +import closeIcon from '../mobile-drawer/icon--close.svg'; +import styles from './mobile-mode-notice.css'; + +/** + * localStorage key — 一度閉じたら、そのマシンでは以後この案内を出さない。 + * + * 「スマホモードであること + PC モードへ切り替えられること」を一度知れば十分 + * なので、繰り返し出して邪魔しないよう永続的に dismiss を記録する。 + */ +const DISMISS_STORAGE_KEY = 'smalruby:mobileModeNoticeDismissed'; + +const messages = defineMessages({ + body: { + defaultMessage: 'This is the smartphone screen. It differs from the PC layout.', + description: 'Notice shown in mobile mode explaining the current layout differs from PC', + id: 'gui.mobile.modeNotice.body', + }, + switchToDesktop: { + defaultMessage: 'Switch to PC mode', + description: 'Button in the mobile-mode notice that switches to the PC (desktop) layout', + id: 'gui.mobile.drawer.switchToDesktop', + }, + dismiss: { + defaultMessage: 'Got it', + description: 'Button in the mobile-mode notice that dismisses it (stays in smartphone mode)', + id: 'gui.mobile.modeNotice.dismiss', + }, + closeAriaLabel: { + defaultMessage: 'Close notice', + description: 'Aria label for the mobile-mode notice close button', + id: 'gui.mobile.modeNotice.close', + }, +}); + +const wasDismissed = () => { + if (typeof window === 'undefined' || !window.localStorage) return false; + return window.localStorage.getItem(DISMISS_STORAGE_KEY) === 'true'; +}; + +/** + * スマホモードで表示する一度きりの案内 (Issue #865)。 + * + * 「いまはスマホ用の画面であり PC とは違うこと」「PC モードへ切り替えられること + * (メニューからいつでも変更可)」をユーザーに伝えるための、閉じられる通知バー。 + * Chromebook のズーム等で意図せずスマホモードに入ったユーザーが、状況を理解して + * 自力で PC モードへ抜け出せるようにする。 + * + * MobileGui からのみマウントされる (= スマホモード時のみ)。PC (広幅) では + * MobileGui 自体がマウントされないので表示されない。閉じると localStorage に + * 記録し、そのマシンでは以後出さない。 + * @param {object} props - props + * @param {object} props.intl - react-intl + * @returns {JSX.Element|null} portal 経由で body 直下にレンダリング (dismiss 済みなら null) + */ +const MobileModeNoticeComponent = ({ intl }) => { + const [visible, setVisible] = useState(() => !wasDismissed()); + + const dismiss = useCallback(() => { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true'); + } + setVisible(false); + }, []); + + const handleSwitchToDesktop = useCallback(() => { + // PC モードへ固定 (localStorage 保存)。ResponsiveGui がイベントを受けて + // desktop GUI に切り替える。以後この案内も不要なので dismiss も記録する。 + persistDisplayMode(DISPLAY_MODE_DESKTOP); + dismiss(); + }, [dismiss]); + + if (typeof document === 'undefined' || !visible) { + return null; + } + + return createPortal( +
    + + + +
    + + +
    + +
    , + document.body, + ); +}; + +MobileModeNoticeComponent.propTypes = { + intl: intlShape.isRequired, +}; + +const MobileModeNotice = injectIntl(MobileModeNoticeComponent); + +export default MobileModeNotice; +export { MobileModeNoticeComponent, DISMISS_STORAGE_KEY }; diff --git a/packages/scratch-gui/src/lib/responsive-gui.jsx b/packages/scratch-gui/src/lib/responsive-gui.jsx index b9200d4b8ae..1a5cb34750c 100644 --- a/packages/scratch-gui/src/lib/responsive-gui.jsx +++ b/packages/scratch-gui/src/lib/responsive-gui.jsx @@ -1,17 +1,35 @@ import React from 'react'; import MobileGui from '../components/mobile-gui/mobile-gui.jsx'; import GUI from '../containers/gui.jsx'; +import { DISPLAY_MODE_DESKTOP, DISPLAY_MODE_MOBILE } from './settings/display-mode/index.js'; +import useDisplayMode from './use-display-mode.js'; import useIsNarrowScreen from './use-is-narrow-screen.js'; /** * 狭い viewport で 、そうでなければ を出し分けるラッパー。 * matchMedia でリアルタイムに切り替わるので resize / 端末回転に追従する。 + * + * ユーザーが表示モードを明示指定している場合 (Issue #865) はそれを優先する: + * `desktop` は常に 、`mobile` は常に 、`auto` は viewport 判定。 + * これにより Chromebook 等で意図せずスマホモードに入ってしまっても、 + * 設定メニュー / モバイルドロワーから PC モードへ固定できる。 * @param {object} props - / に渡す props * @returns {JSX.Element} 選択された GUI コンポーネント */ const ResponsiveGui = (props) => { const isNarrow = useIsNarrowScreen(); - if (isNarrow) { + const displayMode = useDisplayMode(); + + let isMobile; + if (displayMode === DISPLAY_MODE_DESKTOP) { + isMobile = false; + } else if (displayMode === DISPLAY_MODE_MOBILE) { + isMobile = true; + } else { + isMobile = isNarrow; + } + + if (isMobile) { return ; } return ; diff --git a/packages/scratch-gui/src/lib/settings/display-mode/index.js b/packages/scratch-gui/src/lib/settings/display-mode/index.js new file mode 100644 index 00000000000..c2d80dddf90 --- /dev/null +++ b/packages/scratch-gui/src/lib/settings/display-mode/index.js @@ -0,0 +1,60 @@ +import {defineMessages} from 'react-intl'; + +/** + * 表示モードのユーザー設定 (Issue #865)。 + * + * - `auto` : viewport から自動判定 (既定)。狭い画面なら MobileGui。 + * - `desktop` : viewport に関係なく常に PC (desktop) GUI。 + * - `mobile` : viewport に関係なく常に MobileGui。 + * + * Chromebook でズームすると高さが縮んで意図せずスマホモードになってしまう + * 問題への対策として、ユーザーが明示的に PC モードへ固定できるようにする。 + * 固定は localStorage に保存され、そのマシンでは以後ずっと維持される + * (設定メニュー / モバイルドロワーからいつでも変更可能)。 + */ +const DISPLAY_MODE_AUTO = 'auto'; +const DISPLAY_MODE_DESKTOP = 'desktop'; +const DISPLAY_MODE_MOBILE = 'mobile'; + +const messages = defineMessages({ + displayModeMenu: { + id: 'gui.menuBar.displayMode', + defaultMessage: 'Display mode', + description: 'Display mode (auto / PC / mobile) sub-menu label in the settings menu' + }, + [DISPLAY_MODE_AUTO]: { + id: 'gui.displayMode.auto', + defaultMessage: 'Automatic', + description: 'Label for the automatic (viewport-based) display mode' + }, + [DISPLAY_MODE_DESKTOP]: { + id: 'gui.displayMode.desktop', + defaultMessage: 'PC mode', + description: 'Label for the forced desktop (PC) display mode' + }, + [DISPLAY_MODE_MOBILE]: { + id: 'gui.displayMode.mobile', + defaultMessage: 'Smartphone mode', + description: 'Label for the forced mobile (smartphone) display mode' + } +}); + +const displayModeMap = { + [DISPLAY_MODE_AUTO]: { + label: messages[DISPLAY_MODE_AUTO] + }, + [DISPLAY_MODE_DESKTOP]: { + label: messages[DISPLAY_MODE_DESKTOP] + }, + [DISPLAY_MODE_MOBILE]: { + label: messages[DISPLAY_MODE_MOBILE] + } +}; + +export { + DISPLAY_MODE_AUTO, + DISPLAY_MODE_DESKTOP, + DISPLAY_MODE_MOBILE, + displayModeMap, + messages +}; diff --git a/packages/scratch-gui/src/lib/settings/display-mode/persistence.js b/packages/scratch-gui/src/lib/settings/display-mode/persistence.js new file mode 100644 index 00000000000..4297f4b8461 --- /dev/null +++ b/packages/scratch-gui/src/lib/settings/display-mode/persistence.js @@ -0,0 +1,56 @@ +import {DISPLAY_MODE_AUTO, DISPLAY_MODE_DESKTOP, DISPLAY_MODE_MOBILE} from '.'; + +const STORAGE_KEY = 'smalruby:displayMode'; + +/** + * 表示モードが変わったことを同一タブ内に伝えるカスタムイベント名。 + * `ResponsiveGui` はこれを購読して MobileGui / desktop GUI を切り替える。 + * (localStorage の `storage` イベントは他タブにしか飛ばないため、同一タブ用に + * 独自イベントを使う。) + */ +const DISPLAY_MODE_CHANGED_EVENT = 'smalruby:displayModeChanged'; + +const isValidDisplayMode = mode => + [DISPLAY_MODE_AUTO, DISPLAY_MODE_DESKTOP, DISPLAY_MODE_MOBILE].includes(mode); + +/** + * localStorage から現在の表示モード設定を読む。未設定 / 不正値なら `auto`。 + * @returns {string} DISPLAY_MODE_AUTO | DISPLAY_MODE_DESKTOP | DISPLAY_MODE_MOBILE + */ +const detectDisplayMode = () => { + if (typeof window === 'undefined' || !window.localStorage) { + return DISPLAY_MODE_AUTO; + } + const mode = window.localStorage.getItem(STORAGE_KEY); + return isValidDisplayMode(mode) ? mode : DISPLAY_MODE_AUTO; +}; + +/** + * 表示モード設定を localStorage に保存し、同一タブへ変更イベントを飛ばす。 + * `auto` は「設定なし」と等価なのでキーを削除する。 + * @param {string} mode - 保存する表示モード + */ +const persistDisplayMode = mode => { + if (!isValidDisplayMode(mode)) { + throw new Error(`Invalid display mode: ${mode}`); + } + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + if (mode === DISPLAY_MODE_AUTO) { + window.localStorage.removeItem(STORAGE_KEY); + } else { + window.localStorage.setItem(STORAGE_KEY, mode); + } + if (typeof window.dispatchEvent === 'function') { + window.dispatchEvent(new Event(DISPLAY_MODE_CHANGED_EVENT)); + } +}; + +export { + STORAGE_KEY, + DISPLAY_MODE_CHANGED_EVENT, + isValidDisplayMode, + detectDisplayMode, + persistDisplayMode +}; diff --git a/packages/scratch-gui/src/lib/use-display-mode.js b/packages/scratch-gui/src/lib/use-display-mode.js new file mode 100644 index 00000000000..2d14c2ba274 --- /dev/null +++ b/packages/scratch-gui/src/lib/use-display-mode.js @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { DISPLAY_MODE_CHANGED_EVENT, detectDisplayMode } from './settings/display-mode/persistence.js'; + +/** + * 現在の表示モード設定 (`auto` / `desktop` / `mobile`) を返す hook (Issue #865)。 + * + * localStorage の値を読み、`persistDisplayMode` が発火する + * `smalruby:displayModeChanged` イベント (同一タブ) と `storage` イベント + * (他タブ) の両方を購読してリアルタイムに追従する。 + * @returns {string} DISPLAY_MODE_AUTO | DISPLAY_MODE_DESKTOP | DISPLAY_MODE_MOBILE + */ +const useDisplayMode = () => { + const [mode, setMode] = useState(detectDisplayMode); + + useEffect(() => { + if (typeof window === 'undefined') { + return () => {}; + } + const handler = () => setMode(detectDisplayMode()); + window.addEventListener(DISPLAY_MODE_CHANGED_EVENT, handler); + window.addEventListener('storage', handler); + // マウント時点で他所が既に書き換えている可能性があるので同期しておく。 + handler(); + return () => { + window.removeEventListener(DISPLAY_MODE_CHANGED_EVENT, handler); + window.removeEventListener('storage', handler); + }; + }, []); + + return mode; +}; + +export default useDisplayMode; diff --git a/packages/scratch-gui/src/lib/use-is-narrow-screen.js b/packages/scratch-gui/src/lib/use-is-narrow-screen.js index 308a8cb0f81..d9574864bd1 100644 --- a/packages/scratch-gui/src/lib/use-is-narrow-screen.js +++ b/packages/scratch-gui/src/lib/use-is-narrow-screen.js @@ -5,12 +5,16 @@ import { useEffect, useState } from 'react'; * 出し分けに使う共通ブレークポイント。 * * 閾値の根拠: - * - 幅 743px は iPad mini portrait (744) を除外する境界値 - * - 高さ 500px はスマホ横持ち (844×390 等) を拾う保険 - * (デスクトップで 500 以下に縮めても他の min-height 制約に引っかかる範囲) + * - 幅 743px は iPad mini portrait (744) を除外する境界値。iPhone 縦持ちの + * ように横が極端に狭い端末をスマホモードにする主条件。 + * - 高さ 500px はスマホ横持ち (844×390 等) を拾う保険。ただし **幅 950px 以下** + * に限定する。こうしないと Chromebook をズームして高さだけ縮んだ広い画面 + * (例 1380×480) まで拾ってしまい、意図せずスマホモードに入る (Issue #865)。 + * 950px はスマホ横持ちの最大級 (iPhone Pro Max 系 ~932px) を含みつつ、 + * Chromebook / ノート PC の一般的な幅 (>=1280px) を確実に除外する境界値。 * @returns {boolean} スマホ相当のサイズなら true */ -const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-height: 500px)'; +const NARROW_SCREEN_QUERY = '(max-width: 743px), (max-width: 950px) and (max-height: 500px)'; const useIsNarrowScreen = () => { const [isNarrow, setIsNarrow] = useState(() => { diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 093c1b07936..be0a9ca8f58 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -1102,6 +1102,14 @@ export default { 'gui.mobile.drawer.settings.ruby': 'ルビー', 'gui.mobile.drawer.settings.ruby.v1': 'バージョン 1', 'gui.mobile.drawer.settings.ruby.v2': 'バージョン 2', + 'gui.mobile.drawer.switchToDesktop': 'パソコンモードにきりかえる', + 'gui.menuBar.displayMode': 'ひょうじモード', + 'gui.displayMode.auto': 'じどう', + 'gui.displayMode.desktop': 'パソコンモード', + 'gui.displayMode.mobile': 'スマホモード', + 'gui.mobile.modeNotice.body': 'いまはスマホようのがめんです(パソコンとはひょうじがちがいます)。', + 'gui.mobile.modeNotice.dismiss': 'とじる', + 'gui.mobile.modeNotice.close': 'あんないをとじる', // 旧キー (互換のため残す) 'gui.mobile.drawer.section.tools': 'ツール', 'gui.mobile.drawer.section.rubyVersion': 'ルビー バージョン', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index 9509bce22c3..c8cbd999a43 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -1070,6 +1070,14 @@ export default { 'gui.mobile.drawer.settings.ruby': 'ルビー', 'gui.mobile.drawer.settings.ruby.v1': 'バージョン 1', 'gui.mobile.drawer.settings.ruby.v2': 'バージョン 2', + 'gui.mobile.drawer.switchToDesktop': 'PCモードに切り替える', + 'gui.menuBar.displayMode': '表示モード', + 'gui.displayMode.auto': '自動', + 'gui.displayMode.desktop': 'PCモード', + 'gui.displayMode.mobile': 'スマホモード', + 'gui.mobile.modeNotice.body': 'いまはスマホ用の画面です(PCとは表示が違います)。', + 'gui.mobile.modeNotice.dismiss': 'とじる', + 'gui.mobile.modeNotice.close': '案内を閉じる', // 旧キー (削除した section.tools / section.rubyVersion / section.language) は // 互換のため残しておく (未使用)。 'gui.mobile.drawer.section.tools': 'ツール', diff --git a/packages/scratch-gui/src/reducers/menus.js b/packages/scratch-gui/src/reducers/menus.js index f4a55e81f1b..291701aafe0 100644 --- a/packages/scratch-gui/src/reducers/menus.js +++ b/packages/scratch-gui/src/reducers/menus.js @@ -13,6 +13,9 @@ const MENU_SETTINGS = 'settingsMenu'; const MENU_COLOR_MODE = 'colorModeMenu'; const MENU_THEME = 'themeMenu'; const MENU_RUBY_VERSION = 'rubyVersionMenu'; +// === Smalruby: Start of display mode menu === +const MENU_DISPLAY_MODE = 'displayModeMenu'; +// === Smalruby: End of display mode menu === const MENU_KOSHIEN = 'koshienMenu'; const MENU_MESH_V2 = 'meshV2Menu'; const MENU_SMALRUBOT_S1 = 'smalrubotS1Menu'; @@ -60,6 +63,9 @@ const rootMenu = new Menu('root') .addChild(new Menu(MENU_COLOR_MODE)) .addChild(new Menu(MENU_THEME)) .addChild(new Menu(MENU_RUBY_VERSION)) + // === Smalruby: Start of display mode menu === + .addChild(new Menu(MENU_DISPLAY_MODE)) + // === Smalruby: End of display mode menu === ) .addChild(new Menu(MENU_FILE)) .addChild(new Menu(MENU_EDIT)) @@ -84,6 +90,9 @@ const initialState = { [MENU_COLOR_MODE]: false, [MENU_THEME]: false, [MENU_RUBY_VERSION]: false, + // === Smalruby: Start of display mode menu === + [MENU_DISPLAY_MODE]: false, + // === Smalruby: End of display mode menu === [MENU_KOSHIEN]: false, [MENU_MESH_V2]: false, [MENU_SMALRUBOT_S1]: false @@ -195,6 +204,12 @@ const openRubyVersionMenu = () => openMenu(MENU_RUBY_VERSION); const closeRubyVersionMenu = () => closeMenu(MENU_RUBY_VERSION); const rubyVersionMenuOpen = state => state.scratchGui.menus[MENU_RUBY_VERSION]; +// === Smalruby: Start of display mode menu === +const openDisplayModeMenu = () => openMenu(MENU_DISPLAY_MODE); +const closeDisplayModeMenu = () => closeMenu(MENU_DISPLAY_MODE); +const displayModeMenuOpen = state => state.scratchGui.menus[MENU_DISPLAY_MODE]; +// === Smalruby: End of display mode menu === + const openKoshienMenu = () => openMenu(MENU_KOSHIEN); const closeKoshienMenu = () => closeMenu(MENU_KOSHIEN); const koshienMenuOpen = state => state.scratchGui.menus[MENU_KOSHIEN]; @@ -246,6 +261,11 @@ export { openRubyVersionMenu, closeRubyVersionMenu, rubyVersionMenuOpen, + // === Smalruby: Start of display mode menu === + openDisplayModeMenu, + closeDisplayModeMenu, + displayModeMenuOpen, + // === Smalruby: End of display mode menu === openKoshienMenu, closeKoshienMenu, koshienMenuOpen, diff --git a/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx b/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx index 04ebdd78861..0013599f81a 100644 --- a/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-drawer.test.jsx @@ -236,6 +236,24 @@ describe('MobileDrawer', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); + describe('Switch to PC mode (Issue #865)', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('is always visible as a top-level item', () => { + const { getByTestId } = renderWithIntl(); + expect(getByTestId('mobile-drawer-switch-to-desktop')).toBeInTheDocument(); + }); + + test('clicking it persists desktop mode + closes the drawer', () => { + const { getByTestId, props } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-drawer-switch-to-desktop')); + expect(window.localStorage.getItem('smalruby:displayMode')).toBe('desktop'); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + }); + describe('Settings accordion', () => { test('settings is collapsed initially (language / ruby children hidden)', () => { const { queryByTestId } = renderWithIntl(); diff --git a/packages/scratch-gui/test/unit/components/mobile-mode-notice.test.jsx b/packages/scratch-gui/test/unit/components/mobile-mode-notice.test.jsx new file mode 100644 index 00000000000..a0bd9f9f988 --- /dev/null +++ b/packages/scratch-gui/test/unit/components/mobile-mode-notice.test.jsx @@ -0,0 +1,57 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import MobileModeNotice, { + DISMISS_STORAGE_KEY, +} from '../../../src/components/mobile-mode-notice/mobile-mode-notice.jsx'; + +const renderWithIntl = (ui) => + render( + + {ui} + , + ); + +describe('MobileModeNotice (Issue #865)', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('renders the notice with switch and dismiss actions by default', () => { + const { getByTestId } = renderWithIntl(); + expect(getByTestId('mobile-mode-notice')).toBeInTheDocument(); + expect(getByTestId('mobile-mode-notice-switch')).toBeInTheDocument(); + expect(getByTestId('mobile-mode-notice-dismiss')).toBeInTheDocument(); + expect(getByTestId('mobile-mode-notice-close')).toBeInTheDocument(); + }); + + test('does not render when previously dismissed (persisted per machine)', () => { + window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true'); + const { queryByTestId } = renderWithIntl(); + expect(queryByTestId('mobile-mode-notice')).not.toBeInTheDocument(); + }); + + test('clicking dismiss hides the notice and records the dismissal', () => { + const { getByTestId, queryByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-mode-notice-dismiss')); + expect(queryByTestId('mobile-mode-notice')).not.toBeInTheDocument(); + expect(window.localStorage.getItem(DISMISS_STORAGE_KEY)).toBe('true'); + }); + + test('clicking the close (x) button hides the notice and records the dismissal', () => { + const { getByTestId, queryByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-mode-notice-close')); + expect(queryByTestId('mobile-mode-notice')).not.toBeInTheDocument(); + expect(window.localStorage.getItem(DISMISS_STORAGE_KEY)).toBe('true'); + }); + + test('clicking "switch to PC mode" persists desktop mode and dismisses the notice', () => { + const { getByTestId, queryByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-mode-notice-switch')); + expect(window.localStorage.getItem('smalruby:displayMode')).toBe('desktop'); + expect(window.localStorage.getItem(DISMISS_STORAGE_KEY)).toBe('true'); + expect(queryByTestId('mobile-mode-notice')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/display-mode-persistence.test.js b/packages/scratch-gui/test/unit/lib/display-mode-persistence.test.js new file mode 100644 index 00000000000..52bbd5934cc --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/display-mode-persistence.test.js @@ -0,0 +1,77 @@ +/* eslint-env jest */ +import { + DISPLAY_MODE_AUTO, + DISPLAY_MODE_DESKTOP, + DISPLAY_MODE_MOBILE, +} from '../../../src/lib/settings/display-mode/index.js'; +import { + STORAGE_KEY, + DISPLAY_MODE_CHANGED_EVENT, + isValidDisplayMode, + detectDisplayMode, + persistDisplayMode, +} from '../../../src/lib/settings/display-mode/persistence.js'; + +describe('display-mode persistence', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe('isValidDisplayMode', () => { + test('accepts the three known modes', () => { + expect(isValidDisplayMode(DISPLAY_MODE_AUTO)).toBe(true); + expect(isValidDisplayMode(DISPLAY_MODE_DESKTOP)).toBe(true); + expect(isValidDisplayMode(DISPLAY_MODE_MOBILE)).toBe(true); + }); + + test('rejects unknown / empty values', () => { + expect(isValidDisplayMode('tablet')).toBe(false); + expect(isValidDisplayMode(null)).toBe(false); + expect(isValidDisplayMode(undefined)).toBe(false); + }); + }); + + describe('detectDisplayMode', () => { + test('defaults to auto when nothing is stored', () => { + expect(detectDisplayMode()).toBe(DISPLAY_MODE_AUTO); + }); + + test('returns the stored value when valid', () => { + window.localStorage.setItem(STORAGE_KEY, DISPLAY_MODE_DESKTOP); + expect(detectDisplayMode()).toBe(DISPLAY_MODE_DESKTOP); + }); + + test('falls back to auto when the stored value is invalid', () => { + window.localStorage.setItem(STORAGE_KEY, 'garbage'); + expect(detectDisplayMode()).toBe(DISPLAY_MODE_AUTO); + }); + }); + + describe('persistDisplayMode', () => { + test('stores desktop / mobile in localStorage', () => { + persistDisplayMode(DISPLAY_MODE_DESKTOP); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(DISPLAY_MODE_DESKTOP); + persistDisplayMode(DISPLAY_MODE_MOBILE); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe(DISPLAY_MODE_MOBILE); + }); + + test('removes the key when set to auto', () => { + window.localStorage.setItem(STORAGE_KEY, DISPLAY_MODE_DESKTOP); + persistDisplayMode(DISPLAY_MODE_AUTO); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(detectDisplayMode()).toBe(DISPLAY_MODE_AUTO); + }); + + test('throws on an invalid mode', () => { + expect(() => persistDisplayMode('bogus')).toThrow(/Invalid display mode/); + }); + + test('dispatches the change event so listeners can react', () => { + const handler = jest.fn(); + window.addEventListener(DISPLAY_MODE_CHANGED_EVENT, handler); + persistDisplayMode(DISPLAY_MODE_DESKTOP); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(DISPLAY_MODE_CHANGED_EVENT, handler); + }); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx index 498ca85b891..4f9039b279b 100644 --- a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx +++ b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx @@ -21,8 +21,21 @@ jest.mock('../../../src/lib/use-is-narrow-screen.js', () => ({ default: () => mockUseIsNarrowScreen(), })); +// Mock the display-mode override hook so we control the user preference. +const mockUseDisplayMode = jest.fn(); +jest.mock('../../../src/lib/use-display-mode.js', () => ({ + __esModule: true, + default: () => mockUseDisplayMode(), +})); + +const DISPLAY_MODE_AUTO = 'auto'; +const DISPLAY_MODE_DESKTOP = 'desktop'; +const DISPLAY_MODE_MOBILE = 'mobile'; + beforeEach(() => { mockUseIsNarrowScreen.mockReset(); + mockUseDisplayMode.mockReset(); + mockUseDisplayMode.mockReturnValue(DISPLAY_MODE_AUTO); }); describe('ResponsiveGui', () => { @@ -51,4 +64,28 @@ describe('ResponsiveGui', () => { const { getByTestId } = render(); expect(getByTestId('mock-gui')).toHaveAttribute('data-prop', 'passed-through'); }); + + // Issue #865: user-selected display mode overrides the viewport auto-detection. + test('forces when display mode is desktop, even on a narrow viewport', () => { + mockUseIsNarrowScreen.mockReturnValue(true); + mockUseDisplayMode.mockReturnValue(DISPLAY_MODE_DESKTOP); + const { queryByTestId } = render(); + expect(queryByTestId('mock-gui')).toBeInTheDocument(); + expect(queryByTestId('mock-mobile-gui')).not.toBeInTheDocument(); + }); + + test('forces when display mode is mobile, even on a wide viewport', () => { + mockUseIsNarrowScreen.mockReturnValue(false); + mockUseDisplayMode.mockReturnValue(DISPLAY_MODE_MOBILE); + const { queryByTestId } = render(); + expect(queryByTestId('mock-mobile-gui')).toBeInTheDocument(); + expect(queryByTestId('mock-gui')).not.toBeInTheDocument(); + }); + + test('falls back to viewport detection when display mode is auto', () => { + mockUseDisplayMode.mockReturnValue(DISPLAY_MODE_AUTO); + mockUseIsNarrowScreen.mockReturnValue(true); + const { queryByTestId } = render(); + expect(queryByTestId('mock-mobile-gui')).toBeInTheDocument(); + }); }); diff --git a/packages/scratch-gui/test/unit/lib/use-display-mode.test.js b/packages/scratch-gui/test/unit/lib/use-display-mode.test.js new file mode 100644 index 00000000000..64c2666151f --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/use-display-mode.test.js @@ -0,0 +1,45 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { + DISPLAY_MODE_AUTO, + DISPLAY_MODE_DESKTOP, + DISPLAY_MODE_MOBILE, +} from '../../../src/lib/settings/display-mode/index.js'; +import { persistDisplayMode } from '../../../src/lib/settings/display-mode/persistence.js'; +import useDisplayMode from '../../../src/lib/use-display-mode.js'; + +const Probe = ({ onValue }) => { + onValue(useDisplayMode()); + return null; +}; + +describe('useDisplayMode', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('returns auto when nothing is stored', () => { + let observed = null; + render( (observed = v)} />); + expect(observed).toBe(DISPLAY_MODE_AUTO); + }); + + test('reflects the initial stored value', () => { + persistDisplayMode(DISPLAY_MODE_DESKTOP); + let observed = null; + render( (observed = v)} />); + expect(observed).toBe(DISPLAY_MODE_DESKTOP); + }); + + test('updates live when the mode changes', () => { + const observations = []; + render( observations.push(v)} />); + expect(observations[observations.length - 1]).toBe(DISPLAY_MODE_AUTO); + act(() => { + persistDisplayMode(DISPLAY_MODE_MOBILE); + }); + expect(observations[observations.length - 1]).toBe(DISPLAY_MODE_MOBILE); + }); +}); diff --git a/packages/scratch-gui/test/unit/lib/use-is-narrow-screen.test.js b/packages/scratch-gui/test/unit/lib/use-is-narrow-screen.test.js index ce614de83de..2f90879e940 100644 --- a/packages/scratch-gui/test/unit/lib/use-is-narrow-screen.test.js +++ b/packages/scratch-gui/test/unit/lib/use-is-narrow-screen.test.js @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; import { act, render } from '@testing-library/react'; import React from 'react'; -import useIsNarrowScreen from '../../../src/lib/use-is-narrow-screen.js'; +import useIsNarrowScreen, { NARROW_SCREEN_QUERY } from '../../../src/lib/use-is-narrow-screen.js'; const setMatchMedia = (matches) => { const listeners = new Set(); @@ -26,6 +26,22 @@ const ProbeComponent = ({ onValue }) => { return null; }; +describe('NARROW_SCREEN_QUERY', () => { + // Issue #865: the bare `(max-height: 500px)` clause wrongly caught wide but + // short screens (e.g. a zoomed Chromebook at 1380x480), forcing mobile mode. + // The height clause must be bounded by a phone-ish max-width so wide screens + // stay in desktop mode. + test('narrow-width clause stays at 743px', () => { + expect(NARROW_SCREEN_QUERY).toContain('(max-width: 743px)'); + }); + + test('height clause is bounded by a phone-ish max-width (no bare max-height)', () => { + expect(NARROW_SCREEN_QUERY).toContain('(max-width: 950px) and (max-height: 500px)'); + // guard against regressing to an unbounded height clause + expect(NARROW_SCREEN_QUERY).not.toMatch(/,\s*\(max-height: 500px\)/); + }); +}); + describe('useIsNarrowScreen', () => { test('returns true when matchMedia matches', () => { setMatchMedia(true);