Stores can be removed, and added from an address you type
Two gaps that were the same gap: the store list could only ever grow, and it could only grow from what the registry happened to offer. **Removing** uninstalls what the store installed, then deletes the store itself, in that order. The order is the whole of it: `state.json` is the only record of which payloads, icons and menu entries belong to a store, so deleting the home first would strip the one thing that knows — leaving files nothing could ever identify, least of all a later install of the same store into the same folder. The confirmation says how many titles will go, because that is the part nobody would otherwise expect. The token goes too; a credential for a store that is not here is a secret kept for nothing. The window names a *store*, never a path: the home is resolved against what a disk scan actually found before anything is deleted, and `removeHome` refuses anything else. That is the only guard between a bad argument and `rm -rf`, so it has a test. **Adding** moved to a + beside Refresh — both are actions on the whole store rather than on one of them, and the full-width button under the list read as a third store — and the picker now takes a catalog address as well as a listed one. A bare host is enough and the name comes from the address; nothing else about installing changes, which is why the typed path hands the same record to the same method instead of growing a second one. The picker also has a Cancel now: opening it with a store installed used to replace the grid with no way back. `make storetest` is new, and it earned itself immediately. Removal is the only code here that deletes a directory tree, which the smoke test cannot cover — it runs against the real machine and would have to delete a real store to prove anything. Two bugs on the first run: - `http://` was accepted and became a store called *http*. The trailing slashes were stripped before the scheme was checked, turning `http://` into `http:` and then into `https://http:`, whose hostname parses as "http". The URL is rebuilt from the parsed form now, which also settles the trailing slash in one place. - `STORE_ROOT` only *prepended* to the search path, so a "sandboxed" run still listed the real stores — despite the README saying "instead of the real one". Harmless while a sandbox could only add; not harmless now that it can delete. It replaces the search path. The self-test needed two changes, both of which are it working: the store row is a wrapper now, so clicking `.store-row` did nothing at all, and the footer icon check counted exactly two named controls when there are three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ export class RendererApplication {
|
||||
})
|
||||
this.sideMenu = new SideMenuView({
|
||||
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||
onRemoveStore: (home: string, name: string): void => { void this.stores.removeStore(home, name) },
|
||||
onAddStore: (): void => { void this.stores.offerStores() },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
|
||||
@@ -41,11 +41,30 @@ export class StoreController {
|
||||
const messages = state.messages
|
||||
const result = await this.bridge.listRegistryStores()
|
||||
|
||||
// A typed address works whatever the registry said — that is the point of it — so
|
||||
// it is attached to every one of the three outcomes below, including the two that
|
||||
// used to be dead ends.
|
||||
const custom = {
|
||||
label: messages.customTitle,
|
||||
hint: messages.customHint,
|
||||
actionLabel: messages.customAction,
|
||||
perform: (catalogUrl: string): void => { void this.installCatalog(catalogUrl) }
|
||||
}
|
||||
// Only where there is something to go back to. On a machine with no store the grid
|
||||
// behind this screen is empty, and "Cancel" would lead nowhere. Spread rather than
|
||||
// an `undefined` value: the strict optional-property rule treats "absent" and
|
||||
// "present but undefined" as different things, and here they genuinely are.
|
||||
const cancel = state.currentStore === null
|
||||
? {}
|
||||
: { cancel: { label: messages.cancel, perform: (): void => { this.store.applyGate(null) } } }
|
||||
|
||||
if (result.error !== null) {
|
||||
this.store.applyGate({
|
||||
title: messages.registryFailed,
|
||||
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } },
|
||||
custom,
|
||||
...cancel
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -53,7 +72,9 @@ export class StoreController {
|
||||
if (result.stores.length === 0) {
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`,
|
||||
custom,
|
||||
...cancel
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -67,10 +88,65 @@ export class StoreController {
|
||||
if (chosen !== null) void this.installStore(chosen)
|
||||
}
|
||||
},
|
||||
choices: result.stores
|
||||
choices: result.stores,
|
||||
custom,
|
||||
...cancel
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a store off this machine, with what that costs stated first.
|
||||
*
|
||||
* The confirmation names the number of installed titles, because that is the part
|
||||
* somebody would not otherwise expect: removing a store uninstalls everything it
|
||||
* put here. Leaving them behind would be worse — the store's state file is the only
|
||||
* record of which files belong to it, so orphans would be permanent.
|
||||
*/
|
||||
public async removeStore (home: string, name: string): Promise<void> {
|
||||
const state = this.store.readState()
|
||||
const messages = state.messages
|
||||
const installed = state.currentStore?.home === home
|
||||
? state.games.filter((game): boolean => game.installed).length
|
||||
: null
|
||||
|
||||
const question = installed === null
|
||||
? messages.removeStoreConfirm.replace('%{store}', name)
|
||||
: messages.removeStoreConfirmGames
|
||||
.replace('%{store}', name)
|
||||
.replace('%{count}', String(installed))
|
||||
if (!window.confirm(question)) return
|
||||
|
||||
try {
|
||||
await this.bridge.removeStore(home)
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
const remaining = this.store.readState().currentStore
|
||||
this.store.applyGate(null)
|
||||
if (remaining === null) {
|
||||
await this.offerStores()
|
||||
return
|
||||
}
|
||||
await this.catalog.refresh()
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async installCatalog (catalogUrl: string): Promise<void> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
try {
|
||||
await this.bridge.installCatalog(catalogUrl)
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
await this.catalog.syncGames([])
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
this.store.applyProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
|
||||
+28
-1
@@ -33,7 +33,6 @@
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-stores"></h2>
|
||||
<div class="store-list" id="store-list"></div>
|
||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
@@ -66,6 +65,17 @@
|
||||
<path d="M13.5 2v3h-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<!--
|
||||
Adding a store sits beside Refresh rather than under the store list: both
|
||||
are actions on the whole store rather than on one of them, and a full-width
|
||||
button under the list read as a third store.
|
||||
-->
|
||||
<button id="add-store" class="icon-btn">
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M8 3v10" />
|
||||
<path d="M3 8h10" />
|
||||
</svg>
|
||||
</button>
|
||||
<!--
|
||||
The select is still a real `<select>`, stretched over the icon and invisible:
|
||||
the native dropdown knows how to open upward in a cramped window and is
|
||||
@@ -111,8 +121,25 @@
|
||||
<select id="gate-select" class="select"></select>
|
||||
</label>
|
||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||
<button id="gate-cancel" class="btn btn-ghost" hidden></button>
|
||||
<a id="gate-link" class="link" href="#" hidden></a>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
A catalog the registry does not list. Its own row under the picker rather
|
||||
than a third option inside it: choosing from a list and typing an address are
|
||||
different gestures, and a <select> entry that turns into a text field is a
|
||||
control that lies about what it is.
|
||||
-->
|
||||
<div id="gate-custom" class="gate-custom" hidden>
|
||||
<span id="gate-custom-label" class="gate-custom-label"></span>
|
||||
<div class="gate-custom-row">
|
||||
<input id="gate-custom-url" class="input" type="url" spellcheck="false"
|
||||
autocapitalize="off" autocorrect="off">
|
||||
<button id="gate-custom-action" class="btn btn-secondary"></button>
|
||||
</div>
|
||||
<p id="gate-custom-hint" class="gate-custom-hint"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="grid" class="grid" hidden></main>
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface GateLink {
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/** A catalog typed rather than chosen. Absent where typing one makes no sense. */
|
||||
export interface GateCustom {
|
||||
readonly label: string
|
||||
readonly hint: string
|
||||
readonly actionLabel: string
|
||||
readonly perform: (catalogUrl: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gate is showing.
|
||||
*
|
||||
@@ -24,4 +32,12 @@ export interface GatePresentation {
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
readonly custom?: GateCustom
|
||||
/**
|
||||
* A way back, shown only when there is somewhere to go back *to*.
|
||||
*
|
||||
* Without it the picker was a trap: opening it with a store already installed left
|
||||
* no way to reach the grid again short of installing something.
|
||||
*/
|
||||
readonly cancel?: GateAction
|
||||
}
|
||||
|
||||
+90
-9
@@ -113,26 +113,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
|
||||
|
||||
/* Store switcher: one row per store on this machine, the open one marked. */
|
||||
/*
|
||||
* Store switcher: one row per store on this machine, the open one marked.
|
||||
*
|
||||
* The row is a wrapper holding two buttons — open, and remove — rather than being a
|
||||
* button itself: a button inside a button is invalid markup, and the inner click would
|
||||
* reach the outer handler anyway. So the framing lives on the wrapper and the padding
|
||||
* on the part that is actually clicked, or the click target would be smaller than the
|
||||
* thing it looks like.
|
||||
*/
|
||||
.store-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.store-row {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.store-row:hover { background: var(--panel-2); }
|
||||
.store-row.is-active {
|
||||
background: var(--panel-2);
|
||||
border-color: #2f5a49;
|
||||
}
|
||||
.store-row-open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.store-row:hover:not(:disabled) { background: var(--panel-2); }
|
||||
.store-row.is-active {
|
||||
background: var(--panel-2);
|
||||
border-color: #2f5a49;
|
||||
}
|
||||
.store-row .store-row-name { font-weight: 600; }
|
||||
.store-row .store-row-id {
|
||||
font-size: 11px;
|
||||
@@ -141,7 +159,30 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.store-row:disabled { opacity: .55; cursor: default; }
|
||||
/*
|
||||
* Remove appears on hover or focus only. It is destructive, and it sits in a list
|
||||
* whose ordinary use is switching stores — it should not be under the pointer of
|
||||
* every routine click. :focus-within is what keeps it reachable by keyboard.
|
||||
*/
|
||||
.store-row-remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, color 120ms;
|
||||
}
|
||||
.store-row:hover .store-row-remove,
|
||||
.store-row:focus-within .store-row-remove { opacity: 1; }
|
||||
.store-row-remove:hover { color: var(--warn); }
|
||||
.store-row-open:disabled { opacity: .55; cursor: default; }
|
||||
.store-row-remove:disabled { opacity: 0; cursor: default; }
|
||||
.icon-trash { width: 14px; height: 14px; }
|
||||
|
||||
/* Categories: what the catalog is filtered down to. */
|
||||
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
|
||||
@@ -259,6 +300,13 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
|
||||
.btn-ghost { background: transparent; color: var(--ink-dim); }
|
||||
/*
|
||||
* The second-choice button: present and pressable, but not the one the eye lands on.
|
||||
* Used where a card offers signing in rather than installing, and beside the typed
|
||||
* catalog address — both are real actions that are not the primary one.
|
||||
*/
|
||||
.btn-secondary { background: transparent; border-color: var(--line); color: var(--ink); }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--panel-2); border-color: #3a4757; }
|
||||
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
||||
.select {
|
||||
font: inherit;
|
||||
@@ -333,6 +381,39 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
the machine here. */
|
||||
.card.is-purchasable { opacity: 1; }
|
||||
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
|
||||
|
||||
/* --- the gate's typed-address row ---------------------------------------- */
|
||||
.gate-custom {
|
||||
margin: 22px auto 0;
|
||||
max-width: 460px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
.gate-custom-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gate-custom-row { display: flex; gap: 8px; }
|
||||
.gate-custom-row .input { flex: 1; min-width: 0; }
|
||||
.gate-custom-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.input {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.gate { overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
import type { GateLink, GatePresentation } from '../state/GatePresentation'
|
||||
import type { GateCustom, GateLink, GatePresentation } from '../state/GatePresentation'
|
||||
|
||||
/**
|
||||
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||
@@ -15,7 +15,13 @@ export class GateView {
|
||||
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
|
||||
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
||||
private readonly custom = requireElement('gate-custom', HTMLElement)
|
||||
private readonly customLabel = requireElement('gate-custom-label', HTMLElement)
|
||||
private readonly customUrl = requireElement('gate-custom-url', HTMLInputElement)
|
||||
private readonly customAction = requireElement('gate-custom-action', HTMLButtonElement)
|
||||
private readonly customHint = requireElement('gate-custom-hint', HTMLElement)
|
||||
|
||||
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
||||
|
||||
@@ -25,6 +31,8 @@ export class GateView {
|
||||
setText(this.body, presentation.body)
|
||||
this.renderChoices(presentation.choices ?? [], messages)
|
||||
this.renderAction(presentation)
|
||||
this.renderCancel(presentation)
|
||||
this.renderCustom(presentation.custom ?? null)
|
||||
this.renderLink(presentation.link ?? null)
|
||||
}
|
||||
|
||||
@@ -57,6 +65,40 @@ export class GateView {
|
||||
}
|
||||
}
|
||||
|
||||
private renderCancel (presentation: GatePresentation): void {
|
||||
const cancel = presentation.cancel
|
||||
setHidden(this.cancel, cancel === undefined)
|
||||
if (cancel === undefined) return
|
||||
setText(this.cancel, cancel.label)
|
||||
this.cancel.onclick = (): void => { cancel.perform(null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The typed-address row.
|
||||
*
|
||||
* Enter submits as well as the button, because a single text field with a button
|
||||
* beside it is a form, and a form that ignores Enter is a small daily annoyance.
|
||||
*/
|
||||
private renderCustom (custom: GateCustom | null): void {
|
||||
setHidden(this.custom, custom === null)
|
||||
if (custom === null) return
|
||||
setText(this.customLabel, custom.label)
|
||||
setText(this.customHint, custom.hint)
|
||||
setText(this.customAction, custom.actionLabel)
|
||||
|
||||
const submit = (): void => {
|
||||
const value = this.customUrl.value.trim()
|
||||
if (value.length === 0) return
|
||||
custom.perform(value)
|
||||
}
|
||||
this.customAction.onclick = submit
|
||||
this.customUrl.onkeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
private renderLink (link: GateLink | null): void {
|
||||
setHidden(this.link, link === null)
|
||||
if (link === null) return
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onRemoveStore: (home: string, name: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||
@@ -57,7 +58,9 @@ export class SideMenuView {
|
||||
public render (state: AppState): void {
|
||||
setText(this.storesHead, state.messages.stores)
|
||||
setText(this.categoriesHead, state.messages.categories)
|
||||
setText(this.addStore, state.messages.addStore)
|
||||
// Add is an icon now, beside Refresh: naming it is all the view does, and writing
|
||||
// text into it would replace the glyph.
|
||||
describeControl(this.addStore, state.messages.addStoreHint)
|
||||
// Refresh and the language picker are icons: naming them is all the view does, and
|
||||
// writing text into them would replace the glyph.
|
||||
describeControl(this.refresh, state.messages.refresh)
|
||||
@@ -84,15 +87,30 @@ export class SideMenuView {
|
||||
.map((store: InstalledStoreDto): string => store.id))
|
||||
|
||||
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
|
||||
const row = createElement('button', 'store-row')
|
||||
// A row is a button *and* carries one; nesting them would be invalid markup and
|
||||
// the inner click would reach the outer handler anyway. So the row is a wrapper
|
||||
// with two buttons in it: switch, and remove.
|
||||
const row = createElement('div', 'store-row')
|
||||
if (store.home === activeHome) row.classList.add('is-active')
|
||||
row.appendChild(createElement('span', 'store-row-name', store.name))
|
||||
row.appendChild(createElement('span', 'store-row-id',
|
||||
|
||||
const open = createElement('button', 'store-row-open')
|
||||
open.appendChild(createElement('span', 'store-row-name', store.name))
|
||||
open.appendChild(createElement('span', 'store-row-id',
|
||||
ambiguousIds.has(store.id) ? store.home : store.id))
|
||||
row.title = store.home
|
||||
row.addEventListener('click', (): void => {
|
||||
open.title = store.home
|
||||
open.addEventListener('click', (): void => {
|
||||
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
|
||||
})
|
||||
row.appendChild(open)
|
||||
|
||||
const remove = createElement('button', 'store-row-remove')
|
||||
remove.appendChild(createTrashIcon())
|
||||
describeControl(remove, state.messages.removeStore)
|
||||
remove.addEventListener('click', (): void => {
|
||||
this.callbacks.onRemoveStore(store.home, store.name)
|
||||
})
|
||||
row.appendChild(remove)
|
||||
|
||||
return row
|
||||
}))
|
||||
}
|
||||
@@ -168,6 +186,26 @@ export class SideMenuView {
|
||||
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads;
|
||||
* an icon button needs both, and they are the same sentence.
|
||||
*/
|
||||
/**
|
||||
* The remove glyph: a lid and a bin.
|
||||
*
|
||||
* Drawn rather than a character, for the same reason the other two icons are — a font
|
||||
* that lacks the symbol shows a box, and this button has no text to fall back on.
|
||||
*/
|
||||
function createTrashIcon (): SVGSVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'icon icon-trash')
|
||||
svg.setAttribute('viewBox', '0 0 16 16')
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
svg.setAttribute('focusable', 'false')
|
||||
for (const d of ['M3 4.5h10', 'M6.5 4.5V3h3v1.5', 'M4.5 4.5 5 13h6l.5-8.5', 'M6.8 7v3.5', 'M9.2 7v3.5']) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.appendChild(path)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
|
||||
function describeControl (element: HTMLElement, name: string): void {
|
||||
element.title = name
|
||||
element.setAttribute('aria-label', name)
|
||||
|
||||
Reference in New Issue
Block a user