@octanejs/select 0.1.1

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,1621 @@
1
+ import {
2
+ createElement,
3
+ Fragment,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'octane';
11
+ import type { OctaneNode } from 'octane';
12
+ import type { Octane } from 'octane/jsx-runtime';
13
+
14
+ import {
15
+ formatGroupLabel as formatGroupLabelBuiltin,
16
+ getOptionLabel as getOptionLabelBuiltin,
17
+ getOptionValue as getOptionValueBuiltin,
18
+ isOptionDisabled as isOptionDisabledBuiltin,
19
+ } from './builtins';
20
+ import {
21
+ components as builtinComponents,
22
+ defaultComponents,
23
+ type SelectComponents,
24
+ type SelectComponentsConfig,
25
+ type Ref,
26
+ } from './components.tsrx';
27
+ import { createFilter, type FilterOptionOption } from './filters';
28
+ import { defaultStyles } from './default-styles';
29
+ import { getMenuPlacement } from './menu-placement';
30
+ import { useScrollManager } from './scroll-manager';
31
+ import { useStyleResolver } from './style-context.tsrx';
32
+ import type { AriaLiveMessages, AriaSelection } from './accessibility-types';
33
+ import type { ClassNamesConfig, StyleKey, StylesConfig } from './styles';
34
+ import { defaultTheme, type ThemeConfig } from './theme';
35
+ import type {
36
+ ActionMeta,
37
+ CSSObjectWithLabel,
38
+ FocusDirection,
39
+ GetOptionLabel,
40
+ GetOptionValue,
41
+ GroupBase,
42
+ InputActionMeta,
43
+ OnChangeValue,
44
+ Options,
45
+ OptionsOrGroups,
46
+ PropsValue,
47
+ SetValueAction,
48
+ Theme,
49
+ } from './types';
50
+ import { classNames, cleanValue, scrollOptionIntoView, valueTernary } from './utils';
51
+ import { needsInspectableValueChildren } from './animated-registry.ts';
52
+
53
+ export type FormatOptionLabelContext = 'menu' | 'value';
54
+ export interface FormatOptionLabelMeta<Option> {
55
+ context: FormatOptionLabelContext;
56
+ inputValue: string;
57
+ selectValue: Options<Option>;
58
+ }
59
+
60
+ export interface SelectProps<
61
+ Option = unknown,
62
+ IsMulti extends boolean = false,
63
+ Group extends GroupBase<Option> = GroupBase<Option>,
64
+ > {
65
+ ref?: Ref<SelectInstance<Option, IsMulti, Group>>;
66
+ 'aria-errormessage'?: string;
67
+ 'aria-invalid'?: boolean | 'false' | 'true' | 'grammar' | 'spelling';
68
+ 'aria-label'?: string;
69
+ 'aria-labelledby'?: string;
70
+ 'aria-live'?: 'off' | 'polite' | 'assertive';
71
+ ariaLiveMessages?: AriaLiveMessages<Option, IsMulti, Group>;
72
+ autoFocus?: boolean;
73
+ backspaceRemovesValue?: boolean;
74
+ blurInputOnSelect?: boolean;
75
+ captureMenuScroll?: boolean;
76
+ className?: string;
77
+ classNamePrefix?: string | null;
78
+ classNames?: ClassNamesConfig<Option, IsMulti, Group>;
79
+ closeMenuOnSelect?: boolean;
80
+ closeMenuOnScroll?: boolean | ((event: Event) => boolean);
81
+ components?: SelectComponentsConfig<Option, IsMulti, Group>;
82
+ controlShouldRenderValue?: boolean;
83
+ delimiter?: string;
84
+ escapeClearsValue?: boolean;
85
+ filterOption?: ((option: FilterOptionOption<Option>, inputValue: string) => boolean) | null;
86
+ formatGroupLabel?: (group: Group) => OctaneNode;
87
+ formatOptionLabel?: (data: Option, meta: FormatOptionLabelMeta<Option>) => OctaneNode;
88
+ form?: string;
89
+ getOptionLabel?: GetOptionLabel<Option>;
90
+ getOptionValue?: GetOptionValue<Option>;
91
+ hideSelectedOptions?: boolean;
92
+ id?: string;
93
+ inputId?: string;
94
+ inputValue?: string;
95
+ instanceId?: number | string;
96
+ isClearable?: boolean;
97
+ isDisabled?: boolean;
98
+ isLoading?: boolean;
99
+ isMulti?: IsMulti;
100
+ isOptionDisabled?: (option: Option, selectValue: Options<Option>) => boolean;
101
+ isOptionSelected?: (option: Option, selectValue: Options<Option>) => boolean;
102
+ isRtl?: boolean;
103
+ isSearchable?: boolean;
104
+ loadingMessage?: (value: { inputValue: string }) => OctaneNode;
105
+ maxMenuHeight?: number;
106
+ menuIsOpen?: boolean;
107
+ menuPlacement?: 'auto' | 'bottom' | 'top';
108
+ menuPortalTarget?: HTMLElement | null;
109
+ menuPosition?: 'absolute' | 'fixed';
110
+ menuShouldBlockScroll?: boolean;
111
+ menuShouldScrollIntoView?: boolean;
112
+ minMenuHeight?: number;
113
+ name?: string;
114
+ noOptionsMessage?: (value: { inputValue: string }) => OctaneNode;
115
+ onBlur?: (event: FocusEvent) => void;
116
+ onChange?: (value: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => void;
117
+ onFocus?: (event: FocusEvent) => void;
118
+ onInputChange?: (newValue: string, actionMeta: InputActionMeta) => void;
119
+ onKeyDown?: (event: KeyboardEvent) => void;
120
+ onMenuClose?: () => void;
121
+ onMenuOpen?: () => void;
122
+ onMenuScrollToBottom?: (event: WheelEvent | TouchEvent) => void;
123
+ onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void;
124
+ openMenuOnClick?: boolean;
125
+ openMenuOnFocus?: boolean;
126
+ options?: OptionsOrGroups<Option, Group>;
127
+ pageSize?: number;
128
+ placeholder?: OctaneNode;
129
+ required?: boolean;
130
+ screenReaderStatus?: (value: { count: number }) => string;
131
+ styles?: StylesConfig<Option, IsMulti, Group>;
132
+ tabIndex?: number;
133
+ tabSelectsValue?: boolean;
134
+ theme?: ThemeConfig;
135
+ unstyled?: boolean;
136
+ value?: PropsValue<Option>;
137
+ }
138
+
139
+ // `react-select/base` exposes the class-level Props contract, before React's
140
+ // `JSX.LibraryManagedAttributes` makes defaulted members optional. Keep the
141
+ // renderer-facing SelectProps permissive while publishing the same raw shape.
142
+ export type RawSelectRequiredPropKeys =
143
+ | 'backspaceRemovesValue'
144
+ | 'blurInputOnSelect'
145
+ | 'captureMenuScroll'
146
+ | 'classNames'
147
+ | 'closeMenuOnSelect'
148
+ | 'closeMenuOnScroll'
149
+ | 'components'
150
+ | 'controlShouldRenderValue'
151
+ | 'escapeClearsValue'
152
+ | 'filterOption'
153
+ | 'formatGroupLabel'
154
+ | 'getOptionLabel'
155
+ | 'getOptionValue'
156
+ | 'inputValue'
157
+ | 'isDisabled'
158
+ | 'isLoading'
159
+ | 'isOptionDisabled'
160
+ | 'isMulti'
161
+ | 'isRtl'
162
+ | 'isSearchable'
163
+ | 'loadingMessage'
164
+ | 'maxMenuHeight'
165
+ | 'menuIsOpen'
166
+ | 'menuPlacement'
167
+ | 'menuPosition'
168
+ | 'menuShouldBlockScroll'
169
+ | 'menuShouldScrollIntoView'
170
+ | 'minMenuHeight'
171
+ | 'noOptionsMessage'
172
+ | 'onChange'
173
+ | 'onInputChange'
174
+ | 'onMenuClose'
175
+ | 'onMenuOpen'
176
+ | 'openMenuOnClick'
177
+ | 'openMenuOnFocus'
178
+ | 'options'
179
+ | 'pageSize'
180
+ | 'placeholder'
181
+ | 'screenReaderStatus'
182
+ | 'styles'
183
+ | 'tabIndex'
184
+ | 'tabSelectsValue'
185
+ | 'unstyled'
186
+ | 'value';
187
+
188
+ export type RawSelectProps<Option, IsMulti extends boolean, Group extends GroupBase<Option>> =
189
+ Omit<SelectProps<Option, IsMulti, Group>, RawSelectRequiredPropKeys> & Required<
190
+ Pick<SelectProps<Option, IsMulti, Group>, RawSelectRequiredPropKeys>
191
+ >;
192
+
193
+ export interface SelectInstance<
194
+ Option = unknown,
195
+ IsMulti extends boolean = false,
196
+ Group extends GroupBase<Option> = GroupBase<Option>,
197
+ > {
198
+ blur(): void;
199
+ buildCategorizedOptions(): CategorizedGroupOrOption<Option, Group>[];
200
+ buildFocusableOptions(): Option[];
201
+ clearValue(): void;
202
+ focus(): void;
203
+ focusOption(direction?: FocusDirection): void;
204
+ focusValue(direction: 'previous' | 'next'): void;
205
+ getClassNames(name: string, componentProps: unknown): string | undefined;
206
+ getComponents(): SelectComponents<Option, IsMulti, Group>;
207
+ getOptionLabel(option: Option): string;
208
+ getOptionValue(option: Option): string;
209
+ getStyles(name: string, componentProps: unknown): CSSObjectWithLabel;
210
+ getValue(): Options<Option>;
211
+ openMenu(direction: 'first' | 'last'): void;
212
+ popValue(): void;
213
+ removeValue(option: Option): void;
214
+ selectOption(option: Option): void;
215
+ setValue(value: OnChangeValue<Option, IsMulti>, action: SetValueAction, option?: Option): void;
216
+ }
217
+
218
+ function isTouchCapable() {
219
+ try {
220
+ document.createEvent('TouchEvent');
221
+ return true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export const defaultProps = {
228
+ 'aria-live': 'polite' as const,
229
+ backspaceRemovesValue: true,
230
+ blurInputOnSelect: isTouchCapable(),
231
+ captureMenuScroll: !isTouchCapable(),
232
+ classNames: {},
233
+ closeMenuOnSelect: true,
234
+ closeMenuOnScroll: false,
235
+ components: {},
236
+ controlShouldRenderValue: true,
237
+ escapeClearsValue: false,
238
+ filterOption: createFilter(),
239
+ formatGroupLabel: formatGroupLabelBuiltin,
240
+ getOptionLabel: getOptionLabelBuiltin,
241
+ getOptionValue: getOptionValueBuiltin,
242
+ isDisabled: false,
243
+ isLoading: false,
244
+ isMulti: false,
245
+ isRtl: false,
246
+ isSearchable: true,
247
+ isOptionDisabled: isOptionDisabledBuiltin,
248
+ loadingMessage: () => 'Loading...',
249
+ maxMenuHeight: 300,
250
+ minMenuHeight: 140,
251
+ menuIsOpen: false,
252
+ menuPlacement: 'bottom' as const,
253
+ menuPosition: 'absolute' as const,
254
+ menuShouldBlockScroll: false,
255
+ menuShouldScrollIntoView: true,
256
+ noOptionsMessage: () => 'No options',
257
+ openMenuOnFocus: false,
258
+ openMenuOnClick: true,
259
+ options: [],
260
+ pageSize: 5,
261
+ placeholder: 'Select...',
262
+ screenReaderStatus: ({ count }: { count: number }) => `${count} result${
263
+ count !== 1 ? 's' : ''
264
+ } available`,
265
+ styles: {},
266
+ tabIndex: 0,
267
+ tabSelectsValue: true,
268
+ unstyled: false,
269
+ };
270
+
271
+ export interface CategorizedOption<Option> {
272
+ type: 'option';
273
+ data: Option;
274
+ isDisabled: boolean;
275
+ isSelected: boolean;
276
+ label: string;
277
+ value: string;
278
+ index: number;
279
+ }
280
+
281
+ export interface CategorizedGroup<Option, Group extends GroupBase<Option>> {
282
+ type: 'group';
283
+ data: Group;
284
+ options: readonly CategorizedOption<Option>[];
285
+ index: number;
286
+ }
287
+
288
+ export type CategorizedGroupOrOption<Option, Group extends GroupBase<Option>> =
289
+ CategorizedGroup<Option, Group> | CategorizedOption<Option>;
290
+
291
+ function isSelected<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
292
+ props: RequiredSelectProps<Option, IsMulti, Group>,
293
+ option: Option,
294
+ selectValue: Options<Option>,
295
+ ) {
296
+ if (selectValue.indexOf(option) > -1) return true;
297
+ if (props.isOptionSelected) return props.isOptionSelected(option, selectValue);
298
+ const candidate = props.getOptionValue(option);
299
+ return selectValue.some((value) => props.getOptionValue(value) === candidate);
300
+ }
301
+
302
+ type RequiredSelectProps<Option, IsMulti extends boolean, Group extends GroupBase<Option>> =
303
+ SelectProps<Option, IsMulti, Group> & {
304
+ 'aria-live': 'off' | 'polite' | 'assertive';
305
+ captureMenuScroll: boolean;
306
+ classNames: ClassNamesConfig<Option, IsMulti, Group>;
307
+ closeMenuOnSelect: boolean;
308
+ closeMenuOnScroll: boolean | ((event: Event) => boolean);
309
+ components: SelectComponentsConfig;
310
+ controlShouldRenderValue: boolean;
311
+ filterOption: ((option: FilterOptionOption<Option>, inputValue: string) => boolean) | null;
312
+ formatGroupLabel: (group: Group) => OctaneNode;
313
+ getOptionLabel: GetOptionLabel<Option>;
314
+ getOptionValue: GetOptionValue<Option>;
315
+ inputValue: string;
316
+ isDisabled: boolean;
317
+ isLoading: boolean;
318
+ isMulti: IsMulti;
319
+ isOptionDisabled: (option: Option, selectValue: Options<Option>) => boolean;
320
+ isRtl: boolean;
321
+ loadingMessage: (value: { inputValue: string }) => OctaneNode;
322
+ maxMenuHeight: number;
323
+ menuIsOpen: boolean;
324
+ menuPlacement: 'auto' | 'bottom' | 'top';
325
+ menuPosition: 'absolute' | 'fixed';
326
+ menuShouldBlockScroll: boolean;
327
+ menuShouldScrollIntoView: boolean;
328
+ minMenuHeight: number;
329
+ noOptionsMessage: (value: { inputValue: string }) => OctaneNode;
330
+ onChange: (value: OnChangeValue<Option, IsMulti>, actionMeta: ActionMeta<Option>) => void;
331
+ onInputChange: (newValue: string, actionMeta: InputActionMeta) => void;
332
+ onMenuClose: () => void;
333
+ onMenuOpen: () => void;
334
+ openMenuOnClick: boolean;
335
+ options: OptionsOrGroups<Option, Group>;
336
+ pageSize: number;
337
+ placeholder: OctaneNode;
338
+ screenReaderStatus: (value: { count: number }) => string;
339
+ styles: StylesConfig<Option, IsMulti, Group>;
340
+ tabIndex: number;
341
+ tabSelectsValue: boolean;
342
+ unstyled: boolean;
343
+ };
344
+
345
+ export function buildCategorizedOptions<
346
+ Option,
347
+ IsMulti extends boolean,
348
+ Group extends GroupBase<Option>,
349
+ >(
350
+ props: RequiredSelectProps<Option, IsMulti, Group>,
351
+ selectValue: Options<Option>,
352
+ ): CategorizedGroupOrOption<Option, Group>[] {
353
+ const hideSelected = props.hideSelectedOptions ?? props.isMulti;
354
+ const categorize = (option: Option, index: number): CategorizedOption<Option> => ({
355
+ type: 'option',
356
+ data: option,
357
+ isDisabled: props.isOptionDisabled(option, selectValue),
358
+ isSelected: isSelected(props, option, selectValue),
359
+ label: props.getOptionLabel(option),
360
+ value: props.getOptionValue(option),
361
+ index,
362
+ });
363
+ const focusable = (option: CategorizedOption<Option>) =>
364
+ (!hideSelected || !option.isSelected) &&
365
+ (props.filterOption
366
+ ? props.filterOption(
367
+ { label: option.label, value: option.value, data: option.data },
368
+ props.inputValue ?? '',
369
+ )
370
+ : true);
371
+ const result: CategorizedGroupOrOption<Option, Group>[] = [];
372
+ props.options.forEach((item, outerIndex) => {
373
+ if (typeof item === 'object' && item !== null && 'options' in item) {
374
+ const options = item.options.map(categorize).filter(focusable);
375
+ if (options.length) result.push({ type: 'group', data: item, options, index: outerIndex });
376
+ return;
377
+ }
378
+ const option = categorize(item as Option, outerIndex);
379
+ if (focusable(option)) result.push(option);
380
+ });
381
+ return result;
382
+ }
383
+
384
+ function buildFocusableOptionsFromCategorized<Option, Group extends GroupBase<Option>>(
385
+ categorized: readonly CategorizedGroupOrOption<Option, Group>[],
386
+ ): Option[] {
387
+ const options: Option[] = [];
388
+ for (const item of categorized) {
389
+ if (item.type === 'option') {
390
+ options.push(item.data);
391
+ continue;
392
+ }
393
+ for (const option of item.options) options.push(option.data);
394
+ }
395
+ return options;
396
+ }
397
+
398
+ function getNextFocusedValue<Option>(
399
+ focusedValue: Option | null,
400
+ lastSelectValue: Options<Option>,
401
+ nextSelectValue: Options<Option>,
402
+ ): Option | null {
403
+ if (!focusedValue) return null;
404
+ const lastFocusedIndex = lastSelectValue.indexOf(focusedValue);
405
+ if (lastFocusedIndex > -1) {
406
+ const nextFocusedIndex = nextSelectValue.indexOf(focusedValue);
407
+ if (nextFocusedIndex > -1) return focusedValue;
408
+ if (lastFocusedIndex < nextSelectValue.length) return nextSelectValue[lastFocusedIndex];
409
+ }
410
+ return null;
411
+ }
412
+
413
+ let nextInstanceId = 1;
414
+
415
+ const a11yTextStyle = {
416
+ zIndex: 9999,
417
+ border: 0,
418
+ clip: 'rect(1px, 1px, 1px, 1px)',
419
+ height: 1,
420
+ width: 1,
421
+ position: 'absolute',
422
+ overflow: 'hidden',
423
+ padding: 0,
424
+ whiteSpace: 'nowrap',
425
+ label: 'a11yText',
426
+ } as const;
427
+
428
+ const dummyInputStyle = {
429
+ label: 'dummyInput',
430
+ background: 0,
431
+ border: 0,
432
+ caretColor: 'transparent',
433
+ fontSize: 'inherit',
434
+ gridArea: '1 / 1 / 2 / 3',
435
+ outline: 0,
436
+ padding: 0,
437
+ width: 1,
438
+ color: 'transparent',
439
+ left: -100,
440
+ opacity: 0,
441
+ position: 'relative',
442
+ transform: 'scale(.01)',
443
+ } as const;
444
+
445
+ const requiredInputStyle = {
446
+ label: 'requiredInput',
447
+ opacity: 0,
448
+ pointerEvents: 'none',
449
+ position: 'absolute',
450
+ bottom: 0,
451
+ left: 0,
452
+ right: 0,
453
+ width: '100%',
454
+ } as const;
455
+
456
+ const scrollBlockerStyle = {
457
+ position: 'fixed',
458
+ left: 0,
459
+ bottom: 0,
460
+ right: 0,
461
+ top: 0,
462
+ } as const;
463
+
464
+ const defaultAriaLiveMessages = {
465
+ guidance(props: {
466
+ 'aria-label': string | undefined;
467
+ context: 'menu' | 'input' | 'value';
468
+ isSearchable: boolean;
469
+ isMulti: boolean;
470
+ tabSelectsValue: boolean;
471
+ isInitialFocus: boolean;
472
+ }) {
473
+ if (props.context === 'menu') {
474
+ return `Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu${
475
+ props.tabSelectsValue ? ', press Tab to select the option and exit the menu' : ''
476
+ }.`;
477
+ }
478
+ if (props.context === 'value') {
479
+ return 'Use left and right to toggle between focused values, press Backspace to remove the currently focused value';
480
+ }
481
+ return props.isInitialFocus
482
+ ? `${props['aria-label'] || 'Select'} is focused ${
483
+ props.isSearchable ? ',type to refine list' : ''
484
+ }, press Down to open the menu, ${
485
+ props.isMulti ? ' press left to focus selected values' : ''
486
+ }`
487
+ : '';
488
+ },
489
+ onChange(props: { action: string; label?: string; labels: string[]; isDisabled: boolean }) {
490
+ if (props.action === 'clear') return 'All selected options have been cleared.';
491
+ if (['deselect-option', 'pop-value', 'remove-value'].includes(props.action)) {
492
+ return `option ${props.label || ''}, deselected.`;
493
+ }
494
+ if (props.action === 'initial-input-focus') {
495
+ return `option${props.labels.length > 1 ? 's' : ''} ${props.labels.join(',')}, selected.`;
496
+ }
497
+ if (props.action === 'select-option') {
498
+ return props.isDisabled
499
+ ? `option ${props.label || ''} is disabled. Select another option.`
500
+ : `option ${props.label || ''}, selected.`;
501
+ }
502
+ return '';
503
+ },
504
+ onFocus<Option>(props: {
505
+ context: 'menu' | 'value';
506
+ focused: Option;
507
+ label: string;
508
+ options: readonly Option[];
509
+ selectValue: readonly Option[];
510
+ isDisabled: boolean;
511
+ isSelected: boolean;
512
+ isAppleDevice: boolean;
513
+ }) {
514
+ const index = (values: readonly Option[]) => values.length
515
+ ? `${values.indexOf(props.focused) + 1} of ${values.length}`
516
+ : '';
517
+ if (props.context === 'value') {
518
+ return `value ${props.label} focused, ${index(props.selectValue)}.`;
519
+ }
520
+ if (props.isAppleDevice) {
521
+ return `${props.label}${props.isSelected ? ' selected' : ''}${
522
+ props.isDisabled ? ' disabled' : ''
523
+ }, ${index(props.options)}.`;
524
+ }
525
+ return '';
526
+ },
527
+ onFilter(props: { inputValue: string; resultsMessage: string }) {
528
+ return `${props.resultsMessage}${
529
+ props.inputValue ? ` for search term ${props.inputValue}` : ''
530
+ }.`;
531
+ },
532
+ };
533
+
534
+ function LiveRegion<Option, IsMulti extends boolean, Group extends GroupBase<Option>>(props: {
535
+ id: string;
536
+ isFocused: boolean;
537
+ focusedOption: Option | null;
538
+ focusedValue: Option | null;
539
+ focusableOptions: readonly Option[];
540
+ selectValue: readonly Option[];
541
+ selectProps: RequiredSelectProps<Option, IsMulti, Group>;
542
+ ariaSelection: AriaSelection<Option, IsMulti> | null;
543
+ isAppleDevice: boolean;
544
+ }) @{
545
+ const resolveStyle = useStyleResolver();
546
+ const resolved = resolveStyle(a11yTextStyle);
547
+ const messages = { ...defaultAriaLiveMessages, ...props.selectProps.ariaLiveMessages };
548
+ const selection = props.ariaSelection as
549
+ | (AriaSelection<Option, IsMulti> & {
550
+ option?: Option;
551
+ removedValue?: Option;
552
+ removedValues?: readonly Option[];
553
+ options?: readonly Option[];
554
+ })
555
+ | null;
556
+ const selected =
557
+ selection?.removedValue ?? selection?.option ??
558
+ (selection && !Array.isArray(selection.value) ? selection.value as Option : undefined);
559
+ const selectedMany = selection?.options ?? selection?.removedValues;
560
+ const ariaSelected =
561
+ selection && messages.onChange
562
+ ? messages.onChange(
563
+ {
564
+ ...selection,
565
+ isDisabled: selected
566
+ ? props.selectProps.isOptionDisabled(selected, props.selectValue)
567
+ : false,
568
+ label: selected ? props.selectProps.getOptionLabel(selected) : '',
569
+ labels: selectedMany ? selectedMany.map(props.selectProps.getOptionLabel) : [],
570
+ } as never,
571
+ )
572
+ : '';
573
+ const focused = props.focusedOption ?? props.focusedValue;
574
+ const ariaFocused =
575
+ focused && messages.onFocus
576
+ ? messages.onFocus(
577
+ {
578
+ context: focused === props.focusedOption ? 'menu' : 'value',
579
+ focused,
580
+ isDisabled: props.selectProps.isOptionDisabled(focused, props.selectValue),
581
+ isSelected: Boolean(
582
+ props.focusedOption && props.selectValue.includes(props.focusedOption),
583
+ ),
584
+ label: props.selectProps.getOptionLabel(focused),
585
+ options: props.focusableOptions,
586
+ selectValue: props.selectValue,
587
+ isAppleDevice: props.isAppleDevice,
588
+ } as never,
589
+ )
590
+ : '';
591
+ const ariaResults =
592
+ props.selectProps.menuIsOpen && props.selectProps.options.length &&
593
+ !props.selectProps.isLoading &&
594
+ messages.onFilter
595
+ ? messages.onFilter({
596
+ inputValue: props.selectProps.inputValue,
597
+ resultsMessage: props.selectProps.screenReaderStatus({
598
+ count: props.focusableOptions.length,
599
+ }),
600
+ })
601
+ : '';
602
+ const isInitialFocus = selection?.action === 'initial-input-focus';
603
+ const context =
604
+ props.focusedValue
605
+ ? 'value'
606
+ : props.selectProps.menuIsOpen
607
+ ? 'menu'
608
+ : 'input';
609
+ const ariaGuidance =
610
+ messages.guidance?.(
611
+ {
612
+ 'aria-label': props.selectProps['aria-label'],
613
+ context,
614
+ isDisabled: props.focusedOption
615
+ ? props.selectProps.isOptionDisabled(props.focusedOption, props.selectValue)
616
+ : false,
617
+ isMulti: Boolean(props.selectProps.isMulti),
618
+ isSearchable: props.selectProps.isSearchable,
619
+ tabSelectsValue: props.selectProps.tabSelectsValue,
620
+ isInitialFocus,
621
+ } as never,
622
+ ) ??
623
+ '';
624
+ const screenReaderText = <>
625
+ <span id="aria-selection">{ariaSelected}</span>
626
+ <span id="aria-focused">{ariaFocused}</span>
627
+ <span id="aria-results">{ariaResults}</span>
628
+ <span id="aria-guidance">{ariaGuidance}</span>
629
+ </>;
630
+ <>
631
+ <span id={props.id} className={resolved.className}>
632
+ {isInitialFocus ? screenReaderText : null}
633
+ </span>
634
+ <span
635
+ aria-live={props.selectProps['aria-live']}
636
+ aria-atomic="false"
637
+ aria-relevant="additions text"
638
+ role="log"
639
+ className={resolved.className}
640
+ >
641
+ {props.isFocused && !isInitialFocus ? screenReaderText : null}
642
+ </span>
643
+ </>
644
+ }
645
+
646
+ function isAppleDevice() {
647
+ if (typeof window === 'undefined' || window.navigator == null) return false;
648
+ const platform = window.navigator.platform;
649
+ const isMac = /^Mac/i.test(platform);
650
+ return /^iPhone|^iPad/i.test(platform) || isMac && navigator.maxTouchPoints > 1 || isMac;
651
+ }
652
+
653
+ export function Select<
654
+ Option = unknown,
655
+ IsMulti extends boolean = false,
656
+ Group extends GroupBase<Option> = GroupBase<Option>,
657
+ >(inputProps: SelectProps<Option, IsMulti, Group>) @{
658
+ const props = { ...defaultProps, ...inputProps } as RequiredSelectProps<Option, IsMulti, Group>;
659
+ const selectValue = cleanValue(props.value ?? null);
660
+ const categorized = buildCategorizedOptions(props, selectValue);
661
+ const focusableOptions = buildFocusableOptionsFromCategorized(categorized);
662
+ function initialFocusedOption(): Option | null {
663
+ if (!props.menuIsOpen) return null;
664
+ if (!selectValue.length) return null;
665
+ return focusableOptions.includes(selectValue[0]) ? selectValue[0] : null;
666
+ }
667
+ const [instancePrefix] = useState(() => `react-select-${props.instanceId ?? nextInstanceId++}`);
668
+ const [isFocused, setIsFocused] = useState(false);
669
+ const [isComposing, setIsComposing] = useState(false);
670
+ const [inputIsHidden, setInputIsHidden] = useState(false);
671
+ const [appleDevice, setAppleDevice] = useState(false);
672
+ const [focusedOption, setFocusedOption] = useState<Option | null>(initialFocusedOption);
673
+ const [focusedValue, setFocusedValue] = useState<Option | null>(null);
674
+ const clearFocusValueOnUpdateRef = useRef(false);
675
+ const prevSelectValueRef = useRef(selectValue);
676
+ const prevFocusReconcileRef = useRef({
677
+ options: props.options,
678
+ value: props.value,
679
+ menuIsOpen: props.menuIsOpen,
680
+ inputValue: props.inputValue,
681
+ });
682
+ const [ariaSelection, setAriaSelection] = useState<AriaSelection<Option, IsMulti> | null>(null);
683
+ const inputRef = useRef<HTMLInputElement | null>(null);
684
+ const controlRef = useRef<HTMLDivElement | null>(null);
685
+ const initialTouch = useRef({ x: 0, y: 0 });
686
+ const userIsDragging = useRef(false);
687
+ const blockOptionHover = useRef(false);
688
+ const initialFocusPass = useRef(selectValue.length > 0);
689
+ const menuRef = useRef<HTMLDivElement | null>(null);
690
+ const menuListRef = useRef<HTMLDivElement | null>(null);
691
+ const focusedOptionRef = useRef<HTMLDivElement | null>(null);
692
+ const [menuPlacement, setMenuPlacement] = useState<'bottom' | 'top'>(
693
+ props.menuPlacement === 'top' ? 'top' : 'bottom',
694
+ );
695
+ const [menuMaxHeight, setMenuMaxHeight] = useState(props.maxMenuHeight);
696
+ const theme: Theme = useMemo(() => {
697
+ if (!props.theme) return defaultTheme;
698
+ if (typeof props.theme === 'function') return props.theme(defaultTheme);
699
+ return { ...defaultTheme, ...props.theme };
700
+ }, [props.theme]);
701
+ const resolveStyle = useStyleResolver();
702
+ useScrollManager({
703
+ enabled: props.menuIsOpen,
704
+ captureEnabled: props.captureMenuScroll,
705
+ lockEnabled: props.menuShouldBlockScroll,
706
+ target: menuListRef,
707
+ onBottomArrive: props.onMenuScrollToBottom,
708
+ onTopArrive: props.onMenuScrollToTop,
709
+ });
710
+ useLayoutEffect(() => {
711
+ if (!props.menuIsOpen || !menuRef.current) return;
712
+ const next = getMenuPlacement({
713
+ maxHeight: props.maxMenuHeight,
714
+ menuEl: menuRef.current,
715
+ minHeight: props.minMenuHeight,
716
+ placement: props.menuPlacement,
717
+ shouldScroll: props.menuShouldScrollIntoView && props.menuPosition !== 'fixed',
718
+ isFixedPosition: props.menuPosition === 'fixed',
719
+ controlHeight: theme.spacing.controlHeight,
720
+ });
721
+ setMenuPlacement(next.placement);
722
+ setMenuMaxHeight(next.maxHeight);
723
+ }, [
724
+ props.maxMenuHeight,
725
+ props.menuIsOpen,
726
+ props.menuPlacement,
727
+ props.menuPosition,
728
+ props.menuShouldScrollIntoView,
729
+ props.minMenuHeight,
730
+ ]);
731
+ useEffect(() => {
732
+ if (props.autoFocus) inputRef.current?.focus();
733
+ }, []);
734
+ useEffect(() => {
735
+ if (isAppleDevice()) setAppleDevice(true);
736
+ }, []);
737
+ useEffect(() => {
738
+ if (initialFocusPass.current) {
739
+ initialFocusPass.current = false;
740
+ return;
741
+ }
742
+ if (!props.menuIsOpen) {
743
+ if (focusedOption !== null) setFocusedOption(null);
744
+ return;
745
+ }
746
+ const nextFocusedOption =
747
+ focusedOption && focusableOptions.includes(focusedOption)
748
+ ? focusedOption
749
+ : focusableOptions[0] ?? null;
750
+ if (nextFocusedOption !== focusedOption) setFocusedOption(nextFocusedOption);
751
+ }, [props.options, props.value, props.menuIsOpen, props.inputValue, focusedOption]);
752
+ useEffect(() => {
753
+ const prev = prevFocusReconcileRef.current;
754
+ const changed =
755
+ props.value !== prev.value || props.options !== prev.options ||
756
+ props.menuIsOpen !== prev.menuIsOpen ||
757
+ props.inputValue !== prev.inputValue;
758
+ if (!changed) return;
759
+ prevFocusReconcileRef.current = {
760
+ options: props.options,
761
+ value: props.value,
762
+ menuIsOpen: props.menuIsOpen,
763
+ inputValue: props.inputValue,
764
+ };
765
+ const lastSelectValue = prevSelectValueRef.current;
766
+ prevSelectValueRef.current = selectValue;
767
+ const nextFocusedValue =
768
+ clearFocusValueOnUpdateRef.current
769
+ ? getNextFocusedValue(focusedValue, lastSelectValue, selectValue)
770
+ : null;
771
+ clearFocusValueOnUpdateRef.current = false;
772
+ if (nextFocusedValue !== focusedValue) setFocusedValue(nextFocusedValue);
773
+ }, [props.options, props.value, props.menuIsOpen, props.inputValue, selectValue, focusedValue]);
774
+ useLayoutEffect(() => {
775
+ if (!props.menuIsOpen || !menuListRef.current || !focusedOptionRef.current) return;
776
+ scrollOptionIntoView(menuListRef.current, focusedOptionRef.current);
777
+ }, [props.menuIsOpen, focusedOption]);
778
+ const Components = defaultComponents({ components: props.components });
779
+ const inspectableValueChildren = needsInspectableValueChildren(Components.ValueContainer);
780
+ const elementId = (part: string) => `${instancePrefix}-${part}`;
781
+ const getStyles = (name: string, componentProps: unknown) => {
782
+ const key = name as StyleKey;
783
+ const base = defaultStyles[key](componentProps as never, props.unstyled);
784
+ base.boxSizing = 'border-box';
785
+ return props.styles[key]?.(base, componentProps) ?? base;
786
+ };
787
+ const getClassNames = (name: string, componentProps: unknown) =>
788
+ props.classNames[name as StyleKey]?.(componentProps);
789
+ const cx = (state: Record<string, boolean>, ...names: (string | undefined)[]) => classNames(
790
+ props.classNamePrefix,
791
+ state,
792
+ ...names,
793
+ );
794
+
795
+ const closeMenu = () => {
796
+ props.onInputChange('', {
797
+ action: 'menu-close',
798
+ prevInputValue: props.inputValue ?? '',
799
+ });
800
+ props.onMenuClose();
801
+ setFocusedOption(null);
802
+ };
803
+ useEffect(() => {
804
+ if (!props.closeMenuOnScroll || typeof document === 'undefined') return;
805
+ const onScroll = (event: Event) => {
806
+ if (typeof props.closeMenuOnScroll === 'function') {
807
+ if (props.closeMenuOnScroll(event)) props.onMenuClose();
808
+ return;
809
+ }
810
+ const target = event.target;
811
+ if (
812
+ target instanceof HTMLElement &&
813
+ (target === document.documentElement || target === document.body)
814
+ ) props.onMenuClose();
815
+ };
816
+ document.addEventListener('scroll', onScroll, true);
817
+ return () => document.removeEventListener('scroll', onScroll, true);
818
+ }, [props.closeMenuOnScroll, props.onMenuClose]);
819
+ useEffect(() => {
820
+ if (typeof document === 'undefined') return;
821
+ const onTouchStart = (event: TouchEvent) => {
822
+ const touch = event.touches.item(0);
823
+ if (!touch) return;
824
+ initialTouch.current = { x: touch.clientX, y: touch.clientY };
825
+ userIsDragging.current = false;
826
+ };
827
+ const onTouchMove = (event: TouchEvent) => {
828
+ const touch = event.touches.item(0);
829
+ if (!touch) return;
830
+ userIsDragging.current = Math.abs(touch.clientX - initialTouch.current.x) > 5 ||
831
+ Math.abs(touch.clientY - initialTouch.current.y) > 5;
832
+ };
833
+ const onTouchEnd = (event: TouchEvent) => {
834
+ if (!userIsDragging.current) {
835
+ const target = event.target as Node;
836
+ if (
837
+ controlRef.current && !controlRef.current.contains(target) && menuListRef.current &&
838
+ !menuListRef.current.contains(target)
839
+ ) inputRef.current?.blur();
840
+ }
841
+ initialTouch.current = { x: 0, y: 0 };
842
+ };
843
+ document.addEventListener('touchstart', onTouchStart);
844
+ document.addEventListener('touchmove', onTouchMove);
845
+ document.addEventListener('touchend', onTouchEnd);
846
+ return () => {
847
+ document.removeEventListener('touchstart', onTouchStart);
848
+ document.removeEventListener('touchmove', onTouchMove);
849
+ document.removeEventListener('touchend', onTouchEnd);
850
+ };
851
+ }, []);
852
+ const setValue = (
853
+ newValue: OnChangeValue<Option, IsMulti>,
854
+ action: SetValueAction,
855
+ option?: Option,
856
+ ) => {
857
+ props.onInputChange('', {
858
+ action: 'set-value',
859
+ prevInputValue: props.inputValue ?? '',
860
+ });
861
+ if (props.closeMenuOnSelect) closeMenu();
862
+ if (props.closeMenuOnSelect) setInputIsHidden(!props.isMulti);
863
+ const actionMeta = { action, option, name: props.name } as ActionMeta<Option>;
864
+ setAriaSelection({ ...actionMeta, value: newValue } as AriaSelection<Option, IsMulti>);
865
+ clearFocusValueOnUpdateRef.current = true;
866
+ props.onChange(newValue, actionMeta);
867
+ };
868
+ const selectOption = (option: Option) => {
869
+ const optionIsSelected = isSelected(props, option, selectValue);
870
+ if (props.isMulti && optionIsSelected) {
871
+ setValue(
872
+ selectValue.filter(
873
+ (value) => props.getOptionValue(value) !== props.getOptionValue(option),
874
+ ) as OnChangeValue<Option, IsMulti>,
875
+ 'deselect-option',
876
+ option,
877
+ );
878
+ } else if (props.isOptionDisabled(option, selectValue)) {
879
+ setAriaSelection(
880
+ {
881
+ action: 'select-option',
882
+ option,
883
+ name: props.name,
884
+ value: option,
885
+ } as AriaSelection<Option, IsMulti>,
886
+ );
887
+ return;
888
+ } else if (props.isMulti) {
889
+ setValue([...selectValue, option] as OnChangeValue<Option, IsMulti>, 'select-option', option);
890
+ } else {
891
+ setValue(option as OnChangeValue<Option, IsMulti>, 'select-option');
892
+ }
893
+ if (props.blurInputOnSelect) inputRef.current?.blur();
894
+ };
895
+ const clearValue = () => {
896
+ const newValue = valueTernary(props.isMulti, [], null as Option) as OnChangeValue<
897
+ Option,
898
+ IsMulti
899
+ >;
900
+ const actionMeta = {
901
+ action: 'clear',
902
+ removedValues: selectValue,
903
+ name: props.name,
904
+ } as ActionMeta<Option>;
905
+ setAriaSelection({ ...actionMeta, value: newValue } as AriaSelection<Option, IsMulti>);
906
+ props.onChange(newValue, actionMeta);
907
+ };
908
+ const removeValue = (removedValue: Option) => {
909
+ const candidate = props.getOptionValue(removedValue);
910
+ const newValue = selectValue.filter((value) => props.getOptionValue(value) !== candidate);
911
+ const nextValue = valueTernary(
912
+ props.isMulti,
913
+ newValue,
914
+ newValue[0] ?? null as Option,
915
+ ) as OnChangeValue<Option, IsMulti>;
916
+ const actionMeta = {
917
+ action: 'remove-value',
918
+ removedValue,
919
+ name: props.name,
920
+ } as ActionMeta<Option>;
921
+ setAriaSelection({ ...actionMeta, value: nextValue } as AriaSelection<Option, IsMulti>);
922
+ setFocusedValue(null);
923
+ setInputIsHidden(false);
924
+ props.onChange(nextValue, actionMeta);
925
+ };
926
+ const popValue = () => {
927
+ const removedValue = selectValue[selectValue.length - 1];
928
+ if (!removedValue) return;
929
+ const newValue = selectValue.slice(0, -1);
930
+ const nextValue = valueTernary(
931
+ props.isMulti,
932
+ newValue,
933
+ newValue[0] ?? null as Option,
934
+ ) as OnChangeValue<Option, IsMulti>;
935
+ const actionMeta = {
936
+ action: 'pop-value',
937
+ removedValue,
938
+ name: props.name,
939
+ } as ActionMeta<Option>;
940
+ setAriaSelection({ ...actionMeta, value: nextValue } as AriaSelection<Option, IsMulti>);
941
+ props.onChange(nextValue, actionMeta);
942
+ };
943
+ const commonProps = {
944
+ clearValue,
945
+ cx,
946
+ getStyles,
947
+ getClassNames,
948
+ getValue: () => selectValue,
949
+ hasValue: selectValue.length > 0,
950
+ isMulti: Boolean(props.isMulti),
951
+ isRtl: props.isRtl,
952
+ options: props.options,
953
+ selectOption,
954
+ selectProps: props as unknown as { controlShouldRenderValue: boolean } & Record<
955
+ string,
956
+ unknown
957
+ >,
958
+ setValue,
959
+ theme,
960
+ };
961
+
962
+ const focusOption = (direction: FocusDirection = 'first') => {
963
+ if (!focusableOptions.length) return;
964
+ if (ariaSelection?.action === 'initial-input-focus') setAriaSelection(null);
965
+ const current =
966
+ focusedOption ? focusableOptions.indexOf(focusedOption) : -1;
967
+ const index =
968
+ direction === 'first'
969
+ ? 0
970
+ : direction === 'last'
971
+ ? focusableOptions.length - 1
972
+ : direction === 'pageup'
973
+ ? Math.max(current - props.pageSize, 0)
974
+ : direction === 'pagedown'
975
+ ? Math.min(current + props.pageSize, focusableOptions.length - 1)
976
+ : direction === 'down'
977
+ ? (current + 1) % focusableOptions.length
978
+ : current > 0
979
+ ? current - 1
980
+ : focusableOptions.length - 1;
981
+ setFocusedValue(null);
982
+ setFocusedOption(focusableOptions[index]);
983
+ };
984
+
985
+ const focusValue = (direction: 'previous' | 'next') => {
986
+ if (!props.isMulti || !selectValue.length) return;
987
+ if (ariaSelection?.action === 'initial-input-focus') setAriaSelection(null);
988
+ const current =
989
+ focusedValue ? selectValue.indexOf(focusedValue) : -1;
990
+ const next =
991
+ direction === 'previous'
992
+ ? current === 0
993
+ ? 0
994
+ : current === -1
995
+ ? selectValue.length - 1
996
+ : current - 1
997
+ : current > -1 && current < selectValue.length - 1
998
+ ? current + 1
999
+ : -1;
1000
+ setFocusedOption(null);
1001
+ setFocusedValue(next === -1 ? null : selectValue[next]);
1002
+ setInputIsHidden(next !== -1);
1003
+ };
1004
+
1005
+ const openMenu = (direction: 'first' | 'last') => {
1006
+ if (ariaSelection?.action === 'initial-input-focus') setAriaSelection(null);
1007
+ setFocusedValue(null);
1008
+ const selectedIndex =
1009
+ !props.isMulti && selectValue.length ? focusableOptions.indexOf(selectValue[0]) : -1;
1010
+ setFocusedOption(
1011
+ focusableOptions[selectedIndex > -1
1012
+ ? selectedIndex
1013
+ : direction === 'first'
1014
+ ? 0
1015
+ : focusableOptions.length - 1] ??
1016
+ null,
1017
+ );
1018
+ props.onMenuOpen();
1019
+ };
1020
+ useImperativeHandle(
1021
+ props.ref,
1022
+ () => ({
1023
+ blur: () => inputRef.current?.blur(),
1024
+ buildCategorizedOptions: () => categorized,
1025
+ buildFocusableOptions: () => focusableOptions,
1026
+ clearValue,
1027
+ focus: () => inputRef.current?.focus(),
1028
+ focusOption,
1029
+ focusValue,
1030
+ getClassNames,
1031
+ getComponents: () => Components,
1032
+ getOptionLabel: props.getOptionLabel,
1033
+ getOptionValue: props.getOptionValue,
1034
+ getStyles,
1035
+ getValue: () => selectValue,
1036
+ openMenu,
1037
+ popValue,
1038
+ removeValue,
1039
+ selectOption,
1040
+ setValue,
1041
+ }),
1042
+ [
1043
+ categorized,
1044
+ focusableOptions,
1045
+ props.getOptionLabel,
1046
+ props.getOptionValue,
1047
+ selectValue,
1048
+ ],
1049
+ );
1050
+
1051
+ const onKeyDown = (event: KeyboardEvent) => {
1052
+ props.onKeyDown?.(event);
1053
+ if (event.defaultPrevented || props.isDisabled) return;
1054
+ blockOptionHover.current = true;
1055
+ switch (event.key) {
1056
+ case 'ArrowLeft':
1057
+ if (!props.isMulti || props.inputValue) return;
1058
+ focusValue('previous');
1059
+ break;
1060
+ case 'ArrowRight':
1061
+ if (!props.isMulti || props.inputValue) return;
1062
+ focusValue('next');
1063
+ break;
1064
+ case 'ArrowDown':
1065
+ if (props.menuIsOpen) focusOption('down');
1066
+ else openMenu('first');
1067
+ break;
1068
+ case 'ArrowUp':
1069
+ if (props.menuIsOpen) focusOption('up');
1070
+ else openMenu('last');
1071
+ break;
1072
+ case 'PageUp':
1073
+ if (!props.menuIsOpen) return;
1074
+ focusOption('pageup');
1075
+ break;
1076
+ case 'PageDown':
1077
+ if (!props.menuIsOpen) return;
1078
+ focusOption('pagedown');
1079
+ break;
1080
+ case 'Home':
1081
+ if (props.menuIsOpen) focusOption('first');
1082
+ else return;
1083
+ break;
1084
+ case 'End':
1085
+ if (props.menuIsOpen) focusOption('last');
1086
+ else return;
1087
+ break;
1088
+ case 'Enter':
1089
+ if ((event as KeyboardEvent & { keyCode?: number }).keyCode === 229 || isComposing) break;
1090
+ if (props.menuIsOpen && focusedOption) selectOption(focusedOption);
1091
+ else return;
1092
+ break;
1093
+ case 'Escape':
1094
+ if (props.menuIsOpen) closeMenu();
1095
+ else if (props.isClearable && props.escapeClearsValue && selectValue.length) clearValue();
1096
+ else return;
1097
+ break;
1098
+ case 'Backspace':
1099
+ case 'Delete':
1100
+ if (props.inputValue) return;
1101
+ if (focusedValue) removeValue(focusedValue);
1102
+ else if (props.backspaceRemovesValue) {
1103
+ if (props.isMulti) popValue();
1104
+ else if (props.isClearable) clearValue();
1105
+ }
1106
+ break;
1107
+ case 'Tab':
1108
+ if (isComposing) return;
1109
+ if (
1110
+ !event.shiftKey && props.tabSelectsValue && props.menuIsOpen && focusedOption &&
1111
+ !(props.openMenuOnFocus && selectValue.includes(focusedOption))
1112
+ ) selectOption(focusedOption);
1113
+ else return;
1114
+ break;
1115
+ case ' ':
1116
+ if (props.inputValue) return;
1117
+ if (!props.menuIsOpen) openMenu('first');
1118
+ else if (focusedOption) selectOption(focusedOption);
1119
+ else return;
1120
+ break;
1121
+ default:
1122
+ return;
1123
+ }
1124
+ event.preventDefault();
1125
+ };
1126
+
1127
+ const formatOption = (option: Option, context: FormatOptionLabelContext) =>
1128
+ props.formatOptionLabel
1129
+ ? props.formatOptionLabel(option, {
1130
+ context,
1131
+ inputValue: props.inputValue ?? '',
1132
+ selectValue,
1133
+ })
1134
+ : props.getOptionLabel(option);
1135
+ const handleControlPointerEnd = (event: MouseEvent | TouchEvent) => {
1136
+ if (event.defaultPrevented || props.isDisabled) return;
1137
+ const target = event.target as HTMLElement;
1138
+ if (!isFocused) {
1139
+ inputRef.current?.focus();
1140
+ if (props.openMenuOnClick) openMenu('first');
1141
+ } else if (!props.menuIsOpen) {
1142
+ if (props.openMenuOnClick) openMenu('first');
1143
+ } else if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
1144
+ closeMenu();
1145
+ }
1146
+ if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') event.preventDefault();
1147
+ };
1148
+ const handleDropdownPointerEnd = (event: MouseEvent | TouchEvent) => {
1149
+ if (event.type === 'mousedown' && (event as MouseEvent).button !== 0) return;
1150
+ if (props.isDisabled) return;
1151
+ inputRef.current?.focus();
1152
+ if (props.menuIsOpen) {
1153
+ setInputIsHidden(!props.isMulti);
1154
+ closeMenu();
1155
+ } else {
1156
+ openMenu('first');
1157
+ }
1158
+ event.preventDefault();
1159
+ };
1160
+ const handleClearPointerEnd = (event: MouseEvent | TouchEvent) => {
1161
+ if (event.type === 'mousedown' && (event as MouseEvent).button !== 0) return;
1162
+ clearValue();
1163
+ event.preventDefault();
1164
+ if (event.type === 'touchend') inputRef.current?.focus();
1165
+ else setTimeout(() => inputRef.current?.focus());
1166
+ };
1167
+
1168
+ const valueUI =
1169
+ !selectValue.length || !props.controlShouldRenderValue
1170
+ ? props.inputValue
1171
+ ? null
1172
+ : <Components.Placeholder
1173
+ {...commonProps}
1174
+ key="placeholder"
1175
+ isDisabled={props.isDisabled}
1176
+ isFocused={isFocused}
1177
+ innerProps={{ id: elementId('placeholder') }}
1178
+ >{props.placeholder}</Components.Placeholder>
1179
+ : props.isMulti
1180
+ ? selectValue.map(
1181
+ (option, index) => <Components.MultiValue
1182
+ {...commonProps}
1183
+ key={`${props.getOptionLabel(option)}-${props.getOptionValue(option)}`}
1184
+ components={{
1185
+ Container: Components.MultiValueContainer,
1186
+ Label: Components.MultiValueLabel,
1187
+ Remove: Components.MultiValueRemove,
1188
+ }}
1189
+ data={option}
1190
+ index={index}
1191
+ isDisabled={props.isDisabled}
1192
+ isFocused={focusedValue === option}
1193
+ removeProps={{
1194
+ onClick: () => removeValue(option),
1195
+ onMouseDown: (event: MouseEvent) => event.preventDefault(),
1196
+ onTouchEnd: () => {
1197
+ if (!userIsDragging.current) removeValue(option);
1198
+ },
1199
+ }}
1200
+ >{formatOption(option, 'value')}</Components.MultiValue>,
1201
+ )
1202
+ : props.inputValue
1203
+ ? null
1204
+ : <Components.SingleValue
1205
+ {...commonProps}
1206
+ data={selectValue[0]}
1207
+ isDisabled={props.isDisabled}
1208
+ >{formatOption(selectValue[0], 'value')}</Components.SingleValue>;
1209
+
1210
+ const inputId = props.inputId || elementId('input');
1211
+ const handleInputFocus = (event: FocusEvent) => {
1212
+ setInputIsHidden(false);
1213
+ setIsFocused(true);
1214
+ setAriaSelection(
1215
+ {
1216
+ action: 'initial-input-focus',
1217
+ value: valueTernary(props.isMulti, selectValue, selectValue[0] ?? null as Option),
1218
+ options: selectValue,
1219
+ } as AriaSelection<Option, IsMulti>,
1220
+ );
1221
+ props.onFocus?.(event);
1222
+ if (props.openMenuOnFocus) openMenu('first');
1223
+ };
1224
+ const handleInputBlur = (event: FocusEvent) => {
1225
+ if (menuListRef.current?.contains(document.activeElement)) {
1226
+ inputRef.current?.focus();
1227
+ return;
1228
+ }
1229
+ props.onBlur?.(event);
1230
+ props.onInputChange('', {
1231
+ action: 'input-blur',
1232
+ prevInputValue: props.inputValue ?? '',
1233
+ });
1234
+ closeMenu();
1235
+ setFocusedValue(null);
1236
+ setIsFocused(false);
1237
+ };
1238
+ const ariaDescribedBy =
1239
+ selectValue.length
1240
+ ? ariaSelection?.action === 'initial-input-focus'
1241
+ ? elementId('live-region')
1242
+ : undefined
1243
+ : elementId('placeholder');
1244
+ function focusedOptionId(): string {
1245
+ if (!focusedOption) return '';
1246
+ for (const item of categorized) {
1247
+ if (item.type === 'option') {
1248
+ if (item.data === focusedOption) return elementId(`option-${item.index}`);
1249
+ continue;
1250
+ }
1251
+ for (const option of item.options) {
1252
+ if (option.data === focusedOption) {
1253
+ return elementId(`option-${item.index}-${option.index}`);
1254
+ }
1255
+ }
1256
+ }
1257
+ return '';
1258
+ }
1259
+ const ariaActiveDescendant =
1260
+ appleDevice ? undefined : focusedOptionId();
1261
+ const commonInputAttributes: Omit<Octane.JSX.IntrinsicElements['input'], 'ref'> & {
1262
+ ref?: Octane.Ref<HTMLInputElement>;
1263
+ } = {
1264
+ id: inputId,
1265
+ disabled: props.isDisabled,
1266
+ onBlur: handleInputBlur,
1267
+ onFocus: handleInputFocus,
1268
+ tabIndex: props.tabIndex,
1269
+ form: props.form,
1270
+ 'aria-autocomplete': 'list' as const,
1271
+ 'aria-expanded': props.menuIsOpen,
1272
+ 'aria-haspopup': 'true' as const,
1273
+ 'aria-errormessage': props['aria-errormessage'],
1274
+ 'aria-invalid': props['aria-invalid'],
1275
+ 'aria-label': props['aria-label'],
1276
+ 'aria-labelledby': props['aria-labelledby'],
1277
+ 'aria-required': props.required,
1278
+ role: 'combobox',
1279
+ 'aria-activedescendant': ariaActiveDescendant,
1280
+ 'aria-controls': props.menuIsOpen ? elementId('listbox') : undefined,
1281
+ 'aria-describedby': ariaDescribedBy,
1282
+ ref: inputRef,
1283
+ };
1284
+ const inputUI =
1285
+ !props.isSearchable
1286
+ ? <input
1287
+ {...commonInputAttributes}
1288
+ className={resolveStyle(dummyInputStyle).className}
1289
+ inputMode="none"
1290
+ value=""
1291
+ onInput={() => {}}
1292
+ aria-readonly="true"
1293
+ />
1294
+ : <Components.Input
1295
+ {...commonProps}
1296
+ autoCapitalize="none"
1297
+ autoComplete="off"
1298
+ autoCorrect="off"
1299
+ id={inputId}
1300
+ innerRef={inputRef}
1301
+ isDisabled={props.isDisabled}
1302
+ isHidden={inputIsHidden}
1303
+ onBlur={commonInputAttributes.onBlur}
1304
+ onInput={(event: InputEvent) => {
1305
+ const target = event.currentTarget as HTMLInputElement;
1306
+ setInputIsHidden(false);
1307
+ props.onInputChange(target.value, {
1308
+ action: 'input-change',
1309
+ prevInputValue: props.inputValue ?? '',
1310
+ });
1311
+ if (!props.menuIsOpen) props.onMenuOpen();
1312
+ }}
1313
+ onFocus={handleInputFocus}
1314
+ spellCheck="false"
1315
+ onCompositionStart={() => setIsComposing(true)}
1316
+ onCompositionEnd={() => setIsComposing(false)}
1317
+ tabIndex={props.tabIndex}
1318
+ form={props.form}
1319
+ type="text"
1320
+ value={props.inputValue ?? ''}
1321
+ aria-autocomplete="list"
1322
+ aria-expanded={props.menuIsOpen}
1323
+ aria-haspopup="true"
1324
+ aria-errormessage={props['aria-errormessage']}
1325
+ aria-invalid={props['aria-invalid']}
1326
+ aria-label={props['aria-label']}
1327
+ aria-labelledby={props['aria-labelledby']}
1328
+ aria-required={props.required}
1329
+ role="combobox"
1330
+ aria-activedescendant={ariaActiveDescendant}
1331
+ aria-controls={props.menuIsOpen ? elementId('listbox') : undefined}
1332
+ aria-describedby={ariaDescribedBy}
1333
+ />;
1334
+
1335
+ const onOptionHover = (option: Option) => {
1336
+ if (blockOptionHover.current || focusedOption === option) return;
1337
+ setFocusedOption(option);
1338
+ };
1339
+ const renderOption = (option: CategorizedOption<Option>, id: string) => {
1340
+ const optionIsFocused = focusedOption === option.data;
1341
+ const optionId = elementId(`option-${id}`);
1342
+ return <Components.Option
1343
+ {...commonProps}
1344
+ key={optionId}
1345
+ data={option.data}
1346
+ innerRef={optionIsFocused ? focusedOptionRef : null}
1347
+ innerProps={{
1348
+ id: optionId,
1349
+ onClick: option.isDisabled ? undefined : () => selectOption(option.data),
1350
+ onMouseMove: option.isDisabled ? undefined : () => onOptionHover(option.data),
1351
+ onMouseOver: option.isDisabled ? undefined : () => onOptionHover(option.data),
1352
+ onTouchEnd: option.isDisabled
1353
+ ? undefined
1354
+ : () => {
1355
+ if (!userIsDragging.current) selectOption(option.data);
1356
+ },
1357
+ tabIndex: -1,
1358
+ role: 'option',
1359
+ 'aria-selected': appleDevice ? undefined : option.isSelected,
1360
+ }}
1361
+ isDisabled={option.isDisabled}
1362
+ isFocused={optionIsFocused}
1363
+ isSelected={option.isSelected}
1364
+ label={option.label}
1365
+ type="option"
1366
+ >{formatOption(option.data, 'menu')}</Components.Option>;
1367
+ };
1368
+
1369
+ let menuUI: OctaneNode = null;
1370
+ if (props.menuIsOpen) {
1371
+ const children =
1372
+ categorized.length
1373
+ ? categorized.map(
1374
+ (item) => item.type === 'group'
1375
+ ? <Components.Group
1376
+ {...commonProps}
1377
+ key={elementId(`group-${item.index}`)}
1378
+ Heading={Components.GroupHeading}
1379
+ data={item.data}
1380
+ headingProps={{
1381
+ id: `${elementId(`group-${item.index}`)}-heading`,
1382
+ data: item.data,
1383
+ }}
1384
+ label={props.formatGroupLabel(item.data)}
1385
+ options={item.options}
1386
+ >{item.options.map(
1387
+ (option) => renderOption(option, `${item.index}-${option.index}`),
1388
+ )}</Components.Group>
1389
+ : renderOption(item, String(item.index)),
1390
+ )
1391
+ : props.isLoading
1392
+ ? props.loadingMessage({ inputValue: props.inputValue ?? '' })
1393
+ : props.noOptionsMessage({ inputValue: props.inputValue ?? '' });
1394
+ if (children === null) {
1395
+ menuUI = null;
1396
+ } else {
1397
+ const renderedChildren =
1398
+ categorized.length
1399
+ ? children
1400
+ : props.isLoading
1401
+ ? <Components.LoadingMessage {...commonProps}>{children}</Components.LoadingMessage>
1402
+ : <Components.NoOptionsMessage
1403
+ {...commonProps}
1404
+ >{children}</Components.NoOptionsMessage>;
1405
+ const menuElement = <Components.Menu
1406
+ {...commonProps}
1407
+ innerRef={menuRef}
1408
+ innerProps={{
1409
+ onMouseDown: (event: MouseEvent) => {
1410
+ event.stopPropagation();
1411
+ event.preventDefault();
1412
+ },
1413
+ onMouseMove: (event: MouseEvent) => {
1414
+ blockOptionHover.current = false;
1415
+ event.stopPropagation();
1416
+ },
1417
+ }}
1418
+ isLoading={props.isLoading}
1419
+ placement={menuPlacement}
1420
+ minMenuHeight={props.minMenuHeight}
1421
+ maxMenuHeight={menuMaxHeight}
1422
+ menuPlacement={props.menuPlacement}
1423
+ menuPosition={props.menuPosition}
1424
+ menuShouldScrollIntoView={props.menuShouldScrollIntoView}
1425
+ >
1426
+ <Components.MenuList
1427
+ {...commonProps}
1428
+ innerRef={menuListRef}
1429
+ innerProps={{
1430
+ role: 'listbox',
1431
+ 'aria-multiselectable': Boolean(props.isMulti),
1432
+ id: elementId('listbox'),
1433
+ }}
1434
+ maxHeight={menuMaxHeight}
1435
+ focusedOption={focusedOption}
1436
+ >{renderedChildren}</Components.MenuList>
1437
+ </Components.Menu>;
1438
+ menuUI = props.menuPortalTarget || props.menuPosition === 'fixed'
1439
+ ? <Components.MenuPortal
1440
+ {...commonProps}
1441
+ appendTo={props.menuPortalTarget ?? undefined}
1442
+ controlElement={controlRef.current}
1443
+ innerProps={{}}
1444
+ menuPlacement={menuPlacement}
1445
+ menuPosition={props.menuPosition}
1446
+ >{menuElement}</Components.MenuPortal>
1447
+ : menuElement;
1448
+ if (props.menuShouldBlockScroll) {
1449
+ const unlockedMenu = menuUI;
1450
+ menuUI = <>
1451
+ <div
1452
+ className={resolveStyle(scrollBlockerStyle).className}
1453
+ onClick={(event: MouseEvent) => {
1454
+ const target = event.target as HTMLElement;
1455
+ (target.ownerDocument.activeElement as HTMLElement | null)?.blur();
1456
+ }}
1457
+ />
1458
+ {unlockedMenu}
1459
+ </>;
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ let formField: OctaneNode = null;
1465
+ if (props.required && !selectValue.length && !props.isDisabled) {
1466
+ formField = <input
1467
+ required={true}
1468
+ name={props.name}
1469
+ tabIndex={-1}
1470
+ aria-hidden="true"
1471
+ onFocus={(event: FocusEvent) => {
1472
+ event.preventDefault();
1473
+ event.stopPropagation();
1474
+ inputRef.current?.focus();
1475
+ }}
1476
+ className={resolveStyle(requiredInputStyle).className}
1477
+ value=""
1478
+ onInput={() => {}}
1479
+ />;
1480
+ } else if (props.name && !props.isDisabled) {
1481
+ if (props.isMulti) {
1482
+ formField = props.delimiter
1483
+ ? <input
1484
+ name={props.name}
1485
+ type="hidden"
1486
+ value={selectValue.map(props.getOptionValue).join(props.delimiter)}
1487
+ />
1488
+ : <div>
1489
+ {selectValue.length
1490
+ ? selectValue.map(
1491
+ (option, index) =>
1492
+ <input
1493
+ key={`i-${index}`}
1494
+ name={props.name}
1495
+ type="hidden"
1496
+ value={props.getOptionValue(option)}
1497
+ />,
1498
+ )
1499
+ : <input name={props.name} type="hidden" value="" />}
1500
+ </div>;
1501
+ } else {
1502
+ formField = <input
1503
+ name={props.name}
1504
+ type="hidden"
1505
+ value={selectValue[0] ? props.getOptionValue(selectValue[0]) : ''}
1506
+ />;
1507
+ }
1508
+ }
1509
+ <Components.SelectContainer
1510
+ {...commonProps}
1511
+ className={props.className}
1512
+ innerProps={{ id: props.id, onKeyDown }}
1513
+ isDisabled={props.isDisabled}
1514
+ isFocused={isFocused}
1515
+ >
1516
+ <LiveRegion
1517
+ id={elementId('live-region')}
1518
+ isFocused={isFocused}
1519
+ focusedOption={focusedOption}
1520
+ focusedValue={focusedValue}
1521
+ focusableOptions={focusableOptions}
1522
+ selectValue={selectValue}
1523
+ selectProps={props}
1524
+ ariaSelection={ariaSelection}
1525
+ isAppleDevice={appleDevice}
1526
+ />
1527
+ <Components.Control
1528
+ {...commonProps}
1529
+ innerRef={controlRef}
1530
+ innerProps={{
1531
+ onMouseDown: handleControlPointerEnd,
1532
+ onTouchEnd: (event: TouchEvent) => {
1533
+ if (!userIsDragging.current) handleControlPointerEnd(event);
1534
+ },
1535
+ }}
1536
+ isDisabled={props.isDisabled}
1537
+ isFocused={isFocused}
1538
+ menuIsOpen={props.menuIsOpen}
1539
+ >
1540
+ @if (inspectableValueChildren) {
1541
+ <>
1542
+ {createElement(Components.ValueContainer, {
1543
+ ...commonProps,
1544
+ isDisabled: props.isDisabled,
1545
+ children: props.isMulti && Array.isArray(valueUI)
1546
+ ? [
1547
+ ...valueUI,
1548
+ createElement(Fragment, { key: 'select-input', children: inputUI }),
1549
+ ]
1550
+ : [
1551
+ createElement(Fragment, { key: 'select-value', children: valueUI }),
1552
+ createElement(Fragment, { key: 'select-input', children: inputUI }),
1553
+ ],
1554
+ })}
1555
+ </>
1556
+ } @else {
1557
+ <Components.ValueContainer {...commonProps} isDisabled={props.isDisabled}>
1558
+ {valueUI}
1559
+ {inputUI}
1560
+ </Components.ValueContainer>
1561
+ }
1562
+ <Components.IndicatorsContainer {...commonProps} isDisabled={props.isDisabled}>
1563
+ {(props.isClearable ?? Boolean(props.isMulti)) && selectValue.length && !props.isDisabled &&
1564
+ !props.isLoading
1565
+ ? <Components.ClearIndicator
1566
+ {...commonProps}
1567
+ innerProps={{
1568
+ 'aria-hidden': 'true',
1569
+ onMouseDown: handleClearPointerEnd,
1570
+ onTouchEnd: (event: TouchEvent) => {
1571
+ if (!userIsDragging.current) handleClearPointerEnd(event);
1572
+ },
1573
+ }}
1574
+ isDisabled={props.isDisabled}
1575
+ isFocused={isFocused}
1576
+ />
1577
+ : null}
1578
+ {props.isLoading
1579
+ ? <Components.LoadingIndicator
1580
+ {...commonProps}
1581
+ innerProps={{ 'aria-hidden': 'true' }}
1582
+ isDisabled={props.isDisabled}
1583
+ isFocused={isFocused}
1584
+ />
1585
+ : null}
1586
+ {Components.DropdownIndicator && Components.IndicatorSeparator
1587
+ ? <Components.IndicatorSeparator
1588
+ {...commonProps}
1589
+ isDisabled={props.isDisabled}
1590
+ isFocused={isFocused}
1591
+ />
1592
+ : null}
1593
+ {Components.DropdownIndicator
1594
+ ? <Components.DropdownIndicator
1595
+ {...commonProps}
1596
+ innerProps={{
1597
+ 'aria-hidden': 'true',
1598
+ onMouseDown: handleDropdownPointerEnd,
1599
+ onTouchEnd: (event: TouchEvent) => {
1600
+ if (!userIsDragging.current) handleDropdownPointerEnd(event);
1601
+ },
1602
+ }}
1603
+ isDisabled={props.isDisabled}
1604
+ isFocused={isFocused}
1605
+ />
1606
+ : null}
1607
+ </Components.IndicatorsContainer>
1608
+ </Components.Control>
1609
+ {menuUI}
1610
+ {formField}
1611
+ </Components.SelectContainer>
1612
+ }
1613
+
1614
+ export type PublicBaseSelectProps<
1615
+ Option,
1616
+ IsMulti extends boolean,
1617
+ Group extends GroupBase<Option>,
1618
+ > =
1619
+ SelectProps<Option, IsMulti, Group>;
1620
+
1621
+ export const baseComponents = builtinComponents;