diff --git a/web/apps/desk/src/components/crm/LogActivityModal.ts b/web/apps/desk/src/components/crm/LogActivityModal.ts index a6ac18a71..fa7ddaa87 100644 --- a/web/apps/desk/src/components/crm/LogActivityModal.ts +++ b/web/apps/desk/src/components/crm/LogActivityModal.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html } from 'lit'; +import '../ui/gable-select'; import { customElement, property, state } from 'lit/decorators.js'; import type { CreateActivityRequest, Contact, ActivityType } from '../../types/crm'; import { crmApi } from '../../services/crmApi'; @@ -89,17 +90,12 @@ export class GableLogActivityModal extends LitElement {
- + >
diff --git a/web/apps/desk/src/components/inventory/AddProductModal.ts b/web/apps/desk/src/components/inventory/AddProductModal.ts index aac2662ca..74119274f 100644 --- a/web/apps/desk/src/components/inventory/AddProductModal.ts +++ b/web/apps/desk/src/components/inventory/AddProductModal.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../ui/gable-select'; import { customElement, property, state } from 'lit/decorators.js'; import { UOM_OPTIONS } from '../../types/product'; import type { ProductCreate, UOM } from '../../types/product'; @@ -179,13 +180,11 @@ export class GableAddProductModal extends LitElement {
- + >
@@ -201,15 +200,11 @@ export class GableAddProductModal extends LitElement {
- + >
diff --git a/web/apps/desk/src/components/inventory/InventoryTransferModal.test.ts b/web/apps/desk/src/components/inventory/InventoryTransferModal.test.ts new file mode 100644 index 000000000..a95a47897 --- /dev/null +++ b/web/apps/desk/src/components/inventory/InventoryTransferModal.test.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * Transfer Stock asks for a source and a destination with the app's own + * selects. Confirming without a destination must say what is missing in + * words, not fail silently (the browser's own refusal went away with the + * native selects). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './InventoryTransferModal' +import type { GableInventoryTransferModal } from './InventoryTransferModal' +import type { Product } from '../../types/product' +import { mountAsync, update, q, text, jsonResponse } from '../../test/dom.ts' + +const PRODUCT = { id: 'p-1', sku: 'SKU-1', description: 'Stud' } as unknown as Product +const LEVELS = { items: [{ location_id: 'loc-a', location_name: 'A bin', quantity: '50000' }], next_cursor: null } +const LOCATIONS = { + items: [ + { id: 'loc-a', code: 'A', path: 'A', type: 'bin' }, + { id: 'loc-b', code: 'B', path: 'B', type: 'bin' }, + ], + next_cursor: null, +} + +let posts: string[] + +beforeEach(() => { + localStorage.setItem('token', 'test-token') + posts = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (init?.method === 'POST') { + posts.push(url) + return jsonResponse({}) + } + if (url.includes('/api/v1/inventory/levels')) return jsonResponse(LEVELS) + if (url.includes('/api/v1/locations')) return jsonResponse(LOCATIONS) + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${url}`) + })) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +async function open(): Promise { + return mountAsync('gable-inventory-transfer-modal', { isOpen: true, product: PRODUCT }) +} + +describe('gable-inventory-transfer-modal required picks', () => { + it('the source and destination selects read as required', async () => { + const el = await open() + const selects = Array.from(el.querySelectorAll('gable-select')) + expect(selects.length).toBe(2) + for (const s of selects) { + expect(s.querySelector('button')!.getAttribute('aria-required')).toBe('true') + } + }) + + it('a confirm with no destination says what is missing and sends nothing', async () => { + const el = await open() + const qty = q(el, 'input[type="number"]') + qty.value = '1' + qty.dispatchEvent(new Event('input', { bubbles: true })) + await update(el, {}) + q(el, 'form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + await update(el, {}) + const message = q(el, '[data-testid="transfer-form-error"]') + expect(message.getAttribute('role')).toBe('alert') + expect(text(message)).toContain('destination') + expect(posts).toEqual([]) + }) +}) diff --git a/web/apps/desk/src/components/inventory/InventoryTransferModal.ts b/web/apps/desk/src/components/inventory/InventoryTransferModal.ts index 147f661d6..3c8185083 100644 --- a/web/apps/desk/src/components/inventory/InventoryTransferModal.ts +++ b/web/apps/desk/src/components/inventory/InventoryTransferModal.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../ui/gable-select'; import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons'; import { X } from 'lucide'; @@ -24,6 +25,7 @@ export class GableInventoryTransferModal extends LitElement { @state() private _quantity = ''; @state() private _reason = ''; @state() private _isSubmitting = false; + @state() private _formError = ''; @state() private _inventory: Inventory[] = []; @state() private _locations: Location[] = []; @@ -33,6 +35,7 @@ export class GableInventoryTransferModal extends LitElement { this._quantity = ''; this._reason = ''; this._toLoc = ''; + this._formError = ''; } } @@ -60,7 +63,12 @@ export class GableInventoryTransferModal extends LitElement { private async _handleSubmit(e: Event) { e.preventDefault(); - if (!this.product || !this._fromLoc || !this._toLoc || !this._quantity) return; + if (!this.product) return; + if (!this._fromLoc || !this._toLoc || !this._quantity) { + this._formError = 'Choose a source and a destination, and enter a quantity.'; + return; + } + this._formError = ''; this._isSubmitting = true; try { @@ -111,36 +119,24 @@ export class GableInventoryTransferModal extends LitElement {
- + ?invalid=${this._formError !== '' && !this._fromLoc} + .value=${this._fromLoc} + .options=${[{ value: '', label: 'Select Source...' }, ...this._inventory.filter(i => i.location_id).map(i => ({ value: String(i.location_id), label: `${i.location_name} (${formatQuantity(i.quantity)})` }))]} + @change=${(e: Event) => { this._fromLoc = (e.target as HTMLSelectElement).value; this._formError = ''; }} + >
- + ?invalid=${this._formError !== '' && !this._toLoc} + .value=${this._toLoc} + .options=${[{ value: '', label: 'Select Dest...' }, ...this._locations.filter(l => l.id !== this._fromLoc).map(l => ({ value: l.id, label: l.path || l.code }))]} + @change=${(e: Event) => { this._toLoc = (e.target as HTMLSelectElement).value; this._formError = ''; }} + >
@@ -169,6 +165,9 @@ export class GableInventoryTransferModal extends LitElement { /> + ${this._formError ? html` + + ` : nothing}
+ +
+ + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-app-dialog': GableAppDialog; + } +} + +function runDialog(assign: (el: GableAppDialog) => void): Promise { + if (openDialog) openDialog.dismiss(); + return new Promise((resolve) => { + const el = document.createElement('gable-app-dialog') as GableAppDialog; + assign(el); + el.addEventListener('done', (e) => { + el.remove(); + resolve((e as CustomEvent).detail); + }); + openDialog = el; + document.body.appendChild(el); + }); +} + +/** Ask the person to confirm or cancel; resolves true on confirm, false otherwise. */ +export function confirmDialog(options: ConfirmDialogOptions): Promise { + return runDialog((el) => { + el.mode = 'confirm'; + el.heading = options.title; + el.body = options.body ?? ''; + el.confirmLabel = options.confirmLabel ?? 'Confirm'; + el.cancelLabel = options.cancelLabel ?? 'Cancel'; + el.tone = options.tone ?? 'default'; + }).then((detail) => detail.confirmed); +} + +/** Ask the person for a line of text; resolves the typed text or null on cancel. */ +export function promptDialog(options: PromptDialogOptions): Promise { + return runDialog((el) => { + el.mode = 'prompt'; + el.heading = options.title; + el.body = options.body ?? ''; + el.fieldLabel = options.label ?? ''; + el.required = options.required ?? false; + el.confirmLabel = options.confirmLabel ?? 'Confirm'; + el.cancelLabel = options.cancelLabel ?? 'Cancel'; + el.initialValue = options.initialValue ?? ''; + el.placeholder = options.placeholder ?? ''; + el.tone = options.tone ?? 'default'; + }).then((detail) => (detail.confirmed ? detail.value : null)); +} diff --git a/web/apps/desk/src/components/ui/gable-select.test.ts b/web/apps/desk/src/components/ui/gable-select.test.ts new file mode 100644 index 000000000..435f35d39 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-select.test.ts @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { describe, it, expect, vi } from 'vitest' +import { mount, update, q } from '../../test/dom.ts' +import type { GableSelect, GableSelectOption } from './gable-select.ts' +import './gable-select.ts' + +const FRUITS: GableSelectOption[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'pear', label: 'Pear' }, + { value: 'plum', label: 'Plum' }, +] + +const GROUPED: GableSelectOption[] = [ + { value: 'kel-a', label: 'KEL-A', group: 'Kelowna' }, + { value: 'kel-b', label: 'KEL-B', group: 'Kelowna' }, + { value: 'van-a', label: 'VAN-A', group: 'Vancouver', disabled: true }, +] + +async function mountSelect(props: Partial = {}): Promise { + return mount('gable-select', { options: FRUITS, ...props }) +} + +function buttonOf(el: GableSelect): HTMLButtonElement { + return q(el, 'button[aria-haspopup="listbox"]') +} + +async function open(el: GableSelect) { + buttonOf(el).click() + await update(el, {}) +} + +function keydown(el: GableSelect, key: string) { + buttonOf(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) +} + +function options(el: GableSelect): HTMLElement[] { + return Array.from(el.querySelectorAll('[role="option"]')) +} + +describe('gable-select', () => { + it('shows the chosen option label, or the placeholder when nothing is chosen', async () => { + const el = await mountSelect({ placeholder: 'Pick a bin' }) + expect(buttonOf(el).textContent).toContain('Pick a bin') + await update(el, { value: 'pear' }) + expect(buttonOf(el).textContent).toContain('Pear') + expect(buttonOf(el).textContent).not.toContain('Pick a bin') + }) + + it('exposes listbox semantics: aria-haspopup, aria-expanded, role listbox and option', async () => { + const el = await mountSelect({ label: 'Fruit' }) + expect(buttonOf(el).getAttribute('aria-expanded')).toBe('false') + await open(el) + expect(buttonOf(el).getAttribute('aria-expanded')).toBe('true') + const list = q(el, '[role="listbox"]') + expect(list.getAttribute('aria-label')).toBe('Fruit') + expect(options(el).length).toBe(3) + expect(options(el)[0].getAttribute('aria-selected')).toBe('false') + }) + + it('chooses on click and fires change with the value on the element', async () => { + const el = await mountSelect() + const onChange = vi.fn((e: Event) => (e.target as GableSelect).value) + el.addEventListener('change', onChange) + await open(el) + options(el)[1].click() + await update(el, {}) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange.mock.results[0].value).toBe('pear') + expect(el.value).toBe('pear') + expect(el.querySelector('[role="listbox"]')).toBeNull() + }) + + it('drives the list with Up, Down, Home and End; Enter chooses; Space chooses', async () => { + const el = await mountSelect() + await open(el) + keydown(el, 'ArrowDown') + await update(el, {}) + expect(buttonOf(el).getAttribute('aria-activedescendant')).toContain('-opt-1') + keydown(el, 'End') + await update(el, {}) + expect(buttonOf(el).getAttribute('aria-activedescendant')).toContain('-opt-2') + keydown(el, 'Home') + await update(el, {}) + keydown(el, 'Enter') + await update(el, {}) + expect(el.value).toBe('apple') + + await open(el) + keydown(el, 'ArrowDown') + await update(el, {}) + keydown(el, ' ') + await update(el, {}) + expect(el.value).toBe('pear') + }) + + it('is a combobox: the focused button carries controls, expanded and activedescendant', async () => { + const el = await mountSelect({ label: 'Fruit' }) + const button = buttonOf(el) + expect(button.getAttribute('role')).toBe('combobox') + expect(button.getAttribute('aria-expanded')).toBe('false') + await open(el) + expect(button.getAttribute('aria-expanded')).toBe('true') + const list = q(el, '[role="listbox"]') + expect(list.id).toBeTruthy() + expect(button.getAttribute('aria-controls')).toBe(list.id) + button.focus() + keydown(el, 'ArrowDown') + await update(el, {}) + // The element with the focus announces the active option. + expect(document.activeElement).toBe(button) + expect(button.getAttribute('aria-activedescendant')).toBe(options(el)[1].id) + }) + + it('takes its name from the label property: aria-label on the combobox', async () => { + const el = await mountSelect({ label: 'Fruit' }) + expect(buttonOf(el).getAttribute('aria-label')).toBe('Fruit') + expect(buttonOf(el).getAttribute('aria-labelledby')).toBeNull() + }) + + it('takes its name from a label[for] pointing at it, and the label click focuses it', async () => { + const fieldLabel = document.createElement('label') + fieldLabel.setAttribute('for', 'bin-picker') + fieldLabel.textContent = 'Bin' + document.body.appendChild(fieldLabel) + const el = document.createElement('gable-select') as GableSelect + el.id = 'bin-picker' + document.body.appendChild(el) + Object.assign(el, { options: FRUITS, value: 'pear' }) + await update(el, {}) + const button = buttonOf(el) + const labelledby = (button.getAttribute('aria-labelledby') ?? '').split(' ') + expect(labelledby.length).toBe(2) + const [nameEl, valueEl] = labelledby.map((id) => document.getElementById(id)) + expect(nameEl?.textContent).toBe('Bin') + expect(valueEl?.textContent?.trim()).toBe('Pear') + expect(button.getAttribute('aria-label')).toBeNull() + fieldLabel.click() + expect(document.activeElement).toBe(button) + el.remove() + fieldLabel.remove() + }) + + it('shows the list in the top layer, positioned from the button box', async () => { + const el = await mountSelect() + const button = buttonOf(el) + const rect = { left: 100, right: 260, top: 180, bottom: 200, width: 160, height: 20, x: 100, y: 180, toJSON: () => ({}) } as DOMRect + button.getBoundingClientRect = () => rect + await open(el) + const list = q(el, '[role="listbox"]') + // The popover attribute puts the list in the browser's top layer, so no + // modal's overflow or stacking context can clip it. + expect(list.hasAttribute('popover')).toBe(true) + expect(list.style.position).toBe('fixed') + expect(list.style.left).toBe('100px') + expect(list.style.top).toBe('200px') + expect(list.style.width).toBe('160px') + }) + + it("flips up when the room is above the button, its bottom edge at the button's top", async () => { + const el = await mountSelect() + const button = buttonOf(el) + const rect = { left: 10, right: 170, top: 680, bottom: 700, width: 160, height: 20, x: 10, y: 680, toJSON: () => ({}) } as DOMRect + button.getBoundingClientRect = () => rect + await open(el) + const list = q(el, '[role="listbox"]') + // The popover's own stylesheet holds top at 0 unless it is cleared: a + // flipped list with only `bottom` set lands at the top of the screen. + expect(list.style.top).toBe('auto') + expect(list.style.bottom).toBe(`${window.innerHeight - 680}px`) + expect(list.style.left).toBe('10px') + }) + + it('opens downward with the bottom edge cleared', async () => { + const el = await mountSelect() + const button = buttonOf(el) + const rect = { left: 100, right: 260, top: 180, bottom: 200, width: 160, height: 20, x: 100, y: 180, toJSON: () => ({}) } as DOMRect + button.getBoundingClientRect = () => rect + await open(el) + const list = q(el, '[role="listbox"]') + expect(list.style.bottom).toBe('auto') + expect(list.style.top).toBe('200px') + }) + + it('reads as required and as invalid when the page marks it', async () => { + const el = await mountSelect({ required: true } as Partial) + expect(buttonOf(el).getAttribute('aria-required')).toBe('true') + await update(el, { invalid: true } as Partial) + expect(buttonOf(el).getAttribute('aria-invalid')).toBe('true') + expect(buttonOf(el).className).toContain('border-safety-red') + }) + + it('Escape closes without choosing and returns focus to the button', async () => { + const el = await mountSelect() + const onChange = vi.fn() + el.addEventListener('change', onChange) + await open(el) + keydown(el, 'ArrowDown') + keydown(el, 'Escape') + await update(el, {}) + expect(el.querySelector('[role="listbox"]')).toBeNull() + expect(el.value).toBe('') + expect(onChange).not.toHaveBeenCalled() + expect(document.activeElement).toBe(buttonOf(el)) + }) + + it('Tab closes and moves on', async () => { + const el = await mountSelect() + await open(el) + keydown(el, 'Tab') + await update(el, {}) + expect(el.querySelector('[role="listbox"]')).toBeNull() + expect(el.value).toBe('') + }) + + it('typeahead on the first letters moves the active option', async () => { + const el = await mountSelect() + await open(el) + keydown(el, 'p') + await update(el, {}) + expect(buttonOf(el).getAttribute('aria-activedescendant')).toContain('-opt-1') + keydown(el, 'l') + await update(el, {}) + expect(buttonOf(el).getAttribute('aria-activedescendant')).toContain('-opt-2') + keydown(el, 'Enter') + await update(el, {}) + expect(el.value).toBe('plum') + }) + + it('never chooses a disabled option and skips it in keyboard movement', async () => { + const el = await mountSelect({ options: GROUPED }) + const onChange = vi.fn() + el.addEventListener('change', onChange) + await open(el) + const all = options(el) + expect(all[2].getAttribute('aria-disabled')).toBe('true') + all[2].click() + await update(el, {}) + expect(onChange).not.toHaveBeenCalled() + expect(el.querySelector('[role="listbox"]')).not.toBeNull() + keydown(el, 'ArrowDown') + keydown(el, 'ArrowDown') + await update(el, {}) + // From KEL-A Down lands on KEL-B, then skips the disabled VAN-A and wraps to KEL-A. + expect(buttonOf(el).getAttribute('aria-activedescendant')).toContain('-opt-0') + }) + + it('draws a heading row for each group', async () => { + const el = await mountSelect({ options: GROUPED }) + await open(el) + const headings = Array.from(el.querySelectorAll('[role="presentation"]')).map((h) => h.textContent?.trim()) + expect(headings).toEqual(['Kelowna', 'Vancouver']) + }) + + it('closes when the click lands outside', async () => { + const el = await mountSelect() + await open(el) + document.body.click() + await update(el, {}) + expect(el.querySelector('[role="listbox"]')).toBeNull() + }) + + it('a disabled select never opens', async () => { + const el = await mountSelect({ disabled: true }) + buttonOf(el).click() + await update(el, {}) + keydown(el, 'ArrowDown') + await update(el, {}) + expect(el.querySelector('[role="listbox"]')).toBeNull() + }) + + it('cancels the default action of clicks inside the list (label-wrapped selects)', async () => { + const el = await mountSelect() + const label = document.createElement('label') + el.replaceWith(label) + label.appendChild(el) + await open(el) + const option = options(el)[1] + const click = new MouseEvent('click', { bubbles: true, cancelable: true }) + option.dispatchEvent(click) + await update(el, {}) + expect(click.defaultPrevented).toBe(true) + expect(el.value).toBe('pear') + expect(el.querySelector('[role="listbox"]')).toBeNull() + }) + + it('a page reads the chosen value from its own state through the change event', async () => { + const el = await mountSelect() + let pageState = '' + el.addEventListener('change', (e) => { pageState = (e.target as GableSelect).value }) + await open(el) + keydown(el, 'ArrowDown') + keydown(el, 'ArrowDown') + keydown(el, 'Enter') + await update(el, {}) + expect(pageState).toBe('plum') + }) +}) diff --git a/web/apps/desk/src/components/ui/gable-select.ts b/web/apps/desk/src/components/ui/gable-select.ts new file mode 100644 index 000000000..59fceec06 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-select.ts @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The app's own select, in place of the browser's native one: a button that + * shows the chosen option's label (or a placeholder in words) and opens a + * listbox in the app's own popover. Up and Down, Home and End, typeahead on + * the first letters, Enter or Space to choose, Escape to close without + * choosing, Tab to close and move on. Disabled options are listed but cannot + * be chosen; options may carry a `group` that draws a heading row. The list + * opens upward when there is more room above the button than below. A choice + * fires a bubbling `change` event with the value on the element itself, so a + * handler written for a native select keeps its meaning. Clicks inside the + * list cancel their default action: in a page that wraps the select in a + * label element, the browser would otherwise answer an option click with a + * synthetic click at the button, reopening the list after every choice. + */ +import { LitElement, html, nothing, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { icon } from '../../lib/icons.ts'; +import { ChevronDown } from 'lucide'; + +export interface GableSelectOption { + value: string; + label: string; + disabled?: boolean; + group?: string; +} + +let selectSeq = 0; + +@customElement('gable-select') +export class GableSelect extends LitElement { + createRenderRoot() { return this; } + + @property({ type: Array }) options: GableSelectOption[] = []; + @property({ type: String }) value = ''; + @property({ type: String }) placeholder = ''; + @property({ type: String }) label = ''; + @property({ type: Boolean }) disabled = false; + @property({ type: Boolean }) required = false; + @property({ type: Boolean }) invalid = false; + + @state() private _open = false; + @state() private _activeIndex = -1; + @state() private _openUpward = false; + @state() private _listStyle = ''; + @state() private _fieldLabelId: string | null = null; + + private _ids = `gable-select-${++selectSeq}`; + private _typeahead = ''; + private _typeaheadAt = 0; + private _fieldLabel: HTMLLabelElement | null = null; + private _onFieldLabelClick = () => { + this.querySelector('button')?.focus(); + }; + private _onDocClick = (e: Event) => { + if (!this.contains(e.target as Node)) this._close(); + }; + + firstUpdated() { + this._bindFieldLabel(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._onDocClick, true); + this._fieldLabel?.removeEventListener('click', this._onFieldLabelClick); + } + + /** + * A custom element is not labelable, so a `label[for]` naming the host id + * does nothing on its own: take the name from it (with the chosen value, + * so both are read) and let its click focus the control, as a native + * select's label does. An explicit `label` property wins. + */ + private _bindFieldLabel() { + if (this.label || !this.id) return; + const safeId = this.id.replace(/["\\]/g, '\\$&'); + const label = document.querySelector(`label[for="${safeId}"]`); + if (!label) return; + if (!label.id) label.id = `${this._ids}-field-label`; + this._fieldLabel = label; + this._fieldLabelId = label.id; + label.addEventListener('click', this._onFieldLabelClick); + } + + /** The chosen option, or undefined when the value matches none. */ + get selectedOption(): GableSelectOption | undefined { + return this.options.find((o) => o.value === this.value); + } + + private _enabledIndexes(): number[] { + return this.options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0); + } + + private _openList() { + if (this.disabled || this._open) return; + // The list is a manual popover (the browser's top layer, so no modal's + // overflow or transform can clip it) positioned from the button's box, + // flipped up when the room is above. jsdom has no popover; the fixed + // position alone carries the tests there. + const rect = this.querySelector('button')?.getBoundingClientRect() ?? this.getBoundingClientRect(); + const below = window.innerHeight - rect.bottom; + const above = rect.top; + this._openUpward = below < 240 && above > below; + // The popover sheet holds top: 0, so clear the edge that is not used: + // top: auto when flipped up (the bottom edge carries it), bottom: auto + // when opening down. + this._listStyle = + `position:fixed;margin:0;left:${rect.left}px;width:${rect.width}px;` + + (this._openUpward ? `top:auto;bottom:${window.innerHeight - rect.top}px;` : `bottom:auto;top:${rect.bottom}px;`); + const selected = this.options.findIndex((o) => o.value === this.value && !o.disabled); + this._activeIndex = selected >= 0 ? selected : (this._enabledIndexes()[0] ?? -1); + this._open = true; + document.addEventListener('click', this._onDocClick, true); + this.updateComplete.then(() => { + const list = this.querySelector('[role="listbox"]'); + if (list && typeof list.showPopover === 'function') { + try { + list.showPopover(); + } catch { + // already shown; nothing to do + } + } + this._scrollActiveIntoView(); + }); + } + + private _close(restoreFocus = false) { + if (!this._open) return; + this._open = false; + const list = this.querySelector('[role="listbox"]'); + if (list && typeof list.hidePopover === 'function') { + try { + list.hidePopover(); + } catch { + // not shown; nothing to do + } + } + document.removeEventListener('click', this._onDocClick, true); + if (restoreFocus) { + // Synchronously: a change handler that opens a dialog must not + // have a deferred focus steal it back from the dialog. + this.querySelector('button')?.focus(); + } + } + + private _toggle() { + if (this._open) { + this._close(true); + } else { + this._openList(); + } + } + + private _moveActive(step: number) { + const enabled = this._enabledIndexes(); + if (enabled.length === 0) return; + const at = enabled.indexOf(this._activeIndex); + const next = at < 0 ? (step > 0 ? 0 : enabled.length - 1) : (at + step + enabled.length) % enabled.length; + this._activeIndex = enabled[next]; + this._scrollActiveIntoView(); + } + + private _moveActiveToEnd(last: boolean) { + const enabled = this._enabledIndexes(); + if (enabled.length === 0) return; + this._activeIndex = last ? enabled[enabled.length - 1] : enabled[0]; + this._scrollActiveIntoView(); + } + + private _typeaheadTo(char: string) { + const now = Date.now(); + this._typeahead = now - this._typeaheadAt > 600 ? char : this._typeahead + char; + this._typeaheadAt = now; + const needle = this._typeahead.toLowerCase(); + const enabled = this._enabledIndexes(); + const start = enabled.indexOf(this._activeIndex); + for (let step = 1; step <= enabled.length; step++) { + const index = enabled[(start + step + enabled.length) % enabled.length]; + if (this.options[index].label.toLowerCase().startsWith(needle)) { + if (this._open) { + this._activeIndex = index; + this._scrollActiveIntoView(); + } else { + this._choose(index); + } + return; + } + } + } + + private _choose(index: number) { + const option = this.options[index]; + if (!option || option.disabled) return; + const changed = option.value !== this.value; + this.value = option.value; + this._close(true); + if (changed) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + } + } + + private _scrollActiveIntoView() { + const el = this.querySelector(`#${this._ids}-opt-${this._activeIndex}`); + if (el && typeof el.scrollIntoView === 'function') { + try { + el.scrollIntoView({ block: 'nearest' }); + } catch { + // scrollIntoView is best effort; jsdom does not implement layout. + } + } + } + + private _onButtonKeydown(e: KeyboardEvent) { + if (this.disabled) return; + if (!this._open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this._openList(); + } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + this._typeaheadTo(e.key); + } + return; + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this._moveActive(1); + break; + case 'ArrowUp': + e.preventDefault(); + this._moveActive(-1); + break; + case 'Home': + e.preventDefault(); + this._moveActiveToEnd(false); + break; + case 'End': + e.preventDefault(); + this._moveActiveToEnd(true); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (this._activeIndex >= 0) this._choose(this._activeIndex); + break; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this._close(true); + break; + case 'Tab': + this._close(); + break; + default: + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + this._typeaheadTo(e.key); + } + } + } + + private _renderOption(option: GableSelectOption, index: number) { + const active = index === this._activeIndex; + const selected = option.value === this.value; + return html` +
this._choose(index)} + @mouseenter=${() => { if (!option.disabled) this._activeIndex = index; }} + >${option.label}
+ `; + } + + private _renderList() { + const rows: TemplateResult[] = []; + let lastGroup: string | undefined; + this.options.forEach((option, index) => { + if (option.group !== undefined && option.group !== lastGroup) { + rows.push(html` + + `); + } + lastGroup = option.group; + rows.push(this._renderOption(option, index)); + }); + return html` +
e.preventDefault()} + popover="manual" + style=${this._listStyle} + class="z-50 max-h-60 overflow-y-auto rounded-md bg-surface-2 border border-white/10 shadow-xl py-1 ${this._openUpward ? 'mb-1' : 'mt-1'}" + > + ${rows.length > 0 + ? rows + : html``} +
+ `; + } + + render() { + const chosen = this.selectedOption; + const activeDesc = this._open && this._activeIndex >= 0 ? `${this._ids}-opt-${this._activeIndex}` : nothing; + return html` +
+ + ${this._open ? this._renderList() : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-select': GableSelect; + } +} diff --git a/web/apps/desk/src/pages/QuoteBuilder.ts b/web/apps/desk/src/pages/QuoteBuilder.ts index 6b97b8733..752087dca 100644 --- a/web/apps/desk/src/pages/QuoteBuilder.ts +++ b/web/apps/desk/src/pages/QuoteBuilder.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../components/ui/gable-select.ts'; import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../lib/icons.ts'; import { router } from '../lib/router.ts'; @@ -584,13 +585,12 @@ export class GableQuoteBuilder extends LitElement { ${line.uom === '' || err ? html` - +
+ ({ value: code, label: code }))]} + @change=${(e: Event) => this.handleUomInput(idx, (e.target as HTMLSelectElement).value)}> +
${line.uom === '' ? html`
Unit of measure required
` : nothing} ` : html`${line.uom}`} @@ -740,12 +740,9 @@ export class GableQuoteBuilder extends LitElement {
- + ({ value: v.id, label: `${v.name} — ${v.vehicle_type.replace(/_/g, ' ')} (${v.license_plate})` }))]} + @change=${(e: Event) => { this.selectedVehicleId = (e.target as HTMLSelectElement).value || undefined; }}> ${this.vehicles.length === 0 ? html`

