mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 07:14:11 +00:00
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:
182
src/components/ui/toggle-group/ToggleGroup.stories.ts
Normal file
182
src/components/ui/toggle-group/ToggleGroup.stories.ts
Normal file
@@ -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<typeof ToggleGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { ToggleGroup, ToggleGroupItem },
|
||||
setup() {
|
||||
const value = ref('center')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<ToggleGroup
|
||||
v-model="value"
|
||||
type="single"
|
||||
:variant="args.variant"
|
||||
:disabled="args.disabled"
|
||||
class="border border-border-default rounded-lg p-1"
|
||||
>
|
||||
<ToggleGroupItem value="left">Left</ToggleGroupItem>
|
||||
<ToggleGroupItem value="center">Center</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right">Right</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div class="mt-4 text-sm text-muted-foreground">
|
||||
Selected: {{ value || 'None' }}
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
variant: 'default',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => ({
|
||||
components: { ToggleGroup, ToggleGroupItem },
|
||||
setup() {
|
||||
const value = ref('on')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<ToggleGroup v-model="value" type="single" disabled class="border border-border-default rounded-lg p-1">
|
||||
<ToggleGroupItem value="off">Off</ToggleGroupItem>
|
||||
<ToggleGroupItem value="on">On</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
`
|
||||
}),
|
||||
args: {}
|
||||
}
|
||||
|
||||
export const OutlineVariant: Story = {
|
||||
render: (args) => ({
|
||||
components: { ToggleGroup, ToggleGroupItem },
|
||||
setup() {
|
||||
const value = ref('medium')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<ToggleGroup v-model="value" type="single" variant="outline" class="gap-2">
|
||||
<ToggleGroupItem value="small" size="sm">Small</ToggleGroupItem>
|
||||
<ToggleGroupItem value="medium">Medium</ToggleGroupItem>
|
||||
<ToggleGroupItem value="large" size="lg">Large</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
`
|
||||
}),
|
||||
args: {}
|
||||
}
|
||||
|
||||
export const BooleanToggle: Story = {
|
||||
render: (args) => ({
|
||||
components: { ToggleGroup, ToggleGroupItem },
|
||||
setup() {
|
||||
const value = ref('off')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted-foreground">Boolean toggle with custom labels:</p>
|
||||
<ToggleGroup
|
||||
v-model="value"
|
||||
type="single"
|
||||
class="w-48 border border-border-default rounded-lg p-1"
|
||||
>
|
||||
<ToggleGroupItem value="off" size="sm">Outside</ToggleGroupItem>
|
||||
<ToggleGroupItem value="on" size="sm">Inside</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div class="text-sm">Value: {{ value === 'on' ? true : false }}</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {}
|
||||
}
|
||||
|
||||
export const LongLabels: Story = {
|
||||
render: (args) => ({
|
||||
components: { ToggleGroup, ToggleGroupItem },
|
||||
setup() {
|
||||
const value = ref('option1')
|
||||
return { value, args }
|
||||
},
|
||||
template: `
|
||||
<div class="w-64">
|
||||
<p class="text-sm text-muted-foreground mb-2">Labels truncate with ellipsis:</p>
|
||||
<ToggleGroup
|
||||
v-model="value"
|
||||
type="single"
|
||||
class="border border-border-default rounded-lg p-1"
|
||||
>
|
||||
<ToggleGroupItem value="option1" size="sm">Very Long Label One</ToggleGroupItem>
|
||||
<ToggleGroupItem value="option2" size="sm">Another Long Label</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
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: `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground mb-2">Small:</p>
|
||||
<ToggleGroup v-model="sm" type="single" class="border border-border-default rounded p-1">
|
||||
<ToggleGroupItem value="a" size="sm">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" size="sm">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground mb-2">Default:</p>
|
||||
<ToggleGroup v-model="md" type="single" class="border border-border-default rounded-lg p-1">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground mb-2">Large:</p>
|
||||
<ToggleGroup v-model="lg" type="single" class="border border-border-default rounded-lg p-1">
|
||||
<ToggleGroupItem value="a" size="lg">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" size="lg">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
43
src/components/ui/toggle-group/ToggleGroup.vue
Normal file
43
src/components/ui/toggle-group/ToggleGroup.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
|
||||
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, toRef } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ToggleGroupVariants } from './toggleGroup.variants'
|
||||
import {
|
||||
toggleGroupVariantKey,
|
||||
toggleGroupVariants
|
||||
} from './toggleGroup.variants'
|
||||
|
||||
interface Props extends ToggleGroupRootProps {
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ToggleGroupVariants['variant']
|
||||
}
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emits = defineEmits<ToggleGroupRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
|
||||
provide(
|
||||
toggleGroupVariantKey,
|
||||
toRef(() => variant)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleGroupRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(toggleGroupVariants({ variant }), className)"
|
||||
>
|
||||
<slot />
|
||||
</ToggleGroupRoot>
|
||||
</template>
|
||||
50
src/components/ui/toggle-group/ToggleGroupItem.vue
Normal file
50
src/components/ui/toggle-group/ToggleGroupItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToggleGroupItemProps } from 'reka-ui'
|
||||
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ToggleGroupItemVariants } from './toggleGroup.variants'
|
||||
import {
|
||||
toggleGroupItemVariants,
|
||||
toggleGroupVariantKey
|
||||
} from './toggleGroup.variants'
|
||||
|
||||
interface Props extends ToggleGroupItemProps {
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ToggleGroupItemVariants['variant']
|
||||
size?: ToggleGroupItemVariants['size']
|
||||
}
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant,
|
||||
size = 'default',
|
||||
...restProps
|
||||
} = defineProps<Props>()
|
||||
|
||||
const contextVariant = inject(toggleGroupVariantKey, ref('default'))
|
||||
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
|
||||
const resolvedVariant = computed(
|
||||
() => variant ?? contextVariant.value ?? 'default'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleGroupItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
toggleGroupItemVariants({ variant: resolvedVariant, size }),
|
||||
'flex-1 min-w-0 truncate',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</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'
|
||||
55
src/components/ui/toggle-group/toggleGroup.variants.ts
Normal file
55
src/components/ui/toggle-group/toggleGroup.variants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const toggleGroupVariantKey: InjectionKey<
|
||||
Ref<ToggleGroupItemVariants['variant']>
|
||||
> = 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<typeof toggleGroupVariants>
|
||||
export type ToggleGroupItemVariants = VariantProps<
|
||||
typeof toggleGroupItemVariants
|
||||
>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user