mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-11 16:10:05 +00:00
feat: add ToggleGroup support for labeled boolean widgets
This commit is contained in:
41
src/components/ui/toggle-group/ToggleGroup.vue
Normal file
41
src/components/ui/toggle-group/ToggleGroup.vue
Normal 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>
|
||||
45
src/components/ui/toggle-group/ToggleGroupItem.vue
Normal file
45
src/components/ui/toggle-group/ToggleGroupItem.vue
Normal 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>
|
||||
2
src/components/ui/toggle-group/index.ts
Normal file
2
src/components/ui/toggle-group/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ToggleGroup } from "./ToggleGroup.vue"
|
||||
export { default as ToggleGroupItem } from "./ToggleGroupItem.vue"
|
||||
42
src/components/ui/toggle/Toggle.vue
Normal file
42
src/components/ui/toggle/Toggle.vue
Normal 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>
|
||||
28
src/components/ui/toggle/index.ts
Normal file
28
src/components/ui/toggle/index.ts
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user