+
+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 000000000..a74aad6bd
Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/filled.png differ
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 000000000..a30288b72
Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/multiple-buttons.png differ
diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png
new file mode 100644
index 000000000..6db20c134
Binary files /dev/null and b/packages/ui/src/ocelot/components/OsLabeledButton/__screenshots__/chromium/playground.png differ
diff --git a/packages/ui/src/ocelot/components/OsLabeledButton/index.ts b/packages/ui/src/ocelot/components/OsLabeledButton/index.ts
new file mode 100644
index 000000000..12fd50ce5
--- /dev/null
+++ b/packages/ui/src/ocelot/components/OsLabeledButton/index.ts
@@ -0,0 +1 @@
+export { default as OsLabeledButton } from './OsLabeledButton.vue'
diff --git a/packages/ui/src/ocelot/index.ts b/packages/ui/src/ocelot/index.ts
index 81cf076ed..0c0ece095 100644
--- a/packages/ui/src/ocelot/index.ts
+++ b/packages/ui/src/ocelot/index.ts
@@ -1,2 +1,6 @@
// Ocelot migration icons — temporary, will be removed after Vue 3 migration
export { ocelotIcons } from './icons'
+
+// Ocelot composite components — built from Os* primitives
+export { OsActionButton } from './components/OsActionButton'
+export { OsLabeledButton } from './components/OsLabeledButton'
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 14d0610cc..7ab7fac9c 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -49,18 +49,25 @@ export default defineConfig({
'tailwind.preset': resolve(__dirname, 'src/tailwind.preset.ts'),
ocelot: resolve(__dirname, 'src/ocelot/index.ts'),
},
- formats: ['es', 'cjs'],
- fileName: (format, entryName) => `${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 @@
-
-
-
-
-
- {{ label }}
-
-
-
-
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 @@