Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions packages/fingerprint/dist/ui/assets/index-1yjBrEfd.js

This file was deleted.

1 change: 1 addition & 0 deletions packages/fingerprint/dist/ui/assets/index-BNQ_IIC1.css

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/fingerprint/dist/ui/assets/index-BeAnCYkZ.js

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion packages/fingerprint/dist/ui/assets/index-DeacCGXr.css

This file was deleted.

4 changes: 2 additions & 2 deletions packages/fingerprint/dist/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f7f5" />
<title>Magpie · 模型指纹</title>
<script type="module" crossorigin src="./assets/index-1yjBrEfd.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DeacCGXr.css">
<script type="module" crossorigin src="./assets/index-BeAnCYkZ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BNQ_IIC1.css">
</head>
<body>
<div id="root"></div>
Expand Down
143 changes: 109 additions & 34 deletions packages/fingerprint/web/AppMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { createPortal } from 'react-dom';

export type AppMenuOption = { value: string; label: string };

Expand All @@ -18,22 +19,78 @@ export function AppMenu({ label, value, options, onChange, disabled = false }: A
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const list = useRef<HTMLDivElement>(null);
const keyboardNavigation = useRef(true);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<{
top: number;
left: number;
width: number;
maxHeight: number;
} | null>(null);
const selected = Math.max(
0,
options.findIndex((option) => option.value === value),
);
const [active, setActive] = useState(selected);

useEffect(() => setActive(selected), [selected]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
if (!button.current || !list.current) return;
const rect = button.current.getBoundingClientRect();
const margin = 8;
const gap = 4;
const width = Math.min(
Math.max(rect.width, 170),
Math.max(0, window.innerWidth - margin * 2),
);
const below = Math.max(0, window.innerHeight - rect.bottom - gap - margin);
const above = Math.max(0, rect.top - gap - margin);
const desired = Math.min(280, list.current.scrollHeight + 2);
const upward = below < desired && above > below;
const maxHeight = Math.min(280, upward ? above : below);
const height = Math.min(desired, maxHeight);
setPosition({
top: upward ? rect.top - gap - height : rect.bottom + gap,
left: Math.max(margin, Math.min(rect.left, window.innerWidth - margin - width)),
width,
maxHeight,
});
};
place();
window.addEventListener('resize', place);
const onScroll = (event: Event) => {
if (event.target !== list.current) place();
};
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', onScroll, true);
};
}, [open, options.length]);
useEffect(() => {
if (open) list.current?.focus();
}, [open]);
useEffect(() => {
if (!open || !list.current || !keyboardNavigation.current) return;
const option = list.current.querySelector<HTMLElement>(`[id="${listId}-option-${active}"]`);
if (!option) return;
const top = option.offsetTop;
const bottom = top + option.offsetHeight;
if (top < list.current.scrollTop) list.current.scrollTop = top;
else if (bottom > list.current.scrollTop + list.current.clientHeight)
list.current.scrollTop = bottom - list.current.clientHeight;
}, [active, listId, open]);

useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent | PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
if (
!root.current?.contains(event.target as Node) &&
!list.current?.contains(event.target as Node)
)
setOpen(false);
};
document.addEventListener('pointerdown', closeOutside);
return () => document.removeEventListener('pointerdown', closeOutside);
Expand All @@ -54,6 +111,7 @@ export function AppMenu({ label, value, options, onChange, disabled = false }: A
};

const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
keyboardNavigation.current = true;
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
Expand Down Expand Up @@ -93,6 +151,7 @@ export function AppMenu({ label, value, options, onChange, disabled = false }: A
aria-expanded={open}
disabled={disabled}
onClick={() => {
keyboardNavigation.current = true;
setActive(selected);
setOpen((current) => !current);
}}
Expand All @@ -104,6 +163,7 @@ export function AppMenu({ label, value, options, onChange, disabled = false }: A
event.key === ' '
) {
event.preventDefault();
keyboardNavigation.current = true;
setActive(selected);
setOpen(true);
} else if (event.key === 'Escape') {
Expand All @@ -116,39 +176,54 @@ export function AppMenu({ label, value, options, onChange, disabled = false }: A
⌄
</span>
</button>
<div
id={listId}
ref={list}
className="app-menu-list"
role="listbox"
aria-label={label}
aria-activedescendant={`${listId}-option-${active}`}
tabIndex={open ? 0 : -1}
hidden={!open}
onKeyDown={onListKeyDown}
onBlur={(event) => {
if (!root.current?.contains(event.relatedTarget as Node)) setOpen(false);
}}
>
{options.map((option, index) => (
<button
key={option.value}
id={`${listId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[option.value === value ? 'selected' : '', index === active ? 'active' : '']
.filter(Boolean)
.join(' ')}
tabIndex={-1}
onMouseEnter={() => setActive(index)}
onClick={() => choose(index)}
{open &&
createPortal(
<div
id={listId}
ref={list}
className="app-menu-list"
role="listbox"
aria-label={label}
aria-activedescendant={`${listId}-option-${active}`}
tabIndex={0}
style={position ? { ...position, visibility: 'visible' } : { visibility: 'hidden' }}
onKeyDown={onListKeyDown}
onBlur={(event) => {
if (
!root.current?.contains(event.relatedTarget as Node) &&
!list.current?.contains(event.relatedTarget as Node)
)
setOpen(false);
}}
>
<span>{option.label}</span>
{option.value === value && <span aria-hidden="true">✓</span>}
</button>
))}
</div>
{options.map((option, index) => (
<button
key={option.value}
id={`${listId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[
option.value === value ? 'selected' : '',
index === active ? 'active' : '',
]
.filter(Boolean)
.join(' ')}
tabIndex={-1}
onMouseMove={(event) => {
if (!event.movementX && !event.movementY) return;
keyboardNavigation.current = false;
setActive(index);
}}
onClick={() => choose(index)}
>
<span>{option.label}</span>
{option.value === value && <span aria-hidden="true">✓</span>}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
13 changes: 7 additions & 6 deletions packages/fingerprint/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,9 @@ textarea:focus-visible {
line-height: 1;
}
.app-menu-list {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
max-height: 280px;
position: fixed;
z-index: 100;
box-sizing: border-box;
overflow: auto;
padding: 4px;
border: 1px solid var(--line);
Expand All @@ -112,6 +109,10 @@ textarea:focus-visible {
text-align: left;
cursor: pointer;
}
.app-menu-list > button > span:first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.app-menu-list > button:hover,
.app-menu-list > button:focus-visible,
.app-menu-list > button.selected,
Expand Down
Loading