fix: whitelist editor keyboard/mouse navigation + highlight gating, My Shows w on focused show
Whitelist editor (Preferences > Auto Download Whitelist): - input focus driven by nav.inputFocused() (SearchPage pattern) so Esc deterministically defocuses and j/k/Space/Enter browse the suggestions - suggestions react to mouse: click focuses the row and toggles membership - no accent bg / no marker on any suggestion while the input is focused - s re-enters typing mode; suggestion rows scroll into view Search page: recent-searches rows get no bg and no marker while the query input is focused (previously the focused row fell into the inactive branch and rendered the border color as a background). My Shows: w toggles the focused show in/out of the auto-download whitelist at the show list (depth 0), with a whitelist marker on the row and a hint in the preview pane (scope: whitelist).
This commit is contained in:
@@ -178,11 +178,11 @@ export function MyShowsPage() {
|
|||||||
downloadStore.removeDownload(id).catch(() => {});
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
},
|
},
|
||||||
"whitelist-toggle": () => {
|
"whitelist-toggle": () => {
|
||||||
if (depth() < 1) return;
|
|
||||||
const id = drilledShowId();
|
|
||||||
if (!id) return;
|
|
||||||
const prefs = app.state().preferences;
|
const prefs = app.state().preferences;
|
||||||
if (prefs.autoDownloadScope !== "whitelist") return;
|
if (prefs.autoDownloadScope !== "whitelist") return;
|
||||||
|
// depth 0: the focused show; depth ≥1: the drilled show.
|
||||||
|
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
|
||||||
|
if (!id) return;
|
||||||
const cur = prefs.autoDownloadWhitelist ?? [];
|
const cur = prefs.autoDownloadWhitelist ?? [];
|
||||||
const next = cur.includes(id)
|
const next = cur.includes(id)
|
||||||
? cur.filter((x) => x !== id)
|
? cur.filter((x) => x !== id)
|
||||||
@@ -289,6 +289,11 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => focusedShowIdx();
|
const lf = () => focusedShowIdx();
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const wlScope =
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist";
|
||||||
|
const wlInList = (
|
||||||
|
app.state().preferences.autoDownloadWhitelist ?? []
|
||||||
|
).includes(feed.id);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -311,6 +316,19 @@ export function MyShowsPage() {
|
|||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||||
({feed.episodes.length})
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={wlScope}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
index() === lf()
|
||||||
|
? theme.surface
|
||||||
|
: wlInList
|
||||||
|
? theme.warning
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{wlInList ? "●" : "○"}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -410,7 +428,16 @@ export function MyShowsPage() {
|
|||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
<text fg={muted()}>
|
||||||
|
enter/l: open · h: back · x: unsubscribe
|
||||||
|
{app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ??
|
||||||
|
[]
|
||||||
|
).includes(show().id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ function SearchPage() {
|
|||||||
{(query, index) => {
|
{(query, index) => {
|
||||||
const lf = () => focus(0);
|
const lf = () => focus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
// While the input is focused (typing), the list is not
|
||||||
|
// in focus: no bg, no accent fg, no `❯` on any entry.
|
||||||
|
const typing = () => inputActive();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -287,7 +290,11 @@ function SearchPage() {
|
|||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={
|
||||||
|
typing()
|
||||||
|
? undefined
|
||||||
|
: focusBg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(index(), 0);
|
||||||
@@ -297,10 +304,24 @@ function SearchPage() {
|
|||||||
selectRecent(query);
|
selectRecent(query);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text
|
||||||
{index() === lf() ? "❯" : " "}
|
fg={
|
||||||
|
typing()
|
||||||
|
? theme.text
|
||||||
|
: focusFg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{index() === lf() && !typing() ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
typing()
|
||||||
|
? theme.text
|
||||||
|
: focusFg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { useAppStore } from "@/stores/app";
|
|||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
import {
|
import {
|
||||||
NavMode,
|
NavMode,
|
||||||
|
useNavigation,
|
||||||
DEPTH_CENTER_PANE,
|
DEPTH_CENTER_PANE,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
@@ -166,7 +168,7 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
kind: "select",
|
kind: "select",
|
||||||
display: () => scopeLabel(prefs().autoDownloadScope),
|
display: () => scopeLabel(prefs().autoDownloadScope),
|
||||||
help: () =>
|
help: () =>
|
||||||
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on an episode; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
|
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
|
||||||
cycle: (dir) => {
|
cycle: (dir) => {
|
||||||
const idx = SCOPE_LABELS.findIndex(
|
const idx = SCOPE_LABELS.findIndex(
|
||||||
(s) => s.value === prefs().autoDownloadScope,
|
(s) => s.value === prefs().autoDownloadScope,
|
||||||
@@ -299,6 +301,9 @@ const [wlQuery, setWlQuery] = createSignal("");
|
|||||||
const [wlCursor, setWlCursor] = createSignal(0);
|
const [wlCursor, setWlCursor] = createSignal(0);
|
||||||
const [wlTyping, setWlTyping] = createSignal(true);
|
const [wlTyping, setWlTyping] = createSignal(true);
|
||||||
let wlEditorActive = false;
|
let wlEditorActive = false;
|
||||||
|
// Indirection for refocusing the search input from the module-level nav.action
|
||||||
|
// listener (which cannot call useNavigation — that needs the provider).
|
||||||
|
let wlFocusInput: (() => void) | null = null;
|
||||||
|
|
||||||
function wlSuggestions(): Feed[] {
|
function wlSuggestions(): Feed[] {
|
||||||
const q = wlQuery().trim().toLowerCase();
|
const q = wlQuery().trim().toLowerCase();
|
||||||
@@ -349,12 +354,17 @@ const wlOnAction = (data: {
|
|||||||
case "open":
|
case "open":
|
||||||
wlToggle(list[wlCursorClamped()].id);
|
wlToggle(list[wlCursorClamped()].id);
|
||||||
break;
|
break;
|
||||||
|
case "search":
|
||||||
|
// `s` while browsing re-enters typing mode (mirrors SearchPage).
|
||||||
|
wlFocusInput?.();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
on("nav.action", wlOnAction);
|
on("nav.action", wlOnAction);
|
||||||
|
|
||||||
function WhitelistEditor() {
|
function WhitelistEditor() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
|
||||||
@@ -363,8 +373,16 @@ function WhitelistEditor() {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
wlEditorActive = true;
|
wlEditorActive = true;
|
||||||
|
// Restore the last typing/browsing mode across the remounts that
|
||||||
|
// preference updates trigger. nav.inputFocused() drives the input's
|
||||||
|
// focused prop (deterministic Esc-to-blur, same as SearchPage), so
|
||||||
|
// keep the store in sync with the persisted module mode.
|
||||||
|
nav.setInputFocused(wlTyping());
|
||||||
|
wlFocusInput = () => nav.setInputFocused(true);
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
wlEditorActive = false;
|
wlEditorActive = false;
|
||||||
|
nav.setInputFocused(false);
|
||||||
|
wlFocusInput = null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,6 +390,8 @@ function WhitelistEditor() {
|
|||||||
const inputRef = (el: InputRenderable | null | undefined) => {
|
const inputRef = (el: InputRenderable | null | undefined) => {
|
||||||
focusNavRef(el);
|
focusNavRef(el);
|
||||||
if (el) {
|
if (el) {
|
||||||
|
// Sync the persisted mode with real focus changes so remounts
|
||||||
|
// (e.g. after a toggle) restore the right state.
|
||||||
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
|
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
|
||||||
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
|
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
|
||||||
}
|
}
|
||||||
@@ -388,7 +408,7 @@ function WhitelistEditor() {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={wlQuery()}
|
value={wlQuery()}
|
||||||
onInput={setWlQuery}
|
onInput={setWlQuery}
|
||||||
focused={wlTyping()}
|
focused={nav.inputFocused()}
|
||||||
placeholder="Type to filter shows…"
|
placeholder="Type to filter shows…"
|
||||||
width={30}
|
width={30}
|
||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
@@ -402,17 +422,30 @@ function WhitelistEditor() {
|
|||||||
</Show>
|
</Show>
|
||||||
<For each={wlSuggestions()}>
|
<For each={wlSuggestions()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const focused = index() === wlCursorClamped();
|
// While the input is focused (typing), no row shows the
|
||||||
const bg = () => (focused ? theme.primary : undefined);
|
// accent highlight or `❯` — only the input is "in focus".
|
||||||
const fg = () => (focused ? theme.surface : theme.text);
|
const focused = () =>
|
||||||
|
!nav.inputFocused() && index() === wlCursorClamped();
|
||||||
|
const ref = useScrollIntoView(focused);
|
||||||
|
const bg = () => (focused() ? theme.primary : undefined);
|
||||||
|
const fg = () => (focused() ? theme.surface : theme.text);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
backgroundColor={bg()}
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
setWlCursor(index());
|
||||||
|
// Click toggles membership directly (works even
|
||||||
|
// while typing, where Space is input text).
|
||||||
|
wlToggle(feed.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text fg={fg()}>{focused ? "❯" : " "}</text>
|
<text fg={fg()}>{focused() ? "❯" : " "}</text>
|
||||||
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
|
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
|
||||||
<text fg={fg()}>
|
<text fg={fg()}>
|
||||||
{feed.customName || feed.podcast.title}
|
{feed.customName || feed.podcast.title}
|
||||||
@@ -422,7 +455,8 @@ function WhitelistEditor() {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
<text fg={theme.muted ?? theme.textMuted}>
|
<text fg={theme.muted ?? theme.textMuted}>
|
||||||
Type to search · Esc to browse · j/k move · Space toggles · h back
|
Type to search · Esc to browse · j/k move · Space toggles · s to
|
||||||
|
type · h back
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user