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
14 changes: 14 additions & 0 deletions .changeset/11674-shortcuts-dialog-wired-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

The keyboard-shortcuts dialog (`?`) lists only shortcuts that do something (objectui#11674).

**Clause-②: no (narrowing)**: no accept set changes; the dialog shows fewer rows, and seven unused `console.shortcuts.*` keys leave all ten locale packs.

The dialog was a static list, separate from every key handler, and six of its rows did nothing. `N` (create record), `R` (refresh data), `⌘/Ctrl+E` (edit record), `⌘/Ctrl+/` (focus search) and `⌘/Ctrl+D` (dark mode) had no handler anywhere, and the sidebar row said `B` while the sidebar toggles on `⌘/Ctrl+B`. The AI-assistant rows (`⌘/Ctrl+Shift+O`, `⌘/Ctrl+Shift+S`) were listed inside apps, where their only handler, on the AI chat page, is not mounted.

`@object-ui/app-shell`: each shortcut is now advertised beside its handler, for as long as that handler is mounted, and `KeyboardShortcutsDialog` lists what is advertised. Inside an app it lists `⌘K` (command palette), `?` (this dialog), `Esc` (close a dialog or panel) and `⌘B` (toggle the sidebar). The AI chat page advertises its own two shortcuts, so they are listed only where that page is mounted. Rows are grouped as before and sorted by their text. A host that mounts `KeyboardShortcutsDialog` outside the console layout now sees the shortcuts whose handlers it mounts, not a fixed list.

`@object-ui/i18n` (BREAKING; `minor` under this repository's release model, where objectui's major follows the `@objectstack` major): `console.shortcuts.focusSearch`, `createRecord`, `refreshData`, `editRecord`, `toggleDarkMode`, `groups.dataViews` and `groups.preferences` are removed from all ten packs. Nothing reads them now. A host that calls `t()` with one of them gets the key back; supply the string from your own resources if you still need it.
13 changes: 13 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,19 @@ a `data-testid` onto their content element and emit Radix `data-state="open|clos
so overlays are locatable and their open/closed state is machine-readable by
construction (C4).

## Keyboard-shortcuts dialog

`KeyboardShortcutsDialog` (`?`, or `?shortcuts=1`) has no list of its own. Each
shortcut is advertised by the code that handles it, beside the handler and for as
long as the handler is mounted, and the dialog lists what is advertised at that
moment (objectui#11674). Inside an app that is the command palette (`⌘K`), the
dialog itself (`?`), closing a dialog or panel (`Esc`) and the sidebar (`⌘B`, the
`SidebarProvider` listener). The AI chat page advertises `⌘⇧O` and `⌘⇧S` itself,
so they are listed only where that page is mounted. A shortcut without a mounted
handler is never listed. Each listed row carries `data-shortcut-id`, and
`KeyboardShortcutsDialog.wiredOnly-11674.test.tsx` fires every row the console
lists against its real handler.

## Settle signal (is the app idle?)

`<ConsoleShell>` exposes one global "no requests in flight" predicate so an
Expand Down
131 changes: 80 additions & 51 deletions packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
/**
* KeyboardShortcutsDialog
*
* A dialog listing all available keyboard shortcuts, triggered by pressing "?".
* A dialog listing the keyboard shortcuts that are wired right now, triggered
* by pressing "?".
*
* Its rows are not a list of its own: each one is a shortcut some mounted
* handler advertises beside itself (`advertisedShortcuts.ts`, objectui#11674).
* A shortcut whose handler is not mounted is not listed.
* @module
*/

import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import {
Dialog,
DialogContent,
Expand All @@ -16,62 +21,85 @@ import {
import { useObjectTranslation } from '@object-ui/i18n';
import { useUrlOverlay } from '../hooks/useUrlOverlay.js';
import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams.js';
import {
SHORTCUT_GROUPS,
shortcutKeycaps,
useAdvertiseShortcut,
useAdvertisedShortcuts,
type AdvertisedShortcut,
type ShortcutGroup,
type ShortcutLabelTranslate,
} from './advertisedShortcuts.js';

/** `?` — the handler is this component's own keydown listener below. */
const SHOW_SHORTCUTS_SHORTCUT: AdvertisedShortcut = {
id: 'shortcuts-help',
group: 'general',
chord: { key: '?' },
label: (t: ShortcutLabelTranslate) => t('console.shortcuts.showShortcuts'),
};

interface ShortcutEntry {
/**
* `Esc` — the handler is Radix's dismiss listener, which this dialog and every
* other dialog and panel in the shell carry. Advertised here, beside the
* `Dialog` it closes.
*/
const CLOSE_OVERLAY_SHORTCUT: AdvertisedShortcut = {
id: 'close-overlay',
group: 'general',
chord: { key: 'Escape' },
label: (t: ShortcutLabelTranslate) => t('console.shortcuts.closeDialog'),
};

function groupTitle(group: ShortcutGroup, t: ShortcutLabelTranslate): string {
switch (group) {
case 'general':
return t('console.shortcuts.groups.general');
case 'navigation':
return t('console.shortcuts.groups.navigation');
case 'aiChat':
return t('console.shortcuts.groups.aiChat');
}
}

interface ShortcutRow {
id: string;
keys: string[];
description: string;
}

interface ShortcutGroup {
interface ShortcutSection {
group: ShortcutGroup;
title: string;
shortcuts: ShortcutEntry[];
rows: ShortcutRow[];
}

export function KeyboardShortcutsDialog() {
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
// URL-addressable (?shortcuts=1) so the dialog is deep-linkable and openable
// from the header Help menu, not only via the `?` keyboard accelerator (ADR-0054
// C1/C2/C3).
const { open, setOpen, toggleOverlay } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);

const shortcutGroups: ShortcutGroup[] = useMemo(() => [
{
title: t('console.shortcuts.groups.general'),
shortcuts: [
{ keys: ['⌘', 'K'], description: t('console.shortcuts.openCommandPalette') },
{ keys: ['?'], description: t('console.shortcuts.showShortcuts') },
{ keys: ['Esc'], description: t('console.shortcuts.closeDialog') },
],
},
{
title: t('console.shortcuts.groups.navigation'),
shortcuts: [
{ keys: ['B'], description: t('console.shortcuts.toggleSidebar') },
{ keys: ['⌘', '/'], description: t('console.shortcuts.focusSearch') },
],
},
{
title: t('console.shortcuts.groups.dataViews'),
shortcuts: [
{ keys: ['N'], description: t('console.shortcuts.createRecord') },
{ keys: ['R'], description: t('console.shortcuts.refreshData') },
{ keys: ['⌘', 'E'], description: t('console.shortcuts.editRecord') },
],
},
{
title: t('console.shortcuts.groups.aiChat', { defaultValue: 'AI assistant' }),
shortcuts: [
{ keys: ['⌘', '⇧', 'O'], description: t('console.shortcuts.newChat', { defaultValue: 'New chat' }) },
{ keys: ['⌘', '⇧', 'S'], description: t('console.shortcuts.toggleChatsList', { defaultValue: 'Toggle conversations list' }) },
],
},
{
title: t('console.shortcuts.groups.preferences'),
shortcuts: [
{ keys: ['⌘', 'D'], description: t('console.shortcuts.toggleDarkMode') },
],
},
], [t]);
useAdvertiseShortcut(SHOW_SHORTCUTS_SHORTCUT);
useAdvertiseShortcut(CLOSE_OVERLAY_SHORTCUT);
const advertised = useAdvertisedShortcuts();

// Sections in `SHORTCUT_GROUPS` order, rows alphabetical by their text: the
// order handlers mount in is not an order a reader should see.
const translate: ShortcutLabelTranslate = (key) => String(t(key));
const sections: ShortcutSection[] = SHORTCUT_GROUPS.map((group) => ({
group,
title: groupTitle(group, translate),
rows: advertised
.filter((shortcut) => shortcut.group === group)
.map((shortcut) => ({
id: shortcut.id,
keys: shortcutKeycaps(shortcut.chord),
description: shortcut.label(translate),
}))
.sort((a, b) => a.description.localeCompare(b.description, language)),
})).filter((section) => section.rows.length > 0);

useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
Expand Down Expand Up @@ -108,20 +136,21 @@ export function KeyboardShortcutsDialog() {
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-2">
{shortcutGroups.map(group => (
<div key={group.title}>
{sections.map(section => (
<div key={section.group}>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
{group.title}
{section.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut, idx) => (
{section.rows.map(row => (
<div
key={idx}
key={row.id}
data-shortcut-id={row.id}
className="flex items-center justify-between py-1.5"
>
<span className="text-sm">{shortcut.description}</span>
<span className="text-sm">{row.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, kidx) => (
{row.keys.map((key, kidx) => (
<kbd
key={kidx}
className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border bg-muted px-1.5 text-xs font-medium text-muted-foreground"
Expand Down
Loading
Loading