Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { handlers as mswHandlers } from './mswHandlers'
import 'dripicons/webfont/webfont.css'
import '../src/assets/legacy/bootstrap-impresso-theme.css'
import '../src/assets/legacy/bootstrap-vue.css'
import '../src/styles/style.css'

/*
* Initializes MSW
Expand Down
124 changes: 124 additions & 0 deletions src/components/CollapsiblePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<template>
<div ref="rootRef" class="CollapsiblePanel" :class="className" :style="rootStyles">
<div
ref="headerRef"
class="CollapsiblePanel__header d-flex justify-content-between align-items-center"
@click="togglePanelState"
>
<slot name="header">
<div class="p-3">
<h4 class="m-0">{{ title }}</h4>
<p v-if="subtitle.length" class="mb-0">subtitle</p>
</div>
</slot>
<button
class="btn btn-sm btn-icon mx-2"
:class="{
active: modelValue
}"
>
<Icon name="chevron" />
</button>
</div>
<div class="CollapsiblePanel__body position-absolute">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
/**
* Model value is used to control the visibility of the panel.
*/

import { ref, computed, watch } from 'vue'
import Icon from './base/Icon.vue'

const emit = defineEmits(['update:modelValue', 'heightChanged'])

const props = defineProps({
modelValue: {
type: Boolean,
default: false,
required: true
},
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
className: {
type: String,
default: ''
}
})

const rootRef = ref<HTMLElement | null>(null)
const headerRef = ref<HTMLElement | null>(null)

const togglePanelState = () => {
emit('update:modelValue', !props.modelValue)
}

const collapsedHeight = computed(() => {
const header = headerRef.value
return header ? `${header.offsetHeight}px` : 'auto'
})

const expandedHeight = computed(() => {
const root = rootRef.value
const parsedOffset = parseInt(collapsedHeight.value)
const offset = isNaN(parsedOffset) ? 0 : parsedOffset
return root ? `${root.scrollHeight + offset}px` : 'auto'
})

const rootStyles = computed(() => {
return {
height: props.modelValue ? expandedHeight.value : collapsedHeight.value
}
})

watch(
() => rootStyles.value.height,
(height) => {
emit('heightChanged', height)
},
{ immediate: true }
)
</script>

<style>
.CollapsiblePanel {
position: relative;
overflow: hidden;
will-change: height;
transition: height 0.6s;
transition-timing-function: var(--impresso-transition-ease);
}

.CollapsiblePanel__header {
cursor: pointer;
user-select: none;
box-sizing: border-box;
}

.CollapsiblePanel h3 {
font-family: var(--bs-font-sans-serif);
}

.CollapsiblePanel__body {
min-height: 50px;
width: 100%;
}

.CollapsiblePanel button {
transition: transform 0.3s;
transition-timing-function: var(--impresso-transition-ease);
}

.CollapsiblePanel button.active {
transform: rotate(180deg);
}
</style>
119 changes: 119 additions & 0 deletions src/components/TutorialMonitor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<template>
<CollapsiblePanel
class="TutorialMonitor border border-dark"
v-model="isOpen"
:subtitle="subtitle"
:title="title"
:style="{ height: isOpen ? `${offsetHeight}px` : `${headerHeight}px` }"
>
<template v-slot:header>
<div class="p-3">
<h3 class="mb-0">{{ title }}</h3>
<span class="text-muted">{{ subtitle }}</span>
</div>
</template>
<ol>
<li v-for="(task, idx) in tasks" :key="task.id">
<TutorialTaskPanel
:modelValue="currentOpenTaskId === task.id"
:task="task"
:taskNum="idx + 1"
@update:modelValue="(value: boolean) => (currentOpenTaskId = value ? task.id : null)"
@heightChanged="e => updateHeight(task.id, e)"
>
</TutorialTaskPanel>
</li>
</ol>
</CollapsiblePanel>
</template>
<script setup lang="ts">
import TutorialTaskPanel from '@/components/TutorialTaskPanel.vue'
import { ITutorialTask } from '@/models/TutorialTask'
import { computed, PropType, ref, watch } from 'vue'
import CollapsiblePanel from './CollapsiblePanel.vue'

// TODO: Get from the element?
const headerHeight = 50

const offsetHeights = ref<Record<string, number>>({})
const offsetHeight = computed(
() => headerHeight + Object.values(offsetHeights.value).reduce((a, b) => a + b, 0)
)

