Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: mutliple level command-palette, commands for docs #247

Merged
merged 5 commits into from
Jun 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ Temporary Items

# Workspaces
packages/devtools/README.md

clones
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"type": "module",
"version": "0.5.5",
"private": false,
"packageManager": "pnpm@8.6.1",
Expand Down Expand Up @@ -34,10 +35,12 @@
"eslint": "8.42.0",
"esno": "^0.16.3",
"execa": "^7.1.1",
"gray-matter": "^4.0.3",
"lint-staged": "^13.2.2",
"nuxt": "^3.5.2",
"pathe": "^1.1.1",
"simple-git-hooks": "^2.8.1",
"tiged": "^2.12.5",
"typescript": "5.0.4",
"unocss": "^0.53.0",
"vite-hot-client": "^0.2.1",
Expand Down
113 changes: 78 additions & 35 deletions packages/devtools/client/components/CommandPalette.vue
Original file line number Diff line number Diff line change
@@ -1,67 +1,108 @@
<script setup lang="ts">
import Fuse from 'fuse.js'
import type { CommandItem } from '~/composables/state-commands'

const show = ref(false)
const search = ref('')

const items = useCommands()
const rootItems = useCommands()
const overrideItems = ref<CommandItem[] | undefined>()
const items = computed(() => overrideItems.value || rootItems.value)

const fuse = computed(() => new Fuse(items.value, {
keys: [
'id',
'title',
],
threshold: 0.3,
distance: 50,
}))

const filtered = computed(() => {
const result = search.value
? fuse.value.search(search.value).map(i => i.item)
: (items.value || [])
return result
})
const filtered = computed(() => search.value
? fuse.value.search(search.value).map(i => i.item)
: (items.value || []),
)

const elements = ref<any[]>([])
const selectedIndex = ref(0)

watch(search, () => {
selectedIndex.value = 0
scrollToITem()
})

function moveSelected(delta: number) {
selectedIndex.value = ((selectedIndex.value + delta) + filtered.value.length) % filtered.value.length
scrollToITem()
}

const item = elements.value[selectedIndex.value]
item.scrollIntoView({
function scrollToITem() {
const item = document.getElementById(filtered.value[selectedIndex.value]?.id)
item?.scrollIntoView({
block: 'center',
})
}

async function enterItem(item: CommandItem) {
const result = await item.action()
if (!result) {
overrideItems.value = undefined
search.value = ''
show.value = false
}
else {
overrideItems.value = result
search.value = ''
}
}

useEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
overrideItems.value = undefined
search.value = ''
show.value = !show.value
return
}

if (show.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!show.value)
return

switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault()
moveSelected(e.key === 'ArrowDown' ? 1 : -1)
}
break

if (e.key === 'Enter') {
case 'Enter': {
const item = filtered.value[selectedIndex.value]
if (item) {
e.preventDefault()
item.action()
show.value = false
enterItem(item)
}
break
}

if (e.key === 'Escape')
show.value = false
case 'Escape': {
e.preventDefault()
if (overrideItems.value) {
overrideItems.value = undefined
search.value = ''
}
else {
show.value = false
}
break
}
}
})

function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Backspace' && !search.value && overrideItems.value) {
e.preventDefault()
overrideItems.value = undefined
search.value = ''
}
}
</script>

