diff --git a/pages/anchor-navigation/basic.page.tsx b/pages/anchor-navigation/basic.page.tsx index b5349836ef..8aa5339100 100644 --- a/pages/anchor-navigation/basic.page.tsx +++ b/pages/anchor-navigation/basic.page.tsx @@ -4,6 +4,7 @@ import React from 'react'; import AnchorNavigation from '~components/anchor-navigation'; import { TextSample } from './utils'; import SpaceBetween from '~components/space-between'; +import Header from '~components/header'; import ScreenshotArea from '../utils/screenshot-area'; import styles from './styles.scss'; @@ -40,17 +41,20 @@ export default function SimpleAnchorNavigation() {
+
+ On this page +
diff --git a/pages/anchor-navigation/expandable.page.tsx b/pages/anchor-navigation/expandable.page.tsx index be57ba715b..117c89c387 100644 --- a/pages/anchor-navigation/expandable.page.tsx +++ b/pages/anchor-navigation/expandable.page.tsx @@ -6,6 +6,7 @@ import { TextSample } from './utils'; import SpaceBetween from '~components/space-between'; import ScreenshotArea from '../utils/screenshot-area'; import styles from './styles.scss'; +import { ExpandableSection } from '~components'; const TextContent = () => { return ( @@ -40,19 +41,20 @@ export default function SimpleToc() {
- + + +
diff --git a/src/anchor-navigation/index.tsx b/src/anchor-navigation/index.tsx index 00a05c6338..1a3d69a852 100644 --- a/src/anchor-navigation/index.tsx +++ b/src/anchor-navigation/index.tsx @@ -7,7 +7,7 @@ import InternalAnchorNavigation from './internal'; export { AnchorNavigationProps }; -export default function AnchorNavigation({ variant = 'default', ...props }: AnchorNavigationProps) { - return ; +export default function AnchorNavigation({ ...props }: AnchorNavigationProps) { + return ; } applyDisplayName(AnchorNavigation, 'Anchor Navigation'); diff --git a/src/anchor-navigation/interfaces.ts b/src/anchor-navigation/interfaces.ts index 80d26cc4b1..5d251c7659 100644 --- a/src/anchor-navigation/interfaces.ts +++ b/src/anchor-navigation/interfaces.ts @@ -5,9 +5,14 @@ import { CancelableEventHandler } from '../internal/events'; export interface AnchorNavigationProps { /** - * The title of the table of contents, displayed above the anchor items. + * Adds `aria-labelledby` to the component. If you're using this component within a form field, + * don't set this property because the form field component automatically sets it. + * + * Use this property for identifying the header or title that labels the anchor navigation. + * + * To use it correctly, define an ID for the element you want to use as label and set the property to that ID. */ - title?: string; + ariaLabelledby?: string; /** * List of anchors. @@ -16,36 +21,22 @@ export interface AnchorNavigationProps { /** * Specifies the active anchor. - * For using the component in a controlled pattern, use together with 'disableTracking'. + * For using the component in a controlled manner, use together with 'disableTracking'. */ activeAnchor?: AnchorNavigationProps.Anchor; - /** - * The variant of the component: - * 'default' - Use in any context. - * 'expandable' - Allows users to expand or collapse the component to save vertical space. - */ - variant?: 'default' | 'expandable'; - - /** - * Determines whether the component initially displays in expanded state. - * The component operates in an uncontrolled manner even if you provide a value for this property. - * Only applies when the `variant` is set to `expandable`. - */ - defaultExpanded?: boolean; - /** * Option to disable scroll spy. */ disableTracking?: boolean; /** - * Triggered when an anchor link is clicked without any modifier keys. + * Fired when an anchor link is clicked without any modifier keys. */ onFollow?: CancelableEventHandler; /** - * Triggered when an active anchor link changes. + * Fired when an active anchor link changes. */ onActiveAnchorChange?: CancelableEventHandler; } @@ -58,9 +49,10 @@ export namespace AnchorNavigationProps { text: string; /** - * The `id` attribute used to specify a unique HTML element. + * The `id` attribute of the target HTML element to which this anchor refers. + * For example: `"#section1.1"` */ - id: string; + href: string; /** * The level of nesting. diff --git a/src/anchor-navigation/internal.tsx b/src/anchor-navigation/internal.tsx index ca08516e3c..d761e3e37b 100644 --- a/src/anchor-navigation/internal.tsx +++ b/src/anchor-navigation/internal.tsx @@ -1,14 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useMemo } from 'react'; import clsx from 'clsx'; import styles from './styles.css.js'; -import InternalBox from '../box/internal.js'; -import InternalSpaceBetween from '../space-between/internal.js'; import { AnchorNavigationProps } from './interfaces.js'; import { checkSafeUrl } from '../internal/utils/check-safe-url.js'; import useScrollSpy from './scroll-spy.js'; -import InternalExpandableSection from '../expandable-section/internal.js'; const navigateToItem = (event: React.MouseEvent, id: string) => { event.preventDefault(); const href = event.currentTarget.getAttribute('href'); @@ -17,19 +14,20 @@ const navigateToItem = (event: React.MouseEvent, id: string) el?.scrollIntoView(); } }; -const Anchor = ({ id, text, level, isActive }: AnchorNavigationProps.Anchor & { isActive: boolean }) => { - checkSafeUrl('SideNavigation', id); +const Anchor = ({ href, text, level, isActive }: AnchorNavigationProps.Anchor & { isActive: boolean }) => { + checkSafeUrl('SideNavigation', href); return ( -
  • +
  • navigateToItem(e, id)} + onClick={e => navigateToItem(e, href)} className={clsx(styles['anchor-link'], { [styles['anchor-link-active']]: isActive })} - href={`#${id}`} + {...(isActive ? { 'aria-current': true } : {})} + href={href} > {text} @@ -37,36 +35,17 @@ const Anchor = ({ id, text, level, isActive }: AnchorNavigationProps.Anchor & { ); }; -const AnchorList = ({ anchors, activeId }: { anchors: AnchorNavigationProps.Anchor[]; activeId?: string }) => { - return ( -
      - {anchors.map((props, index) => ( - - ))} -
    - ); -}; +export default function InternalAnchorNavigation({ anchors, ariaLabelledby, ...props }: AnchorNavigationProps) { + const hrefs = useMemo(() => anchors.map(anchor => anchor.href), [anchors]); -export default function InternalAnchorNavigation({ anchors, variant, ...props }: AnchorNavigationProps) { - const [activeId] = useScrollSpy({ hrefs: anchors.map(anchor => anchor.id) }); - // console.log(activeHref); - // const activeHref = 'section-1'; + const [activeId] = useScrollSpy({ hrefs }); return ( -
    - - {variant === 'expandable' ? ( - - - - ) : ( - <> - - {props.title} - - - - )} - -
    + ); } diff --git a/src/anchor-navigation/scroll-spy.tsx b/src/anchor-navigation/scroll-spy.tsx index 2ce2433101..f680fbd59b 100644 --- a/src/anchor-navigation/scroll-spy.tsx +++ b/src/anchor-navigation/scroll-spy.tsx @@ -5,86 +5,52 @@ import { useState, useEffect, useRef } from 'react'; export default function useScrollSpy({ hrefs }: { hrefs: string[] }): [string | undefined] { const [activeSlug, setActiveSlug] = useState(undefined); const observerRef = useRef(null); - const isLastItem = useRef(false); - // Only run on mount and unmount useEffect(() => { - if (!observerRef.current) { - observerRef.current = new IntersectionObserver( - entries => { - console.log({ isLastItem }); + if (observerRef.current) { + observerRef.current.disconnect(); + } + + observerRef.current = new IntersectionObserver( + entries => { + let activeSlugTemp; + let smallestIndexInViewport = Infinity; + let largestIndexAboveViewport = -1; - let activeSlugTemp = ''; - if (isLastItem.current) { - activeSlugTemp = hrefs[hrefs.length - 1]; - } else { - let smallestIndexInViewport = Infinity; - let largestIndexAboveViewport = -1; - for (const entry of entries) { - if (entry?.rootBounds) { - const slug = entry.target.id; - const index = hrefs.indexOf(slug); - const aboveHalfViewport = - entry.boundingClientRect.y + entry.boundingClientRect.height <= - entry.rootBounds.y + entry.rootBounds.height; - const insideHalfViewport = entry.intersectionRatio > 0; + for (const entry of entries) { + if (entry?.rootBounds && entry?.boundingClientRect && typeof entry.intersectionRatio === 'number') { + const slug = entry.target.id; + const index = hrefs.indexOf(`#${slug}`); + const aboveHalfViewport = + entry.boundingClientRect.y + entry.boundingClientRect.height <= + entry.rootBounds.y + entry.rootBounds.height; + const insideHalfViewport = entry.intersectionRatio > 0; + + // Check if the element is within the viewport and update the active slug if it's the smallest index seen + if (insideHalfViewport && index < smallestIndexInViewport) { + smallestIndexInViewport = index; + activeSlugTemp = slug; + } - if (insideHalfViewport && index < smallestIndexInViewport) { - smallestIndexInViewport = index; - activeSlugTemp = slug; - } - if (smallestIndexInViewport === Infinity && aboveHalfViewport && index > largestIndexAboveViewport) { - largestIndexAboveViewport = index; - activeSlugTemp = slug; - } - } + // If no entry is found within the viewport, find the entry with the largest index above the viewport + if (smallestIndexInViewport === Infinity && aboveHalfViewport && index > largestIndexAboveViewport) { + largestIndexAboveViewport = index; + activeSlugTemp = slug; } } + } + if (activeSlugTemp !== undefined) { setActiveSlug(activeSlugTemp); - }, - { - rootMargin: '0px 0px -50%', - threshold: [0, 1], } - ); - } - - // const checkScrollBottom = () => { - // const scrollPos = window.innerHeight + window.scrollY; - // if (scrollPos >= document.documentElement.scrollHeight) { - // isLastItem.current = true; - // setActiveSlug(hrefs[hrefs.length - 1]); - // } else if (activeSlug === hrefs[hrefs.length - 1]) { - // const lastElement = document.getElementById(hrefs[hrefs.length - 1]); - // if (lastElement) { - // const rect = lastElement.getBoundingClientRect(); - // if ( - // rect.top <= (window.innerHeight || document.documentElement.clientHeight) && - // activeSlug === hrefs[hrefs.length - 1] - // ) { - // setActiveSlug(hrefs[hrefs.length - 2]); - // isLastItem.current = false; - // } - // } - // } else { - // setActiveSlug(hrefs[hrefs.length - 2]); - // isLastItem.current = false; - // } - // }; - - // window.addEventListener('scroll', checkScrollBottom); - - return () => { - observerRef.current?.disconnect(); - observerRef.current = null; - // window.removeEventListener('scroll', checkScrollBottom); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, + { + rootMargin: '0px 0px -50%', + threshold: [0, 1], + } + ); - // Run whenever hrefs changes - useEffect(() => { - hrefs.forEach(id => { + hrefs.forEach(href => { + const id = href.slice(1); const element = document.getElementById(id); if (element) { observerRef.current?.observe(element); @@ -92,12 +58,18 @@ export default function useScrollSpy({ hrefs }: { hrefs: string[] }): [string | }); return () => { - hrefs.forEach(id => { + hrefs.forEach(href => { + const id = href.slice(1); const element = document.getElementById(id); if (element) { observerRef.current?.unobserve(element); } }); + + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } }; }, [hrefs]);