@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 +21 -0
- package/README.md +234 -0
- package/package.json +51 -0
- package/src/SidebarGroup.vue +195 -0
- package/src/css/sidebar-group-menu.scss +119 -0
- package/src/css/sidebar-group-sub.scss +61 -0
- package/src/index.ts +36 -0
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'
|