Newer
Older
gnexus-ui-kit / src / vue / components / GnTagInput.js
import { defineComponent, h, ref, computed } from "vue";
import { cx } from "../utils.js";

export default defineComponent({
	name: "GnTagInput",
	inheritAttrs: false,
	props: {
		modelValue: { type: Array, default: () => [] },
		label: { type: String, default: "" },
		placeholder: { type: String, default: "Add item…" },
		help: { type: String, default: "" },
		disabled: { type: Boolean, default: false },
		separator: { type: String, default: "," },
		unique: { type: Boolean, default: true },
		maxItems: { type: Number, default: 0 }
	},
	emits: ["update:modelValue", "add", "remove"],
	setup(props, { emit, attrs, slots }) {
		const inputRef = ref(null);
		const focused = ref(false);
		const rawValue = ref("");

		const canAdd = computed(() => {
			if(props.disabled) return false;
			if(props.maxItems > 0 && props.modelValue.length >= props.maxItems) return false;
			return true;
		});

		function normalizeText(text) {
			return text.trim().replace(/\s+/g, " ");
		}

		function addValue(text) {
			const value = normalizeText(text);
			if(!value) return;
			if(props.unique && props.modelValue.includes(value)) return;
			if(props.maxItems > 0 && props.modelValue.length >= props.maxItems) return;

			const next = [...props.modelValue, value];
			emit("update:modelValue", next);
			emit("add", value);
		}

		function removeValue(index) {
			const removed = props.modelValue[index];
			const next = props.modelValue.filter((_, i) => i !== index);
			emit("update:modelValue", next);
			emit("remove", removed);
		}

		function onKeydown(event) {
			if(event.key === "Enter") {
				event.preventDefault();
				if(rawValue.value) {
					const parts = props.separator
						? rawValue.value.split(props.separator).map(s => s.trim()).filter(Boolean)
						: [rawValue.value];
					parts.forEach(addValue);
					rawValue.value = "";
				}
				return;
			}

			if(event.key === "Backspace" && !rawValue.value && props.modelValue.length > 0) {
				removeValue(props.modelValue.length - 1);
				return;
			}
		}

		function onPaste(event) {
			const paste = event.clipboardData.getData("text");
			if(!paste || !props.separator) return;
			event.preventDefault();
			const parts = paste.split(props.separator).map(s => s.trim()).filter(Boolean);
			const appended = [];
			for(const part of parts) {
				const v = normalizeText(part);
				if(!v) continue;
				if(props.unique && props.modelValue.includes(v)) continue;
				if(props.maxItems > 0 && props.modelValue.length + appended.length >= props.maxItems) break;
				appended.push(v);
			}
			if(appended.length) {
				const next = [...props.modelValue, ...appended];
				emit("update:modelValue", next);
				appended.forEach(v => emit("add", v));
			}
		}

		function onWrapClick() {
			inputRef.value?.focus();
		}

		return () => {
			const labelNode = props.label || slots.label
				? h("label", { class: "label" }, [
					slots.label?.() || props.label,
					props.disabled && h("span", { class: "label-disabled-hint" }, "Disabled")
				])
				: null;

			const chips = props.modelValue.map((item, index) =>
				h("span", {
					class: cx("chip", "chip-secondary"),
					key: `${item}-${index}`
				}, [
					item,
					!props.disabled && h("button", {
						class: "chip-remove",
						type: "button",
						"aria-label": `Remove ${item}`,
						onClick: (e) => {
							e.stopPropagation();
							removeValue(index);
						}
					}, [h("i", { class: "ph ph-x" })])
				])
			);

			const field = h("input", {
				ref: inputRef,
				class: "tag-input-field",
				type: "text",
				placeholder: canAdd.value ? props.placeholder : "",
				value: rawValue.value,
				disabled: props.disabled,
				onInput: (e) => { rawValue.value = e.target.value; },
				onKeydown,
				onPaste,
				onFocus: () => focused.value = true,
				onBlur: () => focused.value = false
			});

			const wrap = h("div", {
				class: cx("tag-input-wrap"),
				onClick: onWrapClick
			}, [
				...chips,
				field
			]);

			const meta = props.help
				? h("div", { class: "input-info" }, [
					h("i", { class: "ph ph-info" }),
					" " + props.help
				])
				: null;

			return h("div", {
				...attrs,
				class: cx("tag-input", {
					"tag-input-focused": focused.value,
					"tag-input-disabled": props.disabled
				}, attrs.class)
			}, [
				labelNode,
				wrap,
				meta
			]);
		};
	}
});