Skip to content

Commit

Permalink
feat(ContextMenu/DropdownMenu): handle checkbox items type
Browse files Browse the repository at this point in the history
Resolves #2144
  • Loading branch information
benjamincanac committed Oct 15, 2024
1 parent 0759e29 commit 8ef6e71
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 350 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
const showSidebar = ref(true)
const showToolbar = ref(false)
const items = computed(() => [{
label: 'View',
type: 'label' as const
}, {
type: 'separator' as const
}, {
label: 'Show Sidebar',
type: 'checkbox' as const,
checked: showSidebar.value,
onUpdateChecked(checked: boolean) {
showSidebar.value = checked
},
onSelect(e?: Event) {
e?.preventDefault()
}
}, {
label: 'Show Toolbar',
type: 'checkbox' as const,
checked: showToolbar.value,
onUpdateChecked(checked: boolean) {
showToolbar.value = checked
}
}, {
label: 'Collapse Pinned Tabs',
type: 'checkbox' as const,
disabled: true
}])
</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,46 @@
<script setup lang="ts">
const showBookmarks = ref(true)
const showHistory = ref(false)
const showDownloads = ref(false)
const items = computed(() => [{
label: 'Interface',
icon: 'i-heroicons-window',
type: 'label' as const
}, {
type: 'separator' as const
}, {
label: 'Show Bookmarks',
icon: 'i-heroicons-bookmark',
type: 'checkbox' as const,
checked: showBookmarks.value,
onUpdateChecked(checked: boolean) {
showBookmarks.value = checked
},
onSelect(e?: Event) {
e?.preventDefault()
}
}, {
label: 'Show History',
icon: 'i-heroicons-clock',
type: 'checkbox' as const,
checked: showHistory.value,
onUpdateChecked(checked: boolean) {
showHistory.value = checked
}
}, {
label: 'Show Downloads',
icon: 'i-heroicons-arrow-down-on-square',
type: 'checkbox' as const,
checked: showDownloads.value,
onUpdateChecked(checked: boolean) {
showDownloads.value = checked
}
}])
</script>

