diff --git a/README.md b/README.md index 11ac49c7..cf9aef1a 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,7 @@ Outside lit, use the raw file at `@internetarchive/elements/icons/search.svg`. - **Decorative by default.** The root `` has `aria-hidden="true"` and `focusable="false"`. If an icon is the only content of a control, label the control (`aria-label`), or wrap the icon in an element with `role="img"` and an `aria-label`. - **Color and size.** Icons are filled with `currentColor` and sized `1em` by `width`/`height` on the root, so they follow the surrounding text. Set `font-size` or override `width`/`height` in CSS to resize. +- **Used by.** `ia-item-navigator`, `ia-status-indicator`, `ia-combo-box`, `ia-dropdown-search-bar` and `ia-scrubber-bar` draw their icons from this folder. Recolor one with `color`, since the glyph paints with `currentColor`. - **No barrel.** There's deliberately no `icons/index`. Every icon is its own module, so a page that imports one loads one. - **`sideEffects`.** `package.json` lists the trees that do work at import time (`dist/src/elements/**`, `dist/src/labs/**`, `dist/src/locales/**`). Elements register themselves when imported, so a package-wide `"sideEffects": false` would let a bundler drop `import '@internetarchive/elements/ia-button/ia-button'`. Everything else, including `dist/src/icons/**`, is safe to tree-shake. `src/util/icons-bundle.node.test.ts` bundles the built package to check both. diff --git a/src/elements/ia-combo-box/caret-closed.svg b/icons/caret-closed.svg similarity index 100% rename from src/elements/ia-combo-box/caret-closed.svg rename to icons/caret-closed.svg diff --git a/src/elements/ia-combo-box/clear.svg b/icons/clear.svg similarity index 100% rename from src/elements/ia-combo-box/clear.svg rename to icons/clear.svg diff --git a/src/elements/ia-item-navigator/icons/collapse-sidebar.svg b/icons/collapse-sidebar.svg similarity index 96% rename from src/elements/ia-item-navigator/icons/collapse-sidebar.svg rename to icons/collapse-sidebar.svg index 09943cac..38ab1634 100644 --- a/src/elements/ia-item-navigator/icons/collapse-sidebar.svg +++ b/icons/collapse-sidebar.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-item-navigator/menus/icons/email.svg b/icons/email.svg similarity index 97% rename from src/elements/ia-item-navigator/menus/icons/email.svg rename to icons/email.svg index 8ae0c1c0..f9e73e0c 100644 --- a/src/elements/ia-item-navigator/menus/icons/email.svg +++ b/icons/email.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-item-navigator/menus/icons/facebook.svg b/icons/facebook.svg similarity index 97% rename from src/elements/ia-item-navigator/menus/icons/facebook.svg rename to icons/facebook.svg index bc136cf9..2d8218d6 100644 --- a/src/elements/ia-item-navigator/menus/icons/facebook.svg +++ b/icons/facebook.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-item-navigator/menus/icons/link.svg b/icons/link.svg similarity index 98% rename from src/elements/ia-item-navigator/menus/icons/link.svg rename to icons/link.svg index 0a17e6af..ee40fd44 100644 --- a/src/elements/ia-item-navigator/menus/icons/link.svg +++ b/icons/link.svg @@ -1,6 +1,6 @@ diff --git a/icons/list.svg b/icons/list.svg new file mode 100644 index 00000000..2824955c --- /dev/null +++ b/icons/list.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/elements/ia-status-indicator/icons/audio.svg b/icons/mediatype-audio.svg similarity index 91% rename from src/elements/ia-status-indicator/icons/audio.svg rename to icons/mediatype-audio.svg index 509cf473..c0fab5df 100644 --- a/src/elements/ia-status-indicator/icons/audio.svg +++ b/icons/mediatype-audio.svg @@ -1,5 +1,5 @@ - + diff --git a/src/elements/ia-status-indicator/icons/collection.svg b/icons/mediatype-collection.svg similarity index 98% rename from src/elements/ia-status-indicator/icons/collection.svg rename to icons/mediatype-collection.svg index db3ffd6b..b4b0e5a8 100644 --- a/src/elements/ia-status-indicator/icons/collection.svg +++ b/icons/mediatype-collection.svg @@ -1,5 +1,5 @@ - + diff --git a/src/elements/ia-status-indicator/icons/etree.svg b/icons/mediatype-etree.svg similarity index 93% rename from src/elements/ia-status-indicator/icons/etree.svg rename to icons/mediatype-etree.svg index 64ba3fbc..52ae6493 100644 --- a/src/elements/ia-status-indicator/icons/etree.svg +++ b/icons/mediatype-etree.svg @@ -1,5 +1,5 @@ - + diff --git a/src/elements/ia-status-indicator/icons/images.svg b/icons/mediatype-images.svg similarity index 94% rename from src/elements/ia-status-indicator/icons/images.svg rename to icons/mediatype-images.svg index c04b38e1..ef188d7c 100644 --- a/src/elements/ia-status-indicator/icons/images.svg +++ b/icons/mediatype-images.svg @@ -1,6 +1,6 @@ - + diff --git a/src/elements/ia-status-indicator/icons/software.svg b/icons/mediatype-software.svg similarity index 95% rename from src/elements/ia-status-indicator/icons/software.svg rename to icons/mediatype-software.svg index 4da1aaea..9f58dfef 100644 --- a/src/elements/ia-status-indicator/icons/software.svg +++ b/icons/mediatype-software.svg @@ -1,6 +1,6 @@ - + diff --git a/src/elements/ia-status-indicator/icons/texts.svg b/icons/mediatype-texts.svg similarity index 92% rename from src/elements/ia-status-indicator/icons/texts.svg rename to icons/mediatype-texts.svg index c88abea7..a10fd101 100644 --- a/src/elements/ia-status-indicator/icons/texts.svg +++ b/icons/mediatype-texts.svg @@ -1,6 +1,6 @@ - + diff --git a/src/elements/ia-status-indicator/icons/tv.svg b/icons/mediatype-tv.svg similarity index 92% rename from src/elements/ia-status-indicator/icons/tv.svg rename to icons/mediatype-tv.svg index a3c4d912..b7eaed3e 100644 --- a/src/elements/ia-status-indicator/icons/tv.svg +++ b/icons/mediatype-tv.svg @@ -1,5 +1,5 @@ - + diff --git a/src/elements/ia-status-indicator/icons/video.svg b/icons/mediatype-video.svg similarity index 97% rename from src/elements/ia-status-indicator/icons/video.svg rename to icons/mediatype-video.svg index 55e7dd15..a8018862 100644 --- a/src/elements/ia-status-indicator/icons/video.svg +++ b/icons/mediatype-video.svg @@ -1,6 +1,6 @@ - + diff --git a/src/elements/ia-status-indicator/icons/web.svg b/icons/mediatype-web.svg similarity index 88% rename from src/elements/ia-status-indicator/icons/web.svg rename to icons/mediatype-web.svg index e6c12e81..8ba88a4f 100644 --- a/src/elements/ia-status-indicator/icons/web.svg +++ b/icons/mediatype-web.svg @@ -1,6 +1,6 @@ - + diff --git a/src/elements/ia-item-navigator/menus/icons/pinterest.svg b/icons/pinterest.svg similarity index 99% rename from src/elements/ia-item-navigator/menus/icons/pinterest.svg rename to icons/pinterest.svg index 1121a06b..d9bb2f65 100644 --- a/src/elements/ia-item-navigator/menus/icons/pinterest.svg +++ b/icons/pinterest.svg @@ -1,6 +1,6 @@ diff --git a/icons/scrubber-next-section.svg b/icons/scrubber-next-section.svg new file mode 100644 index 00000000..629cba72 --- /dev/null +++ b/icons/scrubber-next-section.svg @@ -0,0 +1,7 @@ + + + diff --git a/icons/scrubber-previous-section.svg b/icons/scrubber-previous-section.svg new file mode 100644 index 00000000..dbfc57fb --- /dev/null +++ b/icons/scrubber-previous-section.svg @@ -0,0 +1,7 @@ + + + diff --git a/src/elements/ia-item-navigator/menus/icons/share.svg b/icons/share.svg similarity index 98% rename from src/elements/ia-item-navigator/menus/icons/share.svg rename to icons/share.svg index a98fe4a5..59e9ba1b 100644 --- a/src/elements/ia-item-navigator/menus/icons/share.svg +++ b/icons/share.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-item-navigator/menus/icons/sort-asc.svg b/icons/sort-asc.svg similarity index 91% rename from src/elements/ia-item-navigator/menus/icons/sort-asc.svg rename to icons/sort-asc.svg index eb3afee9..77e5362e 100644 --- a/src/elements/ia-item-navigator/menus/icons/sort-asc.svg +++ b/icons/sort-asc.svg @@ -2,17 +2,17 @@ - + diff --git a/src/elements/ia-item-navigator/menus/icons/twitter.svg b/icons/twitter.svg similarity index 98% rename from src/elements/ia-item-navigator/menus/icons/twitter.svg rename to icons/twitter.svg index 28f38a16..c29966ba 100644 --- a/src/elements/ia-item-navigator/menus/icons/twitter.svg +++ b/icons/twitter.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-item-navigator/menus/icons/viewable-files.svg b/icons/viewable-files.svg similarity index 99% rename from src/elements/ia-item-navigator/menus/icons/viewable-files.svg rename to icons/viewable-files.svg index 4fb74cc0..488c54a6 100644 --- a/src/elements/ia-item-navigator/menus/icons/viewable-files.svg +++ b/icons/viewable-files.svg @@ -1,6 +1,6 @@ diff --git a/src/elements/ia-combo-box/caret-open.svg b/src/elements/ia-combo-box/caret-open.svg deleted file mode 100644 index 5972561d..00000000 --- a/src/elements/ia-combo-box/caret-open.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/src/elements/ia-combo-box/ia-combo-box.test.ts b/src/elements/ia-combo-box/ia-combo-box.test.ts index 353d19ba..8b342d20 100644 --- a/src/elements/ia-combo-box/ia-combo-box.test.ts +++ b/src/elements/ia-combo-box/ia-combo-box.test.ts @@ -1145,4 +1145,74 @@ describe('IA Combo Box', () => { expect(comboBox.value).to.equal('foo'); // Closed & selected the highlighted option }); }); + + describe('Icons', () => { + test('draws the caret as inline svg, closed then open', async () => { + const el = await fixture( + html``, + ); + const carets = el.shadowRoot?.querySelectorAll( + '#caret-button .caret-icon', + ) as NodeListOf; + expect(carets).to.have.lengthOf(2); + carets.forEach((caret) => { + expect(caret.getAttribute('part')).to.equal('icon caret-icon'); + expect(caret.getAttribute('aria-hidden')).to.equal('true'); + expect(caret.querySelector('svg')).to.exist; + expect(caret.querySelector('img')).to.not.exist; + }); + expect(carets[0].innerHTML).to.not.equal(carets[1].innerHTML); + + const fontSize = parseFloat(getComputedStyle(carets[0]).fontSize); + const svg = carets[0].querySelector('svg') as SVGElement; + const { width, height } = svg.getBoundingClientRect(); + expect(width).to.be.closeTo(fontSize * 0.875, 0.01); + expect(height).to.be.closeTo(fontSize * 0.875, 0.01); + expect(getComputedStyle(svg).fill).to.equal('rgb(0, 0, 0)'); + }); + + test('draws the clear glyph at 1em, and lets a part rule recolor it', async () => { + const el = await fixture(html` + + + `); + const combo = el.parentElement?.querySelector( + 'ia-combo-box', + ) as IAComboBox; + await combo.updateComplete; + const svg = combo.shadowRoot?.querySelector( + '#clear-button .clear-icon svg', + ) as SVGElement; + expect(svg).to.exist; + + const fontSize = parseFloat(getComputedStyle(svg).fontSize); + expect(svg.getBoundingClientRect().width).to.be.closeTo(fontSize, 0.01); + expect(getComputedStyle(svg).fill).to.equal('rgb(10, 20, 30)'); + }); + + test('keeps the icon-only buttons named by their screen-reader text', async () => { + const el = await fixture( + html``, + ); + + expect( + el.shadowRoot?.querySelector('#caret-button .sr-only')?.textContent, + ).to.equal('Toggle options'); + expect( + el.shadowRoot?.querySelector('#clear-button .sr-only')?.textContent, + ).to.equal('Clear'); + }); + }); }); diff --git a/src/elements/ia-combo-box/ia-combo-box.ts b/src/elements/ia-combo-box/ia-combo-box.ts index bcd7ad15..1c1f39f8 100644 --- a/src/elements/ia-combo-box/ia-combo-box.ts +++ b/src/elements/ia-combo-box/ia-combo-box.ts @@ -26,9 +26,9 @@ import { } from './models'; import themeStyles from '@src/themes/theme-styles'; -import caretClosedIcon from './caret-closed.svg'; -import caretOpenIcon from './caret-open.svg'; -import clearIcon from './clear.svg'; +import caretClosedIcon from '@src/icons/caret-closed'; +import caretOpenIcon from '@src/icons/caret-open'; +import clearIcon from '@src/icons/clear'; /** * Map from filter preset keys to their associated filtering function. @@ -423,13 +423,12 @@ export class IAComboBox extends LitElement { > ${msg('Clear')} - + >${clearIcon} `; @@ -442,22 +441,14 @@ export class IAComboBox extends LitElement { private get caretTemplate(): TemplateResult { return html` - + - + `; } @@ -1337,6 +1328,18 @@ export class IAComboBox extends LitElement { text-align: center; } + /* The svg fills this box, so its width and height are the glyph's size. */ + .icon { + display: inline-block; + color: #000; + } + + .icon > svg { + display: block; + width: 100%; + height: 100%; + } + .caret-icon { width: 0.875em; height: 0.875em; diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts index 46bb6c9f..b0511ec5 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts @@ -269,4 +269,25 @@ describe('IA Dropdown Search Bar', () => { expect(detail.category).to.equal('all'); }); }); + + describe('search button icon', () => { + test('draws the magnifying glass as an 18px inline svg', () => { + const svg = el.shadowRoot?.querySelector( + '#search-button svg', + ) as SVGElement; + expect(svg).to.exist; + expect(el.shadowRoot?.querySelector('#search-button img')).to.not.exist; + + const { width, height } = svg.getBoundingClientRect(); + expect(width).to.equal(18); + expect(height).to.equal(18); + expect(getComputedStyle(svg).color).to.equal('rgb(44, 44, 44)'); + expect(getComputedStyle(svg).fill).to.equal('rgb(44, 44, 44)'); + }); + + test('keeps the button named by its aria-label', () => { + const button = el.shadowRoot?.querySelector('#search-button'); + expect(button?.getAttribute('aria-label')).to.equal('Search'); + }); + }); }); diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts index 3ed75693..c0381ec2 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts @@ -14,7 +14,7 @@ import type { IaDropdown, optionInterface } from '@internetarchive/ia-dropdown'; import type { SearchCategory, SearchRequestedDetail } from './models'; import themeStyles from '@src/themes/theme-styles'; -import searchIcon from './search.svg'; +import searchIcon from '@src/icons/search'; import '@internetarchive/ia-clearable-text-input'; import '@internetarchive/ia-dropdown'; @@ -156,7 +156,7 @@ export class IADropdownSearchBar extends LitElement { mode="loading" class="search-button-loading-icon" >` - : html``} + : searchIcon} `; } @@ -338,9 +338,10 @@ export class IADropdownSearchBar extends LitElement { flex-shrink: 0; } - #search-button img { + #search-button svg { width: 18px; height: 18px; + color: #2c2c2c; } .search-button-loading-icon { diff --git a/src/elements/ia-dropdown-search-bar/search.svg b/src/elements/ia-dropdown-search-bar/search.svg deleted file mode 100644 index c6313e9e..00000000 --- a/src/elements/ia-dropdown-search-bar/search.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index 0c045458..34a9f3bf 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -8,8 +8,8 @@ import './menus/ia-itemnav-sort-files-button'; import './menus/ia-itemnav-share-panel'; import { viewableFilesIcon } from './menus/ia-itemnav-viewable-files-panel'; import { shareIcon } from './menus/ia-itemnav-share-panel'; -import { maskedIcon } from '@src/util/masked-icon'; -import listIconUrl from './menus/icons/list.svg'; +import list from '@src/icons/list'; +import { iconBox } from '@src/util/icon-box'; import type { MenuProviderInterface, MenuShortcutInterface, @@ -22,7 +22,7 @@ import type { import '@demo/story-template'; /** A generic list-ish glyph for the "About" demo menu entry. */ -const demoIcon = maskedIcon(listIconUrl); +const demoIcon = iconBox(list); /** * Alternate themes for the demo's randomize control. diff --git a/src/elements/ia-item-navigator/ia-item-navigator.test.ts b/src/elements/ia-item-navigator/ia-item-navigator.test.ts index ed13cf22..57f4a03c 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.test.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.test.ts @@ -543,4 +543,75 @@ describe('IAItemNavigator', () => { expect(el.openMenu).to.equal('share'); expect(el.menuOpened).to.equal(true); }); + + describe('toggle button icon', () => { + async function navigator(style = ''): Promise { + const el = await fixture( + html``, + ); + el.menuContents = [provider('contents')]; + await el.updateComplete; + return el; + } + + test('draws the ellipses glyph as inline svg at the icon size', async () => { + const el = await navigator('--item-navigator-icon-size: 40px'); + const glyph = el.shadowRoot?.querySelector( + 'button.toggle-menu .ia-icon > svg', + ) as SVGElement; + expect(glyph).to.exist; + expect(glyph.getAttribute('viewBox')).to.equal('0 0 40 40'); + + // The glyph fills the ring's border box, which is the area the ring's + // border is drawn around. + const ring = glyph.parentElement as HTMLElement; + const glyphBox = glyph.getBoundingClientRect(); + const ringBox = ring.getBoundingClientRect(); + expect(glyphBox.width).to.be.closeTo(ringBox.width, 0.5); + expect(glyphBox.height).to.be.closeTo(ringBox.height, 0.5); + expect(glyphBox.left).to.be.closeTo(ringBox.left, 0.5); + expect(glyphBox.top).to.be.closeTo(ringBox.top, 0.5); + }); + + test('keeps the ring geometry but does not draw it', async () => { + const el = await navigator('--item-navigator-icon-size: 40px'); + const box = el.shadowRoot?.querySelector( + 'button.toggle-menu > .ia-icon', + ) as HTMLElement; + const glyph = box.querySelector('svg') as SVGElement; + const style = getComputedStyle(box); + + expect(style.borderTopWidth).to.equal('2px'); + for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + expect(style[`border${side}Color`], side).to.equal('rgba(0, 0, 0, 0)'); + } + // The glyph covers exactly the span's border box. + const outer = box.getBoundingClientRect(); + const inner = glyph.getBoundingClientRect(); + expect(inner.left).to.be.closeTo(outer.left, 0.01); + expect(inner.top).to.be.closeTo(outer.top, 0.01); + expect(inner.width).to.be.closeTo(outer.width, 0.01); + expect(inner.height).to.be.closeTo(outer.height, 0.01); + }); + + test('recolors the glyph with the icon color knob', async () => { + const el = await navigator('--item-navigator-icon-color: rgb(1, 2, 3)'); + const glyph = el.shadowRoot?.querySelector( + 'button.toggle-menu .ia-icon > svg', + ) as SVGElement; + + expect(getComputedStyle(glyph).color).to.equal('rgb(1, 2, 3)'); + expect(getComputedStyle(glyph).fill).to.equal('rgb(1, 2, 3)'); + }); + + test('keeps the toggle named by its aria-label, not the decorative glyph', async () => { + const el = await navigator(); + const button = el.shadowRoot?.querySelector('button.toggle-menu'); + + expect(button?.getAttribute('aria-label')).to.equal('Open side panel'); + expect( + button?.querySelector('svg')?.getAttribute('aria-hidden'), + ).to.equal('true'); + }); + }); }); diff --git a/src/elements/ia-item-navigator/ia-item-navigator.ts b/src/elements/ia-item-navigator/ia-item-navigator.ts index 5200ea9f..0a6b87b5 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.ts @@ -11,7 +11,8 @@ import { customElement } from '@src/util/custom-element'; import { classMap } from 'lit/directives/class-map.js'; import themeStyles from '@src/themes/theme-styles'; -import { ellipsesIcon } from './icons'; +import ellipsesIcon from '@src/icons/ellipses'; +import { iconBox, iconBoxStyles } from '@src/util/icon-box'; import './ia-itemnav-menu-slider'; import './ia-itemnav-no-theater-available'; import type { IAItemNavMenuSlider } from './ia-itemnav-menu-slider'; @@ -245,7 +246,7 @@ export class IAItemNavigator extends LitElement { aria-expanded=${this.menuOpened} aria-controls="menu" > - ${ellipsesIcon} + ${iconBox(ellipsesIcon)} `; } @@ -347,6 +348,7 @@ export class IAItemNavigator extends LitElement { return [ themeStyles, + iconBoxStyles, css` :host { --item-navigator-menu-width--: var( @@ -496,13 +498,30 @@ export class IAItemNavigator extends LitElement { } nav .minimized button.toggle-menu > * { - border: 2px solid var(--item-navigator-icon-color--); + /* The border only reserves the ring's geometry. The glyph fills the + border box and the ring itself isn't drawn. */ + border: 2px solid transparent; border-radius: ${iconSize}; width: ${iconSize}; height: ${iconSize}; margin: auto; } + /* The glyph fills the ring's border box, so it is as large as the ring + it sits in rather than the smaller box inside the border, and is + clipped to the ring's rounded corners. */ + nav .minimized button.toggle-menu > .ia-icon { + position: relative; + clip-path: inset(0 round ${iconSize}); + } + + nav .minimized button.toggle-menu > .ia-icon > svg { + position: absolute; + inset: -2px; + width: calc(100% + 4px); + height: calc(100% + 4px); + } + /* The rail is a list for assistive tech; strip the list chrome so it still reads as a row of icons. */ .shortcuts, @@ -518,10 +537,9 @@ export class IAItemNavigator extends LitElement { height: ${iconSize}; } - /* Our glyphs are masked spans: the mask supplies the shape, this - supplies the paint. */ - span.ia-icon { - background-color: var(--item-navigator-icon-color--); + /* Our glyphs paint with currentColor, so this supplies the paint. */ + .ia-icon { + color: var(--item-navigator-icon-color--); } /* Host-supplied icons may still be inline svg, so keep theming those diff --git a/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts b/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts index 87b2ee78..8ff6bcb3 100644 --- a/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts +++ b/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts @@ -4,7 +4,8 @@ import { describe, expect, test, vi } from 'vitest'; import { IAItemNavMenuButton } from './ia-itemnav-menu-button'; import './ia-itemnav-menu-button'; -import { maskedIcon } from '@src/util/masked-icon'; +import list from '@src/icons/list'; +import { iconBox } from '@src/util/icon-box'; describe('IAItemNavMenuButton', () => { test('renders a `; @@ -191,7 +192,7 @@ export class IAItemNavMenuSlider extends LitElement { title="Close navigation" @click=${this.closeMenu} > - ${collapseSidebarIcon} + ${iconBox(collapseSidebar)} `; } @@ -235,6 +236,7 @@ export class IAItemNavMenuSlider extends LitElement { return [ themeStyles, + iconBoxStyles, css` :host { --item-navigator-menu-width--: var( @@ -356,10 +358,9 @@ export class IAItemNavMenuSlider extends LitElement { height: var(--item-navigator-header-icon-size--); } - /* Our glyphs are masked spans: the mask supplies the shape, this - supplies the paint. */ - span.ia-icon { - background-color: var(--item-navigator-icon-color--); + /* Our glyphs paint with currentColor, so this supplies the paint. */ + .ia-icon { + color: var(--item-navigator-icon-color--); } /* Host-supplied icons may still be inline svg, so keep theming those diff --git a/src/elements/ia-item-navigator/icons.ts b/src/elements/ia-item-navigator/icons.ts deleted file mode 100644 index 07279ba2..00000000 --- a/src/elements/ia-item-navigator/icons.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { maskedIcon } from '@src/util/masked-icon'; - -import ellipsesUrl from './icons/ellipses.svg'; -import collapseSidebarUrl from './icons/collapse-sidebar.svg'; - -/** - * Icons taken from the `@internetarchive/icon-ellipses` and - * `@internetarchive/icon-collapse-sidebar` packages (v1.4.1). They ship as - * standalone `.svg` files — rather than icon package dependencies or inline - * templates — so their path data stays out of the JS bundle. Each is rendered - * as a CSS-masked span (see `maskedIcon`) so host components can still recolor - * it via their own custom properties. - */ - -/** Three-dot "more options" glyph used by the menu toggle button. */ -export const ellipsesIcon = maskedIcon(ellipsesUrl); - -/** Circular "collapse sidebar" glyph used by the menu/panel close buttons. */ -export const collapseSidebarIcon = maskedIcon(collapseSidebarUrl); diff --git a/src/elements/ia-item-navigator/icons/ellipses.svg b/src/elements/ia-item-navigator/icons/ellipses.svg deleted file mode 100644 index 09c40732..00000000 --- a/src/elements/ia-item-navigator/icons/ellipses.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.test.ts b/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.test.ts index ad32180b..ac6d88e2 100644 --- a/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.test.ts +++ b/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.test.ts @@ -154,4 +154,90 @@ describe('IAItemNavSharePanel', () => { vi.advanceTimersByTime(4000); expect(note.classList.contains('visible')).to.equal(false); }); + + describe('icons', () => { + test('every share option and the embed link draw an inline svg glyph', async () => { + const el = await fixture( + html``, + ); + + const rows = el.shadowRoot?.querySelectorAll( + 'a.share-option, summary.share-option', + ); + expect(rows).to.have.lengthOf(6); + const markup = new Set(); + rows?.forEach((row) => { + const glyph = row.querySelector('.ia-icon > svg') as SVGElement; + expect(glyph, row.textContent?.trim()).to.exist; + expect(glyph.getAttribute('aria-hidden')).to.equal('true'); + // The 2em icon box inherits the panel's base font size. + const { width, height } = glyph.getBoundingClientRect(); + expect(width).to.be.greaterThan(0); + expect(width).to.equal(height); + markup.add(glyph.innerHTML); + }); + expect(markup.size, 'each row has its own glyph').to.equal(6); + }); + + test('keeps each icon box geometry but does not draw its border', async () => { + const el = await fixture( + html``, + ); + + const boxes = el.shadowRoot?.querySelectorAll( + '.share-option > .ia-icon', + ) as NodeListOf; + expect(boxes).to.have.lengthOf(6); + boxes.forEach((box) => { + const style = getComputedStyle(box); + expect(style.borderTopWidth).to.equal('1px'); + for (const side of ['Top', 'Right', 'Bottom', 'Left'] as const) { + expect(style[`border${side}Color`], side).to.equal( + 'rgba(0, 0, 0, 0)', + ); + } + // The glyph covers exactly the span's border box. + const outer = box.getBoundingClientRect(); + const inner = ( + box.querySelector('svg') as SVGElement + ).getBoundingClientRect(); + expect(inner.left).to.be.closeTo(outer.left, 0.01); + expect(inner.top).to.be.closeTo(outer.top, 0.01); + expect(inner.width).to.be.closeTo(outer.width, 0.01); + expect(inner.height).to.be.closeTo(outer.height, 0.01); + }); + }); + + test('recolors the glyphs with the icon color knob', async () => { + const el = await fixture( + html``, + ); + + const glyph = el.shadowRoot?.querySelector( + 'a.share-option .ia-icon > svg', + ) as SVGElement; + expect(getComputedStyle(glyph).color).to.equal('rgb(7, 8, 9)'); + expect(getComputedStyle(glyph).fill).to.equal('rgb(7, 8, 9)'); + }); + + test('keeps each option named by its text', async () => { + const el = await fixture( + html``, + ); + + const names = [ + ...(el.shadowRoot?.querySelectorAll('a.share-option') ?? []), + ].map((a) => a.textContent?.trim()); + expect(names.every((name) => !!name)).to.equal(true); + }); + }); }); diff --git a/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.ts b/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.ts index 1542434d..f667f274 100644 --- a/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.ts +++ b/src/elements/ia-item-navigator/menus/ia-itemnav-share-panel.ts @@ -10,15 +10,14 @@ import { import { property } from 'lit/decorators.js'; import { customElement } from '@src/util/custom-element'; import themeStyles from '@src/themes/theme-styles'; -import { - shareIcon, - twitterIcon, - facebookIcon, - tumblrIcon, - pinterestIcon, - emailIcon, - linkIcon, -} from './share-icons'; +import share from '@src/icons/share'; +import twitter from '@src/icons/twitter'; +import facebook from '@src/icons/facebook'; +import tumblr from '@src/icons/tumblr'; +import pinterest from '@src/icons/pinterest'; +import email from '@src/icons/email'; +import link from '@src/icons/link'; +import { iconBox, iconBoxStyles } from '@src/util/icon-box'; type ShareOption = { name: string; @@ -27,7 +26,7 @@ type ShareOption = { }; /** Icon for the share menu shortcut / menu entry. */ -export { shareIcon }; +export const shareIcon: TemplateResult = iconBox(share); /** * The "share this item" side panel: social sharing links (Twitter, Facebook, @@ -83,7 +82,7 @@ export class IAItemNavSharePanel extends LitElement { this.sharingOptions = [ { name: 'Twitter', - icon: twitterIcon, + icon: iconBox(twitter), url: `https://twitter.com/intent/tweet?${new URLSearchParams({ url: shareUrl, text: shareBlurb, @@ -92,14 +91,14 @@ export class IAItemNavSharePanel extends LitElement { }, { name: 'Facebook', - icon: facebookIcon, + icon: iconBox(facebook), url: `https://www.facebook.com/sharer/sharer.php?${new URLSearchParams({ u: shareUrl, })}`, }, { name: 'Tumblr', - icon: tumblrIcon, + icon: iconBox(tumblr), url: `https://www.tumblr.com/widgets/share/tool/preview?${new URLSearchParams( { posttype: 'link', @@ -110,7 +109,7 @@ export class IAItemNavSharePanel extends LitElement { }, { name: 'Pinterest', - icon: pinterestIcon, + icon: iconBox(pinterest), url: `http://www.pinterest.com/pin/create/button/?${new URLSearchParams( { url: shareUrl, @@ -120,7 +119,7 @@ export class IAItemNavSharePanel extends LitElement { }, { name: 'Email', - icon: emailIcon, + icon: iconBox(email), url: `mailto:?${new URLSearchParams({ subject: shareBlurb, body: shareUrl, @@ -190,7 +189,7 @@ export class IAItemNavSharePanel extends LitElement { )}

