-
Notifications
You must be signed in to change notification settings - Fork 22
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
APP-7268: Add nav dropdown #615
base: main
Are you sure you want to change the base?
Changes from all commits
3978676
5e187bd
f5d5dbe
a3807c4
9e2850f
6de6664
30e96b9
5c57033
0eda097
fac224a
75ea478
75cea77
df1fdd5
8ae0b22
5722c39
eea4482
a9727c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,91 @@ | ||||||||||
import { describe, expect, it } from 'vitest'; | ||||||||||
import { render, screen, within } from '@testing-library/svelte'; | ||||||||||
import userEvent from '@testing-library/user-event'; | ||||||||||
import { NavDropdown as Subject } from '$lib'; | ||||||||||
|
||||||||||
const versionOptions = [ | ||||||||||
{ | ||||||||||
label: 'Version 1', | ||||||||||
detail: '1 day ago', | ||||||||||
description: 'stable', | ||||||||||
href: '/v1', | ||||||||||
}, | ||||||||||
{ | ||||||||||
label: 'Version 2', | ||||||||||
detail: '5 hours ago', | ||||||||||
description: 'latest', | ||||||||||
href: '/v2', | ||||||||||
}, | ||||||||||
]; | ||||||||||
|
||||||||||
describe('NavDropdown', () => { | ||||||||||
it('renders a button that controls a menu', () => { | ||||||||||
render(Subject, { | ||||||||||
props: { options: versionOptions, selectedHref: '/v1' }, | ||||||||||
}); | ||||||||||
|
||||||||||
const button = screen.getByRole('button'); | ||||||||||
|
||||||||||
expect(button).toHaveAttribute('aria-haspopup', 'menu'); | ||||||||||
expect(button).toHaveAttribute('aria-expanded', 'false'); | ||||||||||
}); | ||||||||||
|
||||||||||
it('expands the menu on click', async () => { | ||||||||||
const user = userEvent.setup(); | ||||||||||
render(Subject, { | ||||||||||
props: { options: versionOptions, selectedHref: '/v1' }, | ||||||||||
}); | ||||||||||
|
||||||||||
const button = screen.getByRole('button'); | ||||||||||
await user.click(button); | ||||||||||
|
||||||||||
const menu = screen.getByRole('menu'); | ||||||||||
|
||||||||||
expect(menu).toBeInTheDocument(); | ||||||||||
expect(button).toHaveAttribute('aria-expanded', 'true'); | ||||||||||
}); | ||||||||||
|
||||||||||
it('displays option details', async () => { | ||||||||||
const user = userEvent.setup(); | ||||||||||
render(Subject, { | ||||||||||
props: { options: versionOptions, selectedHref: '/v1' }, | ||||||||||
}); | ||||||||||
|
||||||||||
const button = screen.getByRole('button'); | ||||||||||
await user.click(button); | ||||||||||
|
||||||||||
const menuitem = screen.getByRole('menuitem', { name: /Version 1/u }); | ||||||||||
expect(within(menuitem).getByText(/1 day ago/u)).toBeInTheDocument(); | ||||||||||
expect(within(menuitem).getByText('stable')).toBeInTheDocument(); | ||||||||||
Comment on lines
+58
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question (non-blocking): Does
Suggested change
|
||||||||||
}); | ||||||||||
|
||||||||||
it('opens menu with Space when button is focused', async () => { | ||||||||||
const user = userEvent.setup(); | ||||||||||
render(Subject, { | ||||||||||
props: { options: versionOptions, selectedHref: '/v1' }, | ||||||||||
}); | ||||||||||
|
||||||||||
const button = screen.getByRole('button'); | ||||||||||
button.focus(); | ||||||||||
await user.keyboard(' '); | ||||||||||
|
||||||||||
expect(screen.getByRole('menu')).toBeInTheDocument(); | ||||||||||
expect(button).toHaveAttribute('aria-expanded', 'true'); | ||||||||||
}); | ||||||||||
|
||||||||||
it('closes menu with Escape', async () => { | ||||||||||
const user = userEvent.setup(); | ||||||||||
render(Subject, { | ||||||||||
props: { options: versionOptions, selectedHref: '/v1' }, | ||||||||||
}); | ||||||||||
|
||||||||||
const button = screen.getByRole('button'); | ||||||||||
await user.click(button); | ||||||||||
expect(screen.getByRole('menu')).toBeInTheDocument(); | ||||||||||
|
||||||||||
await user.keyboard('{Escape}'); | ||||||||||
|
||||||||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument(); | ||||||||||
expect(button).toHaveAttribute('aria-expanded', 'false'); | ||||||||||
}); | ||||||||||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
<script lang="ts"> | ||
import { clickOutside } from '$lib/click-outside'; | ||
import { Icon } from '$lib/icon'; | ||
import { createHandleKey } from '$lib/keyboard'; | ||
import { Floating, matchWidth } from '$lib/floating'; | ||
|
||
interface NavOption { | ||
label: string; | ||
detail?: string; | ||
description?: string; | ||
href: string; | ||
} | ||
|
||
export let options: NavOption[] = []; | ||
export let selectedHref: string; | ||
|
||
let isOpen = false; | ||
let activeIndex = -1; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (keyboard): when There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated, makes sense, ty! |
||
let buttonElement: HTMLButtonElement | undefined; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (keyboard): we want to make sure we move focus between the links when arrow keys are pressed. I think we can do that by:
let linkElements = []
$: {
if (activeIndex >= 0) {
linkElements[activeIndex].focus();
}
} {#each options as { label, detail, description, href }, i}
<a
bind:this={linkElements[i]}
... |
||
const toggleDropdown = () => { | ||
isOpen = !isOpen; | ||
activeIndex = isOpen ? 0 : -1; | ||
}; | ||
|
||
const closeDropdown = () => { | ||
isOpen = false; | ||
}; | ||
|
||
const handleMenuItemKeydown = createHandleKey({ | ||
Escape: () => { | ||
closeDropdown(); | ||
buttonElement?.focus(); | ||
activeIndex = -1; | ||
}, | ||
}); | ||
|
||
const handleClickOutside = (element: Element) => { | ||
if (!buttonElement?.contains(element)) { | ||
closeDropdown(); | ||
} | ||
}; | ||
|
||
$: if (isOpen) { | ||
buttonElement?.focus(); | ||
} | ||
</script> | ||
|
||
<div class="group flex w-48"> | ||
<button | ||
bind:this={buttonElement} | ||
class="relative z-[2] h-7.5 w-full grow appearance-none border border-light bg-white py-1.5 pl-2 pr-1 text-xs leading-tight outline-none group-hover:border-gray-6" | ||
on:click={toggleDropdown} | ||
type="button" | ||
aria-haspopup="menu" | ||
aria-expanded={isOpen} | ||
> | ||
<div class="flex items-center justify-between"> | ||
<span class="block truncate text-xs"> | ||
{options.find((opt) => opt.href === selectedHref)?.label ?? | ||
'Latest Version'} | ||
</span> | ||
<Icon | ||
name="chevron-down" | ||
cx={['text-gray-6 transition-transform', { 'rotate-180': isOpen }]} | ||
/> | ||
</div> | ||
</button> | ||
|
||
{#if isOpen} | ||
<Floating | ||
referenceElement={buttonElement} | ||
placement="bottom-start" | ||
offset={4} | ||
size={matchWidth} | ||
auto | ||
> | ||
<div | ||
class="w-full overflow-auto border border-gray-6 bg-white shadow-sm focus:outline-none" | ||
role="menu" | ||
use:clickOutside={handleClickOutside} | ||
> | ||
{#each options as { label, detail, description, href }, i} | ||
<a | ||
{href} | ||
class="relative flex flex-col px-2 py-1.5 hover:bg-gray-1 focus:bg-gray-1 focus:outline-none" | ||
class:bg-gray-1={i === activeIndex} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (keyboard): Should we move this styling to a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated |
||
role="menuitem" | ||
aria-current={href === selectedHref ? 'page' : 'false'} | ||
on:click={closeDropdown} | ||
on:keydown={handleMenuItemKeydown} | ||
on:focus={() => { | ||
activeIndex = i; | ||
}} | ||
on:blur={() => { | ||
activeIndex = -1; | ||
}} | ||
tabindex="0" | ||
> | ||
<div class="flex items-center text-xs"> | ||
<span class="block truncate font-normal">{label}</span> | ||
{#if detail} | ||
<span class="ml-1 text-gray-6">({detail})</span> | ||
{/if} | ||
</div> | ||
{#if description} | ||
<span class="block truncate text-[0.625rem] text-gray-6" | ||
>{description}</span | ||
> | ||
{/if} | ||
</a> | ||
{/each} | ||
</div> | ||
</Floating> | ||
{/if} | ||
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
todo: make sure that the button is marked as controlling the menu