Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collapse main menu options in a hamburger menu #489

Merged
merged 20 commits into from
Feb 23, 2023

Conversation

steff456
Copy link
Contributor

@steff456 steff456 commented Dec 6, 2022

This PR fixes jupyterlab/jupyterlab#13148

This PR makes the main menu bar responsive by collapsing the menus that don't fit in a hamburger menu,

responsive-menu

The resize event works for the resize action of the window and zoom in/out keyboard shortcuts, etc.

@welcome
Copy link

welcome bot commented Dec 6, 2022

Thanks for submitting your first pull request! You are awesome! 🤗

If you haven't done so already, check out Jupyter's Code of Conduct. Also, please make sure you followed the pull request template, as this will help us review your contribution more quickly.
welcome
You can meet the other Jovyans by joining our Discourse forum. There is also a intro thread there where you can stop by and say Hi! 👋

Welcome to the Jupyter community! 🎉

@fcollonval fcollonval added the enhancement New feature or request label Dec 7, 2022
Copy link
Member

@fcollonval fcollonval left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @steff456

I have two questions:

  • Should we forbid the menu label to expand on more than one line (as in your demo)?
  • Should we cache the menu item sizes?

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
@steff456
Copy link
Contributor Author

steff456 commented Dec 7, 2022

Hi @fcollonval, thanks for reviewing this PR so fast!

Should we forbid the menu label to expand on more than one line (as in your demo)?

I don't have a strong preference on this, and I think that this will not be the case in jupyterlab because all the menus only have one word. I'm not sure if there's another use case where the menu item has more than one word and if you prefer to give precedence to the actual menu item or the hamburger menu.

Should we cache the menu item sizes?

I'm already saving in cache the menu item sizes to avoid recomputing them each time a resize event happens. This also helps me calculate the amount of free space on the right compared to the "original" size of the menu to take it out from the hamburger menu. If I don't save them I would not be able to do that size comparison.

Copy link
Member

@fcollonval fcollonval left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the answer @steff456

For the styling, your answer makes a lot of sense. And I agree that the best is to let downstream application to decide what they want to do.

For the caching, the code requires some additional changes. In addition to the code comment, could you also invalidate the cache in the method onUpdateRequest as the items list may change.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
@krassowski krassowski self-requested a review December 8, 2022 12:18
@steff456
Copy link
Contributor Author

Thanks for the suggestions @fcollonval, please let me know how you see the implementation now and I wanted to ask you how I should create tests for this feature.

packages/widgets/src/menubar.ts Fixed Show fixed Hide fixed
packages/widgets/src/menubar.ts Fixed Show resolved Hide resolved
Copy link
Contributor

@gabalafou gabalafou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So happy to see this!

I'm requesting changes because I think there's an error with the variable named found (please refer to the in-line code comments).

I also think there's some error in the logic that puts menu items into the hamburger and pulls them out because when I tested this locally, it was pretty easy for me to put the menu bar in the dock panel example into an invalid state, in which the "..." menu item appears somewhere in the middle of the menu bar instead of at the end, as shown in the following screen shot:

image

I think—but I'm not sure—that what's incorrect about this implementation is... well, I think I see a lack of symmetry between putting menus into the overflow/hamburger versus pulling them out. I realized that part of that asymmetry could be explained by the fact that the code might be relying on the fact that the resize event fires multiple times as the user drags the mouse to make the window smaller or bigger. However, I think it's bad practice to depend on that.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Fixed Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
/**
* A message handler invoked on an `'resize'` message.
*/
protected _evtResize(event: Event): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a resize event on initial page load? In other words, if the user makes their browser really skinny first, and then loads JupyterLab (and then never resizes their browser again), will this code get executed and put stuff into the "..." menu or will the user have to first resize their browser window?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, there's not a resize event on the initial page load, so users will first have to resize their window. I'm not sure if there's a signal that exists when the page is loaded that I can connect this method to. If you know of it please let me know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@afshin @fcollonval is there a good signal for initial load?

This seems bad from an end user's perspective, doesn't it? For example, if I've set my browser to a higher zoom and then load the JupyterLab UI, as an end user I don't want to have to jiggle the window resize handle to get the menu bar to render properly.

