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

|
||||||
|

|
||||||
|

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