Skip to content

Commit a41fd8b

Browse files
committed
Move search bar state to persistent atom nanostore in scripts/stores, hide nav item links on desktop and wordmarks on mobile when search bar open to create space in UI
1 parent 578cf76 commit a41fd8b

9 files changed

Lines changed: 302 additions & 7 deletions

File tree

‎_TODO.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ Maybe change the "ready..." in the hero to "get in touch..."
106106

107107
## Search Box
108108

109-
1. Need to make sure we have an action to update the search index on content changes
110109
2. Need to get the "x" back to clear search terms
111110
3. Maybe need a 1px border
112111
4. Hide the nav link items on desktop and the company name is hidden on mobile when the search box is out
@@ -115,6 +114,8 @@ Maybe change the "ready..." in the hero to "get in touch..."
115114
7. Need to improve quality of search results in drop down box - maybe some description text for each item
116115
8. Style drop down box of search results from search box
117116

117+
Title box - need to squish to 75% and have it absolute in place as you scroll down, go back to 100% when you scroll back up
118+
118119
## Image generation models
119120

120121
- dall-e (OpenAI)

‎src/components/Brand/index.astro‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import styles from './index.module.css'
1010

1111
<a
1212
class:list={[
13-
"flex items-stretch relative focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:[inset:-6px] focus-visible:after:border-spotlight",
13+
"flex items-stretch relative focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1.5 focus-visible:after:border-spotlight",
1414
styles.brandLink,
1515
]}
1616
href="/"
@@ -20,7 +20,7 @@ import styles from './index.module.css'
2020
<span class="brand-logo">
2121
<Logo class="h-(--header-icon-size) w-(--header-icon-size) relative z-(--z-content-overlay)" />
2222
</span>
23-
<span class="flex flex-col justify-between items-center ml-2 sm:ml-3">
23+
<span class="flex flex-col justify-between items-center ml-2 sm:ml-3 group-data-[header-search-open=true]:hidden lg:group-data-[header-search-open=true]:flex">
2424
<span class="brand-wordmark-webstack">
2525
<WordmarkWebstack class="h-[calc(var(--header-icon-size)/2-0.3em)]" />
2626
</span>

‎src/components/Header/index.astro‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ const { path } = Astro.props
6060
{/* Header for all pages */}
6161
<header
6262
id="header"
63-
class="flex flex-row items-center justify-between py-2 lg:py-3 relative"
63+
data-header-search-open="false"
64+
class="group flex flex-row items-center justify-between py-2 lg:py-3 relative"
6465
role="banner"
6566
>
6667
{/*- Element for animated background behind mobile navigation menu -*/}

‎src/components/Navigation/Menu.astro‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const activeMenuItem = (url: string) => {
3737
'main-nav-item',
3838
styles.mainNavItem,
3939
'lg:relative',
40+
'group-data-[header-search-open=true]:lg:hidden',
4041
...activeMenuItem(navItem.url),
4142
]}
4243
style={`--nav-item-delay: ${delaySeconds}s`}
@@ -50,7 +51,7 @@ const activeMenuItem = (url: string) => {
5051
'lg:text-primary-offset lg:underline lg:decoration-dotted lg:decoration-2 lg:decoration-primary-offset lg:underline-offset-8',
5152
'lg:hover:text-primary lg:hover:decoration-transparent',
5253
'lg:focus-visible:text-primary lg:focus-visible:decoration-primary',
53-
'lg:focus-visible:outline lg:focus-visible:outline-2 lg:focus-visible:outline-spotlight lg:focus-visible:outline-offset-4',
54+
'lg:focus-visible:outline-2 lg:focus-visible:outline-spotlight lg:focus-visible:outline-offset-4',
5455
styles.mainNavLink,
5556
]}
5657
>

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { handleScriptError } from '@components/scripts/errors/handler'
77
import { addScriptBreadcrumb } from '@components/scripts/errors'
88
import { getSearchBarElements, getSearchBarOptionalElements } from './selectors'
99
import type { SearchHit } from '@actions/search/@types'
10+
import {
11+
closeHeaderSearch,
12+
getHeaderSearchExpanded,
13+
openHeaderSearch,
14+
subscribeHeaderSearchExpanded,
15+
} from '@components/scripts/store'
1016

