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')}