diff --git a/assets/css/_grouped_autocomplete.scss b/assets/css/_grouped_autocomplete.scss new file mode 100644 index 000000000..6df3966e0 --- /dev/null +++ b/assets/css/_grouped_autocomplete.scss @@ -0,0 +1,93 @@ +// #region Autocomplete +.c-autocomplete { + padding: 1rem; + padding-bottom: 0.875rem; + + color: $color-gray-700; + + background-color: $white; + + border: 1px solid $color-gray-400; + border-top-color: transparent; + border-radius: 0 0 4px 4px; + + box-shadow: 0 0 10px $color-gray-400; + box-shadow: 0 5px 5px $color-gray-400; + + &__list &__group-heading:not(:empty) { + // make space between heading and list content + margin-bottom: 0.625rem; + } + + &__group-list { + // Remove default styling on margin for lists + margin: 0; + } + + &__group-heading { + margin: 0; + + // All heading text must be on one line, with no extra space between it and + // it's list items. All text must be a similar style. + > * { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + + color: $color-gray-700; + font-size: var(--font-size-xs); + font-weight: normal; + } + } + + &__group-heading:not(:first-of-type) { + // Display a line between each group + border-top: 1px solid $color-gray-100; + margin-top: 1rem; + padding-top: 1rem; + line-height: 1; + } + + &__option { + --horizontal-padding: 0.5rem; + --negative-horizontal-padding: calc(var(--horizontal-padding) * -1); + + display: block; + // Size content area to available width + width: 100%; + box-sizing: content-box !important; + + position: relative; + // Widen tap, hover, and focus area + right: var(--negative-horizontal-padding); + left: var(--negative-horizontal-padding); + // Pad the contents back into the "available space" + padding: 0.5rem var(--horizontal-padding) 0.5rem var(--horizontal-padding); + + font-size: var(--font-size-s); + line-height: 1; + text-align: left; + + border-radius: 4px; + + &:hover { + background-color: $color-gray-100; + } + + &:focus { + // When focused, bring to front of stack, + // So that hover's `background-color` does not overlap the focus ring + isolation: isolate; + z-index: 1; + outline: $color-eggplant-600 solid 1.5px; + } + } + + &:focus, + &:active { + border: 1px solid $color-eggplant-500; + box-shadow: 0 5px 5px $color-gray-400, 0 0 0 2px #aa4ef22b; + } +} + +// #endregion Autocomplete diff --git a/assets/css/_search_form.scss b/assets/css/_search_form.scss index 0be8a349a..5c548ff83 100644 --- a/assets/css/_search_form.scss +++ b/assets/css/_search_form.scss @@ -2,6 +2,16 @@ display: flex; flex-flow: column nowrap; gap: 1rem; + + // Prevent intersection between accordion and autocomplete popup + .c-filter-accordion, + .c-search-form__search-control { + isolation: isolate; + } + .c-search-form__search-control { + // Stack items within search-control on top of accordion + z-index: 1; + } } .c-search-form__search-control { @@ -105,3 +115,41 @@ } } // #endregion Search Input Buttons + +// #region Autocomplete Control +// Hide the autocomplete control by default +.c-search-form__autocomplete-container { + display: none; +} + +// When input controls have focus -- +.c-search-form__search-control[data-autocomplete-visible="true"]:focus-within { + // -- Show Autocomplete control + .c-search-form__autocomplete-container { + display: block; + } + + // -- Visually join the bottom of the input bar with autocomplete by squaring + // off the bottom + .c-search-form__search-input-container { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } +} + +.c-search-form__autocomplete-container { + // Isolate to ensure that floating on top of other elements doesn't intersect + // in the Z axis. + isolation: isolate; + // Move behind the input control so focus styles overlap the autocomplete + // control + position: relative; + z-index: -1; + + &:focus-within { + // Attempt to put the autocomplete control on top of the input control if + // the autocomplete control has focus + z-index: 1; + } +} +// #endregion Autocomplete Control diff --git a/assets/css/app.scss b/assets/css/app.scss index 4e036541d..8d7b045fa 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -74,6 +74,7 @@ $list-group-border-color: $color-gray-300; @import "incoming_box"; @import "input_modal"; @import "ladder_page"; +@import "grouped_autocomplete"; @import "ladder"; @import "late_view"; @import "loading_modal"; @@ -145,7 +146,7 @@ $list-group-border-color: $color-gray-300; box-sizing: border-box; } -.inherit-box :is(*, *::before, *::after) { +.inherit-box :where(*, *::before, *::after) { box-sizing: inherit; } diff --git a/assets/src/components/groupedAutocomplete.tsx b/assets/src/components/groupedAutocomplete.tsx new file mode 100644 index 000000000..1a0672d2d --- /dev/null +++ b/assets/src/components/groupedAutocomplete.tsx @@ -0,0 +1,739 @@ +import { captureException } from "@sentry/react" +import React, { + ComponentPropsWithoutRef, + MutableRefObject, + ReactEventHandler, + ReactNode, + useContext, + useId, + useImperativeHandle, + useReducer, +} from "react" + +import { useAutocompleteResults } from "../hooks/useAutocompleteResults" +import { + SearchProperties, + searchPropertyDisplayConfig, +} from "../models/searchQuery" +import { isVehicle } from "../models/vehicle" +import { Ghost, Vehicle } from "../realtime" +import { clamp } from "../util/math" +import { formatOperatorNameFromVehicle } from "../util/operatorFormatting" +import { SocketContext } from "../contexts/socketContext" + +// #region Autocomplete Control +// #region Cursor Reducer + +/** + * Represents the 2D jagged array of groups and options per group. + * + * The length of the array is the number of groups. + * The element of the array is the number of options within that group. + */ +type LengthsContext = number[] + +/** + * The possible cursor states. + * + * - `undefined` when there is no cursor state. + * - Group and Option are defined to be a position contained within a + * {@link LengthsContext}. + */ +type CursorState = { group: number; option: number } | undefined + +/** + * Check if the {@link proposedLocation} is a valid location within + * {@link lengthsContext}. + * + * @param proposedLocation The {@link CursorState} to test if it is valid + * @param lengthsContext The {@link LengthsContext} used to bounds check {@link proposedLocation} + * @returns `true` if the {@link proposedLocation} is within the {@link lengthsContext}, otherwise `false` + */ +const isValidLocation = ( + proposedLocation: CursorState, + lengthsContext: LengthsContext +): boolean => { + if (!proposedLocation) { + return false + } + + const { group: x, option: y } = proposedLocation + return Boolean( + 0 <= x && 0 <= y && x < lengthsContext.length && y < lengthsContext[x] + ) +} + +/** + * Checks if the {@link proposedLocation} is valid and returns a + * {@link CursorState} based on the {@link isValidLocation} validity check. + * + * @param proposedLocation The {@link CursorState} to test and return if it is valid. + * @param lengthsContext The {@link LengthsContext} used to bounds check {@link proposedLocation}. + * @returns {} {@link proposedLocation} if {@link proposedLocation} is valid, otherwise `undefined`. + */ +const getValidLocationOrUnset = ( + proposedLocation: CursorState, + lengthsContext: LengthsContext +): CursorState => { + return isValidLocation(proposedLocation, lengthsContext) + ? proposedLocation + : undefined +} + +/** + * Valid {@link cursorLocationReducer} commands. + */ +enum CursorLocationAction { + MoveToStart = "MoveToStart", + MoveToEnd = "MoveToEnd", + ForceCursorTo = "ForceCursorTo", + DeleteCursor = "DeleteCursor", + MoveNext = "MoveNext", + MovePrev = "MovePrev", + FindNearestValidLocation = "FindNearestValidLocation", +} + +/** + * {@link cursorLocationReducer} commands and arguments. + */ +type CursorStateAction = { + lengthsContext: LengthsContext + onCursor?: AutocompleteCursorEventProps +} & ( + | { + action: CursorLocationAction + } + | { + forceLocation: CursorState + } +) + +/** + * A State Reducer that moves a {@link CursorState cursor} to leaf coordinates + * within a 2d jagged array defined by a {@link LengthsContext}. + */ +const cursorLocationReducer = ( + cursorState: CursorState, + nextAction: CursorStateAction +): CursorState => { + const { lengthsContext, onCursor } = nextAction + + // Curry `lengthsContext` into `getValidLocationOrUnset` + const getValidLocationOrUnsetWithLengthsContext = ( + proposedLocation: CursorState + ) => getValidLocationOrUnset(proposedLocation, lengthsContext) + + // Early exit if we're simply setting the position + if ("forceLocation" in nextAction) { + return getValidLocationOrUnsetWithLengthsContext(nextAction.forceLocation) + } + + const { action } = nextAction + + // Do actions that don't require knowing the type of the `cursorState` + switch (action) { + case CursorLocationAction.DeleteCursor: { + return undefined + } + + case CursorLocationAction.MoveToStart: { + // In case the length of the arrays are zero + // Check if the first value is valid + return getValidLocationOrUnsetWithLengthsContext({ group: 0, option: 0 }) + } + + case CursorLocationAction.MoveToEnd: { + // Check if the last value of each length is valid + return getValidLocationOrUnsetWithLengthsContext({ + group: lengthsContext.length - 1, + // move out of bounds ( < 0) if it doesn't exist + option: lengthsContext[lengthsContext.length - 1] - 1, + }) + } + } + + // Do actions that require the `cursorState` + + // Return the current state if it's invalid + if (!cursorState) { + return cursorState + } + + switch (action) { + case CursorLocationAction.FindNearestValidLocation: { + // Constrain current cursor to bounds of `lengthsContext` + return findNearestValidLocation() + } + + case CursorLocationAction.MoveNext: { + const validCursorLocation = + getValidLocationOrUnsetWithLengthsContext({ + ...cursorState, + option: cursorState.option + 1, + }) || + // Then the first option in the next group + getValidLocationOrUnsetWithLengthsContext({ + group: cursorState.group + 1, + option: 0, + }) + + if (validCursorLocation !== undefined) { + return validCursorLocation + } + + // Otherwise return the current state as we've reached the end + onCursor?.onCursorExitEdge?.(CursorExitDirection.ExitEnd) + return cursorState + } + + case CursorLocationAction.MovePrev: { + const validCursorLocation = + // Try the option index before the current option + getValidLocationOrUnsetWithLengthsContext({ + ...cursorState, + option: cursorState.option - 1, + }) || + // Try the last option of the previous group + getValidLocationOrUnsetWithLengthsContext({ + group: cursorState.group - 1, + option: lengthsContext[cursorState.group - 1] - 1, + }) + + if (validCursorLocation) { + return validCursorLocation + } + + onCursor?.onCursorExitEdge?.(CursorExitDirection.ExitStart) + + return cursorState + } + } + + // We really shouldn't reach this point, so for safety we'll return the + // current state + captureException( + new Error( + "internal error: entered unreachable code: end of `cursorLocationReducer`" + ), + { + extra: { + cursorState, + lengthsContext, + action, + }, + } + ) + return cursorState + + /** + * Constrain {@link cursorState} onto the region defined by the + * {@link lengthsContext}. + */ + function findNearestValidLocation() { + // Do not snap an undefined cursor. + if (cursorState === undefined) { + return undefined + } + + // Clamp existing cursor into [0, Array.length) in each dimension + const group = clamp(cursorState.group, 0, lengthsContext.length - 1) + const option = clamp(cursorState.option, 0, lengthsContext[group] - 1) + // Check that answer is correct + return getValidLocationOrUnsetWithLengthsContext({ + group, + option, + }) + } +} + +/** + * {@link cursorLocationReducer} return state with helper functions. + */ +interface CursorReducerControls { + /** + * The current {@link CursorState `cursorLocation`} + */ + cursorLocation: CursorState + + /** + * Function to move the {@link CursorState `cursorLocation`} relatively + * within the {@link LengthsContext} + */ + updateCursorLocation: (action: CursorLocationAction) => void + + /** + * Set the {@link CursorState}, if the {@link CursorState} is valid + * within the {@link LengthsContext}. + */ + setCursorLocation: (forceLocation: CursorState) => void +} + +/** + * Helper hook to pre-configure functions with {@link LengthsContext} from the + * {@link groups groups parameter} and a few specialty functions. + * + * @param groups The {@link AutocompleteDataGroup} array used to bounds check + * {@link CursorState} arguments and changes. + * + * @returns The current cursor state and functions to modify the state. + */ +const useCursorLocationFromGroups = ( + groups: AutocompleteDataGroup[], + onCursor?: AutocompleteCursorEventProps +): CursorReducerControls => { + const lengthsContext = groups.map(({ group }) => group.options.length) + + const [cursorLocation, dispatchCursorLocation] = useReducer( + cursorLocationReducer, + undefined + ) + + const commonArgs = { lengthsContext, onCursor } + + return { + /** + * The current {@link CursorLocation `cursorLocation`} + */ + cursorLocation, + /** + * Function to move the {@link CursorLocation `cursorLocation`} relatively + * within the {@link LengthsContext} + */ + updateCursorLocation: (action: CursorLocationAction) => + dispatchCursorLocation({ action, ...commonArgs }), + /** + * Set the {@link CursorLocation}, if the {@link CursorLocation} is valid + * within the {@link LengthsContext}. + */ + setCursorLocation: (forceLocation: CursorState) => + dispatchCursorLocation({ forceLocation, ...commonArgs }), + } +} +// #endregion Cursor Reducer + +// #region Autocomplete Control Impl +/** + * Props which expose references for controlling the {@link GroupedAutocomplete} + */ +export type GroupedAutocompleteControlRefProps = { + /** + * Reference object containing functions for controlling the + * {@link GroupedAutocomplete}. + */ + controllerRef?: MutableRefObject +} + +/** + * Functions to control the cursor state of the {@link GroupedAutocomplete} + */ +export interface GroupedAutocompleteControls { + /** + * Moves focus to first available option if it exists. + */ + focusCursorToFirstOption: () => void + /** + * Deletes the cursor in the control, relinquish's focus control. + */ + forgetCursor: () => void +} + +/** + * General configuration props for the {@link GroupedAutocomplete} control. + */ +export interface GroupedAutocompleteProps + extends GroupedAutocompleteControlRefProps { + /** + * The ID of the `listbox` control. + * + * Mainly used for aria compatibility. + */ + id?: string + /** + * The groups and options to render as clickable elements in the control. + */ + optionGroups: AutocompleteDataGroup[] + /** + * The name of the control. Read to screen readers when control receives focus. + */ + controlName: ReactNode + /** + * React Component to use as the heading and aria-label for the results list. + * + * Defaults to "h2". + */ + Heading?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" + /** + * Component to display when there are 0 search results. + */ + fallbackOption: AutocompleteOptionData + /** + * Events related to cursor action callbacks. + */ + onCursor?: AutocompleteCursorEventProps +} + +export enum CursorExitDirection { + ExitStart = "ExitStart", + ExitEnd = "ExitEnd", +} + +/** + * Props related to callbacks from the {@link cursorLocationReducer}. + */ +interface AutocompleteCursorEventProps { + /** + * Callback when the cursor attempts to leave the listbox. + * @param CursorExitDirection The direction the cursor attempted to exit from. + */ + onCursorExitEdge?: (direction: CursorExitDirection) => void +} + +/** + * Use {@link autocompleteOption} to construct this type. + */ +type AutocompleteOptionData = { + option: { + label: ReactNode + onSelectOption?: ReactEventHandler + } +} +/** + * {@link AutocompleteOptionData} constructor. + */ +export const autocompleteOption = ( + label: ReactNode, + onSelectOption?: ReactEventHandler +): AutocompleteOptionData => ({ + option: { + label, + onSelectOption, + }, +}) + +/** + * Use {@link autocompleteGroup} to construct this type. + */ +type AutocompleteDataGroup = { + group: { + title: ReactNode + options: AutocompleteOptionData[] + } +} + +/** + * + * + * @param title Group Heading + * @param options Options belonging to the group + */ +export function autocompleteGroup( + title: ReactNode, + ...options: AutocompleteOptionData[] +): AutocompleteDataGroup { + return { + group: { + title, + options, + }, + } +} +/** + * A keyboard & mouse navigable control containing a list of list of options. + * Provides callbacks when options are selected. + */ +export const GroupedAutocomplete = ({ + id, + controlName, + optionGroups, + controllerRef, + fallbackOption, + onCursor, + Heading = "h2", +}: GroupedAutocompleteProps) => { + const listHeadingId = useId() + + // Fallback option and group + if (optionGroups.length === 0) { + optionGroups = [ + { + group: { + title: null, + options: [fallbackOption], + }, + }, + ] + } + + const { cursorLocation, updateCursorLocation, setCursorLocation } = + useCursorLocationFromGroups(optionGroups, onCursor) + + useImperativeHandle( + controllerRef, + (): GroupedAutocompleteControls => ({ + focusCursorToFirstOption() { + updateCursorLocation(CursorLocationAction.MoveToStart) + }, + forgetCursor() { + updateCursorLocation(CursorLocationAction.DeleteCursor) + }, + }) + ) + + return ( +
+ + {controlName} + + +
{ + // Check if the previously focused element is a child of this element. + const autocompleteControlAlreadyHadFocus = + event.currentTarget.contains(event.relatedTarget) + // set the cursor to the first element if: + // - the user focused an element in the autocomplete control that is not an option + // (option focus event prevents propagation). + // - the cursor is unset or the autocomplete control does not contain the previously focused element. + if ( + cursorLocation === undefined || + autocompleteControlAlreadyHadFocus === false + ) { + updateCursorLocation(CursorLocationAction.MoveToStart) + } + }} + onBlur={(event) => { + // Delete the cursor state if the user's focus exits the autocomplete + // control. + if (event.currentTarget.contains(event.relatedTarget)) { + return + } + + updateCursorLocation(CursorLocationAction.DeleteCursor) + }} + onKeyDown={(e) => { + // Handle cursor movement by keyboard. + // If the key is not a movement key, allow event to bubble up. + switch (e.key) { + case "ArrowDown": + updateCursorLocation(CursorLocationAction.MoveNext) + break + case "ArrowUp": + updateCursorLocation(CursorLocationAction.MovePrev) + break + case "Home": + updateCursorLocation(CursorLocationAction.MoveToStart) + break + case "End": + updateCursorLocation(CursorLocationAction.MoveToEnd) + break + default: + return + } + e.preventDefault() + e.stopPropagation() + }} + aria-labelledby={listHeadingId} + tabIndex={-1} + > + {optionGroups.map(({ group: { title, options } }, groupIndex) => ( + + {options.map( + ({ option: { label, onSelectOption } }, optionIndex) => { + const selected = + cursorLocation && + cursorLocation.group === groupIndex && + cursorLocation.option === optionIndex + return ( +
  • { + // Set cursor to this location if focused. + !selected && + setCursorLocation({ + group: groupIndex, + option: optionIndex, + }) + e.stopPropagation() + }} + onClick={onSelectOption} + onKeyDown={(e) => { + // Fire `onSelectOption` if enter is pressed + if (e.key != "Enter") { + return + } + e.preventDefault() + e.stopPropagation() + onSelectOption?.(e) + }} + // If this element is selected by the cursor, + // set document focus to this element when mounted. + ref={(selected || null) && ((r) => r?.focus())} + // Allow element to be focused as a control. + tabIndex={-1} + > + {label} +
  • + ) + } + )} +
    + ))} +
    +
    + ) +} + +// #region LabelledList Components +type LabelledListProps = { + heading: ReactNode + headingProps?: ComponentPropsWithoutRef<"div"> +} & ComponentPropsWithoutRef<"ul"> + +/** + * Component which binds a list aria-label to the supplied {@link heading} as a + * sibling so that screen readers to not count the heading as a option or group. + */ +const LabelledListboxGroup = ({ + heading, + headingProps, + ...props +}: LabelledListProps) => { + const id = useId() + + return ( + <> + +