Resolve merge conflicts by accepting PR #6131 changes

Co-authored-by: xet7 <15545+xet7@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-07 16:30:08 +00:00
parent dc0b68ee80
commit 97dd5d2064
257 changed files with 9483 additions and 14103 deletions

View file

@ -1,6 +1,6 @@
.my-cards-board-wrapper {
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(400px, 52vw);
min-width: min(100%, 400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
@ -33,13 +33,6 @@
text-align: center;
margin-bottom: 0.9vh;
}
.my-cards-list-wrapper {
margin: 1.3vh 1.3vw;
border-radius: 0.7vw;
display: inline-grid;
min-width: min(250px, 32vw);
max-width: min(350px, 45vw);
}
.my-cards-card-wrapper {
margin-top: 0;
margin-bottom: 1.3vh;
@ -81,7 +74,7 @@
}
.accessibility-page h2 {
font-size: 24px;
margin-bottom: 20px;
color: #4d4d4d;
}

View file

@ -1,19 +1,18 @@
.new-comment a.fa.fa-brands.fa-markdown,
.inlined-form a.fa.fa-brands.fa-markdown {
float: right;
position: absolute;
top: -10px;
right: 60px;
.new-comment, .inlined-form {
a.fa.fa-brands.fa-markdown, a.fa.fa-copy {
display: flex;
justify-content: end;
}
}
.new-comment a.fa.fa-copy,
.inlined-form a.fa.fa-copy {
float: right;
position: relative;
top: -10px;
right: 5px;
}
.js-inlined-form.viewer.btn-sm {
position: absolute;
top: 20px;
right: 6px;
.editor-controls {
display: flex;
justify-content: end;
grid-area: editor-controls;
align-items: center;
align-self: start;
gap: 1ch;
}
.editor {
grid-area: editor;
}

View file

@ -1,12 +1,12 @@
template(name="editor")
a.fa.fa-brands.fa-markdown(title="{{_ 'convert-to-markdown'}}")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
.editor-controls
a.fa.fa-brands.fa-markdown(title="{{_ 'convert-to-markdown'}}")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip.copied-tooltip-hidden {{_ 'copied'}}
textarea.editor(
dir="auto"
class="{{class}}"
id=id
autofocus=autofocus
placeholder="{{_ 'comment-placeholder'}}")
+Template.contentBlock

View file

@ -90,7 +90,6 @@ BlazeComponent.extendComponent({
const enableTextarea = function() {
const $textarea = this.$(textareaSelector);
autosize($textarea);
$textarea.escapeableTextComplete(mentions);
};
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === true || Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') {

View file

@ -1,6 +1,6 @@
.global-search-board-wrapper {
border-radius: 8px;
min-width: 400px;
border-radius: 0.8ch;
min-width: min(100%, 400px);
border-width: 8px;
border-color: #808080;
border-style: solid;
@ -67,8 +67,6 @@
color: #8b0000;
}
.global-search-page {
width: 40%;
min-width: 400px;
margin-right: auto;
margin-left: auto;
line-height: 150%;
@ -91,6 +89,13 @@
font-family: Courier;
font-style: italic;
}
.lists-wrapper {
display: flex;
flex-wrap: wrap;
gap: 1ch 0.3lh;
}
code {
color: #000;
background-color: #d3d3d3;

File diff suppressed because it is too large Load diff

View file

@ -5,100 +5,81 @@ template(name="header")
Reddit "subreddit" bar.
The first link goes to the boards page.
if currentUser
#header-quick-access(class=currentBoard.colorClass)
#header-quick-access(class="currentBoard.colorClass {{#if isMiniScreen}}mobile-view{{/if}}")
// Home icon - always at left side of logo
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
i.fa.fa-home
| {{_ 'all-boards'}}
#header-quick-access-left
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
span.emoji-icon
i.fa.fa-home
span
| {{_ 'all-boards'}}
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
if currentSetting.customTopLeftCornerLogoImageUrl
if currentSetting.customTopLeftCornerLogoLinkUrl
a(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0")
unless currentSetting.customTopLeftCornerLogoLinkUrl
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
unless currentSetting.customTopLeftCornerLogoImageUrl
div#headerIsSettingDatabaseCallDone
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
// Zoom controls - always visible
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Drag handles toggle - between zoom and mobile mode toggle
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
i.fa.fa-arrows
if isShowDesktopDragHandles
i.fa.fa-check
unless isShowDesktopDragHandles
i.fa.fa-ban
if isMiniScreen
ul.header-quick-access-list
if currentList
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list
+viewer
= title
else
if isMiniScreen
ul.header-quick-access-list
if currentList
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list.
+viewer
= title
else
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
ul.header-quick-access-list
//li
// a(href="{{pathFor 'public'}}")
// span.fa.fa-globe
// | {{_ 'public'}}
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
li.current.empty(title="{{_ 'quick-access-description'}}")
| {{_ 'quick-access-description'}}
#header-new-board-icon
// Next line is used only for spacing at header,
// there is no visible clickable icon.
#header-new-board-icon
else
ul.header-quick-access-list
//li
// a(href="{{pathFor 'public'}}")
// span.fa.fa-globe
// | {{_ 'public'}}
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
// Hide duplicate create board button,
// because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
// i.fa.fa-plus(title="Create a new board")
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
.logo-container
if currentSetting.customTopLeftCornerLogoImageUrl
if currentSetting.customTopLeftCornerLogoLinkUrl
a.logo(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
+logo
else
+logo
else
li.current.empty(title="{{_ 'quick-access-description'}}")
| {{_ 'quick-access-description'}}
#header-new-board-icon
// Next line is used only for spacing at header,
// there is no visible clickable icon.
#header-new-board-icon
// Hide duplicate create board button,
// because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
// i.fa.fa-plus(title="Create a new board")
div#headerIsSettingDatabaseCallDone.logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Notifications
+notifications
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
+headerUserBar
#header-quick-access-right
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
#header-quick-access-icons
+headerUserBar
// Notifications
+notifications
#header(class=currentBoard.colorClass)
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
If the user is not connected we display "sign in" and "log in" buttons.
#header-main-bar(class="{{#if wrappedHeader}}wrapper{{/if}}")
#header-main-bar(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if wrappedHeader}}wrapper{{/if}}")
+Template.dynamic(template=headerBar)
if appIsOffline
@ -122,3 +103,7 @@ template(name="offlineWarning")
| {{_ 'app-is-offline'}}
a.app-try-reconnect {{_ 'app-try-reconnect'}}
//- a little helper to avoid duplication
template(name="logo")
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" style="{{#if currentSetting.customTopLeftCornerLogoHeight}}min-height: #{currentSetting.customTopLeftCornerLogoHeight};{{/if}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")

View file

@ -22,13 +22,13 @@ Template.header.onCreated(function () {
)
document.getElementById(
'headerIsSettingDatabaseCallDone',
).style.display = 'none';
).style.visibility = 'hidden';
else if (
document.getElementById('headerIsSettingDatabaseCallDone') != null
)
document.getElementById(
'headerIsSettingDatabaseCallDone',
).style.display = 'block';
).style.visibility = 'visible';
return this.stop();
},
});
@ -57,14 +57,6 @@ Template.header.helpers({
return announcements && announcements.body;
},
zoomLevel() {
const sessionZoom = Session.get('wekan-zoom-level');
if (sessionZoom !== undefined) {
return Math.round(sessionZoom * 100);
}
return Math.round(Utils.getZoomLevel() * 100);
},
mobileMode() {
const sessionMode = Session.get('wekan-mobile-mode');
if (sessionMode !== undefined) {
@ -76,51 +68,6 @@ Template.header.helpers({
Template.header.events({
'click .js-create-board': Popup.open('headerBarCreateBoard'),
'click .js-zoom-level-click'(evt) {
const $zoomDisplay = $(evt.currentTarget).find('.zoom-display');
const $zoomInput = $(evt.currentTarget).find('.zoom-input');
// Hide display, show input
$zoomDisplay.hide();
$zoomInput.show().focus().select();
},
'keypress .js-zoom-input'(evt) {
if (evt.which === 13) {
// Enter key
const newZoomPercent = parseInt(evt.target.value);
if (
!isNaN(newZoomPercent) &&
newZoomPercent >= 50 &&
newZoomPercent <= 300
) {
const newZoom = newZoomPercent / 100;
Utils.setZoomLevel(newZoom);
// Hide input, show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
} else {
alert('Please enter a zoom level between 50% and 300%');
evt.target.focus().select();
}
}
},
'blur .js-zoom-input'(evt) {
// When input loses focus, hide it and show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
},
'click .js-mobile-mode-toggle'() {
const currentMode = Utils.getMobileMode();
Utils.setMobileMode(!currentMode);
},
'click .js-open-bookmarks'(evt) {
// Already added but ensure single definition -- safe guard
},

View file

@ -12,7 +12,7 @@
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
padding: 5px 8px;
margin: 5px;
font-size: 18px;
}
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-action {
font-size: 1.4em;

View file

@ -1,7 +1,33 @@
* {
-webkit-box-sizing: unset;
box-sizing: unset;
/* Global variables that we can use to easily test and change layout
Later it could be useful to use a CSS superset */
/* this makes the property computable */
@property --popup-margin {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
:root {
scroll-behavior: smooth;
--label-height: 1.7lh;
--header-scale: clamp(1rem, 1.333rem + -0.333vw, 1.3rem)
--popup-margin: 2vmax;
/* regarding fonts, this is one of the clearest I found: https://modern-fluid-typography.vercel.app/ */
&:has(body.desktop-mode) {
font-size: clamp(1rem, 1.68rem + -0.57vw, 1.4rem);
--quick-header-scale: clamp(0.8rem, 0.6rem + 0.4vw, 1.2rem);
--list-item-size: 1.2em;
}
&:has(body.mobile-mode) {
font-size: clamp(2.5rem, 3vw + 1.7rem, 3.5rem);
--quick-header-scale: 1.3em;
--header-scale: clamp(1rem, -0.5vw + 1.25rem, 1.125rem);
--list-item-size: 1.6em;
}
}
/* Fixed missing 'import nib' stylesheet reset and extra li bullet points
* https://github.com/wekan/wekan/issues/4512#issuecomment-1129347536
*/
@ -32,29 +58,26 @@ a:focus {
color: unset;
text-decoration: unset;
}
.badge {
display: unset;
min-width: unset;
padding: unset;
font-size: unset;
font-weight: unset;
line-height: unset;
color: unset;
text-align: unset;
white-space: unset;
vertical-align: unset;
background-color: unset;
border-radius: unset;
display: flex;
gap: 0 0.3ch;
align-items: center;
}
body {
/* changed programmatically on swimlane resizes, or e.g. when un-collapsed */
transition: height 0.2s ease-out, width 0.2s ease-out;
}
html,
body,
input,
select,
textarea,
button {
font: clamp(14px, 2.5vw, 18px) Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 1.4;
color: #4d4d4d;
font-family: Roboto, Poppins, "Helvetica Neue", "Liberation Sans", Arial, Helvetica, sans-serif;
color: hsl(0, 0%, 30%);
/* Improve text rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@ -63,58 +86,74 @@ button {
user-select: text;
}
html {
font-size: 100%;
max-height: 100%;
-webkit-user-select: text;
user-select: text;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
text-size-adjust: 100%;
overscroll-behavior: none;
}
body {
background: #dedede;
margin: 0;
position: relative;
z-index: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 100vh;
/* iOS Safari fixes */
-webkit-overflow-scrolling: touch;
align-items: stretch;
justify-content: start;
/* height is auto; if set to 100vh, it prevents navbar to disappear on scroll... */
width: 100%;
/* Needs to be set on body and html. Feels ok to disable entirely as Wekan is really drag/scroll-heavy */
overscroll-behavior: none;
min-height: 100vh;
line-height: 1.4;
}
/* Mobile mode specific fixes for iOS Safari */
body.mobile-mode {
overflow-x: hidden;
position: fixed;
width: 100%;
height: 100vh;
/* Prevent iOS Safari bounce scroll */
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Ensure content area is scrollable in mobile mode */
body.mobile-mode #content {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 48px);
}
/* Prevent scroll through popups */
body:has(.pop-over:hover) {
overflow: hidden;
}
/* Some forms will need extra adjustement (removing margins, etc)
but it worth it to let browsers take care of exact placement/sizing */
.inlined-form {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 0.3lh;
width: 100%;
}
#content {
display: flex;
position: relative;
flex: 1;
overflow-x: hidden;
margin-bottom: 1vh;
min-height: 100vh;
max-width: min(100%, 100vw);
}
#content .sk-spinner {
margin-top: 30vh;
}
#content > .wrapper {
margin-top: 1vh;
padding: 2vh 2vw;
}
#modal {
position: absolute;
top: 0;
@ -157,25 +196,6 @@ body.mobile-mode #content {
#modal .modal-content-wide .modal-close-btn {
display: block;
float: right;
font-size: clamp(18px, 4vw, 24px);
}
h1 {
font-size: clamp(18px, 4vw, 24px);
line-height: 1.2em;
margin: 0 0 1vh;
}
h2 {
font-size: clamp(16px, 3.5vw, 20px);
line-height: 1.2em;
margin: 0 0 0.8vh;
}
h3,
h4,
h5,
h6 {
font-size: clamp(14px, 3vw, 18px);
line-height: 1.25em;
margin: 0 0 0.6vh;
}
.quiet,
.quiet a {
@ -226,7 +246,7 @@ p {
}
p a {
text-decoration: underline;
word-wrap: break-word;
overflow-wrap: break-word;
}
table,
p {
@ -250,13 +270,13 @@ blockquote {
padding: 0 0 0 1vw;
}
hr {
height: 1px;
height: 0.2ch;
border: 0;
border: none;
width: 100%;
background: #dbdbdb;
color: #dbdbdb;
margin: 2vh 0;
margin: 0.2lh 0;
padding: 0;
}
table,
@ -303,7 +323,7 @@ kbd {
clear: both;
}
.hide {
display: none;
display: none !important;
}
.show {
display: block;
@ -337,8 +357,11 @@ kbd {
padding-bottom: 0;
}
.wrapper {
width: calc(100% - 2vw);
margin: 0 auto;
margin: 0;
flex: 1;
width: auto;
height: fit-content;
display: grid;
}
.relative {
position: relative;
@ -369,8 +392,12 @@ kbd {
.invisible {
visibility: hidden;
}
.invisible-line {
height: 1.3lh;
visibility: hidden;
}
.wrapword {
word-wrap: break-word;
overflow-wrap: break-word;
}
.grab {
cursor: grab;
@ -445,8 +472,39 @@ a:not(.disabled).is-active i.fa {
}
.viewer {
min-height: 2.5vh;
display: block;
word-wrap: break-word;
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
/* a tentative to get layout less dependant of content,
especially for small elements e.g. labels: the goal is that
content will be cut with `...` if too large (but will be fully
rendered in dedicated interfaces)
the classic technique is to use flex-basis, but it depends
on the parent not overflowing to get the right size; also,
specifying in terms of lines makes the browser act clever, by
fitting the available space and cutting after N lines, whatever
is the text's length */
min-width: 0;
p, ul {
margin: 0;
padding: 0;
text-overflow: ellipsis;
overflow: hidden;
/* See https: //css-tricks.com/line-clampin/,
it is widely supported and waiting standardization https: //caniuse.com/?search=-webkit-line-clamp */
display: -webkit-box !important;
/* 0 has no effect; ensures will not interfere unless asked */
-webkit-line-clamp: var(--overflow-lines, 0);
-webkit-box-orient: vertical;
-webkit-align-items: center;
/* grid properties apply */
align-content: center;
word-break: break-word;
white-space: normal;
}
}
.viewer table {
word-wrap: normal;
@ -481,6 +539,12 @@ a:not(.disabled).is-active i.fa {
padding: 0;
padding-top: 15px;
}
.basicTabs-container .tabs-list .tab-item {
/* where does templates_tabs.css come from? visible in
devtools but not in sources */
font-size: unset !important;
}
.no-scrollbars {
scrollbar-width: none;
}
@ -495,133 +559,30 @@ a:not(.disabled).is-active i.fa {
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) and (orientation: portrait) {
#content {
margin: 1px 0px 0px 0px;
height: calc(100% - 0px);
/* Improve touch scrolling */
-webkit-overflow-scrolling: touch;
}
#content > .wrapper {
margin-top: 0px;
padding: 8px;
}
.wrapper {
height: calc(100% - 31px);
margin: 0px;
padding: 8px;
}
.panel-default {
width: 95vw;
max-width: 95vw;
margin: 0 auto;
}
/* Improve touch targets */
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
font-size: 16px; /* Prevent zoom on iOS */
/* Prevent zoom on iOS */
touch-action: manipulation;
}
/* Form elements */
input, select, textarea {
font-size: 16px; /* Prevent zoom on iOS */
/* Prevent zoom on iOS */
padding: 12px;
min-height: 44px;
touch-action: manipulation;
}
/* Cards and lists */
.minicard {
min-height: 48px;
padding: 12px;
margin-bottom: 8px;
touch-action: manipulation;
}
.list {
margin: 0 8px;
min-width: 280px;
}
/* Board canvas */
.board-canvas {
padding: 0 8px 8px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Header mobile layout */
#header {
padding: 8px;
/* Keep top bar on a single row on small screens */
flex-wrap: nowrap;
align-items: center;
gap: 8px;
}
#header-quick-access {
/* Keep quick-access items in one row */
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
}
/* Hide elements that should move to the hamburger menu on mobile */
#header-quick-access .header-quick-access-list,
#header-quick-access #header-help {
display: none !important;
}
/* Show only the home icon (hide the trailing text) on mobile */
#header-quick-access .home-icon a {
display: inline-flex;
align-items: center;
max-width: 28px; /* enough to display the icon */
overflow: hidden;
white-space: nowrap;
}
/* Hide text in home icon on mobile, show only icon */
#header-quick-access .home-icon a span:not(.fa) {
display: none !important;
}
/* Ensure proper spacing for mobile header elements */
#header-quick-access .zoom-controls {
margin-left: auto;
margin-right: 8px;
}
.mobile-mode-toggle {
margin-right: 8px;
}
#header-user-bar {
margin-left: auto;
}
/* Ensure header elements don't wrap on very small screens */
#header-quick-access {
min-width: 0; /* Allow flexbox to shrink */
}
/* Make sure logo doesn't take too much space on mobile */
#header-quick-access img {
max-height: 24px;
max-width: 120px;
}
/* Ensure zoom controls are compact on mobile */
.zoom-controls .zoom-level {
padding: 4px 8px;
font-size: 12px;
}
/* Modal mobile optimization */
#modal .modal-content,
#modal .modal-content-wide {
@ -632,29 +593,28 @@ a:not(.disabled).is-active i.fa {
max-height: 90vh;
overflow-y: auto;
}
/* Table mobile optimization */
table {
font-size: 14px;
width: 100%;
display: block;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
/* Admin panel mobile optimization */
.setting-content .content-body {
flex-direction: column;
gap: 16px;
padding: 8px;
}
.setting-content .content-body .side-menu {
width: 100%;
order: 2;
}
.setting-content .content-body .main-body {
order: 1;
min-height: 60vh;
@ -663,139 +623,175 @@ a:not(.disabled).is-active i.fa {
}
}
<<<<<<< HEAD
/* Tablet devices (768px - 1024px) */
@media screen and (min-width: 768px) and (max-width: 1024px) {
#content > .wrapper {
padding: 12px;
}
.wrapper {
padding: 12px;
}
.panel-default {
width: 90vw;
max-width: 90vw;
}
/* Touch-friendly but more compact */
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 48px;
min-width: 48px;
padding: 10px 14px;
}
.minicard {
min-height: 40px;
padding: 10px;
}
.list {
margin: 0 12px;
min-width: 300px;
}
.board-canvas {
padding: 0 12px 12px 0;
}
#header {
padding: 12px 16px;
}
#modal .modal-content {
width: 80vw;
max-width: 600px;
}
#modal .modal-content-wide {
width: 90vw;
max-width: 800px;
}
.setting-content .content-body {
gap: 20px;
}
.setting-content .content-body .side-menu {
width: 250px;
}
/* Responsive handling for quick-access description on tablets */
#header-quick-access ul.header-quick-access-list li.current.empty {
max-width: 300px;
}
}
||||||| parent of 2e0149f79 (🚧 Remove zoom/mobile option, rework header/misc layout to be more responsive)
/* Tablet devices (768px - 1024px) */
@media screen and (min-width: 768px) and (max-width: 1024px) {
#content > .wrapper {
padding: 12px;
}
.wrapper {
padding: 12px;
}
.panel-default {
width: 90vw;
max-width: 90vw;
}
/* Touch-friendly but more compact */
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 48px;
min-width: 48px;
padding: 10px 14px;
}
.minicard {
min-height: 40px;
padding: 10px;
}
.list {
margin: 0 12px;
min-width: 300px;
}
.board-canvas {
padding: 0 12px 12px 0;
}
#header {
padding: 12px 16px;
}
#modal .modal-content {
width: 80vw;
max-width: 600px;
}
#modal .modal-content-wide {
width: 90vw;
max-width: 800px;
}
.setting-content .content-body {
gap: 20px;
}
.setting-content .content-body .side-menu {
width: 250px;
}
}
=======
>>>>>>> 2e0149f79 (🚧 Remove zoom/mobile option, rework header/misc layout to be more responsive)
/* Large displays and digital signage (1920px+) */
@media screen and (min-width: 1920px) {
body {
font-size: 18px;
}
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 56px;
min-width: 56px;
padding: 16px 20px;
font-size: 18px;
}
.minicard {
min-height: 56px;
padding: 16px;
font-size: 18px;
}
.list {
margin: 0 8px;
min-width: 360px;
}
.board-canvas {
padding: 0;
}
#header {
padding: 0 8px;
}
#content > .wrapper {
padding: 0;
}
#modal .modal-content {
width: 600px;
}
#modal .modal-content-wide {
width: 1000px;
}
.setting-content .content-body {
gap: 32px;
}
.setting-content .content-body .side-menu {
width: 320px;
}
}
.inline-input {
height: 37px;
margin: 8px 10px 0 0;
width: 100px;
.ui-sortable-handle {
cursor: grab !important;
}
.select-authentication {
width: 100%;
}
.textBelowCustomLoginLogo,
.auth-layout {
#rescue-card-description {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.auth-layout .auth-dialog {
margin: 0 !important;
flex: 1 0 auto;
align-self: center;
margin: 0 0.2lh;
}
.loadingText {
text-align: center;
@ -882,8 +878,18 @@ a:not(.disabled).is-active i.fa {
text-decoration: underline;
text-decoration-color: #17683a;
}
/*
Prevents popups to compute real size, trying to comment
.at-pwd-form, .at-sep, .at-oauth {
display: none;
}*/
#at-pwd-form {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: stretch;
gap: 0.3lh;
}
@-moz-keyframes fadeIn {
from {
@ -928,31 +934,19 @@ a:not(.disabled).is-active i.fa {
/* iOS Safari Mobile Mode Fixes */
@media screen and (max-width: 800px) {
/* Prevent scrolling issues on iOS Safari when card popup is open */
body.mobile-mode {
overflow: hidden;
position: fixed;
width: 100%;
height: 100vh;
}
/* Fix z-index stacking for mobile Safari */
body.mobile-mode .board-wrapper {
z-index: 1;
}
body.mobile-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
body.mobile-mode .card-details {
z-index: 100 !important;
}
body.mobile-mode .pop-over {
z-index: 999;
}
/* Ensure smooth scrolling on iOS */
body.mobile-mode .card-details,
body.mobile-mode .pop-over .content-wrapper {

View file

@ -23,61 +23,56 @@ template(name="main")
//link(rel="stylesheet" type="text/css" class="__meteor-css__" href="css/html5-default-theme.css")
template(name="userFormsLayout")
section.auth-layout
if currentSetting.hideLogo
h1.at-form-landing-logo
br
br
unless currentSetting.hideLogo
h1.at-form-landing-logo
if currentSetting.customLoginLogoImageUrl
if currentSetting.customLoginLogoLinkUrl
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
img(src="{{currentSetting.customLoginLogoImageUrl}}" width="300" height="auto")
.auth-container
section.auth-layout.auth-logo
if currentSetting.hideLogo
h1.at-form-landing-logo
unless currentSetting.hideLogo
if currentSetting.customLoginLogoImageUrl
if currentSetting.customLoginLogoLinkUrl
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
img(src="{{currentSetting.customLoginLogoImageUrl}}")
unless currentSetting.customLoginLogoLinkUrl
a
img(src="{{currentSetting.customLoginLogoImageUrl}}")
else
a
img(src="{{pathFor '/wekan-logo.svg'}}" alt="")
br
unless currentSetting.customLoginLogoLinkUrl
img(src="{{currentSetting.customLoginLogoImageUrl}}" width="300" height="auto")
br
else
img(src="{{pathFor '/wekan-logo.svg'}}" alt="" width="300" height="auto")
br
if currentSetting.textBelowCustomLoginLogo
hr
section.textBelowCustomLoginLogo
+viewer
| {{currentSetting.textBelowCustomLoginLogo}}
hr
section.auth-layout
section.auth-dialog
if isLoading
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
+Template.dynamic(template=content)
if currentSetting.displayAuthenticationMethod
+connectionMethod(authenticationMethod=currentSetting.defaultAuthenticationMethod)
if isLegalNoticeLinkExist
div#legalNoticeDiv
span#legalNoticeSpan {{_ 'acceptance_of_our_legalNotice'}}
a#legalNoticeAtLink.at-link(href="{{currentSetting.legalNotice}}", target="_blank", rel="noopener noreferrer")
| {{_ 'legalNotice'}}
if getLegalNoticeWithWritTraduction
div
div.at-form-lang
label(for="userform-set-language-select") {{_ 'changeLanguagePopup-title'}}
select.select-lang.js-userform-set-language#userform-set-language-select(aria-label="{{_ 'changeLanguagePopup-title'}}")
each languages
if isCurrentLanguage
if rtl
option(value="{{tag}}" selected="selected") {{name}} (RTL)
section.auth-custom-text
if currentSetting.textBelowCustomLoginLogo
section.textBelowCustomLoginLogo
+viewer
| {{currentSetting.textBelowCustomLoginLogo}}
section.auth-layout.auth-form
section.auth-dialog
if isLoading
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f;")
+Template.dynamic(template=content)
if currentSetting.displayAuthenticationMethod
+connectionMethod(authenticationMethod=currentSetting.defaultAuthenticationMethod)
if isLegalNoticeLinkExist
div#legalNoticeDiv
span#legalNoticeSpan {{_ 'acceptance_of_our_legalNotice'}}
a#legalNoticeAtLink.at-link(href="{{currentSetting.legalNotice}}", target="_blank", rel="noopener noreferrer")
| {{_ 'legalNotice'}}
div.at-form-lang
label(for="userform-set-language-select") {{_ 'changeLanguagePopup-title'}}
select.select-lang.js-userform-set-language#userform-set-language-select(aria-label="{{_ 'changeLanguagePopup-title'}}")
each languages
if isCurrentLanguage
if rtl
option(value="{{tag}}" selected="selected") {{name}} (RTL)
else
option(value="{{tag}}" selected="selected") {{name}}
else
option(value="{{tag}}" selected="selected") {{name}}
else
if rtl
option(value="{{tag}}") {{name}} (RTL)
else
option(value="{{tag}}") {{name}}
if rtl
option(value="{{tag}}") {{name}} (RTL)
else
option(value="{{tag}}") {{name}}
template(name="defaultLayout")
+header

View file

@ -1,22 +1,18 @@
.my-cards-board-wrapper {
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
border-width: 0.3vw;
border-style: solid;
border-color: #a2a2a2;
body.mobile-mode {
.my-cards-board-wrapper {
width: 100vw;
}
.my-cards-swimlane-body {
grid-auto-flow: row;
}
}
.my-cards-board-title {
font-size: clamp(1.2rem, 3vw, 1.6rem);
font-weight: bold;
padding: 0.7vh 0.7vw;
background-color: #808080;
color: #fff;
.my-cards-swimlane-body {
display: grid;
grid-auto-flow: column;
gap: 1ch;
}
.my-cards-swimlane-title {
font-size: clamp(1rem, 2.5vw, 1.3rem);
font-size: clamp(1em, 2.5vw, 1.3rem);
font-weight: bold;
padding: 0.7vh 0.7vw;
padding-bottom: 0.5vh;
@ -27,48 +23,12 @@
.swimlane-default-color {
background-color: #d3d3d3;
}
.my-cards-list-title {
font-weight: bold;
font-size: clamp(1rem, 2.5vw, 1.3rem);
text-align: center;
margin-bottom: 0.9vh;
}
.my-cards-list-wrapper {
margin: 1.3vh 1.3vw;
border-radius: 0.7vw;
display: inline-grid;
min-width: min(250px, 32vw);
max-width: min(350px, 45vw);
display: flex;
flex-direction: column;
max-width: clamp(300px, 20vw, 30vw);
}
.my-cards-card-wrapper {
margin-top: 0;
margin-bottom: 1.3vh;
}
.my-cards-dueat-list-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
}
.my-cards-board-table thead {
border-bottom: 3px solid #4d4d4d;
background-color: transparent;
}
.my-cards-board-table th,
.my-cards-board-table td {
border: 0;
}
.my-cards-board-table tr {
border-bottom: 2px solid #a2a2a2;
}
.my-cards-card-title-table {
font-weight: bold;
padding-left: 2px;
max-width: 243px;
}
.my-cards-board-badge {
width: 36px;
height: 24px;
float: left;
border-radius: 5px;
margin-right: 5px;
body.mobile-mode .my-cards-list-wrapper {
max-width: unset;
}

View file

@ -39,15 +39,16 @@ template(name="myCards")
.my-cards-swimlane-title(class="{{#if swimlane.colorClass}}{{ swimlane.colorClass }}{{else}}swimlane-default-color{{/if}}")
+viewer
= swimlane.title
each list in swimlane.myLists
.my-cards-list-wrapper
.my-cards-list-title(class=list.colorClass)
+viewer
= list.title
each card in list.myCards
.my-cards-card-wrapper
a.minicard-wrapper(href=card.originRelativeUrl)
+minicard(card)
.my-cards-swimlane-body
each list in swimlane.myLists
.my-cards-list-wrapper
.my-cards-list-title(class=list.colorClass)
+viewer
= list.title
each card in list.myCards
.my-cards-card-wrapper
a.minicard-wrapper(href=card.originRelativeUrl)
+minicard(card)
if $eq myCardsView 'table'
.wrapper
table.my-cards-board-table

View file

@ -1,91 +1,121 @@
.pop-over {
background: #fff;
border-radius: 0.4vw;
border: 1px solid #dbdbdb;
background: #ededed;
border-bottom-color: #c2c2c2;
box-shadow: 0 0.2vh 0.8vh rgba(0,0,0,0.3);
position: absolute;
/* Wider default to fit full color palette */
width: min(380px, 55vw);
z-index: 99999;
margin-top: 0.7vh;
box-shadow: 0 0.2vh 0.8vh rgba(0, 0, 0, 0.3);
/* so they can easily travel with mouse */
position: fixed;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: stretch;
resize: both;
pointer-events: all;
max-height: 100vh;
.content-wrapper {
width: auto;
height: auto;
position: relative;
overflow-y: auto;
}
.content-wrapper >* {
/* low specificity so that it can be transparently overriden,
but could have side effects if no display is explicitely specific in inner content */
display: flex;
flex: 1;
flex-direction: column;
width: auto;
height: auto;
}
}
.pop-over a:has(.fa-plus)+ :not(*) {
min-height: 1.5lh;
aspect-ratio: 1/1;
display: flex;
justify-content: center;
margin-top: 0.2lh;
}
.pop-over hr {
margin: 0.5vh 0px;
margin: 0.3lh 0;
/* below everything in the same stacking context when
after, child or explicit z-index */
z-index: 0;
}
.pop-over p,
.pop-over textarea,
.pop-over input[type="text"],
.pop-over input[type="email"],
.pop-over input[type="password"],
.pop-over input[type="file"] {
width: 100%;
.pop-over {
/* feels like it's too ad-hod */
input, a:not(.js-board-template, .member, .edit-avatar) {
display: inline-flex;
align-items: center;
gap: 1ch;
min-height: 1.5lh;
}
}
.pop-over select {
width: 100%;
margin-bottom: 1.8vh;
}
.pop-over textarea {
height: 9vh;
}
.pop-over form a span {
padding: 0 0.7vw;
.pop-over .sub-name {
max-width: clamp(30vw, 500px, 80%);
}
.pop-over .header {
height: 4.5vh;
position: relative;
margin-bottom: 1vh;
display: flex;
justify-content: space-between;
gap: 1ch;
align-items: center;
padding: 0 1ch;
background: #f7f7f7;
border-bottom: 1px solid #dcdcdc;
color: #666;
min-height: 2lh;
}
.pop-over .header .header-title {
display: block;
line-height: 4vh;
padding-top: 0.5vh;
margin: 0 1.3vw;
display: flex;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.2em;
flex: 1;
cursor: grab !important;
}
.pop-over .header .back-btn {
.pop-over .back-btn {
float: left;
overflow: hidden;
width: 4vw;
transition: width 0.2s;
}
.pop-over .header .back-btn i.fa {
margin: 1.3vw;
margin-top: 1.5vh;
}
.pop-over .header .back-btn.is-hidden {
.pop-over .back-btn.is-hidden {
width: 0;
}
.pop-over .header .close-btn {
padding: 1.3vh 1.3vw 1.3vh 0.5vw;
position: absolute;
top: 0;
right: 0;
}
.pop-over.no-title .header {
background: none;
}
.pop-over .content-wrapper {
width: 100%;
max-height: calc(70vh + 20px);
overflow-y: auto;
overflow-x: hidden;
.pop-over {
.content-wrapper, .header {
display: flex;
align-items: center;
}
}
/* Allow dynamic max-height to override default constraint */
.pop-over[style*="max-height"] .content-wrapper {
max-height: inherit;
.pop-over:has(.header) .content {
/* inner content has full width available,
so it is also responsive for margins, sizes, etc */
overflow-y: auto;
}
.popup-placeholder {
/* This gives relative coordinates but height/width cannot fit the parent's
without it having position: relative; we need to get them programmatically */
position: absolute;
/* Take all size of parent so it can be useful in computations */
visibility: hidden;
display: none;
}
.pop-over .content-container {
width: 100%;
max-height: calc(70vh + 20px);
transition: transform 0.2s;
display: flex;
align-items: stretch;
flex: 1;
}
/* Allow dynamic max-height to override default constraint for content-container */
@ -93,270 +123,42 @@
max-height: inherit;
}
/* Fix overflow in the Member Settings (member menu) popup:
the popup itself gets a max-height inline style, but the header consumes space.
Make the header overlay the scrollable area so the list can't spill out. */
.pop-over[data-popup="memberMenuPopup"] {
overflow: hidden;
}
.pop-over[data-popup="memberMenuPopup"] > .header {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-bottom: 0;
z-index: 1;
}
.pop-over[data-popup="memberMenuPopup"] > .content-wrapper {
padding-top: calc(4.5vh + 1vh);
box-sizing: border-box;
.pop-over .popup-drag-handle {
cursor: move;
}
/* Admin edit popups: use full height */
.pop-over[data-popup="editUserPopup"],
.pop-over[data-popup="editOrgPopup"],
.pop-over[data-popup="editTeamPopup"] {
height: calc(100vh - 20px) !important;
max-height: calc(100vh - 20px) !important;
body.mobile-mode {
.popup-drag-handle, .close-btn {
font-size: 1.4em;
align-self: center;
}
.pop-over:has(.pop-over-list) {
min-width: 70vw;
}
}
.pop-over[data-popup="editUserPopup"] .content-wrapper,
.pop-over[data-popup="editOrgPopup"] .content-wrapper,
.pop-over[data-popup="editTeamPopup"] .content-wrapper {
max-height: calc(100vh - 80px) !important; /* Subtract header height */
height: calc(100vh - 80px) !important;
overflow-y: auto !important;
.pop-over .header-controls {
display: flex;
gap: 1ch;
}
.pop-over[data-popup="editUserPopup"] .content-container,
.pop-over[data-popup="editOrgPopup"] .content-container,
.pop-over[data-popup="editTeamPopup"] .content-container {
max-height: calc(100vh - 80px) !important; /* Subtract header height */
height: calc(100vh - 80px) !important;
}
/* Ensure language popup list can scroll properly */
.pop-over .pop-over-list {
max-height: none;
overflow: visible;
}
/* Specific styling for language popup list */
.pop-over[data-popup="changeLanguagePopup"] .pop-over-list {
max-height: none;
overflow: visible;
height: auto;
flex: 1;
}
/* Ensure content div in language popup contains all items */
.pop-over[data-popup="changeLanguagePopup"] .content {
height: auto;
/* Remove forced min-height to avoid top gap */
display: flex;
flex-direction: column;
flex: 1;
font-size: 1.1rem;
padding: 0 1ch;
>li>a {
display: grid;
grid-auto-flow: column;
grid-auto-columns: fit-content;
justify-content: start;
padding: 0 0.5ch;
column-gap: 1ch;
.sub-name {
text-align: end;
}
}
}
/* Ensure hidden stack pages truly take no space */
.pop-over[data-popup="changeLanguagePopup"] .content.no-height {
min-height: 0 !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
visibility: hidden !important;
}
/* Make language popup extend to bottom of browser window */
.pop-over[data-popup="changeLanguagePopup"] {
position: fixed !important;
bottom: 0 !important;
top: auto !important;
left: auto !important;
right: 20px !important;
width: auto !important;
max-width: 450px !important;
height: 100vh !important;
max-height: 100vh !important;
min-height: 300px !important;
display: flex !important;
flex-direction: column !important;
margin: 0 !important;
}
/* Allow dynamic height for Change Language popup */
.pop-over[data-popup="changeLanguagePopup"] .header {
flex-shrink: 0 !important;
height: auto !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content-wrapper {
flex: 1 !important;
overflow-y: auto !important;
overflow-x: hidden !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
width: 100% !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content-container {
height: auto !important;
max-height: none !important;
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
width: 100% !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content {
height: auto !important;
max-height: none !important;
padding-bottom: 50px !important;
width: 100% !important;
}
/* Date popup sizing for native HTML inputs */
.pop-over[data-popup="editCardReceivedDatePopup"],
.pop-over[data-popup="editCardStartDatePopup"],
.pop-over[data-popup="editCardDueDatePopup"],
.pop-over[data-popup="editCardEndDatePopup"],
.pop-over[data-popup*="Date"] {
width: min(400px, 90vw) !important; /* Smaller width for native inputs */
min-width: 350px !important;
max-height: 80vh !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardStartDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardDueDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardEndDatePopup"] .content-wrapper,
.pop-over[data-popup*="Date"] .content-wrapper {
max-height: 60vh !important;
overflow-y: auto !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .content-container,
.pop-over[data-popup="editCardStartDatePopup"] .content-container,
.pop-over[data-popup="editCardDueDatePopup"] .content-container,
.pop-over[data-popup="editCardEndDatePopup"] .content-container,
.pop-over[data-popup*="Date"] .content-container {
max-height: 60vh !important;
}
/* Native HTML input styling */
.pop-over[data-popup*="Date"] .datepicker-container {
width: 100% !important;
padding: 15px !important;
}
.pop-over[data-popup*="Date"] .datepicker-container .fields {
display: flex !important;
gap: 15px !important;
margin-bottom: 15px !important;
}
.pop-over[data-popup*="Date"] .datepicker-container .fields .left,
.pop-over[data-popup*="Date"] .datepicker-container .fields .right {
flex: 1 !important;
width: auto !important;
}
.pop-over[data-popup*="Date"] .datepicker-container label {
display: block !important;
margin-bottom: 5px !important;
font-weight: bold !important;
}
.pop-over[data-popup*="Date"] .datepicker-container input[type="date"],
.pop-over[data-popup*="Date"] .datepicker-container input[type="time"] {
width: 100% !important;
padding: 8px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
font-size: 14px !important;
box-sizing: border-box !important;
}
.pop-over[data-popup*="Date"] .datepicker-container input[type="date"]:focus,
.pop-over[data-popup*="Date"] .datepicker-container input[type="time"]:focus {
outline: none !important;
border-color: #007cba !important;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2) !important;
}
/* Ensure date popup buttons stay within popup boundaries */
.pop-over[data-popup="editCardReceivedDatePopup"] .content,
.pop-over[data-popup="editCardStartDatePopup"] .content,
.pop-over[data-popup="editCardDueDatePopup"] .content,
.pop-over[data-popup="editCardEndDatePopup"] .content,
.pop-over[data-popup*="Date"] .content {
max-height: 60vh !important; /* Leave space for buttons */
overflow-y: auto !important;
padding-bottom: 100px !important; /* More space for buttons */
margin-bottom: 0 !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardStartDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardDueDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardEndDatePopup"] .datepicker-container,
.pop-over[data-popup*="Date"] .datepicker-container {
max-height: 50vh !important; /* Limit calendar height */
overflow-y: auto !important;
margin-bottom: 20px !important; /* Space before buttons */
}
/* Ensure buttons are properly positioned */
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date,
.pop-over[data-popup*="Date"] .edit-date {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date .fields,
.pop-over[data-popup*="Date"] .edit-date .fields {
flex-shrink: 0 !important;
margin-bottom: 15px !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup*="Date"] .edit-date .js-datepicker {
flex: 1 !important;
overflow-y: auto !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date button,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date button,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date button,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date button,
.pop-over[data-popup*="Date"] .edit-date button {
flex-shrink: 0 !important;
margin-top: 15px !important;
position: relative !important;
z-index: 10 !important;
}
.pop-over .content-container .content {
/* Match wider popover, leave padding */
width: 100%;
padding: 0 1.3vw 1.3vh;
box-sizing: border-box;
/* Ensure content is not shifted left */
margin-left: 0 !important;
transform: none !important;
}
/* Utility: remove left gutter inside specific popups */
.pop-over .content .flush-left {
margin-left: 0;
@ -378,58 +180,15 @@
.pop-over .content form.create-label .palette-colors {
margin-left: 0;
padding-left: 0;
width: 100%;
display: grid;
grid-template-columns: repeat(5, 1fr);
}
/* Color palette items: ensure proper positioning */
.pop-over .content .palette-colors .palette-color {
margin-left: 0;
margin-right: 2px;
margin-bottom: 2px;
}
/* Global fix for all popup content to prevent left shifting */
.pop-over .content * {
margin-left: 0 !important;
transform: none !important;
}
/* Override any potential left shifting for specific elements */
.pop-over .content form,
.pop-over .content .palette-colors,
.pop-over .content .pop-over-list,
.pop-over .content .flush-left {
margin-left: 0 !important;
padding-left: 0 !important;
transform: none !important;
}
/* Fix popup depth containers that cause left shifting */
.pop-over .popup-container-depth-1,
.pop-over .popup-container-depth-2,
.pop-over .popup-container-depth-3,
.pop-over .popup-container-depth-4,
.pop-over .popup-container-depth-5,
.pop-over .popup-container-depth-6 {
transform: none !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
/* Ensure buttons dont reserve left space; align to flow */
.pop-over .content form.swimlane-color-popup .primary.confirm,
.pop-over .content form.swimlane-color-popup .negate.wide.right,
.pop-over .content .swimlane-height-popup .primary.confirm,
.pop-over .content .swimlane-height-popup .negate.wide.right {
float: none;
margin-left: 0;
}
.pop-over .content-container .content.no-height {
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
visibility: hidden;
border-radius: 0;
outline: 0.1ch solid black;
}
.pop-over.search-over {
background: #f0f0f0;
@ -456,24 +215,6 @@
.pop-over .sk-spinner {
margin: 40px auto;
}
.pop-over .popup-container-depth-1 {
transform: translateX(-300px);
}
.pop-over .popup-container-depth-2 {
transform: translateX(-600px);
}
.pop-over .popup-container-depth-3 {
transform: translateX(-900px);
}
.pop-over .popup-container-depth-4 {
transform: translateX(-1200px);
}
.pop-over .popup-container-depth-5 {
transform: translateX(-1500px);
}
.pop-over .popup-container-depth-6 {
transform: translateX(-1800px);
}
.select-members-list,
.select-avatars-list {
margin-bottom: 8px;
@ -487,15 +228,12 @@
cursor: pointer;
display: block;
font-weight: 700;
padding: 1.5px 10px;
padding-inline: 2vmin 10vmin;
position: relative;
margin: 0;
text-decoration: none;
overflow: hidden;
line-height: 33px;
display:flex;
/* flex-wrap:wrap;*/
gap:5px;
align-items: center;
color: #000 !important;
}
@ -506,7 +244,6 @@
.pop-over-list li > a .item-name {
display: block;
width: auto;
padding-right: 22px;
}
.pop-over-list li > a:not(.disabled):hover {
background-color: #005377;
@ -522,9 +259,9 @@
.pop-over-list li > a .sub-name {
color: #8c8c8c;
display: block;
font-size: 12px;
font-size: 0.8em;
font-weight: 400;
line-height: 15px;
line-height: 1.2em;
}
.pop-over-list li > a.current {
background-color: #e2e6e9;
@ -570,156 +307,21 @@
body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
color: #7a7a7a;
}
.pop-over.miniprofile .header {
border-bottom-color: transparent;
height: 30px;
position: absolute;
right: 0;
top: 0;
width: 60px;
z-index: 1;
}
.pop-over.miniprofile .header-title {
display: none;
}
.pop-over.miniprofile .pop-over-list {
padding-top: 8px;
}
.pop-over.miniprofile .miniprofile-header {
margin-top: 8px;
min-height: 56px;
position: relative;
}
.pop-over.miniprofile .miniprofile-header .member,
.pop-over.miniprofile .miniprofile-header .avatar {
position: absolute;
top: 2px;
left: 2px;
height: 50px;
width: 50px;
}
.pop-over.miniprofile .miniprofile-header .info {
margin: 0 0 0 64px;
word-wrap: break-word;
}
.pop-over.miniprofile .miniprofile-header .info h3 a {
text-decoration: none;
}
.pop-over.miniprofile .miniprofile-header .info h3 a:hover {
text-decoration: underline;
}
@media screen and (max-width: 800px) {
.pop-over {
width: 100%;
height: 100%;
overflow: hidden;
margin-top: 0px;
border: 0px solid #dbdbdb;
/* Ensure popups appear above card details on mobile */
z-index: 999999 !important;
/* iOS Safari scrolling fix */
-webkit-overflow-scrolling: touch;
}
.pop-over .header {
color: #fff;
background: #2980b9;
height: 48px;
padding: 0px 0px;
border: 0px;
margin: 0px 0px;
width: 100%;
position: absolute;
top: 0px;
}
.pop-over .header .header-title {
font-size: 20px;
font-weight: normal;
padding-top: 8px;
}
.pop-over .header .back-btn {
width: 30px;
padding: 8px 12px 8px 12px;
}
.pop-over .header .back-btn i.fa {
color: #fff;
}
.pop-over .header .close-btn {
padding: 10px 12px;
}
.pop-over .header .close-btn i.fa {
font-size: 24px;
color: #fff;
}
.pop-over .content-wrapper {
width: 100%;
height: calc(100% - 48px);
overflow-y: scroll;
overflow-x: hidden;
margin: 48px 0px 0px 0px;
}
.pop-over .content-container {
width: 100%;
height: 100%;
max-height: 100%;
}
.pop-over .content-container .content {
width: calc(100% - 20px);
height: calc(100% - 20px);
padding: 10px;
}
.pop-over .content-container .content form {
margin: 10px 10px;
width: calc(100% - 20px);
}
.pop-over .content-container .content p,
.pop-over .content-container .content textarea,
.pop-over .content-container .content input[type="text"],
.pop-over .content-container .content input[type="email"],
.pop-over .content-container .content input[type="password"],
.pop-over .content-container .content input[type="file"] {
width: 100%;
box-sizing: border-box;
}
.pop-over .pop-over-list li > a {
width: calc(100% - 20px);
margin: 0px 0px;
}
.pop-over .popup-container-depth-1 {
transform: none !important;
}
.pop-over .popup-container-depth-2 {
transform: none !important;
}
.pop-over .popup-container-depth-3 {
transform: none !important;
}
.pop-over .popup-container-depth-4 {
transform: none !important;
}
.pop-over .popup-container-depth-5 {
transform: none !important;
}
.pop-over .popup-container-depth-6 {
transform: none !important;
}
.pop-over .content > form {
padding: 0 1ch;
gap: 0.2lh;
display: flex;
max-width: clamp(20vw, 400px, 50vw);
}
/* Force full-screen popups in mobile mode regardless of screen width */
body.mobile-mode .pop-over {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
}
body.mobile-mode .pop-over .content-wrapper {
width: 100% !important;
height: calc(100vh - 48px) !important;
max-height: calc(100vh - 48px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
body.mobile-mode .pop-over .content>form {
max-width: 100%;
}
.pop-over .board-subtask-settings {
>h3 {
display: flex;
flex-direction: column;
}
}

View file

@ -1,39 +1,696 @@
Popup.template.events({
'click .js-back-view'() {
Popup.back();
},
'click .js-close-pop-over'() {
Popup.close();
},
'click .js-confirm'() {
this.__afterConfirmAction.call(this);
},
// This handler intends to solve a pretty tricky bug with our popup
// transition. The transition is implemented using a large container
// (.content-container) that is moved on the x-axis (from 0 to n*PopupSize)
// inside a wrapper (.container-wrapper) with a hidden overflow. The problem
// is that sometimes the wrapper is scrolled -- even if there are no
// scrollbars. This happen for instance when the newly opened popup has some
// focused field, the browser will automatically scroll the wrapper, resulting
// in moving the whole popup container outside of the popup wrapper. To
// disable this behavior we have to manually reset the scrollLeft position
// whenever it is modified.
'scroll .content-wrapper'(evt) {
evt.currentTarget.scrollLeft = 0;
},
});
import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
import { Template } from 'meteor/templating';
// When a popup content is removed (ie, when the user press the "back" button),
// we need to wait for the container translation to end before removing the
// actual DOM element. For that purpose we use the undocumented `_uihooks` API.
Popup.template.onRendered(() => {
const container = this.find('.content-container');
container._uihooks = {
removeElement(node) {
$(node).addClass('no-height');
$(container).one(CSSEvents.transitionend, () => {
node.parentNode.removeChild(node);
const PopupBias = {
Before: Symbol("S"),
Overlap: Symbol("M"),
After: Symbol("A"),
Fullscreen: Symbol("F"),
includes(e) {
return Object.values(this).includes(e);
}
}
// this class is a bit cumbersome and could probably be done simpler.
// it manages two things : initial placement and sizing given an opener element,
// and then movement and resizing. one difficulty was to be able, as a popup
// which can be resized from the "outside" (CSS4) and move from the inside (inner
// component), which also grows and shrinks frequently, to adapt.
// I tried many approach and failed to get the perfect fit; I feel that there is
// always something indeterminate at some point. so the only drawback is that
// if a popup contains another resizable component (e.g. card details), and if
// it has been resized (with CSS handle), it will lose its dimensions when dragging
// it next time.
class PopupDetachedComponent extends BlazeComponent {
onCreated() {
// Set by parent/caller (usually PopupComponent)
({ nonPlaceholderOpener: this.nonPlaceholderOpener, closeDOMs: this.closeDOMs = [], followDOM: this.followDOM } = this.data());
if (typeof(this.closeDOMs) === "string") {
// helper for passing arg in JADE template
this.closeDOMs = this.closeDOMs.split(';');
}
// The popup's own header, if it exists
this.closeDOMs.push("click .js-close-detached-popup");
}
// Main intent of this component is to have a modular popup with defaults:
// - sticks to its opener while being a child of body (thus in the same stacking context, no z-index issue)
// - is responsive on shrink while keeping position absolute
// - can grow back to initial position step by step
// - exposes various sizes as CSS variables so each rendered popup can use them to adapt defaults
// * issue is that it is done by hand, with heurisitic/simple algorithm from my thoughts, not sure it covers edge cases
// * however it works well so far and maybe more "fixed" element should be popups
onRendered() {
// Remember initial ratio between initial dimensions and viewport
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
this.popup = this.firstNode();
this.popupOpener = this.data().openerElement;
const popupStyle = window.getComputedStyle(this.firstNode());
// margin may be in a relative unit, not computable in JS, but we get the actual pixels here
this.popupMargin = parseFloat(popupStyle.getPropertyValue("--popup-margin"), 10) || Math.min(window.innerWidth / 50, window.innerHeight / 50);
this.dims(this.computeMaxDims());
this.initialPopupWidth = this.popupDims.width;
this.initialPopupHeight = this.popupDims.height;
this.initialHeightRatio = this.initialPopupHeight / viewportHeight;
this.initialWidthRatio = this.initialPopupWidth / viewportWidth;
this.dims(this.computePopupDims());
if (this.followDOM) {
this.innerElement = this.find(this.followDOM) ?? document.querySelector(this.followDOM);
}
this.follow();
this.toFront();
// #FIXME the idea of keeping the initial ratio on resize is quite bad. remove that part.
// there is a reactive variable for window resize in Utils, but the interface is too slow
// with all reactive stuff, use events when possible and when not really bypassing logic
$(window).on('resize', () => {
// #FIXME there is a bug when window grows; popup outer container
// will grow beyond the size of content and it's not easy to fix for me (and I feel tired of this popup)
this.dims(this.computePopupDims());
});
}
margin() {
return this.popupMargin;
}
ensureDimsLimit(dims) {
// boilerplate to make sure that popup visually fits
let { left, top, width, height } = dims;
let overflowBottom = top + height + 2 * this.margin() - window.innerHeight;
let overflowRight = left + width + 2 * this.margin() - window.innerWidth;
if (overflowRight > 0) {
width = Math.max(20 * this.margin(), Math.min(width - overflowRight, window.innerWidth - 2 * this.margin()));
}
if (overflowBottom > 0) {
height = Math.max(10 * this.margin(), Math.min(height - overflowBottom, window.innerHeight - 2 * this.margin()));
}
left = Math.max(left, this.margin());
top = Math.max(top, this.margin());
return { left, top, width, height }
}
dims(newDims) {
if (!this.popupDims) {
this.popupDims = {};
}
if (newDims) {
newDims = this.ensureDimsLimit(newDims);
for (const e of Object.keys(newDims)) {
let value = parseFloat(newDims[e]);
if (!isNaN(value)) {
$(this.popup).css(e, `${value}px`);
this.popupDims[e] = value;
}
}
}
return this.popupDims;
}
isFullscreen() {
return this.fullscreen;
}
maximize() {
this.fullscreen = true;
this.dims(this.computePopupDims());
if (this.innerElement) {
$(this.innerElement).css('width', '');
$(this.innerElement).css('height', '')
}
}
minimize() {
this.fullscreen = false;
this.dims(this.computePopupDims());
}
follow() {
const adaptChild = new ResizeObserver((_) => {
if (this.fullscreen) {return}
const width = this.innerElement?.scrollWidth || this.popup.scrollWidth;
const height = this.innerElement?.scrollHeight || this.popup.scrollHeight;
// we don't want to run this during something that we have caused, eg. dragging
if (!this.mouseDown) {
// extra-"future-proof" stuff: if somebody adds a margin to the popup, it would trigger a loop
if (Math.abs(this.dims().width - width) < 20 && Math.abs(this.dims().height - height) < 20) { return }
// if inner shrinks, follow
if (width < this.dims().width || height < this.dims().height) {
this.dims({ width, height });
}
// otherwise it may be complicated to find a generic situation, but we have the
// classic positionning procedure which works, so use it and ignore positionning
else {
const newDims = this.computePopupDims();
// a bit twisted/ad-hoc for card details, in the edge case where they are opened when collapsed then uncollapsed,
// not sure to understand why the sizing works differently that starting uncollapsed then doing the same sequence
this.dims(this.ensureDimsLimit({
top: this.dims().top,
left: this.dims().left,
width: Math.max(newDims.width, width),
height: Math.max(newDims.height, height)
}));
}
}
else {
const { width, height } = this.popup.getBoundingClientRect();
// only case when we bypass .dims(), to avoid loop
this.popupDims.width = width;
this.popupDims.height = height;
}
});
if (this.innerElement) {
adaptChild.observe(this.innerElement);
} else {
adaptChild.observe(this.popup);
}
}
currentZ(z = undefined) {
// relative, add a constant to be above root elements
if (z !== undefined) {
this.firstNode().style.zIndex = parseInt(z) + 10;
}
return parseInt(this.firstNode().style.zIndex) - 10;
}
// a bit complex...
toFront() {
this.currentZ(Math.max(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 0 + 1);
}
toBack() {
this.currentZ(Math.min(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 1 - 1);
}
events() {
// needs to be done at this level; "parent" is not a parent in DOM
let closeEvents = {};
this.closeDOMs?.forEach((e) => {
closeEvents[e] = (_) => {
this.parentComponent().destroy();
}
})
const miscEvents = {
'click .js-confirm'() {
this.data().afterConfirm?.call(this);
},
// bad heuristic but only for best-effort UI
'pointerdown .pop-over'() {
this.mouseDown = true;
},
'pointerup .pop-over'() {
this.mouseDown = false;
}
};
const movePopup = (event) => {
event.preventDefault();
$(event.target).addClass('is-active');
const deltaHandleX = this.dims().left - event.clientX;
const deltaHandleY = this.dims().top - event.clientY;
const onPointerMove = (e) => {
this.dims(this.ensureDimsLimit({ left: e.clientX + deltaHandleX, top: e.clientY + deltaHandleY, width: this.dims().width, height: this.dims().height }));
if (this.popup.scrollY) {
this.popup.scrollTo(0, 0);
}
};
const onPointerUp = (event) => {
$(document).off('pointermove', onPointerMove);
$(document).off('pointerup', onPointerUp);
$(event.target).removeClass('is-active');
};
if (Utils.shouldIgnorePointer(event)) {
onPointerUp(event);
return;
}
$(document).on('pointermove', onPointerMove);
$(document).on('pointerup', onPointerUp);
};
// We do not manage dragging without our own header
const handleDOM = this.data().handleDOM;
if (this.data().showHeader) {
const handleSelector = Utils.isMiniScreen() ? '.js-popup-drag-handle' : '.header-title';
miscEvents[`pointerdown ${handleSelector}`] = (e) => movePopup(e);
}
if (handleDOM) {
miscEvents[`pointerdown ${handleDOM}`] = (e) => movePopup(e);
}
return super.events().concat(closeEvents).concat(miscEvents);
}
computeMaxDims() {
// Get size of inner content, even if it overflows
const content = this.find('.content');
let popupHeight = content.scrollHeight;
let popupWidth = content.scrollWidth;
if (this.data().showHeader) {
const headerRect = this.find('.header');
popupHeight += headerRect.scrollHeight;
popupWidth = Math.max(popupWidth, headerRect.scrollWidth)
}
return { width: Math.max(popupWidth, $(this.popup).width()), height: Math.max(popupHeight, $(this.popup).height()) };
}
placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n) {
// avoid too much recursion if no solution
if (!n) {
n = 0;
}
if (n >= 5) {
// if we exhausted a bias, remove it
n = 0;
biases.pop();
if (biases.length === 0) {
return -1;
}
} else {
n += 1;
}
if (!biases?.length) {
const cut = maxLength / 3;
if (openerPos < cut) {
// Corresponds to the default ordering: if element is close to the axe's start,
// try to put the popup after it; then to overlap; and give up otherwise.
biases = [PopupBias.After, PopupBias.Overlap]
}
else if (openerPos > 2 * cut) {
// Same idea if popup is close to the end
biases = [PopupBias.Before, PopupBias.Overlap]
}
else {
// If in the middle, try to overlap: choosing between start or end, even for
// default, is too arbitrary; a custom order can be passed in argument.
biases = [PopupBias.Overlap]
}
}
// Remove the first element and get it
const bias = biases.splice(0, 1)[0];
let factor;
const openerRef = openerPos + openerLength / 2;
if (bias === PopupBias.Before) {
factor = 1;
}
else if (bias === PopupBias.Overlap) {
factor = openerRef / maxLength;
}
else {
factor = 0;
}
let candidatePos = openerRef - elementLength * factor;
const deltaMax = candidatePos + elementLength - maxLength;
if (candidatePos < 0 || deltaMax > 0) {
if (deltaMax <= 2 * this.margin()) {
// if this is just a matter of margin, try again
// useful for (literal) corner cases
biases = [bias].concat(biases);
openerPos -= 5;
}
if (biases.length === 0) {
// we could have returned candidate position even if the size is too large, so
// that the caller can choose, but it means more computations and edge cases...
// any negative means fullscreen overall as the caller will take the maximum between
// margin and candidate.
return -1;
}
return this.placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n);
}
return candidatePos;
}
computePopupDims() {
if (!this.isRendered?.()) {
return;
}
// Coordinates of opener related to viewport
let { x: parentX, y: parentY } = this.nonPlaceholderOpener.getBoundingClientRect();
let { height: parentHeight, width: parentWidth } = this.nonPlaceholderOpener.getBoundingClientRect();
// Initial dimensions scaled to the viewport, if it has changed
let popupHeight = window.innerHeight * this.initialHeightRatio;
let popupWidth = window.innerWidth * this.initialWidthRatio;
if (this.fullscreen || Utils.isMiniScreen() && popupWidth >= 4 * window.innerWidth / 5 && popupHeight >= 4 * window.innerHeight / 5) {
// Go fullscreen!
popupWidth = window.innerWidth;
// Avoid address bar, let a bit of margin to scroll
popupHeight = 4 * window.innerHeight / 5;
return ({
width: window.innerWidth,
height: window.innerHeight,
left: 0,
top: 0,
});
},
};
});
} else {
// Current viewport dimensions
let maxHeight = window.innerHeight - this.margin() * 2;
let maxWidth = window.innerWidth - this.margin() * 2;
let biasX, biasY;
if (Utils.isMiniScreen()) {
// On mobile I found that being able to close a popup really close from where it has been clicked
// is comfortable; so given that the close button is top-right, we prefer the position of
// popup being right-bottom, when possible. We then try every position, rather than choosing
// relatively to the relative position of opener in viewport
biasX = [PopupBias.Before, PopupBias.Overlap, PopupBias.After];
biasY = [PopupBias.After, PopupBias.Overlap, PopupBias.Before];
}
const candidateX = this.placeOnSingleDimension(popupWidth, parentX, parentWidth, maxWidth, biasX);
const candidateY = this.placeOnSingleDimension(popupHeight, parentY, parentHeight, maxHeight, biasY);
// Reasonable defaults that can be overriden by CSS later: popups are tall, try to fit the reste
// of the screen starting from parent element, or full screen if element if not fitting
return ({
width: popupWidth,
height: popupHeight,
left: candidateX,
top: candidateY,
});
}
}
}
class PopupComponent extends BlazeComponent {
static stack = [];
// good enough as long as few occurences of such cases
static multipleBlacklist = ["cardDetails"];
// to provide compatibility with Popup.open().
static open(args) {
const openerView = Blaze.getView(args.openerElement);
if (!openerView) {
console.warn(`no parent found for popup ${args.name}, attaching to body: this should not happen`);
}
// render ourselves; everything is automatically managed from that moment, we just added
// a level of indirection but this will not interfere with data
const popup = new PopupComponent();
Blaze.renderWithData(
popup.renderComponent(BlazeComponent.currentComponent()),
args,
args.openerElement,
null,
openerView
);
return popup;
}
static destroy() {
PopupComponent.stack.at(-1)?.destroy();
}
static findParentPopup(element) {
return BlazeComponent.getComponentForElement($(element).closest('.pop-over')[0]);
}
static toFront(event) {
const popup = PopupComponent.findParentPopup(event.target)
popup?.toFront();
return popup;
}
static toBack(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.toBack();
return popup;
}
static maximize(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.toFront();
popup?.maximize();
return popup;
}
static minimize(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.minimize();
return popup;
}
getOpenerElement(view) {
// Look for the first parent view whose first DOM element is not virtually us
const firstNode = $(view.firstNode());
// The goal is to have the best chances to get the element whose size and pos
// are relevant; e.g. when clicking on a date on a minicard, we don't wan't
// the opener to be set to the minicard.
// In order to work in general, we need to take special situations into account,
// e.g. the placeholder is isolated, or does not have previous node, and so on.
// In general we prefer previous node, then next, then any displayed sibling,
// then the parent, and so on.
let candidates = [];
if (!firstNode.hasClass(this.popupPlaceholderClass())) {
candidates.push(firstNode);
}
candidates = candidates.concat([firstNode.prev(), firstNode.next()]);
const otherSiblings = Array.from(firstNode.siblings()).filter(e => !candidates.includes(e));
for (const cand of candidates.concat(otherSiblings)) {
const displayCSS = cand?.css("display");
if (displayCSS && displayCSS !== "none") {
return cand[0];
}
}
return this.getOpenerElement(view.parentView);
}
getParentData(view) {;
let data;
// ⚠️ node can be a text node
while (view.firstNode?.()?.classList?.contains(this.popupPlaceholderClass())) {
view = view.parentView;
data = Blaze.getData(view);
}
// This is VERY IMPORTANT to get data like this and not with templateInstance.data,
// because this form is reactive. So all inner popups have reactive data, which is nice
return data;
}
onCreated() {
// #FIXME prevent secondary popups to open
// Special "magic number" case: never render, for any reason, the same card
// const maybeID = this.parentComponent?.()?.data?.()?._id;
// if (maybeID && PopupComponent.stack.find(e => e.parentComponent().data?.()?._id === maybeID)) {
// this.destroy();
// return;
// }
// do not render a template multiple times
const existing = PopupComponent.stack.find((e) => (e.name == this.data().name));
if (existing && PopupComponent.multipleBlacklist.indexOf(this.data().name)) {
// ⚠️ is there a default better than another? I feel that closing existing
// popup is not bad in general because having the same button for open and close
// is common
if (PopupComponent.multipleBlacklist.includes(existing.name)) {
existing.destroy();
}
// but is could also be re-rendering, eg
// existing.render();
return;
}
// All of this, except name, is optional. The rest is provided "just in case", for convenience (hopefully)
//
// - name is the name of a template to render inside the popup (to the detriment of its size) or the contrary
// - showHeader can be turned off if the inner content always have a header with buttons and so on
// - title is shown when header is shown
// - miscOptions is for compatibility
// - closeVar is an optional string representing a Session variable: if set, the popup reactively closes when the variable changes and set the variable to null on close
// - closeDOMs can be used alternatively; it is an array of "<event> <selector>" to listen that closes the popup.
// if header is shown, closing the popup is already managed. selector is relative to the inner template (same as its event map)
// - followDOM is an element whose dimension will serve as reference so that popup can react to inner changes; works only with inline styles (otherwise we probably would need IntersectionObserver-like stuff, async etc)
// - handleDOM is an element who can be clicked to move popup
// it is useful when the content can be redimensionned/moved by code or user; we still manage events, resizes etc
// but allow inner elements or handles to do it (and we adapt).
const data = this.data();
this.popupArgs = {
name: data.name,
showHeader: data.showHeader ?? true,
title: data.title,
openerElement: data.openerElement,
closeDOMs: data.closeDOMs,
followDOM: data.followDOM,
handleDOM: data.handleDOM,
forceData: data.miscOptions?.dataContextIfCurrentDataIsUndefined,
afterConfirm: data.miscOptions?.afterConfirm,
}
this.name = this.data().name;
this.innerTemplate = Template[this.name];
this.innerComponent = BlazeComponent.getComponent(this.name);
this.outerComponent = BlazeComponent.getComponent('popupDetached');
if (!(this.innerComponent || this.innerTemplate)) {
throw new Error(`template and/or component ${this.name} not found`);
}
// If arg is not set, must be closed manually by calling destroy()
if (this.popupArgs.closeVar) {
this.closeInitialValue = Session.get(this.data().closeVar);
if (!this.closeInitialValue === undefined) {
this.autorun(() => {
if (Session.get(this.data().closeVar) !== this.closeInitialValue) {
this.onDestroyed();
}
});
}
}
}
popupPlaceholderClass() {
return "popup-placeholder";
}
render() {
const oldOuterView = this.outerView;
// see below for comments
this.outerView = Blaze.renderWithData(
// data is passed through the parent relationship
// we need to render it again to keep events in sync with inner popup
this.outerComponent.renderComponent(this.component()),
this.popupArgs,
document.body,
null,
this.openerView
);
this.innerView = Blaze.renderWithData(
// the template to render: either the content is a BlazeComponent or a regular template
// if a BlazeComponent, render it as a template first
this.innerComponent?.renderComponent?.(this.component()) || this.innerTemplate,
// dataContext used for rendering: each time we go find data, because it is non-reactive
() => (this.popupArgs.forceData || this.getParentData(this.currentView)),
// DOM parent: ask to the detached popup, will be inserted at the last child
this.outerView.firstNode()?.getElementsByClassName('content')?.[0] || document.body,
// "stop" DOM element; we don't use
null,
// important: this is the Blaze.View object which will be set as `parentView` of
// the rendered view. we set it as the parent view, so that the detached popup
// can interact with its "parent" without being a child of it, and without
// manipulating DOM directly.
this.openerView
);
if (oldOuterView) {
Blaze.remove(oldOuterView);
}
}
onRendered() {
if (this.detached) {return}
// Use plain Blaze stuff to be able to render all templates, but use components when available/relevant
this.currentView = Blaze.currentView || Blaze.getView(this.component().firstNode());
// Placement will be related to the opener (usually clicked element)
// But template data and view related to the opener are not the same:
// - view is probably outer, as is was already rendered on click
// - template data could be found with Template.parentData(n), but `n` can
// vary depending on context: using those methods feels more reliable for this use case
this.popupArgs.openerElement ??= this.getOpenerElement(this.currentView);
this.openerView = Blaze.getView(this.popupArgs.openerElement);
// With programmatic/click opening, we get the "real" opener; with dynamic
// templating we get the placeholder and need to go up to get a glimpse of
// the "real" opener size. It is quite imprecise in that case (maybe the
// interesting opener is a sibling, not an ancestor), but seems to do the job
// for now.
// Also it feels sane that inner content does not have a reference to
// a virtual placeholder.
const opener = this.popupArgs.openerElement;
let sizedOpener = opener;
if (opener.classList?.contains?.(this.popupPlaceholderClass())) {
sizedOpener = opener.parentNode;
}
this.popupArgs.nonPlaceholderOpener = sizedOpener;
PopupComponent.stack.push(this);
try {
this.render();
// Render above other popups by default
} catch(e) {
// If something went wrong during rendering, do not create
// "zombie" popups
console.error(`cannot render popup ${this.name}: ${e}`);
this.destroy();
}
}
destroy() {
this.detached = true;
if (!PopupComponent.stack.includes(this)) {
// Avoid loop destroy
return;
}
// Maybe overkill but may help to avoid leaking memory
// as programmatic rendering is less usual
for (const view of [this.innerView, this.currentView, this.outerView]) {
try {
Blaze.remove(view);
} catch {
console.warn(`A view failed to be removed: ${view}`)
}
}
this.innerComponent?.removeComponent?.();
this.outerComponent?.removeComponent?.();
this.removeComponent();
// not necesserly removed in order, e.g. multiple cards
PopupComponent.stack = PopupComponent.stack.filter(e => e !== this);
}
closeWithPlaceholder(parentElement) {
// adapted from https://stackoverflow.com/questions/52834774/dom-event-when-element-is-removed
// strangely, when opener is removed because of a reactive change, this component
// do not get any lifecycle hook called, so we need to bridge the gap. Simply
// "close" popup when placeholder is off-DOM.
while (parentElement.nodeType === Node.TEXT_NODE) {
parentElement = parentElement.parentElement;
}
const placeholder = parentElement.getElementsByClassName(this.popupPlaceholderClass());
if (!placeholder.length) {
return;
}
const observer = new MutationObserver(() => {
// DOM element being suppressed is reflected in array
if (placeholder.length === 0) {
this.destroy();
}
});
observer.observe(parentElement, {childList: true});
}
}
PopupComponent.register("popup");
PopupDetachedComponent.register('popupDetached');
export default PopupComponent;

View file

@ -1,24 +0,0 @@
.pop-over.js-pop-over(
class="{{#unless title}}miniprofile{{/unless}}"
class=currentBoard.colorClass
class="{{#unless title}}no-title{{/unless}}"
data-popup="{{popupName}}"
style="left:{{offset.left}}px; top:{{offset.top}}px;{{#if offset.maxHeight}} max-height:{{offset.maxHeight}}px;{{/if}}")
.header
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}")
i.fa.fa-caret-left
span.header-title= title
a.close-btn.js-close-pop-over
i.fa.fa-times-thin
.content-wrapper
//-
We display the all stack of popup content next to each other and move
the "window" by translating .content-container inside .content-wrapper.
.content-container(class="popup-container-depth-{{depth}}")
each stack
//-
XXX We need a better way to express the "is the last element" condition.
Hopefully the @last helper will come soon (or at least @index)
.content(class="{{#unless $eq popupName ../popupName}}no-height{{/unless}}")
+Template.dynamic(template=popupName data=dataContext)
.clearfix

View file

@ -3,7 +3,7 @@
height: 50px;
margin: auto;
text-align: center;
font-size: 10px;
}
.sk-spinner-wave div {
background-color: #333;