UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,307 lines (1,207 loc) 56.8 kB
var LeagueMatch = (function () { 'use strict'; const buttonStyles = ` .button-shared { padding: var(--le-padding-s, 0.75em) var(--le-padding-m, 1.25em); /* Increased padding */ border: 1px solid var(--le-border-color-medium, #ccc); background-color: var(--le-background-color-button, #f0f0f0); color: var(--le-text-color-primary, #333); /* Ensure text color contrasts with button background */ cursor: pointer; border-radius: var(--le-border-radius-standard, 4px); font-size: var(--le-font-size-medium, 1.15em); /* Increased font size */ text-decoration: none; display: inline-block; text-align: center; line-height: normal; /* Ensure consistent line height */ white-space: nowrap; /* Prevent text wrapping */ vertical-align: middle; /* Align nicely if next to text/icons */ user-select: none; /* Prevent text selection on click */ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; /* Smooth transitions */ } .button-shared:hover:not(:disabled) { background-color: var(--le-background-color-button-hover, #e0e0e0); border-color: var(--le-border-color-dark, #bbb); /* Slightly darker border on hover */ /* color: var(--le-text-color-accent-hover, inherit); Optional: change text color on hover */ } .button-shared:active:not(:disabled) { /* Optional: style for active (pressed) state */ /* background-color: var(--le-background-color-button-active, #d0d0d0); */ } .button-shared:disabled, .button-shared.disabled { /* Allow class-based disabling too */ background-color: var(--le-background-color-button-disabled, #eee); color: var(--le-text-color-secondary, #aaa); border-color: var(--le-border-color-medium, #ccc); /* Use medium border for disabled state */ cursor: not-allowed; opacity: 0.7; /* Visually indicate disabled state */ } /* Small button variant */ .button-shared.button-sm { padding: var(--le-padding-xs, 0.4rem) var(--le-padding-s, 0.75rem); /* Increased padding */ font-size: var(--le-font-size-small, 1em); /* Increased small font size */ /* line-height can be tighter if needed for small buttons */ /* line-height: 1.2; */ } /* Variations */ .button-shared.button-primary { background-color: var(--le-color-primary, #007bff); color: var(--le-text-color-on-primary, #fff); border-color: var(--le-color-primary, #007bff); } .button-shared.button-primary:hover:not(:disabled) { background-color: var(--le-color-primary-hover, #0056b3); border-color: var(--le-color-primary-hover, #0056b3); } .button-shared.button-secondary-light { background-color: var(--le-background-color-button-secondary-light, #f8f9fa); color: var(--le-text-color-secondary-light-text, #212529); border-color: var(--le-border-color-secondary-light, #ced4da); } .button-shared.button-secondary-light:hover:not(:disabled) { background-color: var(--le-background-color-button-secondary-light-hover, #e2e6ea); border-color: var(--le-border-color-secondary-light-hover, #dae0e5); color: var(--le-text-color-secondary-light-text-hover, #212529); } /* Example for a darker secondary button if needed elsewhere .button-shared.button-secondary { background-color: var(--le-color-secondary, #6c757d); color: var(--le-text-color-on-secondary, #fff); border-color: var(--le-color-secondary, #6c757d); } .button-shared.button-secondary:hover:not(:disabled) { background-color: var(--le-color-secondary-hover, #5a6268); border-color: var(--le-color-secondary-hover, #5a6268); } */ `; const modalStyles = ` .modal-shared-overlay { display: none; /* Hidden by default */ position: fixed; z-index: var(--le-z-index-modal-overlay, 1000); /* Ensure it's on top */ left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: var(--le-background-color-modal-overlay, rgba(0,0,0,0.4)); } .modal-shared-content { background-color: var(--le-background-color-panel, #fff); margin: var(--le-modal-margin-top, 10%) auto; /* Default to 10% from top, centered */ padding: 0; /* Remove padding, header/body/footer will handle it */ border: 1px solid var(--le-border-color-dark, #ccc); width: var(--le-modal-width, 90%); /* Increased width for mobile */ max-width: var(--le-modal-max-width, 600px); border-radius: var(--le-border-radius-large, 8px); box-shadow: var(--le-shadow-modal, 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)); display: flex; flex-direction: column; } /* Specific styles for mobile-view class, used by leagueMatch.js */ .modal-shared-content.mobile-view { margin: 5% auto; /* Less margin from top on mobile */ width: var(--le-modal-width-mobile, 95%); /* Even wider on mobile */ max-width: var(--le-modal-max-width-mobile, 650px); /* Increased max-width for mobile */ } .modal-shared-header { /* Utilizes .panel-header-shared for base styling if desired, or define fully here */ /* This example assumes it might be combined with .panel-header-shared or similar */ padding: var(--le-padding-s, 0.75em) var(--le-padding-m, 1.25em); /* Increased padding */ border-bottom: 1px solid var(--le-border-color-medium, #eee); font-weight: bold; color: var(--le-text-color-primary, #333); background-color: var(--le-background-color-header, #f9f9f9); /* Modal header distinct background */ display: flex; justify-content: space-between; align-items: center; font-size: var(--le-font-size-large, 1.4em); /* Increased large font size */ border-top-left-radius: var(--le-border-radius-large, 8px); /* Match content radius */ border-top-right-radius: var(--le-border-radius-large, 8px); /* Match content radius */ } .modal-shared-header .close-button-shared { /* Specific styling for a close button if needed */ color: var(--le-text-color-secondary, #aaa); font-size: 1.75em; /* Increased size */ font-weight: bold; background: none; border: none; cursor: pointer; } .modal-shared-header .close-button-shared:hover, .modal-shared-header .close-button-shared:focus { color: var(--le-text-color-primary, #000); text-decoration: none; } .modal-shared-body { padding: var(--le-padding-m, 1.25em); /* Increased padding */ overflow-y: auto; /* Allow body to scroll if content is too long */ flex-grow: 1; /* Allows body to take up available space if modal has fixed height */ } .modal-shared-footer { padding: var(--le-padding-s, 0.75em) var(--le-padding-m, 1.25em); /* Increased padding */ text-align: right; border-top: 1px solid var(--le-border-color-medium, #eee); background-color: var(--le-background-color-header, #f9f9f9); /* Optional: footer background */ border-bottom-left-radius: var(--le-border-radius-large, 8px); /* Match content radius */ border-bottom-right-radius: var(--le-border-radius-large, 8px); /* Match content radius */ } .modal-shared-footer .button-shared + .button-shared { /* Spacing between buttons in footer */ margin-left: var(--le-padding-s, 0.75em); /* Increased margin */ } `; const formStyles = ` .form-group-shared { margin-bottom: var(--le-padding-m, 1.25em); /* Increased margin */ } .form-label-shared { display: block; margin-bottom: var(--le-padding-xs, 0.4em); /* Increased margin */ font-weight: bold; color: var(--le-text-color-primary, #333); font-size: var(--le-font-size-medium, 1.15em); /* Increased font size */ } .form-input-shared, .form-textarea-shared, .form-select-shared { width: 100%; padding: var(--le-padding-s, 0.75em); /* Increased padding */ border: 1px solid var(--le-border-color-dark, #ccc); border-radius: var(--le-border-radius-standard, 4px); box-sizing: border-box; font-size: var(--le-font-size-medium, 1.15em); /* Increased font size */ color: var(--le-text-color-primary, #333); background-color: var(--le-background-color-panel, #fff); } .form-input-shared:focus, .form-textarea-shared:focus, .form-select-shared:focus { border-color: var(--le-border-color-accent, #2196f3); outline: none; /* Or a custom focus ring */ box-shadow: 0 0 0 2px var(--le-focus-ring-color, rgba(33, 150, 243, 0.3)); } /* Specific styling for checkbox groups if needed */ .form-checkbox-label-shared { display: flex; /* Changed to flex for better alignment */ align-items: center; font-weight: normal; /* Typically labels for checkboxes are not bold by default */ font-size: var(--le-font-size-medium, 1.15em); /* Increased font size */ color: var(--le-text-color-primary, #333); } .form-checkbox-label-shared input[type="checkbox"] { margin-right: var(--le-padding-s, 0.75em); /* Increased margin */ /* Consider custom styling for checkboxes if desired, or rely on browser defaults */ /* For consistent appearance across browsers, custom checkbox styling can be complex */ /* For now, using default with adjusted margin */ width: auto; /* Override width: 100% from .form-input-shared if a generic class was applied */ vertical-align: middle; /* Align checkbox with text */ } /* Styling for a container of multiple checkboxes or radio buttons */ .form-options-group-shared { /* Styles for a group of checkboxes/radios, e.g., display: flex; flex-direction: column; gap: ... */ } /* Styling for individual option within a group */ .form-option-item-shared { /* Styles for each checkbox/radio item within a group */ } `; const mobileStyles = ` /* Mobile-specific styling that can be added to host elements */ /* Increased font sizes for better readability on mobile */ --le-font-size-base: 1.4em; --le-font-size-small: 1.2em; --le-font-size-medium: 1.6em; --le-font-size-large: 1.8em; --le-font-size-xlarge: 2em; /* Adjust padding for better touch targets */ --le-padding-s: 0.6rem; --le-padding-m: 1rem; /* Other mobile optimizations */ font-size: var(--le-font-size-base); line-height: 1.4; `; const BASE_STYLES = ` ${buttonStyles} ${modalStyles} ${formStyles} :host { /* Host itself might be the modal-shared-overlay or contain it */ /* If host is the overlay: */ display: none; /* Controlled by 'open' attribute/property */ position: fixed; z-index: var(--le-z-index-modal, 1001); /* Higher than admin modal if stacked */ left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: var(--le-background-color-modal-overlay, rgba(0,0,0,0.4)); /* Use flex to center the modal-shared-content if the host is the overlay */ align-items: center; justify-content: center; } :host([open][is-mobile="true"]) { display: flex; ${mobileStyles} } :host([open]) { display: flex; } /* STYLES FOR .dialog-content, .dialog-header, .dialog-body, .dialog-footer REMOVED as they are covered by .modal-shared-* classes */ /* GENERAL FORM STYLES for .form-group, label, input, select REMOVED as they are covered by .form-*-shared classes */ /* Keep styles specific to leagueMatch.js */ .score-inputs { display: flex; align-items: center; gap: var(--le-padding-s, 0.5em); } .score-inputs label { /* flex-basis: auto; */ /* If they were form-label-shared they'd be block */ margin-bottom: 0; /* Override if needed */ } .score-inputs input[type="number"] { width: 80px; /* Increased from 60px to show placeholders better */ min-width: 80px; /* Ensure minimum width even on small screens */ /* padding: var(--le-padding-xs, 0.25em); Already form-input-shared */ } /* Remove spinner buttons from number inputs */ .score-inputs input[type="number"]::-webkit-inner-spin-button, .score-inputs input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .score-inputs input[type="number"] { -moz-appearance: textfield; /* Firefox */ } /* Rink Results Styles */ .rink-results-container { margin-top: 1rem; border: 1px solid var(--le-border-color, #ccc); border-radius: var(--le-border-radius-standard); padding: 0.75rem; background-color: var(--le-background-color-light, #f9f9f9); } .rink-results-header, .rink-result-row, .rink-results-totals, .rink-points-totals { display: grid; grid-template-columns: 1.5fr 2fr 2fr; /* Rink Label, Home, Away */ gap: 0.5rem; align-items: center; padding: 0.5rem 0; } .rink-results-header div, .rink-results-totals div, .rink-points-totals div { text-align: center; /* Center header and total texts */ } .rink-results-header .rink-header-label { text-align: left; /* Rink label in header to the left */ } .rink-results-totals .rink-total-label, .rink-points-totals .rink-points-label { text-align: left; /* Total labels to the left */ font-weight: normal; } .rink-results-header { font-weight: bold; border-bottom: 1px solid var(--le-border-color, #ddd); margin-bottom: 0.5rem; padding-bottom: 0.75rem; } .rink-result-row { /* Changed from .rink-row to match JS output */ border-bottom: 1px dashed var(--le-border-color-light, #eee); } .rink-result-row:last-child { border-bottom: none; } .rink-results-totals, .rink-points-totals { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--le-border-color, #ccc); font-weight: bold; } .rink-points-totals, .match-points-totals { border-top: 1px dashed var(--le-border-color-light, #eee); /* Lighter top border for points total */ margin-top: 0.25rem; padding-top: 0.25rem; } .rink-result-row .rink-number { /* Style for the Rink X label in data rows */ text-align: left; padding-left: 0.25rem; } .rink-input { width: 100%; text-align: center; padding: 0.35rem 0.5rem !important; /* Slightly increased padding */ box-sizing: border-box; } /* Responsive adjustments for mobile */ @media (max-width: 480px) { .rink-results-header, .rink-result-row, .rink-results-totals, .rink-points-totals { grid-template-columns: 1fr 1.5fr 1.5fr; /* Adjust fr units for mobile */ font-size: 0.9rem; gap: 0.3rem; } .rink-input { padding: 0.3rem 0.25rem !important; font-size: 0.9rem !important; } .rink-results-container { padding: 0.5rem; } } /* Right-align the home score input */ .score-inputs input[type="number"]:first-of-type { text-align: right; } /* Responsive adjustment for mobile */ @media (max-width: 480px) { .modal-shared-content { width: 90% !important; /* Override any fixed width from shared styles */ max-width: 90% !important; margin: 10px auto; font-size: 16px !important; /* Base font size increase */ } .modal-shared-header { padding: 15px; font-size: 18px !important; } .modal-shared-body { padding: 15px; } .modal-shared-footer { padding: 15px; } .form-label-shared { font-size: 16px !important; margin-bottom: 8px; } .form-input-shared, .form-select-shared { font-size: 16px !important; padding: 10px !important; height: auto !important; } .form-checkbox-label-shared { font-size: 16px !important; } .score-inputs { gap: 10px; } .score-inputs input[type="number"] { width: 90px; /* Wider on mobile for touch targets */ min-width: 90px; font-size: 16px !important; padding: 10px !important; } .score-inputs span { font-size: 18px; font-weight: bold; } .button-shared { font-size: 16px !important; padding: 10px 15px !important; min-height: 44px; /* Better touch target */ margin: 5px; } #error-message-match-modal { font-size: 14px !important; padding: 10px; } .attention-banner { font-size: 14px !important; padding: 10px; } } /* Also add styles for when mobile-view class is applied regardless of screen size */ .modal-shared-content.mobile-view { width: 90% !important; /* Override any fixed width from shared styles */ max-width: 90% !important; min-width: 280px !important; margin: 10px auto; font-size: 16px !important; /* Base font size increase */ transform: scale(1.05); } .mobile-view .modal-shared-header { padding: 15px; font-size: 20px !important; } .mobile-view .modal-shared-body { padding: 15px; } .mobile-view .modal-shared-footer { padding: 15px; } .mobile-view .form-label-shared { font-size: 16px !important; margin-bottom: 10px; } .mobile-view .form-input-shared, .mobile-view .form-select-shared { font-size: 16px !important; padding: 12px !important; height: auto !important; border-radius: 6px !important; } .mobile-view .form-checkbox-label-shared { font-size: 16px !important; margin: 5px 0; } .mobile-view .score-inputs { gap: 15px; margin-top: 10px; } .mobile-view .score-inputs input[type="number"] { width: 100px; /* Wider on mobile for touch targets */ min-width: 100px; font-size: 18px !important; padding: 12px !important; border-radius: 6px !important; } .mobile-view .score-inputs span { font-size: 20px; font-weight: bold; } .mobile-view .button-shared { font-size: 18px !important; padding: 12px 20px !important; min-height: 50px; /* Better touch target */ margin: 5px; border-radius: 6px !important; } .mobile-view #error-message-match-modal { font-size: 16px !important; padding: 12px; } .mobile-view .attention-banner { font-size: 16px !important; padding: 12px; } #error-message-match-modal { color: var(--le-text-color-error, #D8000C); background-color: var(--le-background-color-error, #FFD2D2); padding: var(--le-padding-s); border: 1px solid var(--le-border-color-error, #D8000C); border-radius: var(--le-border-radius-standard); margin-bottom: var(--le-padding-m); font-size: var(--le-font-size-small); } .attention-banner { background-color: var(--le-color-status-warning, #f39c12); color: var(--le-text-color-on-primary, #fff); padding: var(--le-padding-s, 0.5em); border-bottom: 1px solid var(--le-border-color-dark, #ccc); text-align: center; font-size: var(--le-font-size-small, 0.85em); border-top-left-radius: var(--le-border-radius-standard); border-top-right-radius: var(--le-border-radius-standard); } `; /** * Safely register a custom element, avoiding duplicate registration errors * in single-page applications where modules may be loaded multiple times. * * @param {string} tagName - The custom element tag name (e.g., 'league-element') * @param {CustomElementConstructor} elementClass - The element class constructor * @param {boolean} [logRegistration=false] - Whether to log successful registrations */ function safeDefine(tagName, elementClass, logRegistration = false) { if (!customElements.get(tagName)) { customElements.define(tagName, elementClass); if (logRegistration) { console.log(`[ElementRegistry] Registered custom element: ${tagName}`); } } else if (logRegistration) { console.log(`[ElementRegistry] Custom element already registered: ${tagName}`); } } // leagueMatch.js // Modal dialog for creating/updating a match class LeagueMatchEvent extends CustomEvent { constructor(type, detail) { super(type, { detail, bubbles: true, composed: true }); } } class LeagueMatch extends HTMLElement { // Or extends LitElement // static get BASE_STYLES() { // Or static styles for LitElement // REMOVED // REMOVED ALL BASE_STYLES CONTENT // } static get observedAttributes() { return ['open', 'is-mobile', 'mode', 'attention-reason', 'leagueSettings']; // Added leagueSettings } constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); this._match = null; this._teams = []; this._open = false; this._isMobile = false; this._mode = 'new'; this._error = ''; this._attentionReason = null; this._boundOnKeydown = this._onKeydown.bind(this); this._firstFocusableElement = null; this._lastFocusableElement = null; // Rink scoring properties are derived from _leagueSettings via getters this._rinkResults = []; this._leagueSettings = {}; // Initialize _leagueSettings } /** * @param {Object} value */ set match(value) { this._match = value; this._initializeRinkResults(); // Centralize rink results initialization this.render(); } _initializeRinkResults() { if (this.rinkPointsEnabled && this._match) { if (this._match.result?.rinkResults && this._match.result.rinkResults.length > 0) { // Use existing rink results from the match if they are valid for the current number of rinks if (this._match.result.rinkResults.length === this.defaultRinks) { this._rinkResults = JSON.parse(JSON.stringify(this._match.result.rinkResults)); } else { console.warn('[LeagueMatch] Mismatch between saved rinks and default rinks. Re-initializing.'); this._rinkResults = this._generateDefaultRinkResults(); } } else { // Initialize with default values or distribute existing simple scores this._rinkResults = this._generateDefaultRinkResults(); } } else { this._rinkResults = []; // Clear if rink points not enabled or no match } } _generateDefaultRinkResults() { let results = Array.from({ length: this.defaultRinks }, (_, i) => ({ rinkNumber: i + 1, homeShots: 0, awayShots: 0 })); // If simple scores exist and we are initializing rinks for the first time for this match data, // attempt to distribute them. This is a basic distribution. if (this._match && this._match.result && (this._match.result.homeScore != null || this._match.result.awayScore != null) && (!this._match.result.rinkResults || this._match.result.rinkResults.length === 0)) { const homeTotal = parseInt(this._match.result.homeScore, 10) || 0; const awayTotal = parseInt(this._match.result.awayScore, 10) || 0; if (this.defaultRinks > 0 && (homeTotal > 0 || awayTotal > 0)) { const homePerRink = Math.floor(homeTotal / this.defaultRinks); const awayPerRink = Math.floor(awayTotal / this.defaultRinks); let homeRemainder = homeTotal % this.defaultRinks; let awayRemainder = awayTotal % this.defaultRinks; results = results.map(rink => { rink.homeShots = homePerRink; rink.awayShots = awayPerRink; return rink; }); for (let i = 0; i < homeRemainder; i++) { if (results[i]) results[i].homeShots++; } for (let i = 0; i < awayRemainder; i++) { if (results[i]) results[i].awayShots++; } } } return results; } get match() { return this._match; } /** * @param {Object} value - The league settings object */ set leagueSettings(value) { const oldSettingsString = JSON.stringify(this._leagueSettings); this._leagueSettings = value && typeof value === 'object' ? value : {}; const newSettingsString = JSON.stringify(this._leagueSettings); // Internal convenience properties for rink/match points have been removed. // The component will now use getters that read directly from _leagueSettings. if (newSettingsString !== oldSettingsString) { // If rink points are enabled and match data exists, ensure rink results are consistent if (this.rinkPointsEnabled && this._match) { // Check if defaultRinks changed or if rinkPointsEnabled status itself changed const oldRinkPointsEnabled = JSON.parse(oldSettingsString)?.rinkPoints?.enabled; const oldDefaultRinks = JSON.parse(oldSettingsString)?.rinkPoints?.defaultRinks || 4; if (this.rinkPointsEnabled !== oldRinkPointsEnabled || this.defaultRinks !== oldDefaultRinks) { this._initializeRinkResults(); // Re-initialize based on new settings } } else if (!this.rinkPointsEnabled) { this._rinkResults = []; // Clear rink results if rink points get disabled } if (this.open) { this.render(); } } } get leagueSettings() { return this._leagueSettings; } // Getters for rink configuration, derived from leagueSettings get rinkPointsEnabled() { return !!this._leagueSettings?.rinkPoints?.enabled; } get defaultRinks() { return this._leagueSettings?.rinkPoints?.defaultRinks || 4; } get pointsPerRinkWin() { return this._leagueSettings?.rinkPoints?.pointsPerRinkWin || 2; } get pointsPerRinkDraw() { return this._leagueSettings?.rinkPoints?.pointsPerRinkDraw || 1; } // Getters for match points configuration, derived from leagueSettings get pointsForMatchWin() { return this._leagueSettings?.pointsForWin || 0; } get pointsForMatchDraw() { return this._leagueSettings?.pointsForDraw || 0; } /** * @param {Array<string>} value */ set teams(value) { this._teams = Array.isArray(value) ? value : []; this.render(); } get teams() { return this._teams; } /** * @param {boolean} value */ set open(value) { const Rerender = this._open !== !!value; this._open = !!value; this.setAttribute('open', this._open.toString()); if (Rerender) { this.render(); } if (this._open) { this.shadowRoot.addEventListener('keydown', this._boundOnKeydown); setTimeout(() => this._focusFirstElement(), 0); } else { this.shadowRoot.removeEventListener('keydown', this._boundOnKeydown); } } get open() { return this._open; } /** * @param {boolean} value */ set isMobile(value) { const oldValue = this._isMobile; this._isMobile = !!value; this.setAttribute('is-mobile', this._isMobile ? 'true' : 'false'); if (oldValue !== this._isMobile) { this.render(); } } get isMobile() { return this._isMobile; } /** * @param {'edit'|'new'} value */ set mode(value) { this._mode = value === 'edit' ? 'edit' : 'new'; this.render(); } get mode() { return this._mode; } /** * @param {string | null} value */ set attentionReason(value) { if (this._attentionReason !== value) { this._attentionReason = value; this.render(); } } get attentionReason() { return this._attentionReason; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; let shouldRender = false; if (name === 'open') { const newOpenState = newValue !== null && newValue !== 'false'; if (this._open !== newOpenState) { this._open = newOpenState; shouldRender = true; } } if (name === 'is-mobile') { const newIsMobileState = newValue !== null && newValue !== 'false'; if (this._isMobile !== newIsMobileState) { this._isMobile = newIsMobileState; shouldRender = true; } } if (name === 'mode') { this._mode = newValue === 'edit' ? 'edit' : 'new'; shouldRender = true; } if (name === 'attention-reason') { this._attentionReason = newValue; shouldRender = true; } if (shouldRender) { this.render(); } } connectedCallback() { this.render(); } /** * Show an error message in the modal. * @param {string} msg */ showError(msg) { this._error = msg; this.render(); } /** * Clear the error message. */ clearError() { this._error = ''; this.render(); } /** * Handle OK button click: validate and emit 'match-save' event. * @private */ _onOk() { // Get form values const homeTeamId = this.shadow.getElementById('homeTeam')?.value; const awayTeamId = this.shadow.getElementById('awayTeam')?.value; const matchDate = this.shadow.getElementById('matchDate')?.value; const isPlayed = this.shadow.getElementById('isPlayed')?.checked || false; // Validate required fields if (!homeTeamId || !awayTeamId || !matchDate) { const errorMsg = 'Please fill in all required fields'; console.error(errorMsg, { homeTeamId, awayTeamId, matchDate }); this.showError(errorMsg); return; } if (homeTeamId === awayTeamId) { const errorMsg = 'Home and away teams must be different'; console.error(errorMsg, { homeTeamId, awayTeamId }); this.showError(errorMsg); return; } // Create match object with basic information const match = { ...(this._match || {}) }; // Find the team objects from their IDs to get their names const homeTeam = this._teams.find(team => team._id === homeTeamId); const awayTeam = this._teams.find(team => team._id === awayTeamId); match.homeTeam = { _id: homeTeamId, name: homeTeam?.name || homeTeamId // Use name if available, fallback to ID }; match.awayTeam = { _id: awayTeamId, name: awayTeam?.name || awayTeamId // Use name if available, fallback to ID }; match.date = matchDate; // Handle result if match is played if (isPlayed) { // Initialize the result object match.result = { played: true, rinkPointsUsed: this.rinkPointsEnabled }; if (this.rinkPointsEnabled) { // Process rink-based scoring // Filter out any invalid rink results const validRinkResults = (this._rinkResults || []).filter(rink => rink && (rink.homeShots !== undefined || rink.awayShots !== undefined)); if (validRinkResults.length === 0) { const errorMsg = 'Please enter scores for at least one rink'; console.error(errorMsg); this.showError(errorMsg); return; } // Calculate scores from rink results const homeScore = validRinkResults.reduce((sum, rink) => sum + (parseInt(rink.homeShots, 10) || 0), 0); const awayScore = validRinkResults.reduce((sum, rink) => sum + (parseInt(rink.awayShots, 10) || 0), 0); // Calculate points based on rink results const homePoints = this._calculateTotalPoints('home'); const awayPoints = this._calculateTotalPoints('away'); // Add scores and points to result match.result.homeScore = homeScore; match.result.awayScore = awayScore; match.result.homePoints = homePoints; match.result.awayPoints = awayPoints; // Add rink results match.result.rinkResults = validRinkResults.map(rink => ({ rinkNumber: rink.rinkNumber || 0, homeShots: parseInt(rink.homeShots, 10) || 0, awayShots: parseInt(rink.awayShots, 10) || 0 })); } else { // Process simple scoring (non-rink points) const homeScore = parseInt(this.shadow.getElementById('homeScore')?.value, 10); const awayScore = parseInt(this.shadow.getElementById('awayScore')?.value, 10); if (isNaN(homeScore) || isNaN(awayScore)) { const errorMsg = 'Please enter valid scores'; console.error(errorMsg, { homeScore, awayScore }); this.showError(errorMsg); return; } // Calculate points (2 for win, 1 for draw, 0 for loss) const homePoints = homeScore > awayScore ? 2 : homeScore === awayScore ? 1 : 0; const awayPoints = awayScore > homeScore ? 2 : homeScore === awayScore ? 1 : 0; match.result.homeScore = homeScore; match.result.awayScore = awayScore; match.result.homePoints = homePoints; match.result.awayPoints = awayPoints; } } else { // If match is not played, keep date but clear result match.result = null; } this.dispatchEvent(new LeagueMatchEvent('match-save', { match })); this.open = false; } /** * Handle Cancel button click: emit 'match-cancel' event. * @private */ _onCancel() { this.clearError(); this.dispatchEvent(new LeagueMatchEvent('match-cancel', {})); this.open = false; } render() { const isMobileView = this._isMobile || false; // Determine if host itself should act as overlay or if it contains an overlay div. // For this example, host itself will be the overlay when open. // The actual dialog box will be modal-shared-content. const title = this._mode === 'edit' ? 'Edit Match' : 'Add Match'; const homeTeamId = this._match?.homeTeam?._id || ''; const awayTeamId = this._match?.awayTeam?._id || ''; const matchDate = this._match?.date ? new Date(this._match.date).toISOString().split('T')[0] : ''; const homeScore = this._match?.result?.homeScore !== undefined && this._match?.result?.homeScore !== null ? this._match.result.homeScore : ''; const awayScore = this._match?.result?.awayScore !== undefined && this._match?.result?.awayScore !== null ? this._match.result.awayScore : ''; const isPlayed = this._match?.result?.played !== undefined ? this._match.result.played : homeScore !== '' || awayScore !== ''; const attentionBannerHTML = this._attentionReason ? `<div class="attention-banner">${this._escapeHtml(this._attentionReason)}</div>` : ''; // MODIFIED: Add mobile class to modal content this.shadow.innerHTML = ` <style> ${BASE_STYLES} </style> <div class="modal-shared-content ${this._open ? 'modal-is-open' : 'modal-is-closed'} ${isMobileView ? 'mobile-view' : ''}" role="dialog" aria-labelledby="match-modal-title" aria-modal="true" style="display: ${this._open ? 'flex' : 'none'};"> ${attentionBannerHTML} <div class="modal-shared-header"> <span id="match-modal-title">${title}</span> <button class="close-button-shared" id="close-match-modal" aria-label="Close dialog">&times;</button> </div> <div class="modal-shared-body"> <div id="error-message-match-modal" style="display: ${this._error ? 'block' : 'none'};">${this._escapeHtml(this._error)}</div> <div class="form-group-shared"> <label for="homeTeam" class="form-label-shared">Home Team</label> <select id="homeTeam" class="form-select-shared"> <option value="">Select Home Team</option> ${this._teams.map(team => { return `<option value="${this._escapeHtml(team._id)}" ${team._id === homeTeamId ? 'selected' : ''}>${this._escapeHtml(team.name)}</option>`; }).join('')} </select> </div> <div class="form-group-shared"> <label for="awayTeam" class="form-label-shared">Away Team</label> <select id="awayTeam" class="form-select-shared"> <option value="">Select Away Team</option> ${this._teams.map(team => { return `<option value="${this._escapeHtml(team._id)}" ${team._id === awayTeamId ? 'selected' : ''}>${this._escapeHtml(team.name)}</option>`; }).join('')} </select> </div> <div class="form-group-shared"> <label for="matchDate" class="form-label-shared">Date</label> <input type="date" id="matchDate" class="form-input-shared" value="${matchDate}"> </div> <div class="form-group-shared"> <label class="form-checkbox-label-shared"> <input type="checkbox" id="isPlayed" ${isPlayed ? 'checked' : ''}> Match Played? </label> </div> ${isPlayed ? this.rinkPointsEnabled ? ` <div class="rink-results-container"> <div class="rink-results-header"> <div class="rink-header-label">Rink</div> <div>${this._teams.find(team => team._id === homeTeamId)?.name || 'Home'}</div> <div>${this._teams.find(team => team._id === awayTeamId)?.name || 'Away'}</div> </div> ${this._renderRinkResults()} <div class="rink-results-totals shots-totals"> <div class="rink-total-label">Shots</div> <div id="homeTotalShots">0</div> <div id="awayTotalShots">0</div> </div> <div class="rink-results-totals rink-points-totals"> <div class="rink-points-label">Rink Points</div> <div id="homeRinkPoints">0.0</div> <div id="awayRinkPoints">0.0</div> </div> <div class="rink-results-totals match-points-totals"> <div class="rink-points-label">Match Points</div> <div id="homeMatchPoints">0.0</div> <div id="awayMatchPoints">0.0</div> </div> <div class="rink-results-totals final-total-points-totals"> <div class="rink-points-label">Total Points</div> <div id="homeFinalTotalPoints">0.0</div> <div id="awayFinalTotalPoints">0.0</div> </div> </div> ` : ` <div class="form-group-shared score-inputs-container"> <label class="form-label-shared">Score</label> <div class="score-inputs"> <input type="number" id="homeScore" class="form-input-shared" placeholder="Home" value="${homeScore}" aria-label="Home team score"> <span>-</span> <input type="number" id="awayScore" class="form-input-shared" placeholder="Away" value="${awayScore}" aria-label="Away team score"> </div> </div> ` : ''} </div> <div class="modal-shared-footer"> <button id="cancel-button" class="button-shared button-secondary-light">Cancel</button> <button id="ok-button" class="button-shared button-primary">OK</button> </div> </div> `; // Event listeners this.shadow.querySelector('#ok-button').addEventListener('click', () => this._onOk()); this.shadow.querySelector('#cancel-button').addEventListener('click', () => this._onCancel()); this.shadow.querySelector('#close-match-modal').addEventListener('click', () => this._onCancel()); const homeTeamSelect = this.shadow.querySelector('#homeTeam'); const awayTeamSelect = this.shadow.querySelector('#awayTeam'); homeTeamSelect.addEventListener('change', () => this._updateDisabledOptions(homeTeamSelect, awayTeamSelect)); awayTeamSelect.addEventListener('change', () => this._updateDisabledOptions(awayTeamSelect, homeTeamSelect)); this._updateDisabledOptions(homeTeamSelect, awayTeamSelect); // Initial sync const isPlayedCheckbox = this.shadow.querySelector('#isPlayed'); this.shadow.querySelector('.score-inputs-container'); this.shadow.querySelector('.rink-results-container'); isPlayedCheckbox.addEventListener('change', e => { const isChecked = e.target.checked; // Preserve the date value before re-render const dateInput = this.shadow.querySelector('#matchDate'); if (dateInput) { this._match = this._match || {}; this._match.date = dateInput.value || null; } if (!this._match) this._match = {}; // Ensure _match exists if (!this._match.result) this._match.result = {}; // Ensure _match.result exists this._match.result.played = isChecked; if (!isChecked) { // Clear standard score inputs if they exist in the DOM const homeScoreInput = this.shadow.querySelector('#homeScore'); const awayScoreInput = this.shadow.querySelector('#awayScore'); if (homeScoreInput) homeScoreInput.value = ''; if (awayScoreInput) awayScoreInput.value = ''; // Clear simple score in match data if (this._match.result) { this._match.result.homeScore = null; this._match.result.awayScore = null; } // Clear rink results data if (this.rinkPointsEnabled) { this._rinkResults = this._rinkResults.map(rink => ({ ...rink, homeShots: '', // Clear to empty string for input fields awayShots: '' // Clear to empty string for input fields })); } } // Re-render the component to reflect the change this.render(); }); // Initialize rink results if needed when rinkPoints is enabled if (this.rinkPointsEnabled) { if (this._rinkResults.length === 0) { // Initialize with default values if no results exist this._rinkResults = Array.from({ length: this.defaultRinks }, (_, i) => ({ rinkNumber: i + 1, homeShots: 0, awayShots: 0 })); } else if (this._rinkResults.length !== this.defaultRinks) { // Adjust the number of rinks if defaultRinks has changed if (this._rinkResults.length < this.defaultRinks) { // Add new rinks const newRinks = Array.from({ length: this.defaultRinks - this._rinkResults.length }, (_, i) => ({ rinkNumber: this._rinkResults.length + i + 1, homeShots: 0, awayShots: 0 })); this._rinkResults = [...this._rinkResults, ...newRinks]; } else { // Remove extra rinks this._rinkResults = this._rinkResults.slice(0, this.defaultRinks); } } } // Add event listeners for rink inputs if (this.rinkPointsEnabled) { const rinkInputs = this.shadow.querySelectorAll('.rink-input'); rinkInputs.forEach(input => { input.addEventListener('input', e => { const rinkNum = parseInt(e.target.dataset.rink, 10); const team = e.target.dataset.team; const value = e.target.value === '' ? 0 : parseInt(e.target.value, 10); // Update the rink results const rinkIndex = this._rinkResults.findIndex(r => r.rinkNumber === rinkNum); this._onRinkInput(rinkIndex, team, value); }); }); // Update totals after initial render this._updateRinkTotals(); } // Error message display update const errorDiv = this.shadow.querySelector('#error-message-match-modal'); if (errorDiv) { errorDiv.style.display = this._error ? 'block' : 'none'; errorDiv.textContent = this._error ? this._escapeHtml(this._error) : ''; } } /** * Handles input changes for rink results * @param {number} rinkIndex - Index of the rink in the _rinkResults array * @param {string} field - Field to update ('homeShots' or 'awayShots') * @param {string|number} value - New value for the field */ _onRinkInput(rinkIndex, field, value) { if (rinkIndex < 0 || !this._rinkResults[rinkIndex]) { console.warn(`Invalid rink index: ${rinkIndex}`); return; } // Convert value to number and ensure it's not negative const numValue = Math.max(0, parseInt(value, 10) || 0); // Only update if the value has changed if (this._rinkResults[rinkIndex][field] === numValue) { return; // No change needed } // Update the rink result this._rinkResults[rinkIndex][field] = numValue; // Find the rink by number in case the index has changed const rinkNumber = this._rinkResults[rinkIndex].rinkNumber; // Update the input value to ensure it's a valid number const input = this.shadowRoot?.querySelector(`.rink-input[data-rink="${rinkNumber}"][data-team="${field}"]`); if (input) { // Only update if the value is different to avoid cursor jumping if (parseInt(input.value, 10) !== numValue) { input.value = numValue || ''; } } else { console.warn(`Input not found for rink ${rinkNumber}, field ${field}`); } // Update the totals display this._updateRinkTotals(); // Ensure the match played checkbox is checked when entering scores const isPlayedCheckbox = this.shadow.querySelector('#isPlayed'); if (isPlayedCheckbox && !isPlayedCheckbox.checked) { isPlayedCheckbox.checked = true; // Trigger change event to update the UI isPlayedCheckbox.dispatchEvent(new Event('change')); } } /** * Renders the rink results rows * @returns {string} HTML string for the rink results */ _renderRinkResults() { if (!this._rinkResults || this._rinkResults.length === 0) { console.warn('No rink results to render'); return ''; } return this._rinkResults.sort((a, b) => (a.rinkNumber || 0) - (b.rinkNumber || 0)).map((rink, index) => { const rinkNumber = rink.rinkNumber || index + 1; const homeShots = rink.homeShots != null ? rink.homeShots : ''; const awayShots = rink.awayShots != null ? rink.awayShots : ''; // Ensure the values are valid numbers or empty strings for the input const homeValue = homeShots === '' ? '' : parseInt(homeShots, 10); const awayValue = awayShots === '' ? '' : parseInt(awayShots, 10); return ` <div class="rink-result-row" data-rink-number="${rinkNumber}"> <div class="rink-number">${rinkNumber}</div> <div class="rink-home"> <input type="number" class="rink-input" min="0" value="${homeValue}" data-rink="${rinkNumber}" data-team="homeShots" aria-label="Rink ${rinkNumber} home shots"> </div> <div class="rink-away"> <input type="number" class="rink-input" min="0" value="${awayValue}" data-rink="${rinkNumber}" data-team="awayShots" aria-label="Rink ${rinkNumber} away shots"> </div> </div> `; }).join(''); } /** * Updates the total shots and points based on rink results */ _updateRinkTotals() { // Only proceed if rink points are enabled and we have results if (!this.rinkPointsEnabled || !this._rinkResults?.length) { console.debug('[LeagueMatch] Skipping rink totals update - rink points not enabled or no results'); return; } // Check if we have the required DOM elements before proceeding const totalsContainer = this.shadowRoot?.querySelector('.rink-results-container'); if (!totalsContainer) { console.debug('[LeagueMatch] Skipping rink totals update - rink results container not found'); return; } console.debug('[LeagueMatch] Updating rink totals with results:', JSON.stringify(this._rinkResults)); let homeTotalShots = 0; let awayTotalShots = 0; let homeRinkPoints = 0; let awayRinkPoints = 0; this._rinkResults.forEach(rink => { if (!rink) return; const homeShots = typeof rink.homeShots === 'number' ? rink.homeShots : parseInt(rink.homeShots, 10) || 0; const awayShots = typeof rink.awayShots === 'number' ? rink.awayShots : parseInt(rink.awayShots, 10) || 0; homeTotalShots += homeShots; awayTotalS