All files / src/ui icons.ts

100% Statements 114/114
87.8% Branches 36/41
100% Functions 10/10
100% Lines 114/114

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302                              2x   2x                                   2x                   2x 47887x 47887x 47887x   47887x 47887x 47887x 47887x 47887x 47887x 47887x 219x 219x 47887x 47668x 47668x 47668x 47668x 47668x 47668x 47887x 162111x 162111x 162111x 162111x 47887x 47887x                                                                     2x 1978x 1978x 1978x 1978x 1978x 1978x 1978x         1978x 1978x 1978x                                 2027x 2027x 2027x 2027x 2027x     2027x 2027x 2027x 2027x 2027x                                     17x 17x 17x 17x 16x 16x               2x 66x 66x 66x 66x 66x 66x 66x 66x 66x                                 2x 39x 39x                                   2x 49x 8x 8x 49x 49x 49x 49x     2x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x 28824x                           2x 458x 458x                               2x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x 429x  
/**
 * The icon set: inline SVG, built in code, no glyphs and no font.
 *
 * Three reasons it is drawn rather than typed. A character like `⇤` or `ⓘ` is
 * rendered by whatever font the OS picks, so it is a different shape, weight
 * and baseline on every machine — and on some it is a box. An icon font is a
 * network request the CSP on a published artifact would block. And a glyph
 * cannot be given a stroke width that matches the rest of the interface.
 *
 * The shapes come from the upstream packages through `npm run icons`, so they
 * are Lucide's and Simple Icons' own geometry rather than an imitation of it:
 * one weight, round caps and joins, `currentColor`. The brand marks are filled
 * instead of stroked, because that is what a logo is.
 */
 
import { GENERATED_ICONS } from './icons.generated'
 
const SVG_NS = 'http://www.w3.org/2000/svg'
 
interface IconSpec {
  shapes: Array<[tag: string, attrs: Record<string, string>]>
  /** Filled rather than stroked, for the marks that are logos. */
  filled?: boolean
}
 
/**
 * The set, copied verbatim from upstream by `npm run icons`.
 *
 * Nothing is drawn here by hand any more. A sketched brand mark is wrong in a
 * way anyone can see, and a sketched interface icon drifts from the set it is
 * imitating — different corner radii, different optical weight, a chevron at a
 * slightly different angle. Lucide (ISC) supplies the interface icons and
 * Simple Icons (CC0) the GitHub mark; both are devDependencies of the
 * generator, not of the app.
 */
const ICONS: Record<string, IconSpec> = GENERATED_ICONS
 
export type IconName = keyof typeof GENERATED_ICONS
 
/**
 * Build one icon.
 *
 * `aria-hidden` because every caller is a control that already carries its own
 * label — an icon announced twice is worse than one not announced at all.
 */
export function icon(name: IconName, options: { size?: number; class?: string } = {}): SVGElement {
  const spec: IconSpec = ICONS[name]
  const size = options.size ?? 16
  const svg = document.createElementNS(SVG_NS, 'svg')
  // Both sources draw on a 24-unit grid, which is why one viewBox serves all.
  svg.setAttribute('viewBox', '0 0 24 24')
  svg.setAttribute('width', String(size))
  svg.setAttribute('height', String(size))
  svg.setAttribute('aria-hidden', 'true')
  svg.setAttribute('focusable', 'false')
  svg.setAttribute('class', options.class === undefined ? 'icon' : `icon ${options.class}`)
  if (spec.filled === true) {
    svg.setAttribute('fill', 'currentColor')
    svg.setAttribute('stroke', 'none')
  } else {
    svg.setAttribute('fill', 'none')
    svg.setAttribute('stroke', 'currentColor')
    svg.setAttribute('stroke-width', '2')
    svg.setAttribute('stroke-linecap', 'round')
    svg.setAttribute('stroke-linejoin', 'round')
  }
  for (const [tag, attrs] of spec.shapes) {
    const node = document.createElementNS(SVG_NS, tag)
    for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value)
    svg.appendChild(node)
  }
  return svg
}
 
