npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@surstromming/sidebar-group

v0.1.6

Published

A labelled group of sidebar navigation, rendered from data.

Readme

@surstromming/sidebar-group

A labelled section of sidebar navigation, rendered from data. One group is a title plus a menu; each menu item is an icon, a label, and — optionally — a submenu: either nested nav links that collapse in place, or a dropdown of actions for that item.

Drop groups into a <Sidebar>; the group knows nothing about the sidebar's open/closed state.

Dependency graph

graph LR
  sidebar_group["@surstromming/sidebar-group"]
  design["@surstromming/design"]
  dropdown_menu["@surstromming/dropdown-menu"]
  icon["@surstromming/icon"]
  popover["@surstromming/popover"]
  scroll_area["@surstromming/scroll-area"]
  util["@surstromming/util"]
  sidebar_group --> design
  sidebar_group --> dropdown_menu
  sidebar_group --> icon
  sidebar_group --> popover
  dropdown_menu --> design
  dropdown_menu --> icon
  dropdown_menu --> popover
  popover --> design
  popover --> scroll_area
  popover --> util
  scroll_area --> design
  scroll_area --> util

Usage

<template>
  <Sidebar v-model:open="sidebar.open">
    <SidebarGroup label="Platform" :items="platform" @select="go" @toggle="toggleSection" />
    <SidebarGroup label="Projects" :items="projects" @select="go" @action="onAction" />
  </Sidebar>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { Sidebar } from '@surstromming/sidebar'
import { SidebarGroup } from '@surstromming/sidebar-group'
import type { SidebarGroupItem } from '@surstromming/sidebar-group'
import { Bot, Frame, SquareTerminal, Folder, Trash2 } from 'lucide'
import { useRoute, useRouter } from 'vue-router'

const router = useRouter()
const route = useRoute()
const go = (value: string) => router.push(value)
const onAction = (item: string, action: string) => console.log(item, action)

// The app owns both `active` (derived from the route) and `expanded`.
const openSection = ref<string | null>('/playground')
const toggleSection = (value: string) => {
  openSection.value = openSection.value === value ? null : value
}

// Nested nav: submenu.type 'items'
const platform = computed<SidebarGroupItem[]>(() => [
  {
    label: 'Playground',
    value: '/playground',
    icon: SquareTerminal,
    active: route.path.startsWith('/playground'),
    expanded: openSection.value === '/playground',
    submenu: {
      type: 'items',
      entries: [
        { label: 'History', value: '/playground/history' },
        { label: 'Starred', value: '/playground/starred', active: route.path === '/playground/starred' },
      ],
    },
  },
  { label: 'Models', value: '/models', icon: Bot },
])

// Per-item action menu: submenu.type 'menu'
const projects: SidebarGroupItem[] = [
  {
    label: 'Design Engineering',
    value: '/projects/design',
    icon: Frame,
    submenu: {
      type: 'menu',
      entries: [
        { label: 'View project', value: 'view', icon: Folder },
        { separator: true },
        { label: 'Delete', value: 'delete', icon: Trash2, destructive: true },
      ],
    },
  },
]
</script>

Props

| Prop | Type | Default | Notes | | ---------------- | ------------------- | ------- | ------------------------------------------------------- | | label | string | — | Group title. Omit for an unlabelled group. | | items | SidebarGroupItem[] | — | Required. | | side | left \| right | left | Where the sidebar is; the action menu opens opposite. | | layer | popover \| menu \| modal | menu | Stacking for the (teleported) action menu. |

Item

| Field | Type | Notes | | --------- | -------------------- | ------------------------------------------------------------ | | label | string | Visible text. | | value | string | Identity; what select emits. A route path reads well. | | href | string | Renders the row as a real <a> — see below. Absent → <button>. | | icon | IconNode | A lucide icon (import { Bot } from 'lucide'). | | active | boolean | Paints the current page's row. The app owns "current". | | expanded | boolean | Opens the nested list. The app owns it — see below. | | submenu | SidebarGroupSubmenu | See below. Absent → a plain row. |

