[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:
@@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
padding-right: 45px;
|
padding-inline-end: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuButton {
|
.menuButton {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user