Skip to content
Open
Changes from 3 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.focusFieldItem();
}
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