<template>
<UDropdownMenu :items="items" :content="{ align: 'start' }" class="w-48">
<UButton label="Open" color="neutral" variant="outline" icon="i-heroicons-bars-3" />
</UDropdownMenu>
</template>
17 changes: 16 additions & 1 deletion docs/content/3.components/context-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Use the `items` prop as an array of objects with the following properties:
- `icon?: string`{lang="ts-type"}
- `avatar?: AvatarProps`{lang="ts-type"}
- `kbds?: string[] | KbdProps[]`{lang="ts-type"}
- `type?: "link" | "label" | "separator"`{lang="ts-type"}
- [`type?: "link" | "label" | "separator" | "checkbox"`{lang="ts-type"}](#with-checkbox-items)
- `disabled?: boolean`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
Expand Down Expand Up @@ -174,6 +174,21 @@ slots:

## Examples

### With checkbox items

You can use the `type` property with `checkbox` and use the `checked` / `onUpdateChecked` properties to control the checked state of the item.

::component-example
---
collapse: true
name: 'context-menu-checkbox-items-example'
---
::

::note
To ensure reactivity for the `checked` state of items, it's recommended to wrap your `items` array inside a `computed`.
::

### With custom slot

Use the `slot` property to customize a specific item.
Expand Down
17 changes: 16 additions & 1 deletion docs/content/3.components/dropdown-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Use the `items` prop as an array of objects with the following properties:
- `icon?: string`{lang="ts-type"}
- `avatar?: AvatarProps`{lang="ts-type"}
- `kbds?: string[] | KbdProps[]`{lang="ts-type"}
- `type?: "link" | "label" | "separator"`{lang="ts-type"}
- [`type?: "link" | "label" | "separator" | "checkbox"`{lang="ts-type"}](#with-checkbox-items)
- `disabled?: boolean`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
Expand Down Expand Up @@ -256,6 +256,21 @@ slots:

## Examples

### With checkbox items

You can use the `type` property with `checkbox` and use the `checked` / `onUpdateChecked` properties to control the checked state of the item.

::component-example
---
collapse: true
name: 'dropdown-menu-checkbox-items-example'
---
::

::note
To ensure reactivity for the `checked` state of items, it's recommended to wrap your `items` array inside a `computed`.
::

### Control open state

You can control the open state by using the `default-open` prop or the `v-model:open` directive.
Expand Down
13 changes: 10 additions & 3 deletions src/runtime/components/ContextMenu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { ContextMenuRootProps, ContextMenuRootEmits, ContextMenuContentProps, ContextMenuTriggerProps, ContextMenuItemProps } from 'radix-vue'
import type { ContextMenuRootProps, ContextMenuRootEmits, ContextMenuContentProps, ContextMenuTriggerProps, ContextMenuItemProps, ContextMenuCheckboxItemProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/context-menu'
Expand All @@ -12,7 +12,7 @@ const appConfig = _appConfig as AppConfig & { ui: { contextMenu: Partial<typeof

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

export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<ContextMenuItemProps, 'disabled'> {
export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<ContextMenuItemProps, 'disabled'>, Pick<ContextMenuCheckboxItemProps, 'checked'> {
label?: string
icon?: string
avatar?: AvatarProps
Expand All @@ -22,19 +22,25 @@ export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custo
* The item type.
* @defaultValue 'link'
*/
type?: 'label' | 'separator' | 'link'
type?: 'label' | 'separator' | 'link' | 'checkbox'
slot?: string
open?: boolean
defaultOpen?: boolean
children?: ContextMenuItem[] | ContextMenuItem[][]
onSelect?(e: Event): void
onUpdateChecked?(checked: boolean): void
}

type ContextMenuVariants = VariantProps<typeof contextMenu>

export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'>, Pick<ContextMenuTriggerProps, 'disabled'> {
size?: ContextMenuVariants['size']
items?: T[] | T[][]
/**
* The icon displayed when an item is checked.
* @defaultValue appConfig.ui.icons.check
*/
checkedIcon?: string
/** The content of the menu. */
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'>
/**
Expand Down Expand Up @@ -103,6 +109,7 @@ const ui = computed(() => contextMenu({
:items="items"
:portal="portal"
:label-key="labelKey"
:checked-icon="checkedIcon"
>
<template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" />
Expand Down
26 changes: 24 additions & 2 deletions src/runtime/components/ContextMenuContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ContextMenuContentProps<T> extends Omit<RadixContextMenuContentProps,
portal?: boolean
sub?: boolean
labelKey: string
checkedIcon?: string
class?: any
ui: typeof _contextMenu
uiOverride?: any
Expand Down Expand Up @@ -62,13 +63,17 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, active })" />
</span>

<span v-if="item.children?.length || item.kbds?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<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 })" />
<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 })" />
</ContextMenu.ItemIndicator>
</span>
</slot>
</DefineItemTemplate>
Expand Down Expand Up @@ -108,7 +113,24 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</template>
</UContextMenuContent>
</ContextMenu.Sub>
<ContextMenu.Item v-else as-child :disabled="item.disabled" :text-value="get(item, props.labelKey as string)" @select="item.onSelect">
<ContextMenu.CheckboxItem
v-else-if="item.type === 'checkbox'"
:checked="item.checked"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: [uiOverride?.item, item.class] })"
@update:checked="item.onUpdateChecked"
@select="item.onSelect"
>
<ReuseItemTemplate :item="item" :index="index" />
</ContextMenu.CheckboxItem>
<ContextMenu.Item
v-else
as-child
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
@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 })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
Expand Down
13 changes: 10 additions & 3 deletions src/runtime/components/DropdownMenu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { DropdownMenuRootProps, DropdownMenuRootEmits, DropdownMenuContentProps, DropdownMenuArrowProps, DropdownMenuTriggerProps, DropdownMenuItemProps } from 'radix-vue'
import type { DropdownMenuRootProps, DropdownMenuRootEmits, DropdownMenuContentProps, DropdownMenuArrowProps, DropdownMenuTriggerProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/dropdown-menu'
Expand All @@ -12,7 +12,7 @@ const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof

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

export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<DropdownMenuItemProps, 'disabled'> {
export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<DropdownMenuItemProps, 'disabled'>, Pick<DropdownMenuCheckboxItemProps, 'checked'> {
label?: string
icon?: string
avatar?: AvatarProps
Expand All @@ -22,19 +22,25 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cust
* The item type.
* @defaultValue 'link'
*/
type?: 'label' | 'separator' | 'link'
type?: 'label' | 'separator' | 'link' | 'checkbox'
slot?: string
open?: boolean
defaultOpen?: boolean
children?: DropdownMenuItem[] | DropdownMenuItem[][]
onSelect?(e: Event): void
onUpdateChecked?(checked: boolean): void
}

type DropdownMenuVariants = VariantProps<typeof dropdownMenu>

export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'>, Pick<DropdownMenuTriggerProps, 'disabled'> {
size?: DropdownMenuVariants['size']
items?: T[] | T[][]
/**
* The icon displayed when an item is checked.
* @defaultValue appConfig.ui.icons.check
*/
checkedIcon?: string
/**
* The content of the menu.
* @defaultValue { side: 'bottom', sideOffset: 8 }
Expand Down Expand Up @@ -113,6 +119,7 @@ const ui = computed(() => dropdownMenu({
:items="items"
:portal="portal"
:label-key="labelKey"
:checked-icon="checkedIcon"
>
<template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" />
Expand Down
28 changes: 26 additions & 2 deletions src/runtime/components/DropdownMenuContent.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { DropdownMenuContentProps as RadixDropdownMenuContentProps, DropdownMenuContentEmits as RadixDropdownMenuContentEmits } from 'radix-vue'
Expand All @@ -11,6 +12,7 @@ interface DropdownMenuContentProps<T> extends Omit<RadixDropdownMenuContentProps
portal?: boolean
sub?: boolean
labelKey: string
checkedIcon?: string
class?: any
ui: typeof _dropdownMenu
uiOverride?: any
Expand All @@ -21,6 +23,7 @@ interface DropdownMenuContentEmits extends RadixDropdownMenuContentEmits {}
type DropdownMenuContentSlots<T extends { slot?: string }> = Omit<DropdownMenuSlots<T>, 'default'> & {
default(props?: {}): any
}

</script>

<script setup lang="ts" generic="T extends DropdownMenuItem">
Expand Down Expand Up @@ -66,13 +69,17 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<UIcon v-if="item.target === '_blank'" :name="appConfig.ui.icons.external" :class="ui.itemLabelExternalIcon({ class: uiOverride?.itemLabelExternalIcon, active })" />
</span>

<span v-if="item.children?.length || item.kbds?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<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 })" />
<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 })" />
</DropdownMenu.ItemIndicator>
</span>
</slot>
</DefineItemTemplate>
Expand Down Expand Up @@ -115,7 +122,24 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</template>
</UDropdownMenuContent>
</DropdownMenu.Sub>
<DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="get(item, props.labelKey as string)" @select="item.onSelect">
<DropdownMenu.CheckboxItem
v-else-if="item.type === 'checkbox'"
:checked="item.checked"
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
:class="ui.item({ class: [uiOverride?.item, item.class] })"
@update:checked="item.onUpdateChecked"
@select="item.onSelect"
>
<ReuseItemTemplate :item="item" :index="index" />
</DropdownMenu.CheckboxItem>
<DropdownMenu.Item
v-else
as-child
:disabled="item.disabled"
:text-value="get(item, props.labelKey as string)"
@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 })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
Expand Down
Loading

0 comments on commit 8ef6e71

Please sign in to comment.