diff --git a/packages/ui/src/components/OsButton/OsButton.spec.ts b/packages/ui/src/components/OsButton/OsButton.spec.ts index d8e983241..e4dd6f828 100644 --- a/packages/ui/src/components/OsButton/OsButton.spec.ts +++ b/packages/ui/src/components/OsButton/OsButton.spec.ts @@ -284,6 +284,158 @@ describe('osButton', () => { }) }) + describe('suffix slot', () => { + it('renders suffix slot content in .os-button__suffix wrapper', () => { + const wrapper = mount(OsButton, { + slots: { suffix: '' }, + }) + const suffixWrapper = wrapper.find('.os-button__suffix') + + expect(suffixWrapper.exists()).toBe(true) + expect(suffixWrapper.find('[data-testid="suffix"]').exists()).toBe(true) + }) + + it('renders both suffix and text', () => { + const wrapper = mount(OsButton, { + slots: { + suffix: '', + default: 'Next', + }, + }) + + expect(wrapper.find('.os-button__suffix').exists()).toBe(true) + expect(wrapper.text()).toContain('Next') + }) + + it('adds gap-2 class when suffix and text are present', () => { + const wrapper = mount(OsButton, { + slots: { + suffix: '', + default: 'Next', + }, + }) + const contentSpan = wrapper.find('button > span') + + expect(contentSpan.classes()).toContain('gap-2') + }) + + it('adds gap-1 class for small sizes with suffix and text', () => { + const wrapper = mount(OsButton, { + props: { size: 'sm' }, + slots: { + suffix: '', + default: 'Next', + }, + }) + const contentSpan = wrapper.find('button > span') + + expect(contentSpan.classes()).toContain('gap-1') + expect(contentSpan.classes()).not.toContain('gap-2') + }) + + it('renders icon + text + suffix together', () => { + const wrapper = mount(OsButton, { + slots: { + icon: '', + default: 'Save', + suffix: '', + }, + }) + + expect(wrapper.find('.os-button__icon').exists()).toBe(true) + expect(wrapper.find('.os-button__suffix').exists()).toBe(true) + expect(wrapper.text()).toContain('Save') + }) + + it('suffix has -mr-1 margin (mirrored from icon -ml-1)', () => { + const wrapper = mount(OsButton, { + slots: { + suffix: '', + default: 'Next', + }, + }) + const suffixWrapper = wrapper.find('.os-button__suffix') + + expect(suffixWrapper.classes()).toContain('-mr-1') + }) + + it('adds relative overflow-visible when loading with suffix', () => { + const wrapper = mount(OsButton, { + props: { loading: true }, + slots: { + suffix: '', + default: 'Save', + }, + }) + const suffixWrapper = wrapper.find('.os-button__suffix') + + expect(suffixWrapper.classes()).toContain('relative') + expect(suffixWrapper.classes()).toContain('overflow-visible') + }) + + it('suffix-only button (no icon, no text) has -ml-1 -mr-1', () => { + const wrapper = mount(OsButton, { + slots: { suffix: '' }, + }) + const suffixWrapper = wrapper.find('.os-button__suffix') + + expect(suffixWrapper.classes()).toContain('-ml-1') + expect(suffixWrapper.classes()).toContain('-mr-1') + }) + + it('icon + suffix without text: icon has -ml-1 but no -mr-1', () => { + const wrapper = mount(OsButton, { + slots: { + icon: '', + suffix: '', + }, + }) + const iconWrapper = wrapper.find('.os-button__icon') + + expect(iconWrapper.classes()).toContain('-ml-1') + expect(iconWrapper.classes()).not.toContain('-mr-1') + }) + + it('icon + suffix without text: suffix has -mr-1 but no -ml-1', () => { + const wrapper = mount(OsButton, { + slots: { + icon: '', + suffix: '', + }, + }) + const suffixWrapper = wrapper.find('.os-button__suffix') + + expect(suffixWrapper.classes()).toContain('-mr-1') + expect(suffixWrapper.classes()).not.toContain('-ml-1') + }) + + it('icon + suffix without text has gap-2', () => { + const wrapper = mount(OsButton, { + slots: { + icon: '', + suffix: '', + }, + }) + const contentSpan = wrapper.find('button > span') + + expect(contentSpan.classes()).toContain('gap-2') + }) + + it('icon + suffix without text has gap-1 for small size', () => { + const wrapper = mount(OsButton, { + props: { size: 'sm' }, + slots: { + icon: '', + suffix: '', + }, + }) + const contentSpan = wrapper.find('button > span') + + expect(contentSpan.classes()).toContain('gap-1') + expect(contentSpan.classes()).not.toContain('gap-2') + }) + }) + describe('circle prop', () => { it('renders as round button with rounded-full and p-0', () => { const wrapper = mount(OsButton, { diff --git a/packages/ui/src/components/OsButton/OsButton.stories.ts b/packages/ui/src/components/OsButton/OsButton.stories.ts index ba0367c00..614591663 100644 --- a/packages/ui/src/components/OsButton/OsButton.stories.ts +++ b/packages/ui/src/components/OsButton/OsButton.stories.ts @@ -27,6 +27,7 @@ interface PlaygroundArgs { disabled: boolean loading: boolean icon: string + suffix: string label: string } @@ -71,6 +72,10 @@ export const Playground: StoryObj = { control: 'select', options: Object.keys(iconMap), }, + suffix: { + control: 'select', + options: Object.keys(iconMap), + }, label: { control: 'text', }, @@ -85,23 +90,26 @@ export const Playground: StoryObj = { disabled: false, loading: false, icon: 'none', + suffix: 'none', label: 'Button', }, render: (args) => ({ components: { OsButton }, setup() { const buttonProps = computed(() => { - const { icon: _icon, label: _label, ...rest } = args + const { icon: _icon, suffix: _suffix, label: _label, ...rest } = args return rest }) const IconComponent = computed(() => iconMap[args.icon] ?? null) + const SuffixComponent = computed(() => iconMap[args.suffix] ?? null) const label = computed(() => args.label) - return { buttonProps, IconComponent, label } + return { buttonProps, IconComponent, SuffixComponent, label } }, template: ` {{ label }} + `, }), @@ -683,6 +691,117 @@ export const Polymorphic: Story = { }), } +export const Suffix: Story = { + render: () => ({ + components: { OsButton, IconCheck, IconClose, IconPlus }, + template: ` +
+
+

Text + Suffix

+
+ + + Confirm + + + + Add + + + + Remove + +
+
+
+

Text + Suffix + Loading

+
+ + + Confirm + + + + Add + +
+
+
+

Icon + Text + Suffix

+
+ + + + Action + + + + + Delete + +
+
+
+

Icon + Text + Suffix + Loading

+
+ + + + Action + + + + + Delete + +
+
+
+

Icon + Suffix + Loading (no text)

+
+ + + + + + + + +
+
+
+

Suffix Only

+
+ + + + + + + + + +
+
+
+

Suffix Only + Loading + Circle

+
+ + + + + + + + + +
+
+
+ `, + }), +} + export const Loading: Story = { render: () => ({ components: { OsButton, IconCheck }, diff --git a/packages/ui/src/components/OsButton/OsButton.visual.spec.ts b/packages/ui/src/components/OsButton/OsButton.visual.spec.ts index d5caa00ea..1de82bb79 100644 --- a/packages/ui/src/components/OsButton/OsButton.visual.spec.ts +++ b/packages/ui/src/components/OsButton/OsButton.visual.spec.ts @@ -227,6 +227,26 @@ test.describe('OsButton visual regression', () => { await checkA11y(page) }) + test('suffix', async ({ page }) => { + await page.goto(`${STORY_URL}--suffix&viewMode=story`) + const root = page.locator(STORY_ROOT) + await root.waitFor() + await waitForFonts(page) + // Pause animations for deterministic screenshots + await page.evaluate(() => { + document.querySelectorAll('.os-button__spinner').forEach((el) => { + ;(el as HTMLElement).style.animationPlayState = 'paused' + }) + document.querySelectorAll('.os-button__spinner circle').forEach((el) => { + ;(el as HTMLElement).style.animationPlayState = 'paused' + }) + }) + + await expect(root.locator('.flex-col').first()).toHaveScreenshot('suffix.png') + + await checkA11y(page) + }) + test('loading', async ({ page }) => { await page.goto(`${STORY_URL}--loading&viewMode=story`) const root = page.locator(STORY_ROOT) diff --git a/packages/ui/src/components/OsButton/OsButton.vue b/packages/ui/src/components/OsButton/OsButton.vue index 8a47072a5..e85c268bd 100644 --- a/packages/ui/src/components/OsButton/OsButton.vue +++ b/packages/ui/src/components/OsButton/OsButton.vue @@ -21,6 +21,7 @@ * * @slot default - Button content (text or HTML) * @slot icon - Optional icon (rendered left of text). Use aria-label for icon-only buttons. + * @slot suffix - Optional trailing content (rendered right of text). Icons, badges, chevrons etc. */ type Size = NonNullable @@ -32,8 +33,10 @@ xl: 'w-14', } - const ICON_CLASS = - 'os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current' + const SLOT_BASE = + 'inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current' + const ICON_CLASS = `os-button__icon ${SLOT_BASE}` + const SUFFIX_CLASS = `os-button__suffix ${SLOT_BASE}` const SPINNER_PX: Record = { sm: 24, md: 32, lg: 40, xl: 46 } @@ -136,7 +139,9 @@ return () => { const iconContent = slots.icon?.() const defaultContent = slots.default?.() + const suffixContent = slots.suffix?.() const hasIcon = iconContent && iconContent.length > 0 + const hasSuffix = suffixContent && suffixContent.length > 0 const hasText = defaultContent?.some((node: unknown) => { const children = (node as Record).children @@ -166,7 +171,7 @@ { class: cn( ICON_CLASS, - !isSmall && (hasText ? '-ml-1' : '-ml-1 -mr-1'), + !isSmall && (hasText || hasSuffix ? '-ml-1' : '-ml-1 -mr-1'), isLoading && 'relative overflow-visible', ), }, @@ -179,12 +184,29 @@ innerChildren.push(...(defaultContent as ReturnType[])) } + if (hasSuffix) { + innerChildren.push( + h( + 'span', + { + class: cn( + SUFFIX_CLASS, + !isSmall && (hasText || hasIcon ? '-mr-1' : '-ml-1 -mr-1'), + isLoading && 'relative overflow-visible', + ), + }, + suffixContent, + ), + ) + } + const contentWrapper = h( 'span', { class: cn( 'inline-flex items-center', - hasIcon && hasText && (isSmall ? 'gap-1' : 'gap-2'), + (((hasIcon || hasSuffix) && hasText) || (hasIcon && hasSuffix)) && + (isSmall ? 'gap-1' : 'gap-2'), ), }, innerChildren, diff --git a/packages/ui/src/components/OsButton/__screenshots__/chromium/suffix.png b/packages/ui/src/components/OsButton/__screenshots__/chromium/suffix.png new file mode 100644 index 000000000..50acb445d Binary files /dev/null and b/packages/ui/src/components/OsButton/__screenshots__/chromium/suffix.png differ diff --git a/packages/ui/src/ocelot/icons/__screenshots__/chromium/all-icons.png b/packages/ui/src/ocelot/icons/__screenshots__/chromium/all-icons.png index a751e0d95..64b1fa3dc 100644 Binary files a/packages/ui/src/ocelot/icons/__screenshots__/chromium/all-icons.png and b/packages/ui/src/ocelot/icons/__screenshots__/chromium/all-icons.png differ diff --git a/packages/ui/src/ocelot/icons/svgs/arrow-up.svg b/packages/ui/src/ocelot/icons/svgs/arrow-up.svg new file mode 100755 index 000000000..f48c186c0 --- /dev/null +++ b/packages/ui/src/ocelot/icons/svgs/arrow-up.svg @@ -0,0 +1,5 @@ + + +arrow-up + + diff --git a/packages/ui/src/ocelot/icons/svgs/map-pin.svg b/packages/ui/src/ocelot/icons/svgs/map-pin.svg new file mode 100755 index 000000000..dbba740b1 --- /dev/null +++ b/packages/ui/src/ocelot/icons/svgs/map-pin.svg @@ -0,0 +1,5 @@ + + +map-pin + + diff --git a/packages/ui/src/ocelot/icons/svgs/shopping-cart.svg b/webapp/assets/_new/icons/svgs/shopping-cart.svg similarity index 100% rename from packages/ui/src/ocelot/icons/svgs/shopping-cart.svg rename to webapp/assets/_new/icons/svgs/shopping-cart.svg diff --git a/webapp/components/FilterMenu/HeaderButton.vue b/webapp/components/FilterMenu/HeaderButton.vue index 4864f6095..dd398687b 100644 --- a/webapp/components/FilterMenu/HeaderButton.vue +++ b/webapp/components/FilterMenu/HeaderButton.vue @@ -1,23 +1,23 @@ - diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index fe9459858..d43773e1d 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -42,9 +42,10 @@ appearance="filled" @click="showFilter = !showFilter" > + {{ $t('contribution.filterMasonryGrid.noFilter') }} -   -