(feature): add FileBrowser/Modal/Loader/Masonry/LightBox/CookieConsent/Ratings/social buttons and core hooks
Some checks failed
Storybook tests / storybook-tests (push) Has been cancelled

This commit is contained in:
Maksym Sadovnychyy 2026-07-24 15:48:43 +02:00
parent 25ae18bab6
commit 6474f1f7c4
83 changed files with 5523 additions and 480 deletions

View File

@ -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). 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 ## [0.4.0] - 2026-07-07
### Changed ### 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. - 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}`). - 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/`. - 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 ### Removed

View File

@ -1,6 +1,8 @@
# MaksIT.WebUI # 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. 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`. **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 ## Release to npmjs

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 78.2%">
<title>Branch Coverage: 78.2%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#97ca00"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">78.2%</text>
<text x="128.75" y="14" fill="#fff">78.2%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 82.5%">
<title>Line Coverage: 82.5%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#4c1"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">82.5%</text>
<text x="115.75" y="14" fill="#fff">82.5%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 58.2%">
<title>Method Coverage: 58.2%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">58.2%</text>
<text x="128.75" y="14" fill="#fff">58.2%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -16,8 +16,8 @@ Peer dependencies must be installed in the host app.
| Area | Examples | | Area | Examples |
|------|----------| |------|----------|
| Contracts | `PagedRequest`, `PatchOperation`, `LoginRequestSchema`, OAuth DTOs | | Contracts | `PagedRequest`, `PatchOperation`, `LoginRequestSchema`, OAuth DTOs |
| Core | `deepDelta`, `useFormState`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers | | Core | `deepDelta`, `useFormState`, `useLocalStorage`, `useSessionStorage`, `useOnScreen`, `useInterval`, `usePrevious`, `useLongPress`, `useDebounce`, `useOnClickOutside`, `useMedia`, `useHover`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers |
| Components | `DataTable`, `Layout`, editors, `ExternalLoginButtons`, `IdpLoginCallbackHandler` | | Components | `DataTable`, `FileBrowser` (create/upload/download hooks), `Modal`, `Loader`, `Masonry`, `LightBox`, `CookieConsent`, `Ratings`, social chat buttons, `Layout`, editors, OAuth UI |
## Example — imports ## 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 ## Repository
[github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/` [github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/`

View File

@ -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 `<a>` 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<string, unknown>
}
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 `<a>`. */
linkComponent?: ConsentLinkComponent
onAccept?: () => void
onDismiss?: () => void
className?: string
}
const DefaultLink: ConsentLinkComponent = ({
href,
to,
children,
...rest
}) => (
<a href={href ?? to} {...rest}>
{children}
</a>
)
const CookieConsent: FC<CookieConsentProps> = ({
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 (
<div
className={[
'fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-3xl flex-wrap items-center gap-3 rounded-lg',
'border border-gray-200 bg-white px-4 py-3 shadow-lg sm:left-6 sm:right-auto',
className,
].filter(Boolean).join(' ')}
role={'dialog'}
aria-live={'polite'}
aria-label={'Cookie consent'}
>
<button
type={'button'}
className={'absolute right-2 top-2 text-lg leading-none text-gray-400 hover:text-gray-700'}
aria-label={'Dismiss'}
onClick={handleDismiss}
>
&times;
</button>
<div className={'min-w-0 flex-1 pr-4 text-sm text-gray-700'}>
{children}
{message}
{links.length > 0 ? (
<ul className={'mt-2 flex flex-wrap gap-x-3 gap-y-1'}>
{links.map((link, index) => (
<li key={index}>
<LinkComponent
href={link.href ?? (typeof link.to === 'string' ? link.to : undefined)}
to={link.to ?? link.href}
className={'text-sky-700 underline hover:text-sky-900'}
{...(link.linkProps ?? {})}
>
{link.label}
</LinkComponent>
</li>
))}
</ul>
) : null}
</div>
<button
type={'button'}
className={'rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700'}
onClick={handleAccept}
>
{acceptLabel}
</button>
</div>
)
}
export { CookieConsent }

View File

@ -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`
}

View File

@ -0,0 +1,3 @@
export { CookieConsent } from './CookieConsent'
export type { CookieConsentProps, CookieConsentLink } from './CookieConsent'
export { getCookie, setCookie } from './cookies'

View File

@ -128,6 +128,33 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
} }
}, [colWidths, storageKey]) }, [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(() => { useEffect(() => {
const el = filterMeasureRef.current const el = filterMeasureRef.current
if (!el || columns.length === 0) return if (!el || columns.length === 0) return
@ -429,7 +456,7 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
ref={gridRef} ref={gridRef}
cellRenderer={cellRenderer} cellRenderer={cellRenderer}
columnCount={columns.length + 1} columnCount={columns.length + 1}
columnWidth={({ index }) => colWidths[index]} columnWidth={({ index }) => colWidths[index] ?? DEFAULT_COL_WIDTH}
fixedColumnCount={1} fixedColumnCount={1}
fixedRowCount={HEADER_ROWS} fixedRowCount={HEADER_ROWS}
height={height} height={height}

View File

@ -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<FileBrowserProps> = (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<HTMLInputElement>(null)
const uploadInputRef = useRef<HTMLInputElement>(null)
const previewRequestId = useRef(0)
const initialPrefs = useMemo(() => loadPrefs(), [])
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
const [copiedField, setCopiedField] = useState<string | null>(null)
const [nameFilter, setNameFilter] = useState('')
const [sortColumn, setSortColumn] = useState<SortColumn>(initialPrefs.sortColumn)
const [sortDirection, setSortDirection] = useState<SortDirection>(initialPrefs.sortDirection)
const [previewText, setPreviewText] = useState<string | null>(null)
const [previewError, setPreviewError] = useState<string | null>(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<HTMLInputElement>) => {
if (e.key === 'Escape') {
setNameFilter('')
filterInputRef.current?.blur()
}
}
const SortIcon = ({ column }: { column: SortColumn }) => {
if (sortColumn !== column)
return <ArrowUpDown className={'h-3.5 w-3.5 opacity-40'} />
return sortDirection === 'asc'
? <ArrowUp className={'h-3.5 w-3.5'} />
: <ArrowDown className={'h-3.5 w-3.5'} />
}
const activeRoot = roots.find(r => r.id === activeRootId)
return (
<div className={'flex min-h-0 flex-1 flex-col gap-2 overflow-hidden'}>
{description && (
<p className={'shrink-0 text-sm text-gray-600'}>{description}</p>
)}
<div className={'flex min-h-0 flex-1 overflow-hidden rounded border border-gray-200 bg-white'}>
<aside className={'flex w-52 shrink-0 flex-col border-r border-gray-200 bg-gray-50'}>
<div className={'border-b border-gray-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500'}>
{rootsTitle}
</div>
<div className={'min-h-0 flex-1 overflow-y-auto p-1'}>
{roots.map((root) => {
const isActiveRoot = root.id === activeRootId
const active = isActiveRoot && pathSegments.length === 0
return (
<button
key={root.id}
type={'button'}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm ${
active ? 'bg-sky-100 text-sky-900' : 'text-gray-700 hover:bg-gray-100'
}`}
onClick={() => onNavigateRoot(root.id)}
>
<HardDrive className={`h-4 w-4 shrink-0 ${root.iconClassName ?? 'text-sky-600'}`} />
<span className={'truncate'}>{root.label}</span>
</button>
)
})}
{activeRootId && navCrumbs.map((crumb) => {
const isLast = crumb.segments.length === pathSegments.length
return (
<button
key={crumb.segments.join('/')}
type={'button'}
className={`mt-0.5 flex w-full items-center gap-1 rounded px-2 py-1.5 pl-4 text-left text-sm ${
isLast ? 'bg-sky-100 text-sky-900' : 'text-gray-600 hover:bg-gray-100'
}`}
onClick={() => onNavigatePath(activeRootId, crumb.segments)}
>
<ChevronRight className={'h-3.5 w-3.5 shrink-0 text-gray-400'} />
<FolderOpen className={'h-4 w-4 shrink-0 text-amber-500'} />
<span className={'truncate'}>{crumb.label}</span>
</button>
)
})}
</div>
</aside>
<div className={'flex min-w-0 flex-1 flex-col'}>
<div className={'flex flex-wrap items-center gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5'}>
{onCreateFolder && activeRootId ? (
<button
type={'button'}
title={'New folder'}
disabled={actionBusy}
className={'inline-flex items-center gap-1 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-200 disabled:opacity-40'}
onClick={() => {
setFolderName('')
setCreateFolderOpen(true)
}}
>
<FolderPlus className={'h-4 w-4'} />
New folder
</button>
) : null}
{onUpload && activeRootId ? (
<>
<input
ref={uploadInputRef}
type={'file'}
multiple={true}
className={'hidden'}
onChange={(e) => void handleUploadChange(e.target.files)}
/>
<button
type={'button'}
title={'Upload'}
disabled={actionBusy}
className={'inline-flex items-center gap-1 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-200 disabled:opacity-40'}
onClick={() => uploadInputRef.current?.click()}
>
<Upload className={'h-4 w-4'} />
Upload
</button>
</>
) : null}
<button
type={'button'}
title={deleteBlockedReason ?? 'Delete'}
disabled={selectedKeys.length === 0 || selectionBlocked || actionBusy}
className={'inline-flex items-center gap-1 rounded px-2 py-1.5 text-sm text-red-700 hover:bg-red-50 disabled:opacity-40'}
onClick={() => void handleDelete()}
>
<Trash2 className={'h-4 w-4'} />
Delete
</button>
{canDownload ? (
<button
type={'button'}
title={'Download'}
disabled={actionBusy}
className={'inline-flex items-center gap-1 rounded px-2 py-1.5 text-sm text-sky-800 hover:bg-sky-50 disabled:opacity-40'}
onClick={() => void handleDownload()}
>
<Download className={'h-4 w-4'} />
Download
</button>
) : null}
<button
type={'button'}
title={'Refresh'}
disabled={actionBusy}
className={'inline-flex items-center gap-1 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-200 disabled:opacity-40'}
onClick={() => {
setSelectedKeys([])
onRefresh()
}}
>
<RefreshCw className={'h-4 w-4'} />
Refresh
</button>
<div className={'ml-auto flex items-center gap-1'}>
<Search className={'h-4 w-4 text-gray-400'} />
<input
ref={filterInputRef}
type={'search'}
value={nameFilter}
onChange={(e) => 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'}
/>
</div>
</div>
<div className={'flex flex-wrap items-center gap-1 border-b border-gray-100 px-2 py-1 text-xs text-gray-500'}>
<button
type={'button'}
className={'rounded px-1.5 py-0.5 hover:bg-gray-100 hover:text-gray-800'}
onClick={() => onNavigateRoot(activeRootId)}
disabled={!activeRootId}
>
{activeRoot?.label ?? rootsTitle}
</button>
{navCrumbs.map((crumb) => (
<span key={crumb.segments.join('/')} className={'inline-flex items-center gap-1'}>
<ChevronRight className={'h-3 w-3'} />
<button
type={'button'}
className={'rounded px-1.5 py-0.5 hover:bg-gray-100 hover:text-gray-800'}
onClick={() => onNavigatePath(activeRootId, crumb.segments)}
>
{crumb.label}
</button>
</span>
))}
{loading && <span className={'ml-2 text-sky-700'}>Loading</span>}
{selectedKeys.length > 0 && (
<span className={'ml-auto'}>
{selectedKeys.length} selected
{selectionBytes > 0 ? ` · ${formatBytes(selectionBytes)}` : ''}
</span>
)}
</div>
<div className={'min-h-0 flex-1 overflow-auto'}>
{visibleItems.length === 0 && !loading ? (
<div className={'px-4 py-8 text-center text-sm text-gray-500'}>
{emptyHint ?? 'No items in this folder.'}
</div>
) : (
<table className={'w-full border-collapse text-sm'}>
<thead className={'sticky top-0 z-10 bg-white shadow-[0_1px_0_0_rgba(0,0,0,0.06)]'}>
<tr className={'text-left text-xs uppercase tracking-wide text-gray-500'}>
{([
['name', 'Name'],
['kind', 'Kind'],
['size', 'Size'],
['modified', 'Modified'],
['type', 'Type'],
] as const).map(([column, label]) => (
<th key={column} className={'px-2 py-2 font-semibold'}>
<button
type={'button'}
className={'inline-flex items-center gap-1 hover:text-gray-800'}
onClick={() => toggleSort(column)}
>
{label}
<SortIcon column={column} />
</button>
</th>
))}
</tr>
</thead>
<tbody>
{(pathSegments.length > 0 || Boolean(activeRootId)) && (
<tr
className={'cursor-pointer border-t border-gray-100 hover:bg-gray-50'}
onDoubleClick={() => {
if (pathSegments.length > 0)
onNavigatePath(activeRootId, pathSegments.slice(0, -1))
else
onNavigateRoot('')
}}
>
<td className={'px-2 py-1.5'} colSpan={5}>
<span className={'inline-flex items-center gap-2 text-gray-600'}>
<Folder className={'h-4 w-4 text-amber-500'} />
..
</span>
</td>
</tr>
)}
{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 (
<tr
key={item.key}
className={`cursor-pointer border-t border-gray-100 ${
selected ? 'bg-sky-50' : 'hover:bg-gray-50'
}`}
onClick={(e) => toggleSelect(item.key, e.ctrlKey || e.metaKey || e.shiftKey)}
onDoubleClick={() => activateItem(item)}
>
<td className={'px-2 py-1.5'}>
<span className={'inline-flex min-w-0 items-center gap-2'}>
<Icon className={`h-4 w-4 shrink-0 ${
item.isFolder
? item.isVirtual ? 'text-violet-500' : 'text-amber-500'
: 'text-sky-600'
}`} />
<span className={'truncate font-medium text-gray-900'}>
{displayName}{item.isFolder ? '/' : ''}
</span>
</span>
</td>
<td className={'px-2 py-1.5 text-gray-600'}>{itemKindLabel(item)}</td>
<td className={'px-2 py-1.5 text-gray-600'}>
{item.isFolder ? '—' : formatBytes(item.size)}
</td>
<td className={'px-2 py-1.5 text-gray-600'}>{formatDate(item.lastModified)}</td>
<td className={'px-2 py-1.5 text-gray-600'} title={item.contentType}>
{item.isFolder ? '—' : formatMediaType(item.contentType)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
{canLoadMore && onLoadMore && (
<div className={'border-t border-gray-200 px-2 py-2'}>
<ButtonComponent label={'Load more'} onClick={onLoadMore} />
</div>
)}
</div>
{selectedItem && (
<aside className={'flex w-72 shrink-0 flex-col border-l border-gray-200 bg-gray-50'}>
<div className={'border-b border-gray-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500'}>
Details
</div>
<div className={'min-h-0 flex-1 space-y-3 overflow-y-auto p-3 text-sm'}>
<div>
<div className={'text-xs font-medium text-gray-500'}>Name</div>
<div className={'break-all font-medium text-gray-900'}>{selectedItem.label ?? selectedItem.name}</div>
</div>
<div>
<div className={'mb-1 flex items-center justify-between text-xs font-medium text-gray-500'}>
<span>Key</span>
<button
type={'button'}
className={'inline-flex items-center gap-1 text-sky-700 hover:underline'}
onClick={() => void copyValue('key', selectedItem.key)}
>
<Copy className={'h-3.5 w-3.5'} />
{copiedField === 'key' ? 'Copied' : 'Copy'}
</button>
</div>
<div className={'break-all font-mono text-xs text-gray-700'}>{selectedItem.key}</div>
</div>
<dl className={'grid grid-cols-1 gap-2 text-xs'}>
<div>
<dt className={'text-gray-500'}>Kind</dt>
<dd className={'text-gray-800'}>{itemKindLabel(selectedItem)}</dd>
</div>
{!selectedItem.isFolder && (
<>
<div>
<dt className={'text-gray-500'}>Size</dt>
<dd className={'text-gray-800'}>{formatBytes(selectedItem.size)}</dd>
</div>
<div>
<dt className={'text-gray-500'}>Type</dt>
<dd className={'text-gray-800'} title={selectedItem.contentType}>
{formatMediaType(selectedItem.contentType)}
</dd>
{selectedItem.contentType && formatMediaType(selectedItem.contentType) !== selectedItem.contentType && (
<dd className={'mt-1 break-all font-mono text-[11px] text-gray-400'}>{selectedItem.contentType}</dd>
)}
</div>
</>
)}
<div>
<dt className={'text-gray-500'}>Modified</dt>
<dd className={'text-gray-800'}>{formatDate(selectedItem.lastModified)}</dd>
</div>
{selectedItem.eTag && (
<div>
<dt className={'text-gray-500'}>ETag</dt>
<dd className={'break-all font-mono text-gray-700'}>{selectedItem.eTag}</dd>
</div>
)}
</dl>
{onFetchTextPreview && !selectedItem.isFolder && !selectedItem.isVirtual && canPreviewTextObject(selectedItem) && (
<div>
<div className={'mb-1 inline-flex items-center gap-1 text-xs font-medium text-gray-500'}>
<Eye className={'h-3.5 w-3.5'} />
Preview
{selectedItem.size != null && selectedItem.size > PREVIEW_MAX_BYTES && (
<span className={'font-normal text-amber-700'}>(too large)</span>
)}
</div>
{previewLoading && <p className={'text-xs text-gray-500'}>Loading</p>}
{previewError && <p className={'text-xs text-red-600'}>{previewError}</p>}
{previewText != null && (
<pre className={'max-h-64 overflow-auto rounded border border-gray-200 bg-white p-2 font-mono text-[11px] text-gray-800'}>
{previewText}
</pre>
)}
</div>
)}
</div>
</aside>
)}
</div>
<Modal
isOpen={createFolderOpen}
title={'New folder'}
size={'sm'}
onClose={() => {
if (!actionBusy)
setCreateFolderOpen(false)
}}
closeOnBackdrop={!actionBusy}
closeOnEscape={!actionBusy}
footer={(
<>
<ButtonComponent
label={'Cancel'}
buttonHierarchy={'secondary'}
disabled={actionBusy}
onClick={() => setCreateFolderOpen(false)}
/>
<ButtonComponent
label={'Create'}
buttonHierarchy={'primary'}
disabled={actionBusy || !folderName.trim()}
onClick={() => void handleCreateFolder()}
/>
</>
)}
>
<label className={'block text-sm text-gray-700'}>
Folder name
<input
type={'text'}
value={folderName}
autoFocus={true}
disabled={actionBusy}
onChange={(e) => setFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter')
void handleCreateFolder()
}}
className={'mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none'}
placeholder={'e.g. reports'}
/>
</label>
</Modal>
</div>
)
}
export { FileBrowser }

View File

@ -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<string, string> = {
'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,
}

View File

@ -0,0 +1,2 @@
export { FileBrowser } from './FileBrowser'
export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './types'

View File

@ -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<FileBrowserPrefs>
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,
}

View File

@ -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<void>
/** Create a folder under the current root + pathSegments. Host builds the storage key/prefix. */
onCreateFolder?: (folderName: string) => void | Promise<void>
/** Upload into the current folder. Host maps File[] → object keys/content-types. */
onUpload?: (files: File[]) => void | Promise<void>
/** Download a single selected file (not folders / virtual entries). */
onDownload?: (item: FileBrowserItem) => void | Promise<void>
/**
* Load object bytes as text for the detail preview panel.
* Return the raw text; the browser formats JSON when possible.
*/
onFetchTextPreview?: (item: FileBrowserItem) => Promise<string>
}

View File

@ -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<LightBoxProps> = ({
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 (
<div className={className || undefined}>
<Masonry breakpoints={breakpoints}>
{slides.map((slide, slideIndex) => (
<button
key={`${slide.src}-${slideIndex}`}
type={'button'}
className={'block w-full overflow-hidden rounded focus:outline-none focus:ring-2 focus:ring-sky-500'}
onClick={() => openAt(slideIndex)}
>
<img
src={slide.thumbnailSrc ?? slide.src}
alt={slide.alt ?? ''}
className={'w-full cursor-pointer object-cover transition hover:opacity-90 hover:shadow-md'}
/>
</button>
))}
</Masonry>
{isOpen && active ? (
<div
className={'fixed inset-0 z-50 flex flex-col bg-black/90'}
role={'dialog'}
aria-modal={true}
aria-label={active.alt || 'Image viewer'}
>
<div className={'flex shrink-0 items-center justify-between px-4 py-3 text-white'}>
<span className={'text-sm'}>
{index + 1} / {slides.length}
</span>
<button
type={'button'}
className={'rounded p-1 hover:bg-white/10'}
aria-label={'Close'}
onClick={close}
>
<X className={'h-6 w-6'} />
</button>
</div>
<div className={'relative flex min-h-0 flex-1 items-center justify-center overflow-hidden px-12'}>
<button
type={'button'}
className={'absolute left-2 z-10 rounded-full bg-white/10 p-2 text-white hover:bg-white/20'}
aria-label={'Previous image'}
onClick={goPrev}
>
<ChevronLeft className={'h-7 w-7'} />
</button>
<img
src={active.src}
alt={active.alt ?? ''}
className={'max-h-full max-w-full object-contain'}
/>
<button
type={'button'}
className={'absolute right-2 z-10 rounded-full bg-white/10 p-2 text-white hover:bg-white/20'}
aria-label={'Next image'}
onClick={goNext}
>
<ChevronRight className={'h-7 w-7'} />
</button>
</div>
{active.alt ? (
<p className={'shrink-0 px-4 py-2 text-center text-sm text-gray-200'}>{active.alt}</p>
) : null}
<div className={'shrink-0 border-t border-white/10 px-4 py-3'}>
<div className={'flex gap-2 overflow-x-auto'}>
{slides.map((slide, slideIndex) => (
<button
key={`thumb-${slide.src}-${slideIndex}`}
type={'button'}
className={[
'h-16 w-24 shrink-0 overflow-hidden rounded border-2',
slideIndex === index ? 'border-sky-400' : 'border-transparent opacity-70 hover:opacity-100',
].join(' ')}
onClick={() => setIndex(slideIndex)}
>
<img
src={slide.thumbnailSrc ?? slide.src}
alt={slide.alt ?? ''}
className={'h-full w-full object-cover'}
/>
</button>
))}
</div>
</div>
</div>
) : null}
</div>
)
}
export { LightBox }

View File

@ -0,0 +1,2 @@
export { LightBox } from './LightBox'
export type { LightBoxProps, LightBoxSlide } from './LightBox'

View File

@ -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<LoaderProps> = ({
visible,
label,
size = 'lg',
className = '',
}) => {
const [state, setState] = useState<LoaderState>({
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 (
<div
className={[
'fixed inset-0 flex items-center justify-center bg-gray-800/75 z-50',
className,
].filter(Boolean).join(' ')}
aria-busy={true}
>
<Spinner size={size} label={label} className={'text-gray-100'} />
</div>
)
}
export { Loader }

View File

@ -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<SpinnerSize, string> = {
sm: 'h-6 w-6 border-2',
md: 'h-12 w-12 border-4',
lg: 'h-32 w-32 border-8',
}
const Spinner: FC<SpinnerProps> = ({
size = 'md',
className = '',
label,
}) => (
<div
className={['inline-flex flex-col items-center gap-2', className].filter(Boolean).join(' ')}
role={'status'}
aria-live={'polite'}
aria-label={label ?? 'Loading'}
>
<div
className={[
'ease-linear rounded-full border-gray-200 animate-spin',
'border-t-sky-600',
sizeClass[size],
].join(' ')}
/>
{label ? <span className={'text-sm text-current opacity-90'}>{label}</span> : null}
</div>
)
export { Spinner }

View File

@ -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'

View File

@ -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

View File

@ -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]` → 14 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<MasonryProps> = ({
breakpoints = [350, 500, 750],
children,
className = '',
columnClassName = '',
}) => {
const containerRef = useRef<HTMLDivElement>(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 (
<div className={className || undefined} ref={containerRef}>
<div className={'flex gap-3 items-start'}>
{columnItems.map((column, columnIndex) => (
<div
key={columnIndex}
className={['flex flex-1 flex-col gap-3', columnClassName].filter(Boolean).join(' ')}
>
{column.map((child, index) => (
<div key={index}>{child}</div>
))}
</div>
))}
</div>
</div>
)
}
export { Masonry }

View File

@ -0,0 +1,2 @@
export { Masonry } from './Masonry'
export type { MasonryProps } from './Masonry'

View File

@ -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<ConfirmDialogProps> = ({
isOpen = false,
title = 'Confirm',
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmHierarchy = 'primary',
onConfirm,
onCancel,
size = 'sm',
closeOnBackdrop = true,
closeOnEscape = true,
}) => (
<Modal
isOpen={isOpen}
title={title}
size={size}
onClose={onCancel}
closeOnBackdrop={closeOnBackdrop}
closeOnEscape={closeOnEscape}
footer={(
<>
<ButtonComponent
label={cancelLabel}
buttonHierarchy={'secondary'}
onClick={onCancel}
/>
<ButtonComponent
label={confirmLabel}
buttonHierarchy={confirmHierarchy}
onClick={onConfirm}
/>
</>
)}
>
{message}
</Modal>
)
export { ConfirmDialog }

View File

@ -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<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
}
const Modal: FC<ModalProps> = ({
isOpen = false,
title,
children,
footer,
onClose,
closeOnBackdrop = true,
closeOnEscape = true,
size = 'md',
className = '',
}) => {
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(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 (
<div
className={'fixed inset-0 z-50 flex items-center justify-center p-4'}
role={'presentation'}
>
<div
className={'absolute inset-0 bg-black/40 backdrop-blur-sm'}
aria-hidden={true}
onClick={closeOnBackdrop ? handleClose : undefined}
/>
<div
ref={panelRef}
role={'dialog'}
aria-modal={true}
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
className={[
'relative z-10 w-full rounded-lg bg-white shadow-xl outline-none',
sizeClass[size],
className,
].filter(Boolean).join(' ')}
>
{(title !== undefined && title !== null && title !== '') || onClose ? (
<div className={'flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4'}>
<h2 id={titleId} className={'text-lg font-semibold text-gray-900'}>
{title}
</h2>
{onClose ? (
<button
type={'button'}
className={'text-2xl leading-none text-gray-500 hover:text-gray-800'}
aria-label={'Close'}
onClick={handleClose}
>
&times;
</button>
) : null}
</div>
) : null}
<div className={'px-5 py-4 text-gray-700'}>
{children}
</div>
{footer ? (
<div className={'flex flex-wrap items-center justify-end gap-2 border-t border-gray-200 px-5 py-4'}>
{footer}
</div>
) : null}
</div>
</div>
)
}
export { Modal }

View File

@ -0,0 +1,4 @@
export { Modal } from './Modal'
export type { ModalProps, ModalSize } from './Modal'
export { ConfirmDialog } from './ConfirmDialog'
export type { ConfirmDialogProps } from './ConfirmDialog'

View File

@ -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<RatingsSize, string> = {
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<RatingsProps> = ({
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<number | null>(null)
const displayValue = hovered ?? value
const starSize = sizeClass[size]
return (
<div
className={['inline-flex items-center gap-0.5', className].filter(Boolean).join(' ')}
role={interactive ? 'radiogroup' : 'img'}
aria-label={label}
aria-valuetext={`${clamp(value, 0, max)} out of ${max}`}
onMouseLeave={() => {
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 = (
<>
<Star
className={[starSize, emptyClassName].join(' ')}
strokeWidth={1.5}
aria-hidden={true}
/>
{fillRatio > 0 ? (
<span
className={'pointer-events-none absolute left-0.5 top-0.5 overflow-hidden'}
style={{ width: `calc(${fillRatio} * (100% - 0.25rem))` }}
>
<Star
className={[
starSize,
'fill-current',
isHoveredStar ? hoverClassName : filledClassName,
].join(' ')}
strokeWidth={1.5}
aria-hidden={true}
/>
</span>
) : null}
</>
)
if (!interactive) {
return (
<span key={starValue} className={'relative inline-flex p-0.5'}>
{icon}
</span>
)
}
return (
<button
key={starValue}
type={'button'}
role={'radio'}
aria-checked={Math.round(value) === starValue}
aria-label={`${starValue} ${starValue === 1 ? 'star' : 'stars'}`}
className={'relative inline-flex cursor-pointer p-0.5'}
onMouseEnter={() => setHovered(starValue)}
onFocus={() => setHovered(starValue)}
onBlur={() => setHovered(null)}
onClick={() => onChange?.(starValue)}
>
{icon}
</button>
)
})}
</div>
)
}
export { Ratings }

View File

@ -0,0 +1,2 @@
export { Ratings } from './Ratings'
export type { RatingsProps, RatingsSize } from './Ratings'

View File

@ -1,36 +1,42 @@
import React, { useRef, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { colSpanClass, type GridColSpan } from '../../functions' import { colSpanClass, type GridColSpan } from '../../functions'
import { ButtonComponent } from './ButtonComponent' import { ButtonComponent } from './ButtonComponent'
import { Trash2 } from 'lucide-react' import { Trash2, Upload } from 'lucide-react'
interface FileUploadComponentProps { interface FileUploadComponentProps {
label?: string label?: string
colspan?: GridColSpan colspan?: GridColSpan
multiple?: boolean multiple?: boolean
/** Comma-separated accept list for the file input (e.g. `.pdf,image/*`). */
accept?: string
files?: File[] files?: File[]
onChange?: (files: File[]) => void onChange?: (files: File[]) => void
disabled?: boolean disabled?: boolean
/** When true (default), the control accepts drag-and-drop. */
droppable?: boolean
} }
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
label = 'Select files', label = 'Select files',
colspan = 6, colspan = 6,
multiple = true, multiple = true,
accept,
files, files,
onChange, onChange,
disabled = false, disabled = false,
droppable = true,
}) => { }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [showPopup, setShowPopup] = useState(false) const [showPopup, setShowPopup] = useState(false)
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0}) const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
const [dragActive, setDragActive] = useState(false)
const popupRef = useRef<HTMLDivElement>(null) const popupRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
// Focus popup when it opens
React.useEffect(() => { React.useEffect(() => {
if (showPopup && popupRef.current) { if (showPopup && popupRef.current)
popupRef.current.focus() popupRef.current.focus()
}
}, [showPopup]) }, [showPopup])
const areFilesEqual = (left: File[], right: File[]) => const areFilesEqual = (left: File[], right: File[]) =>
@ -46,66 +52,127 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const displayFiles = files ?? selectedFiles const displayFiles = files ?? selectedFiles
// Keep native input in sync for controlled resets.
React.useEffect(() => { React.useEffect(() => {
if (files !== undefined && files.length === 0 && inputRef.current) if (files !== undefined && files.length === 0 && inputRef.current)
inputRef.current.value = '' inputRef.current.value = ''
}, [files]) }, [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<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextFiles = e.target.files ? Array.from(e.target.files) : [] const nextFiles = e.target.files ? Array.from(e.target.files) : []
applyFiles(nextFiles)
if (files === undefined) {
setSelectedFiles(nextFiles)
}
if (!areFilesEqual(nextFiles, displayFiles)) {
onChange?.(nextFiles)
}
} }
const handleClear = () => { const handleClear = () => {
if (files === undefined) { if (files === undefined)
setSelectedFiles([]) setSelectedFiles([])
}
if (inputRef.current) inputRef.current.value = '' if (inputRef.current)
inputRef.current.value = ''
if (displayFiles.length > 0) { if (displayFiles.length > 0)
onChange?.([]) onChange?.([])
}
} }
const handleSelectFiles = () => { 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 ( return (
<div className={`grid grid-cols-4 gap-2 ${colSpanClass(colspan)}`}> <div
{/* File input (hidden) */} className={[
'grid grid-cols-4 gap-2 rounded border border-dashed p-2 transition-colors',
colSpanClass(colspan),
dragActive
? 'border-sky-500 bg-sky-50'
: droppable && !disabled
? 'border-gray-300'
: 'border-transparent',
].filter(Boolean).join(' ')}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input <input
ref={inputRef} ref={inputRef}
type={'file'} type={'file'}
multiple={multiple} multiple={multiple}
accept={accept}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileChange} onChange={handleFileChange}
disabled={disabled} disabled={disabled}
/> />
{/* Files counter with hover popup */}
<div <div
className={'col-span-1 flex items-center justify-center relative'} className={'col-span-1 flex items-center justify-center relative'}
onMouseEnter={e => { onMouseEnter={e => {
setShowPopup(true) setShowPopup(true)
if (!showPopup) { if (!showPopup)
setPopupPos({ x: e.clientX, y: e.clientY }) setPopupPos({ x: e.clientX, y: e.clientY })
}
}} }}
onMouseLeave={e => { 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) setShowPopup(false)
}
}} }}
> >
<span <span
@ -129,20 +196,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
outline: 'none', outline: 'none',
}} }}
onBlur={e => { 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) setShowPopup(false)
}
}} }}
onMouseLeave={e => { onMouseLeave={e => {
// Only close if not moving back to counter
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1') const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
if (!parent) setShowPopup(false) if (!parent)
setShowPopup(false)
}} }}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Escape') setShowPopup(false) if (e.key === 'Escape')
setShowPopup(false)
}} }}
onFocus={() => {}}
> >
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}> <ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
{displayFiles.map((file, idx) => ( {displayFiles.map((file, idx) => (
@ -153,11 +218,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
</ul> </ul>
</div> </div>
)} )}
</div> </div>
{/* Clear selection button */}
<ButtonComponent <ButtonComponent
buttonHierarchy={'secondary'} buttonHierarchy={'secondary'}
onClick={handleClear} onClick={handleClear}
@ -167,17 +229,20 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<Trash2 /> <Trash2 />
</ButtonComponent> </ButtonComponent>
{/* Select files button */}
<ButtonComponent <ButtonComponent
colspan={2} colspan={2}
children={label}
buttonHierarchy={'primary'} buttonHierarchy={'primary'}
onClick={handleSelectFiles} onClick={handleSelectFiles}
disabled={disabled} disabled={disabled}
/> >
<span className={'inline-flex items-center gap-2'}>
<Upload className={'h-4 w-4'} />
{dragActive ? 'Drop files' : label}
</span>
</ButtonComponent>
</div> </div>
) )
} }
export { FileUploadComponent } export { FileUploadComponent }
export type { FileUploadComponentProps }

