@agent-native/core 0.168.10 → 0.168.12
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/corpus/README.md +1 -1
- package/corpus/templates/clips/changelog/2026-08-19-desktop-settings-are-tighter-with-a-new-advanced-tab.md +6 -0
- package/corpus/templates/clips/desktop/README.md +79 -1
- package/corpus/templates/clips/desktop/components.json +20 -0
- package/corpus/templates/clips/desktop/package.json +10 -1
- package/corpus/templates/clips/desktop/src/app.tsx +1812 -1994
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +23 -65
- package/corpus/templates/clips/desktop/src/components/settings/settings-ui.tsx +399 -0
- package/corpus/templates/clips/desktop/src/components/ui/alert-dialog.tsx +166 -0
- package/corpus/templates/clips/desktop/src/components/ui/button.tsx +57 -0
- package/corpus/templates/clips/desktop/src/components/ui/empty.tsx +104 -0
- package/corpus/templates/clips/desktop/src/components/ui/input.tsx +22 -0
- package/corpus/templates/clips/desktop/src/components/ui/popover.tsx +38 -0
- package/corpus/templates/clips/desktop/src/components/ui/select.tsx +164 -0
- package/corpus/templates/clips/desktop/src/components/ui/skeleton.tsx +15 -0
- package/corpus/templates/clips/desktop/src/components/ui/spinner.tsx +17 -0
- package/corpus/templates/clips/desktop/src/components/ui/switch.tsx +27 -0
- package/corpus/templates/clips/desktop/src/components/ui/textarea.tsx +22 -0
- package/corpus/templates/clips/desktop/src/dev/browser-preview.ts +65 -0
- package/corpus/templates/clips/desktop/src/hooks/useSystemAccessRows.ts +128 -0
- package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +0 -2
- package/corpus/templates/clips/desktop/src/lib/permission-status.ts +84 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -5
- package/corpus/templates/clips/desktop/src/lib/settings-navigation.ts +18 -5
- package/corpus/templates/clips/desktop/src/lib/utils.ts +6 -0
- package/corpus/templates/clips/desktop/src/main.tsx +31 -4
- package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +2 -2
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +2 -2
- package/corpus/templates/clips/desktop/src/styles.css +75 -1427
- package/corpus/templates/clips/desktop/src/tailwind.css +282 -0
- package/corpus/templates/clips/desktop/tsconfig.json +4 -1
- package/corpus/templates/clips/desktop/vite.config.ts +7 -1
- package/corpus/templates/design/app/components/layout/Layout.tsx +38 -14
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/action-routes.js +3 -2
- package/dist/server/request-origin.d.ts +6 -0
- package/dist/server/request-origin.js +12 -0
- package/package.json +1 -1
|
@@ -5,28 +5,18 @@ import {
|
|
|
5
5
|
IconCircleOff,
|
|
6
6
|
IconRefresh,
|
|
7
7
|
} from "@tabler/icons-react";
|
|
8
|
-
import { invoke } from "@tauri-apps/api/core";
|
|
9
8
|
import { useState, useCallback, useEffect } from "react";
|
|
10
9
|
|
|
10
|
+
import {
|
|
11
|
+
permissionStatusForPane,
|
|
12
|
+
readPermissionStatuses,
|
|
13
|
+
requestOrOpenPermission,
|
|
14
|
+
type MacosPrivacyPane,
|
|
15
|
+
type PermissionStatuses,
|
|
16
|
+
} from "../lib/permission-status";
|
|
11
17
|
import { isMacPlatform, isWindowsPlatform } from "../lib/platform";
|
|
12
18
|
|
|
13
19
|
type CaptureMode = "screen" | "screen-camera" | "camera";
|
|
14
|
-
type MacosPrivacyPane =
|
|
15
|
-
| "camera"
|
|
16
|
-
| "microphone"
|
|
17
|
-
| "screen"
|
|
18
|
-
| "speech"
|
|
19
|
-
| "accessibility"
|
|
20
|
-
| "input-monitoring";
|
|
21
|
-
|
|
22
|
-
type PermissionStatuses = {
|
|
23
|
-
screen: boolean;
|
|
24
|
-
camera: boolean;
|
|
25
|
-
microphone: boolean;
|
|
26
|
-
speech: boolean;
|
|
27
|
-
accessibility: boolean;
|
|
28
|
-
inputMonitoring: boolean;
|
|
29
|
-
};
|
|
30
20
|
|
|
31
21
|
type ReadinessItem = {
|
|
32
22
|
label: string;
|
|
@@ -53,39 +43,39 @@ function readinessItems({
|
|
|
53
43
|
const items: ReadinessItem[] = [
|
|
54
44
|
{
|
|
55
45
|
label: "Screen Recording",
|
|
56
|
-
detail: "
|
|
46
|
+
detail: "Allows Clips to record your screen",
|
|
57
47
|
pane: "screen",
|
|
58
48
|
active: mode !== "camera",
|
|
59
49
|
},
|
|
60
50
|
{
|
|
61
51
|
label: "Microphone",
|
|
62
|
-
detail: "
|
|
52
|
+
detail: "Allows Clips to access your microphone",
|
|
63
53
|
pane: "microphone",
|
|
64
54
|
active: micOn,
|
|
65
55
|
},
|
|
66
56
|
{
|
|
67
57
|
label: "Speech Recognition",
|
|
68
|
-
detail: "
|
|
58
|
+
detail: "Allows Clips to use speech recognition",
|
|
69
59
|
pane: "speech",
|
|
70
60
|
active: micOn,
|
|
71
61
|
macosOnly: true,
|
|
72
62
|
},
|
|
73
63
|
{
|
|
74
64
|
label: "Camera",
|
|
75
|
-
detail: "
|
|
65
|
+
detail: "Allows Clips to access your camera",
|
|
76
66
|
pane: "camera",
|
|
77
67
|
active: mode !== "screen" && cameraOn,
|
|
78
68
|
},
|
|
79
69
|
{
|
|
80
70
|
label: "Accessibility",
|
|
81
|
-
detail: "
|
|
71
|
+
detail: "Allows Clips to control this device to paste dictated text",
|
|
82
72
|
pane: "accessibility",
|
|
83
73
|
active: includeVoicePaste,
|
|
84
74
|
macosOnly: true,
|
|
85
75
|
},
|
|
86
76
|
{
|
|
87
77
|
label: "Input Monitoring",
|
|
88
|
-
detail: "
|
|
78
|
+
detail: "Allows Clips to detect the Fn key",
|
|
89
79
|
pane: "input-monitoring",
|
|
90
80
|
active: includeFnMonitoring,
|
|
91
81
|
macosOnly: true,
|
|
@@ -95,22 +85,6 @@ function readinessItems({
|
|
|
95
85
|
return items.filter((item) => item.active && (!item.macosOnly || mac));
|
|
96
86
|
}
|
|
97
87
|
|
|
98
|
-
function statusForPane(
|
|
99
|
-
pane: MacosPrivacyPane,
|
|
100
|
-
statuses: PermissionStatuses | null,
|
|
101
|
-
): boolean | null {
|
|
102
|
-
if (!statuses) return null;
|
|
103
|
-
const map: Record<MacosPrivacyPane, boolean> = {
|
|
104
|
-
screen: statuses.screen,
|
|
105
|
-
camera: statuses.camera,
|
|
106
|
-
microphone: statuses.microphone,
|
|
107
|
-
speech: statuses.speech,
|
|
108
|
-
accessibility: statuses.accessibility,
|
|
109
|
-
"input-monitoring": statuses.inputMonitoring,
|
|
110
|
-
};
|
|
111
|
-
return map[pane];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
88
|
export function ReadinessPanel({
|
|
115
89
|
mode,
|
|
116
90
|
cameraOn,
|
|
@@ -146,35 +120,19 @@ export function ReadinessPanel({
|
|
|
146
120
|
const checkStatuses = useCallback(async () => {
|
|
147
121
|
setChecking(true);
|
|
148
122
|
try {
|
|
149
|
-
|
|
150
|
-
"check_permission_statuses",
|
|
151
|
-
);
|
|
152
|
-
setStatuses(result);
|
|
153
|
-
} catch {
|
|
154
|
-
// Non-macOS or command not available — leave statuses null
|
|
123
|
+
setStatuses(await readPermissionStatuses());
|
|
155
124
|
} finally {
|
|
156
125
|
setChecking(false);
|
|
157
126
|
}
|
|
158
127
|
}, []);
|
|
159
128
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
await checkStatuses();
|
|
168
|
-
if (granted) return;
|
|
169
|
-
} catch {
|
|
170
|
-
// Fall through to the dedicated privacy pane. The request API may
|
|
171
|
-
// be unavailable on an older macOS build or the user may already
|
|
172
|
-
// have denied the prompt once.
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
onOpenPermission(pane);
|
|
176
|
-
},
|
|
177
|
-
[checkStatuses, mac, onOpenPermission],
|
|
129
|
+
const grantPermission = useCallback(
|
|
130
|
+
(pane: MacosPrivacyPane) =>
|
|
131
|
+
requestOrOpenPermission(pane, {
|
|
132
|
+
onOpenSettings: onOpenPermission,
|
|
133
|
+
onRecheck: () => void checkStatuses(),
|
|
134
|
+
}),
|
|
135
|
+
[checkStatuses, onOpenPermission],
|
|
178
136
|
);
|
|
179
137
|
|
|
180
138
|
useEffect(() => {
|
|
@@ -203,7 +161,7 @@ export function ReadinessPanel({
|
|
|
203
161
|
<div className="readiness-list">
|
|
204
162
|
{items.length ? (
|
|
205
163
|
items.map((item) => {
|
|
206
|
-
const granted =
|
|
164
|
+
const granted = permissionStatusForPane(item.pane, statuses);
|
|
207
165
|
return (
|
|
208
166
|
<div className="readiness-item" key={item.pane}>
|
|
209
167
|
<div className="readiness-item-copy">
|
|
@@ -239,7 +197,7 @@ export function ReadinessPanel({
|
|
|
239
197
|
<button
|
|
240
198
|
type="button"
|
|
241
199
|
className="readiness-open-button"
|
|
242
|
-
onClick={() => void
|
|
200
|
+
onClick={() => void grantPermission(item.pane)}
|
|
243
201
|
>
|
|
244
202
|
Open
|
|
245
203
|
</button>
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation for the tray Settings surface, built on Tailwind + the shadcn
|
|
3
|
+
* primitives in `@/components/ui`. Everything here is layout and typography —
|
|
4
|
+
* no Tauri calls, no feature state — so a row's behavior stays in app.tsx and
|
|
5
|
+
* its shape stays here.
|
|
6
|
+
*/
|
|
7
|
+
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
|
|
8
|
+
import { useState, type ReactElement, type ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import {
|
|
12
|
+
Popover,
|
|
13
|
+
PopoverClose,
|
|
14
|
+
PopoverContent,
|
|
15
|
+
PopoverTrigger,
|
|
16
|
+
} from "@/components/ui/popover";
|
|
17
|
+
import {
|
|
18
|
+
Select,
|
|
19
|
+
SelectContent,
|
|
20
|
+
SelectItem,
|
|
21
|
+
SelectTrigger,
|
|
22
|
+
SelectValue,
|
|
23
|
+
} from "@/components/ui/select";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single shape every control in a settings row wears: same height, same
|
|
28
|
+
* radius, same fill, same type size, same hover. Dropdowns, keycaps, value
|
|
29
|
+
* triggers, and action buttons are interchangeable in a row, so a control that
|
|
30
|
+
* invents its own size is the thing that reads as sloppy.
|
|
31
|
+
*
|
|
32
|
+
* At rest it is the page's own background inside a hairline the same weight as
|
|
33
|
+
* the card's — `border-border`, not the heavier `border-input`. The grey fill is
|
|
34
|
+
* the hover state and nothing else, so a row of controls reads as quiet until
|
|
35
|
+
* the pointer is actually over one.
|
|
36
|
+
*/
|
|
37
|
+
// `rounded-md` (radius − 2px), not the card's own `rounded-lg`: a control
|
|
38
|
+
// nested inside a rounded card wants a concentrically smaller radius, or its
|
|
39
|
+
// corners read puffier than the surface containing it.
|
|
40
|
+
const ROW_CONTROL =
|
|
41
|
+
"h-8 rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
|
|
42
|
+
|
|
43
|
+
/** A card of rows. `label` is the small uppercase divider; omit it when the
|
|
44
|
+
* tab is a single card and its heading already names the group. */
|
|
45
|
+
export function SettingsGroup({
|
|
46
|
+
label,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
label?: string;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<section className="min-w-0">
|
|
54
|
+
{label ? (
|
|
55
|
+
<div className="px-0.5 pb-1.5 text-2xs font-bold uppercase tracking-[0.08em] text-muted-foreground">
|
|
56
|
+
{label}
|
|
57
|
+
</div>
|
|
58
|
+
) : null}
|
|
59
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One setting per row: label and a grounding line on the left, its current
|
|
68
|
+
* state on the right.
|
|
69
|
+
*
|
|
70
|
+
* Every row carries a `description` by product decision — a desktop setting
|
|
71
|
+
* that silently changes OS-level behavior needs grounding the label alone
|
|
72
|
+
* cannot give. It must say something the label does not: where the setting
|
|
73
|
+
* applies, what the user will see, what it costs. Restating the label ("Keep
|
|
74
|
+
* dictation visible — keeps dictation visible") is the failure mode, and the
|
|
75
|
+
* reason to delete a line rather than pad it.
|
|
76
|
+
*/
|
|
77
|
+
export function SettingsRow({
|
|
78
|
+
label,
|
|
79
|
+
description,
|
|
80
|
+
control,
|
|
81
|
+
children,
|
|
82
|
+
stacked = false,
|
|
83
|
+
}: {
|
|
84
|
+
label: string;
|
|
85
|
+
description?: string;
|
|
86
|
+
control?: ReactNode;
|
|
87
|
+
children?: ReactNode;
|
|
88
|
+
stacked?: boolean;
|
|
89
|
+
}) {
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
className={cn(
|
|
93
|
+
// The divider is a pseudo-element inset to the row's own padding, not a
|
|
94
|
+
// `border-b`. An edge-to-edge rule cuts the card into stacked boxes;
|
|
95
|
+
// stopping it short of the corners keeps one card with rows inside it.
|
|
96
|
+
// Dimmer than the card's own outline too — it separates siblings rather
|
|
97
|
+
// than bounding the card, so it should not compete with the edge.
|
|
98
|
+
"relative grid min-h-[46px] min-w-0 items-center gap-4 px-3.5 py-2.5",
|
|
99
|
+
"after:absolute after:inset-x-3.5 after:bottom-0 after:h-px after:bg-border/50 after:content-[''] last:after:hidden",
|
|
100
|
+
stacked
|
|
101
|
+
? "grid-cols-[minmax(0,1fr)] items-start gap-2"
|
|
102
|
+
: "grid-cols-[minmax(0,1fr)_auto]",
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<div className="grid min-w-0 gap-0.5">
|
|
106
|
+
<span className="truncate text-base font-medium text-foreground">
|
|
107
|
+
{label}
|
|
108
|
+
</span>
|
|
109
|
+
{description ? (
|
|
110
|
+
<span className="text-xs text-muted-foreground">{description}</span>
|
|
111
|
+
) : null}
|
|
112
|
+
</div>
|
|
113
|
+
{control ? (
|
|
114
|
+
<div
|
|
115
|
+
className={cn(
|
|
116
|
+
"flex min-w-0 items-center gap-2",
|
|
117
|
+
stacked ? "w-full justify-start" : "justify-end",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{control}
|
|
121
|
+
</div>
|
|
122
|
+
) : null}
|
|
123
|
+
{children ? (
|
|
124
|
+
<div className="col-span-full grid min-w-0 gap-2 text-xs text-muted-foreground">
|
|
125
|
+
{children}
|
|
126
|
+
</div>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A dropdown wearing the shared row-control shape. */
|
|
133
|
+
export function SettingsSelect({
|
|
134
|
+
value,
|
|
135
|
+
onValueChange,
|
|
136
|
+
options,
|
|
137
|
+
ariaLabel,
|
|
138
|
+
/** Shown when `value` matches no option — a catalog still loading, or empty.
|
|
139
|
+
* Without it the trigger renders as a bare chevron. */
|
|
140
|
+
placeholder,
|
|
141
|
+
disabled = false,
|
|
142
|
+
}: {
|
|
143
|
+
value: string;
|
|
144
|
+
onValueChange: (value: string) => void;
|
|
145
|
+
options: Array<{ value: string; label: string; disabled?: boolean }>;
|
|
146
|
+
ariaLabel: string;
|
|
147
|
+
placeholder?: string;
|
|
148
|
+
disabled?: boolean;
|
|
149
|
+
}) {
|
|
150
|
+
// Radix renders the placeholder only for an empty value, so a stored value
|
|
151
|
+
// that matches no option (a catalog still loading) would otherwise leave the
|
|
152
|
+
// trigger showing nothing but its chevron.
|
|
153
|
+
const selected = options.some((option) => option.value === value);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Select
|
|
157
|
+
value={selected ? value : ""}
|
|
158
|
+
onValueChange={onValueChange}
|
|
159
|
+
disabled={disabled}
|
|
160
|
+
>
|
|
161
|
+
<SelectTrigger
|
|
162
|
+
aria-label={ariaLabel}
|
|
163
|
+
/* The registry trigger ships focus:ring-2 + ring-offset-2; zero BOTH or
|
|
164
|
+
the offset halo survives ring-0 and doubles up with ROW_CONTROL's
|
|
165
|
+
focus-visible ring. */
|
|
166
|
+
className={cn(
|
|
167
|
+
ROW_CONTROL,
|
|
168
|
+
"w-auto gap-1.5 shadow-none focus:ring-0 focus:ring-offset-0",
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<SelectValue placeholder={placeholder} />
|
|
172
|
+
</SelectTrigger>
|
|
173
|
+
<SelectContent align="end">
|
|
174
|
+
{options.map((option) => (
|
|
175
|
+
<SelectItem
|
|
176
|
+
key={option.value}
|
|
177
|
+
value={option.value}
|
|
178
|
+
disabled={option.disabled}
|
|
179
|
+
className="text-sm"
|
|
180
|
+
>
|
|
181
|
+
{option.label}
|
|
182
|
+
</SelectItem>
|
|
183
|
+
))}
|
|
184
|
+
</SelectContent>
|
|
185
|
+
</Select>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Shows the current value and opens its detail — a dropdown in every respect
|
|
190
|
+
* a user can see, so it wears the same shape. */
|
|
191
|
+
export function SettingsValueTrigger({
|
|
192
|
+
value,
|
|
193
|
+
mono = false,
|
|
194
|
+
className,
|
|
195
|
+
...props
|
|
196
|
+
}: {
|
|
197
|
+
value: string;
|
|
198
|
+
mono?: boolean;
|
|
199
|
+
} & React.ComponentPropsWithRef<"button">) {
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={cn(
|
|
204
|
+
ROW_CONTROL,
|
|
205
|
+
"inline-flex max-w-60 items-center gap-1.5",
|
|
206
|
+
className,
|
|
207
|
+
)}
|
|
208
|
+
{...props}
|
|
209
|
+
>
|
|
210
|
+
<span className={cn("truncate", mono && "font-mono text-xs")}>
|
|
211
|
+
{value}
|
|
212
|
+
</span>
|
|
213
|
+
<IconChevronDown
|
|
214
|
+
className="size-3.5 shrink-0 opacity-50"
|
|
215
|
+
stroke={1.9}
|
|
216
|
+
aria-hidden="true"
|
|
217
|
+
/>
|
|
218
|
+
</button>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* An action in a settings row: a real button, never a bare text link, and by
|
|
224
|
+
* default the same soft filled shape as the dropdowns it sits beside.
|
|
225
|
+
* `primary` is for the single action a row wants you to take; `quiet` is for a
|
|
226
|
+
* secondary one that should not compete with the control next to it.
|
|
227
|
+
*/
|
|
228
|
+
export function SettingsActionButton({
|
|
229
|
+
className,
|
|
230
|
+
emphasis = "soft",
|
|
231
|
+
...props
|
|
232
|
+
}: { emphasis?: "soft" | "primary" | "quiet" | "destructive" } & Omit<
|
|
233
|
+
React.ComponentProps<typeof Button>,
|
|
234
|
+
"variant" | "size"
|
|
235
|
+
>) {
|
|
236
|
+
return (
|
|
237
|
+
<Button
|
|
238
|
+
variant={emphasis === "primary" ? "default" : "ghost"}
|
|
239
|
+
className={cn(
|
|
240
|
+
"gap-1.5",
|
|
241
|
+
emphasis === "soft" && ROW_CONTROL,
|
|
242
|
+
emphasis === "primary" && "h-8 rounded-md px-3 text-sm font-medium",
|
|
243
|
+
emphasis === "quiet" &&
|
|
244
|
+
"h-8 rounded-md px-2 text-sm font-medium text-muted-foreground hover:bg-accent",
|
|
245
|
+
// Quiet-red, not a filled slab: red text that gains a red-tinted wash
|
|
246
|
+
// on hover. For actions that permanently delete data — nothing else.
|
|
247
|
+
emphasis === "destructive" &&
|
|
248
|
+
"h-8 rounded-md px-2 text-sm font-medium text-destructive hover:bg-destructive/10 hover:text-destructive",
|
|
249
|
+
className,
|
|
250
|
+
)}
|
|
251
|
+
{...props}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A titled popover for a setting's detail. The tray window is small and
|
|
258
|
+
* chromeless, so the title and close button orient the user inside a layer
|
|
259
|
+
* that has no window frame of its own.
|
|
260
|
+
*/
|
|
261
|
+
export function SettingsPopover({
|
|
262
|
+
title,
|
|
263
|
+
trigger,
|
|
264
|
+
children,
|
|
265
|
+
open,
|
|
266
|
+
onOpenChange,
|
|
267
|
+
side = "bottom",
|
|
268
|
+
className,
|
|
269
|
+
}: {
|
|
270
|
+
title: string;
|
|
271
|
+
trigger: ReactElement;
|
|
272
|
+
children: ReactNode;
|
|
273
|
+
open: boolean;
|
|
274
|
+
onOpenChange: (open: boolean) => void;
|
|
275
|
+
side?: "bottom" | "left" | "right" | "top";
|
|
276
|
+
className?: string;
|
|
277
|
+
}) {
|
|
278
|
+
return (
|
|
279
|
+
<Popover open={open} onOpenChange={onOpenChange}>
|
|
280
|
+
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
281
|
+
<PopoverContent
|
|
282
|
+
side={side}
|
|
283
|
+
align="end"
|
|
284
|
+
sideOffset={8}
|
|
285
|
+
collisionPadding={12}
|
|
286
|
+
data-popover-overlay="true"
|
|
287
|
+
className={cn("w-80 p-3", className)}
|
|
288
|
+
>
|
|
289
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
290
|
+
<div className="text-sm font-semibold" role="heading" aria-level={2}>
|
|
291
|
+
{title}
|
|
292
|
+
</div>
|
|
293
|
+
<PopoverClose
|
|
294
|
+
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
295
|
+
aria-label={`Close ${title}`}
|
|
296
|
+
>
|
|
297
|
+
<IconX className="size-3.5" stroke={1.9} />
|
|
298
|
+
</PopoverClose>
|
|
299
|
+
</div>
|
|
300
|
+
{children}
|
|
301
|
+
</PopoverContent>
|
|
302
|
+
</Popover>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A one-of-many setting whose choices need more room than a select — because
|
|
308
|
+
* picking one reveals a follow-up control. `keepOpenValues` names those, so the
|
|
309
|
+
* popover does not close before the user can finish.
|
|
310
|
+
*/
|
|
311
|
+
export function SettingsChoicePopover({
|
|
312
|
+
title,
|
|
313
|
+
value,
|
|
314
|
+
options,
|
|
315
|
+
onChange,
|
|
316
|
+
trigger,
|
|
317
|
+
keepOpenValues,
|
|
318
|
+
children,
|
|
319
|
+
}: {
|
|
320
|
+
title: string;
|
|
321
|
+
value: string;
|
|
322
|
+
options: Array<{ value: string; label: string }>;
|
|
323
|
+
onChange: (value: string) => void;
|
|
324
|
+
trigger: ReactElement;
|
|
325
|
+
keepOpenValues?: string[];
|
|
326
|
+
children?: ReactNode;
|
|
327
|
+
}) {
|
|
328
|
+
const [open, setOpen] = useState(false);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<SettingsPopover
|
|
332
|
+
title={title}
|
|
333
|
+
open={open}
|
|
334
|
+
onOpenChange={setOpen}
|
|
335
|
+
trigger={trigger}
|
|
336
|
+
>
|
|
337
|
+
<div className="grid gap-2">
|
|
338
|
+
{/* Plain toggle buttons, not role="listbox"/"option": those roles
|
|
339
|
+
promise arrow-key navigation and typeahead this popover does not
|
|
340
|
+
implement, so a screen reader would announce a contract the
|
|
341
|
+
keyboard cannot honor. Tab-per-button with aria-pressed matches
|
|
342
|
+
how it actually behaves. */}
|
|
343
|
+
<div className="grid gap-0.5" role="group" aria-label={title}>
|
|
344
|
+
{options.map((option) => {
|
|
345
|
+
const selected = option.value === value;
|
|
346
|
+
return (
|
|
347
|
+
<button
|
|
348
|
+
key={option.value}
|
|
349
|
+
type="button"
|
|
350
|
+
aria-pressed={selected}
|
|
351
|
+
className={cn(
|
|
352
|
+
"flex w-full items-center justify-between gap-2.5 rounded-sm px-2 py-1.5 text-left text-sm font-medium transition-colors",
|
|
353
|
+
selected
|
|
354
|
+
? "bg-accent text-foreground"
|
|
355
|
+
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
356
|
+
)}
|
|
357
|
+
onClick={() => {
|
|
358
|
+
onChange(option.value);
|
|
359
|
+
if (!keepOpenValues?.includes(option.value)) setOpen(false);
|
|
360
|
+
}}
|
|
361
|
+
>
|
|
362
|
+
<span className="truncate">{option.label}</span>
|
|
363
|
+
{selected ? (
|
|
364
|
+
<IconCheck className="size-3.5 shrink-0" stroke={2.1} />
|
|
365
|
+
) : null}
|
|
366
|
+
</button>
|
|
367
|
+
);
|
|
368
|
+
})}
|
|
369
|
+
</div>
|
|
370
|
+
{children}
|
|
371
|
+
</div>
|
|
372
|
+
</SettingsPopover>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The keycap rendering of a shortcut, e.g. `⌘⇧Space`. */
|
|
377
|
+
export function SettingsKeycap({
|
|
378
|
+
children,
|
|
379
|
+
active = false,
|
|
380
|
+
className,
|
|
381
|
+
...props
|
|
382
|
+
}: {
|
|
383
|
+
active?: boolean;
|
|
384
|
+
} & React.ComponentPropsWithRef<"button">) {
|
|
385
|
+
return (
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
className={cn(
|
|
389
|
+
ROW_CONTROL,
|
|
390
|
+
"inline-flex items-center whitespace-nowrap",
|
|
391
|
+
active && "border-ring bg-accent",
|
|
392
|
+
className,
|
|
393
|
+
)}
|
|
394
|
+
{...props}
|
|
395
|
+
>
|
|
396
|
+
{children}
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
}
|