diff --git a/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss new file mode 100644 index 0000000000..1707b3bc08 --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/empty_state.module.scss @@ -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); + } +} diff --git a/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx b/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx new file mode 100644 index 0000000000..8515a6ea1a --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/empty_state.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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: , + }, +}; diff --git a/app/javascript/flavours/glitch/components/empty_state/index.tsx b/app/javascript/flavours/glitch/components/empty_state/index.tsx new file mode 100644 index 0000000000..93f034f3e9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/empty_state/index.tsx @@ -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 = ( + + ), + message, + children, +}) => { + return ( +
+
+

{title}

+ {!!message &&

{message}

} +
+ + {children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss new file mode 100644 index 0000000000..98c0db9f61 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox.module.scss @@ -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; +} 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 new file mode 100644 index 0000000000..412428d345 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.stories.tsx @@ -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) => { + setSearchValue(event.target.value); + }, + [], + ); + + const selectFruit = useCallback((selectedItem: Fruit) => { + setSearchValue(selectedItem.name); + }, []); + + const renderItem = useCallback( + (fruit: Fruit) => {fruit.name}, + [], + ); + + // 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 ( + + ); +}; + +const meta = { + title: 'Components/Form Fields/ComboboxField', + component: ComboboxDemo, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = {}; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx new file mode 100644 index 0000000000..9751748ea7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -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; + isLoading?: boolean; + items: T[]; + getItemId: (item: T) => string; + getIsItemDisabled?: (item: T) => boolean; + renderItem: (item: T) => React.ReactElement; + onSelectItem: (item: T) => void; +} + +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. + */ + +export const ComboboxFieldWithRef = ( + { id, label, hint, hasError, required, ...otherProps }: Props, + ref: React.ForwardedRef, +) => ( + + {(inputProps) => } + +); + +// 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 { + ( + props: Props & { ref?: React.ForwardedRef }, + ): ReturnType; + displayName: string; +}; + +ComboboxField.displayName = 'ComboboxField'; + +const ComboboxWithRef = ( + { + value, + isLoading = false, + items, + getItemId, + getIsItemDisabled, + renderItem, + onSelectItem, + onChange, + onKeyDown, + className, + ...otherProps + }: ComboboxProps, + ref: React.ForwardedRef, +) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const inputRef = useRef(); + + const [highlightedItemId, setHighlightedItemId] = useState( + 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) => { + onChange(e); + resetHighlight(); + setShouldMenuOpen(!!e.target.value); + }, + [onChange, resetHighlight], + ); + + const handleHighlightItem = useCallback( + (e: React.MouseEvent) => { + 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) => { + 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) => { + 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 ( +
+ + {hasMenuContent && ( + + )} + + {isMenuOpen && statusMessage} + + } + container={wrapperRef} + popperConfig={{ + modifiers: [matchWidth], + }} + > + {({ props, placement }) => ( +
+ {showStatusMessageInMenu ? ( + {statusMessage} + ) : ( +
    + {items.map((item) => { + const id = getItemId(item); + const isDisabled = getIsItemDisabled?.(item); + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
  • + {renderItem(item)} +
  • + ); + })} +
+ )} +
+ )} +
+
+ ); +}; + +// 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 { + ( + props: ComboboxProps & { ref?: React.ForwardedRef }, + ): ReturnType; + 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 ''; +} diff --git a/app/javascript/flavours/glitch/components/form_fields/index.ts b/app/javascript/flavours/glitch/components/form_fields/index.ts index e44525e383..ef4a6567e5 100644 --- a/app/javascript/flavours/glitch/components/form_fields/index.ts +++ b/app/javascript/flavours/glitch/components/form_fields/index.ts @@ -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'; diff --git a/app/javascript/flavours/glitch/hooks/useOnClickOutside.ts b/app/javascript/flavours/glitch/hooks/useOnClickOutside.ts new file mode 100644 index 0000000000..ad964978c8 --- /dev/null +++ b/app/javascript/flavours/glitch/hooks/useOnClickOutside.ts @@ -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; + +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]); +}