Replace react-autocomplete (#1263)

## Summary
- switch InputAutocomplete to `downshift`
- remove obsolete `react-autocomplete` dependency
- document autocomplete change in the changelog

## Testing
- `npm run lint`
- `npm run build`


------
https://chatgpt.com/codex/tasks/task_e_68685a78c2d483319c068f813723c1a7

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Bart Louwers
2025-07-05 13:58:28 +02:00
committed by GitHub
parent c486aa2139
commit eb985f4d95
9 changed files with 204 additions and 116 deletions
+1
View File
@@ -14,6 +14,7 @@
- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry - When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry
- Remove react-autobind dependency - Remove react-autobind dependency
- Remove usage of legacy `childContextTypes` API - Remove usage of legacy `childContextTypes` API
- Replace react-autocomplete with Downshift in the autocomplete component
- _...Add new stuff here..._ - _...Add new stuff here..._
### 🐞 Bug fixes ### 🐞 Bug fixes
+8
View File
@@ -8,6 +8,7 @@ export default defineConfig({
exclude: "cypress/**/*.*", exclude: "cypress/**/*.*",
}, },
}, },
e2e: { e2e: {
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
// implement node event listeners here // implement node event listeners here
@@ -20,4 +21,11 @@ export default defineConfig({
openMode: 0, openMode: 0,
}, },
}, },
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
}); });
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import { mount } from 'cypress/react'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
/* eslint-disable @typescript-eslint/no-namespace */
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(<MyComponent />)
+30 -32
View File
@@ -22,6 +22,7 @@
"codemirror": "^5.65.19", "codemirror": "^5.65.19",
"color": "^5.0.0", "color": "^5.0.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
"downshift": "^9.0.9",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^25.3.1", "i18next": "^25.3.1",
@@ -46,7 +47,6 @@
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.1",
"react-aria-menubutton": "^7.0.3", "react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2", "react-aria-modal": "^5.0.2",
"react-autocomplete": "^1.8.1",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -82,7 +82,6 @@
"@types/react": "^18.2.67", "@types/react": "^18.2.67",
"@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-menubutton": "^6.2.14",
"@types/react-aria-modal": "^5.0.0", "@types/react-aria-modal": "^5.0.0",
"@types/react-autocomplete": "^1.8.11",
"@types/react-collapse": "^5.0.4", "@types/react-collapse": "^5.0.4",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
@@ -3114,16 +3113,6 @@
"focus-trap": "^7.4.3" "focus-trap": "^7.4.3"
} }
}, },
"node_modules/@types/react-autocomplete": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/@types/react-autocomplete/-/react-autocomplete-1.8.11.tgz",
"integrity": "sha512-JYaD/OGfVFMK5NaGOCAd25QRs4MEevapn38xvYWjwo5brxrUMo2PucYmShTfuTX99r80UtncCMDrFZ9MGIVyvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-collapse": { "node_modules/@types/react-collapse": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/react-collapse/-/react-collapse-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/react-collapse/-/react-collapse-5.0.4.tgz",
@@ -4690,6 +4679,12 @@
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true "dev": true
}, },
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -5211,11 +5206,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dom-scroll-into-view": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-1.0.1.tgz",
"integrity": "sha512-1Dmy6uH1vRcm2+Lvggyrlc04cMh+mr+VA+qcgs085hAEZp+v+6NT/xhRjfc6vRc7965sCSDdQcw063VkG+eNmQ=="
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -5281,6 +5271,28 @@
"url": "https://github.com/fb55/domutils?sponsor=1" "url": "https://github.com/fb55/domutils?sponsor=1"
} }
}, },
"node_modules/downshift": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz",
"integrity": "sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.5",
"compute-scroll-into-view": "^3.1.0",
"prop-types": "^15.8.1",
"react-is": "18.2.0",
"tslib": "^2.6.2"
},
"peerDependencies": {
"react": ">=16.12.0"
}
},
"node_modules/downshift/node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -10771,19 +10783,6 @@
"react-dom": ">=16.3.0" "react-dom": ">=16.3.0"
} }
}, },
"node_modules/react-autocomplete": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/react-autocomplete/-/react-autocomplete-1.8.1.tgz",
"integrity": "sha512-YQGVN5POdcI3G89wUVWnJhk9rLF6JeB6Ik6xnNpfvSMG4tJkksBzqOE4mkFNGqEz+2AaQw13xNmVXresg9E3zg==",
"dependencies": {
"dom-scroll-into-view": "1.0.1",
"prop-types": "^15.5.10"
},
"peerDependencies": {
"react": "^0.14.7 || ^15.0.0-0 || ^16.0.0-0",
"react-dom": "^0.14.7 || ^15.0.0-0 || ^16.0.0-0"
}
},
"node_modules/react-collapse": { "node_modules/react-collapse": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/react-collapse/-/react-collapse-5.1.1.tgz", "resolved": "https://registry.npmjs.org/react-collapse/-/react-collapse-5.1.1.tgz",
@@ -12662,8 +12661,7 @@
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.6.2", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
"dev": true
}, },
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
+1 -2
View File
@@ -60,7 +60,7 @@
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.1",
"react-aria-menubutton": "^7.0.3", "react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2", "react-aria-modal": "^5.0.2",
"react-autocomplete": "^1.8.1", "downshift": "^9.0.9",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -113,7 +113,6 @@
"@types/react": "^18.2.67", "@types/react": "^18.2.67",
"@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-menubutton": "^6.2.14",
"@types/react-aria-modal": "^5.0.0", "@types/react-aria-modal": "^5.0.0",
"@types/react-autocomplete": "^1.8.11",
"@types/react-collapse": "^5.0.4", "@types/react-collapse": "^5.0.4",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
-1
View File
@@ -29,7 +29,6 @@ class FieldSourceLayerInternal extends React.Component<FieldSourceLayerInternalP
error={this.props.error} error={this.props.error}
> >
<InputAutocomplete <InputAutocomplete
keepMenuWithinWindowBounds={!!this.props.isFixed}
value={this.props.value} value={this.props.value}
onChange={this.props.onChange} onChange={this.props.onChange}
options={this.props.sourceLayerIds?.map(l => [l, l])} options={this.props.sourceLayerIds?.map(l => [l, l])}
+18
View File
@@ -0,0 +1,18 @@
import InputAutocomplete from './InputAutocomplete'
import { mount } from 'cypress/react'
const fruits = ['apple', 'banana', 'cherry'];
describe('<InputAutocomplete />', () => {
it('filters options when typing', () => {
mount(
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
);
cy.get('input').focus();
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 3);
cy.get('input').type('ch');
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 1).and('contain', 'cherry');
cy.get('.maputnik-autocomplete-menu-item').click();
cy.get('input').should('have.value', 'cherry');
});
});
+97 -81
View File
@@ -1,100 +1,116 @@
import React from 'react' import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import Autocomplete from 'react-autocomplete' import {useCombobox} from 'downshift'
const MAX_HEIGHT = 140
const MAX_HEIGHT = 140;
export type InputAutocompleteProps = { export type InputAutocompleteProps = {
value?: string value?: string
options: any[] options?: any[]
onChange(value: string | undefined): unknown onChange?(value: string | undefined): unknown
keepMenuWithinWindowBounds?: boolean
'aria-label'?: string 'aria-label'?: string
}; };
export default class InputAutocomplete extends React.Component<InputAutocompleteProps> { export default function InputAutocomplete({
state = { value,
maxHeight: MAX_HEIGHT options = [],
} onChange = () => {},
'aria-label': ariaLabel,
}: InputAutocompleteProps) {
const [input, setInput] = React.useState(value || '')
const menuRef = React.useRef<HTMLDivElement>(null)
const [maxHeight, setMaxHeight] = React.useState(MAX_HEIGHT)
autocompleteMenuEl: HTMLDivElement | null = null; const filteredItems = React.useMemo(() => {
const lv = input.toLowerCase()
return options.filter((item) => item[0].toLowerCase().includes(lv))
}, [options, input])
static defaultProps = { const calcMaxHeight = React.useCallback(() => {
onChange: () => {}, if (menuRef.current) {
options: [], const space = window.innerHeight - menuRef.current.getBoundingClientRect().top
} setMaxHeight(Math.min(space, MAX_HEIGHT))
calcMaxHeight() {
if(this.props.keepMenuWithinWindowBounds) {
const maxHeight = window.innerHeight - this.autocompleteMenuEl!.getBoundingClientRect().top;
const limitedMaxHeight = Math.min(maxHeight, MAX_HEIGHT);
if(limitedMaxHeight != this.state.maxHeight) {
this.setState({
maxHeight: limitedMaxHeight
})
}
} }
} }, [])
componentDidMount() { const {
this.calcMaxHeight(); isOpen,
} getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
openMenu,
} = useCombobox({
items: filteredItems,
inputValue: input,
itemToString: (item) => (item ? item[0] : ''),
stateReducer: (_state, action) => {
if (action.type === useCombobox.stateChangeTypes.InputClick) {
return {...action.changes, isOpen: true}
}
return action.changes
},
onSelectedItemChange: ({selectedItem}) => {
const v = selectedItem ? selectedItem[0] : ''
setInput(v)
onChange(selectedItem ? selectedItem[0] : undefined)
},
onInputValueChange: ({inputValue: v}) => {
if (typeof v === 'string') {
setInput(v)
onChange(v === '' ? undefined : v)
openMenu()
}
},
})
componentDidUpdate() { React.useEffect(() => {
this.calcMaxHeight(); if (isOpen) {
} calcMaxHeight()
}
}, [isOpen, calcMaxHeight])
onChange(v: string) { React.useEffect(() => {
this.props.onChange(v === "" ? undefined : v); window.addEventListener('resize', calcMaxHeight)
} return () => window.removeEventListener('resize', calcMaxHeight)
}, [calcMaxHeight])
render() { React.useEffect(() => {
return <div setInput(value || '')
ref={(el) => { }, [value])
this.autocompleteMenuEl = el;
}} return (
> <div className="maputnik-autocomplete">
<Autocomplete <input
menuStyle={{ {...getInputProps({
position: "fixed", 'aria-label': ariaLabel,
overflow: "auto", className: 'maputnik-string',
maxHeight: this.state.maxHeight, spellCheck: false,
zIndex: '998' onFocus: () => openMenu(),
}} })}
wrapperProps={{
className: "maputnik-autocomplete",
style: {}
}}
inputProps={{
'aria-label': this.props['aria-label'],
className: "maputnik-string",
spellCheck: false
}}
value={this.props.value}
items={this.props.options}
getItemValue={(item) => item[0]}
onSelect={v => this.onChange(v)}
onChange={(_e, v) => this.onChange(v)}
shouldItemRender={(item, value="") => {
if (typeof(value) === "string") {
return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1
}
return false
}}
renderItem={(item, isHighlighted) => (
<div
key={item[0]}
className={classnames({
"maputnik-autocomplete-menu-item": true,
"maputnik-autocomplete-menu-item-selected": isHighlighted,
})}
>
{item[1]}
</div>
)}
/> />
<div
{...getMenuProps({}, {suppressRefError: true})}
ref={menuRef}
style={{position: 'fixed', overflow: 'auto', maxHeight, zIndex: 998}}
className="maputnik-autocomplete-menu"
>
{isOpen &&
filteredItems.map((item, index) => (
<div
key={item[0]}
{...getItemProps({
item,
index,
className: classnames('maputnik-autocomplete-menu-item', {
'maputnik-autocomplete-menu-item-selected': highlightedIndex === index,
}),
})}
>
{item[1]}
</div>
))}
</div>
</div> </div>
} )
} }