Card Settings: hamburger menus open on top of it, next to their button.

A hamburger of Card Settings opened its menu in Card Settings' place:
the popup slid to the menu and Card Settings disappeared until its back
arrow. Its menus, and the forms it opens (a custom field's edit form,
the + under Custom Fields, Subtask settings), now open as a popup
LAYER: on top of Card Settings, next to the button that opened them,
with Card Settings visible under it and scrolled where it was.

Popup.open(name, { layer: true }), from inside a popup, pushes the
layer on the stack as before, so Popup.back() - the layer's X, Escape,
a form's Save - closes the layer alone. The popup template draws the
popup under it with the layer on top, outside the scrolling content;
depth counts the sliding popups only, so a confirmation opened from a
layer still slides. One layer at a time: another replaces it, its own
button closes it, a press elsewhere in the popup closes it.

A layer opens toward the middle of the popup: a hamburger at the end of
a line gets its right edge, the + on the left its left edge, so the
create form no longer ran out of the popup's left side where its Save
could not be clicked; below the button, or above when there is more
room, its height and width capped to the room
(models/lib/popupLayerPosition.js). A menu's checkboxes start at its
edge rather than .check-div's form indent.

tests/popupLayer.test.cjs runs the placement and pins the Popup wiring
and that every Card Settings menu opens as a layer;
card-add-fields.e2e.js checks Card Settings stays visible under the
menu, the menu sits by its button, toggles, closes on a press elsewhere
and by its X alone, and that the + form opens inside Card Settings.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-28 23:23:08 +02:00
1 parent 0250e54a07
commit 9c9742e09d
14 files changed
+341 -24

No files matched your search

