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.
Files changed (138) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/DEVELOPMENT.md +1 -1
  3. package/dist/cjs/accordion/index.js +43 -26
  4. package/dist/cjs/accordion/index.test.js +80 -0
  5. package/dist/cjs/accordion/style.scss +25 -21
  6. package/dist/cjs/action-card/style.scss +0 -22
  7. package/dist/cjs/breadcrumbs/index.js +80 -0
  8. package/dist/cjs/breadcrumbs/index.test.js +109 -0
  9. package/dist/cjs/breadcrumbs/style.scss +41 -0
  10. package/dist/cjs/card/core-card.js +8 -4
  11. package/dist/cjs/card/index.js +1 -1
  12. package/dist/cjs/card/style-core.scss +14 -0
  13. package/dist/cjs/card/style.scss +0 -9
  14. package/dist/cjs/card-feature/index.js +25 -13
  15. package/dist/cjs/card-settings-group/index.js +3 -0
  16. package/dist/cjs/confirm-dialog/index.js +34 -8
  17. package/dist/cjs/confirm-dialog/index.test.js +173 -0
  18. package/dist/cjs/divider/style.scss +4 -2
  19. package/dist/cjs/grid/style.scss +5 -0
  20. package/dist/cjs/handoff/index.js +8 -5
  21. package/dist/cjs/index.js +34 -1
  22. package/dist/cjs/integration-icon/index.js +40 -0
  23. package/dist/cjs/integration-icon/style.scss +42 -0
  24. package/dist/cjs/integration-icons/index.js +21 -2
  25. package/dist/cjs/modal/style.scss +4 -0
  26. package/dist/cjs/page/index.js +121 -0
  27. package/dist/cjs/page/index.test.js +69 -0
  28. package/dist/cjs/page/style.scss +86 -0
  29. package/dist/cjs/route-match.js +59 -0
  30. package/dist/cjs/route-match.test.js +77 -0
  31. package/dist/cjs/section-header/index.js +8 -1
  32. package/dist/cjs/section-header/style.scss +11 -11
  33. package/dist/cjs/select-control/GroupedSelectControl.js +1 -1
  34. package/dist/cjs/select-control/style.scss +4 -0
  35. package/dist/cjs/style.scss +1 -4
  36. package/dist/cjs/tabbed-navigation/index.js +188 -40
  37. package/dist/cjs/tabbed-navigation/index.test.js +299 -0
  38. package/dist/cjs/tabbed-navigation/style.scss +77 -57
  39. package/dist/cjs/with-wizard/index.js +34 -45
  40. package/dist/cjs/with-wizard-screen/index.js +82 -41
  41. package/dist/cjs/with-wizard-screen/index.test.js +50 -0
  42. package/dist/cjs/with-wizard-screen/style.scss +15 -118
  43. package/dist/cjs/wizard/breadcrumbs-select.js +70 -0
  44. package/dist/cjs/wizard/breadcrumbs-select.test.js +200 -0
  45. package/dist/cjs/wizard/components/WizardError.js +8 -4
  46. package/dist/cjs/wizard/index.js +162 -141
  47. package/dist/esm/accordion/index.js +42 -26
  48. package/dist/esm/accordion/index.test.js +79 -0
  49. package/dist/esm/accordion/style.scss +25 -21
  50. package/dist/esm/action-card/style.scss +0 -22
  51. package/dist/esm/breadcrumbs/index.js +75 -0
  52. package/dist/esm/breadcrumbs/index.test.js +105 -0
  53. package/dist/esm/breadcrumbs/style.scss +41 -0
  54. package/dist/esm/card/core-card.js +8 -4
  55. package/dist/esm/card/index.js +1 -1
  56. package/dist/esm/card/style-core.scss +14 -0
  57. package/dist/esm/card/style.scss +0 -9
  58. package/dist/esm/card-feature/index.js +25 -13
  59. package/dist/esm/card-settings-group/index.js +3 -0
  60. package/dist/esm/confirm-dialog/index.js +34 -8
  61. package/dist/esm/confirm-dialog/index.test.js +173 -0
  62. package/dist/esm/divider/style.scss +4 -2
  63. package/dist/esm/grid/style.scss +5 -0
  64. package/dist/esm/handoff/index.js +7 -5
  65. package/dist/esm/index.js +5 -1
  66. package/dist/esm/integration-icon/index.js +34 -0
  67. package/dist/esm/integration-icon/style.scss +42 -0
  68. package/dist/esm/integration-icons/index.js +22 -1
  69. package/dist/esm/modal/style.scss +4 -0
  70. package/dist/esm/page/index.js +113 -0
  71. package/dist/esm/page/index.test.js +65 -0
  72. package/dist/esm/page/style.scss +86 -0
  73. package/dist/esm/route-match.js +51 -0
  74. package/dist/esm/route-match.test.js +74 -0
  75. package/dist/esm/section-header/index.js +8 -1
  76. package/dist/esm/section-header/style.scss +11 -11
  77. package/dist/esm/select-control/GroupedSelectControl.js +1 -1
  78. package/dist/esm/select-control/style.scss +4 -0
  79. package/dist/esm/style.scss +1 -4
  80. package/dist/esm/tabbed-navigation/index.js +192 -38
  81. package/dist/esm/tabbed-navigation/index.test.js +296 -0
  82. package/dist/esm/tabbed-navigation/style.scss +77 -57
  83. package/dist/esm/with-wizard/index.js +33 -45
  84. package/dist/esm/with-wizard-screen/index.js +78 -41
  85. package/dist/esm/with-wizard-screen/index.test.js +46 -0
  86. package/dist/esm/with-wizard-screen/style.scss +15 -118
  87. package/dist/esm/wizard/breadcrumbs-select.js +63 -0
  88. package/dist/esm/wizard/breadcrumbs-select.test.js +197 -0
  89. package/dist/esm/wizard/components/WizardError.js +7 -5
  90. package/dist/esm/wizard/index.js +165 -144
  91. package/package.json +10 -2
  92. package/src/accordion/index.js +27 -15
  93. package/src/accordion/index.test.js +62 -0
  94. package/src/accordion/style.scss +25 -21
  95. package/src/action-card/style.scss +0 -22
  96. package/src/breadcrumbs/index.js +63 -0
  97. package/src/breadcrumbs/index.test.js +59 -0
  98. package/src/breadcrumbs/style.scss +41 -0
  99. package/src/card/core-card.js +11 -6
  100. package/src/card/index.js +0 -1
  101. package/src/card/style-core.scss +14 -0
  102. package/src/card/style.scss +0 -9
  103. package/src/card-feature/README.md +3 -3
  104. package/src/card-feature/index.tsx +37 -21
  105. package/src/card-settings-group/index.tsx +4 -0
  106. package/src/card-sortable-list/index.tsx +1 -1
  107. package/src/confirm-dialog/index.test.js +142 -0
  108. package/src/confirm-dialog/index.tsx +34 -7
  109. package/src/divider/style.scss +4 -2
  110. package/src/grid/style.scss +5 -0
  111. package/src/handoff/index.js +4 -3
  112. package/src/index.js +5 -1
  113. package/src/integration-icon/index.tsx +35 -0
  114. package/src/integration-icon/style.scss +42 -0
  115. package/src/integration-icons/index.js +23 -0
  116. package/src/modal/style.scss +4 -0
  117. package/src/page/index.js +89 -0
  118. package/src/page/index.test.js +43 -0
  119. package/src/page/style.scss +86 -0
  120. package/src/route-match.js +47 -0
  121. package/src/route-match.test.js +50 -0
  122. package/src/section-header/index.js +7 -3
  123. package/src/section-header/style.scss +11 -11
  124. package/src/select-control/GroupedSelectControl.js +1 -1
  125. package/src/select-control/style.scss +4 -0
  126. package/src/style.scss +1 -4
  127. package/src/tabbed-navigation/index.js +181 -34
  128. package/src/tabbed-navigation/index.test.js +228 -0
  129. package/src/tabbed-navigation/style.scss +77 -57
  130. package/src/with-wizard/index.js +18 -39
  131. package/src/with-wizard-screen/index.js +70 -35
  132. package/src/with-wizard-screen/index.test.js +36 -0
  133. package/src/with-wizard-screen/style.scss +15 -118
  134. package/src/wizard/breadcrumbs-select.js +51 -0
  135. package/src/wizard/breadcrumbs-select.test.js +124 -0
  136. package/src/wizard/components/WizardError.js +4 -3
  137. package/src/wizard/index.js +160 -146
  138. 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 { NavLink, useHistory } = Router;
