feat(package/ui): os-number (#9254)

This commit is contained in:
Ulf Gebhardt 2026-02-21 05:13:42 +01:00 committed by GitHub
parent 518ed8af89
commit bbad57bbc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1552 additions and 1064 deletions

View File

@ -52,4 +52,7 @@
/* Disabled (Payne's Grey - muted watercolor grey) */ /* Disabled (Payne's Grey - muted watercolor grey) */
--color-disabled: #c4bdb5; --color-disabled: #c4bdb5;
--color-disabled-contrast: #5a4f45; --color-disabled-contrast: #5a4f45;
/* Text */
--color-text-soft: #6b5e7b;
} }

View File

@ -13,7 +13,7 @@ Phase 0: Analyse ██████████ 100% (8/8 Schritte) ✅
Phase 3: OsButton ██████████ 100% (133/133 Buttons) ✅ Phase 3: OsButton ██████████ 100% (133/133 Buttons) ✅
Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, OsSpinner, OsCard) ✅ Phase 4: Tier 1 ██████████ 100% (OsButton, OsIcon, OsSpinner, OsCard) ✅
Phase 4: Tier A → HTML ██████████ 100% (10 ds-* Wrapper → Plain HTML) ✅ 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) 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 | | Webapp Komponenten | 139 |
| Styleguide Komponenten | 38 (23 in Webapp genutzt) | | Styleguide Komponenten | 38 (23 in Webapp genutzt) |
| **Gesamt** | **177** | | **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) | | ✅ → 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") | | ✅ → 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 | | ⬜ → UI-Library | Modal, Input, Menu, MenuItem, Select (5) — Tier 2-3 |
| ⬜ Offen | Form (18 Dateien — HTML oder OsForm?) | | ⬜ Offen | Form (18 Dateien — HTML oder OsForm?) |
| ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) | | ⬜ 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) | | 3 | Chip | ✅ UI-Library | → OsBadge (20 Nutzungen in 5 Dateien) |
| 4 | Code | ⬜ Nicht genutzt | Nicht in Webapp verwendet | | 4 | Code | ⬜ Nicht genutzt | Nicht in Webapp verwendet |
| 5 | Icon | ✅ UI-Library | → OsIcon (BaseIcon gelöscht, 82 Ocelot-Icons) | | 5 | Icon | ✅ UI-Library | → OsIcon (BaseIcon gelöscht, 82 Ocelot-Icons) |
| 6 | Number | ⬜ Tier B | 5 Dateien → Plain HTML `<div class="ds-number">` | | 6 | Number | ✅ UI-Library | → OsNumber (5 Dateien, CountTo.vue gelöscht, vue-count-to entfernt) |
| 7 | Placeholder | ✅ → HTML | Tier A: `<div class="ds-placeholder">` | | 7 | Placeholder | ✅ → HTML | Tier A: `<div class="ds-placeholder">` |
| 8 | Spinner | ✅ UI-Library | → OsSpinner (LoadingSpinner gelöscht) | | 8 | Spinner | ✅ UI-Library | → OsSpinner (LoadingSpinner gelöscht) |
| 9 | Table | ✅ → HTML | 7 Dateien → Plain HTML `<table>` + CSS-Klassen (kein OsTable nötig) | | 9 | Table | ✅ → HTML | 7 Dateien → Plain HTML `<table>` + CSS-Klassen (kein OsTable nötig) |
@ -157,7 +158,7 @@ Phase 4: Tier B+ ████████░░ 75% (ds-table→HTML✅)
| 27 | ContextMenu | ⬜ Ausstehend | Navigation | Menu | | | 27 | ContextMenu | ⬜ Ausstehend | Navigation | Menu | |
| 28 | ContributionForm | ✅ Migriert | Feature | Form | Buttons → OsButton, ds-* → HTML | | 28 | ContributionForm | ✅ Migriert | Feature | Form | Buttons → OsButton, ds-* → HTML |
| 29 | CounterIcon | ⬜ Ausstehend | Display | Icon | | | 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 | | | | 31 | CreateInvitation | ⬜ Ausstehend | Feature | | |
| 32 | CtaJoinLeaveGroup | ✅ Migriert | Button | Button | 🔄 Button-Familie, nutzt OsButton | | 32 | CtaJoinLeaveGroup | ✅ Migriert | Button | Button | 🔄 Button-Familie, nutzt OsButton |
| 33 | CtaUnblockAuthor | ✅ Migriert | Button | Button | Nutzt OsButton (icon, as="nuxt-link") | | 33 | CtaUnblockAuthor | ✅ Migriert | Button | Button | Nutzt OsButton (icon, as="nuxt-link") |

View File

