Extract timer module components
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
This commit is contained in:
parent
d4fe3c796a
commit
3007aa1389
4 changed files with 467 additions and 426 deletions
|
|
@ -6,6 +6,7 @@ import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "
|
||||||
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
|
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
|
||||||
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
|
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
|
||||||
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
|
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
|
||||||
|
import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js";
|
||||||
|
|
||||||
test("toolbox storage, cards and pages are wired", async () => {
|
test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
|
|
@ -124,6 +125,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
|
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
|
||||||
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
|
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
|
||||||
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
|
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
|
||||||
|
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
|
||||||
|
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
|
||||||
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
||||||
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
|
||||||
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
|
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
|
||||||
|
|
@ -267,7 +270,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(timerModule, /Tabs/);
|
assert.match(timerModule, /Tabs/);
|
||||||
assert.match(timerModule, /stopwatch/);
|
assert.match(timerModule, /stopwatch/);
|
||||||
assert.match(timerModule, /countdown/);
|
assert.match(timerModule, /countdown/);
|
||||||
assert.match(timerModule, /useInlineEdit/);
|
assert.match(timerParts, /useInlineEdit/);
|
||||||
assert.match(timerModule, /useDraftForm/);
|
assert.match(timerModule, /useDraftForm/);
|
||||||
assert.match(timerModule, /normalizeTimerData/);
|
assert.match(timerModule, /normalizeTimerData/);
|
||||||
assert.match(timerModule, /TextExchangeActions/);
|
assert.match(timerModule, /TextExchangeActions/);
|
||||||
|
|
@ -371,7 +374,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(timerModule, /useGroupedReorder/);
|
assert.match(timerModule, /useGroupedReorder/);
|
||||||
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
||||||
assert.match(timerModule, /parentId=\{moduleId\}/);
|
assert.match(timerModule, /parentId=\{moduleId\}/);
|
||||||
assert.match(timerModule, /timer-drag-handle/);
|
assert.match(timerParts, /timer-drag-handle/);
|
||||||
|
assert.match(timerParts, /export function StopwatchControls/);
|
||||||
|
assert.match(timerParts, /export function CountdownControls/);
|
||||||
|
assert.match(timerParts, /export function CountdownList/);
|
||||||
|
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
|
||||||
assert.match(textImport, /export function parseColonImportLines/);
|
assert.match(textImport, /export function parseColonImportLines/);
|
||||||
assert.match(textImport, /export function exportModuleText/);
|
assert.match(textImport, /export function exportModuleText/);
|
||||||
assert.match(textImport, /export function importModuleText/);
|
assert.match(textImport, /export function importModuleText/);
|
||||||
|
|
@ -451,3 +458,12 @@ test("table helpers keep selection, movement and TSV behavior stable", () => {
|
||||||
const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2);
|
const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2);
|
||||||
assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" });
|
assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("timer options keep alert guards and countdown validation stable", () => {
|
||||||
|
assert.equal(isAlertGuarded({ type: "interval", intervalMs: 60_000 }), true);
|
||||||
|
assert.equal(isAlertGuarded({ type: "interval", intervalMs: 600_000 }), false);
|
||||||
|
assert.equal(canToggleAutoRefresh({ type: "daily_time" }), true);
|
||||||
|
assert.equal(canToggleAutoRefresh({ type: "interval", intervalMs: 60_000 }), false);
|
||||||
|
assert.equal(getCountdownValidationError("duration", { duration: { hours: "", minutes: "", seconds: "" } }, {}), "Valeur requise.");
|
||||||
|
assert.equal(getCountdownValidationError("daily_time", { time: { hours: "12", minutes: "30", seconds: "" } }, {}), "");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,118 +3,31 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { Tabs } from "../../../components/Tabs.jsx";
|
import { Tabs } from "../../../components/Tabs.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
|
||||||
formatDurationWithCentiseconds,
|
|
||||||
formatTargetTime,
|
|
||||||
getCentiseconds,
|
|
||||||
getCountdownTargetMs,
|
getCountdownTargetMs,
|
||||||
getDailyTargetMs,
|
getDailyTargetMs,
|
||||||
getIntervalAnchorMs,
|
getIntervalAnchorMs,
|
||||||
getNowMs,
|
getNowMs,
|
||||||
getTimePatternTargetMs,
|
getTimePatternTargetMs,
|
||||||
getTimePatternRecurrenceMs,
|
|
||||||
timePartsToDurationMs,
|
timePartsToDurationMs,
|
||||||
timePartsToString
|
timePartsToString
|
||||||
} from "./timerUtils.js";
|
} from "./timerUtils.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
|
import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx";
|
||||||
const TIMER_TABS = [
|
import {
|
||||||
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
ALERT_MIN_DELAY_MS,
|
||||||
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
|
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||||
];
|
COUNTDOWN_TYPES,
|
||||||
|
DEFAULT_COUNTDOWN_FORM,
|
||||||
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
|
TIMER_TABS,
|
||||||
const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
|
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
||||||
const DEFAULT_COUNTDOWN_FORM = {
|
canToggleAutoRefresh,
|
||||||
label: "",
|
getAlertGuardMessage,
|
||||||
type: "duration",
|
getCountdownValidationError,
|
||||||
duration: EMPTY_TIME_PARTS,
|
hasTimePartValue,
|
||||||
time: EMPTY_TIME_PARTS,
|
isAlertGuarded
|
||||||
pattern: EMPTY_TIME_PARTS,
|
} from "./timerOptions.js";
|
||||||
interval: EMPTY_TIME_PARTS,
|
|
||||||
intervalStartTime: EMPTY_TIME_PARTS
|
|
||||||
};
|
|
||||||
const ALERT_MODES = [
|
|
||||||
{ mode: "off", icon: "sound-mute" },
|
|
||||||
{ mode: "visible", icon: "sound-min" },
|
|
||||||
{ mode: "site", icon: "sound-max" }
|
|
||||||
];
|
|
||||||
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
|
|
||||||
const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
|
|
||||||
const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
|
|
||||||
|
|
||||||
function isAlertGuarded(countdown) {
|
|
||||||
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
|
|
||||||
if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAlertGuardMessage(textContent) {
|
|
||||||
return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
|
|
||||||
}
|
|
||||||
|
|
||||||
function canToggleAutoRefresh(countdown) {
|
|
||||||
return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
|
|
||||||
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
|
|
||||||
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="timer-value is-compact">
|
|
||||||
<span>{formatDuration(ms)}</span>
|
|
||||||
<small>{getCentiseconds(ms)}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCountdownMetaParts(countdown, textContent) {
|
|
||||||
if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
|
|
||||||
if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
|
|
||||||
if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
|
|
||||||
if (countdown.type === "interval") {
|
|
||||||
const startLabel = countdown.startMode === "time" && countdown.startTime
|
|
||||||
? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
|
|
||||||
: `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
|
|
||||||
return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditableKeyDown(event, callback) {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
event.preventDefault();
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTimePartValue(parts) {
|
|
||||||
return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCountdownValidationError(type, draft, textContent) {
|
|
||||||
const requiredError = textContent.requiredError || "Valeur requise.";
|
|
||||||
const invalidError = textContent.invalidError || "Valeur invalide.";
|
|
||||||
if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
|
|
||||||
return requiredError;
|
|
||||||
}
|
|
||||||
if (type === "daily_time" && !timePartsToString(draft.time)) {
|
|
||||||
return invalidError;
|
|
||||||
}
|
|
||||||
if (type === "time_pattern") {
|
|
||||||
if (!hasTimePartValue(draft.pattern)) return requiredError;
|
|
||||||
if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
|
|
||||||
}
|
|
||||||
if (type === "interval") {
|
|
||||||
const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
|
|
||||||
if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
|
|
||||||
if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
||||||
|
|
@ -411,326 +324,3 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
|
|
||||||
const labels = {
|
|
||||||
off: textContent.alertOffTitle || "Pas d'alerte",
|
|
||||||
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
|
||||||
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
|
||||||
};
|
|
||||||
const blockedTitle = getAlertGuardMessage(textContent);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
|
|
||||||
role="group"
|
|
||||||
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
|
|
||||||
title={blocked ? blockedTitle : undefined}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
{ALERT_MODES.map((option) => {
|
|
||||||
const label = labels[option.mode];
|
|
||||||
const disabled = Boolean(blocked);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.mode}
|
|
||||||
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(option.mode)}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-pressed={mode === option.mode}
|
|
||||||
aria-label={disabled ? blockedTitle : label}
|
|
||||||
title={disabled ? blockedTitle : label}
|
|
||||||
>
|
|
||||||
<Icon name={option.icon} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
|
|
||||||
return (
|
|
||||||
<div className="timer-control-panel" role="tabpanel">
|
|
||||||
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
|
|
||||||
<strong><TimerValue ms={elapsedMs} compact /></strong>
|
|
||||||
<div className="timer-actions">
|
|
||||||
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
|
|
||||||
<Icon name={running ? "pause-circle" : "play-circle"} />
|
|
||||||
</button>
|
|
||||||
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
|
|
||||||
<Icon name="record-circle" />
|
|
||||||
</button>
|
|
||||||
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
|
||||||
<Icon name="stop-circle" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
|
|
||||||
return (
|
|
||||||
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
|
|
||||||
<label>
|
|
||||||
<span>{textContent.countdownLabel || "Libellé"}</span>
|
|
||||||
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{textContent.countdownTypeLabel || "Type"}</span>
|
|
||||||
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
|
|
||||||
<option value="duration">{textContent.durationType || "Durée"}</option>
|
|
||||||
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
|
|
||||||
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
|
|
||||||
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{form.type === "duration" && (
|
|
||||||
<label>
|
|
||||||
<span className="timer-field-spacer" aria-hidden="true" />
|
|
||||||
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
|
|
||||||
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{form.type === "daily_time" && (
|
|
||||||
<label>
|
|
||||||
<span className="timer-field-spacer" aria-hidden="true" />
|
|
||||||
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
|
|
||||||
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{form.type === "time_pattern" && (
|
|
||||||
<label>
|
|
||||||
<span className="timer-field-spacer" aria-hidden="true" />
|
|
||||||
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
|
|
||||||
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{form.type === "interval" && (
|
|
||||||
<div className="timer-interval-fields">
|
|
||||||
<label>
|
|
||||||
<span>{textContent.intervalLabel || "Toutes les"}</span>
|
|
||||||
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
|
|
||||||
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p id="timer-countdown-error" className="timer-form-error" role="alert">
|
|
||||||
<Icon name="close" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimePartsInput({ value, onChange, allowWildcard = false }) {
|
|
||||||
function updatePart(part, nextValue) {
|
|
||||||
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
|
|
||||||
onChange({ ...value, [part]: cleanValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
|
|
||||||
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
|
|
||||||
<span>:</span>
|
|
||||||
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
|
|
||||||
<span>:</span>
|
|
||||||
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
|
|
||||||
const [editingId, setEditingId] = useState("");
|
|
||||||
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
|
|
||||||
return (
|
|
||||||
<ul className="tool-split-entry-list timer-list">
|
|
||||||
{laps.map((lap, index) => (
|
|
||||||
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
|
|
||||||
<div className="tool-split-entry timer-entry has-single-action">
|
|
||||||
{editingId === lap.id ? (
|
|
||||||
<EditableTimerLabel
|
|
||||||
value={lap.label}
|
|
||||||
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
|
|
||||||
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
|
|
||||||
onRename={(label) => onRename(lap.id, label)}
|
|
||||||
onDone={() => setEditingId("")}
|
|
||||||
>
|
|
||||||
<TimerValue ms={lap.elapsedMs} />
|
|
||||||
</EditableTimerLabel>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="tool-split-entry-summary timer-entry-summary"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setEditingId(lap.id)}
|
|
||||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
|
|
||||||
>
|
|
||||||
<span className="timer-entry-line">
|
|
||||||
<TimerValue ms={lap.elapsedMs} />
|
|
||||||
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
|
|
||||||
const [editingId, setEditingId] = useState("");
|
|
||||||
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
|
|
||||||
return (
|
|
||||||
<ul className="tool-split-entry-list timer-list">
|
|
||||||
{countdowns.map((countdown) => {
|
|
||||||
const remainingMs = countdown.targetMs - nowMs;
|
|
||||||
const expired = remainingMs <= 0;
|
|
||||||
const canReset = countdown.type === "duration";
|
|
||||||
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
|
|
||||||
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
|
|
||||||
const refreshTitle = refreshBlocked
|
|
||||||
? getAlertGuardMessage(textContent)
|
|
||||||
: textContent.autoRefreshTitle || "Répéter automatiquement";
|
|
||||||
const className = [
|
|
||||||
"timer-list-item",
|
|
||||||
expired ? "is-expired" : "",
|
|
||||||
editingId === countdown.id ? "is-editing" : "",
|
|
||||||
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
|
|
||||||
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
|
|
||||||
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={countdown.id} className={className} {...reorderProps}>
|
|
||||||
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
|
|
||||||
{reorderEnabled && (
|
|
||||||
<button
|
|
||||||
className="timer-drag-handle"
|
|
||||||
type="button"
|
|
||||||
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
|
|
||||||
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
|
|
||||||
title={textContent.reorderTitle || "Déplacer"}
|
|
||||||
>
|
|
||||||
<Icon name="drag" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{editingId === countdown.id ? (
|
|
||||||
<EditableTimerLabel
|
|
||||||
value={countdown.label}
|
|
||||||
placeholder={textContent.defaultCountdownLabel || "Timer"}
|
|
||||||
ariaLabel={textContent.countdownLabel || "Libellé"}
|
|
||||||
onRename={(label) => onRename(countdown.id, label)}
|
|
||||||
onDone={() => setEditingId("")}
|
|
||||||
>
|
|
||||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
|
||||||
</EditableTimerLabel>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="tool-split-entry-summary timer-entry-summary"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setEditingId(countdown.id)}
|
|
||||||
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
|
|
||||||
>
|
|
||||||
<span className="timer-entry-line">
|
|
||||||
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
|
||||||
<span>{countdown.label}</span>
|
|
||||||
<span className="timer-inline-controls">
|
|
||||||
{canReset && (
|
|
||||||
<button
|
|
||||||
className="timer-inline-reset"
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onReset(countdown);
|
|
||||||
}}
|
|
||||||
aria-label={textContent.resetTitle || "Réinitialiser"}
|
|
||||||
title={textContent.resetTitle || "Réinitialiser"}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canToggleRefresh && (
|
|
||||||
<button
|
|
||||||
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onAutoRefreshToggle(countdown.id);
|
|
||||||
}}
|
|
||||||
disabled={refreshBlocked}
|
|
||||||
aria-pressed={Boolean(countdown.autoRefresh)}
|
|
||||||
aria-label={refreshTitle}
|
|
||||||
title={refreshTitle}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" />
|
|
||||||
<i aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<TimerAlertModeSwitch
|
|
||||||
mode={countdown.alertMode}
|
|
||||||
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
|
|
||||||
textContent={textContent}
|
|
||||||
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="timer-entry-meta">
|
|
||||||
<span>
|
|
||||||
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
|
|
||||||
<b key={`${part}-${index}`}>{part}</b>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<em>
|
|
||||||
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
|
|
||||||
</em>
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
|
|
||||||
const inlineEdit = useInlineEdit({
|
|
||||||
value,
|
|
||||||
onCommit: onRename,
|
|
||||||
onDone,
|
|
||||||
autoFocus: true,
|
|
||||||
commitUnchanged: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
|
|
||||||
<input
|
|
||||||
{...inlineEdit.getInputProps({
|
|
||||||
className: "tool-split-entry-label timer-lap-label",
|
|
||||||
placeholder,
|
|
||||||
"aria-label": ariaLabel
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
350
website/src/features/toolboxes/modules/TimerParts.jsx
Normal file
350
website/src/features/toolboxes/modules/TimerParts.jsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer.
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
|
import { formatDuration, formatDurationWithCentiseconds, formatTargetTime, getCentiseconds } from "./timerUtils.js";
|
||||||
|
import {
|
||||||
|
ALERT_MODES,
|
||||||
|
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||||
|
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
||||||
|
canToggleAutoRefresh,
|
||||||
|
getAlertGuardMessage,
|
||||||
|
getCountdownMetaParts,
|
||||||
|
handleEditableKeyDown,
|
||||||
|
isAlertGuarded
|
||||||
|
} from "./timerOptions.js";
|
||||||
|
|
||||||
|
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
|
||||||
|
return (
|
||||||
|
<div className="timer-control-panel" role="tabpanel">
|
||||||
|
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
|
||||||
|
<strong><TimerValue ms={elapsedMs} compact /></strong>
|
||||||
|
<div className="timer-actions">
|
||||||
|
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
|
||||||
|
<Icon name={running ? "pause-circle" : "play-circle"} />
|
||||||
|
</button>
|
||||||
|
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
|
||||||
|
<Icon name="record-circle" />
|
||||||
|
</button>
|
||||||
|
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
|
||||||
|
<Icon name="stop-circle" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
|
||||||
|
return (
|
||||||
|
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
|
||||||
|
<label>
|
||||||
|
<span>{textContent.countdownLabel || "Libellé"}</span>
|
||||||
|
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{textContent.countdownTypeLabel || "Type"}</span>
|
||||||
|
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
|
||||||
|
<option value="duration">{textContent.durationType || "Durée"}</option>
|
||||||
|
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
|
||||||
|
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
|
||||||
|
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{form.type === "duration" && (
|
||||||
|
<label>
|
||||||
|
<span className="timer-field-spacer" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
|
||||||
|
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.type === "daily_time" && (
|
||||||
|
<label>
|
||||||
|
<span className="timer-field-spacer" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
|
||||||
|
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.type === "time_pattern" && (
|
||||||
|
<label>
|
||||||
|
<span className="timer-field-spacer" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
|
||||||
|
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.type === "interval" && (
|
||||||
|
<div className="timer-interval-fields">
|
||||||
|
<label>
|
||||||
|
<span>{textContent.intervalLabel || "Toutes les"}</span>
|
||||||
|
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
|
||||||
|
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id="timer-countdown-error" className="timer-form-error" role="alert">
|
||||||
|
<Icon name="close" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
|
||||||
|
const [editingId, setEditingId] = useState("");
|
||||||
|
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
|
||||||
|
return (
|
||||||
|
<ul className="tool-split-entry-list timer-list">
|
||||||
|
{laps.map((lap, index) => (
|
||||||
|
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
|
||||||
|
<div className="tool-split-entry timer-entry has-single-action">
|
||||||
|
{editingId === lap.id ? (
|
||||||
|
<EditableTimerLabel
|
||||||
|
value={lap.label}
|
||||||
|
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
|
||||||
|
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
|
||||||
|
onRename={(label) => onRename(lap.id, label)}
|
||||||
|
onDone={() => setEditingId("")}
|
||||||
|
>
|
||||||
|
<TimerValue ms={lap.elapsedMs} />
|
||||||
|
</EditableTimerLabel>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="tool-split-entry-summary timer-entry-summary"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setEditingId(lap.id)}
|
||||||
|
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
|
||||||
|
>
|
||||||
|
<span className="timer-entry-line">
|
||||||
|
<TimerValue ms={lap.elapsedMs} />
|
||||||
|
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
|
||||||
|
const [editingId, setEditingId] = useState("");
|
||||||
|
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
|
||||||
|
return (
|
||||||
|
<ul className="tool-split-entry-list timer-list">
|
||||||
|
{countdowns.map((countdown) => {
|
||||||
|
const remainingMs = countdown.targetMs - nowMs;
|
||||||
|
const expired = remainingMs <= 0;
|
||||||
|
const canReset = countdown.type === "duration";
|
||||||
|
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
|
||||||
|
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
|
||||||
|
const refreshTitle = refreshBlocked
|
||||||
|
? getAlertGuardMessage(textContent)
|
||||||
|
: textContent.autoRefreshTitle || "Répéter automatiquement";
|
||||||
|
const className = [
|
||||||
|
"timer-list-item",
|
||||||
|
expired ? "is-expired" : "",
|
||||||
|
editingId === countdown.id ? "is-editing" : "",
|
||||||
|
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
|
||||||
|
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
|
||||||
|
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={countdown.id} className={className} {...reorderProps}>
|
||||||
|
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
|
||||||
|
{reorderEnabled && (
|
||||||
|
<button
|
||||||
|
className="timer-drag-handle"
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
|
||||||
|
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
|
||||||
|
title={textContent.reorderTitle || "Déplacer"}
|
||||||
|
>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editingId === countdown.id ? (
|
||||||
|
<EditableTimerLabel
|
||||||
|
value={countdown.label}
|
||||||
|
placeholder={textContent.defaultCountdownLabel || "Timer"}
|
||||||
|
ariaLabel={textContent.countdownLabel || "Libellé"}
|
||||||
|
onRename={(label) => onRename(countdown.id, label)}
|
||||||
|
onDone={() => setEditingId("")}
|
||||||
|
>
|
||||||
|
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||||
|
</EditableTimerLabel>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="tool-split-entry-summary timer-entry-summary"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setEditingId(countdown.id)}
|
||||||
|
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
|
||||||
|
>
|
||||||
|
<span className="timer-entry-line">
|
||||||
|
<TimerValue ms={remainingMs} showCentiseconds={false} />
|
||||||
|
<span>{countdown.label}</span>
|
||||||
|
<span className="timer-inline-controls">
|
||||||
|
{canReset && (
|
||||||
|
<button
|
||||||
|
className="timer-inline-reset"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onReset(countdown);
|
||||||
|
}}
|
||||||
|
aria-label={textContent.resetTitle || "Réinitialiser"}
|
||||||
|
title={textContent.resetTitle || "Réinitialiser"}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canToggleRefresh && (
|
||||||
|
<button
|
||||||
|
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onAutoRefreshToggle(countdown.id);
|
||||||
|
}}
|
||||||
|
disabled={refreshBlocked}
|
||||||
|
aria-pressed={Boolean(countdown.autoRefresh)}
|
||||||
|
aria-label={refreshTitle}
|
||||||
|
title={refreshTitle}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" />
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<TimerAlertModeSwitch
|
||||||
|
mode={countdown.alertMode}
|
||||||
|
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
|
||||||
|
textContent={textContent}
|
||||||
|
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="timer-entry-meta">
|
||||||
|
<span>
|
||||||
|
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
|
||||||
|
<b key={`${part}-${index}`}>{part}</b>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<em>
|
||||||
|
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
|
||||||
|
</em>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
|
||||||
|
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
|
||||||
|
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="timer-value is-compact">
|
||||||
|
<span>{formatDuration(ms)}</span>
|
||||||
|
<small>{getCentiseconds(ms)}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
|
||||||
|
const labels = {
|
||||||
|
off: textContent.alertOffTitle || "Pas d'alerte",
|
||||||
|
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
|
||||||
|
site: textContent.alertSiteTitle || "Alerte sur tout le site"
|
||||||
|
};
|
||||||
|
const blockedTitle = getAlertGuardMessage(textContent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
|
||||||
|
title={blocked ? blockedTitle : undefined}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{ALERT_MODES.map((option) => {
|
||||||
|
const label = labels[option.mode];
|
||||||
|
const disabled = Boolean(blocked);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.mode)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={mode === option.mode}
|
||||||
|
aria-label={disabled ? blockedTitle : label}
|
||||||
|
title={disabled ? blockedTitle : label}
|
||||||
|
>
|
||||||
|
<Icon name={option.icon} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimePartsInput({ value, onChange, allowWildcard = false }) {
|
||||||
|
function updatePart(part, nextValue) {
|
||||||
|
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
|
||||||
|
onChange({ ...value, [part]: cleanValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
|
||||||
|
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||||
|
<span>:</span>
|
||||||
|
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||||
|
<span>:</span>
|
||||||
|
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
|
||||||
|
const inlineEdit = useInlineEdit({
|
||||||
|
value,
|
||||||
|
onCommit: onRename,
|
||||||
|
onDone,
|
||||||
|
autoFocus: true,
|
||||||
|
commitUnchanged: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
|
||||||
|
<input
|
||||||
|
{...inlineEdit.getInputProps({
|
||||||
|
className: "tool-split-entry-label timer-lap-label",
|
||||||
|
placeholder,
|
||||||
|
"aria-label": ariaLabel
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
website/src/features/toolboxes/modules/timerOptions.js
Normal file
85
website/src/features/toolboxes/modules/timerOptions.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Rôle : centralise les options, validations et helpers purs du module Timer.
|
||||||
|
import { formatDuration, formatTargetTime, getTimePatternRecurrenceMs, timePartsToDurationMs, timePartsToString } from "./timerUtils.js";
|
||||||
|
|
||||||
|
export const TIMER_TABS = [
|
||||||
|
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
||||||
|
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
|
||||||
|
export const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
|
||||||
|
export const DEFAULT_COUNTDOWN_FORM = {
|
||||||
|
label: "",
|
||||||
|
type: "duration",
|
||||||
|
duration: EMPTY_TIME_PARTS,
|
||||||
|
time: EMPTY_TIME_PARTS,
|
||||||
|
pattern: EMPTY_TIME_PARTS,
|
||||||
|
interval: EMPTY_TIME_PARTS,
|
||||||
|
intervalStartTime: EMPTY_TIME_PARTS
|
||||||
|
};
|
||||||
|
export const ALERT_MODES = [
|
||||||
|
{ mode: "off", icon: "sound-mute" },
|
||||||
|
{ mode: "visible", icon: "sound-min" },
|
||||||
|
{ mode: "site", icon: "sound-max" }
|
||||||
|
];
|
||||||
|
export const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
|
||||||
|
export const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
|
||||||
|
export const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
|
||||||
|
|
||||||
|
export function isAlertGuarded(countdown) {
|
||||||
|
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
|
||||||
|
if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlertGuardMessage(textContent) {
|
||||||
|
return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canToggleAutoRefresh(countdown) {
|
||||||
|
return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCountdownMetaParts(countdown, textContent) {
|
||||||
|
if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
|
||||||
|
if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
|
||||||
|
if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
|
||||||
|
if (countdown.type === "interval") {
|
||||||
|
const startLabel = countdown.startMode === "time" && countdown.startTime
|
||||||
|
? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
|
||||||
|
: `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
|
||||||
|
return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleEditableKeyDown(event, callback) {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTimePartValue(parts) {
|
||||||
|
return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCountdownValidationError(type, draft, textContent) {
|
||||||
|
const requiredError = textContent.requiredError || "Valeur requise.";
|
||||||
|
const invalidError = textContent.invalidError || "Valeur invalide.";
|
||||||
|
if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
|
||||||
|
return requiredError;
|
||||||
|
}
|
||||||
|
if (type === "daily_time" && !timePartsToString(draft.time)) {
|
||||||
|
return invalidError;
|
||||||
|
}
|
||||||
|
if (type === "time_pattern") {
|
||||||
|
if (!hasTimePartValue(draft.pattern)) return requiredError;
|
||||||
|
if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
|
||||||
|
}
|
||||||
|
if (type === "interval") {
|
||||||
|
const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
|
||||||
|
if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
|
||||||
|
if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue