@djangocfg/layouts 2.1.593 → 2.1.595

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.593",
3
+ "version": "2.1.595",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -90,11 +90,11 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/analytics": "^2.1.593",
94
- "@djangocfg/api": "^2.1.593",
95
- "@djangocfg/devtools": "^2.1.593",
96
- "@djangocfg/i18n": "^2.1.593",
97
- "@djangocfg/ui-core": "^2.1.593",
93
+ "@djangocfg/analytics": "^2.1.595",
94
+ "@djangocfg/api": "^2.1.595",
95
+ "@djangocfg/devtools": "^2.1.595",
96
+ "@djangocfg/i18n": "^2.1.595",
97
+ "@djangocfg/ui-core": "^2.1.595",
98
98
  "@hookform/resolvers": "^5.2.2",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -124,12 +124,12 @@
124
124
  "uuid": "^11.1.1"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.593",
128
- "@djangocfg/api": "^2.1.593",
129
- "@djangocfg/devtools": "^2.1.593",
130
- "@djangocfg/i18n": "^2.1.593",
131
- "@djangocfg/typescript-config": "^2.1.593",
132
- "@djangocfg/ui-core": "^2.1.593",
127
+ "@djangocfg/analytics": "^2.1.595",
128
+ "@djangocfg/api": "^2.1.595",
129
+ "@djangocfg/devtools": "^2.1.595",
130
+ "@djangocfg/i18n": "^2.1.595",
131
+ "@djangocfg/typescript-config": "^2.1.595",
132
+ "@djangocfg/ui-core": "^2.1.595",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -19,6 +19,11 @@ import { buildBuiltinSections } from './sections';
19
19
  import { useSettingsDialogStore } from './store';
20
20
  import type { SettingsDialogProps } from './types';
21
21
 
22
+ // Defaults must be module constants: a `= []` / `= {}` default is a new object each render, which would
23
+ // re-run every effect and memo below that lists it.
24
+ const NO_SECTIONS: NonNullable<SettingsDialogProps['sections']> = [];
25
+ const NO_BUILTINS: NonNullable<SettingsDialogProps['builtins']> = {};
26
+
22
27
  /**
23
28
  * SettingsDialog — the globally-mounted settings modal.
24
29
  *
@@ -33,9 +38,9 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
33
38
  syncUrl = true,
34
39
  urlKey = 'settings',
35
40
  title,
36
- sections: appSections = [],
41
+ sections: appSections = NO_SECTIONS,
37
42
  groups,
38
- builtins = {},
43
+ builtins = NO_BUILTINS,
39
44
  generalRows,
40
45
  initialSection,
41
46
  searchable = 'auto',
@@ -26,11 +26,15 @@ import type { SettingsFormProps, SettingsSection } from './types';
26
26
  * `onSectionChange` to control it (the dialog does this, bound to its store).
27
27
  * Built-in sections are merged ahead of app `sections`; ids must be unique.
28
28
  */
