jedmund-svelte/src/lib/components/admin/BaseSegmentedController.svelte
Justin Edmund 1c38dc87e3 fix: drag handle actions now affect the correct block
- Added menuNode state to capture the node position when menu opens
- Updated all action functions to use menuNode instead of currentNode
- This ensures drag handle actions (Turn into, Delete, etc.) always affect the block where the handle was clicked, not where the mouse currently hovers
- Also formatted code with prettier

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-26 10:33:27 -04:00

340 lines
7.4 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte'
import type { Snippet } from 'svelte'
interface BaseItem {
value: string | number
label: string
href?: string
[key: string]: any
}
interface Props<T extends BaseItem = BaseItem> {
items: T[]
value?: string | number
defaultValue?: string | number
onChange?: (value: string | number, item: T) => void
variant?: 'navigation' | 'selection'
size?: 'small' | 'medium' | 'large'
fullWidth?: boolean
pillColor?: string | ((item: T) => string)
showPill?: boolean
gap?: number
containerPadding?: number
class?: string
children?: Snippet<[{ item: T; index: number; isActive: boolean; isHovered: boolean }]>
}
let {
items = [],
value = $bindable(),
defaultValue,
onChange,
variant = 'selection',
size = 'medium',
fullWidth = false,
pillColor = 'white',
showPill = true,
gap = 4,
containerPadding = 4,
class: className = '',
children
}: Props = $props()
// State
let containerElement: HTMLElement
let itemElements: HTMLElement[] = []
let pillStyle = ''
let hoveredIndex = $state(-1)
let internalValue = $state(defaultValue ?? value ?? items[0]?.value ?? '')
// Derived state
const currentValue = $derived(value ?? internalValue)
const activeIndex = $derived(items.findIndex((item) => item.value === currentValue))
// Effects
$effect(() => {
if (value !== undefined) {
internalValue = value
}
})
$effect(() => {
updatePillPosition()
})
// Functions
function updatePillPosition() {
if (!showPill) return
if (activeIndex >= 0 && itemElements[activeIndex] && containerElement) {
const activeElement = itemElements[activeIndex]
const containerRect = containerElement.getBoundingClientRect()
const activeRect = activeElement.getBoundingClientRect()
const left = activeRect.left - containerRect.left - containerPadding
const width = activeRect.width
pillStyle = `transform: translateX(${left}px); width: ${width}px;`
} else {
pillStyle = 'opacity: 0;'
}
}
function handleItemClick(item: BaseItem, index: number) {
if (variant === 'selection') {
const newValue = item.value
internalValue = newValue
if (value === undefined) {
// Uncontrolled mode
value = newValue
}
onChange?.(newValue, item)
}
// Navigation variant handles clicks via href
}
function handleKeyDown(event: KeyboardEvent) {
const currentIndex = activeIndex >= 0 ? activeIndex : 0
let newIndex = currentIndex
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault()
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
break
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault()
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = items.length - 1
break
case 'Enter':
case ' ':
if (variant === 'navigation' && items[currentIndex]?.href) {
// Let the link handle navigation
return
}
event.preventDefault()
if (items[currentIndex]) {
handleItemClick(items[currentIndex], currentIndex)
}
return
}
if (newIndex !== currentIndex && items[newIndex]) {
if (variant === 'navigation' && items[newIndex].href) {
// Focus the link
itemElements[newIndex]?.focus()
} else {
handleItemClick(items[newIndex], newIndex)
}
}
}
function getPillColor(item: BaseItem): string {
if (typeof pillColor === 'function') {
return pillColor(item)
}
return pillColor
}
// Lifecycle
onMount(() => {
const handleResize = () => updatePillPosition()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
// Size classes
const sizeClasses = {
small: 'segmented-controller-small',
medium: 'segmented-controller-medium',
large: 'segmented-controller-large'
}
</script>
<div
bind:this={containerElement}
class="base-segmented-controller {sizeClasses[size]} {className}"
class:full-width={fullWidth}
role="tablist"
style="--gap: {gap}px; --container-padding: {containerPadding}px;"
onkeydown={handleKeyDown}
>
{#if showPill && activeIndex >= 0}
<div
class="segmented-pill"
style="{pillStyle}; background-color: {getPillColor(items[activeIndex])};"
aria-hidden="true"
></div>
{/if}
{#each items as item, index}
{@const isActive = index === activeIndex}
{@const isHovered = index === hoveredIndex}
{#if variant === 'navigation' && item.href}
<a
bind:this={itemElements[index]}
href={item.href}
class="segmented-item"
class:active={isActive}
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = -1)}
>
{#if children}
{@render children({ item, index, isActive, isHovered })}
{:else}
<span class="item-label">{item.label}</span>
{/if}
</a>
{:else}
<button
bind:this={itemElements[index]}
type="button"
class="segmented-item"
class:active={isActive}
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
onclick={() => handleItemClick(item, index)}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = -1)}
>
{#if children}
{@render children({ item, index, isActive, isHovered })}
{:else}
<span class="item-label">{item.label}</span>
{/if}
</button>
{/if}
{/each}
</div>
<style lang="scss">
.base-segmented-controller {
display: inline-flex;
align-items: center;
gap: var(--gap);
padding: var(--container-padding);
background-color: $gray-90;
border-radius: $corner-radius-xl;
position: relative;
box-sizing: border-box;
&.full-width {
width: 100%;
.segmented-item {
flex: 1;
}
}
}
.segmented-pill {
position: absolute;
top: var(--container-padding);
bottom: var(--container-padding);
background-color: white;
border-radius: $corner-radius-lg;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: $shadow-sm;
z-index: $z-index-base;
pointer-events: none;
}
.segmented-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
text-decoration: none;
border-radius: $corner-radius-lg;
transition: all 0.2s ease;
z-index: $z-index-above;
font-family: inherit;
outline: none;
&:not(.active):hover {
background-color: rgba(0, 0, 0, 0.05);
}
&:focus-visible {
box-shadow: 0 0 0 2px $blue-50;
}
&.active {
color: $gray-10;
.item-label {
font-weight: 600;
}
}
&:not(.active) {
color: $gray-50;
&:hover {
color: $gray-30;
}
}
}
.item-label {
position: relative;
transition: all 0.2s ease;
white-space: nowrap;
user-select: none;
}
// Size variants
.segmented-controller-small {
.segmented-item {
padding: $unit $unit-2x;
font-size: 0.875rem;
min-height: 32px;
}
}
.segmented-controller-medium {
.segmented-item {
padding: calc($unit + $unit-half) $unit-3x;
font-size: 0.9375rem;
min-height: 40px;
}
}
.segmented-controller-large {
.segmented-item {
padding: $unit-2x $unit-4x;
font-size: 1rem;
min-height: 48px;
}
}
// Animation states
@media (prefers-reduced-motion: reduce) {
.segmented-pill,
.segmented-item {
transition: none;
}
}
</style>