@acusti/dropdown 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,584 @@
1
+ import InputText from '@acusti/input-text';
2
+ import { Style } from '@acusti/styling';
3
+ import useIsOutOfBounds from '@acusti/use-is-out-of-bounds';
4
+ import classnames from 'classnames';
5
+ import * as React from 'react';
6
+
7
+ import {
8
+ BODY_CLASS_NAME,
9
+ BODY_SELECTOR,
10
+ LABEL_CLASS_NAME,
11
+ LABEL_TEXT_CLASS_NAME,
12
+ ROOT_CLASS_NAME,
13
+ STYLES,
14
+ TRIGGER_CLASS_NAME,
15
+ } from './styles.js';
16
+ import {
17
+ getActiveItemElement,
18
+ getItemElements,
19
+ ITEM_SELECTOR,
20
+ KEY_EVENT_ELEMENTS,
21
+ setActiveItem,
22
+ } from './helpers.js';
23
+
24
+ export type Item = { element: HTMLElement | null; value: string };
25
+
26
+ export type Props = {
27
+ /** Boolean indicating if the user can submit an empty value (i.e. clear the value); defaults to true */
28
+ allowEmpty?: boolean;
29
+ /** Can take a single React element (e.g. ReactChild) or exactly two renderable children */
30
+ children: React.ReactChild | [React.ReactNode, React.ReactNode];
31
+ className?: string;
32
+ disabled?: boolean;
33
+ /** Group identifier string links dropdowns together into a menu (like macOS top menubar) */
34
+ group?: string;
35
+ hasItems?: boolean;
36
+ isOpenOnMount?: boolean;
37
+ isSearchable?: boolean;
38
+ label?: string;
39
+ /** Only usable in conjunction with {isSearchable: true}; used as search input’s name */
40
+ name?: string;
41
+ onSubmitItem?: (payload: Item) => void;
42
+ /** Only usable in conjunction with {isSearchable: true}; used as search input’s placeholder */
43
+ placeholder?: string;
44
+ /** Only usable in conjunction with {isSearchable: true}; used as search input’s tabIndex */
45
+ tabIndex?: number;
46
+ /**
47
+ * Used as search input’s value if props.isSearchable === true
48
+ * Used to determine if value has changed to avoid triggering onSubmitItem if not
49
+ */
50
+ value?: string;
51
+ };
52
+
53
+ type TimeoutID = ReturnType<typeof setTimeout>;
54
+
55
+ type MousePosition = { clientX: number; clientY: number };
56
+
57
+ const { Children, Fragment, useCallback, useLayoutEffect, useRef, useState } = React;
58
+
59
+ const noop = () => {};
60
+
61
+ const CHILDREN_ERROR =
62
+ '@acusti/dropdown requires either 1 child (the dropdown body) or 2 children: the dropdown trigger and the dropdown body.';
63
+
64
+ const Dropdown: React.FC<Props> = ({
65
+ allowEmpty = true,
66
+ children,
67
+ className,
68
+ disabled,
69
+ hasItems = true,
70
+ isOpenOnMount,
71
+ isSearchable,
72
+ label,
73
+ name,
74
+ onSubmitItem,
75
+ placeholder,
76
+ tabIndex,
77
+ value,
78
+ }) => {
79
+ const childrenCount = Children.count(children);
80
+ if (childrenCount !== 1 && childrenCount !== 2) {
81
+ if (childrenCount === 0) {
82
+ throw new Error(CHILDREN_ERROR + ' Received no children.');
83
+ }
84
+ console.error(`${CHILDREN_ERROR} Received ${childrenCount} children.`);
85
+ }
86
+
87
+ let trigger = childrenCount > 1 ? children[0] : null;
88
+ const isTriggerFromProps = React.isValidElement(trigger);
89
+ const [isOpen, setIsOpen] = useState<boolean>(isOpenOnMount || false);
90
+ const [isOpening, setIsOpening] = useState<boolean>(!isOpenOnMount);
91
+ const [dropdownBodyElement, setDropdownBodyElement] = useState<HTMLDivElement | null>(
92
+ null,
93
+ );
94
+
95
+ const dropdownElementRef = useRef<HTMLDivElement | null>(null);
96
+ const inputElementRef = useRef<HTMLInputElement | null>(null);
97
+ const closingTimerRef = useRef<TimeoutID | null>(null);
98
+ const isOpeningTimerRef = useRef<TimeoutID | null>(null);
99
+ const currentInputMethodRef = useRef<'mouse' | 'keyboard'>('mouse');
100
+ const clearEnteredCharactersTimerRef = useRef<TimeoutID | null>(null);
101
+ const enteredCharactersRef = useRef<string>('');
102
+ const mouseDownPositionRef = useRef<MousePosition | null>(null);
103
+ const outOfBounds = useIsOutOfBounds(dropdownBodyElement);
104
+
105
+ const allowEmptyRef = useRef(allowEmpty);
106
+ const hasItemsRef = useRef(hasItems);
107
+ const isOpenRef = useRef(isOpen);
108
+ const isOpeningRef = useRef(isOpening);
109
+ const isTriggerFromPropsRef = useRef(isOpening);
110
+ const onSubmitItemRef = useRef(onSubmitItem);
111
+ const valueRef = useRef(value);
112
+
113
+ useLayoutEffect(() => {
114
+ allowEmptyRef.current = allowEmpty;
115
+ hasItemsRef.current = hasItems;
116
+ isOpenRef.current = isOpen;
117
+ isOpeningRef.current = isOpening;
118
+ isTriggerFromPropsRef.current = isTriggerFromProps;
119
+ onSubmitItemRef.current = onSubmitItem;
120
+ valueRef.current = value;
121
+ }, [
122
+ allowEmpty,
123
+ hasItems,
124
+ isOpen,
125
+ isOpening,
126
+ isTriggerFromProps,
127
+ onSubmitItem,
128
+ value,
129
+ ]);
130
+
131
+ const closeDropdown = useCallback(() => {
132
+ setIsOpen(false);
133
+ setIsOpening(false);
134
+ mouseDownPositionRef.current = null;
135
+ if (closingTimerRef.current) {
136
+ clearTimeout(closingTimerRef.current);
137
+ closingTimerRef.current = null;
138
+ }
139
+ }, []);
140
+
141
+ const handleSubmitItem = useCallback(() => {
142
+ if (isOpenRef.current) {
143
+ // A short timeout before closing is better UX when user selects an item so dropdown
144
+ // doesn’t close before expected. It also enables using <Link />s in the dropdown body.
145
+ closingTimerRef.current = setTimeout(closeDropdown, 90);
146
+ }
147
+
148
+ if (!hasItemsRef.current) return;
149
+
150
+ const nextElement = getActiveItemElement(dropdownElementRef.current);
151
+ if (!nextElement) {
152
+ // If not allowEmpty, don’t allow submitting an empty item
153
+ if (!allowEmptyRef.current) return;
154
+ // If we have an input element as trigger & the user didn’t clear the text, do nothing
155
+ if (inputElementRef.current?.value) return;
156
+ }
157
+
158
+ const label = nextElement?.innerText || '';
159
+ const nextValue = nextElement?.dataset.uktValue || label;
160
+ const nextItem = { element: nextElement, value: nextValue };
161
+ if (inputElementRef.current) {
162
+ inputElementRef.current.value = label;
163
+ if (
164
+ inputElementRef.current ===
165
+ inputElementRef.current.ownerDocument.activeElement
166
+ ) {
167
+ inputElementRef.current.blur();
168
+ }
169
+ }
170
+
171
+ // If parent is controlling Dropdown via props.value and nextValue is the same, do nothing
172
+ if (valueRef.current && valueRef.current === nextValue) return;
173
+
174
+ if (onSubmitItemRef.current) {
175
+ onSubmitItemRef.current(nextItem);
176
+ }
177
+ }, [closeDropdown]);
178
+
179
+ const handleMouseMove = useCallback(
180
+ ({ clientX, clientY }: React.MouseEvent<HTMLElement>) => {
181
+ currentInputMethodRef.current = 'mouse';
182
+ const initialPosition = mouseDownPositionRef.current;
183
+ if (!initialPosition) return;
184
+ if (
185
+ Math.abs(initialPosition.clientX - clientX) < 12 &&
186
+ Math.abs(initialPosition.clientY - clientY) < 12
187
+ ) {
188
+ return;
189
+ }
190
+ setIsOpening(false);
191
+ },
192
+ [],
193
+ );
194
+
195
+ const handleMouseOver = useCallback((event: React.MouseEvent<HTMLElement>) => {
196
+ if (!hasItemsRef.current) return;
197
+
198
+ // If user isn’t currently using the mouse to navigate the dropdown, do nothing
199
+ if (currentInputMethodRef.current !== 'mouse') return;
200
+
201
+ // Ensure we have the dropdown root HTMLElement
202
+ const dropdownElement = dropdownElementRef.current;
203
+ if (!dropdownElement) return;
204
+
205
+ const itemElements = getItemElements(dropdownElement);
206
+ if (!itemElements) return;
207
+
208
+ const eventTarget = event.target as HTMLElement;
209
+ const item = eventTarget.closest(ITEM_SELECTOR) as HTMLElement | null;
210
+ const element = item || eventTarget;
211
+ for (let index = 0; index < itemElements.length; index++) {
212
+ if (itemElements[index] === element) {
213
+ setActiveItem({
214
+ dropdownElement,
215
+ element,
216
+ });
217
+ return;
218
+ }
219
+ }
220
+ }, []);
221
+
222
+ const cleanupEventListenersRef = useRef<() => void>(noop);
223
+
224
+ const handleRef = useCallback(
225
+ (ref: HTMLDivElement | null) => {
226
+ dropdownElementRef.current = ref;
227
+ if (!ref) {
228
+ // If component was unmounted, cleanup handlers
229
+ cleanupEventListenersRef.current();
230
+ cleanupEventListenersRef.current = noop;
231
+ return;
232
+ }
233
+
234
+ const { ownerDocument } = ref;
235
+ let inputElement = inputElementRef.current;
236
+ // Check if trigger from props is an input or textarea element
237
+ if (isTriggerFromProps && !inputElement && ref.firstElementChild) {
238
+ inputElement = ref.firstElementChild.querySelector(
239
+ 'input:not([type=radio]):not([type=checkbox]):not([type=range]),textarea',
240
+ );
241
+ inputElementRef.current = inputElement;
242
+ }
243
+
244
+ const handleMouseDown = ({ clientX, clientY, target }: MouseEvent) => {
245
+ const eventTarget = target as HTMLElement;
246
+ if (
247
+ dropdownElementRef.current &&
248
+ !dropdownElementRef.current.contains(eventTarget)
249
+ ) {
250
+ // Close dropdown on an outside click
251
+ closeDropdown();
252
+ return;
253
+ }
254
+
255
+ if (isOpenRef.current) return;
256
+
257
+ setIsOpen(true);
258
+ setIsOpening(true);
259
+ mouseDownPositionRef.current = { clientX, clientY };
260
+ isOpeningTimerRef.current = setTimeout(() => {
261
+ setIsOpening(false);
262
+ isOpeningTimerRef.current = null;
263
+ }, 1000);
264
+ };
265
+
266
+ const handleMouseUp = ({ target }: MouseEvent) => {
267
+ if (!isOpenRef.current || closingTimerRef.current) return;
268
+
269
+ // If still isOpening (gets set false 1s after open triggers), set it to false onMouseUp
270
+ if (isOpeningRef.current) {
271
+ setIsOpening(false);
272
+ if (isOpeningTimerRef.current) {
273
+ clearTimeout(isOpeningTimerRef.current);
274
+ isOpeningTimerRef.current = null;
275
+ }
276
+ return;
277
+ }
278
+
279
+ const isTargetInBody = (target as HTMLElement).closest(BODY_SELECTOR);
280
+
281
+ // If mouseup is on dropdown body and there are no items, don’t close the dropdown
282
+ if (!hasItemsRef.current && isTargetInBody) return;
283
+
284
+ // If mouseup is on an item, trigger submit item, else close the dropdown
285
+ if (isTargetInBody) {
286
+ handleSubmitItem();
287
+ } else if (
288
+ !inputElementRef.current ||
289
+ (dropdownElementRef.current &&
290
+ dropdownElementRef.current.contains(ownerDocument.activeElement))
291
+ ) {
292
+ // If dropdown is searchable and ref is still focused, this won’t be invoked
293
+ closeDropdown();
294
+ }
295
+ };
296
+
297
+ const handleKeyDown = (event: KeyboardEvent) => {
298
+ const { altKey, ctrlKey, key, metaKey } = event;
299
+ const eventTarget = event.target as HTMLElement;
300
+ const dropdownElement = dropdownElementRef.current;
301
+ if (!dropdownElement) return;
302
+
303
+ const onEventHandled = () => {
304
+ event.stopPropagation();
305
+ event.preventDefault();
306
+ currentInputMethodRef.current = 'keyboard';
307
+ };
308
+
309
+ const isEventTargetingDropdown = dropdownElement.contains(eventTarget);
310
+
311
+ if (!isOpenRef.current) {
312
+ // If dropdown is closed, don’t handle key events if event target isn’t within dropdown
313
+ if (!isEventTargetingDropdown) return;
314
+ // Open the dropdown on spacebar, enter, or if isSearchable and user hits the up/down arrows
315
+ if (
316
+ key === ' ' ||
317
+ key === 'Enter' ||
318
+ (hasItemsRef.current &&
319
+ (key === 'ArrowUp' || key === 'ArrowDown'))
320
+ ) {
321
+ onEventHandled();
322
+ setIsOpen(true);
323
+ return;
324
+ }
325
+
326
+ return;
327
+ }
328
+
329
+ // If dropdown isOpen, hasItems, and not isSearchable, handle entering characters
330
+ if (hasItemsRef.current && !inputElementRef.current) {
331
+ let isEditingCharacters =
332
+ !ctrlKey && !metaKey && /^[A-Za-z0-9]$/.test(key);
333
+ // User could also be editing characters if there are already characters entered
334
+ // and they are hitting delete or spacebar
335
+ if (!isEditingCharacters && enteredCharactersRef.current) {
336
+ isEditingCharacters = key === ' ' || key === 'Backspace';
337
+ }
338
+
339
+ if (isEditingCharacters) {
340
+ onEventHandled();
341
+ if (key === 'Backspace') {
342
+ enteredCharactersRef.current =
343
+ enteredCharactersRef.current.slice(0, -1);
344
+ } else {
345
+ enteredCharactersRef.current += key;
346
+ }
347
+
348
+ setActiveItem({
349
+ dropdownElement,
350
+ // If input element came from props, only override the input’s value
351
+ // with an exact text match so user can enter a value not in items
352
+ isExactMatch: isTriggerFromPropsRef.current,
353
+ text: enteredCharactersRef.current,
354
+ });
355
+
356
+ if (clearEnteredCharactersTimerRef.current) {
357
+ clearTimeout(clearEnteredCharactersTimerRef.current);
358
+ }
359
+
360
+ clearEnteredCharactersTimerRef.current = setTimeout(() => {
361
+ enteredCharactersRef.current = '';
362
+ clearEnteredCharactersTimerRef.current = null;
363
+ }, 1500);
364
+
365
+ return;
366
+ }
367
+ }
368
+ // If dropdown isOpen, handle submitting the value
369
+ if (key === 'Enter' || (key === ' ' && !inputElementRef.current)) {
370
+ onEventHandled();
371
+ handleSubmitItem();
372
+ return;
373
+ }
374
+ // If dropdown isOpen, handle closing it on escape or spacebar if !hasItems
375
+ if (
376
+ key === 'Escape' ||
377
+ (isEventTargetingDropdown && key === ' ' && !hasItemsRef.current)
378
+ ) {
379
+ // If there are no items & event target element uses key events, don’t close it
380
+ if (
381
+ !hasItemsRef.current &&
382
+ (eventTarget.isContentEditable ||
383
+ KEY_EVENT_ELEMENTS.has(eventTarget.tagName))
384
+ ) {
385
+ return;
386
+ }
387
+ closeDropdown();
388
+ return;
389
+ }
390
+ // Handle ↑/↓ arrows
391
+ if (hasItemsRef.current) {
392
+ if (key === 'ArrowUp') {
393
+ onEventHandled();
394
+ if (altKey || metaKey) {
395
+ setActiveItem({
396
+ dropdownElement,
397
+ index: 0,
398
+ });
399
+ } else {
400
+ setActiveItem({
401
+ dropdownElement,
402
+ indexAddend: -1,
403
+ });
404
+ }
405
+ return;
406
+ }
407
+ if (key === 'ArrowDown') {
408
+ onEventHandled();
409
+ if (altKey || metaKey) {
410
+ // Using a negative index counts back from the end
411
+ setActiveItem({
412
+ dropdownElement,
413
+ index: -1,
414
+ });
415
+ } else {
416
+ setActiveItem({
417
+ dropdownElement,
418
+ indexAddend: 1,
419
+ });
420
+ }
421
+ return;
422
+ }
423
+ }
424
+ };
425
+
426
+ // Close dropdown if any element is focused outside of this dropdown
427
+ const handleFocusIn = (event: Event) => {
428
+ if (!isOpenRef.current) return;
429
+
430
+ const eventTarget = event.target as HTMLElement;
431
+ // If focused element is a descendant or a parent of the dropdown, do nothing
432
+ if (
433
+ !dropdownElementRef.current ||
434
+ dropdownElementRef.current.contains(eventTarget) ||
435
+ eventTarget.contains(dropdownElementRef.current)
436
+ ) {
437
+ return;
438
+ }
439
+
440
+ closeDropdown();
441
+ };
442
+
443
+ document.addEventListener('focusin', handleFocusIn);
444
+ document.addEventListener('keydown', handleKeyDown);
445
+ document.addEventListener('mousedown', handleMouseDown);
446
+ document.addEventListener('mouseup', handleMouseUp);
447
+
448
+ if (ownerDocument !== document) {
449
+ ownerDocument.addEventListener('focusin', handleFocusIn);
450
+ ownerDocument.addEventListener('keydown', handleKeyDown);
451
+ ownerDocument.addEventListener('mousedown', handleMouseDown);
452
+ ownerDocument.addEventListener('mouseup', handleMouseUp);
453
+ }
454
+
455
+ // If dropdown should be open on mount, focus it
456
+ if (isOpenOnMount) {
457
+ ref.focus();
458
+ }
459
+
460
+ const handleInput = (event: Event) => {
461
+ const dropdownElement = dropdownElementRef.current;
462
+ if (!dropdownElement) return;
463
+
464
+ if (!isOpenRef.current) setIsOpen(true);
465
+
466
+ const input = event.target as HTMLInputElement;
467
+ const isDeleting =
468
+ enteredCharactersRef.current.length > input.value.length;
469
+ enteredCharactersRef.current = input.value;
470
+ // Don’t set a new active item if user is deleting text unless text is now empty
471
+ if (isDeleting && input.value.length) return;
472
+
473
+ setActiveItem({
474
+ dropdownElement,
475
+ // If input element came from props, only override the input’s value
476
+ // with an exact text match so user can enter a value not in items
477
+ isExactMatch: isTriggerFromPropsRef.current,
478
+ text: enteredCharactersRef.current,
479
+ });
480
+ };
481
+
482
+ if (inputElement) {
483
+ inputElement.addEventListener('input', handleInput);
484
+ }
485
+
486
+ cleanupEventListenersRef.current = () => {
487
+ document.removeEventListener('focusin', handleFocusIn);
488
+ document.removeEventListener('keydown', handleKeyDown);
489
+ document.removeEventListener('mousedown', handleMouseDown);
490
+ document.removeEventListener('mouseup', handleMouseUp);
491
+
492
+ if (ownerDocument !== document) {
493
+ ownerDocument.removeEventListener('focusin', handleFocusIn);
494
+ ownerDocument.removeEventListener('keydown', handleKeyDown);
495
+ ownerDocument.removeEventListener('mousedown', handleMouseDown);
496
+ ownerDocument.removeEventListener('mouseup', handleMouseUp);
497
+ }
498
+
499
+ if (inputElement) {
500
+ inputElement.removeEventListener('input', handleInput);
501
+ }
502
+ };
503
+ },
504
+ [closeDropdown, handleSubmitItem, isOpenOnMount, isTriggerFromProps],
505
+ );
506
+
507
+ const handleTriggerFocus = useCallback(() => {
508
+ setIsOpen(true);
509
+ }, []);
510
+
511
+ if (!isTriggerFromProps) {
512
+ if (isSearchable) {
513
+ trigger = (
514
+ <InputText
515
+ className={TRIGGER_CLASS_NAME}
516
+ disabled={disabled}
517
+ initialValue={value || ''}
518
+ name={name}
519
+ onFocus={handleTriggerFocus}
520
+ placeholder={placeholder}
521
+ ref={inputElementRef}
522
+ selectTextOnFocus
523
+ tabIndex={tabIndex}
524
+ type="text"
525
+ />
526
+ );
527
+ } else {
528
+ trigger = (
529
+ <button className={TRIGGER_CLASS_NAME} tabIndex={0}>
530
+ {trigger}
531
+ </button>
532
+ );
533
+ }
534
+ }
535
+
536
+ if (label) {
537
+ trigger = (
538
+ <label className={LABEL_CLASS_NAME}>
539
+ <div className={LABEL_TEXT_CLASS_NAME}>{label}</div>
540
+ {trigger}
541
+ </label>
542
+ );
543
+ }
544
+
545
+ return (
546
+ <Fragment>
547
+ <Style>{STYLES}</Style>
548
+ <div
549
+ className={classnames(ROOT_CLASS_NAME, className, {
550
+ disabled,
551
+ 'is-open': isOpen,
552
+ 'is-searchable': isSearchable,
553
+ })}
554
+ onMouseMove={handleMouseMove}
555
+ onMouseOver={handleMouseOver}
556
+ ref={handleRef}
557
+ tabIndex={
558
+ isSearchable || inputElementRef.current || !isTriggerFromProps
559
+ ? undefined
560
+ : 0
561
+ }
562
+ >
563
+ {trigger}
564
+ {isOpen ? (
565
+ <div
566
+ className={classnames(BODY_CLASS_NAME, {
567
+ 'calculating-position': !outOfBounds.hasLayout,
568
+ 'has-items': hasItems,
569
+ 'out-of-bounds-bottom': outOfBounds.bottom,
570
+ 'out-of-bounds-left': outOfBounds.left,
571
+ 'out-of-bounds-right': outOfBounds.right,
572
+ 'out-of-bounds-top': outOfBounds.top,
573
+ })}
574
+ ref={setDropdownBodyElement}
575
+ >
576
+ {children[1] || children[0] || children}
577
+ </div>
578
+ ) : null}
579
+ </div>
580
+ </Fragment>
581
+ );
582
+ };
583
+
584
+ export default Dropdown;