mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(useKbd): new composable (#73)
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const { metaSymbol } = useShortcuts()
|
||||
|
||||
const items = computed(() => [
|
||||
const items = [
|
||||
[{
|
||||
label: 'My account',
|
||||
avatar: {
|
||||
@@ -19,11 +17,17 @@ const items = computed(() => [
|
||||
}, {
|
||||
label: 'Billing',
|
||||
icon: 'i-heroicons-credit-card',
|
||||
shortcuts: [metaSymbol.value, 'B']
|
||||
kbds: ['meta', 'b'],
|
||||
select() {
|
||||
console.log('Billing clicked')
|
||||
}
|
||||
}, {
|
||||
label: 'Settings',
|
||||
icon: 'i-heroicons-cog',
|
||||
shortcuts: [metaSymbol.value, ',']
|
||||
kbds: ['?'],
|
||||
select() {
|
||||
console.log('Settings clicked')
|
||||
}
|
||||
}], [{
|
||||
label: 'Team',
|
||||
icon: 'i-heroicons-users'
|
||||
@@ -36,9 +40,9 @@ const items = computed(() => [
|
||||
}, {
|
||||
label: 'Invite by link',
|
||||
icon: 'i-heroicons-link',
|
||||
shortcuts: [metaSymbol.value, 'I'],
|
||||
kbds: ['meta', 'i'],
|
||||
select(e: Event) {
|
||||
e.preventDefault()
|
||||
e?.preventDefault()
|
||||
console.log('Invite by link clicked')
|
||||
}
|
||||
}], [{
|
||||
@@ -72,7 +76,10 @@ const items = computed(() => [
|
||||
}, {
|
||||
label: 'New team',
|
||||
icon: 'i-heroicons-plus',
|
||||
shortcuts: [metaSymbol.value, 'N']
|
||||
kbds: ['meta', 'n'],
|
||||
select() {
|
||||
console.log('New team clicked')
|
||||
}
|
||||
}], [{
|
||||
label: 'GitHub',
|
||||
icon: 'i-simple-icons-github',
|
||||
@@ -86,7 +93,7 @@ const items = computed(() => [
|
||||
icon: 'i-heroicons-lifebuoy',
|
||||
to: '/dropdown-menu'
|
||||
}, {
|
||||
label: 'Shortcuts',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'i-heroicons-key'
|
||||
}, {
|
||||
label: 'API',
|
||||
@@ -95,9 +102,14 @@ const items = computed(() => [
|
||||
}], [{
|
||||
label: 'Logout',
|
||||
icon: 'i-heroicons-arrow-right-start-on-rectangle',
|
||||
shortcuts: ['⇧', '⌘', 'Q']
|
||||
kbds: ['shift', 'meta', 'q'],
|
||||
select() {
|
||||
console.log('Logout clicked')
|
||||
}
|
||||
}]
|
||||
])
|
||||
]
|
||||
|
||||
defineShortcuts(extractShortcuts(items))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import theme from '#build/ui/kbd'
|
||||
import { kbdKeysMap } from '#ui/composables/useKbd'
|
||||
|
||||
const sizes = Object.keys(theme.variants.size)
|
||||
const kbdKeys = Object.keys(kbdKeysMap)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="⌘" />
|
||||
<UKbd value="K" />
|
||||
<UKbd value="meta" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="⌘" color="gray" />
|
||||
<UKbd value="K" color="gray" />
|
||||
<UKbd value="meta" color="gray" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="⌘" color="black" />
|
||||
<UKbd value="K" color="black" />
|
||||
<UKbd value="meta" color="black" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 -ml-[41px]">
|
||||
<template v-for="size in sizes" :key="size">
|
||||
<UKbd value="⌘" :size="(size as any)" />
|
||||
<UKbd value="K" :size="(size as any)" />
|
||||
</template>
|
||||
<div class="flex items-center gap-1 -ml-[216px]">
|
||||
<UKbd v-for="(kdbKey, index) in kbdKeys" :key="index" :value="kdbKey" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 -ml-[22px]">
|
||||
<UKbd v-for="size in sizes" :key="size" value="meta" :size="(size as any)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<UTooltip text="Top" :shortcuts="['⌘', 'T']" :content="{ side: 'top' }" arrow>
|
||||
<UTooltip text="Top" :kbds="['meta', 'T']" :content="{ side: 'top' }" arrow>
|
||||
<UAvatar text="T" />
|
||||
</UTooltip>
|
||||
|
||||
<div class="flex items-center gap-2 ml-[-20px]">
|
||||
<UTooltip text="Left" :shortcuts="['⌘', 'L']" :content="{ side: 'left' }" arrow>
|
||||
<UTooltip text="Left" :kbds="['meta', 'L']" :content="{ side: 'left' }" arrow>
|
||||
<UAvatar text="L" />
|
||||
</UTooltip>
|
||||
|
||||
<UTooltip text="Right" :shortcuts="['⌘', 'R']" :content="{ side: 'right' }" arrow>
|
||||
<UTooltip text="Right" :kbds="['meta', 'R']" :content="{ side: 'right' }" arrow>
|
||||
<UAvatar text="R" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
<UTooltip text="Bottom" :shortcuts="['⌘', 'B']" arrow>
|
||||
<UTooltip text="Bottom" :kbds="['meta', 'B']" arrow>
|
||||
<UAvatar text="B" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -11,11 +11,11 @@ describe('DropdownMenu', () => {
|
||||
}, {
|
||||
label: 'Edit',
|
||||
icon: 'i-heroicons-pencil-square-20-solid',
|
||||
shortcuts: ['E']
|
||||
kbds: ['E']
|
||||
}, {
|
||||
label: 'Duplicate',
|
||||
icon: 'i-heroicons-document-duplicate-20-solid',
|
||||
shortcuts: ['D'],
|
||||
kbds: ['D'],
|
||||
disabled: true,
|
||||
slot: 'custom'
|
||||
}]
|
||||
|
||||
@@ -18,9 +18,9 @@ describe('Tooltip', () => {
|
||||
// Props
|
||||
['with text', { props: { text: 'Tooltip', open: true, portal: false } }],
|
||||
['with arrow', { props: { text: 'Tooltip', arrow: true, open: true, portal: false } }],
|
||||
['with shortcuts', { props: { text: 'Tooltip', shortcuts: ['⌘', 'K'], open: true, portal: false } }],
|
||||
['with kbds', { props: { text: 'Tooltip', kbds: ['meta', 'K'], open: true, portal: false } }],
|
||||
// Slots
|
||||
['with default slot', { props: { text: 'Tooltip', shortcuts: ['⌘', 'K'], open: true, portal: false }, slots: { default: () => 'Default slot' } }],
|
||||
['with default slot', { props: { text: 'Tooltip', kbds: ['meta', 'K'], open: true, portal: false }, slots: { default: () => 'Default slot' } }],
|
||||
['with content slot', { props: { open: true, portal: false }, slots: { content: () => 'Content slot' } }]
|
||||
])('renders %s correctly', async (nameOrHtml: string, options: { props?: TooltipProps, slots?: Partial<TooltipSlots> }) => {
|
||||
const html = await ComponentRender(nameOrHtml, options, TooltipWrapper)
|
||||
|
||||
Reference in New Issue
Block a user