Skip to content

Commit 8d0a6a7

Browse files
committed
feat: endpoint buttons now collapse when screenwidth is too small (fixes #78)
1 parent 75673c5 commit 8d0a6a7

File tree

3 files changed

+315
-6
lines changed

3 files changed

+315
-6
lines changed

docs/user-guide.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,14 @@ Custom endpoint configurations persist across browser sessions. You can delete e
536536
- The endpoint change triggers the same behavior as manually entering an endpoint
537537
- The new endpoint is used for all subsequent queries
538538

539+
**Overflow Dropdown for Limited Space:**
540+
When the control bar is too narrow to display all endpoint buttons (e.g., when resizing the window or having many endpoints), buttons that don't fit are automatically grouped into an overflow dropdown menu:
541+
- A "more" button (⋯) appears at the end of the visible buttons
542+
- Click the overflow button to reveal a dropdown with the hidden endpoint buttons
543+
- The dropdown automatically adjusts as you resize the window
544+
- All endpoint buttons remain accessible even in constrained spaces
545+
- The overflow button only appears when needed and stays visible as long as there is room for it
546+
539547
**Behavior:**
540548
- Clicking a button updates the endpoint textbox with the configured endpoint
541549
- The endpoint change is immediate and doesn't require confirmation

packages/yasgui/src/Tab.ts

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const VERTICAL_LAYOUT_ICON = `<svg viewBox="0 0 24 24">
2222
<rect x="2" y="12" width="20" height="10" stroke="currentColor" stroke-width="2" fill="none"/>
2323
</svg>`;
2424

25+
// Overflow dropdown icon (three horizontal dots / ellipsis menu)
26+
const OVERFLOW_ICON = `<svg viewBox="0 0 24 24" fill="currentColor">
27+
<circle cx="5" cy="12" r="2"/>
28+
<circle cx="12" cy="12" r="2"/>
29+
<circle cx="19" cy="12" r="2"/>
30+
</svg>`;
31+
2532
export interface PersistedJsonYasr extends YasrPersistentConfig {
2633
responseSummary: Parser.ResponseSummary;
2734
}
@@ -75,6 +82,10 @@ export class Tab extends EventEmitter {
7582
private yasrWrapperEl: HTMLDivElement | undefined;
7683
private endpointSelect: EndpointSelect | undefined;
7784
private endpointButtonsContainer: HTMLDivElement | undefined;
85+
private endpointOverflowButton: HTMLButtonElement | undefined;
86+
private endpointOverflowDropdown: HTMLDivElement | undefined;
87+
private endpointButtonConfigs: Array<{ endpoint: string; label: string }> = [];
88+
private resizeObserver: ResizeObserver | undefined;
7889
private settingsModal?: TabSettingsModal;
7990
private currentOrientation: "vertical" | "horizontal";
8091
private orientationToggleButton?: HTMLButtonElement;
@@ -387,13 +398,196 @@ export class Tab extends EventEmitter {
387398
}
388399

389400
this.refreshEndpointButtons();
401+
this.initEndpointButtonsResizeObserver();
402+
}
403+
404+
private initEndpointButtonsResizeObserver() {
405+
if (!this.controlBarEl || !this.endpointButtonsContainer) return;
406+
407+
// Clean up existing observer
408+
if (this.resizeObserver) {
409+
this.resizeObserver.disconnect();
410+
}
411+
412+
// Create resize observer to detect when we need to show overflow
413+
this.resizeObserver = new ResizeObserver(() => {
414+
this.updateEndpointButtonsOverflow();
415+
});
416+
417+
this.resizeObserver.observe(this.controlBarEl);
418+
}
419+
420+
private updateEndpointButtonsOverflow() {
421+
if (!this.endpointButtonsContainer || !this.controlBarEl) return;
422+
423+
// Get all actual endpoint buttons (not the overflow button)
424+
const buttons = Array.from(
425+
this.endpointButtonsContainer.querySelectorAll(".endpointButton:not(.endpointOverflowBtn)"),
426+
) as HTMLButtonElement[];
427+
428+
if (buttons.length === 0) {
429+
this.hideOverflowButton();
430+
return;
431+
}
432+
433+
// Get the container's available width
434+
const containerRect = this.controlBarEl.getBoundingClientRect();
435+
const containerWidth = containerRect.width;
436+
437+
// Calculate the space used by other elements (endpoint select, settings buttons, etc.)
438+
const endpointButtonsRect = this.endpointButtonsContainer.getBoundingClientRect();
439+
const buttonsContainerLeft = endpointButtonsRect.left - containerRect.left;
440+
441+
// Estimate available space for endpoint buttons (leave some margin for overflow button)
442+
const overflowButtonWidth = 40; // Approximate width of overflow button
443+
const availableWidth = containerWidth - buttonsContainerLeft - overflowButtonWidth - 20; // 20px margin
444+
445+
// Make all buttons temporarily visible to measure
446+
buttons.forEach((btn) => btn.classList.remove("endpointButtonHidden"));
447+
448+
// Check if buttons overflow
449+
let totalWidth = 0;
450+
let overflowIndex = -1;
451+
452+
for (let i = 0; i < buttons.length; i++) {
453+
const btn = buttons[i];
454+
const btnWidth = btn.offsetWidth + 4; // Include gap
455+
totalWidth += btnWidth;
456+
457+
if (totalWidth > availableWidth && overflowIndex === -1) {
458+
overflowIndex = i;
459+
}
460+
}
461+
462+
if (overflowIndex === -1) {
463+
// All buttons fit, hide overflow button
464+
this.hideOverflowButton();
465+
buttons.forEach((btn) => btn.classList.remove("endpointButtonHidden"));
466+
} else {
467+
// Some buttons need to go into overflow
468+
buttons.forEach((btn, index) => {
469+
if (index >= overflowIndex) {
470+
btn.classList.add("endpointButtonHidden");
471+
} else {
472+
btn.classList.remove("endpointButtonHidden");
473+
}
474+
});
475+
this.showOverflowButton(overflowIndex);
476+
}
477+
}
478+
479+
private showOverflowButton(overflowStartIndex: number) {
480+
if (!this.endpointButtonsContainer) return;
481+
482+
// Create overflow button if it doesn't exist
483+
if (!this.endpointOverflowButton) {
484+
this.endpointOverflowButton = document.createElement("button");
485+
addClass(this.endpointOverflowButton, "endpointOverflowBtn");
486+
this.endpointOverflowButton.innerHTML = OVERFLOW_ICON;
487+
this.endpointOverflowButton.title = "More endpoints";
488+
this.endpointOverflowButton.setAttribute("aria-label", "More endpoint options");
489+
this.endpointOverflowButton.setAttribute("aria-haspopup", "true");
490+
this.endpointOverflowButton.setAttribute("aria-expanded", "false");
491+
492+
this.endpointOverflowButton.addEventListener("click", (e) => {
493+
e.stopPropagation();
494+
this.toggleOverflowDropdown();
495+
});
496+
497+
this.endpointButtonsContainer.appendChild(this.endpointOverflowButton);
498+
}
499+
500+
// Update the overflow button's data with which buttons are hidden
501+
this.endpointOverflowButton.dataset.overflowStart = String(overflowStartIndex);
502+
this.endpointOverflowButton.style.display = "flex";
503+
}
504+
505+
private hideOverflowButton() {
506+
if (this.endpointOverflowButton) {
507+
this.endpointOverflowButton.style.display = "none";
508+
}
509+
this.closeOverflowDropdown();
510+
}
511+
512+
private toggleOverflowDropdown() {
513+
if (this.endpointOverflowDropdown && this.endpointOverflowDropdown.style.display !== "none") {
514+
this.closeOverflowDropdown();
515+
} else {
516+
this.openOverflowDropdown();
517+
}
518+
}
519+
520+
private openOverflowDropdown() {
521+
if (!this.endpointOverflowButton || !this.endpointButtonsContainer) return;
522+
523+
const overflowStartIndex = parseInt(this.endpointOverflowButton.dataset.overflowStart || "0", 10);
524+
const overflowButtons = this.endpointButtonConfigs.slice(overflowStartIndex);
525+
526+
if (overflowButtons.length === 0) return;
527+
528+
// Create dropdown if it doesn't exist
529+
if (!this.endpointOverflowDropdown) {
530+
this.endpointOverflowDropdown = document.createElement("div");
531+
addClass(this.endpointOverflowDropdown, "endpointOverflowDropdown");
532+
this.endpointButtonsContainer.appendChild(this.endpointOverflowDropdown);
533+
}
534+
535+
// Clear and populate dropdown
536+
this.endpointOverflowDropdown.innerHTML = "";
537+
538+
overflowButtons.forEach((buttonConfig) => {
539+
const item = document.createElement("button");
540+
addClass(item, "endpointOverflowItem");
541+
item.textContent = buttonConfig.label;
542+
item.title = `Set endpoint to ${buttonConfig.endpoint}`;
543+
item.setAttribute("aria-label", `Set endpoint to ${buttonConfig.endpoint}`);
544+
545+
item.addEventListener("click", () => {
546+
this.setEndpoint(buttonConfig.endpoint);
547+
this.closeOverflowDropdown();
548+
});
549+
550+
this.endpointOverflowDropdown!.appendChild(item);
551+
});
552+
553+
// Position and show dropdown
554+
this.endpointOverflowDropdown.style.display = "block";
555+
this.endpointOverflowButton.setAttribute("aria-expanded", "true");
556+
557+
// Add click-outside listener to close dropdown
558+
const closeHandler = (e: MouseEvent) => {
559+
if (
560+
this.endpointOverflowDropdown &&
561+
!this.endpointOverflowDropdown.contains(e.target as Node) &&
562+
e.target !== this.endpointOverflowButton
563+
) {
564+
this.closeOverflowDropdown();
565+
document.removeEventListener("click", closeHandler);
566+
}
567+
};
568+
569+
// Delay adding listener to avoid immediate close
570+
setTimeout(() => {
571+
document.addEventListener("click", closeHandler);
572+
}, 0);
573+
}
574+
575+
private closeOverflowDropdown() {
576+
if (this.endpointOverflowDropdown) {
577+
this.endpointOverflowDropdown.style.display = "none";
578+
}
579+
if (this.endpointOverflowButton) {
580+
this.endpointOverflowButton.setAttribute("aria-expanded", "false");
581+
}
390582
}
391583

392584
public refreshEndpointButtons() {
393585
if (!this.endpointButtonsContainer) return;
394586

395-
// Clear existing buttons
587+
// Clear existing buttons (but keep overflow button reference)
396588
this.endpointButtonsContainer.innerHTML = "";
589+
this.endpointOverflowButton = undefined;
590+
this.endpointOverflowDropdown = undefined;
397591

398592
// Get config buttons (for backwards compatibility) and filter out disabled ones
399593
const disabledButtons = this.yasgui.persistentConfig.getDisabledDevButtons();
@@ -412,6 +606,9 @@ export class Tab extends EventEmitter {
412606

413607
const allButtons = [...configButtons, ...endpointButtons, ...customButtons];
414608

609+
// Store button configs for overflow dropdown
610+
this.endpointButtonConfigs = allButtons;
611+
415612
if (allButtons.length === 0) {
416613
// Hide container if no buttons
417614
this.endpointButtonsContainer.style.display = "none";
@@ -434,6 +631,11 @@ export class Tab extends EventEmitter {
434631

435632
this.endpointButtonsContainer!.appendChild(button);
436633
});
634+
635+
// Trigger overflow check after rendering
636+
requestAnimationFrame(() => {
637+
this.updateEndpointButtonsOverflow();
638+
});
437639
}
438640

439641
public setEndpoint(endpoint: string, endpointHistory?: string[]) {
@@ -1096,6 +1298,12 @@ WHERE {
10961298
document.documentElement.removeEventListener("mousemove", this.doVerticalDrag, false);
10971299
document.documentElement.removeEventListener("mouseup", this.stopVerticalDrag, false);
10981300

1301+
// Clean up resize observer for endpoint buttons overflow
1302+
if (this.resizeObserver) {
1303+
this.resizeObserver.disconnect();
1304+
this.resizeObserver = undefined;
1305+
}
1306+
10991307
this.removeAllListeners();
11001308
this.settingsModal?.destroy();
11011309
this.endpointSelect?.destroy();

packages/yasgui/src/endpointSelect.scss

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,9 @@
126126
align-items: center;
127127
gap: 4px;
128128
margin-left: 4px;
129-
130-
// Hide on small screens (mobile)
131-
@media (max-width: 768px) {
132-
display: none;
133-
}
129+
position: relative;
130+
flex-wrap: nowrap;
131+
overflow: visible;
134132
}
135133

136134
.endpointButton {
@@ -144,6 +142,7 @@
144142
font-weight: 500;
145143
white-space: nowrap;
146144
transition: all 0.2s ease;
145+
flex-shrink: 0;
147146

148147
&:hover {
149148
background-color: var(--yasgui-endpoint-button-hover-bg, #286090);
@@ -164,5 +163,99 @@
164163
padding: 4px 8px;
165164
font-size: 12px;
166165
}
166+
167+
// Hidden state for overflow
168+
&.endpointButtonHidden {
169+
display: none;
170+
}
171+
}
172+
173+
// Overflow button (ellipsis menu)
174+
.endpointOverflowBtn {
175+
display: flex;
176+
align-items: center;
177+
justify-content: center;
178+
width: 28px;
179+
height: 24px;
180+
padding: 4px;
181+
border: 1px solid var(--yasgui-endpoint-button-border, #337ab7);
182+
background-color: var(--yasgui-endpoint-button-bg, #337ab7);
183+
color: var(--yasgui-endpoint-button-text, #ffffff);
184+
border-radius: 3px;
185+
cursor: pointer;
186+
flex-shrink: 0;
187+
transition: all 0.2s ease;
188+
189+
svg {
190+
width: 16px;
191+
height: 16px;
192+
}
193+
194+
&:hover {
195+
background-color: var(--yasgui-endpoint-button-hover-bg, #286090);
196+
border-color: var(--yasgui-endpoint-button-hover-border, #286090);
197+
}
198+
199+
&:focus {
200+
outline: 2px solid var(--yasgui-endpoint-button-focus, #5cb3fd);
201+
outline-offset: 2px;
202+
}
203+
204+
&[aria-expanded="true"] {
205+
background-color: var(--yasgui-endpoint-button-hover-bg, #286090);
206+
}
207+
}
208+
209+
// Overflow dropdown menu
210+
.endpointOverflowDropdown {
211+
display: none;
212+
position: absolute;
213+
top: 100%;
214+
right: 0;
215+
margin-top: 4px;
216+
min-width: 150px;
217+
max-width: 300px;
218+
background-color: var(--yasgui-bg-primary, #ffffff);
219+
border: 1px solid var(--yasgui-border-color, #ddd);
220+
border-radius: 4px;
221+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
222+
z-index: 1000;
223+
overflow: hidden;
224+
225+
// Dark mode shadow
226+
[data-theme="dark"] & {
227+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
228+
}
229+
}
230+
231+
// Dropdown items
232+
.endpointOverflowItem {
233+
display: block;
234+
width: 100%;
235+
padding: 8px 12px;
236+
text-align: left;
237+
background: none;
238+
border: none;
239+
border-bottom: 1px solid var(--yasgui-border-color, #ddd);
240+
color: var(--yasgui-text-primary, rgba(0, 0, 0, 0.87));
241+
font-size: 13px;
242+
cursor: pointer;
243+
white-space: nowrap;
244+
overflow: hidden;
245+
text-overflow: ellipsis;
246+
transition: background-color 0.15s ease;
247+
248+
&:last-child {
249+
border-bottom: none;
250+
}
251+
252+
&:hover {
253+
background-color: var(--yasgui-bg-tertiary, #eee);
254+
}
255+
256+
&:focus {
257+
outline: none;
258+
background-color: var(--yasgui-bg-tertiary, #eee);
259+
}
167260
}
168261
}

0 commit comments

Comments
 (0)