feat(package/ui): os-button suffix slot (#9242)
@ -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, {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
5
packages/ui/src/ocelot/icons/svgs/arrow-up.svg
Executable 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 |
5
packages/ui/src/ocelot/icons/svgs/map-pin.svg
Executable 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 |
|
Before Width: | Height: | Size: 679 B After Width: | Height: | Size: 679 B |
@ -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>
|
||||
|
||||
@ -42,9 +42,10 @@
|
||||
appearance="filled"
|
||||
@click="showFilter = !showFilter"
|
||||
>
|
||||
<template #suffix>
|
||||
<os-icon :icon="filterButtonIcon" />
|
||||
</template>
|
||||
{{ $t('contribution.filterMasonryGrid.noFilter') }}
|
||||
|
||||
<os-icon :icon="filterButtonIcon" />
|
||||
</os-button>
|
||||
|
||||
<header-button
|
||||
|
||||