From d5e7a70265e63bc97ffbb7497fac8aebea344076 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:22:26 -0700 Subject: [PATCH 01/30] test(desk): guard against browser dialogs and native selects in web apps Add a Vitest guard that reads every .ts source under web/apps (tests excluded) and fails on any browser dialog call (confirm, alert, prompt, with or without window., a file's own method excepted) or any native select element, naming each file and line. Fails on today's code: 17 browser dialog calls in 8 desk pages and 64 native selects in 35 files. Signed-off-by: colton --- .../src/test/browser-chrome.guard.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 web/apps/desk/src/test/browser-chrome.guard.test.ts diff --git a/web/apps/desk/src/test/browser-chrome.guard.test.ts b/web/apps/desk/src/test/browser-chrome.guard.test.ts new file mode 100644 index 000000000..26e4b4ac6 --- /dev/null +++ b/web/apps/desk/src/test/browser-chrome.guard.test.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * Guard: the web apps never use the browser's own chrome. No `confirm(`, + * `alert(` or `prompt(` call (with or without `window.`) and no native + * select element belongs in application source; the app's own dialog and + * select components take their place. A method a file declares itself (for + * example `private async confirm(...)`) is not the browser's and is allowed. + * Every violation is named by file and line. + */ +import { describe, it, expect } from 'vitest' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const APPS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..') + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.vite']) + +function isTestFile(rel: string): boolean { + return /\.(test|spec)\.ts$/.test(rel) || rel.includes(`${path.sep}src${path.sep}test${path.sep}`) +} + +function collectSources(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) collectSources(full, out) + } else if (entry.name.endsWith('.ts') && !isTestFile(full)) { + out.push(full) + } + } + return out +} + +// A browser dialog call: not preceded by `.` or an identifier character (so a +// method call or declaration of the file's own does not match), optionally on +// `window.`. +const DIALOG_CALL = /(? { + const where = `${rel}:${index + 1}` + const call = DIALOG_CALL.exec(line) + if (call && !METHOD_DECL.test(line.slice(0, call.index))) { + found.push(`${where}: browser dialog call \`${call[0]}\``) + } + if (NATIVE_SELECT.test(line)) { + found.push(`${where}: native <` + `select element`) + } + }) + } + return found +} + +describe('browser chrome guard', () => { + it('no browser dialog call (confirm, alert, prompt) under web/apps', () => { + const found = violations().filter((v) => v.includes('dialog call')) + expect(found, found.join('\n')).toEqual([]) + }) + + it('no native select element under web/apps', () => { + const found = violations().filter((v) => !v.includes('dialog call')) + expect(found, found.join('\n')).toEqual([]) + }) +}) From a0d0fc6d68b81abc63f1c1135b72c4c124984b3e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:28:24 -0700 Subject: [PATCH 02/30] feat(desk): the app's own dialog with a promise API Add gable-app-dialog and the confirmDialog/promptDialog helpers under components/ui, so pages ask with an awaited promise instead of the browser's blocking dialogs. The dialog is an alertdialog (or dialog for a prompt) with aria-modal, labelled by its title and described by its body; focus lands on the safe choice, Tab stays inside, Escape cancels, Enter confirms away from Cancel, focus returns to the opener, and a required prompt keeps Confirm off until text is typed. A danger tone draws the confirm button in safety red. One dialog is open at a time. Signed-off-by: colton --- .../desk/src/components/ui/app-dialog.test.ts | 203 ++++++++++++++ web/apps/desk/src/components/ui/app-dialog.ts | 254 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 web/apps/desk/src/components/ui/app-dialog.test.ts create mode 100644 web/apps/desk/src/components/ui/app-dialog.ts diff --git a/web/apps/desk/src/components/ui/app-dialog.test.ts b/web/apps/desk/src/components/ui/app-dialog.test.ts new file mode 100644 index 000000000..29c6cda4f --- /dev/null +++ b/web/apps/desk/src/components/ui/app-dialog.test.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { describe, it, expect } from 'vitest' +import { confirmDialog, promptDialog, type GableAppDialog } from './app-dialog.ts' +import { q, flush } from '../../test/dom.ts' + +function dialogEl(): GableAppDialog { + return q(document.body, 'gable-app-dialog') +} + +async function settleDialog(): Promise { + const el = dialogEl() + await el.updateComplete + await flush() + return el +} + +function pressKey(key: string, shiftKey = false) { + const el = dialogEl() + const panel = q(el, '[aria-modal="true"]') + panel.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true })) +} + +async function typeIntoPrompt(value: string) { + const el = dialogEl() + const input = q(el, 'input') + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + await el.updateComplete +} + +function clickConfirm() { + q(dialogEl(), '[data-dialog-confirm]').click() +} + +function clickCancel() { + q(dialogEl(), '[data-dialog-cancel]').click() +} + +describe('gable-app-dialog confirmDialog', () => { + it('resolves true when Confirm is clicked', async () => { + const result = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + clickConfirm() + await expect(result).resolves.toBe(true) + expect(document.body.querySelector('gable-app-dialog')).toBeNull() + }) + + it('resolves false when Cancel is clicked', async () => { + const result = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + clickCancel() + await expect(result).resolves.toBe(false) + }) + + it('resolves false on Escape', async () => { + const result = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + pressKey('Escape') + await expect(result).resolves.toBe(false) + }) + + it('resolves false when the veil is clicked', async () => { + const result = confirmDialog({ title: 'Confirm this order?' }) + const el = await settleDialog() + q(el, '.backdrop-blur-sm').click() + await expect(result).resolves.toBe(false) + }) + + it('is an alertdialog labelled by its title and described by its body', async () => { + const result = confirmDialog({ title: 'Remove this line?', body: 'It cannot be undone.' }) + const el = await settleDialog() + const panel = q(el, '[role="alertdialog"]') + expect(panel.getAttribute('aria-modal')).toBe('true') + const titleId = panel.getAttribute('aria-labelledby')! + expect(el.querySelector(`#${titleId}`)!.textContent).toBe('Remove this line?') + const bodyId = panel.getAttribute('aria-describedby')! + expect(el.querySelector(`#${bodyId}`)!.textContent).toBe('It cannot be undone.') + clickCancel() + await result + }) + + it('uses the dialog role for a prompt', async () => { + const result = promptDialog({ title: 'Cancel this order', label: 'Reason' }) + const el = await settleDialog() + expect(el.querySelector('[role="dialog"]')).not.toBeNull() + expect(el.querySelector('[role="alertdialog"]')).toBeNull() + pressKey('Escape') + await result + }) + + it('focuses Cancel for a danger tone and Confirm otherwise', async () => { + const danger = confirmDialog({ title: 'Void this sale?', tone: 'danger' }) + await settleDialog() + expect(document.activeElement).toBe(q(dialogEl(), '[data-dialog-cancel]')) + clickCancel() + await danger + + const plain = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + expect(document.activeElement).toBe(q(dialogEl(), '[data-dialog-confirm]')) + clickConfirm() + await plain + }) + + it('keeps Tab inside the dialog', async () => { + const result = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + const confirmButton = q(dialogEl(), '[data-dialog-confirm]') + const cancelButton = q(dialogEl(), '[data-dialog-cancel]') + confirmButton.focus() + pressKey('Tab') + expect(document.activeElement).toBe(cancelButton) + pressKey('Tab', true) + expect(document.activeElement).toBe(confirmButton) + clickCancel() + await result + }) + + it('Enter confirms when the focus is not on Cancel, and cancels there', async () => { + const confirmed = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + q(dialogEl(), '[data-dialog-confirm]').focus() + pressKey('Enter') + await expect(confirmed).resolves.toBe(true) + + const cancelled = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + q(dialogEl(), '[data-dialog-cancel]').focus() + pressKey('Enter') + await expect(cancelled).resolves.toBe(false) + }) + + it('returns the focus to the control that opened it', async () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + const result = confirmDialog({ title: 'Confirm this order?' }) + await settleDialog() + expect(document.activeElement).not.toBe(opener) + clickCancel() + await result + expect(document.activeElement).toBe(opener) + opener.remove() + }) + + it('settles a dialog still open when a new one is asked for', async () => { + const first = confirmDialog({ title: 'First?' }) + await settleDialog() + const second = confirmDialog({ title: 'Second?' }) + await expect(first).resolves.toBe(false) + await settleDialog() + expect(document.body.querySelectorAll('gable-app-dialog').length).toBe(1) + clickConfirm() + await expect(second).resolves.toBe(true) + }) +}) + +describe('gable-app-dialog promptDialog', () => { + it('resolves the typed text on Confirm', async () => { + const result = promptDialog({ title: 'Cancel this order', label: 'Reason' }) + await settleDialog() + await typeIntoPrompt('Customer changed their mind') + clickConfirm() + await expect(result).resolves.toBe('Customer changed their mind') + }) + + it('resolves null on Cancel and on Escape', async () => { + const cancelled = promptDialog({ title: 'Cancel this order', label: 'Reason' }) + await settleDialog() + clickCancel() + await expect(cancelled).resolves.toBeNull() + + const escaped = promptDialog({ title: 'Cancel this order', label: 'Reason' }) + await settleDialog() + pressKey('Escape') + await expect(escaped).resolves.toBeNull() + }) + + it('focuses the field', async () => { + const result = promptDialog({ title: 'Cancel this order', label: 'Reason' }) + await settleDialog() + expect(document.activeElement).toBe(q(dialogEl(), 'input')) + pressKey('Escape') + await result + }) + + it('keeps Confirm off until text is typed when required', async () => { + const result = promptDialog({ title: 'Void this sale', label: 'Reason', required: true }) + await settleDialog() + expect(q(dialogEl(), '[data-dialog-confirm]').disabled).toBe(true) + pressKey('Enter') + await flush() + expect(document.body.querySelector('gable-app-dialog')).not.toBeNull() + await typeIntoPrompt(' ') + expect(q(dialogEl(), '[data-dialog-confirm]').disabled).toBe(true) + await typeIntoPrompt('Entered by mistake') + expect(q(dialogEl(), '[data-dialog-confirm]').disabled).toBe(false) + pressKey('Enter') + await expect(result).resolves.toBe('Entered by mistake') + }) +}) diff --git a/web/apps/desk/src/components/ui/app-dialog.ts b/web/apps/desk/src/components/ui/app-dialog.ts new file mode 100644 index 000000000..f437a4679 --- /dev/null +++ b/web/apps/desk/src/components/ui/app-dialog.ts @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The app's own dialog, in place of the browser's. Pages call the promise + * helpers rather than the browser's blocking calls: + * + * const ok = await confirmDialog({ title: 'Confirm this order?' }); + * const reason = await promptDialog({ title: 'Cancel this order', label: 'Reason', required: true }); + * + * One element is rendered over the page at a time; a second call settles the + * one still open as cancelled. Focus moves to the safe choice (Cancel for a + * danger tone, the field for a prompt, the confirm otherwise), Tab stays + * inside, Escape cancels, Enter confirms when the focus is not on Cancel, and + * the focus returns to the control that opened the dialog. A required prompt + * keeps Confirm off until text is typed. + */ +import { LitElement, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +export type DialogTone = 'default' | 'danger'; +export type DialogMode = 'confirm' | 'prompt'; + +export interface ConfirmDialogOptions { + title: string; + body?: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: DialogTone; +} + +export interface PromptDialogOptions { + title: string; + label?: string; + body?: string; + required?: boolean; + confirmLabel?: string; + cancelLabel?: string; + initialValue?: string; + placeholder?: string; +} + +export interface DialogDoneDetail { + confirmed: boolean; + value: string | null; +} + +let dialogSeq = 0; +let openDialog: GableAppDialog | null = null; + +@customElement('gable-app-dialog') +export class GableAppDialog extends LitElement { + createRenderRoot() { return this; } + + @property({ type: String }) mode: DialogMode = 'confirm'; + @property({ type: String }) heading = ''; + @property({ type: String }) body = ''; + @property({ type: String, attribute: 'confirm-label' }) confirmLabel = 'Confirm'; + @property({ type: String, attribute: 'cancel-label' }) cancelLabel = 'Cancel'; + @property({ type: String }) tone: DialogTone = 'default'; + @property({ type: String, attribute: 'field-label' }) fieldLabel = ''; + @property({ type: Boolean }) required = false; + @property({ type: String, attribute: 'initial-value' }) initialValue = ''; + @property({ type: String }) placeholder = ''; + + @state() private _value = ''; + + private _ids = `gable-app-dialog-${++dialogSeq}`; + private _returnFocus: HTMLElement | null = null; + + connectedCallback() { + super.connectedCallback(); + this._returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + this._value = this.initialValue; + } + + firstUpdated() { + const target = + this.mode === 'prompt' + ? this.querySelector('input') + : this.tone === 'danger' + ? this.querySelector('[data-dialog-cancel]') + : this.querySelector('[data-dialog-confirm]'); + target?.focus(); + } + + /** Settle the dialog without a choice; used when a newer dialog takes over. */ + dismiss() { + this._finish(false); + } + + private _finish(confirmed: boolean) { + const detail: DialogDoneDetail = { + confirmed, + value: confirmed && this.mode === 'prompt' ? this._value.trim() : null, + }; + if (openDialog === this) openDialog = null; + this.dispatchEvent(new CustomEvent('done', { detail })); + if (this._returnFocus?.isConnected) this._returnFocus.focus(); + } + + private _cancel() { + this._finish(false); + } + + private _confirm() { + if (this.mode === 'prompt' && this.required && !this._value.trim()) return; + this._finish(true); + } + + private _focusables(): HTMLElement[] { + return Array.from( + this.querySelectorAll('[data-dialog-input], [data-dialog-cancel], [data-dialog-confirm]'), + ).filter((el) => !(el as HTMLButtonElement).disabled); + } + + private _onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this._cancel(); + return; + } + if (e.key === 'Tab') { + const focusables = this._focusables(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || !this.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (active === last || !this.contains(active))) { + e.preventDefault(); + first.focus(); + } + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (document.activeElement === this.querySelector('[data-dialog-cancel]')) { + this._cancel(); + } else { + this._confirm(); + } + } + } + + render() { + const promptInvalid = this.mode === 'prompt' && this.required && !this._value.trim(); + const describedBy = this.body ? `${this._ids}-body` : nothing; + return html` +
+
+
+
+

