diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea06fd..143a72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-07-24 + +### Added + +- `FileBrowser` — full-featured **generic** storage browser (roots sidebar, breadcrumbs, sort/filter, multi-select delete, optional create folder / upload / download hooks, text/JSON preview). No product-specific OCI/catalog logic. Export: `FileBrowser`, `FileBrowserItem`, `FileBrowserProps`, `FileBrowserRoot`. +- `Modal` / `ConfirmDialog` — centered dialog shell (Escape + backdrop close, title/body/footer) and confirm variant with primary/cancel actions. +- `Loader` / `Spinner` — full-screen overlay loader with optional controlled `visible`, plus event helpers `showLoader` / `hideLoader` / `enableLoader` / `disableLoader` (ref-counted, matching host HTTP auth wiring). Inline `Spinner` for local loading UI. +- `Masonry` — responsive column masonry layout (`breakpoints`). +- `LightBox` — masonry gallery + fullscreen viewer (Escape / arrow keys, captions, thumbnails). +- `CookieConsent` — consent bar with injectable `linkComponent` (no hard-coded react-router), plus `getCookie` / `setCookie` helpers. +- `WhatsAppButton` / `FacebookMessengerButton` — click-to-chat links (`wa.me` / `m.me`); optional floating corner placement. Intentionally not the deprecated Facebook Customer Chat SDK. +- `Ratings` — star rating widget (fractional fill, optional interactive `onChange`, sizes). +- Core hooks: `usePrevious`, `useInterval`, `useOnScreen`, `useLocalStorage`, `useSessionStorage`, `useLongPress`, `useDebounce`, `useOnClickOutside`, `useMedia`, `useHover` (typed, SSR-safe where relevant; storage hooks sync via `storage` events). +- Jest coverage for new hooks and component helpers (`FileBrowser` format/prefs, cookie helpers, loader events, social href builders); Storybook interaction plays for new components and a `core/hooks` demo. + +### Changed + +- `FileUploadComponent` — drag-and-drop (`droppable`, default true), `accept` filter, drop-active highlight; drop replaces the current selection. +- Dev dependencies refreshed within current majors (Storybook 10.5, React 19.2.8, Vitest 4.1.10, axios 1.18, lucide-react 1.26, etc.). Held majors: SignalR 8, TypeScript 6, Vite 6. +- Coverage badges: `CoverageBadges` uses `badgeFormat: shields` (updates `README.md` img.shields.io URLs) instead of writing SVG files under `assets/badges/`. + +### Removed + +- `ResponseModelBaseSchema` — empty Zod marker; keep the `ResponseModelBase` TypeScript type only (matches Core). +- `assets/badges/` — local SVG coverage badges (replaced by shields.io). + +### Fixed + +- `LightBox`: fullscreen viewer footer uses a fixed-height horizontal filmstrip instead of a second Masonry grid (which crushed the main image). +- `DataTable`: resync column widths when `columns.length` changes; `columnWidth` falls back to default so react-virtualized never receives `undefined`. + ## [0.4.0] - 2026-07-07 ### Changed @@ -14,7 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Release pipeline (`utils/engines/release/scriptSettings.json`) publishes one package; `NpmReleaseVersion.syncWorkspaceVersions` disabled. - Jest, Vitest, and Storybook configs updated for the flat `src/` layout (`@webui/*` aliases point at `src/{contracts,core,components}`). - VS Code/Cursor: TypeScript SDK and integrated terminal default to `src/`. -- `src/coverage/` explicitly gitignored; generated coverage is no longer tracked (README badges remain under `assets/badges/`). +- `src/coverage/` explicitly gitignored; generated coverage is no longer tracked. ### Removed diff --git a/README.md b/README.md index 9ecbc39..52c1d4c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # MaksIT.WebUI -![Line Coverage](/assets/badges/coverage-lines.svg) ![Branch Coverage](/assets/badges/coverage-branches.svg) ![Method Coverage](/assets/badges/coverage-methods.svg) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-83.8%25-brightgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-72.5%25-green) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-72.8%25-green) Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps. @@ -24,7 +26,7 @@ npm run storybook **Storybook** (`npm run storybook`) runs a local component catalog with Tailwind, React Router, autodocs, a11y checks, and **Vitest component tests** (testing widget + `npm run test-storybook`). Stories live under `src/stories/components/`; see `src/stories/README.md`. -Tests and coverage badges: **`utils\Invoke-TestEngine.bat`** (plugin config in `utils\engines\test\scriptSettings.json`; uses `NpmJestTest`). +Tests and coverage badges (shields.io URLs in this README): **`utils\Invoke-TestEngine.bat`** (plugin config in `utils\engines\test\scriptSettings.json`; uses `NpmJestTest` + `CoverageBadges` with `badgeFormat: shields`). ## Release to npmjs diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg deleted file mode 100644 index f5e7a1b..0000000 --- a/assets/badges/coverage-branches.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Branch Coverage: 78.2% - - - - - - - - - - - - - - - Branch Coverage - - 78.2% - - diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg deleted file mode 100644 index 8a66996..0000000 --- a/assets/badges/coverage-lines.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Line Coverage: 82.5% - - - - - - - - - - - - - - - Line Coverage - - 82.5% - - diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg deleted file mode 100644 index 2313428..0000000 --- a/assets/badges/coverage-methods.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Method Coverage: 58.2% - - - - - - - - - - - - - - - Method Coverage - - 58.2% - - diff --git a/src/README.md b/src/README.md index 585c49e..0a9c489 100644 --- a/src/README.md +++ b/src/README.md @@ -16,8 +16,8 @@ Peer dependencies must be installed in the host app. | Area | Examples | |------|----------| | Contracts | `PagedRequest`, `PatchOperation`, `LoginRequestSchema`, OAuth DTOs | -| Core | `deepDelta`, `useFormState`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers | -| Components | `DataTable`, `Layout`, editors, `ExternalLoginButtons`, `IdpLoginCallbackHandler` | +| Core | `deepDelta`, `useFormState`, `useLocalStorage`, `useSessionStorage`, `useOnScreen`, `useInterval`, `usePrevious`, `useLongPress`, `useDebounce`, `useOnClickOutside`, `useMedia`, `useHover`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers | +| Components | `DataTable`, `FileBrowser` (create/upload/download hooks), `Modal`, `Loader`, `Masonry`, `LightBox`, `CookieConsent`, `Ratings`, social chat buttons, `Layout`, editors, OAuth UI | ## Example — imports @@ -45,6 +45,26 @@ if (deltaHasOperations(delta)) { } ``` +**Request DTOs** that extend `RequestModelBase` (or `PatchRequestModelBase` for PATCH) must use the matching base Zod schema — `RequestModelBaseSchema` or `PatchRequestModelBaseSchema` — not a plain `z.object({...})`. Otherwise `safeParse` strips unknown keys (including PATCH `operations`). Response DTOs only need the `ResponseModelBase` TypeScript marker; there is no response base schema. + +```ts +import { RequestModelBaseSchema, PatchRequestModelBaseSchema } from '@maks-it.com/webui' + +export const CreateEntityRequestSchema = RequestModelBaseSchema.and( + z.object({ name: z.string().min(1) }), +) + +export const PatchEntityScopeRequestSchema = PatchRequestModelBaseSchema.and( + z.object({ + id: z.string().optional(), + _deltaId: z.string().optional(), + entityId: z.string().optional(), + entityType: z.number().optional(), + scope: z.number().optional(), + }), +) +``` + ## Repository [github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/` diff --git a/src/components/components/CookieConsent/CookieConsent.tsx b/src/components/components/CookieConsent/CookieConsent.tsx new file mode 100644 index 0000000..179134e --- /dev/null +++ b/src/components/components/CookieConsent/CookieConsent.tsx @@ -0,0 +1,138 @@ +import { + type ComponentType, + type FC, + type ReactNode, + useEffect, + useState, +} from 'react' +import { getCookie, setCookie } from './cookies' + +export interface CookieConsentLink { + label: ReactNode + /** Used by default `` and as fallback for router links. */ + href?: string + /** Preferred when injecting react-router `Link`. */ + to?: string + /** Extra props passed to the injected link component. */ + linkProps?: Record +} + +type ConsentLinkComponent = ComponentType<{ + href?: string + to?: string + className?: string + children?: ReactNode + [key: string]: unknown +}> + +export interface CookieConsentProps { + children?: ReactNode + message?: ReactNode + links?: CookieConsentLink[] + acceptLabel?: string + cookieName?: string + cookieDays?: number + /** Host injects `Link` from react-router (or any anchor-like component). Defaults to ``. */ + linkComponent?: ConsentLinkComponent + onAccept?: () => void + onDismiss?: () => void + className?: string +} + +const DefaultLink: ConsentLinkComponent = ({ + href, + to, + children, + ...rest +}) => ( + + {children} + +) + +const CookieConsent: FC = ({ + children, + message, + links = [], + acceptLabel = 'Accept', + cookieName = 'CookieConsent', + cookieDays = 30, + linkComponent: LinkComponent = DefaultLink, + onAccept, + onDismiss, + className = '', +}) => { + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (!getCookie(cookieName)) + setVisible(true) + }, [cookieName]) + + if (!visible) + return null + + const handleAccept = () => { + setCookie(cookieName, 'true', cookieDays, typeof window !== 'undefined' ? window.location.hostname : undefined) + setVisible(false) + onAccept?.() + } + + const handleDismiss = () => { + setVisible(false) + onDismiss?.() + } + + return ( +
+ + +
+ {children} + {message} + {links.length > 0 ? ( +
    + {links.map((link, index) => ( +
  • + + {link.label} + +
  • + ))} +
+ ) : null} +
+ + +
+ ) +} + +export { CookieConsent } diff --git a/src/components/components/CookieConsent/cookies.ts b/src/components/components/CookieConsent/cookies.ts new file mode 100644 index 0000000..970d6ff --- /dev/null +++ b/src/components/components/CookieConsent/cookies.ts @@ -0,0 +1,31 @@ +export const getCookie = (name: string): string => { + if (typeof document === 'undefined') + return '' + + const prefix = `${name}=` + const parts = decodeURIComponent(document.cookie).split(';') + + for (const part of parts) { + const trimmed = part.trim() + if (trimmed.startsWith(prefix)) + return trimmed.substring(prefix.length) + } + + return '' +} + +export const setCookie = ( + name: string, + value: string, + days: number, + domain?: string, +): void => { + if (typeof document === 'undefined') + return + + const expires = new Date() + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) + + const domainPart = domain ? `; domain=${domain}` : '' + document.cookie = `${name}=${encodeURIComponent(value)}${domainPart}; expires=${expires.toUTCString()}; path=/; SameSite=Lax` +} diff --git a/src/components/components/CookieConsent/index.ts b/src/components/components/CookieConsent/index.ts new file mode 100644 index 0000000..61c5b0b --- /dev/null +++ b/src/components/components/CookieConsent/index.ts @@ -0,0 +1,3 @@ +export { CookieConsent } from './CookieConsent' +export type { CookieConsentProps, CookieConsentLink } from './CookieConsent' +export { getCookie, setCookie } from './cookies' diff --git a/src/components/components/DataTable/DataTable.tsx b/src/components/components/DataTable/DataTable.tsx index 8dd8514..50cac62 100644 --- a/src/components/components/DataTable/DataTable.tsx +++ b/src/components/components/DataTable/DataTable.tsx @@ -128,6 +128,33 @@ const DataTable = ,>(props: DataTableProps) } }, [colWidths, storageKey]) + // Keep widths in sync when column definitions change (e.g. parent swaps tables without remounting). + useEffect(() => { + const expected = columns.length + 1 + setColWidths((prev) => { + if (prev.length === expected && prev.every((w) => typeof w === 'number' && Number.isFinite(w))) + return prev + + const defaults = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)] + if (storageKey) { + try { + const stored = localStorage.getItem(storageKey) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed) && parsed.length === expected && parsed.every((w) => typeof w === 'number' && Number.isFinite(w))) + return parsed + localStorage.removeItem(storageKey) + } + } + catch { + // fall through to defaults + } + } + + return defaults + }) + }, [columns.length, storageKey]) + useEffect(() => { const el = filterMeasureRef.current if (!el || columns.length === 0) return @@ -429,7 +456,7 @@ const DataTable = ,>(props: DataTableProps) ref={gridRef} cellRenderer={cellRenderer} columnCount={columns.length + 1} - columnWidth={({ index }) => colWidths[index]} + columnWidth={({ index }) => colWidths[index] ?? DEFAULT_COL_WIDTH} fixedColumnCount={1} fixedRowCount={HEADER_ROWS} height={height} diff --git a/src/components/components/FileBrowser/FileBrowser.tsx b/src/components/components/FileBrowser/FileBrowser.tsx new file mode 100644 index 0000000..33c6daf --- /dev/null +++ b/src/components/components/FileBrowser/FileBrowser.tsx @@ -0,0 +1,774 @@ +import { FC, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + ChevronRight, + Copy, + Download, + Eye, + File, + FileCode, + FileImage, + FileJson, + FileText, + Folder, + FolderOpen, + FolderPlus, + HardDrive, + Package, + RefreshCw, + Search, + Trash2, + Upload, +} from 'lucide-react' +import { ButtonComponent } from '../editors/ButtonComponent' +import { Modal } from '../Modal' +import { + buildNavCrumbs, + canPreviewTextObject, + formatBytes, + formatDate, + formatMediaType, + formatPreviewText, + itemKindLabel, + PREVIEW_MAX_BYTES, +} from './format' +import { loadPrefs, savePrefs, type SortColumn, type SortDirection } from './prefs' +import type { FileBrowserItem, FileBrowserProps } from './types' + +const typeSortValue = (item: FileBrowserItem): string => { + if (item.isFolder) + return item.isVirtual ? 'Virtual' : 'Folder' + + return formatMediaType(item.contentType) +} + +const compareItems = ( + a: FileBrowserItem, + b: FileBrowserItem, + column: SortColumn, + direction: SortDirection, +): number => { + const dir = direction === 'asc' ? 1 : -1 + + if (a.isFolder !== b.isFolder) + return a.isFolder ? -1 : 1 + + let cmp = 0 + switch (column) { + case 'name': + cmp = (a.label ?? a.name).localeCompare(b.label ?? b.name, undefined, { sensitivity: 'base' }) + break + case 'kind': + cmp = itemKindLabel(a).localeCompare(itemKindLabel(b), undefined, { sensitivity: 'base' }) + break + case 'size': { + const sa = a.isFolder ? -1 : (a.size ?? 0) + const sb = b.isFolder ? -1 : (b.size ?? 0) + cmp = sa === sb ? 0 : sa < sb ? -1 : 1 + break + } + case 'modified': { + const ta = a.lastModified ? Date.parse(a.lastModified) : 0 + const tb = b.lastModified ? Date.parse(b.lastModified) : 0 + cmp = ta === tb ? 0 : ta < tb ? -1 : 1 + break + } + case 'type': + cmp = typeSortValue(a).localeCompare(typeSortValue(b), undefined, { sensitivity: 'base' }) + break + } + + if (cmp !== 0) + return cmp * dir + + return (a.label ?? a.name).localeCompare(b.label ?? b.name, undefined, { sensitivity: 'base' }) * dir +} + +const fileIcon = (item: FileBrowserItem) => { + if (item.isFolder) { + if (item.isVirtual) + return Package + return Folder + } + + const name = item.name.toLowerCase() + if (name.endsWith('.json') || (item.contentType ?? '').includes('json')) + return FileJson + if (/\.(png|jpe?g|gif|webp|svg|bmp)$/.test(name) || (item.contentType ?? '').startsWith('image/')) + return FileImage + if (/\.(txt|md|ya?ml|xml|csv|log|html?|css)$/.test(name) || (item.contentType ?? '').startsWith('text/')) + return FileText + if (/\.(js|ts|tsx|jsx|cs|py|go|rs)$/.test(name)) + return FileCode + + return File +} + +const FileBrowser: FC = (props) => { + const { + roots, + activeRootId, + pathSegments, + items, + loading = false, + description, + emptyHint, + rootsTitle = 'Roots', + canLoadMore = false, + onNavigateRoot, + onNavigatePath, + onRefresh, + onLoadMore, + onDelete, + onCreateFolder, + onUpload, + onDownload, + onFetchTextPreview, + } = props + + const filterInputRef = useRef(null) + const uploadInputRef = useRef(null) + const previewRequestId = useRef(0) + const initialPrefs = useMemo(() => loadPrefs(), []) + + const [selectedKeys, setSelectedKeys] = useState([]) + const [copiedField, setCopiedField] = useState(null) + const [nameFilter, setNameFilter] = useState('') + const [sortColumn, setSortColumn] = useState(initialPrefs.sortColumn) + const [sortDirection, setSortDirection] = useState(initialPrefs.sortDirection) + const [previewText, setPreviewText] = useState(null) + const [previewError, setPreviewError] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [createFolderOpen, setCreateFolderOpen] = useState(false) + const [folderName, setFolderName] = useState('') + const [actionBusy, setActionBusy] = useState(false) + + const pathKey = pathSegments.join('/') + const navCrumbs = useMemo(() => buildNavCrumbs(pathSegments), [pathSegments]) + + useEffect(() => { + setSelectedKeys([]) + setCopiedField(null) + setPreviewText(null) + setPreviewError(null) + setPreviewLoading(false) + setNameFilter('') + }, [activeRootId, pathKey]) + + useEffect(() => { + savePrefs({ sortColumn, sortDirection }) + }, [sortColumn, sortDirection]) + + const toggleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection(d => d === 'asc' ? 'desc' : 'asc') + return + } + + setSortColumn(column) + setSortDirection('asc') + } + + const visibleItems = useMemo(() => { + let list = [...items] + const q = nameFilter.trim().toLowerCase() + if (q) { + list = list.filter(i => { + const hay = `${i.label ?? ''} ${i.name} ${i.key}`.toLowerCase() + return hay.includes(q) + }) + } + + list.sort((a, b) => compareItems(a, b, sortColumn, sortDirection)) + return list + }, [items, nameFilter, sortColumn, sortDirection]) + + const selectedItem = useMemo(() => { + if (selectedKeys.length !== 1) + return undefined + return items.find(i => i.key === selectedKeys[0]) + }, [items, selectedKeys]) + + useEffect(() => { + if (!selectedItem || !onFetchTextPreview || !canPreviewTextObject(selectedItem)) { + setPreviewText(null) + setPreviewError(null) + setPreviewLoading(false) + return + } + + const requestId = ++previewRequestId.current + setPreviewLoading(true) + setPreviewError(null) + setPreviewText(null) + + void onFetchTextPreview(selectedItem) + .then((raw) => { + if (previewRequestId.current !== requestId) + return + setPreviewText(formatPreviewText(raw)) + setPreviewError(null) + }) + .catch((err: unknown) => { + if (previewRequestId.current !== requestId) + return + const message = err instanceof Error ? err.message : 'Failed to load preview.' + setPreviewError(message) + setPreviewText(null) + }) + .finally(() => { + if (previewRequestId.current === requestId) + setPreviewLoading(false) + }) + }, [selectedItem, onFetchTextPreview]) + + const selectedItems = useMemo( + () => items.filter(i => selectedKeys.includes(i.key)), + [items, selectedKeys], + ) + + const selectionBytes = useMemo( + () => selectedItems.reduce((sum, i) => sum + (i.isFolder ? 0 : (i.size ?? 0)), 0), + [selectedItems], + ) + + const selectionBlocked = useMemo( + () => selectedItems.some(i => i.isFolder && i.isVirtual), + [selectedItems], + ) + + const deleteBlockedReason = useMemo(() => { + if (!selectedItems.length) + return undefined + if (selectedItems.some(i => i.isFolder && i.isVirtual)) + return 'Virtual folders cannot be deleted here' + return undefined + }, [selectedItems]) + + const openFolder = (item: FileBrowserItem) => { + if (!item.isFolder) + return + + if (!activeRootId) { + onNavigateRoot(item.name) + return + } + + onNavigatePath(activeRootId, [...pathSegments, item.name]) + } + + const activateItem = (item: FileBrowserItem) => { + if (item.isFolder) { + openFolder(item) + return + } + + setSelectedKeys([item.key]) + } + + const toggleSelect = (key: string, multi: boolean) => { + setSelectedKeys(prev => { + if (!multi) + return prev.length === 1 && prev[0] === key ? [] : [key] + + if (prev.includes(key)) + return prev.filter(k => k !== key) + + return [...prev, key] + }) + } + + const canDownload = Boolean( + onDownload + && selectedItem + && !selectedItem.isFolder + && !selectedItem.isVirtual, + ) + + const handleDelete = async () => { + if (!selectedKeys.length || selectionBlocked || actionBusy) + return + + setActionBusy(true) + try { + await onDelete(selectedKeys) + setSelectedKeys([]) + } + finally { + setActionBusy(false) + } + } + + const handleCreateFolder = async () => { + if (!onCreateFolder || actionBusy) + return + + const name = folderName.trim() + if (!name) + return + + setActionBusy(true) + try { + await onCreateFolder(name) + setCreateFolderOpen(false) + setFolderName('') + setSelectedKeys([]) + } + finally { + setActionBusy(false) + } + } + + const handleUploadChange = async (files: FileList | null) => { + if (!onUpload || !files?.length || actionBusy) + return + + setActionBusy(true) + try { + await onUpload(Array.from(files)) + setSelectedKeys([]) + } + finally { + setActionBusy(false) + if (uploadInputRef.current) + uploadInputRef.current.value = '' + } + } + + const handleDownload = async () => { + if (!canDownload || !selectedItem || !onDownload || actionBusy) + return + + setActionBusy(true) + try { + await onDownload(selectedItem) + } + finally { + setActionBusy(false) + } + } + + const copyValue = async (field: string, value: string) => { + try { + await navigator.clipboard.writeText(value) + setCopiedField(field) + window.setTimeout(() => setCopiedField(prev => prev === field ? null : prev), 1500) + } + catch { + setCopiedField(null) + } + } + + const onFilterKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setNameFilter('') + filterInputRef.current?.blur() + } + } + + const SortIcon = ({ column }: { column: SortColumn }) => { + if (sortColumn !== column) + return + return sortDirection === 'asc' + ? + : + } + + const activeRoot = roots.find(r => r.id === activeRootId) + + return ( +
+ {description && ( +

{description}

+ )} + +
+ + +
+
+ {onCreateFolder && activeRootId ? ( + + ) : null} + {onUpload && activeRootId ? ( + <> + void handleUploadChange(e.target.files)} + /> + + + ) : null} + + {canDownload ? ( + + ) : null} + + +
+ + setNameFilter(e.target.value)} + onKeyDown={onFilterKeyDown} + placeholder={'Filter…'} + className={'h-8 w-40 rounded border border-gray-300 px-2 text-sm focus:border-sky-500 focus:outline-none'} + /> +
+
+ +
+ + {navCrumbs.map((crumb) => ( + + + + + ))} + {loading && Loading…} + {selectedKeys.length > 0 && ( + + {selectedKeys.length} selected + {selectionBytes > 0 ? ` · ${formatBytes(selectionBytes)}` : ''} + + )} +
+ +
+ {visibleItems.length === 0 && !loading ? ( +
+ {emptyHint ?? 'No items in this folder.'} +
+ ) : ( + + + + {([ + ['name', 'Name'], + ['kind', 'Kind'], + ['size', 'Size'], + ['modified', 'Modified'], + ['type', 'Type'], + ] as const).map(([column, label]) => ( + + ))} + + + + {(pathSegments.length > 0 || Boolean(activeRootId)) && ( + { + if (pathSegments.length > 0) + onNavigatePath(activeRootId, pathSegments.slice(0, -1)) + else + onNavigateRoot('') + }} + > + + + )} + {visibleItems.map((item) => { + const selected = selectedKeys.includes(item.key) + const Icon = fileIcon(item) + const displayName = item.label && item.label !== item.name ? item.label : item.name + + return ( + toggleSelect(item.key, e.ctrlKey || e.metaKey || e.shiftKey)} + onDoubleClick={() => activateItem(item)} + > + + + + + + + ) + })} + +
+ +
+ + + .. + +
+ + + + {displayName}{item.isFolder ? '/' : ''} + + + {itemKindLabel(item)} + {item.isFolder ? '—' : formatBytes(item.size)} + {formatDate(item.lastModified)} + {item.isFolder ? '—' : formatMediaType(item.contentType)} +
+ )} +
+ + {canLoadMore && onLoadMore && ( +
+ +
+ )} +
+ + {selectedItem && ( + + )} +
+ + { + if (!actionBusy) + setCreateFolderOpen(false) + }} + closeOnBackdrop={!actionBusy} + closeOnEscape={!actionBusy} + footer={( + <> + setCreateFolderOpen(false)} + /> + void handleCreateFolder()} + /> + + )} + > + + +
+ ) +} + +export { FileBrowser } diff --git a/src/components/components/FileBrowser/format.ts b/src/components/components/FileBrowser/format.ts new file mode 100644 index 0000000..285ebff --- /dev/null +++ b/src/components/components/FileBrowser/format.ts @@ -0,0 +1,150 @@ +const formatBytes = (value?: number | null): string => { + if (value == null || Number.isNaN(value)) + return '—' + + if (value < 1024) + return `${value} B` + + const units = ['KB', 'MB', 'GB', 'TB'] + let size = value / 1024 + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + + return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}` +} + +const formatDate = (value?: string | null): string => { + if (!value) + return '—' + + const date = new Date(value) + if (Number.isNaN(date.getTime())) + return value + + return date.toLocaleString() +} + +const formatMediaType = (mediaType?: string): string => { + if (!mediaType) + return '—' + + const map: Record = { + 'application/json': 'JSON', + 'application/xml': 'XML', + 'text/plain': 'Text', + 'text/html': 'HTML', + 'text/csv': 'CSV', + 'application/pdf': 'PDF', + 'application/x-directory': 'Directory', + } + + if (map[mediaType]) + return map[mediaType] + + if (mediaType.startsWith('text/')) + return 'Text' + + if (mediaType.startsWith('image/')) + return 'Image' + + const last = mediaType.split('/').pop() ?? mediaType + return last.length > 24 ? `${last.slice(0, 22)}…` : last +} + +const itemKindLabel = (item: { + isFolder: boolean + isVirtual?: boolean + contentType?: string + name: string +}): string => { + if (item.isFolder) + return item.isVirtual ? 'Virtual' : 'Folder' + + return formatMediaType(item.contentType) +} + +/** Max object size for in-UI text/JSON preview (2 MiB). */ +const PREVIEW_MAX_BYTES = 2 * 1024 * 1024 + +const isBinaryContentType = (contentType?: string): boolean => { + const ct = (contentType ?? '').toLowerCase() + if (!ct) + return false + + return ct.includes('octet-stream') + || ct.includes('tar') + || ct.includes('gzip') + || ct.includes('zip') + || ct.includes('pdf') + || ct.startsWith('image/') + || ct.startsWith('audio/') + || ct.startsWith('video/') +} + +const canPreviewTextObject = (item: { + isFolder: boolean + isVirtual?: boolean + name: string + contentType?: string + size?: number +}): boolean => { + if (item.isFolder || item.isVirtual) + return false + + if (item.size != null && item.size > PREVIEW_MAX_BYTES) + return false + + const ct = (item.contentType ?? '').toLowerCase() + if (isBinaryContentType(ct)) + return false + + if (ct.includes('json') || ct.startsWith('text/') || ct.includes('xml') || ct.includes('yaml')) + return true + + const name = item.name.toLowerCase() + if (/\.(json|txt|md|ya?ml|xml|csv|log|html?|css|js|ts|tsx|jsx)$/.test(name)) + return true + + if (item.size != null && item.size <= 512 * 1024 && !ct) + return true + + return false +} + +const formatPreviewText = (raw: string): string => { + const trimmed = raw.trim() + if (!trimmed) + return raw + + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) + || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.stringify(JSON.parse(trimmed), null, 2) + } + catch { + // Keep original text when not valid JSON. + } + } + + return raw +} + +const buildNavCrumbs = (pathSegments: string[]): { label: string; segments: string[] }[] => + pathSegments.map((segment, i) => ({ + label: segment, + segments: pathSegments.slice(0, i + 1), + })) + +export { + buildNavCrumbs, + canPreviewTextObject, + formatBytes, + formatDate, + formatMediaType, + formatPreviewText, + itemKindLabel, + PREVIEW_MAX_BYTES, +} diff --git a/src/components/components/FileBrowser/index.ts b/src/components/components/FileBrowser/index.ts new file mode 100644 index 0000000..4a2e29a --- /dev/null +++ b/src/components/components/FileBrowser/index.ts @@ -0,0 +1,2 @@ +export { FileBrowser } from './FileBrowser' +export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './types' diff --git a/src/components/components/FileBrowser/prefs.ts b/src/components/components/FileBrowser/prefs.ts new file mode 100644 index 0000000..a009106 --- /dev/null +++ b/src/components/components/FileBrowser/prefs.ts @@ -0,0 +1,40 @@ +export type SortColumn = 'name' | 'kind' | 'size' | 'modified' | 'type' +export type SortDirection = 'asc' | 'desc' + +export type FileBrowserPrefs = { + sortColumn: SortColumn + sortDirection: SortDirection +} + +const STORAGE_KEY = 'maksit.webui.fileBrowser.prefs.v1' + +const loadPrefs = (): FileBrowserPrefs => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) + return { sortColumn: 'name', sortDirection: 'asc' } + + const parsed = JSON.parse(raw) as Partial + return { + sortColumn: parsed.sortColumn ?? 'name', + sortDirection: parsed.sortDirection ?? 'asc', + } + } + catch { + return { sortColumn: 'name', sortDirection: 'asc' } + } +} + +const savePrefs = (prefs: FileBrowserPrefs): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) + } + catch { + // Ignore quota / private mode. + } +} + +export { + loadPrefs, + savePrefs, +} diff --git a/src/components/components/FileBrowser/types.ts b/src/components/components/FileBrowser/types.ts new file mode 100644 index 0000000..e941a57 --- /dev/null +++ b/src/components/components/FileBrowser/types.ts @@ -0,0 +1,48 @@ +export interface FileBrowserItem { + key: string + /** Prefer over name when set (display label). */ + label?: string + name: string + isFolder: boolean + /** Virtual list entry (not a real storage prefix). */ + isVirtual?: boolean + size?: number + lastModified?: string + contentType?: string + eTag?: string +} + +export interface FileBrowserRoot { + id: string + label: string + /** Tailwind text color class for the root folder icon (optional). */ + iconClassName?: string +} + +export interface FileBrowserProps { + roots: FileBrowserRoot[] + activeRootId: string + pathSegments: string[] + items: FileBrowserItem[] + loading?: boolean + description?: string + emptyHint?: string + rootsTitle?: string + canLoadMore?: boolean + onNavigateRoot: (rootId: string) => void + onNavigatePath: (rootId: string, segments: string[]) => void + onRefresh: () => void + onLoadMore?: () => void + onDelete: (keys: string[]) => void | Promise + /** Create a folder under the current root + pathSegments. Host builds the storage key/prefix. */ + onCreateFolder?: (folderName: string) => void | Promise + /** Upload into the current folder. Host maps File[] → object keys/content-types. */ + onUpload?: (files: File[]) => void | Promise + /** Download a single selected file (not folders / virtual entries). */ + onDownload?: (item: FileBrowserItem) => void | Promise + /** + * Load object bytes as text for the detail preview panel. + * Return the raw text; the browser formats JSON when possible. + */ + onFetchTextPreview?: (item: FileBrowserItem) => Promise +} diff --git a/src/components/components/LightBox/LightBox.tsx b/src/components/components/LightBox/LightBox.tsx new file mode 100644 index 0000000..6fb36f1 --- /dev/null +++ b/src/components/components/LightBox/LightBox.tsx @@ -0,0 +1,166 @@ +import { + type FC, + useCallback, + useEffect, + useState, +} from 'react' +import { ChevronLeft, ChevronRight, X } from 'lucide-react' +import { Masonry } from '../Masonry' + +export interface LightBoxSlide { + src: string + alt?: string + thumbnailSrc?: string +} + +export interface LightBoxProps { + slides: LightBoxSlide[] + breakpoints?: number[] + className?: string +} + +const LightBox: FC = ({ + slides, + breakpoints = [350, 500, 750], + className = '', +}) => { + const [isOpen, setIsOpen] = useState(false) + const [index, setIndex] = useState(0) + + const close = useCallback(() => { + setIsOpen(false) + }, []) + + const openAt = (slideIndex: number) => { + setIndex(slideIndex) + setIsOpen(true) + } + + const goPrev = useCallback(() => { + setIndex((current) => (current - 1 < 0 ? slides.length - 1 : current - 1)) + }, [slides.length]) + + const goNext = useCallback(() => { + setIndex((current) => (current + 1 > slides.length - 1 ? 0 : current + 1)) + }, [slides.length]) + + useEffect(() => { + if (!isOpen) + return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + close() + else if (event.key === 'ArrowLeft') + goPrev() + else if (event.key === 'ArrowRight') + goNext() + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [isOpen, close, goPrev, goNext]) + + if (slides.length === 0) + return null + + const active = slides[index] + + return ( +
+ + {slides.map((slide, slideIndex) => ( + + ))} + + + {isOpen && active ? ( +
+
+ + {index + 1} / {slides.length} + + +
+ +
+ + + {active.alt + + +
+ + {active.alt ? ( +

{active.alt}

+ ) : null} + +
+
+ {slides.map((slide, slideIndex) => ( + + ))} +
+
+
+ ) : null} +
+ ) +} + +export { LightBox } diff --git a/src/components/components/LightBox/index.ts b/src/components/components/LightBox/index.ts new file mode 100644 index 0000000..10d8fee --- /dev/null +++ b/src/components/components/LightBox/index.ts @@ -0,0 +1,2 @@ +export { LightBox } from './LightBox' +export type { LightBoxProps, LightBoxSlide } from './LightBox' diff --git a/src/components/components/Loader/Loader.tsx b/src/components/components/Loader/Loader.tsx new file mode 100644 index 0000000..701ccf0 --- /dev/null +++ b/src/components/components/Loader/Loader.tsx @@ -0,0 +1,105 @@ +import { type FC, useEffect, useState } from 'react' +import { loaderEventNames } from './loaderEvents' +import { Spinner, type SpinnerSize } from './Spinner' + +export interface LoaderProps { + /** When set, overrides the event-driven ref-count and forces visibility. */ + visible?: boolean + label?: string + size?: SpinnerSize + className?: string +} + +interface LoaderState { + counter: number + disabled: boolean +} + +const Loader: FC = ({ + visible, + label, + size = 'lg', + className = '', +}) => { + const [state, setState] = useState({ + counter: 0, + disabled: false, + }) + + useEffect(() => { + if (visible !== undefined) + return + + const onShow = () => { + setState((prev) => { + if (prev.disabled) + return { counter: 0, disabled: true } + + return { + ...prev, + counter: prev.counter + 1, + } + }) + } + + const onHide = () => { + setState((prev) => { + if (prev.disabled || prev.counter === 0) + return prev + + const counter = prev.counter - 1 + return { + ...prev, + counter, + } + }) + } + + const onEnable = () => { + setState((prev) => ({ + ...prev, + disabled: false, + })) + } + + const onDisable = () => { + setState({ + counter: 0, + disabled: true, + }) + } + + window.addEventListener(loaderEventNames.show, onShow) + window.addEventListener(loaderEventNames.hide, onHide) + window.addEventListener(loaderEventNames.enable, onEnable) + window.addEventListener(loaderEventNames.disable, onDisable) + + return () => { + window.removeEventListener(loaderEventNames.show, onShow) + window.removeEventListener(loaderEventNames.hide, onHide) + window.removeEventListener(loaderEventNames.enable, onEnable) + window.removeEventListener(loaderEventNames.disable, onDisable) + } + }, [visible]) + + const loading = visible !== undefined + ? visible + : !state.disabled && state.counter > 0 + + if (!loading) + return null + + return ( +
+ +
+ ) +} + +export { Loader } diff --git a/src/components/components/Loader/Spinner.tsx b/src/components/components/Loader/Spinner.tsx new file mode 100644 index 0000000..ed4a969 --- /dev/null +++ b/src/components/components/Loader/Spinner.tsx @@ -0,0 +1,39 @@ +import { type FC } from 'react' + +export type SpinnerSize = 'sm' | 'md' | 'lg' + +export interface SpinnerProps { + size?: SpinnerSize + className?: string + label?: string +} + +const sizeClass: Record = { + sm: 'h-6 w-6 border-2', + md: 'h-12 w-12 border-4', + lg: 'h-32 w-32 border-8', +} + +const Spinner: FC = ({ + size = 'md', + className = '', + label, +}) => ( +
+
+ {label ? {label} : null} +
+) + +export { Spinner } diff --git a/src/components/components/Loader/index.ts b/src/components/components/Loader/index.ts new file mode 100644 index 0000000..915973f --- /dev/null +++ b/src/components/components/Loader/index.ts @@ -0,0 +1,10 @@ +export { Loader } from './Loader' +export type { LoaderProps } from './Loader' +export { Spinner } from './Spinner' +export type { SpinnerProps, SpinnerSize } from './Spinner' +export { + showLoader, + hideLoader, + enableLoader, + disableLoader, +} from './loaderEvents' diff --git a/src/components/components/Loader/loaderEvents.ts b/src/components/components/Loader/loaderEvents.ts new file mode 100644 index 0000000..29cc404 --- /dev/null +++ b/src/components/components/Loader/loaderEvents.ts @@ -0,0 +1,27 @@ +const SHOW_EVENT = 'webui-show-loader' +const HIDE_EVENT = 'webui-hide-loader' +const ENABLE_EVENT = 'webui-enable-loader' +const DISABLE_EVENT = 'webui-disable-loader' + +export const showLoader = (): void => { + window.dispatchEvent(new CustomEvent(SHOW_EVENT)) +} + +export const hideLoader = (): void => { + window.dispatchEvent(new CustomEvent(HIDE_EVENT)) +} + +export const enableLoader = (): void => { + window.dispatchEvent(new CustomEvent(ENABLE_EVENT)) +} + +export const disableLoader = (): void => { + window.dispatchEvent(new CustomEvent(DISABLE_EVENT)) +} + +export const loaderEventNames = { + show: SHOW_EVENT, + hide: HIDE_EVENT, + enable: ENABLE_EVENT, + disable: DISABLE_EVENT, +} as const diff --git a/src/components/components/Masonry/Masonry.tsx b/src/components/components/Masonry/Masonry.tsx new file mode 100644 index 0000000..d89be6e --- /dev/null +++ b/src/components/components/Masonry/Masonry.tsx @@ -0,0 +1,73 @@ +import { + Children, + type FC, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +export interface MasonryProps { + /** Min widths (ascending) that add a column. Example: `[350, 500, 750]` → 1–4 columns. */ + breakpoints?: number[] + children?: ReactNode + className?: string + columnClassName?: string +} + +const getColumnCount = (width: number, breakpoints: number[]): number => + breakpoints.reduceRight((count, breakpoint, index) => ( + breakpoint < width ? count : index + ), breakpoints.length) + 1 + +const Masonry: FC = ({ + breakpoints = [350, 500, 750], + children, + className = '', + columnClassName = '', +}) => { + const containerRef = useRef(null) + const [columns, setColumns] = useState(1) + + const updateColumns = useCallback(() => { + const width = containerRef.current?.offsetWidth + if (width === undefined) + return + + const next = getColumnCount(width, breakpoints) + setColumns((prev) => (prev === next ? prev : next)) + }, [breakpoints]) + + useEffect(() => { + updateColumns() + window.addEventListener('resize', updateColumns) + return () => window.removeEventListener('resize', updateColumns) + }, [updateColumns, children]) + + const items = Children.toArray(children) + const columnItems: ReactNode[][] = Array.from({ length: columns }, () => []) + + items.forEach((child, index) => { + columnItems[index % columns]?.push(child) + }) + + return ( +
+
+ {columnItems.map((column, columnIndex) => ( +
+ {column.map((child, index) => ( +
{child}
+ ))} +
+ ))} +
+
+ ) +} + +export { Masonry } diff --git a/src/components/components/Masonry/index.ts b/src/components/components/Masonry/index.ts new file mode 100644 index 0000000..c811082 --- /dev/null +++ b/src/components/components/Masonry/index.ts @@ -0,0 +1,2 @@ +export { Masonry } from './Masonry' +export type { MasonryProps } from './Masonry' diff --git a/src/components/components/Modal/ConfirmDialog.tsx b/src/components/components/Modal/ConfirmDialog.tsx new file mode 100644 index 0000000..ce0b15a --- /dev/null +++ b/src/components/components/Modal/ConfirmDialog.tsx @@ -0,0 +1,58 @@ +import { type FC, type ReactNode } from 'react' +import { ButtonComponent } from '../editors/ButtonComponent' +import { Modal, type ModalSize } from './Modal' + +export interface ConfirmDialogProps { + isOpen?: boolean + title?: ReactNode + message?: ReactNode + confirmLabel?: string + cancelLabel?: string + confirmHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning' + onConfirm?: () => void + onCancel?: () => void + size?: ModalSize + closeOnBackdrop?: boolean + closeOnEscape?: boolean +} + +const ConfirmDialog: FC = ({ + isOpen = false, + title = 'Confirm', + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + confirmHierarchy = 'primary', + onConfirm, + onCancel, + size = 'sm', + closeOnBackdrop = true, + closeOnEscape = true, +}) => ( + + + + + )} + > + {message} + +) + +export { ConfirmDialog } diff --git a/src/components/components/Modal/Modal.tsx b/src/components/components/Modal/Modal.tsx new file mode 100644 index 0000000..ac91da6 --- /dev/null +++ b/src/components/components/Modal/Modal.tsx @@ -0,0 +1,130 @@ +import { + type FC, + type ReactNode, + useCallback, + useEffect, + useId, + useRef, +} from 'react' + +export type ModalSize = 'sm' | 'md' | 'lg' + +export interface ModalProps { + isOpen?: boolean + title?: ReactNode + children?: ReactNode + footer?: ReactNode + onClose?: () => void + closeOnBackdrop?: boolean + closeOnEscape?: boolean + size?: ModalSize + className?: string +} + +const sizeClass: Record = { + sm: 'max-w-sm', + md: 'max-w-lg', + lg: 'max-w-2xl', +} + +const Modal: FC = ({ + isOpen = false, + title, + children, + footer, + onClose, + closeOnBackdrop = true, + closeOnEscape = true, + size = 'md', + className = '', +}) => { + const titleId = useId() + const panelRef = useRef(null) + + const handleClose = useCallback(() => { + onClose?.() + }, [onClose]) + + useEffect(() => { + if (!isOpen || !closeOnEscape) + return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + handleClose() + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [isOpen, closeOnEscape, handleClose]) + + useEffect(() => { + if (!isOpen) + return + + const previous = document.activeElement as HTMLElement | null + panelRef.current?.focus() + + return () => { + previous?.focus?.() + } + }, [isOpen]) + + if (!isOpen) + return null + + return ( +
+
+
+ {(title !== undefined && title !== null && title !== '') || onClose ? ( +
+

+ {title} +

+ {onClose ? ( + + ) : null} +
+ ) : null} + +
+ {children} +
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ ) +} + +export { Modal } diff --git a/src/components/components/Modal/index.ts b/src/components/components/Modal/index.ts new file mode 100644 index 0000000..25b7061 --- /dev/null +++ b/src/components/components/Modal/index.ts @@ -0,0 +1,4 @@ +export { Modal } from './Modal' +export type { ModalProps, ModalSize } from './Modal' +export { ConfirmDialog } from './ConfirmDialog' +export type { ConfirmDialogProps } from './ConfirmDialog' diff --git a/src/components/components/Ratings/Ratings.tsx b/src/components/components/Ratings/Ratings.tsx new file mode 100644 index 0000000..de29002 --- /dev/null +++ b/src/components/components/Ratings/Ratings.tsx @@ -0,0 +1,119 @@ +import { type FC, useState } from 'react' +import { Star } from 'lucide-react' + +export type RatingsSize = 'sm' | 'md' | 'lg' + +export interface RatingsProps { + /** Current rating (supports fractions for partial fill, e.g. `3.5`). */ + value: number + max?: number + /** When set, stars are interactive. */ + onChange?: (value: number) => void + size?: RatingsSize + readOnly?: boolean + className?: string + /** Accessible name; defaults to `"Rating"`. */ + label?: string + emptyClassName?: string + filledClassName?: string + hoverClassName?: string +} + +const sizeClass: Record = { + sm: 'h-4 w-4', + md: 'h-6 w-6', + lg: 'h-8 w-8', +} + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)) + +const Ratings: FC = ({ + value, + max = 5, + onChange, + size = 'md', + readOnly, + className = '', + label = 'Rating', + emptyClassName = 'text-gray-300', + filledClassName = 'text-amber-400', + hoverClassName = 'text-amber-500', +}) => { + const interactive = Boolean(onChange) && !readOnly + const [hovered, setHovered] = useState(null) + const displayValue = hovered ?? value + const starSize = sizeClass[size] + + return ( +
{ + if (interactive) + setHovered(null) + }} + > + {Array.from({ length: max }, (_, index) => { + const starValue = index + 1 + const fillRatio = clamp(displayValue - index, 0, 1) + const isHoveredStar = hovered !== null && starValue <= hovered + + const icon = ( + <> + + {fillRatio > 0 ? ( + + + + ) : null} + + ) + + if (!interactive) { + return ( + + {icon} + + ) + } + + return ( + + ) + })} +
+ ) +} + +export { Ratings } diff --git a/src/components/components/Ratings/index.ts b/src/components/components/Ratings/index.ts new file mode 100644 index 0000000..c918a6b --- /dev/null +++ b/src/components/components/Ratings/index.ts @@ -0,0 +1,2 @@ +export { Ratings } from './Ratings' +export type { RatingsProps, RatingsSize } from './Ratings' diff --git a/src/components/components/editors/FileUploadComponent.tsx b/src/components/components/editors/FileUploadComponent.tsx index ba1963e..01cdba5 100644 --- a/src/components/components/editors/FileUploadComponent.tsx +++ b/src/components/components/editors/FileUploadComponent.tsx @@ -1,36 +1,42 @@ -import React, { useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { colSpanClass, type GridColSpan } from '../../functions' import { ButtonComponent } from './ButtonComponent' -import { Trash2 } from 'lucide-react' +import { Trash2, Upload } from 'lucide-react' interface FileUploadComponentProps { label?: string colspan?: GridColSpan multiple?: boolean + /** Comma-separated accept list for the file input (e.g. `.pdf,image/*`). */ + accept?: string files?: File[] onChange?: (files: File[]) => void disabled?: boolean + /** When true (default), the control accepts drag-and-drop. */ + droppable?: boolean } const FileUploadComponent: React.FC = ({ label = 'Select files', colspan = 6, multiple = true, + accept, files, onChange, disabled = false, + droppable = true, }) => { const [selectedFiles, setSelectedFiles] = useState([]) const [showPopup, setShowPopup] = useState(false) const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0}) + const [dragActive, setDragActive] = useState(false) const popupRef = useRef(null) const inputRef = useRef(null) + const dragDepthRef = useRef(0) - // Focus popup when it opens React.useEffect(() => { - if (showPopup && popupRef.current) { + if (showPopup && popupRef.current) popupRef.current.focus() - } }, [showPopup]) const areFilesEqual = (left: File[], right: File[]) => @@ -46,66 +52,127 @@ const FileUploadComponent: React.FC = ({ const displayFiles = files ?? selectedFiles - // Keep native input in sync for controlled resets. React.useEffect(() => { if (files !== undefined && files.length === 0 && inputRef.current) inputRef.current.value = '' }, [files]) + const applyFiles = useCallback((nextFiles: File[]) => { + if (files === undefined) + setSelectedFiles(nextFiles) + + if (!areFilesEqual(nextFiles, displayFiles)) + onChange?.(nextFiles) + }, [displayFiles, files, onChange]) + const handleFileChange = (e: React.ChangeEvent) => { const nextFiles = e.target.files ? Array.from(e.target.files) : [] - - if (files === undefined) { - setSelectedFiles(nextFiles) - } - - if (!areFilesEqual(nextFiles, displayFiles)) { - onChange?.(nextFiles) - } + applyFiles(nextFiles) } const handleClear = () => { - if (files === undefined) { + if (files === undefined) setSelectedFiles([]) - } - if (inputRef.current) inputRef.current.value = '' + if (inputRef.current) + inputRef.current.value = '' - if (displayFiles.length > 0) { + if (displayFiles.length > 0) onChange?.([]) - } } const handleSelectFiles = () => { - if (!disabled) inputRef.current?.click() + if (!disabled) + inputRef.current?.click() + } + + const resetDrag = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (e: React.DragEvent) => { + if (!droppable || disabled) + return + + e.preventDefault() + e.stopPropagation() + dragDepthRef.current += 1 + if (e.dataTransfer.types.includes('Files')) + setDragActive(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + if (!droppable || disabled) + return + + e.preventDefault() + e.stopPropagation() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) + setDragActive(false) + } + + const handleDragOver = (e: React.DragEvent) => { + if (!droppable || disabled) + return + + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + } + + const handleDrop = (e: React.DragEvent) => { + if (!droppable || disabled) + return + + e.preventDefault() + e.stopPropagation() + resetDrag() + + const dropped = Array.from(e.dataTransfer.files ?? []) + if (dropped.length === 0) + return + + applyFiles(multiple ? dropped : dropped.slice(0, 1)) } return ( -
- {/* File input (hidden) */} +
- {/* Files counter with hover popup */}
{ setShowPopup(true) - if (!showPopup) { + if (!showPopup) setPopupPos({ x: e.clientX, y: e.clientY }) - } }} onMouseLeave={e => { - // Only close if not moving into popup - if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) { + if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) setShowPopup(false) - } }} > = ({ outline: 'none', }} onBlur={e => { - // Only close if focus moves outside popup and counter - if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) { + if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) setShowPopup(false) - } }} onMouseLeave={e => { - // Only close if not moving back to counter const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1') - if (!parent) setShowPopup(false) + if (!parent) + setShowPopup(false) }} onKeyDown={e => { - if (e.key === 'Escape') setShowPopup(false) + if (e.key === 'Escape') + setShowPopup(false) }} - onFocus={() => {}} >
    {displayFiles.map((file, idx) => ( @@ -153,11 +218,8 @@ const FileUploadComponent: React.FC = ({
)} - -
- {/* Clear selection button */} = ({ - {/* Select files button */} - + > + + + {dragActive ? 'Drop files' : label} + +
) } export { FileUploadComponent } +export type { FileUploadComponentProps } diff --git a/src/components/components/editors/index.ts b/src/components/components/editors/index.ts index ede3356..7c26288 100644 --- a/src/components/components/editors/index.ts +++ b/src/components/components/editors/index.ts @@ -34,6 +34,7 @@ export { } export type { SecretDataSource, SecretComponentProps } from './SecretComponent' +export type { FileUploadComponentProps } from './FileUploadComponent' export type { RemoteSelectBoxProps, RemoteSelectSearchDataSource, diff --git a/src/components/components/social/FacebookMessengerButton.tsx b/src/components/components/social/FacebookMessengerButton.tsx new file mode 100644 index 0000000..82dea50 --- /dev/null +++ b/src/components/components/social/FacebookMessengerButton.tsx @@ -0,0 +1,46 @@ +import { type FC, type ReactNode } from 'react' + +export interface FacebookMessengerButtonProps { + /** Facebook Page username or page id for `https://m.me/{page}`. */ + page: string + title?: ReactNode + className?: string + floating?: boolean +} + +const MessengerIcon: FC<{ className?: string }> = ({ className }) => ( + + + +) + +const buildMessengerHref = (page: string): string => + `https://m.me/${encodeURIComponent(page.replace(/^@/, ''))}` + +const FacebookMessengerButton: FC = ({ + page, + title = 'Messenger', + className = '', + floating = true, +}) => ( + + + {title} + +) + +export { FacebookMessengerButton, buildMessengerHref } diff --git a/src/components/components/social/WhatsAppButton.tsx b/src/components/components/social/WhatsAppButton.tsx new file mode 100644 index 0000000..7ccd46b --- /dev/null +++ b/src/components/components/social/WhatsAppButton.tsx @@ -0,0 +1,58 @@ +import { type FC, type ReactNode } from 'react' + +export interface WhatsAppButtonProps { + /** International number without + / spaces / dashes (e.g. `15551234567`). Omit for text-only share picker. */ + number?: string + text: string + title?: ReactNode + className?: string + /** Floating corner button (default true). */ + floating?: boolean +} + +const WhatsAppIcon: FC<{ className?: string }> = ({ className }) => ( + + + +) + +const buildWhatsAppHref = (number: string | undefined, text: string): string => { + const encoded = encodeURIComponent(text) + if (!number) + return `https://wa.me/?text=${encoded}` + + return `https://wa.me/${encodeURIComponent(number)}?text=${encoded}` +} + +const WhatsAppButton: FC = ({ + number, + text, + title = 'WhatsApp', + className = '', + floating = true, +}) => { + const href = buildWhatsAppHref(number, text) + + return ( + + + {title} + + ) +} + +export { WhatsAppButton, buildWhatsAppHref } diff --git a/src/components/components/social/index.ts b/src/components/components/social/index.ts new file mode 100644 index 0000000..a609e04 --- /dev/null +++ b/src/components/components/social/index.ts @@ -0,0 +1,4 @@ +export { WhatsAppButton, buildWhatsAppHref } from './WhatsAppButton' +export type { WhatsAppButtonProps } from './WhatsAppButton' +export { FacebookMessengerButton, buildMessengerHref } from './FacebookMessengerButton' +export type { FacebookMessengerButtonProps } from './FacebookMessengerButton' diff --git a/src/components/index.ts b/src/components/index.ts index 997deda..8461f4c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,7 +4,38 @@ export { SecretComponent } from './components/editors/SecretComponent' export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent' export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout' export { Offcanvas } from './components/Offcanvas' +export { Modal, ConfirmDialog } from './components/Modal' +export type { ModalProps, ModalSize, ConfirmDialogProps } from './components/Modal' +export { + Loader, + Spinner, + showLoader, + hideLoader, + enableLoader, + disableLoader, +} from './components/Loader' +export type { LoaderProps, SpinnerProps, SpinnerSize } from './components/Loader' +export { Masonry } from './components/Masonry' +export type { MasonryProps } from './components/Masonry' +export { LightBox } from './components/LightBox' +export type { LightBoxProps, LightBoxSlide } from './components/LightBox' +export { CookieConsent, getCookie, setCookie } from './components/CookieConsent' +export type { CookieConsentProps, CookieConsentLink } from './components/CookieConsent' +export { + WhatsAppButton, + buildWhatsAppHref, + FacebookMessengerButton, + buildMessengerHref, +} from './components/social' +export type { + WhatsAppButtonProps, + FacebookMessengerButtonProps, +} from './components/social' +export { Ratings } from './components/Ratings' +export type { RatingsProps, RatingsSize } from './components/Ratings' export { LazyLoadTable } from './components/LazyLoadTable' +export { FileBrowser } from './components/FileBrowser' +export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './components/FileBrowser' export { DataTable, DataTableFilter, diff --git a/src/contracts/ResponseModelBase.ts b/src/contracts/ResponseModelBase.ts index c24ddc8..b8703b3 100644 --- a/src/contracts/ResponseModelBase.ts +++ b/src/contracts/ResponseModelBase.ts @@ -1,3 +1,3 @@ export interface ResponseModelBase { - [key: string]: unknown + [key: string]: unknown } diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts new file mode 100644 index 0000000..0e22f89 --- /dev/null +++ b/src/core/hooks/index.ts @@ -0,0 +1,14 @@ +export { useFormState } from './useFormState' +export type { UseFormStateProps } from './useFormState' +export { usePrevious } from './usePrevious' +export { useInterval } from './useInterval' +export { useOnScreen } from './useOnScreen' +export type { UseOnScreenOptions } from './useOnScreen' +export { useLocalStorage } from './useLocalStorage' +export { useSessionStorage } from './useSessionStorage' +export { useLongPress } from './useLongPress' +export type { UseLongPressOptions, UseLongPressHandlers } from './useLongPress' +export { useDebounce } from './useDebounce' +export { useOnClickOutside } from './useOnClickOutside' +export { useMedia } from './useMedia' +export { useHover } from './useHover' diff --git a/src/core/hooks/useDebounce.ts b/src/core/hooks/useDebounce.ts new file mode 100644 index 0000000..ae36960 --- /dev/null +++ b/src/core/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' + +/** + * Debounces `value` by `delayMs`. Useful for search inputs and remote filters. + */ +const useDebounce = (value: T, delayMs: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const id = window.setTimeout(() => { + setDebouncedValue(value) + }, delayMs) + + return () => window.clearTimeout(id) + }, [value, delayMs]) + + return debouncedValue +} + +export { useDebounce } diff --git a/src/core/hooks/useHover.ts b/src/core/hooks/useHover.ts new file mode 100644 index 0000000..0601138 --- /dev/null +++ b/src/core/hooks/useHover.ts @@ -0,0 +1,44 @@ +import { + type RefCallback, + useCallback, + useRef, + useState, +} from 'react' + +/** + * Hover state via callback ref (safe when the bound element changes). + * Returns `[callbackRef, isHovered]`. + */ +const useHover = (): [ + RefCallback, + boolean, +] => { + const [isHovered, setIsHovered] = useState(false) + const nodeRef = useRef(null) + + const handleMouseEnter = useCallback(() => { + setIsHovered(true) + }, []) + + const handleMouseLeave = useCallback(() => { + setIsHovered(false) + }, []) + + const callbackRef = useCallback((node: T | null) => { + if (nodeRef.current) { + nodeRef.current.removeEventListener('mouseenter', handleMouseEnter) + nodeRef.current.removeEventListener('mouseleave', handleMouseLeave) + } + + nodeRef.current = node + + if (node) { + node.addEventListener('mouseenter', handleMouseEnter) + node.addEventListener('mouseleave', handleMouseLeave) + } + }, [handleMouseEnter, handleMouseLeave]) + + return [callbackRef, isHovered] +} + +export { useHover } diff --git a/src/core/hooks/useInterval.ts b/src/core/hooks/useInterval.ts new file mode 100644 index 0000000..ae3f522 --- /dev/null +++ b/src/core/hooks/useInterval.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react' + +/** + * Declarative `setInterval`. Pass `delay: null` to pause. + */ +const useInterval = (callback: () => void, delay: number | null): void => { + const savedCallback = useRef(callback) + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + useEffect(() => { + if (delay === null) + return + + const id = window.setInterval(() => { + savedCallback.current() + }, delay) + + return () => window.clearInterval(id) + }, [delay]) +} + +export { useInterval } diff --git a/src/core/hooks/useLocalStorage.ts b/src/core/hooks/useLocalStorage.ts new file mode 100644 index 0000000..d946c82 --- /dev/null +++ b/src/core/hooks/useLocalStorage.ts @@ -0,0 +1,89 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +const readStorage = (key: string, fallback: T): T => { + if (typeof window === 'undefined') + return fallback + + try { + const item = window.localStorage.getItem(key) + if (item === null) + return fallback + + return JSON.parse(item) as T + } + catch { + return fallback + } +} + +const writeStorage = (key: string, value: T): void => { + if (typeof window === 'undefined') + return + + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } + catch { + // Ignore quota / private mode. + } +} + +/** + * `useState` synced to `localStorage`. Listens for cross-tab `storage` events. + */ +const useLocalStorage = ( + key: string, + initialValue: T, +): [T, Dispatch>] => { + const initialRef = useRef(initialValue) + initialRef.current = initialValue + + const [storedValue, setStoredValue] = useState(() => + readStorage(key, initialValue), + ) + + const setValue: Dispatch> = useCallback((value) => { + setStoredValue((prev) => { + const next = value instanceof Function ? value(prev) : value + writeStorage(key, next) + return next + }) + }, [key]) + + useEffect(() => { + setStoredValue(readStorage(key, initialRef.current)) + }, [key]) + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.storageArea !== window.localStorage || event.key !== key) + return + + if (event.newValue === null) { + setStoredValue(initialRef.current) + return + } + + try { + setStoredValue(JSON.parse(event.newValue) as T) + } + catch { + setStoredValue(initialRef.current) + } + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, [key]) + + return [storedValue, setValue] +} + +export { useLocalStorage } diff --git a/src/core/hooks/useLongPress.ts b/src/core/hooks/useLongPress.ts new file mode 100644 index 0000000..c718c13 --- /dev/null +++ b/src/core/hooks/useLongPress.ts @@ -0,0 +1,108 @@ +import { + type MouseEvent, + type TouchEvent, + useCallback, + useEffect, + useRef, +} from 'react' + +export interface UseLongPressOptions { + onLongPress: (event: MouseEvent | TouchEvent) => void + onClick?: (event: MouseEvent | TouchEvent) => void + delayMs?: number + shouldPreventDefault?: boolean +} + +export interface UseLongPressHandlers { + onMouseDown: (event: MouseEvent) => void + onTouchStart: (event: TouchEvent) => void + onMouseUp: (event: MouseEvent) => void + onMouseLeave: (event: MouseEvent) => void + onTouchEnd: (event: TouchEvent) => void +} + +const isTouchEvent = (event: Event): event is globalThis.TouchEvent => + 'touches' in event + +const preventTouchDefault = (event: Event) => { + if (!isTouchEvent(event)) + return + + if (event.touches.length < 2 && event.cancelable) + event.preventDefault() +} + +/** + * Returns pointer handlers that fire `onLongPress` after `delayMs`, + * or `onClick` on a short press. + */ +const useLongPress = ({ + onLongPress, + onClick, + delayMs = 300, + shouldPreventDefault = true, +}: UseLongPressOptions): UseLongPressHandlers => { + const timeoutRef = useRef(undefined) + const targetRef = useRef(null) + const longPressTriggeredRef = useRef(false) + + const onLongPressRef = useRef(onLongPress) + const onClickRef = useRef(onClick) + + useEffect(() => { + onLongPressRef.current = onLongPress + }, [onLongPress]) + + useEffect(() => { + onClickRef.current = onClick + }, [onClick]) + + useEffect(() => () => { + if (timeoutRef.current !== undefined) + window.clearTimeout(timeoutRef.current) + + if (targetRef.current) + targetRef.current.removeEventListener('touchend', preventTouchDefault) + }, []) + + const start = useCallback((event: MouseEvent | TouchEvent) => { + longPressTriggeredRef.current = false + + if (shouldPreventDefault && event.target) { + event.target.addEventListener('touchend', preventTouchDefault, { passive: false }) + targetRef.current = event.target + } + + timeoutRef.current = window.setTimeout(() => { + onLongPressRef.current(event) + longPressTriggeredRef.current = true + }, delayMs) + }, [delayMs, shouldPreventDefault]) + + const clear = useCallback((event: MouseEvent | TouchEvent, shouldTriggerClick = true) => { + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + } + + if (shouldTriggerClick && !longPressTriggeredRef.current) + onClickRef.current?.(event) + + longPressTriggeredRef.current = false + + if (shouldPreventDefault && targetRef.current) { + targetRef.current.removeEventListener('touchend', preventTouchDefault) + targetRef.current = null + } + }, [shouldPreventDefault]) + + return { + onMouseDown: (event) => start(event), + onTouchStart: (event) => start(event), + onMouseUp: (event) => clear(event), + onMouseLeave: (event) => clear(event, false), + onTouchEnd: (event) => clear(event), + } +} + +export { useLongPress } diff --git a/src/core/hooks/useMedia.ts b/src/core/hooks/useMedia.ts new file mode 100644 index 0000000..8c5563b --- /dev/null +++ b/src/core/hooks/useMedia.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Returns the value for the first matching media query. + * `queries` and `values` are paired by index; `defaultValue` is used when none match. + */ +const useMedia = ( + queries: string[], + values: T[], + defaultValue: T, +): T => { + const valuesRef = useRef(values) + valuesRef.current = values + + const defaultRef = useRef(defaultValue) + defaultRef.current = defaultValue + + const queryKey = queries.join('|') + + const resolve = () => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') + return defaultRef.current + + const list = queryKey === '' ? [] : queryKey.split('|') + const index = list.findIndex((query) => window.matchMedia(query).matches) + if (index === -1) + return defaultRef.current + + return valuesRef.current[index] ?? defaultRef.current + } + + const [value, setValue] = useState(resolve) + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') + return + + const list = queryKey === '' ? [] : queryKey.split('|') + const mediaQueryLists = list.map((query) => window.matchMedia(query)) + + const onChange = () => { + setValue(resolve()) + } + + onChange() + + for (const mql of mediaQueryLists) { + if (typeof mql.addEventListener === 'function') + mql.addEventListener('change', onChange) + else + mql.addListener(onChange) + } + + return () => { + for (const mql of mediaQueryLists) { + if (typeof mql.removeEventListener === 'function') + mql.removeEventListener('change', onChange) + else + mql.removeListener(onChange) + } + } + }, [queryKey]) + + return value +} + +export { useMedia } diff --git a/src/core/hooks/useOnClickOutside.ts b/src/core/hooks/useOnClickOutside.ts new file mode 100644 index 0000000..150b50e --- /dev/null +++ b/src/core/hooks/useOnClickOutside.ts @@ -0,0 +1,39 @@ +import { + type RefObject, + useEffect, + useRef, +} from 'react' + +/** + * Invokes `handler` when a pointer/touch event occurs outside `ref`. + */ +const useOnClickOutside = ( + ref: RefObject, + handler: (event: MouseEvent | TouchEvent) => void, +): void => { + const handlerRef = useRef(handler) + + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + const element = ref.current + if (!element || element.contains(event.target as Node)) + return + + handlerRef.current(event) + } + + document.addEventListener('mousedown', listener) + document.addEventListener('touchstart', listener) + + return () => { + document.removeEventListener('mousedown', listener) + document.removeEventListener('touchstart', listener) + } + }, [ref]) +} + +export { useOnClickOutside } diff --git a/src/core/hooks/useOnScreen.ts b/src/core/hooks/useOnScreen.ts new file mode 100644 index 0000000..20db0d4 --- /dev/null +++ b/src/core/hooks/useOnScreen.ts @@ -0,0 +1,58 @@ +import { + type RefObject, + useEffect, + useState, +} from 'react' + +export interface UseOnScreenOptions extends IntersectionObserverInit { + /** When true, stay `true` after the first intersection and disconnect. */ + once?: boolean +} + +/** + * Tracks whether `ref`'s element intersects the viewport (or `root`). + */ +const useOnScreen = ( + ref: RefObject, + options: UseOnScreenOptions = {}, +): boolean => { + const { + once = false, + root = null, + rootMargin = '0px', + threshold = 0, + } = options + + const [isIntersecting, setIsIntersecting] = useState(false) + + useEffect(() => { + const element = ref.current + if (!element || typeof IntersectionObserver === 'undefined') + return + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry) + return + + setIsIntersecting(entry.isIntersecting) + + if (once && entry.isIntersecting) + observer.disconnect() + }, + { + root, + rootMargin, + threshold, + }, + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [ref, once, root, rootMargin, threshold]) + + return isIntersecting +} + +export { useOnScreen } diff --git a/src/core/hooks/usePrevious.ts b/src/core/hooks/usePrevious.ts new file mode 100644 index 0000000..7c55c8a --- /dev/null +++ b/src/core/hooks/usePrevious.ts @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react' + +/** + * Returns the value from the previous render (undefined on first render). + */ +const usePrevious = (value: T): T | undefined => { + const ref = useRef(undefined) + + useEffect(() => { + ref.current = value + }, [value]) + + return ref.current +} + +export { usePrevious } diff --git a/src/core/hooks/useSessionStorage.ts b/src/core/hooks/useSessionStorage.ts new file mode 100644 index 0000000..3a44e97 --- /dev/null +++ b/src/core/hooks/useSessionStorage.ts @@ -0,0 +1,90 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +const readStorage = (key: string, fallback: T): T => { + if (typeof window === 'undefined') + return fallback + + try { + const item = window.sessionStorage.getItem(key) + if (item === null) + return fallback + + return JSON.parse(item) as T + } + catch { + return fallback + } +} + +const writeStorage = (key: string, value: T): void => { + if (typeof window === 'undefined') + return + + try { + window.sessionStorage.setItem(key, JSON.stringify(value)) + } + catch { + // Ignore quota / private mode. + } +} + +/** + * `useState` synced to `sessionStorage`. Listens for same-origin `storage` events + * (e.g. iframes sharing the tab session). + */ +const useSessionStorage = ( + key: string, + initialValue: T, +): [T, Dispatch>] => { + const initialRef = useRef(initialValue) + initialRef.current = initialValue + + const [storedValue, setStoredValue] = useState(() => + readStorage(key, initialValue), + ) + + const setValue: Dispatch> = useCallback((value) => { + setStoredValue((prev) => { + const next = value instanceof Function ? value(prev) : value + writeStorage(key, next) + return next + }) + }, [key]) + + useEffect(() => { + setStoredValue(readStorage(key, initialRef.current)) + }, [key]) + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.storageArea !== window.sessionStorage || event.key !== key) + return + + if (event.newValue === null) { + setStoredValue(initialRef.current) + return + } + + try { + setStoredValue(JSON.parse(event.newValue) as T) + } + catch { + setStoredValue(initialRef.current) + } + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, [key]) + + return [storedValue, setValue] +} + +export { useSessionStorage } diff --git a/src/core/index.ts b/src/core/index.ts index adaddda..77e6122 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,5 +3,23 @@ export * from './types' export * from './http' export * from './signalr' export { readIdentity, writeIdentity, removeIdentity } from './localStorage/identity' -export { useFormState } from './hooks/useFormState' +export { + useFormState, + usePrevious, + useInterval, + useOnScreen, + useLocalStorage, + useSessionStorage, + useLongPress, + useDebounce, + useOnClickOutside, + useMedia, + useHover, +} from './hooks' +export type { + UseFormStateProps, + UseOnScreenOptions, + UseLongPressOptions, + UseLongPressHandlers, +} from './hooks' export * from './oauth' diff --git a/src/jest.config.cjs b/src/jest.config.cjs index 5f4824e..0002518 100644 --- a/src/jest.config.cjs +++ b/src/jest.config.cjs @@ -1,15 +1,6 @@ -/** @type {import('jest').Config} */ -module.exports = { +const shared = { preset: 'ts-jest', - testEnvironment: 'node', roots: ['/test'], - testMatch: ['**/*.test.ts'], - collectCoverageFrom: [ - 'contracts/**/*.ts', - 'core/**/*.ts', - ], - coverageDirectory: 'coverage', - coverageReporters: ['json-summary', 'text'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '^@webui/contracts$': '/contracts/index.ts', @@ -28,3 +19,42 @@ module.exports = { ], }, } + +/** @type {import('jest').Config} */ +module.exports = { + collectCoverageFrom: [ + 'contracts/**/*.ts', + 'core/**/*.{ts,tsx}', + 'components/components/FileBrowser/format.ts', + 'components/components/FileBrowser/prefs.ts', + 'components/components/CookieConsent/cookies.ts', + 'components/components/Loader/loaderEvents.ts', + 'components/components/social/WhatsAppButton.tsx', + 'components/components/social/FacebookMessengerButton.tsx', + ], + coverageDirectory: 'coverage', + coverageReporters: ['json-summary', 'text'], + projects: [ + { + ...shared, + displayName: 'node', + testEnvironment: 'node', + testMatch: [ + '/test/contracts/**/*.test.ts', + '/test/core/functions/**/*.test.ts', + '/test/core/http/**/*.test.ts', + '/test/core/signalr/**/*.test.ts', + ], + }, + { + ...shared, + displayName: 'jsdom', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/test/setup.ts'], + testMatch: [ + '/test/core/hooks/**/*.test.ts', + '/test/components/**/*.test.ts', + ], + }, + ], +} diff --git a/src/package-lock.json b/src/package-lock.json index a5f2664..e1681aa 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,50 +1,53 @@ { "name": "@maks-it.com/webui", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@maks-it.com/webui", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { - "date-fns": "^4.3.0" + "date-fns": "^4.4.0" }, "devDependencies": { "@microsoft/signalr": "^8.0.7", - "@storybook/addon-a11y": "^10.4.1", - "@storybook/addon-docs": "^10.4.1", - "@storybook/addon-vitest": "^10.4.1", - "@storybook/react-vite": "^10.4.1", - "@tailwindcss/vite": "^4.3.0", + "@storybook/addon-a11y": "^10.5.4", + "@storybook/addon-docs": "^10.5.4", + "@storybook/addon-vitest": "^10.5.4", + "@storybook/react-vite": "^10.5.4", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-table": "^8.21.3", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/mockdate": "^2.0.0", "@types/node": "^22.19.19", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-virtualized": "^9.22.3", - "@vitest/browser-playwright": "^4.1.7", - "@vitest/coverage-v8": "^4.1.7", - "axios": "^1.16.1", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", + "axios": "^1.18.1", "jest": "^30.4.2", - "lucide-react": "^1.16.0", + "jest-environment-jsdom": "^30.4.1", + "lucide-react": "^1.26.0", "mockdate": "^3.0.5", - "msw": "^2.14.6", + "msw": "^2.15.0", "msw-storybook-addon": "^2.0.7", - "playwright": "^1.60.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1", + "playwright": "^1.61.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.1", "react-virtualized": "^9.22.6", - "storybook": "^10.4.1", - "tailwindcss": "^4.3.0", - "ts-jest": "^29.4.11", + "storybook": "^10.5.4", + "tailwindcss": "^4.3.3", + "ts-jest": "^29.4.12", "tsup": "^8.5.1", "typescript": "^6.0.3", - "vite": "^6.4.2", - "vitest": "^4.1.7", + "vite": "^6.4.3", + "vitest": "^4.1.10", "zod": "^4.4.3" }, "engines": { @@ -69,6 +72,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -593,6 +617,146 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1265,6 +1429,34 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/@jest/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", @@ -2811,9 +3003,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.4.1.tgz", - "integrity": "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.4.tgz", + "integrity": "sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2825,20 +3017,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.4.1" + "storybook": "^10.5.4" } }, "node_modules/@storybook/addon-docs": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.1.tgz", - "integrity": "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz", + "integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.4.1", + "@storybook/csf-plugin": "10.5.4", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.4.1", + "@storybook/react-dom-shim": "10.5.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -2849,7 +3041,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.1" + "storybook": "^10.5.4" }, "peerDependenciesMeta": { "@types/react": { @@ -2858,9 +3050,9 @@ } }, "node_modules/@storybook/addon-vitest": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.4.1.tgz", - "integrity": "sha512-ymrX9EOou1x3d21iDhjP3j3XfhOAiflhlPZWKcipULBoJCq/aZPbV68EghzovkJNuGRl9ezMYxbbKxwrMmCmGg==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.4.tgz", + "integrity": "sha512-5lr81sgrbK3Tjjbstieg/yYj3jdtQYP70ihpOiN1TwogvFATY2/9eMT46FE5ioKtEFcfJRDcgJ2wSlmJKE+kZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2875,7 +3067,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.4.1", + "storybook": "^10.5.4", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -2894,13 +3086,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.4.1.tgz", - "integrity": "sha512-/oyQrXoNOqN8SW5hNnYP+I1uvgFxKxWXj/EP6NXYzc5SQwImofgru+D2+6gDhL0+Q//+Hx05DJoQO2omvUJ8bQ==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.4.tgz", + "integrity": "sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.4.1", + "@storybook/csf-plugin": "10.5.4", "ts-dedent": "^2.0.0" }, "funding": { @@ -2908,14 +3100,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.4.1", + "storybook": "^10.5.4", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.1.tgz", - "integrity": "sha512-WdPepGBxDGOUDjYd8KxMtcf+us/2PAcnBczl77XtrnxxHNs0jWesxKkiJ9yiuGrge4BPhDeAj6rxjbBoaHxLBA==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz", + "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==", "dev": true, "license": "MIT", "dependencies": { @@ -2928,7 +3120,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.4.1", + "storybook": "^10.5.4", "vite": "*", "webpack": "*" }, @@ -2966,14 +3158,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.1.tgz", - "integrity": "sha512-WuYz4NaUk4gmFAMliSpCbV8w6jP5OY9juBfw1huwzu2S/k5FhnVXwmrUaL0fmf3Bq/7NgkzmBBbZr6I6LuHayQ==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.4.tgz", + "integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.4.1", + "@storybook/react-dom-shim": "10.5.4", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -2986,7 +3178,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.1", + "storybook": "^10.5.4", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -3002,9 +3194,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.1.tgz", - "integrity": "sha512-6QFqfDNH4DMrt7yHKRfpqRopsVUc/Az+sXIdJ39IetYnHUxL3nW4NVaPc6uy/8Qi8urzUyEXL/nn7cpSIP2aPQ==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz", + "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==", "dev": true, "license": "MIT", "funding": { @@ -3016,7 +3208,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.1" + "storybook": "^10.5.4" }, "peerDependenciesMeta": { "@types/react": { @@ -3028,19 +3220,19 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.4.1.tgz", - "integrity": "sha512-zY6OzaXvXqBIUyc5ySE55/LAPQiF+o9ZyhQI978WMu4mY/fL7FpQ+ZVHRUCCgz/wTXtqE9jJwd/N10HI1kD0/Q==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.4.tgz", + "integrity": "sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.4.1", - "@storybook/react": "10.4.1", + "@storybook/builder-vite": "10.5.4", + "@storybook/react": "10.5.4", "empathic": "^2.0.0", "magic-string": "^0.30.0", - "react-docgen": "^8.0.0", + "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, @@ -3051,54 +3243,60 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.1", + "storybook": "^10.5.4", + "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -3113,9 +3311,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -3130,9 +3328,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -3147,9 +3345,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -3164,9 +3362,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -3181,9 +3379,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -3198,9 +3396,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -3215,9 +3413,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -3232,9 +3430,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -3249,9 +3447,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3267,66 +3465,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -3341,9 +3494,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -3358,15 +3511,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -3413,6 +3566,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3456,9 +3610,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "dev": true, "license": "MIT", "dependencies": { @@ -3470,9 +3624,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -3482,6 +3639,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -3629,6 +3814,18 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -3661,9 +3858,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "peer": true, @@ -3724,6 +3921,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4085,16 +4289,16 @@ ] }, "node_modules/@vitest/browser": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.7.tgz", - "integrity": "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -4105,19 +4309,19 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.7" + "vitest": "4.1.10" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.7.tgz", - "integrity": "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/browser": "4.1.7", - "@vitest/mocker": "4.1.7", + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", "tinyrainbow": "^3.1.0" }, "funding": { @@ -4125,7 +4329,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.7" + "vitest": "4.1.10" }, "peerDependenciesMeta": { "playwright": { @@ -4134,15 +4338,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4156,8 +4360,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4234,13 +4438,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4271,9 +4475,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4284,14 +4488,14 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -4299,14 +4503,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4315,9 +4519,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -4325,13 +4529,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4505,9 +4709,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", - "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { @@ -4544,9 +4748,9 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5102,6 +5306,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5109,6 +5327,57 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -5137,6 +5406,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -5339,9 +5615,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5352,6 +5628,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5994,6 +6283,19 @@ "set-cookie-parser": "^3.0.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6001,6 +6303,30 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -6025,6 +6351,19 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -6150,6 +6489,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6475,6 +6821,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", @@ -6905,6 +7274,141 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6938,6 +7442,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7297,9 +7808,9 @@ } }, "node_modules/lucide-react": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", - "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", + "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", "dev": true, "license": "ISC", "peerDependencies": { @@ -7518,9 +8029,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.14.6", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", - "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7724,6 +8235,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7735,15 +8253,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/onetime": { "version": "5.1.2", @@ -7930,6 +8451,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8064,13 +8598,13 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -8083,9 +8617,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8299,9 +8833,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", "peer": true, @@ -8342,9 +8876,9 @@ } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", "peer": true, @@ -8352,7 +8886,7 @@ "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" } }, "node_modules/react-is": { @@ -8386,9 +8920,9 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", - "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "dev": true, "license": "MIT", "dependencies": { @@ -8409,13 +8943,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", - "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.16.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -8631,6 +9165,13 @@ "dev": true, "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -8644,6 +9185,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8799,28 +9360,30 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, "node_modules/storybook": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.1.tgz", - "integrity": "sha512-V1Zd2e+gBFufqAQVZ1JR8KLqALsEZ3JYSBnWwQbKa6zCfWWanR6AFMyuOkLt2gZOgGp3h2Riuz88pGNVTQSG0A==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.4.tgz", + "integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", + "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", @@ -8839,7 +9402,7 @@ "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", - "vite-plus": "^0.1.15" + "vite-plus": "^0.1.15 || ^0.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -8853,6 +9416,26 @@ } } }, + "node_modules/storybook/node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/storybook/node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -8866,6 +9449,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/storybook/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/storybook/node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -9023,6 +9613,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -9053,9 +9650,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -9254,9 +9851,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -9266,7 +9863,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -9307,9 +9904,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9635,9 +10232,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "peer": true, @@ -10195,20 +10792,20 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10236,12 +10833,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10286,16 +10883,16 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -10323,6 +10920,19 @@ "node": ">=18" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -10347,6 +10957,30 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -10481,6 +11115,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/src/package.json b/src/package.json index c2b4a4c..26dd70a 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui", - "version": "0.4.0", + "version": "0.4.1", "description": "Shared contracts, utilities, and React components for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", @@ -48,7 +48,7 @@ "directory": "src" }, "dependencies": { - "date-fns": "^4.3.0" + "date-fns": "^4.4.0" }, "peerDependencies": { "@microsoft/signalr": "^8.0.0", @@ -63,38 +63,41 @@ }, "devDependencies": { "@microsoft/signalr": "^8.0.7", - "@storybook/addon-a11y": "^10.4.1", - "@storybook/addon-docs": "^10.4.1", - "@storybook/addon-vitest": "^10.4.1", - "@storybook/react-vite": "^10.4.1", - "@tailwindcss/vite": "^4.3.0", + "@storybook/addon-a11y": "^10.5.4", + "@storybook/addon-docs": "^10.5.4", + "@storybook/addon-vitest": "^10.5.4", + "@storybook/react-vite": "^10.5.4", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-table": "^8.21.3", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/mockdate": "^2.0.0", "@types/node": "^22.19.19", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-virtualized": "^9.22.3", - "@vitest/browser-playwright": "^4.1.7", - "@vitest/coverage-v8": "^4.1.7", - "axios": "^1.16.1", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", + "axios": "^1.18.1", "jest": "^30.4.2", - "lucide-react": "^1.16.0", + "jest-environment-jsdom": "^30.4.1", + "lucide-react": "^1.26.0", "mockdate": "^3.0.5", - "msw": "^2.14.6", + "msw": "^2.15.0", "msw-storybook-addon": "^2.0.7", - "playwright": "^1.60.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1", + "playwright": "^1.61.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.1", "react-virtualized": "^9.22.6", - "storybook": "^10.4.1", - "tailwindcss": "^4.3.0", - "ts-jest": "^29.4.11", + "storybook": "^10.5.4", + "tailwindcss": "^4.3.3", + "ts-jest": "^29.4.12", "tsup": "^8.5.1", "typescript": "^6.0.3", - "vite": "^6.4.2", - "vitest": "^4.1.7", + "vite": "^6.4.3", + "vitest": "^4.1.10", "zod": "^4.4.3" }, "author": "MaksIT", diff --git a/src/public/mockServiceWorker.js b/src/public/mockServiceWorker.js index 33dde9e..0c970ef 100644 --- a/src/public/mockServiceWorker.js +++ b/src/public/mockServiceWorker.js @@ -7,8 +7,8 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.14.6' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) { if (client && activeClientIds.has(client.id)) { const serializedRequest = await serializeRequest(requestCloneForEvents) + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() + const responseClone = isEventStreamResponse ? null : response.clone() sendToClient( client, @@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) { ...serializedRequest, }, response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, }, }, }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], ) } diff --git a/src/stories/README.md b/src/stories/README.md index 4670555..11929d4 100644 --- a/src/stories/README.md +++ b/src/stories/README.md @@ -24,7 +24,7 @@ components/components/ stories/components/ └── LazyLoadTable.tsx └── LazyLoadTable.stories.tsx ``` -Use the **same folder names** as in the package (`Toast`, not `feedback`; `list`, not `List`). Storybook sidebar titles use the same path: `components/editors/Button`, `components/Toast`, etc. +Use the **same folder names** as in the package (`Toast`, not `feedback`; `list`, not `List`). Storybook sidebar titles use the same path: `components/editors/Button`, `components/Toast`, etc. Core hook demos live under `stories/core/` (`core/hooks`). ## Imports diff --git a/src/stories/components/CookieConsent.stories.tsx b/src/stories/components/CookieConsent.stories.tsx new file mode 100644 index 0000000..5711e98 --- /dev/null +++ b/src/stories/components/CookieConsent.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' +import { CookieConsent } from '@webui/components/components/CookieConsent' + +const COOKIE_NAME = 'StorybookCookieConsent' + +const meta = { + title: 'components/CookieConsent', + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Bottom consent bar. Inject `linkComponent` (e.g. react-router `Link`) for policy links. Clear the consent cookie to re-show.', + }, + }, + }, + loaders: [ + async () => { + document.cookie = `${COOKIE_NAME}=; max-age=0; path=/` + return {} + }, + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( +
+

Page content

+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body) + const dialog = await canvas.findByRole('dialog', { name: /cookie consent/i }) + await expect(dialog).toBeVisible() + await userEvent.click(canvas.getByRole('button', { name: /accept/i })) + await expect(canvas.queryByRole('dialog', { name: /cookie consent/i })).not.toBeInTheDocument() + }, +} diff --git a/src/stories/components/FileBrowser.stories.tsx b/src/stories/components/FileBrowser.stories.tsx new file mode 100644 index 0000000..a6e0faa --- /dev/null +++ b/src/stories/components/FileBrowser.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, within } from 'storybook/test' +import { FileBrowser } from '@webui/components/components/FileBrowser' +import type { FileBrowserItem, FileBrowserRoot } from '@webui/components/components/FileBrowser' + +const roots: FileBrowserRoot[] = [ + { id: 'docs', label: 'docs', iconClassName: 'text-sky-600' }, +] + +const items: FileBrowserItem[] = [ + { + key: 'docs/reports/', + name: 'reports', + isFolder: true, + }, + { + key: 'docs/readme.md', + name: 'readme.md', + isFolder: false, + size: 1280, + contentType: 'text/markdown', + lastModified: '2026-07-20T10:00:00Z', + }, +] + +const meta = { + component: FileBrowser, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Generic storage browser. Optional `onCreateFolder`, `onUpload`, and `onDownload` show matching toolbar actions; hosts own storage keys and HTTP.', + }, + }, + }, + args: { + roots, + activeRootId: 'docs', + pathSegments: [], + items, + rootsTitle: 'Buckets', + description: 'Generic storage browser — folders, files, preview, and cleanup actions.', + onNavigateRoot: fn(), + onNavigatePath: fn(), + onRefresh: fn(), + onDelete: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText('reports/')).toBeVisible() + await expect(canvas.getByText('readme.md')).toBeVisible() + await expect(canvas.getByText('Buckets')).toBeVisible() + await expect(canvas.queryByRole('button', { name: /new folder/i })).not.toBeInTheDocument() + await expect(canvas.queryByRole('button', { name: /^upload$/i })).not.toBeInTheDocument() + }, +} + +export const WithActions: Story = { + args: { + onCreateFolder: fn(), + onUpload: fn(), + onDownload: fn(), + onFetchTextPreview: fn(async () => '# Hello\n'), + }, + play: async ({ canvas, args, canvasElement }) => { + await expect(canvas.getByRole('button', { name: /new folder/i })).toBeVisible() + await expect(canvas.getByRole('button', { name: /^upload$/i })).toBeVisible() + + await userEvent.click(canvas.getByRole('button', { name: /new folder/i })) + const body = within(canvasElement.ownerDocument.body) + const dialog = await body.findByRole('dialog', { name: /new folder/i }) + await expect(dialog).toBeVisible() + const nameInput = within(dialog).getByRole('textbox', { name: /folder name/i }) + await userEvent.clear(nameInput) + await userEvent.type(nameInput, 'archives') + await userEvent.click(within(dialog).getByRole('button', { name: /^create$/i })) + await expect(args.onCreateFolder).toHaveBeenCalledWith('archives') + + await userEvent.click(canvas.getByText('readme.md')) + const download = canvas.getByRole('button', { name: /^download$/i }) + await expect(download).toBeVisible() + await userEvent.click(download) + await expect(args.onDownload).toHaveBeenCalled() + }, +} + +export const Loading: Story = { + args: { + loading: true, + items: [], + }, + play: async ({ canvas }) => { + await expect(canvas.getByText(/loading/i)).toBeVisible() + }, +} + +export const Empty: Story = { + args: { + items: [], + emptyHint: 'This bucket is empty.', + }, + play: async ({ canvas }) => { + await expect(canvas.getByText('This bucket is empty.')).toBeVisible() + }, +} diff --git a/src/stories/components/LightBox.stories.tsx b/src/stories/components/LightBox.stories.tsx new file mode 100644 index 0000000..2d90b58 --- /dev/null +++ b/src/stories/components/LightBox.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' +import { LightBox } from '@webui/components/components/LightBox' + +const slides = [ + { src: 'https://picsum.photos/id/1015/800/600', alt: 'River valley' }, + { src: 'https://picsum.photos/id/1018/800/900', alt: 'Mountain road' }, + { src: 'https://picsum.photos/id/1025/800/700', alt: 'Dog' }, + { src: 'https://picsum.photos/id/1035/800/500', alt: 'Waterfall' }, + { src: 'https://picsum.photos/id/1043/800/800', alt: 'City lights' }, +] + +const meta = { + title: 'components/LightBox', + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Masonry thumbnail gallery with fullscreen viewer (keyboard: Escape, arrows).', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Gallery: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const body = within(canvasElement.ownerDocument.body) + await userEvent.click(canvas.getByRole('button', { name: /river valley/i })) + await expect(body.getByRole('dialog', { name: /river valley/i })).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect(body.queryByRole('dialog')).not.toBeInTheDocument() + }, +} diff --git a/src/stories/components/Loader.stories.tsx b/src/stories/components/Loader.stories.tsx new file mode 100644 index 0000000..59dedf5 --- /dev/null +++ b/src/stories/components/Loader.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' +import { + Loader, + Spinner, + showLoader, + hideLoader, + enableLoader, + disableLoader, +} from '@webui/components/components/Loader' + +const meta = { + title: 'components/Loader', + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Full-screen overlay loader. Use controlled `visible`, or mount once and drive via `showLoader` / `hideLoader` (ref-counted, matching host HTTP interceptors).', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Visible: Story = { + render: () => ( +
+

Page content behind the overlay.

+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Loading…')).toBeVisible() + }, +} + +export const InlineSpinner: Story = { + render: () => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Working…')).toBeVisible() + }, +} + +export const EventDriven: Story = { + render: () => ( +
+
+ + + + +
+

Ref-counted overlay — call show twice, then hide twice.

+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: /showloader/i })) + await expect(canvas.getByText('Please wait…')).toBeVisible() + await userEvent.click(canvas.getByRole('button', { name: /hideloader/i })) + await expect(canvas.queryByText('Please wait…')).not.toBeInTheDocument() + }, +} diff --git a/src/stories/components/Masonry.stories.tsx b/src/stories/components/Masonry.stories.tsx new file mode 100644 index 0000000..409361d --- /dev/null +++ b/src/stories/components/Masonry.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' +import { Masonry } from '@webui/components/components/Masonry' + +const colors = ['bg-sky-200 h-24', 'bg-emerald-200 h-40', 'bg-amber-200 h-28', 'bg-rose-200 h-36', 'bg-violet-200 h-20', 'bg-cyan-200 h-44'] + +const meta = { + title: 'components/Masonry', + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Basic: Story = { + render: () => ( + + {colors.map((className, index) => ( +
+ ))} + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByTestId('tile-0')).toBeVisible() + await expect(canvas.getByTestId('tile-5')).toBeVisible() + }, +} diff --git a/src/stories/components/Modal.stories.tsx b/src/stories/components/Modal.stories.tsx new file mode 100644 index 0000000..d29a026 --- /dev/null +++ b/src/stories/components/Modal.stories.tsx @@ -0,0 +1,126 @@ +import { useState, type JSX } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn, userEvent, within } from 'storybook/test' +import { ButtonComponent } from '@webui/components/components/editors/ButtonComponent' +import { Modal, ConfirmDialog } from '@webui/components/components/Modal' + +const pageDecorator = (Story: () => JSX.Element) => ( +
+ +
+) + +function ModalDemo({ startOpen = false }: { startOpen?: boolean }) { + const [isOpen, setIsOpen] = useState(startOpen) + + return ( + <> + + setIsOpen(false)} + footer={( + <> + setIsOpen(false)} /> + setIsOpen(false)} /> + + )} + > +

