243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
// 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>
|
||
);
|
||
}
|