import { CSSResultArray , html , PropertyValues , TemplateResult , } from '@spectrum-web-components/base' ; import { property , query , } from '@spectrum-web-components/base/src/decorators.js' ; import { ProvideLang } from '@spectrum-web-components/theme' ; import { streamingListener } from '@spectrum-web-components/base/src/streaming-listener.js' ; import { NumberFormatter , NumberParser } from '@internationalized/number' ; import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron75.js' ; import '@spectrum-web-components/action-button/sp-action-button.js' ; import { isAndroid , isIPhone , } from '@spectrum-web-components/shared/src/platform.js' ; import { TextfieldBase } from '@spectrum-web-components/textfield' ; import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js' ; import styles from './number-field.css.js' ; export const FRAMES_PER_CHANGE = 5 ; export const indeterminatePlaceholder = '-' ; element slot slot export class NumberField extends TextfieldBase { public static get styles ( ) : CSSResultArray { return [ ... super . styles , styles , chevronStyles ] ; } @ query ( '.buttons' ) private buttons ! : HTMLDivElement ; @ property ( { type : Boolean , reflect : true } ) public focused = false ; _forcedUnit = '' ; /**
     * An `&lt;sp-number-field&gt;` element will process its numeric value with
     * `new Intl.NumberFormat(this.resolvedLanguage, this.formatOptions).format(this.valueAsNumber)`
     * in order to prepare it for visual delivery in the input. In order to customize this
     * processing supply your own `Intl.NumberFormatOptions` object here.
     *
     * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
     */ @ property ( { type : Object , attribute : 'format-options' } ) public formatOptions : Intl . NumberFormatOptions = { } ; /**
     * Whether the stepper UI is hidden or not.
     */ @ property ( { type : Boolean , reflect : true , attribute : 'hide-stepper' } ) public hideStepper = false ; @ property ( { type : Boolean , reflect : true } ) public indeterminate = false ; @ property ( { type : Boolean , reflect : true , attribute : 'keyboard-focused' } ) public keyboardFocused = false ; @ property ( { type : Number } ) public max ? : number ; @ property ( { type : Number } ) public min ? : number ; @ property ( { attribute : false } ) private resolvedLanguage = document . documentElement . lang || navigator . language ; /**
     * The distance by which to alter the value of the element when taking a "step".
     *
     * When `this.formatOptions.style === 'percentage'` the default step will be
     * set to 0.01 unless otherwise supplied to the element.
     */ @ property ( { type : Number } ) public step ? : number ; public stepperActive = false ; @ property ( { type : Number , reflect : true , attribute : 'step-modifier' } ) public stepModifier = 10 ; @ property ( { type : Number } ) public set value ( value ) { if ( value === this . value ) { return ; } const oldValue = this . _value ; this . _value = value ; this . requestUpdate ( 'value' , oldValue ) ; } public get value ( ) : number { return this . _value ; } private get inputValue ( ) : string { return this . indeterminate ? this . formattedValue : this . inputElement . value ; } public _value = NaN ; private _trackingValue = '' ; /**
     * Retreive the value of the element parsed to a Number.
     */ public get valueAsString ( ) : string { return this . _value . toString ( ) ; } public set valueAsString ( value ) { this . value = this . numberParser . parse ( value ) ; } public get formattedValue ( ) : string { if ( isNaN ( this . value ) ) return '' ; return ( this . numberFormatter . format ( this . value ) + ( this . focused ? '' : this . _forcedUnit ) ) ; } private convertValueToNumber ( value ) { return this . numberParser . parse ( value ) ; } private get _step ( ) : number { if ( typeof this . step !== 'undefined' ) { return this . step ; } if ( this . formatOptions ?. style === 'percent' ) { return 0.01 ; } return 1 ; } private nextChange ! : number ; private changeCount = 0 ; private findChange ! : ( event : PointerEvent ) => void ; private change ! : ( event : PointerEvent ) => void ; private safty ! : number ; private handlePointerdown ( event ) { if ( event . button !== 0 ) { event . preventDefault ( ) ; return ; } this . stepperActive = true ; this . buttons . setPointerCapture ( event . pointerId ) ; const stepUpRect = this . buttons . children [ 0 ] . getBoundingClientRect ( ) ; const stepDownRect = this . buttons . children [ 1 ] . getBoundingClientRect ( ) ; this . findChange = ( event ) => { if ( event . clientX >= stepUpRect . x && event . clientY >= stepUpRect . y && event . clientX <= stepUpRect . x + stepUpRect . width && event . clientY <= stepUpRect . y + stepUpRect . height ) { this . change = ( event ) => this . increment ( event . shiftKey ? this . stepModifier : 1 ) ; } else if ( event . clientX >= stepDownRect . x && event . clientY >= stepDownRect . y && event . clientX <= stepDownRect . x + stepDownRect . width && event . clientY <= stepDownRect . y + stepDownRect . height ) { this . change = ( event ) => this . decrement ( event . shiftKey ? this . stepModifier : 1 ) ; } } ; this . findChange ( event ) ; this . startChange ( event ) ; } private startChange ( event ) { this . changeCount = 0 ; this . doChange ( event ) ; this . safty = setTimeout ( ( ) => { this . doNextChange ( event ) ; } , 400 ) as unknown as number ; } private doChange ( event ) { this . change ( event ) ; } private handlePointermove ( event ) { this . findChange ( event ) ; } private handlePointerup ( event ) { this . buttons . releasePointerCapture ( event . pointerId ) ; cancelAnimationFrame ( this . nextChange ) ; clearTimeout ( this . safty ) ; this . dispatchEvent ( new Event ( 'change' , { bubbles : true , composed : true } ) ) ; this . stepperActive = false ; } private doNextChange ( event ) { this . changeCount += 1 ; if ( this . changeCount % FRAMES_PER_CHANGE === 0 ) { this . doChange ( event ) ; } return requestAnimationFrame ( ( ) => { this . nextChange = this . doNextChange ( event ) ; } ) ; } private stepBy ( count ) { if ( this . disabled || this . readonly ) { return ; } const min = typeof this . min !== 'undefined' ? this . min : 0 ; let value = this . value ; value += count * this . _step ; if ( isNaN ( this . value ) ) { this . value = min ; } else { this . value = value ; } this . dispatchEvent ( new Event ( 'input' , { bubbles : true , composed : true } ) ) ; this . indeterminate = false ; this . focus ( ) ; } private increment ( factor = 1 ) { this . stepBy ( 1 * factor ) ; } private decrement ( factor = 1 ) { this . stepBy ( - 1 * factor ) ; } private handleKeydown ( event ) { switch ( event . code ) { case 'ArrowUp' : event . preventDefault ( ) ; this . increment ( event . shiftKey ? this . stepModifier : 1 ) ; this . dispatchEvent ( new Event ( 'change' , { bubbles : true , composed : true } ) ) ; break ; case 'ArrowDown' : event . preventDefault ( ) ; this . decrement ( event . shiftKey ? this . stepModifier : 1 ) ; this . dispatchEvent ( new Event ( 'change' , { bubbles : true , composed : true } ) ) ; break ; } } protected onScroll ( event ) { event . preventDefault ( ) ; const direction = event . shiftKey ? event . deltaX / Math . abs ( event . deltaX ) : event . deltaY / Math . abs ( event . deltaY ) ; if ( direction !== 0 && ! isNaN ( direction ) ) { this . stepBy ( direction * ( event . shiftKey ? this . stepModifier : 1 ) ) ; } } protected onFocus ( ) { super . onFocus ( ) ; this . _trackingValue = this . inputValue ; this . keyboardFocused = true ; this . addEventListener ( 'wheel' , this . onScroll ) ; } protected onBlur ( ) { super . onBlur ( ) ; this . keyboardFocused = false ; this . removeEventListener ( 'wheel' , this . onScroll ) ; } private handleFocusin ( ) { this . focused = true ; this . keyboardFocused = true ; } private handleFocusout ( ) { this . focused = false ; this . keyboardFocused = false ; } private wasIndeterminate = false ; private indeterminateValue ? : number ; protected onChange ( ) { const value = this . convertValueToNumber ( this . inputValue ) ; if ( this . wasIndeterminate ) { this . wasIndeterminate = false ; this . indeterminateValue = undefined ; if ( isNaN ( value ) ) { this . indeterminate = true ; return ; } } this . value = value ; super . onChange ( ) ; } protected onInput ( ) { if ( this . indeterminate ) { this . wasIndeterminate = true ; this . indeterminateValue = this . value ; this . inputElement . value = this . inputElement . value . replace ( indeterminatePlaceholder , '' ) ; } const { value , selectionStart } = this . inputElement ; if ( this . numberParser . isValidPartialNumber ( value ) ) { const valueAsNumber = this . convertValueToNumber ( value ) ; if ( ! value && this . indeterminateValue ) { this . indeterminate = true ; this . _value = this . indeterminateValue ; } else { this . indeterminate = false ; this . _value = this . validateInput ( valueAsNumber ) ; } this . _trackingValue = value ; return ; } const currentLength = value . length ; const previousLength = this . _trackingValue . length ; const nextSelectStart = ( selectionStart || currentLength ) - ( currentLength - previousLength ) ; this . inputElement . value = this . indeterminate ? indeterminatePlaceholder : this . _trackingValue ; this . inputElement . setSelectionRange ( nextSelectStart , nextSelectStart ) ; } private validateInput ( value ) { if ( typeof this . min !== 'undefined' ) { value = Math . max ( this . min , value ) ; } if ( typeof this . max !== 'undefined' ) { value = Math . min ( this . max , value ) ; } if ( this . step ) { const min = typeof this . min !== 'undefined' ? this . min : 0 ; const moduloStep = ( value - min ) % this . step ; const fallsOnStep = moduloStep === 0 ; if ( ! fallsOnStep ) { const overUnder = Math . round ( moduloStep / this . step ) ; if ( overUnder === 1 ) { value += this . step - moduloStep ; } else { value -= moduloStep ; } } if ( typeof this . max !== 'undefined' ) { while ( value > this . max ) { value -= this . step ; } } } return value ; } protected get displayValue ( ) : string { const indeterminateValue = this . focused ? '' : indeterminatePlaceholder ; return this . indeterminate ? indeterminateValue : this . formattedValue ; } protected clearNumberFormatterCache ( ) { this . _numberFormatter = undefined ; this . _numberParser = undefined ; } protected get numberFormatter ( ) : NumberFormatter { if ( ! this . _numberFormatter || ! this . _numberFormatterFocused ) { const { style , unit , unitDisplay , ... formatOptionsNoUnit } = this . formatOptions ; if ( style !== 'unit' ) { ( formatOptionsNoUnit as Intl . NumberFormatOptions ) . style = style ; } this . _numberFormatterFocused = new NumberFormatter ( this . resolvedLanguage , formatOptionsNoUnit ) ; try { this . _numberFormatter = new NumberFormatter ( this . resolvedLanguage , this . formatOptions ) ; this . _forcedUnit = '' ; this . _numberFormatter . format ( 1 ) ; } catch ( error ) { if ( style === 'unit' ) { this . _forcedUnit = unit as string ; } this . _numberFormatter = this . _numberFormatterFocused ; } } return this . focused ? this . _numberFormatterFocused : this . _numberFormatter ; } private _numberFormatter ? : NumberFormatter ; private _numberFormatterFocused ? : NumberFormatter ; protected get numberParser ( ) : NumberParser { if ( ! this . _numberParser || ! this . _numberParserFocused ) { const { style , unit , unitDisplay , ... formatOptionsNoUnit } = this . formatOptions ; if ( style !== 'unit' ) { ( formatOptionsNoUnit as Intl . NumberFormatOptions ) . style = style ; } this . _numberParserFocused = new NumberParser ( this . resolvedLanguage , formatOptionsNoUnit ) ; try { this . _numberParser = new NumberParser ( this . resolvedLanguage , this . formatOptions ) ; this . _forcedUnit = '' ; this . _numberParser . parse ( '0' ) ; } catch ( error ) { if ( style === 'unit' ) { this . _forcedUnit = unit as string ; } this . _numberParser = this . _numberParserFocused ; } } return this . focused ? this . _numberParserFocused : this . _numberParser ; } private _numberParser ? : NumberParser ; private _numberParserFocused ? : NumberParser ; protected renderField ( ) { this . autocomplete = 'off' ; return html `
            ${ super . renderField ( ) }
            ${ this . hideStepper ? html `` : html `
                      <span
                          class="buttons"
                          @focusin=${ this . handleFocusin }
                          @focusout=${ this . handleFocusout }
                          ${ streamingListener ( { start : [ 'pointerdown' , this . handlePointerdown ] , streamInside : [ [ 'pointermove' , 'pointerenter' , 'pointerleave' , 'pointerover' , 'pointerout' , ] , this . handlePointermove , ] , end : [ [ 'pointerup' , 'pointercancel' ] , this . handlePointerup , ] , } ) }
                      >
                          <sp-action-button
                              class="stepUp"
                              label="Increment"
                              tabindex="-1"
                              ?focused=${ this . focused }
                              ?disabled=${ this . disabled || this . readonly || ( typeof this . max !== 'undefined' && this . value === this . max ) }
                              ?quiet=${ this . quiet }
                          >
                              <sp-icon-chevron75
                                  slot="icon"
                                  class="stepper-icon spectrum-UIIcon-ChevronUp75"
                              ></sp-icon-chevron75>
                          </sp-action-button>
                          <sp-action-button
                              class="stepDown"
                              label="Decrement"
                              tabindex="-1"
                              ?focused=${ this . focused }
                              ?disabled=${ this . disabled || this . readonly || ( typeof this . min !== 'undefined' && this . value === this . min ) }
                              ?quiet=${ this . quiet }
                          >
                              <sp-icon-chevron75
                                  slot="icon"
                                  class="stepper-icon spectrum-UIIcon-ChevronDown75"
                              ></sp-icon-chevron75>
                          </sp-action-button>
                      </span>
                  ` }
        ` ; } protected update ( changes ) { if ( changes . has ( 'formatOptions' ) || changes . has ( 'resolvedLanguage' ) ) { this . clearNumberFormatterCache ( ) ; } super . update ( changes ) ; } protected firstUpdated ( changes ) { super . firstUpdated ( changes ) ; this . multiline = false ; this . addEventListener ( 'keydown' , this . handleKeydown ) ; } protected updated ( changes ) { if ( changes . has ( 'value' ) || changes . has ( 'max' ) || changes . has ( 'min' ) || changes . has ( 'min' ) ) { const value = this . numberParser . parse ( this . inputValue . replace ( this . _forcedUnit , '' ) ) ; this . value = this . validateInput ( value ) ; } if ( changes . has ( 'min' ) || changes . has ( 'formatOptions' ) ) { let inputMode = 'numeric' ; const hasNegative = typeof this . min !== 'undefined' && this . min < 0 ; const { maximumFractionDigits } = this . formatOptions ; const hasDecimals = maximumFractionDigits && maximumFractionDigits > 0 ; if ( isIPhone ( ) ) { if ( hasNegative ) { inputMode = 'text' ; } else if ( hasDecimals ) { inputMode = 'decimal' ; } } else if ( isAndroid ( ) ) { if ( hasNegative ) { inputMode = 'numeric' ; } else if ( hasDecimals ) { inputMode = 'decimal' ; } } this . inputElement . inputMode = inputMode ; } } public connectedCallback ( ) { super . connectedCallback ( ) ; this . resolveLanguage ( ) ; } public disconnectedCallback ( ) { this . resolveLanguage ( ) ; super . disconnectedCallback ( ) ; } private resolveLanguage ( ) { const queryThemeEvent = new CustomEvent < ProvideLang > ( 'sp-language-context' , { bubbles : true , composed : true , detail : { callback : ( lang ) => { this . resolvedLanguage = lang ; } , } , cancelable : true , } ) ; this . dispatchEvent ( queryThemeEvent ) ; } }