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; }