[Glitch] Allow displaying icon in TextInput component

Port e0cc3a30ef to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-02-23 15:12:02 +01:00
committed by Claire
parent 8ced537389
commit 25e1ade5e2
7 changed files with 146 additions and 43 deletions

View File

@@ -3,7 +3,7 @@
} }
.input { .input {
padding-right: 45px; padding-inline-end: 45px;
} }
.menuButton { .menuButton {

View File

@@ -82,11 +82,23 @@ const ComboboxDemo: React.FC = () => {
const meta = { const meta = {
title: 'Components/Form Fields/ComboboxField', title: 'Components/Form Fields/ComboboxField',
component: ComboboxDemo, component: ComboboxField,
} satisfies Meta<typeof ComboboxDemo>; render: () => <ComboboxDemo />,
} satisfies Meta<typeof ComboboxField>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
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,
},
};

View File

@@ -1,4 +1,3 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useCallback, useId, useRef, useState } from 'react'; import { forwardRef, useCallback, useId, useRef, useState } from 'react';
import { useIntl } from 'react-intl'; 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 KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.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 { matchWidth } from 'flavours/glitch/components/dropdown/utils';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { useOnClickOutside } from 'flavours/glitch/hooks/useOnClickOutside'; import { useOnClickOutside } from 'flavours/glitch/hooks/useOnClickOutside';
@@ -17,6 +17,7 @@ import classes from './combobox.module.scss';
import { FormFieldWrapper } from './form_field_wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { TextInput } from './text_input_field'; import { TextInput } from './text_input_field';
import type { TextInputProps } from './text_input_field';
interface ComboboxItem { interface ComboboxItem {
id: string; id: string;
@@ -27,17 +28,45 @@ export interface ComboboxItemState {
isDisabled: boolean; isDisabled: boolean;
} }
interface ComboboxProps< interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
T extends ComboboxItem, /**
> extends ComponentPropsWithoutRef<'input'> { * The value of the combobox's text input
*/
value: string; value: string;
/**
* Change handler for the text input field
*/
onChange: React.ChangeEventHandler<HTMLInputElement>; onChange: React.ChangeEventHandler<HTMLInputElement>;
/**
* 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; isLoading?: boolean;
/**
* The set of options/suggestions that should be rendered in the dropdown menu.
*/
items: T[]; items: T[];
/**
* A function that must return a unique id for each option passed via `items`
*/
getItemId: (item: T) => string; 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; getIsItemSelected?: (item: T) => boolean;
/**
* Use this function to mark items as disabled, if needed
*/
getIsItemDisabled?: (item: T) => boolean; 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; renderItem: (item: T, state: ComboboxItemState) => React.ReactElement;
/**
* The main selection handler, called when an option is selected or deselected.
*/
onSelectItem: (item: T) => void; onSelectItem: (item: T) => void;
} }
@@ -45,8 +74,12 @@ interface Props<T extends ComboboxItem>
extends ComboboxProps<T>, CommonFieldWrapperProps {} extends ComboboxProps<T>, CommonFieldWrapperProps {}
/** /**
* The combobox field allows users to select one or multiple items * The combobox field allows users to select one or more items
* from a large list of options by searching or filtering. * 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 = <T extends ComboboxItem>( export const ComboboxFieldWithRef = <T extends ComboboxItem>(
@@ -88,6 +121,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
onSelectItem, onSelectItem,
onChange, onChange,
onKeyDown, onKeyDown,
icon = SearchIcon,
className, className,
...otherProps ...otherProps
}: ComboboxProps<T>, }: ComboboxProps<T>,
@@ -306,6 +340,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
value={value} value={value}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
icon={icon}
className={classNames(classes.input, className)} className={classNames(classes.input, className)}
ref={mergeRefs} ref={mergeRefs}
/> />

View File

@@ -20,6 +20,15 @@
font-size: 16px; font-size: 16px;
} }
.iconWrapper & {
// Make space for icon displayed at start of input
padding-inline-start: 36px;
}
&::placeholder {
color: var(--color-text-secondary);
}
&:focus { &:focus {
outline-color: var(--color-text-brand); outline-color: var(--color-text-brand);
} }
@@ -40,3 +49,17 @@
cursor: not-allowed; 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);
}

View File

@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; 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'; import { TextInputField, TextInput } from './text_input_field';
const meta = { 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 = { export const Plain: Story = {
render(args) { render(args) {
return <TextInput {...args} />; return <TextInput {...args} />;

View File

@@ -3,12 +3,18 @@ import { forwardRef } from 'react';
import classNames from 'classnames'; 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 { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss'; import classes from './text_input.module.scss';
interface Props export interface TextInputProps extends ComponentPropsWithoutRef<'input'> {
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {} icon?: IconProp;
}
interface Props extends TextInputProps, CommonFieldWrapperProps {}
/** /**
* A simple form field for single-line text. * A simple form field for single-line text.
@@ -33,16 +39,33 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
TextInputField.displayName = 'TextInputField'; TextInputField.displayName = 'TextInputField';
export const TextInput = forwardRef< export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
HTMLInputElement, ({ type = 'text', icon, className, ...otherProps }, ref) => (
ComponentPropsWithoutRef<'input'> <WrapFieldWithIcon icon={icon}>
>(({ type = 'text', className, ...otherProps }, ref) => ( <input
<input type={type}
type={type} {...otherProps}
{...otherProps} className={classNames(className, classes.input)}
className={classNames(className, classes.input)} ref={ref}
ref={ref} />
/> </WrapFieldWithIcon>
)); ),
);
TextInput.displayName = 'TextInput'; TextInput.displayName = 'TextInput';
const WrapFieldWithIcon: React.FC<{
icon?: IconProp;
children: React.ReactElement;
}> = ({ icon, children }) => {
if (icon) {
return (
<div className={classes.iconWrapper}>
<Icon icon={icon} id='input-icon' className={classes.icon} />
{children}
</div>
);
}
return children;
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useId, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; 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 { Callout } from 'flavours/glitch/components/callout';
import { DisplayName } from 'flavours/glitch/components/display_name'; import { DisplayName } from 'flavours/glitch/components/display_name';
import { EmptyState } from 'flavours/glitch/components/empty_state'; import { EmptyState } from 'flavours/glitch/components/empty_state';
import { import { FormStack, Combobox } from 'flavours/glitch/components/form_fields';
FormStack,
ComboboxField,
} from 'flavours/glitch/components/form_fields';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
@@ -331,6 +328,12 @@ export const CollectionAccounts: React.FC<{
[canSubmit, id, history, accountIds], [canSubmit, id, history, accountIds],
); );
const inputId = useId();
const inputLabel = intl.formatMessage({
id: 'collections.search_accounts_label',
defaultMessage: 'Search for accounts to add…',
});
return ( return (
<form onSubmit={handleSubmit} className={classes.form}> <form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}> <FormStack className={classes.formFieldStack}>
@@ -351,21 +354,12 @@ export const CollectionAccounts: React.FC<{
} }
/> />
)} )}
<ComboboxField <label htmlFor={inputId} className='sr-only'>
label={ {inputLabel}
<FormattedMessage </label>
id='collections.search_accounts_label' <Combobox
defaultMessage='Search for accounts to add…' id={inputId}
/> placeholder={inputLabel}
}
hint={
hasMaxAccounts ? (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
) : undefined
}
value={hasMaxAccounts ? '' : searchValue} value={hasMaxAccounts ? '' : searchValue}
onChange={handleSearchValueChange} onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
@@ -379,6 +373,12 @@ export const CollectionAccounts: React.FC<{
isEditMode ? instantToggleAccountItem : toggleAccountItem isEditMode ? instantToggleAccountItem : toggleAccountItem
} }
/> />
{hasMaxAccounts && (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
)}
{hasMinAccounts && ( {hasMinAccounts && (
<Callout> <Callout>