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 000000000..cf0997e97 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/capped.png differ diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/danger.png b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/danger.png new file mode 100644 index 000000000..cfa6b18de Binary files /dev/null and b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/danger.png differ 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 000000000..d77be0676 Binary files /dev/null and b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/playground.png differ diff --git a/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/soft.png b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/soft.png new file mode 100644 index 000000000..196662c5f Binary files /dev/null and b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/soft.png differ 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 000000000..ea9962fbb Binary files /dev/null and b/packages/ui/src/ocelot/components/OsCounterIcon/__screenshots__/chromium/zero.png differ 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 @@