View File

@ -34,6 +34,7 @@ export {
} }
export type { SecretDataSource, SecretComponentProps } from './SecretComponent' export type { SecretDataSource, SecretComponentProps } from './SecretComponent'
export type { FileUploadComponentProps } from './FileUploadComponent'
export type { export type {
RemoteSelectBoxProps, RemoteSelectBoxProps,
RemoteSelectSearchDataSource, RemoteSelectSearchDataSource,

View File

@ -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 }) => (
<svg
className={className}
viewBox={'0 0 24 24'}
fill={'currentColor'}
aria-hidden={true}
>
<path d={'M12 2C6.36 2 2 6.13 2 11.7c0 2.91 1.19 5.44 3.14 7.17V22l2.87-1.57c1.18.33 2.43.5 3.99.5 5.64 0 10-4.13 10-9.7S17.64 2 12 2zm1.01 12.93l-2.54-2.7-4.96 2.7 5.45-5.78 2.6 2.7 4.9-2.7-5.45 5.78z'} />
</svg>
)
const buildMessengerHref = (page: string): string =>
`https://m.me/${encodeURIComponent(page.replace(/^@/, ''))}`
const FacebookMessengerButton: FC<FacebookMessengerButtonProps> = ({
page,
title = 'Messenger',
className = '',
floating = true,
}) => (
<a
href={buildMessengerHref(page)}
rel={'nofollow noopener noreferrer'}
target={'_blank'}
className={[
'inline-flex items-center gap-2 rounded-full bg-[#0084FF] px-4 py-3 font-medium text-white shadow-md hover:bg-[#0073e0]',
floating ? 'fixed bottom-6 right-6 z-40' : '',
className,
].filter(Boolean).join(' ')}
>
<MessengerIcon className={'h-5 w-5 shrink-0'} />
<span>{title}</span>
</a>
)
export { FacebookMessengerButton, buildMessengerHref }

