@@ -19,14 +19,20 @@ class SidebarEntry {
1919 bool get hasChildren => children.isNotEmpty;
2020}
2121
22- /// A sidebar with support for collapsible nested items .
22+ /// A sidebar replicating Docusaurus's collapsible sidebar exactly .
2323///
24- /// Items with [SidebarEntry.children] render as a toggle row with a chevron
25- /// on the right. Clicking the row expands/collapses the children with a
26- /// 0.5s max-height animation.
24+ /// Matches the Docusaurus `menu__list-item-collapsible` structure:
25+ /// - Category items render a navigable [a] link alongside a separate
26+ /// [button] caret that handles expand/collapse only.
27+ /// - Leaf items render a single [a] link.
28+ /// - The active item and expanded category are determined server-side from
29+ /// the current page URL; client-side toggling uses an inline script.
2730///
28- /// The initial expanded/collapsed state is determined at build time from the
29- /// current page URL.
31+ /// Styling follows Infima's sidebar tokens:
32+ /// - Item padding: 0.375rem 0.75rem, border-radius: 0.25rem
33+ /// - Active: var(--primary) color, w600, 10 % primary tint background
34+ /// - Category headers: always w600 (semibold), whether expanded or not
35+ /// - Hover: rgba(0,0,0,0.05) light / rgba(255,255,255,0.05) dark
3036class CollapsibleSidebar extends StatelessComponent {
3137 const CollapsibleSidebar ({required this .items, super .key});
3238
@@ -38,11 +44,10 @@ class CollapsibleSidebar extends StatelessComponent {
3844
3945 return Component .fragment ([
4046 Document .head (children: [
41- Style (styles: styles),
4247 script (defer: true , content: _toggleScript),
4348 ]),
4449 nav (classes: 'sidebar' , [
45- // Close button for mobile overlay
50+ // Close button for the mobile overlay.
4651 button (classes: 'sidebar-close' , [
4752 svg (
4853 viewBox: '0 0 24 24' ,
@@ -55,21 +60,11 @@ class CollapsibleSidebar extends StatelessComponent {
5560 },
5661 [
5762 line (
58- attributes: {
59- 'x1' : '18' ,
60- 'y1' : '6' ,
61- 'x2' : '6' ,
62- 'y2' : '18' ,
63- },
63+ attributes: {'x1' : '18' , 'y1' : '6' , 'x2' : '6' , 'y2' : '18' },
6464 [],
6565 ),
6666 line (
67- attributes: {
68- 'x1' : '6' ,
69- 'y1' : '6' ,
70- 'x2' : '18' ,
71- 'y2' : '18' ,
72- },
67+ attributes: {'x1' : '6' , 'y1' : '6' , 'x2' : '18' , 'y2' : '18' },
7368 [],
7469 ),
7570 ],
@@ -88,83 +83,94 @@ class CollapsibleSidebar extends StatelessComponent {
8883 final isActive = currentRoute == item.href;
8984
9085 if (! item.hasChildren) {
91- // Simple link item
9286 return li ([
93- div (
94- classes: isActive ? 'active' : null ,
95- [a (href: item.href, [Component .text (item.text)])],
87+ a (
88+ href: item.href,
89+ classes: 'sidebar-link${isActive ? ' active' : '' }' ,
90+ [Component .text (item.text)],
9691 ),
9792 ]);
9893 }
9994
100- // Collapsible item: expanded if current route matches any child
101- final isExpanded = item.children.any (
102- (child) => currentRoute == child.href,
103- );
95+ // Expand the category if the current page is the category itself or a child.
96+ final isChildActive = item.children.any ((c) => currentRoute == c.href);
97+ final isExpanded = isActive || isChildActive;
10498
10599 return li (
106- classes: [
107- 'sidebar-collapsible' ,
108- if (isExpanded) 'expanded' ,
109- ].join (' ' ),
100+ classes: 'sidebar-collapsible${isExpanded ? ' expanded' : '' }' ,
110101 [
111- // Toggle row: text + chevron (clicking toggles, no navigation)
112- div (classes: isActive ? 'sidebar-toggle active' : 'sidebar-toggle' , [
113- span ([Component .text (item.text)]),
114- span (classes: 'sidebar-chevron' , [
115- svg (
116- viewBox: '0 0 24 24' ,
117- attributes: {
118- 'width' : '16' ,
119- 'height' : '16' ,
120- 'fill' : 'none' ,
121- 'stroke' : 'currentColor' ,
122- 'stroke-width' : '2.5' ,
123- 'stroke-linecap' : 'round' ,
124- 'stroke-linejoin' : 'round' ,
125- },
126- [
127- // Right-pointing chevron (>) — rotated 90° when expanded
128- polyline (points: '9 6 15 12 9 18' , []),
129- ],
130- ),
131- ]),
132- ]),
133- // Collapsible children
134- ul (classes: 'sidebar-children' , [
135- for (final child in item.children)
136- li ([
137- div (
138- classes: currentRoute == child.href ? 'active' : null ,
139- [a (href: child.href, [Component .text (child.text)])],
102+ // Category header: navigable link + separate caret toggle button.
103+ // Mirrors Docusaurus's div.menu__list-item-collapsible structure.
104+ div (classes: 'sidebar-category-header' , [
105+ a (
106+ href: item.href,
107+ // Full .active (color + bg tint) when the category page itself is
108+ // current; color-only .parent-active when a child page is current.
109+ classes: 'sidebar-link sidebar-category-link'
110+ '${isActive ? ' active' : isChildActive ? ' parent-active' : '' }' ,
111+ [Component .text (item.text)],
112+ ),
113+ button (
114+ classes: 'sidebar-caret' ,
115+ attributes: {'type' : 'button' },
116+ [
117+ svg (
118+ viewBox: '0 0 24 24' ,
119+ attributes: {
120+ 'width' : '16' ,
121+ 'height' : '16' ,
122+ 'fill' : 'none' ,
123+ 'stroke' : 'currentColor' ,
124+ 'stroke-width' : '2.5' ,
125+ 'stroke-linecap' : 'round' ,
126+ 'stroke-linejoin' : 'round' ,
127+ },
128+ // Right-pointing chevron (>); rotates 90° to point down when expanded.
129+ [polyline (points: '9 6 15 12 9 18' , [])],
140130 ),
141- ]),
131+ ],
132+ ),
133+ ]),
134+ // Wrapper div is required for the CSS grid expand animation:
135+ // grid-template-rows: 0fr → 1fr transitions on the actual content
136+ // height, giving smooth expand AND collapse (unlike max-height tricks).
137+ div (classes: 'sidebar-children' , [
138+ ul ([
139+ for (final child in item.children)
140+ li ([
141+ a (
142+ href: child.href,
143+ classes:
144+ 'sidebar-link${currentRoute == child .href ? ' active' : '' }' ,
145+ [Component .text (child.text)],
146+ ),
147+ ]),
148+ ]),
142149 ]),
143150 ],
144151 );
145152 }
146153
147- /// Inline script that toggles the expanded class on collapsible items.
154+ /// Toggles the `.expanded` class on `.sidebar-collapsible` when the caret
155+ /// button is clicked. The link itself navigates normally (browser default).
148156 static const _toggleScript = '''
149157(function(){
150158 document.addEventListener('click', function(e){
151- var toggle = e.target.closest('.sidebar-toggle');
152- if (!toggle) return;
153- e.preventDefault();
154- toggle.closest('.sidebar-collapsible').classList.toggle('expanded');
159+ var caret = e.target.closest('.sidebar-caret');
160+ if (!caret) return;
161+ caret.closest('.sidebar-collapsible').classList.toggle('expanded');
155162 });
156163})();
157164''' ;
158165
159166 @css
160167 static List <StyleRule > get styles => [
161- // Base sidebar styles (matching jaspr_content Sidebar)
168+ // ── Sidebar container ────────────────────────────────────────────────────
162169 css ('.sidebar' , [
163170 css ('&' ).styles (
164- fontSize: 0.875 .rem,
165- lineHeight: 1.25 .rem,
166171 padding: Padding .only (left: 0.5 .rem, bottom: 1.25 .rem, top: 0.75 .rem),
167172 position: Position .relative (),
173+ fontSize: 0.875 .rem,
168174 ),
169175 css.media (MediaQuery .all (minWidth: 1024. px), [
170176 css ('&' ).styles (padding: Padding .only (top: Unit .zero)),
@@ -182,83 +188,110 @@ class CollapsibleSidebar extends StatelessComponent {
182188 padding: Padding .only (top: 1.5 .rem, right: 0.75 .rem),
183189 ),
184190 css ('ul' ).styles (
185- listStyle: ListStyle .none,
186- margin: Margin .zero,
187191 padding: Padding .zero,
192+ margin: Margin .zero,
193+ listStyle: ListStyle .none,
188194 ),
189- css ('li' , [
190- css ('div' , [
191- css ('&' ).styles (
192- display: Display .flex,
193- margin: Margin .only (bottom: 1. px),
194- opacity: 0.75 ,
195- overflow: Overflow .hidden,
196- radius: BorderRadius .circular (.375. rem),
197- textOverflow: TextOverflow .ellipsis,
198- transition:
199- Transition ('all' , duration: 150. ms, curve: Curve .easeInOut),
200- whiteSpace: WhiteSpace .noWrap,
201- ),
202- css ('&:hover' ).styles (
203- opacity: 1 ,
204- backgroundColor: Color ('#0000000d' ),
205- ),
206- css ('&.active' ).styles (
207- opacity: 1 ,
208- color: ContentColors .primary,
209- fontWeight: FontWeight .w700,
210- backgroundColor:
211- Color ('color-mix(in srgb, currentColor 15%, transparent)' ),
212- ),
213- ]),
214- css ('a' ).styles (
215- display: Display .inlineFlex,
216- flex: Flex (grow: 1 ),
217- padding: Padding .only (left: 12. px, top: .5. rem, bottom: .5. rem),
218- ),
219- ]),
195+ css ('li' ).styles (margin: Margin .only (bottom: 2. px)),
220196 ]),
221197 ]),
222198
223- // Toggle row: flex with space-between so chevron goes to the right
224- css ('.sidebar-toggle' , [
225- css ('&' ).styles (
226- alignItems: AlignItems .center,
227- cursor: Cursor .pointer,
228- raw: {'justify-content' : 'space-between' , 'user-select' : 'none' },
229- ),
230- css ('span' ).styles (
231- display: Display .inlineFlex,
232- flex: Flex (grow: 1 ),
233- padding: Padding .only (left: 12. px, top: .5. rem, bottom: .5. rem),
234- ),
235- ]),
199+ // ── Sidebar link (Docusaurus menu__link) ─────────────────────────────────
200+ css ('.sidebar-link' ).styles (
201+ display: Display .flex,
202+ padding: Padding .symmetric (horizontal: 0.75 .rem, vertical: 0.375 .rem),
203+ radius: BorderRadius .circular (0.25 .rem),
204+ alignItems: AlignItems .center,
205+ color: Color .inherit,
206+ fontWeight: FontWeight .w400,
207+ textDecoration: TextDecoration .none,
208+ raw: {
209+ 'line-height' : '1.25' ,
210+ 'transition' : 'background 0.15s ease-in-out' ,
211+ },
212+ ),
213+ css ('.sidebar-link:hover' ).styles (
214+ backgroundColor: Color ('rgba(0, 0, 0, 0.05)' ),
215+ ),
216+ css ('.sidebar-link.active' ).styles (
217+ color: ContentColors .primary,
218+ backgroundColor: Color ('rgba(0, 0, 0, 0.05)' ),
219+ ),
220+ // Dark mode active background: same tint as hover.
221+ css ('[data-theme="dark"] .sidebar-link.active' ).styles (
222+ backgroundColor: Color ('rgba(255, 255, 255, 0.05)' ),
223+ ),
224+ // Parent category: color only, no background tint.
225+ css ('.sidebar-link.parent-active' ).styles (
226+ color: ContentColors .primary,
227+ ),
236228
237- // Chevron: sits on the right, rotates when expanded
238- css ('.sidebar-chevron' , [
239- css ('&' ).styles (
240- alignItems: AlignItems .center,
241- display: Display .flex,
242- opacity: 0.4 ,
243- padding: Padding .all (0.25 .rem),
244- transition: Transition ('transform' , duration: 300. ms, curve: Curve .ease),
245- ),
246- ]),
247- css ('.sidebar-toggle:hover .sidebar-chevron' ).styles (opacity: 0.8 ),
248- css ('.sidebar-collapsible.expanded .sidebar-chevron' ).styles (
229+ // ── Category header: link + caret side by side ───────────────────────────
230+ // Mirrors Docusaurus's div.menu__list-item-collapsible.
231+ css ('.sidebar-category-header' ).styles (
232+ display: Display .flex,
233+ alignItems: AlignItems .stretch,
234+ ),
235+ // Category link takes remaining width; always semibold (Docusaurus default
236+ // for menu__link--sublist regardless of expanded/active state).
237+ css ('.sidebar-category-link' ).styles (
238+ flex: Flex (grow: 1 ),
239+ fontWeight: FontWeight .w600,
240+ ),
241+
242+ // ── Caret toggle button (Docusaurus menu__caret) ─────────────────────────
243+ css ('.sidebar-caret' ).styles (
244+ display: Display .flex,
245+ padding: Padding .symmetric (horizontal: 0.25 .rem),
246+ radius: BorderRadius .circular (0.25 .rem),
247+ alignItems: AlignItems .center,
248+ color: Color .inherit,
249+ raw: {
250+ 'opacity' : '0.4' ,
251+ 'border' : 'none' ,
252+ 'background' : 'none' ,
253+ 'cursor' : 'pointer' ,
254+ 'transition' : 'transform 0.2s ease, opacity 0.15s ease' ,
255+ 'flex-shrink' : '0' ,
256+ },
257+ ),
258+ css ('.sidebar-caret:hover' ).styles (
259+ opacity: 0.8 ,
260+ backgroundColor: Color ('rgba(0, 0, 0, 0.05)' ),
261+ ),
262+ // Caret stays visible (opacity 0.8) and rotates 90° when category is expanded.
263+ css ('.sidebar-collapsible.expanded .sidebar-caret' ).styles (
264+ opacity: 0.8 ,
249265 raw: {'transform' : 'rotate(90deg)' },
250266 ),
251267
252- // Collapsible children: animated via max-height
268+ // ── Children collapse/expand animation (CSS grid trick) ─────────────────
269+ // grid-template-rows: 0fr → 1fr transitions on the real content height,
270+ // giving symmetric smooth expand AND collapse animations.
271+ // The direct child (ul) must have overflow:hidden to clip at 0fr.
253272 css ('.sidebar-children' ).styles (
254- overflow: Overflow .hidden,
255273 raw: {
256- 'max-height' : '0' ,
257- 'transition' : 'max-height 0.5s ease' ,
274+ 'display' : 'grid' ,
275+ 'grid-template-rows' : '0fr' ,
276+ 'transition' : 'grid-template-rows 0.3s ease' ,
258277 },
259278 ),
279+ css ('.sidebar-children > ul' ).styles (
280+ padding: Padding .only (left: 1. rem),
281+ overflow: Overflow .hidden,
282+ ),
260283 css ('.sidebar-collapsible.expanded .sidebar-children' ).styles (
261- raw: {'max-height' : '500px' },
284+ raw: {'grid-template-rows' : '1fr' },
285+ ),
286+
287+ // ── Dark mode overrides ──────────────────────────────────────────────────
288+ // Active uses var(--primary) which auto-switches via ContentTheme; only
289+ // hover backgrounds need explicit dark overrides.
290+ css ('[data-theme="dark"] .sidebar-link:hover' ).styles (
291+ backgroundColor: Color ('rgba(255, 255, 255, 0.05)' ),
292+ ),
293+ css ('[data-theme="dark"] .sidebar-caret:hover' ).styles (
294+ backgroundColor: Color ('rgba(255, 255, 255, 0.05)' ),
262295 ),
263296 ];
264297}
0 commit comments