@drawnagency/primitives 0.1.11 → 0.1.13

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.
Files changed (71) hide show
  1. package/dist/{chunk-6SK5BLG3.js → chunk-46QI4FDZ.js} +1 -1
  2. package/dist/{chunk-XQXZHDNR.js → chunk-EAEX6DS7.js} +4 -1
  3. package/dist/{chunk-32H6Q6CX.js → chunk-P24YUT3O.js} +1 -1
  4. package/dist/components/editor/AudienceIndicator.d.ts +9 -0
  5. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -0
  6. package/dist/components/editor/IndicatorPill.d.ts +18 -0
  7. package/dist/components/editor/IndicatorPill.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/StatusIndicator.d.ts +29 -0
  11. package/dist/components/editor/StatusIndicator.d.ts.map +1 -0
  12. package/dist/components/editor/index.d.ts +3 -1
  13. package/dist/components/editor/index.d.ts.map +1 -1
  14. package/dist/components/shared/Popover.d.ts +1 -1
  15. package/dist/components/shared/Popover.d.ts.map +1 -1
  16. package/dist/components/shared/SegmentedControl.d.ts +13 -0
  17. package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
  18. package/dist/components/shared/SplitButton.d.ts +17 -0
  19. package/dist/components/shared/SplitButton.d.ts.map +1 -0
  20. package/dist/components/shell/BuildStatusIndicator.d.ts +9 -0
  21. package/dist/components/shell/BuildStatusIndicator.d.ts.map +1 -0
  22. package/dist/components/shell/EditorContext.d.ts +2 -0
  23. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  24. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  25. package/dist/hooks/index.d.ts +2 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/useBuildStatus.d.ts +11 -0
  28. package/dist/hooks/useBuildStatus.d.ts.map +1 -0
  29. package/dist/hooks/useContentLifecycle.d.ts +13 -0
  30. package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
  31. package/dist/hooks/useEditorPublish.d.ts +6 -1
  32. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/lib/dexie.d.ts +8 -1
  35. package/dist/lib/dexie.d.ts.map +1 -1
  36. package/dist/lib/index.js +2 -2
  37. package/dist/lib/registry.d.ts +6 -1
  38. package/dist/lib/registry.d.ts.map +1 -1
  39. package/dist/schemas/index.js +2 -2
  40. package/dist/schemas/site-config.d.ts +8 -6
  41. package/dist/schemas/site-config.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/editor/{AudiencePicker.tsx → AudienceIndicator.tsx} +25 -49
  44. package/src/components/editor/IndicatorPill.tsx +119 -0
  45. package/src/components/editor/SectionWrapper.tsx +59 -13
  46. package/src/components/editor/StatusIndicator.tsx +148 -0
  47. package/src/components/editor/index.ts +3 -1
  48. package/src/components/sections/SectionLayout.tsx +1 -1
  49. package/src/components/shared/Navigation.tsx +3 -3
  50. package/src/components/shared/Popover.tsx +26 -4
  51. package/src/components/shared/PopoverItem.tsx +1 -1
  52. package/src/components/shared/SegmentedControl.tsx +43 -0
  53. package/src/components/shared/SplitButton.tsx +97 -0
  54. package/src/components/shell/BuildStatusIndicator.tsx +67 -0
  55. package/src/components/shell/EditorContext.tsx +5 -1
  56. package/src/components/shell/EditorShell.tsx +180 -52
  57. package/src/hooks/index.ts +2 -0
  58. package/src/hooks/useBuildStatus.ts +139 -0
  59. package/src/hooks/useContentLifecycle.ts +34 -0
  60. package/src/hooks/useEditorPublish.ts +234 -66
  61. package/src/lib/dexie.ts +43 -2
  62. package/src/lib/registry.ts +6 -1
  63. package/src/schemas/site-config.ts +5 -1
  64. package/dist/components/editor/AudiencePicker.d.ts +0 -9
  65. package/dist/components/editor/AudiencePicker.d.ts.map +0 -1
  66. package/dist/components/editor/StatusBadge.d.ts +0 -7
  67. package/dist/components/editor/StatusBadge.d.ts.map +0 -1
  68. package/dist/components/editor/StatusPicker.d.ts +0 -9
  69. package/dist/components/editor/StatusPicker.d.ts.map +0 -1
  70. package/src/components/editor/StatusBadge.tsx +0 -30
  71. package/src/components/editor/StatusPicker.tsx +0 -86
