mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-15 03:01:39 +00:00
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:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user