1117
export class SearchBarElement extends LitElement {
1218
static registeredName = 'search-bar'
@@ -21,6 +27,7 @@ export class SearchBarElement extends LitElement {
2127
private isExpanded = true
2228

2329
private isOutsideListenersAttached = false
30+
private unsubscribeHeaderSearchExpanded: (() => void) | null = null
2431

2532
private debounceHandle: ReturnType<typeof setTimeout> | null = null
2633
private latestRequestId = 0
@@ -32,6 +39,7 @@ export class SearchBarElement extends LitElement {
3239
override connectedCallback(): void {
3340
super.connectedCallback()
3441
this.cacheElements()
42+
this.initHeaderExpandedState()
3543
this.attachListeners()
3644
}
3745

@@ -41,6 +49,11 @@ export class SearchBarElement extends LitElement {
4149
this.debounceHandle = null
4250
}
4351

52+
if (this.unsubscribeHeaderSearchExpanded) {
53+
this.unsubscribeHeaderSearchExpanded()
54+
this.unsubscribeHeaderSearchExpanded = null
55+
}
56+
4457
this.detachOutsideListeners()
4558

4659
super.disconnectedCallback()
@@ -57,7 +70,43 @@ export class SearchBarElement extends LitElement {
5770

5871
this.toggleBtn = toggleBtn
5972
this.panel = panel
60-
this.isExpanded = this.getIsExpandedFromDom()
73+
this.isExpanded = this.toggleBtn ? getHeaderSearchExpanded() : this.getIsExpandedFromDom()
74+
}
75+
76+
private initHeaderExpandedState(): void {
77+
if (!this.toggleBtn) {
78+
return
79+
}
80+
81+
const query = this.getQuery()
82+
const storeExpanded = getHeaderSearchExpanded()
83+
84+
// If the input is pre-populated, force-open so the user can see/edit it.
85+
const desiredExpanded = query.length > 0 ? true : storeExpanded
86+
87+
if (desiredExpanded && !storeExpanded) {
88+
openHeaderSearch()
89+
}
90+
91+
if (!desiredExpanded && storeExpanded) {
92+
closeHeaderSearch()
93+
}
94+
95+
this.setExpandedState(desiredExpanded, { updateStore: false })
96+
97+
if (!this.unsubscribeHeaderSearchExpanded) {
98+
this.unsubscribeHeaderSearchExpanded = subscribeHeaderSearchExpanded(nextExpanded => {
99+
if (!this.toggleBtn) {
100+
return
101+
}
102+
103+
if (nextExpanded === this.isExpanded) {
104+
return
105+
}
106+
107+
this.setExpandedState(nextExpanded, { updateStore: false })
108+
})
109+
}
61110
}
62111

63112
private getIsExpandedFromDom(): boolean {
@@ -101,9 +150,17 @@ export class SearchBarElement extends LitElement {
101150
}
102151
}
103152

104-
private setExpandedState(isExpanded: boolean): void {
153+
private setExpandedState(isExpanded: boolean, { updateStore = true }: { updateStore?: boolean } = {}): void {
105154
this.isExpanded = isExpanded
106155

156+
if (updateStore && this.toggleBtn) {
157+
if (isExpanded) {
158+
openHeaderSearch()
159+
} else {
160+
closeHeaderSearch()
161+
}
162+
}
163+
107164
if (this.toggleBtn) {
108165
this.toggleBtn.setAttribute('aria-expanded', isExpanded ? 'true' : 'false')
109166
this.toggleBtn.setAttribute('data-state', isExpanded ? 'open' : 'closed')

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
initAnimationLifecycle,
99
initConsentFromCookies,
1010
initConsentSideEffects,
11+
initHeaderSearchSideEffects,
1112
exposeStoreActionsForTesting,
1213
} from '@components/scripts/store'
1314
import { SentryBootstrap } from '@components/scripts/sentry/client'
@@ -57,6 +58,10 @@ export class AppBootstrap {
5758
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initConsentSideEffects' })
5859
initConsentSideEffects()
5960

61+
// 3b. Setup header search UI side effects
62+
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initHeaderSearchSideEffects' })
63+
initHeaderSearchSideEffects()
64+
6065
// 4. Expose limited store actions for Playwright-driven E2E tests
6166
exposeStoreActionsForTesting()
6267
} catch (error: unknown) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* Unit tests for header search state management
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { handleScriptError } from '@components/scripts/errors/handler'
8+
import {
9+
__resetHeaderSearchForTests,
10+
closeHeaderSearch,
11+
getHeaderSearchExpanded,
12+
initHeaderSearchSideEffects,
13+
openHeaderSearch,
14+
setHeaderSearchExpanded,
15+
toggleHeaderSearch,
16+
$headerSearchExpanded,
17+
} from '@components/scripts/store/search'
18+
19+
vi.mock('@components/scripts/errors/handler', () => ({
20+
handleScriptError: vi.fn(),
21+
}))
22+
23+
describe('Header search store', () => {
24+
beforeEach(() => {
25+
localStorage.clear()
26+
document.body.innerHTML = ''
27+
__resetHeaderSearchForTests()
28+
vi.clearAllMocks()
29+
})
30+
31+
it('defaults to collapsed', () => {
32+
expect(getHeaderSearchExpanded()).toBe(false)
33+
})
34+
35+
it('can open/close/toggle', () => {
36+
openHeaderSearch()
37+
expect(getHeaderSearchExpanded()).toBe(true)
38+
39+
closeHeaderSearch()
40+
expect(getHeaderSearchExpanded()).toBe(false)
41+
42+
toggleHeaderSearch()
43+
expect(getHeaderSearchExpanded()).toBe(true)
44+
})
45+
46+
it('persists expanded state to localStorage', () => {
47+
setHeaderSearchExpanded(true)
48+
expect($headerSearchExpanded.get()).toBe(true)
49+
50+
// persistentAtom stores JSON strings for this store
51+
expect(localStorage.getItem('headerSearchExpanded')).toBe('true')
52+
})
53+
54+
it('updates #header data attribute via side effects', () => {
55+
const header = document.createElement('header')
56+
header.id = 'header'
57+
document.body.appendChild(header)
58+
59+
initHeaderSearchSideEffects()
60+
expect(header.getAttribute('data-header-search-open')).toBe('false')
61+
62+
openHeaderSearch()
63+
expect(header.getAttribute('data-header-search-open')).toBe('true')
64+
65+
closeHeaderSearch()
66+
expect(header.getAttribute('data-header-search-open')).toBe('false')
67+
})
68+
69+
it('reports errors when side effects initialization fails', () => {
70+
const error = new Error('listener failure')
71+
const listenSpy = vi.spyOn($headerSearchExpanded, 'listen').mockImplementation(() => {
72+
throw error
73+
})
74+
75+
initHeaderSearchSideEffects()
76+
77+
expect(handleScriptError).toHaveBeenCalledWith(error, {
78+
scriptName: 'search',
79+
operation: 'initHeaderSearchSideEffects',
80+
})
81+
82+
listenSpy.mockRestore()
83+
})
84+
})

‎src/components/scripts/store/index.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@ export {
106106
type EmbedCacheState,
107107
} from './socialEmbeds'
108108

109+
// Re-export header search state
110+
export {
111+
$headerSearchExpanded, // only allowed to use in tests
112+
__resetHeaderSearchForTests,
113+
closeHeaderSearch,
114+
getHeaderSearchExpanded,
115+
initHeaderSearchSideEffects,
116+
openHeaderSearch,
117+
setHeaderSearchExpanded,
118+
subscribeHeaderSearchExpanded,
119+
toggleHeaderSearch,
120+
} from './search'
121+
109122
/**
110123
* Expose a limited set of store actions during Playwright runs so E2E tests
111124
* can seed state without relying on private internals or DOM-only flows.

0 commit comments

Comments
 (0)