diff --git a/src/elements/ia-topnav/assets/img/icon-audio.ts b/src/elements/ia-topnav/assets/img/icon-audio.ts new file mode 100644 index 00000000..a420b69f --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-audio.ts @@ -0,0 +1,28 @@ +import { html } from 'lit'; + +export default html` + + Audio icon + An illustration of an audio speaker. + + + + + + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-close.ts b/src/elements/ia-topnav/assets/img/icon-close.ts new file mode 100644 index 00000000..60dab359 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-close.ts @@ -0,0 +1,20 @@ +import { html } from 'lit'; + +export default html` + + Close icon + A line drawing of an X + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-donate-unpadded.ts b/src/elements/ia-topnav/assets/img/icon-donate-unpadded.ts new file mode 100644 index 00000000..8e1b0429 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-donate-unpadded.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Donate icon + An illustration of a heart shape + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-donate.ts b/src/elements/ia-topnav/assets/img/icon-donate.ts new file mode 100644 index 00000000..e7f2c1b0 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-donate.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; + +export default html` + + Donate icon + An illustration of a heart shape + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-ellipses.ts b/src/elements/ia-topnav/assets/img/icon-ellipses.ts new file mode 100644 index 00000000..191e71c7 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-ellipses.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Ellipses icon + An illustration of text ellipses. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-ia-logo.ts b/src/elements/ia-topnav/assets/img/icon-ia-logo.ts new file mode 100644 index 00000000..ccacaced --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-ia-logo.ts @@ -0,0 +1,30 @@ +import { html } from 'lit'; + +export default html` + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-images.ts b/src/elements/ia-topnav/assets/img/icon-images.ts new file mode 100644 index 00000000..64e86d23 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-images.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Images icon + An illustration of two photographs. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-search.ts b/src/elements/ia-topnav/assets/img/icon-search.ts new file mode 100644 index 00000000..18eb1fc8 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-search.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Search icon + An illustration of a magnifying glass. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-software.ts b/src/elements/ia-topnav/assets/img/icon-software.ts new file mode 100644 index 00000000..6f9eed38 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-software.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; + +export default html` + + Software icon + An illustration of a 3.5" floppy disk. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-texts.ts b/src/elements/ia-topnav/assets/img/icon-texts.ts new file mode 100644 index 00000000..e1796245 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-texts.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; + +export default html` + + Texts icon + An illustration of an open book. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-upload-unpadded.ts b/src/elements/ia-topnav/assets/img/icon-upload-unpadded.ts new file mode 100644 index 00000000..5b80855a --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-upload-unpadded.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; + +export default html` + + Upload files + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-upload.ts b/src/elements/ia-topnav/assets/img/icon-upload.ts new file mode 100644 index 00000000..bdc6bfd6 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-upload.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; + +export default html` + + Upload icon + + An illustration of a horizontal line over an up pointing arrow. + + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-user.ts b/src/elements/ia-topnav/assets/img/icon-user.ts new file mode 100644 index 00000000..2d4a16b5 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-user.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + User icon + An illustration of a person's head and chest. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-video.ts b/src/elements/ia-topnav/assets/img/icon-video.ts new file mode 100644 index 00000000..27385fa2 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-video.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Video icon + An illustration of two cells of a film strip. + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon-web.ts b/src/elements/ia-topnav/assets/img/icon-web.ts new file mode 100644 index 00000000..a81002a9 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon-web.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; + +export default html` + + Web icon + An illustration of a computer application window + + +`; diff --git a/src/elements/ia-topnav/assets/img/icon.ts b/src/elements/ia-topnav/assets/img/icon.ts new file mode 100644 index 00000000..88d1b9a4 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icon.ts @@ -0,0 +1,8 @@ +import { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +export default class Icon extends LitElement { + @property({ type: Boolean }) active = false; + + @property({ type: String }) fill = 'fff'; +} diff --git a/src/elements/ia-topnav/assets/img/icons.ts b/src/elements/ia-topnav/assets/img/icons.ts new file mode 100644 index 00000000..04a5f202 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/icons.ts @@ -0,0 +1,33 @@ +import audio from './icon-audio'; +import close from './icon-close'; +import donate from './icon-donate'; +import donateUnpadded from './icon-donate-unpadded'; +import ellipses from './icon-ellipses'; +import iaLogo from './icon-ia-logo'; +import images from './icon-images'; +import search from './icon-search'; +import software from './icon-software'; +import texts from './icon-texts'; +import upload from './icon-upload'; +import uploadUnpadded from './icon-upload-unpadded'; +import user from './icon-user'; +import video from './icon-video'; +import web from './icon-web'; + +export default { + audio, + close, + donate, + donateUnpadded, + ellipses, + iaLogo, + images, + search, + software, + texts, + upload, + uploadUnpadded, + user, + video, + web, +}; diff --git a/src/elements/ia-topnav/assets/img/wayback-logo.ts b/src/elements/ia-topnav/assets/img/wayback-logo.ts new file mode 100644 index 00000000..b7cb923d --- /dev/null +++ b/src/elements/ia-topnav/assets/img/wayback-logo.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; + +export default html` + + + + + + +`; diff --git a/src/elements/ia-topnav/assets/img/wordmark-stacked.ts b/src/elements/ia-topnav/assets/img/wordmark-stacked.ts new file mode 100644 index 00000000..4276a376 --- /dev/null +++ b/src/elements/ia-topnav/assets/img/wordmark-stacked.ts @@ -0,0 +1,13 @@ +import { svg } from 'lit'; + +export default svg` + + + + + + + + + +`; diff --git a/src/elements/ia-topnav/data/menus.test.ts b/src/elements/ia-topnav/data/menus.test.ts new file mode 100644 index 00000000..c3c8891d --- /dev/null +++ b/src/elements/ia-topnav/data/menus.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; + +import { buildTopNavMenus } from '../data/menus'; +import type { IATopNavMenuConfig } from '../models'; + +const MEDIA_CATEGORIES: (keyof IATopNavMenuConfig)[] = [ + 'texts', + 'video', + 'audio', + 'software', + 'images', + 'web', + 'more', +]; + +describe('Menu data', () => { + test.each(MEDIA_CATEGORIES)('builds a %s media menu', (category) => { + const menus = buildTopNavMenus(); + const menu = menus[category]; + + expect(menu, `${category} should be built`).to.exist; + expect(menu).to.have.property('heading'); + }); + + test('builds the signed-out and user link lists', () => { + const menus = buildTopNavMenus(); + + expect(menus.signedOut).to.be.an('array').that.is.not.empty; + expect(menus.user).to.be.an('array').that.is.not.empty; + }); + + /** + * The topnav gates each admin section on the matching list being non-empty, + * so an unset uploader or biblio has to build nothing rather than a section + * with no links in it. + */ + test('leaves the uploader and biblio sections empty when not given one', () => { + const menus = buildTopNavMenus('brewster', '', '', 'goody'); + + expect(menus.userAdminUploader).to.be.an('array').that.is.empty; + expect(menus.userAdminBiblio).to.be.an('array').that.is.empty; + }); + + test('builds the uploader and biblio sections when given both', () => { + const menus = buildTopNavMenus( + 'brewster', + '', + '', + 'goody', + 'uploader@archive.org', + 'https://openlibrary.org/search/inside?q=', + ); + + expect(menus.userAdminUploader).to.be.an('array').that.is.not.empty; + expect(menus.userAdminBiblio).to.be.an('array').that.is.not.empty; + }); +}); diff --git a/src/elements/ia-topnav/data/menus.ts b/src/elements/ia-topnav/data/menus.ts new file mode 100644 index 00000000..cc8721dd --- /dev/null +++ b/src/elements/ia-topnav/data/menus.ts @@ -0,0 +1,691 @@ +// @see https://git.archive.org/www/offshoot/-/blob/main/guides/update-top-nav.md + +import { IATopNavConfig, IATopNavMenuConfig } from '../models'; + +export const defaultTopNavConfig: IATopNavConfig = { + // Google Analytics event category + eventCategory: 'TopNav', + // Default value, if more accurate value is not passed in to `buildTopNavMenus()` + waybackPagesArchived: '1 trillion', +}; + +/** + * Creates archive.org top navigation configuration + * @param { string } userid archive.org account (immutable) userid + * @param { boolean } localLinks passing in false will ensure all links begin: https://archive.org + * @param { string } waybackPagesArchived label readable 'how many pages in WayBack machine?' + * If you don't pass in something, you'll get the potentially + * older/less accurate version. + * @param { string } itemIdentifier The current item being viewed, to populate admin menu items + * @returns { object } + */ +export function buildTopNavMenus( + userid: string = '___USERID___', + baseHost: string = 'https://archive.org', + waybackPagesArchived: string = '', + itemIdentifier: string = '', + uploader: string = '', + biblio: string = '', +): IATopNavMenuConfig { + if (waybackPagesArchived) + defaultTopNavConfig.waybackPagesArchived = waybackPagesArchived; // update to more accurate val + + return { + audio: { + heading: 'Internet Archive Audio', + iconLinks: [ + { + icon: `${baseHost}/services/img/etree`, + title: 'Live Music Archive', + url: `${baseHost}/details/etree`, + }, + { + icon: `${baseHost}/services/img/librivoxaudio`, + title: 'Librivox Free Audio', + url: `${baseHost}/details/librivoxaudio`, + }, + ], + featuredLinks: [ + { + title: 'All Audio', + url: `${baseHost}/details/audio`, + }, + { + title: 'Grateful Dead', + url: `${baseHost}/details/GratefulDead`, + }, + { + title: 'Netlabels', + url: `${baseHost}/details/netlabels`, + }, + { + title: 'Old Time Radio', + url: `${baseHost}/details/oldtimeradio`, + }, + { + title: '78 RPMs and Cylinder Recordings', + url: `${baseHost}/details/78rpm`, + }, + ], + links: [ + { + title: 'Audio Books & Poetry', + url: `${baseHost}/details/audio_bookspoetry`, + }, + { + title: 'Computers, Technology and Science', + url: `${baseHost}/details/audio_tech`, + }, + { + title: 'Music, Arts & Culture', + url: `${baseHost}/details/audio_music`, + }, + { + title: 'News & Public Affairs', + url: `${baseHost}/details/audio_news`, + }, + { + title: 'Spirituality & Religion', + url: `${baseHost}/details/audio_religion`, + }, + { + title: 'Podcasts', + url: `${baseHost}/details/podcasts`, + }, + { + title: 'Radio News Archive', + url: `${baseHost}/details/radio`, + }, + ], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + images: { + heading: 'Images', + iconLinks: [ + { + icon: `${baseHost}/services/img/metropolitanmuseumofart-gallery`, + title: 'Metropolitan Museum', + url: `${baseHost}/details/metropolitanmuseumofart-gallery`, + }, + { + icon: `${baseHost}/services/img/clevelandart`, + title: 'Cleveland Museum of Art', + url: `${baseHost}/details/clevelandart`, + }, + ], + featuredLinks: [ + { + title: 'All Images', + url: `${baseHost}/details/image`, + }, + { + title: 'Flickr Commons', + url: `${baseHost}/details/flickrcommons`, + }, + { + title: 'Occupy Wall Street Flickr', + url: `${baseHost}/details/flickr-ows`, + }, + { + title: 'Cover Art', + url: `${baseHost}/details/coverartarchive`, + }, + { + title: 'USGS Maps', + url: `${baseHost}/details/maps_usgs`, + }, + ], + links: [ + { + title: 'NASA Images', + url: `${baseHost}/details/nasa`, + }, + { + title: 'Solar System Collection', + url: `${baseHost}/details/solarsystemcollection`, + }, + { + title: 'Ames Research Center', + url: `${baseHost}/details/amesresearchcenterimagelibrary`, + }, + ], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + more: { + links: [ + { + title: 'About', + url: `${baseHost}/about/`, + }, + { + title: 'Blog', + url: 'https://blog.archive.org', + }, + { + title: 'Events', + url: `${baseHost}/events`, + }, + { + title: 'Projects', + url: `${baseHost}/projects/`, + }, + { + title: 'Help', + url: `${baseHost}/about/faqs.php`, + }, + { + title: 'Donate', + url: `${baseHost}/donate?origin=iawww-TopNavDonateButton`, + }, + { + title: 'Contact', + url: `${baseHost}/about/contact`, + }, + { + title: 'Jobs', + url: `${baseHost}/about/jobs`, + }, + { + title: 'Volunteer', + url: `${baseHost}/about/volunteer-positions`, + }, + ], + heading: '', + iconLinks: [], + featuredLinks: [], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + software: { + heading: 'Software', + iconLinks: [ + { + icon: `${baseHost}/services/img/internetarcade`, + title: 'Internet Arcade', + url: `${baseHost}/details/internetarcade`, + }, + { + icon: `${baseHost}/services/img/consolelivingroom`, + title: 'Console Living Room', + url: `${baseHost}/details/consolelivingroom`, + }, + ], + featuredLinks: [ + { + title: 'All Software', + url: `${baseHost}/details/software`, + }, + { + title: 'Old School Emulation', + url: `${baseHost}/details/tosec`, + }, + { + title: 'MS-DOS Games', + url: `${baseHost}/details/softwarelibrary_msdos_games`, + }, + { + title: 'Historical Software', + url: `${baseHost}/details/historicalsoftware`, + }, + { + title: 'Classic PC Games', + url: `${baseHost}/details/classicpcgames`, + }, + { + title: 'Software Library', + url: `${baseHost}/details/softwarelibrary`, + }, + ], + links: [ + { + title: 'Kodi Archive and Support File', + url: `${baseHost}/details/kodi_archive`, + }, + { + title: 'Vintage Software', + url: `${baseHost}/details/vintagesoftware`, + }, + { + title: 'APK', + url: `${baseHost}/details/apkarchive`, + }, + { + title: 'MS-DOS', + url: `${baseHost}/details/softwarelibrary_msdos`, + }, + { + title: 'CD-ROM Software', + url: `${baseHost}/details/cd-roms`, + }, + { + title: 'CD-ROM Software Library', + url: `${baseHost}/details/cdromsoftware`, + }, + { + title: 'Software Sites', + url: `${baseHost}/details/softwaresites`, + }, + { + title: 'Tucows Software Library', + url: `${baseHost}/details/tucows`, + }, + { + title: 'Shareware CD-ROMs', + url: `${baseHost}/details/cdbbsarchive`, + }, + { + title: 'Software Capsules Compilation', + url: `${baseHost}/details/softwarecapsules`, + }, + { + title: 'CD-ROM Images', + url: `${baseHost}/details/cdromimages`, + }, + { + title: 'ZX Spectrum', + url: `${baseHost}/details/softwarelibrary_zx_spectrum`, + }, + { + title: 'DOOM Level CD', + url: `${baseHost}/details/doom-cds`, + }, + ], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + texts: { + heading: 'Texts', + iconLinks: [ + { + title: 'Open Library', + icon: `${baseHost}/images/widgetOL.png`, + url: 'https://openlibrary.org/', + }, + { + title: 'American Libraries', + icon: `${baseHost}/services/img/americana`, + url: `${baseHost}/details/americana`, + }, + ], + featuredLinks: [ + { + title: 'All Texts', + url: `${baseHost}/details/texts`, + }, + { + title: 'Smithsonian Libraries', + url: `${baseHost}/details/smithsonian`, + }, + { + title: 'FEDLINK (US)', + url: `${baseHost}/details/fedlink`, + }, + { + title: 'Genealogy', + url: `${baseHost}/details/genealogy`, + }, + { + title: 'Lincoln Collection', + url: `${baseHost}/details/lincolncollection`, + }, + ], + links: [ + { + title: 'American Libraries', + url: `${baseHost}/details/americana`, + }, + { + title: 'Canadian Libraries', + url: `${baseHost}/details/toronto`, + }, + { + title: 'Universal Library', + url: `${baseHost}/details/universallibrary`, + }, + { + title: 'Project Gutenberg', + url: `${baseHost}/details/gutenberg`, + }, + { + title: "Children's Library", + url: `${baseHost}/details/iacl`, + }, + { + title: 'Biodiversity Heritage Library', + url: `${baseHost}/details/biodiversity`, + }, + { + title: 'Books by Language', + url: `${baseHost}/details/booksbylanguage`, + }, + { + title: 'Folkscanomy', + url: `${baseHost}/details/folkscanomy`, + }, + { + title: 'Government Documents', + url: `${baseHost}/details/government-documents`, + }, + ], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + web: { + mobileAppsLinks: [ + { + url: 'https://apps.apple.com/us/app/wayback-machine/id1201888313', + title: 'Wayback Machine (iOS)', + external: true, + }, + { + url: 'https://play.google.com/store/apps/details?id=com.internetarchive.waybackmachine', + title: 'Wayback Machine (Android)', + external: true, + }, + ], + browserExtensionsLinks: [ + { + url: 'https://chrome.google.com/webstore/detail/wayback-machine/fpnmgdkabkmnadcjpehmlllkndpkmiak', + title: 'Chrome', + external: true, + }, + { + url: 'https://addons.mozilla.org/en-US/firefox/addon/wayback-machine_new/', + title: 'Firefox', + external: true, + }, + { + url: 'https://apps.apple.com/us/app/wayback-machine/id1472432422?mt=12', + title: 'Safari', + external: true, + }, + { + url: 'https://microsoftedge.microsoft.com/addons/detail/wayback-machine/kjmickeoogghaimmomagaghnogelpcpn?hl=en-US', + title: 'Edge', + external: true, + }, + ], + archiveItLinks: [ + { + url: 'https://www.archive-it.org/explore', + title: 'Explore the Collections', + external: true, + }, + { + url: 'https://www.archive-it.org/blog/learn-more/', + title: 'Learn More', + external: true, + }, + { + url: 'https://www.archive-it.org/contact-us', + title: 'Build Collections', + external: true, + }, + ], + heading: '', + iconLinks: [], + featuredLinks: [], + links: [], + }, + video: { + heading: 'Video', + iconLinks: [ + { + icon: `${baseHost}/services/img/tv`, + title: 'TV News', + url: `${baseHost}/details/tv`, + }, + { + icon: `${baseHost}/services/img/911`, + title: 'Understanding 9/11', + url: `${baseHost}/details/911`, + }, + ], + featuredLinks: [ + { + title: 'All Video', + url: `${baseHost}/details/movies`, + }, + { + title: 'Prelinger Archives', + url: `${baseHost}/details/prelinger`, + }, + { + title: 'Democracy Now!', + url: `${baseHost}/details/democracy_now_vid`, + }, + { + title: 'Occupy Wall Street', + url: `${baseHost}/details/occupywallstreet`, + }, + { + title: 'TV NSA Clip Library', + url: `${baseHost}/details/nsa`, + }, + ], + links: [ + { + title: 'Animation & Cartoons', + url: `${baseHost}/details/animationandcartoons`, + }, + { + title: 'Arts & Music', + url: `${baseHost}/details/artsandmusicvideos`, + }, + { + title: 'Computers & Technology', + url: `${baseHost}/details/computersandtechvideos`, + }, + { + title: 'Cultural & Academic Films', + url: `${baseHost}/details/culturalandacademicfilms`, + }, + { + title: 'Ephemeral Films', + url: `${baseHost}/details/ephemera`, + }, + { + title: 'Movies', + url: `${baseHost}/details/moviesandfilms`, + }, + { + title: 'News & Public Affairs', + url: `${baseHost}/details/newsandpublicaffairs`, + }, + { + title: 'Spirituality & Religion', + url: `${baseHost}/details/spiritualityandreligion`, + }, + { + title: 'Sports Videos', + url: `${baseHost}/details/sports`, + }, + { + title: 'Television', + url: `${baseHost}/details/television`, + }, + { + title: 'Videogame Videos', + url: `${baseHost}/details/gamevideos`, + }, + { + title: 'Vlogs', + url: `${baseHost}/details/vlogs`, + }, + { + title: 'Youth Media', + url: `${baseHost}/details/youth_media`, + }, + ], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }, + user: [ + { + url: `${baseHost}/upload`, + title: 'Upload files', + analyticsEvent: 'UserUpload', + class: 'mobile-upload', + }, + { + url: `${baseHost}/details/@${userid}`, + title: 'My uploads', + analyticsEvent: 'UserLibrary', + }, + { + url: `${baseHost}/details/@${userid}/loans`, + title: 'My loans', + analyticsEvent: 'UserLoans', + }, + { + url: `${baseHost}/details/@${userid}/favorites`, + title: 'My favorites', + analyticsEvent: 'UserFavorites', + }, + { + url: `${baseHost}/details/@${userid}/lists`, + title: 'My lists', + analyticsEvent: 'UserLists', + }, + { + url: `${baseHost}/details/@${userid}/collections`, + title: 'My collections', + analyticsEvent: 'UserCollections', + }, + { + url: `${baseHost}/details/@${userid}/web-archive`, + title: 'My web archives', + analyticsEvent: 'UserWebArchive', + }, + { + url: `${baseHost}/account/settings`, + title: 'Account settings', + analyticsEvent: 'UserSettings', + }, + { + url: 'https://help.archive.org', + title: 'Get help', + analyticsEvent: 'UserHelp', + }, + { + url: `${baseHost}/logout`, + title: 'Log out', + analyticsEvent: 'UserLogOut', + }, + ], + userAdmin: [ + { + title: 'ADMINS:', + }, + { + title: 'item:', + }, + { + url: `${baseHost}/editxml/${itemIdentifier}`, + title: 'edit xml', + analyticsEvent: 'AdminUserEditXML', + }, + { + url: `${baseHost}/edit.php?redir=1&identifier=${itemIdentifier}`, + title: 'edit files', + analyticsEvent: 'AdminUserEditFiles', + }, + { + url: `${baseHost}/download/${itemIdentifier}/`, + title: 'download', + analyticsEvent: 'AdminUserDownload', + }, + { + url: `${baseHost}/metadata/${itemIdentifier}/`, + title: 'metadata', + analyticsEvent: 'AdminUserMetadata', + }, + { + url: `https://catalogd.archive.org/history/${itemIdentifier}`, + title: 'history', + analyticsEvent: 'AdminUserHistory', + }, + { + url: `${baseHost}/manage/${itemIdentifier}`, + title: 'manage', + analyticsEvent: 'AdminUserManager', + }, + { + url: `${baseHost}/manage/${itemIdentifier}#make_dark`, + title: 'curate', + analyticsEvent: 'AdminUserCurate', + }, + { + url: `${baseHost}/manage/${itemIdentifier}#modify_xml`, + title: 'modify xml', + analyticsEvent: 'AdminUserModifyXML', + }, + ], + userAdminFlags: [ + { + url: `${baseHost}/services/flags/admin.php?identifier=${itemIdentifier}`, + title: 'manage flags', + analyticsEvent: 'AdminUserManageFlags', + }, + ], + userAdminBiblio: biblio + ? [ + { + url: `${biblio}&ignored=${itemIdentifier}`, + title: 'biblio', + analyticsEvent: 'AdminUserBiblio', + }, + { + url: `${baseHost}/bookview.php?mode=debug&identifier=${itemIdentifier}`, + title: 'bookview', + analyticsEvent: 'AdminUserBookView', + }, + { + url: `${baseHost}/download/${itemIdentifier}/format=Single Page Processed JP2 ZIP`, + title: 'jp2 zip', + analyticsEvent: 'AdminUserJP2Zip', + }, + ] + : [], + userAdminUploader: uploader + ? [ + { + title: 'uploader:', + }, + { + title: uploader, + }, + { + url: `https://catalogd.archive.org/control/useradmin.php?email=${encodeURIComponent(uploader)}`, + title: 'user admin', + analyticsEvent: 'AdminUserUserAdmin', + }, + { + url: `https://catalogd.archive.org/control/setadmin.php?user=${encodeURIComponent(uploader)}&ignore=${itemIdentifier}`, + title: 'user privs', + analyticsEvent: 'AdminUserUserPrivs', + }, + ] + : [], + signedOut: [ + { + url: `${baseHost}/signup`, + title: 'Sign up for free', + analyticsEvent: 'AvatarMenu-Signup', + }, + { + url: `${baseHost}/login`, + title: 'Log in', + analyticsEvent: 'AvatarMenu-Login', + }, + ], + }; +} diff --git a/src/elements/ia-topnav/dropdown-menu.test.ts b/src/elements/ia-topnav/dropdown-menu.test.ts new file mode 100644 index 00000000..84a76352 --- /dev/null +++ b/src/elements/ia-topnav/dropdown-menu.test.ts @@ -0,0 +1,30 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import DropdownMenu from './dropdown-menu'; + +import { describe, expect, test } from 'vitest'; +/* + * The base class ships unregistered, so the test gives it a tag to mount + * under. The name is test-only and deliberately outside the topnav's own + * namespace, which element-names.test.ts holds free. + */ +customElements.define('ia-topnav-test-dropdown-menu', DropdownMenu); + +const component = html``; + +describe('', () => { + test('sets default properties', async () => { + const el = await fixture(component); + expect(el.animated).to.be.false; + expect(el.open).to.be.false; + expect(el.menuItems.length).to.equal(0); + }); + + test('renders a closed class if component is animating', async () => { + const el = await fixture(component); + el.animated = true; + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.closed')).to.exist; + }); +}); diff --git a/src/elements/ia-topnav/dropdown-menu.ts b/src/elements/ia-topnav/dropdown-menu.ts new file mode 100644 index 00000000..2833c04f --- /dev/null +++ b/src/elements/ia-topnav/dropdown-menu.ts @@ -0,0 +1,322 @@ +import { + CSSResultGroup, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from 'lit'; +import { property } from 'lit/decorators.js'; + +import icons from './assets/img/icons'; +import { defaultTopNavConfig } from './data/menus'; +import formatUrl from './lib/format-url'; +import { makeBooleanString } from './lib/make-boolean-string'; +import { + IATopNavConfig, + IATopNavLink, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import TrackedElement from './tracked-element'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import KeyboardNavigation from './lib/keyboard-navigation'; +import themeStyles from '@src/themes/theme-styles'; + +export default class DropdownMenu extends TrackedElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: Array }) menuItems: IATopNavLink[] | IATopNavLink[][] = []; + @property({ type: Boolean }) animated = false; + @property({ type: Boolean }) open = false; + + private previousKeydownListener?: // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this: HTMLElement, ev: KeyboardEvent) => any; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + /* + * Where the menu parks when closed. Nothing sets it today, so the + * fallback is what actually applies. + */ + --topnav-dropdown-closed-top--: var(--topOffset, -1500px); + /* + * Set imperatively by ia-topnav once it has measured the account + * button, so the dropdown lines up with the icon that opened it. + */ + --topnav-dropdown-right--: var(--dropdownMenuRight, 0); + } + + .nav-container { + position: relative; + } + + nav { + position: absolute; + right: 0; + z-index: 4; + overflow: hidden; + font-size: 1.6rem; + background-color: var(--dropdownMenuBg); + transition-property: top; + transition-duration: 0.2s; + transition-timing-function: ease; + } + + .initial, + .closed { + top: var(--topnav-dropdown-closed-top--); + } + + .closed { + transition-duration: 0.5s; + } + + .open { + max-width: 100vw; + overflow: auto; + } + + h3 { + padding: 0.6rem 2rem; + margin: 0; + font-size: inherit; + overflow: hidden; + text-overflow: ellipsis; + } + + ul { + padding: 0.4rem 0 0.7rem 0; + margin: 0; + list-style: none; + /* viewport height - nav height + bottom nav border */ + max-height: calc(100vh - 7.2rem + 1px); + overflow: auto; + box-sizing: border-box; + } + + .divider { + margin: 0.5rem 0; + border-bottom: 1px solid var(--dropdownMenuDivider); + } + + a, + .info-item { + display: block; + color: var(--primaryTextColor); + text-decoration: none; + padding: 1rem 2rem; + } + + .info-item { + font-size: 0.8em; + color: var(--dropdownMenuInfoItem); + } + + .callout { + position: absolute; + margin-left: 10px; + padding: 0 5px; + border-radius: 2px; + background: #fee257; + color: #2c2c2c; + font-size: 1.4rem; + font-weight: bold; + } + + a.mobile-upload { + display: flex; + justify-content: left; + align-items: center; + } + a.mobile-upload svg { + fill: var(--white); + margin-right: 1rem; + height: 1.4rem; + width: 1.4rem; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + nav { + display: flex; + overflow: visible; + top: 0; + left: auto; + right: var(--topnav-dropdown-right--); + z-index: 5; + transition: opacity 0.2s ease-in-out; + font-size: 1.4rem; + border-radius: 2px; + background: var(--primaryTextColor); + box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.15); + } + + nav:after { + position: absolute; + right: 7px; + top: -7px; + width: 12px; + height: 7px; + box-sizing: border-box; + color: var(--white); + content: ''; + border-bottom: 7px solid currentColor; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + } + + h3 { + display: none; + } + + ul { + /* viewport height - nav height + bottom nav border */ + max-height: calc(100vh - 8.5rem + 1px); + } + + .divider { + border-bottom-color: var(--dropdownMenuDivider); + } + + a { + padding: 0.5rem 2rem; + color: var(--inverseTextColor); + transition: + background 0.1s ease-out, + color 0.1s ease-out; + } + + .info-item { + padding: 0.5rem 2rem; + font-size: 0.8em; + color: var(--inverseDropdownMenuInfoItem); + } + + a:hover, + a:active, + a:focus { + color: var(--linkHoverColor); + background: var(--linkColor); + outline: none; + } + + .initial, + .closed { + opacity: 0; + transition-duration: 0.2s; + } + + .open { + opacity: 1; + overflow: visible; + } + + a.mobile-upload { + display: none; + } + } + `, + ]; + } + + updated(props: PropertyValues) { + if (props.has('open') && this.open) { + const container = this.shadowRoot?.querySelector( + '.nav-container', + ) as HTMLElement; + + if (container) { + const keyboardNavigation = new KeyboardNavigation( + container, + 'usermenu', + ); + this.addEventListener('keydown', keyboardNavigation.handleKeyDown); + if (this.previousKeydownListener) { + this.removeEventListener('keydown', this.previousKeydownListener); + } + this.previousKeydownListener = keyboardNavigation.handleKeyDown; + } + } + } + + get dropdownItems() { + if (!this.menuItems) return nothing; + + if (!Array.isArray(this.menuItems[0])) { + const submenu = this.menuItems as IATopNavLink[]; + return this.dropdownSection(submenu); + } + return this.menuItems.map((submenu, i) => { + const joiner = i ? DropdownMenu.dropdownDivider : html``; + if (!Array.isArray(submenu)) { + return; + } + return [joiner, ...this.dropdownSection(submenu)]; + }); + } + + static get dropdownDivider() { + return html`
  • `; + } + + private dropdownSection(submenu: IATopNavLink[]): TemplateResult[] { + return submenu.map( + (item) => html` +
  • + ${item.url + ? this.dropdownLink(item) + : DropdownMenu.dropdownText(item)} +
  • + `, + ); + } + + dropdownLink(link: IATopNavLink): TemplateResult { + const calloutText = this.config?.callouts?.[link.title]; + const isMobileUpload = link.class === 'mobile-upload'; + const isTabbable = this.open && !isMobileUpload; + + return html` + ${isMobileUpload ? icons.uploadUnpadded : nothing} ${link.title} + ${calloutText + ? html`` + : nothing} + `; + } + + static dropdownText(item: IATopNavLink) { + return html`${item.title}`; + } + + get menuClass() { + if (this.open) return 'open'; + if (this.animated) return 'closed'; + return 'initial'; + } + + render() { + return html` + + `; + } +} diff --git a/src/elements/ia-topnav/element-names.test.ts b/src/elements/ia-topnav/element-names.test.ts new file mode 100644 index 00000000..bb43c8e9 --- /dev/null +++ b/src/elements/ia-topnav/element-names.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; + +import './ia-topnav'; +import './ia-topnav-desktop-subnav'; +import './ia-topnav-icon-hamburger'; +import './ia-topnav-login-button'; +import './ia-topnav-media-button'; +import './ia-topnav-media-menu'; +import './ia-topnav-media-slider'; +import './ia-topnav-media-subnav'; +import './ia-topnav-more-slider'; +import './ia-topnav-primary-nav'; +import './ia-topnav-save-page-form'; +import './ia-topnav-signed-out-dropdown'; +import './ia-topnav-user-menu'; +import './ia-topnav-wayback-search'; +import './ia-topnav-wayback-slider'; + +/** + * Custom elements share one global registry, so the topnav's parts are + * namespaced under its own prefix rather than taking names on their own. + */ +const NAMESPACED_ELEMENTS = [ + 'ia-topnav-desktop-subnav', + 'ia-topnav-icon-hamburger', + 'ia-topnav-login-button', + 'ia-topnav-media-button', + 'ia-topnav-media-menu', + 'ia-topnav-media-slider', + 'ia-topnav-media-subnav', + 'ia-topnav-more-slider', + 'ia-topnav-primary-nav', + 'ia-topnav-save-page-form', + 'ia-topnav-signed-out-dropdown', + 'ia-topnav-user-menu', + 'ia-topnav-wayback-search', + 'ia-topnav-wayback-slider', +]; + +/** + * Names this component must not claim. Every one is registered verbatim by the + * `@internetarchive/ia-topnav` package, which petabox and offshoot both still + * load, and a second registration of the same name throws rather than + * degrading. `ia-wayback-search` comes along with them because importing + * `@internetarchive/ia-wayback-search` registers it as a side effect, and + * `ia-icon` is generic enough that any page could want it. + */ +const NAMES_TO_AVOID = [ + 'desktop-subnav', + 'dropdown-menu', + 'ia-icon', + 'ia-wayback-search', + 'icon-hamburger', + 'login-button', + 'media-button', + 'media-menu', + 'media-slider', + 'media-subnav', + 'more-slider', + 'primary-nav', + 'save-page-form', + 'signed-out-dropdown', + 'user-menu', + 'wayback-search', + 'wayback-slider', +]; + +describe('topnav element names', () => { + test('registers the topnav under its own name', () => { + expect(customElements.get('ia-topnav')).to.exist; + }); + + test.each(NAMESPACED_ELEMENTS)('registers %s', (tag) => { + expect(customElements.get(tag), `${tag} should be registered`).to.exist; + }); + + test.each(NAMES_TO_AVOID)('leaves %s free for other components', (tag) => { + expect(customElements.get(tag), `${tag} should not be claimed`).to.not + .exist; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-desktop-subnav.ts b/src/elements/ia-topnav/ia-topnav-desktop-subnav.ts new file mode 100644 index 00000000..2ebec751 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-desktop-subnav.ts @@ -0,0 +1,88 @@ +import { CSSResultGroup, TemplateResult, css, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import TrackedElement from './tracked-element'; +import icons from './assets/img/icons'; +import formatUrl from './lib/format-url'; +import { IATopNavLink } from './models'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-desktop-subnav') +export class DesktopSubnav extends TrackedElement { + @property({ type: String }) baseHost = ''; + @property({ type: Array }) menuItems: IATopNavLink[] = []; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + ul { + position: relative; + z-index: 3; + padding: 0.8rem 0; + margin: 0; + font-size: 1.2rem; + text-transform: uppercase; + text-align: center; + background: var(--desktopSubnavBg); + } + + li { + display: inline-block; + padding: 0 15px; + } + + a { + text-decoration: none; + color: var(--subnavLinkColor); + outline: none; + } + + a:hover, + a:active, + a:focus { + color: var(--linkHoverColor); + } + + .donate svg { + width: 1.6rem; + height: 1.6rem; + vertical-align: top; + fill: #f00; + } + `, + ]; + } + + get listItems() { + return this.menuItems + ? this.menuItems.map( + (link) => html` +
  • + ${link.title}${DesktopSubnav.iconFor(link.title)} +
  • + `, + ) + : nothing; + } + + static iconFor(title: string): TemplateResult { + const subnavIcons: Record = { + Donate: icons.donate, + }; + return subnavIcons[title] ? subnavIcons[title] : html``; + } + + render() { + return html` +
      + ${this.listItems} +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-icon-hamburger.test.ts b/src/elements/ia-topnav/ia-topnav-icon-hamburger.test.ts new file mode 100644 index 00000000..ebe6ac62 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-icon-hamburger.test.ts @@ -0,0 +1,20 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import './ia-topnav-icon-hamburger'; +import { HamBurger } from './ia-topnav-icon-hamburger'; + +import { describe, expect, test } from 'vitest'; +describe('', () => { + test('toggles close icon when property toggled', async () => { + const icon = await fixture( + html``, + ); + + icon.active = true; + await icon.updateComplete; + + const titleElement = icon.shadowRoot?.querySelector('svg title'); + + expect(titleElement?.getAttribute('id')).to.match(/close/); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-icon-hamburger.ts b/src/elements/ia-topnav/ia-topnav-icon-hamburger.ts new file mode 100644 index 00000000..fbcdc16a --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-icon-hamburger.ts @@ -0,0 +1,49 @@ +import { html, css } from 'lit'; +import Icon from './assets/img/icon'; +import icons from './assets/img/icons'; +import { customElement } from '@src/util/custom-element'; + +@customElement('ia-topnav-icon-hamburger') +export class HamBurger extends Icon { + static get styles() { + return css` + svg { + display: block; + height: 4rem; + width: 4rem; + } + .fill-color { + fill: var(--activeColor); + } + `; + } + + static get closed() { + return html` + + Hamburger icon + + An icon used to represent a menu that can be toggled by interacting + with this icon. + + + + `; + } + + static get opened() { + return icons.close; + } + + render() { + return this.active ? HamBurger.opened : HamBurger.closed; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-login-button.test.ts b/src/elements/ia-topnav/ia-topnav-login-button.test.ts new file mode 100644 index 00000000..67e8aa82 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-login-button.test.ts @@ -0,0 +1,32 @@ +import { fixture, oneEvent } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test } from 'vitest'; + +import './ia-topnav-login-button'; +import type { LoginButton } from './ia-topnav-login-button'; + +const component = html``; + +describe('', () => { + test('reports the click rather than opening itself', async () => { + const el = await fixture(component); + const toggle = el.shadowRoot?.querySelector( + '.dropdown-toggle', + ) as HTMLAnchorElement; + + setTimeout(() => toggle.click()); + const event = await oneEvent(el, 'menuToggled'); + + expect(event.detail.menuName).to.equal('login'); + expect(el.shadowRoot?.querySelector('.active')).to.not.exist; + }); + + test('marks the toggle active once the parent opens the login menu', async () => { + const el = await fixture(component); + + el.openMenu = 'login'; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.active')).to.exist; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-login-button.ts b/src/elements/ia-topnav/ia-topnav-login-button.ts new file mode 100644 index 00000000..fa35f018 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-login-button.ts @@ -0,0 +1,177 @@ +import { CSSResultGroup, css, html } from 'lit'; +import TrackedElement from './tracked-element'; +import icons from './assets/img/icons'; +import formatUrl from './lib/format-url'; +import { makeBooleanString } from './lib/make-boolean-string'; +import { property, query } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { IATopNavConfig, TOPNAV_MOBILE_BREAKPOINT } from './models'; +import { defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-login-button') +export class LoginButton extends TrackedElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: String }) openMenu = ''; + + @query('button.logged-out-menu') private toggleButton?: HTMLButtonElement; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + .logged-out-menu { + background: inherit; + border: none; + } + .logged-out-menu:focus-visible { + outline: none; + border: none; + } + .dropdown-toggle { + display: block; + text-transform: uppercase; + color: var(--grey80); + cursor: pointer; + } + + .dropdown-toggle svg { + height: 100%; + width: 4rem; + } + + .dropdown-toggle .fill-color { + fill: var(--iconFill); + } + + .dropdown-toggle:active .fill-color, + .dropdown-toggle:focus .fill-color, + .dropdown-toggle:hover .fill-color { + fill: var(--linkHoverColor); + } + + .active { + border-radius: 1rem 1rem 0 0; + background: var(--activeButtonBg); + } + + .active .fill-color { + fill: var(--activeColor); + } + + span { + display: none; + font-size: 1.4rem; + text-transform: uppercase; + color: var(--loginTextColor); + } + + span a { + color: inherit; + text-decoration: none; + outline: 0; + } + + a:hover, + a:active, + a:focus { + color: var(--linkHoverColor) !important; + outline: none !important; + outline-offset: inherit !important; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + .logged-out-toolbar { + padding: 1rem 0.5rem; + vertical-align: middle; + } + + .active { + background: transparent; + } + + .dropdown-toggle { + display: inline-block; + vertical-align: middle; + } + + .dropdown-toggle svg { + height: 3rem; + width: 3rem; + display: block; + } + + span { + display: inline; + vertical-align: middle; + } + } + `, + ]; + } + + /** Distance (px) from this element's right edge to the right edge of the dropdown toggle icon. */ + getDropdownToggleOffset(): number { + if (!this.toggleButton) return 0; + return ( + this.getBoundingClientRect().right - + this.toggleButton.getBoundingClientRect().right + ); + } + + get signupPath() { + return formatUrl('/signup', this.baseHost); + } + + get loginPath() { + return formatUrl('/login', this.baseHost); + } + + get analyticsEvent() { + return `${this.config?.eventCategory}|NavLoginIcon`; + } + + get menuOpened(): boolean { + return this.openMenu === 'login'; + } + + get avatarClass() { + return `dropdown-toggle${this.menuOpened ? ' active' : ''}`; + } + + toggleDropdown(e: Event) { + e.preventDefault(); + this.trackClick(e); + this.dispatchEvent( + new CustomEvent('menuToggled', { + bubbles: true, + composed: true, + detail: { + menuName: 'login', + }, + }), + ); + } + + render() { + return html` +
    + + + Sign up + | + Log in + +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-media-button.test.ts b/src/elements/ia-topnav/ia-topnav-media-button.test.ts new file mode 100644 index 00000000..a592eb8e --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-button.test.ts @@ -0,0 +1,23 @@ +import { fixture, oneEvent } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import './ia-topnav-media-button'; +import { MediaButton } from './ia-topnav-media-button'; + +import { describe, expect, test } from 'vitest'; +describe('', () => { + test('emits an event when button pressed', async () => { + const el = await fixture( + html``, + ); + + const link = el.shadowRoot?.querySelector( + '.menu-item', + ) as HTMLAnchorElement; + + setTimeout(() => link.click()); + const response = await oneEvent(el, 'mediaTypeSelected'); + + expect(response).to.exist; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-media-button.ts b/src/elements/ia-topnav/ia-topnav-media-button.ts new file mode 100644 index 00000000..27ba523d --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-button.ts @@ -0,0 +1,260 @@ +import { CSSResultGroup, TemplateResult, css, html } from 'lit'; +import TrackedElement from './tracked-element'; +import icons from './assets/img/icons'; +import toSentenceCase from './lib/t-sentence-case'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { IATopNavConfig, TOPNAV_MOBILE_BREAKPOINT } from './models'; +import { defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-media-button') +export class MediaButton extends TrackedElement { + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: String }) icon = ''; + @property({ type: String }) href = ''; + @property({ type: String }) label = ''; + @property({ type: String }) mediatype = ''; + @property({ type: String }) openMenu = ''; + @property({ type: Boolean }) selected = false; + @property({ type: Boolean }) followable = false; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + a { + display: inline-block; + text-decoration: none; + } + + .menu-item { + display: inline-block; + width: 100%; + padding: 0; + font-size: 1.6rem; + text-align: left; + background: transparent; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + } + + .menu-item:focus { + outline: none; + } + + .label { + display: inline-block; + padding: 0; + font-weight: 400; + color: var(--primaryTextColor); + text-align: left; + vertical-align: middle; + } + + .menu-item > .icon { + display: inline-flex; + vertical-align: middle; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + } + + .menu-item > .icon > svg { + height: 4rem; + width: 4rem; + } + + .menu-item.selected .icon { + background-color: var(--activeButtonBg); + border-radius: 1rem 0 0 1rem; + } + + .icon .fill-color { + fill: #999; + } + + .icon.active .fill-color { + fill: #fff; + } + + .donate .fill-color { + fill: #f00; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + .menu-item { + width: auto; + height: 5rem; + color: var(--mediaLabelDesktopColor); + display: inline-flex; + } + .menu-item:hover, + .menu-item:active, + .menu-item:focus { + color: var(--linkHoverColor); + } + + .menu-item:hover .fill-color, + .menu-item:active .fill-color, + .menu-item:focus .fill-color { + fill: var(--linkHoverColor); + } + + .label { + display: none; + } + + .label, + .web:after { + padding-right: 1rem; + font-size: 1.3rem; + text-transform: uppercase; + color: inherit; + } + + .web:after { + display: none; + content: 'web'; + } + .donate, + .more { + display: none; + } + + .menu-item.selected { + background: var(--activeDesktopMenuIcon); + } + + .menu-item.selected .label, + .menu-item.selected.web:after { + color: var(--linkHoverColor); + } + + .menu-item.selected .icon { + background: transparent; + } + + /* selected state icon colors */ + .web.selected .fill-color { + fill: #ffcd27; + } + + .texts.selected .fill-color { + fill: #faab3c; + } + + .video.selected .fill-color { + fill: #f1644b; + } + + .audio.selected .fill-color { + fill: #00adef; + } + + .software.selected .fill-color { + fill: #9ecc4f; + } + + .images.selected .fill-color { + fill: #aa99c9; + } + } + + @media (min-width: 1200px) { + .label, + .web:after { + display: inline; + } + + .web .label { + display: none; + } + } + `, + ]; + } + + static get icons(): Record { + return icons; + } + + onClick(e: Event) { + this.trackClick(e); + e.preventDefault(); + // On desktop viewport widths, the media subnav is always visible. To + // ensure the media subnav is open on mobile if the viewport is + // resized, the openMenu needs to be set to 'media'. + if (this.openMenu !== 'media') { + this.dispatchMenuToggledEvent(); + } + this.dispatchMediaTypeSelectedEvent(); + } + + dispatchMenuToggledEvent() { + this.dispatchEvent( + new CustomEvent('menuToggled', { + bubbles: true, + composed: true, + detail: { + menuName: 'media', + }, + }), + ); + } + + dispatchMediaTypeSelectedEvent() { + this.dispatchEvent( + new CustomEvent('mediaTypeSelected', { + bubbles: true, + composed: true, + detail: { + mediatype: this.mediatype, + }, + }), + ); + } + + get buttonClass() { + return this.selected ? 'selected' : ''; + } + + get tooltipPrefix() { + return this.selected ? 'Collapse' : 'Expand'; + } + + get iconClass() { + return this.selected ? 'active' : ''; + } + + get analyticsEvent() { + return `${this.config.eventCategory}|NavMenu${toSentenceCase(this.mediatype)}`; + } + + get menuItem() { + return html` + + ${MediaButton.icons[this.icon]} + + ${this.label} + `; + } + + render() { + return html` + + ${this.menuItem} + + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-media-menu.test.ts b/src/elements/ia-topnav/ia-topnav-media-menu.test.ts new file mode 100644 index 00000000..e8286edc --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-menu.test.ts @@ -0,0 +1,44 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import './ia-topnav-media-menu'; +import { MediaMenu } from './ia-topnav-media-menu'; + +import { describe, expect, test } from 'vitest'; +const component = html``; + +describe('', () => { + test('sets default properties', async () => { + const mediaMenu = await fixture(component); + + expect(mediaMenu.openMenu).to.equal(''); + expect(mediaMenu.selectedMenuOption).to.equal(''); + }); + + test('renders menu icon as selected when selectedMenuOption matches', async () => { + const mediaMenu = await fixture(component); + const mediaType = 'texts'; + + mediaMenu.selectedMenuOption = mediaType; + await mediaMenu.updateComplete; + + const textsButton = mediaMenu.shadowRoot + ?.querySelector(`[data-mediatype=${mediaType}`) + ?.shadowRoot?.querySelector('.selected'); + + expect(textsButton).to.not.be.null; + }); + + test('renders with closed class if done animating', async () => { + const mediaMenu = await fixture(component); + + // mediaMenu.mediaMenuAnimate = true; + // await mediaMenu.updateComplete; + + expect( + mediaMenu.shadowRoot + ?.querySelector('.media-menu-container') + ?.classList.contains('closed'), + ).to.be.true; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-media-menu.ts b/src/elements/ia-topnav/ia-topnav-media-menu.ts new file mode 100644 index 00000000..df255f5a --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-menu.ts @@ -0,0 +1,210 @@ +import { CSSResultGroup, LitElement, PropertyValues, css, html } from 'lit'; +import { property, queryAll } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import { defaultTopNavConfig } from './data/menus'; +import formatUrl from './lib/format-url'; +import './ia-topnav-media-button'; +import { MediaButton } from './ia-topnav-media-button'; +import { IATopNavConfig, TOPNAV_MOBILE_BREAKPOINT } from './models'; +import themeStyles from '@src/themes/theme-styles'; + +const menuSelection = [ + { + icon: 'web', + menu: 'web', + href: 'https://web.archive.org', + label: 'Wayback Machine', + }, + { + icon: 'texts', + menu: 'texts', + href: '/details/texts', + label: 'Texts', + }, + { + icon: 'video', + menu: 'video', + href: '/details/movies', + label: 'Video', + }, + { + icon: 'audio', + menu: 'audio', + href: '/details/audio', + label: 'Audio', + }, + { + icon: 'software', + menu: 'software', + href: '/details/software', + label: 'Software', + }, + { + icon: 'images', + menu: 'images', + href: '/details/image', + label: 'Images', + }, + { + icon: 'donate', + menu: 'donate', + href: '/donate/?origin=iawww-mbhmbgrmenu', + label: 'Donate', + followable: true, + }, + { + icon: 'ellipses', + menu: 'more', + href: '/about/', + label: 'More', + }, +]; + +@customElement('ia-topnav-media-menu') +export class MediaMenu extends LitElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: String }) openMenu = ''; + @property({ type: String }) selectedMenuOption = ''; + @property({ type: Object }) currentTab: { moveTo: string } | undefined; + + @queryAll('ia-topnav-media-button') mediaButtons?: MediaButton[]; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + outline: none; + } + + .media-menu-inner { + z-index: -1; + top: -40rem; + background-color: var(--mediaMenuBg); + margin: 0; + overflow: hidden; + transition-duration: 0.2s; + transition-property: top; + transition-timing-function: ease; + } + + .menu-group { + position: relative; + line-height: normal; + } + + /* Mobile view styles */ + @media (max-width: ${TOPNAV_MOBILE_BREAKPOINT - 1}px) { + .media-menu-inner { + position: absolute; + width: 100%; + } + + .open .media-menu-inner { + top: 0; + } + + .overflow-clip { + position: absolute; + z-index: -1; /** needs to be under the navigation, otherwise it intercepts clicks */ + top: 4rem; + left: 0; + height: 0; + width: 100%; + overflow: hidden; + transition-duration: 0.2s; + transition-property: height; + } + + .open .overflow-clip { + height: 40rem; + } + } + + /* Desktop view styles */ + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + .media-menu-inner { + display: block; + position: static; + width: auto; + height: 5rem; + transition-property: none; + } + + .menu-group { + font-size: 0; + } + } + `, + ]; + } + + updated(props: PropertyValues) { + if (props.has('currentTab')) { + const mediaButtons = Array.from(this.mediaButtons ?? []); + + mediaButtons.map((button, index) => { + const linkItem = button.shadowRoot?.querySelector('a.menu-item'); + if (linkItem) { + if (linkItem.classList.contains(`${this.selectedMenuOption}`)) { + linkItem.classList.remove('selected'); + (linkItem as HTMLElement).blur(); + + const newFocusIndex = + this.currentTab?.moveTo === 'next' ? index + 1 : index - 1; + ( + mediaButtons[newFocusIndex]?.shadowRoot?.querySelector( + 'a.menu-item', + ) as HTMLElement + ).focus(); + } + } + }); + } + } + + get mediaMenuOptionsTemplate() { + const buttons = menuSelection.map( + ({ icon, menu, label, href, followable }) => { + const selected = this.selectedMenuOption === menu; + return html` + + `; + }, + ); + return buttons; + } + + get menuOpened() { + return this.openMenu === 'media'; + } + + get menuClass() { + return this.menuOpened ? 'open' : 'closed'; + } + + render() { + return html` +
    +
    + +
    +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-media-slider.test.ts b/src/elements/ia-topnav/ia-topnav-media-slider.test.ts new file mode 100644 index 00000000..1650d227 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-slider.test.ts @@ -0,0 +1,69 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html, type LitElement } from 'lit'; + +import './ia-topnav-media-slider'; +import { buildTopNavMenus } from './data/menus'; +import { MediaSlider } from './ia-topnav-media-slider'; + +import { describe, expect, test } from 'vitest'; +const menus = buildTopNavMenus(); + +const component = (mediaSliderOpen: boolean, selectedMenuOption: string) => + html``; + +describe('', () => { + test('sets default properties', async () => { + const mediaSlider = await fixture(component(false, '')); + + expect(mediaSlider.mediaSliderOpen).to.be.false; + expect(mediaSlider.selectedMenuOption).to.equal(''); + }); + + test('renders a media subnav when texts selected', async () => { + const mediaSlider = await fixture(component(false, 'texts')); + const menuHeading = mediaSlider.shadowRoot + ?.querySelector('ia-topnav-media-subnav[menu=texts]') + ?.shadowRoot?.querySelector('h3'); + expect(menuHeading?.innerText).to.equal(menus.texts.heading); + }); + + test('renders a media subnav when audio selected', async () => { + const mediaSlider = await fixture(component(false, 'audio')); + const menuHeading = mediaSlider.shadowRoot + ?.querySelector('ia-topnav-media-subnav[menu=audio]') + ?.shadowRoot?.querySelector('h3'); + expect(menuHeading?.innerText).to.equal(menus.audio.heading); + }); + + test('renders a media subnav when video selected', async () => { + const mediaSlider = await fixture(component(false, 'video')); + const menuHeading = mediaSlider.shadowRoot + ?.querySelector('ia-topnav-media-subnav[menu=video]') + ?.shadowRoot?.querySelector('h3'); + expect(menuHeading?.innerText).to.equal(menus.video.heading); + }); + + test('renders the Wayback component when web menu selected', async () => { + const mediaSlider = await fixture(component(false, 'web')); + const waybackSlider = mediaSlider.shadowRoot + ?.querySelector('ia-topnav-media-subnav[menu=web]') + ?.shadowRoot?.querySelector('ia-topnav-wayback-slider'); + expect(waybackSlider).to.exist; + + await (waybackSlider as LitElement)?.updateComplete; + expect(waybackSlider?.shadowRoot?.querySelector('ia-topnav-wayback-search')) + .to.exist; + }); + + test('renders the more links component when more menu selected', async () => { + const mediaSlider = await fixture(component(false, 'more')); + const moreSlider = mediaSlider.shadowRoot + ?.querySelector('ia-topnav-media-subnav[menu=more]') + ?.shadowRoot?.querySelector('ia-topnav-more-slider'); + expect(moreSlider).to.exist; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-media-slider.ts b/src/elements/ia-topnav/ia-topnav-media-slider.ts new file mode 100644 index 00000000..9cd39d27 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-slider.ts @@ -0,0 +1,217 @@ +import { CSSResultGroup, LitElement, PropertyValues, css, html } from 'lit'; +import './ia-topnav-media-subnav'; +import KeyboardNavigation from './lib/keyboard-navigation'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { + IATopNavConfig, + IATopNavMenuConfig, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import { buildTopNavMenus, defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-media-slider') +export class MediaSlider extends LitElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: Boolean }) mediaSliderOpen = false; + @property({ type: Object }) menus: IATopNavMenuConfig = buildTopNavMenus(); + @property({ type: String }) selectedMenuOption = 'texts'; + + private previousKeydownListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((this: HTMLElement, ev: KeyboardEvent) => any) | undefined; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + .media-slider-container { + position: relative; + } + + .overflow-clip { + display: none; + position: absolute; + top: 0; + right: 0; + left: 0; + height: 0; + overflow: hidden; + transition: height 0.2s ease; + } + + .information-menu { + position: absolute; + top: 0; + right: 0; + left: 0; + padding: 0; + height: 31.9rem; + overflow-x: hidden; + font-size: 1.4rem; + background: var(--mediaSliderBg); + } + + .open { + display: block; + } + + .hidden { + display: none; + } + + .info-box { + padding: 1rem; + } + + @media (max-width: ${TOPNAV_MOBILE_BREAKPOINT - 1}px) { + .overflow-clip.open { + display: block; + height: 35.8rem; + left: 4rem; + top: 0; + } + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + .overflow-clip { + display: block; + } + + .information-menu { + left: 0; + z-index: 3; + height: auto; + min-height: 21rem; + background: var(--mediaSliderDesktopBg); + transform: translate(0, -100%); + transition: transform 0.2s ease; + } + + .overflow-clip.open { + height: 22rem; + } + + .information-menu.open { + transform: translate(0, 0); + } + + .info-box { + max-width: 100rem; + padding: 1.5rem 0; + margin: 0 auto; + } + } + `, + ]; + } + + updated(props: PropertyValues) { + if (props.has('selectedMenuOption') && this.selectedMenuOption) { + const container = + this.shadowRoot?.querySelector('.has-focused')?.shadowRoot; + if (container) { + const keyboardNavigation = new KeyboardNavigation( + container as unknown as HTMLElement, + this.selectedMenuOption, + ); + + if (this.previousKeydownListener) { + this.removeEventListener('keydown', this.previousKeydownListener); + } + this.addEventListener('keydown', keyboardNavigation.handleKeyDown); + this.previousKeydownListener = keyboardNavigation.handleKeyDown; + } + } + } + + shouldUpdate() { + const scrollPane = this.shadowRoot + ? this.shadowRoot.querySelector('.information-menu') + : null; + + if (scrollPane) { + scrollPane.scrollTop = 0; + } + return true; + } + + render() { + const sliderDetailsClass = this.mediaSliderOpen ? 'open' : 'closed'; + + return html` +
    +
    +
    +
    + + + + + + + +
    +
    +
    +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-media-subnav.ts b/src/elements/ia-topnav/ia-topnav-media-subnav.ts new file mode 100644 index 00000000..e722efae --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-media-subnav.ts @@ -0,0 +1,295 @@ +import { CSSResultGroup, css, html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import TrackedElement from './tracked-element'; +import './ia-topnav-wayback-slider'; +import './ia-topnav-more-slider'; +import toSentenceCase from './lib/t-sentence-case'; +import formatUrl from './lib/format-url'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { + IATopNavConfig, + IATopNavLink, + IATopNavMediaMenu, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import { defaultTopNavConfig } from './data/menus'; +import { subnavListCSS } from './subnav-list-styles'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-media-subnav') +export class MediaSubnav extends TrackedElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: String }) menu: + | '' + | 'web' + | 'more' + | 'audio' + | 'images' + | 'software' + | 'texts' + | 'video' = ''; + @property({ type: Object }) menuItems: IATopNavMediaMenu = + MediaSubnav.defaultLinks; + + private links: IATopNavMediaMenu = MediaSubnav.defaultLinks; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + subnavListCSS, + css` + img { + display: block; + width: 90px; + height: 90px; + margin: 0 auto 1rem auto; + border-radius: 45px; + } + + h3 { + margin-top: 0; + font-size: 1.8rem; + } + + .icon-links { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: space-evenly; + -ms-flex-pack: space-evenly; + justify-content: space-evenly; + text-align: center; + } + + .icon-links a { + display: inline-block; + width: 12rem; + margin-bottom: 1.5rem; + overflow: hidden; + white-space: nowrap; + text-align: center; + text-overflow: ellipsis; + } + + .icon-links a + a { + margin-left: 2rem; + } + + .featured h4 { + display: none; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + :host { + display: -ms-grid; + display: grid; + -ms-grid-columns: 40% 20% 40%; + grid-template-columns: 40% 20% 40%; + } + + .wayback-search { + -ms-grid-column: 1; + -ms-grid-column-span: 3; + grid-column: 1 / 4; + } + + h3 { + display: none; + } + + .icon-links { + -ms-grid-column: 1; + } + + .icon-links a { + padding-top: 3.5rem; + max-width: 16rem; + } + + .links { + padding: 0 1.5rem; + } + + .featured { + -ms-grid-column: 2; + } + + .featured h4 { + display: block; + } + + .top { + -ms-grid-column: 3; + } + + .top ul { + display: -ms-grid; + display: grid; + -ms-grid-columns: 50% 3rem 50%; + grid-template-columns: 50% 50%; + -ms-grid-rows: (auto) [7]; + grid-template-rows: repeat(7, auto); + grid-column-gap: 3rem; + grid-auto-flow: column; + } + .top ul > *:nth-child(1) { + -ms-grid-row: 1; + -ms-grid-column: 1; + } + .top ul > *:nth-child(2) { + -ms-grid-row: 2; + -ms-grid-column: 1; + } + .top ul > *:nth-child(3) { + -ms-grid-row: 3; + -ms-grid-column: 1; + } + .top ul > *:nth-child(4) { + -ms-grid-row: 4; + -ms-grid-column: 1; + } + .top ul > *:nth-child(5) { + -ms-grid-row: 5; + -ms-grid-column: 1; + } + .top ul > *:nth-child(6) { + -ms-grid-row: 6; + -ms-grid-column: 1; + } + .top ul > *:nth-child(7) { + -ms-grid-row: 7; + -ms-grid-column: 1; + } + .top ul > *:nth-child(8) { + -ms-grid-row: 1; + -ms-grid-column: 3; + } + .top ul > *:nth-child(9) { + -ms-grid-row: 2; + -ms-grid-column: 3; + } + .top ul > *:nth-child(10) { + -ms-grid-row: 3; + -ms-grid-column: 3; + } + .top ul > *:nth-child(11) { + -ms-grid-row: 4; + -ms-grid-column: 3; + } + .top ul > *:nth-child(12) { + -ms-grid-row: 5; + -ms-grid-column: 3; + } + .top ul > *:nth-child(13) { + -ms-grid-row: 6; + -ms-grid-column: 3; + } + .top ul > *:nth-child(14) { + -ms-grid-row: 7; + -ms-grid-column: 3; + } + } + `, + ]; + } + + shouldUpdate() { + if (this.menuItems) { + this.links = this.menuItems; + } + return true; + } + + static get defaultLinks(): IATopNavMediaMenu { + return { + heading: '', + iconLinks: [], + featuredLinks: [], + links: [], + mobileAppsLinks: [], + browserExtensionsLinks: [], + archiveItLinks: [], + }; + } + + analyticsEvent(title: string) { + return `${this.config?.eventCategory}|${toSentenceCase(title)}${toSentenceCase(this.menu)}`; + } + + get iconLinks() { + return this.links.iconLinks.map( + (link) => html` + ${link.title} + `, + ); + } + + renderLinks(links: IATopNavLink[]) { + return links.map( + (link) => html` +
  • + ${link.title} +
  • + `, + ); + } + + render() { + if (!this.menu) { + return html``; + } + + if (this.menuItems) { + this.links = this.menuItems; + } + + if (this.menu === 'web') { + return html` `; + } + + if (this.menu === 'more') { + return html` + `; + } + + return html` +

    ${this.links.heading}

    + + + + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-more-slider.test.ts b/src/elements/ia-topnav/ia-topnav-more-slider.test.ts new file mode 100644 index 00000000..7b12a923 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-more-slider.test.ts @@ -0,0 +1,23 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import './ia-topnav-more-slider'; +import { buildTopNavMenus } from './data/menus'; + +import { describe, expect, test } from 'vitest'; +describe('', () => { + test('renders links with relative hrefs using baseHost', async () => { + const menus = buildTopNavMenus(); + const baseHost = 'archive.org'; + const el = await fixture( + html``, + ); + + expect(el.shadowRoot?.querySelector('a')?.getAttribute('href')).to.contain( + baseHost, + ); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-more-slider.ts b/src/elements/ia-topnav/ia-topnav-more-slider.ts new file mode 100644 index 00000000..63276814 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-more-slider.ts @@ -0,0 +1,58 @@ +import { CSSResultGroup, css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import TrackedElement from './tracked-element'; +import toSentenceCase from './lib/t-sentence-case'; +import formatUrl from './lib/format-url'; +import { IATopNavConfig, IATopNavLink } from './models'; +import { defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-more-slider') +export class MoreSlider extends TrackedElement { + @property({ type: String }) baseHost = ''; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: Array }) menuItems: IATopNavLink[] = []; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + ul { + padding: 0; + margin: -1rem 0 0 0; + list-style: none; + } + a { + display: block; + padding: 1rem 0; + text-decoration: none; + color: var(--activeColor); + } + `, + ]; + } + + analyticsEvent(title: string) { + return `${this.config.eventCategory}|NavMore${toSentenceCase(title)}`; + } + + render() { + return html` +
      + ${this.menuItems.map( + (item) => + html`
    • + ${item.title} +
    • `, + )} +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-primary-nav.test.ts b/src/elements/ia-topnav/ia-topnav-primary-nav.test.ts new file mode 100644 index 00000000..08d1233d --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-primary-nav.test.ts @@ -0,0 +1,136 @@ +import { + elementUpdated, + fixture, + fixtureCleanup, +} from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import './ia-topnav-primary-nav'; +import { IATopNavConfig, IATopNavSecondIdentitySlotMode } from './models'; +import { PrimaryNav } from './ia-topnav-primary-nav'; + +import { afterEach, describe, expect, test } from 'vitest'; +const component = ({ + baseHost = 'archive.org', + username = '', + screenName = '', + hideSearch = false, + config = {}, + secondIdentitySlotMode = 'allow', +}: { + baseHost?: string; + username?: string; + screenName?: string; + hideSearch?: boolean; + config?: IATopNavConfig; + secondIdentitySlotMode?: IATopNavSecondIdentitySlotMode; +}) => + html` `; + +afterEach(() => { + fixtureCleanup(); +}); + +describe('', () => { + test('renders the login link when no username present', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: '', + }), + ); + + expect(el.shadowRoot?.querySelector('ia-topnav-login-button')).to.not.be + .undefined; + }); + + test('does not render search trigger or search slot if hideSearch true', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: 'shaneriley', + screenName: 'shaneriley', + hideSearch: true, + }), + ); + + expect(el.shadowRoot?.querySelector('.search-trigger')).to.equal(null); + expect(el.shadowRoot?.querySelector('.search-container')).to.not.exist; + }); + + test('renders search slot container', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: 'testuser', + screenName: 'testuser', + }), + ); + + expect(el.shadowRoot?.querySelector('.search-container')).to.exist; + + const slot = el.shadowRoot?.querySelector( + 'slot[name="search"]', + ); + expect(slot).to.exist; + }); + + test('renders search trigger button for mobile toggle', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: 'testuser', + screenName: 'testuser', + }), + ); + + expect(el.shadowRoot?.querySelector('.search-trigger')).to.exist; + }); + + test('does not render nav-search', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: 'testuser', + screenName: 'testuser', + }), + ); + + expect(el.shadowRoot?.querySelector('nav-search')).to.not.exist; + }); + + test('opens a slot with `secondIdentitySlotMode`', async () => { + const el = await fixture( + component({ + baseHost: 'archive.org', + username: 'boop', + screenName: 'somesuperlongscreenname', + secondIdentitySlotMode: 'allow', + }), + ); + + const brandingBlock = el.shadowRoot?.querySelector('div.branding'); + expect(brandingBlock?.getAttribute('class')).to.contain( + 'branding second-logo', + ); + + const slot = brandingBlock?.querySelector('slot'); + expect(slot).to.exist; + expect(slot?.getAttribute('name')).to.equal('opt-sec-logo'); + + el.secondIdentitySlotMode = ''; + await elementUpdated(el); + const noSlotBrandingBlock = el.shadowRoot?.querySelector('div.branding'); + expect(noSlotBrandingBlock?.getAttribute('class')).to.contain('branding'); + + const noSlot = noSlotBrandingBlock?.querySelector('slot'); + expect(noSlot).to.not.exist; + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-primary-nav.ts b/src/elements/ia-topnav/ia-topnav-primary-nav.ts new file mode 100644 index 00000000..b419c7d7 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-primary-nav.ts @@ -0,0 +1,663 @@ +import { CSSResultGroup, PropertyValues, css, html, nothing } from 'lit'; +import TrackedElement from './tracked-element'; +import icons from './assets/img/icons'; +import './ia-topnav-icon-hamburger'; +import './ia-topnav-login-button'; +import type { LoginButton } from './ia-topnav-login-button'; +import './ia-topnav-media-menu'; +import logoWordmarkStacked from './assets/img/wordmark-stacked'; +import formatUrl from './lib/format-url'; +import { property, query } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { + IATopNavConfig, + IATopNavSecondIdentitySlotMode, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import { defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-primary-nav') +export class PrimaryNav extends TrackedElement { + @property({ type: String }) mediaBaseHost = 'https://archive.org'; + @property({ type: String }) baseHost = ''; + @property({ type: Boolean }) hideSearch = false; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: String }) openMenu = ''; + @property({ type: String }) screenName = ''; + @property({ type: String }) + secondIdentitySlotMode: IATopNavSecondIdentitySlotMode = ''; + @property({ type: String }) selectedMenuOption = ''; + @property({ type: Boolean }) signedOutMenuOpen = false; + @property({ type: Boolean }) userMenuOpen = false; + @property({ type: Boolean }) mediaMenuAnimate = false; + @property({ type: String }) username = ''; + @property({ type: String }) userProfileImagePath = ''; + @property({ type: Object }) currentTab: + | { mediatype: string; moveTo: string } + | undefined; + signedOutMenuToggled: unknown; + + @query('button.user-menu') private userMenuButton?: HTMLButtonElement; + @query('ia-topnav-login-button') private loginButton?: HTMLElement; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + button:focus, + input:focus { + outline: none; + } + + nav { + position: relative; + display: flex; + height: 4rem; + grid-template-areas: 'hamburger empty heart search user'; + -ms-grid-columns: 4rem minmax(1rem, 100%) 4rem 4rem 4rem; + grid-template-columns: 4rem auto 4rem 4rem 4rem; + -ms-grid-rows: 100%; + grid-template-rows: 100%; + background: var(--primaryNavBg); + border-bottom: 1px solid var(--primaryNavBottomBorder); + } + + nav.hide-search { + grid-template-areas: 'hamburger empty heart user'; + -ms-grid-columns: 4rem minmax(1rem, 100%) 4rem 4rem; + grid-template-columns: 4rem auto 4rem 4rem; + } + + .right-side-section { + display: flex; + margin-left: auto; + user-select: none; + } + button { + background: none; + color: inherit; + border: none; + font: inherit; + cursor: pointer; + } + + .branding { + position: static; + float: left; + margin: 0 !important; + padding: 0 5px 0 10px; + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + transform: translate(0, 0); + } + + slot, + .branding { + display: flex; + justify-content: left; + align-items: center; + } + + ia-topnav-media-menu { + flex: 1; + justify-self: stretch; + } + + .ia-logo { + height: 3rem; + width: 2.7rem; + display: inline-block; + } + .ia-wordmark { + height: 3rem; + width: 9.5rem; + } + .ia-logo, + .ia-wordmark { + margin-right: 5px; + } + + .hamburger { + -ms-grid-row: 1; + -ms-grid-column: 1; + grid-area: hamburger; + padding: 0; + } + .hamburger svg { + height: 4rem; + width: 4rem; + fill: var(--activeColor); + } + + .mobile-donate-link { + display: inline-block; + } + .mobile-donate-link svg { + height: 4rem; + width: 4rem; + } + .mobile-donate-link .fill-color { + fill: rgb(255, 0, 0); + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + white-space: nowrap; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + user-select: none; + } + + .search-trigger { + padding: 0; + } + .search-trigger svg { + height: 4rem; + width: 4rem; + } + .search-trigger .fill-color { + fill: var(--iconFill); + } + + .search-container { + display: none; + } + + .search-container.open { + display: flex; + position: absolute; + top: 0; + right: 4rem; + bottom: 0; + left: 4rem; + z-index: 3; + padding: 0.5rem; + border-radius: 1rem 1rem 0 0; + background: var(--primaryNavBg); + align-items: center; + animation: fade-in 0.2s forwards; + } + + .search-container ::slotted(*) { + display: block; + } + + .search-container slot { + width: 100%; + } + + @keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + .upload { + display: none; + } + + .upload span { + display: none; + } + + .upload svg { + height: 3rem; + width: 3rem; + } + + .screen-name { + display: none; + font-size: 1.3rem; + vertical-align: middle; + text-transform: uppercase; + } + + .user-menu { + color: var(--lightTextColor); + padding: 0.5rem; + height: 100%; + } + + button.user-menu:hover, + button.user-menu:focus { + color: var(--linkHoverColor); + outline: none; + } + + .user-menu.active { + border-radius: 1rem 1rem 0 0; + background: var(--activeButtonBg); + } + + .user-menu img { + display: block; + width: 3rem; + height: 3rem; + } + + .link-home { + text-decoration: none; + display: inline-flex; + } + a.link-home:focus, + a.link-home:focus-visible { + outline-offset: 1px; + } + + @media only screen and (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) and (max-device-width: 905px) { + .branding.second-logo { + padding-right: 0; + } + } + + @media (min-width: 906px) { + .branding.second-logo { + padding-right: 20px; + } + } + + @media (max-width: ${TOPNAV_MOBILE_BREAKPOINT - 1}px) { + slot[name='opt-sec-logo'] { + display: none; + } + + .right-side-section { + display: initial; + } + .right-side-section .user-info { + float: right; + } + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + :host { + --userIconWidth: 3.2rem; + --userIconHeight: 3.2rem; + } + + nav { + display: flex; + z-index: 4; + height: 5rem; + padding-right: 1.5rem; + } + + slot[name='opt-sec-logo-mobile'] { + display: none; + } + + .ia-logo, + .ia-wordmark { + margin-right: 10px; + } + + .hamburger, + .search-trigger, + .mobile-donate-link { + display: none; + } + + .user-info { + display: block; + float: right; + vertical-align: middle; + height: 100%; + } + + .user-info .user-menu img { + height: 3rem; + width: 3rem; + margin-right: 0.5rem; + } + + .user-menu { + padding: 1rem 0.5rem; + } + .user-menu.active { + background: transparent; + } + + .user-menu img { + display: inline-block; + vertical-align: middle; + margin-right: 0.5rem; + } + + .upload { + display: block; + padding: 1rem 0.5rem; + float: right; + font-size: 1.4rem; + text-transform: uppercase; + text-decoration: none; + color: var(--lightTextColor); + } + .upload:active, + .upload:focus, + .upload:hover { + color: var(--linkHoverColor); + } + .upload:focus-visible { + outline: none; + } + + .upload svg { + vertical-align: middle; + fill: var(--iconFill); + } + + .upload:hover svg, + .upload:focus svg, + .upload:active svg { + fill: var(--linkHoverColor); + } + + .search-container, + .search-container.open { + display: flex; + position: static; + top: auto; + right: auto; + bottom: auto; + left: auto; + align-items: center; + padding: 0 0 0 1rem; + background: transparent; + border-radius: 0; + z-index: auto; + } + + .search-container slot { + width: auto; + } + } + + @media (min-width: 990px) { + .screen-name { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 165px; + } + + .upload span { + display: inline-block; + vertical-align: middle; + } + } + `, + ]; + } + + /** Distance (px) from this element's right edge to the right edge of the account dropdown toggle. */ + getAccountDropdownOffset(): number { + const hostRect = this.getBoundingClientRect(); + + if (this.userMenuButton) { + return hostRect.right - this.userMenuButton.getBoundingClientRect().right; + } + + if (this.loginButton) { + const loginRect = this.loginButton.getBoundingClientRect(); + const innerOffset = ( + this.loginButton as LoginButton + ).getDropdownToggleOffset(); + return hostRect.right - loginRect.right + innerOffset; + } + + return 0; + } + + toggleMediaMenu(e: Event) { + this.trackClick(e); + this.dispatchEvent( + new CustomEvent('menuToggled', { + detail: { + menuName: 'media', + }, + }), + ); + } + + toggleSearchMenu(e: Event) { + this.trackClick(e); + this.dispatchEvent( + new CustomEvent('menuToggled', { + detail: { + menuName: 'search', + }, + }), + ); + } + + toggleUserMenu(e: Event) { + this.trackClick(e); + this.dispatchEvent( + new CustomEvent('menuToggled', { + detail: { + menuName: 'user', + }, + }), + ); + } + + updated(props: PropertyValues) { + if (props.has('currentTab')) { + // early return + if (!this.currentTab || Object.keys(this.currentTab).length === 0) + return nothing; + + const isUserMenuTab = + this.currentTab && this.currentTab.mediatype === 'usermenu'; + if (isUserMenuTab) { + const mediaButtons = Array.from( + this.shadowRoot + ?.querySelector('ia-topnav-media-menu') + ?.shadowRoot?.querySelectorAll('ia-topnav-media-button') ?? [], + ); + const lastMediaButton = mediaButtons.filter((element) => { + return element.shadowRoot + ?.querySelector('a') + ?.classList.contains('images'); + }); + + let nextElement; + if (this.username) { + nextElement = this.shadowRoot?.querySelector('a.upload'); + } else { + nextElement = this.shadowRoot + ?.querySelector('ia-topnav-login-button') + ?.shadowRoot?.querySelector('span a'); + } + + const menuItemElement = + lastMediaButton[0]?.shadowRoot?.querySelector('a.menu-item'); + + const focusElement = + this.currentTab.moveTo === 'next' ? nextElement : menuItemElement; + + if (focusElement) { + (focusElement as HTMLElement).focus(); + } + } else if (this.currentTab.moveTo === 'next') { + if (this.shadowRoot?.querySelector('.user-menu')) { + (this.shadowRoot?.querySelector('.user-menu') as HTMLElement).focus(); + } else { + ( + this.shadowRoot + ?.querySelector('ia-topnav-login-button') + ?.shadowRoot?.querySelectorAll('span a')[0] as HTMLElement + )?.focus(); + } + } + } + } + + get userIcon() { + const userMenuClass = this.openMenu === 'user' ? 'active' : ''; + const userMenuToolTip = + this.openMenu === 'user' ? 'Close user menu' : 'Expand user menu'; + + return html` + + `; + } + + get loginIcon() { + return html` + + `; + } + + get searchMenuOpen() { + return this.openMenu === 'search'; + } + + get allowSecondaryIcon() { + return this.secondIdentitySlotMode === 'allow'; + } + + /** + * The search slot container, rendered between ia-topnav-media-menu and + * right-side-section so it sits left of the Upload button on desktop. + */ + get searchSlotContainer() { + if (this.hideSearch) return nothing; + return html` +
    + +
    + `; + } + + get searchMenu() { + if (this.hideSearch) return nothing; + + return html` + + `; + } + + get mobileDonateHeart() { + return html` + + ${icons.donateUnpadded} + "Donate to the archive" + + `; + } + + get uploadButtonTemplate() { + return html` + ${icons.upload} + Upload + `; + } + + get userStateTemplate() { + return html``; + } + + get secondLogoSlot() { + return this.allowSecondaryIcon + ? html` + + + ` + : nothing; + } + + get secondLogoClass() { + return this.allowSecondaryIcon ? 'second-logo' : ''; + } + + render() { + // const mediaMenuTabIndex = this.openMenu === 'media' ? '' : '-1'; + return html` + + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-save-page-form.test.ts b/src/elements/ia-topnav/ia-topnav-save-page-form.test.ts new file mode 100644 index 00000000..95e50828 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-save-page-form.test.ts @@ -0,0 +1,47 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test } from 'vitest'; + +import './ia-topnav-save-page-form'; +import type { SavePageForm } from './ia-topnav-save-page-form'; + +const component = html``; + +/** + * Submits the form for real rather than calling the private handler. The + * submit is cancelable and the handler calls `preventDefault()` on an invalid + * URL, so nothing navigates either way. + */ +async function submitWith(el: SavePageForm, url: string): Promise { + const form = el.shadowRoot?.querySelector('form') as HTMLFormElement; + const urlInput = form.querySelector( + '[name="url_preload"]', + ) as HTMLInputElement; + urlInput.value = url; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await el.updateComplete; +} + +describe('', () => { + test('rejects a URL with no domain suffix and shows the error', async () => { + const el = await fixture(component); + + await submitWith(el, 'archive'); + + expect(el.inputValid).to.be.false; + expect(el.shadowRoot?.querySelector('.error')?.className).to.contain( + 'visible', + ); + }); + + test('accepts a URL with a domain suffix and hides the error', async () => { + const el = await fixture(component); + + await submitWith(el, 'archive.org'); + + expect(el.inputValid).to.be.true; + expect(el.shadowRoot?.querySelector('.error')?.className).to.not.contain( + 'visible', + ); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-save-page-form.ts b/src/elements/ia-topnav/ia-topnav-save-page-form.ts new file mode 100644 index 00000000..e044a79d --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-save-page-form.ts @@ -0,0 +1,122 @@ +import { CSSResultGroup, css, html } from 'lit'; +import TrackedElement from './tracked-element'; +import { property, state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { IATopNavConfig, TOPNAV_MOBILE_BREAKPOINT } from './models'; +import { defaultTopNavConfig } from './data/menus'; +import themeStyles from '@src/themes/theme-styles'; +import { srOnlyStyles } from '@src/themes/sr-only-styles'; + +@customElement('ia-topnav-save-page-form') +export class SavePageForm extends TrackedElement { + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + + @state() inputValid = true; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + div { + display: grid; + grid-template-columns: 1fr auto; + grid-column-gap: 0.8rem; + margin: 0; + padding: 0; + border: none; + } + + input[type='text'] { + width: 100%; + height: 3rem; + box-sizing: border-box; + border: 1px solid var(--savePageInputBorder); + border-radius: 0.5rem; + color: var(--grey13); + font-size: inherit; + } + + input[type='submit'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0.4rem 0.8rem; + font: normal 1.3rem var(--themeFontFamily); + text-transform: uppercase; + color: var(--savePageSubmitText); + border: none; + border-radius: 16px; + background: var(--savePageSubmitBg); + cursor: pointer; + } + + .error { + display: none; + margin-top: 0.5rem; + font-weight: bold; + color: var(--savePageErrorText); + } + + .visible { + display: block; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + h3 { + margin-top: 0; + font: normal 100 1.6rem var(--themeFontFamily); + } + } + `, + srOnlyStyles, + ]; + } + + private validateURL(e: SubmitEvent) { + const target = e.target as HTMLFormElement; + const urlInput = target.querySelector( + '[name="url_preload"]', + ) as HTMLInputElement; + const valid = /\..{2,}$/.test(urlInput.value); + + if (!valid) { + e.preventDefault(); + this.inputValid = false; + return; + } + this.inputValid = true; + this.trackSubmit(e); + } + + get errorClass() { + return `error${this.inputValid ? '' : ' visible'}`; + } + + render() { + return html` +
    +

    Save Page Now

    +

    + Capture a web page as it appears now for use as a trusted citation in + the future. +

    +
    + + + +
    +

    Please enter a valid web address

    +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-signed-out-dropdown.ts b/src/elements/ia-topnav/ia-topnav-signed-out-dropdown.ts new file mode 100644 index 00000000..248b7f4e --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-signed-out-dropdown.ts @@ -0,0 +1,5 @@ +import { customElement } from '@src/util/custom-element'; +import DropdownMenu from './dropdown-menu'; + +@customElement('ia-topnav-signed-out-dropdown') +export class SignedOutDropdown extends DropdownMenu {} diff --git a/src/elements/ia-topnav/ia-topnav-story.ts b/src/elements/ia-topnav/ia-topnav-story.ts new file mode 100644 index 00000000..405586b2 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-story.ts @@ -0,0 +1,157 @@ +import { css, html, LitElement, type CSSResultGroup } from 'lit'; +import { state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import './ia-topnav'; +import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; +import '@demo/story-template'; + +import type { IATopNav } from './ia-topnav'; + +/** + * Enough of an item to exercise the admin sections of the user menu. Biblio + * has to arrive with a query string already on it, since the identifier is + * appended to it. + */ +const DEMO_ITEM_IDENTIFIER = 'goody'; +const DEMO_UPLOADER = 'uploader@archive.org'; +const DEMO_BIBLIO = 'https://openlibrary.org/search/inside?q='; + +@customElement('ia-topnav-story') +export class IATopNavStory extends LitElement { + @state() private signedIn = false; + + @state() private onAnItem = false; + + @state() private admin = false; + + @state() private canManageFlags = false; + + @state() private hideSearch = false; + + @state() private lastEvent = ''; + + private get propInputData() { + const settings: PropInputSettings[] = [ + { + label: 'Wayback pages archived', + propertyName: 'waybackPagesArchived', + defaultValue: '916 billion', + }, + ]; + return { settings }; + } + + private onAnalyticsClick(e: CustomEvent): void { + this.lastEvent = `analyticsClick → ${e.detail?.event ?? ''}`; + } + + private onAnalyticsSubmit(e: CustomEvent): void { + this.lastEvent = `analyticsSubmit → ${e.detail?.event ?? ''}`; + } + + private toggleRow( + label: string, + key: 'signedIn' | 'onAnItem' | 'admin' | 'canManageFlags' | 'hideSearch', + ) { + return html` + + ${label} + + { + this[key] = !this[key]; + }} + /> + + + `; + } + + render() { + const username = this.signedIn ? 'brewster' : ''; + const screenName = this.signedIn ? 'Brewster' : ''; + const itemIdentifier = this.onAnItem ? DEMO_ITEM_IDENTIFIER : ''; + const uploader = this.onAnItem && this.admin ? DEMO_UPLOADER : ''; + const biblio = this.onAnItem && this.admin ? DEMO_BIBLIO : ''; + + return html` + +
    + +

    ${this.lastEvent || 'No events yet.'}

    +
    + +
    + + ${this.toggleRow('Signed in', 'signedIn')} + ${this.toggleRow('Viewing an item', 'onAnItem')} + ${this.toggleRow('Admin on the item', 'admin')} + ${this.toggleRow('Can manage flags', 'canManageFlags')} + ${this.toggleRow('Hide search', 'hideSearch')} +
    +

    + The admin sections of the user menu need all three of Signed in, + Viewing an item and Admin on the item. Narrow the window below 890px + for the mobile layout. +

    +
    + +
    +

    + The topnav renders the whole archive.org masthead: the primary nav, + the media menu and its slider, the wayback search, and the account + dropdown. It reports clicks and form submits through + analyticsClick and analyticsSubmit rather + than talking to an analytics service itself. +

    +

    + The user menu grows an admin section when + itemIdentifier and admin are both set. + Setting uploader adds the uploader's account links, and + biblio adds the book-scanning links for a texts item. +

    +

    + A search bar goes in the search slot. Offshoot slots an + ia-dropdown-search-bar in there. +

    +
    +
    + `; + } + + static get styles(): CSSResultGroup { + return css` + .event-log { + margin: 0.8rem 0 0; + font-family: monospace; + font-size: 1.2rem; + } + + .hint { + font-size: 1.2rem; + } + + td { + padding-right: 1rem; + } + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-user-menu.test.ts b/src/elements/ia-topnav/ia-topnav-user-menu.test.ts new file mode 100644 index 00000000..1190194a --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-user-menu.test.ts @@ -0,0 +1,38 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import './ia-topnav-user-menu'; +import { buildTopNavMenus } from './data/menus'; +import UserMenu from './ia-topnav-user-menu'; + +import { describe, expect, test } from 'vitest'; +const component = html``; +const component2 = html``; + +describe('', () => { + test('does not render admin links for logged out users', async () => { + const el = await fixture(component); + el.menuItems = buildTopNavMenus().user; + + await el.updateComplete; + + expect(el.shadowRoot?.querySelectorAll('li').length).to.be.gt(0); + expect(el.shadowRoot?.querySelectorAll('.divider').length).to.equal(0); + }); + + test('does not render admin links for logged in users', async () => { + // NOTE: top-nav never renders admin links now -- that's been delegated to dynamic JS insertion + // in petabox tree (since it's only relevant there). + const el = await fixture(component2); + el.menuItems = buildTopNavMenus('brewster_userid').user; + + await el.updateComplete; + + expect(el.shadowRoot?.querySelectorAll('li').length).to.be.gt(0); + expect(el.shadowRoot?.querySelectorAll('.divider').length).to.equal(0); + + expect(el.shadowRoot?.querySelectorAll('h3').length).to.equal(1); + expect(el.shadowRoot?.querySelector('h3')?.innerText).to.equal('brewster'); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-user-menu.ts b/src/elements/ia-topnav/ia-topnav-user-menu.ts new file mode 100644 index 00000000..ebb60ed9 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-user-menu.ts @@ -0,0 +1,27 @@ +import { html } from 'lit'; +import DropdownMenu from './dropdown-menu'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +@customElement('ia-topnav-user-menu') +export default class UserMenu extends DropdownMenu { + @property({ type: String }) username = ''; + @property({ type: String }) screenName = ''; + + render() { + return html` + + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-wayback-search.ts b/src/elements/ia-topnav/ia-topnav-wayback-search.ts new file mode 100644 index 00000000..f31cd7e0 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-wayback-search.ts @@ -0,0 +1,277 @@ +import { css, CSSResultGroup, LitElement, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import searchIcon from './assets/img/icon-search'; +import logo from './assets/img/wayback-logo'; +import { TOPNAV_MOBILE_BREAKPOINT } from './models'; +import themeStyles from '@src/themes/theme-styles'; +import { srOnlyStyles } from '@src/themes/sr-only-styles'; + +/** + * The Wayback Machine search form shown inside the topnav's wayback slider. + * + * The layout rules the topnav needs sit in the last `css` block so they win + * over the base ones on equal specificity. + */ +@customElement('ia-topnav-wayback-search') +export class IATopNavWaybackSearch extends LitElement { + @property({ type: Object }) queryHandler: { + performQuery: (query: string) => void; + } = { + performQuery: (query: string) => + (window.location.href = `https://web.archive.org/web/*/${query}`), + }; + + @property({ type: String }) waybackPagesArchived = '916 billion'; + + @query('#url') private urlInput!: HTMLInputElement; + + render() { + return html` +
    +

    + Search the history of more than ${this.waybackPagesArchived} + web pages + on the Internet. +

    +
    + ${logo} +
    + + + ${searchIcon} +
    +
    +
    + `; + } + + handleSubmit(e: Event) { + e.preventDefault(); + const query = this.urlInput.value; + this.emitWaybackSearchSubmitted(query); + this.queryHandler.performQuery(query); + } + + emitWaybackSearchSubmitted(query: string) { + this.dispatchEvent( + new CustomEvent('waybackSearchSubmitted', { + detail: { + query, + }, + }), + ); + } + + emitWaybackMachineStatsLinkClicked() { + this.dispatchEvent(new CustomEvent('waybackMachineStatsLinkClicked')); + } + + emitWaybackMachineLogoLinkClicked() { + this.dispatchEvent(new CustomEvent('waybackMachineLogoLink')); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + srOnlyStyles, + css` + :host { + --topnav-wayback-input-text-color--: var( + --ia-theme-secondary-text-color, + #666 + ); + --topnav-wayback-input-bg--: var( + --ia-theme-secondary-background-color, + #fff + ); + /* + * Darker than the mobile glyph, which follows --iconFill. The topnav + * declares this alongside its other color knobs. The literal covers + * mounting this search on its own, where none of those greys exist, + * and it has to be a literal because fill inherits: an unresolved + * value paints black rather than falling through to another rule. + */ + --topnav-wayback-desktop-icon-fill--: var( + --desktopSearchIconFill, + #333 + ); + + font: normal 1.2rem/1.5 var(--themeFontFamily); + } + + form { + max-width: 600px; + } + + p { + margin-top: 0; + font-weight: 200; + } + + a { + font-weight: 500; + text-decoration: none; + color: var(--activeColor); + } + + fieldset { + padding: 0.7rem 2rem; + margin: 1.5rem 0; + box-sizing: border-box; + text-align: center; + border: none; + border-radius: 7px; + background-color: #fcf5e6; + box-shadow: 3px 3px 0 0 #c3ad97; + } + + fieldset a { + font-size: 0; + } + + img { + width: 100%; + max-width: 215px; + max-height: 60px; + margin-bottom: 1.3rem; + vertical-align: middle; + } + + input { + display: block; + width: 100%; + height: 3rem; + padding: 0.5rem 1rem 0.5rem 3rem; + font: normal 1.2rem/1.5 var(--themeFontFamily); + color: var(--topnav-wayback-input-text-color--); + box-sizing: border-box; + border: 1px solid var(--grey80); + border-radius: 2rem; + background: var(--topnav-wayback-input-bg--); + } + + input:focus { + border-color: #66afe9; + outline: none; + } + + .search-field { + position: relative; + overflow: hidden; + } + + .search-field svg { + position: absolute; + top: 3px; + left: 3px; + width: 2.4rem; + height: 2.4rem; + } + + .search-field .fill-color { + fill: var(--iconFill); + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + form { + margin: 0 auto; + } + + p { + margin-bottom: 3rem; + font-size: 1.6rem; + text-align: center; + } + + img { + margin: 0; + } + + fieldset { + margin: 0 auto; + } + + fieldset a, + .search-field { + display: inline-block; + width: 49%; + vertical-align: middle; + } + + fieldset a { + text-align: center; + } + + .search-field svg { + top: 2px; + } + + .search-field .fill-color { + fill: var(--topnav-wayback-desktop-icon-fill--); + } + } + `, + css` + p { + margin-bottom: 1rem; + font-size: 1.6rem; + text-align: center; + } + + fieldset { + padding: 0.5rem; + border-radius: 5px; + box-shadow: none; + } + + input { + padding-left: 3rem; + margin-top: 0.3rem; + font-size: 1.4rem; + border-color: #bca38e; + background: #fff; + } + + input::placeholder, + input::-webkit-input-placeholder { + color: #8e8e8e; + } + + .search-field svg { + top: 50%; + transform: translateY(-50%); + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + fieldset a, + .search-field { + display: block; + width: auto; + } + + fieldset a { + margin: 0 1.5rem; + } + } + `, + ]; + } +} diff --git a/src/elements/ia-topnav/ia-topnav-wayback-slider.test.ts b/src/elements/ia-topnav/ia-topnav-wayback-slider.test.ts new file mode 100644 index 00000000..b54483ee --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-wayback-slider.test.ts @@ -0,0 +1,99 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import './ia-topnav-wayback-slider'; +import type { IATopNavLink } from './models'; + +import { describe, expect, test } from 'vitest'; +const component = ({ + archiveItLinks, + browserExtensionsLinks, + config, + mobileAppsLinks, +}: { + archiveItLinks: IATopNavLink[]; + browserExtensionsLinks: IATopNavLink[]; + config: Record; + mobileAppsLinks: IATopNavLink[]; +}) => html` + +`; + +const buildDefaults = () => ({ + config: {}, + archiveItLinks: [ + { + url: '1', + title: 'first', + }, + { + url: '2', + title: 'second', + }, + ], + browserExtensionsLinks: [ + { + url: '3', + title: 'third', + }, + { + url: '4', + title: 'fourth', + }, + ], + mobileAppsLinks: [ + { + url: '5', + title: 'fifth', + }, + { + url: '6', + title: 'sixth', + }, + ], +}); + +describe('', () => { + test('renders the links passed in via the archiveItLinks prop', async () => { + const options = buildDefaults(); + const el = await fixture(component(options)); + const anchors = el.shadowRoot?.querySelectorAll('.archive-it a') ?? []; + + options.archiveItLinks.forEach((link, i) => { + const anchor = anchors[i] as HTMLAnchorElement; + expect(anchor.innerText).to.equal(link.title); + expect(anchor.getAttribute('href')).to.contain(link.url); + }); + }); + + test('renders the links passed in via the browserExtensionsLinks prop', async () => { + const options = buildDefaults(); + const el = await fixture(component(options)); + const anchors = + el.shadowRoot?.querySelectorAll('.browser-extensions a') ?? []; + + options.browserExtensionsLinks.forEach((link, i) => { + const anchor = anchors[i] as HTMLAnchorElement; + expect(anchor.innerText).to.equal(link.title); + expect(anchor.getAttribute('href')).to.contain(link.url); + }); + }); + + test('renders the links passed in via the mobileAppsLinks prop', async () => { + const options = buildDefaults(); + const el = await fixture(component(options)); + const anchors = el.shadowRoot?.querySelectorAll('.mobile-apps a') ?? []; + + options.mobileAppsLinks.forEach((link, i) => { + const anchor = anchors[i] as HTMLAnchorElement; + expect(anchor.innerText).to.equal(link.title); + expect(anchor.getAttribute('href')).to.contain(link.url); + }); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav-wayback-slider.ts b/src/elements/ia-topnav/ia-topnav-wayback-slider.ts new file mode 100644 index 00000000..b46c0a63 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav-wayback-slider.ts @@ -0,0 +1,126 @@ +import { CSSResultGroup, css, html } from 'lit'; +import './ia-topnav-wayback-search'; +import TrackedElement from './tracked-element'; +import './ia-topnav-save-page-form'; +import queryHandler from './lib/query-handler'; +import toSentenceCase from './lib/t-sentence-case'; +import formatUrl from './lib/format-url'; +import { property } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { + IATopNavConfig, + IATopNavLink, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import { defaultTopNavConfig } from './data/menus'; +import { subnavListCSS } from './subnav-list-styles'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav-wayback-slider') +export class WaybackSlider extends TrackedElement { + @property({ type: Array }) archiveItLinks: IATopNavLink[] = []; + @property({ type: String }) baseHost = ''; + @property({ type: Array }) browserExtensionsLinks: IATopNavLink[] = []; + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + @property({ type: Array }) mobileAppsLinks: IATopNavLink[] = []; + + static get styles(): CSSResultGroup { + return [ + themeStyles, + subnavListCSS, + css` + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + :host { + display: block; + grid-column: 1 / 4; + padding: 0 1.5rem; + } + + h4 { + margin-top: 0; + font: normal 100 1.6rem var(--themeFontFamily); + } + + .grid { + display: grid; + grid-template-columns: minmax(auto, 260px) 1fr minmax(auto, 260px); + /* Possible for 890 - 935: minmax(auto, 260px) 1fr minmax(auto, 260px) */ + grid-column-gap: 2.5rem; + } + + .link-lists { + display: grid; + grid-template-columns: calc(50% - 1.25rem) calc(50% - 1.25rem); + grid-column-gap: 2.5rem; + } + } + `, + ]; + } + + get mobileAppsItems() { + return this.linkList(this.mobileAppsLinks, 'Wayback'); + } + + get browserExtensionsItems() { + return this.linkList(this.browserExtensionsLinks, 'Wayback'); + } + + get archiveItItems() { + return this.linkList(this.archiveItLinks, 'ArchiveIt'); + } + + private linkList(links: IATopNavLink[], eventPrefix: string) { + return links.map( + (link) => + html`
  • + ${link.title} +
  • `, + ); + } + + analyticsEvent(title: string) { + return `${this.config?.eventCategory}|${toSentenceCase(title)}`; + } + + render() { + return html` +
    + + + +
    + `; + } +} diff --git a/src/elements/ia-topnav/ia-topnav.test.ts b/src/elements/ia-topnav/ia-topnav.test.ts new file mode 100644 index 00000000..569d66d9 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav.test.ts @@ -0,0 +1,393 @@ +import { + elementUpdated, + fixture, + fixtureCleanup, + oneEvent, +} from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import './ia-topnav'; +import { IATopNav } from './ia-topnav'; +import { SignedOutDropdown } from './ia-topnav-signed-out-dropdown'; +import UserMenu from './ia-topnav-user-menu'; + +import { afterEach, describe, expect, test } from 'vitest'; +const verifyClosed = (instance: IATopNav) => { + expect(instance.mediaSliderOpen).to.be.false; + expect(instance.selectedMenuOption).to.equal(''); +}; + +const verifyOpened = (instance: IATopNav, mediatype: string) => { + expect(instance.mediaSliderOpen).to.be.true; + expect(instance.selectedMenuOption).to.equal(mediatype); +}; + +afterEach(() => { + fixtureCleanup(); +}); + +describe('', () => { + test('dispatches an analyticsClick event when trackClick event fired', async () => { + const el = await fixture(html` `); + const clickEvent = new MouseEvent('click'); + + setTimeout(() => + el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.shadowRoot?.querySelector('.hamburger') + ?.dispatchEvent(clickEvent), + ); + const response = await oneEvent(el, 'trackClick'); + + expect(response).to.exist; + }); + + test('closes all menus when close-layer clicked', async () => { + const el = await fixture(html` `); + + el.openMenu = 'media'; + el.selectedMenuOption = 'texts'; + el.mediaSliderOpen = true; + await el.updateComplete; + el.closeMenus(); + await el.updateComplete; + + expect(el.mediaSliderOpen).to.be.false; + expect(el.openMenu).to.equal(''); + expect(el.selectedMenuOption).to.equal(''); + }); + + test('sets media slider to closed', async () => { + const el = await fixture(html` `); + + el.mediaSliderOpen = true; + el.selectedMenuOption = 'foo'; + el.closeMediaSlider(); + + verifyClosed(el); + }); + + test('toggles media slider visibility and starts animation', async () => { + const el = await fixture(html` `); + const mediatype = 'foo'; + + el.selectedMenuOption = mediatype; + el.openMediaSlider(); + + verifyOpened(el, mediatype); + }); + + test('closes media slider if selected menu type is the open menu type', async () => { + const el = await fixture(html` `); + const mediatype = 'foo'; + + el.selectedMenuOption = mediatype; + + const event = new CustomEvent('mediaTypeSelected', { + detail: { + mediatype, + }, + }); + + el.mediaTypeSelected(event); + + verifyClosed(el); + }); + + test('opens media slider menu and starts animation', async () => { + const el = await fixture(html` `); + const mediatype = 'foo'; + + const event = new CustomEvent('mediaTypeSelected', { + detail: { + mediatype, + }, + }); + + el.mediaTypeSelected(event); + + verifyOpened(el, mediatype); + }); + + test('closes slider when menu closed', async () => { + const el = await fixture(html` `); + + el.openMenu = 'media'; + el.selectedMenuOption = 'foo'; + el.mediaSliderOpen = true; + await el.updateComplete; + + const event = new CustomEvent('menuToggled', { + detail: { + menuName: '', + }, + }); + + el.menuToggled(event); + await el.updateComplete; + + expect(el.selectedMenuOption).to.equal(''); + }); + + test('toggles user menu tabindex when dropdown open', async () => { + const el = await fixture( + html` `, + ); + + el.openMenu = 'user'; + await el.updateComplete; + + expect( + el.shadowRoot + ?.querySelector('ia-topnav-user-menu') + ?.getAttribute('tabindex'), + ).to.equal(''); + }); + + test('toggles signed out menu tabindex when dropdown open', async () => { + const el = await fixture(html` `); + + el.openMenu = 'login'; + await el.updateComplete; + + expect( + el.shadowRoot + ?.querySelector('ia-topnav-signed-out-dropdown') + ?.getAttribute('tabindex'), + ).to.equal(''); + }); + + test('toggles search menu when search toggle button clicked', async () => { + const el = await fixture(html` `); + ( + el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.shadowRoot?.querySelector('.search-trigger') as HTMLButtonElement + ).click(); + await el.updateComplete; + + expect(el.openMenu).to.equal('search'); + }); + + test('toggles user menu when search user avatar clicked', async () => { + const el = await fixture( + html` `, + ); + + ( + el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.shadowRoot?.querySelector('.user-menu') as HTMLButtonElement + ).click(); + await el.updateComplete; + + expect(el.openMenu).to.equal('user'); + }); + + test('uses localLinks for archive.org logo link', async () => { + const el = await fixture( + html` `, + ); + const logoLink = el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.shadowRoot?.querySelector('.link-home'); + expect(logoLink?.getAttribute('href')).to.match(/\/\/archive\.org/); + }); + + describe('sets localLinks properly', async () => { + test('uses localLinks to archive.org links on common child components', async () => { + const el = await fixture( + html` `, + ); + const componentSelectors = [ + 'ia-topnav-primary-nav', + 'ia-topnav-media-slider', + 'ia-topnav-desktop-subnav', + ]; + componentSelectors.forEach((selector) => { + const component = el.shadowRoot?.querySelector(selector) as unknown as { + baseHost: string; + }; + expect(component?.baseHost).to.equal('https://archive.org'); + }); + }); + + test('uses localLinks to archive.org links on the signed out dropdown', async () => { + const el = await fixture( + html` `, + ); + const signedOutDropdown = el.shadowRoot?.querySelector( + 'ia-topnav-signed-out-dropdown', + ) as SignedOutDropdown; + expect(signedOutDropdown?.baseHost).to.equal('https://archive.org'); + }); + + test('uses localLinks to archive.org links on the user dropdown', async () => { + const el = await fixture( + html` `, + ); + const signedOutDropdown = el.shadowRoot?.querySelector( + 'ia-topnav-user-menu', + ) as UserMenu; + expect(signedOutDropdown.baseHost).to.equal('https://archive.org'); + }); + }); + + describe('search slot', () => { + test('forwards search slot to ia-topnav-primary-nav', async () => { + const el = await fixture(html``); + await el.updateComplete; + + const primaryNav = el.shadowRoot?.querySelector('ia-topnav-primary-nav'); + const slot = primaryNav?.querySelector('slot[name="search"]'); + expect(slot).to.exist; + }); + + test('does not render search-menu', async () => { + const el = await fixture(html``); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('search-menu')).to.not.exist; + }); + }); + + describe('slot pass throughs', () => { + describe('slot for ', () => { + test('opens a slot with `secondIdentitySlotMode`', async () => { + const el = await fixture( + html``, + ); + + const slot = el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.querySelector('slot[name="opt-sec-logo"]'); + expect(slot).to.exist; + + el.secondIdentitySlotMode = ''; + await elementUpdated(el); + const noSlot = el.shadowRoot + ?.querySelector('ia-topnav-primary-nav') + ?.querySelector('slot[name="opt-sec-logo"]'); + expect(noSlot).to.not.exist; + }); + }); + }); +}); + +describe(' admin user menu sections', () => { + type ExtraSection = '' | 'uploader' | 'biblio' | 'both'; + const adminFixture = async (extra: ExtraSection = '') => + fixture( + html``, + ); + + test('renders the basic and item admin sections by default', async () => { + const el = await adminFixture(); + expect(el.userMenuItems.length).to.equal(2); + }); + + test('adds an uploader section when an uploader is set', async () => { + const el = await adminFixture('uploader'); + const sections = el.userMenuItems; + expect(sections.length).to.equal(3); + + const uploaderSection = sections[2]; + expect(uploaderSection.map((link) => link.title)).to.deep.equal([ + 'uploader:', + 'up+loader@example.com', + 'user admin', + 'user privs', + ]); + expect(uploaderSection[0].url).to.be.undefined; + expect(uploaderSection[1].url).to.be.undefined; + expect(uploaderSection[2].url).to.equal( + 'https://catalogd.archive.org/control/useradmin.php?email=up%2Bloader%40example.com', + ); + expect(uploaderSection[3].url).to.equal( + 'https://catalogd.archive.org/control/setadmin.php?user=up%2Bloader%40example.com&ignore=boop', + ); + }); + + test('adds a biblio section when a biblio URL is set', async () => { + const el = await adminFixture('biblio'); + const sections = el.userMenuItems; + expect(sections.length).to.equal(3); + + const biblioSection = sections[2]; + expect(biblioSection.map((link) => link.title)).to.deep.equal([ + 'biblio', + 'bookview', + 'jp2 zip', + ]); + expect(biblioSection[0].url).to.equal( + 'https://books-yaz.archive.org/biblio.php?b_id=boop&add=1&ignored=boop', + ); + expect(biblioSection[1].url).to.equal( + 'https://archive.org/bookview.php?mode=debug&identifier=boop', + ); + expect(biblioSection[2].url).to.equal( + 'https://archive.org/download/boop/format=Single Page Processed JP2 ZIP', + ); + }); + + test('orders the biblio section before the uploader section', async () => { + const el = await adminFixture('both'); + const sections = el.userMenuItems; + expect(sections.length).to.equal(4); + expect(sections[2][0].title).to.equal('biblio'); + expect(sections[3][0].title).to.equal('uploader:'); + }); + + test('renders the sections in the user menu with dividers between them', async () => { + const el = await adminFixture('both'); + // The menus rebuild in updated(), so the sections land one update later. + await el.updateComplete; + const userMenu = el.shadowRoot?.querySelector( + 'ia-topnav-user-menu', + ) as UserMenu; + await userMenu.updateComplete; + + const listItems = Array.from( + userMenu.shadowRoot?.querySelectorAll('li') ?? [], + ); + const text = listItems.map((li) => li.textContent?.trim()); + expect(text).to.include('uploader:'); + expect(text).to.include('up+loader@example.com'); + expect(text).to.include('user privs'); + expect(text).to.include('jp2 zip'); + expect(userMenu.shadowRoot?.querySelectorAll('.divider').length).to.equal( + 3, + ); + }); + + test('leaves the extra sections out for non-admins', async () => { + const el = await fixture( + html``, + ); + expect(el.userMenuItems.length).to.equal(1); + }); +}); diff --git a/src/elements/ia-topnav/ia-topnav.ts b/src/elements/ia-topnav/ia-topnav.ts new file mode 100644 index 00000000..0880eb45 --- /dev/null +++ b/src/elements/ia-topnav/ia-topnav.ts @@ -0,0 +1,453 @@ +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; + +import { buildTopNavMenus, defaultTopNavConfig } from './data/menus'; +import './ia-topnav-desktop-subnav'; +import './dropdown-menu'; +import './ia-topnav-media-slider'; +import { + IATopNavConfig, + IATopNavMenuConfig, + IATopNavSecondIdentitySlotMode, + TOPNAV_MOBILE_BREAKPOINT, +} from './models'; +import './ia-topnav-primary-nav'; +import type { PrimaryNav } from './ia-topnav-primary-nav'; +import './ia-topnav-signed-out-dropdown'; +import './ia-topnav-user-menu'; +import themeStyles from '@src/themes/theme-styles'; + +@customElement('ia-topnav') +export class IATopNav extends LitElement { + @property({ type: Boolean }) localLinks = false; + + @property({ type: String }) waybackPagesArchived = ''; + + @property({ type: String }) baseHost = 'https://archive.org'; + + @property({ type: String }) mediaBaseHost = 'https://archive.org'; + + @property({ type: Boolean }) admin = false; + + @property({ type: Boolean }) canManageFlags = false; + + @property({ type: Object }) config: IATopNavConfig = defaultTopNavConfig; + + @property({ type: Boolean }) hideSearch = false; + + @property({ type: String }) itemIdentifier = ''; + + /** + * Email of the item's uploader. When set for an admin viewing an item, the + * user menu gets an "uploader:" section with user admin / user privs links. + */ + @property({ type: String }) uploader = ''; + + /** + * Biblio URL for a texts item, which must already carry a query string + * (the item identifier is appended to it as `&ignored=`). When set for an + * admin viewing an item, the user menu gets biblio / bookview / jp2 zip links. + */ + @property({ type: String }) biblio = ''; + + @property({ type: Boolean }) mediaSliderOpen = false; + + @property({ type: String }) openMenu = ''; + + @property({ type: String }) screenName: string = ''; + + @property({ type: String }) selectedMenuOption = ''; + + @property({ type: String }) username: string = ''; + + @property({ type: String }) userProfileImagePath = + '/services/img/user/profile'; + + @property({ type: String }) + secondIdentitySlotMode: IATopNavSecondIdentitySlotMode = ''; + + @property({ type: Object }) currentTab?: { + mediatype: string; + moveTo: string; + }; + + @query('ia-topnav-primary-nav') private primaryNav?: PrimaryNav; + /** Only one of ia-topnav-user-menu or ia-topnav-signed-out-dropdown is rendered at a time. */ + @query('ia-topnav-user-menu, ia-topnav-signed-out-dropdown') + private accountDropdown?: HTMLElement; + + @state() private menus: IATopNavMenuConfig = buildTopNavMenus(); + + private boundHandleKeydown = this.handleDocumentKeydown.bind(this); + + private boundHandleClick = this.handleDocumentClick.bind(this); + + private get normalizedBaseHost() { + return !this.localLinks ? this.baseHost : ''; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --white: #fff; + --grey13: #222; + --grey20: #333; + --grey40: #666; + --grey28: #474747; + --grey60: #999; + --grey66: #aaa; + --grey80: #ccc; + --greya0: #a0a0a0; + --grey6f: #6f6f6f; + --errorYellow: #ffcd27; + + --linkColor: #4b64ff; + --linkHoverColor: var(--white); + --subnavLinkColor: var(--grey66); + --primaryTextColor: var(--white); + --inverseTextColor: var(--grey20); + --lightTextColor: var(--grey60); + --activeColor: var(--white); + --activeButtonBg: var(--grey20); + --iconFill: var(--grey60); + --desktopSearchIconFill: var(--grey20); + + --mediaMenuBg: var(--grey13); + --mediaLabelDesktopColor: var(--grey60); + --activeDesktopMenuIcon: var(--grey28); + + --mediaSliderBg: var(--grey20); + --mediaSliderDesktopBg: var(--grey28); + + --primaryNavBg: var(--grey13); + --primaryNavBottomBorder: var(--grey20); + + --desktopSubnavBg: var(--grey20); + + --dropdownMenuBg: var(--grey20); + --dropdownMenuInfoItem: var(--greya0); + --dropdownMenuDivider: var(--grey40); + --inverseDropdownMenuInfoItem: var(--grey6f); + + --loginTextColor: var(--grey60); + + --themeFontFamily: 'Helvetica Neue', Helvetica, Arial, sans-serif; + --logoWidthTablet: 263px; + + --savePageSubmitBg: var(--grey13); + --savePageSubmitText: var(--white); + --savePageInputBorder: var(--grey60); + --savePageErrorText: var(--errorYellow); + + color: var(--primaryTextColor); + font-family: var(--themeFontFamily); + } + + ia-topnav-primary-nav:focus { + outline: none !important; + } + + #close-layer { + display: none; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 3; + } + #close-layer.visible { + display: block; + } + + .topnav { + position: relative; + z-index: 4; + } + + @media (max-width: ${TOPNAV_MOBILE_BREAKPOINT - 1}px) { + ia-topnav-desktop-subnav { + display: none; + } + } + `, + ]; + } + + updated(props: PropertyValues) { + if ( + props.has('username') || + props.has('waybackPagesArchived') || + props.has('itemIdentifier') || + props.has('uploader') || + props.has('biblio') || + props.has('localLinks') || + props.has('baseHost') + ) { + this.menuSetup(); + } + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('keydown', this.boundHandleKeydown); + document.addEventListener('click', this.boundHandleClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('keydown', this.boundHandleKeydown); + document.removeEventListener('click', this.boundHandleClick); + } + + private handleDocumentKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + this.openMenu = ''; + this.mediaSliderOpen = false; + } + } + + private handleDocumentClick(e: MouseEvent) { + if (!this.openMenu) return; + const path = e.composedPath(); + if (!path.includes(this)) { + this.closeMenus(); + } + } + + menuSetup() { + // re/build the nav + this.menus = buildTopNavMenus( + this.username, + this.normalizedBaseHost, + this.waybackPagesArchived, + this.itemIdentifier, + this.uploader, + this.biblio, + ); + } + + menuToggled(e: CustomEvent) { + const currentMenu = this.openMenu; + this.openMenu = currentMenu === e.detail.menuName ? '' : e.detail.menuName; + // Keeps media slider open if media menu is open + if (this.openMenu === 'media') { + return; + } + this.closeMediaSlider(); + + if (this.openMenu === 'user' || this.openMenu === 'login') { + if (this.primaryNav && this.accountDropdown) { + const right = this.primaryNav.getAccountDropdownOffset(); + this.accountDropdown.style.setProperty( + '--dropdownMenuRight', + `${right}px`, + ); + } + } + } + + openMediaSlider() { + this.mediaSliderOpen = true; + } + + closeMediaSlider() { + this.mediaSliderOpen = false; + this.selectedMenuOption = ''; + } + + closeMenus() { + this.openMenu = ''; + this.closeMediaSlider(); + } + + trackClick(e: CustomEvent) { + this.dispatchEvent( + new CustomEvent('analyticsClick', { + bubbles: true, + composed: true, + detail: e.detail, + }), + ); + } + + trackSubmit(e: CustomEvent) { + this.dispatchEvent( + new CustomEvent('analyticsSubmit', { + bubbles: true, + composed: true, + detail: e.detail, + }), + ); + } + + mediaTypeSelected(e: CustomEvent) { + if (this.selectedMenuOption === e.detail.mediatype) { + this.closeMediaSlider(); + return; + } + this.selectedMenuOption = e.detail.mediatype; + this.openMediaSlider(); + } + + get signedOutOpened() { + return this.openMenu === 'login'; + } + + get userMenuOpened() { + return this.openMenu === 'user'; + } + + get userMenuTabIndex() { + return this.userMenuOpened ? '' : '-1'; + } + + get signedOutTabIndex() { + return this.signedOutOpened ? '' : '-1'; + } + + get closeLayerClass() { + return !!this.openMenu || this.mediaSliderOpen ? 'visible' : ''; + } + + get userMenu() { + return html` + + (this.currentTab = e.detail)} + > + `; + } + + get signedOutDropdown() { + return html` + { + this.currentTab = e.detail; + }} + > + `; + } + + get signedOutMenuItems() { + return this.menus.signedOut; + } + + /** + * Most users just get the basic menu items. + * For users with `/items` priv, additional admin menu items are included too. + * Having the `/flags` priv adds a further admin item for managing flags. + * A `biblio` URL adds a section of book-scanning links, and an `uploader` + * adds a section with the uploader's email and account admin links. Each + * section is rendered with a divider above it. + */ + get userMenuItems() { + const basicItems = this.menus.user; + if (!this.itemIdentifier || !this.admin) return [basicItems]; + + let adminItems = this.menus.userAdmin; + if (this.canManageFlags) { + adminItems = adminItems.concat(this.menus.userAdminFlags); + } + + // The built sections are empty until the matching prop is set and the + // menus have been rebuilt, so gating on them keeps a divider from ever + // rendering above an empty section. + const { userAdminBiblio, userAdminUploader } = this.menus; + const sections = [basicItems, adminItems]; + if (userAdminBiblio.length) sections.push(userAdminBiblio); + if (userAdminUploader.length) sections.push(userAdminUploader); + return sections; + } + + get allowSecondaryIcon() { + return this.secondIdentitySlotMode === 'allow'; + } + + get searchSlot() { + return html``; + } + + get secondLogoSlot() { + return this.allowSecondaryIcon + ? html` + + + ` + : nothing; + } + + get separatorTemplate() { + return html``; + } + + render() { + return html` +
    + + ${this.secondLogoSlot} ${this.searchSlot} + + + (this.currentTab = e.detail)} + > +
    + ${this.username ? this.userMenu : this.signedOutDropdown} + +
    + `; + } +} diff --git a/src/elements/ia-topnav/lib/format-url.ts b/src/elements/ia-topnav/lib/format-url.ts new file mode 100644 index 00000000..712dc356 --- /dev/null +++ b/src/elements/ia-topnav/lib/format-url.ts @@ -0,0 +1,2 @@ +export default (url: string = '', baseHost: string): string => + /^https?:/.test(url) ? url : (`${baseHost}${url}` as string & Location); diff --git a/src/elements/ia-topnav/lib/keyboard-navigation.ts b/src/elements/ia-topnav/lib/keyboard-navigation.ts new file mode 100644 index 00000000..57783279 --- /dev/null +++ b/src/elements/ia-topnav/lib/keyboard-navigation.ts @@ -0,0 +1,171 @@ +export default class KeyboardNavigation { + elementsContainer: HTMLElement; + menuOption: string; + focusableElements: HTMLElement[]; + focusedIndex: number; + + /** + * Constructor for the KeyboardNavigation class. + * @param {HTMLElement} elementsContainer - The container element that holds the focusable elements. + * @param {string} menuOption - The type of menu option ('web' or 'usermenu'). + */ + constructor(elementsContainer: HTMLElement, menuOption: string) { + this.elementsContainer = elementsContainer; + this.menuOption = menuOption; + this.focusableElements = this.getFocusableElements(); + this.focusedIndex = 0; // always start from first element + + if (menuOption !== 'search') { + this.focusableElements[this.focusedIndex]?.focus(); + } + this.handleKeyDown = this.handleKeyDown.bind(this); + } + + /** + * Gets an array of focusable elements within the container. + * @returns {HTMLElement[]} An array of focusable elements. + */ + getFocusableElements(): HTMLElement[] { + const focusableTagSelectors = 'a[href], button, input, [tabindex]'; + + const isFocusable = (el: Element) => + !el.hasAttribute('disabled') && + el.getAttribute('aria-hidden') !== 'true' && + el.getAttribute('tabindex') !== '-1'; + + let elements; + if (this.menuOption === 'web') { + // wayback focusable elements + const waybackSlider = this.elementsContainer.querySelector( + 'ia-topnav-wayback-slider', + )?.shadowRoot; + const waybackSearch = waybackSlider?.querySelector( + 'ia-topnav-wayback-search', + ); + const waybackSearchElements = Array.from( + waybackSearch?.shadowRoot?.querySelectorAll(focusableTagSelectors) ?? + [], + ); + + const normalElements = Array.from( + waybackSlider?.querySelectorAll(focusableTagSelectors) ?? [], + ); + + // wayback save-form focusable elements + const savePageForm = waybackSlider?.querySelector( + 'ia-topnav-save-page-form', + ); + const savePageFormElements = Array.from( + savePageForm?.shadowRoot?.querySelectorAll(focusableTagSelectors) ?? [], + ); + + elements = [ + ...waybackSearchElements, + ...normalElements, + ...savePageFormElements, + ]; + } else { + elements = this.elementsContainer.querySelectorAll(focusableTagSelectors); + } + + return Array.from(elements ?? []).filter(isFocusable) as HTMLElement[]; + } + + /** + * Handles keyboard events and focuses the appropriate element. + * @param {KeyboardEvent} event - The keyboard event object. + */ + handleKeyDown(event: KeyboardEvent) { + const target = event.composedPath()[0] as HTMLElement; + + // Ignore events from editable fields + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable + ) { + return; + } + + const { key } = event; + const isArrowKey = [ + 'ArrowDown', + 'ArrowRight', + 'ArrowUp', + 'ArrowLeft', + ].includes(key); + const isTabKey = key === 'Tab'; + + if (isArrowKey) { + this.handleArrowKey(key); + event.preventDefault(); + } else if (isTabKey) { + this.handleTabKey(event); + } + } + + /** + * Handles arrow key events and focuses the next or previous element for topnav sub-nav and usermenu + * @param {string} key - The key that was pressed ('ArrowDown', 'ArrowRight', 'ArrowUp', or 'ArrowLeft'). + */ + handleArrowKey(key: string) { + const isDownOrRight = ['ArrowDown', 'ArrowRight'].includes(key); + if (isDownOrRight) { + this.focusNext(); + } else { + this.focusPrevious(); + } + } + + /** + * Handles the Tab key event and focuses the next or previous menu item. + * @param {KeyboardEvent} event - The keyboard event object. + */ + handleTabKey(event: KeyboardEvent) { + const isShiftPressed = event.shiftKey; + + this.emitFocusToOtherMenuItems(isShiftPressed); + + this.focusableElements[this.focusedIndex]?.blur(); + if (!['search'].includes(this.menuOption)) { + event.preventDefault(); + } + } + + /** + * Focuses the previous focusable element in the container. + */ + focusPrevious() { + if (this.focusableElements.length === 0) return; + this.focusedIndex = + (this.focusedIndex - 1 + this.focusableElements.length) % + this.focusableElements.length; + this.focusableElements[this.focusedIndex]?.focus(); + } + + /** + * Focuses the next focusable element in the container. + */ + focusNext() { + if (this.focusableElements.length === 0) return; + this.focusedIndex = (this.focusedIndex + 1) % this.focusableElements.length; + this.focusableElements[this.focusedIndex]?.focus(); + } + + /** + * Focuses the other parent menu items based on the provided flag. + * @param {boolean} isPrevious - A flag indicating whether to focus the previous menu item. + */ + emitFocusToOtherMenuItems(isPrevious: boolean = false) { + this.elementsContainer.dispatchEvent( + new CustomEvent('focusToOtherMenuItem', { + bubbles: true, + composed: true, + detail: { + mediatype: this.menuOption, + moveTo: isPrevious ? 'prev' : 'next', + }, + }), + ); + } +} diff --git a/src/elements/ia-topnav/lib/make-boolean-string.ts b/src/elements/ia-topnav/lib/make-boolean-string.ts new file mode 100644 index 00000000..fec5d93d --- /dev/null +++ b/src/elements/ia-topnav/lib/make-boolean-string.ts @@ -0,0 +1,12 @@ +export function makeBooleanFromString(value: string): 'true' | 'false' { + const lowerValue = value.toLowerCase(); + let booleanValue: boolean = false; + if (lowerValue === 'true' || lowerValue === '1') { + booleanValue = true; + } + return makeBooleanString(booleanValue); +} + +export function makeBooleanString(value: boolean): 'true' | 'false' { + return value ? 'true' : 'false'; +} diff --git a/src/elements/ia-topnav/lib/query-handler.ts b/src/elements/ia-topnav/lib/query-handler.ts new file mode 100644 index 00000000..010ecff8 --- /dev/null +++ b/src/elements/ia-topnav/lib/query-handler.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +export default { + performQuery(query: string) { + window.location.href = `https://web.archive.org/web/*/${query}`; + }, +}; diff --git a/src/elements/ia-topnav/lib/t-sentence-case.ts b/src/elements/ia-topnav/lib/t-sentence-case.ts new file mode 100644 index 00000000..8939247c --- /dev/null +++ b/src/elements/ia-topnav/lib/t-sentence-case.ts @@ -0,0 +1,10 @@ +const toSentenceCase = (phrase: string): string => { + const words = phrase.split(' '); + const lastWord = words.pop(); + const capitalizedWord = `${lastWord?.substr(0, 1).toUpperCase()}${lastWord?.substr(1)}`; + return words.length + ? toSentenceCase(`${words.join(' ')}${capitalizedWord}`) + : capitalizedWord; +}; + +export default toSentenceCase; diff --git a/src/elements/ia-topnav/models.ts b/src/elements/ia-topnav/models.ts new file mode 100644 index 00000000..f4c71a65 --- /dev/null +++ b/src/elements/ia-topnav/models.ts @@ -0,0 +1,70 @@ +/** + * The pixel width at which ia-topnav switches from mobile to desktop layout. + * Desktop styles apply at `min-width: TOPNAV_MOBILE_BREAKPOINT px`. + * Mobile styles apply at `max-width: (TOPNAV_MOBILE_BREAKPOINT - 1) px`. + * + * Exported so that host applications can derive their own media queries from + * this value rather than hard-coding it independently. + */ +export const TOPNAV_MOBILE_BREAKPOINT = 890; + +export interface IATopNavConfig { + /** + * Google Analytics event category + */ + eventCategory?: string; + + /** + * Copy to display for number of pages archived at the top of the Wayback search form + * + * ie. "425 billion" + */ + waybackPagesArchived?: string; + + /** + * Map from dropdown item ids to any callout text that should be applied beside them + */ + callouts?: Record; +} + +export interface IATopNavLink { + title: string; + + url?: string; + + class?: string; + + icon?: string; + + analyticsEvent?: string; + + external?: boolean; +} + +export interface IATopNavMediaMenu { + heading: string; + iconLinks: IATopNavLink[]; + featuredLinks: IATopNavLink[]; + links: IATopNavLink[]; + mobileAppsLinks: IATopNavLink[]; + browserExtensionsLinks: IATopNavLink[]; + archiveItLinks: IATopNavLink[]; +} + +export interface IATopNavMenuConfig { + audio: IATopNavMediaMenu; + images: IATopNavMediaMenu; + more: IATopNavMediaMenu; + signedOut: IATopNavLink[]; + software: IATopNavMediaMenu; + texts: IATopNavMediaMenu; + user: IATopNavLink[]; + userAdmin: IATopNavLink[]; + userAdminFlags: IATopNavLink[]; + userAdminBiblio: IATopNavLink[]; + userAdminUploader: IATopNavLink[]; + video: IATopNavMediaMenu; + web: IATopNavMediaMenu; +} + +export type IATopNavSecondIdentitySlotMode = 'allow' | ''; diff --git a/src/elements/ia-topnav/subnav-list-styles.ts b/src/elements/ia-topnav/subnav-list-styles.ts new file mode 100644 index 00000000..966ed9e8 --- /dev/null +++ b/src/elements/ia-topnav/subnav-list-styles.ts @@ -0,0 +1,49 @@ +import { css } from 'lit'; +import { TOPNAV_MOBILE_BREAKPOINT } from './models'; + +export const subnavListCSS = css` + h4 { + font-size: 1.6rem; + } + + a { + text-decoration: none; + color: var(--activeColor); + } + + ul { + padding: 0; + margin: 0; + list-style: none; + } + + li + li { + padding-top: 1.5rem; + } + + @media (min-width: ${TOPNAV_MOBILE_BREAKPOINT}px) { + h4 { + margin: 0 0 1rem 0; + font-weight: 100; + } + + ul { + font-size: 1.3rem; + } + + li { + padding-bottom: 0.5rem; + } + + li + li { + padding-top: 0; + } + + li a { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +`; diff --git a/src/elements/ia-topnav/tracked-element.ts b/src/elements/ia-topnav/tracked-element.ts new file mode 100644 index 00000000..f50b6c7a --- /dev/null +++ b/src/elements/ia-topnav/tracked-element.ts @@ -0,0 +1,32 @@ +import { LitElement } from 'lit'; + +export default class TrackedElement extends LitElement { + trackClick(e: Event) { + const event = (e.currentTarget as HTMLElement)?.dataset?.eventClickTracking; + if (event === undefined) return; + this.dispatchEvent( + new CustomEvent('trackClick', { + bubbles: true, + composed: true, + detail: { + event, + }, + }), + ); + } + + trackSubmit(e: Event) { + const event = (e.currentTarget as HTMLElement)?.dataset + ?.eventSubmitTracking; + if (event === undefined) return; + this.dispatchEvent( + new CustomEvent('trackSubmit', { + bubbles: true, + composed: true, + detail: { + event, + }, + }), + ); + } +} diff --git a/src/elements/index.ts b/src/elements/index.ts index c344327e..dfd905f6 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -29,6 +29,8 @@ export * from './ia-radio-player/search/transcript-index'; export * from './ia-scrubber-bar/ia-scrubber-bar'; export * from './ia-scrubber-bar/ia-section-marker'; export * from './ia-scrubber-bar/models'; +export * from './ia-topnav/ia-topnav'; +export * from './ia-topnav/models'; export * from './ia-transcript-view/duration-formatter'; export * from './ia-transcript-view/ia-transcript-entry'; export * from './ia-transcript-view/ia-transcript-view'; diff --git a/src/themes/sr-only-styles.ts b/src/themes/sr-only-styles.ts new file mode 100644 index 00000000..fe6d77a9 --- /dev/null +++ b/src/themes/sr-only-styles.ts @@ -0,0 +1,24 @@ +import { css } from 'lit'; + +/** + * Hides content visually while leaving it available to screen readers. Apply + * to anything carrying a `sr-only` class. + */ +export const srOnlyStyles = css` + .sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + padding: 0 !important; + border: 0 !important; + overflow: hidden !important; + white-space: nowrap !important; + clip: rect(1px, 1px, 1px, 1px) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + user-select: none !important; + } +`; + +export default srOnlyStyles;