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 (
+
+ );
});
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 (
-
-
-
-
-
+
+
+
+
+
@@ -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