@csedl/hotwire-svelte-helpers 0.1.0 → 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.
@@ -0,0 +1,7 @@
1
+ <component name="ProjectDictionaryState">
2
+ <dictionary name="project">
3
+ <words>
4
+ <w>Hotwire</w>
5
+ </words>
6
+ </dictionary>
7
+ </component>
package/README.md CHANGED
@@ -1,44 +1,49 @@
1
- # Stimulus Dropdown
1
+ # Hotwire Svelte Helpers
2
+
3
+ is the successor of the `@csedl/stimulus-dropdown` package
2
4
 
3
5
  Dropdown with stimulus, based on floating-UI.
4
6
 
5
7
  **Links:**
6
- - [Online Demo App](https://stimulus-dropdown.sedlmair.ch/)
7
- - [Ruby Gem: csedl-stimulus-dropdown](https://gitlab.com/sedl/csedl-stimulus-dropdown)
8
+ - [Online Demo App](https://hotwire-svelte-helpers.sedlmair.ch/)
9
+ - [Ruby Gem: csedl-stimulus-dropdownSvelte](https://gitlab.com/sedl/csedl-stimulus-dropdown)
8
10
  - [How we are building a Rails App](https://dev.to/chmich/setup-vite-svelte-inertia-stimulus-bootstrap-foundation-on-rails-7-overview-1bk1)
9
11
 
10
12
  ## Import and config
11
13
 
12
14
  ```javascript
13
- import {StimulusDropdown} from "@csedl/stimulus-dropdown"
14
- StimulusDropdown.debug = false
15
+ import {HotwireSvelteHelpers} from "@csedl/hotwire-svelte-helpers"
16
+ HotwireSvelteHelpers.debug = true
17
+ HotwireSvelteHelpers.initializeOverlays()
15
18
  ```
16
19
 
17
20
  All configurations and their defaults are as follows:
18
21
 
19
22
  ```javascript
20
- debug: false
21
- closeButtonSelector: '.close-button'
22
- dropdownContentSelector: '.content'
23
- // when a data-src attribute is added to the panel, the content-tag is replaced by the result of the xhr-response
24
- tooltipContentSelector: '.content'
25
- // same as dropdownContentSelector
26
- addArrow: true
27
- // add element with id #arrow within the panel, on opening
28
- persistTooltipOnClick: false
29
- // clicking on the tooltip-label causes the tooltip-panel to persist open
30
- // this may mostly be helpful for development, so you may make it environment-dependent
23
+ HotwireSvelteHelpers.initializeOverlays({
24
+ debug: false,
25
+ closeButtonSelector: '.close-button',
26
+ dropdownContentSelector: '.content',
27
+ // when a data-src attribute is added to the panel, the content-tag is replaced by the result of the xhr-response
28
+ tooltipContentSelector: '.content',
29
+ // same as dropdownContentSelector
30
+ addArrow: true,
31
+ // add element with id #arrow within the panel, on opening
32
+ persistTooltipOnClick: false
33
+ // clicking on the tooltip-label causes the tooltip-panel to persist open
34
+ // this may mostly be helpful for development, so you may make it environment-dependent
35
+ })
31
36
  ```
32
37
 
33
38
  ## Example
34
39
 
35
- There is a [online example app](https://stimulus-dropdown.sedlmair.ch/)
40
+ There is a [online example app](https://hotwire-svelte-helpers.sedlmair.ch/)
36
41
 
37
42
  ```html
38
43
  <div data-controller="csedl-dropdown" data-panel-id="dropdown-panel-3h5k7l4">
39
44
  Button
40
45
  </div>
41
- <div id="dropdown-panel-3h5k7l4" class="hide dropdown-panel-example-class">
46
+ <div id="dropdown-panel-3h5k7l4" class="hide dropdownSvelte-panel-example-class">
42
47
  ... any content
43
48
  </div>
44
49
  ```
@@ -53,35 +58,35 @@ There is a [online example app](https://stimulus-dropdown.sedlmair.ch/)
53
58
 
54
59
  ## Close on click outside
55
60
 
56
- When a dropdown is open, it closes when clicking outside a panel.
61
+ When a dropdownSvelte is open, it closes when clicking outside a panel.
57
62
 
58
63
  This behaviour can be stopped on:
59
64
 
60
- - The clicked element or its parent elements has the `data-dropdown-persist` (not: `data-dropdown-persist="false"`) attribute.
65
+ - The clicked element or its parent elements has the `data-dropdownSvelte-persist` (not: `data-dropdownSvelte-persist="false"`) attribute.
61
66
  - the event has the attribute `event.detail.dataDropdownPersist` set to true.
62
67
 
63
68
  ## Flexibility
64
69
 
65
- The functions of this package are intended to give flexibility on various ways building a dropdown.
70
+ The functions of this package are intended to give flexibility on various ways building a dropdownSvelte.
66
71
 
67
72
  - Stimulus Controller with Rails Helper
68
73
  - Example: [Stimulus controller within this package](https://gitlab.com/sedl/csedl-stimulus-dropdown-js/-/blob/main/src/dropdown-controller.js?ref_type=heads)
69
74
  - Stimulus with Svelte component
70
75
  - Example: [Stimulus controller on example app](https://gitlab.com/sedl/stimulusfloatingdropdown/-/blob/main/app/frontend/javascript/svelte-dropdown-controller.js?ref_type=heads)
71
76
 
72
- **Important:** When creating or initializing the dropdown,
77
+ **Important:** When creating or initializing the dropdownSvelte,
73
78
  always call the initialize function before attaching a listener to the panel's close event.
74
79
  The initialize function adds a `close` event listener to the panel that executes the `onPanelClose` function.
75
80
  If your custom close function destroys the panel, this has to be done after the `onPanelClose` is fired by the `close` event.
76
81
 
77
82
  ## Requirements
78
83
 
79
- - The class `dropdown-panel-example-class` must be set to `position: absolute;`.
84
+ - The class `dropdownSvelte-panel-example-class` must be set to `position: absolute;`.
80
85
  - The class `hide` must be set to `display: none;`.
81
86
 
82
87
  ## Options
83
88
 
84
- - If there is an element with ID `arrow` inside the dropdown panel, it is treated as described on [floating-ui](https://floating-ui.com/docs/arrow).
89
+ - If there is an element with ID `arrow` inside the dropdownSvelte panel, it is treated as described on [floating-ui](https://floating-ui.com/docs/arrow).
85
90
  - The `data-placement` attribute on the panel can be used to control positioning, see [floating-ui/placements](https://floating-ui.com/docs/tutorial#placements).
86
91
 
87
92
  ## Events
@@ -109,12 +114,12 @@ Event Triggers on the panel element:
109
114
  If the panels are rendered to a different location than the button (see z-index on [rails-app](https://gitlab.com/sedl/stimulusfloatingdropdown)), within a scrollable (e.g.) container, the button would scroll away from the panel. For such cases, add this both data-attributes to the scrollable element:
110
115
 
111
116
  ```html
112
- <div data-controller="csedl-place-dropdown-panels" data-on="scroll" data-run-after="500" style="overflow: scroll;">
117
+ <div data-controller="csedl-place-dropdownSvelte-panels" data-on="scroll" data-run-after="500" style="overflow: scroll;">
113
118
  ...
114
119
  </div>
115
120
  ```
116
121
 
117
- Now, on scrolling, it searches for all dropdown-buttons (by class-name `has-open-panel`) and triggers the `place-panel` event there.
122
+ Now, on scrolling, it searches for all dropdownSvelte-buttons (by class-name `has-open-panel`) and triggers the `place-panel` event there.
118
123
 
119
124
  **Options**
120
125
 
@@ -151,15 +156,15 @@ makes a tooltip.
151
156
 
152
157
  It adds the class `tooltip-is-visible` to the tooltip label while the tooltip is visible.
153
158
 
154
- `data-src` attribute is working similar to dropdown
159
+ `data-src` attribute is working similar to dropdownSvelte
155
160
 
156
161
  ## Rails Helpers
157
162
 
158
163
  There is a corresponding rails gem, on [GitLab](https://gitlab.com/sedl/csedl-stimulus-dropdown)
159
164
 
160
- ## Stimulus Usage in stimulus-dropdown
165
+ ## Stimulus Usage in stimulus-dropdownSvelte
161
166
 
162
- This package uses Stimulus unconventionally to initialize and toggle external dropdown or tooltip panels
167
+ This package uses Stimulus unconventionally to initialize and toggle external dropdownSvelte or tooltip panels
163
168
  (via data-panel-id) rather than managing child elements within the controller’s scope,
164
169
  as is typical in Stimulus documentation.
165
170
 
package/index.js CHANGED
@@ -1,19 +1,12 @@
1
1
 
2
- import StimulusDropdown from "./src/config.js";
2
+ import HotwireSvelteHelpers from "./src/lib/config.js";
3
+ import { cleanMount, unmountAllDetached } from './src/svelte/cleanMount';
4
+ import {initializeDropdown, openDropdownPanel, getOrSetPanelId, debugLog} from './src/lib/utils.js'
3
5
 
4
- import { Application } from "@hotwired/stimulus"
5
- window.Stimulus = Application.start()
6
6
 
7
- // initialize dropdowns
8
- import dc from './src/dropdown-controller'
9
- Stimulus.register('csedl-dropdown', dc)
10
7
 
11
- // initialize tooltips
12
- import ttc from './src/tooltip-controller'
13
- Stimulus.register('csedl-tooltip', ttc)
14
-
15
- // initialize place-panel controller
16
- import ppc from './src/move-panels-controller'
17
- Stimulus.register('csedl-move-panels', ppc)
18
-
19
- export { StimulusDropdown }
8
+ export {
9
+ HotwireSvelteHelpers,
10
+ cleanMount, unmountAllDetached,
11
+ initializeDropdown, openDropdownPanel, getOrSetPanelId, debugLog
12
+ }
package/package.json CHANGED
@@ -1,26 +1,30 @@
1
1
  {
2
2
  "name": "@csedl/hotwire-svelte-helpers",
3
- "version": "0.1.0",
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
+ "exports": {
10
+ ".": "./index.js"
11
+ },
9
12
  "repository": {
10
13
  "type": "git",
11
14
  "url": "git+https://gitlab.com/sedl/csedl-hotwire-svelte-helpers"
12
15
  },
13
16
  "keywords": [
14
- "Stimulus",
17
+ "Hotwire",
15
18
  "Dropdown",
16
- "Rails"
19
+ "Rails",
20
+ "Svelte"
17
21
  ],
18
22
  "author": "Christian Sedlmair",
19
23
  "license": "MIT",
20
24
  "bugs": {
21
25
  "url": "https://gitlab.com/sedl/csedl-stimulus-dropdown-js/-/issues"
22
26
  },
23
- "homepage": "https://gitlab.com/sedl/csedl-stimulus-dropdown-js",
27
+ "homepage": "https://gitlab.com/sedl/csedl-hotwire-svelte-helpers",
24
28
  "dependencies": {
25
29
  "@floating-ui/dom": ">=1.5.1",
26
30
  "@hotwired/stimulus": ">=3.2.1"
@@ -0,0 +1,85 @@
1
+ import {debugLog} from "./utils.js";
2
+ import { Application } from "@hotwired/stimulus"
3
+ import dc from '../stimulus/dropdown-controller'
4
+ import ttc from '../stimulus/tooltip-controller'
5
+ import ppc from '../stimulus/move-panels-controller'
6
+ import { validateOptions } from "./type-validators";
7
+
8
+ // DEFAULTS
9
+ let _debug = false;
10
+
11
+ let _close_on_click_outside_listener_added = false;
12
+
13
+ const HotwireSvelteHelpers = {
14
+
15
+ // debug
16
+
17
+ get debug() {
18
+ return _debug;
19
+ },
20
+ set debug(value) {
21
+ if (typeof value !== "boolean") {
22
+ throw new Error("Debug value must be true or false");
23
+ }
24
+ _debug = value;
25
+ debugLog('debugging active')
26
+ },
27
+
28
+ overlays: {
29
+ closeButtonSelector: '.close-button',
30
+ dropdownContentSelector: '.content',
31
+ tooltipContentSelector: '.content',
32
+ addArrow: true,
33
+ persistTooltipOnClick: false,
34
+ closeOnClickOutsideListenerAdded: false,
35
+ },
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
+
48
+ const overlays = this.overlays;
49
+
50
+ if (options.closeButtonSelector !== undefined) {
51
+ overlays.closeButtonSelector = options.closeButtonSelector;
52
+ }
53
+
54
+ if (options.dropdownContentSelector !== undefined) {
55
+ overlays.dropdownContentSelector = options.dropdownContentSelector;
56
+ }
57
+
58
+ if (options.tooltipContentSelector !== undefined) {
59
+ overlays.tooltipContentSelector = options.tooltipContentSelector;
60
+ }
61
+
62
+ if (options.addArrow !== undefined) {
63
+ overlays.addArrow = options.addArrow;
64
+ }
65
+
66
+ if (options.persistTooltipOnClick !== undefined) {
67
+ overlays.persistTooltipOnClick = options.persistTooltipOnClick;
68
+ }
69
+
70
+ window.Stimulus = Application.start()
71
+
72
+ Stimulus.register('csedl-dropdown', dc)
73
+ Stimulus.register('csedl-tooltip', ttc)
74
+ Stimulus.register('csedl-move-panels', ppc)
75
+
76
+ debugLog('Overlays active, driven by Stimulus')
77
+
78
+ return overlays;
79
+ }
80
+
81
+ };
82
+
83
+ window.HotwireSvelteHelpers = HotwireSvelteHelpers;
84
+
85
+ export default HotwireSvelteHelpers;
@@ -28,7 +28,7 @@ export function getAllOpenPanels(scopeElement) {
28
28
  }
29
29
  }
30
30
 
31
- debugLog(`FOUND ${elements.length} OPEN DROPDOWNS`)
31
+ debugLog(`FOUND ${elements.length} OPEN DROPDOWNS for ${buttons.length} buttons`)
32
32
 
33
33
  return (elements)
34
34
  }
@@ -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
+ }
@@ -0,0 +1,277 @@
1
+ import {positionPanelSelf, positionPanelByButton, positionPanel} from "./floating-ui-functions.js";
2
+ import {validateOptions} from "./type-validators";
3
+
4
+ // Fetch content from server based on panel's data-src attribute and update content
5
+ export function openDropdownPanel(buttonElement, panelElement, options = {}) {
6
+
7
+ if (buttonElement.classList.contains('has-open-panel')) {return}
8
+
9
+ //validateOptions(options, {clickedElement: 'object'})
10
+ //const clickedElement = options.clickedElement || buttonElement;
11
+
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';
18
+
19
+ // add arrow
20
+ if (window.HotwireSvelteHelpers.overlays.addArrow) {
21
+ if (!panelElement.querySelector(':scope > #arrow')) {
22
+ const arrowTag = document.createElement('div');
23
+ arrowTag.id = 'arrow';
24
+ panelElement.appendChild(arrowTag)
25
+ }
26
+ }
27
+
28
+ positionPanel(buttonElement, panelElement);
29
+
30
+ // Set focus to input element
31
+ if (panelElement.hasAttribute('data-set-focus')) {
32
+ let dataFocus = panelElement.getAttribute('data-set-focus');
33
+ let focusElement = panelElement.querySelector(dataFocus);
34
+ focusElement.focus();
35
+ }
36
+
37
+ // Set status attribute
38
+ panelElement.setAttribute('data-hsh-panel-status', 'open');
39
+ buttonElement.classList.add('has-open-panel');
40
+
41
+ // Dispatch events
42
+ const panelOpenEvent = new CustomEvent('before-open');
43
+ panelElement.dispatchEvent(panelOpenEvent);
44
+
45
+ const buttonOpenEvent = new CustomEvent('before-open-panel');
46
+ buttonElement.dispatchEvent(buttonOpenEvent);
47
+
48
+ // fetch content from server
49
+ const src = panelElement.getAttribute('data-src');
50
+ if (src) {
51
+ let xhr = new XMLHttpRequest();
52
+ debugLog(`Panel / data-src: «${src}»`);
53
+ xhr.open('GET', src);
54
+ xhr.send();
55
+ xhr.onload = function () {
56
+ if (xhr.status !== 200) {
57
+ alert(`Dropdown Controller, GET «${src}», Error ${xhr.status}: ${xhr.statusText}`);
58
+ } else {
59
+ const buttonElement = document.querySelector(`[data-panel-id="${panelElement.id}"]`);
60
+ const ctrl = buttonElement.getAttribute('data-controller');
61
+ const config = window.HotwireSvelteHelpers.overlays;
62
+ const contentSelector = config.dropdownContentSelector;
63
+
64
+ if (ctrl === 'csedl-dropdown' && contentSelector) {
65
+ debugLog(`dropdown / contentSelector: «${contentSelector}»`);
66
+ const wrapper = panelElement.querySelector(contentSelector);
67
+ wrapper.innerHTML = xhr.response;
68
+ } else if (ctrl === 'csedl-tooltip' && contentSelector) {
69
+ debugLog(`tooltip / contentSelector: «${contentSelector}»`);
70
+ const wrapper = panelElement.querySelector(config.tooltipContentSelector);
71
+ wrapper.innerHTML = xhr.response;
72
+ } else {
73
+ debugLog(`? / contentSelector: «${contentSelector}»`);
74
+ panelElement.innerHTML = xhr.response;
75
+ console.error('fallback to replace whole panel');
76
+ }
77
+
78
+ positionPanelByButton(buttonElement, `after http-request "${ctrl}"`);
79
+ }
80
+ };
81
+ } else {
82
+ debugLog('no data-src attribute provided on panel');
83
+ }
84
+
85
+ }
86
+
87
+ // Handle panel closing
88
+ export function closePanel(button) {
89
+
90
+ const panel = findPanelOrThrow(button);
91
+
92
+ debugLog(`closing panel ${panel.id.split('-').pop()}`)
93
+
94
+ // Update status attributes
95
+ button.classList.remove('has-open-panel');
96
+ panel.setAttribute('data-hsh-panel-status', 'closed');
97
+ panel.style.display = 'none';
98
+
99
+ // Remove outside click listener if no panels are open
100
+ const openPanels = document.querySelectorAll("[data-hsh-panel-status='open']");
101
+ debugLog(`panel closed, still open: ${openPanels.length}`);
102
+ if (window.HotwireSvelteHelpers.closeOnClickOutsideListenerAdded && openPanels.length === 0) {
103
+ window.HotwireSvelteHelpers.closeOnClickOutsideListenerAdded = false;
104
+ window.removeEventListener('click', closeOnOutsideClick);
105
+ debugLog('Listener for close panels on click outside removed');
106
+ }
107
+
108
+ // Dispatch close event
109
+ let buttonCloseEvent = new CustomEvent('after-close-panel');
110
+ button.dispatchEvent(buttonCloseEvent);
111
+ }
112
+
113
+ // Close panels when clicking outside
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;
131
+
132
+ let parentPanelIds = [];
133
+
134
+ const persistElement = btn.closest('[data-overlay-persist]');
135
+ if (persistElement) {
136
+ if (persistElement.getAttribute('data-overlay-persist') !== 'false') {
137
+ debugLog('closing panel prevented because the target element has attribute "data-overlay-persist"');
138
+ return false;
139
+ }
140
+ }
141
+
142
+ debugLog('closeOnOutsideClick called, clicked on:', btn);
143
+
144
+ while (true) {
145
+ const parentPanel = btn.closest("[data-hsh-panel-status='open']");
146
+ if (parentPanel) {
147
+ parentPanelIds.push(parentPanel.id);
148
+ btn = document.querySelector(`[data-panel-id="${parentPanel.id}"]`);
149
+ } else {
150
+ break;
151
+ }
152
+ }
153
+
154
+ const openPanels = document.querySelectorAll("[data-hsh-panel-status='open']");
155
+ for (const panel of openPanels) {
156
+ if (!parentPanelIds.includes(panel.id)) {
157
+ debugLog(`closeOnOutsideClick: dispatching close event for panel ${panel.id.split('-').pop()}`);
158
+ const ev = new Event('close');
159
+ panel.dispatchEvent(ev);
160
+ } else {
161
+ debugLog('closeOnOutsideClick: panel is still open:', panel);
162
+ }
163
+ }
164
+ }
165
+
166
+ // Add panel functionality, including close event listeners
167
+ export function initializeDropdown(button) {
168
+
169
+ // find panel
170
+ const panel_id = button.getAttribute('data-panel-id');
171
+ if (!panel_id) {
172
+ throw new Error(`data-panel-id attribute is not defined for given dropdown button`);
173
+ }
174
+ const panel = document.getElementById(panel_id);
175
+ if (!panel) {
176
+ throw new Error(`panel element not found by ID: «${panel_id}»`);
177
+ }
178
+
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())
200
+ }
201
+
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
+ }
208
+
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)
231
+ }
232
+
233
+ // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
234
+ // LOGGER
235
+ // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
236
+
237
+ export function debugLog(message, object) {
238
+ if (window.HotwireSvelteHelpers.debug) {
239
+
240
+ if (object) {
241
+ console.log(`[HSH] ${message}`, object);
242
+
243
+ } else {
244
+ console.log(`[HSH] ${message}`);
245
+ }
246
+
247
+ }
248
+ }
249
+
250
+ // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
251
+ // ADDITIONAL FUNCTIONS
252
+ // utils that are not used for the primary functions of this package itself, but helpful for building associated functions
253
+ // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
254
+
255
+
256
+ // find or add the data-panel-id attribute of a button and return it
257
+ export function getOrSetPanelId(buttonTag) {
258
+ let id = buttonTag.getAttribute('data-panel-id')
259
+ if (id) {
260
+ return (id)
261
+ } else {
262
+ id = `dropdown-panel-${generateRandomHex(8)}`
263
+ buttonTag.setAttribute('data-panel-id', id)
264
+ return id
265
+ }
266
+ }
267
+
268
+
269
+ function generateRandomHex(n) {
270
+ if (n <= 0) return '';
271
+ return Array.from({length: n}, () =>
272
+ Math.floor(Math.random() * 16).toString(16)
273
+ ).join('');
274
+ }
275
+
276
+
277
+
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { openDropdownPanel, closePanel, findPanelOrThrow } from "../lib/utils.js";
3
+ import { debugLog } from "../lib/utils.js";
4
+
5
+ export default class extends Controller {
6
+
7
+ connect() {
8
+
9
+ this.element.addEventListener('click', (e) => this.toggle(e))
10
+
11
+ }
12
+
13
+
14
+ toggle(e) {
15
+ if (this.element.getAttribute('data-prevent-default')) {
16
+ e.preventDefault()
17
+ }
18
+ e.stopPropagation()
19
+ debugLog('toggle panel', e)
20
+
21
+ const panel = findPanelOrThrow(this.element)
22
+ if (panel.style.display === 'block') {
23
+ closePanel(this.element)
24
+ } else {
25
+ openDropdownPanel(this.element, panel)
26
+ }
27
+ }
28
+
29
+
30
+
31
+ }
32
+
33
+
34
+
@@ -1,6 +1,6 @@
1
1
  import {Controller} from "@hotwired/stimulus"
2
- import {positionAllPanels, getAllOpenPanels} from "./floating-ui-functions";
3
- import {debugLog} from "./utils.js";
2
+ import {positionAllPanels, getAllOpenPanels} from "../lib/floating-ui-functions";
3
+ import {debugLog} from "../lib/utils.js";
4
4
 
5
5
  export default class extends Controller {
6
6
 
@@ -1,6 +1,6 @@
1
1
  import {Controller} from "@hotwired/stimulus"
2
- import {positionPanelByButton} from "./floating-ui-functions.js";
3
- import {debugLog, onPanelOpen} from "./utils.js";
2
+ import {positionPanelByButton} from "../lib/floating-ui-functions.js";
3
+ import {debugLog, findPanelOrThrow, openDropdownPanel} from "../lib/utils.js";
4
4
 
5
5
  export default class extends Controller {
6
6
 
@@ -17,7 +17,7 @@ export default class extends Controller {
17
17
  }
18
18
  this.element.addEventListener('mouseenter', (e) => this.start_opening(e, panel_id, delay_sec * 1000))
19
19
  this.element.addEventListener('mouseleave', (e) => this.close(e, panel_id))
20
- if (window.StimulusDropdown.persistTooltipOnClick) {
20
+ if (window.HotwireSvelteHelpers.persistTooltipOnClick) {
21
21
  this.element.addEventListener('click', (e) => this.toggleByClick(e, panel_id))
22
22
  }
23
23
  }
@@ -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';
@@ -0,0 +1,80 @@
1
+ import { mount, unmount } from 'svelte';
2
+ import { debugLog } from '../lib/utils.js';
3
+
4
+ const mountedByTarget = new WeakMap();
5
+ const trackedTargets = new Set();
6
+
7
+ function getTargetRecordList(target) {
8
+ let records = mountedByTarget.get(target);
9
+
10
+ if (!records) {
11
+ records = [];
12
+ mountedByTarget.set(target, records);
13
+ trackedTargets.add(target);
14
+ }
15
+
16
+ return records;
17
+ }
18
+
19
+ export function cleanMount(component, options = {}) {
20
+
21
+ if (!options.target) {
22
+ throw new Error('[HSH] cleanMount: target is required');
23
+ }
24
+
25
+ if (!component) {
26
+ throw new Error('[HSH] cleanMount: component is required');
27
+ }
28
+
29
+ const instance = mount(component, options);
30
+
31
+ const records = getTargetRecordList(options.target);
32
+ records.push({
33
+ target: options.target,
34
+ component: component,
35
+ instance: instance
36
+ });
37
+
38
+ debugLog(`cleanMount: mounted, stored: ${records.length} components`, instance)
39
+
40
+ return instance;
41
+ }
42
+
43
+ export function unmountAllDetached() {
44
+ let unmountedCount = 0;
45
+
46
+ for (const target of Array.from(trackedTargets)) {
47
+ const records = mountedByTarget.get(target);
48
+
49
+ if (!records || records.length === 0) {
50
+ trackedTargets.delete(target);
51
+ mountedByTarget.delete(target);
52
+ continue;
53
+ }
54
+
55
+ if (target.isConnected) {
56
+ continue;
57
+ }
58
+
59
+ for (const record of records) {
60
+ try {
61
+ unmount(record.instance);
62
+ unmountedCount++;
63
+ } catch (error) {
64
+ console.error('[HSH] unmountAllDetached: failed to unmount component', error);
65
+ }
66
+ }
67
+
68
+ records.length = 0;
69
+ trackedTargets.delete(target);
70
+ mountedByTarget.delete(target);
71
+ }
72
+
73
+ if (unmountedCount === 0) {
74
+ debugLog('unmountAllDetached: nothing to unmount')
75
+ } else {
76
+ debugLog(`unmountAllDetached: unmounted ${unmountedCount} components`)
77
+ }
78
+
79
+ return unmountedCount;
80
+ }
package/src/config.js DELETED
@@ -1,114 +0,0 @@
1
- import {debugLog} from "./utils.js";
2
-
3
- // DEFAULTS
4
- let _debug = false;
5
- let _close_button_selector = '.close-button';
6
- let _dropdown_content_selector = '.content';
7
- let _tooltip_content_selector = '.content';
8
- let _add_arrow = true;
9
- let _persist_tooltip_on_click = false;
10
-
11
- let _close_on_click_outside_listener_added = false;
12
-
13
- const StimulusDropdown = {
14
-
15
- // debug
16
-
17
- get debug() {
18
- return _debug;
19
- },
20
- set debug(value) {
21
- if (typeof value !== "boolean") {
22
- throw new Error("Debug value must be true or false");
23
- }
24
- _debug = value;
25
- debugLog('debugging active')
26
- },
27
-
28
- // close button selector
29
-
30
- get closeButtonSelector() {
31
- return _close_button_selector;
32
- },
33
- set closeButtonSelector(string) {
34
- if (typeof string !== "string") {
35
- throw new Error("Close Button Selector must be a string");
36
- }
37
- _close_button_selector = string;
38
- debugLog(`close button selector is: «${string}»`);
39
- },
40
-
41
- // content selector
42
-
43
- get dropdownContentSelector() {
44
- return _dropdown_content_selector;
45
- },
46
- set dropdownContentSelector(string) {
47
- if (typeof string !== "string") {
48
- throw new Error("Content Selector must be a string");
49
- }
50
- _dropdown_content_selector = string;
51
- debugLog(`dropdown content selector is: «${string}»`);
52
- },
53
-
54
- // tooltip content selector
55
-
56
- get tooltipContentSelector() {
57
- return _tooltip_content_selector;
58
- },
59
- set tooltipContentSelector(string) {
60
- if (typeof string !== "string") {
61
- throw new Error("Tooltip Content Selector must be a string");
62
- }
63
- _tooltip_content_selector = string;
64
- debugLog(`tooltip content selector is: «${string}»`);
65
- },
66
-
67
- // add arrow
68
-
69
- get addArrow() {
70
- return _add_arrow;
71
- },
72
- set addArrow(boolean) {
73
- if (typeof boolean !== "boolean") {
74
- throw new Error("Tooltip Content Selector must be a string");
75
- }
76
- _add_arrow = boolean;
77
- debugLog(`add-arrow is set to: «${boolean}»`);
78
- },
79
-
80
- // add arrow
81
-
82
- get persistTooltipOnClick() {
83
- return _persist_tooltip_on_click;
84
- },
85
- set persistTooltipOnClick(boolean) {
86
- if (typeof boolean !== "boolean") {
87
- throw new Error("Tooltip Content Selector must be a string");
88
- }
89
- _persist_tooltip_on_click = boolean;
90
- debugLog(`Persist tooltip on click is set to: «${boolean}»`);
91
- },
92
-
93
- // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
94
- // INTERNAL USE
95
- // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
96
-
97
- // closeOnClickOutsideListenerAdded
98
-
99
- get closeOnClickOutsideListenerAdded() {
100
- return _close_on_click_outside_listener_added;
101
- },
102
- set closeOnClickOutsideListenerAdded(bool) {
103
- if (typeof bool !== "boolean") {
104
- throw new Error("closeOnClickOutsideListenerAdded must be a boolean");
105
- }
106
- _close_on_click_outside_listener_added = bool;
107
- debugLog(`closeOnClickOutsideListenerAdded is: «${bool}»`);
108
- },
109
-
110
- };
111
-
112
- window.StimulusDropdown = StimulusDropdown;
113
-
114
- export default StimulusDropdown;
@@ -1,59 +0,0 @@
1
- import {Controller} from "@hotwired/stimulus"
2
- import {initializeDropdown, onPanelOpen} from "./utils.js";
3
- import {debugLog} from "./utils.js";
4
-
5
- export default class extends Controller {
6
-
7
- connect() {
8
-
9
- initializeDropdown(this.element)
10
-
11
- this.element.addEventListener('click', (e) => this.toggle(e))
12
-
13
- }
14
-
15
-
16
- toggle(e) {
17
- if (this.element.getAttribute('data-prevent-default')) {
18
- e.preventDefault()
19
- }
20
- e.stopPropagation()
21
- debugLog('toggle panel', e)
22
- const target_id = this.element.getAttribute('data-panel-id')
23
- const panel = document.getElementById(target_id)
24
- if (!panel) {
25
- console.error(`Panel-element with ID ${target_id} not found`)
26
- } else if (panel.style.display === 'block') {
27
- this.close(panel)
28
- } else {
29
- this.open(e, panel)
30
- }
31
- }
32
-
33
- open(e, panel) {
34
-
35
- // open the panel
36
-
37
- panel.style.display = 'block';
38
- debugLog('opened panel:', panel)
39
-
40
- onPanelOpen(e, this.element)
41
-
42
- panel.addEventListener('close', () => this.close(panel))
43
-
44
- }
45
-
46
- close(panel) {
47
-
48
- // Close actions
49
-
50
- panel.style.display = 'none';
51
- debugLog('panel closed:', panel)
52
-
53
- }
54
-
55
-
56
- }
57
-
58
-
59
-
package/src/utils.js DELETED
@@ -1,226 +0,0 @@
1
- import {positionPanelSelf, positionPanelByButton} from "./floating-ui-functions.js";
2
-
3
- // Fetch content from server based on panel's data-src attribute and update content
4
- export function onPanelOpen(event, button) {
5
-
6
- const panel = findPanel(button);
7
-
8
- debugLog('ON-PANEL-OPEN FUNCTIONS')
9
-
10
- closeOnOutsideClick(event)
11
-
12
- // add arrow
13
- if (window.StimulusDropdown.addArrow) {
14
- if (!panel.querySelector(':scope > #arrow')) {
15
- const arrowTag = document.createElement('div');
16
- arrowTag.id = 'arrow';
17
- panel.appendChild(arrowTag)
18
- }
19
- }
20
-
21
- positionPanelByButton(button);
22
-
23
- // 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);
27
- focusElement.focus();
28
- }
29
-
30
- // Set status attribute
31
- panel.setAttribute('data-stimulus-dropdown-panel-status', 'open');
32
- button.classList.add('has-open-panel');
33
-
34
- // Dispatch events
35
- const panelOpenEvent = new CustomEvent('before-open');
36
- panel.dispatchEvent(panelOpenEvent);
37
-
38
- const buttonOpenEvent = new CustomEvent('before-open-panel');
39
- button.dispatchEvent(buttonOpenEvent);
40
-
41
- // Add listener for closing on outside click
42
- if (!window.StimulusDropdown.closeOnClickOutsideListenerAdded) {
43
- window.StimulusDropdown.closeOnClickOutsideListenerAdded = true;
44
- window.addEventListener('click', closeOnOutsideClick);
45
- debugLog('Listener for close panels on click outside added');
46
- }
47
-
48
- // fetch content from server
49
- const src = panel.getAttribute('data-src');
50
- if (src) {
51
- let xhr = new XMLHttpRequest();
52
- debugLog(`Panel / data-src: «${src}»`);
53
- xhr.open('GET', src);
54
- xhr.send();
55
- xhr.onload = function () {
56
- if (xhr.status !== 200) {
57
- alert(`Dropdown Controller, GET «${src}», Error ${xhr.status}: ${xhr.statusText}`);
58
- } else {
59
- const button = document.querySelector(`[data-panel-id="${panel.id}"]`);
60
- const ctrl = button.getAttribute('data-controller');
61
- const config = window.StimulusDropdown;
62
- const contentSelector = config.dropdownContentSelector;
63
-
64
- if (ctrl === 'csedl-dropdown' && contentSelector) {
65
- debugLog(`dropdown / contentSelector: «${contentSelector}»`);
66
- const wrapper = panel.querySelector(contentSelector);
67
- wrapper.innerHTML = xhr.response;
68
- } else if (ctrl === 'csedl-tooltip' && contentSelector) {
69
- debugLog(`tooltip / contentSelector: «${contentSelector}»`);
70
- const wrapper = panel.querySelector(config.tooltipContentSelector);
71
- wrapper.innerHTML = xhr.response;
72
- } else {
73
- debugLog(`? / contentSelector: «${contentSelector}»`);
74
- panel.innerHTML = xhr.response;
75
- console.error('fallback to replace whole panel');
76
- }
77
-
78
- positionPanelByButton(button, `after http-request "${ctrl}"`);
79
- }
80
- };
81
- } else {
82
- debugLog('no data-src attribute provided on panel');
83
- }
84
- }
85
-
86
- // Handle panel closing
87
- export function onPanelClose(button) {
88
-
89
- const panel = findPanel(button);
90
-
91
- debugLog('ON-PANEL-CLOSE FUNCTIONS')
92
-
93
- // Update status attributes
94
- button.classList.remove('has-open-panel');
95
- panel.setAttribute('data-stimulus-dropdown-panel-status', 'closed');
96
-
97
- // Remove outside click listener if no panels are open
98
- const openPanels = document.querySelectorAll("[data-stimulus-dropdown-panel-status='open']");
99
- debugLog(`panel closed, still open: ${openPanels.length}`);
100
- if (window.StimulusDropdown.closeOnClickOutsideListenerAdded && openPanels.length === 0) {
101
- window.StimulusDropdown.closeOnClickOutsideListenerAdded = false;
102
- window.removeEventListener('click', closeOnOutsideClick);
103
- debugLog('Listener for close panels on click outside removed');
104
- }
105
-
106
- // Dispatch close event
107
- let buttonCloseEvent = new CustomEvent('after-close-panel');
108
- button.dispatchEvent(buttonCloseEvent);
109
- }
110
-
111
- // Close panels when clicking outside
112
- export function closeOnOutsideClick(ev) {
113
-
114
- let btn = ev.target;
115
- let parentPanelIds = [];
116
-
117
- const persistElement = ev.target.closest('[data-dropdown-persist]');
118
- if (persistElement) {
119
- if (persistElement.getAttribute('data-dropdown-persist') !== 'false') {
120
- debugLog('closing panel prevented because the target element has attribute "data-dropdown-persist"');
121
- return false;
122
- }
123
- }
124
-
125
- debugLog('closeOnOutsideClick called, event.target:', ev.target);
126
-
127
- while (true) {
128
- const parentPanel = btn.closest("[data-stimulus-dropdown-panel-status='open']");
129
- if (parentPanel) {
130
- parentPanelIds.push(parentPanel.id);
131
- btn = document.querySelector(`[data-panel-id="${parentPanel.id}"]`);
132
- } else {
133
- break;
134
- }
135
- }
136
-
137
- const openPanels = document.querySelectorAll("[data-stimulus-dropdown-panel-status='open']");
138
- for (const panel of openPanels) {
139
- if (!parentPanelIds.includes(panel.id)) {
140
- const ev = new Event('close');
141
- panel.dispatchEvent(ev);
142
- }
143
- }
144
- }
145
-
146
- // Add panel functionality, including close event listeners
147
- export function initializeDropdown(button) {
148
-
149
- // find panel
150
- const panel_id = button.getAttribute('data-panel-id');
151
- if (!panel_id) {
152
- throw new Error(`data-panel-id attribute is not defined for given dropdown button`);
153
- }
154
- const panel = document.getElementById(panel_id);
155
- if (!panel) {
156
- throw new Error(`panel element not found by ID: «${panel_id}»`);
157
- }
158
-
159
- // Add close button functionality
160
- const selector = window.StimulusDropdown.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
- });
167
- }
168
-
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))
173
-
174
- debugLog(`Initialized dropdown, panel-ID: «${panel_id}»`);
175
- }
176
-
177
- // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
178
- // LOGGER
179
- // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
180
-
181
- export function debugLog(message, object) {
182
- if (window.StimulusDropdown.debug) {
183
-
184
- if (object) {
185
- console.log(`[stimulus-dropdown] ${message}`, object);
186
-
187
- } else {
188
- console.log(`[stimulus-dropdown] ${message}`);
189
- }
190
-
191
- }
192
- }
193
-
194
- // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
195
- // ADDITIONAL FUNCTIONS
196
- // utils that are not used for the primary functions of this package itself, but helpful for building associated functions
197
- // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
198
-
199
-
200
- // find or add the data-panel-id attribute of a button and return it
201
- export function getOrSetPanelId(buttonTag) {
202
- let id = buttonTag.getAttribute('data-panel-id')
203
- if (id) {
204
- return (id)
205
- } else {
206
- id = `dropdown-panel-${generateRandomHex(8)}`
207
- buttonTag.setAttribute('data-panel-id', id)
208
- return id
209
- }
210
- }
211
-
212
-
213
- function generateRandomHex(n) {
214
- if (n <= 0) return '';
215
- return Array.from({length: n}, () =>
216
- Math.floor(Math.random() * 16).toString(16)
217
- ).join('');
218
- }
219
-
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
- }