feat: add generic component and controller

This commit is contained in:
Antonella Sgarlatta
2026-05-08 15:04:15 -03:00
parent bb183e525f
commit 29fd9e026e
7 changed files with 800 additions and 0 deletions
@@ -0,0 +1,124 @@
import { createMockUniversalSearchProvider } from './providers/createMockUniversalSearchProvider'
import {
getNextUniversalSearchResultIndex,
getPreviousUniversalSearchResultIndex,
UniversalSearchController,
} from './UniversalSearchController'
import { UniversalSearchProvider } from './types'
import { NoOpUniversalSearchProvider } from './providers/NoOpUniversalSearchProvider'
describe('UniversalSearchController', () => {
async function flushSearch() {
await Promise.resolve()
}
it('calculates predictable wraparound indexes', () => {
expect(getNextUniversalSearchResultIndex(-1, 0)).toBe(-1)
expect(getNextUniversalSearchResultIndex(-1, 3)).toBe(0)
expect(getNextUniversalSearchResultIndex(2, 3)).toBe(0)
expect(getPreviousUniversalSearchResultIndex(-1, 0)).toBe(-1)
expect(getPreviousUniversalSearchResultIndex(0, 3)).toBe(2)
})
it('opens, searches, selects, navigates, and closes with reset state', async () => {
const onSelectResult = jest.fn()
const provider = createMockUniversalSearchProvider({
documents: [{ id: 'one', text: 'hello hello' }],
onSelectResult,
})
const controller = new UniversalSearchController(provider)
controller.open()
controller.setQuery('hello')
await flushSearch()
expect(controller.isOpen).toBe(true)
expect(controller.status).toBe('ready')
expect(controller.results).toHaveLength(2)
expect(controller.currentResultIndex).toBe(0)
controller.goToNextResult()
expect(controller.currentResultIndex).toBe(1)
expect(onSelectResult).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'one-6' }))
controller.goToNextResult()
expect(controller.currentResultIndex).toBe(0)
controller.close()
expect(controller.isOpen).toBe(false)
expect(controller.query).toBe('')
expect(controller.results).toEqual([])
expect(controller.currentResultIndex).toBe(-1)
})
it('handles provider errors', async () => {
const provider: UniversalSearchProvider = {
id: 'failing',
capabilities: {
supportsSearch: true,
supportsReplace: false,
supportsHighlightAll: false,
},
search: () => {
throw new Error('Provider failed')
},
selectResult: jest.fn(),
clear: jest.fn(),
}
const controller = new UniversalSearchController(provider)
controller.open()
controller.setQuery('hello')
await flushSearch()
expect(controller.status).toBe('error')
expect(controller.error).toBe('Provider failed')
expect(controller.results).toEqual([])
})
it('delegates replacement through provider contracts', async () => {
const provider = createMockUniversalSearchProvider({
documents: [{ id: 'one', text: 'hello hello' }],
})
const controller = new UniversalSearchController(provider)
controller.open()
controller.setQuery('hello')
controller.setReplaceQuery('goodbye')
await flushSearch()
await controller.replaceCurrentResult()
expect(provider.getDocuments()[0].text).toBe('goodbye hello')
expect(controller.results).toHaveLength(1)
await controller.replaceAllResults()
expect(provider.getDocuments()[0].text).toBe('goodbye goodbye')
expect(controller.results).toHaveLength(0)
})
it('keeps unsupported providers stable and predictable', async () => {
const controller = new UniversalSearchController(NoOpUniversalSearchProvider)
controller.open()
controller.setQuery('hello')
controller.goToNextResult()
await flushSearch()
expect(controller.results).toEqual([])
expect(controller.currentResultIndex).toBe(-1)
await expect(controller.replaceCurrentResult()).resolves.toBeUndefined()
})
it('does not open when access is disabled', () => {
const controller = new UniversalSearchController(NoOpUniversalSearchProvider, { isEnabled: false })
controller.open()
expect(controller.isOpen).toBe(false)
})
})
@@ -0,0 +1,289 @@
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import {
UniversalSearchProvider,
UniversalSearchResult,
UniversalSearchResultPayload,
UniversalSearchStatus,
} from './types'
export function getNextUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number {
if (resultCount < 1) {
return -1
}
const next = currentResultIndex + 1
return next >= resultCount ? 0 : next
}
export function getPreviousUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number {
if (resultCount < 1) {
return -1
}
const previous = currentResultIndex - 1
return previous < 0 ? resultCount - 1 : previous
}
function statusForEmptyQuery(provider: UniversalSearchProvider): UniversalSearchStatus {
return provider.capabilities.supportsSearch ? 'idle' : 'ready'
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return 'Search failed'
}
interface UniversalSearchControllerOptions<TPayload = UniversalSearchResultPayload> {
provider: UniversalSearchProvider<TPayload>
isEnabled?: boolean
}
export class UniversalSearchController<TPayload = UniversalSearchResultPayload> {
isOpen = false
query = ''
replaceQuery = ''
results: UniversalSearchResult<TPayload>[] = []
currentResultIndex = -1
status: UniversalSearchStatus = 'idle'
error: string | undefined = undefined
isCaseSensitive = false
isReplaceMode = false
shouldHighlightAll: boolean
private searchId = 0
private isEnabled: boolean
constructor(
public readonly provider: UniversalSearchProvider<TPayload>,
options: Omit<UniversalSearchControllerOptions<TPayload>, 'provider'> = {},
) {
this.isEnabled = options.isEnabled ?? true
this.shouldHighlightAll = provider.capabilities.supportsHighlightAll
makeObservable<this, 'setSearchResults' | 'setSearchError' | 'clearResults'>(this, {
isOpen: observable,
query: observable,
replaceQuery: observable,
results: observable,
currentResultIndex: observable,
status: observable,
error: observable,
isCaseSensitive: observable,
isReplaceMode: observable,
shouldHighlightAll: observable,
currentResult: computed,
open: action,
close: action,
setQuery: action,
setReplaceQuery: action,
toggleCaseSensitivity: action,
toggleReplaceMode: action,
setShouldHighlightAll: action,
goToNextResult: action,
goToPreviousResult: action,
setSearchResults: action,
setSearchError: action,
clearResults: action,
})
}
get currentResult(): UniversalSearchResult<TPayload> | undefined {
return this.results[this.currentResultIndex]
}
deinit(): void {
this.searchId++
void this.provider.clear()
this.results = []
}
open = (): void => {
if (!this.isEnabled) {
return
}
this.isOpen = true
void this.search()
}
close = (): void => {
void this.selectCurrentResult()
this.searchId++
void this.provider.clear()
this.isOpen = false
this.query = ''
this.replaceQuery = ''
this.results = []
this.currentResultIndex = -1
this.status = 'idle'
this.error = undefined
this.isCaseSensitive = false
this.isReplaceMode = false
this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll
}
setQuery = (query: string): void => {
this.query = query
void this.search()
}
setReplaceQuery = (replaceQuery: string): void => {
this.replaceQuery = replaceQuery
}
toggleCaseSensitivity = (): void => {
this.isCaseSensitive = !this.isCaseSensitive
void this.search()
}
toggleReplaceMode = (): void => {
this.isReplaceMode = !this.isReplaceMode
}
setShouldHighlightAll = (shouldHighlightAll: boolean): void => {
this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll && shouldHighlightAll
}
goToNextResult = (): void => {
this.currentResultIndex = getNextUniversalSearchResultIndex(this.currentResultIndex, this.results.length)
void this.selectCurrentResult()
}
goToPreviousResult = (): void => {
this.currentResultIndex = getPreviousUniversalSearchResultIndex(this.currentResultIndex, this.results.length)
void this.selectCurrentResult()
}
selectCurrentResult = async (): Promise<void> => {
const result = this.currentResult
if (!result) {
return
}
await this.provider.selectResult(result)
}
replaceCurrentResult = async (): Promise<void> => {
if (!this.provider.capabilities.supportsReplace || !this.provider.replaceCurrentResult) {
return
}
const result = this.currentResult
if (!result || !this.replaceQuery) {
return
}
const nextResults = await this.provider.replaceCurrentResult(result, {
query: this.query,
isCaseSensitive: this.isCaseSensitive,
replaceQuery: this.replaceQuery,
})
await this.handleReplaceResult(nextResults)
}
replaceAllResults = async (): Promise<void> => {
if (!this.provider.capabilities.supportsReplace || !this.provider.replaceAllResults) {
return
}
if (this.results.length < 1 || !this.replaceQuery) {
return
}
const nextResults = await this.provider.replaceAllResults(this.results, {
query: this.query,
isCaseSensitive: this.isCaseSensitive,
replaceQuery: this.replaceQuery,
})
await this.handleReplaceResult(nextResults)
}
private search = async (): Promise<void> => {
if (!this.isOpen || !this.isEnabled) {
return
}
const searchId = this.searchId + 1
this.searchId = searchId
if (!this.query || !this.provider.capabilities.supportsSearch) {
void this.provider.clear()
this.clearResults(statusForEmptyQuery(this.provider))
return
}
this.status = 'loading'
this.error = undefined
try {
const results = await this.provider.search({
query: this.query,
isCaseSensitive: this.isCaseSensitive,
})
if (searchId !== this.searchId) {
return
}
runInAction(() => {
this.setSearchResults(results)
})
await this.selectCurrentResult()
} catch (error) {
if (searchId !== this.searchId) {
return
}
runInAction(() => {
this.setSearchError(errorMessage(error))
})
}
}
private handleReplaceResult = async (nextResults: UniversalSearchResult<TPayload>[] | void): Promise<void> => {
if (Array.isArray(nextResults)) {
runInAction(() => {
this.setSearchResults(nextResults)
})
return
}
const results = await this.provider.search({
query: this.query,
isCaseSensitive: this.isCaseSensitive,
})
runInAction(() => {
this.setSearchResults(results)
})
}
private setSearchResults(results: UniversalSearchResult<TPayload>[]): void {
this.results = results
this.currentResultIndex = results.length > 0 ? 0 : -1
this.status = 'ready'
this.error = undefined
}
private setSearchError(error: string): void {
this.results = []
this.currentResultIndex = -1
this.status = 'error'
this.error = error
}
private clearResults(status: UniversalSearchStatus): void {
this.results = []
this.currentResultIndex = -1
this.status = status
this.error = undefined
}
}
@@ -0,0 +1,252 @@
import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons'
import { classNames } from '@standardnotes/utils'
import { observer } from 'mobx-react-lite'
import { KeyboardEvent, useCallback } from 'react'
import Button from '../../Button/Button'
import Icon from '../../Icon/Icon'
import DecoratedInput from '../../Input/DecoratedInput'
import StyledTooltip from '../../StyledTooltip/StyledTooltip'
import { UniversalSearchController } from './UniversalSearchController'
import { UniversalSearchResultPayload } from './types'
interface UniversalSearchShellProps<TPayload = UniversalSearchResultPayload> {
controller: UniversalSearchController<TPayload>
className?: string
position?: 'absolute' | 'inline'
topMarginClassName?: string
closeShortcut?: string
replaceShortcut?: string
caseSensitivityShortcut?: string
}
function statusLabel<TPayload = UniversalSearchResultPayload>(controller: UniversalSearchController<TPayload>): string {
if (controller.status === 'loading') {
return 'Loading'
}
if (controller.status === 'error') {
return controller.error || 'Error'
}
if (!controller.query) {
return ''
}
if (controller.results.length < 1) {
return '0'
}
return `${controller.currentResultIndex + 1} / ${controller.results.length}`
}
export const UniversalSearchShell = observer(function UniversalSearchShell<TPayload = UniversalSearchResultPayload>({
controller,
className,
position = 'absolute',
topMarginClassName,
closeShortcut,
replaceShortcut,
caseSensitivityShortcut,
}: UniversalSearchShellProps<TPayload>) {
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
}
}, [])
const canReplace = controller.provider.capabilities.supportsReplace
const canHighlightAll = controller.provider.capabilities.supportsHighlightAll
const hasResults = controller.results.length > 0
const hasReplaceQuery = controller.replaceQuery.length > 0
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || !hasResults) {
return
}
if (event.shiftKey) {
controller.goToPreviousResult()
event.preventDefault()
return
}
controller.goToNextResult()
event.preventDefault()
}
const handleReplaceKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || !hasResults || !hasReplaceQuery || !canReplace) {
return
}
if (event.ctrlKey && event.altKey) {
void controller.replaceAllResults()
event.preventDefault()
return
}
void controller.replaceCurrentResult()
event.preventDefault()
}
if (!controller.isOpen) {
return null
}
const defaultTopMarginClassName = position === 'absolute' ? 'top-2 md:top-3' : 'mt-2 md:mt-3'
return (
<div
className={classNames(
'z-10 flex select-none rounded border border-border bg-default font-sans',
position === 'absolute' ? 'absolute left-2 right-6 md:left-auto' : 'relative mx-2 mb-2 w-fit',
topMarginClassName ?? defaultTopMarginClassName,
className,
)}
>
<button
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info disabled:cursor-not-allowed"
onClick={controller.toggleReplaceMode}
title={replaceShortcut ? `Toggle Replace Mode (${replaceShortcut})` : 'Toggle Replace Mode'}
disabled={!canReplace}
aria-label="Toggle replace mode"
>
{controller.isReplaceMode ? (
<ArrowDownIcon className="h-4 w-4 fill-text" />
) : (
<ArrowRightIcon className="h-4 w-4 fill-text" />
)}
</button>
<div
className="flex flex-col gap-2 px-2 py-2"
onKeyDown={(event) => {
if (event.key === 'Escape') {
controller.close()
}
}}
>
<div className="flex items-center gap-2">
<DecoratedInput
placeholder="Search"
className={{
container: classNames('flex-grow !text-[length:inherit]', !controller.query.length && '!py-1'),
right: '!py-1',
}}
value={controller.query}
onChange={controller.setQuery}
onKeyDown={handleSearchKeyDown}
ref={focusOnMount}
right={[
<div
className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right"
aria-live="polite"
aria-label="Search status"
>
{statusLabel(controller)}
</div>,
]}
/>
<label
className={classNames(
'relative flex items-center rounded border px-1.5 py-1 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
controller.isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
)}
title={caseSensitivityShortcut ? `Case sensitive (${caseSensitivityShortcut})` : 'Case sensitive'}
>
<input
type="checkbox"
className="absolute left-0 top-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
checked={controller.isCaseSensitive}
onChange={controller.toggleCaseSensitivity}
aria-label="Case sensitive"
/>
<span aria-hidden>Aa</span>
</label>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={controller.goToPreviousResult}
disabled={!hasResults}
title="Previous result (Shift + Enter)"
aria-label="Previous result"
>
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={controller.goToNextResult}
disabled={!hasResults}
title="Next result (Enter)"
aria-label="Next result"
>
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
onClick={controller.close}
title={closeShortcut ? `Close (${closeShortcut})` : 'Close'}
aria-label="Close search"
>
<CloseIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
{controller.isReplaceMode && (
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
<input
type="text"
placeholder="Replace"
value={controller.replaceQuery}
onChange={(event) => {
controller.setReplaceQuery(event.target.value)
}}
onKeyDown={handleReplaceKeyDown}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
disabled={!canReplace}
aria-label="Replace"
/>
<Button
small
onClick={() => void controller.replaceCurrentResult()}
disabled={!canReplace || !hasResults || !hasReplaceQuery}
title="Replace (Enter)"
>
Replace
</Button>
<Button
small
onClick={() => void controller.replaceAllResults()}
disabled={!canReplace || !hasResults || !hasReplaceQuery}
title="Replace all (Ctrl + Alt + Enter)"
>
Replace all
</Button>
</div>
)}
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2">
<input
className="h-4 w-4 rounded accent-info"
type="checkbox"
checked={controller.shouldHighlightAll}
onChange={(event) => controller.setShouldHighlightAll(event.target.checked)}
disabled={!canHighlightAll}
aria-label="Highlight all results"
/>
<div>Highlight all results</div>
</label>
{!canHighlightAll && (
<StyledTooltip
label="This editor does not support search result highlighting yet."
className="!z-modal"
showOnMobile
>
<button className="cursor-default" aria-label="Highlight all unavailable">
<Icon type="info" size="medium" />
</button>
</StyledTooltip>
)}
</div>
</div>
</div>
)
})
@@ -0,0 +1,4 @@
export * from './UniversalSearchShell'
export * from './UniversalSearchController'
export * from './types'
export * from './providers/NoOpUniversalSearchProvider'
@@ -0,0 +1,17 @@
import { UniversalSearchProvider } from '../types'
export const NoOpUniversalSearchProvider: UniversalSearchProvider = {
id: 'noop',
capabilities: {
supportsSearch: false,
supportsReplace: false,
supportsHighlightAll: false,
},
search: () => [],
selectResult: () => {
return
},
clear: () => {
return
},
}
@@ -0,0 +1,60 @@
/**
* @jest-environment jsdom
*/
import { NoOpUniversalSearchProvider } from './NoOpUniversalSearchProvider'
import { createMockUniversalSearchProvider } from './createMockUniversalSearchProvider'
describe('UniversalSearchProvider', () => {
it('returns no results for unsupported editors', async () => {
expect(await NoOpUniversalSearchProvider.search({ query: 'hello', isCaseSensitive: false })).toEqual([])
expect(NoOpUniversalSearchProvider.capabilities.supportsSearch).toBe(false)
expect(NoOpUniversalSearchProvider.capabilities.supportsReplace).toBe(false)
})
it('searches documents with a mock provider', async () => {
const provider = createMockUniversalSearchProvider({
documents: [
{ id: 'one', text: 'Hello world' },
{ id: 'two', text: 'hello again' },
],
})
const results = await provider.search({ query: 'hello', isCaseSensitive: false })
expect(results.map(({ id }) => id)).toEqual(['one-0', 'two-0'])
})
it('honors case sensitivity in the mock provider', async () => {
const provider = createMockUniversalSearchProvider({
documents: [{ id: 'one', text: 'Hello hello' }],
})
expect(await provider.search({ query: 'hello', isCaseSensitive: true })).toHaveLength(1)
expect(await provider.search({ query: 'hello', isCaseSensitive: false })).toHaveLength(2)
})
it('supports current and all replacements in the mock provider', async () => {
const provider = createMockUniversalSearchProvider({
documents: [{ id: 'one', text: 'hello hello' }],
})
const [firstResult] = await provider.search({ query: 'hello', isCaseSensitive: false })
await provider.replaceCurrentResult?.(firstResult, {
query: 'hello',
isCaseSensitive: false,
replaceQuery: 'goodbye',
})
expect(provider.getDocuments()[0].text).toBe('goodbye hello')
const results = await provider.search({ query: 'hello', isCaseSensitive: false })
await provider.replaceAllResults?.(results, {
query: 'hello',
isCaseSensitive: false,
replaceQuery: 'bye',
})
expect(provider.getDocuments()[0].text).toBe('goodbye bye')
})
})
@@ -0,0 +1,54 @@
export type UniversalSearchStatus = 'idle' | 'loading' | 'ready' | 'error'
export type UniversalSearchResultPayload = unknown
export interface UniversalSearchQuery {
query: string
isCaseSensitive: boolean
}
export interface UniversalSearchReplaceQuery extends UniversalSearchQuery {
replaceQuery: string
}
export interface UniversalSearchResult<TPayload = UniversalSearchResultPayload> {
id: string
label?: string
context?: string
payload?: TPayload
}
export interface UniversalSearchProviderCapabilities {
supportsSearch: boolean
supportsReplace: boolean
supportsHighlightAll: boolean
}
export interface UniversalSearchProvider<TPayload = UniversalSearchResultPayload> {
id: string
capabilities: UniversalSearchProviderCapabilities
search(query: UniversalSearchQuery): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[]
selectResult(result: UniversalSearchResult<TPayload>): Promise<void> | void
clear(): Promise<void> | void
replaceCurrentResult?(
result: UniversalSearchResult<TPayload>,
query: UniversalSearchReplaceQuery,
): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[] | Promise<void> | void
replaceAllResults?(
results: UniversalSearchResult<TPayload>[],
query: UniversalSearchReplaceQuery,
): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[] | Promise<void> | void
}
export interface UniversalSearchControllerState<TPayload = UniversalSearchResultPayload> {
isOpen: boolean
query: string
replaceQuery: string
results: UniversalSearchResult<TPayload>[]
currentResultIndex: number
status: UniversalSearchStatus
error?: string
isCaseSensitive: boolean
isReplaceMode: boolean
shouldHighlightAll: boolean
}