View File

@ -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 }) => (
<svg
className={className}
viewBox={'0 0 24 24'}
fill={'currentColor'}
aria-hidden={true}
>
<path d={'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'} />
</svg>
)
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<WhatsAppButtonProps> = ({
number,
text,
title = 'WhatsApp',
className = '',
floating = true,
}) => {
const href = buildWhatsAppHref(number, text)
return (
<a
href={href}
rel={'nofollow noopener noreferrer'}
target={'_blank'}
className={[
'inline-flex items-center gap-2 rounded-full bg-[#25D366] px-4 py-3 font-medium text-white shadow-md hover:bg-[#1ebe57]',
floating ? 'fixed bottom-6 right-6 z-40' : '',
className,
].filter(Boolean).join(' ')}
>
<WhatsAppIcon className={'h-5 w-5 shrink-0'} />
<span>{title}</span>
</a>
)
}
export { WhatsAppButton, buildWhatsAppHref }

View File

@ -0,0 +1,4 @@
export { WhatsAppButton, buildWhatsAppHref } from './WhatsAppButton'
export type { WhatsAppButtonProps } from './WhatsAppButton'
export { FacebookMessengerButton, buildMessengerHref } from './FacebookMessengerButton'
export type { FacebookMessengerButtonProps } from './FacebookMessengerButton'

