feat(18.1-01): task 2 taglist string mode, customTags and all save sites

- string mode: split on the separator (comma or whitespace), normalised,
  stored joined by a comma or one space; null stores an empty string
- customTags false refuses a tag outside options (taglist.not_allowed)
- taglist checked and hydrated on relation forms and settings forms;
  settings bind taglist columns; required checked on the tags
- admin: TaglistField string mode and closed option lists, preview renders
  the tags read-only as a comma-separated list; rebuilt dist
- docs: Taglist section covers both modes, separator and customTags
This commit is contained in:
Jakub Zych
2026-10-14 15:52:52 +02:00
parent 349bad5596
commit cf63fb3152
17 changed files with 308 additions and 60 deletions

View File

@@ -8,7 +8,7 @@ import { isPluginField, rendererForField } from './registry'
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
// contrast and are read in order. Controls that already have a read-only
// mode (datepicker, fileupload, permissioneditor, money), partials and plugin
// mode (datepicker, fileupload, permissioneditor, money, taglist), partials and plugin
// field types (their element gets `disabled`) are rendered through their own
// component with the field marked read-only.
const props = defineProps<{
@@ -21,7 +21,7 @@ const props = defineProps<{
}>()
/** Types rendered by their own control in its read-only mode. */
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money'])
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money', 'taglist'])
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
const labelId = computed(() => `${controlId.value}-label`)

View File

@@ -6,14 +6,17 @@ import { controlAttributes, controlClass, type FieldControlProps } from '../cont
// 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.
// input that adds the trimmed text on Enter or on typing the separator (a
// comma; a space in string mode with separator space); empty text and a tag
// already in the list are ignored, and with customTags false so is a tag
// outside the field's options. The options are offered as suggestions
// through a datalist. In array mode the value is a list of strings; in
// string mode it is one string of tags joined by a comma or a single space.
// 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 emit = defineEmits<{ 'update:modelValue': [value: string[] | string] }>()
const readOnly = computed(() => {
const attribute = props.field.attributes?.readonly
@@ -22,13 +25,28 @@ const readOnly = computed(() => {
const attrs = computed(() => controlAttributes(props.field))
const listId = computed(() => `${props.controlId}-options`)
const suggestions = computed(() => (props.field.options ?? []).map((option) => option.label))
const arrayMode = computed(() => props.field.mode === 'array')
const spaced = computed(() => !arrayMode.value && props.field.separator === 'space')
/** The character typed to add a tag. */
const separatorChar = computed(() => (spaced.value ? ' ' : ','))
const closed = computed(() => props.field.customTags === false)
/** The tags of a stored value: a list of strings; anything else is no tags. */
/** The tags of a stored value: a list of strings, or a separator-joined string in string mode. */
function tagsOf(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
let items: unknown[] = []
if (Array.isArray(value)) {
items = value
} else if (typeof value === 'string' && !arrayMode.value) {
items = spaced.value ? value.split(/\s+/) : value.split(',')
}
return value.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
const out: string[] = []
for (const item of items) {
const tag = typeof item === 'string' ? item.trim() : ''
if (tag !== '' && !out.includes(tag)) {
out.push(tag)
}
}
return out
}
const tags = ref<string[]>(tagsOf(props.modelValue))
@@ -46,16 +64,16 @@ watch(
function commit(next: string[]): void {
tags.value = next
const value = [...next]
const value = arrayMode.value ? [...next] : next.join(spaced.value ? ' ' : ',')
lastEmitted = value
emit('update:modelValue', value)
}
/** Adds the draft's trimmed text as a tag unless it is empty or present. */
/** Adds the draft's trimmed text as a tag unless it is empty, present or not offered. */
function addDraft(): void {
const tag = draft.value.trim()
draft.value = ''
if (tag === '' || tags.value.includes(tag)) {
if (tag === '' || tags.value.includes(tag) || (closed.value && !suggestions.value.includes(tag))) {
return
}
commit([...tags.value, tag])
@@ -67,11 +85,11 @@ function remove(index: number): void {
function onInput(event: Event): void {
const text = (event.target as HTMLInputElement).value
if (!text.includes(',')) {
if (!text.includes(separatorChar.value)) {
draft.value = text
return
}
const parts = text.split(',')
const parts = text.split(separatorChar.value)
const rest = parts.pop() ?? ''
for (const part of parts) {
draft.value = part
@@ -99,13 +117,12 @@ function onKeydown(event: KeyboardEvent): void {
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 v-for="(tag, index) in tags" :key="tag">
<span v-if="index > 0" class="sr-only">, </span>
<span 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>
</template>
<span v-else class="text-muted" data-empty>—</span>
</div>