mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
feat(CheckboxGroup): new component (#3862)
Co-authored-by: Benjamin Canac <canacb1@gmail.com> Co-authored-by: Romain Hamel <rom.hml@gmail.com>
This commit is contained in:
@@ -18,10 +18,19 @@ export interface CheckboxProps extends Pick<CheckboxRootProps, 'disabled' | 'req
|
||||
* @defaultValue 'primary'
|
||||
*/
|
||||
color?: Checkbox['variants']['color']
|
||||
/**
|
||||
* @defaultValue 'list'
|
||||
*/
|
||||
variant?: Checkbox['variants']['variant']
|
||||
/**
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: Checkbox['variants']['size']
|
||||
/**
|
||||
* Position of the indicator.
|
||||
* @defaultValue 'start'
|
||||
*/
|
||||
indicator?: Checkbox['variants']['indicator']
|
||||
/**
|
||||
* The icon displayed when checked.
|
||||
* @defaultValue appConfig.ui.icons.check
|
||||
@@ -75,9 +84,10 @@ const id = _id.value ?? useId()
|
||||
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.checkbox || {}) })({
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
indicator: props.indicator,
|
||||
required: props.required,
|
||||
disabled: disabled.value,
|
||||
checked: Boolean(modelValue.value ?? props.defaultValue)
|
||||
disabled: disabled.value
|
||||
}))
|
||||
|
||||
function onUpdate(value: any) {
|
||||
@@ -91,7 +101,7 @@ function onUpdate(value: any) {
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
<template>
|
||||
<Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
|
||||
<Primitive :as="variant === 'list' ? as : Label" :class="ui.root({ class: [props.class, props.ui?.root] })">
|
||||
<div :class="ui.container({ class: props.ui?.container })">
|
||||
<CheckboxRoot
|
||||
:id="id"
|
||||
@@ -103,7 +113,7 @@ function onUpdate(value: any) {
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #default="{ modelValue }">
|
||||
<CheckboxIndicator as-child>
|
||||
<CheckboxIndicator :class="ui.indicator({ class: props.ui?.indicator })">
|
||||
<UIcon v-if="modelValue === 'indeterminate'" :name="indeterminateIcon || appConfig.ui.icons.minus" :class="ui.icon({ class: props.ui?.icon })" />
|
||||
<UIcon v-else :name="icon || appConfig.ui.icons.check" :class="ui.icon({ class: props.ui?.icon })" />
|
||||
</CheckboxIndicator>
|
||||
@@ -112,11 +122,11 @@ function onUpdate(value: any) {
|
||||
</div>
|
||||
|
||||
<div v-if="(label || !!slots.label) || (description || !!slots.description)" :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<Label v-if="label || !!slots.label" :for="id" :class="ui.label({ class: props.ui?.label })">
|
||||
<component :is="variant === 'list' ? Label : 'p'" v-if="label || !!slots.label" :for="id" :class="ui.label({ class: props.ui?.label })">
|
||||
<slot name="label" :label="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</Label>
|
||||
</component>
|
||||
<p v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
|
||||
<slot name="description" :description="description">
|
||||
{{ description }}
|
||||
|
||||
175
src/runtime/components/CheckboxGroup.vue
Normal file
175
src/runtime/components/CheckboxGroup.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import type { CheckboxGroupRootProps, CheckboxGroupRootEmits } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/checkbox-group'
|
||||
import type { CheckboxProps } from '../types'
|
||||
import type { AcceptableValue, ComponentConfig } from '../types/utils'
|
||||
|
||||
type CheckboxGroup = ComponentConfig<typeof theme, AppConfig, 'checkboxGroup'>
|
||||
|
||||
export type CheckboxGroupValue = AcceptableValue
|
||||
|
||||
export type CheckboxGroupItem = {
|
||||
label?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
value?: string
|
||||
[key: string]: any
|
||||
} | CheckboxGroupValue
|
||||
|
||||
export interface CheckboxGroupProps<T extends CheckboxGroupItem = CheckboxGroupItem> extends Pick<CheckboxGroupRootProps, 'defaultValue' | 'disabled' | 'loop' | 'modelValue' | 'name' | 'required'>, Pick<CheckboxProps, 'color' | 'variant' | 'indicator' | 'icon'> {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'div'
|
||||
*/
|
||||
as?: any
|
||||
legend?: string
|
||||
/**
|
||||
* When `items` is an array of objects, select the field to use as the value.
|
||||
* @defaultValue 'value'
|
||||
*/
|
||||
valueKey?: string
|
||||
/**
|
||||
* When `items` is an array of objects, select the field to use as the label.
|
||||
* @defaultValue 'label'
|
||||
*/
|
||||
labelKey?: string
|
||||
/**
|
||||
* When `items` is an array of objects, select the field to use as the description.
|
||||
* @defaultValue 'description'
|
||||
*/
|
||||
descriptionKey?: string
|
||||
items?: T[]
|
||||
/**
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: CheckboxGroup['variants']['size']
|
||||
/**
|
||||
* The orientation the checkbox buttons are laid out.
|
||||
* @defaultValue 'vertical'
|
||||
*/
|
||||
orientation?: CheckboxGroupRootProps['orientation']
|
||||
class?: any
|
||||
ui?: CheckboxGroup['slots']
|
||||
}
|
||||
|
||||
export type CheckboxGroupEmits = CheckboxGroupRootEmits & {
|
||||
change: [payload: Event]
|
||||
}
|
||||
|
||||
type SlotProps<T extends CheckboxGroupItem> = (props: { item: T & { id: string }, modelValue?: CheckboxGroupValue }) => any
|
||||
|
||||
export interface CheckboxGroupSlots<T extends CheckboxGroupItem = CheckboxGroupItem> {
|
||||
legend(props?: {}): any
|
||||
label: SlotProps<T>
|
||||
description: SlotProps<T>
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends CheckboxGroupItem">
|
||||
import { computed, useId } from 'vue'
|
||||
import { CheckboxGroupRoot, useForwardProps, useForwardPropsEmits } from 'reka-ui'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useFormField } from '../composables/useFormField'
|
||||
import { get } from '../utils'
|
||||
import { tv } from '../utils/tv'
|
||||
|
||||
const props = withDefaults(defineProps<CheckboxGroupProps<T>>(), {
|
||||
valueKey: 'value',
|
||||
labelKey: 'label',
|
||||
descriptionKey: 'description',
|
||||
orientation: 'vertical'
|
||||
})
|
||||
const emits = defineEmits<CheckboxGroupEmits>()
|
||||
const slots = defineSlots<CheckboxGroupSlots<T>>()
|
||||
|
||||
const appConfig = useAppConfig() as CheckboxGroup['AppConfig']
|
||||
|
||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop', 'required'), emits)
|
||||
const checkboxProps = useForwardProps(reactivePick(props, 'variant', 'indicator', 'icon'))
|
||||
|
||||
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, ariaAttrs } = useFormField<CheckboxGroupProps<T>>(props, { bind: false })
|
||||
const id = _id.value ?? useId()
|
||||
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.checkboxGroup || {}) })({
|
||||
size: size.value,
|
||||
required: props.required,
|
||||
orientation: props.orientation
|
||||
}))
|
||||
|
||||
function normalizeItem(item: any) {
|
||||
if (item === null) {
|
||||
return {
|
||||
id: `${id}:null`,
|
||||
value: undefined,
|
||||
label: undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
return {
|
||||
id: `${id}:${item}`,
|
||||
value: String(item),
|
||||
label: String(item)
|
||||
}
|
||||
}
|
||||
|
||||
const value = get(item, props.valueKey as string)
|
||||
const label = get(item, props.labelKey as string)
|
||||
const description = get(item, props.descriptionKey as string)
|
||||
|
||||
return {
|
||||
...item,
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
id: `${id}:${value}`
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedItems = computed(() => {
|
||||
if (!props.items) {
|
||||
return []
|
||||
}
|
||||
return props.items.map(normalizeItem)
|
||||
})
|
||||
|
||||
function onUpdate(value: any) {
|
||||
// @ts-expect-error - 'target' does not exist in type 'EventInit'
|
||||
const event = new Event('change', { target: { value } })
|
||||
emits('change', event)
|
||||
emitFormChange()
|
||||
emitFormInput()
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
<template>
|
||||
<CheckboxGroupRoot
|
||||
:id="id"
|
||||
v-bind="rootProps"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
:class="ui.root({ class: [props.class, props.ui?.root] })"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<fieldset :class="ui.fieldset({ class: props.ui?.fieldset })" v-bind="ariaAttrs">
|
||||
<legend v-if="legend || !!slots.legend" :class="ui.legend({ class: props.ui?.legend })">
|
||||
<slot name="legend">
|
||||
{{ legend }}
|
||||
</slot>
|
||||
</legend>
|
||||
|
||||
<UCheckbox
|
||||
v-for="item in normalizedItems"
|
||||
:key="item.value"
|
||||
v-bind="{ ...item, ...checkboxProps }"
|
||||
:color="color"
|
||||
:size="size"
|
||||
:name="name"
|
||||
:disabled="item.disabled || disabled"
|
||||
/>
|
||||
</fieldset>
|
||||
</CheckboxGroupRoot>
|
||||
</template>
|
||||
@@ -175,6 +175,7 @@ function onUpdate(value: any) {
|
||||
{{ legend }}
|
||||
</slot>
|
||||
</legend>
|
||||
|
||||
<component :is="variant === 'list' ? 'div' : Label" v-for="item in normalizedItems" :key="item.value" :class="ui.item({ class: props.ui?.item })">
|
||||
<div :class="ui.container({ class: props.ui?.container })">
|
||||
<RadioGroupItem
|
||||
@@ -187,8 +188,8 @@ function onUpdate(value: any) {
|
||||
</RadioGroupItem>
|
||||
</div>
|
||||
|
||||
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<component :is="variant === 'list' ? Label : 'p'" :class="ui.label({ class: props.ui?.label })" :for="item.id">
|
||||
<div v-if="(item.label || !!slots.label) || (item.description || !!slots.description)" :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<component :is="variant === 'list' ? Label : 'p'" v-if="item.label || !!slots.label" :for="item.id" :class="ui.label({ class: props.ui?.label })">
|
||||
<slot name="label" :item="item" :model-value="(modelValue as RadioGroupValue)">
|
||||
{{ item.label }}
|
||||
</slot>
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from '../components/Calendar.vue'
|
||||
export * from '../components/Card.vue'
|
||||
export * from '../components/Carousel.vue'
|
||||
export * from '../components/Checkbox.vue'
|
||||
export * from '../components/CheckboxGroup.vue'
|
||||
export * from '../components/Chip.vue'
|
||||
export * from '../components/Collapsible.vue'
|
||||
export * from '../components/ColorPicker.vue'
|
||||
|
||||
47
src/theme/checkbox-group.ts
Normal file
47
src/theme/checkbox-group.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export default {
|
||||
slots: {
|
||||
root: 'relative',
|
||||
fieldset: 'flex gap-x-2',
|
||||
legend: 'mb-1 block font-medium text-default'
|
||||
},
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: {
|
||||
fieldset: 'flex-row'
|
||||
},
|
||||
vertical: {
|
||||
fieldset: 'flex-col'
|
||||
}
|
||||
},
|
||||
size: {
|
||||
xs: {
|
||||
fieldset: 'gap-y-0.5',
|
||||
legend: 'text-xs'
|
||||
},
|
||||
sm: {
|
||||
fieldset: 'gap-y-0.5',
|
||||
legend: 'text-xs'
|
||||
},
|
||||
md: {
|
||||
fieldset: 'gap-y-1',
|
||||
legend: 'text-sm'
|
||||
},
|
||||
lg: {
|
||||
fieldset: 'gap-y-1',
|
||||
legend: 'text-sm'
|
||||
},
|
||||
xl: {
|
||||
fieldset: 'gap-y-1.5',
|
||||
legend: 'text-base'
|
||||
}
|
||||
},
|
||||
required: {
|
||||
true: {
|
||||
legend: 'after:content-[\'*\'] after:ms-0.5 after:text-error'
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,46 @@ import type { ModuleOptions } from '../module'
|
||||
export default (options: Required<ModuleOptions>) => ({
|
||||
slots: {
|
||||
root: 'relative flex items-start',
|
||||
base: 'shrink-0 flex items-center justify-center rounded-sm text-inverted ring ring-inset ring-accented focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
container: 'flex items-center',
|
||||
wrapper: 'ms-2',
|
||||
base: 'rounded-sm ring ring-inset ring-accented overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
indicator: 'flex items-center justify-center size-full text-inverted',
|
||||
icon: 'shrink-0 size-full',
|
||||
wrapper: 'w-full',
|
||||
label: 'block font-medium text-default',
|
||||
description: 'text-muted'
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, `focus-visible:outline-${color}`])),
|
||||
neutral: 'focus-visible:outline-inverted'
|
||||
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, {
|
||||
base: `focus-visible:outline-${color}`,
|
||||
indicator: `bg-${color}`
|
||||
}])),
|
||||
neutral: {
|
||||
base: 'focus-visible:outline-inverted',
|
||||
indicator: 'bg-inverted'
|
||||
}
|
||||
},
|
||||
variant: {
|
||||
list: {
|
||||
root: ''
|
||||
},
|
||||
card: {
|
||||
root: 'border border-muted rounded-lg'
|
||||
}
|
||||
},
|
||||
indicator: {
|
||||
start: {
|
||||
root: 'flex-row',
|
||||
wrapper: 'ms-2'
|
||||
},
|
||||
end: {
|
||||
root: 'flex-row-reverse',
|
||||
wrapper: 'me-2'
|
||||
},
|
||||
hidden: {
|
||||
base: 'sr-only',
|
||||
wrapper: 'text-center'
|
||||
}
|
||||
},
|
||||
size: {
|
||||
xs: {
|
||||
@@ -58,17 +87,38 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
true: ''
|
||||
}
|
||||
},
|
||||
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
checked: true,
|
||||
class: `ring-2 ring-${color} bg-${color}`
|
||||
})), {
|
||||
color: 'neutral',
|
||||
checked: true,
|
||||
class: 'ring-2 ring-inverted bg-inverted'
|
||||
}],
|
||||
compoundVariants: [
|
||||
{ size: 'xs', variant: 'card', class: { root: 'p-2.5' } },
|
||||
{ size: 'sm', variant: 'card', class: { root: 'p-3' } },
|
||||
{ size: 'md', variant: 'card', class: { root: 'p-3.5' } },
|
||||
{ size: 'lg', variant: 'card', class: { root: 'p-4' } },
|
||||
{ size: 'xl', variant: 'card', class: { root: 'p-4.5' } },
|
||||
...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'card',
|
||||
class: {
|
||||
root: `has-data-[state=checked]:border-${color}`
|
||||
}
|
||||
})),
|
||||
{
|
||||
color: 'neutral',
|
||||
variant: 'card',
|
||||
class: {
|
||||
root: 'has-data-[state=checked]:border-inverted'
|
||||
}
|
||||
},
|
||||
{
|
||||
variant: 'card',
|
||||
disabled: true,
|
||||
class: {
|
||||
root: 'cursor-not-allowed opacity-75'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'primary'
|
||||
color: 'primary',
|
||||
variant: 'list',
|
||||
indicator: 'start'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ export { default as calendar } from './calendar'
|
||||
export { default as card } from './card'
|
||||
export { default as carousel } from './carousel'
|
||||
export { default as checkbox } from './checkbox'
|
||||
export { default as checkboxGroup } from './checkbox-group'
|
||||
export { default as chip } from './chip'
|
||||
export { default as collapsible } from './collapsible'
|
||||
export { default as colorPicker } from './color-picker'
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { ModuleOptions } from '../module'
|
||||
export default (options: Required<ModuleOptions>) => ({
|
||||
slots: {
|
||||
root: 'relative',
|
||||
fieldset: 'flex',
|
||||
fieldset: 'flex gap-x-2',
|
||||
legend: 'mb-1 block font-medium text-default',
|
||||
item: 'flex items-start',
|
||||
base: 'rounded-full ring ring-inset ring-accented focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
indicator: 'flex items-center justify-center size-full rounded-full after:bg-default after:rounded-full',
|
||||
container: 'flex items-center',
|
||||
base: 'rounded-full ring ring-inset ring-accented overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
indicator: 'flex items-center justify-center size-full after:bg-default after:rounded-full',
|
||||
wrapper: 'w-full',
|
||||
label: 'block font-medium text-default',
|
||||
description: 'text-muted'
|
||||
@@ -26,9 +26,10 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
},
|
||||
variant: {
|
||||
list: {
|
||||
item: ''
|
||||
},
|
||||
card: {
|
||||
item: 'items-center border border-muted rounded-lg'
|
||||
item: 'border border-muted rounded-lg'
|
||||
},
|
||||
table: {
|
||||
item: 'border border-muted'
|
||||
@@ -36,8 +37,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
},
|
||||
orientation: {
|
||||
horizontal: {
|
||||
fieldset: 'flex-row',
|
||||
wrapper: 'me-2'
|
||||
fieldset: 'flex-row'
|
||||
},
|
||||
vertical: {
|
||||
fieldset: 'flex-col'
|
||||
@@ -46,11 +46,11 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
indicator: {
|
||||
start: {
|
||||
item: 'flex-row',
|
||||
base: 'me-2'
|
||||
wrapper: 'ms-2'
|
||||
},
|
||||
end: {
|
||||
item: 'flex-row-reverse',
|
||||
base: 'ms-2'
|
||||
wrapper: 'me-2'
|
||||
},
|
||||
hidden: {
|
||||
base: 'sr-only',
|
||||
@@ -59,7 +59,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
},
|
||||
size: {
|
||||
xs: {
|
||||
fieldset: 'gap-0.5',
|
||||
fieldset: 'gap-y-0.5',
|
||||
legend: 'text-xs',
|
||||
base: 'size-3',
|
||||
item: 'text-xs',
|
||||
@@ -67,7 +67,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
indicator: 'after:size-1'
|
||||
},
|
||||
sm: {
|
||||
fieldset: 'gap-0.5',
|
||||
fieldset: 'gap-y-0.5',
|
||||
legend: 'text-xs',
|
||||
base: 'size-3.5',
|
||||
item: 'text-xs',
|
||||
@@ -75,7 +75,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
indicator: 'after:size-1'
|
||||
},
|
||||
md: {
|
||||
fieldset: 'gap-1',
|
||||
fieldset: 'gap-y-1',
|
||||
legend: 'text-sm',
|
||||
base: 'size-4',
|
||||
item: 'text-sm',
|
||||
@@ -83,7 +83,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
indicator: 'after:size-1.5'
|
||||
},
|
||||
lg: {
|
||||
fieldset: 'gap-1',
|
||||
fieldset: 'gap-y-1',
|
||||
legend: 'text-sm',
|
||||
base: 'size-4.5',
|
||||
item: 'text-sm',
|
||||
@@ -91,7 +91,7 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
indicator: 'after:size-1.5'
|
||||
},
|
||||
xl: {
|
||||
fieldset: 'gap-1.5',
|
||||
fieldset: 'gap-y-1.5',
|
||||
legend: 'text-base',
|
||||
base: 'size-5',
|
||||
item: 'text-base',
|
||||
@@ -160,6 +160,13 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
class: {
|
||||
item: 'has-data-[state=checked]:bg-elevated has-data-[state=checked]:border-inverted/50 has-data-[state=checked]:z-[1]'
|
||||
}
|
||||
},
|
||||
{
|
||||
variant: ['card', 'table'],
|
||||
disabled: true,
|
||||
class: {
|
||||
item: 'cursor-not-allowed opacity-75'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user