Add carousel tabs and selection-based nav to save editor

Wrap save editor tab buttons in a Carousel to prevent overflow on
desktop. Carousel nav buttons now cycle through the selected item
(save slot or tab) instead of scrolling, with auto-scroll-into-view.
On mobile, tabs reflow with flex-wrap as before.
This commit is contained in:
Christian Semmler 2026-02-14 09:11:51 -08:00
parent e1b9f2e696
commit 124b7a1370
3 changed files with 136 additions and 33 deletions

View File

@ -505,6 +505,8 @@ body {
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
} }
.config-tab-btn:hover { .config-tab-btn:hover {

View File

@ -4,6 +4,13 @@
export let gap = 10; export let gap = 10;
// Optional selection-based navigation. When provided, nav buttons
// change the selected item instead of scrolling.
export let onPrev = null;
export let onNext = null;
export let hasPrev = undefined;
export let hasNext = undefined;
let trackRef; let trackRef;
let canScrollLeft = false; let canScrollLeft = false;
let canScrollRight = false; let canScrollRight = false;
@ -14,6 +21,9 @@
// Exposed so parent can check if a drag occurred (to prevent click handling) // Exposed so parent can check if a drag occurred (to prevent click handling)
export let hasDragged = false; export let hasDragged = false;
$: leftDisabled = hasPrev !== undefined ? !hasPrev : !canScrollLeft;
$: rightDisabled = hasNext !== undefined ? !hasNext : !canScrollRight;
function updateArrows() { function updateArrows() {
if (!trackRef) return; if (!trackRef) return;
const { scrollLeft, scrollWidth, clientWidth } = trackRef; const { scrollLeft, scrollWidth, clientWidth } = trackRef;
@ -21,12 +31,40 @@
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1; canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
} }
function scrollLeft() { function handleLeft() {
trackRef?.scrollBy({ left: -200, behavior: 'smooth' }); if (onPrev) {
onPrev();
} else {
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
}
} }
function scrollRight() { function handleRight() {
trackRef?.scrollBy({ left: 200, behavior: 'smooth' }); if (onNext) {
onNext();
} else {
trackRef?.scrollBy({ left: 200, behavior: 'smooth' });
}
}
function scrollChildIntoView(child) {
const trackRect = trackRef.getBoundingClientRect();
const childRect = child.getBoundingClientRect();
const isFullyVisible = childRect.left >= trackRect.left && childRect.right <= trackRect.right;
if (!isFullyVisible) {
const scrollTarget = childRect.left < trackRect.left
? trackRef.scrollLeft - (trackRect.left - childRect.left)
: trackRef.scrollLeft + (childRect.right - trackRect.right);
trackRef.scrollTo({ left: scrollTarget, behavior: 'smooth' });
}
}
/** Scroll the nth child (0-indexed) into view */
export function scrollToIndex(index) {
if (!trackRef) return;
const child = trackRef.children[index];
if (child) scrollChildIntoView(child);
} }
function handleMouseDown(e) { function handleMouseDown(e) {
@ -55,24 +93,9 @@
} }
function handleClick(e) { function handleClick(e) {
// Find the direct child element that was clicked
const clickedCard = e.target.closest('.carousel-track > *'); const clickedCard = e.target.closest('.carousel-track > *');
if (!clickedCard || hasDragged) return; if (!clickedCard || hasDragged) return;
scrollChildIntoView(clickedCard);
const trackRect = trackRef.getBoundingClientRect();
const cardRect = clickedCard.getBoundingClientRect();
// Check if card is fully visible
const isFullyVisible = cardRect.left >= trackRect.left && cardRect.right <= trackRect.right;
if (!isFullyVisible) {
// Scroll to bring card into view
const scrollLeft = cardRect.left < trackRect.left
? trackRef.scrollLeft - (trackRect.left - cardRect.left)
: trackRef.scrollLeft + (cardRect.right - trackRect.right);
trackRef.scrollTo({ left: scrollLeft, behavior: 'smooth' });
}
} }
onMount(() => { onMount(() => {
@ -86,7 +109,7 @@
</script> </script>
<div class="carousel"> <div class="carousel">
<NavButton direction="left" onclick={scrollLeft} disabled={!canScrollLeft} /> <NavButton direction="left" onclick={handleLeft} disabled={leftDisabled} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div <div
class="carousel-track" class="carousel-track"
@ -103,7 +126,7 @@
> >
<slot /> <slot />
</div> </div>
<NavButton direction="right" onclick={scrollRight} disabled={!canScrollRight} /> <NavButton direction="right" onclick={handleRight} disabled={rightDisabled} />
</div> </div>
<style> <style>

View File

@ -53,6 +53,46 @@
// Carousel state (bound from Carousel component) // Carousel state (bound from Carousel component)
let carouselHasDragged = false; let carouselHasDragged = false;
let slotCarousel;
let tabCarousel;
// Slot carousel navigation
$: selectedSlotIndex = existingSlots.findIndex(s => s.slotNumber === selectedSlot);
$: hasSlotPrev = selectedSlotIndex > 0;
$: hasSlotNext = selectedSlotIndex >= 0 && selectedSlotIndex < existingSlots.length - 1;
function selectPrevSlot() {
if (!hasSlotPrev) return;
const newIdx = selectedSlotIndex - 1;
handleSlotSelect(existingSlots[newIdx].slotNumber);
slotCarousel?.scrollToIndex(newIdx);
}
function selectNextSlot() {
if (!hasSlotNext) return;
const newIdx = selectedSlotIndex + 1;
handleSlotSelect(existingSlots[newIdx].slotNumber);
slotCarousel?.scrollToIndex(newIdx);
}
// Tab carousel navigation
$: activeTabIndex = saveTabs.findIndex(t => t.id === activeTab);
$: hasTabPrev = activeTabIndex > 0;
$: hasTabNext = activeTabIndex < saveTabs.length - 1;
function selectPrevTab() {
if (!hasTabPrev) return;
const newIdx = activeTabIndex - 1;
switchTab(saveTabs[newIdx]);
tabCarousel?.scrollToIndex(newIdx);
}
function selectNextTab() {
if (!hasTabNext) return;
const newIdx = activeTabIndex + 1;
switchTab(saveTabs[newIdx]);
tabCarousel?.scrollToIndex(newIdx);
}
onMount(async () => { onMount(async () => {
await loadSlots(); await loadSlots();
@ -278,7 +318,7 @@
</div> </div>
<div class="config-main"> <div class="config-main">
{#if loading || error || existingSlots.length > 0} {#if loading || error || existingSlots.length > 0}
<Carousel bind:hasDragged={carouselHasDragged}> <Carousel bind:this={slotCarousel} bind:hasDragged={carouselHasDragged} onPrev={selectPrevSlot} onNext={selectNextSlot} hasPrev={hasSlotPrev} hasNext={hasSlotNext}>
{#if loading} {#if loading}
<span class="save-status-text">Loading save files...</span> <span class="save-status-text">Loading save files...</span>
{:else if error} {:else if error}
@ -326,16 +366,18 @@
{#if currentSlot && currentSlot.exists} {#if currentSlot && currentSlot.exists}
<div class="config-tabs"> <div class="config-tabs">
<div class="config-tab-buttons"> <div class="config-tab-buttons tab-carousel-wrapper">
{#each saveTabs as tab} <Carousel bind:this={tabCarousel} gap={5} onPrev={selectPrevTab} onNext={selectNextTab} hasPrev={hasTabPrev} hasNext={hasTabNext}>
<button {#each saveTabs as tab}
class="config-tab-btn" <button
class:active={activeTab === tab.id} class="config-tab-btn"
onclick={() => switchTab(tab)} class:active={activeTab === tab.id}
> onclick={() => switchTab(tab)}
{tab.label} >
</button> {tab.label}
{/each} </button>
{/each}
</Carousel>
</div> </div>
<!-- Player Tab --> <!-- Player Tab -->
@ -607,6 +649,42 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.tab-carousel-wrapper {
display: block;
}
.tab-carousel-wrapper :global(.carousel) {
gap: 4px;
}
.tab-carousel-wrapper :global(.carousel-track) {
cursor: default;
}
.tab-carousel-wrapper :global(.carousel-track.dragging) {
cursor: default;
}
@media (max-width: 768px) {
.tab-carousel-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0;
}
.tab-carousel-wrapper :global(.carousel) {
display: contents;
}
.tab-carousel-wrapper :global(.nav-btn) {
display: none;
}
.tab-carousel-wrapper :global(.carousel-track) {
display: contents;
}
}
@media (max-width: 400px) { @media (max-width: 400px) {
.name-slot { .name-slot {
width: 32px; width: 32px;