You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React新手求助:为Experience组件实现点击弹窗展示对应选项

嘿,别慌!作为React新手遇到这种需求很正常,我来一步步带你实现点击体验项弹出对应操作弹窗的功能~咱们分几个关键步骤来改:

第一步:整理每个类别的操作选项数据

首先,咱们得把每个体验项对应的操作选项(带图片和文字)整理清楚。你已经导入了各个类别的数据,比如gastronomiaExperiences,假设这些数据里包含了操作的图片和标题,咱们先把它们和体验项对应起来:

// 在组件内部定义这个映射(根据你的实际数据结构调整)
const experienceActions = {
  'GASTRONOMÍA': gastronomiaExperiences,
  'PRODUCTOS': productosExperiences,
  'GIFT CARD': giftcardExperiences,
  'DIVERSIÓN': diversionExperiences,
  'DEPORTE': deporteExperiences,
  'BELLEZA': bellezaExperiences,
};

(如果你的数据结构不是直接包含img和title字段,记得调整成弹窗能直接使用的格式)

第二步:修改状态,记录当前点击的体验项

原来的状态只存了弹窗的锚点,现在咱们要多存一个当前选中的类别,这样才能知道要渲染哪个操作选项:

// 替换原来的useState
const [anchorEl, setAnchorEl] = React.useState(null);
const [currentCategory, setCurrentCategory] = React.useState(null);

然后修改点击事件,同时设置锚点和当前类别,还要处理重复点击关闭弹窗的逻辑:

const handleClick = (event, category) => {
  // 点击同一个项就关闭弹窗,否则打开并设置当前类别
  if (anchorEl === event.currentTarget && currentCategory === category) {
    setAnchorEl(null);
    setCurrentCategory(null);
  } else {
    setAnchorEl(event.currentTarget);
    setCurrentCategory(category);
  }
};

还要修正experience组件里的重复onClick问题(原来的代码写了两个onClick会冲突):

const experience = useCallback((img, title) => (
  <div 
    className="experience" 
    aria-describedby={id} 
    onClick={(e) => handleClick(e, title)} // 传入当前体验项的标题作为类别标识
  >
    <img data-sizes="auto" className="lazyload" data-src={img} alt={title} />
    <div className="experience-title">
      <Typography color="textSecondary" variant="subtitle2" className="highlight highlight1" display="inline" >
        { title }
      </Typography>
    </div>
  </div>
), []);

第三步:修改Popper弹窗内容,渲染对应操作选项

首先给弹窗加个美观的样式,在useStyles里添加:

paper: {
  backgroundColor: theme.palette.background.paper,
  border: '1px solid rgba(0,0,0,0.1)',
  padding: theme.spacing(1),
  display: 'flex',
  gap: theme.spacing(2),
  flexWrap: 'wrap',
  maxWidth: '300px',
  boxShadow: theme.shadows[3],
},
actionItem: {
  textAlign: 'center',
  cursor: 'pointer',
  '& img': {
    width: '60px',
    height: '60px',
    objectFit: 'cover',
    borderRadius: '8px',
  },
  '& p': {
    marginTop: theme.spacing(0.5),
    fontSize: '12px',
  },
},

然后修改Popper的内容,根据currentCategory渲染对应操作:

<Popper id={id} open={open} anchorEl={anchorEl} placement="top">
  {currentCategory && (
    <div className={classes.paper}>
      {experienceActions[currentCategory].map((action, index) => (
        <div key={index} className={classes.actionItem}>
          <img src={action.img} alt={action.title} />
          <Typography variant="caption">{action.title}</Typography>
        </div>
      ))}
    </div>
  )}
</Popper>

可选优化:点击外部关闭弹窗

为了提升用户体验,咱们可以添加点击弹窗外部关闭的逻辑:

