feat: add ToggleGroup support for labeled boolean widgets

This commit is contained in:
Csongor Czezar
2025-12-30 14:52:38 -08:00
parent 795962f3c3
commit 035a0f250c
10 changed files with 411 additions and 118 deletions

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import type { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/utils/tailwindUtil'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant,
size: props.size
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
v-bind="forwarded"
:class="cn('flex items-center justify-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/utils/tailwindUtil'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size
}),
props.class
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>

View File

@@ -0,0 +1,2 @@
export { default as ToggleGroup } from "./ToggleGroup.vue"
export { default as ToggleGroupItem } from "./ToggleGroupItem.vue"

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { ToggleEmits, ToggleProps } from 'reka-ui'
import { Toggle, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { ToggleVariants } from '.'
import { toggleVariants } from '.'
const props = withDefaults(
defineProps<
ToggleProps & {
class?: HTMLAttributes['class']
variant?: ToggleVariants['variant']
size?: ToggleVariants['size']
}
>(),
{
variant: 'default',
size: 'default',
disabled: false
}
)
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Toggle } from "./Toggle.vue"
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>

View File

@@ -3,6 +3,7 @@ import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
@@ -10,7 +11,7 @@ import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: Record<string, any> = {},
options: Record<string, unknown> = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
name: 'test_toggle',
@@ -33,7 +34,7 @@ describe('WidgetToggleSwitch Value Binding', () => {
},
global: {
plugins: [PrimeVue],
components: { ToggleSwitch }
components: { ToggleSwitch, ToggleGroup, ToggleGroupItem }
}
})
}
@@ -150,43 +151,80 @@ describe('WidgetToggleSwitch Value Binding', () => {
})
describe('Label Display', () => {
it('displays label_off when toggle is false', () => {
it('uses ToggleGroup when labels are provided', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('Disabled')
expect(wrapper.text()).not.toContain('Enabled')
expect(wrapper.findComponent({ name: 'ToggleGroup' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
false
)
})
it('displays label_on when toggle is true', () => {
it('uses ToggleSwitch when no labels are provided', () => {
const widget = createMockWidget(false, {})
const wrapper = mountComponent(widget, false)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'ToggleGroup' }).exists()).toBe(
false
)
})
it('displays both label_on and label_off in ToggleGroup', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('Enabled')
expect(wrapper.text()).toContain('Disabled')
})
it('displays correct active state for false', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('off')
})
it('displays correct active state for true', () => {
const widget = createMockWidget(true, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, true)
expect(wrapper.text()).toContain('Enabled')
expect(wrapper.text()).not.toContain('Disabled')
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('on')
})
it('updates label when toggled', async () => {
it('updates active state when toggled', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('Plaintext')
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('off')
await wrapper.setProps({ modelValue: true })
expect(wrapper.text()).toContain('Markdown')
expect(wrapper.text()).not.toContain('Plaintext')
expect(toggleGroup.props('modelValue')).toBe('on')
})
it('does not display label when options are not provided', () => {
const widget = createMockWidget(false, {})
it('emits update:modelValue when ToggleGroup item is clicked', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, false)
const labelSpan = wrapper.find('span')
expect(labelSpan.exists()).toBe(false)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
await toggleGroup.vm.$emit('update:modelValue', 'on')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(true)
})
})
})

View File

@@ -1,13 +1,30 @@
<template>
<WidgetLayoutField :widget>
<div v-if="currentLabel" class="ml-auto flex items-center gap-2">
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
:aria-label="widget.name"
/>
<span class="text-sm">{{ currentLabel }}</span>
</div>
<WidgetLayoutField :widget="widgetWithStyle">
<!-- Use ToggleGroup when explicit labels are provided -->
<ToggleGroup
v-if="hasLabels"
type="single"
:model-value="toggleGroupValue"
class="ml-auto gap-0 bg-node-component-surface"
@update:model-value="handleToggleGroupChange"
>
<ToggleGroupItem
value="off"
:aria-label="`${widget.name}: ${labelOff}`"
class="rounded-l-md rounded-r-none border-0 bg-transparent text-node-component-text data-[state=on]:!bg-white data-[state=on]:!text-black hover:bg-node-component-border/20 cursor-pointer"
>
{{ labelOff }}
</ToggleGroupItem>
<ToggleGroupItem
value="on"
:aria-label="`${widget.name}: ${labelOn}`"
class="rounded-l-none rounded-r-md border-0 bg-transparent text-node-component-text data-[state=on]:!bg-white data-[state=on]:!text-black hover:bg-node-component-border/20 cursor-pointer"
>
{{ labelOn }}
</ToggleGroupItem>
</ToggleGroup>
<!-- Use ToggleSwitch for implicit boolean states -->
<ToggleSwitch
v-else
v-model="modelValue"
@@ -22,6 +39,7 @@
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
@@ -33,7 +51,7 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
interface BooleanWidgetOptions {
on?: string
off?: string
[key: string]: any
[key: string]: unknown
}
const { widget } = defineProps<{
@@ -46,9 +64,30 @@ const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const currentLabel = computed(() => {
return modelValue.value
? (widget.options?.on ?? (widget.options?.off ? 'true' : undefined))
: (widget.options?.off ?? (widget.options?.on ? 'false' : undefined))
const hasLabels = computed(() => {
return !!(widget.options?.on || widget.options?.off)
})
const labelOn = computed(() => widget.options?.on ?? 'true')
const labelOff = computed(() => widget.options?.off ?? 'false')
const toggleGroupValue = computed(() => {
return modelValue.value ? 'on' : 'off'
})
const handleToggleGroupChange = (value: unknown) => {
if (value === 'on') {
modelValue.value = true
} else if (value === 'off') {
modelValue.value = false
}
}
// Override WidgetLayoutField styling when using ToggleGroup
const widgetWithStyle = computed(() => ({
...widget,
borderStyle: hasLabels.value
? 'focus-within:!ring-0 !bg-transparent !rounded-none focus-within:!outline-none'
: undefined
}))
</script>