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
27 changes: 27 additions & 0 deletions .github/contributing/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ Component documentation uses MDC (Markdown Components) syntax.

Docs live in `docs/content/docs/2.components/` with kebab-case naming (e.g., `button.md`).

## Checking a page renders

MDC fails at render time, not at lint time, so a malformed block is invisible
until something builds the site. After `pnpm dev:prepare`:

```bash
NUXT_PUBLIC_USE_AI=false \
NUXT_PUBLIC_SITE_URL=https://bitrix24.github.io \
NUXT_PUBLIC_BASE_URL=/b24ui \
NUXT_PUBLIC_CANONICAL_URL=https://bitrix24.github.io \
NUXT_PUBLIC_GIT_URL=https://github.com/bitrix24/b24ui \
pnpm docs:generate
```

A successful run ends with `Prerendered N routes`; find your page in the list.

The environment is not optional and its absence does not say so. Without those
variables the build dies during prerender with `ERROR completable is not
defined` — a `@nuxtjs/mcp-toolkit` symbol, nothing to do with your page, and
the same failure appears on a clean `main`. `deploy.yml` sets them at the job
level, which is why CI is green while a bare `pnpm docs:build` is not.

CI runs `pnpm docs:full:generate`, which is `pnpm build && pnpm docs:generate`.
The extra build is there because the deploy publishes the package too;
`docs/nuxt.config.ts` registers the module from `../src/module`, so the docs
themselves render from source.

## Basic Structure

```md
Expand Down
226 changes: 219 additions & 7 deletions docs/content/docs/2.components/form-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ translation:
</B24FormField>
```

