From 202c869515e493eba10daaa33b8c6fefe8d242ee Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Mon, 30 Mar 2026 03:28:55 +0200 Subject: [PATCH] feat(package/ui): os-counter-icon (#9471) --- ...{int}_unread_chat_message_in_the_header.js | 4 +- .../the_unread_counter_is_removed.js | 2 +- .../OsCounterIcon/OsCounterIcon.spec.ts | 86 ++++++++++++++++ .../OsCounterIcon/OsCounterIcon.stories.ts | 63 ++++++++++++ .../OsCounterIcon.visual.spec.ts | 90 ++++++++++++++++ .../OsCounterIcon/OsCounterIcon.vue | 96 ++++++++++++++++++ .../__screenshots__/chromium/capped.png | Bin 0 -> 1012 bytes .../__screenshots__/chromium/danger.png | Bin 0 -> 1016 bytes .../__screenshots__/chromium/playground.png | Bin 0 -> 898 bytes .../__screenshots__/chromium/soft.png | Bin 0 -> 878 bytes .../__screenshots__/chromium/zero.png | Bin 0 -> 550 bytes .../ocelot/components/OsCounterIcon/index.ts | 1 + packages/ui/src/ocelot/index.ts | 1 + .../ChatNotificationMenu.vue | 7 +- .../CommentList/CommentList.spec.js | 2 +- webapp/components/CommentList/CommentList.vue | 8 +- .../NotificationMenu/NotificationMenu.spec.js | 10 +- .../NotificationMenu/NotificationMenu.vue | 9 +- webapp/components/PostTeaser/PostTeaser.vue | 15 ++- .../generic/CounterIcon/CounterIcon.spec.js | 46 --------- .../generic/CounterIcon/CounterIcon.story.js | 41 -------- .../_new/generic/CounterIcon/CounterIcon.vue | 73 ------------- .../generic/SearchGroup/SearchGroup.vue | 4 - .../generic/SearchPost/SearchPost.vue | 14 +-- 24 files changed, 372 insertions(+), 200 deletions(-) create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.stories.ts create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.visual.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.vue create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/capped.png create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/danger.png create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/playground.png create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/soft.png create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/zero.png create mode 100644 packages/ui/src/ocelot/components/OsCounterIcon/index.ts delete mode 100644 webapp/components/_new/generic/CounterIcon/CounterIcon.spec.js delete mode 100644 webapp/components/_new/generic/CounterIcon/CounterIcon.story.js delete mode 100644 webapp/components/_new/generic/CounterIcon/CounterIcon.vue diff --git a/cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js b/cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js index 45f0bc298..7f63cdadf 100644 --- a/cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js +++ b/cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js @@ -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, ) diff --git a/cypress/support/step_definitions/Notification.Mention/the_unread_counter_is_removed.js b/cypress/support/step_definitions/Notification.Mention/the_unread_counter_is_removed.js index 60f43bb43..29ad58b8e 100644 --- a/cypress/support/step_definitions/Notification.Mention/the_unread_counter_is_removed.js +++ b/cypress/support/step_definitions/Notification.Mention/the_unread_counter_is_removed.js @@ -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') }) diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.spec.ts b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.spec.ts new file mode 100644 index 000000000..7dbb6d816 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.spec.ts @@ -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() + }) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.stories.ts b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.stories.ts new file mode 100644 index 000000000..1bd8c17bd --- /dev/null +++ b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.stories.ts @@ -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 = { + title: 'Ocelot/CounterIcon', + component: OsCounterIcon, + tags: ['autodocs'], + argTypes: { + icon: { + control: 'select', + options: iconNames, + mapping: iconMap, + }, + }, +} + +export default meta +type Story = StoryObj + +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, + }, +} diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.visual.spec.ts b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.visual.spec.ts new file mode 100644 index 000000000..3365d283a --- /dev/null +++ b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.visual.spec.ts @@ -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) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.vue b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.vue new file mode 100644 index 000000000..4a42cd48b --- /dev/null +++ b/packages/ui/src/ocelot/components/OsCounterIcon/OsCounterIcon.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/capped.png b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/capped.png new file mode 100644 index 0000000000000000000000000000000000000000..cf0997e972ce5905fd76be4d058b7e94b7c1e99e GIT binary patch literal 1012 zcmV>^iznXj;nls*6T(G( zUB%$LCr@1n0001>09OXo9Xobx+_-V@anHW)8MPJ5md<*3`P}()Zx6F*ex)cDoilUt zjJk^Ai4(%bw5rko00024)3AWr-QC^V+WOd|ZG)9$s&7N0qr5DBwkP#P=h5(0K@*px2dUV#flYc*RDNs z?A$Z$+q2n$NF;b+-MzPzmxSHUz1{DB@_l|~#C?m3B8m3ay1%|z1pokmQN@NnB$LT_ zJRUBl*Osq-WWnaG`@{6QA<;2(CciSG@O9}F004l(#je&^e&6iss?t~A>^yTW6R$*hIBgJ)z#I}G5Vo00002+AC3GC zYFk@ddwY9-e}5v82mk;8VC;?j3uM{Q~&?~_&*hT%nSek z0E{UC6#xK$aX~-@003ZI5KsXC02mhpQ~&?~#svWt004k-K|lon0AO7B6954J|MR_{ iQ2+n{21!IgR09CHO(=8=)PA8fH6c4XGtay5+??rm+H%vaZ3|Q8_VU_ZO47HJmz($b|Nnpcpa1`Ndj4~p zi4i(tJ!@8feX^b3UiR%PxfMQpGOLa?Ri^L%ZD_am;k4G36HgpHw)kVFit^uc+rRtF zvNZ-ew;^Ro-)`o+}u+4uI=j4-|<{QL5IAy zY<}%>bvYwYn@Z-7hN@p*UN$u~En2kb^YK~2uWv4WU3D(L=j-?O$33o}4}ZSe_USLL z{{Cy#D~(@&J5+Ig{(dudpw0&urf3u_B+S|;}H#;?DVt%bU`F6D3i z-sjwIzkhYfg)~jgR_pC;{VYHY6P8?AU6!J&ziWoI{SG$n_xh)!!bE`L4xiS^1vtx| z7T+)Wc7{p$H4omG+(py&Z~A@BU7iOh|6rHPx{dEE)}%k(y?)-Bbvpz8dw=f?j#c+> zJ^JO1<>caLDbJoi%ZZwAlotb3&2as76m!;x^Xz`d&+jgGt^j(M;Y0jy^99$gUE8*O z`}QqcTACGo6AfcltzI3yH7gXPWwC;)Sa+-aLtE3YTJIOP@z&PXf}*vjNt>~>W74Ea zqS|34<>lt4rm{8_20)31K(jyAD^gQacbC2Ga$77aDhgJ<@1cKy^VF$RB_$=ZudU$( zDops&D-i&cd+>kCk@PgM0LKcD$(&4DKq|SZ0hnD6%urYW%%cWsC{l;+0~P%LALu&I g6v$u$Wn5;4(|ZJYzcEE^0E#epy85}Sb4q9e028#>umAu6 literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/playground.png b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/playground.png new file mode 100644 index 0000000000000000000000000000000000000000..d77be06760f01c6d8de63ba246d5418f5eab0d9b GIT binary patch literal 898 zcmV-|1AY97P)Ofw`+`yjEs(sMvH+n z7m`!xrh>fA_n$q|ytl6Qp-)b{uz7R-YAYK1`t(=;0001)%THUKnwqMvuFlKLi^t%D!J#bGvAJXd1@qDzx(#VeUI+=?(F5&?&WK<0RR91WG>AP>g42Pdwcux^76vM zLR(wgrK`Urcc-R*T}h|=_U^2B@ZJmKKPPvuT$>31001C!sR6aWzrU)gsJ#K*_rFN-gfM0&1)~!L}4`m004mhLYRC=()jpzluo<5yIWgZSBtN{`RVL$ThiYA z`pf&5mlL0Tes;A|^H|yO_ND*;001(yW(T#iv$LtGDLI`+8V^2^e7XPevbS2FT3Htq zKI_=gnw|G= z3jhEBAak+x3{~t}TDbAkooDuxmppgqkpKVy05)V@0W}PRnJYa<4p+yb)vb>R!xx*Y zPj)oNVqpLP000}3zuv*b(i^WFc=AB`>7nzNuFgcisP;fv@$RzX#`=e=DoO(Y008(G z)*Vp)5(QCN6aWB#Y`7Uv2L}h^@%Y5VM6^g5ii%NdXlSUuzCHi|0DvqA6Nx`RArt+_ zV*l_yYBaF4v=jgU06-S}9Z*x$)z#J3*3Qq*kByBD3=9MS0059>>EA(ZX=&-`=vZ7_ z?CR(60Y)q_3o>eFWYao_tVex_pvwZa&v0GSEZH$Wf_iY%LF)Y-~aPl;gSQbCR4AP z@}BJkiR(RG%Jk~s!N+?)wVB_m`d!KTFf97IyQ2UnQ0_s{yg1{@UQ0LK%<=XC>M-f1bSY#)l6dUcAVNjlEmwDiN=)rZ#K#?8Ai;F5i_l>hJrLRbOup z)YY(B_@w+pYg=1eJG;2(XlrZh*&Zs_u3h`&I(P0|UtizVt5-)yM{8?qCnqN}X#q8a zzn;0c|B%8gucbyaeag$r^`@U*fBf;sojZ5l_HVhoY11ZQ+VvB&KxBaoWHz$glI|0D%aq6iRT2?2_EH8lXE z*1=OC0H{KP6YOBshai9c|F5wl@jQ^h2FfnX4AD={Rt9tY2Kk7=)78&qol`;+00WtZ AlK=n! literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/zero.png b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/zero.png new file mode 100644 index 0000000000000000000000000000000000000000..ea9962fbb607401e4b3a604443ad94390304e282 GIT binary patch literal 550 zcmeAS@N?(olHy`uVBq!ia0y~yV0i#!OK~s($%*r~cr!3C-u85H45^s&=B|BKQlJR? zgVmnXMLY#LmUvB9ZE^4rd?VD9yE*g zS{Fm>|e*OKIV88+7eNYZkS6}h@=bv})-o1H~ zvoRtjKAs&Y-f^>&^Hq+S_EfLkck}kfiF38C-~kE?Y<}EuE5}S;PA)GmPpa2#Z=C^lIZF*({l4Ign0i;x#8i3*GpakI@n%{6MZ@YN+(F&Wsy1rS9j%KXB{BqBp zJuE<#9S5IVuefMF*YE#s)2thL+d;M_eUJ?3x3Rakx3gOpw)$bgjjXMpI5_g=i|myg zvuM5P=Cgg525|y~1omGRzT&YqZ1^}3(zXBq literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/index.ts b/packages/ui/src/ocelot/components/OsCounterIcon/index.ts new file mode 100644 index 000000000..f3f9feb41 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsCounterIcon/index.ts @@ -0,0 +1 @@ +export { default as OsCounterIcon } from './OsCounterIcon.vue' diff --git a/packages/ui/src/ocelot/index.ts b/packages/ui/src/ocelot/index.ts index 0c0ece095..4d114719e 100644 --- a/packages/ui/src/ocelot/index.ts +++ b/packages/ui/src/ocelot/index.ts @@ -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' diff --git a/webapp/components/ChatNotificationMenu/ChatNotificationMenu.vue b/webapp/components/ChatNotificationMenu/ChatNotificationMenu.vue index c2cce6318..e58e358eb 100644 --- a/webapp/components/ChatNotificationMenu/ChatNotificationMenu.vue +++ b/webapp/components/ChatNotificationMenu/ChatNotificationMenu.vue @@ -13,23 +13,22 @@ }" > - - diff --git a/webapp/components/generic/SearchGroup/SearchGroup.vue b/webapp/components/generic/SearchGroup/SearchGroup.vue index 652c2743f..20874841e 100644 --- a/webapp/components/generic/SearchGroup/SearchGroup.vue +++ b/webapp/components/generic/SearchGroup/SearchGroup.vue @@ -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; - } } } diff --git a/webapp/components/generic/SearchPost/SearchPost.vue b/webapp/components/generic/SearchPost/SearchPost.vue index bb7152644..d335662f4 100644 --- a/webapp/components/generic/SearchPost/SearchPost.vue +++ b/webapp/components/generic/SearchPost/SearchPost.vue @@ -3,10 +3,10 @@

{{ option.title | truncate(70) }}

@@ -14,13 +14,13 @@