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