From eb985f4d953dab3fdf98f1242182b399dbcbe22a Mon Sep 17 00:00:00 2001 From: Bart Louwers Date: Sat, 5 Jul 2025 13:58:28 +0200 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + cypress.config.ts | 8 ++ cypress/support/component-index.html | 12 ++ cypress/support/component.ts | 37 +++++ package-lock.json | 62 ++++----- package.json | 3 +- src/components/FieldSourceLayer.tsx | 1 - src/components/InputAutocomplete.cy.tsx | 18 +++ src/components/InputAutocomplete.tsx | 178 +++++++++++++----------- 9 files changed, 204 insertions(+), 116 deletions(-) create mode 100644 cypress/support/component-index.html create mode 100644 cypress/support/component.ts create mode 100644 src/components/InputAutocomplete.cy.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edcd96a..53bb11cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry - Remove react-autobind dependency - Remove usage of legacy `childContextTypes` API +- Replace react-autocomplete with Downshift in the autocomplete component - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/cypress.config.ts b/cypress.config.ts index 939d4606..0eeb88bf 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ exclude: "cypress/**/*.*", }, }, + e2e: { setupNodeEvents(on, config) { // implement node event listeners here @@ -20,4 +21,11 @@ export default defineConfig({ openMode: 0, }, }, + + component: { + devServer: { + framework: "react", + bundler: "vite", + }, + }, }); diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html new file mode 100644 index 00000000..5f9622ae --- /dev/null +++ b/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + diff --git a/cypress/support/component.ts b/cypress/support/component.ts new file mode 100644 index 00000000..b2c275d6 --- /dev/null +++ b/cypress/support/component.ts @@ -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 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() diff --git a/package-lock.json b/package-lock.json index 186c46d7..c50c8bde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "codemirror": "^5.65.19", "color": "^5.0.0", "detect-browser": "^5.3.0", + "downshift": "^9.0.9", "events": "^3.3.0", "file-saver": "^2.0.5", "i18next": "^25.3.1", @@ -46,7 +47,6 @@ "react-accessible-accordion": "^5.0.1", "react-aria-menubutton": "^7.0.3", "react-aria-modal": "^5.0.2", - "react-autocomplete": "^1.8.1", "react-collapse": "^5.1.1", "react-color": "^2.19.3", "react-dom": "^18.2.0", @@ -82,7 +82,6 @@ "@types/react": "^18.2.67", "@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-modal": "^5.0.0", - "@types/react-autocomplete": "^1.8.11", "@types/react-collapse": "^5.0.4", "@types/react-color": "^3.0.13", "@types/react-dom": "^18.2.22", @@ -3114,16 +3113,6 @@ "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": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/react-collapse/-/react-collapse-5.0.4.tgz", @@ -4690,6 +4679,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5211,11 +5206,6 @@ "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": { "version": "2.0.0", "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" } }, + "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10771,19 +10783,6 @@ "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-collapse/-/react-collapse-5.1.1.tgz", @@ -12662,8 +12661,7 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tunnel-agent": { "version": "0.6.0", diff --git a/package.json b/package.json index 5d1541db..79468f0c 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "react-accessible-accordion": "^5.0.1", "react-aria-menubutton": "^7.0.3", "react-aria-modal": "^5.0.2", - "react-autocomplete": "^1.8.1", + "downshift": "^9.0.9", "react-collapse": "^5.1.1", "react-color": "^2.19.3", "react-dom": "^18.2.0", @@ -113,7 +113,6 @@ "@types/react": "^18.2.67", "@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-modal": "^5.0.0", - "@types/react-autocomplete": "^1.8.11", "@types/react-collapse": "^5.0.4", "@types/react-color": "^3.0.13", "@types/react-dom": "^18.2.22", diff --git a/src/components/FieldSourceLayer.tsx b/src/components/FieldSourceLayer.tsx index d17da5a3..832ce98d 100644 --- a/src/components/FieldSourceLayer.tsx +++ b/src/components/FieldSourceLayer.tsx @@ -29,7 +29,6 @@ class FieldSourceLayerInternal extends React.Component [l, l])} diff --git a/src/components/InputAutocomplete.cy.tsx b/src/components/InputAutocomplete.cy.tsx new file mode 100644 index 00000000..cf3d81cd --- /dev/null +++ b/src/components/InputAutocomplete.cy.tsx @@ -0,0 +1,18 @@ +import InputAutocomplete from './InputAutocomplete' +import { mount } from 'cypress/react' + +const fruits = ['apple', 'banana', 'cherry']; + +describe('', () => { + it('filters options when typing', () => { + mount( + [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'); + }); +}); diff --git a/src/components/InputAutocomplete.tsx b/src/components/InputAutocomplete.tsx index b0235cb3..59558740 100644 --- a/src/components/InputAutocomplete.tsx +++ b/src/components/InputAutocomplete.tsx @@ -1,100 +1,116 @@ import React from 'react' import classnames from 'classnames' -import Autocomplete from 'react-autocomplete' +import {useCombobox} from 'downshift' - -const MAX_HEIGHT = 140; +const MAX_HEIGHT = 140 export type InputAutocompleteProps = { value?: string - options: any[] - onChange(value: string | undefined): unknown - keepMenuWithinWindowBounds?: boolean + options?: any[] + onChange?(value: string | undefined): unknown 'aria-label'?: string }; -export default class InputAutocomplete extends React.Component { - state = { - maxHeight: MAX_HEIGHT - } +export default function InputAutocomplete({ + value, + options = [], + onChange = () => {}, + 'aria-label': ariaLabel, +}: InputAutocompleteProps) { + const [input, setInput] = React.useState(value || '') + const menuRef = React.useRef(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 = { - onChange: () => {}, - options: [], - } - - 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 - }) - } + const calcMaxHeight = React.useCallback(() => { + if (menuRef.current) { + const space = window.innerHeight - menuRef.current.getBoundingClientRect().top + setMaxHeight(Math.min(space, MAX_HEIGHT)) } - } + }, []) - componentDidMount() { - this.calcMaxHeight(); - } + const { + 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() { - this.calcMaxHeight(); - } + React.useEffect(() => { + if (isOpen) { + calcMaxHeight() + } + }, [isOpen, calcMaxHeight]) - onChange(v: string) { - this.props.onChange(v === "" ? undefined : v); - } + React.useEffect(() => { + window.addEventListener('resize', calcMaxHeight) + return () => window.removeEventListener('resize', calcMaxHeight) + }, [calcMaxHeight]) - render() { - return
{ - this.autocompleteMenuEl = el; - }} - > - 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) => ( -
- {item[1]} -
- )} + React.useEffect(() => { + setInput(value || '') + }, [value]) + + return ( +
+ openMenu(), + })} /> +
+ {isOpen && + filteredItems.map((item, index) => ( +
+ {item[1]} +
+ ))} +
- } + ) }