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