@urbicon-ui/blocks 6.4.0 → 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.
@@ -32,7 +32,7 @@
32
32
 
33
33
  const guide = getGuideContext();
34
34
 
35
- if (import.meta.env.DEV && !guide) {
35
+ if (import.meta.env?.DEV && !guide) {
36
36
  console.warn(
37
37
  '[Guide] <Guide> is used without a <GuideProvider> ancestor — the tour will not render.'
38
38
  );
@@ -217,7 +217,7 @@
217
217
  // clickable, and only an app-side controller.next() from outside the spotlight can advance.
218
218
  // Usually a step-definition mistake; mirrors the unresolved-target warning.
219
219
  $effect(() => {
220
- if (!import.meta.env.DEV || !guide) return;
220
+ if (!import.meta.env?.DEV || !guide) return;
221
221
  if (step?.advance === 'action' && !step.interactive) {
222
222
  console.warn(
223
223
  `[Guide] tour "${guide.activeTour?.id}" step ${stepIndex}: advance:'action' without ` +
@@ -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();
@@ -22,7 +22,7 @@
22
22
 
23
23
  const guide = getGuideContext();
24
24
 
25
- if (import.meta.env.DEV && !guide) {
25
+ if (import.meta.env?.DEV && !guide) {
26
26
  console.warn(
27
27
  '[Guide] <GuideBeacon> is used without a <GuideProvider> ancestor — it will not render.'
28
28
  );
@@ -37,7 +37,7 @@
37
37
 
38
38
  const guide = getGuideContext();
39
39
 
40
- if (import.meta.env.DEV && !guide) {
40
+ if (import.meta.env?.DEV && !guide) {
41
41
  console.warn(
42
42
  '[Guide] <GuideHint> is used without a <GuideProvider> ancestor — it will not render.'
43
43
  );
@@ -75,7 +75,7 @@
75
75
  $effect(() => {
76
76
  if (trigger === 'manual') {
77
77
  if (open) dismissed = false;
78
- } else if (import.meta.env.DEV && open) {
78
+ } else if (import.meta.env?.DEV && open) {
79
79
  console.warn('[Guide] <GuideHint open> is ignored unless trigger="manual".');
80
80
  }
81
81
  });
@@ -179,7 +179,7 @@
179
179
  // DEV resilience: a requested hint whose target can't be resolved is a silent
180
180
  // no-op otherwise — warn so a wrong `for` id is noticed (mirrors the engine).
181
181
  $effect(() => {
182
- if (import.meta.env.DEV && guide && visible && !guide.resolveTarget(topicId)) {
182
+ if (import.meta.env?.DEV && guide && visible && !guide.resolveTarget(topicId)) {
183
183
  console.warn(
184
184
  `[Guide] <GuideHint for="${topicId}"> cannot anchor — no element with [data-guide="${topicId}"] (or registered target) found.`
185
185
  );
@@ -24,7 +24,7 @@
24
24
 
25
25
  const guide = getGuideContext();
26
26
 
27
- if (import.meta.env.DEV && !guide) {
27
+ if (import.meta.env?.DEV && !guide) {
28
28
  console.warn(
29
29
  '[Guide] <GuideMarker> is used without a <GuideProvider> ancestor — it renders inert.'
30
30
  );
@@ -18,7 +18,7 @@
18
18
 
19
19
  const guide = getGuideContext();
20
20
 
21
- if (import.meta.env.DEV && !guide) {
21
+ if (import.meta.env?.DEV && !guide) {
22
22
  console.warn(
23
23
  '[Guide] <GuideMention> is used without a <GuideProvider> ancestor — it renders as plain text.'
24
24
  );
@@ -42,7 +42,7 @@
42
42
  const interactive = $derived(!!guide && effectiveDirection !== 'to-guide');
43
43
 
44
44
  $effect(() => {
45
- if (import.meta.env.DEV && guide && effectiveDirection === 'to-guide') {
45
+ if (import.meta.env?.DEV && guide && effectiveDirection === 'to-guide') {
46
46
  console.warn(
47
47
  `[Guide] <GuideMention for="${topicId}"> points at a topic whose direction excludes Guide→UI ('to-guide') — it renders as plain text.`
48
48
  );
@@ -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,
@@ -35,7 +37,7 @@
35
37
  // Re-registers if the id changes; releases it on unmount.
36
38
  $effect(() => guide?.registerPanel(panelId));
37
39
 
38
- if (import.meta.env.DEV && !guide) {
40
+ if (import.meta.env?.DEV && !guide) {
39
41
  console.warn(
40
42
  '[Guide] <GuidePanel> is used without a <GuideProvider> ancestor — it will not render.'
41
43
  );
@@ -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
  );
@@ -88,7 +111,7 @@
88
111
  // shows an empty body with no signal. Deferred past the current flush via `tick()` so a
89
112
  // sibling <GuideArticle> registering in the same render isn't a false positive.
90
113
  $effect(() => {
91
- if (!import.meta.env.DEV) return;
114
+ if (!import.meta.env?.DEV) return;
92
115
  const id = activeArticle;
93
116
  if (!id) return;
94
117
  let cancelled = false;
@@ -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...";
@@ -18,7 +18,10 @@ export const badgeVariants = tv({
18
18
  'transition-[color,background-color,border-color,box-shadow,opacity] duration-[var(--blocks-duration-fast)] ease-out'
19
19
  // Radius driven by `tier` axis below.
20
20
  ],
21
- content: ['flex items-center gap-inherit'],
21
+ // `[gap:inherit]` (arbitrary property), NOT `gap-inherit` — Tailwind v4 emits
22
+ // no `gap-inherit` rule, so the icon↔label gap would collapse to 0. The
23
+ // arbitrary property inherits `base`'s per-size gap. (Codeberg #21)
24
+ content: ['flex items-center [gap:inherit]'],
22
25
  // tier: modify — small remove-control on a commit-tier badge.
23
26
  removeButton: [
24
27
  'ml-1 shrink-0 rounded-modify transition-colors text-current',
@@ -10,7 +10,13 @@ export const buttonVariants = tv({
10
10
  'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'
11
11
  ],
12
12
  content: [
13
- 'flex items-center gap-inherit transition-opacity duration-[var(--blocks-duration-fast)]'
13
+ // `[gap:inherit]` (arbitrary property), NOT `gap-inherit`: Tailwind v4 has
14
+ // no `gap-inherit` utility — the spacing-based `gap-*` scale has no
15
+ // `inherit` member (unlike `text-inherit`/`border-inherit`), so that class
16
+ // emits no rule and the icon↔label gap silently collapses to 0. The
17
+ // arbitrary property makes `content` inherit `base`'s per-size gap as
18
+ // intended. (Codeberg #21)
19
+ 'flex items-center [gap:inherit] transition-opacity duration-[var(--blocks-duration-fast)]'
14
20
  ],
15
21
  spinner: [
16
22
  'flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-[var(--blocks-duration-fast)]'
@@ -104,6 +104,25 @@
104
104
  // Focus never leaves the `<input>` while open — the active option is
105
105
  // surfaced via `aria-activedescendant`, not by moving DOM focus into
106
106
  // the listbox.
107
+ //
108
+ // The focus restore in `select`/`clear` must NOT re-open the listbox the way
109
+ // a user-initiated focus does. `inputEl.focus()` dispatches `focus`
110
+ // synchronously, so without this guard `handleFocus` would flip `open = true`
111
+ // again on the very next line — negating the `open = false` above and leaving
112
+ // the listbox open after every mouse-click selection/clear (Codeberg #19).
113
+ // The flag is raised immediately before `focus()` and lowered immediately
114
+ // after, so it masks only that one synchronous event; when `focus()` is a
115
+ // no-op (input already focused, e.g. an Enter-key selection) it fires no
116
+ // `focus` event and the flag is simply lowered again — never left dangling
117
+ // for the next genuine focus.
118
+ let suppressFocusOpen = false;
119
+
120
+ function focusInputWithoutOpening() {
121
+ suppressFocusOpen = true;
122
+ inputEl?.focus();
123
+ suppressFocusOpen = false;
124
+ }
125
+
107
126
  function select(opt: ComboboxOption<T>) {
108
127
  if (opt.disabled) return;
109
128
  value = opt.value;
@@ -111,7 +130,7 @@
111
130
  open = false;
112
131
  activeIndex = -1;
113
132
  onValueChange?.(opt.value);
114
- inputEl?.focus();
133
+ focusInputWithoutOpening();
115
134
  }
116
135
 
117
136
  function clear() {
@@ -120,7 +139,7 @@
120
139
  open = false;
121
140
  activeIndex = -1;
122
141
  onValueChange?.(null);
123
- inputEl?.focus();
142
+ focusInputWithoutOpening();
124
143
  }
125
144
 
126
145
  function handleInput() {
@@ -133,6 +152,7 @@
133
152
  }
134
153
 
135
154
  function handleFocus() {
155
+ if (suppressFocusOpen) return;
136
156
  if (!disabled) open = true;
137
157
  }
138
158
 
@@ -42,7 +42,7 @@
42
42
  }: DialogProps = $props();
43
43
 
44
44
  if (
45
- import.meta.env.DEV &&
45
+ import.meta.env?.DEV &&
46
46
  hideCloseButton &&
47
47
  closeOnEscape === false &&
48
48
  closeOnBackdropClick === false
@@ -42,7 +42,7 @@
42
42
  }: DrawerProps = $props();
43
43
 
44
44
  if (
45
- import.meta.env.DEV &&
45
+ import.meta.env?.DEV &&
46
46
  hideCloseButton &&
47
47
  closeOnEscape === false &&
48
48
  closeOnBackdropClick === false
@@ -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...',
@@ -109,7 +109,7 @@ export interface GuideControllerOptions {
109
109
  storage?: GuideStorageAdapter;
110
110
  /** Overlay stack to integrate with. @default the shared `overlayStack` singleton */
111
111
  overlayStack?: GuideOverlayStackLike;
112
- /** Force DEV-mode warnings on/off. @default `import.meta.env.DEV` */
112
+ /** Force DEV-mode warnings on/off. @default `import.meta.env?.DEV ?? false` */
113
113
  dev?: boolean;
114
114
  }
115
115
  /**
@@ -112,7 +112,10 @@ export class GuideController {
112
112
  constructor(options = {}) {
113
113
  this.#storage = options.storage ?? createLocalStorageAdapter();
114
114
  this.#overlayStack = options.overlayStack ?? overlayStack;
115
- this.#dev = options.dev ?? import.meta.env.DEV;
115
+ // `import.meta.env?.DEV` is `boolean | undefined` (undefined outside Vite);
116
+ // `?? false` keeps `#dev` a strict boolean and means non-Vite consumers
117
+ // simply get no dev warnings (rather than a crash).
118
+ this.#dev = options.dev ?? import.meta.env?.DEV ?? false;
116
119
  this.#seen = new SvelteSet(this.#storage.load());
117
120
  }
118
121
  // ─── Target registry ──────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.4.0",
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.0",
95
- "@urbicon-ui/shared-types": "6.4.0",
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",