diff --git a/gcp/website/frontend3/package-lock.json b/gcp/website/frontend3/package-lock.json index 2d4d1fce000..b481d27e83f 100644 --- a/gcp/website/frontend3/package-lock.json +++ b/gcp/website/frontend3/package-lock.json @@ -14,8 +14,7 @@ "@material/layout-grid": "14.0.0", "@material/theme": "14.0.0", "@material/web": "^2.0.0", - "lit": "3.3.2", - "spicy-sections": "git+https://github.com/tabvengers/spicy-sections.git#c3aae99dbf1e627cdf03a35c913d7f6e970de22b" + "lit": "3.3.2" }, "devDependencies": { "copy-webpack-plugin": "^13.0.0", @@ -5558,12 +5557,6 @@ "dev": true, "license": "MIT" }, - "node_modules/spicy-sections": { - "version": "0.9.0", - "resolved": "git+ssh://git@github.com/tabvengers/spicy-sections.git#c3aae99dbf1e627cdf03a35c913d7f6e970de22b", - "integrity": "sha512-YyjgiaF9vhgpUL1NlXXrRljXJLeWs473YxVGoKWI/yAmimdqxFNrZ0eYqLHfsDjK1h0bzuyAVUkQZLuIg0UoUA==", - "license": "W3C" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/gcp/website/frontend3/package.json b/gcp/website/frontend3/package.json index 0d0bdedd568..3d89a4763a7 100644 --- a/gcp/website/frontend3/package.json +++ b/gcp/website/frontend3/package.json @@ -15,8 +15,7 @@ "@material/layout-grid": "14.0.0", "@material/theme": "14.0.0", "@material/web": "^2.0.0", - "lit": "3.3.2", - "spicy-sections": "git+https://github.com/tabvengers/spicy-sections.git#c3aae99dbf1e627cdf03a35c913d7f6e970de22b" + "lit": "3.3.2" }, "devDependencies": { "copy-webpack-plugin": "^13.0.0", diff --git a/gcp/website/frontend3/src/index.js b/gcp/website/frontend3/src/index.js index 47a442816fc..e0f6ada973b 100644 --- a/gcp/website/frontend3/src/index.js +++ b/gcp/website/frontend3/src/index.js @@ -4,7 +4,7 @@ import '@material/web/icon/icon.js'; import '@material/web/iconbutton/icon-button.js'; import '@material/web/progress/circular-progress.js'; import '@hotwired/turbo'; -import 'spicy-sections/src/SpicySections'; +import './osv-tabs.js'; import { MdFilledTextField } from '@material/web/textfield/filled-text-field.js'; import { LitElement, html } from 'lit'; import { ExpandableSearch, SearchSuggestionsManager } from './search.js'; diff --git a/gcp/website/frontend3/src/osv-tabs.js b/gcp/website/frontend3/src/osv-tabs.js new file mode 100644 index 00000000000..0f13df4faaa --- /dev/null +++ b/gcp/website/frontend3/src/osv-tabs.js @@ -0,0 +1,222 @@ +class OsvTabs extends HTMLElement { + constructor() { + super(); + this.breakpoint = 500; + this.mediaQuery = null; + this.headers = []; + this.panels = []; + this.activeIndex = 0; + this.headerListeners = null; + + // shadow DOM for tab-bar layout + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + +
+ +
+
+ +
+ + `; + } + + connectedCallback() { + this.breakpoint = parseInt(this.getAttribute("breakpoint")) || 500; + this.collectHeadersAndPanels(); + this.setupMediaQuery(); + this.updateAffordance(); + this.setupEventListeners(); + } + + disconnectedCallback() { + if (this.mediaQuery) { + this.mediaQuery.removeEventListener("change", this.boundUpdateAffordance); + } + this.removeEventListeners(); + } + + collectHeadersAndPanels() { + this.headers = []; + this.panels = []; + + const children = Array.from(this.children); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.matches("h2, h3")) { + const nextSibling = children[i + 1]; + if (nextSibling && nextSibling.matches("div")) { + this.headers.push(child); + this.panels.push(nextSibling); + } + } + } + } + + setupMediaQuery() { + this.mediaQuery = window.matchMedia( + `(min-width: ${this.breakpoint + 1}px)` + ); + this.boundUpdateAffordance = () => this.updateAffordance(); + this.mediaQuery.addEventListener("change", this.boundUpdateAffordance); + } + + setupEventListeners() { + this.headerListeners = this.headers.map((header, index) => { + const clickListener = (e) => this.handleHeaderClick(index, e); + const keydownListener = (e) => this.handleKeydown(index, e); + + header.addEventListener("click", clickListener); + header.addEventListener("keydown", keydownListener); + + return { header, clickListener, keydownListener }; + }); + } + + removeEventListeners() { + if (this.headerListeners) { + this.headerListeners.forEach(({ header, clickListener, keydownListener }) => { + header.removeEventListener("click", clickListener); + header.removeEventListener("keydown", keydownListener); + }); + this.headerListeners = null; + } + } + + updateAffordance() { + const isDesktop = this.mediaQuery.matches; + const affordance = isDesktop ? "tab-bar" : "collapse"; + this.setAttribute("affordance", affordance); + + if (isDesktop) { + this.renderTabs(); + } else { + this.renderAccordion(); + } + } + + renderTabs() { + this.headers.forEach((header, index) => { + const isActive = index === this.activeIndex; + header.setAttribute("slot", "tab"); + header.setAttribute("tabindex", isActive ? "0" : "-1"); + header.setAttribute("role", "tab"); + header.setAttribute("aria-selected", isActive ? "true" : "false"); + header.removeAttribute("aria-expanded"); + header.removeAttribute("expanded"); + }); + + this.panels.forEach((panel, index) => { + const isActive = index === this.activeIndex; + panel.setAttribute("slot", "panel"); + panel.setAttribute("role", "tabpanel"); + if (isActive) { + panel.setAttribute("data-panel-active", ""); + } else { + panel.removeAttribute("data-panel-active"); + } + }); + } + + renderAccordion() { + this.headers.forEach((header, index) => { + const panel = this.panels[index]; + + header.removeAttribute("slot"); + header.removeAttribute("role"); + header.removeAttribute("aria-selected"); + header.setAttribute("tabindex", "0"); + header.setAttribute("aria-expanded", "true"); + header.setAttribute("expanded", ""); + + panel.removeAttribute("slot"); + panel.removeAttribute("data-panel-active"); + panel.style.display = ""; + }); + + this.panels.forEach((panel) => { + panel.setAttribute("role", "region"); + }); + } + + handleHeaderClick(index, event) { + if (!this.headers[index].contains(event.target)) { + return; + } + + const affordance = this.getAttribute("affordance"); + + if (affordance === "tab-bar") { + this.activeIndex = index; + this.renderTabs(); + } else { + const panel = this.panels[index]; + const header = this.headers[index]; + const isExpanded = panel.style.display !== "none"; + + panel.style.display = isExpanded ? "none" : ""; + header.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + if (isExpanded) { + header.removeAttribute("expanded"); + } else { + header.setAttribute("expanded", ""); + } + } + } + + handleKeydown(index, event) { + const affordance = this.getAttribute("affordance"); + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.handleHeaderClick(index, { target: this.headers[index] }); + } + + if (affordance === "tab-bar") { + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + event.preventDefault(); + const nextIndex = (index + 1) % this.headers.length; + this.activeIndex = nextIndex; + this.renderTabs(); + this.headers[nextIndex].focus(); + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + event.preventDefault(); + const prevIndex = + (index - 1 + this.headers.length) % this.headers.length; + this.activeIndex = prevIndex; + this.renderTabs(); + this.headers[prevIndex].focus(); + } + } + } +} + +customElements.define("osv-tabs", OsvTabs); diff --git a/gcp/website/frontend3/src/styles.scss b/gcp/website/frontend3/src/styles.scss index 10740b59234..4ec2a395078 100644 --- a/gcp/website/frontend3/src/styles.scss +++ b/gcp/website/frontend3/src/styles.scss @@ -39,6 +39,37 @@ $osv-border-color: #555; margin-right: -50vw; } +@mixin hide-details-marker { + list-style: none; + &::-webkit-details-marker { + display: none; + } + &::marker { + display: none; + content: ''; + } +} + +// Indicator for expand/collapse UI +@mixin chevron-indicator($size: 12px, $margin-right: 8px, $color: $osv-grey-800) { + content: ''; + display: inline-block; + width: $size; + height: $size; + margin-right: $margin-right; + -webkit-mask-image: url(/static/img/filled-triangle.svg); + mask-image: url(/static/img/filled-triangle.svg); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + background-color: $color; + transform: rotate(0deg); + transition: transform 0.2s ease-in-out, background-color 0.2s ease-in-out; +} + /** Reset */ *, @@ -391,65 +422,58 @@ pre { gap: 10px; align-items: center; - // We use to collapse the ecosystem list only on mobile. - // On desktop, display the full list. - --const-mq-affordances: [screen and (max-width: #{$osv-mobile-breakpoint})] collapse; + .ecosystem-label-all { + position: relative; + margin-right: 20px; - &::part(tab-bar) { - // The tab bar affordance isn't used, so hide it. - display: none; + &::after { + content: ''; + position: absolute; + right: -16px; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 30px; + background-color: $osv-grey-600; + } } - &::part(content-panels) { - // Because the shadow root of actually wraps our content - // we need to use display: contents; in order for flex to apply as if the - // child elements are direct descendants of the parent flex container. - display: contents; - } + @media (max-width: #{$osv-mobile-breakpoint}) { + gap: 8px; - .spicy-content { - // Expanded contents also should act as if they were direct descendants - // of our flex container. - display: contents; - } + .ecosystem-label { + padding: 8px 14px; + height: 32px; + gap: 12px; + font-size: 14px; + } - // Customizing the collapsing header for . We can select it - // by looking for the element with affordance="collapse". - [affordance=collapse] { - display: flex; - align-items: center; + .ecosystem-label-all { + margin-right: 14px; - // The ::before on the collapsible is used to display the chevron. - &::before { - display: block; - padding: 0 8px; - // HACK: Invert the black chevron to white. The chevron is set using - // background-image, so we can't use the CSS fill property on it. - filter: invert(100%); + &::after { + height: 20px; + right: -12px; + } } } .ecosystem-label { - display: flex; + display: inline-flex; gap: 16px; font-family: $osv-heading-font-family; padding: 10px 20px; background: #696969; border-radius: 999px; height: 38px; + cursor: pointer; + align-items: center; .ecosystem-count { font-weight: bold; } } - .ecosystems-divider { - display: block; - margin: 0 10px; - height: 30px; - border-right: 1px solid $osv-grey-600; - } - input[type=radio]:checked+.ecosystem-label { background: $osv-text-color; color: $osv-accent-color; @@ -879,12 +903,28 @@ dl.vulnerability-details, margin-bottom: 16px; font-weight: bold; } +} - // Tab bar styling. - --const-mq-affordances: [screen and (max-width: #{$osv-mobile-breakpoint})] collapse | [screen and (min-width: #{$osv-mobile-breakpoint + 1})] tab-bar; +osv-tabs.vulnerability-packages[affordance="tab-bar"] { + display: block; +} + +osv-tabs.vulnerability-packages[affordance="collapse"] { + display: block; + + > h2.package-header { + display: block; + width: 100%; + cursor: pointer; - .force-collapse { - --const-mq-affordances: [screen] collapse; + &::before { + @include chevron-indicator; + } + + &[expanded]::before { + transform: rotate(90deg); + background-color: $osv-accent-color; + } } } @@ -893,6 +933,7 @@ dl.vulnerability-details, background: #aaa; color: #000; display: inline-block; + font-family: $osv-heading-font-family; font-size: 14px; padding: 16px; } @@ -912,28 +953,25 @@ dl.vulnerability-details, } .versions-section { - --const-mq-affordances: [screen] collapse; + > summary { + @include hide-details-marker; + } - h2.version-header::before { - content: ''; - margin-right: 16px; - background: url(/static/img/filled-triangle.svg); - background-position: center; - transform: rotate(0deg); - // Make the filled triangle white. - filter: invert(100%); + summary.version-header::before { + @include chevron-indicator($margin-right: 16px, $color: #fff); } - h2.version-header[expanded]::before { + &[open] > summary.version-header::before { transform: rotate(90deg); } - h2.version-header { + summary.version-header { background: none; font-family: $osv-heading-font-family; color: #fff; - padding-left: 0px; + padding: 16px 0; font-size: 16px; + cursor: pointer; } } @@ -942,6 +980,7 @@ dl.vulnerability-details, overflow: auto; flex-wrap: wrap; gap: 8px; + margin-top: 8px; padding-bottom: 12px; } @@ -969,32 +1008,27 @@ dl.vulnerability-details, } .database-specific-section { - --const-mq-affordances: [screen] collapse; + > summary { + @include hide-details-marker; + } - h2.database-specific-header { + summary.database-specific-header { background: none; font-family: $osv-heading-font-family; color: #fff; - padding-left: 0; + padding: 16px 0; font-size: 16px; display: flex; align-items: center; + cursor: pointer; &::before { - content: ''; - width: 12px; - height: 12px; - margin-right: 8px; - background: url(/static/img/filled-triangle.svg); - background-position: center; - background-repeat: no-repeat; - transform: rotate(0deg); - filter: invert(100%); + @include chevron-indicator($color: #fff); } + } - &[expanded]::before { - transform: rotate(90deg); - } + &[open] > summary.database-specific-header::before { + transform: rotate(90deg); } } @@ -1002,56 +1036,46 @@ dl.vulnerability-details, padding: 8px 0 0 24px; } - .spicy-sections-workaround { - // https://github.com/tabvengers/spicy-sections/issues/64. - pointer-events: none; - } &.force-collapse { - h2.package-header { + .ecosystem-accordion > summary { + @include hide-details-marker; + } + + summary.package-header { width: 100%; background: #393939; color: #fff; padding: 16px; margin-bottom: 2px; border-radius: 0; + font-family: $osv-heading-font-family; font-weight: normal; transition: background-color 0.2s ease-in-out; cursor: pointer; + display: block; &::before { - content: ''; - width: 12px; - height: 12px; - margin-right: 8px; - background-image: url(/static/img/filled-triangle.svg); - background-position: center; - background-repeat: no-repeat; - transition: transform 0.2s ease-in-out; - transform: rotate(0deg); - filter: invert(100%); - } - - &[expanded]::before { - transform: rotate(90deg); + @include chevron-indicator($color: #fff); } &:hover { background: #4F4F4F; } + } - &[expanded] { - background: #fff; - color: $osv-accent-color; - font-weight: bold; - - &::before { - filter: invert(24%) sepia(89%) saturate(2293%) hue-rotate(345deg) brightness(81%) contrast(107%); - } - - &:hover { - background: #f0f0f0; - } + .ecosystem-accordion[open] > summary.package-header { + background: #fff; + color: $osv-accent-color; + font-weight: bold; + + &::before { + transform: rotate(90deg); + background-color: $osv-accent-color; + } + + &:hover { + background: #f0f0f0; } } @@ -1095,7 +1119,10 @@ dl.vulnerability-details, .package-accordion { position: relative; - margin-bottom: 8px; + + > summary { + @include hide-details-marker; + } // The horizontal "connector" line &::before { @@ -1114,37 +1141,29 @@ dl.vulnerability-details, padding-bottom: $last-ecosystem-border-gap; } - .package-accordion h3.package-name-title { + .package-accordion summary.package-name-title { font-family: $osv-heading-font-family; font-size: 1.1rem; + font-weight: bold; color: #f1f1f1; padding: 12px 16px; cursor: pointer; background: #333333; border: 1px solid #444; border-radius: 0; + display: block; &::before { - content: ''; - width: 12px; - height: 12px; - margin-right: 8px; - background-image: url(/static/img/filled-triangle.svg); - background-position: center; - background-repeat: no-repeat; - filter: invert(100%); - transition: transform 0.2s ease-in-out; - display: inline-block; + @include chevron-indicator($color: #fff); vertical-align: middle; - transform: rotate(0deg); } + } - &[expanded] { - border-bottom: 1px dashed #fff; - - &::before { - transform: rotate(90deg); - } + .package-accordion[open] > summary.package-name-title { + border-bottom: 1px dashed #fff; + + &::before { + transform: rotate(90deg); } } @@ -1214,7 +1233,6 @@ dl.vulnerability-details, &[tabindex="0"] { background: #fff; color: $osv-accent-color; - // Override spicy-sections default blue bottom border. border-bottom: 1px solid $osv-accent-color; } diff --git a/gcp/website/frontend3/src/templates/list.html b/gcp/website/frontend3/src/templates/list.html index 7218905be6d..77c2e20d99f 100644 --- a/gcp/website/frontend3/src/templates/list.html +++ b/gcp/website/frontend3/src/templates/list.html @@ -43,27 +43,22 @@

Vulnerabilities

{% if ecosystem_counts %} - - - - - -
- - {% for ecosystem in ecosystem_counts %} - - - {% endfor %} -
-
+
+ + + {% for ecosystem in ecosystem_counts %} + + + {% endfor %} +
{% endif %}
diff --git a/gcp/website/frontend3/src/templates/vulnerability.html b/gcp/website/frontend3/src/templates/vulnerability.html index 3b7a1d58b48..3a37f434249 100644 --- a/gcp/website/frontend3/src/templates/vulnerability.html +++ b/gcp/website/frontend3/src/templates/vulnerability.html @@ -177,16 +177,17 @@

Affected packages

{% if vulnerability.affected|should_collapse %} {% set ecosystems = vulnerability.affected | group_by_ecosystem %} - +
{% for ecosystem_name, packages in ecosystems.items() -%} {% set is_last_ecosystem = loop.last %} -

- {{ ecosystem_name }} -

-
- {% for affected in packages -%} - -

+
+ + {{ ecosystem_name }} + +
+ {% for affected in packages -%} +
+ {% if 'package' in affected %} {{ affected.package.name }} {% else %} @@ -195,7 +196,7 @@

{{ affected_repo | strip_scheme }} {% endif %} {% endif %} -

+
{%- if 'package' in affected -%} @@ -285,14 +286,14 @@

Affected ranges Affected versions

{% for group, versions in (affected.versions|group_versions(ecosystem_name)).items() -%} - -

{{ group }}

+
+ {{ group }}
{% for version in versions -%}
{{ version }}
{% endfor -%}
- +
{% endfor -%}
@@ -311,12 +312,12 @@

Database specific {% for key, value in db_specific.items() %} - -

{{ key }}

+
+ {{ key }}
{{ value | display_json }}
- +
{% endfor %}

{% else %} @@ -327,15 +328,16 @@

{{ key }}

{% endif -%}

- + {% endfor -%} -
+ + {% endfor -%} -
+ {% else %} - + {% for affected in vulnerability.affected -%} {% if 'package' in affected %} {% set ecosystem = affected.package.ecosystem %} @@ -348,9 +350,9 @@

{{ key }}

{% endif %} {% endif %}

- {{ ecosystem }} - / - {{ package }} + {{ ecosystem }} + / + {{ package }}

{%- if 'package' in affected -%} @@ -469,14 +471,14 @@

{% for group, versions in (affected.versions|group_versions(ecosystem)).items() -%} - -

{{ group }}

+
+ {{ group }}
{% for version in versions -%}
{{ version }}
{% endfor -%}
- +
{% endfor -%}
@@ -505,12 +507,12 @@

{% if db_specific is mapping %}
{% for key, value in db_specific.items() %} - -

{{ key }}

+
+ {{ key }}
{{ value | display_json }}
- +
{% endfor %}
{% else %} @@ -521,7 +523,7 @@

{{ key }}

{% endif -%} {% endfor -%} -
+ {% endif %} @@ -544,75 +546,33 @@

{{ key }}

* - Within each ecosystem, all package headers are expanded if their count is below `PACKAGE_EXPAND_THRESHOLD`. */ function setupVerticalLayout() { - const ecosystemHeaders = document.querySelectorAll('.vulnerability-packages.force-collapse .package-header'); - if (!ecosystemHeaders.length) return; + const ecosystemAccordions = document.querySelectorAll('.vulnerability-packages.force-collapse .ecosystem-accordion'); + if (!ecosystemAccordions.length) return; - const shouldExpandEcosystems = ecosystemHeaders.length < ECOSYSTEM_EXPAND_THRESHOLD; + const shouldExpandEcosystems = ecosystemAccordions.length < ECOSYSTEM_EXPAND_THRESHOLD; - ecosystemHeaders.forEach(header => { - const panel = header.nextElementSibling; - const packageHeaders = panel ? panel.querySelectorAll('.package-name-title') : []; + ecosystemAccordions.forEach(accordion => { + const panel = accordion.querySelector('.ecosystem-content-panel'); + const packageAccordions = panel ? panel.querySelectorAll('.package-accordion') : []; const hasManyPackages = - ecosystemHeaders.length > 1 && packageHeaders.length > ECOSYSTEM_PACKAGE_COLLAPSE_THRESHOLD; + ecosystemAccordions.length > 1 && packageAccordions.length > ECOSYSTEM_PACKAGE_COLLAPSE_THRESHOLD; - const shouldExpandHeader = + const shouldExpandAccordion = shouldExpandEcosystems && - header.getAttribute('aria-expanded') === 'false' && panel !== null && !hasManyPackages; - if (shouldExpandHeader) { - header.click(); - } - }); + accordion.open = shouldExpandAccordion; - const ecosystemPanels = document.querySelectorAll('.ecosystem-content-panel'); - ecosystemPanels.forEach(panel => { - const packageHeaders = panel.querySelectorAll('.package-name-title'); - if (packageHeaders.length <= PACKAGE_EXPAND_THRESHOLD) { - packageHeaders.forEach(header => { - if (header.getAttribute('aria-expanded') === 'false') { - header.click(); - } - }); - } + const shouldExpandPackages = packageAccordions.length <= PACKAGE_EXPAND_THRESHOLD; + packageAccordions.forEach(packageAccordion => { + packageAccordion.open = shouldExpandPackages; + }); }); } - /** - * Sets up the default layout,(tabs on desktop, accordion on mobile). - * This function ensures that on the mobile accordion view, - * all package headers are expanded by default if their count is - * below a defined threshold. This has no effect on the desktop tab view. - */ - function setupDefaultLayout() { - const packageHeaders = document.querySelectorAll('.vulnerability-packages:not(.force-collapse) .package-header'); - if (!packageHeaders.length) return; - - /** - * Expands collapsed package headers. - * We use `spicy-section` to make the packages content collapsible for mobile view, - * but it collapses the content by default. We want it expanded after the page - * is loaded, so we programmatically click on the header of collapsed packages - * to make the content visible. - */ - function expandPackageHeaders() { - packageHeaders.forEach((header) => { - if (header.getAttribute('aria-expanded') === 'false') { - header.click(); - } - }); - } - - if (packageHeaders.length < ECOSYSTEM_EXPAND_THRESHOLD) { - expandPackageHeaders(); - } - } - if (document.querySelector('.vulnerability-packages.force-collapse')) { setupVerticalLayout(); - } else { - setupDefaultLayout(); } /**