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

@@ -1318,6 +1318,10 @@
"description": "CurrencyFrom names the sibling column of the model whose value is the\nmoney field's currency code; the admin follows that field's value.",
"type": "string"
},
"customTags": {
"description": "CustomTags is a taglist field's customTags as fields.yaml declares it;\nnil means the default, true. With false only the options are accepted.",
"type": "boolean"
},
"default": {
"$ref": "#/components/schemas/cabana.jsonScalar"
},
@@ -1417,7 +1421,7 @@
"type": "integer"
},
"mode": {
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime), the\npermissioneditor mode (radio or checkbox) or the taglist mode (string,\nthe default, or array).",
"type": "string"
},
"multiple": {
@@ -1475,6 +1479,10 @@
"description": "Scale is the most fraction digits a money value may carry, 2 to 4\n(default 4). A value with more digits is refused, never rounded.",
"type": "integer"
},
"separator": {
"description": "Separator joins the tags of a `type: taglist` field in string mode:\ncomma (the default) or space. Empty in array mode.",
"type": "string"
},
"size": {
"type": "string"
},

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). */

View File

@@ -0,0 +1,118 @@
// Taglist control (Phase 18.1 D-17), the component on its own. No
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import TaglistField from '../../src/components/form/fields/TaglistField.vue'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.taglist.add': { other: 'Add a tag' },
'backend::lang.taglist.remove': { other: 'Remove :tag' },
}
function taglistField(extra: Partial<FormField> = {}): FormField {
return {
name: 'tags',
type: 'taglist',
label: 'Tags',
mode: 'array',
options: [
{ value: 'alpha', label: 'alpha' },
{ value: 'beta', label: 'beta' },
],
...extra,
} as FormField
}
function mountField(props: Record<string, unknown> = {}) {
const field = { ...taglistField(), ...((props.field as Partial<FormField>) ?? {}) } as FormField
return mount(TaglistField, {
props: { modelValue: null, controlId: 'f-tags', ...props, field },
attachTo: document.body,
})
}
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
const chips = (wrapper: VueWrapper) => wrapper.findAll('[data-taglist-chip]').map((chip) => chip.text())
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
async function type(wrapper: VueWrapper, text: string): Promise<void> {
input(wrapper).element.value = text
await input(wrapper).trigger('input')
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('TaglistField', () => {
it('renders a loaded list as chips and does not re-emit it untouched', async () => {
const wrapper = mountField({ modelValue: ['a', 'b'] })
await nextTick()
expect(chips(wrapper)).toEqual(['a', 'b'])
expect(emitted(wrapper)).toEqual([])
expect(wrapper.find('datalist').findAll('option').map((o) => o.attributes('value'))).toEqual(['alpha', 'beta'])
expect(input(wrapper).attributes('placeholder')).toBe('Add a tag')
})
it('adds the typed tag on Enter and ignores a duplicate or empty text', async () => {
const wrapper = mountField({ modelValue: ['a', 'b'] })
await type(wrapper, ' c ')
await input(wrapper).trigger('keydown', { key: 'Enter' })
expect(emitted(wrapper)).toEqual([['a', 'b', 'c']])
expect(input(wrapper).element.value).toBe('')
await type(wrapper, 'a')
await input(wrapper).trigger('keydown', { key: 'Enter' })
await type(wrapper, ' ')
await input(wrapper).trigger('keydown', { key: 'Enter' })
expect(emitted(wrapper)).toEqual([['a', 'b', 'c']])
})
it('adds a tag on typing a comma', async () => {
const wrapper = mountField({ modelValue: [] })
await type(wrapper, 'x,')
expect(emitted(wrapper)).toEqual([['x']])
expect(input(wrapper).element.value).toBe('')
})
it('removes a chip through its labelled remove button', async () => {
const wrapper = mountField({ modelValue: ['a', 'b'] })
const remove = wrapper.find('[data-taglist-remove]')
expect(remove.attributes('aria-label')).toBe('Remove a')
await remove.trigger('click')
expect(emitted(wrapper)).toEqual([['b']])
expect(chips(wrapper)).toEqual(['b'])
})
it('replaces the chips when the value changes from outside', async () => {
const wrapper = mountField({ modelValue: ['a'] })
await wrapper.setProps({ modelValue: ['z', 'y'] })
expect(chips(wrapper)).toEqual(['z', 'y'])
})
it('renders read-only chips without an input or remove buttons', async () => {
const wrapper = mountField({ modelValue: ['a', 'b'], field: { readOnly: true } })
expect(wrapper.find('input').exists()).toBe(false)
expect(wrapper.find('[data-taglist-remove]').exists()).toBe(false)
expect(chips(wrapper)).toEqual(['a', 'b'])
const empty = mountField({ modelValue: [], field: { readOnly: true } })
expect(empty.find('[data-empty]').text()).toBe('—')
})
it('renders a tag holding markup as text', async () => {
const wrapper = mountField({ modelValue: ['<b>bold</b>'] })
expect(wrapper.find('b').exists()).toBe(false)
expect(chips(wrapper)).toEqual(['<b>bold</b>'])
})
})