Skip to content

Commit ba6c1a1

Browse files
committed
feat(NcRadioGroup): add component to group radio buttons
This allows to only add the model value on the wrapper and the radio buttons automatically update it. Also it has a nicer implementation of the button variant. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent df6f046 commit ba6c1a1

File tree

8 files changed

+433
-24
lines changed

8 files changed

+433
-24
lines changed

src/components/NcCheckboxRadioSwitch/NcCheckboxRadioSwitch.vue

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export default {
273273
:class="[
274274
$props.class,
275275
{
276-
['checkbox-radio-switch-' + type]: type,
276+
['checkbox-radio-switch-' + internalType]: internalType,
277277
'checkbox-radio-switch--checked': isChecked,
278278
'checkbox-radio-switch--disabled': disabled,
279279
'checkbox-radio-switch--indeterminate': hasIndeterminate ? indeterminate : false,
@@ -308,7 +308,7 @@ export default {
308308
class="checkbox-radio-switch__content"
309309
icon-class="checkbox-radio-switch__icon"
310310
text-class="checkbox-radio-switch__text"
311-
:type="type"
311+
:type="internalType"
312312
:indeterminate="hasIndeterminate ? indeterminate : false"
313313
:button-variant="buttonVariant"
314314
:is-checked="isChecked"
@@ -337,9 +337,11 @@ export default {
337337
</template>
338338

339339
<script>
340+
import { computed } from 'vue'
340341
import NcCheckboxContent, { TYPE_BUTTON, TYPE_CHECKBOX, TYPE_RADIO, TYPE_SWITCH } from './NcCheckboxContent.vue'
341342
import { n, t } from '../../l10n.ts'
342343
import { createElementId } from '../../utils/createElementId.ts'
344+
import { useInsideRadioGroup } from '../NcRadioGroup/useNcRadioGroup.ts'
343345
344346
export default {
345347
name: 'NcCheckboxRadioSwitch',
@@ -515,16 +517,42 @@ export default {
515517
516518
emits: ['update:modelValue'],
517519
518-
setup() {
520+
setup(props, { emit }) {
521+
const radioGroup = useInsideRadioGroup()
522+
523+
const internalType = computed(() => radioGroup?.value ? TYPE_RADIO : props.type)
524+
525+
/**
526+
* A wrapper around the model value, if inside a radio group use the injected value otherwise use the prop.
527+
*/
528+
const internalModelValue = computed({
529+
get() {
530+
if (radioGroup?.value) {
531+
return radioGroup.value.modelValue
532+
}
533+
return props.modelValue
534+
},
535+
set(value) {
536+
if (radioGroup?.value) {
537+
radioGroup.value.onUpdate(value)
538+
} else {
539+
emit('update:modelValue', value)
540+
}
541+
},
542+
})
543+
519544
return {
545+
internalType,
546+
internalModelValue,
547+
520548
labelId: createElementId(),
521549
descriptionId: createElementId(),
522550
}
523551
},
524552
525553
computed: {
526554
isButtonType() {
527-
return this.type === TYPE_BUTTON
555+
return this.internalType === TYPE_BUTTON
528556
},
529557
530558
computedWrapperElement() {
@@ -549,7 +577,7 @@ export default {
549577
},
550578
551579
iconSize() {
552-
return this.type === TYPE_SWITCH
580+
return this.internalType === TYPE_SWITCH
553581
? 36
554582
: 20
555583
},
@@ -559,7 +587,7 @@ export default {
559587
},
560588
561589
cssIconHeight() {
562-
return this.type === TYPE_SWITCH
590+
return this.internalType === TYPE_SWITCH
563591
? '16px'
564592
: this.cssIconSize
565593
},
@@ -576,8 +604,8 @@ export default {
576604
TYPE_RADIO,
577605
TYPE_BUTTON,
578606
]
579-
if (nativeTypes.includes(this.type)) {
580-
return this.type
607+
if (nativeTypes.includes(this.internalType)) {
608+
return this.internalType
581609
}
582610
return TYPE_CHECKBOX
583611
},
@@ -591,12 +619,12 @@ export default {
591619
*/
592620
isChecked() {
593621
if (this.value !== null) {
594-
if (Array.isArray(this.modelValue)) {
595-
return [...this.modelValue].indexOf(this.value) > -1
622+
if (Array.isArray(this.internalModelValue)) {
623+
return [...this.internalModelValue].indexOf(this.value) > -1
596624
}
597-
return this.modelValue === this.value
625+
return this.internalModelValue === this.value
598626
}
599-
return this.modelValue === true
627+
return this.internalModelValue === true
600628
},
601629
602630
hasIndeterminate() {
@@ -608,19 +636,19 @@ export default {
608636
},
609637
610638
mounted() {
611-
if (this.name && this.type === TYPE_CHECKBOX) {
612-
if (!Array.isArray(this.modelValue)) {
639+
if (this.name && this.internalType === TYPE_CHECKBOX) {
640+
if (!Array.isArray(this.internalModelValue)) {
613641
throw new Error('When using groups of checkboxes, the updated value will be an array.')
614642
}
615643
}
616644
617645
// https://material.io/components/checkboxes#usage
618-
if (this.name && this.type === TYPE_SWITCH) {
646+
if (this.name && this.internalType === TYPE_SWITCH) {
619647
throw new Error('Switches are not made to be used for data sets. Please use checkboxes instead.')
620648
}
621649
622650
// https://material.io/components/checkboxes#usage
623-
if (typeof this.modelValue !== 'boolean' && this.type === TYPE_SWITCH) {
651+
if (typeof this.internalModelValue !== 'boolean' && this.internalType === TYPE_SWITCH) {
624652
throw new Error('Switches can only be used with boolean as modelValue prop.')
625653
}
626654
},
@@ -635,20 +663,20 @@ export default {
635663
}
636664
637665
// If this is a radio, there can only be one value
638-
if (this.type === TYPE_RADIO) {
639-
this.$emit('update:modelValue', this.value)
666+
if (this.internalType === TYPE_RADIO) {
667+
this.internalModelValue = this.value
640668
return
641669
}
642670
643671
// If this is a radio, there can only be one value
644-
if (this.type === TYPE_SWITCH) {
645-
this.$emit('update:modelValue', !this.isChecked)
672+
if (this.internalType === TYPE_SWITCH) {
673+
this.internalModelValue = !this.isChecked
646674
return
647675
}
648676
649677
// If the initial value was a boolean, let's keep it that way
650-
if (typeof this.modelValue === 'boolean') {
651-
this.$emit('update:modelValue', !this.modelValue)
678+
if (typeof this.internalModelValue === 'boolean') {
679+
this.internalModelValue = !this.internalModelValue
652680
return
653681
}
654682
@@ -658,9 +686,9 @@ export default {
658686
.map((input) => input.value)
659687
660688
if (values.includes(this.value)) {
661-
this.$emit('update:modelValue', values.filter((v) => v !== this.value))
689+
this.internalModelValue = values.filter((v) => v !== this.value)
662690
} else {
663-
this.$emit('update:modelValue', [...values, this.value])
691+
this.internalModelValue = [...values, this.value]
664692
}
665693
},
666694
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import type { Slot } from 'vue'
8+
9+
import { computed, provide } from 'vue'
10+
import { INSIDE_RADIO_GROUP_KEY } from './useNcRadioGroup.ts'
11+
12+
const modelValue = defineModel<string>({ required: false, default: '' })
13+
14+
defineProps<{
15+
/**
16+
* If the radio group should be shown as buttons.
17+
* When set `NcRadioGroupButton` must be used in the default slot.
18+
*/
19+
buttonVariant?: boolean
20+
21+
/**
22+
* Optional visual label of the radio group
23+
*/
24+
label?: string
25+
}>()
26+
27+
defineSlots<{
28+
/**
29+
* Slot for the included radio buttons (`NcCheckboxRadioSwitch`).
30+
* The `type` prop of the `NcCheckboxRadioSwitch` will be automatically set (and forced) to `radio`.
31+
*
32+
* If you want the button variant, then you have to use `NcRadioGroupButton`.
33+
*/
34+
default?: Slot
35+
}>()
36+
37+
provide(INSIDE_RADIO_GROUP_KEY, computed(() => ({
38+
modelValue: modelValue.value,
39+
onUpdate,
40+
})))
41+
42+
/**
43+
* Handle updating the current model value
44+
*
45+
* @param value - The new value
46+
*/
47+
function onUpdate(value: string) {
48+
modelValue.value = value
49+
}
50+
</script>
51+
52+
<template>
53+
<fieldset
54+
:class="[{
55+
[$style.radioGroup_buttonVariant]: buttonVariant,
56+
}, $style.radioGroup]">
57+
<legend v-if="label" :class="$style.radioGroup__label">
58+
{{ label }}
59+
</legend>
60+
<div :class="$style.radioGroup__wrapper">
61+
<slot />
62+
</div>
63+
</fieldset>
64+
</template>
65+
66+
<style module lang="scss">
67+
.radioGroup {
68+
display: flex;
69+
flex-direction: column;
70+
71+
&:not(.radioGroup_buttonVariant) :global(.checkbox-content) {
72+
max-width: unset !important;
73+
}
74+
}
75+
76+
.radioGroup__wrapper {
77+
display: flex;
78+
flex-direction: column;
79+
80+
> * {
81+
flex: 1 0 1px;
82+
}
83+
}
84+
85+
.radioGroup_buttonVariant .radioGroup__wrapper {
86+
flex-direction: row;
87+
gap: var(--default-grid-baseline);
88+
}
89+
90+
.radioGroup__label {
91+
font-weight: bold;
92+
margin-inline-start: var(--border-radius-element);
93+
}
94+
</style>
95+
96+
<docs>
97+
## Usage example
98+
99+
### Grouping multiple radio buttons
100+
101+
The radio group allows to group radio buttons into semantical groups.
102+
The `v-model` only needs to be bound to the group component, also the `type` will automatically be set to `radio`.
103+
104+
```vue
105+
<template>
106+
<NcRadioGroup v-model="selectedSides" class="radio-group" label="Sides">
107+
<NcCheckboxRadioSwitch value="fries">
108+
Fries
109+
</NcCheckboxRadioSwitch>
110+
<NcCheckboxRadioSwitch value="salad">
111+
Salad
112+
</NcCheckboxRadioSwitch>
113+
<NcCheckboxRadioSwitch value="none">
114+
Nothing
115+
</NcCheckboxRadioSwitch>
116+
</NcRadioGroup>
117+
</template>
118+
119+
<script>
120+
export default {
121+
data() {
122+
return {
123+
selectedSides: 'none',
124+
}
125+
},
126+
}
127+
</script>
128+
129+
<style scoped>
130+
.radio-group {
131+
max-width: 400px
132+
}
133+
</style>
134+
```
135+
136+
### Radio buttons with button styling
137+
138+
The radio group also allows to create a button like styling together with the `NcRadioGroupButton` component:
139+
140+
```vue
141+
<template>
142+
<div>
143+
<h4>With text labels</h4>
144+
<div style="max-width: 400px">
145+
<NcRadioGroup v-model="alignment" button-variant>
146+
<NcRadioGroupButton label="Start" value="start" />
147+
<NcRadioGroupButton label="Center" value="center" />
148+
<NcRadioGroupButton label="End" value="end" />
149+
</NcRadioGroup>
150+
</div>
151+
152+
<br>
153+
154+
<h4>With icons</h4>
155+
<div style="max-width: 250px">
156+
<NcRadioGroup v-model="alignment" button-variant>
157+
<NcRadioGroupButton aria-label="Start" value="start">
158+
<template #icon>
159+
<NcIconSvgWrapper directional :path="mdiAlignHorizontalLeft" />
160+
</template>
161+
</NcRadioGroupButton>
162+
<NcRadioGroupButton aria-label="Center" value="center">
163+
<template #icon>
164+
<NcIconSvgWrapper :path="mdiAlignHorizontalCenter" />
165+
</template>
166+
</NcRadioGroupButton>
167+
<NcRadioGroupButton aria-label="End" value="end">
168+
<template #icon>
169+
<NcIconSvgWrapper directional :path="mdiAlignHorizontalRight" />
170+
</template>
171+
</NcRadioGroupButton>
172+
</NcRadioGroup>
173+
</div>
174+
</div>
175+
</template>
176+
177+
<script>
178+
import { mdiAlignHorizontalCenter, mdiAlignHorizontalLeft, mdiAlignHorizontalRight } from '@mdi/js'
179+
180+
export default {
181+
setup() {
182+
return {
183+
mdiAlignHorizontalCenter,
184+
mdiAlignHorizontalLeft,
185+
mdiAlignHorizontalRight,
186+
}
187+
},
188+
data() {
189+
return {
190+
alignment: 'center',
191+
}
192+
},
193+
}
194+
</script>
195+
```
196+
</docs>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
export { default } from './NcRadioGroup.vue'

0 commit comments

Comments
 (0)