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
74 changes: 74 additions & 0 deletions apps/obsidian/src/components/NodeSearchFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type ReactElement } from "react";
import { getHintKeys, type HintKey } from "~/utils/keyboardHints";

type NodeSearchFooterProps = {
canAct: boolean;
onClose: () => void;
onOpenInNewTab: () => void;
onOpenInSplit: () => void;
};

type FooterActionProps = {
disabled?: boolean;
keys: HintKey[];
label: string;
onClick: () => void;
};

const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => (
<>
{getHintKeys(keys).map((symbol) => (
<kbd className="dg-search-footer-key" key={symbol}>
{symbol}
</kbd>
))}
</>
);

const FooterAction = ({
disabled = false,
keys,
label,
onClick,
}: FooterActionProps): ReactElement => (
<button
type="button"
className="prompt-instruction dg-search-footer-action inline-flex h-auto cursor-pointer items-center gap-1 rounded-none border-0 p-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={onClick}
// Clicking must not move focus out of the query input, or the arrow keys stop
// reaching the result list.
onMouseDown={(event) => event.preventDefault()}
>
<KeyHints keys={keys} />
<span className="ms-1">{label}</span>
</button>
);

// Sits in Obsidian's `prompt-instructions` container for its type and spacing.
// Obsidian centres that row for the narrow quick switcher; this footer spans a
// full-width result list, so the actions start at its left edge instead.
export const NodeSearchFooter = ({
canAct,
onClose,
onOpenInNewTab,
onOpenInSplit,
}: NodeSearchFooterProps): ReactElement => (
<div className="prompt-instructions dg-search-footer shrink-0 justify-start px-0 pb-0 text-left">
<FooterAction
disabled={!canAct}
keys={["Enter"]}
label="open in new tab"
onClick={onOpenInNewTab}
/>
<FooterAction
disabled={!canAct}
keys={["Shift", "Enter"]}
label="open in split"
onClick={onOpenInSplit}
/>
{/* The Escape key itself is handled by Obsidian's modal scope; this button
is the pointer equivalent, so every footer item responds to a click. */}
<FooterAction keys={["Escape"]} label="close" onClick={onClose} />
</div>
);
58 changes: 52 additions & 6 deletions apps/obsidian/src/components/NodeSearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
} from "react";
import { createRoot, Root } from "react-dom/client";
import type DiscourseGraphPlugin from "~/index";
import { NodeSearchFooter } from "~/components/NodeSearchFooter";
import {
openFileInNewLeaf,
openFileInNewTab,
} from "~/components/canvas/utils/openFileUtils";
import {
QueryEngine,
rankDiscourseNodesByTitle,
Expand Down Expand Up @@ -256,10 +261,11 @@ const ResultList = ({
}, [activeIndex]);

return (
// No `aria-label` here: Obsidian renders one as a hover tooltip, which
// covers the results the moment the pointer enters the list.
<div
ref={listRef}
role="listbox"
aria-label="Discourse node search results"
onMouseMove={() => (pointerMovedRef.current = true)}
className="flex-1 overflow-y-auto"
>
Expand All @@ -279,7 +285,6 @@ const ResultList = ({
>
{result.nodeType.badge && (
<span
title={result.nodeType.name}
aria-label={result.nodeType.name}
style={{
backgroundColor: result.nodeType.badge.backgroundColor,
Expand All @@ -299,8 +304,10 @@ const ResultList = ({

const NodeSearch = ({
plugin,
onClose,
}: {
plugin: DiscourseGraphPlugin;
onClose: () => void;
}): ReactElement => {
const { app } = plugin;
const [candidateState, setCandidateState] = useState<CandidateState>({
Expand Down Expand Up @@ -395,11 +402,44 @@ const NodeSearch = ({
});
};

// Closes before opening: `close()` unmounts this React root, so the file and
// app are read first and nothing touches state afterwards.
const openActiveResult = (
open: (app: App, file: TFile) => Promise<void>,
): void => {
if (!activeResult) return;
const { file } = activeResult;
onClose();
void open(app, file).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
new Notice(`Could not open ${file.basename}: ${message}`);
});
};

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
// Otherwise the caret jumps to the start or end of the query.
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
// Otherwise the caret jumps to the start or end of the query.
event.preventDefault();
moveActiveIndex(event.key === "ArrowDown" ? 1 : -1);
return;
}

if (event.key !== "Enter") return;
// Enter also commits an IME candidate, which must not open a file.
if (event.nativeEvent.isComposing) return;
// Mod+Enter and Alt+Enter are left alone for the insert and dock actions.
if (event.metaKey || event.ctrlKey || event.altKey) return;
// A footer button reached by Tab runs its own action on Enter. Preventing the
// default here would suppress that click and open a new tab instead.
if (
event.target instanceof HTMLElement &&
event.target.closest("button") !== null
) {
return;
}

event.preventDefault();
moveActiveIndex(event.key === "ArrowDown" ? 1 : -1);
openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInNewTab);
Comment on lines 441 to +442

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 Let focused footer buttons handle Enter

When a keyboard user Tabs into a footer button, its bubbling Enter keydown is still intercepted here, preventDefault() suppresses the button's native click, and the selection shortcut runs instead. Consequently, Enter on the close button opens the active result in a new tab, while Enter on the split button also opens a new tab; ignore Enter events originating from footer buttons or scope this shortcut to the search input.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, and worse than described now that close is also a button — fixed in 48c234e.

Enter on a focused footer button bubbled to the modal handler, whose `preventDefault()` suppressed the button's native click, so Tab→close and Tab→split both opened a new tab instead. Rather than scope the shortcut to the input (ENG-2109 deliberately moved it to the wrapper so arrow keys work anywhere in the modal), the Enter branch now returns early when the event originates inside a button:

if (event.target instanceof HTMLElement && event.target.closest("button") !== null) return;

Arrow keys are unaffected — they return before this guard — and the buttons stay Tab-reachable.

};

return (
Expand Down Expand Up @@ -437,6 +477,12 @@ const NodeSearch = ({
</div>
<PreviewPane app={app} result={activeResult} authorName={authorName} />
</div>
<NodeSearchFooter
canAct={candidateState.status === "ready" && !!activeResult}
onClose={onClose}
onOpenInNewTab={() => openActiveResult(openFileInNewTab)}
onOpenInSplit={() => openActiveResult(openFileInNewLeaf)}
/>
</div>
);
};
Expand All @@ -457,7 +503,7 @@ export class NodeSearchModal extends Modal {
this.root = createRoot(contentEl);
this.root.render(
<StrictMode>
<NodeSearch plugin={this.plugin} />
<NodeSearch plugin={this.plugin} onClose={() => this.close()} />
</StrictMode>,
);
}
Expand Down
42 changes: 42 additions & 0 deletions apps/obsidian/src/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3921,3 +3921,45 @@ kbd.tlui-kbd {
border-radius: var(--radius-s);
padding: 0 1px;
}

/* Only the properties Tailwind utilities cannot win here; the rest of this
footer's layout is utilities on the elements themselves. Obsidian sets
`color`, `background-color`, and `box-shadow` in `button:not(.clickable-icon)`
and `button:hover` — both (0,1,1), which outrank a single utility class — so a
utility would leave the label in `--text-normal` on an interactive-grey pill.
`font-size` has no inherit utility, and without it the button takes
`--font-ui-small` rather than the smaller type of the row it sits in. */
.dg-node-search-modal .dg-search-footer-action,
.dg-node-search-modal .dg-search-footer-action:hover {
color: inherit;
background-color: transparent;
box-shadow: none;
font-size: inherit;
}

/* Each key is a bordered cap, so `esc` reads as one of the set rather than as
emphasised text. Kept in CSS for the inherited font and em-based sizing. */
.dg-node-search-modal .dg-search-footer-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5em;
padding: 0 var(--size-2-1);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-primary);
color: var(--text-muted);
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.6;
}

.dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) {
color: var(--text-normal);
}

.dg-node-search-modal .dg-search-footer-action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
33 changes: 33 additions & 0 deletions apps/obsidian/src/utils/keyboardHints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Platform } from "obsidian";

export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape";

// Obsidian shows glyphs on macOS and spelled-out words everywhere else.
const MAC_SYMBOLS: Record<HintKey, string> = {
Mod: "⌘",
Alt: "⌥",
Shift: "⇧",
Enter: "↵",
Escape: "esc",
};

const NON_MAC_SYMBOLS: Record<HintKey, string> = {
Mod: "Ctrl",
Alt: "Alt",
Shift: "Shift",
Enter: "Enter",
Escape: "Esc",
};

/** Takes `isMacOS` so the non-mac branch can be checked without that platform. */
export const formatHintKeys = ({
keys,
isMacOS,
}: {
keys: HintKey[];
isMacOS: boolean;
}): string[] =>
keys.map((key) => (isMacOS ? MAC_SYMBOLS : NON_MAC_SYMBOLS)[key]);

export const getHintKeys = (keys: HintKey[]): string[] =>
formatHintKeys({ keys, isMacOS: Platform.isMacOS });