mirror of
https://github.com/multipleof4/octopus-file-browser.git
synced 2026-09-25 12:35:25 +00:00
Add an opt-in context menu for files and directories
Apps set browser.contextMenu to a list of { label, action, when?, danger? }
items; when(entry, path) scopes an item to directories, files, or any
other match. Right-click, the Menu key, or a touch long press opens it.
The demo adds sample items, including a .json-only one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
:root{color:#181c21;font-synthesis:none;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}body{min-width:280px;margin:0}main{max-width:1220px;margin:0 auto;padding:20px}@media (width<=640px){main{padding:8px}}
|
||||
:root{color:#181c21;font-synthesis:none;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}body{min-width:280px;margin:0}main{max-width:1220px;margin:0 auto;padding:20px}@media (width<=640px){main{padding:8px}}.demo-status{color:#57606a;min-height:20px;margin:8px 2px 0;font-size:13px}
|
||||
File diff suppressed because one or more lines are too long
+3
-2
@@ -6,12 +6,13 @@
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>octopus-file-browser</title>
|
||||
<script type="module" crossorigin src="/assets/index-CRttpLjx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-rQJBLWWV.css">
|
||||
<script type="module" crossorigin src="/assets/index-BJSID0yX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bx92WdqS.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<octopus-file-browser></octopus-file-browser>
|
||||
<p class="demo-status" role="status">Right-click or long-press a file or folder.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<octopus-file-browser></octopus-file-browser>
|
||||
<p class="demo-status" role="status">Right-click or long-press a file or folder.</p>
|
||||
</main>
|
||||
<script type="module" src="/src/demo.js"></script>
|
||||
</body>
|
||||
|
||||
Vendored
+9
-1
@@ -13,6 +13,13 @@ export interface OctopusOpenDetail {
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export interface OctopusMenuItem {
|
||||
label: string;
|
||||
action: (entry: OctopusEntry, path: string[]) => void;
|
||||
when?: (entry: OctopusEntry, path: string[]) => boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface OctopusNavigateDetail {
|
||||
path: string[];
|
||||
}
|
||||
@@ -25,6 +32,7 @@ export interface OctopusFileBrowserEventMap {
|
||||
export class OctopusFileBrowser extends HTMLElement {
|
||||
entries: OctopusEntry[];
|
||||
path: string[];
|
||||
contextMenu: OctopusMenuItem[];
|
||||
addEventListener<K extends keyof OctopusFileBrowserEventMap>(
|
||||
type: K,
|
||||
listener: (this: OctopusFileBrowser, event: OctopusFileBrowserEventMap[K]) => void,
|
||||
@@ -39,7 +47,7 @@ export class OctopusFileBrowser extends HTMLElement {
|
||||
|
||||
export function createOctopusFileBrowser(
|
||||
target: Element,
|
||||
options?: { entries?: OctopusEntry[]; path?: string[]; emptyLabel?: string },
|
||||
options?: { entries?: OctopusEntry[]; path?: string[]; contextMenu?: OctopusMenuItem[]; emptyLabel?: string },
|
||||
): OctopusFileBrowser;
|
||||
|
||||
export function fileKindLabel(name?: string): string;
|
||||
|
||||
@@ -15,3 +15,10 @@ main {
|
||||
@media (max-width: 640px) {
|
||||
main { padding: 8px; }
|
||||
}
|
||||
|
||||
.demo-status {
|
||||
color: #57606a;
|
||||
font-size: 13px;
|
||||
margin: 8px 2px 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
+81
-10
@@ -1,19 +1,90 @@
|
||||
import './demo.css';
|
||||
import './octopus-file-browser.js';
|
||||
import { formatFileSize } from './octopus-file-browser.js';
|
||||
import tree from 'virtual:octopus-repo-tree';
|
||||
|
||||
const browser = document.querySelector('octopus-file-browser');
|
||||
const status = document.querySelector('.demo-status');
|
||||
const say = (text) => { status.textContent = text; };
|
||||
const pathText = (path) => `/${path.join('/')}`;
|
||||
const isDirectory = (entry) => entry.type === 'directory';
|
||||
|
||||
// Stands in for an app backend: returns one directory listing per request.
|
||||
function list(path) {
|
||||
// Stands in for an app backend: `tree` is its filesystem and `list` returns one directory per request.
|
||||
function directory(path) {
|
||||
return path.reduce((entries, name) =>
|
||||
entries.find((entry) => entry.type === 'directory' && entry.name === name)?.children || [], tree)
|
||||
.map(({ children, ...entry }) => entry);
|
||||
entries.find((entry) => isDirectory(entry) && entry.name === name)?.children || [], tree);
|
||||
}
|
||||
|
||||
browser.addEventListener('octopus:navigate', ({ detail }) => {
|
||||
browser.path = detail.path;
|
||||
browser.entries = list(detail.path);
|
||||
});
|
||||
function list(path) {
|
||||
return directory(path).map(({ children, ...entry }) => entry);
|
||||
}
|
||||
|
||||
browser.entries = list([]);
|
||||
function show(path) {
|
||||
browser.path = path;
|
||||
browser.entries = list(path);
|
||||
}
|
||||
|
||||
function stored(path) {
|
||||
return directory(path.slice(0, -1)).find((entry) => entry.name === path.at(-1));
|
||||
}
|
||||
|
||||
browser.contextMenu = [
|
||||
{ label: 'Open', when: isDirectory, action: (entry, path) => show(path) },
|
||||
{
|
||||
label: 'New folder…',
|
||||
when: isDirectory,
|
||||
action: (entry, path) => {
|
||||
const name = prompt(`New folder in ${pathText(path)}`)?.trim();
|
||||
if (!name) return;
|
||||
directory(path).push({ name, type: 'directory', modified: new Date().toISOString(), children: [] });
|
||||
say(`Created ${pathText([...path, name])}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Download',
|
||||
when: (entry) => entry.type === 'file',
|
||||
action: (entry, path) => say(`"Download" on ${pathText(path)} (${formatFileSize(entry.size)})`),
|
||||
},
|
||||
{
|
||||
label: 'Validate JSON',
|
||||
when: (entry) => entry.type === 'file' && entry.name.toLowerCase().endsWith('.json'),
|
||||
action: (entry, path) => say(`"Validate JSON" on ${pathText(path)}`),
|
||||
},
|
||||
{
|
||||
label: 'Copy path',
|
||||
action: async (entry, path) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(pathText(path));
|
||||
say(`Copied ${pathText(path)}`);
|
||||
} catch {
|
||||
say(`Could not copy ${pathText(path)}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Rename…',
|
||||
action: (entry, path) => {
|
||||
const name = prompt(`Rename ${entry.name}`, entry.name)?.trim();
|
||||
if (!name || name === entry.name) return;
|
||||
if (list(browser.path).some((sibling) => sibling.name === name)) return say(`${name} already exists`);
|
||||
stored(path).name = name;
|
||||
browser.entries = list(browser.path);
|
||||
say(`Renamed ${pathText(path)} to ${name}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
action: (entry, path) => {
|
||||
if (!confirm(`Delete ${pathText(path)}?`)) return;
|
||||
const siblings = directory(path.slice(0, -1));
|
||||
siblings.splice(siblings.indexOf(stored(path)), 1);
|
||||
browser.entries = list(browser.path);
|
||||
say(`Deleted ${pathText(path)}`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
browser.addEventListener('octopus:navigate', ({ detail }) => show(detail.path));
|
||||
browser.addEventListener('octopus:open', ({ detail }) => say(`Opened ${pathText(detail.path)}`));
|
||||
|
||||
show([]);
|
||||
|
||||
@@ -52,3 +52,7 @@ export function fileKindLabel(name = '') {
|
||||
png: 'Image', svg: 'SVG', yml: 'YAML', yaml: 'YAML', ts: 'TypeScript',
|
||||
})[extension] || (extension ? extension.toUpperCase() : 'File');
|
||||
}
|
||||
|
||||
export function menuItemsFor(items = [], entry, path) {
|
||||
return items.filter((item) => !item.when || item.when(entry, path));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
--ofb-hover: #f6f8fa;
|
||||
--ofb-focus: #0969da;
|
||||
--ofb-recent: #d15700;
|
||||
--ofb-danger: #cf222e;
|
||||
color: var(--ofb-text);
|
||||
display: block;
|
||||
font: 14px/20px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto,
|
||||
@@ -39,6 +40,9 @@
|
||||
min-height: 37px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -76,6 +80,48 @@ li:first-child .entry { border-top: 0; }
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: #fff;
|
||||
border: 1px solid var(--ofb-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.25);
|
||||
color: var(--ofb-text);
|
||||
font: inherit;
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
max-height: calc(100vh - 8px);
|
||||
max-width: min(280px, calc(100vw - 8px));
|
||||
min-width: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font: inherit;
|
||||
overflow: hidden;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-item:hover, .menu-item:focus-visible { background: var(--ofb-hover); outline: 0; }
|
||||
.menu-item.danger { color: var(--ofb-danger); }
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.menu-item { padding: 11px 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.entry {
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
|
||||
+126
-2
@@ -1,8 +1,10 @@
|
||||
import styles from './octopus-file-browser.css?inline';
|
||||
import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries } from './file-browser-utils.js';
|
||||
import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, menuItemsFor, sortEntries } from './file-browser-utils.js';
|
||||
import { resolveIcon } from './icon-resolver.js';
|
||||
|
||||
const HTMLElementBase = globalThis.HTMLElement || class {};
|
||||
const LONG_PRESS_MS = 500;
|
||||
const LONG_PRESS_SLOP = 10;
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -14,6 +16,10 @@ function element(tag, className, text) {
|
||||
export class OctopusFileBrowser extends HTMLElementBase {
|
||||
#entries = [];
|
||||
#path = [];
|
||||
#contextMenu = [];
|
||||
#menu = null;
|
||||
#menuRow = null;
|
||||
#press = null;
|
||||
|
||||
static get observedAttributes() { return ['empty-label']; }
|
||||
|
||||
@@ -23,6 +29,7 @@ export class OctopusFileBrowser extends HTMLElementBase {
|
||||
}
|
||||
|
||||
connectedCallback() { this.#render(); }
|
||||
disconnectedCallback() { this.#cancelPress(); this.#closeMenu(); }
|
||||
attributeChangedCallback() { if (this.isConnected) this.#render(); }
|
||||
|
||||
set entries(value) {
|
||||
@@ -39,6 +46,13 @@ export class OctopusFileBrowser extends HTMLElementBase {
|
||||
|
||||
get path() { return [...this.#path]; }
|
||||
|
||||
set contextMenu(value) {
|
||||
this.#contextMenu = Array.isArray(value) ? value : [];
|
||||
this.#closeMenu();
|
||||
}
|
||||
|
||||
get contextMenu() { return this.#contextMenu; }
|
||||
|
||||
#emit(name, detail) {
|
||||
this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
|
||||
}
|
||||
@@ -49,6 +63,111 @@ export class OctopusFileBrowser extends HTMLElementBase {
|
||||
this.#emit('octopus:open', { entry, path: [...this.#path, entry.name] });
|
||||
}
|
||||
|
||||
#bindMenu(row, entry) {
|
||||
row.addEventListener('contextmenu', (event) => {
|
||||
const items = menuItemsFor(this.#contextMenu, entry, [...this.#path, entry.name]);
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
this.#cancelPress();
|
||||
if (this.#menuRow === row) return; // Already opened by a long press.
|
||||
const fromKeyboard = !event.clientX && !event.clientY;
|
||||
const rect = row.getBoundingClientRect();
|
||||
this.#openMenu(row, entry, items, fromKeyboard ? rect.left + 16 : event.clientX, fromKeyboard ? rect.bottom : event.clientY);
|
||||
});
|
||||
|
||||
// iOS never fires contextmenu for touch, so long presses are detected here.
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
if (event.pointerType !== 'touch' || !this.#contextMenu.length) return;
|
||||
this.#cancelPress();
|
||||
const { clientX: x, clientY: y } = event;
|
||||
const timer = setTimeout(() => {
|
||||
this.#press = null;
|
||||
const items = menuItemsFor(this.#contextMenu, entry, [...this.#path, entry.name]);
|
||||
if (items.length && this.#menuRow !== row) this.#openMenu(row, entry, items, x, y);
|
||||
}, LONG_PRESS_MS);
|
||||
this.#press = { timer, x, y };
|
||||
});
|
||||
row.addEventListener('pointermove', (event) => {
|
||||
if (this.#press && Math.hypot(event.clientX - this.#press.x, event.clientY - this.#press.y) > LONG_PRESS_SLOP)
|
||||
this.#cancelPress();
|
||||
});
|
||||
for (const type of ['pointerup', 'pointercancel', 'pointerleave'])
|
||||
row.addEventListener(type, () => this.#cancelPress());
|
||||
}
|
||||
|
||||
#cancelPress() {
|
||||
if (!this.#press) return;
|
||||
clearTimeout(this.#press.timer);
|
||||
this.#press = null;
|
||||
}
|
||||
|
||||
#openMenu(row, entry, items, x, y) {
|
||||
this.#closeMenu();
|
||||
const path = [...this.#path, entry.name];
|
||||
const menu = element('div', 'menu');
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', entry.name);
|
||||
menu.setAttribute('popover', 'manual');
|
||||
for (const item of items) {
|
||||
const button = element('button', item.danger ? 'menu-item danger' : 'menu-item', item.label);
|
||||
button.type = 'button';
|
||||
button.tabIndex = -1;
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.addEventListener('click', () => {
|
||||
this.#closeMenu(true);
|
||||
item.action?.(entry, path);
|
||||
});
|
||||
menu.append(button);
|
||||
}
|
||||
menu.addEventListener('keydown', (event) => this.#onMenuKey(event));
|
||||
this.shadowRoot.append(menu);
|
||||
menu.showPopover?.();
|
||||
|
||||
const { width, height } = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(4, Math.min(x, innerWidth - width - 4))}px`;
|
||||
menu.style.top = `${y + height > innerHeight - 4 ? Math.max(4, y - height) : y}px`;
|
||||
|
||||
this.#menu = menu;
|
||||
this.#menuRow = row;
|
||||
document.addEventListener('pointerdown', this.#onOutsidePointer, true);
|
||||
addEventListener('scroll', this.#onDismiss, true);
|
||||
addEventListener('resize', this.#onDismiss);
|
||||
addEventListener('blur', this.#onDismiss);
|
||||
menu.firstChild.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
#closeMenu(restoreFocus = false) {
|
||||
if (!this.#menu) return;
|
||||
const row = this.#menuRow;
|
||||
this.#menu.remove();
|
||||
this.#menu = null;
|
||||
this.#menuRow = null;
|
||||
document.removeEventListener('pointerdown', this.#onOutsidePointer, true);
|
||||
removeEventListener('scroll', this.#onDismiss, true);
|
||||
removeEventListener('resize', this.#onDismiss);
|
||||
removeEventListener('blur', this.#onDismiss);
|
||||
if (restoreFocus && row.isConnected) row.focus();
|
||||
}
|
||||
|
||||
#onOutsidePointer = (event) => {
|
||||
if (!event.composedPath().includes(this.#menu)) this.#closeMenu();
|
||||
};
|
||||
|
||||
#onDismiss = () => this.#closeMenu();
|
||||
|
||||
#onMenuKey(event) {
|
||||
const items = [...this.#menu.children];
|
||||
const index = items.indexOf(this.shadowRoot.activeElement);
|
||||
const next = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 }[event.key];
|
||||
if (next !== undefined) {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length].focus();
|
||||
} else if (event.key === 'Escape' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.#closeMenu(true);
|
||||
}
|
||||
}
|
||||
|
||||
#renderEntry(entry) {
|
||||
const item = element('li');
|
||||
const row = element('button', 'entry');
|
||||
@@ -57,7 +176,9 @@ export class OctopusFileBrowser extends HTMLElementBase {
|
||||
row.setAttribute('aria-label', entry.parent
|
||||
? 'Parent folder'
|
||||
: `${entry.name}, ${entry.type === 'directory' ? 'folder' : fileKindLabel(entry.name)}`);
|
||||
row.addEventListener('click', () => this.#open(entry));
|
||||
// While a menu is open, a click is the release of the long press that opened it.
|
||||
row.addEventListener('click', () => { if (!this.#menu) this.#open(entry); });
|
||||
if (!entry.parent) this.#bindMenu(row, entry);
|
||||
|
||||
const name = element('span', 'cell name');
|
||||
const icon = element('img', 'icon');
|
||||
@@ -77,6 +198,8 @@ export class OctopusFileBrowser extends HTMLElementBase {
|
||||
|
||||
#render() {
|
||||
if (!this.shadowRoot || typeof document === 'undefined') return;
|
||||
this.#cancelPress();
|
||||
this.#closeMenu();
|
||||
const style = element('style');
|
||||
style.textContent = styles;
|
||||
const list = element('ul', 'list');
|
||||
@@ -95,6 +218,7 @@ if (globalThis.customElements && !customElements.get('octopus-file-browser'))
|
||||
export function createOctopusFileBrowser(target, options = {}) {
|
||||
const browser = document.createElement('octopus-file-browser');
|
||||
if (options.emptyLabel) browser.setAttribute('empty-label', options.emptyLabel);
|
||||
if (options.contextMenu) browser.contextMenu = options.contextMenu;
|
||||
browser.path = options.path || [];
|
||||
browser.entries = options.entries || [];
|
||||
target.append(browser);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries } from '../src/file-browser-utils.js';
|
||||
import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, menuItemsFor, sortEntries } from '../src/file-browser-utils.js';
|
||||
|
||||
test('sortEntries keeps folders first and names natural', () => {
|
||||
const sorted = sortEntries([
|
||||
@@ -36,3 +36,15 @@ test('fileKindLabel identifies special and common files', () => {
|
||||
assert.equal(fileKindLabel('app.js'), 'JavaScript');
|
||||
assert.equal(fileKindLabel('Dockerfile'), 'File');
|
||||
});
|
||||
|
||||
test('menuItemsFor keeps items whose when() matches the entry', () => {
|
||||
const items = [
|
||||
{ label: 'All' },
|
||||
{ label: 'Folders', when: (entry) => entry.type === 'directory' },
|
||||
{ label: 'JSON', when: (entry, path) => entry.name.endsWith('.json') && path.length === 2 },
|
||||
];
|
||||
const labels = (entry, path) => menuItemsFor(items, entry, path).map(({ label }) => label);
|
||||
assert.deepEqual(labels({ name: 'src', type: 'directory' }, ['src']), ['All', 'Folders']);
|
||||
assert.deepEqual(labels({ name: 'a.json', type: 'file' }, ['src', 'a.json']), ['All', 'JSON']);
|
||||
assert.deepEqual(menuItemsFor(undefined, { name: 'a', type: 'file' }, ['a']), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user