Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
79475b7
WIP: basic ui logic command bar
Gianthard-cyh Jan 31, 2026
727cc82
WIP(command-bar): trigger handlers
Gianthard-cyh Jan 31, 2026
1fbab7e
WIP(command-bar): trrigger feedback
Gianthard-cyh Jan 31, 2026
6d44e65
WIP(command-bar): command prompt
Gianthard-cyh Jan 31, 2026
7ada4b1
Update app/components/CommandBar.vue
Gianthard-cyh Jan 31, 2026
cc11944
WIP(command-bar): basic api
Gianthard-cyh Jan 31, 2026
4d8e6aa
WIP(command-bar): scoped command example
Gianthard-cyh Jan 31, 2026
95639ea
feat(command-bar): context api (input, select, etc.)
Gianthard-cyh Feb 1, 2026
270c386
chore: revert [...package].vue
Gianthard-cyh Feb 1, 2026
d92cd1b
Merge branch 'main' into main
Gianthard-cyh Feb 1, 2026
e2b331e
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 1, 2026
af1a50a
fix(test): remove unused variable
Gianthard-cyh Feb 1, 2026
f87acf9
Merge branch 'main' of github.com:Gianthard-cyh/npmx.dev
Gianthard-cyh Feb 1, 2026
6020a50
chore: remove unused variable
Gianthard-cyh Feb 1, 2026
516baeb
chore: apply suggestions from copilot
Gianthard-cyh Feb 1, 2026
8f087d4
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 1, 2026
efce157
fix: remove useI18n() to prevent error
Gianthard-cyh Feb 1, 2026
acc860e
Merge branch 'main' of github.com:Gianthard-cyh/npmx.dev
Gianthard-cyh Feb 1, 2026
24a56be
Merge branch 'main' into Gianthard-cyh/main
danielroe Feb 1, 2026
0c69097
fix(test): ignore a11y due to the need of full app context.
Gianthard-cyh Feb 1, 2026
527cdd4
test: add a11y test to CommandBar.vue
Gianthard-cyh Feb 1, 2026
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
8 changes: 8 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const localeMap = locales.value.reduce(
{} as Record<string, Directions>,
)

const commandBarRef = useTemplateRef('commandBarRef')

useHead({
htmlAttrs: {
'lang': () => locale.value,
Expand All @@ -40,6 +42,11 @@ if (import.meta.server) {
// "/" focuses search or navigates to search page
// "?" highlights all keyboard shortcut elements
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
commandBarRef.value?.toggle()
}

if (isEditableElement(e.target)) return

if (isKeyWithoutModifiers(e, '/')) {
Expand Down Expand Up @@ -89,6 +96,7 @@ if (import.meta.client) {
<div class="min-h-screen flex flex-col bg-bg text-fg">
<NuxtPwaAssets />
<a href="#main-content" class="skip-link font-mono">{{ $t('common.skip_link') }}</a>
<CommandBar ref="commandBarRef" />

<AppHeader :show-logo="!isHomepage" />

Expand Down
241 changes: 241 additions & 0 deletions app/components/CommandBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
>
<div
class="cmdbar-container flex items-center justify-center border border-border shadow-lg rounded-xl bg-bg p2 flex-col gap-2 mt-5rem"
role="dialog"
aria-modal="true"
aria-labelledby="command-input-label"
>
<label for="command-input" id="command-input-label" class="sr-only">{{
t('command.label')
}}</label>

<search class="relative w-xl h-12 flex items-center">
<span class="absolute inset-is-4 text-fg-subtle font-mono pointer-events-none">
>
</span>
<input
type="text"
v-model="inputVal"
id="command-input"
ref="inputRef"
class="w-full h-full px-4 pl-8 text-fg outline-none bg-bg-subtle border border-border rounded-md"
:placeholder="placeholderText"
@keydown="handleKeydown"
/>
</search>

<div class="w-xl max-h-lg overflow-auto" v-if="view.type != 'INPUT'">
<div
v-for="item in filteredCmdList"
:key="item.id"
class="px-4 py-2 not-first:mt-2 hover:bg-bg-elevated select-none cursor-pointer rounded-md transition"
:class="{
'bg-bg-subtle': item.id === selected,
'trigger-anim': item.id === triggeringId,
}"
@click="onTrigger(item.id)"
>
<div class="text-fg">{{ item.name }}</div>
<div class="text-fg-subtle text-sm">{{ item.description }}</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<script setup lang="ts">
const { t } = useI18n()

type ViewState =
| { type: 'ROOT' }
| { type: 'INPUT'; prompt: string; resolve: (val: string) => void }
| { type: 'SELECT'; prompt: string; items: any[]; resolve: (val: any) => void }
const view = ref<ViewState>({ type: 'ROOT' })

const cmdCtx: CommandContext = {
async input(options) {
return new Promise(resolve => {
view.value = { type: 'INPUT', prompt: options.prompt, resolve }
})
},
async select(options) {
return new Promise(resolve => {
view.value = { type: 'SELECT', prompt: options.prompt, items: options.items, resolve }
})
},
}

const { commands } = useCommandRegistry()

const selected = shallowRef(commands.value[0]?.id || '')
const inputVal = shallowRef('')
const show = shallowRef(false)
const triggeringId = shallowRef('')
const inputRef = useTemplateRef('inputRef')

const { focused: inputFocused } = useFocus(inputRef)

const placeholderText = computed(() => {
if (view.value.type === 'INPUT' || view.value.type === 'SELECT') {
return view.value.prompt
}
return t('command.placeholder')
})

const filteredCmdList = computed(() => {
if (view.value.type === 'INPUT') {
return []
}

const list = view.value.type === 'SELECT' ? view.value.items : commands.value

if (!inputVal.value) {
return list
}
const filter = inputVal.value.trim().toLowerCase()
return list.filter(
(item: any) =>
item.name.toLowerCase().includes(filter) ||
item.description?.toLowerCase().includes(filter) ||
item.id.includes(filter),
)
})

watch(
() => filteredCmdList.value,
newVal => {
if (newVal.length) {
selected.value = newVal[0]?.id || ''
}
},
)

function focusInput() {
inputFocused.value = true
}

function open() {
inputVal.value = ''
selected.value = commands.value[0]?.id || ''
show.value = true
view.value = { type: 'ROOT' }
nextTick(focusInput)
}

function close() {
inputVal.value = ''
selected.value = commands.value[0]?.id || ''
show.value = false
}

function toggle() {
if (show.value) {
close()
} else {
open()
}
}

function onTrigger(id: string) {
triggeringId.value = id

if (view.value.type === 'ROOT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
selectedItem?.handler?.(cmdCtx)
setTimeout(() => {
triggeringId.value = ''
if (view.value.type === 'ROOT') {
close()
}
}, 100)
} else if (view.value.type === 'INPUT') {
view.value.resolve(inputVal.value)
close()
} else if (view.value.type === 'SELECT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
view.value.resolve(selectedItem)
close()
}
}

const handleKeydown = useThrottleFn((e: KeyboardEvent) => {
if (view.value.type === 'INPUT' && e.key === 'Enter') {
e.preventDefault()
onTrigger('') // Trigger for input doesn't need ID
return
}

if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !filteredCmdList.value.length) {
e.preventDefault()
return
}

const currentIndex = filteredCmdList.value.findIndex((item: any) => item.id === selected.value)

if (e.key === 'ArrowDown') {
e.preventDefault()
const nextIndex = (currentIndex + 1) % filteredCmdList.value.length
selected.value = filteredCmdList.value[nextIndex]?.id || ''
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prevIndex =
(currentIndex - 1 + filteredCmdList.value.length) % filteredCmdList.value.length
selected.value = filteredCmdList.value[prevIndex]?.id || ''
} else if (e.key === 'Enter') {
e.preventDefault()
onTrigger(selected.value)
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}, 50)

defineExpose({
open,
close,
toggle,
})
</script>

<style scoped>
.fade-enter-active {
transition: all 0.05s ease-out;
}

.fade-leave-active {
transition: all 0.1s ease-in;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}

@keyframes trigger-pulse {
0% {
transform: scale(1);
}

50% {
transform: scale(0.96);
background-color: var(--bg-elevated);
}

100% {
transform: scale(1);
}
}

.trigger-anim {
animation: trigger-pulse 0.1s ease-in-out;
}
</style>
33 changes: 33 additions & 0 deletions app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import type { JsrPackageInfo } from '#shared/types/jsr'
import type { PackageManagerId } from '~/utils/install-command'

const { t } = useI18n()

const props = defineProps<{
packageName: string
requestedVersion?: string | null
Expand Down Expand Up @@ -93,6 +95,37 @@ const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))

const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
const copyCreateCommand = () => copyCreate(getFullCreateCommand())

registerScopedCommand({
id: 'package:install',
name: t('command.copy_install'),
description: t('command.copy_install_desc'),
handler: async () => {
copyInstallCommand()
},
})

if (props.executableInfo?.hasExecutable) {
registerScopedCommand({
id: 'packages:copy-run',
name: t('command.copy_run'),
description: t('command.copy_run_desc'),
handler: async () => {
copyRunCommand()
},
})
}

if (props.createPackageInfo) {
registerScopedCommand({
id: 'packages:copy-create',
name: t('command.copy_create'),
description: t('command.copy_create_desc'),
handler: async () => {
copyCreateCommand()
},
})
}
</script>

<template>
Expand Down
81 changes: 81 additions & 0 deletions app/composables/useCommandRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { computed } from 'vue'

export interface Command {
id: string
name: string
description: string | undefined
handler?: (ctx: CommandContext) => Promise<void>
}

export interface CommandContext {
input: (options: CommandInputOptions) => Promise<string | undefined>
select: <T>(options: CommandSelectOptions<T>) => Promise<T | undefined>
}

export interface CommandInputOptions {
prompt: string
}

export interface CommandSelectOptions<T> {
prompt: string
items: T[]
}

/**
* Composable for global command registry.
* @public
*/
export const useCommandRegistry = () => {
const commands = useState<Map<string, Command>>('commands', () => new Map())

const register = (command: Command) => {
const serverCommand = {
...command,
handler: undefined,
}
if (import.meta.server) {
commands.value.set(command.id, serverCommand)
} else {
commands.value.set(command.id, command)
}
return () => {
commands.value.delete(command.id)
}
}

return {
register,
commands: computed(() => Array.from(commands.value.values())),
}
}

/**
* Registers a global command.
* @public
*/
export const registerGlobalCommand = (command: Command) => {
const { register } = useCommandRegistry()
return register(command)
}

/**
* Registers a command bound to the current component's lifecycle.
*
* The command is automatically unregistered when the component unmounts.
* Use this to register commands that rely on local component state (context)
* via closure capture.
*
* @public
*/
export const registerScopedCommand = (command: Command) => {
const { register } = useCommandRegistry()
let unregister: () => void

onMounted(() => {
unregister = register(command)
})

onUnmounted(() => {
unregister()
})
}
Loading