From dfdf00db5e5a9c68967029a46e9f03324860dccd Mon Sep 17 00:00:00 2001 From: Darren Neimke Date: Fri, 23 Oct 2026 15:34:38 +1030 Subject: [PATCH] feat: consolidate match creation into Create Match dropdown and streamline top nav --- scripts/visual-snapshot.js | 28 +++++ src/components/ui/createMatchDropdown.js | 76 ++++++++++++ src/components/ui/index.js | 1 + src/index.html | 49 +++++--- src/main.js | 4 +- src/styles.css | 100 +++++++++++++++ src/timeline-video.html | 4 - tests/components/createMatchDropdown.test.js | 121 +++++++++++++++++++ tests/components/navConsistency.test.js | 12 +- 9 files changed, 370 insertions(+), 25 deletions(-) create mode 100644 src/components/ui/createMatchDropdown.js create mode 100644 tests/components/createMatchDropdown.test.js diff --git a/scripts/visual-snapshot.js b/scripts/visual-snapshot.js index d773274..80871e2 100644 --- a/scripts/visual-snapshot.js +++ b/scripts/visual-snapshot.js @@ -180,6 +180,34 @@ async function runSnapshots() { } } + // Capture Create Match dropdown open state (desktop and mobile) + for (const vp of VIEWPORTS) { + const dropdownCtx = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2 + }); + const dropdownPage = await dropdownCtx.newPage(); + await dropdownPage.goto(`${baseUrl}/?mock=sample`, { waitUntil: 'domcontentloaded' }); + await dropdownPage.waitForSelector('#createMatchDropdownButton'); + await dropdownPage.click('#createMatchDropdownButton'); + await dropdownPage.waitForSelector('#createMatchMenu:not(.hidden)'); + await dropdownPage.waitForTimeout(300); + + const dropdownOpenPath = path.join(OUTPUT_DIR, `create-match-dropdown-open-${vp.name}.png`); + await dropdownPage.screenshot({ path: dropdownOpenPath, fullPage: true }); + + const stats = fs.statSync(dropdownOpenPath); + results.push({ + name: 'create-match-dropdown-open', + viewport: vp.name, + size: `${vp.width}x${vp.height}`, + file: `create-match-dropdown-open-${vp.name}.png`, + bytes: stats.size + }); + + await dropdownCtx.close(); + } + // Capture custom board flow proof: Duplication, Import, and Event Capture const sampleImportBoardPath = path.join(OUTPUT_DIR, 'sample-coach-board.json'); const sampleImportData = { diff --git a/src/components/ui/createMatchDropdown.js b/src/components/ui/createMatchDropdown.js new file mode 100644 index 0000000..dbdaab0 --- /dev/null +++ b/src/components/ui/createMatchDropdown.js @@ -0,0 +1,76 @@ +/** + * Create Match dropdown handler + * @module components/ui/createMatchDropdown + */ + +/** + * Initializes the Create Match dropdown menu behavior + * @param {Document|HTMLElement} [root=document] - Root container or document + * @returns {{ open: Function, close: Function, toggle: Function, destroy: Function } | null} + */ +export function initCreateMatchDropdown(root = document) { + const trigger = root.getElementById ? root.getElementById('createMatchDropdownButton') : root.querySelector('#createMatchDropdownButton'); + const menu = root.getElementById ? root.getElementById('createMatchMenu') : root.querySelector('#createMatchMenu'); + const chevron = root.getElementById ? root.getElementById('createMatchChevron') : root.querySelector('#createMatchChevron'); + + if (!trigger || !menu) return null; + + const open = () => { + menu.classList.remove('hidden'); + trigger.setAttribute('aria-expanded', 'true'); + if (chevron) chevron.classList.add('rotate-180'); + }; + + const close = () => { + menu.classList.add('hidden'); + trigger.setAttribute('aria-expanded', 'false'); + if (chevron) chevron.classList.remove('rotate-180'); + }; + + const toggle = () => { + const isExpanded = trigger.getAttribute('aria-expanded') === 'true'; + if (isExpanded) { + close(); + } else { + open(); + } + }; + + const handleTriggerClick = (e) => { + e.stopPropagation(); + toggle(); + }; + + const handleDocumentClick = (e) => { + if (!trigger.contains(e.target) && !menu.contains(e.target)) { + close(); + } + }; + + const handleDocumentKeydown = (e) => { + if (e.key === 'Escape' && trigger.getAttribute('aria-expanded') === 'true') { + close(); + trigger.focus(); + } + }; + + const menuItems = menu.querySelectorAll('[role="menuitem"]'); + const handleMenuItemClick = () => { + close(); + }; + + trigger.addEventListener('click', handleTriggerClick); + const doc = root.ownerDocument || root; + doc.addEventListener('click', handleDocumentClick); + doc.addEventListener('keydown', handleDocumentKeydown); + menuItems.forEach((item) => item.addEventListener('click', handleMenuItemClick)); + + const destroy = () => { + trigger.removeEventListener('click', handleTriggerClick); + doc.removeEventListener('click', handleDocumentClick); + doc.removeEventListener('keydown', handleDocumentKeydown); + menuItems.forEach((item) => item.removeEventListener('click', handleMenuItemClick)); + }; + + return { open, close, toggle, destroy }; +} diff --git a/src/components/ui/index.js b/src/components/ui/index.js index f27c647..3e221d9 100644 --- a/src/components/ui/index.js +++ b/src/components/ui/index.js @@ -11,3 +11,4 @@ export { createCard, createGameCard } from './card.js'; export { createInput, createSelect, createFormGroup } from './form.js'; export { createLogEntry, createLogContainer } from './log.js'; export { createGrid, createButtonGrid, createResponsiveGrid } from './grid.js'; +export { initCreateMatchDropdown } from './createMatchDropdown.js'; diff --git a/src/index.html b/src/index.html index d210cbf..9188057 100644 --- a/src/index.html +++ b/src/index.html @@ -45,11 +45,6 @@ Hockey Tracker - - - Matches - Configure @@ -338,16 +333,42 @@

