mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
90cf76e05e
commit
0a183d1ba2
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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} />
|
||||
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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({}),
|
||||
});
|
@ -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',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -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}
|
||||
|
17
public/app/core/hooks/useMediaQueryChange.ts
Normal file
17
public/app/core/hooks/useMediaQueryChange.ts
Normal 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]);
|
||||
}
|
Loading…
Reference in New Issue
Block a user