Skip to content

Commit b5eac08

Browse files
authored
Fix breadcrumb scroll button: one-crumb nudge and opaque hover (#62422)
1 parent befa830 commit b5eac08

3 files changed

Lines changed: 125 additions & 6 deletions

File tree

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,81 @@ test.describe('test nav at different viewports', () => {
732732
})
733733
})
734734

735+
test.describe('secondary-bar breadcrumb scroller', () => {
736+
// The secondary bar (and its breadcrumb scroller) only renders at wide
737+
// viewports, and the fixture trail is short enough to fit there, so we cap the
738+
// scroller width to force a deterministic overflow independent of title
739+
// lengths — then exercise the chevrons.
740+
test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => {
741+
// Smooth-scroll settle waits across several chevron clicks add up past the
742+
// default 5s cap.
743+
test.setTimeout(20000)
744+
page.setViewportSize({ width: 1300, height: 700 })
745+
await page.goto('/get-started/foo/bar')
746+
747+
const bar = page.getByTestId('breadcrumbs-bar')
748+
await expect(bar).toBeVisible()
749+
750+
const scrollArea = page.locator('[data-search="breadcrumbs"]')
751+
await expect(scrollArea).toBeVisible()
752+
753+
// Force a deterministic overflow independent of title lengths: cap the
754+
// scroll region, drop the nav's min-width:100% (which otherwise stretches the
755+
// short fixture trail to fill the container so it never overflows), and pad
756+
// the crumbs so several are hidden at once — enough that a per-crumb nudge is
757+
// distinguishable from a jump to the end.
758+
await page.addStyleTag({
759+
content: `
760+
[data-search="breadcrumbs"] { max-width: 360px; }
761+
[data-search="breadcrumbs"] nav { min-width: 0 !important; }
762+
[data-search="breadcrumbs"] li { padding-right: 60px; }
763+
`,
764+
})
765+
766+
const scrollLeftOf = () => scrollArea.evaluate((el) => el.scrollLeft)
767+
const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth)
768+
await expect.poll(maxScrollOf).toBeGreaterThan(0)
769+
770+
// Anchor to the right end explicitly so we start from a known state: fully
771+
// scrolled right (current page visible), only the left chevron active.
772+
await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' }))
773+
const maxScroll = await maxScrollOf()
774+
await expect.poll(scrollLeftOf).toBe(maxScroll)
775+
776+
const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' })
777+
const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' })
778+
// At the right extreme the left chevron is active and the right one is hidden.
779+
await expect(leftChevron).toBeVisible()
780+
await expect(rightChevron).toBeHidden()
781+
782+
// One left click nudges toward the start by a single crumb — it must move,
783+
// but must NOT jump all the way to 0 (the old behavior) while more than one
784+
// crumb is still hidden to the left.
785+
await leftChevron.click()
786+
await expect.poll(scrollLeftOf).toBeLessThan(maxScroll)
787+
const afterOneLeft = await scrollLeftOf()
788+
expect(afterOneLeft).toBeGreaterThan(0)
789+
// The right chevron appears once we're no longer at the right extreme.
790+
await expect(rightChevron).toBeVisible()
791+
792+
// A right click walks back toward the current page by one crumb, not a full
793+
// jump back to the right extreme.
794+
await rightChevron.click()
795+
await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft)
796+
797+
// Repeated left clicks eventually reach the start, which hides the left
798+
// chevron (canScrollLeft flips false). Drive off the chevron's own visibility
799+
// rather than an exact scrollLeft, since smooth scrolling can leave a
800+
// sub-pixel remainder.
801+
for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) {
802+
await leftChevron.click()
803+
await page.waitForTimeout(200)
804+
}
805+
await expect(leftChevron).toBeHidden()
806+
await expect.poll(scrollLeftOf).toBeLessThanOrEqual(1)
807+
})
808+
})
809+
735810
test.describe('survey', () => {
736811
test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled')
737812

src/frame/components/page-header/BreadcrumbsScroller.module.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@
3838
background-color: var(--bgColor-default, var(--color-canvas-default));
3939
}
4040

41+
// On hover, keep the solid canvas background (Primer's invisible IconButton
42+
// otherwise swaps in a translucent tint, letting crumbs bleed through the
43+
// button) and instead signal interactivity by bolding the chevron: a
44+
// currentColor stroke thickens the fill-based octicon glyph.
45+
.leftChevron:hover,
46+
.rightChevron:hover {
47+
background-color: var(
48+
--bgColor-default,
49+
var(--color-canvas-default)
50+
) !important;
51+
color: var(--fgColor-default, var(--color-fg-default));
52+
53+
svg {
54+
stroke: currentColor;
55+
stroke-width: 0.75px;
56+
}
57+
}
58+
4159
// Hidden chevrons are removed from view and interaction (and from the tab
4260
// order / a11y tree in the component). Because they're absolutely positioned
4361
// they already occupy no layout space, so the scroll area is unaffected.

src/frame/components/page-header/BreadcrumbsScroller.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { Breadcrumbs } from './Breadcrumbs'
99

1010
import styles from './BreadcrumbsScroller.module.scss'
1111

12+
// Padding past the ~28px chevron so a revealed crumb clears it rather than
13+
// landing half-hidden underneath.
14+
const CHEVRON_PAD = 32
15+
1216
// Wraps the secondary-bar breadcrumbs in a horizontal scroll region. When the
1317
// trail is too long to fit, the region scrolls — anchored to the RIGHT so the
1418
// current page is shown first — with a left chevron to reveal the leftmost
@@ -67,20 +71,42 @@ export const BreadcrumbsScroller = () => {
6771
return () => observer.disconnect()
6872
}, [anchorRight])
6973

