Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

report(redesign): add sticky scores header #8524

Merged
merged 23 commits into from
Apr 25, 2019
Merged

Conversation

connorjclark
Copy link
Collaborator

#8185

sticky header w/ mini gauges
...appears when scrolling past score scale
...current category in view is underlined

@patrickhulce
Copy link
Collaborator

patrickhulce commented Apr 22, 2019

Nice!!
The only thing that jumps out to me is that the plugin gets underlined before the PWA category as you scroll and the PWA only gets underlined after you scroll past it. Seems like they should be flipped but I guess I'll find out in the code in a second :)

EDIT: Yep! it's flagged as #8524 (comment)

lighthouse-core/report/html/renderer/report-renderer.js Outdated Show resolved Hide resolved
lighthouse-core/report/html/templates.html Outdated Show resolved Hide resolved
// Show sticky header when the score scale begins to go underneath the topbar.
const showStickyHeader =
topbarEl.getBoundingClientRect().bottom - scoreScaleEl.getBoundingClientRect().top >= 0;
stickyHeaderEl.classList.toggle('stuck', showStickyHeader);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use lh-sticky-header--stuck? :)

// is the one "in view".
let highlightIndex = 0;
let highlightIndexDistance = Number.POSITIVE_INFINITY;
for (const [index, categoryEl] of Object.entries(document.querySelectorAll('.lh-category'))) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this index-based approach is what is causing the behavior I was seeing with plugin and PWA flipped. Do we want the gauges to match their appearance order in the report instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just confirming you are correct in your next comment - this code will work as expected when the gauge order is as expected.

}

// Category order matches gauge order in sticky header.
// TODO(hoten): not 100% true yet, need to order gauges like: core, pwa, plugins. Remove
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, I see :)

historically comments without a direct issue link tend to be forgotten, is that PR like the next one though?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a task entry in the report-redesign issue for this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah of course!

Copy link
Collaborator

@patrickhulce patrickhulce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry shouldn't have prematurely flushed!

lighthouse-core/report/html/templates.html Show resolved Hide resolved
// Use the middle of the viewport as an anchor - the closest category to the middle
// is the one "in view".
let highlightIndex = 0;
let highlightIndexDistance = Number.POSITIVE_INFINITY;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, why not Infinity? :)

// Normalize to middle of viewport.
const distanceToMiddle = categoryEl.getBoundingClientRect().top - window.innerHeight / 2;
// Closest negative distance to zero wins.
if (distanceToMiddle < 0 && highlightIndexDistance > distanceToMiddle) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like we could simplify some of this but maybe I'm misunderstanding

  • highlightIndexDistance will only ever update when distanceToMiddle is negative
  • highlightIndexDistance is -distanceToMiddle, so highlightIndexDistance is always positive
  • highlightIndexDistance will always be greater than distanceToMiddle if distanceToMiddle is negative, so it's really just if (distanceToMiddle < 0)

this seems equivalent to "we highlight the last category that starts above the middle of the window" which at least for me is a lot easier to understand.

maybe something like the below?

const categoriesAboveTheMiddle = categories.filter(el => el.getBoundingClientRect().top - window.innerHeight / 2)
const categoryToHighlight = categoriesAboveTheMiddle[categoriesAboveTheMiddle.length - 1]
const guageToHighlight = gauges[categories.indexOf(categoryToHighlight)]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this works:

// Highlight mini gauge when section is in view.
// In view = the last category that starts above the middle of the window.
const categoryEls = Array.from(this._document.querySelectorAll('.lh-category'));
const categoriesAboveTheMiddle =
  categoryEls.filter(el => el.getBoundingClientRect().top - window.innerHeight / 2 < 0);
const highlightIndex =
  categoriesAboveTheMiddle.length > 0 ? categoriesAboveTheMiddle.length - 1 : 0;

