Merge pull request #6640 from ludij/feature/3331-dynamic-column-width

Feature/3331 dynamic column width
This commit is contained in:
Luka Trovic
2025-09-10 09:50:07 +02:00
committed by GitHub
11 changed files with 206 additions and 157 deletions

View File

@@ -14,6 +14,7 @@
input[type=submit].icon-confirm { input[type=submit].icon-confirm {
border-color: var(--color-border-maxcontrast) !important; border-color: var(--color-border-maxcontrast) !important;
border-style: solid;
border-left: none; border-left: none;
} }

View File

@@ -276,18 +276,15 @@ export default {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: calc(100vh - 50px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.board { .board {
padding-left: $board-spacing;
position: relative; position: relative;
max-height: calc(100% - var(--default-clickable-area));
overflow: hidden;
overflow-x: auto; overflow-x: auto;
flex-grow: 1; flex-grow: 1;
scrollbar-gutter: stable;
} }
/** /**
@@ -297,11 +294,15 @@ export default {
.smooth-dnd-container.horizontal { .smooth-dnd-container.horizontal {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: $board-gap;
padding: 0 $board-gap;
height: 100%; height: 100%;
&:deep(.stack-draggable-wrapper.smooth-dnd-draggable-wrapper) { &:deep(.stack-draggable-wrapper.smooth-dnd-draggable-wrapper) {
display: flex; display: flex;
height: auto; height: auto;
flex: 0 1 $card-max-width;
min-width: $card-min-width;
.stack { .stack {
display: flex; display: flex;
@@ -309,16 +310,13 @@ export default {
position: relative; position: relative;
.smooth-dnd-container.vertical { .smooth-dnd-container.vertical {
flex-grow: 1; $margin-x: calc($stack-gap * -1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// Margin left instead of padidng to avoid jumps on dropping a card gap: $stack-gap;
margin-left: $stack-spacing; padding: $stack-gap;
padding-right: $stack-spacing; margin: 0 $margin-x;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding-top: 15px;
margin-top: -10px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }

View File

@@ -14,10 +14,10 @@
{{ stack.title }} {{ stack.title }}
</h3> </h3>
<h3 v-else-if="!editing" <h3 v-else-if="!editing"
title="stack.title"
dir="auto" dir="auto"
tabindex="0" tabindex="0"
:aria-label="stack.title" :aria-label="stack.title"
:title="stack.title"
class="stack__title" class="stack__title"
@click="startEditing(stack)" @click="startEditing(stack)"
@keydown.enter="startEditing(stack)"> @keydown.enter="startEditing(stack)">
@@ -108,7 +108,7 @@
</Container> </Container>
<transition name="slide-bottom" appear> <transition name="slide-bottom" appear>
<div v-show="showAddCard" class="stack__card-add"> <div v-if="showAddCard" class="stack__card-add">
<form :class="{ 'icon-loading-small': stateCardCreating }" <form :class="{ 'icon-loading-small': stateCardCreating }"
@submit.prevent.stop="clickAddCard()"> @submit.prevent.stop="clickAddCard()">
<label for="new-stack-input-main" class="hidden-visually">{{ t('deck', 'Add a new card') }}</label> <label for="new-stack-input-main" class="hidden-visually">{{ t('deck', 'Add a new card') }}</label>
@@ -365,37 +365,31 @@ export default {
@import './../../css/variables'; @import './../../css/variables';
.stack { .stack {
width: $stack-width + $stack-spacing * 3; width: 100%;
} }
.stack__header { .stack__header {
display: flex; display: flex;
position: sticky; position: sticky;
top: 0; top: 0;
height: var(--default-clickable-area);
z-index: 100; z-index: 100;
padding-left: $card-spacing;
padding-right: $card-spacing;
margin: 6px;
margin-top: 0; margin-top: 0;
cursor: grab; cursor: grab;
background-color: var(--color-main-background); background-color: var(--color-main-background);
// Smooth fade out of the cards at the top // Smooth fade out of the cards at the top
&:before { &:before {
content: ' '; content: '';
display: block; display: block;
position: absolute; position: absolute;
width: calc(100% - 16px); width: 100%;
height: 20px; height: $stack-gap;
top: 30px; bottom: 0;
left: 0px;
z-index: 99; z-index: 99;
transition: top var(--animation-slow); transition: top var(--animation-slow);
background-image: linear-gradient(180deg, var(--color-main-background) 0%, transparent 100%);
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(255, 255, 255, 0) 100%); transform: translateY(100%);
body.theme--dark & {
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(0, 0, 0, 0) 100%);
}
} }
& > * { & > * {
@@ -404,8 +398,10 @@ export default {
} }
h3, form { h3, form {
flex-grow: 1; flex: 1 1 auto;
min-width: 0;
display: flex; display: flex;
align-items: center;
cursor: inherit; cursor: inherit;
margin: 0; margin: 0;
@@ -418,9 +414,8 @@ export default {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: calc($stack-width - 60px);
border-radius: 3px; border-radius: 3px;
padding: 4px 4px; padding: $card-padding;
font-size: var(--default-font-size); font-size: var(--default-font-size);
&:focus-visible { &:focus-visible {
@@ -430,7 +425,6 @@ export default {
} }
form { form {
margin: -4px;
input { input {
font-weight: bold; font-weight: bold;
padding: 0 6px; padding: 0 6px;
@@ -453,14 +447,25 @@ export default {
flex-shrink: 0; flex-shrink: 0;
z-index: 100; z-index: 100;
display: flex; display: flex;
margin-bottom: 5px; padding-bottom: $stack-gap;
padding-top: var(--default-grid-baseline);
background-color: var(--color-main-background); background-color: var(--color-main-background);
position: relative;
// Smooth fade out of the cards at the top
&:before {
content: '';
display: block;
position: absolute;
width: 100%;
height: $stack-gap;
z-index: 99;
transition: bottom var(--animation-slow);
background-image: linear-gradient(0deg, var(--color-main-background) 0%, transparent 100%);
transform: translateY(-100%);
}
form { form {
display: flex; display: flex;
margin-left: $stack-spacing;
margin-right: $stack-spacing;
width: 100%; width: 100%;
border: 2px solid var(--color-border-maxcontrast); border: 2px solid var(--color-border-maxcontrast);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
@@ -481,7 +486,6 @@ export default {
input { input {
border: none; border: none;
margin: 0; margin: 0;
padding: 4px;
} }
} }

View File

@@ -101,6 +101,8 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './../../css/variables';
.badges { .badges {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -111,6 +113,7 @@ export default {
.icon-badge { .icon-badge {
color: var(--color-text-maxcontrast); color: var(--color-text-maxcontrast);
display: flex; display: flex;
align-items: center;
margin-right: 2px; margin-right: 2px;
span, span,
@@ -125,6 +128,7 @@ export default {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 3px; gap: 3px;
height: var(--default-clickable-area);
} }
.badges .icon.due { .badges .icon.due {

View File

@@ -4,13 +4,10 @@
--> -->
<template> <template>
<div v-if="referencePreview" class="card-cover"> <div v-if="cardId && ( attachments.length > 0 )" class="card-cover">
<div class="image-wrapper rounded-left rounded-right" :style="{ backgroundImage: `url(${referencePreview})`}" /> <div v-for="attachment in attachments"
</div>
<div v-else-if="cardId && ( attachments.length > 0 )" class="card-cover">
<div v-for="(attachment, index) in attachments"
:key="attachment.id" :key="attachment.id"
:class="['image-wrapper', { 'rounded-left': index === 0 }, { 'rounded-right': index === attachments.length - 1 }]" class="image-wrapper"
:style="{ backgroundImage: `url(${attachmentPreview(attachment)})` }" /> :style="{ backgroundImage: `url(${attachmentPreview(attachment)})` }" />
</div> </div>
</template> </template>
@@ -77,9 +74,7 @@ export default {
.card-cover { .card-cover {
height: 90px; height: 90px;
display: flex; display: flex;
margin-top: -4px; margin: $card-image-margin $card-image-margin 0;
margin-left: -4px;
margin-right: -4px;
.image-wrapper { .image-wrapper {
flex: 1; flex: 1;
@@ -87,12 +82,6 @@ export default {
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center center; background-position: center center;
&.rounded-left {
border-top-left-radius: calc(var(--border-radius-large) - 1px);
}
&.rounded-right {
border-top-right-radius: calc(var(--border-radius-large) - 1px);
}
} }
} }
</style> </style>

View File

@@ -6,7 +6,7 @@
<template> <template>
<AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card"> <AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card">
<div :ref="`card${card.id}`" <div :ref="`card${card.id}`"
:class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'card__editable': canEdit, 'card__archived': card.archived, 'card__highlight': highlight}" :class="{'compact': compactMode, 'current-card': currentCard, 'no-labels': !hasLabels, 'card__editable': canEdit, 'card__archived': card.archived, 'card__highlight': highlight}"
tag="div" tag="div"
:tabindex="0" :tabindex="0"
class="card" class="card"
@@ -331,12 +331,13 @@ export default {
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
font-size: 100%; font-size: 100%;
background-color: var(--color-main-background); background-color: var(--color-main-background);
margin-bottom: $card-spacing; padding: $card-padding;
padding: var(--default-grid-baseline) $card-padding;
border: 2px solid var(--color-border-dark); border: 2px solid var(--color-border-dark);
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $card-gap;
overflow: hidden;
&:deep(*) { &:deep(*) {
cursor: pointer; cursor: pointer;
@@ -359,12 +360,10 @@ export default {
h4 { h4 {
font-weight: normal; font-weight: normal;
margin: 0; margin: 0;
padding: var(--default-grid-baseline);
flex-grow: 1; flex-grow: 1;
font-size: 100%; font-size: 100%;
overflow: hidden; overflow: hidden;
word-wrap: break-word; word-wrap: break-word;
padding-left: 4px;
align-self: center; align-self: center;
:deep(a) { :deep(a) {
@@ -374,9 +373,6 @@ export default {
&.editable { &.editable {
span { span {
cursor: text; cursor: text;
padding-right: 8px;
padding-top: 3px;
padding-bottom: 3px;
&:focus, &:focus-visible { &:focus, &:focus-visible {
outline: none; outline: none;
@@ -385,6 +381,7 @@ export default {
&:focus-within { &:focus-within {
outline: 2px solid var(--color-border-dark); outline: 2px solid var(--color-border-dark);
outline-offset: 4px;
border-radius: 3px; border-radius: 3px;
} }
} }
@@ -427,8 +424,6 @@ export default {
.card-labels { .card-labels {
display: flex; display: flex;
align-items: end; align-items: end;
padding-left: var(--default-grid-baseline);
padding-top: var(--default-grid-baseline);
.labels { .labels {
flex-wrap: wrap; flex-wrap: wrap;
@@ -444,7 +439,7 @@ export default {
.card-related { .card-related {
display: flex; display: flex;
padding: 12px; padding: 4px;
padding-bottom: 0px; padding-bottom: 0px;
color: var(--color-text-maxcontrast); color: var(--color-text-maxcontrast);
@@ -469,8 +464,8 @@ export default {
height: 32px; height: 32px;
width: 32px; width: 32px;
} }
&.has-labels { &.no-labels {
padding-bottom: $card-padding; padding-bottom: var(--default-grid-baseline);
} }
.labels { .labels {
height: 6px; height: 6px;

View File

@@ -14,45 +14,25 @@
</div> </div>
<div v-else-if="isValidFilter" class="overview"> <div v-else-if="isValidFilter" class="overview">
<div class="dashboard-column"> <div v-for="columnProps in columnPropsList" :key="columnProps.title" class="dashboard-column">
<h3>{{ t('deck', 'Overdue') }}</h3> <div class="dashboard-column__header">
<div v-for="card in sortCards('overdue')" :key="card.id"> <h3 class="dashboard-column__header-title"
<CardItem :id="card.id" /> :title="columnProps.title"
:aria-label="columnProps.title">
{{ t('deck', columnProps.title) }}
</h3>
</div> </div>
</div> <div class="dashboard-column__list">
<template v-if="columnProps.sort === false">
<div class="dashboard-column"> <CardItem v-for="card in filterCards(columnProps.filter)"
<h3>{{ t('deck', 'Today') }}</h3> :id="card.id"
<div v-for="card in sortCards('today')" :key="card.id"> :key="card.id" />
<CardItem :id="card.id" /> </template>
</div> <template v-else>
</div> <CardItem v-for="card in sortCards(filterCards(columnProps.filter))"
:id="card.id"
<div class="dashboard-column"> :key="card.id" />
<h3>{{ t('deck', 'Tomorrow') }}</h3> </template>
<div v-for="card in sortCards('tomorrow')" :key="card.id">
<CardItem :id="card.id" />
</div>
</div>
<div class="dashboard-column">
<h3>{{ t('deck', 'Next 7 days') }}</h3>
<div v-for="card in sortCards('nextSevenDays')" :key="card.id">
<CardItem :id="card.id" />
</div>
</div>
<div class="dashboard-column">
<h3>{{ t('deck', 'Later') }}</h3>
<div v-for="card in sortCards('later')" :key="card.id">
<CardItem :id="card.id" />
</div>
</div>
<div class="dashboard-column">
<h3>{{ t('deck', 'No due') }}</h3>
<div v-for="card in assignedCardsDashboard.nodue" :key="card.id">
<CardItem :id="card.id" />
</div> </div>
</div> </div>
</div> </div>
@@ -73,6 +53,34 @@ const SUPPORTED_FILTERS = [
FILTER_UPCOMING, FILTER_UPCOMING,
] ]
const COLUMN_PROPS_LIST = [
{
title: 'Overdue',
filter: 'overdue',
},
{
title: 'Today',
filter: 'today',
},
{
title: 'Tomorrow',
filter: 'tomorrow',
},
{
title: 'Next 7 days',
filter: 'nextSevenDays',
},
{
title: 'Later',
filter: 'later',
},
{
title: 'No due',
filter: 'nodue',
sort: false,
},
]
export default { export default {
name: 'Overview', name: 'Overview',
components: { components: {
@@ -89,6 +97,7 @@ export default {
data() { data() {
return { return {
loading: true, loading: true,
columnPropsList: COLUMN_PROPS_LIST,
} }
}, },
computed: { computed: {
@@ -125,16 +134,16 @@ export default {
} }
this.loading = false this.loading = false
}, },
sortCards(when) { filterCards(when) {
const cards = this.assignedCardsDashboard[when] return this.assignedCardsDashboard[when]
},
sortCards(cards) {
if (!cards) { if (!cards) {
return null return null
} else { } else {
return cards.toSorted((current, next) => { return cards.toSorted((current, next) => {
const currentDueDate = new Date(current.duedate) const currentDueDate = new Date(current.duedate)
const nextDueDate = new Date(next.duedate) const nextDueDate = new Date(next.duedate)
return currentDueDate - nextDueDate return currentDueDate - nextDueDate
}) })
} }
@@ -151,38 +160,75 @@ export default {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: calc(100vh - 50px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.overview { .overview {
position: relative; position: relative;
height: calc(100% - var(--default-clickable-area)); overflow-x: auto;
overflow-x: scroll; flex-grow: 1;
scrollbar-gutter: stable;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
padding-left: $board-spacing; gap: $board-gap;
padding-right: $board-spacing; padding: 0 $board-gap;
.dashboard-column { .dashboard-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: $stack-width; flex: 0 1 $card-max-width;
width: $stack-width; min-width: $card-min-width;
margin-left: $stack-spacing;
margin-right: $stack-spacing;
h3 { .dashboard-column__header {
font-size: var(--default-font-size); display: flex;
margin: -6px;
margin-bottom: 12px;
padding: 6px 13px;
position: sticky; position: sticky;
top: 0; top: 0;
height: var(--default-clickable-area);
z-index: 100; z-index: 100;
margin-top: 0;
background-color: var(--color-main-background); background-color: var(--color-main-background);
border: 1px solid var(--color-main-background);
// Smooth fade out of the cards at the top
&:before {
content: '';
display: block;
position: absolute;
width: 100%;
height: 20px;
top: 30px;
left: 0px;
z-index: 99;
transition: top var(--animation-slow);
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(255, 255, 255, 0) 100%);
body.theme--dark & {
background-image: linear-gradient(180deg, var(--color-main-background) 3px, rgba(0, 0, 0, 0) 100%);
}
}
}
.dashboard-column__header-title {
display: flex;
align-items: center;
height: var(--default-clickable-area);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: $card-padding;
font-size: var(--default-font-size);
}
.dashboard-column__list {
$margin-x: calc($stack-gap * -1);
display: flex;
flex-direction: column;
gap: $stack-gap;
padding: $stack-gap;
margin: 0 $margin-x;
overflow-y: auto;
scrollbar-gutter: stable;
} }
} }
} }

View File

@@ -4,16 +4,19 @@
--> -->
<template> <template>
<div v-if="searchQuery!==''" class="global-search"> <section v-if="searchQuery!==''" class="global-search">
<h2> <header class="search-header">
<NcRichText :text="t('deck', 'Search for {searchQuery} in all boards')" :arguments="queryStringArgs" /> <h2>
<div v-if="loading" class="icon-loading-small" /> <NcRichText :text="$route.params.id ? t('deck', 'Search for {searchQuery} in other boards') : t('deck', 'Search for {searchQuery} in all boards')"
</h2> :arguments="queryStringArgs" />
<NcActions> <span v-if="loading" class="icon-loading-small" />
<NcActionButton icon="icon-close" @click="$store.commit('setSearchQuery', '')" /> </h2>
</NcActions> <NcActions>
<NcActionButton icon="icon-close" @click="$store.commit('setSearchQuery', '')" />
</NcActions>
</header>
<div class="search-wrapper"> <div class="search-wrapper">
<div v-if="loading || filteredResults.length > 0" class="search-results"> <template v-if="loading || filteredResults.length > 0">
<CardItem v-for="card in filteredResults" <CardItem v-for="card in filteredResults"
:id="card.id" :id="card.id"
:key="card.id" :key="card.id"
@@ -26,12 +29,12 @@
{{ t('deck', 'No results found') }} {{ t('deck', 'No results found') }}
</div> </div>
</InfiniteLoading> </InfiniteLoading>
</div> </template>
<div v-else> <template v-else>
<p>{{ t('deck', 'No results found') }}</p> <p>{{ t('deck', 'No results found') }}</p>
</div> </template>
</div> </div>
</div> </section>
</template> </template>
<script> <script>
@@ -159,7 +162,7 @@ export default {
.global-search { .global-search {
width: 100%; width: 100%;
padding: $board-spacing + $stack-spacing; padding: $board-gap;
padding-bottom: 0; padding-bottom: 0;
overflow: hidden; overflow: hidden;
min-height: 35vh; min-height: 35vh;
@@ -169,17 +172,24 @@ export default {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
z-index: 1010; z-index: 1010;
position: relative; position: relative;
display: flex;
flex-direction: column;
.action-item.icon-close { .action-item.icon-close {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
} }
.search-wrapper {
overflow: scroll; .search-header {
height: 100%; display: flex;
position: relative; align-items: flex-start;
padding: 10px; justify-content: space-between;
}
h2 {
margin: 0;
padding: var(--default-grid-baseline) var(--default-grid-baseline) $board-gap;
} }
h2 > div { h2 > div {
@@ -189,23 +199,24 @@ export default {
margin-right: 20px; margin-right: 20px;
} }
} }
h2:deep(span) { h2:deep(span) {
background-color: var(--color-background-dark); background-color: var(--color-background-dark);
padding: 3px; padding: 3px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.search-results { .search-wrapper {
overflow: auto;
height: 100%;
position: relative;
display: flex; display: flex;
flex-wrap: wrap; gap: $stack-gap;
& > div { & > .drop-upload--card {
flex-grow: 0; flex: 0 1 $card-max-width;
min-width: $card-min-width;
} }
} }
&:deep(.card) {
width: $stack-width;
margin-right: $stack-spacing;
}
} }
</style> </style>

View File

@@ -62,14 +62,13 @@ export default {
$clickable-area: var(--default-clickable-area); $clickable-area: var(--default-clickable-area);
.card--placeholder { .card--placeholder {
width: $stack-width; min-width: $card-min-width;
margin-right: $stack-spacing; max-width: $card-min-width;
padding: $card-padding; padding: $card-padding;
transition: box-shadow 0.1s ease-in-out; transition: box-shadow 0.1s ease-in-out;
box-shadow: 0 0 2px 0 var(--color-box-shadow); box-shadow: 0 0 2px 0 var(--color-box-shadow);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
font-size: 100%; font-size: 100%;
margin-bottom: $card-spacing;
height: 100px; height: 100px;
} }

View File

@@ -2,8 +2,10 @@
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
$card-spacing: 8px; $card-min-width: 216px;
$card-padding: 4px; $card-max-width: 300px;
$stack-spacing: 12px; $card-padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
$stack-width: 280px; $card-gap: calc(var(--default-grid-baseline) * 3);
$board-spacing: 16px; $card-image-margin: calc(var(--default-grid-baseline) * -2);
$stack-gap: calc(var(--default-grid-baseline) * 3);
$board-gap: calc(var(--default-grid-baseline) * 4);

View File

@@ -189,7 +189,7 @@ export default {
card.assignedUsers = card.assignedUsers || [] card.assignedUsers = card.assignedUsers || []
const existingIndex = state.cards.findIndex(_card => _card.id === card.id) const existingIndex = state.cards.findIndex(_card => _card.id === card.id)
if (existingIndex !== -1) { if (existingIndex !== -1) {
const existingCard = state.cards.find(_card => _card.id === card.id) const existingCard = state.cards[existingIndex]
Vue.set(state.cards, existingIndex, Object.assign({}, existingCard, card)) Vue.set(state.cards, existingIndex, Object.assign({}, existingCard, card))
} else { } else {
state.cards.push(card) state.cards.push(card)