@urbicon-ui/blocks 6.4.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@
8
8
  let {
9
9
  id,
10
10
  title,
11
+ group,
11
12
  children,
12
13
  class: className = '',
13
14
  unstyled: unstyledProp = false,
@@ -21,9 +22,9 @@
21
22
 
22
23
  // Register with the parent panel's list view. Runs regardless of whether this
23
24
  // article is the active one, so every article shows up in the list. Cleans up
24
- // on unmount and re-registers if id/title change.
25
+ // on unmount and re-registers if id/title/group change.
25
26
  $effect(() => {
26
- return panel?.registerArticle(id, title);
27
+ return panel?.registerArticle(id, title, group);
27
28
  });
28
29
 
29
30
  const blocksConfig = getBlocksConfig();
@@ -6,6 +6,7 @@
6
6
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
7
7
  import { getGuideContext } from './guide.context';
8
8
  import { setGuidePanelContext } from './guide-panel.context';
9
+ import { filterArticles, groupArticles, hasNamedGroups } from './guide-panel.articles';
9
10
  import { guidePanelVariants, type GuidePanelVariants } from './guide.variants';
10
11
  import type { GuidePanelProps } from './index';
11
12
 
@@ -16,6 +17,7 @@
16
17
  placement = 'right',
17
18
  size = 'md',
18
19
  title,
20
+ searchable = false,
19
21
  closeOnEscape = true,
20
22
  footer,
21
23
  children,
@@ -44,12 +46,16 @@
44
46
  // Article registry feeding the list view. GuideArticle children register here.
45
47
  // untrack mirrors GuideController.registerTarget: writes happen from a child's
46
48
  // $effect, so untracking guards the list derived against effect_update_depth_exceeded.
47
- const articleMap = new SvelteMap<string, string>();
49
+ // SvelteMap preserves insertion order, so the index follows definition order.
50
+ const articleMap = new SvelteMap<string, { title: string; group?: string }>();
48
51
  setGuidePanelContext({
49
- registerArticle(id, articleTitle) {
50
- untrack(() => articleMap.set(id, articleTitle));
52
+ registerArticle(id, articleTitle, group) {
53
+ untrack(() => articleMap.set(id, { title: articleTitle, group }));
51
54
  return () => untrack(() => articleMap.delete(id));
52
- }
55
+ },
56
+ // Tracked read (no untrack): a GuideRef's reactive resolvable re-runs as
57
+ // articles register/unregister.
58
+ hasArticle: (id) => articleMap.has(id)
53
59
  });
54
60
 
55
61
  const blocksConfig = getBlocksConfig();
@@ -62,11 +68,28 @@
62
68
 
63
69
  const open = $derived(guide?.panelOpen ?? false);
64
70
  const activeArticle = $derived(guide?.activeArticle ?? null);
65
- const articles = $derived([...articleMap].map(([id, t]) => ({ id, title: t })));
71
+ const articles = $derived(
72
+ // Normalize a blank/whitespace `group` to `undefined` so it collapses into the
73
+ // ungrouped block instead of rendering an empty (padded) section header.
74
+ [...articleMap].map(([id, a]) => ({ id, title: a.title, group: a.group?.trim() || undefined }))
75
+ );
66
76
  const headerTitle = $derived(
67
- (activeArticle ? articleMap.get(activeArticle) : undefined) ?? title ?? bt('guide.openHelp', {})
77
+ (activeArticle ? articleMap.get(activeArticle)?.title : undefined) ??
78
+ title ??
79
+ bt('guide.openHelp', {})
68
80
  );
69
81
 
82
+ // Optional title filter (#26). Grouping below runs on the filtered set, so a
83
+ // search narrows the index while keeping non-empty sections' headers.
84
+ let searchQuery = $state('');
85
+ const filteredArticles = $derived(searchable ? filterArticles(articles, searchQuery) : articles);
86
+ const isFiltering = $derived(searchable && searchQuery.trim().length > 0);
87
+
88
+ // Group the index into sections (first-occurrence order; ungrouped articles in
89
+ // one headerless block). When no article sets a group, this is a flat list.
90
+ const sections = $derived(groupArticles(filteredArticles));
91
+ const hasGroups = $derived(hasNamedGroups(sections));
92
+
70
93
  const panelTransform = $derived(
71
94
  open ? 'translateX(0)' : placement === 'left' ? 'translateX(-100%)' : 'translateX(100%)'
72
95
  );
@@ -180,25 +203,79 @@
180
203
  </header>
181
204
 
182
205
  <div class={unstyled ? (slotClasses?.body ?? '') : styles.body({ class: slotClasses?.body })}>
206
+ {#snippet articleListItem(article: { id: string; title: string })}
207
+ <li>
208
+ <button
209
+ type="button"
210
+ class={unstyled
211
+ ? (slotClasses?.listItem ?? '')
212
+ : styles.listItem({ class: slotClasses?.listItem })}
213
+ onclick={() => guide?.setArticle(article.id)}
214
+ >
215
+ <span class="flex-1">{article.title}</span>
216
+ <ChevronRightIcon class="h-4 w-4 opacity-50" />
217
+ </button>
218
+ </li>
219
+ {/snippet}
183
220
  {#if !activeArticle}
184
- <ul
185
- class={unstyled ? (slotClasses?.list ?? '') : styles.list({ class: slotClasses?.list })}
186
- >
187
- {#each articles as article (article.id)}
188
- <li>
189
- <button
190
- type="button"
221
+ {#if searchable}
222
+ <input
223
+ type="search"
224
+ class={unstyled
225
+ ? (slotClasses?.searchInput ?? '')
226
+ : styles.searchInput({ class: slotClasses?.searchInput })}
227
+ placeholder={bt('guide.filterPlaceholder', {})}
228
+ aria-label={bt('guide.filterPlaceholder', {})}
229
+ bind:value={searchQuery}
230
+ />
231
+ <!-- Persistent polite announcer: present in the a11y tree before the result
232
+ set empties, so a filter narrowing to zero is reliably announced (a region
233
+ inserted together with its text is dropped by some screen readers). The
234
+ visible empty-state below carries no role, to avoid a duplicate region. -->
235
+ <p aria-live="polite" class="sr-only">
236
+ {#if isFiltering && filteredArticles.length === 0}{bt('guide.noResults', {})}{/if}
237
+ </p>
238
+ {/if}
239
+ {#if isFiltering && filteredArticles.length === 0}
240
+ <p
241
+ class={unstyled
242
+ ? (slotClasses?.noResults ?? '')
243
+ : styles.noResults({ class: slotClasses?.noResults })}
244
+ >
245
+ {bt('guide.noResults', {})}
246
+ </p>
247
+ {:else if hasGroups}
248
+ {#each sections as section, i (section.group ?? '')}
249
+ {#if section.group}
250
+ <h3
251
+ id={`${panelId}-group-${i}`}
191
252
  class={unstyled
192
- ? (slotClasses?.listItem ?? '')
193
- : styles.listItem({ class: slotClasses?.listItem })}
194
- onclick={() => guide.setArticle(article.id)}
253
+ ? (slotClasses?.groupHeader ?? '')
254
+ : styles.groupHeader({ class: slotClasses?.groupHeader })}
195
255
  >
196
- <span class="flex-1">{article.title}</span>
197
- <ChevronRightIcon class="h-4 w-4 opacity-50" />
198
- </button>
199
- </li>
256
+ {section.group}
257
+ </h3>
258
+ {/if}
259
+ <ul
260
+ class={unstyled
261
+ ? (slotClasses?.list ?? '')
262
+ : styles.list({ class: slotClasses?.list })}
263
+ aria-labelledby={section.group ? `${panelId}-group-${i}` : undefined}
264
+ >
265
+ {#each section.articles as article (article.id)}
266
+ {@render articleListItem(article)}
267
+ {/each}
268
+ </ul>
200
269
  {/each}
201
- </ul>
270
+ {:else}
271
+ <ul
272
+ class={unstyled ? (slotClasses?.list ?? '') : styles.list({ class: slotClasses?.list })}
273
+ >
274
+ {#each filteredArticles as article (article.id)}
275
+ {@render articleListItem(article)}
276
+ {/each}
277
+ </ul>
278
+ {/if}
202
279
  {/if}
203
280
  {@render children?.()}
204
281
  </div>
@@ -0,0 +1,87 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
+ import { getGuideContext } from './guide.context';
5
+ import { getGuidePanelContext } from './guide-panel.context';
6
+ import { guideRefVariants, type GuideRefVariants } from './guide.variants';
7
+ import type { GuideRefProps } from './index';
8
+
9
+ let {
10
+ article,
11
+ children,
12
+ class: className = '',
13
+ unstyled: unstyledProp = false,
14
+ slotClasses: slotClassesProp = {},
15
+ preset,
16
+ ...restProps
17
+ }: GuideRefProps = $props();
18
+
19
+ const guide = getGuideContext();
20
+ const panel = getGuidePanelContext();
21
+
22
+ if (import.meta.env?.DEV && !guide) {
23
+ console.warn(
24
+ '[Guide] <GuideRef> is used without a <GuideProvider> ancestor — it renders as plain text.'
25
+ );
26
+ } else if (import.meta.env?.DEV && !panel) {
27
+ console.warn('[Guide] <GuideRef> is used outside a <GuidePanel> — it renders as plain text.');
28
+ }
29
+
30
+ const blocksConfig = getBlocksConfig();
31
+ const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
32
+ const variantProps: GuideRefVariants = $derived({});
33
+ const styles = $derived(guideRefVariants(variantProps));
34
+ const slotClasses = $derived(
35
+ resolveSlotClasses(blocksConfig, 'GuideRef', preset, variantProps, slotClassesProp)
36
+ );
37
+
38
+ // Interactive only when wired to a panel that actually registers the target
39
+ // article; otherwise (no provider, outside a panel, or an unknown id) it degrades
40
+ // to plain inline text — mirrors GuideMention's no-provider fallback. Articles
41
+ // register from a child `$effect`, so `hasArticle` is false during SSR *and* the
42
+ // first client render → both emit a `<span>` (no hydration mismatch); a known
43
+ // article reactively upgrades to a `<button>` once its registration effect runs.
44
+ const resolvable = $derived(!!panel?.hasArticle(article));
45
+ const interactive = $derived(!!guide && resolvable);
46
+
47
+ // DEV-only: warn for an article id that stays unresolved past this flush — deferred
48
+ // via `tick()` so a target `<GuideArticle>` registering in the same render isn't a
49
+ // false positive (mirrors GuidePanel's missing-active-article warning).
50
+ $effect(() => {
51
+ if (!import.meta.env?.DEV || !guide || !panel) return;
52
+ const target = article;
53
+ let cancelled = false;
54
+ tick().then(() => {
55
+ if (!cancelled && !panel.hasArticle(target)) {
56
+ console.warn(
57
+ `[Guide] <GuideRef article="${target}"> points at an article that is not registered — it renders as plain text.`
58
+ );
59
+ }
60
+ });
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ });
65
+
66
+ function navigate() {
67
+ guide?.setArticle(article);
68
+ }
69
+ </script>
70
+
71
+ {#if interactive}
72
+ <button
73
+ type="button"
74
+ class={unstyled
75
+ ? [slotClasses?.ref, className].filter(Boolean).join(' ')
76
+ : styles.ref({ class: [slotClasses?.ref, className] })}
77
+ data-guide-ref
78
+ onclick={navigate}
79
+ {...restProps}
80
+ >
81
+ {@render children?.()}
82
+ </button>
83
+ {:else}
84
+ <span class={[slotClasses?.ref, className].filter(Boolean).join(' ') || undefined} {...restProps}>
85
+ {@render children?.()}
86
+ </span>
87
+ {/if}
@@ -0,0 +1,4 @@
1
+ import type { GuideRefProps } from './index.js';
2
+ declare const GuideRef: import("svelte").Component<GuideRefProps, {}, "">;
3
+ type GuideRef = ReturnType<typeof GuideRef>;
4
+ export default GuideRef;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pure transforms for the `GuidePanel` article index — grouping (#25) and
3
+ * filtering (#26). Kept framework-free so the index logic is unit-testable
4
+ * without rendering the panel (mirrors `pagination.engine` / `planner.bucket`).
5
+ */
6
+ /** A registered article as surfaced to the panel index. */
7
+ export interface IndexArticle {
8
+ id: string;
9
+ title: string;
10
+ /** Optional section label; `undefined` means the ungrouped block. */
11
+ group?: string;
12
+ }
13
+ /** One section of the grouped index. */
14
+ export interface ArticleSection {
15
+ /** The section's group label, or `undefined` for the headerless ungrouped block. */
16
+ group: string | undefined;
17
+ articles: IndexArticle[];
18
+ }
19
+ /**
20
+ * Group articles into sections keyed by `group`, in first-occurrence order over
21
+ * the (insertion-ordered) input. Articles without a `group` collect into one
22
+ * headerless section, positioned where its first ungrouped article appears.
23
+ * When no article sets a `group`, the result is a single ungrouped section —
24
+ * i.e. a flat list.
25
+ */
26
+ export declare function groupArticles(articles: IndexArticle[]): ArticleSection[];
27
+ /** Whether any section carries a real group label (drives grouped vs. flat rendering). */
28
+ export declare function hasNamedGroups(sections: ArticleSection[]): boolean;
29
+ /**
30
+ * Filter articles by a case-insensitive substring match on the title. A blank
31
+ * query returns the input unchanged. Whitespace around the query is ignored.
32
+ */
33
+ export declare function filterArticles(articles: IndexArticle[], query: string): IndexArticle[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure transforms for the `GuidePanel` article index — grouping (#25) and
3
+ * filtering (#26). Kept framework-free so the index logic is unit-testable
4
+ * without rendering the panel (mirrors `pagination.engine` / `planner.bucket`).
5
+ */
6
+ /**
7
+ * Group articles into sections keyed by `group`, in first-occurrence order over
8
+ * the (insertion-ordered) input. Articles without a `group` collect into one
9
+ * headerless section, positioned where its first ungrouped article appears.
10
+ * When no article sets a `group`, the result is a single ungrouped section —
11
+ * i.e. a flat list.
12
+ */
13
+ export function groupArticles(articles) {
14
+ const byGroup = new Map();
15
+ for (const article of articles) {
16
+ const key = article.group ?? '';
17
+ let section = byGroup.get(key);
18
+ if (!section) {
19
+ section = { group: article.group, articles: [] };
20
+ byGroup.set(key, section);
21
+ }
22
+ section.articles.push(article);
23
+ }
24
+ return [...byGroup.values()];
25
+ }
26
+ /** Whether any section carries a real group label (drives grouped vs. flat rendering). */
27
+ export function hasNamedGroups(sections) {
28
+ return sections.some((s) => s.group !== undefined);
29
+ }
30
+ /**
31
+ * Filter articles by a case-insensitive substring match on the title. A blank
32
+ * query returns the input unchanged. Whitespace around the query is ignored.
33
+ */
34
+ export function filterArticles(articles, query) {
35
+ const q = query.trim().toLowerCase();
36
+ if (!q)
37
+ return articles;
38
+ return articles.filter((a) => a.title.toLowerCase().includes(q));
39
+ }
@@ -4,8 +4,17 @@
4
4
  * outside a `GuidePanel` simply renders inert.
5
5
  */
6
6
  export interface GuidePanelContext {
7
- /** Register an article (by title) and return an unregister cleanup. */
8
- registerArticle(id: string, title: string): () => void;
7
+ /**
8
+ * Register an article (by title, with an optional `group` for the index's
9
+ * section headers) and return an unregister cleanup.
10
+ */
11
+ registerArticle(id: string, title: string, group?: string): () => void;
12
+ /**
13
+ * Whether an article with this id is currently registered. Reactive — lets a
14
+ * `GuideRef` degrade to plain text for an unknown article. Reads the registry
15
+ * untracked-free so callers re-run when articles register/unregister.
16
+ */
17
+ hasArticle(id: string): boolean;
9
18
  }
10
19
  declare const getGuidePanelContext: () => GuidePanelContext | undefined, setGuidePanelContext: (value: GuidePanelContext | undefined) => GuidePanelContext | undefined;
11
20
  export { getGuidePanelContext, setGuidePanelContext };
@@ -68,6 +68,27 @@ export declare const guidePanelVariants: (props?: import("../../utils/variants.j
68
68
  class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
69
69
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
70
70
  }) | undefined) => string;
71
+ searchInput: (props?: ({
72
+ placement?: "right" | "left" | undefined;
73
+ size?: "sm" | "md" | "lg" | undefined;
74
+ } & {
75
+ class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
76
+ className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
77
+ }) | undefined) => string;
78
+ noResults: (props?: ({
79
+ placement?: "right" | "left" | undefined;
80
+ size?: "sm" | "md" | "lg" | undefined;
81
+ } & {
82
+ class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
83
+ className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
84
+ }) | undefined) => string;
85
+ groupHeader: (props?: ({
86
+ placement?: "right" | "left" | undefined;
87
+ size?: "sm" | "md" | "lg" | undefined;
88
+ } & {
89
+ class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
90
+ className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
91
+ }) | undefined) => string;
71
92
  list: (props?: ({
72
93
  placement?: "right" | "left" | undefined;
73
94
  size?: "sm" | "md" | "lg" | undefined;
@@ -140,6 +161,18 @@ export declare const guideMentionVariants: (props?: import("../../utils/variants
140
161
  className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
141
162
  }) | undefined) => string;
142
163
  };
164
+ /**
165
+ * Styling for `GuideRef` — an inline article→article link inside article prose.
166
+ * Mirrors `GuideMention`'s link-like reset, but with a *solid* underline (vs the
167
+ * Mention's dotted one) that thickens on hover/focus, so a panel-internal jump
168
+ * reads distinctly from a Mention's UI highlight.
169
+ */
170
+ export declare const guideRefVariants: (props?: import("../../utils/variants.js").TVProps<{}> | undefined) => {
171
+ ref: (props?: ({} & {
172
+ class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
173
+ className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
174
+ }) | undefined) => string;
175
+ };
143
176
  /**
144
177
  * Styling for `GuideHint` — a small, non-blocking bubble anchored to a
145
178
  * `data-guide` element via `floating.ts`, rendered in the native popover
@@ -275,6 +308,7 @@ export type GuideBeaconVariants = VariantProps<typeof guideBeaconVariants>;
275
308
  export type GuideArticleVariants = VariantProps<typeof guideArticleVariants>;
276
309
  export type GuideMarkerVariants = VariantProps<typeof guideMarkerVariants>;
277
310
  export type GuideMentionVariants = VariantProps<typeof guideMentionVariants>;
311
+ export type GuideRefVariants = VariantProps<typeof guideRefVariants>;
278
312
  export type GuideHintVariants = VariantProps<typeof guideHintVariants>;
279
313
  /** Slot names derived from each `tv()` config — single source of truth for `slotClasses`. */
280
314
  export type GuidePanelSlots = SlotNames<typeof guidePanelVariants>;
@@ -283,4 +317,5 @@ export type GuideBeaconSlots = SlotNames<typeof guideBeaconVariants>;
283
317
  export type GuideArticleSlots = SlotNames<typeof guideArticleVariants>;
284
318
  export type GuideMarkerSlots = SlotNames<typeof guideMarkerVariants>;
285
319
  export type GuideMentionSlots = SlotNames<typeof guideMentionVariants>;
320
+ export type GuideRefSlots = SlotNames<typeof guideRefVariants>;
286
321
  export type GuideHintSlots = SlotNames<typeof guideHintVariants>;
@@ -25,6 +25,19 @@ export const guidePanelVariants = tv({
25
25
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary/50'
26
26
  ],
27
27
  body: ['flex-1 overflow-y-auto overscroll-contain px-5 py-4'],
28
+ // Filter input above the index (opt-in via GuidePanel `searchable`).
29
+ searchInput: [
30
+ 'mb-3 w-full rounded-lg border border-border-hairline bg-surface-base px-3 py-2 text-sm',
31
+ 'text-text-primary placeholder:text-text-tertiary',
32
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary/50'
33
+ ],
34
+ // Empty-state shown when a search filters every article out.
35
+ noResults: ['px-3 py-6 text-center text-sm text-text-tertiary'],
36
+ // Section label above a group of articles in the index (opt-in via GuideArticle `group`).
37
+ groupHeader: [
38
+ 'px-3 pb-1 pt-4 text-xs font-semibold uppercase tracking-wide text-text-tertiary',
39
+ 'first:pt-1'
40
+ ],
28
41
  list: ['flex flex-col gap-1'],
29
42
  listItem: [
30
43
  'flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
@@ -107,6 +120,23 @@ export const guideMentionVariants = tv({
107
120
  ]
108
121
  }
109
122
  });
123
+ /**
124
+ * Styling for `GuideRef` — an inline article→article link inside article prose.
125
+ * Mirrors `GuideMention`'s link-like reset, but with a *solid* underline (vs the
126
+ * Mention's dotted one) that thickens on hover/focus, so a panel-internal jump
127
+ * reads distinctly from a Mention's UI highlight.
128
+ */
129
+ export const guideRefVariants = tv({
130
+ slots: {
131
+ ref: [
132
+ 'm-0 inline cursor-pointer border-0 bg-transparent p-0 text-left font-medium text-primary',
133
+ 'underline decoration-solid decoration-1 underline-offset-2',
134
+ 'transition-[text-decoration-thickness] duration-[var(--blocks-duration-fast)]',
135
+ 'hover:decoration-2 focus-visible:decoration-2',
136
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary/50 focus-visible:rounded-[0.2em]'
137
+ ]
138
+ }
139
+ });
110
140
  /**
111
141
  * Styling for `GuideHint` — a small, non-blocking bubble anchored to a
112
142
  * `data-guide` element via `floating.ts`, rendered in the native popover
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { GuideController, GuideDirection, GuideStorageAdapter, GuideTour, Placement } from '../../utils/index.js';
3
- import type { GuideArticleSlots, GuideBeaconSlots, GuideBeaconVariants, GuideHintSlots, GuideMarkerSlots, GuideMarkerVariants, GuideMentionSlots, GuidePanelSlots, GuidePanelVariants, GuideTourSlots } from './guide.variants.js';
3
+ import type { GuideArticleSlots, GuideBeaconSlots, GuideBeaconVariants, GuideHintSlots, GuideMarkerSlots, GuideMarkerVariants, GuideMentionSlots, GuidePanelSlots, GuidePanelVariants, GuideRefSlots, GuideTourSlots } from './guide.variants.js';
4
4
  /**
5
5
  * @description Root provider for the Guide help system. Instantiates a `GuideController`
6
6
  * and shares it via context with all Guide surfaces (Panel, Marker, Mention, Hint, Tour).
@@ -74,6 +74,13 @@ export interface GuidePanelProps {
74
74
  size?: GuidePanelVariants['size'];
75
75
  /** Heading shown when no article is open. @default i18n `guide.openHelp` */
76
76
  title?: string;
77
+ /**
78
+ * Render a filter input above the article index that matches article titles
79
+ * (case-insensitive). Off by default. Pairs with article grouping — filtered
80
+ * results keep their section headers and empty sections disappear.
81
+ * @default false
82
+ */
83
+ searchable?: boolean;
77
84
  /** Close the panel when Escape is pressed. @default true */
78
85
  closeOnEscape?: boolean;
79
86
  /** Optional footer content. */
@@ -111,6 +118,14 @@ export interface GuideArticleProps {
111
118
  id: string;
112
119
  /** Title shown in the panel list and header. */
113
120
  title: string;
121
+ /**
122
+ * Optional section this article belongs to in the panel index. Articles that
123
+ * share a `group` are rendered under one section header (sections appear in
124
+ * the order their first article is defined); articles without a `group` stay
125
+ * in an ungrouped block. When no article sets a `group`, the index is a flat
126
+ * list — unchanged from today.
127
+ */
128
+ group?: string;
114
129
  /** Article body. */
115
130
  children?: Snippet;
116
131
  /** Additional classes on the article root. */
@@ -216,6 +231,41 @@ export interface GuideMentionProps {
216
231
  /** Apply a named preset registered via `<BlocksProvider presets={{ GuideMention: {...} }}>`. */
217
232
  preset?: string;
218
233
  }
234
+ /**
235
+ * @description An inline article→article link inside a `GuideArticle` body — the
236
+ * help-internal analogue of `GuideMention` (which links out to a UI element). A real
237
+ * `<button>` that navigates the open `GuidePanel` to the target article via the
238
+ * controller (`setArticle`). Degrades to plain inline text without a `GuideProvider`,
239
+ * outside a `GuidePanel`, or for an unknown `article` id; keyboard/focus parity with
240
+ * `GuideMention`.
241
+ * @tag navigation
242
+ * @related GuideArticle
243
+ * @related GuidePanel
244
+ * @related GuideMention
245
+ * @stability beta
246
+ * @standalone
247
+ *
248
+ * @example
249
+ * ```svelte
250
+ * <GuideArticle id="cost-pot" title="Cost pot">
251
+ * <p>The pot is split by the <GuideRef article="splitting">splitting method</GuideRef>.</p>
252
+ * </GuideArticle>
253
+ * ```
254
+ */
255
+ export interface GuideRefProps {
256
+ /** Id of the `GuideArticle` to navigate to. Inert (plain text) for an unknown id. */
257
+ article: string;
258
+ /** The link text. */
259
+ children?: Snippet;
260
+ /** Additional classes on the ref. */
261
+ class?: string;
262
+ /** Strip all default styles. @default false */
263
+ unstyled?: boolean;
264
+ /** Per-slot class overrides. */
265
+ slotClasses?: Partial<Record<GuideRefSlots, string>>;
266
+ /** Apply a named preset registered via `<BlocksProvider presets={{ GuideRef: {...} }}>`. */
267
+ preset?: string;
268
+ }
219
269
  /**
220
270
  * @description A contextual, non-blocking hint anchored to a `data-guide` element via
221
271
  * `floating.ts` (flip/shift/arrow), rendered in the native popover top-layer. Waits at the
@@ -409,4 +459,5 @@ export { default as GuideMarker } from './GuideMarker.svelte';
409
459
  export { default as GuideMention } from './GuideMention.svelte';
410
460
  export { default as GuidePanel } from './GuidePanel.svelte';
411
461
  export { default as GuideProvider } from './GuideProvider.svelte';
412
- export { type GuideArticleVariants, type GuideBeaconVariants, type GuideHintVariants, type GuideMarkerVariants, type GuideMentionVariants, type GuidePanelVariants, type GuideTourVariants, guideArticleVariants, guideBeaconVariants, guideHintVariants, guideMarkerVariants, guideMentionVariants, guidePanelVariants, guideTourVariants } from './guide.variants.js';
462
+ export { default as GuideRef } from './GuideRef.svelte';
463
+ export { type GuideArticleVariants, type GuideBeaconVariants, type GuideHintVariants, type GuideMarkerVariants, type GuideMentionVariants, type GuidePanelVariants, type GuideRefVariants, type GuideTourVariants, guideArticleVariants, guideBeaconVariants, guideHintVariants, guideMarkerVariants, guideMentionVariants, guidePanelVariants, guideRefVariants, guideTourVariants } from './guide.variants.js';
@@ -6,7 +6,8 @@ export { default as GuideMarker } from './GuideMarker.svelte';
6
6
  export { default as GuideMention } from './GuideMention.svelte';
7
7
  export { default as GuidePanel } from './GuidePanel.svelte';
8
8
  export { default as GuideProvider } from './GuideProvider.svelte';
9
- export { guideArticleVariants, guideBeaconVariants, guideHintVariants, guideMarkerVariants, guideMentionVariants, guidePanelVariants, guideTourVariants } from './guide.variants.js';
9
+ export { default as GuideRef } from './GuideRef.svelte';
10
+ export { guideArticleVariants, guideBeaconVariants, guideHintVariants, guideMarkerVariants, guideMentionVariants, guidePanelVariants, guideRefVariants, guideTourVariants } from './guide.variants.js';
10
11
  // Note: getGuideContext/setGuideContext are intentionally NOT re-exported here.
11
12
  // They are an internal wiring seam — surfaces import them directly from
12
13
  // './guide.context' (mirrors Tab/Icon, which keep their context helpers private).
@@ -128,6 +128,8 @@ declare const blocksTranslations: {
128
128
  readonly dismiss: "Dismiss hint";
129
129
  readonly startTour: "Start the guided tour";
130
130
  readonly actionRequired: "Complete the highlighted action to continue";
131
+ readonly filterPlaceholder: "Filter topics…";
132
+ readonly noResults: "No matching topics";
131
133
  };
132
134
  readonly datepicker: {
133
135
  readonly placeholder: "Select a date...";
@@ -297,6 +299,8 @@ declare const blocksTranslations: {
297
299
  readonly dismiss: "Hinweis ausblenden";
298
300
  readonly startTour: "Geführte Tour starten";
299
301
  readonly actionRequired: "Führe die markierte Aktion aus, um fortzufahren";
302
+ readonly filterPlaceholder: "Themen filtern…";
303
+ readonly noResults: "Keine passenden Themen";
300
304
  };
301
305
  readonly datepicker: {
302
306
  readonly placeholder: "Datum wählen...";
@@ -126,6 +126,8 @@ declare const _default: {
126
126
  readonly dismiss: "Hinweis ausblenden";
127
127
  readonly startTour: "Geführte Tour starten";
128
128
  readonly actionRequired: "Führe die markierte Aktion aus, um fortzufahren";
129
+ readonly filterPlaceholder: "Themen filtern…";
130
+ readonly noResults: "Keine passenden Themen";
129
131
  };
130
132
  readonly datepicker: {
131
133
  readonly placeholder: "Datum wählen...";
@@ -125,7 +125,9 @@ export default {
125
125
  infoAbout: 'Mehr Informationen zu {{label}}',
126
126
  dismiss: 'Hinweis ausblenden',
127
127
  startTour: 'Geführte Tour starten',
128
- actionRequired: 'Führe die markierte Aktion aus, um fortzufahren'
128
+ actionRequired: 'Führe die markierte Aktion aus, um fortzufahren',
129
+ filterPlaceholder: 'Themen filtern…',
130
+ noResults: 'Keine passenden Themen'
129
131
  },
130
132
  datepicker: {
131
133
  placeholder: 'Datum wählen...',
@@ -126,6 +126,8 @@ declare const _default: {
126
126
  readonly dismiss: "Dismiss hint";
127
127
  readonly startTour: "Start the guided tour";
128
128
  readonly actionRequired: "Complete the highlighted action to continue";
129
+ readonly filterPlaceholder: "Filter topics…";
130
+ readonly noResults: "No matching topics";
129
131
  };
130
132
  readonly datepicker: {
131
133
  readonly placeholder: "Select a date...";
@@ -125,7 +125,9 @@ export default {
125
125
  infoAbout: 'More information about {{label}}',
126
126
  dismiss: 'Dismiss hint',
127
127
  startTour: 'Start the guided tour',
128
- actionRequired: 'Complete the highlighted action to continue'
128
+ actionRequired: 'Complete the highlighted action to continue',
129
+ filterPlaceholder: 'Filter topics…',
130
+ noResults: 'No matching topics'
129
131
  },
130
132
  datepicker: {
131
133
  placeholder: 'Select a date...',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.4.1",
3
+ "version": "6.5.0",
4
4
  "description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,8 +91,8 @@
91
91
  "@sveltejs/package": "^2.5.8",
92
92
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
93
93
  "@tailwindcss/vite": "^4.3.1",
94
- "@urbicon-ui/i18n": "6.4.1",
95
- "@urbicon-ui/shared-types": "6.4.1",
94
+ "@urbicon-ui/i18n": "6.5.0",
95
+ "@urbicon-ui/shared-types": "6.5.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",