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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca2623aea45e90824299d7c043f1ed80814fc18dea6598c43a798cc9afa33a7f
4
- data.tar.gz: 8c4140e7664ad61705fee670455761b2ccef3aa429b149c5733f14e374eff45a
3
+ metadata.gz: 0a252059bfea054875121e7d18fbbf83fdf76736b76e92404930bec2af032b0d
4
+ data.tar.gz: a4b9368ca7cbb9cdb49eb8f43deb8d7db59f951e206a7ac38e7b37326c4fdde8
5
5
  SHA512:
6
- metadata.gz: 83809d6e7fbe7a7bf033ef58a68468c690abe17034e1327ba326dea1d13218a571988a621c23ce339bab0ecb04345084c605cd658e20ec80cb35d9dd9e06ba8f
7
- data.tar.gz: 407fee2ddd35600f3722b2abb7b3794288248c0b07ff666fb318555bbb51826e4c84c721b0e24c4750e6dbb07fdfc5100a3241f8b840b38d816664efa76340ce
6
+ metadata.gz: c8a3605f8a49c3a24b0beb21ffdc165c6ea1821563931365a78e707bced0326dcbbd17df6404aead0e1116dd37a6c487e126fc7ea15f2f0915c63cae1e137035
7
+ data.tar.gz: 46ae40d972ab7dad9d9ba741d005b35184717af482db23f77a12b4b5851650ff6da3aefbad40263a42c17e1e9d4c43e533d1159c18cba536e4750d0261c375aa
@@ -0,0 +1,3 @@
1
+ @layer pui {
2
+ @mixin base from url('./mixin.css');
3
+ }
@@ -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,3 @@
1
+ @layer pui {
2
+ @mixin base from url('./mixin.css');
3
+ }
@@ -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 "dom-mutations";
2
- import { Sourdough, toast } from "sourdough-toast";
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 = ["data-flash-alert", "data-flash-notice"];
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 ("sourdoughBooted" in window) return;
12
+ if ('sourdoughBooted' in window) return
17
13
 
18
14
  const sourdough = new Sourdough({
19
15
  richColors: true,
20
- yPosition: "bottom",
21
- xPosition: "center",
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 htl:flashes meta tag
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 === "attributes" &&
37
- mutation.target.nodeName == "META" &&
38
- mutation.attributeName == "content"
32
+ mutation.type === 'attributes' &&
33
+ mutation.target.nodeName == 'META' &&
34
+ mutation.attributeName == 'content'
39
35
  ) {
40
- $ele = mutation.target;
41
- } else if (mutation.type === "childList") {
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("content"));
47
+ const flashes = JSON.parse($ele.getAttribute('content'))
52
48
  for (const [type, message] of Object.entries(flashes)) {
53
- if (type === "alert") {
54
- toast.error(message);
55
- } else if (type === "notice") {
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 === "data-flash-alert") {
69
- toast.warning(newValue);
70
- } else if (name === "data-flash-notice") {
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("pui-flash") &&
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.reject { |e| e.is_a?(Hash) } if option.is_a?(Array)
164
+ option = option.grep_v(Hash) if option.is_a?(Array)
165
165
  [option.first, option.last]
166
166
  else
167
167
  [option, option]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Proscenium
4
4
  module UI
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -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.1.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.8.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.8.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