Centered dialog with title, body, and footer actions.

+
+ + ) +} + +function ConfirmDemo({ startOpen = false }: { startOpen?: boolean }) { + const [isOpen, setIsOpen] = useState(startOpen) + + return ( + <> + + setIsOpen(false)} + onConfirm={() => { + fn() + setIsOpen(false) + }} + /> + + ) +} + +const meta = { + title: 'components/Modal', + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Centered dialog shell (`Modal`) and confirm variant (`ConfirmDialog`). Escape and backdrop close are enabled by default.', + }, + }, + }, + decorators: [pageDecorator], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Interactive: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: /open modal/i })) + await expect(canvas.getByRole('dialog', { name: /edit item/i })).toBeVisible() + }, +} + +export const Open: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('dialog', { name: /edit item/i })).toBeVisible() + await userEvent.click(canvas.getByRole('button', { name: /close/i })) + await expect(canvas.queryByRole('dialog')).not.toBeInTheDocument() + }, +} + +export const Confirm: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: /^delete$/i })) + const body = within(canvasElement.ownerDocument.body) + await expect(body.getByRole('dialog', { name: /delete item/i })).toBeVisible() + }, +} + +export const ConfirmOpen: Story = { + render: () => , + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body) + const dialog = body.getByRole('dialog', { name: /delete item/i }) + await expect(dialog).toBeVisible() + await userEvent.click(within(dialog).getByRole('button', { name: /^delete$/i })) + await expect(body.queryByRole('dialog')).not.toBeInTheDocument() + }, +} diff --git a/src/stories/components/Ratings.stories.tsx b/src/stories/components/Ratings.stories.tsx new file mode 100644 index 0000000..6cd176c --- /dev/null +++ b/src/stories/components/Ratings.stories.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' +import { Ratings } from '@webui/components/components/Ratings' + +const meta = { + title: 'components/Ratings', + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ReadOnly: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('img', { name: /product rating/i })).toBeVisible() + }, +} + +export const Interactive: Story = { + render: () => { + const [value, setValue] = useState(4) + return ( +
+ +

Selected: {value}

+
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('radio', { name: /2 stars/i })) + await expect(canvas.getByText(/selected: 2/i)).toBeVisible() + }, +} + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('img', { name: /small rating/i })).toBeVisible() + await expect(canvas.getByRole('img', { name: /medium rating/i })).toBeVisible() + await expect(canvas.getByRole('img', { name: /large rating/i })).toBeVisible() + }, +} diff --git a/src/stories/components/editors/FileUploadComponent.stories.tsx b/src/stories/components/editors/FileUploadComponent.stories.tsx index 7e363e8..d5870cf 100644 --- a/src/stories/components/editors/FileUploadComponent.stories.tsx +++ b/src/stories/components/editors/FileUploadComponent.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' import { FileUploadComponent } from '@webui/components/components/editors/FileUploadComponent' import { withControlledFiles } from '../../helpers/controlledEditors' @@ -8,6 +9,14 @@ const meta = { title: 'components/editors/FileUpload', component: ControlledFileUpload, tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'File picker with optional drag-and-drop (`droppable`, default true). Drop replaces the current selection.', + }, + }, + }, } satisfies Meta export default meta @@ -18,6 +27,11 @@ export const Default: Story = { label: 'Upload documents', multiple: true, files: [], + accept: 'image/*,.pdf', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText(/upload documents/i)).toBeVisible() }, } @@ -29,6 +43,18 @@ export const SingleFile: Story = { }, } +export const DropDisabled: Story = { + args: { + label: 'Click only', + files: [], + droppable: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText(/click only/i)).toBeVisible() + }, +} + export const Disabled: Story = { args: { label: 'Upload documents', diff --git a/src/stories/components/social.stories.tsx b/src/stories/components/social.stories.tsx new file mode 100644 index 0000000..10ab863 --- /dev/null +++ b/src/stories/components/social.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' +import { + WhatsAppButton, + FacebookMessengerButton, +} from '@webui/components/components/social' + +const meta = { + title: 'components/social', + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const WhatsAppInline: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const link = canvas.getByRole('link', { name: /chat on whatsapp/i }) + await expect(link).toHaveAttribute( + 'href', + `https://wa.me/15551234567?text=${encodeURIComponent('Hello from the website')}`, + ) + }, +} + +export const MessengerInline: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const link = canvas.getByRole('link', { name: /chat on messenger/i }) + await expect(link).toHaveAttribute('href', 'https://m.me/Meta') + }, +} + +export const FloatingPair: Story = { + render: () => ( +
+ + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('link', { name: /^whatsapp$/i })).toHaveAttribute( + 'href', + `https://wa.me/15551234567?text=${encodeURIComponent('Hi')}`, + ) + await expect(canvas.getByRole('link', { name: /^messenger$/i })).toHaveAttribute( + 'href', + 'https://m.me/Meta', + ) + }, +} diff --git a/src/stories/core/hooks.stories.tsx b/src/stories/core/hooks.stories.tsx new file mode 100644 index 0000000..c11abc7 --- /dev/null +++ b/src/stories/core/hooks.stories.tsx @@ -0,0 +1,270 @@ +import { useEffect, useRef, useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, within } from 'storybook/test' +import { + useDebounce, + useHover, + useInterval, + useLocalStorage, + useLongPress, + useMedia, + useOnClickOutside, + useOnScreen, + usePrevious, + useSessionStorage, +} from '@webui/core' + +const meta = { + title: 'core/hooks', + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Interactive demos for core hooks (debounce, storage, hover, previous, interval, media, click-outside, long-press, on-screen).', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function DebounceDemo() { + const [value, setValue] = useState('') + const debounced = useDebounce(value, 300) + + return ( +
+ +

+ Live: {value || '—'} +

+

+ Debounced: {debounced || '—'} +

+
+ ) +} + +function StorageDemo() { + const [localValue, setLocalValue] = useLocalStorage('storybook.local', 0) + const [sessionValue, setSessionValue] = useSessionStorage('storybook.session', 0) + + return ( +
+
+

localStorage: {localValue}

+ +
+
+

sessionStorage: {sessionValue}

+ +
+
+ ) +} + +function HoverPreviousDemo() { + const [ref, isHovered] = useHover() + const [count, setCount] = useState(0) + const previous = usePrevious(count) + + return ( +
+
+ {isHovered ? 'Hovered' : 'Hover me'} +
+
+ + Previous: {previous ?? '—'} +
+
+ ) +} + +function IntervalDemo() { + const [ticks, setTicks] = useState(0) + const [running, setRunning] = useState(true) + useInterval(() => setTicks((n) => n + 1), running ? 400 : null) + + return ( +
+

Ticks: {ticks}

+ +
+ ) +} + +function MediaDemo() { + const label = useMedia(['(min-width: 768px)'], ['wide'], 'narrow') + return

Viewport: {label}

+} + +function ClickOutsideDemo() { + const ref = useRef(null) + const [outside, setOutside] = useState(0) + useOnClickOutside(ref, () => setOutside((n) => n + 1)) + + return ( +
+
+ Inside panel +
+ +

Outside clicks: {outside}

+
+ ) +} + +function LongPressDemo() { + const [message, setMessage] = useState('Idle') + const handlers = useLongPress({ + delayMs: 400, + onLongPress: () => setMessage('Long press'), + onClick: () => setMessage('Click'), + }) + + return ( +
+ +

{message}

+
+ ) +} + +function OnScreenDemo() { + const ref = useRef(null) + const visible = useOnScreen(ref, { threshold: 0.1 }) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( +
+

Visible: {mounted && visible ? 'yes' : 'no'}

+
+ Observed block +
+
+ ) +} + +export const Debounce: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const input = canvas.getByRole('textbox') + await userEvent.type(input, 'hi') + await expect(canvas.getByTestId('live')).toHaveTextContent('hi') + await expect(canvas.getByTestId('debounced')).toHaveTextContent('—') + await new Promise((resolve) => setTimeout(resolve, 350)) + await expect(canvas.getByTestId('debounced')).toHaveTextContent('hi') + }, +} + +export const Storage: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: /increment local/i })) + await expect(canvas.getByText(/localStorage: 1/i)).toBeVisible() + await userEvent.click(canvas.getByRole('button', { name: /increment session/i })) + await expect(canvas.getByText(/sessionStorage: 1/i)).toBeVisible() + }, +} + +export const HoverAndPrevious: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', { name: /count: 0/i })) + await expect(canvas.getByTestId('previous')).toHaveTextContent('Previous: 0') + }, +} + +export const Interval: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await new Promise((resolve) => setTimeout(resolve, 500)) + const text = canvas.getByTestId('ticks').textContent ?? '' + await expect(Number(text.replace(/\D/g, ''))).toBeGreaterThan(0) + }, +} + +export const Media: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByTestId('media')).toHaveTextContent(/Viewport: (wide|narrow)/) + }, +} + +export const OnClickOutside: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByTestId('outside-btn')) + await expect(canvas.getByTestId('outside-count')).toHaveTextContent('Outside clicks: 1') + }, +} + +export const LongPress: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const button = canvas.getByRole('button', { name: /press me/i }) + await userEvent.click(button) + await expect(canvas.getByTestId('longpress')).toHaveTextContent('Click') + }, +} + +export const OnScreen: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByTestId('onscreen')).toHaveTextContent(/Visible: (yes|no)/) + }, +} diff --git a/src/test/components/CookieConsent/cookies.test.ts b/src/test/components/CookieConsent/cookies.test.ts new file mode 100644 index 0000000..e21b973 --- /dev/null +++ b/src/test/components/CookieConsent/cookies.test.ts @@ -0,0 +1,26 @@ +import { getCookie, setCookie } from '@webui/components/components/CookieConsent/cookies' + +describe('cookies', () => { + beforeEach(() => { + // Clear cookies by overwriting each known key with an expired max-age. + for (const part of document.cookie.split(';')) { + const name = part.split('=')[0]?.trim() + if (name) + document.cookie = `${name}=; max-age=0; path=/` + } + }) + + it('returns empty string when missing', () => { + expect(getCookie('missing-cookie-xyz')).toBe('') + }) + + it('sets and reads a cookie value', () => { + setCookie('consent', 'accepted', 7) + expect(getCookie('consent')).toBe('accepted') + }) + + it('reads an existing cookie from document.cookie', () => { + document.cookie = 'preset=hello; path=/' + expect(getCookie('preset')).toBe('hello') + }) +}) diff --git a/src/test/components/FileBrowser/format.test.ts b/src/test/components/FileBrowser/format.test.ts new file mode 100644 index 0000000..f382d83 --- /dev/null +++ b/src/test/components/FileBrowser/format.test.ts @@ -0,0 +1,103 @@ +import { + buildNavCrumbs, + canPreviewTextObject, + formatBytes, + formatDate, + formatMediaType, + formatPreviewText, + itemKindLabel, + PREVIEW_MAX_BYTES, +} from '@webui/components/components/FileBrowser/format' + +describe('FileBrowser format', () => { + describe('formatBytes', () => { + it('returns em dash for nullish or NaN', () => { + expect(formatBytes(undefined)).toBe('—') + expect(formatBytes(null)).toBe('—') + expect(formatBytes(Number.NaN)).toBe('—') + }) + + it('formats bytes and larger units', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2 KB') + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB') + }) + }) + + describe('formatDate', () => { + it('returns em dash for empty', () => { + expect(formatDate(undefined)).toBe('—') + expect(formatDate('')).toBe('—') + }) + + it('returns original string for invalid dates', () => { + expect(formatDate('not-a-date')).toBe('not-a-date') + }) + + it('formats valid ISO dates', () => { + const formatted = formatDate('2026-07-20T10:00:00Z') + expect(formatted).not.toBe('—') + expect(formatted).toContain('2026') + }) + }) + + describe('formatMediaType / itemKindLabel', () => { + it('maps known media types and folders', () => { + expect(formatMediaType('application/json')).toBe('JSON') + expect(formatMediaType('image/png')).toBe('Image') + expect(formatMediaType(undefined)).toBe('—') + expect(itemKindLabel({ isFolder: true, name: 'a' })).toBe('Folder') + expect(itemKindLabel({ isFolder: true, isVirtual: true, name: 'a' })).toBe('Virtual') + expect(itemKindLabel({ isFolder: false, name: 'a', contentType: 'text/plain' })).toBe('Text') + }) + }) + + describe('canPreviewTextObject', () => { + it('rejects folders, virtual items, binaries, and oversized objects', () => { + expect(canPreviewTextObject({ isFolder: true, name: 'dir' })).toBe(false) + expect(canPreviewTextObject({ isFolder: false, isVirtual: true, name: 'v' })).toBe(false) + expect(canPreviewTextObject({ + isFolder: false, + name: 'blob.bin', + contentType: 'application/octet-stream', + })).toBe(false) + expect(canPreviewTextObject({ + isFolder: false, + name: 'big.json', + contentType: 'application/json', + size: PREVIEW_MAX_BYTES + 1, + })).toBe(false) + }) + + it('allows json/text and known extensions', () => { + expect(canPreviewTextObject({ + isFolder: false, + name: 'data.json', + contentType: 'application/json', + size: 100, + })).toBe(true) + expect(canPreviewTextObject({ + isFolder: false, + name: 'readme.md', + size: 100, + })).toBe(true) + }) + }) + + describe('formatPreviewText', () => { + it('pretty-prints JSON and leaves plain text', () => { + expect(formatPreviewText('{"a":1}')).toBe('{\n "a": 1\n}') + expect(formatPreviewText('hello')).toBe('hello') + expect(formatPreviewText('{broken')).toBe('{broken') + }) + }) + + describe('buildNavCrumbs', () => { + it('builds cumulative path segments', () => { + expect(buildNavCrumbs(['a', 'b'])).toEqual([ + { label: 'a', segments: ['a'] }, + { label: 'b', segments: ['a', 'b'] }, + ]) + }) + }) +}) diff --git a/src/test/components/FileBrowser/prefs.test.ts b/src/test/components/FileBrowser/prefs.test.ts new file mode 100644 index 0000000..8063020 --- /dev/null +++ b/src/test/components/FileBrowser/prefs.test.ts @@ -0,0 +1,24 @@ +import { loadPrefs, savePrefs } from '@webui/components/components/FileBrowser/prefs' + +describe('FileBrowser prefs', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('returns defaults when empty', () => { + expect(loadPrefs()).toEqual({ sortColumn: 'name', sortDirection: 'asc' }) + }) + + it('round-trips prefs through localStorage', () => { + savePrefs({ sortColumn: 'size', sortDirection: 'desc' }) + expect(loadPrefs()).toEqual({ sortColumn: 'size', sortDirection: 'desc' }) + }) + + it('falls back for partial or invalid JSON', () => { + localStorage.setItem('maksit.webui.fileBrowser.prefs.v1', '{"sortColumn":"modified"}') + expect(loadPrefs()).toEqual({ sortColumn: 'modified', sortDirection: 'asc' }) + + localStorage.setItem('maksit.webui.fileBrowser.prefs.v1', '{not-json') + expect(loadPrefs()).toEqual({ sortColumn: 'name', sortDirection: 'asc' }) + }) +}) diff --git a/src/test/components/Loader/loaderEvents.test.ts b/src/test/components/Loader/loaderEvents.test.ts new file mode 100644 index 0000000..6b28125 --- /dev/null +++ b/src/test/components/Loader/loaderEvents.test.ts @@ -0,0 +1,36 @@ +import { + disableLoader, + enableLoader, + hideLoader, + loaderEventNames, + showLoader, +} from '@webui/components/components/Loader/loaderEvents' + +describe('loaderEvents', () => { + it('dispatches named custom events', () => { + const show = jest.fn() + const hide = jest.fn() + const enable = jest.fn() + const disable = jest.fn() + + window.addEventListener(loaderEventNames.show, show) + window.addEventListener(loaderEventNames.hide, hide) + window.addEventListener(loaderEventNames.enable, enable) + window.addEventListener(loaderEventNames.disable, disable) + + showLoader() + hideLoader() + enableLoader() + disableLoader() + + expect(show).toHaveBeenCalledTimes(1) + expect(hide).toHaveBeenCalledTimes(1) + expect(enable).toHaveBeenCalledTimes(1) + expect(disable).toHaveBeenCalledTimes(1) + + window.removeEventListener(loaderEventNames.show, show) + window.removeEventListener(loaderEventNames.hide, hide) + window.removeEventListener(loaderEventNames.enable, enable) + window.removeEventListener(loaderEventNames.disable, disable) + }) +}) diff --git a/src/test/components/social/hrefs.test.ts b/src/test/components/social/hrefs.test.ts new file mode 100644 index 0000000..78130b1 --- /dev/null +++ b/src/test/components/social/hrefs.test.ts @@ -0,0 +1,18 @@ +import { buildWhatsAppHref } from '@webui/components/components/social/WhatsAppButton' +import { buildMessengerHref } from '@webui/components/components/social/FacebookMessengerButton' + +describe('social href builders', () => { + it('builds WhatsApp links with and without a number', () => { + expect(buildWhatsAppHref(undefined, 'Hi there')).toBe( + `https://wa.me/?text=${encodeURIComponent('Hi there')}`, + ) + expect(buildWhatsAppHref('15551234567', 'Hello')).toBe( + `https://wa.me/15551234567?text=${encodeURIComponent('Hello')}`, + ) + }) + + it('builds Messenger links and strips leading @', () => { + expect(buildMessengerHref('Meta')).toBe('https://m.me/Meta') + expect(buildMessengerHref('@Meta')).toBe('https://m.me/Meta') + }) +}) diff --git a/src/test/core/hooks/useDebounce.test.ts b/src/test/core/hooks/useDebounce.test.ts new file mode 100644 index 0000000..e746a5a --- /dev/null +++ b/src/test/core/hooks/useDebounce.test.ts @@ -0,0 +1,30 @@ +import { act, renderHook } from '@testing-library/react' +import { useDebounce } from '@webui/core/hooks/useDebounce' + +describe('useDebounce', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns the initial value immediately and updates after delay', () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'a', delay: 200 } }, + ) + + expect(result.current).toBe('a') + + rerender({ value: 'b', delay: 200 }) + expect(result.current).toBe('a') + + act(() => { + jest.advanceTimersByTime(200) + }) + + expect(result.current).toBe('b') + }) +}) diff --git a/src/test/core/hooks/useHover.test.ts b/src/test/core/hooks/useHover.test.ts new file mode 100644 index 0000000..25aa8cf --- /dev/null +++ b/src/test/core/hooks/useHover.test.ts @@ -0,0 +1,24 @@ +import { act, renderHook } from '@testing-library/react' +import { useHover } from '@webui/core/hooks/useHover' + +describe('useHover', () => { + it('tracks hover via the callback ref', () => { + const { result } = renderHook(() => useHover()) + const node = document.createElement('div') + + act(() => { + result.current[0](node) + }) + expect(result.current[1]).toBe(false) + + act(() => { + node.dispatchEvent(new Event('mouseenter')) + }) + expect(result.current[1]).toBe(true) + + act(() => { + node.dispatchEvent(new Event('mouseleave')) + }) + expect(result.current[1]).toBe(false) + }) +}) diff --git a/src/test/core/hooks/useInterval.test.ts b/src/test/core/hooks/useInterval.test.ts new file mode 100644 index 0000000..9d344a8 --- /dev/null +++ b/src/test/core/hooks/useInterval.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react' +import { useInterval } from '@webui/core/hooks/useInterval' + +describe('useInterval', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('invokes the callback on the interval and pauses when delay is null', () => { + const callback = jest.fn() + const { rerender } = renderHook( + ({ delay }) => useInterval(callback, delay), + { initialProps: { delay: 100 as number | null } }, + ) + + act(() => { + jest.advanceTimersByTime(300) + }) + expect(callback).toHaveBeenCalledTimes(3) + + rerender({ delay: null }) + act(() => { + jest.advanceTimersByTime(300) + }) + expect(callback).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/test/core/hooks/useLocalStorage.test.ts b/src/test/core/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000..6291f74 --- /dev/null +++ b/src/test/core/hooks/useLocalStorage.test.ts @@ -0,0 +1,37 @@ +import { act, renderHook } from '@testing-library/react' +import { useLocalStorage } from '@webui/core/hooks/useLocalStorage' + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('reads and writes JSON values', () => { + const { result } = renderHook(() => useLocalStorage('demo', { n: 1 })) + + expect(result.current[0]).toEqual({ n: 1 }) + + act(() => { + result.current[1]({ n: 2 }) + }) + + expect(result.current[0]).toEqual({ n: 2 }) + expect(JSON.parse(localStorage.getItem('demo')!)).toEqual({ n: 2 }) + }) + + it('supports functional updates', () => { + const { result } = renderHook(() => useLocalStorage('count', 0)) + + act(() => { + result.current[1]((prev) => prev + 1) + }) + + expect(result.current[0]).toBe(1) + }) + + it('hydrates from existing storage', () => { + localStorage.setItem('existing', JSON.stringify('stored')) + const { result } = renderHook(() => useLocalStorage('existing', 'fallback')) + expect(result.current[0]).toBe('stored') + }) +}) diff --git a/src/test/core/hooks/useLongPress.test.ts b/src/test/core/hooks/useLongPress.test.ts new file mode 100644 index 0000000..6d143fe --- /dev/null +++ b/src/test/core/hooks/useLongPress.test.ts @@ -0,0 +1,55 @@ +import { act, renderHook } from '@testing-library/react' +import type { MouseEvent as ReactMouseEvent } from 'react' +import { useLongPress } from '@webui/core/hooks/useLongPress' + +const mouseEvent = () => + ({ + target: document.createElement('div'), + preventDefault: jest.fn(), + }) as unknown as ReactMouseEvent + +describe('useLongPress', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('fires onLongPress after the delay', () => { + const onLongPress = jest.fn() + const onClick = jest.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, delayMs: 300 }), + ) + + act(() => { + result.current.onMouseDown(mouseEvent()) + jest.advanceTimersByTime(300) + }) + + expect(onLongPress).toHaveBeenCalledTimes(1) + + act(() => { + result.current.onMouseUp(mouseEvent()) + }) + expect(onClick).not.toHaveBeenCalled() + }) + + it('fires onClick for a short press', () => { + const onLongPress = jest.fn() + const onClick = jest.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, delayMs: 300 }), + ) + + act(() => { + result.current.onMouseDown(mouseEvent()) + result.current.onMouseUp(mouseEvent()) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/test/core/hooks/useMedia.test.ts b/src/test/core/hooks/useMedia.test.ts new file mode 100644 index 0000000..1207fd7 --- /dev/null +++ b/src/test/core/hooks/useMedia.test.ts @@ -0,0 +1,102 @@ +import { act, renderHook } from '@testing-library/react' +import { useMedia } from '@webui/core/hooks/useMedia' + +type Listener = (event: MediaQueryListEvent) => void + +const createMatchMedia = (matchesByQuery: Record) => { + const listeners = new Map>() + + return (query: string): MediaQueryList => { + const matches = matchesByQuery[query] ?? false + return { + matches, + media: query, + onchange: null, + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + const set = listeners.get(query) ?? new Set() + set.add(listener as Listener) + listeners.set(query, set) + }, + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + listeners.get(query)?.delete(listener as Listener) + }, + addListener: (listener: Listener) => { + const set = listeners.get(query) ?? new Set() + set.add(listener) + listeners.set(query, set) + }, + removeListener: (listener: Listener) => { + listeners.get(query)?.delete(listener) + }, + dispatchEvent: () => false, + } + } +} + +describe('useMedia', () => { + afterEach(() => { + // @ts-expect-error restore + delete window.matchMedia + }) + + it('returns the value for the first matching query', () => { + window.matchMedia = createMatchMedia({ + '(min-width: 800px)': false, + '(min-width: 500px)': true, + }) as typeof window.matchMedia + + const { result } = renderHook(() => + useMedia( + ['(min-width: 800px)', '(min-width: 500px)'], + ['lg', 'md'], + 'sm', + ), + ) + + expect(result.current).toBe('md') + }) + + it('returns the default when nothing matches', () => { + window.matchMedia = createMatchMedia({}) as typeof window.matchMedia + + const { result } = renderHook(() => + useMedia(['(min-width: 1200px)'], ['xl'], 'base'), + ) + + expect(result.current).toBe('base') + }) + + it('updates when a media query change fires', () => { + let matches = false + const changeListeners: Listener[] = [] + + window.matchMedia = ((query: string): MediaQueryList => ({ + get matches() { + return matches + }, + media: query, + onchange: null, + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + changeListeners.push(listener as Listener) + }, + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: () => false, + })) as typeof window.matchMedia + + const { result } = renderHook(() => + useMedia(['(min-width: 600px)'], ['wide'], 'narrow'), + ) + + expect(result.current).toBe('narrow') + + act(() => { + matches = true + for (const listener of changeListeners) + listener({ matches: true } as MediaQueryListEvent) + }) + + expect(result.current).toBe('wide') + }) +}) diff --git a/src/test/core/hooks/useOnClickOutside.test.ts b/src/test/core/hooks/useOnClickOutside.test.ts new file mode 100644 index 0000000..e66b339 --- /dev/null +++ b/src/test/core/hooks/useOnClickOutside.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { useOnClickOutside } from '@webui/core/hooks/useOnClickOutside' + +describe('useOnClickOutside', () => { + it('calls the handler for outside clicks and ignores inside clicks', () => { + const handler = jest.fn() + const inside = document.createElement('div') + const outside = document.createElement('div') + document.body.append(inside, outside) + + const ref = createRef() + // @ts-expect-error assign for test + ref.current = inside + + renderHook(() => useOnClickOutside(ref, handler)) + + act(() => { + outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + }) + expect(handler).toHaveBeenCalledTimes(1) + + act(() => { + inside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + }) + expect(handler).toHaveBeenCalledTimes(1) + + inside.remove() + outside.remove() + }) +}) diff --git a/src/test/core/hooks/useOnScreen.test.ts b/src/test/core/hooks/useOnScreen.test.ts new file mode 100644 index 0000000..b81dc39 --- /dev/null +++ b/src/test/core/hooks/useOnScreen.test.ts @@ -0,0 +1,60 @@ +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { useOnScreen } from '@webui/core/hooks/useOnScreen' + +describe('useOnScreen', () => { + const observe = jest.fn() + const disconnect = jest.fn() + let callback: IntersectionObserverCallback | undefined + + beforeEach(() => { + observe.mockClear() + disconnect.mockClear() + callback = undefined + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null + readonly rootMargin = '0px' + readonly thresholds = [0] + + constructor(cb: IntersectionObserverCallback) { + callback = cb + } + + observe = observe + unobserve = jest.fn() + disconnect = disconnect + takeRecords = () => [] + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + writable: true, + value: MockIntersectionObserver, + }) + }) + + it('tracks intersection and disconnects when once is true', () => { + const element = document.createElement('div') + document.body.append(element) + const ref = createRef() + // @ts-expect-error assign for test + ref.current = element + + const { result } = renderHook(() => useOnScreen(ref, { once: true })) + expect(result.current).toBe(false) + expect(observe).toHaveBeenCalledWith(element) + + act(() => { + callback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + + expect(result.current).toBe(true) + expect(disconnect).toHaveBeenCalled() + + element.remove() + }) +}) diff --git a/src/test/core/hooks/usePrevious.test.ts b/src/test/core/hooks/usePrevious.test.ts new file mode 100644 index 0000000..406e5c4 --- /dev/null +++ b/src/test/core/hooks/usePrevious.test.ts @@ -0,0 +1,19 @@ +import { renderHook } from '@testing-library/react' +import { usePrevious } from '@webui/core/hooks/usePrevious' + +describe('usePrevious', () => { + it('returns undefined on first render, then the previous value', () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: 1 } }, + ) + + expect(result.current).toBeUndefined() + + rerender({ value: 2 }) + expect(result.current).toBe(1) + + rerender({ value: 3 }) + expect(result.current).toBe(2) + }) +}) diff --git a/src/test/core/hooks/useSessionStorage.test.ts b/src/test/core/hooks/useSessionStorage.test.ts new file mode 100644 index 0000000..178a500 --- /dev/null +++ b/src/test/core/hooks/useSessionStorage.test.ts @@ -0,0 +1,21 @@ +import { act, renderHook } from '@testing-library/react' +import { useSessionStorage } from '@webui/core/hooks/useSessionStorage' + +describe('useSessionStorage', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('reads and writes JSON values', () => { + const { result } = renderHook(() => useSessionStorage('demo', 'a')) + + expect(result.current[0]).toBe('a') + + act(() => { + result.current[1]('b') + }) + + expect(result.current[0]).toBe('b') + expect(JSON.parse(sessionStorage.getItem('demo')!)).toBe('b') + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/src/tsconfig.json b/src/tsconfig.json index 63966ce..7674133 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -33,6 +33,7 @@ "components", "index.ts", "test/**/*.ts", + "test/**/*.tsx", "test/**/*.d.ts" ] } diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json index 60cfdfc..d2372cc 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft-07/schema", "title": "Run Tests Script Settings", - "description": "maksit-webui: Jest tests and coverage badges for README assets.", + "description": "maksit-webui: Jest tests and shields.io coverage badges in README.md.", "paths": { - "badgesDir": "..\\..\\..\\assets\\badges" + "readmePath": "..\\..\\..\\README.md" }, "plugins": [ { @@ -25,20 +25,18 @@ "name": "CoverageBadges", "stageLabel": "report", "enabled": true, - "badgesDir": "..\\..\\..\\assets\\badges", + "badgeFormat": "shields", + "readmePath": "..\\..\\..\\README.md", "badges": [ { - "name": "coverage-lines.svg", "label": "Line Coverage", "metric": "line" }, { - "name": "coverage-branches.svg", "label": "Branch Coverage", "metric": "branch" }, { - "name": "coverage-methods.svg", "label": "Method Coverage", "metric": "method" } diff --git a/utils/modules/ContainerImagePushSupport.psm1 b/utils/modules/ContainerImagePushSupport.psm1 index d5bb4fc..a3b6eb1 100644 --- a/utils/modules/ContainerImagePushSupport.psm1 +++ b/utils/modules/ContainerImagePushSupport.psm1 @@ -191,7 +191,7 @@ function Invoke-ContainerImagePush { $primaryRef = "${baseName}:$($imageTags[0])" Write-Log -Level 'STEP' -Message "Building $primaryRef via $engineName ..." - Invoke-ContainerEngine -Engine $engine build -t $primaryRef -f $dockerfilePath $imgContextPath + Invoke-ContainerEngine -Engine $engine build --provenance=false --sbom=false -t $primaryRef -f $dockerfilePath $imgContextPath if ($LASTEXITCODE -ne 0) { throw "Container build failed for $primaryRef (exit code: $LASTEXITCODE)" } diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 index 03aeb46..11549ec 100644 --- a/utils/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -30,11 +30,17 @@ function New-EngineContext { $badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir))) } + $readmePath = $null + if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.readmePath) { + $readmePath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.readmePath))) + } + return [pscustomobject]@{ scriptDir = $ScriptDir srcDir = $SrcDir utilsDir = $SrcDir badgesDir = $badgesDir + readmePath = $readmePath deployMode = $DeployMode } } diff --git a/utils/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 index 36ba26a..00637fa 100644 --- a/utils/plugins/Platform/CoverageBadges.psm1 +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -6,7 +6,10 @@ Coverage badge plugin for the test engine. .DESCRIPTION - Reads line/branch/method coverage from shared engine context and writes SVG badges. + Reads line/branch/method coverage from shared engine context. + + badgeFormat "svg" (default): writes SVG files under badgesDir. + badgeFormat "shields": updates readmePath with img.shields.io markdown (no local assets). #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -80,6 +83,79 @@ function New-BadgeSvgInternal { "@ } +function New-ShieldsIoBadgeUrlInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $labelToken = ($Label -replace ' ', '%20') + $valueToken = "$Percentage%25" + return "https://img.shields.io/badge/$labelToken-$valueToken-$Color" +} + +function New-ShieldsIoBadgeMarkdownInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color + return "![$Label]($url)" +} + +function Update-ReadmeShieldsBadgesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReadmePath, + + [Parameter(Mandatory = $true)] + [object[]]$Badges, + + [Parameter(Mandatory = $true)] + [hashtable]$Metrics, + + [Parameter(Mandatory = $true)] + [psobject]$Thresholds + ) + + if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) { + throw "CoverageBadges readmePath not found: $ReadmePath" + } + + $content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8 + + foreach ($badge in @($Badges)) { + $metricValue = $Metrics[[string]$badge.metric] + if ($null -eq $metricValue) { + throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'." + } + + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds + $markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color + $pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)\s*$" + if ($content -notmatch $pattern) { + throw "README badge line not found for label '$($badge.label)' in: $ReadmePath" + } + + $content = [regex]::Replace($content, $pattern, $markdown) + } + + $content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline +} + function Get-CoverageMetricsFromSharedContext { param( [Parameter(Mandatory = $true)] @@ -131,17 +207,9 @@ function Invoke-Plugin { $scriptDir = $sharedSettings.scriptDir $metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings - $badgesDir = $sharedSettings.badgesDir - if ($pluginSettings.badgesDir) { - $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) - $badgesDir = $badgesDirs[0] - } - if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { - throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." - } - - if (-not (Test-Path $badgesDir)) { - New-Item -ItemType Directory -Path $badgesDir | Out-Null + $badgeFormat = 'svg' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) { + $badgeFormat = [string]$pluginSettings.badgeFormat } $thresholds = $pluginSettings.colorThresholds @@ -158,10 +226,50 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Generating coverage badges..." + if ($badgeFormat -eq 'shields') { + $readmePath = $null + if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) { + $readmePath = [string]$sharedSettings.readmePath + } + if ($pluginSettings.readmePath) { + $readmePaths = @(Resolve-RelativePaths -Value $pluginSettings.readmePath -BasePath $scriptDir) + $readmePath = $readmePaths[0] + } + if ([string]::IsNullOrWhiteSpace([string]$readmePath)) { + throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json." + } + + Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds + + foreach ($badge in @($pluginSettings.badges)) { + $metricValue = $metrics[[string]$badge.metric] + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds + Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)" + } + + Write-Log -Level "OK" -Message "README shields updated: $readmePath" + Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs." + return + } + + $badgesDir = $sharedSettings.badgesDir + if ($pluginSettings.badgesDir) { + $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) + $badgesDir = $badgesDirs[0] + } + if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { + throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." + } + + if (-not (Test-Path $badgesDir)) { + New-Item -ItemType Directory -Path $badgesDir | Out-Null + } + foreach ($badge in @($pluginSettings.badges)) { $metricValue = $metrics[[string]$badge.metric] if ($null -eq $metricValue) { - throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'." + $badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label } + throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'." } $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds