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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ emit('update:open', value)"
+ @close="emit('close')"
+ >
+
+
-
-
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 @@
+
+
+
+ emit('update:open', value)"
+ @close="emit('close')"
+ >
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ emit('update:open', value)"
+ @close="emit('close')"
+ >
+
+
+
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 @@
+
+
+
+ emit('update:open', value)"
+ @close="emit('close')"
+ >
+
+
+
+
+
+
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);
+}