Explorateur : actions d'un élément dans un menu déroulant
Le bouton « … » en bout de ligne (ou sur la vignette) ouvre un menu flottant aligné sur lui, au lieu d'insérer la barre d'actions sous l'élément. Le menu se referme au choix d'une action, par Échap, clic ailleurs, défilement ou redimensionnement ; flèches haut/bas pour naviguer. Icônes sur chaque action. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AW78w4N95h6PXiCResh2v
This commit is contained in:
1 parent
ef22ee0976
commit
8e0b5ea0d9
4 files changed
+147
-40
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
PLACEHOLDER
|
||||||
+84
-35
@@ -19,17 +19,22 @@ import {
|
|||||||
import {
|
import {
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
DeselectIcon,
|
DeselectIcon,
|
||||||
|
CopyIcon,
|
||||||
DotsIcon,
|
DotsIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
FilePlusIcon,
|
FilePlusIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
FolderUploadIcon,
|
FolderUploadIcon,
|
||||||
GridIcon,
|
GridIcon,
|
||||||
|
HistoryIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
MoveIcon,
|
MoveIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SelectAllIcon,
|
SelectAllIcon,
|
||||||
|
ShareIcon,
|
||||||
StarFilledIcon,
|
StarFilledIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
@@ -38,6 +43,7 @@ import {
|
|||||||
UploadIcon,
|
UploadIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
import { ActionMenu } from "./ActionMenu";
|
||||||
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
||||||
import { MoveDialog } from "./MoveDialog";
|
import { MoveDialog } from "./MoveDialog";
|
||||||
import { TagsDialog } from "./TagsDialog";
|
import { TagsDialog } from "./TagsDialog";
|
||||||
@@ -102,7 +108,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const [view, setView] = useState<View>(initialView);
|
const [view, setView] = useState<View>(initialView);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
|
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
|
||||||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
const [menu, setMenu] = useState<{ entry: Hit; anchor: HTMLElement } | null>(null); // menu « … » ouvert
|
||||||
const [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
|
const [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -122,6 +128,10 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
// Dossier Favoris : des liens vers les originaux, ni création ni déplacement.
|
// Dossier Favoris : des liens vers les originaux, ni création ni déplacement.
|
||||||
const favView = !results && inFavoris(dir);
|
const favView = !results && inFavoris(dir);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => setMenu(null), []);
|
||||||
|
// La liste change de forme : le bouton « … » d'origine disparaît.
|
||||||
|
useEffect(closeMenu, [view, dir, results, closeMenu]);
|
||||||
|
|
||||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -139,7 +149,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
setTags(tagList);
|
setTags(tagList);
|
||||||
// Tag filtré qui n'existe plus : filtre retiré.
|
// Tag filtré qui n'existe plus : filtre retiré.
|
||||||
setTagFilter((t) => (t && tagList.some((x) => x.name === t) ? t : null));
|
setTagFilter((t) => (t && tagList.some((x) => x.name === t) ? t : null));
|
||||||
setMenuFor(null);
|
setMenu(null);
|
||||||
// Oublie les éléments sélectionnés qui ont disparu.
|
// Oublie les éléments sélectionnés qui ont disparu.
|
||||||
setSelected((s) => {
|
setSelected((s) => {
|
||||||
if (s.size === 0) return s;
|
if (s.size === 0) return s;
|
||||||
@@ -309,7 +319,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
files.map((e) => e.path),
|
files.map((e) => e.path),
|
||||||
fav,
|
fav,
|
||||||
);
|
);
|
||||||
setMenuFor(null);
|
setMenu(null);
|
||||||
if (favView) clearSelection();
|
if (favView) clearSelection();
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
@@ -497,55 +507,90 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
|
|
||||||
// Dans Favoris : actions sur l'original, retrait du lien à la place de la suppression.
|
// Dans Favoris : actions sur l'original, retrait du lien à la place de la suppression.
|
||||||
const favActions = (e: Entry) => (
|
const favActions = (e: Entry) => (
|
||||||
<div className="actions">
|
<>
|
||||||
{!e.broken && (
|
{!e.broken && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setDir(dirname(e.path))}>Ouvrir l'emplacement</button>
|
<button role="menuitem" onClick={() => setDir(dirname(e.path))}>
|
||||||
<button onClick={() => onHistory(e.path)}>Historique…</button>
|
<FolderIcon />
|
||||||
<button onClick={() => onShare(e.path)}>Partager…</button>
|
Ouvrir l'emplacement
|
||||||
<button onClick={() => setTagging([e])}>
|
</button>
|
||||||
|
<button role="menuitem" onClick={() => onHistory(e.path)}>
|
||||||
|
<HistoryIcon />
|
||||||
|
Historique…
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" onClick={() => onShare(e.path)}>
|
||||||
|
<ShareIcon />
|
||||||
|
Partager…
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" onClick={() => setTagging([e])}>
|
||||||
<TagIcon />
|
<TagIcon />
|
||||||
Tags…
|
Tags…
|
||||||
</button>
|
</button>
|
||||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
<a role="menuitem" href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
|
<DownloadIcon />
|
||||||
Télécharger
|
Télécharger
|
||||||
</a>
|
</a>
|
||||||
|
<hr />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button className="danger" onClick={() => removeMany([e.path])}>
|
<button role="menuitem" className="danger" onClick={() => removeMany([e.path])}>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
Retirer des favoris
|
Retirer des favoris
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions = (e: Entry) => (
|
const actions = (e: Entry) => (
|
||||||
<div className="actions">
|
<>
|
||||||
<button onClick={() => rename(e)}>Renommer</button>
|
<button role="menuitem" onClick={() => rename(e)}>
|
||||||
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
<EditIcon />
|
||||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
Renommer
|
||||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
</button>
|
||||||
{!e.isDir && <button onClick={() => onShare(e.path)}>Partager…</button>}
|
<button role="menuitem" onClick={() => setMoving([e.path])}>
|
||||||
|
<MoveIcon />
|
||||||
|
Déplacer…
|
||||||
|
</button>
|
||||||
{!e.isDir && (
|
{!e.isDir && (
|
||||||
<button onClick={() => toggleFavorite([e])}>
|
<button role="menuitem" onClick={() => duplicate(e)}>
|
||||||
|
<CopyIcon />
|
||||||
|
Dupliquer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!e.isDir && (
|
||||||
|
<button role="menuitem" onClick={() => onHistory(e.path)}>
|
||||||
|
<HistoryIcon />
|
||||||
|
Historique…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!e.isDir && (
|
||||||
|
<button role="menuitem" onClick={() => onShare(e.path)}>
|
||||||
|
<ShareIcon />
|
||||||
|
Partager…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!e.isDir && (
|
||||||
|
<button role="menuitem" onClick={() => toggleFavorite([e])}>
|
||||||
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
||||||
{e.favorite ? "Retirer des favoris" : "Favori"}
|
{e.favorite ? "Retirer des favoris" : "Favori"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setTagging([e])}>
|
<button role="menuitem" onClick={() => setTagging([e])}>
|
||||||
<TagIcon />
|
<TagIcon />
|
||||||
Tags…
|
Tags…
|
||||||
</button>
|
</button>
|
||||||
{!e.isDir && (
|
{!e.isDir && (
|
||||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
<a role="menuitem" href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
|
<DownloadIcon />
|
||||||
Télécharger
|
Télécharger
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button className="danger" onClick={() => removeMany([e.path])}>
|
<hr />
|
||||||
|
<button role="menuitem" className="danger" onClick={() => removeMany([e.path])}>
|
||||||
|
<TrashIcon />
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
|
{!e.isDir && <span className="muted size">{fmtSize(e.size)}</span>}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const item = (e: Hit) => {
|
const item = (e: Hit) => {
|
||||||
@@ -559,7 +604,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
e.broken ? "broken" : "",
|
e.broken ? "broken" : "",
|
||||||
dropTarget === e.path ? "drop" : "",
|
dropTarget === e.path ? "drop" : "",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
return [
|
return (
|
||||||
<li key={e.path} className={cls}>
|
<li key={e.path} className={cls}>
|
||||||
<div
|
<div
|
||||||
className="row"
|
className="row"
|
||||||
@@ -617,24 +662,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
||||||
{!e.special && (
|
{!e.special && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn more"
|
className={`icon-btn more ${menu?.entry.path === e.path ? "open" : ""}`}
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menu?.entry.path === e.path}
|
||||||
title="Actions"
|
title="Actions"
|
||||||
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
|
onClick={(ev) => {
|
||||||
|
const anchor = ev.currentTarget;
|
||||||
|
setMenu((m) => (m?.entry.path === e.path ? null : { entry: e, anchor }));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DotsIcon />
|
<DotsIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{view === "list" && menuFor === e.path && (favView ? favActions(e) : actions(e))}
|
|
||||||
</li>,
|
|
||||||
// En grille, le menu occupe toute la largeur sous la vignette.
|
|
||||||
view === "grid" && menuFor === e.path && (
|
|
||||||
<li key={`${e.path}#menu`} className="menu-row">
|
|
||||||
{favView ? favActions(e) : actions(e)}
|
|
||||||
</li>
|
</li>
|
||||||
),
|
);
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -832,7 +875,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{list.flatMap(item)}
|
{list.map(item)}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<li className="muted empty">
|
<li className="muted empty">
|
||||||
{results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : "Dossier vide"}
|
{results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : "Dossier vide"}
|
||||||
@@ -857,6 +900,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{menu && (
|
||||||
|
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
|
||||||
|
{favView ? favActions(menu.entry) : actions(menu.entry)}
|
||||||
|
</ActionMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{moving && (
|
{moving && (
|
||||||
<MoveDialog
|
<MoveDialog
|
||||||
paths={moving}
|
paths={moving}
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ export const CopyIcon = icon(
|
|||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const EditIcon = icon(<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4M13.5 6.5l4 4" />);
|
||||||
|
|
||||||
export const HistoryIcon = icon(
|
export const HistoryIcon = icon(
|
||||||
<>
|
<>
|
||||||
<path d="M12 8v4l2 2" />
|
<path d="M12 8v4l2 2" />
|
||||||
|
|||||||
+60
-5
@@ -550,6 +550,64 @@ li.active > .row {
|
|||||||
height: var(--ex-btn);
|
height: var(--ex-btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu « … » d'un élément : liste déroulante, comme les menus d'Excalidraw. */
|
||||||
|
.icon-btn.more.open {
|
||||||
|
background: var(--ex-surface-high);
|
||||||
|
}
|
||||||
|
.menu-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 40; /* au-dessus du tiroir et du panneau flottant, sous les dialogues */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: var(--ex-radius-lg);
|
||||||
|
box-shadow: var(--ex-shadow-island), 0 0 0 1px var(--border);
|
||||||
|
}
|
||||||
|
.menu-popup button,
|
||||||
|
.menu-popup a {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.menu-popup button:hover:not(:disabled),
|
||||||
|
.menu-popup a:hover,
|
||||||
|
.menu-popup button:focus-visible,
|
||||||
|
.menu-popup a:focus-visible {
|
||||||
|
background: var(--ex-surface-high);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.menu-popup .danger:hover:not(:disabled),
|
||||||
|
.menu-popup .danger:focus-visible {
|
||||||
|
background: var(--ex-danger-bg);
|
||||||
|
}
|
||||||
|
.menu-popup hr {
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.menu-popup .size {
|
||||||
|
padding: 4px 0.625rem 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
/* Tactile : cibles plus hautes. */
|
||||||
|
.menu-popup button,
|
||||||
|
.menu-popup a {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Explorateur non épinglé (bureau) : panneau flottant sur le canevas ------ */
|
/* --- Explorateur non épinglé (bureau) : panneau flottant sur le canevas ------ */
|
||||||
|
|
||||||
.peek-edge {
|
.peek-edge {
|
||||||
@@ -759,8 +817,7 @@ li.active > .row {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.entries.grid > .empty,
|
.entries.grid > .empty {
|
||||||
.entries.grid > .menu-row {
|
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.entries.grid .row {
|
.entries.grid .row {
|
||||||
@@ -819,6 +876,7 @@ li.active > .row {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.entries.grid .item:hover .more,
|
.entries.grid .item:hover .more,
|
||||||
|
.entries.grid .more.open,
|
||||||
.entries.grid .more:focus-visible {
|
.entries.grid .more:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -827,9 +885,6 @@ li.active > .row {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.entries.grid .actions {
|
|
||||||
padding: 4px 2px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-ghost {
|
.drag-ghost {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in new issue
Block a user