feat(18.1-01): task 1 taglist field in array mode end to end

- cabana: core type taglist (mode string|array, separator, customTags,
  options as a list of tags), column type gate, save-time normalisation
  (trim, drop empties, dedupe) and taglist.invalid 422, record hydration
- FormField gains separator and customTags (omitempty); OpenAPI and
  schema.d.ts regenerated
- admin: TaglistField chip input with suggestions, registry entry, vitest
- acme.catalog fixture with TestTaglistFieldCompile and TestTaglistFieldSmoke
- docs: forms.md Taglist section, cabana README; rebuilt dist
This commit is contained in:
Jakub Zych
2026-10-14 15:47:30 +02:00
parent 43d2fdd54a
commit 349bad5596
30 changed files with 1089 additions and 29 deletions

View File

@@ -5608,6 +5608,11 @@ export interface components {
* money field's currency code; the admin follows that field's value.
*/
currencyFrom?: string;
/**
* @description CustomTags is a taglist field's customTags as fields.yaml declares it;
* nil means the default, true. With false only the options are accepted.
*/
customTags?: boolean;
default?: components["schemas"]["cabana.jsonScalar"];
/**
* @description Deferrable is true on a relation-manager field whose relation can be
@@ -5675,8 +5680,9 @@ export interface components {
minItems?: number;
/**
* @description Mode is the fileupload mode (image or file, default file), the
* datepicker mode (date, datetime or time, default datetime) or the
* permissioneditor mode (radio or checkbox).
* datepicker mode (date, datetime or time, default datetime), the
* permissioneditor mode (radio or checkbox) or the taglist mode (string,
* the default, or array).
*/
mode?: string;
multiple?: boolean;
@@ -5716,6 +5722,11 @@ export interface components {
* (default 4). A value with more digits is refused, never rounded.
*/
scale?: number;
/**
* @description Separator joins the tags of a `type: taglist` field in string mode:
* comma (the default) or space. Empty in array mode.
*/
separator?: string;
size?: string;
span?: string;
tab?: string;

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { X } from '@lucide/vue'
import { t } from '../../../app/i18n'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
// Tag list control for `type: taglist` (Phase 18.1 D-17, WinterCMS TagList).
// The tags render as chips, each with a remove button, followed by a text
// input that adds the trimmed text on Enter or on typing a comma; empty
// text and a tag already in the list are ignored. The field's options are
// offered as suggestions through a datalist. In array mode the value is a
// list of strings. A value set from outside (a load, a reset after save)
// replaces the chips; the control's own emits do not, so an untouched value
// is never re-emitted. Tags render through text interpolation only.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const readOnly = computed(() => {
const attribute = props.field.attributes?.readonly
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
})
const attrs = computed(() => controlAttributes(props.field))
const listId = computed(() => `${props.controlId}-options`)
const suggestions = computed(() => (props.field.options ?? []).map((option) => option.label))
/** The tags of a stored value: a list of strings; anything else is no tags. */
function tagsOf(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
}
const tags = ref<string[]>(tagsOf(props.modelValue))
const draft = ref('')
let lastEmitted: unknown
watch(
() => props.modelValue,
(value) => {
if (value !== lastEmitted) {
tags.value = tagsOf(value)
}
},
)
function commit(next: string[]): void {
tags.value = next
const value = [...next]
lastEmitted = value
emit('update:modelValue', value)
}
/** Adds the draft's trimmed text as a tag unless it is empty or present. */
function addDraft(): void {
const tag = draft.value.trim()
draft.value = ''
if (tag === '' || tags.value.includes(tag)) {
return
}
commit([...tags.value, tag])
}
function remove(index: number): void {
commit(tags.value.filter((_, i) => i !== index))
}
function onInput(event: Event): void {
const text = (event.target as HTMLInputElement).value
if (!text.includes(',')) {
draft.value = text
return
}
const parts = text.split(',')
const rest = parts.pop() ?? ''
for (const part of parts) {
draft.value = part
addDraft()
}
draft.value = rest
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault()
addDraft()
} else if (event.key === 'Backspace' && draft.value === '' && tags.value.length > 0) {
remove(tags.value.length - 1)
}
}
</script>
<template>
<div
v-if="readOnly"
:id="controlId"
data-taglist-readonly
:aria-describedby="describedBy || undefined"
class="flex min-h-input flex-wrap items-center gap-1.5 rounded-control border border-border bg-subtle px-3.5 py-2"
>
<template v-if="tags.length > 0">
<span
v-for="tag in tags"
:key="tag"
data-taglist-chip
class="inline-flex items-center rounded-full bg-surface px-2.5 py-0.5 text-[13px] text-text"
>{{ tag }}</span
>
</template>
<span v-else class="text-muted" data-empty>—</span>
</div>
<div
v-else
:class="controlClass(invalid)"
class="flex min-h-input flex-wrap items-center gap-1.5 py-1.5 focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]"
data-taglist
>
<span
v-for="(tag, index) in tags"
:key="tag"
data-taglist-chip
class="inline-flex items-center gap-1 rounded-full bg-subtle py-0.5 pr-1 pl-2.5 text-[13px] text-text"
>
<span>{{ tag }}</span>
<button
type="button"
data-taglist-remove
:aria-label="t('backend::lang.taglist.remove', { tag })"
class="inline-flex size-5 items-center justify-center rounded-full text-muted hover:bg-border hover:text-text"
@click="remove(index)"
>
<X :size="12" aria-hidden="true" />
</button>
</span>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
autocomplete="off"
:list="suggestions.length > 0 ? listId : undefined"
:placeholder="t('backend::lang.taglist.add')"
:value="draft"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
class="min-w-[8rem] flex-1 bg-transparent py-1 outline-none focus-visible:shadow-none"
@input="onInput"
@keydown="onKeydown"
@blur="addDraft"
/>
<datalist v-if="suggestions.length > 0" :id="listId">
<option v-for="option in suggestions" :key="option" :value="option" />
</datalist>
</div>
</template>

View File

@@ -30,6 +30,9 @@
// module always evaluates first and the binding is complete here.
// Phase 18 adds the money control (D-07): an exact decimal string value
// with a currency suffix, formatted by locale only for reading.
// Phase 18.1 adds the taglist control (D-17): tags edited as chips with an
// input that adds a tag on Enter or a comma, the field's options offered as
// suggestions; the value is a list of strings (array mode).
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
@@ -51,6 +54,7 @@ import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import MarkdownField from './fields/MarkdownField.vue'
import MoneyField from './fields/MoneyField.vue'
import TaglistField from './fields/TaglistField.vue'
import MLTextField from './fields/MLTextField.vue'
import MLTextareaField from './fields/MLTextareaField.vue'
import MLMarkdownField from './fields/MLMarkdownField.vue'
@@ -93,6 +97,7 @@ const renderers = new Map<string, Component>([
['richeditor', RichEditorField],
['repeater', RepeaterField],
['money', MoneyField],
['taglist', TaglistField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */