diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d95c1d1..8f7fb46 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -41,7 +41,8 @@ "recent": "Recent", "empty": { "title": "No direct matches", - "subtitle": "You can still filter the task list with this search." + "subtitle": "You can still filter the task list with this search.", + "actionsSubtitle": "No view or action matches. Press backspace to search everything." }, "groups": { "tasks": "Tasks", @@ -76,7 +77,14 @@ "open": "Open", "results_one": "{{count}} result", "results_other": "{{count}} results", - "typeToSearch": "Type to search" + "typeToSearch": "Type to search", + "slashHint": "/ to jump to a view" + }, + "modes": { + "actions": { + "label": "Go to", + "placeholder": "Jump to a view or action" + } } }, "policyUpdate": { diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx index 1a96625..ef62720 100644 --- a/src/search/GlobalSearchPalette.jsx +++ b/src/search/GlobalSearchPalette.jsx @@ -162,6 +162,19 @@ const buildQuickActions = t => [ })), ] +// Typing "/" as the first character switches the palette into navigation mode, +// borrowing the slash-menu reflex from Notion and Slack. The sigil itself is +// stripped from the visible query so the mode reads as state (the chip in the +// input) rather than as syntax the person has to keep typing around. +const MODE_SIGIL = '/' + +const parseMode = value => { + const text = value || '' + return text.startsWith(MODE_SIGIL) + ? { mode: 'actions', term: text.slice(MODE_SIGIL.length).trimStart() } + : { mode: null, term: text } +} + const readRecents = () => { try { return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [] @@ -268,7 +281,8 @@ const GlobalSearchPalette = ({ const focusInputRef = useCallback(node => { if (node) requestAnimationFrame(() => node.focus()) }, []) - const [query, setQuery] = useState(initialQuery || '') + const [query, setQuery] = useState(() => parseMode(initialQuery).term) + const [mode, setMode] = useState(() => parseMode(initialQuery).mode) const [selectedIndex, setSelectedIndex] = useState(0) const [recents] = useState(readRecents) const selectedResultRef = useRef(null) @@ -317,8 +331,34 @@ const GlobalSearchPalette = ({ [documents], ) + const searchActions = useCallback( + normalized => { + const matches = (quickActionIndex.search(normalized, { limit: 8 }) || []) + .map(match => ({ ...match.item, score: match.score ?? 1 })) + .sort((a, b) => a.score - b.score) + // An action whose title the query starts spelling out ("create la…") is + // what the person is after, so it leads. Anything matched only through + // its keywords stays below. + const leading = matches.filter(action => + action.title.toLocaleLowerCase().startsWith(normalized), + ) + return [ + ...leading, + ...matches.filter(action => !leading.includes(action)), + ] + }, + [quickActionIndex], + ) + const results = useMemo(() => { const normalized = query.trim().toLocaleLowerCase() + + // Navigation mode answers only with actions — an empty term lists them all, + // which is how the sigil teaches itself the first time someone hits "/". + if (mode === 'actions') { + return normalized ? searchActions(normalized) : quickActions + } + if (!normalized) { const currentById = new Map(documents.map(item => [item.id, item])) const recentResults = recents @@ -347,15 +387,9 @@ const GlobalSearchPalette = ({ }) .sort((a, b) => a.score - b.score), ) - const actionMatches = ( - quickActionIndex.search(normalized, { limit: 4 }) || [] - ) - .map(match => ({ ...match.item, score: match.score ?? 1 })) - .sort((a, b) => a.score - b.score) - - // An action whose title the query starts spelling out ("create la…") is - // what the person is after, so it leads. Anything matched only through its - // keywords stays below the real content it shares words with. + // Keyword-only action matches stay below the real content they share words + // with; title-prefix matches lead. + const actionMatches = searchActions(normalized).slice(0, 4) const leadingActions = actionMatches.filter(action => action.title.toLocaleLowerCase().startsWith(normalized), ) @@ -375,7 +409,16 @@ const GlobalSearchPalette = ({ route: `/chores?search=${encodeURIComponent(query.trim())}`, }, ] - }, [documents, query, quickActionIndex, recents, searchIndexes, t]) + }, [ + documents, + mode, + query, + quickActions, + recents, + searchActions, + searchIndexes, + t, + ]) // Everything except the always-present "filter the task list" fallback. const matchCount = results.filter( @@ -395,8 +438,25 @@ const GlobalSearchPalette = ({ if (presentation === 'modal') onClose() } + const onQueryChange = value => { + setSelectedIndex(0) + if (!mode) { + const parsed = parseMode(value) + setMode(parsed.mode) + setQuery(parsed.term) + return + } + setQuery(value) + } + const onInputKeyDown = event => { - if (event.key === 'ArrowDown') { + // Backspace on an empty term steps back out of the mode, so the sigil is + // one keystroke to enter and one to leave. + if (event.key === 'Backspace' && mode && !query) { + event.preventDefault() + setMode(null) + setSelectedIndex(0) + } else if (event.key === 'ArrowDown') { event.preventDefault() setSelectedIndex(index => Math.min(index + 1, results.length - 1)) } else if (event.key === 'ArrowUp') { @@ -423,13 +483,30 @@ const GlobalSearchPalette = ({ }, }} value={query} - onChange={event => { - setQuery(event.target.value) - setSelectedIndex(0) - }} + onChange={event => onQueryChange(event.target.value)} onKeyDown={onInputKeyDown} - placeholder={t('search.placeholder')} - startDecorator={} + placeholder={ + mode + ? t('search.modes.actions.placeholder') + : t('search.placeholder') + } + startDecorator={ + mode ? ( + { + setMode(null) + setSelectedIndex(0) + }} + > + {t('search.modes.actions.label')} + + ) : ( + + ) + } endDecorator={ isLoading ? ( @@ -468,7 +545,9 @@ const GlobalSearchPalette = ({ /> {t('search.empty.title')} - {t('search.empty.subtitle')} + {mode + ? t('search.empty.actionsSubtitle') + : t('search.empty.subtitle')} )} @@ -554,8 +633,13 @@ const GlobalSearchPalette = ({ ↑↓ {t('search.footer.navigate')} ↵ {t('search.footer.open')} + {!mode && ( + + {t('search.footer.slashHint')} + + )} - {query.trim() + {query.trim() || mode ? t('search.footer.results', { count: matchCount }) : t('search.footer.typeToSearch')}