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 { 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 Highlighter from 'react-highlight-words'; |
||||
import React, { createRef } from 'react'; |
||||
// @ts-ignore
|
||||
import _ from 'lodash'; |
||||
import { FixedSizeList } from 'react-window'; |
||||
|
||||
import { Themeable, withTheme } from '@grafana/ui'; |
||||
|
||||
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) { |
||||
if (!el || !el.offsetParent) { |
||||
return; |
||||
} |
||||
const container = el.offsetParent as HTMLElement; |
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { |
||||
container.scrollTop = el.offsetTop - container.offsetTop; |
||||
} |
||||
interface Props extends Themeable { |
||||
groupedItems: CompletionItemGroup[]; |
||||
menuRef: any; |
||||
selectedItem: CompletionItem | null; |
||||
onClickItem: (suggestion: CompletionItem) => void; |
||||
prefix?: string; |
||||
typeaheadIndex: number; |
||||
} |
||||
|
||||
interface TypeaheadItemProps { |
||||
isSelected: boolean; |
||||
item: CompletionItem; |
||||
onClickItem: (Suggestion) => void; |
||||
prefix?: string; |
||||
interface State { |
||||
allItems: CompletionItem[]; |
||||
listWidth: number; |
||||
listHeight: number; |
||||
itemHeight: number; |
||||
} |
||||
|
||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> { |
||||
el: HTMLElement; |
||||
export class Typeahead extends React.PureComponent<Props, State> { |
||||
listRef: any = createRef(); |
||||
documentationRef: any = createRef(); |
||||
|
||||
componentDidUpdate(prevProps) { |
||||
if (this.props.isSelected && !prevProps.isSelected) { |
||||
requestAnimationFrame(() => { |
||||
scrollIntoView(this.el); |
||||
}); |
||||
} |
||||
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 }; |
||||
} |
||||
|
||||
getRef = el => { |
||||
this.el = el; |
||||
componentDidUpdate = (prevProps: Readonly<Props>) => { |
||||
if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) { |
||||
if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) { |
||||
this.listRef.current.scrollToItem(0); // special case for handling the first group label
|
||||
this.refreshDocumentation(); |
||||
return; |
||||
} |
||||
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); |
||||
this.listRef.current.scrollToItem(index); |
||||
this.refreshDocumentation(); |
||||
} |
||||
|
||||
if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) { |
||||
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()); |
||||
} |
||||
}; |
||||
|
||||
onClick = () => { |
||||
this.props.onClickItem(this.props.item); |
||||
refreshDocumentation = () => { |
||||
if (!this.documentationRef.current) { |
||||
return; |
||||
} |
||||
|
||||
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); |
||||
const item = this.state.allItems[index]; |
||||
|
||||
if (item) { |
||||
this.documentationRef.current.refresh(item); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { isSelected, item, prefix } = this.props; |
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; |
||||
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> |
||||
); |
||||
} |
||||
} |
||||
onMouseEnter = (item: CompletionItem) => { |
||||
this.documentationRef.current.refresh(item); |
||||
}; |
||||
|
||||
interface TypeaheadGroupProps { |
||||
items: CompletionItem[]; |
||||
label: string; |
||||
onClickItem: (suggestion: CompletionItem) => void; |
||||
selected: CompletionItem; |
||||
prefix?: string; |
||||
} |
||||
onMouseLeave = () => { |
||||
this.documentationRef.current.hide(); |
||||
}; |
||||
|
||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> { |
||||
render() { |
||||
const { items, label, selected, onClickItem, prefix } = this.props; |
||||
const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props; |
||||
const { listWidth, listHeight, itemHeight, allItems } = this.state; |
||||
|
||||
return ( |
||||
<li className="typeahead-group"> |
||||
<div className="typeahead-group__title">{label}</div> |
||||
<ul className="typeahead-group__list"> |
||||
{items.map(item => { |
||||
<ul className="typeahead" ref={menuRef}> |
||||
<TypeaheadInfo |
||||
ref={this.documentationRef} |
||||
width={listWidth} |
||||
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; |
||||
} |
||||
|
||||
return ( |
||||
<TypeaheadItem |
||||
key={item.label} |
||||
onClickItem={onClickItem} |
||||
isSelected={selected === item} |
||||
isSelected={selectedItem === item} |
||||
item={item} |
||||
prefix={prefix} |
||||
style={style} |
||||
onMouseEnter={this.onMouseEnter} |
||||
onMouseLeave={this.onMouseLeave} |
||||
/> |
||||
); |
||||
})} |
||||
</ul> |
||||
</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} /> |
||||
))} |
||||
}} |
||||
</FixedSizeList> |
||||
</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