Browse Source
Implement mobile-first responsive layout for the homepage per DESIGN.md. bolt-ui: - Add Drawer component (SSR-safe, keydown, theme styles) - Add useMediaQuery composable - Document both in bolt-ui README index page: - Sidebar switches between rail (desktop) and drawer (mobile) modes - Three-row mobile header with drawer trigger - Breakpoints aligned with DESIGN.md - Mobile-first masonry column defaults (no flash on mobile refresh) docs: - Add homepage mobile adaptation design spec - Add implementation plan and self-review notes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>as
25 changed files with 2108 additions and 107 deletions
File diff suppressed because it is too large
@ -0,0 +1,350 @@ |
|||
# 首页移动端适配设计 |
|||
|
|||
## 概述 |
|||
|
|||
首页当前在 `<640px` 断点下表现糟糕:左侧栏被压缩为顶部 260px 高的横条、header 突然改为纵向堆叠、瀑布流列数硬切。本设计在保持桌面端 100% 行为不变的前提下,给首页增加完整的移动端体验,并把可复用部分抽离到 `bolt-ui` 组件库。 |
|||
|
|||
## 范围 |
|||
|
|||
- 改造 `app/pages/index/index.vue` 的移动端布局 |
|||
- 改造 `app/components/index/LeftSidebar.vue` 支持 rail / drawer 双模式 |
|||
- 新增 `packages/bolt-ui/components/Drawer/` 通用抽屉组件 |
|||
- 新增 `packages/bolt-ui/composables/useMediaQuery.ts` 通用响应式监听 |
|||
|
|||
## 不在范围内 |
|||
|
|||
- admin / photo / portfolio / about / auth 等其他页面的适配(虽然 Drawer 组件是可复用的,但本任务不重做这些页面) |
|||
- 触屏手势(滑动关闭抽屉、双指缩放等) |
|||
- 离线 / PWA 改造 |
|||
- 性能优化(瀑布流懒加载逻辑、IntersectionObserver 行为保持不变) |
|||
|
|||
--- |
|||
|
|||
## 架构 |
|||
|
|||
### 改动文件 |
|||
|
|||
新增: |
|||
- `packages/bolt-ui/components/Drawer/index.ts` |
|||
- `packages/bolt-ui/components/Drawer/src/Drawer.vue` |
|||
- `packages/bolt-ui/components/Drawer/src/useDrawer.ts` |
|||
- `packages/bolt-ui/components/Drawer/style/css.ts` |
|||
- `packages/bolt-ui/components/Drawer/style/index.ts` |
|||
- `packages/bolt-ui/theme-chalk/src/components/drawer/_index.scss` |
|||
- `packages/bolt-ui/theme-chalk/src/components/drawer/drawer-left.scss` |
|||
- `packages/bolt-ui/theme-chalk/src/components/drawer/drawer-right.scss` |
|||
- `packages/bolt-ui/theme-chalk/src/components/drawer/mask.scss` |
|||
- `packages/bolt-ui/composables/useMediaQuery.ts` |
|||
|
|||
修改: |
|||
- `packages/bolt-ui/components/index.ts` — 注册 Drawer、useMediaQuery |
|||
- `packages/bolt-ui/README.md` — 追加 Drawer 章节 |
|||
- `app/components/index/LeftSidebar.vue` — 新增 `mode` / `drawerOpen` / `width` / `side` props,对应 emits |
|||
- `app/pages/index/index.vue` — 接入 useMediaQuery、BoDrawer、header 三行布局 |
|||
- `app/components/TopNav.vue` — 实际不需要改动(见 § 样式细节 / TopNav 协调) |
|||
|
|||
### 组件关系 |
|||
|
|||
``` |
|||
pages/index/index.vue |
|||
└─ IndexLeftSidebar |
|||
├─ mode === 'rail' → <aside class="left-sidebar"> (原状) |
|||
└─ mode === 'drawer' → <BoDrawer v-model:open> |
|||
└─ default slot: <aside class="left-sidebar"> (同上) |
|||
``` |
|||
|
|||
`mode` 由父组件根据 `useMediaQuery('(max-width: 767.98px)')` 决定。`drawerOpen` 仅在 drawer 模式有意义,rail 模式下忽略。 |
|||
|
|||
--- |
|||
|
|||
## bolt-ui/Drawer 设计 |
|||
|
|||
### API |
|||
|
|||
```ts |
|||
interface DrawerProps { |
|||
open: boolean // v-model:open |
|||
side?: 'left' | 'right' = 'left' |
|||
width?: string | number = '80%' // CSS length;数字视为 px |
|||
closeOnMask?: boolean = true |
|||
closeOnEsc?: boolean = true |
|||
showMask?: boolean = true |
|||
maskClass?: string |
|||
zIndex?: number = 200 // 高于 TopNav 的 100 |
|||
transitionDuration?: number = 280 // ms |
|||
teleportTo?: string = 'body' |
|||
} |
|||
|
|||
defineEmits<{ 'update:open': [v: boolean] }>() |
|||
defineSlots<{ default(): VNode }>() |
|||
``` |
|||
|
|||
### 行为 |
|||
|
|||
- 基于现有 `Mask` 组件渲染遮罩 |
|||
- 抽屉面板从 `side` 一侧滑入;CSS transition 监听 `transform` |
|||
- 打开时 `document.body.style.overflow = 'hidden'`,关闭时恢复 |
|||
- 监听 `keydown.esc` 关闭(仅当面板可见时) |
|||
- 使用 `v-show` 保留 DOM,关闭后不销毁子组件 |
|||
- Teleport 到 `body`,与父组件的 `overflow: hidden` / `transform` 解耦 |
|||
- SSR 兼容:`onMounted` 之前不渲染 Teleport |
|||
|
|||
### 主题变量 |
|||
|
|||
```scss |
|||
:root { |
|||
--bo-drawer-bg: var(--color-canvas); |
|||
--bo-drawer-shadow: 0 8px 32px rgba(20, 20, 19, 0.12); |
|||
--bo-drawer-z-index: 200; |
|||
--bo-drawer-transition: 280ms cubic-bezier(0.4, 0, 0.2, 1); |
|||
} |
|||
``` |
|||
|
|||
### A11y |
|||
|
|||
- 面板元素 `role="dialog"` `aria-modal="true"` `aria-label="侧边栏"` |
|||
- 打开时焦点移到面板内第一个可聚焦元素 |
|||
- 关闭时焦点返回触发器(通过 `:data-drawer-trigger` 引用 + `previousActiveElement` 记忆) |
|||
- Tab / Shift+Tab 在面板内循环(focus trap) |
|||
|
|||
### 与 Mask 的关系 |
|||
|
|||
`Mask` 组件已经存在,本设计复用其 props(`visible`、`onClick`、`zIndex`)。Drawer 不重新发明遮罩层,只把它包进 Teleport 内。 |
|||
|
|||
--- |
|||
|
|||
## LeftSidebar 模式切换 |
|||
|
|||
### 新增 Props |
|||
|
|||
```ts |
|||
interface LeftSidebarProps { |
|||
// 原有 |
|||
tools?: ToolItem[] |
|||
categories: CategoryNode[] |
|||
activeToolKey?: string |
|||
activeCategoryId?: string |
|||
|
|||
// 新增 |
|||
mode: 'rail' | 'drawer' |
|||
drawerOpen: boolean |
|||
width?: string = '300px' |
|||
side?: 'left' | 'right' = 'left' |
|||
} |
|||
``` |
|||
|
|||
### 新增 Emits |
|||
|
|||
```ts |
|||
defineEmits<{ |
|||
// 原有 |
|||
selectTool: [key: string] |
|||
selectCategory: [id: string] |
|||
addCategory: [parentId: string | null] |
|||
renameCategory: [id: string] |
|||
deleteCategory: [id: string] |
|||
moveCategory: [id: string] |
|||
changeCover: [id: string] |
|||
|
|||
// 新增 |
|||
'update:drawerOpen': [v: boolean] |
|||
}>() |
|||
``` |
|||
|
|||
### 模板分支 |
|||
|
|||
```vue |
|||
<template> |
|||
<aside v-if="mode === 'rail'" class="left-sidebar"> |
|||
<!-- 原 rail 内容 --> |
|||
</aside> |
|||
|
|||
<BoDrawer |
|||
v-else |
|||
:open="drawerOpen" |
|||
:side="side" |
|||
:width="width" |
|||
@update:open="emit('update:drawerOpen', $event)" |
|||
> |
|||
<aside class="left-sidebar" :data-drawer-trigger="triggerId"> |
|||
<!-- 同一份内容,分类展开集合 / 滚动位置在切换 mode 时由 v-show 保留 --> |
|||
</aside> |
|||
</BoDrawer> |
|||
</template> |
|||
``` |
|||
|
|||
### 状态保留 |
|||
|
|||
由于 `IndexLeftSidebar` 内部状态(`expandedIds` Set、`ctxVisible`、`tree-scroll` 滚动位置)全部在组件内,rail 与 drawer 共享同一份组件实例,因此切换 mode 时不会重置。仅在父组件销毁整个组件(如跳转到其他页)时才丢失。 |
|||
|
|||
### 上下文菜单 |
|||
|
|||
现有 `IndexContextMenu` 已用 Teleport 到 body 渲染,`ctxX/ctxY` 记录 `MouseEvent.clientX/Y`,drawer 面板内的右键事件天然兼容。 |
|||
|
|||
--- |
|||
|
|||
## 首页集成 |
|||
|
|||
### 响应式状态 |
|||
|
|||
```ts |
|||
const isMobile = useMediaQuery('(max-width: 767.98px)') |
|||
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail') |
|||
const drawerOpen = ref(false) |
|||
``` |
|||
|
|||
`isMobile` 在 SSR 阶段为 `false`(避免 hydration mismatch),`onMounted` 后由 `matchMedia` 真实驱动。`matchMedia.change` 事件触发响应式更新。 |
|||
|
|||
### Header 三行布局(< 768px) |
|||
|
|||
``` |
|||
┌─────────────────────────────────────────┐ |
|||
│ 分类 [⌘][⛯][+]│ ← eyebrow + 动作按钮(图标) |
|||
│ 标题 │ ← 标题(小字号) |
|||
│ [≡ 分类] 当前分类名 │ ← 抽屉触发 + 当前分类 |
|||
└─────────────────────────────────────────┘ |
|||
``` |
|||
|
|||
具体规则: |
|||
|
|||
- 第 1 行:`.header-eyebrow` 文本左对齐,`.header-actions` 3 个 action-btn 缩小为 32×32 圆按钮并隐藏 "新增" 文字标签(保留 `+` 图标) |
|||
- 第 2 行:`.header-title`,`font-size: clamp(22px, 7vw, 30px)` |
|||
- 第 3 行(新增):「≡ 分类」按钮(hairline 描边风格,圆角 md,44×44 touch target)+ 紧邻的 chip 显示当前分类名 |
|||
- chip:`.surface-card` 背景 + `.ink` 文字 + `.body` 字体 |
|||
- 点击「≡ 分类」→ `drawerOpen = true` |
|||
- drawer 内选中分类 → 自动 emit `update:drawerOpen(false)` 关闭 |
|||
|
|||
### 状态联动 |
|||
|
|||
- `selectCategory` 处理器内部:保留原 `activeCategoryId.value = id` 逻辑;然后 `if (drawerOpen.value) drawerOpen.value = false`。这样无论当前是 rail 还是 drawer 模式都安全,rail 模式下赋值是 no-op。 |
|||
- `selectTool` 不关闭抽屉(用户可能想连续切换工具) |
|||
|
|||
### 滚动 |
|||
|
|||
- 现有 `IntersectionObserver` + `sentinel` 触底加载逻辑不变 |
|||
- Drawer 打开时 `body` 滚动锁定由 BoDrawer 内部处理;瀑布流不会"穿透"滚动到底 |
|||
|
|||
--- |
|||
|
|||
## 样式细节 |
|||
|
|||
### Breakpoint 重写 |
|||
|
|||
`app/pages/index/index.vue` 移除现有 `@media (max-width: 900px)` 与 `(max-width: 640px)`,替换为: |
|||
|
|||
```scss |
|||
@media (max-width: 767.98px) { |
|||
// 移动端:侧栏通过 mode='drawer' 隐藏 rail 节点;header 三行;瀑布 1 列 |
|||
.home-layout { grid-template-columns: 1fr; } // 抽屉不占 grid 列 |
|||
.home-main { padding: 16px 16px 32px; } |
|||
.main-header { flex-direction: column; align-items: stretch; gap: 12px; } |
|||
.action-btn { width: 32px; height: 32px; padding: 0; } |
|||
.action-btn span { display: none; } // 隐藏"新增"文字 |
|||
.header-title { font-size: clamp(22px, 7vw, 30px); } |
|||
} |
|||
|
|||
@media (min-width: 768px) and (max-width: 1023.98px) { |
|||
// 平板:侧栏保持 rail 240px;瀑布 2 列 |
|||
.home-layout { grid-template-columns: 240px 1fr; } |
|||
.home-main { padding: 20px 16px 32px; } |
|||
} |
|||
|
|||
@media (min-width: 1024px) and (max-width: 1439.98px) { |
|||
// 桌面:现状 |
|||
} |
|||
|
|||
@media (min-width: 1440px) { |
|||
// 宽屏:现状 |
|||
} |
|||
``` |
|||
|
|||
### Masonry 列数(沿用 decideColumnCount,阈值微调) |
|||
|
|||
| 容器宽度 | 列数 | |
|||
|----------|------| |
|||
| `<520px` | 1 | |
|||
| `<900px` | 2 | |
|||
| `<1200px` | 3 | |
|||
| `<1600px` | 4 | |
|||
| `≥1600px` | 5 | |
|||
|
|||
### Touch target |
|||
|
|||
`<768px` 下所有可点击元素最小 40×40;分类抽屉触发按钮 44×44。 |
|||
|
|||
### TopNav 协调 |
|||
|
|||
`TopNav` 当前 `z-index: 100`,Drawer 默认 `z-index: 200`,Drawer 自然浮在 TopNav 之上,TopNav 汉堡菜单与 Drawer 互不干扰。无需调整 TopNav。 |
|||
|
|||
--- |
|||
|
|||
## useMediaQuery |
|||
|
|||
`packages/bolt-ui/composables/useMediaQuery.ts` |
|||
|
|||
```ts |
|||
export function useMediaQuery(query: string): Ref<boolean> { |
|||
const matches = ref(false) |
|||
let mql: MediaQueryList | null = null |
|||
|
|||
onMounted(() => { |
|||
mql = window.matchMedia(query) |
|||
matches.value = mql.matches |
|||
const handler = (e: MediaQueryListEvent) => { matches.value = e.matches } |
|||
mql.addEventListener('change', handler) |
|||
onBeforeUnmount(() => mql.removeEventListener('change', handler)) |
|||
}) |
|||
|
|||
return matches |
|||
} |
|||
``` |
|||
|
|||
SSR 安全(无 `window` 访问);组件卸载自动清理监听。 |
|||
|
|||
--- |
|||
|
|||
## 验证 |
|||
|
|||
### 手工测试矩阵 |
|||
|
|||
| 设备 | 视口 | 期望 | |
|||
|------|------|------| |
|||
| iPhone SE | 375×667 | 单列瀑布、header 三行、侧栏抽屉触发可见 | |
|||
| iPhone 14 Pro | 393×852 | 同上 + safe area 适配 | |
|||
| iPad Mini (portrait) | 768×1024 | 双列瀑布、rail 侧栏 240px | |
|||
| iPad Mini (landscape) | 1024×768 | 桌面布局回归 | |
|||
| iPad Pro 11" | 834×1194 | 平板布局 | |
|||
| Desktop | 1440×900 | 原状回归 | |
|||
| Wide | 1920×1080 | 原状回归 | |
|||
|
|||
### 检查清单 |
|||
|
|||
- [ ] 移动端侧栏不再以顶部横条形式出现 |
|||
- [ ] 抽屉可开可关:点击触发器、点击遮罩、按 Esc 都能关闭 |
|||
- [ ] 抽屉内选中分类后主视图内容切换、抽屉自动关闭 |
|||
- [ ] 滚动到底部正常加载更多 |
|||
- [ ] 抽屉打开时 body 不滚动、背景不"穿透" |
|||
- [ ] portrait ↔ landscape 旋转布局正确切换、无组件残留 |
|||
- [ ] 桌面端所有现有功能 100% 回归 |
|||
|
|||
### 单元测试(如时间允许) |
|||
|
|||
- `BoDrawer`:`v-model:open` 同步、Esc 关闭、遮罩点击关闭、body overflow 锁定/恢复 |
|||
- `useMediaQuery`:mock matchMedia,验证响应式更新 |
|||
|
|||
--- |
|||
|
|||
## 风险与权衡 |
|||
|
|||
- **新 bolt-ui 组件的回归风险**:Mask 已存在,Drawer 基于其构建;首次对外暴露的 A11y(focus trap、aria 属性)需手工 + 单元测试双覆盖 |
|||
- **SSR hydration**:useMediaQuery 在 SSR 返回 false,客户端首次 mount 后才更新;这意味着移动端用户首次打开页面会看到 0.1s 桌面布局再切换为 drawer。这是可接受的轻微闪烁 |
|||
- **左抽屉与右抽屉的扩展性**:当前只实现 left,但 side prop 已预留 right,admin 等页面未来使用右侧抽屉(设置/通知)时无需改动组件 |
|||
|
|||
--- |
|||
|
|||
## 后续工作(不在本次) |
|||
|
|||
- 其他页面(admin、photo、portfolio、about)适配移动端 |
|||
- 触屏手势(滑动关闭抽屉) |
|||
- 暗色模式(与 DESIGN.md 中的 surface-dark 配合) |
|||
- 抽屉内容懒加载(首次打开才渲染分类树) |
|||
@ -0,0 +1,83 @@ |
|||
# bolt-ui |
|||
|
|||
A Nuxt 4 component library for the homepage and admin UI. Built on Vue 3.5 with TypeScript and SCSS. |
|||
|
|||
## Auto-Registration |
|||
|
|||
When used via the `bolt-ui/nuxt` module (already wired in `nuxt.config.ts`), components and composables under this package are auto-registered globally: |
|||
|
|||
- Components at `components/<Name>/index.ts` are available as `<BoName>` (e.g. `components/Button/index.ts` → `<BoButton>`) |
|||
- Composables exporting `useXxx` functions under `hooks/`, `utils/`, or `locales/` are auto-imported (e.g. `hooks/useClickOutside/index.ts` exports `useClickOutside`) |
|||
- Theme styles are auto-injected on first use of each component |
|||
|
|||
## Components |
|||
|
|||
| Name | Description | |
|||
|------|-------------| |
|||
| `BoButton` | Standard button with type / size / loading / disabled variants | |
|||
| `BoConfigProvider` | Provides design tokens and locale to descendants | |
|||
| `BoContainer` | Centered max-width container with size variants | |
|||
| `BoDialog` | Modal dialog with Teleport + Mask + Transition | |
|||
| `BoDrawer` | Side-sliding panel (left/right) with mask, focus trap, and body scroll lock | |
|||
| `BoMask` | Full-screen mask overlay | |
|||
|
|||
## Drawer 抽屉 |
|||
|
|||
从屏幕边缘滑出的面板,常用于移动端侧栏、设置面板、通知中心等场景。 |
|||
|
|||
### 基础用法 |
|||
|
|||
```vue |
|||
<BoDrawer v-model:open="open" side="left" width="80%"> |
|||
<div class="my-panel">面板内容</div> |
|||
</BoDrawer> |
|||
``` |
|||
|
|||
### Props |
|||
|
|||
| 名称 | 类型 | 默认值 | 说明 | |
|||
|------|------|--------|------| |
|||
| `open` | `boolean` | — | 是否打开,配合 `v-model:open` 双向绑定 | |
|||
| `side` | `'left' \| 'right'` | `'left'` | 滑出方向 | |
|||
| `width` | `string \| number` | `'80%'` | 面板宽度;数字视为 px | |
|||
| `closeOnMask` | `boolean` | `true` | 点击遮罩是否关闭 | |
|||
| `closeOnEsc` | `boolean` | `true` | 按 Esc 是否关闭 | |
|||
| `showMask` | `boolean` | `true` | 是否显示遮罩 | |
|||
| `zIndex` | `number` | `200` | 面板 z-index | |
|||
| `transitionDuration` | `number` | `280` | 过渡时长 (ms) | |
|||
| `teleportTo` | `string` | `'body'` | Teleport 目标选择器 | |
|||
| `ariaLabel` | `string` | `'抽屉'` | ARIA 标签 | |
|||
|
|||
### Emits |
|||
|
|||
| 事件 | 参数 | 说明 | |
|||
|------|------|------| |
|||
| `update:open` | `(value: boolean)` | 由 `v-model:open` 监听 | |
|||
|
|||
### 行为 |
|||
|
|||
- 打开时锁定 `body` 滚动,关闭时恢复 |
|||
- 自动焦点管理:打开时聚焦面板内第一个可聚焦元素,关闭时返回触发器 |
|||
- 内置焦点陷阱(Tab / Shift+Tab 在面板内循环) |
|||
- Teleport 到 `body`,避免父级 `overflow: hidden` / `transform` 影响 |
|||
- SSR 安全:服务端不渲染 Teleport,客户端 `onMounted` 后挂载 |
|||
|
|||
## Composables |
|||
|
|||
### useMediaQuery |
|||
|
|||
```ts |
|||
const isMobile = useMediaQuery('(max-width: 767.98px)') |
|||
``` |
|||
|
|||
响应式地跟踪媒体查询匹配状态。SSR 期间返回 `false`,客户端 mount 后由 `matchMedia` 真实驱动,自动清理监听。 |
|||
|
|||
### useClickOutside |
|||
|
|||
```ts |
|||
useClickOutside(elementRef, (event) => { |
|||
// triggered when click is outside elementRef |
|||
}, { ignore: [someOtherRef] }) |
|||
``` |
|||
|
|||
监听元素外部的点击事件,可选地忽略某些元素。SSR 期间无操作,客户端 mount 后挂载。 |
|||
@ -0,0 +1,7 @@ |
|||
import _Drawer from './src/Drawer.vue' |
|||
import { withInstall } from 'bolt-ui/utils/vue/install' |
|||
|
|||
export type BoDrawer = InstanceType<typeof _Drawer> |
|||
|
|||
export const BoDrawer = withInstall(_Drawer) |
|||
export default BoDrawer |
|||
@ -0,0 +1,156 @@ |
|||
<template> |
|||
<Teleport :to="teleportTo" :disabled="!mounted"> |
|||
<Transition :name="maskTransition" appear> |
|||
<div |
|||
v-if="showMask && isOpen" |
|||
:class="e('mask')" |
|||
@click="onMaskClick" |
|||
/> |
|||
</Transition> |
|||
|
|||
<Transition :name="panelTransition" appear> |
|||
<div |
|||
v-show="isOpen" |
|||
ref="panelRef" |
|||
:class="panelClasses" |
|||
:style="panelStyle" |
|||
role="dialog" |
|||
aria-modal="true" |
|||
:aria-label="ariaLabel" |
|||
tabindex="-1" |
|||
@keydown="onKeydown" |
|||
> |
|||
<slot /> |
|||
</div> |
|||
</Transition> |
|||
</Teleport> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' |
|||
import { useNamespace } from 'bolt-ui/utils/hooks/use-namespace' |
|||
|
|||
const props = withDefaults( |
|||
defineProps<{ |
|||
open: boolean |
|||
side?: 'left' | 'right' |
|||
width?: string | number |
|||
closeOnMask?: boolean |
|||
closeOnEsc?: boolean |
|||
showMask?: boolean |
|||
zIndex?: number |
|||
transitionDuration?: number |
|||
teleportTo?: string |
|||
ariaLabel?: string |
|||
}>(), |
|||
{ |
|||
side: 'left', |
|||
width: '80%', |
|||
closeOnMask: true, |
|||
closeOnEsc: true, |
|||
showMask: true, |
|||
zIndex: 200, |
|||
transitionDuration: 280, |
|||
teleportTo: 'body', |
|||
ariaLabel: '抽屉', |
|||
}, |
|||
) |
|||
|
|||
const emit = defineEmits<{ |
|||
'update:open': [v: boolean] |
|||
}>() |
|||
|
|||
const { b, e, m } = useNamespace('drawer') |
|||
|
|||
const mounted = ref(false) |
|||
const panelRef = ref<HTMLElement | null>(null) |
|||
const isOpen = ref(props.open) |
|||
let prevOverflow = '' |
|||
let lastFocusedTrigger: HTMLElement | null = null |
|||
|
|||
const panelClasses = computed(() => [b(), m(props.side)]) |
|||
const panelStyle = computed(() => ({ |
|||
width: typeof props.width === 'number' ? `${props.width}px` : props.width, |
|||
zIndex: String(props.zIndex), |
|||
transitionDuration: `${props.transitionDuration}ms`, |
|||
})) |
|||
const panelTransition = computed(() => `bo-drawer-panel-${props.side}-fade`) |
|||
const maskTransition = 'bo-drawer-mask-fade' |
|||
|
|||
watch( |
|||
() => props.open, |
|||
async (next) => { |
|||
if (typeof window === 'undefined') return |
|||
isOpen.value = next |
|||
if (next) { |
|||
lastFocusedTrigger = (document.activeElement as HTMLElement | null) ?? null |
|||
prevOverflow = document.body.style.overflow |
|||
document.body.style.overflow = 'hidden' |
|||
await nextTick() |
|||
focusFirst() |
|||
} else { |
|||
document.body.style.overflow = prevOverflow |
|||
lastFocusedTrigger?.focus?.() |
|||
} |
|||
}, |
|||
{ immediate: true }, |
|||
) |
|||
|
|||
function onMaskClick() { |
|||
if (props.closeOnMask) emit('update:open', false) |
|||
} |
|||
|
|||
function onKeydown(e: KeyboardEvent) { |
|||
if (e.key === 'Escape' && props.closeOnEsc) { |
|||
emit('update:open', false) |
|||
e.stopPropagation() |
|||
} |
|||
if (e.key === 'Tab') trapFocus(e) |
|||
} |
|||
|
|||
function focusFirst() { |
|||
const root = panelRef.value |
|||
if (!root) return |
|||
const focusable = getFocusable(root) |
|||
if (focusable.length > 0) { |
|||
focusable[0]?.focus?.() |
|||
} else { |
|||
root.focus() |
|||
} |
|||
} |
|||
|
|||
function getFocusable(root: HTMLElement): HTMLElement[] { |
|||
const sel = 'a[href],button:not([disabled]),[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])' |
|||
return Array.from(root.querySelectorAll<HTMLElement>(sel)) |
|||
} |
|||
|
|||
function trapFocus(e: KeyboardEvent) { |
|||
const root = panelRef.value |
|||
if (!root) return |
|||
const focusable = getFocusable(root) |
|||
if (focusable.length === 0) { |
|||
e.preventDefault() |
|||
return |
|||
} |
|||
const first = focusable[0]! |
|||
const last = focusable[focusable.length - 1]! |
|||
const active = document.activeElement as HTMLElement | null |
|||
if (e.shiftKey && active === first) { |
|||
e.preventDefault() |
|||
last.focus() |
|||
} else if (!e.shiftKey && active === last) { |
|||
e.preventDefault() |
|||
first.focus() |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
mounted.value = true |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
document.body.style.overflow = prevOverflow |
|||
}) |
|||
|
|||
defineOptions({ name: 'BoDrawer' }) |
|||
</script> |
|||
@ -0,0 +1 @@ |
|||
import 'bolt-ui/theme-chalk/src/drawer.scss' |
|||
@ -0,0 +1 @@ |
|||
import 'bolt-ui/theme-chalk/src/drawer.scss' |
|||
@ -1 +1,2 @@ |
|||
export * from './useClickOutside' |
|||
export * from './useMediaQuery' |
|||
@ -0,0 +1,31 @@ |
|||
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue' |
|||
|
|||
/** |
|||
* 响应式地跟踪 CSS 媒体查询匹配状态。 |
|||
* |
|||
* SSR 安全:服务端渲染期间始终返回 `false`,避免 hydration mismatch; |
|||
* 客户端 `onMounted` 之后才订阅 `matchMedia`。 |
|||
* |
|||
* @param query - 合法 CSS media query 字符串 |
|||
* @returns Ref<boolean> - 当前是否匹配 |
|||
*/ |
|||
export function useMediaQuery(query: string): Ref<boolean> { |
|||
const matches = ref(false) |
|||
let mql: MediaQueryList | null = null |
|||
const handler = (e: MediaQueryListEvent) => { |
|||
matches.value = e.matches |
|||
} |
|||
|
|||
onMounted(() => { |
|||
if (typeof window === 'undefined' || !window.matchMedia) return |
|||
mql = window.matchMedia(query) |
|||
matches.value = mql.matches |
|||
mql.addEventListener('change', handler) |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
mql?.removeEventListener('change', handler) |
|||
}) |
|||
|
|||
return matches |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
@use "./anim/mask-fade.scss"; |
|||
@use "./anim/panel-left-fade.scss"; |
|||
@use "./anim/panel-right-fade.scss"; |
|||
@use "./panel.scss"; |
|||
@use "./panel-left.scss"; |
|||
@use "./panel-right.scss"; |
|||
@use "./mask.scss"; |
|||
@ -0,0 +1,3 @@ |
|||
$bo-drawer-bg: var(--color-canvas) !default; |
|||
$bo-drawer-shadow: 0 8px 32px rgba(20, 20, 19, 0.12) !default; |
|||
$bo-drawer-mask-bg: rgba(20, 20, 19, 0.32) !default; |
|||
@ -0,0 +1 @@ |
|||
$drawer-time: 0.28s !default; |
|||
@ -0,0 +1,11 @@ |
|||
@use "_var" as *; |
|||
|
|||
.bo-drawer-mask-fade-enter-active, |
|||
.bo-drawer-mask-fade-leave-active { |
|||
transition: opacity $drawer-time ease; |
|||
} |
|||
|
|||
.bo-drawer-mask-fade-enter-from, |
|||
.bo-drawer-mask-fade-leave-to { |
|||
opacity: 0; |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
@use "_var" as *; |
|||
|
|||
.bo-drawer-panel-left-fade-enter-active, |
|||
.bo-drawer-panel-left-fade-leave-active { |
|||
transition: transform $drawer-time cubic-bezier(0.4, 0, 0.2, 1); |
|||
} |
|||
|
|||
.bo-drawer-panel-left-fade-enter-from, |
|||
.bo-drawer-panel-left-fade-leave-to { |
|||
transform: translateX(-100%); |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
@use "_var" as *; |
|||
|
|||
.bo-drawer-panel-right-fade-enter-active, |
|||
.bo-drawer-panel-right-fade-leave-active { |
|||
transition: transform $drawer-time cubic-bezier(0.4, 0, 0.2, 1); |
|||
} |
|||
|
|||
.bo-drawer-panel-right-fade-enter-from, |
|||
.bo-drawer-panel-right-fade-leave-to { |
|||
transform: translateX(100%); |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
@use "./_var" as *; |
|||
|
|||
.bo-drawer__mask { |
|||
position: fixed; |
|||
inset: 0; |
|||
background: $bo-drawer-mask-bg; |
|||
z-index: 199; // 比 panel 低 1 |
|||
backdrop-filter: blur(2px); |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
.bo-drawer--left { |
|||
left: 0; |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
.bo-drawer--right { |
|||
right: 0; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
@use "./_var" as *; |
|||
|
|||
.bo-drawer { |
|||
position: fixed; |
|||
top: 0; |
|||
bottom: 0; |
|||
background: $bo-drawer-bg; |
|||
box-shadow: $bo-drawer-shadow; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
outline: none; |
|||
-webkit-overflow-scrolling: touch; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
@use "components/drawer/_index.scss"; |
|||
Loading…
Reference in new issue