Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/components/NcFormBox/NcFormBox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { Slot } from 'vue'

import { provide, useCssModule } from 'vue'
import { NC_FORM_BOX_CONTEXT_KEY } from './useNcFormBox.ts'

defineProps<{
/**
* Display the group as a row instead of a column
*/
row?: boolean
}>()

defineSlots<{
/**
* Grouped content
*/
default?: Slot<{
/**
* Class to add on a custom item to apply the border radius effect
*/
itemClass: string
}>
}>()

const style = useCssModule()

provide(NC_FORM_BOX_CONTEXT_KEY, {
isInFormBox: true,
formBoxItemClass: style.ncFormBox__item,
})
</script>

<template>
<div :class="[$style.ncFormBox, row ? $style.ncFormBox_row : $style.ncFormBox_col]">
<slot :item-class="$style.ncFormBox__item" />
</div>
</template>

<style lang="scss" module>
.ncFormBox {
display: flex;
flex-direction: column;
gap: calc(1 * var(--default-grid-baseline));

&.ncFormBox_row {
flex-direction: row;
}
}

.ncFormBox__item {
border-radius: var(--border-radius-small) !important;
}

.ncFormBox_col {
flex-direction: column;

.ncFormBox__item {
&:first-child {
border-start-start-radius: var(--border-radius-element) !important;
border-start-end-radius: var(--border-radius-element) !important;
}

&:last-child {
border-end-start-radius: var(--border-radius-element) !important;
border-end-end-radius: var(--border-radius-element) !important;
}
}
}

.ncFormBox_row {
flex-direction: row;

.ncFormBox__item {
flex: 1 1;

&:first-child {
border-start-start-radius: var(--border-radius-element) !important;
border-end-start-radius: var(--border-radius-element) !important;
}

&:last-child {
border-end-end-radius: var(--border-radius-element) !important;
border-start-end-radius: var(--border-radius-element) !important;
}
}
}
</style>

<docs>
### General

Visually group form elements with a small gap and rounded corners forming a solid group for supported components.

**Note**: if the group has a semantic meaning, consider using the `<NcFormGroup>` component.

```vue
<script>
export default {
data() {
return {
text: 'Text',
option: 'One'
}
}
}
</script>

<template>
<NcFormBox>
<NcTextField v-model="text" label="Text Field" />
<NcTextField v-model="text" label="Text Field" />
<NcTextField v-model="text" label="Text Field" />
<NcSelect v-model="option" input-label="Select Field" :options="['One', 'Two', 'Three']" />
</NcFormBox>
</template>
```

### Advanced usage

Use scoped slots params to apply the item class to custom items.

```vue
<template>
<div>
<h4>NcButton without a group</h4>
<div>
<NcButton wide>
First button
</NcButton>
<NcButton wide>
Second button
</NcButton>
<NcButton wide>
Third button
</NcButton>
</div>

<h4>NcButton inside NcFormBox with scoped-slot</h4>
<NcFormBox v-slot="{ itemClass }">
<NcButton :class="itemClass" wide>
First button
</NcButton>
<NcButton :class="itemClass" wide>
Second button
</NcButton>
<NcButton :class="itemClass" wide>
Third button
</NcButton>
</NcFormBox>
</div>
</template>
```
</docs>
6 changes: 6 additions & 0 deletions src/components/NcFormBox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export { default } from './NcFormBox.vue'
27 changes: 27 additions & 0 deletions src/components/NcFormBox/useNcFormBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { InjectionKey } from 'vue'

import { inject } from 'vue'

export const NC_FORM_BOX_CONTEXT_KEY: InjectionKey<{
isInFormBox: false
formBoxItemClass: undefined
} | {
isInFormBox: true
formBoxItemClass: string
}> = Symbol.for('NcFormBox:context')

/**
* Get NcFormBox context with a fallback
* TODO: make it public?
*/
export function useNcFormBox() {
return inject(NC_FORM_BOX_CONTEXT_KEY, {
isInFormBox: false,
formBoxItemClass: undefined,
})
}
68 changes: 14 additions & 54 deletions src/components/NcRadioGroup/NcRadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import type { Slot } from 'vue'

import { computed, provide, ref, warn } from 'vue'
import { createElementId } from '../../utils/createElementId.ts'
import NcFormBox from '../NcFormBox/NcFormBox.vue'
import NcFormGroup from '../NcFormGroup/NcFormGroup.vue'
import { INSIDE_RADIO_GROUP_KEY } from './useNcRadioGroup.ts'

