Skip to content

Commit 2c15ef2

Browse files
authored
Merge pull request #7765 from nextcloud-libraries/backport/7690/stable8
[stable8] feat: add `NcFormBox` and adjust `NcRadioGroup` to `NcFormBox` and `NcFormGroup`
2 parents e1ffc47 + 7385a25 commit 2c15ef2

File tree

7 files changed

+213
-67
lines changed

7 files changed

+213
-67
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 { provide, useCssModule } from 'vue'
8+
import { NC_FORM_BOX_CONTEXT_KEY } from './useNcFormBox.ts'
9+
10+
withDefaults(defineProps<{
11+
/**
12+
* Display the group as a row instead of a column
13+
*/
14+
row?: boolean
15+
}>(), {
16+
row: false,
17+
})
18+
19+
const style = useCssModule()
20+
21+
provide(NC_FORM_BOX_CONTEXT_KEY, {
22+
isInFormBox: true,
23+
formBoxItemClass: style.ncFormBox__item,
24+
})
25+
</script>
26+
27+
<template>
28+
<div :class="[$style.ncFormBox, row ? $style.ncFormBox_row : $style.ncFormBox_col]">
29+
<!--
30+
@slot Grouped content
31+
@binding {string} itemClass - Class to add on a custom item to apply the border radius effect
32+
-->
33+
<slot :item-class="$style.ncFormBox__item" />
34+
</div>
35+
</template>
36+
37+
<style lang="scss" module>
38+
.ncFormBox {
39+
display: flex;
40+
flex-direction: column;
41+
gap: calc(1 * var(--default-grid-baseline));
42+
43+
&.ncFormBox_row {
44+
flex-direction: row;
45+
}
46+
}
47+
48+
.ncFormBox__item {
49+
border-radius: var(--border-radius-small) !important;
50+
}
51+
52+
.ncFormBox_col {
53+
flex-direction: column;
54+
55+
.ncFormBox__item {
56+
&:first-child {
57+
border-start-start-radius: var(--border-radius-element) !important;
58+
border-start-end-radius: var(--border-radius-element) !important;
59+
}
60+
61+
&:last-child {
62+
border-end-start-radius: var(--border-radius-element) !important;
63+
border-end-end-radius: var(--border-radius-element) !important;
64+
}
65+
}
66+
}
67+
68+
.ncFormBox_row {
69+
flex-direction: row;
70+
71+
.ncFormBox__item {
72+
flex: 1 1;
73+
74+
&:first-child {
75+
border-start-start-radius: var(--border-radius-element) !important;
76+
border-end-start-radius: var(--border-radius-element) !important;
77+
}
78+
79+
&:last-child {
80+
border-end-end-radius: var(--border-radius-element) !important;
81+
border-start-end-radius: var(--border-radius-element) !important;
82+
}
83+
}
84+
}
85+
</style>
86+
87+
<docs>
88+
### General
89+
90+
Visually group form elements with a small gap and rounded corners forming a solid group for supported components.
91+
92+
**Note**: if the group has a semantic meaning, consider using the `<NcFormGroup>` component.
93+
94+
```vue
95+
<script>
96+
export default {
97+
data() {
98+
return {
99+
text: 'Text',
100+
option: 'One'
101+
}
102+
}
103+
}
104+
</script>
105+
106+
<template>
107+
<NcFormBox>
108+
<NcTextField v-model="text" label="Text Field" />
109+
<NcTextField v-model="text" label="Text Field" />
110+
<NcTextField v-model="text" label="Text Field" />
111+
<NcSelect v-model="option" input-label="Select Field" :options="['One', 'Two', 'Three']" />
112+
</NcFormBox>
113+
</template>
114+
```
115+
116+
### Advanced usage
117+
118+
Use scoped slots params to apply the item class to custom items.
119+
120+
```vue
121+
<template>
122+
<div>
123+
<h4>NcButton without a group</h4>
124+
<div>
125+
<NcButton wide>
126+
First button
127+
</NcButton>
128+
<NcButton wide>
129+
Second button
130+
</NcButton>
131+
<NcButton wide>
132+
Third button
133+
</NcButton>
134+
</div>
135+
136+
<h4>NcButton inside NcFormBox with scoped-slot</h4>
137+
<NcFormBox v-slot="{ itemClass }">
138+
<NcButton :class="itemClass" wide>
139+
First button
140+
</NcButton>
141+
<NcButton :class="itemClass" wide>
142+
Second button
143+
</NcButton>
144+
<NcButton :class="itemClass" wide>
145+
Third button
146+
</NcButton>
147+
</NcFormBox>
148+
</div>
149+
</template>
150+
```
151+
</docs>

