diff --git a/docs/pages/_app.js.backup b/docs/pages/_app.js.backup new file mode 100644 index 00000000000000..27343682d9161b --- /dev/null +++ b/docs/pages/_app.js.backup @@ -0,0 +1,422 @@ +import 'docs/src/modules/components/bootstrap'; +// --- Post bootstrap ----- +import * as React from 'react'; +import { loadCSS } from 'fg-loadcss'; +import NextHead from 'next/head'; +import PropTypes from 'prop-types'; +import { useRouter } from 'next/router'; +import { LicenseInfo } from '@mui/x-license'; +import materialPkgJson from '@mui/material/package.json'; +import joyPkgJson from '@mui/joy/package.json'; +import systemPkgJson from '@mui/system/package.json'; +import generalDocsPages from 'docs/data/docs/pages'; +import docsInfraPages from 'docs/data/docs-infra/pages'; +import materialPages from 'docs/data/material/pages'; +import joyPages from 'docs/data/joy/pages'; +import systemPages from 'docs/data/system/pages'; +import PageContext from 'docs/src/modules/components/PageContext'; +import GoogleAnalytics from 'docs/src/modules/components/GoogleAnalytics'; +import { CodeCopyProvider } from '@mui/docs/CodeCopy'; +import { ThemeProvider } from 'docs/src/modules/components/ThemeContext'; +import { CodeVariantProvider } from 'docs/src/modules/utils/codeVariant'; +import DocsStyledEngineProvider from 'docs/src/modules/utils/StyledEngineProvider'; +import createEmotionCache from 'docs/src/createEmotionCache'; +import findActivePage from 'docs/src/modules/utils/findActivePage'; +import getProductInfoFromUrl from 'docs/src/modules/utils/getProductInfoFromUrl'; +import { DocsProvider } from '@mui/docs/DocsProvider'; +import { mapTranslations } from '@mui/docs/i18n'; +import SvgMuiLogomark, { + muiSvgLogoString, + muiSvgWordmarkString, +} from 'docs/src/icons/SvgMuiLogomark'; +import './global.css'; +import '../public/static/components-gallery/base-theme.css'; +import { Inter, Roboto } from 'next/font/google'; +import localFont from 'next/font/local'; +import * as config from '../config'; + +const inter = Inter({ + weight: ['300', '400', '500', '600', '700'], + subsets: ['latin'], +}); + +const roboto = Roboto({ + weight: ['300', '400', '500', '700'], + style: ['normal', 'italic'], + subsets: ['latin'], +}); + +const generalSans = localFont({ + declarations: [{ prop: 'font-family', value: 'General Sans' }], + src: [ + { path: '../public/static/fonts/GeneralSans-Regular.woff2', weight: '400', style: 'normal' }, + { path: '../public/static/fonts/GeneralSans-Medium.woff2', weight: '500', style: 'normal' }, + { path: '../public/static/fonts/GeneralSans-Semibold.woff2', weight: '600', style: 'normal' }, + { path: '../public/static/fonts/GeneralSans-Bold.woff2', weight: '700', style: 'normal' }, + ], +}); + +const ibmPlexSans = localFont({ + declarations: [{ prop: 'font-family', value: 'IBM Plex Sans' }], + src: [ + { path: '../public/static/fonts/IBMPlexSans-Regular.woff2', weight: '400', style: 'normal' }, + { path: '../public/static/fonts/IBMPlexSans-Medium.woff2', weight: '500', style: 'normal' }, + { path: '../public/static/fonts/IBMPlexSans-SemiBold.woff2', weight: '600', style: 'normal' }, + { path: '../public/static/fonts/IBMPlexSans-Bold.woff2', weight: '700', style: 'normal' }, + ], +}); + +export const fontClasses = `${inter.className} ${roboto.className} ${generalSans.className} ${ibmPlexSans.className}`; + +// Remove the license warning from demonstration purposes +LicenseInfo.setLicenseKey(process.env.NEXT_PUBLIC_MUI_LICENSE); + +// Client-side cache, shared for the whole session of the user in the browser. +const clientSideEmotionCache = createEmotionCache(); + +let reloadInterval; + +// Avoid infinite loop when "Upload on reload" is set in the Chrome sw dev tools. +function lazyReload() { + clearInterval(reloadInterval); + reloadInterval = setInterval(() => { + if (document.hasFocus()) { + window.location.reload(); + } + }, 100); +} + +// Inspired by +// https://developers.google.com/web/tools/workbox/guides/advanced-recipes#offer_a_page_reload_for_users +function forcePageReload(registration) { + // console.log('already controlled?', Boolean(navigator.serviceWorker.controller)); + + if (!navigator.serviceWorker.controller) { + // The window client isn't currently controlled so it's a new service + // worker that will activate immediately. + return; + } + + // console.log('registration waiting?', Boolean(registration.waiting)); + if (registration.waiting) { + // SW is waiting to activate. Can occur if multiple clients open and + // one of the clients is refreshed. + registration.waiting.postMessage('skipWaiting'); + return; + } + + function listenInstalledStateChange() { + registration.installing.addEventListener('statechange', (event) => { + // console.log('statechange', event.target.state); + if (event.target.state === 'installed' && registration.waiting) { + // A new service worker is available, inform the user + registration.waiting.postMessage('skipWaiting'); + } else if (event.target.state === 'activated') { + // Force the control of the page by the activated service worker. + lazyReload(); + } + }); + } + + if (registration.installing) { + listenInstalledStateChange(); + return; + } + + // We are currently controlled so a new SW may be found... + // Add a listener in case a new SW is found, + registration.addEventListener('updatefound', listenInstalledStateChange); +} + +async function registerServiceWorker() { + if ( + 'serviceWorker' in navigator && + process.env.NODE_ENV === 'production' && + window.location.host.includes('mui.com') + ) { + // register() automatically attempts to refresh the sw.js. + const registration = await navigator.serviceWorker.register('/sw.js'); + // Force the page reload for users. + forcePageReload(registration); + } +} + +let dependenciesLoaded = false; + +function loadDependencies() { + if (dependenciesLoaded) { + return; + } + + dependenciesLoaded = true; + + loadCSS( + 'https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Two+Tone', + document.querySelector('#material-icon-font'), + ); +} + +if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') { + // eslint-disable-next-line no-console + console.log( + `%c + +███╗ ███╗ ██╗ ██╗ ██████╗ +████╗ ████║ ██║ ██║ ██╔═╝ +██╔████╔██║ ██║ ██║ ██║ +██║╚██╔╝██║ ██║ ██║ ██║ +██║ ╚═╝ ██║ ╚██████╔╝ ██████╗ +╚═╝ ╚═╝ ╚═════╝ ╚═════╝ + +Tip: you can access the documentation \`theme\` object directly in the console. +`, + 'font-family:monospace;color:#1976d2;font-size:12px;', + ); +} +function AppWrapper(props) { + const { children, emotionCache, pageProps } = props; + + const router = useRouter(); + // TODO move productId & productCategoryId resolution to page layout. + // We should use the productId field from the markdown and fallback to getProductInfoFromUrl() + // if not present + const { productId, productCategoryId } = getProductInfoFromUrl(router.asPath); + + React.useEffect(() => { + loadDependencies(); + registerServiceWorker(); + + // Remove the server-side injected CSS. + const jssStyles = document.querySelector('#jss-server-side'); + if (jssStyles) { + jssStyles.parentElement.removeChild(jssStyles); + } + }, []); + + const productIdentifier = React.useMemo(() => { + const languagePrefix = pageProps.userLanguage === 'en' ? '' : `/${pageProps.userLanguage}`; + + if (productId === 'material-ui') { + return { + metadata: '', + name: 'Material UI', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [ + { text: `v${materialPkgJson.version}`, current: true }, + { + text: 'v6', + href: `https://v6.mui.com${languagePrefix}/material-ui/getting-started/`, + }, + { + text: 'v5', + href: `https://v5.mui.com${languagePrefix}/getting-started/installation/`, + }, + { + text: 'v4', + href: `https://v4.mui.com${languagePrefix}/getting-started/installation/`, + }, + { + text: 'View all versions', + href: `https://mui.com${languagePrefix}/versions/`, + }, + ], + }; + } + + if (productId === 'joy-ui') { + return { + metadata: '', + name: 'Joy UI', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [{ text: `v${joyPkgJson.version}`, current: true }], + }; + } + + if (productId === 'system') { + return { + metadata: '', + name: 'MUI System', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [ + { text: `v${systemPkgJson.version}`, current: true }, + { text: 'v6', href: `https://v6.mui.com${languagePrefix}/system/getting-started/` }, + { text: 'v5', href: `https://v5.mui.com${languagePrefix}/system/getting-started/` }, + { text: 'v4', href: `https://v4.mui.com${languagePrefix}/system/basics/` }, + { + text: 'View all versions', + href: `https://mui.com${languagePrefix}/versions/`, + }, + ], + }; + } + + if (productId === 'core') { + return { + metadata: '', + name: 'MUI Core', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [ + { text: `v${materialPkgJson.version}`, current: true }, + { + text: 'View all versions', + href: `https://mui.com${languagePrefix}/versions/`, + }, + ], + }; + } + + if (productId === 'docs-infra') { + return { + metadata: '', + name: 'Docs-infra', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [ + { + text: 'v0.0.0', + href: `https://mui.com${languagePrefix}/versions/`, + }, + ], + }; + } + + if (productId === 'docs') { + return { + metadata: '', + name: 'Home docs', + logo: SvgMuiLogomark, + logoSvg: muiSvgLogoString, + wordmarkSvg: muiSvgWordmarkString, + versions: [ + { + text: 'v0.0.0', + href: `https://mui.com${languagePrefix}/versions/`, + }, + ], + }; + } + + return null; + }, [pageProps.userLanguage, productId]); + + const pageContextValue = React.useMemo(() => { + let pages = generalDocsPages; + if (productId === 'material-ui') { + pages = materialPages; + } else if (productId === 'joy-ui') { + pages = joyPages; + } else if (productId === 'system') { + pages = systemPages; + } else if (productId === 'docs-infra') { + pages = docsInfraPages; + } + + const { activePage, activePageParents } = findActivePage(pages, router.pathname); + + return { + activePage, + activePageParents, + pages, + productIdentifier, + productId, + productCategoryId, + }; + }, [productId, productCategoryId, productIdentifier, router.pathname]); + + return ( + + + + + + + + + + + + + {children} + + + + + + + + + ); +} + +AppWrapper.propTypes = { + children: PropTypes.node.isRequired, + emotionCache: PropTypes.object.isRequired, + pageProps: PropTypes.object.isRequired, +}; + +export default function MyApp(props) { + const { Component, emotionCache = clientSideEmotionCache, pageProps } = props; + const getLayout = Component.getLayout ?? ((page) => page); + + return ( + + {getLayout()} + + ); +} +MyApp.propTypes = { + Component: PropTypes.elementType.isRequired, + emotionCache: PropTypes.object, + pageProps: PropTypes.object.isRequired, +}; + +MyApp.getInitialProps = async ({ ctx, Component }) => { + let pageProps = {}; + + const req = require.context('docs/translations', false, /\.\/translations.*\.json$/); + const translations = mapTranslations(req); + + if (Component.getInitialProps) { + pageProps = await Component.getInitialProps(ctx); + } + + return { + pageProps: { + userLanguage: ctx.query.userLanguage || 'en', + translations, + ...pageProps, + }, + }; +}; + +// Track fraction of actual events to prevent exceeding event quota. +// Filter sessions instead of individual events so that we can track multiple metrics per device. +// See https://github.com/GoogleChromeLabs/web-vitals-report to use this data +const disableWebVitalsReporting = Math.random() > 0.0001; +export function reportWebVitals({ id, name, label, delta, value }) { + if (disableWebVitalsReporting) { + return; + } + + window.gtag('event', name, { + value: delta, + metric_label: label === 'web-vital' ? 'Web Vitals' : 'Next.js custom metric', + metric_value: value, + metric_delta: delta, + metric_id: id, // id unique to current page load + }); +} diff --git a/docs/translations/translations-en.json b/docs/translations/translations-en.json new file mode 100644 index 00000000000000..0967ef424bce67 --- /dev/null +++ b/docs/translations/translations-en.json @@ -0,0 +1 @@ +{} diff --git a/packages/mui-material/src/Rating/Rating.js b/packages/mui-material/src/Rating/Rating.js index be58700fdd5a34..8a6eaac0c1da3b 100644 --- a/packages/mui-material/src/Rating/Rating.js +++ b/packages/mui-material/src/Rating/Rating.js @@ -32,6 +32,68 @@ function roundValueToPrecision(value, precision) { return Number(nearest.toFixed(getDecimalPrecision(precision))); } +// Keyboard helpers to normalize keys across browsers (e.g., Safari) +const KEY_LEFT = 'ArrowLeft'; +const KEY_RIGHT = 'ArrowRight'; +const KEY_UP = 'ArrowUp'; +const KEY_DOWN = 'ArrowDown'; +const KEY_HOME = 'Home'; +const KEY_END = 'End'; + +function isKey(eventKey, logical) { + if (eventKey === logical) { + return true; + } + // Safari legacy key values + switch (logical) { + case KEY_LEFT: + return eventKey === 'Left'; + case KEY_RIGHT: + return eventKey === 'Right'; + case KEY_UP: + return eventKey === 'Up'; + case KEY_DOWN: + return eventKey === 'Down'; + default: + return false; + } +} + +// Returns undefined if the key isnt a navigation key. +// Index 0 means "empty" -> returns null. +function nextValueForKey({ key, value, max, precision, isRtl }) { + const totalSteps = Math.round(max / precision); + const currentIndex = value == null ? 0 : Math.round(value / precision); + + let nextIndex = currentIndex; + const inc = isKey(key, KEY_UP) || (isRtl ? isKey(key, KEY_LEFT) : isKey(key, KEY_RIGHT)); + const dec = isKey(key, KEY_DOWN) || (isRtl ? isKey(key, KEY_RIGHT) : isKey(key, KEY_LEFT)); + + if (inc) { + nextIndex += 1; + } else if (dec) { + nextIndex -= 1; + } else if (isKey(key, KEY_HOME)) { + nextIndex = 0; + } else if (isKey(key, KEY_END)) { + nextIndex = totalSteps; + } else { + return undefined; + } + + // wrap-around + if (nextIndex > totalSteps) { + nextIndex = 0; + } + if (nextIndex < 0) { + nextIndex = totalSteps; + } + + return nextIndex === 0 + ? null + : Number((nextIndex * precision).toFixed(getDecimalPrecision(precision))); +} + const useUtilityClasses = (ownerState) => { const { classes, size, readOnly, disabled, emptyValueFocused, focusVisible } = ownerState; @@ -232,12 +294,14 @@ function RatingItem(props) { onChange, onClick, onFocus, + onKeyDown, readOnly, ownerState, ratingValue, ratingValueRounded, slots = {}, slotProps = {}, + tabIndex, } = props; const isFilled = highlightSelectedOnly ? itemValue === ratingValue : itemValue <= ratingValue; @@ -314,12 +378,14 @@ function RatingItem(props) { onBlur={onBlur} onChange={onChange} onClick={onClick} + onKeyDown={onKeyDown} disabled={disabled} value={itemValue} id={id} type="radio" name={name} checked={isChecked} + tabIndex={tabIndex} /> ); @@ -343,10 +409,12 @@ RatingItem.propTypes = { onChange: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired, onFocus: PropTypes.func.isRequired, + onKeyDown: PropTypes.func.isRequired, ownerState: PropTypes.object.isRequired, ratingValue: PropTypes.number, ratingValueRounded: PropTypes.number, readOnly: PropTypes.bool.isRequired, + tabIndex: PropTypes.number.isRequired, slotProps: PropTypes.object, slots: PropTypes.object, }; @@ -530,6 +598,18 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { const [emptyValueFocused, setEmptyValueFocused] = React.useState(false); + const getStarTabIndex = (itemValue) => { + if (readOnly || disabled) { + return -1; + } + + if (valueRounded == null) { + return -1; + } + + return itemValue === valueRounded ? 0 : -1; + }; + const ownerState = { ...props, component, @@ -550,6 +630,52 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { const classes = useUtilityClasses(ownerState); + const focusRatingInput = (newValue) => { + if (!rootRef.current) { + return; + } + + const selectorValue = newValue == null ? '' : String(newValue); + const inputToFocus = rootRef.current.querySelector( + `input[name="${name}"][value="${selectorValue}"]`, + ); + + if (inputToFocus) { + inputToFocus.focus(); + } + }; + + const handleKeyDown = (event) => { + if (readOnly || disabled) { + return; + } + + if (event.defaultPrevented) { + return; + } + + const next = nextValueForKey({ + key: event.key, + value, + max, + precision, + isRtl, + }); + + if (next === undefined) { + return; + } // not a navigation key + + event.preventDefault(); // keep behavior consistent across browsers + setValueState(next); + // Ensure visual value reflects the new rating even if focus overlay is active + setState((_prev) => ({ hover: -1, focus: -1 })); + focusRatingInput(next); + if (onChange) { + onChange(event, next); + } + }; + const externalForwardedProps = { slots, slotProps, @@ -574,6 +700,14 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { handleMouseLeave(event); handlers.onMouseLeave?.(event); }, + onKeyDownCapture: (event) => { + handleKeyDown(event); + handlers.onKeyDownCapture?.(event); + }, + onKeyDown: (event) => { + handleKeyDown(event); + handlers.onKeyDown?.(event); + }, }), ownerState, additionalProps: { @@ -596,6 +730,11 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { ownerState, }); + let emptyTabIndex = -1; + if (!readOnly && !disabled) { + emptyTabIndex = valueRounded == null ? 0 : -1; + } + return ( {Array.from(new Array(max)).map((_, index) => { @@ -616,12 +755,14 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { onChange: handleChange, onClick: handleClear, onFocus: handleFocus, + onKeyDown: handleKeyDown, ratingValue: value, ratingValueRounded: valueRounded, readOnly, ownerState, slots, slotProps, + tabIndex: getStarTabIndex(itemValue), }; const isActive = itemValue === Math.ceil(value) && (hover !== -1 || focus !== -1); @@ -644,6 +785,7 @@ const Rating = React.forwardRef(function Rating(inProps, ref) { setEmptyValueFocused(true)} onBlur={() => setEmptyValueFocused(false)} + onKeyDown={handleKeyDown} onChange={handleChange} /> {emptyLabelText} diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts index bb1c355506ff02..fada5de18b8606 100644 --- a/test/e2e/index.test.ts +++ b/test/e2e/index.test.ts @@ -149,14 +149,14 @@ describe('e2e', () => { it('should loop the arrow key', async () => { await renderFixture('Rating/BasicRating'); - const activeEl = page.locator(':focus'); + const checked = () => page.locator('input[name="rating-test"]:checked'); await page.focus('input[name="rating-test"]:checked'); - await expect(activeEl).toHaveAttribute('value', '1'); + await expect(checked()).toHaveAttribute('value', '1'); await page.keyboard.press('ArrowLeft'); - await expect(activeEl).toHaveAttribute('value', ''); + await expect(checked()).toHaveAttribute('value', ''); await page.keyboard.press('ArrowLeft'); - await expect(activeEl).toHaveAttribute('value', '5'); + await expect(checked()).toHaveAttribute('value', '5'); }); });