View File

@ -4,7 +4,38 @@ export { SecretComponent } from './components/editors/SecretComponent'
export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent' export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent'
export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout' export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout'
export { Offcanvas } from './components/Offcanvas' 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 { LazyLoadTable } from './components/LazyLoadTable'
export { FileBrowser } from './components/FileBrowser'
export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './components/FileBrowser'
export { export {
DataTable, DataTable,
DataTableFilter, DataTableFilter,

View File

@ -1,3 +1,3 @@
export interface ResponseModelBase { export interface ResponseModelBase {
[key: string]: unknown [key: string]: unknown
} }

14
src/core/hooks/index.ts Normal file
View File

@ -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'

View File

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
/**
* Debounces `value` by `delayMs`. Useful for search inputs and remote filters.
*/
const useDebounce = <T>(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 }

View File

@ -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 = <T extends Element = HTMLElement>(): [
RefCallback<T>,
boolean,
] => {
const [isHovered, setIsHovered] = useState(false)
const nodeRef = useRef<T | null>(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 }

View File

@ -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 }

View File

@ -0,0 +1,89 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
const readStorage = <T>(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 = <T>(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 = <T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] => {
const initialRef = useRef(initialValue)
initialRef.current = initialValue
const [storedValue, setStoredValue] = useState<T>(() =>
readStorage(key, initialValue),
)
const setValue: Dispatch<SetStateAction<T>> = 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 }

View File

@ -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<number | undefined>(undefined)
const targetRef = useRef<EventTarget | null>(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 }

View File

@ -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 = <T>(
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<T>(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 }

View File

@ -0,0 +1,39 @@
import {
type RefObject,
useEffect,
useRef,
} from 'react'
/**
* Invokes `handler` when a pointer/touch event occurs outside `ref`.
*/
const useOnClickOutside = <T extends Element>(
ref: RefObject<T | null>,
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 }

View File

@ -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 = <T extends Element>(
ref: RefObject<T | null>,
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 }

View File

@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
/**
* Returns the value from the previous render (undefined on first render).
*/
const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T | undefined>(undefined)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export { usePrevious }

View File

@ -0,0 +1,90 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
const readStorage = <T>(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 = <T>(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 = <T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] => {
const initialRef = useRef(initialValue)
initialRef.current = initialValue
const [storedValue, setStoredValue] = useState<T>(() =>
readStorage(key, initialValue),
)
const setValue: Dispatch<SetStateAction<T>> = 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 }

View File

@ -3,5 +3,23 @@ export * from './types'
export * from './http' export * from './http'
export * from './signalr' export * from './signalr'
export { readIdentity, writeIdentity, removeIdentity } from './localStorage/identity' 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' export * from './oauth'

View File

@ -1,15 +1,6 @@
/** @type {import('jest').Config} */ const shared = {
module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'], roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'contracts/**/*.ts',
'core/**/*.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['json-summary', 'text'],
moduleNameMapper: { moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1', '^(\\.{1,2}/.*)\\.js$': '$1',
'^@webui/contracts$': '<rootDir>/contracts/index.ts', '^@webui/contracts$': '<rootDir>/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: [
'<rootDir>/test/contracts/**/*.test.ts',
'<rootDir>/test/core/functions/**/*.test.ts',
'<rootDir>/test/core/http/**/*.test.ts',
'<rootDir>/test/core/signalr/**/*.test.ts',
],
},
{
...shared,
displayName: 'jsdom',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
testMatch: [
'<rootDir>/test/core/hooks/**/*.test.ts',
'<rootDir>/test/components/**/*.test.ts',
],
},
],
}

1259
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@maks-it.com/webui", "name": "@maks-it.com/webui",
"version": "0.4.0", "version": "0.4.1",
"description": "Shared contracts, utilities, and React components for MaksIT WebUI apps", "description": "Shared contracts, utilities, and React components for MaksIT WebUI apps",
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
@ -48,7 +48,7 @@
"directory": "src" "directory": "src"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.3.0" "date-fns": "^4.4.0"
}, },
"peerDependencies": { "peerDependencies": {
"@microsoft/signalr": "^8.0.0", "@microsoft/signalr": "^8.0.0",
@ -63,38 +63,41 @@
}, },
"devDependencies": { "devDependencies": {
"@microsoft/signalr": "^8.0.7", "@microsoft/signalr": "^8.0.7",
"@storybook/addon-a11y": "^10.4.1", "@storybook/addon-a11y": "^10.5.4",
"@storybook/addon-docs": "^10.4.1", "@storybook/addon-docs": "^10.5.4",
"@storybook/addon-vitest": "^10.4.1", "@storybook/addon-vitest": "^10.5.4",
"@storybook/react-vite": "^10.4.1", "@storybook/react-vite": "^10.5.4",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.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/jest": "^30.0.0",
"@types/mockdate": "^2.0.0", "@types/mockdate": "^2.0.0",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/react": "^19.2.15", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-virtualized": "^9.22.3", "@types/react-virtualized": "^9.22.3",
"@vitest/browser-playwright": "^4.1.7", "@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.10",
"axios": "^1.16.1", "axios": "^1.18.1",
"jest": "^30.4.2", "jest": "^30.4.2",
"lucide-react": "^1.16.0", "jest-environment-jsdom": "^30.4.1",
"lucide-react": "^1.26.0",
"mockdate": "^3.0.5", "mockdate": "^3.0.5",
"msw": "^2.14.6", "msw": "^2.15.0",
"msw-storybook-addon": "^2.0.7", "msw-storybook-addon": "^2.0.7",
"playwright": "^1.60.0", "playwright": "^1.61.1",
"react": "^19.2.6", "react": "^19.2.8",
"react-dom": "^19.2.6", "react-dom": "^19.2.8",
"react-router-dom": "^7.15.1", "react-router-dom": "^7.18.1",
"react-virtualized": "^9.22.6", "react-virtualized": "^9.22.6",
"storybook": "^10.4.1", "storybook": "^10.5.4",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.3",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.12",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^6.4.2", "vite": "^6.4.3",
"vitest": "^4.1.7", "vitest": "^4.1.10",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"author": "MaksIT", "author": "MaksIT",

View File

@ -7,8 +7,8 @@
* - Please do NOT modify this file. * - Please do NOT modify this file.
*/ */
const PACKAGE_VERSION = '2.14.6' const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set() const activeClientIds = new Set()
@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
if (client && activeClientIds.has(client.id)) { if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents) 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. // Clone the response so both the client and the library could consume it.
const responseClone = response.clone() const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient( sendToClient(
client, client,
@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest, ...serializedRequest,
}, },
response: { response: {
type: responseClone.type, type: response.type,
status: responseClone.status, status: response.status,
statusText: responseClone.statusText, statusText: response.statusText,
headers: Object.fromEntries(responseClone.headers.entries()), headers: Object.fromEntries(response.headers.entries()),
body: responseClone.body, body: responseClone ? responseClone.body : null,
}, },
}, },
}, },
responseClone.body ? [serializedRequest.body, responseClone.body] : [], responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
) )
} }

