Skip to content

Commit c3132c3

Browse files
matt2eclaude
andauthored
feat(desktop): use segmented controls for channel creation (#6845)
## Summary - replace the two-option Type and Visibility dropdowns in the Create channel dialog with single-click segmented controls - keep Expires after as a dropdown and preserve the existing dropdown controls in edit and management dialogs - update channel creation end-to-end coverage for the direct controls ## Before Default Ongoing/Public state: ![Create channel before - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-default.png) Type dropdown open, showing the extra selection click: ![Create channel before - Type dropdown open](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-type-dropdown.png) ## After Default Ongoing/Public state: ![Create channel after - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-default-reversed.png) Temporary/Private state with the Expires after row visible: ![Create channel after - Temporary and Private](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-temporary-private-reversed.png) ## Verification - Biome and TypeScript checks pass - 5,508 desktop unit tests pass - 88 channel smoke tests pass - source guards pass --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eed74bd commit c3132c3

7 files changed

Lines changed: 157 additions & 94 deletions

File tree

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChevronDown } from "lucide-react";
1+
import { ChevronDown, Globe, Lock } from "lucide-react";
22

33
import type { ChannelVisibility } from "@/shared/api/types";
44
import { Button } from "@/shared/ui/button";
@@ -10,75 +10,102 @@ import {
1010
DropdownMenuTrigger,
1111
} from "@/shared/ui/dropdown-menu";
1212
import { cn } from "@/shared/lib/cn";
13+
import { SegmentedControl } from "@/shared/ui/segmented-control";
14+
15+
const VISIBILITY_OPTIONS = [
16+
{ value: "private", label: "Private", Icon: Lock },
17+
{ value: "open", label: "Public", Icon: Globe },
18+
] as const;
1319

1420
export function ChannelPermissionsSettings({
1521
disabled,
1622
onVisibilityChange,
1723
testIdPrefix,
1824
visibility,
25+
variant = "dropdown",
1926
}: {
2027
disabled?: boolean;
2128
onVisibilityChange: (visibility: ChannelVisibility) => void;
2229
testIdPrefix: string;
2330
visibility: ChannelVisibility;
31+
variant?: "dropdown" | "segmented";
2432
}) {
2533
const visibilityLabel = visibility === "private" ? "Private" : "Public";
2634

2735
return (
2836
<div
2937
className={cn(
3038
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
31-
disabled && "opacity-50",
39+
disabled && variant === "dropdown" && "opacity-50",
3240
)}
3341
data-testid={`${testIdPrefix}-permissions-container`}
3442
>
35-
<span className="text-sm font-medium text-foreground">Visibility</span>
36-
<DropdownMenu modal={false}>
37-
<DropdownMenuTrigger asChild>
38-
<Button
39-
aria-label={`Visibility: ${visibilityLabel}`}
40-
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
41-
data-testid={`${testIdPrefix}-permissions`}
42-
disabled={disabled}
43-
type="button"
44-
variant="ghost"
45-
>
46-
<span aria-live="polite" className="text-right">
47-
{visibilityLabel}
48-
</span>
49-
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
50-
</Button>
51-
</DropdownMenuTrigger>
52-
<DropdownMenuContent
53-
align="end"
54-
onCloseAutoFocus={(event) => event.preventDefault()}
55-
style={{
56-
minWidth: "var(--radix-dropdown-menu-trigger-width)",
57-
}}
58-
>
59-
<DropdownMenuRadioGroup
60-
onValueChange={(nextVisibility) =>
61-
onVisibilityChange(
62-
nextVisibility === "private" ? "private" : "open",
63-
)
64-
}
65-
value={visibility}
66-
>
67-
<DropdownMenuRadioItem
68-
data-testid={`${testIdPrefix}-permissions-option-open`}
69-
value="open"
43+
<span
44+
className={cn(
45+
"text-sm font-medium text-foreground",
46+
disabled && variant === "segmented" && "opacity-50",
47+
)}
48+
>
49+
Visibility
50+
</span>
51+
{variant === "segmented" ? (
52+
<SegmentedControl
53+
disabled={disabled}
54+
legend="Visibility"
55+
onValueChange={onVisibilityChange}
56+
optionTestIdPrefix={`${testIdPrefix}-permissions-option`}
57+
options={VISIBILITY_OPTIONS}
58+
testId={`${testIdPrefix}-permissions`}
59+
value={visibility}
60+
/>
61+
) : (
62+
<DropdownMenu modal={false}>
63+
<DropdownMenuTrigger asChild>
64+
<Button
65+
aria-label={`Visibility: ${visibilityLabel}`}
66+
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
67+
data-testid={`${testIdPrefix}-permissions`}
68+
disabled={disabled}
69+
type="button"
70+
variant="ghost"
7071
>
71-
Public
72-
</DropdownMenuRadioItem>
73-
<DropdownMenuRadioItem
74-
data-testid={`${testIdPrefix}-permissions-option-private`}
75-
value="private"
72+
<span aria-live="polite" className="text-right">
73+
{visibilityLabel}
74+
</span>
75+
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
76+
</Button>
77+
</DropdownMenuTrigger>
78+
<DropdownMenuContent
79+
align="end"
80+
onCloseAutoFocus={(event) => event.preventDefault()}
81+
style={{
82+
minWidth: "var(--radix-dropdown-menu-trigger-width)",
83+
}}
84+
>
85+
<DropdownMenuRadioGroup
86+
onValueChange={(nextVisibility) =>
87+
onVisibilityChange(
88+
nextVisibility === "private" ? "private" : "open",
89+
)
90+
}
91+
value={visibility}
7692
>
77-
Private
78-
</DropdownMenuRadioItem>
79-
</DropdownMenuRadioGroup>
80-
</DropdownMenuContent>
81-
</DropdownMenu>
93+
<DropdownMenuRadioItem
94+
data-testid={`${testIdPrefix}-permissions-option-open`}
95+
value="open"
96+
>
97+
Public
98+
</DropdownMenuRadioItem>
99+
<DropdownMenuRadioItem
100+
data-testid={`${testIdPrefix}-permissions-option-private`}
101+
value="private"
102+
>
103+
Private
104+
</DropdownMenuRadioItem>
105+
</DropdownMenuRadioGroup>
106+
</DropdownMenuContent>
107+
</DropdownMenu>
108+
)}
82109
</div>
83110
);
84111
}

