Skip to content

Commit

Permalink
feat: Combobox custom value upgrades
Browse files Browse the repository at this point in the history
  • Loading branch information
heysanil committed Jan 1, 2025
1 parent 2064c92 commit 2e075cc
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 67 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-fireants-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"paris": patch
---

Combobox: add Field overrides
5 changes: 5 additions & 0 deletions .changeset/five-items-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"paris": minor
---

Combobox: Improved custom option props, allowing comboboxes to act more like auto-complete inputs
110 changes: 66 additions & 44 deletions src/stories/combobox/Combobox.stories.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/rules-of-hooks,react/no-children-prop */
import type { Meta, StoryObj } from '@storybook/react';
import { createElement, useState } from 'react';
import type { Option } from './Combobox';
import type { ComboboxProps, Option } from './Combobox';
import { Combobox } from './Combobox';
import { Text } from '../text';

Expand All @@ -12,56 +12,78 @@ const meta: Meta<typeof Combobox> = {
};

export default meta;
type Story = StoryObj<typeof Combobox>;
type Story = StoryObj<typeof Combobox<{ name: string }>>;

export const Default: Story = {
args: {
label: 'Share',
description: 'Search for a friend to share this document with.',
placeholder: 'Search...',
options: [
{
id: '1',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Mia Dolan',
}),
metadata: {
name: 'Mia Dolan',
},
const ComboboxArgs: ComboboxProps<{ name: string }> = {
label: 'Share',
description: 'Search for a friend to share this document with.',
placeholder: 'Search...',
options: [
{
id: '1',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Mia Dolan',
}),
metadata: {
name: 'Mia Dolan',
},
{
id: '2',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Sebastian Wilder',
}),
metadata: {
name: 'Sebastian Wilder',
},
},
{
id: '2',
node: 'SEB',
metadata: {
name: 'Sebastian Wilder',
},
{
id: '3',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Amy Brandt',
}),
metadata: {
name: 'Amy Brandt',
},
},
{
id: '3',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Amy Brandt',
}),
metadata: {
name: 'Amy Brandt',
},
},
{
id: '4',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Laura Wilder',
}),
metadata: {
name: 'Laura Wilder',
},
{
id: '4',
node: createElement(Text, {
kind: 'paragraphSmall',
children: 'Laura Wilder',
}),
},
],
};

export const Default: Story = {
args: ComboboxArgs,
render: (args) => {
const [selected, setSelected] = useState<Option<{ name: string }> | null>(null);
const [inputValue, setInputValue] = useState<string>('');
return createElement('div', {
style: { minHeight: '200px' },
}, createElement(Combobox<{ name: string }>, {
...args,
value: (selected?.id === null) ? {
id: null,
node: inputValue,
metadata: {
name: 'Laura Wilder',
name: inputValue,
},
},
],
} : selected as Option<{ name: string }> | null,
options: (args.options as Option<{ name: string }>[]).filter((o) => (o.metadata?.name as string || '').toLowerCase().includes(inputValue.toLowerCase())),
onChange: (e) => setSelected(e),
onInputChange: (e) => setInputValue(e),
}));
},
};