// Category order matches gauge order in sticky header.
// TODO(hoten): not 100% true yet, need to order gauges like: core, pwa, plugins. Remove
// this comment when that is done.
const gaugeToHighlight = stickyHeaderEl.querySelectorAll('.lh-gauge__wrapper')[highlightIndex];
// @ts-ignore
highlightEl.style.left = gaugeToHighlight.getBoundingClientRect().left + 'px';

thanks for making more declarative, i'll update

// TODO(hoten): not 100% true yet, need to order gauges like: core, pwa, plugins. Remove
// this comment when that is done.
const gaugeToHighlight = stickyHeaderEl.querySelectorAll('.lh-gauge__wrapper')[highlightIndex];
// @ts-ignore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are we ignoring?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Property 'style' does not exist on type 'Element' idk why

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be HTMLElement

Copy link
Collaborator

@patrickhulce patrickhulce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

// TODO(hoten): not 100% true yet, need to order gauges like: core, pwa, plugins. Remove
// this comment when that is done.
const gaugeToHighlight = stickyHeaderEl.querySelectorAll('.lh-gauge__wrapper')[highlightIndex];
(/** @type {HTMLElement} */ (highlightEl)).style.left =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah I guess querySelector can return things that aren't HTMLElements, bummer! Maybe make the assertion at the top where it's declared?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just refactored to use _dom + make member vars

this._setUpCollapseDetailsAfterPrinting();
this._resetUIState();
this._document.addEventListener('keydown', this.printShortCutDetect);
this._document.addEventListener('copy', this.onCopy);
this._document.addEventListener('scroll', this._handleStickyHeader);
// window.addEventListener is undefined in jest tests.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which test file(s) fails? This sounds like jsdom wasn't set up correctly

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup that was it, fixed.

@@ -80,7 +80,7 @@ describe('ReportRenderer', () => {
assert.ok(output.querySelector('.lh-header-sticky'), 'has a header');
assert.ok(output.querySelector('.lh-report'), 'has report body');
assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
sampleResults.reportCategories.length * 2, 'renders category gauges');
sampleResults.reportCategories.length * 3, 'renders category gauges');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add a comment for where this multiplier comes from


// The sticky header is just the score gauges, but styled to be smaller. Just
// clone the gauges from the score header.
for (const gaugeWrapperEl of this._dom.findAll('.lh-gauge__wrapper', scoreHeader)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels pretty squicky to me :)

Can we make a renderScoresHeader() method and use it in both places?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -380,6 +402,29 @@ class ReportUIFeatures {
this._document.body.removeChild(a);
setTimeout(_ => URL.revokeObjectURL(href), 500);
}

_handleStickyHeader() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe rename to make it clear this is for adjusting the sticky header on scroll events?

Copy link
Collaborator Author

@connorjclark connorjclark Apr 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It handles sticky header visibility, and the highlighting. It also fires on scroll and resize.

_handleStickyHeaderVisibilityAndHighlighterOnScrollAndOnResize? :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha, I was thinking more like _updateStickyHeaderOnScroll or whatever :)

