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 from "./Combobox";
import { Combobox as HeadlessCombobox } from "@headlessui/react"; import { Combobox as HeadlessCombobox } from "@headlessui/react";
import { Option } from "@scm-manager/ui-types"; import { Option } from "@scm-manager/ui-types";
import { Link, BrowserRouter } from "react-router-dom";
const waitFor = (ms: number) => const waitFor = (ms: number) =>
function <T>(result: T) { function <T>(result: T) {
@@ -39,6 +40,8 @@ const data = [
{ label: "Zaphod", value: "3" }, { label: "Zaphod", value: "3" },
]; ];
const linkData = [{ label: "Link111111111111111111111111111111111111", value: "1" }];
storiesOf("Combobox", module) storiesOf("Combobox", module)
.add("Options array", () => { .add("Options array", () => {
const [value, setValue] = useState<Option<string>>(); const [value, setValue] = useState<Option<string>>();
@@ -93,4 +96,30 @@ storiesOf("Combobox", module)
)} )}
</Combobox> </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, { import React, {
ForwardedRef, ForwardedRef,
Fragment, Fragment,
KeyboardEvent,
KeyboardEventHandler, KeyboardEventHandler,
ReactElement, ReactElement,
Ref, Ref,
@@ -48,7 +47,8 @@ const OptionsWrapper = styled(HeadlessCombobox.Options).attrs({
border: var(--scm-border); border: var(--scm-border);
background-color: var(--scm-secondary-background); background-color: var(--scm-secondary-background);
max-width: 35ch; max-width: 35ch;
width: 35ch;
&:empty { &:empty {
border: 0; border: 0;
clip: rect(0 0 0 0); clip: rect(0 0 0 0);
@@ -73,6 +73,9 @@ const StyledComboboxOption = styled.li.attrs({
opacity: 40%; opacity: 40%;
cursor: unset !important; cursor: unset !important;
} }
> a {
color: inherit !important;
}
`; `;
type BaseProps<T> = { type BaseProps<T> = {
@@ -138,7 +141,6 @@ function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInp
value={props.value} value={props.value}
onChange={(e?: Option<T>) => props.onChange && props.onChange(e)} onChange={(e?: Option<T>) => props.onChange && props.onChange(e)}
disabled={props.disabled || props.readOnly} disabled={props.disabled || props.readOnly}
onKeyDown={(e: KeyboardEvent<HTMLElement>) => props.onKeyDown && props.onKeyDown(e)}
name={props.name} name={props.name}
form={props.form} form={props.form}
defaultValue={props.defaultValue} defaultValue={props.defaultValue}
@@ -159,6 +161,9 @@ function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInp
placeholder={props.placeholder} placeholder={props.placeholder}
onBlur={props.onBlur} onBlur={props.onBlur}
autoComplete="off" autoComplete="off"
onKeyDown={(e) => {
props.onKeyDown && props.onKeyDown(e)
}}
{...createAttributesForTesting(props.testId)} {...createAttributesForTesting(props.testId)}
/> />
<OptionsWrapper className="is-absolute">{options}</OptionsWrapper> <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 Input } from "./input/Input";
export { default as Select } from "./select/Select"; export { default as Select } from "./select/Select";
export * from "./resourceHooks"; export * from "./resourceHooks";
export { default as Label } from "./base/label/Label";
export const Form = Object.assign(FormCmp, { export const Form = Object.assign(FormCmp, {
Row: FormRow, Row: FormRow,

View File

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

View File

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

View File

@@ -247,11 +247,11 @@ const count = (data?: NotificationCollection) => {
} }
}; };
type NotificationProps = { type NotificationProps = React.PropsWithChildren<{
className?: string; className?: string;
}; }>;
const Notifications: FC<NotificationProps> = ({ className }) => { const Notifications = React.forwardRef<HTMLButtonElement, NotificationProps>(({ className }, ref) => {
const { data, isLoading, error, refetch } = useNotifications(); const { data, isLoading, error, refetch } = useNotifications();
const { notifications, remove, clear } = useNotificationSubscription(refetch, data); const { notifications, remove, clear } = useNotificationSubscription(refetch, data);
@@ -265,11 +265,12 @@ const Notifications: FC<NotificationProps> = ({ className }) => {
icon={<BellNotificationIcon data={data} />} icon={<BellNotificationIcon data={data} />}
count={count(data)} count={count(data)}
mobilePosition="left" mobilePosition="left"
ref={ref}
> >
{data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null} {data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null}
</HeaderDropDown> </HeaderDropDown>
</> </>
); );
}; });
export default Notifications; 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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { import React, { FC, Fragment, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
ComponentProps, import { Hit, Links, Repository, ValueHitField, Option } from "@scm-manager/ui-types";
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 styled from "styled-components"; import styled from "styled-components";
import { useNamespaceAndNameContext, useOmniSearch, useSearchTypes } from "@scm-manager/ui-api"; import { useNamespaceAndNameContext, useOmniSearch, useSearchTypes } from "@scm-manager/ui-api";
import classNames from "classnames"; 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 { useTranslation } from "react-i18next";
import { devices, Icon, RepositoryAvatar } from "@scm-manager/ui-components"; import { RepositoryAvatar, Icon } from "@scm-manager/ui-components";
import SyntaxHelp from "../search/SyntaxHelp";
import SyntaxModal from "../search/SyntaxModal"; import SyntaxModal from "../search/SyntaxModal";
import SearchErrorNotification from "../search/SearchErrorNotification";
import queryString from "query-string"; import queryString from "query-string";
import { orderTypes } from "../search/Search"; import { orderTypes } from "../search/Search";
import { useShortcut } from "@scm-manager/ui-shortcuts"; 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` const ResultHeading = styled.div`
border-radius: 4px !important; border-top: 1px solid lightgray;
`; `;
type Props = { type Props = {
shouldClear: boolean; shouldClear: boolean;
ariaId: string; ariaId: string;
nextFocusRef: RefObject<HTMLElement>;
}; };
type GuardProps = Props & { type GuardProps = Props & {
@@ -68,29 +56,6 @@ const namespaceAndName = (hit: Hit) => {
return `${namespace}/${name}`; 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 }) => { const AvatarSection: FC<{ repository: Repository }> = ({ repository }) => {
if (!repository) { if (!repository) {
return null; 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<{ const HitEntry: FC<{
selected: boolean;
link: string; link: string;
label: string; label: string;
clear: () => void;
repository?: Repository; repository?: Repository;
ariaId: string; query: string;
}> = ({ selected, link, label, clear, repository, ariaId }) => { }> = ({ link, label, repository, query }) => {
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
) => {
const history = useHistory(); const history = useHistory();
return (
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => { <HeadlessCombobox.Option
// We use e.which, because ie 11 does not support e.code value={{ label: query, value: () => history.push(link), displayValue: label }}
// https://caniuse.com/keyboardevent-code key={label}
switch (e.which) { as={Fragment}
case 40: // e.code: ArrowDown >
e.preventDefault(); {({ active }) => (
setIndex((idx) => { <Combobox.Option isActive={active}>
if (idx < entries.length - 1) { <div className="is-flex">
return idx + 1; {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>
return idx; </div>
}); </Combobox.Option>
break; )}
case 38: // e.code: ArrowUp </HeadlessCombobox.Option>
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,
};
}; };
const useDebounce = (value: string, delay: number) => { const useDebounce = (value: string, delay: number) => {
@@ -262,48 +106,6 @@ const useDebounce = (value: string, delay: number) => {
return debouncedValue; 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 useSearchParams = () => {
const location = useLocation(); const location = useLocation();
const pathname = location.pathname; 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 [t] = useTranslation("commons");
const { searchType, initialQuery } = useSearchParams(); const { initialQuery } = useSearchParams();
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
const [value, setValue] = useState<Option<(() => void) | undefined> | undefined>({ label: query, value: query });
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, 250); const debouncedQuery = useDebounce(query, 250);
const [showDropdown, setDropdown] = useState(true);
const context = useNamespaceAndNameContext(); const context = useNamespaceAndNameContext();
const { data, isLoading, error } = useOmniSearch(debouncedQuery, { const comboBoxRef = useRef(null);
const { data, isLoading } = useOmniSearch(debouncedQuery, {
type: "repository", type: "repository",
pageSize: 5, pageSize: 5,
}); });
const { showResults, hideResults, ...handlers } = useShowResultsOnFocus();
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [index, setIndex] = useState(-1); const handleChange = useCallback((value: Option<(() => void) | undefined>) => {
useEffect(() => { setValue(value);
setIndex(-1); value.value && value.value();
setDropdown(true);
}, []); }, []);
useEffect(() => { useEffect(() => {
setQuery(shouldClear ? "" : initialQuery); setQuery(shouldClear ? "" : initialQuery);
setValue(shouldClear ? { label: "", value: undefined } : { label: initialQuery, value: undefined });
}, [shouldClear, initialQuery]); }, [shouldClear, initialQuery]);
const clearInput = () => {
shouldClear = true;
setValue({ label: "", value: undefined });
setQuery("");
setDropdown(false);
};
const openHelp = () => setShowHelp(true); const openHelp = () => setShowHelp(true);
const closeHelp = () => setShowHelp(false); const closeHelp = () => setShowHelp(false);
const clearQuery = useCallback(() => {
if (shouldClear) {
setQuery("");
}
}, [shouldClear]);
const hits = data?._embedded?.hits || []; const hits = data?._embedded?.hits || [];
const searchTypes = useSearchTypes({ const searchTypes = useSearchTypes({
@@ -384,13 +193,11 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
newEntries.push( newEntries.push(
<HitEntry <HitEntry
key="search.quickSearch.searchRepo" key="search.quickSearch.searchRepo"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchRepo")} label={t("search.quickSearch.searchRepo")}
link={`/search/${searchTypes[0]}/?q=${encodeURIComponent(query)}&namespace=${context.namespace}&name=${ link={`/search/${searchTypes[0]}/?q=${encodeURIComponent(query)}&namespace=${context.namespace}&name=${
context.name context.name
}`} }`}
ariaId={ariaId} query={query}
/> />
); );
} }
@@ -398,44 +205,33 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
newEntries.push( newEntries.push(
<HitEntry <HitEntry
key="search.quickSearch.searchNamespace" key="search.quickSearch.searchNamespace"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchNamespace")} label={t("search.quickSearch.searchNamespace")}
link={`/search/repository/?q=${encodeURIComponent(query)}&namespace=${context.namespace}`} link={`/search/repository/?q=${encodeURIComponent(query)}&namespace=${context.namespace}`}
ariaId={ariaId} query={query}
/> />
); );
} }
newEntries.push( newEntries.push(
<HitEntry <HitEntry
key="search.quickSearch.searchEverywhere" key="search.quickSearch.searchEverywhere"
selected={newEntries.length === index}
clear={clearQuery}
label={t("search.quickSearch.searchEverywhere")} label={t("search.quickSearch.searchEverywhere")}
link={`/search/repository/?q=${encodeURIComponent(query)}`} link={`/search/repository/?q=${encodeURIComponent(query)}`}
ariaId={ariaId} query={query}
/> />
); );
const length = newEntries.length;
hits?.forEach((hit, idx) => { hits?.forEach((hit, idx) => {
newEntries.push( newEntries.push(
<HitEntry <HitEntry
key={`search.quickSearch.hit${idx}`} key={`search.quickSearch.hit${idx}`}
selected={length + idx === index}
clear={clearQuery}
label={id(hit)} label={id(hit)}
link={`/repo/${id(hit)}`} link={`/repo/${id(hit)}`}
repository={hit._embedded?.repository} repository={hit._embedded?.repository}
ariaId={ariaId} query={query}
/> />
); );
}); });
return newEntries; return newEntries;
}, [ariaId, clearQuery, context.name, context.namespace, hits, id, index, query, searchTypes, t]); }, [context.name, context.namespace, hits, id, query, searchTypes, t]);
const defaultLink = `/search/${searchType}/?q=${encodeURIComponent(query)}`;
const { onKeyDown } = useKeyBoardNavigation(entries, clearQuery, hideResults, index, setIndex, defaultLink);
return ( return (
<div className={classNames("navbar-item", "field", "mb-0")}> <div className={classNames("navbar-item", "field", "mb-0")}>
{showHelp ? <SyntaxModal close={closeHelp} /> : null} {showHelp ? <SyntaxModal close={closeHelp} /> : null}
@@ -444,50 +240,55 @@ const OmniSearch: FC<Props> = ({ shouldClear, ariaId }) => {
"is-loading": isLoading, "is-loading": isLoading,
})} })}
> >
<div className={classNames("dropdown", { "is-active": (!!data || error) && showResults })}> <Combobox
<div className="dropdown-trigger"> className="input is-small"
<SearchInput placeholder={t("search.placeholder")}
className="input is-small omni-search-bar" value={value}
type="text" onChange={handleChange}
placeholder={t("search.placeholder")} ref={comboBoxRef}
onChange={(e) => setQuery(e.target.value)} onQueryChange={setQuery}
onKeyDown={onKeyDown} onKeyDown={(e) => {
value={query} // This is hacky but it seems to be one of the only solutions right now
role="combobox" if (e.key === "Tab") {
aria-autocomplete="both" nextFocusRef?.current?.focus();
data-omnisearch="true" e.preventDefault();
aria-expanded={query.length > 2} clearInput();
aria-label={t("search.ariaLabel")} comboBoxRef.current.value = "";
aria-owns={`omni-search-results-${ariaId}`} } else {
aria-activedescendant={index >= 0 ? `omni-search-selected-option-${ariaId}` : undefined} setDropdown(true);
ref={searchInputRef} }
{...handlers} }}
/> >
{isLoading ? null : ( {showDropdown ? entries : null}
<span className="icon is-right"> {showDropdown ? (
<i className="fas fa-search" /> <HeadlessCombobox.Option
</span> value={{ label: query, value: openHelp, displayValue: query }}
)} key={query}
</div> as={Fragment}
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}> >
{error ? ( {({ active }) => (
<QuickSearchNotification> <ResultHeading>
<SearchErrorNotification error={error} showHelp={openHelp} /> <Combobox.Option isActive={active}>
</QuickSearchNotification> <div className=" is-flex">
) : null} <Icon name="question-circle" color="blue-light" className="pt-1 pl-1"></Icon>
{!error && data ? <Hits showHelp={openHelp} hits={hits} entries={entries} ariaId={ariaId} /> : null} <Label className="has-text-weight-normal pl-3">{t("search.quickSearch.resultHeading")}</Label>
</DropdownMenu> </div>
</div> </Combobox.Option>
</ResultHeading>
)}
</HeadlessCombobox.Option>
) : null}
</Combobox>
</div> </div>
</div> </div>
); );
}; };
const OmniSearchGuard: FC<GuardProps> = ({ links, shouldClear, ariaId }) => { const OmniSearchGuard: FC<GuardProps> = ({ links, ...props }) => {
if (!links.search) { if (!links.search) {
return null; return null;
} }
return <OmniSearch shouldClear={shouldClear} ariaId={ariaId} />; return <OmniSearch {...props} />;
}; };
export default OmniSearchGuard; export default OmniSearchGuard;

View File

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