From 22b49be8ee821507910b4d29aa98cad4b6e673eb Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 15:41:00 +0000 Subject: [PATCH] feat(ui): label the pin control on app tiles and in the detail view The pin was a small icon-only button and hard to find. Each app tile now shows a button reading Pin, or Pinned with a filled pin, and the detail view has the same control in its header. Both toggle the same stored pin. The Apps grid lists pinned apps first, in pin order, then the others. The sidebar list and the server pins API are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto --- .../react-ui/e2e/addon-navigation.spec.js | 32 ++++++++++++++++++- .../react-ui/public/locales/en/addons.json | 2 ++ .../react-ui/src/components/SideSheet.jsx | 4 ++- .../src/components/addons/AddonTile.jsx | 12 ++----- .../src/components/addons/AppsView.jsx | 13 ++++++-- .../src/components/addons/PinControl.jsx | 25 +++++++++++++++ .../react-ui/src/components/addons/addons.css | 8 +++-- 7 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 core/http/react-ui/src/components/addons/PinControl.jsx diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 122c9405c..084f678c8 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -92,7 +92,10 @@ test.describe('Add-on navigation', () => { await expect(page).toHaveURL(/\/app\/addons$/) await expect(page.getByTestId('addon-tile')).toHaveCount(2) await expect(page.getByTestId('pin-toggle')).toHaveCount(2) - for (const toggle of await page.getByTestId('pin-toggle').all()) await expect(toggle).toHaveAttribute('aria-pressed', 'true') + for (const toggle of await page.getByTestId('pin-toggle').all()) { + await expect(toggle).toHaveAttribute('aria-pressed', 'true') + await expect(toggle).toHaveText('Pinned') + } await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click() await expect.poll(() => server.saved()).toEqual(['talk', 'studio']) }) @@ -106,12 +109,39 @@ test.describe('Add-on navigation', () => { const toggle = page.getByTestId('pin-toggle').first() await expect(toggle).toHaveAttribute('aria-pressed', 'false') await expect(toggle).toHaveAccessibleName(/Pin Notes/) + await expect(toggle).toHaveText('Pin') await toggle.focus() await page.keyboard.press('Enter') await expect(toggle).toHaveAttribute('aria-pressed', 'true') + await expect(toggle).toHaveText('Pinned') expect(server.saved()).toEqual(['notes']) }) + test('pinned apps come first in the grid, in pin order, and the detail view has the same pin control', async ({ page }) => { + const server = await setup(page, { + pins: ['notes'], + apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })], + }) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/app/addons') + const tiles = page.getByTestId('addon-tile') + await expect(tiles.first()).toHaveAttribute('data-app', 'notes') + await expect(tiles.first().getByTestId('pin-toggle')).toHaveText('Pinned') + await expect(tiles.nth(1).getByTestId('pin-toggle')).toHaveText('Pin') + // The tile pin and the detail pin are one state. + await page.goto('/app/addons?detail=zeta') + const detail = page.getByTestId('addon-detail') + const head = detail.getByTestId('pin-toggle') + await expect(head).toHaveText('Pin') + await head.click() + await expect(head).toHaveText('Pinned') + await expect(head).toHaveAttribute('aria-pressed', 'true') + await expect.poll(() => server.saved()).toEqual(['notes', 'zeta']) + await head.click() + await expect(head).toHaveText('Pin') + await expect.poll(() => server.saved()).toEqual(['notes']) + }) + test('the first-run card shows to an administrator with nothing installed and leads to Browse', async ({ page }) => { await setup(page, { user: ADMIN, studio: false, pins: [] }) await page.goto('/app/addons') diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 85ee3fab9..043de95a3 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -104,6 +104,8 @@ "moveEarlier": "Move {{name}} earlier", "moveLater": "Move {{name}} later", "pin": "Pin {{name}}", + "pinLabel": "Pin", + "pinned": "Pinned", "running": "Running", "search": "Search add-ons", "status": { diff --git a/core/http/react-ui/src/components/SideSheet.jsx b/core/http/react-ui/src/components/SideSheet.jsx index e27d048cb..ed7559ea6 100644 --- a/core/http/react-ui/src/components/SideSheet.jsx +++ b/core/http/react-ui/src/components/SideSheet.jsx @@ -8,10 +8,11 @@ import { pushLayer } from '../utils/modalLayers' // click on the veil to close. The body scrolls; the footer holds the actions. // // title, description the head +// headActions controls drawn in the head, before the close button // footer buttons for the foot (primary action last) // wide the 720 px width // closeLabel accessible name of the close button -export default function SideSheet({ title, description, footer, onClose, wide = false, closeLabel = 'Close', testId, labelId = 'side-sheet-title', children }) { +export default function SideSheet({ title, description, footer, headActions, onClose, wide = false, closeLabel = 'Close', testId, labelId = 'side-sheet-title', children }) { const sheetRef = useRef(null) const closeRef = useRef(onClose) closeRef.current = onClose @@ -59,6 +60,7 @@ export default function SideSheet({ title, description, footer, onClose, wide =

{title}

{description &&

{description}

} + {headActions}
{children}
diff --git a/core/http/react-ui/src/components/addons/AddonTile.jsx b/core/http/react-ui/src/components/addons/AddonTile.jsx index ff88f191c..f6f0882b7 100644 --- a/core/http/react-ui/src/components/addons/AddonTile.jsx +++ b/core/http/react-ui/src/components/addons/AddonTile.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import Icon from '../Icon' import { apiUrl } from '../../utils/basePath' import { AppIcon } from './AddonBits' +import PinControl from './PinControl' // One tile of the drawer: icon, name, state and the pin control. A running // tile opens the add-on; every other tile opens the detail view. The pin @@ -36,16 +37,7 @@ export default function AddonTile({ tile, pinned, onPin, onMove, onOpenDetail }) )} - + onPin(tile.id)} /> ) } diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index 391fa3e9f..acdc4cb79 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -14,6 +14,7 @@ import SideSheet from '../SideSheet' import AddonSlot from '../../addons/AddonSlot' import { useHubAuth } from '../hub/useHubAuth' import AddonTile from './AddonTile' +import PinControl from './PinControl' import { useNavigation } from '../../context/NavigationContext' import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles' import { apiUrl } from '../../utils/basePath' @@ -107,8 +108,14 @@ export default function AppsView({ api }) { const tiles = useMemo(() => buildTiles(state.apps, nav.builtin, { language: i18n.language }), [state.apps, nav.builtin, i18n.language]) const titleOf = useCallback((tile) => (tile.titleKey ? tn(tile.titleKey) : tile.title), [tn]) - const shown = useMemo(() => filterTiles(tiles, query, titleOf), [tiles, query, titleOf]) - const pinnedIds = useMemo(() => new Set(resolvePins(nav.pinIds, tiles).map(x => x.id)), [nav.pinIds, tiles]) + const pinOrder = useMemo(() => resolvePins(nav.pinIds, tiles).map(x => x.id), [nav.pinIds, tiles]) + const pinnedIds = useMemo(() => new Set(pinOrder), [pinOrder]) + // Pinned apps come first, in pin order; the rest keep the catalog order. + const shown = useMemo(() => { + const found = filterTiles(tiles, query, titleOf) + const rank = new Map(pinOrder.map((id, i) => [id, i])) + return [...found.filter(x => rank.has(x.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id)), ...found.filter(x => !rank.has(x.id))] + }, [tiles, query, titleOf, pinOrder]) const detail = tiles.find(x => x.id === detailId) || null const onPin = async (id) => { @@ -182,7 +189,7 @@ export default function AppsView({ api }) { )} {!legacy &&
} {detail && ( - + onPin(detail.id)} />} closeLabel={t('drawer.close')} testId="addon-detail"> {detail.app ? ( <> diff --git a/core/http/react-ui/src/components/addons/PinControl.jsx b/core/http/react-ui/src/components/addons/PinControl.jsx new file mode 100644 index 000000000..e095f8405 --- /dev/null +++ b/core/http/react-ui/src/components/addons/PinControl.jsx @@ -0,0 +1,25 @@ +import { useTranslation } from 'react-i18next' + +// The pin control of an add-on, on its tile and in its detail view: a button +// with a visible word and a pin that is filled when the add-on is pinned. The +// accessible name keeps the add-on's name so a list of them stays unambiguous. +export default function PinControl({ name, pinned, onToggle, variant = 'tile' }) { + const { t } = useTranslation('addons') + return ( + + ) +} diff --git a/core/http/react-ui/src/components/addons/addons.css b/core/http/react-ui/src/components/addons/addons.css index a3030c11a..f0a8057b1 100644 --- a/core/http/react-ui/src/components/addons/addons.css +++ b/core/http/react-ui/src/components/addons/addons.css @@ -213,8 +213,12 @@ .ad-tile__order { flex: none; display: flex; flex-direction: column; justify-content: center; } .ad-tile__move { background: none; border: 0; padding: 0.125rem 0.5rem; color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; } .ad-tile__move:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } -.ad-tile__pin { flex: none; width: 2.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; } -.ad-tile__pin[aria-pressed='true'] { color: var(--dk-accent); } +.ad-tile__pin { flex: none; width: 4.25rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.25rem; padding: 0 0.25rem; font: inherit; font-size: 0.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; } +.ad-tile__pin:hover { background: var(--dk-hover); } +.ad-pin__icon { width: 1.125rem; height: 1.125rem; flex: none; } +.ad-pin__label { white-space: nowrap; } +.ad-tile__pin[aria-pressed='true'], .ad-pin[aria-pressed='true'] { color: var(--dk-accent); } +.ad-pin { gap: 0.375rem; } .ad-tile__open:focus-visible, .ad-tile__pin:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } .nav-item--muted { opacity: 0.55; } .nav-item--sub { padding-inline-start: 2.25rem; }