managarten/packages/help/src/components/HelpSearch.svelte
Till JS ab24db36dd fix(packages): cross-package broken imports + missing exports
Five unrelated packages each had a few imports pointing at the wrong
file or missing from their public surface. Grouped because none of
the individual fixes warrants its own commit and they all unblock
the same downstream consumer (apps/mana/apps/web type-check).

packages/help
  - HelpPage.svelte: `'../types.js'` and `'./content'` for
    HelpPageProps/HelpSection/SearchResult — neither path exists.
    Real homes are `../ui-types` (props) and `../search-types`
    (search shapes). Fix the imports.
  - HelpSearch.svelte: same `'../content'` typo for SearchResult →
    `'../search-types'`.
  - translations.ts: `'./types.js'` for HelpPageTranslations →
    `'./ui-types'`.
  - ui-types.ts: was importing SearchResult from `'./content'` but
    that module only exports content shapes. Split into two imports
    so HelpContent stays from content.ts and SearchResult comes from
    search-types.ts.

packages/feedback
  - FeedbackPage.svelte: imported `Feedback` and `CreateFeedbackInput`
    from `'./createFeedbackService'` but the service module only
    exports the service factory. Real homes are `'./feedback'`
    (Feedback) and `'./api'` (CreateFeedbackInput).
  - FeedbackForm.svelte: same `'./feedback'` typo for
    CreateFeedbackInput → `'./api'`.

packages/subscriptions
  - UsageCard / CostCard / pages/SubscriptionPage: all imported
    UsageData / CostItem from `'./plans'` but those types live in
    `'./usage'`. SubscriptionPage additionally had a relative-path
    bug — it's at `src/pages/`, not `src/`, so `./plans` resolved
    to `pages/plans` (nonexistent). Now imports `'../plans'` for
    plan types and `'../usage'` for usage/cost types.

packages/shared-ui
  - index.ts: re-exports the QuickInputItem family from
    `./quick-input` but had forgotten `HighlightPattern`. Added.
    Apps that build their own InputBar pattern config (e.g.
    mana/web/src/lib/quick-input/types.ts) need it as a public type.
  - PillNavigation.svelte: imported `SpotlightAction` and
    `ContentSearcher` from `./GlobalSpotlight.svelte` (a Svelte
    component file), which only re-exports the default. Both types
    live in `./types`. Move them to the existing types-import
    block; the GlobalSpotlight import becomes a plain default.

packages/shared-auth-ui
  - stores/createAuthStore.svelte.ts: imported AuthServiceAdapter /
    AuthResult / BaseUser from `'./types'` (nonexistent — the file
    is `'./store-types'`).

Net: -23 type errors. Zero behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:23:34 +02:00

218 lines
6 KiB
Svelte

<script lang="ts">
import type { HelpSearchProps } from '../ui-types';
import type { SearchResult } from '../search-types';
import { createSearcher } from '../search-engine';
let { content, translations, placeholder, onResultSelect }: HelpSearchProps = $props();
let query = $state('');
let results = $state<SearchResult[]>([]);
let isSearching = $state(false);
let showResults = $state(false);
let selectedIndex = $state(-1);
const searcher = $derived(createSearcher(content));
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
query = target.value;
selectedIndex = -1;
clearTimeout(debounceTimer);
if (query.trim().length < 2) {
results = [];
showResults = false;
return;
}
isSearching = true;
debounceTimer = setTimeout(() => {
results = searcher(query, { limit: 8 });
isSearching = false;
showResults = true;
}, 300);
}
function handleKeyDown(event: KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
selectResult(results[selectedIndex]);
}
break;
case 'Escape':
showResults = false;
selectedIndex = -1;
break;
}
}
function selectResult(result: SearchResult) {
onResultSelect(result);
query = '';
results = [];
showResults = false;
selectedIndex = -1;
}
function handleBlur(event: FocusEvent) {
// Only close if focus moves outside the search container
const relatedTarget = event.relatedTarget as HTMLElement | null;
if (relatedTarget?.closest('[data-help-search]')) return;
showResults = false;
}
function getTypeIcon(type: string): string {
switch (type) {
case 'faq':
return '?';
case 'feature':
return '★';
case 'guide':
return '📖';
case 'changelog':
return '📋';
default:
return '•';
}
}
function getTypeLabel(type: string): string {
switch (type) {
case 'faq':
return 'FAQ';
case 'feature':
return 'Feature';
case 'guide':
return 'Guide';
case 'changelog':
return 'Changelog';
default:
return type;
}
}
</script>
<div class="relative" data-help-search>
<div class="relative">
<input
type="text"
value={query}
oninput={handleInput}
onkeydown={handleKeyDown}
onfocus={() => query.length >= 2 && (showResults = true)}
onblur={handleBlur}
placeholder={placeholder ?? translations.search.noResults}
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
aria-label={placeholder ?? translations.search.noResults}
role="combobox"
aria-expanded={showResults}
aria-haspopup="listbox"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{#if isSearching}
<svg
class="h-5 w-5 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg
class="h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{/if}
</div>
</div>
{#if showResults}
<div
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
{#if results.length === 0}
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{translations.search.noResults.replace('{query}', query)}
</div>
{:else}
<ul class="max-h-96 overflow-auto py-2">
{#each results as result, index (result.id)}
<li role="option" aria-selected={selectedIndex === index}>
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
index
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
onmousedown={(e) => {
e.preventDefault();
selectResult(result);
}}
>
<span
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
aria-label={getTypeLabel(result.type)}
>
{getTypeIcon(result.type)}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900 dark:text-gray-100">
{@html result.highlight ?? result.title}
</span>
<span
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
{getTypeLabel(result.type)}
</span>
</div>
<p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
{result.excerpt}
</p>
</div>
</button>
</li>
{/each}
</ul>
<div
class="border-t border-gray-200 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
{translations.search.resultsCount.replace('{count}', String(results.length))}
</div>
{/if}
</div>
{/if}
</div>