Label document share as copy link

Sharing only copies the viewer URL, so rename the action and
expose the same control on each documents list row.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-29 17:15:53 +02:00
parent 5469a960e2
commit 98e1177aab
16 changed files with 84 additions and 48 deletions

View File

@@ -18,7 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { LinkSimpleIcon } from "@phosphor-icons/react";
import { Checkbox } from "@probo/ui/src/v2/Checkbox/Checkbox"; import { Checkbox } from "@probo/ui/src/v2/Checkbox/Checkbox";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { ListItem } from "@probo/ui/src/v2/List/ListItem"; import { ListItem } from "@probo/ui/src/v2/List/ListItem";
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent"; import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
import { Text } from "@probo/ui/src/v2/typography/Text"; import { Text } from "@probo/ui/src/v2/typography/Text";
@@ -27,6 +30,7 @@ import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router"; import { Link as RouterLink } from "react-router";
import { DocumentAccessAction } from "./DocumentAccessAction"; import { DocumentAccessAction } from "./DocumentAccessAction";
import { documentEntry } from "./variants";
interface DocumentEntryProps { interface DocumentEntryProps {
// Primary line (document title, file name, or framework name). // Primary line (document title, file name, or framework name).
@@ -51,8 +55,9 @@ interface DocumentEntryProps {
} }
// Presentational row shared by the document / file / report list items: a title // Presentational row shared by the document / file / report list items: a title
// with accent metadata and the trailing access action. On small screens the // with accent metadata, a copy-link control, and the trailing access action. On
// whole row is the hit target (the trailing icon is a status affordance only). // small screens the whole row is the hit target (the trailing icon is a status
// affordance only).
export function DocumentEntry({ export function DocumentEntry({
title, title,
meta, meta,
@@ -65,6 +70,8 @@ export function DocumentEntry({
onSelectedChange, onSelectedChange,
}: DocumentEntryProps) { }: DocumentEntryProps) {
const { t } = useTranslation("documents"); const { t } = useTranslation("documents");
const toast = Toast.useToastManager();
const slots = documentEntry();
// Only locked rows (not yet authorized, no pending request) can be requested, // Only locked rows (not yet authorized, no pending request) can be requested,
// so only they are selectable — the checkbox is disabled otherwise to avoid a // so only they are selectable — the checkbox is disabled otherwise to avoid a
@@ -77,6 +84,14 @@ export function DocumentEntry({
? t("actions.view") ? t("actions.view")
: t("actions.getAccess"); : t("actions.getAccess");
const handleCopyLink = () => {
const url = new URL(viewHref, window.location.origin);
navigator.clipboard.writeText(url.href).then(
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
() => {},
);
};
return ( return (
<ListItem <ListItem
className={[ className={[
@@ -105,31 +120,42 @@ export function DocumentEntry({
</Text> </Text>
</ListItemContent> </ListItemContent>
{/* Desktop: labeled interactive control. */} <div className={slots.trailing()}>
<div className="max-sm:hidden"> <IconButton
<DocumentAccessAction variant="ghost"
isAuthorized={isAuthorized} color="neutral"
requested={requested} aria-label={t("viewer.copyLink")}
viewHref={viewHref} onClick={handleCopyLink}
onGetAccess={onGetAccess} >
isRequesting={isRequesting} <LinkSimpleIcon />
/> </IconButton>
</div>
{/* Mobile: status icon only; the row overlay handles activation. {/* Desktop: labeled interactive control. */}
Pending (`requested`) rows keep the icon in the a11y tree as status. */} <div className={slots.accessDesktop()}>
<div <DocumentAccessAction
className="hidden shrink-0 max-sm:block" isAuthorized={isAuthorized}
aria-hidden={mobileHitLabel != null ? true : undefined} requested={requested}
> viewHref={viewHref}
<DocumentAccessAction onGetAccess={onGetAccess}
isAuthorized={isAuthorized} isRequesting={isRequesting}
requested={requested} />
viewHref={viewHref} </div>
onGetAccess={onGetAccess}
isRequesting={isRequesting} {/* Mobile: status icon only; the row overlay handles activation.
interactive={false} Pending (`requested`) rows keep the icon in the a11y tree as status. */}
/> <div
className={slots.accessMobile()}
aria-hidden={mobileHitLabel != null ? true : undefined}
>
<DocumentAccessAction
isAuthorized={isAuthorized}
requested={requested}
viewHref={viewHref}
onGetAccess={onGetAccess}
isRequesting={isRequesting}
interactive={false}
/>
</div>
</div> </div>
{/* Sit above the row content so title / icon areas are part of the hit target. */} {/* Sit above the row content so title / icon areas are part of the hit target. */}

View File

@@ -23,9 +23,9 @@ import {
CaretLeftIcon, CaretLeftIcon,
CaretRightIcon, CaretRightIcon,
DownloadSimpleIcon, DownloadSimpleIcon,
LinkSimpleIcon,
MagnifyingGlassMinusIcon, MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon, MagnifyingGlassPlusIcon,
ShareNetworkIcon,
SpinnerGapIcon, SpinnerGapIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button"; import { Button } from "@probo/ui/src/v2/Button/Button";
@@ -64,8 +64,8 @@ interface DocumentViewerProps {
} }
// Full-page document viewer: a header band with the title and a toolbar // Full-page document viewer: a header band with the title and a toolbar
// (page navigation + zoom for PDFs, share, download) above the scrollable body. // (page navigation + zoom for PDFs, copy link, download) above the scrollable
// PDFs render with react-pdf, images inline, and anything else offers a // body. PDFs render with react-pdf, images inline, and anything else offers a
// download. // download.
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) { export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
const { t } = useTranslation("documents"); const { t } = useTranslation("documents");
@@ -87,7 +87,7 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
setCurrentPage(next); setCurrentPage(next);
}; };
const handleShare = () => { const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href).then( navigator.clipboard.writeText(window.location.href).then(
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }), () => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
() => {}, () => {},
@@ -168,11 +168,11 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
<Button <Button
variant="ghost" variant="ghost"
color="neutral" color="neutral"
iconStart={<ShareNetworkIcon />} iconStart={<LinkSimpleIcon />}
onClick={handleShare} onClick={handleCopyLink}
aria-label={t("viewer.share")} aria-label={t("viewer.copyLink")}
> >
<span className={slots.actionLabel()}>{t("viewer.share")}</span> <span className={slots.actionLabel()}>{t("viewer.copyLink")}</span>
</Button> </Button>
<Separator orientation="vertical" className={slots.separator()} /> <Separator orientation="vertical" className={slots.separator()} />
<Button <Button

View File

@@ -30,6 +30,16 @@ export const documentSection = tv({
}, },
}); });
// Document list row trailing chrome: copy-link + access action. z-2 sits above
// the mobile full-row overlay so these controls stay tappable on small screens.
export const documentEntry = tv({
slots: {
trailing: "relative z-2 flex shrink-0 items-center gap-1",
accessDesktop: "max-sm:hidden",
accessMobile: "hidden shrink-0 max-sm:block",
},
});
// PDF preview: a scrollable grey stage holding the stacked, centered pages. // PDF preview: a scrollable grey stage holding the stacked, centered pages.
export const pdfPreview = tv({ export const pdfPreview = tv({
slots: { slots: {
@@ -48,7 +58,7 @@ export const documentViewer = tv({
root: "flex h-full min-h-0 flex-1 flex-col", root: "flex h-full min-h-0 flex-1 flex-col",
header: "flex w-full flex-col gap-3", header: "flex w-full flex-col gap-3",
back: "-ml-2 self-start", back: "-ml-2 self-start",
// Stay on one row so page/zoom controls and share/download share a baseline; // Stay on one row so page/zoom controls and copy/download share a baseline;
// icon-only action labels on max-sm keep this viable on phones. // icon-only action labels on max-sm keep this viable on phones.
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2", toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2", toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Dokumente", "back": "Dokumente",
"share": "Dokument teilen", "copyLink": "Link kopieren",
"linkCopied": "Link in die Zwischenablage kopiert", "linkCopied": "Link in die Zwischenablage kopiert",
"download": "Herunterladen", "download": "Herunterladen",
"previewUnavailable": "Vorschau nicht verfügbar", "previewUnavailable": "Vorschau nicht verfügbar",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Documents", "back": "Documents",
"share": "Share document", "copyLink": "Copy link",
"linkCopied": "Link copied to clipboard", "linkCopied": "Link copied to clipboard",
"download": "Download", "download": "Download",
"previewUnavailable": "Preview unavailable", "previewUnavailable": "Preview unavailable",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Documentos", "back": "Documentos",
"share": "Compartir documento", "copyLink": "Copiar enlace",
"linkCopied": "Enlace copiado al portapapeles", "linkCopied": "Enlace copiado al portapapeles",
"download": "Descargar", "download": "Descargar",
"previewUnavailable": "Vista previa no disponible", "previewUnavailable": "Vista previa no disponible",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Documents", "back": "Documents",
"share": "Partager le document", "copyLink": "Copier le lien",
"linkCopied": "Lien copié dans le presse-papiers", "linkCopied": "Lien copié dans le presse-papiers",
"download": "Télécharger", "download": "Télécharger",
"previewUnavailable": "Aperçu indisponible", "previewUnavailable": "Aperçu indisponible",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Dokumen", "back": "Dokumen",
"share": "Bagikan dokumen", "copyLink": "Salin tautan",
"linkCopied": "Tautan disalin ke clipboard", "linkCopied": "Tautan disalin ke clipboard",
"download": "Unduh", "download": "Unduh",
"previewUnavailable": "Pratinjau tidak tersedia", "previewUnavailable": "Pratinjau tidak tersedia",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Documenti", "back": "Documenti",
"share": "Condividi documento", "copyLink": "Copia collegamento",
"linkCopied": "Link copiato negli appunti", "linkCopied": "Link copiato negli appunti",
"download": "Scarica", "download": "Scarica",
"previewUnavailable": "Anteprima non disponibile", "previewUnavailable": "Anteprima non disponibile",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "ドキュメント", "back": "ドキュメント",
"share": "ドキュメントを共有", "copyLink": "リンクをコピー",
"linkCopied": "リンクをクリップボードにコピーしました", "linkCopied": "リンクをクリップボードにコピーしました",
"download": "ダウンロード", "download": "ダウンロード",
"previewUnavailable": "プレビューは利用できません", "previewUnavailable": "プレビューは利用できません",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "문서", "back": "문서",
"share": "문서 공유", "copyLink": "링크 복사",
"linkCopied": "링크가 클립보드에 복사되었습니다", "linkCopied": "링크가 클립보드에 복사되었습니다",
"download": "다운로드", "download": "다운로드",
"previewUnavailable": "미리보기를 사용할 수 없습니다", "previewUnavailable": "미리보기를 사용할 수 없습니다",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Dokumenty", "back": "Dokumenty",
"share": "Udostępnij dokument", "copyLink": "Kopiuj link",
"linkCopied": "Link skopiowany do schowka", "linkCopied": "Link skopiowany do schowka",
"download": "Pobierz", "download": "Pobierz",
"previewUnavailable": "Podgląd niedostępny", "previewUnavailable": "Podgląd niedostępny",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Documentos", "back": "Documentos",
"share": "Partilhar documento", "copyLink": "Copiar ligação",
"linkCopied": "Link copiado para a área de transferência", "linkCopied": "Link copiado para a área de transferência",
"download": "Transferir", "download": "Transferir",
"previewUnavailable": "Pré-visualização indisponível", "previewUnavailable": "Pré-visualização indisponível",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Belgeler", "back": "Belgeler",
"share": "Belgeyi paylaş", "copyLink": "Bağlantıyı kopyala",
"linkCopied": "Bağlantı panoya kopyalandı", "linkCopied": "Bağlantı panoya kopyalandı",
"download": "İndir", "download": "İndir",
"previewUnavailable": "Önizleme kullanılamıyor", "previewUnavailable": "Önizleme kullanılamıyor",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "Документи", "back": "Документи",
"share": "Поділитися документом", "copyLink": "Копіювати посилання",
"linkCopied": "Посилання скопійовано до буфера обміну", "linkCopied": "Посилання скопійовано до буфера обміну",
"download": "Завантажити", "download": "Завантажити",
"previewUnavailable": "Попередній перегляд недоступний", "previewUnavailable": "Попередній перегляд недоступний",

View File

@@ -40,7 +40,7 @@
}, },
"viewer": { "viewer": {
"back": "文档", "back": "文档",
"share": "分享文档", "copyLink": "复制链接",
"linkCopied": "链接已复制到剪贴板", "linkCopied": "链接已复制到剪贴板",
"download": "下载", "download": "下载",
"previewUnavailable": "无法预览", "previewUnavailable": "无法预览",