@joewinke/jatui 0.38.6 → 0.40.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.
- package/CLAUDE.md +54 -5
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/dist/actions/scrollEdge.d.ts +55 -0
- package/dist/actions/scrollEdge.js +144 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
- package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
- package/dist/components/BottomSheet.svelte +7 -6
- package/dist/components/BottomSheet.svelte.d.ts +2 -0
- package/dist/components/BulkActionBar.svelte +32 -2
- package/dist/components/BulkActionBar.svelte.d.ts +5 -1
- package/dist/components/CloseButton.svelte +75 -0
- package/dist/components/CloseButton.svelte.d.ts +29 -0
- package/dist/components/Drawer.svelte +10 -11
- package/dist/components/Drawer.svelte.d.ts +2 -8
- package/dist/components/FloatingActionBar.svelte +102 -34
- package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
- package/dist/components/IconButton.svelte +200 -0
- package/dist/components/IconButton.svelte.d.ts +46 -0
- package/dist/components/LinkButton.svelte +117 -0
- package/dist/components/LinkButton.svelte.d.ts +32 -0
- package/dist/components/MediaExplorer.svelte +249 -0
- package/dist/components/MediaExplorer.svelte.d.ts +37 -0
- package/dist/components/MediaLightbox.svelte +2 -7
- package/dist/components/Modal.svelte +2 -10
- package/dist/components/NotificationCenter.svelte +481 -0
- package/dist/components/NotificationCenter.svelte.d.ts +29 -0
- package/dist/components/PinInput.svelte +94 -31
- package/dist/components/PinInput.svelte.d.ts +4 -0
- package/dist/components/SearchDropdown.svelte +54 -8
- package/dist/components/SearchDropdown.svelte.d.ts +12 -0
- package/dist/components/TextArea.svelte +67 -5
- package/dist/components/TextArea.svelte.d.ts +4 -6
- package/dist/components/ThemeSelector.svelte +29 -9
- package/dist/components/ThemeSelector.svelte.d.ts +6 -0
- package/dist/components/ThemeToggle.svelte +28 -3
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/agent/AgentLog.svelte +217 -0
- package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
- package/dist/components/agent/AgentLogRow.svelte +281 -0
- package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
- package/dist/components/agent/AgentTaskList.svelte +607 -0
- package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
- package/dist/components/agent/AgentThinking.svelte +280 -0
- package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
- package/dist/components/agent/ComposerGlow.svelte +265 -0
- package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
- package/dist/components/agent/ContextMeter.svelte +277 -0
- package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
- package/dist/components/agent/Questionnaire.svelte +767 -0
- package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
- package/dist/components/agent/ShimmerText.svelte +100 -0
- package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
- package/dist/components/agent/StreamedText.svelte +135 -0
- package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
- package/dist/components/agent/agentLog.d.ts +22 -0
- package/dist/components/agent/agentLog.js +26 -0
- package/dist/components/agent/agentTaskList.d.ts +90 -0
- package/dist/components/agent/agentTaskList.js +93 -0
- package/dist/components/agent/agentThinking.d.ts +46 -0
- package/dist/components/agent/agentThinking.js +78 -0
- package/dist/components/agent/contextMeter.d.ts +97 -0
- package/dist/components/agent/contextMeter.js +156 -0
- package/dist/components/agent/questionnaire.d.ts +104 -0
- package/dist/components/agent/questionnaire.js +225 -0
- package/dist/components/agent/revealCore.d.ts +74 -0
- package/dist/components/agent/revealCore.js +160 -0
- package/dist/components/agent/revealTicker.d.ts +34 -0
- package/dist/components/agent/revealTicker.js +73 -0
- package/dist/components/agent/streamedText.d.ts +28 -0
- package/dist/components/agent/streamedText.js +59 -0
- package/dist/components/basic/Breadcrumb.svelte +226 -11
- package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
- package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
- package/dist/components/basic/breadcrumbCollapse.js +12 -0
- package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
- package/dist/components/messaging/MessageInput.svelte +34 -4
- package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
- package/dist/components/messaging/MessageItem.svelte +2 -2
- package/dist/components/notificationCenter.d.ts +65 -0
- package/dist/components/notificationCenter.js +110 -0
- package/dist/components/progress/Steps.svelte +269 -5
- package/dist/components/progress/Steps.svelte.d.ts +16 -1
- package/dist/components/progress/stepsSummary.d.ts +14 -0
- package/dist/components/progress/stepsSummary.js +32 -0
- package/dist/index.d.ts +48 -1
- package/dist/index.js +39 -1
- package/dist/selection/clickSelection.d.ts +89 -0
- package/dist/selection/clickSelection.js +152 -0
- package/dist/selection/createClickSelection.d.ts +79 -0
- package/dist/selection/createClickSelection.js +134 -0
- package/dist/styles/tokens.css +80 -1
- package/dist/utils/pinInput.d.ts +33 -0
- package/dist/utils/pinInput.js +63 -0
- package/dist/utils/textareaRows.d.ts +23 -0
- package/dist/utils/textareaRows.js +13 -0
- package/dist/utils/themeWipe.d.ts +36 -17
- package/dist/utils/themeWipe.js +145 -35
- package/package.json +3 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reveal ticker — the pacing core behind AgentLog / AgentTaskList.
|
|
3
|
+
*
|
|
4
|
+
* Counts how many units of a streaming log are visible. Two modes:
|
|
5
|
+
*
|
|
6
|
+
* timer one unit per tick: `startDelay` before the first, then
|
|
7
|
+
* `stepInterval` between the rest, or `delayFor(i)` per unit
|
|
8
|
+
* (the dwell before unit `i` appears) when given.
|
|
9
|
+
* controlled pass `revealed` and the timer is off: real events drive
|
|
10
|
+
* the count, clamped to [0, total].
|
|
11
|
+
*
|
|
12
|
+
* `onComplete` fires exactly once each time the count reaches `total`
|
|
13
|
+
* (total > 0). Dropping back below total (total grew, `replay()`, a lower
|
|
14
|
+
* controlled value) re-arms it.
|
|
15
|
+
*
|
|
16
|
+
* Framework-free and DOM-free, with an injectable clock, so
|
|
17
|
+
* tests/unit/revealTicker.test.ts drives it with fake timers. The Svelte
|
|
18
|
+
* rune wrapper is createRevealTicker in revealTicker.svelte.ts.
|
|
19
|
+
*
|
|
20
|
+
* Behaviour adapted from BoardUI's agent-log `useRevealTicker`:
|
|
21
|
+
* MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)
|
|
22
|
+
* Differences: framework-free class, an in-flight tick survives option
|
|
23
|
+
* updates that do not change the next unit (a log whose `total` grows while
|
|
24
|
+
* streaming does not stall), and switching from controlled back to timer
|
|
25
|
+
* mode resumes from the controlled count instead of jumping back.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_STEP_INTERVAL = 850;
|
|
28
|
+
export const DEFAULT_START_DELAY = 320;
|
|
29
|
+
const realClock = {
|
|
30
|
+
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
31
|
+
clearTimeout: (id) => globalThis.clearTimeout(id)
|
|
32
|
+
};
|
|
33
|
+
function clampCount(n, total) {
|
|
34
|
+
if (!Number.isFinite(n))
|
|
35
|
+
return n > 0 ? total : 0;
|
|
36
|
+
return Math.max(0, Math.min(Math.floor(n), total));
|
|
37
|
+
}
|
|
38
|
+
/** The delay before unit `index` (pure; exported for tests and hosts). */
|
|
39
|
+
export function revealDelay(index, opts) {
|
|
40
|
+
const raw = opts.delayFor
|
|
41
|
+
? opts.delayFor(index)
|
|
42
|
+
: index === 0
|
|
43
|
+
? (opts.startDelay ?? DEFAULT_START_DELAY)
|
|
44
|
+
: (opts.stepInterval ?? DEFAULT_STEP_INTERVAL);
|
|
45
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
46
|
+
}
|
|
47
|
+
export class RevealCore {
|
|
48
|
+
#opts;
|
|
49
|
+
#hooks;
|
|
50
|
+
#clock;
|
|
51
|
+
#ticked = 0;
|
|
52
|
+
#timer = null;
|
|
53
|
+
/** The unit index the pending timer will reveal. */
|
|
54
|
+
#pendingFor = -1;
|
|
55
|
+
#fired = false;
|
|
56
|
+
#active = false;
|
|
57
|
+
#last = -1;
|
|
58
|
+
constructor(opts, hooks = {}) {
|
|
59
|
+
this.#opts = { ...opts };
|
|
60
|
+
this.#hooks = hooks;
|
|
61
|
+
this.#clock = hooks.clock ?? realClock;
|
|
62
|
+
}
|
|
63
|
+
get total() {
|
|
64
|
+
return Math.max(0, Math.floor(this.#opts.total || 0));
|
|
65
|
+
}
|
|
66
|
+
get controlled() {
|
|
67
|
+
return this.#opts.revealed !== undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Units currently visible. */
|
|
70
|
+
get revealed() {
|
|
71
|
+
const total = this.total;
|
|
72
|
+
return this.controlled
|
|
73
|
+
? clampCount(this.#opts.revealed, total)
|
|
74
|
+
: clampCount(this.#ticked, total);
|
|
75
|
+
}
|
|
76
|
+
get complete() {
|
|
77
|
+
return this.total > 0 && this.revealed >= this.total;
|
|
78
|
+
}
|
|
79
|
+
/** True while a tick is scheduled. */
|
|
80
|
+
get pending() {
|
|
81
|
+
return this.#timer !== null;
|
|
82
|
+
}
|
|
83
|
+
/** Begin scheduling (call on mount — never during SSR). */
|
|
84
|
+
start() {
|
|
85
|
+
this.#active = true;
|
|
86
|
+
this.#sync();
|
|
87
|
+
}
|
|
88
|
+
/** Merge new options (any subset) and reconcile. */
|
|
89
|
+
update(next) {
|
|
90
|
+
const wasControlled = this.controlled;
|
|
91
|
+
const before = this.revealed;
|
|
92
|
+
this.#opts = { ...this.#opts, ...next };
|
|
93
|
+
if ('revealed' in next && next.revealed === undefined)
|
|
94
|
+
this.#opts.revealed = undefined;
|
|
95
|
+
// Leaving controlled mode: carry on from where the events left the count.
|
|
96
|
+
if (wasControlled && !this.controlled)
|
|
97
|
+
this.#ticked = before;
|
|
98
|
+
this.#ticked = Math.min(this.#ticked, this.total);
|
|
99
|
+
this.#sync();
|
|
100
|
+
}
|
|
101
|
+
/** Back to zero and run again (timer mode). */
|
|
102
|
+
replay() {
|
|
103
|
+
this.#ticked = 0;
|
|
104
|
+
this.#fired = false;
|
|
105
|
+
this.#cancel();
|
|
106
|
+
this.#sync();
|
|
107
|
+
}
|
|
108
|
+
/** Stop the timer for good (call on unmount). */
|
|
109
|
+
destroy() {
|
|
110
|
+
this.#active = false;
|
|
111
|
+
this.#cancel();
|
|
112
|
+
}
|
|
113
|
+
#cancel() {
|
|
114
|
+
if (this.#timer !== null)
|
|
115
|
+
this.#clock.clearTimeout(this.#timer);
|
|
116
|
+
this.#timer = null;
|
|
117
|
+
this.#pendingFor = -1;
|
|
118
|
+
}
|
|
119
|
+
#sync() {
|
|
120
|
+
this.#emit();
|
|
121
|
+
this.#checkComplete();
|
|
122
|
+
this.#schedule();
|
|
123
|
+
}
|
|
124
|
+
#emit() {
|
|
125
|
+
const n = this.revealed;
|
|
126
|
+
if (n !== this.#last) {
|
|
127
|
+
this.#last = n;
|
|
128
|
+
this.#hooks.onChange?.(n);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
#checkComplete() {
|
|
132
|
+
if (!this.complete) {
|
|
133
|
+
this.#fired = false;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (this.#fired)
|
|
137
|
+
return;
|
|
138
|
+
this.#fired = true;
|
|
139
|
+
this.#hooks.onComplete?.();
|
|
140
|
+
}
|
|
141
|
+
#schedule() {
|
|
142
|
+
const n = this.revealed;
|
|
143
|
+
const canRun = this.#active && !this.controlled && this.#opts.run !== false && n < this.total;
|
|
144
|
+
if (!canRun) {
|
|
145
|
+
this.#cancel();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// A tick for this very unit is already on its way: leave it alone.
|
|
149
|
+
if (this.#timer !== null && this.#pendingFor === n)
|
|
150
|
+
return;
|
|
151
|
+
this.#cancel();
|
|
152
|
+
this.#pendingFor = n;
|
|
153
|
+
this.#timer = this.#clock.setTimeout(() => {
|
|
154
|
+
this.#timer = null;
|
|
155
|
+
this.#pendingFor = -1;
|
|
156
|
+
this.#ticked = Math.min(this.#ticked + 1, this.total);
|
|
157
|
+
this.#sync();
|
|
158
|
+
}, revealDelay(n, this.#opts));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type RevealClock } from './revealCore';
|
|
2
|
+
type MaybeGetter<T> = T | (() => T);
|
|
3
|
+
export interface RevealTickerOptions {
|
|
4
|
+
/** How many units the log has in total. */
|
|
5
|
+
total: MaybeGetter<number>;
|
|
6
|
+
/** ms between units after the first (timer mode). Default 850. */
|
|
7
|
+
stepInterval?: MaybeGetter<number>;
|
|
8
|
+
/** ms before the first unit (timer mode). Default 320. */
|
|
9
|
+
startDelay?: MaybeGetter<number>;
|
|
10
|
+
/**
|
|
11
|
+
* Drive the count yourself from real events. While this resolves to a
|
|
12
|
+
* number the timer is off; resolve to `undefined` to hand back to it.
|
|
13
|
+
* Pass a getter (`() => count`) to keep it reactive.
|
|
14
|
+
*/
|
|
15
|
+
revealed?: (() => number | undefined) | number;
|
|
16
|
+
/** Dwell before unit `index` appears (overrides startDelay/stepInterval). */
|
|
17
|
+
delayFor?: (index: number) => number;
|
|
18
|
+
/** false pauses the timer. Default true. */
|
|
19
|
+
run?: MaybeGetter<boolean>;
|
|
20
|
+
/** Fired once when the last unit lands (re-armed if the count drops). */
|
|
21
|
+
onComplete?: () => void;
|
|
22
|
+
/** Injectable clock (tests). */
|
|
23
|
+
clock?: RevealClock;
|
|
24
|
+
}
|
|
25
|
+
export interface RevealTicker {
|
|
26
|
+
/** Units visible right now. */
|
|
27
|
+
readonly revealed: number;
|
|
28
|
+
/** Every unit is visible (total > 0). */
|
|
29
|
+
readonly complete: boolean;
|
|
30
|
+
/** Restart from zero (timer mode). */
|
|
31
|
+
replay(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function createRevealTicker(opts: RevealTickerOptions): RevealTicker;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* revealTicker.svelte.js generated by Svelte v5.53.11 */
|
|
2
|
+
import * as $ from 'svelte/internal/client';
|
|
3
|
+
import { untrack } from 'svelte';
|
|
4
|
+
import { RevealCore } from './revealCore';
|
|
5
|
+
|
|
6
|
+
function read(v) {
|
|
7
|
+
return typeof v === 'function' ? v() : v;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolve(opts) {
|
|
11
|
+
return {
|
|
12
|
+
total: read(opts.total) ?? 0,
|
|
13
|
+
stepInterval: read(opts.stepInterval),
|
|
14
|
+
startDelay: read(opts.startDelay),
|
|
15
|
+
revealed: read(opts.revealed),
|
|
16
|
+
delayFor: opts.delayFor,
|
|
17
|
+
run: read(opts.run)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createRevealTicker(opts) {
|
|
22
|
+
let revealed = $.state(0);
|
|
23
|
+
let complete = $.state(false);
|
|
24
|
+
|
|
25
|
+
const core = new RevealCore(resolve(opts), {
|
|
26
|
+
clock: opts.clock,
|
|
27
|
+
onChange: (n) => {
|
|
28
|
+
$.set(revealed, n, true);
|
|
29
|
+
$.set(complete, core.complete, true);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
onComplete: () => {
|
|
33
|
+
$.set(complete, true);
|
|
34
|
+
opts.onComplete?.();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// SSR / first paint: the controlled count is already known.
|
|
39
|
+
$.set(revealed, core.revealed, true);
|
|
40
|
+
|
|
41
|
+
$.set(complete, core.complete, true);
|
|
42
|
+
|
|
43
|
+
let started = false;
|
|
44
|
+
|
|
45
|
+
$.user_effect(() => {
|
|
46
|
+
const next = resolve(opts);
|
|
47
|
+
|
|
48
|
+
// Only the options are dependencies — never state read by onComplete.
|
|
49
|
+
untrack(() => {
|
|
50
|
+
core.update(next);
|
|
51
|
+
|
|
52
|
+
if (!started) {
|
|
53
|
+
started = true;
|
|
54
|
+
core.start();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$.set(complete, core.complete, true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
$.user_effect(() => () => core.destroy());
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
get revealed() {
|
|
65
|
+
return $.get(revealed);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
get complete() {
|
|
69
|
+
return $.get(complete);
|
|
70
|
+
},
|
|
71
|
+
replay: () => core.replay()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block splitter behind StreamedText (jatui-lltq9).
|
|
3
|
+
*
|
|
4
|
+
* Streamed assistant text is append-only, so the split has to be too: for any
|
|
5
|
+
* prefix of the final text, every block but the last is IDENTICAL to the
|
|
6
|
+
* final text's block at that index, and the last block's text is a PREFIX of
|
|
7
|
+
* the final block. That is what lets each block mount once (keyed by index)
|
|
8
|
+
* and then only grow — nothing restarts, nothing reflows backwards.
|
|
9
|
+
*
|
|
10
|
+
* Rules that keep that guarantee:
|
|
11
|
+
* - The text after the last "\n" is the in-progress line; it never closes
|
|
12
|
+
* a block, even when it is blank so far.
|
|
13
|
+
* - Block text is right-trimmed, so a half-typed trailing space or a
|
|
14
|
+
* dangling newline never has to be "taken back" later.
|
|
15
|
+
* - 'paragraph' mode: a blank line closes a block, except inside a fenced
|
|
16
|
+
* code block (``` or ~~~) — a markdown `renderBlock` sees whole fences.
|
|
17
|
+
* - 'line' mode: each non-blank line is a block.
|
|
18
|
+
*/
|
|
19
|
+
export type StreamedTextSplit = 'paragraph' | 'line';
|
|
20
|
+
export interface StreamedBlock {
|
|
21
|
+
/** Stable key: the block's index ("b0", "b1", …). */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Block text, right-trimmed. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** True once a later separator closed the block — it can no longer grow. */
|
|
26
|
+
complete: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function splitStreamedText(text: string, split?: StreamedTextSplit): StreamedBlock[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block splitter behind StreamedText (jatui-lltq9).
|
|
3
|
+
*
|
|
4
|
+
* Streamed assistant text is append-only, so the split has to be too: for any
|
|
5
|
+
* prefix of the final text, every block but the last is IDENTICAL to the
|
|
6
|
+
* final text's block at that index, and the last block's text is a PREFIX of
|
|
7
|
+
* the final block. That is what lets each block mount once (keyed by index)
|
|
8
|
+
* and then only grow — nothing restarts, nothing reflows backwards.
|
|
9
|
+
*
|
|
10
|
+
* Rules that keep that guarantee:
|
|
11
|
+
* - The text after the last "\n" is the in-progress line; it never closes
|
|
12
|
+
* a block, even when it is blank so far.
|
|
13
|
+
* - Block text is right-trimmed, so a half-typed trailing space or a
|
|
14
|
+
* dangling newline never has to be "taken back" later.
|
|
15
|
+
* - 'paragraph' mode: a blank line closes a block, except inside a fenced
|
|
16
|
+
* code block (``` or ~~~) — a markdown `renderBlock` sees whole fences.
|
|
17
|
+
* - 'line' mode: each non-blank line is a block.
|
|
18
|
+
*/
|
|
19
|
+
const FENCE = /^\s{0,3}(```|~~~)/;
|
|
20
|
+
export function splitStreamedText(text, split = 'paragraph') {
|
|
21
|
+
if (!text)
|
|
22
|
+
return [];
|
|
23
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
24
|
+
const last = lines.length - 1; // index of the in-progress line
|
|
25
|
+
const blocks = [];
|
|
26
|
+
let open = null;
|
|
27
|
+
let inFence = false;
|
|
28
|
+
const close = (complete) => {
|
|
29
|
+
if (!open)
|
|
30
|
+
return;
|
|
31
|
+
const body = open.join('\n').trimEnd();
|
|
32
|
+
if (body)
|
|
33
|
+
blocks.push({ key: `b${blocks.length}`, text: body, complete });
|
|
34
|
+
open = null;
|
|
35
|
+
};
|
|
36
|
+
for (let i = 0; i <= last; i++) {
|
|
37
|
+
const line = lines[i];
|
|
38
|
+
const blank = line.trim() === '';
|
|
39
|
+
const partial = i === last;
|
|
40
|
+
if (split === 'line') {
|
|
41
|
+
if (!blank)
|
|
42
|
+
blocks.push({ key: `b${blocks.length}`, text: line.trimEnd(), complete: !partial });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (blank && !inFence) {
|
|
46
|
+
// A blank in-progress line has not closed anything yet.
|
|
47
|
+
if (!partial)
|
|
48
|
+
close(true);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!open)
|
|
52
|
+
open = [];
|
|
53
|
+
open.push(line);
|
|
54
|
+
if (FENCE.test(line) && !partial)
|
|
55
|
+
inFence = !inFence;
|
|
56
|
+
}
|
|
57
|
+
close(false);
|
|
58
|
+
return blocks;
|
|
59
|
+
}
|
|
@@ -1,27 +1,242 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Breadcrumb — hierarchical location trail.
|
|
4
|
+
*
|
|
5
|
+
* <Breadcrumb items={[{ label: 'Home', href: '/' }, …, { label: 'README.md' }]} />
|
|
6
|
+
* <Breadcrumb {items} maxItems={4} /> Home / … / y / z
|
|
7
|
+
* <Breadcrumb items={[{ label: 'Home', href: '/', icon: HomeIcon }, …]} />
|
|
8
|
+
*
|
|
9
|
+
* `maxItems` (BoardUI wave 1, jatui-owg2z) keeps the root and the last
|
|
10
|
+
* `maxItems - 1` items and folds the middle into an ellipsis button whose
|
|
11
|
+
* menu lists the hidden levels (arrow keys, Escape, click-outside). The last
|
|
12
|
+
* item is the current page (`aria-current="page"`). Icons come from
|
|
13
|
+
* `item.icon` (any component taking `{ size, class }`, e.g. lucide-svelte)
|
|
14
|
+
* or the `icon` snippet for full control.
|
|
15
|
+
*/
|
|
16
|
+
import { tick, type Component, type Snippet } from 'svelte';
|
|
17
|
+
import { floating } from '../../actions/floating';
|
|
18
|
+
import { collapseBreadcrumb } from './breadcrumbCollapse';
|
|
19
|
+
|
|
2
20
|
export interface BreadcrumbItem {
|
|
3
21
|
label: string;
|
|
4
22
|
href?: string;
|
|
23
|
+
/** Leading icon component (receives `size` and `class`). */
|
|
24
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
5
25
|
}
|
|
6
26
|
|
|
7
27
|
interface Props {
|
|
8
28
|
items: BreadcrumbItem[];
|
|
29
|
+
/** Collapse long trails to root / … / last (maxItems - 1). Unset = never collapse. */
|
|
30
|
+
maxItems?: number;
|
|
31
|
+
/** Custom leading icon per item — overrides `item.icon`. */
|
|
32
|
+
icon?: Snippet<[BreadcrumbItem, number]>;
|
|
33
|
+
/** Accessible name of the nav landmark. Default "Breadcrumb". */
|
|
34
|
+
ariaLabel?: string;
|
|
9
35
|
class?: string;
|
|
10
36
|
}
|
|
11
37
|
|
|
12
|
-
let { items, class: className = '' }: Props = $props();
|
|
38
|
+
let { items, maxItems, icon, ariaLabel = 'Breadcrumb', class: className = '' }: Props = $props();
|
|
39
|
+
|
|
40
|
+
const slots = $derived(collapseBreadcrumb(items, maxItems));
|
|
41
|
+
|
|
42
|
+
let menuOpen = $state(false);
|
|
43
|
+
let triggerEl: HTMLButtonElement | undefined = $state();
|
|
44
|
+
let menuEl: HTMLUListElement | undefined = $state();
|
|
45
|
+
|
|
46
|
+
function openMenu() {
|
|
47
|
+
menuOpen = true;
|
|
48
|
+
// Focus the first hidden level once the menu has mounted.
|
|
49
|
+
void tick().then(() => menuEl?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function closeMenu(refocus = true) {
|
|
53
|
+
menuOpen = false;
|
|
54
|
+
if (refocus) triggerEl?.focus();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// use:floating dismisses on a capture-phase click outside the menu — which
|
|
58
|
+
// includes the trigger, so a click meant to CLOSE would re-open it.
|
|
59
|
+
let dismissedAt = 0;
|
|
60
|
+
function dismiss() {
|
|
61
|
+
dismissedAt = performance.now();
|
|
62
|
+
closeMenu(false);
|
|
63
|
+
}
|
|
64
|
+
function onTriggerClick() {
|
|
65
|
+
if (performance.now() - dismissedAt < 100) return;
|
|
66
|
+
if (menuOpen) closeMenu();
|
|
67
|
+
else openMenu();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onMenuKeydown(e: KeyboardEvent) {
|
|
71
|
+
const entries = [...(menuEl?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
|
72
|
+
const at = entries.indexOf(document.activeElement as HTMLElement);
|
|
73
|
+
let next = -1;
|
|
74
|
+
if (e.key === 'ArrowDown') next = (at + 1) % entries.length;
|
|
75
|
+
else if (e.key === 'ArrowUp') next = (at - 1 + entries.length) % entries.length;
|
|
76
|
+
else if (e.key === 'Home') next = 0;
|
|
77
|
+
else if (e.key === 'End') next = entries.length - 1;
|
|
78
|
+
else if (e.key === 'Escape') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
closeMenu();
|
|
82
|
+
return;
|
|
83
|
+
} else if (e.key === 'Tab') {
|
|
84
|
+
closeMenu(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (next >= 0) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
entries[next]?.focus();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onTriggerKeydown(e: KeyboardEvent) {
|
|
94
|
+
if (e.key === 'ArrowDown' && !menuOpen) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
openMenu();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
13
99
|
</script>
|
|
14
100
|
|
|
15
|
-
|
|
101
|
+
{#snippet lead(item: BreadcrumbItem, i: number)}
|
|
102
|
+
{#if icon}
|
|
103
|
+
{@render icon(item, i)}
|
|
104
|
+
{:else if item.icon}
|
|
105
|
+
{@const Icon = item.icon}
|
|
106
|
+
<Icon size={14} class="jatui-crumb-icon" />
|
|
107
|
+
{/if}
|
|
108
|
+
{/snippet}
|
|
109
|
+
|
|
110
|
+
<nav class="breadcrumbs text-sm {className}" aria-label={ariaLabel}>
|
|
16
111
|
<ul>
|
|
17
|
-
{#each
|
|
18
|
-
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
112
|
+
{#each slots as slot (slot.kind === 'item' ? slot.index : 'ellipsis')}
|
|
113
|
+
{#if slot.kind === 'item'}
|
|
114
|
+
{@const isLast = slot.index === items.length - 1}
|
|
115
|
+
<li>
|
|
116
|
+
{#if slot.item.href && !isLast}
|
|
117
|
+
<a href={slot.item.href} class="jatui-crumb">
|
|
118
|
+
{@render lead(slot.item, slot.index)}{slot.item.label}
|
|
119
|
+
</a>
|
|
120
|
+
{:else}
|
|
121
|
+
<span class="jatui-crumb" aria-current={isLast ? 'page' : undefined}>
|
|
122
|
+
{@render lead(slot.item, slot.index)}{slot.item.label}
|
|
123
|
+
</span>
|
|
124
|
+
{/if}
|
|
125
|
+
</li>
|
|
126
|
+
{:else}
|
|
127
|
+
<li>
|
|
128
|
+
<button
|
|
129
|
+
bind:this={triggerEl}
|
|
130
|
+
type="button"
|
|
131
|
+
class="jatui-crumb-more"
|
|
132
|
+
aria-haspopup="menu"
|
|
133
|
+
aria-expanded={menuOpen}
|
|
134
|
+
aria-label="Show {slot.hidden.length} hidden {slot.hidden.length === 1 ? 'level' : 'levels'}"
|
|
135
|
+
onclick={onTriggerClick}
|
|
136
|
+
onkeydown={onTriggerKeydown}
|
|
137
|
+
>
|
|
138
|
+
<span aria-hidden="true">…</span>
|
|
139
|
+
</button>
|
|
140
|
+
{#if menuOpen}
|
|
141
|
+
<ul
|
|
142
|
+
bind:this={menuEl}
|
|
143
|
+
role="menu"
|
|
144
|
+
aria-label="Hidden levels"
|
|
145
|
+
class="jatui-crumb-menu bg-base-100 border-base-300 z-50 border p-1"
|
|
146
|
+
use:floating={{
|
|
147
|
+
anchor: triggerEl ?? null,
|
|
148
|
+
placement: 'bottom-start',
|
|
149
|
+
offset: 4,
|
|
150
|
+
onDismiss: dismiss
|
|
151
|
+
}}
|
|
152
|
+
onkeydown={onMenuKeydown}
|
|
153
|
+
>
|
|
154
|
+
{#each slot.hidden as h (h.index)}
|
|
155
|
+
<li role="none">
|
|
156
|
+
{#if h.item.href}
|
|
157
|
+
<a
|
|
158
|
+
role="menuitem"
|
|
159
|
+
href={h.item.href}
|
|
160
|
+
class="jatui-crumb-menuitem"
|
|
161
|
+
onclick={() => closeMenu(false)}
|
|
162
|
+
>
|
|
163
|
+
{@render lead(h.item, h.index)}{h.item.label}
|
|
164
|
+
</a>
|
|
165
|
+
{:else}
|
|
166
|
+
<span role="menuitem" tabindex="-1" aria-disabled="true" class="jatui-crumb-menuitem">
|
|
167
|
+
{@render lead(h.item, h.index)}{h.item.label}
|
|
168
|
+
</span>
|
|
169
|
+
{/if}
|
|
170
|
+
</li>
|
|
171
|
+
{/each}
|
|
172
|
+
</ul>
|
|
173
|
+
{/if}
|
|
174
|
+
</li>
|
|
175
|
+
{/if}
|
|
25
176
|
{/each}
|
|
26
177
|
</ul>
|
|
27
|
-
</
|
|
178
|
+
</nav>
|
|
179
|
+
|
|
180
|
+
<style>
|
|
181
|
+
.jatui-crumb,
|
|
182
|
+
.jatui-crumb-menuitem {
|
|
183
|
+
display: inline-flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: var(--jatui-space-md, 6px);
|
|
186
|
+
}
|
|
187
|
+
.jatui-crumb-more {
|
|
188
|
+
display: inline-flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
justify-content: center;
|
|
191
|
+
min-width: 1.75rem;
|
|
192
|
+
height: 1.5rem;
|
|
193
|
+
padding: 0 var(--jatui-space-sm, 4px);
|
|
194
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
195
|
+
color: inherit;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
transition: background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
198
|
+
}
|
|
199
|
+
.jatui-crumb-more:hover,
|
|
200
|
+
.jatui-crumb-more[aria-expanded='true'] {
|
|
201
|
+
background-color: var(
|
|
202
|
+
--jatui-hover-bg,
|
|
203
|
+
color-mix(in oklab, var(--color-base-content) 6%, transparent)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
.jatui-crumb-more:focus-visible,
|
|
207
|
+
.jatui-crumb-menuitem:focus-visible {
|
|
208
|
+
outline: var(--jatui-focus-ring-width, 2px) solid
|
|
209
|
+
var(--jatui-focus-ring, color-mix(in oklab, var(--color-primary) 70%, transparent));
|
|
210
|
+
outline-offset: var(--jatui-focus-ring-offset, 2px);
|
|
211
|
+
}
|
|
212
|
+
.jatui-crumb-menu {
|
|
213
|
+
min-width: 10rem;
|
|
214
|
+
max-width: 20rem;
|
|
215
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
216
|
+
list-style: none;
|
|
217
|
+
}
|
|
218
|
+
/* DaisyUI's breadcrumbs paints a separator before every li — not inside the menu. */
|
|
219
|
+
.jatui-crumb-menu :global(li::before) {
|
|
220
|
+
display: none !important;
|
|
221
|
+
}
|
|
222
|
+
.jatui-crumb-menuitem {
|
|
223
|
+
display: flex;
|
|
224
|
+
width: 100%;
|
|
225
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
|
|
226
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
227
|
+
white-space: nowrap;
|
|
228
|
+
overflow: hidden;
|
|
229
|
+
text-overflow: ellipsis;
|
|
230
|
+
text-decoration: none;
|
|
231
|
+
}
|
|
232
|
+
.jatui-crumb-menuitem:hover,
|
|
233
|
+
.jatui-crumb-menuitem:focus-visible {
|
|
234
|
+
background-color: var(
|
|
235
|
+
--jatui-hover-bg,
|
|
236
|
+
color-mix(in oklab, var(--color-base-content) 6%, transparent)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
.jatui-crumb-menuitem[aria-disabled='true'] {
|
|
240
|
+
opacity: 0.6;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
@@ -1,11 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb — hierarchical location trail.
|
|
3
|
+
*
|
|
4
|
+
* <Breadcrumb items={[{ label: 'Home', href: '/' }, …, { label: 'README.md' }]} />
|
|
5
|
+
* <Breadcrumb {items} maxItems={4} /> Home / … / y / z
|
|
6
|
+
* <Breadcrumb items={[{ label: 'Home', href: '/', icon: HomeIcon }, …]} />
|
|
7
|
+
*
|
|
8
|
+
* `maxItems` (BoardUI wave 1, jatui-owg2z) keeps the root and the last
|
|
9
|
+
* `maxItems - 1` items and folds the middle into an ellipsis button whose
|
|
10
|
+
* menu lists the hidden levels (arrow keys, Escape, click-outside). The last
|
|
11
|
+
* item is the current page (`aria-current="page"`). Icons come from
|
|
12
|
+
* `item.icon` (any component taking `{ size, class }`, e.g. lucide-svelte)
|
|
13
|
+
* or the `icon` snippet for full control.
|
|
14
|
+
*/
|
|
15
|
+
import { type Component, type Snippet } from 'svelte';
|
|
1
16
|
export interface BreadcrumbItem {
|
|
2
17
|
label: string;
|
|
3
18
|
href?: string;
|
|
19
|
+
/** Leading icon component (receives `size` and `class`). */
|
|
20
|
+
icon?: Component<{
|
|
21
|
+
size?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}>;
|
|
4
24
|
}
|
|
5
25
|
interface Props {
|
|
6
26
|
items: BreadcrumbItem[];
|
|
27
|
+
/** Collapse long trails to root / … / last (maxItems - 1). Unset = never collapse. */
|
|
28
|
+
maxItems?: number;
|
|
29
|
+
/** Custom leading icon per item — overrides `item.icon`. */
|
|
30
|
+
icon?: Snippet<[BreadcrumbItem, number]>;
|
|
31
|
+
/** Accessible name of the nav landmark. Default "Breadcrumb". */
|
|
32
|
+
ariaLabel?: string;
|
|
7
33
|
class?: string;
|
|
8
34
|
}
|
|
9
|
-
declare const Breadcrumb:
|
|
35
|
+
declare const Breadcrumb: Component<Props, {}, "">;
|
|
10
36
|
type Breadcrumb = ReturnType<typeof Breadcrumb>;
|
|
11
37
|
export default Breadcrumb;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb overflow collapse (jatui-owg2z). Pure.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the first item and the last `maxItems - 1`, folding the middle into
|
|
5
|
+
* one ellipsis entry: "Home / … / y / z". The current page (last item) is
|
|
6
|
+
* always visible; the root is visible whenever maxItems ≥ 2.
|
|
7
|
+
*/
|
|
8
|
+
export type BreadcrumbSlot<T> = {
|
|
9
|
+
kind: 'item';
|
|
10
|
+
item: T;
|
|
11
|
+
index: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'ellipsis';
|
|
14
|
+
hidden: {
|
|
15
|
+
item: T;
|
|
16
|
+
index: number;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
export declare function collapseBreadcrumb<T>(items: readonly T[], maxItems?: number): BreadcrumbSlot<T>[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function collapseBreadcrumb(items, maxItems) {
|
|
2
|
+
const all = items.map((item, index) => ({ kind: 'item', item, index }));
|
|
3
|
+
if (maxItems == null || !(maxItems > 0) || items.length <= maxItems)
|
|
4
|
+
return all;
|
|
5
|
+
const max = Math.max(1, Math.floor(maxItems));
|
|
6
|
+
if (max === 1) {
|
|
7
|
+
return [{ kind: 'ellipsis', hidden: all.slice(0, -1) }, all[all.length - 1]];
|
|
8
|
+
}
|
|
9
|
+
const tail = all.slice(items.length - (max - 1));
|
|
10
|
+
const hidden = all.slice(1, items.length - (max - 1));
|
|
11
|
+
return [all[0], { kind: 'ellipsis', hidden }, ...tail];
|
|
12
|
+
}
|