Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)); },
Expand All @@ -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

Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 3 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions src/components/date-picker/BqDatePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,13 +599,18 @@ const definition: ComponentDefinition<BqDatePickerProps, BqDatePickerState> = {
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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
Expand Down
8 changes: 8 additions & 0 deletions src/components/number-input/BqNumberInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@ const definition: ComponentDefinition<BqNumberInputProps, BqNumberInputState> =
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, {
Expand Down
17 changes: 16 additions & 1 deletion src/components/pin-input/BqPinInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ const definition: ComponentDefinition<BqPinInputProps, BqPinInputState> = {
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));
Expand All @@ -186,6 +200,7 @@ const definition: ComponentDefinition<BqPinInputProps, BqPinInputState> = {
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);
Expand Down Expand Up @@ -228,7 +243,7 @@ const definition: ComponentDefinition<BqPinInputProps, BqPinInputState> = {
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);
});
Expand Down
13 changes: 10 additions & 3 deletions src/components/tree/BqTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,13 +399,20 @@ const definition: ComponentDefinition<BqTreeProps, BqTreeState> = {
},
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' });
Expand Down
7 changes: 6 additions & 1 deletion src/utils/focus.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions tests/date-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 5 additions & 3 deletions tests/focus-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
14 changes: 14 additions & 0 deletions tests/new-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
9 changes: 9 additions & 0 deletions tests/pin-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions tests/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading