uikit-react-public 0.43.4 → 0.44.0
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/dist/components/Header/Header.d.ts +2 -1
- package/dist/components/Header/Header.stories.d.ts +5 -1
- package/dist/components/HeaderNew/Header.d.ts +2 -1
- package/dist/components/NavigationBar/NavigationBar.d.ts +12 -0
- package/dist/components/NavigationBar/NavigationBar.stories.d.ts +14 -0
- package/dist/components/NavigationBar/NavigationBarItem.d.ts +19 -0
- package/dist/components/NavigationBar/__tests__/NavigationBar.test.d.ts +1 -0
- package/dist/components/NavigationBar/index.d.ts +2 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +4509 -4310
- package/lib/components/Header/Header.mdx +8 -1
- package/lib/components/Header/Header.stories.tsx +13 -0
- package/lib/components/Header/Header.tsx +3 -0
- package/lib/components/Header/__tests__/Header.test.tsx +22 -0
- package/lib/components/HeaderNew/Header.tsx +7 -1
- package/lib/components/HeaderNew/__tests__/Header.test.tsx +41 -0
- package/lib/components/NavigationBar/NavigationBar.stories.tsx +99 -0
- package/lib/components/NavigationBar/NavigationBar.tsx +76 -0
- package/lib/components/NavigationBar/NavigationBarItem.tsx +236 -0
- package/lib/components/NavigationBar/__tests__/NavigationBar.test.tsx +90 -0
- package/lib/components/NavigationBar/index.ts +5 -0
- package/lib/components/index.ts +6 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { Meta, Title, Subtitle, Canvas, Controls } from "@storybook/addon-docs/b
|
|
|
3
3
|
|
|
4
4
|
export const usage = {
|
|
5
5
|
default: `<Header title='App Name' />`,
|
|
6
|
+
noBorder: `<Header title='App Name' showBorder={false} />`,
|
|
6
7
|
homeLink: `<Header title='App Name' homeLinkHref='/' homeLinkAriaLabel='Go to home' />`,
|
|
7
8
|
menu: `<Header title='App Name' homeLinkHref='/'>
|
|
8
9
|
<Header.Menu>
|
|
@@ -26,6 +27,12 @@ to add a menu button on the right.
|
|
|
26
27
|
|
|
27
28
|
<Canvas source={{ code: usage.default }} />
|
|
28
29
|
|
|
30
|
+
## Border
|
|
31
|
+
|
|
32
|
+
Set `showBorder={false}` when another component, such as `NavigationBar`, sits directly below the header and should provide the dividing border.
|
|
33
|
+
|
|
34
|
+
<Canvas of={HeaderStories.WithoutBorder} source={{ code: usage.noBorder }} />
|
|
35
|
+
|
|
29
36
|
## Home link
|
|
30
37
|
|
|
31
38
|
Add `homeLinkHref` (and optionally `homeLinkAriaLabel`) to make the logo/title act as a home link.
|
|
@@ -49,4 +56,4 @@ Use `fixed` to pin the header to the top of the viewport.
|
|
|
49
56
|
Full props specification for `<Header>` is below.
|
|
50
57
|
<Canvas source={{ code: usage.default }} />
|
|
51
58
|
You can use the controls below to manipulate the `<Header>` component above.
|
|
52
|
-
<Controls />
|
|
59
|
+
<Controls />
|
|
@@ -13,6 +13,7 @@ const meta = {
|
|
|
13
13
|
},
|
|
14
14
|
argTypes: {
|
|
15
15
|
title: { control: 'text' },
|
|
16
|
+
showBorder: { control: 'boolean' },
|
|
16
17
|
fixed: { control: 'boolean' },
|
|
17
18
|
homeLinkHref: { control: 'text' },
|
|
18
19
|
homeLinkAriaLabel: { control: 'text' },
|
|
@@ -77,6 +78,18 @@ export const WithHomeLink: Story = {
|
|
|
77
78
|
},
|
|
78
79
|
};
|
|
79
80
|
|
|
81
|
+
export const WithoutBorder: Story = {
|
|
82
|
+
name: 'Without border',
|
|
83
|
+
render: (args) => (
|
|
84
|
+
<div style={{ overflow: 'hidden' }}>
|
|
85
|
+
<Header {...args} />
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
args: {
|
|
89
|
+
showBorder: false,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
80
93
|
export const WithMenu: Story = {
|
|
81
94
|
render: (args) => (
|
|
82
95
|
<div style={storyWrapperStyle}>
|
|
@@ -13,6 +13,7 @@ export const DEFAULT_Z_INDEX = 3;
|
|
|
13
13
|
|
|
14
14
|
export interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
|
15
15
|
title?: string;
|
|
16
|
+
showBorder?: boolean;
|
|
16
17
|
/** @deprecated Only used by the default-theme legacy Header. */
|
|
17
18
|
fixed?: boolean;
|
|
18
19
|
titleAs?: React.ElementType<React.HTMLAttributes<HTMLElement>>;
|
|
@@ -164,6 +165,7 @@ const LegacyHeader = ({
|
|
|
164
165
|
|
|
165
166
|
const Header = ({
|
|
166
167
|
title,
|
|
168
|
+
showBorder = true,
|
|
167
169
|
titleAs = 'div',
|
|
168
170
|
titleClassName,
|
|
169
171
|
titleProps,
|
|
@@ -206,6 +208,7 @@ const Header = ({
|
|
|
206
208
|
return (
|
|
207
209
|
<HeaderNew
|
|
208
210
|
title={title ?? ''}
|
|
211
|
+
showBorder={showBorder}
|
|
209
212
|
titleAs={titleAs}
|
|
210
213
|
titleClassName={titleClassName}
|
|
211
214
|
titleProps={titleProps}
|
|
@@ -89,4 +89,26 @@ describe('Header', () => {
|
|
|
89
89
|
expect(link).toBeInTheDocument();
|
|
90
90
|
expect(link.getAttribute('href')).toBe('/');
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test('passes showBorder to the new header implementation', () => {
|
|
94
|
+
render(
|
|
95
|
+
<ThemeContextProvider theme='light'>
|
|
96
|
+
<Header
|
|
97
|
+
title='LIDS'
|
|
98
|
+
showBorder={false}
|
|
99
|
+
/>
|
|
100
|
+
</ThemeContextProvider>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const header = screen.getByTestId('ucl-uikit-header');
|
|
104
|
+
const generatedClass = [...header.classList].find((className) =>
|
|
105
|
+
className.startsWith('css-')
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(generatedClass).toBeDefined();
|
|
109
|
+
expect(header).not.toHaveAttribute('showBorder');
|
|
110
|
+
expect(document.head.textContent).toContain(
|
|
111
|
+
`.${generatedClass}{height:78px;padding:0 48px 0 64px;}`
|
|
112
|
+
);
|
|
113
|
+
});
|
|
92
114
|
});
|
|
@@ -12,6 +12,7 @@ export const NAME = 'ucl-uikit-header';
|
|
|
12
12
|
|
|
13
13
|
export interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
|
14
14
|
title: string;
|
|
15
|
+
showBorder?: boolean;
|
|
15
16
|
titleAs?: React.ElementType<React.HTMLAttributes<HTMLElement>>;
|
|
16
17
|
titleClassName?: string;
|
|
17
18
|
titleProps?: Record<string, unknown>;
|
|
@@ -22,6 +23,7 @@ export interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
|
|
22
23
|
|
|
23
24
|
const Header = ({
|
|
24
25
|
title,
|
|
26
|
+
showBorder = true,
|
|
25
27
|
titleAs = 'div',
|
|
26
28
|
titleClassName,
|
|
27
29
|
titleProps,
|
|
@@ -51,7 +53,11 @@ const Header = ({
|
|
|
51
53
|
@media (min-width: ${theme.breakpoints.desktop}px) {
|
|
52
54
|
height: ${HEADER_TABLET_HEIGHT_PX}px;
|
|
53
55
|
padding: 0 ${theme.padding.p48} 0 ${theme.padding.p64};
|
|
54
|
-
|
|
56
|
+
${
|
|
57
|
+
showBorder
|
|
58
|
+
? `border-bottom: 1px solid ${theme.colour.border.subtle};`
|
|
59
|
+
: ''
|
|
60
|
+
}
|
|
55
61
|
}
|
|
56
62
|
`;
|
|
57
63
|
|
|
@@ -40,6 +40,47 @@ describe('Header', () => {
|
|
|
40
40
|
expect(link.getAttribute('href')).toBe('/');
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test('shows the bottom border by default and can hide it', () => {
|
|
44
|
+
const { rerender } = render(
|
|
45
|
+
<ThemeContextProvider>
|
|
46
|
+
<Header title='LIDS' />
|
|
47
|
+
</ThemeContextProvider>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const header = screen.getByTestId('ucl-uikit-header');
|
|
51
|
+
const defaultClass = [...header.classList].find((className) =>
|
|
52
|
+
className.startsWith('css-')
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(defaultClass).toBeDefined();
|
|
56
|
+
expect(document.head.textContent).toContain(
|
|
57
|
+
`.${defaultClass}{position:relative`
|
|
58
|
+
);
|
|
59
|
+
expect(document.head.textContent).toContain(
|
|
60
|
+
`.${defaultClass}{height:78px;padding:0 48px 0 64px;border-bottom:1px solid`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
rerender(
|
|
64
|
+
<ThemeContextProvider>
|
|
65
|
+
<Header
|
|
66
|
+
title='LIDS'
|
|
67
|
+
showBorder={false}
|
|
68
|
+
/>
|
|
69
|
+
</ThemeContextProvider>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const borderlessHeader = screen.getByTestId('ucl-uikit-header');
|
|
73
|
+
const borderlessClass = [...borderlessHeader.classList].find((className) =>
|
|
74
|
+
className.startsWith('css-')
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(borderlessClass).toBeDefined();
|
|
78
|
+
expect(borderlessHeader).not.toHaveAttribute('showBorder');
|
|
79
|
+
expect(document.head.textContent).toContain(
|
|
80
|
+
`.${borderlessClass}{height:78px;padding:0 48px 0 64px;}`
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
43
84
|
test('spaces subsequent header items on smaller screens and resets spacing on desktop', () => {
|
|
44
85
|
render(
|
|
45
86
|
<ThemeContextProvider>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { css } from '@emotion/css';
|
|
3
|
+
import NavigationBar from './NavigationBar';
|
|
4
|
+
import Icon from '../Icon';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/NavigationBar',
|
|
8
|
+
component: NavigationBar,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'fullscreen',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
} satisfies Meta<typeof NavigationBar>;
|
|
14
|
+
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
const pageStyle = css`
|
|
19
|
+
min-height: 160px;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<div className={pageStyle}>
|
|
25
|
+
<NavigationBar>
|
|
26
|
+
<NavigationBar.Item href='/courses'>Courses</NavigationBar.Item>
|
|
27
|
+
<NavigationBar.Item
|
|
28
|
+
href='/research'
|
|
29
|
+
active
|
|
30
|
+
>
|
|
31
|
+
Research
|
|
32
|
+
</NavigationBar.Item>
|
|
33
|
+
<NavigationBar.Item
|
|
34
|
+
href='/open-days'
|
|
35
|
+
icon={<Icon.ChevronDown />}
|
|
36
|
+
>
|
|
37
|
+
Open days
|
|
38
|
+
</NavigationBar.Item>
|
|
39
|
+
<NavigationBar.Item href='/events'>Events</NavigationBar.Item>
|
|
40
|
+
<NavigationBar.Item
|
|
41
|
+
href='/news'
|
|
42
|
+
disabled
|
|
43
|
+
>
|
|
44
|
+
News
|
|
45
|
+
</NavigationBar.Item>
|
|
46
|
+
</NavigationBar>
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Wrapped: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div
|
|
54
|
+
className={css`
|
|
55
|
+
width: 720px;
|
|
56
|
+
`}
|
|
57
|
+
>
|
|
58
|
+
<NavigationBar>
|
|
59
|
+
<NavigationBar.Item
|
|
60
|
+
href='/courses'
|
|
61
|
+
active
|
|
62
|
+
>
|
|
63
|
+
Courses
|
|
64
|
+
</NavigationBar.Item>
|
|
65
|
+
<NavigationBar.Item href='/research'>Research</NavigationBar.Item>
|
|
66
|
+
<NavigationBar.Item href='/open-days'>Open days</NavigationBar.Item>
|
|
67
|
+
<NavigationBar.Item href='/events'>Events</NavigationBar.Item>
|
|
68
|
+
<NavigationBar.Item href='/news'>News</NavigationBar.Item>
|
|
69
|
+
<NavigationBar.Item href='/blogs'>IOE blogs</NavigationBar.Item>
|
|
70
|
+
<NavigationBar.Item href='/work-with-us'>
|
|
71
|
+
Work with us
|
|
72
|
+
</NavigationBar.Item>
|
|
73
|
+
<NavigationBar.Item href='/departments-and-centres'>
|
|
74
|
+
Departments and Centres
|
|
75
|
+
</NavigationBar.Item>
|
|
76
|
+
<NavigationBar.Item href='/about'>About IOE</NavigationBar.Item>
|
|
77
|
+
</NavigationBar>
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const WithRouterLinks: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<NavigationBar>
|
|
85
|
+
<NavigationBar.Item
|
|
86
|
+
active
|
|
87
|
+
asChild
|
|
88
|
+
>
|
|
89
|
+
<a href='/overview'>Overview</a>
|
|
90
|
+
</NavigationBar.Item>
|
|
91
|
+
<NavigationBar.Item asChild>
|
|
92
|
+
<a href='/modules'>Modules</a>
|
|
93
|
+
</NavigationBar.Item>
|
|
94
|
+
<NavigationBar.Item asChild>
|
|
95
|
+
<a href='/people'>People</a>
|
|
96
|
+
</NavigationBar.Item>
|
|
97
|
+
</NavigationBar>
|
|
98
|
+
),
|
|
99
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
HTMLAttributes,
|
|
4
|
+
NamedExoticComponent,
|
|
5
|
+
isValidElement,
|
|
6
|
+
memo,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { css, cx } from '@emotion/css';
|
|
9
|
+
import { useTheme } from '../../theme';
|
|
10
|
+
import NavigationBarItem, { NavigationBarItemProps } from './NavigationBarItem';
|
|
11
|
+
|
|
12
|
+
export const NAME = 'ucl-uikit-navigation-bar';
|
|
13
|
+
|
|
14
|
+
export interface NavigationBarProps extends HTMLAttributes<HTMLElement> {
|
|
15
|
+
testId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const NavigationBarBase = ({
|
|
19
|
+
testId = NAME,
|
|
20
|
+
className,
|
|
21
|
+
children,
|
|
22
|
+
'aria-label': ariaLabel = 'Primary navigation',
|
|
23
|
+
...props
|
|
24
|
+
}: NavigationBarProps) => {
|
|
25
|
+
const [theme] = useTheme();
|
|
26
|
+
const items = Children.toArray(children).filter(isValidElement);
|
|
27
|
+
|
|
28
|
+
const baseStyle = css`
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
display: flex;
|
|
31
|
+
width: 100%;
|
|
32
|
+
padding: 0 ${theme.padding.p16};
|
|
33
|
+
align-items: flex-end;
|
|
34
|
+
background-color: ${theme.colour.surface.primary};
|
|
35
|
+
border-bottom: ${theme.border.b1} solid ${theme.colour.border.subtle};
|
|
36
|
+
|
|
37
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
38
|
+
padding: 0 ${theme.padding.p48};
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const listStyle = css`
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-wrap: wrap;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 2px 0;
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: 0;
|
|
49
|
+
list-style: none;
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const style = cx(NAME, baseStyle, className);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<nav
|
|
56
|
+
className={style}
|
|
57
|
+
data-testid={testId}
|
|
58
|
+
aria-label={ariaLabel}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
<ul className={listStyle}>{items}</ul>
|
|
62
|
+
</nav>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface NavigationBarComponent extends NamedExoticComponent<NavigationBarProps> {
|
|
67
|
+
Item: typeof NavigationBarItem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NavigationBar = Object.assign(memo(NavigationBarBase), {
|
|
71
|
+
Item: NavigationBarItem,
|
|
72
|
+
}) as NavigationBarComponent;
|
|
73
|
+
|
|
74
|
+
export type { NavigationBarItemProps };
|
|
75
|
+
|
|
76
|
+
export default NavigationBar;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
ElementType,
|
|
5
|
+
ReactElement,
|
|
6
|
+
ReactNode,
|
|
7
|
+
cloneElement,
|
|
8
|
+
forwardRef,
|
|
9
|
+
isValidElement,
|
|
10
|
+
memo,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { css, cx } from '@emotion/css';
|
|
13
|
+
import { useTheme } from '../../theme';
|
|
14
|
+
import Text from '../Text';
|
|
15
|
+
import BaseLink, { BaseLinkProps } from '../Link/BaseLink';
|
|
16
|
+
import type { IconProps } from '../Icon';
|
|
17
|
+
|
|
18
|
+
export const NAME = 'ucl-uikit-navigation-bar__item';
|
|
19
|
+
|
|
20
|
+
type NavigationBarItemOwnProps = {
|
|
21
|
+
active?: boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
icon?: ReactElement<IconProps>;
|
|
24
|
+
iconPosition?: 'left' | 'right';
|
|
25
|
+
testId?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NavigationBarItemProps<C extends ElementType = 'a'> =
|
|
31
|
+
NavigationBarItemOwnProps &
|
|
32
|
+
BaseLinkProps<C> &
|
|
33
|
+
Omit<ComponentPropsWithoutRef<C>, keyof NavigationBarItemOwnProps>;
|
|
34
|
+
|
|
35
|
+
type NavigationBarItemComponent = <C extends ElementType = 'a'>(
|
|
36
|
+
props: NavigationBarItemProps<C> & {
|
|
37
|
+
ref?: React.ComponentPropsWithRef<C>['ref'];
|
|
38
|
+
}
|
|
39
|
+
) => React.JSX.Element;
|
|
40
|
+
|
|
41
|
+
const NavigationBarItem = forwardRef(function NavigationBarItemInner(
|
|
42
|
+
{
|
|
43
|
+
active = false,
|
|
44
|
+
disabled = false,
|
|
45
|
+
icon,
|
|
46
|
+
iconPosition = 'right',
|
|
47
|
+
testId = NAME,
|
|
48
|
+
className,
|
|
49
|
+
children,
|
|
50
|
+
asChild = false,
|
|
51
|
+
...props
|
|
52
|
+
}: NavigationBarItemProps<ElementType>,
|
|
53
|
+
ref: React.Ref<Element>
|
|
54
|
+
) {
|
|
55
|
+
const [theme] = useTheme();
|
|
56
|
+
|
|
57
|
+
const listItemStyle = css`
|
|
58
|
+
display: inline-flex;
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const baseStyle = css`
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
display: inline-flex;
|
|
64
|
+
min-height: 40px;
|
|
65
|
+
padding: ${theme.padding.p8} ${theme.padding.p16} 0;
|
|
66
|
+
align-items: stretch;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
color: ${theme.colour.link.primary};
|
|
69
|
+
background-color: transparent;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
outline: none;
|
|
73
|
+
|
|
74
|
+
&:visited {
|
|
75
|
+
color: ${theme.colour.link.primary};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&:hover {
|
|
79
|
+
color: ${theme.colour.link.primary};
|
|
80
|
+
background-color: ${theme.colour.fill.brandSubtleHover};
|
|
81
|
+
text-decoration: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
&:focus-visible {
|
|
85
|
+
box-shadow: ${theme.boxShadow.focus};
|
|
86
|
+
z-index: 1;
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const activeStyle = css`
|
|
91
|
+
color: ${theme.colour.link.primary};
|
|
92
|
+
|
|
93
|
+
&:visited,
|
|
94
|
+
&:hover {
|
|
95
|
+
color: ${theme.colour.link.primary};
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const disabledStyle = css`
|
|
100
|
+
color: ${theme.colour.link.disabled};
|
|
101
|
+
background-color: transparent;
|
|
102
|
+
cursor: not-allowed;
|
|
103
|
+
|
|
104
|
+
&:visited,
|
|
105
|
+
&:hover {
|
|
106
|
+
color: ${theme.colour.link.disabled};
|
|
107
|
+
background-color: transparent;
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
const contentStyle = css`
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
align-items: stretch;
|
|
115
|
+
justify-content: flex-start;
|
|
116
|
+
gap: 2px;
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const labelRowStyle = css`
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
min-height: 24px;
|
|
122
|
+
align-items: center;
|
|
123
|
+
justify-content: center;
|
|
124
|
+
gap: ${theme.padding.p4};
|
|
125
|
+
color: inherit;
|
|
126
|
+
white-space: nowrap;
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
const textStackStyle = css`
|
|
130
|
+
display: inline-grid;
|
|
131
|
+
align-items: center;
|
|
132
|
+
justify-items: start;
|
|
133
|
+
color: inherit;
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
const textStyle = css`
|
|
137
|
+
grid-area: 1 / 1;
|
|
138
|
+
color: inherit;
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const hiddenTextStyle = css`
|
|
143
|
+
height: 0;
|
|
144
|
+
overflow: hidden;
|
|
145
|
+
visibility: hidden;
|
|
146
|
+
pointer-events: none;
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const indicatorStyle = css`
|
|
150
|
+
display: block;
|
|
151
|
+
height: 6px;
|
|
152
|
+
background-color: ${active ? theme.colour.fill.brand : 'transparent'};
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
const iconStyle = css`
|
|
156
|
+
display: block;
|
|
157
|
+
flex-shrink: 0;
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
const style = cx(
|
|
161
|
+
NAME,
|
|
162
|
+
baseStyle,
|
|
163
|
+
active && activeStyle,
|
|
164
|
+
disabled && disabledStyle,
|
|
165
|
+
className
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const renderContent = (content: ReactNode) => (
|
|
169
|
+
<span className={contentStyle}>
|
|
170
|
+
<span className={labelRowStyle}>
|
|
171
|
+
{icon &&
|
|
172
|
+
iconPosition === 'left' &&
|
|
173
|
+
cloneElement(icon, {
|
|
174
|
+
size: icon.props.size ?? 20,
|
|
175
|
+
className: cx(iconStyle, icon.props.className),
|
|
176
|
+
'aria-hidden': icon.props['aria-hidden'] ?? true,
|
|
177
|
+
})}
|
|
178
|
+
<span className={textStackStyle}>
|
|
179
|
+
<Text
|
|
180
|
+
level={active ? 'md-semibold' : 'md'}
|
|
181
|
+
className={textStyle}
|
|
182
|
+
>
|
|
183
|
+
{content}
|
|
184
|
+
</Text>
|
|
185
|
+
<Text
|
|
186
|
+
aria-hidden
|
|
187
|
+
level='md-semibold'
|
|
188
|
+
className={cx(textStyle, hiddenTextStyle)}
|
|
189
|
+
>
|
|
190
|
+
{content}
|
|
191
|
+
</Text>
|
|
192
|
+
</span>
|
|
193
|
+
{icon &&
|
|
194
|
+
iconPosition === 'right' &&
|
|
195
|
+
cloneElement(icon, {
|
|
196
|
+
size: icon.props.size ?? 20,
|
|
197
|
+
className: cx(iconStyle, icon.props.className),
|
|
198
|
+
'aria-hidden': icon.props['aria-hidden'] ?? true,
|
|
199
|
+
})}
|
|
200
|
+
</span>
|
|
201
|
+
<span
|
|
202
|
+
className={indicatorStyle}
|
|
203
|
+
aria-hidden='true'
|
|
204
|
+
/>
|
|
205
|
+
</span>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const content = asChild
|
|
209
|
+
? (() => {
|
|
210
|
+
const child = Children.only(children);
|
|
211
|
+
|
|
212
|
+
return isValidElement<{ children?: ReactNode }>(child)
|
|
213
|
+
? cloneElement(child, {}, renderContent(child.props.children))
|
|
214
|
+
: child;
|
|
215
|
+
})()
|
|
216
|
+
: renderContent(children);
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<li className={listItemStyle}>
|
|
220
|
+
<BaseLink
|
|
221
|
+
ref={ref}
|
|
222
|
+
className={style}
|
|
223
|
+
testId={testId}
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
asChild={asChild}
|
|
226
|
+
aria-current={active ? 'page' : undefined}
|
|
227
|
+
data-state={active ? 'active' : 'inactive'}
|
|
228
|
+
{...props}
|
|
229
|
+
>
|
|
230
|
+
{content}
|
|
231
|
+
</BaseLink>
|
|
232
|
+
</li>
|
|
233
|
+
);
|
|
234
|
+
}) as unknown as NavigationBarItemComponent;
|
|
235
|
+
|
|
236
|
+
export default memo(NavigationBarItem) as NavigationBarItemComponent;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import NavigationBar from '../NavigationBar';
|
|
4
|
+
import Icon from '../../Icon';
|
|
5
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
6
|
+
|
|
7
|
+
const wrap = (ui: React.ReactElement) =>
|
|
8
|
+
render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
|
|
9
|
+
|
|
10
|
+
describe('NavigationBar', () => {
|
|
11
|
+
test('renders a navigation landmark with link items', () => {
|
|
12
|
+
wrap(
|
|
13
|
+
<NavigationBar aria-label='Section navigation'>
|
|
14
|
+
<NavigationBar.Item href='/courses'>Courses</NavigationBar.Item>
|
|
15
|
+
<NavigationBar.Item href='/research'>Research</NavigationBar.Item>
|
|
16
|
+
</NavigationBar>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(
|
|
20
|
+
screen.getByRole('navigation', { name: 'Section navigation' })
|
|
21
|
+
).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByRole('link', { name: 'Courses' })).toHaveAttribute(
|
|
23
|
+
'href',
|
|
24
|
+
'/courses'
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('marks the active item and reserves bold label width', () => {
|
|
29
|
+
wrap(
|
|
30
|
+
<NavigationBar>
|
|
31
|
+
<NavigationBar.Item
|
|
32
|
+
href='/courses'
|
|
33
|
+
active
|
|
34
|
+
>
|
|
35
|
+
Courses
|
|
36
|
+
</NavigationBar.Item>
|
|
37
|
+
</NavigationBar>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const item = screen.getByRole('link', { name: 'Courses' });
|
|
41
|
+
const generatedClass = [...item.classList].find((className) =>
|
|
42
|
+
className.startsWith('css-')
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(item).toHaveAttribute('aria-current', 'page');
|
|
46
|
+
expect(item).toHaveAttribute('data-state', 'active');
|
|
47
|
+
expect(screen.getAllByText('Courses')).toHaveLength(2);
|
|
48
|
+
expect(generatedClass).toBeDefined();
|
|
49
|
+
expect(document.head.textContent).toContain('visibility:hidden');
|
|
50
|
+
expect(document.head.textContent).toContain('white-space:nowrap');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('supports disabled items', () => {
|
|
54
|
+
wrap(
|
|
55
|
+
<NavigationBar>
|
|
56
|
+
<NavigationBar.Item
|
|
57
|
+
href='/news'
|
|
58
|
+
disabled
|
|
59
|
+
>
|
|
60
|
+
News
|
|
61
|
+
</NavigationBar.Item>
|
|
62
|
+
</NavigationBar>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const item = screen.getAllByText('News')[0].closest('a');
|
|
66
|
+
|
|
67
|
+
expect(item).toBeInTheDocument();
|
|
68
|
+
expect(item).toHaveAttribute('aria-disabled', 'true');
|
|
69
|
+
expect(item).toHaveAttribute('tabindex', '-1');
|
|
70
|
+
expect(item).not.toHaveAttribute('href');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('supports icons and asChild links', () => {
|
|
74
|
+
wrap(
|
|
75
|
+
<NavigationBar>
|
|
76
|
+
<NavigationBar.Item
|
|
77
|
+
icon={<Icon.ChevronDown />}
|
|
78
|
+
asChild
|
|
79
|
+
>
|
|
80
|
+
<a href='/open-days'>Open days</a>
|
|
81
|
+
</NavigationBar.Item>
|
|
82
|
+
</NavigationBar>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const item = screen.getByRole('link', { name: 'Open days' });
|
|
86
|
+
|
|
87
|
+
expect(item).toHaveAttribute('href', '/open-days');
|
|
88
|
+
expect(item.querySelector('svg')).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
});
|
package/lib/components/index.ts
CHANGED
|
@@ -148,6 +148,12 @@ export type { HeaderProps } from './Header';
|
|
|
148
148
|
export { default as HeaderNew } from './HeaderNew';
|
|
149
149
|
export type { HeaderProps as HeaderNewProps } from './HeaderNew';
|
|
150
150
|
|
|
151
|
+
export { default as NavigationBar } from './NavigationBar';
|
|
152
|
+
export type {
|
|
153
|
+
NavigationBarItemProps,
|
|
154
|
+
NavigationBarProps,
|
|
155
|
+
} from './NavigationBar';
|
|
156
|
+
|
|
151
157
|
export { default as HeaderDraft } from './HeaderDraft';
|
|
152
158
|
export type { HeaderDraftProps } from './HeaderDraft';
|
|
153
159
|
|