29
+ // Module constants: `= []` / `= {}` defaults are new objects every render and would defeat the memos below.
30
+ const NO_SECTIONS: NonNullable<SettingsFormProps['sections']> = [];
31
+ const NO_BUILTINS: NonNullable<SettingsFormProps['builtins']> = {};
32
+
29
33
  export const SettingsForm: React.FC<SettingsFormProps> = ({
30
34
  title,
31
- sections: appSections = [],
35
+ sections: appSections = NO_SECTIONS,
32
36
  groups,
33
- builtins = {},
37
+ builtins = NO_BUILTINS,
34
38
  generalRows,
35
39
  initialSection,
36
40
  searchable = 'auto',
@@ -18,6 +18,11 @@ export interface AccountMenuButtonProps {
18
18
  /** Small muted text after the name (a plan). */
19
19
  plan?: string | null;
20
20
  className?: string;
21
+ /**
22
+ * Class hooks for the parts of the `row` trigger, so a host can restyle them without forking: a collapsed rail
23
+ * hides `name` and `chevron` from its own container query and centres the avatar.
24
+ */
25
+ classNames?: { avatar?: string; name?: string; chevron?: string };
21
26
  }
22
27
 
23
28
  /**
@@ -30,6 +35,7 @@ export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
30
35
  extra,
31
36
  plan,
32
37
  className,
38
+ classNames,
33
39
  }) => {
34
40
  const { user } = useAuth();
35
41
  const items = useAccountMenuItems({ extra });
@@ -61,18 +67,21 @@ export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
61
67
  className,
62
68
  )}
63
69
  >
64
- <Avatar className="size-6 shrink-0">
70
+ <Avatar className={cn('size-6 shrink-0', classNames?.avatar)}>
65
71
  <AvatarImage src={user.avatar ?? ''} alt={name} />
66
72
  <AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
67
73
  </Avatar>
68
74
  {isRow && (
69
75
  <>
70
- <span className="flex min-w-0 items-baseline gap-1">
76
+ <span className={cn('flex min-w-0 items-baseline gap-1', classNames?.name)}>
71
77
  <span className="truncate text-sm">{name}</span>
72
78
  {plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
73
79
  </span>
74
80
  <ChevronDown
75
- className="ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180"
81
+ className={cn(
82
+ 'ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180',
83
+ classNames?.chevron,
84
+ )}
76
85
  aria-hidden
77
86
  />
78
87
  </>
@@ -10,6 +10,8 @@ import type { MenuItem } from '@djangocfg/ui-core/components';
10
10
  import { useLogout } from '../../../hooks';
11
11
  import { useSettingsDialogStore } from '../store';
12
12
 
13
+ const SETTINGS_SHORTCUT = '⇧⌘,';
14
+
13
15
  export interface UseAccountMenuItemsOptions {
14
16
  /** Items placed between the settings shortcuts and Sign out (language, app links). */
15
17
  extra?: MenuItem[];
@@ -18,7 +20,7 @@ export interface UseAccountMenuItemsOptions {
18
20
  }
19
21
 
20
22
  /**
21
- * The user menu every host shares: email, Settings, a shortcut per Settings section (built-in and the host's),
23
+ * The user menu every host shares: email, one row per Settings section (built-in and the host's, as in the dialog's sidebar),
22
24
  * then Sign out. Feed it to `MenuBuilder` with any trigger, so a site's own chrome gets the same menu as
23
25
  * `PrivateLayout`'s sidebar footer instead of a hand-rolled copy.
24
26
  */
@@ -35,23 +37,28 @@ export function useAccountMenuItems({ extra, label }: UseAccountMenuItemsOptions
35
37
  const items: MenuItem[] = [];
36
38
  if (headerLabel) items.push({ kind: 'label', id: 'email', label: headerLabel });
37
39
 
38
- items.push({
39
- kind: 'item',
40
- id: 'settings',
41
- label: settingsLabel,
42
- icon: SettingsIcon,
43
- shortcut: '⇧⌘,',
44
- onSelect: () => useSettingsDialogStore.getState().open(),
45
- });
46
- for (const section of sections) {
40
+ // The menu mirrors the dialog's sidebar (same sections, order and icons) instead of adding a generic
41
+ // "Settings" row that opens the same first section. With no sections at all one "Settings" row stays.
42
+ if (sections.length === 0) {
43
+ items.push({
44
+ kind: 'item',
45
+ id: 'settings',
46
+ label: settingsLabel,
47
+ icon: SettingsIcon,
48
+ shortcut: SETTINGS_SHORTCUT,
49
+ onSelect: () => useSettingsDialogStore.getState().open(),
50
+ });
51
+ }
52
+ sections.forEach((section, index) => {
47
53
  items.push({
48
54
  kind: 'item',
49
55
  id: `settings-${section.id}`,
50
56
  label: section.label,
51
57
  icon: section.icon,
58
+ shortcut: index === 0 ? SETTINGS_SHORTCUT : undefined,
52
59
  onSelect: () => useSettingsDialogStore.getState().open(section.id),
53
60
  });
54
- }
61
+ });
55
62
 
56
63
  if (extra?.length) items.push(...extra);
57
64
 
@@ -43,9 +43,27 @@ interface SettingsDialogState {
43
43
  setSection: (sectionId: string) => void;
44
44
  }
45
45
 
46
- export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
46
+ /**
47
+ * Same shortcuts, whatever the identity of the array or of the host's JSX labels. The dialog re-registers on
48
+ * every render of a host that passes `sections` inline; storing a fresh array each time would re-render every
49
+ * menu that reads `sections`, and when that menu shares a parent with the dialog the two feed each other.
50
+ * String labels are compared by value; a node label is trusted to follow its id.
51
+ */
52
+ function sameMenuSections(a: SettingsMenuSection[], b: SettingsMenuSection[]): boolean {
53
+ if (a.length !== b.length) return false;
54
+ return a.every((x, i) => {
55
+ const y = b[i]!;
56
+ const sameLabel = typeof x.label === 'string' || typeof y.label === 'string' ? x.label === y.label : true;
57
+ return x.id === y.id && x.icon === y.icon && sameLabel;
58
+ });
59
+ }
60
+
61
+ export const useSettingsDialogStore = create<SettingsDialogState>((set, get) => ({
47
62
  sections: [],
48
- setSections: (sections) => set({ sections }),
63
+ setSections: (sections) => {
64
+ if (sameMenuSections(get().sections, sections)) return;
65
+ set({ sections });
66
+ },
49
67
  isOpen: false,
50
68
  activeSection: null,
51
69
  open: (sectionId) =>