diff --git a/cypress/support/step_definitions/Admin.PinPost/there_is_no_button_to_pin_a_post.js b/cypress/support/step_definitions/Admin.PinPost/there_is_no_button_to_pin_a_post.js
index 5d75e9a87..2a407e440 100644
--- a/cypress/support/step_definitions/Admin.PinPost/there_is_no_button_to_pin_a_post.js
+++ b/cypress/support/step_definitions/Admin.PinPost/there_is_no_button_to_pin_a_post.js
@@ -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')
})
diff --git a/cypress/support/step_definitions/Moderation.ReportContent/I_click_on_Report_Post_from_the_content_menu_of_the_post.js b/cypress/support/step_definitions/Moderation.ReportContent/I_click_on_Report_Post_from_the_content_menu_of_the_post.js
index 7f7eb8664..74b64283e 100644
--- a/cypress/support/step_definitions/Moderation.ReportContent/I_click_on_Report_Post_from_the_content_menu_of_the_post.js
+++ b/cypress/support/step_definitions/Moderation.ReportContent/I_click_on_Report_Post_from_the_content_menu_of_the_post.js
@@ -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()
})
diff --git a/cypress/support/step_definitions/User.Block/I_{string}_see_{string}_from_the_content_menu_in_the_user_info_box.js b/cypress/support/step_definitions/User.Block/I_{string}_see_{string}_from_the_content_menu_in_the_user_info_box.js
index f9d966299..e2fb1a474 100644
--- a/cypress/support/step_definitions/User.Block/I_{string}_see_{string}_from_the_content_menu_in_the_user_info_box.js
+++ b/cypress/support/step_definitions/User.Block/I_{string}_see_{string}_from_the_content_menu_in_the_user_info_box.js
@@ -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)
})
diff --git a/cypress/support/step_definitions/common/I_click_on_{string}.js b/cypress/support/step_definitions/common/I_click_on_{string}.js
index 78a1ad8da..c9288dbed 100644
--- a/cypress/support/step_definitions/common/I_click_on_{string}.js
+++ b/cypress/support/step_definitions/common/I_click_on_{string}.js
@@ -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"]',
}
diff --git a/cypress/support/step_definitions/common/I_click_on_{string}_from_the_content_menu_in_the_user_info_box.js b/cypress/support/step_definitions/common/I_click_on_{string}_from_the_content_menu_in_the_user_info_box.js
index 676dc12c1..9afc33f3e 100644
--- a/cypress/support/step_definitions/common/I_click_on_{string}_from_the_content_menu_in_the_user_info_box.js
+++ b/cypress/support/step_definitions/common/I_click_on_{string}_from_the_content_menu_in_the_user_info_box.js
@@ -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
diff --git a/cypress/support/step_definitions/common/I_navigate_to_my_{string}_settings_page.js b/cypress/support/step_definitions/common/I_navigate_to_my_{string}_settings_page.js
index 73b93d3d3..c13f394db 100644
--- a/cypress/support/step_definitions/common/I_navigate_to_my_{string}_settings_page.js
+++ b/cypress/support/step_definitions/common/I_navigate_to_my_{string}_settings_page.js
@@ -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()
})
diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts
index 7d0b4abc3..7e202fded 100644
--- a/packages/ui/.storybook/preview.ts
+++ b/packages/ui/.storybook/preview.ts
@@ -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'
diff --git a/packages/ui/KATALOG.md b/packages/ui/KATALOG.md
index 9b57d023b..f9ca23d61 100644
--- a/packages/ui/KATALOG.md
+++ b/packages/ui/KATALOG.md
@@ -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: `
` |
| 33 | ListItem | ✅ → HTML | Tier A: `- ` |
| 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 `` 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.**
---
diff --git a/packages/ui/PROJEKT.md b/packages/ui/PROJEKT.md
index fd7d451f0..5d3e5912e 100644
--- a/packages/ui/PROJEKT.md
+++ b/packages/ui/PROJEKT.md
@@ -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) |
---
diff --git a/packages/ui/src/components/OsMenu/OsMenu.spec.ts b/packages/ui/src/components/OsMenu/OsMenu.spec.ts
new file mode 100644
index 000000000..4eeb342af
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenu.spec.ts
@@ -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) => 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: `
+
- {{ name }}
+ `,
+ },
+ })
+
+ 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) => (route.path as string) || '/',
+ nameParser: (route: Record) => (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: 'Custom' },
+ })
+
+ 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')
+ })
+ })
+})
diff --git a/packages/ui/src/components/OsMenu/OsMenu.stories.ts b/packages/ui/src/components/OsMenu/OsMenu.stories.ts
new file mode 100644
index 000000000..df91b0408
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenu.stories.ts
@@ -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 = {
+ 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: `
+
+
+
+ `,
+ }),
+}
+
+export const SidebarExactMatch: StoryObj = {
+ render: () => ({
+ components: { OsMenu },
+ setup() {
+ const routes = computed(() => sidebarRoutes)
+ const matcher = (_url: string, route: Record) => route.path === '/settings'
+ return { routes, matcher }
+ },
+ template: `
+
+
+
+ `,
+ }),
+}
+
+export const NestedRoutes: StoryObj = {
+ render: () => ({
+ components: { OsMenu },
+ setup() {
+ const routes = computed(() => nestedRoutes)
+ return { routes }
+ },
+ template: `
+
+
+
+ `,
+ }),
+}
+
+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: `
+
+
+
+
+ {{ route.label }}
+
+
+
+
+ `,
+ }),
+}
+
+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) => {
+ // eslint-disable-next-line no-console
+ console.log('Selected:', route.label)
+ }
+ return { routes, handleClick }
+ },
+ template: `
+
+
+
+
+ {{ route.label }}
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts
new file mode 100644
index 000000000..a52ef321d
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts
@@ -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)
+ })
+})
diff --git a/packages/ui/src/components/OsMenu/OsMenu.vue b/packages/ui/src/components/OsMenu/OsMenu.vue
new file mode 100644
index 000000000..7dd87cef9
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenu.vue
@@ -0,0 +1,151 @@
+
diff --git a/packages/ui/src/components/OsMenu/OsMenuItem.spec.ts b/packages/ui/src/components/OsMenu/OsMenuItem.spec.ts
new file mode 100644
index 000000000..d35fffb36
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenuItem.spec.ts
@@ -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) => (route.path as string) || '/',
+ nameParser: (route: Record) => (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')
+ })
+})
diff --git a/packages/ui/src/components/OsMenu/OsMenuItem.stories.ts b/packages/ui/src/components/OsMenu/OsMenuItem.stories.ts
new file mode 100644
index 000000000..ee28c9a6e
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenuItem.stories.ts
@@ -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 = {
+ 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: `
+
+
+
+
+ {{ route.label }}
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/components/OsMenu/OsMenuItem.visual.spec.ts b/packages/ui/src/components/OsMenu/OsMenuItem.visual.spec.ts
new file mode 100644
index 000000000..5077b9d87
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenuItem.visual.spec.ts
@@ -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)
+ })
+})
diff --git a/packages/ui/src/components/OsMenu/OsMenuItem.vue b/packages/ui/src/components/OsMenu/OsMenuItem.vue
new file mode 100644
index 000000000..94264e2d9
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/OsMenuItem.vue
@@ -0,0 +1,251 @@
+
diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/dropdown.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/dropdown.png
new file mode 100644
index 000000000..0d2b5fd44
Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/dropdown.png differ
diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/menuitem-custom.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/menuitem-custom.png
new file mode 100644
index 000000000..5e57ccf1b
Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/menuitem-custom.png differ
diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/nested.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/nested.png
new file mode 100644
index 000000000..f29beac67
Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/nested.png differ
diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar-active.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar-active.png
new file mode 100644
index 000000000..404c0f28f
Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar-active.png differ
diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar.png
new file mode 100644
index 000000000..104ec083b
Binary files /dev/null and b/packages/ui/src/components/OsMenu/__screenshots__/chromium/sidebar.png differ
diff --git a/packages/ui/src/components/OsMenu/index.ts b/packages/ui/src/components/OsMenu/index.ts
new file mode 100644
index 000000000..524132320
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/index.ts
@@ -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'
diff --git a/packages/ui/src/components/OsMenu/menu.variants.ts b/packages/ui/src/components/OsMenu/menu.variants.ts
new file mode 100644
index 000000000..16cdfc75f
--- /dev/null
+++ b/packages/ui/src/components/OsMenu/menu.variants.ts
@@ -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
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index a10bbef0b..dbe0f867c 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -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'
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index a48e4a49d..881d8cb3b 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -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;
+}
+
diff --git a/webapp/assets/styles/imports/_tooltip.scss b/webapp/assets/styles/imports/_tooltip.scss
index f3f51f576..8db35aa54 100644
--- a/webapp/assets/styles/imports/_tooltip.scss
+++ b/webapp/assets/styles/imports/_tooltip.scss
@@ -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 {
diff --git a/webapp/components/AvatarMenu/AvatarMenu.spec.js b/webapp/components/AvatarMenu/AvatarMenu.spec.js
index 7adedb034..b0e26985b 100644
--- a/webapp/components/AvatarMenu/AvatarMenu.spec.js
+++ b/webapp/components/AvatarMenu/AvatarMenu.spec.js
@@ -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)
})
})
diff --git a/webapp/components/AvatarMenu/AvatarMenu.vue b/webapp/components/AvatarMenu/AvatarMenu.vue
index 82660dd5f..259697134 100644
--- a/webapp/components/AvatarMenu/AvatarMenu.vue
+++ b/webapp/components/AvatarMenu/AvatarMenu.vue
@@ -33,21 +33,21 @@
-
-
-
- {{ item.route.name }}
-
-
+
+
+
+
+ {{ item.route.name }}
+
+
+
@@ -59,7 +59,7 @@
diff --git a/webapp/components/ContentMenu/GroupContentMenu.vue b/webapp/components/ContentMenu/GroupContentMenu.vue
index 78be93062..92ac6585a 100644
--- a/webapp/components/ContentMenu/GroupContentMenu.vue
+++ b/webapp/components/ContentMenu/GroupContentMenu.vue
@@ -20,19 +20,18 @@
@@ -40,7 +39,7 @@
diff --git a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap
index 3f45ded10..7216d5f36 100644
--- a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap
+++ b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap
@@ -60,20 +60,16 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = `
class="group-menu-popover"
>
@@ -225,20 +212,16 @@ exports[`GroupContentMenu renders as groupProfile, muted 1`] = `
class="group-menu-popover"
>
@@ -330,20 +312,16 @@ exports[`GroupContentMenu renders as groupProfile, not muted 1`] = `
class="group-menu-popover"
>
@@ -435,19 +412,16 @@ exports[`GroupContentMenu renders as groupTeaser 1`] = `
class="group-menu-popover"
>
diff --git a/webapp/components/DropdownFilter/DropdownFilter.vue b/webapp/components/DropdownFilter/DropdownFilter.vue
index b70ed8291..ed0e9392c 100644
--- a/webapp/components/DropdownFilter/DropdownFilter.vue
+++ b/webapp/components/DropdownFilter/DropdownFilter.vue
@@ -14,23 +14,23 @@
-
+