diff --git a/src/app/baseball/(dashboard)/_components/hub-sub-nav.test.tsx b/src/app/baseball/(dashboard)/_components/hub-sub-nav.test.tsx new file mode 100644 index 000000000..36aab9732 --- /dev/null +++ b/src/app/baseball/(dashboard)/_components/hub-sub-nav.test.tsx @@ -0,0 +1,54 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/_components/hub-sub-nav.test.tsx +// +// #905 — pins the fix for "Operations"/"Postgame Review" tabs clipping past +// the viewport edge at 320/390px. `shrink-0` gave every tab the flex item's +// default `min-width: auto` floor (its full `whitespace-nowrap` label width), +// so even a hub capped at ≤3 tabs (Ruling 2) could exceed the viewport once +// icon + padding + a longer label were summed — and because +// `getBoundingClientRect` reflects LAYOUT position, not ancestor `overflow` +// clipping, the strip's own `overflow-x-auto` didn't hide the defect from a +// geometry-based clip check. This test locks in that tabs are shrinkable +// (`min-w-0`, not `shrink-0`) with a truncating label — same fix class as the +// FairwayBottomNav min-w-0 fix (#899). +// ============================================================================= + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HubSubNav } from './hub-sub-nav'; +import type { HubSubNavTab } from './hub-sub-nav'; +import { IconUsers } from '@/components/icons'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/baseball/dashboard/roster'), +})); + +const TABS: HubSubNavTab[] = [ + { id: 'roster', label: 'Roster', href: '/baseball/dashboard/roster', icon: IconUsers }, + { id: 'calendar', label: 'Calendar', href: '/baseball/dashboard/calendar', icon: IconUsers }, + { id: 'operations', label: 'Operations', href: '/baseball/dashboard/operations', icon: IconUsers }, +]; + +describe('HubSubNav — #905 shrinkable tabs', () => { + it('does not pin tabs to shrink-0 (the min-width:auto floor that clipped past the viewport)', () => { + const { container } = render(); + const items = container.querySelectorAll('li'); + expect(items.length).toBeGreaterThan(0); + for (const el of Array.from(items)) { + expect((el as HTMLElement).className).not.toContain('shrink-0'); + expect((el as HTMLElement).className).toContain('min-w-0'); + } + }); + + it('truncates each tab label so it can shrink to fit instead of overflowing', () => { + render(); + const label = screen.getByText('Operations'); + expect(label.className).toContain('truncate'); + expect(label.className).toContain('min-w-0'); + }); + + it('still renders every tab as a real link', () => { + render(); + expect(screen.getByRole('link', { name: /Operations/ })).toBeInTheDocument(); + }); +}); diff --git a/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx b/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx index ce1bf89f4..c0ce608c6 100644 --- a/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx +++ b/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx @@ -257,7 +257,23 @@ export function HubSubNav({ tabs, ariaLabel, className }: HubSubNavProps) { const isActive = t.id === resolvedId; const Icon = t.icon; return ( -
  • + // min-w-0 (not shrink-0): #905 — at 320/390px a hub capped at + // ≤3 tabs (Ruling 2) can still exceed the viewport once icon + + // padding + a longer label ("Postgame Review", "Operations") + // are summed (e.g. Stats & Performance's 3 tabs measure to + // ~424px unshrunk at a 390px viewport). `shrink-0` gave every + // tab the flex item's default `min-width: auto` floor (its + // full unbreakable `whitespace-nowrap` label width), so the + // LAST tab's own bounding rect — not just the scrollable + // strip's content — bled past the viewport edge even though + // `overflow-x-auto` visually clipped it (getBoundingClientRect + // reflects layout position, not ancestor clipping). `min-w-0` + // restores the default `flex-shrink: 1` floor to zero so the + // label's `truncate` (below) can actually engage — tabs now + // shrink-to-fit inside the viewport first, falling back to the + // strip's horizontal scroll only once even truncated tabs + // don't fit (mirrors the FairwayBottomNav min-w-0 fix, #899). +
  • { @@ -268,7 +284,18 @@ export function HubSubNav({ tabs, ariaLabel, className }: HubSubNavProps) { onKeyDown={onKeyDown} data-active={isActive ? '' : undefined} className={cn( - 'group relative inline-flex select-none items-center gap-2 whitespace-nowrap', + // `m-0` (#927): globals.css's blanket `li a` "inline link + // touch target" rule (meant for prose body text) matches + // this `` too — a plain anchor inside an `
  • ` — and, + // with no margin utility here to out-specificity it, + // applied its `margin: -0.375rem -0.125rem` unchallenged: + // +4px width and a 2px left shift on every tab, on top of + // whatever this tab's own content needed. `m-0` (a class + // selector) beats the rule's `li a` (two type selectors) + // on specificity and neutralizes it; the global rule is + // now also scoped to exclude `nav`-shaped anchors (this + // strip is a `