From 4b471472a388a5d26d9c05556a1f494d86823fc1 Mon Sep 17 00:00:00 2001 From: Vincent GS Date: Mon, 10 Aug 2026 21:38:19 -0600 Subject: [PATCH] :sparkles: feature: created v-nav-rail according to M3 standards --- .../api-generator/src/locale/en/VNavRail.json | 17 ++ .../src/locale/en/VNavRailItem.json | 10 ++ packages/docs/src/data/page-to-api.json | 1 + .../src/components/VNavRail/VNavRail.sass | 42 +++++ .../src/components/VNavRail/VNavRail.tsx | 154 ++++++++++++++++++ .../src/components/VNavRail/VNavRailItem.sass | 37 +++++ .../src/components/VNavRail/VNavRailItem.tsx | 93 +++++++++++ .../src/components/VNavRail/_variables.scss | 27 +++ .../vuetify/src/components/VNavRail/index.ts | 2 + packages/vuetify/src/components/index.ts | 1 + 10 files changed, 384 insertions(+) create mode 100644 packages/api-generator/src/locale/en/VNavRail.json create mode 100644 packages/api-generator/src/locale/en/VNavRailItem.json create mode 100644 packages/vuetify/src/components/VNavRail/VNavRail.sass create mode 100644 packages/vuetify/src/components/VNavRail/VNavRail.tsx create mode 100644 packages/vuetify/src/components/VNavRail/VNavRailItem.sass create mode 100644 packages/vuetify/src/components/VNavRail/VNavRailItem.tsx create mode 100644 packages/vuetify/src/components/VNavRail/_variables.scss create mode 100644 packages/vuetify/src/components/VNavRail/index.ts diff --git a/packages/api-generator/src/locale/en/VNavRail.json b/packages/api-generator/src/locale/en/VNavRail.json new file mode 100644 index 00000000000..83057121f81 --- /dev/null +++ b/packages/api-generator/src/locale/en/VNavRail.json @@ -0,0 +1,17 @@ +{ + "description": "A vertical navigation rail, typically anchored to the start edge of the application, for switching between top-level destinations.", + "props": { + "location": "Controls the edge of the screen the rail is attached to.", + "width": "Sets the width of the rail.", + "align": "Aligns the rail items along the main (vertical) axis.", + "active": "Controls whether the rail is shown and reserves layout space for it." + }, + "slots": { + "default": "The default slot for `v-nav-rail-item` destinations.", + "prepend": "A slot at the top of the rail, typically for a menu button or FAB.", + "append": "A slot at the bottom of the rail, typically for settings or account actions." + }, + "events": { + "update:active": "Event that is emitted when the active state changes." + } +} diff --git a/packages/api-generator/src/locale/en/VNavRailItem.json b/packages/api-generator/src/locale/en/VNavRailItem.json new file mode 100644 index 00000000000..7846eef6e23 --- /dev/null +++ b/packages/api-generator/src/locale/en/VNavRailItem.json @@ -0,0 +1,10 @@ +{ + "description": "A single destination within a [v-nav-rail](/components/nav-rails), rendering an icon button with a label below it.", + "props": { + "title": "The label shown beneath the icon.", + "icon": "The icon shown for the inactive (and default) state.", + "activeIcon": "The icon shown when the item is selected. Falls back to `icon` when not provided.", + "divider": "Renders a horizontal divider after the item.", + "verticalDivider": "Renders a vertical divider after the item." + } +} diff --git a/packages/docs/src/data/page-to-api.json b/packages/docs/src/data/page-to-api.json index 0ee80be7e68..dcf3c80c704 100644 --- a/packages/docs/src/data/page-to-api.json +++ b/packages/docs/src/data/page-to-api.json @@ -124,6 +124,7 @@ "components/mask-inputs": ["VMaskInput"], "components/menus": ["VMenu"], "components/navigation-drawers": ["VNavigationDrawer"], + "components/nav-rails": ["VNavRail", "VNavRailItem"], "components/no-ssr": ["VNoSsr"], "components/month-pickers": ["VMonthPicker"], "components/number-inputs": ["VNumberInput"], diff --git a/packages/vuetify/src/components/VNavRail/VNavRail.sass b/packages/vuetify/src/components/VNavRail/VNavRail.sass new file mode 100644 index 00000000000..a8a1626f091 --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/VNavRail.sass @@ -0,0 +1,42 @@ +@use '../../styles/tools' +@use './variables' as * + +@include tools.layer('components') + .v-nav-rail + display: flex + flex-direction: column + max-height: 100% + overflow-x: hidden + overflow-y: auto + padding-block: $nav-rail-padding + position: absolute + transition: $nav-rail-transition + + @include tools.border($nav-rail-border...) + @include tools.rounded($nav-rail-border-radius) + @include tools.theme($nav-rail-theme...) + + &--active + @include tools.elevation($nav-rail-elevation) + + .v-nav-rail__prepend + flex: none + + .v-nav-rail__append + flex: none + margin-block-start: auto + + .v-nav-rail__content + display: flex + flex: 1 1 auto + flex-direction: column + width: 100% + + .v-nav-rail--align-start & + justify-content: flex-start + + .v-nav-rail--align-center & + justify-content: center + + .v-nav-rail--align-end & + justify-content: flex-end diff --git a/packages/vuetify/src/components/VNavRail/VNavRail.tsx b/packages/vuetify/src/components/VNavRail/VNavRail.tsx new file mode 100644 index 00000000000..fc780197cbf --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/VNavRail.tsx @@ -0,0 +1,154 @@ +// Styles +import './VNavRail.sass' + +// Composables +import { makeBorderProps, useBorder } from '@/composables/border' +import { useBackgroundColor } from '@/composables/color' +import { makeComponentProps } from '@/composables/component' +import { provideDefaults } from '@/composables/defaults' +import { makeElevationProps, useElevation } from '@/composables/elevation' +import { makeLayoutItemProps, useLayoutItem } from '@/composables/layout' +import { useRtl } from '@/composables/locale' +import { useProxiedModel } from '@/composables/proxiedModel' +import { makeRoundedProps, useRounded } from '@/composables/rounded' +import { useSsrBoot } from '@/composables/ssrBoot' +import { makeTagProps } from '@/composables/tag' +import { makeThemeProps, useTheme } from '@/composables/theme' + +// Utilities +import { computed, toRef } from 'vue' +import { convertToUnit, genericComponent, propsFactory, toPhysical, useRender } from '@/util' + +// Types +import type { PropType } from 'vue' + +const locations = ['start', 'end', 'left', 'right'] as const + +export type VNavRailSlots = { + default: never + prepend: never + append: never +} + +export const makeVNavRailProps = propsFactory({ + color: String, + bgColor: String, + location: { + type: String as PropType, + default: 'start', + validator: (value: any) => locations.includes(value), + }, + width: { + type: [Number, String], + default: 80, + }, + align: { + type: String as PropType<'start' | 'center' | 'end'>, + default: 'center', + }, + active: { + type: Boolean, + default: true, + }, + + ...makeBorderProps(), + ...makeComponentProps(), + ...makeElevationProps(), + ...makeRoundedProps(), + ...makeLayoutItemProps({ name: 'nav-rail' }), + ...makeTagProps({ tag: 'nav' }), + ...makeThemeProps(), +}, 'VNavRail') + +export const VNavRail = genericComponent()({ + name: 'VNavRail', + + props: makeVNavRailProps(), + + emits: { + 'update:active': (value: boolean) => true, + }, + + setup (props, { slots }) { + const { isRtl } = useRtl() + const { themeClasses } = useTheme() + const { borderClasses } = useBorder(props) + const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(() => props.bgColor) + const { elevationClasses } = useElevation(props) + const { roundedClasses, roundedStyles } = useRounded(props) + const { ssrBootStyles } = useSsrBoot() + + const location = toRef(() => toPhysical(props.location, isRtl.value) as 'left' | 'right') + const width = computed(() => Number(props.width)) + const isActive = useProxiedModel(props, 'active', props.active) + + const { layoutItemStyles } = useLayoutItem({ + id: props.name, + order: computed(() => parseInt(props.order, 10)), + position: location, + layoutSize: toRef(() => isActive.value ? width.value : 0), + elementSize: width, + active: isActive, + absolute: toRef(() => props.absolute), + }) + + provideDefaults({ + VNavRailItem: { + color: toRef(() => props.color), + }, + }) + + useRender(() => { + return ( + + { slots.prepend && ( +
+ { slots.prepend() } +
+ )} + + { slots.default && ( +
+ { slots.default() } +
+ )} + + { slots.append && ( +
+ { slots.append() } +
+ )} +
+ ) + }) + + return {} + }, +}) + +export type VNavRail = InstanceType diff --git a/packages/vuetify/src/components/VNavRail/VNavRailItem.sass b/packages/vuetify/src/components/VNavRail/VNavRailItem.sass new file mode 100644 index 00000000000..f6ca86bb693 --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/VNavRailItem.sass @@ -0,0 +1,37 @@ +@use '../../styles/tools' + +// M3 navigation rail item: https://m3.material.io/components/navigation-rail +@include tools.layer('components') + .v-nav-rail-item + align-items: center + display: flex + flex-direction: column + gap: 4px + justify-content: center + margin-block: 0.35em + user-select: none + + // M3 active indicator: 56 x 32 pill behind the icon. + // Size (width/height) and radius (rounded="16") come from the button's + // props as inline styles, which beat .v-btn--icon's square/circle rules. + // Active state uses the button's tonal variant, so the tinted background + // and its contextually-correct "on" color are derived from `color`. + // Inactive icon/label: medium-emphasis on-surface (readable, not disabled). + // NB: Vuetify's `on-surface-variant` is the "on" color for surface-variant + // chips (near-white in the light theme), not M3's muted body text. + .v-nav-rail-item:not(.v-nav-rail-item--active) .v-nav-rail-item__btn + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) + + .v-nav-rail-item__title + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) + font-size: 0.675rem + font-weight: 500 + text-align: center + + .v-nav-rail-item--active .v-nav-rail-item__title + color: rgb(var(--v-theme-on-surface)) + + .v-nav-rail-item--disabled + .v-nav-rail-item__btn, + .v-nav-rail-item__title + color: rgba(var(--v-theme-on-surface), 0.38) diff --git a/packages/vuetify/src/components/VNavRail/VNavRailItem.tsx b/packages/vuetify/src/components/VNavRail/VNavRailItem.tsx new file mode 100644 index 00000000000..30877859369 --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/VNavRailItem.tsx @@ -0,0 +1,93 @@ +// Styles +import './VNavRailItem.sass' + +// Components +import { VBtn } from '@/components/VBtn' +import { VDivider } from '@/components/VDivider' + +// Composables +import { makeComponentProps } from '@/composables/component' +import { makeRouterProps, useLink } from '@/composables/router' +import { makeVariantProps } from '@/composables/variant' + +// Utilities +import { computed } from 'vue' +import { genericComponent, propsFactory, useRender } from '@/util' + +export const makeVNavRailItemProps = propsFactory({ + title: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + // M3 selected icon (usually the filled variant); falls back to `icon` + activeIcon: String, + disabled: Boolean, + divider: Boolean, + verticalDivider: Boolean, + + ...makeComponentProps(), + ...makeRouterProps(), + ...makeVariantProps({ variant: 'tonal' } as const), +}, 'VNavRailItem') + +export const VNavRailItem = genericComponent()({ + name: 'VNavRailItem', + + props: makeVNavRailItemProps(), + + setup (props, { attrs }) { + const link = useLink(props, attrs) + + const selected = computed(() => !props.disabled && (link.isActive?.value ?? false)) + const displayIcon = computed(() => ( + selected.value && props.activeIcon ? props.activeIcon : props.icon + )) + + useRender(() => ( + <> + + + + + { props.title } + + + + { props.divider && } + { props.verticalDivider && } + + )) + + return {} + }, +}) + +export type VNavRailItem = InstanceType diff --git a/packages/vuetify/src/components/VNavRail/_variables.scss b/packages/vuetify/src/components/VNavRail/_variables.scss new file mode 100644 index 00000000000..fce8fdcab6c --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/_variables.scss @@ -0,0 +1,27 @@ +@use 'sass:map'; +@use '../../styles/settings'; +@use "../../styles/tools/functions"; + +// VNavRail +$nav-rail-background: rgb(var(--v-theme-surface)) !default; +$nav-rail-border-color: settings.$border-color-root !default; +$nav-rail-border-radius: map.get(settings.$rounded, '0') !default; +$nav-rail-border-style: settings.$border-style-root !default; +$nav-rail-border-thin-width: thin !default; +$nav-rail-border-width: 0 !default; +$nav-rail-color: functions.theme-color('on-surface', var(--v-high-emphasis-opacity)) !default; +$nav-rail-elevation: 2 !default; +$nav-rail-padding: 8px 1px !default; +$nav-rail-transition: transform, color, .2s, .1s settings.$standard-easing !default; + +$nav-rail-border: ( + $nav-rail-border-color, + $nav-rail-border-style, + $nav-rail-border-width, + $nav-rail-border-thin-width +) !default; + +$nav-rail-theme: ( + $nav-rail-background, + $nav-rail-color +) !default; diff --git a/packages/vuetify/src/components/VNavRail/index.ts b/packages/vuetify/src/components/VNavRail/index.ts new file mode 100644 index 00000000000..979cbfa9c3f --- /dev/null +++ b/packages/vuetify/src/components/VNavRail/index.ts @@ -0,0 +1,2 @@ +export { VNavRail } from './VNavRail' +export { VNavRailItem } from './VNavRailItem' diff --git a/packages/vuetify/src/components/index.ts b/packages/vuetify/src/components/index.ts index 3b1083e210a..2adeae01d40 100644 --- a/packages/vuetify/src/components/index.ts +++ b/packages/vuetify/src/components/index.ts @@ -57,6 +57,7 @@ export * from './VMain' export * from './VMenu' export * from './VMessages' export * from './VNavigationDrawer' +export * from './VNavRail' export * from './VNoSsr' export * from './VNumberInput' export * from './VOtpInput'