Skip to content

Commit 6746f25

Browse files
Merge branch 'main' into fix/ui-tweaks
2 parents 5be494e + c28f514 commit 6746f25

38 files changed

+3110
-225
lines changed

CONTRIBUTING.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ This focus helps guide our project decisions as a community and what we choose t
3333
- [Available commands](#available-commands)
3434
- [Project structure](#project-structure)
3535
- [Local connector CLI](#local-connector-cli)
36+
- [Mock connector (for local development)](#mock-connector-for-local-development)
3637
- [Code style](#code-style)
3738
- [TypeScript](#typescript)
3839
- [Server API patterns](#server-api-patterns)
@@ -104,6 +105,10 @@ pnpm dev # Start development server
104105
pnpm build # Production build
105106
pnpm preview # Preview production build
106107

108+
# Connector
109+
pnpm npmx-connector # Start the real connector (requires npm login)
110+
pnpm mock-connector # Start the mock connector (no npm login needed)
111+
107112
# Code Quality
108113
pnpm lint # Run linter (oxlint + oxfmt)
109114
pnpm lint:fix # Auto-fix lint issues
@@ -157,6 +162,36 @@ pnpm npmx-connector
157162

158163
The connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev.
159164

165+
### Mock connector (for local development)
166+
167+
If you're working on admin features (org management, package access controls, operations queue) and don't want to use your real npm account, you can run the mock connector instead:
168+
169+
```bash
170+
pnpm mock-connector
171+
```
172+
173+
This starts a mock connector server pre-populated with sample data (orgs, teams, members, packages). No npm login is required — operations succeed immediately without making real npm CLI calls.
174+
175+
The mock connector prints a connection URL to the terminal, just like the real connector. Click it (or paste the token manually) to connect the UI.
176+
177+
**Options:**
178+
179+
```bash
180+
pnpm mock-connector # default: port 31415, user "mock-user", sample data
181+
pnpm mock-connector --port 9999 # custom port
182+
pnpm mock-connector --user alice # custom username
183+
pnpm mock-connector --empty # start with no pre-populated data
184+
```
185+
186+
**Default sample data:**
187+
188+
- **@nuxt**: 4 members (mock-user, danielroe, pi0, antfu), 3 teams (core, docs, triage)
189+
- **@unjs**: 2 members (mock-user, pi0), 1 team (maintainers)
190+
- **Packages**: @nuxt/kit, @nuxt/schema, @unjs/nitro with team-based access controls
191+
192+
> [!TIP]
193+
> Run `pnpm dev` in a separate terminal to start the Nuxt dev server, then click the connection URL from the mock connector to connect.
194+
160195
## Code style
161196

162197
When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
@@ -752,6 +787,74 @@ You need to either:
752787
1. Add a fixture file for that package/endpoint
753788
2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server)
754789

790+
### Testing connector features
791+
792+
Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server.
793+
794+
#### Architecture
795+
796+
The mock connector infrastructure is shared between the CLI, E2E tests, and Vitest component tests:
797+
798+
```
799+
cli/src/
800+
├── types.ts # ConnectorEndpoints contract (shared by real + mock)
801+
├── mock-state.ts # MockConnectorStateManager (canonical source)
802+
├── mock-app.ts # H3 mock app + MockConnectorServer class
803+
└── mock-server.ts # CLI entry point (pnpm mock-connector)
804+
805+
test/test-utils/ # Re-exports from cli/src/ for test convenience
806+
test/e2e/helpers/ # E2E-specific wrappers (fixtures, global setup)
807+
```
808+
809+
Both the real server (`cli/src/server.ts`) and the mock server (`cli/src/mock-app.ts`) conform to the `ConnectorEndpoints` interface defined in `cli/src/types.ts`. This ensures the API contract is enforced by TypeScript. When adding a new endpoint, update `ConnectorEndpoints` first, then implement it in both servers.
810+
811+
#### Vitest component tests (`test/nuxt/`)
812+
813+
- Mock the `useConnector` composable with reactive state
814+
- Use `document.body` queries for components using Teleport
815+
- See `test/nuxt/components/HeaderConnectorModal.spec.ts` for an example
816+
817+
```typescript
818+
// Create mock state
819+
const mockState = ref({ connected: false, npmUser: null, ... })
820+
821+
// Mock the composable
822+
vi.mock('~/composables/useConnector', () => ({
823+
useConnector: () => ({
824+
isConnected: computed(() => mockState.value.connected),
825+
// ... other properties
826+
}),
827+
}))
828+
```
829+
830+
#### Playwright E2E tests (`test/e2e/`)
831+
832+
- A mock HTTP server starts automatically via Playwright's global setup
833+
- Use the `mockConnector` fixture to set up test data and the `gotoConnected` helper to navigate with authentication
834+
835+
```typescript
836+
test('shows org members', async ({ page, gotoConnected, mockConnector }) => {
837+
// Set up test data
838+
await mockConnector.setOrgData('@testorg', {
839+
users: { testuser: 'owner', member1: 'admin' },
840+
})
841+
842+
// Navigate with connector authentication
843+
await gotoConnected('/@testorg')
844+
845+
// Test assertions
846+
await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible()
847+
})
848+
```
849+
850+
The mock connector supports test endpoints for state manipulation:
851+
852+
- `/__test__/reset` - Reset all mock state
853+
- `/__test__/org` - Set org users, teams, and team members
854+
- `/__test__/user-orgs` - Set user's organizations
855+
- `/__test__/user-packages` - Set user's packages
856+
- `/__test__/package` - Set package collaborators
857+
755858
## Submitting changes
756859

757860
### Before submitting

app/app.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ if (import.meta.client) {
127127

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

130-
<div id="main-content" class="flex-1 flex flex-col">
130+
<div id="main-content" class="flex-1 flex flex-col" tabindex="-1">
131131
<NuxtPage />
132132
</div>
133133

app/components/CollapsibleSection.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ onPrehydrate(() => {
2626
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
2727
const collapsed: string[] = settings?.sidebar?.collapsed || []
2828
for (const id of collapsed) {
29-
if (!document.documentElement.dataset.collapsed?.includes(id)) {
29+
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
3030
document.documentElement.dataset.collapsed = (
3131
document.documentElement.dataset.collapsed +
3232
' ' +
@@ -38,7 +38,9 @@ onPrehydrate(() => {
3838
3939
onMounted(() => {
4040
if (document?.documentElement) {
41-
isOpen.value = !(document.documentElement.dataset.collapsed?.includes(props.id) ?? false)
41+
isOpen.value = !(
42+
document.documentElement.dataset.collapsed?.split(' ').includes(props.id) ?? false
43+
)
4244
}
4345
})
4446

app/components/Compare/PackageSelector.vue

Lines changed: 110 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ const maxPackages = computed(() => props.max ?? 4)
1414
const inputValue = shallowRef('')
1515
const isInputFocused = shallowRef(false)
1616
17+
// Keyboard navigation state
18+
const highlightedIndex = shallowRef(-1)
19+
const listRef = useTemplateRef('listRef')
20+
const PAGE_JUMP = 5
21+
1722
// Use the shared search composable (supports both npm and Algolia providers)
18-
const { data: searchData, status } = useSearch(inputValue, { size: 15 })
23+
const { searchProvider } = useSearchProvider()
24+
const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 })
1925
2026
const isSearching = computed(() => status.value === 'pending')
2127
@@ -53,6 +59,20 @@ const filteredResults = computed(() => {
5359
.filter(r => !packages.value.includes(r.name))
5460
})
5561
62+
// Unified list of navigable items for keyboard navigation
63+
const navigableItems = computed(() => {
64+
const items: { type: 'no-dependency' | 'package'; name: string }[] = []
65+
if (showNoDependencyOption.value) {
66+
items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID })
67+
}
68+
for (const r of filteredResults.value) {
69+
items.push({ type: 'package', name: r.name })
70+
}
71+
return items
72+
})
73+
74+
const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0))
75+
5676
const numberFormatter = useNumberFormatter()
5777
5878
function addPackage(name: string) {
@@ -70,30 +90,93 @@ function addPackage(name: string) {
7090
packages.value = [...packages.value, name]
7191
}
7292
inputValue.value = ''
93+
highlightedIndex.value = -1
7394
}
7495
7596
function removePackage(name: string) {
7697
packages.value = packages.value.filter(p => p !== name)
7798
}
7899
79100
function handleKeydown(e: KeyboardEvent) {
80-
const inputValueTrim = inputValue.value.trim()
81-
const hasMatchInPackages = filteredResults.value.find(result => {
82-
return result.name === inputValueTrim
83-
})
84-
85-
if (e.key === 'Enter' && inputValueTrim) {
86-
e.preventDefault()
87-
if (showNoDependencyOption.value) {
88-
addPackage(NO_DEPENDENCY_ID)
89-
} else if (hasMatchInPackages) {
90-
addPackage(inputValueTrim)
101+
const items = navigableItems.value
102+
const count = items.length
103+
104+
switch (e.key) {
105+
case 'ArrowDown':
106+
e.preventDefault()
107+
if (count === 0) return
108+
highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1)
109+
break
110+
111+
case 'ArrowUp':
112+
e.preventDefault()
113+
if (count === 0) return
114+
if (highlightedIndex.value > 0) {
115+
highlightedIndex.value--
116+
}
117+
break
118+
119+
case 'PageDown':
120+
e.preventDefault()
121+
if (count === 0) return
122+
if (highlightedIndex.value === -1) {
123+
highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1)
124+
} else {
125+
highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1)
126+
}
127+
break
128+
129+
case 'PageUp':
130+
e.preventDefault()
131+
if (count === 0) return
132+
highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0)
133+
break
134+
135+
case 'Enter': {
136+
const inputValueTrim = inputValue.value.trim()
137+
if (!inputValueTrim) return
138+
139+
e.preventDefault()
140+
141+
// If an item is highlighted, select it
142+
if (highlightedIndex.value >= 0 && highlightedIndex.value < count) {
143+
addPackage(items[highlightedIndex.value]!.name)
144+
return
145+
}
146+
147+
// Fallback: exact match or easter egg (preserves existing behavior)
148+
if (showNoDependencyOption.value) {
149+
addPackage(NO_DEPENDENCY_ID)
150+
} else {
151+
const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim)
152+
if (hasMatch) {
153+
addPackage(inputValueTrim)
154+
}
155+
}
156+
break
91157
}
92-
} else if (e.key === 'Escape') {
93-
inputValue.value = ''
158+
159+
case 'Escape':
160+
inputValue.value = ''
161+
highlightedIndex.value = -1
162+
break
94163
}
95164
}
96165
166+
// Reset highlight when user types
167+
watch(inputValue, () => {
168+
highlightedIndex.value = -1
169+
})
170+
171+
// Scroll highlighted item into view
172+
watch(highlightedIndex, index => {
173+
if (index >= 0 && listRef.value) {
174+
const items = listRef.value.querySelectorAll('[data-navigable]')
175+
const item = items[index] as HTMLElement | undefined
176+
item?.scrollIntoView({ block: 'nearest' })
177+
}
178+
})
179+
97180
const { start, stop } = useTimeoutFn(() => {
98181
isInputFocused.value = false
99182
}, 200)
@@ -175,16 +258,18 @@ function handleFocus() {
175258
leave-to-class="opacity-0"
176259
>
177260
<div
178-
v-if="
179-
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
180-
"
261+
v-if="isInputFocused && (navigableItems.length > 0 || isSearching)"
262+
ref="listRef"
181263
class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto"
182264
>
183265
<!-- No dependency option (easter egg with James) -->
184266
<ButtonBase
185267
v-if="showNoDependencyOption"
268+
data-navigable
186269
class="block w-full text-start"
270+
:class="highlightedIndex === 0 ? '!bg-accent/15' : ''"
187271
:aria-label="$t('compare.no_dependency.add_column')"
272+
@mouseenter="highlightedIndex = 0"
188273
@click="addPackage(NO_DEPENDENCY_ID)"
189274
>
190275
<span class="text-sm text-accent italic flex items-center gap-2">
@@ -196,13 +281,19 @@ function handleFocus() {
196281
</span>
197282
</ButtonBase>
198283

199-
<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
284+
<div
285+
v-if="isSearching && navigableItems.length === 0"
286+
class="px-4 py-3 text-sm text-fg-muted"
287+
>
200288
{{ $t('compare.selector.searching') }}
201289
</div>
202290
<ButtonBase
203-
v-for="result in filteredResults"
291+
v-for="(result, index) in filteredResults"
204292
:key="result.name"
293+
data-navigable
205294
class="block w-full text-start"
295+
:class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''"
296+
@mouseenter="highlightedIndex = index + resultIndexOffset"
206297
@click="addPackage(result.name)"
207298
>
208299
<span class="font-mono text-sm text-fg block">{{ result.name }}</span>

0 commit comments

Comments
 (0)