From 30d5bca1d56afaa43041ee2397e84f4c4835cb30 Mon Sep 17 00:00:00 2001 From: Haz Date: Sat, 6 May 2023 15:52:33 +0200 Subject: [PATCH] Add dialog-next-router example (#2369) This PR adds a new example to the site using Next.js App Router. The example is presented within an iframe with a chrome bar. Alongside this, a few adjustments were made to the `Dialog` component: - Fixed `DialogBackdrop` not including the `data-backdrop` attribute in the initial render, causing a flash of unstyled content when the dialog is initially open. - Fixed `Dialog` calling `hideOnInteractOutside` twice when clicking on the backdrop. - The built-in `DialogBackdrop` component is no longer focusable. - Call `autoFocusOnHide` and `autoFocusOnShow` with a `null` argument when there's no element to focus or the element is not focusable. This allows users to specify a fallback element to focus on hide or show. These changes should make it easier to control the Dialog using the Next.js App Router. --- .changeset/dialog-backdrop-data-attribute.md | 6 + .changeset/dialog-backdrop-duplicate-hide.md | 6 + .changeset/dialog-backdrop-tabindex.md | 6 + .changeset/dialog-final-focus.md | 6 + package-lock.json | 99 +++++++------ package.json | 2 +- packages/ariakit-react-core/package.json | 1 + .../src/dialog/dialog-backdrop.tsx | 70 ++------- .../ariakit-react-core/src/dialog/dialog.tsx | 41 ++---- .../src/dialog/utils/is-backdrop.ts | 13 ++ .../dialog-next-router/@login/login/page.tsx | 50 +++++++ .../dialog-next-router/@login/page.ts | 3 + .../previews/dialog-next-router/default.ts | 1 + .../previews/dialog-next-router/icon.tsx | 22 +++ .../previews/dialog-next-router/layout.tsx | 14 ++ .../previews/dialog-next-router/page.tsx | 9 ++ .../previews/dialog-next-router/readme.md | 47 +++++++ .../previews/dialog-next-router/style.css | 38 +++++ .../dialog-next-router/test-browser.ts | 58 ++++++++ website/app/(examples)/previews/layout.tsx | 16 +++ .../app/(examples)/previews/post-message.tsx | 15 ++ .../(main)/[category]/[page]/page-example.tsx | 13 +- website/app/(main)/[category]/[page]/page.tsx | 11 +- .../[category]/[page]/table-of-contents.tsx | 2 +- .../app/(main)/[category]/list-page-item.tsx | 2 +- website/app/layout.tsx | 5 + website/app/previews/[page]/page.tsx | 6 +- website/build-pages/config.js | 5 +- website/build-pages/const.js | 3 +- website/build-pages/get-example-deps.js | 26 +++- website/build-pages/get-page-entry-files.js | 33 +++-- website/build-pages/pages-webpack-plugin.js | 77 +++++----- website/build-pages/types.ts | 2 +- website/components/footer.tsx | 2 +- website/components/header-menu.tsx | 2 +- website/components/header-theme-switch.tsx | 19 +++ website/components/header-version-select.tsx | 2 +- website/components/header.tsx | 2 +- website/components/hero.tsx | 7 +- website/components/link.ts | 3 - website/components/playground-browser.tsx | 133 ++++++++++++++++++ website/components/playground-client.tsx | 21 +++ website/components/playground-toolbar.tsx | 30 ++-- website/icons/arrow-left.tsx | 20 +++ website/icons/arrow-right.tsx | 3 +- website/icons/new-window.tsx | 1 + website/icons/refresh.tsx | 16 +++ website/link.d.ts | 5 + website/next.config.js | 1 + .../{open-in-stackblitz.ts => stackblitz.ts} | 130 ++++++++++++----- 50 files changed, 837 insertions(+), 268 deletions(-) create mode 100644 .changeset/dialog-backdrop-data-attribute.md create mode 100644 .changeset/dialog-backdrop-duplicate-hide.md create mode 100644 .changeset/dialog-backdrop-tabindex.md create mode 100644 .changeset/dialog-final-focus.md create mode 100644 packages/ariakit-react-core/src/dialog/utils/is-backdrop.ts create mode 100644 website/app/(examples)/previews/dialog-next-router/@login/login/page.tsx create mode 100644 website/app/(examples)/previews/dialog-next-router/@login/page.ts create mode 100644 website/app/(examples)/previews/dialog-next-router/default.ts create mode 100644 website/app/(examples)/previews/dialog-next-router/icon.tsx create mode 100644 website/app/(examples)/previews/dialog-next-router/layout.tsx create mode 100644 website/app/(examples)/previews/dialog-next-router/page.tsx create mode 100644 website/app/(examples)/previews/dialog-next-router/readme.md create mode 100644 website/app/(examples)/previews/dialog-next-router/style.css create mode 100644 website/app/(examples)/previews/dialog-next-router/test-browser.ts create mode 100644 website/app/(examples)/previews/layout.tsx create mode 100644 website/app/(examples)/previews/post-message.tsx delete mode 100644 website/components/link.ts create mode 100644 website/components/playground-browser.tsx create mode 100644 website/icons/arrow-left.tsx create mode 100644 website/icons/refresh.tsx create mode 100644 website/link.d.ts rename website/utils/{open-in-stackblitz.ts => stackblitz.ts} (69%) diff --git a/.changeset/dialog-backdrop-data-attribute.md b/.changeset/dialog-backdrop-data-attribute.md new file mode 100644 index 0000000000..cc79a4038c --- /dev/null +++ b/.changeset/dialog-backdrop-data-attribute.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +Fixed `DialogBackdrop` not including the `data-backdrop` attribute in the initial render, causing a flash of unstyled content when the dialog is initially open. ([#2369](https://github.com/ariakit/ariakit/pull/2369)) diff --git a/.changeset/dialog-backdrop-duplicate-hide.md b/.changeset/dialog-backdrop-duplicate-hide.md new file mode 100644 index 0000000000..a8aa5021ca --- /dev/null +++ b/.changeset/dialog-backdrop-duplicate-hide.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +Fixed `Dialog` calling `hideOnInteractOutside` twice when clicking on the backdrop. ([#2369](https://github.com/ariakit/ariakit/pull/2369)) diff --git a/.changeset/dialog-backdrop-tabindex.md b/.changeset/dialog-backdrop-tabindex.md new file mode 100644 index 0000000000..92d8f9efc2 --- /dev/null +++ b/.changeset/dialog-backdrop-tabindex.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +The built-in `DialogBackdrop` component is no longer focusable. ([#2369](https://github.com/ariakit/ariakit/pull/2369)) diff --git a/.changeset/dialog-final-focus.md b/.changeset/dialog-final-focus.md new file mode 100644 index 0000000000..a9fd9dab33 --- /dev/null +++ b/.changeset/dialog-final-focus.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +Call `autoFocusOnHide` and `autoFocusOnShow` with a `null` argument when there's no element to focus or the element is not focusable. This allows users to specify a fallback element to focus on hide or show. ([#2369](https://github.com/ariakit/ariakit/pull/2369)) diff --git a/package-lock.json b/package-lock.json index 0bc891fbb7..cc28ce102c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,7 @@ "monaco-editor-webpack-plugin": "7.0.1", "monaco-textmate": "3.0.1", "monaco-vscode-textmate-theme-converter": "0.1.7", - "next": "13.3.4", + "next": "13.4.1", "null-loader": "4.0.1", "onigasm": "2.2.5", "open-cli": "7.2.0", @@ -3743,14 +3743,14 @@ } }, "node_modules/@next/env": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-13.3.4.tgz", - "integrity": "sha512-oTK/wRV2qga86m/4VdrR1+/56UA6U1Qv3sIgowB+bZjahniZLEG5BmmQjfoGv7ZuLXBZ8Eec6hkL9BqJcrEL2g==" + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.1.tgz", + "integrity": "sha512-eD6WCBMFjLFooLM19SIhSkWBHtaFrZFfg2Cxnyl3vS3DAdFRfnx5TY2RxlkuKXdIRCC0ySbtK9JXXt8qLCqzZg==" }, "node_modules/@next/swc-darwin-arm64": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.3.4.tgz", - "integrity": "sha512-vux7RWfzxy1lD21CMwZsy9Ej+0+LZdIIj1gEhVmzOQqQZ5N56h8JamrjIVCfDL+Lpj8KwOmFZbPHE8qaYnL2pg==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.1.tgz", + "integrity": "sha512-eF8ARHtYfnoYtDa6xFHriUKA/Mfj/cCbmKb3NofeKhMccs65G6/loZ15a6wYCCx4rPAd6x4t1WmVYtri7EdeBg==", "cpu": [ "arm64" ], @@ -3763,9 +3763,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.3.4.tgz", - "integrity": "sha512-1tb+6JT98+t7UIhVQpKL7zegKnCs9RKU6cKNyj+DYKuC/NVl49/JaIlmwCwK8Ibl+RXxJrK7uSXSIO71feXsgw==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.1.tgz", + "integrity": "sha512-7cmDgF9tGWTgn5Gw+vP17miJbH4wcraMHDCOHTYWkO/VeKT73dUWG23TNRLfgtCNSPgH4V5B4uLHoZTanx9bAw==", "cpu": [ "x64" ], @@ -3778,9 +3778,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.3.4.tgz", - "integrity": "sha512-UqcKkYTKslf5YAJNtZ5XV1D5MQJIkVtDHL8OehDZERHzqOe7jvy41HFto33IDPPU8gJiP5eJb3V9U26uifqHjw==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.1.tgz", + "integrity": "sha512-qwJqmCri2ie8aTtE5gjTSr8S6O8B67KCYgVZhv9gKH44yvc/zXbAY8u23QGULsYOyh1islWE5sWfQNLOj9iryg==", "cpu": [ "arm64" ], @@ -3793,9 +3793,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.3.4.tgz", - "integrity": "sha512-HE/FmE8VvstAfehyo/XsrhGgz97cEr7uf9IfkgJ/unqSXE0CDshDn/4as6rRid74eDR8/exi7c2tdo49Tuqxrw==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.1.tgz", + "integrity": "sha512-qcC54tWNGDv/VVIFkazxhqH1Bnagjfs4enzELVRlUOoJPD2BGJTPI7z08pQPbbgxLtRiu8gl2mXvpB8WlOkMeA==", "cpu": [ "arm64" ], @@ -3808,9 +3808,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.3.4.tgz", - "integrity": "sha512-xU+ugaupGA4SL5aK1ZYEqVHrW3TPOhxVcpaJLfpANm2443J4GfxCmOacu9XcSgy5c51Mq7C9uZ1LODKHfZosRQ==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.1.tgz", + "integrity": "sha512-9TeWFlpLsBosZ+tsm/rWBaMwt5It9tPH8m3nawZqFUUrZyGRfGcI67js774vtx0k3rL9qbyY6+3pw9BCVpaYUA==", "cpu": [ "x64" ], @@ -3823,9 +3823,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.3.4.tgz", - "integrity": "sha512-cZvmf5KcYeTfIK6bCypfmxGUjme53Ep7hx94JJtGrYgCA1VwEuYdh+KouubJaQCH3aqnNE7+zGnVEupEKfoaaA==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.1.tgz", + "integrity": "sha512-sNDGaWmSqTS4QRUzw61wl4mVPeSqNIr1OOjLlQTRuyInxMxtqImRqdvzDvFTlDfdeUMU/DZhWGYoHrXLlZXe6A==", "cpu": [ "x64" ], @@ -3838,9 +3838,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.3.4.tgz", - "integrity": "sha512-7dL+CAUAjmgnVbjXPIpdj7/AQKFqEUL3bKtaOIE1JzJ5UMHHAXCPwzQtibrsvQpf9MwcAmiv8aburD3xH1xf8w==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.1.tgz", + "integrity": "sha512-+CXZC7u1iXdLRudecoUYbhbsXpglYv8KFYsFxKBPn7kg+bk7eJo738wAA4jXIl8grTF2mPdmO93JOQym+BlYGA==", "cpu": [ "arm64" ], @@ -3853,9 +3853,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.3.4.tgz", - "integrity": "sha512-qplTyzEl1vPkS+/DRK3pKSL0HeXrPHkYsV7U6gboHYpfqoHY+bcLUj3gwVUa9PEHRIoq4vXvPzx/WtzE6q52ng==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.1.tgz", + "integrity": "sha512-vIoXVVc7UYO68VwVMDKwJC2+HqAZQtCYiVlApyKEeIPIQpz2gpufzGxk1z3/gwrJt/kJ5CDZjlhYDCzd3hdz+g==", "cpu": [ "ia32" ], @@ -3868,9 +3868,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.3.4.tgz", - "integrity": "sha512-usdvZT7JHrTuXC+4OKN5mCzUkviFkCyJJTkEz8jhBpucg+T7s83e7owm3oNFzmj5iKfvxU2St6VkcnSgpFvEYA==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.1.tgz", + "integrity": "sha512-n8V5ImLQZibKTu10UUdI3nIeTLkliEXe628qxqW9v8My3BAH2a7H0SaCqkV2OgqFnn8sG1wxKYw9/SNJ632kSA==", "cpu": [ "x64" ], @@ -15864,16 +15864,17 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/next": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/next/-/next-13.3.4.tgz", - "integrity": "sha512-sod7HeokBSvH5QV0KB+pXeLfcXUlLrGnVUXxHpmhilQ+nQYT3Im2O8DswD5e4uqbR8Pvdu9pcWgb1CbXZQZlmQ==", + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/next/-/next-13.4.1.tgz", + "integrity": "sha512-JBw2kAIyhKDpjhEWvNVoFeIzNp9xNxg8wrthDOtMctfn3EpqGCmW0FSviNyGgOSOSn6zDaX48pmvbdf6X2W9xA==", "dependencies": { - "@next/env": "13.3.4", + "@next/env": "13.4.1", "@swc/helpers": "0.5.1", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001406", "postcss": "8.4.14", - "styled-jsx": "5.1.1" + "styled-jsx": "5.1.1", + "zod": "3.21.4" }, "bin": { "next": "dist/bin/next" @@ -15882,15 +15883,15 @@ "node": ">=16.8.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "13.3.4", - "@next/swc-darwin-x64": "13.3.4", - "@next/swc-linux-arm64-gnu": "13.3.4", - "@next/swc-linux-arm64-musl": "13.3.4", - "@next/swc-linux-x64-gnu": "13.3.4", - "@next/swc-linux-x64-musl": "13.3.4", - "@next/swc-win32-arm64-msvc": "13.3.4", - "@next/swc-win32-ia32-msvc": "13.3.4", - "@next/swc-win32-x64-msvc": "13.3.4" + "@next/swc-darwin-arm64": "13.4.1", + "@next/swc-darwin-x64": "13.4.1", + "@next/swc-linux-arm64-gnu": "13.4.1", + "@next/swc-linux-arm64-musl": "13.4.1", + "@next/swc-linux-x64-gnu": "13.4.1", + "@next/swc-linux-x64-musl": "13.4.1", + "@next/swc-win32-arm64-msvc": "13.4.1", + "@next/swc-win32-ia32-msvc": "13.4.1", + "@next/swc-win32-x64-msvc": "13.4.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -23487,6 +23488,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index d92c9157b6..b1e0976d73 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "monaco-editor-webpack-plugin": "7.0.1", "monaco-textmate": "3.0.1", "monaco-vscode-textmate-theme-converter": "0.1.7", - "next": "13.3.4", + "next": "13.4.1", "null-loader": "4.0.1", "onigasm": "2.2.5", "open-cli": "7.2.0", diff --git a/packages/ariakit-react-core/package.json b/packages/ariakit-react-core/package.json index 5577dec28a..fba26859fc 100644 --- a/packages/ariakit-react-core/package.json +++ b/packages/ariakit-react-core/package.json @@ -165,6 +165,7 @@ "./dialog/utils/prepend-hidden-dismiss": "./src/dialog/utils/prepend-hidden-dismiss.ts", "./dialog/utils/orchestrate": "./src/dialog/utils/orchestrate.ts", "./dialog/utils/mark-tree-outside": "./src/dialog/utils/mark-tree-outside.ts", + "./dialog/utils/is-backdrop": "./src/dialog/utils/is-backdrop.ts", "./dialog/utils/disable-tree-outside": "./src/dialog/utils/disable-tree-outside.ts", "./dialog/utils/disable-accessibility-tree-outside": "./src/dialog/utils/disable-accessibility-tree-outside.ts", "./dialog/dialog": "./src/dialog/dialog.tsx", diff --git a/packages/ariakit-react-core/src/dialog/dialog-backdrop.tsx b/packages/ariakit-react-core/src/dialog/dialog-backdrop.tsx index 6f9f66b8cd..674e4941b2 100644 --- a/packages/ariakit-react-core/src/dialog/dialog-backdrop.tsx +++ b/packages/ariakit-react-core/src/dialog/dialog-backdrop.tsx @@ -1,29 +1,14 @@ -import type { - KeyboardEvent, - MouseEvent as ReactMouseEvent, - ReactNode, -} from "react"; +import type { ReactNode } from "react"; import { useMemo, useRef } from "react"; -import { isSelfTarget } from "@ariakit/core/utils/events"; import { noop } from "@ariakit/core/utils/misc"; import { useDisclosureContent } from "../disclosure/disclosure-content.js"; -import { - useBooleanEvent, - useEvent, - useForkRef, - useSafeLayoutEffect, -} from "../utils/hooks.js"; +import { useForkRef, useSafeLayoutEffect } from "../utils/hooks.js"; import type { DialogProps } from "./dialog.js"; -import { usePreviousMouseDownRef } from "./utils/use-previous-mouse-down-ref.js"; +import { markAncestor } from "./utils/mark-tree-outside.js"; type DialogBackdropProps = Pick< DialogProps, - | "store" - | "backdrop" - | "backdropProps" - | "hideOnInteractOutside" - | "hideOnEscape" - | "hidden" + "store" | "backdrop" | "backdropProps" | "hidden" > & { children?: ReactNode; }; @@ -32,8 +17,6 @@ export function DialogBackdrop({ store, backdrop, backdropProps, - hideOnInteractOutside = true, - hideOnEscape = true, hidden, children, }: DialogBackdropProps) { @@ -59,47 +42,24 @@ export function DialogBackdrop({ backdrop.style.zIndex = getComputedStyle(dialog).zIndex; }, [contentElement]); - const onClickProp = backdropProps?.onClick; - const hideOnInteractOutsideProp = useBooleanEvent(hideOnInteractOutside); - const mounted = store.useState("mounted"); - const previousMouseDownRef = usePreviousMouseDownRef(mounted); - - const onClick = useEvent((event: ReactMouseEvent) => { - onClickProp?.(event); - if (event.defaultPrevented) return; - if (!isSelfTarget(event)) return; - if (previousMouseDownRef.current !== event.currentTarget) return; - if (!hideOnInteractOutsideProp(event)) return; - event.stopPropagation(); - store.hide(); - }); - - const onKeyDownProp = backdropProps?.onKeyDown; - const hideOnEscapeProp = useBooleanEvent(hideOnEscape); - - // When hideOnInteractOutside is false and the backdrop is clicked, the - // backdrop will receive focus (because we set the tabIndex on it). Therefore, - // the Escape key will not be captured by the Dialog component. So we listen - // to it here. - const onKeyDown = useEvent((event: KeyboardEvent) => { - onKeyDownProp?.(event); - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - if (!isSelfTarget(event)) return; - if (!hideOnEscapeProp(event)) return; - store.hide(); - }); + // Mark the backdrop element as an ancestor of the dialog, otherwise clicking + // on it won't close the dialog when the dialog uses portal, in which case + // elements are only marked outside the portal element. + useSafeLayoutEffect(() => { + const id = contentElement?.id; + if (!id) return; + const backdrop = ref.current; + if (!backdrop) return; + return markAncestor(backdrop, id); + }, [contentElement]); const props = useDisclosureContent({ store, id: undefined, role: "presentation", - tabIndex: -1, hidden, ...backdropProps, ref: useForkRef(backdropProps?.ref, ref), - onClick, - onKeyDown, style: { position: "fixed", top: 0, @@ -113,7 +73,7 @@ export function DialogBackdrop({ const Component = typeof backdrop !== "boolean" ? backdrop || "div" : "div"; return ( - + {children} ); diff --git a/packages/ariakit-react-core/src/dialog/dialog.tsx b/packages/ariakit-react-core/src/dialog/dialog.tsx index ba3d9caa3c..b388953362 100644 --- a/packages/ariakit-react-core/src/dialog/dialog.tsx +++ b/packages/ariakit-react-core/src/dialog/dialog.tsx @@ -64,17 +64,10 @@ import { usePreventBodyScroll } from "./utils/use-prevent-body-scroll.js"; const isSafariBrowser = isSafari(); -function isBackdrop(dialog: HTMLElement, element: Element) { - const id = dialog.id; - if (!id) return; - return element.getAttribute("data-backdrop") === id; -} - function isAlreadyFocusingAnotherElement(dialog: HTMLElement) { const activeElement = getActiveElement(); if (!activeElement) return false; if (contains(dialog, activeElement)) return false; - if (isBackdrop(dialog, activeElement)) return false; // When there's a nested dialog, clicking outside both dialogs will close them // at the same time, but the active element will still point to the nested // dialog element that is still focusable at this point. So we ignore it. @@ -149,7 +142,7 @@ export const useDialog = createHook( // Sets disclosure element using the current active element right after the // dialog is opened. - useEffect(() => { + useSafeLayoutEffect(() => { if (!open) return; const dialog = ref.current; const activeElement = getActiveElement(dialog, true); @@ -301,7 +294,8 @@ export const useDialog = createHook( getFirstTabbableIn(contentElement, true, portal && preserveTabOrder) || // Finally, we fallback to the dialog element itself. contentElement; - if (!autoFocusOnShowProp(element)) return; + const isElementFocusable = isFocusable(element); + if (!autoFocusOnShowProp(isElementFocusable ? element : null)) return; setAutoFocusEnabled(true); element.focus(); }, [ @@ -343,8 +337,7 @@ export const useDialog = createHook( if (isAlreadyFocusingAnotherElement(dialog)) return; const { disclosureElement } = store.getState(); let element = getElementFromProp(finalFocus) || disclosureElement; - if (!element) return; - if (element.id) { + if (element?.id) { const doc = getDocument(element); const selector = `[aria-activedescendant="${element.id}"]`; const composite = doc.querySelector(selector); @@ -359,7 +352,7 @@ export const useDialog = createHook( // it's probably because it's an element inside another popover or menu // that also got hidden when this dialog was shown. We'll try to focus // on their disclosure element instead. - if (!isFocusable(element)) { + if (element && !isFocusable(element)) { const maybeParentDialog = closest(element, "[data-dialog]"); if (maybeParentDialog && maybeParentDialog.id) { const doc = getDocument(maybeParentDialog); @@ -370,8 +363,8 @@ export const useDialog = createHook( } } } - if (!isFocusable(element)) { - if (!retry) return; + const isElementFocusable = element && isFocusable(element); + if (!isElementFocusable && retry) { // If the element is still not focusable by this time, we retry once // again on the next frame. This is sometimes necessary because there // may be nested dialogs that still need a tick to remove the inert @@ -379,8 +372,9 @@ export const useDialog = createHook( requestAnimationFrame(() => focusOnHide(false)); return; } - if (!autoFocusOnHideProp(element)) return; - element.focus(); + if (!autoFocusOnHideProp(isElementFocusable ? element : null)) return; + if (!isElementFocusable) return; + element?.focus(); }; if (!open) { // If this effect is running while the open state is false, this means @@ -452,8 +446,6 @@ export const useDialog = createHook( store={store} backdrop={backdrop} backdropProps={backdropProps} - hideOnInteractOutside={hideOnInteractOutside} - hideOnEscape={hideOnEscape} hidden={hiddenProp} > {element} @@ -462,14 +454,7 @@ export const useDialog = createHook( } return element; }, - [ - store, - backdrop, - backdropProps, - hideOnInteractOutside, - hideOnEscape, - hiddenProp, - ] + [store, backdrop, backdropProps, hiddenProp] ); const [headingId, setHeadingId] = useState(); @@ -603,7 +588,7 @@ export interface DialogOptions * a different element to receive focus. * @default true */ - autoFocusOnShow?: BooleanOrCallback; + autoFocusOnShow?: BooleanOrCallback; /** * Determines whether an element outside of the dialog will be focused when * the dialog is hidden if another element hasn't been focused in the action @@ -613,7 +598,7 @@ export interface DialogOptions * element to be focused. * @default true */ - autoFocusOnHide?: BooleanOrCallback; + autoFocusOnHide?: BooleanOrCallback; /** * Specifies the element that will receive focus when the dialog is first * opened. It can be an `HTMLElement` or a `React.RefObject` with an diff --git a/packages/ariakit-react-core/src/dialog/utils/is-backdrop.ts b/packages/ariakit-react-core/src/dialog/utils/is-backdrop.ts new file mode 100644 index 0000000000..5d18d0719b --- /dev/null +++ b/packages/ariakit-react-core/src/dialog/utils/is-backdrop.ts @@ -0,0 +1,13 @@ +export function isBackdrop( + element?: Element | null, + dialog?: HTMLElement | null +) { + if (!element) return false; + const backdrop = element.getAttribute("data-backdrop"); + if (backdrop == null) return false; + if (backdrop === "") return true; + if (backdrop === "true") return true; + const id = dialog?.id; + if (!id) return false; + return backdrop === id; +} diff --git a/website/app/(examples)/previews/dialog-next-router/@login/login/page.tsx b/website/app/(examples)/previews/dialog-next-router/@login/login/page.tsx new file mode 100644 index 0000000000..fbf884c2c1 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/@login/login/page.tsx @@ -0,0 +1,50 @@ +"use client"; +import * as Ariakit from "@ariakit/react"; +import { usePathname, useRouter } from "next/navigation.js"; + +export default function Page() { + const router = useRouter(); + const pathname = usePathname(); + + const close = () => router.push("/previews/dialog-next-router"); + + const dialog = Ariakit.useDialogStore({ + open: true, + setOpen: (open) => !open && close(), + }); + + return ( + { + if (!element) { + const selector = `[href="${pathname}"]`; + const finalFocus = document.querySelector(selector); + finalFocus?.focus(); + } + return true; + }} + > + Login +
+ + + +
+
+ ); +} diff --git a/website/app/(examples)/previews/dialog-next-router/@login/page.ts b/website/app/(examples)/previews/dialog-next-router/@login/page.ts new file mode 100644 index 0000000000..67e0859135 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/@login/page.ts @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/website/app/(examples)/previews/dialog-next-router/default.ts b/website/app/(examples)/previews/dialog-next-router/default.ts new file mode 100644 index 0000000000..900abf02d4 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/default.ts @@ -0,0 +1 @@ +export { default } from "./page.jsx"; diff --git a/website/app/(examples)/previews/dialog-next-router/icon.tsx b/website/app/(examples)/previews/dialog-next-router/icon.tsx new file mode 100644 index 0000000000..4a33c167b2 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/icon.tsx @@ -0,0 +1,22 @@ +export default function Icon() { + return ( + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + ); +} diff --git a/website/app/(examples)/previews/dialog-next-router/layout.tsx b/website/app/(examples)/previews/dialog-next-router/layout.tsx new file mode 100644 index 0000000000..47f9f3a6da --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/layout.tsx @@ -0,0 +1,14 @@ +import "./style.css"; +import type { ReactNode } from "react"; + +export default function Layout(props: { + children: ReactNode; + login: ReactNode; +}) { + return ( +
+ {props.children} + {props.login} +
+ ); +} diff --git a/website/app/(examples)/previews/dialog-next-router/page.tsx b/website/app/(examples)/previews/dialog-next-router/page.tsx new file mode 100644 index 0000000000..3ab8811d92 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/page.tsx @@ -0,0 +1,9 @@ +import Link from "next/link.js"; + +export default function Page() { + return ( + + Login + + ); +} diff --git a/website/app/(examples)/previews/dialog-next-router/readme.md b/website/app/(examples)/previews/dialog-next-router/readme.md new file mode 100644 index 0000000000..78b4a070da --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/readme.md @@ -0,0 +1,47 @@ +# Dialog with Next.js App Router + +

+ Using Next.js Parallel Routes to create an accessible modal Dialog that is rendered on the server and controlled by the URL, with built-in focus management. +

+ +Example + +## Controlling the Dialog state + +To control the open state, you can pass the [`open`](/apis/dialog-store#open) and [`setOpen`](/apis/dialog-store#setopen) props to [`useDialogStore`](/apis/dialog-store). Theese props allow you to synchronize the dialog state with other state sources, such as the browser history. + +In this example, since the dialog is only rendered when the route matches, we can pass `open: true` to the store so that the dialog is always open. Then, we can use `setOpen` to navigate back when the dialog is closed: + +```js +const router = useRouter(); + +const dialog = Ariakit.useDialogStore({ + open: true, + setOpen(open) { + if (!open) { + router.push("/previews/dialog-next-router"); + } + }, +}); +``` + +You can learn more about controlled state on the [Component stores](/guide/component-stores#controlled-state) guide. + +## Restoring focus on hide + +When the dialog is closed, the focus is automatically returned to the element that was previously focused before opening the dialog. Typically, this is the element that triggered the dialog. However, in cases where a user navigates to the modal URL directly, there is no element to focus on hide. + +To handle this scenario, the [`autoFocusOnHide`](/apis/dialog#autofocusonhide) prop can be used to specify a fallback element to focus on hide: + +```jsx +const pathname = usePathname(); + + { + if (!element) { + document.querySelector(`[href="${pathname}"]`)?.focus(); + } + return true; + }} +> +``` diff --git a/website/app/(examples)/previews/dialog-next-router/style.css b/website/app/(examples)/previews/dialog-next-router/style.css new file mode 100644 index 0000000000..52847cfb13 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/style.css @@ -0,0 +1,38 @@ +@import url("examples/dialog/style.css"); + +.dialog { + @apply + max-w-[320px] + ; +} + +.form { + @apply + flex + flex-col + gap-6 + pt-4 + ; +} + +.input { + @apply + h-10 + px-4 + text-base + w-full + rounded-md + border-none + placeholder-black/60 + dark:placeholder-white/[46%] + text-black + dark:text-white + bg-gray-150/40 + dark:bg-gray-850 + hover:bg-gray-150 + dark:hover:bg-gray-900 + shadow-input + dark:shadow-input-dark + focus-visible:ariakit-outline-input + ; +} diff --git a/website/app/(examples)/previews/dialog-next-router/test-browser.ts b/website/app/(examples)/previews/dialog-next-router/test-browser.ts new file mode 100644 index 0000000000..5900b17a68 --- /dev/null +++ b/website/app/(examples)/previews/dialog-next-router/test-browser.ts @@ -0,0 +1,58 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +const getDialog = (page: Page) => page.getByRole("dialog", { name: "Login" }); + +const getLink = (page: Page) => page.getByRole("link", { name: "Login" }); + +const getInput = (page: Page, name: string) => + page.getByRole("textbox", { name }); + +test.beforeEach(async ({ page }) => { + await page.goto("/previews/dialog-next-router", { waitUntil: "networkidle" }); +}); + +test("show/hide", async ({ page }) => { + // Open with click + await getLink(page).click(); + await expect(page).toHaveURL(/router\/login$/); + await expect(getDialog(page)).toBeVisible(); + await expect(getInput(page, "Email")).toBeFocused(); + // Close with Escape + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(/router$/); + await expect(getDialog(page)).not.toBeVisible(); + await expect(getLink(page)).toBeFocused(); + // Open with browser back + await page.goBack(); + await expect(page).toHaveURL(/router\/login$/); + await expect(getDialog(page)).toBeVisible(); + await expect(getInput(page, "Email")).toBeFocused(); + // Close by clicking outside + await page.mouse.click(0, 0); + await expect(page).toHaveURL(/router$/); + await expect(getDialog(page)).not.toBeVisible(); + await expect(getLink(page)).toBeFocused(); + // Open by navigating to /login + await page.goto("/previews/dialog-next-router/login"); + await expect(getDialog(page)).toBeVisible(); + await expect(getInput(page, "Email")).toBeFocused(); + // Close with browser back + await page.goBack(); + await expect(page).toHaveURL(/router$/); + await expect(getDialog(page)).not.toBeVisible(); + // Open with browser forward + await page.goForward(); + await expect(page).toHaveURL(/router\/login$/); + await expect(getDialog(page)).toBeVisible(); + await expect(getInput(page, "Email")).toBeFocused(); + // Refresh the page + await page.reload(); + await expect(getDialog(page)).toBeVisible(); + await expect(getInput(page, "Email")).toBeFocused(); + // Close with form submit + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/router$/); + await expect(getDialog(page)).not.toBeVisible(); + await expect(getLink(page)).toBeFocused(); +}); diff --git a/website/app/(examples)/previews/layout.tsx b/website/app/(examples)/previews/layout.tsx new file mode 100644 index 0000000000..a6596c1e6a --- /dev/null +++ b/website/app/(examples)/previews/layout.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from "react"; +import { tw } from "utils/tw.js"; +import PostMessage from "./post-message.jsx"; + +export default function Layout({ children }: PropsWithChildren) { + return ( +
+ {children} + +
+ ); +} diff --git a/website/app/(examples)/previews/post-message.tsx b/website/app/(examples)/previews/post-message.tsx new file mode 100644 index 0000000000..cfe3a6ad27 --- /dev/null +++ b/website/app/(examples)/previews/post-message.tsx @@ -0,0 +1,15 @@ +"use client"; +import { useLayoutEffect } from "react"; +import { usePathname } from "next/navigation.js"; + +export default function PostMessage() { + const pathname = usePathname(); + + useLayoutEffect(() => { + if (window.parent) { + window.parent.postMessage({ type: "pathname", pathname }, "/"); + } + }, [pathname]); + + return null; +} diff --git a/website/app/(main)/[category]/[page]/page-example.tsx b/website/app/(main)/[category]/[page]/page-example.tsx index 1776e7b264..d7a90fc881 100644 --- a/website/app/(main)/[category]/[page]/page-example.tsx +++ b/website/app/(main)/[category]/[page]/page-example.tsx @@ -74,12 +74,15 @@ export async function PageExample({ css += await parseCSSFile(file, { id, tailwindConfig, contents }); } + const isAppDir = pageFilename.startsWith(process.cwd()); + const showPreview = type !== "code" && !isAppDir; + return (
- } githubLink={getGithubLink(path)} + previewLink={previewLink} + preview={showPreview ? : null} />
); diff --git a/website/app/(main)/[category]/[page]/page.tsx b/website/app/(main)/[category]/[page]/page.tsx index 435649b27b..4479f9dc3d 100644 --- a/website/app/(main)/[category]/[page]/page.tsx +++ b/website/app/(main)/[category]/[page]/page.tsx @@ -9,10 +9,10 @@ import { getPageTreeFromContent } from "build-pages/get-page-tree.js"; import pagesIndex from "build-pages/index.js"; import type { TableOfContents as TableOfContentsData } from "build-pages/types.js"; import { CodeBlock } from "components/code-block.js"; -import { Link } from "components/link.js"; import matter from "gray-matter"; import { Hashtag } from "icons/hashtag.js"; import { NewWindow } from "icons/new-window.js"; +import Link from "next/link.js"; import { notFound } from "next/navigation.js"; import parseNumericRange from "parse-numeric-range"; import ReactMarkdown from "react-markdown"; @@ -47,7 +47,7 @@ const style = { link: tw` rounded-sm focus-visible:no-underline focus-visible:ariakit-outline-input underline [text-decoration-skip-ink:none] hover:decoration-[3px] - underline-offset-[0.125em] + underline-offset-[0.25em] font-medium dark:font-normal text-blue-700 dark:text-blue-400 `, @@ -69,11 +69,10 @@ const style = { ${stickyHeading} `, paragraph: tw` - dark:text-white/80 leading-7 tracking-[-0.02em] dark:tracking-[-0.01em] + dark:text-white/[85%] leading-7 tracking-[-0.016em] dark:tracking-[-0.008em] - data-[description]:-translate-y-4 data-[description]:text-lg + data-[description]:-translate-y-4 data-[description]:text-xl data-[description]:text-black/70 dark:data-[description]:text-white/60 - data-[description]:!tracking-tight [&_code]:rounded [&_code]:p-1 [&_code]:text-[0.9375em] [&_code]:bg-black/[6.5%] dark:[&_code]:bg-white/[6.5%] @@ -91,7 +90,7 @@ const style = { `, }; -function getPageNames(dir: string) { +function getPageNames(dir: string | string[]) { return getPageEntryFiles(dir).map(getPageName); } diff --git a/website/app/(main)/[category]/[page]/table-of-contents.tsx b/website/app/(main)/[category]/[page]/table-of-contents.tsx index 1a1dd95b84..74c989e3bb 100644 --- a/website/app/(main)/[category]/[page]/table-of-contents.tsx +++ b/website/app/(main)/[category]/[page]/table-of-contents.tsx @@ -4,9 +4,9 @@ import { useEffect, useRef, useState } from "react"; import { cx } from "@ariakit/core/utils/misc"; import * as Ariakit from "@ariakit/react"; import type { TableOfContents as Data } from "build-pages/types.js"; -import { Link } from "components/link.js"; import { Popup } from "components/popup.js"; import { List } from "icons/list.js"; +import Link from "next/link.js"; import { tw } from "utils/tw.js"; import { useMedia } from "utils/use-media.js"; diff --git a/website/app/(main)/[category]/list-page-item.tsx b/website/app/(main)/[category]/list-page-item.tsx index 7f6f7f74c1..34ba107105 100644 --- a/website/app/(main)/[category]/list-page-item.tsx +++ b/website/app/(main)/[category]/list-page-item.tsx @@ -1,7 +1,7 @@ import type { AnchorHTMLAttributes, ReactNode } from "react"; import { useId } from "react"; import { cx } from "@ariakit/core/utils/misc"; -import { Link } from "components/link.js"; +import Link from "next/link.js"; import { tw } from "utils/tw.js"; const style = { diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 179f840bf5..3c09282594 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -19,6 +19,11 @@ if (!("theme" in localStorage)) { }) } } +window.addEventListener("storage", (event) => { + if (event.key === "theme") { + classList(event.newValue === "dark" ? "add" : "remove"); + } +}); `; const inter = Inter({ subsets: ["latin"] }); diff --git a/website/app/previews/[page]/page.tsx b/website/app/previews/[page]/page.tsx index 740e3d8dc0..c95ade3be6 100644 --- a/website/app/previews/[page]/page.tsx +++ b/website/app/previews/[page]/page.tsx @@ -25,8 +25,10 @@ export function generateStaticParams() { const tailwindConfig = resolve(process.cwd(), "../tailwind.config.cjs"); -function getPageNames(dir: string) { - return getPageEntryFiles(dir).map(getPageName); +function getPageNames(dir: string | string[]) { + return getPageEntryFiles(dir) + .filter((path) => !path.startsWith(process.cwd())) + .map(getPageName); } async function parseStyles(cssFiles: string[]) { diff --git a/website/build-pages/config.js b/website/build-pages/config.js index 8141c30ad2..6bd46a6628 100644 --- a/website/build-pages/config.js +++ b/website/build-pages/config.js @@ -44,7 +44,10 @@ const pages = [ { slug: "examples", title: "Examples", - sourceContext: join(root, "examples"), + sourceContext: [ + join(root, "examples"), + join(process.cwd(), "app/(examples)/previews"), + ], getGroup: (filename) => { const page = getPageName(filename); const component = [...components] diff --git a/website/build-pages/const.js b/website/build-pages/const.js index 08d7abf8e7..93e4891d05 100644 --- a/website/build-pages/const.js +++ b/website/build-pages/const.js @@ -1,5 +1,6 @@ // @ts-check -export const PAGE_INDEX_FILE_REGEX = /\/[^\/]+\/(index\.[tj]sx?|readme\.md)/i; +export const PAGE_INDEX_FILE_REGEX = + /\/[^\/]+\/((index|page)\.[tj]sx?|readme\.md)/i; export const PAGE_FILE_REGEX = new RegExp( `(${PAGE_INDEX_FILE_REGEX.source}|\.md)$`, "i" diff --git a/website/build-pages/get-example-deps.js b/website/build-pages/get-example-deps.js index f6f5fa969c..cf2187453b 100644 --- a/website/build-pages/get-example-deps.js +++ b/website/build-pages/get-example-deps.js @@ -1,12 +1,14 @@ import { readFileSync } from "fs"; -import { dirname } from "path"; +import { dirname, join } from "path"; import { parseSync, traverse } from "@babel/core"; import * as t from "@babel/types"; +import { globSync } from "glob"; import { readPackageUpSync } from "read-pkg-up"; import resolveFrom from "resolve-from"; import ts from "typescript"; const host = ts.createCompilerHost({}); +let warnedAboutVersion = false; /** * @typedef {{ [file: string]: Record, dependencies: @@ -46,7 +48,8 @@ function getPackageVersion(source) { if (!result) return "latest"; packageCache.set(source, result); const { version } = result.packageJson; - if (!version) { + if (!version && !warnedAboutVersion) { + warnedAboutVersion = true; console.log("No version found for", source); } return version || "latest"; @@ -96,6 +99,10 @@ export function getExampleDeps( deps = { dependencies: {}, devDependencies: {} } ) { if (!/\.[tj]sx?$/.test(filename)) return deps; + + if (deps[filename]) return deps; + deps[filename] = {}; + const content = readFileSync(filename, "utf8"); const parsed = parseSync(content, { filename, @@ -109,8 +116,19 @@ export function getExampleDeps( assignExternal(deps, "react", filename); assignExternal(deps, "react-dom", filename); - if (!deps[filename]) { - deps[filename] = {}; + const isAppDir = /\/app\/.*\/page\.[mc]?[tj]sx?$/.test(filename); + + if (isAppDir) { + const dir = dirname(filename); + const files = globSync("**/{page,default,layout,loading}.{js,jsx,ts,tsx}", { + cwd: dir, + dotRelative: true, + ignore: Object.keys(deps), + }); + + files.forEach((file) => { + getExampleDeps(join(dir, file), deps); + }); } traverse(parsed, { diff --git a/website/build-pages/get-page-entry-files.js b/website/build-pages/get-page-entry-files.js index 4edc567512..20966b03df 100644 --- a/website/build-pages/get-page-entry-files.js +++ b/website/build-pages/get-page-entry-files.js @@ -8,7 +8,7 @@ import { pathToPosix } from "./path-to-posix.js"; /** * Reads a directory recursively and returns a list of files that match the * given pattern. - * @param {string} context + * @param {string | string[]} context * @param {RegExp} pattern * @param {string[]} [files] */ @@ -17,20 +17,23 @@ export function getPageEntryFiles( pattern = PAGE_FILE_REGEX, files = [] ) { - const items = readdirSync(context, { withFileTypes: true }); - for (const item of items) { - const itemPath = join(context, item.name); - const posixPath = pathToPosix(itemPath); - if (/node_modules/.test(itemPath)) continue; - if (item.isDirectory()) { - getPageEntryFiles(itemPath, pattern, files); - } else if (pattern.test(posixPath)) { - const pageName = getPageName(posixPath); - const index = files.findIndex((file) => getPageName(file) === pageName); - if (index !== -1) { - files.splice(index, 1, posixPath); - } else { - files.push(posixPath); + const contexts = Array.isArray(context) ? context : [context]; + for (const context of contexts) { + const items = readdirSync(context, { withFileTypes: true }); + for (const item of items) { + const itemPath = join(context, item.name); + const posixPath = pathToPosix(itemPath); + if (/node_modules/.test(itemPath)) continue; + if (item.isDirectory()) { + getPageEntryFiles(itemPath, pattern, files); + } else if (pattern.test(posixPath)) { + const pageName = getPageName(posixPath); + const index = files.findIndex((file) => getPageName(file) === pageName); + if (index !== -1) { + files.splice(index, 1, posixPath); + } else { + files.push(posixPath); + } } } } diff --git a/website/build-pages/pages-webpack-plugin.js b/website/build-pages/pages-webpack-plugin.js index 01b5885fbf..3e67b908c2 100644 --- a/website/build-pages/pages-webpack-plugin.js +++ b/website/build-pages/pages-webpack-plugin.js @@ -56,9 +56,10 @@ function writeFiles(buildDir, pages) { writeFileSync(depsFile, depsContents); // examples.js - // TODO: We can use this same logic to produce preview pages for all examples, - // and not only the ones that are in the examples folder. - const examples = [...new Set(Object.values(sourceFiles).flat())]; + const sourceFilesWithoutAppDir = Object.values(sourceFiles) + .flat() + .filter((file) => !file.startsWith(process.cwd())); + const examples = [...new Set(sourceFilesWithoutAppDir)]; const examplesFile = join(buildDir, "examples.js"); const examplesContents = `import { lazy } from "react";\n\nexport default {\n${examples @@ -74,7 +75,12 @@ function writeFiles(buildDir, pages) { const contentsFile = join(buildDir, "contents.json"); const meta = markdownFiles.map((file) => { - const page = pages.find((page) => file.startsWith(page.sourceContext)); + const page = pages.find((page) => { + const context = Array.isArray(page.sourceContext) + ? page.sourceContext + : [page.sourceContext]; + return context.some((context) => file.startsWith(context)); + }); if (!page) throw new Error(`Could not find page for file: ${file}`); return getPageSections(file, page.slug, page.getGroup); }); @@ -134,9 +140,12 @@ function writeFiles(buildDir, pages) { const iconsContents = Object.entries(icons) .map(([file, iconPath]) => { - const category = pages.find((page) => - file.startsWith(page.sourceContext) - ); + const category = pages.find((page) => { + const context = Array.isArray(page.sourceContext) + ? page.sourceContext + : [page.sourceContext]; + return context.some((context) => file.startsWith(context)); + }); invariant(category); const pageName = getPageName(file); return iconPath @@ -180,39 +189,25 @@ class PagesWebpackPlugin { */ apply(compiler) { const pages = this.pages; + const contexts = pages.flatMap((page) => page.sourceContext); - // Find the CSS rule and exclude the pages from it so we can handle the CSS - // ourselves. - const rule = compiler.options.module.rules.find( - (rule) => typeof rule === "object" && typeof rule.oneOf === "object" + const externalContexts = contexts.filter( + (context) => !context.startsWith(process.cwd()) ); - const cssRules = - typeof rule === "object" && - rule.oneOf?.filter( - (rule) => rule.test && /\.css/.test(rule.test.toString()) - ); - - if (cssRules) { - const excludes = pages.map((page) => page.sourceContext); - cssRules.forEach((cssRule) => { - cssRule.exclude = Array.isArray(cssRule.exclude) - ? [...cssRule.exclude, ...excludes] - : excludes; - }); - - compiler.options.module.rules.push({ - include: pages.map((page) => page.sourceContext), - test: /style\.css$/, - loader: "null-loader", - }); - } + compiler.options.module.rules.push({ + issuer: (value) => + externalContexts.some((exclude) => value.startsWith(exclude)), + include: externalContexts, + test: /style\.css$/, + loader: "null-loader", + }); compiler.hooks.make.tap("PagesWebpackPlugin", (compilation) => { if (!compiler.watchMode) return; - for (const page of pages) { - compilation.contextDependencies.add(page.sourceContext); - getPageEntryFiles(page.sourceContext).forEach((file) => { + for (const context of contexts) { + compilation.contextDependencies.add(context); + getPageEntryFiles(context).forEach((file) => { compilation.fileDependencies.add(file); compilation.contextDependencies.add(dirname(file)); }); @@ -234,24 +229,22 @@ class PagesWebpackPlugin { }; for (const file of removedFiles) { - if (!pages.some((page) => file.includes(page.sourceContext))) continue; + if (!contexts.some((context) => file.includes(context))) continue; log(file, true); return writeFiles(this.buildDir, pages); } if (modifiedFiles.size === 1) { - const page = pages.find((page) => - modifiedFiles.has(page.sourceContext) - ); - if (page) { - log(page.sourceContext); + const context = contexts.find((context) => modifiedFiles.has(context)); + if (context) { + log(context); return writeFiles(this.buildDir, pages); } } for (const file of modifiedFiles) { - if (pages.some((page) => file === page.sourceContext)) continue; - if (!pages.some((page) => file.includes(page.sourceContext))) continue; + if (contexts.some((context) => file === context)) continue; + if (!contexts.some((context) => file.includes(context))) continue; log(file); return writeFiles(this.buildDir, pages); } diff --git a/website/build-pages/types.ts b/website/build-pages/types.ts index 8e124178b4..9c8682b899 100644 --- a/website/build-pages/types.ts +++ b/website/build-pages/types.ts @@ -10,7 +10,7 @@ export type Page = { /** * Where the source files for the page are located. */ - sourceContext: string; + sourceContext: string | string[]; /** * A function that returns the group name for the page or null if the page * should not be grouped. diff --git a/website/components/footer.tsx b/website/components/footer.tsx index 5387c2683d..7e47e9c8de 100644 --- a/website/components/footer.tsx +++ b/website/components/footer.tsx @@ -1,8 +1,8 @@ import { useId } from "react"; import { cx } from "@ariakit/core/utils/misc"; import { NewWindow } from "icons/new-window.js"; +import Link from "next/link.js"; import { tw } from "utils/tw.js"; -import { Link } from "./link.js"; import { Logo } from "./logo.js"; const style = { diff --git a/website/components/header-menu.tsx b/website/components/header-menu.tsx index d3224befe2..a4bab5f61d 100644 --- a/website/components/header-menu.tsx +++ b/website/components/header-menu.tsx @@ -56,11 +56,11 @@ import { ChevronRight } from "icons/chevron-right.js"; import { NewWindow } from "icons/new-window.js"; import { Search } from "icons/search.js"; import { Spinner } from "icons/spinner.js"; +import Link from "next/link.js"; import { afterTimeout } from "utils/after-timeout.js"; import { tw } from "utils/tw.js"; import { useIdle } from "utils/use-idle.js"; import { whenIdle } from "utils/when-idle.js"; -import { Link } from "./link.js"; import { Popup } from "./popup.js"; const style = { diff --git a/website/components/header-theme-switch.tsx b/website/components/header-theme-switch.tsx index 46f49ec6b4..e1d36d27f7 100644 --- a/website/components/header-theme-switch.tsx +++ b/website/components/header-theme-switch.tsx @@ -8,6 +8,7 @@ import { tw } from "utils/tw.js"; import { TooltipButton } from "./tooltip-button.js"; type Props = ButtonHTMLAttributes; +type Theme = "light" | "dark"; const style = tw` flex items-center justify-center @@ -19,6 +20,22 @@ const style = tw` [&:focus-visible]:ariakit-outline-input `; +const EVENT_NAME = "themechange"; + +function dispatchChange(theme: Theme) { + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: theme })); +} + +export function onThemeSwitch(callback: (theme: Theme) => void) { + const onSwitch = (event: Event) => { + callback((event as CustomEvent).detail); + }; + window.addEventListener(EVENT_NAME, onSwitch); + return () => { + window.removeEventListener(EVENT_NAME, onSwitch); + }; +} + export function HeaderThemeSwitch(props: Props) { return ( diff --git a/website/components/header-version-select.tsx b/website/components/header-version-select.tsx index 43a51a0a8e..ace81d0ede 100644 --- a/website/components/header-version-select.tsx +++ b/website/components/header-version-select.tsx @@ -18,8 +18,8 @@ import { import { NewWindow } from "icons/new-window.js"; import { React } from "icons/react.js"; import { Vue } from "icons/vue.js"; +import Link from "next/link.js"; import { tw } from "utils/tw.js"; -import { Link } from "./link.js"; import { Popup } from "./popup.js"; const style = { diff --git a/website/components/header.tsx b/website/components/header.tsx index 69be0390a0..820db05167 100644 --- a/website/components/header.tsx +++ b/website/components/header.tsx @@ -1,10 +1,10 @@ +import Link from "next/link.js"; import { tw } from "utils/tw.js"; import { HeaderGlobalNotification } from "./header-global-notification.js"; import { HeaderLogo } from "./header-logo.js"; import { HeaderNav } from "./header-nav.js"; import { HeaderThemeSwitch } from "./header-theme-switch.js"; import { HeaderVersionSelect } from "./header-version-select.js"; -import { Link } from "./link.js"; let cache: Record> | null = null; diff --git a/website/components/hero.tsx b/website/components/hero.tsx index 917e220787..db42e19a72 100644 --- a/website/components/hero.tsx +++ b/website/components/hero.tsx @@ -1,6 +1,6 @@ import { ArrowRight } from "icons/arrow-right.js"; +import Link from "next/link.js"; import { tw } from "utils/tw.js"; -import { Link } from "./link.js"; export function Hero() { return ( @@ -42,7 +42,10 @@ export function Hero() { dark:hover:bg-gray-700`} > Explore components{" "} - +
diff --git a/website/components/link.ts b/website/components/link.ts deleted file mode 100644 index 64a1da27b3..0000000000 --- a/website/components/link.ts +++ /dev/null @@ -1,3 +0,0 @@ -import _Link from "next/link.js"; - -export const Link = _Link as unknown as typeof _Link.default; diff --git a/website/components/playground-browser.tsx b/website/components/playground-browser.tsx new file mode 100644 index 0000000000..eb2383a08d --- /dev/null +++ b/website/components/playground-browser.tsx @@ -0,0 +1,133 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft } from "icons/arrow-left.jsx"; +import { ArrowRight } from "icons/arrow-right.jsx"; +import { NewWindow } from "icons/new-window.jsx"; +import { Refresh } from "icons/refresh.jsx"; +import Link from "next/link.js"; +import { flushSync } from "react-dom"; +import { tw } from "utils/tw.js"; +import { TooltipButton } from "./tooltip-button.jsx"; + +export interface PlaygroundBrowserProps { + previewLink: string; +} + +const style = { + wrapper: tw` + flex h-full flex-col border-none border-[inherit] + `, + chrome: tw` + flex items-center gap-2 py-1 px-2 bg-white dark:bg-gray-750 + border-b border-[inherit] + `, + toolbar: tw` + flex items-center + `, + button: tw` + flex h-8 w-8 items-center justify-center rounded-full p-1.5 + text-black/70 hover:text-black + dark:text-white/75 dark:hover:text-white + bg-transparent hover:bg-black/5 dark:hover:bg-white/5 + focus-visible:ariakit-outline-input + `, + form: tw` + flex-auto + `, + input: tw` + w-full h-8 rounded-full px-4 text-sm + border-none text-black/80 dark:text-white/80 + bg-gray-150 dark:bg-gray-850 + hover:bg-gray-200 dark:hover:bg-gray-900 + dark:shadow-input-dark + focus-visible:ariakit-outline-input + `, +}; + +export function PlaygroundBrowser({ previewLink }: PlaygroundBrowserProps) { + const ref = useRef(null); + const [url, setUrl] = useState(""); + + useEffect(() => { + const iframe = ref.current; + if (!iframe) return; + setUrl(iframe.contentWindow?.location.href ?? ""); + type Event = MessageEvent<{ type: string; pathname: string }>; + const onMessage = (event: Event) => { + if (event.data.type !== "pathname") return; + flushSync(() => { + setUrl(iframe.contentWindow?.location.href ?? ""); + }); + }; + window.addEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + }; + }, []); + + return ( +
+
+
+ ref.current?.contentWindow?.history.back()} + > + Back + + + ref.current?.contentWindow?.history.forward()} + > + Forward + + + ref.current?.contentWindow?.location.reload()} + > + Reload + + +
+
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const url = data.get("url")?.toString(); + if (!url) return; + ref.current?.contentWindow?.location.assign(url); + }} + > + setUrl(event.target.value)} + className={style.input} + /> +
+ + {(props) => ( + + Open in new tab + + + )} + +
+