mirror of https://github.com/grafana/grafana
Feat: Suggestion list in Explore is virtualized (#16342)
* Wip: virtualize suggestions list * Refactor: Separate components to different files * Refactor: Made TypeaheadItem a FunctionComponent using emotion * Refactor: Use theme to calculate width instead of hardcoded values * Refactor: Calculate list height and item size * Style: Adds labels to emotion classes * Refactor: Flattens CompletionItems to one list * Chore: merge yarn.lock * Refactor: Adds documentation popup on the side * Refactor: Makes position of TypeaheadInfo dynamic * Refactor: Calculations moved to separate filepull/16471/head
parent
f0eddcd8a8
commit
ed7ad8f6ac
@ -1,4 +1,5 @@ |
|||||||
import { ThemeContext, withTheme } from './ThemeContext'; |
import { ThemeContext, withTheme } from './ThemeContext'; |
||||||
import { getTheme, mockTheme } from './getTheme'; |
import { getTheme, mockTheme } from './getTheme'; |
||||||
|
import { selectThemeVariant } from './selectThemeVariant'; |
||||||
|
|
||||||
export { ThemeContext, withTheme, mockTheme, getTheme }; |
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant }; |
||||||
|
@ -1,107 +1,132 @@ |
|||||||
import React from 'react'; |
import React, { createRef } from 'react'; |
||||||
import Highlighter from 'react-highlight-words'; |
// @ts-ignore
|
||||||
|
import _ from 'lodash'; |
||||||
|
import { FixedSizeList } from 'react-window'; |
||||||
|
|
||||||
|
import { Themeable, withTheme } from '@grafana/ui'; |
||||||
|
|
||||||
import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; |
import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; |
||||||
|
import { TypeaheadItem } from './TypeaheadItem'; |
||||||
|
import { TypeaheadInfo } from './TypeaheadInfo'; |
||||||
|
import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead'; |
||||||
|
|
||||||
function scrollIntoView(el: HTMLElement) { |
interface Props extends Themeable { |
||||||
if (!el || !el.offsetParent) { |
groupedItems: CompletionItemGroup[]; |
||||||
return; |
menuRef: any; |
||||||
|
selectedItem: CompletionItem | null; |
||||||
|
onClickItem: (suggestion: CompletionItem) => void; |
||||||
|
prefix?: string; |
||||||
|
typeaheadIndex: number; |
||||||
} |
} |
||||||
const container = el.offsetParent as HTMLElement; |
|
||||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { |
interface State { |
||||||
container.scrollTop = el.offsetTop - container.offsetTop; |
allItems: CompletionItem[]; |
||||||
|
listWidth: number; |
||||||
|
listHeight: number; |
||||||
|
itemHeight: number; |
||||||
} |
} |
||||||
|
|
||||||
|
export class Typeahead extends React.PureComponent<Props, State> { |
||||||
|
listRef: any = createRef(); |
||||||
|
documentationRef: any = createRef(); |
||||||
|
|
||||||
|
constructor(props: Props) { |
||||||
|
super(props); |
||||||
|
|
||||||
|
const allItems = flattenGroupItems(props.groupedItems); |
||||||
|
const longestLabel = calculateLongestLabel(allItems); |
||||||
|
const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel); |
||||||
|
this.state = { listWidth, listHeight, itemHeight, allItems }; |
||||||
} |
} |
||||||
|
|
||||||
interface TypeaheadItemProps { |
componentDidUpdate = (prevProps: Readonly<Props>) => { |
||||||
isSelected: boolean; |
if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) { |
||||||
item: CompletionItem; |
if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) { |
||||||
onClickItem: (Suggestion) => void; |
this.listRef.current.scrollToItem(0); // special case for handling the first group label
|
||||||
prefix?: string; |
this.refreshDocumentation(); |
||||||
|
return; |
||||||
|
} |
||||||
|
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); |
||||||
|
this.listRef.current.scrollToItem(index); |
||||||
|
this.refreshDocumentation(); |
||||||
} |
} |
||||||
|
|
||||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> { |
if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) { |
||||||
el: HTMLElement; |
const allItems = flattenGroupItems(this.props.groupedItems); |
||||||
|
const longestLabel = calculateLongestLabel(allItems); |
||||||
|
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel); |
||||||
|
this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation()); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
componentDidUpdate(prevProps) { |
refreshDocumentation = () => { |
||||||
if (this.props.isSelected && !prevProps.isSelected) { |
if (!this.documentationRef.current) { |
||||||
requestAnimationFrame(() => { |
return; |
||||||
scrollIntoView(this.el); |
|
||||||
}); |
|
||||||
} |
} |
||||||
|
|
||||||
|
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); |
||||||
|
const item = this.state.allItems[index]; |
||||||
|
|
||||||
|
if (item) { |
||||||
|
this.documentationRef.current.refresh(item); |
||||||
} |
} |
||||||
|
}; |
||||||
|
|
||||||
getRef = el => { |
onMouseEnter = (item: CompletionItem) => { |
||||||
this.el = el; |
this.documentationRef.current.refresh(item); |
||||||
}; |
}; |
||||||
|
|
||||||
onClick = () => { |
onMouseLeave = () => { |
||||||
this.props.onClickItem(this.props.item); |
this.documentationRef.current.hide(); |
||||||
}; |
}; |
||||||
|
|
||||||
render() { |
render() { |
||||||
const { isSelected, item, prefix } = this.props; |
const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props; |
||||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; |
const { listWidth, listHeight, itemHeight, allItems } = this.state; |
||||||
const label = item.label || ''; |
|
||||||
return ( |
|
||||||
<li ref={this.getRef} className={className} onClick={this.onClick}> |
|
||||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" /> |
|
||||||
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null} |
|
||||||
</li> |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interface TypeaheadGroupProps { |
return ( |
||||||
items: CompletionItem[]; |
<ul className="typeahead" ref={menuRef}> |
||||||
label: string; |
<TypeaheadInfo |
||||||
onClickItem: (suggestion: CompletionItem) => void; |
ref={this.documentationRef} |
||||||
selected: CompletionItem; |
width={listWidth} |
||||||
prefix?: string; |
height={listHeight} |
||||||
|
theme={theme} |
||||||
|
initialItem={selectedItem} |
||||||
|
/> |
||||||
|
<FixedSizeList |
||||||
|
ref={this.listRef} |
||||||
|
itemCount={allItems.length} |
||||||
|
itemSize={itemHeight} |
||||||
|
itemKey={index => { |
||||||
|
const item = allItems && allItems[index]; |
||||||
|
const key = item ? `${index}-${item.label}` : `${index}`; |
||||||
|
return key; |
||||||
|
}} |
||||||
|
width={listWidth} |
||||||
|
height={listHeight} |
||||||
|
> |
||||||
|
{({ index, style }) => { |
||||||
|
const item = allItems && allItems[index]; |
||||||
|
if (!item) { |
||||||
|
return null; |
||||||
} |
} |
||||||
|
|
||||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> { |
|
||||||
render() { |
|
||||||
const { items, label, selected, onClickItem, prefix } = this.props; |
|
||||||
return ( |
|
||||||
<li className="typeahead-group"> |
|
||||||
<div className="typeahead-group__title">{label}</div> |
|
||||||
<ul className="typeahead-group__list"> |
|
||||||
{items.map(item => { |
|
||||||
return ( |
return ( |
||||||
<TypeaheadItem |
<TypeaheadItem |
||||||
key={item.label} |
|
||||||
onClickItem={onClickItem} |
onClickItem={onClickItem} |
||||||
isSelected={selected === item} |
isSelected={selectedItem === item} |
||||||
item={item} |
item={item} |
||||||
prefix={prefix} |
prefix={prefix} |
||||||
|
style={style} |
||||||
|
onMouseEnter={this.onMouseEnter} |
||||||
|
onMouseLeave={this.onMouseLeave} |
||||||
/> |
/> |
||||||
); |
); |
||||||
})} |
}} |
||||||
</ul> |
</FixedSizeList> |
||||||
</li> |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interface TypeaheadProps { |
|
||||||
groupedItems: CompletionItemGroup[]; |
|
||||||
menuRef: any; |
|
||||||
selectedItem: CompletionItem | null; |
|
||||||
onClickItem: (Suggestion) => void; |
|
||||||
prefix?: string; |
|
||||||
} |
|
||||||
class Typeahead extends React.PureComponent<TypeaheadProps> { |
|
||||||
render() { |
|
||||||
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; |
|
||||||
return ( |
|
||||||
<ul className="typeahead" ref={menuRef}> |
|
||||||
{groupedItems.map(g => ( |
|
||||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} /> |
|
||||||
))} |
|
||||||
</ul> |
</ul> |
||||||
); |
); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
export default Typeahead; |
export const TypeaheadWithTheme = withTheme(Typeahead); |
||||||
|
@ -0,0 +1,90 @@ |
|||||||
|
import React, { PureComponent } from 'react'; |
||||||
|
import { Themeable, selectThemeVariant } from '@grafana/ui'; |
||||||
|
import { css, cx } from 'emotion'; |
||||||
|
|
||||||
|
import { CompletionItem } from 'app/types/explore'; |
||||||
|
|
||||||
|
interface Props extends Themeable { |
||||||
|
initialItem: CompletionItem; |
||||||
|
width: number; |
||||||
|
height: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface State { |
||||||
|
item: CompletionItem; |
||||||
|
} |
||||||
|
|
||||||
|
export class TypeaheadInfo extends PureComponent<Props, State> { |
||||||
|
constructor(props: Props) { |
||||||
|
super(props); |
||||||
|
this.state = { item: props.initialItem }; |
||||||
|
} |
||||||
|
|
||||||
|
getStyles = (visible: boolean) => { |
||||||
|
const { width, height, theme } = this.props; |
||||||
|
const selection = window.getSelection(); |
||||||
|
const node = selection.anchorNode; |
||||||
|
if (!node) { |
||||||
|
return {}; |
||||||
|
} |
||||||
|
|
||||||
|
// Read from DOM
|
||||||
|
const rect = node.parentElement.getBoundingClientRect(); |
||||||
|
const scrollX = window.scrollX; |
||||||
|
const scrollY = window.scrollY; |
||||||
|
const left = `${rect.left + scrollX + width + parseInt(theme.spacing.xs, 10)}px`; |
||||||
|
const top = `${rect.top + scrollY + rect.height + 6}px`; |
||||||
|
|
||||||
|
return { |
||||||
|
typeaheadItem: css` |
||||||
|
label: type-ahead-item; |
||||||
|
z-index: auto; |
||||||
|
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md}; |
||||||
|
border-radius: ${theme.border.radius.md}; |
||||||
|
border: ${selectThemeVariant( |
||||||
|
{ light: `solid 1px ${theme.colors.gray5}`, dark: `solid 1px ${theme.colors.dark1}` }, |
||||||
|
theme.type |
||||||
|
)}; |
||||||
|
overflow-y: scroll; |
||||||
|
overflow-x: hidden; |
||||||
|
outline: none; |
||||||
|
background: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)}; |
||||||
|
color: ${theme.colors.text}; |
||||||
|
box-shadow: ${selectThemeVariant( |
||||||
|
{ light: `0 5px 10px 0 ${theme.colors.gray5}`, dark: `0 5px 10px 0 ${theme.colors.black}` }, |
||||||
|
theme.type |
||||||
|
)}; |
||||||
|
visibility: ${visible === true ? 'visible' : 'hidden'}; |
||||||
|
left: ${left}; |
||||||
|
top: ${top}; |
||||||
|
width: 250px; |
||||||
|
height: ${height + parseInt(theme.spacing.xxs, 10)}px; |
||||||
|
position: fixed; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
refresh = (item: CompletionItem) => { |
||||||
|
this.setState({ item }); |
||||||
|
}; |
||||||
|
|
||||||
|
hide = () => { |
||||||
|
this.setState({ item: null }); |
||||||
|
}; |
||||||
|
|
||||||
|
render() { |
||||||
|
const { item } = this.state; |
||||||
|
const visible = item && !!item.documentation; |
||||||
|
const label = item ? item.label : ''; |
||||||
|
const documentation = item && item.documentation ? item.documentation : ''; |
||||||
|
const styles = this.getStyles(visible); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cx([styles.typeaheadItem])}> |
||||||
|
<b>{label}</b> |
||||||
|
<hr /> |
||||||
|
<span>{documentation}</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,87 @@ |
|||||||
|
import React, { FunctionComponent, useContext } from 'react'; |
||||||
|
// @ts-ignore
|
||||||
|
import Highlighter from 'react-highlight-words'; |
||||||
|
import { css, cx } from 'emotion'; |
||||||
|
import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { CompletionItem } from 'app/types/explore'; |
||||||
|
|
||||||
|
export const GROUP_TITLE_KIND = 'GroupTitle'; |
||||||
|
|
||||||
|
export const isGroupTitle = (item: CompletionItem) => { |
||||||
|
return item.kind && item.kind === GROUP_TITLE_KIND ? true : false; |
||||||
|
}; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
isSelected: boolean; |
||||||
|
item: CompletionItem; |
||||||
|
onClickItem: (suggestion: CompletionItem) => void; |
||||||
|
prefix?: string; |
||||||
|
style: any; |
||||||
|
onMouseEnter: (item: CompletionItem) => void; |
||||||
|
onMouseLeave: (item: CompletionItem) => void; |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme) => ({ |
||||||
|
typeaheadItem: css` |
||||||
|
label: type-ahead-item; |
||||||
|
height: auto; |
||||||
|
font-family: ${theme.typography.fontFamily.monospace}; |
||||||
|
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md}; |
||||||
|
font-size: ${theme.typography.size.sm}; |
||||||
|
text-overflow: ellipsis; |
||||||
|
overflow: hidden; |
||||||
|
z-index: 1; |
||||||
|
display: block; |
||||||
|
white-space: nowrap; |
||||||
|
cursor: pointer; |
||||||
|
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), |
||||||
|
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1); |
||||||
|
`,
|
||||||
|
typeaheadItemSelected: css` |
||||||
|
label: type-ahead-item-selected; |
||||||
|
background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)}; |
||||||
|
`,
|
||||||
|
typeaheadItemMatch: css` |
||||||
|
label: type-ahead-item-match; |
||||||
|
color: ${theme.colors.yellow}; |
||||||
|
border-bottom: 1px solid ${theme.colors.yellow}; |
||||||
|
padding: inherit; |
||||||
|
background: inherit; |
||||||
|
`,
|
||||||
|
typeaheadItemGroupTitle: css` |
||||||
|
label: type-ahead-item-group-title; |
||||||
|
color: ${theme.colors.textWeak}; |
||||||
|
font-size: ${theme.typography.size.sm}; |
||||||
|
line-height: ${theme.typography.lineHeight.lg}; |
||||||
|
padding: ${theme.spacing.sm}; |
||||||
|
`,
|
||||||
|
}); |
||||||
|
|
||||||
|
export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => { |
||||||
|
const theme = useContext(ThemeContext); |
||||||
|
const styles = getStyles(theme); |
||||||
|
|
||||||
|
const { isSelected, item, prefix, style, onClickItem } = props; |
||||||
|
const onClick = () => onClickItem(item); |
||||||
|
const onMouseEnter = () => props.onMouseEnter(item); |
||||||
|
const onMouseLeave = () => props.onMouseLeave(item); |
||||||
|
const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]); |
||||||
|
const highlightClassName = cx([styles.typeaheadItemMatch]); |
||||||
|
const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]); |
||||||
|
const label = item.label || ''; |
||||||
|
|
||||||
|
if (isGroupTitle(item)) { |
||||||
|
return ( |
||||||
|
<li className={itemGroupTitleClassName} style={style}> |
||||||
|
<span>{label}</span> |
||||||
|
</li> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<li className={className} onClick={onClick} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> |
||||||
|
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} /> |
||||||
|
</li> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,64 @@ |
|||||||
|
import { GrafanaTheme } from '@grafana/ui'; |
||||||
|
import { default as calculateSize } from 'calculate-size'; |
||||||
|
|
||||||
|
import { CompletionItemGroup, CompletionItem } from 'app/types'; |
||||||
|
import { GROUP_TITLE_KIND } from '../TypeaheadItem'; |
||||||
|
|
||||||
|
export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => { |
||||||
|
return groupedItems.reduce((all, current) => { |
||||||
|
const titleItem: CompletionItem = { |
||||||
|
label: current.label, |
||||||
|
kind: GROUP_TITLE_KIND, |
||||||
|
}; |
||||||
|
return all.concat(titleItem, current.items); |
||||||
|
}, []); |
||||||
|
}; |
||||||
|
|
||||||
|
export const calculateLongestLabel = (allItems: CompletionItem[]): string => { |
||||||
|
return allItems.reduce((longest, current) => { |
||||||
|
return longest.length < current.label.length ? current.label : longest; |
||||||
|
}, ''); |
||||||
|
}; |
||||||
|
|
||||||
|
export const calculateListSizes = (theme: GrafanaTheme, allItems: CompletionItem[], longestLabel: string) => { |
||||||
|
const size = calculateSize(longestLabel, { |
||||||
|
font: theme.typography.fontFamily.monospace, |
||||||
|
fontSize: theme.typography.size.sm, |
||||||
|
fontWeight: 'normal', |
||||||
|
}); |
||||||
|
|
||||||
|
const listWidth = calculateListWidth(size.width, theme); |
||||||
|
const itemHeight = calculateItemHeight(size.height, theme); |
||||||
|
const listHeight = calculateListHeight(itemHeight, allItems); |
||||||
|
|
||||||
|
return { |
||||||
|
listWidth, |
||||||
|
listHeight, |
||||||
|
itemHeight, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
export const calculateItemHeight = (longestLabelHeight: number, theme: GrafanaTheme) => { |
||||||
|
const horizontalPadding = parseInt(theme.spacing.sm, 10) * 2; |
||||||
|
const itemHeight = longestLabelHeight + horizontalPadding; |
||||||
|
|
||||||
|
return itemHeight; |
||||||
|
}; |
||||||
|
|
||||||
|
export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaTheme) => { |
||||||
|
const verticalPadding = parseInt(theme.spacing.sm, 10) + parseInt(theme.spacing.md, 10); |
||||||
|
const maxWidth = 800; |
||||||
|
const listWidth = Math.min(Math.max(longestLabelWidth + verticalPadding, 200), maxWidth); |
||||||
|
|
||||||
|
return listWidth; |
||||||
|
}; |
||||||
|
|
||||||
|
export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => { |
||||||
|
const numberOfItemsToShow = Math.min(allItems.length, 10); |
||||||
|
const minHeight = 100; |
||||||
|
const itemsInView = allItems.slice(0, numberOfItemsToShow); |
||||||
|
const totalHeight = itemsInView.length * itemHeight; |
||||||
|
const listHeight = Math.max(totalHeight, minHeight); |
||||||
|
|
||||||
|
return listHeight; |
||||||
|
}; |
Loading…
Reference in new issue