fix: optimize home layout for mobile

scarlett
NGPixel 4 weeks ago
parent 99ccdc1326
commit 57cda5162c
No known key found for this signature in database

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
264 icons. 263 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32}, "la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -153,7 +153,6 @@ export const BUNDLED_ICONS = {
"mdi:account-group": {"body":"<path fill=\"currentColor\" d=\"M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9A3.5 3.5 0 0 1 12 5.5M5 8c.56 0 1.08.15 1.53.42c-.15 1.43.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3a3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3a3 3 0 0 1-3 3c-1.16 0-2.16-.66-2.66-1.62a5.54 5.54 0 0 0 1.13-3.96c.45-.27.97-.42 1.53-.42M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13zM0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9c-.59.68-.95 1.62-.95 2.65V20zm24 0h-3.5v-1.75c0-1.03-.36-1.97-.95-2.65c2.56.34 4.45 1.51 4.45 2.9z\"/>","width":24,"height":24}, "mdi:account-group": {"body":"<path fill=\"currentColor\" d=\"M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9A3.5 3.5 0 0 1 12 5.5M5 8c.56 0 1.08.15 1.53.42c-.15 1.43.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3a3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3a3 3 0 0 1-3 3c-1.16 0-2.16-.66-2.66-1.62a5.54 5.54 0 0 0 1.13-3.96c.45-.27.97-.42 1.53-.42M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13zM0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9c-.59.68-.95 1.62-.95 2.65V20zm24 0h-3.5v-1.75c0-1.03-.36-1.97-.95-2.65c2.56.34 4.45 1.51 4.45 2.9z\"/>","width":24,"height":24},
"mdi:alert": {"body":"<path fill=\"currentColor\" d=\"M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z\"/>","width":24,"height":24}, "mdi:alert": {"body":"<path fill=\"currentColor\" d=\"M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z\"/>","width":24,"height":24},
"mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24}, "mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24},
"mdi:alpha-t-box-outline": {"body":"<path fill=\"currentColor\" d=\"M9 7h6v2h-2v8h-2V9H9zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 2v14h14V5z\"/>","width":24,"height":24},
"mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24}, "mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24},
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24}, "mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24}, "mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},

