From bbad57bbc7cefe6aaeb1446c742bc36ec65faacd Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Sat, 21 Feb 2026 05:13:42 +0100 Subject: [PATCH] feat(package/ui): os-number (#9254) --- packages/ui/.storybook/storybook.css | 3 + packages/ui/KATALOG.md | 11 +- packages/ui/PROJEKT.md | 67 +- .../src/components/OsNumber/OsNumber.spec.ts | 268 ++++++ .../components/OsNumber/OsNumber.stories.ts | 111 +++ .../OsNumber/OsNumber.visual.spec.ts | 95 ++ .../ui/src/components/OsNumber/OsNumber.vue | 181 ++++ .../__screenshots__/chromium/animated.png | Bin 0 -> 2127 bytes .../chromium/multiple-counters.png | Bin 0 -> 8669 bytes .../__screenshots__/chromium/static-count.png | Bin 0 -> 2348 bytes .../__screenshots__/chromium/with-label.png | Bin 0 -> 2954 bytes packages/ui/src/components/OsNumber/index.ts | 2 + .../components/OsNumber/number.variants.ts | 15 + packages/ui/src/components/index.ts | 1 + packages/ui/src/tailwind.preset.ts | 2 + webapp/assets/_new/styles/_ds-compat.scss | 6 - .../_new/styles/ocelot-ui-variables.scss | 3 + webapp/components/CountTo.vue | 29 - .../UserTeaser/UserTeaserPopover.spec.js | 23 +- .../UserTeaser/UserTeaserPopover.vue | 10 +- .../UserTeaserPopover.spec.js.snap | 439 +++++---- .../SearchResults/SearchResults.spec.js | 2 +- .../TabNavigation/TabNavigation.spec.js | 27 +- .../generic/TabNavigation/TabNavigation.vue | 9 +- webapp/package.json | 1 - webapp/pages/admin/index.vue | 15 +- .../_id/__snapshots__/_slug.spec.js.snap | 384 ++++---- webapp/pages/groups/_id/_slug.spec.js | 3 + webapp/pages/groups/_id/_slug.vue | 25 +- .../_id/__snapshots__/_slug.spec.js.snap | 840 +++++++----------- webapp/pages/profile/_id/_slug.spec.js | 4 + webapp/pages/profile/_id/_slug.vue | 35 +- webapp/yarn.lock | 5 - 33 files changed, 1552 insertions(+), 1064 deletions(-) create mode 100644 packages/ui/src/components/OsNumber/OsNumber.spec.ts create mode 100644 packages/ui/src/components/OsNumber/OsNumber.stories.ts create mode 100644 packages/ui/src/components/OsNumber/OsNumber.visual.spec.ts create mode 100644 packages/ui/src/components/OsNumber/OsNumber.vue create mode 100644 packages/ui/src/components/OsNumber/__screenshots__/chromium/animated.png create mode 100644 packages/ui/src/components/OsNumber/__screenshots__/chromium/multiple-counters.png create mode 100644 packages/ui/src/components/OsNumber/__screenshots__/chromium/static-count.png create mode 100644 packages/ui/src/components/OsNumber/__screenshots__/chromium/with-label.png create mode 100644 packages/ui/src/components/OsNumber/index.ts create mode 100644 packages/ui/src/components/OsNumber/number.variants.ts delete mode 100644 webapp/components/CountTo.vue 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 0000000000000000000000000000000000000000..7f41077c89181bb05e29b6079da30bcffd4c790d GIT binary patch literal 2127 zcma)8XIPV28qI_?@f7!$XDdL^b)Zm{2KWyYv#BGgOk+oJp(^Dks#_Yc~sm1^`}&w6Kb zhEx>CTBB0Y$oYP5Wvejg{6=oi^1?>&)zJ4s(Q}r4*DdB201f%D+3&DjvVh2&TWpog z>RXhclKI1T7|h=!i)|0XB)lOf6!$qu<_8upUPx#|{{HbfVPz=T5K43GCzax94Y6w<<-;bn(HQ51D`b(52m&msV6 z*r`)v(<39lQ>o&y&k2i*fqHs+_XN|ui~tTRX<=w+NHE&K^$iHXfg|_#8)oO_<;{<* zEzf@MUa2h=@anC{x{qFc^#&7-)+^;fchA9z#Pz9Vy%T)%FulVq#(sAsn$- zrf=_C0iv(3Z)|943IU{SSqSNrb=I|W`Q_!#L1Yh)7jNF!w5DwaX)Am1`FyTXtg}gaWMm|c6>Koh^y#sd zqQ@+an!xVwLWdd{8`}jsoB`jn*`j2CopZIJzCJ2=X5dV?rHY0|ABRIrNLZNcGAu5? z*`6t0Tegvvn_ySnpoWHQHk+IA^yv$6OV_h!&*J0bJ3Bjp?u=Jsq#qrR9XnR%ej$h? zwdD}aR!e*yuU2_)!An#K3keBvadGKmvsf%}>X{jT>N_hRzkmQbot`2^P#-V5iUGbT z{-rjewG|y-T~#$dKc8)vo}H?!tQ-*@o|KZ(oT8T zz+$&=-`*o-5Gs0t3?>qZb(Guzv=jYjcL#@Gfvp+i?XQr8KbXwHvH+Ub;K0E8P|YKP zW1fRZB$5@n>bhY9zE&OByB7wBYd<;PYp9W(n%cYKM<$aA&(09Dflk84j~}nEn@WLx zO@QWlw`TNK5XT!EA2<5@`-g{z-|x>G8Xne)?C$P1bSk>py|%W-?&yH0Jb(WDUnr8s zsR7S=-oLYW)1U7qsdp+6bQyR57+BldDhT#2eywRV=@LQ*UqYB zx4!=Jy><@geL;b(wCT*7>6@sIcXwuAqlhGusyqDn@uHj@66}2E)h4hd`JIDw;%tZmX%OvCev1RTV6p?v)YroH#$S##!Pv@5e)Y{QW_j>YvS* zh1b>fURgGM&=#*Ea-}M)@A{v)CAU>ovejd^zO3ZY{~vvA_Lv|1`Z{}h^gzB1CL6n1 zT3TvLaX0=jxW#n!{#;+Ld+yv|P0h>d>T?JL0t$s17@*SNNMr*r4G>Ohs0S$a=3{zF zN=kHev?3af9@&jOo_q1)#WMdvuwAgY7!wy)R$8hS&15pIv*t$|;$FUd`R+Eqsi|pl zW=4%^di3Z=4hQEAl?Kb)-Y80gnwna5bu}S2HkNgN=@!uS(xtk5?3;~qP%|^ohZT4{ zKJ)Rn@o~Cy@%7ckiOiZP`7Z){85>$#Xqcx;p( zfryopmwz83uJmkBc5`#X;W(+UtSpv_1v!PSd(-|=DSjCZp*sN<I_B-pemqlx9@4+oJ z!Ol7QgQ@BH;FS|*W*!GAvB2Cs9xpal`J~ColLrnS+-ET>E3D2&6KJ4IuI1TY zxw3n>HgXq8Z9urTw)Xniw{Kpap6t%f#T9`k{s0)7nwtCf-&y3f3+$z^iyaGxJGaNg z#K7TjBbT?IzI<_JI6FD@u-T(r?hP+5(~V?Gj&x^Z2wiq(&PCb^Rd6!H=>PXcJA47W k*xZB*Y>L2Qo8*5$9#WxG7is)wzgSX(x`ehWvh)c13nS*sAOHXW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6bb1983f6ff682ed2abc2e481d58c4db4ea9ca5f GIT binary patch literal 8669 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 literal 0 HcmV?d00001 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 @@