View File

@ -24,7 +24,7 @@ components/components/ stories/components/
└── LazyLoadTable.tsx └── LazyLoadTable.stories.tsx └── 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 ## Imports

View File

@ -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<typeof meta>
export const Default: Story = {
render: () => (
<div className="min-h-[240px] bg-gray-50 p-6">
<p className="text-gray-600">Page content</p>
<CookieConsent
cookieName={COOKIE_NAME}
message="We use cookies to improve your experience."
links={[
{ href: '/privacy', label: 'Privacy policy' },
{ to: '/cookies', label: 'Cookie policy' },
]}
/>
</div>
),
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()
},
}

View File

@ -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) => (
<div className="flex h-[480px] w-full flex-col">
<Story />
</div>
),
],
} satisfies Meta<typeof FileBrowser>
export default meta
type Story = StoryObj<typeof meta>
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()
},
}

View File

@ -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<typeof meta>
export const Gallery: Story = {
render: () => <LightBox slides={slides} />,
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()
},
}

View File

@ -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<typeof meta>
export const Visible: Story = {
render: () => (
<div className="relative h-[400px] w-full bg-gray-100">
<p className="p-6 text-gray-700">Page content behind the overlay.</p>
<Loader visible label="Loading…" />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByText('Loading…')).toBeVisible()
},
}
export const InlineSpinner: Story = {
render: () => (
<div className="flex items-center gap-6 p-8">
<Spinner size="sm" />
<Spinner size="md" label="Working…" />
<Spinner size="lg" />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByText('Working…')).toBeVisible()
},
}
export const EventDriven: Story = {
render: () => (
<div className="relative h-[400px] w-full space-y-3 bg-gray-100 p-6">
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded bg-blue-500 px-3 py-2 text-white" onClick={() => showLoader()}>
showLoader
</button>
<button type="button" className="rounded bg-gray-600 px-3 py-2 text-white" onClick={() => hideLoader()}>
hideLoader
</button>
<button type="button" className="rounded bg-yellow-600 px-3 py-2 text-white" onClick={() => disableLoader()}>
disableLoader
</button>
<button type="button" className="rounded bg-green-600 px-3 py-2 text-white" onClick={() => enableLoader()}>
enableLoader
</button>
</div>
<p className="text-sm text-gray-600">Ref-counted overlay call show twice, then hide twice.</p>
<Loader label="Please wait…" />
</div>
),
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()
},
}

