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
10 changes: 9 additions & 1 deletion .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,10 @@
"content.edit_content": "Edit content",
"content.edit_entry": "Edit entry",
"content.entry_count": "{count} entry(s)",
"content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries",
"content.entry_count_partial": "Showing {shown} of {total}",
"content.field_required": "This field is required",
"content.filter_keys": "Filter keys...",
"content.filter_keys": "Filter keys or values...",
"content.keep_editing": "Keep editing",
"content.key_column": "Key",
"content.keys_count": "keys",
Expand Down Expand Up @@ -302,7 +304,13 @@
"content.resize_panel": "Resize content panel",
"content.save_all": "Save changes",
"content.save_error": "Failed to save content. Please try again.",
"content.search_entries": "Search entries...",
"content.search_entries_group": "Entries",
"content.search_unavailable_description": "The content index is still loading. Try again in a moment.",
"content.search_unavailable_title": "Search is not ready yet",
"content.searching_description": "Looking through every entry in this model.",
"content.select_entry": "Select an entry",
"content.show_more": "Show more",
"content.slug_hint": "Lowercase letters, numbers, and hyphens only",
"content.stat_entries": "Entries",
"content.stat_locales": "Locales",
Expand Down
10 changes: 7 additions & 3 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ useSeoMeta({
<template>
<NuxtRouteAnnouncer />
<NuxtLoadingIndicator color="#3b82f6" :height="2" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<!-- One tooltip provider for the whole app; the atom falls back to its own
only when mounted outside this scope. See AtomsTooltipScope. -->
<AtomsTooltipScope>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</AtomsTooltipScope>
<OrganismsToastProvider />
</template>
37 changes: 26 additions & 11 deletions app/components/atoms/Tooltip.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
<script setup lang="ts">
import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'radix-vue'
import { tooltipProviderKey } from '~/utils/injection-keys'

/**
* Tooltip that wraps whatever you give it. `InfoTooltip` owns its own button and
* can therefore only ever be an info icon; this takes the trigger as a slot, so
* an action button keeps being an action button.
*
* The provider is inside the atom rather than at the app root because
* `TooltipRoot` throws without one — mounting a component on its own in a test,
* or rendering outside the layout, would break. The cost is Radix's
* cross-tooltip `skipDelayDuration`: the delay is paid per trigger. That is what
* the two hand-rolled stacks already did, so nothing regresses; hoisting the
* provider later is a one-line change here.
*/
withDefaults(defineProps<{
/** Tooltip text. Ignored when the `content` slot is used. */
Expand All @@ -27,12 +21,10 @@ withDefaults(defineProps<{
* close-on-click would immediately undo the tap that opened it.
*/
disableClosingTrigger?: boolean
delayDuration?: number
}>(), {
side: 'top',
sideOffset: 6,
variant: 'text',
delayDuration: 200,
})

/**
Expand All @@ -41,10 +33,33 @@ withDefaults(defineProps<{
* add whatever extra way of opening it needs.
*/
const open = defineModel<boolean | undefined>('open', { default: undefined })

/**
* `TooltipRoot` throws without a provider, and Radix does not export a way to
* ask whether one is present — hence the app root's own flag.
*
* When it is there this renders nothing extra, so the whole app shares one
* provider and Radix's `skipDelayDuration` works: moving between the icons on a
* row opens the second and third instantly instead of re-paying the delay each
* time. When it is absent — a component mounted alone in a test, anything
* outside the layout — the atom supplies its own and still works.
*/
const hasAppProvider = inject(tooltipProviderKey, false)

const PassThrough = defineComponent({
name: 'TooltipProviderPassThrough',
setup(_props, { slots }) {
return () => slots.default?.()
},
})
</script>

<template>
<TooltipProvider :delay-duration="delayDuration">
<component
:is="hasAppProvider ? PassThrough : TooltipProvider"
:delay-duration="hasAppProvider ? undefined : TOOLTIP_DELAY_MS"
:skip-delay-duration="hasAppProvider ? undefined : TOOLTIP_SKIP_DELAY_MS"
>
<TooltipRoot
v-model:open="open"
:disabled="disabled"
Expand All @@ -68,5 +83,5 @@ const open = defineModel<boolean | undefined>('open', { default: undefined })
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</component>
</template>
25 changes: 25 additions & 0 deletions app/components/atoms/TooltipScope.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { TooltipProvider } from 'radix-vue'
import { tooltipProviderKey } from '~/utils/injection-keys'

/**
* The app's single tooltip provider, and the flag that says so.
*
* The two live in one component on purpose. `AtomsTooltip` needs to know
* whether a provider is above it — Radix exports no way to ask — and a flag
* provided without the provider it describes makes every tooltip throw. Binding
* them together means they cannot be separated by a later edit.
*
* Hoisting matters because Radix's `skipDelayDuration` only applies within one
* provider. With a provider per tooltip it never applied at all: scanning the
* three action icons on a row re-paid the open delay each time, and that scan is
* what the tooltips were added for.
*/
provide(tooltipProviderKey, true)
</script>

<template>
<TooltipProvider :delay-duration="TOOLTIP_DELAY_MS" :skip-delay-duration="TOOLTIP_SKIP_DELAY_MS">
<slot />
</TooltipProvider>
</template>
94 changes: 92 additions & 2 deletions app/components/organisms/CommandPalette.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,80 @@ const modeBadge = computed((): { label: string, color: 'primary' | 'secondary' |

const isInProject = computed(() => !!route.params.projectId)

// ── Entry search ───────────────────────────────────────────
// The palette's own help promised "search models, entries, vocabulary" and
// "@modelId to search within a model", and neither searched an entry: nothing
// in the app called `searchContent`. It does now.
//
// `buildResults` is synchronous and `searchContent` is not, so entry hits are
// filled into their own ref and appended, rather than forced into the computed.
const brain = useContentBrain()

interface EntryHit { modelId: string, entryId: string, locale: string, title: string }
const entryHits = ref<EntryHit[]>([])

let entryToken = 0
let entryTimer: ReturnType<typeof setTimeout> | null = null

async function resolveEntryHits(query: string, modelId?: string) {
const token = ++entryToken
const results = await brain.searchContent(query, { modelId, limit: 8 })
if (token !== entryToken) return

const hits: EntryHit[] = []
for (const r of results) {
// The index stores no title — `SearchResult` is ids and a score — so the
// entry is read back and titled the same way the list titles it. Without
// this the palette would list `f3a81c09d24e`.
const model = brain.models.value.find(m => m.id === r.modelId) ?? null
const content = await brain.queryContent(r.modelId, r.locale)
const data = content?.data as Record<string, Record<string, unknown>> | Array<Record<string, unknown>> | null
let entry: Record<string, unknown> | undefined
if (Array.isArray(data)) entry = data.find(d => d.slug === r.entryId)
else if (data) entry = data[r.entryId]

hits.push({
modelId: r.modelId,
entryId: r.entryId,
locale: r.locale,
title: resolveEntryTitle(entry, model, r.entryId),
})
}

if (token !== entryToken) return
entryHits.value = hits
}

watch(parsed, ({ mode, query, modelId }) => {
if (entryTimer) clearTimeout(entryTimer)

const trimmed = query.trim()
const wantsEntries = isInProject.value && trimmed.length > 1 && (mode === 'global' || mode === 'model')
if (!wantsEntries) {
entryToken++
entryHits.value = []
return
}

entryTimer = setTimeout(() => resolveEntryHits(trimmed, mode === 'model' ? modelId : undefined), 150)
})

onBeforeUnmount(() => {
if (entryTimer) clearTimeout(entryTimer)
})

const entryResults = computed<ResultItem[]>(() => entryHits.value.map(hit => ({
id: `entry:${hit.modelId}:${hit.entryId}`,
label: hit.title,
sublabel: hit.modelId,
icon: 'icon-[annon--file-text]',
group: t('content.search_entries_group'),
type: 'entry',
action: () => navigateToEntry(hit),
})))

// Build results via composable
const results = computed<ResultItem[]>(() => {
const baseResults = computed<ResultItem[]>(() => {
const { mode, query, modelId } = parsed.value
return buildResults({
mode,
Expand All @@ -96,10 +168,17 @@ const results = computed<ResultItem[]>(() => {
})
})

// Entries go after the synchronous items so keyboard navigation starts where it
// always did — the top result does not jump when an async search lands.
const results = computed<ResultItem[]>(() => [...baseResults.value, ...entryResults.value])

const groupedResults = computed(() => groupResults(results.value))
const hasResults = computed(() => results.value.length > 0)

watch(results, () => {
// Reset the cursor when the QUERY changes, not when results do: entry hits
// arrive a beat later, and resetting on those would yank the selection out from
// under someone already arrowing through the list.
watch(baseResults, () => {
selectedIndex.value = 0
})

Expand Down Expand Up @@ -198,6 +277,17 @@ function navigateToModel(modelId: string, modelName: string) {
open.value = false
}

/**
* Open the model AND point at the entry. Opening the model alone would hand
* someone a thousand rows and let them find it themselves, which is what they
* used the search for.
*/
function navigateToEntry(hit: EntryHit) {
addRecent({ id: `entry:${hit.modelId}:${hit.entryId}`, label: hit.title, sublabel: hit.modelId, icon: 'icon-[annon--file-text]', type: 'entry' })
router.replace({ query: { model: hit.modelId, entry: hit.entryId } })
open.value = false
}

function navigateToBranch(branchName: string) {
addRecent({ id: `branch:${branchName}`, label: branchName.replace('contentrain/', ''), icon: 'icon-[annon--arrow-swap]', type: 'branch' })
router.replace({ query: { branch: encodeURIComponent(branchName) } })
Expand Down
Loading
Loading