@classic-homes/theme-svelte 1.17.0 → 1.18.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.
@@ -27,4 +27,13 @@ describe('DropdownMenu trigger', () => {
27
27
  }
28
28
  expect(button.classList.contains('custom')).toBe(true);
29
29
  });
30
+ it('adds a disclosure chevron only with showChevron', () => {
31
+ const { unmount } = render(DropdownMenu, { props: { items, trigger } });
32
+ expect(screen.getByRole('button', { name: 'Menu' }).querySelector('svg')).toBeNull();
33
+ unmount();
34
+ render(DropdownMenu, { props: { items, trigger, triggerVariant: 'ghost', showChevron: true } });
35
+ const chevron = screen.getByRole('button', { name: 'Menu' }).querySelector('svg');
36
+ expect(chevron).not.toBeNull();
37
+ expect(chevron?.getAttribute('aria-hidden')).toBe('true');
38
+ });
30
39
  });
@@ -70,4 +70,17 @@ describe('sidebar mobile semantics', () => {
70
70
  sidebarStore.setMobile(false);
71
71
  expect(sidebarStore.isOpen).toBe(false);
72
72
  });
73
+ it('does not animate expansion until the frames after initialize', async () => {
74
+ const appId = freshAppId();
75
+ localStorage.setItem(`classic-theme-sidebar-${appId}-sections`, JSON.stringify(['a']));
76
+ // A fresh store instance would start false; the singleton may already be settled
77
+ // from earlier tests, so check the reset-on-initialize path via a new appId.
78
+ sidebarStore.animate = false;
79
+ sidebarStore.initialize(appId);
80
+ expect(sidebarStore.isSectionExpanded('a')).toBe(true);
81
+ expect(sidebarStore.animate).toBe(false);
82
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
83
+ await new Promise((resolve) => setTimeout(resolve, 0));
84
+ expect(sidebarStore.animate).toBe(true);
85
+ });
73
86
  });
@@ -50,6 +50,11 @@
50
50
  triggerSize?: ButtonVariants['size'];
51
51
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
52
52
  triggerLabel?: string;
53
+ /**
54
+ * Show a disclosure chevron after the trigger content, turned while the menu is open,
55
+ * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
56
+ */
57
+ showChevron?: boolean;
53
58
  }
54
59
 
55
60
  let {
@@ -64,6 +69,7 @@
64
69
  triggerVariant,
65
70
  triggerSize = 'sm',
66
71
  triggerLabel,
72
+ showChevron = false,
67
73
  }: Props = $props();
68
74
 
69
75
  function handleOpenChange(newOpen: boolean) {
@@ -130,6 +136,17 @@
130
136
  aria-label={triggerLabel}
131
137
  >
132
138
  {@render trigger()}
139
+ {#if showChevron}
140
+ <Icon
141
+ name="chevron-down"
142
+ size={16}
143
+ class={cn(
144
+ 'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
145
+ open && 'rotate-180'
146
+ )}
147
+ aria-hidden="true"
148
+ />
149
+ {/if}
133
150
  </button>
134
151
  {/snippet}
135
152
  </DropdownMenuPrimitive.Trigger>
@@ -44,6 +44,11 @@ interface Props {
44
44
  triggerSize?: ButtonVariants['size'];
45
45
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
46
46
  triggerLabel?: string;
47
+ /**
48
+ * Show a disclosure chevron after the trigger content, turned while the menu is open,
49
+ * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
50
+ */
51
+ showChevron?: boolean;
47
52
  }
48
53
  declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
49
54
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
@@ -165,11 +165,10 @@
165
165
  : navigation
166
166
  );
167
167
 
168
- // Reactive media query for mobile detection. Initialized synchronously so
169
- // the first client paint doesn't flash the desktop layout (256px content
170
- // margin + full-width sidebar) before an effect corrects it. Deliberately
171
- // reads the initial `mobileBreakpoint` — later changes are handled by the
172
- // matchMedia effect below.
168
+ // Reactive media query for mobile detection. Initialized synchronously so a
169
+ // client-only render paints the right layout at once. Deliberately reads the
170
+ // initial `mobileBreakpoint` — later changes are handled by the matchMedia
171
+ // effect below.
173
172
  // svelte-ignore state_referenced_locally
174
173
  const initialIsMobile =
175
174
  typeof window !== 'undefined' &&
@@ -179,12 +178,23 @@
179
178
  sidebarStore.setMobile(initialIsMobile);
180
179
  }
