diff --git a/packages/outline-jump-nav/index.ts b/packages/outline-jump-nav/index.ts new file mode 100644 index 000000000..5ebd7a44f --- /dev/null +++ b/packages/outline-jump-nav/index.ts @@ -0,0 +1,3 @@ +export { OutlineJumpNav } from './src/outline-jump-nav'; +export type {} from './src/outline-jump-nav'; +export {} from './src/outline-jump-nav'; diff --git a/packages/outline-jump-nav/package.json b/packages/outline-jump-nav/package.json new file mode 100644 index 000000000..fcb2d8b85 --- /dev/null +++ b/packages/outline-jump-nav/package.json @@ -0,0 +1,41 @@ +{ + "name": "@phase2/outline-jump-nav", + "version": "0.1.0", + "description": "The Outline Components for the web jump navigation component", + "keywords": [ + "outline", + "web-components", + "design system", + "jump-nav" + ], + "main": "index.ts", + "types": "index.ts", + "typings": "index.d.ts", + "files": [ + "/dist/", + "!/dist/tsconfig.build.tsbuildinfo" + ], + "author": "Phase2 Technology", + "repository": { + "type": "git", + "url": "https://github.com/phase2/outline.git", + "directory": "packages/outline-jump-nav" + }, + "license": "BSD-3-Clause", + "scripts": { + "build": "node ../../scripts/build.js", + "package": "yarn publish" + }, + "dependencies": { + "@phase2/outline-core": "^0.1.0", + "lit": "^2.3.1", + "tslib": "^2.1.0" + }, + "devDependencies": {}, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.ts" + } +} diff --git a/packages/outline-jump-nav/src/outline-jump-nav.css b/packages/outline-jump-nav/src/outline-jump-nav.css new file mode 100644 index 000000000..562a951ee --- /dev/null +++ b/packages/outline-jump-nav/src/outline-jump-nav.css @@ -0,0 +1,78 @@ +:host { + z-index: 2; + display: block; + position: sticky; + top: var(--top-offset); +} + +.outline-jump-nav { + display: flex; + width: 100%; + background-color: darkgrey; +} + +/* Used for storybook */ +.outline-jump-nav--nav { + margin-bottom: 0; + padding: 0.5rem 0; +} + +.outline-jump-nav--container { + width: 100%; + padding: 0.5rem 0; + @media (min-width: 768px) { + padding: 0; + } +} + +.outline-jump-nav--list { + list-style: none; + display: flex; + padding: 0; + margin: 0; +} + +.outline-jump-nav--item { + padding-right: 7%; +} + +.outline-jump-nav--link { + display: flex; + position: relative; + font-weight: 500; + font-family: sans-serif; + text-decoration: none; + color: black; + align-self: center; + padding: 0.5rem 0; +} + +.outline-jump-nav--link:focus-visible { + outline: darkblue 4px solid; + border-radius: 5px; +} + +.active-jump::after { + content: ''; + display: flex; + position: absolute; + width: 100%; + height: 4px; + background-color: darkblue; + bottom: 0; +} + +.outline-jump-nav--select { + padding: 0.5rem 0.25rem; + margin: 0 auto; + background-color: lightskyblue; + font-family: 'Courier New', Courier, monospace; + font-size: 1.25rem; + font-weight: 700; + border-radius: 8px; +} + +.outline-jump-nav--label { + margin: 0 auto; + width: fit-content; +} diff --git a/packages/outline-jump-nav/src/outline-jump-nav.ts b/packages/outline-jump-nav/src/outline-jump-nav.ts new file mode 100644 index 000000000..89fa7c951 --- /dev/null +++ b/packages/outline-jump-nav/src/outline-jump-nav.ts @@ -0,0 +1,469 @@ +import { CSSResultGroup, TemplateResult, html } from 'lit'; +import { OutlineElement } from '@phase2/outline-core'; +import { customElement, property, state, query } from 'lit/decorators.js'; +import componentStyles from './outline-jump-nav.css.lit'; +import { MobileController } from '@phase2/outline-core'; + +export type OutlineJumpNavJumps = { [key: string]: string }; +export type OutlineJumpNavVisibility = { [key: string]: number }; +export const outlineJumpNavStatuses = ['loading', true, false] as const; +export type OutlineJumpNavStatus = typeof outlineJumpNavStatuses[number]; + +/** + * The OutlineJumpNav component + * @element outline-jump-nav + */ +@customElement('outline-jump-nav') +export class OutlineJumpNav extends OutlineElement { + resizeObserver: ResizeObserver; + mobileController = new MobileController(this); + static styles: CSSResultGroup = [componentStyles]; + + /** + * ID of "active" element. + */ + @property({ type: String }) + isActive: string; + + /** + * ID or classname of hero or any scrolling element jump nav may need to start below. + */ + @property({ type: String }) + hero: string; + + /** + * ID or classname of navigation or any kind of sticky element the jump nav may need to remain positioned below. + */ + @property({ type: String }) + nav: string; + + /** + * Jump target id and their % visible on screen. + */ + @state() + visibility: OutlineJumpNavVisibility = {}; + + /** + * ID prefix for targeted sections. + */ + @property({ type: String }) + slug: string; + + /** + * Expected link IDs and their titles. + */ + @property({ type: Object }) + jumps: OutlineJumpNavJumps = {}; + + /** + * Ref to the desktop ul element + */ + @query('.outline-jump-nav--list') + ul: HTMLElement; + + /** + * Ref to the mobile select element + */ + @query('.outline-jump-nav--select') + select: HTMLElement; + + /** + * Indicates if the component is first loading or if a toggle between desktop/mobile is required. + */ + @property({ type: String || Boolean }) + status: OutlineJumpNavStatus = 'loading'; + + /** + * Current height of the jump-nav. Used to determine true viewable space. + */ + navOffset: number; + + /** + * Current height of the main-nav. Used to determine true viewable space. + */ + headerOffset: number; + + /** + * Height of any hero on the page/the position that the jump-nav is supposed to switch to fixed/sticky position. + */ + triggerFixedOffset: number; + + /** + * Wether or not the nav is in 'fixed' position. + */ + fixed: boolean; + + /** + * If the users browser is set to 'prefers-reduced-motion' will prevent scrolling and cleanly jump to selected section. + */ + preventScroll: boolean; + + render(): TemplateResult { + return html`
+ + ${this.mobileController.isMobile + ? this.mobileTemplate() + : this.desktopTemplate()} + +
`; + } + + desktopTemplate() { + return html` + + `; + } + + mobileTemplate() { + return html` + + + `; + } + + firstUpdated() { + this.initializeJumpsAndVisibility(); + this.setOffsets(); + this.toggleLinks(); + this.setReduceMotion(); + this.determineViewStatus(); + this.resizeObserver.observe(this); + } + + updated() { + this.setOffsets(); + this.toggleLinks(); + } + + connectedCallback(): void { + super.connectedCallback(); + + this.resizeObserver = new ResizeObserver(() => { + this.setOffsets(); + }); + + window.addEventListener('scroll', this.determineViewStatus); + window.addEventListener('scroll', this.toggleFixedPosition); + } + + disconnectedCallback(): void { + window.removeEventListener('scroll', this.determineViewStatus); + window.removeEventListener('scroll', this.toggleFixedPosition); + this.resizeObserver.unobserve(this); + super.disconnectedCallback(); + } + + /** + * If user has prefers reduced motion set, prevents scrolling behavior and jumps the page to the link. + */ + setReduceMotion() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.preventScroll = true; + } + } + + /** + * Finds all elements with an id that begins with the slug, and creates the jumps object + * { element id: link text } + */ + initializeJumpsAndVisibility() { + const targetedElements = document.querySelectorAll(`[id^="${this.slug}"]`); + targetedElements?.forEach(element => { + const name = element.id.split('--')[1]; + this.jumps[`${element.id}`] = name; + }); + this.initializeState(); + } + + /** + * Generates the visibility object. + */ + initializeState() { + Object.keys(this.jumps).forEach(key => (this.visibility[key] = 0)); + } + + /** + * Sets the css var for the navs fixed position. + */ + setTopVar(height: number) { + this.style.setProperty('--top-offset', `${height}px`); + } + + /** + * Generates links from this.jumps object. + */ + generateLinks() { + Object.entries(this.jumps).forEach(jump => { + const li = document.createElement('li'); + const anchor = document.createElement('a'); + + li.classList.add('outline-jump-nav--item'); + li.addEventListener('click', this.scrollHandler); + + anchor.classList.add('outline-jump-nav--link'); + anchor.id = `${jump[0]}-jump`; + anchor.setAttribute('href', `#${jump[0]}`); + anchor.setAttribute('aria-label', `scroll to ${jump[1]}`); + + // insert your preferred string processing here + anchor.innerText = jump[1].toUpperCase(); + + li.appendChild(anchor); + this.ul.appendChild(li); + }); + } + + /** + * Generates mobile select options from this.jumps object. + */ + generateMobileSelectOptions() { + Object.entries(this.jumps).forEach(jump => { + const option = document.createElement('option'); + option.setAttribute('value', `${jump[0]}`); + + // insert your preferred string processing here + option.innerText = `${jump[1]}`.toUpperCase(); + this.select.appendChild(option); + }); + } + + /** + * Generates correct markup depending on screen width. Forces setActive to make sure all styles are toggled. + */ + toggleLinks() { + if (this.mobileController.isMobile === this.status) { + return; + } else { + this.mobileController.isMobile + ? this.generateMobileSelectOptions() + : this.generateLinks(); + this.status = this.mobileController.isMobile; + } + if (this.isActive) { + this.setActive(this.isActive, true); + } + } + + /** + * On click/change "scroll handler" to initiate scrolling. + */ + scrollHandler(e: Event) { + e.preventDefault(); + const host = document.querySelector('outline-jump-nav') as OutlineJumpNav; + let target; + let targetHref; + + if (host.mobileController.isMobile) { + target = e.target as HTMLOptionElement; + targetHref = `#${target.value}`; + } + if (!host.mobileController.isMobile) { + target = e.target as HTMLAnchorElement; + targetHref = target.getAttribute('href'); + } + + const scrollTarget = document.querySelector(`${targetHref}`) as HTMLElement; + + if (scrollTarget) { + const correctedTop = + scrollTarget.offsetTop - (host.headerOffset + host.navOffset); + window.scroll({ + top: correctedTop, + behavior: host.preventScroll ? 'auto' : 'smooth', + }); + } + } + + /** + * If an element has moved in/out of view, updates the state object and calls + * this.setActiveOnStateUpdate to determine if the active link should be changed. + */ + updateState(id: string, percentage: number) { + if (this.visibility[id] !== percentage) { + this.visibility[id] = percentage; + + this.setActiveOnStateUpdate(); + } + } + + /** + * Called when the component reaches/exceeds triggerFixedOffset point and applies/removes is-fixed class. + */ + toggleFixedPosition() { + const host = document.querySelector('outline-jump-nav') as OutlineJumpNav; + const nav = this.shadowRoot?.querySelector( + '.outline-jump-nav' + ) as HTMLElement; + + if (nav && window.scrollY >= host.triggerFixedOffset) { + nav.classList.add('is-fixed'); + host.fixed = true; + } + if (nav && window.scrollY < host.triggerFixedOffset && host.fixed) { + nav.classList.remove('is-fixed'); + host.fixed = false; + } + } + + /** + * Determines headerOffset, topOffset, and triggerFixedOffset properties for use in calculations. + */ + setOffsets() { + const header = document.querySelector(this.nav); + const headerHeight = header ? header.getBoundingClientRect().height : 0; + const hero = document.querySelector(this.hero); + const heroHeight = hero ? hero?.getBoundingClientRect().height : 0; + const nav = document.querySelector('outline-jump-nav'); + this.navOffset = nav ? nav.getBoundingClientRect().height : 0; + this.headerOffset = headerHeight; + this.triggerFixedOffset = heroHeight; + this.setTopVar(this.headerOffset); + } + + /** + * Passes all present elements with jump links to this.isInView. + */ + determineViewStatus() { + const host = document.querySelector('outline-jump-nav') as OutlineJumpNav; + Object.keys(host.jumps).forEach(key => { + const element = document.querySelector(`#${key}`) as HTMLElement; + return host.isInView(element); + }); + } + + /** + * Takes in an element and returns an object comprised of several positioning values used in multiple methods. + */ + getPositioningValues(el: HTMLElement) { + const windowTop = window.scrollY; + const windowBottom = windowTop + window.innerHeight; + const elRect = el.getBoundingClientRect(); + const elTop = elRect.y + windowTop - (this.navOffset + this.headerOffset); + const elBottom = elRect.y + elRect.height + windowTop; + const elHeight = elRect.height; + + return { + windowTop: windowTop, + windowBottom: windowBottom, + elTop: elTop, + elBottom: elBottom, + elHeight: elHeight, + }; + } + + /** + * Determines if an element is in view or not. + * If only partially in view passes the element id to this.elementXPercentInViewport. + */ + isInView(el: HTMLElement) { + const { elTop, elBottom, windowTop, windowBottom } = + this.getPositioningValues(el); + + const isVis = !(elTop > windowBottom || elBottom < windowTop); + const notVis = elTop > windowBottom || elBottom < windowTop; + const allVis = + (elTop >= windowTop && elBottom <= windowBottom) || + (elTop < windowTop && elBottom > windowBottom); + + if (notVis) { + return this.updateState(el.id, 0); + } + + if (allVis) { + return this.updateState(el.id, 100); + } + + if (isVis) { + return this.elementXPercentInViewport(el); + } + } + + /** + * Determines the percentage of the element in view. + */ + elementXPercentInViewport(el: HTMLElement) { + const { elTop, elBottom, elHeight, windowTop, windowBottom } = + this.getPositioningValues(el); + + if (elBottom > windowBottom) { + const visPx = elHeight - (elBottom - windowBottom); + return this.updateState(el.id, Math.round((visPx / elHeight) * 100)); + } + if (elTop < windowTop) { + const visPx = elHeight - (windowTop - elTop); + return this.updateState(el.id, Math.round((visPx / elHeight) * 100)); + } + } + + /** + * When the visibility state object is updated Compares % visible values and either passes the + * most visible elements ID to this.setActive or if more than one have the same value + * passes them to this.getTopPositions to determine which is the highest on the page. + */ + setActiveOnStateUpdate() { + const hightestPercentageVisible = Math.max( + ...Object.values(this.visibility) + ); + + const mostVisibleElements = Object.entries(this.visibility).filter( + ele => ele[1] === hightestPercentageVisible + ); + + if (mostVisibleElements.length > 1) { + return this.getTopPositions(mostVisibleElements); + } else { + const id: string = mostVisibleElements[0][0]; + return this.setActive(id); + } + } + + /** + * Sorts through multiple elements that are the same percentage in view, and passes the id of the element highest on the page to setActive. + */ + getTopPositions(ids: [string, number][]) { + const topPositions: { [key: string]: number } = {}; + ids.map(ele => { + if (document.querySelector(`#${ele[0]}`)?.getBoundingClientRect().top) { + const id = ele[0] as string; + // @ts-expect-error - because-ts + return (topPositions[id] = document + .querySelector(`#${ele[0]}`) + ?.getBoundingClientRect().top); + } else { + return null; + } + }); + const activeID = Object.keys(topPositions).find( + key => topPositions[key] === Math.min(...Object.values(topPositions)) + )!; + + return this.setActive(activeID); + } + + /** + * Takes an ID and if not already the active ID, sets it as this.isActive, then handles the passing of the active-jump class to the correct link. + * The force argument is used when the component switches between mobile and desktop to make sure the active class is applied. + */ + setActive(id: string, force?: boolean) { + if (this.isActive !== id || force === true) { + this.isActive = id; + this.shadowRoot + ?.querySelector(`.active-jump`) + ?.classList.remove('active-jump'); + this.shadowRoot + ?.querySelector(`#${id}-jump`) + ?.classList.add('active-jump'); + + if (this.mobileController.isMobile) { + const selector = this.select as HTMLSelectElement; + selector.value = id; + } + } + } +} diff --git a/packages/outline-jump-nav/tsconfig.build.json b/packages/outline-jump-nav/tsconfig.build.json new file mode 100644 index 000000000..ebc8e4b8e --- /dev/null +++ b/packages/outline-jump-nav/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + }, + "include": ["index.ts", "src/**/*", "tests/**/*"], + "references": [{ "path": "../outline-core/tsconfig.build.json" }] +} diff --git a/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts new file mode 100644 index 000000000..a5208bb51 --- /dev/null +++ b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { html, TemplateResult } from 'lit'; +import '../../../outline-jump-nav/index'; +import '@phase2/outline-container'; + + +export default { + title: 'Navigation/JumpNav', + component: 'outline-jump-nav', + argTypes: {}, + args: { + slug: 'outline-jump-nav--', + nav: '.pseudo-nav', + hero:'.pseudo-hero' + }, + parameters: { + docs: { + description: { + component: ` +## The \`outline-jump-nav\` element +# Props: +* slug (\`string\`) **Required**: ID prefix for required for targeted sections. Must end with \`--\`. Example: \`outline-jump-nav--\` +* nav (\`string\`): ID or class name query selector of navigation or any kind of sticky element the jump nav may need to remain positioned below. Example: \`.header-class\` +* hero (\`string\`): ID or class name query selector of hero or any scrolling element jump nav may need to remain below when scrolled into view. Example: \`#hero-id\` + +# Usage: +

Place on a page with multiple sections you wish to be able to scroll to. Each must have an id that follows this pattern [slug]--[link-tab-name]. +
\`Example:\` **outline-jump-nav--light-green** +
The slug[outline-jump-nav--] identifies the element, and the text after the \`--\` of the slug sets the link name used on the jump-nav itself. +
The "active" tab is set on scroll on the target element with the highest percentage of itself visible in the viewable area between the bottom of the jump-nav and the bottom of the screen. +

+ +### Notes: +

**Do not test here on the Docs page. There are too many scrolling windows.**

+ +`, + }, + source: { + code: ` +
+ +
Main Navigation
+
Hero
+ +
Light Green
+
Light-Blue
+
Yellow
+
Red
+
Purple
+ +
+
+ `, + }, + }, + }, +}; + +const Template = ({ slug, nav, hero }: any): TemplateResult => html` +
+ +
Main Navigation
+
Hero
+ +
Light Green
+
Light Blue
+
Yellow
+
Red
+
Purple
+ +
+
+` + + +export const JumpNav: any = Template.bind({}); +JumpNav.args = {slug: 'outline-jump-nav--'} +JumpNav.parameters = { + layout: 'fullscreen' +}; + +