diff --git a/packages/ui/.storybook/storybook.css b/packages/ui/.storybook/storybook.css index ba41e93b2..b94eb33b4 100644 --- a/packages/ui/.storybook/storybook.css +++ b/packages/ui/.storybook/storybook.css @@ -52,4 +52,7 @@ /* Disabled (Payne's Grey - muted watercolor grey) */ --color-disabled: #c4bdb5; --color-disabled-contrast: #5a4f45; + + /* Text */ + --color-text-soft: #6b5e7b; } diff --git a/packages/ui/KATALOG.md b/packages/ui/KATALOG.md index 2b402bfa0..4f10bbe04 100644 --- a/packages/ui/KATALOG.md +++ b/packages/ui/KATALOG.md @@ -13,7 +13,7 @@ Phase 0: Analyse ██████████ 100% (8/8 Schritte) ✅ 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 ██████░░░░ 60% (ds-chip→OsBadge✅, ds-tag→OsBadge✅, ds-grid✅, ds-number⬜, ds-radio⬜) +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) ``` @@ -23,10 +23,11 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | Webapp Komponenten | 139 | | Styleguide Komponenten | 38 (23 in Webapp genutzt) | | **Gesamt** | **177** | -| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge (5) | +| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber (6) | | ✅ → Plain HTML | Section, Placeholder, List, ListItem, Container, Heading, Text, Space, Flex, FlexItem, Grid, GridItem, Table (13) | | ✅ → OsBadge | Chip (20 Nutzungen → OsBadge), Tag (3 → OsBadge shape="square") | -| ⬜ → Plain HTML | Number, Radio (2) — Tier B Rest | +| ✅ → OsNumber | Number (5 Nutzungen → OsNumber, CountTo.vue gelöscht, vue-count-to entfernt) | +| ⬜ → Plain HTML | Radio (1) — Tier B Rest | | ⬜ → UI-Library | Modal, Input, Menu, MenuItem, Select (5) — Tier 2-3 | | ⬜ Offen | Form (18 Dateien — HTML oder OsForm?) | | ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) | @@ -57,7 +58,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | 3 | Chip | ✅ UI-Library | → OsBadge (20 Nutzungen in 5 Dateien) | | 4 | Code | ⬜ Nicht genutzt | Nicht in Webapp verwendet | | 5 | Icon | ✅ UI-Library | → OsIcon (BaseIcon gelöscht, 82 Ocelot-Icons) | -| 6 | Number | ⬜ Tier B | 5 Dateien → Plain HTML `
` | +| 6 | Number | ✅ UI-Library | → OsNumber (5 Dateien, CountTo.vue gelöscht, vue-count-to entfernt) | | 7 | Placeholder | ✅ → HTML | Tier A: `
` | | 8 | Spinner | ✅ UI-Library | → OsSpinner (LoadingSpinner gelöscht) | | 9 | Table | ✅ → HTML | 7 Dateien → Plain HTML `` + CSS-Klassen (kein OsTable nötig) | @@ -157,7 +158,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅) | 27 | ContextMenu | ⬜ Ausstehend | Navigation | Menu | | | 28 | ContributionForm | ✅ Migriert | Feature | Form | Buttons → OsButton, ds-* → HTML | | 29 | CounterIcon | ⬜ Ausstehend | Display | Icon | | -| 30 | CountTo | ⬜ Ausstehend | Display | Number | Animation | +| 30 | ~~CountTo~~ | ✅ Gelöscht | Display | Number | → OsNumber (Animation eingebaut, vue-count-to entfernt) | | 31 | CreateInvitation | ⬜ Ausstehend | Feature | | | | 32 | CtaJoinLeaveGroup | ✅ Migriert | Button | Button | 🔄 Button-Familie, nutzt OsButton | | 33 | CtaUnblockAuthor | ✅ Migriert | Button | Button | Nutzt OsButton (icon, as="nuxt-link") | diff --git a/packages/ui/PROJEKT.md b/packages/ui/PROJEKT.md index 9cf99d18d..095a927be 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: ██████░░░░ 59% (16/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅ | Tier B (rest), Tier 2-3 ausstehend +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 5: ░░░░░░░░░░ 0% (0/7 Aufgaben) ─────────────────────────────────────── -Gesamt: ████████░░ 81% (78/96 Aufgaben) +Gesamt: ████████░░ 82% (79/96 Aufgaben) ``` ### Katalogisierung (Details in KATALOG.md) @@ -186,9 +186,20 @@ Tier A ds-* → Plain HTML + CSS: ✅ ├─ ds-flex/ds-flex-item: JavaScript window.innerWidth → CSS @media Queries │ (kein Layout-Shift bei SSR, bessere Performance) ├─ system.css bleibt geladen — bestehende CSS-Klassen funktionieren weiter -├─ Verbleibend: 9 ds-* Komponenten (Tier B Rest: 2 einfache, Tier C: 6 komplexe → UI-Library) +├─ Verbleibend: 8 ds-* Komponenten (Tier B Rest: 1 einfache, Tier C: 6 komplexe → UI-Library) └─ 0 Tier-A ds-* Komponenten-Tags verbleibend +ds-number → OsNumber (UI-Library): ✅ +├─ OsNumber Komponente: h() Render-Function, requestAnimationFrame Animation, inheritAttrs: false +├─ Props: count (required), label (optional), animated (optional) +├─ Animation: 1500ms ease-out, watch(count) re-animiert, SSR-safe (onMounted) +├─ Styling: tabular-nums + min-width für stabile Breite, --color-text-soft Label-Farbe +├─ ds-number + CountTo: 5 Dateien → (UserTeaserPopover, TabNavigation, admin, profile, groups) +├─ vue-count-to Dependency entfernt, CountTo.vue gelöscht +├─ CSS-Variable: --color-text-soft in requiredCssVariables + ocelot-ui-variables.scss +├─ 11 Unit-Tests, 5 Stories, 5 Visual Tests + 1 Keyboard Test +└─ 0 ds-number/CountTo Nutzungen verbleibend + ds-chip + ds-tag → OsBadge (UI-Library): ✅ ├─ OsBadge Komponente: CVA-Varianten, h() Render-Function, inheritAttrs: false ├─ Props: variant (default/primary/danger), size (sm/md/lg), shape (pill/square) @@ -205,20 +216,27 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅ ## Aktueller Stand -**Letzte Aktualisierung:** 2026-02-20 (Session 31) +**Letzte Aktualisierung:** 2026-02-20 (Session 32) -**Aktuelle Phase:** Phase 4 - Tier 1 ✅, Tier A ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅ | Tier B (rest), Tier 2-3 ausstehend +**Aktuelle Phase:** Phase 4 - Tier 1 ✅, Tier A ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅ | Tier B (rest), Tier 2-3 ausstehend -**Zuletzt abgeschlossen (Session 31 - ds-table → Plain HTML):** +**Zuletzt abgeschlossen (Session 32 - OsNumber: ds-number + CountTo → OsNumber):** +- [x] OsNumber Komponente in packages/ui erstellt (h() Render-Function, requestAnimationFrame Animation) +- [x] Props: count (required), label (optional), animated (optional, 1500ms ease-out) +- [x] Animation: requestAnimationFrame-Loop, watch(count) re-animiert von oldVal→newVal +- [x] Stabile Breite: `tabular-nums` + `min-width: Nch` basierend auf Zielwert-Ziffernanzahl +- [x] CSS-Variable `--color-text-soft` in tailwind.preset.ts (requiredCssVariables), Storybook-Theme, ocelot-ui-variables.scss +- [x] 5 Webapp-Dateien migriert: UserTeaserPopover (statisch), TabNavigation (animated), admin/index (animated), profile/_slug (animated), groups/_slug (animated) +- [x] CountTo.vue gelöscht, `vue-count-to` Dependency aus package.json entfernt +- [x] `followedByCountStartValue` / `membersCountStartValue` Pattern entfernt (OsNumber watch-basiert) +- [x] ds-number CSS aus `_ds-compat.scss` entfernt +- [x] Admin-Dashboard: `.os-number-label { text-transform: uppercase }` per CSS (kein neuer Prop) +- [x] Test-Fixes: Fehlende Count-Properties in Mock-Daten (followedByCount, contributionsCount, membersCount etc.) +- [x] 11 Unit-Tests, 5 Stories, 5 Visual + A11y Tests + +**Zuvor abgeschlossen (Session 31 - ds-table → Plain HTML):** - [x] ds-table (7 Nutzungen) → native `
` + CSS-Klassen (kein OsTable nötig) - [x] Table-CSS in `_ds-compat.scss`: .ds-table-wrap, .ds-table, .ds-table-col, .ds-table-head-col, .ds-table-bordered, .ds-table-condensed, Alignment-Klassen -- [x] `pages/admin/hashtags.vue`: 4 Spalten (index, id-Link, taggedCountUnique, taggedCount) -- [x] `pages/admin/categories.vue`: 3 Spalten (icon, name, postCount) -- [x] `pages/admin/users/index.vue`: 9-10 Spalten (conditional badges), komplexeste Tabelle -- [x] `pages/settings/blocked-users.vue`: 4 Spalten, unblockUser() auf direktes User-Objekt umgestellt -- [x] `pages/settings/muted-users.vue`: 4 Spalten, unmuteUser() auf direktes User-Objekt umgestellt -- [x] `components/Group/GroupMember.vue`: 5 Spalten (avatar, name, slug, roleInGroup, edit) -- [x] `components/features/FiledReportsTable/FiledReportsTable.vue`: 4 Spalten mit colgroup widths - [x] `fields()` / `tableFields()` Computed Properties aus allen 7 Dateien entfernt (Labels direkt in `
`) - [x] Alle 16 Tests bestanden (3 Test-Suites: admin/users Snapshots aktualisiert, FiledReportsTable ✅, ReportsTable ✅) @@ -261,8 +279,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 (8 Typen):** -- Tier B Rest (→ Plain HTML): ds-number (5), ds-radio (1) +**Verbleibende ds-* Komponenten (7 Typen):** +- Tier B Rest (→ Plain HTML): ds-radio (1) - Tier C (→ UI-Library): ds-input (23), ds-form (18), ds-modal (7), ds-menu/ds-menu-item (17), ds-select (3) **Zuvor abgeschlossen (Session 26 - CodeRabbit Review Fixes):** @@ -385,11 +403,12 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅ - [x] OsCard Komponente + BaseCard → OsCard Webapp-Migration ✅ - [x] Tier A: 10 triviale ds-* Wrapper → Plain HTML + CSS ✅ - [x] OsBadge Komponente + ds-chip/ds-tag → OsBadge Webapp-Migration ✅ -- [ ] Tier B (Rest): ds-number, ds-grid/ds-grid-item, ds-radio → Plain HTML +- [x] OsNumber Komponente + ds-number/CountTo → OsNumber Webapp-Migration ✅ +- [ ] Tier B (Rest): ds-radio → Plain HTML - [ ] Weitere Tier 2 Komponenten (OsModal, OsDropdown, OsAvatar, OsInput) - [ ] ds-form + ds-input → OsForm + OsInput (stark gekoppelt, 18+23 Dateien) - [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem -- [ ] ds-table → OsTable, ds-select → OsSelect +- [ ] ds-select → OsSelect - [ ] 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):** @@ -656,7 +675,7 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ **Tier B: Einfache ds-* → Plain HTML / UI-Library** - [x] ds-chip (5 Dateien) → OsBadge (UI-Library) ✅ - [x] ds-tag (3 Dateien) → OsBadge shape="square" (UI-Library) ✅ -- [ ] ds-number (5 Dateien) → `
` +- [x] ds-number (5 Dateien) → OsNumber (UI-Library) ✅ + CountTo.vue gelöscht, vue-count-to entfernt - [x] ds-grid / ds-grid-item (10 Dateien) → CSS Grid ✅ - [ ] ds-radio (1 Datei) → native `` @@ -1806,6 +1825,12 @@ Bei der Migration werden: | 2026-02-20 | **Table-CSS** | _ds-compat.scss erweitert: .ds-table-wrap, .ds-table, .ds-table-col, .ds-table-head-col, bordered, condensed, alignment | | 2026-02-20 | **fields() entfernt** | Computed Properties `fields()`/`tableFields()` aus 7 Dateien entfernt — Labels direkt in `
` | | 2026-02-20 | **Scope-Objekte entfernt** | `scope.row` Zugriffe → direkte Iteration-Variable (user, tag, member, report) | +| 2026-02-20 | **OsNumber Komponente (Session 32)** | Neue Komponente: h() Render-Function, requestAnimationFrame Animation (1500ms ease-out), count (required), label, animated Props | +| 2026-02-20 | **ds-number + CountTo → OsNumber** | 5 Dateien: UserTeaserPopover, TabNavigation, admin/index, profile/_slug, groups/_slug | +| 2026-02-20 | **Animation-Stabilität** | `tabular-nums` + `min-width: Nch` für stabile Breite während Count-up Animation | +| 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 | --- @@ -1823,10 +1848,10 @@ Bei der Migration werden: **Styleguide-Migration:** | Status | Komponenten | |--------|------------| -| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge (5) | +| ✅ UI-Library | OsButton, OsIcon, OsSpinner, OsCard, OsBadge, OsNumber (6) | | ✅ → 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) — Tier B | -| ⬜ → Plain HTML | Number, Radio (2) — Tier 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 | | ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) | | ⬜ Offen | Form (18 Dateien — HTML `
` oder OsForm?) | diff --git a/packages/ui/src/components/OsNumber/OsNumber.spec.ts b/packages/ui/src/components/OsNumber/OsNumber.spec.ts new file mode 100644 index 000000000..19c22b514 --- /dev/null +++ b/packages/ui/src/components/OsNumber/OsNumber.spec.ts @@ -0,0 +1,268 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import OsNumber from './OsNumber.vue' + +describe('osNumber', () => { + let rafCallbacks: ((time: number) => void)[] + let mockTime: number + + beforeEach(() => { + rafCallbacks = [] + mockTime = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation( + // eslint-disable-next-line promise/prefer-await-to-callbacks + (cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }, + ) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function flushAnimation() { + mockTime += 1500 + let safety = 0 + while (rafCallbacks.length > 0 && safety++ < 100) { + const fn = rafCallbacks.shift() as (time: number) => void + fn(mockTime) + } + } + + describe('rendering', () => { + it('renders as div element', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + }) + + expect((wrapper.element as HTMLElement).tagName).toBe('DIV') + }) + + it('has os-number class', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + }) + + expect(wrapper.classes()).toContain('os-number') + }) + + it('displays count as text', () => { + const wrapper = mount(OsNumber, { + props: { count: 42 }, + }) + + expect(wrapper.find('.os-number-count').text()).toBe('42') + }) + + it('displays 0 when count is 0', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + }) + + expect(wrapper.find('.os-number-count').text()).toBe('0') + }) + }) + + describe('label', () => { + it('shows label when set', () => { + const wrapper = mount(OsNumber, { + props: { count: 0, label: 'Followers' }, + }) + + expect(wrapper.find('.os-number-label').exists()).toBe(true) + expect(wrapper.find('.os-number-label').text()).toBe('Followers') + }) + + it('hides label when not set', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + }) + + expect(wrapper.find('.os-number-label').exists()).toBe(false) + }) + }) + + describe('css', () => { + it('merges custom classes', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + attrs: { class: 'my-custom-class' }, + }) + + expect(wrapper.classes()).toContain('os-number') + expect(wrapper.classes()).toContain('my-custom-class') + }) + + it('passes through attributes', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + attrs: { 'data-testid': 'my-number' }, + }) + + expect(wrapper.attributes('data-testid')).toBe('my-number') + }) + + it('applies count styling classes', () => { + const wrapper = mount(OsNumber, { + props: { count: 42 }, + }) + + expect(wrapper.find('.os-number-count').classes()).toContain('font-bold') + expect(wrapper.find('.os-number-count').classes()).toContain('text-[1.5rem]') + }) + + it('applies label styling classes', () => { + const wrapper = mount(OsNumber, { + props: { count: 0, label: 'Test' }, + }) + + expect(wrapper.find('.os-number-label').classes()).toContain('text-[12px]') + expect(wrapper.find('.os-number-label').classes()).toContain('text-[var(--color-text-soft)]') + }) + }) + + describe('animation', () => { + it('starts at 0 when animated is true', () => { + const wrapper = mount(OsNumber, { + props: { count: 100, animated: true }, + }) + + expect(wrapper.find('.os-number-count').text()).toBe('0') + }) + + it('hides animated count from screen readers and provides live region', () => { + const wrapper = mount(OsNumber, { + props: { count: 100, animated: true }, + }) + + expect(wrapper.find('.os-number-count').attributes('aria-hidden')).toBe('true') + + const liveRegion = wrapper.find('[aria-live="polite"]') + + expect(liveRegion.exists()).toBe(true) + expect(liveRegion.text()).toBe('100') + expect(liveRegion.classes()).toContain('sr-only') + }) + + it('does not add aria attributes when not animated', () => { + const wrapper = mount(OsNumber, { + props: { count: 42 }, + }) + + expect(wrapper.find('.os-number-count').attributes('aria-hidden')).toBeUndefined() + expect(wrapper.find('[aria-live]').exists()).toBe(false) + }) + + it('animates to target value after mount', async () => { + const wrapper = mount(OsNumber, { + props: { count: 100, animated: true }, + }) + + flushAnimation() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.os-number-count').text()).toBe('100') + }) + + it('re-animates when count changes', async () => { + const wrapper = mount(OsNumber, { + props: { count: 50, animated: true }, + }) + + flushAnimation() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.os-number-count').text()).toBe('50') + + await wrapper.setProps({ count: 100 }) + flushAnimation() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.os-number-count').text()).toBe('100') + }) + + it('shows intermediate value during animation', async () => { + const wrapper = mount(OsNumber, { + props: { count: 100, animated: true }, + }) + + mockTime += 750 + const fn = rafCallbacks.shift() as (time: number) => void + fn(mockTime) + await wrapper.vm.$nextTick() + + const intermediate = Number(wrapper.find('.os-number-count').text()) + + expect(intermediate).toBeGreaterThan(0) + expect(intermediate).toBeLessThan(100) + }) + + it('updates value directly when not animated', async () => { + const wrapper = mount(OsNumber, { + props: { count: 50, animated: false }, + }) + + expect(wrapper.find('.os-number-count').text()).toBe('50') + + await wrapper.setProps({ count: 100 }) + + expect(wrapper.find('.os-number-count').text()).toBe('100') + }) + + it('cancels previous animation when count changes rapidly', async () => { + const wrapper = mount(OsNumber, { + props: { count: 50, animated: true }, + }) + + // First animation starts on mount + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)) + + await wrapper.setProps({ count: 200 }) + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(expect.any(Number)) + + flushAnimation() + await wrapper.vm.$nextTick() + + expect(wrapper.find('.os-number-count').text()).toBe('200') + }) + + it('cancels animation on unmount', () => { + const wrapper = mount(OsNumber, { + props: { count: 100, animated: true }, + }) + + // Animation is running + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)) + + wrapper.unmount() + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(expect.any(Number)) + }) + + it('does not cancel animation on unmount when not animated', () => { + const wrapper = mount(OsNumber, { + props: { count: 100 }, + }) + + wrapper.unmount() + + expect(window.cancelAnimationFrame).not.toHaveBeenCalled() + }) + }) + + describe('keyboard accessibility', () => { + it('is not focusable (non-interactive element)', () => { + const wrapper = mount(OsNumber, { + props: { count: 0 }, + }) + + expect(wrapper.attributes('tabindex')).toBeUndefined() + }) + }) +}) diff --git a/packages/ui/src/components/OsNumber/OsNumber.stories.ts b/packages/ui/src/components/OsNumber/OsNumber.stories.ts new file mode 100644 index 000000000..e583cef00 --- /dev/null +++ b/packages/ui/src/components/OsNumber/OsNumber.stories.ts @@ -0,0 +1,111 @@ +import { computed } from 'vue' + +import OsNumber from './OsNumber.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const meta: Meta = { + title: 'Components/OsNumber', + component: OsNumber, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +interface PlaygroundArgs { + count: number + label: string + animated: boolean +} + +export const Playground: StoryObj = { + argTypes: { + count: { + control: 'number', + }, + label: { + control: 'text', + }, + animated: { + control: 'boolean', + }, + }, + args: { + count: 42, + label: 'Followers', + animated: false, + }, + render: (args) => ({ + components: { OsNumber }, + setup() { + const numberProps = computed(() => ({ + count: args.count, + label: args.label, + animated: args.animated, + })) + const remountKey = computed(() => (args.animated ? Date.now() : 'static')) + return { numberProps, remountKey } + }, + template: ``, + }), +} + +export const StaticCount: Story = { + render: () => ({ + components: { OsNumber }, + template: ` +
+ + + +
+ `, + }), +} + +export const WithLabel: Story = { + render: () => ({ + components: { OsNumber }, + template: ` +
+ + + +
+ `, + }), +} + +export const Animated: Story = { + render: () => ({ + components: { OsNumber }, + template: ` +
+ +
+ `, + }), +} + +export const MultipleCounters: Story = { + render: () => ({ + components: { OsNumber }, + template: ` +
+
+ + + + +
+
+ + + + +
+
+ `, + }), +} diff --git a/packages/ui/src/components/OsNumber/OsNumber.visual.spec.ts b/packages/ui/src/components/OsNumber/OsNumber.visual.spec.ts new file mode 100644 index 000000000..a9644288a --- /dev/null +++ b/packages/ui/src/components/OsNumber/OsNumber.visual.spec.ts @@ -0,0 +1,95 @@ +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-osnumber' +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('OsNumber keyboard accessibility', () => { + test('number is not focusable (non-interactive element)', async ({ page }) => { + await page.goto(`${STORY_URL}--static-count&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + const numbers = root.locator('.os-number') + const count = await numbers.count() + + expect(count).toBeGreaterThan(0) + + for (let i = 0; i < count; i++) { + await expect(numbers.nth(i)).not.toHaveAttribute('tabindex') + await expect(numbers.nth(i)).not.toHaveAttribute('role') + } + + await page.keyboard.press('Tab') + for (let i = 0; i < count; i++) { + await expect(numbers.nth(i)).not.toBeFocused() + } + }) +}) + +test.describe('OsNumber visual regression', () => { + test('static count', async ({ page }) => { + await page.goto(`${STORY_URL}--static-count&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="static-count"]')).toHaveScreenshot('static-count.png') + + await checkA11y(page) + }) + + test('with label', async ({ page }) => { + await page.goto(`${STORY_URL}--with-label&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + await expect(root.locator('[data-testid="with-label"]')).toHaveScreenshot('with-label.png') + + await checkA11y(page) + }) + + test('animated', async ({ page }) => { + await page.goto(`${STORY_URL}--animated&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + // Wait for animation to complete (first counter animates to 128) + await expect(root.locator('.os-number-count').first()).toHaveText('128', { timeout: 3000 }) + + await expect(root.locator('[data-testid="animated"]')).toHaveScreenshot('animated.png') + + await checkA11y(page) + }) + + test('multiple counters', async ({ page }) => { + await page.goto(`${STORY_URL}--multiple-counters&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForReady(page) + + // Wait for animated counters to finish (second row has animated OsNumber instances) + const animatedCounters = root.locator('.os-number-count') + await expect(animatedCounters.nth(4)).toHaveText('156', { timeout: 3000 }) + + await expect(root.locator('[data-testid="multiple-counters"]')).toHaveScreenshot( + 'multiple-counters.png', + ) + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/components/OsNumber/OsNumber.vue b/packages/ui/src/components/OsNumber/OsNumber.vue new file mode 100644 index 000000000..5dbdc322e --- /dev/null +++ b/packages/ui/src/components/OsNumber/OsNumber.vue @@ -0,0 +1,181 @@ + diff --git a/packages/ui/src/components/OsNumber/__screenshots__/chromium/animated.png b/packages/ui/src/components/OsNumber/__screenshots__/chromium/animated.png new file mode 100644 index 000000000..7f41077c8 Binary files /dev/null and b/packages/ui/src/components/OsNumber/__screenshots__/chromium/animated.png differ diff --git a/packages/ui/src/components/OsNumber/__screenshots__/chromium/multiple-counters.png b/packages/ui/src/components/OsNumber/__screenshots__/chromium/multiple-counters.png new file mode 100644 index 000000000..6bb1983f6 Binary files /dev/null and b/packages/ui/src/components/OsNumber/__screenshots__/chromium/multiple-counters.png differ diff --git a/packages/ui/src/components/OsNumber/__screenshots__/chromium/static-count.png b/packages/ui/src/components/OsNumber/__screenshots__/chromium/static-count.png new file mode 100644 index 000000000..46fabee00 Binary files /dev/null and b/packages/ui/src/components/OsNumber/__screenshots__/chromium/static-count.png differ diff --git a/packages/ui/src/components/OsNumber/__screenshots__/chromium/with-label.png b/packages/ui/src/components/OsNumber/__screenshots__/chromium/with-label.png new file mode 100644 index 000000000..6135869ea Binary files /dev/null and b/packages/ui/src/components/OsNumber/__screenshots__/chromium/with-label.png differ diff --git a/packages/ui/src/components/OsNumber/index.ts b/packages/ui/src/components/OsNumber/index.ts new file mode 100644 index 000000000..db4d8708f --- /dev/null +++ b/packages/ui/src/components/OsNumber/index.ts @@ -0,0 +1,2 @@ +export { default as OsNumber } from './OsNumber.vue' +export { numberVariants, type NumberVariants } from './number.variants' diff --git a/packages/ui/src/components/OsNumber/number.variants.ts b/packages/ui/src/components/OsNumber/number.variants.ts new file mode 100644 index 000000000..c9c5a3741 --- /dev/null +++ b/packages/ui/src/components/OsNumber/number.variants.ts @@ -0,0 +1,15 @@ +import { cva } from 'class-variance-authority' + +import type { VariantProps } from 'class-variance-authority' + +/** + * Number display variants using CVA (Class Variance Authority) + * + * Non-interactive numeric display with optional label and count-up animation. + */ +export const numberVariants = cva(['flex flex-col items-center text-center'], { + variants: {}, + defaultVariants: {}, +}) + +export type NumberVariants = VariantProps diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 61e674bc0..a1cf80e64 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -27,3 +27,4 @@ export { type BadgeVariant, type BadgeVariants, } from './OsBadge' +export { OsNumber, numberVariants, type NumberVariants } from './OsNumber' diff --git a/packages/ui/src/tailwind.preset.ts b/packages/ui/src/tailwind.preset.ts index bcb238e9d..e9bb4443e 100644 --- a/packages/ui/src/tailwind.preset.ts +++ b/packages/ui/src/tailwind.preset.ts @@ -62,6 +62,8 @@ export const requiredCssVariables: string[] = [ // Disabled '--color-disabled', '--color-disabled-contrast', + // Text + '--color-text-soft', ] /** diff --git a/webapp/assets/_new/styles/_ds-compat.scss b/webapp/assets/_new/styles/_ds-compat.scss index adbf94c75..d9a298140 100644 --- a/webapp/assets/_new/styles/_ds-compat.scss +++ b/webapp/assets/_new/styles/_ds-compat.scss @@ -100,12 +100,6 @@ .ds-text-warning { color: $text-color-warning; } .ds-text-danger { color: $text-color-danger; } -// ds-number Ersatz — Statistic display (count + label) -// Grep: os-number -.os-number { text-align: center; } -.os-number-count { font-weight: $font-weight-bold; font-size: $font-size-x-large; display: block; } -.os-number-label { font-size: $font-size-small; color: $text-color-soft; display: block; } - // ds-table Ersatz .ds-table-wrap { width: 100%; overflow: auto; } .ds-table { width: 100%; border-collapse: collapse; } diff --git a/webapp/assets/_new/styles/ocelot-ui-variables.scss b/webapp/assets/_new/styles/ocelot-ui-variables.scss index 7d935f7f8..aef21c472 100644 --- a/webapp/assets/_new/styles/ocelot-ui-variables.scss +++ b/webapp/assets/_new/styles/ocelot-ui-variables.scss @@ -54,4 +54,7 @@ // Disabled state --color-disabled: #{$color-neutral-60}; // rgb(177, 171, 186) --color-disabled-contrast: #{$color-neutral-100}; // weiß + + // Text + --color-text-soft: #{$text-color-soft}; // rgb(112, 103, 126) } diff --git a/webapp/components/CountTo.vue b/webapp/components/CountTo.vue deleted file mode 100644 index 42152b5d4..000000000 --- a/webapp/components/CountTo.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/webapp/components/UserTeaser/UserTeaserPopover.spec.js b/webapp/components/UserTeaser/UserTeaserPopover.spec.js index 9b6d52cf2..8268fd307 100644 --- a/webapp/components/UserTeaser/UserTeaserPopover.spec.js +++ b/webapp/components/UserTeaser/UserTeaserPopover.spec.js @@ -8,6 +8,9 @@ const user = { id: 'id', name: 'Tilda Swinton', slug: 'tilda-swinton', + followedByCount: 42, + contributionsCount: 7, + commentedCount: 13, badgeVerification: { id: 'bv1', icon: '/icons/verified', @@ -42,7 +45,12 @@ const userLink = { } describe('UserTeaserPopover', () => { - const Wrapper = ({ badgesEnabled = true, withUserLink = true, onTouchScreen = false }) => { + const Wrapper = ({ + badgesEnabled = true, + withUserLink = true, + onTouchScreen = false, + userData = user, + }) => { const mockIsTouchDevice = onTouchScreen jest.mock('../utils/isTouchDevice', () => ({ isTouchDevice: jest.fn(() => mockIsTouchDevice), @@ -54,7 +62,7 @@ describe('UserTeaserPopover', () => { userLink: withUserLink ? userLink : null, }, data: () => ({ - User: [user], + User: [userData], }), stubs: { NuxtLink: RouterLinkStub, @@ -96,4 +104,15 @@ describe('UserTeaserPopover', () => { const wrapper = Wrapper({ badgesEnabled: false }) expect(wrapper.container).toMatchSnapshot() }) + + it('renders correctly for a fresh user with zero counts', () => { + const freshUser = { + ...user, + followedByCount: 0, + contributionsCount: 0, + commentedCount: 0, + } + const wrapper = Wrapper({ userData: freshUser }) + expect(wrapper.container).toMatchSnapshot() + }) }) diff --git a/webapp/components/UserTeaser/UserTeaserPopover.vue b/webapp/components/UserTeaser/UserTeaserPopover.vue index 7cf1766e6..3c991aa82 100644 --- a/webapp/components/UserTeaser/UserTeaserPopover.vue +++ b/webapp/components/UserTeaser/UserTeaserPopover.vue @@ -10,18 +10,19 @@ :is-owner="userId === $store.getters['auth/user'].id" class="location-info" /> +
  • - +
  • -
  • - @@ -40,7 +41,7 @@