feat: add Reka UI ToggleGroup for BOOLEAN widget label_on/label_off display (#8680)

## Summary

Surfaces the `label_on` and `label_off` (aka `on`/`off`) props for
BOOLEAN widgets in the Vue implementation using a new Reka UI
ToggleGroup component.

**Before:** Labels extended outside node boundaries, causing overflow
issues.
**After:** Labels truncate with ellipsis and share space equally within
the widget.

[Screencast from 2026-02-13
16-27-49.webm](https://github.com/user-attachments/assets/912bab39-50a0-4d4e-8046-4b982e145bd9)


## Changes

### New ToggleGroup Component (`src/components/ui/toggle-group/`)
- `ToggleGroup.vue` - Wrapper around Reka UI `ToggleGroupRoot` with
variant support
- `ToggleGroupItem.vue` - Styled toggle items with size variants
(sm/default/lg)
- `toggleGroup.variants.ts` - CVA variants adapted to ComfyUI design
tokens
- `ToggleGroup.stories.ts` - Storybook stories (Default, Disabled,
Outline, Sizes, LongLabels)

### WidgetToggleSwitch Updates
- Conditionally renders `ToggleGroup` when `options.on` or `options.off`
are provided
- Keeps PrimeVue `ToggleSwitch` for implicit boolean states (no labels)
- Items use `flex-1 min-w-0 truncate` to share space and handle overflow

### i18n
- Added `widgets.boolean.true` and `widgets.boolean.false` translation
keys for fallback labels

## Implementation Details

Follows the established pattern for adding Reka UI components in this
codebase:
- Uses Reka UI primitives (`ToggleGroupRoot`, `ToggleGroupItem`)
- Adapts shadcn-vue structure with ComfyUI design tokens
- Reactive provide/inject with typed `InjectionKey` for variant context
- Styling matches existing `FormSelectButton` appearance


Fixes COM-12709

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Christian Byrne
2026-02-19 23:21:31 -08:00
committed by GitHub
parent 6c205cbf4c
commit 8744d3dd54
9 changed files with 491 additions and 49 deletions

View File

@@ -1,13 +1,30 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
widgets: {
boolean: {
true: 'true',
false: 'false'
}
}
}
}
})
describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
@@ -33,8 +50,8 @@ describe('WidgetToggleSwitch Value Binding', () => {
readonly
},
global: {
plugins: [PrimeVue],
components: { ToggleSwitch }
plugins: [PrimeVue, i18n],
components: { ToggleSwitch, ToggleGroup, ToggleGroupItem }
}
})
}
@@ -68,12 +85,10 @@ describe('WidgetToggleSwitch Value Binding', () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
// Should not throw when changing values
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(true)
await toggle.setValue(false)
// Should emit events for all changes
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(2)
expect(emitted![0]).toContain(true)
@@ -115,12 +130,10 @@ describe('WidgetToggleSwitch Value Binding', () => {
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
// Rapid toggle sequence
await toggle.setValue(true)
await toggle.setValue(false)
await toggle.setValue(true)
// Should have emitted 3 Vue events with correct values
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(3)
expect(emitted![0]).toContain(true)
@@ -134,7 +147,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
// Multiple state changes
await toggle.setValue(true)
await toggle.setValue(false)
await toggle.setValue(true)
@@ -142,7 +154,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(4)
// Verify alternating pattern
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
expect(emitted![2]).toContain(true)
@@ -151,45 +162,126 @@ describe('WidgetToggleSwitch Value Binding', () => {
})
describe('Label Display (label_on/label_off)', () => {
it('displays label_on when value is true', () => {
const widget = createMockWidget(true, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, true)
expect(wrapper.text()).toContain('inside')
})
it('displays label_off when value is false', () => {
it('renders ToggleGroup when labels are provided', () => {
const widget = createMockWidget(false, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('outside')
expect(wrapper.findComponent({ name: 'ToggleGroupRoot' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
false
)
})
it('does not display label when no on/off options provided', () => {
it('renders ToggleSwitch when no labels are provided', () => {
const widget = createMockWidget(false, {})
const wrapper = mountComponent(widget, false)
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'ToggleGroupRoot' }).exists()).toBe(
false
)
})
it('updates label when value changes', async () => {
it('displays both on and off labels in ToggleGroup', () => {
const widget = createMockWidget(false, { on: 'inside', off: 'outside' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('inside')
expect(wrapper.text()).toContain('outside')
})
it('selects correct option based on boolean value (false)', () => {
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.text()).toContain('disabled')
await wrapper.setProps({ modelValue: true })
expect(wrapper.text()).toContain('enabled')
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroupRoot' })
expect(toggleGroup.props('modelValue')).toBe('off')
})
it('falls back to true/false when only partial options provided', () => {
it('selects correct option based on boolean value (true)', () => {
const widget = createMockWidget(true, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, true)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroupRoot' })
expect(toggleGroup.props('modelValue')).toBe('on')
})
it('emits true when "on" option is clicked', async () => {
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, false)
const buttons = wrapper.findAll('button')
const onButton = buttons.find((b) => b.text() === 'enabled')
await onButton!.trigger('click')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(true)
})
it('emits false when "off" option is clicked', async () => {
const widget = createMockWidget(true, { on: 'enabled', off: 'disabled' })
const wrapper = mountComponent(widget, true)
const buttons = wrapper.findAll('button')
const offButton = buttons.find((b) => b.text() === 'disabled')
await offButton!.trigger('click')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(false)
})
it('falls back to i18n defaults when only partial options provided', () => {
const widgetOnOnly = createMockWidget(true, { on: 'active' })
const wrapperOn = mountComponent(widgetOnOnly, true)
expect(wrapperOn.text()).toContain('active')
expect(wrapperOn.text()).toContain('false')
const widgetOffOnly = createMockWidget(false, { off: 'inactive' })
const wrapperOff = mountComponent(widgetOffOnly, false)
expect(wrapperOff.text()).toContain('inactive')
expect(wrapperOff.text()).toContain('true')
})
it('treats empty string labels as explicit values', () => {
const widget = createMockWidget(false, { on: '', off: 'disabled' })
const wrapper = mountComponent(widget, false)
expect(wrapper.findComponent({ name: 'ToggleGroupRoot' }).exists()).toBe(
true
)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
false
)
expect(wrapper.text()).not.toContain('true')
})
it('disables ToggleGroup when read_only option is set', () => {
const widget = createMockWidget(false, {
on: 'yes',
off: 'no',
read_only: true
})
const wrapper = mountComponent(widget, false)
const toggleGroup = wrapper.findComponent({ name: 'ToggleGroupRoot' })
expect(toggleGroup.props('disabled')).toBe(true)
})
it('does not emit when clicking already-selected option', async () => {
const widget = createMockWidget(false, { on: 'yes', off: 'no' })
const wrapper = mountComponent(widget, false)
const buttons = wrapper.findAll('button')
const offButton = buttons.find((b) => b.text() === 'no')
await offButton!.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
})

View File

@@ -1,23 +1,32 @@
<template>
<WidgetLayoutField :widget>
<div
<!-- Use ToggleGroup when explicit labels are provided -->
<ToggleGroup
v-if="hasLabels"
type="single"
:model-value="modelValue ? 'on' : 'off'"
:disabled="Boolean(widget.options?.read_only)"
:class="
cn('flex w-fit items-center gap-2', !hideLayoutField && 'ml-auto')
cn(
WidgetInputBaseClass,
'w-full min-w-0 p-1 flex items-center justify-center gap-1'
)
"
@update:model-value="(v) => handleOptionChange(v as string)"
>
<ToggleGroupItem value="off" size="sm">
{{ widget.options?.off ?? t('widgets.boolean.false') }}
</ToggleGroupItem>
<ToggleGroupItem value="on" size="sm">
{{ widget.options?.on ?? t('widgets.boolean.true') }}
</ToggleGroupItem>
</ToggleGroup>
<!-- Use ToggleSwitch for implicit boolean states -->
<div
v-else
:class="cn('flex w-fit items-center gap-2', hideLayoutField || 'ml-auto')"
>
<span
v-if="stateLabel"
:class="
cn(
'text-sm transition-colors',
modelValue
? 'text-node-component-slot-text'
: 'text-node-component-slot-text/50'
)
"
>
{{ stateLabel }}
</span>
<ToggleSwitch
v-model="modelValue"
v-bind="filteredProps"
@@ -30,7 +39,9 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
@@ -40,6 +51,7 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { widget } = defineProps<{
@@ -49,14 +61,19 @@ const { widget } = defineProps<{
const modelValue = defineModel<boolean>()
const hideLayoutField = useHideLayoutField()
const { t } = useI18n()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
const stateLabel = computed(() => {
const options = widget.options
if (!options?.on && !options?.off) return null
return modelValue.value ? (options.on ?? 'true') : (options.off ?? 'false')
const hasLabels = computed(() => {
return widget.options?.on != null || widget.options?.off != null
})
function handleOptionChange(value: string | undefined) {
if (value) {
modelValue.value = value === 'on'
}
}
</script>

View File

@@ -1,10 +1,7 @@
<template>
<div
:class="
cn(
WidgetInputBaseClass,
'p-1 inline-flex justify-center items-center gap-1'
)
cn(WidgetInputBaseClass, 'w-full p-1 flex min-w-0 items-center gap-1')
"
>
<button
@@ -12,7 +9,7 @@
:key="getOptionValue(option, index)"
:class="
cn(
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out truncate min-w-[4ch]',
'flex-1 min-w-0 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out truncate',
'bg-transparent border-none',
'text-center text-xs font-normal',
{