src/components/NcFormBox/index.ts

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 './NcFormBox.vue'
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { InjectionKey } from 'vue'
7+
8+
import { inject } from 'vue'
9+
10+
export const NC_FORM_BOX_CONTEXT_KEY: InjectionKey<{
11+
isInFormBox: false
12+
formBoxItemClass: undefined
13+
} | {
14+
isInFormBox: true
15+
formBoxItemClass: string
16+
}> = Symbol.for('NcFormBox:context')
17+
18+
/**
19+
* Get NcFormBox context with a fallback
20+
* TODO: make it public?
21+
*/
22+
export function useNcFormBox() {
23+
return inject(NC_FORM_BOX_CONTEXT_KEY, {
24+
isInFormBox: false,
25+
formBoxItemClass: undefined,
26+
})
27+
}

src/components/NcRadioGroup/NcRadioGroup.vue

Lines changed: 15 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
<script setup lang="ts">
77
import Vue, { computed, provide, ref } from 'vue'
8-
import { createElementId } from '../../utils/createElementId.ts'
8+
import NcFormBox from '../NcFormBox/NcFormBox.vue'
9+
import NcFormGroup from '../NcFormGroup/NcFormGroup.vue'
910
import { INSIDE_RADIO_GROUP_KEY } from './useNcRadioGroup.ts'
1011
1112
const props = defineProps<{
@@ -32,10 +33,9 @@ const props = defineProps<{
3233
}>()
3334
3435
const emit = defineEmits<{
35-
(e: 'update:modelValue', v: string): void
36+
(event: 'update:modelValue', value: string): void
3637
}>()
3738
38-
const descriptionId = createElementId()
3939
const buttonVariant = ref<boolean>()
4040
4141
provide(INSIDE_RADIO_GROUP_KEY, computed(() => ({
@@ -76,65 +76,25 @@ export default {
7676
</script>
7777

7878
<template>
79-
<fieldset
80-
:aria-describedby="description ? descriptionId : undefined"
81-
:class="[{
82-
[$style.radioGroup_buttonVariant]: buttonVariant,
83-
}, $style.radioGroup]">
84-
<legend :class="[$style.radioGroup__label, { 'hidden-visually': labelHidden }]">
85-
{{ label }}
86-
</legend>
87-
<p v-if="description" :id="descriptionId" :class="$style.radioGroup__description">
88-
{{ description }}
89-
</p>
90-
<div :class="$style.radioGroup__wrapper">
79+
<NcFormGroup
80+
:label="label"
81+
:description="description"
82+
:hide-label="labelHidden">
83+
<NcFormBox v-if="buttonVariant" row>
9184
<!-- @slot Slot for the included radio buttons (`NcCheckboxRadioSwitch`).
9285
The `type` prop of the `NcCheckboxRadioSwitch` will be automatically set (and forced) to `radio`.
9386
If you want the button variant, then you have to use `NcRadioGroupButton`.-->
9487
<slot />
95-
</div>
96-
</fieldset>
88+
</NcFormBox>
89+
<span v-else :class="$style.radioGroup_checkboxRadioContainer">
90+
<slot />
91+
</span>
92+
</NcFormGroup>
9793
</template>
9894

9995
<style module lang="scss">
100-
.radioGroup {
101-
display: flex;
102-
flex-direction: column;
103-
104-
&:not(.radioGroup_buttonVariant) :global(.checkbox-content) {
105-
max-width: unset !important;
106-
}
107-
}
108-
109-
.radioGroup__label {
110-
font-size: 1.2em;
111-
font-weight: bold;
112-
margin-inline-start: var(--border-radius-element);
113-
}
114-
115-
.radioGroup__description {
116-
color: var(--color-text-maxcontrast);
117-
margin-block-end: var(--default-grid-baseline);
118-
margin-inline-start: var(--border-radius-element);
119-
}
120-
121-
.radioGroup__wrapper {
122-
display: flex;
123-
flex-direction: column;
124-
125-
> * {
126-
flex: 1 0 1px;
127-
}
128-
}
129-
130-
.radioGroup__label + .radioGroup__wrapper {
131-
// when there is no description we need to add some margin between wrapper and label
132-
margin-block-start: var(--default-grid-baseline);
133-
}
134-
135-
.radioGroup_buttonVariant .radioGroup__wrapper {
136-
flex-direction: row;
137-
gap: var(--default-grid-baseline);
96+
.radioGroup_checkboxRadioContainer :global(.checkbox-content) {
97+
max-width: unset !important;
13898
}
13999
</style>
140100

src/components/NcRadioGroupButton/NcRadioGroupButton.vue

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<script setup lang="ts">
77
import { computed, onMounted } from 'vue'
88
import { createElementId } from '../../utils/createElementId.ts'
9+
import { useNcFormBox } from '../NcFormBox/useNcFormBox.ts'
910
import { useInsideRadioGroup } from '../NcRadioGroup/useNcRadioGroup.ts'
1011
1112
const props = defineProps<{
@@ -27,6 +28,8 @@ const props = defineProps<{
2728
2829
const labelId = createElementId()
2930
const radioGroup = useInsideRadioGroup()
31+
const { formBoxItemClass } = useNcFormBox()
32+
3033
onMounted(() => radioGroup!.value.register(true))
3134
3235
const isChecked = computed(() => radioGroup?.value.modelValue === props.value)
@@ -43,9 +46,9 @@ function onUpdate() {
4346
<div
4447
:class="[{
4548
[$style.radioGroupButton_active]: isChecked,
46-
}, $style.radioGroupButton]"
49+
}, $style.radioGroupButton, formBoxItemClass]"
4750
@click="onUpdate">
48-
<div v-if="$slots.icon" :class="$style.radioGroupButton__icon">
51+
<div v-if="$scopedSlots.icon" :class="$style.radioGroupButton__icon">
4952
<!-- @slot Optional icon slot -->
5053
<slot name="icon" />
5154
</div>
@@ -112,16 +115,6 @@ function onUpdate() {
112115
border: var(--radio-group-button--border-width) solid var(--color-main-text) !important;
113116
outline: calc(var(--default-grid-baseline) / 2) var(--color-main-background);
114117
}
115-
116-
&:first-of-type {
117-
border-start-start-radius: var(--border-radius-element);
118-
border-end-start-radius: var(--border-radius-element);
119-
}
120-
121-
&:last-of-type {
122-
border-start-end-radius: var(--border-radius-element);
123-
border-end-end-radius: var(--border-radius-element);
124-
}
125118
}
126119
127120
.radioGroupButton_active {

src/components/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.js'
5757
export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js'
5858
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
5959
export { default as NcEmptyContent } from './NcEmptyContent/index.js'
60+
export { default as NcFormBox } from './NcFormBox/index.ts'
6061
export { default as NcFormGroup } from './NcFormGroup/index.ts'
6162
export { default as NcGuestContent } from './NcGuestContent/index.js'
6263
export { default as NcHeaderButton } from './NcHeaderButton/index.js'

webpack.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,13 @@ module.exports = () => {
141141
'.mjs': ['.mts', '.mjs'],
142142
}
143143

144+
// In Vue 2 (for some reason) vue-styleguidist uses Vue as vue/dist/vue.js - UMD module
145+
// UMD (global) module doesn't support some features, such as useCssModule
146+
// See: https://github.com/vuejs/vue/blob/v2.7.16/src/v3/sfc-helpers/useCssModule.ts
147+
// Workaround: explicitly point to the ESM build of Vue (full version to support template compilation on the page)
148+
webpackConfig.resolve.alias = {
149+
vue: require.resolve('vue/dist/vue.esm.js'),
150+
}
151+
144152
return webpackConfig
145153
}

0 commit comments

Comments
 (0)