@ -0,0 +1,184 @@
<template>
<w-btn
class="header-actions-btn ml-4"
flat
round
dense
icon="la:ellipsis-v"
aria-label="More Actions">
<w-menu ref="menu" class="translucent-menu" anchor="bottom right" self="top right">
<!--
Every row's icon takes its colour as a literal `text-*` class rather than through `WIcon`'s
`color` prop, which builds `text-${color}` at runtime: Tailwind generates a utility only for a
class name it can find as literal text in the source, so a constructed one resolves to nothing
and the icon falls back to the menu's own ink. `color="positive"` happens to work because that
string is written literally elsewhere in the app; `color="amber"` and `color="blue-4"` are not,
and rendered black. The colours themselves match the buttons these rows stand in for.
-->
<w-list padding style="min-width: 250px">
<!--
Who is signed in. The account button this stands in for led with the same two lines, and
nothing else in the phone header says whose session this is.
-->
<template v-if="userStore.authenticated">
<w-item>
<w-item-section avatar>
<w-avatar v-if="userStore.hasAvatar" size="32px">
<img :src="`/_user/current/avatar`" />
</w-avatar>
<w-icon v-else name="la:user-circle" />
</w-item-section>
<w-item-section>
<w-item-label>{{ userStore.name }}</w-item-label>
<w-item-label caption>{{ userStore.email }}</w-item-label>
</w-item-section>
</w-item>
<w-separator class="my-2" />
</template>
<!--
A submenu, because New Page is a choice of editor rather than a single action -- the same
choice, from the same component, as the header button on a wide screen. It anchors to this row
because `WMenu` takes the nearest `.w-item` as its trigger.
`hide-asset-btn` because this menu has a File Manager row of its own directly below, and
`@new-page` closes this menu once the submenu has acted: the editor opens behind it otherwise,
with the menu still floating over it.
-->
<w-item v-if="userStore.can(`write:pages`)" clickable>
<w-item-section avatar>
<w-icon name="la:plus-circle" class="text-blue-4" />
</w-item-section>
<w-item-section>Create New Page</w-item-section>
<w-item-section side>
<w-icon name="la:angle-right" />
</w-item-section>
<page-new-menu hide-asset-btn @new-page="close" />
</w-item>
<!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
whose rules cover the pages but not the assets beside them, since the editor sends them
here to insert an image. Every folder and every file is checked again by the endpoints
behind the manager, which answer per path, so this decides only whether the door is shown.
-->
<w-item v-if="canUseFileManager" clickable @click="openFileManager">
<w-item-section avatar>
<w-icon name="la:folder-open" class="text-positive" />
</w-item-section>
<w-item-section>File Manager</w-item-section>
</w-item>
<w-item v-if="userStore.authenticated" clickable to="/_inbox" @click="close">
<w-item-section avatar>
<w-icon name="mdi:inbox-full" class="text-amber" />
</w-item-section>
<w-item-section>{{ t('inbox.title') }}</w-item-section>
</w-item>
<w-item v-if="userStore.can(`access:admin`)" clickable to="/_admin" @click="close">
<w-item-section avatar>
<w-icon name="la:tools" class="text-pink" />
</w-item-section>
<w-item-section>{{ t('common.header.admin') }}</w-item-section>
</w-item>
<w-separator class="my-2" />
<!--
The account rows, flattened into this list rather than opened as a second submenu: they are two
plain actions, and the panel they live in on a wide screen is 300px of card -- wider than this
menu is on the screens it exists for.
-->
<template v-if="userStore.authenticated">
<w-item clickable to="/_profile" @click="close">
<w-item-section avatar>
<w-icon name="la:user-alt" class="text-primary" />
</w-item-section>
<w-item-section>{{ t('common.header.profile') }}</w-item-section>
</w-item>
<w-item clickable @click="logout">
<w-item-section avatar>
<w-icon name="la:sign-out-alt" class="text-red" />
</w-item-section>
<w-item-section>{{ t('common.header.logout') }}</w-item-section>
</w-item>
</template>
<!-- -> The header's Login button is one of the ones this menu stands in for, so a guest has to
find it in here -->
<w-item v-else clickable to="/login" @click="close">
<w-item-section avatar>
<w-icon name="la:sign-in-alt" class="text-primary" />
</w-item-section>
<w-item-section>{{ t('common.actions.login') }}</w-item-section>
</w-item>
</w-list>
</w-menu>
</w-btn>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import PageNewMenu from '@/components/PageNewMenu.vue'
/**
* The phone header's overflow menu: one button standing in for every action the bar shows as its own
* icon on a wide screen -- New Page, File Manager, Inbox, Administration, and the account.
*
* Rendered only below the `sm` breakpoint; see `HeaderNav`. The rows repeat the permission tests the
* buttons they replace make, rather than being handed a list, so the two cannot drift apart.
*/
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// REFS
const menu = ref(null)
// COMPUTED
const canUseFileManager = computed(
() => userStore.can('write:assets') || userStore.can('write:pages')
)
// METHODS
/**
* Dismiss the menu.
*
* Every row calls it, rather than the menu carrying `auto-close`: that closes on ANY click inside,
* including the New Page row -- which would take the submenu's trigger out of the document in the same
* tick it was pressed.
*/
function close() {
menu.value?.hide()
}
/*
Closed before the manager opens: it is a full-screen overlay, and a menu teleported to the body
outranks it -- so the menu would be left floating over the panel it had just opened.
*/
function openFileManager() {
close()
siteStore.openFileManager()
}
function logout() {
close()
userStore.logout()
}
</script>
<style lang="scss">
// -> Where the button gets its colour, so it carries no `color` prop: `WBtn` emits an inline `color`,
// which would outrank this rule. Matches `.account-avbtn`, the button it stands in for.
.header-actions-btn {
color: rgba(255, 255, 255, 0.75);
}
</style>

