Skip to content

Commit

Permalink
WEBDEV-6386 Refactor facets to extract individual facet row component (
Browse files Browse the repository at this point in the history
…#297)

* Simplify facet event model

* Create new component for individual facet row

* Clean up now-unused code in facets-template

* Update tests to reflect new DOM structure

* Fix facet analytics events

* Update remaining tests

* Add add'l tests for facet template events

* DRY up a duplicate method + one more unit test

* Minor simplification of facet event detail obj

* Improve readability for facet row CSS vars
  • Loading branch information
latonv authored Oct 26, 2023
1 parent 5118f8e commit c3e3bef
Show file tree
Hide file tree
Showing 8 changed files with 862 additions and 430 deletions.
33 changes: 16 additions & 17 deletions src/collection-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1751,27 +1751,26 @@ export class CollectionBrowser
}

facetClickHandler({
detail: { key, state: facetState, negative },
detail: { facetType, bucket, negative },
}: CustomEvent<FacetEventDetails>): void {
let action: analyticsActions;
if (negative) {
this.analyticsHandler?.sendEvent({
category: this.searchContext,
action:
facetState !== 'none'
? analyticsActions.facetNegativeSelected
: analyticsActions.facetNegativeDeselected,
label: key,
});
action =
bucket.state !== 'none'
? analyticsActions.facetNegativeSelected
: analyticsActions.facetNegativeDeselected;
} else {
this.analyticsHandler?.sendEvent({
category: this.searchContext,
action:
facetState !== 'none'
? analyticsActions.facetSelected
: analyticsActions.facetDeselected,
label: key,
});
action =
bucket.state !== 'none'
? analyticsActions.facetSelected
: analyticsActions.facetDeselected;
}

this.analyticsHandler?.sendEvent({
category: this.searchContext,
action,
label: facetType,
});
}

