diff --git a/playground/app.vue b/playground/app.vue index ba0bd2cc..ff567d3a 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -25,6 +25,7 @@ const components = [ 'modal', 'navigation-menu', 'popover', + 'radio-group', 'skeleton', 'slideover', 'switch', diff --git a/playground/components/FormElementsExample.vue b/playground/components/FormElementsExample.vue new file mode 100644 index 00000000..bd9404ad --- /dev/null +++ b/playground/components/FormElementsExample.vue @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + Submit + + + + Clear + + + + diff --git a/playground/pages/form.vue b/playground/pages/form.vue index 16477892..89876800 100644 --- a/playground/pages/form.vue +++ b/playground/pages/form.vue @@ -116,6 +116,7 @@ function onSubmit (event: FormSubmitEvent) { + diff --git a/playground/pages/radio-group.vue b/playground/pages/radio-group.vue new file mode 100644 index 00000000..4de1dac0 --- /dev/null +++ b/playground/pages/radio-group.vue @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + With slots + + + + + {{ option.label }} + + + + + + diff --git a/src/runtime/components/Checkbox.vue b/src/runtime/components/Checkbox.vue index 5e71f478..e79291fc 100644 --- a/src/runtime/components/Checkbox.vue +++ b/src/runtime/components/Checkbox.vue @@ -68,6 +68,9 @@ const checked = computed({ } }) +// FIXME: I think there's a race condition between this and the v-model event. +// This must be triggered after the value updates, otherwise the form validates +// the previous value. function onChecked () { emitFormChange() } diff --git a/src/runtime/components/DropdownMenu.vue b/src/runtime/components/DropdownMenu.vue index b733f872..45abae51 100644 --- a/src/runtime/components/DropdownMenu.vue +++ b/src/runtime/components/DropdownMenu.vue @@ -10,8 +10,6 @@ const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial { label?: string icon?: IconProps['name'] diff --git a/src/runtime/components/RadioGroup.vue b/src/runtime/components/RadioGroup.vue new file mode 100644 index 00000000..43a92928 --- /dev/null +++ b/src/runtime/components/RadioGroup.vue @@ -0,0 +1,138 @@ + + + + + + + + + + {{ legend }} + + + + + + + + + + + + {{ option.label }} + + + + {{ option.description }} + + + + + + + diff --git a/src/theme/index.ts b/src/theme/index.ts index 4a847f9d..d162c68b 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -17,9 +17,10 @@ export { default as link } from './link' export { default as modal } from './modal' export { default as navigationMenu } from './navigationMenu' export { default as popover } from './popover' +export { default as radioGroup } from './radioGroup' export { default as skeleton } from './skeleton' export { default as slideover } from './slideover' export { default as switch } from './switch' export { default as tabs } from './tabs' -export { default as tooltip } from './tooltip' export { default as textarea } from './textarea' +export { default as tooltip } from './tooltip' diff --git a/src/theme/radioGroup.ts b/src/theme/radioGroup.ts new file mode 100644 index 00000000..bd874be5 --- /dev/null +++ b/src/theme/radioGroup.ts @@ -0,0 +1,86 @@ +export default (config: { colors: string[] }) => ({ + slots: { + root: 'relative', + fieldset: 'flex flex-col', + legend: 'mb-1 block font-medium text-gray-700 dark:text-gray-200', + + option: 'flex items-start', + base: 'rounded-full ring ring-inset ring-gray-300 dark:ring-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-offset-white dark:focus-visible:outline-offset-gray-900', + indicator: 'flex items-center justify-center size-full rounded-full after:bg-white dark:after:bg-gray-900 after:rounded-full', + container: 'flex items-center', + + wrapper: 'ms-2', + label: 'block font-medium text-gray-700 dark:text-gray-200', + description: 'text-gray-500 dark:text-gray-400' + }, + variants: { + color: Object.fromEntries(config.colors.map((color: string) => [ + color, { + base: `focus-visible:outline-${color}-500 dark:focus-visible:outline-${color}-400`, + indicator: `bg-${color}-500 dark:bg-${color}-400` + } + ])), + + size: { + '2xs': { + fieldset: 'gap-0.5', + base: 'size-3', + option: 'text-xs', + container: 'h-4', + indicator: 'after:size-1' + }, + xs: { + fieldset: 'gap-0.5', + base: 'size-3.5', + option: 'text-xs', + container: 'h-4', + indicator: 'after:size-1' + }, + sm: { + fieldset: 'gap-1', + base: 'size-4', + option: 'text-sm', + container: 'h-5', + indicator: 'after:size-1.5' + }, + md: { + fieldset: 'gap-1', + base: 'size-[18px]', + option: 'text-sm', + container: 'h-5', + indicator: 'after:size-1.5' + }, + lg: { + fieldset: 'gap-1.5', + base: 'size-5', + option: 'text-base', + container: 'h-6', + indicator: 'after:size-2' + }, + xl: { + fieldset: 'gap-1.5', + base: 'size-[22px]', + option: 'text-base', + container: 'h-6', + indicator: 'after:size-2' + } + }, + + disabled: { + true: { + container: 'cursor-not-allowed opacity-75' + } + }, + + required: { + true: { + legend: 'after:content-[\'*\'] after:ms-0.5 after:text-red-500 dark:after:text-red-400' + } + } + }, + + defaultVariants: { + size: 'sm', + color: 'primary' + } +}) diff --git a/test/components/RadioGroup.spec.ts b/test/components/RadioGroup.spec.ts new file mode 100644 index 00000000..71ca6917 --- /dev/null +++ b/test/components/RadioGroup.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import RadioGroup, { type RadioGroupProps } from '../../src/runtime/components/RadioGroup.vue' +import ComponentRender from '../component-render' +import { defu } from 'defu' + +const defaultOptions = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' } +] + +describe('RadioGroup', () => { + it.each([ + ['basic case', {}], + ['with default value', { props: { defaultValue: '1' } }], + ['with disabled', { props: { disabled: true } }], + ['with description', { props: { options: defaultOptions.map((opt, count) => ({ ...opt, description: `Description ${count}` })) } }], + ['with required', { props: { legend: 'Legend', required: true } }], + ['with custom color', { props: { color: 'red' as const } }], + ['with size 2xs', { props: { size: '2xs' as const } }], + ['with size xs', { props: { size: 'xs' as const } }], + ['with size sm', { props: { size: 'sm' as const } }], + ['with size md', { props: { size: 'md' as const } }], + ['with size lg', { props: { size: 'lg' as const } }], + ['with size xl', { props: { size: 'xl' as const } }], + ['with class', { props: { class: 'bg-red-500' } }], + ['with ui', { props: { ui: {} } }] + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: RadioGroupProps, slots?: any }) => { + const html = await ComponentRender(nameOrHtml, defu(options, { props: { options: defaultOptions } }), RadioGroup) + expect(html).toMatchSnapshot() + }) +}) diff --git a/test/components/__snapshots__/RadioGroup.spec.ts.snap b/test/components/__snapshots__/RadioGroup.spec.ts.snap new file mode 100644 index 00000000..4837c100 --- /dev/null +++ b/test/components/__snapshots__/RadioGroup.spec.ts.snap @@ -0,0 +1,517 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RadioGroup > renders basic case correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with class correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with custom color correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with default value correctly 1`] = ` +" + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with description correctly 1`] = ` +" + + + + + + + + Option 1 + Description 0 + + + + + + + + Option 2 + Description 1 + + + + + + + + Option 3 + Description 2 + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with disabled correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with required correctly 1`] = ` +" + + Legend + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size 2xs correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size lg correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size md correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size sm correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size xl correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with size xs correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`; + +exports[`RadioGroup > renders with ui correctly 1`] = ` +" + + + + + + + + Option 1 + + + + + + + + + Option 2 + + + + + + + + + Option 3 + + + + +" +`;
+ + {{ option.description }} + +
Description 0
Description 1
Description 2