SearchableTextBlock: animated accordion, tag counts, empty state, focus-visible
- Replace <details> with Svelte-controlled accordion + slide transition (180ms) - Close all items on search/tag filter change - Live tag counts per filter showing matches for current search query - Visual empty state with reset button when no results - focus-visible ring on all interactive elements (was focus: — shows on click too) - Tag.svelte: remove ring-0, add focus-visible:ring-2 - Card.svelte: add focus-visible ring to linked cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
variants: {
|
||||
variant: {
|
||||
callout: "cursor-pointer transition-colors no-underline text-inherit hover:bg-wald-100",
|
||||
tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300",
|
||||
tile: "transition-[transform,box-shadow,border-color] duration-200 no-underline hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "tile" },
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
const baseClass =
|
||||
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0";
|
||||
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1";
|
||||
const variantClass = $derived.by(() => {
|
||||
if (customColor?.trim()) {
|
||||
return "";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "$lib/markdown-safe";
|
||||
import { slide } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||
import type {
|
||||
SearchableTextBlockData,
|
||||
@@ -27,7 +29,6 @@
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const fragments = $derived.by(() => {
|
||||
@@ -64,6 +65,39 @@
|
||||
let searchQuery = $state("");
|
||||
let selectedTag = $state("all");
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
let openTitles = $state(new Set<string>());
|
||||
|
||||
function toggleItem(key: string) {
|
||||
const s = new Set(openTitles);
|
||||
if (s.has(key)) s.delete(key);
|
||||
else s.add(key);
|
||||
openTitles = s;
|
||||
}
|
||||
|
||||
// Close all open items when search or tag filter changes
|
||||
$effect(() => {
|
||||
searchQuery;
|
||||
selectedTag;
|
||||
openTitles = new Set();
|
||||
});
|
||||
|
||||
// Per-tag counts based on current search (ignoring tag filter) — updates live as user types
|
||||
const tagCounts = $derived.by(() => {
|
||||
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const counts = new Map<string, number>();
|
||||
let allCount = 0;
|
||||
for (const f of fragments) {
|
||||
const titleNorm = f.title.toLowerCase().replace(/\s+/g, " ");
|
||||
const textNorm = f.text.toLowerCase().replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
||||
const matchesSearch = q === "" || titleNorm.includes(q) || textNorm.includes(q);
|
||||
if (!matchesSearch) continue;
|
||||
allCount++;
|
||||
for (const tag of f.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return { counts, allCount };
|
||||
});
|
||||
|
||||
async function copyFragmentContent(
|
||||
fragment: { title: string; text: string },
|
||||
@@ -186,7 +220,7 @@
|
||||
<Tooltip content={helpTooltipText} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-xs text-stein-400 hover:text-wald-600 hover:bg-wald-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
|
||||
class="shrink-0 p-1.5 rounded-xs text-stein-400 hover:text-wald-600 hover:bg-wald-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-1"
|
||||
aria-label={t(T.searchable_text_help_aria)}
|
||||
>
|
||||
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
|
||||
@@ -195,7 +229,7 @@
|
||||
<input
|
||||
type="search"
|
||||
placeholder={t(T.searchable_text_placeholder)}
|
||||
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-xs focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-white text-stein-900 placeholder:text-stein-400"
|
||||
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:border-wald-400 bg-white text-stein-900 placeholder:text-stein-400"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label={t(T.searchable_text_search_aria)}
|
||||
@@ -203,7 +237,7 @@
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-xs hover:bg-stein-100 text-stein-400 hover:text-stein-700 transition-colors"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-xs hover:bg-stein-100 text-stein-400 hover:text-stein-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label={t(T.searchable_text_clear_search)}
|
||||
>
|
||||
@@ -220,14 +254,14 @@
|
||||
<div class="overflow-x-auto -mx-1 px-1 pt-1 pb-1">
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<Tag
|
||||
label={t(T.searchable_text_tag_all)}
|
||||
label={`${t(T.searchable_text_tag_all)} (${tagCounts.allCount})`}
|
||||
variant={selectedTag === "all" ? "green" : "white"}
|
||||
active={selectedTag === "all"}
|
||||
onclick={() => (selectedTag = "all")}
|
||||
/>
|
||||
{#each allTags as tag}
|
||||
<Tag
|
||||
label={tag}
|
||||
label={`${tag} (${tagCounts.counts.get(tag) ?? 0})`}
|
||||
variant={selectedTag === tag ? "green" : "white"}
|
||||
active={selectedTag === tag}
|
||||
onclick={() => (selectedTag = tag)}
|
||||
@@ -243,86 +277,118 @@
|
||||
class="text-xs text-stein-500 px-4 md:px-6 py-2 bg-stein-50 border-b border-stein-100"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>{t(T.searchable_text_no_results)}</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
||||
: t(T.searchable_text_count_filtered, {
|
||||
visible: visibleFragments.length,
|
||||
total: fragments.length,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
||||
: t(T.searchable_text_count_filtered, {
|
||||
visible: visibleFragments.length,
|
||||
total: fragments.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col divide-y divide-stein-100">
|
||||
{#each visibleFragments as fragment, i}
|
||||
<details class="group border-l-4 border-l-transparent open:border-l-wald-500 transition-colors bg-white open:bg-wald-50/30">
|
||||
<summary
|
||||
class="cursor-pointer list-none px-4 md:px-6 py-3.5 hover:bg-wald-50/60 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="grow min-w-0">
|
||||
{@html highlightInText(
|
||||
fragment.title || t(T.searchable_text_untitled),
|
||||
searchQuery.trim(),
|
||||
)}
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-0 group-hover:opacity-100 inline-flex items-center gap-1 px-2 py-1 text-xs font-normal rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-all"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-3.5 text-wald-600" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-3.5" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
class="text-stein-400 group-open:rotate-180 transition-transform size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<Tags tags={fragment.tags} variant="white" />
|
||||
</span>
|
||||
{/if}
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 py-4 bg-white border-t border-stein-100 markdown prose prose-zinc prose-sm max-w-none text-xs">
|
||||
<div class="flex justify-end mb-3 -mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xs border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
|
||||
<span>{t(T.searchable_text_copied)}</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
|
||||
<span>{t(T.searchable_text_copy_button)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
||||
{#if visibleFragments.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center">
|
||||
<div class="size-12 rounded-full bg-stein-100 flex items-center justify-center">
|
||||
<Icon icon="mdi:magnify" class="size-6 text-stein-400" aria-hidden="true" />
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
<div>
|
||||
<div class="text-sm font-medium text-stein-600">{t(T.searchable_text_no_results)}</div>
|
||||
<div class="text-xs text-stein-400 mt-0.5">Suchbegriff oder Filter anpassen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
|
||||
onclick={() => { searchQuery = ""; selectedTag = "all"; }}
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each visibleFragments as fragment, i}
|
||||
{@const itemKey = fragment.title || String(i)}
|
||||
{@const isOpen = openTitles.has(itemKey)}
|
||||
<div
|
||||
class="group/item border-l-4 transition-colors {isOpen
|
||||
? 'border-l-wald-500 bg-wald-50/30'
|
||||
: 'border-l-transparent bg-white'}"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded={isOpen}
|
||||
class="cursor-pointer px-4 md:px-6 py-3.5 hover:bg-wald-50/60 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-wald-500"
|
||||
onclick={() => toggleItem(itemKey)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
(e.preventDefault(), toggleItem(itemKey))}
|
||||
>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="grow min-w-0">
|
||||
{@html highlightInText(
|
||||
fragment.title || t(T.searchable_text_untitled),
|
||||
searchQuery.trim(),
|
||||
)}
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-0 group-hover/item:opacity-100 focus-visible:opacity-100 inline-flex items-center gap-1 px-2 py-1 text-xs font-normal rounded-xs border border-stein-200 bg-white text-stein-600 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-3.5 text-wald-600" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-3.5" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
class="text-stein-400 transition-transform size-5 {isOpen ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<Tags tags={fragment.tags} variant="white" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 180, easing: cubicOut }}
|
||||
class="px-4 md:px-6 py-4 bg-white border-t border-stein-100 markdown prose prose-zinc prose-sm max-w-none text-xs"
|
||||
>
|
||||
<div class="flex justify-end mb-3 -mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xs border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-300 hover:text-wald-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
|
||||
<span>{t(T.searchable_text_copied)}</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
|
||||
<span>{t(T.searchable_text_copy_button)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user