personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 7.4 kB view raw
1import { flip, shift, size } from '@floating-ui/dom'; 2import { type Placement, getSide } from '@floating-ui/utils'; 3import { useFloating } from 'solid-floating-ui'; 4import { type Component, type JSX, createSignal, onMount } from 'solid-js'; 5 6import { useModalContext } from '~/globals/modals'; 7 8import { createEventListener } from '~/lib/hooks/event-listener'; 9import { useMediaQuery } from '~/lib/hooks/media-query'; 10import { useModalClose } from '~/lib/hooks/modal-close'; 11import { on } from '~/lib/utils/misc'; 12 13import Divider from './divider'; 14import CheckOutlinedIcon from './icons-central/check-outline'; 15 16export interface MenuContainerProps { 17 anchor: HTMLElement; 18 placement?: Placement; 19 cover?: boolean; 20 children: JSX.Element; 21} 22 23const MenuContainer = (props: MenuContainerProps) => { 24 const { close, isActive } = useModalContext(); 25 const isDesktop = useMediaQuery('((width >= 688px) and (height >= 500px)) or (pointer: fine)'); 26 27 return on(isDesktop, ($isDesktop) => { 28 if ($isDesktop) { 29 const [floating, setFloating] = createSignal<HTMLElement>(); 30 const position = useFloating(() => props.anchor, floating, { 31 placement: props.placement ?? 'bottom-end', 32 strategy: 'absolute', 33 middleware: [ 34 props.cover && { 35 name: 'offset', 36 fn(state) { 37 const reference = state.rects.reference; 38 const x = state.x; 39 const y = state.y; 40 41 const multi = getSide(state.placement) === 'bottom' ? 1 : -1; 42 43 return { 44 x: x, 45 y: y - reference.height * multi, 46 }; 47 }, 48 }, 49 flip({ 50 padding: 16, 51 crossAxis: false, 52 }), 53 shift({ 54 padding: 16, 55 }), 56 size({ 57 padding: 16, 58 apply({ availableWidth, availableHeight, elements }) { 59 Object.assign(elements.floating.style, { 60 maxWidth: `${availableWidth}px`, 61 maxHeight: `${availableHeight}px`, 62 }); 63 }, 64 }), 65 ], 66 }); 67 68 const ref = (node: HTMLElement) => { 69 setFloating(node); 70 71 useModalClose(node, close, isActive); 72 73 requestAnimationFrame(() => { 74 const found = node.querySelector('[role^=menuitem]'); 75 // @ts-expect-error 76 found?.focus(); 77 }); 78 }; 79 80 return ( 81 <div 82 ref={ref} 83 role="menu" 84 onKeyDown={onKeyDown} 85 style={{ top: `${position.y ?? 0}px`, left: `${position.x ?? 0}px` }} 86 class="absolute flex max-w-sm flex-col overflow-hidden overflow-y-auto rounded-md border border-outline bg-background" 87 > 88 {props.children} 89 </div> 90 ); 91 } else { 92 const hasReducedMotion = false && matchMedia('(prefers-reduced-motion)').matches; 93 const hasScrollSnapEvent = 'onscrollsnapchange' in window; 94 95 return ( 96 <div 97 ref={(node) => { 98 if (hasScrollSnapEvent) { 99 createEventListener(node, 'scrollsnapchange', () => { 100 if (node.scrollTop < 0) { 101 close(); 102 } 103 }); 104 } else { 105 onMount(() => { 106 const content = node.firstElementChild!; 107 108 createEventListener(node, 'scroll', () => { 109 if (-node.scrollTop > content.clientHeight - 2) { 110 close(); 111 } 112 }); 113 }); 114 } 115 }} 116 class="flex grow snap-y snap-mandatory flex-col-reverse items-center self-stretch overflow-y-auto overscroll-none bg-contrast-overlay/75 scrollbar-hide" 117 > 118 <div class="relative max-h-[60svh] w-[540px] max-w-full shrink-0 grow"> 119 <div class="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between"> 120 <div class="h-12 w-full -translate-y-full snap-end"></div> 121 <div class="h-12 w-full snap-end"></div> 122 </div> 123 124 <div 125 ref={(node) => { 126 if (!hasReducedMotion) { 127 let closing = false; 128 129 onMount(() => { 130 const easing = 'cubic-bezier(0.32, 0.72, 0, 1)'; 131 const duration = 350; 132 133 const handleClose = () => { 134 if (closing) { 135 return; 136 } 137 138 const anim = node.animate([{ translate: '0 0' }, { translate: '0 100%' }], { 139 easing, 140 duration, 141 }); 142 143 closing = true; 144 anim.finished.then(close); 145 }; 146 147 node.animate([{ translate: '0 100%' }, { translate: '0 0' }], { easing, duration }); 148 149 useModalClose(node, handleClose, isActive); 150 }); 151 } else { 152 useModalClose(node, close, isActive); 153 } 154 }} 155 class="flex h-full w-full flex-col overflow-clip rounded-t-lg bg-background pt-6 shadow-lg" 156 > 157 <div class="absolute inset-x-0 top-0 grid h-6 place-items-center"> 158 <div class="h-1 w-12 rounded-full bg-contrast/20"></div> 159 </div> 160 161 <div class="flex flex-col overflow-y-auto pb-3 text-sm">{props.children}</div> 162 </div> 163 </div> 164 <div class="h-svh w-full shrink-0 grow"></div> 165 </div> 166 ); 167 } 168 }) as unknown as JSX.Element; 169}; 170 171const onKeyDown: JSX.EventHandler<HTMLElement, KeyboardEvent> = (ev) => { 172 const key = ev.key; 173 const node = ev.currentTarget; 174 175 if (key === 'ArrowDown') { 176 const found = getSibling(node, true); 177 178 ev.preventDefault(); 179 found?.focus(); 180 } else if (key === 'ArrowUp') { 181 const found = getSibling(node, false); 182 183 ev.preventDefault(); 184 found?.focus(); 185 } 186}; 187 188const getSibling = (node: Element, next: boolean): HTMLElement | null => { 189 const options = Array.from( 190 node.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([hidden]):not([disabled])'), 191 ); 192 193 const selected = document.activeElement; 194 const index = selected instanceof HTMLElement ? options.indexOf(selected) : -1; 195 196 return ( 197 (next ? options[index + 1] : options[index - 1]) || (next ? options[0] : options[options.length - 1]) 198 ); 199}; 200 201export { MenuContainer as Container }; 202 203export interface MenuItemProps { 204 icon?: Component; 205 label: string; 206 variant?: 'default' | 'danger'; 207 disabled?: boolean; 208 checked?: boolean; 209 onClick?: () => void; 210} 211 212const MenuItem = (props: MenuItemProps) => { 213 const hasIcon = 'icon' in props; 214 const hasChecked = 'checked' in props; 215 216 return ( 217 <button role="menuitem" disabled={props.disabled} onClick={props.onClick} class={menuItemClasses(props)}> 218 {hasIcon && ( 219 <div class="mt-0.5 text-lg"> 220 {(() => { 221 const Icon = props.icon; 222 return Icon && <Icon />; 223 })()} 224 </div> 225 )} 226 227 <span class="grow text-sm font-bold">{props.label}</span> 228 229 {hasChecked && ( 230 <CheckOutlinedIcon 231 class={'-my-0.5 -mr-1 shrink-0 text-2xl text-accent' + (!props.checked ? ` invisible` : ``)} 232 /> 233 )} 234 </button> 235 ); 236}; 237const menuItemClasses = ({ variant = 'default', disabled }: MenuItemProps) => { 238 let cn = `flex gap-3 px-4 py-3 text-left outline-2 -outline-offset-2 outline-accent focus-visible:outline `; 239 240 if (disabled) { 241 cn += ` opacity-50`; 242 } 243 244 if (variant === 'default') { 245 cn += ` text-contrast`; 246 247 if (!disabled) { 248 cn += ` hover:bg-contrast/sm active:bg-contrast/sm-pressed`; 249 } 250 } else if (variant === 'danger') { 251 cn += ` text-error`; 252 253 if (!disabled) { 254 cn += ` hover:bg-contrast/sm active:bg-contrast/sm-pressed`; 255 } 256 } 257 258 return cn; 259}; 260 261export { MenuItem as Item }; 262 263export interface MenuDividerProps {} 264 265const MenuDivider = ({}: MenuDividerProps) => { 266 const isDesktop = useMediaQuery('(width >= 688px) and (height >= 500px)'); 267 268 return <Divider gutter={isDesktop() ? undefined : 'md'} class="mx-4" />; 269}; 270 271export { MenuDivider as Divider };