Embed

@@ -221,6 +220,7 @@ export class IAItemNavSharePanel extends LitElement { static get styles(): CSSResultGroup { return [ themeStyles, + iconBoxStyles, css` :host { --item-navigator-text-color--: var( @@ -293,7 +293,8 @@ export class IAItemNavSharePanel extends LitElement { padding: 0.2em; margin-right: 1em; vertical-align: middle; - border: 1px solid var(--item-navigator-border-color--); + /* The border only reserves geometry. It isn't drawn. */ + border: 1px solid transparent; border-radius: 7px; } @@ -303,12 +304,22 @@ export class IAItemNavSharePanel extends LitElement { font-size: var(--item-navigator-base-font-size--); width: 2em; height: 2em; + position: relative; + /* The glyph fills the border box (the padding box plus the 1px + border) and is clipped to its rounded corners. */ + clip-path: inset(0 round 7px); + } + + .share-option .ia-icon > svg { + position: absolute; + inset: -1px; + width: calc(100% + 2px); + height: calc(100% + 2px); } - /* Our glyphs are masked spans: the mask supplies the shape, this - supplies the paint. */ - span.ia-icon { - background-color: var(--item-navigator-icon-color--); + /* Our glyphs paint with currentColor, so this supplies the paint. */ + .ia-icon { + color: var(--item-navigator-icon-color--); } /* Host-supplied icons may still be inline svg, so keep theming those diff --git a/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.test.ts b/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.test.ts index ab418ef5..3c1de55b 100644 --- a/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.test.ts +++ b/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.test.ts @@ -137,11 +137,38 @@ describe('IAItemNavSortFilesButton', () => { style="--item-navigator-icon-color: rgb(0, 128, 64)" >`, ); - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; + const glyph = el.shadowRoot?.querySelector('.ia-icon > svg') as SVGElement; - // The glyph is a masked span, so the theming knob drives background-color. - expect(icon.tagName).to.equal('SPAN'); - expect(getComputedStyle(icon).backgroundColor).to.equal('rgb(0, 128, 64)'); + // The glyph is inline svg painted with currentColor, so the theming knob + // drives color. + expect(getComputedStyle(glyph).color).to.equal('rgb(0, 128, 64)'); + expect(getComputedStyle(glyph).fill).to.equal('rgb(0, 128, 64)'); + }); + + test('sizes the sort glyph with the header icon size knob', async () => { + const el = await fixture( + html``, + ); + const glyph = el.shadowRoot?.querySelector('.ia-icon > svg') as SVGElement; + const { width, height } = glyph.getBoundingClientRect(); + + expect(width).to.equal(24); + expect(height).to.equal(24); + }); + + test('keeps an accessible name on the icon-only toggle in every state', async () => { + const el = await fixture( + html``, + ); + + for (const state of ['title_asc', 'title_desc', 'default'] as const) { + el.sortVolumes(state); + await el.updateComplete; + const button = el.shadowRoot?.querySelector('button.sort-by'); + expect(button?.getAttribute('aria-label'), state).to.match(/^Sort/); + } }); test('recolors every state of the sort toggle', async () => { @@ -154,8 +181,10 @@ describe('IAItemNavSortFilesButton', () => { for (const state of ['title_asc', 'title_desc', 'default'] as const) { el.sortVolumes(state); await el.updateComplete; - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; - expect(getComputedStyle(icon).backgroundColor, state).to.equal( + const glyph = el.shadowRoot?.querySelector( + '.ia-icon > svg', + ) as SVGElement; + expect(getComputedStyle(glyph).color, state).to.equal( 'rgb(200, 100, 50)', ); } diff --git a/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.ts b/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.ts index ca173a7b..c6ca54ce 100644 --- a/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.ts +++ b/src/elements/ia-item-navigator/menus/ia-itemnav-sort-files-button.ts @@ -8,17 +8,17 @@ import { import { property } from 'lit/decorators.js'; import { customElement } from '@src/util/custom-element'; import themeStyles from '@src/themes/theme-styles'; -import { maskedIcon } from '@src/util/masked-icon'; -import sortAscUrl from './icons/sort-asc.svg'; -import sortDescUrl from './icons/sort-desc.svg'; -import sortNeutralUrl from './icons/sort-neutral.svg'; +import { iconBox, iconBoxStyles } from '@src/util/icon-box'; +import sortAsc from '@src/icons/sort-asc'; +import sortDesc from '@src/icons/sort-desc'; +import sortNeutral from '@src/icons/sort-neutral'; import type { FileSortOption, ViewableFileInfo } from './models'; -const sortAscIcon = maskedIcon(sortAscUrl); +const sortAscIcon = iconBox(sortAsc); -const sortDescIcon = maskedIcon(sortDescUrl); +const sortDescIcon = iconBox(sortDesc); -const sortNeutralIcon = maskedIcon(sortNeutralUrl); +const sortNeutralIcon = iconBox(sortNeutral); /** * A tri-state sort toggle for the viewable-files panel header. Cycles @@ -97,6 +97,7 @@ export class IAItemNavSortFilesButton extends LitElement { static get styles(): CSSResultGroup { return [ themeStyles, + iconBoxStyles, css` :host { /* Every glyph is square, so one knob sizes both axes. Matches the @@ -129,12 +130,11 @@ export class IAItemNavSortFilesButton extends LitElement { font: inherit; } - /* The glyph is a masked span: the mask supplies the shape, this - supplies the paint. */ + /* The glyph paints with currentColor, so this supplies the paint. */ button.sort-by .ia-icon { width: var(--item-navigator-header-icon-size--); height: var(--item-navigator-header-icon-size--); - background-color: var(--item-navigator-icon-color--); + color: var(--item-navigator-icon-color--); } `, ]; diff --git a/src/elements/ia-item-navigator/menus/ia-itemnav-viewable-files-panel.ts b/src/elements/ia-item-navigator/menus/ia-itemnav-viewable-files-panel.ts index a7f0e81b..6e50e660 100644 --- a/src/elements/ia-item-navigator/menus/ia-itemnav-viewable-files-panel.ts +++ b/src/elements/ia-item-navigator/menus/ia-itemnav-viewable-files-panel.ts @@ -11,12 +11,12 @@ import { property } from 'lit/decorators.js'; import { customElement } from '@src/util/custom-element'; import { repeat } from 'lit/directives/repeat.js'; import themeStyles from '@src/themes/theme-styles'; -import { maskedIcon } from '@src/util/masked-icon'; -import viewableFilesUrl from './icons/viewable-files.svg'; +import viewableFiles from '@src/icons/viewable-files'; +import { iconBox } from '@src/util/icon-box'; import type { FileSortOption, ViewableFileInfo } from './models'; /** Icon for the viewable-files menu shortcut / menu entry. */ -export const viewableFilesIcon = maskedIcon(viewableFilesUrl); +export const viewableFilesIcon = iconBox(viewableFiles); /** * The "viewable files" side panel: a scrollable list of the item's viewable diff --git a/src/elements/ia-item-navigator/menus/icons/list.svg b/src/elements/ia-item-navigator/menus/icons/list.svg deleted file mode 100644 index 03b969df..00000000 --- a/src/elements/ia-item-navigator/menus/icons/list.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/elements/ia-item-navigator/menus/share-icons.ts b/src/elements/ia-item-navigator/menus/share-icons.ts deleted file mode 100644 index 35a2c253..00000000 --- a/src/elements/ia-item-navigator/menus/share-icons.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type TemplateResult } from 'lit'; -import { maskedIcon } from '@src/util/masked-icon'; - -import shareUrl from './icons/share.svg'; -import twitterUrl from './icons/twitter.svg'; -import facebookUrl from './icons/facebook.svg'; -import tumblrUrl from './icons/tumblr.svg'; -import pinterestUrl from './icons/pinterest.svg'; -import emailUrl from './icons/email.svg'; -import linkUrl from './icons/link.svg'; - -/** - * Icons taken from the `@internetarchive/icon-*` packages (v1.4.1) used by the - * share panel. They ship as standalone `.svg` files — rather than icon package - * dependencies or inline templates — so their path data stays out of the JS - * bundle, and render as CSS-masked spans (see `maskedIcon`) so the panel can - * still theme them. Icons are decorative, labelled by adjacent text. - */ - -export const shareIcon: TemplateResult = maskedIcon(shareUrl); - -export const twitterIcon: TemplateResult = maskedIcon(twitterUrl); - -export const facebookIcon: TemplateResult = maskedIcon(facebookUrl); - -export const tumblrIcon: TemplateResult = maskedIcon(tumblrUrl); - -export const pinterestIcon: TemplateResult = maskedIcon(pinterestUrl); - -export const emailIcon: TemplateResult = maskedIcon(emailUrl); - -export const linkIcon: TemplateResult = maskedIcon(linkUrl); diff --git a/src/elements/ia-scrubber-bar/assets/next-section-marker.ts b/src/elements/ia-scrubber-bar/assets/next-section-marker.ts deleted file mode 100644 index 071ce79c..00000000 --- a/src/elements/ia-scrubber-bar/assets/next-section-marker.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @file Right-pointing arrow shown on a section marker - * - * Inline so the marker can recolor it with `currentColor`, which is not - * possible on the contents of an ``. - */ -import { svg } from 'lit'; - -export default svg` - - - -`; diff --git a/src/elements/ia-scrubber-bar/assets/previous-section-marker.ts b/src/elements/ia-scrubber-bar/assets/previous-section-marker.ts deleted file mode 100644 index 9ef7835b..00000000 --- a/src/elements/ia-scrubber-bar/assets/previous-section-marker.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @file Left-pointing arrow shown on a section marker - * - * Inline so the marker can recolor it with `currentColor`, which is not - * possible on the contents of an ``. - */ -import { svg } from 'lit'; - -export default svg` - - - -`; diff --git a/src/elements/ia-scrubber-bar/ia-scrubber-bar.test.ts b/src/elements/ia-scrubber-bar/ia-scrubber-bar.test.ts index e0adb706..0dc5466a 100644 --- a/src/elements/ia-scrubber-bar/ia-scrubber-bar.test.ts +++ b/src/elements/ia-scrubber-bar/ia-scrubber-bar.test.ts @@ -360,4 +360,27 @@ describe('IA Section Marker', () => { const divider = el.shadowRoot?.querySelector('.center-divider'); expect(getComputedStyle(divider as Element).height).to.equal('24px'); }); + + describe('section marker arrows', () => { + test('draw both arrows as 8x10 inline svgs in the marker color', async () => { + const el = await fixture( + html``, + ); + + const arrows = markersIn(el)[0].shadowRoot?.querySelectorAll( + '.arrow > svg', + ) as NodeListOf; + expect(arrows).to.have.lengthOf(2); + arrows.forEach((arrow) => { + const { width, height } = arrow.getBoundingClientRect(); + expect(width).to.equal(8); + expect(height).to.equal(10); + expect(getComputedStyle(arrow).fill).to.equal('rgb(9, 8, 7)'); + }); + expect(arrows[0].innerHTML).to.not.equal(arrows[1].innerHTML); + }); + }); }); diff --git a/src/elements/ia-scrubber-bar/ia-section-marker.ts b/src/elements/ia-scrubber-bar/ia-section-marker.ts index dcb531d4..68656d88 100644 --- a/src/elements/ia-scrubber-bar/ia-section-marker.ts +++ b/src/elements/ia-scrubber-bar/ia-section-marker.ts @@ -10,8 +10,8 @@ import { customElement } from '@src/util/custom-element'; import { SectionMarkerMode } from './models'; -import nextSectionIcon from './assets/next-section-marker'; -import previousSectionIcon from './assets/previous-section-marker'; +import nextSectionIcon from '@src/icons/scrubber-next-section'; +import previousSectionIcon from '@src/icons/scrubber-previous-section'; /** * A single section boundary drawn on the scrubber bar. @@ -65,6 +65,11 @@ export class IASectionMarker extends LitElement { height: 100%; } + .arrow svg { + width: 8px; + height: 10px; + } + .arrow { padding-top: 10px; opacity: 1; diff --git a/src/elements/ia-status-indicator/ia-status-indicator.test.ts b/src/elements/ia-status-indicator/ia-status-indicator.test.ts index c426a064..a977de1a 100644 --- a/src/elements/ia-status-indicator/ia-status-indicator.test.ts +++ b/src/elements/ia-status-indicator/ia-status-indicator.test.ts @@ -146,17 +146,16 @@ describe('IA Status Indicator', () => { >`, ); - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; - expect(icon).to.exist; - expect(getComputedStyle(icon).maskImage).to.not.equal('none'); + const glyph = el.shadowRoot?.querySelector('.ia-icon > svg'); + expect(glyph).to.exist; + expect(glyph?.getAttribute('viewBox')).to.match(/^[\d.\s-]+$/); }); }); test('maps every mediatype to a distinct glyph', async () => { - // The bundler inlines small SVGs as data URIs, so the asset filename is not - // in the resolved URL. Distinctness is what actually proves the mapping is - // wired up rather than every entry pointing at the same glyph. - const masksSeen = new Map(); + // Distinct markup is what proves the mapping is wired up rather than every + // entry pointing at the same glyph. + const glyphsSeen = new Map(); for (const mediatype of ALL_MEDIATYPES) { const el = await fixture( @@ -164,18 +163,20 @@ describe('IA Status Indicator', () => { .mediatype=${mediatype} >`, ); - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; - const mask = getComputedStyle(icon).maskImage; + const glyph = el.shadowRoot?.querySelector( + '.ia-icon > svg', + ) as SVGElement; + const markup = glyph.innerHTML; - const collision = masksSeen.get(mask); + const collision = glyphsSeen.get(markup); expect( collision, `${mediatype} resolved to the same glyph as ${collision}`, ).to.equal(undefined); - masksSeen.set(mask, mediatype); + glyphsSeen.set(markup, mediatype); } - expect(masksSeen.size).to.equal(ALL_MEDIATYPES.length); + expect(glyphsSeen.size).to.equal(ALL_MEDIATYPES.length); }); test('degrades to the default dots for an unmapped mediatype', async () => { @@ -190,25 +191,13 @@ describe('IA Status Indicator', () => { expect(el.shadowRoot?.querySelector('.loading-dots')).to.exist; }); - test('quotes the mask url so inlined data URIs stay valid', async () => { - const el = await fixture( - html``, - ); - - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; - // An unquoted url() containing apostrophes silently drops the declaration, - // which paints a solid block instead of the glyph. - expect(icon.getAttribute('style')).to.contain('url("'); - expect(getComputedStyle(icon).maskImage).to.not.equal('none'); - }); - test('hides the decorative center glyph from assistive tech', async () => { const el = await fixture( html``, ); - const icon = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; - expect(icon.getAttribute('aria-hidden')).to.equal('true'); + const glyph = el.shadowRoot?.querySelector('.ia-icon > svg') as SVGElement; + expect(glyph.getAttribute('aria-hidden')).to.equal('true'); }); test('keeps the svg title as the only accessible name with an icon present', async () => { @@ -367,6 +356,20 @@ describe('IA Status Indicator', () => { ).to.be.lessThan(1); }); + test('fills the glyph box with the svg, at half the indicator width', async () => { + const { el, glyph } = await sized('200px'); + const svg = glyph.querySelector('svg') as SVGElement; + + const box = glyph.getBoundingClientRect(); + const drawn = svg.getBoundingClientRect(); + expect(drawn.width).to.be.closeTo(box.width, 0.5); + expect(drawn.height).to.be.closeTo(box.height, 0.5); + expect(box.width).to.be.closeTo( + el.getBoundingClientRect().width / 2, + 0.5, + ); + }); + test('keeps the glyph inside the ring', async () => { const { el, glyph } = await sized('200px'); @@ -395,14 +398,15 @@ describe('IA Status Indicator', () => { >`, ); - const glyph = el.shadowRoot?.querySelector('.ia-icon') as HTMLElement; + const glyph = el.shadowRoot?.querySelector( + '.ia-icon > svg', + ) as SVGElement; const ring = el.shadowRoot?.querySelector('.loading-ring') as SVGElement; // The glyph is monochrome with the ring by design -- offshoot's // per-mediatype brand colours are deliberately not carried over. - expect(getComputedStyle(glyph).backgroundColor).to.equal( - 'rgb(12, 34, 56)', - ); + expect(getComputedStyle(glyph).color).to.equal('rgb(12, 34, 56)'); + expect(getComputedStyle(glyph).fill).to.equal('rgb(12, 34, 56)'); expect(getComputedStyle(ring).fill).to.equal('rgb(12, 34, 56)'); }); diff --git a/src/elements/ia-status-indicator/ia-status-indicator.ts b/src/elements/ia-status-indicator/ia-status-indicator.ts index d6102eb2..0f8417f1 100644 --- a/src/elements/ia-status-indicator/ia-status-indicator.ts +++ b/src/elements/ia-status-indicator/ia-status-indicator.ts @@ -15,18 +15,18 @@ import { customElement } from '@src/util/custom-element'; import { localized, msg } from '@lit/localize'; import themeStyles from '@src/themes/theme-styles'; -import { maskedIcon } from '@src/util/masked-icon'; - -import audioIcon from './icons/audio.svg'; -import collectionIcon from './icons/collection.svg'; -import etreeIcon from './icons/etree.svg'; -import imagesIcon from './icons/images.svg'; -import searchIcon from './icons/search.svg'; -import softwareIcon from './icons/software.svg'; -import textsIcon from './icons/texts.svg'; -import tvIcon from './icons/tv.svg'; -import videoIcon from './icons/video.svg'; -import webIcon from './icons/web.svg'; +import { iconBox, iconBoxStyles } from '@src/util/icon-box'; + +import audioIcon from '@src/icons/mediatype-audio'; +import collectionIcon from '@src/icons/mediatype-collection'; +import etreeIcon from '@src/icons/mediatype-etree'; +import imagesIcon from '@src/icons/mediatype-images'; +import searchIcon from '@src/icons/search'; +import softwareIcon from '@src/icons/mediatype-software'; +import textsIcon from '@src/icons/mediatype-texts'; +import tvIcon from '@src/icons/mediatype-tv'; +import videoIcon from '@src/icons/mediatype-video'; +import webIcon from '@src/icons/mediatype-web'; /** How long a mode change takes to fade. Drives both the CSS and the timer. */ const FADE_DURATION_MS = 250; @@ -53,7 +53,7 @@ export type MediaTypeIcon = | 'video' | 'web'; -const MEDIATYPE_ICONS: Record = { +const MEDIATYPE_ICONS: Record = { audio: audioIcon, collection: collectionIcon, etree: etreeIcon, @@ -213,19 +213,19 @@ export class IAStatusIndicator extends LitElement { } /** - * The asset URL for the current mediatype, if it maps to a glyph we ship. + * The glyph for the current mediatype, if it maps to one we ship. * * Unmapped values (e.g. archive.org's `account` or `data`) resolve to - * undefined and degrade to the default dots rather than a broken mask. + * undefined and degrade to the default dots. */ - private get mediatypeIconUrl(): string | undefined { + private get mediatypeGlyph(): SVGTemplateResult | undefined { if (!this.mediatype) return undefined; return MEDIATYPE_ICONS[this.mediatype]; } /** Whether the middle of the ring is occupied by a glyph rather than dots */ private get hasCenterIcon(): boolean { - return this.hasSlottedIcon || !!this.mediatypeIconUrl; + return this.hasSlottedIcon || !!this.mediatypeGlyph; } /** @@ -304,14 +304,14 @@ export class IAStatusIndicator extends LitElement { } /** - * The bundled mediatype glyph. `maskedIcon` supplies the mask geometry and - * the decorative `aria-hidden`; this component supplies the size and paint. + * The bundled mediatype glyph. The generated svg is decorative + * (`aria-hidden`); this component supplies the size and paint. */ private get mediatypeIconTemplate(): TemplateResult | typeof nothing { - const url = this.mediatypeIconUrl; - if (!url) return nothing; + const glyph = this.mediatypeGlyph; + if (!glyph) return nothing; - return maskedIcon(url); + return iconBox(glyph); } private handleIconSlotChange(e: Event): void { @@ -371,6 +371,7 @@ export class IAStatusIndicator extends LitElement { static get styles(): CSSResultGroup { return [ themeStyles, + iconBoxStyles, css` :host { --indicator-width--: var(--icon-width); @@ -430,7 +431,7 @@ export class IAStatusIndicator extends LitElement { display: block; } - .indicator svg { + .indicator > svg { display: block; width: 100%; height: auto; @@ -448,14 +449,13 @@ export class IAStatusIndicator extends LitElement { } /* - * Sized to fit within the icon ring's inner diameter. maskedIcon sets - * the mask itself; background-color is what paints the glyph, so it - * recolors with the ring. + * Sized to fit within the icon ring's inner diameter. The glyph paints + * with currentColor, so it recolors with the ring. */ .ia-icon { width: 50%; height: 50%; - background-color: var(--loading-icon-color--); + color: var(--loading-icon-color--); } /* Block, so the svg isn't padded out by the line box it would sit in */ diff --git a/src/elements/ia-status-indicator/icons.test.ts b/src/elements/ia-status-indicator/icons.test.ts index 15038d4f..a83443b2 100644 --- a/src/elements/ia-status-indicator/icons.test.ts +++ b/src/elements/ia-status-indicator/icons.test.ts @@ -1,35 +1,25 @@ import { describe, expect, test } from 'vitest'; -import audioIcon from './icons/audio.svg'; -import collectionIcon from './icons/collection.svg'; -import etreeIcon from './icons/etree.svg'; -import imagesIcon from './icons/images.svg'; -import searchIcon from './icons/search.svg'; -import softwareIcon from './icons/software.svg'; -import textsIcon from './icons/texts.svg'; -import tvIcon from './icons/tv.svg'; -import videoIcon from './icons/video.svg'; -import webIcon from './icons/web.svg'; - /** - * These assert the shape of the shipped .svg assets rather than any component - * behaviour, because that is where the bug lived: `mask-size: contain` centres - * a glyph's *viewBox*, not its ink, so a glyph whose ink sits against one edge - * of its box renders off-centre no matter what the component does. + * The mediatype glyph sources the status indicator draws, read as raw markup + * from the shared `icons/` folder. These assert the shape of the sources rather + * than any component behaviour, because that is where the bug lived: the + * indicator sizes a glyph by its *viewBox*, and an svg centres the viewBox in + * its box, not the ink. A glyph whose ink sits against one edge of its viewBox + * renders off-centre no matter what the component does. */ +const sources = import.meta.glob( + [ + '../../../icons/mediatype-*.svg', + // The `search` mediatype shares the glyph the dropdown search bar uses. + '../../../icons/search.svg', + ], + { query: '?raw', import: 'default', eager: true }, +); -const ICONS: [name: string, url: string][] = [ - ['audio', audioIcon], - ['collection', collectionIcon], - ['etree', etreeIcon], - ['images', imagesIcon], - ['search', searchIcon], - ['software', softwareIcon], - ['texts', textsIcon], - ['tv', tvIcon], - ['video', videoIcon], - ['web', webIcon], -]; +const ICONS: [name: string, markup: string][] = Object.entries(sources).map( + ([path, markup]) => [path.split('/').pop() as string, markup], +); type Margins = { top: number; @@ -40,12 +30,10 @@ type Margins = { /** * Loads a glyph as inline svg and measures where its ink sits inside its - * viewBox, as a fraction of the box. Inline is the only way to get `getBBox`; - * the component itself draws these as CSS masks. + * viewBox, as a fraction of the box. `getBBox` needs the markup in the + * document. */ -async function measure(url: string): Promise { - const markup = await (await fetch(url)).text(); - +function measure(name: string, markup: string): Margins { const host = document.createElement('div'); // Off-screen rather than hidden: `display: none` gives an empty bbox. host.style.cssText = 'position:absolute;left:-9999px;top:0'; @@ -54,10 +42,10 @@ async function measure(url: string): Promise { try { const svg = host.querySelector('svg'); - expect(svg, `${url} should contain an `).to.exist; + expect(svg, `${name} should contain an `).to.exist; const viewBox = svg?.getAttribute('viewBox'); - expect(viewBox, `${url} should declare a viewBox`).to.exist; + expect(viewBox, `${name} should declare a viewBox`).to.exist; const [vx, vy, width, height] = (viewBox as string) .split(/[\s,]+/) @@ -75,10 +63,25 @@ async function measure(url: string): Promise { } } -describe('mediatype glyph assets', () => { - ICONS.forEach(([name, url]) => { - test(`${name} centres its ink vertically within its viewBox`, async () => { - const { top, bottom } = await measure(url); +describe('mediatype glyph sources', () => { + test('covers every mediatype glyph', () => { + expect(ICONS.map(([name]) => name).sort()).to.deep.equal([ + 'mediatype-audio.svg', + 'mediatype-collection.svg', + 'mediatype-etree.svg', + 'mediatype-images.svg', + 'mediatype-software.svg', + 'mediatype-texts.svg', + 'mediatype-tv.svg', + 'mediatype-video.svg', + 'mediatype-web.svg', + 'search.svg', + ]); + }); + + ICONS.forEach(([name, markup]) => { + test(`${name} centres its ink vertically within its viewBox`, () => { + const { top, bottom } = measure(name, markup); // `collection`, `texts` and `web` each had ~29% empty above the ink and // 6.7% below, so they rendered visibly low in the ring. @@ -90,8 +93,8 @@ describe('mediatype glyph assets', () => { ).to.be.lessThan(0.02); }); - test(`${name} centres its ink horizontally within its viewBox`, async () => { - const { left, right } = await measure(url); + test(`${name} centres its ink horizontally within its viewBox`, () => { + const { left, right } = measure(name, markup); expect( Math.abs(left - right), diff --git a/src/elements/ia-status-indicator/icons/search.svg b/src/elements/ia-status-indicator/icons/search.svg deleted file mode 100644 index c6313e9e..00000000 --- a/src/elements/ia-status-indicator/icons/search.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/util/icon-box.ts b/src/util/icon-box.ts new file mode 100644 index 00000000..f2b70624 --- /dev/null +++ b/src/util/icon-box.ts @@ -0,0 +1,29 @@ +import { css, html, type SVGTemplateResult, type TemplateResult } from 'lit'; + +/** + * Wraps a generated icon (`@src/icons/`) in a box the host sizes and + * recolors. + * + * The icon paints with `currentColor`, so a host sets `color` on `.ia-icon` to + * recolor it and `width`/`height` to resize it. The box is a span rather than + * the bare svg so a host can tell its own glyphs from an inline svg a consumer + * slotted in. + */ +export const iconBox = (glyph: SVGTemplateResult): TemplateResult => + html`${glyph}`; + +/** + * Layout for `iconBox`. Add it to the host's styles: the svg fills the box, so + * the host's `width` and `height` on `.ia-icon` are the rendered size. + */ +export const iconBoxStyles = css` + .ia-icon { + display: inline-block; + } + + .ia-icon > svg { + display: block; + width: 100%; + height: 100%; + } +`; diff --git a/src/util/masked-icon.test.ts b/src/util/masked-icon.test.ts deleted file mode 100644 index e5e4d43a..00000000 --- a/src/util/masked-icon.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { fixture } from '@open-wc/testing-helpers'; -import { html } from 'lit'; -import { describe, expect, test } from 'vitest'; - -import { maskedIcon } from './masked-icon'; - -const ICON_URL = '/some/where/glyph.svg'; - -/** - * What a bundler actually hands us for a small SVG: an inlined `data:` URI - * whose markup is quoted with apostrophes. This is the case that breaks if the - * `url()` isn't quoted, so it's worth testing explicitly. - */ -const INLINED_DATA_URI = - "data:image/svg+xml,%3csvg viewBox='0 0 18 18' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='%23000' d='m9 0h9v9z'/%3e%3c/svg%3e"; - -describe('maskedIcon', () => { - test('renders the glyph as a masked span rather than inline svg', async () => { - const el = await fixture( - html`
${maskedIcon(ICON_URL)}
`, - ); - const icon = el.querySelector('.ia-icon') as HTMLElement; - - // The point of the mask approach: no svg markup ships in the JS bundle. - expect(el.querySelector('svg')).to.not.exist; - expect(icon.tagName).to.equal('SPAN'); - }); - - test('points the mask at the standalone svg asset', async () => { - const el = await fixture( - html`
${maskedIcon(ICON_URL)}
`, - ); - const icon = el.querySelector('.ia-icon') as HTMLElement; - const { maskImage, webkitMaskImage } = getComputedStyle(icon); - - // Both prefixes are emitted so Safari/iOS render the glyph too. - expect(maskImage).to.contain(ICON_URL); - expect(webkitMaskImage).to.contain(ICON_URL); - }); - - test('still applies the mask when the icon is an inlined data URI', async () => { - const el = await fixture( - html`
${maskedIcon(INLINED_DATA_URI)}
`, - ); - const icon = el.querySelector('.ia-icon') as HTMLElement; - const { maskImage } = getComputedStyle(icon); - - // An unquoted url() containing apostrophes is invalid CSS, so the whole - // declaration would be dropped and the glyph would paint as a solid block. - expect(maskImage).to.not.equal('none'); - expect(maskImage).to.contain('data:image/svg+xml'); - }); - - test('scales the glyph to the box without repeating it', async () => { - const el = await fixture( - html`
${maskedIcon(ICON_URL)}
`, - ); - const styles = getComputedStyle( - el.querySelector('.ia-icon') as HTMLElement, - ); - - expect(styles.maskRepeat).to.equal('no-repeat'); - expect(styles.maskSize).to.equal('contain'); - expect(styles.display).to.equal('inline-block'); - }); - - test('takes its paint from background-color so hosts can recolor it', async () => { - const el = await fixture( - html`
- - ${maskedIcon(ICON_URL)} -
`, - ); - const icon = el.querySelector('.ia-icon') as HTMLElement; - - expect(getComputedStyle(icon).backgroundColor).to.equal('rgb(10, 20, 30)'); - }); - - test('hides the decorative glyph from assistive tech', async () => { - const el = await fixture( - html`
${maskedIcon(ICON_URL)}
`, - ); - - expect( - (el.querySelector('.ia-icon') as HTMLElement).getAttribute('aria-hidden'), - ).to.equal('true'); - }); -}); diff --git a/src/util/masked-icon.ts b/src/util/masked-icon.ts deleted file mode 100644 index 80c6a5b3..00000000 --- a/src/util/masked-icon.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { html, type TemplateResult } from 'lit'; - -/** - * Renders an icon from a standalone `.svg` file as a CSS-masked element. - * - * The glyphs live as `.svg` assets rather than inline templates so their path - * data stays out of the JS bundle. They are drawn with `mask-image` (not - * ``) so host components can still recolor them: the mask supplies the - * shape and `background-color` supplies the paint, which keeps the - * `--item-navigator-icon-color` theming working. - * - * The mask geometry is set inline so an icon renders correctly wherever it is - * slotted — including inside a consumer's shadow DOM, for the icons this - * package exports publicly. Hosts only supply `background-color` and a size, - * both of which legitimately vary per host. Icons are decorative (always - * labelled by adjacent text or an `aria-label` on the control), so the span is - * `aria-hidden`. - * - * The `url()` is quoted deliberately: bundlers inline small SVGs as `data:` - * URIs whose markup contains apostrophes, and an *unquoted* CSS `url()` token - * cannot contain them — the whole `mask-image` declaration would be dropped - * and the glyph would paint as a solid block. - */ -export const maskedIcon = (url: string): TemplateResult => html` - -`;