diff --git a/apps/ui/package.json b/apps/ui/package.json index 02ca4db048..8719231a4d 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-router": "^1.120.14", + "@wordpress/a11y": "^4.47.0", "@wordpress/api-fetch": "^7.47.0", "@wordpress/components": "^34.0.0", "@wordpress/core-data": "^7.47.0", diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 93bc75beee..66c874e964 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -112,19 +112,22 @@ textarea, /* Show the focus ring only on keyboard focus, overriding @wordpress/ui's :focus-based outline. Unlayered CSS wins over WP UI's cascade layers. - `a` covers Button primitives rendered as links via its `render` prop. - Also revert the background/border shift @wordpress/ui's Button applies on - `:focus` — when a Dialog auto-focuses a button on open, the "active" look - would otherwise show without any user intent. Leave `color` alone: - loading buttons rely on `color: transparent` to hide their label while - the spinner runs, and overriding that here would make the label visible - again. */ + `a` covers Button primitives rendered as links via its `render` prop. */ button:focus:not( :focus-visible ), a:focus:not( :focus-visible ), [role='button']:focus:not( :focus-visible ) { outline: none; - background-color: var( --wp-ui-button-background-color ); - border-color: var( --wp-ui-button-border-color ); +} + +/* Revert the background/border shift @wordpress/ui's Button applies on + `:focus` without overriding unlayered custom component styles. */ +@layer wp-ui-overrides { + button:focus:not( :focus-visible ), + a:focus:not( :focus-visible ), + [role='button']:focus:not( :focus-visible ) { + background-color: var( --wp-ui-button-background-color ); + border-color: var( --wp-ui-button-border-color ); + } } /* Compact density: shrink icons to match the tighter spacing tokens. @@ -137,6 +140,34 @@ a:focus:not( :focus-visible ), height: 16px; } +@keyframes view-transition-exit { + to { + opacity: 0; + } +} + +@keyframes view-transition-enter { + from { + opacity: 0; + transform: translateY(10px); + } +} + +::view-transition-old(root) { + animation: 120ms ease-out both view-transition-exit; +} + +::view-transition-new(root) { + animation: 220ms ease-out both view-transition-enter; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } +} + /* Desks uses each shape's indicator as the visible selection outline. Hide tldraw's default selection box and handle glyphs while keeping the underlying resize/rotate wrappers interactive. */ diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx index 0db88f49b7..3c8b0ff330 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx @@ -1,7 +1,9 @@ -import { createRoute, Outlet, useMatches, useNavigate } from '@tanstack/react-router'; +import { createRoute, Outlet, useLocation, useMatches, useNavigate } from '@tanstack/react-router'; +import { useEffect, useRef } from 'react'; import { OnboardingLayout } from '@/components/onboarding-layout'; import { useSites } from '@/data/queries/use-sites'; import { rootRoute } from '../layout-root'; +import styles from './style.module.css'; function OnboardingShell() { const navigate = useNavigate(); @@ -20,12 +22,45 @@ function OnboardingShell() { const step = ( match.search as { step?: string } ).step; return step !== 'configure'; } ); + const contentRef = useRef< HTMLDivElement >( null ); + const { href } = useLocation(); + const lastHref = useRef( href ); + useEffect( () => { + if ( lastHref.current === href ) { + return; + } + lastHref.current = href; + const focusHeading = () => { + const content = contentRef.current; + if ( ! content ) { + return false; + } + // A page that claims focus itself wins over the default heading focus. + const active = document.activeElement; + if ( active && active !== document.body && content.contains( active ) ) { + return true; + } + const heading = content.querySelector( 'h1' ); + if ( ! heading ) { + return false; + } + heading.setAttribute( 'tabindex', '-1' ); + heading.focus(); + return true; + }; + if ( ! focusHeading() ) { + const raf = requestAnimationFrame( () => void focusHeading() ); + return () => cancelAnimationFrame( raf ); + } + }, [ href ] ); return ( void navigate( { to: '/' } ) : undefined } width={ isWide ? 'wide' : 'default' } > - +
+ +
); } diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css index 3bf06f28c5..2278cccc9e 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css +++ b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css @@ -14,6 +14,14 @@ margin: 0 0 32px; } +.outlet { + display: contents; +} + +.outlet h1:focus:not(:focus-visible) { + outline: none; +} + .cards { display: flex; gap: 16px; diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index 099c5b8c0c..f9b32b26cf 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -3,7 +3,8 @@ import { updateBlueprintWithFormValues, } from '@studio/common/lib/blueprint-settings'; import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; @@ -109,6 +110,13 @@ function OnboardingBlueprintPage() { filePath: picked.filePath, }, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( diff --git a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx index bbb98abf50..c3756bff18 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx @@ -1,5 +1,6 @@ import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { useState } from 'react'; import { CreateSiteForm } from '@/components/create-site-form'; import { @@ -33,6 +34,13 @@ function CreateSitePage() { adminPassword: values.adminPassword || undefined, adminEmail: values.adminEmail || undefined, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index d210ce266b..2cf447dd78 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -1,6 +1,7 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { arrowLeft, download } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; @@ -143,6 +144,13 @@ function OnboardingImportPage() { siteId: site.id, backup: { path: picked.path, type: picked.file.type }, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( diff --git a/apps/ui/src/ui-classic/router/router.tsx b/apps/ui/src/ui-classic/router/router.tsx index b88e3f9d79..c9b8f6a6d1 100644 --- a/apps/ui/src/ui-classic/router/router.tsx +++ b/apps/ui/src/ui-classic/router/router.tsx @@ -35,6 +35,7 @@ export function createAppRouter( context: RouterContext ) { routeTree, context, defaultPreload: 'intent', + defaultViewTransition: true, history: createPackagedRouterHistory(), } ); } diff --git a/package-lock.json b/package-lock.json index f0934ff7c7..682cdf593e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1087,6 +1087,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-router": "^1.120.14", + "@wordpress/a11y": "^4.47.0", "@wordpress/api-fetch": "^7.47.0", "@wordpress/components": "^34.0.0", "@wordpress/core-data": "^7.47.0",