@linear_non/stellar-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ // Resize.js
2
+ import { sizes, flags, breakpoints } from "../kitStore"
3
+ import emitter, { EVENTS } from "./Emitter"
4
+ import debounce from "lodash.debounce"
5
+ import { sniffer, getWindowSizes, getViewport, setViewportHeight } from "../utils"
6
+
7
+ export default class Resize {
8
+ constructor() {
9
+ this.wasDesktop = sniffer.isDesktop
10
+ this.init()
11
+ }
12
+
13
+ onResize = () => {
14
+ flags.isResizing = true
15
+
16
+ const { width, height } = getViewport()
17
+ const bp = getWindowSizes()
18
+ sniffer.update() // Update sniffer after viewport read
19
+
20
+ Object.assign(sizes, {
21
+ vw: width,
22
+ vh: height,
23
+ })
24
+
25
+ Object.assign(breakpoints, {
26
+ xsmallDown: bp.XS,
27
+ smallDown: bp.S,
28
+ smallUp: bp.S_UP,
29
+ mediumDown: bp.M,
30
+ mediumUp: bp.M_UP,
31
+ })
32
+
33
+ this.updateBodyClasses()
34
+ this.updateVH()
35
+ this.reloadIfBreakpointChanged()
36
+
37
+ emitter.emit(EVENTS.APP_RESIZE)
38
+
39
+ flags.isResizing = false
40
+ }
41
+
42
+ reloadIfBreakpointChanged() {
43
+ const isNowDesktop = sniffer.isDesktop
44
+ if (this.wasDesktop !== isNowDesktop) {
45
+ location.reload()
46
+ }
47
+ }
48
+
49
+ updateVH() {
50
+ setViewportHeight(sizes.vh)
51
+ }
52
+
53
+ updateBodyClasses() {
54
+ const { vw, vh } = sizes
55
+ const orientationClass = vw > vh ? "is-landscape" : "is-portrait"
56
+ const deviceClass = sniffer.isDesktop ? "is-desktop" : "is-device"
57
+
58
+ document.body.classList.add(orientationClass)
59
+ document.body.classList.add(deviceClass)
60
+ }
61
+
62
+ on() {
63
+ window.addEventListener("resize", debounce(this.onResize, 200))
64
+ }
65
+
66
+ init() {
67
+ this.on()
68
+ this.onResize() // Run once on init
69
+ }
70
+ }
@@ -0,0 +1,51 @@
1
+ // Scroll.js
2
+ import { sizes } from "../kitStore"
3
+ import emitter, { EVENTS } from "./Emitter"
4
+ import VirtualScroll from "./VirtualScroll"
5
+ import { bounds, sniffer } from "../utils"
6
+
7
+ export default class Scroll {
8
+ constructor({ isSmooth = false } = {}) {
9
+ this.isSmooth = isSmooth
10
+
11
+ if (this.isSmooth) {
12
+ document.body.classList.add("is-smooth")
13
+
14
+ this.virtualScroll = new VirtualScroll({
15
+ mouseMultiplier: sniffer.isWindows ? 1.1 : 0.45,
16
+ touchMultiplier: 3.5,
17
+ firefoxMultiplier: sniffer.isWindows ? 40 : 90,
18
+ passive: true,
19
+ })
20
+
21
+ this.virtualScroll.on(this.onVirtualScroll)
22
+ } else {
23
+ this.onScroll = this.onScroll.bind(this)
24
+ document.addEventListener("scroll", this.onScroll, true)
25
+ }
26
+
27
+ this.setScrollBounds()
28
+ }
29
+
30
+ setScrollBounds() {
31
+ const el = document.querySelector("[data-view]") || document.body
32
+ const height = bounds(el).height
33
+ sizes.fh = height > sizes.vh ? height - sizes.vh : 0
34
+ }
35
+
36
+ onVirtualScroll = e => {
37
+ emitter.emit(EVENTS.APP_SCROLL, { y: e.deltaY * -1 })
38
+ }
39
+
40
+ onScroll(e) {
41
+ emitter.emit(EVENTS.APP_SCROLL, { y: window.scrollY })
42
+ }
43
+
44
+ destroy() {
45
+ if (this.virtualScroll) {
46
+ this.virtualScroll.destroy()
47
+ } else {
48
+ document.removeEventListener("scroll", this.onScroll, true)
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,196 @@
1
+ // VirtualScroll.js
2
+ import kitStore from "../kitStore"
3
+ import emitter from "./Emitter"
4
+ import { supportMouseTouch } from "../utils"
5
+ import Lethargy from "lethargy"
6
+
7
+ const EVT_ID = "virtualscroll"
8
+
9
+ export default class VirtualScroll {
10
+ constructor(obj = {}) {
11
+ this.options = {
12
+ mouseMultiplier: 1,
13
+ touchMultiplier: 2,
14
+ firefoxMultiplier: 15,
15
+ keyStep: 120,
16
+ preventTouch: false,
17
+ unpreventTouchClass: "vs-touchmove-allowed",
18
+ limitInertia: false,
19
+ useKeyboard: true,
20
+ useTouch: true,
21
+ ...obj,
22
+ }
23
+
24
+ this.el = obj.el || window
25
+ this.event = { x: 0, y: 0, deltaX: 0, deltaY: 0 }
26
+ this.touchStartX = null
27
+ this.touchStartY = null
28
+ this.bodyTouchAction = null
29
+ this.focusingInput = false
30
+
31
+ if (this.options.limitInertia) this.lethargy = new Lethargy()
32
+
33
+ if (this.options.passive !== undefined) {
34
+ this.listenerOptions = { passive: this.options.passive }
35
+ }
36
+
37
+ document.addEventListener("focusin", () => (this.focusingInput = true))
38
+ document.addEventListener("focusout", () => (this.focusingInput = false))
39
+ }
40
+
41
+ notify(e) {
42
+ const evt = this.event
43
+ evt.x += evt.deltaX
44
+ evt.y += evt.deltaY
45
+
46
+ emitter.emit(EVT_ID, {
47
+ x: evt.x,
48
+ y: evt.y,
49
+ deltaX: evt.deltaX,
50
+ deltaY: evt.deltaY,
51
+ originalEvent: e,
52
+ })
53
+ }
54
+
55
+ onWheel = e => {
56
+ if (this.lethargy && !this.lethargy.check(e)) return
57
+
58
+ const evt = this.event
59
+ evt.deltaX = e.wheelDeltaX || e.deltaX * -1
60
+ evt.deltaY = e.wheelDeltaY || e.deltaY * -1
61
+
62
+ if (supportMouseTouch().isFirefox && e.deltaMode === 1) {
63
+ evt.deltaX *= this.options.firefoxMultiplier
64
+ evt.deltaY *= this.options.firefoxMultiplier
65
+ }
66
+
67
+ evt.deltaX *= this.options.mouseMultiplier
68
+ evt.deltaY *= this.options.mouseMultiplier
69
+
70
+ this.notify(e)
71
+ }
72
+
73
+ onMouseWheel = e => {
74
+ if (this.options.limitInertia && !this.lethargy.check(e)) return
75
+
76
+ const evt = this.event
77
+ evt.deltaX = e.wheelDeltaX || 0
78
+ evt.deltaY = e.wheelDeltaY || e.wheelDelta
79
+
80
+ this.notify(e)
81
+ }
82
+
83
+ onTouchStart = e => {
84
+ const t = e.targetTouches ? e.targetTouches[0] : e
85
+ this.touchStartX = t.pageX
86
+ this.touchStartY = t.pageY
87
+ }
88
+
89
+ onTouchMove = e => {
90
+ if (this.options.preventTouch && !e.target.classList.contains(this.options.unpreventTouchClass)) {
91
+ e.preventDefault()
92
+ }
93
+
94
+ const evt = this.event
95
+ const t = e.targetTouches ? e.targetTouches[0] : e
96
+
97
+ evt.deltaX = (t.pageX - this.touchStartX) * this.options.touchMultiplier
98
+ evt.deltaY = (t.pageY - this.touchStartY) * this.options.touchMultiplier
99
+
100
+ this.touchStartX = t.pageX
101
+ this.touchStartY = t.pageY
102
+
103
+ this.notify(e)
104
+ }
105
+
106
+ onKeyDown = e => {
107
+ const evt = this.event
108
+ evt.deltaX = 0
109
+ evt.deltaY = 0
110
+
111
+ const height = window.innerHeight - 40
112
+
113
+ if (this.focusingInput || kitStore.flags.isFocus) return
114
+
115
+ switch (e.keyCode) {
116
+ case 37: // LEFT
117
+ case 38: // UP
118
+ evt.deltaY = this.options.keyStep
119
+ break
120
+ case 39: // RIGHT
121
+ case 40: // DOWN
122
+ evt.deltaY = -this.options.keyStep
123
+ break
124
+ case 32: // SPACE
125
+ evt.deltaY = e.shiftKey ? height : -height
126
+ break
127
+ default:
128
+ return
129
+ }
130
+
131
+ this.notify(e)
132
+ }
133
+
134
+ bind = () => {
135
+ const support = supportMouseTouch()
136
+
137
+ if (support.hasWheelEvent) this.el.addEventListener("wheel", this.onWheel, this.listenerOptions)
138
+
139
+ if (support.hasMouseWheelEvent)
140
+ this.el.addEventListener("mousewheel", this.onMouseWheel, this.listenerOptions)
141
+
142
+ if (support.hasTouch && this.options.useTouch) {
143
+ this.el.addEventListener("touchstart", this.onTouchStart, this.listenerOptions)
144
+ this.el.addEventListener("touchmove", this.onTouchMove, this.listenerOptions)
145
+ }
146
+
147
+ if (support.hasPointer && support.hasTouchWin) {
148
+ this.bodyTouchAction = document.body.style.msTouchAction
149
+ document.body.style.msTouchAction = "none"
150
+ this.el.addEventListener("MSPointerDown", this.onTouchStart, true)
151
+ this.el.addEventListener("MSPointerMove", this.onTouchMove, true)
152
+ }
153
+
154
+ if (support.hasKeyDown && this.options.useKeyboard) document.addEventListener("keydown", this.onKeyDown)
155
+ }
156
+
157
+ unbind = () => {
158
+ const support = supportMouseTouch()
159
+
160
+ if (support.hasWheelEvent) this.el.removeEventListener("wheel", this.onWheel)
161
+ if (support.hasMouseWheelEvent) this.el.removeEventListener("mousewheel", this.onMouseWheel)
162
+ if (support.hasTouch) {
163
+ this.el.removeEventListener("touchstart", this.onTouchStart)
164
+ this.el.removeEventListener("touchmove", this.onTouchMove)
165
+ }
166
+ if (support.hasPointer && support.hasTouchWin) {
167
+ document.body.style.msTouchAction = this.bodyTouchAction
168
+ this.el.removeEventListener("MSPointerDown", this.onTouchStart, true)
169
+ this.el.removeEventListener("MSPointerMove", this.onTouchMove, true)
170
+ }
171
+ if (support.hasKeyDown && this.options.useKeyboard)
172
+ document.removeEventListener("keydown", this.onKeyDown)
173
+ }
174
+
175
+ on = (cb, priority = 0) => {
176
+ emitter.on(EVT_ID, cb, priority)
177
+ const eventCount = emitter.events?.[EVT_ID]?.length || 0
178
+ if (eventCount === 1) this.bind() // Bind only once
179
+ }
180
+
181
+ off = cb => {
182
+ emitter.off(EVT_ID, cb)
183
+ const eventCount = emitter.events?.[EVT_ID]?.length || 0
184
+ if (eventCount === 0) this.unbind() // Unbind when no listeners left
185
+ }
186
+
187
+ reset = () => {
188
+ this.event.x = 0
189
+ this.event.y = 0
190
+ }
191
+
192
+ destroy = () => {
193
+ this.unbind()
194
+ emitter.off(EVT_ID, () => {}) // Clear all
195
+ }
196
+ }
@@ -0,0 +1,8 @@
1
+ import emitter, { EVENTS, PRIORITY } from "./Emitter"
2
+ import VirtualScroll from "./VirtualScroll"
3
+ import Scroll from "./Scroll"
4
+ import Resize from "./Resize"
5
+ import Mouse from "./Mouse"
6
+ import Raf from "./Raf"
7
+
8
+ export { emitter, EVENTS, PRIORITY, Resize, VirtualScroll, Scroll, Mouse, Raf }
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // stellar-kit/index.js
2
+ import { Mouse, Resize, Raf, Scroll } from "./events"
3
+ import kitStore from "./kitStore"
4
+ import { sniffer } from "./utils"
5
+
6
+ export function setupKit({ isSmooth = sniffer.isDesktop } = {}) {
7
+ kitStore.flags.isSmooth = isSmooth
8
+ kitStore.flags.isLocked = false
9
+
10
+ kitStore.resize = new Resize()
11
+ kitStore.raf = new Raf()
12
+ kitStore.scroll = new Scroll({ isSmooth })
13
+ kitStore.mouse = new Mouse()
14
+ }
15
+
16
+ export { kitStore }
package/kitStore.js ADDED
@@ -0,0 +1,35 @@
1
+ //kitStore.js
2
+ export const sizes = {
3
+ vw: 0,
4
+ vh: 0,
5
+ fh: 0,
6
+ }
7
+
8
+ export const flags = {
9
+ isFocus: false,
10
+ isSmooth: false,
11
+ isResizing: false,
12
+ // ... optionally others
13
+ }
14
+
15
+ export const breakpoints = {
16
+ xsmallDown: false,
17
+ smallDown: false,
18
+ smallUp: false,
19
+ mediumDown: false,
20
+ mediumUp: false,
21
+ }
22
+
23
+ export const mouse = {
24
+ el: null,
25
+ x: 0,
26
+ y: 0,
27
+ }
28
+
29
+ export default {
30
+ sizes,
31
+ breakpoints,
32
+ mouse,
33
+ flags,
34
+ pageContent: null, // This should be set to the main content element
35
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@linear_non/stellar-kit",
3
+ "version": "1.0.0",
4
+ "description": "Stellar frontend core for Non-Linear Studio projects.",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "dev": "vite",
12
+ "format": "prettier --write .",
13
+ "check": "vite build --emptyOutDir --watch"
14
+ },
15
+ "files": [
16
+ "classes/",
17
+ "events/",
18
+ "utils/",
19
+ "kitStore.js",
20
+ "index.js"
21
+ ],
22
+ "keywords": [
23
+ "non-linear",
24
+ "frontend",
25
+ "core",
26
+ "scroll",
27
+ "raf",
28
+ "gsap"
29
+ ],
30
+ "author": "Non-Linear Studio",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "gsap": "^3.12.5",
34
+ "lethargy": "^1.0.9",
35
+ "lodash.debounce": "^4.0.8"
36
+ },
37
+ "devDependencies": {
38
+ "@linear_non/prettier-config": "^1.0.5",
39
+ "vite": "^5.2.0"
40
+ }
41
+ }
@@ -0,0 +1,15 @@
1
+ // bounds.js
2
+ export const bounds = el => {
3
+ const rect = el.getBoundingClientRect()
4
+
5
+ return {
6
+ bottom: rect.bottom,
7
+ left: rect.left,
8
+ height: rect.height,
9
+ right: rect.right,
10
+ top: rect.top,
11
+ width: rect.width,
12
+ x: rect.x,
13
+ y: rect.y,
14
+ }
15
+ }
package/utils/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { qs, qsa, getid, gettag } from "./selector"
2
+ export { bounds } from "./bounds"
3
+ export { getViewport, getWindowSizes, setViewportHeight } from "./window"
4
+ export { sniffer } from "./sniffer"
5
+ export { lerp, clamp, norm, round } from "./math"
6
+ export { supportWebp, supportMouseTouch } from "./support"
7
+ export { listener } from "./listener"
8
+ export { splitText, reverseSplit } from "./splitText"
@@ -0,0 +1,12 @@
1
+ // listener.js
2
+ export const listener = (el, action, type, cb, p) => {
3
+ const passive = p === true ? { passive: true } : false
4
+
5
+ if (el.length) {
6
+ for (let i = 0; i < el.length; i++) {
7
+ el[i][action + "EventListener"](type, cb, passive)
8
+ }
9
+ } else {
10
+ el[action + "EventListener"](type, cb, passive)
11
+ }
12
+ }
package/utils/math.js ADDED
@@ -0,0 +1,17 @@
1
+ // math.js
2
+ export const lerp = (a, b, n) => {
3
+ return a * (1 - n) + b * n
4
+ }
5
+
6
+ export const norm = (val, min, max) => {
7
+ return (val - min) / (max - min)
8
+ }
9
+
10
+ export const clamp = (val, min, max) => {
11
+ return Math.min(Math.max(val, min), max)
12
+ }
13
+
14
+ export const round = (n, p) => {
15
+ const precision = p !== undefined ? Math.pow(10, p) : 1000
16
+ return Math.round(n * precision) / precision
17
+ }
@@ -0,0 +1,5 @@
1
+ // selector.js
2
+ export const qs = (s, o = document) => o.querySelector(s)
3
+ export const qsa = (s, o = document) => Array.from(o.querySelectorAll(s))
4
+ export const getid = (s, o = document) => o.getElementById(s)
5
+ export const gettag = (s, o = document) => o.getElementsByTagName(s)
@@ -0,0 +1,133 @@
1
+ // sniffer.js
2
+ export const sniffer = {
3
+ uA: navigator.userAgent.toLowerCase(),
4
+
5
+ get isWindowsMobile() {
6
+ return /windows phone|iemobile|wpdesktop/.test(this.uA)
7
+ },
8
+
9
+ get isMobileOpera() {
10
+ return /opera mini/i.test(this.uA)
11
+ },
12
+
13
+ get isIOS() {
14
+ return /iphone|ipad|ipod/i.test(this.uA)
15
+ },
16
+
17
+ get isIpad() {
18
+ return !this.isWindowsMobile && /ipad/i.test(this.uA) && this.isIOS
19
+ },
20
+
21
+ get isLatestIpad() {
22
+ return /Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 1
23
+ },
24
+
25
+ get isIphone() {
26
+ return !this.isWindowsMobile && /iphone/i.test(this.uA) && this.isIOS
27
+ },
28
+
29
+ get isMobileAndroid() {
30
+ return !this.isWindowsMobile && /android.*mobile/.test(this.uA)
31
+ },
32
+
33
+ get isTabletAndroid() {
34
+ return !this.isWindowsMobile && !this.isMobileAndroid && /android/i.test(this.uA)
35
+ },
36
+
37
+ get isAndroid() {
38
+ return this.isMobileAndroid || this.isTabletAndroid
39
+ },
40
+
41
+ get isPhone() {
42
+ return this.isMobileAndroid || (this.isIOS && !this.isIpad) || this.isWindowsMobile
43
+ },
44
+
45
+ get isTablet() {
46
+ return this.isTabletAndroid || this.isIpad || this.isLatestIpad
47
+ },
48
+
49
+ get isDevice() {
50
+ return this.isPhone || this.isTablet
51
+ },
52
+
53
+ get isFirefox() {
54
+ return this.uA.includes("firefox")
55
+ },
56
+
57
+ get isSafari() {
58
+ return !!this.uA.match(/version\/[\d\.]+.*safari/)
59
+ },
60
+
61
+ get isOpera() {
62
+ return this.uA.includes("opr")
63
+ },
64
+
65
+ get isIE11() {
66
+ return !window.ActiveXObject && "ActiveXObject" in window
67
+ },
68
+
69
+ get isIE() {
70
+ return this.uA.includes("msie") || this.isIE11 || this.uA.includes("edge")
71
+ },
72
+
73
+ get isEdge() {
74
+ return this.uA.includes("edge")
75
+ },
76
+
77
+ get isWindows() {
78
+ return ["Win32", "Win64", "Windows", "WinCE"].includes(window.navigator.platform)
79
+ },
80
+
81
+ get isChrome() {
82
+ return (
83
+ window.chrome !== null &&
84
+ window.chrome !== undefined &&
85
+ navigator.vendor.toLowerCase() === "google inc." &&
86
+ !this.isOpera &&
87
+ !this.isEdge
88
+ )
89
+ },
90
+
91
+ get isMac() {
92
+ return navigator.platform.toLowerCase().includes("mac")
93
+ },
94
+
95
+ get isDesktop() {
96
+ return !this.isPhone && !this.isTablet
97
+ },
98
+
99
+ get isTouch() {
100
+ return "ontouchstart" in window
101
+ },
102
+
103
+ get sniff() {
104
+ return {
105
+ isWindowsMobile: this.isWindowsMobile,
106
+ isMobileOpera: this.isMobileOpera,
107
+ isIOS: this.isIOS,
108
+ isIpad: this.isIpad,
109
+ isIphone: this.isIphone,
110
+ isMobileAndroid: this.isMobileAndroid,
111
+ isTabletAndroid: this.isTabletAndroid,
112
+ isAndroid: this.isAndroid,
113
+ isFirefox: this.isFirefox,
114
+ isSafari: this.isSafari,
115
+ isOpera: this.isOpera,
116
+ isIE11: this.isIE11,
117
+ isIE: this.isIE,
118
+ isEdge: this.isEdge,
119
+ isChrome: this.isChrome,
120
+ isMac: this.isMac,
121
+ isPhone: this.isPhone,
122
+ isTablet: this.isTablet,
123
+ isDevice: this.isDevice,
124
+ isDesktop: this.isDesktop,
125
+ isWindows: this.isWindows,
126
+ isTouch: this.isTouch,
127
+ }
128
+ },
129
+
130
+ update() {
131
+ this.uA = navigator.userAgent.toLowerCase()
132
+ },
133
+ }
@@ -0,0 +1,45 @@
1
+ // splitText.js
2
+ import { emitter, EVENTS } from "../events"
3
+ import { SplitText } from "../libraries/gsap"
4
+
5
+ // Emits `APP_SPLITTEXT_READY` once all elements are split
6
+ export const splitText = elements => {
7
+ const splits = []
8
+ let splitted = 0
9
+ const totalSplits = elements.length
10
+
11
+ elements.forEach(el => {
12
+ const dataset = el.dataset.split
13
+ const types = dataset.split(",")
14
+ const classes = {}
15
+
16
+ types.forEach((type, i) => {
17
+ const t = type.trim()
18
+ if (t === "lines") classes.linesClass = `line-${i}`
19
+ if (t === "words") classes.wordsClass = `word-${i}`
20
+ if (t === "chars") classes.charsClass = `char-${i} chr-++`
21
+ })
22
+
23
+ const splitInstance = new SplitText(el, {
24
+ type: dataset.toString(),
25
+ ...classes,
26
+ })
27
+
28
+ splits.push(splitInstance)
29
+ splitted++
30
+
31
+ if (splitted === totalSplits) {
32
+ setTimeout(() => {
33
+ emitter.emit(EVENTS.APP_SPLITTEXT_READY, splits)
34
+ }, 0)
35
+ }
36
+ })
37
+
38
+ return splits
39
+ }
40
+
41
+ export const reverseSplit = instances => {
42
+ instances.forEach(split => {
43
+ split.revert()
44
+ })
45
+ }
@@ -0,0 +1,23 @@
1
+ // support.js
2
+ export const supportWebp = () => {
3
+ const el = document.createElement("canvas")
4
+ if (el.getContext && el.getContext("2d")) {
5
+ return el.toDataURL("image/webp").indexOf("data:image/webp") === 0
6
+ }
7
+ return false
8
+ }
9
+
10
+ export const supportMouseTouch = () => {
11
+ return {
12
+ hasWheelEvent: "onwheel" in document,
13
+ hasMouseWheelEvent: "onmousewheel" in document,
14
+ hasTouch:
15
+ "ontouchstart" in window ||
16
+ window.TouchEvent ||
17
+ (window.DocumentTouch && document instanceof DocumentTouch),
18
+ hasTouchWin: navigator.msMaxTouchPoints && navigator.msMaxTouchPoints > 1,
19
+ hasPointer: !!window.navigator.msPointerEnabled,
20
+ hasKeyDown: "onkeydown" in document,
21
+ isFirefox: navigator.userAgent.indexOf("Firefox") > -1,
22
+ }
23
+ }