@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.465",
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.465",
338
- "@djangocfg/ui-core": "^2.1.465",
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.465",
422
- "@djangocfg/typescript-config": "^2.1.465",
423
- "@djangocfg/ui-core": "^2.1.465",
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",
@@ -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
- `renderItem` is an escape hatch for one-off chip styling without forking the component.
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
- <h3 className="text-base font-semibold text-foreground">
86
+ <Title className="text-base font-semibold text-foreground">
82
87
  {title}
83
- </h3>
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
- <div
95
- className="grid w-full max-w-xl grid-cols-1 gap-2 sm:grid-cols-2"
96
- role="list"
97
- >
98
- {items.map((item, idx) =>
99
- renderItem ? (
100
- <span key={item.id} role="listitem">
101
- {renderItem(item, idx)}
102
- </span>
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
- <div
110
- className="flex w-full max-w-xl flex-wrap justify-center gap-2"
111
- role="list"
112
- >
113
- {items.map((item, idx) =>
114
- renderItem ? (
115
- <span key={item.id} role="listitem">
116
- {renderItem(item, idx)}
117
- </span>
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="tree"
254
- aria-label={ctx.labels.ariaLabel}
255
- aria-multiselectable={ctx.selectionMode === 'multiple' || undefined}
256
- aria-activedescendant={focusedId ? treeRowDomId(focusedId) : undefined}
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">{renderRow}</TreeContent>
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,