reflex_behaviors 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,18 +3,25 @@
3
3
  require ReflexBehaviors::Engine.root.join("lib/reflex_behaviors/tag_builders")
4
4
 
5
5
  module ReflexBehaviors::ApplicationHelper
6
- def friendly_partial_path(partial_path = nil, start: 1)
7
- prefix = "app/views/"
8
- partial_path ||= caller_locations(start, 1).first.path
9
- partial_path = partial_path.split(prefix).last if partial_path.include?(prefix)
10
- partial_path[0, partial_path.index(".") || partial_path.length].gsub(/\/_/, "/")
6
+ def idomatic_partial_path(partial_path)
7
+ partial_path.to_s.gsub("/_", "/").split(".").first
11
8
  end
12
9
 
13
10
  def current_partial_path
14
- friendly_partial_path start: 2
11
+ path = nil
12
+ prefix = "app/views/"
13
+ start = 1
14
+ while path.nil? && start < 100
15
+ location = caller_locations(start, 1).first
16
+ path = location.path if location.path.include?(prefix)
17
+ start += 1
18
+ end
19
+ return "unknown" if path.nil?
20
+ path[(path.index(prefix) + prefix.length), path.rindex("/")]
15
21
  end
16
22
 
17
23
  def reflex_render(**kwargs)
24
+ kwargs[:partial] = idomatic_partial_path(kwargs[:partial])
18
25
  kwargs[:assigns] ||= {}
19
26
  kwargs[:assigns].each { |key, val| kwargs[:assigns][key] = transportable_value(val) }
20
27
  kwargs[:locals] ||= {}
