Compare commits

...

3 Commits

5 changed files with 280 additions and 124 deletions
+2 -5
View File
@@ -67,11 +67,7 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
@@ -83,6 +79,7 @@
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.option {
+192 -56
View File
@@ -1,4 +1,14 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties
} from 'react';
import { createPortal } from 'react-dom';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
@@ -21,6 +31,51 @@ interface SelectProps {
id?: string;
}
const VIEWPORT_MARGIN = 8;
const DROPDOWN_OFFSET = 6;
const DROPDOWN_MAX_HEIGHT = 240;
const DROPDOWN_Z_INDEX = 2010;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
const left = clamp(
rect.left,
VIEWPORT_MARGIN,
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const direction =
spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
const maxHeight = Math.max(
0,
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
);
return direction === 'down'
? {
position: 'fixed',
top: rect.bottom + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
}
: {
position: 'fixed',
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
};
};
export function Select({
value,
options,
@@ -40,17 +95,78 @@ export function Select({
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const wrapRef = useRef<HTMLDivElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
const isOpen = open && !disabled;
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
const target = event.target as Node;
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const updateDropdownStyle = useCallback(() => {
if (!wrapRef.current) return;
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
}, []);
const scheduleDropdownStyleUpdate = useCallback(() => {
if (typeof window === 'undefined') return;
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updateDropdownStyle();
});
}, [updateDropdownStyle]);
useLayoutEffect(() => {
if (!isOpen) {
if (rafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
updateDropdownStyle();
const handleViewportChange = () => {
scheduleDropdownStyleUpdate();
};
const resizeObserver =
typeof ResizeObserver !== 'undefined' && wrapRef.current
? new ResizeObserver(() => {
scheduleDropdownStyleUpdate();
})
: null;
if (resizeObserver && wrapRef.current) {
resizeObserver.observe(wrapRef.current);
}
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
resizeObserver?.disconnect();
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
const resolvedHighlightedIndex =
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
@@ -136,60 +252,80 @@ export function Select({
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
);
useEffect(() => {
if (!isOpen || resolvedHighlightedIndex < 0) return;
const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`);
highlightedOption?.scrollIntoView({ block: 'nearest' });
}, [isOpen, resolvedHighlightedIndex, selectId]);
const dropdown =
isOpen && dropdownStyle
? (
<div
ref={dropdownRef}
className={styles.dropdown}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
style={dropdownStyle}
>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)
: null;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
<>
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
</div>
{dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
</>
);
}
+21
View File
@@ -15,6 +15,7 @@ export type AuthFilesUiState = {
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const AUTH_FILES_COMPACT_MODE_KEY = 'authFilesPage.compactMode';
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
@@ -40,3 +41,23 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
// ignore
}
};
export const readPersistedAuthFilesCompactMode = (): boolean | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(AUTH_FILES_COMPACT_MODE_KEY);
if (raw === null) return null;
return JSON.parse(raw) === true;
} catch {
return null;
}
};
export const writePersistedAuthFilesCompactMode = (compactMode: boolean) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AUTH_FILES_COMPACT_MODE_KEY, JSON.stringify(compactMode));
} catch {
// ignore
}
};
+3 -27
View File
@@ -356,39 +356,15 @@
.fileGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}
.fileGridQuotaManaged {
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
}
.fileGridCompact {
grid-template-columns: repeat(4, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
}
.antigravityGrid {
+62 -36
View File
@@ -53,7 +53,9 @@ import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFi
import {
isAuthFilesSortMode,
readAuthFilesUiState,
readPersistedAuthFilesCompactMode,
writeAuthFilesUiState,
writePersistedAuthFilesCompactMode,
type AuthFilesSortMode,
} from '@/features/authFiles/uiState';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
@@ -88,6 +90,7 @@ export function AuthFilesPage() {
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [uiStateHydrated, setUiStateHydrated] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
@@ -176,46 +179,58 @@ export function AuthFilesPage() {
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
useEffect(() => {
const persisted = readAuthFilesUiState();
if (!persisted) return;
const persistedCompactMode = readPersistedAuthFilesCompactMode();
if (typeof persistedCompactMode === 'boolean') {
setCompactMode(persistedCompactMode);
}
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (typeof persisted.compactMode === 'boolean') {
setCompactMode(persisted.compactMode);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
const legacyPageSize =
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
? clampCardPageSize(persisted.pageSize)
: null;
const regularPageSize =
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
? clampCardPageSize(persisted.regularPageSize)
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
const compactPageSize =
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
? clampCardPageSize(persisted.compactPageSize)
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
setPageSizeByMode({
regular: regularPageSize,
compact: compactPageSize,
});
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
const persisted = readAuthFilesUiState();
if (persisted) {
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (
typeof persistedCompactMode !== 'boolean' &&
typeof persisted.compactMode === 'boolean'
) {
setCompactMode(persisted.compactMode);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
const legacyPageSize =
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
? clampCardPageSize(persisted.pageSize)
: null;
const regularPageSize =
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
? clampCardPageSize(persisted.regularPageSize)
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
const compactPageSize =
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
? clampCardPageSize(persisted.compactPageSize)
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
setPageSizeByMode({
regular: regularPageSize,
compact: compactPageSize,
});
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
}
}
setUiStateHydrated(true);
}, []);
useEffect(() => {
if (!uiStateHydrated) return;
writeAuthFilesUiState({
filter,
problemOnly,
@@ -227,7 +242,18 @@ export function AuthFilesPage() {
compactPageSize: pageSizeByMode.compact,
sortMode,
});
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
writePersistedAuthFilesCompactMode(compactMode);
}, [
compactMode,
filter,
page,
pageSize,
pageSizeByMode,
problemOnly,
search,
sortMode,
uiStateHydrated,
]);
useEffect(() => {
setPageSizeInput(String(pageSize));