orio-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/dist/module.cjs +5 -0
- package/dist/module.d.mts +3 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +16 -0
- package/dist/runtime/assets/css/animation.css +1 -0
- package/dist/runtime/assets/css/colors.css +1 -0
- package/dist/runtime/assets/css/cool-gradient-hover.css +23 -0
- package/dist/runtime/assets/css/main.css +1 -0
- package/dist/runtime/assets/css/scroll.css +1 -0
- package/dist/runtime/components/Button.vue +102 -0
- package/dist/runtime/components/CheckBox.vue +93 -0
- package/dist/runtime/components/ControlElement.vue +39 -0
- package/dist/runtime/components/DashedContainer.vue +59 -0
- package/dist/runtime/components/DatePicker.vue +30 -0
- package/dist/runtime/components/DateRangePicker.vue +73 -0
- package/dist/runtime/components/EmptyState.vue +81 -0
- package/dist/runtime/components/Icon.vue +40 -0
- package/dist/runtime/components/Input.vue +48 -0
- package/dist/runtime/components/LoadingSpinner.vue +6 -0
- package/dist/runtime/components/Modal.vue +69 -0
- package/dist/runtime/components/Popover.vue +249 -0
- package/dist/runtime/components/Selector.vue +208 -0
- package/dist/runtime/components/Tag.vue +21 -0
- package/dist/runtime/components/Textarea.vue +53 -0
- package/dist/runtime/components/view/Dates.vue +59 -0
- package/dist/runtime/components/view/Separator.vue +26 -0
- package/dist/runtime/components/view/Text.vue +79 -0
- package/dist/runtime/composables/index.d.ts +4 -0
- package/dist/runtime/composables/index.js +4 -0
- package/dist/runtime/composables/useApi.d.ts +10 -0
- package/dist/runtime/composables/useApi.js +9 -0
- package/dist/runtime/composables/useFuzzySearch.d.ts +10 -0
- package/dist/runtime/composables/useFuzzySearch.js +22 -0
- package/dist/runtime/composables/useModal.d.ts +15 -0
- package/dist/runtime/composables/useModal.js +28 -0
- package/dist/runtime/composables/useTheme.d.ts +6 -0
- package/dist/runtime/composables/useTheme.js +23 -0
- package/dist/runtime/index.d.ts +20 -0
- package/dist/runtime/index.js +20 -0
- package/dist/runtime/utils/icon-registry.d.ts +2 -0
- package/dist/runtime/utils/icon-registry.js +26 -0
- package/dist/types.d.mts +7 -0
- package/dist/types.d.ts +7 -0
- package/nuxt.config.ts +38 -0
- package/package.json +99 -0
- package/src/module.ts +16 -0
- package/src/runtime/assets/css/animation.css +88 -0
- package/src/runtime/assets/css/colors.css +142 -0
- package/src/runtime/assets/css/cool-gradient-hover.scss +33 -0
- package/src/runtime/assets/css/main.css +11 -0
- package/src/runtime/assets/css/scroll.css +46 -0
- package/src/runtime/components/Button.vue +110 -0
- package/src/runtime/components/CheckBox.vue +103 -0
- package/src/runtime/components/ControlElement.vue +42 -0
- package/src/runtime/components/DashedContainer.vue +60 -0
- package/src/runtime/components/DatePicker.vue +84 -0
- package/src/runtime/components/DateRangePicker.vue +74 -0
- package/src/runtime/components/EmptyState.vue +87 -0
- package/src/runtime/components/Icon.vue +51 -0
- package/src/runtime/components/Input.vue +54 -0
- package/src/runtime/components/LoadingSpinner.vue +6 -0
- package/src/runtime/components/Modal.vue +111 -0
- package/src/runtime/components/Popover.vue +249 -0
- package/src/runtime/components/Selector.vue +224 -0
- package/src/runtime/components/Tag.vue +45 -0
- package/src/runtime/components/Textarea.vue +59 -0
- package/src/runtime/components/view/Dates.vue +61 -0
- package/src/runtime/components/view/Separator.vue +30 -0
- package/src/runtime/components/view/Text.vue +83 -0
- package/src/runtime/composables/index.ts +4 -0
- package/src/runtime/composables/useApi.ts +26 -0
- package/src/runtime/composables/useFuzzySearch.ts +51 -0
- package/src/runtime/composables/useModal.ts +47 -0
- package/src/runtime/composables/useTheme.ts +31 -0
- package/src/runtime/index.ts +25 -0
- package/src/runtime/utils/icon-registry.ts +41 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, computed } from 'vue';
|
|
3
|
+
import type { ResumeDate } from './view/Dates.vue';
|
|
4
|
+
|
|
5
|
+
export interface DateRangePickerProps {
|
|
6
|
+
month?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
defineProps<DateRangePickerProps>();
|
|
10
|
+
|
|
11
|
+
const dates = defineModel<ResumeDate>('dates', { required: true });
|
|
12
|
+
|
|
13
|
+
const present = ref(dates.value.endDate !== '' && !dates.value.endDate);
|
|
14
|
+
|
|
15
|
+
watch(present, (value) => {
|
|
16
|
+
if (value) {
|
|
17
|
+
dates.value.endDate = null; // Set end date to null when present is checked
|
|
18
|
+
} else {
|
|
19
|
+
dates.value.endDate = ''; // Reset end date when present is unchecked
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const dateIsCorrect = computed(() => {
|
|
24
|
+
// Ensure that the start date is before the end date
|
|
25
|
+
if (dates.value.startDate && dates.value.endDate) {
|
|
26
|
+
return new Date(dates.value.startDate) <= new Date(dates.value.endDate);
|
|
27
|
+
}
|
|
28
|
+
return true; // If one of the dates is empty, consider it correct
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
defineExpose({ dateIsCorrect });
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div>
|
|
36
|
+
<orio-control-element>
|
|
37
|
+
<div class="date-range-picker">
|
|
38
|
+
<orio-date-picker v-model:date="dates.startDate" :month />
|
|
39
|
+
<orio-date-picker v-model:date="dates.endDate" :month />
|
|
40
|
+
<orio-check-box v-model="present"> Present </orio-check-box>
|
|
41
|
+
</div>
|
|
42
|
+
</orio-control-element>
|
|
43
|
+
<div v-if="!dateIsCorrect" class="error-message">
|
|
44
|
+
<p>Start date must be before end date.</p>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.date-range-picker {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
}
|
|
55
|
+
.date-range-picker > * {
|
|
56
|
+
min-width: 0;
|
|
57
|
+
}
|
|
58
|
+
.date-range-picker .date-picker {
|
|
59
|
+
margin-inline: 0;
|
|
60
|
+
}
|
|
61
|
+
.date-range-picker .date-picker:first-child {
|
|
62
|
+
margin-inline-end: 0.5rem;
|
|
63
|
+
}
|
|
64
|
+
.date-range-picker .checkbox {
|
|
65
|
+
margin-inline-start: 0.25rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.error-message {
|
|
69
|
+
color: red;
|
|
70
|
+
font-size: 0.875rem;
|
|
71
|
+
margin-top: 0.5rem;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
icon?: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
size?: 'small' | 'medium' | 'large';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
10
|
+
size: 'medium',
|
|
11
|
+
});
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="empty-state" :class="size">
|
|
16
|
+
<orio-icon v-if="icon" :name="icon" class="empty-state-icon" />
|
|
17
|
+
<orio-view-text
|
|
18
|
+
type="title"
|
|
19
|
+
:size="size === 'large' ? 'medium' : 'small'"
|
|
20
|
+
class="empty-state-title"
|
|
21
|
+
>
|
|
22
|
+
{{ title }}
|
|
23
|
+
</orio-view-text>
|
|
24
|
+
<orio-view-text
|
|
25
|
+
v-if="description"
|
|
26
|
+
type="subtitle"
|
|
27
|
+
class="empty-state-description"
|
|
28
|
+
>
|
|
29
|
+
{{ description }}
|
|
30
|
+
</orio-view-text>
|
|
31
|
+
<slot />
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.empty-state {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
text-align: center;
|
|
42
|
+
gap: 0.5rem;
|
|
43
|
+
padding: 2rem;
|
|
44
|
+
color: var(--color-text-secondary);
|
|
45
|
+
}
|
|
46
|
+
.empty-state.small {
|
|
47
|
+
padding: 1rem;
|
|
48
|
+
gap: 0.25rem;
|
|
49
|
+
}
|
|
50
|
+
.empty-state.small .empty-state-icon {
|
|
51
|
+
font-size: 2rem;
|
|
52
|
+
}
|
|
53
|
+
.empty-state.medium {
|
|
54
|
+
padding: 2rem;
|
|
55
|
+
gap: 0.5rem;
|
|
56
|
+
}
|
|
57
|
+
.empty-state.medium .empty-state-icon {
|
|
58
|
+
font-size: 3rem;
|
|
59
|
+
}
|
|
60
|
+
.empty-state.large {
|
|
61
|
+
padding: 3rem;
|
|
62
|
+
gap: 1rem;
|
|
63
|
+
}
|
|
64
|
+
.empty-state.large .empty-state-icon {
|
|
65
|
+
font-size: 4rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.empty-state-icon {
|
|
69
|
+
color: var(--color-text-tertiary);
|
|
70
|
+
opacity: 0.5;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.empty-state-title {
|
|
74
|
+
color: var(--color-text-secondary);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.empty-state-description {
|
|
78
|
+
color: var(--color-text-tertiary);
|
|
79
|
+
max-width: 30ch;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { iconRegistry, type IconName } from '../utils/icon-registry'
|
|
4
|
+
|
|
5
|
+
export interface IconProps {
|
|
6
|
+
name: IconName | string
|
|
7
|
+
size?: string | number
|
|
8
|
+
color?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<IconProps>(), {
|
|
12
|
+
size: '1em',
|
|
13
|
+
color: 'currentColor'
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const iconSvg = computed(() => iconRegistry[props.name] || '')
|
|
17
|
+
const sizeValue = computed(() =>
|
|
18
|
+
typeof props.size === 'number' ? `${props.size}px` : props.size
|
|
19
|
+
)
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<span
|
|
24
|
+
class="orio-icon"
|
|
25
|
+
:style="{
|
|
26
|
+
width: sizeValue,
|
|
27
|
+
height: sizeValue,
|
|
28
|
+
color: color,
|
|
29
|
+
display: 'inline-flex',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
justifyContent: 'center',
|
|
32
|
+
flexShrink: 0
|
|
33
|
+
}"
|
|
34
|
+
v-html="iconSvg"
|
|
35
|
+
/>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<style scoped>
|
|
39
|
+
.orio-icon{align-items:center;display:inline-flex;flex-shrink:0;justify-content:center}.orio-icon :deep(svg){fill:currentColor;height:100%;width:100%}
|
|
40
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineEmits<{
|
|
3
|
+
(e: 'input', value: string): void;
|
|
4
|
+
}>();
|
|
5
|
+
|
|
6
|
+
const text = defineModel<string>({ default: '' });
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<orio-control-element v-bind="$attrs">
|
|
11
|
+
<input
|
|
12
|
+
v-bind="$attrs"
|
|
13
|
+
v-model="text"
|
|
14
|
+
type="text"
|
|
15
|
+
class="text-input"
|
|
16
|
+
>
|
|
17
|
+
</orio-control-element>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<style scoped>
|
|
21
|
+
.text-input {
|
|
22
|
+
width: 100%;
|
|
23
|
+
padding: 0.5rem 0.75rem;
|
|
24
|
+
border: 1px solid var(--color-border);
|
|
25
|
+
border-radius: 4px;
|
|
26
|
+
font-size: 1rem;
|
|
27
|
+
color: var(--color-text);
|
|
28
|
+
background-color: var(--color-bg);
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
31
|
+
}
|
|
32
|
+
.text-input::placeholder {
|
|
33
|
+
color: var(--color-muted);
|
|
34
|
+
}
|
|
35
|
+
.text-input:hover {
|
|
36
|
+
border-color: var(--color-accent);
|
|
37
|
+
}
|
|
38
|
+
.text-input:focus {
|
|
39
|
+
border-color: var(--color-accent);
|
|
40
|
+
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
|
41
|
+
outline: none;
|
|
42
|
+
}
|
|
43
|
+
.text-input:disabled {
|
|
44
|
+
background-color: var(--color-surface);
|
|
45
|
+
color: var(--color-muted);
|
|
46
|
+
cursor: not-allowed;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, nextTick, watch } from 'vue';
|
|
3
|
+
import { useWindowSize, useTimeoutFn } from '@vueuse/core';
|
|
4
|
+
|
|
5
|
+
export interface OriginRect {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
origin: OriginRect | null;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const show = defineModel<Boolean>('show');
|
|
17
|
+
|
|
18
|
+
const wrapper = ref<HTMLDivElement | null>(null);
|
|
19
|
+
|
|
20
|
+
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
|
21
|
+
|
|
22
|
+
function animateToCenter(el: HTMLDivElement) {
|
|
23
|
+
useTimeoutFn(() => {
|
|
24
|
+
requestAnimationFrame(() => {
|
|
25
|
+
el.style.transform = 'translate(0, 0) scale(1)';
|
|
26
|
+
el.style.opacity = '1';
|
|
27
|
+
});
|
|
28
|
+
}, 100);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function animateOpen() {
|
|
32
|
+
await nextTick();
|
|
33
|
+
|
|
34
|
+
const el = wrapper.value;
|
|
35
|
+
if (!el) return;
|
|
36
|
+
if (!props.origin) {
|
|
37
|
+
animateToCenter(el);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { x, y, width, height } = props.origin;
|
|
42
|
+
|
|
43
|
+
el.style.transform = `
|
|
44
|
+
translate(${x - windowWidth.value / 2}px, ${y - windowHeight.value / 2}px)
|
|
45
|
+
scale(${width / el.offsetWidth}, ${height / el.offsetHeight})
|
|
46
|
+
`;
|
|
47
|
+
el.style.opacity = '0.5';
|
|
48
|
+
|
|
49
|
+
animateToCenter(el);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
watch(show, (open) => {
|
|
53
|
+
if (open) animateOpen();
|
|
54
|
+
});
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<transition name="overlay-fade">
|
|
59
|
+
<div v-if="show" class="overlay" @click.self="show = false">
|
|
60
|
+
<div ref="wrapper" class="modal">
|
|
61
|
+
<slot />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</transition>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.overlay{align-items:center;backdrop-filter:blur(6px);background:rgba(0,0,0,.45);display:flex;inset:0;justify-content:center;position:fixed;transition:opacity .25s ease}.modal{background:#fff;border-radius:1rem;box-shadow:0 25px 60px rgba(0,0,0,.25);color:var(--color-muted);max-height:90vh;max-width:90vw;padding:24px;position:absolute;transform-origin:top left;transition:transform .35s cubic-bezier(.2,.8,.2,1),opacity .3s ease;width:380px}.modal,.overlay-fade-enter-from,.overlay-fade-leave-to{opacity:0}.overlay-fade-enter-active,.overlay-fade-leave-active{transition:opacity .25s ease}
|
|
69
|
+
</style>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<div ref="trigger" @click="togglePopover()">
|
|
4
|
+
<slot :toggle="togglePopover" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<Teleport to="body">
|
|
8
|
+
<Transition name="animate-fade-slide" appear>
|
|
9
|
+
<div
|
|
10
|
+
v-if="showPopover"
|
|
11
|
+
ref="popover"
|
|
12
|
+
class="popover"
|
|
13
|
+
:style="popoverStyle"
|
|
14
|
+
>
|
|
15
|
+
<slot name="content" :toggle="togglePopover" />
|
|
16
|
+
</div>
|
|
17
|
+
</Transition>
|
|
18
|
+
</Teleport>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue';
|
|
24
|
+
import { useElementBounding } from '@vueuse/core';
|
|
25
|
+
|
|
26
|
+
const props = defineProps({
|
|
27
|
+
/**
|
|
28
|
+
* Defines where the popover is placed relative to the trigger.
|
|
29
|
+
* Acceptable single values: 'top', 'bottom', 'left', 'right'
|
|
30
|
+
* Acceptable combos: 'top-left', 'top-right', 'bottom-left', 'bottom-right',
|
|
31
|
+
* 'left-top', 'left-bottom', 'right-top', 'right-bottom'
|
|
32
|
+
* If you only provide 'top', 'bottom', 'left', or 'right',
|
|
33
|
+
* it aligns center by default.
|
|
34
|
+
*/
|
|
35
|
+
position: {
|
|
36
|
+
type: String,
|
|
37
|
+
default: 'bottom-left',
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Distance (in px) between the popover and the trigger element.
|
|
41
|
+
*/
|
|
42
|
+
offset: {
|
|
43
|
+
type: Number,
|
|
44
|
+
default: 10,
|
|
45
|
+
},
|
|
46
|
+
disabled: {
|
|
47
|
+
type: Boolean,
|
|
48
|
+
default: false,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const trigger = ref(null);
|
|
53
|
+
const popover = ref(null);
|
|
54
|
+
|
|
55
|
+
const { width: popoverWidth, height: popoverHeight } =
|
|
56
|
+
useElementBounding(popover);
|
|
57
|
+
|
|
58
|
+
const showPopover = ref(false);
|
|
59
|
+
const triggerRect = ref(null);
|
|
60
|
+
const popoverRect = ref(null);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculates the inline style for the popover based on position & offset.
|
|
64
|
+
*/
|
|
65
|
+
const popoverStyle = computed(() => {
|
|
66
|
+
if (!showPopover.value || !triggerRect.value || !popoverRect.value) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [main, sub = 'center'] = currentPosition.value.split('-');
|
|
71
|
+
const offset = props.offset;
|
|
72
|
+
|
|
73
|
+
const tRect = triggerRect.value;
|
|
74
|
+
const pRect = popoverRect.value;
|
|
75
|
+
|
|
76
|
+
let topValue = 0;
|
|
77
|
+
let leftValue = 0;
|
|
78
|
+
|
|
79
|
+
// Calculate vertical position (top)
|
|
80
|
+
if (main === 'top') {
|
|
81
|
+
topValue = tRect.top - offset - pRect.height;
|
|
82
|
+
} else if (main === 'bottom') {
|
|
83
|
+
topValue = tRect.bottom + offset;
|
|
84
|
+
} else {
|
|
85
|
+
// For 'left' or 'right' main, center vertically
|
|
86
|
+
topValue = tRect.top + tRect.height / 2 - pRect.height / 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Calculate horizontal position (left)
|
|
90
|
+
if (sub === 'left') {
|
|
91
|
+
leftValue = tRect.right - pRect.width;
|
|
92
|
+
} else if (sub === 'right') {
|
|
93
|
+
leftValue = tRect.left;
|
|
94
|
+
} else {
|
|
95
|
+
// 'center' is default horizontally
|
|
96
|
+
leftValue = tRect.left + tRect.width / 2 - pRect.width / 2;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If the main position is 'left' or 'right', override horizontal positioning
|
|
100
|
+
if (main === 'left') {
|
|
101
|
+
leftValue = tRect.left - offset - pRect.width;
|
|
102
|
+
} else if (main === 'right') {
|
|
103
|
+
leftValue = tRect.right + offset;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
top: `${topValue}px`,
|
|
108
|
+
left: `${leftValue}px`,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const currentPosition = ref(props.position);
|
|
113
|
+
|
|
114
|
+
const getFallbackPositions = (pos: string) => {
|
|
115
|
+
const [main, sub = 'center'] = pos.split('-');
|
|
116
|
+
|
|
117
|
+
const opposites: Record<string, string> = {
|
|
118
|
+
top: 'bottom',
|
|
119
|
+
bottom: 'top',
|
|
120
|
+
left: 'right',
|
|
121
|
+
right: 'left',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const allPositions = [
|
|
125
|
+
`${main}-${sub}`,
|
|
126
|
+
`${opposites[main]}-${sub}`,
|
|
127
|
+
`${main}-center`,
|
|
128
|
+
`${opposites[main]}-center`,
|
|
129
|
+
`${sub}-${main}`, // e.g. left-top
|
|
130
|
+
`${sub}-${opposites[main]}`,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
return [...new Set(allPositions)];
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function checkIfFits(position: string, tRect: any, pRect: any, offset: number) {
|
|
137
|
+
const [main, sub = 'center'] = position.split('-');
|
|
138
|
+
|
|
139
|
+
const space = {
|
|
140
|
+
top: tRect.top,
|
|
141
|
+
bottom: window.innerHeight - tRect.bottom,
|
|
142
|
+
left: tRect.left,
|
|
143
|
+
right: window.innerWidth - tRect.right,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (main === 'top' && space.top < pRect.height + offset) return false;
|
|
147
|
+
if (main === 'bottom' && space.bottom < pRect.height + offset) return false;
|
|
148
|
+
if (main === 'left' && space.left < pRect.width + offset) return false;
|
|
149
|
+
if (main === 'right' && space.right < pRect.width + offset) return false;
|
|
150
|
+
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Calculates bounding client rects for trigger & popover,
|
|
156
|
+
* updating reactive refs.
|
|
157
|
+
*/
|
|
158
|
+
async function updateRects() {
|
|
159
|
+
await nextTick();
|
|
160
|
+
const triggerEl = trigger.value;
|
|
161
|
+
const popoverEl = popover.value?.firstElementChild || popover.value;
|
|
162
|
+
|
|
163
|
+
if (!triggerEl || !popoverEl) return;
|
|
164
|
+
|
|
165
|
+
const tRect = triggerEl.getBoundingClientRect();
|
|
166
|
+
triggerRect.value = tRect;
|
|
167
|
+
|
|
168
|
+
const fallbacks = getFallbackPositions(props.position);
|
|
169
|
+
|
|
170
|
+
for (const pos of fallbacks) {
|
|
171
|
+
// temporarily apply style to measure it
|
|
172
|
+
popoverEl.style.visibility = 'hidden';
|
|
173
|
+
popoverEl.style.display = 'block';
|
|
174
|
+
|
|
175
|
+
const pRect = popoverEl.getBoundingClientRect();
|
|
176
|
+
const fits = checkIfFits(pos, tRect, pRect, props.offset);
|
|
177
|
+
|
|
178
|
+
if (fits) {
|
|
179
|
+
popoverRect.value = pRect;
|
|
180
|
+
currentPosition.value = pos;
|
|
181
|
+
popoverEl.style.visibility = '';
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// fallback to original
|
|
187
|
+
popoverRect.value = popoverEl.getBoundingClientRect();
|
|
188
|
+
currentPosition.value = props.position;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Toggles popover visibility.
|
|
193
|
+
* @param {boolean|null} force - If `true`/`false`, force that state; if `null`, toggle.
|
|
194
|
+
*/
|
|
195
|
+
async function togglePopover(force: boolean | null = null) {
|
|
196
|
+
if (props.disabled) return;
|
|
197
|
+
showPopover.value = force !== null ? force : !showPopover.value;
|
|
198
|
+
if (!showPopover.value) return;
|
|
199
|
+
|
|
200
|
+
await nextTick();
|
|
201
|
+
updateRects();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Closes the popover if the click is outside trigger/popover.
|
|
206
|
+
*/
|
|
207
|
+
function handleDocumentClick(e: MouseEvent) {
|
|
208
|
+
if (!showPopover.value) return;
|
|
209
|
+
|
|
210
|
+
const clickedInsideTrigger =
|
|
211
|
+
trigger.value && trigger.value.contains(e.target as Node);
|
|
212
|
+
const clickedInsidePopover =
|
|
213
|
+
popover.value && popover.value.contains(e.target as Node);
|
|
214
|
+
|
|
215
|
+
if (!clickedInsideTrigger && !clickedInsidePopover) {
|
|
216
|
+
showPopover.value = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Updates position of popover on scroll/resize if popover is open.
|
|
222
|
+
*/
|
|
223
|
+
function redrawPopover() {
|
|
224
|
+
if (!showPopover.value) return;
|
|
225
|
+
updateRects();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
watch([popoverWidth, popoverHeight], redrawPopover);
|
|
229
|
+
|
|
230
|
+
onMounted(() => {
|
|
231
|
+
document.addEventListener('click', handleDocumentClick);
|
|
232
|
+
window.addEventListener('scroll', redrawPopover, true);
|
|
233
|
+
window.addEventListener('resize', redrawPopover, true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
onBeforeUnmount(() => {
|
|
237
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
238
|
+
window.removeEventListener('scroll', redrawPopover, true);
|
|
239
|
+
window.removeEventListener('resize', redrawPopover, true);
|
|
240
|
+
});
|
|
241
|
+
</script>
|
|
242
|
+
<style scoped>
|
|
243
|
+
.popover {
|
|
244
|
+
border: 0;
|
|
245
|
+
background-color: transparent;
|
|
246
|
+
position: fixed;
|
|
247
|
+
z-index: 1;
|
|
248
|
+
}
|
|
249
|
+
</style>
|