) {
+ return createPortal(
+
+
Custom Dialog
+ {title}
+
+ {children}
+
+
+ ,
+ document.body,
+ );
+}
diff --git a/e2e/src/components/TopBar.astro b/e2e/src/components/TopBar.astro
new file mode 100644
index 00000000..09961074
--- /dev/null
+++ b/e2e/src/components/TopBar.astro
@@ -0,0 +1,21 @@
+
diff --git a/e2e/test/dialog.override-components.test.ts b/e2e/test/dialog.override-components.test.ts
new file mode 100644
index 00000000..9c38473c
--- /dev/null
+++ b/e2e/test/dialog.override-components.test.ts
@@ -0,0 +1,26 @@
+import { test, expect } from '@playwright/test';
+
+const BASE_URL = '/tests/file-tree';
+
+test('developer can override dialog in File Tree', async ({ page }) => {
+ await page.goto(`${BASE_URL}/allow-edits-glob`);
+ await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
+ await page.getByRole('menuitem', { name: `Create file` }).click();
+
+ await page.locator('*:focus').fill('new-file.js');
+ await page.locator('*:focus').press('Enter');
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByRole('heading', { level: 2, name: 'Custom Dialog' })).toBeVisible();
+
+ // default elements should also be visible
+ await expect(dialog.getByText('Created files and folders must match following patterns:')).toBeVisible();
+ await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*');
+ await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js');
+ await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**');
+
+ await dialog.getByRole('button', { name: 'OK' }).click();
+ await expect(dialog).not.toBeVisible();
+});
diff --git a/e2e/test/topbar.override-components.test.ts b/e2e/test/topbar.override-components.test.ts
new file mode 100644
index 00000000..cb34ba1c
--- /dev/null
+++ b/e2e/test/topbar.override-components.test.ts
@@ -0,0 +1,12 @@
+import { test, expect } from '@playwright/test';
+
+test('developer can override TopBar', async ({ page }) => {
+ await page.goto('/');
+
+ const nav = page.getByRole('navigation');
+ await expect(nav.getByText('Custom Top Bar Mounted')).toBeVisible();
+
+ // default elements should also be visible
+ await expect(nav.getByRole('button', { name: 'Open in StackBlitz' })).toBeVisible();
+ await expect(nav.getByRole('button', { name: 'Toggle Theme' })).toBeVisible();
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
index 272d33e8..085bc833 100644
--- a/e2e/tsconfig.json
+++ b/e2e/tsconfig.json
@@ -8,6 +8,6 @@
"@*": ["src/*"]
}
},
- "include": ["src", "./*.ts"],
+ "include": ["src", "./*.ts", "configs/astro.config.override-components.ts"],
"exclude": ["node_modules", "dist"]
}
diff --git a/packages/astro/src/default/components/WorkspacePanelWrapper.tsx b/packages/astro/src/default/components/WorkspacePanelWrapper.tsx
index d33cfd84..1fdbc4a4 100644
--- a/packages/astro/src/default/components/WorkspacePanelWrapper.tsx
+++ b/packages/astro/src/default/components/WorkspacePanelWrapper.tsx
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
import { WorkspacePanel } from '@tutorialkit/react';
import type { Lesson } from '@tutorialkit/types';
import { useEffect } from 'react';
+import { Dialog } from 'tutorialkit:override-components';
import { themeStore } from '../stores/theme-store.js';
import { tutorialStore } from './webcontainer.js';
@@ -20,5 +21,5 @@ export function WorkspacePanelWrapper({ lesson }: Props) {
tutorialStore.setLesson(lesson, { ssr: import.meta.env.SSR });
}
- return ;
+ return ;
}
diff --git a/packages/astro/src/default/env-default.d.ts b/packages/astro/src/default/env-default.d.ts
index f61727d1..101487bf 100644
--- a/packages/astro/src/default/env-default.d.ts
+++ b/packages/astro/src/default/env-default.d.ts
@@ -9,8 +9,9 @@ interface WebContainerConfig {
declare module 'tutorialkit:override-components' {
const topBar: typeof import('./src/default/components/TopBar.astro').default;
+ const dialog: typeof import('@tutorialkit/react/dialog').default;
- export { topBar as TopBar };
+ export { topBar as TopBar, dialog as Dialog };
}
declare const __ENTERPRISE__: boolean;
diff --git a/packages/astro/src/vite-plugins/override-components.ts b/packages/astro/src/vite-plugins/override-components.ts
index 15ad4515..b71a2ea8 100644
--- a/packages/astro/src/vite-plugins/override-components.ts
+++ b/packages/astro/src/vite-plugins/override-components.ts
@@ -17,6 +17,7 @@
* tutorialkit({
* components: {
* TopBar: './CustomTopBar.astro',
+ * Dialog: './CustomDialog.tsx',
* },
* }),
* ],
@@ -29,8 +30,9 @@ export interface OverrideComponentsOptions {
/**
* Component for overriding the top bar.
*
- * This component has 3 slots that are used to pass TutorialKit's default components:
+ * This component has slots that are used to pass TutorialKit's default components:
* - `logo`: Logo of the application
+ * - `open-in-stackblitz-link`: Link for opening current lesson in StackBlitz
* - `theme-switch`: Switch for changing the theme
* - `login-button`: For StackBlitz Enterprise user, the login button
*
@@ -38,11 +40,20 @@ export interface OverrideComponentsOptions {
*
* ```jsx
*
+ *
*
*
* ```
*/
TopBar?: string;
+
+ /**
+ * Component for overriding confirmation dialogs.
+ *
+ * This component has to be a React component and be the default export of that module.
+ * It will receive same props that `@tutorialkit/react/dialog` supports.
+ */
+ Dialog?: string;
}
interface Options {
@@ -66,9 +77,11 @@ export function overrideComponents({ components, defaultRoutes }: Options): Vite
async load(id) {
if (id === resolvedId) {
const topBar = components?.TopBar || resolveDefaultTopBar(defaultRoutes);
+ const dialog = components?.Dialog || '@tutorialkit/react/dialog';
return `
export { default as TopBar } from '${topBar}';
+ export { default as Dialog } from '${dialog}';
`;
}
diff --git a/packages/react/package.json b/packages/react/package.json
index 0d5b8dda..624cdc2e 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -44,6 +44,12 @@
"default": "./dist/core/Terminal/index.js"
}
},
+ "./dialog": {
+ "import": {
+ "types": "./dist/core/Dialog.d.ts",
+ "default": "./dist/core/Dialog.js"
+ }
+ },
"./package.json": "./package.json"
},
"files": [
diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx
index 7665b0ef..1ed82a0e 100644
--- a/packages/react/src/Panels/WorkspacePanel.tsx
+++ b/packages/react/src/Panels/WorkspacePanel.tsx
@@ -3,6 +3,7 @@ import type { TutorialStore } from '@tutorialkit/runtime';
import type { I18n } from '@tutorialkit/types';
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
+import { DialogProvider } from '../core/Dialog.js';
import type { Theme } from '../core/types.js';
import resizePanelStyles from '../styles/resize-panel.module.css';
import { classNames } from '../utils/classnames.js';
@@ -17,9 +18,10 @@ type FileTreeChangeEvent = Parameters['value']>;
}
-interface PanelProps extends Props {
+interface PanelProps extends Omit {
hasEditor: boolean;
hasPreviews: boolean;
hideTerminalPanel: boolean;
@@ -33,7 +35,7 @@ interface TerminalProps extends PanelProps {
/**
* This component is the orchestrator between various interactive components.
*/
-export function WorkspacePanel({ tutorialStore, theme }: Props) {
+export function WorkspacePanel({ tutorialStore, theme, dialog }: Props) {
/**
* Re-render when lesson changes.
* The `tutorialStore.hasEditor()` and other methods below access
@@ -50,13 +52,15 @@ export function WorkspacePanel({ tutorialStore, theme }: Props) {
return (
-
+
+
+
('idle');
const inputRef = useRef(null);
+ const Dialog = useDialog();
if (!allowEditPatterns?.length) {
return children;
@@ -183,38 +183,6 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp
);
}
-function Dialog({
- title,
- confirmText,
- onClose,
- children,
-}: {
- title: string;
- confirmText: string;
- onClose: () => void;
- children: ReactNode;
-}) {
- return (
- !open && onClose()}>
-
-
-
-
-
-
{title}
-
-
{children}
-
-
-
-
-
-
-
-
- );
-}
-
function AllowPatternsList({ allowEditPatterns }: Required>) {
return (
1 && 'list-disc ml-4')}>
diff --git a/packages/react/src/core/Dialog.tsx b/packages/react/src/core/Dialog.tsx
new file mode 100644
index 00000000..d83e3e1c
--- /dev/null
+++ b/packages/react/src/core/Dialog.tsx
@@ -0,0 +1,46 @@
+import * as RadixDialog from '@radix-ui/react-dialog';
+import { type ReactNode, createContext, useContext } from 'react';
+import { Button } from '../Button.js';
+
+interface Props {
+ /** Title of the dialog */
+ title: string;
+
+ /** Text for the confirmation button */
+ confirmText: string;
+
+ /** Callback invoked when dialog is closed */
+ onClose: () => void;
+
+ /** Content of the dialog */
+ children: ReactNode;
+}
+
+const context = createContext(Dialog);
+export const DialogProvider = context.Provider;
+
+export function useDialog() {
+ return useContext(context);
+}
+
+export default function Dialog({ title, confirmText, onClose, children }: Props) {
+ return (
+ !open && onClose()}>
+
+
+
+
+
+
{title}
+
+
{children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 33dbbc25..80a5735a 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -5,5 +5,6 @@ export * from './Panels/EditorPanel.js';
export * from './Panels/PreviewPanel.js';
export * from './Panels/TerminalPanel.js';
export * from './Panels/WorkspacePanel.js';
+export { default as Dialog } from './core/Dialog.js';
export type * from './core/types.js';
export * from './utils/classnames.js';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ef15b05..12a26601 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -184,6 +184,9 @@ importers:
'@types/react':
specifier: ^18.3.3
version: 18.3.3
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.0
'@unocss/reset':
specifier: ^0.59.4
version: 0.59.4
@@ -4003,7 +4006,7 @@ packages:
'@unocss/core': 0.59.4
'@unocss/reset': 0.59.4
'@unocss/vite': 0.59.4(vite@5.4.2)
- vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6)
+ vite: 5.4.2(@types/node@22.4.2)
transitivePeerDependencies:
- rollup
@@ -4202,7 +4205,7 @@ packages:
chokidar: 3.6.0
fast-glob: 3.3.2
magic-string: 0.30.11
- vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6)
+ vite: 5.4.2(@types/node@22.4.2)
transitivePeerDependencies:
- rollup
@@ -6733,6 +6736,7 @@ packages:
/immutable@4.3.6:
resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==}
+ dev: true
/import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
@@ -8825,6 +8829,7 @@ packages:
chokidar: 3.6.0
immutable: 4.3.6
source-map-js: 1.2.0
+ dev: true
/sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
@@ -9633,7 +9638,7 @@ packages:
'@unocss/transformer-directives': 0.59.4
'@unocss/transformer-variant-group': 0.59.4
'@unocss/vite': 0.59.4(vite@5.4.2)
- vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6)
+ vite: 5.4.2(@types/node@22.4.2)
transitivePeerDependencies:
- postcss
- rollup
@@ -9979,6 +9984,7 @@ packages:
sass: 1.77.6
optionalDependencies:
fsevents: 2.3.3
+ dev: true
/vitefu@0.2.5(vite@5.4.2):
resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==}