diff --git a/docs/data/material/components/material-icons/SearchIcons.js b/docs/data/material/components/material-icons/SearchIcons.js index f1d8cba2e84783..4d4cfabebb246d 100644 --- a/docs/data/material/components/material-icons/SearchIcons.js +++ b/docs/data/material/components/material-icons/SearchIcons.js @@ -5,17 +5,18 @@ import copy from 'clipboard-copy'; import InputBase from '@mui/material/InputBase'; import Typography from '@mui/material/Typography'; import PropTypes from 'prop-types'; -import Grid from '@mui/material/Grid'; +import Grid2 from '@mui/material/Grid2'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; +import Fade from '@mui/material/Fade'; import CircularProgress from '@mui/material/CircularProgress'; import InputAdornment from '@mui/material/InputAdornment'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import Button from '@mui/material/Button'; -import flexsearch from 'flexsearch'; +import MiniSearch from 'minisearch'; import SearchIcon from '@mui/icons-material/Search'; import FormControlLabel from '@mui/material/FormControlLabel'; import RadioGroup from '@mui/material/RadioGroup'; @@ -50,8 +51,6 @@ import useQueryParameterState from 'docs/src/modules/utils/useQueryParameterStat import { HighlightedCode } from '@mui/docs/HighlightedCode'; import synonyms from './synonyms'; -const FlexSearchIndex = flexsearch.Index; - // const mui = { // ExitToApp, // ExitToAppOutlined, @@ -206,7 +205,7 @@ function Icon(props) { ); } -const Icons = React.memo(function Icons(props) { +const SearchIconsIcons = React.memo(function SearchIconsIcons(props) { const { icons, handleOpenClick } = props; return ( @@ -224,11 +223,6 @@ const Icons = React.memo(function Icons(props) { ); }); -Icons.propTypes = { - handleOpenClick: PropTypes.func.isRequired, - icons: PropTypes.array.isRequired, -}; - const ImportLink = styled(Link)(({ theme }) => ({ textAlign: 'right', padding: theme.spacing(0.5, 1), @@ -409,40 +403,38 @@ const DialogDetails = React.memo(function DialogDetails(props) { {t('searchIcons.learnMore')} - - - - - - - - + + + + + - - + + - - - + + + - - - + + + - - - + + + - - + + - - + + - - - + + + @@ -492,9 +484,39 @@ DialogDetails.propTypes = { selectedIcon: PropTypes.object, }; -const Form = styled('form')({ +const Form = styled('form')(({ theme }) => ({ position: 'sticky', top: 80, + marginBottom: theme.spacing(2), +})); + +const SearchIconsFilter = React.memo(function SearchIconsFilter(props) { + const { theme, setTheme } = props; + return ( +
+ + Filter the style + + setTheme(event.target.value)} + sx={{ ml: 0.5 }} + > + {['Filled', 'Outlined', 'Rounded', 'Two tone', 'Sharp'].map( + (currentTheme) => { + return ( + } + label={currentTheme} + /> + ); + }, + )} + +
+ ); }); const Paper = styled(MuiPaper)(({ theme }) => ({ @@ -518,40 +540,110 @@ const Input = styled(InputBase)({ flex: 1, }); -const searchIndex = new FlexSearchIndex({ - tokenize: 'full', -}); - const allIconsMap = {}; +const themeRegEx = /(Outlined|Rounded|TwoTone|Sharp)$/g; + const allIcons = Object.keys(mui) - .sort() + .sort() // Show ASC .map((importName) => { let theme = 'Filled'; let name = importName; - for (const currentTheme of ['Outlined', 'Rounded', 'TwoTone', 'Sharp']) { - if (importName.endsWith(currentTheme)) { - theme = currentTheme === 'TwoTone' ? 'Two tone' : currentTheme; - name = importName.slice(0, -currentTheme.length); - break; - } + const matchTheme = importName.match(themeRegEx); + if (matchTheme !== null) { + theme = matchTheme[0] === 'TwoTone' ? 'Two tone' : matchTheme[0]; + name = importName.slice(0, -matchTheme[0].length); } let searchable = name; if (synonyms[searchable]) { searchable += ` ${synonyms[searchable]}`; } - searchIndex.add(importName, searchable); const icon = { + id: importName, // used by miniSearch importName, name, theme, + searchable, Component: mui[importName], }; allIconsMap[importName] = icon; return icon; }); +function addSuffixes(term, minLength) { + if (term == null) { + return undefined; + } + + const tokens = [term.toLowerCase()]; + + for (let i = 0; i <= term.length - minLength; i += 1) { + tokens.push(term.slice(i).toLowerCase()); + } + return tokens; +} + +const miniSearch = new MiniSearch({ + fields: ['searchable'], // fields to index for full-text search + processTerm: (term) => addSuffixes(term, 4), + storeFields: ['name', 'Component'], + searchOptions: { + processTerm: MiniSearch.getDefault('processTerm'), + prefix: true, + fuzzy: 0.1, // Allow some typo + boostDocument: (documentId, term, storedFields) => { + // Show exact match first + return term.toLowerCase() === storedFields.name.toLowerCase() ? 2 : 1; + }, + }, +}); + +// Copied from mui-x/packages/x-data-grid-generator/src/services/asyncWorker.ts +// https://lucaong.github.io/minisearch/classes/MiniSearch.MiniSearch.html#addAllAsync is crap. +function asyncWorker({ work, tasks, done }) { + const myNonEssentialWork = (deadline) => { + // If there is a surplus time in the frame, or timeout + while ( + (deadline.timeRemaining() > 0 || deadline.didTimeout) && + tasks.current > 0 + ) { + work(); + } + + if (tasks.current > 0) { + requestIdleCallback(myNonEssentialWork); + } else { + done(); + } + }; + + // Don't use requestIdleCallback if the time is mock, better to run synchronously in such case. + if (typeof requestIdleCallback === 'function' && !requestIdleCallback.clock) { + requestIdleCallback(myNonEssentialWork); + } else { + while (tasks.current > 0) { + work(); + } + done(); + } +} + +const indexation = new Promise((resolve) => { + const tasks = { current: allIcons.length }; + + function work() { + miniSearch.addAll([allIcons[tasks.current - 1]]); + tasks.current -= 1; + } + + asyncWorker({ + tasks, + work, + done: () => resolve(), + }); +}); + /** * Returns the last defined value that has been passed in [value] */ @@ -570,6 +662,13 @@ export default function SearchIcons() { const [selectedIcon, setSelectedIcon] = useQueryParameterState('selected', ''); const [query, setQuery] = useQueryParameterState('query', ''); + const allThemeIcons = React.useMemo( + () => allIcons.filter((icon) => theme === icon.theme), + [theme], + ); + + const [icons, setIcons] = React.useState(allThemeIcons); + const handleOpenClick = React.useCallback( (event) => { setSelectedIcon(event.currentTarget.getAttribute('title')); @@ -581,12 +680,24 @@ export default function SearchIcons() { setSelectedIcon(''); }, [setSelectedIcon]); - const icons = React.useMemo(() => { - const keys = query === '' ? null : searchIndex.search(query, { limit: 3000 }); - return (keys === null ? allIcons : keys.map((key) => allIconsMap[key])).filter( - (icon) => theme === icon.theme, - ); - }, [query, theme]); + React.useEffect(() => { + if (query === '') { + setIcons(allThemeIcons); + return; + } + + async function search() { + await indexation; + const keys = miniSearch.search(query); + + setIcons( + keys + .map((key) => allIconsMap[key.id]) + .filter((icon) => theme === icon.theme), + ); + } + search(); + }, [query, theme, allThemeIcons]); const deferredIcons = React.useDeferredValue(icons); @@ -607,32 +718,11 @@ export default function SearchIcons() { ); return ( - - -
- - Filter the style - - setTheme(event.target.value)} - > - {['Filled', 'Outlined', 'Rounded', 'Two tone', 'Sharp'].map( - (currentTheme) => { - return ( - } - label={currentTheme} - /> - ); - }, - )} - -
-
- + + + + + @@ -640,28 +730,38 @@ export default function SearchIcons() { setQuery(event.target.value)} + onChange={(event) => { + setQuery(event.target.value); + }} placeholder="Search icons…" inputProps={{ 'aria-label': 'search icons' }} endAdornment={ isPending ? ( - - - + + + + + ) : null } /> - {`${formatNumber( - icons.length, - )} matching results`} - - + + {`${formatNumber(icons.length)} matching results`} + + + -
+ ); } diff --git a/docs/package.json b/docs/package.json index d0a24e65914713..549981d77a9891 100644 --- a/docs/package.json +++ b/docs/package.json @@ -72,7 +72,6 @@ "feed": "^4.2.2", "fg-loadcss": "^3.1.0", "final-form": "^4.20.10", - "flexsearch": "^0.7.43", "fs-extra": "^11.2.0", "json2mq": "^0.2.0", "jss": "^10.10.0", @@ -82,6 +81,7 @@ "lz-string": "^1.5.0", "markdown-to-jsx": "^7.7.2", "material-ui-popup-state": "^5.3.3", + "minisearch": "^7.1.0", "next": "^15.1.2", "notistack": "3.0.1", "nprogress": "^0.2.0", diff --git a/docs/src/modules/utils/useQueryParameterState.ts b/docs/src/modules/utils/useQueryParameterState.ts index 4af5e339ce418e..96b8c219c45592 100644 --- a/docs/src/modules/utils/useQueryParameterState.ts +++ b/docs/src/modules/utils/useQueryParameterState.ts @@ -1,12 +1,13 @@ import * as React from 'react'; import { useRouter } from 'next/router'; import { debounce } from '@mui/material/utils'; +import useEventCallback from '@mui/utils/useEventCallback'; const QUERY_UPDATE_WAIT_MS = 220; /** * Similar to `React.useState`, but it syncs back the current state to a query - * parameter in the url, therefore it only supports strings. Wrap the result with + * parameter in the URL, therefore it only supports strings. Wrap the result with * parse/stringify logic if more complex values are needed. * * REMARK: this doesn't listen for router changes (yet) to update back the state. @@ -16,13 +17,9 @@ export default function useQueryParameterState( initialValue = '', ): [string, (newValue: string) => void] { const initialValueRef = React.useRef(initialValue); - const router = useRouter(); - const queryParamValue = router.query[name]; - const urlValue = Array.isArray(queryParamValue) ? queryParamValue[0] : queryParamValue; - const [state, setState] = React.useState(urlValue || initialValue); const setUrlValue = React.useMemo( @@ -61,13 +58,12 @@ export default function useQueryParameterState( [setUrlValue], ); - const setUserState = React.useCallback( - (newValue: string) => { - setUrlValue(newValue); - setState(newValue); - }, - [setUrlValue], - ); + // TODO Replace useEventCallback() with React.useCallback() after the App Router migration + // https://github.com/vercel/next.js/discussions/45969 see for why + const setUserState = useEventCallback((newValue: string) => { + // setUrlValue(newValue); + setState(newValue); + }); // Make sure to initialize the state when route params are only available client-side const isInitialized = React.useRef(false); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59cb919694c208..2895ca27a3d9e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -713,9 +713,6 @@ importers: final-form: specifier: ^4.20.10 version: 4.20.10 - flexsearch: - specifier: ^0.7.43 - version: 0.7.43 fs-extra: specifier: ^11.2.0 version: 11.2.0 @@ -743,6 +740,9 @@ importers: material-ui-popup-state: specifier: ^5.3.3 version: 5.3.3(@mui/material@packages+mui-material+build)(@types/react@19.0.2)(react@19.0.0) + minisearch: + specifier: ^7.1.0 + version: 7.1.1 next: specifier: ^15.1.2 version: 15.1.3(@babel/core@7.26.0)(@opentelemetry/api@1.8.0)(@playwright/test@1.48.2)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -7998,9 +7998,6 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} - flexsearch@0.7.43: - resolution: {integrity: sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==} - flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} @@ -9939,6 +9936,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.1.1: + resolution: {integrity: sha512-b3YZEYCEH4EdCAtYP7OlDyx7FdPwNzuNwLQ34SfJpM9dlbBZzeXndGavTrC+VCiRWomL21SWfMc6SCKO/U2ZNw==} + minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -20087,8 +20087,6 @@ snapshots: flatted@3.3.2: {} - flexsearch@0.7.43: {} - flow-enums-runtime@0.0.6: {} flow-parser@0.206.0: {} @@ -22514,6 +22512,8 @@ snapshots: minipass@7.1.2: {} + minisearch@7.1.1: {} + minizlib@2.1.2: dependencies: minipass: 3.3.4