diff --git a/src/components/composer/PostComposer.vue b/src/components/composer/PostComposer.vue index ac05983..176d7fa 100644 --- a/src/components/composer/PostComposer.vue +++ b/src/components/composer/PostComposer.vue @@ -16,7 +16,7 @@ import { // - [ ] video uploads // - [ ] video alt text // - [ ] video captions -// - [ ] language picker +// - [x] language picker // - [ ] emoji picker // - [ ] character counter @@ -409,17 +409,10 @@ async function handlePost() { flex-shrink: 0; } -.avatar { +:deep(.avatar) { width: var(--avatar-size); height: var(--avatar-size); - aspect-ratio: 1 / 1; - - img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 50%; - } + border-radius: 50%; } .thread-line { @@ -437,7 +430,7 @@ async function handlePost() { .composer-toolbar, :deep(textarea.composer-textarea) { - transition: all 0.25s var(--ease-spring); + transition: all 0.295s var(--ease-spring); } .composer-toolbar { diff --git a/src/components/modals/BaseModal.vue b/src/components/modals/BaseModal.vue index cf70093..90ee378 100644 --- a/src/components/modals/BaseModal.vue +++ b/src/components/modals/BaseModal.vue @@ -1,9 +1,10 @@ - - diff --git a/src/components/modals/composables/useDialogDrag.ts b/src/components/modals/composables/useDialogDrag.ts new file mode 100644 index 0000000..6541b8e --- /dev/null +++ b/src/components/modals/composables/useDialogDrag.ts @@ -0,0 +1,79 @@ +import { ref, watch, onUnmounted, type Ref } from "vue"; + +export type DialogVariant = "modal" | "sheet" | "action-sheet"; + +interface UseDialogDragOptions { + open: Ref; + depth: Ref; + variant: Ref; + closeThreshold?: number; + onClose: () => void; +} + +export function useDialogDrag({ + open, + depth, + variant, + closeThreshold = 150, + onClose, +}: UseDialogDragOptions) { + const isDragging = ref(false); + const startY = ref(0); + const dragOffset = ref(0); + + const isEnabled = () => variant.value === "sheet" || variant.value === "action-sheet"; + + const onPointerMove = (e: PointerEvent) => { + if (!isDragging.value) return; + const deltaY = e.clientY - startY.value; + dragOffset.value = deltaY < 0 ? deltaY * 0.2 : deltaY; + }; + + const onPointerUp = () => { + isDragging.value = false; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + + if (dragOffset.value > closeThreshold) { + onClose(); + } else { + dragOffset.value = 0; + } + }; + + const onPointerDown = (e: PointerEvent) => { + if (!isEnabled() || depth.value > 0) return; + isDragging.value = true; + startY.value = e.clientY; + const target = e.currentTarget as HTMLElement; + target.setPointerCapture(e.pointerId); + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); + }; + + watch(open, (isOpen) => { + if (!isOpen) { + isDragging.value = false; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + } + dragOffset.value = 0; + }); + + onUnmounted(() => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + }); + + return { + isDragging, + dragOffset, + onPointerDown, + closeThreshold, + }; +} diff --git a/src/components/modals/composables/useDialogFocusTrap.ts b/src/components/modals/composables/useDialogFocusTrap.ts new file mode 100644 index 0000000..5d8ed8a --- /dev/null +++ b/src/components/modals/composables/useDialogFocusTrap.ts @@ -0,0 +1,126 @@ +import { watch, nextTick, onUnmounted, type Ref } from "vue"; + +import { FOCUSABLE_SELECTOR } from "@/utils/focusable"; + +interface UseDialogFocusTrapOptions { + open: Ref; + depth: Ref; + modalIndex: Ref; + containerRef: Ref; + onClose?: () => void; +} + +export function useDialogFocusTrap({ + open, + depth, + modalIndex, + containerRef, + onClose, +}: UseDialogFocusTrapOptions) { + let previouslyFocused: Element | null = null; + + function getFocusableElements(): HTMLElement[] { + if (!containerRef.value) return []; + const list = Array.from( + containerRef.value.querySelectorAll(FOCUSABLE_SELECTOR), + ) as HTMLElement[]; + return list.filter((el) => { + try { + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + } catch { + return true; + } + }); + } + + function onTrapKeydown(e: KeyboardEvent) { + if (!open.value || depth.value > 0) return; + if (e.key !== "Tab") return; + + const focusables = getFocusableElements(); + const active = document.activeElement as HTMLElement | null; + + if (focusables.length === 0) { + e.preventDefault(); + containerRef.value?.focus(); + return; + } + + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + + if (e.shiftKey) { + if (!active || active === first || !containerRef.value?.contains(active)) { + e.preventDefault(); + last?.focus(); + } + } else { + if (!active || active === last || !containerRef.value?.contains(active)) { + e.preventDefault(); + first?.focus(); + } + } + } + + function onFocusIn(e: FocusEvent) { + if (!open.value || depth.value > 0) return; + if (!containerRef.value) return; + + const target = e.target as Node | null; + if (!target) return; + + if (!containerRef.value.contains(target)) { + const elems = getFocusableElements(); + if (elems.length) { + elems[0]?.focus(); + } else { + containerRef.value.focus(); + } + } + } + + function focusInitial() { + const heading = containerRef.value?.querySelector("h2"); + if (heading) { + (heading as HTMLElement).tabIndex = -1; + (heading as HTMLElement).focus(); + return; + } + containerRef.value?.focus(); + } + + watch( + open, + (isOpen) => { + if (isOpen) { + document.body.style.overflow = "hidden"; + previouslyFocused = document.activeElement; + nextTick(() => { + focusInitial(); + window.addEventListener("keydown", onTrapKeydown); + document.addEventListener("focusin", onFocusIn); + }); + } else { + if (modalIndex.value === 0) document.body.style.overflow = ""; + onClose?.(); + + window.removeEventListener("keydown", onTrapKeydown); + document.removeEventListener("focusin", onFocusIn); + + try { + if (previouslyFocused && (previouslyFocused as HTMLElement).focus) { + (previouslyFocused as HTMLElement).focus(); + } + } catch {} + previouslyFocused = null; + } + }, + { immediate: true }, + ); + + onUnmounted(() => { + if (modalIndex.value === 0) document.body.style.overflow = ""; + window.removeEventListener("keydown", onTrapKeydown); + document.removeEventListener("focusin", onFocusIn); + }); +} diff --git a/src/components/modals/variants/ActionSheetDialog.vue b/src/components/modals/variants/ActionSheetDialog.vue new file mode 100644 index 0000000..e654385 --- /dev/null +++ b/src/components/modals/variants/ActionSheetDialog.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/modals/variants/DialogFrame.vue b/src/components/modals/variants/DialogFrame.vue new file mode 100644 index 0000000..1abd4f6 --- /dev/null +++ b/src/components/modals/variants/DialogFrame.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/src/components/modals/variants/ModalDialog.vue b/src/components/modals/variants/ModalDialog.vue new file mode 100644 index 0000000..4089737 --- /dev/null +++ b/src/components/modals/variants/ModalDialog.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/modals/variants/SheetDialog.vue b/src/components/modals/variants/SheetDialog.vue new file mode 100644 index 0000000..d0dda43 --- /dev/null +++ b/src/components/modals/variants/SheetDialog.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/ui/BasePopover.vue b/src/components/ui/BasePopover.vue index afe28b8..562ad68 100644 --- a/src/components/ui/BasePopover.vue +++ b/src/components/ui/BasePopover.vue @@ -200,12 +200,12 @@ const PopoverModalContent = defineComponent({ return () => { if (slots.content) - return h("div", { class: "popover-modal-content" }, slots.content({ close })); + return h("div", { class: "popover-modal__content" }, slots.content({ close })); if (props.items && props.items.length > 0) - return h(PopoverMenuList, { items: props.items, close, class: "popover-modal-content" }); + return h(PopoverMenuList, { items: props.items, close, class: "popover-modal__content" }); - return h("div", { class: "popover-modal-content" }); + return h("div", { class: "popover-modal__content" }); }; }, }); @@ -286,8 +286,8 @@ const PopoverMenuList = (props: { items: PopoverItem[]; close: () => void }, { a if ("actions" in item) { return h("div", { class: "popover-group", key: index }, [ item.title - ? h("div", { class: "popover-group-title" }, [ - renderIcon(item.icon, "popover-group-icon"), + ? h("div", { class: "popover-group__title" }, [ + renderIcon(item.icon, "popover-group__icon"), item.title, ]) : null, @@ -302,10 +302,10 @@ const PopoverMenuList = (props: { items: PopoverItem[]; close: () => void }, { a }, [ renderIcon(action.icon), - h("div", { class: "popover-action-text" }, [ - h("div", { class: "popover-action-title" }, action.title), + h("div", { class: "popover-action__text" }, [ + h("div", { class: "popover-action__title" }, action.title), action.subtitle - ? h("div", { class: "popover-action-subtitle" }, action.subtitle) + ? h("div", { class: "popover-action__subtitle" }, action.subtitle) : null, ]), ], @@ -324,9 +324,9 @@ const PopoverMenuList = (props: { items: PopoverItem[]; close: () => void }, { a }, [ renderIcon(item.icon), - h("div", { class: "popover-action-text" }, [ - h("div", { class: "popover-action-title" }, item.title), - item.subtitle ? h("div", { class: "popover-action-subtitle" }, item.subtitle) : null, + h("div", { class: "popover-action__text" }, [ + h("div", { class: "popover-action__title" }, item.title), + item.subtitle ? h("div", { class: "popover-action__subtitle" }, item.subtitle) : null, ]), ], ); diff --git a/src/components/ui/css/popover.css b/src/components/ui/css/popover.css index 3228de2..e9b8040 100644 --- a/src/components/ui/css/popover.css +++ b/src/components/ui/css/popover.css @@ -25,7 +25,7 @@ flex-direction: column; gap: var(--space-1); - .popover-group-title { + .popover-group__title { display: flex; align-items: center; gap: var(--space-2); @@ -56,14 +56,14 @@ background-color: hsl(var(--surface0)); } - .popover-action-icon { + .popover-action__icon { width: 1.5rem; height: 1.5rem; flex-shrink: 0; display: flex; } - .popover-action-text { + .popover-action__text { display: flex; flex-direction: column; overflow: hidden; @@ -71,14 +71,14 @@ text-align: left; } - .popover-action-title { + .popover-action__title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .popover-action-subtitle { + .popover-action__subtitle { font-size: 0.85em; color: hsl(var(--subtext0)); white-space: nowrap; @@ -87,19 +87,19 @@ } } -.popover-action-icon svg, -.popover-group-icon svg { +.popover-action__icon svg, +.popover-group__icon svg { width: 1.5rem; height: 1.5rem; } -img.popover-action-icon, -img.popover-group-icon { +img.popover-action__icon, +img.popover-group__icon { object-fit: cover; border-radius: 50%; } -.popover-modal-content { +.popover-modal__content { display: flex; flex-direction: column; gap: var(--space-1); @@ -111,7 +111,7 @@ img.popover-group-icon { border-radius: var(--radius-xxl); border: var(--border-2); - .popover-group-title { + .popover-group__title { text-align: center; display: block; color: hsl(var(--text)); @@ -146,3 +146,7 @@ img.popover-group-icon { } } } + +.action-sheet__cancel-container { + border: var(--border-2); +}