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
140 changes: 140 additions & 0 deletions src/components/NcFormGroup/NcFormGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<!--
- 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 { createElementId } from '../../utils/createElementId.ts'

const {
label = undefined,
description = undefined,
hideLabel = false,
hideDescription = false,
noGap = false,
} = defineProps<{
/**
* Group label #label slot can be used for custom label content
*/
label?: string
/**
* Optional fieldset description. #description slot can be used for custom description content
*/
description?: string
/**
* Hide the label visually but keep it accessible for screen readers
*/
hideLabel?: boolean
/**
* Hide the description visually but keep it accessible for screen readers
*/
hideDescription?: boolean
/**
* Disable default fieldset content gap between content elements
*/
noGap?: boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats the use case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General good practice for UI components — if a component has not only its own styles but also its position/content (its margin, slots styles) - it should be optional.

It could be a list of checkboxes without a gap, or some custom set of elements.

Alternative - ask to wrap content in a div.

}>()

const slots = defineSlots<{
/**
* Content
*/
default?: Slot
/**
* Custom label content
*/
label?: Slot
/**
* Custom description content
*/
description?: Slot
}>()

const id = `nc-form-group-${createElementId()}`
const labelId = `${id}-label`
const descriptionId = `${id}-description`

const hasDescription = () => !!description || !!slots.description
const getDescriptionId = () => hasDescription() ? descriptionId : undefined
</script>

<template>
<fieldset
:class="[$style.formGroup, { [$style.formGroup_noGap]: noGap }]"
:aria-describedby="getDescriptionId()">
<legend :id="labelId" :class="[$style.formGroup__label, { 'hidden-visually': hideLabel }]">
<slot name="label">
{{ label || '⚠️ Missing label' }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to always have a label? I can imagine a use-case like having a Fieldset inside a Modal which already has a title. I feel something might eventually come up where you would want to just not have a label, and having it show "Missing label" would be uncomfortable.

If we want to always have a standard label as a design decision though, I could understand it.

Copy link
Member

@kra-mo kra-mo Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to ask again:

Just so I understand: where would the FormGroup sit in the tree relative to individual fields and the Fieldset itself?

But from what I understand right now, from a design POV, there is definitely a case where there is no label, including in the mockups.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to always have a label?

If its a fieldset we must. Its obligatory for wcag. If its only visually then its the form box not a real group.

</slot>
</legend>
<div v-if="hasDescription()" :id="descriptionId" :class="[$style.formGroup__description, { 'hidden-visually': hideDescription }]">
<slot name="description">
{{ description }}
</slot>
</div>
<div :class="$style.formGroup__content">
<slot />
</div>
</fieldset>
</template>

<style lang="scss" module>
.formGroup {
--form-element-label-offset: calc(var(--border-radius-element) + var(--default-grid-baseline));
--form-group-content-gap: calc(2 * var(--default-grid-baseline));

&.formGroup_noGap {
--form-group-content-gap: 0;
}
}

.formGroup__label {
padding-inline: var(--form-element-label-offset);
font-size: var(--font-size);
font-weight: bold;
}

.formGroup__description {
padding-inline: var(--form-element-label-offset);
color: var(--color-text-maxcontrast);
}

.formGroup__content {
display: flex;
flex-direction: column;
gap: var(--form-group-content-gap);
margin-block-start: calc(4 * var(--default-grid-baseline));

&:first-child {
margin-block-start: 0;
}
}
</style>

<docs>
### General

Labelled group of form elements.

```vue
<template>
<NcFormGroup label="Personal information">
<NcTextField label="First name" />
<NcTextField label="Last name" />
</NcFormGroup>
</template>
```

### With description

```vue
<template>
<NcFormGroup label="Personal information" description="Your contact details">
<NcTextField label="First name" />
<NcTextField label="Last name" />
</NcFormGroup>
</template>
```
</docs>
6 changes: 6 additions & 0 deletions src/components/NcFormGroup/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 './NcFormGroup.vue'
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 NcFormGroup } from './NcFormGroup/index.ts'
export { default as NcGuestContent } from './NcGuestContent/index.ts'
export { default as NcHeaderButton } from './NcHeaderButton/index.ts'
export { default as NcHeaderMenu } from './NcHeaderMenu/index.ts'
Expand Down
Loading