mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-03-22 11:08:10 +00:00
stable with basic suggestion
This commit is contained in:
@@ -114,6 +114,11 @@
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
}
|
||||
|
||||
&.tags .tagInput-dropdown {
|
||||
z-index : 201;
|
||||
max-width : 150px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
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(() => {
|
||||
handleChange(tagList.map((context)=>context.value));
|
||||
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 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 removeTag = (index) => {
|
||||
setTagList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const editTag = (index) => {
|
||||
setTagList((prevContext)=>{
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, editing: true };
|
||||
}
|
||||
return { ...context, editing: false };
|
||||
});
|
||||
});
|
||||
setTagList((prev) => prev.map((t, i) => ({ ...t, editing: i === index })));
|
||||
};
|
||||
|
||||
const renderReadTag = (context, index)=>{
|
||||
const suggestionOptions = tagSuggestionList.map((tag) => (
|
||||
<div
|
||||
className="item"
|
||||
key={`tag-${tag}`} // unique key
|
||||
value={tag}
|
||||
data={tag}
|
||||
title={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<li key={index}
|
||||
data-value={context.value}
|
||||
className='tag'
|
||||
onClick={()=>editTag(index)}>
|
||||
{context.value}
|
||||
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index);}}><i className='fa fa-times fa-fw'/></button>
|
||||
</li>
|
||||
<div className="field tags">
|
||||
{label && <label>{label}</label>}
|
||||
|
||||
<div className="value">
|
||||
<ul className="list">
|
||||
{tagList.map((t, i) =>
|
||||
t.editing ? (
|
||||
<input
|
||||
key={i}
|
||||
type="text"
|
||||
value={t.display}
|
||||
pattern="[-A-Za-z0-9&_.()]+"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setTagList((prev) =>
|
||||
prev.map((tag, idx) => (idx === i ? { ...tag, display: val } : tag)),
|
||||
);
|
||||
};
|
||||
|
||||
const renderWriteTag = (context, index)=>{
|
||||
return (
|
||||
<input type='text'
|
||||
key={index}
|
||||
defaultValue={context.value}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitTag(e.target.value, i);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='field'>
|
||||
<label>{props.label}</label>
|
||||
<div className='value'>
|
||||
<ul className='list'>
|
||||
{tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
|
||||
) : (
|
||||
<li key={i} className="tag" onClick={() => editTag(i)}>
|
||||
{t.display}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
}}>
|
||||
<i className="fa fa-times fa-fw" />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='value'
|
||||
placeholder={props.placeholder}
|
||||
value={tempInputText}
|
||||
onChange={(e)=>setTempInputText(e.target.value)}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })}
|
||||
<Combobox
|
||||
trigger="click"
|
||||
className="tagInput-dropdown"
|
||||
default=""
|
||||
placeholder={placeholder}
|
||||
options={suggestionOptions}
|
||||
autoSuggest={{
|
||||
suggestMethod: "startsWith",
|
||||
clearAutoSuggestOnClick: true,
|
||||
filterOn: ["value", "title"],
|
||||
}}
|
||||
onSelect={(value) => {
|
||||
submitTag(value, null);
|
||||
}}
|
||||
onEntry={(e) => {
|
||||
// Allow free typing + Enter
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitTag(e.target.value, null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
client/homebrew/editor/tagInput/tagSuggestionList.js
Normal file
1
client/homebrew/editor/tagInput/tagSuggestionList.js
Normal file
@@ -0,0 +1 @@
|
||||
export default ["D&D", "P2e", "VtM", "CoC"];
|
||||
Reference in New Issue
Block a user