mirror of
https://github.com/MAKS-IT-COM/maksit-webui.git
synced 2026-08-16 10:28:08 +02:00
(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
Some checks failed
Storybook tests / storybook-tests (push) Has been cancelled
This commit is contained in:
parent
25ae18bab6
commit
6474f1f7c4
33
CHANGELOG.md
33
CHANGELOG.md
@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.1] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- `FileBrowser` — full-featured **generic** storage browser (roots sidebar, breadcrumbs, sort/filter, multi-select delete, optional create folder / upload / download hooks, text/JSON preview). No product-specific OCI/catalog logic. Export: `FileBrowser`, `FileBrowserItem`, `FileBrowserProps`, `FileBrowserRoot`.
|
||||
- `Modal` / `ConfirmDialog` — centered dialog shell (Escape + backdrop close, title/body/footer) and confirm variant with primary/cancel actions.
|
||||
- `Loader` / `Spinner` — full-screen overlay loader with optional controlled `visible`, plus event helpers `showLoader` / `hideLoader` / `enableLoader` / `disableLoader` (ref-counted, matching host HTTP auth wiring). Inline `Spinner` for local loading UI.
|
||||
- `Masonry` — responsive column masonry layout (`breakpoints`).
|
||||
- `LightBox` — masonry gallery + fullscreen viewer (Escape / arrow keys, captions, thumbnails).
|
||||
- `CookieConsent` — consent bar with injectable `linkComponent` (no hard-coded react-router), plus `getCookie` / `setCookie` helpers.
|
||||
- `WhatsAppButton` / `FacebookMessengerButton` — click-to-chat links (`wa.me` / `m.me`); optional floating corner placement. Intentionally not the deprecated Facebook Customer Chat SDK.
|
||||
- `Ratings` — star rating widget (fractional fill, optional interactive `onChange`, sizes).
|
||||
- Core hooks: `usePrevious`, `useInterval`, `useOnScreen`, `useLocalStorage`, `useSessionStorage`, `useLongPress`, `useDebounce`, `useOnClickOutside`, `useMedia`, `useHover` (typed, SSR-safe where relevant; storage hooks sync via `storage` events).
|
||||
- Jest coverage for new hooks and component helpers (`FileBrowser` format/prefs, cookie helpers, loader events, social href builders); Storybook interaction plays for new components and a `core/hooks` demo.
|
||||
|
||||
### Changed
|
||||
|
||||
- `FileUploadComponent` — drag-and-drop (`droppable`, default true), `accept` filter, drop-active highlight; drop replaces the current selection.
|
||||
- Dev dependencies refreshed within current majors (Storybook 10.5, React 19.2.8, Vitest 4.1.10, axios 1.18, lucide-react 1.26, etc.). Held majors: SignalR 8, TypeScript 6, Vite 6.
|
||||
- Coverage badges: `CoverageBadges` uses `badgeFormat: shields` (updates `README.md` img.shields.io URLs) instead of writing SVG files under `assets/badges/`.
|
||||
|
||||
### Removed
|
||||
|
||||
- `ResponseModelBaseSchema` — empty Zod marker; keep the `ResponseModelBase` TypeScript type only (matches Core).
|
||||
- `assets/badges/` — local SVG coverage badges (replaced by shields.io).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `LightBox`: fullscreen viewer footer uses a fixed-height horizontal filmstrip instead of a second Masonry grid (which crushed the main image).
|
||||
- `DataTable`: resync column widths when `columns.length` changes; `columnWidth` falls back to default so react-virtualized never receives `undefined`.
|
||||
|
||||
## [0.4.0] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
@ -14,7 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- Release pipeline (`utils/engines/release/scriptSettings.json`) publishes one package; `NpmReleaseVersion.syncWorkspaceVersions` disabled.
|
||||
- Jest, Vitest, and Storybook configs updated for the flat `src/` layout (`@webui/*` aliases point at `src/{contracts,core,components}`).
|
||||
- VS Code/Cursor: TypeScript SDK and integrated terminal default to `src/`.
|
||||
- `src/coverage/` explicitly gitignored; generated coverage is no longer tracked (README badges remain under `assets/badges/`).
|
||||
- `src/coverage/` explicitly gitignored; generated coverage is no longer tracked.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# MaksIT.WebUI
|
||||
|
||||
  
|
||||

|
||||

|
||||

