reflex_behaviors 0.0.2 → 0.0.4

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.
@@ -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"]'