@basic-ui/core 0.0.28 → 0.0.32

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.
Files changed (132) hide show
  1. package/build/cjs/index.js +44 -21
  2. package/build/cjs/index.js.map +1 -1
  3. package/build/esm/FocusLock/useFocusLock.js +21 -7
  4. package/build/esm/FocusLock/useFocusLock.js.map +1 -1
  5. package/build/esm/Menu/Menu.js +0 -3
  6. package/build/esm/Menu/Menu.js.map +1 -1
  7. package/build/esm/Menu/MenuButton.js +7 -5
  8. package/build/esm/Menu/MenuButton.js.map +1 -1
  9. package/build/esm/Menu/MenuList.js +8 -5
  10. package/build/esm/Menu/MenuList.js.map +1 -1
  11. package/build/esm/Menu/context.d.ts +0 -1
  12. package/build/esm/Menu/context.js.map +1 -1
  13. package/build/esm/Tooltip/Tooltip.d.ts +1 -0
  14. package/build/esm/Tooltip/Tooltip.js +10 -3
  15. package/build/esm/Tooltip/Tooltip.js.map +1 -1
  16. package/build/esm/hooks/useId.d.ts +1 -0
  17. package/build/esm/hooks/useId.js.map +1 -1
  18. package/build/tsconfig.tsbuildinfo +11 -11
  19. package/package.json +4 -3
  20. package/src/Accordion/Accordion.story.tsx +72 -0
  21. package/src/Accordion/Accordion.tsx +51 -0
  22. package/src/Accordion/AccordionBody.tsx +53 -0
  23. package/src/Accordion/AccordionHeader.tsx +165 -0
  24. package/src/Accordion/AccordionItem.tsx +43 -0
  25. package/src/Accordion/context.ts +35 -0
  26. package/src/Accordion/index.ts +4 -0
  27. package/src/Accordion/scopeQuery.ts +7 -0
  28. package/src/Accordion/styles.css +21 -0
  29. package/src/CheckBox/CheckBox.tsx +41 -0
  30. package/src/CheckBox/index.ts +1 -0
  31. package/src/ComboBox/ComboBox.story.tsx +118 -0
  32. package/src/ComboBox/Combobox.tsx +153 -0
  33. package/src/ComboBox/ComboboxButton.tsx +60 -0
  34. package/src/ComboBox/ComboboxInput.tsx +178 -0
  35. package/src/ComboBox/ComboboxLabel.tsx +32 -0
  36. package/src/ComboBox/ComboboxList.tsx +47 -0
  37. package/src/ComboBox/ComboboxOption.tsx +107 -0
  38. package/src/ComboBox/ComboboxPopover.tsx +58 -0
  39. package/src/ComboBox/cities.ts +23194 -0
  40. package/src/ComboBox/context.ts +33 -0
  41. package/src/ComboBox/hooks.tsx +428 -0
  42. package/src/ComboBox/index.ts +8 -0
  43. package/src/ComboBox/makeHash.ts +19 -0
  44. package/src/ComboBox/scopeQuery.ts +6 -0
  45. package/src/ComboBox/styles.css +30 -0
  46. package/src/FocusLock/FocusLock.tsx +59 -0
  47. package/src/FocusLock/index.ts +1 -0
  48. package/src/FocusLock/tabUtils.ts +28 -0
  49. package/src/FocusLock/useFocusLock.ts +61 -0
  50. package/src/List/List.story.tsx +17 -0
  51. package/src/List/List.tsx +17 -0
  52. package/src/List/ListItem.tsx +23 -0
  53. package/src/List/context.ts +19 -0
  54. package/src/List/index.ts +2 -0
  55. package/src/Menu/.gitkeep +0 -0
  56. package/src/Menu/Menu.story.tsx +158 -0
  57. package/src/Menu/Menu.tsx +60 -0
  58. package/src/Menu/MenuButton.tsx +83 -0
  59. package/src/Menu/MenuItem.tsx +83 -0
  60. package/src/Menu/MenuList.tsx +201 -0
  61. package/src/Menu/MenuPopover.tsx +25 -0
  62. package/src/Menu/context.ts +32 -0
  63. package/src/Menu/index.ts +5 -0
  64. package/src/Menu/scope.ts +7 -0
  65. package/src/Menu/styles.css +42 -0
  66. package/src/Modal/Modal.story.tsx +242 -0
  67. package/src/Modal/Modal.tsx +42 -0
  68. package/src/Modal/ModalBackdrop.tsx +72 -0
  69. package/src/Modal/NavDrawer.story.tsx +157 -0
  70. package/src/Modal/index.ts +2 -0
  71. package/src/Modal/styles.css +46 -0
  72. package/src/Popover/.gitkeep +0 -0
  73. package/src/Popper/Popper.story.tsx +267 -0
  74. package/src/Popper/Popper.tsx +149 -0
  75. package/src/Popper/PopperArrow.tsx +36 -0
  76. package/src/Popper/context.ts +9 -0
  77. package/src/Popper/index.ts +3 -0
  78. package/src/Popper/styles.css +60 -0
  79. package/src/Portal/Portal.tsx +20 -0
  80. package/src/Portal/index.ts +1 -0
  81. package/src/RadioButton/RadioButton.story.tsx +73 -0
  82. package/src/RadioButton/RadioButton.tsx +48 -0
  83. package/src/RadioButton/RadioGroup.tsx +56 -0
  84. package/src/RadioButton/context.ts +19 -0
  85. package/src/RadioButton/index.ts +2 -0
  86. package/src/SkipNav/SkipNav.tsx +16 -0
  87. package/src/SkipNav/index.tsx +1 -0
  88. package/src/Spinner/Spinner.story.tsx +30 -0
  89. package/src/Spinner/Spinner.tsx +112 -0
  90. package/src/Spinner/SpinnerButton.tsx +48 -0
  91. package/src/Spinner/context.ts +21 -0
  92. package/src/Spinner/index.ts +2 -0
  93. package/src/Spinner/styles.css +23 -0
  94. package/src/Tabs/Tab.story.tsx +78 -0
  95. package/src/Tabs/Tab.tsx +131 -0
  96. package/src/Tabs/TabList.tsx +63 -0
  97. package/src/Tabs/TabPanel.tsx +52 -0
  98. package/src/Tabs/TabPanels.tsx +30 -0
  99. package/src/Tabs/Tabs.tsx +47 -0
  100. package/src/Tabs/context.ts +30 -0
  101. package/src/Tabs/index.tsx +5 -0
  102. package/src/Tabs/scopeQuery.ts +6 -0
  103. package/src/Tabs/styles.css +0 -0
  104. package/src/Tooltip/.gitkeep +0 -0
  105. package/src/Tooltip/Tooltip.story.tsx +43 -0
  106. package/src/Tooltip/Tooltip.tsx +48 -0
  107. package/src/Tooltip/index.ts +1 -0
  108. package/src/Tooltip/stateMachine.ts +185 -0
  109. package/src/Tooltip/useTooltip.ts +121 -0
  110. package/src/hooks/index.ts +14 -0
  111. package/src/hooks/useAutoFocus.ts +13 -0
  112. package/src/hooks/useChildrenCounter.ts +50 -0
  113. package/src/hooks/useControlledState.ts +37 -0
  114. package/src/hooks/useFocusReturn.ts +23 -0
  115. package/src/hooks/useFocusState.ts +28 -0
  116. package/src/hooks/useGestureHandlers.ts +217 -0
  117. package/src/hooks/useId.ts +18 -0
  118. package/src/hooks/useMeasure.ts +33 -0
  119. package/src/hooks/useOnClickOutside.ts +32 -0
  120. package/src/hooks/useOnKeyDown.ts +18 -0
  121. package/src/hooks/useReducerMachine.ts +59 -0
  122. package/src/hooks/useRemoveBodyScroll.ts +37 -0
  123. package/src/hooks/useScope.ts +51 -0
  124. package/src/hooks/useThrottle.ts +19 -0
  125. package/src/index.ts +19 -0
  126. package/src/utils/assignRef.ts +27 -0
  127. package/src/utils/clamp.ts +3 -0
  128. package/src/utils/createSubscription.ts +16 -0
  129. package/src/utils/getCircularIndex.ts +7 -0
  130. package/src/utils/index.ts +4 -0
  131. package/src/utils/rubberBandClamp.ts +25 -0
  132. package/src/utils/wrapEvent.ts +20 -0
