|
| 1 | +/** |
| 2 | + * Manages dropdown open state programmatically via the `dropdown-open` class. |
| 3 | + * |
| 4 | + * daisyUI's CSS-only dropdown relies on `:focus-within` to stay open. That |
| 5 | + * mechanism breaks when the dropdown contains a native form control like a |
| 6 | + * `<select>`: in some Chromium-based browsers (notably Brave and Edge), |
| 7 | + * opening a native picker moves focus to `<body>`, so `:focus-within` flips to |
| 8 | + * false and the dropdown collapses mid-interaction. |
| 9 | + * |
| 10 | + * Two daisyUI behaviors complicate the JS toggle: |
| 11 | + * |
| 12 | + * 1. While the dropdown is open, daisyUI applies `pointer-events: none` to |
| 13 | + * the trigger. Pointer events at the trigger's position then re-target |
| 14 | + * to the dropdown root, so a listener attached to the trigger element |
| 15 | + * wouldn't fire on the close-on-second-click. Listen on the root and |
| 16 | + * treat any mousedown that's *not* inside the menu as a toggle — that |
| 17 | + * covers both the trigger (first click) and the re-targeted root |
| 18 | + * (second click). |
| 19 | + * |
| 20 | + * 2. After dismissing a native picker, Chromium sometimes fires a |
| 21 | + * synthesized "light-dismiss" click on the page underneath, without a |
| 22 | + * preceding mousedown there. The outside-click handler tracks where the |
| 23 | + * last mousedown landed and ignores outside clicks whose mousedown was |
| 24 | + * inside the dropdown. |
| 25 | + * |
| 26 | + * Document listeners only run while the dropdown is open, so pages with many |
| 27 | + * dropdowns don't pay an event-handler tax for closed ones. |
| 28 | + */ |
| 29 | +export default { |
| 30 | + mounted () { |
| 31 | + this.trigger = this.el.querySelector(`#${this.el.id}-trigger`) |
| 32 | + this.menu = this.el.querySelector(`#${this.el.id}-menu`) |
| 33 | + if (!this.trigger) return |
| 34 | + |
| 35 | + this.isOpen = false |
| 36 | + this.mousedownInside = false |
| 37 | + |
| 38 | + this.handleRootMousedown = this.handleRootMousedown.bind(this) |
| 39 | + this.handleTriggerKeydown = this.handleTriggerKeydown.bind(this) |
| 40 | + this.handleDocumentMousedown = this.handleDocumentMousedown.bind(this) |
| 41 | + this.handleDocumentClick = this.handleDocumentClick.bind(this) |
| 42 | + this.handleDocumentKeydown = this.handleDocumentKeydown.bind(this) |
| 43 | + |
| 44 | + this.el.addEventListener('mousedown', this.handleRootMousedown) |
| 45 | + this.trigger.addEventListener('keydown', this.handleTriggerKeydown) |
| 46 | + }, |
| 47 | + beforeUpdate () { |
| 48 | + // Remember which element inside the dropdown had focus, so we can restore |
| 49 | + // it after morphdom — LiveView's built-in focus preservation can drop focus |
| 50 | + // when the surrounding form is re-rendered, even though the input node |
| 51 | + // itself isn't replaced. |
| 52 | + this.focusedBeforeUpdate = this.el.contains(document.activeElement) |
| 53 | + ? document.activeElement |
| 54 | + : null |
| 55 | + }, |
| 56 | + updated () { |
| 57 | + // Restore the open state across LiveView re-renders, since morphdom strips |
| 58 | + // classes that aren't in the server-rendered HTML. |
| 59 | + this.el.classList.toggle('dropdown-open', this.isOpen) |
| 60 | + |
| 61 | + if (this.focusedBeforeUpdate && !this.el.contains(document.activeElement)) { |
| 62 | + const target = this.focusedBeforeUpdate.isConnected |
| 63 | + ? this.focusedBeforeUpdate |
| 64 | + : this.focusedBeforeUpdate.id && this.el.querySelector(`#${this.focusedBeforeUpdate.id}`) |
| 65 | + target?.focus() |
| 66 | + } |
| 67 | + this.focusedBeforeUpdate = null |
| 68 | + }, |
| 69 | + destroyed () { |
| 70 | + this.detachDocumentListeners() |
| 71 | + this.el.removeEventListener('mousedown', this.handleRootMousedown) |
| 72 | + this.trigger?.removeEventListener('keydown', this.handleTriggerKeydown) |
| 73 | + }, |
| 74 | + open () { |
| 75 | + if (this.isOpen) return |
| 76 | + this.isOpen = true |
| 77 | + this.el.classList.add('dropdown-open') |
| 78 | + this.attachDocumentListeners() |
| 79 | + }, |
| 80 | + close () { |
| 81 | + if (!this.isOpen) return |
| 82 | + this.isOpen = false |
| 83 | + this.mousedownInside = false |
| 84 | + this.el.classList.remove('dropdown-open') |
| 85 | + this.detachDocumentListeners() |
| 86 | + }, |
| 87 | + toggle () { |
| 88 | + if (this.isOpen) this.close() |
| 89 | + else this.open() |
| 90 | + }, |
| 91 | + attachDocumentListeners () { |
| 92 | + document.addEventListener('mousedown', this.handleDocumentMousedown, true) |
| 93 | + document.addEventListener('click', this.handleDocumentClick, true) |
| 94 | + document.addEventListener('keydown', this.handleDocumentKeydown) |
| 95 | + }, |
| 96 | + detachDocumentListeners () { |
| 97 | + document.removeEventListener('mousedown', this.handleDocumentMousedown, true) |
| 98 | + document.removeEventListener('click', this.handleDocumentClick, true) |
| 99 | + document.removeEventListener('keydown', this.handleDocumentKeydown) |
| 100 | + }, |
| 101 | + handleRootMousedown (event) { |
| 102 | + // mousedown reached `this.el`, so it's inside the dropdown. Set the flag |
| 103 | + // up front: when this mousedown is the one that *opens* the dropdown, the |
| 104 | + // document-level listener isn't attached yet and won't catch it. |
| 105 | + this.mousedownInside = true |
| 106 | + if (this.menu?.contains(event.target)) return |
| 107 | + this.toggle() |
| 108 | + }, |
| 109 | + handleTriggerKeydown (event) { |
| 110 | + // Match WAI-ARIA button semantics: Enter and Space activate the trigger. |
| 111 | + if (event.key !== 'Enter' && event.key !== ' ') return |
| 112 | + event.preventDefault() |
| 113 | + this.toggle() |
| 114 | + }, |
| 115 | + handleDocumentMousedown (event) { |
| 116 | + this.mousedownInside = this.el.contains(event.target) |
| 117 | + }, |
| 118 | + handleDocumentClick (event) { |
| 119 | + if (this.el.contains(event.target)) return |
| 120 | + if (this.mousedownInside) { |
| 121 | + this.mousedownInside = false |
| 122 | + return |
| 123 | + } |
| 124 | + this.close() |
| 125 | + }, |
| 126 | + handleDocumentKeydown (event) { |
| 127 | + if (event.key !== 'Escape') return |
| 128 | + this.close() |
| 129 | + this.trigger.focus() |
| 130 | + } |
| 131 | +} |
0 commit comments