70-
// Reveal the start of the trail (Home) in one click. Breadcrumb trails are
71-
// short, so scrolling fully to the left is clearer than a partial nudge that
72-
// can leave the leading crumb half-hidden behind the chevron.
74+
// Reveal the previous crumb: align the crumb straddling the left edge to that
75+
// edge. Targeting the boundary crumb (not stepping back from the first fully
76+
// visible one) also covers a current crumb wider than the viewport. Falls back
77+
// to the far left only when nothing is clipped left.
7378
const scrollLeftClick = useCallback(() => {
7479
const el = scrollRef.current
7580
if (!el) return
76-
el.scrollTo({ left: 0 })
81+
const containerLeft = el.getBoundingClientRect().left + CHEVRON_PAD
82+
const crumbs = Array.from(el.querySelectorAll('li'))
83+
// The rightmost crumb still clipped/off to the left: the one to reveal.
84+
let target: Element | undefined
85+
for (const crumb of crumbs) {
86+
if (crumb.getBoundingClientRect().left < containerLeft - 1) target = crumb
87+
}
88+
if (!target) {
89+
el.scrollTo({ left: 0 })
90+
return
91+
}
92+
el.scrollBy({ left: target.getBoundingClientRect().left - containerLeft })
7793
}, [])
7894

79-
// Return to the current page (the end of the trail) in one click.
95+
// Reveal the next crumb toward the current page. Mirrors scrollLeftClick,
96+
// including the oversized-crumb case. Falls back to the far right only when
97+
// nothing is clipped right.
8098
const scrollRightClick = useCallback(() => {
8199
const el = scrollRef.current
82100
if (!el) return
83-
el.scrollTo({ left: el.scrollWidth })
101+
const containerRight = el.getBoundingClientRect().right - CHEVRON_PAD
102+
const crumbs = Array.from(el.querySelectorAll('li'))
103+
// The leftmost crumb still clipped/off to the right: the one to reveal.
104+
const target = crumbs.find((crumb) => crumb.getBoundingClientRect().right > containerRight + 1)
105+
if (!target) {
106+
el.scrollTo({ left: el.scrollWidth })
107+
return
108+
}
109+
el.scrollBy({ left: target.getBoundingClientRect().right - containerRight })
84110
}, [])
85111

86112
// When a crumb link receives focus via keyboard (Tab), the browser does not

0 commit comments

Comments
 (0)