Skip to content

Commit 1239b63

Browse files
feat(js): sync detached mode open state (#556)
1 parent 32678dd commit 1239b63

File tree

4 files changed

+145
-32
lines changed

4 files changed

+145
-32
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { fireEvent, waitFor } from '@testing-library/dom';
2+
3+
import { autocomplete } from '../autocomplete';
4+
5+
describe('detached', () => {
6+
const originalMatchMedia = window.matchMedia;
7+
8+
beforeAll(() => {
9+
Object.defineProperty(window, 'matchMedia', {
10+
writable: true,
11+
value: jest.fn((query) => ({
12+
matches: true,
13+
media: query,
14+
onchange: null,
15+
addListener: jest.fn(),
16+
removeListener: jest.fn(),
17+
addEventListener: jest.fn(),
18+
removeEventListener: jest.fn(),
19+
dispatchEvent: jest.fn(),
20+
})),
21+
});
22+
});
23+
24+
afterAll(() => {
25+
Object.defineProperty(window, 'matchMedia', {
26+
writable: true,
27+
value: originalMatchMedia,
28+
});
29+
});
30+
31+
test('closes after onSelect', async () => {
32+
const container = document.createElement('div');
33+
document.body.appendChild(container);
34+
autocomplete<{ label: string }>({
35+
id: 'autocomplete',
36+
detachedMediaQuery: '',
37+
container,
38+
getSources() {
39+
return [
40+
{
41+
sourceId: 'testSource',
42+
getItems() {
43+
return [
44+
{ label: 'Item 1' },
45+
{ label: 'Item 2' },
46+
{ label: 'Item 3' },
47+
];
48+
},
49+
templates: {
50+
item({ item }) {
51+
return item.label;
52+
},
53+
},
54+
},
55+
];
56+
},
57+
});
58+
59+
const searchButton = container.querySelector<HTMLButtonElement>(
60+
'.aa-DetachedSearchButton'
61+
);
62+
63+
// Open detached overlay
64+
searchButton.click();
65+
66+
await waitFor(() => {
67+
const input = document.querySelector<HTMLInputElement>('.aa-Input');
68+
69+
expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument();
70+
expect(document.body).toHaveClass('aa-Detached');
71+
expect(input).toHaveFocus();
72+
73+
fireEvent.input(input, { target: { value: 'a' } });
74+
});
75+
76+
// Wait for the panel to open
77+
await waitFor(() => {
78+
expect(
79+
document.querySelector<HTMLElement>('.aa-Panel')
80+
).toBeInTheDocument();
81+
});
82+
83+
const firstItem = document.querySelector<HTMLLIElement>(
84+
'#autocomplete-item-0'
85+
);
86+
87+
// Select the first item
88+
firstItem.click();
89+
90+
// The detached overlay should close
91+
await waitFor(() => {
92+
expect(
93+
document.querySelector('.aa-DetachedOverlay')
94+
).not.toBeInTheDocument();
95+
expect(document.body).not.toHaveClass('aa-Detached');
96+
});
97+
});
98+
});

packages/autocomplete-js/src/autocomplete.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,21 @@ export function autocomplete<TItem extends BaseItem>(
4646
const autocomplete = reactive(() =>
4747
createAutocomplete<TItem>({
4848
...props.value.core,
49-
onStateChange(options) {
50-
hasNoResultsSourceTemplateRef.current = options.state.collections.some(
49+
onStateChange(params) {
50+
hasNoResultsSourceTemplateRef.current = params.state.collections.some(
5151
(collection) =>
5252
(collection.source as AutocompleteSource<TItem>).templates.noResults
5353
);
54-
onStateChangeRef.current?.(options as any);
55-
props.value.core.onStateChange?.(options as any);
54+
onStateChangeRef.current?.(params as any);
55+
props.value.core.onStateChange?.(params as any);
5656
},
5757
shouldPanelOpen:
5858
optionsRef.current.shouldPanelOpen ||
5959
(({ state }) => {
60+
if (isDetached.value) {
61+
return true;
62+
}
63+
6064
const hasItems = getItemsCount(state) > 0;
6165

6266
if (!props.value.core.openOnFocus && !state.query) {
@@ -111,6 +115,7 @@ export function autocomplete<TItem extends BaseItem>(
111115
isDetached: isDetached.value,
112116
placeholder: props.value.core.placeholder,
113117
propGetters,
118+
setIsModalOpen,
114119
state: lastStateRef.current,
115120
})
116121
);
@@ -188,7 +193,7 @@ export function autocomplete<TItem extends BaseItem>(
188193
: dom.value.panel;
189194

190195
if (isDetached.value && lastStateRef.current.isOpen) {
191-
dom.value.openDetachedOverlay();
196+
setIsModalOpen(true);
192197
}
193198

194199
scheduleRender(lastStateRef.current);
@@ -217,11 +222,15 @@ export function autocomplete<TItem extends BaseItem>(
217222
}, 0);
218223

219224
onStateChangeRef.current = ({ state, prevState }) => {
225+
if (isDetached.value && prevState.isOpen !== state.isOpen) {
226+
setIsModalOpen(state.isOpen);
227+
}
228+
220229
// The outer DOM might have changed since the last time the panel was
221230
// positioned. The layout might have shifted vertically for instance.
222231
// It's therefore safer to re-calculate the panel position before opening
223232
// it again.
224-
if (state.isOpen && !prevState.isOpen) {
233+
if (!isDetached.value && state.isOpen && !prevState.isOpen) {
225234
setPanelPosition();
226235
}
227236

@@ -325,6 +334,27 @@ export function autocomplete<TItem extends BaseItem>(
325334
});
326335
}
327336

337+
function setIsModalOpen(value: boolean) {
338+
requestAnimationFrame(() => {
339+
const prevValue = document.body.contains(dom.value.detachedOverlay);
340+
341+
if (value === prevValue) {
342+
return;
343+
}
344+
345+
if (value) {
346+
document.body.appendChild(dom.value.detachedOverlay);
347+
document.body.classList.add('aa-Detached');
348+
dom.value.input.focus();
349+
} else {
350+
document.body.removeChild(dom.value.detachedOverlay);
351+
document.body.classList.remove('aa-Detached');
352+
autocomplete.value.setQuery('');
353+
autocomplete.value.refresh();
354+
}
355+
});
356+
}
357+
328358
return {
329359
...autocompleteScopeApi,
330360
update,

packages/autocomplete-js/src/createAutocompleteDom.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,20 @@ type CreateDomProps<TItem extends BaseItem> = {
2121
isDetached: boolean;
2222
placeholder?: string;
2323
propGetters: AutocompletePropGetters<TItem>;
24+
setIsModalOpen(value: boolean): void;
2425
state: AutocompleteState<TItem>;
2526
};
2627

27-
type CreateAutocompleteDomReturn = AutocompleteDom & {
28-
openDetachedOverlay(): void;
29-
};
30-
3128
export function createAutocompleteDom<TItem extends BaseItem>({
3229
autocomplete,
3330
autocompleteScopeApi,
3431
classNames,
3532
isDetached,
3633
placeholder = 'Search',
3734
propGetters,
35+
setIsModalOpen,
3836
state,
39-
}: CreateDomProps<TItem>): CreateAutocompleteDomReturn {
40-
function onDetachedOverlayClose() {
41-
autocomplete.setQuery('');
42-
autocomplete.setIsOpen(false);
43-
autocomplete.refresh();
44-
document.body.classList.remove('aa-Detached');
45-
}
46-
37+
}: CreateDomProps<TItem>): AutocompleteDom {
4738
const rootProps = propGetters.getRootProps({
4839
state,
4940
props: autocomplete.getRootProps({}),
@@ -63,8 +54,8 @@ export function createAutocompleteDom<TItem extends BaseItem>({
6354
class: classNames.detachedOverlay,
6455
children: [detachedContainer],
6556
onMouseDown() {
66-
document.body.removeChild(detachedOverlay);
67-
onDetachedOverlayClose();
57+
setIsModalOpen(false);
58+
autocomplete.setIsOpen(false);
6859
},
6960
});
7061

@@ -103,8 +94,8 @@ export function createAutocompleteDom<TItem extends BaseItem>({
10394
autocompleteScopeApi,
10495
onDetachedEscape: isDetached
10596
? () => {
106-
document.body.removeChild(detachedOverlay);
107-
onDetachedOverlayClose();
97+
autocomplete.setIsOpen(false);
98+
setIsModalOpen(false);
10899
}
109100
: undefined,
110101
});
@@ -148,12 +139,6 @@ export function createAutocompleteDom<TItem extends BaseItem>({
148139
});
149140
}
150141

151-
function openDetachedOverlay() {
152-
document.body.appendChild(detachedOverlay);
153-
document.body.classList.add('aa-Detached');
154-
input.focus();
155-
}
156-
157142
if (isDetached) {
158143
const detachedSearchButtonIcon = createDomElement('div', {
159144
class: classNames.detachedSearchButtonIcon,
@@ -167,16 +152,16 @@ export function createAutocompleteDom<TItem extends BaseItem>({
167152
class: classNames.detachedSearchButton,
168153
onClick(event: MouseEvent) {
169154
event.preventDefault();
170-
openDetachedOverlay();
155+
setIsModalOpen(true);
171156
},
172157
children: [detachedSearchButtonIcon, detachedSearchButtonPlaceholder],
173158
});
174159
const detachedCancelButton = createDomElement('button', {
175160
class: classNames.detachedCancelButton,
176161
textContent: 'Cancel',
177162
onClick() {
178-
document.body.removeChild(detachedOverlay);
179-
onDetachedOverlayClose();
163+
autocomplete.setIsOpen(false);
164+
setIsModalOpen(false);
180165
},
181166
});
182167
const detachedFormContainer = createDomElement('div', {
@@ -191,7 +176,6 @@ export function createAutocompleteDom<TItem extends BaseItem>({
191176
}
192177

193178
return {
194-
openDetachedOverlay,
195179
detachedContainer,
196180
detachedOverlay,
197181
inputWrapper,

packages/autocomplete-js/src/elements/Input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const Input: AutocompleteElement<InputProps, HTMLInputElement> = ({
3737
...inputProps,
3838
onKeyDown(event: KeyboardEvent) {
3939
if (onDetachedEscape && event.key === 'Escape') {
40+
event.preventDefault();
4041
onDetachedEscape();
4142
return;
4243
}

0 commit comments

Comments
 (0)