@tpzdsp/next-toolkit 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +594 -0
- package/package.json +133 -0
- package/src/assets/fonts/gds-bold-w-v2.woff +0 -0
- package/src/assets/fonts/gds-bold-w2-v2.woff2 +0 -0
- package/src/assets/fonts/gds-light-w-v2.woff +0 -0
- package/src/assets/fonts/gds-light-w2-v2.woff2 +0 -0
- package/src/assets/images/defra-logo.svg +51 -0
- package/src/assets/images/ea-logo.svg +58 -0
- package/src/assets/images/ogl.svg +1 -0
- package/src/assets/styles/globals.css +68 -0
- package/src/assets/styles/index.ts +7 -0
- package/src/components/Button/Button.stories.tsx +41 -0
- package/src/components/Button/Button.test.tsx +55 -0
- package/src/components/Button/Button.tsx +44 -0
- package/src/components/Card/Card.stories.tsx +35 -0
- package/src/components/Card/Card.test.tsx +12 -0
- package/src/components/Card/Card.tsx +19 -0
- package/src/components/ErrorText/ErrorText.stories.tsx +34 -0
- package/src/components/ErrorText/ErrorText.test.tsx +35 -0
- package/src/components/ErrorText/ErrorText.tsx +17 -0
- package/src/components/Heading/Heading.stories.tsx +21 -0
- package/src/components/Heading/Heading.test.tsx +24 -0
- package/src/components/Heading/Heading.tsx +21 -0
- package/src/components/Hint/Hint.stories.tsx +40 -0
- package/src/components/Hint/Hint.test.tsx +35 -0
- package/src/components/Hint/Hint.tsx +13 -0
- package/src/components/Paragraph/Paragraph.stories.tsx +30 -0
- package/src/components/Paragraph/Paragraph.test.tsx +12 -0
- package/src/components/Paragraph/Paragraph.tsx +7 -0
- package/src/components/container/Container.tsx +38 -0
- package/src/components/dropdown/DropdownMenu.test.tsx +213 -0
- package/src/components/dropdown/DropdownMenu.tsx +106 -0
- package/src/components/dropdown/useDropdownMenu.ts +245 -0
- package/src/components/images/DefraLogo.tsx +64 -0
- package/src/components/images/EaLogo.tsx +81 -0
- package/src/components/images/OglLogo.tsx +18 -0
- package/src/components/index.ts +36 -0
- package/src/components/layout/footer/Copyright.tsx +14 -0
- package/src/components/layout/footer/Footer.tsx +26 -0
- package/src/components/layout/footer/Licence.tsx +19 -0
- package/src/components/layout/footer/MetaLinks.tsx +36 -0
- package/src/components/layout/header/Header.tsx +89 -0
- package/src/components/layout/header/HeaderAuthClient.tsx +32 -0
- package/src/components/layout/header/HeaderNavClient.tsx +64 -0
- package/src/components/link/ExternalLink.tsx +29 -0
- package/src/components/link/Link.tsx +26 -0
- package/src/components/link/NextLinkWrapper.tsx +66 -0
- package/src/components/theme/ThemeProvider.tsx +30 -0
- package/src/contexts/ThemeContext.tsx +72 -0
- package/src/contexts/index.ts +5 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useDebounce.ts +18 -0
- package/src/hooks/useLocalStorage.ts +57 -0
- package/src/index.ts +8 -0
- package/src/types.ts +99 -0
- package/src/utils/auth.ts +19 -0
- package/src/utils/constants.ts +3 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/renderers.tsx +68 -0
- package/src/utils/utils.ts +63 -0
- package/src/vite-env.d.ts +17 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
+
import type { Meta, StoryFn } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { ErrorText, type ErrorTextProps } from './ErrorText';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
children: 'Error text',
|
|
8
|
+
component: ErrorText,
|
|
9
|
+
} as Meta;
|
|
10
|
+
|
|
11
|
+
const Template: StoryFn<ErrorTextProps> = (args) => <ErrorText {...args} />;
|
|
12
|
+
|
|
13
|
+
export const DefaultError = Template.bind({});
|
|
14
|
+
DefaultError.args = {
|
|
15
|
+
children: 'Error message',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const CustomStyling = Template.bind({});
|
|
19
|
+
CustomStyling.args = {
|
|
20
|
+
className: 'text-3xl',
|
|
21
|
+
children: 'Error message with large text',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const ComplexChildren = Template.bind({});
|
|
25
|
+
ComplexChildren.args = {
|
|
26
|
+
children: (
|
|
27
|
+
<div>
|
|
28
|
+
Error message with link{' '}
|
|
29
|
+
<a className="underline text-link" href="/">
|
|
30
|
+
Link
|
|
31
|
+
</a>
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ErrorText } from './ErrorText';
|
|
4
|
+
import { render, screen } from '../../utils/renderers';
|
|
5
|
+
|
|
6
|
+
describe('ErrorText', () => {
|
|
7
|
+
it('renders with default ErrorText', () => {
|
|
8
|
+
render(<ErrorText>Error message</ErrorText>);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/error message/i);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders with custom styles', () => {
|
|
14
|
+
render(<ErrorText className="bg-orange-500">Error text</ErrorText>);
|
|
15
|
+
|
|
16
|
+
const error = screen.getByRole('alert');
|
|
17
|
+
|
|
18
|
+
expect(error).toHaveClass(/bg-orange-500/i);
|
|
19
|
+
expect(error).not.toHaveClass(/bg-green-500/i);
|
|
20
|
+
expect(error).toHaveTextContent(/error text/i);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders with complex children', () => {
|
|
24
|
+
render(
|
|
25
|
+
<ErrorText>
|
|
26
|
+
<a href="/">Error link</a>
|
|
27
|
+
</ErrorText>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const linkElement = screen.getByRole('link', { name: /error link/i });
|
|
31
|
+
|
|
32
|
+
expect(linkElement).toBeInTheDocument();
|
|
33
|
+
expect(linkElement).toHaveAttribute('href', '/');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
|
|
3
|
+
import type { ExtendProps } from '../../types';
|
|
4
|
+
|
|
5
|
+
export type ErrorTextProps = ExtendProps<'p'>;
|
|
6
|
+
|
|
7
|
+
export const ErrorText = ({ className, children, ...props }: ErrorTextProps) => {
|
|
8
|
+
return (
|
|
9
|
+
<p
|
|
10
|
+
role="alert"
|
|
11
|
+
className={twMerge('mb-3 text-base text-error font-bold', className)}
|
|
12
|
+
{...props}
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { Heading, type HeadingProps } from './Heading';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
children: 'Heading',
|
|
8
|
+
component: Heading,
|
|
9
|
+
} as Meta;
|
|
10
|
+
|
|
11
|
+
export const AllHeadings: StoryObj<typeof Heading> = {
|
|
12
|
+
render: () => (
|
|
13
|
+
<div>
|
|
14
|
+
{(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as HeadingProps['type'][]).map((type) => (
|
|
15
|
+
<Heading key={type} type={type}>
|
|
16
|
+
{type.toUpperCase()} Example
|
|
17
|
+
</Heading>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { Heading, type HeadingProps } from './Heading';
|
|
4
|
+
import { render, screen } from '../../utils/renderers';
|
|
5
|
+
|
|
6
|
+
describe('Heading Component', () => {
|
|
7
|
+
const headingTypes: HeadingProps['type'][] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
8
|
+
|
|
9
|
+
it.each(headingTypes)('renders correct HTML element for type "%s"', (type) => {
|
|
10
|
+
render(<Heading type={type}>Test {type}</Heading>);
|
|
11
|
+
|
|
12
|
+
const heading = screen.getByRole('heading', { name: `Test ${type}` });
|
|
13
|
+
|
|
14
|
+
expect(heading).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each(headingTypes)('applies correct styles for type "%s"', (type) => {
|
|
18
|
+
render(<Heading type={type}>Styled {type}</Heading>);
|
|
19
|
+
|
|
20
|
+
const heading = screen.getByText(`Styled ${type}`);
|
|
21
|
+
|
|
22
|
+
expect(heading).toHaveClass('font-bold text-text-primary');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type HeadingProps = {
|
|
2
|
+
type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
3
|
+
children: React.ReactNode;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const Heading = ({ type, children }: HeadingProps) => {
|
|
7
|
+
switch (type) {
|
|
8
|
+
case 'h1':
|
|
9
|
+
return <h1 className="py-4 text-4xl font-bold text-text-primary">{children}</h1>;
|
|
10
|
+
case 'h2':
|
|
11
|
+
return <h2 className="py-3 text-3xl font-bold text-text-primary">{children}</h2>;
|
|
12
|
+
case 'h3':
|
|
13
|
+
return <h3 className="py-3 text-xl font-bold text-text-primary">{children}</h3>;
|
|
14
|
+
case 'h4':
|
|
15
|
+
return <h4 className="py-3 text-lg font-bold text-text-primary">{children}</h4>;
|
|
16
|
+
case 'h5':
|
|
17
|
+
return <h5 className="py-2 text-base font-bold text-text-primary">{children}</h5>;
|
|
18
|
+
case 'h6':
|
|
19
|
+
return <h6 className="py-2 text-sm font-bold text-text-primary">{children}</h6>;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
+
import type { Meta, StoryFn } from '@storybook/react';
|
|
3
|
+
import { fn } from '@storybook/test';
|
|
4
|
+
|
|
5
|
+
import { Hint, type HintProps } from './Hint';
|
|
6
|
+
import { Button } from '../Button/Button';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
children: 'Hint',
|
|
10
|
+
component: Hint,
|
|
11
|
+
} as Meta;
|
|
12
|
+
|
|
13
|
+
const Template: StoryFn<HintProps> = (args) => <Hint {...args} />;
|
|
14
|
+
|
|
15
|
+
export const DefaultHint = Template.bind({});
|
|
16
|
+
DefaultHint.args = {
|
|
17
|
+
children: 'Hint message',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const CustomStyling = Template.bind({});
|
|
21
|
+
CustomStyling.args = {
|
|
22
|
+
className: 'text-3xl',
|
|
23
|
+
children: 'Error message with extra large text',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ComplexChildren = Template.bind({});
|
|
27
|
+
ComplexChildren.args = {
|
|
28
|
+
children: (
|
|
29
|
+
<div>
|
|
30
|
+
Hint message with link and button{' '}
|
|
31
|
+
<a className="underline text-link" href="/">
|
|
32
|
+
Link
|
|
33
|
+
</a>
|
|
34
|
+
{/* // eslint-disable-next-line custom/padding-between-jsx-elements */}
|
|
35
|
+
<Button variant="primary" onClick={fn()}>
|
|
36
|
+
Click
|
|
37
|
+
</Button>
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { Hint } from './Hint';
|
|
4
|
+
import { render, screen } from '../../utils/renderers';
|
|
5
|
+
|
|
6
|
+
describe('Hint', () => {
|
|
7
|
+
it('renders with default Hint message', () => {
|
|
8
|
+
render(<Hint>Hint text</Hint>);
|
|
9
|
+
expect(screen.getByText(/hint text/i)).toHaveTextContent(/hint text/i);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders with custom styles', () => {
|
|
13
|
+
render(<Hint className="bg-orange-500">hint message</Hint>);
|
|
14
|
+
|
|
15
|
+
const hintElement = screen.getByText(/hint message/i);
|
|
16
|
+
|
|
17
|
+
expect(hintElement).toHaveClass(/bg-orange-500/i);
|
|
18
|
+
expect(hintElement).not.toHaveClass(/bg-green-500/i);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders with complex children', () => {
|
|
22
|
+
render(
|
|
23
|
+
<Hint>
|
|
24
|
+
Some description text <a href="/">Link</a>
|
|
25
|
+
</Hint>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const linkElement = screen.getByRole('link', { name: /link/i });
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText(/some description text/i)).toBeInTheDocument();
|
|
31
|
+
|
|
32
|
+
expect(linkElement).toBeInTheDocument();
|
|
33
|
+
expect(linkElement).toHaveAttribute('href', '/');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
|
|
3
|
+
import type { ExtendProps } from '../../types';
|
|
4
|
+
|
|
5
|
+
export type HintProps = ExtendProps<'div'>;
|
|
6
|
+
|
|
7
|
+
export const Hint = ({ className, children, ...props }: HintProps) => {
|
|
8
|
+
return (
|
|
9
|
+
<div className={twMerge('mb-2 text-lg text-text-secondary', className)} {...props}>
|
|
10
|
+
{children}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* eslint-disable storybook/no-renderer-packages */
|
|
2
|
+
import type { Meta, StoryFn } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { Paragraph, type ParagraphProps } from './Paragraph';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
children: 'Paragraph',
|
|
8
|
+
component: Paragraph,
|
|
9
|
+
} as Meta;
|
|
10
|
+
|
|
11
|
+
const Template: StoryFn<ParagraphProps> = (args) => <Paragraph {...args} />;
|
|
12
|
+
|
|
13
|
+
export const JustText = Template.bind({});
|
|
14
|
+
JustText.args = {
|
|
15
|
+
children: 'Hello, this is some simple text',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const ImageAndText = Template.bind({});
|
|
19
|
+
ImageAndText.args = {
|
|
20
|
+
children: (
|
|
21
|
+
<div>
|
|
22
|
+
<img
|
|
23
|
+
src="https://images.unsplash.com/photo-1563991655280-cb95c90ca2fb?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8M3x8bm8lMjBjb3B5cmlnaHR8ZW58MHx8MHx8fDA%3D"
|
|
24
|
+
alt="Card"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<p>This is text about the image</p>
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { Paragraph } from './Paragraph';
|
|
4
|
+
import { render, screen } from '../../utils/renderers';
|
|
5
|
+
|
|
6
|
+
describe('Paragraph', () => {
|
|
7
|
+
it('renders with text', () => {
|
|
8
|
+
render(<Paragraph>Some test text</Paragraph>);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByRole('paragraph')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils';
|
|
4
|
+
|
|
5
|
+
export type ContainerProps = {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
9
|
+
centerContent?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const containerSizes = {
|
|
13
|
+
sm: 'max-w-3xl',
|
|
14
|
+
md: 'max-w-5xl',
|
|
15
|
+
lg: 'max-w-7xl',
|
|
16
|
+
xl: 'max-w-screen-2xl',
|
|
17
|
+
full: 'max-w-none',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const Container = ({
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
size = 'lg',
|
|
24
|
+
centerContent = false,
|
|
25
|
+
}: ContainerProps) => {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
'mx-auto px-4 sm:px-6 lg:px-8',
|
|
30
|
+
containerSizes[size],
|
|
31
|
+
centerContent && 'flex items-center justify-center min-h-screen',
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { DropdownMenu, type DropdownMenuItem, type DrowndownMenuButton } from './DropdownMenu';
|
|
4
|
+
import { render, screen, userEvent, waitFor } from '../../utils/renderers';
|
|
5
|
+
|
|
6
|
+
type Item = { label: string };
|
|
7
|
+
|
|
8
|
+
const ITEMS: Item[] = [{ label: 'A' }, { label: 'B' }, { label: 'C' }, { label: 'D' }];
|
|
9
|
+
|
|
10
|
+
const CustomButton = ({ ...props }: DrowndownMenuButton) => {
|
|
11
|
+
return <button {...props}>Some Custom Text</button>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const CustomItem = ({ label, ...props }: DropdownMenuItem<Item>) => {
|
|
15
|
+
return (
|
|
16
|
+
<a {...props} role="link">
|
|
17
|
+
{label}
|
|
18
|
+
</a>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('DropdownMenu Component', () => {
|
|
23
|
+
it('should render with no menu when unopened', () => {
|
|
24
|
+
render(<DropdownMenu items={ITEMS}></DropdownMenu>);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByRole('button', { name: /menu/i })).toBeInTheDocument();
|
|
27
|
+
expect(() => screen.getByRole('menu')).toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should render with menu when opened', async () => {
|
|
31
|
+
render(<DropdownMenu items={ITEMS}></DropdownMenu>);
|
|
32
|
+
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
|
|
35
|
+
await user.click(screen.getByRole('button', { name: /menu/i }));
|
|
36
|
+
|
|
37
|
+
for (const item of ITEMS) {
|
|
38
|
+
expect(await screen.findByRole('menuitem', { name: item.label })).toBeInTheDocument();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should render with a custom item component', async () => {
|
|
43
|
+
render(<DropdownMenu items={ITEMS} itemRenderer={CustomItem}></DropdownMenu>);
|
|
44
|
+
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
|
|
47
|
+
await user.click(screen.getByRole('button', { name: /menu/i }));
|
|
48
|
+
|
|
49
|
+
for (const item of ITEMS) {
|
|
50
|
+
expect(await screen.findByRole('link', { name: item.label })).toBeInTheDocument();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should render with a custom button component', () => {
|
|
55
|
+
render(<DropdownMenu items={ITEMS} buttonRenderer={CustomButton}></DropdownMenu>);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByRole('button', { name: /some custom text/i })).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should open the menu when button is clicked', async () => {
|
|
61
|
+
render(<DropdownMenu items={ITEMS} />);
|
|
62
|
+
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
|
|
65
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
66
|
+
|
|
67
|
+
await user.click(button);
|
|
68
|
+
|
|
69
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should open the menu when Space or Enter is pressed', async () => {
|
|
73
|
+
render(<DropdownMenu items={ITEMS} />);
|
|
74
|
+
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
|
|
77
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
78
|
+
|
|
79
|
+
button.focus();
|
|
80
|
+
|
|
81
|
+
await user.keyboard(' ');
|
|
82
|
+
|
|
83
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
84
|
+
|
|
85
|
+
// pressing another key will close the menu but assuming the menu wasnt open already this would open it anyway
|
|
86
|
+
await user.keyboard('{Enter}');
|
|
87
|
+
|
|
88
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should close when Escape is pressed', async () => {
|
|
92
|
+
render(<DropdownMenu items={ITEMS} />);
|
|
93
|
+
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
|
|
96
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
97
|
+
|
|
98
|
+
await user.click(button);
|
|
99
|
+
|
|
100
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
101
|
+
|
|
102
|
+
await user.keyboard('{Escape}');
|
|
103
|
+
|
|
104
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should close when clicking outside', async () => {
|
|
108
|
+
render(
|
|
109
|
+
<>
|
|
110
|
+
<DropdownMenu items={ITEMS} />
|
|
111
|
+
|
|
112
|
+
<button>Outside</button>
|
|
113
|
+
</>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
|
|
118
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
119
|
+
|
|
120
|
+
await user.click(button);
|
|
121
|
+
|
|
122
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
123
|
+
|
|
124
|
+
// click away to another element, which should close the menu
|
|
125
|
+
await user.click(screen.getByRole('button', { name: /outside/i }));
|
|
126
|
+
|
|
127
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should close when focus moves outside (blur)', async () => {
|
|
131
|
+
render(
|
|
132
|
+
<>
|
|
133
|
+
<DropdownMenu items={ITEMS} />
|
|
134
|
+
|
|
135
|
+
<button>Outside</button>
|
|
136
|
+
</>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const user = userEvent.setup();
|
|
140
|
+
|
|
141
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
142
|
+
|
|
143
|
+
await user.click(button);
|
|
144
|
+
|
|
145
|
+
expect(await screen.findByRole('menu')).toBeInTheDocument();
|
|
146
|
+
|
|
147
|
+
// move focus away from the menu, which should close it
|
|
148
|
+
await user.tab();
|
|
149
|
+
|
|
150
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should navigate items with ArrowUp/ArrowDown', async () => {
|
|
154
|
+
render(<DropdownMenu items={ITEMS} />);
|
|
155
|
+
|
|
156
|
+
const user = userEvent.setup();
|
|
157
|
+
|
|
158
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
159
|
+
|
|
160
|
+
await user.click(button);
|
|
161
|
+
|
|
162
|
+
const items = screen.getAllByRole('menuitem');
|
|
163
|
+
|
|
164
|
+
// first element is focussed when the menu is open
|
|
165
|
+
expect(items[0]).toHaveFocus();
|
|
166
|
+
|
|
167
|
+
await user.keyboard('{ArrowDown}');
|
|
168
|
+
expect(items[1]).toHaveFocus();
|
|
169
|
+
|
|
170
|
+
await user.keyboard('{ArrowUp}');
|
|
171
|
+
expect(items[0]).toHaveFocus();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should select an item with Enter or Space', async () => {
|
|
175
|
+
render(<DropdownMenu items={ITEMS} />);
|
|
176
|
+
|
|
177
|
+
const user = userEvent.setup();
|
|
178
|
+
|
|
179
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
180
|
+
|
|
181
|
+
await user.click(button);
|
|
182
|
+
const items = screen.getAllByRole('menuitem');
|
|
183
|
+
|
|
184
|
+
await user.keyboard('{ArrowDown}');
|
|
185
|
+
await user.keyboard('{Enter}');
|
|
186
|
+
|
|
187
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
188
|
+
|
|
189
|
+
await user.click(button);
|
|
190
|
+
|
|
191
|
+
expect(items[0]).toHaveFocus();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should move focus away when pressing Tab', async () => {
|
|
195
|
+
render(
|
|
196
|
+
<>
|
|
197
|
+
<DropdownMenu items={ITEMS} />
|
|
198
|
+
|
|
199
|
+
<button>Other Button</button>
|
|
200
|
+
</>,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const user = userEvent.setup();
|
|
204
|
+
|
|
205
|
+
const button = screen.getByRole('button', { name: /menu/i });
|
|
206
|
+
|
|
207
|
+
await user.click(button);
|
|
208
|
+
|
|
209
|
+
await user.tab();
|
|
210
|
+
|
|
211
|
+
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentType } from 'react';
|
|
4
|
+
|
|
5
|
+
import { LuChevronDown } from 'react-icons/lu';
|
|
6
|
+
import { twMerge } from 'tailwind-merge';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ButtonRendererProps,
|
|
10
|
+
type ItemRendererProps,
|
|
11
|
+
useDropdownMenu,
|
|
12
|
+
} from './useDropdownMenu';
|
|
13
|
+
|
|
14
|
+
export type DropdownMenuItem<ItemProps extends object> = ItemRendererProps & {
|
|
15
|
+
label: string;
|
|
16
|
+
} & ItemProps;
|
|
17
|
+
|
|
18
|
+
export type DrowndownMenuButton = ButtonRendererProps;
|
|
19
|
+
|
|
20
|
+
type DropdownMenuProps<ItemProps extends object> = {
|
|
21
|
+
items: DropdownMenuItem<ItemProps>[];
|
|
22
|
+
containerClassName?: string;
|
|
23
|
+
menuContainerClassName?: string;
|
|
24
|
+
buttonRenderer?: ComponentType<ButtonRendererProps>;
|
|
25
|
+
buttonClassName?: string;
|
|
26
|
+
itemRenderer?: ComponentType<DropdownMenuItem<ItemProps>>;
|
|
27
|
+
itemClassName?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DefaultButton = ({ state: { isOpen }, ...props }: ButtonRendererProps) => {
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
{...props}
|
|
34
|
+
aria-label="Open Menu"
|
|
35
|
+
className={twMerge(
|
|
36
|
+
`text-black flex gap-2 items-center justify-center border rounded-md border-gray-300
|
|
37
|
+
bg-white px-2 py-1 text-sm shadow-sm focus:border-brand focus:outline-none focus:ring-1
|
|
38
|
+
focus:ring-brand`,
|
|
39
|
+
props.className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<span className="text-base pointer-events-none">Menu</span>
|
|
43
|
+
|
|
44
|
+
<LuChevronDown className={`pointer-events-none ${isOpen ? 'rotate-180' : ''}`} />
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DefaultItem = <Item extends object>({ label, ...props }: DropdownMenuItem<Item>) => {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
{...props}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
className={twMerge(
|
|
55
|
+
`text-black cursor-pointer hover:bg-slate-200 focus:bg-slate-200 active:bg-slate-300 px-2
|
|
56
|
+
py-1`,
|
|
57
|
+
props.className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const DropdownMenu = <Item extends object>({
|
|
66
|
+
items,
|
|
67
|
+
containerClassName,
|
|
68
|
+
menuContainerClassName,
|
|
69
|
+
buttonRenderer = DefaultButton,
|
|
70
|
+
buttonClassName,
|
|
71
|
+
itemRenderer = DefaultItem,
|
|
72
|
+
itemClassName,
|
|
73
|
+
}: DropdownMenuProps<Item>) => {
|
|
74
|
+
// rebind to avoid linting errors
|
|
75
|
+
const ButtonRenderer = buttonRenderer;
|
|
76
|
+
const ItemRenderer = itemRenderer;
|
|
77
|
+
|
|
78
|
+
const { isOpen, buttonProps, itemProps } = useDropdownMenu(items.length);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={twMerge('relative', containerClassName)}>
|
|
82
|
+
<ButtonRenderer {...buttonProps} className={buttonClassName} />
|
|
83
|
+
|
|
84
|
+
<div
|
|
85
|
+
style={{ display: isOpen ? 'flex' : 'none' }}
|
|
86
|
+
aria-hidden={!isOpen}
|
|
87
|
+
className={twMerge(
|
|
88
|
+
`absolute right-0 mt-1 bg-white border shadow-md rounded-md min-w-full flex-col gap-0
|
|
89
|
+
z-[999] divide-y divide-slate-400`,
|
|
90
|
+
menuContainerClassName,
|
|
91
|
+
)}
|
|
92
|
+
role="menu"
|
|
93
|
+
>
|
|
94
|
+
{items.map((item, i) => (
|
|
95
|
+
<ItemRenderer
|
|
96
|
+
{...item}
|
|
97
|
+
{...itemProps[i]}
|
|
98
|
+
key={i}
|
|
99
|
+
label={item.label}
|
|
100
|
+
className={itemClassName}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
};
|