Skip to content

Instantly share code, notes, and snippets.

@realStandal
Last active October 9, 2022 20:46
Show Gist options
  • Save realStandal/bd01ed86bbcbe2b20a3fa07551b5b3a6 to your computer and use it in GitHub Desktop.
Save realStandal/bd01ed86bbcbe2b20a3fa07551b5b3a6 to your computer and use it in GitHub Desktop.
HeadlessUI Comboxbox integrated with RedwoodJS' form library + Floating UI
import { useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Combobox, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useController } from '@redwoodjs/forms'
import type { RegisterOptions } from '@redwoodjs/forms'
import useFloating from 'src/hooks/useFloating'
import Selector from 'src/svgs/selector.svg'
// --
type RenderArg = { active: boolean; disabled: boolean; selected: boolean }
export interface ComboboxFieldProps<T> {
className?: string
disabled?: boolean
/**
* A function which should return a component, displayed when the given `query` does not `filter` any `values`
*
* @example
* (q) => <p>Could not find an order with the name ${q}</p>
*/
displayEmpty?: (query: string) => ReactNode
/**
* A function which should return a string, which will be used:
*
* - To depict the current, selected value.
* - When `displayOption` is not provided, the value of each menu item.
*
* @example
* (v) => v.name
*/
displaySelected: (value: T) => string
/**
* A function which should return a component, used as the `child` for each menu item.
*
* @example
* displayOption: (v) => (
* <div className="flex flex-col space-y-2">
* <p className="font-medium text">{v.name}</p>
* <p className="text-xs hint">{`$${v.price}`}</p>
* </div>
* )
*/
displayOption?: (value: T, args: RenderArg) => ReactNode
/**
* A function which will filter the given `value` based on the given `query`.
*
* When the function returns:
*
* - `true` - The value will be displayed as a menu item.
* - `false` - The value will not be displayed as a menu item.
*
* @example
* filter: (v, q) => => v.name.toLowerCase().includes(q.toLowerCase())
*/
filter: (value: T, query: string) => boolean
/**
* A function which will return a unique identifier, used as the given values `value` in the form component. When _not_ provided, the entire value will be used - returning an object when the form is submitted.
*
* @example
* getIdentifier: (v) => v.id
*/
getIdentifier?: (value: T) => string
/**
* The name of this form field, used to provide a default value and when the form is submitted.
*/
name: string
placeholder?: string
/**
* Form validation options.
*/
validation?: RegisterOptions
values: T[]
}
// --
function ComboboxField<T>({
className,
disabled,
displayEmpty,
displayOption,
displaySelected,
filter,
getIdentifier,
name,
placeholder,
validation: rules,
values,
}: ComboboxFieldProps<T>) {
//
const { t } = useTranslation()
// --
const findValue = useCallback(
(val) =>
values.find((v) =>
typeof getIdentifier === 'function'
? getIdentifier(v) === val
: JSON.stringify(v) === JSON.stringify(val)
),
[getIdentifier, values]
)
// --
const {
field: { onChange, value: controlledValue = null },
formState: { errors },
} = useController({ name, rules })
const hasError = Object.keys(errors).includes(name)
const value = useMemo(
() => findValue(controlledValue),
[controlledValue, findValue]
)
// --
const [query, setQuery] = useState('')
// --
const filtered = useMemo(
() => values.filter((v) => filter(v, query)),
[filter, query, values]
)
// --
const { floating, reference, strategy, x, y, update } = useFloating()
// --
return (
<Combobox
as="div"
className={clsx('menu', className)}
disabled={disabled}
nullable
onChange={
typeof getIdentifier === 'function'
? (v) => (v ? onChange(getIdentifier(v)) : onChange(null))
: onChange
}
value={value}
>
<div className="relative" ref={reference}>
<Combobox.Input
className={clsx('w-full pr-[48px] peer input', hasError && 'error')}
onChange={(e) => {
update()
setQuery(e.target.value)
}}
placeholder={placeholder}
displayValue={(val: T) => (!val ? '' : displaySelected(val))}
/>
<Combobox.Button
as="button"
aria-label={t('combobox.button')}
className={clsx(
'absolute inset-y-0 right-0 rounded-l-none button button-neutral',
!hasError &&
'peer-focus:border-primary-400 dark:peer-focus:border-primary-500',
hasError &&
'border-red-500 dark:border-red-400 focus:border-red-500 focus:dark:border-red-400 focus:ring-red-400 focus:ring-opacity-40'
)}
type="button"
>
<Selector aria-hidden="true" className="w-4 h-4" />
</Combobox.Button>
</div>
<Transition
afterLeave={() => setQuery('')}
as={Combobox.Options}
className="w-full menu-items"
enter="ease-out duration-200 origin-top transition transform"
enterFrom="opacity-0 scale-[0.98]"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150 origin-top transition transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-[0.98]"
// @ts-expect-error Transition -> Combobox.Items === bad. But, Transition -> Listbox.Options === good?
ref={floating}
style={{
position: strategy,
left: x ?? '',
top: y ?? '',
}}
>
{query === '' && (
<Combobox.Option
value={null}
className={({ active, selected }) =>
clsx(
'text-base menu-item',
active && 'active',
selected && 'selected'
)
}
>
{t('combobox.none')}
</Combobox.Option>
)}
{filtered.length === 0 && query !== '' ? (
typeof displayEmpty === 'function' ? (
displayEmpty(query)
) : (
<p className="pl-4 pr-2 py-1.5 text break-words">
{t('combobox.empty', { query })}
</p>
)
) : (
filtered.map((v, idx) => (
<Combobox.Option
className={({ active, selected }) =>
clsx(
'text-base menu-item',
active && 'active',
selected && 'selected'
)
}
key={idx}
value={v}
>
{(args) =>
typeof displayOption === 'function'
? displayOption(v, args)
: displaySelected(v)
}
</Combobox.Option>
))
)}
</Transition>
</Combobox>
)
}
ComboboxField.displayName = 'ComboboxField'
// --
export default ComboboxField
@layer components {
/* Menu */
.menu {
@apply relative;
}
.menu-header {
@apply pl-4 pr-2 py-1.5 text-xs text-neutral-500 dark:text-neutral-300;
}
.menu-items {
@apply absolute z-10 flex flex-col max-h-[20rem] w-56 px-0 py-1 space-y-0.5 outline-none overflow-y-auto shadow-md card;
}
.menu-item {
@apply flex flex-row items-center cursor-pointer bg-transparent pl-4 pr-2 py-1.5 space-x-2 text-sm text-left text-neutral-600 dark:text-neutral-300 transition-colors;
&.col {
@apply flex-col items-start space-x-0 space-y-2;
}
&.active {
@apply bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-100;
}
&.selected {
@apply bg-primary-500 dark:bg-primary-500 text-neutral-50 dark:text-neutral-50;
}
&.disabled {
@apply cursor-not-allowed text-neutral-300 dark:text-neutral-400;
}
}
}
import { useTranslation } from 'react-i18next'
import { TextField } from '@redwoodjs/forms'
import {
Cancel,
Field,
FieldError,
Form,
Label,
Submit,
} from 'src/components/Forms'
import OrderField from 'src/components/Order/OrderFieldCell'
import WarehouseField from 'src/components/Warehouse/WarehouseFieldCell'
import type { PalletInput, SyncPalletProductInput } from 'types/graphql'
// --
type ProductInput = { products?: SyncPalletProductInput[] }
export type PalletFormData = PalletInput & ProductInput
export interface PalletFormProps {
onCancel?: () => void
onSubmit?: (data: PalletFormData) => void
pallet?: PalletFormData
submit: 'create' | 'update'
}
// --
const PalletForm = ({
onCancel,
onSubmit,
submit,
pallet: { ..., warehouseId },
}: PalletFormProps) => {
//
const { t } = useTranslation()
// --
return (
<Form
defaultValues={{ ..., warehouseId }}
onSubmit={onSubmit}
>
...
{/* Warehouse */}
<Field>
<Label name="warehouseId">{t('pallet.form.warehouseId.label')}</Label>
<WarehouseField
name="warehouseId"
placeholder={t('pallet.form.warehouseId.placeholder')}
/>
<FieldError name="warehouseId" />
</Field>
{/* */}
<Form.Actions>
<Cancel onCancel={onCancel} />
<Submit>{t(`common.${submit}`)}</Submit>
</Form.Actions>
</Form>
)
}
PalletForm.displayName = 'PalletForm'
// --
export default PalletForm
import { useEffect } from 'react'
import type { Options as FlipOptions } from '@floating-ui/core/src/middleware/flip'
import type { Options as OffsetOptions } from '@floating-ui/core/src/middleware/offset'
import type { Options as ShiftOptions } from '@floating-ui/core/src/middleware/shift'
import {
autoUpdate,
flip,
offset,
shift,
useFloating as useFloatingUi,
} from '@floating-ui/react-dom'
import type { Placement, UseFloatingReturn } from '@floating-ui/react-dom'
// --
export type { Placement, UseFloatingReturn }
export interface UseFloatingOptions {
flip?: FlipOptions
offset?: OffsetOptions
placement?: Placement
shift?: ShiftOptions
}
// --
/**
* Wraps [`useFloating`](https://floating-ui.com/docs/react-dom#usage), implementing the [`autoUpdate` utility](https://floating-ui.com/docs/autoupdate) and exposing [flip](https://floating-ui.com/docs/flip#options), [offset](https://floating-ui.com/docs/offset#options), [placement](https://floating-ui.com/docs/computePosition#placement), and [shift](https://floating-ui.com/docs/shift#options) for configuration.
*/
const useFloating = ({
flip: f,
offset: o = 5,
placement = 'bottom',
shift: s,
}: UseFloatingOptions = {}): UseFloatingReturn => {
//
const { refs, update, ...rest } = useFloatingUi({
placement,
middleware: [flip(f), offset(o), shift(s)],
})
// --
useEffect(
() =>
refs.floating.current && refs.reference.current
? autoUpdate(refs.reference.current, refs.floating.current, update)
: null,
[refs.floating, refs.reference, update]
)
// --
return { refs, update, ...rest }
}
export default useFloating
import { useTranslation } from 'react-i18next'
import type { RegisterOptions } from '@redwoodjs/forms'
import { Link, routes } from '@redwoodjs/router'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import { ComboboxField } from 'src/components/Forms'
import SkeletonField from 'src/components/Loaders/SkeletonField'
import type { WarehouseFieldQuery } from 'types/graphql'
// --
export interface WarehouseFieldProps {
name: string
placeholder?: string
validation?: RegisterOptions
}
// --
export const QUERY = gql`
query WarehouseFieldQuery {
warehouses: ListWarehouses {
id
name
}
}
`
// --
export const Loading = () => <SkeletonField />
// --
export const Empty = () => {
//
const { t } = useTranslation()
// --
return (
<p className="italic">
<span className="text">{t('warehouse.field.empty')}</span>&nbsp;
<Link className="link link-primary w-fit" to={routes.listWarehouses()}>
{t('common.createOne')}
</Link>
</p>
)
}
// --
type FailureProps = CellFailureProps & WarehouseFieldProps
export const Failure = ({ error, placeholder }: FailureProps) => {
//
const { t } = useTranslation('errors')
// --
return (
<>
<input className="input error" placeholder={placeholder} readOnly />
<p className="field-error">{t(error.message)}</p>
</>
)
}
// --
type SuccessProps = CellSuccessProps<WarehouseFieldQuery> & WarehouseFieldProps
export const Success = ({ name, placeholder, warehouses }: SuccessProps) => {
//
return (
<ComboboxField
displaySelected={(w) => w.name}
filter={(w, q) => w.name.toLowerCase().includes(q.toLowerCase())}
getIdentifier={(w) => w.id}
placeholder={placeholder}
name={name}
values={warehouses}
/>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment