diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md
index 8dde1b5daef..f9ea1aedb3e 100644
--- a/.claude/rules/scratch-gui/smalruby-markers.md
+++ b/.claude/rules/scratch-gui/smalruby-markers.md
@@ -48,6 +48,8 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一
| `src/components/gui/gui.jsx` | meshV2 classroom binding | クラス状態に応じて Mesh v2 ドメインを参加コードに固定する常時マウントの binding |
| `src/components/gui/gui.jsx` | welcome modal | 初回訪問者向けウェルカムモーダル HOC の import と配置、`onShowWelcomeModal` prop |
| `src/containers/gui.jsx` | welcome modal | `onShowWelcomeModal` を Redux `openWelcomeModal()` にマップ |
+| `src/components/gui/gui.jsx` | DNCL mode notice | DnclModeNotice コンポーネントの import・配置・`onRequestExitDnclMode` prop |
+| `src/containers/gui.jsx` | DNCL mode notice | `onRequestExitDnclMode` を Redux `setDnclMode(false)` + `requestExternalExitDnclMode()` にマップ |
| `src/components/gui/gui.jsx` | Redux action props prevention | Redux action props の伝播防止 |
| `src/components/gui/gui.jsx` | iPad portrait narrow desktop stage size | 744〜1023px viewport で stage を small に強制 (issue #572 Phase 3-C, #599 で 768→744 拡張) |
| `src/components/gui/gui.css` | iPad portrait narrow desktop layout | 744〜1023px viewport で editor-wrapper の flex-basis を緩める (issue #572 Phase 3-C, #599 で 768→744 拡張) |
@@ -99,6 +101,7 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一
| `src/containers/menu.jsx` | iPad menu item click fix | メニュー項目クリック時の `setTimeout` 遅延を 0 → 100ms に拡大。iPadOS Safari は `pointerup` から `click` 発火まで ~16–32ms 程度のラグがあり、setTimeout(0) で close すると `
` が click 発火前に unmount され React onClick が skip される問題への対応 |
| `src/containers/blocks.jsx` | palette-toggle initial render | `componentDidMount` 末尾で `_applyPaletteVisibility` を呼び `forceUpdate()` を起動。`this.workspace` はインスタンス変数なので `inject()` 後に再レンダーが走らず、初回 `render()` で workspace=null のまま PaletteToggle がスキップされる問題への対応 (issue #695) |
| `src/containers/blocks.jsx` | iOS flyout touch bleed fix | MobileGui (SP) で「ブロックを作る」タップ時に iOS の SVG タッチイベントが「変数を作る」にも伝播する問題の修正。`handlePromptStart` を 50ms 遅延して `externalProcedureDefCallback` が先に呼ばれた場合にキャンセル (issue #698) |
+| `src/containers/blocks.jsx` | DNCL block filtering | `shouldComponentUpdate` に `dnclMode` を追加して日本語モード切り替え時に即時再レンダリングを保証。import・`getToolboxXML` 内フィルター・`mapStateToProps` への `dnclMode` 追加も含む |
## 関連ファイル
diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md
index 96c8097eee5..fbd6b9a0276 100644
--- a/.claude/rules/scratch-gui/smalruby-prettier-files.md
+++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md
@@ -13,6 +13,7 @@ upstream (Scratch) ファイルは対象外。
**Smalruby 固有ディレクトリ(ディレクトリ内の全ファイルが対象):**
- `src/components/auto-correct-modal/`
+- `src/components/dncl-mode-notice/`
- `src/components/block-display-modal/`
- `src/components/classroom-modal/`
- `src/components/classroom-teacher-modal/`
@@ -204,6 +205,8 @@ upstream (Scratch) ファイルは対象外。
- `test/integration/version-update-notification.test.js`
- `test/integration/workspace-glow-regression.test.js`
- `test/unit/components/action-menu.test.jsx`
+- `test/unit/components/dncl-mode-notice.test.jsx`
+- `test/unit/components/extension-button-dncl.test.jsx`
- `test/unit/components/connected-step.test.jsx`
- `test/unit/components/mobile-bottom-tabs.test.jsx`
- `test/unit/components/mobile-drawer.test.jsx`
@@ -284,6 +287,7 @@ upstream (Scratch) ファイルは対象外。
- `test/unit/make-toolbox-xml-hex.test.js`
- `test/unit/only-blocks-initialization.test.js`
- `test/unit/reducers/cards_reducer.test.js`
+- `test/unit/reducers/dncl-mode-reducer.test.js`
- `test/unit/reducers/classroom-reducer.test.js`
- `test/unit/reducers/menus-reducer.test.js`
- `test/unit/reducers/palette-visibility-reducer.test.js`
diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore
index 82d3fa86f96..7bab2583f56 100644
--- a/packages/scratch-gui/.prettierignore
+++ b/packages/scratch-gui/.prettierignore
@@ -37,6 +37,7 @@ src/components/*
!src/components/smalrubot-firmware-modal/
!src/components/blocks-screenshot-button/
!src/components/google-drive-save-dialog/
+!src/components/dncl-mode-notice/
!src/components/koshien-test-modal/
!src/components/mobile-bottom-tabs/
!src/components/mobile-drawer/
@@ -267,6 +268,8 @@ test/unit/*
# Level 3: test/unit/components/*
test/unit/components/*
!test/unit/components/action-menu.test.jsx
+!test/unit/components/dncl-mode-notice.test.jsx
+!test/unit/components/extension-button-dncl.test.jsx
!test/unit/components/connected-step.test.jsx
!test/unit/components/mobile-bottom-tabs.test.jsx
!test/unit/components/mobile-drawer.test.jsx
@@ -362,6 +365,7 @@ test/unit/lib/*
# Level 3: test/unit/reducers/*
test/unit/reducers/*
!test/unit/reducers/cards_reducer.test.js
+!test/unit/reducers/dncl-mode-reducer.test.js
!test/unit/reducers/classroom-reducer.test.js
!test/unit/reducers/menus-reducer.test.js
!test/unit/reducers/palette-visibility-reducer.test.js
diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css
new file mode 100644
index 00000000000..ff9af49ffff
--- /dev/null
+++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css
@@ -0,0 +1,38 @@
+.notice {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ background-color: rgba(255, 243, 205, 0.95);
+ border-bottom: 1px solid #ffc107;
+ border-left: 1px solid #ffc107;
+ font-size: 12px;
+ gap: 8px;
+ pointer-events: auto;
+}
+
+.message {
+ color: #856404;
+ font-weight: 500;
+ flex: 1;
+}
+
+.exitButton {
+ background: none;
+ border: 1px solid #856404;
+ border-radius: 4px;
+ color: #856404;
+ cursor: pointer;
+ font-size: 11px;
+ padding: 3px 8px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.exitButton:hover {
+ background-color: #ffc107;
+ color: #fff;
+}
diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx
new file mode 100644
index 00000000000..828881dac98
--- /dev/null
+++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx
@@ -0,0 +1,55 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import intlShape from '../../lib/intlShape.js';
+import styles from './dncl-mode-notice.css';
+
+const messages = defineMessages({
+ message: {
+ id: 'gui.dnclModeNotice.message',
+ description: 'Notice shown in blocks tab when DNCL mode is active',
+ defaultMessage: 'Japanese mode: blocks are restricted',
+ },
+ exitButton: {
+ id: 'gui.dnclModeNotice.exitButton',
+ description: 'Button label to exit DNCL mode from the blocks tab notice',
+ defaultMessage: 'Return to Ruby furigana mode',
+ },
+ exitConfirm: {
+ id: 'gui.extensionButton.dnclExtensionConfirm',
+ description: 'Confirm dialog when switching out of DNCL mode',
+ defaultMessage:
+ 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?',
+ },
+});
+
+const DnclModeNotice = ({ dnclMode, onExitDnclMode, intl }) => {
+ const handleExitClick = useCallback(() => {
+ // eslint-disable-next-line no-alert
+ const confirmed = window.confirm(intl.formatMessage(messages.exitConfirm));
+ if (confirmed) onExitDnclMode?.();
+ }, [onExitDnclMode, intl]);
+
+ if (!dnclMode) return null;
+
+ return (
+
+ {intl.formatMessage(messages.message)}
+
+
+ );
+};
+
+DnclModeNotice.propTypes = {
+ dnclMode: PropTypes.bool,
+ intl: intlShape.isRequired,
+ onExitDnclMode: PropTypes.func,
+};
+
+export default injectIntl(DnclModeNotice);
diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx
index 0293bdf73da..2897419e6c3 100644
--- a/packages/scratch-gui/src/components/extension-button/extension-button.jsx
+++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx
@@ -15,33 +15,38 @@ const messages = defineMessages({
description: 'Button to add an extension in the target pane',
defaultMessage: 'Add Extension'
},
- // === Smalruby: Start of DNCL extension alert ===
- dnclExtensionDisabled: {
- id: 'gui.extensionButton.dnclExtensionDisabled',
- description: 'Alert message when extension button is clicked in DNCL mode',
- defaultMessage: 'Extensions are not available in Japanese mode.'
+ // === Smalruby: Start of DNCL extension confirm ===
+ dnclExtensionConfirm: {
+ id: 'gui.extensionButton.dnclExtensionConfirm',
+ description: 'Confirm dialog when extension button is clicked in DNCL mode',
+ defaultMessage: 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?'
}
- // === Smalruby: End of DNCL extension alert ===
+ // === Smalruby: End of DNCL extension confirm ===
});
const ExtensionButton = props => {
const {
intl,
dnclMode,
- onExtensionButtonClick
+ onExtensionButtonClick,
+ onRequestExitDnclMode
} = props;
const {captureFocus} = useContext(ModalFocusContext);
const handleExtensionButtonClick = useCallback(() => {
- // === Smalruby: Start of DNCL extension alert ===
+ // === Smalruby: Start of DNCL extension confirm ===
if (dnclMode) {
- window.alert(intl.formatMessage(messages.dnclExtensionDisabled)); // eslint-disable-line no-alert
+ // eslint-disable-next-line no-alert
+ const confirmed = window.confirm(intl.formatMessage(messages.dnclExtensionConfirm));
+ if (!confirmed) return;
+ onRequestExitDnclMode?.();
+ onExtensionButtonClick?.();
return;
}
- // === Smalruby: End of DNCL extension alert ===
+ // === Smalruby: End of DNCL extension confirm ===
captureFocus();
onExtensionButtonClick?.();
- }, [captureFocus, onExtensionButtonClick, dnclMode, intl]);
+ }, [captureFocus, onExtensionButtonClick, onRequestExitDnclMode, dnclMode, intl]);
return (
@@ -68,7 +73,8 @@ const ExtensionButton = props => {
ExtensionButton.propTypes = {
dnclMode: PropTypes.bool,
intl: intlShape.isRequired,
- onExtensionButtonClick: PropTypes.func
+ onExtensionButtonClick: PropTypes.func,
+ onRequestExitDnclMode: PropTypes.func
};
const ExtensionButtonIntl = injectIntl(ExtensionButton);
diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx
index 9f6dc3f281e..91d2affd331 100644
--- a/packages/scratch-gui/src/components/gui/gui.jsx
+++ b/packages/scratch-gui/src/components/gui/gui.jsx
@@ -24,6 +24,9 @@ import Watermark from '../../containers/watermark.jsx';
import Backpack from '../../containers/backpack.jsx';
import ExtensionsButton from '../extension-button/extension-button.jsx';
+// === Smalruby: Start of DNCL mode notice ===
+import DnclModeNotice from '../dncl-mode-notice/dncl-mode-notice.jsx';
+// === Smalruby: End of DNCL mode notice ===
import WebGlModal from '../../containers/webgl-modal.jsx';
import TipsLibrary from '../../containers/tips-library.jsx';
import Cards from '../../containers/cards.jsx';
@@ -252,6 +255,7 @@ const GUIComponent = props => {
onSetPlatform,
onSetTheme,
onOpenClassroomModal,
+ onRequestExitDnclMode,
// === Smalruby: End of Redux action props prevention ===
rubyTabVisible,
showComingSoon,
@@ -670,12 +674,19 @@ const GUIComponent = props => {
vm={vm}
colorMode={colorMode}
/>
+ {/* === Smalruby: Start of DNCL mode notice === */}
+
+ {/* === Smalruby: End of DNCL mode notice === */}
{/* === Smalruby: Start of DNCL extension button === */}
{/* === Smalruby: End of DNCL extension button === */}
@@ -835,6 +846,7 @@ GUIComponent.propTypes = {
onClickLogo: PropTypes.func,
onCloseAccountNav: PropTypes.func,
onExtensionButtonClick: PropTypes.func,
+ onRequestExitDnclMode: PropTypes.func, // === Smalruby: DNCL mode notice ===
onLogOut: PropTypes.func,
onNewSpriteClick: PropTypes.func,
onNewLibraryCostumeClick: PropTypes.func,
diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx
index 768e60f3cb1..67983df39c4 100644
--- a/packages/scratch-gui/src/containers/blocks.jsx
+++ b/packages/scratch-gui/src/containers/blocks.jsx
@@ -270,7 +270,8 @@ class Blocks extends React.Component {
this.props.stageSize !== nextProps.stageSize ||
this.props.selectedBlocks !== nextProps.selectedBlocks ||
this.props.tutorialAllowedBlocks !== nextProps.tutorialAllowedBlocks ||
- this.props.paletteVisible !== nextProps.paletteVisible
+ this.props.paletteVisible !== nextProps.paletteVisible ||
+ this.props.dnclMode !== nextProps.dnclMode // === Smalruby: DNCL block filtering ===
);
}
componentDidUpdate (prevProps) {
diff --git a/packages/scratch-gui/src/containers/gui.jsx b/packages/scratch-gui/src/containers/gui.jsx
index cf8468a8cb5..bfbaf12ed65 100644
--- a/packages/scratch-gui/src/containers/gui.jsx
+++ b/packages/scratch-gui/src/containers/gui.jsx
@@ -37,6 +37,10 @@ import {setPlatform} from '../reducers/platform';
import {setTheme} from '../reducers/settings';
import {setDynamicAssets} from '../reducers/dynamic-assets';
import {showAlertWithTimeout} from '../reducers/alerts';
+import { // === Smalruby: DNCL mode notice ===
+ setDnclMode,
+ requestExternalExitDnclMode,
+} from '../reducers/dncl-mode'; // === Smalruby: DNCL mode notice ===
import {highlightTarget} from '../reducers/targets';
import {
rubyCodeShape,
@@ -328,6 +332,12 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => ({
onExtensionButtonClick: () => dispatch(openExtensionLibrary()),
+ // === Smalruby: Start of DNCL mode notice ===
+ onRequestExitDnclMode: () => {
+ dispatch(setDnclMode(false));
+ dispatch(requestExternalExitDnclMode());
+ },
+ // === Smalruby: End of DNCL mode notice ===
onActivateTab: tab => dispatch(activateTab(tab)),
onUpdateDynamicAssets: dynamicAssets => dispatch(setDynamicAssets(dynamicAssets)),
onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)),
diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx
index 634a6c9773e..d23502c397c 100644
--- a/packages/scratch-gui/src/containers/ruby-tab.jsx
+++ b/packages/scratch-gui/src/containers/ruby-tab.jsx
@@ -30,7 +30,7 @@ import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx';
import { containsV1Code } from '../lib/ruby-to-blocks-converter/v1-detection';
import { getUrlParams } from '../lib/url-params';
import { showAlertWithTimeout, closeAlertWithId } from '../reducers/alerts';
-import { setDnclMode as setDnclModeAction } from '../reducers/dncl-mode';
+import { setDnclMode as setDnclModeAction, clearExternalExitDnclModeRequest } from '../reducers/dncl-mode';
import { BLOCKS_TAB_INDEX, RUBY_TAB_INDEX } from '../reducers/editor-tab';
import { setAiSaveStatus, clearAiSaveStatus } from '../reducers/koshien-file';
import { closeFileMenu } from '../reducers/menus.js';
@@ -123,6 +123,8 @@ const RubyTab = (props) => {
v1PromptDismissed,
onDismissV1Prompt,
onSetDnclMode,
+ exitDnclModeExternallyRequested,
+ onClearExitDnclModeRequest,
} = props;
// --- State ---
@@ -1161,12 +1163,17 @@ const RubyTab = (props) => {
updateRubyCodeTargetState(vm.editingTarget, rubyVersion);
// Schedule DNCL switch after React re-renders with the new
// Ruby code and the Monaco editor value prop is committed.
+ // Skip if an external exit was requested (e.g. from extension button).
if (wasDncl) {
- requestAnimationFrame(() => {
- setTimeout(() => {
- handleToggleDnclMode();
- }, 0);
- });
+ if (exitDnclModeExternallyRequested) {
+ onClearExitDnclModeRequest?.();
+ } else {
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ handleToggleDnclMode();
+ }, 0);
+ });
+ }
}
}
}
@@ -1328,6 +1335,8 @@ RubyTab.propTypes = {
v1PromptDismissed: PropTypes.bool,
onDismissV1Prompt: PropTypes.func,
onSetDnclMode: PropTypes.func,
+ exitDnclModeExternallyRequested: PropTypes.bool,
+ onClearExitDnclModeRequest: PropTypes.func,
};
const mapStateToProps = (state) => ({
@@ -1340,6 +1349,7 @@ const mapStateToProps = (state) => ({
locale: state.locales.locale,
activeTabIndex: state.scratchGui.editorTab.activeTabIndex,
v1PromptDismissed: state.scratchGui.settings.v1PromptDismissed,
+ exitDnclModeExternallyRequested: state.scratchGui.dnclMode.exitDnclModeExternallyRequested,
});
const mapDispatchToProps = (dispatch) => ({
@@ -1359,6 +1369,7 @@ const mapDispatchToProps = (dispatch) => ({
onMarkRubyTabUsed: () => dispatch(markRubyTabUsed()),
onDismissV1Prompt: () => dispatch(dismissV1Prompt()),
onSetDnclMode: (dnclMode) => dispatch(setDnclModeAction(dnclMode)),
+ onClearExitDnclModeRequest: () => dispatch(clearExternalExitDnclModeRequest()),
});
const ConnectedRubyTab = RubyteeModalHOC(
diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js
index 11a922e7647..05017140f3f 100644
--- a/packages/scratch-gui/src/locales/en.js
+++ b/packages/scratch-gui/src/locales/en.js
@@ -667,7 +667,10 @@ export default {
'gui.menuBar.tutorialTooltip': 'Try Ruby!',
'gui.welcomeTooltip.label': 'Welcome to Smalruby',
'gui.aria.clearButton': 'Clear',
- 'gui.extensionButton.dnclExtensionDisabled': 'Extensions are not available in Japanese mode.',
+ 'gui.extensionButton.dnclExtensionConfirm':
+ 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?',
+ 'gui.dnclModeNotice.message': 'Japanese mode: blocks are restricted.',
+ 'gui.dnclModeNotice.exitButton': 'Return to Ruby furigana mode',
'gui.rubyTab.dnclValidationError':
'This code contains constructs not supported in Japanese mode.\nPlease use only supported instructions before switching modes.',
'gui.mobile.drawer.title': 'Menu',
diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js
index 6413854e139..6a82abe5c15 100644
--- a/packages/scratch-gui/src/locales/ja-Hira.js
+++ b/packages/scratch-gui/src/locales/ja-Hira.js
@@ -1003,7 +1003,10 @@ export default {
'gui.menuBar.tutorialTooltip': 'ルビーをためしてみよう!',
'gui.welcomeTooltip.label': 'スモウルビーへようこそ',
'gui.aria.clearButton': 'クリア',
- 'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。',
+ 'gui.extensionButton.dnclExtensionConfirm':
+ 'にほんごモードではかくちょうきのうはつかえません。\nRubyふりがなモードにもどすとかくちょうきのうがつかえるようになります。\nもどしますか?',
+ 'gui.dnclModeNotice.message': 'にほんごモード:ブロックがせいげんされています',
+ 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードにもどす',
'gui.rubyTab.dnclValidationError':
'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。',
'gui.mobile.drawer.title': 'メニュー',
diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js
index b4f5f47dd68..fd7875979fa 100644
--- a/packages/scratch-gui/src/locales/ja.js
+++ b/packages/scratch-gui/src/locales/ja.js
@@ -976,7 +976,10 @@ export default {
'gui.menuBar.tutorialTooltip': 'ルビーを試してみよう!',
'gui.welcomeTooltip.label': 'スモウルビーへようこそ',
'gui.aria.clearButton': 'クリア',
- 'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。',
+ 'gui.extensionButton.dnclExtensionConfirm':
+ '日本語モードでは拡張機能は使えません。\nRubyふりがなモードに戻すと拡張機能が使えるようになります。\n戻しますか?',
+ 'gui.dnclModeNotice.message': '日本語モード:ブロックが制限されています',
+ 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードに戻す',
'gui.rubyTab.dnclValidationError':
'日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。',
'gui.mobile.drawer.title': 'メニュー',
diff --git a/packages/scratch-gui/src/reducers/dncl-mode.js b/packages/scratch-gui/src/reducers/dncl-mode.js
index b61165a9774..638aea71b30 100644
--- a/packages/scratch-gui/src/reducers/dncl-mode.js
+++ b/packages/scratch-gui/src/reducers/dncl-mode.js
@@ -2,6 +2,8 @@
import { getUrlParams } from '../lib/url-params';
const SET_DNCL_MODE = 'scratch-gui/dncl-mode/SET_DNCL_MODE';
+const REQUEST_EXTERNAL_EXIT_DNCL_MODE = 'scratch-gui/dncl-mode/REQUEST_EXTERNAL_EXIT_DNCL_MODE';
+const CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST = 'scratch-gui/dncl-mode/CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST';
const DNCL_MODE_KEY = 'smalruby:dnclMode';
@@ -22,6 +24,7 @@ const getInitialDnclMode = () => {
const initialState = {
dnclMode: getInitialDnclMode(),
+ exitDnclModeExternallyRequested: false,
};
const reducer = function (state, action) {
@@ -34,6 +37,14 @@ const reducer = function (state, action) {
return Object.assign({}, state, {
dnclMode: action.dnclMode,
});
+ case REQUEST_EXTERNAL_EXIT_DNCL_MODE:
+ return Object.assign({}, state, {
+ exitDnclModeExternallyRequested: true,
+ });
+ case CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST:
+ return Object.assign({}, state, {
+ exitDnclModeExternallyRequested: false,
+ });
default:
return state;
}
@@ -46,4 +57,19 @@ const setDnclMode = function (dnclMode) {
};
};
-export { reducer as default, initialState as dnclModeInitialState, setDnclMode, SET_DNCL_MODE };
+const requestExternalExitDnclMode = function () {
+ return { type: REQUEST_EXTERNAL_EXIT_DNCL_MODE };
+};
+
+const clearExternalExitDnclModeRequest = function () {
+ return { type: CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST };
+};
+
+export {
+ reducer as default,
+ initialState as dnclModeInitialState,
+ setDnclMode,
+ SET_DNCL_MODE,
+ requestExternalExitDnclMode,
+ clearExternalExitDnclModeRequest,
+};
diff --git a/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx
new file mode 100644
index 00000000000..c03cab9bf7b
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx
@@ -0,0 +1,61 @@
+/* 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 DnclModeNotice from '../../../src/components/dncl-mode-notice/dncl-mode-notice.jsx';
+
+const renderNotice = (props) =>
+ render(
+
+
+ ,
+ );
+
+describe('DnclModeNotice', () => {
+ let onExitDnclMode;
+
+ beforeEach(() => {
+ onExitDnclMode = jest.fn();
+ jest.spyOn(window, 'confirm').mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test('renders when dnclMode is true', () => {
+ const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode });
+ expect(getByTestId('dncl-mode-notice')).toBeInTheDocument();
+ });
+
+ test('does not render when dnclMode is false', () => {
+ const { queryByTestId } = renderNotice({ dnclMode: false, onExitDnclMode });
+ expect(queryByTestId('dncl-mode-notice')).not.toBeInTheDocument();
+ });
+
+ test('exit button is present', () => {
+ const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode });
+ expect(getByTestId('dncl-mode-notice-exit-button')).toBeInTheDocument();
+ });
+
+ test('shows confirm dialog when exit button is clicked', () => {
+ const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode });
+ fireEvent.click(getByTestId('dncl-mode-notice-exit-button'));
+ expect(window.confirm).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not call onExitDnclMode when confirm is cancelled', () => {
+ window.confirm.mockReturnValue(false);
+ const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode });
+ fireEvent.click(getByTestId('dncl-mode-notice-exit-button'));
+ expect(onExitDnclMode).not.toHaveBeenCalled();
+ });
+
+ test('calls onExitDnclMode when confirm is accepted', () => {
+ window.confirm.mockReturnValue(true);
+ const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode });
+ fireEvent.click(getByTestId('dncl-mode-notice-exit-button'));
+ expect(onExitDnclMode).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx
new file mode 100644
index 00000000000..0b7b56424f4
--- /dev/null
+++ b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx
@@ -0,0 +1,91 @@
+/* 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 ExtensionButton from '../../../src/components/extension-button/extension-button.jsx';
+import { ModalFocusContext } from '../../../src/contexts/modal-focus-context.jsx';
+
+const mockFocusContext = { captureFocus: jest.fn(), restoreFocus: jest.fn() };
+
+const renderButton = (props) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe('ExtensionButton in DNCL mode', () => {
+ let onExtensionButtonClick;
+ let onRequestExitDnclMode;
+
+ beforeEach(() => {
+ onExtensionButtonClick = jest.fn();
+ onRequestExitDnclMode = jest.fn();
+ jest.spyOn(window, 'confirm').mockReturnValue(false);
+ jest.spyOn(window, 'alert').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test('shows confirm dialog (not alert) when clicked in DNCL mode', () => {
+ const { getByTestId } = renderButton({
+ dnclMode: true,
+ onExtensionButtonClick,
+ onRequestExitDnclMode,
+ });
+ fireEvent.click(getByTestId('extension-button'));
+ expect(window.confirm).toHaveBeenCalledTimes(1);
+ expect(window.alert).not.toHaveBeenCalled();
+ });
+
+ test('does not open extension library when confirm is cancelled', () => {
+ window.confirm.mockReturnValue(false);
+ const { getByTestId } = renderButton({
+ dnclMode: true,
+ onExtensionButtonClick,
+ onRequestExitDnclMode,
+ });
+ fireEvent.click(getByTestId('extension-button'));
+ expect(onExtensionButtonClick).not.toHaveBeenCalled();
+ expect(onRequestExitDnclMode).not.toHaveBeenCalled();
+ });
+
+ test('dispatches exit request and opens extension library when confirm is accepted', () => {
+ window.confirm.mockReturnValue(true);
+ const { getByTestId } = renderButton({
+ dnclMode: true,
+ onExtensionButtonClick,
+ onRequestExitDnclMode,
+ });
+ fireEvent.click(getByTestId('extension-button'));
+ expect(onRequestExitDnclMode).toHaveBeenCalledTimes(1);
+ expect(onExtensionButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('opens extension library directly when not in DNCL mode', () => {
+ const { getByTestId } = renderButton({
+ dnclMode: false,
+ onExtensionButtonClick,
+ onRequestExitDnclMode,
+ });
+ fireEvent.click(getByTestId('extension-button'));
+ expect(window.confirm).not.toHaveBeenCalled();
+ expect(onExtensionButtonClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not call confirm or open library when dnclMode is undefined', () => {
+ const { getByTestId } = renderButton({
+ dnclMode: undefined,
+ onExtensionButtonClick,
+ onRequestExitDnclMode,
+ });
+ fireEvent.click(getByTestId('extension-button'));
+ expect(window.confirm).not.toHaveBeenCalled();
+ expect(onExtensionButtonClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js
index be25a796da1..6cbfaba52e8 100644
--- a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js
+++ b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js
@@ -122,6 +122,7 @@ describe('Ruby tab projectChanged on edit', () => {
alerts: { alertsList: [] },
tutorialOnboarding: { rubyTabUsed: true },
koshienFile: { aiSaveStatus: null },
+ dnclMode: { dnclMode: false, exitDnclModeExternallyRequested: false },
},
locales: { locale: 'en' },
});
diff --git a/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js
new file mode 100644
index 00000000000..25161836249
--- /dev/null
+++ b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js
@@ -0,0 +1,70 @@
+import reducer, {
+ dnclModeInitialState,
+ setDnclMode,
+ requestExternalExitDnclMode,
+ clearExternalExitDnclModeRequest,
+} from '../../../src/reducers/dncl-mode';
+
+describe('dncl-mode reducer', () => {
+ describe('initial state', () => {
+ test('should have dnclMode as false by default', () => {
+ expect(dnclModeInitialState.dnclMode).toBe(false);
+ });
+
+ test('should have exitDnclModeExternallyRequested as false', () => {
+ expect(dnclModeInitialState.exitDnclModeExternallyRequested).toBe(false);
+ });
+ });
+
+ describe('setDnclMode', () => {
+ test('should set dnclMode to true', () => {
+ const state = reducer(dnclModeInitialState, setDnclMode(true));
+ expect(state.dnclMode).toBe(true);
+ });
+
+ test('should set dnclMode to false', () => {
+ const state = reducer({ ...dnclModeInitialState, dnclMode: true }, setDnclMode(false));
+ expect(state.dnclMode).toBe(false);
+ });
+
+ test('should not affect exitDnclModeExternallyRequested', () => {
+ const state = reducer(dnclModeInitialState, setDnclMode(true));
+ expect(state.exitDnclModeExternallyRequested).toBe(false);
+ });
+ });
+
+ describe('requestExternalExitDnclMode', () => {
+ test('should set exitDnclModeExternallyRequested to true', () => {
+ const state = reducer(dnclModeInitialState, requestExternalExitDnclMode());
+ expect(state.exitDnclModeExternallyRequested).toBe(true);
+ });
+
+ test('should not affect dnclMode', () => {
+ const state = reducer({ ...dnclModeInitialState, dnclMode: true }, requestExternalExitDnclMode());
+ expect(state.dnclMode).toBe(true);
+ });
+ });
+
+ describe('clearExternalExitDnclModeRequest', () => {
+ test('should reset exitDnclModeExternallyRequested to false', () => {
+ const active = reducer(dnclModeInitialState, requestExternalExitDnclMode());
+ expect(active.exitDnclModeExternallyRequested).toBe(true);
+
+ const cleared = reducer(active, clearExternalExitDnclModeRequest());
+ expect(cleared.exitDnclModeExternallyRequested).toBe(false);
+ });
+
+ test('should not affect dnclMode', () => {
+ const active = { ...dnclModeInitialState, dnclMode: true, exitDnclModeExternallyRequested: true };
+ const state = reducer(active, clearExternalExitDnclModeRequest());
+ expect(state.dnclMode).toBe(true);
+ });
+ });
+
+ describe('unknown action', () => {
+ test('should return state unchanged', () => {
+ const state = reducer(dnclModeInitialState, { type: 'UNKNOWN' });
+ expect(state).toEqual(dnclModeInitialState);
+ });
+ });
+});