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
5 changes: 5 additions & 0 deletions .changeset/web-confirm-dialog-enter-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Press Enter to confirm in archive and other confirmation dialogs.
45 changes: 43 additions & 2 deletions apps/kimi-web/src/components/dialogs/ConfirmDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
Dialog (height auto, right-aligned footer). The single confirmation surface
for user actions — driven app-wide by useConfirmDialog(). -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from '../ui/Dialog.vue';
import Button from '../ui/Button.vue';

withDefaults(defineProps<{
const confirmButtonRef = ref<InstanceType<typeof Button> | null>(null);

function confirmButtonElement(): HTMLElement | null {
const el = confirmButtonRef.value?.$el;
return el instanceof HTMLElement ? el : null;
}

const props = withDefaults(defineProps<{
open: boolean;
title: string;
message?: string;
Expand All @@ -32,13 +40,41 @@ function onCancel(): void {
emit('update:open', false);
emit('cancel');
}

function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' || !props.open || props.loading) return;
// Preserve native Enter semantics for interactive controls (buttons, links,
// form fields) so tabbing to Cancel / Close and pressing Enter does not
// accidentally confirm the dialog. Only treat Enter as confirm when focus is
// on a non-interactive part of the dialog.
const target = event.target as HTMLElement | null;
if (
target instanceof HTMLButtonElement ||
target instanceof HTMLAnchorElement ||
Comment on lines +51 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ensure bare Enter confirms after the dialog opens

In the default archive-confirm flow, Dialog.vue moves focus to the first focusable element when opened, and the first focusable in this dialog is the header close button. Because this handler returns for every HTMLButtonElement, pressing Enter immediately after the modal appears does not run the new confirm shortcut; the browser instead activates the focused close button and cancels/closes the dialog. This makes the advertised keyboard path fail for the main scenario unless the user first moves focus off the close button.

Useful? React with 👍 / 👎.

target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLInputElement
) {
return;
}
event.preventDefault();
emit('confirm');
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve button semantics when handling Enter

When a confirmation dialog is open, this window-level handler also receives Enter keydowns from the dialog's own buttons; because only text inputs are exempted, tabbing to Cancel or the close button and pressing Enter will hit this branch, preventDefault() the native button action, and emit confirm instead. This makes keyboard users confirm destructive dialogs in exactly the scenario where they are trying to cancel/close them.

Useful? React with 👍 / 👎.

}

if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
}
onBeforeUnmount(() => {
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
});
</script>

<template>
<Dialog
:open="open"
:title="title"
height="auto"
:initial-focus="confirmButtonElement"
@update:open="emit('update:open', $event)"
@close="onCancel"
>
Expand All @@ -47,7 +83,12 @@ function onCancel(): void {
<Button variant="secondary" :disabled="loading" @click="onCancel">
{{ cancelLabel ?? t('common.cancel') }}
</Button>
<Button :variant="variant" :loading="loading" @click="emit('confirm')">
<Button
ref="confirmButtonRef"
:variant="variant"
:loading="loading"
@click="emit('confirm')"
>
{{ confirmLabel ?? t('common.confirm') }}
</Button>
</template>
Expand Down
18 changes: 17 additions & 1 deletion apps/kimi-web/src/components/ui/Dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const props = withDefaults(defineProps<{
/** When false, the body has no padding so the consumer controls layout
* (e.g. a full-bleed side-nav). */
padded?: boolean;
/** Element (or selector / resolver) to receive focus when the dialog opens.
* Falls back to the first focusable element, then the dialog panel. */
initialFocus?: HTMLElement | string | (() => HTMLElement | null | undefined);
}>(), {
closeOnOverlay: true,
closeOnEsc: true,
Expand Down Expand Up @@ -50,6 +53,18 @@ function focusables(): HTMLElement[] {
return panel.value ? Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE)) : [];
}

function resolveInitialFocus(): HTMLElement | null {
const { initialFocus } = props;
if (!initialFocus) return null;
if (typeof initialFocus === 'function') {
return initialFocus() ?? null;
}
if (typeof initialFocus === 'string') {
return panel.value?.querySelector<HTMLElement>(initialFocus) ?? null;
}
return panel.value?.contains(initialFocus) ? initialFocus : null;
}

function onKeydown(event: KeyboardEvent) {
if (!props.open) return;
if (event.key === 'Escape' && props.closeOnEsc) {
Expand Down Expand Up @@ -87,8 +102,9 @@ watch(
openDialogCount.value += 1;
previouslyFocused = document.activeElement;
await nextTick();
const initial = resolveInitialFocus();
const list = focusables();
(list[0] ?? panel.value)?.focus();
(initial ?? list[0] ?? panel.value)?.focus();
} else {
openDialogCount.value = Math.max(0, openDialogCount.value - 1);
if (previouslyFocused instanceof HTMLElement) {
Expand Down
Loading