UNPKG

14.1 kBTypeScriptView Raw
1// *****************************************************************************
2// Copyright (C) 2022 TypeFox and others.
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License v. 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is available at
12// https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16
17import * as React from 'react';
18import * as ReactDOM from 'react-dom';
19import * as DOMPurify from 'dompurify';
20import { codicon } from './widget';
21import { measureTextHeight, measureTextWidth } from '../browser';
22
23import '../../../src/browser/style/select-component.css';
24
25export 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
36export 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
45export interface SelectComponentState {
46 dimensions?: DOMRect
47 selected: number
48 original: number
49 hover: number
50}
51
52export const SELECT_COMPONENT_CONTAINER = 'select-component-container';
53
54export 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; // Increase width by 10 due to side padding
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; // Just to be safe, add another 20 pixels here
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 // Workaround for perfect scrollbar, since using `overflow: hidden`
144 // neither triggers the `scroll`, `wheel` nor `blur` event
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 // Only attach our listeners once we render our dropdown menu
292 this.attachListeners();
293 // We can now also calculate the optimal width
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 // prefer rendering to the bottom unless there is not enough space and more content can be shown to the top
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) }} />; // eslint-disable-line react/no-danger
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}