mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-28 20:15:20 +03:00
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:
parent
954d51c853
commit
70d7347ae3
@ -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);
|
||||
}
|
||||
|
@ -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%;
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
}}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user