Skip to content

Commit

Permalink
feat(DropdownMenu/ContextMenu): handle color field in items (#2510)
Browse files Browse the repository at this point in the history
Co-authored-by: Benjamin Canac <[email protected]>
  • Loading branch information
hywax and benjamincanac authored Nov 5, 2024
1 parent 2d52834 commit f66c96e
Show file tree
Hide file tree
Showing 16 changed files with 214 additions and 54 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
const items = [
[
{
label: 'View',
icon: 'i-heroicons-eye'
},
{
label: 'Copy',
icon: 'i-heroicons-document-duplicate'
},
{
label: 'Edit',
icon: 'i-heroicons-pencil'
}
],
[
{
label: 'Delete',
color: 'error' as const,
icon: 'i-heroicons-trash'
}
]
]
</script>

<template>
<UContextMenu :items="items" class="w-48">
<div class="flex items-center justify-center rounded-md border border-dashed border-[var(--ui-border-accented)] text-sm aspect-video w-72">
Right click here
</div>
</UContextMenu>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
const items = [
[
{
label: 'View',
icon: 'i-heroicons-eye'
},
{
label: 'Copy',
icon: 'i-heroicons-document-duplicate'
},
{
label: 'Edit',
icon: 'i-heroicons-pencil'
}
],
[
{
label: 'Delete',
color: 'error' as const,
icon: 'i-heroicons-trash'
}
]
]
</script>

<template>
<UDropdownMenu :items="items" class="w-48">
<UButton label="Open" color="neutral" variant="outline" icon="i-heroicons-bars-3" />

<template #profile-trailing>
<UIcon name="i-heroicons-check-badge" class="shrink-0 size-5 text-[var(--ui-primary)]" />
</template>
</UDropdownMenu>
</template>
12 changes: 12 additions & 0 deletions docs/content/3.components/context-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ Use the `items` prop as an array of objects with the following properties:

- `label?: string`{lang="ts-type"}
- `icon?: string`{lang="ts-type"}
- `color?: string`{lang="ts-type"}
- `avatar?: AvatarProps`{lang="ts-type"}
- `kbds?: string[] | KbdProps[]`{lang="ts-type"}
- [`type?: "link" | "label" | "separator" | "checkbox"`{lang="ts-type"}](#with-checkbox-items)
- [`color?: "error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"`{lang="ts-type"}](#with-color-items)
- [`checked?: boolean`{lang="ts-type"}](#with-checkbox-items)
- `disabled?: boolean`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
Expand Down Expand Up @@ -191,6 +193,16 @@ name: 'context-menu-checkbox-items-example'
To ensure reactivity for the `checked` state of items, it's recommended to wrap your `items` array inside a `computed`.
::

### With color items

You can use the `color` property to highlight certain items with a color.

::component-example
---
name: 'context-menu-color-items-example'
---
::

### With custom slot

Use the `slot` property to customize a specific item.
Expand Down
12 changes: 12 additions & 0 deletions docs/content/3.components/dropdown-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ Use the `items` prop as an array of objects with the following properties:

- `label?: string`{lang="ts-type"}
- `icon?: string`{lang="ts-type"}
- `color?: string`{lang="ts-type"}
- `avatar?: AvatarProps`{lang="ts-type"}
- `kbds?: string[] | KbdProps[]`{lang="ts-type"}
- [`type?: "link" | "label" | "separator" | "checkbox"`{lang="ts-type"}](#with-checkbox-items)
- [`color?: "error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"`{lang="ts-type"}](#with-color-items)
- [`checked?: boolean`{lang="ts-type"}](#with-checkbox-items)
- `disabled?: boolean`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
Expand Down Expand Up @@ -273,6 +275,16 @@ name: 'dropdown-menu-checkbox-items-example'
To ensure reactivity for the `checked` state of items, it's recommended to wrap your `items` array inside a `computed`.
::

### With color items

You can use the `color` property to highlight certain items with a color.

::component-example
---
name: 'dropdown-menu-color-items-example'
---
::

### Control open state

You can control the open state by using the `default-open` prop or the `v-model:open` directive.
Expand Down
12 changes: 12 additions & 0 deletions playground/app/pages/components/context-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ const items = computed(() => [
}]
])
const itemsWithColor = computed(() => Object.keys(theme.variants.color).map(color => ({
color: (color as keyof typeof theme.variants.color),
icon: 'i-heroicons-swatch',
label: color
})))
const sizes = Object.keys(theme.variants.size)
const size = ref('md' as const)
Expand All @@ -104,5 +110,11 @@ defineShortcuts(extractShortcuts(items.value))
Right click here
</div>
</UContextMenu>

<UContextMenu :items="itemsWithColor" class="min-w-48" :size="size">
<div class="flex items-center justify-center rounded-md border border-dashed border-[var(--ui-border-accented)] text-sm aspect-video w-72">
Color right click here
</div>
</UContextMenu>
</div>
</template>
14 changes: 14 additions & 0 deletions playground/app/pages/components/dropdown-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ const items = computed(() => [
}]
])
const itemsWithColor = computed(() => Object.keys(theme.variants.color).map(color => ({
color: (color as keyof typeof theme.variants.color),
icon: 'i-heroicons-swatch',
label: color
})))
const sizes = Object.keys(theme.variants.size)
const size = ref('md' as const)
Expand All @@ -141,6 +147,14 @@ defineShortcuts(extractShortcuts(items.value))
<UIcon name="i-heroicons-check-badge" class="shrink-0 size-5 text-[var(--ui-primary)]" />
</template>
</UDropdownMenu>

<UDropdownMenu :items="itemsWithColor" :size="size" arrow :content="{ side: 'bottom', align: 'start' }" class="min-w-48">
<UButton label="Color" color="neutral" variant="outline" icon="i-heroicons-bars-3" />

<template #custom-trailing>
<UIcon name="i-heroicons-check-badge" class="shrink-0 size-5 text-[var(--ui-primary)]" />
</template>
</UDropdownMenu>
</div>
</div>
</template>
5 changes: 3 additions & 2 deletions src/runtime/components/ContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const appConfig = _appConfig as AppConfig & { ui: { contextMenu: Partial<typeof

const contextMenu = tv({ extend: tv(theme), ...(appConfig.ui?.contextMenu || {}) })

type ContextMenuVariants = VariantProps<typeof contextMenu>

export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
label?: string
icon?: string
color?: ContextMenuVariants['color']
avatar?: AvatarProps
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'>
kbds?: KbdProps['value'][] | KbdProps[]
Expand All @@ -34,8 +37,6 @@ export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custo
onUpdateChecked?(checked: boolean): void
}

type ContextMenuVariants = VariantProps<typeof contextMenu>

export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'> {
size?: ContextMenuVariants['size']
items?: T[] | T[][]
Expand Down
16 changes: 8 additions & 8 deletions src/runtime/components/ContextMenuContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index">
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, active })" />
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" />
<UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
</slot>

Expand All @@ -62,19 +62,19 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
{{ get(item, props.labelKey as string) }}
</slot>

<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, active })" />
<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, color: item?.color, active })" />
</span>

