From 8744d3dd5450c6bd0865849a6e7e565aef4995ff Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 19 Feb 2026 23:21:31 -0800 Subject: [PATCH] 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 Co-authored-by: Alexander Brown Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../ui/toggle-group/ToggleGroup.stories.ts | 182 ++++++++++++++++++ .../ui/toggle-group/ToggleGroup.vue | 43 +++++ .../ui/toggle-group/ToggleGroupItem.vue | 50 +++++ src/components/ui/toggle-group/index.ts | 2 + .../ui/toggle-group/toggleGroup.variants.ts | 55 ++++++ src/locales/en/main.json | 4 + .../components/WidgetToggleSwitch.test.ts | 142 +++++++++++--- .../widgets/components/WidgetToggleSwitch.vue | 55 ++++-- .../components/form/FormSelectButton.vue | 7 +- 9 files changed, 491 insertions(+), 49 deletions(-) create mode 100644 src/components/ui/toggle-group/ToggleGroup.stories.ts create mode 100644 src/components/ui/toggle-group/ToggleGroup.vue create mode 100644 src/components/ui/toggle-group/ToggleGroupItem.vue create mode 100644 src/components/ui/toggle-group/index.ts create mode 100644 src/components/ui/toggle-group/toggleGroup.variants.ts diff --git a/src/components/ui/toggle-group/ToggleGroup.stories.ts b/src/components/ui/toggle-group/ToggleGroup.stories.ts new file mode 100644 index 000000000..1b0baf0ea --- /dev/null +++ b/src/components/ui/toggle-group/ToggleGroup.stories.ts @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ToggleGroup from './ToggleGroup.vue' +import ToggleGroupItem from './ToggleGroupItem.vue' + +const meta = { + title: 'Components/ToggleGroup', + component: ToggleGroup, + tags: ['autodocs'], + argTypes: { + type: { + control: 'select', + options: ['single', 'multiple'], + description: 'Single or multiple selection' + }, + variant: { + control: 'select', + options: ['default', 'outline'], + description: 'Visual style variant' + }, + disabled: { + control: 'boolean', + description: 'When true, disables the toggle group' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const value = ref('center') + return { value, args } + }, + template: ` + + Left + Center + Right + +
+ Selected: {{ value || 'None' }} +
+ ` + }), + args: { + variant: 'default', + disabled: false + } +} + +export const Disabled: Story = { + render: (args) => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const value = ref('on') + return { value, args } + }, + template: ` + + Off + On + + ` + }), + args: {} +} + +export const OutlineVariant: Story = { + render: (args) => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const value = ref('medium') + return { value, args } + }, + template: ` + + Small + Medium + Large + + ` + }), + args: {} +} + +export const BooleanToggle: Story = { + render: (args) => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const value = ref('off') + return { value, args } + }, + template: ` +
+

Boolean toggle with custom labels:

+ + Outside + Inside + +
Value: {{ value === 'on' ? true : false }}
+
+ ` + }), + args: {} +} + +export const LongLabels: Story = { + render: (args) => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const value = ref('option1') + return { value, args } + }, + template: ` +
+

Labels truncate with ellipsis:

+ + Very Long Label One + Another Long Label + +
+ ` + }), + args: {} +} + +export const Sizes: Story = { + render: () => ({ + components: { ToggleGroup, ToggleGroupItem }, + setup() { + const sm = ref('a') + const md = ref('a') + const lg = ref('a') + return { sm, md, lg } + }, + template: ` +
+
+

Small:

+ + A + B + +
+
+

Default:

+ + A + B + +
+
+

Large:

+ + A + B + +
+
+ ` + }) +} diff --git a/src/components/ui/toggle-group/ToggleGroup.vue b/src/components/ui/toggle-group/ToggleGroup.vue new file mode 100644 index 000000000..4e58920e6 --- /dev/null +++ b/src/components/ui/toggle-group/ToggleGroup.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/ui/toggle-group/ToggleGroupItem.vue b/src/components/ui/toggle-group/ToggleGroupItem.vue new file mode 100644 index 000000000..296402dd5 --- /dev/null +++ b/src/components/ui/toggle-group/ToggleGroupItem.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/ui/toggle-group/index.ts b/src/components/ui/toggle-group/index.ts new file mode 100644 index 000000000..9f89ad775 --- /dev/null +++ b/src/components/ui/toggle-group/index.ts @@ -0,0 +1,2 @@ +export { default as ToggleGroup } from './ToggleGroup.vue' +export { default as ToggleGroupItem } from './ToggleGroupItem.vue' diff --git a/src/components/ui/toggle-group/toggleGroup.variants.ts b/src/components/ui/toggle-group/toggleGroup.variants.ts new file mode 100644 index 000000000..a11bc0600 --- /dev/null +++ b/src/components/ui/toggle-group/toggleGroup.variants.ts @@ -0,0 +1,55 @@ +import type { InjectionKey, Ref } from 'vue' + +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const toggleGroupVariantKey: InjectionKey< + Ref +> = Symbol('toggleGroupVariant') + +export const toggleGroupVariants = cva({ + base: 'flex items-center justify-center gap-1', + variants: { + variant: { + default: 'bg-transparent', + outline: 'bg-transparent' + } + }, + defaultVariants: { + variant: 'default' + } +}) + +export const toggleGroupItemVariants = cva({ + base: [ + 'inline-flex items-center justify-center rounded', + 'border-none cursor-pointer appearance-none', + 'text-center font-normal', + 'transition-all duration-150 ease-in-out', + 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed', + 'data-[state=on]:bg-interface-menu-component-surface-selected data-[state=on]:text-text-primary' + ], + variants: { + variant: { + default: + 'bg-transparent hover:bg-interface-menu-component-surface-selected/50 text-text-secondary', + outline: + 'border border-border-default bg-transparent hover:bg-secondary-background text-text-secondary' + }, + size: { + default: 'h-7 px-3 text-sm', + sm: 'h-6 px-5 py-[5px] text-xs', + lg: 'h-9 px-4 text-sm' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } +}) + +export type ToggleGroupVariants = VariantProps +export type ToggleGroupItemVariants = VariantProps< + typeof toggleGroupItemVariants +> diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 2988bc412..687651ffc 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2410,6 +2410,10 @@ "Set Group Nodes to Always": "Set Group Nodes to Always" }, "widgets": { + "boolean": { + "true": "true", + "false": "false" + }, "node2only": "Node 2.0 only", "selectModel": "Select model", "uploadSelect": { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts index dbd810a8a..5a3c4fc27 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.test.ts @@ -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() }) }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue index 77e075779..3f6149304 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue @@ -1,23 +1,32 @@