/**
 * A card's heading: one mark, then the word.
 *
 * Every card in the three panels carries one, and that is the point of it — a
 * column of five headings set in the same small uppercase grey is read
 * top-to-bottom every time you look for one of them, while five distinct marks
 * are recognised at a glance and then used as the index. It is the same reason
 * the tab strip has a favicon on it.
 *
 * 13 px, against a 12 px cap height: the mark is a label for the word, not a
 * second thing beside it, and anything larger starts competing.
 *
 * **The heading is also the card's fold**, and that is why this is the only
 * place it had to be written. The Genome minimap had the behaviour first, as a
 * `<details>` with its heading for a summary, and its own note said there was no
 * per-card collapse idiom in this codebase to follow. There is now, and it is
 * this: every card in every panel gets a chevron on the right-hand end of its
 * heading, and a press anywhere along that line folds the card away to the line
 * itself. Generalising it here rather than card by card is what keeps twenty
 * cards from each growing their own wrapper, their own marker and their own
 * opinion about where the control sits — the gesture is one gesture because it
 * is one implementation.
 *
 * A heading used outside a card keeps the type and loses the control; see
 * {@link foldCard}.
 *
 * @param extra Classes the heading needs *as well* — `controls-title` for the
 * cards in the Aesthetics column, which are folded into the panel header in the
 * inline layout and hide their titles there. That layout takes the heading away
 * altogether, so a card sitting in a panel header does not fold: the stylesheet
 * leaves it alone rather than letting a fold made in the column hide controls
 * with nothing left on screen to bring them back.
 */
export function cardTitle(text: string, name: IconName, extra?: string): HTMLElement {
  const node = document.createElement('h3')
  node.className = extra === undefined ? 'card-head' : `card-head ${extra}`
  node.appendChild(icon(name, { size: 13 }))
  const label = document.createElement('span')
  label.textContent = text
  node.appendChild(label)
  node.appendChild(foldChevron(text))
  // On the heading and not on the chevron, which is what makes the whole strip
  // the control: a press on the button bubbles to here, so one listener serves
  // the pointer, the Enter key and the space bar without the two of them ever
  // being able to disagree about which way the card is going.
  node.addEventListener('click', () => foldCard(node))
  return node
}
 
/**
 * The mark on the right-hand end of a card's heading, and the state it carries.
 *
 * One chevron that **turns** rather than two that are swapped. A swap is two
 * shapes to keep in step and a hard cut between them; a rotation is one node,
 * one transition, and a direction of travel the eye follows into the fold —
 * which is the whole of why the card reads as opening rather than as being
 * replaced. `chevron-down` is the open state, so the resting card points at what
 * is under it and a folded one points along the row it has become.
 *
 * A real `<button>`, though the heading beside it is what is usually pressed:
 * the fold has to be reachable by keyboard and has to say which way it will go,
 * and `aria-expanded` on a focusable control is how that is said. It carries no
 * listener of its own — {@link cardTitle} explains why.
 */
function foldChevron(text: string): HTMLButtonElement {
  const node = document.createElement('button')
  node.type = 'button'
  node.className = 'card-fold'
  node.setAttribute('aria-expanded', 'true')
  // The card's own name in the label, because a column of five of these read
  // aloud as "collapse" five times over says nothing about which one is which.
  node.setAttribute('aria-label', `Collapse ${text}`)
  node.title = 'Collapse'
  node.appendChild(icon('chevron-down', { size: 14 }))
  return node
}
 
/**
 * Fold or unfold the card a heading belongs to.
 *
 * The class goes on the **card**, not on the heading, because what folds is
 * everything the card holds *except* the heading — a rule the stylesheet can
 * state once for every card in the application rather than each card hiding its
 * own parts. Which is also why this walks up exactly one level and checks what
 * it found: a heading used somewhere that is not a card has nothing to fold, and
 * quietly doing nothing is better than hiding half of a box that was never built
 * to lose it.
 *
 * The choice is **not remembered**, following the Genome minimap this was
 * generalised from: the house mechanisms for a per-reader preference are modules
 * over `localStorage` (`ui/textScale.ts`, `ui/mapSize.ts`), and a new storage
 * scheme for a disclosure triangle would be a scheme to migrate, key and forget
 * about. A card that survives a repaint keeps its fold because the element does.
 */
function foldCard(head: HTMLElement): void {
  const card = head.parentElement
  if (card === null) return
  if (!card.classList.contains('card') && !card.classList.contains('controls-card')) return
  setFolded(card, !card.classList.contains('is-folded'))
}
 
/**
 * Put a card in one fold state, chevron and all — what {@link foldCard} does on
 * a press, for a caller that knows which way it wants the card rather than
 * wanting it turned over. A card with no `cardTitle` heading keeps its class
 * and has no chevron to tell.
 */
export function setFolded(card: HTMLElement, folded: boolean): HTMLElement {
  card.classList.toggle('is-folded', folded)
  const chevron = card.querySelector(':scope > .card-head > .card-fold')
  if (!(chevron instanceof HTMLElement)) return card
  chevron.setAttribute('aria-expanded', String(!folded))
  const name = (chevron.getAttribute('aria-label') ?? '').replace(/^(Collapse|Expand) /, '')
  chevron.setAttribute('aria-label', `${folded ? 'Expand' : 'Collapse'} ${name}`)
  chevron.title = folded ? 'Expand' : 'Collapse'
  return card
}
 