@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅ Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅ Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett 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) Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
─────────────────────────────────────── ───────────────────────────────────────
Gesamt: ████████░░ 81% (78/96 Aufgaben) Gesamt: ████████░░ 82% (79/96 Aufgaben)
``` ```
### Katalogisierung (Details in KATALOG.md) ### 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 ├─ ds-flex/ds-flex-item: JavaScript window.innerWidth → CSS @media Queries
│ (kein Layout-Shift bei SSR, bessere Performance) │ (kein Layout-Shift bei SSR, bessere Performance)
├─ system.css bleibt geladen — bestehende CSS-Klassen funktionieren weiter ├─ 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 └─ 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 → <os-number> (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): ✅ ds-chip + ds-tag → OsBadge (UI-Library): ✅
├─ OsBadge Komponente: CVA-Varianten, h() Render-Function, inheritAttrs: false ├─ OsBadge Komponente: CVA-Varianten, h() Render-Function, inheritAttrs: false
├─ Props: variant (default/primary/danger), size (sm/md/lg), shape (pill/square) ├─ 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 ## 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 `<table>` + CSS-Klassen (kein OsTable nötig) - [x] ds-table (7 Nutzungen) → native `<table>` + 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] 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 `<th>`) - [x] `fields()` / `tableFields()` Computed Properties aus allen 7 Dateien entfernt (Labels direkt in `<th>`)
- [x] Alle 16 Tests bestanden (3 Test-Suites: admin/users Snapshots aktualisiert, FiledReportsTable ✅, ReportsTable ✅) - [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] Test-Fix: Empty.spec.js `attributes().margin``classes().toContain('ds-my-xxx-small')`
- [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend - [x] 0 Tier-A `ds-*` Komponenten-Tags verbleibend
**Verbleibende ds-* Komponenten (8 Typen):** **Verbleibende ds-* Komponenten (7 Typen):**
- Tier B Rest (→ Plain HTML): ds-number (5), ds-radio (1) - 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) - 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):** **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] OsCard Komponente + BaseCard → OsCard Webapp-Migration ✅
- [x] Tier A: 10 triviale ds-* Wrapper → Plain HTML + CSS ✅ - [x] Tier A: 10 triviale ds-* Wrapper → Plain HTML + CSS ✅
- [x] OsBadge Komponente + ds-chip/ds-tag → OsBadge Webapp-Migration ✅ - [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) - [ ] Weitere Tier 2 Komponenten (OsModal, OsDropdown, OsAvatar, OsInput)
- [ ] ds-form + ds-input → OsForm + OsInput (stark gekoppelt, 18+23 Dateien) - [ ] ds-form + ds-input → OsForm + OsInput (stark gekoppelt, 18+23 Dateien)
- [ ] ds-menu / ds-menu-item → OsMenu / OsMenuItem - [ ] 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) - [ ] 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):** **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** **Tier B: Einfache ds-* → Plain HTML / UI-Library**
- [x] ds-chip (5 Dateien) → OsBadge (UI-Library) ✅ - [x] ds-chip (5 Dateien) → OsBadge (UI-Library) ✅
- [x] ds-tag (3 Dateien) → OsBadge shape="square" (UI-Library) ✅ - [x] ds-tag (3 Dateien) → OsBadge shape="square" (UI-Library) ✅
- [ ] ds-number (5 Dateien) → `<div class="ds-number">` - [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 ✅ - [x] ds-grid / ds-grid-item (10 Dateien) → CSS Grid ✅
- [ ] ds-radio (1 Datei) → native `<input type="radio">` - [ ] ds-radio (1 Datei) → native `<input type="radio">`
@ -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 | **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 `<th>` | | 2026-02-20 | **fields() entfernt** | Computed Properties `fields()`/`tableFields()` aus 7 Dateien entfernt — Labels direkt in `<th>` |
| 2026-02-20 | **Scope-Objekte entfernt** | `scope.row` Zugriffe → direkte Iteration-Variable (user, tag, member, report) | | 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:** **Styleguide-Migration:**
| Status | Komponenten | | 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 | | ✅ → 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 | | ✅ → UI-Library | Chip, Tag → OsBadge (2), Number → OsNumber (1) — Tier B |
| ⬜ → Plain HTML | Number, Radio (2) — Tier B | | ⬜ → Plain HTML | Radio (1) — Tier B |
| ⬜ → UI-Library | Modal, Input, Menu, MenuItem, Select (5) — Tier 2-3 | | ⬜ → 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) | | ⬜ Nicht genutzt | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
| ⬜ Offen | Form (18 Dateien — HTML `<form>` oder OsForm?) | | ⬜ Offen | Form (18 Dateien — HTML `<form>` oder OsForm?) |

View File

@ -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()
})
})
})

View File