<span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="(item as T)" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, active })" />
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>

<ContextMenu.ItemIndicator as-child>
<UIcon :name="checkedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon })" />
<UIcon :name="checkedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color })" />
</ContextMenu.ItemIndicator>
</span>
</slot>
Expand All @@ -94,7 +94,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
type="button"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: uiOverride?.item })"
:class="ui.item({ class: uiOverride?.item, color: item?.color })"
>
<ReuseItemTemplate :item="item" :index="index" />
</ContextMenu.SubTrigger>
Expand Down Expand Up @@ -122,7 +122,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:checked="item.checked"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: [uiOverride?.item, item.class] })"
:class="ui.item({ class: [uiOverride?.item, item.class], color: item?.color })"
@update:checked="item.onUpdateChecked"
@select="item.onSelect"
>
Expand All @@ -136,7 +136,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
@select="item.onSelect"
>
<ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item as Omit<ContextMenuItem, 'type'>)" custom>
<ULinkBase v-bind="slotProps" :class="ui.item({ class: [uiOverride?.item, item.class], active })">
<ULinkBase v-bind="slotProps" :class="ui.item({ class: [uiOverride?.item, item.class], active, color: item?.color })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/DropdownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof

const dropdownMenu = tv({ extend: tv(theme), ...(appConfig.ui?.dropdownMenu || {}) })

