diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss index 68c091a6d2..7947b698a5 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss @@ -3,7 +3,7 @@ } .input { - padding-right: 45px; + padding-inline-end: 45px; } .menuButton { diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx index 412428d345..2c4b82fdcd 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx @@ -82,11 +82,23 @@ const ComboboxDemo: React.FC = () => { const meta = { title: 'Components/Form Fields/ComboboxField', - component: ComboboxDemo, -} satisfies Meta; + component: ComboboxField, + render: () => , +} satisfies Meta; export default meta; type Story = StoryObj; -export const Example: Story = {}; +export const Example: Story = { + args: { + // Adding these types to keep TS happy, they're not passed on to `ComboboxDemo` + label: '', + value: '', + onChange: () => undefined, + items: [], + getItemId: () => '', + renderItem: () => <>Nothing, + onSelectItem: () => undefined, + }, +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx index cfcfc1f5d7..e636a4fb40 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -1,4 +1,3 @@ -import type { ComponentPropsWithoutRef } from 'react'; import { forwardRef, useCallback, useId, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -9,6 +8,7 @@ import Overlay from 'react-overlays/Overlay'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import { matchWidth } from 'flavours/glitch/components/dropdown/utils'; import { IconButton } from 'flavours/glitch/components/icon_button'; import { useOnClickOutside } from 'flavours/glitch/hooks/useOnClickOutside'; @@ -17,6 +17,7 @@ import classes from './combobox.module.scss'; import { FormFieldWrapper } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper'; import { TextInput } from './text_input_field'; +import type { TextInputProps } from './text_input_field'; interface ComboboxItem { id: string; @@ -27,17 +28,45 @@ export interface ComboboxItemState { isDisabled: boolean; } -interface ComboboxProps< - T extends ComboboxItem, -> extends ComponentPropsWithoutRef<'input'> { +interface ComboboxProps extends TextInputProps { + /** + * The value of the combobox's text input + */ value: string; + /** + * Change handler for the text input field + */ onChange: React.ChangeEventHandler; + /** + * Set this to true when the list of options is dynamic and currently loading. + * Causes a loading indicator to be displayed inside of the dropdown menu. + */ isLoading?: boolean; + /** + * The set of options/suggestions that should be rendered in the dropdown menu. + */ items: T[]; + /** + * A function that must return a unique id for each option passed via `items` + */ getItemId: (item: T) => string; + /** + * Providing this function turns the combobox into a multi-select box that assumes + * multiple options to be selectable. Single-selection is handled automatically. + */ getIsItemSelected?: (item: T) => boolean; + /** + * Use this function to mark items as disabled, if needed + */ getIsItemDisabled?: (item: T) => boolean; + /** + * Customise the rendering of each option. + * The rendered content must not contain other interactive content! + */ renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; + /** + * The main selection handler, called when an option is selected or deselected. + */ onSelectItem: (item: T) => void; } @@ -45,8 +74,12 @@ interface Props extends ComboboxProps, CommonFieldWrapperProps {} /** - * The combobox field allows users to select one or multiple items - * from a large list of options by searching or filtering. + * The combobox field allows users to select one or more items + * by searching or filtering a large or dynamic list of options. + * + * It is an implementation of the [APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), + * with inspiration taken from Sarah Higley's extensive combobox + * [research & implementations](https://sarahmhigley.com/writing/select-your-poison/). */ export const ComboboxFieldWithRef = ( @@ -88,6 +121,7 @@ const ComboboxWithRef = ( onSelectItem, onChange, onKeyDown, + icon = SearchIcon, className, ...otherProps }: ComboboxProps, @@ -306,6 +340,7 @@ const ComboboxWithRef = ( value={value} onChange={handleInputChange} onKeyDown={handleInputKeyDown} + icon={icon} className={classNames(classes.input, className)} ref={mergeRefs} /> diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss b/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss index 2299068c5a..289ff1333a 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss @@ -20,6 +20,15 @@ font-size: 16px; } + .iconWrapper & { + // Make space for icon displayed at start of input + padding-inline-start: 36px; + } + + &::placeholder { + color: var(--color-text-secondary); + } + &:focus { outline-color: var(--color-text-brand); } @@ -40,3 +49,17 @@ cursor: not-allowed; } } + +.iconWrapper { + position: relative; +} + +.icon { + pointer-events: none; + position: absolute; + width: 22px; + height: 22px; + inset-inline-start: 10px; + inset-block-start: 10px; + color: var(--color-text-secondary); +} diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx index 2cf8613f68..8e8d7e9923 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; + import { TextInputField, TextInput } from './text_input_field'; const meta = { @@ -42,6 +44,14 @@ export const WithError: Story = { }, }; +export const WithIcon: Story = { + args: { + label: 'Search', + hint: undefined, + icon: SearchIcon, + }, +}; + export const Plain: Story = { render(args) { return ; diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx b/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx index 37cf150147..a95812c8d1 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx @@ -3,12 +3,18 @@ import { forwardRef } from 'react'; import classNames from 'classnames'; +import type { IconProp } from 'flavours/glitch/components/icon'; +import { Icon } from 'flavours/glitch/components/icon'; + import { FormFieldWrapper } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper'; import classes from './text_input.module.scss'; -interface Props - extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {} +export interface TextInputProps extends ComponentPropsWithoutRef<'input'> { + icon?: IconProp; +} + +interface Props extends TextInputProps, CommonFieldWrapperProps {} /** * A simple form field for single-line text. @@ -33,16 +39,33 @@ export const TextInputField = forwardRef( TextInputField.displayName = 'TextInputField'; -export const TextInput = forwardRef< - HTMLInputElement, - ComponentPropsWithoutRef<'input'> ->(({ type = 'text', className, ...otherProps }, ref) => ( - -)); +export const TextInput = forwardRef( + ({ type = 'text', icon, className, ...otherProps }, ref) => ( + + + + ), +); TextInput.displayName = 'TextInput'; + +const WrapFieldWithIcon: React.FC<{ + icon?: IconProp; + children: React.ReactElement; +}> = ({ icon, children }) => { + if (icon) { + return ( +
+ + {children} +
+ ); + } + + return children; +}; diff --git a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx index 152e346764..4d403b3523 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -18,10 +18,7 @@ import { Button } from 'flavours/glitch/components/button'; import { Callout } from 'flavours/glitch/components/callout'; import { DisplayName } from 'flavours/glitch/components/display_name'; import { EmptyState } from 'flavours/glitch/components/empty_state'; -import { - FormStack, - ComboboxField, -} from 'flavours/glitch/components/form_fields'; +import { FormStack, Combobox } from 'flavours/glitch/components/form_fields'; import { Icon } from 'flavours/glitch/components/icon'; import { IconButton } from 'flavours/glitch/components/icon_button'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; @@ -331,6 +328,12 @@ export const CollectionAccounts: React.FC<{ [canSubmit, id, history, accountIds], ); + const inputId = useId(); + const inputLabel = intl.formatMessage({ + id: 'collections.search_accounts_label', + defaultMessage: 'Search for accounts to add…', + }); + return (
@@ -351,21 +354,12 @@ export const CollectionAccounts: React.FC<{ } /> )} - - } - hint={ - hasMaxAccounts ? ( - - ) : undefined - } + + + {hasMaxAccounts && ( + + )} {hasMinAccounts && (