svelte-fluentui 1.3.0 → 1.3.2

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.
@@ -1,369 +1,412 @@
1
- <script lang="ts">
2
- import type {SlotType} from "../../types/index.js"
3
- import Button from "../Button.svelte"
4
- import Panel from "./Panel.svelte"
5
- import NavMenu from "../nav/NavMenu.svelte"
6
- import NavGroup from "../nav/NavGroup.svelte"
7
- import NavLinkItem from "../nav/NavLinkItem.svelte"
8
- import DismissIcon from "../icons/DismissIcon.svelte"
9
-
10
- type NavItem = {
11
- label: string
12
- href: string
13
- icon?: string
14
- onClick?: () => void
15
- }
16
-
17
- type NavGroupItem = {
18
- title: string
19
- icon: string
20
- items: Array<{label: string; href: string}>
21
- }
22
-
23
- type CollapseMode = "auto" | "always" | "never"
24
-
25
- type Props = {
26
- brand?: string
27
- brandHref?: string
28
- brandTemplate?: SlotType
29
- items?: NavItem[]
30
- children?: SlotType
31
- navigationGroups?: NavGroupItem[]
32
- drawerContent?: SlotType
33
- collapse?: CollapseMode
34
- height?: number
35
- class?: string
36
- style?: string
37
- }
38
-
39
- let {
40
- brand = "Brand",
41
- brandHref = "/",
42
- brandTemplate = undefined,
43
- items = [],
44
- children = undefined,
45
- navigationGroups = [],
46
- drawerContent = undefined,
47
- collapse = "auto",
48
- height = 60,
49
- class: className = "",
50
- style = ""
51
- }: Props = $props()
52
-
53
- let mobileMenuOpen = $state(false)
54
-
55
- // Drawer is for collapsed nav items / groups OR custom drawerContent.
56
- // The action slot is intentionally NOT counted — it stays visible at every
57
- // width (search, account menu, theme toggle should remain reachable on
58
- // mobile rather than being hidden inside a hamburger).
59
- const hasDrawerContent = $derived(
60
- items.length > 0 || navigationGroups.length > 0 || !!drawerContent
61
- )
62
-
63
- function toggleMobileMenu() {
64
- mobileMenuOpen = !mobileMenuOpen
65
- }
66
-
67
- function closeMobileMenu() {
68
- mobileMenuOpen = false
69
- }
70
-
71
- function handleNavClick(item: NavItem) {
72
- item.onClick?.()
73
- closeMobileMenu()
74
- }
75
- </script>
76
-
77
- <nav
78
- class="topnav {className}"
79
- class:topnav--force-collapse={collapse === "always"}
80
- class:topnav--force-expand={collapse === "never"}
81
- style="height: {height}px; {style}"
82
- >
83
- <div class="topnav-container">
84
- {#if hasDrawerContent}
85
- <Button
86
- appearance="stealth"
87
- class="mobile-menu-toggle"
88
- onclick={toggleMobileMenu}
89
- aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
90
- aria-expanded={mobileMenuOpen}
91
- >
92
- <span class="hamburger-icon">
93
- {#if mobileMenuOpen}
94
- <DismissIcon size={20} />
95
- {:else}
96
-
97
- {/if}
98
- </span>
99
- </Button>
100
- {/if}
101
-
102
- {#if brandTemplate}
103
- {@render brandTemplate()}
104
- {:else}
105
- <a href={brandHref} class="topnav-brand">
106
- {brand}
107
- </a>
108
- {/if}
109
-
110
- <div class="topnav-trailing">
111
- {#if items.length > 0}
112
- <div class="topnav-items">
113
- {#each items as item}
114
- <a
115
- href={item.href}
116
- class="nav-item"
117
- onclick={() => handleNavClick(item)}
118
- >
119
- {#if item.icon}
120
- <span class="nav-icon">{item.icon}</span>
121
- {/if}
122
- <span class="nav-text">{item.label}</span>
123
- </a>
124
- {/each}
125
- </div>
126
- {/if}
127
-
128
- {#if children}
129
- {#if items.length > 0}
130
- <div class="nav-divider"></div>
131
- {/if}
132
- <div class="topnav-actions">
133
- {@render children()}
134
- </div>
135
- {/if}
136
- </div>
137
- </div>
138
- </nav>
139
-
140
- {#if hasDrawerContent}
141
- <Panel
142
- bind:open={mobileMenuOpen}
143
- side="start"
144
- width="280px"
145
- top="{height}px"
146
- class="topnav-drawer"
147
- >
148
- <div class="topnav-drawer-body">
149
- {#if items.length > 0}
150
- <div class="topnav-drawer-items">
151
- {#each items as item}
152
- <a
153
- href={item.href}
154
- class="nav-item"
155
- onclick={() => handleNavClick(item)}
156
- >
157
- {#if item.icon}
158
- <span class="nav-icon">{item.icon}</span>
159
- {/if}
160
- <span class="nav-text">{item.label}</span>
161
- </a>
162
- {/each}
163
- </div>
164
- {/if}
165
-
166
- {#if navigationGroups.length > 0}
167
- {#if items.length > 0}
168
- <hr class="topnav-drawer-separator" />
169
- {/if}
170
- <NavMenu>
171
- {#each navigationGroups as group}
172
- <NavGroup>
173
- {#snippet linkIcon()}
174
- <span class="nav-icon">{group.icon}</span>
175
- {/snippet}
176
- {#snippet linkText()}
177
- {group.title}
178
- {/snippet}
179
-
180
- {#each group.items as item}
181
- <NavLinkItem href={item.href}>{item.label}</NavLinkItem>
182
- {/each}
183
- </NavGroup>
184
- {/each}
185
- </NavMenu>
186
- {/if}
187
-
188
- {#if drawerContent}
189
- {#if items.length > 0 || navigationGroups.length > 0}
190
- <hr class="topnav-drawer-separator" />
191
- {/if}
192
- {@render drawerContent(closeMobileMenu)}
193
- {/if}
194
- </div>
195
- </Panel>
196
- {/if}
197
-
198
- <style>
199
- .topnav {
200
- display: flex;
201
- align-items: center;
202
- background: var(--neutral-layer-1, #ffffff);
203
- border-bottom: 1px solid var(--neutral-stroke-layer-rest, #e0e0e0);
204
- padding: 0 1.5rem;
205
- container-type: inline-size;
206
- container-name: topnav;
207
- }
208
-
209
- .topnav-container {
210
- display: flex;
211
- align-items: center;
212
- width: 100%;
213
- position: relative;
214
- gap: 1rem;
215
- min-width: 0;
216
- }
217
-
218
- .topnav-brand {
219
- text-decoration: none;
220
- color: inherit;
221
- font-size: 1.25rem;
222
- font-weight: 600;
223
- flex-shrink: 0;
224
- }
225
-
226
- /* Mobile menu toggle button — hidden by default, shown when container is narrow */
227
- :global(.topnav .mobile-menu-toggle) {
228
- display: none;
229
- order: -1;
230
- flex-shrink: 0;
231
- width: 42px;
232
- min-width: 42px;
233
- height: 42px;
234
- }
235
-
236
- /* Override fluent-button's internal control padding/min-height so the
237
- hamburger fills the fixed 42×42 box and the icon centers in it. */
238
- :global(.topnav .mobile-menu-toggle::part(control)) {
239
- width: 100%;
240
- height: 100%;
241
- min-width: 0;
242
- min-height: 0;
243
- padding: 0;
244
- }
245
-
246
- .hamburger-icon {
247
- font-size: 1.5rem;
248
- line-height: 1;
249
- display: flex;
250
- align-items: center;
251
- }
252
-
253
- /* Trailing flex group: items + divider + actions. `margin-left: auto`
254
- pushes it to the inline-end edge so brand stays next to hamburger
255
- (or hugs the start edge when hamburger is hidden), instead of being
256
- visually centered the way justify-content: space-between would do
257
- with three flex children. */
258
- .topnav-trailing {
259
- display: flex;
260
- align-items: center;
261
- gap: 1.5rem;
262
- min-width: 0;
263
- margin-inline-start: auto;
264
- }
265
-
266
- .topnav-items {
267
- display: flex;
268
- gap: 1.5rem;
269
- align-items: center;
270
- min-width: 0;
271
- overflow: hidden;
272
- }
273
-
274
- .nav-item {
275
- text-decoration: none;
276
- color: inherit;
277
- display: flex;
278
- align-items: center;
279
- gap: 0.5rem;
280
- padding: 0.5rem 0.75rem;
281
- border-radius: var(--fluent-border-radius-md);
282
- transition: background-color 0.2s;
283
- }
284
-
285
- .nav-item:hover {
286
- background-color: var(--neutral-fill-stealth-hover, rgba(0, 0, 0, 0.05));
287
- }
288
-
289
- .nav-icon {
290
- font-size: 1.1rem;
291
- }
292
-
293
- .nav-divider {
294
- width: 1px;
295
- height: 24px;
296
- background: var(--neutral-stroke-divider-rest, #e0e0e0);
297
- }
298
-
299
- .topnav-actions {
300
- display: flex;
301
- align-items: center;
302
- gap: 0.5rem;
303
- flex-shrink: 0;
304
- }
305
-
306
- /* Drawer styling — Panel handles position / transition / a11y */
307
- .topnav-drawer-body {
308
- flex: 1 1 0;
309
- min-height: 0;
310
- overflow-y: auto;
311
- }
312
-
313
- .topnav-drawer-items {
314
- display: flex;
315
- flex-direction: column;
316
- gap: 0.125rem;
317
- padding: 0 0.75rem;
318
- }
319
-
320
- .topnav-drawer-items .nav-item {
321
- padding: 0.6rem 0.75rem;
322
- }
323
-
324
- .topnav-drawer-separator {
325
- border: none;
326
- border-top: 1px solid var(--neutral-stroke-divider-rest, #e0e0e0);
327
- margin: 0.75rem 0;
328
- }
329
-
330
- @container topnav (max-width: 960px) {
331
- :global(.topnav .mobile-menu-toggle) {
332
- display: flex;
333
- }
334
-
335
- .topnav-items,
336
- .nav-divider {
337
- display: none;
338
- }
339
- }
340
-
341
- @container topnav (max-width: 480px) {
342
- .topnav-brand {
343
- font-size: 1rem;
344
- }
345
- }
346
-
347
- /* collapse="always" — force hamburger visible + items hidden regardless of width */
348
- :global(.topnav.topnav--force-collapse .mobile-menu-toggle) {
349
- display: flex;
350
- }
351
-
352
- .topnav--force-collapse .topnav-items,
353
- .topnav--force-collapse .nav-divider {
354
- display: none;
355
- }
356
-
357
- /* collapse="never" — force items/divider visible + hamburger hidden regardless of width */
358
- :global(.topnav.topnav--force-expand .mobile-menu-toggle) {
359
- display: none;
360
- }
361
-
362
- .topnav--force-expand .topnav-items {
363
- display: flex;
364
- }
365
-
366
- .topnav--force-expand .nav-divider {
367
- display: block;
368
- }
369
- </style>
1
+ <script lang="ts">
2
+ import type {SlotType} from "../../types/index.js"
3
+ import Panel from "./Panel.svelte"
4
+ import NavMenu from "../nav/NavMenu.svelte"
5
+ import NavGroup from "../nav/NavGroup.svelte"
6
+ import NavLinkItem from "../nav/NavLinkItem.svelte"
7
+ import DismissIcon from "../icons/DismissIcon.svelte"
8
+
9
+ type NavItem = {
10
+ label: string
11
+ href: string
12
+ icon?: string
13
+ onClick?: () => void
14
+ }
15
+
16
+ type NavGroupItem = {
17
+ title: string
18
+ icon: string
19
+ items: Array<{label: string; href: string}>
20
+ }
21
+
22
+ type CollapseMode = "auto" | "always" | "never"
23
+
24
+ type Props = {
25
+ brand?: string
26
+ brandHref?: string
27
+ brandTemplate?: SlotType
28
+ items?: NavItem[]
29
+ children?: SlotType
30
+ navigationGroups?: NavGroupItem[]
31
+ drawerContent?: SlotType
32
+ collapse?: CollapseMode
33
+ drawerPinned?: boolean
34
+ drawerWidth?: string
35
+ height?: number
36
+ menuToggleSize?: number
37
+ menuToggleTemplate?: SlotType
38
+ class?: string
39
+ style?: string
40
+ }
41
+
42
+ let {
43
+ brand = "Brand",
44
+ brandHref = "/",
45
+ brandTemplate = undefined,
46
+ items = [],
47
+ children = undefined,
48
+ navigationGroups = [],
49
+ drawerContent = undefined,
50
+ collapse = "auto",
51
+ drawerPinned = false,
52
+ drawerWidth = "280px",
53
+ height = 60,
54
+ menuToggleSize = 42,
55
+ menuToggleTemplate = undefined,
56
+ class: className = "",
57
+ style = ""
58
+ }: Props = $props()
59
+
60
+ let mobileMenuOpen = $state(false)
61
+
62
+ // Drawer is for collapsed nav items / groups OR custom drawerContent.
63
+ // The action slot is intentionally NOT counted — it stays visible at every
64
+ // width (search, account menu, theme toggle should remain reachable on
65
+ // mobile rather than being hidden inside a hamburger).
66
+ const hasDrawerContent = $derived(
67
+ items.length > 0 || navigationGroups.length > 0 || !!drawerContent
68
+ )
69
+
70
+ // Sync drawer's open state with `drawerPinned` so transitioning to pinned
71
+ // mode auto-reveals the rail (desktop default) and transitioning back to
72
+ // overlay mode starts closed (no surprise overlay on viewport resize).
73
+ // User toggles via the hamburger between transitions stand on their own
74
+ // since this effect only re-runs when drawerPinned itself changes.
75
+ $effect(() => {
76
+ mobileMenuOpen = drawerPinned
77
+ })
78
+
79
+ function toggleMobileMenu() {
80
+ mobileMenuOpen = !mobileMenuOpen
81
+ }
82
+
83
+ function closeMobileMenu() {
84
+ mobileMenuOpen = false
85
+ }
86
+
87
+ function handleNavClick(item: NavItem) {
88
+ item.onClick?.()
89
+ closeMobileMenu()
90
+ }
91
+ </script>
92
+
93
+ <nav
94
+ class="topnav {className}"
95
+ class:topnav--force-collapse={collapse === "always"}
96
+ class:topnav--force-expand={collapse === "never"}
97
+ class:topnav--drawer-pinned={drawerPinned}
98
+ style="height: {height}px; --topnav-toggle-size: {menuToggleSize}px; {style}"
99
+ >
100
+ <div class="topnav-container">
101
+ {#if hasDrawerContent}
102
+ <div class="topnav-menu-toggle">
103
+ {#if menuToggleTemplate}
104
+ {@render menuToggleTemplate({open: mobileMenuOpen, toggle: toggleMobileMenu})}
105
+ {:else}
106
+ <button
107
+ type="button"
108
+ class="mobile-menu-toggle"
109
+ onclick={toggleMobileMenu}
110
+ aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
111
+ aria-expanded={mobileMenuOpen}
112
+ >
113
+ {#if mobileMenuOpen}
114
+ <DismissIcon size={Math.round(menuToggleSize * 0.48)} />
115
+ {:else}
116
+ <span class="hamburger-glyph">☰</span>
117
+ {/if}
118
+ </button>
119
+ {/if}
120
+ </div>
121
+ {/if}
122
+
123
+ {#if brandTemplate}
124
+ {@render brandTemplate()}
125
+ {:else}
126
+ <a href={brandHref} class="topnav-brand">
127
+ {brand}
128
+ </a>
129
+ {/if}
130
+
131
+ <div class="topnav-trailing">
132
+ {#if items.length > 0}
133
+ <div class="topnav-items">
134
+ {#each items as item}
135
+ <a
136
+ href={item.href}
137
+ class="nav-item"
138
+ onclick={() => handleNavClick(item)}
139
+ >
140
+ {#if item.icon}
141
+ <span class="nav-icon">{item.icon}</span>
142
+ {/if}
143
+ <span class="nav-text">{item.label}</span>
144
+ </a>
145
+ {/each}
146
+ </div>
147
+ {/if}
148
+
149
+ {#if children}
150
+ {#if items.length > 0}
151
+ <div class="nav-divider"></div>
152
+ {/if}
153
+ <div class="topnav-actions">
154
+ {@render children()}
155
+ </div>
156
+ {/if}
157
+ </div>
158
+ </div>
159
+ </nav>
160
+
161
+ {#if hasDrawerContent}
162
+ <Panel
163
+ bind:open={mobileMenuOpen}
164
+ side="start"
165
+ width={drawerWidth}
166
+ top="{height}px"
167
+ pinned={drawerPinned}
168
+ class="topnav-drawer {drawerPinned ? 'topnav-drawer--pinned' : ''}"
169
+ >
170
+ <div class="topnav-drawer-body">
171
+ {#if items.length > 0}
172
+ <div class="topnav-drawer-items">
173
+ {#each items as item}
174
+ <a
175
+ href={item.href}
176
+ class="nav-item"
177
+ onclick={() => handleNavClick(item)}
178
+ >
179
+ {#if item.icon}
180
+ <span class="nav-icon">{item.icon}</span>
181
+ {/if}
182
+ <span class="nav-text">{item.label}</span>
183
+ </a>
184
+ {/each}
185
+ </div>
186
+ {/if}
187
+
188
+ {#if navigationGroups.length > 0}
189
+ {#if items.length > 0}
190
+ <hr class="topnav-drawer-separator" />
191
+ {/if}
192
+ <NavMenu>
193
+ {#each navigationGroups as group}
194
+ <NavGroup>
195
+ {#snippet linkIcon()}
196
+ <span class="nav-icon">{group.icon}</span>
197
+ {/snippet}
198
+ {#snippet linkText()}
199
+ {group.title}
200
+ {/snippet}
201
+
202
+ {#each group.items as item}
203
+ <NavLinkItem href={item.href}>{item.label}</NavLinkItem>
204
+ {/each}
205
+ </NavGroup>
206
+ {/each}
207
+ </NavMenu>
208
+ {/if}
209
+
210
+ {#if drawerContent}
211
+ {#if items.length > 0 || navigationGroups.length > 0}
212
+ <hr class="topnav-drawer-separator" />
213
+ {/if}
214
+ {@render drawerContent(closeMobileMenu)}
215
+ {/if}
216
+ </div>
217
+ </Panel>
218
+ {/if}
219
+
220
+ <style>
221
+ .topnav {
222
+ display: flex;
223
+ align-items: center;
224
+ background: var(--neutral-layer-1, #ffffff);
225
+ border-bottom: 1px solid var(--neutral-stroke-layer-rest, #e0e0e0);
226
+ padding: 0 0.5rem;
227
+ container-type: inline-size;
228
+ container-name: topnav;
229
+ }
230
+
231
+ .topnav-container {
232
+ display: flex;
233
+ align-items: center;
234
+ width: 100%;
235
+ position: relative;
236
+ gap: 1rem;
237
+ min-width: 0;
238
+ }
239
+
240
+ .topnav-brand {
241
+ text-decoration: none;
242
+ color: inherit;
243
+ font-size: 1.25rem;
244
+ font-weight: 600;
245
+ flex-shrink: 0;
246
+ }
247
+
248
+ /* Wrapper slot — owns visibility + position so a custom menuToggleTemplate
249
+ gets the same collapse behavior as the default button without consumers
250
+ needing to know about the internal rules. */
251
+ .topnav-menu-toggle {
252
+ display: none;
253
+ order: -1;
254
+ flex-shrink: 0;
255
+ align-items: center;
256
+ }
257
+
258
+ /* Default toggle — plain icon target, no button chrome. Sized via
259
+ --topnav-toggle-size (set inline from the menuToggleSize prop).
260
+ Custom templates render at their natural size. */
261
+ .mobile-menu-toggle {
262
+ width: var(--topnav-toggle-size, 42px);
263
+ height: var(--topnav-toggle-size, 42px);
264
+ display: inline-flex;
265
+ align-items: center;
266
+ justify-content: center;
267
+ padding: 0;
268
+ background: transparent;
269
+ border: none;
270
+ color: inherit;
271
+ cursor: pointer;
272
+ border-radius: var(--fluent-border-radius-md, 4px);
273
+ transition: opacity 0.15s, background-color 0.15s;
274
+ }
275
+
276
+ .mobile-menu-toggle:hover {
277
+ opacity: 0.7;
278
+ }
279
+
280
+ .mobile-menu-toggle:focus-visible {
281
+ outline: 2px solid var(--accent-fill-rest, currentColor);
282
+ outline-offset: 2px;
283
+ }
284
+
285
+ .hamburger-glyph {
286
+ font-size: calc(var(--topnav-toggle-size, 42px) * 0.55);
287
+ line-height: 1;
288
+ }
289
+
290
+ /* Trailing flex group: items + divider + actions. `margin-left: auto`
291
+ pushes it to the inline-end edge so brand stays next to hamburger
292
+ (or hugs the start edge when hamburger is hidden), instead of being
293
+ visually centered the way justify-content: space-between would do
294
+ with three flex children. */
295
+ .topnav-trailing {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 1.5rem;
299
+ min-width: 0;
300
+ margin-inline-start: auto;
301
+ }
302
+
303
+ .topnav-items {
304
+ display: flex;
305
+ gap: 1.5rem;
306
+ align-items: center;
307
+ min-width: 0;
308
+ overflow: hidden;
309
+ }
310
+
311
+ .nav-item {
312
+ text-decoration: none;
313
+ color: inherit;
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 0.5rem;
317
+ padding: 0.5rem 0.75rem;
318
+ border-radius: var(--fluent-border-radius-md);
319
+ transition: background-color 0.2s;
320
+ }
321
+
322
+ .nav-item:hover {
323
+ background-color: var(--neutral-fill-stealth-hover, rgba(0, 0, 0, 0.05));
324
+ }
325
+
326
+ .nav-icon {
327
+ font-size: 1.1rem;
328
+ }
329
+
330
+ .nav-divider {
331
+ width: 1px;
332
+ height: 24px;
333
+ background: var(--neutral-stroke-divider-rest, #e0e0e0);
334
+ }
335
+
336
+ .topnav-actions {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 0.5rem;
340
+ flex-shrink: 0;
341
+ }
342
+
343
+ /* Drawer styling — Panel handles position / transition / a11y */
344
+ .topnav-drawer-body {
345
+ flex: 1 1 0;
346
+ min-height: 0;
347
+ overflow-y: auto;
348
+ }
349
+
350
+ .topnav-drawer-items {
351
+ display: flex;
352
+ flex-direction: column;
353
+ gap: 0.125rem;
354
+ padding: 0 0.75rem;
355
+ }
356
+
357
+ .topnav-drawer-items .nav-item {
358
+ padding: 0.6rem 0.75rem;
359
+ }
360
+
361
+ .topnav-drawer-separator {
362
+ border: none;
363
+ border-top: 1px solid var(--neutral-stroke-divider-rest, #e0e0e0);
364
+ margin: 0.75rem 0;
365
+ }
366
+
367
+ @container topnav (max-width: 960px) {
368
+ .topnav-menu-toggle {
369
+ display: flex;
370
+ }
371
+
372
+ .topnav-items,
373
+ .nav-divider {
374
+ display: none;
375
+ }
376
+ }
377
+
378
+ @container topnav (max-width: 480px) {
379
+ .topnav-brand {
380
+ font-size: 1rem;
381
+ }
382
+ }
383
+
384
+ /* collapse="always" — force toggle visible + items hidden regardless of width */
385
+ .topnav--force-collapse .topnav-menu-toggle {
386
+ display: flex;
387
+ }
388
+
389
+ /* When drawer is pinned the toggle acts as a show/hide control for the
390
+ pinned rail itself, so it must stay visible at every width. */
391
+ .topnav--drawer-pinned .topnav-menu-toggle {
392
+ display: flex;
393
+ }
394
+
395
+ .topnav--force-collapse .topnav-items,
396
+ .topnav--force-collapse .nav-divider {
397
+ display: none;
398
+ }
399
+
400
+ /* collapse="never" — force items/divider visible + toggle hidden regardless of width */
401
+ .topnav--force-expand .topnav-menu-toggle {
402
+ display: none;
403
+ }
404
+
405
+ .topnav--force-expand .topnav-items {
406
+ display: flex;
407
+ }
408
+
409
+ .topnav--force-expand .nav-divider {
410
+ display: block;
411
+ }
412
+ </style>