Navigation: Mobile support for topnav items (#56568)

* add topbar layout and wrapper

* mobile support for topnav icons

* improved hooks implementation

* added tests for TopSearchBarSection

* support css overrides for select and valuepicker

* make singlevalue inherit the color from select
This commit is contained in:
Leo 2022-10-11 14:24:02 +02:00 committed by GitHub
parent 90cf76e05e
commit 0a183d1ba2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 215 additions and 60 deletions

View File

@ -13,7 +13,6 @@ import { SlideOutTransition } from '../transitions/SlideOutTransition';
const getStyles = (theme: GrafanaTheme2) => {
const singleValue = css`
label: singleValue;
color: ${theme.components.input.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@ -32,6 +32,9 @@ export interface ValuePickerProps<T> {
menuPlacement?: 'auto' | 'bottom' | 'top';
/** Which ButtonFill to use */
fill?: ButtonFill;
/** custom css applied to the button */
buttonCss?: string;
}
export function ValuePicker<T>({
@ -46,6 +49,7 @@ export function ValuePicker<T>({
isFullWidth = true,
menuPlacement,
fill,
buttonCss,
}: ValuePickerProps<T>) {
const [isPicking, setIsPicking] = useState(false);
const theme = useTheme2();
@ -55,6 +59,7 @@ export function ValuePicker<T>({
{!isPicking && (
<Button
size={size || 'sm'}
className={buttonCss}
icon={icon || 'plus'}
onClick={() => setIsPicking(true)}
variant={variant}

View File

@ -7,7 +7,11 @@ import { DEFAULT_FEED_URL } from 'app/plugins/panel/news/constants';
import { NewsWrapper } from './NewsWrapper';
export function NewsContainer() {
interface NewsContainerProps {
className?: string;
}
export function NewsContainer({ className }: NewsContainerProps) {
const [showNewsDrawer, onToggleShowNewsDrawer] = useToggle(false);
const onChildClick = () => {
@ -16,7 +20,7 @@ export function NewsContainer() {
return (
<>
<ToolbarButton onClick={onChildClick} iconOnly icon="rss" aria-label="News" />
<ToolbarButton className={className} onClick={onChildClick} iconOnly icon="rss" aria-label="News" />
{showNewsDrawer && (
<Drawer title={t('news.title', 'Latest from the blog')} scrollableContent onClose={onToggleShowNewsDrawer}>
<NewsWrapper feedUrl={DEFAULT_FEED_URL} />

View File

@ -1,15 +1,19 @@
import { css } from '@emotion/css';
import React from 'react';
import { ValuePicker } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { ValuePicker, useStyles2 } from '@grafana/ui';
import { UserOrg } from 'app/types';
import { OrganizationBaseProps } from './types';
export function OrganizationPicker({ orgs, onSelectChange }: OrganizationBaseProps) {
const styles = useStyles2(getStyles);
return (
<ValuePicker<UserOrg>
aria-label="Change organization"
variant="secondary"
buttonCss={styles.buttonCss}
size="md"
label=""
fill="text"
@ -24,3 +28,12 @@ export function OrganizationPicker({ orgs, onSelectChange }: OrganizationBasePro
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
buttonCss: css({
color: theme.colors.text.secondary,
'&:hover': {
color: theme.colors.text.primary,
},
}),
});

View File

@ -26,7 +26,7 @@ export function OrganizationSelect({ orgs, onSelectChange }: OrganizationBasePro
aria-label="Change organization"
width={'auto'}
value={value}
prefix={<Icon name="building" />}
prefix={<Icon className="prefix-icon" name="building" />}
className={styles.select}
options={orgs.map((org) => ({
label: org.name,
@ -42,5 +42,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
select: css({
border: 'none',
background: 'none',
color: theme.colors.text.secondary,
'&:hover': {
color: theme.colors.text.primary,
'& .prefix-icon': css({
color: theme.colors.text.primary,
}),
},
}),
});

View File

@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { getUserOrganizations, setUserOrganization } from 'app/features/org/state/actions';
import { useDispatch, useSelector, UserOrg } from 'app/types';
@ -13,9 +14,6 @@ export function OrganizationSwitcher() {
const theme = useTheme2();
const dispatch = useDispatch();
const orgs = useSelector((state) => state.organization.userOrgs);
const [isSmallScreen, setIsSmallScreen] = useState(
window.matchMedia(`(max-width: ${theme.breakpoints.values.sm}px)`).matches
);
const onSelectChange = (option: SelectableValue<UserOrg>) => {
if (option.value) {
setUserOrganization(option.value.orgId);
@ -28,12 +26,16 @@ export function OrganizationSwitcher() {
dispatch(getUserOrganizations());
}, [dispatch]);
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${theme.breakpoints.values.sm}px)`);
const onMediaQueryChange = (e: MediaQueryListEvent) => setIsSmallScreen(e.matches);
mediaQuery.addEventListener('change', onMediaQueryChange);
return () => mediaQuery.removeEventListener('change', onMediaQueryChange);
}, [isSmallScreen, theme.breakpoints.values.sm]);
const breakpoint = theme.breakpoints.values.sm;
const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
useMediaQueryChange({
breakpoint,
onChange: (e) => {
setIsSmallScreen(e.matches);
},
});
if (orgs?.length <= 1) {
return null;

View File

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TopSearchBarSection, TopSearchBarSectionProps } from './TopSearchBarSection';
const renderComponent = (options?: { props: TopSearchBarSectionProps }) => {
const { props } = options || {};
return render(
<TopSearchBarSection {...props}>
<button>Test Item</button>
</TopSearchBarSection>
);
};
describe('TopSearchBarSection', () => {
it('should use a wrapper on non mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
matches: () => false,
}));
const { container } = renderComponent();
expect(container.querySelector('[data-test-id="wrapper"]')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /test item/i })).toBeInTheDocument();
});
it('should not use a wrapper on mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
matches: () => true,
}));
const { container } = renderComponent();
expect(container.querySelector('[data-test-id="wrapper"]')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /test item/i })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,50 @@
import { css, cx } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
export interface TopSearchBarSectionProps {
children: React.ReactNode;
align?: 'left' | 'center' | 'right';
}
export function TopSearchBarSection({ children, align = 'left' }: TopSearchBarSectionProps) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const breakpoint = theme.breakpoints.values.sm;
const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
useMediaQueryChange({
breakpoint,
onChange: (e: MediaQueryListEvent) => {
setIsSmallScreen(e.matches);
},
});
if (isSmallScreen) {
return <>{children}</>;
}
return (
<div data-test-id="wrapper" className={cx(styles.wrapper, { [styles[align]]: align === 'right' })}>
{children}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
}),
right: css({
justifyContent: 'flex-end',
}),
left: css({}),
center: css({}),
});

