feat(package/ui): os-menu (#9431)

This commit is contained in:
Ulf Gebhardt 2026-03-23 22:32:50 +01:00 committed by GitHub
parent cadd0d0286
commit 75c1232860
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1549 additions and 638 deletions

View File

@ -1,7 +1,7 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('there is no button to pin a post', () => {
cy.get('a.ds-menu-item-link')
cy.get('a.os-menu-item-link')
.should('contain', 'Report Post') // sanity check
.should('not.contain', 'Pin post')
})

View File

@ -5,7 +5,7 @@ defineStep('I click on "Report Post" from the content menu of the post', () => {
.find('[data-test="content-menu-button"]')
.click()
cy.get('.popover .ds-menu-item-link')
cy.get('.popover .os-menu-item-link')
.contains('Report Post')
.click()
})

View File

@ -2,6 +2,6 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I {string} see {string} from the content menu in the user info box', (condition, link) => {
cy.get('.user-content-menu [data-test="content-menu-button"]').click()
cy.get('.popover .ds-menu-item-link')
cy.get('.popover .os-menu-item-link')
.should(condition === 'should' ? 'contain' : 'not.contain', link)
})

View File

@ -10,7 +10,7 @@ defineStep('I click on {string}', element => {
'comment button': 'button[type=submit]',
'reply button': '.reply-button',
'security menu': 'a[href="/settings/security"]',
'pin post': '.ds-menu-item:first-child',
'pin post': '.os-menu-item:first-child',
'Moderation': 'a[href="/moderation"]',
}

View File

@ -3,7 +3,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on {string} from the content menu in the user info box',
button => {
cy.get('.user-content-menu [data-test="content-menu-button"]').click()
cy.get('.popover .ds-menu-item-link')
cy.get('.popover .os-menu-item-link')
.contains(button)
.click({
force: true

View File

@ -6,5 +6,5 @@ defineStep('I navigate to my {string} settings page', settingsPage => {
.find('a[href]')
.contains('Settings')
.click()
cy.contains('.ds-menu-item-link', settingsPage).click()
cy.contains('.os-menu-item-link', settingsPage).click()
})

View File

@ -1,5 +1,8 @@
// eslint-disable-next-line import-x/no-unassigned-import
import '@fontsource-variable/inter'
// eslint-disable-next-line import-x/no-relative-parent-imports
import '../src/styles/index.css'
// eslint-disable-next-line import-x/no-unassigned-import
import './storybook.css'

View File

@ -15,7 +15,7 @@ Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, Os
Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅
Phase 4: Tier B ██████████ 100% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number→OsNumber✅, ds-radio→HTML✅)
Phase 4: Tier B ██████████ 100% (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML) ✅
Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form entkoppelt✅, ds-input→OcelotInput✅, ds-select→OcelotSelect✅) | Rest ausstehend (OsMenu, OsDropdown, OsAvatar)
Phase 4: Tier 2+ ██████████ 100% (OsModal✅, ds-form✅, OcelotInput✅, OcelotSelect✅, OsMenu/OsMenuItem✅) | 0 ds-* Tags verbleibend ✅
```
### Statistiken
@ -33,7 +33,7 @@ Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form
| ✅ ds-input → OcelotInput | Input (23 Dateien → OcelotInput Webapp-Komponente, lokale Imports, formValidation-kompatibel) |
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
| ✅ ds-select → OcelotSelect | Select (3 Dateien → OcelotSelect Webapp-Komponente, lokale Imports, click-outside inline) |
| ⬜ → UI-Library | Menu, MenuItem (2) — Tier 3 |
| ✅ → OsMenu/OsMenuItem | Menu, MenuItem (17 Nutzungen → packages/ui, dropdown Prop, eigene CSS) |
| ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
### OsButton Migration (Phase 3) ✅
@ -103,8 +103,8 @@ Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form
| 32 | List | ✅ → HTML | Tier A: `<ul class="ds-list">` |
| 33 | ListItem | ✅ → HTML | Tier A: `<li class="ds-list-item">` |
| 34 | Logo | ⬜ Nicht genutzt | Webapp nutzt eigenes Logo |
| 35 | Menu | ⬜ Tier 3 | 11 Dateien → OsMenu |
| 36 | MenuItem | ⬜ Tier 3 | 6 Dateien → OsMenuItem |
| 35 | Menu | ✅ UI-Library | 11 Dateien → OsMenu (packages/ui, dropdown Prop) |
| 36 | MenuItem | ✅ UI-Library | 6 Dateien → OsMenuItem (packages/ui) |
### Typography
| # | Komponente | Status | Notizen |
@ -372,6 +372,8 @@ Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form
- Modal → OsModal ✅
- Input → OcelotInput (Webapp-Komponente) ✅ — langfristig → OsInput in packages/ui
- Select → OcelotSelect (Webapp-Komponente) ✅ — langfristig → OsSelect in packages/ui
- Menu → OsMenu (packages/ui) ✅
- MenuItem → OsMenuItem (packages/ui) ✅
- Avatar → OsAvatar (falls benötigt)
### Layout & Typography — → Plain HTML ✅ (Tier A)
@ -414,6 +416,7 @@ Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form
| 2026-02-19 | Claude | **Katalog konsolidiert** | Styleguide- und Webapp-Tabellen aktualisiert, veraltete Status korrigiert |
| 2026-03-23 | Claude | **ds-input → OcelotInput** | 23 Dateien migriert, Webapp-Komponente mit lokalen Imports (tree-shakeable), FormItem/Label/Error vereint |
| 2026-03-23 | Claude | **ds-select → OcelotSelect** | 3 Dateien migriert, Webapp-Komponente, DsSelect+inputMixin+multiinputMixin vereint, Form-Kopplung entfernt, DsChip→OsBadge, DsSpinner→OsSpinner, click-outside inline |
| 2026-03-23 | Claude | **ds-menu → OsMenu/OsMenuItem** | packages/ui Komponenten mit h() Render, vue-demi, provide/inject, dropdown Prop, eigene CSS in index.css. 17 Nutzungen in 11 Dateien migriert. Vite-Build: ui.css in style.css integriert. Action-Menüs nutzen `<a>` statt router-link. 0 ds-* Tags verbleibend in Webapp. |
---
@ -451,12 +454,12 @@ Phase 4: Tier 2+ ████████░░ 70% (OsModal✅, ds-form
19. [x] OsModal (h() Render, Focus-Trap, Scroll-Lock, A11y; ConfirmModal + ReportModal nutzen OsModal; DeleteUserModal/DisableModal/ReleaseModal gelöscht) ✅
20. [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
21. [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, FormItem/Label/Error vereint, formValidation-kompatibel) ✅
22. [ ] OsMenu / OsMenuItem (17 Dateien)
22. [x] OsMenu / OsMenuItem (packages/ui, 17 Nutzungen in 11 Dateien, dropdown Prop, eigene CSS) ✅
23. [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente, click-outside inline, DsChip→OsBadge) ✅
---
**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B ✅ (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Radio→HTML, Table→HTML), Tier 2: OsModal ✅, ds-form entkoppelt ✅, ds-input → OcelotInput ✅, ds-select → OcelotSelect ✅, Rest ausstehend (OsMenu).**
**✅ Phase 0-3 abgeschlossen. Phase 4: Alle ds-* Komponenten migriert! Tier 1 ✅, Tier A ✅, Tier B ✅, Tier 2: OsModal ✅, OcelotInput ✅, OcelotSelect ✅, Tier 3: OsMenu/OsMenuItem ✅. 0 ds-* Tags in Webapp.**
---

View File

@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: ████████░░ 78% (21/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅, ds-radio→HTML ✅ | Tier B ✅, OcelotInput ✅, OcelotSelect ✅, Tier 2-3 Rest ausstehend
Phase 4: █████████░ 85% (23/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅, ds-radio→HTML ✅ | Tier B ✅, OcelotInput ✅, OcelotSelect ✅, OsMenu ✅ | 0 ds-* Tags verbleibend
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
───────────────────────────────────────
Gesamt: ████████░░ 86% (83/96 Aufgaben)
Gesamt: █████████░ 89% (85/96 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -288,8 +288,8 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
- [x] Test-Fix: Empty.spec.js `attributes().margin``classes().toContain('ds-my-xxx-small')`
- [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend
**Verbleibende ds-* Komponenten (6 Typen):**
- Tier C (→ UI-Library): ds-modal (7→✅ OsModal), ds-input (23→✅ OcelotInput), ds-select (3→✅ OcelotSelect), ds-menu/ds-menu-item (17)
**Verbleibende ds-* Komponenten: ✅ ALLE MIGRIERT (0 ds-* Tags in Webapp)**
- ✅ ds-modal (7→OsModal), ds-input (23→OcelotInput), ds-select (3→OcelotSelect), ds-menu/ds-menu-item (17→OsMenu/OsMenuItem)
- ✅ ds-form (18 Dateien) → formValidation Mixin (async-validator), vuelidate entfernt
**Zuvor abgeschlossen (Session 26 - CodeRabbit Review Fixes):**
@ -418,7 +418,7 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
- [ ] Weitere Tier 2 Komponenten (OsDropdown, OsAvatar)
- [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
- [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
- [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem
- [x] ds-menu / ds-menu-item → OsMenu / OsMenuItem (packages/ui, 17 Nutzungen in 11 Dateien, dropdown Prop, eigene CSS in index.css) ✅
- [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente mit lokalen Imports, click-outside inline) ✅
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
@ -697,8 +697,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
- [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
**Tier 3: Navigation (UI-Library)**
- [ ] OsMenu (Basis: DsMenu, 11 Dateien)
- [ ] OsMenuItem (Basis: DsMenuItem, 6 Dateien)
- [x] OsMenu + OsMenuItem (packages/ui, h() Render, vue-demi, provide/inject, dropdown Prop, eigene CSS in index.css, 17 Nutzungen in 11 Dateien) ✅
**Tier 4: Spezial-Komponenten**
- [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente, click-outside inline, DsChip→OsBadge, DsSpinner→OsSpinner) ✅
@ -1855,6 +1854,7 @@ Bei der Migration werden:
| 2026-03-23 | **ds-input → OcelotInput** | Neue Webapp-Komponente `OcelotInput.vue`: vereint DsInput + FormItem + InputLabel + InputError in einer Datei. 23 Vue-Dateien migriert mit lokalen Imports (tree-shakeable). formValidation Mixin voll kompatibel. dot-prop Abhängigkeit durch inline `getNestedValue()` ersetzt. 28 Test-Suites, 210 Tests ✅, 7 Snapshots aktualisiert. |
| 2026-03-23 | **OcelotInput: ds-icon → os-icon** | DsIcon durch OsIcon + resolveIcon() ersetzt. at.svg, envelope.svg, paperclip.svg zu Ocelot-Icons hinzugefügt. Ocelot-Icons Visual Snapshot aktualisiert. |
| 2026-03-23 | **ds-select → OcelotSelect** | Neue Webapp-Komponente `OcelotSelect.vue`: vereint DsSelect + inputMixin + multiinputMixin (~420 Zeilen). Form-Validation entfernt (von keinem Consumer genutzt). DsChip→OsBadge, DsSpinner→OsSpinner, DsIcon→OsIcon. vue-click-outside durch inline document.addEventListener ersetzt. 3 Dateien migriert, 16 Tests ✅. |
| 2026-03-23 | **ds-menu → OsMenu/OsMenuItem** | Neue packages/ui Komponenten: h() Render, vue-demi, provide/inject, dropdown Prop für Popup-Variante. CSS in src/styles/index.css (integriert in style.css Build). 17 Nutzungen in 11 Dateien migriert. Action-Menüs nutzen link-tag default 'a' statt router-link. router-link Stub global in testSetup.js. Vite closeBundle Hook: ui.css in style.css gemergt. 273 UI-Tests, 108 Webapp-Tests ✅. **0 ds-* Komponenten-Tags verbleibend in Webapp.** |
---
@ -1878,7 +1878,7 @@ Bei der Migration werden:
| ✅ ds-form entkoppelt | Form-Validierung → formValidation Mixin (async-validator), vuelidate entfernt |
| ✅ ds-input → OcelotInput | Webapp-Komponente (23 Dateien), lokale Imports, FormItem/InputLabel/InputError vereint |
| ✅ ds-select → OcelotSelect | Webapp-Komponente (3 Dateien), lokale Imports, click-outside inline, DsChip→OsBadge |
| ⬜ → UI-Library | Menu, MenuItem (2) — Tier 3 |
| ✅ → OsMenu/OsMenuItem | Menu, MenuItem (17 Nutzungen → packages/ui, dropdown Prop, eigene CSS) |
| ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
---

View File

@ -0,0 +1,244 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OsMenu from './OsMenu.vue'
import OsMenuItem from './OsMenuItem.vue'
const routes = [
{ name: 'Home', path: '/' },
{ name: 'Settings', path: '/settings' },
{ name: 'Profile', path: '/profile' },
]
const nestedRoutes = [
{ name: 'Home', path: '/' },
{
name: 'Posts',
path: '/posts',
children: [
{ name: 'All', path: '/posts/all' },
{ name: 'Drafts', path: '/posts/drafts' },
],
},
]
describe('osMenu', () => {
it('renders a nav element with os-menu class', () => {
const wrapper = mount(OsMenu, { props: { routes } })
expect(wrapper.find('nav.os-menu').exists()).toBe(true)
})
it('renders a ul.os-menu-list', () => {
const wrapper = mount(OsMenu, { props: { routes } })
expect(wrapper.find('ul.os-menu-list').exists()).toBe(true)
})
it('renders menu items for each route', () => {
const wrapper = mount(OsMenu, { props: { routes } })
const items = wrapper.findAll('.os-menu-item')
expect(items).toHaveLength(3)
})
it('uses default linkTag "a"', () => {
const wrapper = mount(OsMenu, { props: { routes } })
const links = wrapper.findAll('.os-menu-item-link')
expect(links[0].element.tagName).toBe('A')
})
it('passes custom linkTag to items', () => {
const wrapper = mount(OsMenu, {
props: { routes, linkTag: 'button' },
})
const links = wrapper.findAll('.os-menu-item-link')
expect(links[0].element.tagName).toBe('BUTTON')
})
it('applies custom nameParser', () => {
const wrapper = mount(OsMenu, {
props: {
routes: [{ name: 'test', path: '/test' }],
nameParser: () => 'Custom Name',
},
})
expect(wrapper.find('.os-menu-item-link').text()).toBe('Custom Name')
})
it('default urlParser falls back to "/" when route has no path', () => {
const wrapper = mount(OsMenu, {
props: { routes: [{ name: 'No Path' }] },
})
expect(wrapper.find('.os-menu-item-link').attributes('href')).toBe('/')
})
it('default nameParser falls back to "" when route has no name', () => {
const wrapper = mount(OsMenu, {
props: { routes: [{ path: '/test' }] },
})
expect(wrapper.find('.os-menu-item-link').text()).toBe('')
})
it('applies matcher for active state', () => {
const wrapper = mount(OsMenu, {
props: {
routes,
matcher: (_url: string, route: Record<string, unknown>) => route.path === '/settings',
},
})
const items = wrapper.findAll('.os-menu-item-link')
const settingsLink = items[1]
expect(settingsLink.classes()).toContain('os-menu-item--active')
})
it('renders menuitem scoped slot', () => {
const wrapper = mount(OsMenu, {
props: { routes },
slots: {
menuitem: `<template #menuitem="{ route, name }">
<li class="custom-item">{{ name }}</li>
</template>`,
},
})
expect(wrapper.findAll('.custom-item')).toHaveLength(3)
})
it('emits navigate on item click', async () => {
const wrapper = mount(OsMenu, { props: { routes } })
await wrapper.find('.os-menu-item-link').trigger('click')
expect(wrapper.emitted('navigate')?.length).toBeGreaterThan(0)
})
describe('keyboard accessibility', () => {
it('menu items are rendered as focusable links', () => {
const wrapper = mount(OsMenu, { props: { routes } })
const links = wrapper.findAll('.os-menu-item-link')
expect(links.length).toBeGreaterThan(0)
links.forEach((link) => {
expect(link.element.tagName).toBe('A')
})
})
})
})
describe('osMenuItem', () => {
const parentMenu = {
linkTag: 'a',
urlParser: (route: Record<string, unknown>) => (route.path as string) || '/',
nameParser: (route: Record<string, unknown>) => (route.name as string) || '',
matcher: () => false,
isExact: (url: string) => url === '/',
handleNavigate: () => {},
}
it('renders an li with os-menu-item class', () => {
const wrapper = mount(OsMenuItem, {
props: { route: routes[0] },
global: { provide: { $parentMenu: parentMenu } },
})
expect(wrapper.find('li.os-menu-item').exists()).toBe(true)
})
it('renders link with correct href', () => {
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'Test', path: '/test' } },
global: { provide: { $parentMenu: parentMenu } },
})
expect(wrapper.find('.os-menu-item-link').attributes('href')).toBe('/test')
})
it('displays route name', () => {
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'My Item', path: '/item' } },
global: { provide: { $parentMenu: parentMenu } },
})
expect(wrapper.find('.os-menu-item-link').text()).toBe('My Item')
})
it('applies level class based on parents', () => {
const wrapper = mount(OsMenuItem, {
props: {
route: { name: 'Child', path: '/child' },
parents: [{ name: 'Parent', path: '/parent' }],
},
global: { provide: { $parentMenu: parentMenu } },
})
expect(wrapper.find('.os-menu-item').classes()).toContain('os-menu-item-level-1')
})
it('emits click with route on click', async () => {
const route = { name: 'Test', path: '/test' }
const wrapper = mount(OsMenuItem, {
props: { route },
global: { provide: { $parentMenu: parentMenu } },
})
await wrapper.find('.os-menu-item-link').trigger('click')
const clicks = wrapper.emitted('click')
expect(clicks?.length).toBeGreaterThan(0)
expect(clicks?.[0]?.[1]).toStrictEqual(route)
})
it('renders submenu for routes with children', () => {
const wrapper = mount(OsMenuItem, {
props: { route: nestedRoutes[1] },
global: { provide: { $parentMenu: parentMenu } },
})
expect(wrapper.find('.os-menu-item-submenu').exists()).toBe(true)
expect(wrapper.findAll('.os-menu-item-submenu .os-menu-item')).toHaveLength(2)
})
it('renders default slot content', () => {
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'Test', path: '/test' } },
global: { provide: { $parentMenu: parentMenu } },
slots: { default: '<strong>Custom</strong>' },
})
expect(wrapper.find('.os-menu-item-link strong').text()).toBe('Custom')
})
it('adds active class when matcher returns true', () => {
const activeMenu = {
...parentMenu,
matcher: () => true,
}
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'Test', path: '/test' } },
global: { provide: { $parentMenu: activeMenu } },
})
expect(wrapper.find('.os-menu-item-link').classes()).toContain('os-menu-item--active')
})
describe('keyboard accessibility', () => {
it('menu item links are focusable', () => {
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'Test', path: '/test' } },
global: { provide: { $parentMenu: parentMenu } },
})
const link = wrapper.find('.os-menu-item-link')
expect(link.element.tagName).toBe('A')
})
})
})

View File

@ -0,0 +1,134 @@
import { computed } from 'vue'
import OsMenu from './OsMenu.vue'
import OsMenuItem from './OsMenuItem.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const sidebarRoutes = [
{ name: 'Dashboard', path: '/dashboard' },
{ name: 'Settings', path: '/settings' },
{ name: 'Profile', path: '/profile' },
{ name: 'Notifications', path: '/notifications' },
]
const nestedRoutes = [
{ name: 'Home', path: '/' },
{
name: 'Posts',
path: '/posts',
children: [
{ name: 'All Posts', path: '/posts/all' },
{ name: 'Drafts', path: '/posts/drafts' },
],
},
{ name: 'Users', path: '/users' },
]
const meta: Meta<typeof OsMenu> = {
title: 'Components/OsMenu',
component: OsMenu,
tags: ['autodocs'],
}
export default meta
export const Sidebar: StoryObj = {
render: () => ({
components: { OsMenu },
setup() {
const routes = computed(() => sidebarRoutes)
return { routes }
},
template: `
<div style="width: 200px">
<OsMenu :routes="routes" />
</div>
`,
}),
}
export const SidebarExactMatch: StoryObj = {
render: () => ({
components: { OsMenu },
setup() {
const routes = computed(() => sidebarRoutes)
const matcher = (_url: string, route: Record<string, unknown>) => route.path === '/settings'
return { routes, matcher }
},
template: `
<div style="width: 200px">
<OsMenu :routes="routes" :matcher="matcher" />
</div>
`,
}),
}
export const NestedRoutes: StoryObj = {
render: () => ({
components: { OsMenu },
setup() {
const routes = computed(() => nestedRoutes)
return { routes }
},
template: `
<div style="width: 200px">
<OsMenu :routes="routes" />
</div>
`,
}),
}
export const CustomMenuItem: StoryObj = {
render: () => ({
components: { OsMenu, OsMenuItem },
setup() {
const routes = computed(() => [
{ name: 'Edit', path: '/edit', label: 'Edit Item' },
{ name: 'Delete', path: '/delete', label: 'Delete Item' },
{ name: 'Share', path: '/share', label: 'Share Item' },
])
return { routes }
},
template: `
<div style="width: 200px">
<OsMenu dropdown :routes="routes">
<template #menuitem="{ route, parents, name }">
<OsMenuItem :route="route" :parents="parents">
<strong>{{ route.label }}</strong>
</OsMenuItem>
</template>
</OsMenu>
</div>
`,
}),
}
export const DropdownMenu: StoryObj = {
render: () => ({
components: { OsMenu, OsMenuItem },
setup() {
const routes = computed(() => [
{ name: 'Option A', label: 'Option A' },
{ name: 'Option B', label: 'Option B' },
{ name: 'Option C', label: 'Option C' },
])
const handleClick = (_e: Event, route: Record<string, unknown>) => {
// eslint-disable-next-line no-console
console.log('Selected:', route.label)
}
return { routes, handleClick }
},
template: `
<div style="width: 200px; background: white; border: 1px solid #ddd; border-radius: 4px; padding: 4px 0;">
<OsMenu dropdown :routes="routes">
<template #menuitem="{ route, parents }">
<OsMenuItem :route="route" :parents="parents" @click="handleClick">
{{ route.label }}
</OsMenuItem>
</template>
</OsMenu>
</div>
`,
}),
}

View File

@ -0,0 +1,85 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
const STORY_URL = '/iframe.html?id=components-osmenu'
const STORY_ROOT = '#storybook-root'
async function waitForFonts(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OsMenu keyboard accessibility', () => {
test('menu items are focusable via links', async ({ page }) => {
await page.goto(`${STORY_URL}--sidebar&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const links = root.locator('.os-menu-item-link')
const count = await links.count()
expect(count).toBeGreaterThan(0)
// Tab through links
for (let i = 0; i < count; i++) {
await page.keyboard.press('Tab')
const focused = page.locator(':focus')
const focusedClass = await focused.getAttribute('class')
expect(focusedClass).toContain('os-menu-item-link')
}
})
})
test.describe('OsMenu visual regression', () => {
test('sidebar', async ({ page }) => {
await page.goto(`${STORY_URL}--sidebar&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('sidebar.png')
await checkA11y(page)
})
test('sidebar with active item', async ({ page }) => {
await page.goto(`${STORY_URL}--sidebar-exact-match&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('sidebar-active.png')
await checkA11y(page)
})
test('nested routes', async ({ page }) => {
await page.goto(`${STORY_URL}--nested-routes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('nested.png')
await checkA11y(page)
})
test('dropdown menu', async ({ page }) => {
await page.goto(`${STORY_URL}--dropdown-menu&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('dropdown.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,151 @@
<script lang="ts">
import { defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '#src/utils'
import OsMenuItem from './OsMenuItem.vue'
import type { Component, PropType } from 'vue-demi'
/**
* Navigation menu component that renders a list of route items.
*
* Use the `menuitem` scoped slot to customize how each item is rendered.
* Pass `link-tag` to control which element renders links (e.g. `'nuxt-link'`).
*
* Styles are defined in src/styles/index.css and included in the style.css build.
*
* @slot default - Override entire menu content
* @slot menuitem - Custom menu item template (scoped: { route, parents, name })
*/
export default defineComponent({
name: 'OsMenu',
components: { OsMenuItem },
provide() {
return {
$parentMenu: this,
}
},
inheritAttrs: false,
props: {
/** Array of route objects to display */
routes: {
type: Array as PropType<Record<string, unknown>[]>,
default: null,
},
/** Display as compact dropdown menu (smaller padding, hover border accent) */
dropdown: {
type: Boolean,
default: false,
},
/** Component or tag used for links */
linkTag: {
type: [String, Object, Function] as PropType<string | Component>,
default: 'a',
},
/** Function to extract URL from a route */
urlParser: {
type: Function as PropType<
(route: Record<string, unknown>, parents: Record<string, unknown>[]) => string
>,
default: (route: Record<string, unknown>) => {
return (route.path as string) || '/'
},
},
/** Function to extract display name from a route */
nameParser: {
type: Function as PropType<
(route: Record<string, unknown>, parents: Record<string, unknown>[]) => string
>,
default: (route: Record<string, unknown>) => {
return (route.name as string) || ''
},
},
/** Custom matcher function for active state */
matcher: {
type: Function as PropType<(url: string, route: Record<string, unknown>) => boolean>,
default: () => false,
},
/** Function to check if URL must match exactly */
isExact: {
type: Function as PropType<(url: string) => boolean>,
default: (url: string) => url === '/',
},
},
emits: ['navigate'],
/* v8 ignore start -- render function tested via unit + visual tests */
setup(props, { slots, attrs }) {
const instance = isVue2 ? getCurrentInstance() : null
return () => {
// Build menu items from routes
let menuItems: unknown[] = []
if (slots.default) {
const defaultContent = slots.default()
menuItems = Array.isArray(defaultContent) ? defaultContent : [defaultContent]
} else if (props.routes) {
menuItems = props.routes.map((route, index) => {
if (slots.menuitem) {
const name = props.nameParser(route, [])
return slots.menuitem({ route, parents: [], name })
}
const key = (route.path as string) || index
if (isVue2) {
return h(OsMenuItem, { key, props: { route } })
}
return h(OsMenuItem, { key, route })
})
}
const listChildren = menuItems
.flat(Infinity)
.filter((item): item is ReturnType<typeof h> => item != null)
const list = h('ul', { class: 'os-menu-list' }, listChildren)
if (isVue2) {
const proxy = instance?.proxy as Record<string, unknown> | undefined
const parentClass =
(proxy?.$vnode as Record<string, Record<string, unknown>> | undefined)?.data
?.staticClass || ''
const parentDynClass = (
proxy?.$vnode as Record<string, Record<string, unknown>> | undefined
)?.data?.class
return h(
'nav',
{
class: [
cn('os-menu', props.dropdown && 'os-menu--dropdown', parentClass as string),
parentDynClass,
].filter(Boolean),
attrs,
},
[list],
)
}
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'nav',
{
class: cn(
'os-menu',
props.dropdown && 'os-menu--dropdown',
(attrClass as string) || '',
),
...restAttrs,
},
[list],
)
}
},
methods: {
handleNavigate() {
this.$emit('navigate')
},
},
/* v8 ignore stop */
})
</script>

View File

@ -0,0 +1,30 @@
/**
* OsMenuItem unit tests are colocated in OsMenu.spec.ts
* since OsMenuItem requires OsMenu as a parent provider.
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OsMenuItem from './OsMenuItem.vue'
const parentMenu = {
linkTag: 'a',
urlParser: (route: Record<string, unknown>) => (route.path as string) || '/',
nameParser: (route: Record<string, unknown>) => (route.name as string) || '',
matcher: () => false,
isExact: (url: string) => url === '/',
handleNavigate: () => {},
}
describe('keyboard accessibility', () => {
it('renders as a focusable link element', () => {
const wrapper = mount(OsMenuItem, {
props: { route: { name: 'Test', path: '/test' } },
global: { provide: { $parentMenu: parentMenu } },
})
const link = wrapper.find('.os-menu-item-link')
expect(link.element.tagName).toBe('A')
expect(link.attributes('href')).toBe('/test')
})
})

View File

@ -0,0 +1,38 @@
import OsMenu from './OsMenu.vue'
import OsMenuItem from './OsMenuItem.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsMenuItem> = {
title: 'Components/OsMenuItem',
component: OsMenuItem,
tags: ['!autodocs', '!dev'],
}
export default meta
const routes = [
{ name: 'Edit', path: '/edit', label: 'Edit Item' },
{ name: 'Delete', path: '/delete', label: 'Delete Item' },
{ name: 'Share', path: '/share', label: 'Share Item' },
]
export const InDropdown: StoryObj = {
render: () => ({
components: { OsMenu, OsMenuItem },
setup() {
return { routes }
},
template: `
<div style="width: 200px; background: white; border: 1px solid #ddd; border-radius: 4px; padding: 4px 0;">
<OsMenu dropdown :routes="routes">
<template #menuitem="{ route, parents }">
<OsMenuItem :route="route" :parents="parents">
<strong>{{ route.label }}</strong>
</OsMenuItem>
</template>
</OsMenu>
</div>
`,
}),
}

View File

@ -0,0 +1,48 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
/**
* OsMenuItem visual tests uses OsMenu stories since
* OsMenuItem is always rendered within an OsMenu context.
*/
const STORY_URL = '/iframe.html?id=components-osmenuitem'
const STORY_ROOT = '#storybook-root'
async function waitForFonts(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OsMenuItem keyboard accessibility', () => {
test('menu items are focusable via links', async ({ page }) => {
await page.goto(`${STORY_URL}--in-dropdown&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const links = root.locator('.os-menu-item-link')
const count = await links.count()
expect(count).toBeGreaterThan(0)
})
})
test.describe('OsMenuItem visual regression', () => {
test('custom menu item', async ({ page }) => {
await page.goto(`${STORY_URL}--in-dropdown&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('menuitem-custom.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,251 @@
<script lang="ts">
import { defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '#src/utils'
import type { Component, PropType } from 'vue-demi'
/**
* Individual menu item used inside OsMenu.
* Injects `$parentMenu` to access menu-level configuration.
*
* @slot default - Custom item label content
*/
export default defineComponent({
name: 'OsMenuItem',
inject: {
$parentMenu: {
default: null,
},
},
inheritAttrs: false,
props: {
/** Route object with name, path, children, etc. */
route: {
type: Object as PropType<Record<string, unknown>>,
default: null,
},
/** Parent routes for nesting level calculation */
parents: {
type: Array as PropType<Record<string, unknown>[]>,
default: () => [],
},
/** Override link component for this item */
linkTag: {
type: [String, Object, Function] as PropType<string | Component>,
default: null,
},
},
emits: ['click'],
/* v8 ignore start -- render function tested via unit + visual tests */
setup(props, { slots, attrs }) {
const instance = isVue2 ? getCurrentInstance() : null
// We need access to component instance for inject, so use render in setup
return () => {
// Access component instance for inject values
const proxy = instance?.proxy as Record<string, unknown> | undefined
const vm = (isVue2 ? proxy : getCurrentInstance()?.proxy) as Record<string, unknown>
if (!vm || !props.route) return null
const resolvedLinkTag = vm.resolvedLinkTag as string | Component
const matcherResult = vm.matcherResult as boolean
const isExact = vm.isExact as boolean
const name = vm.name as string
const level = vm.level as number
const bindings = vm.bindings as Record<string, unknown>
const hasSubmenu = vm.hasSubmenu as boolean
const showSubmenu = vm.showSubmenu as boolean
const handleClick = vm.handleClick as (e: Event) => void
// Build link element
const linkClass = cn('os-menu-item-link', matcherResult && 'os-menu-item--active')
const defaultSlotContent = slots.default?.()
const linkChildren = defaultSlotContent || [name]
let linkNode: ReturnType<typeof h>
if (isVue2) {
linkNode = h(
resolvedLinkTag,
{
class: linkClass,
props: { ...bindings, exact: isExact },
attrs: bindings,
on: { click: handleClick },
ref: 'link',
},
linkChildren,
)
} else {
linkNode = h(
resolvedLinkTag,
{
class: linkClass,
...bindings,
exact: isExact,
onClick: handleClick,
ref: 'link',
},
linkChildren,
)
}
// Build submenu if children exist
const children: ReturnType<typeof h>[] = [linkNode]
if (hasSubmenu) {
const OsMenuItemSelf = vm.$options as Component
const submenuItems = (props.route.children as Record<string, unknown>[]).map((child) => {
const childProps = {
route: child,
parents: [...props.parents, props.route],
}
if (isVue2) {
return h(OsMenuItemSelf, { key: child.name as string, props: childProps })
}
return h(OsMenuItemSelf, { key: child.name as string, ...childProps })
})
children.push(h('ul', { class: 'os-menu-item-submenu' }, submenuItems))
}
// Build li wrapper
const liClass = cn(
'os-menu-item',
`os-menu-item-level-${String(level)}`,
showSubmenu && 'os-menu-item-show-submenu',
)
if (isVue2) {
const parentClass =
(proxy?.$vnode as Record<string, Record<string, unknown>> | undefined)?.data
?.staticClass || ''
const parentDynClass = (
proxy?.$vnode as Record<string, Record<string, unknown>> | undefined
)?.data?.class
const listeners = (proxy as Record<string, unknown>)?.$listeners as
| Record<string, unknown>
| undefined
return h(
'li',
{
class: [cn(liClass, parentClass as string), parentDynClass].filter(Boolean),
attrs,
on: {
...(listeners || {}),
click: handleClick,
},
},
children,
)
}
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'li',
{
class: cn(liClass, (attrClass as string) || ''),
onClick: handleClick,
...restAttrs,
},
children,
)
}
},
data() {
return {
showSubmenu: false,
clickOutsideHandler: null as ((e: Event) => void) | null,
}
},
computed: {
resolvedLinkTag(): string | Component {
if (this.linkTag) return this.linkTag
const menu = this.$parentMenu as Record<string, unknown> | null
return (menu?.linkTag as string | Component) || 'a'
},
hasSubmenu(): boolean {
const children = this.route?.children as unknown[] | undefined
return Boolean(children && children.length)
},
url(): string {
const menu = this.$parentMenu as Record<string, unknown> | null
const parser = menu?.urlParser as (
r: Record<string, unknown>,
p: Record<string, unknown>[],
) => string
return parser ? parser(this.route, this.parents) : (this.route?.path as string) || '/'
},
name(): string {
const menu = this.$parentMenu as Record<string, unknown> | null
const parser = menu?.nameParser as (
r: Record<string, unknown>,
p: Record<string, unknown>[],
) => string
return parser ? parser(this.route, this.parents) : (this.route?.name as string) || ''
},
isExact(): boolean {
const menu = this.$parentMenu as Record<string, unknown> | null
const fn = menu?.isExact as ((url: string) => boolean) | undefined
return fn ? fn(this.url) : this.url === '/'
},
matcherResult(): boolean {
const menu = this.$parentMenu as Record<string, unknown> | null
const fn = menu?.matcher as
| ((url: string, route: Record<string, unknown>) => boolean)
| undefined
return fn ? fn(this.url, this.route) : false
},
level(): number {
return this.parents.length
},
bindings(): Record<string, unknown> {
const tag = this.resolvedLinkTag
if (tag === 'router-link' || tag === 'nuxt-link') {
// Support { name, params } objects for vue-router
if (this.route?.name && this.route?.params && !this.route?.path) {
return { to: { name: this.route.name, params: this.route.params } }
}
return { to: this.url }
}
if (tag === 'a') {
return { href: this.url }
}
// Custom component pass to
return { to: this.url }
},
},
mounted() {
this.clickOutsideHandler = (e: Event) => {
if (this.showSubmenu && !this.$el.contains(e.target as Node)) {
this.showSubmenu = false
}
}
document.addEventListener('click', this.clickOutsideHandler, true)
},
/* v8 ignore next 4 -- Vue 2 lifecycle hook */
// eslint-disable-next-line vue/no-deprecated-destroyed-lifecycle
beforeDestroy() {
this.cleanup()
},
beforeUnmount() {
this.cleanup()
},
methods: {
cleanup() {
if (this.clickOutsideHandler) {
document.removeEventListener('click', this.clickOutsideHandler, true)
}
},
handleClick(event: Event) {
this.$emit('click', event, this.route)
const menu = this.$parentMenu as Record<string, unknown> | null
if (menu && typeof menu.handleNavigate === 'function') {
;(menu.handleNavigate as () => void)()
}
},
},
/* v8 ignore stop */
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,4 @@
export { default as OsMenu } from './OsMenu.vue'
export { default as OsMenuItem } from './OsMenuItem.vue'
export { menuItemVariants } from './menu.variants'
export type { MenuItemVariants } from './menu.variants'

View File

@ -0,0 +1,26 @@
import { cva } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
export const menuItemVariants = cva(
// Base classes for menu item link
[
'os-menu-item-link',
'block no-underline',
'transition-colors duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)]',
],
{
variants: {
level: {
0: '',
1: 'text-sm',
2: 'text-sm',
},
},
defaultVariants: {
level: 0,
},
},
)
export type MenuItemVariants = VariantProps<typeof menuItemVariants>

View File

@ -29,3 +29,4 @@ export {
} from './OsBadge'
export { OsNumber, numberVariants, type NumberVariants } from './OsNumber'
export { OsModal, modalPanelVariants } from './OsModal'
export { OsMenu, OsMenuItem, menuItemVariants, type MenuItemVariants } from './OsMenu'

View File

@ -16,3 +16,89 @@
@source "../ocelot/**/*.vue";
@source "../**/*.ts";
/* OsMenu — component styles (not expressible as pure utility classes) */
.os-menu {
margin: 0;
padding: 0;
line-height: 1.3;
box-sizing: border-box;
}
ul.os-menu-list {
margin: 0;
padding: 0;
list-style: none;
}
.os-menu-item {
list-style: none;
}
.os-menu-item-link {
display: block;
box-sizing: border-box;
color: var(--color-text-base, #4b4554);
line-height: 1.3;
text-decoration: none;
padding: 8px 16px;
border-left: 2px solid transparent;
transition: color 80ms ease-out,
background-color 80ms ease-out,
border-left-color 80ms ease-out;
cursor: pointer;
}
.os-menu-item-link:hover {
color: var(--color-primary, #17b53f);
}
.os-menu-item-link:focus-visible {
color: var(--color-primary, #17b53f);
outline: 1px dashed var(--color-primary, #17b53f);
outline-offset: -1px;
}
/* Active state via vue-router */
.os-menu-item-link.router-link-active {
color: var(--color-primary, #17b53f);
}
.os-menu-item-link.router-link-exact-active {
color: var(--color-primary, #17b53f);
background-color: var(--color-background-soft, #faf9fa);
border-left-color: var(--color-primary, #17b53f);
}
/* Active state via matcher prop */
.os-menu-item-link.os-menu-item--active {
color: var(--color-primary, #17b53f);
background-color: var(--color-background-soft, #faf9fa);
border-left-color: var(--color-primary, #17b53f);
}
/* Dropdown variant — compact padding, hover with border accent */
.os-menu--dropdown .os-menu-item-link {
padding: 8px 12px;
}
.os-menu--dropdown .os-menu-item-link:hover {
border-left-color: var(--color-primary, #17b53f);
}
/* Nesting levels */
.os-menu-item-level-1 .os-menu-item-link {
font-size: 0.8rem;
padding-left: 24px;
}
.os-menu-item-level-2 .os-menu-item-link {
font-size: 0.8rem;
padding-left: 32px;
}
ul.os-menu-item-submenu {
margin: 0;
padding: 0;
list-style: none;
}

View File

@ -97,14 +97,6 @@
padding: $space-x-small $space-small;
box-shadow: $box-shadow-x-large;
nav {
margin-left: -$space-small;
margin-right: -$space-small;
a {
padding-left: 12px;
}
}
}
.popover-arrow {

View File

@ -87,34 +87,34 @@ describe('AvatarMenu.vue', () => {
describe('role user', () => {
it('displays a link to user profile', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/profile/u343/matt'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to "Groups"', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/groups'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to the notifications page', () => {
const notificationsLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/notifications'))
expect(notificationsLink.exists()).toBe(true)
})
it('displays a link to the settings page', () => {
const settingsLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/settings'))
expect(settingsLink.exists()).toBe(true)
})
it('displays a total of 6 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
const allLinks = wrapper.findAll('.os-menu-item')
expect(allLinks).toHaveLength(6)
})
})
@ -134,13 +134,13 @@ describe('AvatarMenu.vue', () => {
it('displays a link to moderation page', () => {
const moderationLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/moderation'))
expect(moderationLink.exists()).toBe(true)
})
it('displays a total of 7 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
const allLinks = wrapper.findAll('.os-menu-item')
expect(allLinks).toHaveLength(7)
})
})
@ -160,13 +160,13 @@ describe('AvatarMenu.vue', () => {
it('displays a link to admin page', () => {
const adminLink = wrapper
.findAll('.ds-menu-item span')
.findAll('.os-menu-item span')
.at(wrapper.vm.routes.findIndex((route) => route.path === '/admin'))
expect(adminLink.exists()).toBe(true)
})
it('displays a total of 8 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
const allLinks = wrapper.findAll('.os-menu-item')
expect(allLinks).toHaveLength(8)
})
})

View File

@ -33,21 +33,21 @@
</p>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="
closeMenu(false)
$emit('toggle-Mobile-Menu-view')
"
>
<os-icon :icon="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<os-menu dropdown :routes="routes" :matcher="matcher" link-tag="router-link">
<template #menuitem="item">
<os-menu-item
:route="item.route"
:parents="item.parents"
@click.native="
closeMenu(false)
$emit('toggle-Mobile-Menu-view')
"
>
<os-icon :icon="item.route.icon" />
{{ item.route.name }}
</os-menu-item>
</template>
</os-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<os-icon :icon="icons.signOut" />
@ -59,7 +59,7 @@
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
import { OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
@ -69,6 +69,8 @@ export default {
components: {
Dropdown,
OsIcon,
OsMenu,
OsMenuItem,
ProfileAvatar,
},
props: {

View File

@ -83,7 +83,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
@ -100,7 +100,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -142,7 +142,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -174,7 +174,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -210,7 +210,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -242,7 +242,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -286,7 +286,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -318,7 +318,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -354,7 +354,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
.length,
).toEqual(0)
})
@ -373,7 +373,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -417,7 +417,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
@ -434,7 +434,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -476,7 +476,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -508,7 +508,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -544,7 +544,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -576,7 +576,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -620,7 +620,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
@ -652,7 +652,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -688,7 +688,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
.length,
).toEqual(0)
})
@ -707,7 +707,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
@ -751,7 +751,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
@ -768,7 +768,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
).toHaveLength(0)
})
})
@ -794,7 +794,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
@ -812,7 +812,7 @@ describe('ContentMenu.vue - Group', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
).toHaveLength(0)
})
})

View File

@ -11,9 +11,6 @@ localVue.use(VTooltip)
localVue.use(Vuex)
const stubs = {
'router-link': {
template: '<span><slot /></span>',
},
'confirm-modal': { template: '<div class="confirm-modal-stub" />' },
'report-modal': { template: '<div class="report-modal-stub" />' },
}
@ -77,19 +74,15 @@ describe('ContentMenu.vue', () => {
})
it('can edit the contribution', () => {
expect(
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.edit')
.at(0)
.find('span.ds-menu-item-link')
.attributes('to'),
).toBe('/post-edit-id')
const editItem = wrapper
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.edit')
expect(editItem).toHaveLength(1)
})
it('can delete the contribution', () => {
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.delete')
.at(0)
.trigger('click')
@ -110,10 +103,10 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.push'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.push'),
).toHaveLength(1)
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.push')
.at(0)
.trigger('click')
@ -140,7 +133,7 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.unpush'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.unpush'),
).toHaveLength(0)
})
@ -156,10 +149,10 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.unpush'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.unpush'),
).toHaveLength(1)
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unpush')
.at(0)
.trigger('click')
@ -190,7 +183,7 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
@ -204,7 +197,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
@ -235,7 +228,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
@ -259,7 +252,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
@ -288,7 +281,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
@ -321,7 +314,7 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
})
@ -341,7 +334,7 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
})
@ -365,7 +358,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
@ -389,7 +382,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
@ -421,7 +414,7 @@ describe('ContentMenu.vue', () => {
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
wrapper.findAll('.os-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
@ -435,7 +428,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
@ -463,7 +456,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'settings.deleteUserAccount.name')
.at(0)
.trigger('click')
@ -487,7 +480,7 @@ describe('ContentMenu.vue', () => {
})
expect(
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'settings.deleteUserAccount.name'),
).toEqual({})
})
@ -507,7 +500,7 @@ describe('ContentMenu.vue', () => {
})
it('edit the comment', () => {
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'comment.menu.edit')
.at(0)
.trigger('click')
@ -515,7 +508,7 @@ describe('ContentMenu.vue', () => {
})
it('delete the comment', () => {
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'comment.menu.delete')
.at(0)
.trigger('click')
@ -536,7 +529,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'report.contribution.title')
.at(0)
.trigger('click')
@ -553,7 +546,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'report.comment.title')
.at(0)
.trigger('click')
@ -570,7 +563,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'report.user.title')
.at(0)
.trigger('click')
@ -587,7 +580,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'report.organization.title')
.at(0)
.trigger('click')
@ -609,7 +602,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'disable.contribution.title')
.at(0)
.trigger('click')
@ -627,7 +620,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'disable.comment.title')
.at(0)
.trigger('click')
@ -645,7 +638,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'disable.user.title')
.at(0)
.trigger('click')
@ -663,7 +656,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'disable.organization.title')
.at(0)
.trigger('click')
@ -681,7 +674,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'release.contribution.title')
.at(0)
.trigger('click')
@ -699,7 +692,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'release.comment.title')
.at(0)
.trigger('click')
@ -717,7 +710,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'release.user.title')
.at(0)
.trigger('click')
@ -735,7 +728,7 @@ describe('ContentMenu.vue', () => {
})
openModalSpy = jest.spyOn(wrapper.vm, 'openModal')
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'release.organization.title')
.at(0)
.trigger('click')
@ -754,14 +747,11 @@ describe('ContentMenu.vue', () => {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
},
})
expect(
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'settings.name')
.at(0)
.find('span.ds-menu-item-link')
.attributes('to'),
).toBe('/settings')
const settingsItem = wrapper
.findAll('.os-menu-item')
.filter((item) => item.text() === 'settings.name')
expect(settingsItem).toHaveLength(1)
expect(settingsItem.at(0).find('.os-menu-item-link').attributes('href')).toBe('/settings')
})
it('can mute other users', async () => {
@ -774,7 +764,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'settings.muted-users.mute')
.at(0)
.trigger('click')
@ -798,7 +788,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'settings.muted-users.unmute')
.at(0)
.trigger('click')
@ -822,7 +812,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.observe')
.at(0)
.trigger('click')
@ -841,7 +831,7 @@ describe('ContentMenu.vue', () => {
},
})
wrapper
.findAll('.ds-menu-item')
.findAll('.os-menu-item')
.filter((item) => item.text() === 'post.menu.unobserve')
.at(0)
.trigger('click')

