资讯动态

原生JS实现后台侧边栏手风琴菜单:折叠、箭头、状态记忆全攻略

发布时间:2026/10/16 4:26:52 来源:尧图企业网站定制
后台系统的侧边栏菜单写到最后几乎都会变成一个绕不开的话题菜单层级一多全部展开看起来就像一堵会呼吸的墙点起来费劲看着更费劲。我早期做某公司内部管理系统的时候侧边栏就是三级菜单平铺结果业务方反馈说每次找入口都要滚动半天后来才改成手风琴Accordion交互——同一时刻只展开一个分组配合一个小的选择箭头指示当前状态导航的“信息密度”和“定位效率”都好了很多。这篇就用原生JS完整实现一遍侧边栏手风琴效果包含折叠箭头的旋转、选中项的小箭头指示、互斥展开、刷新后状态恢复这几个关键点写给那些不想为了一个导航就引一整套前端框架的人。1. 侧边栏“手风琴”解决的是什么问题场景与方案取舍1.1 一个典型的后台导航痛点如果你做过中后台系统一定见过这种场景左侧菜单有“运营中心”“订单管理”“系统设置”三个顶级模块每个模块下面又挂五六个二级页面二级页面下面可能还有三级子功能。全部展开的时候整个侧边栏的视觉高度动辄超过一屏用户想找一个自己常用的入口得在几十个菜单项里来回扫。更麻烦的是用户其实每周只用到其中两三个模块剩下的都只是背景噪音。菜单全部展开时导航的“信噪比”非常低——重要的入口和其他不重要的入口混在一起视觉权重被均摊了。我当年第一次遇到这个场景第一反应是加个“折叠/展开”开关让用户手动收起所有子菜单。但实际体验下来发现手动开关只能解决“整体收起来”和“整体展开”这两个极端状态并没有解决“我只想专心看当前模块下的入口”这个核心诉求。手风琴交互解决的正是这个问题同一时刻只允许一个分组处于展开状态点击新分组时已展开的分组自动收起。它把用户的视觉注意力强制收敛到“当前正在操作”的那一组里其他分组作为简洁的标题条存在。配合一个明确的箭头指示和选中项的定位条用户一眼就能知道自己现在在哪、能往哪去。1.2 手风琴的三种常见形态与选型参考在动手写代码前我建议先想清楚手风琴到底做“多大力度”的互斥因为不同力度的实现复杂度差别不小。第一种是“同层级互斥”。顶层的多个分组之间互斥当前分组展开时其他分组收起但分组内部如果还有二级分组二级分组之间可以再各自互斥。这是最常见的形态也是这篇实现的目标。它的交互直觉最符合“手风琴”这个词一片键盘的琴键按下去一个其他的就抬起来。第二种是“完全自由展开”。允许用户同时打开多个分组所有展开状态互不影响。这种其实叫“多开折叠面板”更准确并不严格算手风琴。如果你的菜单分组之间没有强逻辑互斥关系用户可能同时想看A模块和B模块那多开会更友好。我后来做另一个项目时就把这个做成了可配置项。第三种是“记忆型互斥”Accordion with memory。它在第一种的基础上记住每个分组上次的展开状态刷新后恢复。严格说这已经不是标准手风琴而是手风琴状态管理的混合体。实际业务里这个体验价值最高——用户刷新页面后如果之前正开着“订单管理”刷新完却回到“运营中心”心里会咯噔一下以为页面出问题了。我的建议是菜单层级浅只有一级子菜单且分组数量多时手风琴最好菜单层级深或者每个分组内容都很长时多开模式更能减少反复点击如果团队对交互细节有追求直接上“记忆型互斥”成本只多不到20行代码收益却很实在。这篇实现里我会把第一种和第三种合并做代码里留一个开关就能退回到第一种。2. 先搭基础骨架HTML结构与箭头的CSS实现细节2.1 菜单容器的语义化结构button才是正确的可点击元素很多人写菜单标签随手就是div onclick或者纯a但手风琴的“折叠头”本身不是一个跳转链接它是一个控制子菜单展开/收起的开关。语义上应该用button否则盲人读屏用户听到的是一个“链接”点进去却不会跳转体验是断裂的。我推荐的HTML结构长这样aside classsidebar nav classsidebar-menu idsideMenu div classmenu-group button classmenu-toggle typebutton aria-expandedfalse span classmenu-label运营中心/span span classmenu-arrow aria-hiddentrue/span /button div classsubmenu-wrap ul classsubmenu lia classsub-link href/dashboard数据概览/a/li lia classsub-link active href/reports报表管理/a/li lia classsub-link href/channel渠道分析/a/li /ul /div /div div classmenu-group button classmenu-toggle typebutton aria-expandedfalse span classmenu-label订单管理/span span classmenu-arrow aria-hiddentrue/span /button div classsubmenu-wrap ul classsubmenu lia classsub-link href/orders订单列表/a/li lia classsub-link href/refund退款管理/a/li lia classsub-link href/logistics物流跟踪/a/li /ul /div /div div classmenu-group button classmenu-toggle typebutton aria-expandedfalse span classmenu-label系统设置/span span classmenu-arrow aria-hiddentrue/span /button div classsubmenu-wrap ul classsubmenu lia classsub-link href/users用户管理/a/li lia classsub-link href/roles角色权限/a/li lia classsub-link href/logs操作日志/a/li /ul /div /div /nav /aside这里有三个容易被忽略的点需要说明一下。第一button上的aria-expanded属性不是装饰品。它告诉读屏软件“这个折叠按钮当前是展开还是收起状态”配合aria-hiddentrue的箭头图标才是一组完整的无障碍语义。JS里每次切换is-open类的同时要同步更新这个属性。第二子菜单为什么用一个额外的div.submenu-wrap包了一层。这个div是高度过渡动画的关键角色后面讲到grid-template-rows技巧时你就明白了没有这一层包裹动画很难做得干净。第三active类我直接写死在“报表管理”这个a上这是作为默认选中态。真正的运行环境里这个类应该由JS根据当前URL动态设置我在第3节会写完整的初始化逻辑。2.2 展开箭头的两种画法折叠旋转箭头与选中项指示条标题里特别提到“带选择小箭头”我理解这里其实包含两个不同的视觉要素很多文章会把它们混在一起讲但实际做的时候要分开处理。第一个要素是折叠箭头它表达的是“这个分组能不能展开/收起”。它应该出现在每个分组标题条的右侧展开时箭头改变指向。纯粹用一个span来画不依赖任何图标库.menu-arrow { width: 8px; height: 8px; border-right: 2px solid #8a94a6; border-bottom: 2px solid #8a94a6; transform: rotate(45deg); transition: transform 0.25s ease; flex-shrink: 0; } .menu-group.is-open .menu-arrow { transform: rotate(225deg); }这段CSS用border画出右下角的两个边再把整个元素旋转45度就得到一个指向右下的小三角展开态旋转到225度三角变成指向左上。从“指向右侧”变成“指向左上”视觉上就是“收着的菜单可以往右展开”变“展开的菜单要收回去”的语义切换。第二个要素是选中项指示条或者叫当前定位标记。它表达的是“你当前停留在哪个菜单项”。这个一般放在展开后子菜单项的左侧选中项前面出现一条高亮的短竖条。.sub-link { position: relative; display: block; padding: 10px 16px 10px 20px; color: #5a6374; font-size: 14px; text-decoration: none; transition: background-color 0.2s, color 0.2s; } .sub-link:hover { color: #2f6bff; background: #f2f6ff; } .sub-link.active { color: #2f6bff; background: #eaf1ff; } .sub-link.active::before { content: ; position: absolute; left: 0; top: 50%; width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: #2f6bff; transform: translateY(-50%); }注意这里的::before元素没有旋转是一条竖线不是三角。如果你确实想要一个水平方向的“小箭头”指到选中项可以把这段样式换成基于border的三角形把left调整成-6px让箭头从菜单项左侧伸出来。实际项目中我把两种都试过竖线指示在视觉上更克制三角形箭头更显眼但前提是你的侧边栏有足够留白否则会显得拥挤。还有一点经验折叠箭头用border画好处是不依赖任何图标字体或者SVG文件也没有图标字体加载失败变成豆腐块的隐患坏处是如果将来要改颜色你得保证它的颜色能跟着文字颜色走。所以我在样式里没有写死border-color而是让它从currentColor里取也就是说只要button的文字颜色变了箭头颜色会自己跟着变。如果想要这种效果就把border-right和border-bottom的第二个颜色值从#8a94a6改成currentColor。3. 核心JS逻辑一次点击完成互斥、切换与状态记忆3.1 为什么要用事件代理而不是给每个按钮单独绑定我见过不少新手写法是这样的页面加载后拿到所有.menu-toggle每个都addEventListener点击时处理自己对应的分组。这种写法在“菜单是静态写死在HTML里”的情况下没什么问题但实际业务里侧边栏菜单经常是接口返回之后动态渲染的或者用户权限一变菜单就要重新生成。如果用“每个按钮单独绑定”的方式动态渲染时你得在渲染完成后再绑定一次事件而且还要小心重复绑定——重新渲染一遍之前的老节点虽然没了但如果你用innerHTML替换新节点的绑定要重新做如果你把老节点保留新节点又绑一遍就会出现一个点击触发两三次处理的诡异现象。事件代理Event Delegation把监听器挂在父容器上通过event.target触发时才判断“用户点的到底是不是折叠按钮”。这样无论菜单后续怎么增删只要还在同一个父容器内事件逻辑天然生效不需要重新绑定。对于动态菜单我强烈建议从一开始就用事件代理。3.2 互斥展开与激活态联动点击一次做了三件事下面是核心的JS代码我把它拆成两个监听器来写一个管折叠一个管选中。放在一个监听器里当然也能做但职责分开后逻辑更清楚后续维护成本低。const menu document.getElementById(sideMenu); // 监听一手风琴折叠 menu.addEventListener(click, (event) { const toggle event.target.closest(.menu-toggle); if (!toggle) return; const group toggle.closest(.menu-group); const isCurrentlyOpen group.classList.contains(is-open); // 互斥先收起所有同层分组 [...menu.querySelectorAll(:scope .menu-group)].forEach((item) { item.classList.remove(is-open); item.querySelector(:scope .menu-toggle).setAttribute(aria-expanded, false); }); // 当前分组如果是展开状态就收起如果是收起状态就展开 if (!isCurrentlyOpen) { group.classList.add(is-open); toggle.setAttribute(aria-expanded, true); } });这段代码的关键点在于第一个判断closest(.menu-toggle)。用户在菜单里点击的位置可能是文字span、箭头span也可能正好点到了button本身closest会从被点击元素往上找直到找到最近的.menu-toggle。这样就不需要给每个内部元素分别绑事件了。如果点中的是子菜单里的a链接closest找不到.menu-toggle函数直接返回不会触发折叠逻辑正好避免了“点链接的时候菜单被误收起”这种低级事故。互斥逻辑用的是“先全部收起再决定是否展开当前项”。这样做有一个容易被忽略的好处如果用户连续点击了A分组再点B分组B展开时A自动收起这符合手风琴预期如果用户点击了已经展开的A分组它会被收起此时所有分组都处于收起状态用户需要再点一次才能展开这是带切换语义的手风琴。如果你想做成“点击当前展开项时保持展开不允许全部收起”把最后的if (!isCurrentlyOpen)改成group.classList.add(is-open)即可但我不推荐因为用户习惯性再点一下折叠按钮就是想把它关掉。再看选中态逻辑menu.addEventListener(click, (event) { const link event.target.closest(.sub-link); if (!link) return; const prevActive menu.querySelector(.sub-link.active); if (prevActive) { prevActive.classList.remove(active); } link.classList.add(active); // 把选中项归属的分组展开保证当前页面始终可见 link.closest(.menu-group).classList.add(is-open); // 同步 aria-expanded const currentToggle link.closest(.menu-group).querySelector(:scope .menu-toggle); currentToggle.setAttribute(aria-expanded, true); // 记录当前选中项用于刷新后恢复 try { localStorage.setItem(sidebarActiveLink, link.getAttribute(href)); } catch (err) { // 隐私模式或存储不可用时静默降级不阻断选择行为 } });这里要解释一个容易忽略的设计决策我为什么在点击子链接时也把所属分组强行展开了因为手风琴的互斥特性决定了用户点击A分组下的链接后B分组自动收起但下次用户直接通过某种方式定位到了A分组下的链接比如从别的页面的“跳转到报表”按钮此时整个侧边栏可能处于全收起状态如果A分组没有被展开用户会觉得自己选的项“消失了”。所以每次激活某个二级链接时有意识地把它所属的分组展开能保证“当前选中项永远可见”。我在给某项目中加了这行之后产品经理专门反馈说“现在不会迷路了”。3.3 状态记忆与URL初始化刷新后不丢位置状态记忆分两部分一是记住用户上次选中了哪个菜单项二是记住哪个分组处于展开状态。最简单可靠的做法是选中项的href已经足够。页面加载时根据当前真实URL或者localStorage里记录的href找到对应链接给它们补上active和is-open类。代码长这样// 初始化优先按当前URL匹配其次按上次纪录恢复 (function initSidebar() { let activeHref null; try { const stored localStorage.getItem(sidebarActiveLink); if (stored) activeHref stored; } catch (err) { // 存储不可读时忽略 } const path window.location.pathname; // 优先基于真实URL匹配因为它必然反映用户当前所在页面 const currentLink [...menu.querySelectorAll(.sub-link)].find( (link) link.getAttribute(href) path ); const targetLink currentLink || [...menu.querySelectorAll(.sub-link)].find( (link) link.getAttribute(href) activeHref ); if (targetLink) { const targetGroup targetLink.closest(.menu-group); targetGroup.classList.add(is-open); targetLink.classList.add(active); const toggle targetGroup.querySelector(:scope .menu-toggle); toggle.setAttribute(aria-expanded, true); } })();我把“先按URL匹配再按存储恢复”的顺序写出来是有实际考量的。如果用户从外部链接直接进入一个页面URL是当前真实状态应当优先localStorage只是兜底方案在用户首次进入且URL带的地址不在菜单里时才用上次记录来找。还有一个边界情况如果用户上次选的菜单项后来被后端下掉了权限find会返回undefined此时不做任何展开操作侧边栏保持全收起这其实是最安全的表现——如果强行找第一个分组展开用户可能被带到没权限的菜单下反而引起误解。关于localStorage的try/catch有人觉得多余但实际项目中真的会遇到Safari隐私模式下localStorage.setItem直接抛异常的情况。如果你不在这一步兜底整个点击选中功能都会因为一个存储失败而崩溃。这个坑我在早期项目里踩过后来所有涉及localStorage的写入都习惯性包一层try/catch。4. 实测中的细节坑与排查链路从过渡卡顿到样式裁切4.1 max-height过渡为什么总是慢半拍改用grid-template-rows子菜单展开收起动画最直观的方案是给.submenu-wrap设一个max-height展开时从0变成500px收起时再从500px变成0配合transition实现动画。很多教程都这么写但实际用起来有毛病子菜单的真实高度是不固定的写小了遮内容写大了展开动画延迟得很明显——因为浏览器得先把高度过渡到500px这个过程中前面大半段时间高度都在快速变化到接近500px的时候才会突然慢下来看起来就是“先快后慢”外加一截空行程。我推荐用grid-template-rows来做高度动画结构上只需要保证我们之前提到的.submenu-wrap里面再套一层内容容器把这个容器设置为min-height: 0即可。CSS长这样.submenu-wrap { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.28s ease; } .menu-group.is-open .submenu-wrap { grid-template-rows: 1fr; } .submenu { overflow: hidden; min-height: 0; }原理很简单grid-template-rows: 0fr表示网格行高为01fr表示行高自适应内容浏览器会把这中间的变化当作可过渡属性。子元素.submenu设置overflow: hidden; min-height: 0是为了让内容在0fr时被完全裁掉不会因为内容自身高度把网格撑开。这个方案对比max-height最大的好处是不需要预估菜单高度内容多高就过渡多高动画时长恒定。我第一次用这个方法替换掉max-height时同事说“展开的节奏终于对了”。唯一要留意的是浏览器兼容性如果你面向的用户还存在老旧内核浏览器grid过渡会不生效表现为“直接展开/收起无动画”考虑到那类浏览器本身已经在业务淘汰边缘我个人认为可以接受但如果你们的用户画像里有大量XP时代的存量机器就还得退回max-height方案并在样式里写好降级。还有一个小细节过渡时长0.28s是我试过多个值之后的折中。太短比如0.1s会感觉菜单是被弹开的缺少“拉开”的质感太长0.5s以上在频繁切换分组时会显得拖沓。后台系统里用户经常快速在几个模块之间切换交互动作本身是高频操作宁可稍微利落一点也不要拖泥带水。4.2 展开时子菜单阴影/边框被裁切overflow引发的连锁问题用grid-template-rows配合overflow: hidden裁切内容遇到一个常见反噬如果子菜单项上有box-shadow或者下拉时的小阴影效果阴影会被overflow: hidden裁掉看起来像是子菜单的边界被硬生生切了一刀。排查链路是这样的第一反应可能是阴影颜色不对或者z-index被压低于是调了好久阴影参数发现展开后阴影只显示一半。然后把.submenu的overflow: hidden去掉阴影确实完整了但展开动画又变成内容直接溢出完全失去了裁切效果。最后才意识到问题根源是“裁切”和“阴影”本质上是一对矛盾不能在同一个元素上同时满足——你要裁的就是内容边界而阴影在边界外。我的解决思路是分层让.submenu-wrap负责裁切和动画阴影画在它外面的一个伪元素上或者干脆不要给子菜单项本身加阴影而是给展开后的整个.submenu-wrap一个外阴影。这样裁切归裁切阴影归阴影两边不打架。实际代码可以这样补.menu-group.is-open .submenu-wrap { grid-template-rows: 1fr; box-shadow: 0 4px 12px rgba(31, 41, 55, 0.08); }给展开后的容器加一个外部阴影因为此时容器高度等于内容高度阴影能正常显示且不受内部裁切影响。如果你想要的是子菜单项内部的分隔阴影那就把它画在.sub-link的border-bottom上不要用box-shadow。这个问题的排查过程给我的启发是遇到类似“动画正常但样式不对”的bug不要一上来就改视觉参数先想想是不是结构和样式的层级关系本身有冲突。把“动画裁切层”和“视觉装饰层”分开考虑大部分问题都能定位清楚。4.3 动态渲染菜单时事件代理的配套注意点如果菜单是接口返回后渲染的事件代理已经省去了重复绑定的事但还是有几个配套细节容易翻车。第一初始化逻辑的执行时机必须在菜单渲染完成之后。如果你在DOMContentLoaded时就执行initSidebar而菜单数据是从接口异步加载的此时menu.querySelectorAll(.sub-link)拿到的可能是空数组初始化自然无效。正确做法是把初始化放进渲染函数里渲染完再调。或者利用MutationObserver监听菜单容器的子节点变化渲染完成后自动触发一次初始化这样即使后续菜单二次更新也能自动恢复状态。第二动态渲染时active类不要先写死在模板字符串里。很多人习惯后端或前端把“当前选中项”直接体现在初始HTML中我这篇标准结构里也写了一个active那是为了演示默认态。但如果渲染结果是异步的这个“当前选中项”应该由JS统一判定否则会出现“渲染时猜了一次、初始化时又设置了一次”的重复逻辑猜错的那次还会覆盖正确的。第三关于重复绑定的排查。事件代理本身不会重复绑定但如果你在别处又给同一个父容器绑了一遍同样的逻辑点击时会触发两次处理。因为两次处理的结果是幂等的都移除再添加同一个类肉眼看不出来但如果你在逻辑里加了计数器或者埋点就会看到数据翻倍。排查时用浏览器调试面板看事件监听器列表如果同一个节点上出现了多个相同处理函数就去找是不是有多处初始化调用。5. 把代码收进函数可复用配置与后续扩展建议5.1 组件化封装一个初始化函数接管所有行为为了不把这套逻辑写死在一个页面里我会把它封装成一个工厂函数。核心思路是传入菜单根节点和配置项返回一个实例实例上有init、destroy等方法。function createSidebarAccordion(menuRoot, options {}) { const config { exclusive: true, // 同层互斥 persistActive: true, // 记忆选中项 defaultActiveHref: , // 默认选中项 onActiveChange: null, // 选中变化回调方便接业务埋点 }; Object.assign(config, options); const activeKey sidebarActiveLink; function isOpen(group) { return group.classList.contains(is-open); } function setGroupState(group, open) { group.classList.toggle(is-open, open); const toggle group.querySelector(:scope .menu-toggle); toggle.setAttribute(aria-expanded, String(open)); } function closeSiblingGroups(currentGroup) { if (!config.exclusive) return; [...menuRoot.querySelectorAll(:scope .menu-group)].forEach((group) { if (group ! currentGroup isOpen(group)) { setGroupState(group, false); } }); } function handleToggleClick(event) { const toggle event.target.closest(.menu-toggle); if (!toggle) return; const group toggle.closest(.menu-group); const shouldOpen !isOpen(group); closeSiblingGroups(group); setGroupState(group, shouldOpen); } function setActiveLink(link) { const prev menuRoot.querySelector(.sub-link.active); if (prev) prev.classList.remove(active); link.classList.add(active); const group link.closest(.menu-group); closeSiblingGroups(group); setGroupState(group, true); if (config.persistActive) { try { localStorage.setItem(activeKey, link.getAttribute(href)); } catch (err) { // 存储失败不阻断 } } if (typeof config.onActiveChange function) { config.onActiveChange(link); } } function handleLinkClick(event) { const link event.target.closest(.sub-link); if (!link) return; setActiveLink(link); } function init() { menuRoot.addEventListener(click, handleToggleClick); menuRoot.addEventListener(click, handleLinkClick); let matchLink null; if (config.defaultActiveHref) { matchLink menuRoot.querySelector(.sub-link[href${config.defaultActiveHref}]); } if (!matchLink config.persistActive) { try { const stored localStorage.getItem(activeKey); if (stored) { matchLink menuRoot.querySelector(.sub-link[href${stored}]); } } catch (err) { // 存储不可读时跳过 } } if (matchLink) { setActiveLink(matchLink); } } function destroy() { menuRoot.removeEventListener(click, handleToggleClick); menuRoot.removeEventListener(click, handleLinkClick); } return { init, destroy }; } // 用法 const sidebar createSidebarAccordion(document.getElementById(sideMenu), { exclusive: true, persistActive: true, onActiveChange(link) { console.log(菜单切换, link.textContent.trim()); }, }); sidebar.init();封装之后页面里所有关于侧边栏交互的逻辑都收敛到这一个函数里。要关掉互斥改成多开exclusive: false要清空记忆直接调一次localStorage.removeItem(sidebarActiveLink)。如果想彻底移除所有行为调用destroy所有监听器会被移除适合在单页应用路由切换时做清理避免跨页面的事件残留。5.2 后续还能扩展的几种玩法这套基础实现稳定跑起来后如果你想继续往产品化方向做有几条值得尝试的路径。第一结合路由信息自动高亮。如果你的项目用的是history路由监听popstate事件在路由变化时重新执行一次“按URL匹配选中项”的逻辑可以让用户在浏览器前进后退时侧边栏自动跟随。第二菜单搜索过滤。在手风琴的基础上增加一个搜索框用户输入关键词时把包含关键词的分组自动展开并把匹配项高亮清空搜索时恢复到之前的手风琴互斥状态。这个功能对菜单项超过100个的后台系统特别实用但实现时要注意和互斥逻辑的冲突——为了展示搜索结果而展开的分组不应该被手风琴互斥规则强制收起。第三记住用户的折叠偏好。手风琴的“全部收起”状态也有价值特别是侧边栏宽度本身不大时用户可能希望只看到顶级菜单标题点开才是完整内容。那么除了记住选中项还可以把“当前展开的某个分组”也存进localStorage刷新后直接恢复展开态而不是全收起。我的经验是如果用户上次正停留在“报表管理”刷新后直接打开“报表管理”所在的分组是最自然的体验如果用户上次没有选中任何项全收起状态刷新后也保持全收起。最后分享一个我在多次实战中积累的体会侧边栏组件虽然在视觉上占的地方不大但它几乎是用户每天使用系统时接触最多的界面元素。用户不会记得你的按钮圆角是几像素但一定会记得“侧边栏每次点开都要重新找入口”“刷新完就不知道自己在哪里”这种摩擦感。手风琴效果配合箭头指示和状态记忆本质上是把“导航路径”压缩到一个很小的空间里把用户的注意力留给他真正在做的业务而不是找路这件事本身。动手实现的时候多想一想不同状态下用户会看到什么、刷新后会回到哪里比单纯把动画写得丝滑重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