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) */
--color-disabled: #c4bdb5;
--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 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 `<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">` |
| 8 | Spinner | ✅ UI-Library | → OsSpinner (LoadingSpinner gelöscht) |
| 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 | |
| 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") |

View File

@ -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 → <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): ✅
├─ 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 `<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] `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] 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) → `<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 ✅
- [ ] 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 | **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 | **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 `<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 BadgeVariants,
} from './OsBadge'
export { OsNumber, numberVariants, type NumberVariants } from './OsNumber'

View File

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

View File

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

View File

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

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',
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()
})
})

View File

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

View File

@ -14,61 +14,55 @@ exports[`UserTeaserPopover does not show badges when disabled 1`] = `
>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
42
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
profile.followers
</p>
profile.followers
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
7
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</p>
common.post
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
13
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</p>
common.comment
</span>
</div>
</li>
</ul>
@ -131,61 +125,55 @@ exports[`UserTeaserPopover given a non-touch device does not show button when us
>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
42
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
profile.followers
</p>
profile.followers
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
7
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</p>
common.post
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
13
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</p>
common.comment
</span>
</div>
</li>
</ul>
@ -248,61 +236,55 @@ exports[`UserTeaserPopover given a touch device does not show button when userLi
>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
42
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
profile.followers
</p>
profile.followers
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
7
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</p>
common.post
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
13
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</p>
common.comment
</span>
</div>
</li>
</ul>
@ -365,61 +347,166 @@ exports[`UserTeaserPopover given a touch device shows button when userLink is pr
>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
42
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
profile.followers
</p>
profile.followers
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
7
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</p>
common.post
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
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
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</p>
profile.followers
</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.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>
</li>
</ul>
@ -482,61 +569,55 @@ exports[`UserTeaserPopover shows badges when enabled 1`] = `
>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
42
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
profile.followers
</p>
profile.followers
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 1ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
7
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.post
</p>
common.post
</span>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
class="os-number flex flex-col items-center text-center"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
<span
class="os-number-count font-bold text-[1.5rem] tabular-nums text-center inline-block"
style="min-width: 2ch;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
13
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
common.comment
</p>
common.comment
</span>
</div>
</li>
</ul>

View File

@ -60,7 +60,7 @@ describe('SearchResults', () => {
describe('result contains 25 posts, 8 users and 0 hashtags', () => {
// 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
beforeEach(async () => {

View File

@ -49,32 +49,27 @@ describe('TabNavigation', () => {
})
describe('displays', () => {
// 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"
const counterTimeout = 3000 + 10
beforeEach(() => {
jest.useFakeTimers()
// Re-mount with fake timers so requestAnimationFrame is captured
wrapper = Wrapper()
jest.advanceTimersByTime(1600)
})
it('shows a total of 17 results', () => {
setTimeout(() => {
expect(wrapper.find('.total-search-results').text()).toContain('17')
}, counterTimeout)
afterEach(() => {
jest.useRealTimers()
})
it('shows tab with 12 posts', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('12')
}, counterTimeout)
expect(wrapper.find('[data-test="Post-tab"]').text()).toContain('12')
})
it('shows tab with 9 users', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="User-tab"]').text()).toContain('9')
}, counterTimeout)
expect(wrapper.find('[data-test="User-tab"]').text()).toContain('9')
})
it('shows tab with 0 hashtags', () => {
setTimeout(() => {
expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
}, counterTimeout)
expect(wrapper.find('[data-test="Hashtag-tab"]').text()).toContain('0')
})
describe('basic props setting', () => {

View File

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

View File

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

View File

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

View File

@ -288,36 +288,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -2343,36 +2333,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -3424,36 +3404,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
4
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -4287,36 +4257,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
4
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -5096,36 +5056,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
4
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -6019,36 +5969,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
4
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -7096,36 +7036,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>
@ -8150,36 +8080,26 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<div
class="ds-flex-item"
>
<client-only-stub>
<div
class="ds-number ds-number-size-x-large"
<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;"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
<span>
<client-only-stub
placeholder="0"
tag="span"
>
<span>
0
</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>
0
</span>
<span
class="sr-only"
>
0
</span>
<span
class="os-number-label text-[12px] text-[var(--color-text-soft)]"
>
group.membersCount
</span>
</div>
</div>
</div>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -51,22 +51,18 @@
</div>
<div class="ds-flex">
<div class="ds-flex-item">
<client-only>
<ds-number :label="$t('profile.followers')">
<hc-count-to
slot="count"
:start-val="followedByCountStartValue"
:end-val="user.followedByCount"
/>
</ds-number>
</client-only>
<os-number
:count="user.followedByCount"
:label="$t('profile.followers')"
:animated="true"
/>
</div>
<div class="ds-flex-item">
<client-only>
<ds-number :label="$t('profile.following')">
<hc-count-to slot="count" :end-val="user.followingCount" />
</ds-number>
</client-only>
<os-number
:count="user.followingCount"
:label="$t('profile.following')"
:animated="true"
/>
</div>
</div>
<div v-if="!myProfile" class="action-buttons">
@ -212,14 +208,13 @@
</template>
<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 uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/Button/FollowButton'
import HcCountTo from '~/components/CountTo.vue'
import HcBadges from '~/components/Badges.vue'
import FollowList, { followListVisibleCount } from '~/components/features/ProfileList/FollowList'
import HcEmpty from '~/components/Empty/Empty'
@ -250,11 +245,11 @@ export default {
OsCard,
OsButton,
OsIcon,
OsNumber,
OsSpinner,
SocialMedia,
PostTeaser,
HcFollowButton,
HcCountTo,
HcBadges,
HcEmpty,
ProfileAvatar,
@ -289,7 +284,6 @@ export default {
pageSize: 6,
tabActive: 'post',
filter,
followedByCountStartValue: 0,
followedByCount: followListVisibleCount,
followingCount: followListVisibleCount,
updateUserMutation,
@ -428,10 +422,6 @@ export default {
})
},
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']
if (followedByCurrentUser) {
this.user.followedByCount++
@ -443,7 +433,6 @@ export default {
this.user.followedByCurrentUser = followedByCurrentUser
},
updateFollow({ followedByCurrentUser, followedBy, followedByCount }) {
this.followedByCountStartValue = this.user.followedByCount
this.user.followedByCount = followedByCount
this.user.followedByCurrentUser = followedByCurrentUser
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"
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:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"