Skip to content

Commit 67ad8f4

Browse files
committed
feat(frontend): add a split button component
Adds a split button component to button which is a button with a default action but also a drop-down of extra actions Signed-off-by: Edward Sammut Alessi <[email protected]>
1 parent e38f0ff commit 67ad8f4

File tree

10 files changed

+238
-29
lines changed

10 files changed

+238
-29
lines changed

frontend/eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export default defineConfigWithVueTs(
7070
'vue/no-boolean-default': 'error',
7171
'vue/no-template-target-blank': 'error',
7272
'vue/no-useless-mustaches': 'error',
73+
'vue/require-default-prop': 'off',
7374

7475
// Temporarily disabled rules
7576
'@typescript-eslint/no-explicit-any': 'warn',

frontend/package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@vueuse/components": "^14.0.0",
3434
"@vueuse/core": "^14.0.0",
3535
"apexcharts": "^5.3.5",
36+
"clsx": "^2.1.1",
3637
"date-fns": "^4.1.0",
3738
"fetch-intercept": "^2.4.0",
3839
"js-yaml": "^4.1.0",
@@ -43,6 +44,7 @@
4344
"pluralize": "^8.0.0",
4445
"reka-ui": "^2.6.0",
4546
"semver": "^7.7.3",
47+
"tailwind-merge": "^3.3.1",
4648
"userpilot": "^1.4.1",
4749
"uuid": "^13.0.0",
4850
"vue": "^3.5.22",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import userEvent from '@testing-library/user-event'
6+
import { render, screen } from '@testing-library/vue'
7+
import { expect, test, vi } from 'vitest'
8+
9+
import SplitButton from './SplitButton.vue'
10+
11+
test('sends click events', async () => {
12+
const user = userEvent.setup()
13+
const clickFn = vi.fn()
14+
15+
render(SplitButton, {
16+
props: {
17+
actions: ['one', 'two', 'three'],
18+
onClick: clickFn,
19+
},
20+
})
21+
22+
await user.click(screen.getByRole('button', { name: 'one' }))
23+
expect(clickFn).toHaveBeenCalledExactlyOnceWith('one')
24+
25+
clickFn.mockClear()
26+
27+
await user.click(screen.getByRole('button', { name: 'extra actions' }))
28+
await user.click(screen.getByRole('menuitem', { name: 'one' }))
29+
expect(clickFn).toHaveBeenCalledExactlyOnceWith('one')
30+
31+
clickFn.mockClear()
32+
33+
await user.click(screen.getByRole('button', { name: 'extra actions' }))
34+
await user.click(screen.getByRole('menuitem', { name: 'two' }))
35+
expect(clickFn).toHaveBeenCalledExactlyOnceWith('two')
36+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import { faker } from '@faker-js/faker'
6+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
7+
import { fn } from 'storybook/test'
8+
import type { ComponentProps } from 'vue-component-type-helpers'
9+
10+
import SplitButton from './SplitButton.vue'
11+
12+
const variants: ComponentProps<typeof SplitButton>['variant'][] = [
13+
'primary',
14+
'secondary',
15+
'highlighted',
16+
'subtle',
17+
]
18+
19+
const sizes: ComponentProps<typeof SplitButton>['size'][] = ['md', 'sm', 'xs', 'xxs']
20+
21+
const meta: Meta<typeof SplitButton> = {
22+
component: SplitButton,
23+
args: {
24+
disabled: false,
25+
actions: faker.helpers.uniqueArray(faker.hacker.verb, 5) as [string, string, ...string[]],
26+
onClick: fn(),
27+
},
28+
argTypes: {
29+
variant: {
30+
control: 'select',
31+
options: variants,
32+
},
33+
size: {
34+
control: 'inline-radio',
35+
options: sizes,
36+
},
37+
},
38+
parameters: {
39+
layout: 'centered',
40+
},
41+
}
42+
43+
export default meta
44+
type Story = StoryObj<typeof meta>
45+
46+
export const Default: Story = {}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<!--
2+
Copyright (c) 2025 Sidero Labs, Inc.
3+
4+
Use of this software is governed by the Business Source License
5+
included in the LICENSE file.
6+
-->
7+
<script setup lang="ts">
8+
import { useElementSize } from '@vueuse/core'
9+
import {
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuPortal,
13+
DropdownMenuRoot,
14+
DropdownMenuTrigger,
15+
} from 'reka-ui'
16+
import { useTemplateRef } from 'vue'
17+
import type { ComponentProps } from 'vue-component-type-helpers'
18+
19+
import TButton from '@/components/common/Button/TButton.vue'
20+
21+
interface Props {
22+
variant?: ComponentProps<typeof TButton>['type']
23+
size?: ComponentProps<typeof TButton>['size']
24+
actions: [string, string, ...string[]]
25+
disabled?: boolean
26+
}
27+
28+
const { variant = 'primary', size = 'md' } = defineProps<Props>()
29+
30+
defineEmits<{
31+
click: [action: string]
32+
}>()
33+
34+
const triggerRef = useTemplateRef('trigger')
35+
const { width } = useElementSize(triggerRef)
36+
</script>
37+
38+
<template>
39+
<DropdownMenuRoot>
40+
<div v-bind="$attrs" ref="trigger" class="inline-flex -space-x-px">
41+
<TButton
42+
:type="variant"
43+
:size
44+
:disabled
45+
class="rounded-tr-none rounded-br-none"
46+
@click="() => $emit('click', actions[0])"
47+
>
48+
{{ actions[0] }}
49+
</TButton>
50+
51+
<DropdownMenuTrigger aria-label="extra actions" as-child>
52+
<TButton
53+
:type="variant"
54+
:size
55+
:disabled
56+
class="rounded-tl-none rounded-bl-none"
57+
:class="{
58+
'px-2': size === 'md',
59+
'px-1': size === 'sm',
60+
}"
61+
icon="arrow-down"
62+
/>
63+
</DropdownMenuTrigger>
64+
</div>
65+
66+
<DropdownMenuPortal>
67+
<DropdownMenuContent
68+
class="z-50 max-h-[min(--spacing(70),var(--reka-dropdown-menu-content-available-height))] min-w-(--reka-dropdown-menu-trigger-width) overflow-auto rounded-md bg-naturals-n3 p-1.5 text-xs slide-in-from-top-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
69+
side="bottom"
70+
side-flip
71+
:side-offset="4"
72+
align="end"
73+
:style="{
74+
// Overriding to align to the whole button, not just the trigger
75+
'--reka-dropdown-menu-trigger-width': `${width}px`,
76+
}"
77+
>
78+
<DropdownMenuItem
79+
v-for="action in actions"
80+
:key="action"
81+
value="New Tab"
82+
class="cursor-pointer px-3 py-1.5 outline-none select-none hover:text-naturals-n13 focus:text-naturals-n13"
83+
@select="() => $emit('click', action)"
84+
>
85+
{{ action }}
86+
</DropdownMenuItem>
87+
</DropdownMenuContent>
88+
</DropdownMenuPortal>
89+
</DropdownMenuRoot>
90+
</template>

frontend/src/components/common/Button/TButton.vue

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,52 @@ Use of this software is governed by the Business Source License
55
included in the LICENSE file.
66
-->
77
<script setup lang="ts">
8-
import type { ButtonHTMLAttributes } from 'vue'
8+
import type { ButtonHTMLAttributes, HTMLAttributes } from 'vue'
99
1010
import type { IconType } from '@/components/common/Icon/TIcon.vue'
1111
import TIcon from '@/components/common/Icon/TIcon.vue'
12+
import { cn } from '@/methods/utils'
1213
1314
interface Props extends /* @vue-ignore */ Omit<ButtonHTMLAttributes, 'type'> {
1415
type?: 'primary' | 'secondary' | 'subtle' | 'highlighted'
1516
size?: 'md' | 'sm' | 'xs' | 'xxs'
1617
icon?: IconType
1718
iconPosition?: 'left' | 'right'
19+
class?: HTMLAttributes['class']
1820
}
1921
2022
const {
2123
type = 'primary',
2224
size = 'md',
2325
iconPosition = 'right',
24-
icon = undefined,
26+
icon,
27+
class: className,
2528
} = defineProps<Props>()
2629
</script>
2730

2831
<template>
2932
<button
3033
type="button"
3134
class="flex items-center justify-center gap-1 rounded border transition-colors duration-200"
32-
:class="{
33-
'border-naturals-n5 bg-naturals-n3 text-naturals-n12 hover:border-primary-p3 hover:bg-primary-p3 hover:text-naturals-n14 focus:border-primary-p2 focus:bg-primary-p2 focus:text-naturals-n14 active:border-primary-p4 active:bg-primary-p4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-naturals-n4 disabled:text-naturals-n7':
34-
type === 'primary',
35-
'border-naturals-n5 bg-transparent text-naturals-n10 hover:bg-naturals-n5 hover:text-naturals-n14 focus:border-naturals-n7 focus:bg-naturals-n5 focus:text-naturals-n14 active:border-naturals-n5 active:bg-naturals-n4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-transparent disabled:text-naturals-n6':
36-
type === 'secondary',
37-
'border-none bg-transparent text-naturals-n13 hover:text-primary-p3 focus:text-primary-p2 focus:underline active:text-primary-p4 active:no-underline disabled:cursor-not-allowed disabled:text-naturals-n6':
38-
type === 'subtle',
39-
'border-primary-p2 bg-primary-p4 text-naturals-n14 hover:border-primary-p3 hover:bg-primary-p3 hover:text-naturals-n14 focus:border-primary-p2 focus:bg-primary-p2 focus:text-naturals-n14 active:border-primary-p4 active:bg-primary-p4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-naturals-n4 disabled:text-naturals-n7':
40-
type === 'highlighted',
41-
'px-4 py-1.5 text-sm': size === 'md',
42-
'px-2 py-0.5 text-sm': size === 'sm',
43-
'p-0 text-sm': size === 'xs',
44-
'p-0 text-xs': size === 'xxs',
45-
}"
35+
:class="
36+
cn(
37+
{
38+
'border-naturals-n5 bg-naturals-n3 text-naturals-n12 hover:border-primary-p3 hover:bg-primary-p3 hover:text-naturals-n14 focus:border-primary-p2 focus:bg-primary-p2 focus:text-naturals-n14 active:border-primary-p4 active:bg-primary-p4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-naturals-n4 disabled:text-naturals-n7':
39+
type === 'primary',
40+
'border-naturals-n5 bg-transparent text-naturals-n10 hover:bg-naturals-n5 hover:text-naturals-n14 focus:border-naturals-n7 focus:bg-naturals-n5 focus:text-naturals-n14 active:border-naturals-n5 active:bg-naturals-n4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-transparent disabled:text-naturals-n6':
41+
type === 'secondary',
42+
'border-none bg-transparent text-naturals-n13 hover:text-primary-p3 focus:text-primary-p2 focus:underline active:text-primary-p4 active:no-underline disabled:cursor-not-allowed disabled:text-naturals-n6':
43+
type === 'subtle',
44+
'border-primary-p2 bg-primary-p4 text-naturals-n14 hover:border-primary-p3 hover:bg-primary-p3 hover:text-naturals-n14 focus:border-primary-p2 focus:bg-primary-p2 focus:text-naturals-n14 active:border-primary-p4 active:bg-primary-p4 active:text-naturals-n14 disabled:cursor-not-allowed disabled:border-naturals-n6 disabled:bg-naturals-n4 disabled:text-naturals-n7':
45+
type === 'highlighted',
46+
'px-4 py-1.5 text-sm': size === 'md',
47+
'px-2 py-0.5 text-sm': size === 'sm',
48+
'p-0 text-sm': size === 'xs',
49+
'p-0 text-xs': size === 'xxs',
50+
},
51+
className,
52+
)
53+
"
4654
>
4755
<span v-if="$slots.default" class="whitespace-nowrap">
4856
<slot />

