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 new file mode 100644 index 0000000..df666d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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] + +### 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. +- **`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. + +### 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/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/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/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/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/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/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); }); 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(); 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();