refactor: create BaseSegmentedController and refactor AdminSegmentedController

- Created BaseSegmentedController with shared logic for all segmented controls
- Refactored AdminSegmentedController to use BaseSegmentedController
- Added keyboard navigation support (arrow keys, Home/End)
- Added size variants (small, medium, large)
- Added support for custom pill colors
- Added proper ARIA attributes for accessibility
- Fixed missing CSS variables ($red-error, $shadow-*)

This eliminates significant code duplication and provides a consistent foundation for all segmented control patterns.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-25 22:27:16 -04:00
parent 05ddafcdea
commit 153e0aa080
4 changed files with 403 additions and 113 deletions

View file

@ -312,3 +312,10 @@ $screen-lg-min: 1200px;
$orange-red: $red-70;
$salmon-pink: $red-95; // Desaturated salmon pink for hover states
$gray-5: $gray-97; // Was an old variable between 95 and 100
$red-error: #dc2626; // Error state color
// Shadow variables
$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);

View file

@ -1,70 +1,40 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import BaseSegmentedController from './BaseSegmentedController.svelte'
const currentPath = $derived($page.url.pathname)
interface NavItem {
text: string
value: string
label: string
href: string
icon: string
}
const navItems: NavItem[] = [
{ text: 'Dashboard', href: '/admin', icon: '📊' },
{ text: 'Projects', href: '/admin/projects', icon: '💼' },
{ text: 'Universe', href: '/admin/posts', icon: '🌟' },
{ text: 'Media', href: '/admin/media', icon: '🖼️' }
{ value: 'dashboard', label: 'Dashboard', href: '/admin', icon: '📊' },
{ value: 'projects', label: 'Projects', href: '/admin/projects', icon: '💼' },
{ value: 'universe', label: 'Universe', href: '/admin/posts', icon: '🌟' },
{ value: 'media', label: 'Media', href: '/admin/media', icon: '🖼️' }
]
// Track hover state and dropdown state
let hoveredIndex = $state<number | null>(null)
// Track dropdown state
let showDropdown = $state(false)
// Calculate active index based on current path
const activeIndex = $derived(
// Calculate active value based on current path
const activeValue = $derived(
currentPath === '/admin'
? 0
? 'dashboard'
: currentPath.startsWith('/admin/projects')
? 1
? 'projects'
: currentPath.startsWith('/admin/posts')
? 2
? 'universe'
: currentPath.startsWith('/admin/media')
? 3
: -1
? 'media'
: ''
)
// Calculate pill position and width
let containerElement: HTMLElement
let itemElements: HTMLAnchorElement[] = []
let pillStyle = $state('')
function updatePillPosition() {
if (activeIndex >= 0 && itemElements[activeIndex] && containerElement) {
const activeElement = itemElements[activeIndex]
const containerRect = containerElement.getBoundingClientRect()
const activeRect = activeElement.getBoundingClientRect()
// Subtract the container padding (8px) from the left position
const left = activeRect.left - containerRect.left - 8
const width = activeRect.width
pillStyle = `transform: translateX(${left}px); width: ${width}px;`
}
}
$effect(() => {
updatePillPosition()
// Handle window resize
const handleResize = () => updatePillPosition()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
function logout() {
localStorage.removeItem('admin_auth')
goto('/admin/login')
@ -86,26 +56,21 @@
})
</script>
<nav class="admin-segmented-controller" bind:this={containerElement}>
<div class="pills-container">
{#if activeIndex >= 0}
<div class="active-pill" style={pillStyle}></div>
{/if}
{#each navItems as item, index}
<a
href={item.href}
class="nav-item"
class:active={index === activeIndex}
bind:this={itemElements[index]}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<span class="icon">{item.icon}</span>
<span>{item.text}</span>
</a>
{/each}
</div>
<nav class="admin-segmented-controller">
<BaseSegmentedController
items={navItems}
value={activeValue}
variant="navigation"
pillColor="#e5e5e5"
gap={4}
containerPadding={0}
class="admin-nav-pills"
>
{#snippet children({ item, isActive })}
<span class="icon">{item.icon}</span>
<span>{item.label}</span>
{/snippet}
</BaseSegmentedController>
<div class="dropdown-container">
<button
@ -147,55 +112,33 @@
overflow: visible;
}
.pills-container {
display: flex;
align-items: center;
gap: 4px;
position: relative;
:global(.admin-nav-pills) {
flex: 1;
background: transparent !important;
padding: 0 !important;
box-shadow: none !important;
:global(.segmented-pill) {
background-color: $gray-85 !important;
}
:global(.segmented-item) {
gap: 6px;
padding: 10px 16px;
font-size: 1rem;
color: $gray-20;
&:global(.active) {
color: $gray-10;
}
}
}
.active-pill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: $gray-85;
border-radius: 100px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: 100px;
text-decoration: none;
font-size: 1rem;
font-weight: 400;
color: $gray-20;
position: relative;
z-index: 2;
transition:
color 0.2s ease,
background-color 0.2s ease;
&:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
}
&.active {
color: $gray-10;
}
.icon {
font-size: 1.1rem;
line-height: 1;
width: 20px;
text-align: center;
}
.icon {
font-size: 1.1rem;
line-height: 1;
width: 20px;
text-align: center;
}
.dropdown-container {
@ -237,7 +180,7 @@
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 150px;
z-index: 1050;
z-index: $z-index-modal;
overflow: hidden;
animation: slideDown 0.2s ease;
}

View file

@ -0,0 +1,340 @@
<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>

View file

@ -4,7 +4,7 @@
import Modal from './Modal.svelte'
import EnhancedComposer from './EnhancedComposer.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import FormField from './FormField.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'