diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f588460 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "dev", + "image": "node:stable" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5f65e79 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# react-document-events + +A declarative React component for binding event handlers to `document` and `window`, with automatic cleanup on unmount. + +## Commands + +```bash +# Install dependencies (uses yarn) +yarn + +# Run tests +yarn test + +# Run tests in watch mode +yarn test:watch + +# Run linting +yarn lint + +# Build (transpile src/ to build/) +yarn build +``` + +## Architecture + +- **src/ReactDocumentEvents.js** - Main component. Binds/unbinds event handlers to document or window targets. Uses `addEventListener`/`removeEventListener` with support for passive events. +- **src/events.js** - Lists of valid React and window event names used for prop validation in development. +- **build/** - Babel-transpiled output (generated, not committed). +- **index.js** - Entry point, exports the built component. + +## Key Details + +- Uses Babel for transpilation (`@babel/preset-env`, `@babel/preset-react`) +- Tests use Mocha + Chai + jsdom +- Pre-commit hooks run: test, lint, build +- Peer dependencies: React >0.14, ReactDOM >0.14 diff --git a/package.json b/package.json index acf5d7e..32a36e4 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "1.5.1", "description": "Declarative component for binding handlers to document and window - and cleaning them up.", "main": "index.js", + "exports": { + ".": "./index.js", + "./package.json": "./package.json" + }, "engines": { - "node": ">=4" + "node": ">=20" }, "scripts": { "prepublishOnly": "npm run build", @@ -44,8 +48,8 @@ "react-dom": "^19.2.3" }, "peerDependencies": { - "react": ">0.14", - "react-dom": ">0.14" + "react": ">=16.8", + "react-dom": ">=16.8" }, "pre-commit": [ "test", diff --git a/src/ReactDocumentEvents.js b/src/ReactDocumentEvents.js index 0be07b9..093df68 100644 --- a/src/ReactDocumentEvents.js +++ b/src/ReactDocumentEvents.js @@ -84,10 +84,7 @@ class DocumentEvents extends React.Component { _adjustHandlers(fn, props) { const target = this.getTarget(props); if (!target) return; - // If `passive` is not supported, the third param is `useCapture`, which is a bool - and we won't - // be able to use passive at all. Otherwise, it's safe to use an object. - // eslint-disable-next-line no-use-before-define - const options = SUPPORTS_PASSIVE ? {passive: props.passive, capture: props.capture} : props.capture; + const options = {passive: props.passive, capture: props.capture}; this.getKeys(props).forEach(([eventHandlerName, eventName]) => { // Note that this is a function that looks up the latest handler on `this.props`. // This ensures that if the function in `props` changes, the most recent handler will @@ -110,28 +107,13 @@ class DocumentEvents extends React.Component { } function on(element, event, callback, options) { - !element.addEventListener && (event = 'on' + event); - (element.addEventListener || element.attachEvent).call(element, event, callback, options); - return callback; + element.addEventListener(event, callback, options); } function off(element, event, callback, options) { - !element.removeEventListener && (event = 'on' + event); - (element.removeEventListener || element.detachEvent).call(element, event, callback, options); - return callback; + element.removeEventListener(event, callback, options); } -const SUPPORTS_PASSIVE = (function passiveFeatureTest() { - try { - let support = false; - // eslint-disable-next-line getter-return - document.createElement("div").addEventListener("test", function() {}, { get passive() { support = true; }}); - return support; - } catch (e) { - return false; - } -})(); - // Generate and assign propTypes from all possible events if (NODE_ENV !== 'production') { const propTypes = EventKeys.allEvents.reduce(function(result, key) { diff --git a/test/index.spec.js b/test/index.spec.js index 1794644..8788c01 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -28,27 +28,6 @@ DummyTarget.prototype.removeEventListener = function(name, callback) { delete this.eventListenerOptions[name]; }; -// Target that uses legacy IE attachEvent/detachEvent API -const LegacyTarget = function() { - this.eventListenerCount = {}; - this.eventListeners = {}; -}; -LegacyTarget.prototype.attachEvent = function(name, callback) { - // IE uses 'onclick' format - const eventName = name.replace(/^on/, ''); - if (typeof this.eventListenerCount[eventName] !== "number") { - this.eventListenerCount[eventName] = 0; - } - this.eventListenerCount[eventName]++; - this.eventListeners[eventName] = callback; -}; -LegacyTarget.prototype.detachEvent = function(name, callback) { - const eventName = name.replace(/^on/, ''); - if (this.eventListeners[eventName] !== callback) return; - this.eventListenerCount[eventName]--; - delete this.eventListeners[eventName]; -}; - class DummyComponent extends React.Component { renderCount = 0; getDocumentEvents() { @@ -342,9 +321,7 @@ describe('react-document-events', function () { }); }); - it("should pass capture option to addEventListener (fallback mode)", () => { - // In JSDOM, passive event listeners are not supported, so the component - // falls back to passing just the capture boolean as the third argument + it("should pass capture option to addEventListener", () => { const target = new DummyTarget(); const container = document.createElement("div"); const root = createRoot(container); @@ -353,14 +330,28 @@ describe('react-document-events', function () { {}} /> ); }); - // When passive is not supported, options is just the capture boolean - expect(target.eventListenerOptions.click).to.equal(true); + expect(target.eventListenerOptions.click).to.deep.equal({ passive: false, capture: true }); + act(() => { + root.unmount(); + }); + }); + + it("should pass passive option to addEventListener", () => { + const target = new DummyTarget(); + const container = document.createElement("div"); + const root = createRoot(container); + act(() => { + root.render( + {}} /> + ); + }); + expect(target.eventListenerOptions.click).to.deep.equal({ passive: true, capture: false }); act(() => { root.unmount(); }); }); - it("should default capture to false", () => { + it("should default capture and passive to false", () => { const target = new DummyTarget(); const container = document.createElement("div"); const root = createRoot(container); @@ -369,8 +360,7 @@ describe('react-document-events', function () { {}} /> ); }); - // Default capture is false - expect(target.eventListenerOptions.click).to.equal(false); + expect(target.eventListenerOptions.click).to.deep.equal({ passive: false, capture: false }); act(() => { root.unmount(); }); @@ -481,22 +471,6 @@ describe('react-document-events', function () { }); }); - it("should use attachEvent/detachEvent when addEventListener not available", () => { - const target = new LegacyTarget(); - const container = document.createElement("div"); - const root = createRoot(container); - - act(() => { - root.render( {}} />); - }); - expect(target.eventListenerCount).to.deep.equal({ click: 1 }); - - act(() => { - root.unmount(); - }); - expect(target.eventListenerCount).to.deep.equal({ click: 0 }); - }); - it("should handle rapid enable/disable toggling without leaking listeners", () => { const target = new DummyTarget(); const container = document.createElement("div");