[Glitch] Add new components Combobox and EmptyState

Port 2768ab77e5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-02-06 13:51:27 +01:00
committed by Claire
parent 425d411c9a
commit 9eb718836e
8 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
max-width: 600px;
padding: 20px;
gap: 16px;
text-align: center;
color: var(--color-text-primary);
}
.content {
h3 {
font-size: 17px;
font-weight: 500;
}
p {
font-size: 15px;
margin-top: 8px;
color: var(--color-text-secondary);
}
}

View File

@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Button } from '../button';
import { EmptyState } from '.';
const meta = {
title: 'Components/EmptyState',
component: EmptyState,
argTypes: {
title: {
control: 'text',
type: 'string',
table: {
type: { summary: 'string' },
},
},
},
} satisfies Meta<typeof EmptyState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
message: 'Try clearing filters or refreshing the page.',
},
};
export const WithoutMessage: Story = {
args: {
message: undefined,
},
};
export const WithAction: Story = {
args: {
...Default.args,
// eslint-disable-next-line react/jsx-no-bind
children: <Button onClick={() => action('Refresh')}>Refresh</Button>,
},
};

View File

@@ -0,0 +1,32 @@
import { FormattedMessage } from 'react-intl';
import classes from './empty_state.module.scss';
/**
* Simple empty state component with a neutral default title and customisable message.
*
* Action buttons can be passed as `children`
*/
export const EmptyState: React.FC<{
title?: string | React.ReactElement;
message?: string | React.ReactElement;
children?: React.ReactNode;
}> = ({
title = (
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
),
message,
children,
}) => {
return (
<div className={classes.wrapper}>
<div className={classes.content}>
<h3>{title}</h3>
{!!message && <p>{message}</p>}
</div>
{children}
</div>
);
};

View File

@@ -0,0 +1,69 @@
.wrapper {
position: relative;
}
.input {
padding-right: 45px;
}
.menuButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
&::before {
// Subtle divider line separating the button from the input field
content: '';
position: absolute;
inset-inline-start: 0;
inset-block: 10px;
border-inline-start: 1px solid var(--color-border-primary);
}
}
.popover {
z-index: 9999;
box-sizing: border-box;
padding: 4px;
border-radius: 4px;
color: var(--color-text-primary);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-primary);
box-shadow: var(--dropdown-shadow);
// backdrop-filter: $backdrop-blur-filter;
}
.menuItem {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 12px;
font-size: 14px;
line-height: 20px;
border-radius: 4px;
color: var(--color-text-primary);
cursor: pointer;
user-select: none;
&[aria-selected='true'] {
color: var(--color-text-on-brand-base);
background: var(--color-bg-brand-base);
&[aria-disabled='true'] {
color: var(--color-text-on-disabled);
background: var(--color-bg-disabled);
}
}
&[aria-disabled='true'] {
color: var(--color-text-disabled);
cursor: not-allowed;
}
}
.emptyMessage {
padding: 8px 16px;
font-size: 13px;
}

View File

@@ -0,0 +1,92 @@
import { useCallback, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComboboxField } from './combobox_field';
const ComboboxDemo: React.FC = () => {
const [searchValue, setSearchValue] = useState('');
const items = [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
{ id: '3', name: 'Cherry', disabled: true },
{ id: '4', name: 'Date' },
{ id: '5', name: 'Fig', disabled: true },
{ id: '6', name: 'Grape' },
{ id: '7', name: 'Honeydew' },
{ id: '8', name: 'Kiwi' },
{ id: '9', name: 'Lemon' },
{ id: '10', name: 'Mango' },
{ id: '11', name: 'Nectarine' },
{ id: '12', name: 'Orange' },
{ id: '13', name: 'Papaya' },
{ id: '14', name: 'Quince' },
{ id: '15', name: 'Raspberry' },
{ id: '16', name: 'Strawberry' },
{ id: '17', name: 'Tangerine' },
{ id: '19', name: 'Vanilla bean' },
{ id: '20', name: 'Watermelon' },
{ id: '22', name: 'Yellow Passion Fruit' },
{ id: '23', name: 'Zucchini' },
{ id: '24', name: 'Cantaloupe' },
{ id: '25', name: 'Blackberry' },
{ id: '26', name: 'Persimmon' },
{ id: '27', name: 'Lychee' },
{ id: '28', name: 'Dragon Fruit' },
{ id: '29', name: 'Passion Fruit' },
{ id: '30', name: 'Starfruit' },
];
type Fruit = (typeof items)[number];
const getItemId = useCallback((item: Fruit) => item.id, []);
const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []);
const handleSearchValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
},
[],
);
const selectFruit = useCallback((selectedItem: Fruit) => {
setSearchValue(selectedItem.name);
}, []);
const renderItem = useCallback(
(fruit: Fruit) => <span>{fruit.name}</span>,
[],
);
// Don't filter results if an exact match has been entered
const shouldFilterResults = !items.find((item) => searchValue === item.name);
const results = shouldFilterResults
? items.filter((item) =>
item.name.toLowerCase().includes(searchValue.toLowerCase()),
)
: items;
return (
<ComboboxField
label='Favourite fruit'
value={searchValue}
onChange={handleSearchValueChange}
items={results}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
onSelectItem={selectFruit}
renderItem={renderItem}
/>
);
};
const meta = {
title: 'Components/Form Fields/ComboboxField',
component: ComboboxDemo,
} satisfies Meta<typeof ComboboxDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Example: Story = {};

