tempest-react-sdk 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +35 -5
  2. package/dist/audio/audio-bus.cjs +2 -0
  3. package/dist/audio/audio-bus.cjs.map +1 -0
  4. package/dist/audio/audio-bus.js +116 -0
  5. package/dist/audio/audio-bus.js.map +1 -0
  6. package/dist/audio/use-audio-bus.cjs +2 -0
  7. package/dist/audio/use-audio-bus.cjs.map +1 -0
  8. package/dist/audio/use-audio-bus.js +11 -0
  9. package/dist/audio/use-audio-bus.js.map +1 -0
  10. package/dist/auth/refresh-queue.cjs +1 -1
  11. package/dist/auth/refresh-queue.cjs.map +1 -1
  12. package/dist/auth/refresh-queue.js +6 -6
  13. package/dist/auth/refresh-queue.js.map +1 -1
  14. package/dist/components/RangeSlider/RangeSlider.cjs +1 -1
  15. package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
  16. package/dist/components/RangeSlider/RangeSlider.js +23 -23
  17. package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
  18. package/dist/components/Scheduler/Scheduler.cjs +1 -1
  19. package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
  20. package/dist/components/Scheduler/Scheduler.js +1 -1
  21. package/dist/components/Scheduler/Scheduler.js.map +1 -1
  22. package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
  23. package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
  24. package/dist/components/Slider/Slider.cjs +1 -1
  25. package/dist/components/Slider/Slider.cjs.map +1 -1
  26. package/dist/components/Slider/Slider.js +9 -9
  27. package/dist/components/Slider/Slider.js.map +1 -1
  28. package/dist/forms/FormField.cjs +1 -1
  29. package/dist/forms/FormField.cjs.map +1 -1
  30. package/dist/forms/FormField.js +23 -21
  31. package/dist/forms/FormField.js.map +1 -1
  32. package/dist/http/describe-api-error.cjs +1 -1
  33. package/dist/http/describe-api-error.cjs.map +1 -1
  34. package/dist/http/describe-api-error.js +4 -2
  35. package/dist/http/describe-api-error.js.map +1 -1
  36. package/dist/http/use-describe-api-error.cjs +1 -1
  37. package/dist/http/use-describe-api-error.cjs.map +1 -1
  38. package/dist/http/use-describe-api-error.js +3 -2
  39. package/dist/http/use-describe-api-error.js.map +1 -1
  40. package/dist/offline/create-offline-database.cjs +2 -0
  41. package/dist/offline/create-offline-database.cjs.map +1 -0
  42. package/dist/offline/create-offline-database.js +29 -0
  43. package/dist/offline/create-offline-database.js.map +1 -0
  44. package/dist/offline/create-offline-store.cjs +1 -1
  45. package/dist/offline/create-offline-store.cjs.map +1 -1
  46. package/dist/offline/create-offline-store.js +29 -25
  47. package/dist/offline/create-offline-store.js.map +1 -1
  48. package/dist/query/QueryProvider.cjs +1 -1
  49. package/dist/query/QueryProvider.cjs.map +1 -1
  50. package/dist/query/QueryProvider.js +13 -11
  51. package/dist/query/QueryProvider.js.map +1 -1
  52. package/dist/query/foreign-client-warning.cjs +2 -0
  53. package/dist/query/foreign-client-warning.cjs.map +1 -0
  54. package/dist/query/foreign-client-warning.js +12 -0
  55. package/dist/query/foreign-client-warning.js.map +1 -0
  56. package/dist/tempest-react-sdk.cjs +1 -1
  57. package/dist/tempest-react-sdk.d.ts +695 -41
  58. package/dist/tempest-react-sdk.js +52 -46
  59. package/dist/utils/duplicate-instance.cjs +2 -0
  60. package/dist/utils/duplicate-instance.cjs.map +1 -0
  61. package/dist/utils/duplicate-instance.js +6 -0
  62. package/dist/utils/duplicate-instance.js.map +1 -0
  63. package/dist/utils/format.cjs +1 -1
  64. package/dist/utils/format.cjs.map +1 -1
  65. package/dist/utils/format.js +8 -3
  66. package/dist/utils/format.js.map +1 -1
  67. package/dist/webrtc/opus-sdp.cjs +3 -0
  68. package/dist/webrtc/opus-sdp.cjs.map +1 -0
  69. package/dist/webrtc/opus-sdp.js +94 -0
  70. package/dist/webrtc/opus-sdp.js.map +1 -0
  71. package/dist/webrtc/sender-bitrate.cjs +2 -0
  72. package/dist/webrtc/sender-bitrate.cjs.map +1 -0
  73. package/dist/webrtc/sender-bitrate.js +15 -0
  74. package/dist/webrtc/sender-bitrate.js.map +1 -0
  75. package/dist/ws/create-web-socket.cjs +1 -1
  76. package/dist/ws/create-web-socket.cjs.map +1 -1
  77. package/dist/ws/create-web-socket.js +123 -47
  78. package/dist/ws/create-web-socket.js.map +1 -1
  79. package/dist/ws/resilience.cjs +2 -0
  80. package/dist/ws/resilience.cjs.map +1 -0
  81. package/dist/ws/resilience.js +21 -0
  82. package/dist/ws/resilience.js.map +1 -0
  83. package/dist/ws/use-web-socket.cjs +1 -1
  84. package/dist/ws/use-web-socket.cjs.map +1 -1
  85. package/dist/ws/use-web-socket.js +37 -23
  86. package/dist/ws/use-web-socket.js.map +1 -1
  87. package/package.json +1 -1
