simplyview 2.1.0 → 3.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,125 @@
1
+ class SimplyCommands {
2
+ constructor(options={}) {
3
+ if (!options.app) {
4
+ options.app = {}
5
+ }
6
+ if (!options.app.container) {
7
+ options.app.container = document.body
8
+ }
9
+ this.$handlers = options.handlers || defaultHandlers
10
+ if (options.commands) {
11
+ Object.assign(this, options.commands)
12
+ }
13
+
14
+ const commandHandler = (evt) => {
15
+ const command = getCommand(evt, this.$handlers)
16
+ if (!command) {
17
+ return
18
+ }
19
+ if (!this[command.name]) {
20
+ console.error('simply.command: undefined command '+command.name, command.source);
21
+ return
22
+ }
23
+ const shouldContinue = this[command.name].call(options.app, command.source, command.value)
24
+ if (shouldContinue===false) {
25
+ evt.preventDefault()
26
+ evt.stopPropagation()
27
+ return false
28
+ }
29
+ }
30
+
31
+ options.app.container.addEventListener('click', commandHandler)
32
+ options.app.container.addEventListener('submit', commandHandler)
33
+ options.app.container.addEventListener('change', commandHandler)
34
+ options.app.container.addEventListener('input', commandHandler)
35
+ }
36
+ }
37
+
38
+ export function commands(options={}) {
39
+ return new SimplyCommands(options)
40
+ }
41
+
42
+ function getCommand(evt, handlers) {
43
+ var el = evt.target.closest('[data-simply-command]')
44
+ if (el) {
45
+ for (let handler of handlers) {
46
+ if (el.matches(handler.match)) {
47
+ if (handler.check(el, evt)) {
48
+ return {
49
+ name: el.dataset.simplyCommand,
50
+ source: el,
51
+ value: handler.get(el)
52
+ }
53
+ }
54
+ return null
55
+ }
56
+ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ const defaultHandlers = [
62
+ {
63
+ match: 'input,select,textarea',
64
+ get: function(el) {
65
+ if (el.tagName==='SELECT' && el.multiple) {
66
+ let values = []
67
+ for (let option of el.options) {
68
+ if (option.selected) {
69
+ values.push(option.value)
70
+ }
71
+ }
72
+ return values
73
+ }
74
+ return el.dataset.simplyValue || el.value
75
+ },
76
+ check: function(el, evt) {
77
+ return evt.type=='change' || (el.dataset.simplyImmediate && evt.type=='input')
78
+ }
79
+ },
80
+ {
81
+ match: 'a,button',
82
+ get: function(el) {
83
+ return el.dataset.simplyValue || el.href || el.value
84
+ },
85
+ check: function(el,evt) {
86
+ return evt.type=='click' && evt.ctrlKey==false && evt.button==0
87
+ }
88
+ },
89
+ {
90
+ match: 'form',
91
+ get: function(el) {
92
+ let data = {}
93
+ for (let input of Array.from(el.elements)) {
94
+ if (input.tagName=='INPUT'
95
+ && (input.type=='checkbox' || input.type=='radio')
96
+ ) {
97
+ if (!input.checked) {
98
+ return;
99
+ }
100
+ }
101
+ if (data[input.name] && !Array.isArray(data[input.name])) {
102
+ data[input.name] = [data[input.name]]
103
+ }
104
+ if (Array.isArray(data[input.name])) {
105
+ data[input.name].push(input.value)
106
+ } else {
107
+ data[input.name] = input.value
108
+ }
109
+ }
110
+ return data
111
+ },
112
+ check: function(el,evt) {
113
+ return evt.type=='submit'
114
+ }
115
+ },
116
+ {
117
+ match: '*',
118
+ get: function(el) {
119
+ return el.dataset.simplyValue
120
+ },
121
+ check: function(el, evt) {
122
+ return evt.type=='click' && evt.ctrlKey==false && evt.button==0
123
+ }
124
+ }
125
+ ]
@@ -0,0 +1,27 @@
1
+ import { activate } from './activate.mjs'
2
+ import * as action from './action.mjs'
3
+ import { app } from './app.mjs'
4
+ import { bind } from './bind.mjs'
5
+ import * as command from './command.mjs'
6
+ import { include } from './include.mjs'
7
+ import * as key from './key.mjs'
8
+ import * as model from './model.mjs'
9
+ import * as route from './route.mjs'
10
+ import * as state from './state.mjs'
11
+
12
+ const simply = {
13
+ activate,
14
+ action,
15
+ app,
16
+ bind,
17
+ command,
18
+ include,
19
+ key,
20
+ model,
21
+ route,
22
+ state
23
+ }
24
+
25
+ window.simply = simply
26
+
27
+ export default simply
@@ -0,0 +1,191 @@
1
+ function throttle( callbackFunction, intervalTime ) {
2
+ let eventId = 0
3
+ return () => {
4
+ const myArguments = arguments
5
+ if ( eventId ) {
6
+ return
7
+ } else {
8
+ eventId = globalThis.setTimeout( () => {
9
+ callbackFunction.apply(this, myArguments)
10
+ eventId = 0
11
+ }, intervalTime )
12
+ }
13
+ }
14
+ }
15
+
16
+ const runWhenIdle = (() => {
17
+ if (globalThis.requestIdleCallback) {
18
+ return (callback) => {
19
+ globalThis.requestIdleCallback(callback, {timeout: 500})
20
+ }
21
+ }
22
+ return globalThis.requestAnimationFrame
23
+ })()
24
+
25
+ function rebaseHref(relative, base) {
26
+ let url = new URL(relative, base)
27
+ if (include.cacheBuster) {
28
+ url.searchParams.set('cb',include.cacheBuster)
29
+ }
30
+ return url.href
31
+ }
32
+
33
+ let observer, loaded = {}
34
+ let head = globalThis.document.querySelector('head')
35
+ let currentScript = globalThis.document.currentScript
36
+ let getScriptURL, currentScriptURL
37
+ if (!currentScript) {
38
+ getScriptURL = (() => {
39
+ var scripts = document.getElementsByTagName('script')
40
+ var index = scripts.length - 1
41
+ var myScript = scripts[index]
42
+ return () => myScript.src
43
+ })()
44
+ currentScriptURL = getScriptURL()
45
+ } else {
46
+ currentScriptURL = currentScript.src
47
+ }
48
+
49
+ const waitForPreviousScripts = async () => {
50
+ // because of the async=false attribute, this script will run after
51
+ // the previous scripts have been loaded and run
52
+ // simply.include.next.js only fires the simply-next-script event
53
+ // that triggers the Promise.resolve method
54
+ return new Promise(function(resolve) {
55
+ var next = globalThis.document.createElement('script')
56
+ next.src = rebaseHref('simply.include.next.js', currentScriptURL)
57
+ next.async = false
58
+ globalThis.document.addEventListener('simply-include-next', () => {
59
+ head.removeChild(next)
60
+ resolve()
61
+ }, { once: true, passive: true})
62
+ head.appendChild(next)
63
+ })
64
+ }
65
+
66
+ let scriptLocations = []
67
+
68
+ export const include = {
69
+ cacheBuster: null,
70
+ scripts: (scripts, base) => {
71
+ let arr = scripts.slice()
72
+ const importScript = () => {
73
+ const script = arr.shift()
74
+ if (!script) {
75
+ return
76
+ }
77
+ const attrs = [].map.call(script.attributes, (attr) => {
78
+ return attr.name
79
+ })
80
+ let clone = globalThis.document.createElement('script')
81
+ for (const attr of attrs) {
82
+ clone.setAttribute(attr, script.getAttribute(attr))
83
+ }
84
+ clone.removeAttribute('data-simply-location')
85
+ if (!clone.src) {
86
+ // this is an inline script, so copy the content and wait for previous scripts to run
87
+ clone.innerHTML = script.innerHTML
88
+ waitForPreviousScripts()
89
+ .then(() => {
90
+ const node = scriptLocations[script.dataset.simplyLocation]
91
+ node.parentNode.insertBefore(clone, node)
92
+ node.parentNode.removeChild(node)
93
+ importScript()
94
+ })
95
+ } else {
96
+ clone.src = rebaseHref(clone.src, base)
97
+ if (!clone.hasAttribute('async') && !clone.hasAttribute('defer')) {
98
+ clone.async = false //important! do not use clone.setAttribute('async', false) - it has no effect
99
+ }
100
+ const node = scriptLocations[script.dataset.simplyLocation]
101
+ node.parentNode.insertBefore(clone, node)
102
+ node.parentNode.removeChild(node)
103
+ loaded[clone.src]=true
104
+ importScript()
105
+ }
106
+ }
107
+ if (arr.length) {
108
+ importScript()
109
+ }
110
+ },
111
+ html: (html, link) => {
112
+ let fragment = globalThis.document.createRange().createContextualFragment(html)
113
+ const stylesheets = fragment.querySelectorAll('link[rel="stylesheet"],style')
114
+ // add all stylesheets to head
115
+ for (let stylesheet of stylesheets) {
116
+ if (stylesheet.href) {
117
+ stylesheet.href = rebaseHref(stylesheet.href, link.href)
118
+ }
119
+ head.appendChild(stylesheet)
120
+ }
121
+ // remove the scripts from the fragment, as they will not run in the
122
+ // order in which they are defined
123
+ let scriptsFragment = globalThis.document.createDocumentFragment()
124
+ const scripts = fragment.querySelectorAll('script')
125
+ for (let script of scripts) {
126
+ let placeholder = globalThis.document.createComment(script.src || 'inline script')
127
+ script.parentNode.insertBefore(placeholder, script)
128
+ script.dataset.simplyLocation = scriptLocations.length
129
+ scriptLocations.push(placeholder)
130
+ scriptsFragment.appendChild(script)
131
+ }
132
+ // add the remainder before the include link
133
+ link.parentNode.insertBefore(fragment, link ? link : null)
134
+ globalThis.setTimeout(function() {
135
+ include.scripts(scriptsFragment.childNodes, link ? link.href : globalThis.location.href )
136
+ }, 10)
137
+ }
138
+ }
139
+
140
+ let included = {}
141
+ const includeLinks = async (links) => {
142
+ // mark them as in progress, so handleChanges doesn't find them again
143
+ let remainingLinks = [].reduce.call(links, (remainder, link) => {
144
+ if (link.rel=='simply-include-once' && included[link.href]) {
145
+ link.parentNode.removeChild(link)
146
+ } else {
147
+ included[link.href]=true
148
+ link.rel = 'simply-include-loading'
149
+ remainder.push(link)
150
+ }
151
+ return remainder
152
+ }, [])
153
+
154
+ for (let link of remainingLinks) {
155
+ if (!link.href) {
156
+ return
157
+ }
158
+ // fetch the html
159
+ const response = await fetch(link.href)
160
+ if (!response.ok) {
161
+ console.log('simply-include: failed to load '+link.href);
162
+ continue
163
+ }
164
+ console.log('simply-include: loaded '+link.href);
165
+ const html = await response.text()
166
+ // if succesfull import the html
167
+ include.html(html, link)
168
+ // remove the include link
169
+ link.parentNode.removeChild(link)
170
+ }
171
+ }
172
+
173
+ const handleChanges = throttle(() => {
174
+ runWhenIdle(() => {
175
+ var links = globalThis.document.querySelectorAll('link[rel="simply-include"],link[rel="simply-include-once"]')
176
+ if (links.length) {
177
+ includeLinks(links)
178
+ }
179
+ })
180
+ })
181
+
182
+ const observe = () => {
183
+ observer = new MutationObserver(handleChanges)
184
+ observer.observe(globalThis.document, {
185
+ subtree: true,
186
+ childList: true,
187
+ })
188
+ }
189
+
190
+ observe()
191
+ handleChanges() // check if there are include links in the dom already
package/src/key.mjs ADDED
@@ -0,0 +1,55 @@
1
+ class SimplyKeys {
2
+ constructor(options = {}) {
3
+ if (!options.app) {
4
+ options.app = {}
5
+ }
6
+ if (!options.app.container) {
7
+ options.app.container = document.body
8
+ }
9
+ Object.assign(this, options.keys)
10
+
11
+ const keyHandler = (e) => {
12
+ if (e.isComposing || e.keyCode === 229) {
13
+ return;
14
+ }
15
+ if (e.defaultPrevented) {
16
+ return;
17
+ }
18
+ if (!e.target) {
19
+ return;
20
+ }
21
+
22
+ let selectedKeyboard = 'default';
23
+ if (e.target.closest('[data-simply-keyboard]')) {
24
+ selectedKeyboard = e.target.closest('[data-simply-keyboard]').dataset.simplyKeyboard;
25
+ }
26
+ let key = '';
27
+ if (e.ctrlKey && e.keyCode!=17) {
28
+ key+='Control+';
29
+ }
30
+ if (e.metaKey && e.keyCode!=224) {
31
+ key+='Meta+';
32
+ }
33
+ if (e.altKey && e.keyCode!=18) {
34
+ key+='Alt+';
35
+ }
36
+ if (e.shiftKey && e.keyCode!=16) {
37
+ key+='Shift+';
38
+ }
39
+ key+=e.key;
40
+
41
+ if (this[selectedKeyboard] && this[selectedKeyboard][key]) {
42
+ let keyboard = this[selectedKeyboard]
43
+ keyboard[key].call(options.app,e);
44
+ }
45
+ }
46
+
47
+ options.app.container.addEventListener('keydown', keyHandler)
48
+ }
49
+
50
+ }
51
+
52
+ export function keys(options={}) {
53
+ return new SimplyKeys(options)
54
+ }
55
+
package/src/model.mjs ADDED
@@ -0,0 +1,151 @@
1
+ import {signal, effect, batch} from './state.mjs'
2
+
3
+ /**
4
+ * This class implements a pluggable data model, where you can
5
+ * add effects that are run only when either an option for that
6
+ * effect changes, or when an effect earlier in the chain of
7
+ * effects changes.
8
+ */
9
+ class SimplyModel {
10
+
11
+ /**
12
+ * Creates a new datamodel, with a state property that contains
13
+ * all the data passed to this constructor
14
+ * @param state Object with all the data for this model
15
+ */
16
+ constructor(state) {
17
+ this.state = signal(state)
18
+ if (!this.state.options) {
19
+ this.state.options = {}
20
+ }
21
+ this.effects = [{current:state.data}]
22
+ this.view = signal(state.data)
23
+ }
24
+
25
+ /**
26
+ * Adds an effect to run whenever a signal it depends on
27
+ * changes. this.state is the usual signal.
28
+ * The `fn` function param is not itself an effect, but must return
29
+ * and effect function. `fn` takes one param, which is the data signal.
30
+ * This signal will always have at least a `current` property.
31
+ * The result of the effect function is pushed on to the this.effects
32
+ * list. And the last effect added is set as this.view
33
+ */
34
+ addEffect(fn) {
35
+ const dataSignal = this.effects[this.effects.length-1]
36
+ this.view = fn.call(this, dataSignal)
37
+ this.effects.push(this.view)
38
+ }
39
+ }
40
+
41
+ export function model(options) {
42
+ return new SimplyModel(options)
43
+ }
44
+
45
+ export function sort(options={}) {
46
+ return function(data) {
47
+ // initialize the sort options, only gets called once
48
+ this.state.options.sort = Object.assign({
49
+ direction: 'asc',
50
+ sortBy: null,
51
+ sortFn: ((a,b) => {
52
+ const sort = this.state.options.sort
53
+ const sortBy = sort.sortBy
54
+ if (!sort.sortBy) {
55
+ return 0
56
+ }
57
+ const larger = sort.direction == 'asc' ? 1 : -1
58
+ const smaller = sort.direction == 'asc' ? -1 : 1
59
+ if (typeof a?.[sortBy] === 'undefined') {
60
+ if (typeof b?.[sortBy] === 'undefined') {
61
+ return 0
62
+ }
63
+ return larger
64
+ }
65
+ if (typeof b?.[sortBy] === 'undefined') {
66
+ return smaller
67
+ }
68
+ if (a[sortBy]<b[sortBy]) {
69
+ return smaller
70
+ } else if (a[sortBy]>b[sortBy]) {
71
+ return larger
72
+ } else {
73
+ return 0
74
+ }
75
+ })
76
+ }, options);
77
+ // then return the effect, which is called when
78
+ // either the data or the sort options change
79
+ return effect(() => {
80
+ const sort = this.state.options.sort
81
+ if (sort?.sortBy && sort?.direction) {
82
+ return data.current.toSorted(sort?.sortFn)
83
+ }
84
+ return data.current
85
+ })
86
+ }
87
+ }
88
+
89
+ export function paging(options={}) {
90
+ return function(data) {
91
+ // initialize the paging options
92
+ this.state.options.paging = Object.assign({
93
+ page: 1,
94
+ pageSize: 20,
95
+ max: 1
96
+ }, options)
97
+ return effect(() => {
98
+ return batch(() => {
99
+ const paging = this.state.options.paging
100
+ if (!paging.pageSize) {
101
+ paging.pageSize = 20
102
+ }
103
+ paging.max = Math.ceil(this.state.data.length / paging.pageSize)
104
+ paging.page = Math.max(1, Math.min(paging.max, paging.page))
105
+
106
+ const start = (paging.page-1) * paging.pageSize
107
+ const end = start + paging.pageSize
108
+ return data.current.slice(start, end)
109
+ })
110
+ })
111
+ }
112
+ }
113
+
114
+ export function filter(options) {
115
+ if (!options?.name || typeof options.name!=='string') {
116
+ throw new Error('filter requires options.name to be a string')
117
+ }
118
+ if (!options.matches || typeof options.matches!=='function') {
119
+ throw new Error('filter requires options.matches to be a function')
120
+ }
121
+ return function(data) {
122
+ this.state.options[options.name] = options
123
+ return effect(() => {
124
+ if (this.state.options[options.name].enabled) {
125
+ return data.filter(this.state.options.matches)
126
+ }
127
+ })
128
+ }
129
+ }
130
+
131
+ export function columns(options={}) {
132
+ if (!options
133
+ || typeof options!=='object'
134
+ || Object.keys(options).length===0) {
135
+ throw new Error('columns requires options to be an object with at least one property')
136
+ }
137
+ return function(data) {
138
+ this.state.options.columns = options
139
+ return effect(() => {
140
+ return data.current.map(input => {
141
+ let result = {}
142
+ for (let key of Object.keys(this.state.options.columns)) {
143
+ if (!this.state.options.columns[key].hidden) {
144
+ result[key] = input[key]
145
+ }
146
+ }
147
+ return result
148
+ })
149
+ })
150
+ }
151
+ }