Commit cbecf4b3 authored by Stefan Probst's avatar Stefan Probst
Browse files

refactor: swap combobox impl in search form

parent 2a875649
......@@ -11,6 +11,7 @@ export interface ButtonProps extends AriaButtonProps {
isPressed?: boolean
/** @default "primary" */
variant?: 'primary' | 'gradient' | 'link' | 'header' | 'nav'
className?: string
}
function Button(
......@@ -99,6 +100,7 @@ function Button(
'inline-flex items-center justify-center space-x-1.5 font-body font-normal cursor-default focus:outline-none select-none',
variant.button.default,
variant.button.states[isDisabled ? 'disabled' : 'enabled'],
props.className,
),
spinner: variant.spinner,
}
......
......@@ -38,6 +38,7 @@ export interface ComboBoxProps<T>
// loadingState?: 'loading'
shouldFocusWrap?: boolean
hideSelectionIcon?: boolean
hideButton?: boolean
/** @default "default" */
variant?: 'default' | 'search' | 'form'
style?: CSSProperties
......@@ -144,7 +145,7 @@ export function ComboBox<T extends object>(
container: 'inline-flex relative',
inputContainer: cx(
'w-64',
'inline-flex min-w-0 items-center justify-between rounded border border-gray-300',
'transition inline-flex min-w-0 items-center justify-between rounded border border-gray-300',
variant.inputContainer,
),
input: cx(
......@@ -162,7 +163,7 @@ export function ComboBox<T extends object>(
state.isOpen && 'rotate-180',
variant.icon,
),
spinnerContainer: 'inline-flex items-center justify-center',
spinnerContainer: 'inline-flex items-center justify-center mx-4',
spinner: 'w-4 h-4 text-primary-750',
}
......@@ -190,9 +191,11 @@ export function ComboBox<T extends object>(
<ProgressSpinner className={styles.spinner} />
</span>
) : null}
<button {...buttonProps} className={styles.button} ref={triggerRef}>
<Icon icon={TriangleIcon} className={styles.icon} />
</button>
{props.hideButton !== true ? (
<button {...buttonProps} className={styles.button} ref={triggerRef}>
<Icon icon={TriangleIcon} className={styles.icon} />
</button>
) : null}
</div>
<Popover
popoverRef={popoverRef}
......
......@@ -37,7 +37,11 @@ export function Field(props: FieldProps): JSX.Element {
}
if (props.label === undefined && props.validationState === undefined) {
return <Fragment>{props.children}</Fragment>
return (
<div className={styles.field} style={props.style}>
{props.children}
</div>
)
}
return (
......
import { ensureArray } from '@/lib/util/ensureArray'
import { ensureScalar } from '@/lib/util/ensureScalar'
import { isEmptyString } from '@/lib/util/isEmptyString'
import { isUndefined } from '@/lib/util/isUndefined'
export function getQueryParam(
param: string | Array<string> | undefined,
multiple: false,
): string | undefined
export function getQueryParam(
param: string | Array<string> | undefined,
multiple: true,
): Array<string> | undefined
export function getQueryParam<T>(
param: string | Array<string> | undefined,
multiple: false,
transform: (value: string) => T,
): T | undefined
export function getQueryParam<T>(
param: string | Array<string> | undefined,
multiple: true,
transform: (value: string) => T,
): Array<T> | undefined
export function getQueryParam<T>(
param: string | Array<string> | undefined,
multiple: boolean,
transform?: (value: string) => T,
): string | Array<string> | T | Array<T> | undefined
/**
* Returns query param as single value or array of values. Empty strings and empty arrays return `undefined`.
*
* @param param Query parameter value.
* @param multiple Whether query param holds single value or array of values.
* @param transform Optional function to transform string values, e.g. into numbers.
*/
export function getQueryParam(
param: string | Array<string> | undefined,
multiple: boolean,
transform?: (value: string) => unknown,
): unknown {
if (param === undefined) return undefined
if (Array.isArray(param) && param.length === 0) return undefined
if (multiple === true) {
const values = ensureArray(param).filter((value) => !isEmptyString(value))
if (!transform) return values
const transformed = values
.map(transform)
.filter((value) => !isUndefined(value))
return transformed.length > 0 ? transformed : undefined
} else {
/** Will never return `undefined`, since we check for empty array above. */
const value = ensureScalar(param) as string
if (isEmptyString(value)) return undefined
if (!transform) return value
return transform(value)
}
}
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { getQueryParam } from '@/lib/hooks/getQueryParam'
export function useQueryParam(name: string, multiple: false): string | undefined
export function useQueryParam(
name: string,
multiple: true,
): Array<string> | undefined
export function useQueryParam<T>(
name: string,
multiple: false,
transform: (value: string) => T,
): T | undefined
export function useQueryParam<T>(
name: string,
multiple: true,
transform: (value: string) => T,
): Array<T> | undefined
/**
* Returns query param as single value or array of values. Empty strings and empty arrays return `undefined`.
*
* @param param Name of query parameter.
* @param multiple Whether query param holds single value or array of values.
* @param transform Optional function to transform string values, e.g. into numbers.
*/
export function useQueryParam<T>(
name: string,
multiple: boolean,
transform?: (value: string) => T,
): string | Array<string> | T | Array<T> | undefined {
const router = useRouter()
const value = useMemo(() => {
if (!router.isReady) return undefined
const param = router.query[name]
return getQueryParam(param, multiple, transform)
}, [router, name, multiple, transform])
return value
}
/**
* Ensures provided value is an array.
*/
export function ensureArray<T>(value: T | Array<T>): Array<T> {
return Array.isArray(value) ? value : [value]
}
/**
* Ensures provided value is a scalar.
*/
export function ensureScalar<T>(value: T | Array<T>): T | undefined {
return Array.isArray(value) ? value[0] : value
}
/**
* Type guard for not `undefined`.
*/
export function isDefined<T>(value: T | undefined): value is T {
return value !== undefined
}
/**
* Type guard for empty string.
*/
export function isEmptyString(value: unknown): value is '' {
return value === ''
}
/**
* Type guard for `undefined`.
*/
export function isUndefined(value: unknown): value is undefined {
return value === undefined
}
import { Listbox } from '@headlessui/react'
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
} from '@reach/combobox'
import cx from 'clsx'
import { useRouter } from 'next/router'
import type {
ChangeEvent,
ComponentPropsWithoutRef,
Dispatch,
FormEvent,
SetStateAction,
} from 'react'
import { createContext, Fragment, useContext, useState } from 'react'
import { ReactNode, useMemo, useState } from 'react'
import { useAutocompleteItems, useGetItemCategories } from '@/api/sshoc'
import type { ItemCategory, ItemSearchQuery } from '@/api/sshoc/types'
import CheckMark from '@/modules/ui/CheckMark'
import FadeIn from '@/modules/ui/FadeIn'
import Triangle from '@/modules/ui/Triangle'
import { useDebounce } from '@/utils/useDebounce'
type ItemSearchFormData = {
categories?: Exclude<ItemCategory, 'step'> | ''
import { ItemCategory } from '@/api/sshoc/types'
import { Button } from '@/elements/Button/Button'
import { HighlightedText } from '@/elements/HighlightedText/HighlightedText'
import { useDebouncedState } from '@/lib/hooks/useDebouncedState'
import { useQueryParam } from '@/lib/hooks/useQueryParam'
import { FormComboBox } from '@/modules/form/components/FormComboBox/FormComboBox'
import { FormSelect } from '@/modules/form/components/FormSelect/FormSelect'
import { Form } from '@/modules/form/Form'
interface SearchFormValues {
q?: string
category?: ItemCategory
}
const MIN_AUTOCOMPLETE_LENGTH = 3
const ItemSearchFormContext = createContext<
[ItemSearchFormData, Dispatch<SetStateAction<ItemSearchFormData>>] | null
>(null)
export interface ItemSearchFormProps {
children?: ReactNode
className?: string
}
function useItemSearchFormContext() {
const value = useContext(ItemSearchFormContext)
export default function ItemSearchForm(
props: ItemSearchFormProps,
): JSX.Element {
const router = useRouter()
if (value === null) {
throw new Error(
'`useItemSearchFormContext` must be nested inside a `ItemSearchFormContext.Provider`.',
)
function onSubmit(values: SearchFormValues) {
router.push({
pathname: '/search',
query: {
q: values.q !== undefined && values.q.length > 0 ? values.q : undefined,
categories:
values.category !== undefined && values.category.length > 0
? [values.category]
: undefined,
},
})
}
return value
return (
<Form onSubmit={onSubmit}>
{({ handleSubmit }) => {
return (
<form
onSubmit={handleSubmit}
role="search"
className={props.className}
>
{props.children}
</form>
)
}}
</Form>
)
}
export default function ItemSearchForm({
children,
className,
}: ComponentPropsWithoutRef<'form'>): JSX.Element {
const router = useRouter()
const [formData, setFormData] = useState<ItemSearchFormData>({})
export function ItemCategorySelect(): JSX.Element {
const categories = useGetItemCategories()
const itemCategories = useMemo(() => {
const items = [{ id: '', label: 'All categories' }]
if (categories.data === undefined) return items
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const query: ItemSearchQuery = sanitizeFormData(formData)
router.push({ pathname: '/search', query }, undefined, {
shallow: router.pathname === '/search',
Object.entries(categories.data).forEach(([id, label]) => {
items.push({ id, label })
})
}
return items
}, [categories.data])
return (
<form
className={cx('flex bg-white', className)}
role="search"
autoComplete="off"
onSubmit={onSubmit}
<FormSelect
name="category"
aria-label="Category"
isLoading={categories.isLoading}
items={itemCategories}
/** Use explicit "All categories" option, not placeholder text as initial value. */
defaultSelectedKey=""
variant="search"
>
<ItemSearchFormContext.Provider value={[formData, setFormData]}>
{children}
</ItemSearchFormContext.Provider>
</form>
{(item) => <FormSelect.Item key={item.id}>{item.label}</FormSelect.Item>}
</FormSelect>
)
}
/** avoid empty queries like `?q=&categories=` */
function sanitizeFormData(query: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(query).filter(([key, value]) => {
return value !== ''
}),
)
export interface ItemSearchComboBoxProps {
variant?: 'invisible'
}
export function ItemAutoCompleteInput({
initialValue = '',
className,
}: {
initialValue?: string
className?: string
}): JSX.Element {
const [formData, setFormData] = useItemSearchFormContext()
const searchTerm = formData.q ?? initialValue
function setSearchTerm(searchTerm: string) {
setFormData((prev) => ({ ...prev, q: searchTerm }))
}
export function ItemSearchComboBox(
props: ItemSearchComboBoxProps,
): JSX.Element {
const defaultSearchTerm = useQueryParam('q', false) ?? ''
/** debounce autocomplete requests */
const autocompleteTerm = useDebounce(searchTerm.trim(), 150)
const { data: suggestions } = useAutocompleteItems(
{ q: autocompleteTerm },
const [searchTerm, setSearchTerm] = useState(defaultSearchTerm)
const debouncedSearchTerm = useDebouncedState(searchTerm, 150).trim()
const items = useAutocompleteItems(
{ q: debouncedSearchTerm },
{
enabled: autocompleteTerm.length >= MIN_AUTOCOMPLETE_LENGTH,
/** backend requires non-empty search phrase */
enabled: debouncedSearchTerm.length > 0,
keepPreviousData: true,
},
)
function onChange(event: ChangeEvent<HTMLInputElement>) {
setSearchTerm(event.currentTarget.value)
}
/** in case the backend does not limit suggestions */
const MAX_SUGGESTIONS = 10
const suggestions =
items.data?.suggestions?.map((suggestion) => ({ suggestion })) ?? []
return (
<Combobox
<FormComboBox
name="q"
aria-label="Search term"
openOnFocus
className="relative flex-1"
/**
* we need to wrap the selected autosuggest value in quotes.
* otherwise, solr will interpret special characters like a minus ("-")
* as query language, if the autosuggested term includes such a character.
* */
onSelect={(value) => setSearchTerm(`"${value}"`)}
allowsCustomValue
items={suggestions}
// isLoading={items.isLoading}
defaultInputValue={defaultSearchTerm}
onInputChange={setSearchTerm}
variant="search"
hideSelectionIcon
hideButton
style={
props.variant === 'invisible'
? { borderWidth: 0, flex: 1 }
: { flex: 1 }
}
>
<ComboboxInput
name="q"
type="search"
onChange={onChange}
/** value is managed by `@reach/combobox` internally, i.e. when selecting a `ComboboxOption` */
value={searchTerm}
/** don't change input value while navigating suggestions in listbox */
autocomplete={false}
placeholder="Search"
className={cx(
'w-full h-full px-4 py-2 border border-gray-200 rounded placeholder-gray-350 hover:bg-gray-50 focus:border-primary-750 transition-colors duration-150',
className,
)}
/>
{autocompleteTerm.length >= MIN_AUTOCOMPLETE_LENGTH &&
suggestions?.suggestions !== undefined &&
suggestions.suggestions.length > 0 ? (
<ComboboxPopover
portal={false}
className="absolute z-10 w-full py-2 mt-1 bg-white border border-gray-200 rounded shadow-md"
>
<ComboboxList className="select-none">
{suggestions.suggestions
.slice(0, MAX_SUGGESTIONS)
.map((suggestion) => (
<ComboboxOption
key={suggestion}
value={suggestion}
className="px-4 py-2 truncate hover:bg-gray-50"
/>
))}
</ComboboxList>
</ComboboxPopover>
) : null}
</Combobox>
{(item) => (
<FormComboBox.Item key={item.suggestion} textValue={item.suggestion}>
<HighlightedText
text={item.suggestion}
searchPhrase={debouncedSearchTerm}
/>
</FormComboBox.Item>
)}
</FormComboBox>
)
}
export function ItemCategoriesSelect(): JSX.Element {
const { data: itemCategories = {} } = useGetItemCategories({})
const [formData, setFormData] = useItemSearchFormContext()
const selectedCategory = formData.categories ?? ''
function setSelectedCategory(category: Exclude<ItemCategory, 'step'> | '') {
setFormData((prev) => ({ ...prev, categories: category }))
}
return (
<Listbox value={selectedCategory} onChange={setSelectedCategory}>
{({ open }) => (
<div className="relative w-64">
<Listbox.Button className="inline-flex items-center justify-between w-full h-full p-2 border border-gray-200 divide-x divide-gray-200 rounded hover:text-primary-750 hover:bg-gray-50 focus:bg-gray-50">
<span className="px-2">
{itemCategories[selectedCategory] ?? 'All categories'}
</span>
<span className="inline-flex items-center h-full pl-2 justify-content text-secondary-600">
<Triangle />
</span>
</Listbox.Button>
<FadeIn show={open}>
<Listbox.Options
static
className="absolute min-w-full py-2 mt-1 overflow-hidden whitespace-no-wrap bg-white border border-gray-200 rounded shadow-md select-none"
>
{[['', 'All categories']]
.concat(Object.entries(itemCategories))
.map(([category, label]) => {
return (
<Listbox.Option
key={category}
value={category}
as={Fragment}
>
{({ active, selected }) => (
<li
className={cx(
'px-4 py-3 flex space-x-2 items-center',
active === true && 'bg-gray-50',
selected === true && 'text-primary-750',
)}
>
<span className="w-6">
{selected === true ? <CheckMark /> : null}
</span>
<span>{label}</span>
</li>
)}
</Listbox.Option>
)
})}
</Listbox.Options>
</FadeIn>
</div>
)}
</Listbox>
)
export interface SubmitButtonProps {
className?: string
}
export function SubmitButton({
className,
}: ComponentPropsWithoutRef<'button'>): JSX.Element {
export function SubmitButton(props: SubmitButtonProps): JSX.Element {
return (
<button
type="submit"
className={cx(
'text-lg text-white rounded w-36 bg-gradient-to-r from-secondary-500 to-primary-800 hover:from-secondary-600 hover:to-secondary-600 focus:from-primary-750 focus:to-primary-750 transition-colors duration-150',
className,
)}
>
Search
</button>
<Button type="submit" variant="gradient" className={props.className}>
Submit
</Button>
)
}
......@@ -4,7 +4,7 @@ import { Fragment } from 'react'
import ContentColumn from '@/modules/layout/ContentColumn'