Add internal mix tag input for calculator and goal tree formulas
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 13:24:44 +02:00
parent 5c8148c905
commit ae503c7260
13 changed files with 929 additions and 23 deletions

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence et échange texte.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
@ -183,8 +184,8 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
}
function useEntry(entry) {
setExpression(formatResult(entry.value));
setActiveParentId(entry.id);
setExpression(BASE_TOKEN);
}
function deleteEntry(entryId) {
@ -242,10 +243,48 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
)}
<form className="calculator-form" onSubmit={saveResult}>
<div className="calculator-card" ref={calculatorCardRef}>
<label>
<div className="calculator-field">
<span>{textContent.expressionLabel || "Calcul"}</span>
<input value={expression} onChange={(event) => setExpression(event.target.value)} placeholder={textContent.expressionPlaceholder || "10*10"} inputMode="decimal" />
</label>
{activeParent ? (
<MixTagsInput
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
onChange={setExpression}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
formulaSpacing
insertToken={{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
placeholder={textContent.formulaPlaceholder || "base * 3"}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={expression}
/>
) : (
<input
aria-label={textContent.expressionLabel || "Calcul"}
className="calculator-expression-input"
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.closest("form")?.requestSubmit();
}}
inputMode="decimal"
placeholder={textContent.expressionPlaceholder || "10*10"}
value={expression}
/>
)}
</div>
<div className={`calculator-result ${result == null ? "" : getResultLengthClass(result)}`} aria-live="polite">
<span>{textContent.resultLabel || "Résultat"}</span>
<strong aria-label={result == null ? "-" : formatDisplayResult(result)}>
@ -307,6 +346,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
function closeOnOutsidePointer(event) {
if (event.target?.closest?.(`[data-calculator-edit-fields="${editingId}"]`)) return;
if (event.target?.closest?.("[data-calculator-edit-toggle]")) return;
setEditingId("");
}
@ -322,7 +362,7 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
const className = [
entry.id === activeParentId ? "active" : "",
entry.id === editingId ? "is-editing" : "",
"can-edit-formula",
entry.parentId ? "can-edit-formula" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
@ -355,7 +395,15 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
</span>
</button>
)}
<button type="button" onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)} aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`} title={textContent.renameTitle || "Renommer"}>
<button
className={entry.id === editingId ? "active" : ""}
type="button"
onClick={() => setEditingId(entry.id === editingId ? "" : entry.id)}
aria-pressed={entry.id === editingId}
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
data-calculator-edit-toggle
title={textContent.renameTitle || "Renommer"}
>
<Icon name="edit" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
@ -392,6 +440,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone, autoFoc
function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormula, autoFocus = false }) {
const parent = entries.find((candidate) => candidate.id === entry.parentId);
const parentValue = parent?.value;
const hasParent = Boolean(entry.parentId);
const inlineEdit = useInlineEdit({
value: getEditableFormula(entry),
onCommit: (formula) => {
@ -403,13 +452,49 @@ function EditableCalculatorFormula({ entry, entries, textContent, onUpdateFormul
transform: (draft) => String(draft || "").replaceAll(",", ".").trim()
});
if (!hasParent) {
return (
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label calculator-entry-formula",
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`,
title: textContent.rootFormulaHelp || "Valeur ou formule racine."
})}
/>
);
}
return (
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label calculator-entry-formula",
"aria-label": `${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`,
title: entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."
})}
<MixTagsInput
aria-label={`${textContent.formulaTitle || "Formule enregistrée"} ${entry.label || formatResult(entry.value)}`}
className="tool-split-entry-label calculator-entry-formula"
onBlur={() => inlineEdit.commit()}
onChange={inlineEdit.setDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
inlineEdit.cancel(event);
}
}}
formulaSpacing
insertToken={{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
placeholder={textContent.formulaPlaceholder || "base * 3"}
size="small"
title={entry.parentId ? textContent.childFormulaHelp || "Utilisez base pour référencer le parent." : textContent.rootFormulaHelp || "Valeur ou formule racine."}
tokens={[{
value: "base",
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={inlineEdit.draft}
/>
);
}

View file

@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { DiagramConnectors } from "../../../components/DiagramConnectors.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
@ -42,6 +43,7 @@ const KIND_ICONS = {
};
const GOAL_TREE_VIEW_SCALE = 0.7;
const BASE_TOKEN = "base";
function getGoalTreeKindIcon(kind) {
return KIND_ICONS[kind] || "diagram";
@ -528,10 +530,13 @@ export function GoalTreeModule({ toolboxId, moduleId, context, editing, setEditi
<div className="goal-tree-form-section-title">{textContent.quantitySectionTitle || "Quantité"}</div>
<label>
<span>{textContent.targetQuantityLabel || "Cible"}</span>
<input
<GoalTreeFormulaInput
aria-label={textContent.targetQuantityLabel || "Cible"}
hasParent={Boolean(selectedNode.parentId)}
onChange={(value) => setStepTarget(selectedNode.id, value)}
value={selectedNode.formula || selectedNode.qtyTarget}
placeholder={textContent.formulaPlaceholder || "base * 3"}
onChange={(event) => setStepTarget(selectedNode.id, event.target.value)}
textContent={textContent}
title={textContent.formulaHelp || "Utilisez base pour référencer la cible du parent."}
/>
</label>
@ -852,6 +857,46 @@ function GoalTreeTitleInput({ value, onChange }) {
);
}
function GoalTreeFormulaInput({ value, onChange, hasParent, textContent, placeholder, title, className = "", "aria-label": ariaLabel }) {
const normalizedValue = String(value ?? "");
if (!hasParent) {
return (
<input
aria-label={ariaLabel}
className={className}
inputMode="decimal"
onChange={(event) => onChange(event.target.value.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
value={normalizedValue}
/>
);
}
return (
<MixTagsInput
aria-label={ariaLabel}
className={className}
formulaSpacing
inputMode="decimal"
insertToken={{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent",
icon: "parent",
title: textContent.insertBaseTitle || "Ajouter la valeur du parent"
}}
onChange={(nextValue) => onChange(nextValue.replaceAll(",", "."))}
placeholder={placeholder}
title={title}
tokens={[{
value: BASE_TOKEN,
label: textContent.baseTokenLabel || "Valeur du parent"
}]}
value={normalizedValue}
/>
);
}
function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete }) {
return (
<div className="goal-tree-content-editor">
@ -859,17 +904,21 @@ function GoalTreeContentEditor({ node, textContent, onAdd, onUpdate, onDelete })
{(node.items || []).map((item) => (
<div className={`goal-tree-content-editor-row is-${node.kind}`} key={item.id}>
<input
className="goal-tree-content-label-input"
value={item.label}
placeholder={textContent.itemLabelPlaceholder || "Élément"}
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, label: event.target.value }))}
aria-label={textContent.itemLabelLabel || "Libellé"}
/>
{node.kind === "checklist" && (
<input
<GoalTreeFormulaInput
aria-label={textContent.itemTotalLabel || "Total"}
className="goal-tree-content-formula-input"
hasParent={Boolean(node.parentId)}
onChange={(value) => onUpdate(item.id, (current) => ({ ...current, formula: value.replaceAll(",", ".") }))}
value={item.formula || "1"}
placeholder={textContent.itemTotalPlaceholder || "base"}
onChange={(event) => onUpdate(item.id, (current) => ({ ...current, formula: event.target.value.replaceAll(",", ".") }))}
aria-label={textContent.itemTotalLabel || "Total"}
textContent={textContent}
title={textContent.itemTotalHelp || "Saisissez un nombre ou une formule avec base pour référencer la cible du parent."}
/>
)}