That said, just to be clear I'm not asking us to block on this, because it's something that can be added later.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
for (let i = index; i < n - 1; i++) {
let submenu = this.menus[i];
submenu.title.mnemonic = 0;
this._hamburgerMenu.insertItem(0, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you iterate forward through the clipped menu items and on each iteration insert them into the overflow menu at 0, doesn't that end up putting them in backwards?

For example, imagine we have three menu items, File, Edit, View, and that the last two, Edit and View, both overflow the screen width. On the first loop run, this code will insert Edit at 0. On the second run, it will insert View at 0. Is that correct? Does that end up putting View above Edit in the "..." menu?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the functionality that I envisioned is that the order of the overflow menu is for the menu items that are left to right to gather top to bottom. Then, it is easier for me to preserve the order when unpacking the elements to the top menu again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see now why the code doesn't behave the way I thought it would. The resize event fires so many times when resizing the window that it's hard to actually have this loop handle more than one item at a time and when the user shrinks the window, it's like moving backwards through the menu items (because as the window shrinks the menu items get popped off from right to left).

});
this.removeMenuAt(i);
}
} else if (this._hamburgerMenu !== null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little lost with this else-if block. So if we get here, it means index is -1, right? And that means that all of the menu items fit within the screen size, right? Does that include the "..." menu item?

I guess I'm not sure why there isn't a loop here that just pops all of the menu items from the overflow back into the menu bar, something like:

while (this._hamburgerMenu.items.length) {
  let menu = this._hamburgerMenu.items[0].submenu as Menu;
  this._hamburgerMenu.removeItemAt(0);
  this.insertMenu(i, menu);
}
this.removeMenu(this._hamburgerMenu);
this._hamburgerMenu = null;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is an else-if block because I don't want to unpack all the elements of the overflow menu if there's no space for them, and obviously I don't want to try to do it if the overflow menu doesn't exist in the first place.

One of my first implementations tried to unpack everything and then just calculate which elements needed to go to the overflow menu but it had a lot of issues when testing it because the top menu was rendering faster, so sometimes menus will get lost or they will be inserted in the wrong order. That's why I decided to just unpack one element at the time per resize event. At the end even if the user drags the window continuously, that's firing multiple resize events.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the best solution here is to debounce the code that handles overflow.

On the one hand, it doesn't seem hygienic to me to deliberately rely on the resize event firing multiple times as the user grows or shrinks the window (e.g., what if the user uses some kind of assistive tech to grow/shrink the window by a large x-delta all at once, firing only one resize event). But I also understand that this code becomes nearly impossible to manage if a resize event fires while you're still in the middle of readjusting the menu from the previous resize event, so maybe it ultimately needs a debounce.

let index = -1;
let n = itemMenus.length;

if (this._menuItemSizes.length == 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce the number of lines in this resize handler, maybe you want to replace this._menuItemSizes with a _getMenuItemSizes method that handles this caching of the menu item sizes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can move this block of code to another function but I would need to send almost all the variables defined in the function as a parameter, so I don't know what's best...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might just be me, but would it be better if the entire function were moved into a separate method? Like so:

_evtResize(event: Event): void {
  this._handleOverflow();
}

I'm not sure that's the best name for the method, but there are a few reasons why I think having such a method would be a good idea:

  • I think it might be prudent to provide a way for clients to opt out of this behavior. I don't know all of the places that the Lumino menu bar class is used. I'm not 100% sure this behavior should be forced on downstream consumers always. Pulling the body of this function into its own method will make it easy to isolate that code and exit early if the client has passed some kind of "no overflow" option to the menu bar.
  • Once we figure out how to hook into page load and where, we will be able to call this method (rather than this._evtResize) from there.
  • Slightly simplified unit testing, since you don't have to trigger a resize event (also it makes it clear that you're not actually using the event object)
  • Friendlier to future programmers if they want to add more stuff to the resize handler (that doesn't have to do with overflow)

@fcollonval
Copy link
Member

Thanks for the suggestions @fcollonval, please let me know how you see the implementation now and I wanted to ask you how I should create tests for this feature.

Thanks for the changes @steff456
Regarding the tests, you can get inspiration from that test:

it('should render the content', () => {
let bar = new LogMenuBar();
let menu = new Menu({ commands });
bar.addMenu(menu);
expect(bar.contentNode.children.length).to.equal(0);
MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest);
let child = bar.contentNode.firstChild as HTMLElement;
expect(child.className).to.contain('lm-MenuBar-item');
bar.dispose();
});

I guess that if you force the width style of bar.node and then requesting an update. You should be able to test whether all or some items are displayed depending on the width.

Copy link
Contributor

@gabalafou gabalafou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to provide a "no resize" option?

Some of my comments here might be obsolete if you're currently working on trying to fix the issue with the bug I found in which the ellipsis appears somewhere in the middle of the menu bar rather than at the end, but I wanted to add them because they might impact the direction in which you go with the code.

Also, just a heads up, there are still some parts of this code (variables and comments) that refer to "hamburger" even though you renamed the class member. Could you update those please? 😊

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
});
this.removeMenuAt(i);
}
} else if (this._hamburgerMenu !== null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the best solution here is to debounce the code that handles overflow.

On the one hand, it doesn't seem hygienic to me to deliberately rely on the resize event firing multiple times as the user grows or shrinks the window (e.g., what if the user uses some kind of assistive tech to grow/shrink the window by a large x-delta all at once, firing only one resize event). But I also understand that this code becomes nearly impossible to manage if a resize event fires while you're still in the middle of readjusting the menu from the previous resize event, so maybe it ultimately needs a debounce.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
/**
* A message handler invoked on an `'resize'` message.
*/
protected _evtResize(event: Event): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@afshin @fcollonval is there a good signal for initial load?

This seems bad from an end user's perspective, doesn't it? For example, if I've set my browser to a higher zoom and then load the JupyterLab UI, as an end user I don't want to have to jiggle the window resize handle to get the menu bar to render properly.

That said, just to be clear I'm not asking us to block on this, because it's something that can be added later.

let index = -1;
let n = itemMenus.length;

if (this._menuItemSizes.length == 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might just be me, but would it be better if the entire function were moved into a separate method? Like so:

_evtResize(event: Event): void {
  this._handleOverflow();
}

I'm not sure that's the best name for the method, but there are a few reasons why I think having such a method would be a good idea:

  • I think it might be prudent to provide a way for clients to opt out of this behavior. I don't know all of the places that the Lumino menu bar class is used. I'm not 100% sure this behavior should be forced on downstream consumers always. Pulling the body of this function into its own method will make it easy to isolate that code and exit early if the client has passed some kind of "no overflow" option to the menu bar.
  • Once we figure out how to hook into page load and where, we will be able to call this method (rather than this._evtResize) from there.
  • Slightly simplified unit testing, since you don't have to trigger a resize event (also it makes it clear that you're not actually using the event object)
  • Friendlier to future programmers if they want to add more stuff to the resize handler (that doesn't have to do with overflow)

for (let i = index; i < n - 1; i++) {
let submenu = this.menus[i];
submenu.title.mnemonic = 0;
this._hamburgerMenu.insertItem(0, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see now why the code doesn't behave the way I thought it would. The resize event fires so many times when resizing the window that it's hard to actually have this loop handle more than one item at a time and when the user shrinks the window, it's like moving backwards through the menu items (because as the window shrinks the menu items get popped off from right to left).

@steff456
Copy link
Contributor Author

Hi @gabalafou, I had a meeting earlier this week with @afshin and I'm working in the complete refactoring of this PR to fix some of the issues that you flagged and others. I will ping you once that is ready for review 😊

@steff456
Copy link
Contributor Author

@gabalafou, @fcollonval, @afshin this PR is ready for another round of review 😬

Copy link
Member

@krassowski krassowski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome, thank you very much. This is also relatively complex logic with a lot of potential for off-by-one errors so we need good unit test coverage for this one.

this.update();
}

protected updateOverflowIndex(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to expose this method, or could it be private? _overflowIndex and _menuItemSizes are private so overriding this method would be rather difficult as it stands

If it should be protected to allow overriding, maybe it could return a new (private) interface, like:

interface IMenuOverflowState {
   itemSizes: number[];
   index: number
}

then you can have a single private variable set as:

this._overflowState = this.calculateOverflowState();

and use it with unpacking:

if (!this._overflowState) {
   return; // or otherwise skip
}
const { index: overflowIndex, itemSizes: menuItemSizes } = this._overflowState;

Then it would be also easier to unit-test it too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to create the interface, but soon enough I found myself wondering that I needed to update the overflow index value in multiple spots, so I'm not sure if we need the interface. For now I just converted the function to private.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
Comment on lines 468 to 469
this._overflowMenu.title.label = '...';
this._overflowMenu.title.mnemonic = 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth adding a new optional method in IRenderer, e.g. createOverflowMenu() that would allow to customise this? In particular downstream may want to customize label. You could provide a default implementation in Renderer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that 4e8c625 added renderOverflowItem which is a copy-paste of renderItem and is used to replace it; I think that we should . This might be useful (though I would avoid copy-pasting the code and instead just invoke the other method by default), but this is not what I had in mind.

I meant to suggest that we should wrap rendering of the overflow menu so that the .... and mnemonic can be customized downstream. The lines that I commented on would then become for example:

Suggested change
this._overflowMenu.title.label = '...';
this._overflowMenu.title.mnemonic = 0;
this.renderer.decorateOverflowMenu(this._overflowMenu.title)

This is very minor, I don't think we have to do this. To limit the surface of API changes I would also suggest reverting renderOverflowItem for now - if this is needed in the future we can always add it (removing things is more difficult).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @krassowski's suggestion to remove renderOverflowItem until it turns out it is necessary is a good plan.

packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
packages/widgets/src/menubar.ts Outdated Show resolved Hide resolved
VirtualDOM.render(content, this.contentNode);
this.updateOverflowIndex();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this one will lead to the state being always delayed by one tick. It is not bad in itself if this is a conscious decision made as a performance-UX tradeoff. Could we document this?

One thing to consider is whether we should wrap it in an extra requestAnimationFrame to protect against layout trashing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do I wrap it in a requestAnimationFrame?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be something like:

requestAnimationFrame(() => this._updateOverflowIndex());

I have not tested if this helps. You would need to record a profile in developer tools when you resize the page to trigger collapsing before and after applying the change and compare. If you have the profiles it should be obvious from the time spend on forced style recalculation/layout whether it is worth waiting for the next frame or not.

@afshin
Copy link
Member

afshin commented Jan 27, 2023

This should be its own issue unless you have seen it and think there might be a fix you can apply here.

It seems we have an outstanding bug in the MenuBar that does not come from this PR and happens with mousemove events. That was a red herring that I thought I could track down to this one before I tried just the upstream version and realized we have a problem (that does not need to be fixed here, but does need to be resolved before Lumino 2 final).

Chrome version of bug:
Screenshot 2023-01-27 at 19 07 27

Firefox version of bug:
Screenshot 2023-01-27 at 20 13 09

@afshin
Copy link
Member

afshin commented Jan 27, 2023

Here is a weird artifact that does come from this PR, though. It happens when you first shrink and then re-expand the window. You can end up with two overflow menus. Here it is in Firefox:

re-expand.mov

@afshin
Copy link
Member

afshin commented Jan 27, 2023

I think this PR is very close. The new logic flow is more in line with Lumino's widget lifecycle and easier to follow. Thanks for all the hard work @steff456! We can get this over the line.

@steff456
Copy link
Contributor Author

@afshin I already fixed the merging conflicts and added a test to this PR. Apparently the current failures involve another test, I'm not sure if re-running them will fix the issue.

Please review this PR again and let me know if anything else is needed!

@fcollonval
Copy link
Member

@steff456 the error seems legit but I don't know what is causing it as your changes from a quick glance seem unrelated.

@steff456
Copy link
Contributor Author

steff456 commented Feb 22, 2023

I'm not sure why that error is popping up either 😅. I'll give it another look because I'm not having that issue locally, just in the CI.

Edit: I could reproduce it locally, working on it!

@steff456
Copy link
Contributor Author

steff456 commented Feb 22, 2023

As tests run in parallel, apparently having two animation frames in the test I added was creating the weird failure. I split the test into two and now tests are working 🎉🎉🎉.

Let me know if there's anything else this PR need @fcollonval @afshin

Thanks a lot!

Copy link
Member

@fcollonval fcollonval left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @steff456 for the patience - let's merge this to be part of Lumino 2 RC.

@fcollonval fcollonval merged commit ca76204 into jupyterlab:main Feb 23, 2023
@welcome
Copy link

welcome bot commented Feb 23, 2023

Congrats on your first merged pull request in this project! 🎉
congrats
Thank you for contributing, we are very proud of you! ❤️

@krassowski
Copy link
Member

@steff456 @gabalafou is there anything else needed on the JupyterLab side to make it work? When resizing the window currently I do not see the menu collapse as on the screencast nor is it seen in jupyterlab/jupyterlab#14766. Was there a specific released version where it worked or was it not integration tested after merging this PR?

@@ -188,8 +208,8 @@ export class MenuBar extends Widget {
* #### Notes
* If the menu is already added to the menu bar, it will be moved.
*/
addMenu(menu: Menu): void {
this.insertMenu(this._menus.length, menu);
addMenu(menu: Menu, update: boolean = true): void {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @krassowski, I remembered that we added this optional argument here in Lumino just in case other applications didn't want to have the collapse behavior. I can check if this argument is set to True in Jupyter, and that should be the solution of not seeing this feature active

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Collapse main menu options in a hamburger menu in small screens and high zoom
5 participants