@@ -0,0 +1,34 @@
1
+ export function template (html) {
2
+ let template = document.createElement('template')
3
+ template.innerHTML = html
4
+ return template
5
+ }
6
+
7
+ export function appendHTML (html, parent) {
8
+ parent = parent || document.body
9
+ return parent.appendChild(template(html).content.cloneNode(true))
10
+ }
11
+
12
+ export function addHighlight (element, options = {}) {
13
+ if (!element) return
14
+ let { color, offset, width } = options
15
+ color = color || 'red'
16
+ offset = offset || '0px'
17
+ width = width || '3px'
18
+ const { outline, outlineOffset } = element.style
19
+ element.originalStyles = element.originalStyles || {
20
+ outline,
21
+ outlineOffset
22
+ }
23
+ element.style.outline = `dotted ${width} ${color}`
24
+ element.style.outlineOffset = offset
25
+ }
26
+
27
+ export function removeHighlight (element) {
28
+ if (element && element.originalStyles) {
29
+ for (const [key, value] of Object.entries(element.originalStyles)) {
30
+ value ? (element.style[key] = value) : (element.style[key] = '')
31
+ }
32
+ delete element.originalStyles
33
+ }
34
+ }
@@ -0,0 +1,62 @@
1
+ export default class DevtoolElement extends HTMLElement {
2
+ constructor () {
3
+ super()
4
+ this.attachShadow({ mode: 'open' })
5
+ this.shadowRoot.innerHTML = this.html
6
+ this.labelElement.addEventListener('click', event => {
7
+ event.preventDefault()
8
+ this.toggle()
9
+ })
10
+ this.checkboxElement.addEventListener('change', event =>
11
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true }))
12
+ )
13
+ }
14
+
15
+ toggle () {
16
+ this.checked ? this.uncheck() : this.check()
17
+ }
18
+
19
+ check () {
20
+ this.checkboxElement.checked = true
21
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true }))
22
+ }
23
+
24
+ uncheck () {
25
+ this.checkboxElement.checked = false
26
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true }))
27
+ }
28
+
29
+ get name () {
30
+ return this.getAttribute('name')
31
+ }
32
+
33
+ get checked () {
34
+ return this.checkboxElement.checked
35
+ }
36
+
37
+ get checkboxElement () {
38
+ return this.shadowRoot.querySelector('input')
39
+ }
40
+
41
+ get labelElement () {
42
+ return this.shadowRoot.querySelector('label')
43
+ }
44
+
45
+ get html () {
46
+ return `
47
+ <style>${this.stylesheet}</style>
48
+ <div>
49
+ <input name="checkbox" type="checkbox">
50
+ <label for="checkbox"><slot name="label"></slot></label>
51
+ </div>
52
+ `
53
+ }
54
+
55
+ get stylesheet () {
56
+ return `
57
+ div {
58
+ display: flex;
59
+ }
60
+ `
61
+ }
62
+ }
@@ -0,0 +1,113 @@
1
+ import { appendHTML } from '../dom'
2
+
3
+ export default class SupervisorElement extends HTMLElement {
4
+ constructor () {
5
+ super()
6
+ this.enabledDevtools = {}
7
+ this.attachShadow({ mode: 'open' })
8
+ this.shadowRoot.innerHTML = this.html
9
+ this.shadowRoot
10
+ .querySelector('button[data-role="closer"]')
11
+ .addEventListener('click', () => this.close())
12
+
13
+ this.addEventListener('change', event => {
14
+ const devtoolElement = event.target
15
+ const { checked, name } = devtoolElement
16
+ checked ? this.enableDevtool(name) : this.disableDevtool(name)
17
+ })
18
+ }
19
+
20
+ enableDevtool (name) {
21
+ if (this.enabledDevtools[name]) return
22
+ this.enabledDevtools[name] = true
23
+ this.dispatchEvent(
24
+ new CustomEvent('reflex-behaviors:devtool-enable', {
25
+ bubbles: true,
26
+ detail: { name: name }
27
+ })
28
+ )
29
+ }
30
+
31
+ disableDevtool (name) {
32
+ if (!this.enabledDevtools[name]) return
33
+ delete this.enabledDevtools[name]
34
+ this.dispatchEvent(
35
+ new CustomEvent('reflex-behaviors:devtool-disable', {
36
+ bubbles: true,
37
+ detail: { name: name }
38
+ })
39
+ )
40
+ }
41
+
42
+ close () {
43
+ this.devtoolElements.forEach(el => {
44
+ if (el.checked) el.uncheck()
45
+ })
46
+ this.remove()
47
+ }
48
+
49
+ get devtoolElements () {
50
+ return this.querySelectorAll('[slot="devtool"]')
51
+ }
52
+
53
+ get closeElement () {
54
+ return this.querySelector('button[data-role="closer"]')
55
+ }
56
+
57
+ get html () {
58
+ return `
59
+ <style>${this.stylesheet}</style>
60
+ <div data-role="container">
61
+ <strong>ReflexBehaviors</strong>
62
+ <slot name="devtool"></slot>
63
+ <button data-role='closer'>X</button>
64
+ </div>
65
+ `
66
+ }
67
+
68
+ get stylesheet () {
69
+ return `
70
+ :host {
71
+ background-color: ghostwhite;
72
+ border-radius: 10px;
73
+ outline: solid 1px gainsboro;
74
+ bottom: 20px;
75
+ display: block;
76
+ filter: drop-shadow(0 4px 3px rgba(0,0,0,.07));
77
+ left: 50%;
78
+ padding: 5px 10px;
79
+ position: fixed;
80
+ transform: translateX(-50%);
81
+ z-index: 10000;
82
+ }
83
+
84
+ :host, :host * {
85
+ -webkit-user-select: none;
86
+ user-select: none;
87
+ }
88
+
89
+ strong {
90
+ color: silver;
91
+ font-weight: 600;
92
+ }
93
+
94
+ div[data-role="container"] {
95
+ display: flex;
96
+ gap: 0 5px;
97
+ }
98
+
99
+ button[data-role="closer"] {
100
+ border: none;
101
+ background-color: gainsboro;
102
+ border-radius: 50%;
103
+ color: white;
104
+ font-size: 12px;
105
+ height: 18px;
106
+ line-height: 18px;
107
+ margin: 0 -5px 0 10px;
108
+ padding: 0 3px;
109
+ width: 18px;
110
+ }
111
+ `
112
+ }
113
+ }
@@ -0,0 +1,102 @@
1
+ export default class TooltipElement extends HTMLElement {
2
+ constructor () {
3
+ super()
4
+ this.attachShadow({ mode: 'open' })
5
+ this.shadowRoot.innerHTML = this.html
6
+ }
7
+
8
+ get color () {
9
+ return this.getAttribute('color') || 'darkslategray'
10
+ }
11
+
12
+ get emphasisColor () {
13
+ return this.getAttribute('emphasis-color') || 'black'
14
+ }
15
+
16
+ get backgroundColor () {
17
+ return this.getAttribute('background-color') || 'gainsboro'
18
+ }
19
+
20
+ get position () {
21
+ return this.getAttribute('position') || 'top'
22
+ }
23
+
24
+ get cssArrow () {
25
+ switch (this.position) {
26
+ case 'bottom':
27
+ return `transparent transparent ${this.emphasisColor} transparent`
28
+ default:
29
+ return `${this.emphasisColor} transparent transparent transparent;`
30
+ }
31
+ }
32
+
33
+ get html () {
34
+ return `
35
+ <style>${this.stylesheet}</style>
36
+ <div role="tooltip">
37
+ <slot name="title"></slot>
38
+ <hr>
39
+ <slot name="normal"></slot>
40
+ <slot name="emphasis"></slot>
41
+ </div>
42
+ `
43
+ }
44
+
45
+ get stylesheet () {
46
+ return `
47
+ :host {
48
+ display: block;
49
+ position: absolute;
50
+ z-index: 10000;
51
+ }
52
+
53
+ * {
54
+ color: ${this.color}
55
+ }
56
+
57
+ [role="tooltip"] {
58
+ background-color: ${this.backgroundColor};
59
+ border-radius: 5px;
60
+ filter: drop-shadow(3px 3px 3px rgba(0,0,0,0.3));
61
+ font-family: monospace;
62
+ left: 50px;
63
+ min-height: 30px;
64
+ min-width: 100px;
65
+ opacity: 0.9;
66
+ outline: solid 2px ${this.emphasisColor};
67
+ padding: 8px 12px 8px 12px;
68
+ white-space: nowrap;
69
+ }
70
+
71
+ [role="tooltip"]::after {
72
+ border-color: ${this.cssArrow};
73
+ border-style: solid;
74
+ border-width: 5px;
75
+ content: "";
76
+ margin-left: -5px;
77
+ position: absolute;
78
+ top: ${this.position === 'bottom' ? '-10px' : '100%'};
79
+ }
80
+
81
+ slot[name="title"] {
82
+ color: ${this.emphasisColor};
83
+ font-weight: bold;
84
+ }
85
+
86
+ slot[name="emphasis"] {
87
+ color: ${this.emphasisColor};
88
+ font-weight: normal;
89
+ opacity: 0.7;
90
+ }
91
+
92
+ hr {
93
+ background-color: ${this.emphasisColor};
94
+ border: none;
95
+ height: 1px;
96
+ margin-bottom: 4px;
97
+ margin-top: 4px;
98
+ opacity: 0.3;
99
+ }
100
+ `
101
+ }
102
+ }
@@ -0,0 +1,5 @@
1
+ import supervisor from './supervisor'
2
+
3
+ const { restart, start, stop } = supervisor
4
+
5
+ export default { restart, start, stop }
@@ -0,0 +1,83 @@
1
+ import { appendHTML } from './dom'
2
+ import DevtoolElement from './elements/devtool_element'
3
+ import SupervisorElement from './elements/supervisor_element'
4
+ import TooltipElement from './elements/tooltip_element'
5
+
6
+ customElements.define('reflex-behaviors-devtool', DevtoolElement)
7
+ customElements.define('reflex-behaviors-devtool-supervisor', SupervisorElement)
8
+ customElements.define('reflex-behaviors-devools-tooltip', TooltipElement)
9
+
10
+ let supervisorElement
11
+
12
+ function stop () {
13
+ if (!supervisorElement) return
14
+ supervisorElement.close()
15
+ supervisorElement.dispatchEvent(
16
+ new CustomEvent('reflex-behaviors:devtools-stop', {
17
+ bubbles: true
18
+ })
19
+ )
20
+ supervisorElement = null
21
+ }
22
+
23
+ function start () {
24
+ appendHTML(
25
+ '<reflex-behaviors-devtool-supervisor></reflex-behaviors-devtool-supervisor>'
26
+ )
27
+ supervisorElement = document.body.querySelector(
28
+ 'reflex-behaviors-devtool-supervisor'
29
+ )
30
+ supervisorElement.dispatchEvent(
31
+ new CustomEvent('reflex-behaviors:devtools-start', {
32
+ bubbles: true
33
+ })
34
+ )
35
+ }
36
+
37
+ function restart () {
38
+ const enabledList = supervisorElement
39
+ ? Object.keys(supervisorElement.enabledDevtools)
40
+ : []
41
+
42
+ stop()
43
+ start()
44
+
45
+ supervisorElement.devtoolElements.forEach(el => {
46
+ if (enabledList.includes(el.name)) el.check()
47
+ })
48
+ }
49
+
50
+ let restartTimeout
51
+ function debouncedRestart () {
52
+ clearTimeout(restartTimeout)
53
+ restartTimeout = setTimeout(restart, 25)
54
+ }
55
+ addEventListener('turbo:load', debouncedRestart)
56
+ addEventListener('turbo-frame:load', debouncedRestart)
57
+ addEventListener('turbo-reflex:success', debouncedRestart)
58
+ addEventListener('turbo-reflex:finish', debouncedRestart)
59
+
60
+ function register (name, label) {
61
+ if (!supervisorElement) return
62
+ return appendHTML(
63
+ `
64
+ <reflex-behaviors-devtool name="${name}" slot="devtool">
65
+ <span slot="label">${label}</span>
66
+ </reflex-behaviors-devtool>
67
+ `,
68
+ supervisorElement
69
+ )
70
+ }
71
+
72
+ function enabled (name) {
73
+ if (!supervisorElement) return false
74
+ return supervisorElement.enabledDevtools[name]
75
+ }
76
+
77
+ export default {
78
+ enabled,
79
+ register,
80
+ restart: debouncedRestart,
81
+ start,
82
+ stop
83
+ }
@@ -0,0 +1,150 @@
1
+ import { appendHTML, addHighlight, removeHighlight } from './dom'
2
+ import supervisor from './supervisor'
3
+
4
+ document.addEventListener('reflex-behaviors:devtools-start', () =>
5
+ supervisor.register('toggle', 'toggles<small>(trigger/target)</small>')
6
+ )
7
+
8
+ const triggerTooltipId = 'toggle-trigger-tooltip'
9
+ const targetTooltipId = 'toggle-target-tooltip'
10
+
11
+ addEventListener('click', () => setTimeout(removeTooltips))
12
+
13
+ function removeTooltips () {
14
+ const ids = [triggerTooltipId, targetTooltipId]
15
+ ids.forEach(id => {
16
+ const el = document.getElementById(id)
17
+ if (el) el.remove()
18
+ })
19
+ }
20
+
21
+ function appendTooltip (id, title, content, options = {}) {
22
+ let { backgroundColor, color, emphaisColor, position } = options
23
+ color = color || 'white'
24
+ position = position || 'top'
25
+
26
+ appendHTML(`
27
+ <reflex-behaviors-devools-tooltip id="${id}" position="${position}" background-color="${backgroundColor}" color="${color}" emphasis-color="${emphaisColor}">
28
+ <div slot='title'>${title}</div>
29
+ ${content}
30
+ </reflex-behaviors-devools-tooltip>
31
+ `)
32
+ return document.getElementById(id)
33
+ }
34
+
35
+ export default class ToggleDevtool {
36
+ constructor (trigger) {
37
+ this.name = 'toggle'
38
+ this.reflex = trigger.dataset.turboReflex
39
+ this.trigger = trigger
40
+ this.target = trigger.target
41
+ this.renderingPartial = trigger.renderingPartial
42
+ this.renderingElement = trigger.renderingElement
43
+ this.renderingElementId = this.renderingElement
44
+ ? this.renderingElement.id
45
+ : null
46
+
47
+ document.addEventListener('reflex-behaviors:devtool-enable', event => {
48
+ const { name } = event.detail
49
+ if (name === this.name)
50
+ addHighlight(this.trigger, { color: 'red', offset: '2px' })
51
+ })
52
+
53
+ document.addEventListener('reflex-behaviors:devtool-disable', event => {
54
+ const { name } = event.detail
55
+ if (name === this.name) removeHighlight(this.trigger)
56
+ })
57
+ }
58
+
59
+ get enabled () {
60
+ return supervisor.enabled(this.name)
61
+ }
62
+
63
+ show () {
64
+ if (!this.enabled) return
65
+ this.hide()
66
+ this.createTriggerTooltip()
67
+ this.createTargetTooltip()
68
+ addHighlight(this.target, { color: 'blue', offset: '-2px' })
69
+ addHighlight(this.renderingElement, {
70
+ color: 'turquoise',
71
+ offset: '4px',
72
+ width: '4px'
73
+ })
74
+
75
+ console.table({
76
+ trigger: { id: this.trigger.id, partial: this.trigger.partial },
77
+ target: { id: this.target.id, partial: this.target.partial },
78
+ [this.reflex]: {
79
+ id: this.renderingElementId,
80
+ partial: this.renderingPartial
81
+ }
82
+ })
83
+ }
84
+
85
+ hide () {
86
+ this.destroyTriggerTooltip()
87
+ this.destroyTargetTooltip()
88
+ removeHighlight(this.target)
89
+ removeHighlight(this.renderingElement)
90
+ }
91
+
92
+ createTriggerTooltip () {
93
+ const title = `TRIGGER (targets: ${this.trigger.controls})`
94
+ const content = this.trigger.viewStack
95
+ .map(view => {
96
+ return this.trigger.sharedViews.includes(view)
97
+ ? `<div slot="emphasis">${view}</div>`
98
+ : `<div slot="normal">${view}</div>`
99
+ }, this)
100
+ .join('')
101
+
102
+ this.triggerTooltip = appendTooltip(triggerTooltipId, title, content, {
103
+ backgroundColor: 'pink',
104
+ emphaisColor: 'darkred'
105
+ })
106
+
107
+ const coords = this.trigger.coordinates
108
+ const top = Math.ceil(coords.top - this.triggerTooltip.offsetHeight - 5)
109
+ const left = Math.ceil(coords.left)
110
+ this.triggerTooltip.style.top = `${top}px`
111
+ this.triggerTooltip.style.left = `${left}px`
112
+ }
113
+
114
+ destroyTriggerTooltip () {
115
+ if (!this.triggerTooltip) return
116
+ this.triggerTooltip.remove()
117
+ delete this.triggerTooltip
118
+ }
119
+
120
+ createTargetTooltip () {
121
+ if (!this.target) return
122
+
123
+ const title = `TARGET (id: ${this.target.id})`
124
+ const content = this.target.viewStack
125
+ .map(view => {
126
+ return this.trigger.sharedViews.includes(view)
127
+ ? `<div slot="emphasis">${view}</div>`
128
+ : `<div slot="normal">${view}</div>`
129
+ }, this)
130
+ .join('')
131
+
132
+ this.targetTooltip = appendTooltip(targetTooltipId, title, content, {
133
+ backgroundColor: 'lightskyblue',
134
+ emphaisColor: 'blue',
135
+ position: 'bottom'
136
+ })
137
+
138
+ const coords = this.target.coordinates
139
+ const top = Math.ceil(coords.top + coords.height + 4)
140
+ const left = Math.ceil(coords.left)
141
+ this.targetTooltip.style.top = `${top}px`
142
+ this.targetTooltip.style.left = `${left}px`
143
+ }
144
+
145
+ destroyTargetTooltip () {
146
+ if (!this.targetTooltip) return
147
+ this.targetTooltip.remove()
148
+ delete this.targetTooltip
149
+ }
150
+ }
@@ -1,6 +1,14 @@
1
1
  export default class ReflexElement extends HTMLElement {
2
+ constructor () {
3
+ super()
4
+ this.devtool = 'unknown'
5
+ this.attachShadow({ mode: 'open' })
6
+ this.shadowRoot.innerHTML = '<slot></slot>'
7
+ }
8
+
2
9
  connectedCallback () {
3
10
  this.ensureId()
11
+ this.dataset.elementOrigin = 'hopsoft/reflex_behaviors'
4
12
  }
5
13
 
6
14
  ensureId () {
@@ -17,4 +25,23 @@ export default class ReflexElement extends HTMLElement {
17
25
  ).toString(16)
18
26
  )
19
27
  }