@@ -114,6 +114,12 @@
align-items: center;
column-gap: 0.3em;
}
/* A menu's options start at its edge, like its table: not .check-div's
indent for a form's checkboxes. */
.card-settings-item-menu .card-settings-board-option,
.card-settings-section-menu .card-settings-board-option {
margin-inline-start: 0;
}
/* A tick is drawn rotated (forms.css #6786) and reaches about 3px past its
box toward the label, which ate the space: drawn 3px further left, it keeps
it - the box, and so the label, do not move when it is ticked. Right to
+39
View File
@@ -1494,3 +1494,42 @@ body.mobile-mode .pop-over .content-wrapper {
.pop-over.pop-over--maximized .card-drawing {
min-width: 0;
}
/* A layer (client/lib/popup.js `layer`): a menu on top of its popup, next to
the button that opened it - Card Settings' hamburgers. Its place and its
max-height come from models/lib/popupLayerPosition.js; the popup under it
stays visible. */
.pop-over .popup-layer {
position: absolute;
z-index: 20;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 16em;
max-width: calc(100% - 16px);
background: #fff;
color: #4d4d4d;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
.pop-over .popup-layer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid #eee;
font-weight: bold;
}
.pop-over .popup-layer-title { flex: 1 1 auto; }
.pop-over .popup-layer-close {
color: #8c8c8c;
padding: 2px 4px;
}
.pop-over .popup-layer-close:hover,
.pop-over .popup-layer-close:focus-visible { color: #4d4d4d; }
.pop-over .popup-layer-content {
overflow: auto;
min-height: 0;
padding: 8px 12px;
}
+10 -1
View File
@@ -141,7 +141,16 @@ Popup.template.events({
const direction = window.getComputedStyle(element).direction === 'rtl' ? -1 : 1;
resizeDatePopup(element, bounds.width + direction * width, bounds.height + height);
},
'click .js-back-view'() {
'click .js-back-view'(evt) {
evt.preventDefault();
Popup.back();
},
// A layer is a menu: a press anywhere else in the popup under it closes it,
// except on the button that opened it, whose own click closes it.
'mousedown .content-wrapper'(evt) {
const top = Popup._getTopStack();
if (!top || !top.layer) return;
if (top.openerElement && top.openerElement.contains(evt.target)) return;
Popup.back();
},
'click .js-close-pop-over'() {
+12
View File
@@ -44,5 +44,17 @@
.content(class="{{#unless $eq popupName ../popupName}}no-height{{/unless}}")
+Template.dynamic(template=popupName data=dataContext)
.clearfix
//- A layer (Popup.open(name, { layer: true })): a menu opened from inside
this popup, on top of it next to its button, the popup still visible under
it. Outside .content-wrapper, so it does not scroll away with the popup's
content. Its X goes back to the popup alone.
if layer
.popup-layer.js-popup-layer(data-popup="{{layer.popupName}}" role="dialog" aria-label="{{layer.title}}" style="{{layer.layerStyle}}")
.popup-layer-header
span.popup-layer-title= layer.title
a.popup-layer-close.js-back-view(href="#" role="button" aria-label="{{_ 'close-popup'}}" title="{{_ 'close-popup'}}")
i.fa.fa-times-thin(aria-hidden="true")
.popup-layer-content
+Template.dynamic(template=layer.popupName data=layer.dataContext)
button.js-date-popup-resize(type="button" aria-label="{{_ 'size'}}" title="{{_ 'size'}}")
span(aria-hidden="true") ◢
+5 -5
View File
@@ -2662,7 +2662,7 @@ const cardSettingsEvents = {
const id = evt.currentTarget.dataset.action;
const action = Object.values(cardSettingsSections.CARD_ACTIONS).flat().find(a => a.id === id);
if (!action || !action.board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
Popup.open(action.popup)(evt);
Popup.open(action.popup, { layer: true })(evt);
},
// Only the checkbox just clicked animates; its own handler does the change.
'click .card-field-order-toggle'(evt, tpl) {
@@ -3383,7 +3383,7 @@ Template.cardSettingsInline.events({
evt.preventDefault();
if (!cardSettingsSections.SECTIONS.some(s => s.id === id)) return;
const data = { sectionId: id, side: tpl.showSide.get() };
Popup.open('cardSettingsSection', { dataContext: data })(evt);
Popup.open('cardSettingsSection', { dataContext: data, layer: true })(evt);
},
// Custom Fields' items: the checkbox is the definition's flag of this side.
'click .js-card-settings-custom-field-toggle'(evt, tpl) {
@@ -3416,13 +3416,13 @@ Template.cardSettingsInline.events({
const item = evt.currentTarget.closest('.js-card-settings-custom-field');
const definition = item && ReactiveCache.getCustomField(item.dataset.id);
if (!definition || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
Popup.open('editCustomField', { dataContext: definition })(evt);
Popup.open('editCustomField', { dataContext: definition, layer: true })(evt);
},
// "+" under the items: a new custom field of the board, the last item.
'click .js-card-settings-add-custom-field'(evt) {
evt.preventDefault();
if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;
Popup.open('createCustomField', { dataContext: { boardId: Session.get('currentBoard') } })(evt);
Popup.open('createCustomField', { dataContext: { boardId: Session.get('currentBoard') }, layer: true })(evt);
},
'click .js-open-card-settings-item-menu'(evt, tpl) {
const row = evt.currentTarget.closest('.js-card-field-order-row');
@@ -3430,7 +3430,7 @@ Template.cardSettingsInline.events({
if (!row || !sectionEl) return;
evt.preventDefault();
const data = { key: row.dataset.key, sectionId: sectionEl.dataset.section, side: tpl.showSide.get() };
Popup.open('cardSettingsItem', { dataContext: data })(evt);
Popup.open('cardSettingsItem', { dataContext: data, layer: true })(evt);
},
});
+57 -5
View File
@@ -6,6 +6,7 @@ import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { computePopupOffset } from '/client/lib/popupOffset';
import { focusFirstControl } from '/client/lib/accessibility';
import { popupLayerPosition, popupLayerStyle } from '/models/lib/popupLayerPosition';
window.Popup = new (class {
constructor() {
@@ -30,7 +31,7 @@ window.Popup = new (class {
focusCurrentPopup() {
Tracker.afterFlush(() => {
const popup = document.querySelector('.js-pop-over');
const popup = document.querySelector('.js-pop-over .js-popup-layer') || document.querySelector('.js-pop-over');
if (!popup) return;
focusFirstControl(popup);
});
@@ -49,6 +50,12 @@ window.Popup = new (class {
/// adding the convention key would put a second copy of one phrase into all
/// 147 language files - starting as English in every one of them, so most
/// languages would show English for something they have already translated.
///
/// `openOptions.layer`: opened from inside a popup, the new one is a LAYER on
/// top of it, next to the button that opened it, instead of sliding in its
/// place - the popup stays visible under it (Card Settings' hamburgers).
/// Popup.back() closes the layer alone; another layer from the same popup
/// replaces it, and its own button closes it again.
open(name, openOptions = {}) {
const self = this;
const popupName = `${name}Popup`;
@@ -60,6 +67,22 @@ window.Popup = new (class {
* @param options options (dataContextIfCurrentDataIsUndefined use this dataContext if this.currentData() is undefined)
*/
return function(evt, options) {
const asLayer = Boolean(openOptions.layer) && self.isOpen() && clickFromPopup(evt);
if (asLayer) {
const top = self._getTopStack();
if (top && top.layer) {
// One layer at a time: the open one goes, and its own button only
// closes it.
self._stack.pop();
$(top.openerElement).removeClass('is-active');
if (top.openerElement === evt.currentTarget) {
evt.preventDefault();
self._dep.changed();
self.focusCurrentPopup();
return;
}
}
}
// If a popup is already opened, clicking again on the opener element
// should close it -- and interrupt the current `open` function.
if (self.isOpen()) {
@@ -93,7 +116,9 @@ window.Popup = new (class {
// has one. This allows us to position a sub-popup exactly at the same
// position than its parent.
let openerElement;
if (clickFromPopup(evt) && self._getTopStack()) {
if (asLayer) {
openerElement = evt.currentTarget;
} else if (clickFromPopup(evt) && self._getTopStack()) {
openerElement = self._getTopStack().openerElement;
} else {
// For Member Settings sub-popups, always start fresh to avoid content mixing
@@ -117,10 +142,13 @@ window.Popup = new (class {
depth: self._stack.length,
offset: self._getOffset(openerElement, popupName),
dataContext: openOptions.dataContext || (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
layer: asLayer,
layerStyle: asLayer ? self._layerStyle(openerElement) : '',
});
const $contentWrapper = $('.content-wrapper')
if ($contentWrapper.length > 0) {
// A layer leaves the popup under it where it was scrolled to.
if ($contentWrapper.length > 0 && !asLayer) {
const contentWrapper = $contentWrapper[0];
self._getTopStack().scrollTop = contentWrapper.scrollTop;
// scroll from e.g. delete comment to the top (where the confirm button is)
@@ -145,7 +173,7 @@ window.Popup = new (class {
self.template,
() => {
self._dep.depend();
return { ...self._getTopStack(), stack: self._stack };
return self._renderData();
},
document.body,
);
@@ -196,7 +224,10 @@ window.Popup = new (class {
back(n = 1) {
if (this._stack.length > n) {
const $contentWrapper = $('.content-wrapper')
if ($contentWrapper.length > 0) {
const top = this._getTopStack();
if (top && top.layer) $(top.openerElement).removeClass('is-active');
// Closing a layer leaves the popup under it as it is.
if ($contentWrapper.length > 0 && !(n === 1 && top && top.layer)) {
const contentWrapper = $contentWrapper[0];
const stack = this._stack[this._stack.length - n];
// scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
@@ -255,6 +286,27 @@ window.Popup = new (class {
return this._stack[this._stack.length - 1];
}
// What the popup template draws: the top of the stack - or, when that is a
// layer, the popup under it, with the layer drawn on top of it.
// `depth` counts the sliding popups only: a layer does not slide.
_renderData() {
const top = this._getTopStack();
const stack = this._stack.filter(entry => !entry.layer);
const depth = Math.max(0, stack.length - 1);
if (!top || !top.layer) return { ...top, depth, stack, layer: null };
return { ...this._stack[this._stack.length - 2], depth, stack, layer: top };
}
// A layer's place in the popup, next to its button (models/lib/popupLayerPosition.js).
_layerStyle(openerElement) {
const popover = openerElement && openerElement.closest('.js-pop-over');
if (!popover) return '';
return popupLayerStyle(popupLayerPosition({
opener: openerElement.getBoundingClientRect(),
popover: popover.getBoundingClientRect(),
}));
}
_cleanupPreviousPopupContent() {
// Force a re-render to ensure proper cleanup
if (this._dep) {
+12 -1
View File
@@ -120,6 +120,16 @@ sees no + and disabled checkboxes.
A hamburger is drawn **only when it has something in it**.
A hamburger's menu - and the forms Card Settings opens, a custom field's edit
form and the + under Custom Fields - opens **on top of Card Settings**, next
to the button that opened it, and Card Settings stays visible under it,
scrolled where it was. It opens toward the middle of Card Settings: a
hamburger at the end of a line gets the menu's right edge at its own, the +
on the left its left edge; below the button, or above it when there is more
room there. Its X, its own button again, a press anywhere else in Card
Settings, or Escape closes it; another hamburger replaces it. This is a popup
**layer** (`client/lib/popup.js`, `Popup.open(name, { layer: true })`).
| Hamburger | Holds |
| --- | --- |
| A row's | Labels text's way back to the board's default, while the user overrides it. Every other row has none. |
@@ -169,13 +179,14 @@ setting, as checklists do:
| `client/components/sidebar/sidebar.jade` | `boardCardSettingsPopup` is `+cardSettingsInline`; `cardSettingsInlineSection`, `cardSettingsSectionPopup`, `cardSettingsItemPopup` |
| `client/components/sidebar/sidebar.js` | The sections (`inlineSectionsOf`, `single`, `customFieldItems`), the rows (`sectionRowsOf`), the handlers (`cardSettingsEvents`, `js-card-settings-section-shown`, `js-card-settings-single-toggle`, the custom field ones), the drags (`reorderOnDrop`, `ownListOnly`) |
| `client/components/cards/cardAddFields.css` | The checklist look |
| `client/lib/popup.js`, `client/components/main/popup.tpl.jade`, `models/lib/popupLayerPosition.js` | The hamburgers' menus as layers on top of Card Settings, and where one sits |
| `models/lib/cardSettingsSections.js` | The sections, their order, and whether one is shown: `hiddenSectionsOf`, `sectionShown`, `hiddenSectionsAfter`, `rowKeysHiddenBySections`, `isSingleRowSection`, `singleLineChecked` |
| `models/lib/cardSettingsCustomFields.js` | Custom Fields' items: their order, their checkbox per side, their new `sort` after a drop |
| `models/lib/cardShownFields.js` | `boardForCard` / `boardForMinicard`: the board as its cards / minicards see it, the rows of hidden sections reading false |
| `client/components/cards/cardAddFields.js` | `cardBoardView`, `CARD_FIELD_TEMPLATES`: the opened card's templates read that view |
| `client/components/cards/minicard.js` | `MINICARD_FIELD_TEMPLATES` and its helpers read the minicard's view |
| `models/boards.js` | `cardSettingsSectionOrder`, `cardSettingsHiddenOnCard`, `cardSettingsHiddenOnMinicard`, `setCardSettingsSectionOrder`, `setCardSettingsSectionShown` |
| `tests/cardSettingsSectionShown.test.cjs`, `tests/cardSettingsCustomFields.test.cjs`, `tests/cardSettingsOnCard.test.cjs` | The rules, the layout, the handlers |
| `tests/cardSettingsSectionShown.test.cjs`, `tests/cardSettingsCustomFields.test.cjs`, `tests/cardSettingsOnCard.test.cjs`, `tests/popupLayer.test.cjs` | The rules, the layout, the handlers, the layers |
| `tests/playwright/specs/card-add-fields.e2e.js` | The popup, the checkboxes, the hamburgers, Custom Fields, the drag and collapse |
Card Settings itself reads the board as it is - its checkboxes show each
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// Where a popup LAYER sits (client/lib/popup.js `Popup.open(name, { layer:
// true })`): a menu opened from inside a popup - a hamburger of Card Settings -
// drawn ON TOP of that popup, next to the button that opened it, while the
// popup stays visible under it. Coordinates are relative to the popup's own
// box, which the layer is positioned in.
//
// opener the button's rect { top, bottom, left, right }
// popover the popup's rect { top, bottom, left, right }
//
// The layer opens toward the middle of the popup: a button in its right half
// (a hamburger at the end of a line) gets the layer's right edge at its own, a
// button in its left half (the + under Custom Fields) its left edge - so a
// wide form never runs out of the popup's side. `maxWidth` is the room on
// that side. Being physical, this is right in a right-to-left page too. It
// opens below the button when there is room for `minHeight` there or at
// least as much room as above, else above it; `maxHeight` is the room on that
// side, so a long menu scrolls inside the popup rather than past it.
function popupLayerPosition({ opener, popover, gap = 2, margin = 8, minHeight = 200 }) {
const below = popover.bottom - opener.bottom - gap - margin;
const above = opener.top - popover.top - gap - margin;
const down = below >= minHeight || below >= above;
const middle = (popover.left + popover.right) / 2;
const fromLeft = (opener.left + opener.right) / 2 < middle;
const x = fromLeft ? opener.left - popover.left : popover.right - opener.right;
const room = fromLeft ? popover.right - opener.left - margin : opener.right - popover.left - margin;
return {
vertical: down ? 'top' : 'bottom',
y: Math.round(down ? opener.bottom - popover.top + gap : popover.bottom - opener.top + gap),
horizontal: fromLeft ? 'left' : 'right',
x: Math.max(0, Math.round(x)),
maxHeight: Math.max(0, Math.round(down ? below : above)),
maxWidth: Math.max(0, Math.round(Math.min(room, popover.right - popover.left - margin))),
};
}
// The layer's inline style from its position.
function popupLayerStyle(pos) {
if (!pos) return '';
return `${pos.vertical}:${pos.y}px; ${pos.horizontal}:${pos.x}px; max-height:${pos.maxHeight}px; max-width:${pos.maxWidth}px;`;
}
module.exports = { popupLayerPosition, popupLayerStyle };
+3 -1
View File
@@ -88,7 +88,9 @@ test('Card Settings: only the board\'s actions, in the hamburger of their sectio
assert.match(sidebarJs, /\.filter\(action => action\.board && mayBoard\)/);
const click = sidebarJs.slice(sidebarJs.indexOf("'click .js-card-settings-action'(evt) {"));
const handler = click.slice(0, click.indexOf('\n },\n'));
assert.match(handler, /if \(!action \|\| !action\.board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;\n\s+Popup\.open\(action\.popup\)\(evt\);/);
// Opened as a layer on top of Card Settings, which stays visible under it
// (client/lib/popup.js `layer`), not in its place - a deliberate change.
assert.match(handler, /if \(!action \|\| !action\.board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;\n\s+Popup\.open\(action\.popup, \{ layer: true \}\)\(evt\);/);
assert.ok(!/filterSubtasksOf|settingsCard|dataContext: card/.test(sidebarJs), 'negative: Card Settings has no card path left');
});
+4 -2
View File
@@ -99,8 +99,10 @@ test('the section: definitions as items, its own checkbox the Custom Fields flag
assert.match(js, /definitionToggleSet\(side,\n\s+evt\.currentTarget\.getAttribute\('aria-checked'\) !== 'true'\)/);
assert.match(js, /Object\.entries\(sorts\)\.forEach\(\(\[id, sort\]\) => CustomFields\.update\(id, \{ \$set: \{ sort \} \}\)\);/);
assert.match(js, /ownListOnly: true,\n\s+items: '> \.js-card-settings-custom-field\.is-positioned', handle: '\.js-card-settings-custom-field-handle',/);
assert.match(js, /Popup\.open\('createCustomField', \{ dataContext: \{ boardId: Session\.get\('currentBoard'\) \} \}\)\(evt\);/);
assert.match(js, /Popup\.open\('editCustomField', \{ dataContext: definition \}\)\(evt\);/);
// Opened as a layer on top of Card Settings, which stays visible under it
// (client/lib/popup.js `layer`), not in its place - a deliberate change.
assert.match(js, /Popup\.open\('createCustomField', \{ dataContext: \{ boardId: Session\.get\('currentBoard'\) \}, layer: true \}\)\(evt\);/);
assert.match(js, /Popup\.open\('editCustomField', \{ dataContext: definition, layer: true \}\)\(evt\);/);
// The + is Create Field: it is not also a hamburger entry, so Custom Fields
// has no hamburger at all.
assert.deepStrictEqual(require('../models/lib/cardSettingsSections.js').cardActionsOf('customFields'), []);
@@ -19,7 +19,7 @@ async function openSettings(page, section) {
async function openCardSectionMenu(page) {
const popup = page.locator('.pop-over[data-popup="boardCardSettingsPopup"]');
await popup.locator('.js-card-settings-section[data-section="card"] .js-open-card-settings-section-menu').click();
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
await expect(menu).toBeVisible();
return menu;
}
+54 -7
View File
@@ -52,28 +52,68 @@ const sectionOf = (inline, id) => inline.locator(`.js-card-settings-section[data
const itemOf = (inline, key) => inline.locator(`.js-card-field-order-row[data-key="${key}"]`);
async function openItemMenu(page, inline, key) {
await itemOf(inline, key).locator('.js-open-card-settings-item-menu').click();
const menu = page.locator('.js-pop-over[data-popup="cardSettingsItemPopup"]');
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsItemPopup"]');
await expect(menu).toBeVisible();
return menu;
}
// A hamburger opened from Card Settings takes the popup's place (its back
// arrow returns to Card Settings); the X closes the popup, as every popup's does.
// A hamburger opened from Card Settings is a layer on top of it, next to its
// button, Card Settings still visible under it (client/lib/popup.js `layer`);
// the popup's X closes both, the layer's X the layer alone.
async function closePopup(page) {
const close = page.locator('.js-pop-over .js-close-pop-over');
await close.first().click();
await expect(page.locator('.js-pop-over')).toHaveCount(0);
}
async function backToCardSettings(page) {
await page.locator('.js-pop-over .js-back-view').first().click();
await page.locator('.js-pop-over .js-popup-layer .popup-layer-close').click();
await expect(page.locator('.js-pop-over .js-popup-layer')).toHaveCount(0);
await expect(settingsPopup(page)).toBeVisible();
}
async function openSectionMenu(page, inline, id) {
await sectionOf(inline, id).locator('.js-open-card-settings-section-menu').click();
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
await expect(menu).toBeVisible();
return menu;
}
// A hamburger opens ON TOP of Card Settings, next to its button, and Card
// Settings stays visible and where it was scrolled; another hamburger replaces
// it, its own button and a press elsewhere close it.
test('a hamburger menu opens on top of Card Settings, next to its button, Card Settings still visible', async ({ boardPage: page }) => {
await openBoardCardSettings(page);
const popup = settingsPopup(page);
const inline = popup.locator('.js-card-settings-inline');
const button = sectionOf(inline, 'card').locator('.js-open-card-settings-section-menu');
await button.click();
const layer = popup.locator('.js-popup-layer[data-popup="cardSettingsSectionPopup"]');
await expect(layer).toBeVisible();
// Card Settings is still there and visible under it.
await expect(sectionOf(inline, 'date')).toBeVisible();
await expect(popup).toHaveAttribute('data-popup', 'boardCardSettingsPopup');
// Next to its button: just below it, its end edge at the button's, inside the popup.
const b = await button.boundingBox();
const l = await layer.boundingBox();
const p = await popup.boundingBox();
expect(Math.abs(l.y - (b.y + b.height))).toBeLessThan(8);
expect(Math.abs((l.x + l.width) - (b.x + b.width))).toBeLessThan(8);
expect(l.x).toBeGreaterThanOrEqual(p.x);
expect(l.y + l.height).toBeLessThanOrEqual(p.y + p.height + 1);
// Its own button closes it; Card Settings stays.
await button.click();
await expect(layer).toHaveCount(0);
await expect(sectionOf(inline, 'date')).toBeVisible();
// A press elsewhere in Card Settings closes it too.
await button.click();
await expect(layer).toBeVisible();
await sectionOf(inline, 'date').locator('.card-settings-inline-section-title').click();
await expect(layer).toHaveCount(0);
// Negative: the layer's X closes the layer alone, not Card Settings.
await button.click();
await popup.locator('.js-popup-layer .popup-layer-close').click();
await expect(layer).toHaveCount(0);
await expect(popup).toBeVisible();
});
test('an empty card shows every field its board shows on cards - Due, Members, Labels - with no per-card action', async ({ boardPage: page, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsDueDate: true, allowsMembers: true, allowsLabels: true, allowsComments: true } });
@@ -482,8 +522,15 @@ test('Custom Fields: its items are the board\'s custom fields; + adds one, the l
const last = await customItems(inline).last().boundingBox();
expect((await plus.boundingBox()).y).toBeGreaterThan(last.y);
await plus.click();
const form = page.locator('.js-pop-over[data-popup="createCustomFieldPopup"]');
const form = page.locator('.js-pop-over .js-popup-layer[data-popup="createCustomFieldPopup"]');
await expect(form).toBeVisible();
// The + is at the left: the form opens rightward, inside Card Settings,
// which stays visible under it.
const box = await form.boundingBox();
const pop = await settingsPopup(page).boundingBox();
expect(box.x).toBeGreaterThanOrEqual(pop.x);
expect(box.x + box.width).toBeLessThanOrEqual(pop.x + pop.width + 1);
await expect(cf).toBeVisible();
await form.locator('.js-field-name').fill('Risk');
await form.locator('button.primary, .primary').first().click();
await expect.poll(() => fieldsOf(board).map(f => f.name).sort()).toEqual(['Estimate', 'Priority', 'Risk']);
@@ -516,7 +563,7 @@ test('Custom Fields: an item\'s checkbox per side - Show on Minicard is showOnCa
await expect.poll(() => db.findOne('customFields', { _id: id }).showOnCard).toBe(false);
// Its hamburger is the custom field's own edit form.
await customItems(inline).first().locator('.js-card-settings-edit-custom-field').click();
await expect(page.locator('.js-pop-over[data-popup="editCustomFieldPopup"] .js-field-name')).toHaveValue('Priority');
await expect(page.locator('.js-pop-over .js-popup-layer[data-popup="editCustomFieldPopup"] .js-field-name')).toHaveValue('Priority');
await closePopup(page);
});
@@ -77,7 +77,7 @@ test('Southern Sotho card settings identify data fields', async ({ boardPage: pa
await expect(popup.locator('.js-card-settings-section[data-section="date"] .card-settings-inline-section-title')).toHaveText('Letsatsi');
await expect(popup.locator('.js-card-settings-inline')).not.toContainText('masimo');
await popup.locator('.js-card-settings-section[data-section="card"] .js-open-card-settings-section-menu').click();
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
await expect(menu.locator('.js-ask-move-reason')).toBeVisible();
await expect(menu).not.toContainText('masimo');
await expect(menu.locator('.js-show-empty-card-fields')).toHaveCount(0);
+93
View File
@@ -0,0 +1,93 @@
'use strict';
// A popup LAYER (client/lib/popup.js `Popup.open(name, { layer: true })`): a
// menu opened from inside a popup - Card Settings' hamburgers - drawn on top
// of that popup next to its button, the popup still visible under it. Before,
// a hamburger slid into the popup's place and Card Settings disappeared.
// Run: node tests/popupLayer.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { popupLayerPosition, popupLayerStyle } = require('../models/lib/popupLayerPosition.js');
const root = path.join(__dirname, '..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('popupLayer:');
const popover = { top: 100, bottom: 900, left: 200, right: 800 };
test('below a button in the right half, its right edge at the button\'s', () => {
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 760, right: 780 }, popover });
assert.deepStrictEqual(pos, { vertical: 'top', y: 72, horizontal: 'right', x: 20, maxHeight: 720, maxWidth: 572 });
assert.strictEqual(popupLayerStyle(pos), 'top:72px; right:20px; max-height:720px; max-width:572px;');
});
// The + under Custom Fields is at the left: anchored by its right edge, the
// create form ran out of the popup's left side and its Save was unreachable.
test('a button in the left half: the layer\'s left edge at the button\'s, opening toward the middle', () => {
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 240, right: 260 }, popover });
assert.strictEqual(pos.horizontal, 'left');
assert.strictEqual(pos.x, 40);
assert.strictEqual(pos.maxWidth, 800 - 240 - 8);
});
test('above its button when there is more room there', () => {
const pos = popupLayerPosition({ opener: { top: 820, bottom: 840, left: 760, right: 780 }, popover });
assert.strictEqual(pos.vertical, 'bottom');
assert.strictEqual(pos.y, 900 - 820 + 2);
assert.strictEqual(pos.maxHeight, 820 - 100 - 2 - 8);
});
test('below while there is room for a menu, even with more room above', () => {
const pos = popupLayerPosition({ opener: { top: 600, bottom: 620, left: 760, right: 780 }, popover });
assert.strictEqual(pos.vertical, 'top');
assert.strictEqual(pos.maxHeight, 900 - 620 - 2 - 8);
});
test('physical, so the same right to left: a hamburger on the left opens rightward', () => {
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 220, right: 240 }, popover });
assert.strictEqual(pos.horizontal, 'left');
assert.strictEqual(pos.x, 20);
});
test('negative: never outside the popup', () => {
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 790, right: 820 }, popover });
assert.strictEqual(pos.x, 0);
// Its width is capped to the room on its side.
assert.ok(pos.maxWidth <= 800 - 200);
assert.strictEqual(popupLayerStyle(null), '');
});
test('Popup keeps the popup under a layer, and back() closes the layer alone', () => {
const js = read('client/lib/popup.js');
assert.match(js, /const asLayer = Boolean\(openOptions\.layer\) && self\.isOpen\(\) && clickFromPopup\(evt\);/);
// One layer at a time; its own button closes it.
assert.match(js, /if \(top && top\.layer\) \{\n\s+\/\/[^\n]*\n\s+\/\/[^\n]*\n\s+self\._stack\.pop\(\);/);
assert.match(js, /if \(top\.openerElement === evt\.currentTarget\) \{/);
// Drawn: the popup under the layer, with the layer; depth counts sliding popups.
assert.match(js, /return \{ \.\.\.this\._stack\[this\._stack\.length - 2\], depth, stack, layer: top \};/);
assert.match(js, /const stack = this\._stack\.filter\(entry => !entry\.layer\);/);
// A layer does not scroll the popup under it.
assert.match(js, /if \(\$contentWrapper\.length > 0 && !asLayer\) \{/);
const tpl = read('client/components/main/popup.tpl.jade');
assert.match(tpl, /if layer\n\s+\.popup-layer\.js-popup-layer\(data-popup="\{\{layer\.popupName\}\}"/);
// Negative: the layer is outside the scrolling content, so it stays by its button.
assert.ok(tpl.indexOf('.popup-layer.js-popup-layer') > tpl.indexOf('.clearfix'));
const events = read('client/components/main/popup.js');
assert.match(events, /'mousedown \.content-wrapper'\(evt\) \{/);
});
test('every hamburger and form of Card Settings opens as a layer', () => {
const js = read('client/components/sidebar/sidebar.js');
for (const name of ['cardSettingsSection', 'cardSettingsItem', 'editCustomField', 'createCustomField']) {
const calls = [...js.matchAll(new RegExp(`Popup\\.open\\('${name}'[^\\n]*`, 'g'))].map(m => m[0]);
assert.ok(calls.length, name);
for (const call of calls) assert.match(call, /layer: true/, call);
}
assert.match(js, /Popup\.open\(action\.popup, \{ layer: true \}\)\(evt\);/);
});
console.log(`popupLayer: ${passed} passed`);