const modelValue = defineModel<string>({ required: false, default: '' })
Expand Down Expand Up @@ -40,7 +41,6 @@ defineSlots<{
default?: Slot
}>()

const descriptionId = createElementId()
const buttonVariant = ref<boolean>()

provide(INSIDE_RADIO_GROUP_KEY, computed(() => ({
Expand Down Expand Up @@ -72,62 +72,22 @@ function onUpdate(value: string) {
</script>

<template>
<fieldset
:aria-describedby="description ? descriptionId : undefined"
:class="[{
[$style.radioGroup_buttonVariant]: buttonVariant,
}, $style.radioGroup]">
<legend :class="[$style.radioGroup__label, { 'hidden-visually': labelHidden }]">
{{ label }}
</legend>
<p v-if="description" :id="descriptionId" :class="$style.radioGroup__description">
{{ description }}
</p>
<div :class="$style.radioGroup__wrapper">
<NcFormGroup
:label
:description
:hide-label="labelHidden">
<NcFormBox v-if="buttonVariant" row>
<slot />
</div>
</fieldset>
</NcFormBox>
<span v-else :class="$style.radioGroup_checkboxRadioContainer">
<slot />
</span>
</NcFormGroup>
</template>

<style module lang="scss">
.radioGroup {
display: flex;
flex-direction: column;

&:not(.radioGroup_buttonVariant) :global(.checkbox-content) {
max-width: unset !important;
}
}

.radioGroup__label {
font-size: 1.2em;
font-weight: bold;
margin-inline-start: var(--border-radius-element);
}

.radioGroup__description {
color: var(--color-text-maxcontrast);
margin-block-end: var(--default-grid-baseline);
margin-inline-start: var(--border-radius-element);
}

.radioGroup__wrapper {
display: flex;
flex-direction: column;

> * {
flex: 1 0 1px;
}
}

.radioGroup__label + .radioGroup__wrapper {
// when there is no description we need to add some margin between wrapper and label
margin-block-start: var(--default-grid-baseline);
}

.radioGroup_buttonVariant .radioGroup__wrapper {
flex-direction: row;
gap: var(--default-grid-baseline);
.radioGroup_checkboxRadioContainer :global(.checkbox-content) {
max-width: unset !important;
}
</style>

Expand Down
15 changes: 4 additions & 11 deletions src/components/NcRadioGroupButton/NcRadioGroupButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Slot } from 'vue'

import { computed, onMounted } from 'vue'
import { createElementId } from '../../utils/createElementId.ts'
import { useNcFormBox } from '../NcFormBox/useNcFormBox.ts'
import { useInsideRadioGroup } from '../NcRadioGroup/useNcRadioGroup.ts'

const props = defineProps<{
Expand Down Expand Up @@ -36,6 +37,8 @@ defineSlots<{

const labelId = createElementId()
const radioGroup = useInsideRadioGroup()
const { formBoxItemClass } = useNcFormBox()

onMounted(() => radioGroup!.value.register(true))

const isChecked = computed(() => radioGroup?.value.modelValue === props.value)
Expand All @@ -52,7 +55,7 @@ function onUpdate() {
<div
:class="[{
[$style.radioGroupButton_active]: isChecked,
}, $style.radioGroupButton]"
}, $style.radioGroupButton, formBoxItemClass]"
@click="onUpdate">
<div v-if="$slots.icon" :class="$style.radioGroupButton__icon">
<slot name="icon" />
Expand Down Expand Up @@ -120,16 +123,6 @@ function onUpdate() {
border: var(--radio-group-button--border-width) solid var(--color-main-text) !important;
outline: calc(var(--default-grid-baseline) / 2) var(--color-main-background);
}

&:first-of-type {
border-start-start-radius: var(--border-radius-element);
border-end-start-radius: var(--border-radius-element);
}

&:last-of-type {
border-start-end-radius: var(--border-radius-element);
border-end-end-radius: var(--border-radius-element);
}
}

.radioGroupButton_active {
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export { default as NcDialogButton } from './NcDialogButton/index.ts'
export { default as NcEllipsisedOption } from './NcEllipsisedOption/index.js'
export { default as NcEmojiPicker } from './NcEmojiPicker/index.js'
export { default as NcEmptyContent } from './NcEmptyContent/index.ts'
export { default as NcFormBox } from './NcFormBox/index.ts'
export { default as NcFormGroup } from './NcFormGroup/index.ts'
export { default as NcGuestContent } from './NcGuestContent/index.ts'
export { default as NcHeaderButton } from './NcHeaderButton/index.ts'
Expand Down
Loading