@djangocfg/ui-tools 2.1.465 → 2.1.468
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/dist/tree/index.cjs +8 -6
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.mjs +8 -6
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/chat/README.md +5 -1
- package/src/tools/chat/messages/MessageBubble.tsx +5 -4
- package/src/tools/chat/messages/MessageDateSeparator.tsx +36 -0
- package/src/tools/chat/messages/MessageList.tsx +81 -5
- package/src/tools/chat/messages/__tests__/messageDate.test.ts +50 -0
- package/src/tools/chat/messages/index.ts +7 -0
- package/src/tools/chat/messages/messageDate.ts +79 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +26 -33
- package/src/tools/chat/shell/__tests__/SuggestedPrompts.test.tsx +51 -0
- package/src/tools/chat/styles/bubbleTokens.ts +9 -10
- package/src/tools/data/Tree/README.md +3 -0
- package/src/tools/data/Tree/TreeRoot.tsx +13 -6
- package/src/tools/data/Tree/__tests__/TreeRoot.accessibility.test.tsx +36 -0
- package/src/tools/data/Tree/components/TreeEmpty.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.468",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -334,8 +334,8 @@
|
|
|
334
334
|
"test:watch": "vitest"
|
|
335
335
|
},
|
|
336
336
|
"peerDependencies": {
|
|
337
|
-
"@djangocfg/i18n": "^2.1.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
337
|
+
"@djangocfg/i18n": "^2.1.468",
|
|
338
|
+
"@djangocfg/ui-core": "^2.1.468",
|
|
339
339
|
"consola": "^3.4.2",
|
|
340
340
|
"lodash-es": "^4.18.1",
|
|
341
341
|
"lucide-react": "^0.545.0",
|
|
@@ -418,9 +418,9 @@
|
|
|
418
418
|
"@maplibre/maplibre-gl-geocoder": "^1.9.4"
|
|
419
419
|
},
|
|
420
420
|
"devDependencies": {
|
|
421
|
-
"@djangocfg/i18n": "^2.1.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
421
|
+
"@djangocfg/i18n": "^2.1.468",
|
|
422
|
+
"@djangocfg/typescript-config": "^2.1.468",
|
|
423
|
+
"@djangocfg/ui-core": "^2.1.468",
|
|
424
424
|
"@types/lodash-es": "^4.17.12",
|
|
425
425
|
"@types/mapbox__mapbox-gl-draw": "^1.4.9",
|
|
426
426
|
"@types/node": "^25.9.5",
|
package/src/tools/chat/README.md
CHANGED
|
@@ -1002,6 +1002,7 @@ const PROMPTS: readonly SuggestedPromptItem[] = [
|
|
|
1002
1002
|
onPick={(item) => { setValue(item.prompt); focus(); }}
|
|
1003
1003
|
hero={<Sparkles className="size-6 text-primary" />}
|
|
1004
1004
|
title="How can I help?"
|
|
1005
|
+
titleLevel={2}
|
|
1005
1006
|
description="Pick a starter, or just start typing."
|
|
1006
1007
|
/>
|
|
1007
1008
|
),
|
|
@@ -1009,7 +1010,10 @@ const PROMPTS: readonly SuggestedPromptItem[] = [
|
|
|
1009
1010
|
/>
|
|
1010
1011
|
```
|
|
1011
1012
|
|
|
1012
|
-
|
|
1013
|
+
The prompt collection uses native `ul`/`li` semantics while each prompt remains
|
|
1014
|
+
a button. `titleLevel` defaults to `2`; set it to `3` or `4` when the empty state
|
|
1015
|
+
is nested below an existing section heading. `renderItem` is an escape hatch for
|
|
1016
|
+
one-off chip styling without forking the component.
|
|
1013
1017
|
|
|
1014
1018
|
### 3. Headless
|
|
1015
1019
|
|
|
@@ -41,6 +41,7 @@ import type { ComposerAppearance } from '../composer/types';
|
|
|
41
41
|
export interface MessageBubbleProps {
|
|
42
42
|
message: ChatMessage;
|
|
43
43
|
isUser?: boolean;
|
|
44
|
+
/** Show the sender avatar. Defaults to `false` for the flat transcript layout. */
|
|
44
45
|
showAvatar?: boolean;
|
|
45
46
|
/** Override avatar URL (skips persona resolution). */
|
|
46
47
|
avatarSrc?: string;
|
|
@@ -158,9 +159,8 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
158
159
|
avatar: 'size-10',
|
|
159
160
|
avatarFallback: 'text-sm',
|
|
160
161
|
content: 'max-w-[44rem]',
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed',
|
|
162
|
+
// Both roles share roomy geometry; their surfaces differ by intensity.
|
|
163
|
+
bubble: 'text-base leading-relaxed',
|
|
164
164
|
meta: 'text-xs',
|
|
165
165
|
},
|
|
166
166
|
};
|
|
@@ -168,7 +168,7 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
168
168
|
const MessageBubbleInner = ({
|
|
169
169
|
message,
|
|
170
170
|
isUser: isUserProp,
|
|
171
|
-
showAvatar =
|
|
171
|
+
showAvatar = false,
|
|
172
172
|
avatarSrc,
|
|
173
173
|
avatarFallback,
|
|
174
174
|
user,
|
|
@@ -346,6 +346,7 @@ const MessageBubbleInner = ({
|
|
|
346
346
|
// `select-text` on the prose itself: the text is the only
|
|
347
347
|
// selection island, bounded to this one message.
|
|
348
348
|
'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
|
|
349
|
+
appearance === 'full' && 'rounded-3xl px-5 py-3.5',
|
|
349
350
|
ap.bubble,
|
|
350
351
|
bubbleSurface,
|
|
351
352
|
)}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
4
|
+
|
|
5
|
+
import { formatMessageDateLabel } from './messageDate';
|
|
6
|
+
|
|
7
|
+
export interface MessageDateSeparatorProps {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
timeZone: string;
|
|
10
|
+
locale?: string;
|
|
11
|
+
now?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MessageDateSeparator = memo(function MessageDateSeparator({
|
|
16
|
+
timestamp,
|
|
17
|
+
timeZone,
|
|
18
|
+
locale,
|
|
19
|
+
now,
|
|
20
|
+
className,
|
|
21
|
+
}: MessageDateSeparatorProps) {
|
|
22
|
+
const label = formatMessageDateLabel(timestamp, { locale, timeZone, now });
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
role="separator"
|
|
27
|
+
aria-label={label}
|
|
28
|
+
className={cn(
|
|
29
|
+
'flex items-center justify-center px-3 pb-2 pt-5 text-xs font-medium text-muted-foreground',
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<time dateTime={new Date(timestamp).toISOString()}>{label}</time>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -19,6 +19,8 @@ import { useCopy } from '@djangocfg/ui-core/hooks';
|
|
|
19
19
|
import type { ChatMessage } from '../types';
|
|
20
20
|
import { useChatContextOptional } from '../context';
|
|
21
21
|
import { MessageBubble } from './MessageBubble';
|
|
22
|
+
import { MessageDateSeparator } from './MessageDateSeparator';
|
|
23
|
+
import { formatMessageDateLabel, getCalendarDayKey } from './messageDate';
|
|
22
24
|
import type { ComposerAppearance } from '../composer/types';
|
|
23
25
|
|
|
24
26
|
export interface MessageListProps {
|
|
@@ -69,6 +71,18 @@ export interface MessageListProps {
|
|
|
69
71
|
* whole bubble up for full-page chat. Default `compact` (no change).
|
|
70
72
|
* Ignored when `renderItem` is set (the host owns bubble rendering). */
|
|
71
73
|
appearance?: ComposerAppearance;
|
|
74
|
+
/** Show a divider when adjacent messages fall on different calendar days. */
|
|
75
|
+
showDateSeparators?: boolean;
|
|
76
|
+
/** Locale used for divider labels. Defaults to the viewer's locale. */
|
|
77
|
+
dateSeparatorLocale?: string;
|
|
78
|
+
/**
|
|
79
|
+
* IANA timezone used for calendar grouping (for example `UTC` or
|
|
80
|
+
* `Asia/Makassar`). Defaults to the viewer's browser timezone after mount.
|
|
81
|
+
* Message timestamps remain absolute UTC epoch milliseconds.
|
|
82
|
+
*/
|
|
83
|
+
dateSeparatorTimeZone?: string;
|
|
84
|
+
/** Replace the default date divider while retaining day-grouping behavior. */
|
|
85
|
+
renderDateSeparator?: (timestamp: number, label: string) => ReactNode;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
export interface MessageListHandle {
|
|
@@ -103,6 +117,10 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
103
117
|
atBottomThreshold = 120,
|
|
104
118
|
scrollAnchorId,
|
|
105
119
|
appearance = 'compact',
|
|
120
|
+
showDateSeparators = true,
|
|
121
|
+
dateSeparatorLocale,
|
|
122
|
+
dateSeparatorTimeZone,
|
|
123
|
+
renderDateSeparator,
|
|
106
124
|
},
|
|
107
125
|
ref,
|
|
108
126
|
) {
|
|
@@ -110,6 +128,22 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
110
128
|
const messages = messagesProp ?? ctx?.messages ?? [];
|
|
111
129
|
const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false;
|
|
112
130
|
const { copyToClipboard } = useCopy();
|
|
131
|
+
// Do not guess the server timezone during SSR. When the host does not pass
|
|
132
|
+
// one explicitly, resolve it in the browser after mount; this avoids a
|
|
133
|
+
// hydration mismatch around midnight while still grouping for the viewer.
|
|
134
|
+
const [browserTimeZone, setBrowserTimeZone] = useState<string | undefined>(dateSeparatorTimeZone);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (dateSeparatorTimeZone) {
|
|
137
|
+
setBrowserTimeZone(dateSeparatorTimeZone);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setBrowserTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
|
141
|
+
}, [dateSeparatorTimeZone]);
|
|
142
|
+
const resolvedDateSeparatorTimeZone = dateSeparatorTimeZone ?? browserTimeZone;
|
|
143
|
+
// Keep relative labels stable for the lifetime of this list. Normal chat
|
|
144
|
+
// activity remounts/re-renders the shell often enough; more importantly,
|
|
145
|
+
// one render cannot label adjacent separators against different instants.
|
|
146
|
+
const dateLabelNow = useMemo(() => Date.now(), []);
|
|
113
147
|
|
|
114
148
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
|
115
149
|
// Virtuoso's `atBottomStateChange` only fires when the list is
|
|
@@ -210,6 +244,50 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
210
244
|
);
|
|
211
245
|
|
|
212
246
|
const itemRenderer = renderItem ?? defaultRenderItem;
|
|
247
|
+
const renderTranscriptItem = useCallback(
|
|
248
|
+
(m: ChatMessage, index: number) => {
|
|
249
|
+
const item = itemRenderer(m, index);
|
|
250
|
+
if (!showDateSeparators || !resolvedDateSeparatorTimeZone) return item;
|
|
251
|
+
|
|
252
|
+
const previous = messages[index - 1];
|
|
253
|
+
const startsNewDay =
|
|
254
|
+
!previous ||
|
|
255
|
+
getCalendarDayKey(previous.createdAt, resolvedDateSeparatorTimeZone) !==
|
|
256
|
+
getCalendarDayKey(m.createdAt, resolvedDateSeparatorTimeZone);
|
|
257
|
+
|
|
258
|
+
if (!startsNewDay) return item;
|
|
259
|
+
|
|
260
|
+
const separator = (
|
|
261
|
+
<MessageDateSeparator
|
|
262
|
+
timestamp={m.createdAt}
|
|
263
|
+
timeZone={resolvedDateSeparatorTimeZone}
|
|
264
|
+
locale={dateSeparatorLocale}
|
|
265
|
+
now={dateLabelNow}
|
|
266
|
+
/>
|
|
267
|
+
);
|
|
268
|
+
const label = formatMessageDateLabel(m.createdAt, {
|
|
269
|
+
locale: dateSeparatorLocale,
|
|
270
|
+
timeZone: resolvedDateSeparatorTimeZone,
|
|
271
|
+
now: dateLabelNow,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
{renderDateSeparator ? renderDateSeparator(m.createdAt, label) : separator}
|
|
277
|
+
{item}
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
[
|
|
282
|
+
dateLabelNow,
|
|
283
|
+
dateSeparatorLocale,
|
|
284
|
+
itemRenderer,
|
|
285
|
+
messages,
|
|
286
|
+
renderDateSeparator,
|
|
287
|
+
resolvedDateSeparatorTimeZone,
|
|
288
|
+
showDateSeparators,
|
|
289
|
+
],
|
|
290
|
+
);
|
|
213
291
|
// Jump to bottom on the first non-empty messages batch. A SINGLE rAF +
|
|
214
292
|
// scrollToIndex is not enough when the list mounts with a long transcript
|
|
215
293
|
// (opening / switching a contact): virtuoso measures the dynamic bubble
|
|
@@ -242,9 +320,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
242
320
|
// self-cancel.
|
|
243
321
|
const scroller = scrollerRef.current;
|
|
244
322
|
const scrollerEl =
|
|
245
|
-
scroller != null && scroller !== window && scroller instanceof HTMLElement
|
|
246
|
-
? scroller
|
|
247
|
-
: null;
|
|
323
|
+
scroller != null && scroller !== window && scroller instanceof HTMLElement ? scroller : null;
|
|
248
324
|
scrollerEl?.addEventListener('wheel', cancelOnUserScroll, { passive: true });
|
|
249
325
|
scrollerEl?.addEventListener('touchstart', cancelOnUserScroll, { passive: true });
|
|
250
326
|
scrollerEl?.addEventListener('keydown', cancelOnUserScroll);
|
|
@@ -454,7 +530,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
454
530
|
</div>
|
|
455
531
|
) : null}
|
|
456
532
|
{messages.map((m, i) => (
|
|
457
|
-
<div key={m.id ?? i}>{
|
|
533
|
+
<div key={m.id ?? i}>{renderTranscriptItem(m, i)}</div>
|
|
458
534
|
))}
|
|
459
535
|
{/* Mirror the virtualized Footer spacer so the last bubble keeps
|
|
460
536
|
its breathing room above the composer in the non-virtual path. */}
|
|
@@ -474,7 +550,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
474
550
|
computeItemKey={computeItemKey}
|
|
475
551
|
itemContent={(index, m) => {
|
|
476
552
|
if (!m) return null;
|
|
477
|
-
const node =
|
|
553
|
+
const node = renderTranscriptItem(m, index);
|
|
478
554
|
// Wrap only the last bubble in a size-observed shell so we can
|
|
479
555
|
// re-pin to the absolute bottom as the streaming reply grows
|
|
480
556
|
// (corrects virtuoso's slight final-chunk under-scroll). The
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatMessageDateLabel, getCalendarDayKey } from '../messageDate';
|
|
4
|
+
|
|
5
|
+
describe('messageDate', () => {
|
|
6
|
+
it('groups an absolute timestamp by the requested timezone', () => {
|
|
7
|
+
const timestamp = Date.UTC(2026, 6, 12, 23, 30);
|
|
8
|
+
|
|
9
|
+
expect(getCalendarDayKey(timestamp, 'UTC')).toBe('2026-07-12');
|
|
10
|
+
expect(getCalendarDayKey(timestamp, 'Asia/Makassar')).toBe('2026-07-13');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('formats today and yesterday relative to the display timezone', () => {
|
|
14
|
+
const now = Date.UTC(2026, 6, 13, 12);
|
|
15
|
+
|
|
16
|
+
expect(
|
|
17
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 13, 1), {
|
|
18
|
+
locale: 'en-US',
|
|
19
|
+
timeZone: 'UTC',
|
|
20
|
+
now,
|
|
21
|
+
}),
|
|
22
|
+
).toBe('Today');
|
|
23
|
+
expect(
|
|
24
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 12, 23), {
|
|
25
|
+
locale: 'en-US',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
now,
|
|
28
|
+
}),
|
|
29
|
+
).toBe('Yesterday');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('includes the year only when the message is outside the current year', () => {
|
|
33
|
+
const now = Date.UTC(2026, 6, 13, 12);
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 4, 12), {
|
|
37
|
+
locale: 'en-US',
|
|
38
|
+
timeZone: 'UTC',
|
|
39
|
+
now,
|
|
40
|
+
}),
|
|
41
|
+
).toBe('Saturday, July 4');
|
|
42
|
+
expect(
|
|
43
|
+
formatMessageDateLabel(Date.UTC(2025, 6, 4, 12), {
|
|
44
|
+
locale: 'en-US',
|
|
45
|
+
timeZone: 'UTC',
|
|
46
|
+
now,
|
|
47
|
+
}),
|
|
48
|
+
).toBe('Friday, July 4, 2025');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -8,6 +8,13 @@ export {
|
|
|
8
8
|
type MessageListHandle,
|
|
9
9
|
} from './MessageList';
|
|
10
10
|
export { MessageBubble, type MessageBubbleProps } from './MessageBubble';
|
|
11
|
+
export { MessageDateSeparator, type MessageDateSeparatorProps } from './MessageDateSeparator';
|
|
12
|
+
export {
|
|
13
|
+
formatMessageDateLabel,
|
|
14
|
+
getCalendarDateParts,
|
|
15
|
+
getCalendarDayKey,
|
|
16
|
+
type CalendarDateParts,
|
|
17
|
+
} from './messageDate';
|
|
11
18
|
// The auto-detect link-preview slot — shared by MessageBubble and any forked
|
|
12
19
|
// bubble (e.g. cmdop's mention bubble) so previews never silently drift.
|
|
13
20
|
export { AutoLinkPreview, type AutoLinkPreviewProps } from './AutoLinkPreview';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar helpers for chat timestamps.
|
|
3
|
+
*
|
|
4
|
+
* `ChatMessage.createdAt` is an epoch-millisecond value, so it represents an
|
|
5
|
+
* absolute UTC instant. Calendar grouping is deliberately performed in the
|
|
6
|
+
* viewer's display timezone: the same instant may belong to Monday in UTC and
|
|
7
|
+
* Tuesday in Asia/Makassar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CalendarDateParts {
|
|
11
|
+
year: number;
|
|
12
|
+
month: number;
|
|
13
|
+
day: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getCalendarDateParts(timestamp: number, timeZone: string): CalendarDateParts {
|
|
17
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
18
|
+
timeZone,
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
month: '2-digit',
|
|
21
|
+
day: '2-digit',
|
|
22
|
+
}).formatToParts(new Date(timestamp));
|
|
23
|
+
|
|
24
|
+
const values = Object.fromEntries(
|
|
25
|
+
parts
|
|
26
|
+
.filter((part) => part.type === 'year' || part.type === 'month' || part.type === 'day')
|
|
27
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
year: values.year,
|
|
32
|
+
month: values.month,
|
|
33
|
+
day: values.day,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getCalendarDayKey(timestamp: number, timeZone: string): string {
|
|
38
|
+
const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
|
|
39
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function calendarDayNumber(timestamp: number, timeZone: string): number {
|
|
43
|
+
const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
|
|
44
|
+
return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function capitalizeLabel(label: string, locale?: string): string {
|
|
48
|
+
if (!label) return label;
|
|
49
|
+
return label[0].toLocaleUpperCase(locale) + label.slice(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatMessageDateLabel(
|
|
53
|
+
timestamp: number,
|
|
54
|
+
options: {
|
|
55
|
+
locale?: string;
|
|
56
|
+
timeZone: string;
|
|
57
|
+
now?: number;
|
|
58
|
+
},
|
|
59
|
+
): string {
|
|
60
|
+
const { locale, timeZone, now = Date.now() } = options;
|
|
61
|
+
const dayDelta = calendarDayNumber(timestamp, timeZone) - calendarDayNumber(now, timeZone);
|
|
62
|
+
|
|
63
|
+
if (dayDelta === 0 || dayDelta === -1) {
|
|
64
|
+
return capitalizeLabel(
|
|
65
|
+
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(dayDelta, 'day'),
|
|
66
|
+
locale,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const messageYear = getCalendarDateParts(timestamp, timeZone).year;
|
|
71
|
+
const currentYear = getCalendarDateParts(now, timeZone).year;
|
|
72
|
+
return new Intl.DateTimeFormat(locale, {
|
|
73
|
+
timeZone,
|
|
74
|
+
weekday: 'long',
|
|
75
|
+
month: 'long',
|
|
76
|
+
day: 'numeric',
|
|
77
|
+
...(messageYear === currentYear ? {} : { year: 'numeric' as const }),
|
|
78
|
+
}).format(new Date(timestamp));
|
|
79
|
+
}
|
|
@@ -30,12 +30,15 @@ export type SuggestedPromptItem = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
export type SuggestedPromptsLayout = 'chips' | 'grid';
|
|
33
|
+
export type SuggestedPromptsTitleLevel = 2 | 3 | 4;
|
|
33
34
|
|
|
34
35
|
export interface SuggestedPromptsProps {
|
|
35
36
|
items: readonly SuggestedPromptItem[];
|
|
36
37
|
onPick: (item: SuggestedPromptItem) => void;
|
|
37
38
|
/** Optional title above the chips. */
|
|
38
39
|
title?: ReactNode;
|
|
40
|
+
/** Semantic title level. Defaults to `2`; set it to match the host page outline. */
|
|
41
|
+
titleLevel?: SuggestedPromptsTitleLevel;
|
|
39
42
|
/** Optional subtitle/description above the chips. */
|
|
40
43
|
description?: ReactNode;
|
|
41
44
|
/** Optional hero element above the title (icon, logo, …). */
|
|
@@ -54,6 +57,7 @@ export function SuggestedPrompts({
|
|
|
54
57
|
items,
|
|
55
58
|
onPick,
|
|
56
59
|
title,
|
|
60
|
+
titleLevel = 2,
|
|
57
61
|
description,
|
|
58
62
|
hero,
|
|
59
63
|
layout = 'chips',
|
|
@@ -62,6 +66,7 @@ export function SuggestedPrompts({
|
|
|
62
66
|
renderItem,
|
|
63
67
|
}: SuggestedPromptsProps) {
|
|
64
68
|
const hasHeader = hero != null || title != null || description != null;
|
|
69
|
+
const Title = `h${titleLevel}` as 'h2' | 'h3' | 'h4';
|
|
65
70
|
|
|
66
71
|
return (
|
|
67
72
|
<section
|
|
@@ -78,9 +83,9 @@ export function SuggestedPrompts({
|
|
|
78
83
|
<div className="text-foreground/90">{hero}</div>
|
|
79
84
|
) : null}
|
|
80
85
|
{title != null ? (
|
|
81
|
-
<
|
|
86
|
+
<Title className="text-base font-semibold text-foreground">
|
|
82
87
|
{title}
|
|
83
|
-
</
|
|
88
|
+
</Title>
|
|
84
89
|
) : null}
|
|
85
90
|
{description != null ? (
|
|
86
91
|
<p className="max-w-md text-sm leading-snug text-muted-foreground">
|
|
@@ -91,35 +96,25 @@ export function SuggestedPrompts({
|
|
|
91
96
|
) : null}
|
|
92
97
|
|
|
93
98
|
{layout === 'grid' ? (
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
) : (
|
|
104
|
-
<GridCard key={item.id} item={item} onPick={onPick} />
|
|
105
|
-
),
|
|
106
|
-
)}
|
|
107
|
-
</div>
|
|
99
|
+
<ul className="m-0 grid w-full max-w-xl list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
|
|
100
|
+
{items.map((item, idx) => (
|
|
101
|
+
<li key={item.id} className="min-w-0">
|
|
102
|
+
{renderItem
|
|
103
|
+
? renderItem(item, idx)
|
|
104
|
+
: <GridCard item={item} onPick={onPick} />}
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
108
|
) : (
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
) : (
|
|
119
|
-
<Chip key={item.id} item={item} onPick={onPick} />
|
|
120
|
-
),
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
109
|
+
<ul className="m-0 flex w-full max-w-xl list-none flex-wrap justify-center gap-2 p-0">
|
|
110
|
+
{items.map((item, idx) => (
|
|
111
|
+
<li key={item.id}>
|
|
112
|
+
{renderItem
|
|
113
|
+
? renderItem(item, idx)
|
|
114
|
+
: <Chip item={item} onPick={onPick} />}
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
123
118
|
)}
|
|
124
119
|
</section>
|
|
125
120
|
);
|
|
@@ -135,7 +130,6 @@ function Chip({
|
|
|
135
130
|
return (
|
|
136
131
|
<button
|
|
137
132
|
type="button"
|
|
138
|
-
role="listitem"
|
|
139
133
|
onClick={() => onPick(item)}
|
|
140
134
|
className={cn(
|
|
141
135
|
'inline-flex items-center gap-1.5 rounded-full border border-border',
|
|
@@ -166,10 +160,9 @@ function GridCard({
|
|
|
166
160
|
return (
|
|
167
161
|
<button
|
|
168
162
|
type="button"
|
|
169
|
-
role="listitem"
|
|
170
163
|
onClick={() => onPick(item)}
|
|
171
164
|
className={cn(
|
|
172
|
-
'group flex flex-col gap-1 rounded-md border border-border',
|
|
165
|
+
'group flex h-full w-full flex-col gap-1 rounded-md border border-border',
|
|
173
166
|
'bg-card/60 p-3 text-left',
|
|
174
167
|
'transition-colors duration-150',
|
|
175
168
|
'hover:border-primary/40 hover:bg-card',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, createElement, type ComponentProps } from 'react';
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { SuggestedPrompts, type SuggestedPromptItem } from '../SuggestedPrompts';
|
|
8
|
+
|
|
9
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const items: readonly SuggestedPromptItem[] = [
|
|
12
|
+
{ id: 'one', label: 'First prompt', prompt: 'First prompt' },
|
|
13
|
+
{ id: 'two', label: 'Second prompt', prompt: 'Second prompt' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe('SuggestedPrompts', () => {
|
|
17
|
+
const mounted: Array<ReturnType<typeof createRoot>> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function render(props: Partial<ComponentProps<typeof SuggestedPrompts>> = {}) {
|
|
24
|
+
const container = document.createElement('div');
|
|
25
|
+
const root = createRoot(container);
|
|
26
|
+
mounted.push(root);
|
|
27
|
+
act(() => {
|
|
28
|
+
root.render(createElement(SuggestedPrompts, {
|
|
29
|
+
items,
|
|
30
|
+
onPick: vi.fn(),
|
|
31
|
+
title: 'Start here',
|
|
32
|
+
...props,
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
return container;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('uses native list semantics without overriding button roles', () => {
|
|
39
|
+
const container = render();
|
|
40
|
+
const list = container.querySelector('ul');
|
|
41
|
+
|
|
42
|
+
expect(list?.children).toHaveLength(2);
|
|
43
|
+
expect(Array.from(list?.children ?? []).every((item) => item.tagName === 'LI')).toBe(true);
|
|
44
|
+
expect(container.querySelectorAll('button[role]')).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('defaults to h2 and lets the host select the outline level', () => {
|
|
48
|
+
expect(render().querySelector('h2')?.textContent).toBe('Start here');
|
|
49
|
+
expect(render({ titleLevel: 4 }).querySelector('h4')?.textContent).toBe('Start here');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
/** Bubble surface classes (background + text), keyed by message state. */
|
|
17
17
|
export const BUBBLE_SURFACE = {
|
|
18
|
-
/** User-authored bubble
|
|
19
|
-
user: 'bg-
|
|
20
|
-
/** Assistant
|
|
21
|
-
assistant: 'bg-muted text-foreground
|
|
18
|
+
/** User-authored bubble - a quiet neutral surface aligned to the right. */
|
|
19
|
+
user: 'bg-muted text-foreground',
|
|
20
|
+
/** Assistant replies use a lighter neutral surface than user messages. */
|
|
21
|
+
assistant: 'bg-muted/40 text-foreground',
|
|
22
22
|
/** Assistant bubble when the turn failed — destructive tint. */
|
|
23
23
|
error: 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30',
|
|
24
24
|
} as const;
|
|
@@ -44,23 +44,22 @@ export const BLOCK_SURFACE =
|
|
|
44
44
|
/**
|
|
45
45
|
* Anchor (link) classes for markdown content rendered inside a bubble.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* contrast tracks the design system automatically.
|
|
47
|
+
* User and assistant prose now share neutral surfaces, so both roles use the
|
|
48
|
+
* theme's primary link color with a restrained underline.
|
|
50
49
|
*
|
|
51
50
|
* On the neutral assistant bubble we keep the brand-primary color so links
|
|
52
51
|
* still pop without competing with the body text.
|
|
53
52
|
*/
|
|
54
53
|
export const ANCHOR = {
|
|
55
54
|
user:
|
|
56
|
-
'text-primary
|
|
57
|
-
'hover:decoration-primary
|
|
55
|
+
'text-primary underline decoration-primary/50 underline-offset-2 ' +
|
|
56
|
+
'hover:text-primary/80 hover:decoration-primary transition-colors break-all',
|
|
58
57
|
assistant: 'text-primary underline hover:text-primary/80 transition-colors break-all',
|
|
59
58
|
} as const;
|
|
60
59
|
|
|
61
60
|
/** Inline secondary action (e.g. "Show more / less"). Same logic as anchors. */
|
|
62
61
|
export const TOGGLE = {
|
|
63
|
-
user: 'text-primary
|
|
62
|
+
user: 'text-primary hover:text-primary/80',
|
|
64
63
|
assistant: 'text-primary hover:text-primary/80',
|
|
65
64
|
} as const;
|
|
66
65
|
|
|
@@ -20,6 +20,9 @@ We tried popular headless tree engines first. They all leak React-integration bu
|
|
|
20
20
|
5. **CRUD = adapter, not props.** The host app describes how to delete / rename / move / etc. through a single `TreeAdapter<T>` object. Tree owns the UX (dialogs, hotkeys, menus) and calls back into the adapter. No `onDelete`/`onRename`/... prop sprawl.
|
|
21
21
|
6. **Dialogs come from `<DialogProvider>`.** Tree never re-implements its own dialogs. Built-in CRUD flows resolve `window.dialog` (installed by `@djangocfg/ui-core/lib/dialog-service`). If the host app hasn't mounted it, CRUD flows silently no-op with a dev-mode warning — Tree itself still renders.
|
|
22
22
|
7. **CSS-variable theming.** Density, sizes, gaps, indent — all exposed as `--tree-*` variables on the root. Override in any consumer without re-implementing components.
|
|
23
|
+
8. **Honest empty semantics.** An empty collection is announced as a status and
|
|
24
|
+
does not expose an invalid, focusable ARIA tree until at least one tree item
|
|
25
|
+
exists.
|
|
23
26
|
|
|
24
27
|
## Layered architecture
|
|
25
28
|
|