${this.heading}

+ ${this.body + ? html`

${this.body}

` + : nothing} + ${this.mode === 'prompt' + ? html` +
+ ${this.fieldLabel + ? html`` + : nothing} + { this._value = (e.target as HTMLInputElement).value; }} + class="w-full bg-slate-steel/50 border border-white/10 rounded-md py-2 px-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-gable-green/50" + /> +
+ ` + : 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 ?? ''; + }).then((detail) => (detail.confirmed ? detail.value : null)); +} From d5314e662d254c428afaa3b11365abeb651fca2b Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:32:20 -0700 Subject: [PATCH 03/30] feat(desk): the app's own select element Add gable-select under components/ui: a button showing the chosen option's label (or a placeholder in words) and the app's own popover listbox in place of the browser's native list. aria-haspopup listbox with aria-expanded, role listbox and option with aria-selected and aria-activedescendant; Up, Down, Home and End move the active option, typeahead matches on the first letters, Enter or Space chooses, Escape closes without choosing, Tab closes and moves on. Disabled options are listed but cannot be chosen, and an option may carry a group heading. The list opens upward when there is more room above. A choice fires a bubbling change event with the value on the element, so a handler written for a native select keeps its meaning. Signed-off-by: colton --- .../src/components/ui/gable-select.test.ts | 188 ++++++++++++ .../desk/src/components/ui/gable-select.ts | 280 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 web/apps/desk/src/components/ui/gable-select.test.ts create mode 100644 web/apps/desk/src/components/ui/gable-select.ts 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..9307b347b --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-select.test.ts @@ -0,0 +1,188 @@ +// 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, flush } 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(el.querySelector('[role="listbox"]')!.getAttribute('aria-activedescendant')).toContain('-opt-1') + keydown(el, 'End') + await update(el, {}) + expect(el.querySelector('[role="listbox"]')!.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('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(el.querySelector('[role="listbox"]')!.getAttribute('aria-activedescendant')).toContain('-opt-1') + keydown(el, 'l') + await update(el, {}) + expect(el.querySelector('[role="listbox"]')!.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(el.querySelector('[role="listbox"]')!.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('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..b5390f684 --- /dev/null +++ b/web/apps/desk/src/components/ui/gable-select.ts @@ -0,0 +1,280 @@ +// 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. + */ +import { LitElement, html, nothing } 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; + + @state() private _open = false; + @state() private _activeIndex = -1; + @state() private _openUpward = false; + + private _ids = `gable-select-${++selectSeq}`; + private _typeahead = ''; + private _typeaheadAt = 0; + private _onDocClick = (e: Event) => { + if (!this.contains(e.target as Node)) this._close(); + }; + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._onDocClick, true); + } + + /** 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; + const rect = this.getBoundingClientRect(); + const below = window.innerHeight - rect.bottom; + const above = rect.top; + this._openUpward = below < 240 && above > below; + 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(() => this._scrollActiveIntoView()); + } + + private _close(restoreFocus = false) { + if (!this._open) return; + this._open = false; + document.removeEventListener('click', this._onDocClick, true); + if (restoreFocus) { + this.updateComplete.then(() => 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 = []; + 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)); + }); + const activeDesc = this._activeIndex >= 0 ? `${this._ids}-opt-${this._activeIndex}` : nothing; + return html` +
+ ${rows.length > 0 + ? rows + : html``} +
+ `; + } + + render() { + const chosen = this.selectedOption; + return html` +
+ + ${this._open ? this._renderList() : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gable-select': GableSelect; + } +} From d80cf5b3e2fc00f8279bbe794f8637db344b1fff Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:39:33 -0700 Subject: [PATCH 04/30] refactor(desk): sweep the browser's dialogs to the app's own Every confirm( and prompt( call in the desk now asks through confirmDialog/promptDialog: the order confirm and cancel reason, the counter's leave-the-open-sale question (yard change, register change, till close), clearing every line of a sale, the void reason, Tech Admin's key and partner removals, the contact delete, the vehicle and driver removals, the market index value, and the saved report and schedule deletes. Removals and voids ask with the danger tone; the cancel and void reasons are required prompts. The register picker's decline path is now async and puts the picker's value back. The guard's root is corrected to web/apps so the generated api-client schema is out of its scope. Signed-off-by: colton --- web/apps/desk/src/components/ui/app-dialog.ts | 2 + .../desk/src/pages/accounts/ContactList.ts | 8 +++- .../desk/src/pages/admin/MarketIndices.ts | 13 +++-- .../pages/admin/tech_admin/TechAdminPage.ts | 29 ++++++++++-- .../src/pages/logistics/FleetManagement.ts | 17 ++++++- web/apps/desk/src/pages/orders/OrderDetail.ts | 5 +- web/apps/desk/src/pages/pos/POSTerminal.ts | 47 +++++++++++++------ .../desk/src/pages/reports/SavedReports.ts | 15 +++++- .../src/test/browser-chrome.guard.test.ts | 2 +- 9 files changed, 107 insertions(+), 31 deletions(-) diff --git a/web/apps/desk/src/components/ui/app-dialog.ts b/web/apps/desk/src/components/ui/app-dialog.ts index f437a4679..1741f7653 100644 --- a/web/apps/desk/src/components/ui/app-dialog.ts +++ b/web/apps/desk/src/components/ui/app-dialog.ts @@ -38,6 +38,7 @@ export interface PromptDialogOptions { cancelLabel?: string; initialValue?: string; placeholder?: string; + tone?: DialogTone; } export interface DialogDoneDetail { @@ -250,5 +251,6 @@ export function promptDialog(options: PromptDialogOptions): Promise (detail.confirmed ? detail.value : null)); } diff --git a/web/apps/desk/src/pages/accounts/ContactList.ts b/web/apps/desk/src/pages/accounts/ContactList.ts index 8e690320e..903032e68 100644 --- a/web/apps/desk/src/pages/accounts/ContactList.ts +++ b/web/apps/desk/src/pages/accounts/ContactList.ts @@ -10,6 +10,7 @@ import { ApiError, apiErrorMessage, fieldErrorMap } from '../../services/apiErro import { CONTACT_ROLES } from '../../types/customer.ts'; import type { Contact, ContactRequest, ContactRole } from '../../types/customer.ts'; import { BUTTON_PRIMARY, BUTTON_QUIET, INPUT_CLASS, checkbox, idPrefix, labeled, orNull, textInput } from './formFields.ts'; +import { confirmDialog } from '../../components/ui/app-dialog.ts'; interface ContactDraft { first_name: string; @@ -170,7 +171,12 @@ export class GableContactList extends LitElement { } private async handleDelete(c: Contact) { - if (!confirm('Are you sure you want to delete this contact?')) return; + if (!await confirmDialog({ + title: 'Delete this contact?', + body: `${c.first_name} ${c.last_name} is removed. This cannot be undone.`, + confirmLabel: 'Delete', + tone: 'danger', + })) return; try { await CustomerService.deleteContact(c.id, c.revision); ToastService.show('Contact deleted', 'success'); diff --git a/web/apps/desk/src/pages/admin/MarketIndices.ts b/web/apps/desk/src/pages/admin/MarketIndices.ts index b40db39f5..00e9a396e 100644 --- a/web/apps/desk/src/pages/admin/MarketIndices.ts +++ b/web/apps/desk/src/pages/admin/MarketIndices.ts @@ -6,6 +6,7 @@ import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; import { ExposureService } from '../../services/ExposureService'; +import { promptDialog } from '../../components/ui/app-dialog.ts'; import type { MarketIndex } from '../../types/exposure'; import { Activity, RefreshCw } from 'lucide'; @@ -40,10 +41,14 @@ export class MarketIndicesPage extends LitElement { } private async _refresh(idx: MarketIndex) { - const raw = window.prompt( - `Apply a new value for ${idx.name} (${idx.index_code || '—'}).\nCurrent: ${this._fmt(idx.current_value)} ${idx.unit || ''}\n\nEnter the new index value:`, - idx.current_value != null ? String(idx.current_value) : '', - ); + const raw = await promptDialog({ + title: `Apply a new value for ${idx.name} (${idx.index_code || '—'})`, + body: `Current: ${this._fmt(idx.current_value)} ${idx.unit || ''}`, + label: 'New index value', + required: true, + confirmLabel: 'Apply', + initialValue: idx.current_value != null ? String(idx.current_value) : '', + }); if (raw == null) return; const newValue = Number(raw); if (!Number.isFinite(newValue) || newValue <= 0) { diff --git a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts index ba92c40ba..4e9fce506 100644 --- a/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts +++ b/web/apps/desk/src/pages/admin/tech_admin/TechAdminPage.ts @@ -7,6 +7,7 @@ import { icon } from '../../../lib/icons.ts'; import { cn } from '../../../lib/utils.ts'; import './MarketCheckSettings.ts'; import { techAdminService, ediService } from '../../../services/TechAdminService'; +import { confirmDialog } from '../../../components/ui/app-dialog.ts'; import { aiHeadline, aiStatusLine, imageWords, modelLabel, providerLabel } from '../../../lib/aiWords.ts'; import type { APIKey, AISettings, RoutingSettings, EDITradingPartner, StaffMember, ModuleInfo, Readiness } from '../../../services/TechAdminService'; import { @@ -121,7 +122,12 @@ export class TechAdminPage extends LitElement { } private async _handleRevokeKey(id: string) { - if (!confirm('Are you sure you want to revoke this key? integrations using it will break immediately.')) return; + if (!await confirmDialog({ + title: 'Revoke this API key?', + body: 'Integrations using it will break immediately. This cannot be undone.', + confirmLabel: 'Revoke key', + tone: 'danger', + })) return; try { this.keyError = null; await techAdminService.revokeKey(id); @@ -254,7 +260,12 @@ export class TechAdminPage extends LitElement { } private async _handleDeleteAIKey() { - if (!confirm('Remove the admin-configured API key? If an environment variable is set, it will be used as fallback.')) return; + if (!await confirmDialog({ + title: 'Remove the admin-configured API key?', + body: 'If an environment variable is set, it will be used as fallback.', + confirmLabel: 'Remove key', + tone: 'danger', + })) return; try { await techAdminService.deleteAIKey(); this.aiSuccess = 'Admin API key removed.'; @@ -295,7 +306,12 @@ export class TechAdminPage extends LitElement { } private async _handleDeleteORSKey() { - if (!confirm('Remove the admin-configured OpenRouteService key? If an environment variable is set, it will be used as fallback; otherwise routing reverts to mock distances.')) return; + if (!await confirmDialog({ + title: 'Remove the admin-configured OpenRouteService key?', + body: 'If an environment variable is set, it will be used as fallback; otherwise routing reverts to mock distances.', + confirmLabel: 'Remove key', + tone: 'danger', + })) return; try { await techAdminService.deleteORSKey(); this.routingSuccess = 'Admin routing key removed.'; @@ -348,7 +364,12 @@ export class TechAdminPage extends LitElement { } private async _handleDeletePartner(id: string) { - if (!confirm('Are you sure you want to remove this EDI partner? This will disconnect their catalog sync.')) return; + if (!await confirmDialog({ + title: 'Remove this EDI partner?', + body: 'This disconnects their catalog sync and cannot be undone.', + confirmLabel: 'Remove partner', + tone: 'danger', + })) return; try { this.ediError = null; await ediService.deletePartner(id); diff --git a/web/apps/desk/src/pages/logistics/FleetManagement.ts b/web/apps/desk/src/pages/logistics/FleetManagement.ts index 1d68ecdb3..b4ee0b4ed 100644 --- a/web/apps/desk/src/pages/logistics/FleetManagement.ts +++ b/web/apps/desk/src/pages/logistics/FleetManagement.ts @@ -7,6 +7,7 @@ import { icon } from '../../lib/icons.ts'; import { Truck, Users, Plus, Pencil, Trash2, AlertTriangle, RefreshCw, X, Upload } from 'lucide'; import { deliveryService } from '../../services/deliveryService'; import { ToastService } from '../../lib/toast-service.ts'; +import { confirmDialog } from '../../components/ui/app-dialog.ts'; import type { Vehicle, Driver, CreateVehicleRequest, UpdateVehicleRequest, CreateDriverRequest, UpdateDriverRequest, VehicleType, DriverStatus } from '../../types/delivery'; type Tab = 'vehicles' | 'drivers'; @@ -165,7 +166,13 @@ export class FleetManagement extends LitElement { } private async _handleVehicleDelete() { - if (!this._vehicleModalVehicle || !confirm('Delete this vehicle? This action cannot be undone.')) return; + if (!this._vehicleModalVehicle) return; + if (!await confirmDialog({ + title: 'Delete this vehicle?', + body: 'This action cannot be undone.', + confirmLabel: 'Delete', + tone: 'danger', + })) return; this._vDeleting = true; try { await deliveryService.deleteVehicle(this._vehicleModalVehicle.id, this._vehicleModalVehicle.revision); @@ -235,7 +242,13 @@ export class FleetManagement extends LitElement { } private async _handleDriverDelete() { - if (!this._driverModalDriver || !confirm('Remove this driver? This action cannot be undone.')) return; + if (!this._driverModalDriver) return; + if (!await confirmDialog({ + title: 'Remove this driver?', + body: 'This action cannot be undone.', + confirmLabel: 'Remove', + tone: 'danger', + })) return; this._dDeleting = true; try { await deliveryService.deleteDriver(this._driverModalDriver.id, this._driverModalDriver.revision); diff --git a/web/apps/desk/src/pages/orders/OrderDetail.ts b/web/apps/desk/src/pages/orders/OrderDetail.ts index efa7c160a..c41fa18b2 100644 --- a/web/apps/desk/src/pages/orders/OrderDetail.ts +++ b/web/apps/desk/src/pages/orders/OrderDetail.ts @@ -5,6 +5,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; import { ToastService } from '../../lib/toast-service.ts'; +import { confirmDialog, promptDialog } from '../../components/ui/app-dialog.ts'; import { OrderService } from '../../services/OrderService.ts'; import { InvoiceService } from '../../services/InvoiceService.ts'; import { SalesTeamService } from '../../services/SalesTeamService.ts'; @@ -85,7 +86,7 @@ export class GableOrderDetail extends LitElement { */ private async handleConfirm() { if (!this.order) return; - if (!confirm('Confirm this order?')) return; + if (!await confirmDialog({ title: 'Confirm this order?' })) return; this.processing = true; try { const updated = await OrderService.confirmOrder(this.order.id, this.order.revision); @@ -173,7 +174,7 @@ export class GableOrderDetail extends LitElement { private async handleCancel() { if (!this.order) return; - const reason = prompt('Cancel this order. Reason:'); + const reason = await promptDialog({ title: 'Cancel this order', label: 'Reason', required: true, confirmLabel: 'Cancel order', tone: 'danger' }); if (!reason) return; this.processing = true; try { diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 23a7b6c49..e1d2dd3cb 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -10,6 +10,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'; @@ -104,17 +105,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 { @@ -205,10 +216,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(); @@ -255,7 +266,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); @@ -658,7 +669,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); @@ -802,7 +817,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); @@ -1017,7 +1032,9 @@ export class POSTerminal extends LitElement { ({ value: c.id, label: `${c.first_name} ${c.last_name} (${c.role})` }))]} @change=${(e: Event) => this._contactId = (e.target as HTMLSelectElement).value} - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" - > - - ${this.contacts.map(c => html` - - `)} - + >
diff --git a/web/apps/desk/src/components/inventory/AddProductModal.ts b/web/apps/desk/src/components/inventory/AddProductModal.ts index a1e0ad90c..f2d655590 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.ts b/web/apps/desk/src/components/inventory/InventoryTransferModal.ts index d2b3940e8..2d5efdabb 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'; @@ -111,36 +112,20 @@ export class GableInventoryTransferModal extends LitElement {
- + >
- + >
diff --git a/web/apps/desk/src/components/inventory/StockAdjustmentModal.ts b/web/apps/desk/src/components/inventory/StockAdjustmentModal.ts index dd9358624..ba2e7dbe0 100644 --- a/web/apps/desk/src/components/inventory/StockAdjustmentModal.ts +++ b/web/apps/desk/src/components/inventory/StockAdjustmentModal.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 type { Product } from '../../types/product'; import type { Location } from '../../types/location'; @@ -80,17 +81,11 @@ export class GableStockAdjustmentModal extends LitElement {
- + >
@@ -107,17 +102,11 @@ export class GableStockAdjustmentModal extends LitElement {
- + >
diff --git a/web/apps/desk/src/components/invoices/PaymentModal.ts b/web/apps/desk/src/components/invoices/PaymentModal.ts index 2b8cb2104..c60f0edc3 100644 --- a/web/apps/desk/src/components/invoices/PaymentModal.ts +++ b/web/apps/desk/src/components/invoices/PaymentModal.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 type { CreatePaymentRequest, PaymentMethod } from '../../types/payment'; @@ -97,13 +98,11 @@ export class GablePaymentModal extends LitElement {
- + >
diff --git a/web/apps/desk/src/components/logistics/CreateRouteModal.ts b/web/apps/desk/src/components/logistics/CreateRouteModal.ts index 15185c3a0..30b258942 100644 --- a/web/apps/desk/src/components/logistics/CreateRouteModal.ts +++ b/web/apps/desk/src/components/logistics/CreateRouteModal.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, Truck, User, Calendar, FileText } from 'lucide'; @@ -95,19 +96,11 @@ export class GableCreateRouteModal extends LitElement { - + > ${selectedVehicle?.capacity_weight_lbs ? html`

Capacity: ${selectedVehicle.capacity_weight_lbs.toLocaleString()} lbs @@ -119,19 +112,11 @@ export class GableCreateRouteModal extends LitElement { - + >

diff --git a/web/apps/desk/src/components/quotes/EscalatorToggle.ts b/web/apps/desk/src/components/quotes/EscalatorToggle.ts index 572ce0bd8..429d2143b 100644 --- a/web/apps/desk/src/components/quotes/EscalatorToggle.ts +++ b/web/apps/desk/src/components/quotes/EscalatorToggle.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 { TrendingUp, AlertTriangle, Link2, Calendar, Percent, BarChart3 } from 'lucide'; @@ -140,16 +141,11 @@ export class GableEscalatorToggle extends LitElement { ` : html`
- + >
`} From da72ed38225ba6bac47b7cc5705321c0cde4f93e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:49:06 -0700 Subject: [PATCH 06/30] refactor(desk): the back-office pages' native selects become gable-select The quote builder's unit and truck pickers, accounts payable, the bank reconciliation, the chart of accounts, journal entries, the account pages and forms, the pricing matrix, Tech Admin's AI provider and model, and the credit memo pages offer their choices through the app's own select. Aria labels carry over as the component's label; width constraints move to a wrapper div; every handler keeps its meaning. Signed-off-by: colton --- web/apps/desk/src/pages/QuoteBuilder.ts | 23 +++++------ .../src/pages/accounting/AccountsPayable.ts | 39 +++++++------------ .../pages/accounting/BankReconciliation.ts | 10 ++--- .../src/pages/accounting/ChartOfAccounts.ts | 9 ++--- .../src/pages/accounting/JournalEntries.ts | 10 ++--- .../src/pages/accounts/AccountDetailPage.ts | 9 ++--- .../desk/src/pages/accounts/AccountsPage.ts | 28 +++++++------ .../desk/src/pages/accounts/ContactList.ts | 6 +-- .../desk/src/pages/accounts/CustomerForm.ts | 9 ++--- .../src/pages/admin/pricing/PricingMatrix.ts | 12 ++---- .../pages/admin/tech_admin/TechAdminPage.ts | 27 +++++++------ .../src/pages/credit-memos/CreditMemoForm.ts | 26 ++++++------- .../src/pages/credit-memos/CreditMemoList.ts | 29 +++++++------- 13 files changed, 101 insertions(+), 136 deletions(-) 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.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.ts index 3a1b06b9b..97309ce41 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/inventory/tabs/ProductMediaTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts index 9fcc588f9..99f5e52a9 100644 --- a/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.ts +++ b/web/apps/desk/src/pages/inventory/tabs/ProductMediaTab.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 { Sparkles, Trash2, Star, Loader2, Image as ImageIcon, AlertTriangle } from 'lucide'; @@ -104,15 +105,11 @@ export class GableProductMediaTab extends LitElement {

- + >
@@ -594,13 +593,11 @@ export class FleetManagement extends LitElement {
- + >
${this._renderDateField('CDL Expiry', this._dForm.cdl_expiry || '', (v: string) => { this._dForm = { ...this._dForm, cdl_expiry: v || undefined }; })} ${this._renderDateField('Hire Date', this._dForm.hire_date || '', (v: string) => { this._dForm = { ...this._dForm, hire_date: v || undefined }; })} @@ -608,13 +605,11 @@ export class FleetManagement extends LitElement { ${isEdit ? html`
- + >
` : nothing}
diff --git a/web/apps/desk/src/pages/portal/PortalCatalog.ts b/web/apps/desk/src/pages/portal/PortalCatalog.ts index 2afac562e..cf1d69de6 100644 --- a/web/apps/desk/src/pages/portal/PortalCatalog.ts +++ b/web/apps/desk/src/pages/portal/PortalCatalog.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 { ToastService } from '../../lib/toast-service.ts'; @@ -106,30 +107,21 @@ export class PortalCatalog extends LitElement { class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-500 focus:outline-none focus:border-gable-green/50 transition-colors text-sm" />
- - - + >
diff --git a/web/apps/desk/src/pages/portal/PortalTeam.ts b/web/apps/desk/src/pages/portal/PortalTeam.ts index f967886cc..54a217b86 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.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index e1d2dd3cb..1a705f98d 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'; @@ -465,12 +466,10 @@ export class POSTerminal extends LitElement {
- + >
` : nothing} @@ -982,13 +981,16 @@ export class POSTerminal extends LitElement { `)}
- +
+ { this._returnMethod = (e.target as HTMLSelectElement).value as RefundMethod; }} + > +
{ this._returnReason = (e.target as HTMLInputElement).value; }} @@ -1028,17 +1030,16 @@ export class POSTerminal extends LitElement { ${this._register.branch_name} ${this._registerId} ${this._registers.length > 1 ? html` - - ` : nothing} + >` : nothing}

` : nothing}