View File

@ -20,18 +20,18 @@
</template>
<template #popover="{ toggleMenu }">
<div class="content-menu-popover">
<ds-menu :routes="routes">
<os-menu dropdown :routes="routes">
<template #menuitem="item">
<ds-menu-item
<os-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</os-menu-item>
</template>
</ds-menu>
</os-menu>
</div>
</template>
</dropdown>
@ -51,7 +51,7 @@
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Dropdown from '~/components/Dropdown'
import ConfirmModal from '~/components/Modal/ConfirmModal'
@ -66,6 +66,8 @@ export default {
Dropdown,
OsButton,
OsIcon,
OsMenu,
OsMenuItem,
ReportModal,
},
mixins: [PinnedPostsMixin],
@ -401,12 +403,7 @@ export default {
</script>
<style lang="scss">
.content-menu-popover {
nav {
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
margin-left: -$space-x-small;
margin-right: -$space-x-small;
}
.tooltip-inner.popover-inner .os-menu {
margin: (-$space-xx-small) (-$space-small) !important;
}
</style>

View File

@ -20,19 +20,18 @@
</template>
<template #popover="{ toggleMenu }">
<div class="group-menu-popover">
<ds-menu :routes="routes">
<os-menu dropdown :routes="routes">
<template #menuitem="item">
{{ item.parents }}
<ds-menu-item
<os-menu-item
:route="item.route"
:parents="item.parents"
@click.stop.prevent="openItem(item.route, toggleMenu)"
>
<os-icon :icon="item.route.icon" />
{{ item.route.label }}
</ds-menu-item>
</os-menu-item>
</template>
</ds-menu>
</os-menu>
</div>
</template>
</dropdown>
@ -40,7 +39,7 @@
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Dropdown from '~/components/Dropdown'
@ -50,6 +49,8 @@ export default {
Dropdown,
OsButton,
OsIcon,
OsMenu,
OsMenuItem,
},
props: {
usage: {
@ -128,12 +129,7 @@ export default {
</script>
<style lang="scss">
.group-menu-popover {
nav {
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
margin-left: -$space-x-small;
margin-right: -$space-x-small;
}
.tooltip-inner.popover-inner .os-menu {
margin: (-$space-xx-small) (-$space-small) !important;
}
</style>

View File

@ -60,20 +60,16 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/"
>
<span
@ -94,16 +90,12 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
group.contentMenu.muteGroup
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
class="os-menu-item-link"
href="/groups/edit/groupid"
>
<span
@ -124,16 +116,12 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
admin.settings.name
</a>
<!---->
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
class="os-menu-item-link"
href="/groups/edit/groupid/invites"
>
<span
@ -154,7 +142,6 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
group.contentMenu.inviteLinks
</a>
<!---->
</li>
</ul>
</nav>
@ -225,20 +212,16 @@ exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/"
>
<span
@ -259,7 +242,6 @@ exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
group.contentMenu.unmuteGroup
</a>
<!---->
</li>
</ul>
</nav>
@ -330,20 +312,16 @@ exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/"
>
<span
@ -364,7 +342,6 @@ exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
group.contentMenu.muteGroup
</a>
<!---->
</li>
</ul>
</nav>
@ -435,19 +412,16 @@ exports[`GroupContentMenu renders as groupTeaser 1`] = `
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
class="os-menu-item-link"
href="/groups/groupid"
>
<span
@ -468,7 +442,6 @@ exports[`GroupContentMenu renders as groupTeaser 1`] = `
group.contentMenu.visitGroupPage
</a>
<!---->
</li>
</ul>
</nav>

View File

@ -14,23 +14,23 @@
</a>
</template>
<template #popover="{ toggleMenu }">
<ds-menu class="dropdown-menu-popover" :routes="filterOptions">
<os-menu dropdown class="dropdown-menu-popover" :routes="filterOptions">
<template #menuitem="item">
<ds-menu-item
<os-menu-item
class="dropdown-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="filter(item.route, toggleMenu)"
>
{{ item.route.label }}
</ds-menu-item>
</os-menu-item>
</template>
</ds-menu>
</os-menu>
</template>
</dropdown>
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
import { OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Dropdown from '~/components/Dropdown'
@ -38,6 +38,8 @@ export default {
components: {
Dropdown,
OsIcon,
OsMenu,
OsMenuItem,
},
setup() {
return { icons: iconRegistry }

View File

@ -20,25 +20,25 @@
</os-button>
</template>
<template #popover="{ toggleMenu }">
<ds-menu class="locale-menu-popover" :matcher="matcher" :routes="routes">
<os-menu dropdown class="locale-menu-popover" :matcher="matcher" :routes="routes">
<template #menuitem="item">
<ds-menu-item
<os-menu-item
class="locale-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
>
{{ item.route.name }}
</ds-menu-item>
</os-menu-item>
</template>
</ds-menu>
</os-menu>
</template>
</dropdown>
</client-only>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Dropdown from '~/components/Dropdown'
import find from 'lodash/find'
@ -52,6 +52,8 @@ export default {
Dropdown,
OsButton,
OsIcon,
OsMenu,
OsMenuItem,
},
props: {
placement: { type: String, default: 'bottom-start' },

View File

@ -24,164 +24,109 @@ exports[`settings.vue given badges are disabled renders 1`] = `
class="menu-container"
>
<nav
class="ds-menu"
class="os-menu"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings"
>
</a>
<!---->
to="/settings"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/my-email-address"
>
</a>
<!---->
to="/settings/my-email-address"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/security"
>
</a>
<!---->
to="/settings/security"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/privacy"
>
</a>
<!---->
to="/settings/privacy"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/my-social-media"
>
</a>
<!---->
to="/settings/my-social-media"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/muted-users"
>
</a>
<!---->
to="/settings/muted-users"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/blocked-users"
>
</a>
<!---->
to="/settings/blocked-users"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/embeds"
>
</a>
<!---->
to="/settings/embeds"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/notifications"
>
</a>
<!---->
to="/settings/notifications"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/data-download"
>
</a>
<!---->
to="/settings/data-download"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/delete-account"
>
</a>
<!---->
to="/settings/delete-account"
/>
</li>
</ul>
</nav>
@ -227,178 +172,118 @@ exports[`settings.vue given badges are enabled renders 1`] = `
class="menu-container"
>
<nav
class="ds-menu"
class="os-menu"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings"
>
</a>
<!---->
to="/settings"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/my-email-address"
>
</a>
<!---->
to="/settings/my-email-address"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/badges"
>
</a>
<!---->
to="/settings/badges"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/security"
>
</a>
<!---->
to="/settings/security"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/privacy"
>
</a>
<!---->
to="/settings/privacy"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/my-social-media"
>
</a>
<!---->
to="/settings/my-social-media"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/muted-users"
>
</a>
<!---->
to="/settings/muted-users"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/blocked-users"
>
</a>
<!---->
to="/settings/blocked-users"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/embeds"
>
</a>
<!---->
to="/settings/embeds"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/notifications"
>
</a>
<!---->
to="/settings/notifications"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/data-download"
>
</a>
<!---->
to="/settings/data-download"
/>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<a
class="ds-menu-item-link"
exact="true"
class="os-menu-item-link"
href="/settings/delete-account"
>
</a>
<!---->
to="/settings/delete-account"
/>
</li>
</ul>
</nav>

View File

@ -3,7 +3,7 @@
<h1 class="ds-heading ds-heading-h1">{{ $t('admin.name') }}</h1>
<div class="ds-flex ds-flex-gap-small admin-layout">
<div class="admin-layout__sidebar">
<ds-menu :routes="routes" :is-exact="() => true" />
<os-menu :routes="routes" :is-exact="() => true" link-tag="router-link" />
</div>
<div class="admin-layout__main">
<transition name="slide-up" appear>
@ -15,7 +15,12 @@
</template>
<script>
import { OsMenu } from '@ocelot-social/ui'
export default {
components: {
OsMenu,
},
middleware: ['isAdmin'],
computed: {
routes() {

View File

@ -120,21 +120,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -153,18 +149,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
group.contentMenu.unmuteGroup
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g1"
<a
class="os-menu-item-link"
href="/groups/edit/g1"
>
<span
aria-hidden="true"
@ -183,18 +175,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
admin.settings.name
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g1/invites"
<a
class="os-menu-item-link"
href="/groups/edit/g1/invites"
>
<span
aria-hidden="true"
@ -213,8 +201,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
group.contentMenu.inviteLinks
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -2241,21 +2228,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -2274,8 +2257,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
group.contentMenu.unmuteGroup
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -3292,21 +3274,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -3325,18 +3303,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
group.contentMenu.muteGroup
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g2"
<a
class="os-menu-item-link"
href="/groups/edit/g2"
>
<span
aria-hidden="true"
@ -3355,18 +3329,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
admin.settings.name
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g2/invites"
<a
class="os-menu-item-link"
href="/groups/edit/g2/invites"
>
<span
aria-hidden="true"
@ -3385,8 +3355,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
group.contentMenu.inviteLinks
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -5933,21 +5902,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -5966,8 +5931,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
group.contentMenu.muteGroup
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -6916,21 +6880,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -6949,18 +6909,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
group.contentMenu.muteGroup
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g0"
<a
class="os-menu-item-link"
href="/groups/edit/g0"
>
<span
aria-hidden="true"
@ -6979,18 +6935,14 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
admin.settings.name
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/groups/edit/g0/invites"
<a
class="os-menu-item-link"
href="/groups/edit/g0/invites"
>
<span
aria-hidden="true"
@ -7009,8 +6961,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
group.contentMenu.inviteLinks
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -8028,21 +7979,17 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
class="group-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -8061,8 +8008,7 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
group.contentMenu.muteGroup
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>

View File

@ -12,7 +12,7 @@
<div class="ds-my-large"></div>
<div class="ds-flex ds-flex-gap-small group-edit-layout">
<div class="group-edit-layout__sidebar">
<ds-menu :routes="routes" :is-exact="() => true" />
<os-menu :routes="routes" :is-exact="() => true" link-tag="router-link" />
</div>
<div class="group-edit-layout__main">
<transition name="slide-up" appear>
@ -24,10 +24,14 @@
</template>
<script>
import { OsMenu } from '@ocelot-social/ui'
import { groupQuery } from '~/graphql/groups.js'
import { mapGetters } from 'vuex'
export default {
components: {
OsMenu,
},
data() {
return {
group: {},

View File

@ -5,7 +5,7 @@
</h1>
<div class="ds-flex ds-flex-gap-small moderation-layout">
<div class="moderation-layout__sidebar">
<ds-menu :routes="routes" />
<os-menu :routes="routes" link-tag="router-link" />
</div>
<div class="moderation-layout__main">
<transition name="slide-up" appear>
@ -17,7 +17,12 @@
</template>
<script>
import { OsMenu } from '@ocelot-social/ui'
export default {
components: {
OsMenu,
},
middleware: ['isModerator'],
computed: {
routes() {

View File

@ -176,7 +176,7 @@
</os-card>
</div>
<div class="post-detail-layout__sidebar" style="flex: 0 0 200px; width: 200px">
<ds-menu :routes="routes" class="post-side-navigation" />
<os-menu :routes="routes" class="post-side-navigation" link-tag="router-link" />
</div>
</div>
</div>
@ -184,7 +184,7 @@
</template>
<script>
import { OsButton, OsCard, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsCard, OsIcon, OsMenu } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
@ -224,6 +224,7 @@ export default {
OsCard,
OsButton,
OsIcon,
OsMenu,
CommentForm,
CommentList,
ContentMenu,

View File

@ -14,16 +14,16 @@
<div class="ds-my-large"></div>
<div class="ds-flex ds-flex-gap-small post-create-layout">
<div class="post-create-layout__sidebar">
<ds-menu :routes="routes">
<ds-menu-item
<os-menu :routes="routes" link-tag="router-link">
<os-menu-item
@click.prevent="switchPostType($event, item)"
slot="menuitem"
slot-scope="item"
:route="item.route"
>
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
</os-menu-item>
</os-menu>
</div>
<div class="post-create-layout__main">
<transition name="slide-up" appear>
@ -35,12 +35,15 @@
</template>
<script>
import { OsMenu, OsMenuItem } from '@ocelot-social/ui'
import { groupQuery } from '~/graphql/groups'
import ContributionForm from '~/components/ContributionForm/ContributionForm'
export default {
components: {
ContributionForm,
OsMenu,
OsMenuItem,
},
data() {
const { groupId = null } = this.$route.query

View File

@ -89,18 +89,17 @@ exports[`ProfileSlug given an authenticated user given another profile user and
class="content-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -119,16 +118,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
report.user.title
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -147,16 +144,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
disable.user.title
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -175,16 +170,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
settings.muted-users.mute
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -203,8 +196,7 @@ exports[`ProfileSlug given an authenticated user given another profile user and
settings.blocked-users.block
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -784,18 +776,17 @@ exports[`ProfileSlug given an authenticated user given another profile user and
class="content-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -814,16 +805,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
report.user.title
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -842,16 +831,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
disable.user.title
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -870,16 +857,14 @@ exports[`ProfileSlug given an authenticated user given another profile user and
settings.muted-users.mute
</router-link-stub>
<!---->
</a>
</li>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
exact="true"
to="/"
<a
class="os-menu-item-link"
href="/"
>
<span
aria-hidden="true"
@ -898,8 +883,7 @@ exports[`ProfileSlug given an authenticated user given another profile user and
settings.blocked-users.block
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -1546,17 +1530,17 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
class="content-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/settings"
<a
class="os-menu-item-link"
href="/settings"
>
<span
aria-hidden="true"
@ -1575,8 +1559,7 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
settings.name
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>
@ -2135,17 +2118,17 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
class="content-menu-popover"
>
<nav
class="ds-menu"
class="os-menu os-menu--dropdown"
>
<ul
class="ds-menu-list"
class="os-menu-list"
>
<li
class="ds-menu-item ds-menu-item-level-0"
class="os-menu-item os-menu-item-level-0"
>
<router-link-stub
class="ds-menu-item-link"
to="/settings"
<a
class="os-menu-item-link"
href="/settings"
>
<span
aria-hidden="true"
@ -2164,8 +2147,7 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
settings.name
</router-link-stub>
<!---->
</a>
</li>
</ul>
</nav>

View File

@ -6,7 +6,7 @@
<div class="ds-my-large"></div>
<div class="ds-flex ds-flex-gap-small">
<div class="menu-container">
<ds-menu :routes="routes" :is-exact="() => true" />
<os-menu :routes="routes" :is-exact="() => true" link-tag="router-link" />
</div>
<div class="settings-content" id="settings-content">
<transition name="slide-up" appear>
@ -18,7 +18,12 @@
</template>
<script>
import { OsMenu } from '@ocelot-social/ui'
export default {
components: {
OsMenu,
},
computed: {
routes() {
const routes = [

View File

@ -32,3 +32,20 @@ global.localVue.use(Filters)
global.localVue.use(Directives)
global.localVue.use(InfiniteLoading)
global.localVue.use(VueObserveVisibility)
// Register router-link stub globally (OsMenu/OsMenuItem render it via h())
Vue.component('router-link', {
name: 'RouterLink',
props: { to: { type: [String, Object], default: '' }, exact: { type: Boolean, default: false } },
render(h) {
// Resolve href: string path or { name, params } object
let href = ''
const to = this.to
if (typeof to === 'string') {
href = to
} else if (to) {
href = to.path || `/${to.name || ''}`
}
return h('a', { attrs: { href, to: href }, class: this.$attrs.class }, this.$slots.default)
},
})