personal web client for Bluesky
typescript
solidjs
bluesky
atcute
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 };