181
180
 
182
- // Suppress the margin transition until after the first paint so any
183
- // hydration correction snaps instead of visibly sliding.
181
+ // Until mounted the layout is responsive in CSS alone: the server cannot know
182
+ // the viewport, so a JS-sized layout would render as desktop and visibly snap
183
+ // to mobile on hydration. Server HTML and the hydrating render therefore share
184
+ // breakpoint classes; once mounted, JS state (collapse, drawer) takes over.
184
185
  let mounted = $state(false);
186
+ // One frame later still: lets the hand-over to JS sizing (e.g. a saved collapsed
187
+ // sidebar) snap instead of sliding.
188
+ let animate = $state(false);
185
189
 
186
190
  $effect(() => {
187
191
  mounted = true;
192
+ if (typeof requestAnimationFrame !== 'function') {
193
+ animate = true;
194
+ return;
195
+ }
196
+ const frame = requestAnimationFrame(() => (animate = true));
197
+ return () => cancelAnimationFrame(frame);
188
198
  });
189
199
 
190
200
  $effect(() => {
@@ -252,6 +262,14 @@
252
262
  isMobile ? 0 : sidebarStore.isOpen ? expandedWidth : collapsedWidth
253
263
  );
254
264
 
265
+ // Pre-mount responsive classes, per breakpoint (literal strings, for Tailwind)
266
+ const ssrClasses: Record<Breakpoint, { content: string; sidebar: string }> = {
267
+ sm: { content: 'sm:ml-[var(--dl-sidebar-width)]', sidebar: 'max-sm:-translate-x-full' },
268
+ md: { content: 'md:ml-[var(--dl-sidebar-width)]', sidebar: 'max-md:-translate-x-full' },
269
+ lg: { content: 'lg:ml-[var(--dl-sidebar-width)]', sidebar: 'max-lg:-translate-x-full' },
270
+ };
271
+ const ssr = $derived(ssrClasses[mobileBreakpoint]);
272
+
255
273
  const contentPaddingClass = $derived(
256
274
  contentPadding === 'none' ? '' : contentPadding === 'compact' ? 'p-2 lg:p-4' : 'p-4 lg:p-6'
257
275
  );
@@ -286,21 +304,25 @@
286
304
  {onPinnedChange}
287
305
  {pinIcon}
288
306
  {appId}
307
+ class={mounted ? undefined : ssr.sidebar}
289
308
  />
290
309
 
291
310
  <!-- Main Content Area -->
292
311
  <div
293
- class="flex flex-1 flex-col {mounted
312
+ class="flex flex-1 flex-col {mounted ? '' : ssr.content} {animate
294
313
  ? 'transition-[margin] duration-300 motion-reduce:transition-none'
295
314
  : ''}"
296
- style="margin-left: {sidebarWidth}px;"
315
+ style={mounted
316
+ ? `margin-left: ${sidebarWidth}px;`
317
+ : `--dl-sidebar-width: ${sidebarCollapsed ? collapsedWidth : expandedWidth}px;`}
297
318
  >
298
319
  {#if showHeader}
299
320
  <Header
300
- showMenuButton={isMobile}
301
- menuOpen={sidebarStore.isOpen}
321
+ {mobileBreakpoint}
322
+ showMenuButton={isMobile || !mounted}
323
+ menuOpen={mounted && isMobile && sidebarStore.isOpen}
302
324
  onMenuClick={() => sidebarStore.toggle()}
303
- showCollapseButton={!isMobile}
325
+ showCollapseButton={!isMobile || !mounted}
304
326
  sidebarCollapsed={!sidebarStore.isOpen}
305
327
  onCollapseClick={() => sidebarStore.toggle()}
306
328
  title={pageTitle}
@@ -346,7 +346,8 @@
346
346
  >
347
347
  <svg
348
348
  class={cn(
349
- 'h-4 w-4 transition-transform duration-200',
349
+ 'h-4 w-4',
350
+ sidebarStore.animate && 'transition-transform duration-200',
350
351
  isExpanded ? 'rotate-0' : '-rotate-90'
351
352
  )}
352
353
  fill="none"
@@ -371,7 +372,10 @@
371
372
  </div>
372
373
 
373
374
  {#if isExpanded}
374
- <ul class="mt-1 space-y-1 pl-4 overflow-hidden" transition:slide={{ duration: 200 }}>
375
+ <ul
376
+ class="mt-1 space-y-1 pl-4 overflow-hidden"
377
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
378
+ >
375
379
  {#each item.children ?? [] as child (child.id)}
376
380
  <li>
377
381
  {#if child.children?.length}
@@ -78,7 +78,8 @@
78
78
  <button
79
79
  onclick={toggleSection}
80
80
  class={cn(
81
- 'flex w-full items-center justify-between mb-2 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
81
+ // min-h-8: a 32px target (WCAG 2.5.8 asks 24px); the text-xs line alone is 16px
82
+ 'flex min-h-8 w-full items-center justify-between mb-1 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
82
83
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
83
84
  isLight
84
85
  ? 'text-muted-foreground hover:text-foreground'
@@ -90,7 +91,8 @@
90
91
  <span class="truncate">{section.title}</span>
91
92
  <svg
92
93
  class={cn(
93
- 'h-4 w-4 shrink-0 transition-transform duration-200',
94
+ 'h-4 w-4 shrink-0',
95
+ sidebarStore.animate && 'transition-transform duration-200',
94
96
  isExpanded ? 'rotate-0' : '-rotate-90'
95
97
  )}
96
98
  fill="none"
@@ -147,7 +149,7 @@
147
149
  <ul
148
150
  class="space-y-1 px-2 overflow-hidden"
149
151
  id={`sidebar-section-${section.id}`}
150
- transition:slide={{ duration: 200 }}
152
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
151
153
  >
152
154
  {#each section.items as item}
153
155
  <li>
@@ -13,6 +13,12 @@ declare class SidebarStore {
13
13
  expandedSections: Set<string>;
14
14
  expandedItems: Set<string>;
15
15
  pinnedItems: Set<string>;
16
+ /**
17
+ * Whether expand/collapse should animate. False until the frame after `initialize`,
18
+ * so restoring saved sections (and any defaults or reveals applied on mount) appears
19
+ * at once instead of every section sliding open on each page load.
20
+ */
21
+ animate: boolean;
16
22
  /**
17
23
  * Whether the store has been initialized
18
24
  */
@@ -27,6 +27,12 @@ class SidebarStore {
27
27
  this.expandedSections = $state(new Set());
28
28
  this.expandedItems = $state(new Set());
29
29
  this.pinnedItems = $state(new Set());
30
+ /**
31
+ * Whether expand/collapse should animate. False until the frame after `initialize`,
32
+ * so restoring saved sections (and any defaults or reveals applied on mount) appears
33
+ * at once instead of every section sliding open on each page load.
34
+ */
35
+ this.animate = $state(false);
30
36
  _SidebarStore_initialized.set(this, false);
31
37
  _SidebarStore_loadedFromStorage.set(this, false);
32
38
  _SidebarStore_loadedExpansionFromStorage.set(this, false);
@@ -114,6 +120,13 @@ class SidebarStore {
114
120
  catch {
115
121
  // localStorage not available
116
122
  }
123
+ // Two frames: effects that apply defaults run after this one, before the next paint.
124
+ if (typeof requestAnimationFrame === 'function') {
125
+ requestAnimationFrame(() => requestAnimationFrame(() => (this.animate = true)));
126
+ }
127
+ else {
128
+ this.animate = true;
129
+ }
117
130
  }
118
131
  /**
119
132
  * Get the current app ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",