frontend/src/methods/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import type { ClassValue } from 'clsx'
6+
import { clsx } from 'clsx'
7+
import { twMerge } from 'tailwind-merge'
8+
9+
export function cn(...inputs: ClassValue[]) {
10+
return twMerge(clsx(inputs))
11+
}

frontend/src/modal.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,11 @@
22
//
33
// Use of this software is governed by the Business Source License
44
// included in the LICENSE file.
5-
6-
import type { AllowedComponentProps, Component, Ref, VNodeProps } from 'vue'
5+
import type { Component } from 'vue'
76
import { shallowRef } from 'vue'
7+
import type { ComponentProps } from 'vue-component-type-helpers'
88

9-
type ComponentProps<C extends Component> = C extends new (...args: any) => any
10-
? Omit<InstanceType<C>['$props'], keyof VNodeProps | keyof AllowedComponentProps>
11-
: never
12-
13-
export type Modal = {
14-
component: Component
15-
props?: any
16-
}
17-
18-
export const modal: Ref<{ component: Component; props: any } | null> = shallowRef(null)
9+
export const modal = shallowRef<{ component: Component; props: unknown } | null>(null)
1910

2011
export const showModal = <C extends Component>(component: C, props: ComponentProps<C>) => {
2112
modal.value = {

frontend/src/views/omni/Modals/CloseButton.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import type { ButtonHTMLAttributes } from 'vue'
99
1010
import TIcon from '@/components/common/Icon/TIcon.vue'
1111
12-
defineProps</* @vue-ignore */ ButtonHTMLAttributes>()
12+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
13+
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
14+
15+
defineProps<Props>()
1316
</script>
1417

1518
<template>

0 commit comments

Comments
 (0)