View File

@ -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<typeof meta>
export const Basic: Story = {
render: () => (
<Masonry breakpoints={[400, 700]}>
{colors.map((className, index) => (
<div key={index} data-testid={`tile-${index}`} className={`w-full rounded ${className}`} />
))}
</Masonry>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByTestId('tile-0')).toBeVisible()
await expect(canvas.getByTestId('tile-5')).toBeVisible()
},
}

View File

@ -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) => (
<div className="relative min-h-[320px] w-full rounded border border-gray-300 bg-gray-50 p-6">
<Story />
</div>
)
function ModalDemo({ startOpen = false }: { startOpen?: boolean }) {
const [isOpen, setIsOpen] = useState(startOpen)
return (
<>
<button
type="button"
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
onClick={() => setIsOpen(true)}
>
Open modal
</button>
<Modal
isOpen={isOpen}
title="Edit item"
onClose={() => setIsOpen(false)}
footer={(
<>
<ButtonComponent label="Cancel" buttonHierarchy="secondary" onClick={() => setIsOpen(false)} />
<ButtonComponent label="Save" buttonHierarchy="primary" onClick={() => setIsOpen(false)} />
</>
)}
>
<p>Centered dialog with title, body, and footer actions.</p>
</Modal>
</>
)
}
function ConfirmDemo({ startOpen = false }: { startOpen?: boolean }) {
const [isOpen, setIsOpen] = useState(startOpen)
return (
<>
<button
type="button"
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
onClick={() => setIsOpen(true)}
>
Delete
</button>
<ConfirmDialog
isOpen={isOpen}
title="Delete item?"
message="This action cannot be undone."
confirmLabel="Delete"
confirmHierarchy="error"
onCancel={() => 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<typeof meta>
export const Interactive: Story = {
render: () => <ModalDemo />,
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: () => <ModalDemo startOpen />,
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: () => <ConfirmDemo />,
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: () => <ConfirmDemo startOpen />,
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()
},
}

View File

@ -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<typeof meta>
export const ReadOnly: Story = {
render: () => <Ratings value={3.5} label="Product rating" />,
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 (
<div className="space-y-2">
<Ratings value={value} onChange={setValue} />
<p className="text-sm text-gray-600">Selected: {value}</p>
</div>
)
},
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: () => (
<div className="flex flex-col gap-3">
<Ratings value={4} size="sm" label="Small rating" />
<Ratings value={4} size="md" label="Medium rating" />
<Ratings value={4} size="lg" label="Large rating" />
</div>
),
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()
},
}