@@ -1,22 +1,18 @@
1
- import { useRef, useState } from "react";
2
1
  import { Check } from "lucide-react";
3
2
  import { cn } from "../../lib/cn";
4
- import { Popover } from "../shared/Popover";
3
+ import { IndicatorPill } from "./IndicatorPill";
5
4
  import { PopoverItem } from "../shared/PopoverItem";
6
5
  import type { Audience } from "../../auth/types";
7
6
 
8
- interface Props {
7
+ interface AudienceIndicatorProps {
9
8
  access: string[];
10
9
  audiences: Audience[];
11
10
  onChange: (access: string[]) => void;
12
11
  }
13
12
 
14
- export function AudiencePicker({ access, audiences, onChange }: Props) {
15
- const [open, setOpen] = useState(false);
16
- const buttonRef = useRef<HTMLButtonElement>(null);
17
-
13
+ export function AudienceIndicator({ access, audiences, onChange }: AudienceIndicatorProps) {
18
14
  const selected = audiences.filter((a) => access.includes(a.name));
19
- const visibleCircles = selected.slice(0, 3);
15
+ const visibleDots = selected.slice(0, 3);
20
16
 
21
17
  function toggle(name: string) {
22
18
  const next = access.includes(name)
@@ -25,47 +21,27 @@ export function AudiencePicker({ access, audiences, onChange }: Props) {
25
21
  onChange(next);
26
22
  }
27
23
 
28
- return (
29
- <div className="relative">
30
- <button
31
- ref={buttonRef}
32
- type="button"
33
- onClick={() => setOpen((v) => !v)}
34
- className={cn(
35
- "cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
36
- "border border-base-200 hover:bg-base-accent",
37
- selected.length === 0 && "text-base-contrast-light",
38
- )}
39
- >
40
- {selected.length === 0 && <span>No audience</span>}
41
- {selected.length >= 1 && (
42
- <span className="flex -space-x-1.5">
43
- {visibleCircles.map((a) => (
44
- <span
45
- key={a.name}
46
- className="h-3 w-3 rounded-full border border-base"
47
- style={{ backgroundColor: a.color ?? "#9ca3af" }}
48
- />
49
- ))}
50
- </span>
51
- )}
52
- {selected.length === 1 && <span>{selected[0].displayName}</span>}
53
- {selected.length >= 2 && <span>{selected.length} audiences</span>}
54
- </button>
24
+ const pillDots = visibleDots.map((a) => ({
25
+ color: a.color ?? "#9ca3af",
26
+ }));
55
27
 
56
- <Popover
57
- isOpen={open}
58
- onClose={() => setOpen(false)}
59
- anchorRef={buttonRef}
60
- align="end"
61
- className="w-56"
62
- >
63
- {audiences.length === 0 ? (
64
- <div className="px-3 py-2 text-xs text-base-contrast-light">
28
+ let labelText: string;
29
+ if (selected.length === 0) labelText = "No audience";
30
+ else if (selected.length === 1) labelText = selected[0].displayName;
31
+ else labelText = `${selected.length} audiences`;
32
+
33
+ return (
34
+ <IndicatorPill
35
+ dots={pillDots}
36
+ label={labelText}
37
+ buttonClassName={selected.length === 0 ? "text-base-contrast-light" : undefined}
38
+ clickContent={
39
+ audiences.length === 0 ? (
40
+ <div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
65
41
  No audiences configured. Add audiences in Site Settings → Viewer Access.
66
42
  </div>
67
43
  ) : (
68
- <ul role="list" className="py-1">
44
+ <ul role="list" className="w-full overflow-hidden py-1">
69
45
  {audiences.map((a) => {
70
46
  const checked = access.includes(a.name);
71
47
  return (
@@ -90,14 +66,14 @@ export function AudiencePicker({ access, audiences, onChange }: Props) {
90
66
  className="h-3 w-3 shrink-0 rounded-full border border-base-200"
91
67
  style={{ backgroundColor: a.color ?? "#9ca3af" }}
92
68
  />
93
- <span className="text-sm font-medium text-base-contrast">{a.displayName}</span>
69
+ <span className="font-medium text-base-contrast">{a.displayName}</span>
94
70
  </PopoverItem>
95
71
  </li>
96
72
  );
97
73
  })}
98
74
  </ul>
99
- )}
100
- </Popover>
101
- </div>
75
+ )
76
+ }
77
+ />
102
78
  );
103
79
  }
@@ -0,0 +1,119 @@
1
+ import { useRef, useState, useCallback, type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { Popover } from "../shared/Popover";
4
+
5
+ interface DotDef {
6
+ color?: string;
7
+ className?: string;
8
+ }
9
+
10
+ interface IndicatorPillProps {
11
+ dots: DotDef[];
12
+ label: string;
13
+ /** Shown on hover above the pill. Currently unused — kept for planned tooltip feature. */
14
+ hoverContent?: ReactNode;
15
+ clickContent?: ReactNode | ((onClose: () => void) => ReactNode);
16
+ className?: string;
17
+ buttonClassName?: string;
18
+ ariaLabel?: string;
19
+ }
20
+
21
+ export function IndicatorPill({
22
+ dots,
23
+ label,
24
+ hoverContent,
25
+ clickContent,
26
+ className,
27
+ buttonClassName,
28
+ ariaLabel,
29
+ }: IndicatorPillProps) {
30
+ const [hoverOpen, setHoverOpen] = useState(false);
31
+ const [clickOpen, setClickOpen] = useState(false);
32
+ const buttonRef = useRef<HTMLButtonElement>(null);
33
+ const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+
35
+ const clearHoverTimeout = useCallback(() => {
36
+ if (hoverTimeout.current) {
37
+ clearTimeout(hoverTimeout.current);
38
+ hoverTimeout.current = null;
39
+ }
40
+ }, []);
41
+
42
+ function handleMouseEnter() {
43
+ clearHoverTimeout();
44
+ if (!clickOpen && hoverContent) {
45
+ setHoverOpen(true);
46
+ }
47
+ }
48
+
49
+ function handleMouseLeave() {
50
+ clearHoverTimeout();
51
+ hoverTimeout.current = setTimeout(() => {
52
+ setHoverOpen(false);
53
+ }, 150);
54
+ }
55
+
56
+ function handleClick() {
57
+ if (!clickContent) return;
58
+ setHoverOpen(false);
59
+ clearHoverTimeout();
60
+ setClickOpen((v) => !v);
61
+ }
62
+
63
+ function handleClickClose() {
64
+ setClickOpen(false);
65
+ }
66
+
67
+ return (
68
+ <div className={cn("relative", className)}>
69
+ <button
70
+ ref={buttonRef}
71
+ type="button"
72
+ onClick={handleClick}
73
+ onMouseEnter={handleMouseEnter}
74
+ onMouseLeave={handleMouseLeave}
75
+ aria-haspopup={clickContent ? "true" : undefined}
76
+ aria-expanded={clickContent ? clickOpen : undefined}
77
+ aria-label={ariaLabel}
78
+ className={cn(
79
+ "cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
80
+ "border border-base-200 hover:bg-base-accent",
81
+ buttonClassName,
82
+ )}
83
+ >
84
+ {dots.length > 0 && (
85
+ <span className="flex -space-x-1">
86
+ {dots.map((dot, i) => (
87
+ <span
88
+ key={i}
89
+ aria-hidden="true"
90
+ className={cn("h-3 w-3 rounded-full border border-base", dot.className)}
91
+ style={dot.color ? { backgroundColor: dot.color } : undefined}
92
+ />
93
+ ))}
94
+ </span>
95
+ )}
96
+ <span>{label}</span>
97
+ </button>
98
+
99
+ {/* Hover tooltip — above the button */}
100
+ {hoverOpen && hoverContent && (
101
+ <div
102
+ className="absolute bottom-full right-0 z-50 mb-1 rounded-full bg-base-accent px-3 py-1 text-xs text-base-contrast-light whitespace-nowrap"
103
+ >
104
+ {hoverContent}
105
+ </div>
106
+ )}
107
+
108
+ {/* Click dropdown — below the button */}
109
+ <Popover
110
+ isOpen={clickOpen}
111
+ onClose={handleClickClose}
112
+ anchorRef={buttonRef}
113
+ className="min-w-full max-w-xs"
114
+ >
115
+ {typeof clickContent === "function" ? clickContent(handleClickClose) : clickContent}
116
+ </Popover>
117
+ </div>
118
+ );
119
+ }
@@ -4,14 +4,14 @@ import { DragHandle } from "./DragHandle";
4
4
  import { InsertButton } from "./InsertButton";
5
5
  import { DeleteButton } from "./DeleteButton";
6
6
  import { SettingsButton } from "./SettingsButton";
7
- import { StatusBadge } from "./StatusBadge";
8
- import { StatusPicker } from "./StatusPicker";
9
- import { AudiencePicker } from "./AudiencePicker";
7
+ import { StatusIndicator } from "./StatusIndicator";
8
+ import { AudienceIndicator } from "./AudienceIndicator";
10
9
  import { SettingsForm } from "./SettingsForm";
11
10
  import { useEditorContext } from "../shell/EditorContext";
12
11
  import { useEditorModal } from "../shell/EditorModalContext";
13
12
  import type { WrapperProps } from "../../lib/registry";
14
13
  import { cn } from "../../lib/cn";
14
+ import { Button } from "../shared/Button";
15
15
 
16
16
  export function SectionWrapper({
17
17
  sectionId,
@@ -30,6 +30,11 @@ export function SectionWrapper({
30
30
  onReorder,
31
31
  onRequestInsert,
32
32
  onDelete,
33
+ isDeleted,
34
+ onUndoDelete,
35
+ mainStatus,
36
+ contentDiffersFromMain,
37
+ isLocalOnly,
33
38
  children,
34
39
  }: WrapperProps) {
35
40
  const { isEditMode, showAllChrome } = useEditorContext();
@@ -175,8 +180,35 @@ export function SectionWrapper({
175
180
  }
176
181
  }
177
182
 
183
+ if (isDeleted && isEditMode) {
184
+ return (
185
+ <div
186
+ ref={blockRef}
187
+ className="relative opacity-30 pointer-events-none"
188
+ data-section-id={sectionId}
189
+ data-section-type={sectionType}
190
+ >
191
+ <div className="pointer-events-auto absolute right-0 bottom-full z-30 mb-1">
192
+ <Button
193
+ variant="secondary"
194
+ size="sm"
195
+ onClick={onUndoDelete}
196
+ className="pointer-events-auto"
197
+ >
198
+ Undo delete
199
+ </Button>
200
+ </div>
201
+ {children}
202
+ </div>
203
+ );
204
+ }
205
+
206
+ if (isDeleted) {
207
+ return null;
208
+ }
209
+
178
210
  if (!isEditMode) {
179
- if (status && status !== "published") {
211
+ if (status && status !== "live") {
180
212
  return (
181
213
  <div
182
214
  className="relative"
@@ -184,7 +216,17 @@ export function SectionWrapper({
184
216
  data-section-type={sectionType}
185
217
  >
186
218
  <div className="pointer-events-none absolute right-0 bottom-full z-30 mb-1">
187
- <StatusBadge status={status} dirty={dirty} />
219
+ <span
220
+ className={cn(
221
+ "rounded-full px-2 py-0.5 text-xs font-medium",
222
+ status === "live" ? "bg-status-live-bg text-status-live-text"
223
+ : status === "draft" ? "bg-status-draft-bg text-status-draft-text"
224
+ : "bg-status-archived-bg text-status-archived-text",
225
+ )}
226
+ >
227
+ {status}
228
+ {dirty && <span className="ml-1 opacity-70">· Unsaved</span>}
229
+ </span>
188
230
  </div>
189
231
  {children}
190
232
  </div>
@@ -245,17 +287,21 @@ export function SectionWrapper({
245
287
  )}
246
288
  >
247
289
  <div className="pointer-events-auto">
248
- <AudiencePicker
249
- access={access}
250
- audiences={audiences}
251
- onChange={(newAccess) => onAccessChange?.(newAccess)}
290
+ <StatusIndicator
291
+ mainStatus={mainStatus ?? null}
292
+ savedStatus={status as string}
293
+ contentDiffers={contentDiffersFromMain ?? false}
294
+ isLocalOnly={isLocalOnly ?? false}
295
+ status={status as "draft" | "live" | "archived"}
296
+ dirty={dirty}
297
+ onChange={(s) => onStatusChange?.(s)}
252
298
  />
253
299
  </div>
254
300
  <div className="pointer-events-auto">
255
- <StatusPicker
256
- status={status as "draft" | "published" | "archived"}
257
- dirty={dirty}
258
- onChange={(s) => onStatusChange?.(s)}
301
+ <AudienceIndicator
302
+ access={access}
303
+ audiences={audiences}
304
+ onChange={(newAccess) => onAccessChange?.(newAccess)}
259
305
  />
260
306
  </div>
261
307
 
@@ -0,0 +1,148 @@
1
+ import { Check } from "lucide-react";
2
+ import { cn } from "../../lib/cn";
3
+ import { IndicatorPill } from "./IndicatorPill";
4
+ import { PopoverItem } from "../shared/PopoverItem";
5
+
6
+ type StatusColor = "draft" | "live" | "archived" | "modified";
7
+ type Status = "draft" | "live" | "archived";
8
+
9
+ interface DotDef {
10
+ color: StatusColor;
11
+ }
12
+
13
+ interface DeriveInput {
14
+ mainStatus: string | null;
15
+ savedStatus: string;
16
+ contentDiffers: boolean;
17
+ isLocalOnly: boolean;
18
+ }
19
+
20
+ interface DeriveResult {
21
+ dots: DotDef[];
22
+ unsaved: boolean;
23
+ description: string;
24
+ }
25
+
26
+ const descriptions: Record<string, string> = {
27
+ "live": "Live — synced",
28
+ "live,modified": "Live — unpublished content edits",
29
+ "live,draft": "Live on site, changed to draft",
30
+ "live,archived": "Live on site, will be hidden on publish",
31
+ "draft": "Draft — editor only",
32
+ "draft,modified": "Draft — unpublished content edits",
33
+ "draft,live": "Draft, will become live on publish",
34
+ "archived": "Archived",
35
+ "archived,modified": "Archived — unpublished content edits",
36
+ "archived,live": "Archived, will become live on publish",
37
+ "modified": "Unpublished content edits",
38
+ };
39
+
40
+ export function deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly }: DeriveInput): DeriveResult {
41
+ const unsaved = isLocalOnly;
42
+
43
+ if (!mainStatus) {
44
+ return {
45
+ dots: [{ color: savedStatus as StatusColor }],
46
+ unsaved,
47
+ description: `${savedStatus} — new section`,
48
+ };
49
+ }
50
+
51
+ if (mainStatus !== savedStatus) {
52
+ const key = `${mainStatus},${savedStatus}`;
53
+ return {
54
+ dots: [{ color: mainStatus as StatusColor }, { color: savedStatus as StatusColor }],
55
+ unsaved,
56
+ description: descriptions[key] ?? `${mainStatus} → ${savedStatus}`,
57
+ };
58
+ }
59
+
60
+ if (contentDiffers) {
61
+ const key = `${mainStatus},modified`;
62
+ return {
63
+ dots: [{ color: mainStatus as StatusColor }, { color: "modified" }],
64
+ unsaved,
65
+ description: descriptions[key] ?? `${mainStatus} — with edits`,
66
+ };
67
+ }
68
+
69
+ return {
70
+ dots: [{ color: mainStatus as StatusColor }],
71
+ unsaved: false,
72
+ description: descriptions[mainStatus] ?? mainStatus,
73
+ };
74
+ }
75
+
76
+ const dotColorClasses: Record<StatusColor, string> = {
77
+ live: "bg-green-500",
78
+ draft: "bg-gray-400",
79
+ archived: "bg-white border-gray-300",
80
+ modified: "bg-orange-400",
81
+ };
82
+
83
+
84
+ const STATUSES: Status[] = ["draft", "live", "archived"];
85
+
86
+ interface StatusIndicatorProps {
87
+ mainStatus: string | null;
88
+ savedStatus: string;
89
+ contentDiffers: boolean;
90
+ isLocalOnly: boolean;
91
+ status: Status;
92
+ dirty?: boolean;
93
+ onChange: (status: Status) => void;
94
+ }
95
+
96
+ export function StatusIndicator({
97
+ mainStatus,
98
+ savedStatus,
99
+ contentDiffers,
100
+ isLocalOnly,
101
+ status,
102
+ dirty,
103
+ onChange,
104
+ }: StatusIndicatorProps) {
105
+ const display = deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly });
106
+
107
+ const pillDots = display.dots.map((d) => ({
108
+ className: dotColorClasses[d.color],
109
+ }));
110
+
111
+ const unsavedFromDirtyOrLocal = dirty || display.unsaved;
112
+ const labelText = `${status.charAt(0).toUpperCase() + status.slice(1)}${unsavedFromDirtyOrLocal ? " (Unsaved)" : ""}`;
113
+
114
+ return (
115
+ <IndicatorPill
116
+ dots={pillDots}
117
+ label={labelText}
118
+ ariaLabel={display.description}
119
+ buttonClassName="text-base-contrast-light"
120
+ clickContent={(onClose) => (
121
+ <ul role="radiogroup" aria-label="Section status" className="w-full overflow-hidden py-1">
122
+ {STATUSES.map((s) => {
123
+ const checked = s === status;
124
+ return (
125
+ <li key={s}>
126
+ <PopoverItem
127
+ role="radio"
128
+ aria-checked={checked}
129
+ onClick={() => {
130
+ if (s !== status) onChange(s);
131
+ onClose();
132
+ }}
133
+ >
134
+ <span
135
+ aria-hidden="true"
136
+ className={cn("h-3 w-3 shrink-0 rounded-full border border-base-200", dotColorClasses[s])}
137
+ />
138
+ <span className="flex-1 font-medium capitalize text-base-contrast">{s}</span>
139
+ {checked && <Check size={14} strokeWidth={3} className="text-primary" />}
140
+ </PopoverItem>
141
+ </li>
142
+ );
143
+ })}
144
+ </ul>
145
+ )}
146
+ />
147
+ );
148
+ }
@@ -3,5 +3,7 @@ export { InsertButton } from "./InsertButton";
3
3
  export { DeleteButton } from "./DeleteButton";
4
4
  export { SettingsButton } from "./SettingsButton";
5
5
  export { SettingsForm } from "./SettingsForm";
6
- export { StatusBadge } from "./StatusBadge";
6
+ export { IndicatorPill } from "./IndicatorPill";
7
+ export { StatusIndicator, deriveStatusDisplay } from "./StatusIndicator";
8
+ export { AudienceIndicator } from "./AudienceIndicator";
7
9
  export { SectionWrapper } from "./SectionWrapper";
@@ -19,7 +19,7 @@ interface Props {
19
19
  }
20
20
 
21
21
  export function SectionLayout({ type, status, dimNonPublished, children }: Props) {
22
- const isDimmed = dimNonPublished && status && status !== "published";
22
+ const isDimmed = dimNonPublished && status && status !== "live";
23
23
 
24
24
  return (
25
25
  <div className={cn("section-wrapper group", getSectionCategory(type), isDimmed && "opacity-50")}>
@@ -121,7 +121,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
121
121
  className={cn(
122
122
  "cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
123
123
  isActiveParent ? "text-primary" : "text-base-contrast hover:text-primary",
124
- !isEditMode && parent.status && parent.status !== "published" && "opacity-50",
124
+ !isEditMode && parent.status && parent.status !== "live" && "opacity-50",
125
125
  )}
126
126
  >
127
127
  {parent.label}
@@ -140,7 +140,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
140
140
  className={cn(
141
141
  "cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
142
142
  isActiveChild ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
143
- !isEditMode && child.status && child.status !== "published" && "opacity-50",
143
+ !isEditMode && child.status && child.status !== "live" && "opacity-50",
144
144
  )}
145
145
  >
146
146
  {child.label}
@@ -157,7 +157,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
157
157
  className={cn(
158
158
  "cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
159
159
  activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
160
- !isEditMode && gc.status && gc.status !== "published" && "opacity-50",
160
+ !isEditMode && gc.status && gc.status !== "live" && "opacity-50",
161
161
  )}
162
162
  >
163
163
  {gc.label}
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, type ReactNode, type RefObject } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
2
  import { useFocusTrap } from "../../hooks/useFocusTrap";
3
3
  import { cn } from "../../lib/cn";
4
4
 
@@ -7,7 +7,7 @@ interface PopoverProps {
7
7
  onClose: () => void;
8
8
  anchorRef: RefObject<HTMLElement | null>;
9
9
  children: ReactNode;
10
- align?: "start" | "end";
10
+ align?: "start" | "end" | "auto";
11
11
  className?: string;
12
12
  }
13
13
 
@@ -16,13 +16,33 @@ export function Popover({
16
16
  onClose,
17
17
  anchorRef,
18
18
  children,
19
- align = "start",
19
+ align = "auto",
20
20
  className,
21
21
  }: PopoverProps) {
22
22
  const panelRef = useRef<HTMLDivElement>(null);
23
+ const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
23
24
 
24
25
  useFocusTrap(panelRef, isOpen);
25
26
 
27
+ useLayoutEffect(() => {
28
+ if (!isOpen || align !== "auto") {
29
+ if (!isOpen) setResolvedAlign("start");
30
+ return;
31
+ }
32
+ const panel = panelRef.current;
33
+ if (!panel) return;
34
+ panel.style.left = "0";
35
+ panel.style.right = "auto";
36
+ const rect = panel.getBoundingClientRect();
37
+ if (rect.right > window.innerWidth) {
38
+ setResolvedAlign("end");
39
+ } else {
40
+ setResolvedAlign("start");
41
+ }
42
+ panel.style.left = "";
43
+ panel.style.right = "";
44
+ }, [isOpen, align]);
45
+
26
46
  useEffect(() => {
27
47
  if (!isOpen) return;
28
48
  function onKeyDown(e: KeyboardEvent) {
@@ -44,6 +64,8 @@ export function Popover({
44
64
 
45
65
  if (!isOpen) return null;
46
66
 
67
+ const effectiveAlign = align === "auto" ? resolvedAlign : align;
68
+
47
69
  return (
48
70
  <div
49
71
  ref={panelRef}
@@ -51,7 +73,7 @@ export function Popover({
51
73
  aria-modal="false"
52
74
  className={cn(
53
75
  "absolute top-full z-50 mt-1 rounded-md border border-base-200 bg-base shadow-lg",
54
- align === "end" ? "right-0" : "left-0",
76
+ effectiveAlign === "end" ? "right-0" : "left-0",
55
77
  className,
56
78
  )}
57
79
  >
@@ -12,7 +12,7 @@ export const PopoverItem = forwardRef<HTMLButtonElement, PopoverItemProps>(
12
12
  ref={ref}
13
13
  type="button"
14
14
  className={cn(
15
- "flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left hover:bg-base-accent",
15
+ "flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left text-xs hover:bg-base-accent",
16
16
  className,
17
17
  )}
18
18
  {...rest}
@@ -0,0 +1,43 @@
1
+ import { cn } from "../../lib/cn";
2
+
3
+ export interface SegmentedOption {
4
+ value: string;
5
+ label: string;
6
+ }
7
+
8
+ interface SegmentedControlProps {
9
+ options: SegmentedOption[];
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ className?: string;
13
+ }
14
+
15
+ export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) {
16
+ return (
17
+ <div role="radiogroup" className={cn("inline-flex rounded border border-base-200 bg-base-accent", className)}>
18
+ {options.map((option) => {
19
+ const selected = option.value === value;
20
+ return (
21
+ <button
22
+ key={option.value}
23
+ type="button"
24
+ role="radio"
25
+ aria-checked={selected}
26
+ aria-label={option.label}
27
+ onClick={() => {
28
+ if (!selected) onChange(option.value);
29
+ }}
30
+ className={cn(
31
+ "cursor-pointer px-3 py-1 text-xs font-medium transition-colors first:rounded-l last:rounded-r",
32
+ selected
33
+ ? "bg-base-contrast text-base-accent"
34
+ : "text-base-contrast-light hover:text-base-contrast",
35
+ )}
36
+ >
37
+ {option.label}
38
+ </button>
39
+ );
40
+ })}
41
+ </div>
42
+ );
43
+ }