Skip to content

Commit 343dad7

Browse files
authored
fix: stop the header overlapping itself when it runs out of room (#4906)
On a tablet-width screen the header had no way to give up space. The navigation, the logo and the session controls each held their full width, so once an extension added a few links the row simply overflowed and the controls painted over each other — a username cut mid-word on an iPad in portrait, or nav links sitting on top of the search icon. Three things were behind it: - `.container` was pinned to the width that opened each breakpoint, so at 820px the layout was still 768px wide and 52px of screen went unused. The bands above 1100px were already fixed this way; this extends the same clamp down to tablet and desktop. - `.Header-primary` declared no flex behaviour at all, so it could neither shrink nor grow, and `.Header-secondary` used an auto margin — which takes free space before flex distributes any, pooling all the slack into one gap mid-row. - Nothing ever moved out of the way. Navigation now collapses what does not fit into a menu at the end of the row, via a new `OverflowingList`. The header search made this worse than it needed to be. Since search moved into a modal it has been a `readonly` text field that cannot be typed into — reserving a text field's width for a control that only ever behaved as a button, and announcing itself to assistive technology as a textbox that refuses input. It is now a button, with the same icon-and-label markup as the notification and message controls, so it collapses to its icon in the header and keeps its label in the drawer. The locale selector gains an icon for the same reason. The fitting arithmetic lives in `countItemsThatFit` rather than inside the component, so the awkward cases can be tested directly: boundaries where an item exactly fits, keeping room for the toggle that is about to appear, and the monotonicity that stops the row oscillating between two states. `OverflowingList` measures real layout, which jsdom cannot provide, so the component tests cover the markup either side of the decision and the sums are covered on their own.
1 parent c5b897d commit 343dad7

14 files changed

Lines changed: 959 additions & 60 deletions

File tree

framework/core/js/src/common/components/AbstractGlobalSearch.tsx

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Component, { ComponentAttrs } from '../Component';
33
import SearchState from '../states/SearchState';
44
import extractText from '../utils/extractText';
55
import ItemList from '../utils/ItemList';
6-
import Input from './Input';
6+
import Icon from './Icon';
77
import type Mithril from 'mithril';
88

99
export interface SearchAttrs extends ComponentAttrs {
@@ -90,50 +90,51 @@ export default abstract class AbstractGlobalSearch<T extends SearchAttrs = Searc
9090
// Hide the search view if no sources were loaded
9191
if (this.sourceItems().isEmpty()) return <div></div>;
9292

93+
const value = this.searchState.getValue();
94+
95+
// Search happens in a modal, so this control does not accept text: it is a
96+
// button that opens the modal, and reads as one to assistive technology
97+
// rather than as a textbox that refuses input. Where its label is shown —
98+
// the drawer — it displays the current query in place of the prompt, so a
99+
// search that has been run stays visible after the modal closes.
93100
const openSearchModal = () => {
94-
this.$('input').blur() &&
95-
app.modal.show(() => import('../../common/components/SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
101+
app.modal.show(() => import('../../common/components/SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
96102
};
97103

98104
return (
99-
<div
100-
role="search"
101-
className="Search"
102-
aria-label={this.attrs.a11yRoleLabel}
103-
onclick={() => {
104-
// On phones the search input lives inside the slide-out drawer. Close the drawer as soon as
105-
// search is activated, before the modal is shown, so it isn't left open behind (and overlapping)
106-
// the full-screen search modal as it animates in. Hiding it here rather than in `openSearchModal`
107-
// lets the drawer finish sliding out during the delay below. No-op where the drawer is never open.
108-
app.drawer.hide();
109-
this.$('input').blur();
110-
setTimeout(() => openSearchModal(), 150);
111-
}}
112-
>
113-
<Input
114-
type="search"
115-
className="Search-input"
116-
clearable={this.searchState.getValue()}
117-
clearLabel={app.translator.trans('core.lib.search.search_clear_button_accessible_label')}
118-
prefixIcon="fas fa-search"
119-
aria-label={this.attrs.label}
120-
readonly={true}
121-
placeholder={this.attrs.label}
122-
value={this.searchState.getValue()}
123-
onchange={(value: string) => {
124-
if (!value) this.searchState.clear();
125-
else this.searchState.setValue(value);
126-
}}
127-
inputAttrs={{
128-
// for keyboard navigation, click event would be triggered on keydown
129-
onkeydown: (e: KeyboardEvent) => {
130-
if (e.key === 'Enter') {
131-
e.preventDefault();
132-
this.$('input').blur() && openSearchModal();
133-
}
134-
},
105+
<div role="search" className="Search" aria-label={this.attrs.a11yRoleLabel}>
106+
<button
107+
type="button"
108+
// `Button--flat` rather than `Button--link`: this sits among the
109+
// notification and message controls, which are flat buttons, and
110+
// should pick up the same hover and focus treatment as them.
111+
className="Search-input Button Button--flat"
112+
aria-label={extractText(this.attrs.label)}
113+
title={extractText(this.attrs.label)}
114+
onclick={() => {
115+
// On phones the search button lives inside the slide-out drawer. Close the drawer as soon as
116+
// search is activated, before the modal is shown, so it isn't left open behind (and overlapping)
117+
// the full-screen search modal as it animates in. Hiding it here rather than in `openSearchModal`
118+
// lets the drawer finish sliding out during the delay below. No-op where the drawer is never open.
119+
app.drawer.hide();
120+
setTimeout(() => openSearchModal(), 150);
135121
}}
136-
/>
122+
>
123+
<Icon name="fas fa-search" className="Button-icon" />
124+
<span className="Button-label">
125+
<span className="Button-labelText">{value || this.attrs.label}</span>
126+
</span>
127+
</button>
128+
{!!value && (
129+
<button
130+
type="button"
131+
className="Search-clear Button Button--icon Button--link"
132+
aria-label={extractText(app.translator.trans('core.lib.search.search_clear_button_accessible_label'))}
133+
onclick={() => this.searchState.clear()}
134+
>
135+
<Icon name="fas fa-times-circle" />
136+
</button>
137+
)}
137138
</div>
138139
);
139140
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import Component, { ComponentAttrs } from '../Component';
2+
import Dropdown from './Dropdown';
3+
import listItems, { ModdedChildrenWithItemName } from '../helpers/listItems';
4+
import classList from '../utils/classList';
5+
import countItemsThatFit from '../utils/countItemsThatFit';
6+
import extractText from '../utils/extractText';
7+
import app from '../app';
8+
import type Mithril from 'mithril';
9+
10+
export interface IOverflowingListAttrs extends ComponentAttrs {
11+
/** The items to lay out, as returned by `ItemList.toArray()`. */
12+
items: ModdedChildrenWithItemName[];
13+
/** A class name to apply to the list element. */
14+
className?: string;
15+
/** The label used to describe the overflow menu to assistive readers. */
16+
accessibleToggleLabel?: string;
17+
}
18+
19+
/**
20+
* Lays out a list of items on a single row, moving those that do not fit into
21+
* a dropdown at the end of the row.
22+
*
23+
* The list is rendered in full and measured after each paint, so item widths
24+
* come from the real laid-out DOM rather than an estimate. That keeps the
25+
* result correct whatever an item happens to contain — an icon, a long label,
26+
* a translated string, or something an extension added.
27+
*/
28+
export default class OverflowingList extends Component<IOverflowingListAttrs> {
29+
/**
30+
* How many leading items are shown in the row. `null` means "not measured
31+
* yet", during which everything renders so the widths can be read.
32+
*/
33+
protected visibleCount: number | null = null;
34+
35+
/**
36+
* Width of each item, by index, captured while all of them were on the row.
37+
* Measuring once and reusing avoids feeding the widths of an already
38+
* collapsed row back into the next calculation.
39+
*/
40+
protected itemWidths: number[] = [];
41+
42+
protected toggleWidth = 0;
43+
44+
protected observer?: ResizeObserver;
45+
46+
protected onWindowResize?: () => void;
47+
48+
view(vnode: Mithril.Vnode<IOverflowingListAttrs, this>) {
49+
const items = this.attrs.items || [];
50+
const count = this.visibleCount ?? items.length;
51+
const visible = items.slice(0, count);
52+
const overflowed = items.slice(count);
53+
54+
return (
55+
<ul className={classList('OverflowingList', this.attrs.className)}>
56+
{listItems(visible)}
57+
{overflowed.length > 0 && (
58+
<li className="OverflowingList-toggle" itemName="overflow">
59+
<Dropdown
60+
className="OverflowingList-dropdown"
61+
buttonClassName="Button Button--link"
62+
menuClassName="Dropdown-menu--right"
63+
icon="fas fa-ellipsis-h"
64+
// The ellipsis already says "there is more here"; the caret a
65+
// dropdown adds by default only doubles up on that. An empty
66+
// string rather than null, since the default is applied with
67+
// `??=` and would overwrite a nullish value.
68+
caretIcon=""
69+
accessibleToggleLabel={
70+
this.attrs.accessibleToggleLabel ?? extractText(app.translator.trans('core.lib.overflowing_list.toggle_accessible_label'))
71+
}
72+
>
73+
{overflowed}
74+
</Dropdown>
75+
</li>
76+
)}
77+
</ul>
78+
);
79+
}
80+
81+
oncreate(vnode: Mithril.VnodeDOM<IOverflowingListAttrs, this>) {
82+
super.oncreate(vnode);
83+
84+
// Watch the container rather than the row itself. The row's own width is
85+
// a *result* of collapsing — observing it would mean reacting to this
86+
// component's own output and never seeing the space open back up. The
87+
// container tracks the viewport, and its other children (a logo, the
88+
// controls opposite) can change width with no resize event of their own.
89+
//
90+
// Where there is no ResizeObserver the row still works: it lays out on
91+
// creation and on window resizes, and only misses changes that resize a
92+
// sibling without resizing the window.
93+
if (typeof ResizeObserver !== 'undefined') {
94+
this.observer = new ResizeObserver(() => this.recalculate());
95+
96+
const list = this.element as HTMLElement;
97+
const container = list.parentElement?.parentElement ?? list.parentElement ?? list;
98+
this.observer.observe(container);
99+
}
100+
101+
// Sibling controls can change width without the container resizing at all
102+
// — a label dropping at a breakpoint, a badge appearing. Nothing reports
103+
// that, so the row also re-checks on viewport changes.
104+
this.onWindowResize = () => this.recalculate();
105+
window.addEventListener('resize', this.onWindowResize);
106+
window.addEventListener('orientationchange', this.onWindowResize);
107+
108+
this.recalculate();
109+
}
110+
111+
onupdate(vnode: Mithril.VnodeDOM<IOverflowingListAttrs, this>) {
112+
super.onupdate(vnode);
113+
114+
this.recalculate();
115+
}
116+
117+
onremove(vnode: Mithril.VnodeDOM<IOverflowingListAttrs, this>) {
118+
super.onremove(vnode);
119+
120+
this.observer?.disconnect();
121+
122+
if (this.onWindowResize) {
123+
window.removeEventListener('resize', this.onWindowResize);
124+
window.removeEventListener('orientationchange', this.onWindowResize);
125+
}
126+
}
127+
128+
/**
129+
* Work out how many items fit, and redraw if that has changed.
130+
*/
131+
protected recalculate(): void {
132+
const list = this.element as HTMLElement;
133+
if (!list) return;
134+
135+
const children = Array.from(list.children) as HTMLElement[];
136+
const itemCount = (this.attrs.items || []).length;
137+
138+
// Nothing can be measured before the row is in a document, so leave every
139+
// item showing until it is.
140+
if (!list.isConnected) return;
141+
142+
// Collapsing only makes sense while the items share a single line. In the
143+
// drawer the same list is laid out as a vertical column with a whole
144+
// screen height to grow into, so there is nothing to save and everything
145+
// should simply render.
146+
if (getComputedStyle(list).flexDirection !== 'row') {
147+
if (this.visibleCount !== itemCount) {
148+
this.visibleCount = itemCount;
149+
m.redraw();
150+
}
151+
return;
152+
}
153+
154+
// Widths are read whenever every item is on the row, which is the only
155+
// time they are all measurable. Re-reading rather than trusting the first
156+
// measurement matters because what an item is worth changes underneath
157+
// this: a label hidden at a breakpoint, a font that finishes loading, a
158+
// count appearing on a badge.
159+
if (this.visibleCount === null || this.visibleCount === itemCount) {
160+
const measured = children.slice(0, itemCount).map((child) => this.outerWidth(child));
161+
if (measured.length === itemCount) this.itemWidths = measured;
162+
// Nothing has overflowed, so the toggle is not in the DOM to measure.
163+
// Reserve a conservative width for it; it is replaced with the real
164+
// figure as soon as the toggle renders.
165+
if (!this.toggleWidth) this.toggleWidth = 48;
166+
} else {
167+
const toggle = list.querySelector('.OverflowingList-toggle') as HTMLElement | null;
168+
if (toggle) this.toggleWidth = this.outerWidth(toggle);
169+
}
170+
171+
const available = this.availableWidth(list);
172+
if (!available || this.itemWidths.length !== itemCount) return;
173+
174+
// Decided from the measured widths and the container's width — never from
175+
// the row's current width, which is a consequence of the last decision and
176+
// would turn this into a feedback loop.
177+
const fits = countItemsThatFit(this.itemWidths, available, this.toggleWidth);
178+
179+
if (fits !== this.visibleCount) {
180+
this.visibleCount = fits;
181+
m.redraw();
182+
}
183+
}
184+
185+
/**
186+
* How much room the row could occupy, rather than how much it currently
187+
* does.
188+
*
189+
* The list sits in a flex parent that shrink-wraps its contents, so once
190+
* items have been collapsed the element reports the narrower width it took
191+
* *because* of the collapse. Measuring that would be circular — the row
192+
* could never grow back. What matters is the gap to whatever sits beside it.
193+
*/
194+
protected availableWidth(list: HTMLElement): number {
195+
const parent = list.parentElement;
196+
if (!parent) return list.clientWidth;
197+
198+
const container = parent.parentElement;
199+
if (!container) return parent.clientWidth;
200+
201+
const parentBox = parent.getBoundingClientRect();
202+
const containerStyle = getComputedStyle(container);
203+
const containerBox = container.getBoundingClientRect();
204+
205+
// Start from the container's inner edge, then walk the siblings and take
206+
// out whatever they occupy along with the gaps between them.
207+
let free = containerBox.width - parseFloat(containerStyle.paddingLeft || '0') - parseFloat(containerStyle.paddingRight || '0');
208+
209+
const gap = parseFloat(containerStyle.columnGap || containerStyle.gap || '0') || 0;
210+
let siblings = 0;
211+
212+
Array.from(container.children).forEach((child) => {
213+
if (child === parent) return;
214+
siblings++;
215+
free -= (child as HTMLElement).getBoundingClientRect().width;
216+
});
217+
218+
free -= gap * siblings;
219+
220+
// Anything the row's own parent adds around it — padding, a border, a
221+
// margin — is not usable by the items themselves.
222+
free -= parentBox.width - parent.clientWidth;
223+
224+
return Math.max(0, Math.floor(free));
225+
}
226+
227+
protected outerWidth(el: HTMLElement): number {
228+
const style = getComputedStyle(el);
229+
230+
return el.getBoundingClientRect().width + parseFloat(style.marginLeft || '0') + parseFloat(style.marginRight || '0');
231+
}
232+
}

framework/core/js/src/common/components/SelectDropdown.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = I
5050
let label = (activeChild && typeof activeChild === 'object' && 'children' in activeChild && activeChild.children) || this.attrs.defaultLabel;
5151

5252
return [
53+
// An icon is what identifies the control once there is no room for its
54+
// label — without one, hiding the label leaves a caret floating on its
55+
// own with nothing to say what it selects.
56+
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : null,
5357
<span className="Button-label">
5458
<span className="Button-labelText">{label}</span>
5559
</span>,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Work out how many items from the start of a row can be shown before the rest
3+
* have to be collapsed into an overflow menu.
4+
*
5+
* Kept separate from the component that uses it so the decision can be
6+
* exercised directly: it is pure arithmetic over measured widths, and the
7+
* awkward cases — a boundary where an item exactly fits, a row that has to make
8+
* room for the toggle it is about to show — are the ones most worth pinning
9+
* down in tests.
10+
*
11+
* The result depends only on the arguments. Nothing here may read from the row
12+
* being laid out: its width is a *consequence* of the last decision, so feeding
13+
* it back in produces a layout that oscillates rather than settles.
14+
*
15+
* @param itemWidths Width of each item, in order, as measured while they were
16+
* all on the row.
17+
* @param available Room the row has to lay items out in.
18+
* @param toggleWidth Width of the overflow toggle, needed whenever at least one
19+
* item is collapsed.
20+
* @returns How many leading items to show. Everything after them belongs in the
21+
* overflow menu.
22+
*/
23+
export default function countItemsThatFit(itemWidths: number[], available: number, toggleWidth: number): number {
24+
const itemCount = itemWidths.length;
25+
26+
if (itemCount === 0) return 0;
27+
28+
const total = itemWidths.reduce((sum, width) => sum + width, 0);
29+
30+
// Everything fits, so no toggle is needed and nothing is held back.
31+
if (total <= available) return itemCount;
32+
33+
// Something has to be collapsed, which means the toggle will be shown and
34+
// has to be paid for out of the same space.
35+
let used = 0;
36+
let fits = 0;
37+
38+
for (let i = 0; i < itemCount; i++) {
39+
if (used + itemWidths[i] + toggleWidth > available) break;
40+
used += itemWidths[i];
41+
fits++;
42+
}
43+
44+
return fits;
45+
}

0 commit comments

Comments
 (0)