Skip to content
Open
Changes from 4 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
85 changes: 84 additions & 1 deletion modules/System/assets/vue-components/fields/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export let FieldRenderer = {
modelValue() {
this.val = this.modelValue;
this.update();
},
fieldItem(val) {
if (val) {
this.$nextTick(() => this.focusFieldItem());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (correctness): this autofocus is almost certainly a no-op — the dialog has no input yet when focusFieldItem() runs.

The dialog body renders the field via <fields-renderer>field-renderer, and the inner FieldRenderer template is gated on v-if="fieldTypes". fieldTypes is only populated in its own mounted() hook, from FieldTypes.get() (modules/System/assets/js/settings.js), which is declared async get() — so even on the cached this._fields path it resolves through extra microtask ticks, and assigning this.fieldTypes then requires a further render flush.

Microtask ordering when fieldItem is set:

  1. flush: dialog mounts, inner field-renderer mounts with fieldTypes === null → renders nothing; FieldTypes.get().then(...) queued.
  2. $nextTick callback (registered on the flush promise) → focusFieldItem() runs.
  3. only later does fieldTypes get assigned and a second flush render the actual <input>.

At step 2 dialog.querySelector("input, textarea, select, [contenteditable]") returns null, if (input) short-circuits, and nothing is focused — silently. So the second half of #127 ("input field should be auto focused") is not actually delivered, and the failure mode is invisible because of the null guard.

Please verify in a browser. If confirmed, focus needs to be driven by something that waits for the field to exist — e.g. focus from the inner component once it has rendered, or a bounded MutationObserver / retry on the dialog element — not a single $nextTick.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1fa69d3. focusFieldItem() now polls via a bounded requestAnimationFrame loop (~60 frames) until a genuinely focusable control exists in the dialog, instead of relying on a single $nextTick that fires before the nested field-renderer's async fieldTypes resolves.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},

Expand Down Expand Up @@ -171,6 +176,84 @@ export let FieldRenderer = {
this.fieldItem = null;
},

getFocusableFieldItemInput(dialog) {

const candidates = dialog.querySelectorAll('input, textarea, select, [contenteditable]');

for (const el of candidates) {

if (el.disabled) {
continue;
}

if (el.hasAttribute('contenteditable') && el.getAttribute('contenteditable') === 'false') {
continue;
}

// skip offscreen / not-yet-sized controls, e.g. CodeMirror's hidden measuring textarea
if (el.offsetWidth < 10 || el.offsetHeight < 10) {
continue;
}

return el;
}

return null;
},

focusFieldItem(attempt = 0) {

const dialog = document.querySelector(`kiss-dialog[data-field-render-uid="${this.uid}"]`);

if (!dialog) {
return;
}

const input = this.getFocusableFieldItemInput(dialog);

if (input) {
input.focus();
return;
}

// the nested field-renderer's own fieldTypes is resolved asynchronously,
// so its input may not exist in the DOM yet — poll a bounded number of frames
if (attempt >= 60) {
return;
}

requestAnimationFrame(() => this.focusFieldItem(attempt + 1));
},

onFieldItemKeydown(evt) {

// ignore Enter that commits an IME (CJK/etc) composition
if (evt.isComposing) {
return;
}

// another widget on the same element (e.g. app-tags) already handled this
// keydown and called preventDefault() - don't also save/close the dialog
if (evt.defaultPrevented) {
return;
}

const tag = evt.target.tagName;

// buttons/links already trigger their own action on enter, don't also save
if (tag === 'BUTTON' || tag === 'A') {
return;
}

// let textareas / contenteditable areas keep their own newline behaviour
if (tag === 'TEXTAREA' || evt.target.isContentEditable) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (correctness): the TEXTAREA / contenteditable / BUTTON / A allowlist does not cover the field types that actually consume Enter, and keyup is the wrong event for this.

Concrete counter-example in this repo — the tags field. field-tags.js renders <app-tags>, whose app-tags.js handleKeydown() does:

case "Enter":
    e.preventDefault();
    ... this.addTag(inputValue);

Its editable control is a plain <input type="text" class="app-tags-input">, so tagName is INPUT and isContentEditable is false — neither guard fires. And preventDefault() on keydown does not suppress keyup, nor does app-tags call stopPropagation(). Net result for a tags field with multiple: true: pressing Enter to commit a tag also saves and closes the item dialog. The user loses the dialog mid-entry. Same class of bug for any third-party field type that handles Enter itself.

Two further gaps that keyup makes unavoidable:

  • IME composition. With a CJK IME, the Enter that commits a candidate produces a keyup with key === "Enter" and isComposing === false (the composition flag only survives on keydown/keypress). So CJK users get the dialog submitted while they are still typing the option label. This is the standard argument for handling Enter on keydown and checking evt.isComposing || evt.keyCode === 229.
  • Focus moving between keydown and keyup. Keyboard events are delivered to whatever is focused at the time of the event. Once the autofocus above is fixed (it is currently inert — see my comment on line 71), activating the "Add item" <button> with Enter will move focus into the dialog before keyup fires, so the keyup lands on the newly focused input, bubbles to kiss-content, and immediately re-saves and closes the dialog the user just opened. The BUTTON/A guard does not help, because by then the target is no longer the button. Today this is masked only because autofocus does not work — the two fixes are coupled.

Suggested shape: move to @keydown.enter, bail on evt.isComposing || evt.keyCode === 229, bail on evt.defaultPrevented (which cleanly covers app-tags and any other control that already calls preventDefault() on Enter), keep the TEXTAREA/isContentEditable guard, and evt.preventDefault() before saving.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1fa69d3, taking the suggested shape: switched to @keydown.enter, bail on evt.isComposing || evt.keyCode === 229, bail on evt.defaultPrevented (confirmed this correctly excludes app-tags, since its own keydown listener sits on the input itself and runs before our ancestor listener during bubble, so its preventDefault() is already visible to us by the time we check), kept the TEXTAREA/isContentEditable guard, and call evt.preventDefault() before saving. Moving fully to keydown also removes the focus-race you flagged, since there's only one keydown event per physical keypress and it's dispatched/bubbled through the DOM as it existed at press time, before any dialog/focus change from that same press could occur.

return;
}

evt.preventDefault();
this.saveFieldItem();
},

removeFieldItem(list, index) {
list.splice(index, 1);
},
Expand Down Expand Up @@ -241,7 +324,7 @@ export let FieldRenderer = {

<teleport to="body">
<kiss-dialog open="true" size="large" :data-field-render-uid="uid" v-if="fieldItem">
<kiss-content class="animated fadeInUp faster">
<kiss-content class="animated fadeInUp faster" @keydown.enter="onFieldItemKeydown">

<div class="kiss-flex kiss-flex-middle">
<div>
Expand Down