diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..3ba34c73
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,43 @@
+Maputnik is a MapLibre style editor written using React and TypeScript.
+
+To get started, install all npm packages:
+
+```
+npm install
+```
+
+Verify code correctness by running ESLint:
+
+```
+npm run lint
+```
+
+Or try fixing lint issues with:
+
+```
+npm run lint -- --fix
+```
+
+The project type checked and built with:
+
+```
+npm run build
+```
+
+To run the tests make sure that xvfb is installed:
+
+```
+apt run xvfb
+```
+
+Run the development server in the background with Vite:
+
+```
+nohup npm run start &
+```
+
+Then start the Cypress tests with:
+
+```
+xvfb-run -a npm run test
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 567bc7de..53bb11cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,11 +14,13 @@
- 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
- Fix incorrect handing of network error response (#944)
+- Show an error when adding a layer with a duplicate ID
- _...Add new stuff here..._
## 2.1.1
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/e2e/modals.cy.ts b/cypress/e2e/modals.cy.ts
index 71420b78..c87ea1ea 100644
--- a/cypress/e2e/modals.cy.ts
+++ b/cypress/e2e/modals.cy.ts
@@ -272,6 +272,22 @@ describe("modals", () => {
});
+ describe("add layer", () => {
+ beforeEach(() => {
+ when.setStyle("layer");
+ when.modal.open();
+ });
+
+ it("shows duplicate id error", () => {
+ when.setValue("add-layer.layer-id.input", "background");
+ when.click("add-layer");
+ then(get.elementByTestId("modal:add-layer")).shouldExist();
+ then(get.element(".maputnik-modal-error")).shouldContainText(
+ "Layer ID already exists"
+ );
+ });
+ });
+
describe("sources", () => {
it("toggle");
});
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 4e080ede..6103e2d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,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",
@@ -48,7 +49,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",
@@ -83,7 +83,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",
@@ -3168,16 +3167,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",
@@ -4744,6 +4733,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",
@@ -5265,11 +5260,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",
@@ -5335,6 +5325,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",
@@ -10817,19 +10829,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",
diff --git a/package.json b/package.json
index 9703234c..7b25bcd5 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",
@@ -114,7 +114,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/App.tsx b/src/components/App.tsx
index 8b6052ed..8ae59798 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -876,8 +876,8 @@ export default class App extends React.Component {
this.setModal(modalName, !this.state.isOpen[modalName]);
}
- onSetFileHandle(fileHandle: FileSystemFileHandle | null) {
- this.setState({fileHandle: fileHandle});
+ onSetFileHandle = (fileHandle: FileSystemFileHandle | null) => {
+ this.setState({ fileHandle });
}
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
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]}
+
+ ))}
+
- }
+ )
}
diff --git a/src/components/ModalAdd.tsx b/src/components/ModalAdd.tsx
index e503c02e..edbdfe04 100644
--- a/src/components/ModalAdd.tsx
+++ b/src/components/ModalAdd.tsx
@@ -23,10 +23,16 @@ type ModalAddState = {
id: string
source?: string
'source-layer'?: string
+ error?: string | null
};
class ModalAddInternal extends React.Component {
addLayer = () => {
+ if (this.props.layers.some(l => l.id === this.state.id)) {
+ this.setState({ error: this.props.t('Layer ID already exists') })
+ return
+ }
+
const changedLayers = this.props.layers.slice(0)
const layer: ModalAddState = {
id: this.state.id,
@@ -41,9 +47,10 @@ class ModalAddInternal extends React.Component {
+ this.props.onLayersChange(changedLayers)
+ this.props.onOpenToggle(false)
+ })
}
constructor(props: ModalAddInternalProps) {
@@ -51,6 +58,7 @@ class ModalAddInternal extends React.Component 0) {
@@ -129,6 +137,21 @@ class ModalAddInternal extends React.Component
+ {this.state.error}
+ this.setState({ error: null })}
+ className="maputnik-modal-error-close"
+ >
+ ×
+
+
+ );
+ }
return
+ {errorElement}
{
- this.setState({ id: v })
+ this.setState({ id: v, error: null })
}}
/>