Add branch filtering to sidebar

This commit is contained in:
Mattias Granlund 2023-11-25 13:19:20 +01:00
parent 5fe2ea0d0c
commit d9f6249a55
6 changed files with 307 additions and 10 deletions

View 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>

View File

@ -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>

View 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;
}

View 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>

View File

@ -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>

View File

@ -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;