Last active
October 9, 2022 20:46
-
-
Save realStandal/bd01ed86bbcbe2b20a3fa07551b5b3a6 to your computer and use it in GitHub Desktop.
HeadlessUI Comboxbox integrated with RedwoodJS' form library + Floating UI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
<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