diff --git a/.eslintrc.js b/.eslintrc.js index f7d778987d4df0..5d8b57f41c94a5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -76,7 +76,7 @@ const restrictedSyntax = [ // here. That's why we use \\u002F in the regexes below. { selector: - 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]', + 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]:not([source.value=/^@wordpress\\u002F.+\\u002Fbuild-types\\u002F/])', message: 'Path access on WordPress dependencies is not allowed.', }, { diff --git a/docs/manifest.json b/docs/manifest.json index 4a911fa47669ad..d66a1215940474 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1517,6 +1517,12 @@ "markdown_source": "../packages/blocks/README.md", "parent": "packages" }, + { + "title": "@wordpress/boot", + "slug": "packages-boot", + "markdown_source": "../packages/boot/README.md", + "parent": "packages" + }, { "title": "@wordpress/browserslist-config", "slug": "packages-browserslist-config", diff --git a/lib/experimental/boot/boot-menu-items.php b/lib/experimental/boot/boot-menu-items.php new file mode 100644 index 00000000000000..dde0028543ace9 --- /dev/null +++ b/lib/experimental/boot/boot-menu-items.php @@ -0,0 +1,49 @@ + $id, + 'label' => $label, + 'to' => $to, + ); + + // Only include parent if it's not empty (matches next-admin approach). + if ( ! empty( $parent_id ) ) { + $menu_item['parent'] = $parent_id; + } + + $gutenberg_boot_menu_items[] = $menu_item; +} + +/** + * Get all registered boot menu items. + * + * @return array Array of registered menu items. + */ +function gutenberg_get_boot_menu_items() { + global $gutenberg_boot_menu_items; + return $gutenberg_boot_menu_items; +} diff --git a/lib/experimental/boot/boot.php b/lib/experimental/boot/boot.php new file mode 100644 index 00000000000000..d36da3364b491f --- /dev/null +++ b/lib/experimental/boot/boot.php @@ -0,0 +1,193 @@ +queue as $script ) { + wp_dequeue_script( $script ); + } + foreach ( wp_styles()->queue as $style ) { + wp_dequeue_style( $style ); + } + + // Fire init action for extensions to register routes and menu items. + do_action( 'gutenberg_boot_init' ); + + // Register some default menu items for demonstration. + gutenberg_register_boot_menu_item( 'home', __( 'Home', 'gutenberg' ), '/', '' ); + gutenberg_register_boot_menu_item( 'about', __( 'About', 'gutenberg' ), '/', '' ); + gutenberg_register_boot_menu_item( 'settings', __( 'Settings', 'gutenberg' ), '/', '' ); + + // Get routes and menu items. + $menu_items = gutenberg_get_boot_menu_items(); + + // Get boot module asset file for dependencies. + $asset_file = gutenberg_dir_path() . 'build/modules/boot/index.min.asset.php'; + if ( file_exists( $asset_file ) ) { + $asset = require $asset_file; + + // This script serves two purposes: + // 1. It ensures all the globals that are made available to the modules are loaded. + // 2. It initializes the boot module as an inline script. + wp_register_script( 'gutenberg-boot-prerequisites', '', $asset['dependencies'], $asset['version'], true ); + + // Add inline script to initialize the app. + wp_add_inline_script( + 'gutenberg-boot-prerequisites', + sprintf( + 'import("@wordpress/boot").then(mod => mod.init(%s));', + wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) + ) + ); + + // Register prerequisites style by filtering script dependencies to find registered styles. + $style_dependencies = array_filter( + $asset['dependencies'], + function ( $handle ) { + return wp_style_is( $handle, 'registered' ); + } + ); + wp_register_style( 'gutenberg-boot-prerequisites', false, $style_dependencies, $asset['version'] ); + + // Dummy script module to ensure dependencies are loaded. + wp_register_script_module( + 'gutenberg-boot', + gutenberg_url( 'lib/experimental/boot/loader.js' ), + array( + 'import' => 'static', + 'id' => '@wordpress/boot', + ) + ); + + // Enqueue the boot scripts a,d styles. + wp_enqueue_script( 'gutenberg-boot-prerequisites' ); + wp_enqueue_script_module( 'gutenberg-boot' ); + wp_enqueue_style( 'gutenberg-boot-prerequisites' ); + } + + // Output the HTML. + ?> + + > + + + + <?php esc_html_e( 'Boot Demo', 'gutenberg' ); ?> + + + + +
+ print_import_map(); + print_footer_scripts(); + wp_script_modules()->print_enqueued_script_modules(); + wp_script_modules()->print_script_module_preloads(); + wp_script_modules()->print_script_module_data(); + + /** + * Prints scripts or data after the default footer scripts. + * + * @since 2.8.0 + */ + do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // END see wp-admin/admin-footer.php. + ?> + + + =12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.133.36", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.133.36.tgz", + "integrity": "sha512-pT4d2uEucDQ3SAIQ0pLUw6RUKwkB5pHzpBB6otaoKpO0cAwHkRPi+p59DivuzSANJLHLVEiXyJCCk72EeHMRxA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.133.28", + "@tanstack/react-store": "^0.8.0", + "@tanstack/router-core": "1.133.36", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.8.0.tgz", + "integrity": "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.8.0", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.133.36", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.133.36.tgz", + "integrity": "sha512-VJ9kFduePsxgjhDW+uKxL6ol9ZpJlaUO2EI9Zmq8AA6uhW/LRg+0/365OZOZaVqNqvx2eKt3MZKHLN+29jd5Uw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.133.28", + "@tanstack/store": "^0.8.0", + "cookie-es": "^2.0.0", + "seroval": "^1.3.2", + "seroval-plugins": "^1.3.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.8.0.tgz", + "integrity": "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@terrazzo/cli": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@terrazzo/cli/-/cli-0.10.3.tgz", @@ -16907,6 +16995,10 @@ "resolved": "packages/blocks", "link": true }, + "node_modules/@wordpress/boot": { + "resolved": "packages/boot", + "link": true + }, "node_modules/@wordpress/browserslist-config": { "resolved": "packages/browserslist-config", "link": true @@ -21835,6 +21927,12 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -29961,6 +30059,15 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/isbot": { + "version": "5.1.31", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.31.tgz", + "integrity": "sha512-DPgQshehErHAqSCKDb3rNW03pa2wS/v5evvUqtxt6TTnHRqAG8FdzcSSJs9656pK6Y+NT7K9R4acEYXLHYfpUQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -43787,6 +43894,27 @@ "randombytes": "^2.1.0" } }, + "node_modules/seroval": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", + "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/serve-favicon": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz", @@ -46726,8 +46854,13 @@ "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" }, "node_modules/tinyrainbow": { "version": "1.2.0", @@ -47966,12 +48099,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", - "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/userhome": { @@ -52024,6 +52157,39 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, + "packages/boot": { + "name": "@wordpress/boot", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@tanstack/history": "^1.133.28", + "@tanstack/react-router": "^1.120.5", + "@wordpress/admin-ui": "file:../admin-ui", + "@wordpress/commands": "file:../commands", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/primitives": "file:../primitives", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/theme": "file:../theme", + "@wordpress/url": "file:../url", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "packages/browserslist-config": { "name": "@wordpress/browserslist-config", "version": "6.34.0", diff --git a/packages/boot/package.json b/packages/boot/package.json new file mode 100644 index 00000000000000..1142ad6f2809ac --- /dev/null +++ b/packages/boot/package.json @@ -0,0 +1,63 @@ +{ + "name": "@wordpress/boot", + "version": "0.1.0", + "description": "Minimal boot package for WordPress admin pages.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "boot" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/boot/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/boot" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "module": "build-module/index.js", + "exports": { + ".": { + "types": "./build-types/index.d.ts", + "import": "./build-module/index.js" + }, + "./package.json": "./package.json" + }, + "react-native": "src/index", + "wpScriptModuleExports": "./build-module/index.js", + "types": "build-types", + "dependencies": { + "@tanstack/history": "^1.133.28", + "@tanstack/react-router": "^1.120.5", + "@wordpress/admin-ui": "file:../admin-ui", + "@wordpress/commands": "file:../commands", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/core-data": "file:../core-data", + "@wordpress/data": "file:../data", + "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/primitives": "file:../primitives", + "@wordpress/private-apis": "file:../private-apis", + "@wordpress/theme": "file:../theme", + "@wordpress/url": "file:../url", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/boot/src/components/app/index.tsx b/packages/boot/src/components/app/index.tsx new file mode 100644 index 00000000000000..e6a54480d78706 --- /dev/null +++ b/packages/boot/src/components/app/index.tsx @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { createRoot, StrictMode } from '@wordpress/element'; +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import Router from './router'; +import { STORE_NAME } from '../../store'; +import type { MenuItem } from '../../store/types'; + +function App() { + return ; +} + +export async function init( menuItems: MenuItem[] ) { + // Register menu items + menuItems.forEach( ( menuItem ) => { + // @ts-ignore + dispatch( STORE_NAME ).registerMenuItem( menuItem.id, menuItem ); + } ); + + // Render the app + const rootElement = document.getElementById( 'gutenberg-boot-app' ); + if ( rootElement ) { + const root = createRoot( rootElement ); + root.render( + + + + ); + } +} diff --git a/packages/boot/src/components/app/router.tsx b/packages/boot/src/components/app/router.tsx new file mode 100644 index 00000000000000..f806707f1d7bc5 --- /dev/null +++ b/packages/boot/src/components/app/router.tsx @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { + createRouter, + createRootRoute, + createRoute, + RouterProvider, + createBrowserHistory, +} from '@tanstack/react-router'; +import { parseHref } from '@tanstack/history'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Root from '../root'; +import Home from '../home'; + +// Not found component +function NotFoundComponent() { + return ( +
+

{ __( 'Route not found' ) }

+
+ ); +} + +// Create root route +const rootRoute = createRootRoute( { + component: Root, +} ); + +// Create home route +const homeRoute = createRoute( { + getParentRoute: () => rootRoute, + path: '/', + component: Home, +} ); + +// Create route tree +const routeTree = rootRoute.addChildren( [ homeRoute ] ); + +// Create custom history that parses ?p= query parameter +function createPathHistory() { + return createBrowserHistory( { + parseLocation: () => { + const url = new URL( window.location.href ); + const path = url.searchParams.get( 'p' ) || '/'; + const pathHref = `${ path }${ url.hash }`; + return parseHref( pathHref, window.history.state ); + }, + createHref: ( href ) => { + const searchParams = new URLSearchParams( window.location.search ); + searchParams.set( 'p', href ); + return `${ window.location.pathname }?${ searchParams }`; + }, + } ); +} + +const history = createPathHistory(); + +// Create router +const router = createRouter( { + history, + routeTree, + defaultNotFoundComponent: NotFoundComponent, +} ); + +export default function Router() { + return ; +} diff --git a/packages/boot/src/components/home/index.tsx b/packages/boot/src/components/home/index.tsx new file mode 100644 index 00000000000000..398c9a24bc2bf5 --- /dev/null +++ b/packages/boot/src/components/home/index.tsx @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Page } from '@wordpress/admin-ui'; + +export default function Home() { + return ( + +

