@atlaskit/navigation-system 9.3.1 → 9.4.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 (32) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/constellation/index/migration-guide.mdx +338 -150
  3. package/dist/cjs/components/skip-links/focus-element.js +54 -0
  4. package/dist/cjs/components/skip-links/skip-link.js +32 -65
  5. package/dist/cjs/components/skip-links/skip-links-popup.js +26 -4
  6. package/dist/cjs/context/skip-links/use-skip-link-internal.js +5 -3
  7. package/dist/cjs/ui/page-layout/side-nav/side-nav.js +16 -5
  8. package/dist/cjs/ui/page-layout/side-nav/use-expand-side-nav.js +104 -38
  9. package/dist/cjs/ui/page-layout/side-nav/use-toggle-side-nav.js +33 -3
  10. package/dist/es2019/components/skip-links/focus-element.js +49 -0
  11. package/dist/es2019/components/skip-links/skip-link.js +32 -65
  12. package/dist/es2019/components/skip-links/skip-links-popup.js +26 -4
  13. package/dist/es2019/context/skip-links/use-skip-link-internal.js +5 -3
  14. package/dist/es2019/ui/page-layout/side-nav/side-nav.js +16 -5
  15. package/dist/es2019/ui/page-layout/side-nav/use-expand-side-nav.js +104 -38
  16. package/dist/es2019/ui/page-layout/side-nav/use-toggle-side-nav.js +33 -3
  17. package/dist/esm/components/skip-links/focus-element.js +49 -0
  18. package/dist/esm/components/skip-links/skip-link.js +32 -65
  19. package/dist/esm/components/skip-links/skip-links-popup.js +26 -4
  20. package/dist/esm/context/skip-links/use-skip-link-internal.js +5 -3
  21. package/dist/esm/ui/page-layout/side-nav/side-nav.js +16 -5
  22. package/dist/esm/ui/page-layout/side-nav/use-expand-side-nav.js +104 -38
  23. package/dist/esm/ui/page-layout/side-nav/use-toggle-side-nav.js +33 -3
  24. package/dist/types/components/skip-links/focus-element.d.ts +4 -0
  25. package/dist/types/components/skip-links/skip-link.d.ts +2 -1
  26. package/dist/types/context/skip-links/types.d.ts +18 -1
  27. package/dist/types/context/skip-links/use-skip-link-internal.d.ts +3 -3
  28. package/dist/types-ts4.5/components/skip-links/focus-element.d.ts +4 -0
  29. package/dist/types-ts4.5/components/skip-links/skip-link.d.ts +2 -1
  30. package/dist/types-ts4.5/context/skip-links/types.d.ts +18 -1
  31. package/dist/types-ts4.5/context/skip-links/use-skip-link-internal.d.ts +3 -3
  32. package/package.json +2 -2
@@ -2,62 +2,14 @@
2
2
  import "./skip-link.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { useCallback } from 'react';
5
- import { bind } from 'bind-event-listener';
6
5
  import { fg } from '@atlaskit/platform-feature-flags';
7
6
  // eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss
8
7
  import { Anchor } from '@atlaskit/primitives/compiled';
8
+ import { focusElement } from './focus-element';
9
9
  const styles = {
10
10
  skipLinkListItem: "_1pfhze3t",
11
11
  skipLinkListItemNew: "_1rjcu2gc"
12
12
  };
13
-
14
- /**
15
- * Used for moving focus to the corresponding slot or custom target after clicking on a skip link.
16
- */
17
- function focusElement(element) {
18
- /**
19
- * Elements without an explicit `tabindex` attribute are not guaranteed to be focusable:
20
- * https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
21
- *
22
- * Our slots are not interactive, so this is required.
23
- *
24
- * In the future we may want to check if there is an existing `tabindex` attribute,
25
- * as custom skip linked elements might already have one.
26
- */
27
- element.setAttribute('tabindex', '-1');
28
-
29
- /**
30
- * Cleanup the `tabindex` attribute we set when the slot or custom target loses focus.
31
- *
32
- * This is preferable to always having `tabindex="-1"` because always applying the tab index can:
33
- *
34
- * - mess with click events
35
- * - potentially cause a focus ring to be always visible
36
- */
37
- bind(element, {
38
- type: 'blur',
39
- listener() {
40
- element.removeAttribute('tabindex');
41
- },
42
- options: {
43
- // Using a one-time listener so it cleans itself up
44
- once: true
45
- }
46
- });
47
-
48
- /**
49
- * Move focus to the slot or custom target.
50
- *
51
- * Calling `.focus()` will also scroll the element into view:
52
- * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
53
- */
54
- element.focus({
55
- // Forces the focus ring to appear after moving focus to the slot
56
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
57
- // @ts-expect-error - new and not in types yet
58
- focusVisible: true
59
- });
60
- }
61
13
  /**
62
14
  * A link that moves current tab position to a different element
63
15
  *
@@ -66,26 +18,41 @@ function focusElement(element) {
66
18
  export const SkipLink = ({
67
19
  id,
68
20
  children,
69
- onBeforeNavigate
21
+ onBeforeNavigate,
22
+ navigate
70
23
  }) => {
71
24
  const href = `#${id}`;
72
- const onClick = useCallback(event => {
25
+ const handleClick = useCallback(event => {
73
26
  event.preventDefault();
27
+ if (navigate && fg('platform_dst_nav4_skip_link_a11y_1')) {
28
+ /**
29
+ * The consumer takes over the navigation effect (e.g. expanding the
30
+ * side nav and focusing the first nav item). The universal pre/post
31
+ * work below (e.g. `window.scrollTo`) still runs around it.
32
+ */
33
+ navigate();
34
+ } else {
35
+ // Intentionally not using `document.querySelector` because many valid IDs are not valid selectors.
36
+ const target = document.getElementById(id);
37
+ if (!target) {
38
+ return;
39
+ }
74
40
 
75
- // Intentionally not using `document.querySelector` because many valid IDs are not valid selectors.
76
- const target = document.getElementById(id);
77
- if (!target) {
78
- return;
41
+ /**
42
+ * Legacy `onBeforeNavigate` hook. Intentionally NOT called when
43
+ * `platform_dst_nav4_skip_link_a11y_1` is enabled — under the gate the
44
+ * gate-on path delegates state mutation + focus management to `navigate`,
45
+ * and `SkipLinksPopup` injects its popup-close behavior into the
46
+ * `navigate` wrapper instead of relying on this hook.
47
+ *
48
+ * This callback can be removed entirely on gate cleanup.
49
+ */
50
+ if (!fg('platform_dst_nav4_skip_link_a11y_1')) {
51
+ onBeforeNavigate === null || onBeforeNavigate === void 0 ? void 0 : onBeforeNavigate();
52
+ }
53
+ focusElement(target);
79
54
  }
80
55
 
81
- /**
82
- * Internal slots can attach an `onBeforeNavigate` callback.
83
- *
84
- * Side nav uses this to ensure it is expanded.
85
- */
86
- onBeforeNavigate === null || onBeforeNavigate === void 0 ? void 0 : onBeforeNavigate();
87
- focusElement(target);
88
-
89
56
  /**
90
57
  * We should look into removing this, or only calling it in specific cases.
91
58
  *
@@ -99,7 +66,7 @@ export const SkipLink = ({
99
66
  * E.g. jumping to main / aside it makes sense to look at the start of the content.
100
67
  */
101
68
  window.scrollTo(0, 0);
102
- }, [id, onBeforeNavigate]);
69
+ }, [id, onBeforeNavigate, navigate]);
103
70
  return /*#__PURE__*/React.createElement("li", {
104
71
  className: ax([styles.skipLinkListItem, fg('platform_dst_nav4_skip_link_a11y_1') && styles.skipLinkListItemNew])
105
72
  }, /*#__PURE__*/React.createElement(Anchor
@@ -111,6 +78,6 @@ export const SkipLink = ({
111
78
  */, {
112
79
  tabIndex: 0,
113
80
  href: href,
114
- onClick: onClick
81
+ onClick: handleClick
115
82
  }, children));
116
83
  };
@@ -8,6 +8,7 @@ import { flushSync } from 'react-dom';
8
8
  import Button from '@atlaskit/button/new';
9
9
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
10
10
  import Popup from '@atlaskit/popup';
11
+ import { focusElement } from './focus-element';
11
12
  import { SkipLink } from './skip-link';
12
13
  const contentStyles = {
13
14
  root: "_1rjcu2gc _18zrpxbi _1e0c1txw _2lx21bp4 _1pbyjh3g",
@@ -85,13 +86,34 @@ export function SkipLinksPopup({
85
86
  }, links.map(({
86
87
  id,
87
88
  label,
88
- onBeforeNavigate
89
+ navigate
89
90
  }) => /*#__PURE__*/React.createElement(SkipLink, {
90
91
  key: id,
91
- id: id,
92
- onBeforeNavigate: () => {
92
+ id: id
93
+ /**
94
+ * The popup always owns the navigation effect under the
95
+ * `platform_dst_nav4_skip_link_a11y_1` gate (the only path
96
+ * that renders `SkipLinksPopup`). It first closes itself
97
+ * synchronously so its focus lock is released, then either:
98
+ *
99
+ * - delegates to the consumer's `navigate` (e.g. SideNav
100
+ * expanding and focusing its first nav item), or
101
+ * - falls back to focusing the slot element with `id`.
102
+ *
103
+ * This means `SkipLink`'s `onBeforeNavigate` hook is never
104
+ * needed under the gate and can be removed on cleanup.
105
+ */,
106
+ navigate: () => {
93
107
  closePopup();
94
- onBeforeNavigate === null || onBeforeNavigate === void 0 ? void 0 : onBeforeNavigate();
108
+ if (navigate) {
109
+ navigate();
110
+ return;
111
+ }
112
+ // Intentionally not using `document.querySelector` because many valid IDs are not valid selectors.
113
+ const target = document.getElementById(id);
114
+ if (target) {
115
+ focusElement(target);
116
+ }
95
117
  }
96
118
  }, label)))),
97
119
  trigger: triggerProps => /*#__PURE__*/React.createElement("div", {
@@ -5,14 +5,15 @@ import { SkipLinksContext } from './skip-links-context';
5
5
  *
6
6
  * `useSkipLink` is the public API wrapper of this.
7
7
  *
8
- * This private version exists for us to support `onBeforeNavigate` for the side nav use case,
9
- * where we might need to expand it before moving focus, without having to support `onBeforeNavigate` publicly.
8
+ * This private version exists for us to support `onBeforeNavigate` / `navigate` for the side nav use case,
9
+ * where we might need to expand it before moving focus, without having to support those publicly.
10
10
  */
11
11
  export const useSkipLinkInternal = ({
12
12
  id,
13
13
  label,
14
14
  listIndex,
15
15
  onBeforeNavigate,
16
+ navigate,
16
17
  isHidden
17
18
  }) => {
18
19
  const {
@@ -32,10 +33,11 @@ export const useSkipLinkInternal = ({
32
33
  label,
33
34
  listIndex,
34
35
  onBeforeNavigate,
36
+ navigate,
35
37
  isHidden
36
38
  });
37
39
  return () => {
38
40
  unregisterSkipLink(id);
39
41
  };
40
- }, [id, isHidden, label, listIndex, onBeforeNavigate, registerSkipLink, unregisterSkipLink]);
42
+ }, [id, isHidden, label, listIndex, onBeforeNavigate, navigate, registerSkipLink, unregisterSkipLink]);
41
43
  };
@@ -9,6 +9,7 @@ import { useAnalyticsEvents } from '@atlaskit/analytics-next';
9
9
  import mergeRefs from '@atlaskit/ds-lib/merge-refs';
10
10
  import useStableRef from '@atlaskit/ds-lib/use-stable-ref';
11
11
  import { OpenLayerObserverNamespaceProvider, useOpenLayerObserver } from '@atlaskit/layering/experimental/open-layer-observer';
12
+ import { fg } from '@atlaskit/platform-feature-flags';
12
13
  import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
13
14
  import { media } from '@atlaskit/primitives/responsive';
14
15
  import { useSkipLinkInternal } from '../../../context/skip-links/use-skip-link-internal';
@@ -98,28 +99,38 @@ function SideNavInternal({
98
99
  const id = useLayoutId({
99
100
  providedId
100
101
  });
101
- const expandSideNav = useExpandSideNav({
102
+ const expandAndFocusSideNav = useExpandSideNav({
102
103
  trigger: 'skip-link'
103
104
  });
104
105
  /**
105
106
  * Called after clicking on the side nav skip link, and ensures the side nav is expanded so that it is focusable.
106
107
  *
107
108
  * We need to update the DOM synchronously because `.focus()` is called synchronously after this state update.
109
+ *
110
+ * Only used when `platform_dst_nav4_skip_link_a11y_1` is OFF; can be removed on gate cleanup.
108
111
  */
109
112
  const synchronouslyExpandSideNav = useCallback(() => {
110
113
  flushSync(() => {
111
114
  /**
112
115
  * Calling this unconditionally and relying on it to avoid no-op renders.
113
116
  *
114
- * We _could_ call it conditionally, but we'd be duplicating the screen size checks `expandSideNav` makes.
117
+ * We _could_ call it conditionally, but we'd be duplicating the screen size checks `expandAndFocusSideNav` makes.
115
118
  */
116
- expandSideNav();
119
+ expandAndFocusSideNav();
117
120
  });
118
- }, [expandSideNav]);
121
+ }, [expandAndFocusSideNav]);
119
122
  useSkipLinkInternal({
120
123
  id,
121
124
  label: skipLinkLabel,
122
- onBeforeNavigate: synchronouslyExpandSideNav
125
+ /**
126
+ * `navigate` is the gate-on contract: it owns expanding the side nav AND moving
127
+ * focus to the first nav item, atomically (via `useExpandSideNav`'s `flushSync`).
128
+ *
129
+ * `onBeforeNavigate` is the legacy contract used only when the gate is OFF.
130
+ * On gate cleanup, drop `onBeforeNavigate` and `synchronouslyExpandSideNav` here.
131
+ */
132
+ navigate: fg('platform_dst_nav4_skip_link_a11y_1') ? expandAndFocusSideNav : undefined,
133
+ onBeforeNavigate: fg('platform_dst_nav4_skip_link_a11y_1') ? undefined : synchronouslyExpandSideNav
123
134
  });
124
135
  const sideNavState = useContext(SideNavVisibilityState);
125
136
  const setSideNavState = useContext(SetSideNavVisibilityState);
@@ -1,5 +1,59 @@
1
1
  import { useCallback, useContext } from 'react';
2
+ import { bind } from 'bind-event-listener';
3
+ import { flushSync } from 'react-dom';
4
+ import { fg } from '@atlaskit/platform-feature-flags';
2
5
  import { SetSideNavVisibilityState } from './set-side-nav-visibility-state';
6
+ import { useSideNavRef } from './use-side-nav-ref';
7
+ /**
8
+ * Moves focus to the first focusable item in the side nav, or the side nav element itself as a fallback.
9
+ */
10
+ function focusFirstNavItem(sideNavElement) {
11
+ var _firstNavItem$checkVi, _firstNavItem$checkVi2;
12
+ /**
13
+ * Try to get the first focusable item in the side nav.
14
+ * The selector is not very broad, but should be appropriate for items from this package.
15
+ */
16
+ const firstNavItem = sideNavElement.querySelector('a, button');
17
+ const isFirstNavItemVisible = firstNavItem !== null && ((_firstNavItem$checkVi = (_firstNavItem$checkVi2 = firstNavItem.checkVisibility) === null || _firstNavItem$checkVi2 === void 0 ? void 0 : _firstNavItem$checkVi2.call(firstNavItem)) !== null && _firstNavItem$checkVi !== void 0 ? _firstNavItem$checkVi : false);
18
+ const itemToFocus = isFirstNavItemVisible ? firstNavItem : sideNavElement;
19
+ if (itemToFocus === sideNavElement) {
20
+ /**
21
+ * Elements without an explicit `tabindex` attribute are not guaranteed to be focusable:
22
+ * https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
23
+ *
24
+ * Our slots are not interactive, so this is required.
25
+ *
26
+ * In the future we may want to check if there is an existing `tabindex` attribute,
27
+ * as custom skip linked elements might already have one.
28
+ */
29
+ sideNavElement.setAttribute('tabindex', '-1');
30
+
31
+ /**
32
+ * Cleanup the `tabindex` attribute we set when the slot or custom target loses focus.
33
+ *
34
+ * This is preferable to always having `tabindex="-1"` because always applying the tab index can:
35
+ *
36
+ * - mess with click events
37
+ * - potentially cause a focus ring to be always visible
38
+ */
39
+ bind(sideNavElement, {
40
+ type: 'blur',
41
+ listener() {
42
+ sideNavElement.removeAttribute('tabindex');
43
+ },
44
+ options: {
45
+ once: true
46
+ }
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Not using `focusVisible` option because we don't want clicks on the toggle button to show a focus ring.
52
+ */
53
+ itemToFocus.focus();
54
+ }
55
+ const triggersWithFocusOnExpand = new Set(['toggle-button', 'skip-link']);
56
+
3
57
  /**
4
58
  * __useExpandSideNav__
5
59
  *
@@ -13,49 +67,61 @@ export function useExpandSideNav({
13
67
  trigger = 'programmatic'
14
68
  } = {}) {
15
69
  const setSideNavState = useContext(SetSideNavVisibilityState);
70
+ const sideNavRef = useSideNavRef();
16
71
  const expandSideNav = useCallback(() => {
17
72
  const {
18
- matches
73
+ matches: isDesktop
19
74
  } = window.matchMedia('(min-width: 64rem)');
20
- if (matches) {
21
- setSideNavState(currentState => {
22
- // No-op if the side nav state has not been initialised yet
23
- // e.g. if the SideNav has not been mounted yet
24
- if (!currentState) {
25
- return null;
26
- }
75
+ const runUpdate = () => {
76
+ if (isDesktop) {
77
+ setSideNavState(currentState => {
78
+ // No-op if the side nav state has not been initialised yet
79
+ // e.g. if the SideNav has not been mounted yet
80
+ if (!currentState) {
81
+ return null;
82
+ }
27
83
 
28
- // Skip the re-render if it's a no-op change
29
- if (currentState.desktop === 'expanded' && currentState.flyout === 'closed') {
30
- return currentState;
31
- }
32
- return {
33
- mobile: currentState.mobile,
34
- desktop: 'expanded',
35
- flyout: 'closed',
36
- lastTrigger: trigger
37
- };
38
- });
39
- } else {
40
- setSideNavState(currentState => {
41
- // No-op if the side nav state has not been initialised yet
42
- // e.g. if the SideNav has not been mounted yet
43
- if (!currentState) {
44
- return null;
45
- }
84
+ // Skip the re-render if it's a no-op change
85
+ if (currentState.desktop === 'expanded' && currentState.flyout === 'closed') {
86
+ return currentState;
87
+ }
88
+ return {
89
+ mobile: currentState.mobile,
90
+ desktop: 'expanded',
91
+ flyout: 'closed',
92
+ lastTrigger: trigger
93
+ };
94
+ });
95
+ } else {
96
+ setSideNavState(currentState => {
97
+ // No-op if the side nav state has not been initialised yet
98
+ // e.g. if the SideNav has not been mounted yet
99
+ if (!currentState) {
100
+ return null;
101
+ }
46
102
 
47
- // Skip the re-render if it's a no-op change
48
- if (currentState.mobile === 'expanded' && currentState.flyout === 'closed') {
49
- return currentState;
50
- }
51
- return {
52
- desktop: currentState.desktop,
53
- mobile: 'expanded',
54
- flyout: 'closed',
55
- lastTrigger: trigger
56
- };
57
- });
103
+ // Skip the re-render if it's a no-op change
104
+ if (currentState.mobile === 'expanded' && currentState.flyout === 'closed') {
105
+ return currentState;
106
+ }
107
+ return {
108
+ desktop: currentState.desktop,
109
+ mobile: 'expanded',
110
+ flyout: 'closed',
111
+ lastTrigger: trigger
112
+ };
113
+ });
114
+ }
115
+ };
116
+ if (triggersWithFocusOnExpand.has(trigger) && fg('platform_dst_nav4_skip_link_a11y_1')) {
117
+ flushSync(runUpdate);
118
+ const sideNavElement = sideNavRef.current;
119
+ if (sideNavElement) {
120
+ focusFirstNavItem(sideNavElement);
121
+ }
122
+ } else {
123
+ runUpdate();
58
124
  }
59
- }, [setSideNavState, trigger]);
125
+ }, [setSideNavState, sideNavRef, trigger]);
60
126
  return expandSideNav;
61
127
  }
@@ -1,5 +1,8 @@
1
1
  import { useCallback, useContext } from 'react';
2
+ import { fg } from '@atlaskit/platform-feature-flags';
2
3
  import { SetSideNavVisibilityState } from './set-side-nav-visibility-state';
4
+ import { SideNavVisibilityState } from './side-nav-visibility-state';
5
+ import { useExpandSideNav } from './use-expand-side-nav';
3
6
  /**
4
7
  * __useToggleSideNav__
5
8
  *
@@ -13,11 +16,38 @@ export function useToggleSideNav({
13
16
  trigger = 'programmatic'
14
17
  } = {}) {
15
18
  const setSideNavState = useContext(SetSideNavVisibilityState);
19
+ const sideNavState = useContext(SideNavVisibilityState);
20
+ const expandSideNav = useExpandSideNav({
21
+ trigger
22
+ });
23
+ const isCollapsedOnDesktop = (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.desktop) === 'collapsed';
24
+ const isCollapsedOnMobile = (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.mobile) === 'collapsed';
16
25
  const toggleSideNav = useCallback(() => {
17
26
  const {
18
- matches
27
+ matches: isDesktop
19
28
  } = window.matchMedia('(min-width: 64rem)');
20
- if (matches) {
29
+ if (fg('platform_dst_nav4_skip_link_a11y_1')) {
30
+ const isExpanding = isDesktop ? isCollapsedOnDesktop : isCollapsedOnMobile;
31
+ if (isExpanding) {
32
+ expandSideNav();
33
+ } else {
34
+ setSideNavState(currentState => {
35
+ // No-op if the side nav state has not been initialised yet
36
+ // e.g. if the SideNav has not been mounted yet
37
+ if (!currentState) {
38
+ return null;
39
+ }
40
+ return {
41
+ mobile: isDesktop ? currentState.mobile : 'collapsed',
42
+ desktop: isDesktop ? 'collapsed' : currentState.desktop,
43
+ flyout: 'closed',
44
+ lastTrigger: trigger
45
+ };
46
+ });
47
+ }
48
+ return;
49
+ }
50
+ if (isDesktop) {
21
51
  setSideNavState(currentState => {
22
52
  // No-op if the side nav state has not been initialised yet
23
53
  // e.g. if the SideNav has not been mounted yet
@@ -46,6 +76,6 @@ export function useToggleSideNav({
46
76
  };
47
77
  });
48
78
  }
49
- }, [setSideNavState, trigger]);
79
+ }, [expandSideNav, isCollapsedOnDesktop, isCollapsedOnMobile, setSideNavState, trigger]);
50
80
  return toggleSideNav;
51
81
  }
@@ -0,0 +1,49 @@
1
+ import { bind } from 'bind-event-listener';
2
+
3
+ /**
4
+ * Used for moving focus to the corresponding slot or custom target after clicking on a skip link.
5
+ */
6
+ export function focusElement(element) {
7
+ /**
8
+ * Elements without an explicit `tabindex` attribute are not guaranteed to be focusable:
9
+ * https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
10
+ *
11
+ * Our slots are not interactive, so this is required.
12
+ *
13
+ * In the future we may want to check if there is an existing `tabindex` attribute,
14
+ * as custom skip linked elements might already have one.
15
+ */
16
+ element.setAttribute('tabindex', '-1');
17
+
18
+ /**
19
+ * Cleanup the `tabindex` attribute we set when the slot or custom target loses focus.
20
+ *
21
+ * This is preferable to always having `tabindex="-1"` because always applying the tab index can:
22
+ *
23
+ * - mess with click events
24
+ * - potentially cause a focus ring to be always visible
25
+ */
26
+ bind(element, {
27
+ type: 'blur',
28
+ listener: function listener() {
29
+ element.removeAttribute('tabindex');
30
+ },
31
+ options: {
32
+ // Using a one-time listener so it cleans itself up
33
+ once: true
34
+ }
35
+ });
36
+
37
+ /**
38
+ * Move focus to the slot or custom target.
39
+ *
40
+ * Calling `.focus()` will also scroll the element into view:
41
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
42
+ */
43
+ element.focus({
44
+ // Forces the focus ring to appear after moving focus to the slot
45
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
46
+ // @ts-expect-error - new and not in types yet
47
+ focusVisible: true
48
+ });
49
+ }
@@ -2,62 +2,14 @@
2
2
  import "./skip-link.compiled.css";
3
3
  import { ax, ix } from "@compiled/react/runtime";
4
4
  import React, { useCallback } from 'react';
5
- import { bind } from 'bind-event-listener';
6
5
  import { fg } from '@atlaskit/platform-feature-flags';
7
6
  // eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- to be migrated to @atlaskit/primitives/compiled – go/akcss
8
7
  import { Anchor } from '@atlaskit/primitives/compiled';
8
+ import { focusElement } from './focus-element';
9
9
  var styles = {
10
10
  skipLinkListItem: "_1pfhze3t",
11
11
  skipLinkListItemNew: "_1rjcu2gc"
12
12
  };
13
-
14
- /**
15
- * Used for moving focus to the corresponding slot or custom target after clicking on a skip link.
16
- */
17
- function focusElement(element) {
18
- /**
19
- * Elements without an explicit `tabindex` attribute are not guaranteed to be focusable:
20
- * https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
21
- *
22
- * Our slots are not interactive, so this is required.
23
- *
24
- * In the future we may want to check if there is an existing `tabindex` attribute,
25
- * as custom skip linked elements might already have one.
26
- */
27
- element.setAttribute('tabindex', '-1');
28
-
29
- /**
30
- * Cleanup the `tabindex` attribute we set when the slot or custom target loses focus.
31
- *
32
- * This is preferable to always having `tabindex="-1"` because always applying the tab index can:
33
- *
34
- * - mess with click events
35
- * - potentially cause a focus ring to be always visible
36
- */
37
- bind(element, {
38
- type: 'blur',
39
- listener: function listener() {
40
- element.removeAttribute('tabindex');
41
- },
42
- options: {
43
- // Using a one-time listener so it cleans itself up
44
- once: true
45
- }
46
- });
47
-
48
- /**
49
- * Move focus to the slot or custom target.
50
- *
51
- * Calling `.focus()` will also scroll the element into view:
52
- * https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus
53
- */
54
- element.focus({
55
- // Forces the focus ring to appear after moving focus to the slot
56
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
57
- // @ts-expect-error - new and not in types yet
58
- focusVisible: true
59
- });
60
- }
61
13
  /**
62
14
  * A link that moves current tab position to a different element
63
15
  *
@@ -66,25 +18,40 @@ function focusElement(element) {
66
18
  export var SkipLink = function SkipLink(_ref) {
67
19
  var id = _ref.id,
68
20
  children = _ref.children,
69
- onBeforeNavigate = _ref.onBeforeNavigate;
21
+ onBeforeNavigate = _ref.onBeforeNavigate,
22
+ navigate = _ref.navigate;
70
23
  var href = "#".concat(id);
71
- var onClick = useCallback(function (event) {
24
+ var handleClick = useCallback(function (event) {
72
25
  event.preventDefault();
26
+ if (navigate && fg('platform_dst_nav4_skip_link_a11y_1')) {
27
+ /**
28
+ * The consumer takes over the navigation effect (e.g. expanding the
29
+ * side nav and focusing the first nav item). The universal pre/post
30
+ * work below (e.g. `window.scrollTo`) still runs around it.
31
+ */
32
+ navigate();
33
+ } else {
34
+ // Intentionally not using `document.querySelector` because many valid IDs are not valid selectors.
35
+ var target = document.getElementById(id);
36
+ if (!target) {
37
+ return;
38
+ }
73
39
 
74
- // Intentionally not using `document.querySelector` because many valid IDs are not valid selectors.
75
- var target = document.getElementById(id);
76
- if (!target) {
77
- return;
40
+ /**
41
+ * Legacy `onBeforeNavigate` hook. Intentionally NOT called when
42
+ * `platform_dst_nav4_skip_link_a11y_1` is enabled — under the gate the
43
+ * gate-on path delegates state mutation + focus management to `navigate`,
44
+ * and `SkipLinksPopup` injects its popup-close behavior into the
45
+ * `navigate` wrapper instead of relying on this hook.
46
+ *
47
+ * This callback can be removed entirely on gate cleanup.
48
+ */
49
+ if (!fg('platform_dst_nav4_skip_link_a11y_1')) {
50
+ onBeforeNavigate === null || onBeforeNavigate === void 0 || onBeforeNavigate();
51
+ }
52
+ focusElement(target);
78
53
  }
79
54
 
80
- /**
81
- * Internal slots can attach an `onBeforeNavigate` callback.
82
- *
83
- * Side nav uses this to ensure it is expanded.
84
- */
85
- onBeforeNavigate === null || onBeforeNavigate === void 0 || onBeforeNavigate();
86
- focusElement(target);
87
-
88
55
  /**
89
56
  * We should look into removing this, or only calling it in specific cases.
90
57
  *
@@ -98,7 +65,7 @@ export var SkipLink = function SkipLink(_ref) {
98
65
  * E.g. jumping to main / aside it makes sense to look at the start of the content.
99
66
  */
100
67
  window.scrollTo(0, 0);
101
- }, [id, onBeforeNavigate]);
68
+ }, [id, onBeforeNavigate, navigate]);
102
69
  return /*#__PURE__*/React.createElement("li", {
103
70
  className: ax([styles.skipLinkListItem, fg('platform_dst_nav4_skip_link_a11y_1') && styles.skipLinkListItemNew])
104
71
  }, /*#__PURE__*/React.createElement(Anchor
@@ -110,6 +77,6 @@ export var SkipLink = function SkipLink(_ref) {
110
77
  */, {
111
78
  tabIndex: 0,
112
79
  href: href,
113
- onClick: onClick
80
+ onClick: handleClick
114
81
  }, children));
115
82
  };