Rows, links, and clicks

A row with href is an <a href>; without one it's a <button>. The element is picked by a computed returning { is, props }, so the row's children are written once.

href exists for the behaviors only a genuine link has: middle-click and Cmd/Ctrl+click open a new tab, the context menu offers "Open in new tab", the browser previews the URL on hover. Faking those on a <button> with auxclick

  • window.open gets one of them right and lies about the rest, so it isn't done.

Plain left clicks are still intercepted (preventDefault) and emitted as select, leaving navigation to the app's router; modified clicks fall through to the browser untouched, and middle-click never reaches the handler at all (it fires auxclick, not click). This is RouterLink's contract. The consequence to know: an item with href whose select nobody handles does nothing on a plain click — the href only carries modified clicks.

Submenu

Nested links and an action menu both hang off an item's trailing control, so they can't coexist — the data names which one it is:

export type SidebarGroupSubmenu =
  | { type: 'items'; entries: SidebarGroupSubItem[] } // collapsible nested nav
  | { type: 'menu'; entries: DropdownMenuItem[] }     // '…' dropdown of actions

type: 'items' renders a chevron that expands entries beneath the row ({ label, value, active? } — same select emit as a top-level item). type: 'menu' renders a trigger — appearing on hover or keyboard focus — that opens a DropdownMenu built from entries (@surstromming/dropdown-menu's own item type, separators and destructive included). The menu teleports to <body> and opens on the side away from the sidebar (side flips it), so a sidebar's overflow never clips it.

Emits

| Event | Payload | Fired when | | -------- | -------------------------------- | --------------------------------------------------- | | select | value: string | An item or sub-item row is plain-clicked. | | action | item: string, action: string | A dropdown entry is chosen; item is whose menu. | | toggle | value: string | The chevron is clicked; flip that item's expanded. |

The component emits and the app decides (router.push, a store call, anything) — the package never navigates and never imports a router.

Fallthrough

Single root (<div>), default inheritAttrsclass, aria-* and listeners land on the group. The action menu is teleported to <body>, so it's stacked by layer (default menu) — above the sidebar's own rung, since on mobile the drawer is teleported too and a lower menu would render behind it.

Anatomy

div.root                        the group
├── div.label                   `label` (omitted when unset)
└── ul.menu
    └── li.item
        ├── a|button .button    icon + label (`href` picks) → select
        ├── button.action       chevron, type 'items'       → toggle
        ├── DropdownMenu        '…', type 'menu'            → action
        └── div.sub             collapse wrapper (0fr → 1fr)
            └── ul > li > button.subButton                  → select

Nested rows collapse with grid-template-rows: 0fr → 1fr plus overflow: hidden (Accordion's trick) — real height, no JS measuring.

Expanded state

expanded is item data, like active — the component is fully controlled and holds no open/closed state of its own. The chevron emits toggle(value); the app flips the flag (usually in the same computed that derives active). Nothing auto-opens from active either — that's the app's call too.

Policy lives where the state does. One-section-at-a-time isn't a prop, it's the app holding a single value:

const openSection = ref<string | null>('playground')
const toggleSection = (value: string) => {
  openSection.value = openSection.value === value ? null : value
}
// in the items computed:  expanded: openSection.value === 'playground'

Several-at-once is the same handler over a string[] instead.

Tokens

sidebar-foreground (label at 70% via with-alpha, rows at full), sidebar-accent / sidebar-accent-foreground (hover and the active row), sidebar-border (the nested list's leading rule), sidebar-ring (focus). Spacing and radii come from design.spacing() / design.radius().

Accessibility

The menu is a <ul> of <li>s; every row is a real <button> or <a href>, so Tab, Enter and the link affordances are the browser's. The chevron carries aria-expanded and controls the nested list; an active row gets aria-current="page". The trigger is labelled aria-label="More".

Recipes

Flat group, no submenus — pass items without submenu (shadcn's nav-secondary).

Push a group to the bottomclass falls through: <SidebarGroup class="mt-auto" …>, or margin-block-start: auto in the app.