fix(editor): single-letter tags in select/multi-select table cell (#14808)

### Summary of Changes
Resolves #14715 and #14280.

When a user types into a **Select/Multi-Select** table cell to
create/choose a tag, that character is stashed on the cell container
(setTagDraft) instead of going through valueSetFromString. Opening the
tag picker reads it via consumeTagDraftFromTableCellHost.

### Verification
- Added unit test to check that single-character input doesn't
immediately call valueSetFromString.



https://github.com/user-attachments/assets/432b2693-52f9-4ab4-a694-8440aea007a3



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Tag selection popups now initialize with draft text from keypresses in
tag columns, improving user experience when editing tags.

* **Tests**
* Added comprehensive hotkey tests for single-select and multi-select
tag column behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aisha Roslan
2026-05-03 15:58:18 -04:00
committed by GitHub
parent 1ad088398f
commit 5d234ad6a8
7 changed files with 177 additions and 8 deletions
@@ -69,8 +69,20 @@ export type TagManagerOptions = {
options: ReadonlySignal<SelectTag[]>;
onOptionsChange: (options: SelectTag[]) => void;
onComplete?: () => void;
initialDraftText?: string;
};
// parent elements that can consume tag draft
const TABLE_CELL_HOST_SELECTOR =
'dv-table-view-cell-container, affine-database-virtual-cell-container';
export function consumeTagDraftFromTableCellHost(
fromElement: Element
): string | undefined {
const host = fromElement.closest(TABLE_CELL_HOST_SELECTOR) as any;
return host?.consumeTagDraft?.();
}
class TagManager {
changeTag = (option: Partial<SelectTag>) => {
this.ops.onOptionsChange(
@@ -427,6 +439,15 @@ export class MultiTagSelect extends SignalWatcher(
);
}
override connectedCallback() {
super.connectedCallback();
const draft = this.initialDraftText;
if (draft != null && draft !== '') {
this.tagManager.text$.value = draft;
this.initialDraftText = undefined;
}
}
protected override firstUpdated() {
const disposables = this.disposables;
this.classList.add(tagSelectContainerStyle);
@@ -471,6 +492,9 @@ export class MultiTagSelect extends SignalWatcher(
@property({ attribute: false })
accessor value!: ReadonlySignal<string[]>;
@property({ attribute: false })
accessor initialDraftText: string | undefined;
}
declare global {
@@ -481,6 +505,9 @@ declare global {
const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
const tagManager = new TagManager(ops);
if (ops.initialDraftText) {
tagManager.text$.value = ops.initialDraftText;
}
const onInput = (e: InputEvent) => {
tagManager.text$.value = (e.target as HTMLInputElement).value;
};
@@ -604,6 +631,7 @@ export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
component.onChange = ops.onChange;
component.options = ops.options;
component.onOptionsChange = ops.onOptionsChange;
component.initialDraftText = ops.initialDraftText;
component.onComplete = () => {
ops.onComplete?.();
remove();