diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md index cbad04806cd..0115643f09a 100644 --- a/.claude/rules/scratch-gui/smalruby-markers.md +++ b/.claude/rules/scratch-gui/smalruby-markers.md @@ -45,7 +45,9 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/components/menu-bar/menu-bar.jsx` | smalrubot firmware menu | SmalrubotS1 メニューの import、ハンドラー、レンダリング、Redux 接続 | | `src/components/gui/gui.jsx` | smalrubot firmware modal | SmalrubotS1 ファームウェアモーダルの import と配置 | | `src/components/gui/gui.jsx` | classroom modal | クラスルームモーダルの import と配置 | +| `src/components/gui/gui.jsx` | narrow screen warning | 狭幅画面警告バナーの import と配置 (issue #572 Phase 1) | | `src/components/gui/gui.jsx` | Redux action props prevention | Redux action props の伝播防止 | +| `src/playground/index.css` | narrow viewport vertical scroll lock | 狭幅画面で縦スクロールを overflow-y: clip で抑止 (issue #572 Phase 1) | | `src/containers/gui.jsx` | smalrubot firmware modal | SmalrubotS1 ファームウェアモーダル state マッピング | | `src/containers/gui.jsx` | classroom modal | クラスルームモーダル state マッピング | | `src/containers/gui.jsx` | classcode auto-open | クラスコード URL パラメーターによるモーダル自動オープン | diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 83ee66b7978..1968d7d85cf 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -21,6 +21,7 @@ upstream (Scratch) ファイルは対象外。 - `src/components/blocks-screenshot-button/` - `src/components/google-drive-save-dialog/` - `src/components/koshien-test-modal/` +- `src/components/narrow-screen-warning/` - `src/components/palette-toggle/` - `src/components/ruby-script-preview/` - `src/components/ruby-toolbar/` @@ -181,6 +182,7 @@ upstream (Scratch) ファイルは対象外。 - `test/integration/version-update-notification.test.js` - `test/unit/components/action-menu.test.jsx` - `test/unit/components/connected-step.test.jsx` +- `test/unit/components/narrow-screen-warning.test.jsx` - `test/unit/components/palette-toggle.test.jsx` - `test/unit/components/project-title-input.test.jsx` - `test/unit/components/scanning-step-name-search.test.js` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index b8291cf86b0..b29a4cf5257 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -38,6 +38,7 @@ src/components/* !src/components/blocks-screenshot-button/ !src/components/google-drive-save-dialog/ !src/components/koshien-test-modal/ +!src/components/narrow-screen-warning/ !src/components/palette-toggle/ !src/components/ruby-script-preview/ !src/components/ruby-toolbar/ @@ -244,6 +245,7 @@ test/unit/* test/unit/components/* !test/unit/components/action-menu.test.jsx !test/unit/components/connected-step.test.jsx +!test/unit/components/narrow-screen-warning.test.jsx !test/unit/components/palette-toggle.test.jsx !test/unit/components/project-title-input.test.jsx !test/unit/components/scanning-step-name-search.test.js diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index bb50118ef1e..9250c34f490 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -38,6 +38,9 @@ import SmalrubotFirmwareModal from '../../containers/smalrubot-firmware-modal.js // === Smalruby: Start of classroom modal === import ClassroomModal from '../../containers/classroom-modal.jsx'; // === Smalruby: End of classroom modal === +// === Smalruby: Start of narrow screen warning === +import NarrowScreenWarning from '../narrow-screen-warning/narrow-screen-warning.jsx'; +// === Smalruby: End of narrow screen warning === import URLLoaderModal from '../url-loader-modal/url-loader-modal.jsx'; import KoshienTestModal from '../koshien-test-modal/koshien-test-modal.jsx'; import RubyTab from '../../containers/ruby-tab.jsx'; @@ -420,6 +423,9 @@ const GUIComponent = props => { ) : null} {/* === Smalruby: End of classroom modal === */} {/* === Smalruby: End of smalrubot firmware modal === */} + {/* === Smalruby: Start of narrow screen warning === */} + + {/* === Smalruby: End of narrow screen warning === */} {!menuBarHidden && { + const [isNarrow, setIsNarrow] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(NARROW_SCREEN_QUERY).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return () => {}; + } + const mql = window.matchMedia(NARROW_SCREEN_QUERY); + const handler = event => setIsNarrow(event.matches); + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + } + // Safari < 14 fallback + mql.addListener(handler); + return () => mql.removeListener(handler); + }, []); + + return isNarrow; +}; + +/** + * バナーを visual viewport の下端に追従させる (position: fixed 前提)。 + * fixed の containing block は layout viewport なので、visual viewport との + * オフセット (visualViewport.offsetTop / offsetLeft) を加味して位置決めする。 + * 縦スクロール領域には影響しない (fixed なので body の overflow に乗らない)。 + * @param {object} ref - バナー root への React ref + * @param {boolean} enabled - 位置追従を有効化するか + */ +const useVisualViewportPosition = (ref, enabled) => { + useLayoutEffect(() => { + if (!enabled || !ref.current) return () => {}; + if (typeof window === 'undefined') return () => {}; + const el = ref.current; + const vv = window.visualViewport; + + const update = () => { + const height = el.offsetHeight; + if (vv) { + // fixed コンテキストなので layout viewport 基準の座標で指定 + el.style.top = `${vv.offsetTop + vv.height - height}px`; + el.style.left = `${vv.offsetLeft}px`; + el.style.width = `${vv.width}px`; + } else { + el.style.top = `${window.innerHeight - height}px`; + el.style.left = '0px'; + el.style.width = `${window.innerWidth}px`; + } + }; + + update(); + const targets = vv ? [vv, window] : [window]; + const events = ['resize', 'scroll']; + for (const t of targets) { + for (const ev of events) { + t.addEventListener(ev, update, { passive: true }); + } + } + return () => { + for (const t of targets) { + for (const ev of events) { + t.removeEventListener(ev, update); + } + } + }; + }, [enabled, ref]); +}; + +const NarrowScreenWarning = () => { + const isNarrow = useIsNarrowScreen(); + // 永続化はしない (issue #572 Phase 1 では狭幅利用は鑑賞用途として + // 案内し続ける)。dismiss は現在の画面表示のみで、リロードや再訪では再表示。 + const [dismissed, setDismissed] = useState(false); + const ref = useRef(null); + const visible = isNarrow && !dismissed; + useVisualViewportPosition(ref, visible); + + const handleClose = useCallback(() => { + setDismissed(true); + }, []); + + if (!visible) { + return null; + } + + if (typeof document === 'undefined') { + return null; + } + + // body 直下に Portal で飛ばし、JS で visualViewport 座標へ位置決め。 + return createPortal( +
+

+ +

+ +
, + document.body, + ); +}; + +export default NarrowScreenWarning; diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index bdda0c767f2..e458cc2dbb7 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -635,4 +635,6 @@ export default { 'gui.extensionButton.dnclExtensionDisabled': 'Extensions are not available in Japanese mode.', 'gui.rubyTab.dnclValidationError': 'This code contains constructs not supported in Japanese mode.\nPlease use only supported instructions before switching modes.', + 'gui.narrowScreenWarning.message': '📱 For full editing, a PC or tablet is recommended.', + 'gui.narrowScreenWarning.close': 'Close', }; diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 457fb5aa167..7694f437c9f 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -941,4 +941,6 @@ export default { 'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。', 'gui.rubyTab.dnclValidationError': 'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。', + 'gui.narrowScreenWarning.message': '📱 ほんかくてきな へんしゅうは PC・タブレットが おすすめです。', + 'gui.narrowScreenWarning.close': 'とじる', }; diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index c9ca5430f1c..d7f15c728d9 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -916,4 +916,6 @@ export default { 'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。', 'gui.rubyTab.dnclValidationError': '日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。', + 'gui.narrowScreenWarning.message': '📱 本格的な編集は PC・タブレットを推奨します。', + 'gui.narrowScreenWarning.close': '閉じる', }; diff --git a/packages/scratch-gui/src/playground/index.css b/packages/scratch-gui/src/playground/index.css index 4b65314a001..7c2cfe1bcb1 100644 --- a/packages/scratch-gui/src/playground/index.css +++ b/packages/scratch-gui/src/playground/index.css @@ -2,7 +2,7 @@ html, body, .app { /* probably unecessary, transitional until layout is refactored */ - width: 100%; + width: 100%; height: 100%; margin: 0; @@ -11,5 +11,18 @@ body, min-height: 600px; /* Min height to fit sprite/backdrop button */ } +/* === Smalruby: Start of narrow viewport vertical scroll lock === */ +/* iPhone 等の狭幅画面で、min-height: 600px と GUI 内部要素の縦方向に */ +/* よって body が viewport より縦に伸びてしまい、ページ全体が縦スクロール */ +/* 可能になっていた。横スクロール (1024px min-width) は残したいので、 */ +/* overflow-y: clip だけ当てる (clip は overflow-x への副作用がない)。 */ +@media (max-width: 767px) { + html, + body { + overflow-y: clip; + } +} +/* === Smalruby: End of narrow viewport vertical scroll lock === */ + /* @todo: move globally? Safe / side FX, for blocks particularly? */ * { box-sizing: border-box; } diff --git a/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx b/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx new file mode 100644 index 00000000000..25f9a49c285 --- /dev/null +++ b/packages/scratch-gui/test/unit/components/narrow-screen-warning.test.jsx @@ -0,0 +1,71 @@ +/* eslint-env jest */ +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import '@testing-library/jest-dom'; +import { act, fireEvent, render } from '@testing-library/react'; +import NarrowScreenWarning from '../../../src/components/narrow-screen-warning/narrow-screen-warning.jsx'; + +const renderWithIntl = ui => + render( + + {ui} + , + ); + +const setMatchMedia = matches => { + const listeners = new Set(); + const mql = { + matches, + media: '', + onchange: null, + addEventListener: (_event, listener) => listeners.add(listener), + removeEventListener: (_event, listener) => listeners.delete(listener), + addListener: listener => listeners.add(listener), + removeListener: listener => listeners.delete(listener), + dispatchEvent: () => false, + }; + window.matchMedia = jest.fn(() => mql); + return { mql, listeners }; +}; + +describe('NarrowScreenWarning', () => { + test('renders banner when viewport is narrow', () => { + setMatchMedia(true); + const { queryByTestId } = renderWithIntl(); + expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument(); + }); + + test('renders nothing when viewport is wide', () => { + setMatchMedia(false); + const { queryByTestId } = renderWithIntl(); + expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument(); + }); + + test('hides banner for the current render after close is clicked', () => { + setMatchMedia(true); + const { queryByTestId, getByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('narrow-screen-warning-close')); + expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument(); + }); + + test('shows banner again on a fresh render even after a previous close', () => { + setMatchMedia(true); + // 1st render: close it + const first = renderWithIntl(); + fireEvent.click(first.getByTestId('narrow-screen-warning-close')); + first.unmount(); + // 2nd render (e.g. reload simulation): banner reappears + const second = renderWithIntl(); + expect(second.queryByTestId('narrow-screen-warning')).toBeInTheDocument(); + }); + + test('appears when viewport shrinks below the breakpoint', () => { + const { listeners } = setMatchMedia(false); + const { queryByTestId } = renderWithIntl(); + expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument(); + act(() => { + listeners.forEach(listener => listener({ matches: true })); + }); + expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument(); + }); +});