React.useEffect(() => {
  const handleOutsideClick = (event) => {
    if (anchorEl && !anchorEl.contains(event.target) && !event.target.closest(`#${id}`)) {
      setAnchorEl(null);
      setCurrentCategory(null);
    }
  };

  document.addEventListener('mousedown', handleOutsideClick);
  return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [anchorEl, id]);

完整修改后的代码

把所有修改整合起来,完整组件代码如下:

import React, { memo, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/styles';
import Typography from '@material-ui/core/Typography';
import clsx from 'clsx';
import Popper from '@material-ui/core/Popper';
import gastronomia from 'assets/experiences/gastronomia.jpg';
import productos from 'assets/experiences/productos.jpg';
import giftcard from 'assets/experiences/giftcard.jpg';
import diversion from 'assets/experiences/diversion.jpg';
import deporte from 'assets/experiences/deporte.jpg';
import belleza from 'assets/experiences/belleza.jpg';
import gastronomiaExperiences from 'data/gastronomia';
import productosExperiences from 'data/productos';
import giftcardExperiences from 'data/giftcard';
import diversionExperiences from 'data/diversion';
import deporteExperiences from 'data/deporte';
import bellezaExperiences from 'data/belleza';

// Proptypes definitions to the component.
const propTypes = {
  /** Custom root className. */
  className: PropTypes.string,
};

// Default props definitions.
const defaultProps = {
  className: null,
};

// Component's styles
const useStyles = makeStyles(theme => ({
  root: {
    display: 'block',
    margin: '0 auto',
    maxWidth: '50%',
    [theme.breakpoints.down('md')]: {
      maxWidth: '70%',
    },
    [theme.breakpoints.down('sm')]: {
      maxWidth: '100%',
    },
    '& .experiences-column': {
      display: 'inline-block',
      verticalAlign: 'top',
      textAlign: 'center',
      '&.col1': {
        width: '36.31%',
        [theme.breakpoints.down('sm')]: {
          width: 'initial',
        },
      },
      '&.col2': {
        width: '63.69%',
        [theme.breakpoints.down('sm')]: {
          width: 'initial',
        },
      },
      '& .experience': {
        padding: 2,
        position: 'relative',
        cursor: 'pointer', // 添加鼠标指针提示可点击
        '& img': {
          width: '100%',
          display: 'block',
        },
        '& .experience-title': {
          position: 'absolute',
          bottom: 30,
          left: 0,
          right: 0,
          textAlign: 'center',
        },
      },
    },
  },
  paper: {
    backgroundColor: theme.palette.background.paper,
    border: '1px solid rgba(0,0,0,0.1)',
    padding: theme.spacing(1),
    display: 'flex',
    gap: theme.spacing(2),
    flexWrap: 'wrap',
    maxWidth: '300px',
    boxShadow: theme.shadows[3],
  },
  actionItem: {
    textAlign: 'center',
    cursor: 'pointer',
    '& img': {
      width: '60px',
      height: '60px',
      objectFit: 'cover',
      borderRadius: '8px',
    },
    '& p': {
      marginTop: theme.spacing(0.5),
      fontSize: '12px',
    },
  },
}), { name: 'ExperiencesStyle' });

/**
 * Component used to render a grid of experiences.
 *
 * @param {object} props - The component's props.
 * @returns {object} React element.
 */
const Experiences = memo(
  (props) => {
    const { className } = props;
    const classes = useStyles(props);
    const [anchorEl, setAnchorEl] = React.useState(null);
    const [currentCategory, setCurrentCategory] = React.useState(null);

    // 映射每个体验类别对应的操作选项
    const experienceActions = {
      'GASTRONOMÍA': gastronomiaExperiences,
      'PRODUCTOS': productosExperiences,
      'GIFT CARD': giftcardExperiences,
      'DIVERSIÓN': diversionExperiences,
      'DEPORTE': deporteExperiences,
      'BELLEZA': bellezaExperiences,
    };

    const handleClick = (event, category) => {
      // 点击同一项目则关闭,否则打开弹窗并设置当前类别
      if (anchorEl === event.currentTarget && currentCategory === category) {
        setAnchorEl(null);
        setCurrentCategory(null);
      } else {
        setAnchorEl(event.currentTarget);
        setCurrentCategory(category);
      }
    };

    const open = Boolean(anchorEl);
    const id = open ? 'experience-popper' : undefined;

    // 点击外部关闭弹窗
    useEffect(() => {
      const handleOutsideClick = (event) => {
        if (anchorEl && !anchorEl.contains(event.target) && !event.target.closest(`#${id}`)) {
          setAnchorEl(null);
          setCurrentCategory(null);
        }
      };

      document.addEventListener('mousedown', handleOutsideClick);
      return () => document.removeEventListener('mousedown', handleOutsideClick);
    }, [anchorEl, id]);

    const experience = useCallback((img, title) => (
      <div 
        className="experience" 
        aria-describedby={id} 
        onClick={(e) => handleClick(e, title)}
      >
        <img data-sizes="auto" className="lazyload" data-src={img} alt={title} />
        <div className="experience-title">
          <Typography color="textSecondary" variant="subtitle2" className="highlight highlight1" display="inline" >
            { title }
          </Typography>
        </div>
      </div>
    ), []);

    return (
      <div className={clsx(classes.root, className)}>
        <div className="experiences-column col1">
          {experience(gastronomia, 'GASTRONOMÍA')}
          {experience(giftcard, 'GIFT CARD')}
          {experience(deporte, 'DEPORTE')}
        </div>
        <div className="experiences-column col2">
          {experience(productos, 'PRODUCTOS')}
          {experience(diversion, 'DIVERSIÓN')}
          {experience(belleza, 'BELLEZA')}
        </div>
        <Popper id={id} open={open} anchorEl={anchorEl} placement="top">
          {currentCategory && (
            <div className={classes.paper}>
              {experienceActions[currentCategory].map((action, index) => (
                <div key={index} className={classes.actionItem}>
                  <img src={action.img} alt={action.title} />
                  <Typography variant="caption">{action.title}</Typography>
                </div>
              ))}
            </div>
          )}
        </Popper>
      </div>
    );
  },
);

// Component proptypes.
Experiences.propTypes = propTypes;

// Component default props.
Experiences.defaultProps = defaultProps;

export default Experiences;

一些注意事项

  • 如果你的gastronomiaExperiences等数据结构不包含img和title,记得根据实际数据调整弹窗里的渲染逻辑。
  • 弹窗的位置(placement属性)、样式都可以根据你的需求自由修改。
  • 慢慢理解状态管理和事件处理的逻辑,React的核心就是状态驱动视图,多练几次就熟练啦~

内容的提问来源于stack exchange,提问作者NaNodine

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.06 07:52:30