Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/components/ui/toggle-group/ToggleGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit } from '@vueuse/core'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'

import { cn } from '@/utils/tailwindUtil'

import { toggleGroupVariants } from './toggleGroup.variants'
import type { ToggleGroupItemVariants } from './toggleGroup.variants'

const props = defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupItemVariants['variant']
}
>()
const emits = defineEmits<ToggleGroupRootEmits>()

provide('toggleGroup', {
variant: props.variant
})

const delegatedProps = reactiveOmit(props, 'class')

const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

<template>
<ToggleGroupRoot
v-slot="slotProps"
v-bind="forwarded"
:class="cn(toggleGroupVariants(), props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
47 changes: 47 additions & 0 deletions src/components/ui/toggle-group/ToggleGroupItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit } from '@vueuse/core'
import type { ToggleGroupItemProps } from 'reka-ui'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { toggleGroupItemVariants } from './toggleGroup.variants'
import type { ToggleGroupItemVariants } from './toggleGroup.variants'
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupItemVariants['variant']
}
>()
const context = inject<{ variant?: ToggleGroupItemVariants['variant'] }>(
'toggleGroup'
)
const delegatedProps = reactiveOmit(props, 'class', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>

<template>
<ToggleGroupItem
v-slot="slotProps"
v-bind="forwardedProps"
:class="
cn(
toggleGroupItemVariants({
variant: context?.variant || variant
}),
props.class
)
"
>
<span class="truncate min-w-0">
<slot v-bind="slotProps" />
</span>
</ToggleGroupItem>
</template>
36 changes: 36 additions & 0 deletions src/components/ui/toggle-group/toggleGroup.variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'

export const toggleGroupVariants = cva({
base: 'flex gap-[var(--primitive-padding-padding-1,4px)] p-[var(--primitive-padding-padding-1,4px)] rounded-[var(--primitive-border-radius-rounded-sm,4px)] bg-component-node-widget-background'
})

export const toggleGroupItemVariants = cva({
base: 'flex-1 inline-flex items-center justify-center border-0 rounded-[var(--primitive-border-radius-rounded-sm,4px)] px-[var(--primitive-padding-padding-2,8px)] py-[var(--primitive-padding-padding-1,4px)] text-xs font-inter font-normal transition-colors cursor-pointer overflow-hidden',
variants: {
variant: {
primary: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-primary-background data-[state=on]:text-white'
],
secondary: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-component-node-widget-background-selected data-[state=on]:text-base-foreground'
],
inverted: [
'data-[state=off]:bg-transparent data-[state=off]:text-muted-foreground',
'data-[state=off]:hover:bg-component-node-widget-background-hovered data-[state=off]:hover:text-white',
'data-[state=on]:bg-white data-[state=on]:text-base-background'
]
}
},
defaultVariants: {
variant: 'secondary'
}
})

export type ToggleGroupItemVariants = VariantProps<
typeof toggleGroupItemVariants
>
4 changes: 4 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,10 @@
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"widgets": {
"boolean": {
"true": "true",
"false": "false"
},
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'

import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'

import WidgetToggleSwitch from './WidgetToggleSwitch.vue'

vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'widgets.boolean.true': 'true',
'widgets.boolean.false': 'false'
}
return translations[key] || key
}
})
}))

describe('WidgetToggleSwitch Value Binding', () => {
const createMockWidget = (
value: boolean = false,
options: Partial<ToggleSwitchProps> = {},
options: Record<string, unknown> = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
name: 'test_toggle',
Expand All @@ -34,7 +47,7 @@ describe('WidgetToggleSwitch Value Binding', () => {
},
global: {
plugins: [PrimeVue],
components: { ToggleSwitch }
components: { ToggleSwitch, ToggleGroup, ToggleGroupItem }
}
})
}
Expand Down Expand Up @@ -149,4 +162,82 @@ describe('WidgetToggleSwitch Value Binding', () => {
expect(emitted![3]).toContain(false)
})
})

describe('Label Display', () => {
it('uses ToggleGroup when labels are provided', () => {
const widget = createMockWidget(false, { on: 'Enabled', off: 'Disabled' })
const wrapper = mountComponent(widget, false)

expect(wrapper.findComponent({ name: 'ToggleGroup' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'ToggleSwitch' }).exists()).toBe(
false
)
})

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)

const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('on')
})

it('updates active state when toggled', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, false)

const toggleGroup = wrapper.findComponent({ name: 'ToggleGroup' })
expect(toggleGroup.props('modelValue')).toBe('off')

await wrapper.setProps({ modelValue: true })

expect(toggleGroup.props('modelValue')).toBe('on')
})

it('emits update:modelValue when ToggleGroup item is clicked', async () => {
const widget = createMockWidget(false, {
on: 'Markdown',
off: 'Plaintext'
})
const wrapper = mountComponent(widget, 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)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
<template>
<WidgetLayoutField :widget>
<WidgetLayoutField :widget="widgetWithStyle">
<!-- Use ToggleGroup when explicit labels are provided -->
<!-- The variant attribute is not necessary here because the default is secondary -->
<!-- It was still added to show that a variant (3) can be explicitly set -->
<ToggleGroup
v-if="hasLabels"
type="single"
variant="secondary"
:model-value="toggleGroupValue"
class="w-full mb-[-0.5rem]"
@update:model-value="handleToggleGroupChange"
>
<ToggleGroupItem value="off" :aria-label="`${widget.name}: ${labelOff}`">
{{ labelOff }}
</ToggleGroupItem>
<ToggleGroupItem value="on" :aria-label="`${widget.name}: ${labelOn}`">
{{ labelOn }}
</ToggleGroupItem>
</ToggleGroup>

<!-- Use ToggleSwitch for implicit boolean states -->
<ToggleSwitch
v-else
v-model="modelValue"
v-bind="filteredProps"
class="ml-auto block"
Expand All @@ -12,7 +33,10 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
Expand All @@ -21,13 +45,50 @@ import {

import WidgetLayoutField from './layout/WidgetLayoutField.vue'

interface BooleanWidgetOptions {
on?: string
off?: string
[key: string]: unknown
}

const { widget } = defineProps<{
widget: SimplifiedWidget<boolean>
widget: SimplifiedWidget<boolean, BooleanWidgetOptions>
}>()

const modelValue = defineModel<boolean>()

const { t } = useI18n()

const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)

const hasLabels = computed(() => {
return !!(widget.options?.on || widget.options?.off)
})

const labelOn = computed(() => widget.options?.on ?? t('widgets.boolean.true'))
const labelOff = computed(
() => widget.options?.off ?? t('widgets.boolean.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 flex justify-end'
: undefined
}))
</script>
Loading