sokko-g/website/src/features/toolboxes/modules/ModuleShell.jsx
Shinuwa 808610dcf2
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
Add planner-aware memos and unified tool configuration panels
2026-10-03 15:58:33 +02:00

243 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Rôle : rend l'en-tête, les contrôles et le contenu repliable d'un outil toolbox.
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { GuideChecklistControls } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", configurationText = {}, scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component;
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationTarget, setConfigurationTarget] = useState(null);
const configurationId = useId();
const moduleRef = useRef(null);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const hasConfiguration = spaces.length > 1 || Boolean(guidePanel.list) || hasTextExchange;
const scrollable = definition.scrollable && module.scrollable === true;
const collapsed = module.collapsed === true;
const className = [
"module",
reorder.isItemDragging(module.id) ? "is-dragging" : "",
reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
scrollable ? "is-scrollable" : "",
collapsed ? "is-collapsed" : ""
].filter(Boolean).join(" ");
const componentProps = {
toolboxId: toolbox.id,
moduleId: module.id,
context,
editing,
setEditing,
openEditor: () => {
setConfigurationOpen(false);
setEditing(true);
},
configurationTarget
};
useEffect(() => {
if (!scrollIntoView) return undefined;
const frame = window.requestAnimationFrame(() => {
moduleRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
onScrolledIntoView?.();
});
return () => window.cancelAnimationFrame(frame);
}, [onScrolledIntoView, scrollIntoView]);
useEffect(() => {
if (!configurationOpen) return undefined;
const closeOnEscape = (event) => {
if (event.key === "Escape" && !document.querySelector('[aria-modal="true"]')) setConfigurationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [configurationOpen]);
function toggleCollapsed() {
if (!collapsed) {
setEditing(false);
setConfigurationOpen(false);
}
onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (collapsed) {
delete nextModule.collapsed;
} else {
nextModule.collapsed = true;
}
return nextModule;
});
}
return (
<article
ref={moduleRef}
className={className}
data-module-type={module.type}
data-module-id={module.id}
{...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })}
>
<header className="module-header">
<div className="module-header-main">
<button
className="module-drag-handle nds-icon-button"
data-size="md"
data-variant="secondary"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, module.id)}
aria-label={`Déplacer ${module.title || label}`}
title="Déplacer"
>
<Icon name="drag" />
</button>
<span className="module-icon" aria-hidden="true">
<span className={`module-icon-svg module-icon-${definition.icon || "notepad"}`} />
</span>
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div>
<div className="module-header-actions">
{definition.scrollable && (
<button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}
data-size="lg"
onClick={() => onUpdateModule(module.id, (currentModule) => {
const nextModule = { ...currentModule };
if (scrollable) {
delete nextModule.scrollable;
} else {
nextModule.scrollable = true;
}
return nextModule;
})}
aria-label={`${scrollable ? "Désactiver" : "Activer"} le scroll de ${module.title || label}`}
aria-pressed={scrollable}
title={scrollable ? "Désactiver le scroll" : "Activer le scroll"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
)}
{hasAddPanel && (
<button
className={`module-edit-button nds-icon-button ${editing ? "active" : ""}`}
data-size="md"
onClick={() => setEditing((value) => {
const nextEditing = !value;
if (nextEditing) setConfigurationOpen(false);
return nextEditing;
})}
aria-label={`${editing ? "Masquer" : "Afficher"} l'ajout de ${module.title || label}`}
aria-pressed={editing}
title={editing ? "Masquer l'ajout" : "Ajouter"}
>
<Icon name="add" />
</button>
)}
{hasConfiguration && (
<button
className={`module-configuration-button nds-icon-button ${configurationOpen ? "active" : ""}`}
data-size="md"
data-guide-linked={guidePanel.list && context.guideChecklist?.selected?.id === guidePanel.list.id || undefined}
type="button"
onClick={() => setConfigurationOpen((value) => {
const nextOpen = !value;
if (nextOpen) setEditing(false);
return nextOpen;
})}
aria-label={configurationText.buttonLabel || "Configuration"}
aria-expanded={configurationOpen}
aria-controls={configurationId}
title={configurationText.buttonLabel || "Configuration"}
>
<Icon name="settings" />
</button>
)}
<button className="module-delete-button nds-icon-button" data-size="md" data-variant="danger" onClick={() => onDelete(module.id)} aria-label={`Retirer ${module.title || label}`} data-toolbox-tutorial-target={module.type === "links" ? "deleteTool" : undefined} title="Retirer">
<span className="ui-icon ui-icon-trash" aria-hidden="true" />
</button>
<button
className="module-collapse-button nds-icon-button"
data-size="md"
type="button"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
aria-label={`${collapsed ? "Afficher" : "Masquer"} ${module.title || label}`}
title={collapsed ? "Afficher l’outil" : "Masquer l’outil"}
>
<Icon name={collapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</header>
{configurationOpen && (
<section
id={configurationId}
className="module-configuration-panel"
aria-label={`${configurationText.panelLabel || "Configuration de"} ${module.title || label}`}
>
{spaces.length > 1 && (
<section className="module-configuration-section module-configuration-organization">
<h3>{configurationText.organizationTitle || "Espace"}</h3>
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={configurationText.spaceLabel || moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
</section>
)}
{guidePanel.list && (
<section className="module-configuration-section module-configuration-specific">
<h3>{configurationText.guideTitle || "Guide"}</h3>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} open />
</section>
)}
<div className="module-configuration-target" ref={setConfigurationTarget} />
</section>
)}
{(!collapsed || configurationOpen) && (
<div className="module-component-host" hidden={collapsed}>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
</ModuleDataGate>
</Suspense>
</div>
</div>
)}
</article>
);
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
blurOnEscape: true
});
return (
<h2
{...inlineEdit.getContentEditableProps({
className: "module-title",
"data-toolbox-tutorial-target": tutorialTarget || undefined,
title: "Cliquer pour renommer"
})}
>
{value}
</h2>
);
}