Files
react-test/docs/data/material/components/material-icons/SearchIcons.js
how2ice 005cf56baf
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
init project
2025-12-12 14:26:25 +09:00

678 lines
20 KiB
JavaScript

import * as React from 'react';
import { styled } from '@mui/material/styles';
import MuiPaper from '@mui/material/Paper';
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 Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
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 SearchIcon from '@mui/icons-material/Search';
import FormControlLabel from '@mui/material/FormControlLabel';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import SvgIcon from '@mui/material/SvgIcon';
import * as mui from '@mui/icons-material';
import { Link } from '@mui/docs/Link';
import { useTranslate } from '@mui/docs/i18n';
import useQueryParameterState from 'docs/src/modules/utils/useQueryParameterState';
// For Debugging
// import Menu from '@mui/icons-material/Menu';
// import MenuOutlined from '@mui/icons-material/MenuOutlined';
// import MenuRounded from '@mui/icons-material/MenuRounded';
// import MenuTwoTone from '@mui/icons-material/MenuTwoTone';
// import MenuSharp from '@mui/icons-material/MenuSharp';
// import ExitToApp from '@mui/icons-material/ExitToApp';
// import ExitToAppOutlined from '@mui/icons-material/ExitToAppOutlined';
// import ExitToAppRounded from '@mui/icons-material/ExitToAppRounded';
// import ExitToAppTwoTone from '@mui/icons-material/ExitToAppTwoTone';
// import ExitToAppSharp from '@mui/icons-material/ExitToAppSharp';
// import Delete from '@mui/icons-material/Delete';
// import DeleteOutlined from '@mui/icons-material/DeleteOutlined';
// import DeleteRounded from '@mui/icons-material/DeleteRounded';
// import DeleteTwoTone from '@mui/icons-material/DeleteTwoTone';
// import DeleteSharp from '@mui/icons-material/DeleteSharp';
// import DeleteForever from '@mui/icons-material/DeleteForever';
// import DeleteForeverOutlined from '@mui/icons-material/DeleteForeverOutlined';
// import DeleteForeverRounded from '@mui/icons-material/DeleteForeverRounded';
// import DeleteForeverTwoTone from '@mui/icons-material/DeleteForeverTwoTone';
// import DeleteForeverSharp from '@mui/icons-material/DeleteForeverSharp';
import { HighlightedCode } from '@mui/docs/HighlightedCode';
import synonyms from './synonyms';
const FlexSearchIndex = flexsearch.Index;
// const mui = {
// ExitToApp,
// ExitToAppOutlined,
// ExitToAppRounded,
// ExitToAppTwoTone,
// ExitToAppSharp,
// Menu,
// MenuOutlined,
// MenuRounded,
// MenuTwoTone,
// MenuSharp,
// Delete,
// DeleteOutlined,
// DeleteRounded,
// DeleteTwoTone,
// DeleteSharp,
// DeleteForever,
// DeleteForeverOutlined,
// DeleteForeverRounded,
// DeleteForeverTwoTone,
// DeleteForeverSharp,
// };
if (process.env.NODE_ENV !== 'production') {
Object.keys(synonyms).forEach((icon) => {
if (!mui[icon]) {
console.warn(`The icon ${icon} no longer exists. Remove it from \`synonyms\``);
}
});
}
function selectNode(node) {
// Clear any current selection
const selection = window.getSelection();
selection.removeAllRanges();
// Select code
const range = document.createRange();
range.selectNodeContents(node);
selection.addRange(range);
}
const iconWidth = 35;
const SVG_ICON_CLASS = 'svg-icon';
const StyledIcon = styled('span')(({ theme }) => ({
display: 'inline-flex',
flexDirection: 'column',
color: (theme.vars ?? theme).palette.text.secondary,
margin: '0 4px',
'& > div': {
flexGrow: 1,
fontSize: '.6rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'center',
width: `calc(${iconWidth}px + ${theme.spacing(2)} * 2 + 2px)`,
},
[`& .${SVG_ICON_CLASS}`]: {
width: iconWidth,
height: iconWidth,
boxSizing: 'content-box',
cursor: 'pointer',
color: (theme.vars ?? theme).palette.text.primary,
border: '1px solid transparent',
fontSize: iconWidth,
borderRadius: '12px',
transition: theme.transitions.create(['background-color', 'box-shadow'], {
duration: theme.transitions.duration.shortest,
}),
padding: theme.spacing(2),
margin: theme.spacing(0.5, 0),
'&:hover': {
backgroundColor: (theme.vars ?? theme).palette.background.default,
borderColor: (theme.vars ?? theme).palette.primary.light,
},
},
}));
const handleIconClick = (icon) => () => {
window.gtag('event', 'material-icons', {
eventAction: 'click',
eventLabel: icon.name,
});
window.gtag('event', 'material-icons-theme', {
eventAction: 'click',
eventLabel: icon.theme,
});
};
function handleLabelClick(event) {
selectNode(event.currentTarget);
}
function isElmVisible(elm, margin = 0) {
const rect = elm.getBoundingClientRect();
return rect.bottom >= -margin && rect.top <= window.innerHeight + margin;
}
function Icon(props) {
const { icon, onOpenClick, initiallyVisible = false } = props;
const rootRef = React.useRef(null);
const [isVisible, setIsVisible] = React.useState(initiallyVisible);
// Virtualize the icons to reduce page size and React rendering time.
// Only render the icons after they become visible in the viewport.
React.useEffect(() => {
const margin = 200;
const root = /** @type {SVGElement} */ (rootRef.current);
if (initiallyVisible || isElmVisible(root, margin)) {
setIsVisible(true);
return () => {};
}
const observer = new IntersectionObserver(
(entries) => {
if (isElmVisible(entries[0].target, margin)) {
setIsVisible(true);
}
},
{ rootMargin: `${margin}px 0px` },
);
observer.observe(root);
return () => {
observer.disconnect();
};
}, [initiallyVisible]);
/* eslint-disable jsx-a11y/click-events-have-key-events */
return (
<StyledIcon
key={icon.importName}
ref={rootRef}
onClick={Math.random() < 0.1 ? handleIconClick(icon) : null}
>
{isVisible ? (
<SvgIcon
component={icon.Component}
className={SVG_ICON_CLASS}
tabIndex={-1}
onClick={onOpenClick}
title={icon.importName}
/>
) : (
<div className={SVG_ICON_CLASS} />
)}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- TODO: a11y */}
<div onClick={handleLabelClick}>{icon.importName}</div>
{/* eslint-enable jsx-a11y/click-events-have-key-events */}
</StyledIcon>
);
}
const Icons = React.memo(function Icons(props) {
const { icons, handleOpenClick } = props;
return (
<div>
{icons.map((icon, i) => (
<Icon
key={icon.importName}
icon={icon}
onOpenClick={handleOpenClick}
// Render the first 50 icons immediately as they would be visible on page load
initiallyVisible={i < 50}
/>
))}
</div>
);
});
Icons.propTypes = {
handleOpenClick: PropTypes.func.isRequired,
icons: PropTypes.array.isRequired,
};
const ImportLink = styled(Link)(({ theme }) => ({
textAlign: 'right',
padding: theme.spacing(0.5, 1),
}));
const Markdown = styled(HighlightedCode)(({ theme }) => ({
cursor: 'pointer',
transition: theme.transitions.create('background-color', {
duration: theme.transitions.duration.shortest,
}),
'&:hover': {
'& code': {
backgroundColor: '#96c6fd80',
},
},
'& pre': {
borderRadius: 0,
margin: 0,
},
}));
const Title = styled(Typography)(({ theme }) => ({
display: 'inline-block',
cursor: 'pointer',
transition: theme.transitions.create('background-color', {
duration: theme.transitions.duration.shortest,
}),
'&:hover': {
backgroundColor: '#96c6fd80',
},
}));
const CanvasComponent = styled('div')(({ theme }) => ({
fontSize: 210,
color: (theme.vars ?? theme).palette.text.primary,
backgroundSize: '30px 30px',
backgroundColor: 'transparent',
backgroundPosition: '0 0, 0 15px, 15px -15px, -15px 0',
backgroundImage:
'linear-gradient(45deg, #e6e6e6 25%, transparent 25%), linear-gradient(-45deg, #e6e6e6 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e6e6e6 75%), linear-gradient(-45deg, transparent 75%, #e6e6e6 75%)',
...theme.applyStyles('dark', {
backgroundImage:
'linear-gradient(45deg, #595959 25%, transparent 25%), linear-gradient(-45deg, #595959 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #595959 75%), linear-gradient(-45deg, transparent 75%, #595959 75%)',
}),
}));
const FontSizeComponent = styled('span')(({ theme }) => ({
margin: theme.spacing(2),
}));
const ContextComponent = styled('div', {
shouldForwardProp: (prop) => prop !== 'contextColor' && prop !== 'as',
})(({ theme }) => ({
margin: theme.spacing(0.5),
padding: theme.spacing(1, 2),
borderRadius: theme.shape.borderRadius,
boxSizing: 'content-box',
variants: [
{
props: {
contextColor: 'primary',
},
style: {
color: (theme.vars ?? theme).palette.primary.main,
},
},
{
props: {
contextColor: 'primaryInverse',
},
style: {
color: (theme.vars ?? theme).palette.primary.contrastText,
backgroundColor: (theme.vars ?? theme).palette.primary.main,
},
},
{
props: {
contextColor: 'textPrimary',
},
style: {
color: (theme.vars ?? theme).palette.text.primary,
},
},
{
props: {
contextColor: 'textPrimaryInverse',
},
style: {
color: (theme.vars ?? theme).palette.background.paper,
backgroundColor: (theme.vars ?? theme).palette.text.primary,
},
},
{
props: {
contextColor: 'textSecondary',
},
style: {
color: (theme.vars ?? theme).palette.text.secondary,
},
},
{
props: {
contextColor: 'textSecondaryInverse',
},
style: {
color: (theme.vars ?? theme).palette.background.paper,
backgroundColor: (theme.vars ?? theme).palette.text.secondary,
},
},
],
}));
const DialogDetails = React.memo(function DialogDetails(props) {
const { open, selectedIcon, handleClose } = props;
const t = useTranslate();
const [copied1, setCopied1] = React.useState(false);
const [copied2, setCopied2] = React.useState(false);
const handleClick = (tooltip) => async (event) => {
await copy(event.currentTarget.textContent);
const setCopied = tooltip === 1 ? setCopied1 : setCopied2;
setCopied(true);
};
return (
<Dialog
fullWidth
maxWidth="sm"
open={open}
onClose={handleClose}
sx={{
'& .MuiDialog-paper': {
borderRadius: 2.5,
backgroundImage: 'none',
border: '1px solid',
borderColor: 'divider',
},
}}
>
{selectedIcon ? (
<React.Fragment>
<DialogTitle>
<Tooltip
placement="right"
title={copied1 ? t('copied') : t('clickToCopy')}
slotProps={{
transition: {
onExited: () => setCopied1(false),
},
}}
>
<Title component="span" variant="inherit" onClick={handleClick(1)}>
{selectedIcon.importName}
</Title>
</Tooltip>
</DialogTitle>
<Tooltip
placement="top"
title={copied2 ? t('copied') : t('clickToCopy')}
slotProps={{
transition: { onExited: () => setCopied2(false) },
}}
>
<Markdown
copyButtonHidden
onClick={handleClick(2)}
code={`import ${selectedIcon.importName}Icon from '@mui/icons-material/${selectedIcon.importName}';`}
language="js"
/>
</Tooltip>
<ImportLink
color="text.secondary"
href="/material-ui/icons/"
variant="caption"
>
{t('searchIcons.learnMore')}
</ImportLink>
<DialogContent>
<Grid container>
<Grid size="grow">
<Grid container sx={{ justifyContent: 'center' }}>
<CanvasComponent as={selectedIcon.Component} />
</Grid>
</Grid>
<Grid size="grow">
<Grid
container
sx={{ alignItems: 'flex-end', justifyContent: 'center' }}
>
<Grid>
<Tooltip title="fontSize small">
<FontSizeComponent
as={selectedIcon.Component}
fontSize="small"
/>
</Tooltip>
</Grid>
<Grid>
<Tooltip title="fontSize medium">
<FontSizeComponent as={selectedIcon.Component} />
</Tooltip>
</Grid>
<Grid>
<Tooltip title="fontSize large">
<FontSizeComponent
as={selectedIcon.Component}
fontSize="large"
/>
</Tooltip>
</Grid>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="primary"
/>
<ContextComponent
as={selectedIcon.Component}
contextColor="primaryInverse"
/>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="textPrimary"
/>
<ContextComponent
as={selectedIcon.Component}
contextColor="textPrimaryInverse"
/>
</Grid>
<Grid container sx={{ justifyContent: 'center' }}>
<ContextComponent
as={selectedIcon.Component}
contextColor="textSecondary"
/>
<ContextComponent
as={selectedIcon.Component}
contextColor="textSecondaryInverse"
/>
</Grid>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ borderTop: '1px solid', borderColor: 'divider' }}>
<Button onClick={handleClose}>{t('close')}</Button>
</DialogActions>
</React.Fragment>
) : (
<div />
)}
</Dialog>
);
});
DialogDetails.propTypes = {
handleClose: PropTypes.func.isRequired,
open: PropTypes.bool.isRequired,
selectedIcon: PropTypes.object,
};
const Form = styled('form')({
position: 'sticky',
top: 80,
});
const Paper = styled(MuiPaper)(({ theme }) => ({
position: 'sticky',
top: 80,
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(2),
width: '100%',
borderRadius: '12px',
border: '1px solid',
borderColor: (theme.vars ?? theme).palette.divider,
boxShadow: 'none',
}));
function formatNumber(value) {
return new Intl.NumberFormat('en-US').format(value);
}
const Input = styled(InputBase)({
flex: 1,
});
const searchIndex = new FlexSearchIndex({
tokenize: 'full',
});
const allIconsMap = {};
const allIcons = Object.keys(mui)
.sort()
.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;
}
}
let searchable = name;
if (synonyms[searchable]) {
searchable += ` ${synonyms[searchable]}`;
}
searchIndex.add(importName, searchable);
const icon = {
importName,
name,
theme,
Component: mui[importName],
};
allIconsMap[importName] = icon;
return icon;
});
/**
* Returns the last defined value that has been passed in [value]
*/
function useLatest(value) {
const latest = React.useRef(value);
React.useEffect(() => {
if (value !== undefined && value !== null) {
latest.current = value;
}
}, [value]);
return value ?? latest.current;
}
export default function SearchIcons() {
const [theme, setTheme] = useQueryParameterState('theme', 'Filled');
const [selectedIcon, setSelectedIcon] = useQueryParameterState('selected', '');
const [query, setQuery] = useQueryParameterState('query', '');
const handleOpenClick = React.useCallback(
(event) => {
setSelectedIcon(event.currentTarget.getAttribute('title'));
},
[setSelectedIcon],
);
const handleClose = React.useCallback(() => {
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]);
const deferredIcons = React.useDeferredValue(icons);
const isPending = deferredIcons !== icons;
React.useEffect(() => {
// Keep track of the no results so we can add synonyms in the future.
if (query.length >= 4 && icons.length === 0) {
window.gtag('event', 'material-icons', {
eventAction: 'no-results',
eventLabel: query,
});
}
}, [query, icons.length]);
const dialogSelectedIcon = useLatest(
selectedIcon ? allIconsMap[selectedIcon] : null,
);
return (
<Grid container sx={{ minHeight: 500, width: '100%' }}>
<Grid
size={{
xs: 12,
sm: 3,
}}
>
<Form>
<Typography fontWeight={500} sx={{ mb: 1 }}>
Filter the style
</Typography>
<RadioGroup
value={theme}
onChange={(event) => setTheme(event.target.value)}
>
{['Filled', 'Outlined', 'Rounded', 'Two tone', 'Sharp'].map(
(currentTheme) => {
return (
<FormControlLabel
key={currentTheme}
value={currentTheme}
control={<Radio size="small" />}
label={currentTheme}
/>
);
},
)}
</RadioGroup>
</Form>
</Grid>
<Grid
size={{
xs: 12,
sm: 9,
}}
>
<Paper>
<IconButton sx={{ padding: '10px' }} aria-label="search">
<SearchIcon />
</IconButton>
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search icons…"
inputProps={{ 'aria-label': 'search icons' }}
endAdornment={
isPending ? (
<InputAdornment position="end">
<CircularProgress size={16} sx={{ mr: 2 }} />
</InputAdornment>
) : null
}
/>
</Paper>
<Typography sx={{ mb: 1 }}>{`${formatNumber(
icons.length,
)} matching results`}</Typography>
<Icons icons={deferredIcons} handleOpenClick={handleOpenClick} />
</Grid>
<DialogDetails
open={!!selectedIcon}
selectedIcon={dialogSelectedIcon}
handleClose={handleClose}
/>
</Grid>
);
}