Add expansion/collapse animations for cards
Key changes: - Add animation for card opening/collapse. Other supporting changes: - Remove card expansion panel to its own component for easier maintainability and better separation of concerns. - Use real DOM element instead of &:before pseudo class for showing expansion arrow. This increases by maintainability by separating its code and concerns. - TODO: When one card is expanded and others is also expanded then the transition sucks.
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card__expander">
|
||||||
|
<div class="card__expander__close-button">
|
||||||
|
<FlatButton
|
||||||
|
icon="xmark"
|
||||||
|
@click="collapse()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="card__expander__content">
|
||||||
|
<ScriptsTree
|
||||||
|
:category-id="categoryId"
|
||||||
|
:has-top-padding="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
defineComponent,
|
||||||
|
} from 'vue';
|
||||||
|
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||||
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
ScriptsTree,
|
||||||
|
FlatButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
categoryId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
onCollapse: () => true,
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
|
},
|
||||||
|
setup(_, { emit }) {
|
||||||
|
function collapse() {
|
||||||
|
emit('onCollapse');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collapse,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
$expanded-margin-top : 30px;
|
||||||
|
|
||||||
|
.card__expander {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
background-color: $color-primary-darker;
|
||||||
|
color: $color-on-primary;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
margin-top: $expanded-margin-top;
|
||||||
|
|
||||||
|
.card__expander__content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
||||||
|
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__expander__close-button {
|
||||||
|
font-size: $font-size-absolute-large;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
@include clickable;
|
||||||
|
color: $color-primary-light;
|
||||||
|
@include hover-or-touch {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="arrow-container">
|
||||||
|
<div class="arrow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
$arrow-size : 15px;
|
||||||
|
|
||||||
|
.arrow-container {
|
||||||
|
position: relative;
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - $arrow-size * 1.5);
|
||||||
|
top: calc(1.5 * $arrow-size);
|
||||||
|
border: solid $color-primary-darker;
|
||||||
|
border-width: 0 $arrow-size $arrow-size 0;
|
||||||
|
padding: $arrow-size;
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
v-for="categoryId of categoryIds"
|
v-for="categoryId of categoryIds"
|
||||||
:key="categoryId"
|
:key="categoryId"
|
||||||
class="card"
|
class="card"
|
||||||
|
:total-cards-per-row="cardsPerRow"
|
||||||
:class="{
|
:class="{
|
||||||
'small-screen': width <= 500,
|
'small-screen': width <= 500,
|
||||||
'medium-screen': width > 500 && width < 750,
|
'medium-screen': width > 500 && width < 750,
|
||||||
@@ -62,6 +63,19 @@ export default defineComponent({
|
|||||||
);
|
);
|
||||||
const activeCategoryId = ref<number | undefined>(undefined);
|
const activeCategoryId = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const cardsPerRow = computed<number>(() => {
|
||||||
|
if (width.value === undefined) {
|
||||||
|
throw new Error('Unknown width, total cards should not be calculated');
|
||||||
|
}
|
||||||
|
if (width.value <= 500) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (width.value < 750) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
});
|
||||||
|
|
||||||
function onSelected(categoryId: number, isExpanded: boolean) {
|
function onSelected(categoryId: number, isExpanded: boolean) {
|
||||||
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
||||||
}
|
}
|
||||||
@@ -108,6 +122,7 @@ export default defineComponent({
|
|||||||
width,
|
width,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
activeCategoryId,
|
activeCategoryId,
|
||||||
|
cardsPerRow,
|
||||||
onSelected,
|
onSelected,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,20 +29,15 @@
|
|||||||
:category-id="categoryId"
|
:category-id="categoryId"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__expander" @click.stop>
|
<CardExpansionPanelArrow v-show="isExpanded" />
|
||||||
<div class="card__expander__close-button">
|
<ExpandCollapseTransition>
|
||||||
<FlatButton
|
<CardExpansionPanel
|
||||||
icon="xmark"
|
v-show="isExpanded"
|
||||||
@click="collapse()"
|
:category-id="categoryId"
|
||||||
/>
|
@on-collapse="collapse"
|
||||||
</div>
|
@click.stop
|
||||||
<div class="card__expander__content">
|
/>
|
||||||
<ScriptsTree
|
</ExpandCollapseTransition>
|
||||||
:category-id="categoryId"
|
|
||||||
:has-top-padding="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -51,24 +46,30 @@ import {
|
|||||||
defineComponent, computed, shallowRef,
|
defineComponent, computed, shallowRef,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
import ExpandCollapseTransition from '@/presentation/components/Shared/ExpandCollapse/ExpandCollapseTransition.vue';
|
||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
||||||
|
import CardExpansionPanel from './CardExpansionPanel.vue';
|
||||||
|
import CardExpansionPanelArrow from './CardExpansionPanelArrow.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
ScriptsTree,
|
|
||||||
AppIcon,
|
AppIcon,
|
||||||
CardSelectionIndicator,
|
CardSelectionIndicator,
|
||||||
FlatButton,
|
CardExpansionPanel,
|
||||||
|
ExpandCollapseTransition,
|
||||||
|
CardExpansionPanelArrow,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
categoryId: {
|
categoryId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
totalCardsPerRow: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
activeCategoryId: {
|
activeCategoryId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -94,6 +95,14 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cardWidth = computed<string>(() => {
|
||||||
|
const totalTimesGapIsUsedInRow = props.totalCardsPerRow - 1;
|
||||||
|
const totalGapWidthInRow = `calc(${totalTimesGapIsUsedInRow} * 15px)`; // TODO: 15px is hardcoded, $card-gap variable should be used
|
||||||
|
const availableRowWidthForCards = `calc(100% - (${totalGapWidthInRow}))`;
|
||||||
|
const availableWidthPerCard = `calc((${availableRowWidthForCards}) / ${totalTimesGapIsUsedInRow})`;
|
||||||
|
return availableWidthPerCard;
|
||||||
|
});
|
||||||
|
|
||||||
const cardElement = shallowRef<HTMLElement>();
|
const cardElement = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
const cardTitle = computed<string>(() => {
|
const cardTitle = computed<string>(() => {
|
||||||
@@ -118,6 +127,7 @@ export default defineComponent({
|
|||||||
cardTitle,
|
cardTitle,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
cardElement,
|
cardElement,
|
||||||
|
cardWidth,
|
||||||
collapse,
|
collapse,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -131,11 +141,22 @@ export default defineComponent({
|
|||||||
$card-inner-padding : 30px;
|
$card-inner-padding : 30px;
|
||||||
$arrow-size : 15px;
|
$arrow-size : 15px;
|
||||||
$expanded-margin-top : 30px;
|
$expanded-margin-top : 30px;
|
||||||
$card-horizontal-gap : $card-gap;
|
|
||||||
|
.expansion__arrow {
|
||||||
|
position: relative;
|
||||||
|
.expansion__arrow__inner {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - $arrow-size * 1.5);
|
||||||
|
top: calc(1.5 * $arrow-size);
|
||||||
|
border: solid $color-primary-darker;
|
||||||
|
border-width: 0 $arrow-size $arrow-size 0;
|
||||||
|
padding: $arrow-size;
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
transition: all 0.2s ease-in-out;
|
width: v-bind(cardWidth);
|
||||||
|
|
||||||
&__inner {
|
&__inner {
|
||||||
padding-top: $card-inner-padding;
|
padding-top: $card-inner-padding;
|
||||||
padding-right: $card-inner-padding;
|
padding-right: $card-inner-padding;
|
||||||
@@ -160,9 +181,6 @@ $card-horizontal-gap : $card-gap;
|
|||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
&:after {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
.card__inner__title {
|
.card__inner__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -184,73 +202,12 @@ $card-horizontal-gap : $card-gap;
|
|||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card__expander {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
position: relative;
|
|
||||||
background-color: $color-primary-darker;
|
|
||||||
color: $color-on-primary;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.card__expander__content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
word-break: break-word;
|
|
||||||
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
|
||||||
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__expander__close-button {
|
|
||||||
font-size: $font-size-absolute-large;
|
|
||||||
align-self: flex-end;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
@include clickable;
|
|
||||||
color: $color-primary-light;
|
|
||||||
@include hover-or-touch {
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-collapsed {
|
|
||||||
.card__inner {
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__expander {
|
|
||||||
max-height: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
.card__inner {
|
.card__inner {
|
||||||
height: auto;
|
height: auto;
|
||||||
background-color: $color-secondary;
|
background-color: $color-secondary;
|
||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
&:after { // arrow
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(-1 * #{$expanded-margin-top});
|
|
||||||
left: calc(50% - #{$arrow-size});
|
|
||||||
border-left: #{$arrow-size} solid transparent;
|
|
||||||
border-right: #{$arrow-size} solid transparent;
|
|
||||||
border-bottom: #{$arrow-size} solid $color-primary-darker;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__expander {
|
|
||||||
min-height: 200px;
|
|
||||||
margin-top: $expanded-margin-top;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
@@ -277,26 +234,26 @@ $card-horizontal-gap : $card-gap;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@mixin adaptive-card($cards-in-row) {
|
@mixin adaptive-card($cards-in-row) {
|
||||||
&.card {
|
.card {
|
||||||
$total-times-gap-is-used-in-row: $cards-in-row - 1;
|
$total-times-gap-is-used-in-row: $cards-in-row - 1;
|
||||||
$total-gap-width-in-row: $total-times-gap-is-used-in-row * $card-horizontal-gap;
|
$total-gap-width-in-row: $total-times-gap-is-used-in-row * $card-horizontal-gap;
|
||||||
$available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
|
$available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
|
||||||
$available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
|
$available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
|
||||||
width:$available-width-per-card;
|
width:$available-width-per-card;
|
||||||
.card__expander {
|
// .card__expander {
|
||||||
$all-cards-width: 100% * $cards-in-row;
|
// $all-cards-width: 100% * $cards-in-row;
|
||||||
$additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
|
// $additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
|
||||||
width: calc(#{$all-cards-width} + #{$additional-padding-width});
|
// width: calc(#{$all-cards-width} + #{$additional-padding-width});
|
||||||
}
|
// }
|
||||||
@for $nth-card from 2 through $cards-in-row { // From second card to rest
|
// @for $nth-card from 2 through $cards-in-row { // From second card to rest
|
||||||
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
|
// &:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
|
||||||
.card__expander {
|
// .card__expander {
|
||||||
$card-left: -100% * ($nth-card - 1);
|
// $card-left: -100% * ($nth-card - 1);
|
||||||
$additional-space: $card-horizontal-gap * ($nth-card - 1);
|
// $additional-space: $card-horizontal-gap * ($nth-card - 1);
|
||||||
margin-left: calc(#{$card-left} - #{$additional-space});
|
// margin-left: calc(#{$card-left} - #{$additional-space});
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
// Ensure new line after last row
|
// Ensure new line after last row
|
||||||
$card-after-last: $cards-in-row + 1;
|
$card-after-last: $cards-in-row + 1;
|
||||||
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
|
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
|
||||||
@@ -304,8 +261,4 @@ $card-horizontal-gap : $card-gap;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-screen { @include adaptive-card(3); }
|
|
||||||
.medium-screen { @include adaptive-card(2); }
|
|
||||||
.small-screen { @include adaptive-card(1); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user