feat(ui): new SelectTab component
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectTab } from '@proj-airi/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const focusMode = ref<'mode-1' | 'mode-2' | 'mode-3'>('mode-1')
|
||||
const quality = ref<'low' | 'balanced' | 'high'>('balanced')
|
||||
const locked = ref<'auto' | 'manual'>('auto')
|
||||
|
||||
const focusOptions = [
|
||||
{
|
||||
label: 'Mode 1',
|
||||
value: 'mode-1' as const,
|
||||
icon: 'i-solar:widget-2-line-duotone',
|
||||
},
|
||||
{
|
||||
label: 'Mode 2',
|
||||
value: 'mode-2' as const,
|
||||
icon: 'i-solar:menu-dots-line-duotone',
|
||||
},
|
||||
{
|
||||
label: 'Mode 3',
|
||||
value: 'mode-3' as const,
|
||||
icon: 'i-solar:atoms-line-duotone',
|
||||
},
|
||||
]
|
||||
|
||||
const qualityOptions = [
|
||||
{ label: 'Light', value: 'low' as const },
|
||||
{ label: 'Balanced', value: 'balanced' as const },
|
||||
{ label: 'Detailed', value: 'high' as const },
|
||||
]
|
||||
|
||||
const lockedOptions = [
|
||||
{ label: 'Auto sync', value: 'auto' as const },
|
||||
{ label: 'Manual', value: 'manual' as const },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="SelectTab"
|
||||
group="misc"
|
||||
:layout="{ type: 'single', width: '100%' }"
|
||||
>
|
||||
<template #controls>
|
||||
<ThemeColorsHueControl />
|
||||
</template>
|
||||
|
||||
<Variant
|
||||
id="default"
|
||||
title="Mode selection"
|
||||
>
|
||||
<div class="max-w-4xl w-full flex flex-col gap-4">
|
||||
<SelectTab v-model="focusMode" :options="focusOptions" />
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
Selected mode: {{ focusMode }}
|
||||
</p>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="with-descriptions"
|
||||
title="Sizing and descriptions"
|
||||
>
|
||||
<div class="max-w-3xl w-full flex flex-col gap-4">
|
||||
<SelectTab v-model="quality" size="sm" :options="qualityOptions" />
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
Rendering quality: {{ quality }}
|
||||
</p>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="disabled-readonly"
|
||||
title="Disabled and readonly states"
|
||||
>
|
||||
<div class="max-w-3xl w-full flex flex-col gap-4">
|
||||
<SelectTab v-model="locked" :options="lockedOptions" disabled />
|
||||
<SelectTab v-model="locked" :options="lockedOptions" readonly />
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
Locked selection mirrors both states: {{ locked }}
|
||||
</p>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface SelectTabOption {
|
||||
label: string
|
||||
value: string | number
|
||||
description?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
options: SelectTabOption[]
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}>(), {
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const modelValue = defineModel<string | number>({ required: true })
|
||||
|
||||
const activeIndex = computed(() => props.options.findIndex(option => option.value === modelValue.value))
|
||||
const itemCount = computed(() => props.options.length || 1)
|
||||
const isDisabled = computed(() => props.disabled || props.readonly)
|
||||
|
||||
const sizeClasses = computed(() =>
|
||||
props.size === 'sm'
|
||||
? ['py-2', 'px-3', 'text-xs', 'rounded-md']
|
||||
: ['py-2.5', 'px-3.5', 'text-sm', 'rounded-xl'],
|
||||
)
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--select-tab-count': String(itemCount.value),
|
||||
'--select-tab-active-index': String(Math.max(activeIndex.value, 0)),
|
||||
'--select-tab-padding': props.size === 'sm' ? '0px' : '0px',
|
||||
'--select-tab-gap': '0.5rem',
|
||||
'--select-tab-indicator-opacity': activeIndex.value === -1 ? '0' : '1',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
v-model="modelValue"
|
||||
:disabled="isDisabled"
|
||||
:aria-readonly="props.readonly"
|
||||
:class="[
|
||||
'select-tab',
|
||||
'relative', 'flex', 'w-full', 'items-stretch', 'rounded-xl',
|
||||
'bg-white-400/6', 'dark:bg-neutral-900/70',
|
||||
'transition-[border-color,box-shadow,opacity]', 'duration-200', 'ease-out', 'border-2',
|
||||
'border-neutral-100', 'dark:border-neutral-800',
|
||||
isDisabled ? ['cursor-not-allowed', 'opacity-60'] : ['shadow-[0_14px_50px_-32px_rgba(0,0,0,0.55)]', 'backdrop-blur-sm'],
|
||||
// before
|
||||
'before:bg-primary-300/50', 'dark:before:bg-primary-400/50',
|
||||
'before:rounded-lg', 'sm:before:rounded-xl',
|
||||
'before:absolute', 'before:z-0', 'before:content-empty',
|
||||
'before:transition-[left,width,opacity,background-color]', 'before:duration-200', 'before:ease',
|
||||
'before:pointer-events-none',
|
||||
'before:opacity-$select-tab-indicator-opacity',
|
||||
'before:top-$select-tab-padding',
|
||||
]"
|
||||
:style="[
|
||||
rootStyle,
|
||||
{ padding: 'var(--select-tab-padding)', gap: 'var(--select-tab-gap)' },
|
||||
]"
|
||||
>
|
||||
<RadioGroupItem
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:disabled="isDisabled"
|
||||
:aria-label="option.label"
|
||||
:class="[
|
||||
'select-tab__item',
|
||||
'relative', 'z-1',
|
||||
'flex', 'flex-1', 'items-center', 'justify-center', 'gap-2',
|
||||
'text-center', 'text-neutral-700', 'dark:text-neutral-200', 'font-medium',
|
||||
'transition-[color,background-color,border-color,transform]', 'duration-200', 'ease-out',
|
||||
'focus-visible:border-none', 'focus-visible:outline-none',
|
||||
sizeClasses,
|
||||
isDisabled ? 'pointer-events-none' : 'cursor-pointer',
|
||||
// checked
|
||||
'data-[state=checked]:text-primary-950', 'dark:data-[state=checked]:text-primary-50',
|
||||
// unchecked
|
||||
'data-[state=unchecked]:hover:bg-primary-300/20', 'dark:data-[state=unchecked]:hover:bg-primary-400/20', 'data-[state=unchecked]:rounded-lg',
|
||||
]"
|
||||
>
|
||||
<span v-if="option.icon" :class="['size-4 shrink-0 text-current', option.icon]" />
|
||||
<span :class="['truncate']">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-tab {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.select-tab::before {
|
||||
left:
|
||||
calc(
|
||||
(100% + var(--select-tab-gap))
|
||||
/ var(--select-tab-count)
|
||||
* var(--select-tab-active-index)
|
||||
+ var(--select-tab-padding)
|
||||
);
|
||||
width:
|
||||
calc(
|
||||
(100% + var(--select-tab-gap))
|
||||
/ var(--select-tab-count)
|
||||
- var(--select-tab-gap)
|
||||
);
|
||||
height: calc(100% - var(--select-tab-padding) * 2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SelectTab } from './SelectTab.vue'
|
||||
@@ -5,4 +5,5 @@ export * from './Input'
|
||||
export * from './Radio'
|
||||
export * from './Range'
|
||||
export * from './Select'
|
||||
export * from './SelectTab'
|
||||
export * from './Textarea'
|
||||
|
||||
Reference in New Issue
Block a user