View File

@ -10,6 +10,7 @@ import { NewsContainer } from './News/NewsContainer';
import { OrganizationSwitcher } from './Organization/OrganizationSwitcher';
import { SignInLink } from './TopBar/SignInLink';
import { TopNavBarMenu } from './TopBar/TopNavBarMenu';
import { TopSearchBarSection } from './TopBar/TopSearchBarSection';
import { TopSearchBarInput } from './TopSearchBarInput';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
@ -21,23 +22,23 @@ export function TopSearchBar() {
const profileNode = navIndex['profile'];
return (
<div className={styles.container}>
<div className={styles.leftContent}>
<div className={styles.layout}>
<TopSearchBarSection>
<a className={styles.logo} href="/" title="Go to home">
<Icon name="grafana" size="xl" />
</a>
<OrganizationSwitcher />
</div>
<div className={styles.searchWrapper}>
</TopSearchBarSection>
<TopSearchBarSection>
<TopSearchBarInput />
</div>
<div className={styles.actions}>
</TopSearchBarSection>
<TopSearchBarSection align="right">
{helpNode && (
<Dropdown overlay={() => <TopNavBarMenu node={helpNode} />}>
<ToolbarButton iconOnly icon="question-circle" aria-label="Help" />
</Dropdown>
)}
<NewsContainer />
<NewsContainer className={styles.newsButton} />
{!contextSrv.user.isSignedIn && <SignInLink />}
{profileNode && (
<Dropdown overlay={<TopNavBarMenu node={profileNode} />}>
@ -49,45 +50,43 @@ export function TopSearchBar() {
/>
</Dropdown>
)}
</div>
</TopSearchBarSection>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
height: TOP_BAR_LEVEL_HEIGHT,
display: 'grid',
gap: theme.spacing(0.5),
const getStyles = (theme: GrafanaTheme2) => ({
layout: css({
height: TOP_BAR_LEVEL_HEIGHT,
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
padding: theme.spacing(0, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
justifyContent: 'space-between',
[theme.breakpoints.up('sm')]: {
gridTemplateColumns: '1fr 2fr 1fr',
padding: theme.spacing(0, 2),
alignItems: 'center',
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
leftContent: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
logo: css({
display: 'flex',
}),
searchWrapper: css({}),
searchInput: css({}),
actions: css({
display: 'flex',
gap: theme.spacing(0.5),
justifyContent: 'flex-end',
alignItems: 'center',
}),
profileButton: css({
img: {
borderRadius: '50%',
height: '24px',
marginRight: 0,
width: '24px',
},
}),
};
};
display: 'grid',
justifyContent: 'flex-start',
},
}),
logo: css({
display: 'flex',
}),
profileButton: css({
img: {
borderRadius: '50%',
height: '24px',
marginRight: 0,
width: '24px',
},
}),
newsButton: css({
[theme.breakpoints.down('sm')]: {
display: 'none',
},
}),
});

View File

@ -1,12 +1,24 @@
import React from 'react';
import React, { useState } from 'react';
import { locationService } from '@grafana/runtime';
import { FilterInput } from '@grafana/ui';
import { FilterInput, ToolbarButton, useTheme2 } from '@grafana/ui';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { t } from 'app/core/internationalization';
import { useSearchQuery } from 'app/features/search/hooks/useSearchQuery';
export function TopSearchBarInput() {
const theme = useTheme2();
const { query, onQueryChange } = useSearchQuery({});
const breakpoint = theme.breakpoints.values.sm;
const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
useMediaQueryChange({
breakpoint,
onChange: (e) => {
setIsSmallScreen(e.matches);
},
});
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
@ -18,6 +30,11 @@ export function TopSearchBarInput() {
onOpenSearch();
}
};
if (isSmallScreen) {
return <ToolbarButton iconOnly icon="search" aria-label="Search Grafana" onClick={onOpenSearch} />;
}
return (
<FilterInput
onClick={onOpenSearch}

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
export function useMediaQueryChange({
breakpoint,
onChange,
}: {
breakpoint: number;
onChange: (e: MediaQueryListEvent) => void;
}) {
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint}px)`);
const onMediaQueryChange = (e: MediaQueryListEvent) => onChange(e);
mediaQuery.addEventListener('change', onMediaQueryChange);
return () => mediaQuery.removeEventListener('change', onMediaQueryChange);
}, [breakpoint, onChange]);
}