@joewinke/jatui 0.35.0 → 0.36.1
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.
- package/CLAUDE.md +8 -3
- package/dist/actions/reveal.d.ts +72 -11
- package/dist/actions/reveal.js +103 -14
- package/dist/components/AudioRecorder.svelte +23 -1
- package/dist/components/AudioRecorder.svelte.d.ts +13 -1
- package/dist/components/BulkActionBar.svelte +17 -2
- package/dist/components/BulkActionBar.svelte.d.ts +5 -0
- package/dist/components/Drawer.svelte +20 -1
- package/dist/components/Drawer.svelte.d.ts +11 -0
- package/dist/components/FloatingActionBar.svelte +26 -11
- package/dist/components/FloatingActionBar.svelte.d.ts +5 -0
- package/dist/components/KeyboardShortcutsOverlay.svelte +180 -7
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +21 -0
- package/dist/components/ThumbnailGrid.svelte +34 -7
- package/dist/components/ThumbnailGrid.svelte.d.ts +17 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/motion/index.d.ts +1 -1
- package/dist/motion/index.js +1 -1
- package/dist/utils/audioRecorder.d.ts +6 -1
- package/dist/utils/audioRecorder.js +5 -0
- package/dist/utils/audioRecorderCore.d.ts +34 -0
- package/dist/utils/audioRecorderCore.js +43 -6
- package/dist/utils/keyboardShortcutHub.d.ts +73 -0
- package/dist/utils/keyboardShortcutHub.js +89 -0
- package/dist/utils/mentionParser.d.ts +44 -2
- package/dist/utils/mentionParser.js +84 -4
- package/package.json +2 -1
package/CLAUDE.md
CHANGED
|
@@ -116,7 +116,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
116
116
|
|
|
117
117
|
| Component | Purpose | Key Props |
|
|
118
118
|
|-----------|---------|-----------|
|
|
119
|
-
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `addable`+`onAdd`, `onSelect`, `cell` snippet |
|
|
119
|
+
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
|
|
120
120
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
121
121
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
122
122
|
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
@@ -232,14 +232,19 @@ const rec = createAudioRecorder({
|
|
|
232
232
|
mode: "push", // 'toggle' (default) | 'push' (hold-to-talk: pointerdown→start, pointerup→stop;
|
|
233
233
|
// a stop during stream acquisition cancels silently, matching VoiceMicButton)
|
|
234
234
|
maxDurationMs: 0, // auto-stop ceiling; 0 = until stopped
|
|
235
|
+
timesliceMs: 1000, // MediaRecorder.start(timeslice) — chunks fire onChunk; 0 = single blob until stop
|
|
235
236
|
onStart?, onPause?, onResume?, onCancel?,
|
|
236
237
|
onStop?, // (result: AudioRecordingResult) — blob/mimeType/durationSeconds/sizeBytes;
|
|
237
238
|
// may be async, recorder stays 'processing' until it settles
|
|
238
239
|
onError?, // (err: { kind, message }) — kind: unsupported|denied|no-mic|start-failed|too-short|handler-failed
|
|
239
240
|
onFrame?, // ({ level, frequency, time }) per animation frame — draw your own visualization
|
|
241
|
+
onChunk?, // (chunk: AudioRecorderChunk) per timeslice — { blob, mimeType, sizeBytes (cumulative),
|
|
242
|
+
// elapsedMs (pause-adjusted), index }; the take still accumulates for onStop
|
|
243
|
+
// (Scribe: full take + restartable chunks)
|
|
240
244
|
})
|
|
241
245
|
// Reactive state (Svelte 5 getters): rec.phase ('idle'|'starting'|'recording'|'processing'|'done'),
|
|
242
|
-
// rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported
|
|
246
|
+
// rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported,
|
|
247
|
+
// rec.stream (live MediaStream while recording, else null — for WebRTC / requestData()-driven streaming)
|
|
243
248
|
// Methods: start() stop() pause() resume() cancel() reset() destroy()
|
|
244
249
|
```
|
|
245
250
|
|
|
@@ -334,7 +339,7 @@ const callbacks: AnnotationCallbacks = {
|
|
|
334
339
|
|
|
335
340
|
| Component | Purpose | Key Props |
|
|
336
341
|
|-----------|---------|-----------|
|
|
337
|
-
| `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable) |
|
|
342
|
+
| `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. Host/contributor mode: one `mode="host"` overlay is the page-wide `?` hub that aggregates every `mode="auto"` instance's sections under that instance's `sectionTitle`; contributors render nothing (no panel, no hint) and forward `open` to the host via `requestOpen()`. With no host mounted, `auto` self-renders (portal/contract-page fallback); `mode="standalone"` never defers. `showHint={false}` opts out of the closed-state press-`?` hint. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable), `mode?: 'auto' | 'host' | 'standalone'`, `sectionTitle?: string`, `showHint?: boolean` |
|
|
338
343
|
|
|
339
344
|
**Shortcut registry** (`createShortcutRegistry()`, or the lazy `getShortcutRegistry()` singleton) — the data source for the overlay, so apps stop hand-wiring a keydown handler plus a separate list for the overlay. Shortcuts register against a **context** and return an unregister fn (return it from `$effect`). Contexts form a stack: `global` is the bottom, `pushContext(name, { modal?, label? })` returns a pop fn, the top context shadows the same combo lower down, and a `modal` context suppresses everything beneath it (including `global`). Handled for you: no firing while typing in text inputs/textarea/select/contenteditable (opt in per shortcut with `allowInInput`), IME composition and `defaultPrevented` events are skipped, `mod` = ⌘ on macOS / Ctrl elsewhere, exact modifier matching (`?` doesn't need `shift`), and macOS Option combos fall back to `event.code`. Per shortcut: `enabled()`, `allowRepeat`, `preventDefault`, `group` (overlay section), `hidden`, `label` (keycap override).
|
|
340
345
|
|
package/dist/actions/reveal.d.ts
CHANGED
|
@@ -2,12 +2,54 @@
|
|
|
2
2
|
* Scroll-reveal action — reveals an element the first time it scrolls into
|
|
3
3
|
* the viewport (IntersectionObserver, fires once, then unobserves).
|
|
4
4
|
*
|
|
5
|
-
* Ported from chimaro's `use:reveal` (jatui-m4uz3.3)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Ported from chimaro's `use:reveal` (jatui-m4uz3.3), then hardened with the
|
|
6
|
+
* MOS reveal fixes (jatui-jtip4, from mardukos's `src/lib/actions/reveal.ts`):
|
|
7
|
+
*
|
|
8
|
+
* 1. **threshold defaults to 0** (was 0.1). A tall element in a short
|
|
9
|
+
* viewport — a 9000px email preview in an 800px pane — can only ever
|
|
10
|
+
* intersect ~8%, so a fractional default made it sit at `opacity: 0`
|
|
11
|
+
* forever (the blank-reading-pane bug). Elements still opt into a
|
|
12
|
+
* fractional threshold explicitly.
|
|
13
|
+
* 2. **zero-area guard**: an entry whose `boundingClientRect` is 0×0 means
|
|
14
|
+
* the element sits inside a `display: none` subtree — that is not
|
|
15
|
+
* "below the fold", and waiting for an intersection that can't be
|
|
16
|
+
* trusted left routed panes permanently blank. Such entries reveal
|
|
17
|
+
* immediately *without* the entrance (fail visible).
|
|
18
|
+
* 3. **entrance classes are stripped on `animationend`**:
|
|
19
|
+
* `animation-fill-mode: both` keeps the animation's `transform` applied
|
|
20
|
+
* after it finishes, which silently turns the node into the containing
|
|
21
|
+
* block for `position: fixed` descendants (tiptap drag handles and other
|
|
22
|
+
* overlays land wrong). Removing `.jatui-reveal` + the variant class once
|
|
23
|
+
* the entrance has played restores a transform-free node. Bubbled
|
|
24
|
+
* `animationend`s from children and unrelated animations are ignored.
|
|
25
|
+
*
|
|
26
|
+
* MOS extras also ported:
|
|
27
|
+
*
|
|
28
|
+
* - **delay normalization + stagger cap** — `delay < 1` is seconds (the
|
|
29
|
+
* chimaro contract: `0.2` = 200ms); `delay >= 1` is read as milliseconds
|
|
30
|
+
* (ported call sites keep passing `200`/`500`). Either way the effective
|
|
31
|
+
* wait is capped at {@link REVEAL_MAX_DELAY_SECONDS} (0.3s), so a unit
|
|
32
|
+
* mix-up or an over-long stagger can never hold content invisible.
|
|
33
|
+
* - **`data-reveal="off"` surface switch** — any ancestor carrying the
|
|
34
|
+
* attribute opts its whole subtree out of reveal: the action no-ops and
|
|
35
|
+
* the content simply renders visible.
|
|
36
|
+
* - **paint-first (below-fold-only entrances)** — if the observer's very
|
|
37
|
+
* first delivery reports the element already intersecting (hydration or
|
|
38
|
+
* first layout: it was painted in view), it is revealed *without* the
|
|
39
|
+
* entrance instead of replaying an animation over content the user can
|
|
40
|
+
* already see. Only content that actually starts below the fold animates.
|
|
41
|
+
* The check rides the observer itself, so clipping by an inner scroller is
|
|
42
|
+
* respected (rows scrolled out of a scroll box still animate when the box
|
|
43
|
+
* scrolls them into view).
|
|
44
|
+
* - **live `prefers-reduced-motion` listener** — the media query is watched
|
|
45
|
+
* for the lifetime of the action; switching to reduce while nodes are
|
|
46
|
+
* still hidden reveals them immediately (no entrance) and unobserves.
|
|
47
|
+
*
|
|
48
|
+
* The public API stays chimaro-compatible (animation / delay / threshold) so
|
|
49
|
+
* chimaro's local copy stays deletable in favor of this import — but the
|
|
50
|
+
* animista.css dependency is NOT shipped: each named variant remaps onto a
|
|
51
|
+
* `jatui-reveal-*` class in `jatui/styles/motion.css`, whose keyframes and
|
|
52
|
+
* timing are built entirely from the `--motion-*` tokens.
|
|
11
53
|
*
|
|
12
54
|
* Usage:
|
|
13
55
|
* <div use:reveal> → fade-in-bottom on scroll
|
|
@@ -15,23 +57,42 @@
|
|
|
15
57
|
* <div use:reveal={{ delay: 0.2 }}> → 200ms staggered delay
|
|
16
58
|
* <div use:reveal={{ threshold: 0.3 }}> → triggers at 30% visibility
|
|
17
59
|
*
|
|
18
|
-
*
|
|
19
|
-
* units), unlike staggerEntrance's ms-based options. Use fractional values
|
|
20
|
-
* for staggers (0.2 = 200ms).
|
|
60
|
+
* Opt a subtree out entirely: `<section data-reveal="off">…</section>`.
|
|
21
61
|
*/
|
|
22
62
|
export type RevealVariant = 'fade-in' | 'fade-in-bottom' | 'fade-in-top' | 'fade-in-left' | 'fade-in-right' | 'scale-in-center';
|
|
23
63
|
export interface RevealOptions {
|
|
24
64
|
/** Named entrance variant, rendered by a `jatui-reveal-*` class in motion.css. Default 'fade-in-bottom'. */
|
|
25
65
|
animation?: RevealVariant;
|
|
26
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Delay before the entrance runs. Values below 1 are SECONDS (chimaro's
|
|
68
|
+
* contract — CSS animation-delay units: 0.2 = 200ms); values ≥ 1 are read
|
|
69
|
+
* as MILLISECONDS (250 → 0.25s). The effective wait is capped at
|
|
70
|
+
* {@link REVEAL_MAX_DELAY_SECONDS} so content is never held invisible.
|
|
71
|
+
* Default 0.
|
|
72
|
+
*/
|
|
27
73
|
delay?: number;
|
|
28
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Fraction of the element that must be visible before revealing (0–1).
|
|
76
|
+
* Default 0 — any intersection counts, so tall elements in short
|
|
77
|
+
* viewports still reveal (a fractional default strands them at opacity 0).
|
|
78
|
+
*/
|
|
29
79
|
threshold?: number;
|
|
30
80
|
}
|
|
31
81
|
/** Class applied while the element is waiting to be revealed (opacity: 0). */
|
|
32
82
|
export declare const REVEAL_HIDDEN_CLASS = "jatui-reveal-hidden";
|
|
33
83
|
/** Base class carrying the token-driven duration/easing/fill. */
|
|
34
84
|
export declare const REVEAL_BASE_CLASS = "jatui-reveal";
|
|
85
|
+
/**
|
|
86
|
+
* Selector that switches reveal off for a subtree: any ancestor carrying
|
|
87
|
+
* `data-reveal="off"` makes the action a no-op for nodes inside it.
|
|
88
|
+
*/
|
|
89
|
+
export declare const REVEAL_OFF_SELECTOR = "[data-reveal=\"off\"]";
|
|
90
|
+
/**
|
|
91
|
+
* Cap (in seconds) on how long any reveal may hold its content hidden before
|
|
92
|
+
* the entrance starts — keeps unit mix-ups and long stagger trains from
|
|
93
|
+
* blanking content.
|
|
94
|
+
*/
|
|
95
|
+
export declare const REVEAL_MAX_DELAY_SECONDS = 0.3;
|
|
35
96
|
export declare function reveal(node: HTMLElement, options?: RevealOptions): {
|
|
36
97
|
destroy(): void;
|
|
37
98
|
};
|
package/dist/actions/reveal.js
CHANGED
|
@@ -2,40 +2,129 @@
|
|
|
2
2
|
export const REVEAL_HIDDEN_CLASS = 'jatui-reveal-hidden';
|
|
3
3
|
/** Base class carrying the token-driven duration/easing/fill. */
|
|
4
4
|
export const REVEAL_BASE_CLASS = 'jatui-reveal';
|
|
5
|
+
/**
|
|
6
|
+
* Selector that switches reveal off for a subtree: any ancestor carrying
|
|
7
|
+
* `data-reveal="off"` makes the action a no-op for nodes inside it.
|
|
8
|
+
*/
|
|
9
|
+
export const REVEAL_OFF_SELECTOR = '[data-reveal="off"]';
|
|
10
|
+
/**
|
|
11
|
+
* Cap (in seconds) on how long any reveal may hold its content hidden before
|
|
12
|
+
* the entrance starts — keeps unit mix-ups and long stagger trains from
|
|
13
|
+
* blanking content.
|
|
14
|
+
*/
|
|
15
|
+
export const REVEAL_MAX_DELAY_SECONDS = 0.3;
|
|
5
16
|
function variantClass(animation) {
|
|
6
17
|
return `${REVEAL_BASE_CLASS}-${animation}`;
|
|
7
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a delay option to seconds under the stagger cap. `< 1` is
|
|
21
|
+
* seconds (chimaro contract), `≥ 1` is read as milliseconds — call sites
|
|
22
|
+
* ported from ms-based APIs pass `200` meaning 0.2s, not 200s.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeDelay(delay) {
|
|
25
|
+
if (!delay || delay <= 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const seconds = delay >= 1 ? delay / 1000 : delay;
|
|
28
|
+
return Math.min(seconds, REVEAL_MAX_DELAY_SECONDS);
|
|
29
|
+
}
|
|
30
|
+
/** A 0×0 rect means the element is inside a `display: none` subtree. */
|
|
31
|
+
function isZeroArea(rect) {
|
|
32
|
+
return !!rect && rect.width === 0 && rect.height === 0;
|
|
33
|
+
}
|
|
8
34
|
export function reveal(node, options) {
|
|
9
35
|
const animation = options?.animation ?? 'fade-in-bottom';
|
|
10
|
-
const threshold = options?.threshold ?? 0
|
|
11
|
-
|
|
12
|
-
const prefersReducedMotion = typeof window !== 'undefined' &&
|
|
13
|
-
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
36
|
+
const threshold = options?.threshold ?? 0;
|
|
37
|
+
const variant = variantClass(animation);
|
|
14
38
|
// No window (SSR) or no IntersectionObserver: stay visible, do nothing.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
|
40
|
+
return { destroy() { } };
|
|
41
|
+
}
|
|
42
|
+
// Surface switch: an ancestor with data-reveal="off" opts the whole
|
|
43
|
+
// subtree out — content renders visible with no entrance, ever.
|
|
44
|
+
if (node.closest?.(REVEAL_OFF_SELECTOR)) {
|
|
45
|
+
return { destroy() { } };
|
|
46
|
+
}
|
|
47
|
+
// Respect prefers-reduced-motion: skip the animation entirely, show content.
|
|
48
|
+
const motionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
49
|
+
if (motionQuery?.matches) {
|
|
19
50
|
return { destroy() { } };
|
|
20
51
|
}
|
|
21
52
|
node.classList.add(REVEAL_HIDDEN_CLASS);
|
|
22
|
-
|
|
23
|
-
|
|
53
|
+
const delay = normalizeDelay(options?.delay);
|
|
54
|
+
if (delay > 0) {
|
|
55
|
+
node.style.animationDelay = `${delay}s`;
|
|
24
56
|
}
|
|
57
|
+
// Strip the entrance classes once the animation has played: fill-mode:both
|
|
58
|
+
// would otherwise leave a computed transform on the node, making it the
|
|
59
|
+
// containing block for position:fixed descendants (overlays land wrong).
|
|
60
|
+
// Attached only when an entrance actually runs; bubbled animationends from
|
|
61
|
+
// descendants and unrelated animations are ignored.
|
|
62
|
+
const stripEntrance = (event) => {
|
|
63
|
+
if (event.target !== node)
|
|
64
|
+
return; // bubbled from a descendant
|
|
65
|
+
if (!event.animationName?.startsWith(`${REVEAL_BASE_CLASS}-`))
|
|
66
|
+
return; // unrelated animation
|
|
67
|
+
node.classList.remove(REVEAL_BASE_CLASS, variant);
|
|
68
|
+
node.removeEventListener('animationend', stripEntrance);
|
|
69
|
+
};
|
|
70
|
+
const bareReveal = () => {
|
|
71
|
+
// Fail visible: drop the hidden class with no entrance. The inline
|
|
72
|
+
// animation-delay is meaningless without the entrance — clear it too.
|
|
73
|
+
node.classList.remove(REVEAL_HIDDEN_CLASS);
|
|
74
|
+
if (delay > 0)
|
|
75
|
+
node.style.animationDelay = '';
|
|
76
|
+
observer.unobserve(node);
|
|
77
|
+
};
|
|
78
|
+
const entranceReveal = () => {
|
|
79
|
+
node.classList.remove(REVEAL_HIDDEN_CLASS);
|
|
80
|
+
node.classList.add(REVEAL_BASE_CLASS, variant);
|
|
81
|
+
node.addEventListener('animationend', stripEntrance);
|
|
82
|
+
observer.unobserve(node);
|
|
83
|
+
};
|
|
84
|
+
// Live prefers-reduced-motion: if the user switches to reduce while this
|
|
85
|
+
// node is still hidden, show it immediately (no entrance) and stop watching.
|
|
86
|
+
const onMotionChange = () => {
|
|
87
|
+
if (motionQuery?.matches)
|
|
88
|
+
bareReveal();
|
|
89
|
+
};
|
|
90
|
+
motionQuery?.addEventListener?.('change', onMotionChange);
|
|
91
|
+
// True until the observer's first delivery has been processed — an element
|
|
92
|
+
// already intersecting on that first look was painted in view, so it gets
|
|
93
|
+
// no entrance (paint-first: below-fold-only entrances).
|
|
94
|
+
let firstDelivery = true;
|
|
25
95
|
const observer = new IntersectionObserver((entries) => {
|
|
26
96
|
for (const entry of entries) {
|
|
97
|
+
// Zero-area guard: 0×0 = display:none subtree, not below-the-fold.
|
|
98
|
+
// Reveal without the entrance so a routed pane can never sit at
|
|
99
|
+
// opacity: 0 permanently.
|
|
100
|
+
if (isZeroArea(entry.boundingClientRect)) {
|
|
101
|
+
bareReveal();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
27
104
|
if (entry.isIntersecting) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
105
|
+
if (firstDelivery) {
|
|
106
|
+
// Paint-first: visible at the observer's first look (hydration
|
|
107
|
+
// or first layout) → no entrance over already-seen content.
|
|
108
|
+
// Rides the observer, so inner-scroller clipping is respected.
|
|
109
|
+
bareReveal();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
entranceReveal();
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
31
115
|
}
|
|
32
116
|
}
|
|
117
|
+
// First delivery said "not visible" — later intersections are real
|
|
118
|
+
// scroll-ins and get the entrance.
|
|
119
|
+
firstDelivery = false;
|
|
33
120
|
}, { threshold });
|
|
34
121
|
observer.observe(node);
|
|
35
122
|
return {
|
|
36
123
|
destroy() {
|
|
37
124
|
observer.disconnect();
|
|
38
|
-
node.
|
|
125
|
+
node.removeEventListener('animationend', stripEntrance);
|
|
126
|
+
motionQuery?.removeEventListener?.('change', onMotionChange);
|
|
127
|
+
node.classList.remove(REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, variant);
|
|
39
128
|
node.style.animationDelay = '';
|
|
40
129
|
},
|
|
41
130
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The capture pipeline lives in the headless factory (jatui-is2ow.8) —
|
|
3
3
|
// this component is its markup. Re-exported here so consumers importing
|
|
4
4
|
// the type from the component keep working.
|
|
5
|
-
export type { AudioRecordingResult } from '../utils/audioRecorderCore'
|
|
5
|
+
export type { AudioRecordingResult, AudioRecorderChunk } from '../utils/audioRecorderCore'
|
|
6
6
|
|
|
7
7
|
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
8
8
|
export function formatDuration(totalSeconds: number): string {
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
variant?: 'small' | 'large'
|
|
56
56
|
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
57
57
|
maxDurationMs?: number
|
|
58
|
+
/** Timeslice in ms passed to MediaRecorder.start() — each chunk fires
|
|
59
|
+
* `onRecordChunk` as it arrives (restartable dictation, streaming
|
|
60
|
+
* transcription). Default 1000; 0 records a single blob until stop. */
|
|
61
|
+
timesliceMs?: number
|
|
58
62
|
disabled?: boolean
|
|
59
63
|
/** Fired once the mic stream is live and recording has started. */
|
|
60
64
|
onRecordStart?: () => void
|
|
@@ -66,6 +70,14 @@
|
|
|
66
70
|
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
|
|
67
71
|
/** Fired when the user cancels — no blob is produced. */
|
|
68
72
|
onRecordCancel?: () => void
|
|
73
|
+
/** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
|
|
74
|
+
* a final shorter chunk at stop). The take still accumulates for
|
|
75
|
+
* `onRecordStop` — full take AND a live chunk stream (jatui-ft2ot). */
|
|
76
|
+
onRecordChunk?: (chunk: AudioRecorderChunk) => void
|
|
77
|
+
/** Fired with the live mic MediaStream once recording starts, and with
|
|
78
|
+
* null when it is released — for hosts that attach the raw stream
|
|
79
|
+
* (WebRTC, a custom MediaRecorder). */
|
|
80
|
+
onRecordStream?: (stream: MediaStream | null) => void
|
|
69
81
|
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
70
82
|
onError?: (message: string) => void
|
|
71
83
|
class?: string
|
|
@@ -74,12 +86,15 @@
|
|
|
74
86
|
let {
|
|
75
87
|
variant = 'small',
|
|
76
88
|
maxDurationMs = 0,
|
|
89
|
+
timesliceMs = 1000,
|
|
77
90
|
disabled = false,
|
|
78
91
|
onRecordStart,
|
|
79
92
|
onRecordPause,
|
|
80
93
|
onRecordResume,
|
|
81
94
|
onRecordStop,
|
|
82
95
|
onRecordCancel,
|
|
96
|
+
onRecordChunk,
|
|
97
|
+
onRecordStream,
|
|
83
98
|
onError,
|
|
84
99
|
class: className = ''
|
|
85
100
|
}: Props = $props()
|
|
@@ -92,11 +107,13 @@
|
|
|
92
107
|
const rec = createAudioRecorder({
|
|
93
108
|
mode: 'toggle',
|
|
94
109
|
maxDurationMs,
|
|
110
|
+
timesliceMs,
|
|
95
111
|
onStart: onRecordStart,
|
|
96
112
|
onPause: onRecordPause,
|
|
97
113
|
onResume: onRecordResume,
|
|
98
114
|
onStop: onRecordStop,
|
|
99
115
|
onCancel: onRecordCancel,
|
|
116
|
+
onChunk: onRecordChunk,
|
|
100
117
|
onError: (err) => {
|
|
101
118
|
if (err.kind === 'denied') permissionDenied = true
|
|
102
119
|
onError?.(err.message)
|
|
@@ -297,6 +314,11 @@
|
|
|
297
314
|
}
|
|
298
315
|
})
|
|
299
316
|
|
|
317
|
+
// Notify stream-access hosts on every acquire/release (jatui-ft2ot).
|
|
318
|
+
$effect(() => {
|
|
319
|
+
onRecordStream?.(rec.stream)
|
|
320
|
+
})
|
|
321
|
+
|
|
300
322
|
// Space toggles pause/resume while recording (chimaro behaviour).
|
|
301
323
|
function handleKeydown(e: KeyboardEvent) {
|
|
302
324
|
if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { AudioRecordingResult } from '../utils/audioRecorderCore';
|
|
1
|
+
export type { AudioRecordingResult, AudioRecorderChunk } from '../utils/audioRecorderCore';
|
|
2
2
|
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
3
3
|
export declare function formatDuration(totalSeconds: number): string;
|
|
4
4
|
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
@@ -8,6 +8,10 @@ interface Props {
|
|
|
8
8
|
variant?: 'small' | 'large';
|
|
9
9
|
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
10
10
|
maxDurationMs?: number;
|
|
11
|
+
/** Timeslice in ms passed to MediaRecorder.start() — each chunk fires
|
|
12
|
+
* `onRecordChunk` as it arrives (restartable dictation, streaming
|
|
13
|
+
* transcription). Default 1000; 0 records a single blob until stop. */
|
|
14
|
+
timesliceMs?: number;
|
|
11
15
|
disabled?: boolean;
|
|
12
16
|
/** Fired once the mic stream is live and recording has started. */
|
|
13
17
|
onRecordStart?: () => void;
|
|
@@ -19,6 +23,14 @@ interface Props {
|
|
|
19
23
|
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>;
|
|
20
24
|
/** Fired when the user cancels — no blob is produced. */
|
|
21
25
|
onRecordCancel?: () => void;
|
|
26
|
+
/** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
|
|
27
|
+
* a final shorter chunk at stop). The take still accumulates for
|
|
28
|
+
* `onRecordStop` — full take AND a live chunk stream (jatui-ft2ot). */
|
|
29
|
+
onRecordChunk?: (chunk: AudioRecorderChunk) => void;
|
|
30
|
+
/** Fired with the live mic MediaStream once recording starts, and with
|
|
31
|
+
* null when it is released — for hosts that attach the raw stream
|
|
32
|
+
* (WebRTC, a custom MediaRecorder). */
|
|
33
|
+
onRecordStream?: (stream: MediaStream | null) => void;
|
|
22
34
|
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
23
35
|
onError?: (message: string) => void;
|
|
24
36
|
class?: string;
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* `isTypingTarget`), for modifier combos and for non-printable keys.
|
|
22
22
|
* The key also renders as a `<kbd>` hint on the button.
|
|
23
23
|
*
|
|
24
|
+
* Per-action `disabled` and `loadingText` (upstreamed from mardukos
|
|
25
|
+
* BulkActionBar): when `disabled` is true the button renders dimmed and
|
|
26
|
+
* non-interactive; when `loadingText` is set the button is disabled and
|
|
27
|
+
* shows a spinner + that text instead of its label while the action is
|
|
28
|
+
* in flight — other actions stay interactive. Global `loading` +
|
|
29
|
+
* `loadingMessage` still take precedence over per-action state.
|
|
30
|
+
*
|
|
24
31
|
* While `loadingMessage` is set, the buttons are replaced by a spinner +
|
|
25
32
|
* message row (matches the IDE's BulkActionBar fork).
|
|
26
33
|
*
|
|
@@ -34,7 +41,8 @@
|
|
|
34
41
|
* selection={nav.selection}
|
|
35
42
|
* actions={[
|
|
36
43
|
* { label: 'Assign', onSelect: () => assignBulk(nav.selection) },
|
|
37
|
-
* { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true },
|
|
44
|
+
* { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true, disabled: !canDelete },
|
|
45
|
+
* { label: 'Send', onSelect: () => sendBulk(nav.selection), loadingText: 'Sending…' },
|
|
38
46
|
* ]}
|
|
39
47
|
* onClear={() => nav.clearSelection()}
|
|
40
48
|
* />
|
|
@@ -71,6 +79,11 @@
|
|
|
71
79
|
destructive?: boolean;
|
|
72
80
|
/** Semantic variant override — takes precedence over `destructive`. */
|
|
73
81
|
variant?: ActionButton['variant'];
|
|
82
|
+
/** Dimmed, non-interactive button (upstreamed from mardukos). */
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
/** When set, the button is disabled and shows a spinner + this text
|
|
85
|
+
* while the action is in flight (upstreamed from mardukos). */
|
|
86
|
+
loadingText?: string | null;
|
|
74
87
|
/** Unique key (defaults to label). */
|
|
75
88
|
id?: string;
|
|
76
89
|
}
|
|
@@ -135,6 +148,8 @@
|
|
|
135
148
|
key: a.key,
|
|
136
149
|
variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
|
|
137
150
|
onclick: a.onSelect,
|
|
151
|
+
disabled: a.disabled,
|
|
152
|
+
loadingText: a.loadingText,
|
|
138
153
|
})),
|
|
139
154
|
);
|
|
140
155
|
|
|
@@ -143,7 +158,7 @@
|
|
|
143
158
|
// typing targets, modifier combos and non-printable keys never fire.
|
|
144
159
|
$effect(() => {
|
|
145
160
|
if (count === 0 || loading || loadingMessage) return;
|
|
146
|
-
const hotkeyed = actions.filter((a) => a.key);
|
|
161
|
+
const hotkeyed = actions.filter((a) => a.key && !a.disabled && !a.loadingText);
|
|
147
162
|
if (hotkeyed.length === 0) return;
|
|
148
163
|
function onKeydown(event: KeyboardEvent) {
|
|
149
164
|
const action = matchBulkHotkey(event, hotkeyed);
|
|
@@ -14,6 +14,11 @@ export interface BulkAction {
|
|
|
14
14
|
destructive?: boolean;
|
|
15
15
|
/** Semantic variant override — takes precedence over `destructive`. */
|
|
16
16
|
variant?: ActionButton['variant'];
|
|
17
|
+
/** Dimmed, non-interactive button (upstreamed from mardukos). */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/** When set, the button is disabled and shows a spinner + this text
|
|
20
|
+
* while the action is in flight (upstreamed from mardukos). */
|
|
21
|
+
loadingText?: string | null;
|
|
17
22
|
/** Unique key (defaults to label). */
|
|
18
23
|
id?: string;
|
|
19
24
|
}
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* Drawer Component
|
|
4
4
|
*
|
|
5
5
|
* Sliding panel overlay with backdrop, escape key, and header actions.
|
|
6
|
+
* Supports a `footer` snippet (pinned below the scrollable body —
|
|
7
|
+
* matches mardukos DrawerShell) and an arbitrary Tailwind `width`
|
|
8
|
+
* class (e.g. `'w-[42rem]'`) which takes precedence over `size`.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { untrack, type Snippet } from 'svelte';
|
|
@@ -13,6 +16,10 @@
|
|
|
13
16
|
title?: string;
|
|
14
17
|
position?: 'left' | 'right';
|
|
15
18
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
|
19
|
+
/** Arbitrary Tailwind width class (e.g. `'w-[42rem]'`). Takes precedence
|
|
20
|
+
* over `size`. Pins the panel to a named breakpoint if combined with one,
|
|
21
|
+
* so you don't need `max-w-[100vw]` guards for mobile. */
|
|
22
|
+
width?: string;
|
|
16
23
|
showCloseButton?: boolean;
|
|
17
24
|
contentClass?: string;
|
|
18
25
|
/**
|
|
@@ -37,6 +44,10 @@
|
|
|
37
44
|
onclose?: () => void;
|
|
38
45
|
class?: string;
|
|
39
46
|
headerActions?: Snippet;
|
|
47
|
+
/** Pinned below the scrollable content area (matches mardukos DrawerShell
|
|
48
|
+
* `footer` — action rows / submit buttons that must stay visible while
|
|
49
|
+
* the body scrolls). */
|
|
50
|
+
footer?: Snippet;
|
|
40
51
|
children?: Snippet;
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -45,6 +56,7 @@
|
|
|
45
56
|
title = '',
|
|
46
57
|
position = 'right',
|
|
47
58
|
size = 'md',
|
|
59
|
+
width,
|
|
48
60
|
showCloseButton = true,
|
|
49
61
|
contentClass = '',
|
|
50
62
|
trapKeys = true,
|
|
@@ -53,6 +65,7 @@
|
|
|
53
65
|
onclose,
|
|
54
66
|
class: className = '',
|
|
55
67
|
headerActions,
|
|
68
|
+
footer,
|
|
56
69
|
children
|
|
57
70
|
}: Props = $props();
|
|
58
71
|
|
|
@@ -118,7 +131,7 @@
|
|
|
118
131
|
<div
|
|
119
132
|
class="fixed top-0 {position === 'right'
|
|
120
133
|
? 'right-0'
|
|
121
|
-
: 'left-0'} h-full bg-base-200 {position === 'right' ? 'border-l' : 'border-r'} border-base-300 {sizeClasses[size]} flex flex-col transition-all duration-300 {open
|
|
134
|
+
: 'left-0'} h-full bg-base-200 {position === 'right' ? 'border-l' : 'border-r'} border-base-300 {width || sizeClasses[size]} flex flex-col transition-all duration-300 {open
|
|
122
135
|
? 'z-[101] translate-x-0'
|
|
123
136
|
: '-z-10 ' + (position === 'right' ? 'translate-x-full' : '-translate-x-full')} {className}"
|
|
124
137
|
role="dialog"
|
|
@@ -157,4 +170,10 @@
|
|
|
157
170
|
{@render children()}
|
|
158
171
|
{/if}
|
|
159
172
|
</div>
|
|
173
|
+
|
|
174
|
+
{#if footer}
|
|
175
|
+
<div class="flex-shrink-0 border-t border-base-content/10">
|
|
176
|
+
{@render footer()}
|
|
177
|
+
</div>
|
|
178
|
+
{/if}
|
|
160
179
|
</div>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Drawer Component
|
|
3
3
|
*
|
|
4
4
|
* Sliding panel overlay with backdrop, escape key, and header actions.
|
|
5
|
+
* Supports a `footer` snippet (pinned below the scrollable body —
|
|
6
|
+
* matches mardukos DrawerShell) and an arbitrary Tailwind `width`
|
|
7
|
+
* class (e.g. `'w-[42rem]'`) which takes precedence over `size`.
|
|
5
8
|
*/
|
|
6
9
|
import { type Snippet } from 'svelte';
|
|
7
10
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
@@ -10,6 +13,10 @@ interface Props {
|
|
|
10
13
|
title?: string;
|
|
11
14
|
position?: 'left' | 'right';
|
|
12
15
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
|
16
|
+
/** Arbitrary Tailwind width class (e.g. `'w-[42rem]'`). Takes precedence
|
|
17
|
+
* over `size`. Pins the panel to a named breakpoint if combined with one,
|
|
18
|
+
* so you don't need `max-w-[100vw]` guards for mobile. */
|
|
19
|
+
width?: string;
|
|
13
20
|
showCloseButton?: boolean;
|
|
14
21
|
contentClass?: string;
|
|
15
22
|
/**
|
|
@@ -34,6 +41,10 @@ interface Props {
|
|
|
34
41
|
onclose?: () => void;
|
|
35
42
|
class?: string;
|
|
36
43
|
headerActions?: Snippet;
|
|
44
|
+
/** Pinned below the scrollable content area (matches mardukos DrawerShell
|
|
45
|
+
* `footer` — action rows / submit buttons that must stay visible while
|
|
46
|
+
* the body scrolls). */
|
|
47
|
+
footer?: Snippet;
|
|
37
48
|
children?: Snippet;
|
|
38
49
|
}
|
|
39
50
|
declare const Drawer: import("svelte").Component<Props, {}, "open">;
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* the matching keydown handling lives in the host/BulkActionBar). While
|
|
17
17
|
* `loading` with a `loadingMessage` set, the buttons are replaced by a
|
|
18
18
|
* spinner + message row (matching the IDE's fork, audit jat-1alfp #15).
|
|
19
|
+
*
|
|
20
|
+
* Per-action `disabled` renders the button dimmed and non-interactive;
|
|
21
|
+
* per-action `loadingText` disables it and swaps the label for a
|
|
22
|
+
* spinner + that text (mardukos fork) while other actions stay active.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import { fade } from 'svelte/transition';
|
|
@@ -33,6 +37,11 @@
|
|
|
33
37
|
key?: string;
|
|
34
38
|
/** Called when clicked */
|
|
35
39
|
onclick: () => void;
|
|
40
|
+
/** Dimmed, non-interactive button (mardukos). */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** When set, the button is disabled and shows a spinner + this text
|
|
43
|
+
* instead of the label (mardukos per-action loading). */
|
|
44
|
+
loadingText?: string | null;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export interface DropdownItem {
|
|
@@ -136,18 +145,24 @@
|
|
|
136
145
|
type="button"
|
|
137
146
|
class="fab-btn {variantClass(action.variant)}"
|
|
138
147
|
onclick={action.onclick}
|
|
139
|
-
disabled={loading}
|
|
140
|
-
|
|
148
|
+
disabled={loading || action.disabled || !!action.loadingText}
|
|
149
|
+
aria-busy={loading || !!action.loadingText || undefined}
|
|
150
|
+
title={action.key && !action.disabled && !action.loadingText ? `${action.label} (${action.key})` : undefined}
|
|
141
151
|
>
|
|
142
|
-
{#if action.
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
{#if action.loadingText}
|
|
153
|
+
<span class="fab-spinner"></span>
|
|
154
|
+
<span class="fab-loading-msg">{action.loadingText}</span>
|
|
155
|
+
{:else}
|
|
156
|
+
{#if action.icon}
|
|
157
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
|
158
|
+
<!-- svelte-ignore a11y_consider_explicit_label -->
|
|
159
|
+
{@html action.icon}
|
|
160
|
+
</svg>
|
|
161
|
+
{/if}
|
|
162
|
+
{action.label}
|
|
163
|
+
{#if action.key}
|
|
164
|
+
<kbd class="fab-key">{action.key}</kbd>
|
|
165
|
+
{/if}
|
|
151
166
|
{/if}
|
|
152
167
|
</button>
|
|
153
168
|
{/each}
|
|
@@ -11,6 +11,11 @@ export interface ActionButton {
|
|
|
11
11
|
key?: string;
|
|
12
12
|
/** Called when clicked */
|
|
13
13
|
onclick: () => void;
|
|
14
|
+
/** Dimmed, non-interactive button (mardukos). */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** When set, the button is disabled and shows a spinner + this text
|
|
17
|
+
* instead of the label (mardukos per-action loading). */
|
|
18
|
+
loadingText?: string | null;
|
|
14
19
|
}
|
|
15
20
|
export interface DropdownItem {
|
|
16
21
|
/** Display label */
|