View File

@@ -0,0 +1,408 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useCallback, useId, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
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 { matchWidth } from 'flavours/glitch/components/dropdown/utils';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useOnClickOutside } from 'flavours/glitch/hooks/useOnClickOutside';
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';
interface Item {
id: string;
}
interface ComboboxProps<
T extends Item,
> extends ComponentPropsWithoutRef<'input'> {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
isLoading?: boolean;
items: T[];
getItemId: (item: T) => string;
getIsItemDisabled?: (item: T) => boolean;
renderItem: (item: T) => React.ReactElement;
onSelectItem: (item: T) => void;
}
interface Props<T extends Item>
extends ComboboxProps<T>, CommonFieldWrapperProps {}
/**
* The combobox field allows users to select one or multiple items
* from a large list of options by searching or filtering.
*/
export const ComboboxFieldWithRef = <T extends Item>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
);
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
<T extends Item>(
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxFieldWithRef>;
displayName: string;
};
ComboboxField.displayName = 'ComboboxField';
const ComboboxWithRef = <T extends Item>(
{
value,
isLoading = false,
items,
getItemId,
getIsItemDisabled,
renderItem,
onSelectItem,
onChange,
onKeyDown,
className,
...otherProps
}: ComboboxProps<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>();
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
null,
);
const [shouldMenuOpen, setShouldMenuOpen] = useState(false);
const statusMessage = useGetA11yStatusMessage({
value,
isLoading,
itemCount: items.length,
});
const showStatusMessageInMenu =
!!statusMessage && value.length > 0 && items.length === 0;
const hasMenuContent = items.length > 0 || showStatusMessageInMenu;
const isMenuOpen = shouldMenuOpen && hasMenuContent;
const openMenu = useCallback(() => {
setShouldMenuOpen(true);
}, []);
const closeMenu = useCallback(() => {
setShouldMenuOpen(false);
}, []);
const resetHighlight = useCallback(() => {
const firstItem = items[0];
const firstItemId = firstItem ? getItemId(firstItem) : null;
setHighlightedItemId(firstItemId);
}, [getItemId, items]);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e);
resetHighlight();
setShouldMenuOpen(!!e.target.value);
},
[onChange, resetHighlight],
);
const handleHighlightItem = useCallback(
(e: React.MouseEvent<HTMLLIElement>) => {
const { itemId } = e.currentTarget.dataset;
if (itemId) {
setHighlightedItemId(itemId);
}
},
[],
);
const selectItem = useCallback(
(itemId: string | null) => {
const item = items.find((item) => item.id === itemId);
if (item) {
const isDisabled = getIsItemDisabled?.(item) ?? false;
if (!isDisabled) {
onSelectItem(item);
}
}
inputRef.current?.focus();
},
[getIsItemDisabled, items, onSelectItem],
);
const handleSelectItem = useCallback(
(e: React.MouseEvent<HTMLLIElement>) => {
const { itemId } = e.currentTarget.dataset;
selectItem(itemId ?? null);
},
[selectItem],
);
const selectHighlightedItem = useCallback(() => {
selectItem(highlightedItemId);
}, [highlightedItemId, selectItem]);
const moveHighlight = useCallback(
(direction: number) => {
if (items.length === 0) {
return;
}
const highlightedItemIndex = items.findIndex(
(item) => getItemId(item) === highlightedItemId,
);
if (highlightedItemIndex === -1) {
// If no item is highlighted yet, highlight the first or last
if (direction > 0) {
const firstItem = items.at(0);
setHighlightedItemId(firstItem ? getItemId(firstItem) : null);
} else {
const lastItem = items.at(-1);
setHighlightedItemId(lastItem ? getItemId(lastItem) : null);
}
} else {
// If there is a highlighted item, select the next or previous item
// and wrap around at the start or end:
let newIndex = highlightedItemIndex + direction;
if (newIndex >= items.length) {
newIndex = 0;
} else if (newIndex < 0) {
newIndex = items.length - 1;
}
const newHighlightedItem = items[newIndex];
setHighlightedItemId(
newHighlightedItem ? getItemId(newHighlightedItem) : null,
);
}
},
[getItemId, highlightedItemId, items],
);
useOnClickOutside(wrapperRef, closeMenu);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.key === 'ArrowUp') {
e.preventDefault();
if (isMenuOpen) {
moveHighlight(-1);
} else {
openMenu();
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (isMenuOpen) {
moveHighlight(1);
} else {
openMenu();
}
}
if (e.key === 'Tab') {
if (isMenuOpen) {
selectHighlightedItem();
closeMenu();
}
}
if (e.key === 'Enter') {
if (isMenuOpen) {
e.preventDefault();
selectHighlightedItem();
closeMenu();
}
}
if (e.key === 'Escape') {
if (isMenuOpen) {
e.preventDefault();
closeMenu();
}
}
},
[
closeMenu,
isMenuOpen,
moveHighlight,
onKeyDown,
openMenu,
selectHighlightedItem,
],
);
const mergeRefs = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref],
);
const id = useId();
const listId = `${id}-list`;
return (
<div className={classes.wrapper} ref={wrapperRef}>
<TextInput
role='combobox'
{...otherProps}
aria-controls={listId}
aria-expanded={isMenuOpen ? 'true' : 'false'}
aria-haspopup='true'
aria-activedescendant={
isMenuOpen && highlightedItemId ? highlightedItemId : undefined
}
aria-autocomplete='list'
autoComplete='off'
spellCheck='false'
value={value}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
className={classNames(classes.input, className)}
ref={mergeRefs}
/>
{hasMenuContent && (
<IconButton
title={
isMenuOpen
? intl.formatMessage({
id: 'combobox.close_results',
defaultMessage: 'Close results',
})
: intl.formatMessage({
id: 'combobox.open_results',
defaultMessage: 'Open results',
})
}
className={classes.menuButton}
icon='results'
iconComponent={
isMenuOpen ? KeyboardArrowUpIcon : KeyboardArrowDownIcon
}
onClick={isMenuOpen ? closeMenu : openMenu}
/>
)}
<span role='status' aria-live='polite' className='sr-only'>
{isMenuOpen && statusMessage}
</span>
<Overlay
flip
show={isMenuOpen}
offset={[0, 1]}
placement='bottom-start'
onHide={closeMenu}
target={inputRef as React.RefObject<HTMLInputElement>}
container={wrapperRef}
popperConfig={{
modifiers: [matchWidth],
}}
>
{({ props, placement }) => (
<div {...props} className={classNames(classes.popover, placement)}>
{showStatusMessageInMenu ? (
<span className={classes.emptyMessage}>{statusMessage}</span>
) : (
<ul role='listbox' id={listId} tabIndex={-1}>
{items.map((item) => {
const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
key={id}
role='option'
className={classes.menuItem}
aria-selected={id === highlightedItemId}
aria-disabled={isDisabled}
data-item-id={id}
onMouseEnter={handleHighlightItem}
onClick={handleSelectItem}
>
{renderItem(item)}
</li>
);
})}
</ul>
)}
</div>
)}
</Overlay>
</div>
);
};
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const Combobox = forwardRef(ComboboxWithRef) as {
<T extends Item>(
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};
Combobox.displayName = 'Combobox';
function useGetA11yStatusMessage({
itemCount,
value,
isLoading,
}: {
itemCount: number;
value: string;
isLoading: boolean;
}): string {
const intl = useIntl();
if (isLoading) {
return intl.formatMessage({
id: 'combobox.loading',
defaultMessage: 'Loading',
});
}
if (value.length && !itemCount) {
return intl.formatMessage({
id: 'combobox.no_results_found',
defaultMessage: 'No results for this search',
});
}
if (itemCount > 0) {
return intl.formatMessage(
{
id: 'combobox.results_available',
defaultMessage:
'{count, plural, one {# suggestion} other {# suggestions}} available. Use up and down arrow keys to navigate. Press Enter key to select.',
},
{
count: itemCount,
},
);
}
return '';
}

View File

@@ -3,6 +3,7 @@ export { Fieldset } from './fieldset';
export { TextInputField, TextInput } from './text_input_field';
export { TextAreaField, TextArea } from './text_area_field';
export { CheckboxField, Checkbox } from './checkbox_field';
export { ComboboxField, Combobox } from './combobox_field';
export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field';

View File

@@ -0,0 +1,55 @@
/**
* Handle clicks that occur outside of the element(s) provided in the first parameter
*/
import type { MutableRefObject } from 'react';
import { useEffect } from 'react';
type ElementRef = MutableRefObject<HTMLElement | null>;
export function useOnClickOutside(
excludedElementRef: ElementRef | ElementRef[] | null,
onClick: (e: MouseEvent) => void,
enabled = true,
) {
useEffect(() => {
// If the search popover is expanded, close it when tabbing or
// clicking outside of it or the search form, while allowing
// tabbing or clicking inside of the popover
if (enabled) {
function handleClickOutside(event: MouseEvent) {
const excludedRefs = Array.isArray(excludedElementRef)
? excludedElementRef
: [excludedElementRef];
for (const ref of excludedRefs) {
const excludedElement = ref?.current;
// Bail out if the clicked element or the currently focused element
// is inside of excludedElement. We're also checking the focused element
// to prevent an issue in Chrome where initiating a drag inside of an
// input (to select the text inside of it) and ending that drag outside
// of the input fires a click event, breaking our excludedElement rule.
if (
excludedElement &&
(excludedElement === event.target ||
excludedElement === document.activeElement ||
excludedElement.contains(event.target as Node) ||
excludedElement.contains(document.activeElement))
) {
return;
}
}
onClick(event);
}
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}
return () => null;
}, [enabled, excludedElementRef, onClick]);
}