mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Fix(nav): collapse the link row by measured width, not by isMobile
Tablets report a desktop UA, so between 768px and ~1366px the flat link row rendered and wrapped inside its links; no single breakpoint fits six locales. use-nav-overflow.js measures the row against the space beside the brand and actions and collapses to the hamburger when it doesn't fit. The row stays rendered (invisible, inert) so it remains measurable. DocsSearch switches to its icon below xl by CSS so the action group's width never depends on the collapse state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
35e691da6e
commit
d28fe239ae
5 files changed
+153
-23
No files matched your search
@@ -177,6 +177,8 @@ Every "business state → color" mapping goes through `use-status-tone.js`
|
||||
`resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
|
||||
scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
|
||||
no section is highlighted off Home.
|
||||
- The link row collapses to the hamburger + Sheet when `use-nav-overflow.js`
|
||||
measures it doesn't fit (no breakpoint); `isMobile` only drives phone behaviour.
|
||||
- **Page breadcrumb** — every page but Home opens its body with
|
||||
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
|
||||
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
|
||||
|
||||
+37
-16
@@ -6,11 +6,13 @@
|
||||
:class="{ '-translate-y-full': isNavHidden,
|
||||
'bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur': !isPwa || (isPwa && !isMobile),
|
||||
'bg-page-bg': isPwa && isMobile }">
|
||||
<nav id="navbar-top" class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
|
||||
<nav id="navbar-top" ref="navEl"
|
||||
class="relative mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
|
||||
|
||||
<!-- Left: Hamburger (only mobile) + Brand -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
|
||||
<!-- Left: Hamburger (collapsed nav only) + Brand. Expanded, every group
|
||||
keeps its natural width so use-nav-overflow measures true sizes. -->
|
||||
<div ref="brandEl" class="flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
|
||||
<Button v-if="isNavCollapsed" variant="ghost" size="icon" class="size-8" :aria-expanded="isNavMenuOpen"
|
||||
aria-label="Toggle navigation menu" @click="store.toggleSheet('navMenu')">
|
||||
<Menu />
|
||||
</Button>
|
||||
@@ -25,8 +27,12 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Middle: Desktop nav links + GitHub star badge (left aligned, next to brand) -->
|
||||
<div v-if="!isMobile" class="flex items-center gap-0.5">
|
||||
<!-- Middle: nav links + GitHub star badge (left aligned, next to brand).
|
||||
Always rendered so its width stays measurable; collapsed, it is
|
||||
invisible, out of flow and out of the a11y tree / tab order. -->
|
||||
<div ref="linksEl" class="flex w-max shrink-0 items-center gap-0.5"
|
||||
:class="{ 'invisible absolute left-0 top-0 pointer-events-none': isNavCollapsed }"
|
||||
:aria-hidden="isNavCollapsed ? 'true' : undefined" :inert="isNavCollapsed || undefined">
|
||||
<template v-for="item in navItems" :key="item">
|
||||
<!-- Advanced Tools: hover reveals the sub-tools, click goes to the
|
||||
section like the other links (disable-click-trigger frees the
|
||||
@@ -36,7 +42,7 @@
|
||||
class="flex-none">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger :class="['h-auto bg-transparent', navLinkClass(item)]"
|
||||
<NavigationMenuTrigger :class="['h-auto bg-transparent whitespace-nowrap', navLinkClass(item)]"
|
||||
@click="goToSection(item)">
|
||||
{{ t(`nav.${item}`) }}
|
||||
</NavigationMenuTrigger>
|
||||
@@ -58,7 +64,7 @@
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<!-- All other sections -->
|
||||
<a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)">
|
||||
<a v-else href="#" :class="[navLinkClass(item), 'whitespace-nowrap']" @click.prevent="goToSection(item)">
|
||||
{{ t(`nav.${item}`) }}
|
||||
</a>
|
||||
</template>
|
||||
@@ -76,11 +82,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: Action area (ml-auto push to the right) -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<div ref="actionsEl" class="ml-auto flex items-center gap-2" :class="{ 'shrink-0': !isNavCollapsed }">
|
||||
<!-- Earth Online entry (code name: pulse) -->
|
||||
<Pulse />
|
||||
|
||||
<!-- Docs assistant entry point (ask box on desktop, icon on mobile) -->
|
||||
<!-- Docs assistant entry point (ask box from xl up, icon below) -->
|
||||
<DocsSearch />
|
||||
|
||||
<!-- Account menu: preferences cog (Firebase-less) or the user dropdown -->
|
||||
@@ -88,9 +94,10 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile navigation sheet. Flex column so the link list scrolls instead
|
||||
of clipping on short screens when Advanced Tools is expanded. -->
|
||||
<Sheet v-if="isMobile" :open="isNavMenuOpen" @update:open="onNavMenuChange">
|
||||
<!-- Navigation sheet of the collapsed nav (a phone, or a viewport where the
|
||||
links don't fit). Flex column so the link list scrolls instead of
|
||||
clipping on short screens when Advanced Tools is expanded. -->
|
||||
<Sheet v-if="isNavCollapsed" :open="isNavMenuOpen" @update:open="onNavMenuChange">
|
||||
<SheetContent side="left" class="w-80 p-0 flex flex-col gap-0" :title="t('nav.Navigation')">
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
|
||||
<h5 class="m-0 text-base font-semibold">{{ t('nav.Navigation') }}</h5>
|
||||
@@ -119,7 +126,7 @@
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<!-- All other sections, as on desktop. -->
|
||||
<!-- All other sections, as in the expanded nav. -->
|
||||
<a v-else href="#" :class="navLinkClass(item, { block: true })" @click.prevent="goToSection(item)">
|
||||
{{ t(`nav.${item}`) }}
|
||||
</a>
|
||||
@@ -176,6 +183,7 @@ import { formatStarCount } from '@/utils/format-star-count.js';
|
||||
import { isRunningAsPwa } from '@/utils/pwa.js';
|
||||
import { isPlainClick } from '@/utils/nav-target.js';
|
||||
import { useNavTarget } from '@/composables/use-nav-target.js';
|
||||
import { useNavOverflow } from '@/composables/use-nav-overflow.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
@@ -185,6 +193,19 @@ const { navigateTo } = useNavTarget();
|
||||
|
||||
const isDarkMode = computed(() => store.isDarkMode);
|
||||
const isMobile = computed(() => store.isMobile);
|
||||
|
||||
// The link row collapses into the hamburger + Sheet on a phone, or wherever
|
||||
// it doesn't fit beside the brand and actions. isMobile alone drives phone
|
||||
// behaviour (scroll-hide, PWA background).
|
||||
const NAV_GAP_PX = 8; // the nav's gap-2
|
||||
const navEl = ref(null);
|
||||
const brandEl = ref(null);
|
||||
const linksEl = ref(null);
|
||||
const actionsEl = ref(null);
|
||||
const { overflow } = useNavOverflow({
|
||||
nav: navEl, brand: brandEl, links: linksEl, actions: actionsEl, gap: NAV_GAP_PX,
|
||||
});
|
||||
const isNavCollapsed = computed(() => isMobile.value || overflow.value);
|
||||
// store.currentSection keeps Home's last section, so it's ignored elsewhere.
|
||||
const isHome = computed(() => route.name === 'home');
|
||||
const currentSection = computed(() => (isHome.value ? store.currentSection : null));
|
||||
@@ -203,7 +224,7 @@ const navItems = SECTION_IDS;
|
||||
const configs = computed(() => store.configs);
|
||||
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
|
||||
|
||||
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
|
||||
// Sheet: Advanced Tools sub-list expanded by default for discoverability.
|
||||
const mobileToolsOpen = ref(true);
|
||||
|
||||
// GitHub star count for the repo badge. Fetched from our own edge-cached
|
||||
@@ -252,7 +273,7 @@ const handleLogoClick = (e) => {
|
||||
trackEvent('Nav', 'NavClick', 'Logo');
|
||||
};
|
||||
|
||||
// Section and tool entries close the mobile nav Sheet first and navigate once
|
||||
// Section and tool entries close the nav Sheet first and navigate once
|
||||
// its slide-out is over: WebKit records the back/forward snapshot of a page
|
||||
// as it navigates away, and a swipe back would show the sheet still open.
|
||||
const SHEET_CLOSE_MS = 300; // ui/sheet data-[state=closed]:duration-300
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<!-- Nav entry point for the docs assistant. Desktop gets an ask box, mobile
|
||||
the same entry as a single icon. Both are inert while the panel itself
|
||||
is open (it owns the conversation then).
|
||||
<!-- Nav entry point for the docs assistant: an ask box from xl up, the same
|
||||
entry as a single icon below it (both rendered, CSS picks one, so the
|
||||
nav's action group never changes width with its own collapse). Both are
|
||||
inert while the panel itself is open (it owns the conversation then).
|
||||
The embed lives in widgets/DocsAssistant.vue; the loading and
|
||||
configuration in composables/use-docs-assistant.js. -->
|
||||
<form v-if="enabled && !isMobile" class="relative" @submit.prevent="submitDocsSearch">
|
||||
<form v-if="enabled" class="relative max-xl:hidden" @submit.prevent="submitDocsSearch">
|
||||
<Sparkle class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground z-10" />
|
||||
<!-- Focused, the native placeholder prompts for a question; idle, the
|
||||
rotating overlay below advertises what can be asked. -->
|
||||
@@ -27,8 +28,8 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<JnTooltip v-else-if="enabled" :text="t('nav.AskIPilot')">
|
||||
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" :aria-label="t('nav.AskIPilot')"
|
||||
<JnTooltip v-if="enabled" :text="t('nav.AskIPilot')">
|
||||
<Button variant="ghost" size="icon" class="size-8 cursor-pointer xl:hidden" :aria-label="t('nav.AskIPilot')"
|
||||
:disabled="isDocsOpen" @click="openAssistant">
|
||||
<Sparkle />
|
||||
</Button>
|
||||
@@ -50,7 +51,6 @@ const ROTATE_MS = 4000;
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
const isMobile = computed(() => store.isMobile);
|
||||
|
||||
// Two gates for visibility: a docs site to answer from (build time) and the
|
||||
// canonical deployment (same as the original-site-only advanced tools).
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Whether the site nav's link row fits beside the brand and the action
|
||||
// group. Collapse follows the measured content, not a breakpoint: link
|
||||
// labels differ widely in width across locales.
|
||||
|
||||
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const isSize = (n) => typeof n === 'number' && Number.isFinite(n);
|
||||
|
||||
// The nav lays out brand · links · actions with `gap` between each pair, so
|
||||
// the links get the nav width minus the other two groups and two gaps.
|
||||
// An unmeasured nav (a missing size, or a zero-width nav) counts as fitting,
|
||||
// so desktop never collapses on bad input.
|
||||
export const navLinksFit = ({ navWidth, brandWidth, actionsWidth, linksWidth, gap }) => {
|
||||
const sizes = [navWidth, brandWidth, actionsWidth, linksWidth, gap];
|
||||
if (!sizes.every(isSize) || navWidth <= 0) return true;
|
||||
return linksWidth <= navWidth - brandWidth - actionsWidth - 2 * gap;
|
||||
};
|
||||
|
||||
const widthOf = (el) => (el ? el.getBoundingClientRect().width : NaN);
|
||||
|
||||
// Template refs in, `overflow` out. The links element must stay rendered
|
||||
// (and keep its natural width) while collapsed so it can still be measured.
|
||||
export const useNavOverflow = ({ nav, brand, links, actions, gap }) => {
|
||||
const overflow = ref(false);
|
||||
const { locale } = useI18n();
|
||||
let observer = null;
|
||||
|
||||
const measure = () => {
|
||||
overflow.value = !navLinksFit({
|
||||
navWidth: widthOf(nav.value),
|
||||
brandWidth: widthOf(brand.value),
|
||||
actionsWidth: widthOf(actions.value),
|
||||
linksWidth: widthOf(links.value),
|
||||
gap,
|
||||
});
|
||||
};
|
||||
|
||||
// Synchronous first pass, so the first paint already has the right layout.
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
measure();
|
||||
observer = new ResizeObserver(measure);
|
||||
for (const el of [nav.value, brand.value, links.value, actions.value]) {
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
// Translated labels change the links' width; measure once they rendered.
|
||||
watch(locale, async () => {
|
||||
if (!observer) return;
|
||||
await nextTick();
|
||||
measure();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
});
|
||||
|
||||
return { overflow };
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
// Specs for navLinksFit in composables/use-nav-overflow.js — the width check
|
||||
// that decides whether the site nav's link row fits or collapses into the
|
||||
// hamburger. The ResizeObserver half of the composable is out of scope.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { navLinksFit } from '../frontend/composables/use-nav-overflow.js';
|
||||
|
||||
const sizes = (over = {}) => ({
|
||||
navWidth: 1200, brandWidth: 130, actionsWidth: 320, linksWidth: 700, gap: 8, ...over,
|
||||
});
|
||||
|
||||
describe('navLinksFit', () => {
|
||||
it('fits with room to spare', () => {
|
||||
assert.equal(navLinksFit(sizes()), true);
|
||||
});
|
||||
|
||||
it('treats an exact fit as a fit', () => {
|
||||
// 1200 - 130 - 320 - 2 * 8 = 734
|
||||
assert.equal(navLinksFit(sizes({ linksWidth: 734 })), true);
|
||||
});
|
||||
|
||||
it('overflows one pixel past the room left', () => {
|
||||
assert.equal(navLinksFit(sizes({ linksWidth: 735 })), false);
|
||||
});
|
||||
|
||||
it('counts the gap twice (brand · links · actions)', () => {
|
||||
// 1000 - 100 - 200 = 700 before gaps; one gap would leave 690, two leave 680.
|
||||
const base = { navWidth: 1000, brandWidth: 100, actionsWidth: 200, gap: 10 };
|
||||
assert.equal(navLinksFit({ ...base, linksWidth: 680 }), true);
|
||||
assert.equal(navLinksFit({ ...base, linksWidth: 681 }), false);
|
||||
assert.equal(navLinksFit({ ...base, linksWidth: 690 }), false);
|
||||
});
|
||||
|
||||
it('fits when a measurement is missing', () => {
|
||||
for (const key of ['navWidth', 'brandWidth', 'actionsWidth', 'linksWidth', 'gap']) {
|
||||
assert.equal(navLinksFit(sizes({ linksWidth: 5000, [key]: undefined })), true, key);
|
||||
assert.equal(navLinksFit(sizes({ linksWidth: 5000, [key]: NaN })), true, key);
|
||||
}
|
||||
assert.equal(navLinksFit({}), true);
|
||||
});
|
||||
|
||||
it('fits while the nav has no width yet', () => {
|
||||
assert.equal(navLinksFit(sizes({ navWidth: 0, linksWidth: 5000 })), true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user