Skip to content

Commit 1f582fd

Browse files
anjinaimcous
authored andcommitted
APP-7268 - create nav dropdown in prime
1 parent 2e1d9e5 commit 1f582fd

File tree

6 files changed

+289
-12
lines changed

6 files changed

+289
-12
lines changed

packages/core/src/lib/__tests__/code-snippet.spec.ts

-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ describe('CodeSnippet', () => {
6767

6868
const initialCode = screen.getByText(common.code);
6969
expect(initialCode).toBeInTheDocument();
70-
7170
const newCode = '{ their: "json" }';
7271
await rerender({
7372
...common,

packages/core/src/lib/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export { preventHandler, preventKeyboardHandler } from './prevent-handler';
5555

5656
export * from './select';
5757

58+
export { default as NavDropdown } from './nav-dropdown/nav-dropdown.svelte';
5859
export { default as Progress } from './progress/progress.svelte';
5960
export { default as Switch } from './switch.svelte';
6061
export { default as Radio } from './radio.svelte';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render, screen, within } from '@testing-library/svelte';
3+
import userEvent from '@testing-library/user-event';
4+
import { NavDropdown as Subject } from '$lib';
5+
6+
const versionOptions = [
7+
{
8+
label: 'Version 1',
9+
detail: '1 day ago',
10+
description: 'stable',
11+
href: '/v1',
12+
},
13+
{
14+
label: 'Version 2',
15+
detail: '5 hours ago',
16+
description: 'latest',
17+
href: '/v2',
18+
},
19+
];
20+
21+
describe('NavDropdown', () => {
22+
it('renders a button that controls a menu', () => {
23+
render(Subject, {
24+
props: { options: versionOptions, selectedHref: '/v1' },
25+
});
26+
27+
const button = screen.getByRole('button');
28+
29+
expect(button).toHaveAttribute('aria-haspopup', 'menu');
30+
expect(button).toHaveAttribute('aria-expanded', 'false');
31+
});
32+
33+
it('expands the menu on click', async () => {
34+
const user = userEvent.setup();
35+
render(Subject, {
36+
props: { options: versionOptions, selectedHref: '/v1' },
37+
});
38+
39+
const button = screen.getByRole('button');
40+
await user.click(button);
41+
42+
const menu = screen.getByRole('menu');
43+
44+
expect(menu).toBeInTheDocument();
45+
expect(button).toHaveAttribute('aria-expanded', 'true');
46+
});
47+
48+
it('displays option details', async () => {
49+
const user = userEvent.setup();
50+
render(Subject, {
51+
props: { options: versionOptions, selectedHref: '/v1' },
52+
});
53+
54+
const button = screen.getByRole('button');
55+
await user.click(button);
56+
57+
const menuitem = screen.getByRole('menuitem', { name: /Version 1/u });
58+
expect(within(menuitem).getByText(/1 day ago/u)).toBeInTheDocument();
59+
expect(within(menuitem).getByText('stable')).toBeInTheDocument();
60+
});
61+
62+
it('opens menu with Space when button is focused', async () => {
63+
const user = userEvent.setup();
64+
render(Subject, {
65+
props: { options: versionOptions, selectedHref: '/v1' },
66+
});
67+
68+
const button = screen.getByRole('button');
69+
button.focus();
70+
await user.keyboard(' ');
71+
72+
expect(screen.getByRole('menu')).toBeInTheDocument();
73+
expect(button).toHaveAttribute('aria-expanded', 'true');
74+
});
75+
76+
it('closes menu with Escape', async () => {
77+
const user = userEvent.setup();
78+
render(Subject, {
79+
props: { options: versionOptions, selectedHref: '/v1' },
80+
});
81+
82+
const button = screen.getByRole('button');
83+
await user.click(button);
84+
expect(screen.getByRole('menu')).toBeInTheDocument();
85+
86+
await user.keyboard('{Escape}');
87+
88+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
89+
expect(button).toHaveAttribute('aria-expanded', 'false');
90+
});
91+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<script lang="ts">
2+
import { clickOutside } from '$lib/click-outside';
3+
import { Icon } from '$lib/icon';
4+
import { createHandleKey } from '$lib/keyboard';
5+
import { Floating, matchWidth } from '$lib/floating';
6+
7+
interface NavOption {
8+
label: string;
9+
detail?: string;
10+
description?: string;
11+
href: string;
12+
}
13+
14+
export let options: NavOption[] = [];
15+
export let selectedHref: string;
16+
17+
let isOpen = false;
18+
let activeIndex = -1;
19+
let buttonElement: HTMLButtonElement | undefined;
20+
21+
const toggleDropdown = () => {
22+
isOpen = !isOpen;
23+
activeIndex = isOpen ? 0 : -1;
24+
};
25+
26+
const closeDropdown = () => {
27+
isOpen = false;
28+
};
29+
30+
const handleMenuItemKeydown = createHandleKey({
31+
Escape: () => {
32+
closeDropdown();
33+
buttonElement?.focus();
34+
activeIndex = -1;
35+
},
36+
});
37+
38+
const handleClickOutside = (element: Element) => {
39+
if (!buttonElement?.contains(element)) {
40+
closeDropdown();
41+
}
42+
};
43+
44+
$: if (isOpen) {
45+
buttonElement?.focus();
46+
}
47+
</script>
48+
49+
<div class="group flex w-48">
50+
<button
51+
bind:this={buttonElement}
52+
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"
53+
on:click={toggleDropdown}
54+
type="button"
55+
aria-haspopup="menu"
56+
aria-expanded={isOpen}
57+
>
58+
<div class="flex items-center justify-between">
59+
<span class="block truncate text-xs">
60+
{options.find((opt) => opt.href === selectedHref)?.label ??
61+
'Latest Version'}
62+
</span>
63+
<Icon
64+
name="chevron-down"
65+
cx={['text-gray-6 transition-transform', { 'rotate-180': isOpen }]}
66+
/>
67+
</div>
68+
</button>
69+
70+
{#if isOpen}
71+
<Floating
72+
referenceElement={buttonElement}
73+
placement="bottom-start"
74+
offset={4}
75+
size={matchWidth}
76+
auto
77+
>
78+
<div
79+
class="w-full overflow-auto border border-gray-6 bg-white shadow-sm focus:outline-none"
80+
role="menu"
81+
use:clickOutside={handleClickOutside}
82+
>
83+
{#each options as { label, detail, description, href }, i}
84+
<a
85+
{href}
86+
class="relative flex flex-col px-2 py-1.5 hover:bg-gray-1 focus:bg-gray-1 focus:outline-none"
87+
class:bg-gray-1={i === activeIndex}
88+
role="menuitem"
89+
aria-current={href === selectedHref ? 'page' : 'false'}
90+
on:click={closeDropdown}
91+
on:keydown={handleMenuItemKeydown}
92+
on:focus={() => {
93+
activeIndex = i;
94+
}}
95+
on:blur={() => {
96+
activeIndex = -1;
97+
}}
98+
tabindex="0"
99+
>
100+
<div class="flex items-center text-xs">
101+
<span class="block truncate font-normal">{label}</span>
102+
{#if detail}
103+
<span class="ml-1 text-gray-6">({detail})</span>
104+
{/if}
105+
</div>
106+
{#if description}
107+
<span class="block truncate text-[0.625rem] text-gray-6"
108+
>{description}</span
109+
>
110+
{/if}
111+
</a>
112+
{/each}
113+
</div>
114+
</Floating>
115+
{/if}
116+
</div>

packages/core/src/routes/+page.svelte

+39
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
CodeSnippet,
4242
RangeInput,
4343
Progress,
44+
NavDropdown,
4445
} from '$lib';
4546
import { uniqueId } from 'lodash-es';
4647
@@ -1107,6 +1108,44 @@ const onHoverDelayMsInput = (event: Event) => {
11071108
</div>
11081109
</div>
11091110

1111+
<!-- NAV Dropdown -->
1112+
<h1 class="text-2xl">NAV Dropdown</h1>
1113+
<div class="flex gap-4">
1114+
<NavDropdown
1115+
selectedHref="/v1"
1116+
options={[
1117+
{
1118+
label: 'v1',
1119+
detail: '1 day ago',
1120+
description: 'stable',
1121+
href: '/v1',
1122+
},
1123+
{
1124+
label: 'v2',
1125+
detail: '5 hours ago',
1126+
description: 'latest',
1127+
href: '/v2',
1128+
},
1129+
{
1130+
label: 'v3',
1131+
detail: '2 weeks ago',
1132+
href: '/v3',
1133+
},
1134+
{
1135+
label: 'v4',
1136+
detail: '1 month ago',
1137+
href: '/v4',
1138+
},
1139+
{
1140+
label: 'v5',
1141+
detail: '2 months ago',
1142+
description: 'legacy',
1143+
href: '/v5',
1144+
},
1145+
]}
1146+
/>
1147+
</div>
1148+
11101149
<!-- Notify -->
11111150
<h1 class="text-2xl">Notify</h1>
11121151

0 commit comments

Comments
 (0)