Files
overleaf-cep/services/web/frontend/js/shared/components/select.tsx
Rebeka Dekany 3b5ea89a1c Update loading spinner status (#28177)
* Update test for the loading spinner component

* Create a story for the loading spinner component

* Move role and use CSS for spacing instead

* Add a classname prop

* Reuse LoadingSpinner

* Use OLSpinner instead of Spinner

* Use data-testid since status role was moved

* Wait for journals to load

* Use `isLoading` prop instead and fix the button's height

* Use `isLoading` prop instead

* Use LoadingSpinner instead and remove spacing

* Update test for the loading spinner component

* Use `isLoading` prop instead

* Add aria-describedby to layout button for processing state

* Replace `spinner` to `ol-spinner`

* Scope status

* Remove redundant `div.loading`

---------

Co-authored-by: Antoine Clausse <antoine.clausse@overleaf.com>
GitOrigin-RevId: 8f43b991f8f458b2abd5a4661913ac9b972d892a
2025-09-26 08:05:46 +00:00

214 lines
6.5 KiB
TypeScript

import {
useRef,
useEffect,
KeyboardEventHandler,
useCallback,
ReactNode,
useState,
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
import { useTranslation } from 'react-i18next'
import { Form } from 'react-bootstrap'
import FormControl from '@/shared/components/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
import OLSpinner from './ol/ol-spinner'
export type SelectProps<T> = {
// The items rendered as dropdown options.
items: T[]
// Stringifies an item of type T. The resulting string is rendered as a dropdown option.
itemToString: (item: T | null | undefined) => string
// Caption for the dropdown.
label?: ReactNode
// Attribute used to identify the component inside a Form. This name is used to
// retrieve FormData when the form is submitted. The value of the FormData entry
// is the string returned by `itemToString(selectedItem)`.
name?: string
// Hint text displayed in the initial render.
defaultText?: string
// Initial selected item, displayed in the initial render. When both `defaultText`
// and `defaultItem` are set the latter is ignored.
defaultItem?: T | null
// Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
itemToSubtitle?: (item: T | null | undefined) => string
// Stringifies an item. The resulting string is rendered as a React `key` for each item.
itemToKey: (item: T) => string
// Callback invoked after the selected item is updated.
onSelectedItemChanged?: (item: T | null | undefined) => void
// Optionally directly control the selected item.
selected?: T | null
// When `true` item selection is disabled.
disabled?: boolean
// Determine which items should be disabled
itemToDisabled?: (item: T | null | undefined) => boolean
// When `true` displays an "Optional" subtext after the `label` caption.
optionalLabel?: boolean
// When `true` displays a spinner next to the `label` caption.
loading?: boolean
// Show a checkmark next to the selected item
selectedIcon?: boolean
// testId for the input element
dataTestId?: string
}
export const Select = <T,>({
items,
itemToString = item => (item === null ? '' : String(item)),
label,
name,
defaultText = 'Items',
defaultItem,
itemToSubtitle,
itemToKey,
onSelectedItemChanged,
selected,
disabled = false,
itemToDisabled,
optionalLabel = false,
loading = false,
selectedIcon = false,
dataTestId,
}: SelectProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
)
const { t } = useTranslation()
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getItemProps,
highlightedIndex,
openMenu,
closeMenu,
} = useSelect({
items: items ?? [],
itemToString,
isItemDisabled: item => itemToDisabled?.(item) || false,
selectedItem: selected || defaultItem,
onSelectedItemChange: changes => {
if (onSelectedItemChanged) {
onSelectedItemChanged(changes.selectedItem)
}
setSelectedItem(changes.selectedItem)
},
})
useEffect(() => {
setSelectedItem(selected)
}, [selected])
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!name || !rootRef.current) return
const parentForm: HTMLFormElement | null | undefined =
rootRef.current?.closest('form')
if (!parentForm) return
function handleFormDataEvent(event: FormDataEvent) {
const data = event.formData
const key = name as string // can't be undefined due to early exit in the effect
if (selectedItem || defaultItem) {
data.append(key, itemToString(selectedItem || defaultItem))
}
}
parentForm.addEventListener('formdata', handleFormDataEvent)
return () => {
parentForm.removeEventListener('formdata', handleFormDataEvent)
}
}, [name, itemToString, selectedItem, defaultItem])
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
event => {
if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
event.preventDefault()
;(event.nativeEvent as any).preventDownshiftDefault = true
openMenu()
} else if (event.key === 'Escape' && isOpen) {
event.stopPropagation()
closeMenu()
}
},
[closeMenu, isOpen, openMenu]
)
let value: string | undefined
if (selectedItem || defaultItem) {
value = itemToString(selectedItem || defaultItem)
} else {
value = defaultText
}
return (
<div className="select-wrapper" ref={rootRef}>
{label ? (
<Form.Label {...getLabelProps()}>
{label}{' '}
{optionalLabel && (
<span className="fw-normal">({t('optional')})</span>
)}{' '}
{loading && <OLSpinner size="sm" />}
</Form.Label>
) : null}
<FormControl
data-testid={dataTestId}
{...getToggleButtonProps({
disabled,
onKeyDown,
className: 'select-trigger',
})}
value={value}
readOnly
append={
<MaterialIcon
type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className="align-text-bottom"
/>
}
/>
<ul
{...getMenuProps({ disabled })}
className={classNames('dropdown-menu w-100', { show: isOpen })}
>
{isOpen &&
items?.map((item, index) => {
// We're using an actual disabled button so we don't need the
// aria-disabled prop
const { 'aria-disabled': disabled, ...itemProps } = getItemProps({
item,
index,
})
return (
<li role="none" key={itemToKey(item)}>
<DropdownItem
as="button"
type="button"
className={classNames({
'select-highlighted': highlightedIndex === index,
})}
active={selectedItem === item}
trailingIcon={
selectedIcon && selectedItem === item ? 'check' : undefined
}
description={
itemToSubtitle ? itemToSubtitle(item) : undefined
}
{...itemProps}
disabled={disabled}
>
{itemToString(item)}
</DropdownItem>
</li>
)
})}
</ul>
</div>
)
}