@surstromming/sidebar-group 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evgeniy Mnatsakanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @surstromming/sidebar-group
2
+
3
+ A labelled section of sidebar navigation, rendered from data. One group is a
4
+ title plus a menu; each menu item is an icon, a label, and — optionally — a
5
+ **submenu**: either nested nav links that collapse in place, or a `…` dropdown
6
+ of actions for that item.
7
+
8
+ Drop groups into a `<Sidebar>`; the group knows nothing about the sidebar's
9
+ open/closed state.
10
+
11
+ ## Dependency graph
12
+
13
+ ```mermaid
14
+ graph LR
15
+ sidebar_group["@surstromming/sidebar-group"]
16
+ design["@surstromming/design"]
17
+ dropdown_menu["@surstromming/dropdown-menu"]
18
+ icon["@surstromming/icon"]
19
+ popover["@surstromming/popover"]
20
+ scroll_area["@surstromming/scroll-area"]
21
+ util["@surstromming/util"]
22
+ sidebar_group --> design
23
+ sidebar_group --> dropdown_menu
24
+ dropdown_menu --> design
25
+ dropdown_menu --> icon
26
+ dropdown_menu --> popover
27
+ popover --> design
28
+ popover --> scroll_area
29
+ scroll_area --> design
30
+ scroll_area --> util
31
+ sidebar_group --> icon
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```vue
37
+ <template>
38
+ <Sidebar v-model:open="sidebar.open">
39
+ <SidebarGroup label="Platform" :items="platform" @select="go" @toggle="toggleSection" />
40
+ <SidebarGroup label="Projects" :items="projects" @select="go" @action="onAction" />
41
+ </Sidebar>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ import { computed, ref } from 'vue'
46
+ import { Sidebar } from '@surstromming/sidebar'
47
+ import { SidebarGroup } from '@surstromming/sidebar-group'
48
+ import type { SidebarGroupItem } from '@surstromming/sidebar-group'
49
+ import { Bot, Frame, SquareTerminal, Folder, Trash2 } from 'lucide'
50
+ import { useRoute, useRouter } from 'vue-router'
51
+
52
+ const router = useRouter()
53
+ const route = useRoute()
54
+ const go = (value: string) => router.push(value)
55
+ const onAction = (item: string, action: string) => console.log(item, action)
56
+
57
+ // The app owns both `active` (derived from the route) and `expanded`.
58
+ const openSection = ref<string | null>('/playground')
59
+ const toggleSection = (value: string) => {
60
+ openSection.value = openSection.value === value ? null : value
61
+ }
62
+
63
+ // Nested nav: submenu.type 'items'
64
+ const platform = computed<SidebarGroupItem[]>(() => [
65
+ {
66
+ label: 'Playground',
67
+ value: '/playground',
68
+ icon: SquareTerminal,
69
+ active: route.path.startsWith('/playground'),
70
+ expanded: openSection.value === '/playground',
71
+ submenu: {
72
+ type: 'items',
73
+ entries: [
74
+ { label: 'History', value: '/playground/history' },
75
+ { label: 'Starred', value: '/playground/starred', active: route.path === '/playground/starred' },
76
+ ],
77
+ },
78
+ },
79
+ { label: 'Models', value: '/models', icon: Bot },
80
+ ])
81
+
82
+ // Per-item action menu: submenu.type 'menu'
83
+ const projects: SidebarGroupItem[] = [
84
+ {
85
+ label: 'Design Engineering',
86
+ value: '/projects/design',
87
+ icon: Frame,
88
+ submenu: {
89
+ type: 'menu',
90
+ entries: [
91
+ { label: 'View project', value: 'view', icon: Folder },
92
+ { separator: true },
93
+ { label: 'Delete', value: 'delete', icon: Trash2, destructive: true },
94
+ ],
95
+ },
96
+ },
97
+ ]
98
+ </script>
99
+ ```
100
+
101
+ ## Props
102
+
103
+ | Prop | Type | Default | Notes |
104
+ | ---------------- | ------------------- | ------- | ------------------------------------------------------- |
105
+ | `label` | `string` | — | Group title. Omit for an unlabelled group. |
106
+ | `items` | `SidebarGroupItem[]` | — | Required. |
107
+ | `side` | `left \| right` | `left` | Where the *sidebar* is; the action menu opens opposite. |
108
+ | `layer` | `popover \| menu \| modal` | `menu` | Stacking for the (teleported) action menu. |
109
+
110
+ ### Item
111
+
112
+ | Field | Type | Notes |
113
+ | --------- | -------------------- | ------------------------------------------------------------ |
114
+ | `label` | `string` | Visible text. |
115
+ | `value` | `string` | Identity; what `select` emits. A route path reads well. |
116
+ | `href` | `string` | Renders the row as a real `<a>` — see below. Absent → `<button>`. |
117
+ | `icon` | `IconNode` | A lucide icon (`import { Bot } from 'lucide'`). |
118
+ | `active` | `boolean` | Paints the current page's row. The app owns "current". |
119
+ | `expanded` | `boolean` | Opens the nested list. The app owns it — see below. |
120
+ | `submenu` | `SidebarGroupSubmenu` | See below. Absent → a plain row. |
121
+
122
+ ### Rows, links, and clicks
123
+
124
+ A row with `href` is an `<a href>`; without one it's a `<button>`. The element
125
+ is picked by a `computed` returning `{ is, props }`, so the row's children are
126
+ written once.
127
+
128
+ `href` exists for the behaviors only a genuine link has: middle-click and
129
+ Cmd/Ctrl+click open a new tab, the context menu offers "Open in new tab", the
130
+ browser previews the URL on hover. Faking those on a `<button>` with `auxclick`
131
+ + `window.open` gets one of them right and lies about the rest, so it isn't done.
132
+
133
+ Plain left clicks are still intercepted (`preventDefault`) and emitted as
134
+ `select`, leaving navigation to the app's router; modified clicks fall through
135
+ to the browser untouched, and middle-click never reaches the handler at all
136
+ (it fires `auxclick`, not `click`). This is `RouterLink`'s contract. The
137
+ consequence to know: **an item with `href` whose `select` nobody handles does
138
+ nothing on a plain click** — the href only carries modified clicks.
139
+
140
+ ### Submenu
141
+
142
+ Nested links and an action menu both hang off an item's trailing control, so
143
+ they can't coexist — the data names which one it is:
144
+
145
+ ```ts
146
+ export type SidebarGroupSubmenu =
147
+ | { type: 'items'; entries: SidebarGroupSubItem[] } // collapsible nested nav
148
+ | { type: 'menu'; entries: DropdownMenuItem[] } // '…' dropdown of actions
149
+ ```
150
+
151
+ `type: 'items'` renders a chevron that expands `entries` beneath the row
152
+ (`{ label, value, active? }` — same `select` emit as a top-level item).
153
+ `type: 'menu'` renders a `…` trigger — appearing on hover or keyboard focus —
154
+ that opens a `DropdownMenu` built from `entries`
155
+ (`@surstromming/dropdown-menu`'s own item type, separators and `destructive`
156
+ included). The menu teleports to `<body>` and opens on the side away from the
157
+ sidebar (`side` flips it), so a sidebar's `overflow` never clips it.
158
+
159
+ ## Emits
160
+
161
+ | Event | Payload | Fired when |
162
+ | -------- | -------------------------------- | --------------------------------------------------- |
163
+ | `select` | `value: string` | An item or sub-item row is plain-clicked. |
164
+ | `action` | `item: string, action: string` | A dropdown entry is chosen; `item` is whose menu. |
165
+ | `toggle` | `value: string` | The chevron is clicked; flip that item's `expanded`. |
166
+
167
+ The component emits and the app decides (`router.push`, a store call, anything)
168
+ — the package never navigates and never imports a router.
169
+
170
+ ## Fallthrough
171
+
172
+ Single root (`<div>`), default `inheritAttrs` — `class`, `aria-*` and listeners
173
+ land on the group. The action menu is teleported to `<body>`, so it's stacked
174
+ by `layer` (default `menu`) — above the sidebar's own rung, since on mobile the
175
+ drawer is teleported too and a lower menu would render *behind* it.
176
+
177
+ ## Anatomy
178
+
179
+ ```
180
+ div.root the group
181
+ ├── div.label `label` (omitted when unset)
182
+ └── ul.menu
183
+ └── li.item
184
+ ├── a|button .button icon + label (`href` picks) → select
185
+ ├── button.action chevron, type 'items' → toggle
186
+ ├── DropdownMenu '…', type 'menu' → action
187
+ └── div.sub collapse wrapper (0fr → 1fr)
188
+ └── ul > li > button.subButton → select
189
+ ```
190
+
191
+ Nested rows collapse with `grid-template-rows: 0fr → 1fr` plus `overflow:
192
+ hidden` (Accordion's trick) — real height, no JS measuring.
193
+
194
+ ## Expanded state
195
+
196
+ `expanded` is item data, like `active` — the component is fully controlled and
197
+ holds no open/closed state of its own. The chevron emits `toggle(value)`; the
198
+ app flips the flag (usually in the same `computed` that derives `active`).
199
+ Nothing auto-opens from `active` either — that's the app's call too.
200
+
201
+ Policy lives where the state does. One-section-at-a-time isn't a prop, it's
202
+ the app holding a single value:
203
+
204
+ ```ts
205
+ const openSection = ref<string | null>('playground')
206
+ const toggleSection = (value: string) => {
207
+ openSection.value = openSection.value === value ? null : value
208
+ }
209
+ // in the items computed: expanded: openSection.value === 'playground'
210
+ ```
211
+
212
+ Several-at-once is the same handler over a `string[]` instead.
213
+
214
+ ## Tokens
215
+
216
+ `sidebar-foreground` (label at 70% via `with-alpha`, rows at full), `sidebar-accent`
217
+ / `sidebar-accent-foreground` (hover and the `active` row), `sidebar-border` (the
218
+ nested list's leading rule), `sidebar-ring` (focus). Spacing and radii come from
219
+ `design.spacing()` / `design.radius()`.
220
+
221
+ ## Accessibility
222
+
223
+ The menu is a `<ul>` of `<li>`s; every row is a real `<button>` or `<a href>`,
224
+ so Tab, Enter and the link affordances are the browser's. The chevron carries `aria-expanded` and controls the
225
+ nested list; an `active` row gets `aria-current="page"`. The `…` trigger is
226
+ labelled `aria-label="More"`.
227
+
228
+ ## Recipes
229
+
230
+ **Flat group, no submenus** — pass items without `submenu` (shadcn's
231
+ `nav-secondary`).
232
+
233
+ **Push a group to the bottom** — `class` falls through:
234
+ `<SidebarGroup class="mt-auto" …>`, or `margin-block-start: auto` in the app.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@surstromming/sidebar-group",
3
+ "version": "0.1.0",
4
+ "description": "A labelled group of sidebar navigation, rendered from data.",
5
+ "license": "MIT",
6
+ "author": "Evgeniy Mnatsakanov <evgeniy.mnatsakanov@gmail.com>",
7
+ "homepage": "https://github.com/hackteck/surstromming/tree/main/packages/sidebar-group#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hackteck/surstromming.git",
11
+ "directory": "packages/sidebar-group"
12
+ },
13
+ "bugs": "https://github.com/hackteck/surstromming/issues",
14
+ "keywords": [
15
+ "vue",
16
+ "vue3",
17
+ "component-library",
18
+ "design-system",
19
+ "ui",
20
+ "scss",
21
+ "css-modules",
22
+ "shadcn",
23
+ "sidebar-group"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": [
27
+ "**/*.scss",
28
+ "**/*.css"
29
+ ],
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "main": "./src/index.ts",
34
+ "module": "./src/index.ts",
35
+ "types": "./src/index.ts",
36
+ "exports": {
37
+ ".": "./src/index.ts"
38
+ },
39
+ "dependencies": {
40
+ "@surstromming/design": "^0.1.0",
41
+ "@surstromming/dropdown-menu": "^0.1.0",
42
+ "@surstromming/icon": "^0.1.0",
43
+ "@surstromming/popover": "^0.1.0"
44
+ },
45
+ "peerDependencies": {
46
+ "vue": "^3.4.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,195 @@
1
+ <template>
2
+ <div :class="$style.root">
3
+ <div v-if="label" :class="$style.label">{{ label }}</div>
4
+
5
+ <ul :class="$style.menu">
6
+ <li v-for="item in items" :key="item.value" :class="$style.item">
7
+ <component
8
+ :is="rowElement(item)"
9
+ :class="buttonClasses(item)"
10
+ :href="item.href"
11
+ :type="rowType(item)"
12
+ :aria-current="ariaCurrent(item)"
13
+ @click="select($event, item)"
14
+ >
15
+ <Icon v-if="item.icon" :icon="item.icon" :size="16" />
16
+ <span :class="$style.text">{{ item.label }}</span>
17
+ </component>
18
+
19
+ <button
20
+ v-if="nestedEntries(item).length"
21
+ :class="[$style.action, $style.control]"
22
+ type="button"
23
+ :aria-label="`Toggle ${item.label}`"
24
+ :aria-expanded="Boolean(item.expanded)"
25
+ @click="emit('toggle', item.value)"
26
+ >
27
+ <Icon :class="chevronClasses(item)" :icon="ChevronRight" :size="16" />
28
+ </button>
29
+
30
+ <DropdownMenu
31
+ v-else-if="menuEntries(item).length"
32
+ :class="$style.action"
33
+ :items="menuEntries(item)"
34
+ :side="menuSide"
35
+ :layer="layer"
36
+ align="start"
37
+ @select="(action) => emit('action', item.value, action)"
38
+ >
39
+ <template #trigger="{ open, toggle: toggleMenu }">
40
+ <button
41
+ :class="triggerClasses(open)"
42
+ type="button"
43
+ aria-label="More"
44
+ @click="toggleMenu"
45
+ >
46
+ <Icon :icon="Ellipsis" :size="16" />
47
+ </button>
48
+ </template>
49
+ </DropdownMenu>
50
+
51
+ <!-- inert while collapsed: the rows are clipped, not gone — Tab would
52
+ otherwise walk into links nobody can see. -->
53
+ <div
54
+ v-if="nestedEntries(item).length"
55
+ :class="nestedClasses(item)"
56
+ :inert="!item.expanded"
57
+ >
58
+ <div :class="$style.subInner">
59
+ <ul :class="$style.subList">
60
+ <li v-for="sub in nestedEntries(item)" :key="sub.value">
61
+ <component
62
+ :is="rowElement(sub)"
63
+ :class="subButtonClasses(sub)"
64
+ :href="sub.href"
65
+ :type="rowType(sub)"
66
+ :aria-current="ariaCurrent(sub)"
67
+ @click="select($event, sub)"
68
+ >
69
+ {{ sub.label }}
70
+ </component>
71
+ </li>
72
+ </ul>
73
+ </div>
74
+ </div>
75
+ </li>
76
+ </ul>
77
+ </div>
78
+ </template>
79
+
80
+ <script setup lang="ts">
81
+ import { computed, useCssModule } from 'vue'
82
+ import { DropdownMenu } from '@surstromming/dropdown-menu'
83
+ import type { PopoverLayer } from '@surstromming/popover'
84
+ import { Icon } from '@surstromming/icon'
85
+ import { ChevronRight, Ellipsis } from 'lucide'
86
+ import type { SidebarGroupItem, SidebarGroupSide, SidebarGroupSubItem } from './index'
87
+
88
+ const props = withDefaults(
89
+ defineProps<{
90
+ label?: string
91
+ items: SidebarGroupItem[]
92
+ side?: SidebarGroupSide
93
+ /**
94
+ * Stacking for the action menu, which is teleported to <body>. The `menu`
95
+ * rung by default, above the sidebar's own: on mobile the drawer is
96
+ * teleported too, and a menu opened inside it would otherwise render
97
+ * behind it.
98
+ */
99
+ layer?: PopoverLayer
100
+ }>(),
101
+ { side: 'left', layer: 'menu' },
102
+ )
103
+
104
+ // The menu opens away from the sidebar, over the content. Flipping with the
105
+ // sidebar's side is also what an RTL layout will want.
106
+ const menuSide = computed(() => (props.side === 'left' ? 'right' : 'left'))
107
+
108
+ // Fully controlled: `expanded` lives in the data, so the chevron only asks.
109
+ const emit = defineEmits<{
110
+ select: [value: string]
111
+ action: [item: string, action: string]
112
+ toggle: [value: string]
113
+ }>()
114
+
115
+ // Empty arrays rather than undefined: the template can count them and loop
116
+ // them without narrowing a union across two calls.
117
+ const nestedEntries = (item: SidebarGroupItem): SidebarGroupSubItem[] =>
118
+ item.submenu?.type === 'items' ? item.submenu.entries : []
119
+
120
+ const menuEntries = (item: SidebarGroupItem) =>
121
+ item.submenu?.type === 'menu' ? item.submenu.entries : []
122
+
123
+ // `href` buys the link affordances a <button> can't have: middle-click,
124
+ // Cmd+click, "Open in new tab", the URL preview on hover.
125
+ const rowElement = (item: SidebarGroupSubItem) => (item.href ? 'a' : 'button')
126
+ const rowType = (item: SidebarGroupSubItem) => (item.href ? undefined : 'button')
127
+ const ariaCurrent = (item: SidebarGroupSubItem) => (item.active ? 'page' : undefined)
128
+
129
+ const select = (event: MouseEvent, item: SidebarGroupSubItem) => {
130
+ // A modified click on a real link is the browser's — that's the whole point
131
+ // of the <a>. (Middle-click fires auxclick, so it never lands here.)
132
+ if (item.href && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) return
133
+ event.preventDefault()
134
+ emit('select', item.value)
135
+ }
136
+
137
+ const $style = useCssModule()
138
+ const buttonClasses = (item: SidebarGroupItem) => [
139
+ $style.button,
140
+ {
141
+ [$style.isActive]: item.active,
142
+ [$style.hasAction]: Boolean(item.submenu),
143
+ },
144
+ ]
145
+ // The '…' hides until the row is hovered; an open menu holds it visible —
146
+ // :focus-within can't, now that the menu it opens is teleported to <body>.
147
+ const triggerClasses = (open: boolean) => [
148
+ $style.control,
149
+ $style.onHover,
150
+ { [$style.isOpen]: open },
151
+ ]
152
+ const subButtonClasses = (sub: SidebarGroupSubItem) => [
153
+ $style.subButton,
154
+ { [$style.isActive]: sub.active },
155
+ ]
156
+ const chevronClasses = (item: SidebarGroupItem) => [
157
+ $style.chevron,
158
+ { [$style.isExpanded]: item.expanded },
159
+ ]
160
+ const nestedClasses = (item: SidebarGroupItem) => [
161
+ $style.sub,
162
+ { [$style.isExpanded]: item.expanded },
163
+ ]
164
+ </script>
165
+
166
+ <style module lang="scss">
167
+ @use '@surstromming/design' as design;
168
+ @use 'css/sidebar-group-menu';
169
+ @use 'css/sidebar-group-sub';
170
+
171
+ .root {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: design.spacing(1);
175
+ padding: design.spacing(2);
176
+ }
177
+
178
+ .label {
179
+ display: flex;
180
+ align-items: center;
181
+ height: design.spacing(8);
182
+ padding: 0 design.spacing(2);
183
+ color: design.with-alpha(sidebar-foreground, 70%);
184
+ font-size: 0.75rem;
185
+ font-weight: 500;
186
+ }
187
+
188
+ @media (prefers-reduced-motion: reduce) {
189
+ .sub,
190
+ .chevron,
191
+ .onHover {
192
+ transition: none;
193
+ }
194
+ }
195
+ </style>
@@ -0,0 +1,119 @@
1
+ @use '@surstromming/design' as design;
2
+
3
+ .menu {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: design.spacing(1);
7
+ padding: 0; // the reset zeroes margins, not the UA's list indent
8
+ list-style: none;
9
+ }
10
+
11
+ // The trailing control (chevron / '…') is positioned against the row.
12
+ .item {
13
+ position: relative;
14
+ }
15
+
16
+ .button {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: design.spacing(2);
20
+ width: 100%;
21
+ height: design.spacing(8);
22
+ border-radius: design.radius(md);
23
+ background-color: transparent;
24
+ padding: 0 design.spacing(2);
25
+ color: inherit;
26
+ font-size: 0.875rem;
27
+ text-align: start;
28
+ text-decoration: none;
29
+ cursor: pointer;
30
+ outline: none;
31
+ transition:
32
+ background-color 0.15s ease,
33
+ color 0.15s ease,
34
+ box-shadow 0.15s ease;
35
+
36
+ &:hover {
37
+ background-color: design.color(sidebar-accent);
38
+ color: design.color(sidebar-accent-foreground);
39
+ }
40
+
41
+ &:focus-visible {
42
+ box-shadow: 0 0 0 2px design.color(sidebar-ring);
43
+ }
44
+
45
+ svg {
46
+ flex-shrink: 0;
47
+ color: design.color(muted-foreground);
48
+ }
49
+ }
50
+
51
+ // The current page: same paint as hover, held on.
52
+ .button.isActive {
53
+ background-color: design.color(sidebar-accent);
54
+ color: design.color(sidebar-accent-foreground);
55
+ font-weight: 500;
56
+ }
57
+
58
+ // Room for the trailing control, so a long label never runs under it.
59
+ .button.hasAction {
60
+ padding-inline-end: design.spacing(8);
61
+ }
62
+
63
+ .text {
64
+ flex: 1;
65
+ min-width: 0; // without it a flex item refuses to shrink below its text
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ }
70
+
71
+ .action {
72
+ position: absolute;
73
+ top: design.spacing(1);
74
+ right: design.spacing(1);
75
+ }
76
+
77
+ .control {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ width: design.spacing(6);
82
+ height: design.spacing(6);
83
+ border-radius: design.radius(sm);
84
+ background-color: transparent;
85
+ color: design.color(muted-foreground);
86
+ cursor: pointer;
87
+ outline: none;
88
+
89
+ &:hover {
90
+ background-color: design.color(sidebar-accent);
91
+ color: design.color(sidebar-accent-foreground);
92
+ }
93
+
94
+ &:focus-visible {
95
+ box-shadow: 0 0 0 2px design.color(sidebar-ring);
96
+ }
97
+ }
98
+
99
+ // The action menu stays out of the way until the row is hovered — but a
100
+ // keyboard user must still find it, and an open menu can't have its trigger
101
+ // fade out from under the pointer.
102
+ .onHover {
103
+ opacity: 0;
104
+ transition: opacity 0.15s ease;
105
+
106
+ .item:hover &,
107
+ &:focus-visible,
108
+ &.isOpen {
109
+ opacity: 1;
110
+ }
111
+ }
112
+
113
+ .chevron {
114
+ transition: transform 0.2s ease;
115
+ }
116
+
117
+ .chevron.isExpanded {
118
+ transform: rotate(90deg);
119
+ }
@@ -0,0 +1,61 @@
1
+ @use '@surstromming/design' as design;
2
+
3
+ // The row collapses to 0fr and expands to 1fr — the nested list keeps its
4
+ // natural height, so nothing has to be measured in JS.
5
+ .sub {
6
+ display: grid;
7
+ grid-template-rows: 0fr;
8
+ transition: grid-template-rows 0.2s ease;
9
+ }
10
+
11
+ .sub.isExpanded {
12
+ grid-template-rows: 1fr;
13
+ }
14
+
15
+ .subInner {
16
+ overflow: hidden; // required: a grid item won't shrink below its content without it
17
+ }
18
+
19
+ .subList {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: design.spacing(1);
23
+ margin: design.spacing(1) 0 design.spacing(1) design.spacing(3.5);
24
+ border-inline-start: 1px solid design.color(sidebar-border);
25
+ padding-inline-start: design.spacing(2.5);
26
+ list-style: none;
27
+ }
28
+
29
+ .subButton {
30
+ display: flex;
31
+ align-items: center;
32
+ width: 100%;
33
+ height: design.spacing(7);
34
+ border-radius: design.radius(md);
35
+ background-color: transparent;
36
+ padding: 0 design.spacing(2);
37
+ color: design.with-alpha(sidebar-foreground, 70%);
38
+ font-size: 0.875rem;
39
+ text-align: start;
40
+ text-decoration: none;
41
+ cursor: pointer;
42
+ outline: none;
43
+ transition:
44
+ background-color 0.15s ease,
45
+ color 0.15s ease,
46
+ box-shadow 0.15s ease;
47
+
48
+ &:hover {
49
+ background-color: design.color(sidebar-accent);
50
+ color: design.color(sidebar-accent-foreground);
51
+ }
52
+
53
+ &:focus-visible {
54
+ box-shadow: 0 0 0 2px design.color(sidebar-ring);
55
+ }
56
+ }
57
+
58
+ .subButton.isActive {
59
+ background-color: design.color(sidebar-accent);
60
+ color: design.color(sidebar-accent-foreground);
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { DropdownMenuItem } from '@surstromming/dropdown-menu'
2
+ import type { IconNode } from '@surstromming/icon'
3
+
4
+ /**
5
+ * Which side the *sidebar* is on — mirror the `side` given to `<Sidebar>`.
6
+ * An item's action menu opens on the opposite side, towards the content.
7
+ */
8
+ export type SidebarGroupSide = 'left' | 'right'
9
+
10
+ export interface SidebarGroupSubItem {
11
+ label: string
12
+ value: string
13
+ /** Renders the row as a real <a>: middle-click and Cmd+click open a tab. */
14
+ href?: string
15
+ active?: boolean
16
+ }
17
+
18
+ /**
19
+ * Nested nav and an action menu both hang off the row's trailing control, so
20
+ * they can't coexist — the data names which one it is.
21
+ */
22
+ export type SidebarGroupSubmenu =
23
+ | { type: 'items'; entries: SidebarGroupSubItem[] }
24
+ | { type: 'menu'; entries: DropdownMenuItem[] }
25
+
26
+ export interface SidebarGroupItem extends SidebarGroupSubItem {
27
+ icon?: IconNode
28
+ submenu?: SidebarGroupSubmenu
29
+ /**
30
+ * Opens the nested list (`submenu.type: 'items'`; ignored otherwise). The
31
+ * chevron doesn't write it — it emits `toggle`, and the app flips the flag.
32
+ */
33
+ expanded?: boolean
34
+ }
35
+
36
+ export { default as SidebarGroup } from './SidebarGroup.vue'