From d63199d1272b158dc0bf2db20eb51f2e6587a0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:17:23 +0000 Subject: [PATCH 1/3] fix(pin-input,number-input): repaint normalized values when the markup is unchanged bQuery 1.18 morphs the shadow tree instead of replacing innerHTML and skips the DOM write when a render produces identical markup. Both controls relied on the replacement to throw away what the user typed when the committed value did not change: - bq-pin-input kept a rejected character (a letter in a numeric cell) on screen when it was typed over a filled cell. - bq-number-input kept showing e.g. 999 after it was clamped back to a max that already was the current value. Write the normalized value into the control after committing. This is a no-op on bQuery 1.15-1.17, where the render replaced the nodes anyway. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ju3jbgag99iuxndoJTrgM --- CHANGELOG.md | 11 +++++++++++ src/components/number-input/BqNumberInput.ts | 8 ++++++++ src/components/pin-input/BqPinInput.ts | 17 ++++++++++++++++- tests/new-components.test.ts | 14 ++++++++++++++ tests/pin-input.test.ts | 9 +++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7285fa8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to `@bquery/ui` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Fixed + +- **`bq-pin-input`**: a rejected character stays out of the cell even when the code does not change. Typing a letter into a numeric cell that already held a digit used to leave the letter on screen under bQuery 1.18, because a render with unchanged markup no longer rewrites the cells. +- **`bq-number-input`**: a typed value that is clamped back to the current value is shown normalized. Typing `999` into a field already at its `max` of `10` used to keep showing `999` under bQuery 1.18. + diff --git a/src/components/number-input/BqNumberInput.ts b/src/components/number-input/BqNumberInput.ts index 97ab52e..3742c5a 100644 --- a/src/components/number-input/BqNumberInput.ts +++ b/src/components/number-input/BqNumberInput.ts @@ -224,6 +224,14 @@ const definition: ComponentDefinition = const { min, max, step, precision } = readSettings(); const value = normalizeNumber(next, min, max, step, precision); self.setAttribute('value', String(value)); + // Show the normalized value even when it equals the previous one (e.g. + // 999 typed into a field already at its max of 10): the attribute write + // then renders identical markup, and bQuery >= 1.18 morphs the shadow + // tree instead of replacing it, so the field would keep the raw text. + const field = self.shadowRoot?.querySelector( + 'input[type="number"]' + ) as HTMLInputElement | null; + if (field && field.value !== String(value)) field.value = String(value); local.proxy?.setValue(String(value)); self.dispatchEvent( new CustomEvent(eventName, { diff --git a/src/components/pin-input/BqPinInput.ts b/src/components/pin-input/BqPinInput.ts index 9e0e16a..33fc313 100644 --- a/src/components/pin-input/BqPinInput.ts +++ b/src/components/pin-input/BqPinInput.ts @@ -165,6 +165,20 @@ const definition: ComponentDefinition = { const cells = (): HTMLInputElement[] => Array.from(self.shadowRoot?.querySelectorAll('.cell') ?? []); + /** + * Writes the committed code back into the cells. A re-render only touches + * a cell whose rendered value changed (bQuery >= 1.18 morphs the shadow + * tree instead of replacing it), so a rejected character, or one typed + * over a cell that keeps its value, would otherwise stay on screen. + */ + const syncCells = () => { + const value = sanitizePin(readValue(), type(), length()); + cells().forEach((cell, index) => { + const char = value[index] ?? ''; + if (cell.value !== char) cell.value = char; + }); + }; + const focusCell = (index: number) => { const list = cells(); const clamped = Math.max(0, Math.min(index, list.length - 1)); @@ -186,6 +200,7 @@ const definition: ComponentDefinition = { const size = length(); self.setAttribute('value', next); proxy.setValue(next); + syncCells(); // The attribute write re-renders synchronously, so the cells that exist // after it are the ones to move focus into. focusCell(caret); @@ -228,7 +243,7 @@ const definition: ComponentDefinition = { const cell = event.target as HTMLInputElement; const typed = sanitizePin(cell.value, type(), length()); const next = applyPinEdit(readValue(), index, typed, length()); - // An empty edit means the character was rejected; re-render anyway so + // An empty edit means the character was rejected; commit anyway so // the cell drops what the user typed instead of showing it. commit(next, typed ? index + typed.length : index); }); diff --git a/tests/new-components.test.ts b/tests/new-components.test.ts index 2356235..fa99308 100644 --- a/tests/new-components.test.ts +++ b/tests/new-components.test.ts @@ -348,6 +348,20 @@ describe('BqNumberInput', () => { ).toBe(true); }); + it('repaints a typed value clamped back to the current one', () => { + // The committed value does not change, so the re-render produces the same + // markup; the field must still drop the out-of-range text. + const el = mount('bq-number-input', { value: '10', min: '0', max: '10' }); + const input = el.shadowRoot?.querySelector('input') as HTMLInputElement; + input.value = '999'; + input.dispatchEvent(new win.Event('change', { bubbles: true })); + + expect(el.getAttribute('value')).toBe('10'); + expect( + (el.shadowRoot?.querySelector('input') as HTMLInputElement).value + ).toBe('10'); + }); + it('links hint and error text to the field', () => { const el = mount('bq-number-input', { hint: 'Between 1 and 9', label: 'Qty' }); const input = el.shadowRoot?.querySelector('input'); diff --git a/tests/pin-input.test.ts b/tests/pin-input.test.ts index 0b0e734..85fb6e6 100644 --- a/tests/pin-input.test.ts +++ b/tests/pin-input.test.ts @@ -117,6 +117,15 @@ describe('bq-pin-input', () => { expect(cells(el)[0]?.value ?? '').toBe(''); }); + it('drops a rejected character typed over a filled cell', async () => { + const el = create({ type: 'numeric', value: '12' }); + await waitForFrame(); + type(el, 0, 'a'); + await waitForFrame(); + expect(el.getAttribute('value')).toBe('12'); + expect(cells(el).map((cell) => cell.value)).toEqual(['1', '2', '', '']); + }); + it('clears the current cell on Backspace, then steps back', async () => { const el = create({ value: '12' }); await waitForFrame(); From 0a70751fb088bd8d8319e2d9f35fc8dab5789198 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:17:23 +0000 Subject: [PATCH 2/3] fix(tree,date-picker): move keyboard focus with the roving tab stop updated() only focused the new tree node / calendar day when nothing in the shadow root had focus, assuming the render had destroyed the focused node. Under bQuery 1.18 morph rendering the old node survives and keeps focus; on 1.15-1.17 restorePreservedFocus put focus back on the old node's stand-in (tree) or on the first day of the grid (date picker). Either way the arrow keys moved the tabindex but not focus. Move focus to the active node / cursor day whenever focus sits on another item of the same widget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ju3jbgag99iuxndoJTrgM --- CHANGELOG.md | 2 ++ src/components/date-picker/BqDatePicker.ts | 11 ++++++++--- src/components/tree/BqTree.ts | 13 ++++++++++--- tests/date-picker.test.ts | 20 ++++++++++++++++++++ tests/tree.test.ts | 14 ++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7285fa8..18a2dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,6 @@ All notable changes to `@bquery/ui` are documented in this file. The format is b - **`bq-pin-input`**: a rejected character stays out of the cell even when the code does not change. Typing a letter into a numeric cell that already held a digit used to leave the letter on screen under bQuery 1.18, because a render with unchanged markup no longer rewrites the cells. - **`bq-number-input`**: a typed value that is clamped back to the current value is shown normalized. Typing `999` into a field already at its `max` of `10` used to keep showing `999` under bQuery 1.18. +- **`bq-tree`**: arrow keys, Home/End and type-ahead move keyboard focus to the newly active node. Before, only the roving `tabindex` moved and focus stayed on the previous node (with every bQuery version). +- **`bq-date-picker`**: arrow-key navigation in the calendar moves focus to the new cursor day. Before, focus stayed on the previous day under bQuery 1.18, and on 1.15–1.17 it jumped to the first day of the grid. diff --git a/src/components/date-picker/BqDatePicker.ts b/src/components/date-picker/BqDatePicker.ts index b8aedd2..5940a39 100644 --- a/src/components/date-picker/BqDatePicker.ts +++ b/src/components/date-picker/BqDatePicker.ts @@ -599,13 +599,18 @@ const definition: ComponentDefinition = { local.syncPosition?.(); syncFormProxy(this); - // Arrow keys move a roving tab stop through the grid, and the render that - // moved it destroyed the button that had focus. + // Arrow keys move a roving tab stop through the grid. bQuery >= 1.18 + // patches the grid in place, so the day that had focus keeps it (older + // releases replaced it, and focus was restored onto a stand-in); move + // focus to the cursor whenever it is on another day, or nowhere. const root = this.shadowRoot; const cursor = root?.querySelector( '.day[tabindex="0"]' ) as HTMLElement | null; - if (cursor && this.hasAttribute('data-open') && !root?.activeElement) { + const focused = root?.activeElement as HTMLElement | null | undefined; + const focusOnOtherDay = + !focused || (focused !== cursor && focused.classList.contains('day')); + if (cursor && this.hasAttribute('data-open') && focusOnOtherDay) { cursor.focus(); } }, diff --git a/src/components/tree/BqTree.ts b/src/components/tree/BqTree.ts index f6b0675..17aefe7 100644 --- a/src/components/tree/BqTree.ts +++ b/src/components/tree/BqTree.ts @@ -399,13 +399,20 @@ const definition: ComponentDefinition = { }, updated() { restorePreservedFocus(this); - // The roving tab stop has to end up on the node the keyboard moved to, - // and the render that moved it also destroyed the element that had focus. + // The roving tab stop has to end up on the node the keyboard moved to. + // bQuery >= 1.18 patches the tree in place, so the node that had focus + // keeps it; older releases replaced it and `restorePreservedFocus` put + // focus back on its stand-in. Either way focus still sits on the old + // node, so move it whenever it is on a node other than the active one. const root = this.shadowRoot; const active = root?.querySelector( '.node[data-active="true"]' ) as HTMLElement | null; - if (active && root?.activeElement === null && this.matches(':focus-within')) { + const focused = root?.activeElement as HTMLElement | null | undefined; + const focusInTree = focused + ? focused.classList.contains('node') + : this.matches(':focus-within'); + if (active && focused !== active && focusInTree) { active.focus(); } active?.scrollIntoView?.({ block: 'nearest' }); diff --git a/tests/date-picker.test.ts b/tests/date-picker.test.ts index 942cd87..d730320 100644 --- a/tests/date-picker.test.ts +++ b/tests/date-picker.test.ts @@ -272,6 +272,26 @@ describe('bq-date-picker', () => { ).toBe('2026-03-22'); }); + it('moves focus along with the cursor', async () => { + const el = create({ value: '2026-03-15' }); + await waitForFrame(); + click(el.shadowRoot?.querySelector('.calendar-btn') ?? null); + await waitForFrame(); + const focusedDate = () => + (el.shadowRoot?.activeElement as HTMLElement | null)?.getAttribute( + 'data-date' + ); + + (el.shadowRoot?.querySelector('.day[tabindex="0"]') as HTMLElement).focus(); + press(el, 'ArrowRight'); + await waitForFrame(); + expect(focusedDate()).toBe('2026-03-16'); + + press(el, 'ArrowDown'); + await waitForFrame(); + expect(focusedDate()).toBe('2026-03-23'); + }); + it('closes on Escape without changing the value', async () => { const el = create({ value: '2026-03-15' }); await waitForFrame(); diff --git a/tests/tree.test.ts b/tests/tree.test.ts index 80f5fe4..3c6c6c7 100644 --- a/tests/tree.test.ts +++ b/tests/tree.test.ts @@ -200,6 +200,20 @@ describe('bq-tree', () => { expect(nodes(el)[1]?.getAttribute('tabindex')).toBe('0'); }); + it('moves focus along with the roving tab stop', async () => { + const el = create(); + await waitForFrame(); + nodes(el)[0]?.focus(); + + press(el, 0, 'ArrowDown'); + await waitForFrame(); + expect(el.shadowRoot?.activeElement).toBe(nodes(el)[1] ?? null); + + press(el, 1, 'ArrowUp'); + await waitForFrame(); + expect(el.shadowRoot?.activeElement).toBe(nodes(el)[0] ?? null); + }); + it('selects on click and reports the node', async () => { const el = create(); await waitForFrame(); From 8372cc148b53a7eecc2b8cb9432e5225ae4a813b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:17:23 +0000 Subject: [PATCH 3/3] chore(deps): update @bquery/bquery to 1.18.0 Bump the dev dependency to ^1.18.0 and refresh bun.lock. The peer range stays ^1.15.1: the library uses no 1.18-only API and the suite passes against both 1.15.1 and 1.18.0. 1.18 re-renders components by morphing the existing DOM. Adjust the focus-preservation test, which asserted that a re-render replaces the focused input, to check what users see under either strategy (value and focus kept), and document in AGENT.md and utils/focus.ts how components must behave under morph and replace rendering. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ju3jbgag99iuxndoJTrgM --- AGENT.md | 42 ++++++++++++++++++++++++++------ CHANGELOG.md | 7 ++++++ bun.lock | 8 +++--- package.json | 2 +- src/utils/focus.ts | 7 +++++- tests/focus-preservation.test.ts | 8 +++--- 6 files changed, 56 insertions(+), 18 deletions(-) diff --git a/AGENT.md b/AGENT.md index 4372bb7..509d7e7 100644 --- a/AGENT.md +++ b/AGENT.md @@ -143,9 +143,12 @@ attribute the sanitizer would drop, so this can no longer ship unnoticed. ### Focus across re-renders -Rendering assigns `shadowRoot.innerHTML`, so every render destroys the node the -user is in. Any component with a focusable element inside its shadow root wants -the packaged treatment: +Since bQuery 1.18 a re-render morphs the existing shadow tree in place +(`renderStrategy: 'morph'`), so the focused node usually survives. The peer +range still includes 1.15–1.17, which render by assigning +`shadowRoot.innerHTML` and destroy the node the user is in, so any component +with a focusable element inside its shadow root still wants the packaged +treatment: ```ts connected() { trackFocus(host(this), bind(this)); }, @@ -157,7 +160,11 @@ updated() { restorePreservedFocus(this); }, **capture** phase, so the snapshot is taken before the component's own handler runs. `beforeUpdate` covers attribute-driven renders, which no event precedes. `setState` renders skip `beforeUpdate` entirely, which is why the event-driven -half is needed at all. +half is needed at all. `restorePreservedFocus` does nothing while something in +the tree still has focus, so it is harmless under morph. A roving tab stop +(tree nodes, calendar days) must therefore move focus itself in `updated()` +when focus sits on an item other than the active one: under morph the old item +keeps focus. ### Tinted surfaces @@ -178,10 +185,29 @@ Chromium. See `src/theme/scheme.ts`. ### Shadow-DOM state and re-renders -Every render replaces the shadow root's contents. Anything written into the -shadow root imperatively (for example the file-upload file list, or the -avatar-group overflow counter, which depend on light-DOM children that `render` -cannot see) must be repainted from `updated()`. +Write components so they behave the same under both render strategies: + +- **Morph (bQuery ≥ 1.18, the default).** Nodes are reused. Attributes are + diffed against the *previous render*, so an attribute set or removed at + runtime survives until the template changes that attribute, and a form + control's `value`/`checked` property is only written when the rendered + attribute changes. A render with identical markup skips the DOM write + entirely. Children the template does not contain are removed. +- **Replace (bQuery 1.15–1.17, still in the peer range).** Every render + replaces the shadow root's contents. + +Consequences: + +- Anything written into the shadow root imperatively (for example the + file-upload file list, or the avatar-group overflow counter, which depend on + light-DOM children that `render` cannot see) must be repainted from + `updated()`. +- Never rely on a re-render to throw away what the user typed. When a control + normalizes or rejects input and the committed value may equal the previous + one (a pin-input cell rejecting a letter, a number-input clamped back to its + max), write the normalized value into the control's property yourself. +- Listeners on shadow-root children must be bound once (or delegated to the + shadow root, as most components do); never re-bind them in `updated()`. ## Setup and validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a2dbd..df666d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `@bquery/ui` are documented in this file. The format is b ## [Unreleased] +### Changed + +- **Dependencies**: the `@bquery/bquery` dev dependency is now `^1.18.0`, so tests, Storybook, the docs and the bundled CDN builds (`index.umd.js` / `index.iife.js`) use bQuery 1.18.0. In 1.18 component re-renders morph the existing shadow tree instead of replacing `innerHTML`: input values, focus, caret and nested elements survive an update. The peer dependency stays `^1.15.1`. The library uses no 1.18-only API, and the fixes below work with both rendering strategies. The full test suite passes against 1.15.1 and 1.18.0. + ### Fixed - **`bq-pin-input`**: a rejected character stays out of the cell even when the code does not change. Typing a letter into a numeric cell that already held a digit used to leave the letter on screen under bQuery 1.18, because a render with unchanged markup no longer rewrites the cells. @@ -11,3 +15,6 @@ All notable changes to `@bquery/ui` are documented in this file. The format is b - **`bq-tree`**: arrow keys, Home/End and type-ahead move keyboard focus to the newly active node. Before, only the roving `tabindex` moved and focus stayed on the previous node (with every bQuery version). - **`bq-date-picker`**: arrow-key navigation in the calendar moves focus to the new cursor day. Before, focus stayed on the previous day under bQuery 1.18, and on 1.15–1.17 it jumped to the first day of the grid. +### Documentation + +- `AGENT.md` describes how components must behave under both render strategies (morph in 1.18, replace in 1.15–1.17). diff --git a/bun.lock b/bun.lock index 6279449..ee1f4ca 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@bquery/ui", "devDependencies": { - "@bquery/bquery": "^1.15.1", + "@bquery/bquery": "^1.18.0", "@storybook/addon-docs": "^10.5.5", "@storybook/web-components-vite": "^10.5.5", "@tailwindcss/postcss": "^4.3.3", @@ -84,7 +84,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@bquery/bquery": ["@bquery/bquery@1.15.1", "", { "bin": { "bquery-view-compile": "bin/bquery-view-compile.mjs", "bquery-i18n": "bin/bquery-i18n.mjs" } }, "sha512-DRuVZCbzppo1pPtxbTC3zYkFVkpPvqut4x5hd1FqlrTQUcYmQplruMvDUtuqtJU7mNNGXyez5z/87urW7ioOQQ=="], + "@bquery/bquery": ["@bquery/bquery@1.18.0", "", { "bin": { "bquery-i18n": "bin/bquery-i18n.mjs", "create-bquery": "bin/create-bquery.mjs", "bquery-view-compile": "bin/bquery-view-compile.mjs" } }, "sha512-8rR0q4SXRccs/TsdB5IOh2y/4dS7+AYsXvUrOnhouJ81l8nSzPegzx/dJ41Blj3oPq+CpcuRLNa9NSBSXc7skg=="], "@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="], @@ -712,7 +712,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -994,8 +994,6 @@ "@rollup/pluginutils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], diff --git a/package.json b/package.json index 991b48d..a5c6e5a 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@bquery/bquery": "^1.15.1" }, "devDependencies": { - "@bquery/bquery": "^1.15.1", + "@bquery/bquery": "^1.18.0", "@storybook/addon-docs": "^10.5.5", "@storybook/web-components-vite": "^10.5.5", "@tailwindcss/postcss": "^4.3.3", diff --git a/src/utils/focus.ts b/src/utils/focus.ts index 55f243e..a71ca08 100644 --- a/src/utils/focus.ts +++ b/src/utils/focus.ts @@ -1,12 +1,17 @@ /** * Focus and caret preservation across re-renders. * - * The component runtime renders by assigning `shadowRoot.innerHTML`, which + * bQuery 1.15–1.17 render by assigning `shadowRoot.innerHTML`, which * discards every node in the shadow tree and builds new ones. For a purely * presentational component that is invisible. For a component containing a * focused text field it is not: the focused node is destroyed, focus falls * back to the document body, and the caret position is gone. * + * bQuery 1.18 morphs the existing tree instead, so the focused node usually + * survives and these helpers find focus still in place and do nothing. They + * stay because the peer range still includes the replacing releases, and + * because a morph can still recreate a node whose tag changes. + * * Concretely, a control that reflects its value to an attribute on every * keystroke re-renders on every keystroke, so the user can type exactly one * character before losing the field. diff --git a/tests/focus-preservation.test.ts b/tests/focus-preservation.test.ts index 896b3ef..f84e7a3 100644 --- a/tests/focus-preservation.test.ts +++ b/tests/focus-preservation.test.ts @@ -109,13 +109,15 @@ describe('form controls survive a re-render while focused', () => { input.focus(); expect(el.shadowRoot?.activeElement).toBe(input); - // Reflecting a keystroke re-renders the shadow tree, replacing the node - // the user is typing into. + // Reflecting a keystroke re-renders the shadow tree. bQuery >= 1.18 + // patches the existing nodes in place; older releases replaced the node + // the user was typing into, and `restorePreservedFocus` moved focus onto + // its replacement. Either way the field the user sees keeps focus. el.setAttribute('value', 'abc'); await waitForFrame(); const next = el.shadowRoot?.querySelector('input') as HTMLInputElement; - expect(next).not.toBe(input); + expect(next.value).toBe('abc'); expect(el.shadowRoot?.activeElement).toBe(next); });