From e144170cf065a761ac9c95efab3088b586cb96a7 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Mon, 30 Mar 2026 02:16:21 +0200 Subject: [PATCH] feat(package/ui): actionButton + labledButton (#9470) --- packages/ui/scripts/check-completeness.ts | 26 +-- .../components/OsMenu/OsMenu.visual.spec.ts | 11 ++ .../chromium/custom-menu-item.png | Bin 0 -> 4330 bytes packages/ui/src/index.ts | 3 + .../OsActionButton/OsActionButton.spec.ts | 120 ++++++++++++ .../OsActionButton/OsActionButton.stories.ts | 62 ++++++ .../OsActionButton.visual.spec.ts | 82 ++++++++ .../OsActionButton/OsActionButton.vue | 110 +++++++++++ .../__screenshots__/chromium/disabled.png | Bin 0 -> 1858 bytes .../__screenshots__/chromium/filled.png | Bin 0 -> 2125 bytes .../__screenshots__/chromium/loading.png | Bin 0 -> 2490 bytes .../__screenshots__/chromium/playground.png | Bin 0 -> 2020 bytes .../ocelot/components/OsActionButton/index.ts | 1 + .../OsLabeledButton/OsLabeledButton.spec.ts | 94 +++++++++ .../OsLabeledButton.stories.ts | 56 ++++++ .../OsLabeledButton.visual.spec.ts | 66 +++++++ .../OsLabeledButton/OsLabeledButton.vue | 84 ++++++++ .../__screenshots__/chromium/filled.png | Bin 0 -> 2895 bytes .../chromium/multiple-buttons.png | Bin 0 -> 5752 bytes .../__screenshots__/chromium/playground.png | Bin 0 -> 2403 bytes .../components/OsLabeledButton/index.ts | 1 + packages/ui/src/ocelot/index.ts | 4 + packages/ui/vite.config.ts | 23 ++- .../_new/styles/ocelot-ui-variables.scss | 4 + webapp/components/ActionButton.spec.js | 66 ------- webapp/components/ActionButton.vue | 68 ------- webapp/components/Button/FollowButton.spec.js | 96 ---------- webapp/components/Button/FollowButton.vue | 93 --------- webapp/components/Button/JoinLeaveButton.vue | 34 ++-- webapp/components/CommentCard/CommentCard.vue | 48 ++++- .../EmotionButton/EmotionButton.vue | 70 ------- .../FilterMenu/FilterMenuComponent.vue | 8 +- .../components/FilterMenu/PostTypeFilter.vue | 23 +-- webapp/components/HeaderMenu/HeaderMenu.vue | 83 +++++++- .../components/InviteButton/InviteButton.vue | 147 -------------- .../LoginButton/LoginButton.spec.js | 44 ----- webapp/components/LoginButton/LoginButton.vue | 72 ------- webapp/components/ObserveButton.spec.js | 56 ------ webapp/components/ObserveButton.vue | 35 ---- webapp/components/ShoutButton.spec.js | 63 ------ webapp/components/ShoutButton.vue | 89 --------- .../__snapshots__/ActionButton.spec.js.snap | 92 --------- .../__snapshots__/ObserveButton.spec.js.snap | 93 --------- .../__snapshots__/ShoutButton.spec.js.snap | 181 ------------------ .../LabeledButton/LabeledButton.story.js | 24 --- .../generic/LabeledButton/LabeledButton.vue | 51 ----- .../PaginationButtons/PaginationButtons.vue | 36 +--- webapp/composables/useFollowUser.js | 20 ++ webapp/composables/useFollowUser.spec.js | 53 +++++ webapp/composables/useInviteCode.js | 53 +++++ webapp/composables/useInviteCode.spec.js | 99 ++++++++++ webapp/composables/useJoinLeaveGroup.js | 21 ++ webapp/composables/useJoinLeaveGroup.spec.js | 63 ++++++ webapp/composables/useShout.js | 19 ++ webapp/composables/useShout.spec.js | 57 ++++++ webapp/graphql/Shout.js | 13 ++ webapp/jest.config.js | 2 +- webapp/layouts/basic.vue | 53 ++++- webapp/locales/de.json | 8 +- webapp/locales/en.json | 8 +- webapp/locales/es.json | 8 +- webapp/locales/fr.json | 8 +- webapp/locales/it.json | 8 +- webapp/locales/nl.json | 8 +- webapp/locales/pl.json | 10 +- webapp/locales/pt.json | 8 +- webapp/locales/ru.json | 8 +- webapp/locales/sq.json | 8 +- webapp/locales/uk.json | 8 +- webapp/nuxt.config.js | 5 +- webapp/pages/post/_id/_slug/index.spec.js | 10 +- webapp/pages/post/_id/_slug/index.vue | 85 +++++--- .../_id/__snapshots__/_slug.spec.js.snap | 8 +- webapp/pages/profile/_id/_slug.vue | 88 +++++++-- webapp/pages/settings/security.vue | 2 +- 75 files changed, 1529 insertions(+), 1531 deletions(-) create mode 100644 packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png create mode 100644 packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts create mode 100644 packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue create mode 100644 packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png create mode 100644 packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png create mode 100644 packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/loading.png create mode 100644 packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png create mode 100644 packages/ui/src/ocelot/components/OsActionButton/index.ts create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png create mode 100644 packages/ui/src/ocelot/components/OsLabeledButton/index.ts delete mode 100644 webapp/components/ActionButton.spec.js delete mode 100644 webapp/components/ActionButton.vue delete mode 100644 webapp/components/Button/FollowButton.spec.js delete mode 100644 webapp/components/Button/FollowButton.vue delete mode 100644 webapp/components/EmotionButton/EmotionButton.vue delete mode 100644 webapp/components/InviteButton/InviteButton.vue delete mode 100644 webapp/components/LoginButton/LoginButton.spec.js delete mode 100644 webapp/components/LoginButton/LoginButton.vue delete mode 100644 webapp/components/ObserveButton.spec.js delete mode 100644 webapp/components/ObserveButton.vue delete mode 100644 webapp/components/ShoutButton.spec.js delete mode 100644 webapp/components/ShoutButton.vue delete mode 100644 webapp/components/__snapshots__/ActionButton.spec.js.snap delete mode 100644 webapp/components/__snapshots__/ObserveButton.spec.js.snap delete mode 100644 webapp/components/__snapshots__/ShoutButton.spec.js.snap delete mode 100644 webapp/components/_new/generic/LabeledButton/LabeledButton.story.js delete mode 100644 webapp/components/_new/generic/LabeledButton/LabeledButton.vue create mode 100644 webapp/composables/useFollowUser.js create mode 100644 webapp/composables/useFollowUser.spec.js create mode 100644 webapp/composables/useInviteCode.js create mode 100644 webapp/composables/useInviteCode.spec.js create mode 100644 webapp/composables/useJoinLeaveGroup.js create mode 100644 webapp/composables/useJoinLeaveGroup.spec.js create mode 100644 webapp/composables/useShout.js create mode 100644 webapp/composables/useShout.spec.js create mode 100644 webapp/graphql/Shout.js diff --git a/packages/ui/scripts/check-completeness.ts b/packages/ui/scripts/check-completeness.ts index 4f5e7bb8f..179717a2e 100644 --- a/packages/ui/scripts/check-completeness.ts +++ b/packages/ui/scripts/check-completeness.ts @@ -87,7 +87,6 @@ function checkStoryCoverage( } const results: CheckResult[] = [] -let hasErrors = false // Find all Vue components (excluding index files) const components = [ @@ -169,18 +168,19 @@ for (const componentPath of components) { if (result.errors.length > 0 || result.warnings.length > 0) { results.push(result) } - - if (result.errors.length > 0) { - hasErrors = true - } } -// --- Ocelot stories (no .vue files, story-driven checks) --- +// --- Ocelot stories without .vue files (e.g. icon stories) --- const ocelotStories = await glob('src/ocelot/**/*.stories.ts') +const componentDirs = new Set(components.map((c) => dirname(c))) for (const storyPath of ocelotStories) { const storyName = basename(storyPath, '.stories.ts') const storyDir = dirname(storyPath) + + // Skip stories that already have a .vue component (checked in the component loop above) + if (componentDirs.has(storyDir)) continue + const visualTestPath = join(storyDir, `${storyName}.visual.spec.ts`) const unitTestPath = join(storyDir, 'index.spec.ts') @@ -210,10 +210,6 @@ for (const storyPath of ocelotStories) { if (result.errors.length > 0 || result.warnings.length > 0) { results.push(result) } - - if (result.errors.length > 0) { - hasErrors = true - } } // Output results @@ -230,16 +226,12 @@ if (results.length === 0) { } for (const warning of result.warnings) { - console.log(` ⚠ ${warning}`) + console.log(` ✗ ${warning}`) } console.log('') } - if (hasErrors) { - console.log('Completeness check failed with errors.') - process.exit(1) - } else { - console.log('Completeness check passed with warnings.') - } + console.log('Completeness check failed.') + process.exit(1) } diff --git a/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts index a52ef321d..65d377d97 100644 --- a/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts +++ b/packages/ui/src/components/OsMenu/OsMenu.visual.spec.ts @@ -82,4 +82,15 @@ test.describe('OsMenu visual regression', () => { await checkA11y(page) }) + + test('custom menu item', async ({ page }) => { + await page.goto(`${STORY_URL}--custom-menu-item&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('custom-menu-item.png') + + await checkA11y(page) + }) }) diff --git a/packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png b/packages/ui/src/components/OsMenu/__screenshots__/chromium/custom-menu-item.png new file mode 100644 index 0000000000000000000000000000000000000000..ef8a22ab89fe85fa3be99acc1e0ddcd590e444a4 GIT binary patch literal 4330 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 JTB&0F@~;E3m)!sW literal 0 HcmV?d00001 diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d626404c1..2625a045d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -10,6 +10,9 @@ // Re-export all components export * from './components' +// Re-export Ocelot composite components +export * from './ocelot' + // Export Vue plugin for global registration export { default as OcelotUI } from './plugin' diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts new file mode 100644 index 000000000..d0c5600ab --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.spec.ts @@ -0,0 +1,120 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { markRaw } from 'vue-demi' + +import { IconCheck } from '#src/components/OsIcon' + +import OsActionButton from './OsActionButton.vue' + +const icon = markRaw(IconCheck) + +describe('osActionButton', () => { + const defaultProps = { count: 5, ariaLabel: 'Like', icon } + + it('renders the count badge', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('.os-action-button__count').text()).toBe('5') + }) + + it('renders with wrapper class', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.classes()).toContain('os-action-button') + }) + + it('passes aria-label to the button', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Like') + }) + + it('renders a circular OsButton', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').classes()).toContain('rounded-full') + }) + + describe('filled prop', () => { + it('renders outline appearance by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('outline') + }) + + it('renders filled appearance when filled is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, filled: true }, + }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('filled') + }) + }) + + describe('disabled prop', () => { + it('is not disabled by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('disabled')).toBeUndefined() + }) + + it('disables the button when disabled is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, disabled: true }, + }) + + expect(wrapper.find('button').attributes('disabled')).toBeDefined() + }) + }) + + describe('loading prop', () => { + it('is not loading by default', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-busy')).toBeUndefined() + }) + + it('shows loading state when loading is true', () => { + const wrapper = mount(OsActionButton, { + props: { ...defaultProps, loading: true }, + }) + + expect(wrapper.find('button').attributes('aria-busy')).toBe('true') + }) + }) + + describe('click event', () => { + it('emits click when button is clicked', async () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + }) + }) + + describe('keyboard accessibility', () => { + it('renders a native button element (inherits keyboard support)', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').exists()).toBe(true) + }) + + it('button is not excluded from tab order', () => { + const wrapper = mount(OsActionButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('tabindex')).not.toBe('-1') + }) + }) + + describe('icon slot', () => { + it('renders custom icon slot content', () => { + const wrapper = mount(OsActionButton, { + props: defaultProps, + slots: { icon: '' }, + }) + + expect(wrapper.find('.custom-icon').exists()).toBe(true) + }) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts new file mode 100644 index 000000000..4dd4d0f7d --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.stories.ts @@ -0,0 +1,62 @@ +import { ocelotIcons } from '#src/ocelot/icons' + +import OsActionButton from './OsActionButton.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const iconMap = ocelotIcons +const iconNames = Object.keys(iconMap) + +const meta: Meta = { + title: 'Ocelot/ActionButton', + component: OsActionButton, + tags: ['autodocs'], + argTypes: { + icon: { + control: 'select', + options: iconNames, + mapping: iconMap, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Playground: Story = { + args: { + count: 5, + ariaLabel: 'Like', + icon: iconMap.heartO, + filled: false, + disabled: false, + loading: false, + }, +} + +export const Filled: Story = { + args: { + count: 12, + ariaLabel: 'Liked', + icon: iconMap.heartO, + filled: true, + }, +} + +export const Loading: Story = { + args: { + count: 3, + ariaLabel: 'Loading', + icon: iconMap.heartO, + loading: true, + }, +} + +export const Disabled: Story = { + args: { + count: 0, + ariaLabel: 'Disabled', + icon: iconMap.heartO, + disabled: true, + }, +} diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts new file mode 100644 index 000000000..ad98c2290 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.visual.spec.ts @@ -0,0 +1,82 @@ +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-actionbutton' +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('OsActionButton keyboard accessibility', () => { + test('button shows visible focus indicator', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + await page.keyboard.press('Tab') + const button = root.locator('button').first() + const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle) + + expect(outline, 'Button must have visible focus outline via Tab').not.toBe('none') + }) +}) + +test.describe('OsActionButton 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('filled', async ({ page }) => { + await page.goto(`${STORY_URL}--filled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('filled.png') + + await checkA11y(page) + }) + + test('loading', async ({ page }) => { + await page.goto(`${STORY_URL}--loading&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + await page.evaluate(() => { + document.querySelectorAll('.os-button__spinner, .os-button__spinner circle').forEach((el) => { + ;(el as HTMLElement).style.animationPlayState = 'paused' + }) + }) + + await expect(root).toHaveScreenshot('loading.png') + + await checkA11y(page) + }) + + test('disabled', async ({ page }) => { + await page.goto(`${STORY_URL}--disabled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('disabled.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue new file mode 100644 index 000000000..53b6f8c04 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/OsActionButton.vue @@ -0,0 +1,110 @@ + + + diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..d71f0ee4c758056302b27a145d8bf97538a08d22 GIT binary patch literal 1858 zcmXw4c~lbE8s}MQGo((PCRbjiY3|R_w4S-9i8>;#WUeh*YKEqQdwRlBqve$vDWa7U zf)U3B9A&^aI<9CAf*X!HuArbK<{t8d=KXW;J@@y_dDM$^z{L&ey{yK002<+ z^l1XUW>N^={Rt zRXi*;y_YqgKwqOGD|dd-dwHyt=E*Z5J;9~^8Gfte4g;}o z0|4mCtJ_PlWxOb$z&~_^uUItkjj!Cs^x-A?geY&s=*(gtjdO2?tphQ1tqXSi#Vp34 zVazuf*!-Y<((s!A7Vcak9h~pj_QD?%fOK`WG8^CY$B2LkGYf*@okLvbCVg!dTznkb zSUNY4>!{WxHv3(8xuo8FP|p7=E#S_x&UY^ak_Qw*YRZk?6)xm?whQJ+jSV4w*?hK& zIVD?+!-qiL)Al4AWPSyx=Bif2q(B{cSWf@%Dy$0CVtKxnwDeg}9pk3VT4Wt%jA2^0 ziQ?8h3Wh1|>L<5diPWvsT^lh2f^8ighR(`~fUfcBTNB z9q6vp)(YkoY!C-}g+yPad--pqG$IwJ=H zx+bN$WmA2(I6B!VD7q?hex4s8?Q!~@&Dfc3ZI$2#zEi-|l$MgxCWYz+U4!u0^tF7A zh_9WY;)w#AbhHQ;v9%Mbk+U&RgB2;j&WG_KfM~VfgHMx^)9m~MgVNyL4l;FF`RpPxG^=b|MdLR#HgxZv&xI(?c#5$`9^sE#ledS zl`@gWx(Sljv~Y{keX8XO*(jV@aZP6IM|N*pE8HS|)$1}}rcGNH?f!&tD6Iri{xu1$ zCVC^gt|zQzhJYW@RW7Z(EyBSo#(?r-)AH((n^JDZ40mz0j8g`CoxbU`Y?~Q#t%llP zUkagEC>$kC+8Gw5TVoWx01#Nlh5Qx|XJcW3LGPjL zgLpWu86RXSk`EUEAf;yg0I5re2J63j;a{iu-9T!FG~-Ybu=#}|?3^Ex!xI8EAq!)s zvf9Aj44aaq?O`elhHV!LzvXk_&pl%HX>!*T=4IIVxp1|#;DeQ;#xI}P}?3B zbL3MC@W$3YtJY|#d-UuaCR2+l`CR;KG0F<}>ctf1mWk>^0q)-HaNJ9%AhBuxH1KSr@Z+qcq?z>Mw37ciL zPdUU!=7|Pg`TudOWlw{VGGiOfBUQ5j^%HjIGovY~BwMVHLG!~uJrrr*xFX$EapBDP zjH=Q&6#IhRPgHt?dGg}t(p#KZL)@1%X(lC5@p8`j{$yA~Dc39Qeu^ZkXtS7Q5afqd zFCVdh=KH9Pi*#2NFaZx(ip>9+=8m-&m~MR9(Z)gv#PvM(z6nKHOY}->R5sz*YR#5r zcQh&+rMS&)Sy2f@4=p6N>~}>6IPN-pcx<|^aa=L600@T*#NEJD-@V9iHS}|jlvtOb zAd1r|g@KqT@o7)>NU%Qev2Tm3bf!l{t}>eV?QFP;Je=sQdw4n$Rk8#9|^+iV+m9PWVdS9EglZI|J@ye~3 uCL|Mu#k8=K`@2lWkqlezAC%=^-~qsjZ=L|;?DmHo;(6J}?c=3u5B>+phke)p literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/filled.png new file mode 100644 index 0000000000000000000000000000000000000000..0abd5d9cf0d4f6822761109a375bce07e7045bb8 GIT binary patch literal 2125 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 literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png b/packages/ui/src/ocelot/components/OsActionButton/__screenshots__/chromium/playground.png new file mode 100644 index 0000000000000000000000000000000000000000..b56aec888975603a887fb7bb7787c551ee4a3b68 GIT binary patch literal 2020 zcmXX{c{~&RAD>bx@u){N*W-~Z8jFQrlavf2Gk4_7J@?I)BT7HHo*bF9kz&qr-la@09ww`Kp)M@?omL&8CBzrRpW3%iv700$4d zuB^?ikv`u&;f{MTQBt(hyB`jL{0UMi&m<6)yyiKE31m78YuScTmHreZGvdp>*n`J! zeixO;fu0uW7_5#X-lnLjyX>*)4JD)d7}R2SYTpzFH98m_NE@d}Trw)r)+Ie77f78c zeT{~qo|9BFGnV4Bva^i2@(`ck!z4ji)*x)fWoM_q{Un0QSu0zAbqtl4 zcU(ie^dP9AaodgjJKo1W_15#+&=!x#>RPXw7!_6L1AMSYnCOdCk{4U+of~F0IYdJH z`biv71mvXAHd~Sq)|tJpW|SWG`nfwqHDvQeGETQClJQ+hh>rwP(ISI?>~NQ=-&9v- z48RWc+mwmk4?+!XMcNW)l^oq;RNIOrVdcZaqoW3%E}47`;AqH%{?D^)#OF^@3Itm< zUEk{_oUe^6Jkr&*TuPrTtAt}vZO>~cj-#a11%On_STcHY^1?ee88mchm7bE$>df+) zMZtIpku-7xiO|X%5axS&^ft*M!DO7zOmv__k0iHn+lWQe%l_{UrFkiX8f|z=Ku0}X zjbUa)65BF_(t*;f;fcaaJKvis{p#-i2gbl@ayObVufd{vbU|+b20|NA@vf%#z|597 zEu=$z_;U6Mc?OtL5Zl~C!(r}Uf+AVt#b+(8r}zp7B;Pon_&$m99DK^Vl29e)7`V3G zx{>yfKx6flU-R^g1@CO26LcKMtRJKS1Mkp=VP++=>O9)I{X0bQ`L^80{+54vX%%N? zh2(TGi1zxyF@Ao2Bd;=rcVhWZ3P6VqFqT}lsN3?hwKfZHY%tSxlpF{5@J>IRL%dSp z@A;Xcv9U1>yFgaGn$BQ#u2vX#wl$0@-V9Bg8|>?i7^hW}aAvLync#@3LI2(^yq1bj zP!#zM)}OH`sF!-x_PU*YR7cAc+exTXGnTUK#))&WSsyrC)hd>A(LFw4;&wmUG=mx| zr*nGV(L$xN^2_kB9(THg*W`?u?y7yC34|(5LhFJJp`i}B->dh!a`{z#JsyicR&uUi z@RENu-`#hJ2dQ$A zo_ER62gf{51&Il=0GxmpQWRrN?K`2c8h_(Mk}%=9`bSyaCOHDi%Xj<&kzUo2A_lv265>?u{Dzh(_6?SnHX|&Ku&p^B^hil7+S5QBb4RjLNfr z!{5}Ba%Fhj3yp@(^nfpc&K6PQ>*?KW3z7v|Kvq?#w4c{$ z6bY3s8)+eVUU4ks6^#5b@vVC8N=sTULV4@Y>Adni6{|}Y$%y8-&)0)N*VttFQ@g_i zt&OsR4lU#(`=8n&qtrB%O$2F_8clD+XeKeZ-1r)!xrag z<`(#lJi{kLX@f`k0N|Z`g{x7_zknD*vt~ZyNt?orh{*Z6M*1tRSyiQ9;B1ej0#4BW zl-^D54rF#=eAID`Lx3rC2LvwU_u18rr2TlmQ(#q+5Ycug{N=wlr5CF+Fko^=X5Bpr zbK%Q{sVPqHz+C#zN7ZKg=Op=YC8$eYjWv$f4D66X#G+ZWoJm@ z$wubV^iR_UFZRm-sl(}qeFZb?GBOsd>HK>|c#6$i$kSd`+zLy2>Q>kF4RM0c3s|3_ zDZ?icdU#e(9r`Y|coR20e?jk;fo^ES1$K#)@sdQR)r{?V8RuAr+Hu1V*<-Tep}@Rv!&?*;SSXKHI*N zfWQM)IW}k(S%2HS?6g?TLo2yFk7I&(>-SKXm%)T9BfdBI? zZw<;|9~YhPT@{2}dqrl8MG zyaTKLd4BU3_Eo;WlzW3SOc3WBk|zQH@OZcmsoT2;2l=1$et=Wp1H?PZ4-38v9ELD{ IZsd{jf8^e}kpKVy literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsActionButton/index.ts b/packages/ui/src/ocelot/components/OsActionButton/index.ts new file mode 100644 index 000000000..00fa70c7b --- /dev/null +++ b/packages/ui/src/ocelot/components/OsActionButton/index.ts @@ -0,0 +1 @@ +export { default as OsActionButton } from './OsActionButton.vue' diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts new file mode 100644 index 000000000..ddae24534 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.spec.ts @@ -0,0 +1,94 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { markRaw } from 'vue-demi' + +import { IconCheck } from '#src/components/OsIcon' + +import OsLabeledButton from './OsLabeledButton.vue' + +const icon = markRaw(IconCheck) + +describe('osLabeledButton', () => { + const defaultProps = { icon, label: 'Filter' } + + it('renders the label', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('.os-labeled-button__label').text()).toBe('Filter') + }) + + it('passes label as aria-label to the button', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Filter') + }) + + it('renders with wrapper class', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.classes()).toContain('os-labeled-button') + }) + + it('renders a circular OsButton', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').classes()).toContain('rounded-full') + }) + + describe('filled prop', () => { + it('renders outline appearance by default', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('outline') + }) + + it('renders filled appearance when filled is true', () => { + const wrapper = mount(OsLabeledButton, { + props: { ...defaultProps, filled: true }, + }) + + expect(wrapper.find('button').attributes('data-appearance')).toBe('filled') + }) + }) + + describe('click event', () => { + it('emits click when button is clicked', async () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + await wrapper.find('button').trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + }) + }) + + describe('keyboard accessibility', () => { + it('renders a native button element (inherits keyboard support)', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').exists()).toBe(true) + }) + + it('button is not excluded from tab order', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('tabindex')).not.toBe('-1') + }) + + it('has an accessible name via aria-label', () => { + const wrapper = mount(OsLabeledButton, { props: defaultProps }) + + expect(wrapper.find('button').attributes('aria-label')).toBe('Filter') + }) + }) + + describe('icon slot', () => { + it('renders custom icon slot content', () => { + const wrapper = mount(OsLabeledButton, { + props: defaultProps, + slots: { icon: '' }, + }) + + expect(wrapper.find('.custom-icon').exists()).toBe(true) + }) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts new file mode 100644 index 000000000..b0941890b --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.stories.ts @@ -0,0 +1,56 @@ +import { ocelotIcons } from '#src/ocelot/icons' + +import OsLabeledButton from './OsLabeledButton.vue' + +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +const iconMap = ocelotIcons +const iconNames = Object.keys(iconMap) + +const meta: Meta = { + title: 'Ocelot/LabeledButton', + component: OsLabeledButton, + tags: ['autodocs'], + argTypes: { + icon: { + control: 'select', + options: iconNames, + mapping: iconMap, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Playground: Story = { + args: { + icon: iconMap.plus, + label: 'Add item', + filled: false, + }, +} + +export const Filled: Story = { + args: { + icon: iconMap.check, + label: 'Selected', + filled: true, + }, +} + +export const MultipleButtons: Story = { + render: () => ({ + components: { OsLabeledButton }, + setup() { + return { icons: ocelotIcons } + }, + template: ` +
+ + + +
+ `, + }), +} diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts new file mode 100644 index 000000000..6ce9c48a9 --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.visual.spec.ts @@ -0,0 +1,66 @@ +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-labeledbutton' +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('OsLabeledButton keyboard accessibility', () => { + test('button shows visible focus indicator', async ({ page }) => { + await page.goto(`${STORY_URL}--playground&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + + await page.keyboard.press('Tab') + const button = root.locator('button').first() + const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle) + + expect(outline, 'Button must have visible focus outline via Tab').not.toBe('none') + }) +}) + +test.describe('OsLabeledButton 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('filled', async ({ page }) => { + await page.goto(`${STORY_URL}--filled&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('filled.png') + + await checkA11y(page) + }) + + test('multiple buttons', async ({ page }) => { + await page.goto(`${STORY_URL}--multiple-buttons&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + + await expect(root).toHaveScreenshot('multiple-buttons.png') + + await checkA11y(page) + }) +}) diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue new file mode 100644 index 000000000..421de37aa --- /dev/null +++ b/packages/ui/src/ocelot/components/OsLabeledButton/OsLabeledButton.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png new file mode 100644 index 0000000000000000000000000000000000000000..a74aad6bd69b75df3651e9ba0c2e6de1c862f49d GIT binary patch literal 2895 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 MJ%nzl_8siM0g2j35C8xG literal 0 HcmV?d00001 diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png new file mode 100644 index 0000000000000000000000000000000000000000..a30288b72f773c8b14ad086e3fa9f88867ea9650 GIT binary patch literal 5752 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 `${entryName}.${format === 'es' ? 'mjs' : 'cjs'}`, }, rollupOptions: { external: ['vue', 'vue-demi', 'tailwindcss'], - output: { - globals: { - vue: 'Vue', - 'vue-demi': 'VueDemi', + output: [ + { + format: 'es', + entryFileNames: '[name].mjs', + chunkFileNames: '[name]-[hash].mjs', + assetFileNames: '[name].[ext]', + globals: { vue: 'Vue', 'vue-demi': 'VueDemi' }, }, - assetFileNames: '[name].[ext]', - }, + { + format: 'cjs', + entryFileNames: '[name].cjs', + chunkFileNames: '[name]-[hash].cjs', + assetFileNames: '[name].[ext]', + globals: { vue: 'Vue', 'vue-demi': 'VueDemi' }, + }, + ], }, cssCodeSplit: false, sourcemap: true, diff --git a/webapp/assets/_new/styles/ocelot-ui-variables.scss b/webapp/assets/_new/styles/ocelot-ui-variables.scss index aef21c472..fdc4ff531 100644 --- a/webapp/assets/_new/styles/ocelot-ui-variables.scss +++ b/webapp/assets/_new/styles/ocelot-ui-variables.scss @@ -57,4 +57,8 @@ // Text --color-text-soft: #{$text-color-soft}; // rgb(112, 103, 126) + + // Ocelot ActionButton badge + --os-action-button-color: #{$color-primary-dark}; + --os-action-button-bg: #{$color-secondary-inverse}; } diff --git a/webapp/components/ActionButton.spec.js b/webapp/components/ActionButton.spec.js deleted file mode 100644 index 9da1ae6ea..000000000 --- a/webapp/components/ActionButton.spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/vue' -import '@testing-library/jest-dom' -import ActionButton from './ActionButton.vue' -import { ocelotIcons } from '@ocelot-social/ui/ocelot' - -const localVue = global.localVue - -describe('ActionButton.vue', () => { - let mocks - - beforeEach(() => { - mocks = { - $t: jest.fn((t) => t), - } - }) - - let wrapper - const Wrapper = ({ isDisabled = false } = {}) => { - return render(ActionButton, { - mocks, - localVue, - propsData: { - icon: ocelotIcons.heartO, - text: 'Click me', - count: 7, - disabled: isDisabled, - }, - }) - } - - describe('when not disabled', () => { - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders', () => { - expect(wrapper.container).toMatchSnapshot() - }) - - it('shows count', () => { - const count = screen.getByText('7') - expect(count).toBeInTheDocument() - }) - - it('button emits click event', async () => { - const button = screen.getByRole('button') - await fireEvent.click(button) - expect(wrapper.emitted().click).toEqual([[]]) - }) - }) - - describe('when disabled', () => { - beforeEach(() => { - wrapper = Wrapper({ isDisabled: true }) - }) - - it('renders', () => { - expect(wrapper.container).toMatchSnapshot() - }) - - it('button is disabled', () => { - const button = screen.getByRole('button') - expect(button).toBeDisabled() - }) - }) -}) diff --git a/webapp/components/ActionButton.vue b/webapp/components/ActionButton.vue deleted file mode 100644 index 827538cf5..000000000 --- a/webapp/components/ActionButton.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/webapp/components/Button/FollowButton.spec.js b/webapp/components/Button/FollowButton.spec.js deleted file mode 100644 index ab58ee5e2..000000000 --- a/webapp/components/Button/FollowButton.spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { mount } from '@vue/test-utils' -import FollowButton from './FollowButton.vue' - -const localVue = global.localVue - -describe('FollowButton.vue', () => { - let mocks - let propsData - - beforeEach(() => { - mocks = { - $t: jest.fn(), - $apollo: { - mutate: jest.fn(), - }, - } - propsData = {} - }) - - describe('mount', () => { - let wrapper - const Wrapper = () => { - return mount(FollowButton, { mocks, propsData, localVue }) - } - - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders button and text', () => { - expect(mocks.$t).toHaveBeenCalledWith('followButton.follow') - expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1) - }) - - it('renders button and text when followed', () => { - propsData.isFollowed = true - wrapper = Wrapper() - expect(mocks.$t).toHaveBeenCalledWith('followButton.following') - expect(wrapper.findAll('[data-test="follow-btn"]')).toHaveLength(1) - }) - - describe('clicking the follow button', () => { - beforeEach(() => { - propsData = { followId: 'u1' } - mocks.$apollo.mutate.mockResolvedValue({ - data: { followUser: { id: 'u1', followedByCurrentUser: true } }, - }) - wrapper = Wrapper() - }) - - it('emits optimistic result', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('optimistic')[0]).toEqual([{ followedByCurrentUser: true }]) - }) - - it('calls followUser mutation', async () => { - await wrapper.vm.toggle() - expect(mocks.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { id: 'u1' } }), - ) - }) - - it('emits update with server response', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('update')[0]).toEqual([{ id: 'u1', followedByCurrentUser: true }]) - }) - }) - - describe('clicking the unfollow button', () => { - beforeEach(() => { - propsData = { followId: 'u1', isFollowed: true } - mocks.$apollo.mutate.mockResolvedValue({ - data: { unfollowUser: { id: 'u1', followedByCurrentUser: false } }, - }) - wrapper = Wrapper() - }) - - it('emits optimistic result', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('optimistic')[0]).toEqual([{ followedByCurrentUser: false }]) - }) - - it('calls unfollowUser mutation', async () => { - await wrapper.vm.toggle() - expect(mocks.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { id: 'u1' } }), - ) - }) - - it('emits update with server response', async () => { - await wrapper.vm.toggle() - expect(wrapper.emitted('update')[0]).toEqual([{ id: 'u1', followedByCurrentUser: false }]) - }) - }) - }) -}) diff --git a/webapp/components/Button/FollowButton.vue b/webapp/components/Button/FollowButton.vue deleted file mode 100644 index 4e65fc1c1..000000000 --- a/webapp/components/Button/FollowButton.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/webapp/components/Button/JoinLeaveButton.vue b/webapp/components/Button/JoinLeaveButton.vue index 1e2a22874..7370fea31 100644 --- a/webapp/components/Button/JoinLeaveButton.vue +++ b/webapp/components/Button/JoinLeaveButton.vue @@ -29,7 +29,7 @@ import { OsButton, OsIcon } from '@ocelot-social/ui' import { iconRegistry } from '~/utils/iconRegistry' import ConfirmModal from '~/components/Modal/ConfirmModal' -import { joinGroupMutation, leaveGroupMutation } from '~/graphql/groups' +import { useJoinLeaveGroup } from '~/composables/useJoinLeaveGroup' export default { name: 'JoinLeaveButton', @@ -115,6 +115,11 @@ export default { }, created() { this.icons = iconRegistry + const { joinLeaveGroup } = useJoinLeaveGroup({ + apollo: this.$apollo, + toast: this.$toast, + }) + this._joinLeaveGroup = joinLeaveGroup }, methods: { onHover() { @@ -124,30 +129,21 @@ export default { }, toggle() { if (this.isMember) { - this.openLeaveModal() + this.showConfirmModal = true } else { this.joinLeave() } }, - openLeaveModal() { - this.showConfirmModal = true - }, async joinLeave() { - const join = !this.isMember - const mutation = join ? joinGroupMutation() : leaveGroupMutation() - this.hovered = false - this.$emit('prepare', join) - - try { - const { data } = await this.$apollo.mutate({ - mutation, - variables: { groupId: this.group.id, userId: this.userId }, - }) - const joinedLeftGroupResult = join ? data.JoinGroup : data.LeaveGroup - this.$emit('update', joinedLeftGroupResult) - } catch (error) { - this.$toast.error(error.message) + this.$emit('prepare', !this.isMember) + const { success, data } = await this._joinLeaveGroup({ + groupId: this.group.id, + userId: this.userId, + isMember: this.isMember, + }) + if (success) { + this.$emit('update', data) } }, }, diff --git a/webapp/components/CommentCard/CommentCard.vue b/webapp/components/CommentCard/CommentCard.vue index 64d87e3b2..a9025464d 100644 --- a/webapp/components/CommentCard/CommentCard.vue +++ b/webapp/components/CommentCard/CommentCard.vue @@ -46,13 +46,15 @@
- - - diff --git a/webapp/components/FilterMenu/FilterMenuComponent.vue b/webapp/components/FilterMenu/FilterMenuComponent.vue index 81261aa0f..8b446ecf5 100644 --- a/webapp/components/FilterMenu/FilterMenuComponent.vue +++ b/webapp/components/FilterMenu/FilterMenuComponent.vue @@ -4,7 +4,7 @@

{{ $t('filter-menu.filter-by') }}

-