private async fetchFacets() {
Expand Down
274 changes: 274 additions & 0 deletions src/collection-facets/facet-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import {
css,
html,
LitElement,
TemplateResult,
CSSResultGroup,
nothing,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { CollectionNameCacheInterface } from '@internetarchive/collection-name-cache';
import eyeIcon from '../assets/img/icons/eye';
import eyeClosedIcon from '../assets/img/icons/eye-closed';
import type {
FacetOption,
FacetBucket,
FacetEventDetails,
FacetState,
} from '../models';

@customElement('facet-row')
export class FacetRow extends LitElement {
//
// UI STATE
//

/** The name of the facet group to which this facet belongs (e.g., "mediatype") */
@property({ type: String }) facetType?: FacetOption;

/** The facet bucket containing details about the state, count, and key for this row */
@property({ type: Object }) bucket?: FacetBucket;

/** The collection name cache for converting collection identifiers to titles */
@property({ type: Object })
collectionNameCache?: CollectionNameCacheInterface;

//
// COMPONENT LIFECYCLE METHODS
//

render() {
return html`${this.facetRowTemplate}`;
}

//
// TEMPLATE GETTERS
//

/**
* Template for the full facet row, including the positive/negative checks,
* the display name, and the count.
*/
private get facetRowTemplate(): TemplateResult | typeof nothing {
const { bucket, facetType } = this;
if (!bucket || !facetType) return nothing;

const showOnlyCheckboxId = `${facetType}:${bucket.key}-show-only`;
const negativeCheckboxId = `${facetType}:${bucket.key}-negative`;

// For collections, we need to asynchronously load the collection name
// so we use the `async-collection-name` widget.
// For other facet types, we just have a static value to use.
const bucketTextDisplay =
facetType !== 'collection'
? html`${bucket.displayText ?? bucket.key}`
: html`<a href="/details/${bucket.key}">
<async-collection-name
.collectionNameCache=${this.collectionNameCache}
.identifier=${bucket.key}
placeholder="-"
></async-collection-name>
</a> `;

const facetHidden = bucket.state === 'hidden';
const facetSelected = bucket.state === 'selected';

const titleText = `${facetType}: ${bucket.displayText ?? bucket.key}`;
const onlyShowText = facetSelected
? `Show all ${facetType}s`
: `Only show ${titleText}`;
const hideText = `Hide ${titleText}`;
const unhideText = `Unhide ${titleText}`;
const showHideText = facetHidden ? unhideText : hideText;
const ariaLabel = `${titleText}, ${bucket.count} results`;

return html`
<div class="facet-row-container">
<div class="facet-checkboxes">
<input
type="checkbox"
.name=${facetType}
.value=${bucket.key}
@click=${(e: Event) => {
this.facetClicked(e, false);
}}
.checked=${facetSelected}
class="select-facet-checkbox"
title=${onlyShowText}
id=${showOnlyCheckboxId}
/>
<input
type="checkbox"
id=${negativeCheckboxId}
.name=${facetType}
.value=${bucket.key}
@click=${(e: Event) => {
this.facetClicked(e, true);
}}
.checked=${facetHidden}
class="hide-facet-checkbox"
/>
<label
for=${negativeCheckboxId}
class="hide-facet-icon${facetHidden ? ' active' : ''}"
title=${showHideText}
>
<span class="eye">${eyeIcon}</span>
<span class="eye-closed">${eyeClosedIcon}</span>
</label>
</div>
<label
for=${showOnlyCheckboxId}
class="facet-info-display"
title=${onlyShowText}
aria-label=${ariaLabel}
>
<div class="facet-title">${bucketTextDisplay}</div>
<div class="facet-count">${bucket.count.toLocaleString()}</div>
</label>
</div>
`;
}

//
// EVENT HANDLERS & DISPATCHERS
//

/**
* Handler for whenever this facet is clicked & its state changes
*/
private facetClicked(e: Event, negative: boolean) {
const { bucket, facetType } = this;
if (!bucket || !facetType) return;

const target = e.target as HTMLInputElement;
const { checked } = target;
bucket.state = FacetRow.getFacetState(checked, negative);

this.dispatchFacetClickEvent({
facetType,
bucket,
negative,
});
}

/**
* Emits a `facetClick` event with details about this facet & its current state
*/
private dispatchFacetClickEvent(detail: FacetEventDetails) {
const event = new CustomEvent<FacetEventDetails>('facetClick', {
detail,
});
this.dispatchEvent(event);
}

//
// OTHER METHODS
//

/**
* Returns the composed facet state corresponding to a positive or negative facet's checked state
*/
static getFacetState(checked: boolean, negative: boolean): FacetState {
let state: FacetState;
if (checked) {
state = negative ? 'hidden' : 'selected';
} else {
state = 'none';
}
return state;
}

//
// STYLES
//

static get styles(): CSSResultGroup {
const facetRowBorderTop = css`var(--facet-row-border-top, 1px solid transparent)`;
const facetRowBorderBottom = css`var(--facet-row-border-bottom, 1px solid transparent)`;

return css`
async-collection-name {
display: contents;
}
.facet-checkboxes {
margin: 0 5px 0 0;
display: flex;
height: 15px;
}
.facet-checkboxes input:first-child {
margin-right: 5px;
}
.facet-checkboxes input {
height: 15px;
width: 15px;
margin: 0;
}
.facet-row-container {
display: flex;
font-weight: 500;
font-size: 1.2rem;
margin: 2.5px auto;
height: auto;
border-top: ${facetRowBorderTop};
border-bottom: ${facetRowBorderBottom};
overflow: hidden;
}
.facet-info-display {
display: flex;
flex: 1 1 0%;
cursor: pointer;
flex-wrap: wrap;
}
.facet-title {
word-break: break-word;
display: inline-block;
flex: 1 1 0%;
}
.facet-count {
text-align: right;
}
.select-facet-checkbox {
cursor: pointer;
display: inline-block;
}
.hide-facet-checkbox {
display: none;
}
.hide-facet-icon {
width: 15px;
height: 15px;
cursor: pointer;
opacity: 0.3;
display: inline-block;
}
.hide-facet-icon:hover,
.active {
opacity: 1;
}
.hide-facet-icon:hover .eye,
.hide-facet-icon .eye-closed {
display: none;
}
.hide-facet-icon:hover .eye-closed,
.hide-facet-icon.active .eye-closed {
display: inline;
}
.hide-facet-icon.active .eye {
display: none;
}
.sorting-icon {
cursor: pointer;
}
a:link,
a:visited {
text-decoration: none;
color: var(--ia-theme-link-color, #4b64ff);
}
a:hover {
text-decoration: underline;
}
`;
}
}
Loading

0 comments on commit c3e3bef

Please sign in to comment.