ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Ariakit Sliding Menu 实战:用 CSS Scroll Snap 实现可横滑的嵌套子菜单

Ariakit Sliding Menu 实战:用 CSS Scroll Snap 实现可横滑的嵌套子菜单 UI组件前端【免费下载链接】ariakitToolkit with accessible components, styles, and examples for your next web app项目地址https://gitcode.com/gh_mirrors/ar/ariakit点击查看免费下载本篇围绕 Ariakit 官方的 Sliding Menu 示例展开讲解如何利用嵌套的Menu组件配合 CSS Scroll Snap实现子菜单在父菜单容器内水平滑动进出的浏览器风格选项菜单类似 Chrome 的 Options 菜单。读完你可以掌握MenuStore的placement/animated/autoFocusOnShow等配置项的实际用法、子菜单 Portal 到父级滚动容器内的技巧以及 Scroll Snap 与滚动检测联动的完整实现方案。示例目标与文件结构示例定义见 examples/menu-slide/readme.mdRendering nested Menu components to create submenus that slide in and out using CSS Scroll Snap.也就是说它不是让子菜单以独立浮层形式弹出而是把每个子菜单渲染进同一个可横向滚动的容器中用滚动实现滑入/滑出。相关文件examples/menu-slide/index.react.tsx示例入口声明菜单树结构examples/menu-slide/menu.tsx核心封装实现滑动的Menu、MenuItem、MenuSeparator、MenuGroup四个组件examples/menu-slide/style.css基于 Tailwind 的样式包含全部 Scroll Snap 配置。菜单树纯声明式的嵌套结构入口组件只是声明一棵浏览器菜单式的树所有交互逻辑都下沉到了Menu封装里见 index.react.tsximport { Menu, MenuGroup, MenuItem, MenuSeparator } from ./menu.tsx; import ./style.css; export default function Example() { return ( Menu labelOptions MenuItem labelNew Tab / MenuItem labelNew Window / MenuSeparator / Menu labelBookmarks MenuItem labelBookmark current tab / {/* ... */} /Menu Menu labelHistory Menu labelRecently closed tabs {/* 二级嵌套 */} MenuItem labelGetting Started / {/* ... */} /Menu MenuItem labelRestore previous session / {/* ... */} /Menu {/* ... */} /Menu ); }几个要点根菜单只有一个labelOptions的触发按钮子菜单就是再嵌套一层Menu label...它既是一条可聚焦的MenuItem父菜单中可见的行又是一个可展开的菜单面板支持任意层级的嵌套——History Recently closed tabs就是二级子菜单示例中的实现天然支持继续深入。核心封装menu.tsx 的关键实现1. 用 Context 串联父子菜单Menu组件通过 React Context 把父级引用传给子级menu.tsx#L6-L12interface MenuContextProps { getWrapper: () HTMLElement | null; // 父级或自身的滚动容器 getMenu: () HTMLElement | null; // 父级菜单面板元素 getOffsetRight: () number; // 当前菜单右边缘的累计偏移 } const MenuContext React.createContextMenuContextProps | null(null);getOffsetRight是自身宽度 父级累计偏移的递归求和menu.tsx#L50-L59用于判断子菜单在横向滚动后被移出可视区多远。2. 每个菜单各自持有独立的 MenuStoreconst menu Ariakit.useMenuStore({ placement: isSubmenu ? right-start : bottom-start, animated: isSubmenu ? 500 : false, });根菜单bottom-start按钮正下方展开子菜单right-start逻辑上锚定在右侧但实际锚点被getAnchorRect重写animated: 500是关键从 disclosure-content.tsx#L192-L196 的源码可以看到当animated为数字时框架会在隐藏后延迟该毫秒数才把animating置为 false从而给 CSS 过渡留出时间配合unmountOnHide保证元素不会在过渡中途被卸载。数字越大滑出动画越从容。3. 只延迟隐藏不延迟出现滑动菜单希望打开时子面板立即可见滑动由滚动驱动只希望关闭时保留一段淡出/滑出时间。源码用两个useLayoutEffect实现menu.tsx#L36-L48// 子菜单默认打开时不自动聚焦这里强制聚焦 // 保证键盘用户能立刻在子菜单中导航 React.useLayoutEffect(() { if (!autoFocusOnShow) { menu.setAutoFocusOnShow(true); } }, [autoFocusOnShow, menu]); // 打开时立即 stopAnimation()取消 500ms 的动画中状态 // 使菜单立刻挂载完成不做进入动画 React.useLayoutEffect(() { if (open) { menu.stopAnimation(); } }, [open, menu]);stopAnimation对应把 store 的animating状态置为 false参见 disclosure-content.tsx#L185 中stopAnimation () store?.setState(animating, false)的同款逻辑。4. 滚动出可视区后同步卸载子菜单这是滑出即消失的核心监听父级滚动容器当累计偏移量超过当前滚动视口时用flushSync同步隐藏并停止动画子菜单立即从 DOM 中卸载menu.tsx#L62-L84React.useEffect(() { if (!parent) return; const parentWrapper parent.getWrapper(); if (!parentWrapper) return; let timeout 0; const onScroll () { clearTimeout(timeout); timeout window.setTimeout(() { // 在 RTL 布局中 scrollLeft 为负值取绝对值 const scrollLeft Math.abs(parentWrapper.scrollLeft); const wrapperOffset scrollLeft parentWrapper.clientWidth; if (wrapperOffset parent.getOffsetRight()) { // 子菜单已完全不可见同步隐藏 停止动画立即卸载 flushSync(menu.hide); menu.stopAnimation(); } }, 100); }; parentWrapper.addEventListener(scroll, onScroll); return () parentWrapper.removeEventListener(scroll, onScroll); }, [parent, menu]);细节值得注意100ms 的防抖避免滚动过程中的抖动flushSync保证hide状态更新是同步的否则动画停止后元素可能仍残留一个渲染帧。5. 子菜单 Portal 进父级滚动容器Ariakit.Menu的渲染配置是滑动效果的物理基础menu.tsx#L129-L147Ariakit.Menu store{menu} classNamemenu unmountOnHide portal{isSubmenu} // 子菜单 portal portalElement{parent?.getWrapper} // 目标是父级滚动容器 wrapperProps{wrapperProps} autoFocusOnShow{autoFocus} autoFocusOnHide{autoFocus} overflowPadding{isSubmenu ? 0 : 8} gutter{isSubmenu ? 0 : 8} flip{!isSubmenu} getAnchorRect{(anchor) parent?.getMenu()?.getBoundingClientRect() || anchor?.getBoundingClientRect() || null } portal{isSubmenu}portalElement子菜单的浮层不挂在 body而是挂在父菜单的滚动 wrapper里于是横向滚动时子菜单跟随父容器一起滑动这正是 sliding 效果的来源getAnchorRect重写锚点子菜单定位参考父菜单面板compositeElement的getBoundingClientRect而非按钮使每一级子面板都紧贴在上一级面板的右侧起始位置wrapperProps中style: { left: auto }的源码注释说明其用途——让 Chrome 能把子菜单滚动到可见位置overflowPadding/gutter对子菜单取 0使子面板边缘与父面板严格对齐视觉上像是同一个容器的下一页。6. 一个组件兼任 MenuItem 与 MenuButton父菜单里的子菜单触发行本质是MenuItem可聚焦、可方向键导航与MenuButton控制 store 打开/收起的复合体menu.tsx#L114-L128{isSubmenu ? ( Ariakit.MenuItem ref{ref} focusOnHover{false} classNamemenu-item {...props} render{renderMenuButton} // MenuItem 外壳 MenuButton 内核 / ) : ( renderMenuButton({ ref, ...props }) // 根菜单纯 MenuButton )}renderMenuButton内部使用Ariakit.MenuButtonshowOnHover{false}、render{button /}并附带MenuButtonArrow表示可展开。这种外层定语义、内层定行为的复合方式是 Ariakitrender能力的典型用法。7. 子菜单头部返回按钮 标题子菜单面板头部由一个aria-labelBack to parent menu的MenuItem和MenuHeading组成menu.tsx#L150-L169点击返回按钮调用menu.hide关闭当前子菜单回到父级。这为键盘与触屏用户提供了明确的上一级操作弥补了纯滚动交互缺少返回入口的缺陷。CSS Scroll Snap让滑动停在整页style.css 中滑动行为全部由 Scroll Snap 属性完成分两部分滚动容器根菜单 wrapperstyle.css#L4-L27.menu-wrapper { apply overflow-x-scroll overscroll-contain scroll-smooth [scroll-snap-type:x_mandatory] /* 横向强制吸附 */ [scroll-snap-stop:always] /* 一次只允许滚到一格 */ [scrollbar-width:none] [::-webkit-scrollbar]:hidden /* 隐藏滚动条 */ /* ...z-50、圆角、边框、阴影等外观样式 */ ; }每一个菜单面板style.css#L29-L44.menu { apply w-[320px] h-[min(320px,var(--popover-available-height))] flex flex-col snap-start /* 吸附点对齐到滚动起点 */ snap-always overflow-y-auto leave:z-0 ; }机制解读scroll-snap-type: x mandatory让父容器横向滚动时强制吸附到子面板的snap-start位置——每开一个子菜单容器横向滚到下一格视觉上就是子面板滑入scroll-snap-stop: always限制每次惯性滚动只落在一格用户无法越过当前子菜单滑到更深的层级每格固定 320px 宽w-[320px]所以源码中getOffsetRight按offsetWidth累计偏移能精确对应第几格h-[min(320px,var(--popover-available-height))]利用 Ariakit Popover 暴露的--popover-available-height变量在视口不足时收缩高度滚动条被隐藏滑动手感更接近原生浏览器的菜单iOS/浏览器 chrome 菜单即此形态。需要说明的是子菜单挂载/隐藏flushSync(menu.hide)stopAnimation与 Scroll Snap 的滚动定位是两条独立但互补的机制——前者保证 DOM 生命周期与焦点管理正确后者保证视觉上的整格滑动。涉及的 Ariakit 组件与 API 速览本例用到的ariakit/reactAPI 及其角色完整 Menu 组件族 API 见 components/menu.mdAPI在本例中的作用useMenuStore每个Menu持有独立 store配置placement/animated见 menu-store.tsMenuButton/MenuButtonArrow触发按钮及展开箭头MenuButtonArrow支持placementleft作返回箭头MenuItem普通项与子菜单触发行复合MenuButtonMenu浮层面板本体portal/getAnchorRect/unmountOnHide等 props 决定滑动行为MenuGroup/MenuGroupLabel带分组标题的选项区如 Recent bookmarksMenuSeparator分隔线MenuHeading子菜单头部标题useStoreState读取open/autoFocusOnShow状态驱动副作用实现思路小结结构层用递归的Menu封装声明菜单树Context 传递父级引用自动识别isSubmenu定位层子菜单 Portal 进父级滚动容器、锚定父面板 rect、left: auto让浏览器把子菜单滚入视野动效层animated: 500提供关闭过渡窗口打开时stopAnimation()立即完成Scroll Snap 负责整格滑动生命周期层滚动偏移检测 flushSync(menu.hide)实现滑出即卸载子菜单unmountOnHide保证不残留可访问性层autoFocusOnShow强制子菜单获得焦点、返回按钮提供明确退出路径、focusOnHover{false}保持键盘导航优先。该示例展示了 Ariakit Menu 组件族的组合能力边界通过 store 级配置animated、autoFocusOnShow与组件级配置portal、getAnchorRect、wrapperProps的协同可以在不改框架源码的前提下把标准的下拉子菜单改造成系统级滑动抽屉菜单。赞分享UI组件前端【免费下载链接】ariakitToolkit with accessible components, styles, and examples for your next web app项目地址https://gitcode.com/gh_mirrors/ar/ariakit点击查看免费下载相关推荐Ariakit 嵌套子菜单实战用嵌套 Menu 组件构建悬停展开的 SubmenuAriakit 嵌套子菜单实战用嵌套 Menu 组件构建悬停展开的 Submenu 本文基于 Ariakit 官方示例 examples/menu nesteUI组件前端Ariakit 实战:用 Menu Combobox 构建可搜索的 Notion 风格嵌套菜单Ariakit 实战:用 Menu Combobox 构建可搜索的 Notion 风格嵌套菜单 Ariakit 的 menu nested comboboxUI组件前端Ariakit在模态 Dialog 中嵌套 Menu 下拉菜单 —— dialog-menu 示例实战解析Ariakit在模态 Dialog 中嵌套 Menu 下拉菜单 —— dialog menu 示例实战解析 本文以 Ariakit 仓库中的 dialog mUI组件前端上一篇EasyExcel多语言表头导出实现方案解析下一篇StemRoller终极指南如何用一次点击分离任何歌曲的人声和器乐音轨创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表