import { rectangle , line , ellipse } from './wired-lib' ; import { randomSeed , fireEvent } from './wired-base' ; import { css , TemplateResult , html , LitElement , PropertyValues } from 'lit' ; import { customElement , property } from 'lit/decorators.js' ; interface AreaSize { width : number ; height : number ; } interface CalendarCell { value : string ; text : string ; selected : boolean ; dimmed : boolean ; disabled ? : boolean ; } const SECOND = 1000 ; const MINUTE = SECOND * 60 ; const HOUR = MINUTE * 60 ; const DAY = HOUR * 24 ; const TABLE_PADDING = 8 ; @ customElement ( 'wired-calendar' ) export class WiredCalendar extends LitElement { @ property ( { type : Number } ) elevation = 3 ; @ property ( { type : String } ) selected ? : string ; @ property ( { type : String } ) firstdate ? : string ; @ property ( { type : String } ) lastdate ? : string ; @ property ( { type : String } ) locale ? : string ; @ property ( { type : Boolean , reflect : true } ) disabled = false ; @ property ( { type : Boolean , reflect : true } ) initials = false ; @ property ( { type : Object } ) value ? : { date : Date ; text : string ; } ; @ property ( { type : Function } ) format : Function = ( d ) => this . months_short [ d . getMonth ( ) ] + ' ' + d . getDate ( ) + ', ' + d . getFullYear ( ) ; private weekdays_short : string [ ] = [ 'Sun' , 'Mon' , 'Tue' , 'Wed' , 'Thu' , 'Fri' , 'Sat' ] ; private months : string [ ] = [ 'January' , 'February' , 'March' , 'April' , 'May' , 'June' , 'July' , 'August' , 'September' , 'October' , 'November' , 'December' ] ; private months_short : string [ ] = [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ] ; private resizeHandler ? : EventListenerOrEventListenerObject ; private firstOfMonthDate : Date = new Date ( ) ; private fDate : Date | undefined = undefined ; private lDate : Date | undefined = undefined ; private calendarRefSize : AreaSize = { width : 0 , height : 0 } ; private tblColWidth : number = 0 ; private tblRowHeight : number = 0 ; private tblHeadHeight : number = 0 ; private monthYear : string = '' ; private weeks : CalendarCell [ ] [ ] = [ [ ] ] ; private seed = randomSeed ( ) ; connectedCallback ( ) { super . connectedCallback ( ) ; if ( ! this . resizeHandler ) { this . resizeHandler = this . debounce ( this . resized . bind ( this ) , 200 , false , this ) ; window . addEventListener ( 'resize' , this . resizeHandler , { passive : true } ) ; } this . localizeCalendarHeaders ( ) ; this . setInitialConditions ( ) ; this . computeCalendar ( ) ; this . refreshSelection ( ) ; setTimeout ( ( ) => this . updated ( ) ) ; } disconnectedCallback ( ) { super . disconnectedCallback ( ) ; if ( this . resizeHandler ) { window . removeEventListener ( 'resize' , this . resizeHandler ) ; delete this . resizeHandler ; } } static get styles ( ) { return css `
    :host {
      display: inline-block;
      font-family: inherit;
      position: relative;
      outline: none;
      opacity: 0;
    }

    :host(.wired-disabled) {
      opacity: 0.5 !important;
      cursor: default;
      pointer-events: none;
      background: rgba(0, 0, 0, 0.02);
    }

    :host(.wired-rendered) {
      opacity: 1;
    }

    :host(:focus) path {
      stroke-width: 1.5;
    }

    .overlay {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      pointer-events: none;
    }

    svg {
      display: block;
    }

    .calendar path {
      stroke: var(--wired-calendar-color, black);
      stroke-width: 0.7;
      fill: transparent;
    }

    .selected path {
      stroke: var(--wired-calendar-selected-color, red);
      stroke-width: 2.5;
      fill: transparent;
      transition: transform 0.05s ease;
    }

    table {
      position: relative;
      background: var(--wired-calendar-bg, white);
      border-collapse: collapse;
      font-size: inherit;
      text-transform: capitalize;
      line-height: unset;
      cursor: default;
      overflow: hidden;
    }

    table:focus {
      outline: none !important;
    }

    td,
    th {
      border-radius: 4px;
      text-align: center;
    }

    td.disabled {
      color: var(--wired-calendar-disabled-color, lightgray);
      cursor: not-allowed;
    }

    td.dimmed {
      color: var(--wired-calendar-dimmed-color, gray);
    }

    td.selected {
      position: absolute;
    }

    td:not(.disabled):not(.selected):hover {
      background-color: #d0d0d0;
      cursor: pointer;
    }

    .pointer {
      cursor: pointer;
    }

    ` ; } render ( ) { return html `
    <table style="width:${ this . calendarRefSize . width }px;height:${ this . calendarRefSize . height }px;border:${ TABLE_PADDING }px solid transparent"
            @mousedown="${ this . onItemClick }"
            @touchstart="${ this . onItemClick }">
      ${ '' }
      <tr class="top-header" style="height:${ this . tblHeadHeight }px;">
        <th id="prevCal" class="pointer" @click="${ this . onPrevClick }">&lt;&lt;</th>
        <th colSpan="5">${ this . monthYear }</th>
        <th id="nextCal" class="pointer" @click="${ this . onNextClick }">&gt;&gt;</th>
      </tr>
      ${ '' }
      <tr class="header" style="height:${ this . tblHeadHeight }px;">
        ${ this . weekdays_short . map ( ( d ) => html `<th style="width: ${ this . tblColWidth };">${ this . initials ? d [ 0 ] : d }</th>
            ` ) }
      </tr>
      ${ '' }
      ${ this . weeks . map ( ( weekDays ) => html `<tr style="height:${ this . tblRowHeight }px;">
              ${ '' }
              ${ weekDays . map ( ( d ) => html `${ d . selected ? html `
                            <td class="selected" value="${ d . value }">
                            <div style="width: ${ this . tblColWidth }px; line-height:${ this . tblRowHeight }px;">${ d . text }</div>
                            <div class="overlay">
                              <svg id="svgTD" class="selected"></svg>
                            </div></td>
                        ` : html `
                            <td .className="${ d . disabled ? 'disabled' : ( d . dimmed ? 'dimmed' : '' ) }"
                                value="${ d . disabled ? '' : d . value }">${ d . text }</td>
                        ` }
                    ` ) }${ '' }
            </tr>` ) }${ '' }
    </table>
    <div class="overlay">
      <svg id="svg" class="calendar"></svg>
    </div>
    ` ; } firstUpdated ( ) { this . setAttribute ( 'role' , 'dialog' ) ; } updated ( changed ? ) { if ( changed && changed instanceof Map ) { if ( changed . has ( 'disabled' ) ) this . refreshDisabledState ( ) ; if ( changed . has ( 'selected' ) ) this . refreshSelection ( ) ; } const svg = ( this . shadowRoot ! . getElementById ( 'svg' ) as any ) as SVGSVGElement ; while ( svg . hasChildNodes ( ) ) { svg . removeChild ( svg . lastChild ! ) ; } const s = this . getCalendarSize ( ) ; const elev = Math . min ( Math . max ( 1 , this . elevation ) , 5 ) ; const w = s . width + ( ( elev - 1 ) * 2 ) ; const h = s . height + ( ( elev - 1 ) * 2 ) ; svg . setAttribute ( 'width' , `${ w }` ) ; svg . setAttribute ( 'height' , `${ h }` ) ; rectangle ( svg , 2 , 2 , s . width - 4 , s . height - 4 , this . seed ) ; for ( let i = 1 ; i < elev ; i ++ ) { ( line ( svg , ( i * 2 ) , s . height - 4 + ( i * 2 ) , s . width - 4 + ( i * 2 ) , s . height - 4 + ( i * 2 ) , this . seed ) ) . style . opacity = `${ ( 85 - ( i * 10 ) ) / 100 }` ; ( line ( svg , s . width - 4 + ( i * 2 ) , s . height - 4 + ( i * 2 ) , s . width - 4 + ( i * 2 ) , i * 2 , this . seed ) ) . style . opacity = `${ ( 85 - ( i * 10 ) ) / 100 }` ; ( line ( svg , ( i * 2 ) , s . height - 4 + ( i * 2 ) , s . width - 4 + ( i * 2 ) , s . height - 4 + ( i * 2 ) , this . seed ) ) . style . opacity = `${ ( 85 - ( i * 10 ) ) / 100 }` ; ( line ( svg , s . width - 4 + ( i * 2 ) , s . height - 4 + ( i * 2 ) , s . width - 4 + ( i * 2 ) , i * 2 , this . seed ) ) . style . opacity = `${ ( 85 - ( i * 10 ) ) / 100 }` ; } const svgTD = ( this . shadowRoot ! . getElementById ( 'svgTD' ) as any ) as SVGSVGElement ; if ( svgTD ) { while ( svgTD . hasChildNodes ( ) ) { svgTD . removeChild ( svgTD . lastChild ! ) ; } const iw = Math . max ( this . tblColWidth * 1.0 , 20 ) ; const ih = Math . max ( this . tblRowHeight * 0.9 , 18 ) ; const c = ellipse ( svgTD , this . tblColWidth / 2 , this . tblRowHeight / 2 , iw , ih , this . seed ) ; svgTD . appendChild ( c ) ; } this . classList . add ( 'wired-rendered' ) ; } setSelectedDate ( formatedDate ) { this . selected = formatedDate ; if ( this . selected ) { const d = new Date ( this . selected ) ; this . firstOfMonthDate = new Date ( d . getFullYear ( ) , d . getMonth ( ) , 1 ) ; this . computeCalendar ( ) ; this . requestUpdate ( ) ; this . fireSelected ( ) ; } } private localizeCalendarHeaders ( ) { if ( ! this . locale ) { const n = navigator ; if ( n . hasOwnProperty ( 'systemLanguage' ) ) this . locale = n [ 'systemLanguage' ] ; else if ( n . hasOwnProperty ( 'browserLanguage' ) ) this . locale = n [ 'browserLanguage' ] ; else this . locale = ( navigator . languages || [ 'en' ] ) [ 0 ] ; } const l = ( this . locale || '' ) . toLowerCase ( ) ; if ( l !== 'en-us' && l !== 'en' ) { const d = new Date ( ) ; const weekDayOffset = d . getUTCDay ( ) ; const daySunday = new Date ( d . getTime ( ) - DAY * weekDayOffset ) ; for ( let i = 0 ; i < 7 ; i ++ ) { const weekdayDate = new Date ( daySunday ) ; weekdayDate . setDate ( daySunday . getDate ( ) + i ) ; this . weekdays_short [ i ] = weekdayDate . toLocaleString ( this . locale , { weekday : 'short' } ) ; } d . setDate ( 1 ) ; for ( let m = 0 ; m < 12 ; m ++ ) { d . setMonth ( m ) ; this . months [ m ] = d . toLocaleString ( this . locale , { month : 'long' } ) ; } } } private setInitialConditions ( ) { this . calendarRefSize = this . getCalendarSize ( ) ; let d ; if ( this . selected ) { d = new Date ( this . selected ) ; this . value = { date : new Date ( this . selected ) , text : this . selected } ; } else { d = new Date ( ) ; } this . firstOfMonthDate = new Date ( d . getFullYear ( ) , d . getMonth ( ) , 1 ) ; if ( this . firstdate ) this . fDate = new Date ( this . firstdate ) ; if ( this . lastdate ) this . lDate = new Date ( this . lastdate ) ; } private refreshSelection ( ) { this . weeks . forEach ( ( week ) => week . forEach ( ( day ) => { day . selected = this . selected && ( day . value === this . selected ) || false ; } ) ) ; this . requestUpdate ( ) ; } private resized ( ) { this . calendarRefSize = this . getCalendarSize ( ) ; this . computeCalendar ( ) ; this . refreshSelection ( ) ; } private getCalendarSize ( ) { const limits = this . getBoundingClientRect ( ) ; return { width : limits . width > 180 ? limits . width : 320 , height : limits . height > 180 ? limits . height : 320 } ; } private computeCellsizes ( size , rows ) { const numerOfHeaderRows = 2 ; const headerRealStateProportion = 0.25 ; const borderSpacing = 2 ; this . tblColWidth = ( size . width / 7 ) - borderSpacing ; this . tblHeadHeight = ( size . height * headerRealStateProportion / numerOfHeaderRows ) - borderSpacing ; this . tblRowHeight = ( size . height * ( 1 - headerRealStateProportion ) / rows ) - borderSpacing ; } private refreshDisabledState ( ) { if ( this . disabled ) { this . classList . add ( 'wired-disabled' ) ; } else { this . classList . remove ( 'wired-disabled' ) ; } this . tabIndex = this . disabled ? - 1 : + ( this . getAttribute ( 'tabindex' ) || 0 ) ; } private onItemClick ( event ) { event . stopPropagation ( ) ; const sel = event . target as HTMLElement ; if ( sel && sel . hasAttribute ( 'value' ) && sel . getAttribute ( 'value' ) !== '' ) { this . selected = sel . getAttribute ( 'value' ) || undefined ; this . refreshSelection ( ) ; this . fireSelected ( ) ; } } private fireSelected ( ) { if ( this . selected ) { this . value = { date : new Date ( this . selected ) , text : this . selected } ; fireEvent ( this , 'selected' , { selected : this . selected } ) ; } } private computeCalendar ( ) { this . monthYear = this . months [ this . firstOfMonthDate . getMonth ( ) ] + ' ' + this . firstOfMonthDate . getFullYear ( ) ; const first_day_in_month = new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) , 1 ) ; let dayInMonthOffset = 0 - first_day_in_month . getDay ( ) ; const amountOfWeeks = Math . ceil ( ( new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) + 1 , 0 ) . getDate ( ) - dayInMonthOffset ) / 7 ) ; this . weeks = [ ] ; for ( let weekIndex = 0 ; weekIndex < amountOfWeeks ; weekIndex ++ ) { this . weeks [ weekIndex ] = [ ] ; for ( let dayOfWeekIndex = 0 ; dayOfWeekIndex < 7 ; dayOfWeekIndex ++ ) { const day = new Date ( first_day_in_month . getTime ( ) + DAY * dayInMonthOffset ) ; const formatedDate = this . format ( day ) ; this . weeks [ weekIndex ] [ dayOfWeekIndex ] = { value : formatedDate , text : day . getDate ( ) . toString ( ) , selected : formatedDate === this . selected , dimmed : day . getMonth ( ) !== first_day_in_month . getMonth ( ) , disabled : this . isDateOutOfRange ( day ) } ; dayInMonthOffset ++ ; } } this . computeCellsizes ( this . calendarRefSize , amountOfWeeks ) ; } private onPrevClick ( ) { if ( this . fDate === undefined || new Date ( this . fDate . getFullYear ( ) , this . fDate . getMonth ( ) - 1 , 1 ) . getMonth ( ) !== new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) - 1 , 1 ) . getMonth ( ) ) { this . firstOfMonthDate = new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) - 1 , 1 ) ; this . computeCalendar ( ) ; this . refreshSelection ( ) ; } } private onNextClick ( ) { if ( this . lDate === undefined || new Date ( this . lDate . getFullYear ( ) , this . lDate . getMonth ( ) + 1 , 1 ) . getMonth ( ) !== new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) + 1 , 1 ) . getMonth ( ) ) { this . firstOfMonthDate = new Date ( this . firstOfMonthDate . getFullYear ( ) , this . firstOfMonthDate . getMonth ( ) + 1 , 1 ) ; this . computeCalendar ( ) ; this . refreshSelection ( ) ; } } private isDateOutOfRange ( day ) { if ( this . fDate && this . lDate ) { return day < this . fDate || this . lDate < day ; } else if ( this . fDate ) { return day < this . fDate ; } else if ( this . lDate ) { return this . lDate < day ; } return false ; } private debounce ( func , wait , immediate , context ) { let timeout = 0 ; return ( ) => { const args = arguments ; const later = ( ) => { timeout = 0 ; if ( ! immediate ) { func . apply ( context , args ) ; } } ; const callNow = immediate && ! timeout ; clearTimeout ( timeout ) ; timeout = window . setTimeout ( later , wait ) ; if ( callNow ) { func . apply ( context , args ) ; } } ; } }