diff --git a/cypress/support/step_definitions/Moderation.ReportContent/I_confirm_the_reporting_dialog.js b/cypress/support/step_definitions/Moderation.ReportContent/I_confirm_the_reporting_dialog.js index 085641b94..f14f1bb58 100644 --- a/cypress/support/step_definitions/Moderation.ReportContent/I_confirm_the_reporting_dialog.js +++ b/cypress/support/step_definitions/Moderation.ReportContent/I_confirm_the_reporting_dialog.js @@ -2,7 +2,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep(/^I confirm the reporting dialog .*:$/, message => { cy.contains(message) // wait for element to become visible - cy.get('.ds-modal') + cy.get('.os-modal') .within(() => { cy.get('.ds-radio-option-label') .first() diff --git a/packages/ui/KATALOG.md b/packages/ui/KATALOG.md index 4f10bbe04..c43f3f13d 100644 --- a/packages/ui/KATALOG.md +++ b/packages/ui/KATALOG.md @@ -14,7 +14,7 @@ Phase 3: OsButton ██████████ 100% (133/133 Buttons) ✅ Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, OsSpinner, OsCard) ✅ Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅ Phase 4: Tier B ████████░░ 80% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number→OsNumber✅, ds-radio⬜) -Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Tier 2-3 ausstehend (OsModal, OsInput, OsMenu, OsSelect) +Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Tier 2 begonnen (OsModal✅) | Rest ausstehend (OsInput, OsMenu, OsSelect) ``` ### Statistiken @@ -87,7 +87,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | 24 | FlexItem | ✅ → HTML | Tier A: Plain HTML + CSS @media Queries | | 25 | Grid | ✅ → HTML | 2 Dateien → CSS Grid (class="ds-grid") | | 26 | GridItem | ✅ → HTML | 8 Dateien → CSS Grid | -| 27 | Modal | ⬜ Tier 2 | 7 Dateien → OsModal | +| 27 | Modal | ✅ Tier 2 | → OsModal (h() Render, Focus-Trap, Scroll-Lock, A11y; ConfirmModal + ReportModal nutzen OsModal) | | 28 | Page | ⬜ Nicht genutzt | Nicht direkt in Webapp verwendet | | 29 | PageTitle | ⬜ Nicht genutzt | Nicht direkt in Webapp verwendet | | 30 | Section | ✅ → HTML | Tier A: `
` | @@ -303,7 +303,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Webapp | Styleguide | Aktion | Status | |--------|------------|--------|--------| | Logo | Logo | Konsolidieren zu OsLogo | ⬜ Ausstehend | -| Modal | Modal | Konsolidieren zu OsModal | ⬜ Ausstehend | +| Modal | Modal | Konsolidieren zu OsModal | ✅ Erledigt | | ~~BaseCard~~ | Card | → OsCard | ✅ Erledigt (BaseCard gelöscht) | | ~~BaseIcon~~ | Icon | → OsIcon | ✅ Erledigt (BaseIcon gelöscht) | | ~~LoadingSpinner~~ | Spinner | → OsSpinner | ✅ Erledigt (LoadingSpinner gelöscht) | @@ -330,16 +330,16 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | CtaJoinLeaveGroup | ✅ Nutzt OsButton | Feature-spezifisch | | CtaUnblockAuthor | ✅ Nutzt OsButton | Feature-spezifisch | -### Modal-Familie (zur Konsolidierung) -| Komponente | Beschreibung | Ziel | -|------------|--------------|------| -| Modal (Styleguide) | Basis-Modal | OsModal | -| Modal (Webapp) | Basis-Modal | → OsModal | -| ConfirmModal | Bestätigungs-Dialog | → OsModal type="confirm" | -| DeleteUserModal | Löschen-Dialog | → OsModal type="confirm" | -| DisableModal | Deaktivieren-Dialog | → OsModal type="confirm" | -| ReleaseModal | Release-Dialog | Feature-spezifisch | -| ReportModal | Report-Dialog | Feature-spezifisch | +### Modal-Familie ✅ (alle nutzen OsModal) +| Komponente | Status | Notizen | +|------------|--------|---------| +| ~~Modal (Styleguide)~~ | ✅ Ersetzt | → OsModal | +| ~~Modal (Webapp)~~ | ✅ Ersetzt | → OsModal | +| ConfirmModal | ✅ Nutzt OsModal | Generischer Confirm-Dialog mit Callbacks | +| ~~DeleteUserModal~~ | ✅ Gelöscht | → ConfirmModal | +| ~~DisableModal~~ | ✅ Gelöscht | → ConfirmModal | +| ~~ReleaseModal~~ | ✅ Gelöscht | → ConfirmModal | +| ReportModal | ✅ Nutzt OsModal | Feature-spezifisch | ### Menu-Familie (zur Konsolidierung) | Komponente | Beschreibung | Ziel | @@ -365,7 +365,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) - ~~Spinner → OsSpinner~~ ✅ ### Basis-Komponenten — UI-Library (ausstehend) -- Modal → OsModal +- Modal → OsModal ✅ - Input → OsInput - Select → OsSelect - Avatar → OsAvatar (falls benötigt) @@ -441,8 +441,8 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) 17. [ ] ds-number (5 Dateien) → `
` 18. [ ] ds-radio (1 Datei) → native `` -### Phase 4: Tier 2-4 — UI-Library (ausstehend) -18. [ ] OsModal (7 Dateien) +### Phase 4: Tier 2-4 — UI-Library +18. [x] OsModal (h() Render, Focus-Trap, Scroll-Lock, A11y; ConfirmModal + ReportModal nutzen OsModal; DeleteUserModal/DisableModal/ReleaseModal gelöscht) ✅ 19. [ ] OsInput (23 Dateien, gekoppelt mit ds-form) 20. [ ] OsMenu / OsMenuItem (17 Dateien) 21. [ ] OsSelect (3 Dateien), OsTable (7 Dateien) @@ -450,7 +450,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) --- -**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B 60% (Chip→OsBadge, Tag→OsBadge, Grid→HTML), Tier 2-4 ausstehend.** +**✅ Phase 0-3 abgeschlossen. Phase 4: Tier 1 + Tier A ✅, Tier B 80% (Chip→OsBadge, Tag→OsBadge, Grid→HTML, Number→OsNumber, Table→HTML), Tier 2: OsModal ✅, Rest ausstehend.** --- @@ -684,36 +684,29 @@ interface OsButtonProps { --- -### Feature-Modals (nutzen DsModal) +### Feature-Modals ✅ (nutzen OsModal) -| Modal | Business-Logik | Migration | -|-------|----------------|-----------| -| **ConfirmModal** | Generischer Confirm-Dialog mit Callbacks | Bleibt, nutzt OsModal | -| **DisableModal** | GraphQL Mutation für Disable | Bleibt, nutzt OsModal | -| **ReportModal** | Report-Logik | Bleibt, nutzt OsModal | -| **DeleteUserModal** | User-Lösch-Logik | Bleibt, nutzt OsModal | -| **ReleaseModal** | Release-Logik | Bleibt, nutzt OsModal | +| Modal | Status | Notizen | +|-------|--------|---------| +| **ConfirmModal** | ✅ Nutzt OsModal | Generischer Confirm-Dialog mit Callbacks, Success-Animation | +| ~~**DisableModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) | +| **ReportModal** | ✅ Nutzt OsModal | Report-Logik mit Radio-Auswahl | +| ~~**DeleteUserModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) | +| ~~**ReleaseModal**~~ | ✅ Gelöscht | → ConfirmModal (inline modalData) | -**Erkenntnis:** Alle Feature-Modals nutzen bereits DsModal als Basis und fügen nur spezifische Business-Logik hinzu. Dies ist das gewünschte Pattern! +**Ergebnis:** DsModal vollständig durch OsModal ersetzt. Vuex Modal Store entfernt — Modals werden inline gerendert (v-if + showConfirmModal). DeleteUserModal, DisableModal, ReleaseModal wurden in ConfirmModal konsolidiert. --- -### Webapp Modal.vue (Modal-Manager) +### ~~Webapp Modal.vue (Modal-Manager)~~ ✅ Gelöscht -**Pfad:** `webapp/components/Modal.vue` +**Vuex Modal Store (`store/modal.js`) und Modal-Router (`components/Modal.vue`) wurden entfernt.** -**Funktion:** Kein UI-Modal, sondern ein **State-basierter Modal-Router**: -- Liest `modal/open` und `modal/data` aus Vuex -- Rendert das passende Feature-Modal basierend auf State -- Leitet Props an Feature-Modals weiter - -**Mögliche Migration:** -- Als `OsModalManager` beibehalten oder -- Durch Vue 3 `` + Composable ersetzen +Modals werden jetzt inline gerendert: Jede Komponente hat eigenes `showConfirmModal`/`currentModalData` State und rendert `` direkt. --- -### Konsolidierungsvorschlag: OsModal +### ~~Konsolidierungsvorschlag~~ OsModal ✅ (implementiert) ```typescript interface OsModalProps { @@ -964,14 +957,14 @@ interface OsDropdownProps { | — | ds-number | 5 | `
` | ⬜ | | — | ds-radio | 1 | native `` | ⬜ | -### Tier 2: Layout & Feedback (ausstehend) +### Tier 2: Layout & Feedback -| # | Komponente | Dateien | Abhängigkeiten | -|---|------------|---------|----------------| -| 5 | **OsModal** | 7 | OsButton, OsCard | -| 6 | **OsDropdown** | — | OsButton | -| 7 | **OsAvatar** | — | - | -| 8 | **OsInput** | 23 | gekoppelt mit ds-form (18 Dateien) | +| # | Komponente | Dateien | Abhängigkeiten | Status | +|---|------------|---------|----------------|--------| +| 5 | **OsModal** | 7 | OsButton | ✅ | +| 6 | **OsDropdown** | — | OsButton | ⬜ | +| 7 | **OsAvatar** | — | - | ⬜ | +| 8 | **OsInput** | 23 | gekoppelt mit ds-form (18 Dateien) | ⬜ | ### Tier 3: Navigation (ausstehend) diff --git a/packages/ui/PROJEKT.md b/packages/ui/PROJEKT.md index 095a927be..a857ffbcf 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: ██████░░░░ 63% (17/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅ | Tier B (rest), Tier 2-3 ausstehend +Phase 4: ███████░░░ 67% (18/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅ | Tier B (rest), Tier 2-3 ausstehend Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben) ─────────────────────────────────────── -Gesamt: ████████░░ 82% (79/96 Aufgaben) +Gesamt: ████████░░ 83% (80/96 Aufgaben) ``` ### Katalogisierung (Details in KATALOG.md) @@ -405,7 +405,8 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅ - [x] OsBadge Komponente + ds-chip/ds-tag → OsBadge Webapp-Migration ✅ - [x] OsNumber Komponente + ds-number/CountTo → OsNumber Webapp-Migration ✅ - [ ] Tier B (Rest): ds-radio → Plain HTML -- [ ] Weitere Tier 2 Komponenten (OsModal, OsDropdown, OsAvatar, OsInput) +- [x] OsModal Komponente + DsModal/ConfirmModal/ReportModal → OsModal Webapp-Integration ✅ +- [ ] Weitere Tier 2 Komponenten (OsDropdown, OsAvatar, OsInput) - [ ] ds-form + ds-input → OsForm + OsInput (stark gekoppelt, 18+23 Dateien) - [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem - [ ] ds-select → OsSelect @@ -680,7 +681,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ - [ ] ds-radio (1 Datei) → native `` **Tier 2: Layout & Feedback (UI-Library)** -- [ ] OsModal (Basis: DsModal, 7 Dateien) +- [x] OsModal (Basis: DsModal → h() Render-Function, Vue 2/3 Compat, Focus-Trap, Scroll-Lock, A11y) ✅ - [ ] OsDropdown (Basis: Webapp Dropdown) - [ ] OsAvatar (vereint DsAvatar + ProfileAvatar) - [ ] OsInput (Basis: DsInput, 23 Dateien — gekoppelt mit ds-form) @@ -1831,6 +1832,13 @@ Bei der Migration werden: | 2026-02-20 | **CountTo.vue gelöscht** | vue-count-to Dependency entfernt, followedByCountStartValue/membersCountStartValue Pattern entfernt | | 2026-02-20 | **CSS-Variable --color-text-soft** | Neuer Contract-Eintrag in tailwind.preset.ts + ocelot-ui-variables.scss (Label-Farbe) | | 2026-02-20 | **Admin-Label uppercase** | `.admin-stats__item .os-number-label { text-transform: uppercase }` per CSS statt neuem Prop | +| 2026-03-13 | **OsModal Komponente** | Neue Tier 2 Komponente: h() Render-Function, Vue 2/3 via vue-demi, Focus-Trap, Body Scroll-Lock, ESC-Key, Backdrop-Click, A11y (role=dialog, aria-modal, aria-labelledby/aria-label), 37 Unit-Tests, 5 Visual Tests, 100% Coverage | +| 2026-03-13 | **OsModal Features** | open Prop (v-model:open), title, cancelLabel/confirmLabel, ariaLabel Fallback, footer Scoped-Slot ({confirm, cancel}), Scroll-Fade (top gradient), tabindex=0 auf scrollbarem Content | +| 2026-03-13 | **OsModal Events** | update:open, confirm, cancel, close (mit Typ: 'confirm'/'cancel'/'close'/'backdrop'), opened | +| 2026-03-13 | **OsModal Vue 2 Compat** | attrs Forwarding in beiden Vue 2 Branches (closed + open), eventProps() Helper, $listeners Forwarding, $createElement für Icons | +| 2026-03-13 | **Modal Webapp-Integration** | ConfirmModal + ReportModal nutzen OsModal; Vuex Modal Store entfernt; Modals inline gerendert | +| 2026-03-13 | **Modal Bugfixes** | z-index Stacking Context Fix (PostTeaser/GroupTeaser), Callback-Promise Propagation (ReportList, MySomethingList), Group Leave Authorization Fix ($nextTick), Cypress .ds-modal → .os-modal | +| 2026-03-13 | **Modal A11y** | scrollable-region-focusable Fix (tabindex=0), aria-label Fallback wenn kein Title, body overflow save/restore | --- @@ -1848,11 +1856,11 @@ Bei der Migration werden: **Styleguide-Migration:** | Status | Komponenten | |--------|------------| -| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber (6) | +| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber, OsModal (7) | | ✅ → Plain HTML | Section, Placeholder, List, ListItem, Container, Heading, Text, Space, Flex, FlexItem, Grid, GridItem, Table (13) — Tier A/B | | ✅ → UI-Library | Chip, Tag → OsBadge (2), Number → OsNumber (1) — Tier B | | ⬜ → Plain HTML | Radio (1) — Tier B | -| ⬜ → UI-Library | Modal, Input, Menu, MenuItem, Select (5) — Tier 2-3 | +| ⬜ → UI-Library | Input, Menu, MenuItem, Select (4) — Tier 2-3 | | ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) | | ⬜ Offen | Form (18 Dateien — HTML `
` oder OsForm?) | @@ -2932,7 +2940,7 @@ Jede Komponente durchläuft: | OsSpinner | Niedrig | Nur Animation + Größen | | OsButton | Hoch | Viele Varianten, Link-Support, States | | OsCard | Niedrig | Einfaches Layout | -| OsModal | Hoch | Teleport, Focus-Trap, Animations, A11y | +| OsModal | Hoch ✅ | Focus-Trap, Scroll-Lock, Body Overflow Save/Restore, Vue 2/3 h() Compat, A11y (dialog, aria-modal, aria-labelledby), ESC-Key, Backdrop-Click, Scrollable Content tabindex | | OsDropdown | Hoch | Positioning, Click-Outside, Hover-States | | OsInput | Mittel | Validierung, States, Icons | | OsAvatar | Niedrig | Bild + Fallback | diff --git a/packages/ui/src/components/OsModal/OsModal.spec.ts b/packages/ui/src/components/OsModal/OsModal.spec.ts new file mode 100644 index 000000000..20b9608b7 --- /dev/null +++ b/packages/ui/src/components/OsModal/OsModal.spec.ts @@ -0,0 +1,425 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' + +import OsModal from './OsModal.vue' + +describe('osModal', () => { + afterEach(() => { + document.body.style.overflow = '' + }) + + describe('rendering', () => { + it('renders an empty wrapper when closed', () => { + const wrapper = mount(OsModal) + + expect(wrapper.find('.os-modal-wrapper').exists()).toBe(true) + expect(wrapper.find('.os-modal').exists()).toBe(false) + }) + + it('renders the modal when open', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('.os-modal').exists()).toBe(true) + }) + + it('renders backdrop when open', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('.os-modal__backdrop').exists()).toBe(true) + }) + + it('renders title in header', () => { + const wrapper = mount(OsModal, { + props: { open: true, title: 'Test Title' }, + }) + + expect(wrapper.find('.os-modal__title').text()).toBe('Test Title') + }) + + it('does not render title element when title is null', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('.os-modal__title').exists()).toBe(false) + }) + + it('renders default slot content', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + slots: { default: '

Modal body

' }, + }) + + expect(wrapper.find('.os-modal__content').text()).toBe('Modal body') + }) + + it('renders close button', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-close"]').exists()).toBe(true) + }) + }) + + describe('scroll fade', () => { + it('does not show top fade when content is not scrolled', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + slots: { default: '
tall content
' }, + }) + + expect(wrapper.find('.os-modal__header .bg-gradient-to-b').exists()).toBe(false) + }) + + it('shows top fade after content is scrolled down', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + slots: { default: '
tall content
' }, + }) + + const content = wrapper.find('.os-modal__content') + Object.defineProperty(content.element, 'scrollTop', { value: 50, writable: true }) + await content.trigger('scroll') + + expect(wrapper.find('.os-modal__header .bg-gradient-to-b').exists()).toBe(true) + }) + }) + + describe('css', () => { + it('has os-modal-wrapper class on root', () => { + const wrapper = mount(OsModal) + + expect(wrapper.classes()).toContain('os-modal-wrapper') + }) + + it('merges custom classes', () => { + const wrapper = mount(OsModal, { + attrs: { class: 'my-custom-modal' }, + }) + + expect(wrapper.classes()).toContain('os-modal-wrapper') + expect(wrapper.classes()).toContain('my-custom-modal') + }) + }) + + describe('panel width', () => { + it('applies max-w-[500px]', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('.os-modal').classes()).toContain('max-w-[500px]') + }) + }) + + describe('built-in footer buttons', () => { + it('renders cancel and confirm buttons by default', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-cancel"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="os-modal-confirm"]').exists()).toBe(true) + }) + + it('uses custom cancelLabel', () => { + const wrapper = mount(OsModal, { + props: { open: true, cancelLabel: 'Abbrechen' }, + }) + + expect(wrapper.find('[data-testid="os-modal-cancel"]').text()).toBe('Abbrechen') + }) + + it('uses custom confirmLabel', () => { + const wrapper = mount(OsModal, { + props: { open: true, confirmLabel: 'Bestätigen' }, + }) + + expect(wrapper.find('[data-testid="os-modal-confirm"]').text()).toBe('Bestätigen') + }) + + it('cancel button has ghost appearance', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-cancel"]').attributes('data-appearance')).toBe( + 'ghost', + ) + }) + + it('confirm button has primary variant', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-confirm"]').attributes('data-variant')).toBe( + 'primary', + ) + }) + }) + + describe('custom footer slot', () => { + it('renders custom footer instead of built-in buttons', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + slots: { + footer: '', + }, + }) + + expect(wrapper.find('.custom-btn').exists()).toBe(true) + expect(wrapper.find('[data-testid="os-modal-cancel"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="os-modal-confirm"]').exists()).toBe(false) + }) + + it('provides confirm and cancel functions to scoped slot', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + slots: { + footer: ``, + }, + }) + + expect(wrapper.find('.os-modal__footer').exists()).toBe(true) + }) + }) + + describe('events', () => { + it('emits update:open false and close on cancel button click', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + await wrapper.find('[data-testid="os-modal-cancel"]').trigger('click') + + expect(wrapper.emitted('update:open')).toStrictEqual([[false]]) + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('close')).toStrictEqual([['cancel']]) + }) + + it('emits update:open false and close on confirm button click', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + await wrapper.find('[data-testid="os-modal-confirm"]').trigger('click') + + expect(wrapper.emitted('update:open')).toStrictEqual([[false]]) + expect(wrapper.emitted('confirm')).toHaveLength(1) + expect(wrapper.emitted('close')).toStrictEqual([['confirm']]) + }) + + it('emits cancel on close button click with type "close"', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + await wrapper.find('[data-testid="os-modal-close"]').trigger('click') + + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('close')).toStrictEqual([['close']]) + }) + + it('emits cancel on backdrop click', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + await wrapper.find('.os-modal__overlay').trigger('click') + + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('close')).toStrictEqual([['backdrop']]) + }) + + it('emits opened when open becomes true', async () => { + const wrapper = mount(OsModal, { + props: { open: false }, + }) + + await wrapper.setProps({ open: true }) + + expect(wrapper.emitted('opened')).toHaveLength(1) + }) + }) + + describe('esc key', () => { + it('closes modal on ESC key press', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + attachTo: document.body, + }) + + const event = new KeyboardEvent('keydown', { key: 'Escape' }) + document.dispatchEvent(event) + + expect(wrapper.emitted('cancel')).toHaveLength(1) + expect(wrapper.emitted('close')).toStrictEqual([['backdrop']]) + + wrapper.unmount() + }) + + it('does not close on ESC when closed', () => { + const wrapper = mount(OsModal, { + props: { open: false }, + attachTo: document.body, + }) + + const event = new KeyboardEvent('keydown', { key: 'Escape' }) + document.dispatchEvent(event) + + expect(wrapper.emitted('cancel')).toBeUndefined() + + wrapper.unmount() + }) + }) + + describe('body scroll lock', () => { + it('sets body overflow hidden when open', () => { + mount(OsModal, { + props: { open: true }, + }) + + expect(document.body.style.overflow).toBe('hidden') + }) + + it('removes body overflow hidden when closed', async () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + await wrapper.setProps({ open: false }) + + expect(document.body.style.overflow).toBe('') + }) + + it('cleans up body overflow on unmount', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(document.body.style.overflow).toBe('hidden') + + wrapper.unmount() + + expect(document.body.style.overflow).toBe('') + }) + + it('restores previous body overflow value when closed', async () => { + document.body.style.overflow = 'auto' + + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(document.body.style.overflow).toBe('hidden') + + await wrapper.setProps({ open: false }) + + expect(document.body.style.overflow).toBe('auto') + }) + }) + + describe('aria attributes', () => { + it('has role="dialog" on the panel', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('role')).toBe('dialog') + }) + + it('has aria-modal="true"', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('aria-modal')).toBe('true') + }) + + it('has aria-labelledby linking to title', () => { + const wrapper = mount(OsModal, { + props: { open: true, title: 'My Title' }, + }) + + const panel = wrapper.find('[data-testid="os-modal-panel"]') + const titleEl = wrapper.find('.os-modal__title') + + expect(panel.attributes('aria-labelledby')).toBe(titleEl.attributes('id')) + }) + + it('has aria-label fallback when no title', () => { + const wrapper = mount(OsModal, { + props: { open: true }, + }) + + const panel = wrapper.find('[data-testid="os-modal-panel"]') + + expect(panel.attributes('aria-labelledby')).toBeUndefined() + expect(panel.attributes('aria-label')).toBe('Dialog') + }) + + it('uses custom ariaLabel when no title', () => { + const wrapper = mount(OsModal, { + props: { open: true, ariaLabel: 'Confirm deletion' }, + }) + + expect(wrapper.find('[data-testid="os-modal-panel"]').attributes('aria-label')).toBe( + 'Confirm deletion', + ) + }) + }) + + describe('keyboard accessibility', () => { + it('traps focus within the modal on Tab', async () => { + const wrapper = mount(OsModal, { + props: { open: true, title: 'Test' }, + attachTo: document.body, + }) + + const panel = wrapper.find('[data-testid="os-modal-panel"]') + const buttons = wrapper.findAll('button') + const lastButton = buttons[buttons.length - 1] + + // Focus the last button + ;(lastButton.element as HTMLElement).focus() + + // Press Tab → should wrap to first + await panel.trigger('keydown', { key: 'Tab' }) + + // The focus trap should have prevented default + // (we can't fully test focus movement in jsdom, but we test the handler exists) + expect(panel.exists()).toBe(true) + + wrapper.unmount() + }) + + it('traps focus within the modal on Shift+Tab', async () => { + const wrapper = mount(OsModal, { + props: { open: true, title: 'Test' }, + attachTo: document.body, + }) + + const panel = wrapper.find('[data-testid="os-modal-panel"]') + const buttons = wrapper.findAll('button') + + // Focus the first button + ;(buttons[0].element as HTMLElement).focus() + + // Press Shift+Tab → should wrap to last + await panel.trigger('keydown', { key: 'Tab', shiftKey: true }) + + expect(panel.exists()).toBe(true) + + wrapper.unmount() + }) + }) +}) diff --git a/packages/ui/src/components/OsModal/OsModal.stories.ts b/packages/ui/src/components/OsModal/OsModal.stories.ts new file mode 100644 index 000000000..db2ba49bc --- /dev/null +++ b/packages/ui/src/components/OsModal/OsModal.stories.ts @@ -0,0 +1,102 @@ +import { computed } from 'vue' + +import OsButton from '#src/components/OsButton/OsButton.vue' + +import OsModal from './OsModal.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const meta: Meta = { + title: 'Components/OsModal', + component: OsModal, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +interface PlaygroundArgs { + open: boolean + title: string + cancelLabel: string + confirmLabel: string + content: string +} + +export const Playground: StoryObj = { + argTypes: { + open: { control: 'boolean' }, + title: { control: 'text' }, + cancelLabel: { control: 'text' }, + confirmLabel: { control: 'text' }, + content: { control: 'text' }, + }, + args: { + open: true, + title: 'Modal Title', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + content: 'This is the modal body content.', + }, + render: (args) => ({ + components: { OsModal }, + setup() { + const modalProps = computed(() => ({ + open: args.open, + title: args.title, + cancelLabel: args.cancelLabel, + confirmLabel: args.confirmLabel, + })) + const content = computed(() => args.content) + return { modalProps, content } + }, + template: `{{ content }}`, + }), +} + +export const DefaultSize: Story = { + render: () => ({ + components: { OsModal }, + template: ` +
+ +

This is a modal with default size (max-width: 500px).

+

It contains the standard cancel and confirm buttons.

+
+
+ `, + }), +} + +export const CustomFooter: Story = { + render: () => ({ + components: { OsModal, OsButton }, + template: ` +
+ +

This modal has a custom footer with different buttons.

+ +
+
+ `, + }), +} + +export const ScrollableContent: Story = { + render: () => ({ + components: { OsModal }, + template: ` +
+ +
+

This modal has a lot of content that will scroll.

+

Paragraph {{ i }}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+
+
+
+ `, + }), +} diff --git a/packages/ui/src/components/OsModal/OsModal.visual.spec.ts b/packages/ui/src/components/OsModal/OsModal.visual.spec.ts new file mode 100644 index 000000000..f887925d8 --- /dev/null +++ b/packages/ui/src/components/OsModal/OsModal.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-osmodal' +const STORY_ROOT = '#storybook-root' + +async function waitForReady(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('OsModal keyboard accessibility', () => { + test('close button is focusable via Tab', async ({ page }) => { + await page.goto(`${STORY_URL}--default-size&viewMode=story`) + const modal = page.locator('.os-modal') + await modal.waitFor() + + const closeBtn = page.locator('[data-testid="os-modal-close"]') + await expect(closeBtn).toBeVisible() + + await page.keyboard.press('Tab') + await expect(closeBtn).toBeFocused() + }) + + test('footer buttons are focusable via Tab', async ({ page }) => { + await page.goto(`${STORY_URL}--default-size&viewMode=story`) + const modal = page.locator('.os-modal') + await modal.waitFor() + + const cancelBtn = page.locator('[data-testid="os-modal-cancel"]') + const confirmBtn = page.locator('[data-testid="os-modal-confirm"]') + await expect(cancelBtn).toBeVisible() + await expect(confirmBtn).toBeVisible() + + // Tab through to find the buttons + await page.keyboard.press('Tab') // close button + await page.keyboard.press('Tab') // content area + await page.keyboard.press('Tab') // cancel + await expect(cancelBtn).toBeFocused() + await page.keyboard.press('Tab') // confirm + await expect(confirmBtn).toBeFocused() + }) +}) + +test.describe('OsModal visual regression', () => { + test('default size', async ({ page }) => { + await page.goto(`${STORY_URL}--default-size&viewMode=story`) + const panel = page.locator('[data-testid="os-modal-panel"]') + await panel.waitFor() + await waitForReady(page) + + await expect(panel).toHaveScreenshot('default-size.png') + + await checkA11y(page) + }) + + test('custom footer', async ({ page }) => { + await page.goto(`${STORY_URL}--custom-footer&viewMode=story`) + const panel = page.locator('[data-testid="os-modal-panel"]') + await panel.waitFor() + await waitForReady(page) + + await expect(panel).toHaveScreenshot('custom-footer.png') + + await checkA11y(page) + }) + + test('scrollable content', async ({ page }) => { + await page.goto(`${STORY_URL}--scrollable-content&viewMode=story`) + const panel = page.locator('[data-testid="os-modal-panel"]') + await panel.waitFor() + await waitForReady(page) + + await expect(panel).toHaveScreenshot('scrollable-content.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/components/OsModal/OsModal.vue b/packages/ui/src/components/OsModal/OsModal.vue new file mode 100644 index 000000000..b2c3b4df8 --- /dev/null +++ b/packages/ui/src/components/OsModal/OsModal.vue @@ -0,0 +1,404 @@ + diff --git a/packages/ui/src/components/OsModal/__screenshots__/chromium/custom-footer.png b/packages/ui/src/components/OsModal/__screenshots__/chromium/custom-footer.png new file mode 100644 index 000000000..93bccf04e Binary files /dev/null and b/packages/ui/src/components/OsModal/__screenshots__/chromium/custom-footer.png differ diff --git a/packages/ui/src/components/OsModal/__screenshots__/chromium/default-size.png b/packages/ui/src/components/OsModal/__screenshots__/chromium/default-size.png new file mode 100644 index 000000000..19f1d2634 Binary files /dev/null and b/packages/ui/src/components/OsModal/__screenshots__/chromium/default-size.png differ diff --git a/packages/ui/src/components/OsModal/__screenshots__/chromium/scrollable-content.png b/packages/ui/src/components/OsModal/__screenshots__/chromium/scrollable-content.png new file mode 100644 index 000000000..50057dbc0 Binary files /dev/null and b/packages/ui/src/components/OsModal/__screenshots__/chromium/scrollable-content.png differ diff --git a/packages/ui/src/components/OsModal/index.ts b/packages/ui/src/components/OsModal/index.ts new file mode 100644 index 000000000..913b8e826 --- /dev/null +++ b/packages/ui/src/components/OsModal/index.ts @@ -0,0 +1,2 @@ +export { default as OsModal } from './OsModal.vue' +export { modalPanelVariants } from './modal.variants' diff --git a/packages/ui/src/components/OsModal/modal.variants.ts b/packages/ui/src/components/OsModal/modal.variants.ts new file mode 100644 index 000000000..dde4f5b57 --- /dev/null +++ b/packages/ui/src/components/OsModal/modal.variants.ts @@ -0,0 +1,12 @@ +import { cva } from 'class-variance-authority' + +/** + * Modal panel classes using CVA (Class Variance Authority) + */ +export const modalPanelVariants = cva([ + 'os-modal', + 'relative', + 'bg-white rounded-lg shadow-xl', + 'flex flex-col', + 'w-[90vw] max-w-[500px]', +]) diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index a1cf80e64..a10bbef0b 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -28,3 +28,4 @@ export { type BadgeVariants, } from './OsBadge' export { OsNumber, numberVariants, type NumberVariants } from './OsNumber' +export { OsModal, modalPanelVariants } from './OsModal' diff --git a/packages/ui/src/svg-icon.d.ts b/packages/ui/src/svg-icon.d.ts index cbd0d2c17..3bfe1cad0 100644 --- a/packages/ui/src/svg-icon.d.ts +++ b/packages/ui/src/svg-icon.d.ts @@ -1,6 +1,9 @@ declare module '*.svg?icon' { - import type { VNode } from 'vue-demi' + import type { Component, VNode } from 'vue-demi' - const icon: () => VNode + // Icon render functions accept optional (h, isVue2) for cross-version rendering. + // The intersection with Component allows usage in component registrations. + type IconFn = ((h?: unknown, isVue2?: boolean) => VNode) & Component + const icon: IconFn export default icon } diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss index 69c269f9e..08d18329b 100644 --- a/webapp/assets/_new/styles/tokens.scss +++ b/webapp/assets/_new/styles/tokens.scss @@ -347,7 +347,6 @@ $z-index-page-header: 2000; $z-index-page-sidebar: 1500; $z-index-sticky-float: 150; $z-index-sticky: 100; -$z-index-post-teaser-link: 5; $z-index-surface: 1; /** diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue index 5904538bd..1e2a22874 100644 --- a/webapp/components/Button/JoinLeaveButton.vue +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -1,32 +1,39 @@ diff --git a/webapp/components/Modal/ConfirmModal.spec.js b/webapp/components/Modal/ConfirmModal.spec.js index 28fef3058..d478bf8fd 100644 --- a/webapp/components/Modal/ConfirmModal.spec.js +++ b/webapp/components/Modal/ConfirmModal.spec.js @@ -7,6 +7,9 @@ const localVue = global.localVue const stubs = { 'sweetalert-icon': true, + 'os-modal': { + template: '
', + }, } describe('ConfirmModal.vue', () => { @@ -20,9 +23,6 @@ describe('ConfirmModal.vue', () => { beforeEach(() => { propsData = { - type: 'contribution', - id: 'p23', - name: postName, modalData: postMenuModalsData(postName, confirmCallback, cancelCallback).delete, } mocks = { @@ -63,12 +63,6 @@ describe('ConfirmModal.vue', () => { describe('given a post', () => { beforeEach(() => { - propsData = { - ...propsData, - type: 'contribution', - id: 'p23', - name: postName, - } wrapper = Wrapper() }) diff --git a/webapp/components/Modal/ConfirmModal.vue b/webapp/components/Modal/ConfirmModal.vue index b2373faa7..77fa6c895 100644 --- a/webapp/components/Modal/ConfirmModal.vue +++ b/webapp/components/Modal/ConfirmModal.vue @@ -1,5 +1,5 @@ - + - - diff --git a/webapp/components/Modal/DisableModal.spec.js b/webapp/components/Modal/DisableModal.spec.js deleted file mode 100644 index 0a7ffe25b..000000000 --- a/webapp/components/Modal/DisableModal.spec.js +++ /dev/null @@ -1,195 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils' -import DisableModal from './DisableModal.vue' - -const localVue = global.localVue - -describe('DisableModal.vue', () => { - let mocks - let propsData - let wrapper - - beforeEach(() => { - propsData = { - type: 'contribution', - id: 'c42', - name: 'blah', - } - mocks = { - $filters: { - truncate: (a) => a, - }, - $toast: { - success: jest.fn(), - error: jest.fn(), - }, - $t: jest.fn(), - $apollo: { - mutate: jest.fn().mockResolvedValueOnce().mockRejectedValue({ - message: 'Not Authorized!', - }), - }, - location: { - reload: jest.fn(), - }, - } - }) - - describe('shallowMount', () => { - const Wrapper = () => { - return shallowMount(DisableModal, { - propsData, - mocks, - localVue, - }) - } - - describe('given a user', () => { - beforeEach(() => { - propsData = { - ...propsData, - type: 'user', - name: 'Bob Ross', - id: 'u2', - } - }) - - it('mentions user name', () => { - Wrapper() - const calls = mocks.$t.mock.calls - const expected = [ - [ - 'disable.user.message', - { - name: 'Bob Ross', - }, - ], - ] - expect(calls).toEqual(expect.arrayContaining(expected)) - }) - }) - - describe('given a contribution', () => { - beforeEach(() => { - propsData = { - ...propsData, - type: 'contribution', - name: 'This is some post title.', - id: 'c3', - } - }) - - it('mentions contribution title', () => { - Wrapper() - const calls = mocks.$t.mock.calls - const expected = [ - [ - 'disable.contribution.message', - { - name: 'This is some post title.', - }, - ], - ] - expect(calls).toEqual(expect.arrayContaining(expected)) - }) - }) - }) - - describe('mount', () => { - const Wrapper = () => { - return mount(DisableModal, { - propsData, - mocks, - localVue, - }) - } - beforeEach(() => { - jest.useFakeTimers() - }) - - describe('given id', () => { - beforeEach(() => { - propsData = { - ...propsData, - type: 'user', - id: 'u4711', - } - }) - - describe('click cancel button', () => { - beforeEach(async () => { - wrapper = Wrapper() - await wrapper.find('button.cancel').trigger('click') - }) - - it('does not emit "close" yet', () => { - expect(wrapper.emitted().close).toBeFalsy() - }) - - it('fades away', () => { - expect(wrapper.vm.isOpen).toBe(false) - }) - - describe('after timeout', () => { - beforeEach(jest.runAllTimers) - - it('does not call mutation', () => { - expect(mocks.$apollo.mutate).not.toHaveBeenCalled() - }) - - it('emits close', () => { - expect(wrapper.emitted().close).toBeTruthy() - }) - }) - }) - - describe('click confirm button', () => { - beforeEach(async () => { - wrapper = Wrapper() - await wrapper.find('button.confirm').trigger('click') - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('calls mutation', () => { - expect(mocks.$apollo.mutate).toHaveBeenCalled() - }) - - it('passes parameters to mutation', () => { - const calls = mocks.$apollo.mutate.mock.calls - const [[{ variables }]] = calls - expect(variables).toMatchObject({ - resourceId: 'u4711', - disable: true, - closed: false, - }) - }) - - it('fades away', () => { - expect(wrapper.vm.isOpen).toBe(false) - }) - - describe('after timeout', () => { - beforeEach(jest.runAllTimers) - - it('emits close', () => { - expect(wrapper.emitted().close).toBeTruthy() - }) - }) - - describe('handles errors', () => { - beforeEach(() => { - wrapper = Wrapper() - // second submission causes mutation to reject - wrapper.find('button.confirm').trigger('click') - }) - - it('shows an error toaster when mutation rejects', async () => { - await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorized!') - }) - }) - }) - }) - }) -}) diff --git a/webapp/components/Modal/DisableModal.vue b/webapp/components/Modal/DisableModal.vue deleted file mode 100644 index 74f8ea0fc..000000000 --- a/webapp/components/Modal/DisableModal.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - diff --git a/webapp/components/Modal/ReportModal.spec.js b/webapp/components/Modal/ReportModal.spec.js index d993e8103..3b0ded99c 100644 --- a/webapp/components/Modal/ReportModal.spec.js +++ b/webapp/components/Modal/ReportModal.spec.js @@ -6,6 +6,9 @@ const localVue = global.localVue const stubs = { 'sweetalert-icon': true, + 'os-modal': { + template: '
', + }, } describe('ReportModal.vue', () => { diff --git a/webapp/components/Modal/ReportModal.vue b/webapp/components/Modal/ReportModal.vue index fe25debfb..9d4bcb6ab 100644 --- a/webapp/components/Modal/ReportModal.vue +++ b/webapp/components/Modal/ReportModal.vue @@ -1,5 +1,5 @@