mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
fix: Add dropdown size control to Select components and improve UI (#5290)
* feature: size adjust * feature: design adjust * fix: popover width, height added * fix: li style override * refactor: improve component readability and maintainability per PR feedback - Replace CardGridList component with createGridStyle utility function - Add runtime validation for grid column values - Remove !important usage in MultiSelect, use cn() function instead - Extract popover sizing logic into usePopoverSizing composable - Improve class string readability by splitting into logical groups - Use Tailwind size utilities (size-8, size-10) instead of separate width/height - Remove magic numbers in SearchBox, align with button sizes - Rename BaseWidgetLayout to BaseModalLayout for clarity - Enhance SearchBox click area to cover entire component - Refactor long class strings using cn() utility across components * fix: BaseWidgetLayout => BaseModalLayout * fix: CardGrid deleted * fix: unused exported types * Update test expectations [skip ci] * chore: code review * Update test expectations [skip ci] * chore: restore screenshot --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -57,9 +57,8 @@
|
|||||||
|
|
||||||
/* Override Storybook's problematic & selector styles */
|
/* Override Storybook's problematic & selector styles */
|
||||||
/* Reset only the specific properties that Storybook injects */
|
/* Reset only the specific properties that Storybook injects */
|
||||||
#storybook-root li+li,
|
li+li {
|
||||||
#storybook-docs li+li {
|
margin: 0;
|
||||||
margin: inherit;
|
padding: revert-layer;
|
||||||
padding: inherit;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getButtonTypeClasses,
|
getButtonTypeClasses,
|
||||||
getIconButtonSizeClasses
|
getIconButtonSizeClasses
|
||||||
} from '@/types/buttonTypes'
|
} from '@/types/buttonTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
interface IconButtonProps extends BaseButtonProps {
|
interface IconButtonProps extends BaseButtonProps {
|
||||||
onClick: (event: Event) => void
|
onClick: (event: Event) => void
|
||||||
@@ -46,8 +47,6 @@ const buttonStyle = computed(() => {
|
|||||||
? getBorderButtonTypeClasses(type)
|
? getBorderButtonTypeClasses(type)
|
||||||
: getButtonTypeClasses(type)
|
: getButtonTypeClasses(type)
|
||||||
|
|
||||||
return [baseClasses, sizeClasses, typeClasses, className]
|
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="iconGroupClasses">
|
||||||
class="flex justify-center items-center shrink-0 outline-hidden border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
|
|
||||||
>
|
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const iconGroupClasses = cn(
|
||||||
|
'flex justify-center items-center shrink-0',
|
||||||
|
'outline-hidden border-none p-0 rounded-lg',
|
||||||
|
'bg-white dark-theme:bg-zinc-700',
|
||||||
|
'text-neutral-950 dark-theme:text-white',
|
||||||
|
'cursor-pointer'
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getButtonSizeClasses,
|
getButtonSizeClasses,
|
||||||
getButtonTypeClasses
|
getButtonTypeClasses
|
||||||
} from '@/types/buttonTypes'
|
} from '@/types/buttonTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false
|
inheritAttrs: false
|
||||||
@@ -52,8 +53,6 @@ const buttonStyle = computed(() => {
|
|||||||
? getBorderButtonTypeClasses(type)
|
? getBorderButtonTypeClasses(type)
|
||||||
: getButtonTypeClasses(type)
|
: getButtonTypeClasses(type)
|
||||||
|
|
||||||
return [baseClasses, sizeClasses, typeClasses, className]
|
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
unstyled
|
unstyled
|
||||||
:pt="pt"
|
:pt="pt"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1 p-2 min-w-40">
|
<div class="flex flex-col gap-2 p-2 min-w-40">
|
||||||
<slot :close="hide" />
|
<slot :close="hide" />
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import IconButton from './IconButton.vue'
|
import IconButton from './IconButton.vue'
|
||||||
|
|
||||||
const popover = ref<InstanceType<typeof Popover>>()
|
const popover = ref<InstanceType<typeof Popover>>()
|
||||||
@@ -39,13 +41,16 @@ const hide = () => {
|
|||||||
|
|
||||||
const pt = computed(() => ({
|
const pt = computed(() => ({
|
||||||
root: {
|
root: {
|
||||||
class: 'absolute z-50'
|
class: cn('absolute z-50')
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
class: [
|
class: cn(
|
||||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
|
'mt-2 rounded-lg',
|
||||||
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
|
'bg-white dark-theme:bg-zinc-800',
|
||||||
]
|
'text-neutral dark-theme:text-white',
|
||||||
|
'shadow-lg',
|
||||||
|
'border border-zinc-200 dark-theme:border-zinc-700'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getButtonSizeClasses,
|
getButtonSizeClasses,
|
||||||
getButtonTypeClasses
|
getButtonTypeClasses
|
||||||
} from '@/types/buttonTypes'
|
} from '@/types/buttonTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
interface TextButtonProps extends BaseButtonProps {
|
interface TextButtonProps extends BaseButtonProps {
|
||||||
label: string
|
label: string
|
||||||
@@ -48,8 +49,6 @@ const buttonStyle = computed(() => {
|
|||||||
? getBorderButtonTypeClasses(type)
|
? getBorderButtonTypeClasses(type)
|
||||||
: getButtonTypeClasses(type)
|
: getButtonTypeClasses(type)
|
||||||
|
|
||||||
return [baseClasses, sizeClasses, typeClasses, className]
|
return cn(baseClasses, sizeClasses, typeClasses, className)
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ const meta: Meta<CardStoryArgs> = {
|
|||||||
options: ['square', 'portrait', 'tallPortrait'],
|
options: ['square', 'portrait', 'tallPortrait'],
|
||||||
description: 'Card container aspect ratio'
|
description: 'Card container aspect ratio'
|
||||||
},
|
},
|
||||||
maxWidth: {
|
|
||||||
control: { type: 'range', min: 200, max: 600, step: 10 },
|
|
||||||
description: 'Maximum width in pixels'
|
|
||||||
},
|
|
||||||
minWidth: {
|
|
||||||
control: { type: 'range', min: 150, max: 400, step: 10 },
|
|
||||||
description: 'Minimum width in pixels'
|
|
||||||
},
|
|
||||||
topRatio: {
|
topRatio: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
options: ['square', 'landscape'],
|
options: ['square', 'landscape'],
|
||||||
@@ -155,11 +147,10 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
<div class="min-h-screen">
|
||||||
<CardContainer
|
<CardContainer
|
||||||
:ratio="args.containerRatio"
|
:ratio="args.containerRatio"
|
||||||
:max-width="args.maxWidth"
|
class="max-w-[320px] mx-auto"
|
||||||
:min-width="args.minWidth"
|
|
||||||
>
|
>
|
||||||
<template #top>
|
<template #top>
|
||||||
<CardTop :ratio="args.topRatio">
|
<CardTop :ratio="args.topRatio">
|
||||||
@@ -214,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #bottom>
|
<template #bottom>
|
||||||
<CardBottom class="p-3">
|
<CardBottom class="p-3 bg-neutral-100">
|
||||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||||
</CardBottom>
|
</CardBottom>
|
||||||
@@ -228,8 +219,6 @@ export const Default: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'portrait',
|
containerRatio: 'portrait',
|
||||||
maxWidth: 300,
|
|
||||||
minWidth: 200,
|
|
||||||
topRatio: 'square',
|
topRatio: 'square',
|
||||||
showTopLeft: false,
|
showTopLeft: false,
|
||||||
showTopRight: true,
|
showTopRight: true,
|
||||||
@@ -255,8 +244,6 @@ export const SquareCard: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'square',
|
containerRatio: 'square',
|
||||||
maxWidth: 400,
|
|
||||||
minWidth: 250,
|
|
||||||
topRatio: 'landscape',
|
topRatio: 'landscape',
|
||||||
showTopLeft: false,
|
showTopLeft: false,
|
||||||
showTopRight: true,
|
showTopRight: true,
|
||||||
@@ -282,8 +269,6 @@ export const TallPortraitCard: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'tallPortrait',
|
containerRatio: 'tallPortrait',
|
||||||
maxWidth: 280,
|
|
||||||
minWidth: 180,
|
|
||||||
topRatio: 'square',
|
topRatio: 'square',
|
||||||
showTopLeft: true,
|
showTopLeft: true,
|
||||||
showTopRight: true,
|
showTopRight: true,
|
||||||
@@ -309,8 +294,6 @@ export const ImageCard: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'portrait',
|
containerRatio: 'portrait',
|
||||||
maxWidth: 350,
|
|
||||||
minWidth: 220,
|
|
||||||
topRatio: 'square',
|
topRatio: 'square',
|
||||||
showTopLeft: false,
|
showTopLeft: false,
|
||||||
showTopRight: true,
|
showTopRight: true,
|
||||||
@@ -335,8 +318,6 @@ export const MinimalCard: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'square',
|
containerRatio: 'square',
|
||||||
maxWidth: 300,
|
|
||||||
minWidth: 200,
|
|
||||||
topRatio: 'landscape',
|
topRatio: 'landscape',
|
||||||
showTopLeft: false,
|
showTopLeft: false,
|
||||||
showTopRight: false,
|
showTopRight: false,
|
||||||
@@ -361,8 +342,6 @@ export const FullFeaturedCard: Story = {
|
|||||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||||
args: {
|
args: {
|
||||||
containerRatio: 'tallPortrait',
|
containerRatio: 'tallPortrait',
|
||||||
maxWidth: 320,
|
|
||||||
minWidth: 240,
|
|
||||||
topRatio: 'square',
|
topRatio: 'square',
|
||||||
showTopLeft: true,
|
showTopLeft: true,
|
||||||
showTopRight: true,
|
showTopRight: true,
|
||||||
@@ -376,270 +355,10 @@ export const FullFeaturedCard: Story = {
|
|||||||
backgroundColor: '#ef4444',
|
backgroundColor: '#ef4444',
|
||||||
showImage: false,
|
showImage: false,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
tags: ['Bundle', 'Premium', 'SDXL'],
|
tags: ['Bundle', 'SDXL'],
|
||||||
showFileSize: true,
|
showFileSize: true,
|
||||||
fileSize: '5.4 GB',
|
fileSize: '5.4 GB',
|
||||||
showFileType: true,
|
showFileType: true,
|
||||||
fileType: 'pack'
|
fileType: 'pack'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridOfCards: Story = {
|
|
||||||
render: () => ({
|
|
||||||
components: {
|
|
||||||
CardContainer,
|
|
||||||
CardTop,
|
|
||||||
CardBottom,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
IconButton,
|
|
||||||
SquareChip
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const cards = ref([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Realistic Vision',
|
|
||||||
description: 'Photorealistic model for portraits',
|
|
||||||
color: 'from-blue-400 to-blue-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['SD 1.5'],
|
|
||||||
size: '2.1 GB'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'DreamShaper XL',
|
|
||||||
description: 'Artistic style model with enhanced details',
|
|
||||||
color: 'from-purple-400 to-pink-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['SDXL'],
|
|
||||||
size: '6.5 GB'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Anime LoRA',
|
|
||||||
description: 'Character style LoRA',
|
|
||||||
color: 'from-green-400 to-teal-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['LoRA'],
|
|
||||||
size: '144 MB'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'VAE Model',
|
|
||||||
description: 'Enhanced color VAE',
|
|
||||||
color: 'from-orange-400 to-red-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['VAE'],
|
|
||||||
size: '335 MB'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: 'Workflow Bundle',
|
|
||||||
description: 'Complete workflow setup',
|
|
||||||
color: 'from-indigo-400 to-blue-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['Workflow'],
|
|
||||||
size: '45 KB'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: 'Embedding Pack',
|
|
||||||
description: 'Negative embeddings collection',
|
|
||||||
color: 'from-yellow-400 to-orange-600',
|
|
||||||
ratio: 'portrait' as const,
|
|
||||||
tags: ['Embedding'],
|
|
||||||
size: '2.3 MB'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
return { cards }
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
|
||||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
|
||||||
<CardContainer
|
|
||||||
v-for="card in cards"
|
|
||||||
:key="card.id"
|
|
||||||
:ratio="card.ratio"
|
|
||||||
:max-width="300"
|
|
||||||
:min-width="180"
|
|
||||||
>
|
|
||||||
<template #top>
|
|
||||||
<CardTop ratio="square">
|
|
||||||
<template #default>
|
|
||||||
<div
|
|
||||||
class="w-full h-full bg-gray-600"
|
|
||||||
:class="card.color"
|
|
||||||
></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #top-right>
|
|
||||||
<IconButton
|
|
||||||
class="!bg-white/90 !text-neutral-900"
|
|
||||||
@click="() => console.log('Info:', card.title)"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--info] size-4" />
|
|
||||||
</IconButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #bottom-right>
|
|
||||||
<SquareChip
|
|
||||||
v-for="tag in card.tags"
|
|
||||||
:key="tag"
|
|
||||||
:label="tag"
|
|
||||||
>
|
|
||||||
<template v-if="tag === 'LoRA'" #icon>
|
|
||||||
<i class="icon-[lucide--folder] size-3" />
|
|
||||||
</template>
|
|
||||||
</SquareChip>
|
|
||||||
<SquareChip :label="card.size" />
|
|
||||||
</template>
|
|
||||||
</CardTop>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #bottom>
|
|
||||||
<CardBottom class="p-3">
|
|
||||||
<CardTitle>{{ card.title }}</CardTitle>
|
|
||||||
<CardDescription>{{ card.description }}</CardDescription>
|
|
||||||
</CardBottom>
|
|
||||||
</template>
|
|
||||||
</CardContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResponsiveGrid: Story = {
|
|
||||||
render: () => ({
|
|
||||||
components: {
|
|
||||||
CardContainer,
|
|
||||||
CardTop,
|
|
||||||
CardBottom,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
SquareChip
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const generateCards = (
|
|
||||||
count: number,
|
|
||||||
ratio: 'square' | 'portrait' | 'tallPortrait'
|
|
||||||
) => {
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
title: `Model ${i + 1}`,
|
|
||||||
description: `Description for model ${i + 1}`,
|
|
||||||
ratio,
|
|
||||||
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const squareCards = ref(generateCards(4, 'square'))
|
|
||||||
const portraitCards = ref(generateCards(6, 'portrait'))
|
|
||||||
const tallCards = ref(generateCards(5, 'tallPortrait'))
|
|
||||||
|
|
||||||
return {
|
|
||||||
squareCards,
|
|
||||||
portraitCards,
|
|
||||||
tallCards
|
|
||||||
}
|
|
||||||
},
|
|
||||||
template: `
|
|
||||||
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
<CardContainer
|
|
||||||
v-for="card in squareCards"
|
|
||||||
:key="card.id"
|
|
||||||
:ratio="card.ratio"
|
|
||||||
:max-width="400"
|
|
||||||
:min-width="200"
|
|
||||||
>
|
|
||||||
<template #top>
|
|
||||||
<CardTop ratio="landscape">
|
|
||||||
<div
|
|
||||||
class="w-full h-full"
|
|
||||||
:style="{ backgroundColor: card.color }"
|
|
||||||
></div>
|
|
||||||
</CardTop>
|
|
||||||
</template>
|
|
||||||
<template #bottom>
|
|
||||||
<CardBottom class="p-3">
|
|
||||||
<CardTitle>{{ card.title }}</CardTitle>
|
|
||||||
<CardDescription>{{ card.description }}</CardDescription>
|
|
||||||
</CardBottom>
|
|
||||||
</template>
|
|
||||||
</CardContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
||||||
<CardContainer
|
|
||||||
v-for="card in portraitCards"
|
|
||||||
:key="card.id"
|
|
||||||
:ratio="card.ratio"
|
|
||||||
:max-width="280"
|
|
||||||
:min-width="160"
|
|
||||||
>
|
|
||||||
<template #top>
|
|
||||||
<CardTop ratio="square">
|
|
||||||
<div
|
|
||||||
class="w-full h-full"
|
|
||||||
:style="{ backgroundColor: card.color }"
|
|
||||||
></div>
|
|
||||||
</CardTop>
|
|
||||||
</template>
|
|
||||||
<template #bottom>
|
|
||||||
<CardBottom class="p-2">
|
|
||||||
<CardTitle>{{ card.title }}</CardTitle>
|
|
||||||
</CardBottom>
|
|
||||||
</template>
|
|
||||||
</CardContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
||||||
<CardContainer
|
|
||||||
v-for="card in tallCards"
|
|
||||||
:key="card.id"
|
|
||||||
:ratio="card.ratio"
|
|
||||||
:max-width="260"
|
|
||||||
:min-width="150"
|
|
||||||
>
|
|
||||||
<template #top>
|
|
||||||
<CardTop ratio="square">
|
|
||||||
<template #default>
|
|
||||||
<div
|
|
||||||
class="w-full h-full"
|
|
||||||
:style="{ backgroundColor: card.color }"
|
|
||||||
></div>
|
|
||||||
</template>
|
|
||||||
<template #bottom-right>
|
|
||||||
<SquareChip :label="'#' + card.id" />
|
|
||||||
</template>
|
|
||||||
</CardTop>
|
|
||||||
</template>
|
|
||||||
<template #bottom>
|
|
||||||
<CardBottom class="p-3">
|
|
||||||
<CardTitle>{{ card.title }}</CardTitle>
|
|
||||||
<CardDescription>{{ card.description }}</CardDescription>
|
|
||||||
</CardBottom>
|
|
||||||
</template>
|
|
||||||
</CardContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}),
|
|
||||||
parameters: {
|
|
||||||
controls: { disable: true },
|
|
||||||
actions: { disable: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="containerClasses" :style="containerStyle">
|
<div :class="containerClasses">
|
||||||
<slot name="top"></slot>
|
<slot name="top"></slot>
|
||||||
<slot name="bottom"></slot>
|
<slot name="bottom"></slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,13 +8,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const {
|
const { ratio = 'square' } = defineProps<{
|
||||||
ratio = 'square',
|
|
||||||
maxWidth,
|
|
||||||
minWidth
|
|
||||||
} = defineProps<{
|
|
||||||
maxWidth?: number
|
|
||||||
minWidth?: number
|
|
||||||
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
ratio?: 'square' | 'portrait' | 'tallPortrait'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -30,13 +24,4 @@ const containerClasses = computed(() => {
|
|||||||
|
|
||||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const containerStyle = computed(() =>
|
|
||||||
maxWidth || minWidth
|
|
||||||
? {
|
|
||||||
maxWidth: `${maxWidth}px`,
|
|
||||||
minWidth: `${minWidth}px`
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
69
src/components/card/CardGridList.stories.ts
Normal file
69
src/components/card/CardGridList.stories.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import { createGridStyle } from '@/utils/gridUtil'
|
||||||
|
|
||||||
|
import CardBottom from './CardBottom.vue'
|
||||||
|
import CardContainer from './CardContainer.vue'
|
||||||
|
import CardTop from './CardTop.vue'
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Components/Card/CardGridList',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
minWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Minimum width for each grid item'
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Maximum width for each grid item'
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Padding around the grid'
|
||||||
|
},
|
||||||
|
gap: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Gap between grid items'
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
control: 'number',
|
||||||
|
description: 'Fixed number of columns (overrides auto-fill)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
minWidth: '15rem',
|
||||||
|
maxWidth: '1fr',
|
||||||
|
padding: '0rem',
|
||||||
|
gap: '1rem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
components: { CardContainer, CardTop, CardBottom },
|
||||||
|
setup() {
|
||||||
|
const gridStyle = createGridStyle(args)
|
||||||
|
return { gridStyle }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div :style="gridStyle">
|
||||||
|
<CardContainer v-for="i in 12" :key="i" ratio="square">
|
||||||
|
<template #top>
|
||||||
|
<CardTop ratio="landscape">
|
||||||
|
<template #default>
|
||||||
|
<div class="w-full h-full bg-blue-500"></div>
|
||||||
|
</template>
|
||||||
|
</CardTop>
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<CardBottom class="bg-neutral-200"></CardBottom>
|
||||||
|
</template>
|
||||||
|
</CardContainer>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
|
|||||||
showSelectedCount?: boolean
|
showSelectedCount?: boolean
|
||||||
showClearButton?: boolean
|
showClearButton?: boolean
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
listMaxHeight?: string
|
||||||
|
popoverMinWidth?: string
|
||||||
|
popoverMaxWidth?: string
|
||||||
// Override modelValue type to match our Option type
|
// Override modelValue type to match our Option type
|
||||||
modelValue?: Array<{ name: string; value: string }>
|
modelValue?: Array<{ name: string; value: string }>
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,18 @@ const meta: Meta<ExtendedProps> = {
|
|||||||
},
|
},
|
||||||
searchPlaceholder: {
|
searchPlaceholder: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
},
|
||||||
|
listMaxHeight: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Maximum height of the dropdown list'
|
||||||
|
},
|
||||||
|
popoverMinWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Minimum width of the popover'
|
||||||
|
},
|
||||||
|
popoverMaxWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Maximum width of the popover'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
@@ -274,3 +289,140 @@ export const CustomSearchPlaceholder: Story = {
|
|||||||
searchPlaceholder: 'Filter packages...'
|
searchPlaceholder: 'Filter packages...'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CustomMaxHeight: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { MultiSelect },
|
||||||
|
setup() {
|
||||||
|
const selected1 = ref([])
|
||||||
|
const selected2 = ref([])
|
||||||
|
const selected3 = ref([])
|
||||||
|
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
name: `Option ${i + 1}`,
|
||||||
|
value: `option${i + 1}`
|
||||||
|
}))
|
||||||
|
return { selected1, selected2, selected3, manyOptions }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selected1"
|
||||||
|
:options="manyOptions"
|
||||||
|
label="Small Dropdown"
|
||||||
|
list-max-height="10rem"
|
||||||
|
show-selected-count
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selected2"
|
||||||
|
:options="manyOptions"
|
||||||
|
label="Default Dropdown"
|
||||||
|
list-max-height="28rem"
|
||||||
|
show-selected-count
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="selected3"
|
||||||
|
:options="manyOptions"
|
||||||
|
label="Large Dropdown"
|
||||||
|
list-max-height="32rem"
|
||||||
|
show-selected-count
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMinWidth: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { MultiSelect },
|
||||||
|
setup() {
|
||||||
|
const selected1 = ref([])
|
||||||
|
const selected2 = ref([])
|
||||||
|
const selected3 = ref([])
|
||||||
|
const options = [
|
||||||
|
{ name: 'A', value: 'a' },
|
||||||
|
{ name: 'B', value: 'b' },
|
||||||
|
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||||
|
]
|
||||||
|
return { selected1, selected2, selected3, options }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||||
|
<MultiSelect v-model="selected1" :options="options" label="Auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
|
||||||
|
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
|
||||||
|
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMaxWidth: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { MultiSelect },
|
||||||
|
setup() {
|
||||||
|
const selected1 = ref([])
|
||||||
|
const selected2 = ref([])
|
||||||
|
const selected3 = ref([])
|
||||||
|
const longOptions = [
|
||||||
|
{ name: 'Short', value: 'short' },
|
||||||
|
{
|
||||||
|
name: 'This is a very long option name that would normally expand the dropdown',
|
||||||
|
value: 'long1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||||
|
value: 'long2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return { selected1, selected2, selected3, longOptions }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||||
|
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
|
||||||
|
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
|
||||||
|
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||||
1. Our value template only shows a static label (not dynamic based on selection)
|
1. Our value template only shows a static label (not dynamic based on selection)
|
||||||
2. We display a count badge instead of actual selected labels
|
2. We display a count badge instead of actual selected labels
|
||||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||||
|
|
||||||
option-label="name" is required because our option template directly accesses option.name
|
option-label="name" is required because our option template directly accesses option.name
|
||||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||||
-->
|
-->
|
||||||
@@ -20,12 +19,13 @@
|
|||||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||||
#header
|
#header
|
||||||
>
|
>
|
||||||
<div class="p-2 flex flex-col pb-0">
|
<div class="pt-2 pb-0 px-2 flex flex-col">
|
||||||
<SearchBox
|
<SearchBox
|
||||||
v-if="showSearchBox"
|
v-if="showSearchBox"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||||
:show-order="true"
|
:show-order="true"
|
||||||
|
:show-border="true"
|
||||||
:place-holder="searchPlaceholder"
|
:place-holder="searchPlaceholder"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
:label="$t('g.clearAll')"
|
:label="$t('g.clearAll')"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
size="fit-content"
|
size="fit-content"
|
||||||
class="text-sm text-blue-500! dark-theme:text-blue-600!"
|
class="text-sm text-blue-500 dark-theme:text-blue-600"
|
||||||
@click.stop="selectedItems = []"
|
@click.stop="selectedItems = []"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
<div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,13 +75,13 @@
|
|||||||
|
|
||||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||||
<template #option="slotProps">
|
<template #option="slotProps">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" :style="popoverStyle">
|
||||||
<div
|
<div
|
||||||
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
|
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
|
||||||
:class="
|
:class="
|
||||||
slotProps.selected
|
slotProps.selected
|
||||||
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||||
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
|
: 'bg-neutral-100 dark-theme:bg-zinc-700'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i-lucide:check
|
<i-lucide:check
|
||||||
@@ -89,9 +89,11 @@
|
|||||||
class="text-xs text-bold text-white"
|
class="text-xs text-bold text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button class="border-none outline-none bg-transparent" unstyled>{{
|
<Button
|
||||||
slotProps.option.name
|
class="border-none outline-none bg-transparent text-left"
|
||||||
}}</Button>
|
unstyled
|
||||||
|
>{{ slotProps.option.name }}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
@@ -105,6 +107,8 @@ import MultiSelect, {
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import SearchBox from '@/components/input/SearchBox.vue'
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
|
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import TextButton from '../button/TextButton.vue'
|
import TextButton from '../button/TextButton.vue'
|
||||||
|
|
||||||
@@ -125,6 +129,12 @@ interface Props {
|
|||||||
showClearButton?: boolean
|
showClearButton?: boolean
|
||||||
/** Placeholder for the search input */
|
/** Placeholder for the search input */
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||||
|
listMaxHeight?: string
|
||||||
|
/** Minimum width of the popover (default: auto) */
|
||||||
|
popoverMinWidth?: string
|
||||||
|
/** Maximum width of the popover (default: auto) */
|
||||||
|
popoverMaxWidth?: string
|
||||||
// Note: options prop is intentionally omitted.
|
// Note: options prop is intentionally omitted.
|
||||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||||
}
|
}
|
||||||
@@ -133,7 +143,10 @@ const {
|
|||||||
showSearchBox = false,
|
showSearchBox = false,
|
||||||
showSelectedCount = false,
|
showSelectedCount = false,
|
||||||
showClearButton = false,
|
showClearButton = false,
|
||||||
searchPlaceholder = 'Search...'
|
searchPlaceholder = 'Search...',
|
||||||
|
listMaxHeight = '28rem',
|
||||||
|
popoverMinWidth,
|
||||||
|
popoverMaxWidth
|
||||||
} = defineProps<Props>()
|
} = defineProps<Props>()
|
||||||
|
|
||||||
const selectedItems = defineModel<Option[]>({
|
const selectedItems = defineModel<Option[]>({
|
||||||
@@ -142,10 +155,15 @@ const selectedItems = defineModel<Option[]>({
|
|||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery')
|
||||||
const selectedCount = computed(() => selectedItems.value.length)
|
const selectedCount = computed(() => selectedItems.value.length)
|
||||||
|
|
||||||
|
const popoverStyle = usePopoverSizing({
|
||||||
|
minWidth: popoverMinWidth,
|
||||||
|
maxWidth: popoverMaxWidth
|
||||||
|
})
|
||||||
|
|
||||||
const pt = computed(() => ({
|
const pt = computed(() => ({
|
||||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||||
class: [
|
class: [
|
||||||
'relative inline-flex cursor-pointer select-none',
|
'h-10 relative inline-flex cursor-pointer select-none',
|
||||||
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
'border-[2.5px] border-solid',
|
'border-[2.5px] border-solid',
|
||||||
@@ -170,16 +188,26 @@ const pt = computed(() => ({
|
|||||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||||
}),
|
}),
|
||||||
// Overlay & list visuals unchanged
|
// Overlay & list visuals unchanged
|
||||||
overlay:
|
overlay: {
|
||||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
|
class: cn(
|
||||||
|
'mt-2 rounded-lg py-2 px-2',
|
||||||
|
'bg-white dark-theme:bg-zinc-800',
|
||||||
|
'text-neutral dark-theme:text-white',
|
||||||
|
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
listContainer: () => ({
|
||||||
|
style: { maxHeight: listMaxHeight },
|
||||||
|
class: 'overflow-y-auto scrollbar-hide'
|
||||||
|
}),
|
||||||
list: {
|
list: {
|
||||||
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||||
},
|
},
|
||||||
// Option row hover and focus tone
|
// Option row hover and focus tone
|
||||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||||
class: [
|
class: [
|
||||||
'flex gap-1 items-center p-2',
|
'flex gap-2 items-center h-10 px-2 rounded-lg',
|
||||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||||
// Add focus/highlight state for keyboard navigation
|
// Add focus/highlight state for keyboard navigation
|
||||||
{
|
{
|
||||||
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
||||||
@@ -189,11 +217,11 @@ const pt = computed(() => ({
|
|||||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||||
pcHeaderCheckbox: {
|
pcHeaderCheckbox: {
|
||||||
root: { class: 'hidden' },
|
root: { class: 'hidden' },
|
||||||
style: 'display: none !important'
|
style: { display: 'none' }
|
||||||
},
|
},
|
||||||
pcOptionCheckbox: {
|
pcOptionCheckbox: {
|
||||||
root: { class: 'hidden' },
|
root: { class: 'hidden' },
|
||||||
style: 'display: none !important'
|
style: { display: 'none' }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ const meta: Meta<typeof SearchBox> = {
|
|||||||
showBorder: {
|
showBorder: {
|
||||||
control: 'boolean',
|
control: 'boolean',
|
||||||
description: 'Toggle border prop'
|
description: 'Toggle border prop'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['md', 'lg'],
|
||||||
|
description: 'Size variant of the search box'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
placeHolder: 'Search...',
|
placeHolder: 'Search...',
|
||||||
showBorder: false
|
showBorder: false,
|
||||||
|
size: 'md'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,3 +59,27 @@ export const NoBorder: Story = {
|
|||||||
showBorder: false
|
showBorder: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MediumSize: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
size: 'md',
|
||||||
|
showBorder: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LargeSize: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
size: 'lg',
|
||||||
|
showBorder: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LargeSizeWithBorder: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
size: 'lg',
|
||||||
|
showBorder: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
:placeholder="placeHolder || 'Search...'"
|
:placeholder="placeHolder || 'Search...'"
|
||||||
type="text"
|
type="text"
|
||||||
unstyled
|
unstyled
|
||||||
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white"
|
:class="inputStyle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -15,20 +15,56 @@
|
|||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const { placeHolder, showBorder = false } = defineProps<{
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const {
|
||||||
|
placeHolder,
|
||||||
|
showBorder = false,
|
||||||
|
size = 'md'
|
||||||
|
} = defineProps<{
|
||||||
placeHolder?: string
|
placeHolder?: string
|
||||||
showBorder?: boolean
|
showBorder?: boolean
|
||||||
|
size?: 'md' | 'lg'
|
||||||
}>()
|
}>()
|
||||||
// defineModel without arguments uses 'modelValue' as the prop name
|
// defineModel without arguments uses 'modelValue' as the prop name
|
||||||
const searchQuery = defineModel<string>()
|
const searchQuery = defineModel<string>()
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
return showBorder
|
const baseClasses = [
|
||||||
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
|
'relative flex w-full items-center gap-2',
|
||||||
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
|
'bg-white dark-theme:bg-zinc-800',
|
||||||
|
'cursor-text'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (showBorder) {
|
||||||
|
return cn(
|
||||||
|
...baseClasses,
|
||||||
|
'rounded p-2',
|
||||||
|
'border border-solid',
|
||||||
|
'border-zinc-200 dark-theme:border-zinc-700'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size-specific classes matching button sizes for consistency
|
||||||
|
const sizeClasses = {
|
||||||
|
md: 'h-8 px-2 py-1.5', // Matches button sm size
|
||||||
|
lg: 'h-10 px-4 py-2' // Matches button md size
|
||||||
|
}[size]
|
||||||
|
|
||||||
|
return cn(...baseClasses, 'rounded-lg', sizeClasses)
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputStyle = computed(() => {
|
||||||
|
return cn(
|
||||||
|
'absolute inset-0 w-full h-full pl-11',
|
||||||
|
'border-none outline-none bg-transparent',
|
||||||
|
'text-sm text-neutral dark-theme:text-white'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const iconColorStyle = computed(() => {
|
const iconColorStyle = computed(() => {
|
||||||
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
return cn(
|
||||||
|
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
|
||||||
|
)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ const meta: Meta<typeof SingleSelect> = {
|
|||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
label: { control: 'text' },
|
label: { control: 'text' },
|
||||||
options: { control: 'object' }
|
options: { control: 'object' },
|
||||||
|
listMaxHeight: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Maximum height of the dropdown list'
|
||||||
|
},
|
||||||
|
popoverMinWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Minimum width of the popover'
|
||||||
|
},
|
||||||
|
popoverMaxWidth: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Maximum width of the popover'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
label: 'Sorting Type',
|
label: 'Sorting Type',
|
||||||
@@ -121,6 +133,124 @@ export const AllVariants: Story = {
|
|||||||
}),
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: { disable: true },
|
controls: { disable: true },
|
||||||
actions: { disable: true }
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMaxHeight: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { SingleSelect },
|
||||||
|
setup() {
|
||||||
|
const selected = ref<string | null>(null)
|
||||||
|
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
name: `Option ${i + 1}`,
|
||||||
|
value: `option${i + 1}`
|
||||||
|
}))
|
||||||
|
return { selected, manyOptions }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
|
||||||
|
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
|
||||||
|
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
|
||||||
|
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMinWidth: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { SingleSelect },
|
||||||
|
setup() {
|
||||||
|
const selected1 = ref<string | null>(null)
|
||||||
|
const selected2 = ref<string | null>(null)
|
||||||
|
const selected3 = ref<string | null>(null)
|
||||||
|
const options = [
|
||||||
|
{ name: 'A', value: 'a' },
|
||||||
|
{ name: 'B', value: 'b' },
|
||||||
|
{ name: 'Very Long Option Name Here', value: 'long' }
|
||||||
|
]
|
||||||
|
return { selected1, selected2, selected3, options }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||||
|
<SingleSelect v-model="selected1" :options="options" label="Auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
|
||||||
|
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
|
||||||
|
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMaxWidth: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { SingleSelect },
|
||||||
|
setup() {
|
||||||
|
const selected1 = ref<string | null>(null)
|
||||||
|
const selected2 = ref<string | null>(null)
|
||||||
|
const selected3 = ref<string | null>(null)
|
||||||
|
const longOptions = [
|
||||||
|
{ name: 'Short', value: 'short' },
|
||||||
|
{
|
||||||
|
name: 'This is a very long option name that would normally expand the dropdown',
|
||||||
|
value: 'long1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Another extremely long option that demonstrates max-width constraint',
|
||||||
|
value: 'long2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return { selected1, selected2, selected3, longOptions }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
|
||||||
|
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
|
||||||
|
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
|
||||||
|
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
controls: { disable: true },
|
||||||
|
actions: { disable: true },
|
||||||
|
slot: { disable: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
Note: We explicitly pass options here (not just via $attrs) because:
|
Note: We explicitly pass options here (not just via $attrs) because:
|
||||||
1. Our custom value template needs options to look up labels from values
|
1. Our custom value template needs options to look up labels from values
|
||||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||||
3. We need to maintain the icon slot functionality in the value template
|
3. We need to maintain the icon slot functionality in the value template
|
||||||
|
|
||||||
option-label="name" is required because our option template directly accesses option.name
|
option-label="name" is required because our option template directly accesses option.name
|
||||||
-->
|
-->
|
||||||
<Select
|
<Select
|
||||||
@@ -18,7 +17,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Trigger value -->
|
<!-- Trigger value -->
|
||||||
<template #value="slotProps">
|
<template #value="slotProps">
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
<span
|
<span
|
||||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||||
@@ -34,18 +33,19 @@
|
|||||||
|
|
||||||
<!-- Trigger caret -->
|
<!-- Trigger caret -->
|
||||||
<template #dropdownicon>
|
<template #dropdownicon>
|
||||||
<i-lucide:chevron-down
|
<i-lucide:chevron-down class="text-base text-neutral-500" />
|
||||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Option row -->
|
<!-- Option row -->
|
||||||
<template #option="{ option, selected }">
|
<template #option="{ option, selected }">
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<div
|
||||||
|
class="flex items-center justify-between gap-3 w-full"
|
||||||
|
:style="optionStyle"
|
||||||
|
>
|
||||||
<span class="truncate">{{ option.name }}</span>
|
<span class="truncate">{{ option.name }}</span>
|
||||||
<i-lucide:check
|
<i-lucide:check
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
class="text-neutral-900 dark-theme:text-white"
|
class="text-neutral-600 dark-theme:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,11 +56,19 @@
|
|||||||
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false
|
inheritAttrs: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const { label, options } = defineProps<{
|
const {
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
listMaxHeight = '28rem',
|
||||||
|
popoverMinWidth,
|
||||||
|
popoverMaxWidth
|
||||||
|
} = defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
/**
|
/**
|
||||||
* Required for displaying the selected item's label.
|
* Required for displaying the selected item's label.
|
||||||
@@ -71,6 +79,12 @@ const { label, options } = defineProps<{
|
|||||||
name: string
|
name: string
|
||||||
value: string
|
value: string
|
||||||
}[]
|
}[]
|
||||||
|
/** Maximum height of the dropdown panel (default: 28rem) */
|
||||||
|
listMaxHeight?: string
|
||||||
|
/** Minimum width of the popover (default: auto) */
|
||||||
|
popoverMinWidth?: string
|
||||||
|
/** Maximum width of the popover (default: auto) */
|
||||||
|
popoverMaxWidth?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedItem = defineModel<string | null>({ required: true })
|
const selectedItem = defineModel<string | null>({ required: true })
|
||||||
@@ -87,6 +101,17 @@ const getLabel = (val: string | null | undefined) => {
|
|||||||
return found ? found.name : label ?? ''
|
return found ? found.name : label ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract complex style logic from template
|
||||||
|
const optionStyle = computed(() => {
|
||||||
|
if (!popoverMinWidth && !popoverMaxWidth) return undefined
|
||||||
|
|
||||||
|
const styles: string[] = []
|
||||||
|
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
|
||||||
|
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
|
||||||
|
|
||||||
|
return styles.join('; ')
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unstyled + PT API only
|
* Unstyled + PT API only
|
||||||
* - No background/border (same as page background)
|
* - No background/border (same as page background)
|
||||||
@@ -98,7 +123,7 @@ const pt = computed(() => ({
|
|||||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||||
class: [
|
class: [
|
||||||
// container
|
// container
|
||||||
'relative inline-flex cursor-pointer select-none items-center',
|
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||||
// trigger surface
|
// trigger surface
|
||||||
'rounded-md',
|
'rounded-md',
|
||||||
'bg-transparent text-neutral dark-theme:text-white',
|
'bg-transparent text-neutral dark-theme:text-white',
|
||||||
@@ -118,23 +143,28 @@ const pt = computed(() => ({
|
|||||||
'flex shrink-0 items-center justify-center px-3 py-2'
|
'flex shrink-0 items-center justify-center px-3 py-2'
|
||||||
},
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
class: [
|
class: cn(
|
||||||
// dropdown panel
|
'mt-2 p-2 rounded-lg',
|
||||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700'
|
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||||
]
|
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
listContainer: () => ({
|
||||||
|
style: `max-height: ${listMaxHeight}`,
|
||||||
|
class: 'overflow-y-auto scrollbar-hide'
|
||||||
|
}),
|
||||||
list: {
|
list: {
|
||||||
class:
|
class:
|
||||||
// Same list tone/size as MultiSelect
|
// Same list tone/size as MultiSelect
|
||||||
'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
|
||||||
},
|
},
|
||||||
option: ({
|
option: ({
|
||||||
context
|
context
|
||||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||||
class: [
|
class: [
|
||||||
// Row layout
|
// Row layout
|
||||||
'flex items-center justify-between gap-3 px-3 py-2',
|
'flex items-center justify-between gap-3 px-2 py-3 rounded',
|
||||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||||
// Selected state + check icon
|
// Selected state + check icon
|
||||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
||||||
// Add focus state for keyboard navigation
|
// Add focus state for keyboard navigation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseWidgetLayout :content-title="$t('Checkpoints')">
|
<BaseModalLayout :content-title="$t('Checkpoints')">
|
||||||
<template #leftPanel>
|
<template #leftPanel>
|
||||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||||
<template #header-icon>
|
<template #header-icon>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header-right-area>
|
<template #header-right-area>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #contentFilter>
|
<template #contentFilter>
|
||||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
<div class="relative px-6 pb-4 flex gap-2">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="selectedFrameworks"
|
v-model="selectedFrameworks"
|
||||||
v-model:search-query="searchText"
|
v-model:search-query="searchText"
|
||||||
@@ -87,14 +87,8 @@
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- Card Examples -->
|
<!-- Card Examples -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div :style="gridStyle">
|
||||||
<CardContainer
|
<CardContainer v-for="i in 100" :key="i" ratio="square">
|
||||||
v-for="i in 100"
|
|
||||||
:key="i"
|
|
||||||
ratio="square"
|
|
||||||
:max-width="480"
|
|
||||||
:min-width="230"
|
|
||||||
>
|
|
||||||
<template #top>
|
<template #top>
|
||||||
<CardTop ratio="landscape">
|
<CardTop ratio="landscape">
|
||||||
<template #default>
|
<template #default>
|
||||||
@@ -124,17 +118,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
</div>
|
</div>
|
||||||
<!-- </div> -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #rightPanel>
|
<template #rightPanel>
|
||||||
<RightSidePanel></RightSidePanel>
|
<RightSidePanel></RightSidePanel>
|
||||||
</template>
|
</template>
|
||||||
</BaseWidgetLayout>
|
</BaseModalLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { provide, ref, watch } from 'vue'
|
import { computed, provide, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import IconButton from '@/components/button/IconButton.vue'
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
@@ -147,11 +140,12 @@ import SquareChip from '@/components/chip/SquareChip.vue'
|
|||||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
import SearchBox from '@/components/input/SearchBox.vue'
|
import SearchBox from '@/components/input/SearchBox.vue'
|
||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import BaseWidgetLayout from '@/components/widget/layout/BaseWidgetLayout.vue'
|
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
|
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
|
||||||
import { NavGroupData, NavItemData } from '@/types/navTypes'
|
import { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import { createGridStyle } from '@/utils/gridUtil'
|
||||||
|
|
||||||
const frameworkOptions = ref([
|
const frameworkOptions = ref([
|
||||||
{ name: 'Vue', value: 'vue' },
|
{ name: 'Vue', value: 'vue' },
|
||||||
@@ -207,6 +201,8 @@ const selectedSort = ref<string>('popular')
|
|||||||
|
|
||||||
const selectedNavItem = ref<string | null>('installed')
|
const selectedNavItem = ref<string | null>('installed')
|
||||||
|
|
||||||
|
const gridStyle = computed(() => createGridStyle())
|
||||||
|
|
||||||
watch(searchText, (newQuery) => {
|
watch(searchText, (newQuery) => {
|
||||||
console.log('searchText:', searchText.value, newQuery)
|
console.log('searchText:', searchText.value, newQuery)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
import { provide, ref } from 'vue'
|
import { computed, provide, ref } from 'vue'
|
||||||
|
|
||||||
import IconButton from '@/components/button/IconButton.vue'
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
@@ -13,10 +13,11 @@ import SearchBox from '@/components/input/SearchBox.vue'
|
|||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import { createGridStyle } from '@/utils/gridUtil'
|
||||||
|
|
||||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||||
import RightSidePanel from '../panel/RightSidePanel.vue'
|
import RightSidePanel from '../panel/RightSidePanel.vue'
|
||||||
import BaseWidgetLayout from './BaseWidgetLayout.vue'
|
import BaseModalLayout from './BaseModalLayout.vue'
|
||||||
|
|
||||||
interface StoryArgs {
|
interface StoryArgs {
|
||||||
contentTitle: string
|
contentTitle: string
|
||||||
@@ -29,7 +30,7 @@ interface StoryArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta: Meta<StoryArgs> = {
|
const meta: Meta<StoryArgs> = {
|
||||||
title: 'Components/Widget/Layout/BaseWidgetLayout',
|
title: 'Components/Widget/Layout/BaseModalLayout',
|
||||||
argTypes: {
|
argTypes: {
|
||||||
contentTitle: {
|
contentTitle: {
|
||||||
control: 'text',
|
control: 'text',
|
||||||
@@ -67,7 +68,7 @@ type Story = StoryObj<typeof meta>
|
|||||||
|
|
||||||
const createStoryTemplate = (args: StoryArgs) => ({
|
const createStoryTemplate = (args: StoryArgs) => ({
|
||||||
components: {
|
components: {
|
||||||
BaseWidgetLayout,
|
BaseModalLayout,
|
||||||
LeftSidePanel,
|
LeftSidePanel,
|
||||||
RightSidePanel,
|
RightSidePanel,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
@@ -156,6 +157,8 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
const selectedProjects = ref<string[]>([])
|
const selectedProjects = ref<string[]>([])
|
||||||
const selectedSort = ref<string>('popular')
|
const selectedSort = ref<string>('popular')
|
||||||
|
|
||||||
|
const gridStyle = computed(() => createGridStyle())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
args,
|
args,
|
||||||
t,
|
t,
|
||||||
@@ -167,12 +170,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
sortOptions,
|
sortOptions,
|
||||||
selectedFrameworks,
|
selectedFrameworks,
|
||||||
selectedProjects,
|
selectedProjects,
|
||||||
selectedSort
|
selectedSort,
|
||||||
|
gridStyle
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||||
<!-- Left Panel -->
|
<!-- Left Panel -->
|
||||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||||
@@ -189,6 +193,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
<template v-if="args.hasHeader" #header>
|
<template v-if="args.hasHeader" #header>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
class="max-w-[384px]"
|
class="max-w-[384px]"
|
||||||
|
size="lg"
|
||||||
:modelValue="searchQuery"
|
:modelValue="searchQuery"
|
||||||
@update:modelValue="searchQuery = $event"
|
@update:modelValue="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +236,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Content Filter -->
|
<!-- Content Filter -->
|
||||||
<template v-if="args.hasContentFilter" #contentFilter>
|
<template v-if="args.hasContentFilter" #contentFilter>
|
||||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
<div class="relative px-6 py-4 flex gap-2">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="selectedFrameworks"
|
v-model="selectedFrameworks"
|
||||||
label="Select Frameworks"
|
label="Select Frameworks"
|
||||||
@@ -260,7 +265,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
<div :style="gridStyle">
|
||||||
<CardContainer
|
<CardContainer
|
||||||
v-for="i in args.cardCount"
|
v-for="i in args.cardCount"
|
||||||
:key="i"
|
:key="i"
|
||||||
@@ -293,9 +298,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
</CardContainer>
|
</CardContainer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseWidgetLayout>
|
</BaseModalLayout>
|
||||||
|
|
||||||
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||||
<!-- Same content but WITH right panel -->
|
<!-- Same content but WITH right panel -->
|
||||||
<!-- Left Panel -->
|
<!-- Left Panel -->
|
||||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||||
@@ -313,6 +318,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
<template v-if="args.hasHeader" #header>
|
<template v-if="args.hasHeader" #header>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
class="max-w-[384px]"
|
class="max-w-[384px]"
|
||||||
|
size="lg"
|
||||||
:modelValue="searchQuery"
|
:modelValue="searchQuery"
|
||||||
@update:modelValue="searchQuery = $event"
|
@update:modelValue="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
@@ -355,7 +361,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Content Filter -->
|
<!-- Content Filter -->
|
||||||
<template v-if="args.hasContentFilter" #contentFilter>
|
<template v-if="args.hasContentFilter" #contentFilter>
|
||||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
<div class="relative px-6 py-4 flex gap-2">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="selectedFrameworks"
|
v-model="selectedFrameworks"
|
||||||
label="Select Frameworks"
|
label="Select Frameworks"
|
||||||
@@ -381,7 +387,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
|
<div :style="gridStyle">
|
||||||
<CardContainer
|
<CardContainer
|
||||||
v-for="i in args.cardCount"
|
v-for="i in args.cardCount"
|
||||||
:key="i"
|
:key="i"
|
||||||
@@ -419,7 +425,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
<template #rightPanel>
|
<template #rightPanel>
|
||||||
<RightSidePanel />
|
<RightSidePanel />
|
||||||
</template>
|
</template>
|
||||||
</BaseWidgetLayout>
|
</BaseModalLayout>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="layoutClasses">
|
||||||
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-50 dark-theme:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
v-show="!isRightPanelOpen && hasRightPanel"
|
v-show="!isRightPanelOpen && hasRightPanel"
|
||||||
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
|
:class="rightPanelButtonClasses"
|
||||||
:class="{
|
|
||||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
|
||||||
}"
|
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i-lucide:panel-right class="text-sm" />
|
<i-lucide:panel-right class="text-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton :class="closeButtonClasses" @click="closeDialog">
|
||||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
|
|
||||||
@click="closeDialog"
|
|
||||||
>
|
|
||||||
<i class="pi pi-times text-sm"></i>
|
<i class="pi pi-times text-sm"></i>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<div class="flex w-full h-full">
|
<div class="flex w-full h-full">
|
||||||
@@ -32,12 +24,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
|
<div :class="mainContainerClasses">
|
||||||
<div class="w-full h-full flex flex-col">
|
<div class="w-full h-full flex flex-col">
|
||||||
<header
|
<header v-if="$slots.header" :class="headerClasses">
|
||||||
v-if="$slots.header"
|
|
||||||
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div class="flex-1 flex gap-2 shrink-0">
|
<div class="flex-1 flex gap-2 shrink-0">
|
||||||
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
|
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
|
||||||
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
|
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
|
||||||
@@ -46,12 +35,7 @@
|
|||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header-right-area"></slot>
|
<slot name="header-right-area"></slot>
|
||||||
<div
|
<div :class="rightAreaClasses">
|
||||||
class="flex justify-end gap-2 w-0"
|
|
||||||
:class="
|
|
||||||
hasRightPanel && !isRightPanelOpen ? 'min-w-18' : 'min-w-8'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="isRightPanelOpen && hasRightPanel"
|
v-if="isRightPanelOpen && hasRightPanel"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
@@ -67,14 +51,14 @@
|
|||||||
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
|
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
|
||||||
{{ contentTitle }}
|
{{ contentTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-hide">
|
<div :class="contentContainerClasses">
|
||||||
<slot name="content"></slot>
|
<slot name="content"></slot>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<aside
|
<aside
|
||||||
v-if="hasRightPanel && isRightPanelOpen"
|
v-if="hasRightPanel && isRightPanelOpen"
|
||||||
class="w-1/4 min-w-40 max-w-80"
|
:class="rightPanelClasses"
|
||||||
>
|
>
|
||||||
<slot name="rightPanel"></slot>
|
<slot name="rightPanel"></slot>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -89,6 +73,7 @@ import { computed, inject, ref, useSlots, watch } from 'vue'
|
|||||||
|
|
||||||
import IconButton from '@/components/button/IconButton.vue'
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { contentTitle } = defineProps<{
|
const { contentTitle } = defineProps<{
|
||||||
contentTitle: string
|
contentTitle: string
|
||||||
@@ -137,6 +122,50 @@ const toggleLeftPanel = () => {
|
|||||||
const toggleRightPanel = () => {
|
const toggleRightPanel = () => {
|
||||||
isRightPanelOpen.value = !isRightPanelOpen.value
|
isRightPanelOpen.value = !isRightPanelOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed classes for better readability
|
||||||
|
const layoutClasses = cn(
|
||||||
|
'base-widget-layout',
|
||||||
|
'rounded-2xl overflow-hidden relative',
|
||||||
|
'bg-zinc-50 dark-theme:bg-zinc-800'
|
||||||
|
)
|
||||||
|
|
||||||
|
const rightPanelButtonClasses = computed(() => {
|
||||||
|
return cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||||
|
'opacity-0 pointer-events-none':
|
||||||
|
isRightPanelOpen.value || !hasRightPanel.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeButtonClasses = cn(
|
||||||
|
'absolute top-4 right-6 z-10',
|
||||||
|
'transition-opacity duration-200'
|
||||||
|
)
|
||||||
|
|
||||||
|
const mainContainerClasses = cn(
|
||||||
|
'flex-1 flex',
|
||||||
|
'bg-zinc-100 dark-theme:bg-neutral-900'
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerClasses = cn(
|
||||||
|
'w-full h-18 px-6',
|
||||||
|
'flex items-center justify-between gap-2'
|
||||||
|
)
|
||||||
|
|
||||||
|
const rightAreaClasses = computed(() => {
|
||||||
|
return cn(
|
||||||
|
'flex justify-end gap-2 w-0',
|
||||||
|
hasRightPanel.value && !isRightPanelOpen.value ? 'min-w-22' : 'min-w-10'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentContainerClasses = computed(() => {
|
||||||
|
return cn('min-h-0 px-6 pt-0 pb-10', 'overflow-y-auto scrollbar-hide')
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightPanelClasses = computed(() => {
|
||||||
|
return cn('w-1/4 min-w-40 max-w-80')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.base-widget-layout {
|
.base-widget-layout {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
|
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
|
||||||
:class="
|
:class="
|
||||||
active
|
active
|
||||||
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
|
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<h3
|
<h3
|
||||||
class="m-0 px-3 py-0 pt-5 text-xxs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
|
||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
30
src/composables/usePopoverSizing.ts
Normal file
30
src/composables/usePopoverSizing.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { type CSSProperties, type ComputedRef, computed } from 'vue'
|
||||||
|
|
||||||
|
interface PopoverSizeOptions {
|
||||||
|
minWidth?: string
|
||||||
|
maxWidth?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing popover sizing styles
|
||||||
|
* @param options Popover size configuration
|
||||||
|
* @returns Computed style object for popover sizing
|
||||||
|
*/
|
||||||
|
export function usePopoverSizing(
|
||||||
|
options: PopoverSizeOptions
|
||||||
|
): ComputedRef<CSSProperties> {
|
||||||
|
return computed(() => {
|
||||||
|
const { minWidth, maxWidth } = options
|
||||||
|
const style: CSSProperties = {}
|
||||||
|
|
||||||
|
if (minWidth) {
|
||||||
|
style.minWidth = minWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWidth) {
|
||||||
|
style.maxWidth = maxWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -56,8 +56,8 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
|
|||||||
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
|
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
'fit-content': 'w-auto h-auto',
|
'fit-content': 'w-auto h-auto',
|
||||||
sm: 'w-6 h-6 text-xs !rounded-md',
|
sm: 'size-8 text-xs !rounded-md',
|
||||||
md: 'w-8 h-8 text-sm'
|
md: 'size-10 text-sm'
|
||||||
}
|
}
|
||||||
return sizeClasses[size]
|
return sizeClasses[size]
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/utils/gridUtil.ts
Normal file
45
src/utils/gridUtil.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CSSProperties } from 'vue'
|
||||||
|
|
||||||
|
interface GridOptions {
|
||||||
|
/** Minimum width for each grid item (default: 15rem) */
|
||||||
|
minWidth?: string
|
||||||
|
/** Maximum width for each grid item (default: 1fr) */
|
||||||
|
maxWidth?: string
|
||||||
|
/** Padding around the grid (default: 0) */
|
||||||
|
padding?: string
|
||||||
|
/** Gap between grid items (default: 1rem) */
|
||||||
|
gap?: string
|
||||||
|
/** Fixed number of columns (overrides auto-fill with minmax) */
|
||||||
|
columns?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates CSS grid styles for responsive grid layouts
|
||||||
|
* @param options Grid configuration options
|
||||||
|
* @returns CSS properties object for grid styling
|
||||||
|
*/
|
||||||
|
export function createGridStyle(options: GridOptions = {}): CSSProperties {
|
||||||
|
const {
|
||||||
|
minWidth = '15rem',
|
||||||
|
maxWidth = '1fr',
|
||||||
|
padding = '0',
|
||||||
|
gap = '1rem',
|
||||||
|
columns
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// Runtime validation for columns
|
||||||
|
if (columns !== undefined && columns < 1) {
|
||||||
|
console.warn('createGridStyle: columns must be >= 1, defaulting to 1')
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridTemplateColumns = columns
|
||||||
|
? `repeat(${Math.max(1, columns ?? 1)}, 1fr)`
|
||||||
|
: `repeat(auto-fill, minmax(${minWidth}, ${maxWidth}))`
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns,
|
||||||
|
padding,
|
||||||
|
gap
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user