feat(ui:items): normalize context menu
This commit is contained in:
@@ -53,13 +53,22 @@
|
|||||||
/**
|
/**
|
||||||
* Optional per-item visibility gate. Called at menu-open time
|
* Optional per-item visibility gate. Called at menu-open time
|
||||||
* with the target item + context; return `false` to hide the
|
* with the target item + context; return `false` to hide the
|
||||||
* entry for that row. Synchronous by contract — pages that need
|
* entry entirely for that row (e.g. `open_parent` on a drive-
|
||||||
* an async check (e.g. "does the caller have Read on the parent
|
* root folder that has no parent to open). Prefer `disabled?`
|
||||||
* folder?") should pre-warm a cache when items load so the
|
* over hiding when the action *could* apply but the caller
|
||||||
* answer is already resolved by the time this runs. See
|
* lacks the required permission — a greyed entry answers
|
||||||
* `$lib/utils/folderAccess.ts` for the reference pattern.
|
* "this option exists" for the user instead of leaving a hole
|
||||||
|
* that reads as a forgotten feature.
|
||||||
*/
|
*/
|
||||||
visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
|
visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
|
||||||
|
/**
|
||||||
|
* Optional per-item disabled gate. Called at menu-open time;
|
||||||
|
* `true` renders the entry non-interactive (dimmed, no click).
|
||||||
|
* Kept sync by the same contract as `visible?` — use the
|
||||||
|
* `menuPrepare` prop to prime any cache the predicate depends
|
||||||
|
* on before the menu renders.
|
||||||
|
*/
|
||||||
|
disabled?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
|
||||||
run: (item: FileItem | FolderItem, ctx?: ItemContext) => void;
|
run: (item: FileItem | FolderItem, ctx?: ItemContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1352,12 +1361,17 @@
|
|||||||
data-testid="resource-list-context-menu"
|
data-testid="resource-list-context-menu"
|
||||||
>
|
>
|
||||||
{#each visibleActions as action (action.key)}
|
{#each visibleActions as action (action.key)}
|
||||||
|
{@const dis = action.disabled?.(ctxItem!, ctxOf(ctxItem!.id)) === true}
|
||||||
<button
|
<button
|
||||||
class="rl-ctx-item"
|
class="rl-ctx-item"
|
||||||
class:rl-ctx-item--danger={action.danger}
|
class:rl-ctx-item--danger={action.danger}
|
||||||
|
class:rl-ctx-item--disabled={dis}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
disabled={dis}
|
||||||
|
aria-disabled={dis}
|
||||||
data-testid={`resource-list-context-${action.key}-item`}
|
data-testid={`resource-list-context-${action.key}-item`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
if (dis) return;
|
||||||
const target = ctxItem!;
|
const target = ctxItem!;
|
||||||
closeContext();
|
closeContext();
|
||||||
action.run(target, ctxOf(target.id));
|
action.run(target, ctxOf(target.id));
|
||||||
@@ -1542,10 +1556,24 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rl-ctx-item:hover {
|
.rl-ctx-item:hover:not(:disabled) {
|
||||||
background: var(--color-bg-hover);
|
background: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled entry — dimmed but STILL RENDERED so the user sees that
|
||||||
|
the option exists and infers "I can't do this here" rather than
|
||||||
|
assuming a forgotten feature. No `cursor: not-allowed` badge on
|
||||||
|
hover (deliberate — a forbidden-sign cursor reads as alarming for
|
||||||
|
an entry the user didn't try to activate). The `disabled`
|
||||||
|
attribute alone still blocks click + keyboard activation and
|
||||||
|
flags the element to assistive tech via `aria-disabled`. */
|
||||||
|
.rl-ctx-item--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.rl-ctx-item--disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.rl-ctx-item--danger {
|
.rl-ctx-item--danger {
|
||||||
/* Danger *foreground* on the light menu surface — the red accent, not
|
/* Danger *foreground* on the light menu surface — the red accent, not
|
||||||
--color-danger-text (white, for text ON a red fill, invisible here). */
|
--color-danger-text (white, for text ON a red fill, invisible here). */
|
||||||
|
|||||||
@@ -236,13 +236,20 @@
|
|||||||
key: 'open_parent',
|
key: 'open_parent',
|
||||||
label: t('files.open_parent', 'Open parent folder'),
|
label: t('files.open_parent', 'Open parent folder'),
|
||||||
icon: 'folder-open',
|
icon: 'folder-open',
|
||||||
// Sync gate on the pre-warmed folder-access cache (see
|
// Hidden only when there's literally no parent to open
|
||||||
// `warmFolderAccess` in `load()` below). `undefined` = not
|
// (drive-root folders where `parent_id === null`); otherwise
|
||||||
// yet probed → hide; the entry appears once the probe
|
// the entry is always visible and shows up disabled when the
|
||||||
// resolves to `true`.
|
// caller lacks read on the parent — a greyed row reads as
|
||||||
visible: (item) => {
|
// "you can't do this here" instead of "the option is missing."
|
||||||
|
// `folderAccessCached` returns `true`/`false`/`undefined`;
|
||||||
|
// disabled fires when the answer is explicitly `false`. On
|
||||||
|
// first right-click of a fresh row, `menuPrepare` below has
|
||||||
|
// primed the cache so the entry either enables or disables
|
||||||
|
// without a "flash of enabled" beforehand.
|
||||||
|
visible: (item) => parentFolderId(item) !== null,
|
||||||
|
disabled: (item) => {
|
||||||
const pid = parentFolderId(item);
|
const pid = parentFolderId(item);
|
||||||
return pid !== null && folderAccessCached(pid) === true;
|
return pid === null || folderAccessCached(pid) === false;
|
||||||
},
|
},
|
||||||
run: (item) => {
|
run: (item) => {
|
||||||
const pid = parentFolderId(item);
|
const pid = parentFolderId(item);
|
||||||
@@ -274,6 +281,17 @@
|
|||||||
moveOpen = true;
|
moveOpen = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Every row on /favorites IS a favorite, so the entry is always
|
||||||
|
// "Remove favorite" — no per-item state lookup needed. Mirrors
|
||||||
|
// the star-widget behaviour: click, row un-stars, disappears
|
||||||
|
// from the list on next reload. Placed between Move and Rename
|
||||||
|
// to match the canonical context-menu order on `/files`.
|
||||||
|
key: 'unfavorite',
|
||||||
|
label: t('files.unfavorite', 'Remove favorite'),
|
||||||
|
icon: 'star-outline',
|
||||||
|
run: (item) => void unfavorite(item)
|
||||||
|
},
|
||||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||||
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1920,7 +1920,7 @@
|
|||||||
const tg = ctxTarget!;
|
const tg = ctxTarget!;
|
||||||
closeContext();
|
closeContext();
|
||||||
openShare(tg.kind, tg.id, tg.name);
|
openShare(tg.kind, tg.id, tg.name);
|
||||||
}}><Icon name="link" /> {t('files.share', 'Share')}</button
|
}}><Icon name="share-alt" /> {t('files.share', 'Share')}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="ctx-item"
|
class="ctx-item"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
type RecentResourceItem
|
type RecentResourceItem
|
||||||
} from '$lib/api/endpoints/recent';
|
} from '$lib/api/endpoints/recent';
|
||||||
import {
|
import {
|
||||||
|
addFavorite,
|
||||||
dateBucket,
|
dateBucket,
|
||||||
resolveOwnerName,
|
resolveOwnerName,
|
||||||
sizeBucket,
|
sizeBucket,
|
||||||
@@ -268,14 +269,16 @@
|
|||||||
key: 'open_parent',
|
key: 'open_parent',
|
||||||
label: t('files.open_parent', 'Open parent folder'),
|
label: t('files.open_parent', 'Open parent folder'),
|
||||||
icon: 'folder-open',
|
icon: 'folder-open',
|
||||||
// Sync gate: relies on the `warmFolderAccess` call in `load()`
|
// Same disabled-not-hidden pattern as /favorites: hide only
|
||||||
// having populated the cache with a boolean answer for each
|
// when there's no parent (drive-root folder), otherwise
|
||||||
// visible row's parent id by the time the user right-clicks.
|
// show and disable when the caller lacks Read on the
|
||||||
// `undefined` (not yet probed) is treated as "hide" — the
|
// parent. `menuPrepare` primes the cache before the menu
|
||||||
// entry appears once the probe resolves to `true`.
|
// renders so the final enabled/disabled state is correct
|
||||||
visible: (item) => {
|
// on the very first right-click of a row.
|
||||||
|
visible: (item) => parentFolderId(item) !== null,
|
||||||
|
disabled: (item) => {
|
||||||
const pid = parentFolderId(item);
|
const pid = parentFolderId(item);
|
||||||
return pid !== null && folderAccessCached(pid) === true;
|
return pid === null || folderAccessCached(pid) === false;
|
||||||
},
|
},
|
||||||
run: (item) => {
|
run: (item) => {
|
||||||
const pid = parentFolderId(item);
|
const pid = parentFolderId(item);
|
||||||
@@ -307,6 +310,21 @@
|
|||||||
moveOpen = true;
|
moveOpen = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// "Add to favorites" — /recent doesn't track per-row favorite
|
||||||
|
// state (the star widget was replaced by the broom), so the
|
||||||
|
// entry always reads "Add" and the backend swallows duplicate
|
||||||
|
// adds idempotently. If the user wants to un-favorite, they
|
||||||
|
// navigate to /favorites and use the row menu there. Placed
|
||||||
|
// between Move and Rename to match the canonical context-menu
|
||||||
|
// order on `/files`.
|
||||||
|
key: 'favorite',
|
||||||
|
label: t('files.favorite', 'Add favorite'),
|
||||||
|
icon: 'star',
|
||||||
|
run: (item) => {
|
||||||
|
void addFavorite(kindOf(item), item.id).catch(errorToast);
|
||||||
|
}
|
||||||
|
},
|
||||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||||
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { errorMessage } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { primeContextPage } from '$lib/utils/listContext';
|
import { primeContextPage } from '$lib/utils/listContext';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites';
|
import {
|
||||||
|
addFavorite,
|
||||||
|
dateBucket,
|
||||||
|
resolveOwnerName,
|
||||||
|
typeLabel
|
||||||
|
} from '$lib/api/endpoints/favorites';
|
||||||
|
import { fileDownloadUrl } from '$lib/api/endpoints/files';
|
||||||
|
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
||||||
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
||||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
import type { FileItem, FolderItem } from '$lib/api/types';
|
||||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||||
import ResourceList, {
|
import ResourceList, {
|
||||||
isFile,
|
isFile,
|
||||||
|
type ContextAction,
|
||||||
type GroupByDef,
|
type GroupByDef,
|
||||||
type ItemContext
|
type ItemContext
|
||||||
} from '$lib/components/ResourceList.svelte';
|
} from '$lib/components/ResourceList.svelte';
|
||||||
@@ -130,6 +138,59 @@
|
|||||||
viewerOpen = true;
|
viewerOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off a download using an ephemeral `<a download>` so the file
|
||||||
|
* saves to disk instead of navigating away. Files stream directly
|
||||||
|
* from `/api/files/{id}/content`; folders come back as a server-
|
||||||
|
* side zip via `/api/folders/{id}/zip`.
|
||||||
|
*/
|
||||||
|
function downloadItem(item: FileItem | FolderItem) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = isFile(item) ? fileDownloadUrl(item.id) : folderZipUrl(item.id);
|
||||||
|
a.download = isFile(item) ? item.name : `${item.name}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu ordering mirrors `/files` / `/favorites` / `/recent`:
|
||||||
|
// Download / Download as ZIP → (later entries as we grow the menu)
|
||||||
|
// Favorite → (destructive actions if / when introduced)
|
||||||
|
//
|
||||||
|
// Kind-gated download: files show "Download" (direct stream);
|
||||||
|
// folders show "Download as ZIP" (server-side archive). Two entries
|
||||||
|
// with `visible?` predicates rather than one label that changes,
|
||||||
|
// so the `.icon` reads correctly per kind too.
|
||||||
|
//
|
||||||
|
// The favorite entry stays "Add to favorites" only: un-favoriting
|
||||||
|
// from here would need per-row favorite-state tracking which this
|
||||||
|
// view doesn't carry — users toggle off from /favorites' own row
|
||||||
|
// menu. Backend swallows duplicate `addFavorite` calls idempotently.
|
||||||
|
const contextActions: ContextAction[] = [
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
label: t('common.download', 'Download'),
|
||||||
|
icon: 'download',
|
||||||
|
visible: (item) => isFile(item),
|
||||||
|
run: downloadItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'download_zip',
|
||||||
|
label: t('files.download_zip', 'Download as ZIP'),
|
||||||
|
icon: 'download',
|
||||||
|
visible: (item) => !isFile(item),
|
||||||
|
run: downloadItem
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'favorite',
|
||||||
|
label: t('files.favorite', 'Add favorite'),
|
||||||
|
icon: 'star',
|
||||||
|
run: (item) => {
|
||||||
|
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
onMount(() => load(true));
|
onMount(() => load(true));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -161,6 +222,7 @@
|
|||||||
{items}
|
{items}
|
||||||
{contextMap}
|
{contextMap}
|
||||||
resolveOwnerName={(id) => sharers.name(id)}
|
resolveOwnerName={(id) => sharers.name(id)}
|
||||||
|
{contextActions}
|
||||||
{loading}
|
{loading}
|
||||||
{error}
|
{error}
|
||||||
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
|
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
|
||||||
|
|||||||
Reference in New Issue
Block a user