type DropdownMenuVariants = VariantProps<typeof dropdownMenu>

export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
label?: string
icon?: string
color?: DropdownMenuVariants['color']
avatar?: AvatarProps
content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'>
kbds?: KbdProps['value'][] | KbdProps[]
Expand All @@ -34,8 +37,6 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cust
onUpdateChecked?(checked: boolean): void
}

type DropdownMenuVariants = VariantProps<typeof dropdownMenu>

export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'> {
size?: DropdownMenuVariants['size']
items?: T[] | T[][]
Expand Down
16 changes: 8 additions & 8 deletions src/runtime/components/DropdownMenuContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index">
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, active })" />
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" />
<UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
</slot>

Expand All @@ -68,19 +68,19 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
{{ get(item, props.labelKey as string) }}
</slot>

<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, active })" />
<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, color: item?.color, active })" />
</span>

<span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="(item as T)" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, active })" />
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>

<DropdownMenu.ItemIndicator as-child>
<UIcon :name="checkedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon })" />
<UIcon :name="checkedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color })" />
</DropdownMenu.ItemIndicator>
</span>
</slot>
Expand All @@ -100,7 +100,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
type="button"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: uiOverride?.item })"
:class="ui.item({ class: uiOverride?.item, color: item?.color })"
>
<ReuseItemTemplate :item="item" :index="index" />
</DropdownMenu.SubTrigger>
Expand Down Expand Up @@ -131,7 +131,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:checked="item.checked"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: [uiOverride?.item, item.class] })"
:class="ui.item({ class: [uiOverride?.item, item.class], color: item?.color })"
@update:checked="item.onUpdateChecked"
@select="item.onSelect"
>
Expand All @@ -145,7 +145,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
@select="item.onSelect"
>
<ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item as Omit<DropdownMenuItem, 'type'>)" custom>
<ULinkBase v-bind="slotProps" :class="ui.item({ class: [uiOverride?.item, item.class], active })">
<ULinkBase v-bind="slotProps" :class="ui.item({ class: [uiOverride?.item, item.class], color: item?.color, active })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>
Expand Down
19 changes: 19 additions & 0 deletions src/theme/context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export default (options: Required<ModuleOptions>) => ({
itemLabelExternalIcon: 'inline-block size-3 align-top text-[var(--ui-text-dimmed)]'
},
variants: {
color: {
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, ''])),
neutral: ''
},
active: {
true: {
item: 'text-[var(--ui-text-highlighted)] before:bg-[var(--ui-bg-elevated)]',
Expand Down Expand Up @@ -81,6 +85,21 @@ export default (options: Required<ModuleOptions>) => ({
}
}
},
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
color,
active: false,
class: {
item: `text-[var(--ui-${color})] data-highlighted:text-[var(--ui-${color})] data-highlighted:before:bg-[var(--ui-${color})]/10 data-[state=open]:before:bg-[var(--ui-${color})]/10`,
itemLeadingIcon: `text-[var(--ui-${color})]/75 group-data-highlighted:text-[var(--ui-${color})] group-data-[state=open]:text-[var(--ui-${color})]`
}
})), ...(options.theme.colors || []).map((color: string) => ({
color,
active: true,
class: {
item: `text-[var(--ui-${color})] before:bg-[var(--ui-${color})]/10`,
itemLeadingIcon: `text-[var(--ui-${color})]`
}
}))],
defaultVariants: {
size: 'md'
}
Expand Down
Loading

0 comments on commit f66c96e

Please sign in to comment.