Skip to content

Commit 6997f87

Browse files
committed
Add 'x' close button to search bar
1 parent a41fd8b commit 6997f87

10 files changed

Lines changed: 143 additions & 30 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,7 @@ Maybe change the "ready..." in the hero to "get in touch..."
106106

107107
## Search Box
108108

109-
2. Need to get the "x" back to clear search terms
110109
3. Maybe need a 1px border
111-
4. Hide the nav link items on desktop and the company name is hidden on mobile when the search box is out
112-
5. Make sure state is being kept in nanostores
113110
6. What happens when search query exceeds size of search box? Drop down to second line?
114111
7. Need to improve quality of search results in drop down box - maybe some description text for each item
115112
8. Style drop down box of search results from search box

‎src/components/Button/server/__tests__/index.spec.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,32 @@ import { buildButtonClassList, resolveAriaLabel, isIconOnly } from '../index'
33

44
type ClassList = Record<string, boolean>
55

6+
const hasAnyPaddingClasses = (classList: ClassList) => {
7+
const classNames = Object.keys(classList)
8+
return classNames.some(className => /(^|:)(p[xy])-/.test(className))
9+
}
10+
611
describe('Button server helpers', () => {
712
describe('buildButtonClassList', () => {
813
it('includes base, size, and variant classes', () => {
9-
const classList = buildButtonClassList({
14+
const largeSecondary = buildButtonClassList({
1015
variant: 'secondary',
1116
size: 'large',
1217
})
1318

14-
expect(classList).toMatchObject<ClassList>({
19+
const smallSecondary = buildButtonClassList({
20+
variant: 'secondary',
21+
size: 'small',
22+
})
23+
24+
// Avoid brittle assertions on exact Tailwind spacing values; verify stable invariants instead.
25+
expect(largeSecondary).toMatchObject<ClassList>({
1526
'inline-flex': true,
16-
'py-4': true,
17-
'px-8': true,
1827
'bg-secondary': true,
1928
})
29+
30+
expect(hasAnyPaddingClasses(largeSecondary)).toBe(true)
31+
expect(largeSecondary).not.toEqual(smallSecondary)
2032
})
2133

2234
it('appends additional classes when provided', () => {

‎src/components/Header/index.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const { path } = Astro.props
2020
--circle-stroke: calc(var(--header-icon-size) * 2 * 3.1416);
2121
--header-search-panel-width: 18rem;
2222
--header-search-panel-overhang: calc(var(--header-icon-size) + 0.5rem);
23-
--header-search-panel-width-open: calc(var(--header-search-panel-width) + var(--header-search-panel-overhang));
23+
--header-search-panel-width-open: var(--header-search-panel-width);
2424
--header-search-panel-right: calc(-1 * var(--header-search-panel-overhang));
2525
}
2626

‎src/components/Search/SearchBar/client/__tests__/index.spec.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,54 @@ describe('SearchBar web component', () => {
136136
await runHeaderComponentRender(async ({ element, window }) => {
137137
const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement
138138
const input = element.querySelector('[data-search-input]') as HTMLInputElement
139+
const clearBtn = element.querySelector('[data-search-clear]') as HTMLButtonElement
139140

140141
expect(toggleBtn.getAttribute('aria-expanded')).toBe('false')
141142
expect(input.classList.contains('hidden')).toBe(true)
143+
expect(toggleBtn.hasAttribute('hidden')).toBe(false)
144+
expect(clearBtn.hasAttribute('hidden')).toBe(true)
142145

143146
toggleBtn.click()
144147
await flushMicrotasks()
145148

146149
expect(toggleBtn.getAttribute('aria-expanded')).toBe('true')
147150
expect(input.classList.contains('hidden')).toBe(false)
151+
expect(toggleBtn.hasAttribute('hidden')).toBe(true)
152+
expect(clearBtn.hasAttribute('hidden')).toBe(false)
148153

149154
input.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
150155
await flushMicrotasks()
151156

152157
expect(toggleBtn.getAttribute('aria-expanded')).toBe('false')
153158
expect(input.classList.contains('hidden')).toBe(true)
159+
expect(toggleBtn.hasAttribute('hidden')).toBe(false)
160+
expect(clearBtn.hasAttribute('hidden')).toBe(true)
161+
})
162+
})
163+
164+
it('shows a custom clear button and clears the query in header variant', async () => {
165+
await runHeaderComponentRender(async ({ element, window }) => {
166+
const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement
167+
const input = element.querySelector('[data-search-input]') as HTMLInputElement
168+
const clearBtn = element.querySelector('[data-search-clear]') as HTMLButtonElement
169+
170+
expect(clearBtn.hasAttribute('hidden')).toBe(true)
171+
172+
toggleBtn.click()
173+
await flushMicrotasks()
174+
175+
input.value = 'ab'
176+
input.dispatchEvent(new window.Event('input', { bubbles: true }))
177+
await flushMicrotasks()
178+
179+
expect(clearBtn.hasAttribute('hidden')).toBe(false)
180+
181+
clearBtn.click()
182+
await flushMicrotasks()
183+
184+
expect(input.value).toBe('')
185+
// Still open; button switches to "Close search" mode.
186+
expect(clearBtn.hasAttribute('hidden')).toBe(false)
154187
})
155188
})
156189
})

‎src/components/Search/SearchBar/client/index.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class SearchBarElement extends LitElement {
2424

2525
private toggleBtn: HTMLButtonElement | null = null
2626
private panel: HTMLElement | null = null
27+
private clearBtn: HTMLButtonElement | null = null
2728
private isExpanded = true
2829

2930
private isOutsideListenersAttached = false
@@ -61,7 +62,7 @@ export class SearchBarElement extends LitElement {
6162

6263
private cacheElements(): void {
6364
const { form, input, resultsContainer, resultsList } = getSearchBarElements(this)
64-
const { toggleBtn, panel } = getSearchBarOptionalElements(this)
65+
const { toggleBtn, panel, clearBtn } = getSearchBarOptionalElements(this)
6566

6667
this.form = form
6768
this.input = input
@@ -70,7 +71,10 @@ export class SearchBarElement extends LitElement {
7071

7172
this.toggleBtn = toggleBtn
7273
this.panel = panel
74+
this.clearBtn = clearBtn
7375
this.isExpanded = this.toggleBtn ? getHeaderSearchExpanded() : this.getIsExpandedFromDom()
76+
77+
this.updateClearButtonVisibility()
7478
}
7579

7680
private initHeaderExpandedState(): void {
@@ -144,6 +148,11 @@ export class SearchBarElement extends LitElement {
144148
this.toggleBtn.dataset['searchListener'] = 'true'
145149
}
146150

151+
if (this.clearBtn && !this.clearBtn.dataset['searchListener']) {
152+
this.clearBtn.addEventListener('click', this.handleClearClick)
153+
this.clearBtn.dataset['searchListener'] = 'true'
154+
}
155+
147156
if (!this.dataset['searchKeyListener']) {
148157
this.addEventListener('keydown', this.handleKeyDown)
149158
this.dataset['searchKeyListener'] = 'true'
@@ -164,6 +173,7 @@ export class SearchBarElement extends LitElement {
164173
if (this.toggleBtn) {
165174
this.toggleBtn.setAttribute('aria-expanded', isExpanded ? 'true' : 'false')
166175
this.toggleBtn.setAttribute('data-state', isExpanded ? 'open' : 'closed')
176+
this.toggleBtn.toggleAttribute('hidden', isExpanded)
167177
}
168178

169179
const state = isExpanded ? 'open' : 'closed'
@@ -185,6 +195,19 @@ export class SearchBarElement extends LitElement {
185195
} else {
186196
this.attachOutsideListeners()
187197
}
198+
199+
this.updateClearButtonVisibility()
200+
}
201+
202+
private updateClearButtonVisibility(): void {
203+
if (!this.clearBtn) {
204+
return
205+
}
206+
207+
const query = this.getQuery()
208+
const shouldShow = this.isExpanded
209+
this.clearBtn.toggleAttribute('hidden', !shouldShow)
210+
this.clearBtn.setAttribute('aria-label', query.length > 0 ? 'Clear search' : 'Close search')
188211
}
189212

190213
private expand(): void {
@@ -338,6 +361,8 @@ export class SearchBarElement extends LitElement {
338361
}
339362

340363
private readonly handleInput = () => {
364+
this.updateClearButtonVisibility()
365+
341366
const query = this.getQuery()
342367

343368
if (!query || query.length < 2) {
@@ -359,6 +384,25 @@ export class SearchBarElement extends LitElement {
359384
return (this.input?.value ?? '').trim()
360385
}
361386

387+
private readonly handleClearClick = () => {
388+
if (!this.input) {
389+
return
390+
}
391+
392+
const query = this.getQuery()
393+
394+
if (query.length === 0) {
395+
this.collapse({ restoreFocus: true })
396+
return
397+
}
398+
399+
this.input.value = ''
400+
this.updateClearButtonVisibility()
401+
this.clearResults()
402+
this.hideResults()
403+
this.input.focus()
404+
}
405+
362406
private showResults(): void {
363407
this.resultsContainer?.classList.remove('hidden')
364408
}

‎src/components/Search/SearchBar/client/selectors.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const SELECTORS = {
1111
export const OPTIONAL_SELECTORS = {
1212
toggleBtn: '[data-search-toggle]',
1313
panel: '[data-search-panel]',
14+
clearBtn: '[data-search-clear]',
1415
} as const
1516

1617
/**
@@ -64,9 +65,11 @@ export function getSearchBarElements(context: Element) {
6465
export function getSearchBarOptionalElements(context: Element) {
6566
const toggleBtn = context.querySelector(OPTIONAL_SELECTORS.toggleBtn)
6667
const panel = context.querySelector(OPTIONAL_SELECTORS.panel)
68+
const clearBtn = context.querySelector(OPTIONAL_SELECTORS.clearBtn)
6769

6870
return {
6971
toggleBtn: toggleBtn instanceof HTMLButtonElement ? toggleBtn : null,
7072
panel: panel instanceof HTMLElement ? panel : null,
73+
clearBtn: clearBtn instanceof HTMLButtonElement ? clearBtn : null,
7174
}
7275
}

‎src/components/Search/SearchBar/index.astro‎

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
22
import Icon from '@components/Icon/index.astro'
3+
import closeSvg from '../../../icons/close.svg?raw'
4+
import styles from './index.module.css'
35
46
export interface Props {
57
placeholder?: string
@@ -17,29 +19,15 @@ const {
1719
1820
const isHeaderVariant = variant === 'header'
1921
const isExpandedOnLoad = isHeaderVariant ? initialExpanded || initialQuery.trim().length > 0 : true
22+
const hasInitialQuery = initialQuery.trim().length > 0
23+
const clearButtonLabel = hasInitialQuery ? 'Clear search' : 'Close search'
2024
---
2125

22-
<style>
23-
search-bar[data-search-variant='header'] button[data-search-toggle][data-state='open'] .icon--search {
24-
color: var(--color-primary);
25-
fill: var(--color-primary);
26-
}
27-
28-
/* Tailwind Forms normalizes inputs with appearance resets, which removes the native
29-
type="search" clear (X). Restore searchfield appearance for the header search. */
30-
search-bar[data-search-variant='header'] input[type='search'] {
31-
-webkit-appearance: searchfield;
32-
appearance: searchfield;
33-
}
34-
35-
search-bar[data-search-variant='header'] input[type='search']::-webkit-search-cancel-button {
36-
height: 1.25rem;
37-
width: 1.25rem;
38-
cursor: pointer;
39-
}
40-
</style>
41-
42-
<search-bar class="relative block" data-testid="search-bar" data-search-variant={variant}>
26+
<search-bar
27+
class:list={['relative block', styles.searchBar]}
28+
data-testid="search-bar"
29+
data-search-variant={variant}
30+
>
4331
{
4432
isHeaderVariant ? (
4533
<div
@@ -68,12 +56,23 @@ const isExpandedOnLoad = isHeaderVariant ? initialExpanded || initialQuery.trim(
6856
]}
6957
/>
7058

59+
<button
60+
data-search-clear
61+
type="button"
62+
aria-label={clearButtonLabel}
63+
hidden={!isExpandedOnLoad}
64+
class="relative inline-flex items-center justify-center w-(--header-icon-size) h-(--header-icon-size) shrink-0 rounded-full border-0 bg-transparent text-content-active hover:text-primary focus:outline-none focus-visible:text-primary transition-colors duration-150 ease-linear"
65+
>
66+
<span aria-hidden="true" set:html={closeSvg} />
67+
</button>
68+
7169
<button
7270
data-search-toggle
7371
type="button"
7472
aria-label="Search"
7573
aria-expanded={isExpandedOnLoad ? 'true' : 'false'}
7674
data-state={isExpandedOnLoad ? 'open' : 'closed'}
75+
hidden={isExpandedOnLoad}
7776
class="relative inline-flex items-center justify-center w-(--header-icon-size) h-(--header-icon-size) shrink-0 rounded-full border-0 bg-primary hover:bg-primary-offset focus:bg-primary-offset focus:outline-none focus-visible:bg-primary-offset transition-colors duration-150 ease-linear data-[state=open]:bg-transparent data-[state=open]:hover:bg-transparent data-[state=open]:focus:bg-transparent data-[state=open]:focus-visible:bg-transparent"
7877
>
7978
<Icon
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.searchBar[data-search-variant='header'] button[data-search-toggle][data-state='open'] .icon--search {
2+
color: var(--color-primary);
3+
fill: var(--color-primary);
4+
}
5+
6+
/* Keep behavior consistent across browsers by disabling the native search clear control.
7+
We'll render our own clear button instead. */
8+
.searchBar[data-search-variant='header'] input[type='search']::-webkit-search-cancel-button {
9+
display: none;
10+
}
11+
12+
.searchBar[data-search-variant='header'] button[data-search-clear] svg {
13+
display: block;
14+
height: calc(var(--header-icon-svg-size) * 0.75);
15+
width: calc(var(--header-icon-svg-size) * 0.75);
16+
fill: currentColor;
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare const styles: {
2+
readonly searchBar: string
3+
}
4+
5+
export default styles

‎src/components/scripts/bootstrap/__tests__/index.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('@components/scripts/errors', async () => {
2424
vi.mock('@components/scripts/store', () => ({
2525
initConsentFromCookies: vi.fn(),
2626
initConsentSideEffects: vi.fn(),
27+
initHeaderSearchSideEffects: vi.fn(),
2728
initAnimationLifecycle: vi.fn(),
2829
exposeStoreActionsForTesting: vi.fn(),
2930
$hasFunctionalConsent: {
@@ -53,6 +54,7 @@ vi.mock('@components/scripts/utils/environmentClient', () => mockEnvironmentClie
5354
import {
5455
initConsentFromCookies,
5556
initConsentSideEffects,
57+
initHeaderSearchSideEffects,
5658
initAnimationLifecycle,
5759
exposeStoreActionsForTesting,
5860
} from '@components/scripts/store'
@@ -77,6 +79,7 @@ const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
7779
const mockSuccessfulInit = () => {
7880
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
7981
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
82+
vi.mocked(initHeaderSearchSideEffects).mockReturnValue(undefined)
8083
vi.mocked(initAnimationLifecycle).mockReturnValue(undefined)
8184
vi.mocked(exposeStoreActionsForTesting).mockReturnValue(undefined)
8285
}

0 commit comments

Comments
 (0)