const props = defineProps({
title: {
type: String,
required: true
},
isCollapsed: {
type: Boolean,
default: true
},
subtitle: {
type: String,
default: ''
},
tasks: {
type: Array as PropType<ITutorialTask[]>,
required: true
},
initialOpenedTaskId: {
type: String,
default: null
}
})

const isOpen = ref(!props.isCollapsed)
const currentOpenTaskId = ref<string | null>(props.initialOpenedTaskId)

watch(
() => props.isCollapsed,
isCollapsed => {
isOpen.value = !isCollapsed
}
)

const updateHeight = (id: string, height: string) => {
const heightAsNumber = parseInt(height.replace('px', ''))
offsetHeights.value[id] = isNaN(heightAsNumber) ? 0 : heightAsNumber
}

// const onTutorialTaskToggled = (idx: number, payload: CollapsiblePanelData) => {
// console.debug('[TutorialMonitor] idx', idx, '@onTutorialTaskToggled', payload)
// offsetHeights[idx] = payload.value ? 50 : payload.expandedHeight
// }
</script>

<style>
.TutorialMonitor.CollapsiblePanel {
box-shadow: var(--bs-box-shadow-sm);
border-radius: var(--border-radius-lg);
}

.TutorialMonitor ol {
list-style-type: none;
padding-inline-start: 0;
padding-inline-end: 0;
margin-inline-start: var(--spacing-2);
margin-inline-end: var(--spacing-2);

margin-bottom: var(--spacing-1);
}

.TutorialMonitor li {
border-top: 1px solid var(--clr-grey-200);
}

.TutorialMonitor li:first-of-type {
border-top-width: 0px;
}

.TutorialMonitor h3 {
font-size: var(--impresso-font-size-smallcaps);
font-family: var(--bs-font-sans-serif);
text-transform: uppercase;
letter-spacing: var(--impresso-letter-spacing-smallcaps);
font-weight: var(--impresso-wght-smallcaps);
font-variation-settings: 'wght' var(--impresso-wght-smallcaps);
}
</style>
82 changes: 82 additions & 0 deletions src/components/TutorialTaskPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<CollapsiblePanel
class="TutorialTask"
v-model="state"
:title="task.title"
@heightChanged="e => emit('heightChanged', e)"
>
<template v-slot:header>
<div class="d-flex align-items-center">
<div class="TutorialTask__num py-3 px-2 mb-0">{{ taskNum }}</div>
<h4 class="mb-0">{{ task.title }} {{ task.getCompletion() }}</h4>
</div>
</template>
<img v-if="task.coverUrl" :src="task.coverUrl" alt="task screencast video" class="img-fluid" />
<div v-html="task.description"></div>
<ol v-if="task.tasks.length">
<li
v-for="(subtask, idx) in task.tasks"
:key="subtask.id"
class="pt-2 mb-2 mx-2 d-flex align-items-center"
>
<div class="p-1 flex-grow-1">
{{ subtask.title }}
</div>
<div class="pr-2 flex-shrink-1">{{ subtask.status }}</div>
</li>
</ol>
</CollapsiblePanel>
</template>

<script setup lang="ts">
import { computed, PropType } from 'vue'
import CollapsiblePanel from './CollapsiblePanel.vue'
import { ITutorialTask } from '@/models/TutorialTask'

const emit = defineEmits(['update:modelValue', 'heightChanged'])

const props = defineProps({
modelValue: {
type: Boolean,
default: false,
required: true
},
task: {
type: Object as PropType<ITutorialTask>,
required: true
},
taskNum: {
type: Number,
required: true
}
})

const state = computed({
set(value: boolean) {
emit('update:modelValue', value)
},
get() {
return props.modelValue
}
})
</script>
<style>
.TutorialTask h4 {
font-family: var(--bs-font-sans-serif);
font-size: inherit;
}

.TutorialTask__num {
font-size: var(--impresso-font-size-smallcaps);
font-family: var(--bs-font-sans-serif);
text-transform: uppercase;
letter-spacing: var(--impresso-letter-spacing-smallcaps);
font-weight: var(--impresso-wght-smallcaps);
font-variation-settings: 'wght' var(--impresso-wght-smallcaps);
}

.TutorialTask.CollapsiblePanel {
box-shadow: none;
border-radius: 0;
}
</style>
Loading