|
||||
|
||||
Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
|
||||
|
||||
@ -24,7 +26,7 @@ npm run storybook
|
||||
|
||||
**Storybook** (`npm run storybook`) runs a local component catalog with Tailwind, React Router, autodocs, a11y checks, and **Vitest component tests** (testing widget + `npm run test-storybook`). Stories live under `src/stories/components/`; see `src/stories/README.md`.
|
||||
|
||||
Tests and coverage badges: **`utils\Invoke-TestEngine.bat`** (plugin config in `utils\engines\test\scriptSettings.json`; uses `NpmJestTest`).
|
||||
Tests and coverage badges (shields.io URLs in this README): **`utils\Invoke-TestEngine.bat`** (plugin config in `utils\engines\test\scriptSettings.json`; uses `NpmJestTest` + `CoverageBadges` with `badgeFormat: shields`).
|
||||
|
||||
## Release to npmjs
|
||||
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -16,8 +16,8 @@ Peer dependencies must be installed in the host app.
|
||||
| Area | Examples |
|
||||
|------|----------|
|
||||
| Contracts | `PagedRequest`, `PatchOperation`, `LoginRequestSchema`, OAuth DTOs |
|
||||
| Core | `deepDelta`, `useFormState`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers |
|
||||
| Components | `DataTable`, `Layout`, editors, `ExternalLoginButtons`, `IdpLoginCallbackHandler` |
|
||||
| Core | `deepDelta`, `useFormState`, `useLocalStorage`, `useSessionStorage`, `useOnScreen`, `useInterval`, `usePrevious`, `useLongPress`, `useDebounce`, `useOnClickOutside`, `useMedia`, `useHover`, `createWebUiHttpClient`, `useWebUiHub`, ACL parsers |
|
||||
| Components | `DataTable`, `FileBrowser` (create/upload/download hooks), `Modal`, `Loader`, `Masonry`, `LightBox`, `CookieConsent`, `Ratings`, social chat buttons, `Layout`, editors, OAuth UI |
|
||||
|
||||
## Example — imports
|
||||
|
||||
@ -45,6 +45,26 @@ if (deltaHasOperations(delta)) {
|
||||
}
|
||||
```
|
||||
|
||||
**Request DTOs** that extend `RequestModelBase` (or `PatchRequestModelBase` for PATCH) must use the matching base Zod schema — `RequestModelBaseSchema` or `PatchRequestModelBaseSchema` — not a plain `z.object({...})`. Otherwise `safeParse` strips unknown keys (including PATCH `operations`). Response DTOs only need the `ResponseModelBase` TypeScript marker; there is no response base schema.
|
||||
|
||||
```ts
|
||||
import { RequestModelBaseSchema, PatchRequestModelBaseSchema } from '@maks-it.com/webui'
|
||||
|
||||
export const CreateEntityRequestSchema = RequestModelBaseSchema.and(
|
||||
z.object({ name: z.string().min(1) }),
|
||||
)
|
||||
|
||||
export const PatchEntityScopeRequestSchema = PatchRequestModelBaseSchema.and(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
_deltaId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
entityType: z.number().optional(),
|
||||
scope: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Repository
|
||||
|
||||
[github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/`
|
||||
|
||||
138
src/components/components/CookieConsent/CookieConsent.tsx
Normal file
138
src/components/components/CookieConsent/CookieConsent.tsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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 }
|
||||
31
src/components/components/CookieConsent/cookies.ts
Normal file
31
src/components/components/CookieConsent/cookies.ts
Normal 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`
|
||||
}
|
||||
3
src/components/components/CookieConsent/index.ts
Normal file
3
src/components/components/CookieConsent/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { CookieConsent } from './CookieConsent'
|
||||
export type { CookieConsentProps, CookieConsentLink } from './CookieConsent'
|
||||
export { getCookie, setCookie } from './cookies'
|
||||
@ -128,6 +128,33 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
|
||||
}
|
||||
}, [colWidths, storageKey])
|
||||
|
||||
// Keep widths in sync when column definitions change (e.g. parent swaps tables without remounting).
|
||||
useEffect(() => {
|
||||
const expected = columns.length + 1
|
||||
setColWidths((prev) => {
|
||||
if (prev.length === expected && prev.every((w) => typeof w === 'number' && Number.isFinite(w)))
|
||||
return prev
|
||||
|
||||
const defaults = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)]
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
if (Array.isArray(parsed) && parsed.length === expected && parsed.every((w) => typeof w === 'number' && Number.isFinite(w)))
|
||||
return parsed
|
||||
localStorage.removeItem(storageKey)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// fall through to defaults
|
||||
}
|
||||
}
|
||||
|
||||
return defaults
|
||||
})
|
||||
}, [columns.length, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
const el = filterMeasureRef.current
|
||||
if (!el || columns.length === 0) return
|
||||
@ -429,7 +456,7 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
|
||||
ref={gridRef}
|
||||
cellRenderer={cellRenderer}
|
||||
columnCount={columns.length + 1}
|
||||
columnWidth={({ index }) => colWidths[index]}
|
||||
columnWidth={({ index }) => colWidths[index] ?? DEFAULT_COL_WIDTH}
|
||||
fixedColumnCount={1}
|
||||
fixedRowCount={HEADER_ROWS}
|
||||
height={height}
|
||||
|
||||
774
src/components/components/FileBrowser/FileBrowser.tsx
Normal file
774
src/components/components/FileBrowser/FileBrowser.tsx
Normal 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 }
|
||||
150
src/components/components/FileBrowser/format.ts
Normal file
150
src/components/components/FileBrowser/format.ts
Normal 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,
|
||||
}
|
||||
2
src/components/components/FileBrowser/index.ts
Normal file
2
src/components/components/FileBrowser/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FileBrowser } from './FileBrowser'
|
||||
export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './types'
|
||||
40
src/components/components/FileBrowser/prefs.ts
Normal file
40
src/components/components/FileBrowser/prefs.ts
Normal 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,
|
||||
}
|
||||
48
src/components/components/FileBrowser/types.ts
Normal file
48
src/components/components/FileBrowser/types.ts
Normal 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>
|
||||
}
|
||||
166
src/components/components/LightBox/LightBox.tsx
Normal file
166
src/components/components/LightBox/LightBox.tsx
Normal 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 }
|
||||
2
src/components/components/LightBox/index.ts
Normal file
2
src/components/components/LightBox/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { LightBox } from './LightBox'
|
||||
export type { LightBoxProps, LightBoxSlide } from './LightBox'
|
||||
105
src/components/components/Loader/Loader.tsx
Normal file
105
src/components/components/Loader/Loader.tsx
Normal 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 }
|
||||
39
src/components/components/Loader/Spinner.tsx
Normal file
39
src/components/components/Loader/Spinner.tsx
Normal 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 }
|
||||
10
src/components/components/Loader/index.ts
Normal file
10
src/components/components/Loader/index.ts
Normal 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'
|
||||
27
src/components/components/Loader/loaderEvents.ts
Normal file
27
src/components/components/Loader/loaderEvents.ts
Normal 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
|
||||
73
src/components/components/Masonry/Masonry.tsx
Normal file
73
src/components/components/Masonry/Masonry.tsx
Normal 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]` → 1–4 columns. */
|
||||
breakpoints?: number[]
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
columnClassName?: string
|
||||
}
|
||||
|
||||
const getColumnCount = (width: number, breakpoints: number[]): number =>
|
||||
breakpoints.reduceRight((count, breakpoint, index) => (
|
||||
breakpoint < width ? count : index
|
||||
), breakpoints.length) + 1
|
||||
|
||||
const Masonry: FC<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 }
|
||||
2
src/components/components/Masonry/index.ts
Normal file
2
src/components/components/Masonry/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Masonry } from './Masonry'
|
||||
export type { MasonryProps } from './Masonry'
|
||||
58
src/components/components/Modal/ConfirmDialog.tsx
Normal file
58
src/components/components/Modal/ConfirmDialog.tsx
Normal 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 }
|
||||
130
src/components/components/Modal/Modal.tsx
Normal file
130
src/components/components/Modal/Modal.tsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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 }
|
||||
4
src/components/components/Modal/index.ts
Normal file
4
src/components/components/Modal/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { Modal } from './Modal'
|
||||
export type { ModalProps, ModalSize } from './Modal'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog'
|
||||
119
src/components/components/Ratings/Ratings.tsx
Normal file
119
src/components/components/Ratings/Ratings.tsx
Normal 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 }
|
||||
2
src/components/components/Ratings/index.ts
Normal file
2
src/components/components/Ratings/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Ratings } from './Ratings'
|
||||
export type { RatingsProps, RatingsSize } from './Ratings'
|
||||
@ -1,36 +1,42 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { colSpanClass, type GridColSpan } from '../../functions'
|
||||
import { ButtonComponent } from './ButtonComponent'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Trash2, Upload } from 'lucide-react'
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
label?: string
|
||||
colspan?: GridColSpan
|
||||
multiple?: boolean
|
||||
/** Comma-separated accept list for the file input (e.g. `.pdf,image/*`). */
|
||||
accept?: string
|
||||
files?: File[]
|
||||
onChange?: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
/** When true (default), the control accepts drag-and-drop. */
|
||||
droppable?: boolean
|
||||
}
|
||||
|
||||
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
label = 'Select files',
|
||||
colspan = 6,
|
||||
multiple = true,
|
||||
accept,
|
||||
files,
|
||||
onChange,
|
||||
disabled = false,
|
||||
droppable = true,
|
||||
}) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
|
||||
// Focus popup when it opens
|
||||
React.useEffect(() => {
|
||||
if (showPopup && popupRef.current) {
|
||||
if (showPopup && popupRef.current)
|
||||
popupRef.current.focus()
|
||||
}
|
||||
}, [showPopup])
|
||||
|
||||
const areFilesEqual = (left: File[], right: File[]) =>
|
||||
@ -46,66 +52,127 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
|
||||
const displayFiles = files ?? selectedFiles
|
||||
|
||||
// Keep native input in sync for controlled resets.
|
||||
React.useEffect(() => {
|
||||
if (files !== undefined && files.length === 0 && inputRef.current)
|
||||
inputRef.current.value = ''
|
||||
}, [files])
|
||||
|
||||
const applyFiles = useCallback((nextFiles: File[]) => {
|
||||
if (files === undefined)
|
||||
setSelectedFiles(nextFiles)
|
||||
|
||||
if (!areFilesEqual(nextFiles, displayFiles))
|
||||
onChange?.(nextFiles)
|
||||
}, [displayFiles, files, onChange])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFiles = e.target.files ? Array.from(e.target.files) : []
|
||||
|
||||
if (files === undefined) {
|
||||
setSelectedFiles(nextFiles)
|
||||
}
|
||||
|
||||
if (!areFilesEqual(nextFiles, displayFiles)) {
|
||||
onChange?.(nextFiles)
|
||||
}
|
||||
applyFiles(nextFiles)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (files === undefined) {
|
||||
if (files === undefined)
|
||||
setSelectedFiles([])
|
||||
}
|
||||
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
if (inputRef.current)
|
||||
inputRef.current.value = ''
|
||||
|
||||
if (displayFiles.length > 0) {
|
||||
if (displayFiles.length > 0)
|
||||
onChange?.([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
if (!disabled) inputRef.current?.click()
|
||||
if (!disabled)
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const resetDrag = () => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
if (!droppable || disabled)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current += 1
|
||||
if (e.dataTransfer.types.includes('Files'))
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!droppable || disabled)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0)
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!droppable || disabled)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!droppable || disabled)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
resetDrag()
|
||||
|
||||
const dropped = Array.from(e.dataTransfer.files ?? [])
|
||||
if (dropped.length === 0)
|
||||
return
|
||||
|
||||
applyFiles(multiple ? dropped : dropped.slice(0, 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-4 gap-2 ${colSpanClass(colspan)}`}>
|
||||
{/* File input (hidden) */}
|
||||
<div
|
||||
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
|
||||
ref={inputRef}
|
||||
type={'file'}
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Files counter with hover popup */}
|
||||
<div
|
||||
className={'col-span-1 flex items-center justify-center relative'}
|
||||
onMouseEnter={e => {
|
||||
setShowPopup(true)
|
||||
if (!showPopup) {
|
||||
if (!showPopup)
|
||||
setPopupPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
// Only close if not moving into popup
|
||||
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) {
|
||||
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node))
|
||||
setShowPopup(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@ -129,20 +196,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
outline: 'none',
|
||||
}}
|
||||
onBlur={e => {
|
||||
// Only close if focus moves outside popup and counter
|
||||
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) {
|
||||
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1')))
|
||||
setShowPopup(false)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
// Only close if not moving back to counter
|
||||
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
|
||||
if (!parent) setShowPopup(false)
|
||||
if (!parent)
|
||||
setShowPopup(false)
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') setShowPopup(false)
|
||||
if (e.key === 'Escape')
|
||||
setShowPopup(false)
|
||||
}}
|
||||
onFocus={() => {}}
|
||||
>
|
||||
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
|
||||
{displayFiles.map((file, idx) => (
|
||||
@ -153,11 +218,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Clear selection button */}
|
||||
<ButtonComponent
|
||||
buttonHierarchy={'secondary'}
|
||||
onClick={handleClear}
|
||||
@ -167,17 +229,20 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
<Trash2 />
|
||||
</ButtonComponent>
|
||||
|
||||
{/* Select files button */}
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
children={label}
|
||||
colspan={2}
|
||||
buttonHierarchy={'primary'}
|
||||
onClick={handleSelectFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
>
|
||||
<span className={'inline-flex items-center gap-2'}>
|
||||
<Upload className={'h-4 w-4'} />
|
||||
{dragActive ? 'Drop files' : label}
|
||||
</span>
|
||||
</ButtonComponent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { FileUploadComponent }
|
||||
export type { FileUploadComponentProps }
|
||||
|
||||
@ -34,6 +34,7 @@ export {
|
||||
}
|
||||
|
||||
export type { SecretDataSource, SecretComponentProps } from './SecretComponent'
|
||||
export type { FileUploadComponentProps } from './FileUploadComponent'
|
||||
export type {
|
||||
RemoteSelectBoxProps,
|
||||
RemoteSelectSearchDataSource,
|
||||
|
||||
46
src/components/components/social/FacebookMessengerButton.tsx
Normal file
46
src/components/components/social/FacebookMessengerButton.tsx
Normal 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 }
|
||||
58
src/components/components/social/WhatsAppButton.tsx
Normal file
58
src/components/components/social/WhatsAppButton.tsx
Normal 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 }
|
||||
4
src/components/components/social/index.ts
Normal file
4
src/components/components/social/index.ts
Normal 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'
|
||||
@ -4,7 +4,38 @@ export { SecretComponent } from './components/editors/SecretComponent'
|
||||
export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent'
|
||||
export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout'
|
||||
export { Offcanvas } from './components/Offcanvas'
|
||||
export { Modal, ConfirmDialog } from './components/Modal'
|
||||
export type { ModalProps, ModalSize, ConfirmDialogProps } from './components/Modal'
|
||||
export {
|
||||
Loader,
|
||||
Spinner,
|
||||
showLoader,
|
||||
hideLoader,
|
||||
enableLoader,
|
||||
disableLoader,
|
||||
} from './components/Loader'
|
||||
export type { LoaderProps, SpinnerProps, SpinnerSize } from './components/Loader'
|
||||
export { Masonry } from './components/Masonry'
|
||||
export type { MasonryProps } from './components/Masonry'
|
||||
export { LightBox } from './components/LightBox'
|
||||
export type { LightBoxProps, LightBoxSlide } from './components/LightBox'
|
||||
export { CookieConsent, getCookie, setCookie } from './components/CookieConsent'
|
||||
export type { CookieConsentProps, CookieConsentLink } from './components/CookieConsent'
|
||||
export {
|
||||
WhatsAppButton,
|
||||
buildWhatsAppHref,
|
||||
FacebookMessengerButton,
|
||||
buildMessengerHref,
|
||||
} from './components/social'
|
||||
export type {
|
||||
WhatsAppButtonProps,
|
||||
FacebookMessengerButtonProps,
|
||||
} from './components/social'
|
||||
export { Ratings } from './components/Ratings'
|
||||
export type { RatingsProps, RatingsSize } from './components/Ratings'
|
||||
export { LazyLoadTable } from './components/LazyLoadTable'
|
||||
export { FileBrowser } from './components/FileBrowser'
|
||||
export type { FileBrowserItem, FileBrowserProps, FileBrowserRoot } from './components/FileBrowser'
|
||||
export {
|
||||
DataTable,
|
||||
DataTableFilter,
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export interface ResponseModelBase {
|
||||
[key: string]: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
14
src/core/hooks/index.ts
Normal file
14
src/core/hooks/index.ts
Normal 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'
|
||||
20
src/core/hooks/useDebounce.ts
Normal file
20
src/core/hooks/useDebounce.ts
Normal 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 }
|
||||
44
src/core/hooks/useHover.ts
Normal file
44
src/core/hooks/useHover.ts
Normal 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 }
|
||||
25
src/core/hooks/useInterval.ts
Normal file
25
src/core/hooks/useInterval.ts
Normal 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 }
|
||||
89
src/core/hooks/useLocalStorage.ts
Normal file
89
src/core/hooks/useLocalStorage.ts
Normal 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 }
|
||||
108
src/core/hooks/useLongPress.ts
Normal file
108
src/core/hooks/useLongPress.ts
Normal 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 }
|
||||
67
src/core/hooks/useMedia.ts
Normal file
67
src/core/hooks/useMedia.ts
Normal 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 }
|
||||
39
src/core/hooks/useOnClickOutside.ts
Normal file
39
src/core/hooks/useOnClickOutside.ts
Normal 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 }
|
||||
58
src/core/hooks/useOnScreen.ts
Normal file
58
src/core/hooks/useOnScreen.ts
Normal 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 }
|
||||
16
src/core/hooks/usePrevious.ts
Normal file
16
src/core/hooks/usePrevious.ts
Normal 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 }
|
||||
90
src/core/hooks/useSessionStorage.ts
Normal file
90
src/core/hooks/useSessionStorage.ts
Normal 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 }
|
||||
@ -3,5 +3,23 @@ export * from './types'
|
||||
export * from './http'
|
||||
export * from './signalr'
|
||||
export { readIdentity, writeIdentity, removeIdentity } from './localStorage/identity'
|
||||
export { useFormState } from './hooks/useFormState'
|
||||
export {
|
||||
useFormState,
|
||||
usePrevious,
|
||||
useInterval,
|
||||
useOnScreen,
|
||||
useLocalStorage,
|
||||
useSessionStorage,
|
||||
useLongPress,
|
||||
useDebounce,
|
||||
useOnClickOutside,
|
||||
useMedia,
|
||||
useHover,
|
||||
} from './hooks'
|
||||
export type {
|
||||
UseFormStateProps,
|
||||
UseOnScreenOptions,
|
||||
UseLongPressOptions,
|
||||
UseLongPressHandlers,
|
||||
} from './hooks'
|
||||
export * from './oauth'
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
const shared = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/test'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'contracts/**/*.ts',
|
||||
'core/**/*.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['json-summary', 'text'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
'^@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
1259
src/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@maks-it.com/webui",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Shared contracts, utilities, and React components for MaksIT WebUI apps",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
@ -48,7 +48,7 @@
|
||||
"directory": "src"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.3.0"
|
||||
"date-fns": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@microsoft/signalr": "^8.0.0",
|
||||
@ -63,38 +63,41 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@storybook/addon-a11y": "^10.4.1",
|
||||
"@storybook/addon-docs": "^10.4.1",
|
||||
"@storybook/addon-vitest": "^10.4.1",
|
||||
"@storybook/react-vite": "^10.4.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@storybook/addon-a11y": "^10.5.4",
|
||||
"@storybook/addon-docs": "^10.5.4",
|
||||
"@storybook/addon-vitest": "^10.5.4",
|
||||
"@storybook/react-vite": "^10.5.4",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mockdate": "^2.0.0",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-virtualized": "^9.22.3",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"axios": "^1.16.1",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"axios": "^1.18.1",
|
||||
"jest": "^30.4.2",
|
||||
"lucide-react": "^1.16.0",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"lucide-react": "^1.26.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.14.6",
|
||||
"msw": "^2.15.0",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"playwright": "^1.60.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"playwright": "^1.61.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"storybook": "^10.4.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"ts-jest": "^29.4.11",
|
||||
"storybook": "^10.5.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^4.1.7",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^4.1.10",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"author": "MaksIT",
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const PACKAGE_VERSION = '2.15.0'
|
||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('text/event-stream')
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
responseClone && responseClone.body
|
||||
? [serializedRequest.body, responseClone.body]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ components/components/ stories/components/
|
||||
└── LazyLoadTable.tsx └── LazyLoadTable.stories.tsx
|
||||
```
|
||||
|
||||
Use the **same folder names** as in the package (`Toast`, not `feedback`; `list`, not `List`). Storybook sidebar titles use the same path: `components/editors/Button`, `components/Toast`, etc.
|
||||
Use the **same folder names** as in the package (`Toast`, not `feedback`; `list`, not `List`). Storybook sidebar titles use the same path: `components/editors/Button`, `components/Toast`, etc. Core hook demos live under `stories/core/` (`core/hooks`).
|
||||
|
||||
## Imports
|
||||
|
||||
|
||||
51
src/stories/components/CookieConsent.stories.tsx
Normal file
51
src/stories/components/CookieConsent.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
119
src/stories/components/FileBrowser.stories.tsx
Normal file
119
src/stories/components/FileBrowser.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
40
src/stories/components/LightBox.stories.tsx
Normal file
40
src/stories/components/LightBox.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
84
src/stories/components/Loader.stories.tsx
Normal file
84
src/stories/components/Loader.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
31
src/stories/components/Masonry.stories.tsx
Normal file
31
src/stories/components/Masonry.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
126
src/stories/components/Modal.stories.tsx
Normal file
126
src/stories/components/Modal.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
56
src/stories/components/Ratings.stories.tsx
Normal file
56
src/stories/components/Ratings.stories.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { expect, within } from 'storybook/test'
|
||||
import { FileUploadComponent } from '@webui/components/components/editors/FileUploadComponent'
|
||||
import { withControlledFiles } from '../../helpers/controlledEditors'
|
||||
|
||||
@ -8,6 +9,14 @@ const meta = {
|
||||
title: 'components/editors/FileUpload',
|
||||
component: ControlledFileUpload,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'File picker with optional drag-and-drop (`droppable`, default true). Drop replaces the current selection.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ControlledFileUpload>
|
||||
|
||||
export default meta
|
||||
@ -18,6 +27,11 @@ export const Default: Story = {
|
||||
label: 'Upload documents',
|
||||
multiple: true,
|
||||
files: [],
|
||||
accept: 'image/*,.pdf',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await expect(canvas.getByText(/upload documents/i)).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
@ -29,6 +43,18 @@ export const SingleFile: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const DropDisabled: Story = {
|
||||
args: {
|
||||
label: 'Click only',
|
||||
files: [],
|
||||
droppable: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await expect(canvas.getByText(/click only/i)).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Upload documents',
|
||||
|
||||
80
src/stories/components/social.stories.tsx
Normal file
80
src/stories/components/social.stories.tsx
Normal 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',
|
||||
)
|
||||
},
|
||||
}
|
||||
270
src/stories/core/hooks.stories.tsx
Normal file
270
src/stories/core/hooks.stories.tsx
Normal 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)/)
|
||||
},
|
||||
}
|
||||
26
src/test/components/CookieConsent/cookies.test.ts
Normal file
26
src/test/components/CookieConsent/cookies.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
103
src/test/components/FileBrowser/format.test.ts
Normal file
103
src/test/components/FileBrowser/format.test.ts
Normal 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'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
24
src/test/components/FileBrowser/prefs.test.ts
Normal file
24
src/test/components/FileBrowser/prefs.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
36
src/test/components/Loader/loaderEvents.test.ts
Normal file
36
src/test/components/Loader/loaderEvents.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
src/test/components/social/hrefs.test.ts
Normal file
18
src/test/components/social/hrefs.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
30
src/test/core/hooks/useDebounce.test.ts
Normal file
30
src/test/core/hooks/useDebounce.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
24
src/test/core/hooks/useHover.test.ts
Normal file
24
src/test/core/hooks/useHover.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
31
src/test/core/hooks/useInterval.test.ts
Normal file
31
src/test/core/hooks/useInterval.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/test/core/hooks/useLocalStorage.test.ts
Normal file
37
src/test/core/hooks/useLocalStorage.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
55
src/test/core/hooks/useLongPress.test.ts
Normal file
55
src/test/core/hooks/useLongPress.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
102
src/test/core/hooks/useMedia.test.ts
Normal file
102
src/test/core/hooks/useMedia.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
31
src/test/core/hooks/useOnClickOutside.test.ts
Normal file
31
src/test/core/hooks/useOnClickOutside.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
60
src/test/core/hooks/useOnScreen.test.ts
Normal file
60
src/test/core/hooks/useOnScreen.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
19
src/test/core/hooks/usePrevious.test.ts
Normal file
19
src/test/core/hooks/usePrevious.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
src/test/core/hooks/useSessionStorage.test.ts
Normal file
21
src/test/core/hooks/useSessionStorage.test.ts
Normal 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
1
src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@ -33,6 +33,7 @@
|
||||
"components",
|
||||
"index.ts",
|
||||
"test/**/*.ts",
|
||||
"test/**/*.tsx",
|
||||
"test/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Run Tests Script Settings",
|
||||
"description": "maksit-webui: Jest tests and coverage badges for README assets.",
|
||||
"description": "maksit-webui: Jest tests and shields.io coverage badges in README.md.",
|
||||
"paths": {
|
||||
"badgesDir": "..\\..\\..\\assets\\badges"
|
||||
"readmePath": "..\\..\\..\\README.md"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@ -25,20 +25,18 @@
|
||||
"name": "CoverageBadges",
|
||||
"stageLabel": "report",
|
||||
"enabled": true,
|
||||
"badgesDir": "..\\..\\..\\assets\\badges",
|
||||
"badgeFormat": "shields",
|
||||
"readmePath": "..\\..\\..\\README.md",
|
||||
"badges": [
|
||||
{
|
||||
"name": "coverage-lines.svg",
|
||||
"label": "Line Coverage",
|
||||
"metric": "line"
|
||||
},
|
||||
{
|
||||
"name": "coverage-branches.svg",
|
||||
"label": "Branch Coverage",
|
||||
"metric": "branch"
|
||||
},
|
||||
{
|
||||
"name": "coverage-methods.svg",
|
||||
"label": "Method Coverage",
|
||||
"metric": "method"
|
||||
}
|
||||
|
||||
@ -191,7 +191,7 @@ function Invoke-ContainerImagePush {
|
||||
|
||||
$primaryRef = "${baseName}:$($imageTags[0])"
|
||||
Write-Log -Level 'STEP' -Message "Building $primaryRef via $engineName ..."
|
||||
Invoke-ContainerEngine -Engine $engine build -t $primaryRef -f $dockerfilePath $imgContextPath
|
||||
Invoke-ContainerEngine -Engine $engine build --provenance=false --sbom=false -t $primaryRef -f $dockerfilePath $imgContextPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Container build failed for $primaryRef (exit code: $LASTEXITCODE)"
|
||||
}
|
||||
|
||||
@ -30,11 +30,17 @@ function New-EngineContext {
|
||||
$badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir)))
|
||||
}
|
||||
|
||||
$readmePath = $null
|
||||
if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.readmePath) {
|
||||
$readmePath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.readmePath)))
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
scriptDir = $ScriptDir
|
||||
srcDir = $SrcDir
|
||||
utilsDir = $SrcDir
|
||||
badgesDir = $badgesDir
|
||||
readmePath = $readmePath
|
||||
deployMode = $DeployMode
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,10 @@
|
||||
Coverage badge plugin for the test engine.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads line/branch/method coverage from shared engine context and writes SVG badges.
|
||||
Reads line/branch/method coverage from shared engine context.
|
||||
|
||||
badgeFormat "svg" (default): writes SVG files under badgesDir.
|
||||
badgeFormat "shields": updates readmePath with img.shields.io markdown (no local assets).
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -80,6 +83,79 @@ function New-BadgeSvgInternal {
|
||||
"@
|
||||
}
|
||||
|
||||
function New-ShieldsIoBadgeUrlInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[double]$Percentage,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Color
|
||||
)
|
||||
|
||||
$labelToken = ($Label -replace ' ', '%20')
|
||||
$valueToken = "$Percentage%25"
|
||||
return "https://img.shields.io/badge/$labelToken-$valueToken-$Color"
|
||||
}
|
||||
|
||||
function New-ShieldsIoBadgeMarkdownInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[double]$Percentage,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Color
|
||||
)
|
||||
|
||||
$url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color
|
||||
return ""
|
||||
}
|
||||
|
||||
function Update-ReadmeShieldsBadgesInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReadmePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object[]]$Badges,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[hashtable]$Metrics,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$Thresholds
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) {
|
||||
throw "CoverageBadges readmePath not found: $ReadmePath"
|
||||
}
|
||||
|
||||
$content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8
|
||||
|
||||
foreach ($badge in @($Badges)) {
|
||||
$metricValue = $Metrics[[string]$badge.metric]
|
||||
if ($null -eq $metricValue) {
|
||||
throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'."
|
||||
}
|
||||
|
||||
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds
|
||||
$markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color
|
||||
$pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)\s*$"
|
||||
if ($content -notmatch $pattern) {
|
||||
throw "README badge line not found for label '$($badge.label)' in: $ReadmePath"
|
||||
}
|
||||
|
||||
$content = [regex]::Replace($content, $pattern, $markdown)
|
||||
}
|
||||
|
||||
$content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline
|
||||
}
|
||||
|
||||
function Get-CoverageMetricsFromSharedContext {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -131,17 +207,9 @@ function Invoke-Plugin {
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
$metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings
|
||||
|
||||
$badgesDir = $sharedSettings.badgesDir
|
||||
if ($pluginSettings.badgesDir) {
|
||||
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
|
||||
$badgesDir = $badgesDirs[0]
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
|
||||
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $badgesDir)) {
|
||||
New-Item -ItemType Directory -Path $badgesDir | Out-Null
|
||||
$badgeFormat = 'svg'
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) {
|
||||
$badgeFormat = [string]$pluginSettings.badgeFormat
|
||||
}
|
||||
|
||||
$thresholds = $pluginSettings.colorThresholds
|
||||
@ -158,10 +226,50 @@ function Invoke-Plugin {
|
||||
|
||||
Write-Log -Level "STEP" -Message "Generating coverage badges..."
|
||||
|
||||
if ($badgeFormat -eq 'shields') {
|
||||
$readmePath = $null
|
||||
if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) {
|
||||
$readmePath = [string]$sharedSettings.readmePath
|
||||
}
|
||||
if ($pluginSettings.readmePath) {
|
||||
$readmePaths = @(Resolve-RelativePaths -Value $pluginSettings.readmePath -BasePath $scriptDir)
|
||||
$readmePath = $readmePaths[0]
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$readmePath)) {
|
||||
throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json."
|
||||
}
|
||||
|
||||
Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds
|
||||
|
||||
foreach ($badge in @($pluginSettings.badges)) {
|
||||
$metricValue = $metrics[[string]$badge.metric]
|
||||
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds
|
||||
Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)"
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "README shields updated: $readmePath"
|
||||
Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs."
|
||||
return
|
||||
}
|
||||
|
||||
$badgesDir = $sharedSettings.badgesDir
|
||||
if ($pluginSettings.badgesDir) {
|
||||
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
|
||||
$badgesDir = $badgesDirs[0]
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
|
||||
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $badgesDir)) {
|
||||
New-Item -ItemType Directory -Path $badgesDir | Out-Null
|
||||
}
|
||||
|
||||
foreach ($badge in @($pluginSettings.badges)) {
|
||||
$metricValue = $metrics[[string]$badge.metric]
|
||||
if ($null -eq $metricValue) {
|
||||
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'."
|
||||
$badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label }
|
||||
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'."
|
||||
}
|
||||
|
||||
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds
|
||||
|
||||
Loading…
Reference in New Issue
Block a user