Skip to content

Commit 77e6094

Browse files
authored
feat: migrate to cosmoz-dropdown-next and keybindings (#223)
* feat: migrate to cosmoz-dropdown-next Migrate from cosmoz-dropdown utilities (useFocus, useFloating) to cosmoz-dropdown-next with native CSS Anchor Positioning and Popover API. Migrate from global document keydown listeners to the useActivity() keybindings system from @neovici/cosmoz-utils. - Replace useFocus/useFloating with cosmoz-dropdown-next open-on-focus - Sync open/close state via composed dropdown-toggle event - Replace global keydown listeners with useActivity() hooks - Export autocompleteKeybindings (includes listbox + Backspace bindings) - Export listboxKeybindings and LISTBOX_NAVIGATE_UP, LISTBOX_NAVIGATE_DOWN, LISTBOX_SELECT from new ./listbox/keybindings entry point - Export AUTOCOMPLETE_DESELECT_LAST from ./keybindings entry point - Add delegatesFocus to autocomplete and autocomplete-excluding - Remove connectable wrapper, hidden workaround, use-keys.ts - Bump cosmoz-dropdown to ^7.3.0, cosmoz-input to ^5.6.0 Closes NEO-916 BREAKING CHANGE: Keyboard navigation and selection now use the useActivity() keybindings system instead of global document keydown listeners. Consumers must register autocompleteKeybindings (or the individual listboxKeybindings + autocomplete bindings) via useKeybindings() at the app level for keyboard interaction to work. * test: add KeyboardFullCycle story for virtualized scroll navigation * test: force e2e snapshot failure to validate CI * Revert "test: force e2e snapshot failure to validate CI" This reverts commit 00e8a27.
1 parent 3ff72f5 commit 77e6094

File tree

17 files changed

+238
-296
lines changed

17 files changed

+238
-296
lines changed

.storybook/preview.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { html } from '@pionjs/pion';
1+
import { useKeybindings } from '@neovici/cosmoz-utils/keybindings';
2+
import { component, html } from '@pionjs/pion';
23
import i18next from 'i18next';
34
import { within as withinShadow } from 'shadow-dom-testing-library';
5+
import { autocompleteKeybindings } from '../src/autocomplete/autocomplete-keybindings';
46

57
// Initialize i18next for the autocomplete component
68
i18next.init({
@@ -14,6 +16,24 @@ i18next.init({
1416
},
1517
});
1618

19+
/**
20+
* Component that provides keybindings context for all stories.
21+
* Uses children prop instead of slot since shadow DOM is disabled
22+
* to allow context events to bubble up to the provider.
23+
*/
24+
customElements.define(
25+
'storybook-keybindings',
26+
component(
27+
(props) => {
28+
const register = useKeybindings(autocompleteKeybindings);
29+
return html`<cosmoz-keybinding-provider .value=${register}>
30+
${props.content}
31+
</cosmoz-keybinding-provider>`;
32+
},
33+
{ useShadowDOM: false },
34+
),
35+
);
36+
1737
export default {
1838
tags: ['autodocs'],
1939
parameters: {
@@ -55,7 +75,9 @@ export default {
5575
font-family: 'Inter', sans-serif;
5676
}
5777
</style>
58-
<div class="story-root">${story()}</div>
78+
<storybook-keybindings
79+
.content=${html`<div class="story-root">${story()}</div>`}
80+
></storybook-keybindings>
5981
`;
6082
},
6183
],

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,15 @@
7272
"exports": {
7373
".": "./dist/index.js",
7474
"./autocomplete": "./dist/autocomplete/index.js",
75+
"./keybindings": "./dist/autocomplete/autocomplete-keybindings.js",
7576
"./listbox": "./dist/listbox/index.js",
77+
"./listbox/keybindings": "./dist/listbox/listbox-keybindings.js",
7678
"./item-renderer": "./dist/listbox/item-renderer.js"
7779
},
7880
"dependencies": {
7981
"@lit-labs/virtualizer": "^2.0.0",
80-
"@neovici/cosmoz-dropdown": "^6.0.0 || ^7.0.0",
81-
"@neovici/cosmoz-input": "^5.0.2",
82+
"@neovici/cosmoz-dropdown": "^7.3.0",
83+
"@neovici/cosmoz-input": "^5.6.0",
8284
"@neovici/cosmoz-utils": "^6.19.0",
8385
"@pionjs/pion": "^2.7.1",
8486
"i18next": "^23.16.8",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Activity, KeyBinding } from '@neovici/cosmoz-utils/keybindings';
2+
import { listboxKeybindings } from '../listbox/listbox-keybindings';
3+
4+
export const AUTOCOMPLETE_DESELECT_LAST: Activity = Symbol(
5+
'autocomplete.deselect.last',
6+
);
7+
8+
export const autocompleteKeybindings = [
9+
...listboxKeybindings,
10+
[
11+
{ key: 'Backspace' },
12+
[AUTOCOMPLETE_DESELECT_LAST],
13+
{
14+
title: 'Deselect last',
15+
description: 'Remove the last selected item',
16+
},
17+
{ allowInEditable: true },
18+
],
19+
] as const satisfies readonly KeyBinding[];

src/autocomplete/autocomplete.ts

Lines changed: 20 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
import {
2-
Placement,
3-
defaultMiddleware,
4-
size,
5-
useFloating,
6-
} from '@neovici/cosmoz-dropdown/use-floating';
1+
import '@neovici/cosmoz-dropdown/cosmoz-dropdown-next';
72
import '@neovici/cosmoz-input';
8-
import { useHost } from '@neovici/cosmoz-utils/hooks/use-host';
9-
import { useImperativeApi } from '@neovici/cosmoz-utils/hooks/use-imperative-api';
10-
import { useEffect } from '@pionjs/pion';
113
import { t } from 'i18next';
124
import { html } from 'lit-html';
135
import { live } from 'lit-html/directives/live.js';
14-
import { ref } from 'lit-html/directives/ref.js';
156
import { until } from 'lit-html/directives/until.js';
167
import { when } from 'lit-html/directives/when.js';
178
import { listbox } from '../listbox';
@@ -35,7 +26,6 @@ export interface Props<I> extends Base<I> {
3526
wrap?: boolean;
3627
defaultIndex?: number;
3728
externalSearch?: boolean;
38-
placement?: Placement;
3929
itemRenderer?: ItemRenderer<I>;
4030
chipRenderer?: ChipRenderer<I>;
4131
}
@@ -49,34 +39,23 @@ const inputParts = ['input', 'control', 'label', 'line', 'error', 'wrap']
4939
.map((part) => `${part}: input-${part}`)
5040
.join();
5141

52-
const middleware = [
53-
size({
54-
apply({ rects, elements }) {
55-
Object.assign(elements.floating.style, {
56-
minWidth: `${Math.max(rects.reference.width, rects.floating.width)}px`,
57-
});
58-
},
59-
}),
60-
...defaultMiddleware,
61-
];
62-
6342
const shouldShowDropdown = <I>({
64-
active,
43+
opened,
6544
isSingle,
6645
showSingle,
67-
}: Pick<AProps<I>, 'active' | 'showSingle'> & {
46+
hasResultsOrQuery,
47+
}: Pick<AProps<I>, 'opened' | 'showSingle'> & {
6848
isSingle: boolean;
49+
hasResultsOrQuery: boolean;
6950
}) => {
70-
if (!active) return false;
71-
72-
const disallowedSingle = isSingle && !showSingle;
73-
74-
return !disallowedSingle;
51+
if (!opened) return false;
52+
if (isSingle && !showSingle) return false;
53+
return hasResultsOrQuery;
7554
};
7655

7756
const autocomplete = <I>(props: AProps<I>) => {
7857
const {
79-
active,
58+
opened,
8059
invalid,
8160
errorMessage,
8261
label,
@@ -87,54 +66,31 @@ const autocomplete = <I>(props: AProps<I>) => {
8766
textual,
8867
text,
8968
onText,
90-
onFocus,
91-
onClick,
69+
onToggle,
9270
onDeselect,
9371
value,
9472
limit,
9573
min,
9674
showSingle,
9775
items,
9876
source$,
99-
placement,
10077
loading,
10178
chipRenderer,
10279
} = props,
103-
host = useHost(),
10480
isOne = limit == 1, // eslint-disable-line eqeqeq
10581
isSingle = isOne && value?.[0] != null;
10682

107-
const { styles, setReference, setFloating } = useFloating({
108-
placement,
109-
middleware,
110-
});
111-
112-
useEffect(() => {
113-
host.addEventListener('focusin', onFocus);
114-
host.addEventListener('focusout', onFocus);
115-
return () => {
116-
host.removeEventListener('focusin', onFocus);
117-
host.removeEventListener('focusout', onFocus);
118-
};
119-
}, [onFocus]);
120-
121-
useImperativeApi(
122-
{
123-
focus: () =>
124-
(
125-
host.shadowRoot?.querySelector('#input') as HTMLInputElement
126-
)?.focus(),
127-
},
128-
[],
129-
);
130-
13183
const hasResultsOrQuery =
13284
loading || items.length > 0 || (text != null && text.length > 0);
13385

134-
return html`<cosmoz-input
86+
return html`<cosmoz-dropdown-next
87+
open-on-focus
88+
@dropdown-toggle=${onToggle}
89+
>
90+
<cosmoz-input
91+
slot="button"
13592
id="input"
13693
part="input"
137-
${ref(setReference)}
13894
.label=${label}
13995
.placeholder=${isSingle ? undefined : placeholder}
14096
?no-label-float=${noLabelFloat}
@@ -157,7 +113,6 @@ const autocomplete = <I>(props: AProps<I>) => {
157113
)}
158114
.value=${live(text)}
159115
@value-changed=${onText}
160-
@click=${onClick}
161116
autocomplete="off"
162117
exportparts=${inputParts}
163118
?data-one=${isOne}
@@ -178,23 +133,17 @@ const autocomplete = <I>(props: AProps<I>) => {
178133
179134
${when(
180135
shouldShowDropdown({
181-
active,
136+
opened,
182137
isSingle,
183138
showSingle,
139+
hasResultsOrQuery,
184140
}),
185141
() =>
186142
listbox<I>(
187143
{
188144
...props,
189145
items,
190146
multi: !isOne,
191-
setFloating,
192-
styles: {
193-
...styles,
194-
// WORKAROUND: hide the listbox if there are no results, don't remove it from DOM
195-
// TODO: revert https://github.com/Neovici/cosmoz-autocomplete/pull/206 after https://github.com/pionjs/pion/issues/64 is fixed
196-
display: hasResultsOrQuery ? undefined : 'none',
197-
},
198147
},
199148
when(
200149
loading,
@@ -210,7 +159,8 @@ const autocomplete = <I>(props: AProps<I>) => {
210159
),
211160
),
212161
),
213-
)}`;
162+
)}
163+
</cosmoz-dropdown-next>`;
214164
},
215165
Autocomplete = <I>(props: Props<I>) => {
216166
const thru = {

src/autocomplete/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@ const Standalone = <I>(host: HTMLElement & Props<I>) => {
3333
};
3434

3535
const styleSheets = [sheet(style)];
36+
const shadowRootInit = { mode: 'open' as const, delegatesFocus: true };
3637

3738
customElements.define(
3839
'cosmoz-autocomplete-ui',
39-
component(Autocomplete, { observedAttributes, styleSheets }),
40+
component(Autocomplete, { observedAttributes, styleSheets, shadowRootInit }),
4041
);
4142
customElements.define(
4243
'cosmoz-autocomplete',
43-
component(Standalone, { observedAttributes, styleSheets }),
44+
component(Standalone, { observedAttributes, styleSheets, shadowRootInit }),
4445
);

src/autocomplete/styles.css.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export default css`
77
min-width: 35px;
88
}
99
10+
cosmoz-dropdown-next {
11+
display: block;
12+
}
13+
1014
cosmoz-input::part(control) {
1115
display: flex;
1216
gap: 4px;

0 commit comments

Comments
 (0)