initial commit
This commit is contained in:
24
components/vlAccordion/content.vue
Normal file
24
components/vlAccordion/content.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
const item = inject('accordionItem');
|
||||
const contentHeight = ref(0);
|
||||
const content = ref(null);
|
||||
|
||||
watch(item.hidden, () => {
|
||||
if (!item.hidden.value) {
|
||||
setTimeout(() => {
|
||||
contentHeight.value = content.value.offsetHeight;
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="`vueless-${item.index}`" role="region" :aria-labelledby="`vueless-${item.index}`" class="vl-accordion-content"
|
||||
:style="`--vueless-accordion-content-height: ${contentHeight}px`"
|
||||
:data-state="(item.isOpen.value) ? 'open' : 'closed'"
|
||||
@animationend="(!item.isOpen.value) ? item.hidden.value = true : ''" :hidden="item.hidden.value">
|
||||
<div ref="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
194
components/vlAccordion/index.vue
Normal file
194
components/vlAccordion/index.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "multiple"
|
||||
},
|
||||
defaultValue: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
|
||||
const accordion = ref(null);
|
||||
|
||||
const accordionItems = ref([])
|
||||
|
||||
function toggleAccordion(index) {
|
||||
const item = accordionItems.value[index];
|
||||
if (props.type === "single") {
|
||||
// close everything but the one we just opened
|
||||
accordionItems.value.forEach((item, i) => {
|
||||
if (i === index) return;
|
||||
item.isOpen = false;
|
||||
})
|
||||
}
|
||||
if (item.hidden) {
|
||||
item.hidden = false;
|
||||
}
|
||||
item.isOpen = !item.isOpen;
|
||||
}
|
||||
|
||||
const registerAccordionItem = (value) => {
|
||||
const item = { isOpen: ref(false), hidden: ref(true), value }
|
||||
accordionItems.value.push(item);
|
||||
|
||||
return { index: accordionItems.value.indexOf(item), isOpen: item.isOpen, hidden: item.hidden, value };
|
||||
};
|
||||
|
||||
const unregisterAccordionItem = (index) => {
|
||||
accordionItems.value.splice(index, 1);
|
||||
};
|
||||
|
||||
provide('accordion', { registerAccordionItem, unregisterAccordionItem, toggleAccordion })
|
||||
|
||||
function keydown(event) {
|
||||
const headers = Array.from(accordion.value.querySelectorAll(".vl-accordion-header"));
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const focusedElement = document.activeElement;
|
||||
const currentIndex = headers.indexOf(focusedElement.parentElement);
|
||||
const nextIndex = currentIndex > 0 ? currentIndex - 1 : headers.length - 1;
|
||||
const nextButton = headers[nextIndex].querySelector("button");
|
||||
nextButton.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const focusedElement = document.activeElement;
|
||||
const currentIndex = headers.indexOf(focusedElement.parentElement);
|
||||
const nextIndex = currentIndex < headers.length - 1 ? currentIndex + 1 : 0;
|
||||
const nextButton = headers[nextIndex].querySelector("button");
|
||||
nextButton.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
return headers[headers.length - 1].querySelector("button").focus();
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
return headers[0].querySelector("button").focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.defaultValue) {
|
||||
const item = accordionItems.value.filter(item => item.value === props.defaultValue)[0];
|
||||
item.isOpen = true;
|
||||
item.hidden = false;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vl-accordion" ref="accordion" @keydown="keydown($event)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vl-accordion {
|
||||
border-radius: 6px;
|
||||
background-color: #1D1E1F;
|
||||
box-shadow: 0 0 16px 0 #07070738;
|
||||
width: 70%;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vl-accordion-content {
|
||||
width: var(--vueless-accordion-content-width);
|
||||
background-color: #0B0C0D;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.vl-accordion-item:focus-within {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: #4e367e 0px 0px 0px 2px
|
||||
}
|
||||
|
||||
.vl-accordion-content div {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.vl-accordion-content[data-state="closed"] {
|
||||
animation: 300ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards running closeAccordion;
|
||||
}
|
||||
|
||||
.vl-accordion-content[data-state="open"] {
|
||||
animation: 300ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards running openAccordion;
|
||||
}
|
||||
|
||||
.vl-accordion-item h3 button svg {
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.vl-accordion-item[data-state="open"] h3 button svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vl-accordion-item {
|
||||
background-color: #161718;
|
||||
height: 100%;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vl-accordion-item:first-child {
|
||||
margin-top: 0px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.vl-accordion-item:last-child {
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.vl-accordion-header:hover {
|
||||
background-color: #131415;
|
||||
}
|
||||
|
||||
.vl-accordion-header button {
|
||||
all: unset;
|
||||
background-color: transparent;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 1;
|
||||
padding: 0px 20px;
|
||||
flex: 1 1 0%;
|
||||
box-shadow: #1D1E1F 0px 1px 0px;
|
||||
}
|
||||
|
||||
.vl-accordion-header {
|
||||
all: unset;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes closeAccordion {
|
||||
0% {
|
||||
height: var(--vueless-accordion-content-height);
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes openAccordion {
|
||||
0% {
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: var(--vueless-accordion-content-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
components/vlAccordion/item.vue
Normal file
19
components/vlAccordion/item.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
}
|
||||
})
|
||||
const accordion = inject('accordion')
|
||||
|
||||
const item = accordion.registerAccordionItem(props.value);
|
||||
|
||||
provide('accordionItem', item);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vl-accordion-item" :data-state="(item.isOpen.value) ? 'open' : 'closed'">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
components/vlAccordion/trigger.vue
Normal file
13
components/vlAccordion/trigger.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
const item = inject('accordionItem');
|
||||
const { toggleAccordion } = inject('accordion');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 class="vl-accordion-header">
|
||||
<button :id="`vueless-${item.index}`" :aria-controls="`vueless-${item.index}`" :aria-expanded="item.isOpen.value"
|
||||
@click="toggleAccordion(item.index)">
|
||||
<slot />
|
||||
</button>
|
||||
</h3>
|
||||
</template>
|
||||
151
components/vlDropdown.vue
Normal file
151
components/vlDropdown.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
const hex = ref((Math.random() * 0xfffff * 1000000).toString(16).slice(0, 6).toString());
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownButton = ref(null);
|
||||
const dropdown = ref(null);
|
||||
|
||||
function dropdownButtonKeypress(event) {
|
||||
if (event.key !== "Enter" && event.key !== " " && event.key !== "ArrowUp" && event.key !== "ArrowDown" || dropdownOpen.value === true) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
dropdownOpen.value = true;
|
||||
setTimeout(() => {
|
||||
dropdown.value.children[dropdown.value.children.length - 1].focus();
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
dropdownOpen.value = true;
|
||||
setTimeout(() => {
|
||||
dropdown.value.children[1].focus();
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
setTimeout(() => {
|
||||
dropdown.value.children[1].focus();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function dropdownKeypress(event) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const focusedElement = document.activeElement;
|
||||
const elements = Array.from(dropdown.value.children).filter(
|
||||
(el) => el.tabIndex === 0
|
||||
);
|
||||
const currentIndex = elements.indexOf(focusedElement);
|
||||
const nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1;
|
||||
elements[nextIndex].focus();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const focusedElement = document.activeElement;
|
||||
const elements = Array.from(dropdown.value.children).filter(
|
||||
(el) => el.tabIndex === 0
|
||||
);
|
||||
const currentIndex = elements.indexOf(focusedElement);
|
||||
const nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0;
|
||||
elements[nextIndex].focus();
|
||||
}
|
||||
|
||||
if (event.key === "Escape" || event.key === "Esc") {
|
||||
event.preventDefault();
|
||||
dropdownButton.value.focus();
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vl-dropdown-button">
|
||||
<button :id="`menubutton-${hex}`" :aria-controls="`menu-${hex}`" class="vl-button"
|
||||
@click.exact="dropdownOpen = !dropdownOpen;" @keydown="dropdownButtonKeypress($event)" ref="dropdownButton"
|
||||
aria-haspopup="menu" :aria-expanded="dropdownOpen" :data-state="(dropdownOpen) ? 'open' : 'closed'">
|
||||
Dropdown
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m6 9l6 6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<Transition name="vl-dropdown">
|
||||
<div class="vl-dropdown" :id="`menu-${hex}`" tabindex="-1" :aria-labelledby="`menubutton-${hex}`"
|
||||
:data-state="(dropdownOpen) ? 'open' : 'closed'" :key="dropdownOpen" :hidden="!dropdownOpen" role="menu"
|
||||
@keydown="dropdownKeypress($event)" :aria-hidden="!dropdownOpen" ref="dropdown">
|
||||
<span class="vl-diamond"><svg style="display:block" width="10" height="5" viewBox="0 0 30 10"
|
||||
preserveAspectRatio="none">
|
||||
<polygon points="0,0 30,0 15,10" style=""></polygon>
|
||||
</svg></span>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vl-dropdown-button .vl-button[data-state="open"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vl-dropdown-button .vl-button svg {
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.vl-dropdown-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vl-diamond {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
transform-origin: center 0px 0px;
|
||||
transform: rotate(180deg) translateX(50%);
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.vl-diamond svg {
|
||||
fill: #070809;
|
||||
}
|
||||
|
||||
.vl-dropdown[data-state="open"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.vl-dropdown {
|
||||
position: absolute;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
margin-top: 8px;
|
||||
left: -25%;
|
||||
width: 150%;
|
||||
background: #070809;
|
||||
border-radius: 6px;
|
||||
flex-direction: column;
|
||||
gap: 2px 0;
|
||||
}
|
||||
|
||||
.vl-dropdown button {
|
||||
width: 100%;
|
||||
background: unset;
|
||||
border: unset;
|
||||
justify-content: start;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vl-dropdown button:hover {
|
||||
background: #090a0b;
|
||||
}
|
||||
|
||||
.vl-dropdown button:focus {
|
||||
outline: 0;
|
||||
background-color: hsl(226, 79%, 37.5%);
|
||||
}
|
||||
</style>
|
||||
227
components/vlSlider.vue
Normal file
227
components/vlSlider.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 0.01
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const modelValue = ref(props.value);
|
||||
|
||||
watch(modelValue, (newValue) => {
|
||||
emit('update:modelValue', newValue);
|
||||
});
|
||||
emit('update:modelValue', props.value);
|
||||
|
||||
function roundValueToStep(number) {
|
||||
const clampedNumber = Math.min(Math.max(number, props.min), props.max);
|
||||
const quotient = Math.floor((clampedNumber - props.min) / props.step);
|
||||
const lowerValue = props.min + quotient * props.step;
|
||||
const upperValue = lowerValue + props.step;
|
||||
|
||||
if (clampedNumber - lowerValue < upperValue - clampedNumber) {
|
||||
return lowerValue; // Round down
|
||||
} else {
|
||||
return upperValue; // Round up
|
||||
}
|
||||
}
|
||||
|
||||
const rangeSlider = ref(null);
|
||||
const rangeThumb = ref(null);
|
||||
const rangeValue = ref({
|
||||
real: ref(roundValueToStep(props.value)),
|
||||
stepped: ref(roundValueToStep(props.value))
|
||||
});
|
||||
|
||||
const sliderPercentage = ref((rangeValue.value.real / props.max) * 100);
|
||||
const steppedSliderPercentage = ref((rangeValue.value.stepped / props.max) * 100)
|
||||
const steps = (props.max / props.step)
|
||||
|
||||
function snapToStep(percentage) {
|
||||
const pixelsPerStep = rangeSlider.value.clientWidth / steps;
|
||||
let step = roundValueToStep((((percentage / 100) * rangeSlider.value.clientWidth) / pixelsPerStep) * props.step);
|
||||
|
||||
const split = step.toString().split('.');
|
||||
// fix float imprecisions by making sure the number only has at most 3 digits after the decimal point
|
||||
if (split.length > 1) {
|
||||
split[1] = split[1].slice(0,3);
|
||||
step = parseFloat(split.join('.'));
|
||||
}
|
||||
|
||||
rangeValue.value.stepped = step;
|
||||
modelValue.value = step;
|
||||
|
||||
return (rangeValue.value.stepped / props.max) * 100;
|
||||
}
|
||||
|
||||
function calculateSliderPosition(pointerEvent) {
|
||||
if (!rangeSlider.value) return;
|
||||
let pointerPosFromRangeStart = pointerEvent.clientX - rangeSlider.value.getClientRects()[0].x;
|
||||
|
||||
if (pointerPosFromRangeStart < 0) {
|
||||
pointerPosFromRangeStart = 0;
|
||||
}
|
||||
|
||||
if (pointerPosFromRangeStart > rangeSlider.value.clientWidth) {
|
||||
pointerPosFromRangeStart = rangeSlider.value.clientWidth;
|
||||
}
|
||||
|
||||
const value = (pointerPosFromRangeStart / rangeSlider.value.clientWidth);
|
||||
|
||||
const newSliderPercentage = value * 100;
|
||||
|
||||
if (newSliderPercentage === sliderPercentage.value) return;
|
||||
|
||||
updateSliderPosition(newSliderPercentage);
|
||||
}
|
||||
|
||||
function updateSliderPosition(newSliderPercentage) {
|
||||
sliderPercentage.value = newSliderPercentage;
|
||||
|
||||
steppedSliderPercentage.value = snapToStep(newSliderPercentage);
|
||||
}
|
||||
|
||||
function pointerDown(pointerEvent) {
|
||||
document.body.addEventListener('pointermove', calculateSliderPosition)
|
||||
|
||||
calculateSliderPosition(pointerEvent);
|
||||
|
||||
document.body.addEventListener('pointerup', () => {
|
||||
rangeThumb.value.focus();
|
||||
document.body.removeEventListener('pointermove', calculateSliderPosition)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
function keydown(keyboardEvent) {
|
||||
if (keyboardEvent.keyCode >= 37 && keyboardEvent.keyCode <= 40) {
|
||||
keyboardEvent.preventDefault();
|
||||
const direction = (keyboardEvent.key === "ArrowRight" || keyboardEvent.key === "ArrowUp") ? 'forward' : 'backward';
|
||||
|
||||
if (direction === 'forward') {
|
||||
if (rangeValue.value.stepped + props.step > props.max) return;
|
||||
rangeValue.value.stepped += props.step;
|
||||
} else {
|
||||
if (rangeValue.value.stepped - props.step < props.min) return;
|
||||
rangeValue.value.stepped -= props.step;
|
||||
}
|
||||
|
||||
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
||||
}
|
||||
|
||||
if (keyboardEvent.keyCode === 36 || keyboardEvent.keyCode === 35) {
|
||||
keyboardEvent.preventDefault();
|
||||
|
||||
if (keyboardEvent.key === "Home") {
|
||||
rangeValue.value.stepped = props.min;
|
||||
} else {
|
||||
rangeValue.value.stepped = props.max;
|
||||
}
|
||||
|
||||
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
||||
}
|
||||
|
||||
if (keyboardEvent.keyCode === 33 || keyboardEvent.keyCode === 34) {
|
||||
keyboardEvent.preventDefault();
|
||||
const direction = (keyboardEvent.key === "PageUp") ? 'forward' : 'backward';
|
||||
let multiplier = 2;
|
||||
|
||||
if (direction === 'forward') {
|
||||
if (rangeValue.value.stepped + (props.step * multiplier) > props.max) multiplier = 1;
|
||||
if (rangeValue.value.stepped + (props.step * multiplier) > props.max) return;
|
||||
rangeValue.value.stepped += (props.step * multiplier);
|
||||
} else {
|
||||
if (rangeValue.value.stepped - (props.step * multiplier) > props.max) multiplier = 1;
|
||||
if (rangeValue.value.stepped - (props.step * multiplier) > props.max) return;
|
||||
rangeValue.value.stepped -= (props.step * multiplier);
|
||||
}
|
||||
|
||||
return updateSliderPosition((rangeValue.value.stepped / props.max) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.id) {
|
||||
document.body.querySelector(`[for="${props.id}"]`).addEventListener("click", () => {
|
||||
rangeThumb.value.focus();
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (props.id) {
|
||||
document.body.querySelector(`[for="${props.id}"]`).removeEventListener("click", () => {
|
||||
rangeThumb.value.focus();
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vl-range-slider" ref="rangeSlider" @pointerdown="pointerDown($event)">
|
||||
<span class="vl-range-track">
|
||||
<span class="vl-range-track-filled" :style="'width: ' + steppedSliderPercentage + '%;'">
|
||||
</span>
|
||||
</span>
|
||||
<span class="vl-range-thumb" :aria-label="props.label" :aria-labelledby="props.id" role="slider" ref="rangeThumb"
|
||||
:aria-valuemin="props.min" :aria-valuemax="props.max" aria-orientation="horizontal" :aria-valuenow="rangeValue.stepped" @keydown="keydown($event)"
|
||||
tabindex="0" :style="'left: ' + steppedSliderPercentage + '%;'"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vl-range-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.vl-range-track,
|
||||
.vl-range-track-filled {
|
||||
display: block;
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
background: #313234;
|
||||
border-radius: 99999px;
|
||||
}
|
||||
|
||||
.vl-range-track-filled {
|
||||
background: hsl(226, 79%, 47.5%);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.vl-range-thumb {
|
||||
position: absolute;
|
||||
display: block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 99999px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.vl-range-thumb:focus {
|
||||
outline: 4px solid #b7b7b74a;
|
||||
}
|
||||
</style>
|
||||
19
components/vlToggle.vue
Normal file
19
components/vlToggle.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const checked = ref(false);
|
||||
|
||||
watch(checked, (newValue) => {
|
||||
emit('update:modelValue', newValue);
|
||||
});
|
||||
emit('update:modelValue', checked.value);
|
||||
|
||||
function toggleChecked() {
|
||||
checked.value = !checked.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="vl-toggle-button" @click="toggleChecked()" :aria-pressed="checked" :data-state="(checked) ? 'on' : 'off'">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
95
components/vlToggleGroup/index.vue
Normal file
95
components/vlToggleGroup/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "single"
|
||||
},
|
||||
defaultValue: {
|
||||
type: String || Array,
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const toggleGroup = ref(null);
|
||||
const selected = ref((props.type === "single") ? props.defaultValue : [].concat(props.defaultValue));
|
||||
|
||||
watch(selected, (newValue) => {
|
||||
emit('update:modelValue', newValue);
|
||||
})
|
||||
emit('update:modelValue', selected.value);
|
||||
|
||||
const items = ref([]);
|
||||
|
||||
function registerToggleItem(value) {
|
||||
const item = { value, tabIndex: (props.defaultValue.includes(value)) ? ref('0') : ref('-1') }
|
||||
const itemIndex = (items.value.push(item)) - 1
|
||||
return { item, index: itemIndex };
|
||||
}
|
||||
|
||||
function toggleItem(index) {
|
||||
if (props.type === "single") {
|
||||
selected.value = items.value[index].value;
|
||||
return;
|
||||
}
|
||||
if (selected.value.indexOf(items.value[index].value) > -1) {
|
||||
selected.value = selected.value.filter((e) => e !== items.value[index].value)
|
||||
return;
|
||||
}
|
||||
selected.value.push(items.value[index].value);
|
||||
}
|
||||
|
||||
function keydown(event) {
|
||||
const elements = Array.from(toggleGroup.value.children).filter(
|
||||
(el) => el.nodeName.toLowerCase() === 'button'
|
||||
);
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
items.value.forEach((el, i) => {
|
||||
if (i === 0) return items.value[i].tabIndex = '0';
|
||||
return items.value[i].tabIndex = '-1';
|
||||
});
|
||||
elements[0].focus();
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
items.value.forEach((el, i) => {
|
||||
if (i === items.value.length - 1) return items.value[i].tabIndex = '0';
|
||||
return items.value[i].tabIndex = '-1';
|
||||
});
|
||||
elements[items.value.length-1].focus();
|
||||
}
|
||||
|
||||
const focusedElement = document.activeElement;
|
||||
const currentIndex = elements.indexOf(focusedElement);
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0;
|
||||
items.value.forEach((el, i) => {
|
||||
if (i === nextIndex) return items.value[i].tabIndex = '0';
|
||||
return items.value[i].tabIndex = '-1';
|
||||
});
|
||||
elements[nextIndex].focus();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1;
|
||||
items.value.forEach((el, i) => {
|
||||
if (i === nextIndex) return items.value[i].tabIndex = '0';
|
||||
return items.value[i].tabIndex = '-1';
|
||||
});
|
||||
elements[nextIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
provide('toggleGroup', { registerToggleItem, toggleItem, selected })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="group" ref="toggleGroup" class="vl-toggle-group" @keydown="keydown($event)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
components/vlToggleGroup/item.vue
Normal file
17
components/vlToggleGroup/item.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
})
|
||||
const toggleGroup = inject('toggleGroup');
|
||||
|
||||
const item = toggleGroup.registerToggleItem(props.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button role="radio" type="button" @click="toggleGroup.toggleItem(item.index)" :aria-checked="toggleGroup.selected.value === props.value || toggleGroup.selected.value.indexOf(props.value) > -1" :tabindex="item.item.tabIndex.value" :data-state="(toggleGroup.selected.value === props.value || toggleGroup.selected.value.indexOf(props.value) > -1) ? 'checked' : 'unchecked'" class="vl-toggle-button">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
87
components/vlToggleSwitch.vue
Normal file
87
components/vlToggleSwitch.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String
|
||||
},
|
||||
label: {
|
||||
type: String
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const active = ref(props.checked);
|
||||
|
||||
watch(() => props.checked, (newValue) => {
|
||||
active.value = newValue;
|
||||
})
|
||||
|
||||
watch(active, (newValue) => {
|
||||
emit('update:modelValue', newValue);
|
||||
});
|
||||
emit('update:modelValue', props.checked)
|
||||
|
||||
onMounted(() => {
|
||||
document.querySelector(`[for='${props.id}']`).addEventListener('click', () => {
|
||||
if (props.disabled) return;
|
||||
active.value = !active.value
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button role="switch" class="vl-toggle-switch" :aria-disabled="(props.disabled === true) ? 'true' : 'false'"
|
||||
:aria-label="label" :aria-labelledby="id" :tabindex="(disabled) ? '-1' : '0'"
|
||||
@click="(!disabled) ? active = !active : ''" :aria-checked="active" :data-state="(active) ? 'checked' : 'unchecked'">
|
||||
<div></div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped> .vl-toggle-switch {
|
||||
font-size: inherit;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: 2.5em;
|
||||
height: 1.4em;
|
||||
background: #1a1b1c;
|
||||
border-radius: 100px;
|
||||
padding: 0.125rem 0.25rem;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[aria-disabled="true"] div {
|
||||
background: #919191;
|
||||
}
|
||||
|
||||
.vl-toggle-switch div {
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: #f7f7f7;
|
||||
border-radius: 90px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] {
|
||||
background: hsl(226, 79%, 47.5%);
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] div {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.vl-toggle-switch:active div {
|
||||
width: 1.3em;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user