@marianmeres/stuic 3.100.0 → 3.101.1
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.
- package/dist/components/Button/Button.svelte +52 -2
- package/dist/components/Button/Button.svelte.d.ts +16 -0
- package/dist/components/Button/README.md +33 -2
- package/dist/components/Button/index.css +10 -0
- package/dist/components/Button/index.d.ts +1 -1
- package/dist/components/Nav/Nav.svelte +374 -290
- package/dist/components/Nav/Nav.svelte.d.ts +6 -0
- package/dist/components/Nav/README.md +64 -37
- package/dist/components/Nav/index.css +30 -0
- package/package.json +4 -4
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
export type ButtonVariant = "solid" | "outline" | "ghost" | "soft" | "link";
|
|
7
7
|
export type ButtonSize = "sm" | "md" | "lg" | "xl";
|
|
8
8
|
|
|
9
|
+
export type ButtonNavDirection = "prev" | "next";
|
|
10
|
+
|
|
11
|
+
export interface ButtonNavProps {
|
|
12
|
+
/** Which direction this button represents */
|
|
13
|
+
direction: ButtonNavDirection;
|
|
14
|
+
/** Override the default arrow. Pass any iconLucide* SVG string or Snippet. */
|
|
15
|
+
icon?: string | Snippet;
|
|
16
|
+
/** Extra classes merged onto the rendered icon wrapper */
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export interface Props extends Omit<HTMLButtonAttributes, "children"> {
|
|
10
21
|
/** Color intent (semantic meaning) */
|
|
11
22
|
intent?: IntentColorKey;
|
|
@@ -51,6 +62,13 @@
|
|
|
51
62
|
* For that look, use: `<Button x unstyled class="stuic-close-button" />`
|
|
52
63
|
*/
|
|
53
64
|
x?: boolean | XProps;
|
|
65
|
+
/**
|
|
66
|
+
* Render as a normalized prev/next navigation icon button. Implies iconButton
|
|
67
|
+
* (square, fully-rounded). Default icon is an arrow in the correct direction;
|
|
68
|
+
* pass the object form with `icon` to override (e.g. chevron). If both `x` and
|
|
69
|
+
* `nav` are set, `x` takes precedence.
|
|
70
|
+
*/
|
|
71
|
+
nav?: ButtonNavDirection | ButtonNavProps;
|
|
54
72
|
/** Two icon states for swap animation (implies iconButton). Uses `checked` for active state. */
|
|
55
73
|
iconSwap?: [string | Snippet, string | Snippet];
|
|
56
74
|
/** Optional out-of-the-box spinner support */
|
|
@@ -67,6 +85,10 @@
|
|
|
67
85
|
import Thc, { type THC } from "../Thc/Thc.svelte";
|
|
68
86
|
import Spinner from "../Spinner/Spinner.svelte";
|
|
69
87
|
import { IconSwap } from "../IconSwap/index.js";
|
|
88
|
+
import {
|
|
89
|
+
iconArrowLeft as iconPrev,
|
|
90
|
+
iconArrowRight as iconNext,
|
|
91
|
+
} from "../../icons/index.js";
|
|
70
92
|
let {
|
|
71
93
|
class: classProp,
|
|
72
94
|
intent,
|
|
@@ -86,6 +108,7 @@
|
|
|
86
108
|
iconButton = false,
|
|
87
109
|
tooltip: _tooltip,
|
|
88
110
|
x,
|
|
111
|
+
nav,
|
|
89
112
|
iconSwap,
|
|
90
113
|
spinner,
|
|
91
114
|
spinnerOnly,
|
|
@@ -120,8 +143,13 @@
|
|
|
120
143
|
}
|
|
121
144
|
});
|
|
122
145
|
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
let _navProps: undefined | ButtonNavProps = $derived.by(() => {
|
|
147
|
+
if (!nav) return;
|
|
148
|
+
return typeof nav === "string" ? { direction: nav } : { ...nav };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// "x", "nav" and "iconSwap" are semantically icon buttons
|
|
152
|
+
let _isIconButton = $derived(iconButton || !!_xProps || !!_navProps || !!iconSwap);
|
|
125
153
|
|
|
126
154
|
// icon buttons implicitly set aspect1
|
|
127
155
|
let _isAspect1 = $derived(aspect1 || _isIconButton);
|
|
@@ -143,11 +171,22 @@
|
|
|
143
171
|
data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
|
|
144
172
|
data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
|
|
145
173
|
data-x={!unstyled && !!_xProps ? "true" : undefined}
|
|
174
|
+
data-nav={!unstyled && _navProps ? _navProps.direction : undefined}
|
|
146
175
|
use:tooltip={_tooltipConfig}
|
|
147
176
|
{...rest as HTMLAnchorAttributes}
|
|
148
177
|
>
|
|
149
178
|
{#if _xProps}
|
|
150
179
|
<X {..._xProps} />
|
|
180
|
+
{:else if _navProps}
|
|
181
|
+
{#if typeof _navProps.icon === "string"}
|
|
182
|
+
<span class={_navProps.class}>{@html _navProps.icon}</span>
|
|
183
|
+
{:else if _navProps.icon}
|
|
184
|
+
{@render _navProps.icon()}
|
|
185
|
+
{:else if _navProps.direction === "prev"}
|
|
186
|
+
<span class={_navProps.class}>{@html iconPrev({ size: 24 })}</span>
|
|
187
|
+
{:else}
|
|
188
|
+
<span class={_navProps.class}>{@html iconNext({ size: 24 })}</span>
|
|
189
|
+
{/if}
|
|
151
190
|
{:else if iconSwap}
|
|
152
191
|
<IconSwap states={iconSwap} active={checked ? 1 : 0} />
|
|
153
192
|
{:else}
|
|
@@ -177,11 +216,22 @@
|
|
|
177
216
|
data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
|
|
178
217
|
data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
|
|
179
218
|
data-x={!unstyled && !!_xProps ? "true" : undefined}
|
|
219
|
+
data-nav={!unstyled && _navProps ? _navProps.direction : undefined}
|
|
180
220
|
use:tooltip={_tooltipConfig}
|
|
181
221
|
{...rest}
|
|
182
222
|
>
|
|
183
223
|
{#if _xProps}
|
|
184
224
|
<X {..._xProps} />
|
|
225
|
+
{:else if _navProps}
|
|
226
|
+
{#if typeof _navProps.icon === "string"}
|
|
227
|
+
<span class={_navProps.class}>{@html _navProps.icon}</span>
|
|
228
|
+
{:else if _navProps.icon}
|
|
229
|
+
{@render _navProps.icon()}
|
|
230
|
+
{:else if _navProps.direction === "prev"}
|
|
231
|
+
<span class={_navProps.class}>{@html iconPrev({ size: 24 })}</span>
|
|
232
|
+
{:else}
|
|
233
|
+
<span class={_navProps.class}>{@html iconNext({ size: 24 })}</span>
|
|
234
|
+
{/if}
|
|
185
235
|
{:else if iconSwap}
|
|
186
236
|
<IconSwap states={iconSwap} active={checked ? 1 : 0} />
|
|
187
237
|
{:else}
|
|
@@ -3,6 +3,15 @@ import type { Snippet } from "svelte";
|
|
|
3
3
|
import type { IntentColorKey } from "../../utils/design-tokens.js";
|
|
4
4
|
export type ButtonVariant = "solid" | "outline" | "ghost" | "soft" | "link";
|
|
5
5
|
export type ButtonSize = "sm" | "md" | "lg" | "xl";
|
|
6
|
+
export type ButtonNavDirection = "prev" | "next";
|
|
7
|
+
export interface ButtonNavProps {
|
|
8
|
+
/** Which direction this button represents */
|
|
9
|
+
direction: ButtonNavDirection;
|
|
10
|
+
/** Override the default arrow. Pass any iconLucide* SVG string or Snippet. */
|
|
11
|
+
icon?: string | Snippet;
|
|
12
|
+
/** Extra classes merged onto the rendered icon wrapper */
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
6
15
|
export interface Props extends Omit<HTMLButtonAttributes, "children"> {
|
|
7
16
|
/** Color intent (semantic meaning) */
|
|
8
17
|
intent?: IntentColorKey;
|
|
@@ -50,6 +59,13 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
|
|
|
50
59
|
* For that look, use: `<Button x unstyled class="stuic-close-button" />`
|
|
51
60
|
*/
|
|
52
61
|
x?: boolean | XProps;
|
|
62
|
+
/**
|
|
63
|
+
* Render as a normalized prev/next navigation icon button. Implies iconButton
|
|
64
|
+
* (square, fully-rounded). Default icon is an arrow in the correct direction;
|
|
65
|
+
* pass the object form with `icon` to override (e.g. chevron). If both `x` and
|
|
66
|
+
* `nav` are set, `x` takes precedence.
|
|
67
|
+
*/
|
|
68
|
+
nav?: ButtonNavDirection | ButtonNavProps;
|
|
53
69
|
/** Two icon states for swap animation (implies iconButton). Uses `checked` for active state. */
|
|
54
70
|
iconSwap?: [string | Snippet, string | Snippet];
|
|
55
71
|
/** Optional out-of-the-box spinner support */
|
|
@@ -18,6 +18,8 @@ A flexible button component with semantic intents, visual variants, sizes, and o
|
|
|
18
18
|
| `el` | `HTMLElement` | - | Element reference (bindable) |
|
|
19
19
|
| `iconButton` | `boolean` | `false` | Icon-only button (implies aspect1, adds CSS hook) |
|
|
20
20
|
| `iconSwap` | `[string \| Snippet, string \| Snippet]` | - | Two icon states with swap animation (implies iconButton) |
|
|
21
|
+
| `x` | `boolean \| XProps` | - | Normalized "X" icon button shortcut (close/dismiss) |
|
|
22
|
+
| `nav` | `"prev" \| "next" \| ButtonNavProps` | - | Normalized prev/next icon button shortcut (arrow by default; `x` wins on conflict) |
|
|
21
23
|
| `class` | `string` | - | Additional CSS classes |
|
|
22
24
|
|
|
23
25
|
## Snippet Props
|
|
@@ -97,6 +99,33 @@ The `children` snippet receives `{ checked }` when `roleSwitch` is enabled.
|
|
|
97
99
|
<Button iconSwap={[iconPlus(), iconMinus()]} roleSwitch bind:checked roundedFull />
|
|
98
100
|
```
|
|
99
101
|
|
|
102
|
+
### Nav (prev / next)
|
|
103
|
+
|
|
104
|
+
Shortcut for a normalized prev/next navigation icon button (square, fully
|
|
105
|
+
rounded, arrow icon). Mirrors the `x` shortcut pattern — single `nav`
|
|
106
|
+
prop carries the direction so contradictory props are impossible.
|
|
107
|
+
|
|
108
|
+
```svelte
|
|
109
|
+
<!-- Shorthand: default arrow in the correct direction -->
|
|
110
|
+
<Button nav="prev" />
|
|
111
|
+
<Button nav="next" />
|
|
112
|
+
|
|
113
|
+
<!-- Cascades intent / variant / size like `x` -->
|
|
114
|
+
<Button nav="prev" intent="primary" size="sm" />
|
|
115
|
+
<Button nav="next" variant="ghost" />
|
|
116
|
+
|
|
117
|
+
<!-- Override the default arrow (e.g. chevron) via object form -->
|
|
118
|
+
<Button nav={{ direction: "prev", icon: iconChevronLeft() }} />
|
|
119
|
+
<Button nav={{ direction: "next", icon: iconChevronRight({ size: 18 }) }} />
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Notes:
|
|
123
|
+
|
|
124
|
+
- `nav` implies `iconButton` (square + fully-rounded).
|
|
125
|
+
- `ghost + nav + roundedFull` auto-applies the same neutral overlay hover
|
|
126
|
+
used by the rounded X — handy for carousels/lightboxes on imagery.
|
|
127
|
+
- If both `x` and `nav` are set on the same Button, `x` takes precedence.
|
|
128
|
+
|
|
100
129
|
Global CSS targeting for all icon buttons:
|
|
101
130
|
|
|
102
131
|
```css
|
|
@@ -170,5 +199,7 @@ The component uses data attributes for styling:
|
|
|
170
199
|
- `data-raised` - Present when raised
|
|
171
200
|
- `data-checked` - Present when roleSwitch is enabled and checked
|
|
172
201
|
- `data-rounded-full` - Present when roundedFull
|
|
173
|
-
- `data-aspect1` - Present when aspect1 (or iconButton, or x)
|
|
174
|
-
- `data-icon-button` - Present when iconButton (or x)
|
|
202
|
+
- `data-aspect1` - Present when aspect1 (or iconButton, or x, or nav)
|
|
203
|
+
- `data-icon-button` - Present when iconButton (or x, or nav)
|
|
204
|
+
- `data-x` - Present when x is set
|
|
205
|
+
- `data-nav` - Set to `"prev"` or `"next"` when nav is set
|
|
@@ -434,4 +434,14 @@
|
|
|
434
434
|
--_bg-hover: var(--stuic-button-x-bg-hover);
|
|
435
435
|
--_bg-active: var(--stuic-button-x-bg-hover);
|
|
436
436
|
}
|
|
437
|
+
|
|
438
|
+
/* ============================================================================
|
|
439
|
+
PURE ROUNDED NAV (prev/next)
|
|
440
|
+
Same treatment as the rounded X — useful for carousel/lightbox overlays
|
|
441
|
+
where the prev/next button sits on imagery.
|
|
442
|
+
============================================================================ */
|
|
443
|
+
.stuic-button[data-variant="ghost"][data-nav][data-rounded-full] {
|
|
444
|
+
--_bg-hover: var(--stuic-button-x-bg-hover);
|
|
445
|
+
--_bg-active: var(--stuic-button-x-bg-hover);
|
|
446
|
+
}
|
|
437
447
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default as Button, type Props as ButtonProps, type ButtonVariant, type ButtonSize, } from "./Button.svelte";
|
|
1
|
+
export { default as Button, type Props as ButtonProps, type ButtonVariant, type ButtonSize, type ButtonNavDirection, type ButtonNavProps, } from "./Button.svelte";
|
|
@@ -119,6 +119,15 @@
|
|
|
119
119
|
|
|
120
120
|
/** Storage key prefix for localStorage (default: 'stuic-nav') */
|
|
121
121
|
storageKeyPrefix?: string;
|
|
122
|
+
|
|
123
|
+
/** When true, the section title becomes a button that collapses/expands all groups beneath it. Default: false (non-interactive, today's behavior). */
|
|
124
|
+
collapsibleTitle?: boolean;
|
|
125
|
+
|
|
126
|
+
/** Initial expanded state when `collapsibleTitle` is true. Default: true. Overridden by localStorage when `persistState` is on. */
|
|
127
|
+
defaultTitleExpanded?: boolean;
|
|
128
|
+
|
|
129
|
+
/** Callback when section title is toggled. */
|
|
130
|
+
onTitleToggle?: (isExpanded: boolean) => void;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
export const NAV_BASE_CLASSES = "stuic-nav";
|
|
@@ -169,11 +178,16 @@
|
|
|
169
178
|
el = $bindable(),
|
|
170
179
|
persistState = true,
|
|
171
180
|
storageKeyPrefix = "stuic-nav",
|
|
181
|
+
collapsibleTitle = false,
|
|
182
|
+
defaultTitleExpanded = true,
|
|
183
|
+
onTitleToggle,
|
|
172
184
|
...rest
|
|
173
185
|
}: Props = $props();
|
|
174
186
|
|
|
175
187
|
// Unique IDs for accessibility
|
|
176
188
|
const navId = getId("nav-");
|
|
189
|
+
const sectionTitleId = `${navId}-section-title`;
|
|
190
|
+
const sectionContentId = `${navId}-section-content`;
|
|
177
191
|
|
|
178
192
|
// Device detection for touch-friendly sizing
|
|
179
193
|
const devicePointer = new DevicePointer();
|
|
@@ -198,6 +212,10 @@
|
|
|
198
212
|
return `${storageKeyPrefix}-item-${itemId}`;
|
|
199
213
|
}
|
|
200
214
|
|
|
215
|
+
function getSectionStorageKey(): string {
|
|
216
|
+
return `${storageKeyPrefix}-section`;
|
|
217
|
+
}
|
|
218
|
+
|
|
201
219
|
function loadGroupState(groupId: string): boolean | undefined {
|
|
202
220
|
if (!persistState) return undefined;
|
|
203
221
|
return localStorageValue<boolean | undefined>(
|
|
@@ -224,6 +242,19 @@
|
|
|
224
242
|
localStorageValue(getItemStorageKey(itemId), expanded).set(expanded);
|
|
225
243
|
}
|
|
226
244
|
|
|
245
|
+
function loadSectionState(): boolean | undefined {
|
|
246
|
+
if (!persistState) return undefined;
|
|
247
|
+
return localStorageValue<boolean | undefined>(
|
|
248
|
+
getSectionStorageKey(),
|
|
249
|
+
undefined
|
|
250
|
+
).get();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function saveSectionState(expanded: boolean): void {
|
|
254
|
+
if (!persistState) return;
|
|
255
|
+
localStorageValue(getSectionStorageKey(), expanded).set(expanded);
|
|
256
|
+
}
|
|
257
|
+
|
|
227
258
|
// Check if an item matches active state (used during initialization)
|
|
228
259
|
function checkItemActive(item: NavItem): boolean {
|
|
229
260
|
if (isActive) return isActive(item);
|
|
@@ -270,6 +301,26 @@
|
|
|
270
301
|
// Initialize state synchronously from props (not via effect)
|
|
271
302
|
let groupExpandedStates = $state<boolean[]>(computeGroupStates());
|
|
272
303
|
|
|
304
|
+
// Compute initial section expanded state synchronously
|
|
305
|
+
function computeSectionState(): boolean {
|
|
306
|
+
// First priority: localStorage (if persistence enabled)
|
|
307
|
+
if (persistState) {
|
|
308
|
+
const stored = loadSectionState();
|
|
309
|
+
if (stored != null) return stored;
|
|
310
|
+
}
|
|
311
|
+
// Default: use defaultTitleExpanded prop (defaults to true)
|
|
312
|
+
return defaultTitleExpanded;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let sectionExpanded = $state<boolean>(computeSectionState());
|
|
316
|
+
|
|
317
|
+
function toggleSectionTitle() {
|
|
318
|
+
const newState = !sectionExpanded;
|
|
319
|
+
sectionExpanded = newState;
|
|
320
|
+
saveSectionState(newState);
|
|
321
|
+
onTitleToggle?.(newState);
|
|
322
|
+
}
|
|
323
|
+
|
|
273
324
|
// Re-sync if groups array length changes
|
|
274
325
|
$effect.pre(() => {
|
|
275
326
|
if (groupExpandedStates.length !== groups.length) {
|
|
@@ -469,304 +520,337 @@
|
|
|
469
520
|
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
470
521
|
{...rest}
|
|
471
522
|
>
|
|
472
|
-
<!-- Section title (optional
|
|
523
|
+
<!-- Section title (optional). Non-interactive by default; opt-in collapsible via `collapsibleTitle`. -->
|
|
473
524
|
{#if title}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
525
|
+
{#if collapsibleTitle}
|
|
526
|
+
<button
|
|
527
|
+
type="button"
|
|
528
|
+
id={sectionTitleId}
|
|
529
|
+
class={twMerge(!unstyled && NAV_SECTION_TITLE_CLASSES, "uppercase", classTitle)}
|
|
530
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
531
|
+
data-interactive=""
|
|
532
|
+
data-expanded={!unstyled && sectionExpanded ? "" : undefined}
|
|
533
|
+
onclick={toggleSectionTitle}
|
|
534
|
+
aria-expanded={sectionExpanded}
|
|
535
|
+
aria-controls={sectionContentId}
|
|
536
|
+
>
|
|
537
|
+
<span
|
|
538
|
+
class={twMerge(
|
|
539
|
+
"inline-block shrink-0 transition-transform duration-150",
|
|
540
|
+
sectionExpanded && "rotate-90",
|
|
541
|
+
classChevron
|
|
542
|
+
)}
|
|
543
|
+
>
|
|
544
|
+
{@html iconChevronRight({ size: 12 })}
|
|
545
|
+
</span>
|
|
546
|
+
<span>{resolveLabel(title)}</span>
|
|
547
|
+
</button>
|
|
548
|
+
{:else}
|
|
549
|
+
<span
|
|
550
|
+
class={twMerge(!unstyled && NAV_SECTION_TITLE_CLASSES, "uppercase", classTitle)}
|
|
551
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
552
|
+
>
|
|
553
|
+
{resolveLabel(title)}
|
|
554
|
+
</span>
|
|
555
|
+
{/if}
|
|
480
556
|
{/if}
|
|
481
557
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
expanded
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
558
|
+
{#if !collapsibleTitle || sectionExpanded}
|
|
559
|
+
<div
|
|
560
|
+
id={sectionContentId}
|
|
561
|
+
class="flex flex-col gap-(--stuic-nav-children-gap)"
|
|
562
|
+
transition:slide={{ duration: transitionDuration }}
|
|
563
|
+
>
|
|
564
|
+
<!-- Render each group -->
|
|
565
|
+
{#each groups as group, groupIndex}
|
|
566
|
+
{@const hasItems = groupHasItems(group)}
|
|
567
|
+
{@const expanded = isGroupExpanded(groupIndex)}
|
|
568
|
+
{@const groupActive = !hasItems && isGroupItemActive(group)}
|
|
569
|
+
|
|
570
|
+
{#if hasItems}
|
|
571
|
+
<!-- Group with items: show expandable header -->
|
|
572
|
+
{#if !isCollapsed}
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
id={groupElId(groupIndex)}
|
|
576
|
+
class={twMerge(!unstyled && NAV_GROUP_TITLE_CLASSES, classGroupTitle)}
|
|
577
|
+
onclick={() => toggleGroup(groupIndex)}
|
|
578
|
+
aria-expanded={expanded}
|
|
579
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
580
|
+
>
|
|
581
|
+
<span
|
|
582
|
+
class={twMerge(
|
|
583
|
+
"inline-block shrink-0 transition-transform duration-150",
|
|
584
|
+
expanded && "rotate-90",
|
|
585
|
+
classChevron
|
|
586
|
+
)}
|
|
587
|
+
>
|
|
588
|
+
{@html iconChevronRight({ size: isTouchFriendly ? 18 : 16 })}
|
|
589
|
+
</span>
|
|
590
|
+
{#if group.icon}
|
|
591
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
592
|
+
<Thc thc={group.icon} />
|
|
593
|
+
</span>
|
|
594
|
+
{/if}
|
|
595
|
+
<span class={twMerge(classLabel)}>{resolveLabel(group.title)}</span>
|
|
596
|
+
</button>
|
|
597
|
+
{:else}
|
|
598
|
+
<!-- Collapsed mode: show only chevron -->
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
class={twMerge(!unstyled && NAV_ITEM_CLASSES, classItemCollapsed)}
|
|
602
|
+
onclick={() => toggleGroup(groupIndex)}
|
|
603
|
+
data-collapsed=""
|
|
604
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
605
|
+
use:tooltip={() => ({
|
|
606
|
+
enabled: isCollapsed,
|
|
607
|
+
content: resolveLabel(group.title),
|
|
608
|
+
position: "right",
|
|
609
|
+
})}
|
|
610
|
+
>
|
|
611
|
+
<span
|
|
612
|
+
class={twMerge(
|
|
613
|
+
"inline-block shrink-0 transition-transform duration-150",
|
|
614
|
+
expanded && "rotate-90",
|
|
615
|
+
classChevron
|
|
616
|
+
)}
|
|
617
|
+
>
|
|
618
|
+
{@html iconChevronRight({ size: 16 })}
|
|
619
|
+
</span>
|
|
620
|
+
</button>
|
|
512
621
|
{/if}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
{@const label = resolveLabel(item.label)}
|
|
553
|
-
<li>
|
|
554
|
-
{#if hasChildren}
|
|
555
|
-
<!-- Parent with children: render as toggle button -->
|
|
556
|
-
<button
|
|
557
|
-
type="button"
|
|
558
|
-
id={itemElId(groupIndex, item.id)}
|
|
559
|
-
class={twMerge(
|
|
560
|
-
!unstyled && NAV_ITEM_CLASSES,
|
|
561
|
-
isCollapsed && classItemCollapsed,
|
|
562
|
-
active && classItemActive,
|
|
563
|
-
item.disabled && classItemDisabled,
|
|
564
|
-
item.class,
|
|
565
|
-
classItem
|
|
566
|
-
)}
|
|
567
|
-
onclick={() => toggleItem(item.id)}
|
|
568
|
-
disabled={item.disabled}
|
|
569
|
-
data-active={!unstyled && active ? "" : undefined}
|
|
570
|
-
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
571
|
-
data-has-children=""
|
|
572
|
-
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
573
|
-
aria-expanded={itemExpanded}
|
|
574
|
-
use:tooltip={() => ({
|
|
575
|
-
enabled: isCollapsed,
|
|
576
|
-
content: label,
|
|
577
|
-
position: "right",
|
|
578
|
-
})}
|
|
579
|
-
>
|
|
580
|
-
<!-- Chevron indicator -->
|
|
581
|
-
<span
|
|
582
|
-
class={twMerge(
|
|
583
|
-
"inline-block shrink-0 transition-transform duration-150",
|
|
584
|
-
itemExpanded && "rotate-90",
|
|
585
|
-
classChevron
|
|
586
|
-
)}
|
|
587
|
-
>
|
|
588
|
-
{@html iconChevronRight({ size: isTouchFriendly ? 18 : 16 })}
|
|
589
|
-
</span>
|
|
590
|
-
{#if item.icon && !isCollapsed}
|
|
591
|
-
<span class={twMerge("shrink-0", classIcon)}>
|
|
592
|
-
<Thc thc={item.icon} />
|
|
593
|
-
</span>
|
|
594
|
-
{/if}
|
|
595
|
-
{#if !isCollapsed}
|
|
596
|
-
<span class={classLabel}>{label}</span>
|
|
597
|
-
{/if}
|
|
598
|
-
</button>
|
|
599
|
-
|
|
600
|
-
<!-- Children (only shown when expanded) -->
|
|
601
|
-
{#if itemExpanded}
|
|
602
|
-
<ul
|
|
603
|
-
class={twMerge(!unstyled && NAV_CHILDREN_CLASSES, classChildren)}
|
|
604
|
-
transition:slide={{ duration: transitionDuration }}
|
|
605
|
-
>
|
|
606
|
-
{#each item.children ?? [] as child (child.id)}
|
|
607
|
-
{@render renderItem(child, depth + 1)}
|
|
608
|
-
{/each}
|
|
609
|
-
</ul>
|
|
610
|
-
{/if}
|
|
611
|
-
{:else if item.href}
|
|
612
|
-
<!-- Leaf item with href -->
|
|
613
|
-
<a
|
|
614
|
-
id={itemElId(groupIndex, item.id)}
|
|
615
|
-
href={item.href}
|
|
616
|
-
class={twMerge(
|
|
617
|
-
!unstyled && NAV_ITEM_CLASSES,
|
|
618
|
-
isCollapsed && classItemCollapsed,
|
|
619
|
-
active && classItemActive,
|
|
620
|
-
item.disabled && classItemDisabled,
|
|
621
|
-
item.class,
|
|
622
|
-
classItem
|
|
623
|
-
)}
|
|
624
|
-
onclick={() => handleItemSelect(item)}
|
|
625
|
-
data-active={!unstyled && active ? "" : undefined}
|
|
626
|
-
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
627
|
-
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
628
|
-
data-disabled={!unstyled && item.disabled ? "" : undefined}
|
|
629
|
-
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
630
|
-
aria-current={active ? "page" : undefined}
|
|
631
|
-
aria-disabled={item.disabled}
|
|
632
|
-
tabindex={item.disabled ? -1 : 0}
|
|
633
|
-
use:tooltip={() => ({
|
|
634
|
-
enabled: isCollapsed,
|
|
635
|
-
content: label,
|
|
636
|
-
position: "right",
|
|
637
|
-
})}
|
|
638
|
-
>
|
|
639
|
-
{#if item.icon}
|
|
640
|
-
<span class={twMerge("shrink-0", classIcon)}>
|
|
641
|
-
<Thc thc={item.icon} />
|
|
642
|
-
</span>
|
|
643
|
-
{:else if isCollapsed}
|
|
644
|
-
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
645
|
-
>{getFirstLetter(label)}</span
|
|
622
|
+
|
|
623
|
+
<!-- Items -->
|
|
624
|
+
{#if expanded}
|
|
625
|
+
<ul
|
|
626
|
+
class={twMerge(!unstyled && NAV_CHILDREN_CLASSES, classChildren)}
|
|
627
|
+
aria-labelledby={groupElId(groupIndex)}
|
|
628
|
+
transition:slide={{ duration: transitionDuration }}
|
|
629
|
+
>
|
|
630
|
+
{#snippet renderItem(item: NavItem, depth: number)}
|
|
631
|
+
{@const hasChildren = itemHasChildren(item)}
|
|
632
|
+
{@const itemExpanded = hasChildren && isItemExpanded(item.id)}
|
|
633
|
+
{@const active = isItemActive(item)}
|
|
634
|
+
{@const label = resolveLabel(item.label)}
|
|
635
|
+
<li>
|
|
636
|
+
{#if hasChildren}
|
|
637
|
+
<!-- Parent with children: render as toggle button -->
|
|
638
|
+
<button
|
|
639
|
+
type="button"
|
|
640
|
+
id={itemElId(groupIndex, item.id)}
|
|
641
|
+
class={twMerge(
|
|
642
|
+
!unstyled && NAV_ITEM_CLASSES,
|
|
643
|
+
isCollapsed && classItemCollapsed,
|
|
644
|
+
active && classItemActive,
|
|
645
|
+
item.disabled && classItemDisabled,
|
|
646
|
+
item.class,
|
|
647
|
+
classItem
|
|
648
|
+
)}
|
|
649
|
+
onclick={() => toggleItem(item.id)}
|
|
650
|
+
disabled={item.disabled}
|
|
651
|
+
data-active={!unstyled && active ? "" : undefined}
|
|
652
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
653
|
+
data-has-children=""
|
|
654
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
655
|
+
aria-expanded={itemExpanded}
|
|
656
|
+
use:tooltip={() => ({
|
|
657
|
+
enabled: isCollapsed,
|
|
658
|
+
content: label,
|
|
659
|
+
position: "right",
|
|
660
|
+
})}
|
|
646
661
|
>
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
{
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
662
|
+
<!-- Chevron indicator -->
|
|
663
|
+
<span
|
|
664
|
+
class={twMerge(
|
|
665
|
+
"inline-block shrink-0 transition-transform duration-150",
|
|
666
|
+
itemExpanded && "rotate-90",
|
|
667
|
+
classChevron
|
|
668
|
+
)}
|
|
669
|
+
>
|
|
670
|
+
{@html iconChevronRight({ size: isTouchFriendly ? 18 : 16 })}
|
|
671
|
+
</span>
|
|
672
|
+
{#if item.icon && !isCollapsed}
|
|
673
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
674
|
+
<Thc thc={item.icon} />
|
|
675
|
+
</span>
|
|
676
|
+
{/if}
|
|
677
|
+
{#if !isCollapsed}
|
|
678
|
+
<span class={classLabel}>{label}</span>
|
|
679
|
+
{/if}
|
|
680
|
+
</button>
|
|
681
|
+
|
|
682
|
+
<!-- Children (only shown when expanded) -->
|
|
683
|
+
{#if itemExpanded}
|
|
684
|
+
<ul
|
|
685
|
+
class={twMerge(!unstyled && NAV_CHILDREN_CLASSES, classChildren)}
|
|
686
|
+
transition:slide={{ duration: transitionDuration }}
|
|
687
|
+
>
|
|
688
|
+
{#each item.children ?? [] as child (child.id)}
|
|
689
|
+
{@render renderItem(child, depth + 1)}
|
|
690
|
+
{/each}
|
|
691
|
+
</ul>
|
|
692
|
+
{/if}
|
|
693
|
+
{:else if item.href}
|
|
694
|
+
<!-- Leaf item with href -->
|
|
695
|
+
<a
|
|
696
|
+
id={itemElId(groupIndex, item.id)}
|
|
697
|
+
href={item.href}
|
|
698
|
+
class={twMerge(
|
|
699
|
+
!unstyled && NAV_ITEM_CLASSES,
|
|
700
|
+
isCollapsed && classItemCollapsed,
|
|
701
|
+
active && classItemActive,
|
|
702
|
+
item.disabled && classItemDisabled,
|
|
703
|
+
item.class,
|
|
704
|
+
classItem
|
|
705
|
+
)}
|
|
706
|
+
onclick={() => handleItemSelect(item)}
|
|
707
|
+
data-active={!unstyled && active ? "" : undefined}
|
|
708
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
709
|
+
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
710
|
+
data-disabled={!unstyled && item.disabled ? "" : undefined}
|
|
711
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
712
|
+
aria-current={active ? "page" : undefined}
|
|
713
|
+
aria-disabled={item.disabled}
|
|
714
|
+
tabindex={item.disabled ? -1 : 0}
|
|
715
|
+
use:tooltip={() => ({
|
|
716
|
+
enabled: isCollapsed,
|
|
717
|
+
content: label,
|
|
718
|
+
position: "right",
|
|
719
|
+
})}
|
|
685
720
|
>
|
|
721
|
+
{#if item.icon}
|
|
722
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
723
|
+
<Thc thc={item.icon} />
|
|
724
|
+
</span>
|
|
725
|
+
{:else if isCollapsed}
|
|
726
|
+
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
727
|
+
>{getFirstLetter(label)}</span
|
|
728
|
+
>
|
|
729
|
+
{/if}
|
|
730
|
+
{#if !isCollapsed}
|
|
731
|
+
<span class={classLabel}>{label}</span>
|
|
732
|
+
{/if}
|
|
733
|
+
</a>
|
|
734
|
+
{:else}
|
|
735
|
+
<!-- Leaf item with onClick only -->
|
|
736
|
+
<button
|
|
737
|
+
type="button"
|
|
738
|
+
id={itemElId(groupIndex, item.id)}
|
|
739
|
+
class={twMerge(
|
|
740
|
+
!unstyled && NAV_ITEM_CLASSES,
|
|
741
|
+
isCollapsed && classItemCollapsed,
|
|
742
|
+
active && classItemActive,
|
|
743
|
+
item.disabled && classItemDisabled,
|
|
744
|
+
item.class,
|
|
745
|
+
classItem
|
|
746
|
+
)}
|
|
747
|
+
onclick={() => handleItemSelect(item)}
|
|
748
|
+
disabled={item.disabled}
|
|
749
|
+
data-active={!unstyled && active ? "" : undefined}
|
|
750
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
751
|
+
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
752
|
+
data-disabled={!unstyled && item.disabled ? "" : undefined}
|
|
753
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
754
|
+
use:tooltip={() => ({
|
|
755
|
+
enabled: isCollapsed,
|
|
756
|
+
content: label,
|
|
757
|
+
position: "right",
|
|
758
|
+
})}
|
|
759
|
+
>
|
|
760
|
+
{#if item.icon}
|
|
761
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
762
|
+
<Thc thc={item.icon} />
|
|
763
|
+
</span>
|
|
764
|
+
{:else if isCollapsed}
|
|
765
|
+
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
766
|
+
>{getFirstLetter(label)}</span
|
|
767
|
+
>
|
|
768
|
+
{/if}
|
|
769
|
+
{#if !isCollapsed}
|
|
770
|
+
<span class={classLabel}>{label}</span>
|
|
771
|
+
{/if}
|
|
772
|
+
</button>
|
|
686
773
|
{/if}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
{#each group.items ?? [] as item (item.id)}
|
|
696
|
-
{@render renderItem(item, 0)}
|
|
697
|
-
{/each}
|
|
698
|
-
</ul>
|
|
699
|
-
{/if}
|
|
700
|
-
{:else}
|
|
701
|
-
<!-- Group without items: render as a simple nav item (no chevron) -->
|
|
702
|
-
{@const label = resolveLabel(group.title)}
|
|
703
|
-
{#if group.href}
|
|
704
|
-
<a
|
|
705
|
-
href={group.href}
|
|
706
|
-
class={twMerge(
|
|
707
|
-
!unstyled && NAV_ITEM_CLASSES,
|
|
708
|
-
isCollapsed && classItemCollapsed,
|
|
709
|
-
groupActive && classItemActive,
|
|
710
|
-
classItem
|
|
711
|
-
)}
|
|
712
|
-
onclick={() => handleGroupSelect(group)}
|
|
713
|
-
data-active={!unstyled && groupActive ? "" : undefined}
|
|
714
|
-
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
715
|
-
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
716
|
-
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
717
|
-
use:tooltip={() => ({
|
|
718
|
-
enabled: isCollapsed,
|
|
719
|
-
content: label,
|
|
720
|
-
position: "right",
|
|
721
|
-
})}
|
|
722
|
-
>
|
|
723
|
-
{#if group.icon}
|
|
724
|
-
<span class={twMerge("shrink-0", classIcon)}>
|
|
725
|
-
<Thc thc={group.icon} />
|
|
726
|
-
</span>
|
|
727
|
-
{:else if isCollapsed}
|
|
728
|
-
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
729
|
-
>{getFirstLetter(label)}</span
|
|
730
|
-
>
|
|
731
|
-
{/if}
|
|
732
|
-
{#if !isCollapsed}
|
|
733
|
-
<span class={classLabel}>{label}</span>
|
|
774
|
+
</li>
|
|
775
|
+
{/snippet}
|
|
776
|
+
|
|
777
|
+
{#each group.items ?? [] as item (item.id)}
|
|
778
|
+
{@render renderItem(item, 0)}
|
|
779
|
+
{/each}
|
|
780
|
+
</ul>
|
|
734
781
|
{/if}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
<span class={twMerge("shrink-0", classIcon)}>
|
|
758
|
-
<Thc thc={group.icon} />
|
|
759
|
-
</span>
|
|
760
|
-
{:else if isCollapsed}
|
|
761
|
-
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
762
|
-
>{getFirstLetter(label)}</span
|
|
782
|
+
{:else}
|
|
783
|
+
<!-- Group without items: render as a simple nav item (no chevron) -->
|
|
784
|
+
{@const label = resolveLabel(group.title)}
|
|
785
|
+
{#if group.href}
|
|
786
|
+
<a
|
|
787
|
+
href={group.href}
|
|
788
|
+
class={twMerge(
|
|
789
|
+
!unstyled && NAV_ITEM_CLASSES,
|
|
790
|
+
isCollapsed && classItemCollapsed,
|
|
791
|
+
groupActive && classItemActive,
|
|
792
|
+
classItem
|
|
793
|
+
)}
|
|
794
|
+
onclick={() => handleGroupSelect(group)}
|
|
795
|
+
data-active={!unstyled && groupActive ? "" : undefined}
|
|
796
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
797
|
+
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
798
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
799
|
+
use:tooltip={() => ({
|
|
800
|
+
enabled: isCollapsed,
|
|
801
|
+
content: label,
|
|
802
|
+
position: "right",
|
|
803
|
+
})}
|
|
763
804
|
>
|
|
805
|
+
{#if group.icon}
|
|
806
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
807
|
+
<Thc thc={group.icon} />
|
|
808
|
+
</span>
|
|
809
|
+
{:else if isCollapsed}
|
|
810
|
+
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
811
|
+
>{getFirstLetter(label)}</span
|
|
812
|
+
>
|
|
813
|
+
{/if}
|
|
814
|
+
{#if !isCollapsed}
|
|
815
|
+
<span class={classLabel}>{label}</span>
|
|
816
|
+
{/if}
|
|
817
|
+
</a>
|
|
818
|
+
{:else}
|
|
819
|
+
<button
|
|
820
|
+
type="button"
|
|
821
|
+
class={twMerge(
|
|
822
|
+
!unstyled && NAV_ITEM_CLASSES,
|
|
823
|
+
isCollapsed && classItemCollapsed,
|
|
824
|
+
groupActive && classItemActive,
|
|
825
|
+
classItem
|
|
826
|
+
)}
|
|
827
|
+
onclick={() => handleGroupSelect(group)}
|
|
828
|
+
data-active={!unstyled && groupActive ? "" : undefined}
|
|
829
|
+
data-collapsed={!unstyled && isCollapsed ? "" : undefined}
|
|
830
|
+
data-expanding={!unstyled && isExpanding ? "" : undefined}
|
|
831
|
+
data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
|
|
832
|
+
use:tooltip={() => ({
|
|
833
|
+
enabled: isCollapsed,
|
|
834
|
+
content: label,
|
|
835
|
+
position: "right",
|
|
836
|
+
})}
|
|
837
|
+
>
|
|
838
|
+
{#if group.icon}
|
|
839
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
840
|
+
<Thc thc={group.icon} />
|
|
841
|
+
</span>
|
|
842
|
+
{:else if isCollapsed}
|
|
843
|
+
<span class={twMerge("shrink-0 font-medium", classIcon)}
|
|
844
|
+
>{getFirstLetter(label)}</span
|
|
845
|
+
>
|
|
846
|
+
{/if}
|
|
847
|
+
{#if !isCollapsed}
|
|
848
|
+
<span class={classLabel}>{label}</span>
|
|
849
|
+
{/if}
|
|
850
|
+
</button>
|
|
764
851
|
{/if}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
{/if}
|
|
770
|
-
{/if}
|
|
771
|
-
{/each}
|
|
852
|
+
{/if}
|
|
853
|
+
{/each}
|
|
854
|
+
</div>
|
|
855
|
+
{/if}
|
|
772
856
|
</nav>
|
|
@@ -99,6 +99,12 @@ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children" | "t
|
|
|
99
99
|
persistState?: boolean;
|
|
100
100
|
/** Storage key prefix for localStorage (default: 'stuic-nav') */
|
|
101
101
|
storageKeyPrefix?: string;
|
|
102
|
+
/** When true, the section title becomes a button that collapses/expands all groups beneath it. Default: false (non-interactive, today's behavior). */
|
|
103
|
+
collapsibleTitle?: boolean;
|
|
104
|
+
/** Initial expanded state when `collapsibleTitle` is true. Default: true. Overridden by localStorage when `persistState` is on. */
|
|
105
|
+
defaultTitleExpanded?: boolean;
|
|
106
|
+
/** Callback when section title is toggled. */
|
|
107
|
+
onTitleToggle?: (isExpanded: boolean) => void;
|
|
102
108
|
}
|
|
103
109
|
export declare const NAV_BASE_CLASSES = "stuic-nav";
|
|
104
110
|
export declare const NAV_SECTION_TITLE_CLASSES = "stuic-nav-section-title";
|
|
@@ -4,35 +4,38 @@ A navigation component for sidebars with support for groups, nested items, expan
|
|
|
4
4
|
|
|
5
5
|
## Props
|
|
6
6
|
|
|
7
|
-
| Prop
|
|
8
|
-
|
|
|
9
|
-
| `groups`
|
|
10
|
-
| `title`
|
|
11
|
-
| `locale`
|
|
12
|
-
| `isCollapsed`
|
|
13
|
-
| `isExpanding`
|
|
14
|
-
| `activeId`
|
|
15
|
-
| `isActive`
|
|
16
|
-
| `isGroupActive`
|
|
17
|
-
| `onSelect`
|
|
18
|
-
| `onGroupSelect`
|
|
19
|
-
| `onGroupToggle`
|
|
20
|
-
| `touchFriendly`
|
|
21
|
-
| `persistState`
|
|
22
|
-
| `storageKeyPrefix`
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
7
|
+
| Prop | Type | Default | Description |
|
|
8
|
+
| ---------------------- | --------------------------------------------------- | ------------- | --------------------------------------------------------- |
|
|
9
|
+
| `groups` | `NavGroup[]` | - | Navigation groups to render |
|
|
10
|
+
| `title` | `MaybeLocalized` | - | Section title above groups (uppercase, non-interactive) |
|
|
11
|
+
| `locale` | `string` | - | Current locale for localized labels |
|
|
12
|
+
| `isCollapsed` | `boolean` | `false` | Collapsed mode (icon-only) |
|
|
13
|
+
| `isExpanding` | `boolean` | `false` | Transitioning from collapsed to expanded |
|
|
14
|
+
| `activeId` | `string` | - | Active item ID for highlighting |
|
|
15
|
+
| `isActive` | `(item: NavItem) => boolean` | - | Custom active check callback |
|
|
16
|
+
| `isGroupActive` | `(group: NavGroup) => boolean` | - | Custom group active check callback |
|
|
17
|
+
| `onSelect` | `(item: NavItem) => void` | - | Item selection callback |
|
|
18
|
+
| `onGroupSelect` | `(group: NavGroup) => void` | - | Group selection callback (groups without items) |
|
|
19
|
+
| `onGroupToggle` | `(groupIndex: number, isExpanded: boolean) => void` | - | Group expand/collapse callback |
|
|
20
|
+
| `touchFriendly` | `boolean \| "auto"` | `false` | Touch-friendly sizing mode |
|
|
21
|
+
| `persistState` | `boolean` | `true` | Enable localStorage persistence for expand/collapse state |
|
|
22
|
+
| `storageKeyPrefix` | `string` | `"stuic-nav"` | Storage key prefix for localStorage |
|
|
23
|
+
| `collapsibleTitle` | `boolean` | `false` | Make the section title clickable to collapse all groups |
|
|
24
|
+
| `defaultTitleExpanded` | `boolean` | `true` | Initial expanded state when `collapsibleTitle` is true |
|
|
25
|
+
| `onTitleToggle` | `(isExpanded: boolean) => void` | - | Callback when the section title is toggled |
|
|
26
|
+
| `class` | `string` | - | Classes for wrapper element |
|
|
27
|
+
| `classTitle` | `string` | - | Classes for section title |
|
|
28
|
+
| `classGroupTitle` | `string` | - | Classes for group title/header |
|
|
29
|
+
| `classItem` | `string` | - | Classes for individual items |
|
|
30
|
+
| `classItemActive` | `string` | - | Classes for active items |
|
|
31
|
+
| `classItemCollapsed` | `string` | - | Classes for collapsed mode items |
|
|
32
|
+
| `classItemDisabled` | `string` | - | Classes for disabled items |
|
|
33
|
+
| `classIcon` | `string` | - | Classes for icons |
|
|
34
|
+
| `classLabel` | `string` | - | Classes for labels |
|
|
35
|
+
| `classChildren` | `string` | - | Classes for children container |
|
|
36
|
+
| `classChevron` | `string` | - | Classes for chevron icon |
|
|
37
|
+
| `unstyled` | `boolean` | `false` | Skip all default styling |
|
|
38
|
+
| `el` | `HTMLElement` | - | Element reference (bindable) |
|
|
36
39
|
|
|
37
40
|
## Interfaces
|
|
38
41
|
|
|
@@ -249,6 +252,28 @@ Storage keys follow the pattern:
|
|
|
249
252
|
|
|
250
253
|
- Groups: `{prefix}-group-{groupId}`
|
|
251
254
|
- Items: `{prefix}-item-{itemId}`
|
|
255
|
+
- Section title (when `collapsibleTitle`): `{prefix}-section`
|
|
256
|
+
|
|
257
|
+
### Collapsible Section Title
|
|
258
|
+
|
|
259
|
+
Opt in with `collapsibleTitle` to make the section title clickable. Clicking it expands or collapses all groups beneath it as a unit. The expanded state is persisted (under `{storageKeyPrefix}-section`) unless `persistState={false}`.
|
|
260
|
+
|
|
261
|
+
```svelte
|
|
262
|
+
<Nav title="Main" {groups} collapsibleTitle />
|
|
263
|
+
|
|
264
|
+
<!-- Start collapsed -->
|
|
265
|
+
<Nav title="Main" {groups} collapsibleTitle defaultTitleExpanded={false} />
|
|
266
|
+
|
|
267
|
+
<!-- React to toggle -->
|
|
268
|
+
<Nav
|
|
269
|
+
title="Main"
|
|
270
|
+
{groups}
|
|
271
|
+
collapsibleTitle
|
|
272
|
+
onTitleToggle={(expanded) => console.log("section expanded:", expanded)}
|
|
273
|
+
/>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Note: in icon-only sidebar mode (`isCollapsed`), the section title is visually hidden but the saved collapsed state is still respected — if a user collapsed the section in expanded-sidebar mode, its groups will stay hidden in icon-only mode too.
|
|
252
277
|
|
|
253
278
|
### Custom Active Check
|
|
254
279
|
|
|
@@ -326,11 +351,13 @@ The component applies these base classes (when `unstyled` is false):
|
|
|
326
351
|
|
|
327
352
|
### Data Attributes
|
|
328
353
|
|
|
329
|
-
| Attribute | Applied When
|
|
330
|
-
| --------------------- |
|
|
331
|
-
| `data-collapsed` | Sidebar is in collapsed mode
|
|
332
|
-
| `data-expanding` | Transitioning from collapsed to expanded
|
|
333
|
-
| `data-active` | Item/group is currently active
|
|
334
|
-
| `data-touch-friendly` | Touch-friendly mode is active
|
|
335
|
-
| `data-has-children` | Item has nested children
|
|
336
|
-
| `data-disabled` | Item is disabled
|
|
354
|
+
| Attribute | Applied When |
|
|
355
|
+
| --------------------- | ----------------------------------------------------- |
|
|
356
|
+
| `data-collapsed` | Sidebar is in collapsed mode |
|
|
357
|
+
| `data-expanding` | Transitioning from collapsed to expanded |
|
|
358
|
+
| `data-active` | Item/group is currently active |
|
|
359
|
+
| `data-touch-friendly` | Touch-friendly mode is active |
|
|
360
|
+
| `data-has-children` | Item has nested children |
|
|
361
|
+
| `data-disabled` | Item is disabled |
|
|
362
|
+
| `data-interactive` | Section title is clickable (`collapsibleTitle` is on) |
|
|
363
|
+
| `data-expanded` | Section title's content is currently expanded |
|
|
@@ -117,6 +117,36 @@
|
|
|
117
117
|
visibility: hidden;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/* Interactive (collapsible) variant: opt-in via `collapsibleTitle` prop */
|
|
121
|
+
.stuic-nav-section-title[data-interactive] {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: var(--stuic-nav-gap);
|
|
125
|
+
background: transparent;
|
|
126
|
+
border: none;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
text-align: left;
|
|
129
|
+
-webkit-tap-highlight-color: transparent;
|
|
130
|
+
border-radius: var(--stuic-nav-radius, var(--stuic-radius));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.stuic-nav-section-title[data-interactive]:hover {
|
|
134
|
+
background: var(--stuic-nav-item-bg-hover);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.stuic-nav-section-title[data-interactive]:focus {
|
|
138
|
+
outline: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.stuic-nav-section-title[data-interactive]:focus-visible {
|
|
142
|
+
background: var(--stuic-nav-item-bg-hover);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Chevron opacity to match group chevrons */
|
|
146
|
+
.stuic-nav-section-title[data-interactive] > span:first-child {
|
|
147
|
+
opacity: var(--stuic-nav-chevron-opacity);
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
/* =============================================================================
|
|
121
151
|
GROUP TITLE/HEADER (collapsible)
|
|
122
152
|
============================================================================= */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/stuic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.101.1",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && pnpm run prepack",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@eslint/js": "^9.39.4",
|
|
56
56
|
"@marianmeres/random-human-readable": "^1.9.0",
|
|
57
57
|
"@sveltejs/adapter-auto": "^4.0.0",
|
|
58
|
-
"@sveltejs/kit": "^2.61.
|
|
58
|
+
"@sveltejs/kit": "^2.61.1",
|
|
59
59
|
"@sveltejs/package": "^2.5.7",
|
|
60
60
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
61
61
|
"@tailwindcss/cli": "^4.3.0",
|
|
@@ -69,12 +69,12 @@
|
|
|
69
69
|
"prettier": "^3.8.3",
|
|
70
70
|
"prettier-plugin-svelte": "^3.5.2",
|
|
71
71
|
"publint": "^0.3.21",
|
|
72
|
-
"svelte": "^5.55.
|
|
72
|
+
"svelte": "^5.55.10",
|
|
73
73
|
"svelte-check": "^4.4.8",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
75
|
"tsx": "^4.22.3",
|
|
76
76
|
"typescript": "^5.9.3",
|
|
77
|
-
"typescript-eslint": "^8.
|
|
77
|
+
"typescript-eslint": "^8.60.0",
|
|
78
78
|
"vite": "^7.3.3",
|
|
79
79
|
"vitest": "^3.2.4"
|
|
80
80
|
},
|