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:
15
admin/src/api/schema.d.ts
vendored
15
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
156
admin/src/components/form/fields/TaglistField.vue
Normal file
156
admin/src/components/form/fields/TaglistField.vue
Normal 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>
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user