feat(grid): refine boolean editing and copy actions

This commit is contained in:
t8y2 committed 2026-08-05 14:22:52 +08:00
1 parent d65d712089
commit f67f02e107
16 files changed
+188 -279

No files matched your search

+97 -132
View File
@@ -135,7 +135,7 @@ import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, default
import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter";
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
import { isBooleanCheckboxValue, isBooleanColumnType, isPointInBooleanCheckbox, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
@@ -2968,7 +2968,6 @@ const {
startEdit,
commitEdit,
commitEditAndMaybeAutoSave,
cycleBooleanCellValue,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
@@ -3146,7 +3145,6 @@ function showReadonlyCellDetailsOnDblClick(item: RowItem, rowIndex: number, visi
}
function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) {
if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return;
if (showReadonlyCellDetailsOnDblClick(item, rowIndex, visibleColIdx, actualColIdx)) return;
startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event);
}
@@ -3186,19 +3184,6 @@ function measureCellTextWidth(text: string, font: string): number {
return width;
}
const cellTextWidthCache = new Map<string, number>();
function measureCellTextWidthCached(text: string, font: string): number {
const key = `${font}|${text}`;
let width = cellTextWidthCache.get(key);
if (width === undefined) {
width = measureCellTextWidth(text, font);
if (cellTextWidthCache.size >= 200) cellTextWidthCache.clear();
cellTextWidthCache.set(key, width);
}
return width;
}
const tableColumnsByResultIndex = computed(() =>
resolveDataGridColumnsByResultIndex({
resultColumns: props.result.columns,
@@ -3232,7 +3217,7 @@ function isEnumGridColumnNullable(columnIndex: number): boolean {
return tableColumnForGridColumn(columnIndex)?.is_nullable ?? false;
}
function isEnumEditorInitialNull(rowId: number | undefined, columnIndex: number): boolean {
function isGridCellInitialNull(rowId: number | undefined, columnIndex: number): boolean {
if (rowId === undefined) return false;
return getRowItem(rowId)?.data[columnIndex] === null;
}
@@ -3249,22 +3234,19 @@ function isBooleanGridColumn(columnIndex: number): boolean {
}
function isBooleanGridCell(item: RowItem | undefined, columnIndex: number): boolean {
return !!item && isBooleanGridColumn(columnIndex) && isBooleanCheckboxValue(item.data[columnIndex]);
return !!item && isBooleanGridColumn(columnIndex) && isBooleanCellValue(item.data[columnIndex]);
}
function isBooleanGridColumnNullable(columnIndex: number): boolean {
return tableColumnForGridColumn(columnIndex)?.is_nullable ?? true;
}
function booleanCellChecked(value: unknown): boolean {
return normalizeBooleanCellValue(value) === true;
}
function cycleBooleanGridCell(item: RowItem | undefined, actualColIdx: number, event: MouseEvent) {
if (!item || !isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return;
event.stopPropagation();
void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx));
}
const booleanEditorModelValue = computed({
get: () => booleanCellEditorValue(editValue.value),
set: (value: string) => {
editValue.value = value;
},
});
function cellEditInputModeForColumn(columnIndex: number): "decimal" | "numeric" | undefined {
const dataType = normalizedColumnDataType(tableColumnForGridColumn(columnIndex));
@@ -5079,8 +5061,7 @@ function onCanvasMouseMove(event: MouseEvent) {
const next = hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null;
const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined;
if (canvasRef.value) {
const overBooleanInteractive = hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event);
canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell";
canvasRef.value.style.cursor = hit?.rowNumber ? "default" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell";
}
if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) {
return;
@@ -5123,43 +5104,6 @@ function clearCanvasDetailHover(event?: MouseEvent) {
onCanvasMouseLeave();
}
function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
if (item.data[actualColIdx] === null) return false;
const canvas = canvasRef.value;
const canvasRect = canvas?.getBoundingClientRect();
const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx);
if (!canvasRect || !cellRect) return false;
return isPointInBooleanCheckbox({ x: event.clientX - canvasRect.left, y: event.clientY - canvasRect.top }, { left: cellRect.left, top: cellRect.top, width: cellRect.width, height: cellRect.height });
}
function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
if (item.data[actualColIdx] !== null) return false;
const canvas = canvasRef.value;
const canvasRect = canvas?.getBoundingClientRect();
const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx);
if (!canvasRect || !cellRect) return false;
const text = firstLineCellDisplayValue(formatCellCached(item.data[actualColIdx], actualColIdx));
if (!text) return false;
const textWidth = measureCellTextWidthCached(text, `italic 400 ${tableFontSize.value}px ${tableFontFamily.value}`);
if (textWidth <= 0) return false;
const left = cellRect.left + (cellRect.width - textWidth) / 2 - 2;
const right = left + textWidth + 4;
const x = event.clientX - canvasRect.left;
const y = event.clientY - canvasRect.top;
return x >= left && x <= right && y >= cellRect.top && y <= cellRect.top + cellRect.height;
}
function booleanInteractiveHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
return item.data[actualColIdx] === null ? booleanNullTextHitFromCanvasEvent(item, hit, actualColIdx, event) : booleanCheckboxHitFromCanvasEvent(item, hit, actualColIdx, event);
}
function tryCycleBooleanCheckboxOnCanvasMouseDown(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
if (!isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return false;
if (!booleanInteractiveHitFromCanvasEvent(item, hit, actualColIdx, event)) return false;
void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx));
return true;
}
function onCanvasMouseDown(event: MouseEvent) {
if (event.button !== 0) return;
const hit = canvasHitTest(event);
@@ -5176,7 +5120,6 @@ function onCanvasMouseDown(event: MouseEvent) {
onRowNumberMouseDown(item, event);
} else {
handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event);
if (actualColIdx !== undefined) tryCycleBooleanCheckboxOnCanvasMouseDown(item, hit, actualColIdx, event);
}
gridRef.value?.focus({ preventScroll: true });
scheduleCanvasDraw();
@@ -5209,7 +5152,6 @@ function onCanvasDblClick(event: MouseEvent) {
const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx];
if (!item || actualColIdx === undefined) return;
if (showReadonlyCellDetailsOnDblClick(item, item.displayIndex, hit.visibleColIdx, actualColIdx)) return;
if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return;
startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx));
}
@@ -5375,7 +5317,6 @@ function drawCanvasGrid() {
searchMatchKeys: searchMatchSet.value,
currentSearchMatch: currentSearchMatch.value,
formatCell: formatCellCached,
columnIsBoolean: isBooleanGridColumn,
draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"),
isRowActive,
rowCellsUseSelectionVisual,
@@ -5629,7 +5570,7 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
"csv-with-headers": t("grid.copyExtractorWithHeaders", { format: "CSV" }),
"pipe-separated": t("grid.copyExtractorPipeSeparated"),
dsv: "DSV",
json: "JSON",
json: t("grid.copyExtractorJsonArray"),
"json-lines": "JSON Lines",
"one-row": t("grid.copyExtractorOneRow"),
"sql-in-list": t("grid.copyExtractorSqlInList"),
@@ -5900,7 +5841,6 @@ function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, display
showTransposeCellDetails(rowIndex, actualColIdx);
return;
}
if (isBooleanGridCell(item, actualColIdx)) return;
startDomCellEdit(item.id, actualColIdx, displayText, event);
}
@@ -6401,10 +6341,6 @@ function editSelectedCell(): boolean {
const item = displayItemAt(position.rowIndex);
const actualColIndex = actualColumnIndex(position.colIndex);
if (!item || !canEditCellItem(item, actualColIndex)) return false;
if (isBooleanGridCell(item, actualColIndex)) {
void cycleBooleanCellValue(item.id, actualColIndex, isBooleanGridColumnNullable(actualColIndex));
return true;
}
startEdit(item.id, actualColIndex);
return true;
}
@@ -6444,10 +6380,23 @@ function deleteCurrentRow(): boolean {
return true;
}
function commitGridEdit(value?: string | null) {
function commitGridEdit(value?: CellValue) {
void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true })));
}
function commitBooleanGridEdit(value?: string | null) {
if (value === undefined) {
commitGridEdit();
return;
}
const parsedValue = parseBooleanCellEditorValue(value);
if (parsedValue === undefined) {
commitGridEdit();
return;
}
commitGridEdit(parsedValue);
}
async function commitEditFromCellBlur() {
const target = pendingQuickEntryDraftCellFocus.value;
pendingQuickEntryDraftCellFocus.value = null;
@@ -8275,46 +8224,66 @@ function filterSubmenu(): ContextMenuItem {
function buildExtractorContextItems(): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
let separatorPending = false;
for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) {
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
if (descriptor.separatorBefore) {
items.push({ label: "", separator: true });
separatorPending = items.length > 0;
}
const selected = extractor === selectedCopyExtractor.value;
const extractorItems: ContextMenuItem[] = [];
if (extractor === "sql-inserts") {
for (const excludePrimaryKeysFromInsert of [false, true]) {
if (selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert) continue;
const options = sqlInsertExtractorOptions(excludePrimaryKeysFromInsert);
items.push({
extractorItems.push({
label: t(excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys"),
action: () => void copyWithExtractor(extractor, options),
disabled: !canCopyWithExtractor(extractor, options),
checked: selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert,
});
}
continue;
} else if (!selected) {
extractorItems.push({
label: copyExtractorLabel(extractor),
action: () => {
// One-off copy as the chosen format; do NOT persist it as the default —
// the saved default stays controlled by the toolbar/settings dialog.
void copyWithExtractor(extractor);
},
disabled: !canCopyWithExtractor(extractor),
});
}
items.push({
label: copyExtractorLabel(extractor),
action: () => {
// One-off copy as the chosen format; do NOT persist it as the default —
// the saved default stays controlled by the toolbar/settings dialog.
void copyWithExtractor(extractor);
},
disabled: !canCopyWithExtractor(extractor),
checked: selected,
});
if (extractorItems.length === 0) continue;
if (separatorPending) {
items.push({ label: "", separator: true });
separatorPending = false;
}
items.push(...extractorItems);
}
items.push({ label: "", separator: true });
items.push({
label: t("grid.copyExtractorConfigure"),
icon: Settings2,
action: openExtractorConfiguration,
});
return items;
}
function defaultExtractorContextItem(): ContextMenuItem {
const extractor = selectedCopyExtractor.value;
const extractorOptions = extractor === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined;
return {
label: defaultCopyExtractorLabel.value,
action: () => void copyWithExtractor(extractor, extractorOptions),
disabled: !canCopyWithExtractor(extractor, extractorOptions),
checked: true,
};
}
function copySubmenu(): ContextMenuItem {
const items: ContextMenuItem[] = [];
const items: ContextMenuItem[] = [
{
label: t("grid.copyExtractorConfigure"),
icon: Settings2,
action: openExtractorConfiguration,
},
defaultExtractorContextItem(),
{ label: "", separator: true },
];
if (contextColumn.value) {
items.push({ label: t("grid.copyCell"), action: copyCell });
}
@@ -8812,12 +8781,22 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
<EnumCellEditor
v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)"
v-model="booleanEditorModelValue"
:values="BOOLEAN_CELL_EDITOR_VALUES"
:nullable="isBooleanGridColumnNullable(cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitBooleanGridEdit"
/>
<EnumCellEditor
v-else-if="isEnumGridColumn(cell.valueIndex)"
v-model="editValue"
:values="enumValuesForGridColumn(cell.valueIndex)"
:nullable="isEnumGridColumnNullable(cell.valueIndex)"
:initial-null="isEnumEditorInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
:initial-null="isGridCellInitialNull(displayItems[cell.recordIndex]?.id, cell.valueIndex)"
cell-layout="transpose"
@cancel="cancelEdit"
@commit="commitGridEdit"
@@ -8858,22 +8837,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<template v-if="draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) }}</span>
</template>
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex) && !cell.isNull">
<span class="flex w-full justify-center">
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
:class="[booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : '']"
@click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)"
>
<Check v-if="booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex])" class="h-3 w-3" />
</span>
</span>
</template>
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)">
<span class="flex w-full justify-center">
<span :class="canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)">{{ firstLineCellDisplayValue(cell.display) }}</span>
</span>
</template>
<template v-else>{{ firstLineCellDisplayValue(cell.display) }}</template>
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<LightDropdownMenu
@@ -9330,12 +9293,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
<EnumCellEditor
v-else-if="isBooleanGridCell(getRowItem(canvasEditingCell.rowId), canvasEditingCell.actualColIdx)"
v-model="booleanEditorModelValue"
:values="BOOLEAN_CELL_EDITOR_VALUES"
:nullable="isBooleanGridColumnNullable(canvasEditingCell.actualColIdx)"
:initial-null="isGridCellInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
@cancel="cancelEdit"
@commit="commitBooleanGridEdit"
/>
<EnumCellEditor
v-else-if="isEnumGridColumn(canvasEditingCell.actualColIdx)"
v-model="editValue"
:values="enumValuesForGridColumn(canvasEditingCell.actualColIdx)"
:nullable="isEnumGridColumnNullable(canvasEditingCell.actualColIdx)"
:initial-null="isEnumEditorInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
:initial-null="isGridCellInitialNull(canvasEditingCell.rowId, canvasEditingCell.actualColIdx)"
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
@@ -9497,12 +9469,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
<EnumCellEditor
v-else-if="isBooleanGridCell(item, col.actualColIdx)"
v-model="booleanEditorModelValue"
:values="BOOLEAN_CELL_EDITOR_VALUES"
:nullable="isBooleanGridColumnNullable(col.actualColIdx)"
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
@cancel="cancelEdit"
@commit="commitBooleanGridEdit"
/>
<EnumCellEditor
v-else-if="isEnumGridColumn(col.actualColIdx)"
v-model="editValue"
:values="enumValuesForGridColumn(col.actualColIdx)"
:nullable="isEnumGridColumnNullable(col.actualColIdx)"
:initial-null="isEnumEditorInitialNull(item.id, col.actualColIdx)"
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
@@ -9542,22 +9523,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
</template>
<template v-else-if="isBooleanGridCell(item, col.actualColIdx) && !isNull(item.data[col.actualColIdx])">
<span class="flex w-full justify-center">
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
:class="[booleanCellChecked(item.data[col.actualColIdx]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : '']"
@click="cycleBooleanGridCell(item, col.actualColIdx, $event)"
>
<Check v-if="booleanCellChecked(item.data[col.actualColIdx])" class="h-3 w-3" />
</span>
</span>
</template>
<template v-else-if="isBooleanGridCell(item, col.actualColIdx)">
<span class="flex w-full justify-center">
<span :class="canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(item, col.actualColIdx, $event)">{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</span>
</span>
</template>
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</template>
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<LightDropdownMenu
@@ -141,7 +141,7 @@ function save() {
<div class="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-3">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorFormat") }}</Label>
<Label>{{ t("grid.copyExtractorDefaultFormat") }}</Label>
<Select v-model="draftExtractor">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -282,10 +282,10 @@ onBeforeUnmount(() => {
</div>
<button v-else :disabled="itemIsDisabled(item)" :class="[...itemButtonClass(item.variant), activeSubIndex === index ? 'bg-accent text-accent-foreground' : '']" @click="handleItemClick(item)" @mouseenter="(e) => onItemMouseEnter(index, e)" @mouseleave="onItemMouseLeave">
<span class="flex size-4 shrink-0 items-center justify-center">
<component :is="item.icon" v-if="item.icon" :class="['size-4', item.iconClass]" />
<Check v-if="item.checked" class="size-4 text-primary" />
<component :is="item.icon" v-else-if="item.icon" :class="['size-4', item.iconClass]" />
</span>
<span class="flex-1 whitespace-nowrap">{{ item.label }}</span>
<Check v-if="item.checked" class="ml-4 size-4 shrink-0 text-primary" />
<span v-if="item.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<kbd v-for="key in shortcutKeys(item.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
</span>
@@ -313,10 +313,10 @@ onBeforeUnmount(() => {
</div>
<button v-else :disabled="itemIsDisabled(child)" :class="itemButtonClass(child.variant)" @click="handleSubItemClick(child)">
<span class="flex size-4 shrink-0 items-center justify-center">
<component :is="child.icon" v-if="child.icon" :class="['size-4', child.iconClass]" />
<Check v-if="child.checked" class="size-4 text-primary" />
<component :is="child.icon" v-else-if="child.icon" :class="['size-4', child.iconClass]" />
</span>
<span class="flex-1 whitespace-nowrap">{{ child.label }}</span>
<Check v-if="child.checked" class="ml-4 size-4 shrink-0 text-primary" />
<span v-if="child.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<kbd v-for="key in shortcutKeys(child.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
</span>
@@ -164,7 +164,7 @@ describe("CustomContextMenu lifecycle", () => {
app.unmount();
});
it("renders checked state after the menu label", async () => {
it("renders checked state in the leading icon slot", async () => {
const root = defineComponent({
setup() {
return () =>
@@ -188,7 +188,8 @@ describe("CustomContextMenu lifecycle", () => {
const current = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Current"));
const other = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Other"));
expect(current?.lastElementChild?.tagName.toLowerCase()).toBe("svg");
expect(current?.firstElementChild?.querySelector("svg")).not.toBeNull();
expect(current?.lastElementChild?.tagName.toLowerCase()).toBe("span");
expect(other?.querySelector("svg")).toBeNull();
app.unmount();