mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 08:25:44 +01:00
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:
2
gradle/changelog/omnisearch_refactor.yaml
Normal file
2
gradle/changelog/omnisearch_refactor.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: changed
|
||||||
|
description: OmniSearchbar now makes use of the Combobox
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
ForwardedRef,
|
ForwardedRef,
|
||||||
Fragment,
|
Fragment,
|
||||||
KeyboardEvent,
|
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
Ref,
|
Ref,
|
||||||
@@ -48,6 +47,7 @@ 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,19 +158,22 @@ const IconWrapper: FC<IconWrapperProps> = ({ icon, count }) => (
|
|||||||
</IconContainer>
|
</IconContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
type Props = DropDownMenuProps & {
|
type Props = React.PropsWithChildren<
|
||||||
|
DropDownMenuProps & {
|
||||||
className?: string;
|
className?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
count?: string;
|
count?: string;
|
||||||
error?: Error | null;
|
error?: Error | null;
|
||||||
isLoading?: boolean;
|
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>(
|
||||||
|
({ className, icon, count, error, isLoading, mobilePosition, children }, ref) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,24 +183,27 @@ const HeaderDropDown: FC<Props> = ({ className, icon, count, error, isLoading, m
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 openHelp = () => setShowHelp(true);
|
const clearInput = () => {
|
||||||
const closeHelp = () => setShowHelp(false);
|
shouldClear = true;
|
||||||
const clearQuery = useCallback(() => {
|
setValue({ label: "", value: undefined });
|
||||||
if (shouldClear) {
|
|
||||||
setQuery("");
|
setQuery("");
|
||||||
}
|
setDropdown(false);
|
||||||
}, [shouldClear]);
|
};
|
||||||
|
|
||||||
|
const openHelp = () => setShowHelp(true);
|
||||||
|
|
||||||
|
const closeHelp = () => setShowHelp(false);
|
||||||
|
|
||||||
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
|
|
||||||
className="input is-small omni-search-bar"
|
|
||||||
type="text"
|
|
||||||
placeholder={t("search.placeholder")}
|
placeholder={t("search.placeholder")}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={value}
|
||||||
onKeyDown={onKeyDown}
|
onChange={handleChange}
|
||||||
value={query}
|
ref={comboBoxRef}
|
||||||
role="combobox"
|
onQueryChange={setQuery}
|
||||||
aria-autocomplete="both"
|
onKeyDown={(e) => {
|
||||||
data-omnisearch="true"
|
// This is hacky but it seems to be one of the only solutions right now
|
||||||
aria-expanded={query.length > 2}
|
if (e.key === "Tab") {
|
||||||
aria-label={t("search.ariaLabel")}
|
nextFocusRef?.current?.focus();
|
||||||
aria-owns={`omni-search-results-${ariaId}`}
|
e.preventDefault();
|
||||||
aria-activedescendant={index >= 0 ? `omni-search-selected-option-${ariaId}` : undefined}
|
clearInput();
|
||||||
ref={searchInputRef}
|
comboBoxRef.current.value = "";
|
||||||
{...handlers}
|
} else {
|
||||||
/>
|
setDropdown(true);
|
||||||
{isLoading ? null : (
|
}
|
||||||
<span className="icon is-right">
|
}}
|
||||||
<i className="fas fa-search" />
|
>
|
||||||
</span>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</HeadlessCombobox.Option>
|
||||||
<DropdownMenu className="dropdown-menu" onMouseDown={(e) => e.preventDefault()}>
|
|
||||||
{error ? (
|
|
||||||
<QuickSearchNotification>
|
|
||||||
<SearchErrorNotification error={error} showHelp={openHelp} />
|
|
||||||
</QuickSearchNotification>
|
|
||||||
) : null}
|
) : null}
|
||||||
{!error && data ? <Hits showHelp={openHelp} hits={hits} entries={entries} ariaId={ariaId} /> : null}
|
</Combobox>
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user