`hint` and `description` take a slot the same way. The `required` asterisk is
drawn on the `<label>` element, so it survives a custom `#label` slot.
`hint`, `description`, `help` and `error` take a slot the same way — and, like
`#label` above, each wants its prop set alongside it. [Slots do not replace
their props](#slots-do-not-replace-their-props) is why. The `required` asterisk
is drawn on the `<label>` element, so it survives a custom `#label` slot.

::caution
`#error` and `#help` are not interchangeable with their props. The error block
renders whenever an `#error` slot exists — with or without an actual error —
and `help` is the `v-else` of that same branch, so supplying `#error`
permanently hides `help`. Use the `error` and `help` props unless you need
markup in the error itself.
`#error` goes further than the others: the error block renders whenever an
`#error` slot exists, with or without an actual error, and takes `help` with
it. [Error and help slots](#error-and-help-slots) has the working pattern.
::

### Description
Expand All @@ -150,6 +150,61 @@ slots:
:b24-input{placeholder="Enter your email" class="w-full"}
::

### Description slot

Use the `#description` slot when the description needs markup — a link to the
policy the field refers to, a piece of emphasis, an inline code sample. It
replaces the content of the same `<p>` the prop fills.

**Keep the prop.** The block renders from either the prop or the slot, but
`aria-describedby` is built from the props alone — so a slot with no prop
beside it is a description on screen that no screen reader is told about. See
[Slots do not replace their props](#slots-do-not-replace-their-props).

::component-code
---
prettier: true
props:
name: email
label: Email
description: We'll never share it.
slots:
description: |

We'll never share it. <a href="/docs/components/form/" class="underline">How we use your data</a>

default: |

<B24Input placeholder="Enter your email" class="w-full" />
---

#description
We'll never share it. [How we use your data](/docs/components/form/)

#default
:b24-input{placeholder="Enter your email" class="w-full"}
::

A link is safe here in a way it is not in `#label`: the description sits
outside the `<label>` element, so clicking it does not toggle the control.

The slot receives the `description` prop, so the wrapper decorates the string
rather than restating it — which matters when the text comes from a schema or
a translation, and is what keeps the prop and the visible text in step:

```vue
<B24FormField label="Email" name="email" description="We'll never share it.">
<template #description="{ description }">
{{ description }}
<B24Link to="/docs/components/form/" raw class="underline">
How we use your data
</B24Link>
</template>

<B24Input placeholder="Enter your email" class="w-full" />
</B24FormField>
```

### Hint

Use the `hint` prop to display a hint message next to the label.
Expand All @@ -171,6 +226,53 @@ slots:
:b24-input{placeholder="Enter your email"}
::

### Hint slot

Use the `#hint` slot when the hint carries state rather than a word — a
character count, a badge, an icon. It renders inside the label row, next to
the label itself.

Pass `hint` as well as the slot, for the same reason as `#description`: the
announcement follows the prop, not the slot.

::component-code
---
prettier: true
props:
name: bio
label: Bio
hint: 0 / 140
slots:
hint: |

<B24Badge label="0 / 140" size="xs" color="air-secondary-accent-1" />

default: |

<B24Input placeholder="Tell us about yourself" class="w-full" />
---

#hint
:b24-badge{label="0 / 140" size="xs" color="air-secondary-accent-1"}

#default
:b24-input{placeholder="Tell us about yourself" class="w-full"}
::

::note
The hint is a sibling of the `<label>`, not part of it, so its content does not
join the control's accessible name — unlike `#label`. With the `hint` prop set
it is announced through `aria-describedby` instead.
::

::caution
**A hint needs a label**, and failing that requirement is silent in both
directions. The hint renders inside the label row, and that row is only drawn
when `label` or `#label` is present — so a `hint` on a label-less field renders
nothing at all. The control still advertises `aria-describedby="…-hint"`,
pointing at an element that was never drawn.
::

### Help

Use the `help` prop to display a help message below the form control. When used together with the `error` prop, the `error` prop takes precedence.
Expand Down Expand Up @@ -215,6 +317,116 @@ slots:
:b24-input{placeholder="Enter your email" class="w-full"}
::

### Slots do not replace their props

Every one of the five blocks renders from `props.x || !!slots.x` — the prop or
the slot will do. The accessible wiring does not: `aria-describedby` is
assembled from the **props alone**, in `useFormField`, and never looks at what
was slotted.

The two halves disagree in both directions:

| what you pass | block on screen | named in `aria-describedby` |
|---|---|---|
| `description` prop | yes | yes |
| `#description` slot, no prop | **yes** | **no** |
| `hint` prop, no `label` | **no** | **yes** |

So the rule for all five slots is the same, and it is the shape `#label` has
had since it was documented: **set the prop, and use the slot for markup.**
The slot receives the prop, so decorating it costs one interpolation and keeps
the two in step.

::note
This is upstream `nuxt/ui` behaviour, not a divergence in this fork — the
component and the composable are line-for-line identical at our sync cursor.
Tracked in [#497](https://github.com/bitrix24/b24ui/issues/497).
::

### Error and help slots

`#error` is where the rule above bites hardest, because the error block has a
second condition on it:

```
v-if="props.error !== false && ((typeof error === 'string' && error) || !!slots.error)"
v-else-if="props.help || !!slots.help"
```

An `#error` slot alone satisfies that `v-if` **whether or not there is an
error**. So the block renders permanently, `help` — the `v-else-if` of the same
branch — never renders at all, and the control still reads
`aria-invalid="false"`. If `help` was set, `aria-describedby` still names it,
pointing at an element that is no longer in the document:

```vue
<!-- Don't: the message is always visible, `help` never is, and a screen
reader is told the field is valid. -->
<B24FormField label="Email" name="email" help="We'll only use it to sign you in.">
<template #error>
<WarningIcon class="size-4" /> Please enter a valid email address.
</template>

<B24Input placeholder="Enter your email" />
</B24FormField>
```

Bind `error` to the message — or to `false` when there is none — and use the
slot for markup only. Then the branch behaves exactly as it does with the prop:

```vue
<script setup lang="ts">
import WarningIcon from '@bitrix24/b24icons-vue/main/WarningIcon'

const value = ref('')
const message = computed<string | false>(() =>
value.value.includes('@') ? false : 'Please enter a valid email address.'
)
</script>

<template>
<B24FormField
label="Email"
name="email"
:error="message"
help="We'll only use it to sign you in."
>
<template #error="{ error }">
<WarningIcon
class="size-4"
aria-hidden="true"
/> {{ error }}
</template>

<B24Input
v-model="value"
placeholder="Enter your email"
/>
</B24FormField>
</template>
```

| `message` | error block | help block | `aria-invalid` | `aria-describedby` |
|---|---|---|---|---|
| `false` | — | shown | `false` | `…-help` |
| `'Please enter…'` | your markup | — | `true` | `…-error …-help` |

The second row still names `…-help` while the help block is not rendered —
`help` and `error` are mutually exclusive on screen but not in the attribute.
Same defect as the table above, and the same ticket.

::note
Inside a [Form](/docs/components/form/) you do not set `error` at all — the
Form sets it from the schema, and it is a string or `undefined`. `undefined` is
not `false`, so an `#error` slot still renders permanently there. Use `#error`
only on fields whose `error` you control, or use the prop and style the block
through `b24ui.error`.
::

`#help` follows the general rule: pass `help` alongside it, or the help text
appears with nothing describing it. It disappears whenever an error is
showing, by design.

### Error pattern

Use the `error-pattern` prop to match form errors with a regular expression. This is especially relevant for components with array values such as [InputTags](/docs/components/input-tags/), where errors include array indices in their name (e.g. `tags.0`).
Expand Down
73 changes: 60 additions & 13 deletions skills/b24-ui-nuxt/references/guidelines/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,23 @@ function onSubmit(event: FormSubmitEvent<Schema>) {
|---|---|
| `name` | Links to schema field for validation errors |
| `label` | Visible label text |
| `description` | Help text below the input |
| `hint` | Right-aligned hint text (e.g., "Optional") |
| `description` | Explanatory text under the label, above the control |
| `hint` | Right-aligned hint next to the label — **needs a `label`**, see below |
| `help` | Text under the control; hidden while an error is showing |
| `error` | Error text under the control; `false` disables the block entirely |
| `required` | Shows required indicator |
| `size` | Inherits to child input |

## Rich labels

`label`, `description` and `hint` each have a matching slot for when a string is
not enough. The slot replaces the content inside the `<label>`, not the element
itself, so the field's existing association is unchanged.
All five of `label`, `description`, `hint`, `help` and `error` have a matching
slot for when a string is not enough. `#label` replaces the content inside the
`<label>` element; the other four replace the content of their own block.

Anything in `#label` joins the control's accessible name — mark decorative
content `aria-hidden="true"`, and keep links and buttons out of it, since a
`<label>` toggles its control when activated.

`#error` and `#help` are the exception: the error block renders whenever an
`#error` slot exists, and `help` is the `v-else` of that branch, so supplying
`#error` hides `help` entirely. Prefer the props there.
`<label>` toggles its control when activated. A link inside `#description` is
fine: that block sits outside the `<label>`.

```vue
<B24FormField label="Email" name="email">
Expand All @@ -81,9 +80,57 @@ content `aria-hidden="true"`, and keep links and buttons out of it, since a
</B24FormField>
```

The slot receives the prop it replaces, so a wrapper can decorate the value
rather than restate it — which matters when the text comes from a schema or a
translation.
Each slot receives the prop it replaces, so a wrapper decorates the value
rather than restating it — which matters when the text comes from a schema or
a translation.

## Set the prop, use the slot for markup

Every block renders from `props.x || !!slots.x`. `aria-describedby` does not:
it is assembled from the **props alone**, in `useFormField`, and never looks at
what was slotted. The two disagree in both directions.

| what you pass | block on screen | named in `aria-describedby` |
|---|---|---|
| `description` prop | yes | yes |
| `#description` slot, no prop | **yes** | **no** |
| `hint` prop, no `label` | **no** | **yes** |

So always pass the prop alongside the slot. Upstream `nuxt/ui` behaves the same
way — the component and the composable are line-for-line identical at our sync
cursor — and it is tracked in b24ui#497.

`hint` needs one more thing: it renders inside the label row, and that row is
only drawn when there is a label. A `hint` on a field with no `label` renders
nothing at all, while still being named in `aria-describedby`.

`#error` goes further still. The error block renders whenever an `#error` slot
exists, with or without an error, and `help` is the `v-else-if` of that branch,
so supplying `#error` hides `help` entirely — while the control keeps
`aria-invalid="false"`.

Bind `error` to the message — or to `false` when there is none — and let the
slot supply markup only:

```vue
<B24FormField
label="Email"
name="email"
:error="message"
help="We'll only use it to sign you in."
>
<template #error="{ error }">
<WarningIcon class="size-4" aria-hidden="true" /> {{ error }}
</template>

<B24Input v-model="value" placeholder="Enter your email" />
</B24FormField>
```

Inside a `B24Form` the error comes from the schema and is a string or
`undefined` — and `undefined` is not `false`, so an `#error` slot still renders
permanently there. On form-driven fields, use the `error` prop and style the
block through `b24ui.error`.

## Field layout patterns

Expand Down
Loading