this.inputEl = ref }
diff --git a/src/components/Super/Facets.js b/src/components/Super/Facets.js
new file mode 100644
index 00000000..100e562b
--- /dev/null
+++ b/src/components/Super/Facets.js
@@ -0,0 +1,193 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { isCurrentSite } from './util';
+
+export const TYPES = [
+ {
+ value: 'all',
+ label: 'Posts and comments',
+ },
+ {
+ value: 'post',
+ label: 'Posts only',
+ },
+ {
+ value: 'comment',
+ label: 'Comments only',
+ },
+];
+
+export const ORDERS = [
+ {
+ value: 'relevance',
+ label: 'Most relevant',
+ },
+ {
+ value: 'date',
+ label: 'Newest first',
+ },
+];
+
+export const PERIODS = [
+ {
+ value: 'any',
+ label: 'Any time',
+ },
+ {
+ value: 'week',
+ label: 'Past week',
+ days: 7,
+ },
+ {
+ value: 'month',
+ label: 'Past month',
+ days: 30,
+ },
+ {
+ value: 'year',
+ label: 'Past year',
+ days: 365,
+ },
+];
+
+const LABEL_CLASSES = 'flex items-center gap-2 cursor-pointer py-0.5 hover:text-hm-vibrant-blue';
+const CONTROL_CLASSES = 'm-0 shrink-0 size-4 cursor-pointer accent-hm-vibrant-blue';
+
+function Facet( props ) {
+ return (
+
+
+ { props.title }
+
+ { props.children }
+
+ );
+}
+
+function RadioFacet( props ) {
+ const { name, options, title, value, onChange } = props;
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Filters for network search.
+ *
+ * An empty `selectedSites` list means every site.
+ *
+ * @param {object} props Current filter values, available sites, and change callbacks.
+ * @returns {React.ReactNode} Filter controls.
+ */
+export default function Facets( props ) {
+ const { orderby, period, selectedSites, sites, type } = props;
+
+ const toggleSite = id => {
+ if ( selectedSites.includes( id ) ) {
+ props.onChangeSites( selectedSites.filter( selected => selected !== id ) );
+ } else {
+ props.onChangeSites( [ ...selectedSites, id ] );
+ }
+ };
+
+ return (
+
+
+ { sites.isLoading ? (
+ Loading sites…
+ ) : (
+
+ ) }
+
+
+
+
+
+
+
+
+ );
+}
+
+Facets.propTypes = {
+ orderby: PropTypes.oneOf( ORDERS.map( order => order.value ) ).isRequired,
+ period: PropTypes.oneOf( PERIODS.map( period => period.value ) ).isRequired,
+ selectedSites: PropTypes.arrayOf( PropTypes.number ).isRequired,
+ sites: PropTypes.shape( {
+ data: PropTypes.array,
+ isLoading: PropTypes.bool,
+ } ).isRequired,
+ type: PropTypes.oneOf( TYPES.map( type => type.value ) ).isRequired,
+ onChangeOrderby: PropTypes.func.isRequired,
+ onChangePeriod: PropTypes.func.isRequired,
+ onChangeSites: PropTypes.func.isRequired,
+ onChangeType: PropTypes.func.isRequired,
+};
diff --git a/src/components/Super/Result.js b/src/components/Super/Result.js
new file mode 100644
index 00000000..c3196927
--- /dev/null
+++ b/src/components/Super/Result.js
@@ -0,0 +1,193 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { decodeEntities } from '../../util';
+import Avatar from '../Avatar';
+import FormattedDate from '../FormattedDate';
+import Link from '../Link';
+
+import { getPlainText, isCurrentSite } from './util';
+
+const SNIPPET_CLASSES = [
+ 'Super-result-snippet',
+ 'mt-2 text-sm text-black/70 leading-normal',
+ '[&_p]:m-0 [&_p+p]:mt-1',
+ '[&_mark]:bg-[#fff3a3] [&_mark]:text-black [&_mark]:rounded-xs [&_mark]:px-0.5',
+].join( ' ' );
+
+const TITLE_LINK_CLASSES = 'text-black! hover:text-hm-vibrant-blue! [&_mark]:bg-transparent [&_mark]:text-inherit [&_mark]:underline [&_mark]:decoration-hm-vibrant-blue [&_mark]:decoration-2';
+
+const POST_TYPE_LABELS = {
+ post: 'Post',
+ page: 'Page',
+};
+
+/**
+ * Get the label for a post type.
+ *
+ * @param {string} postType Post type slug.
+ * @returns {string} Known label, or a readable version of the slug.
+ */
+function getPostTypeLabel( postType ) {
+ if ( ! postType ) {
+ return POST_TYPE_LABELS.post;
+ }
+ if ( POST_TYPE_LABELS[ postType ] ) {
+ return POST_TYPE_LABELS[ postType ];
+ }
+
+ const words = postType.replace( /[-_]+/g, ' ' );
+ return words.charAt( 0 ).toUpperCase() + words.slice( 1 );
+}
+
+/**
+ * Get the details to show for a post or comment.
+ *
+ * Posts and comments use the shapes of their regular REST API endpoints, with
+ * related objects embedded.
+ *
+ * @param {string} type Type of result, `post` or `comment`.
+ * @param {object} object Post or comment from the search API.
+ * @returns {object} Label, title, author and fallback excerpt for the result.
+ */
+function getDetails( type, object ) {
+ const embedded = object._embedded || {};
+ const author = embedded.author && embedded.author[0];
+
+ if ( type === 'comment' ) {
+ const post = embedded.up && embedded.up[0];
+
+ return {
+ authorName: object.author_name,
+ avatarUrls: object.author_avatar_urls,
+ // Comments have no excerpt, so fall back to the text of the comment.
+ excerptText: getPlainText( object.content.rendered ),
+ label: 'Comment',
+ title: post ? post.title.rendered : '',
+ };
+ }
+
+ return {
+ authorName: author && author.name,
+ avatarUrls: author && author.avatar_urls,
+ excerptHtml: object.excerpt.rendered,
+ // `post` results cover every post type, so label by the post's own type.
+ label: getPostTypeLabel( object.type ),
+ title: object.title.rendered,
+ };
+}
+
+/**
+ * A single network search result.
+ *
+ * Results link to their own site; only results on the current site are routed
+ * within the app.
+ *
+ * @param {object} props Props, with the `result` from the search API.
+ * @returns {React.ReactNode} List item for the result.
+ */
+export default function Result( props ) {
+ const { highlight = {}, result: object, site, type } = props.result;
+ const isComment = type === 'comment';
+ const highlightedTitle = highlight.title && highlight.title[0];
+ const snippets = highlight.content;
+ const details = getDetails( type, object );
+ const { authorName, excerptHtml, excerptText, label, title } = details;
+ const avatarUrls = details.avatarUrls || {};
+ const avatarUrl = avatarUrls['48'] || avatarUrls['96'] || window.H2Data.site.default_avatar;
+
+ // The router can only handle links on the current site.
+ const Anchor = isCurrentSite( site ) ? Link : 'a';
+ const anchorProps = Anchor === Link ? { disablePreviews: true } : {};
+
+ return (
+
+
+
+ { site.name }
+
+ { label }
+
+
+
+
+
+
+
+ { isComment && (
+ Comment on
+ ) }
+ { highlightedTitle ? (
+
+ ) : (
+ decodeEntities( title )
+ ) }
+
+
+
+ { authorName && (
+
+ ) }
+
+ { snippets && snippets.length ? (
+
+ { snippets.map( ( snippet, index ) => (
+
+ ) ) }
+
+ ) : excerptHtml ? (
+
+ ) : excerptText ? (
+
+ { excerptText }
+
+ ) : null }
+
+ );
+}
+
+const Rendered = PropTypes.shape( {
+ rendered: PropTypes.string.isRequired,
+} );
+
+Result.propTypes = {
+ result: PropTypes.shape( {
+ type: PropTypes.string.isRequired,
+ site: PropTypes.shape( {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ } ).isRequired,
+ highlight: PropTypes.objectOf( PropTypes.arrayOf( PropTypes.string ) ),
+ // Post or comment, in the shape of its regular REST API endpoint.
+ result: PropTypes.shape( {
+ id: PropTypes.number.isRequired,
+ date_gmt: PropTypes.string.isRequired,
+ type: PropTypes.string,
+ link: PropTypes.string.isRequired,
+ title: Rendered,
+ excerpt: Rendered,
+ content: Rendered,
+ author_name: PropTypes.string,
+ author_avatar_urls: PropTypes.object,
+ _embedded: PropTypes.object,
+ } ).isRequired,
+ } ).isRequired,
+};
diff --git a/src/components/Super/Results.js b/src/components/Super/Results.js
new file mode 100644
index 00000000..83cb436a
--- /dev/null
+++ b/src/components/Super/Results.js
@@ -0,0 +1,132 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import LinkButton from '../LinkButton';
+import Notification from '../Notification';
+
+import Result from './Result';
+import { formatTotal } from './util';
+
+function Pagination( props ) {
+ const { page, totalPages, onChangePage } = props;
+ if ( totalPages <= 1 ) {
+ return null;
+ }
+
+ return (
+
+
+ { page > 1 && (
+ onChangePage( page - 1 ) }
+ >
+ ←
+ Previous
+
+ ) }
+
+ Page { page } of { totalPages }
+
+ { page < totalPages && (
+ onChangePage( page + 1 ) }
+ >
+ Next
+ →
+
+ ) }
+
+
+ );
+}
+
+/**
+ * List of network search results.
+ *
+ * @param {object} props Results, request state, and paging callbacks.
+ * @returns {React.ReactNode} Results section.
+ */
+export default function Results( props ) {
+ const { error, loading, page, results, term, total, totalPages } = props;
+
+ let heading;
+ if ( error ) {
+ heading = `Search for “${ term }”`;
+ } else if ( loading && ! results ) {
+ heading = `Searching for “${ term }”…`;
+ } else {
+ heading = `${ formatTotal( total ) } for “${ term }”`;
+ }
+
+ return (
+
+
+
+ { heading }
+
+ { loading && results && (
+ Updating…
+ ) }
+
+
+ { error ? (
+
+ { error.message || 'The search request failed.' }
+
+ ) : results && ! results.length ? (
+
+ Nothing matched “{ term }”. Try different words, or widen the filters.
+
+ ) : results ? (
+
+ { results.map( result => (
+
+ ) ) }
+
+ ) : null }
+
+ { ! error && (
+
+ ) }
+
+ );
+}
+
+Results.propTypes = {
+ error: PropTypes.object,
+ loading: PropTypes.bool.isRequired,
+ page: PropTypes.number.isRequired,
+ results: PropTypes.array,
+ term: PropTypes.string.isRequired,
+ total: PropTypes.number.isRequired,
+ totalPages: PropTypes.number.isRequired,
+ onChangePage: PropTypes.func.isRequired,
+};
diff --git a/src/components/Super/Search.js b/src/components/Super/Search.js
new file mode 100644
index 00000000..ad54610b
--- /dev/null
+++ b/src/components/Super/Search.js
@@ -0,0 +1,303 @@
+import PropTypes from 'prop-types';
+import qs from 'qs';
+import React, { Component } from 'react';
+
+import api from '../../api';
+import { withSites } from '../../hocs';
+
+import Facets, { PERIODS } from './Facets';
+import Results from './Results';
+
+/**
+ * Delay before searching after the term changes, in milliseconds.
+ */
+const DEBOUNCE = 250;
+
+export const PER_PAGE = 10;
+
+/**
+ * Build the query parameters for a search.
+ *
+ * @param {object} options Search options.
+ * @returns {object} Parameters for the `h2/v1/search` endpoint.
+ */
+export function buildQuery( options ) {
+ const { orderby, page, period, selectedSites, term, type } = options;
+ const params = {
+ search: term,
+ page,
+ per_page: PER_PAGE,
+ };
+
+ if ( selectedSites.length ) {
+ params.sites = selectedSites;
+ }
+ if ( type !== 'all' ) {
+ params.type = [ type ];
+ }
+ if ( orderby === 'date' ) {
+ params.orderby = 'date';
+ params.order = 'desc';
+ }
+
+ const periodOption = PERIODS.find( option => option.value === period );
+ if ( periodOption && periodOption.days ) {
+ const after = new Date( Date.now() - periodOption.days * 24 * 60 * 60 * 1000 );
+ params.after = after.toISOString().replace( /\.\d{3}Z$/, 'Z' );
+ }
+
+ return params;
+}
+
+/**
+ * Get a string identifying the query represented by the search state.
+ *
+ * Unlike buildQuery(), this is stable over time, so can be used to detect
+ * changes to the query.
+ *
+ * @param {object} state Search state.
+ * @returns {string} Query signature.
+ */
+function getSignature( state ) {
+ const { orderby, page, period, selectedSites, term, type } = state;
+ return qs.stringify( {
+ term,
+ page,
+ sites: selectedSites,
+ type,
+ period,
+ orderby,
+ } );
+}
+
+/**
+ * Run a network search.
+ *
+ * @param {object} params Query parameters, from buildQuery().
+ * @param {object} options Additional fetch options (e.g. `signal`).
+ * @returns {Promise
} Results, with `results`, `total` and `totalPages`.
+ */
+export function fetchResults( params, options = {} ) {
+ const query = qs.stringify( params, {
+ arrayFormat: 'brackets',
+ } );
+
+ return api.fetch( `/h2/v1/search?${ query }`, options ).then( response => {
+ return response.json().catch( () => {
+ throw new Error( 'The search request failed.' );
+ } ).then( data => {
+ if ( ! response.ok ) {
+ const error = new Error( data.message || 'The search request failed.' );
+ error.code = data.code;
+ throw error;
+ }
+
+ return {
+ results: data,
+ total: Number( response.headers.get( 'X-WP-Total' ) ) || 0,
+ totalPages: Number( response.headers.get( 'X-WP-TotalPages' ) ) || 0,
+ };
+ } );
+ } );
+}
+
+/**
+ * Presentational layout for search: facets on the left, results on the right.
+ *
+ * @param {object} props Search state and change callbacks; see Search.
+ * @returns {React.ReactNode} Search layout.
+ */
+export function SearchView( props ) {
+ const { orderby, period, selectedSites, sites, type } = props;
+
+ return (
+
+
+
+
+ );
+}
+
+SearchView.propTypes = {
+ error: PropTypes.object,
+ loading: PropTypes.bool.isRequired,
+ orderby: PropTypes.string.isRequired,
+ page: PropTypes.number.isRequired,
+ period: PropTypes.string.isRequired,
+ results: PropTypes.array,
+ selectedSites: PropTypes.arrayOf( PropTypes.number ).isRequired,
+ sites: PropTypes.object.isRequired,
+ term: PropTypes.string.isRequired,
+ total: PropTypes.number.isRequired,
+ totalPages: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ onChangeOrderby: PropTypes.func.isRequired,
+ onChangePage: PropTypes.func.isRequired,
+ onChangePeriod: PropTypes.func.isRequired,
+ onChangeSites: PropTypes.func.isRequired,
+ onChangeType: PropTypes.func.isRequired,
+};
+
+/**
+ * Network-wide search.
+ *
+ * Searches every site the user can access via the H2 Network plugin. The term
+ * from the input is debounced into state, and every change to the query
+ * (term, filters, page) triggers a fresh request, cancelling any in flight.
+ */
+export class Search extends Component {
+ constructor( props ) {
+ super( props );
+
+ this.state = {
+ error: null,
+ loading: true,
+ orderby: 'relevance',
+ page: 1,
+ period: 'any',
+ results: null,
+ selectedSites: [],
+ term: props.term,
+ total: 0,
+ totalPages: 0,
+ type: 'all',
+ };
+ }
+
+ componentDidMount() {
+ this.load();
+ }
+
+ componentDidUpdate( prevProps, prevState ) {
+ if ( prevProps.term !== this.props.term ) {
+ // Wait for a pause in typing before searching.
+ window.clearTimeout( this.timer );
+ this.timer = window.setTimeout( () => {
+ this.setState( {
+ page: 1,
+ term: this.props.term,
+ } );
+ }, DEBOUNCE );
+ }
+
+ if ( getSignature( this.state ) !== getSignature( prevState ) ) {
+ this.load();
+ }
+ }
+
+ componentWillUnmount() {
+ window.clearTimeout( this.timer );
+ this.abort();
+ }
+
+ abort() {
+ if ( this.controller ) {
+ this.controller.abort();
+ this.controller = null;
+ }
+ }
+
+ load() {
+ this.abort();
+
+ const options = {};
+ if ( typeof AbortController !== 'undefined' ) {
+ this.controller = new AbortController();
+ options.signal = this.controller.signal;
+ }
+ const controller = this.controller;
+
+ this.setState( {
+ error: null,
+ loading: true,
+ } );
+
+ fetchResults( buildQuery( this.state ), options ).then( data => {
+ if ( controller && controller !== this.controller ) {
+ // Superseded by a newer request.
+ return;
+ }
+
+ this.setState( {
+ loading: false,
+ results: data.results,
+ total: data.total,
+ totalPages: data.totalPages,
+ } );
+ } ).catch( error => {
+ if ( error.name === 'AbortError' || ( controller && controller !== this.controller ) ) {
+ return;
+ }
+
+ this.setState( {
+ error,
+ loading: false,
+ results: null,
+ total: 0,
+ totalPages: 0,
+ } );
+ } );
+ }
+
+ /**
+ * Change a filter, returning to the first page of results.
+ *
+ * @param {object} changes State changes.
+ */
+ onChangeFilter( changes ) {
+ this.setState( {
+ ...changes,
+ page: 1,
+ } );
+ }
+
+ render() {
+ return (
+ this.onChangeFilter( { orderby } ) }
+ onChangePage={ page => this.setState( { page } ) }
+ onChangePeriod={ period => this.onChangeFilter( { period } ) }
+ onChangeSites={ selectedSites => this.onChangeFilter( { selectedSites } ) }
+ onChangeType={ type => this.onChangeFilter( { type } ) }
+ />
+ );
+ }
+}
+
+Search.propTypes = {
+ sites: PropTypes.object.isRequired,
+ term: PropTypes.string.isRequired,
+};
+
+export default withSites( Search );
diff --git a/src/components/Super/SearchInput.js b/src/components/Super/SearchInput.js
new file mode 100644
index 00000000..c02627c7
--- /dev/null
+++ b/src/components/Super/SearchInput.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { setSuperSearch } from '../../actions';
+import { FORM_CLASSES, getWrapClasses, INPUT_CLASSES } from '../SearchInput';
+
+import { getNetwork } from './util';
+
+/**
+ * Search input for network-wide search.
+ *
+ * Replaces the regular site search while the super overlay is open. The term
+ * is held in the store so that the header and overlay inputs stay in sync.
+ *
+ * @param {object} props Current value and change callback.
+ * @returns {React.ReactNode} Search form.
+ */
+export function NetworkSearchInput( props ) {
+ const { autoFocus, className, small, value, onChange } = props;
+ const network = getNetwork();
+
+ return (
+
+ );
+}
+
+NetworkSearchInput.propTypes = {
+ autoFocus: PropTypes.bool,
+ className: PropTypes.string,
+ small: PropTypes.bool,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+NetworkSearchInput.defaultProps = {
+ autoFocus: false,
+ small: false,
+};
+
+export default connect(
+ state => ( {
+ value: state.ui.superSearch,
+ } ),
+ dispatch => ( {
+ onChange: value => dispatch( setSuperSearch( value ) ),
+ } )
+)( NetworkSearchInput );
diff --git a/src/components/Super/SiteSwitcher.js b/src/components/Super/SiteSwitcher.js
new file mode 100644
index 00000000..372049b3
--- /dev/null
+++ b/src/components/Super/SiteSwitcher.js
@@ -0,0 +1,122 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { withSites } from '../../hocs';
+import Notification from '../Notification';
+
+import { displayUrl, isCurrentSite } from './util';
+
+const CARD_CLASSES = [
+ 'group block h-full p-5 rounded-sm',
+ 'border border-solid border-hm-beige bg-hm-beige/20',
+ 'text-black! no-underline!',
+ 'transition-colors duration-150',
+ 'hover:bg-hm-beige/50 hover:border-hm-warm-grey/40',
+ 'focus:bg-hm-beige/50 focus:border-hm-vibrant-blue focus:outline-hidden',
+].join( ' ' );
+
+export function SiteCard( props ) {
+ const { current, site } = props;
+
+ return (
+
+
+
+
+ { site.name }
+
+ { current ? (
+
+ Current
+
+ ) : (
+
+ View →
+
+ ) }
+
+
+ { site.description || No description yet. }
+
+
+ { displayUrl( site.url ) }
+
+
+
+ );
+}
+
+SiteCard.propTypes = {
+ current: PropTypes.bool,
+ site: PropTypes.shape( {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ url: PropTypes.string.isRequired,
+ } ).isRequired,
+};
+
+/**
+ * Grid of every site the user can switch to.
+ *
+ * @param {object} props Props, with `sites` from the site switcher API.
+ * @returns {React.ReactNode} Site grid, or a loading/error state.
+ */
+export function SiteSwitcher( props ) {
+ const { sites } = props;
+
+ if ( sites.isLoading ) {
+ return Loading sites…
;
+ }
+
+ if ( sites.error ) {
+ return (
+
+ Could not load sites: { sites.error.message }
+
+ );
+ }
+
+ const list = sites.data || [];
+ if ( ! list.length ) {
+ return (
+
+ No sites have been added to the site switcher yet.
+
+ );
+ }
+
+ return (
+
+
+ { list.length === 1 ? '1 site' : `${ list.length } sites` }
+
+
+ { list.map( site => (
+
+ ) ) }
+
+
+ );
+}
+
+SiteSwitcher.propTypes = {
+ sites: PropTypes.shape( {
+ data: PropTypes.array,
+ error: PropTypes.object,
+ isLoading: PropTypes.bool,
+ } ).isRequired,
+};
+
+export default withSites( SiteSwitcher );
diff --git a/src/components/Super/index.js b/src/components/Super/index.js
new file mode 100644
index 00000000..77c3a901
--- /dev/null
+++ b/src/components/Super/index.js
@@ -0,0 +1,168 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+
+import { hideSuperSidebar } from '../../actions';
+import { getChangesForUser } from '../../changelog';
+import { withCurrentUser } from '../../hocs';
+import Button from '../Button';
+import Notification from '../Notification';
+import Overlay from '../Overlay';
+import Shortcuts from '../Shortcuts';
+
+import Search from './Search';
+import NetworkSearchInput from './SearchInput';
+import SiteSwitcher, { SiteCard } from './SiteSwitcher';
+import { getNetwork } from './util';
+
+// Invert along with the button, so the count stays readable on hover.
+const BADGE_CLASSES = [
+ 'inline-block ml-2 rounded-full h-4 min-w-4 px-1 align-middle',
+ 'text-xs leading-4 text-center font-normal',
+ 'bg-hm-vibrant-blue text-white transition-colors duration-200 ease-in-out',
+ 'group-hover:bg-white group-hover:text-hm-vibrant-blue',
+ 'group-focus:bg-white group-focus:text-hm-vibrant-blue',
+].join( ' ' );
+
+/**
+ * Full-screen overlay for switching sites and searching the network.
+ *
+ * Opens beneath the header, so that the header's search input stays available
+ * and becomes a network-wide search while the overlay is open.
+ */
+export class SuperOverlay extends Component {
+ state = {
+ top: 0,
+ }
+
+ componentDidMount() {
+ this.updateOffset();
+ window.addEventListener( 'resize', this.updateOffset );
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener( 'resize', this.updateOffset );
+ }
+
+ /**
+ * Position the overlay directly beneath the header.
+ */
+ updateOffset = () => {
+ const header = this.props.headerRef && this.props.headerRef.current;
+ const top = header ? Math.max( 0, Math.round( header.getBoundingClientRect().bottom ) ) : 0;
+ if ( top !== this.state.top ) {
+ this.setState( { top } );
+ }
+ }
+
+ renderSingleSite() {
+ const { site } = window.H2Data;
+
+ return (
+
+
+ This site isn't part of a network, so there are no other sites to switch to.
+
+
+
+ );
+ }
+
+ render() {
+ const { currentUser, search, onClose } = this.props;
+ const network = getNetwork();
+ const term = network ? search.trim() : '';
+ const newChanges = ( currentUser && getChangesForUser( currentUser ) ) || [];
+
+ return (
+
+
+
+
+
+
+
+ { network ? network.name : window.H2Data.site.name }
+
+
+
+ What's New?
+ { newChanges.length > 0 && (
+
+ { newChanges.length }
+
+ ) }
+
+
+
+ Close
+
+
+ { network && (
+
+ ) }
+
+
+ { ! network ? (
+ this.renderSingleSite()
+ ) : term ? (
+
+ ) : (
+
+ ) }
+
+
+ );
+ }
+}
+
+SuperOverlay.propTypes = {
+ currentUser: PropTypes.object,
+ headerRef: PropTypes.shape( {
+ current: PropTypes.any,
+ } ),
+ search: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onShowChanges: PropTypes.func.isRequired,
+};
+
+export default connect(
+ state => ( {
+ search: state.ui.superSearch,
+ } ),
+ dispatch => ( {
+ onClose: () => dispatch( hideSuperSidebar() ),
+ } )
+)( withCurrentUser( SuperOverlay ) );
diff --git a/src/components/Super/index.stories.js b/src/components/Super/index.stories.js
new file mode 100644
index 00000000..0c94068d
--- /dev/null
+++ b/src/components/Super/index.stories.js
@@ -0,0 +1,226 @@
+import { action } from '@storybook/addon-actions';
+import React from 'react';
+
+import { withPadding, withStore } from '../../stories/decorators';
+import { user, users } from '../../stories/stubs';
+
+import { SearchView } from './Search';
+import { SiteSwitcher } from './SiteSwitcher';
+
+export default {
+ title: 'Interface|Super Overlay',
+ decorators: [
+ withStore(),
+ withPadding(),
+ ],
+};
+
+const sites = [
+ {
+ id: 1,
+ name: 'Storybook Site',
+ description: 'The site you are currently viewing.',
+ url: 'http://example.com',
+ },
+ {
+ id: 2,
+ name: 'Engineering',
+ description: 'Technical discussion, incident reports and release notes.',
+ url: 'https://example.com/eng',
+ },
+ {
+ id: 3,
+ name: 'Updates',
+ description: '',
+ url: 'https://updates.example.com',
+ },
+ {
+ id: 4,
+ name: 'Design',
+ description: 'Critique, research and the design system.',
+ url: 'https://example.com/design',
+ },
+];
+
+const author = ( site, data = user ) => ( {
+ id: data.id,
+ name: data.name,
+ slug: data.slug,
+ link: `${ site.url }/author/${ data.slug }/`,
+ avatar_urls: data.avatar_urls,
+} );
+
+const results = [
+ {
+ type: 'post',
+ site: sites[1],
+ score: 4.2,
+ highlight: {
+ title: [ 'Deploy checklist ' ],
+ content: [
+ 'Run through the checklist before pushing the button. If anything on the checklist fails, stop',
+ ],
+ },
+ result: {
+ id: 123,
+ date: '2026-09-01T10:00:00',
+ date_gmt: '2026-09-01T09:00:00',
+ type: 'post',
+ link: 'https://example.com/eng/2026/09/01/deploy-checklist/',
+ title: {
+ rendered: 'Deploy checklist',
+ },
+ excerpt: {
+ rendered: 'Before every deploy, run through the checklist.
',
+ },
+ author: user.id,
+ _embedded: {
+ author: [ author( sites[1] ) ],
+ },
+ },
+ },
+ {
+ type: 'comment',
+ site: sites[0],
+ score: 2.1,
+ highlight: {
+ content: [ 'Have we added the new service to the checklist yet?' ],
+ },
+ result: {
+ id: 456,
+ post: 1,
+ author: users[1].id,
+ author_name: users[1].name,
+ date: '2026-08-30T15:20:00',
+ date_gmt: '2026-08-30T14:20:00',
+ content: {
+ rendered: 'Have we added the new service to the checklist yet?
',
+ },
+ link: 'http://example.com/2018/01/01/tecum-optime/#comment-456',
+ author_avatar_urls: users[1].avatar_urls,
+ _embedded: {
+ author: [ author( sites[0], users[1] ) ],
+ up: [
+ {
+ id: 1,
+ link: 'http://example.com/2018/01/01/tecum-optime/',
+ title: {
+ rendered: 'Tecum optime, deinde etiam cum mediocri amico.',
+ },
+ },
+ ],
+ },
+ },
+ },
+ {
+ type: 'post',
+ site: sites[3],
+ score: 1.4,
+ highlight: {},
+ result: {
+ id: 789,
+ date: '2026-07-12T09:00:00',
+ date_gmt: '2026-07-12T08:00:00',
+ type: 'page',
+ link: 'https://example.com/design/component-review-checklist/',
+ title: {
+ rendered: 'Component review checklist',
+ },
+ excerpt: {
+ rendered: 'Everything a component needs before it ships in the design system.
',
+ },
+ author: user.id,
+ _embedded: {
+ author: [ author( sites[3] ) ],
+ },
+ },
+ },
+];
+
+const searchProps = {
+ error: null,
+ loading: false,
+ orderby: 'relevance',
+ page: 1,
+ period: 'any',
+ results,
+ selectedSites: [],
+ sites: {
+ data: sites,
+ isLoading: false,
+ },
+ term: 'checklist',
+ total: 23,
+ totalPages: 3,
+ type: 'all',
+ onChangeOrderby: action( 'onChangeOrderby' ),
+ onChangePage: action( 'onChangePage' ),
+ onChangePeriod: action( 'onChangePeriod' ),
+ onChangeSites: action( 'onChangeSites' ),
+ onChangeType: action( 'onChangeType' ),
+};
+
+export const Sites = () => (
+
+);
+
+export const SitesLoading = () => (
+
+);
+
+export const SearchResults = () => (
+
+);
+
+export const SearchFiltered = () => (
+ result.site.id === 2 ) }
+ selectedSites={ [ 2 ] }
+ total={ 1 }
+ totalPages={ 1 }
+ type="post"
+ />
+);
+
+export const SearchLoading = () => (
+
+);
+
+export const SearchEmpty = () => (
+
+);
+
+export const SearchError = () => (
+
+);
diff --git a/src/components/Super/util.js b/src/components/Super/util.js
new file mode 100644
index 00000000..f62c550b
--- /dev/null
+++ b/src/components/Super/util.js
@@ -0,0 +1,69 @@
+/**
+ * Get the network the site belongs to, if any.
+ *
+ * `H2Data.network` is provided by the H2 Network plugin: unset when the plugin
+ * isn't active, `false` on single-site installs, and otherwise an object
+ * describing the network.
+ *
+ * @returns {object|null} Network data, or null when not part of a network.
+ */
+export function getNetwork() {
+ return ( window.H2Data && window.H2Data.network ) || null;
+}
+
+/**
+ * Normalise a URL for comparison.
+ *
+ * @param {string} url URL to normalise.
+ * @returns {string} URL without scheme or trailing slashes.
+ */
+const normaliseUrl = url => ( url || '' ).replace( /^https?:\/\//, '' ).replace( /\/+$/, '' ).toLowerCase();
+
+/**
+ * Check whether a site from the network API is the site being viewed.
+ *
+ * @param {object} site Site data, with a `url`.
+ * @returns {boolean} True if the site is the current one.
+ */
+export function isCurrentSite( site ) {
+ return normaliseUrl( site.url ) === normaliseUrl( window.H2Data.site.home );
+}
+
+/**
+ * Format a URL for display.
+ *
+ * @param {string} url Full URL.
+ * @returns {string} URL without the scheme or trailing slash.
+ */
+export function displayUrl( url ) {
+ return ( url || '' ).replace( /^https?:\/\//, '' ).replace( /\/+$/, '' );
+}
+
+/**
+ * Get the text of an HTML string.
+ *
+ * The HTML is parsed into an inert document, so nothing in it is run or loaded.
+ *
+ * @param {string} html HTML to get the text of.
+ * @returns {string} Text content.
+ */
+export function getPlainText( html ) {
+ const doc = new DOMParser().parseFromString( html || '', 'text/html' );
+ return ( doc.body.textContent || '' ).trim();
+}
+
+/**
+ * Format a count of results.
+ *
+ * Elasticsearch stops counting at 10,000 results.
+ *
+ * @param {number} total Total results.
+ * @returns {string} Formatted count.
+ */
+export function formatTotal( total ) {
+ if ( total >= 10000 ) {
+ return '10,000+ results';
+ }
+
+ return total === 1 ? '1 result' : `${ total.toLocaleString() } results`;
+}
diff --git a/src/components/SuperMenu.js b/src/components/SuperMenu.js
deleted file mode 100644
index 335dd9cd..00000000
--- a/src/components/SuperMenu.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import React from 'react';
-
-import { getChangesForUser } from '../changelog';
-import { withCategories, withCurrentUser, withSites } from '../hocs';
-
-import HeaderLabel from './Header/HeaderLabel';
-import Logo from './Header/Logo';
-import Link from './Link';
-import Overlay from './Overlay';
-import SearchInput from './SearchInput';
-
-const Category = ( { all, category } ) => {
- const childCategories = all.filter( cat => cat.parent === category.id );
-
- return (
-
-
- { category.name }
-
-
- { childCategories && (
-
- ) }
-
- );
-};
-
-const CategoryList = ( { all, categories } ) => (
-
- { categories && categories.map( category => (
-
- ) ) }
-
-);
-
-export class SuperMenu extends React.Component {
- render() {
- const { categories, visible } = this.props;
- const sites = this.props.sites && this.props.sites.data;
-
- const classes = [
- 'SuperMenu',
- 'bg-hm-light-grey absolute w-[300px] h-screen overflow-auto z-30 p-4 px-5 shadow-[0_1px_3px_rgba(0,0,0,0.15)] transition-transform duration-150 flex flex-col',
- visible ? 'SuperMenu--visible translate-x-0' : '-translate-x-[300px]',
- ];
-
- const newChanges = this.props.currentUser ? getChangesForUser( this.props.currentUser ) : [];
-
- const newLabel = (
-
- What's New?
- { ' ' }
- { newChanges.length >= 0 ? { newChanges.length + 8 } : null }
-
- );
-
- return (
-
- { visible && (
-
- ) }
-
-
-
-
-
-
-
Navigation
-
-
-
-
-
- { categories.data && (
-
- Categories
- cat.parent === 0 ) }
- />
-
- ) }
-
- { sites ? (
-
- Sites
-
-
- ) : null }
-
-
- );
- }
-}
-
-export default withSites( withCategories( withCurrentUser( SuperMenu ) ) );
diff --git a/src/components/SuperMenu.stories.js b/src/components/SuperMenu.stories.js
deleted file mode 100644
index 233d6ed1..00000000
--- a/src/components/SuperMenu.stories.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { action } from '@storybook/addon-actions';
-import React from 'react';
-
-import { withStore } from '../stories/decorators';
-
-import { SuperMenu } from './SuperMenu';
-
-export default {
- title: 'Interface|Super Menu',
- decorators: [
- withStore(),
- ],
-};
-
-const sites = [
- {
- id: 1,
- url: 'https://example.com/updates/',
- name: 'Updates',
- },
- {
- id: 2,
- url: 'https://example.com/dev/',
- name: 'Dev',
- },
-];
-
-const commonProps = {
- categories: [],
- sites: [],
- visible: true,
- onClose: action( 'onClose' ),
-};
-
-export const Empty = () => (
-
-);
-
-export const WithSites = () => (
-
-);
diff --git a/src/reducers/ui.js b/src/reducers/ui.js
index 73b586ed..e2b7444b 100644
--- a/src/reducers/ui.js
+++ b/src/reducers/ui.js
@@ -8,6 +8,7 @@ import {
HIDE_SIDEBAR,
SHOW_SUPER_SIDEBAR,
HIDE_SUPER_SIDEBAR,
+ SET_SUPER_SEARCH,
SET_DEFAULT_POST_VIEW,
} from '../actions';
@@ -15,6 +16,7 @@ const DEFAULT_STATE = {
defaultPostView: 'expanded',
showingSuper: false,
sidebarProfile: null,
+ superSearch: '',
};
export default function ui( state = DEFAULT_STATE, action ) {
@@ -74,6 +76,13 @@ export default function ui( state = DEFAULT_STATE, action ) {
return {
...state,
showingSuper: false,
+ superSearch: '',
+ };
+
+ case SET_SUPER_SEARCH:
+ return {
+ ...state,
+ superSearch: action.search,
};
case SET_DEFAULT_POST_VIEW:
diff --git a/src/tailwind.css b/src/tailwind.css
index 1cb30674..a7d1e553 100644
--- a/src/tailwind.css
+++ b/src/tailwind.css
@@ -26,6 +26,15 @@
--animate-hovercard-in: hovercard-fade-in-up 0.1s ease forwards;
--animate-hovercard-out: hovercard-fade-out-down 0.1s ease forwards;
+ --animate-super-in: super-fade-in 0.15s ease-out;
+ @keyframes super-fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
@keyframes hovercard-fade-in-up {
0% {
opacity: 0;