diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.less b/client/homebrew/editor/metadataEditor/metadataEditor.less index fd04f07d9..df915a546 100644 --- a/client/homebrew/editor/metadataEditor/metadataEditor.less +++ b/client/homebrew/editor/metadataEditor/metadataEditor.less @@ -114,6 +114,11 @@ z-index : 200; max-width : 150px; } + + &.tags .tagInput-dropdown { + z-index : 201; + max-width : 150px; + } } diff --git a/client/homebrew/editor/tagInput/tagInput.jsx b/client/homebrew/editor/tagInput/tagInput.jsx index 8ed006283..eb5912f23 100644 --- a/client/homebrew/editor/tagInput/tagInput.jsx +++ b/client/homebrew/editor/tagInput/tagInput.jsx @@ -1,100 +1,161 @@ -import './tagInput.less'; -import React, { useState, useEffect } from 'react'; -import _ from 'lodash'; +import "./tagInput.less"; +import React, { useState, useEffect, useMemo } from "react"; +import Combobox from "../../../components/combobox.jsx"; -const TagInput = ({ unique = true, values = [], ...props })=>{ - const [tempInputText, setTempInputText] = useState(''); - const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false }))); +import tagSuggestionList from "./tagSuggestionList.js"; - useEffect(()=>{ - handleChange(tagList.map((context)=>context.value)); +const TagInput = ({ label, unique = true, values = [], placeholder = "", onChange }) => { + const [tagList, setTagList] = useState( + values.map((value) => ({ + value, + display: value.trim(), + editing: false, + })), + ); + + // Keep in sync if parent updates values + useEffect(() => { + const incoming = values || []; + const current = tagList.map((t) => t.value); + + const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]); + + if (changed) { + setTagList( + incoming.map((value) => ({ + value, + display: value.trim(), + editing: false, + })), + ); + } + }, [values]); + + // Emit changes upward + useEffect(() => { + onChange?.({ + target: { value: tagList.map((t) => t.value) }, + }); }, [tagList]); - const handleChange = (value)=>{ - props.onChange({ - target : { value } + // Canonical duplicate groups + const duplicateGroups = [ + ["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"], + ["P2e", "p2e", "P2E", "Pathfinder 2e"], + ]; + + const normalizeValue = (input) => { + const group = duplicateGroups.find((grp) => grp.some((tag) => tag.toLowerCase() === input.toLowerCase())); + return group ? group[0] : input; + }; + + const regexPattern = /^[-A-Za-z0-9&_.()]+$/; + + const submitTag = (newValue, index = null) => { + const trimmed = newValue?.trim(); + if (!trimmed) return; + if (!regexPattern.test(trimmed)) return; + + const canonical = normalizeValue(trimmed); + + setTagList((prev) => { + const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === canonical.toLowerCase()); + + if (unique && existsIndex !== -1) return prev; + + if (index !== null) { + return prev.map((t, i) => + i === index ? { ...t, value: canonical, display: canonical, editing: false } : t, + ); + } + + return [...prev, { value: canonical, display: canonical, editing: false }]; }); }; - const handleInputKeyDown = ({ evt, value, index, options = {} })=>{ - if(_.includes(['Enter', ','], evt.key)) { - evt.preventDefault(); - submitTag(evt.target.value, value, index); - if(options.clear) { - setTempInputText(''); - } - } + const removeTag = (index) => { + setTagList((prev) => prev.filter((_, i) => i !== index)); }; - const submitTag = (newValue, originalValue, index)=>{ - setTagList((prevContext)=>{ - // remove existing tag - if(newValue === null){ - return [...prevContext].filter((context, i)=>i !== index); - } - // add new tag - if(originalValue === null){ - return [...prevContext, { value: newValue, editing: false }]; - } - // update existing tag - return prevContext.map((context, i)=>{ - if(i === index) { - return { ...context, value: newValue, editing: false }; - } - return context; - }); - }); + const editTag = (index) => { + setTagList((prev) => prev.map((t, i) => ({ ...t, editing: i === index }))); }; - const editTag = (index)=>{ - setTagList((prevContext)=>{ - return prevContext.map((context, i)=>{ - if(i === index) { - return { ...context, editing: true }; - } - return { ...context, editing: false }; - }); - }); - }; - - const renderReadTag = (context, index)=>{ - return ( -
  • editTag(index)}> - {context.value} - -
  • - ); - }; - - const renderWriteTag = (context, index)=>{ - return ( - handleInputKeyDown({ evt, value: context.value, index: index })} - autoFocus - /> - ); - }; + const suggestionOptions = tagSuggestionList.map((tag) => ( +
    + {tag} +
    + )); return ( -
    - -
    -
      - {tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })} +
      + {label && } + +
      +
        + {tagList.map((t, i) => + t.editing ? ( + { + const val = e.target.value; + setTagList((prev) => + prev.map((tag, idx) => (idx === i ? { ...tag, display: val } : tag)), + ); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitTag(e.target.value, i); + } + }} + autoFocus + /> + ) : ( +
      • editTag(i)}> + {t.display} + +
      • + ), + )}
      - setTempInputText(e.target.value)} - onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })} + { + submitTag(value, null); + }} + onEntry={(e) => { + // Allow free typing + Enter + if (e.key === "Enter") { + e.preventDefault(); + submitTag(e.target.value, null); + } + }} />
      diff --git a/client/homebrew/editor/tagInput/tagSuggestionList.js b/client/homebrew/editor/tagInput/tagSuggestionList.js new file mode 100644 index 000000000..ec0ff4f8e --- /dev/null +++ b/client/homebrew/editor/tagInput/tagSuggestionList.js @@ -0,0 +1 @@ +export default ["D&D", "P2e", "VtM", "CoC"];