@lovebowls/leagueelements
Version:
League Elements package for LoveBowls
1,371 lines (1,245 loc) • 61.9 kB
JavaScript
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">×</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