Skip to content

Commit a09aed0

Browse files
authored
Merge pull request #363 from rackerlabs/surf-1416-hx-menu
feat(hx-menu): implement new positioning logic
2 parents 68aa6b0 + 37d62ba commit a09aed0

File tree

2 files changed

+154
-54
lines changed

2 files changed

+154
-54
lines changed

docs/elements/hx-menu/index.html

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,48 @@
5858
</dd>
5959
</dl>
6060
{% endblock %}
61+
62+
{% block properties %}
63+
<dl>
64+
<dt>controlElement (HTMLElement)</dt>
65+
<dd>
66+
<p>
67+
<i>(read-only)</i>
68+
Returns the HTML element with the <code>aria-controls</code>
69+
attribute that matches the <code>&lt;hx-menu&gt;</code> ID.
70+
</p>
71+
</dd>
72+
73+
<dt>open (Boolean [false])</dt>
74+
<dd>
75+
<p>
76+
Manipulates the <code>open</code> attribute.
77+
</p>
78+
</dd>
79+
80+
<dt>position (String ["bottom-start"])</dt>
81+
<dd>
82+
<p>
83+
Manipulates the <code>position</code> attribute.
84+
</p>
85+
</dd>
86+
87+
<dt>relativeElement (HTMLElement)</dt>
88+
<dd>
89+
<p class="comfortable">
90+
<i>(read-only)</i>
91+
Returns the HTML element used as a reference to calculate the position of
92+
the open menu. If the <code>relative-to</code> attribute is set, this will
93+
be the element with ID matching the <code>relativeTo</code> property value.
94+
Otherwise, this will be the <code>controlElement</code>.
95+
</p>
96+
</dd>
97+
98+
<dt>relativeTo (String)</dt>
99+
<dd>
100+
<p>
101+
Manipulates the <code>relative-to</code> attribute.
102+
</p>
103+
</dd>
104+
</dl>
105+
{% endblock %}
Lines changed: 109 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { HXElement } from './HXElement';
22
import { getPosition } from '../utils/position';
3+
import debounce from 'lodash/debounce';
4+
5+
const DEFAULT_POSITION = 'bottom-start';
36

47
/**
58
* Fires when the element is concealed.
@@ -33,20 +36,22 @@ export class HXMenuElement extends HXElement {
3336

3437
$onCreate () {
3538
this._onDocumentClick = this._onDocumentClick.bind(this);
39+
this._onDocumentScroll = this._onDocumentScroll.bind(this);
40+
this._reposition = this._reposition.bind(this);
41+
42+
this._onWindowResize = debounce(this._reposition, 50);
3643
}
3744

3845
$onConnect () {
3946
this.$upgradeProperty('open');
4047
this.$upgradeProperty('position');
4148
this.$upgradeProperty('relativeTo');
42-
this.$defaultAttribute('position', 'bottom-start');
49+
50+
this.$defaultAttribute('position', DEFAULT_POSITION);
4351
this.$defaultAttribute('role', 'menu');
44-
this._initialPosition = this.position;
45-
document.addEventListener('click', this._onDocumentClick);
46-
}
4752

48-
$onDisconnect () {
49-
document.removeEventListener('click', this._onDocumentClick);
53+
this.setAttribute('aria-hidden', !this.open);
54+
this.setAttribute('aria-expanded', this.open);
5055
}
5156

5257
static get $observedAttributes () {
@@ -55,84 +60,134 @@ export class HXMenuElement extends HXElement {
5560

5661
$onAttributeChange (attr, oldVal, newVal) {
5762
if (attr === 'open') {
58-
let isOpen = (newVal !== null);
59-
this.setAttribute('aria-expanded', isOpen);
60-
this.$emit(isOpen ? 'open' : 'close');
63+
this._attrOpenChange(oldVal, newVal);
6164
}
6265
}
6366

64-
set position (value) {
67+
/**
68+
* External element that controls menu visibility.
69+
* This is commonly a `<hx-disclosure>`.
70+
*
71+
* @readonly
72+
* @type {HTMLElement}
73+
*/
74+
get controlElement () {
75+
return this.getRootNode().querySelector(`[aria-controls="${this.id}"]`);
76+
}
77+
78+
/**
79+
* Determines if the menu is revealed.
80+
*
81+
* @default false
82+
* @type {Boolean}
83+
*/
84+
get open () {
85+
return this.hasAttribute('open');
86+
}
87+
set open (value) {
6588
if (value) {
66-
this.setAttribute('position', value);
89+
this.setAttribute('open', '');
6790
} else {
68-
this.removeAttribute('position');
91+
this.removeAttribute('open');
6992
}
7093
}
7194

