mirror of https://github.com/grafana/grafana
GrafanaUI: Add new `EmptyState` component (#84891)
* add empty search state component * extract translations * nicer prop spreading * actually expose component in grafana-ui * use requestAnimationFrame * add rollup-plugin-svg-import * move to a single EmptyState component with variants * update docs * remove CTA * add some more prop documentation * just use 1 svgpull/85004/head
parent
f47916253c
commit
a0bcc44b63
@ -0,0 +1,43 @@ |
|||||||
|
import { ArgTypes } from '@storybook/blocks'; |
||||||
|
import { EmptyState } from './EmptyState'; |
||||||
|
|
||||||
|
# EmptyState |
||||||
|
|
||||||
|
Use an empty state to communicate to the user that there is no data to display, or that a search query returned no results. |
||||||
|
|
||||||
|
## variant="search" |
||||||
|
|
||||||
|
### When to use |
||||||
|
|
||||||
|
Use in place of a results table or list when a search query or filter returns no results. |
||||||
|
|
||||||
|
There are sensible defaults for the image and message, so in most cases you can use it without any additional props. |
||||||
|
|
||||||
|
```jsx |
||||||
|
import { EmptyState } from '@grafana/ui'; |
||||||
|
|
||||||
|
<EmptyState variant="search" />; |
||||||
|
``` |
||||||
|
|
||||||
|
### Providing custom overrides |
||||||
|
|
||||||
|
You can optionally override the message or image, and add additional information or a button (e.g. to clear the search query) |
||||||
|
|
||||||
|
```jsx |
||||||
|
import { Button, EmptyState } from '@grafana/ui'; |
||||||
|
|
||||||
|
<EmptyState |
||||||
|
button={<Button variant="secondary" onClick={clearSearchQuery} />} |
||||||
|
image={<AnyReactNode />} |
||||||
|
message="No playlists found" |
||||||
|
> |
||||||
|
Optionally provide some additional information here. Maybe even a link to{' '} |
||||||
|
<TextLink href="<externalDocsLink>" external> |
||||||
|
documentation. |
||||||
|
</TextLink> |
||||||
|
</EmptyState>; |
||||||
|
``` |
||||||
|
|
||||||
|
## Props |
||||||
|
|
||||||
|
<ArgTypes of={EmptyState} /> |
@ -0,0 +1,33 @@ |
|||||||
|
import { Meta, StoryFn } from '@storybook/react'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { EmptyState } from './EmptyState'; |
||||||
|
import mdx from './EmptyState.mdx'; |
||||||
|
|
||||||
|
const meta: Meta<typeof EmptyState> = { |
||||||
|
title: 'General/EmptyState', |
||||||
|
component: EmptyState, |
||||||
|
parameters: { |
||||||
|
docs: { |
||||||
|
page: mdx, |
||||||
|
}, |
||||||
|
controls: { |
||||||
|
exclude: ['button', 'image', 'variant'], |
||||||
|
}, |
||||||
|
}, |
||||||
|
argTypes: { |
||||||
|
children: { |
||||||
|
type: 'string', |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
export const Basic: StoryFn<typeof EmptyState> = (args) => { |
||||||
|
return <EmptyState {...args} />; |
||||||
|
}; |
||||||
|
|
||||||
|
Basic.args = { |
||||||
|
children: 'Use this space to add any additional information', |
||||||
|
}; |
||||||
|
|
||||||
|
export default meta; |
@ -0,0 +1,47 @@ |
|||||||
|
import React, { ReactNode } from 'react'; |
||||||
|
|
||||||
|
import { t } from '../../utils/i18n'; |
||||||
|
import { Box } from '../Layout/Box/Box'; |
||||||
|
import { Stack } from '../Layout/Stack/Stack'; |
||||||
|
import { Text } from '../Text/Text'; |
||||||
|
|
||||||
|
import { GrotNotFound } from './GrotNotFound/GrotNotFound'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
/** |
||||||
|
* Provide a button to render below the message |
||||||
|
*/ |
||||||
|
button?: ReactNode; |
||||||
|
hideImage?: boolean; |
||||||
|
/** |
||||||
|
* Override the default image for the variant |
||||||
|
*/ |
||||||
|
image?: ReactNode; |
||||||
|
/** |
||||||
|
* Message to display to the user |
||||||
|
*/ |
||||||
|
message?: string; |
||||||
|
/** |
||||||
|
* Empty state variant. Possible values are 'search'. |
||||||
|
*/ |
||||||
|
variant: 'search'; |
||||||
|
} |
||||||
|
|
||||||
|
export const EmptyState = ({ |
||||||
|
button, |
||||||
|
children, |
||||||
|
image = <GrotNotFound width={300} />, |
||||||
|
message = t('grafana-ui.empty-state.search-message', 'No results found'), |
||||||
|
hideImage = false, |
||||||
|
}: React.PropsWithChildren<Props>) => { |
||||||
|
return ( |
||||||
|
<Box paddingY={4} gap={4} display="flex" direction="column" alignItems="center"> |
||||||
|
{!hideImage && image} |
||||||
|
<Stack direction="column" alignItems="center"> |
||||||
|
<Text variant="h4">{message}</Text> |
||||||
|
{children && <Text color="secondary">{children}</Text>} |
||||||
|
</Stack> |
||||||
|
{button} |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,72 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { SVGProps, useEffect, useRef } from 'react'; |
||||||
|
import SVG from 'react-inlinesvg'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
|
||||||
|
import { useStyles2 } from '../../../themes'; |
||||||
|
|
||||||
|
import notFoundSvg from './grot-not-found.svg'; |
||||||
|
|
||||||
|
const MIN_ARM_ROTATION = -20; |
||||||
|
const MAX_ARM_ROTATION = 5; |
||||||
|
const MIN_ARM_TRANSLATION = -5; |
||||||
|
const MAX_ARM_TRANSLATION = 5; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
width?: SVGProps<SVGElement>['width']; |
||||||
|
height?: SVGProps<SVGElement>['height']; |
||||||
|
} |
||||||
|
|
||||||
|
export const GrotNotFound = ({ width = 'auto', height }: Props) => { |
||||||
|
const svgRef = useRef<SVGElement>(null); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handleMouseMove = (event: MouseEvent) => { |
||||||
|
const grotArm = svgRef.current?.querySelector('#grot-not-found-arm'); |
||||||
|
const grotMagnifier = svgRef.current?.querySelector('#grot-not-found-magnifier'); |
||||||
|
|
||||||
|
const { clientX, clientY } = event; |
||||||
|
const { innerWidth, innerHeight } = window; |
||||||
|
const heightRatio = clientY / innerHeight; |
||||||
|
const widthRatio = clientX / innerWidth; |
||||||
|
const rotation = getIntermediateValue(heightRatio, MIN_ARM_ROTATION, MAX_ARM_ROTATION); |
||||||
|
const translation = getIntermediateValue(widthRatio, MIN_ARM_TRANSLATION, MAX_ARM_TRANSLATION); |
||||||
|
|
||||||
|
window.requestAnimationFrame(() => { |
||||||
|
grotArm?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`); |
||||||
|
grotMagnifier?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove); |
||||||
|
|
||||||
|
return () => { |
||||||
|
window.removeEventListener('mousemove', handleMouseMove); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return <SVG innerRef={svgRef} src={notFoundSvg} className={styles.svg} height={height} width={width} />; |
||||||
|
}; |
||||||
|
|
||||||
|
GrotNotFound.displayName = 'GrotNotFound'; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
svg: css({ |
||||||
|
'#grot-not-found-arm, #grot-not-found-magnifier': { |
||||||
|
transformOrigin: 'center', |
||||||
|
}, |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Given a start value, end value, and a ratio, return the intermediate value |
||||||
|
* Works with negative and inverted start/end values |
||||||
|
*/ |
||||||
|
const getIntermediateValue = (ratio: number, start: number, end: number) => { |
||||||
|
const value = ratio * (end - start) + start; |
||||||
|
return value; |
||||||
|
}; |
After Width: | Height: | Size: 16 KiB |
Loading…
Reference in new issue