@@ -0,0 +1,165 @@
1
+ import { forwardRef, useRef, useState, useEffect } from 'react';
2
+ import type * as React from 'react';
3
+ import { wrapEvent, assignMultipleRefs, getCircularIndex } from '../utils';
4
+ import { useAccordionContext, useAccordionItemContext } from './context';
5
+ import { headerScopeQuery as scopeQuery } from './scopeQuery';
6
+
7
+ export interface AccordionHeaderProps
8
+ extends React.HTMLAttributes<HTMLDivElement> {
9
+ as?: React.ElementType<any>;
10
+ innerAs?: React.ElementType<any>;
11
+ children?: React.ReactNode;
12
+ }
13
+
14
+ export const AccordionHeader = forwardRef<HTMLDivElement, AccordionHeaderProps>(
15
+ function AccordionHeader(props, forwardedRef) {
16
+ const {
17
+ as: Comp = 'div',
18
+ onKeyDown,
19
+ onClick: onClickProp,
20
+ onFocus,
21
+ onBlur,
22
+ ...otherProps
23
+ } = props;
24
+ const accordionContext = useAccordionContext();
25
+ const accordionItemContext = useAccordionItemContext();
26
+ const ref = useRef<HTMLDivElement>();
27
+ const [index, setIndex] = useState<number | undefined>();
28
+
29
+ if (!accordionItemContext) {
30
+ throw new Error('Missing parent <Accordion /> component');
31
+ }
32
+
33
+ useEffect(() => {
34
+ if (accordionContext) {
35
+ const allHeaders =
36
+ accordionContext.scope.current.queryAllNodes(scopeQuery) || [];
37
+
38
+ const index = allHeaders.findIndex((e) => e === ref.current);
39
+ setIndex(index);
40
+ }
41
+ }, [accordionContext]);
42
+
43
+ const onClick = wrapEvent(
44
+ onClickProp,
45
+ (e: React.MouseEvent<HTMLDivElement>) => {
46
+ let index = 0;
47
+ if (accordionItemContext.expanded) {
48
+ index = -1;
49
+ } else if (accordionContext) {
50
+ const allHeaders =
51
+ accordionContext.scope.current.queryAllNodes(scopeQuery) || [];
52
+
53
+ index = allHeaders.findIndex((e) => e === ref.current);
54
+ if (index === accordionContext.expandedIndex) {
55
+ index = -1;
56
+ }
57
+ accordionContext.onChange && accordionContext.onChange(e, index);
58
+ }
59
+
60
+ accordionItemContext.onChange &&
61
+ accordionItemContext.onChange(e, index >= 0);
62
+ }
63
+ );
64
+
65
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
66
+ switch (e.key) {
67
+ case 'Enter':
68
+ case ' ': {
69
+ onClick((e as unknown) as React.MouseEvent<HTMLDivElement>);
70
+ e.preventDefault();
71
+ break;
72
+ }
73
+ case 'ArrowUp':
74
+ case 'ArrowDown':
75
+ case 'Home':
76
+ case 'End': {
77
+ if (!accordionContext) {
78
+ return;
79
+ }
80
+ const allHeaders = accordionContext.scope.current.queryAllNodes(
81
+ scopeQuery
82
+ );
83
+
84
+ e.preventDefault();
85
+
86
+ if (allHeaders.length === 0) {
87
+ return;
88
+ }
89
+
90
+ let nextIndex = allHeaders.findIndex((e) => e === ref.current);
91
+ switch (e.key) {
92
+ case 'ArrowUp':
93
+ nextIndex += -1;
94
+ break;
95
+ case 'ArrowDown':
96
+ nextIndex += +1;
97
+ break;
98
+ case 'Home':
99
+ nextIndex = 0;
100
+ break;
101
+ case 'End':
102
+ nextIndex = -1;
103
+ break;
104
+ }
105
+
106
+ // We're sure it will not be null, because we already checked for allHeaders.length > 0 above
107
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
108
+ nextIndex = getCircularIndex(nextIndex, allHeaders.length)!;
109
+ allHeaders[nextIndex] && allHeaders[nextIndex].focus();
110
+ break;
111
+ }
112
+ default:
113
+ return;
114
+ }
115
+ };
116
+
117
+ const handleFocus = () => {
118
+ if (accordionContext) {
119
+ if (!accordionContext.childrenHeaderHasFocus) {
120
+ // this is needed to avoid rerendering the parent and
121
+ // messing up with the internal count for children/parent count
122
+ accordionContext.setChildrenHeaderHasFocus(true);
123
+ }
124
+ }
125
+ };
126
+
127
+ const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
128
+ if (accordionContext) {
129
+ const allHeaders = accordionContext.scope.current.queryAllNodes(
130
+ scopeQuery
131
+ );
132
+ const newFocusIsHeader =
133
+ allHeaders.findIndex((header) => header === e.relatedTarget) >= 0;
134
+
135
+ // only remove focus flag if the focus went to some element
136
+ // that is not an accordion header
137
+ if (!newFocusIsHeader) {
138
+ accordionContext.setChildrenHeaderHasFocus(false);
139
+ }
140
+ }
141
+ };
142
+
143
+ const expanded = Boolean(
144
+ accordionItemContext.expanded ||
145
+ (accordionContext && accordionContext.expandedIndex === index)
146
+ );
147
+
148
+ return (
149
+ <Comp
150
+ ref={assignMultipleRefs(ref, forwardedRef)}
151
+ {...otherProps}
152
+ id={accordionItemContext.headerId}
153
+ aria-controls={accordionItemContext.bodyId}
154
+ role="button"
155
+ data-accordion-header=""
156
+ tabIndex="0"
157
+ onKeyDown={wrapEvent(onKeyDown, handleKeyDown)}
158
+ onFocus={wrapEvent(onFocus, handleFocus)}
159
+ onBlur={wrapEvent(onBlur, handleBlur)}
160
+ onClick={onClick}
161
+ aria-expanded={String(expanded)}
162
+ />
163
+ );
164
+ }
165
+ );
@@ -0,0 +1,43 @@
1
+ import { Fragment, forwardRef } from 'react';
2
+ import type * as React from 'react';
3
+ import { AccordionItemContextProps, AccordionItemProvider } from './context';
4
+ import { useId } from '../hooks';
5
+
6
+ export interface AccordionItemProps
7
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
8
+ as?: React.ElementType<any>;
9
+ innerAs?: React.ElementType<any>;
10
+ children?: React.ReactNode;
11
+ expanded?: boolean;
12
+ onChange?: (
13
+ e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>,
14
+ value: boolean
15
+ ) => void;
16
+ }
17
+
18
+ export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
19
+ function AccordionItem(props, forwardedRef) {
20
+ const {
21
+ as: Comp = Fragment,
22
+ expanded = false,
23
+ onChange,
24
+ ...otherProps
25
+ } = props;
26
+ const id = useId();
27
+
28
+ const headerId = id ? `accordion-header-${id}` : undefined;
29
+ const bodyId = id ? `accordion-body-${id}` : undefined;
30
+ const contextValue: AccordionItemContextProps = {
31
+ headerId,
32
+ bodyId,
33
+ expanded,
34
+ onChange,
35
+ };
36
+
37
+ return (
38
+ <AccordionItemProvider value={contextValue}>
39
+ <Comp ref={forwardedRef} {...otherProps} />
40
+ </AccordionItemProvider>
41
+ );
42
+ }
43
+ );
@@ -0,0 +1,35 @@
1
+ import { useContext, createContext } from 'react';
2
+ import { Scope } from '../hooks/useScope';
3
+
4
+ // AccordionGroup Component
5
+ export interface AccordionContextProps {
6
+ childrenHeaderHasFocus: boolean;
7
+ setChildrenHeaderHasFocus: (value: boolean) => void;
8
+ scope: Scope<HTMLElement>;
9
+ expandedIndex: number;
10
+ onChange?: (
11
+ e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>,
12
+ index: number
13
+ ) => void;
14
+ }
15
+
16
+ const accordionContext = createContext<AccordionContextProps | null>(null);
17
+ export const { Provider: AccordionProvider } = accordionContext;
18
+ export const useAccordionContext = () => useContext(accordionContext);
19
+
20
+ // Accordion Component
21
+ export interface AccordionItemContextProps {
22
+ headerId: string | undefined;
23
+ bodyId: string | undefined;
24
+ expanded: boolean;
25
+ onChange?: (
26
+ e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>,
27
+ value: boolean
28
+ ) => void;
29
+ }
30
+
31
+ const accordionItemContext = createContext<AccordionItemContextProps | null>(
32
+ null
33
+ );
34
+ export const { Provider: AccordionItemProvider } = accordionItemContext;
35
+ export const useAccordionItemContext = () => useContext(accordionItemContext);
@@ -0,0 +1,4 @@
1
+ export * from './Accordion';
2
+ export * from './AccordionItem';
3
+ export * from './AccordionHeader';
4
+ export * from './AccordionBody';
@@ -0,0 +1,7 @@
1
+ export function headerScopeQuery(type: string, props: Record<string, unknown>) {
2
+ return props['data-accordion-header'] === '';
3
+ }
4
+
5
+ export function bodyScopeQuery(type: string, props: Record<string, unknown>) {
6
+ return props['data-accordion-body'] === '';
7
+ }
@@ -0,0 +1,21 @@
1
+ [data-accordion-root] {
2
+ box-sizing: border-box;
3
+ max-width: 300px;
4
+ padding: 32px;
5
+ border: solid 1px #aaa;
6
+ margin: 32px;
7
+ }
8
+ [data-accordion-root][data-children-has-focus='true'] {
9
+ border-color: red;
10
+ }
11
+
12
+ [data-accordion-body]:not([hidden]) {
13
+ box-sizing: border-box;
14
+ padding: 16px 0;
15
+ display: flex;
16
+ flex-direction: column;
17
+ }
18
+
19
+ [data-accordion-header] {
20
+ cursor: pointer;
21
+ }
@@ -0,0 +1,41 @@
1
+ import { forwardRef } from 'react';
2
+ import type * as React from 'react';
3
+ import { useControlledState } from '../hooks';
4
+
5
+ export interface CheckBoxProps
6
+ extends React.InputHTMLAttributes<HTMLInputElement> {
7
+ as?: React.ElementType<any>;
8
+ innerAs?: React.ElementType<any>;
9
+ children?: React.ReactNode;
10
+ }
11
+
12
+ export const CheckBox = forwardRef<HTMLInputElement, CheckBoxProps>(
13
+ function CheckBox(props, forwardedRef) {
14
+ const {
15
+ as: Comp = 'input',
16
+ checked: checkedProp,
17
+ defaultChecked = false,
18
+ onChange: onChangeProp,
19
+ ...otherProps
20
+ } = props;
21
+ const [checked, onChange] = useControlledState(
22
+ checkedProp,
23
+ onChangeProp,
24
+ defaultChecked,
25
+ (setValue) => (e) => {
26
+ setValue(e.target.checked);
27
+ }
28
+ );
29
+
30
+ return (
31
+ <Comp
32
+ ref={forwardedRef}
33
+ type="checkbox"
34
+ checked={checked}
35
+ aria-checked={checked}
36
+ onChange={onChange}
37
+ {...otherProps}
38
+ />
39
+ );
40
+ }
41
+ );
@@ -0,0 +1 @@
1
+ export * from './CheckBox';
@@ -0,0 +1,118 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type * as React from 'react';
3
+ import { storiesOf } from '@storybook/react';
4
+ import { ComboboxOption } from './ComboboxOption';
5
+ import { ComboboxList } from './ComboboxList';
6
+ import { ComboboxPopover } from './ComboboxPopover';
7
+ import { ComboboxInput } from './ComboboxInput';
8
+ import { ComboboxLabel } from './ComboboxLabel';
9
+ import { Combobox } from './Combobox';
10
+ import cities from './cities';
11
+ import './styles.css';
12
+
13
+ const stories = storiesOf('Components/Combobox', module);
14
+
15
+ function useCityMatch(searchTerm: string) {
16
+ return useMemo(() => {
17
+ const term = searchTerm.trim().toLowerCase();
18
+ return term === ''
19
+ ? []
20
+ : cities.filter(
21
+ (city) =>
22
+ city.city.toLowerCase().indexOf(term) !== -1 ||
23
+ city.state.toLowerCase().indexOf(term) !== -1
24
+ );
25
+ }, [searchTerm]);
26
+ }
27
+
28
+ export function UncontrolledClientSideExample({ initialValue = '' }) {
29
+ const [term, setTerm] = useState(initialValue);
30
+ const [selected, setSelected] = useState(initialValue);
31
+ const results = useCityMatch(term);
32
+
33
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
34
+ setTerm(event.target.value);
35
+ };
36
+
37
+ const handleSelect = (value: string) => {
38
+ setSelected(value);
39
+ };
40
+
41
+ return (
42
+ <div>
43
+ <h2>Clientside Search</h2>
44
+ <p>Selection: {selected}</p>
45
+ <p>Term: {term}</p>
46
+ <Combobox onSelect={handleSelect} selectOnBlur>
47
+ <ComboboxLabel>Enter a city name</ComboboxLabel>
48
+ <br />
49
+ <ComboboxInput onChange={handleChange} defaultValue={initialValue} />
50
+ {results.length > 0 && (
51
+ <ComboboxPopover>
52
+ <ComboboxList persistSelection={true}>
53
+ {results.slice(0, 10).map((result, index) => (
54
+ <ComboboxOption
55
+ key={`${result.city}, ${result.state}, ${index}`}
56
+ id={`${result.city}, ${result.state}, ${index}`}
57
+ text={`${result.city}, ${result.state}`}
58
+ value={result}
59
+ />
60
+ ))}
61
+ </ComboboxList>
62
+ </ComboboxPopover>
63
+ )}
64
+ </Combobox>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export function ControlledClientSideExample({ initialValue = '' }) {
70
+ const [term, setTerm] = useState(initialValue);
71
+ const [selected, setSelected] = useState(initialValue);
72
+ const results = useCityMatch(term);
73
+
74
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
75
+ setTerm(event.target.value);
76
+ setSelected('');
77
+ };
78
+
79
+ const handleSelect = (value: string) => {
80
+ setSelected(value);
81
+ };
82
+
83
+ return (
84
+ <div>
85
+ <h2>Clientside Search</h2>
86
+ <p>Selection: {selected}</p>
87
+ <p>Term: {term}</p>
88
+ <Combobox onSelect={handleSelect} selectOnBlur>
89
+ <ComboboxLabel>Enter a city name</ComboboxLabel>
90
+ <br />
91
+ <ComboboxInput onChange={handleChange} value={selected || term} />
92
+ {results.length > 0 && (
93
+ <ComboboxPopover>
94
+ <ComboboxList persistSelection={true}>
95
+ {results.slice(0, 10).map((result, index) => (
96
+ <ComboboxOption
97
+ key={`${result.city}, ${result.state}, ${index}`}
98
+ id={`${result.city}, ${result.state}, ${index}`}
99
+ text={`${result.city}, ${result.state}`}
100
+ value={result}
101
+ />
102
+ ))}
103
+ </ComboboxList>
104
+ </ComboboxPopover>
105
+ )}
106
+ </Combobox>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ stories.add('Uncontrolled Clientside', () => <UncontrolledClientSideExample />);
112
+ stories.add('Uncontrolled Clientside - Initial', () => (
113
+ <UncontrolledClientSideExample initialValue="Aberdeen" />
114
+ ));
115
+ stories.add('Controlled Clientside', () => <ControlledClientSideExample />);
116
+ stories.add('Controlled Clientside - Initial', () => (
117
+ <ControlledClientSideExample initialValue="Aberdeen" />
118
+ ));
@@ -0,0 +1,153 @@
1
+ import { forwardRef, useRef, useMemo } from 'react';
2
+ import type * as React from 'react';
3
+ import {
4
+ stateChart,
5
+ comboboxReducer,
6
+ isVisible,
7
+ INIT,
8
+ ActionTypes,
9
+ } from './hooks';
10
+ import { ComboBoxProvider, ComboBoxContextProps } from './context';
11
+ import { useReducerMachine } from '../hooks/useReducerMachine';
12
+ import { useId, Scope, getScope } from '../hooks';
13
+
14
+ export type SelectEventHandler = (
15
+ text: string,
16
+ itemId: string,
17
+ obj: any
18
+ ) => void;
19
+
20
+ export type ComboboxProps = Omit<
21
+ React.HTMLAttributes<HTMLDivElement>,
22
+ 'onSelect'
23
+ > & {
24
+ onSelect?: SelectEventHandler;
25
+ openOnFocus?: boolean;
26
+ selectOnBlur?: boolean;
27
+ children?: React.ReactNode;
28
+ as?: React.ElementType<any>;
29
+ innerAs?: React.ElementType<any>;
30
+ };
31
+
32
+ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
33
+ function Combobox(
34
+ {
35
+ // Called whenever the user selects an item from the list
36
+ onSelect,
37
+
38
+ // opens the list when the input receives focused (but only if there are
39
+ // items in the list)
40
+ openOnFocus = false,
41
+
42
+ // if set to true, it will select an item after blurring
43
+ selectOnBlur = false,
44
+
45
+ children,
46
+ as: Comp = 'div',
47
+ innerAs,
48
+ ...rest
49
+ }: ComboboxProps,
50
+ ref
51
+ ) {
52
+ // We store the values of all the ComboboxOptions on this ref. This makes it
53
+ // possible to perform the keyboard navigation from the input on the list. We
54
+ // manipulate this array through context so that we don't have to enforce a
55
+ // parent/child relationship between ComboboxList and ComboboxOption.
56
+ const optionsRef = useRef<{
57
+ [itemId: string]: {
58
+ value: string | unknown;
59
+ text: string;
60
+ };
61
+ }>({});
62
+
63
+ const listScope: Scope<HTMLElement> = useRef(getScope({ current: null }));
64
+
65
+ // Need this to focus it
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+
68
+ const popoverRef = useRef<HTMLDivElement>(null);
69
+
70
+ const buttonRef = useRef<HTMLButtonElement>(null);
71
+
72
+ // When <ComboboxInput autocomplete={false} /> we don't want cycle back to
73
+ // the user's value while navigating (because it's always the user's value),
74
+ // but we need to know this in useKeyDown which is far away from the prop
75
+ // here, so we do something sneaky and write it to this ref on context so we
76
+ // can use it anywhere else 😛. Another new trick for me and I'm excited
77
+ // about this one too!
78
+ const autocompletePropRef = useRef<boolean>(false);
79
+
80
+ const persistSelectionRef = useRef<boolean>(false);
81
+
82
+ const clearOnEscapeRef = useRef<boolean>(false);
83
+
84
+ const listboxIdRef = useRef<string | undefined>();
85
+
86
+ const labelIdRef = useRef<string | undefined>();
87
+
88
+ const defaultData = {
89
+ // initial state
90
+ state: stateChart.initial,
91
+ // the value the user has typed, we derived this also when the developer is
92
+ // controlling the value of ComboboxInput
93
+ text: '',
94
+ // the index the user has typed, we derived this also when the developer is
95
+ // controlling the value of ComboboxInput
96
+ item: '',
97
+ // the hash of the currently navigated item
98
+ navigationItem: '',
99
+ // the last submitted action
100
+ lastActionType: INIT as ActionTypes,
101
+ };
102
+
103
+ const [state, data, transition] = useReducerMachine(
104
+ stateChart,
105
+ comboboxReducer,
106
+ defaultData
107
+ );
108
+
109
+ listboxIdRef.current = useId();
110
+
111
+ labelIdRef.current = useId();
112
+
113
+ const context = useMemo(
114
+ (): ComboBoxContextProps => ({
115
+ data,
116
+ inputRef,
117
+ popoverRef,
118
+ buttonRef,
119
+ onSelect,
120
+ optionsRef,
121
+ listScope,
122
+ state,
123
+ transition,
124
+ listboxIdRef,
125
+ labelIdRef,
126
+ autocompletePropRef,
127
+ persistSelectionRef,
128
+ clearOnEscapeRef,
129
+ isVisible: isVisible(state),
130
+ openOnFocus,
131
+ selectOnBlur,
132
+ }),
133
+ [data, onSelect, listScope, state, transition, openOnFocus, selectOnBlur]
134
+ );
135
+
136
+ return (
137
+ <ComboBoxProvider value={context}>
138
+ <Comp
139
+ {...rest}
140
+ as={innerAs}
141
+ data-reach-combobox=""
142
+ ref={ref}
143
+ role="combobox"
144
+ aria-haspopup="listbox"
145
+ aria-owns={listboxIdRef.current}
146
+ aria-expanded={context.isVisible}
147
+ >
148
+ {children}
149
+ </Comp>
150
+ </ComboBoxProvider>
151
+ );
152
+ }
153
+ );
@@ -0,0 +1,60 @@
1
+ import { forwardRef } from 'react';
2
+ import type * as React from 'react';
3
+ import { useComboBoxContext } from './context';
4
+ import { useKeyDown, IDLE, OPEN_WITH_BUTTON, CLOSE_WITH_BUTTON } from './hooks';
5
+ import { wrapEvent } from '../utils/wrapEvent';
6
+ import { assignMultipleRefs } from '../utils/assignRef';
7
+
8
+ export interface ComboboxButtonProps
9
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
10
+ as?: React.ElementType<any>;
11
+ innerAs?: React.ElementType<any>;
12
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
13
+ onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
14
+ children?: React.ReactNode;
15
+ }
16
+
17
+ export const ComboboxButton = forwardRef<
18
+ HTMLButtonElement,
19
+ ComboboxButtonProps
20
+ >(function ComboboxButton(
21
+ { as: Comp = 'button', innerAs, onClick, onKeyDown, ...props },
22
+ ref
23
+ ) {
24
+ const {
25
+ transition,
26
+ data,
27
+ state,
28
+ buttonRef,
29
+ listboxIdRef,
30
+ isVisible,
31
+ } = useComboBoxContext();
32
+
33
+ const handleKeyDown = useKeyDown();
34
+
35
+ const handleClick = () => {
36
+ const payload = {
37
+ item: data.navigationItem,
38
+ };
39
+
40
+ if (state === IDLE) {
41
+ transition(OPEN_WITH_BUTTON, payload);
42
+ } else {
43
+ transition(CLOSE_WITH_BUTTON, payload);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <Comp
49
+ as={innerAs}
50
+ data-reach-combobox-button=""
51
+ aria-controls={listboxIdRef.current}
52
+ aria-haspopup="listbox"
53
+ aria-expanded={isVisible}
54
+ ref={assignMultipleRefs(ref, buttonRef)}
55
+ onClick={wrapEvent(onClick, handleClick)}
56
+ onKeyDown={wrapEvent(onKeyDown, handleKeyDown)}
57
+ {...props}
58
+ />
59
+ );
60
+ });