feat(package/ui): os-counter-icon (#9471)

This commit is contained in:
Ulf Gebhardt 2026-03-30 03:28:55 +02:00 committed by GitHub
parent e144170cf0
commit 202c869515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 372 additions and 200 deletions

View File

@ -2,11 +2,11 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see no unread chat messages in the header', () => {
cy.get('.chat-notification-menu:visible', { timeout: 15000 }).should('exist')
cy.get('.chat-notification-menu:visible .count.--danger').should('not.exist')
cy.get('.chat-notification-menu:visible .os-counter-icon__count.os-counter-icon__count--danger').should('not.exist')
})
defineStep('I see {int} unread chat message in the header', (count) => {
cy.get('.chat-notification-menu:visible .count.--danger', { timeout: 15000 }).should(
cy.get('.chat-notification-menu:visible .os-counter-icon__count.os-counter-icon__count--danger', { timeout: 15000 }).should(
'contain',
count,
)

View File

@ -1,6 +1,6 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('the unread counter is removed', () => {
cy.get('.notifications-menu .counter-icon .count')
cy.get('.notifications-menu .os-counter-icon .os-counter-icon__count')
.should('not.exist')
})

View File

@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { markRaw } from 'vue-demi'
import { IconCheck } from '#src/components/OsIcon'
import OsCounterIcon from './OsCounterIcon.vue'
const icon = markRaw(IconCheck)
describe('osCounterIcon', () => {
const defaultProps = { icon, count: 5 }
it('renders with wrapper class', () => {
const wrapper = mount(OsCounterIcon, { props: defaultProps })
expect(wrapper.classes()).toContain('os-counter-icon')
})
it('renders the icon', () => {
const wrapper = mount(OsCounterIcon, { props: defaultProps })
expect(wrapper.find('svg').exists()).toBe(true)
})
it('displays the count badge when count > 0', () => {
const wrapper = mount(OsCounterIcon, { props: defaultProps })
expect(wrapper.find('.os-counter-icon__count').text()).toBe('5')
})
it('hides the count badge when count is 0', () => {
const wrapper = mount(OsCounterIcon, { props: { icon, count: 0 } })
expect(wrapper.find('.os-counter-icon__count').exists()).toBe(false)
})
it('caps count at 99+', () => {
const wrapper = mount(OsCounterIcon, { props: { icon, count: 150 } })
expect(wrapper.find('.os-counter-icon__count').text()).toBe('99+')
})
it('shows 99 without cap', () => {
const wrapper = mount(OsCounterIcon, { props: { icon, count: 99 } })
expect(wrapper.find('.os-counter-icon__count').text()).toBe('99')
})
describe('variants', () => {
it('applies danger class', () => {
const wrapper = mount(OsCounterIcon, { props: { ...defaultProps, danger: true } })
expect(wrapper.find('.os-counter-icon__count--danger').exists()).toBe(true)
})
it('applies soft class', () => {
const wrapper = mount(OsCounterIcon, { props: { ...defaultProps, soft: true } })
expect(wrapper.find('.os-counter-icon__count--soft').exists()).toBe(true)
})
it('soft takes precedence over danger', () => {
const wrapper = mount(OsCounterIcon, {
props: { ...defaultProps, soft: true, danger: true },
})
expect(wrapper.find('.os-counter-icon__count--soft').exists()).toBe(true)
expect(wrapper.find('.os-counter-icon__count--danger').exists()).toBe(false)
})
})
describe('keyboard accessibility', () => {
it('renders as non-interactive span (decorative element)', () => {
const wrapper = mount(OsCounterIcon, { props: defaultProps })
expect((wrapper.element as HTMLElement).tagName).toBe('SPAN')
})
it('is not focusable', () => {
const wrapper = mount(OsCounterIcon, { props: defaultProps })
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
})
})

View File

@ -0,0 +1,63 @@
import { ocelotIcons } from '#src/ocelot/icons'
import OsCounterIcon from './OsCounterIcon.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const iconMap = ocelotIcons
const iconNames = Object.keys(iconMap)
const meta: Meta<typeof OsCounterIcon> = {
title: 'Ocelot/CounterIcon',
component: OsCounterIcon,
tags: ['autodocs'],
argTypes: {
icon: {
control: 'select',
options: iconNames,
mapping: iconMap,
},
},
}
export default meta
type Story = StoryObj<typeof OsCounterIcon>
export const Playground: Story = {
args: {
count: 3,
icon: iconMap.bell,
danger: false,
soft: false,
},
}
export const Danger: Story = {
args: {
count: 42,
icon: iconMap.bell,
danger: true,
},
}
export const Soft: Story = {
args: {
count: 7,
icon: iconMap.comments,
soft: true,
},
}
export const Capped: Story = {
args: {
count: 150,
icon: iconMap.bell,
},
}
export const Zero: Story = {
args: {
count: 0,
icon: iconMap.bell,
},
}

View File

@ -0,0 +1,90 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
const STORY_URL = '/iframe.html?id=ocelot-countericon'
const STORY_ROOT = '#storybook-root'
async function waitForFonts(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('OsCounterIcon keyboard accessibility', () => {
test('element is not focusable (decorative)', async ({ page }) => {
await page.goto(`${STORY_URL}--playground&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await page.keyboard.press('Tab')
// CounterIcon should not receive focus — it's decorative
const counterIcon = root.locator('.os-counter-icon').first()
const isFocused = await counterIcon.evaluate((el) => document.activeElement === el)
expect(isFocused).toBe(false)
})
})
test.describe('OsCounterIcon visual regression', () => {
test('playground', async ({ page }) => {
await page.goto(`${STORY_URL}--playground&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('playground.png')
await checkA11y(page)
})
test('danger', async ({ page }) => {
await page.goto(`${STORY_URL}--danger&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('danger.png')
await checkA11y(page)
})
test('soft', async ({ page }) => {
await page.goto(`${STORY_URL}--soft&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('soft.png')
await checkA11y(page)
})
test('capped', async ({ page }) => {
await page.goto(`${STORY_URL}--capped&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('capped.png')
await checkA11y(page)
})
test('zero', async ({ page }) => {
await page.goto(`${STORY_URL}--zero&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root).toHaveScreenshot('zero.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,96 @@
<script lang="ts">
import { computed, defineComponent, h, isVue2 } from 'vue-demi'
import OsIcon from '#src/components/OsIcon/OsIcon.vue'
import type { Component, PropType } from 'vue-demi'
/**
* Icon with a positioned counter badge.
* Used for notification counts, comment counts, and similar indicators.
* The badge appears only when count > 0 and caps at 99+.
*/
export default defineComponent({
name: 'OsCounterIcon',
props: {
/** Icon component or render function */
icon: { type: [Object, Function] as PropType<Component>, required: true },
/** Number to display in the badge */
count: { type: Number, required: true },
/** Use danger color for the badge */
danger: { type: Boolean, default: false },
/** Use soft/muted color for the badge */
soft: { type: Boolean, default: false },
},
setup(props) {
const cappedCount = computed(() => (props.count <= 99 ? String(props.count) : '99+'))
const badgeClass = computed(() => {
const classes = ['os-counter-icon__count']
if (props.soft) classes.push('os-counter-icon__count--soft')
else if (props.danger) classes.push('os-counter-icon__count--danger')
return classes.join(' ')
})
return () => {
const icon = h(
OsIcon,
/* v8 ignore next -- Vue 2 */ isVue2
? { props: { icon: props.icon } }
: { icon: props.icon },
)
const children = [icon]
if (props.count > 0) {
children.push(
h(
'span',
{ class: badgeClass.value },
/* v8 ignore next -- Vue 2 */ isVue2 ? [cappedCount.value] : cappedCount.value,
),
)
}
return h('span', { class: 'os-counter-icon' }, children)
}
},
})
</script>
<style>
.os-counter-icon {
position: relative;
display: inline-flex;
}
.os-counter-icon__count {
position: absolute;
top: -4px;
right: 0;
transform: translateX(50%);
display: inline-flex;
align-items: center;
justify-content: center;
height: 16px;
min-width: 16px;
padding: 3px;
border-radius: 50%;
color: var(--os-counter-icon-color, var(--color-primary-contrast));
background-color: var(--os-counter-icon-bg, var(--color-primary));
font-size: 10px;
line-height: 1;
text-align: center;
}
.os-counter-icon__count--danger {
background-color: var(--os-counter-icon-danger-bg, var(--color-danger));
}
.os-counter-icon__count--soft {
background-color: var(--os-counter-icon-soft-bg, var(--color-default));
color: var(--os-counter-icon-soft-color, var(--color-text-soft));
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

View File

@ -0,0 +1 @@
export { default as OsCounterIcon } from './OsCounterIcon.vue'

View File

@ -3,4 +3,5 @@ export { ocelotIcons } from './icons'
// Ocelot composite components — built from Os* primitives
export { OsActionButton } from './components/OsActionButton'
export { OsCounterIcon } from './components/OsCounterIcon'
export { OsLabeledButton } from './components/OsLabeledButton'

View File

@ -13,23 +13,22 @@
}"
>
<template #icon>
<counter-icon :icon="icons.chatBubble" :count="unreadRoomCount" danger />
<os-counter-icon :icon="icons.chatBubble" :count="unreadRoomCount" danger />
</template>
</os-button>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { OsCounterIcon, OsButton } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import { mapGetters, mapMutations } from 'vuex'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { unreadRoomsQuery, roomCountUpdated } from '~/graphql/Rooms'
export default {
name: 'ChatNotificationMenu',
components: {
OsCounterIcon,
OsButton,
CounterIcon,
},
computed: {
...mapGetters({

View File

@ -89,7 +89,7 @@ describe('CommentList.vue', () => {
it('displays a comments counter that ignores disabled and deleted comments', () => {
wrapper = Wrapper()
expect(wrapper.find('.count').text()).toEqual('1')
expect(wrapper.find('.os-counter-icon__count').text()).toEqual('1')
})
describe('scrollToAnchor mixin', () => {

View File

@ -1,7 +1,7 @@
<template>
<div id="comments" class="comment-list">
<h3 class="title">
<counter-icon :icon="icons.comments" :count="commentsCount" />
<os-counter-icon :icon="icons.comments" :count="commentsCount" />
{{ $t('common.comment', null, 0) }}
</h3>
<div v-if="post.comments" class="comments">
@ -19,15 +19,15 @@
</div>
</template>
<script>
import { OsCounterIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import CommentCard from '~/components/CommentCard/CommentCard'
import scrollToAnchor from '~/mixins/scrollToAnchor'
export default {
mixins: [scrollToAnchor],
components: {
CounterIcon,
OsCounterIcon,
CommentCard,
},
props: {
@ -73,7 +73,7 @@ export default {
> .title {
margin-top: 0;
> .counter-icon {
> .os-counter-icon {
margin-right: $space-small;
}
}

View File

@ -39,7 +39,7 @@ describe('NotificationMenu.vue', () => {
it('renders as link without counter', () => {
wrapper = Wrapper()
expect(wrapper.classes('notifications-menu')).toBe(true)
expect(() => wrapper.get('.count')).toThrow()
expect(wrapper.find('.os-counter-icon__count').exists()).toBe(false)
})
it('no dropdown is rendered', () => {
@ -74,7 +74,7 @@ describe('NotificationMenu.vue', () => {
it('renders as link without counter', () => {
wrapper = Wrapper()
expect(wrapper.classes('notifications-menu')).toBe(true)
expect(() => wrapper.get('.count')).toThrow()
expect(wrapper.find('.os-counter-icon__count').exists()).toBe(false)
})
it('no dropdown is rendered', () => {
@ -161,12 +161,14 @@ describe('NotificationMenu.vue', () => {
it('displays the number of unread notifications', () => {
wrapper = Wrapper()
expect(wrapper.find('.count').text()).toEqual('2')
expect(wrapper.find('.os-counter-icon__count').text()).toEqual('2')
})
it('renders the counter in red', () => {
wrapper = Wrapper()
expect(wrapper.find('.count').classes()).toContain('--danger')
expect(wrapper.find('.os-counter-icon__count').classes()).toContain(
'os-counter-icon__count--danger',
)
})
})
})

View File

@ -14,7 +14,7 @@
}"
>
<template #icon>
<counter-icon :icon="icons.bell" :count="unreadNotificationsCount" danger />
<os-counter-icon :icon="icons.bell" :count="unreadNotificationsCount" danger />
</template>
</os-button>
<dropdown
@ -38,7 +38,7 @@
@click="toggleMenu"
>
<template #icon>
<counter-icon :icon="icons.bell" :count="unreadNotificationsCount" danger />
<os-counter-icon :icon="icons.bell" :count="unreadNotificationsCount" danger />
</template>
</os-button>
</template>
@ -83,7 +83,7 @@
<script>
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy'
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsCounterIcon, OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import {
notificationQuery,
@ -91,7 +91,6 @@ import {
notificationAdded,
markAllAsReadMutation,
} from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown'
import NotificationsTable from '../NotificationsTable/NotificationsTable.vue'
@ -99,7 +98,7 @@ export default {
name: 'NotificationMenu',
components: {
NotificationsTable,
CounterIcon,
OsCounterIcon,
Dropdown,
OsButton,
OsIcon,

View File

@ -90,7 +90,7 @@
/>
</div>
<div v-else class="categories-placeholder"></div>
<counter-icon
<os-counter-icon
:icon="icons.heartO"
:count="post.shoutedCount"
v-tooltip="{
@ -98,7 +98,7 @@
placement: 'bottom-start',
}"
/>
<counter-icon
<os-counter-icon
:icon="icons.comments"
:count="post.commentsCount"
v-tooltip="{
@ -106,7 +106,7 @@
placement: 'bottom-start',
}"
/>
<counter-icon
<os-counter-icon
:icon="icons.handPointer"
:count="post.clickedCount"
v-tooltip="{
@ -114,7 +114,7 @@
placement: 'bottom-start',
}"
/>
<counter-icon
<os-counter-icon
:icon="icons.eye"
:count="post.viewedTeaserCount"
v-tooltip="{
@ -166,11 +166,10 @@
</template>
<script>
import { OsCard, OsIcon } from '@ocelot-social/ui'
import { OsCounterIcon, OsCard, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import Category from '~/components/Category'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
import HcRibbon from '~/components/Ribbon'
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
@ -188,9 +187,9 @@ export default {
components: {
Category,
ContentMenu,
OsCounterIcon,
OsCard,
OsIcon,
CounterIcon,
DateTimeRange,
HcRibbon,
LocationTeaser,
@ -413,7 +412,7 @@ export default {
flex-grow: 1;
}
> .counter-icon {
> .os-counter-icon {
display: block;
margin-right: $space-small;
opacity: $opacity-disabled;

View File

@ -1,46 +0,0 @@
import { mount } from '@vue/test-utils'
import CounterIcon from './CounterIcon'
import { OsIcon } from '@ocelot-social/ui'
import { ocelotIcons } from '@ocelot-social/ui/ocelot'
const localVue = global.localVue
describe('CounterIcon.vue', () => {
let propsData, wrapper, count
const Wrapper = () => {
return mount(CounterIcon, { propsData, localVue })
}
describe('given a valid icon and count below 100', () => {
beforeEach(() => {
propsData = { icon: ocelotIcons.comments, count: 42 }
wrapper = Wrapper()
count = wrapper.find('.count')
})
it('renders the icon', () => {
expect(wrapper.findComponent(OsIcon).exists()).toBe(true)
})
it('renders the count', () => {
expect(count.text()).toEqual('42')
})
})
describe('given a valid icon and count above 100', () => {
beforeEach(() => {
propsData = { icon: ocelotIcons.comments, count: 750 }
wrapper = Wrapper()
count = wrapper.find('.count')
})
it('renders the icon', () => {
expect(wrapper.findComponent(OsIcon).exists()).toBe(true)
})
it('renders the capped count with a plus', () => {
expect(count.text()).toEqual('99+')
})
})
})

View File

@ -1,41 +0,0 @@
import { storiesOf } from '@storybook/vue'
import helpers from '~/storybook/helpers'
import CounterIcon from './CounterIcon.vue'
storiesOf('Generic/CounterIcon', module)
.addDecorator(helpers.layout)
.add('default', () => ({
components: { CounterIcon },
template: `
<counter-icon icon="flag" :count="3" />
`,
}))
.add('high count', () => ({
components: { CounterIcon },
template: `
<counter-icon icon="comments" :count="150" />
`,
}))
.add('danger', () => ({
components: { CounterIcon },
template: `
<counter-icon icon="bell" :count="42" danger />
`,
}))
.add('soft', () => ({
components: { CounterIcon },
template: `
<counter-icon icon="bell" :count="42" soft />
`,
}))
.add('count is 0', () => ({
components: { CounterIcon },
template: `
<counter-icon icon="bell" :count="0" />
`,
}))

View File

@ -1,73 +0,0 @@
<template>
<span class="counter-icon">
<os-icon :icon="icon" />
<span v-if="count > 0" :class="counterClass">{{ cappedCount }}</span>
</span>
</template>
<script>
import { OsIcon } from '@ocelot-social/ui'
export default {
components: { OsIcon },
props: {
icon: { type: [Object, Function], required: true },
count: { type: Number, required: true },
danger: { type: Boolean, default: false },
soft: { type: Boolean, default: false },
},
computed: {
cappedCount() {
return this.count <= 99 ? this.count : '99+'
},
counterClass() {
let counterClass = 'count'
if (this.soft) counterClass += ' --soft'
else if (this.danger) counterClass += ' --danger'
if (this.count === 0) counterClass += ' --inactive'
return counterClass
},
},
}
</script>
<style lang="scss">
.counter-icon {
position: relative;
> .count {
position: absolute;
top: -$space-xx-small;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: $size-icon-base;
min-width: $size-icon-base;
padding: 3px; // magic number to center count
border-radius: 50%;
transform: translateX(50%);
color: $color-neutral-100;
background-color: $color-primary;
font-size: 10px; // magic number to center count
line-height: 1;
text-align: center;
&.--danger {
background-color: $color-danger;
}
&.--inactive {
background-color: $color-neutral-60;
}
&.--soft {
background-color: $color-neutral-90;
color: $text-color-soft;
}
}
}
</style>

View File

@ -29,10 +29,6 @@ export default {
align-items: flex-end;
color: $text-color-softer;
font-size: $font-size-small;
> .counts > .counter-icon {
margin: 0 $space-x-small;
}
}
}
</style>

View File

@ -3,10 +3,10 @@
<p class="label">{{ option.title | truncate(70) }}</p>
<div class="metadata">
<span class="counts">
<counter-icon :icon="icons.comments" :count="option.commentsCount" soft />
<counter-icon :icon="icons.heartO" :count="option.shoutedCount" soft />
<counter-icon :icon="icons.handPointer" :count="option.clickedCount" soft />
<counter-icon :icon="icons.eye" :count="option.viewedTeaserCount" soft />
<os-counter-icon :icon="icons.comments" :count="option.commentsCount" soft />
<os-counter-icon :icon="icons.heartO" :count="option.shoutedCount" soft />
<os-counter-icon :icon="icons.handPointer" :count="option.clickedCount" soft />
<os-counter-icon :icon="icons.eye" :count="option.viewedTeaserCount" soft />
</span>
{{ option.author.name | truncate(32) }} - {{ option.createdAt | dateTime('dd.MM.yyyy') }}
</div>
@ -14,13 +14,13 @@
</template>
<script>
import { OsCounterIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
export default {
name: 'SearchPost',
components: {
CounterIcon,
OsCounterIcon,
},
props: {
option: { type: Object, required: true },
@ -46,7 +46,7 @@ export default {
color: $text-color-softer;
font-size: $font-size-small;
> .counts > .counter-icon {
> .counts > .os-counter-icon {
margin: 0 $space-x-small;
}
}