|
import cc from 'classcat' |
|
import { computed } from 'vue' |
|
import { defineComponent } from 'vue' |
|
import { ref } from 'vue' |
|
import { Teleport } from 'vue' |
|
import type { ComputedRef } from 'vue' |
|
import type { Ref } from 'vue' |
|
import type { PropType } from 'vue' |
|
|
|
import './FocusedTour.scss' |
|
|
|
interface State { |
|
currentStep: Ref<number> |
|
isReady: ComputedRef<boolean> |
|
} |
|
|
|
interface Step { |
|
target: () => HTMLElement | null |
|
text: string |
|
title: string |
|
} |
|
|
|
interface Box { |
|
left: number |
|
right: number |
|
top: number |
|
bottom: number |
|
} |
|
|
|
interface Props { |
|
/** |
|
* Default `false`. If `true`, allows the focused region to accept pointer |
|
* events. |
|
*/ |
|
interactive: boolean |
|
|
|
/** |
|
* Default `false`. If `true`, will show the component. |
|
*/ |
|
show: boolean |
|
|
|
/** |
|
* An array of `Step` objects. |
|
*/ |
|
steps: Step[] |
|
} |
|
|
|
const props = { |
|
interactive: { |
|
type: Boolean, |
|
default: false, |
|
}, |
|
show: { |
|
type: Boolean, |
|
default: false, |
|
}, |
|
steps: { |
|
type: Array as PropType<Props['steps']>, |
|
}, |
|
} |
|
|
|
const zeroBox = () => ({ |
|
bottom: 0, |
|
left: 0, |
|
right: 0, |
|
top: 0, |
|
}) |
|
|
|
const getBox = (step: Step): Box => { |
|
const element = step.target() |
|
|
|
if (!element) { |
|
return zeroBox() |
|
} |
|
|
|
// @ts-ignore |
|
const rect = element.$el.getBoundingClientRect() |
|
|
|
return rect |
|
} |
|
|
|
const getFocusStyles = (step: Step | undefined) => { |
|
const box: Box = step === undefined ? zeroBox() : getBox(step) |
|
|
|
const clipPathValue = ` |
|
polygon( |
|
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, |
|
${box.left}px 0%, |
|
${box.left}px ${box.bottom}px, |
|
${box.right}px ${box.bottom}px, |
|
${box.right}px ${box.top}px, |
|
${box.left}px ${box.top}px |
|
)` |
|
|
|
return { |
|
'clip-path': clipPathValue, |
|
} |
|
} |
|
|
|
const render = (state: State) => (props: Props) => { |
|
if (props.show && !state.isReady.value) { |
|
console.warn('Cannot show focused-tour, as there are still null targets.') |
|
} |
|
|
|
const classes = { |
|
component: cc({ |
|
'ccs-focused-tour': true, |
|
hide: !props.show || !state.isReady.value, |
|
}), |
|
background: cc({ |
|
background: true, |
|
interactive: props.interactive, |
|
}), |
|
} |
|
|
|
const step = props.steps[state.currentStep.value] |
|
|
|
const styles = { |
|
focus: getFocusStyles(step), |
|
} |
|
|
|
return ( |
|
<Teleport to="body"> |
|
<div |
|
class={classes.component} |
|
onClick={() => |
|
(state.currentStep.value = state.currentStep.value === 0 ? 1 : 0) |
|
} |
|
> |
|
<div class={classes.background}> |
|
<div class="focus" style={styles.focus} /> |
|
</div> |
|
</div> |
|
</Teleport> |
|
) |
|
} |
|
|
|
const Component = defineComponent({ |
|
name: 'FocusedTour', |
|
props, |
|
setup: (props) => { |
|
const state: State = { |
|
currentStep: ref(0), |
|
isReady: computed(() => { |
|
const noNullElements = (props.steps ?? []) |
|
.map((step) => step.target()) |
|
.every((target) => target !== null) |
|
|
|
return (props.steps ?? []).length > 0 && noNullElements |
|
}), |
|
} |
|
|
|
return render(state) |
|
}, |
|
}) |
|
|
|
export { Component as FocusedTour, type Props as FocusedTourProps } |