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: add arrow navigation type toggle #350

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
25 changes: 23 additions & 2 deletions cmdk/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ type CommandProps = Children &
* Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`.
*/
vimBindings?: boolean
/**
* Determines which arrow keys are used for navigation. Default is 'vertical'.
*/
arrowNavigationType?: 'vertical' | 'horizontal' | 'both'
}

type Context = {
Expand Down Expand Up @@ -198,6 +202,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
loop,
disablePointerSelection = false,
vimBindings = true,
arrowNavigationType = 'vertical',
...etc
} = props

Expand Down Expand Up @@ -599,7 +604,15 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
break
}
case 'ArrowDown': {
next(e)
if (['vertical', 'both'].includes(propsRef.current.arrowNavigationType)) {
next(e)
}
break
}
case 'ArrowRight': {
if (['horizontal', 'both'].includes(propsRef.current.arrowNavigationType)) {
next(e)
}
break
}
case 'p':
Expand All @@ -611,7 +624,15 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
break
}
case 'ArrowUp': {
prev(e)
if (['vertical', 'both'].includes(propsRef.current.arrowNavigationType)) {
prev(e)
}
break
}
case 'ArrowLeft': {
if (['horizontal', 'both'].includes(propsRef.current.arrowNavigationType)) {
prev(e)
}
break
}
case 'Home': {
Expand Down
118 changes: 118 additions & 0 deletions test/arrow-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test'

test.describe('arrowNavigationType - vertical (default)', async () => {
test.beforeEach(async ({ page }) => {
await page.goto('/arrow-navigation?type=vertical')
})

test('vertical arrows change selected item', async ({ page }) => {
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Down arrow should work
await page.locator(`[cmdk-input]`).press('ArrowDown')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Up arrow should work
await page.locator(`[cmdk-input]`).press('ArrowUp')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Right arrow should NOT work
await page.locator(`[cmdk-input]`).press('ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Left arrow should NOT work
await page.locator(`[cmdk-input]`).press('ArrowLeft')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')
})
})

test.describe('arrowNavigationType - horizontal', async () => {
test.beforeEach(async ({ page }) => {
await page.goto('/arrow-navigation?type=horizontal')
})

test('horizontal arrows change selected item', async ({ page }) => {
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Down arrow should NOT work
await page.locator(`[cmdk-input]`).press('ArrowDown')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Up arrow should NOT work
await page.locator(`[cmdk-input]`).press('ArrowUp')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Right arrow should work
await page.locator(`[cmdk-input]`).press('ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Left arrow should work
await page.locator(`[cmdk-input]`).press('ArrowLeft')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')
})

test('horizontal navigation with modifier keys', async ({ page }) => {
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Meta+Right should go to last item
await page.locator(`[cmdk-input]`).press('Meta+ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'last')

// Meta+Left should go to first item
await page.locator(`[cmdk-input]`).press('Meta+ArrowLeft')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Alt+Right should navigate to next group
await page.locator(`[cmdk-input]`).press('Alt+ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

await page.locator(`[cmdk-input]`).press('Alt+ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item 1')
})
})

test.describe('arrowNavigationType - both', async () => {
test.beforeEach(async ({ page }) => {
await page.goto('/arrow-navigation?type=both')
})

test('all arrow keys change selected item', async ({ page }) => {
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Down arrow should work
await page.locator(`[cmdk-input]`).press('ArrowDown')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Up arrow should work
await page.locator(`[cmdk-input]`).press('ArrowUp')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Right arrow should work
await page.locator(`[cmdk-input]`).press('ArrowRight')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Left arrow should work
await page.locator(`[cmdk-input]`).press('ArrowLeft')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')
})

test('vim keybinds still work with both navigation types', async ({ page }) => {
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Test Ctrl+j (vim down)
await page.locator(`[cmdk-input]`).press('Control+j')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Test Ctrl+k (vim up)
await page.locator(`[cmdk-input]`).press('Control+k')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')

// Test Ctrl+n (vim down)
await page.locator(`[cmdk-input]`).press('Control+n')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'Item A')

// Test Ctrl+p (vim up)
await page.locator(`[cmdk-input]`).press('Control+p')
await expect(page.locator(`[cmdk-item][aria-selected="true"]`)).toHaveAttribute('data-value', 'first')
})
})
39 changes: 39 additions & 0 deletions test/pages/arrow-navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Command } from 'cmdk'
import { useRouter } from 'next/router'
import * as React from 'react'

const Page = () => {
const {
query: { type },
} = useRouter()

const arrowNavigationType = type as 'vertical' | 'horizontal' | 'both'

return (
<Command arrowNavigationType={arrowNavigationType}>
<Command.Input />
<Command.List>
<Command.Empty>No results.</Command.Empty>

<Command.Item value="first">First Item</Command.Item>

<Command.Group heading="Group 1">
<Command.Item>Item A</Command.Item>
<Command.Item>Item B</Command.Item>
<Command.Item>Item C</Command.Item>
</Command.Group>

<Command.Group heading="Group 2">
<Command.Item>Item 1</Command.Item>
<Command.Item>Item 2</Command.Item>
<Command.Item disabled>Disabled Item</Command.Item>
<Command.Item>Item 3</Command.Item>
</Command.Group>

<Command.Item value="last">Last Item</Command.Item>
</Command.List>
</Command>
)
}

export default Page