0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-03-22 08:58:11 +00:00

Merge pull request #4643 from naturalcrit/refactor-tag-system

proper tooltips
This commit is contained in:
Víctor Losada Hernández
2026-02-24 12:35:35 +01:00
committed by GitHub
11 changed files with 275 additions and 232 deletions

View File

@@ -11,6 +11,7 @@ const Combobox = createReactClass({
trigger : 'hover', trigger : 'hover',
default : '', default : '',
placeholder : '', placeholder : '',
tooltip: '',
autoSuggest : { autoSuggest : {
clearAutoSuggestOnClick : true, clearAutoSuggestOnClick : true,
suggestMethod : 'includes', suggestMethod : 'includes',
@@ -70,11 +71,13 @@ const Combobox = createReactClass({
return ( return (
<div className='dropdown-input item' <div className='dropdown-input item'
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined} onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}> onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
<input <input
type='text' type='text'
onChange={(e)=>this.handleInput(e)} onChange={(e)=>this.handleInput(e)}
value={this.state.value || ''} value={this.state.value || ''}
title=''
pattern={this.props.valuePatterns} pattern={this.props.valuePatterns}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
onBlur={(e)=>{ onBlur={(e)=>{

View File

@@ -10,6 +10,7 @@
position : absolute; position : absolute;
z-index : 100; z-index : 100;
width : 100%; width : 100%;
height : max-content;
max-height : 200px; max-height : 200px;
overflow-y : auto; overflow-y : auto;
background-color : white; background-color : white;

View File

@@ -99,18 +99,18 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
return ( return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'> <div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<div className='toggleButton'> <div className='toggleButton'>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{ <button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
setToolsVisible(!toolsVisible); setToolsVisible(!toolsVisible);
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible); localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
}}><i className='fas fa-glasses' /></button> }}><i className='fas fa-glasses' /></button>
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button> <button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
</div> </div>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/} {/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}> <div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
<button <button
id='fill-width' id='fill-width'
className='tool' className='tool'
title='Set zoom to fill preview with one page' data-tooltip-bottom='Set zoom to fill preview with one page'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))} onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
> >
<i className='fac fit-width' /> <i className='fac fit-width' />
@@ -118,7 +118,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<button <button
id='zoom-to-fit' id='zoom-to-fit'
className='tool' className='tool'
title='Set zoom to fit entire page in preview' data-tooltip-bottom='Set zoom to fit entire page in preview'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))} onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
> >
<i className='fac zoom-to-fit' /> <i className='fac zoom-to-fit' />
@@ -128,7 +128,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='tool' className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)} onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM} disabled={displayOptions.zoomLevel <= MIN_ZOOM}
title='Zoom Out' data-tooltip-bottom='Zoom Out'
> >
<i className='fas fa-magnifying-glass-minus' /> <i className='fas fa-magnifying-glass-minus' />
</button> </button>
@@ -137,7 +137,6 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='range-input tool hover-tooltip' className='range-input tool hover-tooltip'
type='range' type='range'
name='zoom' name='zoom'
title='Set Zoom'
list='zoomLevels' list='zoomLevels'
min={MIN_ZOOM} min={MIN_ZOOM}
max={MAX_ZOOM} max={MAX_ZOOM}
@@ -154,7 +153,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='tool' className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)} onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM} disabled={displayOptions.zoomLevel >= MAX_ZOOM}
title='Zoom In' data-tooltip-bottom='Zoom In'
> >
<i className='fas fa-magnifying-glass-plus' /> <i className='fas fa-magnifying-glass-plus' />
</button> </button>
@@ -166,44 +165,44 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<button role='radio' <button role='radio'
id='single-spread' id='single-spread'
className='tool' className='tool'
title='Single Page' data-tooltip-bottom='Single Page'
onClick={()=>{handleOptionChange('spread', 'single');}} onClick={()=>{handleOptionChange('spread', 'single');}}
aria-checked={displayOptions.spread === 'single'} aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button> ><i className='fac single-spread' /></button>
<button role='radio' <button role='radio'
id='facing-spread' id='facing-spread'
className='tool' className='tool'
title='Facing Pages' data-tooltip-bottom='Facing Pages'
onClick={()=>{handleOptionChange('spread', 'facing');}} onClick={()=>{handleOptionChange('spread', 'facing');}}
aria-checked={displayOptions.spread === 'facing'} aria-checked={displayOptions.spread === 'facing'}
><i className='fac facing-spread' /></button> ><i className='fac facing-spread' /></button>
<button role='radio' <button role='radio'
id='flow-spread' id='flow-spread'
className='tool' className='tool'
title='Flow Pages' data-tooltip-bottom='Flow Pages'
onClick={()=>{handleOptionChange('spread', 'flow');}} onClick={()=>{handleOptionChange('spread', 'flow');}}
aria-checked={displayOptions.spread === 'flow'} aria-checked={displayOptions.spread === 'flow'}
><i className='fac flow-spread' /></button> ><i className='fac flow-spread' /></button>
</div> </div>
<Anchored> <Anchored>
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger> <AnchoredTrigger id='spread-settings' className='tool' data-tooltip-bottom='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox title='Options'> <AnchoredBox>
<h1>Options</h1> <h1>Options</h1>
<label title='Modify the horizontal space between pages.'> <label data-tooltip-left='Modify the horizontal space between pages.'>
Column gap Column gap
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} /> <input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
</label> </label>
<label title='Modify the vertical space between rows of pages.'> <label data-tooltip-left='Modify the vertical space between rows of pages.'>
Row gap Row gap
<input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} /> <input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
</label> </label>
<label title='Start 1st page on the right side, such as if you have cover page.'> <label data-tooltip-left='Start 1st page on the right side, such as if you have cover page.'>
Start on right Start on right
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}} <input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} /> data-tooltip-right={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
</label> </label>
<label title='Toggle the page shadow on every page.'> <label data-tooltip-left='Toggle the page shadow on every page.'>
Page shadows Page shadows
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} /> <input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
</label> </label>
@@ -217,7 +216,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
id='previous-page' id='previous-page'
className='previousPage tool' className='previousPage tool'
type='button' type='button'
title='Previous Page(s)' data-tooltip-bottom='Previous Page(s)'
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)} onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
disabled={visiblePages.includes(1)} disabled={visiblePages.includes(1)}
> >
@@ -230,7 +229,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='text-input' className='text-input'
type='text' type='text'
name='page' name='page'
title='Current page(s) in view' data-tooltip-bottom='Current page(s) in view'
inputMode='numeric' inputMode='numeric'
pattern='[0-9]' pattern='[0-9]'
value={pageNum} value={pageNum}
@@ -240,14 +239,14 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)} onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
style={{ width: `${pageNum.length}ch` }} style={{ width: `${pageNum.length}ch` }}
/> />
<span id='page-count' title='Total Page Count'>/ {totalPages}</span> <span id='page-count' data-tooltip-bottom='Total Page Count'>/ {totalPages}</span>
</div> </div>
<button <button
id='next-page' id='next-page'
className='tool' className='tool'
type='button' type='button'
title='Next Page(s)' data-tooltip-bottom='Next Page(s)'
onClick={()=>scrollToPage(_.max(visiblePages) + 1)} onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
disabled={visiblePages.includes(totalPages)} disabled={visiblePages.includes(totalPages)}
> >