@ -0,0 +1,111 @@
import { computed } from 'vue'
import OsNumber from './OsNumber.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsNumber> = {
title: 'Components/OsNumber',
component: OsNumber,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OsNumber>
interface PlaygroundArgs {
count: number
label: string
animated: boolean
}
export const Playground: StoryObj<PlaygroundArgs> = {
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: `<OsNumber :key="remountKey" v-bind="numberProps" />`,
}),
}
export const StaticCount: Story = {
render: () => ({
components: { OsNumber },
template: `
<div data-testid="static-count" class="flex items-center gap-8">
<OsNumber :count="0" />
<OsNumber :count="42" />
<OsNumber :count="1337" />
</div>
`,
}),
}
export const WithLabel: Story = {
render: () => ({
components: { OsNumber },
template: `
<div data-testid="with-label" class="flex items-center gap-8">
<OsNumber :count="12" label="Posts" />
<OsNumber :count="42" label="Followers" />
<OsNumber :count="7" label="Following" />
</div>
`,
}),
}
export const Animated: Story = {
render: () => ({
components: { OsNumber },
template: `
<div data-testid="animated" class="flex items-center gap-8">
<OsNumber :count="128" label="Posts" :animated="true" />
</div>
`,
}),
}
export const MultipleCounters: Story = {
render: () => ({
components: { OsNumber },
template: `
<div data-testid="multiple-counters" class="flex flex-col gap-6">
<div class="flex items-center gap-8">
<OsNumber :count="156" label="Users" />
<OsNumber :count="42" label="Posts" />
<OsNumber :count="7" label="Comments" />
<OsNumber :count="3" label="Groups" />
</div>
<div class="flex items-center gap-8">
<OsNumber :count="156" label="Users" :animated="true" />
<OsNumber :count="42" label="Posts" :animated="true" />
<OsNumber :count="7" label="Comments" :animated="true" />
<OsNumber :count="3" label="Groups" :animated="true" />
</div>
</div>
`,
}),
}

View File

@ -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)
})
})

View File

@ -0,0 +1,181 @@
<script lang="ts">
import {
defineComponent,
getCurrentInstance,
h,
isVue2,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue-demi'
import { cn } from '#src/utils'
import { numberVariants } from './number.variants'
import type { ClassValue } from 'clsx'
const ANIMATION_DURATION = 1500
function easeOut(t: number): number {
return 1 - (1 - t) * (1 - t)
}
/**
* Non-interactive numeric display with optional label and count-up animation.
*
* @slot default - Not used. Content is derived from the `count` prop.
*/
export default defineComponent({
name: 'OsNumber',
inheritAttrs: false,
props: {
/**
* The number to display.
*/
count: {
type: Number,
required: true,
},
/**
* Optional label displayed below the count.
*/
label: {
type: String,
default: undefined,
},
/**
* Whether to animate from 0 to the count value on mount.
* Re-animates when count changes.
*/
animated: {
type: Boolean,
default: false,
},
},
setup(props, { attrs }) {
/* v8 ignore start -- Vue 2 only */
const instance = isVue2 ? getCurrentInstance() : null
/* v8 ignore stop */
const displayValue = ref(props.animated ? 0 : props.count)
let animationFrame: number | undefined
function animateTo(from: number, to: number) {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
const startTime = performance.now()
function step(currentTime: number) {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
const easedProgress = easeOut(progress)
displayValue.value = Math.round(from + (to - from) * easedProgress)
if (progress < 1) {
animationFrame = requestAnimationFrame(step)
}
}
animationFrame = requestAnimationFrame(step)
}
onMounted(() => {
if (props.animated) {
animateTo(0, props.count)
}
})
onUnmounted(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
})
watch(
() => props.count,
(newVal, oldVal) => {
if (props.animated) {
/* v8 ignore start -- oldVal is always numeric from Vue's watch */
animateTo(oldVal ?? 0, newVal)
/* v8 ignore stop */
} else {
displayValue.value = newVal
}
},
)
return () => {
const rootClass = cn('os-number', numberVariants())
const countAttrs: Record<string, unknown> = {
class: 'os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block',
style: { minWidth: `${String(props.count).length}ch` },
}
if (props.animated) {
countAttrs['aria-hidden'] = 'true'
}
const countChild = h('span', countAttrs, [String(displayValue.value)])
const children = [countChild]
if (props.animated) {
children.push(
h(
'span',
{
class: 'sr-only',
'aria-live': 'polite',
},
[String(props.count)],
),
)
}
if (props.label) {
children.push(
h('span', { class: 'os-number-label text-[12px] text-[var(--color-text-soft)]' }, [
props.label,
]),
)
}
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
if (isVue2) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proxy = instance?.proxy as any
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
const parentAttrs = proxy?.$vnode?.data?.attrs || {}
return h(
'div',
{
class: cn(rootClass, parentClass, parentDynClass),
attrs: { ...parentAttrs, ...attrs },
},
children,
)
}
/* v8 ignore stop */
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'div',
{
class: cn(rootClass, attrClass as ClassValue),
...restAttrs,
},
children,
)
}
},
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,2 @@
export { default as OsNumber } from './OsNumber.vue'
export { numberVariants, type NumberVariants } from './number.variants'

View File

@ -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<typeof numberVariants>

View File

@ -27,3 +27,4 @@ export {
type BadgeVariant, type BadgeVariant,
type BadgeVariants, type BadgeVariants,
} from './OsBadge' } from './OsBadge'
export { OsNumber, numberVariants, type NumberVariants } from './OsNumber'

View File

