Fixing the OmniSearch component

The OmniSearch component had several issues which have been resolved in this fix. It now makes use of the new combobox component.
This commit is contained in:
Tarik Gürsoy
2023-09-28 16:16:17 +02:00
parent 80b5a3dad6
commit 4eb735d552
9 changed files with 177 additions and 331 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: OmniSearchbar now makes use of the Combobox

View File

@@ -27,6 +27,7 @@ import React, { Fragment, useState } from "react";
import Combobox from "./Combobox";
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import { Option } from "@scm-manager/ui-types";
import { Link, BrowserRouter } from "react-router-dom";
const waitFor = (ms: number) =>
function <T>(result: T) {
@@ -39,6 +40,8 @@ const data = [
{ label: "Zaphod", value: "3" },
];
const linkData = [{ label: "Link111111111111111111111111111111111111", value: "1" }];
storiesOf("Combobox", module)
.add("Options array", () => {
const [value, setValue] = useState<Option<string>>();
@@ -93,4 +96,30 @@ storiesOf("Combobox", module)
)}
</Combobox>
);
})
.add("Links as render props", () => {
const [value, setValue] = useState<Option<string>>();
const [query, setQuery] = useState("Hello");
return (
<BrowserRouter>
<Combobox
className="input is-small omni-search-bar"
placeholder={"Placeholder"}
value={value}
options={linkData}
onChange={setValue}
onQueryChange={setQuery}
>
{(o) => (
<HeadlessCombobox.Option value={{ label: o.label, value: query, displayValue: o.value }} key={o.value} as={Fragment}>
{({ active }) => (
<Combobox.Option isActive={active}>
<Link to={o.label}>{o.label}</Link>
</Combobox.Option>
)}
</HeadlessCombobox.Option>
)}
</Combobox>
</BrowserRouter>
);
});

View File

