feat: 优化页面样式
This commit is contained in:
@@ -231,3 +231,25 @@ describe('BoardToolbar 多选', () => {
|
|||||||
expect(exactButton(wrapper, '多选')).toBeUndefined()
|
expect(exactButton(wrapper, '多选')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 溢出容器', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归守卫:筛选组一旦变成滚动容器(overflow-x-auto 会让 overflow-y 也算 auto),
|
||||||
|
* 「标签」下拉面板就会被裁在 36px 高的行里 —— 点开等于没反应,右侧还多一条滚动条。
|
||||||
|
* 空间不够时应当换行(flex-wrap),而不是把内容推到滚动区外。
|
||||||
|
*/
|
||||||
|
it('筛选组不是滚动容器,标签面板不会被裁', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const chip = buttonByText(wrapper, '今日到期')
|
||||||
|
const group = chip?.element.parentElement
|
||||||
|
|
||||||
|
expect(group).toBeTruthy()
|
||||||
|
const classes = group?.className ?? ''
|
||||||
|
expect(classes).toContain('flex-wrap')
|
||||||
|
expect(classes).not.toMatch(/overflow-(x-|y-)?(auto|scroll|hidden)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+177
-91
@@ -47,12 +47,17 @@ defineExpose({ focusSearch })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="flex flex-col gap-2.5">
|
<header class="flex flex-col gap-2">
|
||||||
|
<!--
|
||||||
|
单行三段:搜索 │ 筛选 │ 操作。
|
||||||
|
早先是两行(第一行筛选、第二行统计 + 批量操作),「今日到期 / 逾期」
|
||||||
|
又被 ml-auto 顶到最右端,与搜索之间空出半个屏幕 —— 同一件事被切到两个空间。
|
||||||
|
-->
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
<div class="relative min-w-44 flex-1 max-sm:order-1 max-sm:min-w-0 sm:max-w-[260px] xl:max-w-[320px]">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400 dark:text-zinc-500"
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-500 dark:text-zinc-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@@ -63,136 +68,217 @@ defineExpose({ focusSearch })
|
|||||||
ref="searchRef"
|
ref="searchRef"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
type="search"
|
type="search"
|
||||||
class="h-9 w-full rounded-md border border-slate-200 bg-white pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500 dark:focus:border-sky-600 dark:focus:ring-sky-950"
|
class="h-9 w-full rounded-lg border border-slate-200 bg-white pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-500 focus:border-sky-400 focus:ring-2 focus:ring-sky-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-400 dark:focus:border-sky-600 dark:focus:ring-sky-950"
|
||||||
placeholder="搜索标题或描述"
|
placeholder="搜索标题或描述"
|
||||||
aria-label="搜索任务"
|
aria-label="搜索任务"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!--
|
||||||
class="flex h-9 items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5 dark:border-zinc-700 dark:bg-zinc-900"
|
筛选:快捷截止日期 + 优先级 + 标签,同一个语义组。
|
||||||
role="group"
|
这里**不能用 overflow-x-auto**:浏览器会把 overflow-y 一并算成 auto,
|
||||||
aria-label="按优先级筛选"
|
于是这一条 36px 高的行变成滚动容器,标签下拉面板(~175px 高)被裁在盒外
|
||||||
>
|
—— 表现就是「点了没反应」,右侧还多出一条滚动条。空间不够时改为换行。
|
||||||
<button
|
-->
|
||||||
v-for="value in PRIORITY_ORDER"
|
<div class="flex min-w-0 flex-wrap items-center gap-1.5 max-sm:order-3 max-sm:w-full">
|
||||||
:key="value"
|
|
||||||
type="button"
|
|
||||||
class="flex h-full items-center rounded px-2.5 text-xs transition"
|
|
||||||
:class="
|
|
||||||
priorities.includes(value)
|
|
||||||
? 'bg-slate-900 font-medium text-white dark:bg-zinc-100 dark:text-zinc-900'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
|
||||||
"
|
|
||||||
:aria-pressed="priorities.includes(value)"
|
|
||||||
@click="filters.togglePriority(value)"
|
|
||||||
>
|
|
||||||
{{ PRIORITY_LABELS[value] }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TagFilter
|
|
||||||
:tags="store.allTags"
|
|
||||||
:selected="tags"
|
|
||||||
@toggle="filters.toggleTag($event)"
|
|
||||||
@clear="filters.clearTags()"
|
|
||||||
@manage="emit('manage-tags')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
|
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
|
||||||
:class="
|
:class="
|
||||||
due === 'today'
|
due === 'today'
|
||||||
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
|
? 'border-amber-300 bg-amber-50 font-medium text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="due === 'today'"
|
:aria-pressed="due === 'today'"
|
||||||
@click="filters.toggleDue('today')"
|
@click="filters.toggleDue('today')"
|
||||||
>
|
>
|
||||||
今日到期
|
今日到期
|
||||||
<span class="font-semibold">{{ dueTodayCount }}</span>
|
<span
|
||||||
|
class="font-semibold tabular-nums"
|
||||||
|
:class="due === 'today' || dueTodayCount === 0 ? '' : 'text-amber-700 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ dueTodayCount }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
|
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
|
||||||
:class="
|
:class="
|
||||||
due === 'overdue'
|
due === 'overdue'
|
||||||
? 'border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
? 'border-rose-300 bg-rose-50 font-medium text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
||||||
: overdueCount > 0
|
: overdueCount > 0
|
||||||
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-rose-400 dark:hover:border-rose-800 dark:hover:bg-rose-950'
|
? 'border-slate-200 bg-white text-slate-600 hover:border-rose-200 hover:bg-rose-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-rose-800 dark:hover:bg-rose-950'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="due === 'overdue'"
|
:aria-pressed="due === 'overdue'"
|
||||||
@click="filters.toggleDue('overdue')"
|
@click="filters.toggleDue('overdue')"
|
||||||
>
|
>
|
||||||
逾期
|
逾期
|
||||||
<span class="font-semibold">{{ overdueCount }}</span>
|
<span
|
||||||
|
class="font-semibold tabular-nums"
|
||||||
|
:class="due === 'overdue' || overdueCount === 0 ? '' : 'text-rose-600 dark:text-rose-400'"
|
||||||
|
>
|
||||||
|
{{ overdueCount }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-zinc-400">
|
<span class="hidden h-5 w-px shrink-0 bg-slate-200 sm:block dark:bg-zinc-700" aria-hidden="true" />
|
||||||
<span>共 {{ store.activeTasks.length }} 个任务</span>
|
|
||||||
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div
|
||||||
<button
|
class="flex h-9 shrink-0 items-center gap-0.5 rounded-lg border border-slate-200 bg-white p-0.5 dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
type="button"
|
role="group"
|
||||||
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
aria-label="按优先级筛选"
|
||||||
@click="emit('batch-add')"
|
|
||||||
>
|
>
|
||||||
批量添加
|
<button
|
||||||
</button>
|
v-for="value in PRIORITY_ORDER"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
class="flex h-full items-center rounded-md px-2.5 text-[13px] whitespace-nowrap transition"
|
||||||
|
:class="
|
||||||
|
priorities.includes(value)
|
||||||
|
? 'bg-sky-50 font-semibold text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950 dark:text-sky-300 dark:ring-sky-800'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="priorities.includes(value)"
|
||||||
|
@click="filters.togglePriority(value)"
|
||||||
|
>
|
||||||
|
{{ PRIORITY_LABELS[value] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<TagFilter
|
||||||
type="button"
|
:tags="store.allTags"
|
||||||
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
|
:selected="tags"
|
||||||
:class="
|
@toggle="filters.toggleTag($event)"
|
||||||
selecting
|
@clear="filters.clearTags()"
|
||||||
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
@manage="emit('manage-tags')"
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
/>
|
||||||
"
|
|
||||||
:aria-pressed="selecting === true"
|
|
||||||
@click="emit('toggle-select')"
|
|
||||||
>
|
|
||||||
{{ selecting ? '退出多选' : '多选' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="archivableCount > 0"
|
|
||||||
type="button"
|
|
||||||
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
|
||||||
@click="emit('archive')"
|
|
||||||
>
|
|
||||||
归档 {{ archivableCount }} 个已完成
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
|
|
||||||
:class="
|
|
||||||
includeArchived
|
|
||||||
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
|
||||||
"
|
|
||||||
:aria-pressed="includeArchived"
|
|
||||||
@click="filters.toggleArchived()"
|
|
||||||
>
|
|
||||||
显示归档
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<!-- 筛选生效时才出现:常态下工具栏不摆一个点了没事干的按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="active"
|
v-if="active"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-8 items-center rounded-md px-2.5 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200"
|
class="flex h-9 shrink-0 items-center gap-1 rounded-lg px-2 text-[13px] whitespace-nowrap text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||||
@click="filters.clear()"
|
@click="filters.clear()"
|
||||||
>
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.7"
|
||||||
|
stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="m5.5 5.5 9 9M14.5 5.5l-9 9" />
|
||||||
|
</svg>
|
||||||
清空筛选
|
清空筛选
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作:主操作(批量添加)+ 两个视图开关,全部平铺,不再收进菜单 -->
|
||||||
|
<div class="ml-auto flex shrink-0 items-center gap-1.5 max-sm:order-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 items-center gap-1.5 rounded-lg bg-sky-700 px-3 text-[13px] font-medium whitespace-nowrap text-white transition hover:bg-sky-800 active:scale-[0.98] max-lg:w-9 max-lg:justify-center max-lg:px-0 dark:bg-sky-500 dark:text-slate-950 dark:hover:bg-sky-400"
|
||||||
|
aria-label="批量添加"
|
||||||
|
title="批量添加"
|
||||||
|
@click="emit('batch-add')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3.5 5.5h9M3.5 10h9M3.5 14.5h5" />
|
||||||
|
<path d="M15 11.5v5M12.5 14h5" />
|
||||||
|
</svg>
|
||||||
|
<span class="max-lg:hidden">批量添加</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition max-lg:w-9 max-lg:justify-center max-lg:px-0"
|
||||||
|
:class="
|
||||||
|
selecting
|
||||||
|
? 'border-sky-300 bg-sky-50 font-medium text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="selecting === true"
|
||||||
|
:aria-label="selecting ? '退出多选' : '多选'"
|
||||||
|
:title="selecting ? '退出多选' : '多选'"
|
||||||
|
@click="emit('toggle-select')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3.5" y="3.5" width="13" height="13" rx="3" />
|
||||||
|
<path d="m7.2 10.2 2 2 3.6-4.1" stroke-width="1.7" />
|
||||||
|
</svg>
|
||||||
|
<span class="max-lg:hidden">{{ selecting ? '退出多选' : '多选' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition max-lg:w-9 max-lg:justify-center max-lg:px-0"
|
||||||
|
:class="
|
||||||
|
includeArchived
|
||||||
|
? 'border-sky-300 bg-sky-50 font-medium text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="includeArchived"
|
||||||
|
aria-label="显示归档"
|
||||||
|
title="显示归档"
|
||||||
|
@click="filters.toggleArchived()"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4.5" width="14" height="3.5" rx="1" />
|
||||||
|
<path d="M4.6 8v6.4c0 .9.7 1.6 1.6 1.6h7.6c.9 0 1.6-.7 1.6-1.6V8" />
|
||||||
|
<path d="M8.4 11h3.2" />
|
||||||
|
</svg>
|
||||||
|
<span class="max-lg:hidden">显示归档</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 dark:text-zinc-400">
|
||||||
|
<span>共 {{ store.activeTasks.length }} 个任务</span>
|
||||||
|
<span v-if="hiddenNote" class="cursor-help" :title="`${hiddenNote};此时列内排序会暂停`">
|
||||||
|
{{ hiddenNote }}
|
||||||
|
</span>
|
||||||
|
<!--
|
||||||
|
归档是「有活了才出现」的清理动作,不该常驻占一个按钮位;
|
||||||
|
挪到这一行后只有真的攒够可归档任务时才露头。
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
v-if="archivableCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="rounded text-slate-600 underline decoration-slate-300 underline-offset-2 transition hover:text-slate-900 hover:decoration-slate-500 dark:text-zinc-400 dark:decoration-zinc-600 dark:hover:text-zinc-200"
|
||||||
|
@click="emit('archive')"
|
||||||
|
>
|
||||||
|
归档 {{ archivableCount }} 个已完成
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import TagFilter from './TagFilter.vue'
|
import TagFilter from './TagFilter.vue'
|
||||||
@@ -137,3 +138,35 @@ describe('TagFilter 空状态', () => {
|
|||||||
expect(wrapper.text()).toContain('还没有任何标签')
|
expect(wrapper.text()).toContain('还没有任何标签')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TagFilter 面板定位', () => {
|
||||||
|
it('靠近视口右缘时把面板往左推回视口内', async () => {
|
||||||
|
const wrapper = mountFilter()
|
||||||
|
const root = wrapper.element as HTMLElement
|
||||||
|
// 模拟「标签」被挤到窗口右侧 60px 处:224px 的面板必然伸出窗口
|
||||||
|
root.getBoundingClientRect = () =>
|
||||||
|
({ left: window.innerWidth - 60, top: 0, width: 60, height: 36 }) as DOMRect
|
||||||
|
|
||||||
|
await triggerButton(wrapper).trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const panel = wrapper.find('div.absolute').element as HTMLElement
|
||||||
|
const shift = Number.parseFloat(panel.style.marginLeft || '0')
|
||||||
|
expect(shift).toBeLessThan(0)
|
||||||
|
// 右缘不能越过视口
|
||||||
|
expect(window.innerWidth - 60 + shift + 224).toBeLessThanOrEqual(window.innerWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('左侧空间充足时不偏移', async () => {
|
||||||
|
const wrapper = mountFilter()
|
||||||
|
const root = wrapper.element as HTMLElement
|
||||||
|
root.getBoundingClientRect = () =>
|
||||||
|
({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect
|
||||||
|
|
||||||
|
await triggerButton(wrapper).trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const panel = wrapper.find('div.absolute').element as HTMLElement
|
||||||
|
expect(Number.parseFloat(panel.style.marginLeft || '0')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。
|
* 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
|
defineProps<{ tags: readonly string[]; selected: readonly string[] }>()
|
||||||
|
|
||||||
@@ -15,6 +15,34 @@ const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>()
|
|||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板固定 224px(w-56)且默认左对齐触发按钮。
|
||||||
|
* 当「标签」被挤到靠近视口右缘时(窄屏工具栏、筛选换行的情况),
|
||||||
|
* 面板会伸出窗口被裁掉 —— 这里把面板往左推回视口内。
|
||||||
|
* 用 margin-left 而不是 transform:面板自身的 pop-in 动画也在动 transform,两者会打架。
|
||||||
|
*/
|
||||||
|
const PANEL_WIDTH = 224
|
||||||
|
const EDGE = 8
|
||||||
|
const shiftX = ref(0)
|
||||||
|
|
||||||
|
function reposition(): void {
|
||||||
|
const root = rootRef.value
|
||||||
|
if (!root) return
|
||||||
|
const rect = root.getBoundingClientRect()
|
||||||
|
const overflowRight = rect.left + PANEL_WIDTH - (window.innerWidth - EDGE)
|
||||||
|
shiftX.value = overflowRight > 0 ? -Math.min(overflowRight, Math.max(rect.left - EDGE, 0)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(): void {
|
||||||
|
if (open.value) reposition()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, async (value) => {
|
||||||
|
if (!value) return
|
||||||
|
await nextTick()
|
||||||
|
reposition()
|
||||||
|
})
|
||||||
|
|
||||||
function onDocumentClick(event: MouseEvent): void {
|
function onDocumentClick(event: MouseEvent): void {
|
||||||
if (!open.value) return
|
if (!open.value) return
|
||||||
const root = rootRef.value
|
const root = rootRef.value
|
||||||
@@ -38,11 +66,13 @@ function openManager(): void {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onDocumentClick)
|
document.removeEventListener('click', onDocumentClick)
|
||||||
document.removeEventListener('keydown', onDocumentKeydown, true)
|
document.removeEventListener('keydown', onDocumentKeydown, true)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -50,10 +80,10 @@ onBeforeUnmount(() => {
|
|||||||
<div ref="rootRef" class="relative">
|
<div ref="rootRef" class="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs transition"
|
class="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] whitespace-nowrap transition"
|
||||||
:class="
|
:class="
|
||||||
selected.length > 0
|
selected.length > 0
|
||||||
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
? 'border-sky-300 bg-sky-50 font-medium text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
"
|
"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
@@ -63,7 +93,7 @@ onBeforeUnmount(() => {
|
|||||||
标签
|
标签
|
||||||
<span
|
<span
|
||||||
v-if="selected.length > 0"
|
v-if="selected.length > 0"
|
||||||
class="rounded-full bg-sky-600 px-1.5 text-[11px] leading-4 font-medium text-white dark:bg-sky-500"
|
class="rounded-full bg-sky-700 px-1.5 text-[11px] leading-4 font-medium text-white tabular-nums dark:bg-sky-400 dark:text-sky-950"
|
||||||
>
|
>
|
||||||
{{ selected.length }}
|
{{ selected.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -87,8 +117,9 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="open"
|
v-if="open"
|
||||||
class="absolute top-full left-0 z-20 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
|
class="absolute top-full left-0 z-20 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
|
:style="{ marginLeft: `${shiftX}px` }"
|
||||||
>
|
>
|
||||||
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400 dark:text-zinc-500">
|
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-500 dark:text-zinc-400">
|
||||||
还没有任何标签
|
还没有任何标签
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user