mirror of https://github.com/grafana/grafana
CommandPalette: Render dashboard + nav actions as links (#62315)
* CommandPalette: Render links as links! * Update public/app/features/commandPalette/KBarResults.tsx Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> * Apply suggestions from code review Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> * fix ellipsis showing --------- Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>pull/62109/head
parent
7f5ed9f59d
commit
305209f4be
@ -0,0 +1,226 @@ |
||||
import { ActionImpl, getListboxItemId, KBAR_LISTBOX, useKBar } from 'kbar'; |
||||
import { usePointerMovedSinceMount } from 'kbar/lib/utils'; |
||||
import * as React from 'react'; |
||||
import { useVirtual } from 'react-virtual'; |
||||
|
||||
// From https://github.com/timc1/kbar/blob/main/src/KBarResults.tsx
|
||||
// TODO: Go back to KBarResults from kbar when https://github.com/timc1/kbar/issues/281 is fixed
|
||||
// Remember to remove dependency on react-virtual when removing this file
|
||||
|
||||
const START_INDEX = 0; |
||||
|
||||
interface RenderParams<T = ActionImpl | string> { |
||||
item: T; |
||||
active: boolean; |
||||
} |
||||
|
||||
interface KBarResultsProps { |
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
items: any[]; |
||||
onRender: (params: RenderParams) => React.ReactElement; |
||||
maxHeight?: number; |
||||
} |
||||
|
||||
export const KBarResults: React.FC<KBarResultsProps> = (props) => { |
||||
const activeRef = React.useRef<HTMLElement>(null); |
||||
const parentRef = React.useRef(null); |
||||
|
||||
// store a ref to all items so we do not have to pass
|
||||
// them as a dependency when setting up event listeners.
|
||||
const itemsRef = React.useRef(props.items); |
||||
itemsRef.current = props.items; |
||||
|
||||
const rowVirtualizer = useVirtual({ |
||||
size: itemsRef.current.length, |
||||
parentRef, |
||||
}); |
||||
|
||||
const { query, search, currentRootActionId, activeIndex, options } = useKBar((state) => ({ |
||||
search: state.searchQuery, |
||||
currentRootActionId: state.currentRootActionId, |
||||
activeIndex: state.activeIndex, |
||||
})); |
||||
|
||||
React.useEffect(() => { |
||||
const handler = (event: KeyboardEvent) => { |
||||
if (event.key === 'ArrowUp' || (event.ctrlKey && event.key === 'p')) { |
||||
event.preventDefault(); |
||||
query.setActiveIndex((index) => { |
||||
let nextIndex = index > START_INDEX ? index - 1 : index; |
||||
// avoid setting active index on a group
|
||||
if (typeof itemsRef.current[nextIndex] === 'string') { |
||||
if (nextIndex === 0) { |
||||
return index; |
||||
} |
||||
nextIndex -= 1; |
||||
} |
||||
return nextIndex; |
||||
}); |
||||
} else if (event.key === 'ArrowDown' || (event.ctrlKey && event.key === 'n')) { |
||||
event.preventDefault(); |
||||
query.setActiveIndex((index) => { |
||||
let nextIndex = index < itemsRef.current.length - 1 ? index + 1 : index; |
||||
// avoid setting active index on a group
|
||||
if (typeof itemsRef.current[nextIndex] === 'string') { |
||||
if (nextIndex === itemsRef.current.length - 1) { |
||||
return index; |
||||
} |
||||
nextIndex += 1; |
||||
} |
||||
return nextIndex; |
||||
}); |
||||
} else if (event.key === 'Enter') { |
||||
event.preventDefault(); |
||||
// storing the active dom element in a ref prevents us from
|
||||
// having to calculate the current action to perform based
|
||||
// on the `activeIndex`, which we would have needed to add
|
||||
// as part of the dependencies array.
|
||||
activeRef.current?.click(); |
||||
} |
||||
}; |
||||
window.addEventListener('keydown', handler); |
||||
return () => window.removeEventListener('keydown', handler); |
||||
}, [query]); |
||||
|
||||
// destructuring here to prevent linter warning to pass
|
||||
// entire rowVirtualizer in the dependencies array.
|
||||
const { scrollToIndex } = rowVirtualizer; |
||||
React.useEffect(() => { |
||||
scrollToIndex(activeIndex, { |
||||
// ensure that if the first item in the list is a group
|
||||
// name and we are focused on the second item, to not
|
||||
// scroll past that group, hiding it.
|
||||
align: activeIndex <= 1 ? 'end' : 'auto', |
||||
}); |
||||
}, [activeIndex, scrollToIndex]); |
||||
|
||||
React.useEffect(() => { |
||||
// TODO(tim): fix scenario where async actions load in
|
||||
// and active index is reset to the first item. i.e. when
|
||||
// users register actions and bust the `useRegisterActions`
|
||||
// cache, we won't want to reset their active index as they
|
||||
// are navigating the list.
|
||||
query.setActiveIndex( |
||||
// avoid setting active index on a group
|
||||
typeof props.items[START_INDEX] === 'string' ? START_INDEX + 1 : START_INDEX |
||||
); |
||||
}, [search, currentRootActionId, props.items, query]); |
||||
|
||||
const execute = React.useCallback( |
||||
(ev: React.MouseEvent, item: RenderParams['item']) => { |
||||
if (typeof item === 'string') { |
||||
return; |
||||
} |
||||
|
||||
// ActionImpl constructor copies all properties from action onto ActionImpl
|
||||
// so our url property is secretly there, but completely untyped
|
||||
// Preferably this change is upstreamed and ActionImpl has this
|
||||
// eslint-disable-next-line
|
||||
const url = (item as ActionImpl & { url?: string }).url; |
||||
|
||||
if (item.command) { |
||||
item.command.perform(item); |
||||
query.toggle(); |
||||
} else if (url) { |
||||
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) { |
||||
query.toggle(); |
||||
} |
||||
} else { |
||||
query.setSearch(''); |
||||
query.setCurrentRootAction(item.id); |
||||
} |
||||
|
||||
options.callbacks?.onSelectAction?.(item); |
||||
}, |
||||
[query, options] |
||||
); |
||||
|
||||
const pointerMoved = usePointerMovedSinceMount(); |
||||
|
||||
return ( |
||||
<div |
||||
ref={parentRef} |
||||
style={{ |
||||
maxHeight: props.maxHeight || 400, |
||||
position: 'relative', |
||||
overflow: 'auto', |
||||
}} |
||||
> |
||||
<div |
||||
role="listbox" |
||||
id={KBAR_LISTBOX} |
||||
style={{ |
||||
height: `${rowVirtualizer.totalSize}px`, |
||||
width: '100%', |
||||
}} |
||||
> |
||||
{rowVirtualizer.virtualItems.map((virtualRow) => { |
||||
const item = itemsRef.current[virtualRow.index]; |
||||
|
||||
// ActionImpl constructor copies all properties from action onto ActionImpl
|
||||
// so our url property is secretly there, but completely untyped
|
||||
// Preferably this change is upstreamed and ActionImpl has this
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const url = (item as ActionImpl & { url?: string }).url; |
||||
|
||||
const handlers = typeof item !== 'string' && { |
||||
onPointerMove: () => |
||||
pointerMoved && activeIndex !== virtualRow.index && query.setActiveIndex(virtualRow.index), |
||||
onPointerDown: () => query.setActiveIndex(virtualRow.index), |
||||
onClick: (ev: React.MouseEvent) => execute(ev, item), |
||||
}; |
||||
const active = virtualRow.index === activeIndex; |
||||
|
||||
const childProps = { |
||||
id: getListboxItemId(virtualRow.index), |
||||
role: 'option', |
||||
'aria-selected': active, |
||||
style: { |
||||
position: 'absolute', |
||||
top: 0, |
||||
left: 0, |
||||
width: '100%', |
||||
transform: `translateY(${virtualRow.start}px)`, |
||||
} as const, |
||||
...handlers, |
||||
}; |
||||
|
||||
const renderedItem = React.cloneElement( |
||||
props.onRender({ |
||||
item, |
||||
active, |
||||
}), |
||||
{ |
||||
ref: virtualRow.measureRef, |
||||
} |
||||
); |
||||
|
||||
if (url) { |
||||
return ( |
||||
<a |
||||
key={virtualRow.index} |
||||
href={url} |
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
ref={active ? (activeRef as React.RefObject<HTMLAnchorElement>) : null} |
||||
{...childProps} |
||||
> |
||||
{renderedItem} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
key={virtualRow.index} |
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
ref={active ? (activeRef as React.RefObject<HTMLDivElement>) : null} |
||||
{...childProps} |
||||
> |
||||
{renderedItem} |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
Loading…
Reference in new issue