@ -8,16 +8,38 @@
</w-avatar> </w-avatar>
<img v-else :src="`/_site/current/logo`" style="height: 34px" /> <img v-else :src="`/_site/current/logo`" style="height: 34px" />
</w-btn> </w-btn>
<div v-if="siteStore.logoText" class="text-h6 ml-2 min-w-0 flex-1 truncate"> <div v-if="siteStore.logoText" class="site-title text-h6 ml-2 min-w-0 flex-1 truncate">
{{ siteStore.title }} {{ siteStore.title }}
</div> </div>
</w-toolbar> </w-toolbar>
<header-search /> <!-- -> Inline between the title and the actions only where there is room for all three; on a
phone the field gets a row of its own at the bottom of this header instead -->
<header-search v-if="!isPhoneViewport" />
<w-toolbar style="height: 64px"> <w-toolbar style="height: 64px">
<w-space /> <w-space />
<transition name="syncing"> <transition name="syncing">
<w-spinner v-show="commonStore.routerLoading" size="20px" class="text-accent" /> <w-spinner v-show="commonStore.routerLoading" size="20px" class="text-accent" />
</transition> </transition>
<!--
The phone form of everything to the right of the spinner: the search field's opener, and one
button for the rest. Five icon buttons whose meaning is only in a tooltip is not something a
touch screen can offer, and they do not fit beside the site title in any case.
-->
<template v-if="isPhoneViewport">
<w-btn
v-if="siteStore.features.search"
class="ml-4"
flat
round
dense
:icon="searchRowIsOpen ? `la:times` : `la:search`"
color="white"
:aria-label="searchRowIsOpen ? t(`common.actions.close`) : t(`common.header.search`)"
:aria-expanded="searchRowIsOpen"
@click="toggleSearchRow" />
<header-actions-menu />
</template>
<template v-else>
<w-btn <w-btn
v-if="userStore.can(`write:pages`)" v-if="userStore.can(`write:pages`)"
class="ml-4" class="ml-4"
@ -31,10 +53,11 @@
<new-menu /> <new-menu />
</w-btn> </w-btn>
<!-- <!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author -> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an
whose rules cover the pages but not the assets beside them, since the editor sends them author whose rules cover the pages but not the assets beside them, since the editor
here to insert an image. Every folder and every file is checked again by the endpoints sends them here to insert an image. Every folder and every file is checked again by the
behind the manager, which answer per path, so this decides only whether the door is shown. endpoints behind the manager, which answer per path, so this decides only whether the
door is shown.
--> -->
<w-btn <w-btn
v-if="userStore.can(`write:assets`) || userStore.can(`write:pages`)" v-if="userStore.can(`write:assets`) || userStore.can(`write:pages`)"
@ -87,20 +110,39 @@
to="/login" to="/login"
padding="sm" padding="sm"
no-caps /> no-caps />
</template>
</w-toolbar> </w-toolbar>
</div> </div>
<!--
The phone search field, in a row of its own under the bar. Unmounted on the way out, so there is
never a second field bound to the same query.
Focused from `@after-enter` rather than on mount: focusing the field is what draws the suggestions
panel under it, and doing that while the row is still sliding put a fresh layout and a
`backdrop-filter` blur into the middle of the animation -- which is what made it stutter.
-->
<transition name="header-search-row" @after-enter="searchRow?.focus()">
<div v-if="isPhoneViewport && searchRowIsOpen" class="header-search-row">
<header-search ref="searchRow" row />
</div>
</transition>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMinWidth } from '@/composables/screen'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import AccountMenu from '@/components/AccountMenu.vue' import AccountMenu from '@/components/AccountMenu.vue'
import NewMenu from '@/components/PageNewMenu.vue' import NewMenu from '@/components/PageNewMenu.vue'
import HeaderActionsMenu from '@/components/HeaderActionsMenu.vue'
import HeaderSearch from '@/components/HeaderSearch.vue' import HeaderSearch from '@/components/HeaderSearch.vue'
/** /**
@ -116,13 +158,141 @@ const commonStore = useCommonStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
// ROUTER
const route = useRoute()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()
// REFS
/** The phone search field, for the one thing this component does to it: focus it once it is down. */
const searchRow = ref(null)
// DATA
/** Whether the phone search row is down. Never consulted above the breakpoint. */
const searchRowIsOpen = ref(false)
// COMPUTED
/**
* Below the `sm` breakpoint (`css/tailwind.css`), which is this app's phone boundary where the
* search field and the five action buttons stop fitting alongside the site title.
*/
const isAtLeastSm = useMinWidth(600)
const isPhoneViewport = computed(() => !isAtLeastSm.value)
// WATCHERS
/*
The search row closes on arriving somewhere, which is what pressing Enter in it does: the results are
the answer, and a field still hanging under the header is one more thing to put away by hand.
*/
watch(
() => route.path,
() => {
searchRowIsOpen.value = false
}
)
// MOUNTED
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
})
// METHODS // METHODS
/*
Ctrl+K at phone width, where the field is not mounted and so cannot claim the shortcut itself: this
opens the row, and `HeaderSearch` focuses on mount. Above the breakpoint, and while the row is
already down, the field's own handler is the one that answers -- see `HeaderSearch.handleKeyPress`.
*/
function onKeydown(ev) {
if (!isPhoneViewport.value || searchRowIsOpen.value || !siteStore.features.search) {
return
}
if (ev.ctrlKey && ev.key === 'k' && !siteStore.overlayIsShown) {
ev.preventDefault()
searchRowIsOpen.value = true
}
}
function toggleSearchRow() {
searchRowIsOpen.value = !searchRowIsOpen.value
}
function openFileManager() { function openFileManager() {
siteStore.openFileManager() siteStore.openFileManager()
} }
</script> </script>
<style scoped lang="scss">
/*
The site name, a step down on a phone: `text-h6` is 20px, which is a heading's size next to a 34px
logo and two buttons on a 390px bar. Slight on purpose -- the title is still the first thing the bar
says.
*/
@media (max-width: $breakpoint-xs-max) {
.site-title {
font-size: 1.0625rem;
}
}
/*
The site's sidebar colour, which is what says this IS a second row rather than more of the bar: the
header's colour is the site's to choose and is black by default, so against an identical black the
field read as a pill floating in one tall bar -- generously padded above and cropped below.
The sidebar's rather than any other: on a phone the sidebar is the panel this same header opens, so the
two things that come out from behind the bar are the one colour.
Through `--color-sidebar` rather than the `bg-sidebar` utility, so the row follows a site that themes
its colours at runtime (the variable is rewritten in place; see `tailwind.css`).
*/
.header-search-row {
background-color: var(--color-sidebar);
}
/*
The search row sliding out from under the bar.
`max-height` rather than `height`, because the row is a `WToolbar` and carries `min-height: 50px` of
its own -- which a height of 0 loses to, and a max-height overrules. 52px is the height the row is
given in `HeaderSearch`; the two have to agree, or the slide stops short of the row's full height and
jumps the rest of the way.
`overflow: hidden` for the duration only, so that the search panel -- which hangs BELOW this row and
is positioned against it -- is not clipped once the row is open.
*/
.header-search-row-enter-active,
.header-search-row-leave-active {
overflow: hidden;
transition:
max-height 0.2s var(--ease-standard),
opacity 0.2s var(--ease-standard);
}
.header-search-row-enter-from,
.header-search-row-leave-to {
max-height: 0;
opacity: 0;
}
.header-search-row-enter-to,
.header-search-row-leave-from {
max-height: 52px;
}
@media (prefers-reduced-motion: reduce) {
.header-search-row-enter-active,
.header-search-row-leave-active {
transition-duration: 0.01ms;
}
}
</style>

