1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | import * as React from 'react';
|
18 | import * as ReactDOM from 'react-dom';
|
19 | import * as DOMPurify from 'dompurify';
|
20 | import { codicon } from './widget';
|
21 | import { measureTextHeight, measureTextWidth } from '../browser';
|
22 |
|
23 | import '../../../src/browser/style/select-component.css';
|
24 |
|
25 | export interface SelectOption {
|
26 | value?: string
|
27 | label?: string
|
28 | separator?: boolean
|
29 | disabled?: boolean
|
30 | detail?: string
|
31 | description?: string
|
32 | markdown?: boolean
|
33 | userData?: string
|
34 | }
|
35 |
|
36 | export interface SelectComponentProps {
|
37 | options: readonly SelectOption[]
|
38 | defaultValue?: string | number
|
39 | onChange?: (option: SelectOption, index: number) => void,
|
40 | onBlur?: () => void,
|
41 | onFocus?: () => void,
|
42 | alignment?: 'left' | 'right';
|
43 | }
|
44 |
|
45 | export interface SelectComponentState {
|
46 | dimensions?: DOMRect
|
47 | selected: number
|
48 | original: number
|
49 | hover: number
|
50 | }
|
51 |
|
52 | export const SELECT_COMPONENT_CONTAINER = 'select-component-container';
|
53 |
|
54 | export class SelectComponent extends React.Component<SelectComponentProps, SelectComponentState> {
|
55 | protected dropdownElement: HTMLElement;
|
56 | protected fieldRef = React.createRef<HTMLDivElement>();
|
57 | protected dropdownRef = React.createRef<HTMLDivElement>();
|
58 | protected mountedListeners: Map<string, EventListenerOrEventListenerObject> = new Map();
|
59 | protected optimalWidth = 0;
|
60 | protected optimalHeight = 0;
|
61 |
|
62 | constructor(props: SelectComponentProps) {
|
63 | super(props);
|
64 | let selected = 0;
|
65 | if (typeof props.defaultValue === 'number') {
|
66 | selected = props.defaultValue;
|
67 | } else if (typeof props.defaultValue === 'string') {
|
68 | selected = Math.max(props.options.findIndex(e => e.value === props.defaultValue), 0);
|
69 | }
|
70 | this.state = {
|
71 | selected,
|
72 | original: selected,
|
73 | hover: selected
|
74 | };
|
75 |
|
76 | let list = document.getElementById(SELECT_COMPONENT_CONTAINER);
|
77 | if (!list) {
|
78 | list = document.createElement('div');
|
79 | list.id = SELECT_COMPONENT_CONTAINER;
|
80 | document.body.appendChild(list);
|
81 | }
|
82 | this.dropdownElement = list;
|
83 | }
|
84 |
|
85 | get options(): readonly SelectOption[] {
|
86 | return this.props.options;
|
87 | }
|
88 |
|
89 | get value(): string | number | undefined {
|
90 | return this.props.options[this.state.selected].value ?? this.state.selected;
|
91 | }
|
92 |
|
93 | set value(value: string | number | undefined) {
|
94 | let index = -1;
|
95 | if (typeof value === 'number') {
|
96 | index = value;
|
97 | } else if (typeof value === 'string') {
|
98 | index = this.props.options.findIndex(e => e.value === value);
|
99 | }
|
100 | if (index >= 0) {
|
101 | this.setState({
|
102 | selected: index,
|
103 | original: index,
|
104 | hover: index
|
105 | });
|
106 | }
|
107 | }
|
108 |
|
109 | protected get alignLeft(): boolean {
|
110 | return this.props.alignment !== 'right';
|
111 | }
|
112 |
|
113 | protected getOptimalWidth(): number {
|
114 | const textWidth = measureTextWidth(this.props.options.map(e => e.label || e.value || '' + (e.detail || '')));
|
115 | return Math.ceil(textWidth + 16);
|
116 | }
|
117 |
|
118 | protected getOptimalHeight(maxWidth?: number): number {
|
119 | const firstLine = this.props.options.find(e => e.label || e.value || e.detail);
|
120 | if (!firstLine) {
|
121 | return 0;
|
122 | }
|
123 | if (maxWidth) {
|
124 | maxWidth = Math.ceil(maxWidth) + 10;
|
125 | }
|
126 | const descriptionHeight = measureTextHeight(this.props.options.map(e => e.description || ''), { maxWidth: `${maxWidth}px` }) + 18;
|
127 | const singleLineHeight = measureTextHeight(firstLine.label || firstLine.value || firstLine.detail || '') + 6;
|
128 | const optimal = descriptionHeight + singleLineHeight * this.props.options.length;
|
129 | return optimal + 20;
|
130 | }
|
131 |
|
132 | protected attachListeners(): void {
|
133 | const hide = (event: MouseEvent) => {
|
134 | if (!this.dropdownRef.current?.contains(event.target as Node)) {
|
135 | this.hide();
|
136 | }
|
137 | };
|
138 | this.mountedListeners.set('scroll', hide);
|
139 | this.mountedListeners.set('wheel', hide);
|
140 |
|
141 | let parent = this.fieldRef.current?.parentElement;
|
142 | while (parent) {
|
143 |
|
144 |
|
145 | if (parent.classList.contains('ps')) {
|
146 | parent.addEventListener('ps-scroll-y', hide);
|
147 | }
|
148 | parent = parent.parentElement;
|
149 | }
|
150 |
|
151 | for (const [key, listener] of this.mountedListeners.entries()) {
|
152 | window.addEventListener(key, listener);
|
153 | }
|
154 | }
|
155 |
|
156 | override componentWillUnmount(): void {
|
157 | if (this.mountedListeners.size > 0) {
|
158 | const eventListener = this.mountedListeners.get('scroll')!;
|
159 | let parent = this.fieldRef.current?.parentElement;
|
160 | while (parent) {
|
161 | parent.removeEventListener('ps-scroll-y', eventListener);
|
162 | parent = parent.parentElement;
|
163 | }
|
164 | for (const [key, listener] of this.mountedListeners.entries()) {
|
165 | window.removeEventListener(key, listener);
|
166 | }
|
167 | }
|
168 | }
|
169 |
|
170 | override render(): React.ReactNode {
|
171 | const { options } = this.props;
|
172 | let { selected } = this.state;
|
173 | if (options[selected]?.separator) {
|
174 | selected = this.nextNotSeparator('forwards');
|
175 | }
|
176 | const selectedItemLabel = options[selected]?.label ?? options[selected]?.value;
|
177 | return <>
|
178 | <div
|
179 | key="select-component"
|
180 | ref={this.fieldRef}
|
181 | tabIndex={0}
|
182 | className="theia-select-component"
|
183 | onClick={e => this.handleClickEvent(e)}
|
184 | onBlur={
|
185 | () => {
|
186 | this.hide();
|
187 | this.props.onBlur?.();
|
188 | }
|
189 | }
|
190 | onFocus={() => this.props.onFocus?.()}
|
191 | onKeyDown={e => this.handleKeypress(e)}
|
192 | >
|
193 | <div key="label" className="theia-select-component-label">{selectedItemLabel}</div>
|
194 | <div key="icon" className={`theia-select-component-chevron ${codicon('chevron-down')}`} />
|
195 | </div>
|
196 | {ReactDOM.createPortal(this.renderDropdown(), this.dropdownElement)}
|
197 | </>;
|
198 | }
|
199 |
|
200 | protected nextNotSeparator(direction: 'forwards' | 'backwards'): number {
|
201 | const { options } = this.props;
|
202 | const step = direction === 'forwards' ? 1 : -1;
|
203 | const length = this.props.options.length;
|
204 | let selected = this.state.selected;
|
205 | let count = 0;
|
206 | do {
|
207 | selected = (selected + step) % length;
|
208 | if (selected < 0) {
|
209 | selected = length - 1;
|
210 | }
|
211 | count++;
|
212 | }
|
213 | while (options[selected]?.separator && count < length);
|
214 | return selected;
|
215 | }
|
216 |
|
217 | protected handleKeypress(ev: React.KeyboardEvent<HTMLDivElement>): void {
|
218 | if (!this.fieldRef.current) {
|
219 | return;
|
220 | }
|
221 | if (ev.key === 'ArrowUp') {
|
222 | const selected = this.nextNotSeparator('backwards');
|
223 | this.setState({
|
224 | selected,
|
225 | hover: selected
|
226 | });
|
227 | } else if (ev.key === 'ArrowDown') {
|
228 | if (this.state.dimensions) {
|
229 | const selected = this.nextNotSeparator('forwards');
|
230 | this.setState({
|
231 | selected,
|
232 | hover: selected
|
233 | });
|
234 | } else {
|
235 | this.toggleVisibility();
|
236 | this.setState({
|
237 | selected: 0,
|
238 | hover: 0,
|
239 | });
|
240 | }
|
241 | } else if (ev.key === 'Enter') {
|
242 | if (!this.state.dimensions) {
|
243 | this.toggleVisibility();
|
244 | } else {
|
245 | const selected = this.state.selected;
|
246 | this.selectOption(selected, this.props.options[selected]);
|
247 | }
|
248 | } else if (ev.key === 'Escape' || ev.key === 'Tab') {
|
249 | this.hide();
|
250 | }
|
251 | ev.stopPropagation();
|
252 | ev.nativeEvent.stopImmediatePropagation();
|
253 | }
|
254 |
|
255 | protected handleClickEvent(event: React.MouseEvent<HTMLElement>): void {
|
256 | this.toggleVisibility();
|
257 | event.stopPropagation();
|
258 | event.nativeEvent.stopImmediatePropagation();
|
259 | }
|
260 |
|
261 | protected toggleVisibility(): void {
|
262 | if (!this.fieldRef.current) {
|
263 | return;
|
264 | }
|
265 | if (!this.state.dimensions) {
|
266 | const rect = this.fieldRef.current.getBoundingClientRect();
|
267 | this.setState({ dimensions: rect });
|
268 | } else {
|
269 | this.hide();
|
270 | }
|
271 | }
|
272 |
|
273 | protected hide(index?: number): void {
|
274 | const selectedIndex = index === undefined ? this.state.original : index;
|
275 | this.setState({
|
276 | dimensions: undefined,
|
277 | selected: selectedIndex,
|
278 | original: selectedIndex,
|
279 | hover: selectedIndex
|
280 | });
|
281 | }
|
282 |
|
283 | protected renderDropdown(): React.ReactNode {
|
284 | if (!this.state.dimensions) {
|
285 | return;
|
286 | }
|
287 |
|
288 | const shellArea = document.getElementById('theia-app-shell')!.getBoundingClientRect();
|
289 | const maxWidth = this.alignLeft ? shellArea.width - this.state.dimensions.left : this.state.dimensions.right;
|
290 | if (this.mountedListeners.size === 0) {
|
291 |
|
292 | this.attachListeners();
|
293 |
|
294 | this.optimalWidth = this.getOptimalWidth();
|
295 | this.optimalHeight = this.getOptimalHeight(Math.max(this.state.dimensions.width, this.optimalWidth));
|
296 | }
|
297 | const availableTop = this.state.dimensions.top - shellArea.top;
|
298 | const availableBottom = shellArea.top + shellArea.height - this.state.dimensions.bottom;
|
299 |
|
300 | const invert = availableBottom < this.optimalHeight && (availableBottom - this.optimalHeight) < (availableTop - this.optimalHeight);
|
301 |
|
302 | const { options } = this.props;
|
303 | const { hover } = this.state;
|
304 | const description = options[hover].description;
|
305 | const markdown = options[hover].markdown;
|
306 | const items = options.map((item, i) => this.renderOption(i, item));
|
307 | if (description) {
|
308 | let descriptionNode: React.ReactNode | undefined;
|
309 | const className = 'theia-select-component-description';
|
310 | if (markdown) {
|
311 | descriptionNode = <div key="description" className={className}
|
312 | dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(description) }} />;
|
313 | } else {
|
314 | descriptionNode = <div key="description" className={className}>
|
315 | {description}
|
316 | </div>;
|
317 | }
|
318 | if (invert) {
|
319 | items.unshift(descriptionNode);
|
320 | } else {
|
321 | items.push(descriptionNode);
|
322 | }
|
323 | }
|
324 |
|
325 | return <div key="dropdown" className="theia-select-component-dropdown" style={{
|
326 | top: invert ? 'none' : this.state.dimensions.bottom,
|
327 | bottom: invert ? shellArea.top + shellArea.height - this.state.dimensions.top : 'none',
|
328 | left: this.alignLeft ? this.state.dimensions.left : 'none',
|
329 | right: this.alignLeft ? 'none' : shellArea.width - this.state.dimensions.right,
|
330 | width: Math.min(Math.max(this.state.dimensions.width, this.optimalWidth), maxWidth),
|
331 | maxHeight: shellArea.height - (invert ? shellArea.height - this.state.dimensions.bottom : this.state.dimensions.top) - this.state.dimensions.height,
|
332 | position: 'absolute'
|
333 | }} ref={this.dropdownRef}>
|
334 | {items}
|
335 | </div>;
|
336 | }
|
337 |
|
338 | protected renderOption(index: number, option: SelectOption): React.ReactNode {
|
339 | if (option.separator) {
|
340 | return <div key={index} className="theia-select-component-separator" />;
|
341 | }
|
342 | const selected = this.state.hover;
|
343 | return (
|
344 | <div
|
345 | key={index}
|
346 | className={`theia-select-component-option${index === selected ? ' selected' : ''}`}
|
347 | onMouseOver={() => {
|
348 | this.setState({
|
349 | hover: index
|
350 | });
|
351 | }}
|
352 | onMouseDown={() => {
|
353 | this.selectOption(index, option);
|
354 | }}
|
355 | >
|
356 | <div key="value" className="theia-select-component-option-value">{option.label ?? option.value}</div>
|
357 | {option.detail && <div key="detail" className="theia-select-component-option-detail">{option.detail}</div>}
|
358 | </div>
|
359 | );
|
360 | }
|
361 |
|
362 | protected selectOption(index: number, option: SelectOption): void {
|
363 | this.props.onChange?.(option, index);
|
364 | this.hide(index);
|
365 | }
|
366 | }
|