No vehicles in fleet. Add vehicles in Fleet Management.

` : nothing}
diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts index 733a73222..6cfe7660a 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts @@ -25,7 +25,7 @@ import './AccountsPayable' import type { LitElement } from 'lit' import type { VendorInvoice, APPayment, APAgingSummary } from '../../types/ap' import type { Vendor } from '../../types/vendor' -import { mountAsync, text, jsonResponse, clickByText, update, flush, q } from '../../test/dom' +import { mountAsync, text, jsonResponse, clickByText, update, flush, q, chooseOption } from '../../test/dom' const VENDOR_ID = '11111111-1111-1111-1111-111111111111' const BRANCH_ID = '22222222-2222-4222-8222-222222222222' @@ -276,10 +276,8 @@ describe('accounts payable: writing the wire scales', () => { ) { await clickByText(el, 'button', 'Log Vendor Bill') - const selects = Array.from(el.querySelectorAll('select')) - const vendorSelect = selects[0] - vendorSelect.value = VENDOR_ID - vendorSelect.dispatchEvent(new Event('change', { bubbles: true })) + const selects = Array.from(el.querySelectorAll('gable-select')) + await chooseOption(selects[0], VENDOR_ID) const setText = (input: HTMLInputElement, value: string) => { input.value = value @@ -358,9 +356,8 @@ describe('accounts payable: writing the wire scales', () => { const el = await mountAsync('gable-accounts-payable') await clickByText(el, 'button', 'Record Payment') - const vendorSelect = el.querySelector('select')! - vendorSelect.value = VENDOR_ID - vendorSelect.dispatchEvent(new Event('change', { bubbles: true })) + const vendorSelect = el.querySelector('gable-select')! + await chooseOption(vendorSelect, VENDOR_ID) await update(el, {}) const checkbox = el.querySelector('input[type="checkbox"]') diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.ts index 89008413e..2b00907fa 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../../components/ui/gable-select.ts'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { formatCents, formatPrice4 } from '../../lib/utils'; @@ -649,14 +650,11 @@ export class AccountsPayable extends LitElement {
- + >
@@ -760,17 +758,14 @@ export class AccountsPayable extends LitElement {
- + >
diff --git a/web/apps/desk/src/pages/portal/PortalTeam.ts b/web/apps/desk/src/pages/portal/PortalTeam.ts index aeb91b09c..d886c5d1b 100644 --- a/web/apps/desk/src/pages/portal/PortalTeam.ts +++ b/web/apps/desk/src/pages/portal/PortalTeam.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../../components/ui/gable-select.ts'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { router } from '../../lib/router.ts'; @@ -129,15 +130,11 @@ export class PortalTeam extends LitElement {
- + > ${icon(Shield, 14, 'absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none')}
diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts index 5912e1294..c37b8aae9 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts @@ -31,6 +31,7 @@ import './POSTerminal'; import type { POSTerminal } from './POSTerminal'; import { branchContext } from '../../services/BranchContext'; import { authCustody } from '@gable/auth'; +import { openAppDialog, answerAppDialog, chooseOption } from '../../test/dom'; import type { CounterRegister } from '../../types/pos'; const reg = (id: string, branch: string, name = 'Front till'): CounterRegister => ({ @@ -699,28 +700,28 @@ describe('the Panels board', () => { }); it('asks first, and declining removes nothing', async () => { - const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); const el = await mount(sale()); q(el, 'pos-clear-sale').click(); await flush(el); - expect(confirm).toHaveBeenCalledTimes(1); + expect(openAppDialog()).toBeTruthy(); + await answerAppDialog(false); + await flush(el); expect(svc.removeItem).not.toHaveBeenCalled(); - confirm.mockRestore(); el.remove(); }); it('removes every line through the line removal once confirmed', async () => { - const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); svc.removeItem .mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] })) .mockResolvedValueOnce(sale({ lines: [] })); const el = await mount(sale()); q(el, 'pos-clear-sale').click(); await flush(el); + await answerAppDialog(true); + await flush(el); await flush(el); expect(svc.removeItem.mock.calls).toEqual([['s1', 'a'], ['s1', 'b']]); expect(q(el, 'pos-lines')).toBeNull(); - confirm.mockRestore(); el.remove(); }); }); @@ -1120,9 +1121,10 @@ describe('the counter opens on its own yard\'s register', () => { document.body.appendChild(el); await settle(el); expect(svc.startSale).toHaveBeenLastCalledWith('REG-01'); - vi.spyOn(window, 'confirm').mockReturnValue(true); branchContext.setCurrent('b-wk'); await settle(el); + await answerAppDialog(true); + await settle(el); expect(svc.currentTill).toHaveBeenLastCalledWith('REG-WK'); expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK'); expect(q(el, 'pos-register').textContent).toContain('REG-WK'); @@ -1152,11 +1154,10 @@ describe('the counter opens on its own yard\'s register', () => { await settle(el); // the first by name: Front till expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK'); - const picker = q(el, 'pos-register-picker') as HTMLSelectElement; + const picker = q(el, 'pos-register-picker') as HTMLElement & { value: string }; expect(picker).toBeTruthy(); - vi.spyOn(window, 'confirm').mockReturnValue(true); - picker.value = 'REG-WK2'; - picker.dispatchEvent(new Event('change')); + await chooseOption(picker, 'REG-WK2'); + await answerAppDialog(true); await settle(el); expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK2'); el.remove(); @@ -1234,19 +1235,20 @@ describe('the register and the sale when the cashier moves', () => { const el = document.createElement('gable-pos-terminal') as POSTerminal; document.body.appendChild(el); await settle(el); - const ask = vi.spyOn(window, 'confirm').mockReturnValue(false); branchContext.setCurrent('b-wk'); await settle(el); - expect(ask).toHaveBeenCalledTimes(1); + expect(openAppDialog()).toBeTruthy(); + await answerAppDialog(false); + await settle(el); expect(branchContext.currentId).toBe('b-lk'); expect(q(el, 'pos-register').textContent).toContain('REG-LK'); expect(svc.startSale).toHaveBeenCalledTimes(1); - ask.mockReturnValue(true); branchContext.setCurrent('b-wk'); await settle(el); - expect(ask).toHaveBeenCalledTimes(2); + expect(openAppDialog()).toBeTruthy(); + await answerAppDialog(true); + await settle(el); expect(q(el, 'pos-register').textContent).toContain('REG-WK'); - ask.mockRestore(); el.remove(); }); @@ -1255,12 +1257,10 @@ describe('the register and the sale when the cashier moves', () => { const el = document.createElement('gable-pos-terminal') as POSTerminal; document.body.appendChild(el); await settle(el); - const ask = vi.spyOn(window, 'confirm').mockReturnValue(false); branchContext.setCurrent('b-wk'); await settle(el); - expect(ask).not.toHaveBeenCalled(); + expect(openAppDialog()).toBeNull(); expect(q(el, 'pos-register').textContent).toContain('REG-WK'); - ask.mockRestore(); el.remove(); }); @@ -1775,15 +1775,15 @@ describe('closing the till', () => { it('asks first over a sale with lines, and declining keeps the till and the sale', async () => { const el = await mount(sale()); - const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); const close = [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Close Till') as HTMLElement; close.click(); await el.updateComplete; - expect(confirm).toHaveBeenCalledTimes(1); + expect(openAppDialog()).toBeTruthy(); + await answerAppDialog(false); + await el.updateComplete; expect(svc.tillReport).not.toHaveBeenCalled(); expect(q(el, 'pos-till-state').textContent).toContain('Till open'); expect((el as unknown as { _sale: Sale })._sale.id).toBe('s1'); - confirm.mockRestore(); el.remove(); }); @@ -1899,7 +1899,6 @@ describe('the resets after Clear tenders and Clear sale', () => { }); it('puts a tender that was pressed but not added back to none on Clear sale', async () => { - const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); svc.removeItem .mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] })) .mockResolvedValueOnce(sale({ lines: [], subtotal_cents: 0, tax_cents: 0, total_cents: 0 })); @@ -1910,6 +1909,8 @@ describe('the resets after Clear tenders and Clear sale', () => { expect(pressed(el)).toEqual(['cash']); q(el, 'pos-clear-sale').click(); await settle(); + await answerAppDialog(true); + await settle(); await el.updateComplete; (el as unknown as { _sale: Sale })._sale = sale({ lines: [line('n', 5000)] as unknown as Sale['lines'], subtotal_cents: 5000, tax_cents: 0, total_cents: 5000, @@ -1920,12 +1921,10 @@ describe('the resets after Clear tenders and Clear sale', () => { q(el, 'pos-pay').click(); await settle(); expect(svc.completeSale).not.toHaveBeenCalled(); - confirm.mockRestore(); el.remove(); }); it('drops the added tenders on Clear sale, so the next lines are not paid on them', async () => { - const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); svc.removeItem .mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] })) .mockResolvedValueOnce(sale({ lines: [], subtotal_cents: 0, tax_cents: 0, total_cents: 0 })); @@ -1937,6 +1936,8 @@ describe('the resets after Clear tenders and Clear sale', () => { await el.updateComplete; q(el, 'pos-clear-sale').click(); await settle(); + await answerAppDialog(true); + await settle(); await el.updateComplete; // a new line lands on the emptied sale (el as unknown as { _sale: Sale })._sale = sale({ @@ -1948,7 +1949,6 @@ describe('the resets after Clear tenders and Clear sale', () => { q(el, 'pos-pay').click(); await settle(); expect(svc.completeSale).not.toHaveBeenCalled(); - confirm.mockRestore(); el.remove(); }); }); diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index f6bac5deb..8ab85a583 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors import { LitElement, html, nothing } from 'lit'; +import '../../components/ui/gable-select'; import { customElement, state } from 'lit/decorators.js'; import { posService, dollarsToCents, type TenderIn } from '../../services/POSService'; import type { Sale, QuickSearchResult, SaleLine, TillSession, TillReport, TenderMethod, RefundMethod, CounterCustomer, CounterRegister } from '../../types/pos'; @@ -10,6 +11,7 @@ import { chargeRefusal, standingOf, money, PO_REFUSAL } from './standing'; import { formatPrice4 } from '../../lib/utils'; import { isCountedUnit, normalizeQuantity } from '../../lib/money'; import { payPlan, panelProgress, type PanelState, type PanelKey } from './panels'; +import { confirmDialog, promptDialog } from '../../components/ui/app-dialog'; import { authCustody } from '@gable/auth'; import './catalog/gable-pos-catalog'; @@ -106,17 +108,27 @@ export class POSTerminal extends LitElement { */ private _onYardChanged = () => { if (this._revertingYard) return; - if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) { - this._revertingYard = true; - try { - branchContext.setCurrent(this._yardId); - } finally { - this._revertingYard = false; - } + if (!this._saleHasLines) { + this._yardId = branchContext.currentId; + void this._loadRegisters(); return; } - this._yardId = branchContext.currentId; - void this._loadRegisters(); + const askedYard = branchContext.currentId; + void confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }).then((leave) => { + if (!leave) { + // A newer yard change asked its own question in the meantime; leave it be. + if (branchContext.currentId !== askedYard) return; + this._revertingYard = true; + try { + branchContext.setCurrent(this._yardId); + } finally { + this._revertingYard = false; + } + return; + } + this._yardId = askedYard; + void this._loadRegisters(); + }); }; private get _saleHasLines(): boolean { @@ -207,10 +219,10 @@ export class POSTerminal extends LitElement { } /** False when the cashier declined to leave the open sale. */ - private _chooseRegister(id: string): boolean { + private async _chooseRegister(id: string): Promise { const next = this._registers.find((r) => r.id === id); if (!next || next.id === this._registerId) return true; - if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) return false; + if (this._saleHasLines && !(await confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }))) return false; localStorage.setItem(this._storageKey(), next.id); this._register = next; void this._openRegister(); @@ -257,7 +269,7 @@ export class POSTerminal extends LitElement { private async _beginCloseTill() { if (!this._till) return; // The same question a register change asks: declining keeps till and sale. - if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) return; + if (this._saleHasLines && !(await confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }))) return; try { this._tillBusy = true; this._tillReport = await posService.tillReport(this._till.id); @@ -457,14 +469,11 @@ export class POSTerminal extends LitElement { ${c.jobs.length > 0 ? html`
-
- - + >
` : nothing}
@@ -662,7 +671,11 @@ export class POSTerminal extends LitElement { if (!sale || sale.status !== 'open') return; const ids = sale.lines.filter((l) => l.parent_line_id === null).map((l) => l.id); if (ids.length === 0) return; - if (!window.confirm(`Remove all ${ids.length} line${ids.length === 1 ? '' : 's'} from sale ${sale.number}?`)) return; + if (!await confirmDialog({ + title: `Remove all ${ids.length} line${ids.length === 1 ? '' : 's'} from sale ${sale.number}?`, + confirmLabel: 'Remove', + tone: 'danger', + })) return; for (const id of ids) { if (this._sale?.id !== sale.id) return; await this._removeItem(id); @@ -818,7 +831,7 @@ export class POSTerminal extends LitElement { this._error = 'Only a completed sale is voided, while its drawer is open'; return; } - const reason = window.prompt('Void reason?'); + const reason = await promptDialog({ title: `Void sale ${sale.number}?`, label: 'Reason', required: true, confirmLabel: 'Void sale', tone: 'danger' }); if (!reason) return; try { const voided = await posService.voidSale(sale.id, sale.revision, reason); @@ -983,13 +996,16 @@ export class POSTerminal extends LitElement { `)}
- +
+ { this._returnMethod = (e.target as HTMLSelectElement).value as RefundMethod; }} + > +
{ this._returnReason = (e.target as HTMLInputElement).value; }} @@ -1030,14 +1046,16 @@ export class POSTerminal extends LitElement { ${this._registerId} ${this._registers.length > 1 ? html` - ` : nothing} + >` : nothing}

` : nothing}