coupdoeil 1.0.0.pre.alpha.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1 -0
- data/MIT-LICENSE +20 -0
- data/README.md +6 -0
- data/Rakefile +8 -0
- data/app/assets/config/coupdoeil_manifest.js +1 -0
- data/app/assets/javascripts/coupdoeil.js +2289 -0
- data/app/assets/javascripts/coupdoeil.min.js +2 -0
- data/app/assets/javascripts/coupdoeil.min.js.map +1 -0
- data/app/assets/stylesheets/coupdoeil/application.css +15 -0
- data/app/assets/stylesheets/coupdoeil/hovercard-animation.css +44 -0
- data/app/assets/stylesheets/coupdoeil/hovercard-arrow.css +39 -0
- data/app/assets/stylesheets/coupdoeil/hovercard.css +84 -0
- data/app/controllers/coupdoeil/hovercards_controller.rb +46 -0
- data/app/helpers/coupdoeil/application_helper.rb +16 -0
- data/app/javascript/coupdoeil/elements/coupdoeil_element.js +33 -0
- data/app/javascript/coupdoeil/events/onclick.js +68 -0
- data/app/javascript/coupdoeil/events/onmouseover.js +86 -0
- data/app/javascript/coupdoeil/events.js +19 -0
- data/app/javascript/coupdoeil/hovercard/actions.js +60 -0
- data/app/javascript/coupdoeil/hovercard/attributes.js +33 -0
- data/app/javascript/coupdoeil/hovercard/cache.js +18 -0
- data/app/javascript/coupdoeil/hovercard/closing.js +81 -0
- data/app/javascript/coupdoeil/hovercard/config.js +15 -0
- data/app/javascript/coupdoeil/hovercard/controller.js +22 -0
- data/app/javascript/coupdoeil/hovercard/opening.js +139 -0
- data/app/javascript/coupdoeil/hovercard/optionsParser.js +117 -0
- data/app/javascript/coupdoeil/hovercard/positioning.js +74 -0
- data/app/javascript/coupdoeil/hovercard/state_check.js +11 -0
- data/app/javascript/coupdoeil/hovercard.js +6 -0
- data/app/javascript/coupdoeil/index.js +1 -0
- data/app/models/coupdoeil/hovercard/option/animation.rb +20 -0
- data/app/models/coupdoeil/hovercard/option/cache.rb +19 -0
- data/app/models/coupdoeil/hovercard/option/loading.rb +19 -0
- data/app/models/coupdoeil/hovercard/option/offset.rb +35 -0
- data/app/models/coupdoeil/hovercard/option/placement.rb +44 -0
- data/app/models/coupdoeil/hovercard/option/trigger.rb +19 -0
- data/app/models/coupdoeil/hovercard/option.rb +45 -0
- data/app/models/coupdoeil/hovercard/options_set.rb +57 -0
- data/app/models/coupdoeil/hovercard/registry.rb +25 -0
- data/app/models/coupdoeil/hovercard/setup.rb +44 -0
- data/app/models/coupdoeil/hovercard/view_context_delegation.rb +18 -0
- data/app/models/coupdoeil/hovercard.rb +115 -0
- data/app/models/coupdoeil/params.rb +83 -0
- data/app/models/coupdoeil/tag.rb +45 -0
- data/app/style/hovercard-animation.scss +44 -0
- data/app/style/hovercard-arrow.scss +40 -0
- data/app/style/hovercard.scss +2 -0
- data/app/views/layouts/coupdoeil/application.html.erb +15 -0
- data/config/routes.rb +3 -0
- data/lib/coupdoeil/engine.rb +62 -0
- data/lib/coupdoeil/version.rb +3 -0
- data/lib/coupdoeil.rb +6 -0
- data/lib/generators/coupdoeil/hovercard/USAGE +15 -0
- data/lib/generators/coupdoeil/hovercard/hovercard_generator.rb +22 -0
- data/lib/generators/coupdoeil/hovercard/templates/hovercard.rb.tt +8 -0
- data/lib/generators/coupdoeil/install/install_generator.rb +71 -0
- data/lib/generators/coupdoeil/install/templates/layout.html.erb.tt +14 -0
- data/lib/tasks/coupdoeil_tasks.rake +4 -0
- metadata +129 -0
@@ -0,0 +1,117 @@
|
|
1
|
+
const OPTIONS = {
|
2
|
+
animation: { getter: getAnimation },
|
3
|
+
cache: { getter: getCache },
|
4
|
+
offset: { getter: getOffset },
|
5
|
+
placement: { getter: getPlacement },
|
6
|
+
loading: { getter: getLoading },
|
7
|
+
trigger: { getter: getTrigger }
|
8
|
+
}
|
9
|
+
|
10
|
+
const ORDERED_OPTIONS = [
|
11
|
+
"trigger", // bit size: 1 shift: 0
|
12
|
+
"loading", // bit size: 2 shift: 1
|
13
|
+
"cache", // bit size: 1 shift: 3
|
14
|
+
"animation", // bit size: 3 shift: 4
|
15
|
+
"placement", // bit size: 16 shift: 7
|
16
|
+
"offset" // bit size: 21 shift: 23
|
17
|
+
]
|
18
|
+
|
19
|
+
const TRIGGERS = ["hover", "click"]
|
20
|
+
const ANIMATIONS = [false, "slide-in", "fade-in", "slide-out", "custom"]
|
21
|
+
const PLACEMENTS = [
|
22
|
+
'auto',
|
23
|
+
'top', 'top-start', 'top-end',
|
24
|
+
'right', 'right-start', 'right-end',
|
25
|
+
'bottom', 'bottom-start', 'bottom-end',
|
26
|
+
'left', 'left-start', 'left-end'
|
27
|
+
]
|
28
|
+
const LOADINGS = ["asyn", "preload", "lazy"]
|
29
|
+
|
30
|
+
function parseCSSSize(value) {
|
31
|
+
if (typeof value === 'number') {
|
32
|
+
return value
|
33
|
+
} else if ((/^(-?\d+\.?\d+)px$/).test(value)) {
|
34
|
+
return parseFloat(value)
|
35
|
+
} else if ((/^(-?\d*\.?\d+)rem$/).test(value)) {
|
36
|
+
return parseFloat(value) * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
37
|
+
}
|
38
|
+
return 0
|
39
|
+
}
|
40
|
+
|
41
|
+
function getOffset(optionsInt) {
|
42
|
+
// shift is BigInt(16 + 3 + 1 + 2 + 1)
|
43
|
+
const offsetBits = Number(BigInt(optionsInt) >> BigInt(23))
|
44
|
+
if (offsetBits === 0)
|
45
|
+
return 0
|
46
|
+
|
47
|
+
const isNegative = (offsetBits & 1) === 1
|
48
|
+
const isREM = (offsetBits & 2) === 2
|
49
|
+
// decimals mask is 2 ** 11 - 1
|
50
|
+
const decimals = (offsetBits >> 2) & 2047
|
51
|
+
const integer = (offsetBits >> (2 + 11))
|
52
|
+
|
53
|
+
const CSSSize = `${isNegative ? '-' : ''}${integer}.${decimals}${isREM ? 'rem' : 'px'}`
|
54
|
+
return parseCSSSize(CSSSize)
|
55
|
+
}
|
56
|
+
|
57
|
+
function getPlacement(optionsInt) {
|
58
|
+
// shift is 3 + 1 + 2 + 1, mask is 2 ** 16 - 1
|
59
|
+
const placementBits = (optionsInt >> 7) & 65535
|
60
|
+
let shift = 0
|
61
|
+
let lastPlacement = null
|
62
|
+
const placements = []
|
63
|
+
while (lastPlacement !== "auto" && shift < 16) {
|
64
|
+
// mask is 2 ** 4 - 1
|
65
|
+
lastPlacement = PLACEMENTS[(placementBits >> shift) & 15]
|
66
|
+
placements.push(lastPlacement)
|
67
|
+
shift += 4
|
68
|
+
}
|
69
|
+
return placements
|
70
|
+
}
|
71
|
+
|
72
|
+
function getAnimation(optionsInt) {
|
73
|
+
return ANIMATIONS[(optionsInt & 56) >> 4]
|
74
|
+
}
|
75
|
+
|
76
|
+
function getCache(optionsInt) {
|
77
|
+
return (optionsInt & 8) === 8
|
78
|
+
}
|
79
|
+
|
80
|
+
function getLoading(optionsInt) {
|
81
|
+
return LOADINGS[optionsInt & 3 >> 1]
|
82
|
+
}
|
83
|
+
|
84
|
+
function getTrigger(optionsInt) {
|
85
|
+
return TRIGGERS[optionsInt & 1]
|
86
|
+
}
|
87
|
+
|
88
|
+
const HovercardOptions = {
|
89
|
+
animation: undefined,
|
90
|
+
cache: undefined,
|
91
|
+
offset: undefined,
|
92
|
+
placement: undefined,
|
93
|
+
loading: undefined,
|
94
|
+
trigger: undefined,
|
95
|
+
}
|
96
|
+
|
97
|
+
export function extractOptionsFromElement(coupdoeilElement) {
|
98
|
+
const optionsInt = coupdoeilElement.hovercardController.optionsInt ||= parseOtionsInt((coupdoeilElement))
|
99
|
+
const options = Object.create(HovercardOptions)
|
100
|
+
|
101
|
+
for (const option of ORDERED_OPTIONS) {
|
102
|
+
options[option] = OPTIONS[option].getter(optionsInt)
|
103
|
+
}
|
104
|
+
|
105
|
+
return options
|
106
|
+
}
|
107
|
+
|
108
|
+
function parseOtionsInt(coupdoeilElement) {
|
109
|
+
const optionsString = coupdoeilElement.getAttribute("hc")
|
110
|
+
return parseInt(optionsString, 36)
|
111
|
+
}
|
112
|
+
|
113
|
+
export function extractOptionFromElement(coupdoeilElement, optionName) {
|
114
|
+
const optionsInt = coupdoeilElement.hovercardController.optionsInt ||= parseOtionsInt((coupdoeilElement))
|
115
|
+
|
116
|
+
return OPTIONS[optionName].getter(optionsInt)
|
117
|
+
}
|
@@ -0,0 +1,74 @@
|
|
1
|
+
import {
|
2
|
+
autoPlacement,
|
3
|
+
computePosition,
|
4
|
+
detectOverflow,
|
5
|
+
offset,
|
6
|
+
arrow
|
7
|
+
} from "@floating-ui/dom"
|
8
|
+
|
9
|
+
export async function positionHovercard(controller, options) {
|
10
|
+
let { placement: placements, offset: offsetValue } = options
|
11
|
+
const placement = placements[0]
|
12
|
+
const arrowElement = controller.card.querySelector('[data-hovercard-arrow]')
|
13
|
+
const middleware = [AutoPositioningWithFallbacks(placements)]
|
14
|
+
|
15
|
+
if (arrowElement) {
|
16
|
+
const arrowSize = arrowElement.clientWidth
|
17
|
+
offsetValue += arrowSize
|
18
|
+
middleware.push(arrow({ element: arrowElement }))
|
19
|
+
}
|
20
|
+
|
21
|
+
middleware.push(offset(offsetValue))
|
22
|
+
|
23
|
+
const computedPosition = await computePosition(
|
24
|
+
controller.coupdoeilElement, controller.card, { placement, middleware }
|
25
|
+
)
|
26
|
+
const { x, y, placement: actualPlacement} = computedPosition
|
27
|
+
|
28
|
+
if (arrowElement) {
|
29
|
+
positionArrow(arrowElement, computedPosition)
|
30
|
+
}
|
31
|
+
|
32
|
+
controller.card.dataset.placement = actualPlacement
|
33
|
+
Object.assign(controller.card.style, { left: `${x}px`, top: `${y}px` })
|
34
|
+
}
|
35
|
+
|
36
|
+
const AutoPositioningWithFallbacks = (placements) => {
|
37
|
+
let placementIndex = 0
|
38
|
+
return {
|
39
|
+
name: 'autoPlacement',
|
40
|
+
async fn(state) {
|
41
|
+
if (state.placement === 'auto') {
|
42
|
+
return autoPlacement().fn(state)
|
43
|
+
}
|
44
|
+
|
45
|
+
const { top, bottom, left, right } = await detectOverflow(state)
|
46
|
+
const isOverflowing = (top > 0 || bottom > 0 || left > 0 || right > 0)
|
47
|
+
|
48
|
+
if (placements[placementIndex] !== 'auto' && isOverflowing) {
|
49
|
+
placementIndex++
|
50
|
+
return { reset: { placement: placements[placementIndex] || 'auto' } }
|
51
|
+
} else if (isOverflowing) {
|
52
|
+
return autoPlacement().fn(state)
|
53
|
+
}
|
54
|
+
return {}
|
55
|
+
}
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
const OPPOSITE_SIDES = { right: 'left', left: 'right', top: 'bottom', bottom: 'top' }
|
60
|
+
|
61
|
+
function positionArrow(arrowElement, computedData) {
|
62
|
+
const arrowSize = arrowElement.clientWidth
|
63
|
+
const { placement, middlewareData: { arrow: arrowData } } = computedData
|
64
|
+
const side = placement.split("-")[0]
|
65
|
+
const oppositeSide = OPPOSITE_SIDES[side]
|
66
|
+
const { x, y } = arrowData
|
67
|
+
const { borderWidth: parentBorderWidht } = getComputedStyle(arrowElement.parentElement)
|
68
|
+
|
69
|
+
Object.assign(arrowElement.style, {
|
70
|
+
left: x != null ? `${x}px` : '',
|
71
|
+
top: y != null ? `${y}px` : '',
|
72
|
+
[oppositeSide]: `calc(${-arrowSize}px + ${parentBorderWidht})`
|
73
|
+
})
|
74
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
import {HOVERCARD_CLOSE_BTN_SELECTOR} from "./config";
|
2
|
+
import {currentHovercardsById} from "./actions";
|
3
|
+
|
4
|
+
export function isElementCloseHovercardButton(element) {
|
5
|
+
return element.closest(HOVERCARD_CLOSE_BTN_SELECTOR) ||
|
6
|
+
element.dataset.hasOwnProperty("hovercardClose")
|
7
|
+
}
|
8
|
+
|
9
|
+
export function isAnyHovercardOpened() {
|
10
|
+
return currentHovercardsById().size > 0
|
11
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
import './hovercard'
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Animation < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 3
|
8
|
+
|
9
|
+
VALUES = %w[slide-in fade-in slide-out custom].unshift(false).freeze
|
10
|
+
INDEX_BY_VALUES = VALUES.each_with_index.to_h.with_indifferent_access.freeze
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def parse(value) = INDEX_BY_VALUES.fetch(value)
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate! = validate_inclusion!
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Cache < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 1
|
8
|
+
|
9
|
+
VALUES = [true, false].freeze
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def parse(value) = value ? 1 : 0
|
13
|
+
end
|
14
|
+
|
15
|
+
def validate! = validate_inclusion!
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Loading < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 2
|
8
|
+
|
9
|
+
VALUES = %w[async preload lazy].freeze
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def parse(value) = VALUES.index(value.to_s)
|
13
|
+
end
|
14
|
+
|
15
|
+
def validate! = validate_inclusion!
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Offset < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 8 + 11 + 2
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def parse(value)
|
11
|
+
float_value = value.to_f
|
12
|
+
return 0 if float_value.zero?
|
13
|
+
|
14
|
+
base = 0
|
15
|
+
base |= 1 if float_value.negative?
|
16
|
+
base |= 2 if value.is_a?(String) && value.end_with?("rem")
|
17
|
+
|
18
|
+
integer, decimals = float_value.abs.to_s.split(".")
|
19
|
+
base |= (decimals.to_i << 2)
|
20
|
+
base |= (integer.to_i << 2 + 11)
|
21
|
+
|
22
|
+
base
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate!
|
27
|
+
return if value in Float | Integer
|
28
|
+
return if value.to_s.match?(/^-?\d+(\.\d{1,3})?(px|rem)?$/)
|
29
|
+
|
30
|
+
raise_invalid_option "Value should be a signed float or integer, followed or not by 'rem' or 'px'."
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Placement < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 4 * 4
|
8
|
+
|
9
|
+
VALUES = %w[
|
10
|
+
auto
|
11
|
+
top top-start top-end
|
12
|
+
right right-start right-end
|
13
|
+
bottom bottom-start bottom-end
|
14
|
+
left left-start left-end
|
15
|
+
].freeze
|
16
|
+
INDEX_BY_VALUES = VALUES.each_with_index.to_h.with_indifferent_access.freeze
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def parse(value)
|
20
|
+
values = value.to_s.split(",")
|
21
|
+
4.times.sum do |index|
|
22
|
+
next 0 unless (placement = values[index])
|
23
|
+
|
24
|
+
placement.strip!
|
25
|
+
placement_index = INDEX_BY_VALUES[placement]
|
26
|
+
placement_index << (index * 4)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def validate!
|
32
|
+
values = value.to_s.split(",")
|
33
|
+
|
34
|
+
values.each do |placement_value|
|
35
|
+
next if placement_value.strip.in?(VALUES)
|
36
|
+
|
37
|
+
values_sentence = VALUES.to_sentence(last_word_connector: " or ")
|
38
|
+
raise_invalid_option "Value must be one of: #{values_sentence}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Option
|
6
|
+
class Trigger < Coupdoeil::Hovercard::Option
|
7
|
+
self.bit_size = 1
|
8
|
+
|
9
|
+
VALUES = %w[click hover].freeze
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def parse(value) = value.to_s == "click" ? 1 : 0
|
13
|
+
end
|
14
|
+
|
15
|
+
def validate! = validate_inclusion!
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Coupdoeil
|
3
|
+
class Hovercard
|
4
|
+
class Option
|
5
|
+
InvalidOptionError = Class.new(StandardError)
|
6
|
+
|
7
|
+
class << self
|
8
|
+
attr_accessor :bit_size, :key
|
9
|
+
|
10
|
+
def into_bits(value) = parse(value)
|
11
|
+
|
12
|
+
def inherited(subclass)
|
13
|
+
super
|
14
|
+
subclass.class_eval do
|
15
|
+
@key = name.demodulize.underscore.to_sym
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :value
|
21
|
+
|
22
|
+
def initialize(value)
|
23
|
+
value = value.to_s if value.is_a? Symbol
|
24
|
+
@value = value
|
25
|
+
end
|
26
|
+
|
27
|
+
def validate! = raise(NotImplementedError)
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def raise_invalid_option(message)
|
32
|
+
raise InvalidOptionError,
|
33
|
+
"Invalid value '#{value}' (#{value.class.name}) for #{self.class.key} option. #{message}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_inclusion!
|
37
|
+
values = self.class.const_get("VALUES")
|
38
|
+
return if value.in?(values)
|
39
|
+
|
40
|
+
values_sentence = values.to_sentence(two_words_connector: " or ", last_word_connector: " or ")
|
41
|
+
raise_invalid_option "Value must be one of: #{values_sentence}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
module Coupdoeil
|
6
|
+
class Hovercard
|
7
|
+
class OptionsSet
|
8
|
+
ORDERED_OPTIONS = [
|
9
|
+
Option::Offset,
|
10
|
+
Option::Placement,
|
11
|
+
Option::Animation,
|
12
|
+
Option::Cache,
|
13
|
+
Option::Loading,
|
14
|
+
Option::Trigger
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
OPTION_NAMES = ORDERED_OPTIONS.map(&:key)
|
18
|
+
|
19
|
+
attr_reader :options
|
20
|
+
|
21
|
+
def dup = OptionsSet.new(options.deep_dup)
|
22
|
+
def preload? = options[:loading] == :preload
|
23
|
+
def custom_animation? = options[:animation].to_s == "custom"
|
24
|
+
def to_h = options
|
25
|
+
|
26
|
+
def initialize(options = {})
|
27
|
+
@options = options
|
28
|
+
end
|
29
|
+
|
30
|
+
def merge(options_set)
|
31
|
+
OptionsSet.new(@options.merge(options_set.options))
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate!
|
35
|
+
ORDERED_OPTIONS.map do |option|
|
36
|
+
next unless @options.key?(option.key)
|
37
|
+
|
38
|
+
value = @options[option.key]
|
39
|
+
option.new(value).validate!
|
40
|
+
end
|
41
|
+
@options.assert_valid_keys(ORDERED_OPTIONS.map(&:key))
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_base36
|
45
|
+
@to_base36 ||= begin
|
46
|
+
shift = 0
|
47
|
+
ORDERED_OPTIONS.reverse.sum do |option|
|
48
|
+
bits = option.into_bits(@options[option.key])
|
49
|
+
result = bits << shift
|
50
|
+
shift += option.bit_size
|
51
|
+
result
|
52
|
+
end.to_s(36).freeze
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Registry
|
6
|
+
attr_reader :registry, :semaphore
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@semaphore = Mutex.new
|
10
|
+
@registry = Hash.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def register(type, klass) = @semaphore.synchronize { @registry[type] = klass }
|
14
|
+
def lookup(type) = @semaphore.synchronize {@registry.fetch(type) }
|
15
|
+
def safe_lookup(type) = @semaphore.synchronize {@registry[type] }
|
16
|
+
|
17
|
+
def lookup_or_register(type)
|
18
|
+
safe_lookup(type) ||
|
19
|
+
begin
|
20
|
+
register(type, (type.classify + "Hovercard").constantize)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
class Setup
|
6
|
+
attr_reader :klass, :type, :params
|
7
|
+
|
8
|
+
EMPTY_PARAMS = {}.freeze
|
9
|
+
|
10
|
+
def initialize(klass)
|
11
|
+
@klass = klass
|
12
|
+
@type = nil
|
13
|
+
@params = EMPTY_PARAMS
|
14
|
+
end
|
15
|
+
|
16
|
+
def default_options = klass.default_options_for(type)
|
17
|
+
def identifier = "#{type}@#{klass.hovercard_resource_name}"
|
18
|
+
def render_in(view_context) = klass.new(params, view_context).process(type)
|
19
|
+
|
20
|
+
def with_type(type)
|
21
|
+
@type = type
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
def with_params(**params)
|
26
|
+
@params = params
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
def method_missing(method_name, *args, &)
|
31
|
+
if klass.action_methods.include?(method_name.name)
|
32
|
+
raise ArgumentError, "expected no arguments, given #{args.size}" if args.any?
|
33
|
+
with_type(method_name)
|
34
|
+
else
|
35
|
+
super
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def respond_to_missing?(method, include_all = false)
|
40
|
+
klass.action_methods.include?(method.name) || super
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard
|
5
|
+
module ViewContextDelegation
|
6
|
+
attr_accessor :__cp_view_context
|
7
|
+
|
8
|
+
# For CSRF authenticity tokens in forms
|
9
|
+
def config = __cp_view_context.config
|
10
|
+
def form_authenticity_token(...) = __cp_view_context.form_authenticity_token(...)
|
11
|
+
def protect_against_forgery? = __cp_view_context.protect_against_forgery?
|
12
|
+
def request_forgery_protection_token = __cp_view_context.request_forgery_protection_token
|
13
|
+
|
14
|
+
def helpers = __cp_view_context
|
15
|
+
def controller = __cp_view_context.controller
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Coupdoeil
|
4
|
+
class Hovercard < AbstractController::Base
|
5
|
+
abstract!
|
6
|
+
|
7
|
+
include AbstractController::Rendering
|
8
|
+
|
9
|
+
include AbstractController::Logger
|
10
|
+
include AbstractController::Helpers
|
11
|
+
include AbstractController::Translation
|
12
|
+
include AbstractController::Callbacks
|
13
|
+
include AbstractController::Caching
|
14
|
+
|
15
|
+
include ActionView::Layouts
|
16
|
+
include ActionView::Rendering
|
17
|
+
|
18
|
+
include Rails.application.routes.url_helpers
|
19
|
+
|
20
|
+
layout "hovercard"
|
21
|
+
|
22
|
+
# so coupdoeil helpers are always available within hovercards
|
23
|
+
helper Coupdoeil::ApplicationHelper
|
24
|
+
|
25
|
+
@registry = Registry.new
|
26
|
+
@default_options_by_method = {}.with_indifferent_access
|
27
|
+
|
28
|
+
DEFAULT_OPTIONS_KEY = Object.new
|
29
|
+
private_constant :DEFAULT_OPTIONS_KEY
|
30
|
+
|
31
|
+
@default_options_by_method[DEFAULT_OPTIONS_KEY] = OptionsSet.new(
|
32
|
+
offset: 0,
|
33
|
+
placement: "auto",
|
34
|
+
animation: "slide-in",
|
35
|
+
cache: true,
|
36
|
+
loading: :async,
|
37
|
+
trigger: "hover"
|
38
|
+
)
|
39
|
+
|
40
|
+
DoubleRenderError = Class.new(::AbstractController::DoubleRenderError)
|
41
|
+
|
42
|
+
# See engine initialization for view paths
|
43
|
+
|
44
|
+
class << self
|
45
|
+
attr_reader :registry
|
46
|
+
|
47
|
+
def hovercard_resource_name = @hovercard_resource_name ||= name.delete_suffix("Hovercard").underscore
|
48
|
+
def with(...) = Setup.new(self).with_params(...)
|
49
|
+
|
50
|
+
def inherited(subclass)
|
51
|
+
super
|
52
|
+
Coupdoeil::Hovercard.registry.register(subclass.hovercard_resource_name, subclass)
|
53
|
+
subclass.instance_variable_set(:@default_options_by_method, @default_options_by_method.dup)
|
54
|
+
end
|
55
|
+
|
56
|
+
def default_options(...) = default_options_for(DEFAULT_OPTIONS_KEY, ...)
|
57
|
+
|
58
|
+
def default_options_for(*action_names, **option_values)
|
59
|
+
if option_values.blank?
|
60
|
+
@default_options_by_method[action_names.first] || default_options
|
61
|
+
else
|
62
|
+
action_names.each do |action_name|
|
63
|
+
options = @default_options_by_method[action_name] || default_options
|
64
|
+
@default_options_by_method[action_name] = options.merge(OptionsSet.new(option_values))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def method_missing(method_name, *args, &)
|
70
|
+
return super unless action_methods.include?(method_name.name)
|
71
|
+
raise ArgumentError, "expected no arguments" if args.any?
|
72
|
+
|
73
|
+
Setup.new(self).with_type(method_name)
|
74
|
+
end
|
75
|
+
|
76
|
+
def respond_to_missing?(method, include_all = false)
|
77
|
+
action_methods.include?(method.name) || super
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
attr_reader :params
|
82
|
+
|
83
|
+
def initialize(params, cp_view_context)
|
84
|
+
super()
|
85
|
+
@params = params
|
86
|
+
@__cp_view_context = cp_view_context
|
87
|
+
end
|
88
|
+
|
89
|
+
def helpers = @__cp_view_context
|
90
|
+
def controller = @__cp_view_context.controller
|
91
|
+
|
92
|
+
def view_context
|
93
|
+
super.tap do |context|
|
94
|
+
context.extend ViewContextDelegation
|
95
|
+
context.__cp_view_context = @__cp_view_context
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def render(...)
|
100
|
+
return super unless response_body
|
101
|
+
|
102
|
+
raise DoubleRenderError, "Render was called multiple times in this action. \
|
103
|
+
Also note that render does not terminate execution of the action."
|
104
|
+
end
|
105
|
+
|
106
|
+
def process(method_name, *)
|
107
|
+
benchmark("processed hovercard #{self.class.hovercard_resource_name}/#{method_name}", silence: true) do
|
108
|
+
ActiveSupport::Notifications.instrument("render_hovercard.coupdoeil") do
|
109
|
+
super
|
110
|
+
response_body || render(action_name)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|