Event Log

Matches

Record a live match, code from video, or import Sportscode XML.

-
- - Live Match - - - From Video - - +
diff --git a/src/main.js b/src/main.js index 6f79ec9..07f4171 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ import { importNamedConfig, triggerFileDownload } from './components/config.js'; -import { createGameCard, createButton, createInput, createSelect } from './components/ui/index.js'; +import { createGameCard, createButton, createInput, createSelect, initCreateMatchDropdown } from './components/ui/index.js'; import { gameStorageManager } from './services/gameStorageManager.js'; import { checkStorageQuota } from './utils/storageWarningUtils.js'; import { draftService, finishCapture } from './services/draftService.js'; @@ -1297,6 +1297,8 @@ function registerEventListeners() { } }); + initCreateMatchDropdown(); + importXmlButton.addEventListener('click', () => { xmlFileInput.click(); }); diff --git a/src/styles.css b/src/styles.css index 1e0d051..198b45b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1470,6 +1470,106 @@ code { } } +/* ===== Create Match Dropdown ===== */ +.create-match-dropdown { + position: relative; + display: inline-block; +} + +.create-match-menu { + position: absolute; + top: calc(100% + var(--space-2)); + right: 0; + z-index: 30; + display: flex; + flex-direction: column; + min-width: 17rem; + padding: var(--space-1); + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); +} + +.create-match-menu.hidden { + display: none; +} + +.create-match-item { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 3rem; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + text-align: left; + text-decoration: none; + color: var(--color-ink); + transition: background-color 0.15s ease; + cursor: pointer; + background: transparent; + border: none; + width: 100%; +} + +.create-match-item:hover, +.create-match-item:focus-visible { + outline: none; + background-color: var(--color-surface-muted); +} + +.create-match-item:focus-visible { + box-shadow: var(--focus-ring); +} + +.create-match-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: var(--radius-sm); + background-color: var(--color-primary-soft); + color: var(--color-primary); + font-size: 0.95rem; +} + +.create-match-item-text { + display: flex; + flex-direction: column; +} + +.create-match-item-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-ink); + line-height: 1.25rem; +} + +.create-match-item-desc { + font-size: 0.75rem; + color: var(--color-muted); + line-height: 1rem; +} + +@media (max-width: 640px) { + .create-match-dropdown { + width: 100%; + } + + .create-match-dropdown > button { + width: 100%; + justify-content: center; + } + + .create-match-menu { + left: 0; + right: 0; + min-width: 100%; + } +} + /* ===== Match review ===== */ .review-page .app-nav-inner, .review-page .page { diff --git a/src/timeline-video.html b/src/timeline-video.html index a430078..db630f5 100644 --- a/src/timeline-video.html +++ b/src/timeline-video.html @@ -45,10 +45,6 @@ Hockey Tracker - - - Matches - Configure diff --git a/tests/components/createMatchDropdown.test.js b/tests/components/createMatchDropdown.test.js new file mode 100644 index 0000000..b2087aa --- /dev/null +++ b/tests/components/createMatchDropdown.test.js @@ -0,0 +1,121 @@ +import fs from 'fs'; +import path from 'path'; +import { initCreateMatchDropdown } from '../../src/components/ui/createMatchDropdown.js'; + +describe('Create Match Dropdown Component', () => { + let container; + + beforeEach(() => { + const filePath = path.resolve(process.cwd(), 'src/index.html'); + const html = fs.readFileSync(filePath, 'utf8'); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + container = doc; + }); + + test('replaces the three separate action buttons with a single "Create Match" dropdown trigger', () => { + const trigger = container.querySelector('#createMatchDropdownButton'); + expect(trigger).not.toBeNull(); + expect(trigger.textContent).toContain('Create Match'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + test('contains a dropdown menu with the three creation modes', () => { + const menu = container.querySelector('#createMatchMenu'); + expect(menu).not.toBeNull(); + expect(menu.getAttribute('role')).toBe('menu'); + expect(menu.classList.contains('hidden')).toBe(true); + + const liveMatchItem = menu.querySelector('#startLiveMatchButton'); + const fromVideoItem = menu.querySelector('#codeFromVideoButton'); + const importXmlItem = menu.querySelector('#importXmlButton'); + + expect(liveMatchItem).not.toBeNull(); + expect(liveMatchItem.getAttribute('href')).toBe('#event-capture'); + expect(liveMatchItem.textContent).toContain('Live Match'); + + expect(fromVideoItem).not.toBeNull(); + expect(fromVideoItem.getAttribute('href')).toBe('timeline-video.html?new=video'); + expect(fromVideoItem.textContent).toContain('From Video'); + + expect(importXmlItem).not.toBeNull(); + expect(importXmlItem.textContent).toContain('Import XML'); + }); + + describe('Interactive Behavior', () => { + let trigger; + let menu; + let controller; + + beforeEach(() => { + // Put a copy of the dropdown in document.body + document.body.innerHTML = ` +
+ + +
+ `; + trigger = document.getElementById('createMatchDropdownButton'); + menu = document.getElementById('createMatchMenu'); + controller = initCreateMatchDropdown(document); + }); + + afterEach(() => { + controller?.destroy(); + document.body.innerHTML = ''; + }); + + test('toggles menu visibility and aria-expanded on trigger click', () => { + expect(menu.classList.contains('hidden')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + + trigger.click(); + expect(menu.classList.contains('hidden')).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + trigger.click(); + expect(menu.classList.contains('hidden')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + test('closes when clicking outside the dropdown', () => { + trigger.click(); + expect(menu.classList.contains('hidden')).toBe(false); + + document.body.click(); + expect(menu.classList.contains('hidden')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + test('closes on Escape key and refocuses trigger', () => { + trigger.click(); + expect(menu.classList.contains('hidden')).toBe(false); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + expect(menu.classList.contains('hidden')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(document.activeElement).toBe(trigger); + }); + + test('closes when a menu item is clicked', () => { + trigger.click(); + expect(menu.classList.contains('hidden')).toBe(false); + + const liveItem = document.getElementById('startLiveMatchButton'); + liveItem.click(); + + expect(menu.classList.contains('hidden')).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + }); +}); diff --git a/tests/components/navConsistency.test.js b/tests/components/navConsistency.test.js index fc80f5a..6f3de5c 100644 --- a/tests/components/navConsistency.test.js +++ b/tests/components/navConsistency.test.js @@ -19,22 +19,22 @@ describe('Navigation Menu Consistency Across Screens', () => { container = doc; }); - test('main menu contains exactly 2 main entry points: Matches and Configure', () => { + test('main menu contains Configure entry point with brand acting as Home/Matches link', () => { const navLinks = Array.from(container.querySelectorAll('#mainMenu .nav-link')); - expect(navLinks).toHaveLength(2); + expect(navLinks).toHaveLength(1); const linkTexts = navLinks.map(el => el.querySelector('span')?.textContent.trim() || el.textContent.trim()); - expect(linkTexts).toEqual(['Matches', 'Configure']); + expect(linkTexts).toEqual(['Configure']); - expect(navLinks[0].getAttribute('href')).toBe(matchesHref); - expect(navLinks[1].getAttribute('href')).toBe(configHref); + expect(navLinks[0].getAttribute('href')).toBe(configHref); }); - test('does not contain "Event Capture" or "Saved Games" in main menu', () => { + test('does not contain "Event Capture", "Saved Games", or redundant "Matches" link in main menu', () => { const navLinks = Array.from(container.querySelectorAll('#mainMenu .nav-link')); const linkTexts = navLinks.map(el => el.textContent); expect(linkTexts.some(t => t.includes('Event Capture'))).toBe(false); expect(linkTexts.some(t => t.includes('Saved Games'))).toBe(false); + expect(linkTexts.some(t => t.includes('Matches'))).toBe(false); }); test('mobile and desktop brand logos link to saved matches', () => {