@djangocfg/ui-tools 2.1.465 → 2.1.466
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/shell/SuggestedPrompts.tsx +26 -33
- package/src/tools/chat/shell/__tests__/SuggestedPrompts.test.tsx +51 -0
- 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.466",
|
|
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.466",
|
|
338
|
+
"@djangocfg/ui-core": "^2.1.466",
|
|
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.466",
|
|
422
|
+
"@djangocfg/typescript-config": "^2.1.466",
|
|
423
|
+
"@djangocfg/ui-core": "^2.1.466",
|
|
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
|
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
|
@@ -219,6 +219,7 @@ function TreeRootShell<T>({
|
|
|
219
219
|
// nav, type-ahead, programmatic). Centralised so every focus source gets
|
|
220
220
|
// consistent scrolling — previously only type-ahead scrolled.
|
|
221
221
|
const focusedId = ctx.focused;
|
|
222
|
+
const isEmpty = ctx.flatRows.length === 0;
|
|
222
223
|
useEffect(() => {
|
|
223
224
|
if (!focusedId) return;
|
|
224
225
|
const el = containerRef.current?.querySelector<HTMLElement>(
|
|
@@ -249,11 +250,15 @@ function TreeRootShell<T>({
|
|
|
249
250
|
const treeBody = (
|
|
250
251
|
<div
|
|
251
252
|
ref={setContainerRef}
|
|
252
|
-
tabIndex={0}
|
|
253
|
-
role=
|
|
254
|
-
aria-label={ctx.labels.ariaLabel}
|
|
255
|
-
aria-multiselectable={
|
|
256
|
-
|
|
253
|
+
tabIndex={isEmpty ? undefined : 0}
|
|
254
|
+
role={isEmpty ? undefined : 'tree'}
|
|
255
|
+
aria-label={isEmpty ? undefined : ctx.labels.ariaLabel}
|
|
256
|
+
aria-multiselectable={
|
|
257
|
+
!isEmpty && ctx.selectionMode === 'multiple' ? true : undefined
|
|
258
|
+
}
|
|
259
|
+
aria-activedescendant={
|
|
260
|
+
!isEmpty && focusedId ? treeRowDomId(focusedId) : undefined
|
|
261
|
+
}
|
|
257
262
|
className={cn(
|
|
258
263
|
'group/tree flex h-full w-full flex-col gap-2 rounded-sm outline-none',
|
|
259
264
|
'focus-visible:ring-1 focus-visible:ring-ring/50',
|
|
@@ -264,7 +269,9 @@ function TreeRootShell<T>({
|
|
|
264
269
|
>
|
|
265
270
|
{enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
|
|
266
271
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto px-1">
|
|
267
|
-
<TreeContent<T> role="group"
|
|
272
|
+
<TreeContent<T> role={isEmpty ? "presentation" : "group"}>
|
|
273
|
+
{renderRow}
|
|
274
|
+
</TreeContent>
|
|
268
275
|
{/* Empty-area: catches right-clicks on whitespace below the
|
|
269
276
|
last row + acts as the root drop target for DnD. Always
|
|
270
277
|
rendered — it self-disables when there's nothing to do. */}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act } from 'react';
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { TreeRoot } from '../TreeRoot';
|
|
8
|
+
|
|
9
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
describe('TreeRoot empty semantics', () => {
|
|
12
|
+
const mounted: Array<ReturnType<typeof createRoot>> = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('announces the empty state without exposing an invalid empty tree', () => {
|
|
19
|
+
const container = document.createElement('div');
|
|
20
|
+
const root = createRoot(container);
|
|
21
|
+
mounted.push(root);
|
|
22
|
+
|
|
23
|
+
act(() => {
|
|
24
|
+
root.render(
|
|
25
|
+
<TreeRoot<{ name: string }>
|
|
26
|
+
data={[]}
|
|
27
|
+
getItemName={(node) => node.data.name}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(container.querySelector('[role="tree"]')).toBeNull();
|
|
33
|
+
expect(container.querySelector('[data-tree-root]')?.hasAttribute('tabindex')).toBe(false);
|
|
34
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe('Nothing to show');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -10,6 +10,7 @@ export interface TreeEmptyProps {
|
|
|
10
10
|
export function TreeEmpty({ children, className }: TreeEmptyProps) {
|
|
11
11
|
return (
|
|
12
12
|
<div
|
|
13
|
+
role="status"
|
|
13
14
|
className={cn(
|
|
14
15
|
'flex h-full min-h-32 items-center justify-center px-4 py-6 text-sm text-muted-foreground',
|
|
15
16
|
className,
|