mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
55
app/javascript/flavours/glitch/hooks/useOnClickOutside.ts
Normal file
55
app/javascript/flavours/glitch/hooks/useOnClickOutside.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user