Navigation latérale
La navigation d'une barre latérale : un arbre de liens, montré sur place plutôt que dans un panneau flottant, dont les branches s'ouvrent et se referment. Il s'écrit niveau par niveau avec ses propres sous-composants, jamais décrit comme une liste de données.
Utilisation
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<VSideNavigationItem href="#usage" current>Overview</VSideNavigationItem>
<VSideNavigationItem href="#usage">Activity</VSideNavigationItem>
<VSideNavigationItem href="#usage">Settings</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Exemples
Liens et actions
href rend une ligne sous forme de vrai lien ; sans lui, la ligne est un bouton qui rapporte son activation par select. current marque la ligne où se trouve le lecteur et l'annonce comme la page courante. Une ligne qui porte des sous-éléments ignore href.
<script setup lang="ts">
import { ref } from 'vue'
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import {
arrow_right_alt as arrowRightAlt,
description,
info,
schedule,
table_chart as tableChart,
} from 'vectis-ui/icons'
const selected = ref<string | null>(null)
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<!-- With `href` the row is a real link: it can be middle-clicked, its address
copied, and a crawler follows it. `current` says which one is the page being
read, and that is what a screen reader announces as the current page. -->
<VSideNavigationItem href="#usage" :icon="description" current>Usage</VSideNavigationItem>
<VSideNavigationItem href="#api" :icon="info">API</VSideNavigationItem>
<VSideNavigationItem
href="https://vuejs.org"
:icon="arrowRightAlt"
target="_blank"
rel="noreferrer"
>
Vue documentation
</VSideNavigationItem>
<!-- Without `href` the row is a button instead, and reports its activation
through `select`. That is the shape for what switches a view rather than
for what leads to an address. -->
<VSideNavigationItem :icon="tableChart" @select="selected = 'Board'">
Board
</VSideNavigationItem>
<VSideNavigationItem :icon="schedule" @select="selected = 'Timeline'">
Timeline
</VSideNavigationItem>
</VSideNavigation>
<p class="log">{{ selected ? `Selected: ${selected}` : 'Nothing selected yet' }}</p>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.log {
margin: var(--vectis-space-3) var(--vectis-space-2) 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Sous-libellés
sublabel ajoute une seconde ligne sous le libellé, pour un compte, un état de synchronisation ou une taille. Le slot #sublabel prend la même place quand cette ligne demande du balisage.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { cloud_upload as cloudUpload, description, folder_zip as folderZip } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<!-- A second line under the label, for what the row does not already say: a
count, a status, a size. The row grows to hold both and the icon stays
centred on the pair rather than on the first line. -->
<VSideNavigationItem href="#usage" :icon="description" sublabel="12 open tasks">
Alpha
</VSideNavigationItem>
<VSideNavigationItem :icon="cloudUpload" default-open>
Storage
<!-- The slot takes markup where the prop takes a string. -->
<template #sublabel>Synced <strong>2 min</strong> ago</template>
<template #children>
<VSideNavigationItem href="#usage" :icon="folderZip" sublabel="4.2 GB">
Archives
</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Du contenu en fin de ligne
Le slot #end place un compteur, un badge ou un petit contrôle après le libellé et avant le chevron. Sur une ligne de branche, il se retrouve dans le résumé natif de la section : tenez-vous-en donc à du contenu non interactif.
<script setup lang="ts">
import { VBadge, VIconButton, VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, more_horiz as moreHoriz, notifications, search } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Project">
<!-- On a leaf the action is stretched over the row by an overlay, so whatever
sits at the end stays a sibling of it rather than inside it. A real control
belongs here and nowhere else. -->
<VSideNavigationItem href="#usage" :icon="description">
Drafts
<template #end>
<VIconButton
label="Draft options"
:icon="moreHoriz"
size="xs"
variant="ghost"
tone="neutral"
/>
</template>
</VSideNavigationItem>
<!-- A branch is a native disclosure and its row is the summary, which has to
hold the whole line. So the end of a branch row takes a counter or a badge,
never a control: a control there would be nested inside the one the row
already is. -->
<VSideNavigationItem :icon="notifications">
Notifications
<template #end><VBadge :count="12" tone="accent" /></template>
<template #children>
<VSideNavigationItem href="#usage">Mentions</VSideNavigationItem>
<VSideNavigationItem href="#usage">Replies</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="search">
Saved searches
<template #end><span class="count">8</span></template>
<template #children>
<VSideNavigationItem href="#usage">Open bugs</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.count {
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Groupes et séparateurs
VSideNavigationGroup nomme un bloc de lignes et VSideNavigationSeparator trace un filet entre deux d'entre elles. Un titre n'est pas une ligne : rien ne se passe au clic et les flèches ne s'y arrêtent jamais. Un groupe n'est pas non plus un niveau de la hiérarchie, ses éléments ne sont donc pas indentés.
<script setup lang="ts">
import {
VSideNavigation,
VSideNavigationGroup,
VSideNavigationItem,
VSideNavigationSeparator,
} from 'vectis-ui'
import { description, image, schedule, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<VSideNavigationItem href="#usage" :icon="tableChart" current>Overview</VSideNavigationItem>
<!-- A rule, for a break between two runs of rows. It says nothing and is
announced as nothing: real grouping is the group's job. -->
<VSideNavigationSeparator />
<!-- A named block. The heading is not a row: nothing happens when it is clicked
and the arrow keys never stop on it. It takes the height of a row all the
same, which is what keeps the vertical rhythm of the list. What it names is
the sublist under it, so a screen reader reads the section and its items as
one thing. -->
<VSideNavigationGroup label="Content">
<VSideNavigationItem href="#usage" :icon="description">Documents</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="image">Media</VSideNavigationItem>
</VSideNavigationGroup>
<!-- A group is not a level of the hierarchy: its items are indented as if it
were not there. -->
<VSideNavigationGroup>
<!-- The slot takes markup where the prop takes a string. One of the two is
needed, since this is what names the sublist. -->
<template #label>Recent <span class="hint">(7 days)</span></template>
<VSideNavigationItem href="#usage" :icon="schedule">Release notes</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="schedule">Meeting minutes</VSideNavigationItem>
</VSideNavigationGroup>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.hint {
color: var(--vectis-color-text-muted);
}
</style>Imbrication
Une ligne à laquelle on donne un slot #children devient une branche, et une branche peut porter ses propres branches aussi loin que va l'arbre. Chaque niveau est indenté exactement de la place que prend une icône de début.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { code, description, folder_zip as folderZip } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Repository">
<!-- Nesting is not limited. Each level is indented by the room a start icon
takes, so a subitem's label lands on the same vertical as the label of the
branch holding it. -->
<VSideNavigationItem :icon="folderZip" default-open>
src
<template #children>
<VSideNavigationItem :icon="folderZip" default-open>
components
<template #children>
<VSideNavigationItem href="#usage" :icon="code">VButton.vue</VSideNavigationItem>
<VSideNavigationItem :icon="folderZip" default-open>
icons
<template #children>
<VSideNavigationItem href="#usage" :icon="code">index.ts</VSideNavigationItem>
</template>
</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="code">index.ts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="description">README.md</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Le chevron des sections
expandIcon est le glyphe d'une branche fermée, pivoté de 180° à l'ouverture de la section. Nommer aussi collapseIcon échange plutôt un dessin pour l'autre. Les deux se posent sur la navigation entière.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import {
arrow_drop_down as arrowDropDown,
chevron_right as chevronRight,
expand_more as expandMore,
} from 'vectis-ui/icons'
</script>
<template>
<div class="row">
<aside class="sidebar">
<p class="caption">One icon, rotated</p>
<!-- Given only the closed chevron, the open one is that same drawing turned by
180 degrees. It is the right shape for a glyph that reads both ways up. -->
<VSideNavigation label="Rotated chevron" :expand-icon="arrowDropDown">
<VSideNavigationItem default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">Two icons, swapped</p>
<!-- Naming both swaps one drawing for the other instead, which is what a file
tree wants: a chevron pointing along the reading direction when the branch
is closed, and down when it is open. -->
<VSideNavigation
label="Swapped chevrons"
:expand-icon="chevronRight"
:collapse-icon="expandMore"
>
<VSideNavigationItem default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</div>
</template>
<style scoped>
.row {
display: flex;
gap: var(--vectis-space-5);
}
.sidebar {
inline-size: 15rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.caption {
margin: 0 var(--vectis-space-2) var(--vectis-space-2);
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Une section à la fois
exclusive ne garde qu'une section ouverte à la fois au sein de chaque niveau. Il est désactivé par défaut, à l'inverse de VAccordion, dont les sections se referment l'une l'autre sauf si multiple est posé.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<!-- Opening one section closes the one beside it. The browser does this on its
own, through the name the sections of a level share, so there is no state to
keep. The exclusivity is local to each level: opening a subsection leaves its
parent's neighbours alone. -->
<VSideNavigation label="Workspace" exclusive>
<VSideNavigationItem :icon="description" default-open>
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
<VSideNavigationItem href="#usage">Published</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="image">
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
<VSideNavigationItem href="#usage">Video</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem :icon="tableChart">
Reports
<template #children>
<VSideNavigationItem href="#usage">Weekly</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Savoir si une section est ouverte
defaultOpen décide de l'état initial d'une branche puis le confie au navigateur. v-model:open rapporte au contraire chaque pliage, et ouvre ou ferme la branche quand on y écrit.
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image } from 'vectis-ui/icons'
// The model is typed `boolean | null`, `null` being how the component writes "nobody is
// driving this branch".
const documentsOpen = ref<boolean | null>(true)
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<!-- Bound, the branch reports every fold back to the model, so a click on the
row is enough to keep it in step, and writing to the model opens or closes
the branch from code. -->
<VSideNavigationItem v-model:open="documentsOpen" :icon="description">
Documents
<template #children>
<VSideNavigationItem href="#usage">Drafts</VSideNavigationItem>
<VSideNavigationItem href="#usage">Published</VSideNavigationItem>
</template>
</VSideNavigationItem>
<!-- Left unbound, the browser keeps that state to itself and `defaultOpen`
gives only the value it starts on. -->
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
<div class="controls">
<p class="state">Documents is {{ documentsOpen ? 'open' : 'closed' }}</p>
<VButton size="sm" variant="outline" tone="neutral" @click="documentsOpen = !documentsOpen">
Toggle from code
</VButton>
</div>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.controls {
display: flex;
flex-direction: column;
align-items: start;
gap: var(--vectis-space-2);
margin: var(--vectis-space-4) var(--vectis-space-2) 0;
}
.state {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>Lignes désactivées
disabled grise une ligne par les tokens de couleur et la sort du parcours clavier. Sur une branche, la section ne se déplie plus du tout.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<aside class="sidebar">
<VSideNavigation label="Workspace">
<VSideNavigationItem href="#usage" :icon="description" current>Documents</VSideNavigationItem>
<!-- A disabled link stops leading anywhere and the arrow keys step over it. It
greys out through the colour tokens rather than through an opacity, so it
keeps its contrast against the sidebar. -->
<VSideNavigationItem href="#usage" :icon="image" disabled>Media</VSideNavigationItem>
<!-- On a branch it goes further: the section can no longer be unfolded, so what
it holds is out of reach for good. -->
<VSideNavigationItem :icon="tableChart" disabled>
Reports
<template #children>
<VSideNavigationItem href="#usage">Weekly</VSideNavigationItem>
</template>
</VSideNavigationItem>
</VSideNavigation>
</aside>
</template>
<style scoped>
.sidebar {
inline-size: 16rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
</style>Tailles
size définit la hauteur des lignes à 32 ou 40 pixels, et compact lui retire 4px. Elle se pose une fois sur la navigation, et chaque niveau la lit de là.
<script setup lang="ts">
import { VSideNavigation, VSideNavigationItem } from 'vectis-ui'
import { description, image, table_chart as tableChart } from 'vectis-ui/icons'
</script>
<template>
<div class="row">
<!-- The size is set once on the navigation and every level follows, however deep:
a sublist never restates it. `compact` takes 4px off each row on top of that.
It is a density setting, not a rail folded down to its icons. -->
<aside class="sidebar">
<p class="caption">sm</p>
<VSideNavigation label="Rows of 32px" size="sm">
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">md</p>
<VSideNavigation label="Rows of 40px" size="md">
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
<aside class="sidebar">
<p class="caption">md compact</p>
<VSideNavigation label="Rows of 36px" size="md" compact>
<VSideNavigationItem href="#usage" :icon="description" current
>Documents</VSideNavigationItem
>
<VSideNavigationItem :icon="image" default-open>
Media
<template #children>
<VSideNavigationItem href="#usage">Images</VSideNavigationItem>
</template>
</VSideNavigationItem>
<VSideNavigationItem href="#usage" :icon="tableChart">Reports</VSideNavigationItem>
</VSideNavigation>
</aside>
</div>
</template>
<style scoped>
.row {
display: flex;
flex-wrap: wrap;
gap: var(--vectis-space-4);
}
.sidebar {
inline-size: 12rem;
padding: var(--vectis-space-2);
border-inline-end: 1px solid var(--vectis-color-border);
}
.caption {
margin: 0 var(--vectis-space-2) var(--vectis-space-2);
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>API
Props
Événements
Slots
Types
Les types que les tables ci-dessus nomment, écrits comme la librairie les déclare. Ceux qui portent export s'importent depuis vectis-ui pour typer votre propre code ; les autres décrivent la forme de ce qu'un slot fournit.
export interface BuiltinIcon {
name: string
paths: readonly [string] | readonly [string, string]
}export type IconRender =
| { path: string; viewBox?: string }
| { component: Component; props?: Record<string, unknown> }
| { src: string }
| { text: string; class?: string }
| { class: string }export type IconSource = string | BuiltinIcon | IconRender