@csedl/hotwire-svelte-helpers 0.1.1 → 1.0.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.
package/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
 
2
2
  import HotwireSvelteHelpers from "./src/lib/config.js";
3
3
  import { cleanMount, unmountAllDetached } from './src/svelte/cleanMount';
4
- import {initializeDropdown, onPanelOpen, getOrSetPanelId, debugLog} from './src/lib/utils.js'
4
+ import {initializeDropdown, openDropdownPanel, getOrSetPanelId, debugLog} from './src/lib/utils.js'
5
5
 
6
6
 
7
7
 
8
8
  export {
9
9
  HotwireSvelteHelpers,
10
10
  cleanMount, unmountAllDetached,
11
- initializeDropdown, onPanelOpen, getOrSetPanelId, debugLog
11
+ initializeDropdown, openDropdownPanel, getOrSetPanelId, debugLog
12
12
  }
package/package.json CHANGED
@@ -1,25 +1,23 @@
1
1
  {
2
2
  "name": "@csedl/hotwire-svelte-helpers",
3
- "version": "0.1.1",
4
- "description": "Hotwire + Svelte helpers for Rails: Stimulus floating dropdowns/toolips + Svelte global panels/modals + Rails form error mapping + Turbo-friendly utilities. Build together with the rubygem svelte-on-rails and its npm-package.",
3
+ "version": "1.0.0",
4
+ "description": "Hotwire + Svelte helpers for Rails: Stimulus floating dropdowns/toolips + Svelte global panels/modals + RTurbo-friendly utilities. Build together with the rubygem svelte-on-rails and its npm-package.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"
8
8
  },
9
9
  "exports": {
10
- ".": [
11
- "./index.js",
12
- "./src/utils.js"
13
- ]
10
+ ".": "./index.js"
14
11
  },
15
12
  "repository": {
16
13
  "type": "git",
17
14
  "url": "git+https://gitlab.com/sedl/csedl-hotwire-svelte-helpers"
18
15
  },
19
16
  "keywords": [
20
- "Stimulus",
17
+ "Hotwire",
21
18
  "Dropdown",
22
- "Rails"
19
+ "Rails",
20
+ "Svelte"
23
21
  ],
24
22
  "author": "Christian Sedlmair",
25
23
  "license": "MIT",
package/src/lib/config.js CHANGED
@@ -3,6 +3,7 @@ import { Application } from "@hotwired/stimulus"
3
3
  import dc from '../stimulus/dropdown-controller'
4
4
  import ttc from '../stimulus/tooltip-controller'
5
5
  import ppc from '../stimulus/move-panels-controller'
6
+ import { validateOptions } from "./type-validators";
6
7
 
7
8
  // DEFAULTS
8
9
  let _debug = false;
@@ -34,6 +35,16 @@ const HotwireSvelteHelpers = {
34
35
  },
35
36
 
36
37
  initializeOverlays(options = {}) {
38
+
39
+ validateOptions(options, {
40
+ closeButtonSelector: 'string',
41
+ dropdownContentSelector: 'string',
42
+ tooltipContentSelector: 'string',
43
+ addArrow: 'boolean',
44
+ persistTooltipOnClick: 'boolean',
45
+ closeOnClickOutsideListenerAdded: 'boolean',
46
+ })
47
+
37
48
  const overlays = this.overlays;
38
49
 
39
50
  if (options.closeButtonSelector !== undefined) {
@@ -42,6 +42,7 @@ export function positionAllPanels(elements) {
42
42
 
43
43
  // Positions a single panel relative to its button
44
44
  export function positionPanel(button, panel) {
45
+
45
46
  let arrowElement = panel.querySelector('#arrow')
46
47
  panel.style.removeProperty('height')
47
48
  panel.style.removeProperty('left')
@@ -75,9 +76,9 @@ export function positionPanel(button, panel) {
75
76
  bottom: '',
76
77
  [staticSide]: '-4px',
77
78
  });
78
- debugLog(`panel + arrow positioned`)
79
+ debugLog(`panel + arrow positioned: ${Math.round(x)}/${Math.round(y)}`)
79
80
  } else {
80
- debugLog(`panel positioned`)
81
+ debugLog(`panel positioned: ${Math.round(x)}/${Math.round(y)}`)
81
82
  }
82
83
 
83
84
  adjustToWindowBounds(button, panel)
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Validates that all options in `givenOptions` are valid keys
3
+ * and have the correct runtime type as defined in `validations`.
4
+ *
5
+ * @param {Object} givenOptions - The options object to validate
6
+ * @param {Object} validations - Map of option name → expected typeof string
7
+ * e.g. { timeout: 'number', debug: 'boolean' }
8
+ * @throws {Error} If an invalid option is found or type doesn't match
9
+ */
10
+ export function validateOptions(givenOptions, validations) {
11
+ const validKeys = Object.keys(validations);
12
+
13
+ for (const option of Object.keys(givenOptions)) {
14
+ // Check if the option is allowed
15
+ if (!validKeys.includes(option)) {
16
+ throw new Error(
17
+ `Invalid option: "${option}". ` +
18
+ `Available options are:\n • ${validKeys.map(k => `"${k}"`).join("\n • ")}`
19
+ );
20
+ }
21
+
22
+ // Check the type
23
+ const expectedType = validations[option];
24
+ const actualType = typeof givenOptions[option];
25
+
26
+ if (actualType !== expectedType) {
27
+ throw new Error(
28
+ `Option «${option}»: expected type «${expectedType}», ` +
29
+ `but received «${actualType}» ` +
30
+ `(value: ${JSON.stringify(givenOptions[option])})`
31
+ );
32
+ }
33
+ }
34
+ }
package/src/lib/utils.js CHANGED
@@ -1,52 +1,52 @@
1
- import {positionPanelSelf, positionPanelByButton} from "./floating-ui-functions.js";
1
+ import {positionPanelSelf, positionPanelByButton, positionPanel} from "./floating-ui-functions.js";
2
+ import {validateOptions} from "./type-validators";
2
3
 
3
4
  // Fetch content from server based on panel's data-src attribute and update content
4
- export function onPanelOpen(event, button) {
5
+ export function openDropdownPanel(buttonElement, panelElement, options = {}) {
5
6
 
6
- const panel = findPanel(button);
7
+ if (buttonElement.classList.contains('has-open-panel')) {return}
7
8
 
8
- debugLog('ON-PANEL-OPEN FUNCTIONS')
9
+ //validateOptions(options, {clickedElement: 'object'})
10
+ //const clickedElement = options.clickedElement || buttonElement;
9
11
 
10
- closeOnOutsideClick(event)
12
+
13
+ // link button and panel, initialize
14
+ panelElement.id = getOrSetPanelId(buttonElement);
15
+ initializeDropdown(buttonElement)
16
+ debugLog(`opening panel ${panelElement.id.split('-').pop()}`)
17
+ panelElement.style.display = 'block';
11
18
 
12
19
  // add arrow
13
20
  if (window.HotwireSvelteHelpers.overlays.addArrow) {
14
- if (!panel.querySelector(':scope > #arrow')) {
21
+ if (!panelElement.querySelector(':scope > #arrow')) {
15
22
  const arrowTag = document.createElement('div');
16
23
  arrowTag.id = 'arrow';
17
- panel.appendChild(arrowTag)
24
+ panelElement.appendChild(arrowTag)
18
25
  }
19
26
  }
20
27
 
21
- positionPanelByButton(button);
28
+ positionPanel(buttonElement, panelElement);
22
29
 
23
30
  // Set focus to input element
24
- if (panel.hasAttribute('data-set-focus')) {
25
- let dataFocus = panel.getAttribute('data-set-focus');
26
- let focusElement = panel.querySelector(dataFocus);
31
+ if (panelElement.hasAttribute('data-set-focus')) {
32
+ let dataFocus = panelElement.getAttribute('data-set-focus');
33
+ let focusElement = panelElement.querySelector(dataFocus);
27
34
  focusElement.focus();
28
35
  }
29
36
 
30
37
  // Set status attribute
31
- panel.setAttribute('data-stimulus-overlay-panel-status', 'open');
32
- button.classList.add('has-open-panel');
38
+ panelElement.setAttribute('data-hsh-panel-status', 'open');
39
+ buttonElement.classList.add('has-open-panel');
33
40
 
34
41
  // Dispatch events
35
42
  const panelOpenEvent = new CustomEvent('before-open');
36
- panel.dispatchEvent(panelOpenEvent);
43
+ panelElement.dispatchEvent(panelOpenEvent);
37
44
 
38
45
  const buttonOpenEvent = new CustomEvent('before-open-panel');
39
- button.dispatchEvent(buttonOpenEvent);
40
-
41
- // Add listener for closing on outside click
42
- if (!window.HotwireSvelteHelpers.overlays.closeOnClickOutsideListenerAdded) {
43
- window.HotwireSvelteHelpers.overlays.closeOnClickOutsideListenerAdded = true;
44
- window.addEventListener('click', closeOnOutsideClick);
45
- debugLog('Listener for close panels on click outside added');
46
- }
46
+ buttonElement.dispatchEvent(buttonOpenEvent);
47
47
 
48
48
  // fetch content from server
49
- const src = panel.getAttribute('data-src');
49
+ const src = panelElement.getAttribute('data-src');
50
50
  if (src) {
51
51
  let xhr = new XMLHttpRequest();
52
52
  debugLog(`Panel / data-src: «${src}»`);
@@ -56,46 +56,48 @@ export function onPanelOpen(event, button) {
56
56
  if (xhr.status !== 200) {
57
57
  alert(`Dropdown Controller, GET «${src}», Error ${xhr.status}: ${xhr.statusText}`);
58
58
  } else {
59
- const button = document.querySelector(`[data-panel-id="${panel.id}"]`);
60
- const ctrl = button.getAttribute('data-controller');
59
+ const buttonElement = document.querySelector(`[data-panel-id="${panelElement.id}"]`);
60
+ const ctrl = buttonElement.getAttribute('data-controller');
61
61
  const config = window.HotwireSvelteHelpers.overlays;
62
62
  const contentSelector = config.dropdownContentSelector;
63
63
 
64
64
  if (ctrl === 'csedl-dropdown' && contentSelector) {
65
65
  debugLog(`dropdown / contentSelector: «${contentSelector}»`);
66
- const wrapper = panel.querySelector(contentSelector);
66
+ const wrapper = panelElement.querySelector(contentSelector);
67
67
  wrapper.innerHTML = xhr.response;
68
68
  } else if (ctrl === 'csedl-tooltip' && contentSelector) {
69
69
  debugLog(`tooltip / contentSelector: «${contentSelector}»`);
70
- const wrapper = panel.querySelector(config.tooltipContentSelector);
70
+ const wrapper = panelElement.querySelector(config.tooltipContentSelector);
71
71
  wrapper.innerHTML = xhr.response;
72
72
  } else {
73
73
  debugLog(`? / contentSelector: «${contentSelector}»`);
74
- panel.innerHTML = xhr.response;
74
+ panelElement.innerHTML = xhr.response;
75
75
  console.error('fallback to replace whole panel');
76
76
  }
77
77
 
78
- positionPanelByButton(button, `after http-request "${ctrl}"`);
78
+ positionPanelByButton(buttonElement, `after http-request "${ctrl}"`);
79
79
  }
80
80
  };
81
81
  } else {
82
82
  debugLog('no data-src attribute provided on panel');
83
83
  }
84
+
84
85
  }
85
86
 
86
87
  // Handle panel closing
87
- export function onPanelClose(button) {
88
+ export function closePanel(button) {
88
89
 
89
- const panel = findPanel(button);
90
+ const panel = findPanelOrThrow(button);
90
91
 
91
- debugLog('ON-PANEL-CLOSE FUNCTIONS')
92
+ debugLog(`closing panel ${panel.id.split('-').pop()}`)
92
93
 
93
94
  // Update status attributes
94
95
  button.classList.remove('has-open-panel');
95
- panel.setAttribute('data-stimulus-overlay-panel-status', 'closed');
96
+ panel.setAttribute('data-hsh-panel-status', 'closed');
97
+ panel.style.display = 'none';
96
98
 
97
99
  // Remove outside click listener if no panels are open
98
- const openPanels = document.querySelectorAll("[data-stimulus-overlay-panel-status='open']");
100
+ const openPanels = document.querySelectorAll("[data-hsh-panel-status='open']");
99
101
  debugLog(`panel closed, still open: ${openPanels.length}`);
100
102
  if (window.HotwireSvelteHelpers.closeOnClickOutsideListenerAdded && openPanels.length === 0) {
101
103
  window.HotwireSvelteHelpers.closeOnClickOutsideListenerAdded = false;
@@ -109,12 +111,27 @@ export function onPanelClose(button) {
109
111
  }
110
112
 
111
113
  // Close panels when clicking outside
112
- export function closeOnOutsideClick(ev) {
114
+ export function closeOnOutsideClick(clickedElement) {
115
+
116
+ // set button
117
+ let btn
118
+ if (clickedElement instanceof HTMLElement) {
119
+ btn = clickedElement;
120
+ } else if (clickedElement instanceof Event) {
121
+ btn = clickedElement.target;
122
+ } else {
123
+ console.error('closeOnOutsideClick: invalid argument', clickedElement);
124
+ return false;
125
+ }
126
+
127
+ // do nothing if clicked within button or panel
128
+ const in_btn = btn.closest(".dropdown-button");
129
+ const in_panel = btn.closest(".dropdown-panel");
130
+ if (in_btn || in_panel) return;
113
131
 
114
- let btn = ev.target;
115
132
  let parentPanelIds = [];
116
133
 
117
- const persistElement = ev.target.closest('[data-overlay-persist]');
134
+ const persistElement = btn.closest('[data-overlay-persist]');
118
135
  if (persistElement) {
119
136
  if (persistElement.getAttribute('data-overlay-persist') !== 'false') {
120
137
  debugLog('closing panel prevented because the target element has attribute "data-overlay-persist"');
@@ -122,10 +139,10 @@ export function closeOnOutsideClick(ev) {
122
139
  }
123
140
  }
124
141
 
125
- debugLog('closeOnOutsideClick called, event.target:', ev.target);
142
+ debugLog('closeOnOutsideClick called, clicked on:', btn);
126
143
 
127
144
  while (true) {
128
- const parentPanel = btn.closest("[data-stimulus-overlay-panel-status='open']");
145
+ const parentPanel = btn.closest("[data-hsh-panel-status='open']");
129
146
  if (parentPanel) {
130
147
  parentPanelIds.push(parentPanel.id);
131
148
  btn = document.querySelector(`[data-panel-id="${parentPanel.id}"]`);
@@ -134,11 +151,14 @@ export function closeOnOutsideClick(ev) {
134
151
  }
135
152
  }
136
153
 
137
- const openPanels = document.querySelectorAll("[data-stimulus-overlay-panel-status='open']");
154
+ const openPanels = document.querySelectorAll("[data-hsh-panel-status='open']");
138
155
  for (const panel of openPanels) {
139
156
  if (!parentPanelIds.includes(panel.id)) {
157
+ debugLog(`closeOnOutsideClick: dispatching close event for panel ${panel.id.split('-').pop()}`);
140
158
  const ev = new Event('close');
141
159
  panel.dispatchEvent(ev);
160
+ } else {
161
+ debugLog('closeOnOutsideClick: panel is still open:', panel);
142
162
  }
143
163
  }
144
164
  }
@@ -156,22 +176,58 @@ export function initializeDropdown(button) {
156
176
  throw new Error(`panel element not found by ID: «${panel_id}»`);
157
177
  }
158
178
 
159
- // Add close button functionality
160
- const selector = window.HotwireSvelteHelpers.overlays.closeButtonSelector;
161
- const closeButtons = panel.querySelectorAll(selector);
162
- for (const btn of closeButtons) {
163
- btn.addEventListener('click', () => {
164
- const ev = new Event('close');
165
- panel.dispatchEvent(ev);
166
- });
179
+ let msg = []
180
+
181
+ // initialize panel
182
+ if (!panel.hasAttribute('data-hsh-initialized')) {
183
+ panel.setAttribute('data-hsh-initialized', 'true');
184
+
185
+ // Add close button functionality
186
+ const selector = window.HotwireSvelteHelpers.overlays.closeButtonSelector;
187
+ const closeButtons = panel.querySelectorAll(selector);
188
+ for (const btn of closeButtons) {
189
+ btn.addEventListener('click', () => {
190
+ const ev = new Event('close');
191
+ panel.dispatchEvent(ev);
192
+ });
193
+ }
194
+ // Add event listeners to panel
195
+ panel.addEventListener('place-me', () => positionPanelSelf(panel));
196
+ panel.addEventListener('close', () => closePanel(button))
197
+ msg.push('panel')
198
+ } else {
199
+ debugLog('initializeDropdown: panel already initialized:', panel.id.split('-').pop())
167
200
  }
168
201
 
169
- // Add event listeners to panel
170
- panel.addEventListener('place-me', () => positionPanelSelf(panel));
171
- button.addEventListener('place-panel', () => positionPanelByButton(button))
172
- panel.addEventListener('close', () => onPanelClose(button))
202
+ // initialize the button
203
+ if (!button.hasAttribute('data-hsh-initialized')) {
204
+ button.setAttribute('data-hsh-initialized', 'true');
205
+ button.addEventListener('place-panel', () => positionPanelByButton(button))
206
+ msg.push('button')
207
+ }
173
208
 
174
- debugLog(`Initialized dropdown, panel-ID: «${panel_id}»`);
209
+ // Add listener for closing on outside click
210
+ let overlays = window.HotwireSvelteHelpers.overlays
211
+ if (!overlays.closeOnClickOutsideListenerAdded) {
212
+ overlays.closeOnClickOutsideListenerAdded = true;
213
+ setTimeout(() => {
214
+ window.addEventListener('click', closeOnOutsideClick)
215
+ }, 100)
216
+ msg.push('close-on-outside-click')
217
+ }
218
+
219
+
220
+ if (msg.length > 0) {
221
+ debugLog(`Initialized «${panel_id}»: ${msg.join(' + ')}`);
222
+ }
223
+ }
224
+
225
+ export function findPanelOrThrow(button) {
226
+ const panel = document.getElementById(button.getAttribute('data-panel-id'))
227
+ if (!panel) {
228
+ throw new Error(`dropdown button not found by attribute data-panel-id: «${button.getAttribute('data-panel-id')}»`)
229
+ }
230
+ return (panel)
175
231
  }
176
232
 
177
233
  // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -217,10 +273,5 @@ function generateRandomHex(n) {
217
273
  ).join('');
218
274
  }
219
275
 
220
- function findPanel(button) {
221
- const panel = document.getElementById(button.getAttribute('data-panel-id'))
222
- if (!panel) {
223
- throw new Error(`dropdown button not found by attribute data-panel-id: «${button.getAttribute('data-panel-id')}»`)
224
- }
225
- return(panel)
226
- }
276
+
277
+
@@ -1,17 +1,13 @@
1
- import {Controller} from "@hotwired/stimulus"
2
- import {initializeDropdown, onPanelOpen} from "../lib/utils.js";
3
- import {debugLog} from "../lib/utils.js";
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { openDropdownPanel, closePanel, findPanelOrThrow } from "../lib/utils.js";
3
+ import { debugLog } from "../lib/utils.js";
4
4
 
5
5
  export default class extends Controller {
6
6
 
7
7
  connect() {
8
8
 
9
- initializeDropdown(this.element)
10
-
11
9
  this.element.addEventListener('click', (e) => this.toggle(e))
12
10
 
13
- debugLog('dropdown connected')
14
-
15
11
  }
16
12
 
17
13
 
@@ -21,38 +17,15 @@ export default class extends Controller {
21
17
  }
22
18
  e.stopPropagation()
23
19
  debugLog('toggle panel', e)
24
- const target_id = this.element.getAttribute('data-panel-id')
25
- const panel = document.getElementById(target_id)
26
- if (!panel) {
27
- console.error(`Panel-element with ID ${target_id} not found`)
28
- } else if (panel.style.display === 'block') {
29
- this.close(panel)
20
+
21
+ const panel = findPanelOrThrow(this.element)
22
+ if (panel.style.display === 'block') {
23
+ closePanel(this.element)
30
24
  } else {
31
- this.open(e, panel)
25
+ openDropdownPanel(this.element, panel)
32
26
  }
33
27
  }
34
28
 
35
- open(e, panel) {
36
-
37
- // open the panel
38
-
39
- panel.style.display = 'block';
40
- debugLog('opened panel:', panel)
41
-
42
- onPanelOpen(e, this.element)
43
-
44
- panel.addEventListener('close', () => this.close(panel))
45
-
46
- }
47
-
48
- close(panel) {
49
-
50
- // Close actions
51
-
52
- panel.style.display = 'none';
53
- debugLog('panel closed:', panel)
54
-
55
- }
56
29
 
57
30
 
58
31
  }
@@ -1,6 +1,6 @@
1
1
  import {Controller} from "@hotwired/stimulus"
2
2
  import {positionPanelByButton} from "../lib/floating-ui-functions.js";
3
- import {debugLog, onPanelOpen} from "../lib/utils.js";
3
+ import {debugLog, findPanelOrThrow, openDropdownPanel} from "../lib/utils.js";
4
4
 
5
5
  export default class extends Controller {
6
6
 
@@ -42,7 +42,7 @@ export default class extends Controller {
42
42
 
43
43
  open(e, panel_id) {
44
44
  this.element.classList.add('tooltip-is-visible')
45
- onPanelOpen(e, this.element)
45
+ openDropdownPanel(this.element, findPanelOrThrow(this.element))
46
46
  this.opening_timer_id = null
47
47
  let panel = document.getElementById(panel_id)
48
48
  panel.style.display = 'block';