Skip to content

Commit e2139c8

Browse files
committed
feat(desktop): share the morphing primary pill between plugin and channel detail pages
1 parent 3f10a51 commit e2139c8

5 files changed

Lines changed: 109 additions & 88 deletions

File tree

desktop/src/renderer/components/channels/ChannelModuleDetailPage.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Settings } from 'lucide-react'
33
import { useT } from '../../contexts/LocaleContext'
44
import { CatalogBreadcrumb, CatalogTopBar } from '../catalog/CatalogSurface'
55
import { IconButton } from '../ui/IconButton'
6-
import { PluginInstallButton } from '../plugins/PluginInstallButton'
6+
import { MorphingActionPill } from '../plugins/MorphingActionPill'
77
import { IdentityMark, type IdentityMarkRole } from '../ui/IdentityMark'
88
import { ChannelFormPage } from './ChannelFormPage'
99
import styles from './ChannelModuleDetailPage.module.css'
@@ -93,14 +93,12 @@ export function ChannelModuleDetailPage({
9393
tooltipLabel={t('plugins.manage')}
9494
onClick={() => setMode('manage')}
9595
/>
96-
<PluginInstallButton
97-
variant={active ? 'secondary' : 'primary'}
96+
<MorphingActionPill
97+
label={t(active ? 'appBinding.disconnect' : 'appBinding.connect')}
9898
loading={busy}
9999
disabled={!controlsAvailable}
100100
onClick={onToggleConnection}
101-
>
102-
{t(active ? 'appBinding.disconnect' : 'appBinding.connect')}
103-
</PluginInstallButton>
101+
/>
104102
</div>
105103
</div>
106104
</header>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.measure {
2+
position: fixed;
3+
top: 0;
4+
left: 0;
5+
display: inline-flex;
6+
width: max-content;
7+
visibility: hidden;
8+
pointer-events: none;
9+
}
10+
11+
.slot {
12+
display: inline-flex;
13+
flex: 0 0 auto;
14+
overflow: hidden;
15+
justify-content: flex-end;
16+
}
17+
18+
.slot[data-transition-ready='true'] {
19+
transition: width 180ms cubic-bezier(.77, 0, .175, 1);
20+
}
21+
22+
.button {
23+
width: 100%;
24+
min-width: 0;
25+
flex-shrink: 0;
26+
}
27+
28+
:global(html[data-motion='reduced']) .slot,
29+
:global(html[data-reduce-motion='on']) .slot {
30+
transition-duration: 0ms !important;
31+
}
32+
33+
@media (prefers-reduced-motion: reduce) {
34+
.slot {
35+
transition-duration: 0ms !important;
36+
}
37+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useLayoutEffect, useRef, useState } from 'react'
2+
import type { JSX, ReactNode } from 'react'
3+
import { PluginInstallButton } from './PluginInstallButton'
4+
import styles from './MorphingActionPill.module.css'
5+
6+
/** The off-screen copy measures the next label so the visible slot can transition to that width. */
7+
export function MorphingActionPill({
8+
label,
9+
iconLeft,
10+
loading = false,
11+
disabled = false,
12+
onClick
13+
}: {
14+
label: string
15+
iconLeft?: ReactNode
16+
loading?: boolean
17+
disabled?: boolean
18+
onClick: () => void
19+
}): JSX.Element {
20+
const measureRef = useRef<HTMLSpanElement>(null)
21+
const [width, setWidth] = useState<number | null>(null)
22+
const [transitionReady, setTransitionReady] = useState(false)
23+
24+
useLayoutEffect(() => {
25+
const nextWidth = measureRef.current?.getBoundingClientRect().width
26+
if (nextWidth == null || nextWidth === 0) return
27+
setWidth(nextWidth)
28+
if (!transitionReady) {
29+
const frame = window.requestAnimationFrame(() => setTransitionReady(true))
30+
return () => window.cancelAnimationFrame(frame)
31+
}
32+
}, [label, loading, transitionReady])
33+
34+
return (
35+
<>
36+
<span ref={measureRef} className={styles.measure} aria-hidden="true">
37+
<PluginInstallButton variant="primary" loading={loading} disabled tabIndex={-1} iconLeft={iconLeft}>
38+
{label}
39+
</PluginInstallButton>
40+
</span>
41+
<span
42+
className={styles.slot}
43+
data-transition-ready={transitionReady ? 'true' : 'false'}
44+
style={width == null ? undefined : { width }}
45+
>
46+
<PluginInstallButton
47+
variant="primary"
48+
loading={loading}
49+
aria-busy={loading}
50+
disabled={disabled}
51+
onClick={onClick}
52+
iconLeft={iconLeft}
53+
className={styles.button}
54+
>
55+
{label}
56+
</PluginInstallButton>
57+
</span>
58+
</>
59+
)
60+
}

desktop/src/renderer/components/plugins/PluginDetailView.module.css

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,3 @@
2020
color: var(--text-primary);
2121
background: rgb(0 0 0 / 75%);
2222
}
23-
24-
.primaryMeasure {
25-
position: fixed;
26-
top: 0;
27-
left: 0;
28-
display: inline-flex;
29-
width: max-content;
30-
visibility: hidden;
31-
pointer-events: none;
32-
}
33-
34-
.primarySlot {
35-
display: inline-flex;
36-
flex: 0 0 auto;
37-
overflow: hidden;
38-
justify-content: flex-end;
39-
}
40-
41-
.primarySlot[data-transition-ready='true'] {
42-
transition: width 180ms cubic-bezier(.77, 0, .175, 1);
43-
}
44-
45-
.primaryButton {
46-
width: 100%;
47-
min-width: 0;
48-
flex-shrink: 0;
49-
}
50-
51-
:global(html[data-motion='reduced']) .primarySlot,
52-
:global(html[data-reduce-motion='on']) .primarySlot {
53-
transition-duration: 0ms !important;
54-
}
55-
56-
@media (prefers-reduced-motion: reduce) {
57-
.primarySlot {
58-
transition-duration: 0ms !important;
59-
}
60-
}

desktop/src/renderer/components/plugins/PluginDetailView.tsx

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useLayoutEffect, useRef, useState } from 'react'
1+
import { useState } from 'react'
22
import type { CSSProperties, MouseEvent } from 'react'
33
import { Anchor, Box, Code2, Ellipsis, ExternalLink, Link, MessageCircle, Plus, Server, Settings, Trash2, Wrench } from 'lucide-react'
44
import { useT } from '../../contexts/LocaleContext'
@@ -15,8 +15,8 @@ import { Button } from '../ui/Button'
1515
import { ContextMenu, type ContextMenuPosition } from '../ui/ContextMenu'
1616
import { IconButton } from '../ui/IconButton'
1717
import { AppBindingPanel } from './AppBindingPanel'
18+
import { MorphingActionPill } from './MorphingActionPill'
1819
import { PluginIcon, pluginSubtitle, pluginTitle } from './PluginCatalogItem'
19-
import { PluginInstallButton } from './PluginInstallButton'
2020
import { displayCategory } from './pluginCatalogModel'
2121
import styles from './PluginDetailView.module.css'
2222