@@ -25,7 +25,6 @@
import React, {
ForwardedRef,
Fragment,
KeyboardEvent,
KeyboardEventHandler,
ReactElement,
Ref,
@@ -48,7 +47,8 @@ const OptionsWrapper = styled(HeadlessCombobox.Options).attrs({
border: var(--scm-border);
background-color: var(--scm-secondary-background);
max-width: 35ch;
width: 35ch;
&:empty {
border: 0;
clip: rect(0 0 0 0);
@@ -73,6 +73,9 @@ const StyledComboboxOption = styled.li.attrs({
opacity: 40%;
cursor: unset !important;
}
> a {
color: inherit !important;
}
`;
type BaseProps<T> = {
@@ -138,7 +141,6 @@ function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInp
value={props.value}
onChange={(e?: Option<T>) => props.onChange && props.onChange(e)}
disabled={props.disabled || props.readOnly}
onKeyDown={(e: KeyboardEvent<HTMLElement>) => props.onKeyDown && props.onKeyDown(e)}
name={props.name}
form={props.form}
defaultValue={props.defaultValue}
@@ -159,6 +161,9 @@ function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInp
placeholder={props.placeholder}
onBlur={props.onBlur}
autoComplete="off"
onKeyDown={(e) => {
props.onKeyDown && props.onKeyDown(e)
}}
{...createAttributesForTesting(props.testId)}
/>
<OptionsWrapper className="is-absolute">{options}</OptionsWrapper>

View File

@@ -45,6 +45,7 @@ export { default as ComboboxField } from "./combobox/ComboboxField";
export { default as Input } from "./input/Input";
export { default as Select } from "./select/Select";
export * from "./resourceHooks";
export { default as Label } from "./base/label/Label";
export const Form = Object.assign(FormCmp, {
Row: FormRow,

View File

@@ -43,7 +43,7 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
}
@media screen and (max-width: ${devices.mobile.width}px) {
${props =>
${(props) =>
props.mobilePosition === "right" &&
css`
right: -1.5rem;
@@ -84,7 +84,7 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
right: 1.375rem;
}
${props =>
${(props) =>
props.mobilePosition === "right" &&
css`
@media screen and (max-width: ${devices.mobile.width}px) {
@@ -143,7 +143,7 @@ type CounterProps = {
const Counter = styled.span<CounterProps>`
position: absolute;
top: -0.75rem;
right: ${props => (props.count.length <= 1 ? "-0.25" : "-0.50")}rem;
right: ${(props) => (props.count.length <= 1 ? "-0.25" : "-0.50")}rem;
`;
type IconWrapperProps = {
@@ -158,46 +158,52 @@ const IconWrapper: FC<IconWrapperProps> = ({ icon, count }) => (
</IconContainer>
);
type Props = DropDownMenuProps & {
className?: string;
icon: React.ReactNode;
count?: string;
error?: Error | null;
isLoading?: boolean;
};
type Props = React.PropsWithChildren<
DropDownMenuProps & {
className?: string;
icon: React.ReactNode;
count?: string;
error?: Error | null;
isLoading?: boolean;
}
>;
const DropDownTrigger = styled.div`
padding: 0.65rem 0.75rem;
`;
const HeaderDropDown: FC<Props> = ({ className, icon, count, error, isLoading, mobilePosition, children }) => {
const [open, setOpen] = useState(false);
const HeaderDropDown = React.forwardRef<HTMLButtonElement, Props>(
({ className, icon, count, error, isLoading, mobilePosition, children }, ref) => {
const [open, setOpen] = useState(false);
useEffect(() => {
const close = () => setOpen(false);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, []);
useEffect(() => {
const close = () => setOpen(false);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, []);
return (
<>
<div
return (
<button
type="button"
className={classNames(
"notifications",
"dropdown",
"is-hoverable",
"p-0",
"is-borderless",
"has-background-transparent",
{
"is-active": open
"is-active": open,
},
className
)}
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
tabIndex={0}
ref={ref}
>
<DropDownTrigger
className={classNames("is-flex", "dropdown-trigger", "is-clickable")}
onClick={() => setOpen(o => !o)}
onClick={() => setOpen((o) => !o)}
>
<IconWrapper icon={icon} count={count} />
</DropDownTrigger>
@@ -206,9 +212,9 @@ const HeaderDropDown: FC<Props> = ({ className, icon, count, error, isLoading, m
{isLoading ? <LoadingBox /> : null}
{children}
</DropDownMenu>
</div>
</>
);
};
</button>
);
}
);
export default HeaderDropDown;

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import React, {FC, useEffect, useRef, useState} from "react";
import { Links } from "@scm-manager/ui-types";
import classNames from "classnames";
import styled from "styled-components";
@@ -105,6 +105,7 @@ type Props = {
const NavigationBar: FC<Props> = ({ links }) => {
const [burgerActive, setBurgerActive] = useState(false);
const [t] = useTranslation("commons");
const notificationsRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const close = () => {
if (burgerActive) {
@@ -114,7 +115,6 @@ const NavigationBar: FC<Props> = ({ links }) => {
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [burgerActive]);
return (
<StyledNavBar className="navbar is-fixed-top has-scm-background" aria-label="main navigation">
<div className="container">
@@ -139,8 +139,8 @@ const NavigationBar: FC<Props> = ({ links }) => {
</div>
<div className="is-active navbar-header-actions">
<Alerts className="navbar-item" />
<OmniSearch links={links} shouldClear={true} ariaId="navbar" />
<Notifications className="navbar-item" />
<OmniSearch links={links} shouldClear={true} ariaId="navbar" nextFocusRef={notificationsRef} />
<Notifications ref={notificationsRef} className="navbar-item" />
</div>
<div className="navbar-end">
<LogoutButton burgerMode={burgerActive} links={links} />

View File

@@ -247,11 +247,11 @@ const count = (data?: NotificationCollection) => {
}
};
type NotificationProps = {
type NotificationProps = React.PropsWithChildren<{
className?: string;
};
}>;
const Notifications: FC<NotificationProps> = ({ className }) => {
const Notifications = React.forwardRef<HTMLButtonElement, NotificationProps>(({ className }, ref) => {
const { data, isLoading, error, refetch } = useNotifications();
const { notifications, remove, clear } = useNotificationSubscription(refetch, data);
@@ -265,11 +265,12 @@ const Notifications: FC<NotificationProps> = ({ className }) => {
icon={<BellNotificationIcon data={data} />}
count={count(data)}
mobilePosition="left"
ref={ref}
>
{data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null}
</HeaderDropDown>
</>
);
};
});
export default Notifications;

View File

@@ -21,41 +21,29 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, {
ComponentProps,
Dispatch,
FC,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent,
ReactElement,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types";
import React, { FC, Fragment, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Hit, Links, Repository, ValueHitField, Option } from "@scm-manager/ui-types";
import styled from "styled-components";
import { useNamespaceAndNameContext, useOmniSearch, useSearchTypes } from "@scm-manager/ui-api";
import classNames from "classnames";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { devices, Icon, RepositoryAvatar } from "@scm-manager/ui-components";
import SyntaxHelp from "../search/SyntaxHelp";
import { RepositoryAvatar, Icon } from "@scm-manager/ui-components";
import SyntaxModal from "../search/SyntaxModal";
import SearchErrorNotification from "../search/SearchErrorNotification";
import queryString from "query-string";
import { orderTypes } from "../search/Search";
import { useShortcut } from "@scm-manager/ui-shortcuts";
import { Label, Combobox } from "@scm-manager/ui-forms";
import { Combobox as HeadlessCombobox } from "@headlessui/react";
const Input = styled.input`
border-radius: 4px !important;
const ResultHeading = styled.div`
border-top: 1px solid lightgray;
`;
type Props = {
shouldClear: boolean;
ariaId: string;
nextFocusRef: RefObject<HTMLElement>;
};
type GuardProps = Props & {
@@ -68,29 +56,6 @@ const namespaceAndName = (hit: Hit) => {
return `${namespace}/${name}`;
};
type HitsProps = {
entries: ReactElement<ComponentProps<typeof HitEntry>>[];
hits: Hit[];
showHelp: () => void;
ariaId: string;
};
const QuickSearchNotification: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
const ResultHeading = styled.h3`
border-top: 1px solid lightgray;
`;
const DropdownMenu = styled.div`
max-width: 20rem;
`;
const SearchInput = styled(Input)`
@media screen and (max-width: ${devices.mobile.width}px) {
width: 9rem;
}
`;
const AvatarSection: FC<{ repository: Repository }> = ({ repository }) => {
if (!repository) {
return null;
@@ -103,150 +68,29 @@ const AvatarSection: FC<{ repository: Repository }> = ({ repository }) => {
);
};
const HitList: FC<Omit<HitsProps, "showHelp" | "hits">> = ({ entries, ariaId }) => {
return (
<ul id={`omni-search-results-${ariaId}`} aria-expanded="true" role="listbox">
{entries}
</ul>
);
};
const HitEntry: FC<{
selected: boolean;
link: string;
label: string;
clear: () => void;
repository?: Repository;
ariaId: string;
}> = ({ selected, link, label, clear, repository, ariaId }) => {
return (
<li
key={label}
onMouseDown={(e) => e.preventDefault()}
onClick={clear}
role="option"
aria-selected={selected}
id={selected ? `omni-search-selected-option-${ariaId}` : undefined}
>
<Link
className={classNames("is-flex", "dropdown-item", "has-text-weight-medium", "is-ellipsis-overflow", {
"is-active": selected,
})}
title={label}
to={link}
data-omnisearch="true"
>
{repository ? <AvatarSection repository={repository} /> : <Icon name="search" className="mr-3 ml-1 mt-1" />}
{label}
</Link>
</li>
);
};
type ScreenReaderHitSummaryProps = {
hits: Hit[];
};
const ScreenReaderHitSummary: FC<ScreenReaderHitSummaryProps> = ({ hits }) => {
const [t] = useTranslation("commons");
const key = hits.length > 0 ? "screenReaderHint" : "screenReaderHintNoResult";
return (
<span aria-live="assertive" className="is-sr-only">
{t(`search.quickSearch.${key}`, { count: hits.length })}
</span>
);
};
const Hits: FC<HitsProps> = ({ entries, hits, showHelp, ...rest }) => {
const [t] = useTranslation("commons");
return (
<>
<div className="dropdown-content p-0">
<ScreenReaderHitSummary hits={hits} />
<HitList entries={entries} {...rest} />
<ResultHeading
className={classNames(
"dropdown-item",
"is-flex",
"is-justify-content-space-between",
"is-align-items-center",
"mx-2",
"px-2",
"py-1",
"mt-1",
"has-text-weight-bold"
)}
>
<span>{t("search.quickSearch.resultHeading")}</span>
<SyntaxHelp onClick={showHelp} />
</ResultHeading>
</div>
</>
);
};
const useKeyBoardNavigation = (
entries: HitsProps["entries"],
clear: () => void,
hideResults: () => void,
index: number,
setIndex: Dispatch<SetStateAction<number>>,
defaultLink: string
) => {
query: string;
}> = ({ link, label, repository, query }) => {
const history = useHistory();
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
// We use e.which, because ie 11 does not support e.code
// https://caniuse.com/keyboardevent-code
switch (e.which) {
case 40: // e.code: ArrowDown
e.preventDefault();
setIndex((idx) => {
if (idx < entries.length - 1) {
return idx + 1;
}
return idx;
});
break;
case 38: // e.code: ArrowUp
e.preventDefault();
setIndex((idx) => {
if (idx > 0) {
return idx - 1;
}
return idx;
});
break;
case 13: // e.code: Enter
if ((e.target as HTMLInputElement).value.length >= 2) {
if (index < 0) {
history.push(defaultLink);
} else {
const entry = entries[index];
if (entry?.props.link) {
history.push(entry.props.link);
}
}
clear();
hideResults();
}
break;
case 27: // e.code: Escape
if (index >= 0) {
setIndex(-1);
} else {
clear();
}
break;
}
};
return {
onKeyDown,
index,
};
return (
<HeadlessCombobox.Option
value={{ label: query, value: () => history.push(link), displayValue: label }}
key={label}
as={Fragment}
>
{({ active }) => (
<Combobox.Option isActive={active}>
<div className="is-flex">
{repository ? <AvatarSection repository={repository} /> : <Icon name="search" className="mr-2 ml-1 mt-1" />}
<Label className="has-text-weight-normal is-size-6">{label}</Label>
</div>
</Combobox.Option>
)}
</HeadlessCombobox.Option>
);
};
const useDebounce = (value: string, delay: number) => {
@@ -262,48 +106,6 @@ const useDebounce = (value: string, delay: number) => {
return debouncedValue;
};
const isOnmiSearchElement = (element: Element) => {
return element.getAttribute("data-omnisearch");
};
const useShowResultsOnFocus = () => {
const [showResults, setShowResults] = useState(false);
useEffect(() => {
if (showResults) {
const close = () => {
setShowResults(false);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.which === 9) {
// tab
const element = document.activeElement;
if (!element || !isOnmiSearchElement(element)) {
close();
}
}
};
window.addEventListener("click", close);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keyup", onKeyUp);
};
}
}, [showResults]);
return {
showResults,
onClick: (e: MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
setShowResults(true);
},
onKeyPress: () => setShowResults(true),
onFocus: () => setShowResults(true),
hideResults: () => setShowResults(false),
};
};
const useSearchParams = () => {
const location = useLocation();
const pathname = location.pathname;
@@ -334,35 +136,42 @@ const useSearchParams = () => {
};
};
const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
const OmniSearch: FC<Props> = ({ shouldClear, ariaId, nextFocusRef, ...props }) => {
const [t] = useTranslation("commons");
const { searchType, initialQuery } = useSearchParams();
const { initialQuery } = useSearchParams();
const [query, setQuery] = useState(initialQuery);
const [value, setValue] = useState<Option<(() => void) | undefined> | undefined>({ label: query, value: query });
const searchInputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, 250);
const [showDropdown, setDropdown] = useState(true);
const context = useNamespaceAndNameContext();
const { data, isLoading, error } = useOmniSearch(debouncedQuery, {
const comboBoxRef = useRef(null);
const { data, isLoading } = useOmniSearch(debouncedQuery, {
type: "repository",
pageSize: 5,
});
const { showResults, hideResults, ...handlers } = useShowResultsOnFocus();
const [showHelp, setShowHelp] = useState(false);
const [index, setIndex] = useState(-1);
useEffect(() => {
setIndex(-1);
const handleChange = useCallback((value: Option<(() => void) | undefined>) => {
setValue(value);
value.value && value.value();
setDropdown(true);
}, []);
useEffect(() => {
setQuery(shouldClear ? "" : initialQuery);
setValue(shouldClear ? { label: "", value: undefined } : { label: initialQuery, value: undefined });
}, [shouldClear, initialQuery]);
const clearInput = () => {
shouldClear = true;
setValue({ label: "", value: undefined });
setQuery("");
setDropdown(false);
};
const openHelp = () => setShowHelp(true);
const closeHelp = () => setShowHelp(false);
const clearQuery = useCallback(() => {
if (shouldClear) {
setQuery("");
}
}, [shouldClear]);
const hits = data?._embedded?.hits || [];
const searchTypes = useSearchTypes({
@@ -384,13 +193,11 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
newEntries.push(
<HitEntry
key="search.quickSearch.searchRepo"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchRepo")}
link={`/search/${searchTypes[0]}/?q=${encodeURIComponent(query)}&namespace=${context.namespace}&name=${
context.name
}`}
ariaId={ariaId}
query={query}
/>
);
}
@@ -398,44 +205,33 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
newEntries.push(
<HitEntry
key="search.quickSearch.searchNamespace"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchNamespace")}
link={`/search/repository/?q=${encodeURIComponent(query)}&namespace=${context.namespace}`}
ariaId={ariaId}
query={query}
/>
);
}
newEntries.push(
<HitEntry
key="search.quickSearch.searchEverywhere"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchEverywhere")}
link={`/search/repository/?q=${encodeURIComponent(query)}`}
ariaId={ariaId}
query={query}
/>
);
const length = newEntries.length;
hits?.forEach((hit, idx) => {
newEntries.push(
<HitEntry
key={`search.quickSearch.hit${idx}`}
selected={length + idx === index}
clear={clearQuery}
label={id(hit)}
link={`/repo/${id(hit)}`}
repository={hit._embedded?.repository}
ariaId={ariaId}
query={query}
/>
);
});
return newEntries;
}, [ariaId, clearQuery, context.name, context.namespace, hits, id, index, query, searchTypes, t]);
const defaultLink = `/search/${searchType}/?q=${encodeURIComponent(query)}`;
const { onKeyDown } = useKeyBoardNavigation(entries, clearQuery, hideResults, index, setIndex, defaultLink);
}, [context.name, context.namespace, hits, id, query, searchTypes, t]);
return (
<div className={classNames("navbar-item", "field", "mb-0")}>
{showHelp ? <SyntaxModal close={closeHelp} /> : null}
@@ -444,50 +240,55 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
"is-loading": isLoading,
})}
>
<div className={classNames("dropdown", { "is-active": (!!data || error) && showResults })}>
<div className="dropdown-trigger">
<SearchInput
className="input is-small omni-search-bar"
type="text"
placeholder={t("search.placeholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
value={query}
role="combobox"
aria-autocomplete="both"
data-omnisearch="true"
aria-expanded={query.length > 2}
aria-label={t("search.ariaLabel")}
aria-owns={`omni-search-results-${ariaId}`}
aria-activedescendant={index >= 0 ? `omni-search-selected-option-${ariaId}` : undefined}
ref={searchInputRef}
{...handlers}
/>
{isLoading ? null : (
<span className="icon is-right">
<i className="fas fa-search" />
</span>
)}
</div>
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}>
{error ? (
<QuickSearchNotification>
<SearchErrorNotification error={error} showHelp={openHelp} />
</QuickSearchNotification>
) : null}
{!error && data ? <Hits showHelp={openHelp} hits={hits} entries={entries} ariaId={ariaId} /> : null}
</DropdownMenu>
</div>
<Combobox
className="input is-small"
placeholder={t("search.placeholder")}
value={value}
onChange={handleChange}
ref={comboBoxRef}
onQueryChange={setQuery}
onKeyDown={(e) => {
// This is hacky but it seems to be one of the only solutions right now
if (e.key === "Tab") {
nextFocusRef?.current?.focus();
e.preventDefault();
clearInput();
comboBoxRef.current.value = "";
} else {
setDropdown(true);
}
}}
>
{showDropdown ? entries : null}
{showDropdown ? (
<HeadlessCombobox.Option
value={{ label: query, value: openHelp, displayValue: query }}
key={query}
as={Fragment}
>
{({ active }) => (
<ResultHeading>
<Combobox.Option isActive={active}>
<div className=" is-flex">
<Icon name="question-circle" color="blue-light" className="pt-1 pl-1"></Icon>
<Label className="has-text-weight-normal pl-3">{t("search.quickSearch.resultHeading")}</Label>
</div>
</Combobox.Option>
</ResultHeading>
)}
</HeadlessCombobox.Option>
) : null}
</Combobox>
</div>
</div>
);
};
const OmniSearchGuard: FC<GuardProps> = ({ links, shouldClear, ariaId }) => {
const OmniSearchGuard: FC<GuardProps> = ({ links, ...props }) => {
if (!links.search) {
return null;
}
return <OmniSearch shouldClear={shouldClear} ariaId={ariaId} />;
return <OmniSearch {...props} />;
};
export default OmniSearchGuard;

View File

@@ -28,13 +28,14 @@ import { HelpIcon, NoStyleButton } from "@scm-manager/ui-components";
type Props = {
onClick: () => void;
className?: string
};
const SyntaxHelp: FC<Props> = ({ onClick }) => {
const SyntaxHelp: FC<Props> = ({ onClick, className }) => {
const [t] = useTranslation("commons");
return (
<>
<NoStyleButton title={t("search.quickSearch.hintsIcon")} onClick={onClick}>
<NoStyleButton className={className} title={t("search.quickSearch.hintsIcon")} onClick={onClick}>
<HelpIcon />
</NoStyleButton>
</>