View File

@@ -166,7 +166,7 @@
&.hidden { &.hidden {
flex-wrap : nowrap; flex-wrap : nowrap;
width : 92px; width : 50%;
overflow : hidden; overflow : hidden;
background-color : unset; background-color : unset;
opacity : 0.7; opacity : 0.7;

View File

@@ -4,6 +4,7 @@
width : 100%; width : 100%;
height : 100%; height : 100%;
container : editor / inline-size; container : editor / inline-size;
background:white;
.codeEditor { .codeEditor {
height : calc(100% - 25px); height : calc(100% - 25px);
.CodeMirror { height : 100%; } .CodeMirror { height : 100%; }

View File

@@ -213,7 +213,7 @@ const MetadataEditor = createReactClass({
</div>; </div>;
} else { } else {
dropdown = dropdown =
<div className='value'> <div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
<Combobox trigger='click' <Combobox trigger='click'
className='themes-dropdown' className='themes-dropdown'
default={currentThemeDisplay} default={currentThemeDisplay}
@@ -231,7 +231,6 @@ const MetadataEditor = createReactClass({
filterOn : ['value', 'title'] filterOn : ['value', 'title']
}} }}
/> />
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
</div>; </div>;
} }
@@ -256,7 +255,7 @@ const MetadataEditor = createReactClass({
return <div className='field language'> return <div className='field language'>
<label>language</label> <label>language</label>
<div className='value'> <div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
<Combobox trigger='click' <Combobox trigger='click'
className='language-dropdown' className='language-dropdown'
default={this.props.metadata.lang || ''} default={this.props.metadata.lang || ''}
@@ -273,7 +272,6 @@ const MetadataEditor = createReactClass({
filterOn : ['value', 'detail', 'title'] filterOn : ['value', 'detail', 'title']
}} }}
/> />
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
</div> </div>
</div>; </div>;
@@ -339,14 +337,20 @@ const MetadataEditor = createReactClass({
{this.renderThumbnail()} {this.renderThumbnail()}
</div> </div>
<TagInput <div className="field tags">
label='tags' <label>Tags</label>
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/} <div className="value" >
placeholder='add tag' unique={true} <TagInput
values={this.props.metadata.tags} label='tags'
smallText='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.' valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
onChange={(e)=>this.handleFieldChange('tags', e)} placeholder='add tag' unique={true}
/> values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)}
tooltip='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.'
/>
</div>
</div>
{this.renderLanguageDropdown()} {this.renderLanguageDropdown()}
@@ -358,15 +362,22 @@ const MetadataEditor = createReactClass({
{this.renderAuthors()} {this.renderAuthors()}
<TagInput <div className="field invitedAuthors">
label='invited authors' <label>Invited authors</label>
valuePatterns={/.+/} <div className="value">
validators={[(v)=>!this.props.metadata.authors?.includes(v)]} <TagInput
placeholder='invite author' unique={true} label='invited authors'
values={this.props.metadata.invitedAuthors} valuePatterns={/.+/}
smallText='Invited author usernames are case sensitive. After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.' validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)} placeholder='invite author' unique={true}
/> tooltip={`Invited author usernames are case sensitive.
After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.`}
values={this.props.metadata.invitedAuthors}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/>
</div>
</div>
<h2>Privacy</h2> <h2>Privacy</h2>

View File

@@ -44,8 +44,6 @@
gap : 10px; gap : 10px;
} }
.field { .field {
position : relative; position : relative;
display : flex; display : flex;
@@ -62,6 +60,7 @@
& > .value { & > .value {
flex : 1 1 auto; flex : 1 1 auto;
width : 50px; width : 50px;
&[data-tooltip-right] { max-width : 380px; }
&:invalid { background : #FFB9B9; } &:invalid { background : #FFB9B9; }
small { small {
display : block; display : block;
@@ -74,6 +73,16 @@
border : 1px solid gray; border : 1px solid gray;
&:focus { outline : 1px solid #444444; } &:focus { outline : 1px solid #444444; }
} }
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.thumbnail, &.themes { &.thumbnail, &.themes {
label { line-height : 2.0em; } label { line-height : 2.0em; }
.value { .value {
@@ -90,6 +99,15 @@
} }
} }
&.tags .tagInput-dropdown {
z-index : 400;
max-width : 200px;
}
&.language .value {
z-index : 300;
max-width : 150px;
}
&.themes { &.themes {
.value { .value {
overflow : visible; overflow : visible;
@@ -101,27 +119,13 @@
} }
} }
&.description { &.invitedAuthors .value {
flex : 1; z-index : 100;
textarea.value {
height : auto; .tagInput-dropdown { max-width : 200px; }
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.language .language-dropdown {
z-index : 200;
max-width : 150px;
}
&.tags .tagInput-dropdown {
z-index : 201;
max-width : 200px;
} }
} }
.thumbnail-preview { .thumbnail-preview {
position : relative; position : relative;
flex : 1 1; flex : 1 1;
@@ -174,7 +178,7 @@
.themes.field { .themes.field {
& .dropdown-container { & .dropdown-container {
position : relative; position : relative;
z-index : 100; z-index : 200;
background-color : white; background-color : white;
} }
& .dropdown-options { overflow-y : visible; } & .dropdown-options { overflow-y : visible; }

View File

@@ -1,71 +1,71 @@
import './tagInput.less'; import "./tagInput.less";
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import Combobox from '../../../components/combobox.jsx'; import Combobox from "../../../components/combobox.jsx";
import tagSuggestionList from './curatedTagSuggestionList.js'; import tagSuggestionList from "./curatedTagSuggestionList.js";
const TagInput = ({ label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, placeholder = "", smallText = "", onChange }) => {
const [tagList, setTagList] = useState( const [tagList, setTagList] = useState(
values.map((value)=>({ values.map((value) => ({
value, value,
editing : false, editing: false,
draft : '', draft: "",
})), })),
); );
useEffect(()=>{ useEffect(() => {
const incoming = values || []; const incoming = values || [];
const current = tagList.map((t)=>t.value); const current = tagList.map((t) => t.value);
const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]); const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]);
if(changed) { if (changed) {
setTagList( setTagList(
incoming.map((value)=>({ incoming.map((value) => ({
value, value,
editing : false, editing: false,
})), })),
); );
} }
}, [values]); }, [values]);
useEffect(()=>{ useEffect(() => {
onChange?.({ onChange?.({
target : { value: tagList.map((t)=>t.value) }, target: { value: tagList.map((t) => t.value) },
}); });
}, [tagList]); }, [tagList]);
// substrings to be normalized to the first value on the array // substrings to be normalized to the first value on the array
const duplicateGroups = [ const duplicateGroups = [
['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'], ["5e 2024", "5.5e", "5e'24", "5.24", "5e24", "5.5"],
['5e', '5th Edition'], ["5e", "5th Edition"],
['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'], ["Dungeons & Dragons", "Dungeons and Dragons", "Dungeons n dragons"],
['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'], ["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"],
['P2e', 'p2e', 'P2E', 'Pathfinder 2e'], ["P2e", "p2e", "P2E", "Pathfinder 2e"],
]; ];
const normalizeValue = (input)=>{ const normalizeValue = (input) => {
const lowerInput = input.toLowerCase(); const lowerInput = input.toLowerCase();
let normalizedTag = input; let normalizedTag = input;
for (const group of duplicateGroups) { for (const group of duplicateGroups) {
for (const tag of group) { for (const tag of group) {
if(!tag) continue; if (!tag) continue;
const index = lowerInput.indexOf(tag.toLowerCase()); const index = lowerInput.indexOf(tag.toLowerCase());
if(index !== -1) { if (index !== -1) {
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length); normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
break; break;
} }
} }
} }
if(normalizedTag.includes(':')) { if (normalizedTag.includes(":")) {
const [rawType, rawValue = ''] = normalizedTag.split(':'); const [rawType, rawValue = ""] = normalizedTag.split(":");
const tagType = rawType.trim().toLowerCase(); const tagType = rawType.trim().toLowerCase();
const tagValue = rawValue.trim(); const tagValue = rawValue.trim();
if(tagValue.length > 0) { if (tagValue.length > 0) {
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`; normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
} }
//trims spaces around colon and capitalizes the first word after the colon //trims spaces around colon and capitalizes the first word after the colon
@@ -75,88 +75,114 @@ const TagInput = ({ label, valuePatterns, values = [], unique = true, placeholde
return normalizedTag; return normalizedTag;
}; };
const submitTag = (newValue, index = null)=>{ const submitTag = (newValue, index = null) => {
const trimmed = newValue?.trim(); const trimmed = newValue?.trim();
if(!trimmed) return; if (!trimmed) return;
if(!valuePatterns.test(trimmed)) return; if (!valuePatterns.test(trimmed)) return;
const normalizedTag = normalizeValue(trimmed); const normalizedTag = normalizeValue(trimmed);
setTagList((prev)=>{ setTagList((prev) => {
const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase()); const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === normalizedTag.toLowerCase());
if(unique && existsIndex !== -1) return prev; if (unique && existsIndex !== -1) return prev;
if(index !== null) { if (index !== null) {
return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t)); return prev.map((t, i) => (i === index ? { ...t, value: normalizedTag, editing: false } : t));
} }
return [...prev, { value: normalizedTag, editing: false }]; return [...prev, { value: normalizedTag, editing: false }];
}); });
}; };
const removeTag = (index)=>{ const removeTag = (index) => {
setTagList((prev)=>prev.filter((_, i)=>i !== index)); setTagList((prev) => prev.filter((_, i) => i !== index));
}; };
const editTag = (index)=>{ const editTag = (index) => {
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t))); setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t)));
}; };
const stopEditing = (index)=>{ const stopEditing = (index) => {
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t))); setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t)));
}; };
const suggestionOptions = tagSuggestionList.map((tag)=>{ const suggestionOptions = tagSuggestionList.map((tag) => {
const tagType = tag.split(':'); const tagType = tag.split(":");
let classes = 'item'; let classes = "item";
switch (tagType[0]) { switch (tagType[0]) {
case 'type': case "type":
classes = 'item type'; classes = "item type";
break; break;
case 'group': case "group":
classes = 'item group'; classes = "item group";
break; break;
case 'meta': case "meta":
classes = 'item meta'; classes = "item meta";
break; break;
case 'system': case "system":
classes = 'item system'; classes = "item system";
break; break;
default: default:
classes = 'item'; classes = "item";
break; break;
} }
return ( return (
<div className={classes} key={`tag-${tag}`} value={tag} data={tag} title={tag}> <div className={classes} key={`tag-${tag}`} value={tag} data={tag}>
{tag} {tag}
</div> </div>
); );
}); });
return ( return (
<div className='field tags'> <div className="tagInputWrap">
{label && <label>{label}</label>} <Combobox
trigger="click"
<div className='value'> className="tagInput-dropdown"
<ul className='list'> default=""
{tagList.map((t, i)=>t.editing ? ( placeholder={placeholder}
options={label === "tags" ? suggestionOptions : []}
tooltip={tooltip}
autoSuggest={
label === "tags"
? {
suggestMethod: "startsWith",
clearAutoSuggestOnClick: true,
filterOn: ["value", "title"],
}
: { suggestMethod: "includes", clearAutoSuggestOnClick: true, filterOn: [] }
}
valuePatterns={valuePatterns.source}
onSelect={(value) => submitTag(value)}
onEntry={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitTag(e.target.value);
}
}}
/>
<ul className="list">
{tagList.map((t, i) =>
t.editing ? (
<input <input
key={i} key={i}
type='text' type="text"
value={t.draft} // always use draft value={t.draft} // always use draft
pattern={valuePatterns.source} pattern={valuePatterns.source}
onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)), onChange={(e) =>
) setTagList((prev) =>
prev.map((tag, idx) => (idx === i ? { ...tag, draft: e.target.value } : tag)),
)
} }
onKeyDown={(e)=>{ onKeyDown={(e) => {
if(e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
submitTag(t.draft, i); // submit draft submitTag(t.draft, i); // submit draft
setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: '' } : tag)), setTagList((prev) =>
prev.map((tag, idx) => (idx === i ? { ...tag, draft: "" } : tag)),
); );
} }
if(e.key === 'Escape') { if (e.key === "Escape") {
stopEditing(i); stopEditing(i);
e.target.blur(); e.target.blur();
} }
@@ -164,48 +190,20 @@ const TagInput = ({ label, valuePatterns, values = [], unique = true, placeholde
autoFocus autoFocus
/> />
) : ( ) : (
<li key={i} className='tag' onClick={()=>editTag(i)}> <li key={i} className="tag" onClick={() => editTag(i)}>
{t.value} {t.value}
<button <button
type='button' type="button"
onClick={(e)=>{ onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeTag(i); removeTag(i);
}}> }}>
<i className='fa fa-times fa-fw' /> <i className="fa fa-times fa-fw" />
</button> </button>
</li> </li>
), ),
)} )}
</ul> </ul>
<Combobox
trigger='click'
className='tagInput-dropdown'
default=''
placeholder={placeholder}
options={label === 'tags' ? suggestionOptions : []}
autoSuggest={
label === 'tags'
? {
suggestMethod : 'startsWith',
clearAutoSuggestOnClick : true,
filterOn : ['value', 'title'],
}
: { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
}
valuePatterns={valuePatterns.source}
onSelect={(value)=>submitTag(value)}
onEntry={(e)=>{
if(e.key === 'Enter') {
console.log('submit');
e.preventDefault();
submitTag(e.target.value);
}
}}
/>
{smallText.length !== 0 && <small>{smallText}</small>}
</div>
</div> </div>
); );
}; };

View File

@@ -1,21 +1,30 @@
.list input { .tags {
border-radius: 5px;
}
.tagInput-dropdown { .tagInputWrap {
.dropdown-options { display:grid;
.item { grid-template-columns: 200px 3fr;
&.type { gap:10px;
background-color: #00800035; }
}
&.group { .list input {
background-color: #50505035; border-radius: 5px;
} }
&.meta {
background-color: #00008035; .tagInput-dropdown {
} .dropdown-options {
&.system { .item {
background-color: #80000035; &.type {
background-color: #00800035;
}
&.group {
background-color: #50505035;
}
&.meta {
background-color: #00008035;
}
&.system {
background-color: #80000035;
}
} }
} }
} }

78
package-lock.json generated
View File

@@ -73,7 +73,7 @@
"globals": "^16.4.0", "globals": "^16.4.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom": "^28.0.0", "jsdom": "^28.1.0",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.25.0", "stylelint": "^16.25.0",
@@ -3245,13 +3245,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.2.3", "version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/stack-utils": { "node_modules/@types/stack-utils": {
@@ -4121,9 +4121,9 @@
} }
}, },
"node_modules/asn1.js/node_modules/bn.js": { "node_modules/asn1.js/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/assert": { "node_modules/assert": {
@@ -4497,9 +4497,9 @@
} }
}, },
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "5.2.2", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
@@ -5511,9 +5511,9 @@
} }
}, },
"node_modules/create-ecdh/node_modules/bn.js": { "node_modules/create-ecdh/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/create-hash": { "node_modules/create-hash": {
@@ -5960,9 +5960,9 @@
} }
}, },
"node_modules/diffie-hellman/node_modules/bn.js": { "node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dir-glob": { "node_modules/dir-glob": {
@@ -6067,9 +6067,9 @@
} }
}, },
"node_modules/elliptic/node_modules/bn.js": { "node_modules/elliptic/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/emittery": { "node_modules/emittery": {
@@ -7913,9 +7913,9 @@
} }
}, },
"node_modules/hashery": { "node_modules/hashery": {
"version": "1.4.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
"integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -10326,9 +10326,9 @@
} }
}, },
"node_modules/miller-rabin/node_modules/bn.js": { "node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mime": { "node_modules/mime": {
@@ -10413,10 +10413,10 @@
} }
}, },
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "ISC", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
@@ -11911,9 +11911,9 @@
} }
}, },
"node_modules/public-encrypt/node_modules/bn.js": { "node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/punycode": { "node_modules/punycode": {
@@ -14636,9 +14636,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -15360,9 +15360,9 @@
} }
}, },
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "16.0.0", "version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -3,18 +3,23 @@
@arrowSize : 6px; @arrowSize : 6px;
@arrowPosition : 18px; @arrowPosition : 18px;
[data-tooltip] { [data-tooltip] {
position:relative;
.tooltip(attr(data-tooltip)); .tooltip(attr(data-tooltip));
} }
[data-tooltip-top] { [data-tooltip-top] {
position:relative;
.tooltipTop(attr(data-tooltip-top)); .tooltipTop(attr(data-tooltip-top));
} }
[data-tooltip-bottom] { [data-tooltip-bottom] {
position:relative;
.tooltipBottom(attr(data-tooltip-bottom)); .tooltipBottom(attr(data-tooltip-bottom));
} }
[data-tooltip-left] { [data-tooltip-left] {
position:relative;
.tooltipLeft(attr(data-tooltip-left)); .tooltipLeft(attr(data-tooltip-left));
} }
[data-tooltip-right] { [data-tooltip-right] {
position:relative;
.tooltipRight(attr(data-tooltip-right)); .tooltipRight(attr(data-tooltip-right));
} }
.tooltip(@content) { .tooltip(@content) {
@@ -30,6 +35,7 @@
&::before, &::after { &::before, &::after {
bottom : 100%; bottom : 100%;
left : 50%; left : 50%;
translate: -50% 0;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateY(-(@arrowSize + 2))); .transform(translateY(-(@arrowSize + 2)));
@@ -45,6 +51,7 @@
&::before, &::after { &::before, &::after {
top : 100%; top : 100%;
left : 50%; left : 50%;
translate: -50% 0;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateY(@arrowSize + 2)); .transform(translateY(@arrowSize + 2));
@@ -57,7 +64,10 @@
margin-bottom : -@arrowSize; margin-bottom : -@arrowSize;
border-left-color : @tooltipColor; border-left-color : @tooltipColor;
} }
&::after { margin-bottom : -14px;} &::after {
margin-bottom : -14px;
max-width : 50ch;
}
&::before, &::after { &::before, &::after {
right : 100%; right : 100%;
bottom : 50%; bottom : 50%;
@@ -73,10 +83,14 @@
margin-left : -@arrowSize * 2; margin-left : -@arrowSize * 2;
border-right-color : @tooltipColor; border-right-color : @tooltipColor;
} }
&::after { margin-bottom : -14px;} &::after {
margin-bottom : -14px;
max-width : 50ch;
}
&::before, &::after { &::before, &::after {
bottom : 50%; top : 50%;
left : 100%; left : 100%;
translate:0 -50%;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateX(@arrowSize + 2)); .transform(translateX(@arrowSize + 2));
@@ -106,9 +120,12 @@
font-size : 12px; font-size : 12px;
line-height : 12px; line-height : 12px;
color : white; color : white;
white-space : nowrap;
content : @content; content : @content;
background : @tooltipColor; background : @tooltipColor;
max-width : 60ch;
width :max-content;
word-break : break-word;
overflow-wrap : break-word;
} }
&:hover::before, &:hover::after { &:hover::before, &:hover::after {
visibility : visible; visibility : visible;