@djangocfg/layouts 2.1.601 → 2.1.602

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/layouts",
3
- "version": "2.1.601",
3
+ "version": "2.1.602",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -91,11 +91,11 @@
91
91
  "test": "vitest run"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/analytics": "^2.1.601",
95
- "@djangocfg/api": "^2.1.601",
96
- "@djangocfg/devtools": "^2.1.601",
97
- "@djangocfg/i18n": "^2.1.601",
98
- "@djangocfg/ui-core": "^2.1.601",
94
+ "@djangocfg/analytics": "^2.1.602",
95
+ "@djangocfg/api": "^2.1.602",
96
+ "@djangocfg/devtools": "^2.1.602",
97
+ "@djangocfg/i18n": "^2.1.602",
98
+ "@djangocfg/ui-core": "^2.1.602",
99
99
  "@hookform/resolvers": "^5.2.2",
100
100
  "consola": "^3.4.2",
101
101
  "lucide-react": "^0.545.0",
@@ -125,12 +125,12 @@
125
125
  "uuid": "^11.1.1"
126
126
  },
127
127
  "devDependencies": {
128
- "@djangocfg/analytics": "^2.1.601",
129
- "@djangocfg/api": "^2.1.601",
130
- "@djangocfg/devtools": "^2.1.601",
131
- "@djangocfg/i18n": "^2.1.601",
132
- "@djangocfg/typescript-config": "^2.1.601",
133
- "@djangocfg/ui-core": "^2.1.601",
128
+ "@djangocfg/analytics": "^2.1.602",
129
+ "@djangocfg/api": "^2.1.602",
130
+ "@djangocfg/devtools": "^2.1.602",
131
+ "@djangocfg/i18n": "^2.1.602",
132
+ "@djangocfg/typescript-config": "^2.1.602",
133
+ "@djangocfg/ui-core": "^2.1.602",
134
134
  "@storybook/react-vite": "^10.5.0",
135
135
  "@types/node": "^25.9.5",
136
136
  "@types/react": "19.2.15",
@@ -31,6 +31,8 @@ import type { PrivateLayoutProps } from './types';
31
31
 
32
32
  export type {
33
33
  SidebarItem,
34
+ SidebarLinkItem,
35
+ SidebarSettingsItem,
34
36
  SidebarGroupConfig,
35
37
  SidebarConfig,
36
38
  HeaderConfig,
@@ -151,7 +151,8 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
151
151
  | Field | Type | Notes |
152
152
  |---|---|---|
153
153
  | `label` | `string` | Group title. Empty string → flat group (no header). |
154
- | `items` | `SidebarItem[]` | Nav links. |
154
+ | `items` | `SidebarItem[]` | Nav links, or `settingsSection` rows (below). A group whose rows are all launchers for unregistered sections is not rendered. |
155
+ | `separated` | `boolean` | A secondary group (setup, connect): a full divider with air above it, then the label. Ordinary groups are only a hairline apart. Same in the docked sidebar, the peek panel and the drawer (there is no icon rail: a collapsed sidebar is hidden). |
155
156
  | `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
156
157
  | `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. |
157
158
  | `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
@@ -163,9 +164,12 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
163
164
  | Field | Type | Notes |
164
165
  |---|---|---|
165
166
  | `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
167
+ | `settingsSection` | section id | Instead of `href`: a row that opens the Settings dialog on that section. Rendered only while the mounted dialog lists the section (no dead door), active while the dialog shows it. Type it with `SidebarItem<AppSectionId>` (see [SettingsLayout](../SettingsLayout/README.md#typed-section-ids)). |
166
168
  | `badge` | `string \| number` | Trailing badge content. |
167
169
  | `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
168
170
 
171
+ **MCP defaults.** `mcpSidebarItem({ label, icon = 'Cable' })` and `mcpSidebarGroup({ label, itemLabel = 'MCP' })` are ready entries for the section `mcpSettingsSection()` (`@djangocfg/widget-mcp`) registers. Layouts matches it by id (`MCP_SECTION_ID`), so it has no dependency on the widget. Add them unconditionally; they vanish while the section is not registered.
172
+
169
173
  `SidebarFeaturedConfig`:
170
174
 
171
175
  | Field | Type | Notes |
@@ -8,6 +8,7 @@
8
8
  import React, { useMemo } from 'react';
9
9
 
10
10
  import { usePrivateLayoutContext } from '../context';
11
+ import { sidebarItemKey } from '../sidebarItems';
11
12
  import { PrivateSidebarAccount } from './PrivateSidebarAccount';
12
13
  import { SidebarBrand } from './SidebarBrand';
13
14
  import { SidebarNavGroup } from './SidebarNavGroup';
@@ -19,9 +20,10 @@ export function SidebarBody() {
19
20
  const groups = useMemo(
20
21
  () =>
21
22
  (sidebar?.groups ?? []).map((group) =>
23
+ // A group of launchers for sections nobody registered renders nothing itself (see SidebarNavGroup).
22
24
  group.dynamic && group.items.length === 0 ? null : (
23
25
  <SidebarNavGroup
24
- key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
26
+ key={group.label || `__flat_${group.items.map(sidebarItemKey).join('|')}`}
25
27
  group={group}
26
28
  />
27
29
  ),
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import { SidebarProvider } from '@djangocfg/ui-core/components';
7
+
8
+ import { useSettingsDialogStore } from '../../SettingsLayout/store';
9
+ import { PrivateLayoutProvider } from '../context';
10
+ import { mcpSidebarGroup } from '../mcpSidebar';
11
+ import type { SidebarConfig, SidebarGroupConfig } from '../types';
12
+ import { SidebarNavGroup } from './SidebarNavGroup';
13
+
14
+ // react-dom + jsdom only: the package declares no testing-library.
15
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ let host: HTMLElement | null = null;
18
+ let root: Root | null = null;
19
+
20
+ /** Elements by ARIA role and accessible text (own text content), enough for these rows. */
21
+ const screen = {
22
+ all(selector: string, name?: string): HTMLElement[] {
23
+ const found = Array.from(host?.querySelectorAll<HTMLElement>(selector) ?? []);
24
+ return name === undefined ? found : found.filter((el) => el.textContent?.trim() === name);
25
+ },
26
+ link: (name: string) => screen.all('a[href]', name)[0] ?? null,
27
+ button: (name: string) => screen.all('button', name)[0] ?? null,
28
+ separators: () => screen.all('[role="separator"]'),
29
+ text: (value: string) => screen.all('span, div, p', value)[0] ?? null,
30
+ };
31
+
32
+ beforeAll(() => {
33
+ // jsdom has no matchMedia; the sidebar's mobile check reads it.
34
+ window.matchMedia ??= ((query: string) => ({
35
+ matches: false,
36
+ media: query,
37
+ onchange: null,
38
+ addEventListener: () => {},
39
+ removeEventListener: () => {},
40
+ addListener: () => {},
41
+ removeListener: () => {},
42
+ dispatchEvent: () => false,
43
+ })) as typeof window.matchMedia;
44
+ });
45
+
46
+ const registerSections = (...ids: string[]) =>
47
+ useSettingsDialogStore.setState({ sections: ids.map((id) => ({ id, label: id })) });
48
+
49
+ function renderGroups(groups: SidebarGroupConfig[], pathname = '/') {
50
+ const sidebar: SidebarConfig = { groups };
51
+ host = document.createElement('div');
52
+ document.body.appendChild(host);
53
+ root = createRoot(host);
54
+ act(() => root!.render(
55
+ <SidebarProvider>
56
+ <PrivateLayoutProvider sidebar={sidebar} header={undefined} pathname={pathname} isMobile={false}>
57
+ {groups.map((group, i) => (
58
+ <SidebarNavGroup key={i} group={group} />
59
+ ))}
60
+ </PrivateLayoutProvider>
61
+ </SidebarProvider>,
62
+ ));
63
+ }
64
+
65
+ const main: SidebarGroupConfig = { label: 'Main', items: [{ label: 'Home', href: '/' }, { label: 'Reports', href: '/reports' }] };
66
+ const connect = mcpSidebarGroup({ label: 'Connect', itemLabel: 'MCP' });
67
+
68
+ beforeEach(() => {
69
+ useSettingsDialogStore.setState({ sections: [], isOpen: false, activeSection: null, intent: null });
70
+ });
71
+ afterEach(() => {
72
+ act(() => root?.unmount());
73
+ host?.remove();
74
+ host = root = null;
75
+ });
76
+
77
+ describe('href rows (unchanged)', () => {
78
+ it('render as links and mark the current route', () => {
79
+ renderGroups([main], '/reports');
80
+ const reports = screen.link('Reports')!;
81
+ expect(reports.getAttribute('href')).toBe('/reports');
82
+ expect(reports.getAttribute('aria-current')).toBe('page');
83
+ expect(screen.link('Home')!.getAttribute('aria-current')).toBeNull();
84
+ expect(screen.separators()).toHaveLength(0);
85
+ });
86
+ });
87
+
88
+ describe('settingsSection rows', () => {
89
+ it('render nothing, and no group, while the section is not registered', () => {
90
+ renderGroups([main, connect]);
91
+ expect(screen.button('MCP')).toBeNull();
92
+ expect(screen.text('Connect')).toBeNull();
93
+ expect(screen.separators()).toHaveLength(0);
94
+ });
95
+
96
+ it('appear once the section is registered, and open the dialog on it', () => {
97
+ registerSections('account', 'mcp');
98
+ renderGroups([main, connect]);
99
+ act(() => screen.button('MCP')!.click());
100
+ const state = useSettingsDialogStore.getState();
101
+ expect(state.isOpen).toBe(true);
102
+ expect(state.activeSection).toBe('mcp');
103
+ });
104
+
105
+ it('are active only while the dialog shows that section', () => {
106
+ registerSections('account', 'mcp');
107
+ renderGroups([main, connect]);
108
+ const row = () => screen.button('MCP')!;
109
+ expect(row().getAttribute('aria-current')).toBeNull();
110
+
111
+ act(() => useSettingsDialogStore.getState().open('account'));
112
+ expect(row().getAttribute('aria-current')).toBeNull();
113
+
114
+ act(() => useSettingsDialogStore.getState().open('mcp'));
115
+ expect(row().getAttribute('aria-current')).toBe('true');
116
+ expect(row().getAttribute('data-active')).toBe('true');
117
+
118
+ act(() => useSettingsDialogStore.getState().close());
119
+ expect(row().getAttribute('aria-current')).toBeNull();
120
+ });
121
+
122
+ it('do not take over the route highlight of href rows', () => {
123
+ registerSections('mcp');
124
+ renderGroups([main, connect], '/');
125
+ expect(screen.link('Home')!.getAttribute('aria-current')).toBe('page');
126
+ });
127
+ });
128
+
129
+ describe('separated groups', () => {
130
+ it('draw a divider above the label', () => {
131
+ registerSections('mcp');
132
+ renderGroups([main, connect]);
133
+ const separators = screen.separators();
134
+ expect(separators).toHaveLength(1);
135
+ const divider = separators[0]!;
136
+ const label = screen.text('Connect')!;
137
+ expect(divider.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
138
+ expect(divider.getAttribute('data-sidebar')).toBe('group-divider');
139
+ });
140
+
141
+ it('draw a bare divider when the label is empty', () => {
142
+ registerSections('mcp');
143
+ renderGroups([main, mcpSidebarGroup({ label: '' })]);
144
+ expect(screen.separators()).toHaveLength(1);
145
+ expect(screen.button('MCP')).not.toBeNull();
146
+ });
147
+
148
+ it('are not drawn for ordinary groups', () => {
149
+ renderGroups([main, { label: 'More', items: [{ label: 'Docs', href: '/docs' }] }]);
150
+ expect(screen.separators()).toHaveLength(0);
151
+ });
152
+ });
@@ -20,8 +20,10 @@ import {
20
20
  import { cn } from '@djangocfg/ui-core/lib';
21
21
 
22
22
  import { LucideIcon } from '../../../components';
23
+ import { useSettingsDialogStore } from '../../SettingsLayout/store';
23
24
  import { shallowEqualBy } from '../../../utils/shallowEqualBy';
24
25
  import { usePrivateLayoutContext } from '../context';
26
+ import { isSettingsItem, isSidebarGroupVisible, sidebarItemKey, visibleSidebarItems } from '../sidebarItems';
25
27
  import type { SidebarGroupConfig } from '../types';
26
28
  import { SidebarNavItem } from './SidebarNavItem';
27
29
 
@@ -30,6 +32,21 @@ const GROUP_CLASS = cn(
30
32
  '[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
31
33
  );
32
34
 
35
+ /** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
36
+ const SEPARATED_GROUP_CLASS = 'gap-0 px-2 pb-1 pt-0';
37
+
38
+ /** Full-width rule with room above and below; decorative, so the rail and the drawer read the same. */
39
+ function GroupDivider() {
40
+ return (
41
+ <div
42
+ role="separator"
43
+ aria-orientation="horizontal"
44
+ data-sidebar="group-divider"
45
+ className="mx-2 mb-2 mt-3 h-px shrink-0 bg-sidebar-border"
46
+ />
47
+ );
48
+ }
49
+
33
50
  function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
34
51
  const { isActive, geometry } = usePrivateLayoutContext();
35
52
 
@@ -37,9 +54,20 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
37
54
  const isCollapsible = Boolean(group.collapsible) && hasLabel;
38
55
  const hideItemIcons = group.hideItemIcons ?? isCollapsible;
39
56
 
57
+ const menuSections = useSettingsDialogStore((s) => s.sections);
58
+ const dialogSection = useSettingsDialogStore((s) =>
59
+ s.isOpen ? (s.activeSection ?? s.sections[0]?.id ?? null) : null,
60
+ );
61
+ const registered = useMemo(() => new Set(menuSections.map((s) => s.id)), [menuSections]);
62
+ const items = useMemo(() => visibleSidebarItems(group.items, registered), [group.items, registered]);
63
+ const visible = isSidebarGroupVisible(group, registered);
64
+
40
65
  const hasActiveChild = useMemo(
41
- () => group.items.some((item) => isActive(item.href)),
42
- [group.items, isActive],
66
+ () =>
67
+ items.some((item) =>
68
+ isSettingsItem(item) ? item.settingsSection === dialogSection : isActive(item.href),
69
+ ),
70
+ [items, isActive, dialogSection],
43
71
  );
44
72
 
45
73
  const [open, setOpen] = useState(isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true);
@@ -50,59 +78,69 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
50
78
 
51
79
  const handleOpenChange = useCallback((value: boolean) => setOpen(value), []);
52
80
 
53
- const items = group.items.map((item) => (
54
- <SidebarNavItem key={item.href} item={item} hideItemIcons={hideItemIcons} />
81
+ const rows = items.map((item) => (
82
+ <SidebarNavItem key={sidebarItemKey(item)} item={item} hideItemIcons={hideItemIcons} />
55
83
  ));
84
+ if (!visible) return null;
85
+
86
+ const groupClass = group.separated ? SEPARATED_GROUP_CLASS : GROUP_CLASS;
87
+ const divider = group.separated ? <GroupDivider /> : null;
56
88
 
57
89
  if (isCollapsible) {
58
90
  return (
59
- <SidebarGroup className={GROUP_CLASS}>
60
- <Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
61
- <CollapsibleTrigger asChild>
62
- <button
63
- type="button"
64
- aria-expanded={open}
65
- className={cn(
66
- 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
67
- 'transition-colors duration-150 hover:bg-sidebar-accent/60',
68
- 'outline-none ring-sidebar-ring focus-visible:ring-2',
69
- geometry.item,
70
- )}
71
- >
72
- {group.icon ? <LucideIcon icon={group.icon} className="shrink-0 text-sidebar-foreground/60" /> : null}
73
- <span className="flex-1 truncate text-left">{group.label}</span>
74
- <ChevronDown
75
- aria-hidden
91
+ <>
92
+ {divider}
93
+ <SidebarGroup className={groupClass}>
94
+ <Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
95
+ <CollapsibleTrigger asChild>
96
+ <button
97
+ type="button"
98
+ aria-expanded={open}
76
99
  className={cn(
77
- 'size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
78
- open && 'rotate-180',
100
+ 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
101
+ 'transition-colors duration-150 hover:bg-sidebar-accent/60',
102
+ 'outline-none ring-sidebar-ring focus-visible:ring-2',
103
+ geometry.item,
79
104
  )}
80
- />
81
- </button>
82
- </CollapsibleTrigger>
83
- <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
84
- <SidebarGroupContent>
85
- <SidebarMenu className="mt-0.5 gap-0.5">{items}</SidebarMenu>
86
- </SidebarGroupContent>
87
- </CollapsibleContent>
88
- </Collapsible>
89
- </SidebarGroup>
105
+ >
106
+ {group.icon ? <LucideIcon icon={group.icon} className="shrink-0 text-sidebar-foreground/60" /> : null}
107
+ <span className="flex-1 truncate text-left">{group.label}</span>
108
+ <ChevronDown
109
+ aria-hidden
110
+ className={cn(
111
+ 'size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
112
+ open && 'rotate-180',
113
+ )}
114
+ />
115
+ </button>
116
+ </CollapsibleTrigger>
117
+ <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
118
+ <SidebarGroupContent>
119
+ <SidebarMenu className="mt-0.5 gap-0.5">{rows}</SidebarMenu>
120
+ </SidebarGroupContent>
121
+ </CollapsibleContent>
122
+ </Collapsible>
123
+ </SidebarGroup>
124
+ </>
90
125
  );
91
126
  }
92
127
 
93
128
  return (
94
- <SidebarGroup className={GROUP_CLASS}>
95
- {hasLabel ? (
96
- <SidebarGroupLabel
97
- className={cn('font-medium normal-case tracking-normal text-sidebar-foreground/50', geometry.label)}
98
- >
99
- {group.label}
100
- </SidebarGroupLabel>
101
- ) : null}
102
- <SidebarGroupContent>
103
- <SidebarMenu className="gap-0.5">{items}</SidebarMenu>
104
- </SidebarGroupContent>
105
- </SidebarGroup>
129
+ <>
130
+ {divider}
131
+ <SidebarGroup className={groupClass}>
132
+ {hasLabel ? (
133
+ <SidebarGroupLabel
134
+ className={cn('font-medium normal-case tracking-normal text-sidebar-foreground/50', geometry.label)}
135
+ >
136
+ {group.label}
137
+ </SidebarGroupLabel>
138
+ ) : null}
139
+ <SidebarGroupContent>
140
+ <SidebarMenu className="gap-0.5">{rows}</SidebarMenu>
141
+ </SidebarGroupContent>
142
+ </SidebarGroup>
143
+ </>
106
144
  );
107
145
  }
108
146
 
@@ -112,12 +150,14 @@ const groupEqual = (a: SidebarGroupConfig, b: SidebarGroupConfig) =>
112
150
  a.defaultOpen === b.defaultOpen &&
113
151
  a.icon === b.icon &&
114
152
  a.hideItemIcons === b.hideItemIcons &&
153
+ a.separated === b.separated &&
115
154
  a.dynamic === b.dynamic &&
116
155
  shallowEqualBy(
117
156
  a.items,
118
157
  b.items,
119
158
  (x, y) =>
120
159
  x.href === y.href &&
160
+ x.settingsSection === y.settingsSection &&
121
161
  x.label === y.label &&
122
162
  x.icon === y.icon &&
123
163
  x.badge === y.badge &&
@@ -17,7 +17,9 @@ import { cn } from '@djangocfg/ui-core/lib';
17
17
 
18
18
  import { LucideIcon } from '../../../components';
19
19
  import { usePrivateLayoutContext } from '../context';
20
- import type { SidebarItem } from '../types';
20
+ import { useSettingsSectionState } from '../../SettingsLayout/hooks/useSettingsSectionState';
21
+ import { isSettingsItem } from '../sidebarItems';
22
+ import type { SidebarItem, SidebarLinkItem, SidebarSettingsItem } from '../types';
21
23
 
22
24
  interface SidebarNavItemProps {
23
25
  item: SidebarItem;
@@ -32,7 +34,47 @@ const ITEM_CLASS = cn(
32
34
  'data-[active=true]:[&>svg]:text-sidebar-foreground',
33
35
  );
34
36
 
35
- function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
37
+ function ItemContent({ item, hideItemIcons }: SidebarNavItemProps) {
38
+ return (
39
+ <>
40
+ {!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
41
+ <span>{item.label}</span>
42
+ {item.badge ? (
43
+ <SidebarMenuBadge
44
+ className={cn(item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary')}
45
+ >
46
+ {item.badge}
47
+ </SidebarMenuBadge>
48
+ ) : null}
49
+ </>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * A row that opens Settings on a section. Not rendered while the dialog does not list the section (no dead door),
55
+ * active while the dialog shows it. The dialog owns the URL, so a row never writes the address itself.
56
+ */
57
+ function SidebarSettingsNavItem({ item, hideItemIcons }: { item: SidebarSettingsItem; hideItemIcons?: boolean }) {
58
+ const { geometry } = usePrivateLayoutContext();
59
+ const { registered, active, open } = useSettingsSectionState(item.settingsSection);
60
+ if (!registered) return null;
61
+
62
+ return (
63
+ <SidebarMenuItem>
64
+ <SidebarMenuButton
65
+ isActive={active}
66
+ onClick={open}
67
+ aria-haspopup="dialog"
68
+ aria-current={active ? 'true' : undefined}
69
+ className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
70
+ >
71
+ <ItemContent item={item} hideItemIcons={hideItemIcons} />
72
+ </SidebarMenuButton>
73
+ </SidebarMenuItem>
74
+ );
75
+ }
76
+
77
+ function SidebarLinkNavItem({ item, hideItemIcons }: { item: SidebarLinkItem; hideItemIcons?: boolean }) {
36
78
  const { isActive, geometry } = usePrivateLayoutContext();
37
79
  const active = isActive(item.href);
38
80
 
@@ -44,25 +86,24 @@ function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
44
86
  className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
45
87
  >
46
88
  <Link href={item.href} aria-current={active ? 'page' : undefined}>
47
- {!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
48
- <span>{item.label}</span>
49
- {item.badge ? (
50
- <SidebarMenuBadge
51
- className={cn(
52
- item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary',
53
- )}
54
- >
55
- {item.badge}
56
- </SidebarMenuBadge>
57
- ) : null}
89
+ <ItemContent item={item} hideItemIcons={hideItemIcons} />
58
90
  </Link>
59
91
  </SidebarMenuButton>
60
92
  </SidebarMenuItem>
61
93
  );
62
94
  }
63
95
 
96
+ function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
97
+ return isSettingsItem(item) ? (
98
+ <SidebarSettingsNavItem item={item} hideItemIcons={hideItemIcons} />
99
+ ) : (
100
+ <SidebarLinkNavItem item={item} hideItemIcons={hideItemIcons} />
101
+ );
102
+ }
103
+
64
104
  const itemEqual = (a: SidebarItem, b: SidebarItem) =>
65
105
  a.href === b.href &&
106
+ a.settingsSection === b.settingsSection &&
66
107
  a.label === b.label &&
67
108
  a.icon === b.icon &&
68
109
  a.badge === b.badge &&
@@ -11,7 +11,8 @@ import React, { createContext, useContext, useMemo } from 'react';
11
11
 
12
12
  import { NAV_GEOMETRY } from './geometry';
13
13
  import type { NavGeometry } from './geometry';
14
- import type { HeaderConfig, SidebarConfig, SidebarItem } from './types';
14
+ import { isLinkItem } from './sidebarItems';
15
+ import type { HeaderConfig, SidebarConfig, SidebarLinkItem } from './types';
15
16
 
16
17
  export interface PrivateLayoutContextValue {
17
18
  sidebar: SidebarConfig | undefined;
@@ -19,7 +20,8 @@ export interface PrivateLayoutContextValue {
19
20
  pathname: string;
20
21
  isMobile: boolean;
21
22
  geometry: NavGeometry;
22
- allItems: SidebarItem[];
23
+ /** The route rows; Settings launchers have no route and take no part in the active-route check. */
24
+ allItems: SidebarLinkItem[];
23
25
  isActive: (href: string) => boolean;
24
26
  homeHref: string;
25
27
  brandTitle: string;
@@ -60,7 +62,7 @@ export function PrivateLayoutProvider({
60
62
  ).toUpperCase();
61
63
 
62
64
  const allItems = useMemo(
63
- () => sidebar?.groups.flatMap((g) => g.items) ?? [],
65
+ () => sidebar?.groups.flatMap((g) => g.items).filter(isLinkItem) ?? [],
64
66
  [sidebar?.groups],
65
67
  );
66
68
 
@@ -4,11 +4,15 @@
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
6
  export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
7
+ export { mcpSidebarItem, mcpSidebarGroup } from './mcpSidebar';
8
+ export type { McpSidebarItemOptions, McpSidebarGroupOptions } from './mcpSidebar';
7
9
  export { SidebarBrandSwitcher, ForbiddenState } from './components';
8
10
  export type { ForbiddenStateProps } from './components';
9
11
  export type {
10
12
  PrivateLayoutProps,
11
13
  SidebarItem,
14
+ SidebarLinkItem,
15
+ SidebarSettingsItem,
12
16
  SidebarGroupConfig,
13
17
  SidebarConfig,
14
18
  HeaderConfig,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Ready sidebar entries for the MCP section (`mcpSettingsSection()` from `@djangocfg/widget-mcp`).
3
+ *
4
+ * They name the section by id only (`MCP_SECTION_ID`), so layouts holds no import of the widget. They render only
5
+ * while the host registered that section: add them to the config unconditionally.
6
+ */
7
+
8
+ import type { LucideIcon } from 'lucide-react';
9
+
10
+ import { MCP_SECTION_ID } from '../SettingsLayout/sectionIds';
11
+ import type { SidebarGroupConfig, SidebarSettingsItem } from './types';
12
+
13
+ export interface McpSidebarItemOptions {
14
+ /** The row's text. The host's to translate. */
15
+ label: string;
16
+ /** Lucide icon name or component. Default `"Cable"`. */
17
+ icon?: string | LucideIcon;
18
+ }
19
+
20
+ export interface McpSidebarGroupOptions extends Partial<McpSidebarItemOptions> {
21
+ /** The group's label, under the divider. Use `''` for a bare divider. */
22
+ label: string;
23
+ /** The row's text. Default `"MCP"` (a product name, not translated). */
24
+ itemLabel?: string;
25
+ }
26
+
27
+ /** A row that opens Settings on the MCP section; hidden while the section is not registered. */
28
+ export function mcpSidebarItem({ label, icon = 'Cable' }: McpSidebarItemOptions): SidebarSettingsItem<typeof MCP_SECTION_ID> {
29
+ return { label, icon, settingsSection: MCP_SECTION_ID };
30
+ }
31
+
32
+ /** A separated group holding the MCP row; the whole group is hidden while the section is not registered. */
33
+ export function mcpSidebarGroup({ label, itemLabel = 'MCP', icon }: McpSidebarGroupOptions): SidebarGroupConfig<typeof MCP_SECTION_ID> {
34
+ return { label, separated: true, items: [mcpSidebarItem({ label: itemLabel, icon })] };
35
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { mcpSidebarGroup, mcpSidebarItem } from './mcpSidebar';
4
+ import { isSidebarGroupVisible, sidebarItemKey, visibleSidebarItems } from './sidebarItems';
5
+ import type { SidebarGroupConfig, SidebarItem } from './types';
6
+
7
+ const link: SidebarItem = { label: 'Home', href: '/' };
8
+ const mcp = mcpSidebarItem({ label: 'MCP' });
9
+ const none: ReadonlySet<string> = new Set();
10
+ const withMcp: ReadonlySet<string> = new Set(['account', 'mcp']);
11
+
12
+ describe('visibleSidebarItems', () => {
13
+ it('keeps links whatever is registered', () => {
14
+ expect(visibleSidebarItems([link], none)).toEqual([link]);
15
+ });
16
+
17
+ it('drops a launcher for a section that is not registered (no dead door)', () => {
18
+ expect(visibleSidebarItems([link, mcp], none)).toEqual([link]);
19
+ expect(visibleSidebarItems([link, mcp], withMcp)).toEqual([link, mcp]);
20
+ });
21
+ });
22
+
23
+ describe('isSidebarGroupVisible', () => {
24
+ it('hides a group whose launchers are all unregistered, label and divider with it', () => {
25
+ const group = mcpSidebarGroup({ label: 'Connect' });
26
+ expect(isSidebarGroupVisible(group, none)).toBe(false);
27
+ expect(isSidebarGroupVisible(group, withMcp)).toBe(true);
28
+ });
29
+
30
+ it('keeps a mixed group while one row survives', () => {
31
+ const group: SidebarGroupConfig = { label: 'Tools', items: [link, mcp] };
32
+ expect(isSidebarGroupVisible(group, none)).toBe(true);
33
+ });
34
+
35
+ it('keeps the old rules for href-only groups', () => {
36
+ expect(isSidebarGroupVisible({ label: 'A', items: [link] }, none)).toBe(true);
37
+ expect(isSidebarGroupVisible({ label: 'A', items: [], dynamic: true }, none)).toBe(false);
38
+ expect(isSidebarGroupVisible({ label: 'A', items: [] }, none)).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('keys and the MCP defaults', () => {
43
+ it('keys a link by href and a launcher by section', () => {
44
+ expect(sidebarItemKey(link)).toBe('/');
45
+ expect(sidebarItemKey(mcp)).toBe('settings:mcp');
46
+ });
47
+
48
+ it('mcpSidebarItem defaults the icon to Cable and names the section by id only', () => {
49
+ expect(mcp).toEqual({ label: 'MCP', icon: 'Cable', settingsSection: 'mcp' });
50
+ expect(mcpSidebarItem({ label: 'X', icon: 'Plug' }).icon).toBe('Plug');
51
+ });
52
+
53
+ it('mcpSidebarGroup is a separated group with one MCP row', () => {
54
+ const group = mcpSidebarGroup({ label: 'Connect', itemLabel: 'Assistants' });
55
+ expect(group).toMatchObject({ label: 'Connect', separated: true });
56
+ expect(group.items).toEqual([{ label: 'Assistants', icon: 'Cable', settingsSection: 'mcp' }]);
57
+ });
58
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure helpers over sidebar config: which rows are launchers, what is visible, a stable key.
3
+ * No React and no store, so the rules are tested without rendering.
4
+ */
5
+
6
+ import type { SidebarGroupConfig, SidebarItem, SidebarLinkItem, SidebarSettingsItem } from './types';
7
+
8
+ export function isSettingsItem<Id extends string>(item: SidebarItem<Id>): item is SidebarSettingsItem<Id> {
9
+ return typeof item.settingsSection === 'string';
10
+ }
11
+
12
+ export function isLinkItem(item: SidebarItem): item is SidebarLinkItem {
13
+ return typeof item.href === 'string';
14
+ }
15
+
16
+ /** Identity of a row for React keys and memo checks. */
17
+ export function sidebarItemKey(item: SidebarItem): string {
18
+ return isSettingsItem(item) ? `settings:${item.settingsSection}` : (item as SidebarLinkItem).href;
19
+ }
20
+
21
+ /**
22
+ * The rows of a group that may render: links always, launchers only for a registered section (no dead door).
23
+ * `registered` is the id set the mounted SettingsDialog lists.
24
+ */
25
+ export function visibleSidebarItems(items: SidebarItem[], registered: ReadonlySet<string>): SidebarItem[] {
26
+ return items.filter((item) => !isSettingsItem(item) || registered.has(item.settingsSection));
27
+ }
28
+
29
+ /**
30
+ * Whether a group renders at all. A `dynamic` group needs a row; any group that was given rows needs one of them to
31
+ * survive, so a "Connect" group whose only row is an unregistered section leaves no label and no divider behind.
32
+ * A group declared with no rows keeps its old behaviour (it renders, as a label).
33
+ */
34
+ export function isSidebarGroupVisible(group: SidebarGroupConfig, registered: ReadonlySet<string>): boolean {
35
+ if (group.dynamic && group.items.length === 0) return false;
36
+ if (group.items.length === 0) return true;
37
+ return visibleSidebarItems(group.items, registered).length > 0;
38
+ }
@@ -15,9 +15,8 @@ import type { UserMenuConfig } from '../types';
15
15
  // Sidebar Item Types
16
16
  // ============================================================================
17
17
 
18
- export interface SidebarItem {
18
+ interface SidebarItemBase {
19
19
  label: string;
20
- href: string;
21
20
  icon?: string | LucideIcon;
22
21
  badge?: string | number;
23
22
  /** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
@@ -26,15 +25,39 @@ export interface SidebarItem {
26
25
  tooltip?: string;
27
26
  }
28
27
 
28
+ /** A row that navigates to a route. */
29
+ export interface SidebarLinkItem extends SidebarItemBase {
30
+ href: string;
31
+ settingsSection?: never;
32
+ }
33
+
34
+ /**
35
+ * A row that opens the Settings dialog on a section. It renders only while the mounted `SettingsDialog` lists that
36
+ * section, so there is no dead door when a section is switched off or not registered yet; it is active while the
37
+ * dialog shows the section. `Id` is the section-id union (see `defineSettingsSections`); a bare `SidebarItem` takes any string.
38
+ */
39
+ export interface SidebarSettingsItem<Id extends string = string> extends SidebarItemBase {
40
+ settingsSection: Id;
41
+ href?: never;
42
+ }
43
+
44
+ /** A sidebar row: a link (`href`) or a Settings launcher (`settingsSection`). */
45
+ export type SidebarItem<Id extends string = string> = SidebarLinkItem | SidebarSettingsItem<Id>;
46
+
29
47
  // ============================================================================
30
48
  // Sidebar Group Types
31
49
  // ============================================================================
32
50
 
33
- export interface SidebarGroupConfig {
51
+ export interface SidebarGroupConfig<Id extends string = string> {
34
52
  /** Group label displayed above items */
35
53
  label: string;
36
- /** Items in this group */
37
- items: SidebarItem[];
54
+ /** Items in this group. A group whose items are all `settingsSection` rows for unregistered sections is not rendered. */
55
+ items: SidebarItem<Id>[];
56
+ /**
57
+ * A secondary group (setup, connect): a full divider with extra space above it, and the label below the divider if
58
+ * there is one. The default groups are only a hairline apart. Works in the docked sidebar, the peek panel and the drawer.
59
+ */
60
+ separated?: boolean;
38
61
  /** If true, group is only shown when it has items (for dynamic groups like extensions) */
39
62
  dynamic?: boolean;
40
63
  /** Render group as an accordion (Mailersend/Vercel-style). Label becomes a clickable trigger. */
@@ -73,9 +96,9 @@ export interface SidebarFeaturedConfig {
73
96
  // Sidebar Config
74
97
  // ============================================================================
75
98
 
76
- export interface SidebarConfig {
99
+ export interface SidebarConfig<Id extends string = string> {
77
100
  /** Grouped items with labels */
78
- groups: SidebarGroupConfig[];
101
+ groups: SidebarGroupConfig<Id>[];
79
102
  /** Home link href */
80
103
  homeHref?: string;
81
104
  /**
@@ -64,6 +64,27 @@ const settings = useSettingsDialog();
64
64
  // #settings/api-keys → opens on the API-keys section
65
65
  ```
66
66
 
67
+ ### Typed section ids
68
+
69
+ `useSettingsDialog().open(id)` takes any string by default, so a typo opens the first section silently. Declare the
70
+ sections with `defineSettingsSections` and give the hook the id union; an unknown id is then a compile error.
71
+
72
+ ```tsx
73
+ const sections = defineSettingsSections([mcpSettingsSection(), { id: 'billing', label: 'Billing', content: <Billing /> }]);
74
+ type AppSection = SettingsSectionId<typeof sections>; // 'mcp' | 'billing' | 'account' | 'general' | 'api-keys'
75
+
76
+ <PrivateLayout settings={{ sections }} sidebar={{ groups: [
77
+ { label: 'Workspace', items: [{ label: 'Billing', settingsSection: 'billing' } satisfies SidebarItem<AppSection>] },
78
+ mcpSidebarGroup({ label: 'Connect' }), // separated group, appears only if 'mcp' is registered
79
+ ] }}>
80
+ const { open } = useSettingsDialog<AppSection>(); // open('biling') does not compile
81
+ <a href={settingsHref<AppSection>('billing')}>Billing</a> // "#settings/billing"
82
+ ```
83
+
84
+ `mcpSettingsSection()` returns `id: 'mcp'` as a literal, so it keeps its place in the union; a section whose `id` is a
85
+ plain `string` widens the union to `string` (the old behaviour). Existing `useSettingsDialog()` callers are unchanged.
86
+ `settingsHref(id, urlKey = 'settings')` is the one place that spells the `#settings/<id>` fragment.
87
+
67
88
  ### Inline page
68
89
 
69
90
  ```tsx
@@ -13,3 +13,6 @@ export type {
13
13
 
14
14
  export { useAccountMenuItems } from './useAccountMenuItems';
15
15
  export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';
16
+
17
+ export { useSettingsSectionState } from './useSettingsSectionState';
18
+ export type { SettingsSectionState } from './useSettingsSectionState';
@@ -9,6 +9,9 @@
9
9
  * <Button onClick={() => settings.open()}>Settings</Button>
10
10
  * <MenuItem onClick={() => settings.open('billing')}>Billing</MenuItem>
11
11
  *
12
+ * The id is `string` by default. Pass the union of your sections to make a typo a compile error:
13
+ * const settings = useSettingsDialog<SettingsSectionId<typeof sections>>(); // see `defineSettingsSections`
14
+ *
12
15
  * Returns stable callbacks, safe in deps arrays. The dialog itself must be
13
16
  * mounted once in the tree (PrivateLayout does this).
14
17
  */
@@ -17,24 +20,24 @@ import { useCallback, useMemo } from 'react';
17
20
 
18
21
  import { useSettingsDialogStore, type SettingsMenuSection } from '../store';
19
22
 
20
- export interface UseSettingsDialogReturn {
23
+ export interface UseSettingsDialogReturn<Id extends string = string> {
21
24
  /** Whether the dialog is currently open. */
22
25
  isOpen: boolean;
23
26
  /** Currently active section id (null = fall back to first). */
24
- activeSection: string | null;
27
+ activeSection: Id | null;
25
28
  /** Open the dialog, optionally on a specific section. */
26
- open: (sectionId?: string) => void;
29
+ open: (sectionId?: Id) => void;
27
30
  /** Close the dialog. */
28
31
  close: () => void;
29
32
  /** Toggle open/closed. */
30
33
  toggle: () => void;
31
34
  /** Switch the active section. */
32
- setSection: (sectionId: string) => void;
35
+ setSection: (sectionId: Id) => void;
33
36
  /** The visible sections of the mounted dialog (built-in and the host's), in rail order: for menu shortcuts, `open(id)`. */
34
37
  sections: SettingsMenuSection[];
35
38
  }
36
39
 
37
- export function useSettingsDialog(): UseSettingsDialogReturn {
40
+ export function useSettingsDialog<Id extends string = string>(): UseSettingsDialogReturn<Id> {
38
41
  const isOpen = useSettingsDialogStore((s) => s.isOpen);
39
42
  const activeSection = useSettingsDialogStore((s) => s.activeSection);
40
43
  const storeOpen = useSettingsDialogStore((s) => s.open);
@@ -43,13 +46,13 @@ export function useSettingsDialog(): UseSettingsDialogReturn {
43
46
  const storeSetSection = useSettingsDialogStore((s) => s.setSection);
44
47
  const sections = useSettingsDialogStore((s) => s.sections);
45
48
 
46
- const open = useCallback((sectionId?: string) => storeOpen(sectionId), [storeOpen]);
49
+ const open = useCallback((sectionId?: Id) => storeOpen(sectionId), [storeOpen]);
47
50
  const close = useCallback(() => storeClose(), [storeClose]);
48
51
  const toggle = useCallback(() => storeToggle(), [storeToggle]);
49
- const setSection = useCallback((id: string) => storeSetSection(id), [storeSetSection]);
52
+ const setSection = useCallback((id: Id) => storeSetSection(id), [storeSetSection]);
50
53
 
51
54
  return useMemo(
52
- () => ({ isOpen, activeSection, open, close, toggle, setSection, sections }),
55
+ () => ({ isOpen, activeSection: activeSection as Id | null, open, close, toggle, setSection, sections }),
53
56
  [isOpen, activeSection, open, close, toggle, setSection, sections],
54
57
  );
55
58
  }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useSettingsSectionState — what a launcher of one Settings section needs to know: is the section registered with the
5
+ * mounted dialog (a launcher for a missing section is a dead door), is the dialog showing it now, and how to open it.
6
+ *
7
+ * Reads the same store `useSettingsDialog` does. Registration comes from `<SettingsDialog />`, which lists its visible
8
+ * sections (built-in and the host's) after it mounts, so before that, or with no dialog, nothing is registered.
9
+ */
10
+
11
+ import { useCallback } from 'react';
12
+
13
+ import { useSettingsDialogStore } from '../store';
14
+
15
+ export interface SettingsSectionState {
16
+ /** The dialog lists this section in its rail. */
17
+ registered: boolean;
18
+ /** The dialog is open and showing this section (an open dialog with no pick shows the first one). */
19
+ active: boolean;
20
+ /** Open the dialog on this section. */
21
+ open: () => void;
22
+ }
23
+
24
+ export function useSettingsSectionState(id: string): SettingsSectionState {
25
+ const registered = useSettingsDialogStore((s) => s.sections.some((section) => section.id === id));
26
+ const active = useSettingsDialogStore(
27
+ (s) => s.isOpen && (s.activeSection ?? s.sections[0]?.id ?? null) === id,
28
+ );
29
+ const storeOpen = useSettingsDialogStore((s) => s.open);
30
+ const open = useCallback(() => storeOpen(id), [storeOpen, id]);
31
+ return { registered, active, open };
32
+ }
@@ -43,6 +43,10 @@ export type {
43
43
  ResolvedGroup,
44
44
  } from './hooks';
45
45
 
46
+ // Typed section ids, the `#settings/<id>` address
47
+ export { defineSettingsSections, settingsHref, MCP_SECTION_ID } from './sectionIds';
48
+ export type { SettingsSectionId, BuiltinSettingsSectionId } from './sectionIds';
49
+
46
50
  // Context (for advanced custom shells)
47
51
  export { SettingsProvider, useSettingsContext } from './context';
48
52
  export type { SettingsProviderProps } from './context';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Type tests: tsc (vitest typecheck, and `pnpm check`) must accept the first block and reject every
3
+ * `@ts-expect-error` line. A line that stops being an error fails the build.
4
+ */
5
+
6
+ import type { ComponentType } from 'react';
7
+ import { describe, expectTypeOf, it } from 'vitest';
8
+
9
+ import { MCP_SECTION_ID, defineSettingsSections, settingsHref, type SettingsSectionId } from './sectionIds';
10
+ import { useSettingsDialog } from './hooks/useSettingsDialog';
11
+ import type { SettingsSection } from './types';
12
+
13
+ declare const mcpLike: { id: 'mcp'; label: string; content: null; icon: ComponentType<{ className?: string }> };
14
+
15
+ const sections = defineSettingsSections([mcpLike, { id: 'billing', label: 'Billing', content: null }]);
16
+ type AppId = SettingsSectionId<typeof sections>;
17
+
18
+ describe('typed settings section ids', () => {
19
+ it('the id union is the declared ids plus the built-ins', () => {
20
+ expectTypeOf<AppId>().toEqualTypeOf<'mcp' | 'billing' | 'account' | 'general' | 'api-keys'>();
21
+ expectTypeOf(MCP_SECTION_ID).toEqualTypeOf<'mcp'>();
22
+ });
23
+
24
+ it('the result still goes where a SettingsSection[] goes', () => {
25
+ expectTypeOf(sections).toMatchTypeOf<SettingsSection[]>();
26
+ });
27
+
28
+ it('useSettingsDialog<Id> accepts known ids only', () => {
29
+ const typed = useSettingsDialog<AppId>();
30
+ typed.open('billing');
31
+ typed.setSection('mcp');
32
+ // @ts-expect-error unknown id
33
+ typed.open('biling');
34
+ // @ts-expect-error unknown id
35
+ typed.setSection('nope');
36
+ expectTypeOf(typed.activeSection).toEqualTypeOf<AppId | null>();
37
+ });
38
+
39
+ it('the untyped default still takes any string', () => {
40
+ const loose = useSettingsDialog();
41
+ loose.open('anything');
42
+ loose.setSection('else');
43
+ expectTypeOf(loose.activeSection).toEqualTypeOf<string | null>();
44
+ });
45
+
46
+ it('settingsHref keeps the id in the type', () => {
47
+ expectTypeOf(settingsHref('mcp')).toEqualTypeOf<`#${string}/mcp`>();
48
+ // @ts-expect-error the helper for a typed id rejects other strings
49
+ settingsHref<AppId>('biling');
50
+ });
51
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseSettingsHash } from './intent/parseSettingsHash';
4
+ import { MCP_SECTION_ID, defineSettingsSections, settingsHref } from './sectionIds';
5
+
6
+ describe('settingsHref', () => {
7
+ it('spells the fragment the dialog reads', () => {
8
+ expect(settingsHref('mcp')).toBe('#settings/mcp');
9
+ expect(settingsHref('api-keys')).toBe('#settings/api-keys');
10
+ });
11
+
12
+ it('follows a custom urlKey', () => {
13
+ expect(settingsHref('mcp', 'prefs')).toBe('#prefs/mcp');
14
+ });
15
+
16
+ it('round-trips through the parser', () => {
17
+ const fragment = settingsHref('billing').slice(1);
18
+ expect(parseSettingsHash(fragment, 'settings')).toMatchObject({ open: true, section: 'billing' });
19
+ });
20
+ });
21
+
22
+ describe('defineSettingsSections', () => {
23
+ it('returns the very array it was given', () => {
24
+ const list = [{ id: 'billing', label: 'Billing', content: null }];
25
+ expect(defineSettingsSections(list)).toBe(list);
26
+ });
27
+
28
+ it('MCP_SECTION_ID is the id the MCP widget uses', () => {
29
+ expect(MCP_SECTION_ID).toBe('mcp');
30
+ });
31
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed section ids.
3
+ *
4
+ * The dialog is mounted once and driven through a global store, so its `sections` prop cannot type the hooks that
5
+ * open it. Declare the sections with `defineSettingsSections` and hand the id union to the hook instead:
6
+ *
7
+ * const sections = defineSettingsSections([mcpSettingsSection(), { id: 'billing', ... }]);
8
+ * type AppSection = SettingsSectionId<typeof sections>; // 'mcp' | 'billing' | built-in ids
9
+ * useSettingsDialog<AppSection>().open('biling'); // compile error
10
+ *
11
+ * Nothing here is runtime weight beyond two one-line functions.
12
+ */
13
+
14
+ import type { SettingsSection } from './types';
15
+
16
+ /** Ids of the sections the layout ships (`builtins`). They are openable whether or not the host switched one off. */
17
+ export type BuiltinSettingsSectionId = 'account' | 'general' | 'api-keys';
18
+
19
+ /**
20
+ * The id of the MCP section. `@djangocfg/widget-mcp` (`mcpSettingsSection()`) uses the same literal; layouts cannot
21
+ * import the widget (a package never depends on a widget), so the two agree by value and each side has a test on it.
22
+ */
23
+ export const MCP_SECTION_ID = 'mcp' as const;
24
+
25
+ /** The ids of a section list: the host's own plus the built-ins. Collapses to `string` if any id is not a literal. */
26
+ export type SettingsSectionId<S extends readonly { id: string }[]> = S[number]['id'] | BuiltinSettingsSectionId;
27
+
28
+ /**
29
+ * Identity function that keeps each section's `id` as a literal type, so `SettingsSectionId<typeof sections>` is a
30
+ * closed union. Accepts the result of `mcpSettingsSection()` as is.
31
+ */
32
+ export function defineSettingsSections<const S extends SettingsSection[]>(sections: S): S {
33
+ return sections;
34
+ }
35
+
36
+ /**
37
+ * The address that opens the dialog on a section: `settingsHref('mcp')` is `"#settings/mcp"`. For a plain link
38
+ * (`href`, `<a>`), a route table or an email; `useSettingsUrl` opens the dialog when the page lands on it.
39
+ * `urlKey` follows `SettingsDialog`'s `urlKey` prop.
40
+ */
41
+ export function settingsHref<Id extends string = string>(id: Id, urlKey = 'settings'): `#${string}/${Id}` {
42
+ return `#${urlKey}/${id}`;
43
+ }