package/README.md CHANGED
@@ -51,6 +51,7 @@ The goal is to start every new React frontend with the same opinionated foundati
51
51
  - [BR validators & masked inputs](#br-validators--masked-inputs-recipe)
52
52
  - [ViaCEP lookup](#viacep-lookup-recipe)
53
53
  - [WebSocket](#websocket-recipe)
54
+ - [WebRTC (Opus SDP tuning)](#webrtc-opus-sdp-tuning-recipe)
54
55
  - [Server-Sent Events (SSE)](#server-sent-events-sse-recipe)
55
56
  - [Web Push](#web-push-recipe)
56
57
  - [Service Worker helpers](#service-worker-helpers-recipe)
@@ -240,14 +241,15 @@ Every module is re-exported from the package root — `import { Button, useDebou
240
241
  | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
241
242
  | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
242
243
  | `sse` | `createEventStream`, `useEventStream` |
243
- | `ws` | `createWebSocket`, `useWebSocket` |
244
+ | `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
245
+ | `webrtc` | `tuneOpus` (Opus `fmtp` per audio m-line: per-key merge, `stereo` + `sprop-stereo`, inserts a missing `fmtp`), `setTunedLocalDescription` (falls back to the untouched SDP when the browser refuses the edit), `setSenderBitrate` |
244
246
  | `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
245
247
  | `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
246
248
  | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
247
249
  | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
248
250
  | `charts` _(subpath `tempest-react-sdk/charts`, opt. peer `recharts`)_ | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DEFAULT_CHART_COLORS` — themed recharts wrappers (recharts externalized; install it only if you use charts) |
249
251
  | `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2024 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one chunk per initial letter** (25 max) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces. Unknown slug renders `fallback` and never throws; lucide's 257 deprecated aliases keep resolving |
250
- | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
252
+ | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
251
253
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
252
254
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
253
255
  | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
@@ -274,7 +276,8 @@ Every module is re-exported from the package root — `import { Button, useDebou
274
276
  | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, `tempestPwaManifest` (emits `precache-manifest.json` for offline precache), `tempestPwaIcons` (generates the PNG icon set from one SVG via `sharp`), `tempestPwaDevSw` (serves the SW under `npm run dev`), `tempestPwaIcons({ appleSplash })` (Apple splash screens), types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig`, `TempestPwaManifestOptions`, `TempestPwaIconsOptions`, `TempestPwaDevSwOptions`, `AppleSplashSpec`, `TempestVitePlugin` |
275
277
  | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
276
278
  | `sse` | `createEventStream`, `useEventStream` |
277
- | `ws` | `createWebSocket`, `useWebSocket` |
279
+ | `ws` | `createWebSocket` (handshake timeout, silence watchdog, jittered backoff, `navigator.onLine` suspend, close-code classification, `opened` promise), `useWebSocket`, `isRejectionCloseCode`, `HEARTBEAT_CLOSE_CODE` |
280
+ | `webrtc` | `tuneOpus` (Opus `fmtp` per audio m-line: per-key merge, `stereo` + `sprop-stereo`, inserts a missing `fmtp`), `setTunedLocalDescription` (falls back to the untouched SDP when the browser refuses the edit), `setSenderBitrate` |
278
281
  | `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
279
282
  | `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
280
283
  | `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2024 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one chunk per initial letter** (25 max) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces. Unknown slug renders `fallback` and never throws; lucide's 257 deprecated aliases keep resolving |
@@ -284,7 +287,7 @@ Every module is re-exported from the package root — `import { Button, useDebou
284
287
  | `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter`, `DetectClassify` (fused detect→classify pipeline) + `warmup()`, `raiseOnEmpty`/`NoDetectionsError`, `LetterboxPipeline`, `readFusionSpec` and results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
285
288
  | `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
286
289
  | `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one |
287
- | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
290
+ | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
288
291
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
289
292
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
290
293
  | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
@@ -1022,7 +1025,9 @@ function AddressFields({ form }: { form: UseFormReturn<Address> }) {
1022
1025
 
1023
1026
  ### WebSocket recipe
1024
1027
 
1025
- Wrapper around `WebSocket` with exponential reconnect (up to 10 attempts), optional ping heartbeat, JSON parsing, and `send` that no-ops while the socket isn't open.
1028
+ Wrapper around `WebSocket` with exponential reconnect (up to 10 attempts, jittered), optional ping heartbeat, JSON parsing, and `send` that no-ops while the socket isn't open.
1029
+
1030
+ It also covers the three failures that fire no event at all, which is where a retry chain built on `open`/`close`/`error` gets stuck: a handshake that hangs in `CONNECTING` instead of failing (`handshakeTimeout`, 8 s by default), a link that dies mid-flight leaving the socket `OPEN` with nothing arriving (`silenceTimeout` + `setSilenceTimeout`), and a device with its radio off burning the retry budget (`waitForOnline`). Whether a close is retried comes from its code, not from `wasClean` alone — 4400–4499 is a refusal, minus `4408`, which is the `tempest-fastapi-sdk` heartbeat timeout and does reconnect. See [the WebSocket guide](https://mauriciobenjamin700.github.io/tempest-react-sdk/websocket/).
1026
1031
 
1027
1032
  ```tsx
1028
1033
  import { useWebSocket } from "tempest-react-sdk";
@@ -1058,6 +1063,29 @@ socket.send("hello");
1058
1063
  socket.close();
1059
1064
  ```
1060
1065
 
1066
+ ### WebRTC (Opus SDP tuning) recipe
1067
+
1068
+ Audio over WebRTC is mono and narrow by default, and the only place that gets corrected is the SDP — which is why shared-screen audio famously sounds like a telephone: music and video inherit the speech profile and no high-level API lets you change it.
1069
+
1070
+ ```ts
1071
+ import { setSenderBitrate, setTunedLocalDescription, tuneOpus } from "tempest-react-sdk";
1072
+
1073
+ // one profile per audio m-line (by index or by mid), or one for all of them
1074
+ const tuned = tuneOpus(offer.sdp!, {
1075
+ 0: { maxAverageBitrate: 48_000, stereo: false, fec: true, dtx: true },
1076
+ 1: { maxAverageBitrate: 192_000, stereo: true, fec: false, dtx: false },
1077
+ });
1078
+
1079
+ // applies the tuned SDP, falling back to the untouched one if the browser refuses it
1080
+ const applied = await setTunedLocalDescription(pc, await pc.createOffer(), profiles);
1081
+
1082
+ // the fmtp above says what we want to *receive*; this limits what we *send*
1083
+ const sender = pc.getSenders().find((s) => s.track?.kind === "audio");
1084
+ if (sender) await setSenderBitrate(sender, 48_000);
1085
+ ```
1086
+
1087
+ No preset table on purpose — which values to use is the consumer's call. What the module owns is the parsing: per-key `fmtp` merge (so the browser's `minptime` survives), `stereo` written together with `sprop-stereo` (they point in opposite directions), payload types read from the `rtpmap`, an `fmtp` inserted when absent, and video m-lines left alone. Full guide: [WebRTC](https://mauriciobenjamin700.github.io/tempest-react-sdk/webrtc/).
1088
+
1061
1089
  ### Server-Sent Events (SSE) recipe
1062
1090
 
1063
1091
  Stream with exponential reconnect (up to 10 attempts), `ping` heartbeat, JSON parsing by default. For cookie-auth endpoints, pass `withCredentials: true`.
@@ -1203,6 +1231,8 @@ The SDK does **not** ship the worker file or the bundler config — pair it with
1203
1231
 
1204
1232
  `playAudio` for one-shot sounds (notification chime, payment success), `createAudioPlayer` for isolated channels, `useAudio` for a per-component player.
1205
1233
 
1234
+ To turn a source **up** rather than down, use `createAudioBus` / `useAudioBus`: `element.volume` is clamped at `1`, so a quiet participant could only ever be attenuated. The bus mixes several streams through a Web Audio graph with a limiter after the sum (clipping is a property of the mix, not of one source), routes the result with `setSinkId`, and carries the muted anchor element Chrome needs to pull samples from a remote WebRTC stream at all ([crbug.com/687574](https://crbug.com/687574)). Guide: [Audio](https://mauriciobenjamin700.github.io/tempest-react-sdk/audio/).
1235
+
1206
1236
  ```ts
1207
1237
  import { playAudio } from "tempest-react-sdk";
1208
1238
 
@@ -0,0 +1,2 @@
1
+ const e=require("./audio-output.cjs");var t=3,n={threshold:-8,knee:0,ratio:20,attack:.003,release:.25};function r(e,t){return Number.isFinite(e)?Math.min(t,Math.max(0,e)):1}function i(){return typeof AudioContext<`u`?AudioContext:globalThis.webkitAudioContext}function a(e,t){return typeof MediaStream>`u`?e:new MediaStream([t])}function o(e){try{e.play?.()?.catch(()=>void 0)}catch{}}function s({maxGain:t=3,limiter:s,context:c}={}){let l=i(),u=c??(l?new l:null);if(!u)return{attach:()=>({setGain:()=>void 0,gain:1,stop:()=>void 0}),setMasterGain:()=>void 0,masterGain:1,setOutputDevice:async()=>!1,outputDevice:``,resume:async()=>void 0,close:()=>void 0,supported:!1,canSelectOutput:!1};let d=u,f=!c,p=d.createGain(),m=d.createMediaStreamDestination(),h=null;if(s===!1)p.connect(m);else{let e=d.createDynamicsCompressor();h=e;let t={...n,...s};e.threshold.value=t.threshold,e.knee.value=t.knee,e.ratio.value=t.ratio,e.attack.value=t.attack,e.release.value=t.release,p.connect(e),e.connect(m)}let g=new Audio;g.autoplay=!0,g.srcObject=m.stream,o(g);let _=``,v=1,y=!1,b=new Set;function x(){return{setGain:()=>void 0,gain:1,stop:()=>void 0}}function S(e,{gain:n=1}={}){let i=e.getAudioTracks()[0];if(y||!i)return x();let s=new Audio;s.muted=!0,s.autoplay=!0,s.srcObject=e,o(s);let c=d.createMediaStreamSource(a(e,i)),l=d.createGain(),u=r(n,t);l.gain.value=u,c.connect(l),l.connect(p);let f={setGain(e){u=r(e,t),l.gain.value=u},get gain(){return u},stop(){b.has(f)&&(b.delete(f),c.disconnect(),l.disconnect(),s.srcObject=null,s.pause())}};return b.add(f),f}return{attach:S,setMasterGain(e){y||(v=r(e,t),p.gain.value=v)},get masterGain(){return v},async setOutputDevice(t){let n=await e.setAudioOutput(g,t);return n&&(_=t),n},get outputDevice(){return _},async resume(){await d.resume?.().catch(()=>void 0)},close(){if(!y){y=!0;for(let e of[...b])e.stop();g.srcObject=null,g.pause(),p.disconnect(),h?.disconnect(),f&&d.close().catch(()=>void 0)}},supported:!0,get canSelectOutput(){return e.isAudioOutputSelectionSupported()}}}exports.DEFAULT_MAX_GAIN=t,exports.createAudioBus=s;
2
+ //# sourceMappingURL=audio-bus.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-bus.cjs","names":[],"sources":["../../src/audio/audio-bus.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mixing graph, the per-source handle and the\n * output route are one object's lifetime: the limiter only makes sense after the\n * sum, and the element that carries `setSinkId` is the same one the mix leaves\n * through. Splitting them would hand the caller two halves that are useless apart.\n */\nimport { isAudioOutputSelectionSupported, setAudioOutput } from \"./audio-output\";\n\n/** Default ceiling for a per-source or master gain, as a multiplier. */\nexport const DEFAULT_MAX_GAIN = 3;\n\n/** Shape of the master limiter. Matches `DynamicsCompressorNode`'s params. */\nexport interface LimiterSettings {\n /** dBFS above which the compressor starts working. */\n threshold: number;\n /** dB range over which the curve bends. `0` is a hard knee. */\n knee: number;\n /** Input/output ratio above the threshold. 20 is limiting, not compression. */\n ratio: number;\n /** Seconds to clamp a peak. */\n attack: number;\n /** Seconds to let go. */\n release: number;\n}\n\n/**\n * A limiter, not a compressor: a hard knee at a 20:1 ratio with a 3 ms attack\n * catches the sum before it clips without audibly ducking anything below it.\n */\nconst DEFAULT_LIMITER: LimiterSettings = {\n threshold: -8,\n knee: 0,\n ratio: 20,\n attack: 0.003,\n release: 0.25,\n};\n\n/** Options for {@link createAudioBus}. */\nexport interface AudioBusOptions {\n /** Ceiling for every gain on this bus. Default {@link DEFAULT_MAX_GAIN}. */\n maxGain?: number;\n /** Override the master limiter, or pass `false` to run without one. */\n limiter?: Partial<LimiterSettings> | false;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap the number of live contexts (Chrome allows around six), so a\n * page that already has one — a level meter, a player — should hand it over\n * rather than open a second.\n */\n context?: AudioContext;\n}\n\n/** One source attached to the bus. */\nexport interface AudioBusHandle {\n /**\n * Set this source's gain, where `1` is the level it arrived at.\n *\n * Values above `1` are the point of the whole graph: `element.volume` is\n * clamped at `1`, so a quiet talker could only ever be attenuated — the one\n * correction nobody needs.\n */\n setGain: (gain: number) => void;\n /** Current gain, after clamping. */\n readonly gain: number;\n /** Detach this source and release its nodes. The bus stays up. */\n stop: () => void;\n}\n\n/** A running mix. */\nexport interface AudioBus {\n /**\n * Play a stream through the shared mix.\n *\n * @param stream - The stream to play. Only its first audio track is used.\n * @param options - `gain` is the initial multiplier, default `1`.\n * @returns A handle to adjust or detach this source.\n */\n attach: (stream: MediaStream, options?: { gain?: number }) => AudioBusHandle;\n /** Scale every source, on top of its own gain. */\n setMasterGain: (gain: number) => void;\n /** Current master gain, after clamping. */\n readonly masterGain: number;\n /**\n * Route the whole mix to one output device.\n *\n * @param deviceId - Device id from `useMediaDevices().audioOutputs`, or `\"\"`\n * for the system default.\n * @returns `false` when the engine cannot route audio, or the device is gone.\n */\n setOutputDevice: (deviceId: string) => Promise<boolean>;\n /** Device id the mix is routed to. `\"\"` is the system default. */\n readonly outputDevice: string;\n /**\n * Resume a context the browser started suspended.\n *\n * Autoplay policy suspends a context created outside a user gesture, and a\n * suspended context is silent with no error anywhere. Call this from the\n * click that starts playback.\n */\n resume: () => Promise<void>;\n /**\n * Detach everything and close the context this bus created.\n *\n * Idempotent, and the bus stays callable afterwards: `attach` hands back an\n * inert handle instead of throwing. That is not politeness — creating a node\n * on a closed `AudioContext` throws `InvalidStateError`, and the stream that\n * arrives late is the normal case (a WebRTC `ontrack` firing after the\n * component that owned the bus went away).\n */\n close: () => void;\n /** Whether this browser gave us a Web Audio graph at all. */\n readonly supported: boolean;\n /** Whether this browser can route the mix to a chosen output device. */\n readonly canSelectOutput: boolean;\n}\n\n/**\n * Clamp a gain into `0..max`, treating a non-finite value as unity.\n *\n * `NaN` reaches here from an empty input or a failed parse, and assigning it to\n * an `AudioParam` throws — losing the audio for a typo in a number field.\n */\nfunction clampGain(gain: number, max: number): number {\n if (!Number.isFinite(gain)) return 1;\n return Math.min(max, Math.max(0, gain));\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n if (typeof AudioContext !== \"undefined\") return AudioContext;\n return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/** A media element that may support output routing. */\ntype SinkCapableElement = HTMLAudioElement & { setSinkId?: (sinkId: string) => Promise<void> };\n\n/**\n * The stream to feed the graph: just the audio track, when this engine can build\n * one.\n *\n * Isolating the track keeps a camera's video out of the source node. `MediaStream`\n * is missing outside a browser (jsdom, a worker), and there the original stream is\n * the right answer anyway — `createMediaStreamSource` reads its first audio track.\n */\nfunction audioOnly(stream: MediaStream, track: MediaStreamTrack): MediaStream {\n if (typeof MediaStream === \"undefined\") return stream;\n return new MediaStream([track]);\n}\n\n/** Start playback without letting an environment that has no media pipeline throw. */\nfunction playQuietly(element: HTMLAudioElement): void {\n try {\n void element.play?.()?.catch(() => undefined);\n } catch {\n /* jsdom and any engine without a media pipeline — nothing to play anyway */\n }\n}\n\n/**\n * Mix several streams into one output, with gain above 100% and a master limiter.\n *\n * Three things make this a graph instead of a few `<audio>` elements:\n *\n * 1. **`element.volume` is clamped at `1`.** A participant who speaks too quietly\n * can only be turned *down* — the one correction nobody needs. A `GainNode`\n * has no ceiling, so this bus takes one (`maxGain`, default 3).\n * 2. **Clipping is a property of the sum.** Three sources at 200% each are clean\n * alone and distort the instant they play together. A per-source limiter\n * cannot see that; the one after the mix can, which is where this puts it.\n * 3. **`setSinkId` lives on the element, not on the context.** Sending the mix to\n * a headset while the rest of the system keeps the speakers is only reachable\n * by leaving through a `MediaStreamAudioDestinationNode` into a real\n * `<audio>`. `AudioContext.setSinkId` has far thinner support.\n *\n * @param options - See {@link AudioBusOptions}.\n * @returns The bus. On an engine with no Web Audio it is inert but callable, and\n * `supported` is `false` — a page without sound beats a page that throws.\n *\n * @example\n * const bus = createAudioBus({ maxGain: 3 });\n * const handle = bus.attach(remoteStream, { gain: 1 });\n * handle.setGain(2.4); // above 1 — the point of the whole thing\n * await bus.setOutputDevice(headsetId);\n */\nexport function createAudioBus({\n maxGain = DEFAULT_MAX_GAIN,\n limiter: limiterOptions,\n context: injectedContext,\n}: AudioBusOptions = {}): AudioBus {\n const Ctor = audioContextConstructor();\n const context = injectedContext ?? (Ctor ? new Ctor() : null);\n\n if (!context) {\n return {\n attach: () => ({ setGain: () => undefined, gain: 1, stop: () => undefined }),\n setMasterGain: () => undefined,\n masterGain: 1,\n setOutputDevice: async () => false,\n outputDevice: \"\",\n resume: async () => undefined,\n close: () => undefined,\n supported: false,\n canSelectOutput: false,\n };\n }\n\n const graph = context;\n const ownsContext = !injectedContext;\n const master = graph.createGain();\n const destination = graph.createMediaStreamDestination();\n\n let limiterNode: DynamicsCompressorNode | null = null;\n if (limiterOptions === false) {\n master.connect(destination);\n } else {\n const limiter = graph.createDynamicsCompressor();\n limiterNode = limiter;\n const settings = { ...DEFAULT_LIMITER, ...limiterOptions };\n limiter.threshold.value = settings.threshold;\n limiter.knee.value = settings.knee;\n limiter.ratio.value = settings.ratio;\n limiter.attack.value = settings.attack;\n limiter.release.value = settings.release;\n master.connect(limiter);\n limiter.connect(destination);\n }\n\n const element = new Audio();\n element.autoplay = true;\n element.srcObject = destination.stream;\n playQuietly(element);\n\n let sinkId = \"\";\n let masterGain = 1;\n let closed = false;\n const handles = new Set<AudioBusHandle>();\n\n /** The handle a bus hands out when there is nothing to play through. */\n function inertHandle(): AudioBusHandle {\n return { setGain: () => undefined, gain: 1, stop: () => undefined };\n }\n\n /**\n * Play one stream through the mix.\n *\n * The muted `<audio>` anchor is not dead weight, and deleting it as unused is\n * the mistake this comment exists to prevent: Chrome will not pull samples\n * from a `MediaStreamAudioSourceNode` built over a **remote** WebRTC stream\n * unless that same stream is also attached to a media element\n * (crbug.com/687574). Without it the graph is visibly correct and completely\n * silent — a day of debugging for anyone who does not know the bug. It is\n * muted because the audible copy is the one leaving the bus.\n */\n function attach(stream: MediaStream, { gain = 1 }: { gain?: number } = {}): AudioBusHandle {\n const track = stream.getAudioTracks()[0];\n if (closed || !track) return inertHandle();\n\n const anchor = new Audio();\n anchor.muted = true;\n anchor.autoplay = true;\n anchor.srcObject = stream;\n playQuietly(anchor);\n\n const source = graph.createMediaStreamSource(audioOnly(stream, track));\n const node = graph.createGain();\n let current = clampGain(gain, maxGain);\n node.gain.value = current;\n source.connect(node);\n node.connect(master);\n\n const handle: AudioBusHandle = {\n setGain(next: number): void {\n current = clampGain(next, maxGain);\n node.gain.value = current;\n },\n get gain(): number {\n return current;\n },\n stop(): void {\n if (!handles.has(handle)) return;\n handles.delete(handle);\n source.disconnect();\n node.disconnect();\n anchor.srcObject = null;\n anchor.pause();\n },\n };\n handles.add(handle);\n return handle;\n }\n\n return {\n attach,\n setMasterGain(gain: number): void {\n if (closed) return;\n masterGain = clampGain(gain, maxGain);\n master.gain.value = masterGain;\n },\n get masterGain(): number {\n return masterGain;\n },\n async setOutputDevice(deviceId: string): Promise<boolean> {\n const applied = await setAudioOutput(element as SinkCapableElement, deviceId);\n if (applied) sinkId = deviceId;\n return applied;\n },\n get outputDevice(): string {\n return sinkId;\n },\n async resume(): Promise<void> {\n await graph.resume?.().catch(() => undefined);\n },\n close(): void {\n if (closed) return;\n closed = true;\n for (const handle of [...handles]) handle.stop();\n element.srcObject = null;\n element.pause();\n master.disconnect();\n limiterNode?.disconnect();\n if (ownsContext) void graph.close().catch(() => undefined);\n },\n supported: true,\n get canSelectOutput(): boolean {\n return isAudioOutputSelectionSupported();\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,116 @@
1
+ import { isAudioOutputSelectionSupported as e, setAudioOutput as t } from "./audio-output.js";
2
+ //#region src/audio/audio-bus.ts
3
+ var n = 3, r = {
4
+ threshold: -8,
5
+ knee: 0,
6
+ ratio: 20,
7
+ attack: .003,
8
+ release: .25
9
+ };
10
+ function i(e, t) {
11
+ return Number.isFinite(e) ? Math.min(t, Math.max(0, e)) : 1;
12
+ }
13
+ function a() {
14
+ return typeof AudioContext < "u" ? AudioContext : globalThis.webkitAudioContext;
15
+ }
16
+ function o(e, t) {
17
+ return typeof MediaStream > "u" ? e : new MediaStream([t]);
18
+ }
19
+ function s(e) {
20
+ try {
21
+ e.play?.()?.catch(() => void 0);
22
+ } catch {}
23
+ }
24
+ function c({ maxGain: n = 3, limiter: c, context: l } = {}) {
25
+ let u = a(), d = l ?? (u ? new u() : null);
26
+ if (!d) return {
27
+ attach: () => ({
28
+ setGain: () => void 0,
29
+ gain: 1,
30
+ stop: () => void 0
31
+ }),
32
+ setMasterGain: () => void 0,
33
+ masterGain: 1,
34
+ setOutputDevice: async () => !1,
35
+ outputDevice: "",
36
+ resume: async () => void 0,
37
+ close: () => void 0,
38
+ supported: !1,
39
+ canSelectOutput: !1
40
+ };
41
+ let f = d, p = !l, m = f.createGain(), h = f.createMediaStreamDestination(), g = null;
42
+ if (c === !1) m.connect(h);
43
+ else {
44
+ let e = f.createDynamicsCompressor();
45
+ g = e;
46
+ let t = {
47
+ ...r,
48
+ ...c
49
+ };
50
+ e.threshold.value = t.threshold, e.knee.value = t.knee, e.ratio.value = t.ratio, e.attack.value = t.attack, e.release.value = t.release, m.connect(e), e.connect(h);
51
+ }
52
+ let _ = new Audio();
53
+ _.autoplay = !0, _.srcObject = h.stream, s(_);
54
+ let v = "", y = 1, b = !1, x = /* @__PURE__ */ new Set();
55
+ function S() {
56
+ return {
57
+ setGain: () => void 0,
58
+ gain: 1,
59
+ stop: () => void 0
60
+ };
61
+ }
62
+ function C(e, { gain: t = 1 } = {}) {
63
+ let r = e.getAudioTracks()[0];
64
+ if (b || !r) return S();
65
+ let a = new Audio();
66
+ a.muted = !0, a.autoplay = !0, a.srcObject = e, s(a);
67
+ let c = f.createMediaStreamSource(o(e, r)), l = f.createGain(), u = i(t, n);
68
+ l.gain.value = u, c.connect(l), l.connect(m);
69
+ let d = {
70
+ setGain(e) {
71
+ u = i(e, n), l.gain.value = u;
72
+ },
73
+ get gain() {
74
+ return u;
75
+ },
76
+ stop() {
77
+ x.has(d) && (x.delete(d), c.disconnect(), l.disconnect(), a.srcObject = null, a.pause());
78
+ }
79
+ };
80
+ return x.add(d), d;
81
+ }
82
+ return {
83
+ attach: C,
84
+ setMasterGain(e) {
85
+ b || (y = i(e, n), m.gain.value = y);
86
+ },
87
+ get masterGain() {
88
+ return y;
89
+ },
90
+ async setOutputDevice(e) {
91
+ let n = await t(_, e);
92
+ return n && (v = e), n;
93
+ },
94
+ get outputDevice() {
95
+ return v;
96
+ },
97
+ async resume() {
98
+ await f.resume?.().catch(() => void 0);
99
+ },
100
+ close() {
101
+ if (!b) {
102
+ b = !0;
103
+ for (let e of [...x]) e.stop();
104
+ _.srcObject = null, _.pause(), m.disconnect(), g?.disconnect(), p && f.close().catch(() => void 0);
105
+ }
106
+ },
107
+ supported: !0,
108
+ get canSelectOutput() {
109
+ return e();
110
+ }
111
+ };
112
+ }
113
+ //#endregion
114
+ export { n as DEFAULT_MAX_GAIN, c as createAudioBus };
115
+
116
+ //# sourceMappingURL=audio-bus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-bus.js","names":[],"sources":["../../src/audio/audio-bus.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mixing graph, the per-source handle and the\n * output route are one object's lifetime: the limiter only makes sense after the\n * sum, and the element that carries `setSinkId` is the same one the mix leaves\n * through. Splitting them would hand the caller two halves that are useless apart.\n */\nimport { isAudioOutputSelectionSupported, setAudioOutput } from \"./audio-output\";\n\n/** Default ceiling for a per-source or master gain, as a multiplier. */\nexport const DEFAULT_MAX_GAIN = 3;\n\n/** Shape of the master limiter. Matches `DynamicsCompressorNode`'s params. */\nexport interface LimiterSettings {\n /** dBFS above which the compressor starts working. */\n threshold: number;\n /** dB range over which the curve bends. `0` is a hard knee. */\n knee: number;\n /** Input/output ratio above the threshold. 20 is limiting, not compression. */\n ratio: number;\n /** Seconds to clamp a peak. */\n attack: number;\n /** Seconds to let go. */\n release: number;\n}\n\n/**\n * A limiter, not a compressor: a hard knee at a 20:1 ratio with a 3 ms attack\n * catches the sum before it clips without audibly ducking anything below it.\n */\nconst DEFAULT_LIMITER: LimiterSettings = {\n threshold: -8,\n knee: 0,\n ratio: 20,\n attack: 0.003,\n release: 0.25,\n};\n\n/** Options for {@link createAudioBus}. */\nexport interface AudioBusOptions {\n /** Ceiling for every gain on this bus. Default {@link DEFAULT_MAX_GAIN}. */\n maxGain?: number;\n /** Override the master limiter, or pass `false` to run without one. */\n limiter?: Partial<LimiterSettings> | false;\n /**\n * Reuse an existing `AudioContext` instead of creating one.\n *\n * Browsers cap the number of live contexts (Chrome allows around six), so a\n * page that already has one — a level meter, a player — should hand it over\n * rather than open a second.\n */\n context?: AudioContext;\n}\n\n/** One source attached to the bus. */\nexport interface AudioBusHandle {\n /**\n * Set this source's gain, where `1` is the level it arrived at.\n *\n * Values above `1` are the point of the whole graph: `element.volume` is\n * clamped at `1`, so a quiet talker could only ever be attenuated — the one\n * correction nobody needs.\n */\n setGain: (gain: number) => void;\n /** Current gain, after clamping. */\n readonly gain: number;\n /** Detach this source and release its nodes. The bus stays up. */\n stop: () => void;\n}\n\n/** A running mix. */\nexport interface AudioBus {\n /**\n * Play a stream through the shared mix.\n *\n * @param stream - The stream to play. Only its first audio track is used.\n * @param options - `gain` is the initial multiplier, default `1`.\n * @returns A handle to adjust or detach this source.\n */\n attach: (stream: MediaStream, options?: { gain?: number }) => AudioBusHandle;\n /** Scale every source, on top of its own gain. */\n setMasterGain: (gain: number) => void;\n /** Current master gain, after clamping. */\n readonly masterGain: number;\n /**\n * Route the whole mix to one output device.\n *\n * @param deviceId - Device id from `useMediaDevices().audioOutputs`, or `\"\"`\n * for the system default.\n * @returns `false` when the engine cannot route audio, or the device is gone.\n */\n setOutputDevice: (deviceId: string) => Promise<boolean>;\n /** Device id the mix is routed to. `\"\"` is the system default. */\n readonly outputDevice: string;\n /**\n * Resume a context the browser started suspended.\n *\n * Autoplay policy suspends a context created outside a user gesture, and a\n * suspended context is silent with no error anywhere. Call this from the\n * click that starts playback.\n */\n resume: () => Promise<void>;\n /**\n * Detach everything and close the context this bus created.\n *\n * Idempotent, and the bus stays callable afterwards: `attach` hands back an\n * inert handle instead of throwing. That is not politeness — creating a node\n * on a closed `AudioContext` throws `InvalidStateError`, and the stream that\n * arrives late is the normal case (a WebRTC `ontrack` firing after the\n * component that owned the bus went away).\n */\n close: () => void;\n /** Whether this browser gave us a Web Audio graph at all. */\n readonly supported: boolean;\n /** Whether this browser can route the mix to a chosen output device. */\n readonly canSelectOutput: boolean;\n}\n\n/**\n * Clamp a gain into `0..max`, treating a non-finite value as unity.\n *\n * `NaN` reaches here from an empty input or a failed parse, and assigning it to\n * an `AudioParam` throws — losing the audio for a typo in a number field.\n */\nfunction clampGain(gain: number, max: number): number {\n if (!Number.isFinite(gain)) return 1;\n return Math.min(max, Math.max(0, gain));\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n if (typeof AudioContext !== \"undefined\") return AudioContext;\n return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/** A media element that may support output routing. */\ntype SinkCapableElement = HTMLAudioElement & { setSinkId?: (sinkId: string) => Promise<void> };\n\n/**\n * The stream to feed the graph: just the audio track, when this engine can build\n * one.\n *\n * Isolating the track keeps a camera's video out of the source node. `MediaStream`\n * is missing outside a browser (jsdom, a worker), and there the original stream is\n * the right answer anyway — `createMediaStreamSource` reads its first audio track.\n */\nfunction audioOnly(stream: MediaStream, track: MediaStreamTrack): MediaStream {\n if (typeof MediaStream === \"undefined\") return stream;\n return new MediaStream([track]);\n}\n\n/** Start playback without letting an environment that has no media pipeline throw. */\nfunction playQuietly(element: HTMLAudioElement): void {\n try {\n void element.play?.()?.catch(() => undefined);\n } catch {\n /* jsdom and any engine without a media pipeline — nothing to play anyway */\n }\n}\n\n/**\n * Mix several streams into one output, with gain above 100% and a master limiter.\n *\n * Three things make this a graph instead of a few `<audio>` elements:\n *\n * 1. **`element.volume` is clamped at `1`.** A participant who speaks too quietly\n * can only be turned *down* — the one correction nobody needs. A `GainNode`\n * has no ceiling, so this bus takes one (`maxGain`, default 3).\n * 2. **Clipping is a property of the sum.** Three sources at 200% each are clean\n * alone and distort the instant they play together. A per-source limiter\n * cannot see that; the one after the mix can, which is where this puts it.\n * 3. **`setSinkId` lives on the element, not on the context.** Sending the mix to\n * a headset while the rest of the system keeps the speakers is only reachable\n * by leaving through a `MediaStreamAudioDestinationNode` into a real\n * `<audio>`. `AudioContext.setSinkId` has far thinner support.\n *\n * @param options - See {@link AudioBusOptions}.\n * @returns The bus. On an engine with no Web Audio it is inert but callable, and\n * `supported` is `false` — a page without sound beats a page that throws.\n *\n * @example\n * const bus = createAudioBus({ maxGain: 3 });\n * const handle = bus.attach(remoteStream, { gain: 1 });\n * handle.setGain(2.4); // above 1 — the point of the whole thing\n * await bus.setOutputDevice(headsetId);\n */\nexport function createAudioBus({\n maxGain = DEFAULT_MAX_GAIN,\n limiter: limiterOptions,\n context: injectedContext,\n}: AudioBusOptions = {}): AudioBus {\n const Ctor = audioContextConstructor();\n const context = injectedContext ?? (Ctor ? new Ctor() : null);\n\n if (!context) {\n return {\n attach: () => ({ setGain: () => undefined, gain: 1, stop: () => undefined }),\n setMasterGain: () => undefined,\n masterGain: 1,\n setOutputDevice: async () => false,\n outputDevice: \"\",\n resume: async () => undefined,\n close: () => undefined,\n supported: false,\n canSelectOutput: false,\n };\n }\n\n const graph = context;\n const ownsContext = !injectedContext;\n const master = graph.createGain();\n const destination = graph.createMediaStreamDestination();\n\n let limiterNode: DynamicsCompressorNode | null = null;\n if (limiterOptions === false) {\n master.connect(destination);\n } else {\n const limiter = graph.createDynamicsCompressor();\n limiterNode = limiter;\n const settings = { ...DEFAULT_LIMITER, ...limiterOptions };\n limiter.threshold.value = settings.threshold;\n limiter.knee.value = settings.knee;\n limiter.ratio.value = settings.ratio;\n limiter.attack.value = settings.attack;\n limiter.release.value = settings.release;\n master.connect(limiter);\n limiter.connect(destination);\n }\n\n const element = new Audio();\n element.autoplay = true;\n element.srcObject = destination.stream;\n playQuietly(element);\n\n let sinkId = \"\";\n let masterGain = 1;\n let closed = false;\n const handles = new Set<AudioBusHandle>();\n\n /** The handle a bus hands out when there is nothing to play through. */\n function inertHandle(): AudioBusHandle {\n return { setGain: () => undefined, gain: 1, stop: () => undefined };\n }\n\n /**\n * Play one stream through the mix.\n *\n * The muted `<audio>` anchor is not dead weight, and deleting it as unused is\n * the mistake this comment exists to prevent: Chrome will not pull samples\n * from a `MediaStreamAudioSourceNode` built over a **remote** WebRTC stream\n * unless that same stream is also attached to a media element\n * (crbug.com/687574). Without it the graph is visibly correct and completely\n * silent — a day of debugging for anyone who does not know the bug. It is\n * muted because the audible copy is the one leaving the bus.\n */\n function attach(stream: MediaStream, { gain = 1 }: { gain?: number } = {}): AudioBusHandle {\n const track = stream.getAudioTracks()[0];\n if (closed || !track) return inertHandle();\n\n const anchor = new Audio();\n anchor.muted = true;\n anchor.autoplay = true;\n anchor.srcObject = stream;\n playQuietly(anchor);\n\n const source = graph.createMediaStreamSource(audioOnly(stream, track));\n const node = graph.createGain();\n let current = clampGain(gain, maxGain);\n node.gain.value = current;\n source.connect(node);\n node.connect(master);\n\n const handle: AudioBusHandle = {\n setGain(next: number): void {\n current = clampGain(next, maxGain);\n node.gain.value = current;\n },\n get gain(): number {\n return current;\n },\n stop(): void {\n if (!handles.has(handle)) return;\n handles.delete(handle);\n source.disconnect();\n node.disconnect();\n anchor.srcObject = null;\n anchor.pause();\n },\n };\n handles.add(handle);\n return handle;\n }\n\n return {\n attach,\n setMasterGain(gain: number): void {\n if (closed) return;\n masterGain = clampGain(gain, maxGain);\n master.gain.value = masterGain;\n },\n get masterGain(): number {\n return masterGain;\n },\n async setOutputDevice(deviceId: string): Promise<boolean> {\n const applied = await setAudioOutput(element as SinkCapableElement, deviceId);\n if (applied) sinkId = deviceId;\n return applied;\n },\n get outputDevice(): string {\n return sinkId;\n },\n async resume(): Promise<void> {\n await graph.resume?.().catch(() => undefined);\n },\n close(): void {\n if (closed) return;\n closed = true;\n for (const handle of [...handles]) handle.stop();\n element.srcObject = null;\n element.pause();\n master.disconnect();\n limiterNode?.disconnect();\n if (ownsContext) void graph.close().catch(() => undefined);\n },\n supported: true,\n get canSelectOutput(): boolean {\n return isAudioOutputSelectionSupported();\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=require("./audio-bus.cjs");let t=require("react");function n(n={}){let[r]=(0,t.useState)(()=>e.createAudioBus(n));return(0,t.useEffect)(()=>()=>r.close(),[r]),r}exports.useAudioBus=n;
2
+ //# sourceMappingURL=use-audio-bus.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-audio-bus.cjs","names":[],"sources":["../../src/audio/use-audio-bus.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { createAudioBus, type AudioBus, type AudioBusOptions } from \"./audio-bus\";\n\n/**\n * Keep one {@link AudioBus} alive for as long as the component is mounted.\n *\n * The bus is built on the first render and closed on unmount, which matters more\n * than it looks: browsers cap the number of live `AudioContext`s (Chrome allows\n * around six), so a bus leaked on unmount eventually breaks every later one on\n * the page.\n *\n * Options are read once. A bus is a device route and a mixing graph, not a\n * render output — rebuilding it because a prop changed would drop every attached\n * source mid-sentence. Change gain and output through the bus itself.\n *\n * @param options - See {@link AudioBusOptions}. Read on the first render only.\n * @returns The bus, stable for the component's lifetime.\n *\n * @example\n * const bus = useAudioBus({ maxGain: 3 });\n *\n * useEffect(() => {\n * const handle = bus.attach(stream, { gain: 1 });\n * return () => handle.stop();\n * }, [bus, stream]);\n */\nexport function useAudioBus(options: AudioBusOptions = {}): AudioBus {\n const [bus] = useState<AudioBus>(() => createAudioBus(options));\n\n useEffect(() => {\n return () => bus.close();\n }, [bus]);\n\n return bus;\n}\n"],"mappings":"0DA0BA,SAAgB,EAAY,EAA2B,CAAC,EAAa,CACjE,GAAM,CAAC,IAAA,EAAO,EAAA,SAAA,KAAyB,EAAA,eAAe,CAAO,CAAC,EAM9D,OAJA,EAAA,EAAA,UAAA,SACiB,EAAI,MAAM,EACxB,CAAC,CAAG,CAAC,EAED,CACX"}
@@ -0,0 +1,11 @@
1
+ import { createAudioBus as e } from "./audio-bus.js";
2
+ import { useEffect as t, useState as n } from "react";
3
+ //#region src/audio/use-audio-bus.ts
4
+ function r(r = {}) {
5
+ let [i] = n(() => e(r));
6
+ return t(() => () => i.close(), [i]), i;
7
+ }
8
+ //#endregion
9
+ export { r as useAudioBus };
10
+
11
+ //# sourceMappingURL=use-audio-bus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-audio-bus.js","names":[],"sources":["../../src/audio/use-audio-bus.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { createAudioBus, type AudioBus, type AudioBusOptions } from \"./audio-bus\";\n\n/**\n * Keep one {@link AudioBus} alive for as long as the component is mounted.\n *\n * The bus is built on the first render and closed on unmount, which matters more\n * than it looks: browsers cap the number of live `AudioContext`s (Chrome allows\n * around six), so a bus leaked on unmount eventually breaks every later one on\n * the page.\n *\n * Options are read once. A bus is a device route and a mixing graph, not a\n * render output — rebuilding it because a prop changed would drop every attached\n * source mid-sentence. Change gain and output through the bus itself.\n *\n * @param options - See {@link AudioBusOptions}. Read on the first render only.\n * @returns The bus, stable for the component's lifetime.\n *\n * @example\n * const bus = useAudioBus({ maxGain: 3 });\n *\n * useEffect(() => {\n * const handle = bus.attach(stream, { gain: 1 });\n * return () => handle.stop();\n * }, [bus, stream]);\n */\nexport function useAudioBus(options: AudioBusOptions = {}): AudioBus {\n const [bus] = useState<AudioBus>(() => createAudioBus(options));\n\n useEffect(() => {\n return () => bus.close();\n }, [bus]);\n\n return bus;\n}\n"],"mappings":";;;AA0BA,SAAgB,EAAY,IAA2B,CAAC,GAAa;CACjE,IAAM,CAAC,KAAO,QAAyB,EAAe,CAAO,CAAC;CAM9D,OAJA,cACiB,EAAI,MAAM,GACxB,CAAC,CAAG,CAAC,GAED;AACX"}
@@ -1,2 +1,2 @@
1
- function e(e){let t=null;return()=>t||(t=(async()=>{try{await e()}finally{t=null}})(),t)}exports.createRefreshQueue=e;
1
+ function e(e,t={}){let{getToken:n}=t,r=null,i=null;return()=>r||(n&&i!==null&&n()===i?Promise.resolve():(r=(async()=>{try{await e(),n&&(i=n()??null)}finally{r=null}})(),r))}exports.createRefreshQueue=e;
2
2
  //# sourceMappingURL=refresh-queue.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-queue.cjs","names":[],"sources":["../../src/auth/refresh-queue.ts"],"sourcesContent":["/**\n * Deduplicate concurrent refresh calls. When multiple 401 responses arrive\n * at once, all of them share the same in-flight `refresh()` promise instead\n * of triggering N parallel refreshes.\n *\n * @example\n * const refresh = createRefreshQueue(() => AuthService.refresh());\n *\n * // In every request that hits 401:\n * await refresh();\n * // ...retry the original request\n */\nexport function createRefreshQueue(refresh: () => Promise<void>): () => Promise<void> {\n let current: Promise<void> | null = null;\n\n return () => {\n if (current) return current;\n current = (async () => {\n try {\n await refresh();\n } finally {\n current = null;\n }\n })();\n return current;\n };\n}\n"],"mappings":"AAYA,SAAgB,EAAmB,EAAmD,CAClF,IAAI,EAAgC,KAEpC,UACQ,IACJ,GAAW,SAAY,CACnB,GAAI,CACA,MAAM,EAAQ,CAClB,QAAU,CACN,EAAU,IACd,CACJ,EAAA,CAAG,EACI,EAEf"}
1
+ {"version":3,"file":"refresh-queue.cjs","names":[],"sources":["../../src/auth/refresh-queue.ts"],"sourcesContent":["export interface CreateRefreshQueueOptions {\n /**\n * Reads the credential the refresh installs — typically the access token.\n *\n * Supplying it makes the queue skip a refresh whose work another caller has\n * already done. Without it, only calls that literally overlap in time are\n * collapsed.\n */\n getToken?: () => string | null | undefined;\n}\n\n/**\n * Deduplicate concurrent refresh calls. When multiple 401 responses arrive\n * at once, all of them share the same in-flight `refresh()` promise instead\n * of triggering N parallel refreshes.\n *\n * **In-flight sharing alone does not collapse a burst.** A page that fires\n * several requests at once gets several 401s back, but they do not land inside\n * one window: the stragglers arrive after the first refresh already resolved,\n * find no promise to join, and each starts another one — rotating a token that\n * is already fresh. Measured against a mock backend, five concurrent expired\n * requests took two refreshes, not one.\n *\n * Pass `getToken` to close that gap. The queue remembers the token its last\n * refresh produced, and a call that finds that same token still in place returns\n * immediately, because the refresh it was about to perform has already happened.\n * The same five requests then take exactly one refresh, and so do twenty.\n *\n * @param refresh - Performs the refresh and installs the new credentials.\n * @param options - Optional token reader that enables already-refreshed\n * detection.\n * @returns A function that refreshes at most once per rotation.\n *\n * @example\n * const refresh = createRefreshQueue(() => AuthService.refresh(), {\n * getToken: () => useAuthStore.getState().token,\n * });\n *\n * // In every request that hits 401:\n * await refresh();\n * // ...retry the original request\n */\nexport function createRefreshQueue(\n refresh: () => Promise<void>,\n options: CreateRefreshQueueOptions = {},\n): () => Promise<void> {\n const { getToken } = options;\n let current: Promise<void> | null = null;\n let lastIssued: string | null = null;\n\n return () => {\n if (current) return current;\n\n if (getToken && lastIssued !== null && getToken() === lastIssued) {\n return Promise.resolve();\n }\n\n current = (async () => {\n try {\n await refresh();\n if (getToken) lastIssued = getToken() ?? null;\n } finally {\n current = null;\n }\n })();\n return current;\n };\n}\n"],"mappings":"AA0CA,SAAgB,EACZ,EACA,EAAqC,CAAC,EACnB,CACnB,GAAM,CAAE,YAAa,EACjB,EAAgC,KAChC,EAA4B,KAEhC,UACQ,IAEA,GAAY,IAAe,MAAQ,EAAS,IAAM,EAC3C,QAAQ,QAAQ,GAG3B,GAAW,SAAY,CACnB,GAAI,CACA,MAAM,EAAQ,EACV,IAAU,EAAa,EAAS,GAAK,KAC7C,QAAU,CACN,EAAU,IACd,CACJ,EAAA,CAAG,EACI,GAEf"}
@@ -1,13 +1,13 @@
1
1
  //#region src/auth/refresh-queue.ts
2
- function e(e) {
3
- let t = null;
4
- return () => t || (t = (async () => {
2
+ function e(e, t = {}) {
3
+ let { getToken: n } = t, r = null, i = null;
4
+ return () => r || (n && i !== null && n() === i ? Promise.resolve() : (r = (async () => {
5
5
  try {
6
- await e();
6
+ await e(), n && (i = n() ?? null);
7
7
  } finally {
8
- t = null;
8
+ r = null;
9
9
  }
10
- })(), t);
10
+ })(), r));
11
11
  }
12
12
  //#endregion
13
13
  export { e as createRefreshQueue };
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-queue.js","names":[],"sources":["../../src/auth/refresh-queue.ts"],"sourcesContent":["/**\n * Deduplicate concurrent refresh calls. When multiple 401 responses arrive\n * at once, all of them share the same in-flight `refresh()` promise instead\n * of triggering N parallel refreshes.\n *\n * @example\n * const refresh = createRefreshQueue(() => AuthService.refresh());\n *\n * // In every request that hits 401:\n * await refresh();\n * // ...retry the original request\n */\nexport function createRefreshQueue(refresh: () => Promise<void>): () => Promise<void> {\n let current: Promise<void> | null = null;\n\n return () => {\n if (current) return current;\n current = (async () => {\n try {\n await refresh();\n } finally {\n current = null;\n }\n })();\n return current;\n };\n}\n"],"mappings":";AAYA,SAAgB,EAAmB,GAAmD;CAClF,IAAI,IAAgC;CAEpC,aACQ,MACJ,KAAW,YAAY;EACnB,IAAI;GACA,MAAM,EAAQ;EAClB,UAAU;GACN,IAAU;EACd;CACJ,EAAA,CAAG,GACI;AAEf"}
1
+ {"version":3,"file":"refresh-queue.js","names":[],"sources":["../../src/auth/refresh-queue.ts"],"sourcesContent":["export interface CreateRefreshQueueOptions {\n /**\n * Reads the credential the refresh installs — typically the access token.\n *\n * Supplying it makes the queue skip a refresh whose work another caller has\n * already done. Without it, only calls that literally overlap in time are\n * collapsed.\n */\n getToken?: () => string | null | undefined;\n}\n\n/**\n * Deduplicate concurrent refresh calls. When multiple 401 responses arrive\n * at once, all of them share the same in-flight `refresh()` promise instead\n * of triggering N parallel refreshes.\n *\n * **In-flight sharing alone does not collapse a burst.** A page that fires\n * several requests at once gets several 401s back, but they do not land inside\n * one window: the stragglers arrive after the first refresh already resolved,\n * find no promise to join, and each starts another one — rotating a token that\n * is already fresh. Measured against a mock backend, five concurrent expired\n * requests took two refreshes, not one.\n *\n * Pass `getToken` to close that gap. The queue remembers the token its last\n * refresh produced, and a call that finds that same token still in place returns\n * immediately, because the refresh it was about to perform has already happened.\n * The same five requests then take exactly one refresh, and so do twenty.\n *\n * @param refresh - Performs the refresh and installs the new credentials.\n * @param options - Optional token reader that enables already-refreshed\n * detection.\n * @returns A function that refreshes at most once per rotation.\n *\n * @example\n * const refresh = createRefreshQueue(() => AuthService.refresh(), {\n * getToken: () => useAuthStore.getState().token,\n * });\n *\n * // In every request that hits 401:\n * await refresh();\n * // ...retry the original request\n */\nexport function createRefreshQueue(\n refresh: () => Promise<void>,\n options: CreateRefreshQueueOptions = {},\n): () => Promise<void> {\n const { getToken } = options;\n let current: Promise<void> | null = null;\n let lastIssued: string | null = null;\n\n return () => {\n if (current) return current;\n\n if (getToken && lastIssued !== null && getToken() === lastIssued) {\n return Promise.resolve();\n }\n\n current = (async () => {\n try {\n await refresh();\n if (getToken) lastIssued = getToken() ?? null;\n } finally {\n current = null;\n }\n })();\n return current;\n };\n}\n"],"mappings":";AA0CA,SAAgB,EACZ,GACA,IAAqC,CAAC,GACnB;CACnB,IAAM,EAAE,gBAAa,GACjB,IAAgC,MAChC,IAA4B;CAEhC,aACQ,MAEA,KAAY,MAAe,QAAQ,EAAS,MAAM,IAC3C,QAAQ,QAAQ,KAG3B,KAAW,YAAY;EACnB,IAAI;GAEA,AADA,MAAM,EAAQ,GACV,MAAU,IAAa,EAAS,KAAK;EAC7C,UAAU;GACN,IAAU;EACd;CACJ,EAAA,CAAG,GACI;AAEf"}
@@ -1,2 +1,2 @@
1
- const e=require("../../utils/cn.cjs"),t=require("./RangeSlider.module.cjs");let n=require("react"),r=require("react/jsx-runtime");function i({value:i,onChange:a,min:o=0,max:s=100,step:c=1,label:l,helperText:u,disabled:d=!1,formatValue:f,className:p}){let[m,h]=i,g=s-o,_=(0,n.useMemo)(()=>(m-o)/g*100,[m,o,g]),v=(0,n.useMemo)(()=>(h-o)/g*100,[h,o,g]),y=f?.(i)??`${m} – ${h}`,b=(0,n.useCallback)(e=>{a([Math.min(Number(e.target.value),h),h])},[h,a]),x=(0,n.useCallback)(e=>{let t=Math.max(Number(e.target.value),m);a([m,t])},[m,a]);return(0,r.jsxs)(`div`,{className:e.cn(t.default.wrapper,p),children:[l&&(0,r.jsxs)(`div`,{className:t.default.label,children:[(0,r.jsx)(`span`,{children:l}),(0,r.jsx)(`span`,{className:t.default.value,children:y})]}),(0,r.jsxs)(`div`,{className:t.default.field,children:[(0,r.jsx)(`div`,{className:t.default.track}),(0,r.jsx)(`div`,{className:t.default.fill,style:{left:`${_}%`,right:`${100-v}%`}}),(0,r.jsx)(`input`,{type:`range`,className:t.default.input,min:o,max:s,step:c,value:m,onChange:b,disabled:d,"aria-label":l?`${l} (mínimo)`:`Mínimo`}),(0,r.jsx)(`input`,{type:`range`,className:t.default.input,min:o,max:s,step:c,value:h,onChange:x,disabled:d,"aria-label":l?`${l} (máximo)`:`Máximo`})]}),u&&(0,r.jsx)(`span`,{className:t.default.helper,children:u})]})}exports.RangeSlider=i;
1
+ const e=require("../../utils/cn.cjs"),t=require("./RangeSlider.module.cjs");let n=require("react"),r=require("react/jsx-runtime");function i({value:i,onChange:a,min:o=0,max:s=100,step:c=1,label:l,helperText:u,disabled:d=!1,formatValue:f,"aria-label":p,className:m}){let[h,g]=i,_=s-o,v=(0,n.useMemo)(()=>(h-o)/_*100,[h,o,_]),y=(0,n.useMemo)(()=>(g-o)/_*100,[g,o,_]),b=f?.(i)??`${h} – ${g}`,x=p??l,S=(0,n.useCallback)(e=>{a([Math.min(Number(e.target.value),g),g])},[g,a]),C=(0,n.useCallback)(e=>{let t=Math.max(Number(e.target.value),h);a([h,t])},[h,a]);return(0,r.jsxs)(`div`,{className:e.cn(t.default.wrapper,m),children:[l&&(0,r.jsxs)(`div`,{className:t.default.label,children:[(0,r.jsx)(`span`,{children:l}),(0,r.jsx)(`span`,{className:t.default.value,children:b})]}),(0,r.jsxs)(`div`,{className:t.default.field,children:[(0,r.jsx)(`div`,{className:t.default.track}),(0,r.jsx)(`div`,{className:t.default.fill,style:{left:`${v}%`,right:`${100-y}%`}}),(0,r.jsx)(`input`,{type:`range`,className:t.default.input,min:o,max:s,step:c,value:h,onChange:S,disabled:d,"aria-label":x?`${x} (mínimo)`:`Mínimo`}),(0,r.jsx)(`input`,{type:`range`,className:t.default.input,min:o,max:s,step:c,value:g,onChange:C,disabled:d,"aria-label":x?`${x} (máximo)`:`Máximo`})]}),u&&(0,r.jsx)(`span`,{className:t.default.helper,children:u})]})}exports.RangeSlider=i;
2
2
  //# sourceMappingURL=RangeSlider.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"RangeSlider.cjs","names":[],"sources":["../../../src/components/RangeSlider/RangeSlider.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — min, max and step define the scale, value/onChange\n * the two thumbs, formatValue the readout, and label/helperText/disabled/className\n * the field around it. Same surface as Slider, one thumb more.\n */\nimport { useCallback, useMemo } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./RangeSlider.module.css\";\n\nexport type RangeValue = [number, number];\n\nexport interface RangeSliderProps {\n value: RangeValue;\n onChange: (value: RangeValue) => void;\n min?: number;\n max?: number;\n step?: number;\n label?: string;\n helperText?: string;\n disabled?: boolean;\n /** Formatter for the value badge next to the label. Defaults to `min – max`. */\n formatValue?: (value: RangeValue) => string;\n className?: string;\n}\n\n/**\n * Dual-thumb range slider. Built on two native `<input type=\"range\">` so it\n * stays accessible and works with keyboards/screen readers without\n * heavyweight positioning libs. The active fill is positioned via percentages.\n */\nexport function RangeSlider({\n value,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n label,\n helperText,\n disabled = false,\n formatValue,\n className,\n}: RangeSliderProps) {\n const [low, high] = value;\n const range = max - min;\n\n const fillLeft = useMemo(() => ((low - min) / range) * 100, [low, min, range]);\n const fillRight = useMemo(() => ((high - min) / range) * 100, [high, min, range]);\n\n const valueText = formatValue?.(value) ?? `${low} – ${high}`;\n\n const handleLow = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.min(Number(event.target.value), high);\n onChange([next, high]);\n },\n [high, onChange],\n );\n\n const handleHigh = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.max(Number(event.target.value), low);\n onChange([low, next]);\n },\n [low, onChange],\n );\n\n return (\n <div className={cn(styles.wrapper, className)}>\n {label && (\n <div className={styles.label}>\n <span>{label}</span>\n <span className={styles.value}>{valueText}</span>\n </div>\n )}\n <div className={styles.field}>\n <div className={styles.track} />\n <div\n className={styles.fill}\n style={{ left: `${fillLeft}%`, right: `${100 - fillRight}%` }}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={low}\n onChange={handleLow}\n disabled={disabled}\n aria-label={label ? `${label} (mínimo)` : \"Mínimo\"}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={high}\n onChange={handleHigh}\n disabled={disabled}\n aria-label={label ? `${label} (máximo)` : \"Máximo\"}\n />\n </div>\n {helperText && <span className={styles.helper}>{helperText}</span>}\n </div>\n );\n}\n"],"mappings":"kIA8BA,SAAgB,EAAY,CACxB,QACA,WACA,MAAM,EACN,MAAM,IACN,OAAO,EACP,QACA,aACA,WAAW,GACX,cACA,aACiB,CACjB,GAAM,CAAC,EAAK,GAAQ,EACd,EAAQ,EAAM,EAEd,GAAA,EAAW,EAAA,QAAA,MAAgB,EAAM,GAAO,EAAS,IAAK,CAAC,EAAK,EAAK,CAAK,CAAC,EACvE,GAAA,EAAY,EAAA,QAAA,MAAgB,EAAO,GAAO,EAAS,IAAK,CAAC,EAAM,EAAK,CAAK,CAAC,EAE1E,EAAY,IAAc,CAAK,GAAK,GAAG,EAAI,KAAK,IAEhD,GAAA,EAAY,EAAA,YAAA,CACb,GAAqD,CAElD,EAAS,CADI,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,EAAG,CACxC,EAAM,CAAI,CAAC,CACzB,EACA,CAAC,EAAM,CAAQ,CACnB,EAEM,GAAA,EAAa,EAAA,YAAA,CACd,GAAqD,CAClD,IAAM,EAAO,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,EAAG,CAAG,EACrD,EAAS,CAAC,EAAK,CAAI,CAAC,CACxB,EACA,CAAC,EAAK,CAAQ,CAClB,EAEA,OACI,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,GAAG,EAAA,QAAO,QAAS,CAAS,EAA5C,SAAA,CACK,IACG,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,MAAvB,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,OAAD,CAAA,SAAO,CAAY,CAAA,GACnB,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,MAAQ,SAAA,CAAgB,CAAA,CAC/C,KAET,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,MAAvB,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,KAAQ,CAAA,GAC/B,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,UAAW,EAAA,QAAO,KAClB,MAAO,CAAE,KAAM,GAAG,EAAS,GAAI,MAAO,GAAG,IAAM,EAAU,EAAG,CAC/D,CAAA,GACD,EAAA,EAAA,IAAA,CAAC,QAAD,CACI,KAAK,QACL,UAAW,EAAA,QAAO,MACb,MACA,MACC,OACN,MAAO,EACP,SAAU,EACA,WACV,aAAY,EAAQ,GAAG,EAAM,WAAa,QAC7C,CAAA,GACD,EAAA,EAAA,IAAA,CAAC,QAAD,CACI,KAAK,QACL,UAAW,EAAA,QAAO,MACb,MACA,MACC,OACN,MAAO,EACP,SAAU,EACA,WACV,aAAY,EAAQ,GAAG,EAAM,WAAa,QAC7C,CAAA,CACA,IACJ,IAAc,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,OAAS,SAAA,CAAiB,CAAA,CAChE,GAEb"}
1
+ {"version":3,"file":"RangeSlider.cjs","names":[],"sources":["../../../src/components/RangeSlider/RangeSlider.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — min, max and step define the scale, value/onChange\n * the two thumbs, formatValue the readout, and label/helperText/disabled/className\n * the field around it. Same surface as Slider, one thumb more.\n */\nimport { useCallback, useMemo } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./RangeSlider.module.css\";\n\nexport type RangeValue = [number, number];\n\nexport interface RangeSliderProps {\n value: RangeValue;\n onChange: (value: RangeValue) => void;\n min?: number;\n max?: number;\n step?: number;\n label?: string;\n helperText?: string;\n disabled?: boolean;\n /** Formatter for the value badge next to the label. Defaults to `min – max`. */\n formatValue?: (value: RangeValue) => string;\n /**\n * Accessible name for the pair, for when the visible `label` block does not fit.\n *\n * Each thumb still gets its own suffixed name — `\"Preço (mínimo)\"` and\n * `\"Preço (máximo)\"` — because a screen reader user moving between the two\n * needs to know which end they are on. Takes precedence over `label`.\n */\n \"aria-label\"?: string;\n className?: string;\n}\n\n/**\n * Dual-thumb range slider. Built on two native `<input type=\"range\">` so it\n * stays accessible and works with keyboards/screen readers without\n * heavyweight positioning libs. The active fill is positioned via percentages.\n */\nexport function RangeSlider({\n value,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n label,\n helperText,\n disabled = false,\n formatValue,\n \"aria-label\": ariaLabel,\n className,\n}: RangeSliderProps) {\n const [low, high] = value;\n const range = max - min;\n\n const fillLeft = useMemo(() => ((low - min) / range) * 100, [low, min, range]);\n const fillRight = useMemo(() => ((high - min) / range) * 100, [high, min, range]);\n\n const valueText = formatValue?.(value) ?? `${low} – ${high}`;\n const name = ariaLabel ?? label;\n\n const handleLow = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.min(Number(event.target.value), high);\n onChange([next, high]);\n },\n [high, onChange],\n );\n\n const handleHigh = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.max(Number(event.target.value), low);\n onChange([low, next]);\n },\n [low, onChange],\n );\n\n return (\n <div className={cn(styles.wrapper, className)}>\n {label && (\n <div className={styles.label}>\n <span>{label}</span>\n <span className={styles.value}>{valueText}</span>\n </div>\n )}\n <div className={styles.field}>\n <div className={styles.track} />\n <div\n className={styles.fill}\n style={{ left: `${fillLeft}%`, right: `${100 - fillRight}%` }}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={low}\n onChange={handleLow}\n disabled={disabled}\n aria-label={name ? `${name} (mínimo)` : \"Mínimo\"}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={high}\n onChange={handleHigh}\n disabled={disabled}\n aria-label={name ? `${name} (máximo)` : \"Máximo\"}\n />\n </div>\n {helperText && <span className={styles.helper}>{helperText}</span>}\n </div>\n );\n}\n"],"mappings":"kIAsCA,SAAgB,EAAY,CACxB,QACA,WACA,MAAM,EACN,MAAM,IACN,OAAO,EACP,QACA,aACA,WAAW,GACX,cACA,aAAc,EACd,aACiB,CACjB,GAAM,CAAC,EAAK,GAAQ,EACd,EAAQ,EAAM,EAEd,GAAA,EAAW,EAAA,QAAA,MAAgB,EAAM,GAAO,EAAS,IAAK,CAAC,EAAK,EAAK,CAAK,CAAC,EACvE,GAAA,EAAY,EAAA,QAAA,MAAgB,EAAO,GAAO,EAAS,IAAK,CAAC,EAAM,EAAK,CAAK,CAAC,EAE1E,EAAY,IAAc,CAAK,GAAK,GAAG,EAAI,KAAK,IAChD,EAAO,GAAa,EAEpB,GAAA,EAAY,EAAA,YAAA,CACb,GAAqD,CAElD,EAAS,CADI,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,EAAG,CACxC,EAAM,CAAI,CAAC,CACzB,EACA,CAAC,EAAM,CAAQ,CACnB,EAEM,GAAA,EAAa,EAAA,YAAA,CACd,GAAqD,CAClD,IAAM,EAAO,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,EAAG,CAAG,EACrD,EAAS,CAAC,EAAK,CAAI,CAAC,CACxB,EACA,CAAC,EAAK,CAAQ,CAClB,EAEA,OACI,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,GAAG,EAAA,QAAO,QAAS,CAAS,EAA5C,SAAA,CACK,IACG,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,MAAvB,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,OAAD,CAAA,SAAO,CAAY,CAAA,GACnB,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,MAAQ,SAAA,CAAgB,CAAA,CAC/C,KAET,EAAA,EAAA,KAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,MAAvB,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,KAAQ,CAAA,GAC/B,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,UAAW,EAAA,QAAO,KAClB,MAAO,CAAE,KAAM,GAAG,EAAS,GAAI,MAAO,GAAG,IAAM,EAAU,EAAG,CAC/D,CAAA,GACD,EAAA,EAAA,IAAA,CAAC,QAAD,CACI,KAAK,QACL,UAAW,EAAA,QAAO,MACb,MACA,MACC,OACN,MAAO,EACP,SAAU,EACA,WACV,aAAY,EAAO,GAAG,EAAK,WAAa,QAC3C,CAAA,GACD,EAAA,EAAA,IAAA,CAAC,QAAD,CACI,KAAK,QACL,UAAW,EAAA,QAAO,MACb,MACA,MACC,OACN,MAAO,EACP,SAAU,EACA,WACV,aAAY,EAAO,GAAG,EAAK,WAAa,QAC3C,CAAA,CACA,IACJ,IAAc,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,UAAW,EAAA,QAAO,OAAS,SAAA,CAAiB,CAAA,CAChE,GAEb"}
@@ -3,29 +3,29 @@ import t from "./RangeSlider.module.js";
3
3
  import { useCallback as n, useMemo as r } from "react";
4
4
  import { jsx as i, jsxs as a } from "react/jsx-runtime";
5
5
  //#region src/components/RangeSlider/RangeSlider.tsx
6
- function o({ value: o, onChange: s, min: c = 0, max: l = 100, step: u = 1, label: d, helperText: f, disabled: p = !1, formatValue: m, className: h }) {
7
- let [g, _] = o, v = l - c, y = r(() => (g - c) / v * 100, [
8
- g,
9
- c,
10
- v
11
- ]), b = r(() => (_ - c) / v * 100, [
6
+ function o({ value: o, onChange: s, min: c = 0, max: l = 100, step: u = 1, label: d, helperText: f, disabled: p = !1, formatValue: m, "aria-label": h, className: g }) {
7
+ let [_, v] = o, y = l - c, b = r(() => (_ - c) / y * 100, [
12
8
  _,
13
9
  c,
14
- v
15
- ]), x = m?.(o) ?? `${g} ${_}`, S = n((e) => {
16
- s([Math.min(Number(e.target.value), _), _]);
17
- }, [_, s]), C = n((e) => {
18
- let t = Math.max(Number(e.target.value), g);
19
- s([g, t]);
20
- }, [g, s]);
10
+ y
11
+ ]), x = r(() => (v - c) / y * 100, [
12
+ v,
13
+ c,
14
+ y
15
+ ]), S = m?.(o) ?? `${_} – ${v}`, C = h ?? d, w = n((e) => {
16
+ s([Math.min(Number(e.target.value), v), v]);
17
+ }, [v, s]), T = n((e) => {
18
+ let t = Math.max(Number(e.target.value), _);
19
+ s([_, t]);
20
+ }, [_, s]);
21
21
  return /* @__PURE__ */ a("div", {
22
- className: e(t.wrapper, h),
22
+ className: e(t.wrapper, g),
23
23
  children: [
24
24
  d && /* @__PURE__ */ a("div", {
25
25
  className: t.label,
26
26
  children: [/* @__PURE__ */ i("span", { children: d }), /* @__PURE__ */ i("span", {
27
27
  className: t.value,
28
- children: x
28
+ children: S
29
29
  })]
30
30
  }),
31
31
  /* @__PURE__ */ a("div", {
@@ -35,8 +35,8 @@ function o({ value: o, onChange: s, min: c = 0, max: l = 100, step: u = 1, label
35
35
  /* @__PURE__ */ i("div", {
36
36
  className: t.fill,
37
37
  style: {
38
- left: `${y}%`,
39
- right: `${100 - b}%`
38
+ left: `${b}%`,
39
+ right: `${100 - x}%`
40
40
  }
41
41
  }),
42
42
  /* @__PURE__ */ i("input", {
@@ -45,10 +45,10 @@ function o({ value: o, onChange: s, min: c = 0, max: l = 100, step: u = 1, label
45
45
  min: c,
46
46
  max: l,
47
47
  step: u,
48
- value: g,
49
- onChange: S,
48
+ value: _,
49
+ onChange: w,
50
50
  disabled: p,
51
- "aria-label": d ? `${d} (mínimo)` : "Mínimo"
51
+ "aria-label": C ? `${C} (mínimo)` : "Mínimo"
52
52
  }),
53
53
  /* @__PURE__ */ i("input", {
54
54
  type: "range",
@@ -56,10 +56,10 @@ function o({ value: o, onChange: s, min: c = 0, max: l = 100, step: u = 1, label
56
56
  min: c,
57
57
  max: l,
58
58
  step: u,
59
- value: _,
60
- onChange: C,
59
+ value: v,
60
+ onChange: T,
61
61
  disabled: p,
62
- "aria-label": d ? `${d} (máximo)` : "Máximo"
62
+ "aria-label": C ? `${C} (máximo)` : "Máximo"
63
63
  })
64
64
  ]
65
65
  }),
@@ -1 +1 @@
1
- {"version":3,"file":"RangeSlider.js","names":[],"sources":["../../../src/components/RangeSlider/RangeSlider.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — min, max and step define the scale, value/onChange\n * the two thumbs, formatValue the readout, and label/helperText/disabled/className\n * the field around it. Same surface as Slider, one thumb more.\n */\nimport { useCallback, useMemo } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./RangeSlider.module.css\";\n\nexport type RangeValue = [number, number];\n\nexport interface RangeSliderProps {\n value: RangeValue;\n onChange: (value: RangeValue) => void;\n min?: number;\n max?: number;\n step?: number;\n label?: string;\n helperText?: string;\n disabled?: boolean;\n /** Formatter for the value badge next to the label. Defaults to `min – max`. */\n formatValue?: (value: RangeValue) => string;\n className?: string;\n}\n\n/**\n * Dual-thumb range slider. Built on two native `<input type=\"range\">` so it\n * stays accessible and works with keyboards/screen readers without\n * heavyweight positioning libs. The active fill is positioned via percentages.\n */\nexport function RangeSlider({\n value,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n label,\n helperText,\n disabled = false,\n formatValue,\n className,\n}: RangeSliderProps) {\n const [low, high] = value;\n const range = max - min;\n\n const fillLeft = useMemo(() => ((low - min) / range) * 100, [low, min, range]);\n const fillRight = useMemo(() => ((high - min) / range) * 100, [high, min, range]);\n\n const valueText = formatValue?.(value) ?? `${low} – ${high}`;\n\n const handleLow = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.min(Number(event.target.value), high);\n onChange([next, high]);\n },\n [high, onChange],\n );\n\n const handleHigh = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.max(Number(event.target.value), low);\n onChange([low, next]);\n },\n [low, onChange],\n );\n\n return (\n <div className={cn(styles.wrapper, className)}>\n {label && (\n <div className={styles.label}>\n <span>{label}</span>\n <span className={styles.value}>{valueText}</span>\n </div>\n )}\n <div className={styles.field}>\n <div className={styles.track} />\n <div\n className={styles.fill}\n style={{ left: `${fillLeft}%`, right: `${100 - fillRight}%` }}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={low}\n onChange={handleLow}\n disabled={disabled}\n aria-label={label ? `${label} (mínimo)` : \"Mínimo\"}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={high}\n onChange={handleHigh}\n disabled={disabled}\n aria-label={label ? `${label} (máximo)` : \"Máximo\"}\n />\n </div>\n {helperText && <span className={styles.helper}>{helperText}</span>}\n </div>\n );\n}\n"],"mappings":";;;;;AA8BA,SAAgB,EAAY,EACxB,UACA,aACA,SAAM,GACN,SAAM,KACN,UAAO,GACP,UACA,eACA,cAAW,IACX,gBACA,gBACiB;CACjB,IAAM,CAAC,GAAK,KAAQ,GACd,IAAQ,IAAM,GAEd,IAAW,SAAgB,IAAM,KAAO,IAAS,KAAK;EAAC;EAAK;EAAK;CAAK,CAAC,GACvE,IAAY,SAAgB,IAAO,KAAO,IAAS,KAAK;EAAC;EAAM;EAAK;CAAK,CAAC,GAE1E,IAAY,IAAc,CAAK,KAAK,GAAG,EAAI,KAAK,KAEhD,IAAY,GACb,MAAqD;EAElD,EAAS,CADI,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,GAAG,CACxC,GAAM,CAAI,CAAC;CACzB,GACA,CAAC,GAAM,CAAQ,CACnB,GAEM,IAAa,GACd,MAAqD;EAClD,IAAM,IAAO,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,GAAG,CAAG;EACrD,EAAS,CAAC,GAAK,CAAI,CAAC;CACxB,GACA,CAAC,GAAK,CAAQ,CAClB;CAEA,OACI,kBAAC,OAAD;EAAK,WAAW,EAAG,EAAO,SAAS,CAAS;EAA5C,UAAA;GACK,KACG,kBAAC,OAAD;IAAK,WAAW,EAAO;IAAvB,UAAA,CACI,kBAAC,QAAD,EAAA,UAAO,EAAY,CAAA,GACnB,kBAAC,QAAD;KAAM,WAAW,EAAO;KAAQ,UAAA;IAAgB,CAAA,CAC/C;;GAET,kBAAC,OAAD;IAAK,WAAW,EAAO;IAAvB,UAAA;KACI,kBAAC,OAAD,EAAK,WAAW,EAAO,MAAQ,CAAA;KAC/B,kBAAC,OAAD;MACI,WAAW,EAAO;MAClB,OAAO;OAAE,MAAM,GAAG,EAAS;OAAI,OAAO,GAAG,MAAM,EAAU;MAAG;KAC/D,CAAA;KACD,kBAAC,SAAD;MACI,MAAK;MACL,WAAW,EAAO;MACb;MACA;MACC;MACN,OAAO;MACP,UAAU;MACA;MACV,cAAY,IAAQ,GAAG,EAAM,aAAa;KAC7C,CAAA;KACD,kBAAC,SAAD;MACI,MAAK;MACL,WAAW,EAAO;MACb;MACA;MACC;MACN,OAAO;MACP,UAAU;MACA;MACV,cAAY,IAAQ,GAAG,EAAM,aAAa;KAC7C,CAAA;IACA;;GACJ,KAAc,kBAAC,QAAD;IAAM,WAAW,EAAO;IAAS,UAAA;GAAiB,CAAA;EAChE;;AAEb"}
1
+ {"version":3,"file":"RangeSlider.js","names":[],"sources":["../../../src/components/RangeSlider/RangeSlider.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count — min, max and step define the scale, value/onChange\n * the two thumbs, formatValue the readout, and label/helperText/disabled/className\n * the field around it. Same surface as Slider, one thumb more.\n */\nimport { useCallback, useMemo } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./RangeSlider.module.css\";\n\nexport type RangeValue = [number, number];\n\nexport interface RangeSliderProps {\n value: RangeValue;\n onChange: (value: RangeValue) => void;\n min?: number;\n max?: number;\n step?: number;\n label?: string;\n helperText?: string;\n disabled?: boolean;\n /** Formatter for the value badge next to the label. Defaults to `min – max`. */\n formatValue?: (value: RangeValue) => string;\n /**\n * Accessible name for the pair, for when the visible `label` block does not fit.\n *\n * Each thumb still gets its own suffixed name — `\"Preço (mínimo)\"` and\n * `\"Preço (máximo)\"` — because a screen reader user moving between the two\n * needs to know which end they are on. Takes precedence over `label`.\n */\n \"aria-label\"?: string;\n className?: string;\n}\n\n/**\n * Dual-thumb range slider. Built on two native `<input type=\"range\">` so it\n * stays accessible and works with keyboards/screen readers without\n * heavyweight positioning libs. The active fill is positioned via percentages.\n */\nexport function RangeSlider({\n value,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n label,\n helperText,\n disabled = false,\n formatValue,\n \"aria-label\": ariaLabel,\n className,\n}: RangeSliderProps) {\n const [low, high] = value;\n const range = max - min;\n\n const fillLeft = useMemo(() => ((low - min) / range) * 100, [low, min, range]);\n const fillRight = useMemo(() => ((high - min) / range) * 100, [high, min, range]);\n\n const valueText = formatValue?.(value) ?? `${low} – ${high}`;\n const name = ariaLabel ?? label;\n\n const handleLow = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.min(Number(event.target.value), high);\n onChange([next, high]);\n },\n [high, onChange],\n );\n\n const handleHigh = useCallback(\n (event: React.ChangeEvent<HTMLInputElement>): void => {\n const next = Math.max(Number(event.target.value), low);\n onChange([low, next]);\n },\n [low, onChange],\n );\n\n return (\n <div className={cn(styles.wrapper, className)}>\n {label && (\n <div className={styles.label}>\n <span>{label}</span>\n <span className={styles.value}>{valueText}</span>\n </div>\n )}\n <div className={styles.field}>\n <div className={styles.track} />\n <div\n className={styles.fill}\n style={{ left: `${fillLeft}%`, right: `${100 - fillRight}%` }}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={low}\n onChange={handleLow}\n disabled={disabled}\n aria-label={name ? `${name} (mínimo)` : \"Mínimo\"}\n />\n <input\n type=\"range\"\n className={styles.input}\n min={min}\n max={max}\n step={step}\n value={high}\n onChange={handleHigh}\n disabled={disabled}\n aria-label={name ? `${name} (máximo)` : \"Máximo\"}\n />\n </div>\n {helperText && <span className={styles.helper}>{helperText}</span>}\n </div>\n );\n}\n"],"mappings":";;;;;AAsCA,SAAgB,EAAY,EACxB,UACA,aACA,SAAM,GACN,SAAM,KACN,UAAO,GACP,UACA,eACA,cAAW,IACX,gBACA,cAAc,GACd,gBACiB;CACjB,IAAM,CAAC,GAAK,KAAQ,GACd,IAAQ,IAAM,GAEd,IAAW,SAAgB,IAAM,KAAO,IAAS,KAAK;EAAC;EAAK;EAAK;CAAK,CAAC,GACvE,IAAY,SAAgB,IAAO,KAAO,IAAS,KAAK;EAAC;EAAM;EAAK;CAAK,CAAC,GAE1E,IAAY,IAAc,CAAK,KAAK,GAAG,EAAI,KAAK,KAChD,IAAO,KAAa,GAEpB,IAAY,GACb,MAAqD;EAElD,EAAS,CADI,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,GAAG,CACxC,GAAM,CAAI,CAAC;CACzB,GACA,CAAC,GAAM,CAAQ,CACnB,GAEM,IAAa,GACd,MAAqD;EAClD,IAAM,IAAO,KAAK,IAAI,OAAO,EAAM,OAAO,KAAK,GAAG,CAAG;EACrD,EAAS,CAAC,GAAK,CAAI,CAAC;CACxB,GACA,CAAC,GAAK,CAAQ,CAClB;CAEA,OACI,kBAAC,OAAD;EAAK,WAAW,EAAG,EAAO,SAAS,CAAS;EAA5C,UAAA;GACK,KACG,kBAAC,OAAD;IAAK,WAAW,EAAO;IAAvB,UAAA,CACI,kBAAC,QAAD,EAAA,UAAO,EAAY,CAAA,GACnB,kBAAC,QAAD;KAAM,WAAW,EAAO;KAAQ,UAAA;IAAgB,CAAA,CAC/C;;GAET,kBAAC,OAAD;IAAK,WAAW,EAAO;IAAvB,UAAA;KACI,kBAAC,OAAD,EAAK,WAAW,EAAO,MAAQ,CAAA;KAC/B,kBAAC,OAAD;MACI,WAAW,EAAO;MAClB,OAAO;OAAE,MAAM,GAAG,EAAS;OAAI,OAAO,GAAG,MAAM,EAAU;MAAG;KAC/D,CAAA;KACD,kBAAC,SAAD;MACI,MAAK;MACL,WAAW,EAAO;MACb;MACA;MACC;MACN,OAAO;MACP,UAAU;MACA;MACV,cAAY,IAAO,GAAG,EAAK,aAAa;KAC3C,CAAA;KACD,kBAAC,SAAD;MACI,MAAK;MACL,WAAW,EAAO;MACb;MACA;MACC;MACN,OAAO;MACP,UAAU;MACA;MACV,cAAY,IAAO,GAAG,EAAK,aAAa;KAC3C,CAAA;IACA;;GACJ,KAAc,kBAAC,QAAD;IAAM,WAAW,EAAO;IAAS,UAAA;GAAiB,CAAA;EAChE;;AAEb"}