feat(mobile): Wave 4 — RegisterPage responsive + TransferIndicator on settings + MessageInput sheets
- RegisterPage: scroll envelope, iOS-zoom-safe inputs, ≥44px tap targets, mobile-friendly invite chip + swatch row - TransferIndicator: mounted via MobileScreenHeader.rightActions across every settings/instance screen and inline in MobileChatScreen; touch-close listener; viewport-safe panel width - MessageInput popovers (emoji/GIF/mention) now route through new InputPopover wrapper — desktop popover, mobile bottom-sheet - EmojiPicker mobile branch: dynamicWidth + scoped CSS (.emoji-picker-wrapper--mobile) so the <em-emoji-picker> custom element fills the sheet; bigger touch targets (perLine 8, button 40, emoji 28); desktop unchanged - MessageInput trigger row: mobile-first sizing with md: desktop overrides (40×40 buttons + gap on mobile, 34×34 unchanged on desktop) - Spec updates: docs/systems/auth.md (RegisterPage responsive contract), docs/systems/uploads.md (TransferIndicator mobile surfaces)
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { EmojiPicker } from './EmojiPicker';
|
||||
import { GifPicker } from './GifPicker';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
export type InputPopoverTab = 'emoji' | 'gif';
|
||||
|
||||
@@ -15,7 +16,38 @@ interface InputPopoverProps {
|
||||
onTabChange: (tab: InputPopoverTab) => void;
|
||||
}
|
||||
|
||||
export function InputPopover({
|
||||
interface SharedTabProps {
|
||||
activeTab: InputPopoverTab;
|
||||
availableTabs: { key: InputPopoverTab; label: string }[];
|
||||
onTabChange: (tab: InputPopoverTab) => void;
|
||||
}
|
||||
|
||||
function TabBar({ activeTab, availableTabs, onTabChange }: SharedTabProps) {
|
||||
if (availableTabs.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
|
||||
{availableTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onTabChange(t.key)}
|
||||
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
|
||||
activeTab === t.key
|
||||
? 'bg-interactive-selected text-txt-primary'
|
||||
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopPopoverProps extends InputPopoverProps {
|
||||
availableTabs: { key: InputPopoverTab; label: string }[];
|
||||
}
|
||||
|
||||
function DesktopPopover({
|
||||
activeTab,
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
@@ -23,7 +55,8 @@ export function InputPopover({
|
||||
anchorRef,
|
||||
gifEnabled,
|
||||
onTabChange,
|
||||
}: InputPopoverProps) {
|
||||
availableTabs,
|
||||
}: DesktopPopoverProps) {
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Position above the anchor
|
||||
@@ -93,15 +126,6 @@ export function InputPopover({
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||
{ key: 'emoji', label: 'Emoji' },
|
||||
];
|
||||
if (gifEnabled) {
|
||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||
}
|
||||
|
||||
const showTabs = availableTabs.length > 1;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={floatingRef}
|
||||
@@ -109,36 +133,96 @@ export function InputPopover({
|
||||
style={{ top: -9999, left: -9999 }}
|
||||
>
|
||||
<div className="glass rounded-xl overflow-hidden flex flex-col w-fit max-h-[435px]">
|
||||
{/* Tab bar */}
|
||||
{showTabs && (
|
||||
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
|
||||
{availableTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onTabChange(t.key)}
|
||||
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
|
||||
activeTab === t.key
|
||||
? 'bg-interactive-selected text-txt-primary'
|
||||
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{activeTab === 'emoji' && (
|
||||
<EmojiPicker onEmojiSelect={onEmojiSelect} />
|
||||
)}
|
||||
{activeTab === 'gif' && gifEnabled && (
|
||||
<GifPicker onGifSelect={onGifSelect} />
|
||||
)}
|
||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface MobileSheetProps extends InputPopoverProps {
|
||||
availableTabs: { key: InputPopoverTab; label: string }[];
|
||||
}
|
||||
|
||||
function MobileSheet({
|
||||
activeTab,
|
||||
onClose,
|
||||
onEmojiSelect,
|
||||
onGifSelect,
|
||||
gifEnabled,
|
||||
onTabChange,
|
||||
availableTabs,
|
||||
}: MobileSheetProps) {
|
||||
// Escape to close (parity with desktop)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop — single tap (mousedown OR touchstart) closes */}
|
||||
<div
|
||||
className="fixed inset-0 z-[300] bg-black/30"
|
||||
onMouseDown={onClose}
|
||||
onTouchStart={onClose}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet flex flex-col"
|
||||
style={{
|
||||
// Sit at the bottom of the visible viewport. On iOS 16.4+ the
|
||||
// `keyboard-inset-height` env var lifts us above the soft keyboard;
|
||||
// on older iOS the 100dvh-based MobileShell layout already shrinks
|
||||
// the visual viewport when the keyboard is open, so bottom:0 lands
|
||||
// just above the keyboard naturally.
|
||||
bottom: 'env(keyboard-inset-height, 0px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
maxHeight: 'min(60dvh, 60vh)',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
|
||||
|
||||
{/* Tab bar (only when multiple tabs available) */}
|
||||
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function InputPopover(props: InputPopoverProps) {
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
|
||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||
{ key: 'emoji', label: 'Emoji' },
|
||||
];
|
||||
if (props.gifEnabled) {
|
||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
||||
}
|
||||
return <DesktopPopover {...props} availableTabs={availableTabs} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user