refactor segmented control

This commit is contained in:
Justin Edmund 2025-09-29 23:44:37 -07:00
parent a9f6336427
commit 1b2bee497b
4 changed files with 182 additions and 90 deletions

View file

@ -1,28 +1,42 @@
<!-- Segment Component --> <!-- Segment Component -->
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui'; import { RadioGroup as RadioGroupPrimitive } from 'bits-ui'
import styles from './segment.module.scss'; import { getContext } from 'svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'; import styles from './segment.module.scss'
import type { HTMLButtonAttributes } from 'svelte/elements'
import type { SegmentedControlVariant } from './SegmentedControl.svelte'
interface Props extends Omit<HTMLButtonAttributes, 'value'> { interface Props extends Omit<HTMLButtonAttributes, 'value'> {
value: string; value: string
class?: string; class?: string
} }
let { let { value, class: className, children: content, ...restProps }: Props = $props()
value,
class: className, // Get variant from parent context
children: content, const variant = getContext<SegmentedControlVariant>('segmented-control-variant') || 'default'
...restProps
}: Props = $props(); // Apply variant-specific classes
const variantClasses = {
default: styles.default,
blended: styles.blended,
background: styles.background
}
const segmentClass = $derived(
[
styles.segment,
variantClasses[variant],
className || ''
]
.filter(Boolean)
.join(' ')
)
</script> </script>
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item {value} class={segmentClass} {...restProps}>
{value}
class={`${styles.segment} ${className || ''}`}
{...restProps}
>
{#snippet children({ checked })} {#snippet children({ checked })}
{#if checked} {#if checked}
<div class={styles.indicator}></div> <div class={styles.indicator}></div>

View file

@ -1,19 +1,24 @@
<!-- SegmentedControl Component --> <!-- SegmentedControl Component -->
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import styles from './segmented-control.module.scss';
import type { HTMLDivAttributes } from 'svelte/elements';
interface Props extends HTMLDivAttributes { <script lang="ts">
value?: string; import { RadioGroup as RadioGroupPrimitive } from 'bits-ui'
onValueChange?: (value: string) => void; import { setContext } from 'svelte'
variant?: 'default' | 'blended' | 'background'; import styles from './segmented-control.module.scss'
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | null; import type { HTMLAttributes } from 'svelte/elements'
grow?: boolean;
gap?: boolean; export type SegmentedControlVariant = 'default' | 'blended' | 'background'
class?: string;
wrapperClass?: string; interface Props extends HTMLAttributes<HTMLDivElement> {
value?: string
onValueChange?: (value: string) => void
variant?: SegmentedControlVariant
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | null
grow?: boolean
gap?: boolean
class?: string
wrapperClass?: string
children?: import('svelte').Snippet
} }
let { let {
@ -27,19 +32,22 @@
wrapperClass, wrapperClass,
children, children,
...restProps ...restProps
}: Props = $props(); }: Props = $props()
// Provide variant to child segments via context
setContext('segmented-control-variant', variant)
$effect(() => { $effect(() => {
if (onValueChange && value !== undefined) { if (onValueChange && value !== undefined) {
onValueChange(value); onValueChange(value)
} }
}); })
const variantClasses = { const variantClasses = {
default: '', default: '',
blended: styles.blended, blended: styles.blended,
background: styles.background background: styles.background
}; }
const elementClasses = { const elementClasses = {
wind: styles.wind, wind: styles.wind,
@ -48,15 +56,34 @@
earth: styles.earth, earth: styles.earth,
dark: styles.dark, dark: styles.dark,
light: styles.light light: styles.light
}; }
const classList = $derived(
[
styles.segmentedControl,
variantClasses[variant],
element ? elementClasses[element] : '',
grow ? styles.grow : '',
gap ? styles.gap : '',
className || ''
]
.filter(Boolean)
.join(' ')
)
const wrapperClassList = $derived(
[
styles.wrapper,
grow ? styles.growWrapper : '',
wrapperClass || ''
]
.filter(Boolean)
.join(' ')
)
</script> </script>
<div class={`${styles.wrapper} ${wrapperClass || ''}`}> <div class={wrapperClassList}>
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root bind:value class={classList} {...restProps}>
bind:value
class={`${styles.segmentedControl} ${variantClasses[variant]} ${element ? elementClasses[element] : ''} ${grow ? styles.grow : ''} ${gap ? styles.gap : ''} ${className || ''}`}
{...restProps}
>
{@render children?.()} {@render children?.()}
</RadioGroupPrimitive.Root> </RadioGroupPrimitive.Root>
</div> </div>

View file

@ -5,61 +5,105 @@
@use 'themes/mixins'; @use 'themes/mixins';
.segment { .segment {
color: colors.$grey-55; color: var(--text-secondary);
cursor: pointer; cursor: pointer;
flex-grow: 1; flex-grow: 1;
font-size: typography.$font-regular; font-size: typography.$font-regular;
font-weight: typography.$normal; font-weight: typography.$normal;
min-width: 100px; min-width: 100px;
position: relative; position: relative;
background: transparent; background: transparent;
border: none; border: none;
padding: 0; padding: 0;
text-align: center; text-align: center;
outline: none; outline: none;
&:hover .label { &:focus-visible .label {
background: var(--page-hover); outline: 2px solid colors.$blue;
color: var(--text-primary); outline-offset: 2px;
} }
&[data-state='checked'] .label { @include mixins.breakpoint(phone) {
background: var(--background); min-width: initial;
color: var(--text-primary); width: 100%;
} }
&:focus-visible .label {
outline: 2px solid colors.$blue;
outline-offset: 2px;
}
@include mixins.breakpoint(phone) {
min-width: initial;
width: 100%;
}
} }
.label { .label {
border-radius: spacing.$unit * 3; border: 0.5px solid transparent;
display: flex; border-radius: layout.$full-corner;
align-items: center; display: flex;
justify-content: center; align-items: center;
text-align: center; justify-content: center;
box-sizing: border-box; text-align: center;
height: 100%; box-sizing: border-box;
white-space: nowrap; font-weight: typography.$medium;
overflow: hidden; height: 100%;
padding: 8px 12px; white-space: nowrap;
text-overflow: ellipsis; overflow: hidden;
cursor: pointer; padding: calc(spacing.$unit * 1.5) spacing.$unit-2x;
transition: all 0.15s ease-in-out; text-overflow: ellipsis;
cursor: pointer;
transition: all 0.15s ease-in-out;
} }
.indicator { .indicator {
display: none; display: none;
}
// Default variant styles
.default {
.label {
background: transparent;
color: var(--text-secondary);
}
&:hover .label {
background: var(--page-hover);
color: var(--text-primary);
}
&[data-state='checked'] .label {
background: var(--background);
color: var(--text-primary);
}
} }
// Blended variant styles // Blended variant styles
:global(.blended) .segment[data-state='checked'] .label { .blended {
background: var(--card-bg); .label {
background: var(--segmented-control-blended-segment-bg);
color: var(--segmented-control-blended-segment-text);
}
&:hover .label {
background: var(--segmented-control-blended-segment-bg-hover);
color: var(--segmented-control-blended-segment-text-hover);
}
&[data-state='checked'] .label {
background: var(--segmented-control-blended-segment-bg-checked);
color: var(--segmented-control-blended-segment-text-checked);
box-shadow: 0 0 3px rgba(0, 0, 0, 0.12);
}
}
// Background variant styles
.background {
.label {
background: var(--segmented-control-background-segment-bg);
color: var(--segmented-control-background-segment-text);
}
&:hover .label {
background: var(--segmented-control-background-segment-bg-hover);
color: var(--segmented-control-background-segment-text-hover);
}
&[data-state='checked'] .label {
background: var(--segmented-control-background-segment-bg-checked);
color: var(--segmented-control-background-segment-text-checked);
border: 0.5px solid rgba(0, 0, 0, 0.12);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.08);
}
} }

View file

@ -16,6 +16,10 @@
} }
} }
.growWrapper {
width: 100%;
}
.segmentedControl { .segmentedControl {
display: inline-flex; display: inline-flex;
position: relative; position: relative;
@ -30,17 +34,20 @@
} }
&.blended { &.blended {
background: var(--input-bound-bg); background: var(--segmented-control-blended-bg);
border-radius: layout.$full-corner; border-radius: layout.$full-corner;
padding: spacing.$unit-half;
} }
&.background { &.background {
background: var(--input-bg); background: var(--segmented-control-background-bg);
border-radius: layout.$full-corner; border-radius: layout.$full-corner;
padding: spacing.$unit-half;
} }
&.grow { &.grow {
flex-grow: 1; flex-grow: 1;
width: 100%;
} }
&.gap { &.gap {
@ -51,7 +58,7 @@
width: 100%; width: 100%;
} }
// Element colors // Element colors (not used currently, but keeping for future)
&.fire { &.fire {
[data-state='checked'] { [data-state='checked'] {
background: var(--fire-bg); background: var(--fire-bg);