feat: migrate ToggleSwitch to shadcn-vue Switch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Matthew McPeak 2026-07-11 14:28:51 -04:00
parent 382d76c2d7
commit 7a4ae9ff6a
8 changed files with 86 additions and 28 deletions

View file

@ -4,3 +4,4 @@ export { Button, buttonVariants } from './ui/button'
export type { ButtonVariants } from './ui/button' export type { ButtonVariants } from './ui/button'
export { Input } from './ui/input' export { Input } from './ui/input'
export { Textarea } from './ui/textarea' export { Textarea } from './ui/textarea'
export { Switch } from './ui/switch'

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<SwitchRootProps & {
class?: HTMLAttributes["class"]
size?: "sm" | "default"
}>(), {
size: "default",
})
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
:data-size="size"
v-bind="forwarded"
:class="cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View file

@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"

View file

@ -16,7 +16,7 @@ export {
CheckboxGroup, CheckboxGroup,
RadioButton, RadioButton,
RadioButtonGroup, RadioButtonGroup,
ToggleSwitch, Switch,
Select, Select,
Card, Card,
Dialog, Dialog,

View file

@ -4,7 +4,6 @@ import {
CheckboxGroup as PrimeCheckboxGroup, CheckboxGroup as PrimeCheckboxGroup,
RadioButton as PrimeRadioButton, RadioButton as PrimeRadioButton,
RadioButtonGroup as PrimeRadioButtonGroup, RadioButtonGroup as PrimeRadioButtonGroup,
ToggleSwitch as PrimeToggleSwitch,
Select as PrimeSelect, Select as PrimeSelect,
Card as PrimeCard, Card as PrimeCard,
Dialog as PrimeDialog, Dialog as PrimeDialog,
@ -12,9 +11,9 @@ import {
Tag as PrimeTag, Tag as PrimeTag,
} from 'primevue' } from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine' import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { NychLoadingIcon, Button, buttonVariants, Input, Textarea } from '../components' import { NychLoadingIcon, Button, buttonVariants, Input, Textarea, Switch } from '../components'
export { Button, buttonVariants, Input, Textarea } export { Button, buttonVariants, Input, Textarea, Switch }
/** /**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API. * Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
@ -62,7 +61,6 @@ const nychthemeron: ThemeLibrary = {
CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'), CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'),
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']), RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'), RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
ToggleSwitch: ptThemeable(PrimeToggleSwitch, 'toggleswitch', ['input', 'slider', 'handle']),
Select: ptThemeable(PrimeSelect, 'select', [ Select: ptThemeable(PrimeSelect, 'select', [
'label', 'label',
'dropdown', 'dropdown',
@ -114,8 +112,6 @@ export const RadioButton: typeof PrimeRadioButton =
_engine.getComponent<typeof PrimeRadioButton>('RadioButton') _engine.getComponent<typeof PrimeRadioButton>('RadioButton')
export const RadioButtonGroup: typeof PrimeRadioButtonGroup = export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
_engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup') _engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup')
export const ToggleSwitch: typeof PrimeToggleSwitch =
_engine.getComponent<typeof PrimeToggleSwitch>('ToggleSwitch')
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select') export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card') export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog') export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
@ -131,7 +127,7 @@ export const createNychthemeron = (): {
CheckboxGroup: typeof PrimeCheckboxGroup CheckboxGroup: typeof PrimeCheckboxGroup
RadioButton: typeof PrimeRadioButton RadioButton: typeof PrimeRadioButton
RadioButtonGroup: typeof PrimeRadioButtonGroup RadioButtonGroup: typeof PrimeRadioButtonGroup
ToggleSwitch: typeof PrimeToggleSwitch Switch: typeof Switch
Select: typeof PrimeSelect Select: typeof PrimeSelect
Card: typeof PrimeCard Card: typeof PrimeCard
Dialog: typeof PrimeDialog Dialog: typeof PrimeDialog
@ -147,7 +143,7 @@ export const createNychthemeron = (): {
CheckboxGroup, CheckboxGroup,
RadioButton, RadioButton,
RadioButtonGroup, RadioButtonGroup,
ToggleSwitch, Switch,
Select, Select,
Card, Card,
Dialog, Dialog,
@ -162,7 +158,7 @@ export const createNychthemeron = (): {
app.component('NychCheckboxGroup', CheckboxGroup) app.component('NychCheckboxGroup', CheckboxGroup)
app.component('NychRadioButton', RadioButton) app.component('NychRadioButton', RadioButton)
app.component('NychRadioButtonGroup', RadioButtonGroup) app.component('NychRadioButtonGroup', RadioButtonGroup)
app.component('NychToggleSwitch', ToggleSwitch) app.component('NychSwitch', Switch)
app.component('NychSelect', Select) app.component('NychSelect', Select)
app.component('NychCard', Card) app.component('NychCard', Card)
app.component('NychDialog', Dialog) app.component('NychDialog', Dialog)

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { Switch } from '../src/lib'
describe('Switch', () => {
it('renders unchecked by default', () => {
const wrapper = mount(Switch)
expect(wrapper.get('button').attributes('data-state')).toBe('unchecked')
})
it('reflects modelValue as checked', () => {
const wrapper = mount(Switch, { props: { modelValue: true } })
expect(wrapper.get('button').attributes('data-state')).toBe('checked')
})
it('emits update:modelValue on click', async () => {
const wrapper = mount(Switch, { props: { modelValue: false } })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
})
})

View file

@ -7,7 +7,6 @@ import {
RadioButton, RadioButton,
CheckboxGroup, CheckboxGroup,
RadioButtonGroup, RadioButtonGroup,
ToggleSwitch,
Select, Select,
Card, Card,
Dialog, Dialog,
@ -73,7 +72,6 @@ describe('themed pt components', () => {
{ name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' }, { name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' },
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' }, { name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' }, { name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
{ name: 'ToggleSwitch', component: ToggleSwitch, base: 'nych-toggleswitch' },
{ name: 'Select', component: Select, base: 'nych-select' }, { name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Card', component: Card, base: 'nych-card' }, { name: 'Card', component: Card, base: 'nych-card' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' }, { name: 'Dialog', component: Dialog, base: 'nych-dialog' },

View file

@ -1,11 +1,11 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue' import { ref } from 'vue'
import { ToggleSwitch as NychToggleSwitch } from '@nychthemeron/library' import { Switch as NychSwitch } from '@nychthemeron/library'
const meta = { const meta = {
title: 'Components/ToggleSwitch', title: 'Components/Switch',
component: NychToggleSwitch, component: NychSwitch,
parameters: { parameters: {
layout: 'centered', layout: 'centered',
}, },
@ -16,43 +16,40 @@ const meta = {
description: 'Disable the switch', description: 'Disable the switch',
}, },
}, },
} satisfies Meta<typeof NychToggleSwitch> } satisfies Meta<typeof NychSwitch>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
export const Default: Story = { export const Default: Story = {
render: () => ({ render: () => ({
components: { NychToggleSwitch }, components: { NychSwitch },
setup() { setup() {
const on = ref(false) const on = ref(false)
return { on } return { on }
}, },
template: `<NychToggleSwitch v-model="on" />`, template: `<NychSwitch v-model="on" />`,
}), }),
} }
export const On: Story = { export const On: Story = {
render: () => ({ render: () => ({
components: { NychToggleSwitch }, components: { NychSwitch },
setup() { setup() {
const on = ref(true) const on = ref(true)
return { on } return { on }
}, },
template: `<NychToggleSwitch v-model="on" />`, template: `<NychSwitch v-model="on" />`,
}), }),
} }
export const Disabled: Story = { export const Disabled: Story = {
render: () => ({ render: () => ({
components: { NychToggleSwitch }, components: { NychSwitch },
setup() {
return {}
},
template: ` template: `
<div style="display: flex; gap: 1rem;"> <div style="display: flex; gap: 1rem;">
<NychToggleSwitch :modelValue="false" disabled /> <NychSwitch :model-value="false" disabled />
<NychToggleSwitch :modelValue="true" disabled /> <NychSwitch :model-value="true" disabled />
</div> </div>
`, `,
}), }),
@ -60,14 +57,14 @@ export const Disabled: Story = {
export const WithLabel: Story = { export const WithLabel: Story = {
render: () => ({ render: () => ({
components: { NychToggleSwitch }, components: { NychSwitch },
setup() { setup() {
const nocturnal = ref(true) const nocturnal = ref(true)
return { nocturnal } return { nocturnal }
}, },
template: ` template: `
<label style="display: flex; align-items: center; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body); cursor: pointer;"> <label style="display: flex; align-items: center; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body); cursor: pointer;">
<NychToggleSwitch v-model="nocturnal" /> <NychSwitch v-model="nocturnal" />
{{ nocturnal ? 'Hades (night)' : 'Apollo (day)' }} {{ nocturnal ? 'Hades (night)' : 'Apollo (day)' }}
</label> </label>
`, `,