refactor: clean up component templates and improve code readability across various Vue files by consolidating attributes and removing unnecessary line breaks

This commit is contained in:
Yoga Pangestu 2026-06-25 20:23:48 +07:00
parent cecbf92310
commit 459d568c1a
32 changed files with 324 additions and 598 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue';
interface LowStockItem {
name: string;
@ -26,36 +26,23 @@ withDefaults(defineProps<Props>(), {
<template>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
</div>
<component
v-if="icon"
:is="icon"
class="size-4"
:class="iconColorClass"
/>
<component v-if="icon" :is="icon" class="size-4" :class="iconColorClass" />
</CardHeader>
<CardContent>
<div v-if="items.length > 0" class="space-y-2">
<div
v-for="item in items"
:key="item.name"
class="flex items-center justify-between rounded-md border px-3 py-2"
>
<div v-for="item in items" :key="item.name"
class="flex items-center justify-between rounded-md border px-3 py-2">
<span class="text-sm">{{ item.name }}</span>
<Badge variant="outline" :class="badgeColorClass">
{{ item.stock }} {{ item.unit || 'pcs' }}
</Badge>
</div>
</div>
<div
v-else
class="flex h-[80px] items-center justify-center text-sm text-muted-foreground"
>
<div v-else class="flex h-[80px] items-center justify-center text-sm text-muted-foreground">
{{ emptyText }}
</div>
</CardContent>

View File

@ -124,7 +124,7 @@ const getTotalStock = (product: Product) => {
{{ getProductPriceRange(product) }}
</p>
<span class="text-[11px] text-slate-400 font-light mt-0.5">
Ukuran tersedia: {{ product.variants.map(v => v.name).join(', ') }}
Ukuran tersedia: {{product.variants.map(v => v.name).join(', ')}}
</span>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface StatItem {
label: string;
@ -40,16 +40,11 @@ withDefaults(defineProps<Props>(), {
{{ subLabel }}
</p>
</div>
<div
class="border-t pt-2 text-center"
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'"
>
<div class="border-t pt-2 text-center"
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'">
<div v-for="item in items" :key="item.label">
<p class="text-xs text-muted-foreground">{{ item.label }}</p>
<p
class="text-sm font-semibold"
:class="item.color ?? ''"
>
<p class="text-sm font-semibold" :class="item.color ?? ''">
{{ item.value }}
</p>
</div>

View File

@ -5,7 +5,6 @@ import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Button } from '@/components/ui/button';
import DataTableEmpty from './DataTableEmpty.vue';
import {
Table,
TableBody,
@ -21,6 +20,7 @@ import type {
DataTablePaginationLink,
DataTableSort,
} from '@/types/data-table';
import DataTableEmpty from './DataTableEmpty.vue';
const props = withDefaults(
defineProps<{
@ -157,11 +157,8 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined"
>
<TableHead v-for="header in headerGroup.headers" :key="header.id"
:class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" />
</TableHead>
@ -169,27 +166,17 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows.length">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
:class="getRowClassName?.(row.original, row.index)"
:data-state="row.getIsSelected() ? 'selected' : undefined"
>
<template
v-for="cell in row.getVisibleCells()"
:key="cell.id"
>
<TableCell
v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0"
:rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1
? resolveRowSpan(row.original, cell.column.id)
: undefined"
:class="[
cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
cell.column.columnDef.meta?.cellClassName,
]"
>
:data-state="row.getIsSelected() ? 'selected' : undefined">
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
<TableCell v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0" :rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1
? resolveRowSpan(row.original, cell.column.id)
: undefined" :class="[
cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
cell.column.columnDef.meta?.cellClassName,
]">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</template>
@ -202,26 +189,15 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
</Table>
</div>
<div
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div
v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>

View File

@ -36,12 +36,9 @@ function sortDirection(): 'asc' | 'desc' | null {
<template>
<div :class="cn('flex items-center gap-2', props.class)">
<Button
variant="ghost"
size="sm"
<Button variant="ghost" size="sm"
:class="cn('-ml-3 h-8 data-[state=open]:bg-accent', props.class?.includes('justify-end') && 'ml-auto')"
@click="sortContext?.onSort(column)"
>
@click="sortContext?.onSort(column)">
<span>{{ title }}</span>
<ArrowDown v-if="sortDirection() === 'desc'" class="size-4" />
<ArrowUp v-else-if="sortDirection() === 'asc'" class="size-4" />

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { cn } from '@/lib/utils';
withDefaults(
defineProps<{

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core';
import { ListFilter, Search } from '@lucide/vue';
import { useMediaQuery } from '@vueuse/core';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -66,12 +66,7 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<div class="flex items-center justify-between gap-4">
<div class="relative w-full max-w-sm">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
v-model="search"
type="search"
:placeholder="searchPlaceholder ?? 'Cari...'"
class="pl-9"
/>
<Input v-model="search" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
</div>
<Popover v-if="filterDefs?.length">
@ -79,25 +74,18 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<Button variant="outline" class="shrink-0">
<ListFilter class="size-4" />
Filter
<Badge
v-if="activeFilterCount > 0"
variant="secondary"
class="ml-1 size-5 rounded-full px-1 text-xs"
>
<Badge v-if="activeFilterCount > 0" variant="secondary"
class="ml-1 size-5 rounded-full px-1 text-xs">
{{ activeFilterCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-80 space-y-4 p-4"
@pointer-down-outside="(event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]')) {
event.preventDefault();
}
}"
>
<PopoverContent align="end" class="w-80 space-y-4 p-4" @pointer-down-outside="(event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]')) {
event.preventDefault();
}
}">
<div class="space-y-1">
<h4 class="text-sm font-medium">
Filter
@ -107,26 +95,16 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
</p>
</div>
<div
v-for="filter in filterDefs"
:key="filter.key"
class="space-y-2"
>
<div v-for="filter in filterDefs" :key="filter.key" class="space-y-2">
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
<Input
v-if="filter.type === 'text'"
:id="`filter-${filter.key}`"
<Input v-if="filter.type === 'text'" :id="`filter-${filter.key}`"
:model-value="filterValue(filter.key)"
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
/>
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
<Select
v-else
:model-value="filterValue(filter.key) || 'all'"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
>
<Select v-else :model-value="filterValue(filter.key) || 'all'"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))">
<SelectTrigger :id="`filter-${filter.key}`" class="w-full">
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
</SelectTrigger>
@ -134,24 +112,14 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<SelectItem value="all">
Semua
</SelectItem>
<SelectItem
v-for="option in filter.options"
:key="option.value"
:value="option.value"
>
<SelectItem v-for="option in filter.options" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
v-if="activeFilterCount > 0"
variant="ghost"
size="sm"
class="w-full"
@click="resetFilters"
>
<Button v-if="activeFilterCount > 0" variant="ghost" size="sm" class="w-full" @click="resetFilters">
Reset Filter
</Button>
</PopoverContent>

View File

@ -20,21 +20,21 @@ function formatDecimal(value: string | number): string {
const str = String(value ?? '').trim();
if (!str) {
return '';
}
return '';
}
// Normalize to dot for processing
const normalized = str.replace(',', '.');
if (isNaN(parseFloat(normalized))) {
return '';
}
return '';
}
// Split into integer and decimal parts
const parts = normalized.split('.');
const integerPart = parts[0];
let decimalPart = parts[1];
if (decimalPart !== undefined) {
decimalPart = decimalPart.slice(0, 2);
@ -49,20 +49,20 @@ function parseDecimal(value: string): string {
let normalized = value;
const hasComma = value.includes(',');
const dotCount = (value.match(/\./g) || []).length;
if (!hasComma && dotCount === 1) {
normalized = value.replace('.', ',');
}
// Remove all dots (if any were entered as thousands) and replace comma with dot
let clean = normalized.replace(/\./g, '').replace(',', '.');
const parts = clean.split('.');
if (parts.length > 2) {
clean = parts[0] + '.' + parts.slice(1).join('');
}
clean = clean.replace(/[^0-9.]/g, '');
const cleanParts = clean.split('.');
@ -70,7 +70,7 @@ function parseDecimal(value: string): string {
if (cleanParts[1] !== undefined) {
clean = `${cleanParts[0]}.${cleanParts[1].slice(0, 2)}`;
}
return clean;
}
@ -83,13 +83,6 @@ const displayValue = computed({
</script>
<template>
<Input
:id="id"
v-model="displayValue"
inputmode="decimal"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="props.class"
/>
<Input :id="id" v-model="displayValue" inputmode="decimal" autocomplete="off" :placeholder="placeholder ?? '0'"
:disabled="disabled" :class="props.class" />
</template>

View File

@ -56,34 +56,20 @@ function clearFile() {
</FieldDescription>
<div class="space-y-3">
<div
v-if="displayUrl"
:class="cn(
'relative overflow-hidden rounded-lg border bg-muted/30',
previewClass ?? 'aspect-video max-w-md',
)"
>
<div v-if="displayUrl" :class="cn(
'relative overflow-hidden rounded-lg border bg-muted/30',
previewClass ?? 'aspect-video max-w-md',
)">
<img :src="displayUrl" :alt="label" class="size-full object-cover">
<Button
v-if="model"
type="button"
variant="secondary"
size="icon"
class="absolute top-2 right-2 size-7"
@click="clearFile"
>
<Button v-if="model" type="button" variant="secondary" size="icon" class="absolute top-2 right-2 size-7"
@click="clearFile">
<X class="size-4" />
</Button>
</div>
<div class="flex items-center gap-2">
<Input
:id="id"
type="file"
:accept="accept ?? 'image/*'"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<Input :id="id" type="file" :accept="accept ?? 'image/*'" class="max-w-md cursor-pointer"
@change="onFileChange" />
<ImagePlus v-if="!displayUrl" class="text-muted-foreground size-5 shrink-0" />
</div>
</div>

View File

@ -101,54 +101,32 @@ onBeforeUnmount(() => {
<div class="space-y-3">
<div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2">
<button
v-for="item in visibleExisting"
:key="`existing-${item.id}`"
type="button"
<button v-for="item in visibleExisting" :key="`existing-${item.id}`" type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(item.url)"
>
@click="openPreview(item.url)">
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
<Button type="button" variant="secondary" size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeExisting(item.id)"
>
@click.stop="removeExisting(item.id)">
<X class="size-3" />
</Button>
</button>
<button
v-for="(url, index) in previewUrls"
:key="`new-${index}`"
type="button"
<button v-for="(url, index) in previewUrls" :key="`new-${index}`" type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(url)"
>
@click="openPreview(url)">
<img :src="url" :alt="label" class="size-full object-cover">
<Button
type="button"
variant="secondary"
size="icon"
<Button type="button" variant="secondary" size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeNew(index)"
>
@click.stop="removeNew(index)">
<X class="size-3" />
</Button>
</button>
</div>
<div v-if="canAddMore" class="flex items-center gap-2">
<Input
:id="id"
type="file"
accept="image/*"
:multiple="maxFiles > 1"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="max-w-md cursor-pointer"
@change="onFileChange" />
<ImagePlus class="text-muted-foreground size-5 shrink-0" />
</div>
</div>

View File

@ -20,8 +20,8 @@ function formatNumber(value: string | number): string {
const clean = str.replace(/\D/g, '');
if (!clean) {
return '';
}
return '';
}
return Number(clean).toLocaleString('id-ID');
}
@ -39,13 +39,6 @@ const displayValue = computed({
</script>
<template>
<Input
:id="id"
v-model="displayValue"
inputmode="numeric"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="props.class"
/>
<Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
:disabled="disabled" :class="props.class" />
</template>

View File

@ -27,19 +27,10 @@ const displayValue = computed({
<template>
<div class="relative">
<span
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm"
>
<span class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm">
Rp
</span>
<Input
:id="id"
v-model="displayValue"
inputmode="numeric"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="cn('pl-9', props.class)"
/>
<Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
:disabled="disabled" :class="cn('pl-9', props.class)" />
</div>
</template>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import DropZone from 'dropzone-vue';
import { computed, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import {
Field,
@ -8,11 +10,9 @@ import {
} from '@/components/ui/field';
import type { MediaUploadState } from '@/types/media';
import { createMediaUploadState } from '@/types/media';
import DropZone from 'dropzone-vue';
import 'dropzone-vue/dist/dropzone-vue.common.css';
import { computed, ref } from 'vue';
const props = withDefaults(
withDefaults(
defineProps<{
id: string;
label: string;
@ -51,8 +51,14 @@ type FilePreview = { id: string; file: File; objectUrl: string };
const filePreviews = ref<FilePreview[]>([]);
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@ -67,9 +73,13 @@ function onAddedFile(item: { id: string; file: File }) {
function onRemovedFile(item: { id: string; file: File }) {
const idx = state.value.newFiles.indexOf(item.file);
if (idx !== -1) state.value.newFiles.splice(idx, 1);
if (idx !== -1) {
state.value.newFiles.splice(idx, 1);
}
const pi = filePreviews.value.findIndex((p) => p.id === item.id);
if (pi !== -1) {
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
filePreviews.value.splice(pi, 1);
@ -99,8 +109,10 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
sizeLabel: 'Tersimpan',
onRemove: () => {
const idx = state.value.existing.findIndex((e) => e.id === item.id);
if (idx !== -1) {
state.value.existing.splice(idx, 1);
if (!state.value.removeIds.includes(item.id)) {
state.value.removeIds.push(item.id);
}
@ -155,179 +167,104 @@ function triggerDropzonePicker() {
<template>
<div class="media-dropzone-root">
<Field>
<FieldLabel :for="id" :required="required">
{{ label }}
</FieldLabel>
<FieldDescription v-if="description">
{{ description }}
</FieldDescription>
<FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
thumbnail.
</FieldDescription>
<Field>
<FieldLabel :for="id" :required="required">
{{ label }}
</FieldLabel>
<FieldDescription v-if="description">
{{ description }}
</FieldDescription>
<FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
thumbnail.
</FieldDescription>
<div class="dropzone-wrapper">
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
<div
:class="[
<div class="dropzone-wrapper">
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
<div :class="[
'dz-form-host',
{ 'dz-form-hidden': allPreviews.length > 0 },
]"
>
<DropZone
ref="dropzoneRef"
:max-files="maxFiles"
:max-file-size="maxFileSize"
:accepted-files="acceptedFiles"
:upload-on-drop="false"
:clickable="true"
dropzone-class-name="dz-box"
dropzone-message-class-name="dz-message"
@added-file="onAddedFile"
@removed-file="onRemovedFile"
>
<template #message>
<div class="dz-placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
]">
<DropZone ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
:accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
dropzone-class-name="dz-box" dropzone-message-class-name="dz-message" @added-file="onAddedFile"
@removed-file="onRemovedFile">
<template #message>
<div class="dz-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file,
{{ formatBytes(maxFileSize) }}/file
</span>
</div>
</template>
</DropZone>
</div>
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
<div v-if="allPreviews.length > 0" class="dz-overlay" @click="triggerDropzonePicker">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
</span>
</div>
</div>
<!-- Preview grid (existing + new) -->
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
<!-- Thumbnail -->
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
<div v-else class="preview-thumb-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file,
{{ formatBytes(maxFileSize) }}/file
</span>
</div>
</template>
</DropZone>
</div>
</div>
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
<div
v-if="allPreviews.length > 0"
class="dz-overlay"
@click="triggerDropzonePicker"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
</span>
</div>
</div>
<!-- Preview grid (existing + new) -->
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
<div
v-for="entry in allPreviews"
:key="entry.key"
class="preview-card"
>
<!-- Thumbnail -->
<div
class="preview-thumb"
@click="openPreview(entry.previewSrc)"
>
<img
v-if="entry.isImage"
:src="entry.thumbSrc"
:alt="entry.name"
/>
<div v-else class="preview-thumb-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<!-- Info + Remove -->
<div class="preview-footer">
<div class="preview-info">
<p class="preview-name" :title="entry.name">
{{ entry.name }}
</p>
<p class="preview-size">{{ entry.sizeLabel }}</p>
</div>
<button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<!-- Info + Remove -->
<div class="preview-footer">
<div class="preview-info">
<p class="preview-name" :title="entry.name">
{{ entry.name }}
</p>
<p class="preview-size">{{ entry.sizeLabel }}</p>
</div>
<button
type="button"
class="preview-remove"
title="Hapus"
@click="entry.onRemove()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
<FieldError :errors="errors" />
<FieldError :errors="errors" />
<MediaPreviewDialog
v-model:open="previewOpen"
:url="previewUrl"
:title="label"
/>
</Field>
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
</Field>
</div>
</template>

View File

@ -20,12 +20,8 @@ defineProps<{
<DialogHeader class="sr-only">
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
</DialogHeader>
<img
v-if="url"
:src="url"
:alt="title ?? 'Pratinjau gambar'"
class="max-h-[80vh] w-full rounded-md object-contain"
>
<img v-if="url" :src="url" :alt="title ?? 'Pratinjau gambar'"
class="max-h-[80vh] w-full rounded-md object-contain">
</DialogContent>
</Dialog>
</template>

View File

@ -22,13 +22,8 @@ function openPreview(url: string) {
<template>
<div v-if="items.length" class="flex items-center gap-1">
<button
v-for="item in visibleItems"
:key="item.id"
type="button"
class="size-8 overflow-hidden rounded border bg-muted/30"
@click="openPreview(item.url)"
>
<button v-for="item in visibleItems" :key="item.id" type="button"
class="size-8 overflow-hidden rounded border bg-muted/30" @click="openPreview(item.url)">
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
</button>
<span v-if="hiddenCount > 0" class="text-muted-foreground text-xs">

View File

@ -108,13 +108,12 @@ const handleAddToCart = () => {
<!-- Image Thumbnails -->
<div v-if="allImages.length > 1" class="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
<button v-for="(img, idx) in allImages" :key="img.id"
@click="activeImageIndex = idx" :class="[
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
activeImageIndex === idx
? 'border-amber-500 shadow-md shadow-amber-200/40'
: 'border-slate-200/60 opacity-60 hover:opacity-100'
]">
<button v-for="(img, idx) in allImages" :key="img.id" @click="activeImageIndex = idx" :class="[
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
activeImageIndex === idx
? 'border-amber-500 shadow-md shadow-amber-200/40'
: 'border-slate-200/60 opacity-60 hover:opacity-100'
]">
<img :src="img.thumb_url || img.url" :alt="`Gambar ${idx + 1}`"
class="w-full h-full object-cover" />
</button>
@ -226,6 +225,7 @@ const handleAddToCart = () => {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui";
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
@ -9,7 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</AlertDialogRoot>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</AlertDialogRoot>
</template>

View File

@ -12,7 +12,7 @@ const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@ -12,14 +12,11 @@ const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
<AlertDialogCancel v-bind="delegatedProps" :class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)">
<slot />
</AlertDialogCancel>
</template>

View File

@ -3,15 +3,15 @@ import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
@ -23,22 +23,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 pointer-events-auto fixed top-[50%] left-[50%] z-[201] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
<AlertDialogPortal>
<AlertDialogOverlay data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80" />
<AlertDialogContent data-slot="alert-dialog-content" v-bind="{ ...$attrs, ...forwarded }" :class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 pointer-events-auto fixed top-[50%] left-[50%] z-[201] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
">
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@ -3,7 +3,7 @@ import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
@ -13,11 +13,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
<AlertDialogDescription data-slot="alert-dialog-description" v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</AlertDialogDescription>
</template>

View File

@ -1,22 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn(
<div data-slot="alert-dialog-footer" :class="cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
)
">
<slot />
</div>
</template>

View File

@ -1,17 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
<div data-slot="alert-dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
<AlertDialogTitle data-slot="alert-dialog-title" v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)">
<slot />
</AlertDialogTitle>
</template>

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
import type { AlertDialogTriggerProps } from "reka-ui";
import { AlertDialogTrigger } from "reka-ui";
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@ -1,18 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { AvatarRoot } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
<AvatarRoot data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)">
<slot />
</AvatarRoot>
</template>

View File

@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
<AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)">
<slot />
</AvatarFallback>
</template>

View File

@ -1,16 +1,12 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
import type { AvatarImageProps } from "reka-ui";
import { AvatarImage } from "reka-ui";
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
<AvatarImage data-slot="avatar-image" v-bind="props" class="aspect-square size-full">
<slot />
</AvatarImage>
</template>

View File

@ -8,19 +8,15 @@ import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
<Primitive data-slot="badge" :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
<slot />
</Primitive>
</template>

View File

@ -7,25 +7,19 @@ import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
<Primitive data-slot="button" :data-variant="variant" :data-size="size" :as="as" :as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)">
<slot />
</Primitive>
</template>

View File

@ -49,16 +49,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { month: 'short' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
:model-value="date.month"
@change="(e: Event) => {
placeholder = placeholder.set({
month: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
<NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.month" @change="(e: Event) => {
placeholder = placeholder.set({
month: Number((e?.target as any)?.value),
})
}">
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()"
:value="month.month" :selected="date.month === month.month">
{{ formatter.custom(toDate(month), { month: 'short' }) }}
</NativeSelectOption>
</NativeSelect>
@ -72,16 +69,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
:model-value="date.year"
@change="(e: Event) => {
placeholder = placeholder.set({
year: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
<NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.year" @change="(e: Event) => {
placeholder = placeholder.set({
year: Number((e?.target as any)?.value),
})
}">
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year"
:selected="date.year === year.year">
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
</NativeSelectOption>
</NativeSelect>
@ -89,13 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</div>
</DefineYearTemplate>
<CalendarRoot
v-slot="{ grid, weekDays, date }"
v-bind="forwarded"
v-model:placeholder="placeholder"
data-slot="calendar"
:class="cn('p-3', props.class)"
>
<CalendarRoot v-slot="{ grid, weekDays, date }" v-bind="forwarded" v-model:placeholder="placeholder"
data-slot="calendar" :class="cn('p-3', props.class)">
<CalendarHeader class="pt-0">
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
<CalendarPrevButton>
@ -135,24 +124,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>

View File

@ -16,24 +16,20 @@ const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
data-slot="calendar-cell-trigger"
:class="cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 [&[data-selected]:hover]:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground',
props.class,
)"
v-bind="forwardedProps"
>
<CalendarCellTrigger data-slot="calendar-cell-trigger" :class="cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 [&[data-selected]:hover]:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground',
props.class,
)" v-bind="forwardedProps">
<slot />
</CalendarCellTrigger>
</template>