@ -62,6 +62,8 @@ export const requiredCssVariables: string[] = [
// Disabled // Disabled
'--color-disabled', '--color-disabled',
'--color-disabled-contrast', '--color-disabled-contrast',
// Text
'--color-text-soft',
] ]
/** /**

View File

@ -100,12 +100,6 @@
.ds-text-warning { color: $text-color-warning; } .ds-text-warning { color: $text-color-warning; }
.ds-text-danger { color: $text-color-danger; } .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 Ersatz
.ds-table-wrap { width: 100%; overflow: auto; } .ds-table-wrap { width: 100%; overflow: auto; }
.ds-table { width: 100%; border-collapse: collapse; } .ds-table { width: 100%; border-collapse: collapse; }

View File

@ -54,4 +54,7 @@
// Disabled state // Disabled state
--color-disabled: #{$color-neutral-60}; // rgb(177, 171, 186) --color-disabled: #{$color-neutral-60}; // rgb(177, 171, 186)
--color-disabled-contrast: #{$color-neutral-100}; // weiß --color-disabled-contrast: #{$color-neutral-100}; // weiß
// Text
--color-text-soft: #{$text-color-soft}; // rgb(112, 103, 126)
} }

View File

@ -1,29 +0,0 @@
<template>
<span>
<client-only placeholder="0" tag="span">
<count-to
:start-val="startVal"
:end-val="endVal"
:duration="duration"
:autoplay="autoplay"
:separator="separator"
/>
</client-only>
</span>
</template>
<script>
import CountTo from 'vue-count-to'
export default {
components: {
CountTo,
},
props: {
startVal: { type: Number, default: 0 },
endVal: { type: Number, default: 0 },
duration: { type: Number, default: 3000 },
autoplay: { type: Boolean, default: true },
separator: { type: String, default: '.' },
},
}
</script>

View File

@ -8,6 +8,9 @@ const user = {
id: 'id', id: 'id',
name: 'Tilda Swinton', name: 'Tilda Swinton',
slug: 'tilda-swinton', slug: 'tilda-swinton',
followedByCount: 42,
contributionsCount: 7,
commentedCount: 13,
badgeVerification: { badgeVerification: {
id: 'bv1', id: 'bv1',
icon: '/icons/verified', icon: '/icons/verified',
@ -42,7 +45,12 @@ const userLink = {
} }
describe('UserTeaserPopover', () => { describe('UserTeaserPopover', () => {
const Wrapper = ({ badgesEnabled = true, withUserLink = true, onTouchScreen = false }) => { const Wrapper = ({
badgesEnabled = true,
withUserLink = true,
onTouchScreen = false,
userData = user,
}) => {
const mockIsTouchDevice = onTouchScreen const mockIsTouchDevice = onTouchScreen
jest.mock('../utils/isTouchDevice', () => ({ jest.mock('../utils/isTouchDevice', () => ({
isTouchDevice: jest.fn(() => mockIsTouchDevice), isTouchDevice: jest.fn(() => mockIsTouchDevice),
@ -54,7 +62,7 @@ describe('UserTeaserPopover', () => {
userLink: withUserLink ? userLink : null, userLink: withUserLink ? userLink : null,
}, },
data: () => ({ data: () => ({
User: [user], User: [userData],
}), }),
stubs: { stubs: {
NuxtLink: RouterLinkStub, NuxtLink: RouterLinkStub,
@ -96,4 +104,15 @@ describe('UserTeaserPopover', () => {
const wrapper = Wrapper({ badgesEnabled: false }) const wrapper = Wrapper({ badgesEnabled: false })
expect(wrapper.container).toMatchSnapshot() 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()
})
}) })

View File

@ -10,18 +10,19 @@
:is-owner="userId === $store.getters['auth/user'].id" :is-owner="userId === $store.getters['auth/user'].id"
class="location-info" class="location-info"
/> />
<!-- No :animated on OsNumber popover appears on hover, animation would be distracting -->
<ul class="statistics"> <ul class="statistics">
<li> <li>
<ds-number :count="user.followedByCount" :label="$t('profile.followers')" /> <os-number :count="user.followedByCount" :label="$t('profile.followers')" />
</li> </li>
<li> <li>
<ds-number <os-number
:count="user.contributionsCount" :count="user.contributionsCount"
:label="$t('common.post', null, user.contributionsCount)" :label="$t('common.post', null, user.contributionsCount)"
/> />
</li> </li>
<li> <li>
<ds-number <os-number
:count="user.commentedCount" :count="user.commentedCount"
:label="$t('common.comment', null, user.commentedCount)" :label="$t('common.comment', null, user.commentedCount)"
/> />
@ -40,7 +41,7 @@
</template> </template>
<script> <script>
import { OsButton } from '@ocelot-social/ui' import { OsButton, OsNumber } from '@ocelot-social/ui'
import Badges from '~/components/Badges.vue' import Badges from '~/components/Badges.vue'
import LocationInfo from '~/components/LocationInfo/LocationInfo.vue' import LocationInfo from '~/components/LocationInfo/LocationInfo.vue'
import { isTouchDevice } from '~/components/utils/isTouchDevice' import { isTouchDevice } from '~/components/utils/isTouchDevice'
@ -52,6 +53,7 @@ export default {
Badges, Badges,
LocationInfo, LocationInfo,
OsButton, OsButton,
OsNumber,
}, },
props: { props: {
userId: { type: String }, userId: { type: String },

View File

@ -14,61 +14,55 @@ exports[`UserTeaserPopover does not show badges when disabled 1`] = `
> >
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 42
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
profile.followers </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 1ch;"
> >
0 7
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.post
common.post </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 13
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.comment
common.comment </span>
</p>
</div> </div>
</li> </li>
</ul> </ul>
@ -131,61 +125,55 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
> >
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 42
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
profile.followers </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 1ch;"
> >
0 7
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.post
common.post </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 13
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.comment
common.comment </span>
</p>
</div> </div>
</li> </li>
</ul> </ul>
@ -248,61 +236,55 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
> >
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 42
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
profile.followers </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 1ch;"
> >
0 7
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.post
common.post </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 13
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.comment
common.comment </span>
</p>
</div> </div>
</li> </li>
</ul> </ul>
@ -365,61 +347,166 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
> >
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 42
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
profile.followers </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 1ch;"
> >
0 7
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.post
common.post </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
>
13
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</span>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;
exports[`UserTeaserPopover renders correctly for a fresh user with zero counts 1`] = `
<div>
<div
class="user-teaser-popover"
>
<div
class="hc-badges"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/verified"
title="Verified"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy1"
title="Trophy 1"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy2"
title="Trophy 2"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/empty"
title="Empty"
/>
</div>
</div>
<!---->
<ul
class="statistics"
>
<li>
<div
class="os-number flex flex-col items-center text-center"
>
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
0 0
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
common.comment </span>
</div>
</p> </li>
<li>
<div
class="os-number flex flex-col items-center text-center"
>
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</span>
</div>
</li>
<li>
<div
class="os-number flex flex-col items-center text-center"
>
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</span>
</div> </div>
</li> </li>
</ul> </ul>
@ -482,61 +569,55 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
> >
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 42
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
profile.followers
profile.followers </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 1ch;"
> >
0 7
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.post
common.post </span>
</p>
</div> </div>
</li> </li>
<li> <li>
<div <div
class="ds-number ds-number-size-x-large" class="os-number flex flex-col items-center text-center"
> >
<p <span
class="ds-text ds-number-count ds-text-size-x-large" class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="margin-bottom: 0px;" style="min-width: 2ch;"
> >
0 13
</p> </span>
<p <span
class="ds-text ds-number-label ds-text-size-small ds-text-soft" class="os-number-label text-[12px] text-[var(--color-text-soft)]"
> >
common.comment
common.comment </span>
</p>
</div> </div>
</li> </li>
</ul> </ul>

View File

@ -60,7 +60,7 @@ describe('SearchResults', () => {
describe('result contains 25 posts, 8 users and 0 hashtags', () => { describe('result contains 25 posts, 8 users and 0 hashtags', () => {
// we couldn't get it running with "jest.runAllTimers()" and so we used "setTimeout" // we couldn't get it running with "jest.runAllTimers()" and so we used "setTimeout"
// time is a bit more then 3000 milisec see "webapp/components/CountTo.vue" // OsNumber animation runs for 1500ms
const counterTimeout = 3000 + 10 const counterTimeout = 3000 + 10
beforeEach(async () => { beforeEach(async () => {

View File

@ -49,32 +49,27 @@ describe('TabNavigation', () => {
}) })
describe('displays', () => { describe('displays', () => {
// we couldn't get it running with "jest.runAllTimers()" and so we used "setTimeout" beforeEach(() => {
// time is a bit more then 3000 milisec see "webapp/components/CountTo.vue" jest.useFakeTimers()
const counterTimeout = 3000 + 10 // Re-mount with fake timers so requestAnimationFrame is captured
wrapper = Wrapper()
jest.advanceTimersByTime(1600)
})
it('shows a total of 17 results', () => { afterEach(() => {
setTimeout(() => { jest.useRealTimers()
expect(wrapper.find('.total-search-results').text()).toContain('17')
}, counterTimeout)
}) })
it('shows tab with 12 posts', () => { it('shows tab with 12 posts', () => {
setTimeout(() => { expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('12')
expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('12')
}, counterTimeout)
}) })
it('shows tab with 9 users', () => { it('shows tab with 9 users', () => {
setTimeout(() => { expect(wrapper.find('[data-test="User-tab"]').text()).toContain('9')
expect(wrapper.find('[data-test="User-tab"]').text()).toContain('9')
}, counterTimeout)
}) })
it('shows tab with 0 hashtags', () => { it('shows tab with 0 hashtags', () => {
setTimeout(() => { expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
}, counterTimeout)
}) })
describe('basic props setting', () => { describe('basic props setting', () => {

View File

@ -16,9 +16,7 @@
<a :data-test="tab.type + '-tab-click'" @click="switchTab(tab)"> <a :data-test="tab.type + '-tab-click'" @click="switchTab(tab)">
<div class="ds-my-small"> <div class="ds-my-small">
<client-only :placeholder="$t('client-only.loading')"> <client-only :placeholder="$t('client-only.loading')">
<ds-number :label="tab.title"> <os-number :count="tab.count" :label="tab.title" :animated="true" />
<hc-count-to slot="count" :end-val="tab.count" />
</ds-number>
</client-only> </client-only>
</div> </div>
</a> </a>
@ -29,13 +27,12 @@
</template> </template>
<script> <script>
import { OsCard } from '@ocelot-social/ui' import { OsCard, OsNumber } from '@ocelot-social/ui'
import HcCountTo from '~/components/CountTo.vue'
export default { export default {
components: { components: {
OsCard, OsCard,
HcCountTo, OsNumber,
}, },
props: { props: {
tabs: { tabs: {

View File

@ -55,7 +55,6 @@
"validator": "^13.15.26", "validator": "^13.15.26",
"vue": "~2.7.16", "vue": "~2.7.16",
"vue-advanced-chat": "^2.1.2", "vue-advanced-chat": "^2.1.2",
"vue-count-to": "~1.0.13",
"vue-demi": "^0.14.10", "vue-demi": "^0.14.10",
"vue-infinite-loading": "^2.4.5", "vue-infinite-loading": "^2.4.5",
"vue-izitoast": "^1.2.1", "vue-izitoast": "^1.2.1",

View File

@ -14,11 +14,7 @@
class="admin-stats__item" class="admin-stats__item"
> >
<div class="ds-my-small"> <div class="ds-my-small">
<ds-number :count="0" :label="$t('admin.dashboard.' + name)" size="x-large" uppercase> <os-number :count="value" :label="$t('admin.dashboard.' + name)" :animated="true" />
<client-only slot="count">
<hc-count-to :end-val="value" />
</client-only>
</ds-number>
</div> </div>
</div> </div>
</div> </div>
@ -38,15 +34,14 @@
</template> </template>
<script> <script>
import { OsCard, OsSpinner } from '@ocelot-social/ui' import { OsCard, OsNumber, OsSpinner } from '@ocelot-social/ui'
import HcCountTo from '~/components/CountTo.vue'
import { Statistics } from '~/graphql/admin/Statistics' import { Statistics } from '~/graphql/admin/Statistics'
export default { export default {
components: { components: {
OsCard, OsCard,
OsNumber,
OsSpinner, OsSpinner,
HcCountTo,
}, },
data() { data() {
return { return {
@ -75,6 +70,10 @@ export default {
.admin-stats__item { .admin-stats__item {
flex: 0 0 100%; flex: 0 0 100%;
width: 100%; width: 100%;
.os-number-label {
text-transform: uppercase;
}
} }
@media #{$media-query-small} { @media #{$media-query-small} {
.admin-stats__item { .admin-stats__item {

View File

@ -288,36 +288,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 0
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -2343,36 +2333,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 0
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -3424,36 +3404,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 4
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -4287,36 +4257,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 4
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -5096,36 +5056,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 4
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -6019,36 +5969,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 4
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -7096,36 +7036,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 0
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>
@ -8150,36 +8080,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div <div
class="ds-flex-item" class="ds-flex-item"
> >
<client-only-stub> <div
<div class="os-number flex flex-col items-center text-center"
class="ds-number ds-number-size-x-large" >
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
> >
<p 0
class="ds-text ds-number-count ds-text-size-x-large" </span>
style="margin-bottom: 0px;" <span
> class="sr-only"
<span> >
<client-only-stub 0
placeholder="0" </span>
tag="span" <span
> class="os-number-label text-[12px] text-[var(--color-text-soft)]"
<span> >
group.membersCount
0 </span>
</div>
</span>
</client-only-stub>
</span>
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
group.membersCount
</p>
</div>
</client-only-stub>
</div> </div>
</div> </div>

View File

@ -118,6 +118,7 @@ describe('GroupProfileSlug', () => {
locationName: null, locationName: null,
location: null, location: null,
isMutedByMe: false, isMutedByMe: false,
membersCount: 4,
// myRole: 'usual', // myRole: 'usual',
} }
schoolForCitizens = { schoolForCitizens = {
@ -152,6 +153,7 @@ describe('GroupProfileSlug', () => {
nameEN: 'Paris', nameEN: 'Paris',
}, },
isMutedByMe: true, isMutedByMe: true,
membersCount: 0,
// myRole: 'usual', // myRole: 'usual',
} }
investigativeJournalism = { investigativeJournalism = {
@ -195,6 +197,7 @@ describe('GroupProfileSlug', () => {
nameEN: 'Hamburg', nameEN: 'Hamburg',
}, },
isMutedByMe: false, isMutedByMe: false,
membersCount: 0,
// myRole: 'usual', // myRole: 'usual',
} }
peterLustig = { peterLustig = {

View File

@ -51,15 +51,11 @@
<div class="ds-flex" v-if="isAllowedSeeingGroupMembers"> <div class="ds-flex" v-if="isAllowedSeeingGroupMembers">
<!-- group members count --> <!-- group members count -->
<div class="ds-flex-item" v-if="isAllowedSeeingGroupMembers"> <div class="ds-flex-item" v-if="isAllowedSeeingGroupMembers">
<client-only> <os-number
<ds-number :label="$t('group.membersCount', {}, groupMembers.length)"> :count="group.membersCount"
<count-to :label="$t('group.membersCount', {}, groupMembers.length)"
slot="count" :animated="true"
:start-val="membersCountStartValue" />
:end-val="group.membersCount"
/>
</ds-number>
</client-only>
</div> </div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
@ -80,7 +76,6 @@
:isNonePendingMember="isGroupMemberNonePending" :isNonePendingMember="isGroupMemberNonePending"
:disabled="isGroupOwner" :disabled="isGroupOwner"
:loading="$apollo.loading" :loading="$apollo.loading"
@prepare="prepareJoinLeave"
@update="updateJoinLeave" @update="updateJoinLeave"
/> />
</div> </div>
@ -293,7 +288,7 @@
</template> </template>
<script> <script>
import { OsBadge, OsButton, OsCard, OsIcon, OsSpinner } from '@ocelot-social/ui' import { OsBadge, OsButton, OsCard, OsIcon, OsNumber, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry' import { iconRegistry } from '~/utils/iconRegistry'
import uniqBy from 'lodash/uniqBy' import uniqBy from 'lodash/uniqBy'
import { profilePagePosts } from '~/graphql/PostQuery' import { profilePagePosts } from '~/graphql/PostQuery'
@ -304,7 +299,6 @@ import postListActions from '~/mixins/postListActions'
import AvatarUploader from '~/components/Uploader/AvatarUploader' import AvatarUploader from '~/components/Uploader/AvatarUploader'
import Category from '~/components/Category' import Category from '~/components/Category'
import ContentViewer from '~/components/Editor/ContentViewer' import ContentViewer from '~/components/Editor/ContentViewer'
import CountTo from '~/components/CountTo.vue'
import Empty from '~/components/Empty/Empty' import Empty from '~/components/Empty/Empty'
import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu' import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu'
import JoinLeaveButton from '~/components/Button/JoinLeaveButton' import JoinLeaveButton from '~/components/Button/JoinLeaveButton'
@ -334,11 +328,11 @@ export default {
OsCard, OsCard,
OsButton, OsButton,
OsIcon, OsIcon,
OsNumber,
OsSpinner, OsSpinner,
AvatarUploader, AvatarUploader,
Category, Category,
ContentViewer, ContentViewer,
CountTo,
Empty, Empty,
GroupContentMenu, GroupContentMenu,
JoinLeaveButton, JoinLeaveButton,
@ -372,7 +366,6 @@ export default {
pageSize: 6, pageSize: 6,
// tabActive: 'post', // tabActive: 'post',
filter, filter,
membersCountStartValue: 0,
membersCountToLoad: 25, membersCountToLoad: 25,
updateGroupMutation, updateGroupMutation,
isDescriptionCollapsed: true, isDescriptionCollapsed: true,
@ -577,10 +570,6 @@ export default {
// this.user.followedByCurrentUser = followedByCurrentUser // this.user.followedByCurrentUser = followedByCurrentUser
// this.user.followedBy = followedBy // this.user.followedBy = followedBy
// }, // },
prepareJoinLeave() {
// "membersCountStartValue" is updated to avoid counting from 0 when join/leave
this.membersCountStartValue = (this.GroupMembers && this.GroupMembers.length) || 0
},
updateJoinLeave() { updateJoinLeave() {
this.$apollo.queries.Group.refetch() this.$apollo.queries.Group.refetch()
if (this.isAllowedSeeingGroupMembers) { if (this.isAllowedSeeingGroupMembers) {

File diff suppressed because it is too large Load Diff

View File

@ -95,6 +95,8 @@ describe('ProfileSlug', () => {
contributionsCount: 6, contributionsCount: 6,
shoutedCount: 7, shoutedCount: 7,
commentedCount: 8, commentedCount: 8,
followedByCount: 0,
followingCount: 0,
location: { location: {
name: 'Berlin', name: 'Berlin',
distanceToMe: '877 km', distanceToMe: '877 km',
@ -157,6 +159,8 @@ describe('ProfileSlug', () => {
contributionsCount: 6, contributionsCount: 6,
shoutedCount: 7, shoutedCount: 7,
commentedCount: 8, commentedCount: 8,
followedByCount: 0,
followingCount: 0,
location: { location: {
name: 'Paris', name: 'Paris',
distanceToMe: '0 km', distanceToMe: '0 km',

View File

@ -51,22 +51,18 @@
</div> </div>
<div class="ds-flex"> <div class="ds-flex">
<div class="ds-flex-item"> <div class="ds-flex-item">
<client-only> <os-number
<ds-number :label="$t('profile.followers')"> :count="user.followedByCount"
<hc-count-to :label="$t('profile.followers')"
slot="count" :animated="true"
:start-val="followedByCountStartValue" />
:end-val="user.followedByCount"
/>
</ds-number>
</client-only>
</div> </div>
<div class="ds-flex-item"> <div class="ds-flex-item">
<client-only> <os-number
<ds-number :label="$t('profile.following')"> :count="user.followingCount"
<hc-count-to slot="count" :end-val="user.followingCount" /> :label="$t('profile.following')"
</ds-number> :animated="true"
</client-only> />
</div> </div>
</div> </div>
<div v-if="!myProfile" class="action-buttons"> <div v-if="!myProfile" class="action-buttons">
@ -212,14 +208,13 @@
</template> </template>
<script> <script>
import { OsButton, OsCard, OsIcon, OsSpinner } from '@ocelot-social/ui' import { OsButton, OsCard, OsIcon, OsNumber, OsSpinner } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry' import { iconRegistry } from '~/utils/iconRegistry'
import uniqBy from 'lodash/uniqBy' import uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions' import postListActions from '~/mixins/postListActions'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue' import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/Button/FollowButton' import HcFollowButton from '~/components/Button/FollowButton'
import HcCountTo from '~/components/CountTo.vue'
import HcBadges from '~/components/Badges.vue' import HcBadges from '~/components/Badges.vue'
import FollowList, { followListVisibleCount } from '~/components/features/ProfileList/FollowList' import FollowList, { followListVisibleCount } from '~/components/features/ProfileList/FollowList'
import HcEmpty from '~/components/Empty/Empty' import HcEmpty from '~/components/Empty/Empty'
@ -250,11 +245,11 @@ export default {
OsCard, OsCard,
OsButton, OsButton,
OsIcon, OsIcon,
OsNumber,
OsSpinner, OsSpinner,
SocialMedia, SocialMedia,
PostTeaser, PostTeaser,
HcFollowButton, HcFollowButton,
HcCountTo,
HcBadges, HcBadges,
HcEmpty, HcEmpty,
ProfileAvatar, ProfileAvatar,
@ -289,7 +284,6 @@ export default {
pageSize: 6, pageSize: 6,
tabActive: 'post', tabActive: 'post',
filter, filter,
followedByCountStartValue: 0,
followedByCount: followListVisibleCount, followedByCount: followListVisibleCount,
followingCount: followListVisibleCount, followingCount: followListVisibleCount,
updateUserMutation, updateUserMutation,
@ -428,10 +422,6 @@ export default {
}) })
}, },
optimisticFollow({ followedByCurrentUser }) { optimisticFollow({ followedByCurrentUser }) {
/*
* Note: followedByCountStartValue is updated to avoid counting from 0 when follow/unfollow
*/
this.followedByCountStartValue = this.user.followedByCount
const currentUser = this.$store.getters['auth/user'] const currentUser = this.$store.getters['auth/user']
if (followedByCurrentUser) { if (followedByCurrentUser) {
this.user.followedByCount++ this.user.followedByCount++
@ -443,7 +433,6 @@ export default {
this.user.followedByCurrentUser = followedByCurrentUser this.user.followedByCurrentUser = followedByCurrentUser
}, },
updateFollow({ followedByCurrentUser, followedBy, followedByCount }) { updateFollow({ followedByCurrentUser, followedBy, followedByCount }) {
this.followedByCountStartValue = this.user.followedByCount
this.user.followedByCount = followedByCount this.user.followedByCount = followedByCount
this.user.followedByCurrentUser = followedByCurrentUser this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedBy = followedBy this.user.followedBy = followedBy

View File

@ -21269,11 +21269,6 @@ vue-client-only@^2.1.0:
resolved "https://registry.yarnpkg.com/vue-client-only/-/vue-client-only-2.1.0.tgz#1a67a47b8ecacfa86d75830173fffee3bf8a4ee3" resolved "https://registry.yarnpkg.com/vue-client-only/-/vue-client-only-2.1.0.tgz#1a67a47b8ecacfa86d75830173fffee3bf8a4ee3"
integrity sha512-vKl1skEKn8EK9f8P2ZzhRnuaRHLHrlt1sbRmazlvsx6EiC3A8oWF8YCBrMJzoN+W3OnElwIGbVjsx6/xelY1AA== integrity sha512-vKl1skEKn8EK9f8P2ZzhRnuaRHLHrlt1sbRmazlvsx6EiC3A8oWF8YCBrMJzoN+W3OnElwIGbVjsx6/xelY1AA==
vue-count-to@~1.0.13:
version "1.0.13"
resolved "https://registry.yarnpkg.com/vue-count-to/-/vue-count-to-1.0.13.tgz#3e7573ea6e64c2b2972f64e0a2ab2e23c7590ff3"
integrity sha512-6R4OVBVNtQTlcbXu6SJ8ENR35M2/CdWt3Jmv57jOUM+1ojiFmjVGvZPH8DfHpMDSA+ITs+EW5V6qthADxeyYOQ==
vue-demi@^0.14.10: vue-demi@^0.14.10:
version "0.14.10" version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"