mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-02 07:53:55 +03:00
Add branch filtering to sidebar
This commit is contained in:
parent
5fe2ea0d0c
commit
d9f6249a55
104
packages/ui/src/lib/components/SegmentControl/Segment.svelte
Normal file
104
packages/ui/src/lib/components/SegmentControl/Segment.svelte
Normal file
@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
import type { SegmentContext } from './segment';
|
||||
import type iconsJson from '$lib/icons/icons.json';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
|
||||
export let id: string;
|
||||
export let disabled = false;
|
||||
export let icon: keyof typeof iconsJson | undefined = undefined;
|
||||
|
||||
let ref: HTMLButtonElement | undefined;
|
||||
const dispatcher = createEventDispatcher<{ select: string }>();
|
||||
|
||||
const context = getContext<SegmentContext>('SegmentedControl');
|
||||
const index = context.setIndex();
|
||||
const focusedSegmentIndex = context.focusedSegmentIndex;
|
||||
const selectedSegmentIndex = context.selectedSegmentIndex;
|
||||
const length = context.length;
|
||||
|
||||
$: isFocused = $focusedSegmentIndex === index;
|
||||
$: if (isFocused) {
|
||||
ref?.focus();
|
||||
}
|
||||
$: isSelected = $selectedSegmentIndex === index;
|
||||
|
||||
onMount(() => {
|
||||
context.addSegment({ id, index, disabled });
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={ref}
|
||||
class="btn"
|
||||
class:left={index == 0}
|
||||
class:right={index == $length - 1}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={disabled}
|
||||
tabindex={isSelected ? 0 : -1}
|
||||
{...$$restProps}
|
||||
on:click|preventDefault={() => {
|
||||
if (index !== $selectedSegmentIndex && !disabled) {
|
||||
context.setSelected(index);
|
||||
dispatcher('select', id);
|
||||
}
|
||||
}}
|
||||
on:keydown={({ key }) => {
|
||||
if (key === 'ArrowRight') {
|
||||
context.setSelected(index + 1);
|
||||
} else if (key === 'ArrowLeft') {
|
||||
context.setSelected(index - 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="text-base-12">
|
||||
<slot />
|
||||
</span>
|
||||
{#if icon}
|
||||
<Icon name={icon} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="postcss">
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
flex-basis: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
justify-content: center;
|
||||
background-color: var(--clr-theme-container-pale);
|
||||
border-color: var(--clr-theme-container-outline-light);
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
padding-left: var(--space-8);
|
||||
padding-right: var(--space-8);
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
color: var(--clr-theme-scale-ntrl-40);
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--clr-theme-container-light);
|
||||
border-left-width: 1px;
|
||||
color: var(--clr-theme-scale-ntrl-10);
|
||||
&.left {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
&.right {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
border-right-width: 1px;
|
||||
}
|
||||
&.left {
|
||||
border-top-left-radius: var(--radius-m);
|
||||
border-left-width: 1px;
|
||||
border-bottom-left-radius: var(--radius-m);
|
||||
}
|
||||
&.right {
|
||||
border-right-width: 1px;
|
||||
border-top-right-radius: var(--radius-m);
|
||||
border-bottom-right-radius: var(--radius-m);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { SegmentContext, SegmentItem } from './segment';
|
||||
|
||||
export let wide = false;
|
||||
export let selectedIndex = 0;
|
||||
|
||||
let dispatch = createEventDispatcher<{ select: string }>();
|
||||
|
||||
let indexesIterator = -1;
|
||||
let segments: SegmentItem[] = [];
|
||||
|
||||
let focusedSegmentIndex = writable(selectedIndex);
|
||||
let selectedSegmentIndex = writable(selectedIndex);
|
||||
let length = writable(0);
|
||||
|
||||
const context: SegmentContext = {
|
||||
focusedSegmentIndex,
|
||||
selectedSegmentIndex,
|
||||
length,
|
||||
setIndex: () => {
|
||||
indexesIterator += 1;
|
||||
return indexesIterator;
|
||||
},
|
||||
addSegment: ({ id, index, disabled }) => {
|
||||
segments = [...segments, { id, index, disabled }];
|
||||
length.set(segments.length);
|
||||
},
|
||||
setSelected: (segmentIndex) => {
|
||||
if (segmentIndex >= 0 && segmentIndex < segments.length) {
|
||||
$focusedSegmentIndex = segmentIndex;
|
||||
|
||||
if (!segments[segmentIndex].disabled) {
|
||||
$selectedSegmentIndex = $focusedSegmentIndex;
|
||||
dispatch('select', segments[segmentIndex].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
setContext<SegmentContext>('SegmentedControl', context);
|
||||
</script>
|
||||
|
||||
<div class="wrapper" class:wide>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.wrapper {
|
||||
display: inline-flex;
|
||||
&.wide {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
15
packages/ui/src/lib/components/SegmentControl/segment.ts
Normal file
15
packages/ui/src/lib/components/SegmentControl/segment.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface SegmentItem {
|
||||
id: string;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
}
|
||||
export interface SegmentContext {
|
||||
focusedSegmentIndex: Writable<number>;
|
||||
selectedSegmentIndex: Writable<number>;
|
||||
length: Writable<number>;
|
||||
setIndex(): number;
|
||||
addSegment(segment: SegmentItem): void;
|
||||
setSelected(index: number): void;
|
||||
}
|
59
packages/ui/src/lib/components/TextBox.svelte
Normal file
59
packages/ui/src/lib/components/TextBox.svelte
Normal file
@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type iconsJson from '$lib/icons/icons.json';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let icon: keyof typeof iconsJson | undefined = undefined;
|
||||
export let iconPosition: 'left' | 'right' = 'left';
|
||||
|
||||
const dispatch = createEventDispatcher<{ input: string }>();
|
||||
</script>
|
||||
|
||||
<div class="textbox">
|
||||
{#if icon && iconPosition == 'left'}
|
||||
<Icon name={icon} />
|
||||
{/if}
|
||||
<input
|
||||
type="text text-base-13"
|
||||
class="textbox__input"
|
||||
on:input={(e) => dispatch('input', e.currentTarget.value)}
|
||||
/>
|
||||
{#if icon && iconPosition == 'right'}
|
||||
<Icon name={icon} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.textbox {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: var(--clr-theme-scale-ntrl-50);
|
||||
background-color: var(--clr-theme-container-light);
|
||||
border: 1px solid var(--clr-theme-container-outline-light);
|
||||
border-radius: var(--radius-s);
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
padding-left: var(--space-12);
|
||||
padding-right: var(--space-12);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--clr-theme-container-outline-pale);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--clr-theme-container-outline-sub);
|
||||
}
|
||||
&:invalid {
|
||||
border-color: var(--clr-theme-err-element);
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--clr-theme-scale-ntrl-60);
|
||||
border-color: var(--clr-theme-err-element);
|
||||
background-color: var(--clr-theme-container-pale);
|
||||
}
|
||||
}
|
||||
.textbox__input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
@ -1,11 +1,45 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
<script lang="ts" context="module">
|
||||
export type TypeFilter = 'all' | 'branch' | 'pr';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex w-full items-center gap-x-2 rounded border px-3 py-1"
|
||||
style:border-color="var(--clr-theme-container-outline-light)"
|
||||
>
|
||||
<input type="text" class="w-full bg-transparent" />
|
||||
<Icon name="filter" />
|
||||
<script lang="ts">
|
||||
import Segment from '$lib/components/SegmentControl/Segment.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentControl/SegmentedControl.svelte';
|
||||
import TextBox from '$lib/components/TextBox.svelte';
|
||||
import type { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export let textFilter$: BehaviorSubject<string | undefined>;
|
||||
export let typeFilter$: BehaviorSubject<TypeFilter>;
|
||||
|
||||
let options: { id: TypeFilter; name: string }[] = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'branch', name: 'Branch' },
|
||||
{ id: 'pr', name: 'Pull request' }
|
||||
];
|
||||
|
||||
function onSelect(id: string) {
|
||||
typeFilter$.next(id as TypeFilter);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<TextBox icon="filter" on:input={(e) => textFilter$.next(e.detail)} />
|
||||
<div class="filter-btns">
|
||||
<SegmentedControl on:select={(e) => onSelect(e.detail)} wide selectedIndex={0}>
|
||||
{#each options as option}
|
||||
<Segment id={option.id}>{option.name}</Segment>
|
||||
{/each}
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.filter-btns {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@ -8,6 +8,9 @@
|
||||
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/settings/userSettings';
|
||||
import SectionHeader from './SectionHeader.svelte';
|
||||
import { accordion } from './accordion';
|
||||
import BranchFilter, { type TypeFilter } from './BranchFilter.svelte';
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import type { CombinedBranch } from '$lib/branches/types';
|
||||
|
||||
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
|
||||
|
||||
@ -15,7 +18,14 @@
|
||||
export let projectId: string;
|
||||
export let expanded = false;
|
||||
|
||||
export const textFilter$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
export const typeFilter$ = new BehaviorSubject<TypeFilter>('all');
|
||||
|
||||
$: branches$ = branchService.branches$;
|
||||
$: filteredBranches$ = combineLatest(
|
||||
[branchService.branches$, typeFilter$, textFilter$],
|
||||
(branches, type, search) => searchFilter(typeFilter(branches, type), search)
|
||||
);
|
||||
|
||||
let viewport: HTMLElement;
|
||||
let contents: HTMLElement;
|
||||
@ -24,6 +34,22 @@
|
||||
const onScroll: UIEventHandler<HTMLDivElement> = (e) => {
|
||||
scrolled = e.currentTarget.scrollTop != 0;
|
||||
};
|
||||
|
||||
function typeFilter(branches: CombinedBranch[], type: TypeFilter): CombinedBranch[] {
|
||||
switch (type) {
|
||||
case 'all':
|
||||
return branches;
|
||||
case 'branch':
|
||||
return branches.filter((b) => b.branch && !b.pr);
|
||||
case 'pr':
|
||||
return branches.filter((b) => b.pr);
|
||||
}
|
||||
}
|
||||
|
||||
function searchFilter(branches: CombinedBranch[], search: string | undefined) {
|
||||
if (search == undefined) return branches;
|
||||
return branches.filter((b) => b.displayName.includes(search));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if expanded}
|
||||
@ -50,9 +76,10 @@
|
||||
style:height={`${$userSettings.vbranchExpandableHeight}px`}
|
||||
>
|
||||
<div bind:this={viewport} class="viewport hide-native-scrollbar" on:scroll={onScroll}>
|
||||
<BranchFilter {typeFilter$} {textFilter$}></BranchFilter>
|
||||
<div bind:this={contents} class="content">
|
||||
{#if $branches$}
|
||||
{#each $branches$ as branch}
|
||||
{#if $filteredBranches$}
|
||||
{#each $filteredBranches$ as branch}
|
||||
<BranchItem {projectId} {branch} />
|
||||
{/each}
|
||||
{/if}
|
||||
@ -67,6 +94,9 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: none;
|
||||
|
Loading…
Reference in New Issue
Block a user