Add Field component system and migrate UI

This commit is contained in:
Gregor Vostrak
2026-02-10 12:22:53 +01:00
parent 1ecb332458
commit fd012e7c69
71 changed files with 1023 additions and 741 deletions

View File

@@ -18,12 +18,11 @@ export const buttonVariants = cva(
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
default: 'h-9 px-3 text-sm',
xs: 'h-7 rounded px-2 text-xs',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
lg: 'h-10 rounded-md px-4',
icon: 'h-9 w-9',
input: 'h-[42px] px-3 py-2 text-base',
},
},
defaultVariants: {

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from './field';
const model = defineModel<number | null>();
const emit = defineEmits(['submit']);
</script>
<template>
<div class="pt-6">
<div class="flex items-center space-x-1 mb-2">
<Field class="pt-6">
<div class="flex items-center space-x-1">
<ClockIcon class="text-text-quaternary w-4"></ClockIcon>
<InputLabel for="billable" value="Time Estimated" />
<FieldLabel for="billable">Time Estimated</FieldLabel>
</div>
<EstimatedTimeInput v-model="model" @submit="emit('submit')"></EstimatedTimeInput>
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
@@ -21,7 +21,7 @@ const emit = defineEmits(['submit']);
<span class="font-semibold">2h 30m</span>
</span>
</div>
</div>
</Field>
</template>
<style scoped></style>

View File

@@ -75,12 +75,12 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
<template #content>
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
v-model:open="open"
class="p-2"
:filter-function="(val: string[]) => val">
<ComboboxAnchor>
<ComboboxInput
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
:placeholder="searchPlaceholder" />
</ComboboxAnchor>
<ComboboxContent

View File

@@ -25,7 +25,7 @@ const model = defineModel();
v-model="model"
:class="
twMerge(
'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
'h-9 px-3 py-1 text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
props.class
)
"

View File

@@ -3,7 +3,6 @@ import { ref, watch } from 'vue';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { useFocus } from '@vueuse/core';
import { TextInput } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
@@ -12,11 +11,9 @@ const model = defineModel<string | null>({
const props = withDefaults(
defineProps<{
size?: 'base' | 'large';
focus?: boolean;
}>(),
{
size: 'base',
focus: false,
}
);
@@ -101,7 +98,7 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
v-bind="$attrs"
ref="timeInput"
v-model="inputValue"
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
class="text-center w-full"
data-testid="time_picker_input"
type="text"
@blur="updateTime"

View File

@@ -12,7 +12,7 @@ import Badge from '@/packages/ui/src/Badge.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from '../field';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/packages/api/src';
@@ -76,14 +76,12 @@ const currentClientName = computed(() => {
<template #content>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center">
<div class="text-center pr-5">
<InputLabel for="color" value="Color" />
<ProjectColorSelector
v-model="project.color"
class="mt-2.5"></ProjectColorSelector>
</div>
<div class="w-full">
<InputLabel for="projectName" value="Project name" />
<Field class="text-center pr-5">
<FieldLabel for="color">Color</FieldLabel>
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
</Field>
<Field class="w-full">
<FieldLabel for="projectName">Project name</FieldLabel>
<TextInput
id="projectName"
ref="projectNameInput"
@@ -91,19 +89,18 @@ const currentClientName = computed(() => {
name="projectName"
type="text"
placeholder="The next big thing"
class="mt-2 block w-full"
class="block w-full"
required
autocomplete="projectName"
@keydown.enter="submit()" />
</div>
</Field>
</div>
<div>
<InputLabel for="client" value="Client" />
<Field>
<FieldLabel for="client">Client</FieldLabel>
<ClientDropdown
v-model="project.client_id"
:create-client="createClient"
:clients="activeClients"
class="mt-2">
:clients="activeClients">
<template #trigger>
<Badge
tag="button"
@@ -118,7 +115,7 @@ const currentClientName = computed(() => {
</Badge>
</template>
</ClientDropdown>
</div>
</Field>
</div>
<div>
<div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from '../field';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import ProjectBillableSelect from '@/packages/ui/src/Project/ProjectBillableSelect.vue';
import { computed, onMounted, ref, watch } from 'vue';
@@ -55,23 +55,21 @@ const emit = defineEmits(['submit']);
<template>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
<div>
<div class="flex items-center space-x-1 mb-2">
<Field>
<div class="flex items-center space-x-1">
<BillableIcon class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
<InputLabel for="billable" value="Billable Default" />
<FieldLabel for="billable">Billable Default</FieldLabel>
</div>
<ProjectBillableSelect
v-model="billableRateSelect"
class="mt-2"></ProjectBillableSelect>
</div>
<div v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" class="mb-2" />
<ProjectBillableSelect v-model="billableRateSelect"></ProjectBillableSelect>
</Field>
<Field v-if="billableRateSelect === 'custom-rate'">
<FieldLabel for="billableRate">Billable Rate</FieldLabel>
<BillableRateInput
v-model="billableRate"
:currency="currency"
name="billableRate"
@keydown.enter="emit('submit')" />
</div>
</Field>
</div>
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
<span>

View File

@@ -5,7 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from '../field';
import { TagIcon } from '@heroicons/vue/20/solid';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
@@ -24,7 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -165,6 +165,7 @@ const billableProxy = computed({
v-model:project="timeEntry.project_id"
v-model:task="timeEntry.task_id"
variant="input"
size="default"
:clients
:create-project
:create-client
@@ -181,7 +182,7 @@ const billableProxy = computed({
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Button variant="input" size="sm">
<Button variant="input">
<TagIcon class="h-4 text-icon-default" />
<span>{{
timeEntry.tags.length === 0
@@ -192,7 +193,7 @@ const billableProxy = computed({
</template>
</TagDropdown>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectTrigger :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{ timeEntry.billable ? 'Billable' : 'Non-Billable' }}</span>
@@ -206,9 +207,9 @@ const billableProxy = computed({
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 flex flex-col">
<Field class="flex-1">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"
@@ -222,31 +223,27 @@ const billableProxy = computed({
</span>
</div>
</div>
</div>
</Field>
<div class="grid grid-cols-2 sm:flex gap-4">
<div>
<InputLabel>Start</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2 sm:w-28">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
class="w-full"></TimePickerSimple>
<DatePicker
v-model="localStart"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
<div>
<InputLabel>End</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2 sm:w-28">
<TimePickerSimple v-model="localEnd" class="w-full"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</Field>
</div>
</div>
</template>

View File

@@ -5,7 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from '../field';
import { TagIcon } from '@heroicons/vue/20/solid';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
@@ -24,7 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -199,7 +199,7 @@ const billableProxy = computed({
</template>
</TagDropdown>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectTrigger size="sm" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
@@ -215,9 +215,9 @@ const billableProxy = computed({
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 flex flex-col">
<Field class="flex-1">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"
@@ -231,34 +231,32 @@ const billableProxy = computed({
</span>
</div>
</div>
</div>
</Field>
<div class="grid grid-cols-2 sm:flex gap-4">
<div>
<InputLabel>Start</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2 sm:w-28">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
class="w-full"></TimePickerSimple>
<DatePicker
v-model="localStart"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
<div>
<InputLabel>End</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2 sm:w-28">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
class="w-full"></TimePickerSimple>
<DatePicker
v-model="localEnd"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
</Field>
</div>
</div>
</div>

View File

@@ -14,7 +14,8 @@ import type {
} from '@/packages/api/src';
import { ref } from 'vue';
import { twMerge } from 'tailwind-merge';
import { Checkbox, InputLabel } from '@/packages/ui/src';
import { Checkbox } from '@/packages/ui/src';
import { FieldLabel } from '../field';
const props = defineProps<{
selectedTimeEntries: TimeEntry[];
@@ -71,14 +72,14 @@ const showMassUpdateModal = ref(false);
:checked="allSelected"
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
</Checkbox>
<InputLabel
<FieldLabel
v-if="selectedTimeEntries.length > 0"
for="selectAll"
class="select-none text-text-secondary">
{{ selectedTimeEntries.length }} selected
</InputLabel>
<InputLabel v-else for="selectAll" class="text-text-secondary select-none"
>Select All</InputLabel
</FieldLabel>
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
>Select All</FieldLabel
>
<button
v-if="selectedTimeEntries.length"

View File

@@ -5,7 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '../Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '../Input/InputLabel.vue';
import { Field, FieldLabel } from '../field';
import {
type CreateClientBody,
type CreateProjectBody,
@@ -23,7 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import type { Tag, Task } from '@/packages/api/src';
@@ -146,18 +146,18 @@ watch(removeAllTags, () => {
<template #content>
<div class="space-y-4">
<div class="space-y-2">
<InputLabel for="description" value="Description" />
<Field>
<FieldLabel for="description">Description</FieldLabel>
<TextInput
id="description"
ref="descriptionInput"
v-model="description"
type="text"
class="mt-1 block w-full"
class="block w-full"
@keydown.enter="submit" />
</div>
<div class="space-y-2">
<InputLabel for="project" value="Project" />
</Field>
<Field>
<FieldLabel for="project">Project</FieldLabel>
<TimeTrackerProjectTaskDropdown
v-model:project="projectId"
v-model:task="taskId"
@@ -169,15 +169,14 @@ watch(removeAllTags, () => {
:create-client
:currency="currency"
:can-create-project
class="mt-1"
empty-placeholder="Select project..."
allow-reset
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</div>
<div class="space-y-2">
<InputLabel for="project" value="Tag" />
</Field>
<Field>
<FieldLabel>Tag</FieldLabel>
<div class="flex space-x-5">
<TagDropdown
v-model="selectedTags"
@@ -194,33 +193,31 @@ watch(removeAllTags, () => {
</Button>
</template>
</TagDropdown>
<div class="flex items-center space-x-2">
<Field orientation="horizontal">
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<InputLabel for="no_tags" value="Remove all tags" />
</div>
<FieldLabel for="no_tags">Remove all tags</FieldLabel>
</Field>
</div>
</div>
<div class="space-y-2">
<InputLabel for="project" value="Billable" />
<div class="flex">
<Select v-model="timeEntryBillable">
<SelectTrigger>
<SelectValue>
<span v-if="billable === undefined">Set billable status</span>
<span v-else-if="billable === true">Billable</span>
<span v-else>Non Billable</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="do-not-update">
Keep current billable status
</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="non-billable">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Field>
<Field>
<FieldLabel>Billable</FieldLabel>
<Select v-model="timeEntryBillable">
<SelectTrigger>
<SelectValue>
<span v-if="billable === undefined">Set billable status</span>
<span v-else-if="billable === true">Billable</span>
<span v-else>Non Billable</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="do-not-update">
Keep current billable status
</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="non-billable">Non Billable</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</template>
<template #footer>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import type { FieldVariants } from '.';
import { cn } from '@/lib/utils';
import { fieldVariants } from '.';
const props = defineProps<{
class?: HTMLAttributes['class'];
orientation?: FieldVariants['orientation'];
}>();
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(fieldVariants({ orientation }), props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="field-content"
:class="cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class
)
">
<slot />
</p>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
errors?: Array<{ message?: string } | undefined>;
}>();
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
if (props.errors.length === 1 && props.errors[0]?.message) {
return props.errors[0].message;
}
return props.errors.some((e) => e?.message) ? props.errors : null;
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)">
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
<li v-for="(error, index) in content" :key="index">
{{ error?.message }}
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class
)
">
<slot />
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { Label } from '@/Components/ui/label';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
props.class
)
">
<slot />
</Label>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
variant?: 'legend' | 'label';
}>();
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="
cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class
)
">
<slot />
</legend>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { Separator } from '../separator';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="
cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class
)
">
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content">
<slot />
</span>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class
)
">
<slot />
</fieldset>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class
)
">
<slot />
</div>
</template>

View File

@@ -0,0 +1,39 @@
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
}
);
export type FieldVariants = VariantProps<typeof fieldVariants>;
export { default as Field } from './Field.vue';
export { default as FieldContent } from './FieldContent.vue';
export { default as FieldDescription } from './FieldDescription.vue';
export { default as FieldError } from './FieldError.vue';
export { default as FieldGroup } from './FieldGroup.vue';
export { default as FieldLabel } from './FieldLabel.vue';
export { default as FieldLegend } from './FieldLegend.vue';
export { default as FieldSeparator } from './FieldSeparator.vue';
export { default as FieldSet } from './FieldSet.vue';
export { default as FieldTitle } from './FieldTitle.vue';

View File

@@ -43,6 +43,21 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
import { RangeCalendar } from './range-calendar/index';
import { CommandPalette } from './CommandPalette/index';
import { Separator } from './separator/index';
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
fieldVariants,
} from './field/index';
export type { FieldVariants } from './field/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export type {
CommandPaletteCommand,
@@ -93,4 +108,16 @@ export {
PopoverAnchor,
RangeCalendar,
CommandPalette,
Separator,
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
fieldVariants,
};

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Separator } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
props.class
)
" />
</template>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue';