Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
f587200
Merge remote-tracking branch 'upstream/main'
Gianthard-cyh Feb 6, 2026
604dfe0
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 6, 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
14 changes: 14 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const colorScheme = computed(() => {
}[colorMode.preference]
})

const commandBarRef = useTemplateRef('commandBarRef')

useHead({
htmlAttrs: {
'lang': () => locale.value,
Expand Down Expand Up @@ -77,6 +79,16 @@ onKeyDown(
{ dedupe: true },
)

onKeyDown(
'k',
e => {
if (!(e.metaKey || e.ctrlKey)) return
e.preventDefault()
commandBarRef.value?.toggle()
},
{ dedupe: true },
)

onKeyUp(
'?',
e => {
Expand Down Expand Up @@ -122,6 +134,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 Expand Up @@ -153,6 +166,7 @@ if (import.meta.client) {
color: var(--bg);
text-decoration: underline;
}

.skip-link:focus {
top: 0;
}
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"
>
Comment on lines +4 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clicking the backdrop does not close the modal.

Users expect that clicking outside a modal dialogue dismisses it. The overlay div has no click handler. Without this, keyboard-only close (Escape) is the only way out, and pointer users are left stranded.

Proposed fix
       class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
       v-show="show"
+      `@click.self`="close"
     >

Using @click.self ensures only clicks directly on the backdrop (not bubbled from the inner container) trigger close.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
>
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
`@click.self`="close"
>

<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>
Comment on lines +33 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Command list lacks ARIA listbox semantics.

The filtered command list is keyboard-navigable via ArrowUp/Down, but there is no role="listbox" on the container or role="option" on items, and no aria-activedescendant on the input to link the focused option. Screen readers will not announce the selected command.

Sketch of the fix
-          <div class="w-xl max-h-lg overflow-auto" v-if="view.type != 'INPUT'">
+          <div class="w-xl max-h-lg overflow-auto" role="listbox" id="command-listbox" v-if="view.type !== 'INPUT'">
             <div
               v-for="item in filteredCmdList"
               :key="item.id"
+              :id="`cmd-${item.id}`"
+              role="option"
+              :aria-selected="item.id === selected"
               class="px-4 py-2 not-first:mt-2 hover:bg-bg-elevated select-none cursor-pointer rounded-md transition"

Also add aria-activedescendant to the input, bound to `cmd-${selected}`, and aria-controls="command-listbox".

</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),
)
})
Comment on lines +93 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

filteredCmdList accesses .name / .description on any items without guards.

In SELECT mode, view.value.items is typed any[], so item.name may not exist. If a select handler provides items without a name field, this will render undefined and toLowerCase() will throw at runtime (Line 106).

Either tighten the item type (preferred) or add a guard:

Defensive guard (if typing is deferred)
   return list.filter(
-    (item: any) =>
-      item.name.toLowerCase().includes(filter) ||
-      item.description?.toLowerCase().includes(filter) ||
+    (item: { id: string; name?: string; description?: string }) =>
+      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()
}
}
Comment on lines +147 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handler result is fire-and-forget — unhandled rejections will be silent.

selectedItem?.handler?.(cmdCtx) returns a Promise<void> but is neither awaited nor .catch()-ed. If a handler throws after the ctx.input() / ctx.select() call, the rejection is swallowed. At minimum, add a .catch() to log/surface the error.

Proposed fix
-    selectedItem?.handler?.(cmdCtx)
+    selectedItem?.handler?.(cmdCtx).catch((err: unknown) => {
+      console.error(`[CommandBar] handler "${id}" failed:`, err)
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()
}
}
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)?.catch((err: unknown) => {
console.error(`[CommandBar] handler "${id}" failed:`, err)
})
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)
Comment on lines +169 to +199
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

useThrottleFn at 50 ms throttles all keystrokes, including typing.

Because handleKeydown is the sole @keydown handler on the text input, every character the user types is also throttled to 50 ms. On fast typists this may drop characters or feel laggy. The throttle is presumably intended only for arrow-key navigation. Consider either:

  • Moving the throttle inside the arrow/Enter branches only, or
  • Returning early for printable characters before the throttle takes effect.


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
Loading
Loading