Branch name input improvments (#3507)

* Resizer hooks improved

- Resizer hook updated in order to get an accurate value without post adjustment
- Naming inconsistency fixed in `useResizer`

* Refactor: name input

- return initial name if a user trying to submit an empty branch name
- removed extra elements and CSS
- Input handling with less code

* trim lane name
This commit is contained in:
Pavel Laptev 2024-04-13 22:07:40 +02:00 committed by GitHub
parent 7908d0a195
commit 6c25a7d5bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 130 additions and 167 deletions

View File

@ -31,6 +31,8 @@
let branchName = branch?.upstreamName || normalizeBranchName($branchStore.name);
function handleBranchNameChange(title: string) {
if (title == '') return;
branchName = normalizeBranchName(title);
branchController.updateBranchName(branch.id, title);
}
@ -87,40 +89,44 @@
</div>
{:else}
<div class="header__wrapper">
<div class="header card" class:isUnapplied>
<div class="header__info">
<div class="header__label">
<div class="header card">
<div class="header__info-wrapper">
{#if !isUnapplied}
<div class="draggable" data-drag-handle>
<Icon name="draggable" />
</div>
{/if}
<div class="header__info">
<BranchLabel
name={branch.name || 'hello'}
name={branch.name}
on:change={(e) => handleBranchNameChange(e.detail.name)}
disabled={isUnapplied}
/>
</div>
<div class="header__remote-branch">
<ActiveBranchStatus
branchName={branch.upstreamName ?? branchName}
{isUnapplied}
{hasIntegratedCommits}
remoteExists={!!branch.upstreamName}
isLaneCollapsed={$isLaneCollapsed}
/>
<div class="header__remote-branch">
<ActiveBranchStatus
branchName={branch.upstreamName ?? branchName}
{isUnapplied}
{hasIntegratedCommits}
remoteExists={!!branch.upstreamName}
isLaneCollapsed={$isLaneCollapsed}
/>
{#await branch.isMergeable then isMergeable}
{#if !isMergeable}
<Tag
icon="locked-small"
style="warning"
help="Applying this branch will add merge conflict markers that you will have to resolve"
>
Conflict
</Tag>
{/if}
{/await}
</div>
<div class="draggable" data-drag-handle>
<Icon name="draggable" />
{#await branch.isMergeable then isMergeable}
{#if !isMergeable}
<Tag
icon="locked-small"
style="warning"
help="Applying this branch will add merge conflict markers that you will have to resolve"
>
Conflict
</Tag>
{/if}
{/await}
</div>
</div>
</div>
<div class="header__actions">
<div class="header__buttons">
{#if branch.active}
@ -229,7 +235,7 @@
</div>
{/if}
<style lang="postcss">
<style>
.header__wrapper {
z-index: var(--z-lifted);
position: sticky;
@ -240,15 +246,6 @@
position: relative;
flex-direction: column;
gap: var(--size-2);
&:hover {
& .draggable {
opacity: 1;
}
}
&.isUnapplied {
background: var(--clr-bg-alt);
}
}
.header__top-overlay {
z-index: var(--z-ground);
@ -258,13 +255,17 @@
width: 100%;
height: var(--size-20);
background: var(--target-branch-background);
/* background-color: red; */
}
.header__info-wrapper {
display: flex;
gap: var(--size-2);
padding: var(--size-10);
}
.header__info {
flex: 1;
display: flex;
flex-direction: column;
transition: margin var(--transition-slow);
padding: var(--size-12);
overflow: hidden;
gap: var(--size-10);
}
.header__actions {
@ -276,31 +277,19 @@
border-radius: 0 0 var(--radius-m) var(--radius-m);
user-select: none;
}
.isUnapplied .header__actions {
border-top: 1px solid var(--clr-border-main);
}
.header__buttons {
display: flex;
position: relative;
gap: var(--size-4);
}
.header__label {
display: flex;
flex-grow: 1;
align-items: center;
gap: var(--size-4);
}
.draggable {
display: flex;
height: fit-content;
cursor: grab;
position: absolute;
right: var(--size-4);
top: var(--size-6);
opacity: 0;
padding: var(--size-2) var(--size-2) 0 0;
color: var(--clr-scale-ntrl-50);
transition:
opacity var(--transition-slow),
color var(--transition-slow);
transition: color var(--transition-slow);
&:hover {
color: var(--clr-scale-ntrl-40);
@ -326,7 +315,7 @@
align-items: center;
}
/* COLLAPSABLE LANE */
/* COLLAPSIBLE LANE */
.collapsed-lane {
cursor: default;

View File

@ -1,119 +1,97 @@
<script lang="ts">
import { useResize } from '$lib/utils/useResize';
import { createEventDispatcher } from 'svelte';
export let name: string;
export let disabled = false;
let inputActive = false;
let label: HTMLDivElement;
let input: HTMLInputElement;
function activateInput() {
if (disabled) return;
inputActive = true;
setTimeout(() => input.select(), 0);
}
let inputEl: HTMLInputElement;
let initialName = name;
let mesureEl: HTMLSpanElement;
let inputWidth = 0;
let inputWidth: string | undefined;
const dispatch = createEventDispatcher<{
change: { name: string };
}>();
$: {
if (mesureEl) {
inputWidth = mesureEl.getBoundingClientRect().width;
}
}
</script>
{#if inputActive}
<span class="branch-name-mesure-el text-base-13 text-bold" bind:this={mesureEl}>{name}</span>
<input
type="text"
{disabled}
bind:this={input}
bind:value={name}
on:change={(e) => dispatch('change', { name: e.currentTarget.value })}
on:input={() => {
if (input.value.length > 0) {
inputWidth = mesureEl.getBoundingClientRect().width;
} else {
inputWidth = 0;
}
}}
title={name}
class="branch-name-input text-base-13 text-bold"
on:dblclick|stopPropagation
on:blur={() => (inputActive = false)}
on:keydown={(e) => {
if (e.key == 'Enter') {
// Unmount input field asynchronously to ensure on:change gets executed.
setTimeout(() => (inputActive = false), 0);
setTimeout(() => label.focus(), 0);
}
if (e.key == 'Escape') {
inputActive = false;
name = initialName;
setTimeout(() => label.focus(), 0);
}
}}
autocomplete="off"
autocorrect="off"
spellcheck="false"
style={`width: calc(${inputWidth}px + var(--size-12))`}
/>
{:else}
<div
bind:this={label}
role="textbox"
tabindex="0"
class="branch-name text-base-13 text-bold truncate"
on:keydown={(e) => e.key == 'Enter' && activateInput()}
on:mousedown={activateInput}
>
{name}
</div>
{/if}
<span
use:useResize={(frame) => {
inputWidth = `${Math.round(frame.width)}px`;
}}
class="branch-name-mesure-el text-base-14 text-bold"
bind:this={mesureEl}>{name}</span
>
<input
type="text"
{disabled}
bind:this={inputEl}
bind:value={name}
on:change={(e) => dispatch('change', { name: e.currentTarget.value.trim() })}
title={name}
class="branch-name-input text-base-14 text-bold"
on:dblclick|stopPropagation
on:click|stopPropagation={() => {
inputEl.focus();
}}
on:blur={() => {
if (name == '') name = initialName;
}}
on:focus={() => {
initialName = name;
}}
on:keydown={(e) => {
if (e.key == 'Enter' || e.key == 'Escape') {
inputEl.blur();
}
}}
autocomplete="off"
autocorrect="off"
spellcheck="false"
style:width={inputWidth}
/>
<style lang="postcss">
.branch-name,
.branch-name-mesure-el,
.branch-name-input {
min-width: 2.8rem;
height: var(--size-20);
pointer-events: auto;
color: var(--clr-scale-ntrl-0);
padding: var(--size-2) var(--size-4);
border-radius: var(--radius-s);
border: 1px solid transparent;
}
.branch-name {
cursor: text;
display: inline-block;
transition: background-color var(--transition-fast);
&:hover,
&:focus {
background-color: var(--clr-bg-muted);
outline: none;
}
}
.branch-name-mesure-el {
pointer-events: auto;
visibility: hidden;
border: 2px solid transparent;
top: 30px;
color: black;
position: absolute;
display: inline-block;
visibility: hidden;
white-space: pre;
}
.branch-name-input {
min-width: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
width: 100%;
border-radius: var(--radius-s);
color: var(--clr-scale-ntrl-0);
background-color: var(--clr-bg-main);
outline: none;
&:focus {
/* not readonly */
&:not([disabled]):hover {
background-color: var(--clr-bg-muted);
}
&:not([disabled]):focus {
outline: none;
background-color: var(--clr-bg-muted);
border-color: var(--clr-border-main);
}
}
</style>

View File

@ -22,8 +22,6 @@
const branchController = getContext(BranchController);
const project = getContext(Project);
let meatballButton: HTMLDivElement;
let container: HTMLDivElement;
let isApplying = false;
function updateContextMenu(copyablePrUrl: string) {
@ -44,11 +42,9 @@
</script>
<div class="header__wrapper">
<div class="header card" bind:this={container}>
<div class="header card">
<div class="header__info">
<div class="header__label">
<BranchLabel bind:name={branch.name} />
</div>
<BranchLabel disabled bind:name={branch.name} />
<div class="header__remote-branch">
<div
class="status-tag text-base-11 text-semibold remote"
@ -90,8 +86,7 @@
</div>
</div>
<div class="header__actions">
<div class="header__buttons"></div>
<div class="relative" bind:this={meatballButton}>
<div class="header__buttons">
<Button
style="ghost"
kind="solid"
@ -145,15 +140,16 @@
display: flex;
flex-direction: column;
transition: margin var(--transition-slow);
padding: var(--size-12);
padding: var(--size-10);
gap: var(--size-10);
overflow: hidden;
}
.header__actions {
display: flex;
gap: var(--size-4);
background: var(--clr-bg-alt);
padding: var(--size-14);
justify-content: space-between;
justify-content: flex-end;
border-radius: 0 0 var(--radius-m) var(--radius-m);
user-select: none;
}
@ -162,12 +158,6 @@
position: relative;
gap: var(--size-4);
}
.header__label {
display: flex;
flex-grow: 1;
align-items: center;
gap: var(--size-4);
}
.header__remote-branch {
color: var(--clr-scale-ntrl-50);

View File

@ -19,7 +19,7 @@
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { setAutoHeight } from '$lib/utils/useAutoHeight';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { BranchController } from '$lib/vbranches/branchController';
import { Ownership } from '$lib/vbranches/ownership';
@ -69,8 +69,8 @@
}
function updateHeights() {
setAutoHeight(titleTextArea);
setAutoHeight(descriptionTextArea);
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
async function commit() {
@ -148,9 +148,9 @@
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
setAutoHeight(titleTextArea);
useAutoHeight(titleTextArea);
}}
on:focus={(e) => setAutoHeight(e.currentTarget)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(e.currentTarget.value, description);
}}
@ -172,8 +172,8 @@
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => setAutoHeight(descriptionTextArea)}
on:focus={(e) => setAutoHeight(e.currentTarget)}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(title, e.currentTarget.value);
}}
@ -182,7 +182,7 @@
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
setAutoHeight(e.currentTarget);
useAutoHeight(e.currentTarget);
} else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();

View File

@ -1,5 +1,5 @@
export function setAutoHeight(element: HTMLTextAreaElement) {
export function useAutoHeight(element: HTMLTextAreaElement) {
if (!element) return;
element.style.height = 'auto';
element.style.height = `${element.scrollHeight + 2}px`;
element.style.height = `${element.scrollHeight}px`;
}

View File

@ -1,9 +1,15 @@
export function useResize(element: HTMLElement, callback: (width: number, height: number) => void) {
export function useResize(
element: HTMLElement,
callback: (frame: { width: number; height: number }) => void
) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const { inlineSize, blockSize } = entry.borderBoxSize[0];
callback(width, height);
callback({
width: Math.round(inlineSize),
height: Math.round(blockSize)
});
}
});