mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-21 15:31:46 +01:00
feat(useKbd): new composable (#73)
This commit is contained in:
@@ -16,7 +16,7 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type'> {
|
||||
avatar?: AvatarProps
|
||||
disabled?: boolean
|
||||
content?: Omit<DropdownMenuContentProps, 'asChild' | 'forceMount'>
|
||||
shortcuts?: string[] | KbdProps[]
|
||||
kbds?: KbdProps['value'][] | KbdProps[]
|
||||
/**
|
||||
* The item type.
|
||||
* @defaultValue "link"
|
||||
|
||||
@@ -54,11 +54,11 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="$slots.trailing || item.children?.length || item.shortcuts?.length" :class="ui.linkTrailing()">
|
||||
<span v-if="$slots.trailing || item.children?.length || item.kbds?.length" :class="ui.linkTrailing()">
|
||||
<slot name="trailing" :item="item" :active="active" :index="index">
|
||||
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.linkTrailingIcon()" />
|
||||
<span v-else-if="item.shortcuts?.length" :class="ui.linkTrailingShortcuts()">
|
||||
<UKbd v-for="(shortcut, shortcutIndex) in item.shortcuts" :key="shortcutIndex" size="md" v-bind="typeof shortcut === 'string' ? { value: shortcut } : shortcut" />
|
||||
<span v-else-if="item.kbds?.length" :class="ui.linkTrailingKbds()">
|
||||
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
@@ -107,7 +107,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
|
||||
<slot :name="item.slot || 'item'" :item="item" :index="index">
|
||||
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'shortcuts', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
|
||||
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'kbds', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
|
||||
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
|
||||
<ReuseItemTemplate :item="item" :active="active" :index="index" />
|
||||
</ULinkBase>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PrimitiveProps } from 'radix-vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/kbd'
|
||||
import type { KbdKey } from '#ui/composables/useKbd'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { kbd: Partial<typeof theme> } }
|
||||
|
||||
@@ -12,7 +13,7 @@ const kbd = tv({ extend: tv(theme), ...(appConfig.ui?.kbd || {}) })
|
||||
type KbdVariants = VariantProps<typeof kbd>
|
||||
|
||||
export interface KbdProps extends Omit<PrimitiveProps, 'asChild'> {
|
||||
value?: string
|
||||
value: KbdKey | string
|
||||
color?: KbdVariants['color']
|
||||
size?: KbdVariants['size']
|
||||
class?: any
|
||||
@@ -25,15 +26,18 @@ export interface KbdSlots {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from 'radix-vue'
|
||||
import { useKbd } from '#imports'
|
||||
|
||||
const props = withDefaults(defineProps<KbdProps>(), { as: 'kbd' })
|
||||
defineSlots<KbdSlots>()
|
||||
|
||||
const { getKbdKey } = useKbd()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="kbd({ color, size, class: props.class })">
|
||||
<slot>
|
||||
{{ value }}
|
||||
{{ getKbdKey(value) }}
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,7 @@ const tooltip = tv({ extend: tv(theme), ...(appConfig.ui?.tooltip || {}) })
|
||||
|
||||
export interface TooltipProps extends TooltipRootProps {
|
||||
text?: string
|
||||
shortcuts?: string[] | KbdProps[]
|
||||
kbds?: KbdProps['value'][] | KbdProps[]
|
||||
content?: Omit<TooltipContentProps, 'asChild'>
|
||||
arrow?: boolean | Omit<TooltipArrowProps, 'asChild'>
|
||||
portal?: boolean
|
||||
@@ -57,8 +57,8 @@ const ui = computed(() => tv({ extend: tooltip, slots: props.ui })({ side: conte
|
||||
<slot name="content">
|
||||
<span v-if="text" :class="ui.text()">{{ text }}</span>
|
||||
|
||||
<span v-if="shortcuts?.length" :class="ui.shortcuts()">
|
||||
<UKbd v-for="(shortcut, index) in shortcuts" :key="index" size="sm" v-bind="typeof shortcut === 'string' ? { value: shortcut } : shortcut" />
|
||||
<span v-if="kbds?.length" :class="ui.kbds()">
|
||||
<UKbd v-for="(kbd, index) in kbds" :key="index" size="sm" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
|
||||
</span>
|
||||
</slot>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref, computed, toValue } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useEventListener, useDebounceFn } from '@vueuse/core'
|
||||
import { useShortcuts } from './useShortcuts'
|
||||
import { useEventListener, useActiveElement, useDebounceFn } from '@vueuse/core'
|
||||
import { useKbd } from '#imports'
|
||||
|
||||
type Handler = () => void
|
||||
type Handler = (e?: any) => void
|
||||
|
||||
export interface ShortcutConfig {
|
||||
handler: Handler
|
||||
@@ -35,15 +35,36 @@ interface Shortcut {
|
||||
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
|
||||
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
|
||||
|
||||
export const defineShortcuts = (config: MaybeRef<ShortcutsConfig>, options: ShortcutsOptions = {}) => {
|
||||
const { macOS, usingInput } = useShortcuts()
|
||||
export function extractShortcuts(items: any[] | any[][]) {
|
||||
const shortcuts: Record<string, Handler> = {}
|
||||
|
||||
function traverse(items: any[]) {
|
||||
items.forEach((item) => {
|
||||
if (item.kbds?.length && (item.select || item.click)) {
|
||||
const shortcutKey = item.kbds.join('_')
|
||||
shortcuts[shortcutKey] = item.select || item.click
|
||||
}
|
||||
if (item.children) {
|
||||
traverse(item.children.flat())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
traverse(items.flat())
|
||||
|
||||
return shortcuts
|
||||
}
|
||||
|
||||
export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: ShortcutsOptions = {}) {
|
||||
const chainedInputs = ref<string[]>([])
|
||||
const clearChainedInput = () => {
|
||||
chainedInputs.value.splice(0, chainedInputs.value.length)
|
||||
}
|
||||
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
|
||||
|
||||
const { macOS } = useKbd()
|
||||
const activeElement = useActiveElement()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Input autocomplete triggers a keydown event
|
||||
if (!e.key) {
|
||||
@@ -65,7 +86,7 @@ export const defineShortcuts = (config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
|
||||
if (shortcut.enabled) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
shortcut.handler(e)
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
@@ -102,6 +123,19 @@ export const defineShortcuts = (config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
debouncedClearChainedInput()
|
||||
}
|
||||
|
||||
const usingInput = computed(() => {
|
||||
const tagName = activeElement.value?.tagName
|
||||
const contentEditable = activeElement.value?.contentEditable
|
||||
|
||||
const usingInput = !!(tagName === 'INPUT' || tagName === 'TEXTAREA' || contentEditable === 'true' || contentEditable === 'plaintext-only')
|
||||
|
||||
if (usingInput) {
|
||||
return ((activeElement.value as any)?.name as string) || true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Map config to full detailled shortcuts
|
||||
const shortcuts = computed<Shortcut[]>(() => {
|
||||
return Object.entries(toValue(config)).map(([key, shortcutConfig]) => {
|
||||
@@ -132,11 +166,11 @@ export const defineShortcuts = (config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
} else {
|
||||
const keySplit = key.toLowerCase().split('_').map(k => k)
|
||||
shortcut = {
|
||||
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
|
||||
metaKey: keySplit.includes('meta'),
|
||||
key: keySplit.filter(k => !['meta', 'command', 'ctrl', 'shift', 'alt', 'option'].includes(k)).join('_'),
|
||||
metaKey: keySplit.includes('meta') || keySplit.includes('command'),
|
||||
ctrlKey: keySplit.includes('ctrl'),
|
||||
shiftKey: keySplit.includes('shift'),
|
||||
altKey: keySplit.includes('alt')
|
||||
altKey: keySplit.includes('alt') || keySplit.includes('option')
|
||||
}
|
||||
}
|
||||
shortcut.chained = chained
|
||||
|
||||
53
src/runtime/composables/useKbd.ts
Normal file
53
src/runtime/composables/useKbd.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
export const kbdKeysMap = {
|
||||
meta: '',
|
||||
command: '⌘',
|
||||
shift: '⇧',
|
||||
ctrl: '⌃',
|
||||
option: '⌥',
|
||||
alt: '⎇',
|
||||
enter: '↵',
|
||||
delete: '⌦',
|
||||
backspace: '⌫',
|
||||
escape: '⎋',
|
||||
tab: '⇥',
|
||||
capslock: '⇪',
|
||||
arrowup: '↑',
|
||||
arrowright: '→',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
pageup: '⇞',
|
||||
pagedown: '⇟',
|
||||
home: '↖',
|
||||
end: '↘'
|
||||
}
|
||||
|
||||
export type KbdKey = keyof typeof kbdKeysMap
|
||||
|
||||
const _useKbd = () => {
|
||||
const macOS = computed(() => import.meta.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
|
||||
|
||||
const metaSymbol = ref(' ')
|
||||
|
||||
onMounted(() => {
|
||||
metaSymbol.value = macOS.value ? kbdKeysMap.command : kbdKeysMap.ctrl
|
||||
})
|
||||
|
||||
function getKbdKey(value: KbdKey | string) {
|
||||
if (value === 'meta') {
|
||||
return metaSymbol.value
|
||||
}
|
||||
|
||||
return kbdKeysMap[value as KbdKey] || value.toUpperCase()
|
||||
}
|
||||
|
||||
return {
|
||||
macOS,
|
||||
metaSymbol,
|
||||
getKbdKey
|
||||
}
|
||||
}
|
||||
|
||||
export const useKbd = createSharedComposable(_useKbd)
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createSharedComposable, useActiveElement } from '@vueuse/core'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type {} from '@vueuse/shared'
|
||||
|
||||
const _useShortcuts = () => {
|
||||
const macOS = computed(() => import.meta.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
|
||||
|
||||
const metaSymbol = ref(' ')
|
||||
|
||||
const activeElement = useActiveElement()
|
||||
const usingInput = computed(() => {
|
||||
const tagName = activeElement.value?.tagName
|
||||
const contentEditable = activeElement.value?.contentEditable
|
||||
|
||||
const usingInput = !!(tagName === 'INPUT' || tagName === 'TEXTAREA' || contentEditable === 'true' || contentEditable === 'plaintext-only')
|
||||
|
||||
if (usingInput) {
|
||||
return ((activeElement.value as any)?.name as string) || true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
|
||||
})
|
||||
|
||||
return {
|
||||
macOS,
|
||||
metaSymbol,
|
||||
activeElement,
|
||||
usingInput
|
||||
}
|
||||
}
|
||||
|
||||
export const useShortcuts = createSharedComposable(_useShortcuts)
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
linkLeadingAvatar: 'shrink-0',
|
||||
linkTrailing: 'ms-auto inline-flex',
|
||||
linkTrailingIcon: 'shrink-0 size-5',
|
||||
linkTrailingShortcuts: 'hidden lg:inline-flex items-center shrink-0 gap-0.5',
|
||||
linkTrailingKbds: 'hidden lg:inline-flex items-center shrink-0 gap-0.5',
|
||||
linkLabel: 'truncate'
|
||||
},
|
||||
variants: {
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
content: 'flex items-center gap-1 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow rounded ring ring-gray-200 dark:ring-gray-800 h-6 px-2 py-1 text-xs select-none',
|
||||
arrow: 'fill-gray-200 dark:fill-gray-800',
|
||||
text: 'truncate',
|
||||
shortcuts: `hidden lg:inline-flex items-center shrink-0 gap-0.5 before:content-['·'] before:mr-0.5`
|
||||
kbds: `hidden lg:inline-flex items-center shrink-0 gap-0.5 before:content-['·'] before:mr-0.5`
|
||||
},
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
Reference in New Issue
Block a user