19
+ const { useHistory, useLocation } = Router;
14
20
 
15
- const TabbedNavigation = ( { items, className, disableUpcoming, children = null } ) => {
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
- function isActive( item, match, pathname ) {
21
- if ( item.path === pathname ) {
22
- return true;
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 ( Array.isArray( item?.activeTabPaths ) ) {
25
- return item.activeTabPaths.some( path => {
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 match;
34
- }
63
+ return best;
64
+ }, -1 );
65
+ const activeValue = activeIndex > -1 ? getItemValue( displayedItems[ activeIndex ], activeIndex ) : null;
35
66
 
36
- return (
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
- <ul>
39
- { displayedItems.map( ( item, index ) => (
40
- <li key={ index }>
41
- <NavLink
42
- to={ item.path }
43
- isActive={ ( match, { pathname } ) => isActive( item, match, pathname ) }
44
- exact={ item.hasOwnProperty( 'exact' ) ? item.exact : true }
45
- activeClassName={ 'selected' }
46
- className={ classnames( {
47
- disabled: disableUpcoming && index > currentIndex,
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
- </NavLink>
52
- </li>
53
- ) ) }
54
- </ul>
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
- @media only screen and ( min-width: 600px ) {
12
- position: sticky;
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
- ul {
22
- box-shadow: 0 -1px 0 inset wp-colors.$gray-300;
23
- display: flex;
24
- flex-wrap: wrap;
25
- margin: 0;
26
- }
23
+ a[role="tab"] {
24
+ text-decoration: none;
27
25
 
28
- li {
29
- margin-bottom: 0;
30
- width: 100%;
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
- @media screen and ( min-width: 600px ) {
33
- width: auto;
32
+ &:hover,
33
+ &:active,
34
+ &[aria-selected="true"] {
35
+ color: wp-colors.$gray-900;
36
+ }
34
37
  }
35
38
 
36
- a {
37
- align-items: center;
38
- color: wp-colors.$gray-900;
39
- display: flex;
40
- font-weight: bold;
41
- height: 48px;
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
- padding: 12px 15px;
44
- text-decoration: none;
47
+ }
45
48
 
46
- @media screen and ( min-width: 600px ) {
47
- border-bottom: 4px solid transparent;
48
- padding-bottom: 8px;
49
- }
49
+ // Square the component's focus ring corners.
50
+ &::after {
51
+ border-radius: 0;
52
+ }
50
53
 
51
- &:active,
52
- &:focus,
53
- &:hover {
54
- color: var(--wp-admin-theme-color);
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
- &:focus-visible {
58
- outline: 2px solid var(--wp-admin-theme-color);
59
- outline-offset: -2px;
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
- &.selected {
63
- box-shadow: inset 0 0 0 2px white, inset 0 0 0 4px var(--wp-admin-theme-color);
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
- @media screen and ( min-width: 600px ) {
66
- border-color: var(--wp-admin-theme-color);
67
- box-shadow: none;
68
- }
69
- }
79
+ &:hover,
80
+ &:active,
81
+ &:focus {
82
+ color: wp-colors.$gray-900;
83
+ }
70
84
 
71
- &.disabled {
72
- cursor: not-allowed;
73
- opacity: 0.3;
74
- pointer-events: none;
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
- &:hover {
77
- color: inherit;
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
  }