View File

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite' import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, within } from 'storybook/test'
import { FileUploadComponent } from '@webui/components/components/editors/FileUploadComponent' import { FileUploadComponent } from '@webui/components/components/editors/FileUploadComponent'
import { withControlledFiles } from '../../helpers/controlledEditors' import { withControlledFiles } from '../../helpers/controlledEditors'
@ -8,6 +9,14 @@ const meta = {
title: 'components/editors/FileUpload', title: 'components/editors/FileUpload',
component: ControlledFileUpload, component: ControlledFileUpload,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'File picker with optional drag-and-drop (`droppable`, default true). Drop replaces the current selection.',
},
},
},
} satisfies Meta<typeof ControlledFileUpload> } satisfies Meta<typeof ControlledFileUpload>
export default meta export default meta
@ -18,6 +27,11 @@ export const Default: Story = {
label: 'Upload documents', label: 'Upload documents',
multiple: true, multiple: true,
files: [], 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 = { export const Disabled: Story = {
args: { args: {
label: 'Upload documents', label: 'Upload documents',

View File

@ -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<typeof meta>
export const WhatsAppInline: Story = {
render: () => (
<WhatsAppButton
floating={false}
number="15551234567"
text="Hello from the website"
title="Chat on WhatsApp"
/>
),
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: () => (
<FacebookMessengerButton
floating={false}
page="Meta"
title="Chat on Messenger"
/>
),
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: () => (
<div className="relative h-[280px] rounded border border-gray-200 bg-gray-50">
<WhatsAppButton
number="15551234567"
text="Hi"
title="WhatsApp"
className="!bottom-6 !right-6"
/>
<FacebookMessengerButton
page="Meta"
title="Messenger"
className="!bottom-6 !right-44"
/>
</div>
),
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',
)
},
}

View File

@ -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<typeof meta>
function DebounceDemo() {
const [value, setValue] = useState('')
const debounced = useDebounce(value, 300)
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Type to debounce
<input
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</label>
<p className="text-sm text-gray-600">
Live: <span data-testid="live">{value || '—'}</span>
</p>
<p className="text-sm text-gray-600">
Debounced: <span data-testid="debounced">{debounced || '—'}</span>
</p>
</div>
)
}
function StorageDemo() {
const [localValue, setLocalValue] = useLocalStorage('storybook.local', 0)
const [sessionValue, setSessionValue] = useSessionStorage('storybook.session', 0)
return (
<div className="flex flex-wrap gap-6">
<div className="space-y-2">
<p className="text-sm font-medium">localStorage: {localValue}</p>
<button
type="button"
className="rounded bg-sky-600 px-3 py-1.5 text-sm text-white"
onClick={() => setLocalValue((n) => n + 1)}
>
Increment local
</button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">sessionStorage: {sessionValue}</p>
<button
type="button"
className="rounded bg-emerald-600 px-3 py-1.5 text-sm text-white"
onClick={() => setSessionValue((n) => n + 1)}
>
Increment session
</button>
</div>
</div>
)
}
function HoverPreviousDemo() {
const [ref, isHovered] = useHover<HTMLDivElement>()
const [count, setCount] = useState(0)
const previous = usePrevious(count)
return (
<div className="space-y-4">
<div
ref={ref}
className={`rounded border px-4 py-6 text-center ${isHovered ? 'bg-amber-100 border-amber-400' : 'bg-gray-50 border-gray-200'}`}
>
{isHovered ? 'Hovered' : 'Hover me'}
</div>
<div className="flex items-center gap-3 text-sm">
<button
type="button"
className="rounded bg-gray-800 px-3 py-1.5 text-white"
onClick={() => setCount((n) => n + 1)}
>
Count: {count}
</button>
<span data-testid="previous">Previous: {previous ?? '—'}</span>
</div>
</div>
)
}
function IntervalDemo() {
const [ticks, setTicks] = useState(0)
const [running, setRunning] = useState(true)
useInterval(() => setTicks((n) => n + 1), running ? 400 : null)
return (
<div className="space-y-2">
<p data-testid="ticks">Ticks: {ticks}</p>
<button
type="button"
className="rounded bg-gray-800 px-3 py-1.5 text-sm text-white"
onClick={() => setRunning((v) => !v)}
>
{running ? 'Pause' : 'Resume'}
</button>
</div>
)
}
function MediaDemo() {
const label = useMedia(['(min-width: 768px)'], ['wide'], 'narrow')
return <p data-testid="media">Viewport: {label}</p>
}
function ClickOutsideDemo() {
const ref = useRef<HTMLDivElement>(null)
const [outside, setOutside] = useState(0)
useOnClickOutside(ref, () => setOutside((n) => n + 1))
return (
<div className="space-y-3">
<div ref={ref} className="rounded border border-sky-300 bg-sky-50 px-4 py-6">
Inside panel
</div>
<button type="button" className="rounded border px-3 py-1.5 text-sm" data-testid="outside-btn">
Outside button
</button>
<p data-testid="outside-count">Outside clicks: {outside}</p>
</div>
)
}
function LongPressDemo() {
const [message, setMessage] = useState('Idle')
const handlers = useLongPress({
delayMs: 400,
onLongPress: () => setMessage('Long press'),
onClick: () => setMessage('Click'),
})
return (
<div className="space-y-2">
<button type="button" className="rounded bg-violet-700 px-4 py-2 text-white" {...handlers}>
Press me
</button>
<p data-testid="longpress">{message}</p>
</div>
)
}
function OnScreenDemo() {
const ref = useRef<HTMLDivElement>(null)
const visible = useOnScreen(ref, { threshold: 0.1 })
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return (
<div className="space-y-2">
<p data-testid="onscreen">Visible: {mounted && visible ? 'yes' : 'no'}</p>
<div ref={ref} className="rounded bg-emerald-100 px-4 py-8 text-center">
Observed block
</div>
</div>
)
}
export const Debounce: Story = {
render: () => <DebounceDemo />,
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: () => <StorageDemo />,
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: () => <HoverPreviousDemo />,
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: () => <IntervalDemo />,
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: () => <MediaDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByTestId('media')).toHaveTextContent(/Viewport: (wide|narrow)/)
},
}
export const OnClickOutside: Story = {
render: () => <ClickOutsideDemo />,
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: () => <LongPressDemo />,
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: () => <OnScreenDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await expect(canvas.getByTestId('onscreen')).toHaveTextContent(/Visible: (yes|no)/)
},
}

