Skip to content
Snippets Groups Projects
Unverified Commit f62f8267 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Mobile friendly search (#21434)

* Move SearchBarContainer styles to AppBar

* Fix sidebar's border still takes space

* Make search results take full space on mobile

* Minor rename

* Move floating results panel out of input

* Add search clear button for mobile

* Use collapsed search icon for mobile

* Clean up styles

* Add useKeyboardShortcut hook

* Add useOnOutsideClick hook

* Fix search behavior

* Rename `useOnClickOutside`
parent a604b948
Branches
Tags
No related merge requests found
Showing with 337 additions and 113 deletions
import { useEffect } from "react";
export function useKeyboardShortcut(
key: string,
callback: (e: KeyboardEvent) => void,
) {
useEffect(() => {
function keyboardListener(e: KeyboardEvent) {
if (e.key === key) {
callback(e);
}
}
document.addEventListener("keyup", keyboardListener);
return () => {
document.removeEventListener("keyup", keyboardListener);
};
});
}
import { useEffect, RefObject } from "react";
interface ValidRefTarget {
contains(target: EventTarget | null): boolean;
}
export function useOnClickOutside<T extends ValidRefTarget = HTMLDivElement>(
ref: RefObject<T>,
callback: () => void,
) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && event.target && !ref.current.contains(event.target)) {
callback();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, callback]);
}
......@@ -4,7 +4,6 @@ import { t } from "ttag";
import _ from "underscore";
import RecentViews from "metabase/entities/recent-views";
import Card from "metabase/components/Card";
import Text from "metabase/components/type/Text";
import * as Urls from "metabase/lib/urls";
import { isSyncCompleted } from "metabase/lib/syncing";
......@@ -19,6 +18,7 @@ import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { getTranslatedEntityName } from "../utils";
import {
Root,
EmptyStateContainer,
Header,
RecentListItemContent,
......@@ -51,7 +51,7 @@ function RecentsList({ list, loading }) {
}
return (
<Card py={1}>
<Root>
<Header>{t`Recently viewed`}</Header>
<LoadingAndErrorWrapper loading={loading} noWrapper>
<React.Fragment>
......@@ -104,7 +104,7 @@ function RecentsList({ list, loading }) {
)}
</React.Fragment>
</LoadingAndErrorWrapper>
</Card>
</Root>
);
}
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { breakpointMinSmall } from "metabase/styled-components/theme";
export const Root = styled.div`
padding-top: 0.5rem;
padding-bottom: 0.5rem;
background-color: ${color("bg-white")};
line-height: 24px;
box-shadow: 0 20px 20px ${color("shadow")};
${breakpointMinSmall} {
border: 1px solid ${color("border")};
border-radius: 6px;
box-shadow: 0 7px 20px ${color("shadow")};
}
`;
export const EmptyStateContainer = styled.div`
margin: 3rem 0;
`;
......
import styled from "@emotion/styled";
import { css } from "@emotion/react";
import Card from "metabase/components/Card";
import Icon from "metabase/components/Icon";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
import { color } from "metabase/lib/colors";
export const SearchWrapper = styled.div`
import {
breakpointMaxSmall,
breakpointMinSmall,
} from "metabase/styled-components/theme";
const activeInputCSS = css`
border-radius: 6px;
justify-content: flex-start;
`;
export const SearchInputContainer = styled.div<{ isActive: boolean }>`
display: flex;
flex: 1 1 auto;
align-items: center;
max-width: 50em;
position: relative;
background-color: ${color("bg-light")};
border: 1px solid ${color("border")};
border-radius: 6px;
transition: background 150ms;
overflow: hidden;
transition: background 150ms, width 0.2s;
&:hover {
background-color: ${color("bg-medium")};
}
`;
export const SearchInput = styled.input`
width: 100%;
padding: 10px 12px;
font-weight: 700;
@media (prefers-reduced-motion) {
transition: none;
}
color: ${color("text-dark")};
${breakpointMaxSmall} {
justify-content: center;
margin-left: auto;
width: 2rem;
height: 2rem;
border-radius: 99px;
${props =>
props.isActive &&
css`
width: 95%;
${activeInputCSS};
`}
}
${breakpointMinSmall} {
max-width: 50em;
${activeInputCSS};
}
`;
export const SearchInput = styled.input<{ isActive: boolean }>`
background-color: transparent;
border: none;
color: ${color("text-dark")};
font-weight: 700;
&:focus {
outline: none;
}
&::placeholder {
color: ${color("text-dark")};
}
${breakpointMinSmall} {
padding: 10px 12px;
}
${breakpointMaxSmall} {
width: 0;
${props =>
props.isActive &&
css`
width: 100%;
padding-top: 10px;
padding-bottom: 10px;
`}
}
`;
export const SearchIcon = styled(Icon)`
margin-left: 10px;
const ICON_MARGIN = "10px";
export const SearchIcon = styled(Icon)<{ isActive: boolean }>`
${breakpointMaxSmall} {
margin-left: ${props => (props.isActive ? ICON_MARGIN : "3px")};
margin-right: ${props => (props.isActive ? ICON_MARGIN : 0)};
transition: margin 0.3s;
}
${breakpointMinSmall} {
margin-left: ${ICON_MARGIN};
}
`;
export const ClearIconButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 100%;
color: ${color("text-light")};
cursor: pointer;
`;
export const SearchResultsFloatingContainer = styled.div`
position: absolute;
top: 60px;
left: 0;
right: 0;
color: ${color("text-dark")};
${breakpointMaxSmall} {
top: ${APP_BAR_HEIGHT};
}
${breakpointMinSmall} {
top: 60px;
}
`;
export const SearchResultsContainer = styled(Card)`
padding-top: 8px;
padding-bottom: 8px;
export const SearchResultsContainer = styled.div`
padding-top: 0.5rem;
padding-bottom: 0.5rem;
overflow-y: auto;
max-height: 400px;
background-color: ${color("bg-white")};
line-height: 24px;
box-shadow: 0 20px 20px ${color("shadow")};
${breakpointMaxSmall} {
height: calc(100vh - ${APP_BAR_HEIGHT});
}
${breakpointMinSmall} {
max-height: 400px;
border: 1px solid ${color("border")};
border-radius: 6px;
box-shadow: 0 7px 20px ${color("shadow")};
}
`;
import React, {
FocusEvent,
useEffect,
useCallback,
useRef,
useState,
} from "react";
import React, { useEffect, useCallback, useRef, useState } from "react";
import { t } from "ttag";
import { Location, LocationDescriptorObject } from "history";
import OnClickOutsideWrapper from "metabase/components/OnClickOutsideWrapper";
import Icon from "metabase/components/Icon";
import { useKeyboardShortcut } from "metabase/hooks/use-keyboard-shortcut";
import { useOnClickOutside } from "metabase/hooks/use-on-click-outside";
import { usePrevious } from "metabase/hooks/use-previous";
import { useToggle } from "metabase/hooks/use-toggle";
import { isSmallScreen } from "metabase/lib/dom";
import MetabaseSettings from "metabase/lib/settings";
import { SearchResults } from "./SearchResults";
import RecentsList from "./RecentsList";
import {
SearchWrapper,
SearchInputContainer,
SearchIcon,
ClearIconButton,
SearchInput,
SearchResultsFloatingContainer,
SearchResultsContainer,
......@@ -30,7 +28,8 @@ type SearchAwareLocation = Location<{ q?: string }>;
type Props = {
location: SearchAwareLocation;
onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
onSearchActive?: () => void;
onSearchInactive?: () => void;
onChangeLocation: (nextLocation: LocationDescriptorObject) => void;
};
......@@ -46,7 +45,12 @@ function getSearchTextFromLocation(location: SearchAwareLocation) {
return "";
}
function SearchBar({ location, onFocus, onChangeLocation }: Props) {
function SearchBar({
location,
onSearchActive,
onSearchInactive,
onChangeLocation,
}: Props) {
const [searchText, setSearchText] = useState<string>(() =>
getSearchTextFromLocation(location),
);
......@@ -55,13 +59,39 @@ function SearchBar({ location, onFocus, onChangeLocation }: Props) {
false,
);
const wasActive = usePrevious(isActive);
const previousLocation = usePrevious(location);
const container = useRef<HTMLDivElement>(null);
const searchInput = useRef<HTMLInputElement>(null);
const onInputContainerClick = useCallback(() => {
searchInput.current?.focus();
setActive();
}, [setActive]);
const onTextChange = useCallback(e => {
setSearchText(e.target.value);
}, []);
const onClear = useCallback(e => {
setSearchText("");
}, []);
useOnClickOutside(container, setInactive);
useKeyboardShortcut("Escape", setInactive);
useEffect(() => {
if (!wasActive && isActive) {
onSearchActive?.();
} else if (wasActive && !isActive) {
if (isSmallScreen()) {
setSearchText("");
}
onSearchInactive?.();
}
}, [wasActive, isActive, onSearchActive, onSearchInactive]);
useEffect(() => {
function focusOnForwardSlashPress(e: KeyboardEvent) {
if (
......@@ -106,33 +136,39 @@ function SearchBar({ location, onFocus, onChangeLocation }: Props) {
[searchText, onChangeLocation],
);
const hasSearchText = searchText.trim().length > 0;
return (
<OnClickOutsideWrapper handleDismissal={setInactive}>
<SearchWrapper onClick={setActive}>
<SearchIcon name="search" />
<div ref={container}>
<SearchInputContainer isActive={isActive} onClick={onInputContainerClick}>
<SearchIcon name="search" isActive={isActive} />
<SearchInput
isActive={isActive}
value={searchText}
placeholder={t`Search` + ""}
maxLength={200}
onClick={setActive}
onFocus={onFocus}
onChange={onTextChange}
onKeyPress={handleInputKeyPress}
ref={searchInput}
/>
{isActive && MetabaseSettings.searchTypeaheadEnabled() && (
<SearchResultsFloatingContainer>
{searchText.trim().length > 0 ? (
<SearchResultsContainer>
<SearchResults searchText={searchText.trim()} />
</SearchResultsContainer>
) : (
<RecentsList />
)}
</SearchResultsFloatingContainer>
{isSmallScreen() && hasSearchText && (
<ClearIconButton onClick={onClear}>
<Icon name="close" />
</ClearIconButton>
)}
</SearchWrapper>
</OnClickOutsideWrapper>
</SearchInputContainer>
{isActive && MetabaseSettings.searchTypeaheadEnabled() && (
<SearchResultsFloatingContainer>
{hasSearchText ? (
<SearchResultsContainer>
<SearchResults searchText={searchText.trim()} />
</SearchResultsContainer>
) : (
<RecentsList />
)}
</SearchResultsFloatingContainer>
)}
</div>
);
}
export default SearchBar;
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { space } from "metabase/styled-components/theme";
import {
breakpointMaxSmall,
breakpointMinSmall,
space,
} from "metabase/styled-components/theme";
import { APP_BAR_HEIGHT } from "../constants";
......@@ -9,6 +13,7 @@ export const AppBarRoot = styled.header`
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: ${APP_BAR_HEIGHT};
background-color: ${color("bg-white")};
......@@ -16,6 +21,17 @@ export const AppBarRoot = styled.header`
z-index: 4;
`;
export const RowLeft = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;
export const RowRight = styled(RowLeft)`
flex: 1;
justify-content: flex-end;
`;
export const LogoIconWrapper = styled.div`
cursor: pointer;
display: flex;
......@@ -29,3 +45,24 @@ export const LogoIconWrapper = styled.div`
background-color: ${color("bg-light")};
}
`;
export const SearchBarContainer = styled.div`
display: flex;
align-items: center;
margin-right: 1rem;
${breakpointMaxSmall} {
width: 100%;
}
`;
export const SearchBarContent = styled.div`
${breakpointMaxSmall} {
width: 100%;
}
${breakpointMinSmall} {
position: relative;
width: 500px;
}
`;
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import { Location, LocationDescriptorObject } from "history";
......@@ -9,16 +9,19 @@ import LogoIcon from "metabase/components/LogoIcon";
import SearchBar from "metabase/nav/components/SearchBar";
import SidebarButton from "metabase/nav/components/SidebarButton";
import NewButton from "metabase/nav/containers/NewButton";
import {
SearchBarContainer,
SearchBarContent,
} from "metabase/nav/containers/Navbar.styled";
import Database from "metabase/entities/databases";
import { isMac } from "metabase/lib/browser";
import { isSmallScreen } from "metabase/lib/dom";
import { AppBarRoot, LogoIconWrapper } from "./AppBar.styled";
import {
AppBarRoot,
LogoIconWrapper,
SearchBarContainer,
SearchBarContent,
RowLeft,
RowRight,
} from "./AppBar.styled";
type Props = {
isSidebarOpen: boolean;
......@@ -37,13 +40,28 @@ function AppBar({
handleCloseSidebar,
onChangeLocation,
}: Props) {
const closeSidebarForSmallScreens = useCallback(() => {
const [isSearchActive, setSearchActive] = useState(false);
const onLogoClick = useCallback(() => {
if (isSmallScreen()) {
handleCloseSidebar();
}
}, [handleCloseSidebar]);
const sidebarButtonLabel = useMemo(() => {
const onSearchActive = useCallback(() => {
if (isSmallScreen()) {
setSearchActive(true);
handleCloseSidebar();
}
}, [handleCloseSidebar]);
const onSearchInactive = useCallback(() => {
if (isSmallScreen()) {
setSearchActive(false);
}
}, []);
const sidebarButtonTooltip = useMemo(() => {
const message = isSidebarOpen ? t`Close sidebar` : t`Open sidebar`;
const shortcut = isMac() ? "(⌘ + .)" : "(Ctrl + .)";
return `${message} ${shortcut}`;
......@@ -51,31 +69,34 @@ function AppBar({
return (
<AppBarRoot>
<LogoIconWrapper>
<Link
to="/"
onClick={closeSidebarForSmallScreens}
data-metabase-event="Navbar;Logo"
>
<LogoIcon size={24} />
</Link>
</LogoIconWrapper>
<Tooltip tooltip={sidebarButtonLabel}>
<SidebarButton
onClick={onToggleSidebarClick}
isSidebarOpen={isSidebarOpen}
/>
</Tooltip>
<SearchBarContainer>
<SearchBarContent>
<SearchBar
location={location}
onChangeLocation={onChangeLocation}
onFocus={closeSidebarForSmallScreens}
/>
</SearchBarContent>
</SearchBarContainer>
<NewButton setModal={onNewClick} />
<RowLeft>
<LogoIconWrapper>
<Link to="/" onClick={onLogoClick} data-metabase-event="Navbar;Logo">
<LogoIcon size={24} />
</Link>
</LogoIconWrapper>
{!isSearchActive && (
<Tooltip tooltip={sidebarButtonTooltip}>
<SidebarButton
isSidebarOpen={isSidebarOpen}
onClick={onToggleSidebarClick}
/>
</Tooltip>
)}
</RowLeft>
<RowRight>
<SearchBarContainer>
<SearchBarContent>
<SearchBar
location={location}
onChangeLocation={onChangeLocation}
onSearchActive={onSearchActive}
onSearchInactive={onSearchInactive}
/>
</SearchBarContent>
</SearchBarContainer>
<NewButton setModal={onNewClick} />
</RowRight>
</AppBarRoot>
);
}
......
......@@ -11,6 +11,8 @@ import {
const openNavbarCSS = css`
width: ${NAV_SIDEBAR_WIDTH};
border-right: 1px solid ${color("border")};
${breakpointMaxSmall} {
width: 90vw;
}
......@@ -30,8 +32,6 @@ export const Sidebar = styled.aside<{ isOpen: boolean }>`
overflow-x: hidden;
z-index: 4;
border-right: 1px solid ${color("border")};
transition: width 0.2s;
@media (prefers-reduced-motion) {
......@@ -55,32 +55,6 @@ export const LogoIconContainer = styled.div`
height: 2rem;
`;
export const SearchBarContainer = styled.div`
display: flex;
flex: 1 0 auto;
align-items: center;
padding-right: 1rem;
z-index: 1;
`;
export const SearchBarContent = styled.div`
width: 100%;
max-width: 500px;
margin-left: auto;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
transition: max-width 0.2s;
@media (prefers-reduced-motion) {
transition: none;
}
${breakpointMaxSmall} {
max-width: 60vw;
}
`;
export const EntityMenuContainer = styled.div`
display: flex;
position: relative;
......
......@@ -27,7 +27,7 @@ function NewButton({
}) {
return (
<EntityMenu
className="hide sm-show mr1 ml-auto"
className="hide sm-show mr1"
trigger={
<Link
mr={1}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment