@csedl/hotwire-svelte-helpers 0.1.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,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="WEB_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.tmp" />
6
+ <excludeFolder url="file://$MODULE_DIR$/temp" />
7
+ <excludeFolder url="file://$MODULE_DIR$/tmp" />
8
+ </content>
9
+ <orderEntry type="inheritedJdk" />
10
+ <orderEntry type="sourceFolder" forTests="false" />
11
+ </component>
12
+ </module>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/StimulusDropdown-NpmPackage.iml" filepath="$PROJECT_DIR$/.idea/StimulusDropdown-NpmPackage.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Stimulus Dropdown
2
+
3
+ Dropdown with stimulus, based on floating-UI.
4
+
5
+ **Links:**
6
+ - [Online Demo App](https://stimulus-dropdown.sedlmair.ch/)
7
+ - [Ruby Gem: csedl-stimulus-dropdown](https://gitlab.com/sedl/csedl-stimulus-dropdown)
8
+ - [How we are building a Rails App](https://dev.to/chmich/setup-vite-svelte-inertia-stimulus-bootstrap-foundation-on-rails-7-overview-1bk1)
9
+
10
+ ## Import and config
11
+
12
+ ```javascript
13
+ import {StimulusDropdown} from "@csedl/stimulus-dropdown"
14
+ StimulusDropdown.debug = false
15
+ ```
16
+
17
+ All configurations and their defaults are as follows:
18
+
19
+ ```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
31
+ ```
32
+
33
+ ## Example
34
+
35
+ There is a [online example app](https://stimulus-dropdown.sedlmair.ch/)
36
+
37
+ ```html
38
+ <div data-controller="csedl-dropdown" data-panel-id="dropdown-panel-3h5k7l4">
39
+ Button
40
+ </div>
41
+ <div id="dropdown-panel-3h5k7l4" class="hide dropdown-panel-example-class">
42
+ ... any content
43
+ </div>
44
+ ```
45
+
46
+ ## What it does
47
+
48
+ - When the button is clicked, it toggles the `hide` class on the panel and places the panel using floating-ui.
49
+ - When the panel is open, the `has-open-panel` class is added to the button, otherwise it is removed.
50
+ - Adds functionality to close all panels when clicking outside a panel.
51
+ - When a `data-src` attribute is given to the panel, on opening the panel, it fires a xhr request and replaces the configured content-tags (see: content-selectors on configs) by the response.
52
+ - This all works with stacked panels too (panel in panel).
53
+
54
+ ## Close on click outside
55
+
56
+ When a dropdown is open, it closes when clicking outside a panel.
57
+
58
+ This behaviour can be stopped on:
59
+
60
+ - The clicked element or its parent elements has the `data-dropdown-persist` (not: `data-dropdown-persist="false"`) attribute.
61
+ - the event has the attribute `event.detail.dataDropdownPersist` set to true.
62
+
63
+ ## Flexibility
64
+
65
+ The functions of this package are intended to give flexibility on various ways building a dropdown.
66
+
67
+ - Stimulus Controller with Rails Helper
68
+ - Example: [Stimulus controller within this package](https://gitlab.com/sedl/csedl-stimulus-dropdown-js/-/blob/main/src/dropdown-controller.js?ref_type=heads)
69
+ - Stimulus with Svelte component
70
+ - Example: [Stimulus controller on example app](https://gitlab.com/sedl/stimulusfloatingdropdown/-/blob/main/app/frontend/javascript/svelte-dropdown-controller.js?ref_type=heads)
71
+
72
+ **Important:** When creating or initializing the dropdown,
73
+ always call the initialize function before attaching a listener to the panel's close event.
74
+ The initialize function adds a `close` event listener to the panel that executes the `onPanelClose` function.
75
+ If your custom close function destroys the panel, this has to be done after the `onPanelClose` is fired by the `close` event.
76
+
77
+ ## Requirements
78
+
79
+ - The class `dropdown-panel-example-class` must be set to `position: absolute;`.
80
+ - The class `hide` must be set to `display: none;`.
81
+
82
+ ## Options
83
+
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).
85
+ - 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
+
87
+ ## Events
88
+
89
+ Events on the button element:
90
+
91
+ - `place-panel` places the panel, and, if present, the arrow element, by `floating-ui`.
92
+
93
+ Events the panel element:
94
+
95
+ - `close` closes the panel.
96
+ - `place-me` like place-panel on the button.
97
+
98
+ Event Triggers on the button element:
99
+
100
+ - `before-open-panel`
101
+ - `after-close-panel`
102
+
103
+ Event Triggers on the panel element:
104
+
105
+ - `before-open`
106
+
107
+ ## Helpers
108
+
109
+ 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
+
111
+ ```html
112
+ <div data-controller="csedl-place-dropdown-panels" data-on="scroll" data-run-after="500" style="overflow: scroll;">
113
+ ...
114
+ </div>
115
+ ```
116
+
117
+ Now, on scrolling, it searches for all dropdown-buttons (by class-name `has-open-panel`) and triggers the `place-panel` event there.
118
+
119
+ **Options**
120
+
121
+ `data-on` Attribute:
122
+
123
+ - `scroll` triggered by `scroll` Event of the given element.
124
+ - `resize-observer` triggered by [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) on the given element.
125
+
126
+ `data-run-after` Attribute:
127
+
128
+ - Milliseconds as number.
129
+
130
+ This is only relevant if you have things like `css transition` enabled, so that after the above resize events are fired, subsequent events are needed. It will fire the `place-panel` after the last resize/scroll event within the given time.
131
+
132
+ **Tip** Turn `console-debug-log` on (see configs) and check how events are working.
133
+
134
+ **Explanation**
135
+
136
+ What these helpers mainly do is to find all the dropdowns by the `has-open-panel` class and fire the `place-panel` event. But within the helper, things like performance optimisation are done: it searches once and places the panels multiple times.
137
+
138
+ ## Tooltip
139
+
140
+ ```html
141
+ <span data-controller="csedl-tooltip" data-panel-id="tooltip-123" data-delay="0.2">
142
+ Text-with-tooltip
143
+ </span>
144
+ <div id="tooltip-123" class="hide tooltip-panel">
145
+ <div id="arrow"></div>
146
+ ... any content
147
+ </div>
148
+ ```
149
+
150
+ makes a tooltip.
151
+
152
+ It adds the class `tooltip-is-visible` to the tooltip label while the tooltip is visible.
153
+
154
+ `data-src` attribute is working similar to dropdown
155
+
156
+ ## Rails Helpers
157
+
158
+ There is a corresponding rails gem, on [GitLab](https://gitlab.com/sedl/csedl-stimulus-dropdown)
159
+
160
+ ## Stimulus Usage in stimulus-dropdown
161
+
162
+ This package uses Stimulus unconventionally to initialize and toggle external dropdown or tooltip panels
163
+ (via data-panel-id) rather than managing child elements within the controller’s scope,
164
+ as is typical in Stimulus documentation.
165
+
166
+ The same result could be achieved using MutationObserver and plain JavaScript.
167
+
168
+ Stimulus was chosen because this package is intended for use with Hotwire/Turbo
169
+ where Stimulus already is installed. It has a modest footprint of just 10 KB.
170
+ It also holds many configs for MutationObserver, especially for our exact purpose.
171
+
172
+ **License**
173
+
174
+ MIT
package/index.js ADDED
@@ -0,0 +1,19 @@
1
+
2
+ import StimulusDropdown from "./src/config.js";
3
+
4
+ import { Application } from "@hotwired/stimulus"
5
+ window.Stimulus = Application.start()
6
+
7
+ // initialize dropdowns
8
+ import dc from './src/dropdown-controller'
9
+ Stimulus.register('csedl-dropdown', dc)
10
+
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 }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
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.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://gitlab.com/sedl/csedl-hotwire-svelte-helpers"
12
+ },
13
+ "keywords": [
14
+ "Stimulus",
15
+ "Dropdown",
16
+ "Rails"
17
+ ],
18
+ "author": "Christian Sedlmair",
19
+ "license": "MIT",
20
+ "bugs": {
21
+ "url": "https://gitlab.com/sedl/csedl-stimulus-dropdown-js/-/issues"
22
+ },
23
+ "homepage": "https://gitlab.com/sedl/csedl-stimulus-dropdown-js",
24
+ "dependencies": {
25
+ "@floating-ui/dom": ">=1.5.1",
26
+ "@hotwired/stimulus": ">=3.2.1"
27
+ }
28
+ }
package/src/config.js ADDED
@@ -0,0 +1,114 @@
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;
@@ -0,0 +1,59 @@
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
+
@@ -0,0 +1,106 @@
1
+ import {arrow, computePosition, flip, offset, shift} from "@floating-ui/dom";
2
+ import {debugLog} from "./utils.js";
3
+
4
+ // Positions a panel relative to its trigger button
5
+ export function positionPanelByButton(button, logContext) {
6
+ const toggleId = button.getAttribute('data-panel-id')
7
+ let panel = document.getElementById(toggleId)
8
+ positionPanel(button, panel)
9
+ debugLog('panel positioned', logContext)
10
+ }
11
+
12
+ // Positions a panel using its event listener
13
+ export function positionPanelSelf(panel) {
14
+ let button = document.querySelector(`[data-panel-id="${panel.id}"]`)
15
+ positionPanel(button, panel)
16
+ }
17
+
18
+ // Retrieves all currently open panels within a scope element
19
+ export function getAllOpenPanels(scopeElement) {
20
+ const buttons = scopeElement.getElementsByClassName('has-open-panel')
21
+ const elements = []
22
+
23
+ for (const button of buttons) {
24
+ if (button.getAttribute('data-controller') === 'csedl-dropdown') {
25
+ const getOrSetPanelId = button.getAttribute('data-panel-id')
26
+ const panel = document.getElementById(getOrSetPanelId)
27
+ elements.push([button, panel])
28
+ }
29
+ }
30
+
31
+ debugLog(`FOUND ${elements.length} OPEN DROPDOWNS`)
32
+
33
+ return (elements)
34
+ }
35
+
36
+ // Positions all provided panel-button pairs
37
+ export function positionAllPanels(elements) {
38
+ for (const el of elements) {
39
+ positionPanel(el[0], el[1])
40
+ }
41
+ }
42
+
43
+ // Positions a single panel relative to its button
44
+ export function positionPanel(button, panel) {
45
+ let arrowElement = panel.querySelector('#arrow')
46
+ panel.style.removeProperty('height')
47
+ panel.style.removeProperty('left')
48
+ panel.style.removeProperty('top')
49
+ let placementAttr = panel.getAttribute('data-placement')
50
+ let placement = (placementAttr ? placementAttr : 'bottom')
51
+ debugLog(`placement: «${placement}»`)
52
+
53
+ computePosition(button, panel, {
54
+ middleware: [flip(), offset(6), shift({padding: 5}), arrow({element: arrowElement})],
55
+ placement: placement
56
+ }).then(({x, y, placement, middlewareData}) => {
57
+ Object.assign(panel.style, {
58
+ left: `${x}px`,
59
+ top: `${y}px`,
60
+ });
61
+
62
+ if (arrowElement) {
63
+ // ARROW
64
+ const {x: arrowX, y: arrowY} = middlewareData.arrow;
65
+ const staticSide = {
66
+ top: 'bottom',
67
+ right: 'left',
68
+ bottom: 'top',
69
+ left: 'right',
70
+ }[placement.split('-')[0]];
71
+ Object.assign(arrowElement.style, {
72
+ left: arrowX != null ? `${arrowX}px` : '',
73
+ top: arrowY != null ? `${arrowY}px` : '',
74
+ right: '',
75
+ bottom: '',
76
+ [staticSide]: '-4px',
77
+ });
78
+ debugLog(`panel + arrow positioned`)
79
+ } else {
80
+ debugLog(`panel positioned`)
81
+ }
82
+
83
+ adjustToWindowBounds(button, panel)
84
+ });
85
+ }
86
+
87
+ // Adjusts panel to prevent overflow beyond window boundaries
88
+ function adjustToWindowBounds(button, panel) {
89
+ if (button.getAttribute('data-constrain-to-window-borders') === 'true') {
90
+ const rect = panel.getBoundingClientRect();
91
+
92
+ if (rect.top < 0) {
93
+ const newTop = parseFloat(panel.style.top) + Math.abs(rect.top) + 2
94
+ console.log('constrain top', 'actual top:', panel.style.top, 'absolute top:', rect.top, 'new top:', newTop)
95
+ panel.style.top = `${newTop}px`
96
+ }
97
+
98
+ const rect2 = panel.getBoundingClientRect();
99
+
100
+ if (rect2.bottom > window.innerHeight) {
101
+ console.log('constrain height')
102
+ const height = window.innerHeight - rect2.top - 2
103
+ panel.style.height = `${height}px`
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,99 @@
1
+ import {Controller} from "@hotwired/stimulus"
2
+ import {positionAllPanels, getAllOpenPanels} from "./floating-ui-functions";
3
+ import {debugLog} from "./utils.js";
4
+
5
+ export default class extends Controller {
6
+
7
+ elements_to_place = null
8
+
9
+ connect() {
10
+ const on = this.element.getAttribute('data-on')
11
+
12
+ if (on.includes('scroll')) {
13
+ this.element.addEventListener('scroll', () => {
14
+ this.handle_placement('scroll')
15
+ })
16
+ }
17
+
18
+ if (on.includes('resize-observer')) {
19
+
20
+ var resize_observer = new ResizeObserver(entries => {
21
+ for (let entry of entries) {
22
+ this.handle_placement('resize-observer')
23
+ }
24
+ });
25
+
26
+ resize_observer.observe(this.element)
27
+ }
28
+ }
29
+
30
+ handle_placement(trigger_label) {
31
+ if (this.elements_to_place === null) {
32
+ this.elements_to_place = getAllOpenPanels(this.element)
33
+ this.element.setAttribute('csedl-place-all-first-done-time', performance.now())
34
+ }
35
+
36
+ if (this.elements_to_place.length === 0) {
37
+ this.cleanup()
38
+
39
+ } else {
40
+ const c = Number(this.element.getAttribute('csedl-place-all-counter'))
41
+ this.element.setAttribute('csedl-place-all-counter', c + 1)
42
+ const first_run = Number(this.element.getAttribute('csedl-place-all-first-done-time'))
43
+
44
+ debugLog(`place panels by ${trigger_label} ${c} / ${performance.now() - first_run}ms: x: ${this.element.offsetWidth} y: ${this.element.offsetHeight}`)
45
+ positionAllPanels(this.elements_to_place)
46
+ this.element.setAttribute('csedl-place-all-done-time', performance.now())
47
+ this.setup_run_after_event()
48
+ }
49
+ }
50
+
51
+ setup_run_after_event() {
52
+ if (this.element.hasAttribute('data-run-after')) {
53
+ const run_after_str = this.element.getAttribute('data-run-after')
54
+ const run_after = Number(run_after_str)
55
+ if (isNaN(run_after)) {
56
+ console.error(`the data-run-after attribute must be a valid positive number (integer for milliseconds) but is: «${run_after_str}»`, this.element)
57
+ } else if (run_after < 0.0) {
58
+ console.error(`the data-run-after attribute must be a positive number (integer for milliseconds) but is: «${run_after_str}»`, this.element)
59
+ } else {
60
+ if (this.element.getAttribute('csedl-run-after-is-active')) {
61
+ debugLog('place-panel run-after is already installed (nothing to do)')
62
+ } else {
63
+ this.element.setAttribute('csedl-run-after-is-active', true)
64
+ this.element.setAttribute('csedl-place-all-counter', 0)
65
+ setTimeout(() => this.handle_placement_after(), 50);
66
+ debugLog('place-panel run-after INSTALLED')
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ handle_placement_after() {
73
+ const run_after = Number(this.element.getAttribute('data-run-after'))
74
+ const last_run = Number(this.element.getAttribute('csedl-place-all-done-time'))
75
+ const c = Number(this.element.getAttribute('csedl-place-all-counter'))
76
+ this.element.setAttribute('csedl-place-all-counter', c + 1)
77
+ const running_time = performance.now() - last_run
78
+
79
+ positionAllPanels(this.elements_to_place)
80
+ debugLog(`PLACEMENT FOLLOW-UP ${c}: ${running_time}ms / ${run_after}ms`)
81
+
82
+ if (running_time < run_after) {
83
+ setTimeout(() => this.handle_placement_after(), 50);
84
+
85
+ } else {
86
+ this.cleanup()
87
+ }
88
+ }
89
+
90
+ cleanup() {
91
+ this.element.removeAttribute('csedl-place-all-done-time')
92
+ this.element.removeAttribute('csedl-place-all-first-done-time')
93
+ this.element.removeAttribute('csedl-place-all-counter')
94
+ this.element.removeAttribute('csedl-run-after-is-active')
95
+ this.elements_to_place = null
96
+ debugLog('FINISHED placement follow-up')
97
+ }
98
+
99
+ }
@@ -0,0 +1,62 @@
1
+ import {Controller} from "@hotwired/stimulus"
2
+ import {positionPanelByButton} from "./floating-ui-functions.js";
3
+ import {debugLog, onPanelOpen} from "./utils.js";
4
+
5
+ export default class extends Controller {
6
+
7
+ opening_timer_id = null
8
+
9
+ connect() {
10
+ const panel_id = this.element.getAttribute('data-panel-id')
11
+ if (!panel_id) {
12
+ console.error(`Attribute data-panel-id missing`, this.element)
13
+ }
14
+ const delay_sec = parseFloat(this.element.getAttribute('data-delay'))
15
+ if (!delay_sec) {
16
+ console.error('missing required attribute: data-delay', this.element)
17
+ }
18
+ this.element.addEventListener('mouseenter', (e) => this.start_opening(e, panel_id, delay_sec * 1000))
19
+ this.element.addEventListener('mouseleave', (e) => this.close(e, panel_id))
20
+ if (window.StimulusDropdown.persistTooltipOnClick) {
21
+ this.element.addEventListener('click', (e) => this.toggleByClick(e, panel_id))
22
+ }
23
+ }
24
+
25
+ start_opening(e, panel_id, delay_ms) {
26
+ if (this.element.hasAttribute('data-tooltip-click')) { return; }
27
+ this.opening_timer_id = setTimeout(() => this.open(e, panel_id), delay_ms)
28
+ }
29
+
30
+ toggleByClick(e, panel_id) {
31
+ const stat = this.element.getAttribute('data-tooltip-click')
32
+ if (stat === 'open') {
33
+ debugLog('closing by click')
34
+ this.element.removeAttribute('data-tooltip-click')
35
+ this.close(e, panel_id)
36
+ } else {
37
+ debugLog('opening by click')
38
+ this.open(e, panel_id)
39
+ this.element.setAttribute('data-tooltip-click', 'open')
40
+ }
41
+ }
42
+
43
+ open(e, panel_id) {
44
+ this.element.classList.add('tooltip-is-visible')
45
+ onPanelOpen(e, this.element)
46
+ this.opening_timer_id = null
47
+ let panel = document.getElementById(panel_id)
48
+ panel.style.display = 'block';
49
+ positionPanelByButton(this.element)
50
+ }
51
+
52
+ close(e, panel_id) {
53
+ if (this.element.hasAttribute('data-tooltip-click')) { return; }
54
+ this.element.classList.remove('tooltip-is-visible')
55
+ if (this.opening_timer_id) {
56
+ clearTimeout(this.opening_timer_id)
57
+ }
58
+ let panel = document.getElementById(panel_id)
59
+ panel.style.display = 'none';
60
+ }
61
+
62
+ }
package/src/utils.js ADDED
@@ -0,0 +1,226 @@
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
+ }