Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 133 additions & 14 deletions apps/desktop/e2e/browser-chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
response.end(
path.endsWith('.js')
? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
: css.css
: `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
Comment thread
waleedlatif1 marked this conversation as resolved.
)
return
}
Expand All @@ -75,7 +75,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
response.end(
path === '/page'
? '<!doctype html><html><body style="background:#192b40;color:white;font:24px system-ui;padding:25px"><h1>Browser fixture</h1><p>A live page behind the application chrome.</p><button>Page action</button></body></html>'
: '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:#191919;color:#eee"><div id="root"></div><script src="/fixture.js"></script></body></html>'
: '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>'
)
})
await new Promise<void>((resolve) => server?.listen(0, resolve))
Expand Down Expand Up @@ -116,31 +116,150 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
return { width: bounds.width, right: bounds.right }
})
)
expect(geometry.every((tab) => tab.width >= 64 && tab.width < 160)).toBe(true)
expect(geometry.every((tab) => tab.width < 160)).toBe(true)
expect(geometry.at(-1)?.right).toBeLessThan(1070)
await page.screenshot({ path: testInfo.outputPath('tabs.png') })
})
await test.step('Short labels keep their compact intrinsic width', async () => {
await page.locator('#short-tabs').click()
const widths = await page
.locator('[data-tab-strip-item]')
.evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width))
expect(widths.every((width) => width >= 64 && width < 96)).toBe(true)
await page.locator('#eight-tabs').click()
})
await test.step('Crowded tabs preserve controls and scroll', async () => {
await test.step('Crowded tabs keep readable labels when selected, hovered, and focused', async () => {
await page.locator('#many-tabs').click()
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18)
const overflow = await page
.locator('[data-tab-strip-item]')
.first()
.evaluate((tab) => ({
width: tab.getBoundingClientRect().width,
scrollWidth: tab.parentElement?.scrollWidth ?? 0,
clientWidth: tab.parentElement?.clientWidth ?? 0,
}))
expect(overflow.width).toBeGreaterThanOrEqual(64)
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth)
const active = page.getByRole('tab', { selected: true })
const label = active.locator('[data-overflow-text]')
await expect
.poll(() => label.evaluate((element) => element.getBoundingClientRect().width))
.toBeGreaterThanOrEqual(48)
const neighbor = page.getByRole('tab').nth(2)
const beforeHover = await neighbor.evaluate((element: HTMLElement) => ({
left: element.offsetLeft,
width: element.offsetWidth,
}))
await neighbor.hover()
await expect
.poll(() =>
neighbor
.locator('[data-overflow-text]')
.evaluate((element) => element.getBoundingClientRect().width)
)
.toBeGreaterThanOrEqual(48)
expect(
await neighbor.evaluate((element: HTMLElement) => ({
left: element.offsetLeft,
width: element.offsetWidth,
}))
).toEqual(beforeHover)
await neighbor.click()
await expect(neighbor).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('End')
const last = page.getByRole('tab').last()
await expect(last).toBeFocused()
await expect(last).toHaveAttribute('aria-selected', 'true')
await expect(last).toBeInViewport({ ratio: 1 })
await expect
.poll(() => label.evaluate((element) => element.getBoundingClientRect().width))
.toBeGreaterThanOrEqual(48)
await page.mouse.move(200, 180)
await page.screenshot({
path: testInfo.outputPath('crowded-tabs.png'),
animations: 'disabled',
})
await page.evaluate(() => document.documentElement.classList.remove('dark'))
await page.screenshot({
path: testInfo.outputPath('crowded-tabs-light.png'),
animations: 'disabled',
})
await page.evaluate(() => document.documentElement.classList.add('dark'))
await page.keyboard.press('Home')
await expect(page.getByRole('tab').first()).toBeInViewport({ ratio: 1 })
await page.locator('#eight-tabs').click()
})
await test.step('Touch tabs leave room for both attention and close controls', async () => {
const session = await page.context().newCDPSession(page)
try {
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true })
expect(await page.evaluate(() => matchMedia('(any-pointer: coarse)').matches)).toBe(true)
await page.locator('#many-tabs').click()
const attention = page.getByRole('tab').nth(1)
await expect
.poll(() =>
attention
.locator('[data-overflow-text]')
.evaluate((element) => element.getBoundingClientRect().width)
)
.toBeGreaterThanOrEqual(48)
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
const attentionItem = page.locator('[data-tab-strip-item="tab-1"]')
const indicator = attentionItem.locator('[data-row-action-indicator]')
const controls = attentionItem.locator('[data-row-action-controls]')
await expect(indicator).toBeVisible()
await expect(indicator).toHaveCSS('opacity', '1')
await expect(controls).toHaveCSS('opacity', '1')
await attentionItem.getByRole('button', { name: /^Close / }).click({ trial: true })
expect(
await attentionItem.evaluate((element) => {
const title = element.querySelector('[data-overflow-text]')?.getBoundingClientRect()
const indicator = element
.querySelector('[data-row-action-indicator]')
?.getBoundingClientRect()
const close = element.querySelector('[aria-label^="Close "]')?.getBoundingClientRect()
return (
title &&
indicator &&
close &&
title.right <= indicator.left &&
indicator.right <= close.left &&
close.right <= element.getBoundingClientRect().right
)
})
).toBe(true)
await page.screenshot({ path: testInfo.outputPath('crowded-tabs-touch.png') })
const beforeSelection = await attention.evaluate(
(element: HTMLElement) => element.offsetWidth
)
await attention.click()
await expect(attention).toHaveAttribute('aria-selected', 'true')
expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe(
beforeSelection
)
await page.locator('#toggle-activity').click()
expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe(
beforeSelection
)
await page.locator('#toggle-activity').click()
await page.locator('#medium-tabs').click()
await page.getByRole('tab').first().click()
const intrinsicWidth = await attention.evaluate(
(element: HTMLElement) => element.offsetWidth
)
expect(intrinsicWidth).toBeGreaterThan(144)
expect(intrinsicWidth).toBeLessThan(200)
await attention.click()
expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe(
intrinsicWidth
)
await page.locator('#toggle-activity').click()
expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe(
intrinsicWidth
)
await page.locator('#toggle-activity').click()
} finally {
await session.send('Emulation.setTouchEmulationEnabled', { enabled: false })
await session.detach()
}
await page.locator('#eight-tabs').click()
})
await test.step('Short labels stay below the maximum tab width', async () => {
await page.locator('#short-tabs').click()
const widths = await page
.locator('[data-tab-strip-item]')
.evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width))
expect(widths.every((width) => width < 120)).toBe(true)
await page.locator('#eight-tabs').click()
})
await test.step('An open menu recovers when no native page was available for its initial capture', async () => {
Expand Down
27 changes: 23 additions & 4 deletions apps/desktop/e2e/fixtures/browser-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) {
const [activeTabId, setActiveTabId] = useState<string | null>(null)
const [selected, setSelected] = useState('tab-0')
const [tabCount, setTabCount] = useState(8)
const [shortTitles, setShortTitles] = useState(false)
const [titleLength, setTitleLength] = useState<'short' | 'medium' | 'long'>('long')
const [attention, setAttention] = useState(true)
const [error, setError] = useState<string | null>(null)
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
const { snapshot, snapshotLayer, onSnapshotError } = useOcclusion(
Expand Down Expand Up @@ -93,25 +94,43 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) {
id='eight-tabs'
onClick={() => {
setTabCount(8)
setShortTitles(false)
setTitleLength('long')
}}
>
Eight tabs
</Button>
<Button id='many-tabs' onClick={() => setTabCount(18)}>
Many tabs
</Button>
<Button id='short-tabs' onClick={() => setShortTitles(true)}>
<Button id='short-tabs' onClick={() => setTitleLength('short')}>
Short titles
</Button>
<Button
id='medium-tabs'
onClick={() => {
setTitleLength('medium')
setTabCount(4)
}}
>
Medium titles
</Button>
<Button id='toggle-activity' onClick={() => setAttention((value) => !value)}>
Toggle activity
</Button>
</div>
{error && <p role='alert'>{error}</p>}
<TabStrip
tabs={Array.from({ length: tabCount }, (_, index) => ({
id: `tab-${index}`,
title: shortTitles ? 'A' : `Example resource ${index + 1} with a descriptive title`,
title:
titleLength === 'short'
? 'A'
: titleLength === 'medium'
? 'Medium title'
: `Example resource ${index + 1} with a descriptive title`,
icon: <File className='size-[16px] shrink-0' />,
active: selected === `tab-${index}`,
attention: attention && index === 1,
}))}
variant='floating'
onSelect={setSelected}
Expand Down
80 changes: 26 additions & 54 deletions packages/emcn/src/components/tab-strip/tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {
useRef,
useState,
} from 'react'
import { OverflowText, RowActions, rowActionsGroupClass } from '@sim/emcn'
import {
OverflowText,
RowActions,
rowActionsGroupClass,
SCROLL_FADE_BAND_PX,
scrollFadeAttributes,
scrollFadeXClass,
} from '@sim/emcn'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { Plus, X } from '../../icons'
import { cn } from '../../lib/cn'
Expand All @@ -24,36 +31,6 @@ import { TabStripAction } from './tab-strip-action'

const DRAG_EDGE_ZONE = 40
const DRAG_SCROLL_SPEED = 8
/**
* Width of the scroll-edge fades, and so the margin a tab has to clear to be
* genuinely visible. Keep in step with the `w-4` on the gradients below: a tab
* revealed flush against the container edge lands under its gradient and reads
* as half-faded, which is indistinguishable from "there is more to scroll".
*/
const EDGE_FADE_PX = 24

/**
* Edge fades, as a mask rather than a tinted gradient laid over the tabs.
*
* Tabs paint their own fills, and an overlay tinted with the surface colour
* washes a pill's edge toward that colour instead of dissolving it — and it is
* only correct while whatever sits behind the strip is exactly that colour. A
* mask fades pill and label together to real transparency, over any background.
* This is how the command palette fades its results, and how every other
* horizontal fade in the app is drawn.
*
* The four combinations are spelled out because Tailwind scans for literal class
* strings; a template built at runtime would never be generated. Keep the 24px
* stops in step with {@link EDGE_FADE_PX}, which is how far `revealActiveTab`
* insets a tab so it lands clear of the fade rather than under it.
*/
const SCROLL_FADE = {
none: '',
start:
'[-webkit-mask-image:linear-gradient(to_right,transparent_0px,black_24px)] [mask-image:linear-gradient(to_right,transparent_0px,black_24px)]',
end: '[-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_24px),transparent_100%)] [mask-image:linear-gradient(to_right,black_calc(100%_-_24px),transparent_100%)]',
both: '[-webkit-mask-image:linear-gradient(to_right,transparent_0px,black_24px,black_calc(100%_-_24px),transparent_100%)] [mask-image:linear-gradient(to_right,transparent_0px,black_24px,black_calc(100%_-_24px),transparent_100%)]',
} as const
const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }

/**
Expand All @@ -62,13 +39,16 @@ const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const }
* the basis and left every tab sized by its own title.
*
* Floating tabs start at their content width, capped at 200px, then shrink with
* the available space. Floating tabs stop at a 64px control footprint; attached
* tabs retain their 96px label minimum. Crowded rows then scroll, and clipped
* titles remain available through tooltips.
* the available space. Their 112px minimum leaves 50px for the title beside a
* 16px icon and visible close button, including OverflowText's fade. Keep the
* same minimum in every interaction state so revealing actions never shifts
* tabs beneath the pointer. Touch layouts reserve both action slots even when
* no activity indicator is present, so activity changes cannot resize tabs.
* Crowded rows then scroll.
*/
const TAB_WIDTH: Record<TabStripVariant, string> = {
attached: 'w-[156px] min-w-[96px] shrink',
floating: 'min-w-[64px] max-w-[var(--tab-strip-max-tab-width,200px)] shrink',
floating: 'min-w-28 max-w-[var(--tab-strip-max-tab-width,200px)] shrink',
}

/** The resting shape of a tab that is not the active one. */
Expand Down Expand Up @@ -378,8 +358,7 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(function Tab(
tab.pinned ? 'justify-center px-0' : 'justify-start gap-1.5 px-2',
closeable && 'pr-8',
closeable &&
tab.attention &&
!tab.active &&
(variant === 'floating' || (tab.attention && !tab.active)) &&
'[@media(any-pointer:coarse)]:pr-[62px] [@media(hover:none)]:pr-[62px]',
TAB_SHAPE[variant],
tab.selected && !tab.active && TAB_SELECTED[variant],
Expand Down Expand Up @@ -427,9 +406,10 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(function Tab(
className={cn(
'group relative select-none',
rowActionsGroupClass,
// `shrink` lets a crowded strip squeeze tabs to their floor before it
// starts scrolling.
tab.pinned ? 'w-[34px] min-w-[34px] max-w-[34px] flex-none' : TAB_WIDTH[variant],
variant === 'floating' &&
closeable &&
'[@media(any-pointer:coarse)]:min-w-36 [@media(hover:none)]:min-w-36',
Comment thread
waleedlatif1 marked this conversation as resolved.
dragging && 'opacity-30'
)}
data-tab-strip-item={tab.id}
Expand Down Expand Up @@ -584,16 +564,15 @@ export function TabStrip({
const nodeRect = node.getBoundingClientRect()
const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
const tabRight = tabLeft + tabRect.width
// Inset by the fade on both sides so the tab comes to rest clear of the
// gradient rather than beneath it.
const viewLeft = node.scrollLeft + EDGE_FADE_PX
const viewRight = node.scrollLeft + node.clientWidth - EDGE_FADE_PX
/** Keep the active tab clear of the canonical scroll fade. */
const viewLeft = node.scrollLeft + SCROLL_FADE_BAND_PX
const viewRight = node.scrollLeft + node.clientWidth - SCROLL_FADE_BAND_PX
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth)
const target =
tabLeft < viewLeft
? tabLeft - EDGE_FADE_PX
? tabLeft - SCROLL_FADE_BAND_PX
: tabRight > viewRight
? tabRight - node.clientWidth + EDGE_FADE_PX
? tabRight - node.clientWidth + SCROLL_FADE_BAND_PX
: null
if (target === null) return
// The clamp is what lets the first and last tabs sit flush: there is no
Expand Down Expand Up @@ -926,18 +905,11 @@ export function TabStrip({
<div className='flex min-w-0 shrink'>
<div
ref={scrollNodeRef}
{...scrollFadeAttributes({ left: canScrollLeft, right: canScrollRight })}
className={cn(
'flex min-w-0 shrink select-none gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
variant === 'attached' ? 'items-end' : 'items-center gap-2',
SCROLL_FADE[
canScrollLeft
? canScrollRight
? 'both'
: 'start'
: canScrollRight
? 'end'
: 'none'
]
scrollFadeXClass
)}
>
<AnimatePresence initial={false} mode='popLayout'>
Expand Down
Loading