28
+
29
+ get viewStack () {
30
+ if (!this.dataset.viewStack) return []
31
+ return JSON.parse(this.dataset.viewStack)
32
+ }
33
+
34
+ get partial () {
35
+ return this.viewStack[0]
36
+ }
37
+
38
+ get coordinates () {
39
+ const rect = this.getBoundingClientRect()
40
+ return {
41
+ left: rect.left + window.scrollX,
42
+ top: rect.top + window.scrollY,
43
+ width: this.offsetWidth,
44
+ height: this.offsetHeight
45
+ }
46
+ }
20
47
  }
@@ -1,6 +1,29 @@
1
1
  import ReflexElement from './reflex_element'
2
+ import DevtoolSupervisor from '../devtools/supervisor'
3
+ import ToggleDevtool from '../devtools/toggle'
2
4
 
3
5
  export default class ToggleTriggerElement extends ReflexElement {
6
+ connectedCallback () {
7
+ super.connectedCallback()
8
+
9
+ const mouseenter = () => this.devtool.show()
10
+ const mouseleave = () => this.devtool.hide()
11
+
12
+ document.addEventListener('reflex-behaviors:devtools-start', () => {
13
+ this.devtool = new ToggleDevtool(this)
14
+ this.addEventListener('mouseenter', mouseenter)
15
+ this.addEventListener('mouseleave', mouseleave)
16
+ })
17
+
18
+ document.addEventListener('reflex-behaviors:devtools-stop', () => {
19
+ this.removeEventListener('mouseenter', mouseenter)
20
+ this.removeEventListener('mouseleave', mouseleave)
21
+ delete this.devtool
22
+ })
23
+
24
+ DevtoolSupervisor.restart()
25
+ }
26
+
4
27
  collapse () {
5
28
  try {
6
29
  this.target.remove()
@@ -10,14 +33,38 @@ export default class ToggleTriggerElement extends ReflexElement {
10
33
  }
11
34
  }
12
35
 
13
- get controls () {
14
- return this.getAttribute('aria-controls')
36
+ get sharedViews () {
37
+ if (!this.target) return []
38
+ const reducer = (memo, view) => {
39
+ if (this.target.viewStack.includes(view)) memo.push(view)
40
+ return memo
41
+ }
42
+ return this.viewStack.reduce(reducer.bind(this), [])
43
+ }
44
+
45
+ get renderingInfo () {
46
+ if (!this.dataset.render) return {}
47
+ return JSON.parse(this.dataset.render)
48
+ }
49
+
50
+ get renderingPartial () {
51
+ return this.renderingInfo.partial
52
+ }
53
+
54
+ get renderingElement () {
55
+ const { id } = this.renderingInfo
56
+ if (!id) return null
57
+ return document.getElementById(id)
15
58
  }
16
59
 
17
60
  get expanded () {
18
61
  return this.getAttribute('aria-expanded') === 'true'
19
62
  }
20
63
 
64
+ get controls () {
65
+ return this.getAttribute('aria-controls')
66
+ }
67
+
21
68
  get target () {
22
69
  return document.getElementById(this.controls)
23
70
  }
@@ -45,6 +92,7 @@ addEventListener(
45
92
  )
46
93
 
47
94
  addEventListener('click', event => {
95
+ if (event.target.tagName.match(/reflex-behaviors-devtool/i)) return
48
96
  setTimeout(() => {
49
97
  const selector =
50
98
  'toggle-trigger[aria-controls][aria-expanded="true"][data-auto-collapse="true"]'