<template>
Expand All @@ -71,27 +112,29 @@ useEventListener('keydown', (e) => {
<NTextInput
v-model="search"
placeholder="Type to search..."
class="rounded-none py3 px2! ring-0!" n="lg green borderless"
class="rounded-none py3 px2! ring-0!"
n="green borderless"
@keydown="onKeyDown"
/>
</header>
<div flex-auto of-auto p2 flex="~ col">
<button
v-for="item, idx of filtered"
:id="item.id"
ref="elements"
:key="item.id"
@click="item.action(), show = false"
@click="enterItem(item)"
@mouseover="selectedIndex = idx"
>
<div
flex="~ items-center justify-between" rounded px3 py2
:class="selectedIndex === idx ? 'op100 bg-primary/10 text-primary saturate-100 bg-active' : 'op50'"
flex="~ gap-2 items-center justify-between" rounded px3 py2
:class="selectedIndex === idx ? 'op100 bg-primary/10 text-primary saturate-100 bg-active' : 'op80'"
>
<span flex items-center gap2>
<TabIcon text-xl :icon="item.icon" :title="item.title" />
{{ item.title }}
<TabIcon :icon="item.icon" :title="item.title" flex-none text-xl />
<span flex flex-auto items-center gap2 of-hidden>
<span ws-nowrap>{{ item.title }}</span>
<span of-hidden truncate ws-nowrap text-sm op50>{{ item.description }}</span>
</span>
<NIcon v-if="selectedIndex === idx" icon="tabler-arrow-back" />
<NIcon v-if="selectedIndex === idx" icon="i-carbon-text-new-line scale-x--100" flex-none />
</div>
</button>
<div v-if="!filtered.length" h-full flex items-center justify-center gap-2 text-xl>
Expand All @@ -105,12 +148,6 @@ useEventListener('keydown', (e) => {
</div>
</div>
<footer border="t base" flex="~ none justify-between items-center gap-4" pointer-events-none px4 py2>
<div text-xs flex="~ items-center gap2">
<NButton n="xs" px1>
<NIcon icon="tabler-arrow-back" />
</NButton>
<span op75>to select</span>
</div>
<div text-xs flex="~ items-center gap2">
<NButton n="xs" px1>
<NIcon icon="carbon-arrow-down" />
Expand All @@ -124,7 +161,13 @@ useEventListener('keydown', (e) => {
<NButton n="xs" px1>
Esc
</NButton>
<span op75>to close</span>
<span op75>to {{ overrideItems ? 'go back' : 'close' }}</span>
</div>
<div text-xs flex="~ items-center gap2">
<NButton n="xs" px1>
<NIcon icon="i-carbon-text-new-line scale-x--100" />
</NButton>
<span op75>to select</span>
</div>
</footer>
</div>
Expand Down
41 changes: 39 additions & 2 deletions packages/devtools/client/composables/state-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { MaybeRefOrGetter } from 'vue'
export interface CommandItem {
id: string
title: string
description?: string
icon?: string
action: () => void
action: () => void | CommandItem[] | Promise<CommandItem[]>
}

const registeredCommands = reactive(new Map<string, MaybeRefOrGetter<CommandItem[]>>())
Expand All @@ -19,7 +20,17 @@ export function useCommands() {
id: 'fixed:settings',
title: 'Settings',
icon: 'carbon-settings-adjust',
action: () => router.push('/settings'),
action: () => {
router.push('/settings')
},
},
{
id: 'fixed:docs',
title: 'Nuxt Documentations',
icon: 'logos-nuxt-icon',
action: () => {
return getNuxtDocsCommands()
},
},
]

Expand Down Expand Up @@ -58,3 +69,29 @@ export function registerCommands(getter: MaybeRefOrGetter<CommandItem[]>) {
registeredCommands.delete(id)
})
}

let _nuxtDocsCommands: CommandItem[] | undefined

const docsIcons = [
[':components:', 'i-carbon-assembly-cluster'],
[':modules:', 'i-carbon-cube'],
[':commands:', 'i-carbon-terminal'],
[':directory-structure:', 'i-carbon-folder'],
[':composables:', 'i-carbon-function'],
[':getting-started:', 'i-carbon-idea'],
[':api:', 'carbon-api-1'],
]

export async function getNuxtDocsCommands() {
if (!_nuxtDocsCommands) {
const list = await import('../data/nuxt-docs.json').then(i => i.default)
_nuxtDocsCommands = list.map(i => ({
...i,
icon: docsIcons.find(([k]) => i.id.includes(k))?.[1] || 'i-carbon-document-multiple-01',
action: () => {
window.open(i.url, '_blank')
},
}))
}
return _nuxtDocsCommands
}
Loading