{ __( 'Welcome to the minimal boot package!' ) }

+
+ ); +} diff --git a/packages/boot/src/components/navigation/drilldown-item/index.tsx b/packages/boot/src/components/navigation/drilldown-item/index.tsx new file mode 100644 index 00000000000000..b86ce410e0842c --- /dev/null +++ b/packages/boot/src/components/navigation/drilldown-item/index.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { + FlexBlock, + __experimentalItem as Item, + // @ts-ignore + __experimentalHStack as HStack, + Icon, +} from '@wordpress/components'; +import { isRTL } from '@wordpress/i18n'; +import { chevronRightSmall, chevronLeftSmall } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { wrapIcon } from '../items'; +import type { IconType } from '../../../store/types'; + +interface DrilldownItemProps { + /** + * Optional CSS class name. + */ + className?: string; + /** + * Identifier of the navigation item. + */ + id: string; + /** + * Icon to display with the navigation item. + */ + icon?: IconType; + /** + * Whether to show placeholder icons for alignment. + */ + shouldShowPlaceholder?: boolean; + /** + * Content to display inside the navigation item. + */ + children: ReactNode; + /** + * Function to handle sidebar navigation when the item is clicked. + */ + onNavigate: ( { + id, + direction, + }: { + id?: string; + direction: 'forward' | 'backward'; + } ) => void; +} + +export default function DrilldownItem( { + className, + id, + icon, + shouldShowPlaceholder = true, + children, + onNavigate, +}: DrilldownItemProps ) { + const handleClick = ( e: React.MouseEvent ) => { + e.preventDefault(); + onNavigate( { id, direction: 'forward' } ); + }; + + return ( + + + { wrapIcon( icon, shouldShowPlaceholder ) } + { children } + + + + ); +} diff --git a/packages/boot/src/components/navigation/dropdown-item/index.tsx b/packages/boot/src/components/navigation/dropdown-item/index.tsx new file mode 100644 index 00000000000000..3e65a564df1139 --- /dev/null +++ b/packages/boot/src/components/navigation/dropdown-item/index.tsx @@ -0,0 +1,134 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { + FlexBlock, + __experimentalItem as Item, + // @ts-ignore + __experimentalHStack as HStack, + Icon, + __unstableMotion as motion, + __unstableAnimatePresence as AnimatePresence, +} from '@wordpress/components'; +import { chevronDownSmall } from '@wordpress/icons'; +import { useReducedMotion } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from '../../../store'; +import NavigationItem from '../navigation-item'; +import { wrapIcon } from '../items'; +import type { IconType, MenuItem } from '../../../store/types'; +import './style.scss'; + +const ANIMATION_DURATION = 0.2; + +interface DropdownItemProps { + /** + * Optional CSS class name. + */ + className?: string; + /** + * Identifier of the parent menu item. + */ + id: string; + /** + * Icon to display with the dropdown item. + */ + icon?: IconType; + /** + * Whether to show placeholder icons for alignment. + */ + shouldShowPlaceholder?: boolean; + /** + * Content to display inside the dropdown item. + */ + children: ReactNode; + /** + * Whether this dropdown is currently expanded. + */ + isExpanded: boolean; + /** + * Function to toggle this dropdown's expanded state. + */ + onToggle: () => void; +} + +export default function DropdownItem( { + className, + id, + icon, + children, + isExpanded, + onToggle, +}: DropdownItemProps ) { + const menuItems: MenuItem[] = useSelect( + ( select ) => + // @ts-ignore + select( STORE_NAME ).getMenuItems(), + [] + ); + const items = menuItems.filter( ( item ) => item.parent === id ); + const disableMotion = useReducedMotion(); + return ( +
+ { + e.preventDefault(); + e.stopPropagation(); + onToggle(); + } } + onMouseDown={ ( e ) => e.preventDefault() } + > + + { wrapIcon( icon, false ) } + { children } + + + + + { isExpanded && ( + + { items.map( ( item, index ) => ( + + { item.label } + + ) ) } + + ) } + +
+ ); +} diff --git a/packages/boot/src/components/navigation/dropdown-item/style.scss b/packages/boot/src/components/navigation/dropdown-item/style.scss new file mode 100644 index 00000000000000..49c520f265841a --- /dev/null +++ b/packages/boot/src/components/navigation/dropdown-item/style.scss @@ -0,0 +1,23 @@ +@use "@wordpress/base-styles/variables"; + +.boot-dropdown-item__children { + display: flex; + flex-direction: column; + + // In order to avoid the focus ring of each list item from being cut off, + // we add padding around the menu items. + // At the same time, we use the same value to tweak margins so that + // the items still retain the same position and footprint. + $padding-to-avoid-cutting-focus-ring: 2px; + padding: $padding-to-avoid-cutting-focus-ring; + margin-block-start: -$padding-to-avoid-cutting-focus-ring; + margin-block-end: $padding-to-avoid-cutting-focus-ring; + margin-inline-start: + variables.$grid-unit-40 - + $padding-to-avoid-cutting-focus-ring; + overflow: hidden; +} + +.boot-dropdown-item__chevron.is-up { + transform: rotate(180deg); +} diff --git a/packages/boot/src/components/navigation/index.tsx b/packages/boot/src/components/navigation/index.tsx new file mode 100644 index 00000000000000..3ed7e05316431b --- /dev/null +++ b/packages/boot/src/components/navigation/index.tsx @@ -0,0 +1,126 @@ +/** + * WordPress dependencies + */ +import { useState, useMemo, useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from '../../store'; +import NavigationItem from './navigation-item'; +import DrilldownItem from './drilldown-item'; +import DropdownItem from './dropdown-item'; +import NavigationScreen from './navigation-screen'; +import { useSidebarParent } from './use-sidebar-parent'; +import type { MenuItem } from '../../store/types'; + +function Navigation() { + const backButtonRef = useRef< HTMLButtonElement >( null ); + const [ animationDirection, setAnimationDirection ] = useState< + 'forward' | 'backward' | null + >( null ); + const [ parentId, setParentId, parentDropdownId, setParentDropdownId ] = + useSidebarParent(); + const menuItems = useSelect( + ( select ) => + // @ts-ignore + select( STORE_NAME ).getMenuItems() as MenuItem[], + [] + ); + const parent = useMemo( + () => menuItems.find( ( item ) => item.id === parentId ), + [ menuItems, parentId ] + ); + // Create a unique key for the current navigation state + // The sidebar will animate when the key changes. + const navigationKey = parent ? `drilldown-${ parent.id }` : 'root'; + + // We use transitions to handle navigation clicks + // This allows smooth animations and non blocking navigation. + const handleNavigate = ( { + id, + direction, + }: { + id?: string; + direction: 'forward' | 'backward'; + } ) => { + setAnimationDirection( direction ); + setParentId( id ); + }; + + const handleDropdownToggle = ( dropdownId: string ) => { + setParentDropdownId( + parentDropdownId === dropdownId ? undefined : dropdownId + ); + }; + + const items = useMemo( + () => menuItems.filter( ( item ) => item.parent === parentId ), + [ menuItems, parentId ] + ); + + const hasRealIcons = items.some( ( item ) => !! item.icon ); + + return ( + + { items.map( ( item: MenuItem ) => { + if ( item.parent_type === 'dropdown' ) { + return ( + + handleDropdownToggle( item.id ) + } + > + { item.label } + + ); + } + + if ( item.parent_type === 'drilldown' ) { + return ( + + { item.label } + + ); + } + + return ( + + { item.label } + + ); + } ) } + + } + /> + ); +} + +export default Navigation; diff --git a/packages/boot/src/components/navigation/items.tsx b/packages/boot/src/components/navigation/items.tsx new file mode 100644 index 00000000000000..8205dcb505f129 --- /dev/null +++ b/packages/boot/src/components/navigation/items.tsx @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { isValidElement } from '@wordpress/element'; +import { Dashicon, Icon } from '@wordpress/components'; +import { SVG } from '@wordpress/primitives'; + +/** + * Internal dependencies + */ +import type { IconType } from '../../store/types'; + +/** + * Type guard for verifying whether a given element + * is a valid SVG element for the Icon component. + * + * @param element - The element to check + */ +function isSvg( element: unknown ): element is JSX.Element { + return ( + isValidElement( element ) && + ( element.type === SVG || element.type === 'svg' ) + ); +} + +/** + * Converts the given IconType into a renderable component: + * - Dashicon string into a Dashicon component + * - JSX SVG element into an Icon component + * - Data URL into an img element + * + * @param icon - The icon to convert + * @param shouldShowPlaceholder - Whether to show placeholder when no icon is provided + * @return The converted icon as a JSX element + */ +export function wrapIcon( + icon?: IconType, + shouldShowPlaceholder: boolean = true +) { + if ( isSvg( icon ) ) { + return ; + } + + if ( typeof icon === 'string' && icon.startsWith( 'dashicons-' ) ) { + const iconKey = icon.replace( + /^dashicons-/, + '' + ) as React.ComponentProps< typeof Dashicon >[ 'icon' ]; + + return ( +