@@ -242,50 +242,14 @@ function PluginPrimaryAction({
242242
: t('plugins.enable')
243243
const showInstallIcon = !installed && !pending
244244
const showTryIcon = installed && enabled && !pending
245-
const measureRef = useRef<HTMLSpanElement>(null)
246-
const [width, setWidth] = useState<number | null>(null)
247-
const [transitionReady, setTransitionReady] = useState(false)
248-
249-
useLayoutEffect(() => {
250-
const nextWidth = measureRef.current?.getBoundingClientRect().width
251-
if (nextWidth == null || nextWidth === 0) return
252-
setWidth(nextWidth)
253-
if (!transitionReady) {
254-
const frame = window.requestAnimationFrame(() => setTransitionReady(true))
255-
return () => window.cancelAnimationFrame(frame)
256-
}
257-
}, [enabled, installed, label, pending, transitionReady])
258245

259246
return (
260-
<>
261-
<span ref={measureRef} className={styles.primaryMeasure} aria-hidden="true">
262-
<PluginInstallButton
263-
variant="primary"
264-
loading={pending}
265-
disabled
266-
tabIndex={-1}
267-
iconLeft={showInstallIcon ? <Plus size={14} /> : showTryIcon ? <MessageCircle size={14} /> : undefined}
268-
>
269-
{label}
270-
</PluginInstallButton>
271-
</span>
272-
<span
273-
className={styles.primarySlot}
274-
data-transition-ready={transitionReady ? 'true' : 'false'}
275-
style={width == null ? undefined : { width }}
276-
>
277-
<PluginInstallButton
278-
variant="primary"
279-
loading={pending}
280-
aria-busy={pending}
281-
onClick={!installed ? onInstall : enabled ? onTryInChat : onEnable}
282-
iconLeft={showInstallIcon ? <Plus size={14} /> : showTryIcon ? <MessageCircle size={14} /> : undefined}
283-
className={styles.primaryButton}
284-
>
285-
{label}
286-
</PluginInstallButton>
287-
</span>
288-
</>
247+
<MorphingActionPill
248+
label={label}
249+
loading={pending}
250+
iconLeft={showInstallIcon ? <Plus size={14} /> : showTryIcon ? <MessageCircle size={14} /> : undefined}
251+
onClick={!installed ? onInstall : enabled ? onTryInChat : onEnable}
252+
/>
289253
)
290254
}
291255

0 commit comments

Comments
 (0)