/**
 * Build a card already folded, with its chevron saying so.
 *
 * `cardTitle` builds its chevron open, which is right for a card a reader came
 * to read; this is for the ones a reader opens when they want them — the
 * Metadata annotations and Bookmarks cards at the foot of the Annotations
 * column, the stored JSON on the Info panel. The class goes on the card and the
 * chevron is told what {@link foldCard} tells it on every later press, so the
 * two never start out disagreeing: a chevron pointing down over a card with
 * nothing under it looks broken until it is pressed twice.
 *
 * Not remembered, for `foldCard`'s reason: a card starts folded each time it is
 * built — once per file — and keeps whatever the reader made of it while the
 * element lives.
 */
export function startFolded(card: HTMLElement): HTMLElement {
  return setFolded(card, true)
}
 
/**
 * Give a heading built by hand the fold every `cardTitle` has.
 *
 * Two headings in the app cannot come from {@link cardTitle}: the File card's
 * pair in compare mode wears a `◥` / `◣` glyph where the mark goes and a close
 * button where the chevron goes, and neither is an icon from the set. They were
 * therefore the only cards in any column that did not fold — three cards in the
 * General column, two of which folded and one of which did not, which is the
 * kind of disagreement §26.6 exists to settle. This appends the chevron and
 * wires the same gesture, so a heading that is not built here still behaves
 * like one.
 *
 * `stopPropagation` on whatever already sits at the end of the heading: a close
 * button inside a strip that folds on click would fold the card on its way to
 * closing the file.
 */
export function foldable(head: HTMLElement, name: string): HTMLElement {
  for (const control of head.querySelectorAll('button')) {
    control.addEventListener('click', (event) => event.stopPropagation())
  }
  head.appendChild(foldChevron(name))
  head.addEventListener('click', () => foldCard(head))
  return head
}
 
/** A button whose whole content is one icon. The title is the accessible name. */
export function iconButton(
  name: IconName,
  title: string,
  onClick: () => void,
  options: { class?: string; size?: number; pressed?: boolean } = {},
): HTMLButtonElement {
  const node = document.createElement('button')
  node.type = 'button'
  node.className = options.class ?? 'btn btn-icon'
  node.title = title
  node.setAttribute('aria-label', title)
  if (options.pressed !== undefined) node.setAttribute('aria-pressed', String(options.pressed))
  node.appendChild(icon(name, options.size === undefined ? {} : { size: options.size }))
  node.addEventListener('click', onClick)
  return node
}
 
/**
 * Say which way a toggle is flipped, in the one way the whole app says it.
 *
 * `aria-pressed`, and the stylesheet lights the button from that alone — so a
 * toggle declares its state once and is drawn, named to a screen reader, and
 * testable from the same word. Five classes used to do this and each drew its
 * own look: `.is-on` lit three of them, `.is-off` *dimmed* two others, and a
 * dimmed live control is indistinguishable from a disabled one — a reader who
 * had learned that grey means "you cannot" was being told, by the same grey,
 * "this is off, press it". Lit means on here, plain means off, and nothing
 * live is ever dimmed.
 */
export function setPressed(node: HTMLElement, pressed: boolean): void {
  node.setAttribute('aria-pressed', String(pressed))
}
 
/**
 * A large button with a mark on it: the one thing a section is *for*.
 *
 * The shape the Connect dialog's sign-ins established — a filled accent block,
 * the provider's mark on the left, a verb set in it — generalised, because the
 * landing page now offers the same gestures and a door drawn two different
 * ways in two places is two things to learn (`ui/connectFronts.ts`).
 *
 * `reason` is the disabled case the sign-in panes make, and it is the only
 * place a disabled control is right here: where the sentence beside the button
 * names the one thing a reader can do about it, hiding the button would hide
 * what the sentence is about. Everywhere else §8.5 applies and the caller
 * leaves the button out.
 */
export function markButton(
  label: string,
  mark: IconName,
  onClick: () => void,
  options: { class?: string; title?: string; disabled?: boolean; reason?: string } = {},
): HTMLButtonElement {
  const node = document.createElement('button')
  node.type = 'button'
  node.className = options.class ?? 'btn btn-mark'
  const title = options.disabled === true ? options.reason ?? options.title : options.title
  if (title !== undefined) node.title = title
  node.appendChild(icon(mark, { size: 18 }))
  node.appendChild(document.createTextNode(label))
  node.disabled = options.disabled === true
  node.addEventListener('click', onClick)
  return node
}