From ac251a8596262373f6b5c844a32c02fdebdc0ccb Mon Sep 17 00:00:00 2001 From: IMGoodrich Date: Tue, 13 Dec 2022 15:51:38 -0800 Subject: [PATCH 1/6] feat(outline-jump-nav): add outline-jump-nav and stories. --- packages/outline-jump-nav/index.ts | 3 + packages/outline-jump-nav/package.json | 41 ++ .../outline-jump-nav/src/outline-jump-nav.css | 59 +++ .../outline-jump-nav/src/outline-jump-nav.ts | 387 ++++++++++++++++++ packages/outline-jump-nav/tsconfig.build.json | 9 + .../config/storybook.main.css | 20 +- .../components/outline-jump-nav.stories.ts | 82 ++++ 7 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 packages/outline-jump-nav/index.ts create mode 100644 packages/outline-jump-nav/package.json create mode 100644 packages/outline-jump-nav/src/outline-jump-nav.css create mode 100644 packages/outline-jump-nav/src/outline-jump-nav.ts create mode 100644 packages/outline-jump-nav/tsconfig.build.json create mode 100644 packages/outline-storybook/stories/components/outline-jump-nav.stories.ts 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..049ee9738 --- /dev/null +++ b/packages/outline-jump-nav/src/outline-jump-nav.css @@ -0,0 +1,59 @@ +: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%; +} + +.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; +} 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..a46353058 --- /dev/null +++ b/packages/outline-jump-nav/src/outline-jump-nav.ts @@ -0,0 +1,387 @@ +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'; + +export type OutlineJumpNavJumps = { [key: string]: string }; +export type OutlineJumpNavVisibility = { [key: string]: number }; + +/** + * The OutlineJumpNav component + * @element outline-jump-nav + */ +@customElement('outline-jump-nav') +export class OutlineJumpNav extends OutlineElement { + resizeObserver: ResizeObserver; + 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 ul element + */ + @query('.outline-jump-nav--list') + ul: HTMLElement; + + /** + * 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`
+ + + +
`; + } + firstUpdated() { + this.initializeJumpsAndVisibility(); + this.setOffsets(); + this.generateLinks(); + this.setReduceMotion(); + this.determineViewStatus(); + this.resizeObserver.observe(this); + } + + updated() { + this.setOffsets(); + } + + 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(); + } + + 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]}`); + anchor.innerText = jump[1].toUpperCase(); + + li.appendChild(anchor); + this.ul.appendChild(li); + }); + } + + /** + * On click "scroll handler" to initiate scrolling when jump link is clicked. + */ + scrollHandler(e: Event) { + e.preventDefault(); + const host = document.querySelector('outline-jump-nav') as OutlineJumpNav; + const target = e.target as HTMLAnchorElement; + const 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 if 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. + */ + setActive(id: string) { + if (this.isActive !== id) { + this.isActive = id; + this.shadowRoot + ?.querySelector(`.active-jump`) + ?.classList.remove('active-jump'); + this.shadowRoot + ?.querySelector(`#${id}-jump`) + ?.classList.add('active-jump'); + } + } +} 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/config/storybook.main.css b/packages/outline-storybook/config/storybook.main.css index 842d2f7e1..60c6cdcb7 100644 --- a/packages/outline-storybook/config/storybook.main.css +++ b/packages/outline-storybook/config/storybook.main.css @@ -455,7 +455,7 @@ video{ border-color: currentColor; } -.shadow-md, .shadow-xl, .shadow{ +.shadow-xl, .shadow, .shadow-md{ --tw-ring-offset-shadow: 0 0 #0000; --tw-ring-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000; @@ -506,6 +506,10 @@ video{ .relative{ position: relative; +} +.sticky{ + position: sticky; + } .col-start-3{ grid-column-start: 3; @@ -948,13 +952,6 @@ video{ .no-underline{ text-decoration: none; -} -.shadow-md{ - --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); - box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); - } .shadow-xl{ --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); @@ -969,6 +966,13 @@ video{ box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} +.shadow-md{ + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } .outline{ outline-style: solid; 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..d01701196 --- /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 JumpNavNoJumps: any = Template.bind({}); +JumpNavNoJumps.args = {slug: 'outline-jump-nav--'} +JumpNavNoJumps.parameters = { + layout: 'fullscreen' +}; + + From 26a15a57fe2ffd8d0968a20b9591c3d38469e23c Mon Sep 17 00:00:00 2001 From: IMGoodrich Date: Tue, 13 Dec 2022 16:21:31 -0800 Subject: [PATCH 2/6] fix(outline-jump-nav): Format. --- packages/outline-jump-nav/src/outline-jump-nav.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/outline-jump-nav/src/outline-jump-nav.ts b/packages/outline-jump-nav/src/outline-jump-nav.ts index a46353058..6bfb2cd6f 100644 --- a/packages/outline-jump-nav/src/outline-jump-nav.ts +++ b/packages/outline-jump-nav/src/outline-jump-nav.ts @@ -1,11 +1,6 @@ import { CSSResultGroup, TemplateResult, html } from 'lit'; import { OutlineElement } from '@phase2/outline-core'; -import { - customElement, - property, - state, - query, -} from 'lit/decorators.js'; +import { customElement, property, state, query } from 'lit/decorators.js'; import componentStyles from './outline-jump-nav.css.lit'; export type OutlineJumpNavJumps = { [key: string]: string }; @@ -138,9 +133,7 @@ export class OutlineJumpNav extends OutlineElement { * { element id: link text } */ initializeJumpsAndVisibility() { - const targetedElements = document.querySelectorAll( - `[id^="${this.slug}"]` - ); + const targetedElements = document.querySelectorAll(`[id^="${this.slug}"]`); targetedElements?.forEach(element => { const name = element.id.split('--')[1]; this.jumps[`${element.id}`] = name; From 87b4929b21aa450f3a2483eaff584e87a7123f24 Mon Sep 17 00:00:00 2001 From: IMGoodrich Date: Tue, 13 Dec 2022 16:35:51 -0800 Subject: [PATCH 3/6] fix(outline): Format. --- .../stories/components/outline-container.mdx | 82 +++++++++++-------- .../outline-templates/default/tsconfig.json | 2 +- 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/packages/outline-storybook/stories/components/outline-container.mdx b/packages/outline-storybook/stories/components/outline-container.mdx index 72620b397..5ca766490 100644 --- a/packages/outline-storybook/stories/components/outline-container.mdx +++ b/packages/outline-storybook/stories/components/outline-container.mdx @@ -46,16 +46,18 @@ import code7 from '@phase2/outline-static-assets/media/tech/1440/code-7.jpg'; ## Examples - +container-width="full" +top-margin="spacing-12" +bottom-margin="spacing-12" + +> + Container: Full

This `outline-container` component is using the `container-width` set to `full`.

+
- +container-width="wide" +top-margin="spacing-12" +bottom-margin="spacing-12" + +> + Container: Wide

This `outline-container` component is using the `container-width` set to `wide`.

+
- +container-width="medium" +top-margin="spacing-12" +bottom-margin="spacing-12" + +> + Container: Medium

This `outline-container` component is using the `container-width` set to `medium`.

+
- +container-width="narrow" +top-margin="spacing-12" +bottom-margin="spacing-12" +component-spacing="spacing-6" + +> + Container: Narrow

This `outline-container` component is using the `container-width` set to `narrow`.

+
- +container-width="medium" +top-margin="spacing-12" +bottom-margin="spacing-12" +component-spacing="spacing-6" +justify-end + +> + Container: Medium (Right)

This `outline-container` component is using the `container-width` set to @@ -139,6 +148,7 @@ import code7 from '@phase2/outline-static-assets/media/tech/1440/code-7.jpg'; The `justify-end` attribute is used in order to align the content to the right.

+
- +container-width="medium" +top-margin="spacing-12" +bottom-margin="spacing-12" +component-spacing="spacing-6" +justify-start + +> + Container: Medium (Left)

This `outline-container` component is using the `container-width` set to @@ -164,6 +175,7 @@ import code7 from '@phase2/outline-static-assets/media/tech/1440/code-7.jpg'; The `justify-start` attribute is used in order to align the content to the left.

+
Date: Wed, 14 Dec 2022 13:20:39 -0800 Subject: [PATCH 4/6] feat(outline-jump-nav): add mobile select dropdown. --- .../outline-jump-nav/src/outline-jump-nav.css | 19 ++++ .../outline-jump-nav/src/outline-jump-nav.ts | 107 ++++++++++++++++-- .../components/outline-jump-nav.stories.ts | 6 +- 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/packages/outline-jump-nav/src/outline-jump-nav.css b/packages/outline-jump-nav/src/outline-jump-nav.css index 049ee9738..562a951ee 100644 --- a/packages/outline-jump-nav/src/outline-jump-nav.css +++ b/packages/outline-jump-nav/src/outline-jump-nav.css @@ -19,6 +19,10 @@ .outline-jump-nav--container { width: 100%; + padding: 0.5rem 0; + @media (min-width: 768px) { + padding: 0; + } } .outline-jump-nav--list { @@ -57,3 +61,18 @@ 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 index 6bfb2cd6f..0911a071c 100644 --- a/packages/outline-jump-nav/src/outline-jump-nav.ts +++ b/packages/outline-jump-nav/src/outline-jump-nav.ts @@ -2,9 +2,12 @@ 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 @@ -13,6 +16,7 @@ export type OutlineJumpNavVisibility = { [key: string]: number }; @customElement('outline-jump-nav') export class OutlineJumpNav extends OutlineElement { resizeObserver: ResizeObserver; + mobileController = new MobileController(this); static styles: CSSResultGroup = [componentStyles]; /** @@ -52,11 +56,23 @@ export class OutlineJumpNav extends OutlineElement { jumps: OutlineJumpNavJumps = {}; /** - * Ref to the ul element + * 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. */ @@ -85,16 +101,36 @@ export class OutlineJumpNav extends OutlineElement { render(): TemplateResult { return html`
- + ${this.mobileController.isMobile + ? this.mobileTemplate() + : this.desktopTemplate()}
`; } + + desktopTemplate() { + return html` + + `; + } + + mobileTemplate() { + return html` + + + `; + } + firstUpdated() { this.initializeJumpsAndVisibility(); this.setOffsets(); - this.generateLinks(); + this.toggleLinks(); this.setReduceMotion(); this.determineViewStatus(); this.resizeObserver.observe(this); @@ -102,6 +138,7 @@ export class OutlineJumpNav extends OutlineElement { updated() { this.setOffsets(); + this.toggleLinks(); } connectedCallback(): void { @@ -122,6 +159,9 @@ export class OutlineJumpNav extends OutlineElement { 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; @@ -178,13 +218,52 @@ export class OutlineJumpNav extends OutlineElement { } /** - * On click "scroll handler" to initiate scrolling when jump link is clicked. + * 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]}`); + 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; - const target = e.target as HTMLAnchorElement; - const targetHref = target.getAttribute('href'); + 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) { @@ -341,7 +420,7 @@ export class OutlineJumpNav extends OutlineElement { } /** - * Sorts through multiple elements that are the same percentage in view, and passes the if of the element highest on the page to setActive. + * 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 } = {}; @@ -365,9 +444,10 @@ export class OutlineJumpNav extends OutlineElement { /** * 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) { - if (this.isActive !== id) { + setActive(id: string, force?: boolean) { + if (this.isActive !== id || force === true) { this.isActive = id; this.shadowRoot ?.querySelector(`.active-jump`) @@ -375,6 +455,11 @@ export class OutlineJumpNav extends OutlineElement { 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-storybook/stories/components/outline-jump-nav.stories.ts b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts index d01701196..a5208bb51 100644 --- a/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts +++ b/packages/outline-storybook/stories/components/outline-jump-nav.stories.ts @@ -73,9 +73,9 @@ const Template = ({ slug, nav, hero }: any): TemplateResult => html` ` -export const JumpNavNoJumps: any = Template.bind({}); -JumpNavNoJumps.args = {slug: 'outline-jump-nav--'} -JumpNavNoJumps.parameters = { +export const JumpNav: any = Template.bind({}); +JumpNav.args = {slug: 'outline-jump-nav--'} +JumpNav.parameters = { layout: 'fullscreen' }; From fe5b52c5b6cf06f38ed7bb7b261d83c98f831b46 Mon Sep 17 00:00:00 2001 From: IMGoodrich Date: Wed, 14 Dec 2022 13:21:28 -0800 Subject: [PATCH 5/6] feat(main): css update --- .../outline-storybook/config/storybook.main.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/outline-storybook/config/storybook.main.css b/packages/outline-storybook/config/storybook.main.css index 829d56ae4..123ff7236 100644 --- a/packages/outline-storybook/config/storybook.main.css +++ b/packages/outline-storybook/config/storybook.main.css @@ -455,7 +455,7 @@ video{ border-color: currentColor; } -.shadow, .shadow-md, .shadow-xl{ +.shadow-xl, .shadow, .shadow-md{ --tw-ring-offset-shadow: 0 0 #0000; --tw-ring-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000; @@ -952,6 +952,13 @@ video{ .no-underline{ text-decoration: none; +} +.shadow-xl{ + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } .shadow{ --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); @@ -966,13 +973,6 @@ video{ box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} -.shadow-xl{ - --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); - box-shadow: 0 0 #0000, 0 0 #0000, var(--tw-shadow); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); - } .outline{ outline-style: solid; From c1ff424148442e78985fa813870f53dcdea5d175 Mon Sep 17 00:00:00 2001 From: IMGoodrich Date: Wed, 14 Dec 2022 13:32:27 -0800 Subject: [PATCH 6/6] chore(outline-jump-nav): update docs --- packages/outline-jump-nav/src/outline-jump-nav.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/outline-jump-nav/src/outline-jump-nav.ts b/packages/outline-jump-nav/src/outline-jump-nav.ts index 0911a071c..89fa7c951 100644 --- a/packages/outline-jump-nav/src/outline-jump-nav.ts +++ b/packages/outline-jump-nav/src/outline-jump-nav.ts @@ -210,6 +210,8 @@ export class OutlineJumpNav extends OutlineElement { 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); @@ -224,6 +226,8 @@ export class OutlineJumpNav extends OutlineElement { 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); });