Refactor context menus and popups

- add dividers (sections)
- stop using overlay for branch popup
- click outside to dismiss (without canceling click)
This commit is contained in:
Mattias Granlund 2023-12-12 23:24:33 +01:00
parent 954d51c853
commit 70d7347ae3
12 changed files with 143 additions and 151 deletions

View File

@ -1,4 +1,4 @@
export type ClickOpts = { trigger?: HTMLElement; handler: () => void; enabled: boolean };
export type ClickOpts = { trigger?: HTMLElement; handler: () => void; enabled?: boolean };
export function clickOutside(
node: HTMLElement,
@ -14,13 +14,14 @@ export function clickOutside(
params.handler();
}
}
document.addEventListener('click', onClick, true);
return {
destroy() {
document.removeEventListener('click', onClick, true);
},
update(opts: ClickOpts) {
document.removeEventListener('click', onClick, true);
if (!opts.enabled) return;
if (opts.enabled !== undefined && !opts.enabled) return;
trigger = opts.trigger;
document.addEventListener('click', onClick, true);
}

View File

@ -19,7 +19,7 @@
}
let container: HTMLDivElement;
let popup: HTMLDivElement;
let contextMenuContainer: HTMLDivElement;
let iconElt: HTMLElement;
</script>
@ -50,12 +50,12 @@
/>
</div>
<div
class="context-wrapper"
class="context-menu-container"
use:clickOutside={{ trigger: iconElt, handler: () => (visible = !visible), enabled: visible }}
bind:this={popup}
bind:this={contextMenuContainer}
style:display={visible ? 'block' : 'none'}
>
<slot name="popup" />
<slot name="context-menu" />
</div>
</div>
@ -103,7 +103,7 @@
align-items: center;
}
.context-wrapper {
.context-menu-container {
position: absolute;
right: 0;
bottom: 100%;

View File

@ -1,4 +1,6 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
let pos = { x: 0, y: 0 };
let menu = { h: 0, w: 0 };
let browser = { h: 0, w: 0 };
@ -46,25 +48,19 @@
{#if showMenu}
<div
class="popup-overlay"
role="menu"
tabindex="0"
class="absolute left-0 top-0 z-50 h-full w-full shadow-2xl"
on:click={onDismiss}
on:keydown={onDismiss}
use:recordDimensions
use:clickOutside={{ handler: () => onDismiss() }}
style="position: absolute; top:{pos.y}px; left:{pos.x}px"
>
<div
role="menu"
tabindex="0"
use:recordDimensions
on:mouseleave={onDismiss}
on:blur={onDismiss}
style="position: absolute; top:{pos.y}px; left:{pos.x}px"
class="flex flex-col rounded border border-light-400 bg-white p-1 drop-shadow-[0_10px_10px_rgba(0,0,0,0.30)] dark:border-dark-500 dark:bg-dark-700"
>
<slot {item} />
</div>
<slot {item} />
</div>
{/if}
<style>
.popup-overlay {
z-index: 50; /* Must be higher than scrollbar hover */
}
</style>

View File

@ -26,8 +26,6 @@
display: flex;
flex-direction: column;
background: var(--clr-theme-container-pale);
padding: var(--space-8);
gap: var(--space-2);
border: 1px solid var(--clr-theme-container-outline-light);
border-radius: var(--radius-m);
}

View File

@ -30,10 +30,14 @@
<Icon name={icon} />
{/if}
{#if context.type == 'checklist'}
<Icon name={checked ? 'tick-small' : 'empty'} />
{#if checked}
<Icon name="tick-small" />
{:else}
<Icon name="empty-checkbox-small" opacity="0.2" />
{/if}
{/if}
<div class="label">
<div class="label text-base-12">
{label}
</div>
</button>
@ -43,6 +47,7 @@
display: flex;
align-items: center;
color: var(--clr-theme-scale-ntrl-0);
height: var(--space-24);
padding: var(--space-4) var(--space-6);
border-radius: var(--radius-s);
gap: var(--space-8);

View File

@ -0,0 +1,18 @@
<script lang="ts">
</script>
<div class="context-menu-section">
<slot />
</div>
<style lang="postcss">
.context-menu-section {
display: flex;
flex-direction: column;
padding: var(--space-8);
gap: var(--space-2);
&:not(:first-child) {
border-top: 1px solid var(--clr-theme-container-outline-light);
}
}
</style>

View File

@ -7,6 +7,7 @@
export let name: keyof typeof iconsJson;
export let color: IconColor = undefined;
export let opacity: string | undefined = undefined;
</script>
<svg
@ -19,6 +20,7 @@
class:pop={color == 'pop'}
class:warn={color == 'warn'}
class:default={!color}
style:opacity
>
<path fill="currentColor" d={iconsJson[name]}></path>
</svg>

View File

@ -9,7 +9,7 @@
type DraggableHunk
} from '$lib/draggables';
import { Ownership } from '$lib/vbranches/ownership';
import { getExpandedWithCacheFallback, setExpandedWithCache } from './cache';
import { getExpandedWithCacheFallback } from './cache';
import type { BranchController } from '$lib/vbranches/branchController';
import type { User, getCloudApiClient } from '$lib/backend/cloud';
import Resizer from '$lib/components/Resizer.svelte';
@ -65,18 +65,6 @@
$allCollapsed = branch.files.every((f) => getExpandedWithCacheFallback(f) == false);
}
function handleCollapseAll() {
branch.files.forEach((f) => setExpandedWithCache(f, false));
$allExpanded = false;
branch.files = branch.files;
}
function handleExpandAll() {
branch.files.forEach((f) => setExpandedWithCache(f, true));
$allExpanded = true;
branch.files = branch.files;
}
let commitDialogShown = false;
$: if (commitDialogShown && branch.files.length === 0) {
@ -160,15 +148,9 @@
{readonly}
{branchController}
{branch}
{allCollapsed}
{allExpanded}
projectId={project.id}
on:action={(e) => {
if (e.detail == 'expand') {
handleExpandAll();
} else if (e.detail == 'collapse') {
handleCollapseAll();
} else if (e.detail == 'generate-branch-name') {
if (e.detail == 'generate-branch-name') {
generateBranchName();
}
}}

View File

@ -6,43 +6,22 @@
import { fade } from 'svelte/transition';
import BranchLabel from './BranchLabel.svelte';
import BranchLanePopupMenu from './BranchLanePopupMenu.svelte';
import type { Writable } from 'svelte/store';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { clickOutside } from '$lib/clickOutside';
export let readonly = false;
export let branch: Branch;
export let allExpanded: Writable<boolean>;
export let allCollapsed: Writable<boolean>;
export let branchController: BranchController;
export let projectId: string;
const dispatch = createEventDispatcher<{ action: string }>();
let meatballButton: HTMLDivElement;
// We have to create this manually for now.
// TODO: Use document.body.addEventListener to avoid having to use backdrop
let popupMenu = new BranchLanePopupMenu({
target: document.body,
props: { allExpanded, allCollapsed, branchController, projectId }
});
let visible = false;
function handleBranchNameChange() {
branchController.updateBranchName(branch.id, branch.name);
}
onMount(() => {
return popupMenu.$on('action', (e) => {
dispatch('action', e.detail);
});
});
onDestroy(() => {
popupMenu.$destroy();
});
</script>
<div class="card__header" data-drag-handle>
<div class="card__header relative" data-drag-handle>
<div class="header__left">
{#if !readonly}
<div class="draggable" data-drag-handle>
@ -54,17 +33,22 @@
<div class="flex items-center gap-x-1" transition:fade={{ duration: 150 }}>
{#if !readonly}
<div bind:this={meatballButton}>
<IconButton
icon="kebab"
size="m"
on:click={() => popupMenu.openByElement(meatballButton, branch)}
/>
<IconButton icon="kebab" size="m" on:click={() => (visible = !visible)} />
</div>
<div
class="branch-popup-menu"
use:clickOutside={{ trigger: meatballButton, handler: () => (visible = false) }}
>
<BranchLanePopupMenu {branchController} {branch} {projectId} bind:visible on:action />
</div>
{/if}
</div>
</div>
<style lang="postcss">
.card__header {
position: relative;
}
.card__header:hover .draggable {
color: var(--clr-theme-scale-ntrl-40);
}
@ -83,4 +67,11 @@
color: var(--clr-theme-scale-ntrl-60);
transition: color var(--transition-medium);
}
.branch-popup-menu {
position: absolute;
top: calc(var(--space-2) + var(--space-40));
right: var(--space-12);
z-index: 10;
}
</style>

View File

@ -1,18 +1,19 @@
<script lang="ts">
import PopupMenu from '$lib/components/PopupMenu.svelte';
import Modal from '$lib/components/Modal.svelte';
import Button from '$lib/components/Button.svelte';
import PopupMenuItem from '$lib/components/PopupMenuItem.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import type { BranchController } from '$lib/vbranches/branchController';
import { createEventDispatcher } from 'svelte';
import type { Writable } from 'svelte/store';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import type { Branch } from '$lib/vbranches/types';
export let branchController: BranchController;
export let branch: Branch;
export let projectId: string;
export let allCollapsed: Writable<boolean | undefined>;
export let allExpanded: Writable<boolean | undefined>;
let popupMenu: PopupMenu;
export let visible: boolean;
let deleteBranchModal: Modal;
const dispatch = createEventDispatcher<{
@ -20,63 +21,55 @@
}>();
const aiGenEnabled = projectAiGenEnabled(projectId);
export function openByMouse(e: MouseEvent, item: any) {
popupMenu.openByMouse(e, item);
}
export function openByElement(elt: HTMLElement, item: any) {
popupMenu.openByElement(elt, item);
}
</script>
<PopupMenu bind:this={popupMenu} let:item={branch}>
<PopupMenuItem on:click={() => branch.id && branchController.unapplyBranch(branch.id)}>
Unapply
</PopupMenuItem>
{#if visible}
<ContextMenu>
<ContextMenuSection>
<ContextMenuItem
label="Unapply"
on:click={() => branch.id && branchController.unapplyBranch(branch.id)}
/>
<PopupMenuItem on:click={() => deleteBranchModal.show(branch)}>Delete</PopupMenuItem>
<ContextMenuItem label="Delete" on:click={() => deleteBranchModal.show(branch)} />
<PopupMenuItem on:click={() => dispatch('action', 'expand')} disabled={$allExpanded}>
Expand all
</PopupMenuItem>
<ContextMenuItem
label="Generate branch name"
on:click={() => dispatch('action', 'generate-branch-name')}
disabled={!$aiGenEnabled}
/>
</ContextMenuSection>
<PopupMenuItem on:click={() => dispatch('action', 'collapse')} disabled={$allCollapsed}>
Collapse all
</PopupMenuItem>
<ContextMenuSection>
<ContextMenuItem
label="Create branch before"
on:click={() => branchController.createBranch({ order: branch.order })}
/>
<PopupMenuItem
on:click={() => dispatch('action', 'generate-branch-name')}
disabled={!$aiGenEnabled}
>
Generate branch name
</PopupMenuItem>
<ContextMenuItem
label="Create branch after"
on:click={() => branchController.createBranch({ order: branch.order + 1 })}
/>
</ContextMenuSection>
</ContextMenu>
<div class="mx-3">
<div class="bg-color-3 my-2 h-[0.0625rem] w-full" />
</div>
<Modal width="small" title="Delete branch" bind:this={deleteBranchModal} let:item={branch}>
<div>
Deleting <code>{branch.name}</code> cannot be undone.
</div>
<svelte:fragment slot="controls" let:close let:item={branch}>
<Button kind="outlined" on:click={close}>Cancel</Button>
<Button
color="error"
on:click={async () => {
await branchController.deleteBranch(branch.id);
}}
>
Delete
</Button>
</svelte:fragment>
</Modal>
{/if}
<PopupMenuItem on:click={() => branchController.createBranch({ order: branch.order })}>
Create branch before
</PopupMenuItem>
<PopupMenuItem on:click={() => branchController.createBranch({ order: branch.order + 1 })}>
Create branch after
</PopupMenuItem>
</PopupMenu>
<Modal width="small" title="Delete branch" bind:this={deleteBranchModal} let:item={branch}>
<div>
Deleting <code>{branch.name}</code> cannot be undone.
</div>
<svelte:fragment slot="controls" let:close let:item={branch}>
<Button kind="outlined" on:click={close}>Cancel</Button>
<Button
color="error"
on:click={async () => {
await branchController.deleteBranch(branch.id);
}}
>
Delete
</Button>
</svelte:fragment>
</Modal>
<style lang="postcss">
</style>

View File

@ -21,6 +21,7 @@
import Tooltip from '$lib/components/Tooltip.svelte';
import type { Writable } from 'svelte/store';
import { createEventDispatcher } from 'svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
@ -127,17 +128,19 @@
on:click={() => generateCommitMessage(branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="popup" bind:this={contextMenu}>
<ContextMenuItem
checked={$commitGenerationExtraConcise}
label="Extra concise"
on:click={() => commitGenerationExtraConcise.update((value) => !value)}
/>
<ContextMenuItem
checked={$commitGenerationUseEmojis}
label="Use emojis 😎"
on:click={() => commitGenerationUseEmojis.update((value) => !value)}
/>
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
checked={$commitGenerationExtraConcise}
label="Extra concise"
on:click={() => commitGenerationExtraConcise.update((value) => !value)}
/>
<ContextMenuItem
checked={$commitGenerationUseEmojis}
label="Use emojis 😎"
on:click={() => commitGenerationUseEmojis.update((value) => !value)}
/>
</ContextMenuSection>
</ContextMenu>
</DropDown>
</Tooltip>

View File

@ -5,6 +5,7 @@
import DropDown from '$lib/components/DropDown.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
let disabled = false;
@ -37,19 +38,21 @@
{$selection$?.label}
<ContextMenu
type="select"
slot="popup"
slot="context-menu"
bind:this={contextMenu}
on:select={(e) => {
$createPr = e.detail?.id == 'pr';
dropDown.close();
}}
>
<ContextMenuItem id="push" label="Push to remote" selected={mode == 'push'} />
<ContextMenuItem
id="pr"
label="Create Pull Request"
disabled={!githubContext}
selected={mode == 'pr'}
/>
<ContextMenuSection>
<ContextMenuItem id="push" label="Push to remote" selected={mode == 'push'} />
<ContextMenuItem
id="pr"
label="Create Pull Request"
disabled={!githubContext}
selected={mode == 'pr'}
/>
</ContextMenuSection>
</ContextMenu>
</DropDown>