Files
privacy.sexy/src/presentation/components/Scripts/View/Cards/CardListItem.vue
undergroundwires 65f121c451 Introduce new TreeView UI component
Key highlights:

- Written from scratch to cater specifically to privacy.sexy's
  needs and requirements.
- The visual look mimics the previous component with minimal changes,
  but its internal code is completely rewritten.
- Lays groundwork for future functionalities like the "expand all"
  button a flat view mode as discussed in #158.
- Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent
  `liquour-tree` as part of #230.

Improvements and features:

- Caching for quicker node queries.
- Gradual rendering of nodes that introduces a noticable boost in
  performance, particularly during search/filtering.
  - `TreeView` solely governs the check states of branch nodes.

Changes:

- Keyboard interactions now alter the background color to highlight the
  focused item. Previously, it was changing the color of the text.
- Better state management with clear separation of concerns:
  - `TreeView` exclusively manages indeterminate states.
  - `TreeView` solely governs the check states of branch nodes.
  - Introduce transaction pattern to update state in batches to minimize
    amount of events handled.
- Improve keyboard focus, style background instead of foreground. Use
  hover/touch color on keyboard focus.
- `SelectableTree` has been removed. Instead, `TreeView` is now directly
  integrated with `ScriptsTree`.
- `ScriptsTree` has been refactored to incorporate hooks for clearer
  code and separation of duties.
- Adopt Vue-idiomatic bindings instead of keeping a reference of the
  tree component.
- Simplify and change filter event management.
- Abandon global styles in favor of class-scoped styles.
- Use global mixins with descriptive names to clarify indended
  functionality.
2023-09-09 22:26:21 +02:00

333 lines
8.5 KiB
Vue

<template>
<div
class="card"
v-on:click="isExpanded = !isExpanded"
v-bind:class="{
'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
'is-expanded': isExpanded,
}"
ref="cardElement">
<div class="card__inner">
<!-- Title -->
<span
class="card__inner__title"
v-if="cardTitle && cardTitle.length > 0">
<span>{{cardTitle}}</span>
</span>
<span v-else>Oh no 😢</span>
<!-- Expand icon -->
<font-awesome-icon
class="card__inner__expand-icon"
:icon="['far', isExpanded ? 'folder-open' : 'folder']"
/>
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<font-awesome-icon
:icon="['fa', 'battery-half']"
v-if="isAnyChildSelected && !areAllChildrenSelected"
/>
<font-awesome-icon
:icon="['fa', 'battery-full']"
v-if="areAllChildrenSelected"
/>
</div>
</div>
<div class="card__expander" v-on:click.stop>
<div class="card__expander__content">
<ScriptsTree :categoryId="categoryId" />
</div>
<div class="card__expander__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="collapse()"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, watch, computed,
inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export default defineComponent({
components: {
ScriptsTree,
},
props: {
categoryId: {
type: Number,
required: true,
},
activeCategoryId: {
type: Number,
default: undefined,
},
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
cardExpansionChanged: (isExpanded: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const isExpanded = computed({
get: () => {
return props.activeCategoryId === props.categoryId;
},
set: (newValue) => {
if (newValue) {
scrollToCard();
}
emit('cardExpansionChanged', newValue);
},
});
const isAnyChildSelected = ref(false);
const areAllChildrenSelected = ref(false);
const cardElement = ref<HTMLElement>();
const cardTitle = computed<string | undefined>(() => {
if (!props.categoryId || !currentState.value) {
return undefined;
}
const category = currentState.value.collection.findCategory(props.categoryId);
return category?.name;
});
function collapse() {
isExpanded.value = false;
}
onStateChange((state) => {
events.unsubscribeAllAndRegister([
state.selection.changed.on(
() => updateSelectionIndicators(props.categoryId),
),
]);
updateSelectionIndicators(props.categoryId);
}, { immediate: true });
watch(
() => props.categoryId,
(categoryId) => updateSelectionIndicators(categoryId),
);
async function scrollToCard() {
await sleep(400); // wait a bit to allow GUI to render the expanded card
cardElement.value.scrollIntoView({ behavior: 'smooth' });
}
function updateSelectionIndicators(categoryId: number) {
const category = currentState.value.collection.findCategory(categoryId);
const { selection } = currentState.value;
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
areAllChildrenSelected.value = category ? selection.areAllSelected(category) : false;
}
return {
cardTitle,
isExpanded,
isAnyChildSelected,
areAllChildrenSelected,
cardElement,
collapse,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
$card-inner-padding : 30px;
$arrow-size : 15px;
$expanded-margin-top : 30px;
$card-horizontal-gap : $card-gap;
.card {
transition: all 0.2s ease-in-out;
&__inner {
padding-top: $card-inner-padding;
padding-right: $card-inner-padding;
padding-bottom: 0;
padding-left: $card-inner-padding;
position: relative;
@include clickable;
background-color: $color-primary;
color: $color-on-primary;
font-size: 1.5em;
height: 100%;
width: 100%;
text-transform: uppercase;
text-align: center;
transition: all 0.2s ease-in-out;
display:flex;
flex-direction: column;
justify-content: center;
@include hover-or-touch {
background-color: $color-secondary;
color: $color-on-secondary;
transform: scale(1.05);
}
&:after {
transition: all 0.3s ease-in-out;
}
&__title {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
}
&__state-icons {
height: $card-inner-padding;
margin-right: -$card-inner-padding;
padding-right: 10px;
display: flex;
justify-content: flex-end;
}
&__expand-icon {
width: 100%;
margin-top: .25em;
vertical-align: middle;
}
}
&__expander {
transition: all 0.2s ease-in-out;
position: relative;
background-color: $color-primary-darker;
color: $color-on-primary;
display: flex;
align-items: center;
&__content {
flex: 1;
display: flex;
justify-content: center;
word-break: break-word;
}
&__close-button {
width: auto;
font-size: 1.5em;
align-self: flex-start;
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 {
.card__inner {
height: auto;
background-color: $color-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;
// max-height: 1000px;
// overflow-y: auto;
margin-top: $expanded-margin-top;
opacity: 1;
}
@include hover-or-touch {
.card__inner {
transform: scale(1);
}
}
}
&.is-inactive {
.card__inner {
pointer-events: none;
height: auto;
background-color: $color-primary-light;
transform: scale(0.95);
}
@include hover-or-touch {
.card__inner {
background-color: $color-primary;
transform: scale(1);
}
}
}
}
@mixin adaptive-card($cards-in-row) {
&.card {
$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;
$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});
width:$available-width-per-card;
.card__expander {
$all-cards-width: 100% * $cards-in-row;
$additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
width: calc(#{$all-cards-width} + #{$additional-padding-width});
}
@for $nth-card from 2 through $cards-in-row { // From second card to rest
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
.card__expander {
$card-left: -100% * ($nth-card - 1);
$additional-space: $card-horizontal-gap * ($nth-card - 1);
margin-left: calc(#{$card-left} - #{$additional-space});
}
}
}
// Ensure new line after last row
$card-after-last: $cards-in-row + 1;
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
clear: left;
}
}
}
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style>