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:
@@ -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`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user