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
2 changes: 2 additions & 0 deletions .claude/rules/scratch-gui/smalruby-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 パラメーターによるモーダル自動オープン |
Expand Down
2 changes: 2 additions & 0 deletions .claude/rules/scratch-gui/smalruby-prettier-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/scratch-gui/src/components/gui/gui.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 === */}
<NarrowScreenWarning />
{/* === Smalruby: End of narrow screen warning === */}
{!menuBarHidden && <MenuBar
ariaRole="banner"
ariaLabel={intl.formatMessage(ariaMessages.menuBar)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './narrow-screen-warning.jsx';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.banner {
/* top / left / width は JS (visualViewport) で実時間に制御。 */
/* position: fixed なので body の縦スクロール領域には影響しない。 */
/* fixed の containing block は layout viewport なので、 */
/* visualViewport.offsetTop / offsetLeft で visual viewport 位置を補正する。 */
position: fixed;
top: 0;
left: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
/* iPhone X 系 home indicator 領域を避ける */
padding-bottom: max(8px, env(safe-area-inset-bottom, 8px));
padding-left: max(12px, env(safe-area-inset-left, 12px));
padding-right: max(12px, env(safe-area-inset-right, 12px));
box-sizing: border-box;
background: #4c97ff;
color: white;
font-size: 12px;
line-height: 1.35;
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.15);
}

.message {
flex: 1;
margin: 0;
word-break: break-word;
}

.close-button {
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 4px;
padding: 6px 12px;
color: white;
font-size: 12px;
font-weight: bold;
cursor: pointer;
min-width: 56px;
min-height: 32px;
}

.close-button:hover,
.close-button:focus {
background: rgba(255, 255, 255, 0.35);
outline: none;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FormattedMessage } from 'react-intl';

import styles from './narrow-screen-warning.css';

const NARROW_SCREEN_QUERY = '(max-width: 767px)';

const useIsNarrowScreen = () => {
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(
<div ref={ref} className={styles.banner} role="status" data-testid="narrow-screen-warning">
<p className={styles.message}>
<FormattedMessage
defaultMessage="📱 For full editing, a PC or tablet is recommended."
description="Banner shown on narrow screens (e.g. iPhone) suggesting PC/tablet for editing"
id="gui.narrowScreenWarning.message"
/>
</p>
<button
className={styles.closeButton}
type="button"
onClick={handleClose}
data-testid="narrow-screen-warning-close"
>
<FormattedMessage
defaultMessage="Close"
description="Button to dismiss the narrow-screen warning banner"
id="gui.narrowScreenWarning.close"
/>
</button>
</div>,
document.body,
);
};

export default NarrowScreenWarning;
2 changes: 2 additions & 0 deletions packages/scratch-gui/src/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
2 changes: 2 additions & 0 deletions packages/scratch-gui/src/locales/ja-Hira.js
Original file line number Diff line number Diff line change
Expand Up @@ -941,4 +941,6 @@ export default {
'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。',
'gui.rubyTab.dnclValidationError':
'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。',
'gui.narrowScreenWarning.message': '📱 ほんかくてきな へんしゅうは PC・タブレットが おすすめです。',
'gui.narrowScreenWarning.close': 'とじる',
};
2 changes: 2 additions & 0 deletions packages/scratch-gui/src/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -916,4 +916,6 @@ export default {
'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。',
'gui.rubyTab.dnclValidationError':
'日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。',
'gui.narrowScreenWarning.message': '📱 本格的な編集は PC・タブレットを推奨します。',
'gui.narrowScreenWarning.close': '閉じる',
};
15 changes: 14 additions & 1 deletion packages/scratch-gui/src/playground/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ html,
body,
.app {
/* probably unecessary, transitional until layout is refactored */
width: 100%;
width: 100%;
height: 100%;
margin: 0;

Expand All @@ -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; }
Original file line number Diff line number Diff line change
@@ -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(
<IntlProvider locale="en" messages={{}}>
{ui}
</IntlProvider>,
);

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(<NarrowScreenWarning />);
expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument();
});

test('renders nothing when viewport is wide', () => {
setMatchMedia(false);
const { queryByTestId } = renderWithIntl(<NarrowScreenWarning />);
expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument();
});

test('hides banner for the current render after close is clicked', () => {
setMatchMedia(true);
const { queryByTestId, getByTestId } = renderWithIntl(<NarrowScreenWarning />);
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(<NarrowScreenWarning />);
fireEvent.click(first.getByTestId('narrow-screen-warning-close'));
first.unmount();
// 2nd render (e.g. reload simulation): banner reappears
const second = renderWithIntl(<NarrowScreenWarning />);
expect(second.queryByTestId('narrow-screen-warning')).toBeInTheDocument();
});

test('appears when viewport shrinks below the breakpoint', () => {
const { listeners } = setMatchMedia(false);
const { queryByTestId } = renderWithIntl(<NarrowScreenWarning />);
expect(queryByTestId('narrow-screen-warning')).not.toBeInTheDocument();
act(() => {
listeners.forEach(listener => listener({ matches: true }));
});
expect(queryByTestId('narrow-screen-warning')).toBeInTheDocument();
});
});
Loading