/*!
* Module Context
*/
/**
* @namespace Context
*/
const MODULE_NAME = 'Context';
//###[ IMPORTS ]########################################################################################################
import {hasValue, isFunction, isArray, orDefault, Observable} from './basic.js';
import {throttle} from './functions.js';
import {reschedule} from './timers.js';
//###[ DATA ]###########################################################################################################
const INTERACTION_TYPE_DETECTION = {
touchHappening : false,
touchEndingTimer : null,
touchStartHandler(){
INTERACTION_TYPE_DETECTION.touchHappening = true;
if( CURRENT_INTERACTION_TYPE.getValue() !== 'touch' ){
CURRENT_INTERACTION_TYPE.setValue('touch');
}
},
touchEndHandler(){
INTERACTION_TYPE_DETECTION.touchEndingTimer = reschedule(INTERACTION_TYPE_DETECTION.touchEndingTimer, 1032, () => {
INTERACTION_TYPE_DETECTION.touchHappening = false;
});
},
blurHandler(){
INTERACTION_TYPE_DETECTION.touchEndingTimer = reschedule(INTERACTION_TYPE_DETECTION.touchEndingTimer, 1032, () => {
INTERACTION_TYPE_DETECTION.touchHappening = false;
});
},
mouseMoveHandler : throttle(1000, function(){
if( (CURRENT_INTERACTION_TYPE.getValue('pointer')) && !INTERACTION_TYPE_DETECTION.touchHappening ){
CURRENT_INTERACTION_TYPE.setValue('pointer');
}
})
};
export let CURRENT_INTERACTION_TYPE;
//###[ EXPORTS ]########################################################################################################
/**
* @namespace Context:browserSupportsHistoryManipulation
*/
/**
* Detects if the browser supports history manipulation, by checking the most common
* methods for presence in the history-object.
*
* @returns {Boolean} true if browser seems to support history manipulation
*
* @memberof Context:browserSupportsHistoryManipulation
* @alias browserSupportsHistoryManipulation
* @example
* if( browserSupportsHistoryManipulation() ){
* window.history.replaceState(null, 'test', '/test');
* }
*/
export function browserSupportsHistoryManipulation(){
return hasValue(window.history)
&& isFunction(window.history.pushState)
&& isFunction(window.history.replaceState)
;
}
/**
* @namespace Context:contextHasHighDpi
*/
/**
* Checks if the context would benefit from high DPI graphics.
*
* @returns {Boolean} true if device has high DPI, false if not or browser does not support media queries
*
* @memberof Context:contextHasHighDpi
* @alias contextHasHighDpi
* @example
* if( contextHasHighDpi() ){
* document.querySelectorAll('img').forEach(img => {
* img.setAttribute('src', img.getAttribute('src').replace('.jpg', '@2x.jpg'));
* });
* }
*/
export function contextHasHighDpi(){
if( window.matchMedia ){
return window.matchMedia(
'only screen and (-webkit-min-device-pixel-ratio: 1.5),'
+'only screen and (-o-min-device-pixel-ratio: 3/2),'
+'only screen and (min--moz-device-pixel-ratio: 1.5),'
+'only screen and (min-device-pixel-ratio: 1.5),'
+'only screen and (min-resolution: 144dpi),'
+'only screen and (min-resolution: 1.5dppx)'
).matches;
} else {
return false;
}
}
/**
* @namespace Context:getBrowserScrollbarWidth
*/
/**
* Returns the current context's scrollbar width. Returns 0 if scrollbar is over content.
* There are edge cases in which we might want to calculate positions in respect to the
* actual width of the scrollbar. For example when working with elements with a 100vw width.
*
* This method temporarily inserts three elements into the body while forcing the body to
* actually show scrollbars, measuring the difference between 100vw and 100% on the body and
* returns the result.
*
* @returns {Number} the width of the body scrollbar in pixels
*
* @memberof Context:getBrowserScrollbarWidth
* @alias getBrowserScrollbarWidth
* @example
* foobarElement.style.width = `calc(100vw - ${getBrowserScrollbarWidth()}px)`;
*/
export function getBrowserScrollbarWidth(){
const sandbox = document.createElement('div');
sandbox.style.visibility = 'hidden';
sandbox.style.opacity = '0';
sandbox.style.pointerEvents = 'none';
sandbox.style.overflow = 'scroll';
sandbox.style.position = 'fixed';
sandbox.style.top = '0';
sandbox.style.right = '0';
sandbox.style.left = '0';
// firefox needs container to be at least 30px high to display scrollbar
sandbox.style.height = '50px';
const scrollbarEnforcer = document.createElement('div');
scrollbarEnforcer.style.width = '100%';
scrollbarEnforcer.style.height = '100px';
sandbox.appendChild(scrollbarEnforcer);
document.body.appendChild(sandbox);
const scrollbarWidth = sandbox.offsetWidth - scrollbarEnforcer.offsetWidth;
document.body.removeChild(sandbox);
return scrollbarWidth;
}
/**
* @namespace Context:detectInteractionType
*/
/**
* Try to figure out the current type of interaction between the user and the document.
* This is determined by the input device and is currently limited to either "pointer" or "touch".
*
* On call the function returns an educated guess about the fact what interaction type might be more
* probable based on browser features and sets up event listeners to update Context module's CURRENT_INTERACTION_TYPE
* observable (to which you may subscribe to be informed about updates), when interaction type should change while
* the page is being interacted with. In case a touch occurs we determine touch interaction and
* on mousemove we determine pointer interaction. If you use this observable to set up a class on your document for
* example you can even relatively safely handle dual devices like a surface book.
*
* Hint: because touch devices also emit a single mousemove after touchend with a single touch we have to block
* mousemove detection for 1s after the last touchend. Therefore, it takes up to 1s after the last touch event until
* we are able to detect the change to a pointer device.
*
* @param {?Boolean} [returnObservable=false] - if set to true, the call returns Context module's CURRENT_INTERACTION_TYPE observable
* @returns {String|Basic.Observable} interaction type string "pointer" or "touch", or the CURRENT_INTERACTION_TYPE observable
*
* @memberof Context:detectInteractionType
* @alias detectInteractionType
* @example
* let interactionTypeGuess = detectInteractionType();
* detectInteractionType(true).subscribe(function(type){
* document.body.classList.toggle('touch', type === 'touch');
* });
*/
export function detectInteractionType(returnObservable=false){
returnObservable = orDefault(returnObservable, false, 'bool');
if( !hasValue(CURRENT_INTERACTION_TYPE) ){
CURRENT_INTERACTION_TYPE = new Observable('');
if( ('ontouchstart' in document) && ('ontouchend' in document) && (window.navigator.maxTouchPoints > 0) ){
CURRENT_INTERACTION_TYPE.setValue('touch');
} else {
CURRENT_INTERACTION_TYPE.setValue('pointer');
}
document.addEventListener('touchstart', INTERACTION_TYPE_DETECTION.touchStartHandler);
document.addEventListener('touchend', INTERACTION_TYPE_DETECTION.touchEndHandler);
window.addEventListener('blur', INTERACTION_TYPE_DETECTION.blurHandler);
document.addEventListener('mousemove', INTERACTION_TYPE_DETECTION.mouseMoveHandler);
}
return returnObservable ? CURRENT_INTERACTION_TYPE : CURRENT_INTERACTION_TYPE.getValue();
}
/**
* @namespace Context:detectAppleDevice
*/
/**
* Try to determine if the execution context is an Apple device and if so: which type.
*
* We use an escalating test starting with the user agent and then, as a fallback, checking the platform value
* to determine the general device class (iPhone, iPad ,iPod ,Macintosh). If we get a Macintosh, we double check
* if the device might be a falsely reporting iPad with iPadOS13+.
*
* You can hook up additional tests by providing an "additionalTest" function as a function parameter,
* that function takes the evaluated device type at the end of the function and expects a new device type to be
* returned. Using this, you can tap into the process and handle edge cases yourself.
*
* @param {?Function} [additionalTest=null] - if set, is executed after determining the device type, takes the current device type as parameter and is expected to return a new one; use this to add edge case tests to overwrite the result in certain conditions
* @returns {String} "ipad", "iphone", "ipod" or "mac"
*
* @memberof Context:detectAppleDevice
* @alias detectAppleDevice
* @example
* const IS_IOS_DEVICE = ['iphone', 'ipod', 'ipad'].includes(detectAppleDevice());
*/
export function detectAppleDevice(additionalTest=null){
let
family = /iPhone|iPad|iPod|Macintosh/.exec(window.navigator.userAgent),
deviceType = null
;
if( Array.isArray(family) && (family.length > 0) ){
family = family[0];
} else {
family = /^(iPhone|iPad|iPod|Mac)/.exec(window.navigator.platform);
if( Array.isArray(family) && (family.length > 0) ){
family = family[0];
if( family === 'Mac' ){
family = 'Macintosh';
}
} else {
family = null;
}
}
if( hasValue(family) ){
// If User-Agent reports Macintosh double check this against touch points, since the device might
// be a disguised iPad with i(Pad)Os13+
if(
(family === 'Macintosh')
&& (window.navigator.maxTouchPoints > 1)
){
family = 'iPad';
}
switch( family ) {
case 'iPad':
deviceType = 'ipad';
break;
case 'iPhone':
deviceType = 'iphone';
break;
case 'iPod':
deviceType = 'ipod';
break;
case 'Macintosh':
deviceType = 'mac';
break;
}
if( isFunction(additionalTest) ){
deviceType = additionalTest(deviceType);
}
}
return deviceType;
}
/**
* @namespace Context:getBrowserLanguage
*/
/**
* Evaluates all available browser languages and tries to return the preferred one.
*
* Since browsers could not agree on a uniform way to return language values yet, the returned language
* will always be "lowercaselanguage-UPPERCASECOUNTRY" or just "lowercaselanguage", if we have no country.
*
* @param {?String} [fallbackLanguage=null] - fallback value to return if no language could be evaluated
* @returns {String|null} the preferred browser language if available, null if no language can be detected and no fallback has been defined
*
* @memberof Context:getBrowserLanguage
* @alias getBrowserLanguage
* @see getBrowserLocale
* @example
* getBrowserLanguage()
* => "en"
*/
export function getBrowserLanguage(fallbackLanguage=null){
let language = null;
if( hasValue(window.navigator.languages) ){
const browserLanguages = Array.from(window.navigator.languages);
if( isArray(browserLanguages) && (browserLanguages.length > 0) ){
language = `${browserLanguages[0]}`;
}
}
if( !hasValue(language) ){
['language', 'browserLanguage', 'userLanguage', 'systemLanguage'].forEach(browserLanguagePropertyKey => {
if( !hasValue(language) ){
const browserLanguage = window.navigator[browserLanguagePropertyKey];
language = hasValue(browserLanguage) ? `${browserLanguage}` : null;
}
});
}
if( !hasValue(language) && hasValue(fallbackLanguage) ){
language = `${fallbackLanguage}`;
}
const languageParts = language.split('-');
language = languageParts[0].toLowerCase().trim();
const country = languageParts?.[1]?.toUpperCase()?.trim();
language = hasValue(country) ? `${language}-${country}` : language;
return language;
}
/**
* @namespace Context:getLocale
*/
/**
* Evaluates the document's locale by having a look at the HTML element's lang-attribute.
*
* Since browsers could not agree on a uniform way to return locale values yet, the returned "code" will always be
* "lowercaselanguage-UPPERCASECOUNTRY" (or just "lowercaselanguage", if we have no country), regardless of how the
* browser returns the value, while "country" and "language" will always be lower case.
*
* @param {?HTMLElement} [element=document.documentElement] - the element holding the lang-attribute to evaluate
* @param {?String} [fallbackLanguage=null] - if defined, a fallback lang value if element holds no lang information
* @returns {Object} the locale as an object, having the lang value as "code", the split-up parts in "country" and "language" (if available) and "isFallback" to tell us if the fallback had to be used
*
* @memberof Context:getLocale
* @alias getLocale
* @see getBrowserLocale
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang
* @example
* getLocale()
* => {
* code : 'en-GB',
* country : 'gb',
* language : 'en',
* isFallback : false
* }
* getLocale(document.querySelector('p'), 'en-US')
* => {
* code : 'en-US',
* country : 'us',
* language : 'en',
* isFallback : true
* }
*/
export function getLocale(element=null, fallbackLanguage=null){
// document.documentElement not as function default to prevent errors in document-less context on import
element = orDefault(element, document.documentElement);
const locale = {
code : null,
country : null,
language : null,
isFallback : false
};
let langAttr = isFunction(element.getAttribute) ? element.getAttribute('lang') : null;
if( !hasValue(langAttr) && hasValue(fallbackLanguage) ){
langAttr = `${fallbackLanguage}`;
locale.isFallback = true;
}
if( hasValue(langAttr) ){
const localeParts = `${langAttr}`.split('-');
locale.country = localeParts?.[1]?.toLowerCase()?.trim();
locale.language = localeParts[0].toLowerCase().trim();
locale.code = hasValue(locale.country) ? `${locale.language}-${locale.country.toUpperCase()}` : locale.language;
}
return locale;
}
/**
* @namespace Context:getBrowserLocale
*/
/**
* Evaluates the browser's locale by having a look at the preferred browser language, as reported by `getBrowserLanguage`.
*
* Since browsers could not agree on a uniform way to return locale values yet, the returned "code" will always be
* "lowercaselanguage-UPPERCASECOUNTRY" (or just "lowercaselanguage", if we have no country), regardless of how the
* browser returns the value, while "country" and "language" will always be lower case.
*
* @param {?String} [fallbackLanguage=null] - if defined, a fallback lang value if browser reports no preferred language
* @returns {Object} the locale as an object, having the in "country" and "language" (if available) and "isFallback" to tell us if the fallback had to be used
*
* @memberof Context:getBrowserLocale
* @alias getBrowserLocale
* @see getBrowserLanguage
* @example
* getBrowserLocale()
* => {
* code : 'en-GB',
* country : 'gb',
* language : 'en',
* isFallback : false
* }
* getBrowserLocale('en-US')
* => {
* code : 'en-US',
* country : 'us',
* language : 'en',
* isFallback : true
* }
*/
export function getBrowserLocale(fallbackLanguage=null){
return getLocale({getAttribute(){ return getBrowserLanguage(fallbackLanguage); }}, fallbackLanguage);
}