newspack-components 4.5.0 → 4.6.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/CHANGELOG.md +40 -0
- package/DEVELOPMENT.md +1 -1
- package/dist/cjs/accordion/index.js +43 -26
- package/dist/cjs/accordion/index.test.js +80 -0
- package/dist/cjs/accordion/style.scss +25 -21
- package/dist/cjs/action-card/style.scss +0 -22
- package/dist/cjs/breadcrumbs/index.js +80 -0
- package/dist/cjs/breadcrumbs/index.test.js +109 -0
- package/dist/cjs/breadcrumbs/style.scss +41 -0
- package/dist/cjs/card/core-card.js +8 -4
- package/dist/cjs/card/index.js +1 -1
- package/dist/cjs/card/style-core.scss +14 -0
- package/dist/cjs/card/style.scss +0 -9
- package/dist/cjs/card-feature/index.js +25 -13
- package/dist/cjs/card-settings-group/index.js +3 -0
- package/dist/cjs/confirm-dialog/index.js +34 -8
- package/dist/cjs/confirm-dialog/index.test.js +173 -0
- package/dist/cjs/divider/style.scss +4 -2
- package/dist/cjs/grid/style.scss +5 -0
- package/dist/cjs/handoff/index.js +8 -5
- package/dist/cjs/index.js +34 -1
- package/dist/cjs/integration-icon/index.js +40 -0
- package/dist/cjs/integration-icon/style.scss +42 -0
- package/dist/cjs/integration-icons/index.js +21 -2
- package/dist/cjs/modal/style.scss +4 -0
- package/dist/cjs/page/index.js +121 -0
- package/dist/cjs/page/index.test.js +69 -0
- package/dist/cjs/page/style.scss +86 -0
- package/dist/cjs/route-match.js +59 -0
- package/dist/cjs/route-match.test.js +77 -0
- package/dist/cjs/section-header/index.js +8 -1
- package/dist/cjs/section-header/style.scss +11 -11
- package/dist/cjs/select-control/GroupedSelectControl.js +1 -1
- package/dist/cjs/select-control/style.scss +4 -0
- package/dist/cjs/style.scss +1 -4
- package/dist/cjs/tabbed-navigation/index.js +188 -40
- package/dist/cjs/tabbed-navigation/index.test.js +299 -0
- package/dist/cjs/tabbed-navigation/style.scss +77 -57
- package/dist/cjs/with-wizard/index.js +34 -45
- package/dist/cjs/with-wizard-screen/index.js +82 -41
- package/dist/cjs/with-wizard-screen/index.test.js +50 -0
- package/dist/cjs/with-wizard-screen/style.scss +15 -118
- package/dist/cjs/wizard/breadcrumbs-select.js +70 -0
- package/dist/cjs/wizard/breadcrumbs-select.test.js +200 -0
- package/dist/cjs/wizard/components/WizardError.js +8 -4
- package/dist/cjs/wizard/index.js +162 -141
- package/dist/esm/accordion/index.js +42 -26
- package/dist/esm/accordion/index.test.js +79 -0
- package/dist/esm/accordion/style.scss +25 -21
- package/dist/esm/action-card/style.scss +0 -22
- package/dist/esm/breadcrumbs/index.js +75 -0
- package/dist/esm/breadcrumbs/index.test.js +105 -0
- package/dist/esm/breadcrumbs/style.scss +41 -0
- package/dist/esm/card/core-card.js +8 -4
- package/dist/esm/card/index.js +1 -1
- package/dist/esm/card/style-core.scss +14 -0
- package/dist/esm/card/style.scss +0 -9
- package/dist/esm/card-feature/index.js +25 -13
- package/dist/esm/card-settings-group/index.js +3 -0
- package/dist/esm/confirm-dialog/index.js +34 -8
- package/dist/esm/confirm-dialog/index.test.js +173 -0
- package/dist/esm/divider/style.scss +4 -2
- package/dist/esm/grid/style.scss +5 -0
- package/dist/esm/handoff/index.js +7 -5
- package/dist/esm/index.js +5 -1
- package/dist/esm/integration-icon/index.js +34 -0
- package/dist/esm/integration-icon/style.scss +42 -0
- package/dist/esm/integration-icons/index.js +22 -1
- package/dist/esm/modal/style.scss +4 -0
- package/dist/esm/page/index.js +113 -0
- package/dist/esm/page/index.test.js +65 -0
- package/dist/esm/page/style.scss +86 -0
- package/dist/esm/route-match.js +51 -0
- package/dist/esm/route-match.test.js +74 -0
- package/dist/esm/section-header/index.js +8 -1
- package/dist/esm/section-header/style.scss +11 -11
- package/dist/esm/select-control/GroupedSelectControl.js +1 -1
- package/dist/esm/select-control/style.scss +4 -0
- package/dist/esm/style.scss +1 -4
- package/dist/esm/tabbed-navigation/index.js +192 -38
- package/dist/esm/tabbed-navigation/index.test.js +296 -0
- package/dist/esm/tabbed-navigation/style.scss +77 -57
- package/dist/esm/with-wizard/index.js +33 -45
- package/dist/esm/with-wizard-screen/index.js +78 -41
- package/dist/esm/with-wizard-screen/index.test.js +46 -0
- package/dist/esm/with-wizard-screen/style.scss +15 -118
- package/dist/esm/wizard/breadcrumbs-select.js +63 -0
- package/dist/esm/wizard/breadcrumbs-select.test.js +197 -0
- package/dist/esm/wizard/components/WizardError.js +7 -5
- package/dist/esm/wizard/index.js +165 -144
- package/package.json +10 -2
- package/src/accordion/index.js +27 -15
- package/src/accordion/index.test.js +62 -0
- package/src/accordion/style.scss +25 -21
- package/src/action-card/style.scss +0 -22
- package/src/breadcrumbs/index.js +63 -0
- package/src/breadcrumbs/index.test.js +59 -0
- package/src/breadcrumbs/style.scss +41 -0
- package/src/card/core-card.js +11 -6
- package/src/card/index.js +0 -1
- package/src/card/style-core.scss +14 -0
- package/src/card/style.scss +0 -9
- package/src/card-feature/README.md +3 -3
- package/src/card-feature/index.tsx +37 -21
- package/src/card-settings-group/index.tsx +4 -0
- package/src/card-sortable-list/index.tsx +1 -1
- package/src/confirm-dialog/index.test.js +142 -0
- package/src/confirm-dialog/index.tsx +34 -7
- package/src/divider/style.scss +4 -2
- package/src/grid/style.scss +5 -0
- package/src/handoff/index.js +4 -3
- package/src/index.js +5 -1
- package/src/integration-icon/index.tsx +35 -0
- package/src/integration-icon/style.scss +42 -0
- package/src/integration-icons/index.js +23 -0
- package/src/modal/style.scss +4 -0
- package/src/page/index.js +89 -0
- package/src/page/index.test.js +43 -0
- package/src/page/style.scss +86 -0
- package/src/route-match.js +47 -0
- package/src/route-match.test.js +50 -0
- package/src/section-header/index.js +7 -3
- package/src/section-header/style.scss +11 -11
- package/src/select-control/GroupedSelectControl.js +1 -1
- package/src/select-control/style.scss +4 -0
- package/src/style.scss +1 -4
- package/src/tabbed-navigation/index.js +181 -34
- package/src/tabbed-navigation/index.test.js +228 -0
- package/src/tabbed-navigation/style.scss +77 -57
- package/src/with-wizard/index.js +18 -39
- package/src/with-wizard-screen/index.js +70 -35
- package/src/with-wizard-screen/index.test.js +36 -0
- package/src/with-wizard-screen/style.scss +15 -118
- package/src/wizard/breadcrumbs-select.js +51 -0
- package/src/wizard/breadcrumbs-select.test.js +124 -0
- package/src/wizard/components/WizardError.js +4 -3
- package/src/wizard/index.js +160 -146
- package/tsconfig.json +7 -0
|
@@ -1,60 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { __ } from '@wordpress/i18n';
|
|
5
|
+
import { Tabs } from '@wordpress/ui';
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* External dependencies.
|
|
3
9
|
*/
|
|
4
10
|
import classnames from 'classnames';
|
|
5
|
-
import findIndex from 'lodash/findIndex';
|
|
6
11
|
import Router from '../proxied-imports/router';
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Internal dependencies.
|
|
10
15
|
*/
|
|
16
|
+
import { matchesRoute } from '../route-match';
|
|
11
17
|
import './style.scss';
|
|
12
18
|
|
|
13
|
-
const {
|
|
19
|
+
const { useHistory, useLocation } = Router;
|
|
14
20
|
|
|
15
|
-
const
|
|
21
|
+
const getItemValue = ( item, index ) => item.path || item.href || `item-${ index }`;
|
|
22
|
+
|
|
23
|
+
export const isItemActive = ( item, pathname ) => {
|
|
24
|
+
if ( item.selected ) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if ( null === pathname ) {
|
|
28
|
+
return Boolean( item.href ) && window.location.href === item.href;
|
|
29
|
+
}
|
|
30
|
+
return matchesRoute( item, pathname );
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default layout when `Page` doesn't inject one: bar above content.
|
|
35
|
+
*/
|
|
36
|
+
const defaultRenderShell = ( bar, content ) => (
|
|
37
|
+
<>
|
|
38
|
+
{ bar }
|
|
39
|
+
{ content }
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Router-driven tabs: a real WAI-ARIA tabs widget. The routed page content
|
|
45
|
+
* renders inside the active tab's `Tabs.Panel`, so each tab's `aria-controls`
|
|
46
|
+
* points at the panel that actually contains its content — or, when no visible
|
|
47
|
+
* tab owns the route, as a sibling of the panels (see `unownedContent` below).
|
|
48
|
+
*/
|
|
49
|
+
const RoutedTabbedNavigation = ( { items, className, disableUpcoming, content = null, renderShell = defaultRenderShell, children = null } ) => {
|
|
50
|
+
const history = useHistory();
|
|
51
|
+
const { pathname } = useLocation();
|
|
16
52
|
const displayedItems = items.filter( item => ! item.isHiddenInTabbedNavigation );
|
|
17
|
-
const { location } = useHistory();
|
|
18
|
-
const currentIndex = findIndex( displayedItems, [ 'path', location.pathname ] );
|
|
19
53
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
54
|
+
// Most-specific match wins, so a tab is never outranked by a sibling that
|
|
55
|
+
// happens to prefix-match the same pathname.
|
|
56
|
+
const activeIndex = displayedItems.reduce( ( best, item, index ) => {
|
|
57
|
+
if ( ! isItemActive( item, pathname ) ) {
|
|
58
|
+
return best;
|
|
23
59
|
}
|
|
24
|
-
if (
|
|
25
|
-
return
|
|
26
|
-
if ( path.endsWith( '*' ) ) {
|
|
27
|
-
const basePath = path.slice( 0, -1 );
|
|
28
|
-
return pathname.startsWith( basePath );
|
|
29
|
-
}
|
|
30
|
-
return item.activeTabPaths.includes( pathname );
|
|
31
|
-
} );
|
|
60
|
+
if ( best === -1 || ( item.path?.length || 0 ) > ( displayedItems[ best ].path?.length || 0 ) ) {
|
|
61
|
+
return index;
|
|
32
62
|
}
|
|
33
|
-
return
|
|
34
|
-
}
|
|
63
|
+
return best;
|
|
64
|
+
}, -1 );
|
|
65
|
+
const activeValue = activeIndex > -1 ? getItemValue( displayedItems[ activeIndex ], activeIndex ) : null;
|
|
35
66
|
|
|
36
|
-
|
|
67
|
+
const onTabClick = ( event, item ) => {
|
|
68
|
+
// Modified clicks (new tab/window) keep native anchor behavior. Middle
|
|
69
|
+
// clicks never reach onClick (they dispatch auxclick), so the anchor's
|
|
70
|
+
// href handles those natively too.
|
|
71
|
+
if ( event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Route through the history object so navigation guards registered via
|
|
75
|
+
// history.block() (unsaved-changes prompts) are consulted.
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
if ( item.path !== pathname ) {
|
|
78
|
+
history.push( item.path );
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const bar = (
|
|
37
83
|
<div className={ classnames( 'newspack-tabbed-navigation', className ) }>
|
|
38
|
-
<
|
|
39
|
-
{ displayedItems.map( ( item, index ) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
<Tabs.List activateOnFocus={ false }>
|
|
85
|
+
{ displayedItems.map( ( item, index ) => {
|
|
86
|
+
const isDisabled = disableUpcoming && index > activeIndex;
|
|
87
|
+
const href = item.path ? `#${ item.path }` : item.href;
|
|
88
|
+
return (
|
|
89
|
+
<Tabs.Tab
|
|
90
|
+
key={ getItemValue( item, index ) }
|
|
91
|
+
value={ getItemValue( item, index ) }
|
|
92
|
+
disabled={ isDisabled }
|
|
93
|
+
nativeButton={ false }
|
|
94
|
+
render={
|
|
95
|
+
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid -- content is supplied via the Tab children through @wordpress/ui's render prop, and disabled tabs intentionally drop the href.
|
|
96
|
+
<a
|
|
97
|
+
href={ isDisabled ? undefined : href }
|
|
98
|
+
onClick={ isDisabled || ! item.path ? undefined : event => onTabClick( event, item ) }
|
|
99
|
+
/>
|
|
100
|
+
}
|
|
49
101
|
>
|
|
50
102
|
{ item.label }
|
|
51
|
-
</
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</
|
|
103
|
+
</Tabs.Tab>
|
|
104
|
+
);
|
|
105
|
+
} ) }
|
|
106
|
+
</Tabs.List>
|
|
55
107
|
{ children }
|
|
56
108
|
</div>
|
|
57
109
|
);
|
|
110
|
+
|
|
111
|
+
// Panels must stay 1:1 with the tabs above (@wordpress/ui validates the
|
|
112
|
+
// pairing in development builds); only the active panel holds the content.
|
|
113
|
+
const panels = displayedItems.map( ( item, index ) => {
|
|
114
|
+
const value = getItemValue( item, index );
|
|
115
|
+
return (
|
|
116
|
+
<Tabs.Panel key={ value } value={ value } tabIndex={ -1 } className="newspack-tabbed-navigation__panel">
|
|
117
|
+
{ value === activeValue ? content : null }
|
|
118
|
+
</Tabs.Panel>
|
|
119
|
+
);
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
// A route no visible tab owns — a hidden sub-view (an edit screen), or a path
|
|
123
|
+
// with no matching tab at all — still has to render its content, outside the
|
|
124
|
+
// panels: with no active tab there is no panel entitled to hold it. Without
|
|
125
|
+
// this the page body is blank, and because the router's own fallback (the
|
|
126
|
+
// wizard's trailing `<Redirect>`) lives inside that content, it never mounts
|
|
127
|
+
// to correct the route either.
|
|
128
|
+
//
|
|
129
|
+
// This renders without the panels' `__panel` class, which is styleless today.
|
|
130
|
+
// Keep it that way: styling `__panel` would silently indent/pad owned routes
|
|
131
|
+
// only, and the divergence would be hard to trace back to here.
|
|
132
|
+
const unownedContent = null === activeValue ? content : null;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Tabs.Root value={ activeValue } className="newspack-tabbed-navigation__root">
|
|
136
|
+
{ renderShell(
|
|
137
|
+
bar,
|
|
138
|
+
<>
|
|
139
|
+
{ panels }
|
|
140
|
+
{ unownedContent }
|
|
141
|
+
</>
|
|
142
|
+
) }
|
|
143
|
+
</Tabs.Root>
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Link-driven "tabs": plain navigation for items that trigger full page loads
|
|
149
|
+
* (e.g. the PHP-rendered wizards admin header). These are not tabs in the ARIA
|
|
150
|
+
* sense — there is no in-page panel to control — so they render as a `nav` of
|
|
151
|
+
* links with `aria-current`, styled to match the tabs widget.
|
|
152
|
+
*/
|
|
153
|
+
const LinkTabbedNavigation = ( { items, className, content = null, renderShell = defaultRenderShell, children = null } ) => {
|
|
154
|
+
const displayedItems = items.filter( item => ! item.isHiddenInTabbedNavigation );
|
|
155
|
+
const bar = (
|
|
156
|
+
<nav
|
|
157
|
+
className={ classnames( 'newspack-tabbed-navigation', 'newspack-tabbed-navigation--links', className ) }
|
|
158
|
+
aria-label={ __( 'Secondary navigation', 'newspack-plugin' ) }
|
|
159
|
+
>
|
|
160
|
+
{ displayedItems.map( ( item, index ) => (
|
|
161
|
+
<a
|
|
162
|
+
key={ getItemValue( item, index ) }
|
|
163
|
+
href={ item.href }
|
|
164
|
+
className="newspack-tabbed-navigation__link"
|
|
165
|
+
aria-current={ isItemActive( item, null ) ? 'page' : undefined }
|
|
166
|
+
>
|
|
167
|
+
{ item.label }
|
|
168
|
+
</a>
|
|
169
|
+
) ) }
|
|
170
|
+
{ children }
|
|
171
|
+
</nav>
|
|
172
|
+
);
|
|
173
|
+
return renderShell( bar, content );
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tabbed navigation over `{ label, path? | href?, exact?, activeTabPaths?, selected?, isHiddenInTabbedNavigation? }` items.
|
|
178
|
+
*
|
|
179
|
+
* Items with router `path`s render as a real tabs widget whose active panel
|
|
180
|
+
* contains `content`; href-only items render as plain navigation links.
|
|
181
|
+
*
|
|
182
|
+
* When no visible tab owns the current route — a hidden sub-view, or an unknown
|
|
183
|
+
* URL — `content` renders as a sibling of the panels with no tab selected. That
|
|
184
|
+
* is a safety net, not the preferred shape: for a route that is conceptually a
|
|
185
|
+
* sub-view of a tab, give that tab an `activeTabPaths` entry (e.g.
|
|
186
|
+
* `activeTabPaths: [ '/edit/*' ]`) so it stays selected and its panel keeps
|
|
187
|
+
* owning the content.
|
|
188
|
+
*
|
|
189
|
+
* `renderShell( bar, content )` is a layout injection point used by `Page` to
|
|
190
|
+
* place the bar inside its sticky header region while the content flows below.
|
|
191
|
+
*
|
|
192
|
+
* @param {Object} props
|
|
193
|
+
* @param {Array} props.items Navigation items.
|
|
194
|
+
* @param {boolean} [props.disableUpcoming] Disable tabs after the active one (setup flows).
|
|
195
|
+
* @param {*} [props.content] Page content, rendered inside the active tab's panel — or
|
|
196
|
+
* as a sibling of the panels when no visible tab owns the route.
|
|
197
|
+
* @param {*} [props.renderShell] Layout callback `( bar, content ) => element`.
|
|
198
|
+
* @param {string} [props.className]
|
|
199
|
+
* @param {*} [props.children] Extra elements rendered inside the bar (e.g. error notices).
|
|
200
|
+
* @return {JSX.Element} Tabbed navigation.
|
|
201
|
+
*/
|
|
202
|
+
const TabbedNavigation = props => {
|
|
203
|
+
const hasRoutedItems = props.items.some( item => item.path );
|
|
204
|
+
return hasRoutedItems ? <RoutedTabbedNavigation { ...props } /> : <LinkTabbedNavigation { ...props } />;
|
|
58
205
|
};
|
|
59
206
|
|
|
60
207
|
export default TabbedNavigation;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { MemoryRouter, Redirect, useHistory } from 'react-router-dom';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies.
|
|
9
|
+
*/
|
|
10
|
+
import TabbedNavigation, { isItemActive } from './index';
|
|
11
|
+
|
|
12
|
+
const HistoryGrabber = ( { historyRef } ) => {
|
|
13
|
+
historyRef.current = useHistory();
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ITEMS = [
|
|
18
|
+
{ label: 'Stories', path: '/stories' },
|
|
19
|
+
{ label: 'Budgets', path: '/budgets' },
|
|
20
|
+
{ label: 'Sites', path: '/sites' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const renderTabs = ( { initialEntries = [ '/stories' ], ...props } = {} ) => {
|
|
24
|
+
const historyRef = { current: null };
|
|
25
|
+
render(
|
|
26
|
+
<MemoryRouter initialEntries={ initialEntries }>
|
|
27
|
+
<HistoryGrabber historyRef={ historyRef } />
|
|
28
|
+
<TabbedNavigation items={ ITEMS } content={ <div>Routed content</div> } { ...props } />
|
|
29
|
+
</MemoryRouter>
|
|
30
|
+
);
|
|
31
|
+
return historyRef.current;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const getTab = name => screen.getByRole( 'tab', { name } );
|
|
35
|
+
|
|
36
|
+
describe( 'isItemActive', () => {
|
|
37
|
+
it( 'treats an explicitly selected item as active regardless of pathname', () => {
|
|
38
|
+
expect( isItemActive( { selected: true, path: '/other' }, '/current' ) ).toBe( true );
|
|
39
|
+
expect( isItemActive( { selected: true }, null ) ).toBe( true );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
describe( 'outside a router (pathname is null)', () => {
|
|
43
|
+
afterEach( () => {
|
|
44
|
+
delete window.location;
|
|
45
|
+
window.location = new URL( 'http://localhost/' );
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'is active when the href matches the current URL', () => {
|
|
49
|
+
delete window.location;
|
|
50
|
+
window.location = new URL( 'http://example.com/wp-admin/admin.php?page=ads' );
|
|
51
|
+
expect( isItemActive( { href: 'http://example.com/wp-admin/admin.php?page=ads' }, null ) ).toBe( true );
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
it( 'is inactive when the href does not match', () => {
|
|
55
|
+
delete window.location;
|
|
56
|
+
window.location = new URL( 'http://example.com/wp-admin/admin.php?page=ads' );
|
|
57
|
+
expect( isItemActive( { href: 'http://example.com/wp-admin/admin.php?page=other' }, null ) ).toBe( false );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'is inactive when the item has no href', () => {
|
|
61
|
+
expect( isItemActive( { path: '/ads' }, null ) ).toBe( false );
|
|
62
|
+
} );
|
|
63
|
+
} );
|
|
64
|
+
|
|
65
|
+
describe( 'inside a router (delegates to the shared route matcher)', () => {
|
|
66
|
+
it( 'matches a path as a prefix by default', () => {
|
|
67
|
+
expect( isItemActive( { path: '/stories' }, '/stories' ) ).toBe( true );
|
|
68
|
+
expect( isItemActive( { path: '/stories' }, '/stories/new' ) ).toBe( true );
|
|
69
|
+
expect( isItemActive( { path: '/stories' }, '/budgets' ) ).toBe( false );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'matches exactly when the item opts in via exact', () => {
|
|
73
|
+
expect( isItemActive( { path: '/stories', exact: true }, '/stories/new' ) ).toBe( false );
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'keeps the parent tab active on a hidden subpage via wildcard', () => {
|
|
77
|
+
const item = { path: '/additional-brands', activeTabPaths: [ '/additional-brands/*' ] };
|
|
78
|
+
expect( isItemActive( item, '/additional-brands/new' ) ).toBe( true );
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
describe( 'TabbedNavigation with routed items', () => {
|
|
84
|
+
it( 'renders the routed content inside the active tab panel', () => {
|
|
85
|
+
renderTabs( { initialEntries: [ '/budgets' ] } );
|
|
86
|
+
expect( getTab( 'Budgets' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
87
|
+
|
|
88
|
+
const content = screen.getByText( 'Routed content' );
|
|
89
|
+
const panel = content.closest( '[role="tabpanel"]' );
|
|
90
|
+
expect( panel ).not.toBeNull();
|
|
91
|
+
expect( getTab( 'Budgets' ) ).toHaveAttribute( 'aria-controls', panel.id );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'keeps the tab active and the content in its panel on a nested route', () => {
|
|
95
|
+
renderTabs( { initialEntries: [ '/stories/new' ] } );
|
|
96
|
+
expect( getTab( 'Stories' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
97
|
+
expect( screen.getByText( 'Routed content' ).closest( '[role="tabpanel"]' ) ).not.toBeNull();
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
it( 'prefers the most specific tab when paths nest', () => {
|
|
101
|
+
render(
|
|
102
|
+
<MemoryRouter initialEntries={ [ '/stories/new' ] }>
|
|
103
|
+
<TabbedNavigation
|
|
104
|
+
items={ [
|
|
105
|
+
{ label: 'Stories', path: '/stories' },
|
|
106
|
+
{ label: 'New story', path: '/stories/new' },
|
|
107
|
+
] }
|
|
108
|
+
/>
|
|
109
|
+
</MemoryRouter>
|
|
110
|
+
);
|
|
111
|
+
expect( getTab( 'New story' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
112
|
+
expect( getTab( 'Stories' ) ).toHaveAttribute( 'aria-selected', 'false' );
|
|
113
|
+
} );
|
|
114
|
+
|
|
115
|
+
it( 'navigates through history.push on click', () => {
|
|
116
|
+
const history = renderTabs();
|
|
117
|
+
fireEvent.click( getTab( 'Sites' ) );
|
|
118
|
+
expect( history.location.pathname ).toBe( '/sites' );
|
|
119
|
+
expect( getTab( 'Sites' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
it( 'leaves modified clicks to the browser', () => {
|
|
123
|
+
const history = renderTabs();
|
|
124
|
+
fireEvent.click( getTab( 'Sites' ), { metaKey: true } );
|
|
125
|
+
expect( history.location.pathname ).toBe( '/stories' );
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it( 'consults history.block guards before navigating', () => {
|
|
129
|
+
const history = renderTabs();
|
|
130
|
+
const unblock = history.block( () => false );
|
|
131
|
+
fireEvent.click( getTab( 'Sites' ) );
|
|
132
|
+
expect( history.location.pathname ).toBe( '/stories' );
|
|
133
|
+
expect( getTab( 'Stories' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
134
|
+
unblock();
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'disables tabs after the active one with disableUpcoming', () => {
|
|
138
|
+
renderTabs( { initialEntries: [ '/budgets' ], disableUpcoming: true } );
|
|
139
|
+
expect( getTab( 'Stories' ) ).toHaveAttribute( 'href' );
|
|
140
|
+
expect( getTab( 'Sites' ) ).not.toHaveAttribute( 'href' );
|
|
141
|
+
expect( getTab( 'Sites' ) ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
142
|
+
|
|
143
|
+
fireEvent.click( getTab( 'Sites' ) );
|
|
144
|
+
expect( getTab( 'Budgets' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
145
|
+
} );
|
|
146
|
+
|
|
147
|
+
it( 'disables every tab with disableUpcoming when no route matches', () => {
|
|
148
|
+
renderTabs( { initialEntries: [ '/unknown' ], disableUpcoming: true } );
|
|
149
|
+
ITEMS.forEach( ( { label } ) => {
|
|
150
|
+
expect( getTab( label ) ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
151
|
+
} );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
it( 'renders the content outside the panels when no tab owns the route', () => {
|
|
155
|
+
// A route no tab matches — an edit screen registered as hidden, or a stale
|
|
156
|
+
// URL. The content must still mount, or the page body is blank.
|
|
157
|
+
renderTabs( { initialEntries: [ '/edit/123' ] } );
|
|
158
|
+
|
|
159
|
+
// Assert positively: with nothing selected the panels unmount entirely, so
|
|
160
|
+
// a `closest( '[role=tabpanel]' )` check alone would pass even if the
|
|
161
|
+
// content rendered somewhere wrong. Exactly one copy also guards the mutual
|
|
162
|
+
// exclusivity with the in-panel render.
|
|
163
|
+
const matches = screen.getAllByText( 'Routed content' );
|
|
164
|
+
expect( matches ).toHaveLength( 1 );
|
|
165
|
+
expect( matches[ 0 ].closest( '.newspack-tabbed-navigation__root' ) ).not.toBeNull();
|
|
166
|
+
expect( matches[ 0 ].closest( '[role="tabpanel"]' ) ).toBeNull();
|
|
167
|
+
ITEMS.forEach( ( { label } ) => {
|
|
168
|
+
expect( getTab( label ) ).toHaveAttribute( 'aria-selected', 'false' );
|
|
169
|
+
} );
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
it( 'renders the content when the only matching item is hidden from the bar', () => {
|
|
173
|
+
// The wizard routes hidden sections but filters them out of the bar, so the
|
|
174
|
+
// item exists and yet can never own the route.
|
|
175
|
+
render(
|
|
176
|
+
<MemoryRouter initialEntries={ [ '/edit/123' ] }>
|
|
177
|
+
<TabbedNavigation
|
|
178
|
+
items={ [ ...ITEMS, { label: 'Edit', path: '/edit/:id', isHiddenInTabbedNavigation: true } ] }
|
|
179
|
+
content={ <div>Routed content</div> }
|
|
180
|
+
/>
|
|
181
|
+
</MemoryRouter>
|
|
182
|
+
);
|
|
183
|
+
expect( screen.getAllByText( 'Routed content' ) ).toHaveLength( 1 );
|
|
184
|
+
expect( screen.queryByRole( 'tab', { name: 'Edit' } ) ).not.toBeInTheDocument();
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
it( 'renders unowned content even when disableUpcoming disables every tab', () => {
|
|
188
|
+
// The setup wizard's Welcome and Completed screens are hidden from the bar
|
|
189
|
+
// AND use disableUpcoming, so `index > activeIndex` disables every tab. The
|
|
190
|
+
// content still has to render — this is the case that left onboarding blank.
|
|
191
|
+
renderTabs( { initialEntries: [ '/unowned' ], disableUpcoming: true } );
|
|
192
|
+
expect( screen.getAllByText( 'Routed content' ) ).toHaveLength( 1 );
|
|
193
|
+
ITEMS.forEach( ( { label } ) => {
|
|
194
|
+
expect( getTab( label ) ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
195
|
+
} );
|
|
196
|
+
} );
|
|
197
|
+
|
|
198
|
+
it( 'mounts unowned content so a router fallback inside it still runs', () => {
|
|
199
|
+
// The wizard's trailing <Redirect> lives inside this content, so dropping
|
|
200
|
+
// it would strand the route instead of correcting it.
|
|
201
|
+
renderTabs( { initialEntries: [ '/unknown' ], content: <Redirect to="/stories" /> } );
|
|
202
|
+
expect( getTab( 'Stories' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
203
|
+
} );
|
|
204
|
+
|
|
205
|
+
it( 'navigates when the active tab changes so panels follow the route', () => {
|
|
206
|
+
const history = renderTabs();
|
|
207
|
+
act( () => history.push( '/budgets' ) );
|
|
208
|
+
expect( getTab( 'Budgets' ) ).toHaveAttribute( 'aria-selected', 'true' );
|
|
209
|
+
expect( screen.getByText( 'Routed content' ).closest( '[role="tabpanel"]' ).id ).toBe( getTab( 'Budgets' ).getAttribute( 'aria-controls' ) );
|
|
210
|
+
} );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
describe( 'TabbedNavigation with href-only items', () => {
|
|
214
|
+
const LINK_ITEMS = [
|
|
215
|
+
{ label: 'Newsletters', href: 'http://example.com/wp-admin/admin.php?page=newsletters', selected: true },
|
|
216
|
+
{ label: 'Ads', href: 'http://example.com/wp-admin/admin.php?page=ads' },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
it( 'renders plain navigation links instead of a tabs widget', () => {
|
|
220
|
+
render( <TabbedNavigation items={ LINK_ITEMS } /> );
|
|
221
|
+
expect( screen.queryByRole( 'tab' ) ).not.toBeInTheDocument();
|
|
222
|
+
expect( screen.getByRole( 'navigation' ) ).toBeInTheDocument();
|
|
223
|
+
|
|
224
|
+
const active = screen.getByRole( 'link', { name: 'Newsletters' } );
|
|
225
|
+
expect( active ).toHaveAttribute( 'aria-current', 'page' );
|
|
226
|
+
expect( screen.getByRole( 'link', { name: 'Ads' } ) ).not.toHaveAttribute( 'aria-current' );
|
|
227
|
+
} );
|
|
228
|
+
} );
|
|
@@ -2,81 +2,101 @@
|
|
|
2
2
|
* Tabbed Navigation
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
@use "../../../colors/colors.module" as colors;
|
|
5
6
|
@use "~@wordpress/base-styles/colors" as wp-colors;
|
|
7
|
+
@use "~@wordpress/base-styles/variables" as wp-vars;
|
|
8
|
+
|
|
9
|
+
// The Tabs.Root wrapper spans the tab bar and the tab panels so the panels can
|
|
10
|
+
// contain the routed content; it plays no layout role of its own.
|
|
11
|
+
.newspack-tabbed-navigation__root {
|
|
12
|
+
display: contents;
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
.newspack-tabbed-navigation {
|
|
8
|
-
background: white;
|
|
16
|
+
background: wp-colors.$white;
|
|
17
|
+
border-bottom: var( --wpds-border-width-xs, #{wp-vars.$border-width} ) solid var( --wpds-color-stroke-surface-neutral-weak, #{wp-colors.$gray-100} );
|
|
9
18
|
width: 100%;
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
top: 46px;
|
|
14
|
-
z-index: 3;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
@media only screen and ( min-width: 783px ) {
|
|
18
|
-
top: 32px;
|
|
19
|
-
}
|
|
20
|
+
/* stylelint-disable-next-line plugin-wpds/no-setting-wpds-custom-properties -- deliberate Newspack brand override of the indicator token. */
|
|
21
|
+
--wpds-color-stroke-interactive-neutral-strong: #{colors.$primary-600};
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
display: flex;
|
|
24
|
-
flex-wrap: wrap;
|
|
25
|
-
margin: 0;
|
|
26
|
-
}
|
|
23
|
+
a[role="tab"] {
|
|
24
|
+
text-decoration: none;
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// wp-admin's unlayered `a { color }` beats every rule inside the design
|
|
27
|
+
// system's @layer wp-ui, so anchor tabs need their colors restated here:
|
|
28
|
+
// gray-900 for enabled tabs, and the DS disabled token for disabled ones.
|
|
29
|
+
&:not([data-disabled]):not([aria-disabled="true"]) {
|
|
30
|
+
color: wp-colors.$gray-900;
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
&:hover,
|
|
33
|
+
&:active,
|
|
34
|
+
&[aria-selected="true"] {
|
|
35
|
+
color: wp-colors.$gray-900;
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
a
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// wp-admin's common.css and the wizard shell both decorate a:focus with a
|
|
40
|
+
// box-shadow ring, a 2px border-radius, and a transparent outline. Reset
|
|
41
|
+
// all three so the component's own :focus-visible ring (an inset outline
|
|
42
|
+
// on its ::after, shipped by @wordpress/ui) is the only focus treatment.
|
|
43
|
+
&:focus {
|
|
44
|
+
border-radius: 0;
|
|
45
|
+
box-shadow: none;
|
|
42
46
|
outline: none;
|
|
43
|
-
|
|
44
|
-
text-decoration: none;
|
|
47
|
+
}
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// Square the component's focus ring corners.
|
|
50
|
+
&::after {
|
|
51
|
+
border-radius: 0;
|
|
52
|
+
}
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
&[data-disabled],
|
|
55
|
+
&[aria-disabled="true"] {
|
|
56
|
+
color: var( --wpds-color-foreground-interactive-neutral-disabled, #{wp-colors.$gray-600} );
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
// Link-driven variant: plain navigation styled to match the tabs widget.
|
|
62
|
+
&--links {
|
|
63
|
+
align-items: stretch;
|
|
64
|
+
display: flex;
|
|
65
|
+
overflow-x: auto;
|
|
66
|
+
}
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
.newspack-tabbed-navigation__link {
|
|
69
|
+
align-items: center;
|
|
70
|
+
color: wp-colors.$gray-900;
|
|
71
|
+
display: flex;
|
|
72
|
+
font-size: var( --wpds-typography-font-size-md, #{wp-vars.$default-font-size} );
|
|
73
|
+
height: wp-vars.$grid-unit-60; // Matches @wordpress/ui's horizontal tab height.
|
|
74
|
+
line-height: 1.2;
|
|
75
|
+
padding-inline: var( --wpds-dimension-padding-lg, #{wp-vars.$grid-unit-20} );
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
white-space: nowrap;
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
&:hover,
|
|
80
|
+
&:active,
|
|
81
|
+
&:focus {
|
|
82
|
+
color: wp-colors.$gray-900;
|
|
83
|
+
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
// Mirror the tabs widget's focus ring (an inset outline) with plain
|
|
86
|
+
// outline + offset, replacing wp-admin's box-shadow focus decoration.
|
|
87
|
+
&:focus {
|
|
88
|
+
border-radius: 0;
|
|
89
|
+
box-shadow: none;
|
|
90
|
+
outline: none;
|
|
91
|
+
}
|
|
75
92
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
&:focus-visible {
|
|
94
|
+
outline: var( --wpds-border-width-focus, var( --wp-admin-border-width-focus, #{wp-vars.$border-width-focus-fallback} ) ) solid var( --wpds-color-stroke-focus, var( --wp-admin-theme-color ) );
|
|
95
|
+
outline-offset: calc( var( --wpds-dimension-padding-md, #{wp-vars.$grid-unit-15} ) * -1 );
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&[aria-current="page"] {
|
|
99
|
+
box-shadow: inset 0 calc( var( --wpds-border-width-focus, #{wp-vars.$border-width-focus-fallback} ) * -1 ) 0 var( --wpds-color-stroke-interactive-neutral-strong, #{colors.$primary-600} );
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
}
|