@thespielplatz/tsp-tools-theme 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +144 -0
- package/README.md +148 -44
- package/assets/css/theme.css +86 -6
- package/components/TspBanner.vue +97 -0
- package/components/TspBrandTile.vue +46 -0
- package/components/TspCodeBlock.vue +68 -0
- package/components/TspContainer.vue +10 -2
- package/components/TspCopyButton.vue +40 -0
- package/components/TspCopyField.vue +102 -0
- package/components/TspFooterRow.vue +56 -0
- package/components/TspLanguageToggle.vue +75 -0
- package/components/TspNavBadge.vue +36 -0
- package/components/TspNavGroup.vue +58 -0
- package/components/TspNavItem.vue +68 -12
- package/components/TspNavLabel.vue +56 -0
- package/components/TspNavSection.vue +33 -0
- package/components/TspNavSubItem.vue +35 -0
- package/components/TspPageTabs.vue +46 -0
- package/components/TspReleaseNotes.vue +143 -0
- package/components/TspSectionCard.vue +52 -0
- package/components/TspSidebar.vue +96 -3
- package/components/TspSidebarFooter.vue +332 -37
- package/components/TspSiteFooter.vue +47 -16
- package/components/TspSiteHeader.vue +18 -2
- package/components/TspThemeToggle.vue +24 -5
- package/components/TspToolOf.vue +42 -0
- package/components/TspTopBar.vue +69 -0
- package/components/TspUserCard.vue +127 -0
- package/components/TspVersionBadge.vue +49 -0
- package/components/TspWordmark.vue +36 -4
- package/composables/useTspCopy.ts +72 -0
- package/composables/useTspNavDrawer.ts +14 -0
- package/composables/useTspReveal.ts +18 -0
- package/docs/api.md +394 -0
- package/docs/design-system.md +297 -0
- package/nuxt.config.ts +11 -0
- package/package.json +24 -13
- package/page-meta.d.ts +11 -0
- package/plugins/01.tspColorMode.ts +12 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A break in the nav: a rule, optionally followed by a small label.
|
|
3
|
+
|
|
4
|
+
<TspNavSection label="External" /> rule + label
|
|
5
|
+
<TspNavSection /> bare divider
|
|
6
|
+
|
|
7
|
+
The label deliberately shares the sidebar footer's `User` treatment — 10px,
|
|
8
|
+
uppercase, muted — so a section header reads the same wherever it appears
|
|
9
|
+
(spec 04).
|
|
10
|
+
-->
|
|
11
|
+
<template>
|
|
12
|
+
<div
|
|
13
|
+
data-testid="tsp-nav-section"
|
|
14
|
+
:data-bare="!label"
|
|
15
|
+
class="mt-3 shrink-0"
|
|
16
|
+
>
|
|
17
|
+
<hr class="border-default">
|
|
18
|
+
<p
|
|
19
|
+
v-if="label"
|
|
20
|
+
data-testid="tsp-nav-section-label"
|
|
21
|
+
class="px-3 mt-3 mb-1 text-[10px] uppercase tracking-wide text-muted"
|
|
22
|
+
>
|
|
23
|
+
{{ label }}
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
defineProps<{
|
|
30
|
+
/** Omit for a bare divider. */
|
|
31
|
+
label?: string
|
|
32
|
+
}>()
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A child row inside a TspNavGroup. Carries its own icon, in its own indented
|
|
3
|
+
column — the level is already carried by the indent, the spine and the muting,
|
|
4
|
+
so the icon does not have to do that work too.
|
|
5
|
+
|
|
6
|
+
Muted at rest against the parent's white (spec 04, reading 2), amber when it
|
|
7
|
+
is the current route — amber means "you are here" at every level. The icon
|
|
8
|
+
inherits the row colour rather than setting its own, so it follows through
|
|
9
|
+
rest, hover and active without three rules. Children are the narrowest rows in
|
|
10
|
+
the sidebar, so they get the same hover reveal as their parents.
|
|
11
|
+
-->
|
|
12
|
+
<template>
|
|
13
|
+
<NuxtLink
|
|
14
|
+
:to="to"
|
|
15
|
+
data-testid="tsp-nav-sub-item"
|
|
16
|
+
class="group relative flex shrink-0 items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-muted hover:text-highlighted hover:bg-default"
|
|
17
|
+
active-class="tsp-text-brand"
|
|
18
|
+
>
|
|
19
|
+
<UIcon
|
|
20
|
+
v-if="icon"
|
|
21
|
+
:name="icon"
|
|
22
|
+
class="text-lg"
|
|
23
|
+
/>
|
|
24
|
+
<TspNavLabel><slot /></TspNavLabel>
|
|
25
|
+
</NuxtLink>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
defineProps<{
|
|
30
|
+
/** Internal route. A child row is always internal. */
|
|
31
|
+
to: string
|
|
32
|
+
/** Tabler icon name, e.g. `i-tabler-list-details`. */
|
|
33
|
+
icon?: string
|
|
34
|
+
}>()
|
|
35
|
+
</script>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Tabs across the top of an area, above the page title — the shape trips uses
|
|
3
|
+
for Settings / MCP Server.
|
|
4
|
+
|
|
5
|
+
<TspPageTabs :items="[
|
|
6
|
+
{ label: 'Settings', to: '/settings', icon: 'i-tabler-adjustments' },
|
|
7
|
+
{ label: 'MCP Server', to: '/settings/mcp', icon: 'i-tabler-plug' },
|
|
8
|
+
]" />
|
|
9
|
+
|
|
10
|
+
ROUTES, not local state: these are pages, so each tab is a link and the
|
|
11
|
+
browser's back button works. For switching views WITHIN one page, use plain
|
|
12
|
+
buttons — see /content.
|
|
13
|
+
|
|
14
|
+
Active is amber TEXT plus an underline, using `.tsp-text-brand` rather than
|
|
15
|
+
`text-primary`, so it steps down to amber-600 on light where #fbad18 fails as
|
|
16
|
+
text. Same rule as the active nav item (ADR 018).
|
|
17
|
+
-->
|
|
18
|
+
<template>
|
|
19
|
+
<nav
|
|
20
|
+
data-testid="tsp-page-tabs"
|
|
21
|
+
class="flex gap-6 border-b border-default mb-8 overflow-x-auto tsp-scroll-quiet"
|
|
22
|
+
>
|
|
23
|
+
<NuxtLink
|
|
24
|
+
v-for="item in items"
|
|
25
|
+
:key="item.to"
|
|
26
|
+
:to="item.to"
|
|
27
|
+
data-testid="tsp-page-tab"
|
|
28
|
+
class="-mb-px flex shrink-0 items-center gap-1.5 border-b-2 border-transparent pb-2.5 text-sm font-bold text-muted transition-colors hover:text-highlighted"
|
|
29
|
+
active-class="!border-current tsp-text-brand"
|
|
30
|
+
>
|
|
31
|
+
<UIcon
|
|
32
|
+
v-if="item.icon"
|
|
33
|
+
:name="item.icon"
|
|
34
|
+
class="text-base"
|
|
35
|
+
/>
|
|
36
|
+
{{ item.label }}
|
|
37
|
+
</NuxtLink>
|
|
38
|
+
</nav>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
defineProps<{
|
|
43
|
+
/** One entry per view: `{ label, to, icon? }`. `to` is a ROUTE, so the back button works. */
|
|
44
|
+
items: { label: string, to: string, icon?: string }[]
|
|
45
|
+
}>()
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Release notes for one or more versions — the overview the tsp-release skill
|
|
3
|
+
writes, rendered.
|
|
4
|
+
|
|
5
|
+
The shape mirrors that skill exactly, so an app passes its RELEASES array
|
|
6
|
+
straight in rather than mapping it:
|
|
7
|
+
|
|
8
|
+
{ version, date, showBanner?, showPopup?, important?: Item[], other?: Item[] }
|
|
9
|
+
Item = string | { text, compare?: { before, after }, beforeImage?, afterImage? }
|
|
10
|
+
|
|
11
|
+
`important` and `other` are separated because they are read differently: the
|
|
12
|
+
first is what made the release worth announcing, the second is what a curious
|
|
13
|
+
reader wants after that. Flattening them into one list loses the editorial
|
|
14
|
+
judgement the skill asked a human to make.
|
|
15
|
+
|
|
16
|
+
This renders the SAME content in the page and in the modal. Two renderings
|
|
17
|
+
would drift, and the drift would be invisible until someone compared them.
|
|
18
|
+
-->
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex flex-col gap-8">
|
|
21
|
+
<article
|
|
22
|
+
v-for="entry in releases"
|
|
23
|
+
:key="entry.version"
|
|
24
|
+
data-testid="tsp-release-entry"
|
|
25
|
+
>
|
|
26
|
+
<header class="flex items-baseline gap-3">
|
|
27
|
+
<h3 class="text-base font-bold text-highlighted">
|
|
28
|
+
{{ entry.version }}
|
|
29
|
+
</h3>
|
|
30
|
+
<time
|
|
31
|
+
v-if="entry.date"
|
|
32
|
+
:datetime="entry.date"
|
|
33
|
+
class="text-xs text-dimmed"
|
|
34
|
+
>{{ entry.date }}</time>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<template
|
|
38
|
+
v-for="group in groups"
|
|
39
|
+
:key="group.key"
|
|
40
|
+
>
|
|
41
|
+
<div v-if="entry[group.key]?.length">
|
|
42
|
+
<p class="mt-4 mb-2 text-xs font-bold uppercase tracking-wide text-muted">
|
|
43
|
+
{{ group.key === 'important' ? importantLabel : otherLabel }}
|
|
44
|
+
</p>
|
|
45
|
+
<ul class="flex flex-col gap-3">
|
|
46
|
+
<li
|
|
47
|
+
v-for="(item, i) in entry[group.key]"
|
|
48
|
+
:key="i"
|
|
49
|
+
data-testid="tsp-release-item"
|
|
50
|
+
class="flex gap-2.5 text-sm text-muted"
|
|
51
|
+
>
|
|
52
|
+
<UIcon
|
|
53
|
+
:name="group.icon"
|
|
54
|
+
class="mt-0.5 shrink-0 text-base"
|
|
55
|
+
:class="group.key === 'important' ? 'tsp-text-brand' : 'text-dimmed'"
|
|
56
|
+
/>
|
|
57
|
+
<div class="min-w-0">
|
|
58
|
+
<p>{{ text(item) }}</p>
|
|
59
|
+
|
|
60
|
+
<!-- before/after, as the skill's `compare` captions -->
|
|
61
|
+
<div
|
|
62
|
+
v-if="compare(item)"
|
|
63
|
+
data-testid="tsp-release-compare"
|
|
64
|
+
class="mt-2 grid gap-2 sm:grid-cols-2"
|
|
65
|
+
>
|
|
66
|
+
<div
|
|
67
|
+
v-for="side in (['before', 'after'] as const)"
|
|
68
|
+
:key="side"
|
|
69
|
+
class="rounded-md border border-default p-3"
|
|
70
|
+
>
|
|
71
|
+
<p
|
|
72
|
+
class="mb-1 text-[10px] font-bold uppercase tracking-wide"
|
|
73
|
+
:class="side === 'after' ? 'tsp-text-brand' : 'text-dimmed'"
|
|
74
|
+
>
|
|
75
|
+
{{ side === 'before' ? beforeLabel : afterLabel }}
|
|
76
|
+
</p>
|
|
77
|
+
<img
|
|
78
|
+
v-if="image(item, side)"
|
|
79
|
+
:src="image(item, side)"
|
|
80
|
+
:alt="`${side}: ${text(item)}`"
|
|
81
|
+
class="mb-2 w-full rounded-sm border border-default"
|
|
82
|
+
>
|
|
83
|
+
<p class="text-xs text-dimmed">
|
|
84
|
+
{{ compare(item)?.[side] }}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</li>
|
|
90
|
+
</ul>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
93
|
+
</article>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
export interface TspReleaseItemObject {
|
|
99
|
+
text: string
|
|
100
|
+
compare?: { before: string, after: string }
|
|
101
|
+
beforeImage?: string
|
|
102
|
+
afterImage?: string
|
|
103
|
+
}
|
|
104
|
+
export type TspReleaseItem = string | TspReleaseItemObject
|
|
105
|
+
export interface TspRelease {
|
|
106
|
+
version: string
|
|
107
|
+
date?: string
|
|
108
|
+
showBanner?: boolean
|
|
109
|
+
showPopup?: boolean
|
|
110
|
+
important?: TspReleaseItem[]
|
|
111
|
+
other?: TspReleaseItem[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
withDefaults(defineProps<{
|
|
115
|
+
/** Newest first. Mirrors the `tsp-release` skill's RELEASES shape, so a service can pass its
|
|
116
|
+
* own array straight through.
|
|
117
|
+
*/
|
|
118
|
+
releases: TspRelease[]
|
|
119
|
+
/** Heading over the `important` items. */
|
|
120
|
+
importantLabel?: string
|
|
121
|
+
/** Heading over the `other` items. */
|
|
122
|
+
otherLabel?: string
|
|
123
|
+
/** Caption on the left side of a before/after pair. */
|
|
124
|
+
beforeLabel?: string
|
|
125
|
+
/** Caption on its right side. */
|
|
126
|
+
afterLabel?: string
|
|
127
|
+
}>(), {
|
|
128
|
+
importantLabel: 'Highlights',
|
|
129
|
+
otherLabel: 'Also in this release',
|
|
130
|
+
beforeLabel: 'Before',
|
|
131
|
+
afterLabel: 'After',
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const groups = [
|
|
135
|
+
{ key: 'important' as const, icon: 'i-tabler-sparkles' },
|
|
136
|
+
{ key: 'other' as const, icon: 'i-tabler-point' },
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
const text = (i: TspReleaseItem) => (typeof i === 'string' ? i : i.text)
|
|
140
|
+
const compare = (i: TspReleaseItem) => (typeof i === 'string' ? undefined : i.compare)
|
|
141
|
+
const image = (i: TspReleaseItem, side: 'before' | 'after') =>
|
|
142
|
+
(typeof i === 'string' ? undefined : side === 'before' ? i.beforeImage : i.afterImage)
|
|
143
|
+
</script>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A titled content card: icon + title in a header row, content below.
|
|
3
|
+
|
|
4
|
+
<TspSectionCard icon="i-tabler-server" title="MCP Server"> … </TspSectionCard>
|
|
5
|
+
|
|
6
|
+
This is the shape trips uses on its settings pages and it generalises: an
|
|
7
|
+
icon and a short title make a card say what it is without a heading competing
|
|
8
|
+
with the page title above it.
|
|
9
|
+
|
|
10
|
+
Sits on `bg-elevated` — the raised CONTENT surface, distinct from the shell's
|
|
11
|
+
`bg-muted`. And `rounded-xl`, written here rather than left to Nuxt UI's
|
|
12
|
+
`rounded-lg`, because this IS the layer's card and the 12px rule applies.
|
|
13
|
+
|
|
14
|
+
No cards inside cards (ADR 018): the code block and fields it holds are
|
|
15
|
+
bordered, not filled a second time.
|
|
16
|
+
-->
|
|
17
|
+
<template>
|
|
18
|
+
<section
|
|
19
|
+
data-testid="tsp-section-card"
|
|
20
|
+
class="rounded-xl border border-default bg-elevated p-5"
|
|
21
|
+
>
|
|
22
|
+
<div
|
|
23
|
+
v-if="title || icon"
|
|
24
|
+
class="flex items-center gap-2 mb-4"
|
|
25
|
+
>
|
|
26
|
+
<UIcon
|
|
27
|
+
v-if="icon"
|
|
28
|
+
:name="icon"
|
|
29
|
+
class="text-lg shrink-0 tsp-text-brand"
|
|
30
|
+
/>
|
|
31
|
+
<!-- Gated too: the wrapper allows an icon-only card, which would
|
|
32
|
+
otherwise emit an empty <h2> that heading navigation lands on. -->
|
|
33
|
+
<h2
|
|
34
|
+
v-if="title"
|
|
35
|
+
data-testid="tsp-section-card-title"
|
|
36
|
+
class="text-sm font-bold text-highlighted"
|
|
37
|
+
>
|
|
38
|
+
{{ title }}
|
|
39
|
+
</h2>
|
|
40
|
+
</div>
|
|
41
|
+
<slot />
|
|
42
|
+
</section>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup lang="ts">
|
|
46
|
+
defineProps<{
|
|
47
|
+
/** Short card title. Omit for an untitled card — no empty heading is emitted. */
|
|
48
|
+
title?: string
|
|
49
|
+
/** Tabler icon name. Amber, because it labels rather than decorates. */
|
|
50
|
+
icon?: string
|
|
51
|
+
}>()
|
|
52
|
+
</script>
|
|
@@ -5,9 +5,52 @@
|
|
|
5
5
|
#brand — wordmark / logo (inside an inset bottom-bordered header)
|
|
6
6
|
#nav — TspNavItem links
|
|
7
7
|
#footer — typically <TspSidebarFooter>
|
|
8
|
+
|
|
9
|
+
STICKY at `sm` and above: exactly the viewport's height, pinned to the top.
|
|
10
|
+
Without that it is a flex child of a `min-h-svh` row, so on a long page it
|
|
11
|
+
grows to the DOCUMENT's height and the footer rows — settings, logout, theme,
|
|
12
|
+
language — scroll away with the content. Navigation that leaves the screen is
|
|
13
|
+
not navigation.
|
|
14
|
+
|
|
15
|
+
RESPONSIVE. At `sm` and above it is an ordinary column. Below `sm` it becomes
|
|
16
|
+
an off-canvas drawer over a backdrop, opened by <TspTopBar>'s burger through
|
|
17
|
+
useTspNavDrawer(). 640px is the breakpoint the platform's own mockup already
|
|
18
|
+
used, so services that hand-built a drawer match.
|
|
19
|
+
|
|
20
|
+
The nav scrolls when it has to, with no visible scrollbar (`tsp-scroll-quiet`)
|
|
21
|
+
— a track beside 32px rows in a 224px column is clutter. Rows carry `shrink-0`
|
|
22
|
+
and the nav `min-h-0`: in a fixed-height column an overflowing flex child
|
|
23
|
+
shrinks by default, which silently squashed 32px rows to 23px once the
|
|
24
|
+
sidebar stopped growing with the page.
|
|
25
|
+
|
|
26
|
+
Fallthrough attrs are bound explicitly to the <aside>: this template has two
|
|
27
|
+
roots (backdrop + aside), and Vue cannot auto-inherit onto a fragment, so a
|
|
28
|
+
consumer's `class` was being dropped silently.
|
|
29
|
+
|
|
30
|
+
The drawer closes on backdrop click, on Escape, on leaving mobile, and ON
|
|
31
|
+
ROUTE CHANGE — a drawer
|
|
32
|
+
that survives navigation is the classic mobile bug: you tap a link, the page
|
|
33
|
+
behind changes, and the menu is still sitting on top of it.
|
|
8
34
|
-->
|
|
9
35
|
<template>
|
|
10
|
-
<
|
|
36
|
+
<div
|
|
37
|
+
v-if="isOpen"
|
|
38
|
+
data-testid="tsp-nav-backdrop"
|
|
39
|
+
class="sm:hidden fixed inset-0 z-40 bg-black/50"
|
|
40
|
+
@click="close"
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
<aside
|
|
44
|
+
id="tsp-sidebar"
|
|
45
|
+
v-bind="$attrs"
|
|
46
|
+
data-testid="tsp-sidebar"
|
|
47
|
+
:inert="mobileHidden"
|
|
48
|
+
class="flex flex-col w-56 shrink-0 bg-muted border-r border-default py-5
|
|
49
|
+
sm:sticky sm:top-0 sm:h-svh
|
|
50
|
+
max-sm:fixed max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:transition-transform"
|
|
51
|
+
:class="isOpen ? 'max-sm:translate-x-0' : 'max-sm:-translate-x-full'"
|
|
52
|
+
:aria-hidden="mobileHidden"
|
|
53
|
+
>
|
|
11
54
|
<div
|
|
12
55
|
v-if="$slots.brand"
|
|
13
56
|
class="mx-2 px-2 pb-4 mb-3 border-b border-default"
|
|
@@ -15,12 +58,62 @@
|
|
|
15
58
|
<slot name="brand" />
|
|
16
59
|
</div>
|
|
17
60
|
|
|
18
|
-
<nav class="flex flex-col gap-0.5 px-2">
|
|
61
|
+
<nav class="flex min-h-0 flex-col gap-0.5 px-2 overflow-y-auto tsp-scroll-quiet">
|
|
19
62
|
<slot name="nav" />
|
|
20
63
|
</nav>
|
|
21
64
|
|
|
22
|
-
<div class="mt-auto px-3 pt-3">
|
|
65
|
+
<div class="mt-auto shrink-0 px-3 pt-3">
|
|
23
66
|
<slot name="footer" />
|
|
24
67
|
</div>
|
|
25
68
|
</aside>
|
|
26
69
|
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
defineOptions({ inheritAttrs: false })
|
|
73
|
+
|
|
74
|
+
const { isOpen, close } = useTspNavDrawer()
|
|
75
|
+
const route = useRoute()
|
|
76
|
+
|
|
77
|
+
watch(() => route.fullPath, close)
|
|
78
|
+
|
|
79
|
+
// Off-screen is not the same as hidden: a translated-away drawer is still in
|
|
80
|
+
// the tab order and still read aloud. Only applies below `sm`, so it is tracked
|
|
81
|
+
// against the viewport rather than assumed.
|
|
82
|
+
const isMobile = ref(false)
|
|
83
|
+
const mobileHidden = computed(() => (isMobile.value && !isOpen.value ? 'true' : undefined))
|
|
84
|
+
|
|
85
|
+
onMounted(() => {
|
|
86
|
+
const mq = window.matchMedia('(max-width: 639px)')
|
|
87
|
+
const sync = () => {
|
|
88
|
+
isMobile.value = mq.matches
|
|
89
|
+
// Leaving mobile with the drawer open would otherwise strand it: visually
|
|
90
|
+
// gone, still "open", still holding the scroll lock.
|
|
91
|
+
if (!mq.matches) close()
|
|
92
|
+
}
|
|
93
|
+
sync()
|
|
94
|
+
mq.addEventListener('change', sync)
|
|
95
|
+
onUnmounted(() => mq.removeEventListener('change', sync))
|
|
96
|
+
|
|
97
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
|
|
98
|
+
window.addEventListener('keydown', onKey)
|
|
99
|
+
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// A drawer over the page should not let the page scroll behind it.
|
|
103
|
+
const setBodyScroll = (locked: boolean) => {
|
|
104
|
+
if (import.meta.client) document.body.style.overflow = locked ? 'hidden' : ''
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Keyed on BOTH. Keyed on isOpen alone, widening past `sm` with the drawer
|
|
108
|
+
// open left <body> locked forever: the drawer and backdrop are sm:hidden, so
|
|
109
|
+
// they vanish, the watcher never re-fires, and the page cannot scroll with no
|
|
110
|
+
// control left to unlock it.
|
|
111
|
+
watch([isOpen, isMobile], ([open, mobile]) => setBodyScroll(open && mobile))
|
|
112
|
+
|
|
113
|
+
// Released on unmount as well as on close. A layout swap or an HMR reload while
|
|
114
|
+
// the drawer is open would otherwise leave `overflow: hidden` on <body>
|
|
115
|
+
// forever, and the page could never scroll again.
|
|
116
|
+
onUnmounted(() => {
|
|
117
|
+
if (isOpen.value) setBodyScroll(false)
|
|
118
|
+
})
|
|
119
|
+
</script>
|