feat(package/ui): os-button suffix slot (#9242)

This commit is contained in:
Ulf Gebhardt 2026-02-18 02:56:21 +01:00 committed by GitHub
parent 0cbdfea5a1
commit 282d4a33eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 351 additions and 46 deletions

View File

@ -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: '<svg data-testid="suffix"></svg>' },
})
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: '<svg data-testid="suffix"></svg>',
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: '<svg></svg>',
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: '<svg></svg>',
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: '<svg data-testid="icon"></svg>',
default: 'Save',
suffix: '<svg data-testid="suffix"></svg>',
},
})
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: '<svg></svg>',
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: '<svg></svg>',
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: '<svg></svg>' },
})
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: '<svg></svg>',
suffix: '<svg></svg>',
},
})
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: '<svg></svg>',
suffix: '<svg></svg>',
},
})
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: '<svg></svg>',
suffix: '<svg></svg>',
},
})
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: '<svg></svg>',
suffix: '<svg></svg>',
},
})
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, {

View File

@ -27,6 +27,7 @@ interface PlaygroundArgs {
disabled: boolean
loading: boolean
icon: string
suffix: string
label: string
}
@ -71,6 +72,10 @@ export const Playground: StoryObj<PlaygroundArgs> = {
control: 'select',
options: Object.keys(iconMap),
},
suffix: {
control: 'select',
options: Object.keys(iconMap),
},
label: {
control: 'text',
},
@ -85,23 +90,26 @@ export const Playground: StoryObj<PlaygroundArgs> = {
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: `
<OsButton v-bind="buttonProps" :href="buttonProps.as === 'a' ? '#' : undefined">
<template v-if="IconComponent" #icon><component :is="IconComponent" /></template>
{{ label }}
<template v-if="SuffixComponent" #suffix><component :is="SuffixComponent" /></template>
</OsButton>
`,
}),
@ -683,6 +691,117 @@ export const Polymorphic: Story = {
}),
}
export const Suffix: Story = {
render: () => ({
components: { OsButton, IconCheck, IconClose, IconPlus },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Text + Suffix</h3>
<div class="flex flex-wrap gap-2">
<OsButton variant="primary">
<template #suffix><IconCheck /></template>
Confirm
</OsButton>
<OsButton variant="default">
<template #suffix><IconPlus /></template>
Add
</OsButton>
<OsButton variant="danger">
<template #suffix><IconClose /></template>
Remove
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Text + Suffix + Loading</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading variant="primary">
<template #suffix><IconCheck /></template>
Confirm
</OsButton>
<OsButton loading variant="default">
<template #suffix><IconPlus /></template>
Add
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Icon + Text + Suffix</h3>
<div class="flex flex-wrap gap-2">
<OsButton variant="primary">
<template #icon><IconCheck /></template>
<template #suffix><IconPlus /></template>
Action
</OsButton>
<OsButton variant="danger">
<template #icon><IconClose /></template>
<template #suffix><IconCheck /></template>
Delete
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Icon + Text + Suffix + Loading</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading variant="primary">
<template #icon><IconCheck /></template>
<template #suffix><IconPlus /></template>
Action
</OsButton>
<OsButton loading variant="danger">
<template #icon><IconClose /></template>
<template #suffix><IconCheck /></template>
Delete
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Icon + Suffix + Loading (no text)</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading variant="primary" aria-label="Action">
<template #icon><IconCheck /></template>
<template #suffix><IconPlus /></template>
</OsButton>
<OsButton loading variant="danger" aria-label="Delete">
<template #icon><IconClose /></template>
<template #suffix><IconCheck /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Suffix Only</h3>
<div class="flex flex-wrap gap-2">
<OsButton variant="primary" aria-label="Confirm">
<template #suffix><IconCheck /></template>
</OsButton>
<OsButton variant="danger" aria-label="Close">
<template #suffix><IconClose /></template>
</OsButton>
<OsButton variant="default" aria-label="Add">
<template #suffix><IconPlus /></template>
</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Suffix Only + Loading + Circle</h3>
<div class="flex flex-wrap gap-2">
<OsButton loading circle variant="primary" aria-label="Confirm">
<template #suffix><IconCheck /></template>
</OsButton>
<OsButton loading circle variant="danger" aria-label="Close">
<template #suffix><IconClose /></template>
</OsButton>
<OsButton loading circle variant="default" aria-label="Add">
<template #suffix><IconPlus /></template>
</OsButton>
</div>
</div>
</div>
`,
}),
}
export const Loading: Story = {
render: () => ({
components: { OsButton, IconCheck },

View File

@ -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)

View File

@ -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<ButtonVariants['size']>
@ -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<Size, number> = { 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<string, unknown>).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<typeof h>[]))
}
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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>arrow-up</title>
<path d="M16 4.094l0.719 0.688 8.5 8.5-1.438 1.438-6.781-6.781v20.063h-2v-20.063l-6.781 6.781-1.438-1.438 8.5-8.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>map-pin</title>
<path d="M16 5c3.854 0 7 3.146 7 7 0 3.514-2.617 6.417-6 6.906v9.094h-2v-9.094c-3.383-0.489-6-3.392-6-6.906 0-3.854 3.146-7 7-7zM16 7c-2.773 0-5 2.227-5 5s2.227 5 5 5 5-2.227 5-5-2.227-5-5-5zM16 8v2c-1.117 0-2 0.883-2 2h-2c0-2.197 1.803-4 4-4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 679 B

View File

@ -1,23 +1,23 @@
<template>
<span class="header-button-wrapper">
<os-button class="my-filter-button" variant="primary" appearance="filled" @click="clickButton">
{{ title }}
</os-button>
<os-button
class="filter-remove"
variant="primary"
appearance="filled"
circle
size="sm"
:title="titleRemove"
:aria-label="titleRemove"
@click.stop="clickRemove"
>
<template #icon>
<os-icon :icon="icons.close" />
</template>
</os-button>
</span>
<os-button class="my-filter-button" variant="primary" appearance="filled" @click="clickButton">
{{ title }}
<template #suffix>
<os-button
class="filter-remove"
variant="primary"
appearance="filled"
circle
size="sm"
:title="titleRemove"
:aria-label="titleRemove"
@click.stop="clickRemove"
>
<template #icon>
<os-icon :icon="icons.close" />
</template>
</os-button>
</template>
</os-button>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
@ -49,22 +49,3 @@ export default {
},
}
</script>
<style lang="scss">
.header-button-wrapper {
display: inline-flex;
align-items: center;
position: relative;
margin-right: 8px;
> .my-filter-button {
padding-right: 36px !important;
}
> .filter-remove {
position: absolute !important;
right: 4px !important;
top: 50% !important;
transform: translateY(-50%);
}
}
</style>

View File

@ -42,9 +42,10 @@
appearance="filled"
@click="showFilter = !showFilter"
>
<template #suffix>
<os-icon :icon="filterButtonIcon" />
</template>
{{ $t('contribution.filterMasonryGrid.noFilter') }}
&nbsp;
<os-icon :icon="filterButtonIcon" />
</os-button>
<header-button