UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,371 lines (1,245 loc) 61.9 kB
var LeagueMatch = (function () { 'use strict'; const baseFontSizeConstants = (fontScale = 1.0) => ` /* Base font sizes for different contexts */ --le-font-size-base-desktop: ${16 * fontScale}px; --le-font-size-base-mobile: ${18 * fontScale}px; line-height: 1.2; `; // Shared font size base variables and UI element font sizes (used internally) const getFontSizeElementVariables = (isMobile = false) => ` /* Specific sizes for common UI elements */ --le-font-size-button: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-label: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-input: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-table-header: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-table-cell: 0.7em; --le-font-size-dropdown: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-padding-xs: 0.25rem; --le-padding-s: 0.6rem; --le-padding-m: 1rem; `; const getMobileStyles = (fontScale = 1.0) => ` :host { ${baseFontSizeConstants(fontScale)} font-size: var(--le-font-size-base-mobile); /* Mobile-specific styling that can be added to host elements */ --le-font-size-base: 1em; --le-font-size-xs: 0.6em; --le-font-size-small: 0.8em; --le-font-size-medium: 1.0em; --le-font-size-large: 1.2em; --le-font-size-xlarge: 1.4em; --le-font-size-xxlarge: 1.6em; ${getFontSizeElementVariables(true)} .no-data padding: var(--le-padding-m, 1rem); color: var(--le-text-color-secondary, #666); background-color: transparent; border: none; } .controls-panel { gap: var(--le-padding-s, 0.5rem); } /* Mobile-specific dropdown styling */ .controls-panel .filter-controls .dropdown-shared { flex: 1; } .controls-panel .dropdown-shared { flex: 1; } .controls-panel .dropdown-shared .dropdown-select-shared { width: 100%; padding: var(--le-padding-s, 0.75rem) calc(var(--le-padding-m, 1rem) * 2.5) var(--le-padding-s, 0.75rem) var(--le-padding-m, 1rem); min-height: 44px; /* Minimum touch target size */ background-size: 1.2rem; background-position: right 1rem center; border-width: 2px; } .controls-panel .dropdown-select-shared:focus { border-width: 2px; } /* Input fields on mobile */ .form-input-shared, .form-select-shared, { min-height: 44px; /* Standard mobile touch target */ padding: var(--le-padding-xs, 0.5rem) var(--le-padding-m, 1rem); font-size: var(--le-font-size-medium, 1.0em); border-width: 2px; width: 100% !important; /* Force width consistency on mobile */ max-width: 100%; box-sizing: border-box; } /* Specific handling for date inputs on mobile */ .form-input-shared[type="date"] { -webkit-appearance: none; -moz-appearance: textfield; appearance: none; } .form-input-shared:focus, .form-select-shared:focus { border-width: 2px; } .form-label-shared { margin-bottom: var(--le-padding-s, 0.75rem); } .resizer { display: none !important; } } `; const getDesktopStyles = (fontScale = 1.0) => ` :host { ${baseFontSizeConstants(fontScale)} font-size: var(--le-font-size-base-desktop); /* Standardized font sizes for desktop - these cascade to all sub-components */ --le-font-size-base: 1em; --le-font-size-xs: 0.75em; --le-font-size-small: 0.9em; --le-font-size-medium: 1.0em; --le-font-size-large: 1.2em; --le-font-size-xlarge: 1.4em; --le-font-size-xxlarge: 1.6em; ${getFontSizeElementVariables(false)} .no-data padding: var(--le-padding-m, 1rem); color: var(--le-text-color-secondary, #666); background-color: transparent; border: none; } /* Desktop-specific dropdown styling */ .controls-panel .filter-controls .dropdown-shared { width: auto; min-width: 200px; } .controls-panel .dropdown-shared .dropdown-select-shared { min-width: 160px; width: auto; } .resizer { width: 5px; min-width: 5px; cursor: col-resize; background-color: var(--swal-background-color-header); border-left: 1px solid var(--swal-border-color-light); border-right: 1px solid var(--swal-border-color-light); z-index: 10; } .resizer:hover { background: var(--le-text-color-secondary); } } `; const buttonStyles = ` .button-shared { padding: 4px 8px; 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-button, var(--le-font-size-medium, 1.15em)); /* Use variable with fallback */ 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 */ } /* 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)); padding-top: 5%; padding-bottom: 5%; } .modal-shared-overlay.open { display: flex; justify-content: center; } .modal-shared-content { background-color: var(--le-background-color-panel, #fff); margin: 10% auto; /* Default to 10% from top, centered */ padding: 0; border: 1px solid var(--le-border-color-dark, #ccc); 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; align-self: flex-start; } /* Specific styles for modal mobile-view class */ .modal-shared-content.mobile-view { width: 90%; /* Override any fixed width from shared styles */ margin: 0; /* Less margin from top on mobile */ max-width: 90%; min-width: 280px !important; } .modal-shared-header { 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: var(--le-font-size-xlarge, 1.8em); /* 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-m, 1.25em); 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-label, 1em)); /* Use variable with fallback */ } .form-input-shared, .form-select-shared { width: 100%; height: auto; padding: var(--le-padding-s, 0.75em); 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-input, 1em)); /* Use variable with fallback */ color: var(--le-text-color-primary, #333); background-color: var(--le-background-color-panel, #fff); } /* Ensure date inputs have consistent styling across browsers */ .form-input-shared[type="date"] { max-width: 100%; } .form-input-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 */ 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 */ } /* Enhanced checkbox styles for better mobile usability */ .checkbox-enhanced-shared { position: relative; display: inline-block; cursor: pointer; user-select: none; margin-right: var(--le-padding-s, 0.75em); } .checkbox-enhanced-shared input[type="checkbox"] { position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; } .checkbox-enhanced-shared .checkmark-shared { position: relative; display: inline-block; width: 1.5em; height: 1.5em; background-color: var(--le-background-color-panel, #fff); border: 2px solid var(--le-border-color-dark, #ccc); border-radius: var(--le-border-radius-small, 3px); transition: all 0.2s ease; vertical-align: middle; margin-right: var(--le-padding-xs, 0.25em); } /* Mobile-specific larger checkboxes */ @media (max-width: 768px) { .checkbox-enhanced-shared .checkmark-shared { width: 2em; height: 2em; border-width: 2px; } } /* Hover state */ .checkbox-enhanced-shared:hover input[type="checkbox"] ~ .checkmark-shared { border-color: var(--le-text-color-accent, #2196f3); background-color: var(--le-background-color-row-hover, #f9f9f9); } /* Focus state */ .checkbox-enhanced-shared input[type="checkbox"]:focus ~ .checkmark-shared { border-color: var(--le-text-color-accent, #2196f3); box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); } /* Checked state */ .checkbox-enhanced-shared input[type="checkbox"]:checked ~ .checkmark-shared { background-color: var(--le-text-color-accent, #2196f3); border-color: var(--le-text-color-accent, #2196f3); } /* Checkmark icon */ .checkbox-enhanced-shared .checkmark-shared:after { content: ""; position: absolute; display: none; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(45deg); width: 0.4em; height: 0.8em; border: solid var(--le-text-color-on-primary, #fff); border-width: 0 0.15em 0.15em 0; } /* Mobile-specific larger checkmark */ @media (max-width: 768px) { .checkbox-enhanced-shared .checkmark-shared:after { width: 0.5em; height: 1em; border-width: 0 0.2em 0.2em 0; } } /* Show checkmark when checked */ .checkbox-enhanced-shared input[type="checkbox"]:checked ~ .checkmark-shared:after { display: block; } /* Disabled state */ .checkbox-enhanced-shared input[type="checkbox"]:disabled ~ .checkmark-shared { background-color: var(--le-background-color-button-disabled, #eee); border-color: var(--le-border-color-medium, #ddd); cursor: not-allowed; } .checkbox-enhanced-shared input[type="checkbox"]:disabled ~ .checkmark-shared:after { border-color: var(--le-text-color-secondary, #aaa); } /* Legend/checkbox list styles for responsive layouts */ .checkbox-list-responsive-shared { display: grid; gap: var(--le-padding-s, 0.75em); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } /* Mobile-specific responsive checkbox list */ @media (max-width: 768px) { .checkbox-list-responsive-shared { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--le-padding-m, 1em); } } /* Extra small screens - force 2 columns */ @media (max-width: 480px) { .checkbox-list-responsive-shared { grid-template-columns: 1fr 1fr; gap: var(--le-padding-s, 0.75em); } } /* Legend item styling for checkbox lists */ .legend-item-shared { display: flex; align-items: center; font-size: var(--le-font-size-small, 0.9em); padding: var(--le-padding-xs, 0.25em); border-radius: var(--le-border-radius-small, 3px); transition: background-color 0.2s ease; } .legend-item-shared:hover { background-color: var(--le-background-color-row-hover, #f9f9f9); } /* Mobile-specific legend item styling */ @media (max-width: 768px) { .legend-item-shared { font-size: var(--le-font-size-medium, 1.2em); padding: var(--le-padding-s, 0.75em) var(--le-padding-xs, 0.25em); min-height: 3em; } } `; 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]) { 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); flex-wrap: wrap; } .score-inputs label { margin-bottom: 0; /* Override if needed */ } .score-inputs input[type="number"] { width: 80px; /* Increased from 60px to show placeholders better */ flex: 1 1 60px; } /* 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: 2fr 1.5fr 1.5fr; /* Rink Label, Home, Away */ gap: 0.4rem; align-items: center; padding: 0.3rem 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: center; /* Rink label in header to the left */ } .rink-results-totals .rink-total-label, .rink-points-totals .rink-points-label, .match-points-totals .rink-points-label, .final-total-points-totals .rink-points-label { text-align: center; /* Total labels to the left */ font-weight: normal; font-size: var(--le-font-size-small, 0.8em); } .final-total-points-totals { font-weight: bold; font-size: var(--le-font-size-medium, 1em); } .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); text-align: left; } .rink-points-totals, .match-points-totals { text-align: left; 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: center; 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 */ gap: 0.3rem; } .rink-input { padding: 0.3rem 0.25rem !important; } .rink-results-container { padding: 0.5rem; } } /* Right-align the home score input */ .score-inputs input[type="number"]:first-of-type { text-align: right; } /* Rink number input styling */ #rinkNumber { width: 50%; } #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); } .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; 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}`); } } class LeagueMatchEvent extends CustomEvent { constructor(type, detail) { super(type, { detail, bubbles: true, composed: true }); } } class LeagueMatch extends HTMLElement { // Or extends LitElement 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; } get pointsForMatchLoss() { return this._leagueSettings?.pointsForLoss || 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 rinkNumber = this.shadow.getElementById('rinkNumber')?.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 optional rink number if (rinkNumber && rinkNumber.trim() !== '') { const rinkNum = parseInt(rinkNumber, 10); if (!isNaN(rinkNum) && rinkNum > 0) { match.rink = rinkNum; } } else { // Remove rink property if no rink is specified delete match.rink; } // 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 based on league settings const homePoints = homeScore > awayScore ? this.pointsForMatchWin : homeScore === awayScore ? this.pointsForMatchDraw : this.pointsForMatchLoss; const awayPoints = awayScore > homeScore ? this.pointsForMatchWin : homeScore === awayScore ? this.pointsForMatchDraw : this.pointsForMatchLoss; 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 rinkNumber = this._match?.rink !== undefined && this._match?.rink !== null ? this._match.rink : ''; 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} ${this._isMobile ? getMobileStyles() : getDesktopStyles()} </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 for="rinkNumber" class="form-label-shared">Rink (Optional)</label> <input type="number" id="rinkNumber" class="form-input-shared" value="${rinkNumber}" placeholder="Enter rink number" min="1" max="24"> </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</div> <div id="awayRinkPoints">0</div> </div> <div class="rink-results-totals match-points-totals"> <div class="rink-points-label">Match Points</div> <div id="homeMatchPoints">0</div> <div id="awayMatchPoints">0</div> </div> <div class="rink-results-totals final-total-points-totals"> <div class="rink-points-label">Total Points</div> <div id="homeFinalTotalPoints">0</div> <div id="awayFinalTotalPoints">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; } // Co