fix(web): 48px session search clear with calendar left of it (#2034)

* fix(web): 48px session search clear with calendar left of it (#2019)

Quest/touch: expanded search uses a min 48x48 clear end-cap as the
furthest-right control, with the calendar embedded immediately to its
left inside the field. Collapsed search stays shrink-0 with a larger
clear chip so it does not steal the toolbar row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): anchor date picker to search group; let collapsed chip shrink (#2019)

HAPI Bot: mount the calendar popup on the search control (not the 48px
button) so left/right align stays on-screen at narrow widths. Drop
shrink-0 on the collapsed group so the query chip can yield space while
calendar/clear stay fixed hit targets.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-10-15 19:15:50 +01:00
committed by GitHub
co-authored by Cursor
parent 7b00df458c
commit 317e39a0bb
2 changed files with 183 additions and 110 deletions
@@ -129,7 +129,8 @@ describe('SessionList directory action', () => {
fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL }))
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER)
const headerRow = searchInput.parentElement?.parentElement
// input → field row → session-list-search → header actions row
const headerRow = searchInput.parentElement?.parentElement?.parentElement
expect(headerRow).toHaveClass('px-2')
expect(headerRow).toHaveClass('py-1')
})
@@ -194,9 +195,10 @@ describe('SessionList time filter', () => {
const searchButton = screen.getByRole('button', { name: SEARCH_LABEL })
const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' })
const searchControl = searchButton.parentElement
expect(searchControl?.nextElementSibling).toBe(filterButton)
expect(searchControl?.parentElement).toBe(filterButton.parentElement)
expect(searchControl?.parentElement).toHaveClass('relative', 'gap-1')
expect(searchControl?.nextElementSibling).toBe(filterButton.parentElement)
expect(searchControl?.parentElement).toBe(filterButton.parentElement?.parentElement)
expect(searchControl?.parentElement).toHaveClass('relative', 'gap-1', 'min-w-0')
expect(searchControl?.parentElement?.className.split(/\s+/)).not.toContain('shrink-0')
expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull()
fireEvent.click(filterButton)
@@ -211,8 +213,8 @@ describe('SessionList time filter', () => {
expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Old session/ })).toBeNull()
expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull()
expect(filterButton).toHaveAttribute('title', '2026-07-17 – 2026-07-18')
expect(filterButton).toHaveAccessibleName('Filter sessions by last activity: 2026-07-17 – 2026-07-18')
expect(filterButton).toHaveAttribute('title', '2026-07-17 - 2026-07-18')
expect(filterButton).toHaveAccessibleName('Filter sessions by last activity: 2026-07-17 - 2026-07-18')
expect(filterButton).toHaveFocus()
})
@@ -277,7 +279,7 @@ describe('SessionList time filter', () => {
fireEvent.click(screen.getByRole('button', { name: `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity` }))
expect(filterButton).toHaveAttribute('aria-expanded', 'false')
expect(filterButton).toHaveAttribute('title', '2026-07-01 – 2026-07-18')
expect(filterButton).toHaveAttribute('title', '2026-07-01 - 2026-07-18')
})
it('returns focus to the search input after clearing the date range', () => {
@@ -1091,6 +1093,45 @@ describe('SessionList search toggle', () => {
expect(screen.getByRole('button', { name: /Other task/ })).toBeInTheDocument()
})
it('gives expanded search a 48px clear end-cap with calendar immediately left (tiann/hapi#2019)', () => {
renderWithProviders(
<SessionList
sessions={[makeSession({
id: 'session-1',
updatedAt: 100,
metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' },
})]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
fireEvent.click(screen.getByRole('button', { name: SEARCH_LABEL }))
const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER)
fireEvent.change(input, { target: { value: 'DJ' } })
const clearButton = screen.getByRole('button', { name: 'Clear search' })
expect(clearButton.className).toMatch(/session-list-search-clear-endcap/)
expect(clearButton.className).toMatch(/min-h-\[48px\]/)
expect(clearButton.className).toMatch(/min-w-\[48px\]/)
const dateButton = screen.getByTestId('session-list-search-date-embedded')
expect(dateButton.className).toMatch(/min-h-\[48px\]/)
expect(dateButton.className).toMatch(/min-w-\[48px\]/)
expect(clearButton.parentElement?.contains(dateButton)).toBe(true)
const fieldChildren = Array.from(clearButton.parentElement?.children ?? [])
const dateIndex = fieldChildren.findIndex((node) => node === dateButton.parentElement || node.contains(dateButton))
const clearIndex = fieldChildren.indexOf(clearButton)
expect(dateIndex).toBeGreaterThanOrEqual(0)
expect(clearIndex).toBeGreaterThan(dateIndex)
})
it('stays expanded with focus on the input after clearing the query', () => {
renderWithProviders(
<SessionList
+135 -103
View File
@@ -763,79 +763,109 @@ export function SessionListSearch(props: {
}
}, [props.expanded])
const renderDateFilter = (variant: 'standalone' | 'embedded') => {
const returnFocus = () => {
(variant === 'embedded' ? inputRef.current : dateButtonRef.current)?.focus()
const returnDateFocus = () => {
if (props.expanded) {
inputRef.current?.focus()
return
}
return (
<>
<button
ref={dateButtonRef}
type="button"
onClick={() => setDatePickerOpen(open => !open)}
className={cn(
'relative shrink-0 transition-colors hover:bg-[var(--app-subtle-bg)]',
variant === 'standalone'
? 'rounded-full p-1.5 hover:text-[var(--app-fg)]'
: 'flex items-center rounded-r-lg rounded-l-md px-1',
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
)}
title={hasDateRange ? `${props.customStart} – ${props.customEnd}` : t('sessions.timeFilter.label')}
aria-label={hasDateRange
? `${t('sessions.timeFilter.label')}: ${props.customStart} – ${props.customEnd}`
: t('sessions.timeFilter.label')}
aria-expanded={datePickerOpen}
>
<CalendarIcon className="h-5 w-5" />
{hasDateRange ? (
<span className={cn(
'absolute h-1.5 w-1.5 rounded-full bg-[var(--app-link)]',
variant === 'standalone' ? 'right-0.5 top-0.5' : 'right-1 top-1'
)} />
) : null}
</button>
{datePickerOpen ? (
<>
<button
type="button"
aria-label={t('sessions.timeFilter.close')}
className="fixed inset-0 z-20 cursor-default"
onClick={() => {
setDatePickerOpen(false)
returnFocus()
}}
/>
<SessionDateRangePicker
start={props.customStart}
end={props.customEnd}
sessionActivityDates={props.sessionActivityDates}
onChange={props.onDateRangeChange}
onClear={() => {
props.onDateRangeChange('', '')
// The footer Clear button unmounts once the range is
// empty; return focus so it does not drop to <body>.
returnFocus()
}}
onClose={() => {
setDatePickerOpen(false)
returnFocus()
}}
align={variant === 'standalone' ? 'left' : 'right'}
/>
</>
) : null}
</>
)
dateButtonRef.current?.focus()
}
// Layout A (#2019 / Quest): calendar sits INSIDE the expanded field,
// immediately left of the furthest-right 48px clear end-cap. Both hit
// targets are min 48x48 so the clear is no longer a ~14px absolute X.
// Date button only — picker mounts on the search-group container so
// left-0 / right-0 stay viewport-safe (not a 48px button containing block).
const renderDateButton = (variant: 'standalone' | 'embedded') => (
<div className={cn('shrink-0', variant === 'embedded' && 'flex items-stretch')}>
<button
ref={dateButtonRef}
type="button"
data-testid={variant === 'embedded'
? 'session-list-search-date-embedded'
: 'session-list-search-date-standalone'}
onClick={() => setDatePickerOpen(open => !open)}
className={cn(
'relative flex shrink-0 items-center justify-center transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] focus-visible:ring-inset',
variant === 'standalone'
? cn(
'min-h-[48px] min-w-[48px] rounded-full p-2',
'hover:bg-[var(--app-subtle-bg)]',
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
)
: cn(
'session-list-search-date-embedded min-h-[48px] min-w-[48px] border-l border-[var(--app-border)]',
'hover:bg-[var(--app-subtle-bg)]',
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
)
)}
title={hasDateRange
? `${props.customStart} - ${props.customEnd}`
: t('sessions.timeFilter.label')}
aria-label={hasDateRange
? `${t('sessions.timeFilter.label')}: ${props.customStart} - ${props.customEnd}`
: t('sessions.timeFilter.label')}
aria-expanded={datePickerOpen}
>
<CalendarIcon className="h-5 w-5 shrink-0" />
{hasDateRange ? (
<span
className={cn(
'absolute h-1.5 w-1.5 rounded-full bg-[var(--app-link)]',
variant === 'standalone' ? 'right-1 top-1' : 'right-1.5 top-1.5'
)}
aria-hidden="true"
/>
) : null}
</button>
</div>
)
const datePicker = datePickerOpen ? (
<>
<button
type="button"
aria-label={t('sessions.timeFilter.close')}
className="fixed inset-0 z-20 cursor-default"
onClick={() => {
setDatePickerOpen(false)
returnDateFocus()
}}
/>
<SessionDateRangePicker
start={props.customStart}
end={props.customEnd}
sessionActivityDates={props.sessionActivityDates}
onChange={props.onDateRangeChange}
onClear={() => {
props.onDateRangeChange('', '')
// The footer Clear button unmounts once the range is
// empty; return focus so it does not drop to <body>.
returnDateFocus()
}}
onClose={() => {
setDatePickerOpen(false)
returnDateFocus()
}}
align={props.expanded ? 'right' : 'left'}
/>
</>
) : null
const searchLabel = t('sessions.search.open')
if (!props.expanded) {
const hasTextQuery = props.value.length > 0
const collapsedLabel = hasTextQuery ? `${searchLabel}: ${props.value}` : searchLabel
return (
<div className="relative flex items-center gap-1">
// min-w-0 (no shrink-0): group can yield space to neighboring toolbar
// controls; calendar + clear stay shrink-0; query text truncates.
<div
data-testid="session-list-search"
data-expanded="false"
className="relative flex min-w-0 flex-nowrap items-center gap-1"
>
<div className={cn(
'relative flex min-w-0 items-center rounded-full transition-colors',
hasTextQuery
@@ -848,10 +878,10 @@ export function SessionListSearch(props: {
type="button"
onClick={() => props.onExpandedChange(true)}
className={cn(
'relative flex min-w-0 items-center gap-1 transition-colors',
'relative flex min-h-[40px] items-center gap-1 transition-colors',
hasTextQuery
? 'flex-1 rounded-l-full bg-[var(--app-chat-user-chip-bg)] px-2 py-1 text-[var(--app-chat-user-chip-fg)] hover:opacity-90'
: 'shrink-0 rounded-full p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]'
? 'min-w-0 flex-1 rounded-l-full bg-[var(--app-chat-user-chip-bg)] px-2 py-1 text-[var(--app-chat-user-chip-fg)] hover:opacity-90'
: 'min-w-[40px] shrink-0 rounded-full p-2 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]'
)}
title={collapsedLabel}
aria-label={collapsedLabel}
@@ -870,21 +900,24 @@ export function SessionListSearch(props: {
// the collapsed search trigger instead of dropping to body.
collapsedButtonRef.current?.focus()
}}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-r-full bg-[var(--app-chat-user-chip-action-bg)] text-[var(--app-chat-user-chip-action-fg)] transition-colors hover:text-[var(--app-chat-user-chip-action-hover-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] focus-visible:ring-inset"
className="flex h-10 w-10 min-h-[40px] min-w-[40px] shrink-0 items-center justify-center rounded-r-full bg-[var(--app-chat-user-chip-action-bg)] text-[var(--app-chat-user-chip-action-fg)] transition-colors hover:text-[var(--app-chat-user-chip-action-hover-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] focus-visible:ring-inset"
title={t('sessions.search.clear')}
aria-label={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
<XIcon className="h-4 w-4" />
</button>
) : null}
</div>
{renderDateFilter('standalone')}
{renderDateButton('standalone')}
{datePicker}
</div>
)
}
return (
<div
data-testid="session-list-search"
data-expanded="true"
className="relative min-w-0 flex-1"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
@@ -892,40 +925,39 @@ export function SessionListSearch(props: {
}
}}
>
<div className="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
</div>
<input
ref={inputRef}
type="search"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
placeholder={t('sessions.search.placeholder')}
aria-label={searchLabel}
title={searchLabel}
className={cn(
'w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] [text-overflow:ellipsis] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden',
props.value ? 'pr-16' : 'pr-7'
)}
/>
{props.value ? (
<button
type="button"
onClick={() => {
props.onChange('')
// The clear button unmounts with the query; keep focus off <body>
// so a later outside click still routes blur through the wrapper.
inputRef.current?.focus()
}}
className="absolute inset-y-0 right-9 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
</button>
) : null}
<div className="absolute inset-y-0 right-0 flex items-stretch">
{renderDateFilter('embedded')}
<div className="relative flex min-h-12 min-w-0 items-stretch rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] transition-colors focus-within:border-[var(--app-link)]">
<div className="pointer-events-none flex shrink-0 items-center pl-2.5 text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
</div>
<input
ref={inputRef}
type="search"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
placeholder={t('sessions.search.placeholder')}
aria-label={searchLabel}
title={searchLabel}
className="min-w-0 flex-1 appearance-none bg-transparent px-2 py-1.5 text-sm text-[var(--app-fg)] outline-none placeholder:text-[var(--app-hint)] [text-overflow:ellipsis] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
/>
{renderDateButton('embedded')}
{props.value ? (
<button
type="button"
onClick={() => {
props.onChange('')
// The clear button unmounts with the query; keep focus off <body>
// so a later outside click still routes blur through the wrapper.
inputRef.current?.focus()
}}
className="session-list-search-clear-endcap flex min-h-[48px] min-w-[48px] shrink-0 items-center justify-center border-l border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] focus-visible:ring-inset"
title={t('sessions.search.clear')}
aria-label={t('sessions.search.clear')}
>
<XIcon className="h-5 w-5" />
</button>
) : null}
</div>
{datePicker}
</div>
)
}