desktop/src/features/channels/ui/ChannelTypeSettings.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChevronDown } from "lucide-react";
1+
import { ChevronDown, ClockFading, Hash } from "lucide-react";
22
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
33

44
import {
@@ -11,6 +11,7 @@ import {
1111
} from "@/features/channels/lib/ephemeralChannel";
1212
import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
1313
import type { Channel } from "@/shared/api/types";
14+
import { cn } from "@/shared/lib/cn";
1415
import { Button } from "@/shared/ui/button";
1516
import {
1617
DropdownMenu,
@@ -19,9 +20,15 @@ import {
1920
DropdownMenuRadioItem,
2021
DropdownMenuTrigger,
2122
} from "@/shared/ui/dropdown-menu";
23+
import { SegmentedControl } from "@/shared/ui/segmented-control";
2224
import { EditableInfoFieldRow } from "./ChannelManagementSheetRows";
2325
import { ChannelTypePicker } from "./ChannelTypePicker";
2426

27+
const CHANNEL_TYPE_OPTIONS = [
28+
{ value: "temporary", label: "Temporary", Icon: ClockFading },
29+
{ value: "ongoing", label: "Ongoing", Icon: Hash },
30+
] as const;
31+
2532
const EPHEMERAL_TIMEOUT_OPTIONS = [
2633
{ label: "30 minutes", seconds: 30 * 60 },
2734
{ label: "1 hour", seconds: 60 * 60 },
@@ -76,6 +83,7 @@ export function ChannelTypeSettings({
7683
temporary,
7784
testIdPrefix,
7885
ttlSeconds,
86+
variant = "dropdown",
7987
}: {
8088
channelId?: string | null;
8189
disabled?: boolean;
@@ -87,6 +95,7 @@ export function ChannelTypeSettings({
8795
temporary: boolean;
8896
testIdPrefix: string;
8997
ttlSeconds: number;
98+
variant?: "dropdown" | "segmented";
9099
}) {
91100
const projectHome = useIsProjectHomeChannel(channelId);
92101
const lifecycle = channelLifecycle({ projectHome, temporary });
@@ -116,18 +125,39 @@ export function ChannelTypeSettings({
116125
className="flex items-center justify-between gap-3 px-3 py-3"
117126
data-testid={`${testIdPrefix}-channel-type-row`}
118127
>
119-
<span className="text-sm font-medium text-foreground">{label}</span>
120-
<ChannelTypePicker
121-
align="end"
122-
allowProject={projectHome}
123-
className="-mr-2.5"
124-
disabled={disabled}
125-
lifecycle={lifecycle}
126-
onLifecycleChange={(next) => onTemporaryChange(next === "temporary")}
127-
onOpenChange={onOpenChange}
128-
open={open}
129-
testId={`${testIdPrefix}-channel-type`}
130-
/>
128+
<span
129+
className={cn(
130+
"text-sm font-medium text-foreground",
131+
disabled && variant === "segmented" && "opacity-50",
132+
)}
133+
>
134+
{label}
135+
</span>
136+
{variant === "segmented" ? (
137+
<SegmentedControl
138+
disabled={disabled}
139+
legend="Channel type"
140+
onValueChange={(value) => onTemporaryChange(value === "temporary")}
141+
optionTestIdPrefix={`${testIdPrefix}-channel-type-option`}
142+
options={CHANNEL_TYPE_OPTIONS}
143+
testId={`${testIdPrefix}-channel-type`}
144+
value={temporary ? "temporary" : "ongoing"}
145+
/>
146+
) : (
147+
<ChannelTypePicker
148+
align="end"
149+
allowProject={projectHome}
150+
className="-mr-2.5"
151+
disabled={disabled}
152+
lifecycle={lifecycle}
153+
onLifecycleChange={(next) =>
154+
onTemporaryChange(next === "temporary")
155+
}
156+
onOpenChange={onOpenChange}
157+
open={open}
158+
testId={`${testIdPrefix}-channel-type`}
159+
/>
160+
)}
131161
</div>
132162
<AnimatePresence initial={false}>
133163
{temporary && !projectHome ? (
@@ -144,7 +174,10 @@ export function ChannelTypeSettings({
144174
data-testid={`${testIdPrefix}-ephemeral-settings`}
145175
>
146176
<label
147-
className="text-sm font-medium"
177+
className={cn(
178+
"text-sm font-medium",
179+
disabled && variant === "segmented" && "opacity-50",
180+
)}
148181
htmlFor={`${testIdPrefix}-ttl`}
149182
>
150183
Expires after

desktop/src/features/sidebar/lib/useCreateChannelForm.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ export type CreateChannelFormState = {
4141
setEphemeral: (value: boolean) => void;
4242
ttlSeconds: number;
4343
setTtlSeconds: (value: number) => void;
44-
typePopoverOpen: boolean;
45-
setTypePopoverOpen: (open: boolean) => void;
4644
errorMessage: string | null;
4745
selectedTemplateId: string | null;
4846
handleTemplateChange: (templateId: string) => void;
@@ -79,7 +77,6 @@ export function useCreateChannelForm({
7977
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
8078
string | null
8179
>(null);
82-
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
8380
const nameInputRef = React.useRef<HTMLInputElement>(null);
8481
const visibilityTouchedRef = React.useRef(false);
8582

@@ -97,7 +94,6 @@ export function useCreateChannelForm({
9794
setTtlSeconds(DEFAULT_EPHEMERAL_TTL_SECONDS);
9895
setErrorMessage(null);
9996
setSelectedTemplateId(null);
100-
setTypePopoverOpen(false);
10197
visibilityTouchedRef.current = false;
10298

10399
if (!autoFocusName) return;
@@ -211,8 +207,6 @@ export function useCreateChannelForm({
211207
setEphemeral,
212208
ttlSeconds,
213209
setTtlSeconds,
214-
typePopoverOpen,
215-
setTypePopoverOpen,
216210
errorMessage,
217211
selectedTemplateId,
218212
handleTemplateChange,

desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,30 +129,32 @@ export function CreateChannelFormFields({
129129
<ChannelTypeSettings
130130
disabled={isCreating}
131131
label="Type"
132-
onOpenChange={form.setTypePopoverOpen}
133132
onTemporaryChange={form.setEphemeral}
134133
onTtlSecondsChange={form.setTtlSeconds}
135-
open={form.typePopoverOpen}
136134
temporary={form.ephemeral}
137135
testIdPrefix="create-channel"
138136
ttlSeconds={form.ttlSeconds}
137+
variant="segmented"
139138
/>
140139

141140
<ChannelPermissionsSettings
142141
disabled={isCreating}
143142
onVisibilityChange={form.setVisibility}
144143
testIdPrefix="create-channel"
145144
visibility={form.visibility}
145+
variant="segmented"
146146
/>
147147

148148
<div
149-
className={cn(
150-
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
151-
isCreating && "opacity-50",
152-
)}
149+
className="flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3"
153150
data-testid="create-channel-template-container"
154151
>
155-
<span className="text-sm font-medium text-foreground">
152+
<span
153+
className={cn(
154+
"text-sm font-medium text-foreground",
155+
isCreating && "opacity-50",
156+
)}
157+
>
156158
Template
157159
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
158160
</span>

desktop/src/shared/ui/segmented-control.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
1919
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
2020
export function SegmentedControl<Value extends string>({
2121
className,
22+
disabled = false,
2223
indicatorTestId,
2324
legend,
2425
onPreviewChange,
@@ -30,6 +31,7 @@ export function SegmentedControl<Value extends string>({
3031
value,
3132
}: {
3233
className?: string;
34+
disabled?: boolean;
3335
indicatorTestId?: string;
3436
legend: string;
3537
onPreviewChange?: (value: Value | null) => void;
@@ -161,10 +163,12 @@ export function SegmentedControl<Value extends string>({
161163
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
162164
SIZE_CLASSES[size],
163165
onPreviewChange && "touch-none select-none cursor-ew-resize",
166+
"disabled:pointer-events-none disabled:opacity-50",
164167
className,
165168
)}
166169
data-slot="segmented-control"
167170
data-testid={testId}
171+
disabled={disabled}
168172
onLostPointerCapture={handleLostPointerCapture}
169173
onPointerCancel={handlePointerCancel}
170174
onPointerDown={handlePointerDown}

0 commit comments

Comments
 (0)