@ -1,5 +1,11 @@
<template> <template>
<w-toolbar style="height: 64px;" v-if="siteStore.features.search"> <!--
`row` is the phone form: the field is not squeezed between the site title and the header's
buttons but has a row of the whole width to itself, opened from a search button. Slightly
shorter than the header proper, so the two read as a bar and a drawer under it rather than as
two headers.
-->
<w-toolbar :style="{ height: row ? `52px` : `64px` }" v-if="siteStore.features.search">
<!-- <!--
The positioning context for the panel below, and the width it matches. The toolbar cannot be The positioning context for the panel below, and the width it matches. The toolbar cannot be
it: the panel would then span the toolbar's padding as well, and with no positioned ancestor it: the panel would then span the toolbar's padding as well, and with no positioned ancestor
@ -9,7 +15,9 @@
`top: 100%` on the panel lands on the bottom edge of the header instead of 12px above it. `top: 100%` on the panel lands on the bottom edge of the header instead of 12px above it.
--> -->
<div class="header-search relative flex h-full min-w-0 flex-1 flex-col justify-center"> <div class="header-search relative flex h-full min-w-0 flex-1 flex-col justify-center">
<div class="header-search-field" :class="{ 'is-focused': state.searchIsFocused }"> <div
class="header-search-field"
:class="{ 'is-focused': state.searchIsFocused, 'header-search-field--row': row }">
<w-circular-progress <w-circular-progress
v-if="siteStore.searchIsLoading && route.path !== `/_search`" v-if="siteStore.searchIsLoading && route.path !== `/_search`"
class="header-search-lead" class="header-search-lead"
@ -49,16 +57,24 @@
<!-- <!--
The shortcut hint doubles as the focus affordance, so it gives way to whatever the field The shortcut hint doubles as the focus affordance, so it gives way to whatever the field
has to say once it is in use. has to say once it is in use.
Never in `row` form: that is the phone field, opened by a button, and a keyboard shortcut is
not something the device it exists for can offer. The focus test moves onto the branch below,
which the chain used to get for free from this one.
--> -->
<span <span
v-if="!state.searchIsFocused" v-if="!row && !state.searchIsFocused"
class="header-search-kbd" class="header-search-kbd"
aria-hidden="true" aria-hidden="true"
@click="searchField.focus()"> @click="searchField.focus()">
Ctrl+K Ctrl+K
</span> </span>
<span <span
v-else-if="siteStore.search && siteStore.search !== siteStore.searchLastQuery" v-else-if="
state.searchIsFocused &&
siteStore.search &&
siteStore.search !== siteStore.searchLastQuery
"
class="header-search-kbd"> class="header-search-kbd">
Press Enter Press Enter
</span> </span>
@ -87,10 +103,18 @@
</div> </div>
</template> </template>
<div class="searchpanel-header">Search Operators</div> <div class="searchpanel-header">Search Operators</div>
<div class="searchpanel-tip"><code>!foo</code> or <code>-bar</code> to exclude "foo" and "bar".</div> <div class="searchpanel-tip">
<div class="searchpanel-tip"><code>bana*</code> for to match any term starting with "bana" (e.g. banana).</div> <code>!foo</code> or <code>-bar</code> to exclude "foo" and "bar".
<div class="searchpanel-tip"><code>foo,bar</code> or <code>foo|bar</code> to search for "foo" OR "bar".</div> </div>
<div class="searchpanel-tip"><code>"foo bar"</code> to match exactly the phrase "foo bar".</div> <div class="searchpanel-tip">
<code>bana*</code> for to match any term starting with "bana" (e.g. banana).
</div>
<div class="searchpanel-tip">
<code>foo,bar</code> or <code>foo|bar</code> to search for "foo" OR "bar".
</div>
<div class="searchpanel-tip">
<code>"foo bar"</code> to match exactly the phrase "foo bar".
</div>
</div> </div>
</div> </div>
</w-toolbar> </w-toolbar>
@ -105,6 +129,18 @@ import { useSiteStore } from '@/stores/site'
import { orderBy } from 'es-toolkit/array' import { orderBy } from 'es-toolkit/array'
// PROPS
const props = defineProps({
/**
* Render as a row of its own rather than inline in the header bar. What the phone header opens;
* see `HeaderNav`.
*/
row: {
type: Boolean,
default: false
}
})
// STORES // STORES
@ -190,6 +226,18 @@ function checkSearchFocus(ev) {
} }
} }
/**
* Put the caret in the field.
*
* Exposed because in `row` form the field is not focused by being mounted: focusing it is what draws
* the panel below it, and a panel appearing mid-slide is layout and a `backdrop-filter` blur landing
* in the middle of an animation. `HeaderNav` owns that transition, so it calls this when the slide has
* finished -- see its `@after-enter`.
*/
function focus() {
searchField.value?.focus()
}
function clearSearch() { function clearSearch() {
siteStore.search = '' siteStore.search = ''
searchField.value.focus() searchField.value.focus()
@ -217,6 +265,8 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyPress) window.removeEventListener('keydown', handleKeyPress)
} }
}) })
defineExpose({ focus })
</script> </script>
<style lang="scss"> <style lang="scss">
@ -242,10 +292,26 @@ onBeforeUnmount(() => {
color 0.25s var(--ease-standard); color 0.25s var(--ease-standard);
} }
/*
In `row` form, a wash of black over whatever is behind rather than a grey of its own.
The row is the site's sidebar colour (see `HeaderNav`), which is the site's to choose -- so a fixed
grey is a slab of a foreign colour sitting on it, right for one theme and wrong for the rest. A
translucent black darkens whatever it is given and reads as a well sunk into the row on any of them.
The inline form keeps its grey: it sits on the HEADER, which is black by default, and a translucent
black on black is no field at all.
*/
&-field--row {
background-color: rgb(0 0 0 / 0.25);
}
/* /*
In use, the field inverts: white fill, dark ink. Driven by a class rather than `:focus-within` In use, the field inverts: white fill, dark ink. Driven by a class rather than `:focus-within`
so it stays inverted while the panel below is being used -- clicking a tag in there moves focus so it stays inverted while the panel below is being used -- clicking a tag in there moves focus
out of the input, and the field flickering back to dark mid-interaction reads as a glitch. out of the input, and the field flickering back to dark mid-interaction reads as a glitch.
Two classes, so this outranks the `--row` wash above whichever order they end up in.
*/ */
&-field.is-focused { &-field.is-focused {
background-color: #fff; background-color: #fff;

@ -333,8 +333,24 @@ watch(
The other way round this does not arise: with the sidebar on the left the notch is on the right edge, The other way round this does not arise: with the sidebar on the left the notch is on the right edge,
and only the left of a child row is indented. and only the left of a child row is indented.
*/ */
&--flipped .w-list .w-expansion-item__content .w-item.router-link-exact-active::after { /*
left: -10px; No notch at all once the drawer overlays the page instead of taking a column beside it.
The mark is not drawn ON the sidebar -- it is a piece of the sidebar MISSING, painted in the colour
of whatever is on the other side of that edge, which is the page. Overlaying, there is nothing on
the other side to show through: the panel floats over the article with a scrim behind it, so the
notch stops being an absence and becomes what it is made of -- a white arrow on a coloured panel,
pointing at the middle of a page it is covering. Which is also why hiding it is a matter of the
layout rather than of the screen being a phone: the same arrow is just as wrong on a tablet.
`content: none` rather than `display: none`, so the box is never generated. Stated after both
`--flipped` rules and at their specificity, so it takes the notch away whichever edge it was cut
from -- and 1023.98px is `WDrawer`'s own 1024px overlay breakpoint, which the two have to agree on.
*/
@media (max-width: $breakpoint-sm-max) {
.w-list .w-item.router-link-exact-active::after {
content: none;
}
} }
&-header { &-header {

@ -401,6 +401,20 @@ function removePendingAsset(item) {
.page-actions { .page-actions {
flex: 0 0 56px; flex: 0 0 56px;
/*
Gone on a phone while a page is being read: the rail is a column of icon buttons whose labels only
ever appear in a tooltip, which a touch screen has no way to show -- so it reads as six unexplained
glyphs down the edge of an already narrow article.
Not while the editor is open (`is-editor`), where the rail holds the properties panel and the pending
asset queue, and taking it away would leave an author with no way to reach either.
*/
@media (max-width: $breakpoint-xs-max) {
&:not(.is-editor) {
display: none;
}
}
@at-root .body--light & { @at-root .body--light & {
background-color: $grey-3; background-color: $grey-3;
} }

@ -6,20 +6,20 @@
class="rounded" class="rounded"
v-if="isEditing" v-if="isEditing"
padding="none" padding="none"
size="64px" :size="iconSize"
color="primary" color="primary"
flat flat
:aria-label="t(`editor.props.icon`)" :aria-label="t(`editor.props.icon`)"
style="min-height: 64px"> :style="{ minHeight: iconSize }">
<!-- -> 64px, the size the icon has when the page is merely being read; see the branch below --> <!-- -> The same size the icon has when the page is merely being read; see the branch below -->
<w-icon :name="pageStore.icon" size="64px" /> <w-icon :name="pageStore.icon" :size="iconSize" />
<!-- -> Not `v-model`: writing the store is only half of what picking an icon means here, and <!-- -> Not `v-model`: writing the store is only half of what picking an icon means here, and
the other half is telling the editor the page changed. See `setIcon`. --> the other half is telling the editor the page changed. See `setIcon`. -->
<w-menu content-class="shadow-7"> <w-menu content-class="shadow-7">
<icon-picker-dialog :model-value="pageStore.icon" @update:model-value="setIcon" /> <icon-picker-dialog :model-value="pageStore.icon" @update:model-value="setIcon" />
</w-menu> </w-menu>
</w-btn> </w-btn>
<w-icon class="rounded" v-else :name="pageStore.icon" size="64px" color="primary" /> <w-icon class="rounded" v-else :name="pageStore.icon" :size="iconSize" color="primary" />
</div> </div>
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<!-- <!--
@ -72,7 +72,14 @@
</div> </div>
</div> </div>
<!-- PAGE ACTIONS --> <!-- PAGE ACTIONS -->
<div class="flex-none p-4 flex items-center justify-end"> <!--
`has-editor-actions` is what keeps this row on a phone, where it is otherwise hidden: what it
holds while a page is being read is a handful of icons for a wide screen, but while a page is
being WRITTEN it holds the only way to save or to get back out. See the stylesheet.
-->
<div
class="page-header-actions flex-none p-4 flex items-center justify-end"
:class="{ 'has-editor-actions': hasEditorActions }">
<template v-if="!editorStore.isActive"> <template v-if="!editorStore.isActive">
<!-- <!--
Whoever is looking at a draft can already see it, so the badge is not gated on being logged Whoever is looking at a draft can already see it, so the badge is not gated on being logged
@ -328,6 +335,7 @@ import { useI18n } from 'vue-i18n'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
@ -364,6 +372,29 @@ const { t } = useI18n()
// COMPUTED // COMPUTED
/**
* At or above the `sm` breakpoint (`css/tailwind.css`), which is this app's phone boundary below it
* the header is compacted, since a 64px icon and a 34px title over a row of icons is most of a phone
* screen before the page has said anything.
*/
const isAtLeastSm = useMinWidth(600)
const isPhoneViewport = computed(() => !isAtLeastSm.value)
/**
* The page icon, halved on a phone.
*
* Bound rather than left to a media query: `WIcon` renders `size` as an inline `font-size`, which no
* stylesheet can outrank without `!important`.
*/
const iconSize = computed(() => (isPhoneViewport.value ? '32px' : '64px'))
/**
* Whether this row holds an editor's own controls Save, Discard, Submit rather than only the
* actions offered to a reader. Those must survive the phone layout: the properties panel puts the
* header into the pending state with no editor open, so `isActive` alone is not the question.
*/
const hasEditorActions = computed(() => editorStore.isActive || editorStore.hasPendingChanges)
/** /**
* Suggesting an edit rather than making one: the editor is open on a submission, and everything about * Suggesting an edit rather than making one: the editor is open on a submission, and everything about
* the page other than its content is out of scope. * the page other than its content is out of scope.
@ -813,6 +844,32 @@ function notImplemented() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/*
The phone layout of this row.
The title comes down from `text-h4`, which is a 34px display size written for a header the width of a
desktop window: at 390px a title of any length wrapped, and the description under it was pushed out of
the 95px bar. 24px is the same step `text-h5` takes, chosen as a value rather than as that class so the
size lives beside the breakpoint that asks for it.
And the actions go, all of them -- Watch, Print, the review queue, Edit -- because they are icons
squeezed against the right edge of a row that has no room for the title as it is. Nothing is lost that
is not reachable elsewhere: Print is the browser's own menu, and a page is edited on a machine with a
keyboard. An editor already open keeps its controls, or there would be no way to save or leave it.
Unlayered scoped rules, so they beat the `text-h4` utility without needing `!important`.
*/
@media (max-width: $breakpoint-xs-max) {
.page-header-title {
font-size: 1.5rem;
line-height: 2rem;
}
.page-header-actions:not(.has-editor-actions) {
display: none;
}
}
/* /*
The bell swinging as a page starts being watched. The bell swinging as a page starts being watched.

@ -5,8 +5,7 @@
</w-header> </w-header>
<w-drawer <w-drawer
class="bg-sidebar" class="bg-sidebar"
:model-value="isSidebarShown" v-model="isSidebarOpen"
:show-if-above="siteStore.theme.sidebarPosition !== `off`"
:width="sidebarWidth" :width="sidebarWidth"
:side="siteStore.theme.sidebarPosition === `right` ? `right` : `left`"> :side="siteStore.theme.sidebarPosition === `right` ? `right` : `left`">
<div v-if="isSidebarMini" class="sidebar-mini flex flex-col items-stretch"> <div v-if="isSidebarMini" class="sidebar-mini flex flex-col items-stretch">
@ -91,6 +90,31 @@
</w-bar> </w-bar>
</template> </template>
</w-drawer> </w-drawer>
<!--
The way back to the sidebar on a narrow viewport, where it overlays the page instead of taking a
column of its own: closed to start with, so it is not sitting over the article on arrival, and
nothing else on that screen opens it -- the header is full of page actions and has no room for a
menu button.
Bottom LEFT whichever side the sidebar is on, because the opposite corner belongs to
scroll-to-top: on a narrow viewport that button is in the corner too (`scrollerAnchorX` is null),
so one that followed the sidebar to the right would land on top of it.
The position goes on a wrapper rather than on the button, as `WPageScroller` does it: `WBtn` is
`relative` from its own class list, and Tailwind emits `relative` after `fixed`, so a `fixed`
alongside it loses.
-->
<transition name="sidebar-open-btn">
<div v-if="showSidebarBtn" class="fixed bottom-4 left-4 z-30">
<w-btn
icon="la:bars"
color="primary"
round
size="md"
:aria-label="t(`common.sidebar.mainMenu`)"
@click="openSidebar" />
</div>
</transition>
<!-- <!--
No `<w-footer>` here, unlike every other layout: this one only ever holds the page view, and No `<w-footer>` here, unlike every other layout: this one only ever holds the page view, and
there the article column scrolls inside a shell that holds still, so a footer at this level there the article column scrolls inside a shell that holds still, so a footer at this level
@ -175,9 +199,25 @@ useMeta(() => {
const navEditMenu = ref(null) const navEditMenu = ref(null)
const navEditMenuMini = ref(null) const navEditMenuMini = ref(null)
// DATA
/**
* Whether the reader has opened the overlaying sidebar. Only consulted on a narrow viewport, where
* the drawer is the only thing over the page and closing it is a state of its own; on a wide one the
* sidebar is a column that is simply there.
*/
const isNarrowSidebarOpen = ref(false)
// COMPUTED // COMPUTED
const isSidebarShown = computed(() => { /**
* Where the drawer stops overlaying the page and takes its own column. Matches `WDrawer`'s own
* breakpoint below it there is no seam to straddle, because the sidebar is not beside anything.
*/
const isWideViewport = useMinWidth(1024)
/** Whether this site, page and mode have a sidebar at all — before asking whether it is open. */
const isSidebarAvailable = computed(() => {
return ( return (
siteStore.showSideNav && siteStore.showSideNav &&
!siteStore.sideNavIsDisabled && !siteStore.sideNavIsDisabled &&
@ -185,6 +225,31 @@ const isSidebarShown = computed(() => {
) )
}) })
/**
* Whether the sidebar is on screen: always on a wide viewport, where it has a column of its own, and
* only once asked for on a narrow one, where it overlays the page.
*
* It used to be `isSidebarAvailable` on its own, bound one-way so on a phone the sidebar came up
* over the article on every page load and there was no way to put it away: the drawer asks to be
* closed when its scrim is tapped, and with no listener for that the request went nowhere.
*/
const isSidebarOpen = computed({
get: () => isSidebarAvailable.value && (isWideViewport.value || isNarrowSidebarOpen.value),
// -> Only ever reached from the scrim, which exists only while overlaying
set: (val) => {
isNarrowSidebarOpen.value = val
}
})
/*
Shown only where the sidebar is something to open: a narrow viewport, on a site and a page that have
one. Not while it is already open -- the scrim is what closes it, and the button would be behind the
panel in any case.
*/
const showSidebarBtn = computed(() => {
return isSidebarAvailable.value && !isWideViewport.value && !isNarrowSidebarOpen.value
})
const isSidebarMini = computed(() => { const isSidebarMini = computed(() => {
return ['hide', 'hideExact'].includes(pageStore.navigationMode) || !pageStore.navigationId return ['hide', 'hideExact'].includes(pageStore.navigationMode) || !pageStore.navigationId
}) })
@ -195,12 +260,6 @@ const SIDEBAR_WIDTH_MINI = 56
const sidebarWidth = computed(() => (isSidebarMini.value ? SIDEBAR_WIDTH_MINI : SIDEBAR_WIDTH)) const sidebarWidth = computed(() => (isSidebarMini.value ? SIDEBAR_WIDTH_MINI : SIDEBAR_WIDTH))
/**
* Where the drawer stops overlaying the page and takes its own column. Matches `WDrawer`'s own
* breakpoint below it there is no seam to straddle, because the sidebar is not beside anything.
*/
const isWideViewport = useMinWidth(1024)
/* /*
The scroll-to-top button straddles the seam between the sidebar and the content, half over each, so The scroll-to-top button straddles the seam between the sidebar and the content, half over each, so
its centre is the sidebar's inner edge — which is the sidebar's width on the left, or the same its centre is the sidebar's inner edge — which is the sidebar's width on the left, or the same
@ -211,7 +270,9 @@ const isWideViewport = useMinWidth(1024)
the full width. the full width.
*/ */
const scrollerAnchorX = computed(() => { const scrollerAnchorX = computed(() => {
if (!isWideViewport.value || !isSidebarShown.value || siteStore.theme.sidebarPosition === 'off') { // -> No separate test for `sidebarPosition === 'off'`: that IS `sideNavIsDisabled`, which
// `isSidebarAvailable` already asks
if (!isWideViewport.value || !isSidebarAvailable.value) {
return null return null
} }
return siteStore.theme.sidebarPosition === 'right' return siteStore.theme.sidebarPosition === 'right'
@ -231,6 +292,25 @@ const showSidebarActions = computed(() => siteStore.locales.showMenu || canBrows
const canEditNav = computed(() => { const canEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation') return userStore.authenticated && userStore.can('manage:navigation')
}) })
// WATCHERS
/*
Following a link out of the overlaying sidebar puts it away, since what the reader asked for is
behind it. On a wide viewport there is nothing to close and the flag is not consulted anyway.
*/
watch(
() => route.path,
() => {
isNarrowSidebarOpen.value = false
}
)
// METHODS
function openSidebar() {
isNarrowSidebarOpen.value = true
}
</script> </script>
<style lang="scss"> <style lang="scss">
@ -250,6 +330,26 @@ const canEditNav = computed(() => {
height: 100%; height: 100%;
} }
/*
The menu button fades as the sidebar it opens slides, rather than blinking out from under the panel
the instant it is tapped. Same duration and easing as the drawer's own slide.
*/
.sidebar-open-btn-enter-active,
.sidebar-open-btn-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.sidebar-open-btn-enter-from,
.sidebar-open-btn-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.sidebar-open-btn-enter-active,
.sidebar-open-btn-leave-active {
transition-duration: 0.01ms;
}
}
/* /*
No background of its own, and nothing sticky: the drawer is the height of the shell and the nav list No background of its own, and nothing sticky: the drawer is the height of the shell and the nav list
above scrolls inside itself, so this bar sits at the bottom of the window by being last in the above scrolls inside itself, so this bar sits at the bottom of the window by being last in the

@ -21,7 +21,12 @@
<template #separator><w-icon name="la:angle-right" /></template> <template #separator><w-icon name="la:angle-right" /></template>
</w-breadcrumbs> </w-breadcrumbs>
</div> </div>
<div class="flex-none flex items-center justify-end"> <!--
Off on a phone: on a 390px screen the date takes a whole line of its own under the trail, which
is a lot of room for something a reader is not here for -- and the trail itself is how they get
back out, so that is what the bar keeps.
-->
<div class="flex-none items-center justify-end hidden sm:flex">
<template v-if="!pageStore.publishState === `draft`"> <template v-if="!pageStore.publishState === `draft`">
<div class="text-caption text-accent"><strong>Unpublished</strong></div> <div class="text-caption text-accent"><strong>Unpublished</strong></div>
<w-separator class="mx-2" vertical /> <w-separator class="mx-2" vertical />
@ -936,6 +941,16 @@ function goBack() {
.page-sidebar { .page-sidebar {
flex: 0 0 300px; flex: 0 0 300px;
/*
Gone on a phone rather than narrowed: this column is a fixed 300px, so on a 390px screen it took
three quarters of the width and left the article a strip a few characters wide. There is no width to
share here, and the contents list, the tags and the rating are all things beside the page rather than
the page itself.
*/
@media (max-width: $breakpoint-xs-max) {
display: none;
}
@at-root .body--light & { @at-root .body--light & {
background-color: $grey-2; background-color: $grey-2;
} }

Loading…
Cancel
Save