diff --git a/assets/scss/_functions.scss b/assets/scss/_functions.scss index ae06b60..3767f9a 100644 --- a/assets/scss/_functions.scss +++ b/assets/scss/_functions.scss @@ -6,3 +6,4 @@ @import "functions/fluid"; @import "functions/list"; @import "functions/decimal"; +@import "functions/flex-grid"; diff --git a/assets/scss/functions/_flex-grid.scss b/assets/scss/functions/_flex-grid.scss new file mode 100644 index 0000000..797305b --- /dev/null +++ b/assets/scss/functions/_flex-grid.scss @@ -0,0 +1,15 @@ +@function flex-grid($columns: 1, $total-columns: 1, $length: '%', $fullscreen: false) { + @return calc(#{flex-grid-value($columns, $total-columns, $length, $fullscreen)}); +} + +@function flex-grid-value($columns: 1, $total-columns: 1, $length: '%', $fullscreen: false) { + $num-gutters: if($fullscreen, $total-columns + 1, $total-columns - 1); + $total-width: '(100#{$length} - #{$num-gutters} * var(--gutter))'; + $result: '(#{$total-width} / #{$total-columns})'; + + @if $columns != 1 { + $result: '#{$result} * #{$columns} + #{$columns - 1} * var(--gutter)'; + } + + @return $result; +} diff --git a/components/molecules/VSlider/Default.stories.vue b/components/molecules/VSlider/Default.stories.vue new file mode 100644 index 0000000..7aa8f40 --- /dev/null +++ b/components/molecules/VSlider/Default.stories.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/components/molecules/VSlider/IrregularWidthSlide.stories.vue b/components/molecules/VSlider/IrregularWidthSlide.stories.vue new file mode 100644 index 0000000..f252355 --- /dev/null +++ b/components/molecules/VSlider/IrregularWidthSlide.stories.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/components/molecules/VSlider/OneSlide.stories.vue b/components/molecules/VSlider/OneSlide.stories.vue new file mode 100644 index 0000000..daa4e36 --- /dev/null +++ b/components/molecules/VSlider/OneSlide.stories.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/components/molecules/VSlider/SnapVisualizer.stories.vue b/components/molecules/VSlider/SnapVisualizer.stories.vue new file mode 100644 index 0000000..6784c71 --- /dev/null +++ b/components/molecules/VSlider/SnapVisualizer.stories.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/components/molecules/VSlider/VImgSlider.stories.vue b/components/molecules/VSlider/VImgSlider.stories.vue new file mode 100644 index 0000000..e205722 --- /dev/null +++ b/components/molecules/VSlider/VImgSlider.stories.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/components/molecules/VSlider/VSlider.vue b/components/molecules/VSlider/VSlider.vue new file mode 100644 index 0000000..6df74cc --- /dev/null +++ b/components/molecules/VSlider/VSlider.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/components/molecules/VSlider/WithControls.stories.vue b/components/molecules/VSlider/WithControls.stories.vue new file mode 100644 index 0000000..b92fc80 --- /dev/null +++ b/components/molecules/VSlider/WithControls.stories.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/composables/use-draggable-scroll.ts b/composables/use-draggable-scroll.ts new file mode 100644 index 0000000..a84065b --- /dev/null +++ b/composables/use-draggable-scroll.ts @@ -0,0 +1,152 @@ +import { getHtmlElement, type TemplateElementRef } from '~/utils/ref/get-html-element' + +interface Point { + x: number + y: number +} + +interface UseDraggableScrollOptions { + element: TemplateElementRef + onMouseUp?: (event: MouseEvent) => void + onMouseDown?: () => void +} + +// Amount of pixels for detecting if the element is being dragged or just clicked. +const MIN_DRAG_AMOUNT = 6 + +export function useDraggableScroll(options: UseDraggableScrollOptions) { + const isDragging = ref(false) + const hasScroll = ref(false) + + let isListening = false + let dragged = false + let dragAmount: Point = { x: 0, y: 0 } + let resizeObserver: ResizeObserver | null = null + + const XDirection = ref(1) + let oldX = 0 + function setDragDirection(e: MouseEvent) { + if (!isDragging.value || e.pageX == oldX) return + XDirection.value = e.pageX < oldX ? -1 : 1 + oldX = e.pageX + } + + function onMouseDown() { + removeListeners() + + isDragging.value = true + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + + options.onMouseDown?.() + } + + function onMouseUp(event: MouseEvent) { + removeListeners() + + isDragging.value = false + + dragged = dragAmount.x > MIN_DRAG_AMOUNT || dragAmount.y > MIN_DRAG_AMOUNT + dragAmount = { x: 0, y: 0 } + + options.onMouseUp?.(event) + } + + function onMouseMove(event: MouseEvent) { + const element = getHtmlElement(options.element) + + if (!element) return + + event.preventDefault() + + setDragDirection(event) + + const oldScrollTop = element.scrollTop + const oldScrollLeft = element.scrollLeft + + element.scrollTop -= event.movementY + element.scrollLeft -= event.movementX + + dragAmount = { + x: dragAmount.x + Math.abs(oldScrollLeft - element.scrollLeft), + y: dragAmount.y + Math.abs(oldScrollTop - element.scrollTop), + } + } + + function onClick(event: MouseEvent) { + if (dragged) { + event.preventDefault() + event.stopImmediatePropagation() + } + } + + function removeListeners() { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + function createResizeObserver() { + const element = getHtmlElement(options.element) + if (!element) return + + resizeObserver = new ResizeObserver(function () { + setStyle() + }) + resizeObserver.observe(element) + } + + function disposeResizeObserver() { + resizeObserver?.disconnect() + resizeObserver = null + } + + watch(isDragging, setStyle) + function setStyle(isGrabbing?: boolean) { + const element = getHtmlElement(options.element) + if (!element) return + + hasScroll.value = element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight + + element.style.cursor = hasScroll.value ? (isGrabbing ? 'grabbing' : 'grab') : '' + element.style.userSelect = isGrabbing ? 'none' : '' + } + + function listen() { + const element = getHtmlElement(options.element) + if (!element) return + + createResizeObserver() + setStyle() + + element.addEventListener('mousedown', onMouseDown) + element.addEventListener('click', onClick, true) + isListening = true + } + + function destroy(el?: UseDraggableScrollOptions['element']) { + const element = getHtmlElement(options.element) || (el && getHtmlElement(el)) + + if (!element) return + + disposeResizeObserver() + removeListeners() + + element.removeEventListener('mousedown', onMouseDown) + element.removeEventListener('click', onClick, true) + isListening = false + } + + // Stop listening if element ref become nullish + if (isRef(options.element)) { + watch(options.element, (el, prevEl) => { + if (el && !isListening) listen() + else if (!el && isListening) destroy(prevEl) + }) + } + + onMounted(listen) + onUnmounted(destroy) + + return { isDragging, hasScroll, setStyle, XDirection } +} diff --git a/package.json b/package.json index c4b263e..2c943bd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@rezo-zero/xilofone-fetch": "^0.2.0", "@roadiz/types": "^0.2.0", "@types/json-schema": "^7.0.15", + "@types/throttle-debounce": "^5.0.2", "@vueuse/nuxt": "^10.10.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -58,6 +59,7 @@ "marked": "^12.0.2", "plyr": "^3.7.8", "tiny-emitter": "^2.1.0", + "throttle-debounce": "^5.0.0", "vue-multiselect": "^3.0.0" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97aea6d..980d5d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: plyr: specifier: ^3.7.8 version: 3.7.8 + throttle-debounce: + specifier: ^5.0.0 + version: 5.0.0 tiny-emitter: specifier: ^2.1.0 version: 2.1.0 @@ -78,6 +81,9 @@ importers: '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 + '@types/throttle-debounce': + specifier: ^5.0.2 + version: 5.0.2 '@vueuse/nuxt': specifier: ^10.10.0 version: 10.10.0(nuxt@3.11.2(@opentelemetry/api@1.9.0)(@parcel/watcher@2.4.1)(@types/node@20.14.2)(@unocss/reset@0.60.4)(encoding@0.1.13)(eslint@8.57.0)(floating-vue@5.2.2(@nuxt/kit@3.11.2(rollup@4.18.0))(vue@3.4.27(typescript@5.4.5)))(ioredis@5.4.1)(optionator@0.9.4)(rollup@4.18.0)(sass@1.77.4)(stylelint@16.6.1(typescript@5.4.5))(terser@5.31.1)(typescript@5.4.5)(unocss@0.60.4(@unocss/webpack@0.60.4(rollup@4.18.0)(webpack@5.91.0))(postcss@8.4.38)(rollup@4.18.0)(vite@5.2.13(@types/node@20.14.2)(sass@1.77.4)(terser@5.31.1)))(vite@5.2.13(@types/node@20.14.2)(sass@1.77.4)(terser@5.31.1)))(rollup@4.18.0)(vue@3.4.27(typescript@5.4.5)) @@ -1247,6 +1253,9 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/throttle-debounce@5.0.2': + resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -4046,8 +4055,8 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-git@3.24.0: - resolution: {integrity: sha512-QqAKee9Twv+3k8IFOFfPB2hnk6as6Y6ACUpwCtQvRYBAes23Wv3SZlHVobAzqcE8gfsisCvPw3HGW3HYM+VYYw==} + simple-git@3.25.0: + resolution: {integrity: sha512-KIY5sBnzc4yEcJXW7Tdv4viEz8KyG+nU0hay+DWZasvdFOYKeUZ6Xc25LUHHjw0tinPT7O1eY6pzX7pRT1K8rw==} simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -4383,6 +4392,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttle-debounce@5.0.0: + resolution: {integrity: sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==} + engines: {node: '>=12.22'} + tiny-emitter@2.1.0: resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} @@ -5678,7 +5691,7 @@ snapshots: rc9: 2.1.2 scule: 1.3.0 semver: 7.6.2 - simple-git: 3.24.0 + simple-git: 3.25.0 sirv: 2.0.4 unimport: 3.7.2(rollup@4.18.0) vite: 5.2.13(@types/node@20.14.2)(sass@1.77.4)(terser@5.31.1) @@ -6412,6 +6425,8 @@ snapshots: '@types/semver@7.5.8': {} + '@types/throttle-debounce@5.0.2': {} + '@types/web-bluetooth@0.0.20': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)': @@ -9822,7 +9837,7 @@ snapshots: simple-concat: 1.0.1 optional: true - simple-git@3.24.0: + simple-git@3.25.0: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -10224,6 +10239,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttle-debounce@5.0.0: {} + tiny-emitter@2.1.0: {} tiny-invariant@1.3.3: {} diff --git a/utils/ref/get-html-element.ts b/utils/ref/get-html-element.ts new file mode 100644 index 0000000..ea64be2 --- /dev/null +++ b/utils/ref/get-html-element.ts @@ -0,0 +1,20 @@ +import type { ComponentPublicInstance, MaybeRefOrGetter } from 'vue' + +export type TemplateElement = HTMLElement | ComponentPublicInstance | null +export type TemplateElementRef = MaybeRefOrGetter + +export function isComponentInstanceElement(el: TemplateElement) { + return el && '$data' in el && '$props' in el && '$props' in el +} + +export function isDomElement(el: TemplateElement) { + if (!el || isComponentInstanceElement(el)) return false + return (el as HTMLElement).ownerDocument?.documentElement.tagName.toLowerCase() === 'html' +} + +export function getHtmlElement(element: TemplateElementRef) { + const el = toValue(element) + if (!el) return + + return ((el as ComponentPublicInstance)?.$el || el) as HTMLElement | undefined +}