merge conflict resolve

This commit is contained in:
Pavel Laptev 2024-07-26 12:29:27 +02:00
commit 7a3a9148cb
15 changed files with 808 additions and 155 deletions

View File

@ -0,0 +1,35 @@
export type ProjectMetricsReport = {
[key: string]: ProjectMetric | undefined;
};
type ProjectMetric = {
value: number;
minValue: number;
maxValue: number;
};
export class ProjectMetrics {
private metrics: { [key: string]: ProjectMetric | undefined } = {};
constructor(readonly projectId?: string) {}
setMetric(key: string, value: number) {
const oldvalue = this.metrics[key];
const maxValue = Math.max(value, oldvalue?.maxValue || value);
const minValue = Math.min(value, oldvalue?.minValue || value);
this.metrics[key] = {
value,
maxValue,
minValue
};
}
getMetrics(): ProjectMetricsReport {
return this.metrics;
}
resetMetric(key: string) {
delete this.metrics[key];
}
}

View File

@ -1,100 +0,0 @@
<script lang="ts">
import { tooltip } from '@gitbutler/ui/utils/tooltip';
export let name:
| 'remote-branch'
| 'local-branch'
| 'virtual-branch'
| 'pr'
| 'pr-draft'
| 'pr-closed'
| undefined;
export let help: string | undefined;
function getIconColor(name: string | undefined) {
if (name === 'remote-branch') return 'neutral';
if (name === 'virtual-branch' || name === 'local-branch') return 'virtual';
if (name === 'pr') return 'success';
if (name === 'pr-draft') return 'purple';
if (name === 'pr-closed') return 'neutral';
return 'neutral';
}
</script>
<div class="branch-icon {getIconColor(name)}" use:tooltip={help}>
{#if name === 'virtual-branch'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.75 8V4H4.25V8H5.75Z" />
<path
d="M11.75 7V4H10.25V7C10.25 8.24264 9.24264 9.25 8 9.25C5.92893 9.25 4.25 10.9289 4.25 13V16H5.75V13C5.75 11.7574 6.75736 10.75 8 10.75C10.0711 10.75 11.75 9.07107 11.75 7Z"
/>
</svg>
{/if}
{#if name === 'remote-branch' || name === 'local-branch'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.75 9.99973V4H4.25V16H5.75V13C5.75 11.7574 6.75736 10.75 8 10.75C10.0711 10.75 11.75 9.07107 11.75 7V4H10.25V7C10.25 8.24264 9.24264 9.25 8 9.25C7.1558 9.25 6.37675 9.52896 5.75 9.99973Z"
/>
</svg>
{/if}
{#if name === 'pr'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 5L8 2V4.25H10C11.5188 4.25 12.75 5.48122 12.75 7V16H11.25V7C11.25 6.30964 10.6904 5.75 10 5.75H8V8L3 5Z"
/>
<path d="M4.25 9L4.25 16H5.75L5.75 9H4.25Z" />
</svg>
{/if}
{#if name === 'pr-draft'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.75 7.88555C12.7643 7.56698 13.5 6.61941 13.5 5.5C13.5 4.11929 12.3807 3 11 3C9.61929 3 8.5 4.11929 8.5 5.5C8.5 6.61941 9.23572 7.56698 10.25 7.88555V9H11.75V7.88555Z"
/>
<path d="M4.25 16V4L5.75 4L5.75 16H4.25Z" />
<path d="M10.25 13L10.25 11H11.75L11.75 13H10.25Z" />
<path d="M10.25 16V15H11.75V16H10.25Z" />
</svg>
{/if}
{#if name === 'pr-closed'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.99999 4.93934L12.4697 2.46967L13.5303 3.53033L11.0607 6L13.5303 8.46967L12.4697 9.53033L9.99999 7.06066L7.53033 9.53033L6.46967 8.46967L8.93933 6L6.46967 3.53033L7.53033 2.46967L9.99999 4.93934Z"
/>
<path d="M3.25 4.00001V16H4.75L4.75 4.00001H3.25Z" />
<path d="M9.25 10L9.25 16H10.75L10.75 10H9.25Z" />
</svg>
{/if}
</div>
<style lang="postcss">
.branch-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: var(--radius-s);
& svg {
width: 16px;
height: 16px;
}
& path {
fill: var(--clr-bg-1);
}
}
.virtual {
background-color: var(--clr-scale-ntrl-60);
}
.neutral {
background-color: var(--clr-scale-ntrl-50);
}
.success {
background-color: var(--clr-scale-succ-50);
}
.purple {
background-color: var(--clr-scale-purp-50);
}
</style>

View File

@ -1,15 +1,11 @@
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;
setSelected({ index, id }: { index: number; id: string }): void;
}

View File

@ -0,0 +1,256 @@
<script lang="ts">
import Icon from '$lib/shared/Icon.svelte';
import TimeAgo from '$lib/shared/TimeAgo.svelte';
import { stringToColor } from '@gitbutler/ui/utils/stringToColor';
import { tooltip } from '@gitbutler/ui/utils/tooltip';
import type { CombinedBranch } from '$lib/branches/types';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface Props {
projectId: string;
branch: CombinedBranch;
}
const { projectId, branch }: Props = $props();
let href = $derived(getBranchLink(branch));
let selected = $state(false);
$effect(() => {
selected = href ? $page.url.href.endsWith(href) : false;
// console.log(branch);
});
function getBranchLink(b: CombinedBranch): string | undefined {
if (b.vbranch) return `/${projectId}/board/`;
if (b.remoteBranch) return `/${projectId}/remote/${branch?.remoteBranch?.displayName}`;
if (b.pr) return `/${projectId}/pull/${b.pr.number}`;
}
</script>
<button
class="branch"
class:selected
onmousedown={() => {
if (href) goto(href);
}}
>
<h4 class="text-base-13 text-semibold branch-name">
{branch.displayName}
</h4>
<div class="row">
<div class="row-group">
<div class="branch-authors">
{#each branch.authors as author}
<div
use:tooltip={{
text: author.name || 'Unknown',
delay: 500
}}
class="author-avatar"
style:background-color={stringToColor(author.name)}
style:background-image="url({author.gravatarUrl})"
></div>
{/each}
</div>
<div class="branch-remotes">
<!-- NEED API -->
{#if branch.remoteBranch}
<div class="branch-tag tag-remote">
<span class="text-base-10 text-semibold">origin</span>
</div>
<!-- <div class="branch-tag tag-local">
<span class="text-base-10 text-semibold">local</span>
</div> -->
{/if}
</div>
</div>
<div class="row-group">
{#if branch.pr}
<div use:tooltip={{ text: branch.pr.title, delay: 500 }} class="branch-tag tag-pr">
<span class="text-base-10 text-semibold">PR</span>
<Icon name="pr-small" />
</div>
{/if}
<!-- NEED API -->
<!-- <div class="branch-tag tag-applied">
<span class="text-base-10 text-semibold">applied</span>
</div> -->
</div>
</div>
<div class="row">
<span class="branch-time text-base-11 details truncate">
<TimeAgo date={branch.modifiedAt} />
{#if branch.author?.name}
by {branch.author?.name}
{/if}
</span>
<!-- NEED API -->
<div class="stats">
<div use:tooltip={'Number of commits'} class="branch-tag tag-commits">
<span class="text-base-10 text-semibold">34</span>
<Icon name="commit" />
</div>
<div use:tooltip={'Code changes'} class="code-changes">
<span class="text-base-10 text-semibold">+289</span>
<span class="text-base-10 text-semibold">-129</span>
</div>
</div>
</div>
</button>
<style lang="postcss">
.branch {
display: flex;
flex-direction: column;
padding: 10px 14px 12px 14px;
gap: 8px;
width: 100%;
text-align: left;
border-bottom: 1px solid var(--clr-border-3);
transition: background-color var(--transition-fast);
&:last-child {
border-bottom: none;
}
}
/* ROW */
.row {
display: flex;
align-items: center;
width: 100%;
gap: 6px;
justify-content: space-between;
}
.row-group {
display: flex;
align-items: center;
}
/* AUTHORS */
.branch-authors {
display: flex;
margin-right: 6px;
}
.author-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
margin-left: -4px;
background-color: var(--clr-scale-ntrl-50);
background-size: cover;
&:first-child {
margin-left: 0;
}
}
/* TAG */
.branch-tag {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 0 4px;
height: 16px;
border-radius: var(--radius-s);
}
.tag-remote {
background-color: var(--clr-theme-ntrl-soft-hover);
color: var(--clr-text-1);
}
.tag-local {
background-color: var(--clr-theme-ntrl-soft-hover);
color: var(--clr-text-2);
}
.tag-pr {
background-color: var(--clr-theme-succ-element);
color: var(--clr-theme-succ-on-element);
}
.tag-applied {
background-color: var(--clr-scale-ntrl-40);
color: var(--clr-theme-ntrl-on-element);
margin-left: 4px;
&:first-child {
margin-left: 0;
}
}
.tag-commits {
background-color: var(--clr-bg-3);
color: var(--clr-text-2);
}
/* */
.code-changes {
display: flex;
height: 16px;
& span {
padding: 2px 4px;
height: 100%;
}
& span:first-child {
background-color: var(--clr-theme-succ-soft);
color: var(--clr-theme-succ-on-soft);
border-radius: var(--radius-s) 0 0 var(--radius-s);
}
& span:last-child {
background-color: var(--clr-theme-err-soft);
color: var(--clr-theme-err-on-soft);
border-radius: 0 var(--radius-s) var(--radius-s) 0;
}
}
.stats {
display: flex;
align-items: center;
gap: 6px;
}
.branch-remotes {
display: flex;
gap: 2px;
}
.branch-name {
width: 100%;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
.branch-time {
color: var(--clr-scale-ntrl-50);
}
.branch:not(.selected):hover,
.branch:not(.selected):focus {
background-color: var(--clr-bg-1-muted);
transition: none;
}
.selected {
background-color: var(--clr-bg-2);
}
</style>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import Badge from '$lib/shared/Badge.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Segment from '@gitbutler/ui/SegmentControl/Segment.svelte';
import SegmentControl from '@gitbutler/ui/SegmentControl/SegmentControl.svelte';
import type { Snippet } from 'svelte';
interface Props {
filteredBranchCount?: number;
totalBranchCount: number;
filterButton?: Snippet<[filtersActive: boolean]>;
onSearch: (value: string) => void;
}
const { filteredBranchCount, onSearch }: Props = $props();
let searchValueState = $state('');
</script>
<div class="header">
<div class="branches-title">
<span class="text-base-14 text-bold">Branches</span>
{#if filteredBranchCount !== undefined}
<Badge count={filteredBranchCount} />
{/if}
</div>
<TextBox
icon="search"
placeholder="Search"
on:input={(e) => onSearch(e.detail)}
value={searchValueState}
/>
<SegmentControl fullWidth selectedIndex={0}>
<Segment id="all">All</Segment>
<Segment id="mine">PRs</Segment>
<Segment id="active">Mine</Segment>
</SegmentControl>
</div>
<style lang="postcss">
.header {
display: flex;
flex-direction: column;
color: var(--clr-scale-ntrl-0);
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 14px 12px 14px;
gap: 8px;
border-bottom: 1px solid transparent;
transition: border-bottom var(--transition-fast);
position: relative;
}
.branches-title {
display: flex;
align-items: center;
gap: 4px;
}
</style>

View File

@ -0,0 +1,200 @@
<script lang="ts">
import BranchItemNew from './BranchItemNew.svelte';
import BranchesHeaderNew from './BranchesHeaderNew.svelte';
import noBranchesSvg from '$lib/assets/empty-state/no-branches.svg?raw';
import { getBranchServiceStore } from '$lib/branches/service';
// import FilterButton from '$lib/components/FilterBranchesButton.svelte';
// import { getGitHost } from '$lib/gitHost/interface/gitHost';
// import { persisted } from '$lib/persisted/persisted';
import ScrollableContainer from '$lib/shared/ScrollableContainer.svelte';
import { readable } from 'svelte/store';
import type { CombinedBranch } from '$lib/branches/types';
interface Props {
projectId: string;
}
const { projectId }: Props = $props();
const branchService = getBranchServiceStore();
let searchValue = $state<undefined | string>();
let branches = $state($branchService?.branches || readable([]));
let searchedBranches = $derived(filterByText($branches, searchValue));
let groupedBranches = $derived(groupByDate(searchedBranches));
let viewport = $state<HTMLDivElement>();
let contents = $state<HTMLElement>();
$effect(() => {
branches = $branchService?.branches || readable([]);
});
function filterByText(branches: CombinedBranch[], searchText: string | undefined) {
// console.log('filterByText', branches, searchText);
if (searchText === undefined || searchText === '') return branches;
return branches.filter((b) => searchMatchesAnIdentifier(searchText, b.searchableIdentifiers));
}
function searchMatchesAnIdentifier(search: string, identifiers: string[]) {
for (const identifier of identifiers) {
if (identifier.includes(search.toLowerCase())) return true;
}
return false;
}
function groupByDate(branches: CombinedBranch[]) {
const grouped: Record<string, CombinedBranch[]> = {
today: [],
yesterday: [],
lastWeek: [],
older: []
};
const currentTs = new Date().getTime();
const remoteBranches = branches.filter((b) => b.remoteBranch);
remoteBranches.forEach((b) => {
if (!b.modifiedAt) {
grouped.older.push(b);
return;
}
const modifiedAt = b.modifiedAt?.getTime();
const ms = currentTs - modifiedAt;
if (ms < 86400 * 1000) {
grouped.today.push(b);
} else if (ms < 2 * 86400 * 1000) {
grouped.yesterday.push(b);
} else if (ms < 7 * 86400 * 1000) {
grouped.lastWeek.push(b);
} else {
grouped.older.push(b);
}
});
return grouped;
}
</script>
{#snippet branchGroup(props: {
title: string,
children: CombinedBranch[]
})}
{#if props.children.length > 0}
<div class="group">
<h3 class="text-base-12 text-semibold group-header">{props.title}</h3>
{#each props.children as branch}
<BranchItemNew {projectId} {branch} />
{/each}
</div>
{/if}
{/snippet}
<div class="branches">
<BranchesHeaderNew
totalBranchCount={$branches.length}
filteredBranchCount={searchedBranches?.length}
onSearch={(value) => (searchValue = value)}
>
<!-- {#snippet filterButton()}
<FilterButton
{filtersActive}
{includePrs}
{includeRemote}
{hideBots}
{hideInactive}
showPrCheckbox={!!$gitHost}
on:action
/>
{/snippet} -->
</BranchesHeaderNew>
{#if $branches.length > 0}
{#if searchedBranches.length > 0}
<ScrollableContainer
bind:viewport
showBorderWhenScrolled
fillViewport={searchedBranches.length === 0}
>
<div bind:this={contents} class="scroll-container">
{@render branchGroup({ title: 'Today', children: groupedBranches.today })}
{@render branchGroup({ title: 'Yesterday', children: groupedBranches.yesterday })}
{@render branchGroup({ title: 'Last week', children: groupedBranches.lastWeek })}
{@render branchGroup({ title: 'Older', children: groupedBranches.older })}
</div>
</ScrollableContainer>
{:else}
<div class="branches__empty-state">
<div class="branches__empty-state__image">
{@html noBranchesSvg}
</div>
<span class="branches__empty-state__caption text-base-body-14 text-semibold"
>No branches match your filter</span
>
</div>
{/if}
{:else}
<div class="branches__empty-state">
<div class="branches__empty-state__image">
{@html noBranchesSvg}
</div>
<span class="branches__empty-state__caption text-base-body-14 text-semibold"
>You have no branches</span
>
</div>
{/if}
</div>
<style lang="postcss">
.branches {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
border-top: 1px solid var(--clr-border-2);
}
/* BRANCHES LIST */
.scroll-container {
display: flex;
flex-direction: column;
}
.group {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--clr-border-2);
}
.group-header {
padding: 20px 14px 4px;
color: var(--clr-text-3);
}
/* EMPTY STATE */
.branches__empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
}
.branches__empty-state__image {
width: 130px;
}
.branches__empty-state__caption {
color: var(--clr-scale-ntrl-60);
text-align: center;
max-width: 160px;
}
</style>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import BaseBranchCard from './BaseBranchCard.svelte';
import Branches from './Branches.svelte';
import BranchesNew from './BranchesNew.svelte';
import Footer from './Footer.svelte';
import ProjectSelector from './ProjectSelector.svelte';
import DomainButton from '../components/DomainButton.svelte';
@ -25,7 +25,6 @@
let viewport: HTMLDivElement;
let isResizerHovered = false;
let isResizerDragging = false;
let isScrollbarDragging = false;
$: isNavCollapsed = persisted<boolean>(false, 'projectNavCollapsed_' + project.id);
@ -42,7 +41,7 @@
<svelte:window on:keydown={handleKeyDown} />
<aside class="navigation-wrapper" class:hide-fold-button={isScrollbarDragging}>
<aside class="navigation-wrapper">
<div
class="resizer-wrapper"
tabindex="0"
@ -121,10 +120,7 @@
</div>
{#if !$isNavCollapsed}
<Branches
projectId={project.id}
on:scrollbarDragging={(e) => (isScrollbarDragging = e.detail)}
/>
<BranchesNew projectId={project.id} />
{/if}
<Footer projectId={project.id} isNavCollapsed={$isNavCollapsed} />
{/if}

View File

@ -1,29 +1,7 @@
<script lang="ts">
import { stringToColor } from '@gitbutler/ui/utils/stringToColor';
export let name: string | undefined;
const colors = [
'#E78D8D',
'#62CDCD',
'#EC90D2',
'#7DC8D8',
'#F1BC55',
'#6B6B4C',
'#9785DE',
'#99CE63',
'#636ECE',
'#5FD2B0'
];
function nameToColor(name: string | undefined) {
const trimmed = name?.replace(/\s/g, '');
if (!trimmed) {
return `linear-gradient(45deg, ${colors[0][0]} 15%, ${colors[0][1]} 90%)`;
}
const startHash = trimmed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[startHash % colors.length];
}
function getFirstLetter(name: string | undefined) {
return name ? name[0].toUpperCase() : '';
}
@ -31,7 +9,7 @@
$: firstLetter = getFirstLetter(name);
</script>
<div class="project-avatar" style:background-color={nameToColor(name)}>
<div class="project-avatar" style:background-color={stringToColor(name)}>
<svg class="avatar-letter" viewBox="0 0 24 24">
<text x="50%" y="54%" text-anchor="middle" alignment-baseline="middle">
{firstLetter.toUpperCase()}

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { stringToColor } from '$lib/utils/stringToColor';
import { tooltip } from '$lib/utils/tooltip';
interface Props {
@ -12,25 +13,14 @@
const { srcUrl, tooltipText, altText }: Props = $props();
</script>
<div class="image-wrapper">
<div class="image-wrapper" style:background-color={stringToColor(altText)}>
<img
class="avatar"
alt={altText}
src={srcUrl}
loading="lazy"
onload={() => (isLoaded = true)}
width="100"
height="100"
class:hidden={!isLoaded}
use:tooltip={tooltipText}
/>
<img
class="avatar"
class:hidden={isLoaded}
alt={altText}
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAASCAYAAAA6yNxSAAAJcklEQVR4AQCBAH7/ANnXrf/Y163/19is/9XZq//R2an/zdqn/8jbpv/C26X/u9qk/7PYpP+r1aX/odGn/5fMqv+Nxq3/gr+x/3e3tf9srrn/YqW8/1idv/9QlsH/SpDB/0aMwf9FisD/R4q9/0qNuv9PkbX/VJaw/1mbq/9eoKb/YqWi/2Sonv9mqpz/AIEAfv8A2Neu/9jXrv/W2K3/1Nms/9Haqv/M2qj/x9um/8Lbpf+72qX/s9il/6vWpv+i0qj/mM2q/43Grv+Dv7L/eLe2/22vuf9ipr3/WZ6//1CXwf9KkcL/Ro3B/0aLwP9HjL3/S466/1CStv9Wl7H/W52s/2Cip/9kpqL/Z6mf/2irnP8AgQB+/wDV17D/1dew/9TYr//S2a7/z9qs/8vbqv/G26j/wdun/7rbpv+z2ab/q9en/6LTqf+Zzqz/j8iv/4TBs/95urb/brK6/2Oqvf9aosD/UZvC/0uVw/9HksL/R5DB/0qQvv9Ok7v/VJa3/1qbsv9goK3/ZaWo/2qppP9trKD/b66e/wCBAH7/ANHXtP/R2LT/0Niz/87Zsf/L2q//yNut/8Tcq//A3Kr/utyp/7Pbqf+s2Kn/pNWr/5vQrf+Ry7H/hsW0/3u9uP9wtrz/Za6//1unwf9SocP/TJzE/0mYxP9Jl8L/TZfA/1OZvf9ZnLj/YKG0/2elr/9tqqr/cq2m/3awov94sqD/AIEAfv8Ay9i4/8vYuP/K2bf/ydq1/8fbs//F3LH/wt2v/77drf+53az/s9ys/63arP+l167/nNOw/5POs/+IyLb/fsK6/3K7vf9ntMH/Xa7D/1Soxf9Oo8b/S6DG/02exP9Rn8L/WKG//2Cku/9op7b/b6ux/3avrP98s6j/gLal/4K3ov8AgQB+/wDE2L3/xNi9/8TZvP/D2rr/wtu4/8Ddtv++3rT/u96x/7fesP+z3q//rdyv/6bZsf+e1rP/ldK1/4vMuf+Axrz/dcDA/2m6w/9ftMX/Vq/H/1CryP9OqMj/UKfG/1anxP9eqMH/Z6u9/3CuuP94srT/gLav/4a5q/+Ku6f/jb2l/wCBAH7/ALzZw/+82cL/vNrB/7zbv/+83L3/u927/7rfuP+437b/teC0/7Hfs/+s3rP/pty0/5/Ytf+W1bj/jNC7/4LLv/92xcL/a8DF/2G7yP9Ytsn/U7LK/1Gwyv9Ur8n/W6/H/2Sww/9usr//eLW7/4G4tv+Ju7H/j76t/5TAqv+Xwaj/AIEAfv8As9rI/7PayP+028b/tNzE/7Xdwv+13r//td+9/7Tguv+y4Lj/r+C3/6vftv+l3bf/n9q4/5bXu/+N077/gs7B/3fJxP9sxcf/YsDK/1q8zP9Vucz/VbfM/1m2y/9gtsn/arfG/3S4wv9/u73/iL24/5HAtP+YwrD/ncSs/6DFqv8AgQB+/wCq283/q9vN/6vcy/+s3Mn/rt7H/6/fxP+v4MH/r+C+/67gvP+s4Lr/qN+5/6Teuv+d27v/ldi9/4zVwP+C0cP/eM3H/23Iyv9jxMz/XMHO/1i+z/9YvM7/XbvN/2W7y/9vvMj/er3E/4W/v/+Pwbr/mMS1/5/Fsf+kx67/p8is/wCBAH7/AKHc0v+i3NH/o93Q/6Tdzv+m3sv/qN/I/6ngxf+q4ML/qeC//6jgvf+l37z/oN28/5rbvv+T2MD/itXC/4HSxf93zsn/bcvM/2THzv9dxND/WsLR/1zA0P9hv8//ar/N/3TAyf+AwcX/i8LB/5XEvP+dxrf/pcey/6rIr/+tya3/AIEAfv8Amd3W/5re1f+b3tT/nd7S/5/fz/+h38v/ot/I/6Pfxf+j38L/ot7A/6Ddv/+c3L//ltrA/4/Xwv+H1cT/ftLH/3XPyv9rzM3/ZMnQ/17G0v9cxNL/X8PS/2XC0f9uwc7/ecLL/4TCxv+PxMH/mcW8/6LGt/+px7P/rsiv/7HJrf8AgQB+/wCS39n/kt/Z/5Pf1/+V39X/l9/S/5rfzv+b38v/nN7H/53exP+c3cL/mdvB/5Xawf+Q2MH/itXD/4LTxv960Mn/cs7M/2nLz/9jydH/X8bT/1/F1P9iw9P/acPS/3LCz/99wsz/iMPH/5LDwv+cxLz/pMW3/6vGsv+wx67/s8es/wCBAH7/AIzg3P+M4Nv/jeDa/4/g1/+R39T/k9/R/5Tezf+V3cn/ldzG/5TaxP+S2ML/jtbC/4nUw/+D0sX/fNDH/3XOyv9uy83/Z8nQ/2LH0/9fxtT/YcTV/2XD1P9swtP/dsLQ/4DCzP+Lwsf/lcLC/57CvP+mw7f/rcOx/7HErf+0xKv/AIEAfv8Ah+Le/4fi3f+I4dz/ieHZ/4vg1v+M3tP/jd3P/43by/+N2cj/jNfF/4nVw/+G08P/gdHE/3zOxv92zMj/b8rL/2nIzv9kx9H/YMXU/2DE1f9iwtb/aMLV/2/B0/95wND/g8DM/43Ax/+WwML/n8C7/6fAtf+twLD/scCs/7TAqf8AgQB+/wCD4+D/g+Pf/4Ti3f+F4dv/heDY/4be1P+G3ND/htnM/4XXyP+E1Mb/gdLE/33PxP95zcT/dMrG/27Iyf9pxsz/ZMXP/2DD0v9fwtT/YMHW/2TA1/9qv9b/cr/U/3u+0f+Fvsz/jr3H/5e9wf+gvLv/p7y0/6y8r/+xvKr/s7yn/wCBAH7/AIHk4f+B5OD/gePf/4Hi3P+B4Nn/gd3V/4Hb0f+A2M3/ftXJ/3zSxv95zsX/dczE/3HJxf9sx8f/Z8XJ/2PDzP9fwdD/XcDT/12/1f9gvtf/Zb7X/2u91v90vdT/fbzR/4a7zP+Pu8f/mLrA/6C5uv+muLP/rLit/7C3qP+yt6X/AIEAfv8Af+Xi/3/l4f9/5N//f+Ld/37g2f993dX/fNrR/3rWzf9408n/dc/H/3LMxf9uycT/asbF/2XDx/9hwcr/XcDN/1u+0P9avdP/XL3V/2C81/9lvNf/bbvW/3W71P9+utH/h7nM/5C4xv+Yt8D/n7a5/6a1sv+rtaz/rrSn/7G0o/8BgQB+/wB+5uL/fuXh/37k4P994t3/fODa/3vd1v952dH/d9bN/3TSyv9xzsf/bcrF/2nHxf9lxMX/YMHH/12/yv9avs3/WLzQ/1m80/9bu9b/YLvX/2a61/9uutb/drnU/3+40f+IuMz/kLfG/5i1v/+ftLj/pbOx/6qyq/+usqb/sLGi/+et3p4B1/E6AAAAAElFTkSuQmCC"
width="100"
height="100"
class:show={isLoaded}
use:tooltip={tooltipText}
/>
</div>
@ -40,20 +30,24 @@
display: grid;
place-content: center;
overflow: hidden;
border-radius: 6px;
width: 12px;
height: 12px;
}
.image-wrapper > * {
grid-area: 1 / 1;
}
.hidden {
.avatar {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
}
.avatar {
position: relative;
width: 12px;
height: 12px;
border-radius: 6px;
.show {
opacity: 1;
}
</style>

View File

@ -0,0 +1,110 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import type { SegmentContext } from './segmentTypes';
import type { Snippet } from 'svelte';
interface SegmentProps {
id: string;
onselect?: (id: string) => void;
children: Snippet;
}
const { id, onselect, children }: SegmentProps = $props();
const context = getContext<SegmentContext>('SegmentControl');
const index = context.setIndex();
const selectedSegmentIndex = context.selectedSegmentIndex;
let elRef = $state<HTMLButtonElement>();
let isFocused = $state(false);
let isSelected = $state(false);
$effect(() => {
elRef && isFocused && elRef.focus();
});
$effect(() => {
isSelected = $selectedSegmentIndex === index;
});
onMount(() => {
context.addSegment({ index });
});
</script>
<button
bind:this={elRef}
{id}
class="segment"
role="tab"
tabindex={isSelected ? -1 : 0}
aria-selected={isSelected}
onmousedown={() => {
if (index !== $selectedSegmentIndex) {
context.setSelected({
index,
id
});
onselect && onselect(id);
}
}}
onkeydown={({ key }) => {
if (key === 'Enter' || key === ' ') {
if (index !== $selectedSegmentIndex) {
context.setSelected({
index,
id
});
onselect && onselect(id);
}
}
}}
><span class="text-base-12 label">
{@render children()}
</span></button
>
<style lang="postcss">
.segment {
cursor: pointer;
display: inline-flex;
flex-grow: 1;
flex-basis: 0;
align-items: center;
justify-content: center;
user-select: none;
padding: 0 8px;
gap: 4px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-width: 1px;
color: var(--clr-text-1);
border-color: var(--clr-border-2);
background-color: var(--clr-bg-1);
height: var(--size-button);
transition: background var(--transition-fast);
&:first-of-type {
border-top-left-radius: var(--radius-m);
border-bottom-left-radius: var(--radius-m);
}
&:last-of-type {
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
border-right-width: 1px;
}
&:not([aria-selected='true']):hover {
background-color: var(--clr-bg-1-muted);
}
&[aria-selected='true'] {
background-color: var(--clr-bg-2);
color: var(--clr-text-2);
}
}
</style>

View File

@ -0,0 +1,53 @@
<script lang="ts">
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
import type { SegmentContext, SegmentItem } from './segmentTypes';
import type { Snippet } from 'svelte';
interface SegmentProps {
selectedIndex: number;
fullWidth?: boolean;
onselect?: (id: string) => void;
children: Snippet;
}
const { selectedIndex, fullWidth, onselect, children }: SegmentProps = $props();
let indexesIterator = -1;
let segments: SegmentItem[] = [];
let selectedSegmentIndex = writable(selectedIndex);
const context: SegmentContext = {
selectedSegmentIndex,
setIndex: () => {
indexesIterator += 1;
return indexesIterator;
},
addSegment: ({ index }) => {
segments = [...segments, { index }];
},
setSelected: ({ index: segmentIndex, id }) => {
if (segmentIndex >= 0 && segmentIndex < segments.length) {
$selectedSegmentIndex = segmentIndex;
onselect && onselect(id);
}
}
};
setContext<SegmentContext>('SegmentControl', context);
</script>
<div class="wrapper" class:full-width={fullWidth}>
{@render children()}
</div>
<style lang="postcss">
.wrapper {
display: inline-flex;
}
.wrapper.full-width {
width: 100%;
}
</style>

View File

@ -0,0 +1,11 @@
import type { Writable } from 'svelte/store';
export interface SegmentItem {
index: number;
}
export interface SegmentContext {
selectedSegmentIndex: Writable<number>;
setIndex(): number;
addSegment(segment: SegmentItem): void;
setSelected({ index, id }: { index: number; id: string }): void;
}

View File

@ -0,0 +1,22 @@
const colors = [
'#E78D8D',
'#62CDCD',
'#EC90D2',
'#7DC8D8',
'#F1BC55',
'#50D6AE',
'#9785DE',
'#99CE63',
'#636ECE',
'#5FD2B0'
];
export function stringToColor(name: string | undefined) {
const trimmed = name?.replace(/\s/g, '');
if (!trimmed) {
return `linear-gradient(45deg, ${colors[0][0]} 15%, ${colors[0][1]} 90%)`;
}
const startHash = trimmed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[startHash % colors.length];
}

View File

@ -0,0 +1,16 @@
import SegmentControl from './SegmentControl.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: SegmentControl
} satisfies Meta<SegmentControl>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SegmentControlStory: Story = {
args: {
selectedIndex: 1,
fullWidth: false
}
};

View File

@ -0,0 +1,24 @@
<script lang="ts">
import Segment from '$lib/SegmentControl/Segment.svelte';
import SegmentControl from '$lib/SegmentControl/SegmentControl.svelte';
interface Props {
selectedIndex: number;
fullWidth?: boolean;
}
const { selectedIndex, fullWidth }: Props = $props();
</script>
<SegmentControl
{selectedIndex}
{fullWidth}
onselect={(id) => {
console.log('Selected index:', id);
}}
>
<Segment id="first">First</Segment>
<Segment id="second">Second</Segment>
<Segment id="third">Third</Segment>
<Segment id="fourth">Fourth</Segment>
</SegmentControl>