proscenium-ui 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/lib/proscenium/ui/dropdown/index.css +3 -0
- data/lib/proscenium/ui/dropdown/index.js +181 -0
- data/lib/proscenium/ui/dropdown/mixin.css +95 -0
- data/lib/proscenium/ui/dropdown.rb +55 -0
- data/lib/proscenium/ui/dropdown_menu/index.css +3 -0
- data/lib/proscenium/ui/dropdown_menu/index.js +96 -0
- data/lib/proscenium/ui/dropdown_menu/mixin.css +63 -0
- data/lib/proscenium/ui/dropdown_menu.rb +37 -0
- data/lib/proscenium/ui/flash/index.js +33 -38
- data/lib/proscenium/ui/form/fields/select.rb +1 -1
- data/lib/proscenium/ui/version.rb +1 -1
- data/lib/proscenium/ui/web_component.js +497 -0
- metadata +12 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a252059bfea054875121e7d18fbbf83fdf76736b76e92404930bec2af032b0d
|
|
4
|
+
data.tar.gz: a4b9368ca7cbb9cdb49eb8f43deb8d7db59f951e206a7ac38e7b37326c4fdde8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c8a3605f8a49c3a24b0beb21ffdc165c6ea1821563931365a78e707bced0326dcbbd17df6404aead0e1116dd37a6c487e126fc7ea15f2f0915c63cae1e137035
|
|
7
|
+
data.tar.gz: 46ae40d972ab7dad9d9ba741d005b35184717af482db23f77a12b4b5851650ff6da3aefbad40263a42c17e1e9d4c43e533d1159c18cba536e4750d0261c375aa
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import WebComponent from '../web_component'
|
|
2
|
+
|
|
3
|
+
const SUPPORTS_POPOVER =
|
|
4
|
+
typeof HTMLElement !== 'undefined' && typeof HTMLElement.prototype.showPopover === 'function'
|
|
5
|
+
|
|
6
|
+
export class Dropdown extends WebComponent {
|
|
7
|
+
static componentName = 'pui-dropdown'
|
|
8
|
+
static actions = { click: ['toggle'], keydown: ['onTriggerKey'] }
|
|
9
|
+
|
|
10
|
+
#originalParent = null
|
|
11
|
+
#originalNextSibling = null
|
|
12
|
+
#cleanupPosition = null
|
|
13
|
+
|
|
14
|
+
connectedCallback() {
|
|
15
|
+
super.connectedCallback()
|
|
16
|
+
|
|
17
|
+
this.fui = import('@floating-ui/dom')
|
|
18
|
+
this.$container?.addEventListener('toggle', this.#onToggle)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnectedCallback() {
|
|
22
|
+
super.disconnectedCallback()
|
|
23
|
+
|
|
24
|
+
if (this.$container?.parentNode === document.body) {
|
|
25
|
+
this.$container.remove()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.$container?.removeEventListener('toggle', this.#onToggle)
|
|
29
|
+
this.#removeListeners()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
handleEvent(event) {
|
|
33
|
+
if (event.type === 'blur' && event.target === globalThis) {
|
|
34
|
+
this.close()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toggle = () => {
|
|
39
|
+
this.isOpen ? this.close() : this.open()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onTriggerKey = event => {
|
|
43
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
this.toggle()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#onToggle = event => {
|
|
50
|
+
if (event.newState !== 'closed' || !this.isOpen) return
|
|
51
|
+
|
|
52
|
+
this.close()
|
|
53
|
+
|
|
54
|
+
const ae = document.activeElement
|
|
55
|
+
if (ae === document.body || this.contains(ae)) {
|
|
56
|
+
this.$trigger.focus()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
open() {
|
|
61
|
+
if (this.isOpen) return
|
|
62
|
+
|
|
63
|
+
this.#showContainer()
|
|
64
|
+
this.dataset.open = true
|
|
65
|
+
this.$container.dataset.open = true
|
|
66
|
+
this.$trigger.setAttribute('aria-expanded', 'true')
|
|
67
|
+
this.#startPositionUpdates()
|
|
68
|
+
this.#addListeners()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
close = () => {
|
|
72
|
+
if (!this.isOpen) return
|
|
73
|
+
|
|
74
|
+
delete this.dataset.open
|
|
75
|
+
delete this.$container.dataset.open
|
|
76
|
+
this.$trigger.setAttribute('aria-expanded', 'false')
|
|
77
|
+
|
|
78
|
+
this.#cleanupPosition?.()
|
|
79
|
+
this.#cleanupPosition = null
|
|
80
|
+
|
|
81
|
+
this.#hideContainer()
|
|
82
|
+
this.#removeListeners()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get isOpen() {
|
|
86
|
+
return 'open' in this.dataset
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#showContainer() {
|
|
90
|
+
if (SUPPORTS_POPOVER) {
|
|
91
|
+
this.$container.showPopover()
|
|
92
|
+
} else {
|
|
93
|
+
this.#originalParent = this.$container.parentNode
|
|
94
|
+
this.#originalNextSibling = this.$container.nextSibling
|
|
95
|
+
document.body.append(this.$container)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#hideContainer() {
|
|
100
|
+
if (SUPPORTS_POPOVER) {
|
|
101
|
+
this.$container.hidePopover()
|
|
102
|
+
} else if (this.#originalParent) {
|
|
103
|
+
this.#originalParent.insertBefore(this.$container, this.#originalNextSibling)
|
|
104
|
+
this.#originalParent = null
|
|
105
|
+
this.#originalNextSibling = null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#addListeners() {
|
|
110
|
+
window.addEventListener('blur', this, { once: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#removeListeners() {
|
|
114
|
+
window.removeEventListener('blur', this, { once: true })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async #startPositionUpdates() {
|
|
118
|
+
const { computePosition, offset, flip, shift, arrow, autoUpdate } = await this.fui
|
|
119
|
+
|
|
120
|
+
const update = () => {
|
|
121
|
+
computePosition(this.$trigger, this.$container, {
|
|
122
|
+
strategy: 'fixed',
|
|
123
|
+
placement: 'bottom-start',
|
|
124
|
+
middleware: [offset(6), flip(), shift({ padding: 5 }), arrow({ element: this.$arrow })]
|
|
125
|
+
}).then(({ x, y, placement, middlewareData }) => {
|
|
126
|
+
Object.assign(this.$container.style, {
|
|
127
|
+
left: `${x}px`,
|
|
128
|
+
top: `${y}px`
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const { x: arrowX, y: arrowY } = middlewareData.arrow
|
|
132
|
+
const side = placement.split('-')[0]
|
|
133
|
+
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side]
|
|
134
|
+
const outerBorders = {
|
|
135
|
+
bottom: ['borderLeft', 'borderTop'],
|
|
136
|
+
top: ['borderRight', 'borderBottom'],
|
|
137
|
+
right: ['borderLeft', 'borderBottom'],
|
|
138
|
+
left: ['borderTop', 'borderRight']
|
|
139
|
+
}[side]
|
|
140
|
+
const border =
|
|
141
|
+
'1px solid var(--pui-dropdown-border-color, color-mix(in srgb, CanvasText 20%, transparent))'
|
|
142
|
+
|
|
143
|
+
Object.assign(this.$arrow.style, {
|
|
144
|
+
left: arrowX == null ? '' : `${arrowX}px`,
|
|
145
|
+
top: arrowY == null ? '' : `${arrowY}px`,
|
|
146
|
+
right: '',
|
|
147
|
+
bottom: '',
|
|
148
|
+
borderTop: '',
|
|
149
|
+
borderRight: '',
|
|
150
|
+
borderBottom: '',
|
|
151
|
+
borderLeft: '',
|
|
152
|
+
[staticSide]: '-4px',
|
|
153
|
+
[outerBorders[0]]: border,
|
|
154
|
+
[outerBorders[1]]: border
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cleanup = autoUpdate(this.$trigger, this.$container, update)
|
|
160
|
+
|
|
161
|
+
if (this.isOpen) {
|
|
162
|
+
this.#cleanupPosition = cleanup
|
|
163
|
+
} else {
|
|
164
|
+
cleanup()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get $trigger() {
|
|
169
|
+
return (this._$trigger ??= this.querySelector('pui-dropdown-trigger'))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get $container() {
|
|
173
|
+
return (this._$container ??= this.querySelector('pui-dropdown-container'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get $arrow() {
|
|
177
|
+
return (this._$arrow ??= this.$container.querySelector('pui-dropdown-arrow'))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
WebComponent.register(Dropdown)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Properties:
|
|
3
|
+
*
|
|
4
|
+
* --pui-dropdown-background: Canvas;
|
|
5
|
+
* --pui-dropdown-color: CanvasText;
|
|
6
|
+
* --pui-dropdown-border-color: color-mix(in srgb, CanvasText 20%, transparent);
|
|
7
|
+
* --pui-dropdown-shadow:
|
|
8
|
+
* 0 0 15px color-mix(in srgb, CanvasText 10%, transparent),
|
|
9
|
+
* 0 5px 15px color-mix(in srgb, CanvasText 7%, transparent);
|
|
10
|
+
* --pui-dropdown-max-height: min(20em, 70vh);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
@define-mixin base {
|
|
14
|
+
pui-dropdown-trigger {
|
|
15
|
+
@mixin trigger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pui-dropdown-container {
|
|
19
|
+
@mixin container;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pui-dropdown-body {
|
|
23
|
+
@mixin body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pui-dropdown-arrow {
|
|
27
|
+
@mixin arrow;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@define-mixin trigger {
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
|
|
36
|
+
&:focus-visible {
|
|
37
|
+
outline: 2px solid Highlight;
|
|
38
|
+
outline-offset: 2px;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@define-mixin container {
|
|
43
|
+
background-color: var(--pui-dropdown-background, Canvas);
|
|
44
|
+
color: var(--pui-dropdown-color, CanvasText);
|
|
45
|
+
box-shadow: var(
|
|
46
|
+
--pui-dropdown-shadow,
|
|
47
|
+
0 0 15px color-mix(in srgb, CanvasText 10%, transparent),
|
|
48
|
+
0 5px 15px color-mix(in srgb, CanvasText 7%, transparent)
|
|
49
|
+
);
|
|
50
|
+
padding: 6px 0;
|
|
51
|
+
border-radius: 4px;
|
|
52
|
+
width: max-content;
|
|
53
|
+
position: fixed;
|
|
54
|
+
inset: auto;
|
|
55
|
+
top: 0;
|
|
56
|
+
left: 0;
|
|
57
|
+
margin: 0;
|
|
58
|
+
display: none;
|
|
59
|
+
overflow: visible;
|
|
60
|
+
border: 1px solid
|
|
61
|
+
var(--pui-dropdown-border-color, color-mix(in srgb, CanvasText 20%, transparent));
|
|
62
|
+
opacity: 0;
|
|
63
|
+
transition:
|
|
64
|
+
opacity 120ms ease,
|
|
65
|
+
display 120ms allow-discrete,
|
|
66
|
+
overlay 120ms allow-discrete;
|
|
67
|
+
z-index: 1000;
|
|
68
|
+
|
|
69
|
+
&[data-open] {
|
|
70
|
+
display: block;
|
|
71
|
+
opacity: 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@starting-style {
|
|
75
|
+
&[data-open] {
|
|
76
|
+
opacity: 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@define-mixin body {
|
|
82
|
+
display: block;
|
|
83
|
+
max-height: var(--pui-dropdown-max-height, min(20em, 70vh));
|
|
84
|
+
overflow-y: auto;
|
|
85
|
+
overscroll-behavior: contain;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@define-mixin arrow {
|
|
89
|
+
position: absolute;
|
|
90
|
+
background: var(--pui-dropdown-background, Canvas);
|
|
91
|
+
width: 8px;
|
|
92
|
+
height: 8px;
|
|
93
|
+
transform: rotate(45deg);
|
|
94
|
+
box-sizing: border-box;
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium::UI
|
|
4
|
+
class Dropdown < Component
|
|
5
|
+
register_element :pui_dropdown
|
|
6
|
+
register_element :pui_dropdown_trigger
|
|
7
|
+
register_element :pui_dropdown_container
|
|
8
|
+
register_element :pui_dropdown_body
|
|
9
|
+
register_element :pui_dropdown_arrow
|
|
10
|
+
|
|
11
|
+
def self.source_path
|
|
12
|
+
(super || superclass.source_path) / '../dropdown/index.rb'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def trigger_template
|
|
16
|
+
raise NotImplementedError,
|
|
17
|
+
"`#trigger_template` must be implemented in subclasses of #{self.class}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dropdown_template
|
|
21
|
+
raise NotImplementedError,
|
|
22
|
+
"`#dropdown_template` must be implemented in subclasses of #{self.class}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def view_template = base_template
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def host_element = :pui_dropdown
|
|
30
|
+
def trigger_haspopup = 'true'
|
|
31
|
+
def container_role = nil
|
|
32
|
+
|
|
33
|
+
def base_template
|
|
34
|
+
container_id = "pui-dd-#{object_id}"
|
|
35
|
+
|
|
36
|
+
send(host_element) do
|
|
37
|
+
pui_dropdown_trigger(
|
|
38
|
+
tabindex: 0,
|
|
39
|
+
role: 'button',
|
|
40
|
+
aria_haspopup: trigger_haspopup,
|
|
41
|
+
aria_expanded: 'false',
|
|
42
|
+
aria_controls: container_id,
|
|
43
|
+
on_click: :toggle,
|
|
44
|
+
on_keydown: :onTriggerKey,
|
|
45
|
+
&:trigger_template
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
pui_dropdown_container(id: container_id, role: container_role, popover: :auto) do
|
|
49
|
+
pui_dropdown_body(&:dropdown_template)
|
|
50
|
+
pui_dropdown_arrow
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import WebComponent from '../web_component'
|
|
2
|
+
import { Dropdown } from '../dropdown/index.js'
|
|
3
|
+
|
|
4
|
+
const TYPEAHEAD_TIMEOUT = 500
|
|
5
|
+
|
|
6
|
+
class DropdownMenu extends Dropdown {
|
|
7
|
+
static componentName = 'pui-dropdown-menu'
|
|
8
|
+
|
|
9
|
+
#typeBuffer = ''
|
|
10
|
+
#typeTimer = null
|
|
11
|
+
|
|
12
|
+
connectedCallback() {
|
|
13
|
+
super.connectedCallback()
|
|
14
|
+
this.$container?.addEventListener('keydown', this.#onMenuKey)
|
|
15
|
+
this.$container?.addEventListener('click', this.#onMenuClick)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
disconnectedCallback() {
|
|
19
|
+
super.disconnectedCallback()
|
|
20
|
+
this.$container?.removeEventListener('keydown', this.#onMenuKey)
|
|
21
|
+
this.$container?.removeEventListener('click', this.#onMenuClick)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
open() {
|
|
25
|
+
if (this.isOpen) return
|
|
26
|
+
super.open()
|
|
27
|
+
queueMicrotask(() => {
|
|
28
|
+
const items = this.#items()
|
|
29
|
+
if (items.length > 0) items[0].focus()
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#onMenuKey = event => {
|
|
34
|
+
const items = this.#items()
|
|
35
|
+
if (items.length === 0) return
|
|
36
|
+
|
|
37
|
+
const current = items.indexOf(document.activeElement)
|
|
38
|
+
|
|
39
|
+
switch (event.key) {
|
|
40
|
+
case 'ArrowDown':
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
this.#focusAt(items, current + 1)
|
|
43
|
+
break
|
|
44
|
+
case 'ArrowUp':
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
this.#focusAt(items, current - 1)
|
|
47
|
+
break
|
|
48
|
+
case 'Home':
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
this.#focusAt(items, 0)
|
|
51
|
+
break
|
|
52
|
+
case 'End':
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
this.#focusAt(items, items.length - 1)
|
|
55
|
+
break
|
|
56
|
+
case 'Tab':
|
|
57
|
+
this.close()
|
|
58
|
+
break
|
|
59
|
+
default:
|
|
60
|
+
if (event.key.length === 1 && /\S/.test(event.key)) {
|
|
61
|
+
this.#typeahead(event.key.toLowerCase(), items)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#onMenuClick = event => {
|
|
67
|
+
const item = event.target.closest('[role="menuitem"]')
|
|
68
|
+
if (!item || item.getAttribute('aria-disabled') === 'true') return
|
|
69
|
+
this.close()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#focusAt(items, index) {
|
|
73
|
+
const wrapped = ((index % items.length) + items.length) % items.length
|
|
74
|
+
items[wrapped].focus()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#items() {
|
|
78
|
+
return Array.from(
|
|
79
|
+
this.$container.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#typeahead(char, items) {
|
|
84
|
+
clearTimeout(this.#typeTimer)
|
|
85
|
+
this.#typeBuffer += char
|
|
86
|
+
this.#typeTimer = setTimeout(() => {
|
|
87
|
+
this.#typeBuffer = ''
|
|
88
|
+
}, TYPEAHEAD_TIMEOUT)
|
|
89
|
+
|
|
90
|
+
const buffer = this.#typeBuffer
|
|
91
|
+
const match = items.find(item => item.textContent.trim().toLowerCase().startsWith(buffer))
|
|
92
|
+
if (match) match.focus()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
WebComponent.register(DropdownMenu)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Properties:
|
|
3
|
+
*
|
|
4
|
+
* --pui-dropdown-menu-item-hover-bg: color-mix(in srgb, CanvasText 8%, transparent);
|
|
5
|
+
* --pui-dropdown-menu-item-active-bg: SelectedItem;
|
|
6
|
+
* --pui-dropdown-menu-item-active-color: SelectedItemText;
|
|
7
|
+
* --pui-dropdown-menu-item-disabled-opacity: 0.5;
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
@define-mixin base {
|
|
11
|
+
pui-dropdown-menu {
|
|
12
|
+
@mixin host;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@define-mixin host {
|
|
17
|
+
[role='menuitem'] {
|
|
18
|
+
display: block;
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
padding: 0.4em 0.8em;
|
|
21
|
+
color: inherit;
|
|
22
|
+
text-decoration: none;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
background: none;
|
|
25
|
+
border: none;
|
|
26
|
+
width: 100%;
|
|
27
|
+
text-align: inherit;
|
|
28
|
+
font: inherit;
|
|
29
|
+
|
|
30
|
+
&:hover {
|
|
31
|
+
background-color: var(
|
|
32
|
+
--pui-dropdown-menu-item-hover-bg,
|
|
33
|
+
color-mix(in srgb, CanvasText 8%, transparent)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&:focus-visible {
|
|
38
|
+
outline: none;
|
|
39
|
+
background-color: var(--pui-dropdown-menu-item-active-bg, SelectedItem);
|
|
40
|
+
color: var(--pui-dropdown-menu-item-active-color, SelectedItemText);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&[aria-disabled='true'] {
|
|
44
|
+
cursor: default;
|
|
45
|
+
opacity: var(--pui-dropdown-menu-item-disabled-opacity, 0.5);
|
|
46
|
+
|
|
47
|
+
&:hover {
|
|
48
|
+
background-color: transparent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (pointer: coarse) {
|
|
53
|
+
padding: 0.7em 1em;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hr {
|
|
58
|
+
border: 0;
|
|
59
|
+
border-top: 1px solid
|
|
60
|
+
var(--pui-dropdown-border-color, color-mix(in srgb, CanvasText 20%, transparent));
|
|
61
|
+
margin: 0.25em 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium::UI
|
|
4
|
+
class DropdownMenu < Dropdown
|
|
5
|
+
register_element :pui_dropdown_menu
|
|
6
|
+
|
|
7
|
+
def self.source_path
|
|
8
|
+
Pathname(__FILE__).sub_ext('').join('index.rb')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def menu_template
|
|
12
|
+
raise NotImplementedError,
|
|
13
|
+
"`#menu_template` must be implemented in subclasses of #{self.class}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def dropdown_template
|
|
17
|
+
menu_template
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def item(href: nil, disabled: false, **attrs, &)
|
|
21
|
+
base = { role: 'menuitem', tabindex: -1, **attrs }
|
|
22
|
+
if disabled
|
|
23
|
+
span(**base, aria_disabled: 'true', &)
|
|
24
|
+
elsif href
|
|
25
|
+
a(href: href, **base, &)
|
|
26
|
+
else
|
|
27
|
+
button(type: :button, **base, &)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def host_element = :pui_dropdown_menu
|
|
34
|
+
def trigger_haspopup = 'menu'
|
|
35
|
+
def container_role = 'menu'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,59 +1,55 @@
|
|
|
1
|
-
import domMutations from
|
|
2
|
-
import { Sourdough, toast } from
|
|
3
|
-
|
|
4
|
-
export function foo() {
|
|
5
|
-
console.log("foo");
|
|
6
|
-
}
|
|
1
|
+
import domMutations from 'dom-mutations'
|
|
2
|
+
import { Sourdough, toast } from 'sourdough-toast'
|
|
7
3
|
|
|
8
4
|
class HueFlash extends HTMLElement {
|
|
9
|
-
static observedAttributes = [
|
|
5
|
+
static observedAttributes = ['data-flash-alert', 'data-flash-notice']
|
|
10
6
|
|
|
11
7
|
connectedCallback() {
|
|
12
|
-
this.#initSourdough()
|
|
8
|
+
this.#initSourdough()
|
|
13
9
|
}
|
|
14
10
|
|
|
15
11
|
async #initSourdough() {
|
|
16
|
-
if (
|
|
12
|
+
if ('sourdoughBooted' in window) return
|
|
17
13
|
|
|
18
14
|
const sourdough = new Sourdough({
|
|
19
15
|
richColors: true,
|
|
20
|
-
yPosition:
|
|
21
|
-
xPosition:
|
|
22
|
-
})
|
|
23
|
-
sourdough.boot()
|
|
24
|
-
window.sourdoughBooted = true
|
|
16
|
+
yPosition: 'bottom',
|
|
17
|
+
xPosition: 'center'
|
|
18
|
+
})
|
|
19
|
+
sourdough.boot()
|
|
20
|
+
window.sourdoughBooted = true
|
|
25
21
|
|
|
26
|
-
// Watch for changes to
|
|
27
|
-
const flashesSelector = "meta[name='rails:flashes']"
|
|
22
|
+
// Watch for changes to rails:flashes meta tag
|
|
23
|
+
const flashesSelector = "meta[name='rails:flashes']"
|
|
28
24
|
for await (const mutation of domMutations(document.head, {
|
|
29
25
|
childList: true,
|
|
30
26
|
subtree: true,
|
|
31
|
-
attributes: true
|
|
27
|
+
attributes: true
|
|
32
28
|
})) {
|
|
33
|
-
let $ele = null
|
|
29
|
+
let $ele = null
|
|
34
30
|
|
|
35
31
|
if (
|
|
36
|
-
mutation.type ===
|
|
37
|
-
mutation.target.nodeName ==
|
|
38
|
-
mutation.attributeName ==
|
|
32
|
+
mutation.type === 'attributes' &&
|
|
33
|
+
mutation.target.nodeName == 'META' &&
|
|
34
|
+
mutation.attributeName == 'content'
|
|
39
35
|
) {
|
|
40
|
-
$ele = mutation.target
|
|
41
|
-
} else if (mutation.type ===
|
|
36
|
+
$ele = mutation.target
|
|
37
|
+
} else if (mutation.type === 'childList') {
|
|
42
38
|
for (const node of mutation.addedNodes) {
|
|
43
39
|
if (node.matches(flashesSelector)) {
|
|
44
|
-
$ele = node
|
|
45
|
-
break
|
|
40
|
+
$ele = node
|
|
41
|
+
break
|
|
46
42
|
}
|
|
47
43
|
}
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
if ($ele) {
|
|
51
|
-
const flashes = JSON.parse($ele.getAttribute(
|
|
47
|
+
const flashes = JSON.parse($ele.getAttribute('content'))
|
|
52
48
|
for (const [type, message] of Object.entries(flashes)) {
|
|
53
|
-
if (type ===
|
|
54
|
-
toast.error(message)
|
|
55
|
-
} else if (type ===
|
|
56
|
-
toast.success(message)
|
|
49
|
+
if (type === 'alert') {
|
|
50
|
+
toast.error(message)
|
|
51
|
+
} else if (type === 'notice') {
|
|
52
|
+
toast.success(message)
|
|
57
53
|
}
|
|
58
54
|
}
|
|
59
55
|
}
|
|
@@ -61,17 +57,16 @@ class HueFlash extends HTMLElement {
|
|
|
61
57
|
}
|
|
62
58
|
|
|
63
59
|
attributeChangedCallback(name, _oldValue, newValue) {
|
|
64
|
-
this.#initSourdough()
|
|
60
|
+
this.#initSourdough()
|
|
65
61
|
|
|
66
|
-
if (newValue === null) return
|
|
62
|
+
if (newValue === null) return
|
|
67
63
|
|
|
68
|
-
if (name ===
|
|
69
|
-
toast.warning(newValue)
|
|
70
|
-
} else if (name ===
|
|
71
|
-
toast.success(newValue)
|
|
64
|
+
if (name === 'data-flash-alert') {
|
|
65
|
+
toast.warning(newValue)
|
|
66
|
+
} else if (name === 'data-flash-notice') {
|
|
67
|
+
toast.success(newValue)
|
|
72
68
|
}
|
|
73
69
|
}
|
|
74
70
|
}
|
|
75
71
|
|
|
76
|
-
!customElements.get(
|
|
77
|
-
customElements.define("pui-flash", HueFlash);
|
|
72
|
+
!customElements.get('pui-flash') && customElements.define('pui-flash', HueFlash)
|
|
@@ -161,7 +161,7 @@ module Proscenium::UI::Form::Fields
|
|
|
161
161
|
def option_text_and_value(option)
|
|
162
162
|
# Options are [text, value] pairs or strings used for both.
|
|
163
163
|
if !option.is_a?(String) && option.respond_to?(:first) && option.respond_to?(:last)
|
|
164
|
-
option = option.
|
|
164
|
+
option = option.grep_v(Hash) if option.is_a?(Array)
|
|
165
165
|
[option.first, option.last]
|
|
166
166
|
else
|
|
167
167
|
[option, option]
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
const debug = proscenium.env.RAILS_ENV === 'development'
|
|
2
|
+
|
|
3
|
+
// Supported action events
|
|
4
|
+
const actionEvents = new Set([
|
|
5
|
+
'click',
|
|
6
|
+
'focusin',
|
|
7
|
+
'keydown',
|
|
8
|
+
'keyup',
|
|
9
|
+
'change',
|
|
10
|
+
'submit',
|
|
11
|
+
'beforeinput',
|
|
12
|
+
'input',
|
|
13
|
+
'dragstart',
|
|
14
|
+
'dragover',
|
|
15
|
+
'drop',
|
|
16
|
+
'dragend'
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extend any component with the ability to register itself as a custom element and listen for
|
|
21
|
+
* actions defined in the HTML.
|
|
22
|
+
*
|
|
23
|
+
* ### Usage
|
|
24
|
+
*
|
|
25
|
+
* A basic component can be defined and registered like this, where the required `componentName`
|
|
26
|
+
* static variable is a unique name for the component:
|
|
27
|
+
*
|
|
28
|
+
* ```javascript
|
|
29
|
+
* Component.register(
|
|
30
|
+
* class extends WebComponent {
|
|
31
|
+
* static componentName = 'my-component'
|
|
32
|
+
* }
|
|
33
|
+
* )
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Registering a component is required to use it in HTML, and is atomic, so it can be called
|
|
37
|
+
* multiple times without side effects.
|
|
38
|
+
*
|
|
39
|
+
* Finally insert your component into the page, using the `componentName` you gave it earlier:
|
|
40
|
+
*
|
|
41
|
+
* ```html
|
|
42
|
+
* <my-component></my-component>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* ### Actions
|
|
46
|
+
*
|
|
47
|
+
* WebComponent provides the ability to define 'actions' that can be triggered by events on the
|
|
48
|
+
* component. Actions are a convenient way to define events directly in the HTML, without needing to
|
|
49
|
+
* manually add and remove event listeners.
|
|
50
|
+
*
|
|
51
|
+
* Actions are defined as an object with event types as keys and an array of action names as values.
|
|
52
|
+
* The action names are the names of the methods on the component that will be called when the event
|
|
53
|
+
* is triggered.
|
|
54
|
+
*
|
|
55
|
+
* ```javascript
|
|
56
|
+
* Component.register(
|
|
57
|
+
* class extends WebComponent {
|
|
58
|
+
* static componentName = 'my-component'
|
|
59
|
+
* static actions = { click: ['doSomething'] }
|
|
60
|
+
*
|
|
61
|
+
* doSomething(event, target) {
|
|
62
|
+
* console.log('Hello, world!')
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
* )
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* The above component can be used as follows, where `doSomething` will be called when the button is
|
|
69
|
+
* clicked. The `doSomething` method will receive the event that triggered the action and the target
|
|
70
|
+
* of the event as arguments.
|
|
71
|
+
*
|
|
72
|
+
* ```html
|
|
73
|
+
* <my-component>
|
|
74
|
+
* <button on-click="doSomething">Click me</button>
|
|
75
|
+
* </my-component>
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* See `actionEvents` for the supported event types.
|
|
79
|
+
*
|
|
80
|
+
* ### Targets
|
|
81
|
+
*
|
|
82
|
+
* All elements with a `data-target` attribute are available via instance properties. For example,
|
|
83
|
+
* an element with `data-target="menu"` will create `$menuTarget` and `$menuTargets` properties.
|
|
84
|
+
* Where `$menuTarget` will be the first matching element, and `$menuTargets` will be an array of
|
|
85
|
+
* all matching elements.
|
|
86
|
+
*
|
|
87
|
+
* These properties are created on demand via a Proxy, so they are "live" — they always reflect the
|
|
88
|
+
* current DOM state. You can override a target property by explicitly setting it on the instance.
|
|
89
|
+
*
|
|
90
|
+
* ### Values
|
|
91
|
+
*
|
|
92
|
+
* All elements with a `data-value` attribute are available via instance properties. For example, an
|
|
93
|
+
* element with `data-value="count"` will create a `countValue` property. This property is reactive,
|
|
94
|
+
* so setting it will update the text content of the element, and changing the text content of the
|
|
95
|
+
* element will update the property.
|
|
96
|
+
*
|
|
97
|
+
* ### Observed Attributes
|
|
98
|
+
*
|
|
99
|
+
* Any attributes defined in the static `observedAttributes` array will be observed for changes as
|
|
100
|
+
* per the custom elements standard, and when they change, a method with the name of the attribute
|
|
101
|
+
* in camelCase with 'Changed' appended will be called. For example, an attribute of `data-open`
|
|
102
|
+
* will call the method `dataOpenChanged(oldValue, newValue)` when it changes.
|
|
103
|
+
*/
|
|
104
|
+
class WebComponent extends HTMLElement {
|
|
105
|
+
/**
|
|
106
|
+
* Register the component as a defined custom element.
|
|
107
|
+
*
|
|
108
|
+
* @param {Class} klass - The class to register as a custom element. If not given, the class this
|
|
109
|
+
* method is called on will be registered.
|
|
110
|
+
* @param {...Function} extendables - Any mixins to apply to the class before registering it.
|
|
111
|
+
* Deprecated in favour of `withMixins()` which correctly handles the order of mixins in a class
|
|
112
|
+
* hierarchy.
|
|
113
|
+
* @throws {TypeError} If the static variable `componentName` is not defined.
|
|
114
|
+
*/
|
|
115
|
+
static register(klass, ...extendables) {
|
|
116
|
+
function defineCustomElement(klass) {
|
|
117
|
+
if (klass.componentName === undefined) {
|
|
118
|
+
throw new TypeError('`componentName` must be defined when extending WebComponent')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!customElements.get(klass.componentName)) {
|
|
122
|
+
debug && console.debug(`[WebComponent] Registered: ${klass.componentName}`)
|
|
123
|
+
customElements.define(klass.componentName, klass)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (klass === undefined) {
|
|
128
|
+
defineCustomElement(this)
|
|
129
|
+
} else {
|
|
130
|
+
if (extendables.length > 0) {
|
|
131
|
+
console.warn(
|
|
132
|
+
'Passing mixins to WebComponent.register()` is deprecated. Please use `WebComponent.withMixins()` instead to ensure correct mixin order.'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
extendables.forEach(extendable => {
|
|
136
|
+
klass = extendable(klass)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If the class being registered has its own observedAttributes, we need to merge them with
|
|
141
|
+
// the ones from WebComponent. This is because when a class defines its own
|
|
142
|
+
// observedAttributes, it completely overrides the parent's value, which for some mixins,
|
|
143
|
+
// means the mixin won't work if the property is not merged.
|
|
144
|
+
if (Object.getPrototypeOf(klass).observedAttributes) {
|
|
145
|
+
klass.observedAttributes = [
|
|
146
|
+
...klass.observedAttributes,
|
|
147
|
+
...Object.getPrototypeOf(klass).observedAttributes
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
defineCustomElement(klass)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A helper method for applying mixins to a WebComponent class. Mixins should be functions that
|
|
157
|
+
* take a class and return a new class that extends it. For example:
|
|
158
|
+
*
|
|
159
|
+
* ```javascript
|
|
160
|
+
* function MyMixin(superClass) {
|
|
161
|
+
* return class extends superClass {
|
|
162
|
+
* myMethod() {
|
|
163
|
+
* console.log('Hello, world!')
|
|
164
|
+
* }
|
|
165
|
+
* }
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* You can then apply this mixin to a WebComponent class like this:
|
|
170
|
+
*
|
|
171
|
+
* ```javascript
|
|
172
|
+
* class MyComponent extends WebComponent.withMixins(MyMixin) {
|
|
173
|
+
* static componentName = 'my-component'
|
|
174
|
+
* }
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* You can apply multiple mixins by passing them as additional arguments to `withMixins()`, and
|
|
178
|
+
* they will be applied in reverse order. For example:
|
|
179
|
+
*
|
|
180
|
+
* ```javascript
|
|
181
|
+
* class MyComponent extends WebComponent.withMixins(MyMixin1, MyMixin2) {
|
|
182
|
+
* static componentName = 'my-component'
|
|
183
|
+
* }
|
|
184
|
+
* MyComponent.register()
|
|
185
|
+
* ```
|
|
186
|
+
*
|
|
187
|
+
* @param {...Function} mixins - The mixins to apply to the class.
|
|
188
|
+
*
|
|
189
|
+
* @returns {Class} A new class that extends the original class with the mixins applied.
|
|
190
|
+
*/
|
|
191
|
+
static withMixins(...mixins) {
|
|
192
|
+
let observedAttributes = []
|
|
193
|
+
let klass = this
|
|
194
|
+
|
|
195
|
+
mixins.reverse().forEach(mixin => {
|
|
196
|
+
klass = mixin(klass)
|
|
197
|
+
if (klass.observedAttributes) {
|
|
198
|
+
observedAttributes = [...observedAttributes, ...klass.observedAttributes]
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
klass.observedAttributes = observedAttributes
|
|
203
|
+
|
|
204
|
+
return klass
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
static actions = []
|
|
208
|
+
|
|
209
|
+
#hasActions = new Set()
|
|
210
|
+
|
|
211
|
+
componentName = this.constructor.componentName
|
|
212
|
+
|
|
213
|
+
constructor() {
|
|
214
|
+
super()
|
|
215
|
+
this.actions = this.constructor.actions
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
connectedCallback() {
|
|
219
|
+
this.#listenForActions()
|
|
220
|
+
this.#createValues()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
disconnectedCallback() {
|
|
224
|
+
this.#unlistenForActions()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
228
|
+
const callbackName = `${camelCase(name)}Changed`
|
|
229
|
+
if (callbackName in this) {
|
|
230
|
+
this[callbackName](oldValue, newValue)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Listen for an event on this component with the name of `eventName`. This accepts the same
|
|
236
|
+
* arguments as `addEventListener`, but with a few niceties.
|
|
237
|
+
*
|
|
238
|
+
* If `eventName` is prefixed with a colon, the component name will be prepended to it. This is
|
|
239
|
+
* useful for namespacing events to the component. For example, an event name of ':clickOutside'
|
|
240
|
+
* on a component with name of 'my-component' will be converted to 'my-component:clickOutside'.
|
|
241
|
+
* All other event names are passed through untouched.
|
|
242
|
+
*
|
|
243
|
+
* If `eventName` is prefixed with a caret, it will be listened for on the document instead of the
|
|
244
|
+
* component, but the callback will still be on the component instance. For example, `^click` will
|
|
245
|
+
* listen for 'click' events on the document, but call the callback on the component instance.
|
|
246
|
+
* This is a convenient syntax for listening globally - on the document, while still keeping the
|
|
247
|
+
* callback logic within the component.
|
|
248
|
+
*
|
|
249
|
+
* Any event that contains a colon will be listened for on the `document` instead of the
|
|
250
|
+
* component, which means you can use this same listen/dispatch API within any component to listen
|
|
251
|
+
* for events on any other component.
|
|
252
|
+
*
|
|
253
|
+
* If `callback` is not provided, the component will listen for the event on itself and call the
|
|
254
|
+
* `handleEvent` method when the event is triggered (as per the `addEventListener` logic).
|
|
255
|
+
*
|
|
256
|
+
* If `callback` is not given as the second argument, you can pass the options object as the
|
|
257
|
+
* instead. For example: `listen('click', { capture: true })`.
|
|
258
|
+
*
|
|
259
|
+
* If `handleEvent` is used, and the event is namespaced to the component, the method with the
|
|
260
|
+
* same name as the event type will be called. For example, if the event type is
|
|
261
|
+
* 'my-component:click', the method `onClick` will be called. This avoids you having to define
|
|
262
|
+
* `handleEvent`, and simply define the methods for each event type.
|
|
263
|
+
*
|
|
264
|
+
* @param {String} eventName
|
|
265
|
+
* @param {Function | Object#handleEvent | Object} callback - The function to call when the event
|
|
266
|
+
* is triggered. Defaults to `this`.
|
|
267
|
+
* @param {Object} options - Options to pass to `addEventListener`
|
|
268
|
+
*/
|
|
269
|
+
// eslint-disable-next-line no-unused-vars
|
|
270
|
+
listen(eventName, callback, options = {}) {
|
|
271
|
+
const listenerArgs = this.#buildEventListenerArguments(...arguments)
|
|
272
|
+
|
|
273
|
+
if (listenerArgs[0].startsWith('^')) {
|
|
274
|
+
listenerArgs[0] = listenerArgs[0].slice(1)
|
|
275
|
+
document.addEventListener(...listenerArgs)
|
|
276
|
+
} else if (listenerArgs[0].includes(':')) {
|
|
277
|
+
document.addEventListener(...listenerArgs)
|
|
278
|
+
} else {
|
|
279
|
+
this.addEventListener(...listenerArgs)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Unlisten for an event on this component with the name of `eventName`. This accepts the same
|
|
285
|
+
* arguments as the `listen()` method above.
|
|
286
|
+
*
|
|
287
|
+
* @param {String} eventName
|
|
288
|
+
* @param {Function | Object#handleEvent | Object} callback - The function to call when the event
|
|
289
|
+
* is triggered.
|
|
290
|
+
* @param {Object} options - Options to pass to `addEventListener`
|
|
291
|
+
*/
|
|
292
|
+
// eslint-disable-next-line no-unused-vars
|
|
293
|
+
unlisten(eventName, callback, options = {}) {
|
|
294
|
+
const listenerArgs = this.#buildEventListenerArguments(...arguments)
|
|
295
|
+
|
|
296
|
+
if (listenerArgs[0].startsWith('^')) {
|
|
297
|
+
listenerArgs[0] = listenerArgs[0].slice(1)
|
|
298
|
+
document.removeEventListener(...listenerArgs)
|
|
299
|
+
} else if (listenerArgs[0].includes(':')) {
|
|
300
|
+
document.removeEventListener(...listenerArgs)
|
|
301
|
+
} else {
|
|
302
|
+
this.removeEventListener(...listenerArgs)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Dispatch a custom event on this component. This is a wrapper around the native `dispatchEvent`
|
|
308
|
+
* method, but with some niceties.
|
|
309
|
+
*
|
|
310
|
+
* If `eventName` is prefixed with a colon, the component name will be prepended to it. This is
|
|
311
|
+
* useful for namespacing events to the component. For example, an event name of ':clickOutside'
|
|
312
|
+
* on a component with name of 'my-component' will be converted to 'my-component:clickOutside'.
|
|
313
|
+
* All other event names are passed through untouched.
|
|
314
|
+
*
|
|
315
|
+
* If the converted `eventName` is prefixed with the current component name and colon, events will
|
|
316
|
+
* be dispatched on the component. Eg. `my-component:clickOutside` will be dispatched on the
|
|
317
|
+
* `my-component` instance.
|
|
318
|
+
*
|
|
319
|
+
* @param {String} eventName - The name of the event to dispatch.
|
|
320
|
+
* @param {Object} options - Options for the event.
|
|
321
|
+
* @param {boolean} options.cancelable - Whether the event is cancelable.
|
|
322
|
+
* @param {any} options.detail - The detail to include with the event.
|
|
323
|
+
*
|
|
324
|
+
* @returns {CustomEvent} The dispatched event.
|
|
325
|
+
*/
|
|
326
|
+
dispatch(eventName, { cancelable, detail } = {}) {
|
|
327
|
+
if (eventName.startsWith(':')) {
|
|
328
|
+
eventName = this.constructor.componentName + eventName
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const event = new CustomEvent(eventName, {
|
|
332
|
+
bubbles: true,
|
|
333
|
+
composed: true,
|
|
334
|
+
cancelable,
|
|
335
|
+
detail
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (eventName.startsWith(`${this.constructor.componentName}:`)) {
|
|
339
|
+
this.dispatchEvent(event)
|
|
340
|
+
} else {
|
|
341
|
+
document.dispatchEvent(event)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return event
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Handle an event triggered on this component. This is called automatically when an event is
|
|
349
|
+
* triggered on the component, and can be overridden to provide custom logic.
|
|
350
|
+
*
|
|
351
|
+
* If a function with the same name as the event type exists on the component, it will be called.
|
|
352
|
+
* For example, if the event type is 'click' or 'my-component:click', the method `onClick` will be
|
|
353
|
+
* called.
|
|
354
|
+
*
|
|
355
|
+
* @param {Event} event - The event that was triggered.
|
|
356
|
+
*
|
|
357
|
+
* @returns {boolean} Whether the event was handled. This allows you to override this method while
|
|
358
|
+
* still calling the parent method. For example: `super.handleEvent(event) || ...`
|
|
359
|
+
*/
|
|
360
|
+
handleEvent(event) {
|
|
361
|
+
let { type } = event
|
|
362
|
+
if (type.includes(':')) {
|
|
363
|
+
type = type.slice(type.indexOf(':') + 1)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (this['on' + capitalize(type)]) {
|
|
367
|
+
this['on' + capitalize(type)](event)
|
|
368
|
+
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
$(x) {
|
|
376
|
+
return this.querySelector(x)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
$$(x) {
|
|
380
|
+
return Array.from(this.querySelectorAll(x))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Create reactive value properties
|
|
384
|
+
#createValues() {
|
|
385
|
+
this.querySelectorAll('[data-value]').forEach($ele => {
|
|
386
|
+
const prop = `${$ele.dataset.value}Value`
|
|
387
|
+
if (!(prop in this)) {
|
|
388
|
+
Object.defineProperty(this, prop, {
|
|
389
|
+
get: () => $ele.textContent,
|
|
390
|
+
set: v => {
|
|
391
|
+
$ele.textContent = v
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#listenForActions() {
|
|
399
|
+
Object.entries(this.actions).forEach(([type, actions]) => {
|
|
400
|
+
// Warn if the action type is unknown. See `actionEvents`.
|
|
401
|
+
if (!actionEvents.has(type)) {
|
|
402
|
+
console.warn(
|
|
403
|
+
'%c[WebComponent]%c Unknown action: %o for %o',
|
|
404
|
+
'font-weight: bold',
|
|
405
|
+
'font-weight: normal',
|
|
406
|
+
type,
|
|
407
|
+
this.constructor.componentName
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Type is defined, but no actions are given, so ignore.
|
|
413
|
+
if (actions.length === 0) return
|
|
414
|
+
|
|
415
|
+
this.#hasActions.add(type)
|
|
416
|
+
this.addEventListener(type, this.#handleAction)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#unlistenForActions() {
|
|
421
|
+
this.#hasActions.forEach(type => {
|
|
422
|
+
this.removeEventListener(type, this.#handleAction)
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#handleAction = event => {
|
|
427
|
+
if (!Object.keys(this.actions).includes(event.type)) return
|
|
428
|
+
|
|
429
|
+
const actionAttr = `on-${event.type}`
|
|
430
|
+
const target = event.target.closest(`[${actionAttr}]`)
|
|
431
|
+
|
|
432
|
+
if (!target) return
|
|
433
|
+
|
|
434
|
+
const action = target.getAttribute(actionAttr)
|
|
435
|
+
|
|
436
|
+
if (this.actions[event.type].includes(action)) {
|
|
437
|
+
if (this[action] === undefined) {
|
|
438
|
+
throw new TypeError(
|
|
439
|
+
`Function for action '${action}' is not defined in ${this.constructor.name}`
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this[action](event, target)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#buildEventListenerArguments(eventName, callback, options) {
|
|
448
|
+
if (callback === undefined) {
|
|
449
|
+
callback = this
|
|
450
|
+
} else if (isPlainObject(callback)) {
|
|
451
|
+
options = callback
|
|
452
|
+
callback = this
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (eventName.startsWith(':')) {
|
|
456
|
+
eventName = this.constructor.componentName + eventName
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return [eventName, callback, options]
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Proxy the prototype to lazily resolve $<name>Target and $<name>Targets properties from the DOM.
|
|
464
|
+
Object.setPrototypeOf(
|
|
465
|
+
WebComponent.prototype,
|
|
466
|
+
new Proxy(Object.getPrototypeOf(WebComponent.prototype), {
|
|
467
|
+
get(target, prop, receiver) {
|
|
468
|
+
if (typeof prop === 'string' && !Object.hasOwn(receiver, prop)) {
|
|
469
|
+
let match
|
|
470
|
+
if ((match = prop.match(/^\$(.+)Targets$/))) {
|
|
471
|
+
return receiver.$$(`[data-target="${match[1]}"]`)
|
|
472
|
+
}
|
|
473
|
+
if ((match = prop.match(/^\$(.+)Target$/))) {
|
|
474
|
+
return receiver.$(`[data-target="${match[1]}"]`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return Reflect.get(target, prop, receiver)
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
function isPlainObject(value) {
|
|
483
|
+
if (Object.prototype.toString.call(value) !== '[object Object]') return false
|
|
484
|
+
|
|
485
|
+
const prototype = Object.getPrototypeOf(value)
|
|
486
|
+
return prototype === null || prototype === Object.prototype
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function capitalize(str) {
|
|
490
|
+
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function camelCase(str) {
|
|
494
|
+
return str.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export default WebComponent
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: proscenium-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joel Moss
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 1.
|
|
32
|
+
version: 1.9.0
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 1.
|
|
39
|
+
version: 1.9.0
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: phonelib
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -105,6 +105,14 @@ files:
|
|
|
105
105
|
- lib/proscenium/ui/combobox/index.css
|
|
106
106
|
- lib/proscenium/ui/combobox/index.js
|
|
107
107
|
- lib/proscenium/ui/component.rb
|
|
108
|
+
- lib/proscenium/ui/dropdown.rb
|
|
109
|
+
- lib/proscenium/ui/dropdown/index.css
|
|
110
|
+
- lib/proscenium/ui/dropdown/index.js
|
|
111
|
+
- lib/proscenium/ui/dropdown/mixin.css
|
|
112
|
+
- lib/proscenium/ui/dropdown_menu.rb
|
|
113
|
+
- lib/proscenium/ui/dropdown_menu/index.css
|
|
114
|
+
- lib/proscenium/ui/dropdown_menu/index.js
|
|
115
|
+
- lib/proscenium/ui/dropdown_menu/mixin.css
|
|
108
116
|
- lib/proscenium/ui/flash.rb
|
|
109
117
|
- lib/proscenium/ui/flash/index.css
|
|
110
118
|
- lib/proscenium/ui/flash/index.js
|
|
@@ -149,6 +157,7 @@ files:
|
|
|
149
157
|
- lib/proscenium/ui/ujs/data_disable_with.js
|
|
150
158
|
- lib/proscenium/ui/ujs/index.js
|
|
151
159
|
- lib/proscenium/ui/version.rb
|
|
160
|
+
- lib/proscenium/ui/web_component.js
|
|
152
161
|
homepage: https://proscenium.rocks
|
|
153
162
|
licenses:
|
|
154
163
|
- MIT
|