feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The `'item-aligned'` positioning strategy for the content panel: positions the
|
||||
* panel like a native MacOS menu by vertically centring the selected option
|
||||
* against the trigger, horizontally aligning the selected item's text with the
|
||||
* trigger value, clamping to the viewport (collision margin), computing
|
||||
* min/max height, supporting `dir="rtl"`, growing on scroll, and repositioning
|
||||
* on trigger resize. Chosen internally by `SelectContentImpl` when `position`
|
||||
* is `'item-aligned'`.
|
||||
*/
|
||||
export interface SelectItemAlignedPositionProps extends PrimitiveProps {
|
||||
/** Reading direction, forwarded from the root. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
export interface SelectItemAlignedPositionEmits {
|
||||
placed: [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import {
|
||||
provideSelectItemAlignedPositionContext,
|
||||
useSelectContentContext,
|
||||
useSelectRootContext,
|
||||
} from './context';
|
||||
import { CONTENT_MARGIN } from './utils';
|
||||
|
||||
const { as = 'div' } = defineProps<SelectItemAlignedPositionProps>();
|
||||
const emit = defineEmits<SelectItemAlignedPositionEmits>();
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { forwardRef, currentElement: contentElement } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
const contentCtx = useSelectContentContext();
|
||||
|
||||
const contentWrapper = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const shouldExpandOnScrollRef = ref(false);
|
||||
const shouldRepositionRef = ref(true);
|
||||
const contentZIndex = ref('');
|
||||
|
||||
function position() {
|
||||
const trigger = rootCtx.triggerElement.value;
|
||||
const valueNode = rootCtx.valueElement.value;
|
||||
const wrapper = contentWrapper.value;
|
||||
const content = contentElement.value;
|
||||
const viewport = contentCtx.viewportRef.value;
|
||||
const selectedItem = contentCtx.selectedItemRef.value;
|
||||
const selectedItemText = contentCtx.selectedItemTextRef.value;
|
||||
|
||||
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
|
||||
emit('placed');
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
|
||||
// --- Horizontal positioning ---
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const valueNodeRect = valueNode.getBoundingClientRect();
|
||||
const itemTextRect = selectedItemText.getBoundingClientRect();
|
||||
|
||||
if (rootCtx.dir.value !== 'rtl') {
|
||||
const itemTextOffset = itemTextRect.left - contentRect.left;
|
||||
const left = valueNodeRect.left - itemTextOffset;
|
||||
const leftDelta = triggerRect.left - left;
|
||||
const minContentWidth = triggerRect.width + leftDelta;
|
||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
||||
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
|
||||
|
||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
||||
wrapper.style.left = `${clampedLeft}px`;
|
||||
}
|
||||
else {
|
||||
const itemTextOffset = contentRect.right - itemTextRect.right;
|
||||
const right = window.innerWidth - valueNodeRect.right - itemTextOffset;
|
||||
const rightDelta = window.innerWidth - triggerRect.right - right;
|
||||
const minContentWidth = triggerRect.width + rightDelta;
|
||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||
const leftEdge = window.innerWidth - CONTENT_MARGIN;
|
||||
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
|
||||
|
||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
||||
wrapper.style.right = `${clampedRight}px`;
|
||||
}
|
||||
|
||||
// --- Vertical positioning ---
|
||||
const items = Array.from(
|
||||
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
|
||||
);
|
||||
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
|
||||
const itemsHeight = viewport.scrollHeight;
|
||||
|
||||
const contentStyles = globalThis.getComputedStyle(content);
|
||||
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
|
||||
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
|
||||
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
|
||||
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
|
||||
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
|
||||
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
|
||||
|
||||
const viewportStyles = globalThis.getComputedStyle(viewport);
|
||||
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
|
||||
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
|
||||
|
||||
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
|
||||
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
|
||||
|
||||
const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
|
||||
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
|
||||
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
|
||||
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
|
||||
|
||||
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
|
||||
|
||||
if (willAlignWithoutTopOverflow) {
|
||||
const isLastItem = selectedItem === items.at(-1);
|
||||
wrapper.style.bottom = '0px';
|
||||
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
|
||||
const clampedTriggerMiddleToBottomEdge = Math.max(
|
||||
triggerMiddleToBottomEdge,
|
||||
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
|
||||
);
|
||||
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
|
||||
wrapper.style.height = `${height}px`;
|
||||
}
|
||||
else {
|
||||
const isFirstItem = selectedItem === items[0];
|
||||
wrapper.style.top = '0px';
|
||||
const clampedTopEdgeToTriggerMiddle = Math.max(
|
||||
topEdgeToTriggerMiddle,
|
||||
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
|
||||
);
|
||||
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
|
||||
wrapper.style.height = `${height}px`;
|
||||
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
|
||||
}
|
||||
|
||||
wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
|
||||
wrapper.style.minHeight = `${minContentHeight}px`;
|
||||
wrapper.style.maxHeight = `${availableHeight}px`;
|
||||
|
||||
emit('placed');
|
||||
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
position();
|
||||
if (contentElement.value) {
|
||||
contentZIndex.value = globalThis.getComputedStyle(contentElement.value).zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
// When the scroll-up button mounts (because the viewport became scrollable at
|
||||
// the top) it pushes the viewport down, throwing the alignment off; re-run once.
|
||||
function handleScrollButtonChange(node: HTMLElement | undefined) {
|
||||
if (node && shouldRepositionRef.value) {
|
||||
position();
|
||||
contentCtx.focusSelectedItem();
|
||||
shouldRepositionRef.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(rootCtx.triggerElement, () => position());
|
||||
|
||||
provideSelectItemAlignedPositionContext({
|
||||
contentWrapper,
|
||||
shouldExpandOnScrollRef,
|
||||
onScrollButtonChange: handleScrollButtonChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contentWrapper"
|
||||
data-primitives-select-content-wrapper
|
||||
:style="{ display: 'flex', flexDirection: 'column', position: 'fixed', zIndex: contentZIndex }"
|
||||
>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
data-primitives-select-content
|
||||
:style="{ boxSizing: 'border-box', maxHeight: '100%' }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user