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:
2026-09-23 18:50:21 -07:00
co-authored by Claude Opus 5.5
parent 13e2654415
commit d8b8012e40
12 changed files with 292 additions and 18 deletions
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
View File
@@ -6,12 +6,13 @@
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<title>octopus-file-browser</title> <title>octopus-file-browser</title>
<script type="module" crossorigin src="/assets/index-CRttpLjx.js"></script> <script type="module" crossorigin src="/assets/index-BJSID0yX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-rQJBLWWV.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bx92WdqS.css">
</head> </head>
<body> <body>
<main> <main>
<octopus-file-browser></octopus-file-browser> <octopus-file-browser></octopus-file-browser>
<p class="demo-status" role="status">Right-click or long-press a file or folder.</p>
</main> </main>
</body> </body>
</html> </html>
+1
View File
@@ -10,6 +10,7 @@
<body> <body>
<main> <main>
<octopus-file-browser></octopus-file-browser> <octopus-file-browser></octopus-file-browser>
<p class="demo-status" role="status">Right-click or long-press a file or folder.</p>
</main> </main>
<script type="module" src="/src/demo.js"></script> <script type="module" src="/src/demo.js"></script>
</body> </body>
+9 -1
View File
@@ -13,6 +13,13 @@ export interface OctopusOpenDetail {
path: string[]; path: string[];
} }
export interface OctopusMenuItem {
label: string;
action: (entry: OctopusEntry, path: string[]) => void;
when?: (entry: OctopusEntry, path: string[]) => boolean;
danger?: boolean;
}
export interface OctopusNavigateDetail { export interface OctopusNavigateDetail {
path: string[]; path: string[];
} }
@@ -25,6 +32,7 @@ export interface OctopusFileBrowserEventMap {
export class OctopusFileBrowser extends HTMLElement { export class OctopusFileBrowser extends HTMLElement {
entries: OctopusEntry[]; entries: OctopusEntry[];
path: string[]; path: string[];
contextMenu: OctopusMenuItem[];
addEventListener<K extends keyof OctopusFileBrowserEventMap>( addEventListener<K extends keyof OctopusFileBrowserEventMap>(
type: K, type: K,
listener: (this: OctopusFileBrowser, event: OctopusFileBrowserEventMap[K]) => void, listener: (this: OctopusFileBrowser, event: OctopusFileBrowserEventMap[K]) => void,
@@ -39,7 +47,7 @@ export class OctopusFileBrowser extends HTMLElement {
export function createOctopusFileBrowser( export function createOctopusFileBrowser(
target: Element, target: Element,
options?: { entries?: OctopusEntry[]; path?: string[]; emptyLabel?: string }, options?: { entries?: OctopusEntry[]; path?: string[]; contextMenu?: OctopusMenuItem[]; emptyLabel?: string },
): OctopusFileBrowser; ): OctopusFileBrowser;
export function fileKindLabel(name?: string): string; export function fileKindLabel(name?: string): string;
+7
View File
@@ -15,3 +15,10 @@ main {
@media (max-width: 640px) { @media (max-width: 640px) {
main { padding: 8px; } main { padding: 8px; }
} }
.demo-status {
color: #57606a;
font-size: 13px;
margin: 8px 2px 0;
min-height: 20px;
}
+81 -10
View File
@@ -1,19 +1,90 @@
import './demo.css'; import './demo.css';
import './octopus-file-browser.js'; import { formatFileSize } from './octopus-file-browser.js';
import tree from 'virtual:octopus-repo-tree'; import tree from 'virtual:octopus-repo-tree';
const browser = document.querySelector('octopus-file-browser'); 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. // Stands in for an app backend: `tree` is its filesystem and `list` returns one directory per request.
function list(path) { function directory(path) {
return path.reduce((entries, name) => return path.reduce((entries, name) =>
entries.find((entry) => entry.type === 'directory' && entry.name === name)?.children || [], tree) entries.find((entry) => isDirectory(entry) && entry.name === name)?.children || [], tree);
.map(({ children, ...entry }) => entry);
} }
browser.addEventListener('octopus:navigate', ({ detail }) => { function list(path) {
browser.path = detail.path; return directory(path).map(({ children, ...entry }) => entry);
browser.entries = list(detail.path); }
});
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([]);
+4
View File
@@ -52,3 +52,7 @@ export function fileKindLabel(name = '') {
png: 'Image', svg: 'SVG', yml: 'YAML', yaml: 'YAML', ts: 'TypeScript', png: 'Image', svg: 'SVG', yml: 'YAML', yaml: 'YAML', ts: 'TypeScript',
})[extension] || (extension ? extension.toUpperCase() : 'File'); })[extension] || (extension ? extension.toUpperCase() : 'File');
} }
export function menuItemsFor(items = [], entry, path) {
return items.filter((item) => !item.when || item.when(entry, path));
}
+46
View File
@@ -7,6 +7,7 @@
--ofb-hover: #f6f8fa; --ofb-hover: #f6f8fa;
--ofb-focus: #0969da; --ofb-focus: #0969da;
--ofb-recent: #d15700; --ofb-recent: #d15700;
--ofb-danger: #cf222e;
color: var(--ofb-text); color: var(--ofb-text);
display: block; display: block;
font: 14px/20px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, font: 14px/20px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto,
@@ -39,6 +40,9 @@
min-height: 37px; min-height: 37px;
padding: 0; padding: 0;
text-align: left; text-align: left;
-webkit-touch-callout: none;
user-select: none;
-webkit-user-select: none;
width: 100%; width: 100%;
} }
@@ -76,6 +80,48 @@ li:first-child .entry { border-top: 0; }
text-align: center; 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) { @media (max-width: 640px) {
.entry { .entry {
grid-template-columns: minmax(0, 1fr) 104px; grid-template-columns: minmax(0, 1fr) 104px;
+126 -2
View File
@@ -1,8 +1,10 @@
import styles from './octopus-file-browser.css?inline'; 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'; import { resolveIcon } from './icon-resolver.js';
const HTMLElementBase = globalThis.HTMLElement || class {}; const HTMLElementBase = globalThis.HTMLElement || class {};
const LONG_PRESS_MS = 500;
const LONG_PRESS_SLOP = 10;
function element(tag, className, text) { function element(tag, className, text) {
const node = document.createElement(tag); const node = document.createElement(tag);
@@ -14,6 +16,10 @@ function element(tag, className, text) {
export class OctopusFileBrowser extends HTMLElementBase { export class OctopusFileBrowser extends HTMLElementBase {
#entries = []; #entries = [];
#path = []; #path = [];
#contextMenu = [];
#menu = null;
#menuRow = null;
#press = null;
static get observedAttributes() { return ['empty-label']; } static get observedAttributes() { return ['empty-label']; }
@@ -23,6 +29,7 @@ export class OctopusFileBrowser extends HTMLElementBase {
} }
connectedCallback() { this.#render(); } connectedCallback() { this.#render(); }
disconnectedCallback() { this.#cancelPress(); this.#closeMenu(); }
attributeChangedCallback() { if (this.isConnected) this.#render(); } attributeChangedCallback() { if (this.isConnected) this.#render(); }
set entries(value) { set entries(value) {
@@ -39,6 +46,13 @@ export class OctopusFileBrowser extends HTMLElementBase {
get path() { return [...this.#path]; } get path() { return [...this.#path]; }
set contextMenu(value) {
this.#contextMenu = Array.isArray(value) ? value : [];
this.#closeMenu();
}
get contextMenu() { return this.#contextMenu; }
#emit(name, detail) { #emit(name, detail) {
this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, 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] }); 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) { #renderEntry(entry) {
const item = element('li'); const item = element('li');
const row = element('button', 'entry'); const row = element('button', 'entry');
@@ -57,7 +176,9 @@ export class OctopusFileBrowser extends HTMLElementBase {
row.setAttribute('aria-label', entry.parent row.setAttribute('aria-label', entry.parent
? 'Parent folder' ? 'Parent folder'
: `${entry.name}, ${entry.type === 'directory' ? 'folder' : fileKindLabel(entry.name)}`); : `${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 name = element('span', 'cell name');
const icon = element('img', 'icon'); const icon = element('img', 'icon');
@@ -77,6 +198,8 @@ export class OctopusFileBrowser extends HTMLElementBase {
#render() { #render() {
if (!this.shadowRoot || typeof document === 'undefined') return; if (!this.shadowRoot || typeof document === 'undefined') return;
this.#cancelPress();
this.#closeMenu();
const style = element('style'); const style = element('style');
style.textContent = styles; style.textContent = styles;
const list = element('ul', 'list'); const list = element('ul', 'list');
@@ -95,6 +218,7 @@ if (globalThis.customElements && !customElements.get('octopus-file-browser'))
export function createOctopusFileBrowser(target, options = {}) { export function createOctopusFileBrowser(target, options = {}) {
const browser = document.createElement('octopus-file-browser'); const browser = document.createElement('octopus-file-browser');
if (options.emptyLabel) browser.setAttribute('empty-label', options.emptyLabel); if (options.emptyLabel) browser.setAttribute('empty-label', options.emptyLabel);
if (options.contextMenu) browser.contextMenu = options.contextMenu;
browser.path = options.path || []; browser.path = options.path || [];
browser.entries = options.entries || []; browser.entries = options.entries || [];
target.append(browser); target.append(browser);
+13 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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', () => { test('sortEntries keeps folders first and names natural', () => {
const sorted = sortEntries([ const sorted = sortEntries([
@@ -36,3 +36,15 @@ test('fileKindLabel identifies special and common files', () => {
assert.equal(fileKindLabel('app.js'), 'JavaScript'); assert.equal(fileKindLabel('app.js'), 'JavaScript');
assert.equal(fileKindLabel('Dockerfile'), 'File'); 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']), []);
});