diff --git a/cypress/support/step_definitions/Moderation.ReportContent/I_can_visit_the_post_page.js b/cypress/support/step_definitions/Moderation.ReportContent/I_can_visit_the_post_page.js index 2986a8fc8..a9b8ad33c 100644 --- a/cypress/support/step_definitions/Moderation.ReportContent/I_can_visit_the_post_page.js +++ b/cypress/support/step_definitions/Moderation.ReportContent/I_can_visit_the_post_page.js @@ -3,5 +3,5 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I can visit the post page', () => { cy.contains('Fake news').click() cy.location('pathname').should('contain', '/post') - .get('.base-card .title').should('contain', 'Fake news') + .get('.os-card .title').should('contain', 'Fake news') }) 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 810bf52b8..7f7eb8664 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 @@ -1,7 +1,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I click on "Report Post" from the content menu of the post', () => { - cy.contains('.base-card', 'The Truth about the Holocaust') + cy.contains('.os-card', 'The Truth about the Holocaust') .find('[data-test="content-menu-button"]') .click() diff --git a/cypress/support/step_definitions/Post.Create/the_post_was_saved_successfully.js b/cypress/support/step_definitions/Post.Create/the_post_was_saved_successfully.js index 4850ab432..49769af9d 100644 --- a/cypress/support/step_definitions/Post.Create/the_post_was_saved_successfully.js +++ b/cypress/support/step_definitions/Post.Create/the_post_was_saved_successfully.js @@ -2,7 +2,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the post was saved successfully', () => { cy.task('getValue', 'lastPost').then(lastPost => { - cy.get('.base-card > .title').should('contain', lastPost.title) + cy.get('.os-card .title').should('contain', lastPost.title) cy.get('.content').should('contain', lastPost.content) }) }) diff --git a/cypress/support/step_definitions/Post.Images/the_first_image_should_not_be_displayed_anymore.js b/cypress/support/step_definitions/Post.Images/the_first_image_should_not_be_displayed_anymore.js index f2188a28a..1f8a87d35 100644 --- a/cypress/support/step_definitions/Post.Images/the_first_image_should_not_be_displayed_anymore.js +++ b/cypress/support/step_definitions/Post.Images/the_first_image_should_not_be_displayed_anymore.js @@ -1,9 +1,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the first image should not be displayed anymore', () => { - cy.get('.hero-image') - .children() - .get('.hero-image > .image') + cy.get('.os-card__hero-image > .image') .should('have.length', 1) .and('have.attr', 'src') }) diff --git a/cypress/support/step_definitions/Post.Images/the_post_was_saved_successfully_with_the_{string}_teaser_image.js b/cypress/support/step_definitions/Post.Images/the_post_was_saved_successfully_with_the_{string}_teaser_image.js index fdfb1c84a..e3d0b5bee 100644 --- a/cypress/support/step_definitions/Post.Images/the_post_was_saved_successfully_with_the_{string}_teaser_image.js +++ b/cypress/support/step_definitions/Post.Images/the_post_was_saved_successfully_with_the_{string}_teaser_image.js @@ -1,7 +1,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the post was saved successfully with the {string} teaser image', condition => { - cy.get('.base-card > .title') + cy.get('.os-card .title') .should('contain', condition === 'updated' ? 'to be updated' : 'new post') .get('.content') .should('contain', condition === 'updated' ? 'successfully updated' : 'new post content') diff --git a/cypress/support/step_definitions/Post.Images/the_{string}_post_was_saved_successfully_without_a_teaser_image.js b/cypress/support/step_definitions/Post.Images/the_{string}_post_was_saved_successfully_without_a_teaser_image.js index 39947d029..a5a7a68b3 100644 --- a/cypress/support/step_definitions/Post.Images/the_{string}_post_was_saved_successfully_without_a_teaser_image.js +++ b/cypress/support/step_definitions/Post.Images/the_{string}_post_was_saved_successfully_without_a_teaser_image.js @@ -1,12 +1,12 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the {string} post was saved successfully without a teaser image', condition => { - cy.get(".base-card > .title") + cy.get(".os-card > .title") .should("contain", condition === 'updated' ? 'to be updated' : 'new post') .get(".content") .should("contain", condition === 'updated' ? 'successfully updated' : 'new post content') .get('.post-page') .should('exist') - .get('.hero-image > .image') + .get('.os-card__hero-image > .image') .should('not.exist') }) diff --git a/cypress/support/step_definitions/Post/the_post_shows_up_on_the_newsfeed_at_position_{int}.js b/cypress/support/step_definitions/Post/the_post_shows_up_on_the_newsfeed_at_position_{int}.js index 59484591f..dbe68184d 100644 --- a/cypress/support/step_definitions/Post/the_post_shows_up_on_the_newsfeed_at_position_{int}.js +++ b/cypress/support/step_definitions/Post/the_post_shows_up_on_the_newsfeed_at_position_{int}.js @@ -1,7 +1,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the post shows up on the newsfeed at position {int}', index => { - const selector = `.post-teaser:nth-child(${index}) > .base-card` + const selector = `.post-teaser:nth-child(${index}) > .os-card` cy.get(selector).should('contain', 'previously created post') cy.get(selector).should('contain', 'with some content') }) diff --git a/cypress/support/step_definitions/User.Block/I_should_not_see_{string}_button.js b/cypress/support/step_definitions/User.Block/I_should_not_see_{string}_button.js index ae47405f3..72665d1a0 100644 --- a/cypress/support/step_definitions/User.Block/I_should_not_see_{string}_button.js +++ b/cypress/support/step_definitions/User.Block/I_should_not_see_{string}_button.js @@ -1,6 +1,6 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I should not see {string} button', button => { - cy.get('.base-card .action-buttons') + cy.get('.os-card .action-buttons') .should('have.length', 1) }) diff --git a/cypress/support/step_definitions/User.Block/I_should_see_the_{string}_button.js b/cypress/support/step_definitions/User.Block/I_should_see_the_{string}_button.js index 88d331fa3..3362062e6 100644 --- a/cypress/support/step_definitions/User.Block/I_should_see_the_{string}_button.js +++ b/cypress/support/step_definitions/User.Block/I_should_see_the_{string}_button.js @@ -1,6 +1,6 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I should see the {string} button', button => { - cy.get('.base-card .action-buttons button') + cy.get('.os-card .action-buttons button') .should('contain', button) }) diff --git a/cypress/support/step_definitions/User.Block/they_should_not_see_the_comment_form.js b/cypress/support/step_definitions/User.Block/they_should_not_see_the_comment_form.js index b9dff833d..89216f623 100644 --- a/cypress/support/step_definitions/User.Block/they_should_not_see_the_comment_form.js +++ b/cypress/support/step_definitions/User.Block/they_should_not_see_the_comment_form.js @@ -1,5 +1,5 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('they should not see the comment form', () => { - cy.get('.base-card').children().should('not.have.class', 'comment-form') + cy.get('.os-card').children().should('not.have.class', 'comment-form') }) diff --git a/cypress/support/step_definitions/User.Mute/the_list_of_posts_of_this_user_is_empty.js b/cypress/support/step_definitions/User.Mute/the_list_of_posts_of_this_user_is_empty.js index 7a2f3d7df..970994fc3 100644 --- a/cypress/support/step_definitions/User.Mute/the_list_of_posts_of_this_user_is_empty.js +++ b/cypress/support/step_definitions/User.Mute/the_list_of_posts_of_this_user_is_empty.js @@ -1,6 +1,6 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('the list of posts of this user is empty', () => { - cy.get('.base-card').not('.post-link') + cy.get('.os-card').not('.post-link') cy.get('.main-container').find('.ds-space.hc-empty') }) diff --git a/cypress/support/step_definitions/UserProfile.Avatar/I_cannot_upload_a_picture.js b/cypress/support/step_definitions/UserProfile.Avatar/I_cannot_upload_a_picture.js index 792c6462c..756548952 100644 --- a/cypress/support/step_definitions/UserProfile.Avatar/I_cannot_upload_a_picture.js +++ b/cypress/support/step_definitions/UserProfile.Avatar/I_cannot_upload_a_picture.js @@ -1,7 +1,7 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I cannot upload a picture', () => { - cy.get('.base-card') + cy.get('.os-card') .children() .should('not.have.id', 'customdropzone') .should('have.class', 'profile-avatar') diff --git a/cypress/support/step_definitions/common/I_get_removed_from_his_follower_collection.js b/cypress/support/step_definitions/common/I_get_removed_from_his_follower_collection.js index 36ef62a54..0042b1874 100644 --- a/cypress/support/step_definitions/common/I_get_removed_from_his_follower_collection.js +++ b/cypress/support/step_definitions/common/I_get_removed_from_his_follower_collection.js @@ -1,8 +1,8 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor' defineStep('I get removed from his follower collection', () => { - cy.get('.base-card') + cy.get('.os-card') .not('.post-link') cy.get('.main-container') - .contains('.base-card','is not followed by anyone') + .contains('.os-card','is not followed by anyone') }) diff --git a/packages/ui/PROJEKT.md b/packages/ui/PROJEKT.md index 4ff7c5493..8810d1c59 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: ████░░░░░░ 35% (6/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅ +Phase 4: █████░░░░░ 47% (8/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅, OsCard ✅, BaseCard→OsCard Migration ✅ Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben) ─────────────────────────────────────── -Gesamt: ████████░░ 79% (68/86 Aufgaben) +Gesamt: ████████░░ 81% (70/86 Aufgaben) ``` ### Katalogisierung (Details in KATALOG.md) @@ -142,6 +142,23 @@ OsSpinner: ├─ vue-compat: ✅ h() Render-Function mit isVue2 └─ webapp: ✅ 4 Spinner migriert (DsSpinner + LoadingSpinner → OsSpinner) +BaseCard → OsCard Webapp-Migration: ✅ +├─ ~30 Webapp-Dateien: (lokale Imports) +├─ 3 Template-Dateien: #imageColumn/#topMenu Slots → inline Layout mit --columns CSS +├─ 16 Spec-Dateien: wrapper.classes('base-card') → wrapper.classes('os-card') +├─ 4 Story-Dateien: mit Import +├─ 12 Cypress E2E-Dateien: .base-card → .os-card Selektoren +├─ 2 Cypress-Dateien: .hero-image → .os-card__hero-image +├─ BaseCard.vue Komponente gelöscht +├─ base-components.js Plugin gelöscht (keine Base*.vue mehr) +├─ nuxt.config.js, maintenance config, testSetup.js bereinigt +├─ main.scss: .os-card Regeln (title, ds-section, hero-image, --columns Layout) +├─ CSS Fixes: Tailwind p-6 Override (!important), outline statt border (highlight), +│ child selectors → descendant selectors (hero-image content wrapper) +├─ ContributionForm: Media-Query Selektoren auf .os-card__content korrigiert +├─ ProfileList: .profile-list.os-card Spezifität erhöht (0,3,0 vs 0,2,0) +└─ 0 Template-Nutzungen verbleibend + DsSpinner/LoadingSpinner → OsSpinner Webapp-Migration: ✅ ├─ ImageUploader.vue: LoadingSpinner → OsSpinner (size="lg") ├─ pages/profile: ds-spinner → os-spinner (size="lg") @@ -166,11 +183,30 @@ BaseIcon → OsIcon Webapp-Migration: ✅ ## Aktueller Stand -**Letzte Aktualisierung:** 2026-02-18 (Session 24) +**Letzte Aktualisierung:** 2026-02-19 (Session 25) -**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅ +**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅, OsCard ✅, BaseCard → OsCard Migration ✅ -**Zuletzt abgeschlossen (Session 24 - OsSpinner Webapp-Migration + Refactoring):** +**Zuletzt abgeschlossen (Session 25 - BaseCard → OsCard Webapp-Migration):** +- [x] ~30 Webapp-Dateien: `` → `` mit lokalen Imports +- [x] 3 Template-Dateien mit #imageColumn/#topMenu Slots → inline Layout (LoginForm, RegistrationSlider, password-reset) +- [x] CSS: `.os-card.--columns` Layout in main.scss (flex, image-column, content-column, top-menu, responsive) +- [x] 16 Spec-Dateien: `wrapper.classes('base-card')` → `wrapper.classes('os-card')` +- [x] 4 Story-Dateien: `` → `` mit OsCard-Import +- [x] 12 Cypress E2E Step-Definitions: `.base-card` → `.os-card` Selektoren +- [x] 2 Cypress-Dateien: `.hero-image` → `.os-card__hero-image` +- [x] BaseCard.vue Komponente gelöscht +- [x] `base-components.js` Plugin gelöscht (keine Base*.vue Komponenten mehr) +- [x] Plugin-Referenzen entfernt: nuxt.config.js, nuxt.config.maintenance.js, testSetup.js +- [x] main.scss bereinigt: `.base-card > .ds-section` entfernt, `.os-card` Regeln hinzugefügt +- [x] CSS-Fixes: Tailwind `p-6` Override (`!important`), `outline` statt `border` (highlight), child → descendant selectors +- [x] ContributionForm: Media-Query Selektoren auf `.os-card__content > .buttons-footer` korrigiert +- [x] ProfileList: Spezifität `.profile-list.os-card` erhöht (0,3,0 vs 0,2,0) +- [x] OsCard highlight Tests: `border` → `outline-1` (twMerge), Testnamen aktualisiert +- [x] Kleinere Verbesserungen: SocialMedia Props typisiert, LoginForm querySelector statt fragiler DOM-Traversierung, redundante `` entfernt, NotificationsTable optional chaining +- [x] `hasBaseCard` Property verbleibt in 4 Dateien (rein semantisch, kein Komponentenbezug) + +**Zuvor abgeschlossen (Session 24 - OsSpinner Webapp-Migration + Refactoring):** - [x] OsButton refactored: nutzt `h(OsSpinner, { 'aria-hidden': 'true' })` statt Inline-SVG - [x] OsSpinner: Decorative-Modus (`aria-hidden="true"` unterdrückt role/aria-label) - [x] `ButtonSize` Type exportiert (sm/md/lg/xl), `types.d.ts` Kommentar aktualisiert @@ -263,8 +299,8 @@ BaseIcon → OsIcon Webapp-Migration: ✅ **Nächste Schritte:** - [x] OsSpinner Webapp-Migration (DsSpinner + LoadingSpinner → OsSpinner) ✅ -- [ ] OsCard Komponente (vereint DsCard + BaseCard) -- [ ] Weitere Tier 1 Komponenten +- [x] OsCard Komponente + BaseCard → OsCard Webapp-Migration ✅ +- [ ] Weitere Tier 2 Komponenten (OsModal, OsDropdown, OsAvatar, OsInput) - [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase) **Manuelle Setup-Aufgaben (außerhalb Code):** @@ -519,7 +555,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ - [x] OsSpinner (vereint DsSpinner + LoadingSpinner) ✅ OsButton nutzt OsSpinner als Komponente - [x] OsSpinner Webapp-Migration ✅ 4 Spinner migriert, LoadingSpinner gelöscht, Admin ApolloQuery→$apollo.loading - [x] OsButton (vereint DsButton + BaseButton) ✅ Entwickelt in Phase 2 -- [ ] OsCard (vereint DsCard + BaseCard) +- [x] OsCard (vereint DsCard + BaseCard) ✅ Webapp-Migration abgeschlossen, BaseCard gelöscht **Tier 2: Layout & Feedback** - [ ] OsModal (Basis: DsModal) @@ -1629,6 +1665,12 @@ Bei der Migration werden: | 2026-02-18 | **Admin Spinner Fix** | `` → `apollo`-Option + `$apollo.loading`; SSR-Prefetch verhinderte Loading-State im Client | | 2026-02-18 | **filterStatistics Fix** | `delete data.__typename` → Destructuring `{ __typename, ...rest }` (keine Mutation des Originalobjekts) | | 2026-02-18 | **infinite-loading Spinner-Slot** | OsSpinner im `spinner`-Slot von vue-infinite-loading in 3 Seiten (index, profile, groups); einheitliches Spinner-Design | +| 2026-02-19 | **BaseCard → OsCard Migration** | ~30 Webapp-Dateien: `` → `` mit lokalen Imports; CSS-Fixes für Tailwind p-6 Override, outline highlight, child→descendant selectors | +| 2026-02-19 | **#imageColumn/#topMenu inline** | LoginForm, RegistrationSlider, password-reset: BaseCard-Slots → inline Layout mit `.os-card.--columns` CSS in main.scss | +| 2026-02-19 | **Tests & Stories migriert** | 16 Spec-Dateien, 4 Story-Dateien, 12+2 Cypress E2E Step-Definitions: base-card → os-card Selektoren | +| 2026-02-19 | **BaseCard gelöscht** | BaseCard.vue Komponente + base-components.js Plugin entfernt; nuxt.config, maintenance config, testSetup bereinigt | +| 2026-02-19 | **CSS-Fixes** | ContributionForm Media-Query Selektoren, ProfileList Spezifität, InternalPage $space-small, OsCard highlight outline-1 Tests | +| 2026-02-19 | **Code-Quality** | SocialMedia Props typisiert, LoginForm querySelector, redundante client-only entfernt, NotificationsTable optional chaining, HashtagsFilter doppeltes Mounting | --- diff --git a/packages/ui/package.json b/packages/ui/package.json index 140724e9f..c11476594 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -144,7 +144,7 @@ "size-limit": [ { "path": "dist/index.mjs", - "limit": "15 kB", + "limit": "20 kB", "brotli": true }, { diff --git a/packages/ui/src/components/OsCard/OsCard.spec.ts b/packages/ui/src/components/OsCard/OsCard.spec.ts new file mode 100644 index 000000000..92b411d13 --- /dev/null +++ b/packages/ui/src/components/OsCard/OsCard.spec.ts @@ -0,0 +1,191 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import OsCard from './OsCard.vue' + +describe('osCard', () => { + describe('rendering', () => { + it('renders as div element by default', () => { + const wrapper = mount(OsCard) + + expect((wrapper.element as HTMLElement).tagName).toBe('DIV') + }) + + it('renders default slot content', () => { + const wrapper = mount(OsCard, { + slots: { default: '

Card content

' }, + }) + + expect(wrapper.find('p').text()).toBe('Card content') + }) + + it('renders without content', () => { + const wrapper = mount(OsCard) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.text()).toBe('') + }) + }) + + describe('as prop', () => { + it('renders as article when as="article"', () => { + const wrapper = mount(OsCard, { + props: { as: 'article' }, + }) + + expect((wrapper.element as HTMLElement).tagName).toBe('ARTICLE') + }) + + it('renders as section when as="section"', () => { + const wrapper = mount(OsCard, { + props: { as: 'section' }, + }) + + expect((wrapper.element as HTMLElement).tagName).toBe('SECTION') + }) + + it('renders as aside when as="aside"', () => { + const wrapper = mount(OsCard, { + props: { as: 'aside' }, + }) + + expect((wrapper.element as HTMLElement).tagName).toBe('ASIDE') + }) + }) + + describe('css', () => { + it('has os-card class', () => { + const wrapper = mount(OsCard) + + expect(wrapper.classes()).toContain('os-card') + }) + + it('has padding when no heroImage slot', () => { + const wrapper = mount(OsCard) + + expect(wrapper.classes()).toContain('p-6') + }) + + it('merges custom classes', () => { + const wrapper = mount(OsCard, { + attrs: { class: 'my-custom-class' }, + }) + + expect(wrapper.classes()).toContain('os-card') + expect(wrapper.classes()).toContain('my-custom-class') + }) + + it('passes through attributes', () => { + const wrapper = mount(OsCard, { + attrs: { 'data-testid': 'my-card' }, + }) + + expect(wrapper.attributes('data-testid')).toBe('my-card') + }) + }) + + describe('highlight', () => { + it('does not have outline class by default', () => { + const wrapper = mount(OsCard) + + expect(wrapper.classes()).not.toContain('outline-1') + }) + + it('adds outline class when highlight is true', () => { + const wrapper = mount(OsCard, { + props: { highlight: true }, + }) + + expect(wrapper.classes()).toContain('outline-1') + }) + + it('does not add outline class when highlight is false', () => { + const wrapper = mount(OsCard, { + props: { highlight: false }, + }) + + expect(wrapper.classes()).not.toContain('outline-1') + }) + }) + + describe('heroImage slot', () => { + const heroSlots = { + heroImage: 'Hero', + default: '

Content

', + } + + it('renders heroImage slot content', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + expect(wrapper.find('img').exists()).toBe(true) + expect(wrapper.find('img').attributes('alt')).toBe('Hero') + }) + + it('wraps heroImage in os-card__hero-image div', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + const heroDiv = wrapper.find('.os-card__hero-image') + + expect(heroDiv.exists()).toBe(true) + expect(heroDiv.find('img').exists()).toBe(true) + }) + + it('wraps default content in os-card__content div', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + const contentDiv = wrapper.find('.os-card__content') + + expect(contentDiv.exists()).toBe(true) + expect(contentDiv.find('p').text()).toBe('Content') + }) + + it('does not have padding on card when heroImage is present', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + expect(wrapper.classes()).not.toContain('p-6') + }) + + it('content wrapper has padding when heroImage is present', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + expect(wrapper.find('.os-card__content').classes()).toContain('p-6') + }) + + it('does not create wrapper divs without heroImage slot', () => { + const wrapper = mount(OsCard, { + slots: { default: '

Content

' }, + }) + + expect(wrapper.find('.os-card__hero-image').exists()).toBe(false) + expect(wrapper.find('.os-card__content').exists()).toBe(false) + }) + + it('renders heroImage before content', () => { + const wrapper = mount(OsCard, { slots: heroSlots }) + + const heroImage = wrapper.find('.os-card__hero-image') + const content = wrapper.find('.os-card__content') + + expect(heroImage.exists()).toBe(true) + expect(content.exists()).toBe(true) + expect( + heroImage.element.compareDocumentPosition(content.element) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0) + }) + }) + + describe('keyboard accessibility', () => { + it('is not focusable (non-interactive element)', () => { + const wrapper = mount(OsCard) + + expect(wrapper.attributes('tabindex')).toBeUndefined() + }) + + it('has no interactive role', () => { + const wrapper = mount(OsCard) + + expect(wrapper.attributes('role')).toBeUndefined() + }) + }) +}) diff --git a/packages/ui/src/components/OsCard/OsCard.stories.ts b/packages/ui/src/components/OsCard/OsCard.stories.ts new file mode 100644 index 000000000..012d625f7 --- /dev/null +++ b/packages/ui/src/components/OsCard/OsCard.stories.ts @@ -0,0 +1,161 @@ +import { computed } from 'vue' + +import OsCard from './OsCard.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const HERO_SVG = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200'%3E%3Crect fill='%234a5a9e' width='400' height='200'/%3E%3Ctext x='200' y='105' text-anchor='middle' fill='white' font-size='20' font-family='sans-serif'%3EHero Image%3C/text%3E%3C/svg%3E" + +const meta: Meta = { + title: 'Components/OsCard', + component: OsCard, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +interface PlaygroundArgs { + as: string + highlight: boolean + heroImage: boolean + content: string +} + +export const Playground: StoryObj = { + argTypes: { + as: { + control: 'select', + options: ['div', 'article', 'section', 'aside'], + }, + highlight: { + control: 'boolean', + }, + heroImage: { + control: 'boolean', + }, + content: { + control: 'text', + }, + }, + args: { + as: 'div', + highlight: false, + heroImage: false, + content: 'This is a card with customizable props. Try toggling the controls below.', + }, + render: (args) => ({ + components: { OsCard }, + setup() { + const cardProps = computed(() => ({ + as: args.as, + highlight: args.highlight, + })) + const showHero = computed(() => args.heroImage) + const content = computed(() => args.content) + return { cardProps, showHero, content, HERO_SVG } + }, + template: ` +
+ + +

{{ content }}

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

Card Title

+

Some card content goes here. Cards provide a contained surface for related information.

+
+ +

A minimal card with just text.

+
+ +

Another Card

+

Cards stack naturally in a flex column layout.

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

Centered content via custom class.

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

Pinned Post

+

This card is highlighted with a colored border, used for pinned or featured content.

+
+ +

Normal Post

+

This card has no highlight for comparison.

+
+
+ `, + }), +} + +export const HeroImage: Story = { + render: () => ({ + components: { OsCard }, + setup() { + return { HERO_SVG } + }, + template: ` +
+ + +

Pinned Post with Image

+

Combines hero image, highlight border, and article semantics.

+
+ + +

Post with Hero Image

+

The image spans the full card width. Content below has its own padding.

+
+ +

Card without Image

+

A regular card for comparison.

+
+
+ `, + }), +} diff --git a/packages/ui/src/components/OsCard/OsCard.visual.spec.ts b/packages/ui/src/components/OsCard/OsCard.visual.spec.ts new file mode 100644 index 000000000..dd073f8e0 --- /dev/null +++ b/packages/ui/src/components/OsCard/OsCard.visual.spec.ts @@ -0,0 +1,88 @@ +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-oscard' +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('OsCard keyboard accessibility', () => { + test('card is not focusable (non-interactive element)', async ({ page }) => { + await page.goto(`${STORY_URL}--simple-wrapper&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + const cards = root.locator('.os-card') + const count = await cards.count() + + expect(count).toBeGreaterThan(0) + + for (let i = 0; i < count; i++) { + await expect(cards.nth(i)).not.toHaveAttribute('tabindex') + await expect(cards.nth(i)).not.toHaveAttribute('role') + } + + await page.keyboard.press('Tab') + for (let i = 0; i < count; i++) { + await expect(cards.nth(i)).not.toBeFocused() + } + }) +}) + +test.describe('OsCard visual regression', () => { + test('simple wrapper', async ({ page }) => { + await page.goto(`${STORY_URL}--simple-wrapper&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="simple-wrapper"]')).toHaveScreenshot( + 'simple-wrapper.png', + ) + + await checkA11y(page) + }) + + test('custom class', async ({ page }) => { + await page.goto(`${STORY_URL}--custom-class&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="custom-class"]')).toHaveScreenshot('custom-class.png') + + await checkA11y(page) + }) + + test('highlight', async ({ page }) => { + await page.goto(`${STORY_URL}--highlight&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="highlight"]')).toHaveScreenshot('highlight.png') + + await checkA11y(page) + }) + + test('hero image', async ({ page }) => { + await page.goto(`${STORY_URL}--hero-image&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="hero-image"]')).toHaveScreenshot('hero-image.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/components/OsCard/OsCard.vue b/packages/ui/src/components/OsCard/OsCard.vue new file mode 100644 index 000000000..ceebadcd9 --- /dev/null +++ b/packages/ui/src/components/OsCard/OsCard.vue @@ -0,0 +1,104 @@ + diff --git a/packages/ui/src/components/OsCard/__screenshots__/chromium/custom-class.png b/packages/ui/src/components/OsCard/__screenshots__/chromium/custom-class.png new file mode 100644 index 000000000..0b2fe0080 Binary files /dev/null and b/packages/ui/src/components/OsCard/__screenshots__/chromium/custom-class.png differ diff --git a/packages/ui/src/components/OsCard/__screenshots__/chromium/hero-image.png b/packages/ui/src/components/OsCard/__screenshots__/chromium/hero-image.png new file mode 100644 index 000000000..e73fb40d1 Binary files /dev/null and b/packages/ui/src/components/OsCard/__screenshots__/chromium/hero-image.png differ diff --git a/packages/ui/src/components/OsCard/__screenshots__/chromium/highlight.png b/packages/ui/src/components/OsCard/__screenshots__/chromium/highlight.png new file mode 100644 index 000000000..4754af2e4 Binary files /dev/null and b/packages/ui/src/components/OsCard/__screenshots__/chromium/highlight.png differ diff --git a/packages/ui/src/components/OsCard/__screenshots__/chromium/simple-wrapper.png b/packages/ui/src/components/OsCard/__screenshots__/chromium/simple-wrapper.png new file mode 100644 index 000000000..1d86ca05c Binary files /dev/null and b/packages/ui/src/components/OsCard/__screenshots__/chromium/simple-wrapper.png differ diff --git a/packages/ui/src/components/OsCard/index.ts b/packages/ui/src/components/OsCard/index.ts new file mode 100644 index 000000000..1414d1bb5 --- /dev/null +++ b/packages/ui/src/components/OsCard/index.ts @@ -0,0 +1 @@ +export { default as OsCard } from './OsCard.vue' diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index f416e68f3..fad180acf 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -18,3 +18,4 @@ export { type SystemIconName, } from './OsIcon' export { OsSpinner, SPINNER_SIZES } from './OsSpinner' +export { OsCard } from './OsCard' diff --git a/webapp/assets/styles/main.scss b/webapp/assets/styles/main.scss index 9c43a3e65..4099d811d 100644 --- a/webapp/assets/styles/main.scss +++ b/webapp/assets/styles/main.scss @@ -151,7 +151,7 @@ body.dropdown-open { overflow: hidden; } -.base-card > .ds-section { +.os-card > .ds-section { padding: 0; margin: -$space-base; @@ -160,6 +160,57 @@ body.dropdown-open { } } +.os-card { + > .title, + > .content-column > .title { + font-size: $font-size-large; + margin-bottom: $space-x-small; + } + + > .os-card__hero-image > .image { + width: 100%; + object-fit: contain; + } + + &.--columns { + display: flex; + + > .image-column { + flex-basis: 50%; + display: flex; + justify-content: center; + align-items: center; + padding-right: $space-base; + + .image { + width: 100%; + max-width: 200px; + } + } + + > .content-column { + flex-basis: 50%; + } + + > .top-menu { + position: absolute; + top: $space-small; + left: $space-small; + } + } +} + +@media (max-width: 565px) { + .os-card.--columns { + flex-direction: column; + + > .image-column { + padding-right: 0; + margin-bottom: $space-base; + } + } +} + [class$='menu-popover'] { min-width: 130px; diff --git a/webapp/components/CommentCard/CommentCard.vue b/webapp/components/CommentCard/CommentCard.vue index aaa64dba4..f59eb1347 100644 --- a/webapp/components/CommentCard/CommentCard.vue +++ b/webapp/components/CommentCard/CommentCard.vue @@ -1,11 +1,11 @@ diff --git a/webapp/components/Group/GroupTeaser.vue b/webapp/components/Group/GroupTeaser.vue index 7f98ad3e3..7d4143f1d 100644 --- a/webapp/components/Group/GroupTeaser.vue +++ b/webapp/components/Group/GroupTeaser.vue @@ -3,7 +3,7 @@ class="group-teaser" :to="{ name: 'groups-id-slug', params: { id: group.id, slug: group.slug } }" > - - +
diff --git a/webapp/components/LoginForm/LoginForm.vue b/webapp/components/LoginForm/LoginForm.vue index 3bbff1454..6a7117943 100644 --- a/webapp/components/LoginForm/LoginForm.vue +++ b/webapp/components/LoginForm/LoginForm.vue @@ -4,58 +4,60 @@

{{ $t('quotes.african.quote') }}

- {{ $t('quotes.african.author') }} - - -

{{ $t('login.login') }}

-
- -
+ +
+

{{ $t('login.login') }}

+ - -
- - {{ $t('login.forgotPassword') }} - - - - {{ $t('login.login') }} - -

- {{ $t('login.no-account') }} - {{ $t('login.register') }} -

- - -
+ +
@@ -66,7 +68,7 @@ import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParams import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch' import Logo from '~/components/Logo/Logo' import ShowPassword from '../ShowPassword/ShowPassword.vue' -import { OsButton, OsIcon } from '@ocelot-social/ui' +import { OsButton, OsCard, OsIcon } from '@ocelot-social/ui' import { iconRegistry } from '~/utils/iconRegistry' import { mapGetters, mapMutations } from 'vuex' @@ -75,6 +77,7 @@ export default { LocaleSwitch, Logo, OsButton, + OsCard, OsIcon, PageParamsLink, ShowPassword, @@ -135,7 +138,7 @@ export default { toggleShowPassword() { this.showPassword = !this.showPassword this.$nextTick(() => { - this.$refs.password.$el.children[1].children[1].focus() + this.$refs.password.$el.querySelector('input').focus() this.$emit('focus') }) }, diff --git a/webapp/components/NotificationsTable/NotificationsTable.vue b/webapp/components/NotificationsTable/NotificationsTable.vue index fc1430d8b..f448334fd 100644 --- a/webapp/components/NotificationsTable/NotificationsTable.vue +++ b/webapp/components/NotificationsTable/NotificationsTable.vue @@ -18,74 +18,66 @@ - - - - - + - -
- -
- - -
- - -
- - - {{ - notification.from.title || - notification.from.groupName || - notification.from.post.title | truncate(50) - }} - - -

- {{ - notification.from.contentExcerpt || - notification.from.descriptionExcerpt | removeHtml - }} -

-
+
+ +
+ +
- + + +
+ + + {{ + notification.from.title || + notification.from.groupName || + (notification.from.post && notification.from.post.title) | truncate(50) + }} + + +

+ {{ + notification.from.contentExcerpt || + notification.from.descriptionExcerpt | removeHtml + }} +

+
+
@@ -98,7 +90,6 @@ import { OsIcon } from '@ocelot-social/ui' import { iconRegistry } from '~/utils/iconRegistry' import UserTeaser from '~/components/UserTeaser/UserTeaser' import HcEmpty from '~/components/Empty/Empty' -import BaseCard from '../_new/generic/BaseCard/BaseCard.vue' import mobile from '~/mixins/mobile' const maxMobileWidth = 768 // at this point the table breaks down @@ -109,7 +100,6 @@ export default { OsIcon, UserTeaser, HcEmpty, - BaseCard, }, props: { notifications: { type: Array, default: () => [] }, @@ -181,11 +171,6 @@ export default { .notification-grid .content-section { flex-wrap: nowrap; } -.notification-grid .base-card { - border-radius: 0; - box-shadow: none; - padding: 16px 4px; -} /* dirty fix to override broken styleguide inline-styles */ .notification-grid .ds-grid { grid-template-columns: 5fr 6fr !important; @@ -201,9 +186,9 @@ export default { &:nth-child(odd) { background-color: $color-neutral-90; } - .base-card { + + > .ds-grid > div { padding: 8px 0; - background-color: unset !important; } } @media screen and (max-width: 768px) { diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 738f2a591..6d92b2126 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -3,7 +3,7 @@ class="post-teaser" :to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }" > - {{ $t('site.back-to-login') }}
- - + +
+ + - - + - + - + - - - - - - - + +
- - diff --git a/webapp/components/_new/generic/TabNavigation/TabNavigation.vue b/webapp/components/_new/generic/TabNavigation/TabNavigation.vue index ff06c42d9..7ff4cda8f 100644 --- a/webapp/components/_new/generic/TabNavigation/TabNavigation.vue +++ b/webapp/components/_new/generic/TabNavigation/TabNavigation.vue @@ -1,6 +1,6 @@ diff --git a/webapp/pages/groups/create.vue b/webapp/pages/groups/create.vue index 88350041f..f4ea5b7c3 100644 --- a/webapp/pages/groups/create.vue +++ b/webapp/pages/groups/create.vue @@ -5,7 +5,7 @@ - + @@ -16,17 +16,19 @@   - +