Arguably (it's a stretch :) resize is a special case of scrolling for this element, resizing is uncommon compared to scrolling, and the main thing is the name can communicate something about what it's up to and when

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sold, to _updateStickyHeaderOnScroll

Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few more things, but LGTM!

/**
* @param {LH.ReportResult} report
* @param {CategoryRenderer} categoryRenderer
* @param {Record<string, CategoryRenderer>} specificCategoryRenderers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

categoryRenderer and specificCategoryRenderers probably need docstrings (sorry for that second name :)

scoreHeader.append(...defaultGauges, ...customGauges, ...pluginGauges);

scoreHeader.append(
...this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind of hard to read the spread after a line break like this. Maybe save to a local var first?

this._dom.createChildOf(stickyHeader, 'div', 'lh-highlighter');

// The sticky header is just the score gauges, but styled to be smaller. Just
// clone the gauges from the score header.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not cloned anymore

// The sticky header is just the score gauges, but styled to be smaller. Just
// clone the gauges from the score header.
stickyHeader.append(
...this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same thing with the line break and spread as above

@@ -380,6 +399,29 @@ class ReportUIFeatures {
this._document.body.removeChild(a);
setTimeout(_ => URL.revokeObjectURL(href), 500);
}

_updateStickyHeaderOnScroll() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be called once on startup?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be called once on startup?

I guess not since it starts out hidden

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL - https://stackoverflow.com/a/34095654

The scroll event does not fire on every load, only when refreshing a page that was scrolled, or when navigating to an anchor directly.

So fresh view (top of page), state should be hidden, and the default is just that. Refresh after scrolling a bit, or click on a link w/ an anchor to the report, and a scroll event fires.

const showStickyHeader = topbarBottom >= scoreScaleTop;
this.stickyHeaderEl.classList.toggle('lh-sticky-header--visible', showStickyHeader);

// Highlight mini gauge when section is in view.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

early return if showStickyHeader === false?

if (!this._dom.isDevTools()) {
const topbarDocumentFragment = this._renderReportTopbar(report);
reportFragment.appendChild(topbarDocumentFragment);
}

if (scoreHeader && !this._dom.isDevTools()) {
const stickyHeader = this._dom.createElement('div', 'lh-sticky-header');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need a test for this, though jsdom (AFAIK) doesn't support scrolling, so it will have to be a limited one. Since we have a nice complete "renders score gauges in this order" test, maybe just a test that .lh-sticky-header has child gauges that match the ones in .lh-scores-header?

Or something like that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a test that .lh-sticky-header has child gauges that match the ones in .lh-scores-header?

imo that doesn't seem like a useful test, since these gauges are created with the same function.

jsdom (AFAIK) doesn't support scrolling

jsdom/jsdom#1422 (comment) this makes me think it should be possible to fake it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok it'd be way too much faking, basically all the getBoundingClientRect needs to be mocked

@brendankenny
Copy link
Member

will also need a PR rename.

@paulirish do you feel comfortable with the overhead of this scroll handler? Seems comparable to the one we had before

@connorjclark connorjclark changed the title Report ui sticky header report(redesign): sticky header Apr 25, 2019
@connorjclark
Copy link
Collaborator Author

Attempted a rebase - it's a lot of conflicts over many commits. Resolving them is not realistic imo.

I ran all the tests locally and they passed.

@brendankenny
Copy link
Member

Attempted a rebase - it's a lot of conflicts over many commits. Resolving them is not realistic imo.

just merge? a rebase isn't necessary

I ran all the tests locally and they passed.

I got burned the last two times I overrode the Travis check, so I'd rather not do that anymore

@connorjclark
Copy link
Collaborator Author

connorjclark commented Apr 25, 2019 via email

@brendankenny
Copy link
Member

Looks like it was the NO_TRACING_STARTED flakiness. Green now

@brendankenny brendankenny changed the title report(redesign): sticky header report(redesign): add sticky scores header Apr 25, 2019
@brendankenny
Copy link
Member

@paulirish had some performance feedback

const topbarBottom = this.topbarEl.getBoundingClientRect().bottom;
const scoreScaleTop = this.scoreScaleEl.getBoundingClientRect().top;
const showStickyHeader = topbarBottom >= scoreScaleTop;
this.stickyHeaderEl.classList.toggle('lh-sticky-header--visible', showStickyHeader);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll want to change this at the bottom.

otherwise we force a layout within this function.

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting... The order is the same, so is the issue that code in between these two changes allows for the browser to jump in and do an extra layout? Idk how layouts work

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

@connorjclark connorjclark Apr 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, makes sense.

AFAICT from the performance tab w/ this code, there are no extra layouts. One change I saw for when the sticky header toggles - "Recalculate Styles" was within the update function, but w/ your changes it ran just after on the main thread. Would like to know if I'm reading the performance timeline wrong.

Even if there technically isn't a layout cost, moving the DOM mutation to the bottom SGTM b/c it makes it obvious there is no layout thrashing.

@paulirish paulirish merged commit 7ea30c7 into master Apr 25, 2019
@paulirish paulirish deleted the report-ui-sticky-header branch April 25, 2019 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants