mirror of https://github.com/grafana/grafana
parent
134467fc4a
commit
5654a52249
@ -0,0 +1,84 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { CSSProperties } from 'react'; |
||||
import * as React from 'react'; |
||||
|
||||
import { GrafanaTheme2, fieldColorModeRegistry } from '@grafana/data'; |
||||
import { LineStyle } from '@grafana/schema'; |
||||
|
||||
import { useTheme2, useStyles2 } from '../../themes'; |
||||
|
||||
export interface Props extends React.HTMLAttributes<HTMLDivElement> { |
||||
color?: string; |
||||
gradient?: string; |
||||
lineStyle?: LineStyle; |
||||
} |
||||
|
||||
export const SeriesIcon = React.memo( |
||||
React.forwardRef<HTMLDivElement, Props>(({ color, className, gradient, lineStyle, ...restProps }, ref) => { |
||||
const theme = useTheme2(); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
let cssColor: string; |
||||
|
||||
if (gradient) { |
||||
const colors = fieldColorModeRegistry.get(gradient).getColors?.(theme); |
||||
if (colors?.length) { |
||||
cssColor = `linear-gradient(90deg, ${colors.join(', ')})`; |
||||
} else { |
||||
// Not sure what to default to, this will return gray, this should not happen though.
|
||||
cssColor = theme.visualization.getColorByName(''); |
||||
} |
||||
} else { |
||||
cssColor = color!; |
||||
} |
||||
|
||||
let customStyle: CSSProperties; |
||||
|
||||
if (lineStyle?.fill === 'dot' && !gradient) { |
||||
// make a circle bg image and repeat it
|
||||
customStyle = { |
||||
backgroundImage: `radial-gradient(circle at 2px 2px, ${color} 2px, transparent 0)`, |
||||
backgroundSize: '4px 4px', |
||||
backgroundRepeat: 'space', |
||||
}; |
||||
} else if (lineStyle?.fill === 'dash' && !gradient) { |
||||
// make a rectangle bg image and repeat it
|
||||
customStyle = { |
||||
backgroundImage: `linear-gradient(to right, ${color} 100%, transparent 0%)`, |
||||
backgroundSize: '6px 4px', |
||||
backgroundRepeat: 'space', |
||||
}; |
||||
} else { |
||||
customStyle = { |
||||
background: cssColor, |
||||
borderRadius: theme.shape.radius.pill, |
||||
}; |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
data-testid="series-icon" |
||||
ref={ref} |
||||
className={cx(className, styles.forcedColors, styles.container)} |
||||
style={customStyle} |
||||
{...restProps} |
||||
/> |
||||
); |
||||
}) |
||||
); |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
container: css({ |
||||
marginRight: '8px', |
||||
display: 'inline-block', |
||||
width: '14px', |
||||
height: '4px', |
||||
}), |
||||
forcedColors: css({ |
||||
'@media (forced-colors: active)': { |
||||
forcedColorAdjust: 'none', |
||||
}, |
||||
}), |
||||
}); |
||||
|
||||
SeriesIcon.displayName = 'SeriesIcon'; |
@ -0,0 +1,139 @@ |
||||
import { useCallback } from 'react'; |
||||
import * as React from 'react'; |
||||
|
||||
import { DataHoverClearEvent, DataHoverEvent } from '@grafana/data'; |
||||
import { LegendDisplayMode } from '@grafana/schema'; |
||||
|
||||
import { SeriesVisibilityChangeMode, usePanelContext } from '../PanelChrome'; |
||||
|
||||
import { VizLegendList } from './VizLegendList'; |
||||
import { VizLegendTable } from './VizLegendTable'; |
||||
import { LegendProps, SeriesVisibilityChangeBehavior, VizLegendItem } from './types'; |
||||
import { mapMouseEventToMode } from './utils'; |
||||
|
||||
/** |
||||
* @public |
||||
*/ |
||||
export function VizLegend2<T>({ |
||||
items, |
||||
thresholdItems, |
||||
displayMode, |
||||
sortBy: sortKey, |
||||
seriesVisibilityChangeBehavior = SeriesVisibilityChangeBehavior.Isolate, |
||||
sortDesc, |
||||
onLabelClick, |
||||
onToggleSort, |
||||
placement, |
||||
className, |
||||
itemRenderer, |
||||
readonly, |
||||
isSortable, |
||||
}: LegendProps<T>) { |
||||
const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext(); |
||||
|
||||
const onMouseOver = useCallback( |
||||
( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement> |
||||
) => { |
||||
eventBus?.publish({ |
||||
type: DataHoverEvent.type, |
||||
payload: { |
||||
raw: event, |
||||
x: 0, |
||||
y: 0, |
||||
dataId: item.label, |
||||
}, |
||||
}); |
||||
}, |
||||
[eventBus] |
||||
); |
||||
|
||||
const onMouseOut = useCallback( |
||||
( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement> |
||||
) => { |
||||
eventBus?.publish({ |
||||
type: DataHoverClearEvent.type, |
||||
payload: { |
||||
raw: event, |
||||
x: 0, |
||||
y: 0, |
||||
dataId: item.label, |
||||
}, |
||||
}); |
||||
}, |
||||
[eventBus] |
||||
); |
||||
|
||||
const onLegendLabelClick = useCallback( |
||||
(item: VizLegendItem, event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { |
||||
if (onLabelClick) { |
||||
onLabelClick(item, event); |
||||
} |
||||
if (onToggleSeriesVisibility) { |
||||
onToggleSeriesVisibility( |
||||
item.fieldName ?? item.label, |
||||
seriesVisibilityChangeBehavior === SeriesVisibilityChangeBehavior.Hide |
||||
? SeriesVisibilityChangeMode.AppendToSelection |
||||
: mapMouseEventToMode(event) |
||||
); |
||||
} |
||||
}, |
||||
[onToggleSeriesVisibility, onLabelClick, seriesVisibilityChangeBehavior] |
||||
); |
||||
|
||||
switch (displayMode) { |
||||
case LegendDisplayMode.Table: |
||||
return ( |
||||
<VizLegendTable<T> |
||||
className={className} |
||||
items={items} |
||||
placement={placement} |
||||
sortBy={sortKey} |
||||
sortDesc={sortDesc} |
||||
onLabelClick={onLegendLabelClick} |
||||
onToggleSort={onToggleSort || onToggleLegendSort} |
||||
onLabelMouseOver={onMouseOver} |
||||
onLabelMouseOut={onMouseOut} |
||||
itemRenderer={itemRenderer} |
||||
readonly={readonly} |
||||
isSortable={isSortable} |
||||
/> |
||||
); |
||||
case LegendDisplayMode.List: |
||||
return ( |
||||
<> |
||||
{/* render series colors if more than one series or no threshold colors */} |
||||
{(items.length > 1 || thresholdItems?.length === 0) && ( |
||||
<VizLegendList<T> |
||||
className={className} |
||||
items={items} |
||||
placement={placement} |
||||
onLabelMouseOver={onMouseOver} |
||||
onLabelMouseOut={onMouseOut} |
||||
onLabelClick={onLegendLabelClick} |
||||
itemRenderer={itemRenderer} |
||||
readonly={readonly} |
||||
/> |
||||
)} |
||||
{/* render threshold colors if exists */} |
||||
{thresholdItems && ( |
||||
<VizLegendList<T> |
||||
className={className} |
||||
items={thresholdItems} |
||||
placement={placement} |
||||
onLabelMouseOver={onMouseOver} |
||||
onLabelMouseOut={onMouseOut} |
||||
onLabelClick={onLegendLabelClick} |
||||
itemRenderer={itemRenderer} |
||||
readonly={readonly} |
||||
/> |
||||
)} |
||||
</> |
||||
); |
||||
default: |
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,121 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { useStyles2 } from '../../themes'; |
||||
import { InlineList } from '../List/InlineList'; |
||||
import { List } from '../List/List'; |
||||
|
||||
import { VizLegendListItem } from './VizLegendListItem'; |
||||
import { VizLegendBaseProps, VizLegendItem } from './types'; |
||||
|
||||
export interface Props<T> extends VizLegendBaseProps<T> {} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const VizLegendList = <T extends unknown>({ |
||||
items, |
||||
itemRenderer, |
||||
onLabelMouseOver, |
||||
onLabelMouseOut, |
||||
onLabelClick, |
||||
placement, |
||||
className, |
||||
readonly, |
||||
}: Props<T>) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (!itemRenderer) { |
||||
/* eslint-disable-next-line react/display-name */ |
||||
itemRenderer = (item) => ( |
||||
<VizLegendListItem |
||||
item={item} |
||||
onLabelClick={onLabelClick} |
||||
onLabelMouseOver={onLabelMouseOver} |
||||
onLabelMouseOut={onLabelMouseOut} |
||||
readonly={readonly} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
const getItemKey = (item: VizLegendItem<T>) => `${item.getItemKey ? item.getItemKey() : item.label}`; |
||||
|
||||
switch (placement) { |
||||
case 'right': { |
||||
const renderItem = (item: VizLegendItem<T>, index: number) => { |
||||
return <span className={styles.itemRight}>{itemRenderer!(item, index)}</span>; |
||||
}; |
||||
|
||||
return ( |
||||
<div className={cx(styles.rightWrapper, className)}> |
||||
<List items={items} renderItem={renderItem} getItemKey={getItemKey} /> |
||||
</div> |
||||
); |
||||
} |
||||
case 'bottom': |
||||
default: { |
||||
const leftItems = items.filter((item) => item.yAxis === 1); |
||||
const rightItems = items.filter((item) => item.yAxis !== 1); |
||||
|
||||
const renderItem = (item: VizLegendItem<T>, index: number) => { |
||||
return <span className={styles.itemBottom}>{itemRenderer!(item, index)}</span>; |
||||
}; |
||||
|
||||
return ( |
||||
<div className={cx(styles.bottomWrapper, className)}> |
||||
{leftItems.length > 0 && ( |
||||
<div className={styles.section}> |
||||
<InlineList items={leftItems} renderItem={renderItem} getItemKey={getItemKey} /> |
||||
</div> |
||||
)} |
||||
{rightItems.length > 0 && ( |
||||
<div className={cx(styles.section, styles.sectionRight)}> |
||||
<InlineList items={rightItems} renderItem={renderItem} getItemKey={getItemKey} /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
VizLegendList.displayName = 'VizLegendList'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
const itemStyles = css({ |
||||
paddingRight: '10px', |
||||
display: 'flex', |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
whiteSpace: 'nowrap', |
||||
}); |
||||
|
||||
return { |
||||
itemBottom: itemStyles, |
||||
itemRight: cx( |
||||
itemStyles, |
||||
css({ |
||||
marginBottom: theme.spacing(0.5), |
||||
}) |
||||
), |
||||
rightWrapper: css({ |
||||
paddingLeft: theme.spacing(0.5), |
||||
}), |
||||
bottomWrapper: css({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
justifyContent: 'space-between', |
||||
width: '100%', |
||||
paddingLeft: theme.spacing(0.5), |
||||
gap: '15px 25px', |
||||
}), |
||||
section: css({ |
||||
display: 'flex', |
||||
}), |
||||
sectionRight: css({ |
||||
justifyContent: 'flex-end', |
||||
flexGrow: 1, |
||||
flexBasis: '50%', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,128 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { useCallback } from 'react'; |
||||
import * as React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
import { useStyles2 } from '../../themes'; |
||||
|
||||
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; |
||||
import { VizLegendStatsList } from './VizLegendStatsList'; |
||||
import { VizLegendItem } from './types'; |
||||
|
||||
export interface Props<T> { |
||||
item: VizLegendItem<T>; |
||||
className?: string; |
||||
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLButtonElement>) => void; |
||||
onLabelMouseOver?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
onLabelMouseOut?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
readonly?: boolean; |
||||
} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const VizLegendListItem = <T = unknown,>({ |
||||
item, |
||||
onLabelClick, |
||||
onLabelMouseOver, |
||||
onLabelMouseOut, |
||||
className, |
||||
readonly, |
||||
}: Props<T>) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const onMouseOver = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => { |
||||
if (onLabelMouseOver) { |
||||
onLabelMouseOver(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelMouseOver] |
||||
); |
||||
|
||||
const onMouseOut = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => { |
||||
if (onLabelMouseOut) { |
||||
onLabelMouseOut(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelMouseOut] |
||||
); |
||||
|
||||
const onClick = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { |
||||
if (onLabelClick) { |
||||
onLabelClick(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelClick] |
||||
); |
||||
|
||||
return ( |
||||
<div |
||||
className={cx(styles.itemWrapper, item.disabled && styles.itemDisabled, className)} |
||||
data-testid={selectors.components.VizLegend.seriesName(item.label)} |
||||
> |
||||
<VizLegendSeriesIcon |
||||
seriesName={item.fieldName ?? item.label} |
||||
color={item.color} |
||||
gradient={item.gradient} |
||||
readonly={readonly} |
||||
lineStyle={item.lineStyle} |
||||
/> |
||||
<button |
||||
disabled={readonly} |
||||
type="button" |
||||
onBlur={onMouseOut} |
||||
onFocus={onMouseOver} |
||||
onMouseOver={onMouseOver} |
||||
onMouseOut={onMouseOut} |
||||
onClick={onClick} |
||||
className={styles.label} |
||||
> |
||||
{item.label} |
||||
</button> |
||||
|
||||
{item.getDisplayValues && <VizLegendStatsList stats={item.getDisplayValues()} />} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
VizLegendListItem.displayName = 'VizLegendListItem'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
label: css({ |
||||
label: 'LegendLabel', |
||||
whiteSpace: 'nowrap', |
||||
background: 'none', |
||||
border: 'none', |
||||
fontSize: 'inherit', |
||||
padding: 0, |
||||
userSelect: 'text', |
||||
}), |
||||
itemDisabled: css({ |
||||
label: 'LegendLabelDisabled', |
||||
color: theme.colors.text.disabled, |
||||
}), |
||||
itemWrapper: css({ |
||||
label: 'LegendItemWrapper', |
||||
display: 'flex', |
||||
whiteSpace: 'nowrap', |
||||
alignItems: 'center', |
||||
flexGrow: 1, |
||||
}), |
||||
value: css({ |
||||
textAlign: 'right', |
||||
}), |
||||
yAxisLabel: css({ |
||||
color: theme.v1.palette.gray2, |
||||
}), |
||||
}); |
@ -0,0 +1,49 @@ |
||||
import { memo, useCallback } from 'react'; |
||||
|
||||
import { LineStyle } from '@grafana/schema'; |
||||
|
||||
import { SeriesColorPicker } from '../ColorPicker/ColorPicker'; |
||||
import { usePanelContext } from '../PanelChrome'; |
||||
|
||||
import { SeriesIcon } from './SeriesIcon'; |
||||
|
||||
interface Props { |
||||
seriesName: string; |
||||
color?: string; |
||||
gradient?: string; |
||||
readonly?: boolean; |
||||
lineStyle?: LineStyle; |
||||
} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const VizLegendSeriesIcon = memo(({ seriesName, color, gradient, readonly, lineStyle }: Props) => { |
||||
const { onSeriesColorChange } = usePanelContext(); |
||||
const onChange = useCallback( |
||||
(color: string) => { |
||||
return onSeriesColorChange!(seriesName, color); |
||||
}, |
||||
[seriesName, onSeriesColorChange] |
||||
); |
||||
|
||||
if (seriesName && onSeriesColorChange && color && !readonly) { |
||||
return ( |
||||
<SeriesColorPicker color={color} onChange={onChange} enableNamedColors> |
||||
{({ ref, showColorPicker, hideColorPicker }) => ( |
||||
<SeriesIcon |
||||
color={color} |
||||
className="pointer" |
||||
ref={ref} |
||||
onClick={showColorPicker} |
||||
onMouseLeave={hideColorPicker} |
||||
lineStyle={lineStyle} |
||||
/> |
||||
)} |
||||
</SeriesColorPicker> |
||||
); |
||||
} |
||||
return <SeriesIcon color={color} gradient={gradient} lineStyle={lineStyle} />; |
||||
}); |
||||
|
||||
VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon'; |
@ -0,0 +1,46 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { capitalize } from 'lodash'; |
||||
|
||||
import { DisplayValue, formattedValueToString } from '@grafana/data'; |
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext'; |
||||
import { InlineList } from '../List/InlineList'; |
||||
|
||||
interface Props { |
||||
stats: DisplayValue[]; |
||||
} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const VizLegendStatsList = ({ stats }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (stats.length === 0) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<InlineList |
||||
className={styles.list} |
||||
items={stats} |
||||
renderItem={(stat) => ( |
||||
<div className={styles.item} title={stat.description}> |
||||
{stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)} |
||||
</div> |
||||
)} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = () => ({ |
||||
list: css({ |
||||
flexGrow: 1, |
||||
textAlign: 'right', |
||||
}), |
||||
item: css({ |
||||
marginLeft: '8px', |
||||
}), |
||||
}); |
||||
|
||||
VizLegendStatsList.displayName = 'VizLegendStatsList'; |
@ -0,0 +1,145 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext'; |
||||
import { Icon } from '../Icon/Icon'; |
||||
|
||||
import { LegendTableItem } from './VizLegendTableItem'; |
||||
import { VizLegendItem, VizLegendTableProps } from './types'; |
||||
|
||||
const nameSortKey = 'Name'; |
||||
const naturalCompare = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare; |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const VizLegendTable = <T extends unknown>({ |
||||
items, |
||||
sortBy: sortKey, |
||||
sortDesc, |
||||
itemRenderer, |
||||
className, |
||||
onToggleSort, |
||||
onLabelClick, |
||||
onLabelMouseOver, |
||||
onLabelMouseOut, |
||||
readonly, |
||||
isSortable, |
||||
}: VizLegendTableProps<T>): JSX.Element => { |
||||
const styles = useStyles2(getStyles); |
||||
const header: Record<string, string> = {}; |
||||
|
||||
if (isSortable) { |
||||
header[nameSortKey] = ''; |
||||
} |
||||
|
||||
for (const item of items) { |
||||
if (item.getDisplayValues) { |
||||
for (const displayValue of item.getDisplayValues()) { |
||||
header[displayValue.title ?? '?'] = displayValue.description ?? ''; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (sortKey != null) { |
||||
let itemVals = new Map<VizLegendItem, number>(); |
||||
|
||||
items.forEach((item) => { |
||||
if (sortKey !== nameSortKey && item.getDisplayValues) { |
||||
const stat = item.getDisplayValues().find((stat) => stat.title === sortKey); |
||||
const val = stat == null || Number.isNaN(stat.numeric) ? -Infinity : stat.numeric; |
||||
itemVals.set(item, val); |
||||
} |
||||
}); |
||||
|
||||
let sortMult = sortDesc ? -1 : 1; |
||||
|
||||
if (sortKey === nameSortKey) { |
||||
// string sort
|
||||
items.sort((a, b) => { |
||||
return sortMult * naturalCompare(a.label, b.label); |
||||
}); |
||||
} else { |
||||
// numeric sort
|
||||
items.sort((a, b) => { |
||||
const aVal = itemVals.get(a) ?? 0; |
||||
const bVal = itemVals.get(b) ?? 0; |
||||
|
||||
return sortMult * (aVal - bVal); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
if (!itemRenderer) { |
||||
/* eslint-disable-next-line react/display-name */ |
||||
itemRenderer = (item, index) => ( |
||||
<LegendTableItem |
||||
key={`${item.label}-${index}`} |
||||
item={item} |
||||
onLabelClick={onLabelClick} |
||||
onLabelMouseOver={onLabelMouseOver} |
||||
onLabelMouseOut={onLabelMouseOut} |
||||
readonly={readonly} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<table className={cx(styles.table, className)}> |
||||
<thead> |
||||
<tr> |
||||
{!isSortable && <th></th>} |
||||
{Object.keys(header).map((columnTitle) => ( |
||||
<th |
||||
title={header[columnTitle]} |
||||
key={columnTitle} |
||||
className={cx(styles.header, onToggleSort && styles.headerSortable, isSortable && styles.nameHeader, { |
||||
[styles.withIcon]: sortKey === columnTitle, |
||||
})} |
||||
onClick={() => { |
||||
if (onToggleSort) { |
||||
onToggleSort(columnTitle); |
||||
} |
||||
}} |
||||
> |
||||
{columnTitle} |
||||
{sortKey === columnTitle && <Icon size="xs" name={sortDesc ? 'angle-down' : 'angle-up'} />} |
||||
</th> |
||||
))} |
||||
</tr> |
||||
</thead> |
||||
<tbody>{items.map(itemRenderer!)}</tbody> |
||||
</table> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
table: css({ |
||||
width: '100%', |
||||
'th:first-child': { |
||||
width: '100%', |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
}, |
||||
}), |
||||
header: css({ |
||||
color: theme.colors.primary.text, |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
padding: theme.spacing(0.25, 1, 0.25, 1), |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
textAlign: 'right', |
||||
whiteSpace: 'nowrap', |
||||
}), |
||||
nameHeader: css({ |
||||
textAlign: 'left', |
||||
paddingLeft: '30px', |
||||
}), |
||||
// This needs to be padding-right - icon size(xs==12) to avoid jumping
|
||||
withIcon: css({ |
||||
paddingRight: '4px', |
||||
}), |
||||
headerSortable: css({ |
||||
cursor: 'pointer', |
||||
}), |
||||
}); |
@ -0,0 +1,153 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { useCallback } from 'react'; |
||||
import * as React from 'react'; |
||||
|
||||
import { formattedValueToString, GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { styleMixins } from '../../themes'; |
||||
import { useStyles2 } from '../../themes/ThemeContext'; |
||||
|
||||
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; |
||||
import { VizLegendItem } from './types'; |
||||
|
||||
export interface Props { |
||||
key?: React.Key; |
||||
item: VizLegendItem; |
||||
className?: string; |
||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLButtonElement>) => void; |
||||
onLabelMouseOver?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
onLabelMouseOut?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
readonly?: boolean; |
||||
} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const LegendTableItem = ({ |
||||
item, |
||||
onLabelClick, |
||||
onLabelMouseOver, |
||||
onLabelMouseOut, |
||||
className, |
||||
readonly, |
||||
}: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const onMouseOver = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => { |
||||
if (onLabelMouseOver) { |
||||
onLabelMouseOver(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelMouseOver] |
||||
); |
||||
|
||||
const onMouseOut = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => { |
||||
if (onLabelMouseOut) { |
||||
onLabelMouseOut(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelMouseOut] |
||||
); |
||||
|
||||
const onClick = useCallback( |
||||
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { |
||||
if (onLabelClick) { |
||||
onLabelClick(item, event); |
||||
} |
||||
}, |
||||
[item, onLabelClick] |
||||
); |
||||
|
||||
return ( |
||||
<tr className={cx(styles.row, className)}> |
||||
<td> |
||||
<span className={styles.itemWrapper}> |
||||
<VizLegendSeriesIcon |
||||
color={item.color} |
||||
seriesName={item.fieldName ?? item.label} |
||||
readonly={readonly} |
||||
lineStyle={item.lineStyle} |
||||
/> |
||||
<button |
||||
disabled={readonly} |
||||
type="button" |
||||
title={item.label} |
||||
onBlur={onMouseOut} |
||||
onFocus={onMouseOver} |
||||
onMouseOver={onMouseOver} |
||||
onMouseOut={onMouseOut} |
||||
onClick={!readonly ? onClick : undefined} |
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)} |
||||
> |
||||
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>} |
||||
</button> |
||||
</span> |
||||
</td> |
||||
{item.getDisplayValues && |
||||
item.getDisplayValues().map((stat, index) => { |
||||
return ( |
||||
<td className={styles.value} key={`${stat.title}-${index}`}> |
||||
{formattedValueToString(stat)} |
||||
</td> |
||||
); |
||||
})} |
||||
</tr> |
||||
); |
||||
}; |
||||
|
||||
LegendTableItem.displayName = 'LegendTableItem'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
const rowHoverBg = styleMixins.hoverColor(theme.colors.background.primary, theme); |
||||
|
||||
return { |
||||
row: css({ |
||||
label: 'LegendRow', |
||||
fontSize: theme.v1.typography.size.sm, |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
td: { |
||||
padding: theme.spacing(0.25, 1), |
||||
whiteSpace: 'nowrap', |
||||
}, |
||||
|
||||
'&:hover': { |
||||
background: rowHoverBg, |
||||
}, |
||||
}), |
||||
label: css({ |
||||
label: 'LegendLabel', |
||||
whiteSpace: 'nowrap', |
||||
background: 'none', |
||||
border: 'none', |
||||
fontSize: 'inherit', |
||||
padding: 0, |
||||
maxWidth: '600px', |
||||
textOverflow: 'ellipsis', |
||||
overflow: 'hidden', |
||||
userSelect: 'text', |
||||
}), |
||||
labelDisabled: css({ |
||||
label: 'LegendLabelDisabled', |
||||
color: theme.colors.text.disabled, |
||||
}), |
||||
itemWrapper: css({ |
||||
display: 'flex', |
||||
whiteSpace: 'nowrap', |
||||
alignItems: 'center', |
||||
}), |
||||
value: css({ |
||||
textAlign: 'right', |
||||
}), |
||||
yAxisLabel: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,54 @@ |
||||
import * as React from 'react'; |
||||
|
||||
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data'; |
||||
import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema'; |
||||
|
||||
export enum SeriesVisibilityChangeBehavior { |
||||
Isolate, |
||||
Hide, |
||||
} |
||||
|
||||
export interface VizLegendBaseProps<T> { |
||||
placement: LegendPlacement; |
||||
className?: string; |
||||
items: Array<VizLegendItem<T>>; |
||||
thresholdItems?: Array<VizLegendItem<T>>; |
||||
seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior; |
||||
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLButtonElement>) => void; |
||||
itemRenderer?: (item: VizLegendItem<T>, index: number) => JSX.Element; |
||||
onLabelMouseOver?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
onLabelMouseOut?: ( |
||||
item: VizLegendItem, |
||||
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement> |
||||
) => void; |
||||
readonly?: boolean; |
||||
} |
||||
|
||||
export interface VizLegendTableProps<T> extends VizLegendBaseProps<T> { |
||||
sortBy?: string; |
||||
sortDesc?: boolean; |
||||
onToggleSort?: (sortBy: string) => void; |
||||
isSortable?: boolean; |
||||
} |
||||
|
||||
export interface LegendProps<T = any> extends VizLegendBaseProps<T>, VizLegendTableProps<T> { |
||||
displayMode: LegendDisplayMode; |
||||
} |
||||
|
||||
export interface VizLegendItem<T = any> { |
||||
getItemKey?: () => string; |
||||
label: string; |
||||
color?: string; |
||||
gradient?: string; |
||||
yAxis: number; |
||||
disabled?: boolean; |
||||
// displayValues?: DisplayValue[];
|
||||
getDisplayValues?: () => DisplayValue[]; |
||||
fieldIndex?: DataFrameFieldIndex; |
||||
fieldName?: string; |
||||
data?: T; |
||||
lineStyle?: LineStyle; |
||||
} |
@ -0,0 +1,8 @@ |
||||
import { SeriesVisibilityChangeMode } from '../PanelChrome/types'; |
||||
|
||||
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode { |
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) { |
||||
return SeriesVisibilityChangeMode.AppendToSelection; |
||||
} |
||||
return SeriesVisibilityChangeMode.ToggleSelection; |
||||
} |
|
Loading…
Reference in new issue