add segmented control / tabs component

This commit is contained in:
Justin Edmund 2025-09-16 01:34:29 -07:00
parent 1b0294a683
commit dc9898f334
5 changed files with 291 additions and 0 deletions

View file

@ -0,0 +1,28 @@
<!-- Segment Component -->
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import styles from './segment.module.scss';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLButtonAttributes, 'value'> {
value: string;
class?: string;
}
let {
value,
class: className,
children,
...restProps
}: Props = $props();
</script>
<RadioGroupPrimitive.Item
{value}
class={cn(styles.segment, className)}
{...restProps}
>
<RadioGroupPrimitive.ItemIndicator class={styles.indicator} />
<span class={styles.label}>{children}</span>
</RadioGroupPrimitive.Item>

View file

@ -0,0 +1,69 @@
<!-- SegmentedControl Component -->
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import styles from './segmented-control.module.scss';
import type { HTMLDivAttributes } from 'svelte/elements';
interface Props extends HTMLDivAttributes {
value?: string;
onValueChange?: (value: string) => void;
variant?: 'default' | 'blended' | 'background';
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | null;
grow?: boolean;
gap?: boolean;
class?: string;
wrapperClass?: string;
}
let {
value = $bindable(),
onValueChange,
variant = 'default',
element = null,
grow = false,
gap = false,
class: className,
wrapperClass,
children,
...restProps
}: Props = $props();
$: if (onValueChange && value !== undefined) {
onValueChange(value);
}
const variantClasses = {
default: '',
blended: styles.blended,
background: styles.background
};
const elementClasses = {
wind: styles.wind,
fire: styles.fire,
water: styles.water,
earth: styles.earth,
dark: styles.dark,
light: styles.light
};
</script>
<div class={cn(styles.wrapper, wrapperClass)}>
<RadioGroupPrimitive.Root
bind:value
class={cn(
styles.segmentedControl,
variantClasses[variant],
element && elementClasses[element],
{
[styles.grow]: grow,
[styles.gap]: gap
},
className
)}
{...restProps}
>
{children}
</RadioGroupPrimitive.Root>
</div>

View file

@ -0,0 +1,2 @@
export { default as SegmentedControl } from './SegmentedControl.svelte';
export { default as Segment } from './Segment.svelte';

View file

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

View file

@ -0,0 +1,127 @@
@use 'themes/spacing';
@use 'themes/colors';
@use 'themes/layout';
@use 'themes/mixins';
.wrapper {
display: flex;
justify-content: center;
&.raid {
width: 100%;
}
@include mixins.breakpoint(small-tablet) {
width: 100%;
}
}
.segmentedControl {
display: inline-flex;
padding: 3px;
position: relative;
gap: spacing.$unit-half;
user-select: none;
overflow: hidden;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
@include mixins.breakpoint(small-tablet) {
background: var(--card-bg);
border-radius: 100px;
}
&.blended {
background: var(--input-bound-bg);
border-radius: layout.$full-corner;
}
&.background {
background: var(--input-bg);
border-radius: layout.$full-corner;
}
&.grow {
flex-grow: 1;
}
&.gap {
gap: spacing.$unit;
}
&.raid {
width: 100%;
}
// Element colors
&.fire {
[data-state='checked'] {
background: var(--fire-bg);
color: var(--fire-text);
}
button:hover {
background: var(--fire-hover-bg);
color: var(--fire-hover-text);
}
}
&.water {
[data-state='checked'] {
background: var(--water-bg);
color: var(--water-text);
}
button:hover {
background: var(--water-hover-bg);
color: var(--water-hover-text);
}
}
&.earth {
[data-state='checked'] {
background: var(--earth-bg);
color: var(--earth-text);
}
button:hover {
background: var(--earth-hover-bg);
color: var(--earth-hover-text);
}
}
&.wind {
[data-state='checked'] {
background: var(--wind-bg);
color: var(--wind-text);
}
button:hover {
background: var(--wind-hover-bg);
color: var(--wind-hover-text);
}
}
&.light {
[data-state='checked'] {
background: var(--light-bg);
color: var(--light-text);
}
button:hover {
background: var(--light-hover-bg);
color: var(--light-hover-text);
}
}
&.dark {
[data-state='checked'] {
background: var(--dark-bg);
color: var(--dark-text);
}
button:hover {
background: var(--dark-hover-bg);
color: var(--dark-hover-text);
}
}
}