|
1 | | -import {DataLoader, DropDown} from 'argo-ui'; |
| 1 | +import {DataLoader} from 'argo-ui'; |
| 2 | +import {Key, KeybindingContext} from 'argo-ui/v2'; |
| 3 | +import classNames from 'classnames'; |
2 | 4 | import * as React from 'react'; |
| 5 | +import * as ReactDOM from 'react-dom'; |
3 | 6 |
|
4 | 7 | import {Context} from '../../../shared/context'; |
| 8 | +import * as models from '../../../shared/models'; |
5 | 9 | import {services} from '../../../shared/services'; |
| 10 | +import {getTheme} from '../../../shared/utils'; |
6 | 11 | import {getAppUrl} from '../utils'; |
7 | 12 |
|
8 | 13 | export const ApplicationsDetailsAppDropdown = (props: {appName: string; objectListKind: string}) => { |
9 | 14 | const [opened, setOpened] = React.useState(false); |
10 | | - const [appFilter, setAppFilter] = React.useState(''); |
| 15 | + const [filter, setFilter] = React.useState(''); |
| 16 | + const [highlight, setHighlight] = React.useState(0); |
11 | 17 | const ctx = React.useContext(Context); |
| 18 | + const {useKeybinding} = React.useContext(KeybindingContext); |
| 19 | + const containerRef = React.useRef<HTMLDivElement>(null); |
| 20 | + const panelRef = React.useRef<HTMLDivElement>(null); |
| 21 | + const inputRef = React.useRef<HTMLInputElement>(null); |
| 22 | + const listRef = React.useRef<HTMLUListElement>(null); |
| 23 | + // Viewport coordinates for the portal-rendered panel; recomputed from the anchor's |
| 24 | + // bounding rect whenever the dropdown opens or the page scrolls/resizes. |
| 25 | + const [panelPos, setPanelPos] = React.useState<{top: number; left: number}>({top: 0, left: 0}); |
| 26 | + // Latest filtered result, refreshed during render of the DataLoader child. Read by the |
| 27 | + // input's onKeyDown closure for Enter / Arrow navigation without re-computing the filter. |
| 28 | + const filteredRef = React.useRef<models.AbstractApplication[]>([]); |
| 29 | + |
| 30 | + const openDropdown = () => { |
| 31 | + setFilter(''); |
| 32 | + setHighlight(0); |
| 33 | + setOpened(true); |
| 34 | + }; |
| 35 | + |
| 36 | + React.useEffect(() => { |
| 37 | + if (opened && inputRef.current) { |
| 38 | + inputRef.current.focus(); |
| 39 | + } |
| 40 | + }, [opened]); |
| 41 | + |
| 42 | + // Keep the keyboard-highlighted row visible when navigating past the scroll boundary. |
| 43 | + React.useEffect(() => { |
| 44 | + const active = listRef.current?.querySelector<HTMLLIElement>('.application-details-app-dropdown__item--active'); |
| 45 | + active?.scrollIntoView({block: 'nearest'}); |
| 46 | + }, [highlight, filter]); |
| 47 | + |
| 48 | + // Recompute panel position from the anchor's bounding rect. The panel is portal-rendered |
| 49 | + // into document.body to escape any ancestor `overflow: hidden`, so it needs absolute |
| 50 | + // viewport coordinates. |
| 51 | + const updatePanelPos = React.useCallback(() => { |
| 52 | + if (!containerRef.current) { |
| 53 | + return; |
| 54 | + } |
| 55 | + const rect = containerRef.current.getBoundingClientRect(); |
| 56 | + setPanelPos({top: rect.bottom + 4, left: rect.left}); |
| 57 | + }, []); |
| 58 | + |
| 59 | + // The portal panel sits at document.body, outside Layout's `.theme-*` wrapper, so |
| 60 | + // themify()'s descendant selectors don't match — re-apply the class on the portal root. |
| 61 | + const [theme, setTheme] = React.useState<string>(''); |
| 62 | + React.useEffect(() => { |
| 63 | + const sub = services.viewPreferences.getPreferences().subscribe(p => setTheme(p.theme)); |
| 64 | + return () => sub.unsubscribe(); |
| 65 | + }, []); |
| 66 | + const themeClass = theme ? `theme-${getTheme(theme)}` : ''; |
| 67 | + |
| 68 | + React.useLayoutEffect(() => { |
| 69 | + if (!opened) { |
| 70 | + return; |
| 71 | + } |
| 72 | + updatePanelPos(); |
| 73 | + // Coalesce scroll/resize bursts into one update per frame to avoid |
| 74 | + // re-rendering the whole details view on every pixel of scroll. |
| 75 | + let raf = 0; |
| 76 | + const onScroll = () => { |
| 77 | + if (raf) { |
| 78 | + return; |
| 79 | + } |
| 80 | + raf = requestAnimationFrame(() => { |
| 81 | + raf = 0; |
| 82 | + updatePanelPos(); |
| 83 | + }); |
| 84 | + }; |
| 85 | + window.addEventListener('scroll', onScroll, true); |
| 86 | + window.addEventListener('resize', onScroll); |
| 87 | + return () => { |
| 88 | + if (raf) { |
| 89 | + cancelAnimationFrame(raf); |
| 90 | + } |
| 91 | + window.removeEventListener('scroll', onScroll, true); |
| 92 | + window.removeEventListener('resize', onScroll); |
| 93 | + }; |
| 94 | + }, [opened, updatePanelPos]); |
| 95 | + |
| 96 | + React.useEffect(() => { |
| 97 | + if (!opened) { |
| 98 | + return; |
| 99 | + } |
| 100 | + const handler = (e: MouseEvent) => { |
| 101 | + const target = e.target as Node; |
| 102 | + const insideAnchor = containerRef.current?.contains(target); |
| 103 | + const insidePanel = panelRef.current?.contains(target); |
| 104 | + if (!insideAnchor && !insidePanel) { |
| 105 | + setOpened(false); |
| 106 | + } |
| 107 | + }; |
| 108 | + document.addEventListener('mousedown', handler); |
| 109 | + return () => document.removeEventListener('mousedown', handler); |
| 110 | + }, [opened]); |
| 111 | + |
| 112 | + useKeybinding({ |
| 113 | + keys: Key.SLASH, |
| 114 | + action: () => { |
| 115 | + if (!opened) { |
| 116 | + openDropdown(); |
| 117 | + return true; |
| 118 | + } |
| 119 | + return false; |
| 120 | + } |
| 121 | + }); |
| 122 | + |
| 123 | + useKeybinding({ |
| 124 | + keys: Key.ESCAPE, |
| 125 | + action: () => { |
| 126 | + if (opened) { |
| 127 | + setOpened(false); |
| 128 | + return true; |
| 129 | + } |
| 130 | + return false; |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + const renderItems = (apps: models.AbstractApplication[]) => { |
| 135 | + const filtered = apps.filter(app => filter.length === 0 || app.metadata.name.toLowerCase().includes(filter.toLowerCase())).slice(0, 100); // take top 100 results after filtering to avoid performance issues |
| 136 | + filteredRef.current = filtered; |
| 137 | + const activeIndex = Math.min(highlight, Math.max(0, filtered.length - 1)); |
| 138 | + if (filtered.length === 0) { |
| 139 | + return <li className='application-details-app-dropdown__empty'>No matches</li>; |
| 140 | + } |
| 141 | + return filtered.map((app, idx) => ( |
| 142 | + <li |
| 143 | + key={`${app.metadata.namespace}/${app.metadata.name}`} |
| 144 | + className={classNames('application-details-app-dropdown__item', { |
| 145 | + 'application-details-app-dropdown__item--active': idx === activeIndex |
| 146 | + })} |
| 147 | + onMouseEnter={() => setHighlight(idx)} |
| 148 | + onClick={() => { |
| 149 | + ctx.navigation.goto(`/${getAppUrl(app)}`); |
| 150 | + setOpened(false); |
| 151 | + }}> |
| 152 | + {app.metadata.name} |
| 153 | + {app.metadata.name === props.appName && ' (current)'} |
| 154 | + </li> |
| 155 | + )); |
| 156 | + }; |
| 157 | + |
12 | 158 | return ( |
13 | | - <DropDown |
14 | | - onOpenStateChange={setOpened} |
15 | | - isMenu={true} |
16 | | - anchor={() => ( |
17 | | - <> |
18 | | - <i className='fa fa-search' /> <span>{props.appName}</span> |
19 | | - </> |
20 | | - )}> |
21 | | - {opened && ( |
22 | | - <ul> |
23 | | - <li> |
24 | | - <input |
25 | | - className='argo-field' |
26 | | - value={appFilter} |
27 | | - onChange={e => setAppFilter(e.target.value)} |
28 | | - ref={el => |
29 | | - el && |
30 | | - setTimeout(() => { |
31 | | - if (el) { |
32 | | - el.focus(); |
33 | | - } |
34 | | - }, 100) |
35 | | - } |
36 | | - /> |
37 | | - </li> |
38 | | - <DataLoader load={() => services.applications.list([], props.objectListKind, {fields: ['items.metadata.name', 'items.metadata.namespace']})}> |
39 | | - {apps => |
40 | | - apps.items |
41 | | - .filter(app => { |
42 | | - return appFilter.length === 0 || app.metadata.name.toLowerCase().includes(appFilter.toLowerCase()); |
43 | | - }) |
44 | | - .slice(0, 100) // take top 100 results after filtering to avoid performance issues |
45 | | - .map(app => ( |
46 | | - <li key={app.metadata.name} onClick={() => ctx.navigation.goto(`/${getAppUrl(app)}`)}> |
47 | | - {app.metadata.name} {app.metadata.name === props.appName && ' (current)'} |
48 | | - </li> |
49 | | - )) |
50 | | - } |
51 | | - </DataLoader> |
52 | | - </ul> |
53 | | - )} |
54 | | - </DropDown> |
| 159 | + <div className='application-details-app-dropdown' ref={containerRef}> |
| 160 | + <div className='application-details-app-dropdown__anchor' onClick={() => (opened ? setOpened(false) : openDropdown())}> |
| 161 | + <i className='fa fa-search' /> <span>{props.appName}</span> |
| 162 | + </div> |
| 163 | + {opened && |
| 164 | + ReactDOM.createPortal( |
| 165 | + <div className={themeClass}> |
| 166 | + <div className='application-details-app-dropdown__panel' ref={panelRef} style={{top: panelPos.top, left: panelPos.left}}> |
| 167 | + <div className='application-details-app-dropdown__search'> |
| 168 | + <input |
| 169 | + ref={inputRef} |
| 170 | + className='argo-field' |
| 171 | + value={filter} |
| 172 | + placeholder='Filter applications...' |
| 173 | + onChange={e => { |
| 174 | + setFilter(e.target.value); |
| 175 | + setHighlight(0); |
| 176 | + }} |
| 177 | + onKeyDown={e => { |
| 178 | + const filtered = filteredRef.current; |
| 179 | + if (e.key === 'ArrowDown') { |
| 180 | + e.preventDefault(); |
| 181 | + setHighlight(h => Math.min(h + 1, Math.max(0, filtered.length - 1))); |
| 182 | + } else if (e.key === 'ArrowUp') { |
| 183 | + e.preventDefault(); |
| 184 | + setHighlight(h => Math.max(h - 1, 0)); |
| 185 | + } else if (e.key === 'Enter' && filtered.length > 0) { |
| 186 | + e.preventDefault(); |
| 187 | + const activeIndex = Math.min(highlight, filtered.length - 1); |
| 188 | + ctx.navigation.goto(`/${getAppUrl(filtered[activeIndex])}`); |
| 189 | + setOpened(false); |
| 190 | + } else if (e.key === 'Escape') { |
| 191 | + e.preventDefault(); |
| 192 | + setOpened(false); |
| 193 | + } |
| 194 | + }} |
| 195 | + /> |
| 196 | + </div> |
| 197 | + <ul className='application-details-app-dropdown__list' ref={listRef}> |
| 198 | + <DataLoader |
| 199 | + load={() => services.applications.list([], props.objectListKind, {fields: ['items.metadata.name', 'items.metadata.namespace']})} |
| 200 | + loadingRenderer={() => <li className='application-details-app-dropdown__empty'>Loading...</li>}> |
| 201 | + {apps => renderItems(apps.items)} |
| 202 | + </DataLoader> |
| 203 | + </ul> |
| 204 | + </div> |
| 205 | + </div>, |
| 206 | + document.body |
| 207 | + )} |
| 208 | + </div> |
55 | 209 | ); |
56 | 210 | }; |
0 commit comments