export const AllowCustomValue: Story = {
args: { ...ComboboxArgs, allowCustomValue: true, customValueString: 'Add "%v"' },
render: (args) => {
const [selected, setSelected] = useState<Option | null>(null);
const [inputValue, setInputValue] = useState<string>('');
Expand Down
92 changes: 69 additions & 23 deletions src/stories/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
import { useId, useState } from 'react';
import {
useMemo, useId, useState,
} from 'react';
import { Combobox as HCombobox, Transition } from '@headlessui/react';
import clsx from 'clsx';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
Expand All @@ -14,6 +16,7 @@ import { Text } from '../text';
import type { InputProps } from '../input';
import { MemoizedEnhancer } from '../../helpers/renderEnhancer';
import { pget, theme } from '../theme';
import type { FieldProps } from '../field';
import { Field } from '../field';
import { Button } from '../button';

Expand Down Expand Up @@ -60,13 +63,27 @@ export type ComboboxProps<T extends Record<string, any>> = {
* @default false
*/
allowCustomValue?: boolean;
/**
* Whether to show the custom value option in the dropdown. This is irrelevant if `allowCustomValue` is `false`.
* @default true
*/
showCustomValueOption?: boolean;
/**
* The text to use for the custom creation option. This should include a `%v` placeholder, which will be replaced with the user's input.
*
* For example, if the user types in `foo` and this is set to "New %v", the custom value option will be rendered as `New "foo"`.
* @default Create "%v"...
*/
customValueString?: string;
/**
* A function that will be called to create an {@link Option} based on the user's custom typed query value. This is useful for adding custom styling by allowing you to pass a custom `Option.node` based on the value. This overrides the `customValueString` prop.
* @param value
*/
customValueToOption?: (value: string) => Option<T>;
/**
* Whether to hide the clear button when a value is selected. This will never be hidden if the selected option's node is not a strong, because there is no other way to clear the value as of now.
*/
hideClearButton?: boolean;
/**
* The size of the options dropdown, in pixels.
*/
Expand All @@ -80,6 +97,7 @@ export type ComboboxProps<T extends Record<string, any>> = {
* Prop overrides for other rendered elements. Overrides for the input itself should be passed directly to the component.
*/
overrides?: {
field?: FieldProps;
container?: ComponentPropsWithoutRef<'div'>;
input?: ComponentPropsWithoutRef<'input'>;
optionsContainer?: ComponentPropsWithoutRef<'div'>;
Expand All @@ -94,6 +112,10 @@ export type ComboboxProps<T extends Record<string, any>> = {
/**
* A Combobox component is used to render a searchable select.
*
* When the selected option node is a strings, the combobox will act like an input even when an option is selected, allowing users to edit the selected option directly in order to pick a new one. To circumvent this and make selected options non-editable, pass nodes that are `Text` components instead.
*
* When `allowCustomValue` is `true`, a custom value option will be added to the dropdown. This option's text can be customized by passing a value for `customValueString`, where `%v` within the string is the user's input. You can provide an entirely custom node through `renderCustomValueOption`. By default, `onChange` will be called for every input change when custom values are allowed.
*
* <hr />
*
* To use this component, import it as follows:
Expand All @@ -118,7 +140,10 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
disabled,
onInputChange,
allowCustomValue,
showCustomValueOption = true,
customValueString = 'Create "%v"',
customValueToOption,
hideClearButton = false,
maxHeight = 320,
hasOptionBorder = false,
overrides,
Expand All @@ -127,6 +152,13 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
const [selectedID, setSelectedID] = useState<string | null>(value?.id || null);
const [query, setQuery] = useState('');

const optionsWithCustomValue = useMemo(() => ([
...((allowCustomValue && customValueToOption) ? [
customValueToOption(query),
] : []),
...options,
]), [allowCustomValue, customValueToOption, options, query]);

return (
<Field
htmlFor={inputID}
Expand All @@ -140,13 +172,14 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
label: overrides?.label,
description: overrides?.description,
}}
{...(overrides?.field ?? {})}
>
<HCombobox
as="div"
value={selectedID}
onChange={(id) => {
if (onChange) {
const sel = options.find((o) => o.id === id);
const sel = optionsWithCustomValue.find((o) => o.id === id);
if (sel) {
onChange(sel);
setSelectedID(sel.id);
Expand Down Expand Up @@ -178,16 +211,23 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
</div>
)}
<div className={styles.content}>
{value ? value.node : (
{(value?.node && typeof value.node !== 'string') ? value.node : (
<HCombobox.Input
id={inputID}
{...overrides?.input}
placeholder={placeholder}
// value={query}
displayValue={(allowCustomValue && typeof value?.node === 'string') ? () => value.node as string : undefined}
onChange={(e) => {
setQuery(e.target.value);
if (onInputChange) onInputChange(e.target.value);
if (overrides?.input?.onChange) overrides.input.onChange(e);
if (allowCustomValue && e.target.value) {
onChange?.(customValueToOption?.(e.target.value) || {
id: null,
node: e.target.value,
});
}
}}
aria-disabled={disabled}
data-status={disabled ? 'disabled' : (status || 'default')}
Expand All @@ -199,7 +239,8 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
/>
)}
</div>
{!!value && (

{(!!value && (!hideClearButton || typeof value.node !== 'string')) && (
<Button
size="xs"
shape="circle"
Expand Down Expand Up @@ -246,7 +287,7 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
'--options-maxHeight': `${maxHeight}px`,
} as CSSProperties}
>
{(allowCustomValue && query.length > 0) && (
{(allowCustomValue && showCustomValueOption && !customValueToOption && query.length > 0) && (
<HCombobox.Option
value={query}
data-selected={false}
Expand All @@ -260,24 +301,29 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
</Text>
</HCombobox.Option>
)}
{(options || []).map((option) => (
<HCombobox.Option
key={option.id}
value={option.id}
data-selected={option.id === value}
className={clsx(
overrides?.option,
styles.option,
hasOptionBorder && styles.optionBorder,
)}
>
{typeof option.node === 'string' ? (
<Text as="span" kind="paragraphSmall">
{option.node}
</Text>
) : option.node}
</HCombobox.Option>
))}
{
(
optionsWithCustomValue || []
)
.map((option) => (
<HCombobox.Option
key={option.id}
value={option.id}
data-selected={option.id === value}
className={clsx(
overrides?.option,
styles.option,
hasOptionBorder && styles.optionBorder,
)}
>
{typeof option.node === 'string' ? (
<Text as="span" kind="paragraphSmall">
{option.node}
</Text>
) : option.node}
</HCombobox.Option>
))
}
</HCombobox.Options>
</Transition>
</HCombobox>
Expand Down

0 comments on commit 2e075cc

Please sign in to comment.