95+
// TODO: Need to re-evaluate how we handle positioning when scrolling
96+
/**
97+
* Where to position the open menu in relation to its reference element.
98+
*
99+
* @default 'bottom-start'
100+
* @type {PositionString}
101+
*/
72102
get position () {
73-
if (this.hasAttribute('position')) {
74-
return this.getAttribute('position');
75-
}
76-
return undefined;
77-
}
78-
79-
set relativeTo (value) {
80-
this.setAttribute('relative-to', value);
103+
return this.getAttribute('position') || DEFAULT_POSITION;
81104
}
82-
83-
get relativeTo () {
84-
return this.getAttribute('relative-to');
105+
set position (value) {
106+
this.setAttribute('position', value);
85107
}
86108

109+
/**
110+
* Reference element used to calculate open menu position.
111+
*
112+
* @readonly
113+
* @type {HTMLElement}
114+
*/
87115
get relativeElement () {
88116
if (this.relativeTo) {
89117
return this.getRootNode().getElementById(this.relativeTo);
90118
} else {
91-
return this.getRootNode().querySelector(`[aria-controls="${this.id}"]`);
119+
return this.controlElement;
92120
}
93121
}
94122

95-
set open (value) {
96-
if (value) {
97-
this.setAttribute('open', '');
98-
this._setPosition();
99-
} else {
100-
this.removeAttribute('open');
101-
}
123+
/**
124+
* ID of the element to position the menu.
125+
*
126+
* @type {String}
127+
*/
128+
get relativeTo () {
129+
return this.getAttribute('relative-to');
102130
}
103-
104-
get open () {
105-
return this.hasAttribute('open');
131+
set relativeTo (value) {
132+
this.setAttribute('relative-to', value);
106133
}
107134

108-
_setPosition () {
109-
let offset = getPosition({
110-
element: this,
111-
reference: this.relativeElement,
112-
position: this.position,
113-
margin: 2,
114-
});
115-
this.style.top = `${offset.y}px`;
116-
this.style.left = `${offset.x}px`;
135+
/** @private */
136+
_addOpenListeners () {
137+
document.addEventListener('click', this._onDocumentClick);
138+
document.addEventListener('scroll', this._onDocumentScroll);
139+
window.addEventListener('resize', this._onWindowResize);
117140
}
118141

119-
_isDescendant (el) {
120-
if (el.closest(`hx-menu[id="${this.id}"]`)) {
121-
return true;
142+
/** @private */
143+
_attrOpenChange (oldVal, newVal) {
144+
let isOpen = (newVal !== null);
145+
this.setAttribute('aria-hidden', !isOpen);
146+
this.setAttribute('aria-expanded', isOpen);
147+
this.$emit(isOpen ? 'open' : 'close');
148+
149+
if (isOpen) {
150+
this._addOpenListeners();
151+
this._reposition();
152+
} else {
153+
this._removeOpenListeners();
122154
}
123-
return false;
124155
}
125156

126-
_isDisclosure (el) {
127-
if (el.closest(`hx-disclosure[aria-controls="${this.id}"]`)) {
128-
return true;
157+
/** @private */
158+
_onDocumentClick (evt) {
159+
let isDescendant = this.contains(evt.target);
160+
let withinControl = this.controlElement.contains(evt.target);
161+
let isBackground = (!isDescendant && !withinControl);
162+
163+
if (this.open && isBackground) {
164+
this.open = false;
129165
}
130-
return false;
131166
}
132167

133-
_onDocumentClick (event) {
134-
if (!this._isDescendant(event.target) && !this._isDisclosure(event.target)) {
135-
this.open = false;
168+
/** @private */
169+
_onDocumentScroll () {
170+
this._reposition();
171+
}
172+
173+
/** @private */
174+
_removeOpenListeners () {
175+
document.removeEventListener('click', this._onDocumentClick);
176+
document.removeEventListener('scroll', this._onDocumentScroll);
177+
window.removeEventListener('resize', this._onWindowResize);
178+
}
179+
180+
/** @private */
181+
_reposition () {
182+
if (this.relativeElement) {
183+
let { x, y } = getPosition({
184+
element: this,
185+
reference: this.relativeElement,
186+
position: this.position,
187+
});
188+
189+
this.style.top = `${y}px`;
190+
this.style.left = `${x}px`;
136191
}
137192
}
138193
}

0 commit comments

Comments
 (0)