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:
Jin Yi
2025-09-11 19:01:06 +09:00
committed by GitHub
parent 038f86fe84
commit e70ddea684
23 changed files with 722 additions and 426 deletions

View File

@@ -57,9 +57,8 @@
/* Override Storybook's problematic & selector styles */
/* Reset only the specific properties that Storybook injects */
#storybook-root li+li,
#storybook-docs li+li {
margin: inherit;
padding: inherit;
li+li {
margin: 0;
padding: revert-layer;
}
</style>

View File

@@ -21,6 +21,7 @@ import {
getButtonTypeClasses,
getIconButtonSizeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void
@@ -46,8 +47,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -1,7 +1,17 @@
<template>
<div
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"
>
<div :class="iconGroupClasses">
<slot></slot>
</div>
</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>

View File

@@ -23,6 +23,7 @@ import {
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
@@ -52,8 +53,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -14,7 +14,7 @@
unstyled
: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" />
</div>
</Popover>
@@ -25,6 +25,8 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue'
const popover = ref<InstanceType<typeof Popover>>()
@@ -39,13 +41,16 @@ const hide = () => {
const pt = computed(() => ({
root: {
class: 'absolute z-50'
class: cn('absolute z-50')
},
content: {
class: [
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
]
class: cn(
'mt-2 rounded-lg',
'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>

View File

@@ -21,6 +21,7 @@ import {
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface TextButtonProps extends BaseButtonProps {
label: string
@@ -48,8 +49,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -49,14 +49,6 @@ const meta: Meta<CardStoryArgs> = {
options: ['square', 'portrait', 'tallPortrait'],
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: {
control: 'select',
options: ['square', 'landscape'],
@@ -155,11 +147,10 @@ const createCardTemplate = (args: CardStoryArgs) => ({
}
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div class="min-h-screen">
<CardContainer
:ratio="args.containerRatio"
:max-width="args.maxWidth"
:min-width="args.minWidth"
class="max-w-[320px] mx-auto"
>
<template #top>
<CardTop :ratio="args.topRatio">
@@ -214,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template #bottom>
<CardBottom class="p-3">
<CardBottom class="p-3 bg-neutral-100">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
@@ -228,8 +219,6 @@ export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 300,
minWidth: 200,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -255,8 +244,6 @@ export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 400,
minWidth: 250,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
@@ -282,8 +269,6 @@ export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 280,
minWidth: 180,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -309,8 +294,6 @@ export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 350,
minWidth: 220,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -335,8 +318,6 @@ export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 300,
minWidth: 200,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
@@ -361,8 +342,6 @@ export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 320,
minWidth: 240,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -376,270 +355,10 @@ export const FullFeaturedCard: Story = {
backgroundColor: '#ef4444',
showImage: false,
imageUrl: '',
tags: ['Bundle', 'Premium', 'SDXL'],
tags: ['Bundle', 'SDXL'],
showFileSize: true,
fileSize: '5.4 GB',
showFileType: true,
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 }
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div :class="containerClasses" :style="containerStyle">
<div :class="containerClasses">
<slot name="top"></slot>
<slot name="bottom"></slot>
</div>
@@ -8,13 +8,7 @@
<script setup lang="ts">
import { computed } from 'vue'
const {
ratio = 'square',
maxWidth,
minWidth
} = defineProps<{
maxWidth?: number
minWidth?: number
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'portrait' | 'tallPortrait'
}>()
@@ -30,13 +24,4 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
</script>

View 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>
`
})
}

View File

@@ -13,6 +13,9 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }>
}
@@ -42,6 +45,18 @@ const meta: Meta<ExtendedProps> = {
},
searchPlaceholder: {
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: {
@@ -274,3 +289,140 @@ export const CustomSearchPlaceholder: Story = {
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 }
}
}

View File

@@ -1,10 +1,9 @@
<template>
<!--
<!--
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)
2. We display a count badge instead of actual selected labels
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
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
@@ -20,12 +19,13 @@
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="p-2 flex flex-col pb-0">
<div class="pt-2 pb-0 px-2 flex flex-col">
<SearchBox
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:show-order="true"
:show-border="true"
:place-holder="searchPlaceholder"
/>
<div
@@ -47,11 +47,11 @@
:label="$t('g.clearAll')"
type="transparent"
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 = []"
/>
</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>
</template>
@@ -75,13 +75,13 @@
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" :style="popoverStyle">
<div
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
:class="
slotProps.selected
? 'border-[3px] border-blue-400 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-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'bg-neutral-100 dark-theme:bg-zinc-700'
"
>
<i-lucide:check
@@ -89,9 +89,11 @@
class="text-xs text-bold text-white"
/>
</div>
<Button class="border-none outline-none bg-transparent" unstyled>{{
slotProps.option.name
}}</Button>
<Button
class="border-none outline-none bg-transparent text-left"
unstyled
>{{ slotProps.option.name }}</Button
>
</div>
</template>
</MultiSelect>
@@ -105,6 +107,8 @@ import MultiSelect, {
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import TextButton from '../button/TextButton.vue'
@@ -125,6 +129,12 @@ interface Props {
showClearButton?: boolean
/** Placeholder for the search input */
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.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
@@ -133,7 +143,10 @@ const {
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...'
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<Option[]>({
@@ -142,10 +155,15 @@ const selectedItems = defineModel<Option[]>({
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
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',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
@@ -170,16 +188,26 @@ const pt = computed(() => ({
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
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',
overlay: {
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: {
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: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: [
'flex gap-1 items-center p-2',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
'flex gap-2 items-center h-10 px-2 rounded-lg',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation
{
'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)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: 'display: none !important'
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: 'display: none !important'
style: { display: 'none' }
}
}))
</script>

View File

@@ -14,11 +14,17 @@ const meta: Meta<typeof SearchBox> = {
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
}
},
args: {
placeHolder: 'Search...',
showBorder: false
showBorder: false,
size: 'md'
}
}
@@ -53,3 +59,27 @@ export const NoBorder: Story = {
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
}
}

View File

@@ -6,7 +6,7 @@
:placeholder="placeHolder || 'Search...'"
type="text"
unstyled
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white"
:class="inputStyle"
/>
</div>
</template>
@@ -15,20 +15,56 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
const { placeHolder, showBorder = false } = defineProps<{
import { cn } from '@/utils/tailwindUtil'
const {
placeHolder,
showBorder = false,
size = 'md'
} = defineProps<{
placeHolder?: string
showBorder?: boolean
size?: 'md' | 'lg'
}>()
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const wrapperStyle = computed(() => {
return showBorder
? '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'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
const baseClasses = [
'relative flex w-full items-center gap-2',
'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(() => {
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>

View File

@@ -10,7 +10,19 @@ const meta: Meta<typeof SingleSelect> = {
tags: ['autodocs'],
argTypes: {
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: {
label: 'Sorting Type',
@@ -121,6 +133,124 @@ export const AllVariants: Story = {
}),
parameters: {
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 }
}
}

View File

@@ -1,10 +1,9 @@
<template>
<!--
<!--
Note: We explicitly pass options here (not just via $attrs) because:
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
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
-->
<Select
@@ -18,7 +17,7 @@
>
<!-- Trigger value -->
<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" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -34,18 +33,19 @@
<!-- Trigger caret -->
<template #dropdownicon>
<i-lucide:chevron-down
class="text-base text-neutral-400 dark-theme:text-gray-300"
/>
<i-lucide:chevron-down class="text-base text-neutral-500" />
</template>
<!-- Option row -->
<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>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
class="text-neutral-600 dark-theme:text-white"
/>
</div>
</template>
@@ -56,11 +56,19 @@
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const { label, options } = defineProps<{
const {
label,
options,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
@@ -71,6 +79,12 @@ const { label, options } = defineProps<{
name: 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 })
@@ -87,6 +101,17 @@ const getLabel = (val: string | null | undefined) => {
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
* - No background/border (same as page background)
@@ -98,7 +123,7 @@ const pt = computed(() => ({
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [
// container
'relative inline-flex cursor-pointer select-none items-center',
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-md',
'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'
},
overlay: {
class: [
// dropdown panel
'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 p-2 rounded-lg',
'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: {
class:
// 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: ({
context
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [
// Row layout
'flex items-center justify-between gap-3 px-3 py-2',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
'flex items-center justify-between gap-3 px-2 py-3 rounded',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation

View File

@@ -1,5 +1,5 @@
<template>
<BaseWidgetLayout :content-title="$t('Checkpoints')">
<BaseModalLayout :content-title="$t('Checkpoints')">
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
@@ -12,7 +12,7 @@
</template>
<template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -56,7 +56,7 @@
</template>
<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
v-model="selectedFrameworks"
v-model:search-query="searchText"
@@ -87,14 +87,8 @@
<template #content>
<!-- Card Examples -->
<div class="flex flex-wrap gap-2">
<CardContainer
v-for="i in 100"
:key="i"
ratio="square"
:max-width="480"
:min-width="230"
>
<div :style="gridStyle">
<CardContainer v-for="i in 100" :key="i" ratio="square">
<template #top>
<CardTop ratio="landscape">
<template #default>
@@ -124,17 +118,16 @@
</template>
</CardContainer>
</div>
<!-- </div> -->
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
</template>
</BaseWidgetLayout>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
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 SearchBox from '@/components/input/SearchBox.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 RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
@@ -207,6 +201,8 @@ const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed')
const gridStyle = computed(() => createGridStyle())
watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery)
})

View File

@@ -1,5 +1,5 @@
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 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 type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseWidgetLayout from './BaseWidgetLayout.vue'
import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs {
contentTitle: string
@@ -29,7 +30,7 @@ interface StoryArgs {
}
const meta: Meta<StoryArgs> = {
title: 'Components/Widget/Layout/BaseWidgetLayout',
title: 'Components/Widget/Layout/BaseModalLayout',
argTypes: {
contentTitle: {
control: 'text',
@@ -67,7 +68,7 @@ type Story = StoryObj<typeof meta>
const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseWidgetLayout,
BaseModalLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
@@ -156,6 +157,8 @@ const createStoryTemplate = (args: StoryArgs) => ({
const selectedProjects = ref<string[]>([])
const selectedSort = ref<string>('popular')
const gridStyle = computed(() => createGridStyle())
return {
args,
t,
@@ -167,12 +170,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
sortOptions,
selectedFrameworks,
selectedProjects,
selectedSort
selectedSort,
gridStyle
}
},
template: `
<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 -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
@@ -189,6 +193,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
@@ -231,7 +236,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter -->
<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
v-model="selectedFrameworks"
label="Select Frameworks"
@@ -260,7 +265,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<div :style="gridStyle">
<CardContainer
v-for="i in args.cardCount"
:key="i"
@@ -293,9 +298,9 @@ const createStoryTemplate = (args: StoryArgs) => ({
</CardContainer>
</div>
</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 -->
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
@@ -313,6 +318,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
@@ -355,7 +361,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter -->
<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
v-model="selectedFrameworks"
label="Select Frameworks"
@@ -381,7 +387,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<div :style="gridStyle">
<CardContainer
v-for="i in args.cardCount"
:key="i"
@@ -419,7 +425,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template #rightPanel>
<RightSidePanel />
</template>
</BaseWidgetLayout>
</BaseModalLayout>
</div>
`
})

View File

@@ -1,21 +1,13 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-50 dark-theme:bg-zinc-800"
>
<div :class="layoutClasses">
<IconButton
v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
:class="rightPanelButtonClasses"
@click="toggleRightPanel"
>
<i-lucide:panel-right class="text-sm" />
</IconButton>
<IconButton
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<IconButton :class="closeButtonClasses" @click="closeDialog">
<i class="pi pi-times text-sm"></i>
</IconButton>
<div class="flex w-full h-full">
@@ -32,12 +24,9 @@
</nav>
</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">
<header
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<header v-if="$slots.header" :class="headerClasses">
<div class="flex-1 flex gap-2 shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
@@ -46,12 +35,7 @@
<slot name="header"></slot>
</div>
<slot name="header-right-area"></slot>
<div
class="flex justify-end gap-2 w-0"
:class="
hasRightPanel && !isRightPanelOpen ? 'min-w-18' : 'min-w-8'
"
>
<div :class="rightAreaClasses">
<IconButton
v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel"
@@ -67,14 +51,14 @@
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
{{ contentTitle }}
</h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-hide">
<div :class="contentContainerClasses">
<slot name="content"></slot>
</div>
</main>
</div>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
:class="rightPanelClasses"
>
<slot name="rightPanel"></slot>
</aside>
@@ -89,6 +73,7 @@ import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { contentTitle } = defineProps<{
contentTitle: string
@@ -137,6 +122,50 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => {
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>
<style scoped>
.base-widget-layout {

View File

@@ -1,6 +1,6 @@
<template>
<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="
active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'

View File

@@ -1,6 +1,6 @@
<template>
<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 }}
</h3>

View 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
})
}

View File

@@ -56,8 +56,8 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
const sizeClasses = {
'fit-content': 'w-auto h-auto',
sm: 'w-6 h-6 text-xs !rounded-md',
md: 'w-8 h-8 text-sm'
sm: 'size-8 text-xs !rounded-md',
md: 'size-10 text-sm'
}
return sizeClasses[size]
}

45
src/utils/gridUtil.ts Normal file
View 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
}
}