View File

@ -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')
})
})

View File

@ -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'] },
])
})
})
})

View File

@ -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' })
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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<string, boolean>) => {
const listeners = new Map<string, Set<Listener>>()
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')
})
})

View File

@ -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<HTMLDivElement>()
// @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()
})
})

View File

@ -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<HTMLDivElement>()
// @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()
})
})

View File

@ -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)
})
})

View File

@ -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')
})
})

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@ -33,6 +33,7 @@
"components", "components",
"index.ts", "index.ts",
"test/**/*.ts", "test/**/*.ts",
"test/**/*.tsx",
"test/**/*.d.ts" "test/**/*.d.ts"
] ]
} }

View File

@ -1,9 +1,9 @@
{ {
"$schema": "https://json-schema.org/draft-07/schema", "$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings", "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": { "paths": {
"badgesDir": "..\\..\\..\\assets\\badges" "readmePath": "..\\..\\..\\README.md"
}, },
"plugins": [ "plugins": [
{ {
@ -25,20 +25,18 @@
"name": "CoverageBadges", "name": "CoverageBadges",
"stageLabel": "report", "stageLabel": "report",
"enabled": true, "enabled": true,
"badgesDir": "..\\..\\..\\assets\\badges", "badgeFormat": "shields",
"readmePath": "..\\..\\..\\README.md",
"badges": [ "badges": [
{ {
"name": "coverage-lines.svg",
"label": "Line Coverage", "label": "Line Coverage",
"metric": "line" "metric": "line"
}, },
{ {
"name": "coverage-branches.svg",
"label": "Branch Coverage", "label": "Branch Coverage",
"metric": "branch" "metric": "branch"
}, },
{ {
"name": "coverage-methods.svg",
"label": "Method Coverage", "label": "Method Coverage",
"metric": "method" "metric": "method"
} }

View File

@ -191,7 +191,7 @@ function Invoke-ContainerImagePush {
$primaryRef = "${baseName}:$($imageTags[0])" $primaryRef = "${baseName}:$($imageTags[0])"
Write-Log -Level 'STEP' -Message "Building $primaryRef via $engineName ..." 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) { if ($LASTEXITCODE -ne 0) {
throw "Container build failed for $primaryRef (exit code: $LASTEXITCODE)" throw "Container build failed for $primaryRef (exit code: $LASTEXITCODE)"
} }

View File

@ -30,11 +30,17 @@ function New-EngineContext {
$badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir))) $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]@{ return [pscustomobject]@{
scriptDir = $ScriptDir scriptDir = $ScriptDir
srcDir = $SrcDir srcDir = $SrcDir
utilsDir = $SrcDir utilsDir = $SrcDir
badgesDir = $badgesDir badgesDir = $badgesDir
readmePath = $readmePath
deployMode = $DeployMode deployMode = $DeployMode
} }
} }

View File

@ -6,7 +6,10 @@
Coverage badge plugin for the test engine. Coverage badge plugin for the test engine.
.DESCRIPTION .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)) { 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 { function Get-CoverageMetricsFromSharedContext {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -131,17 +207,9 @@ function Invoke-Plugin {
$scriptDir = $sharedSettings.scriptDir $scriptDir = $sharedSettings.scriptDir
$metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings $metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings
$badgesDir = $sharedSettings.badgesDir $badgeFormat = 'svg'
if ($pluginSettings.badgesDir) { if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) {
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) $badgeFormat = [string]$pluginSettings.badgeFormat
$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
} }
$thresholds = $pluginSettings.colorThresholds $thresholds = $pluginSettings.colorThresholds
@ -158,10 +226,50 @@ function Invoke-Plugin {
Write-Log -Level "STEP" -Message "Generating coverage badges..." 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)) { foreach ($badge in @($pluginSettings.badges)) {
$metricValue = $metrics[[string]$badge.metric] $metricValue = $metrics[[string]$badge.metric]
if ($null -eq $metricValue) { 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 $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds