Skip to content

Commit fb87b17

Browse files
feat: implement collapsible/expandable sidebar custom component
1 parent 90be0c3 commit fb87b17

2 files changed

Lines changed: 169 additions & 170 deletions

File tree

site_jaspr/lib/components/collapsible_sidebar.dart

Lines changed: 166 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3036
class 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

Comments
 (0)