diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 2a111e65b..72a071dfe 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -21,7 +21,6 @@ import path from '../../lib/path.js'; import item_icon from '../../helpers/itemIcon.js'; import { is_owned_by_me, owner_of_path } from '../../helpers/pathOwner.js'; import { invalidate_shared_roots } from '../../helpers/sharedAccess.js'; -import { icons } from '../../helpers/actionIcons.js'; import { mode_label, options_for } from '../../helpers/shareModes.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js'; @@ -43,6 +42,19 @@ const { html_encode } = window; const closeIcon = ``; const chevronIcon = ``; +const linkIcon = ``; +const lockIcon = ``; +const globeIcon = ``; +const checkIcon = ``; + +// The option at the foot of a person's access menu that takes their access +// away. Never a mode the API knows, so it can't be confused with one. +const REMOVE_OPTION = '__remove'; + +// How long a success message stays up. Errors stay until the next action. +const SUCCESS_MS = 4000; +// How long Copy link reads "Link copied" before going back. +const COPIED_MS = 2000; // How many item icons the header fans out before it stops adding to the pile. const MAX_STACKED_ICONS = 3; @@ -186,31 +198,43 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
- + ${allow_manage ? `` : ''} + + @@ -226,9 +250,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const $list = $overlay.find('.share-modal-list'); const $recipient = $overlay.find('.share-modal-recipient'); const $submit = $overlay.find('.share-modal-submit'); + const $add_actions = $overlay.find('.share-modal-add-actions'); const $link_access = $overlay.find('.share-modal-link-access'); const $link_mode = $overlay.find('.share-modal-link-mode'); const $link_note = $overlay.find('.share-modal-general-note'); + const $link_icon = $overlay.find('.share-modal-general-icon'); const $copy_link = $overlay.find('.share-modal-copy-link'); /** @@ -238,6 +264,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse let link_state = { access: total > MAX_ITEMS_PER_REQUEST ? 'unchecked' : 'restricted', mode: null }; /** The one item's uid, which its link is built on. Single item only. */ let single_uid = is_multi ? null : (targets[0].fsentry?.uid ?? null); + $copy_link.prop('hidden', ! single_uid); // Focus returns to wherever the user was (usually the shared row) when // the modal closes. While it's up, the recipient input takes it on @@ -251,11 +278,18 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse $recipient.get(0)?.focus({ preventScroll: true }); } + // A success confirms what the list already shows, so it fades on its own; + // an error stays until the user does something else. + let status_timer = null; + let copied_timer = null; + let closed = false; const close = () => { if ( closed ) return; closed = true; $overlay.removeClass('share-modal-show'); + clearTimeout(status_timer); + clearTimeout(copied_timer); $(document).off(`keydown.share-modal-${modal_id}`); // The recipient field listens on the document to know when a click // landed outside its suggestions; that has to come off with the dialog. @@ -302,6 +336,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // Both take HTML-safe text; `error_html` encodes the one raw source. const show_error = (html) => { + clearTimeout(status_timer); $status .removeClass('share-modal-status-success') .addClass('share-modal-status-error') @@ -309,10 +344,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse }; const show_success = (html) => { + clearTimeout(status_timer); $status .removeClass('share-modal-status-error') .addClass('share-modal-status-success') .html(html); + status_timer = setTimeout(() => { + if ( $status.hasClass('share-modal-status-success') ) clear_status(); + }, SUCCESS_MS); }; const error_html = (err) => (err?.message @@ -392,24 +431,24 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse row += via; row += ''; - if ( can_change ) { - // The accessible names carry the person: a list where every row - // reads as bare "Access level" / "Remove access" leaves a screen - // reader user unable to tell whose grant a control changes. - row += ``; - } else { - let fixed_mode = group.inheritedMode; - if ( group.pending ) fixed_mode = group.pendingMode; - else if ( group.directPaths.length ) fixed_mode = group.mode; - row += ``; - } + let fixed_mode = group.inheritedMode; + if ( group.pending ) fixed_mode = group.pendingMode; + else if ( group.directPaths.length ) fixed_mode = group.mode; + const fixed_label = fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed'); - if ( can_revoke ) { - const label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access'); - const aria = group.pending - ? i18n('share_cancel_invite_for', { recipient: group.name }) - : i18n('share_remove_access_for', { recipient: group.name }); - row += ``; + if ( can_change || can_revoke ) { + // One menu per person: their access level, then a way to take it + // away. A grant they can only withdraw shows its level as a fixed + // first entry. The accessible name carries the person, so a screen + // reader user can tell whose access the control changes. + const levels = can_change + ? options_for(group.mode, { allow_manage }) + : ``; + const remove_label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access'); + row += ``; + } else { + row += ``; } row += ''; return row; @@ -500,8 +539,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse /** Put the control where the listings say the selection stands. */ const render_link_access = () => { + // The link opens the item for anyone who has access, so it's worth + // copying whether or not it's open to everyone. + $copy_link.prop('hidden', ! single_uid); if ( ! allow_manage ) return; const { access, mode } = link_state; + $link_icon + .toggleClass('share-modal-general-icon-open', access === 'anyone') + .html(access === 'anyone' ? globeIcon : (access === 'mixed' ? linkIcon : lockIcon)); $link_access.val(access); $link_mode.prop('hidden', access !== 'anyone'); // `null` rests the mode on the "Mixed" placeholder, as the rows do. @@ -517,11 +562,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse note = i18n(is_multi ? 'share_link_restricted_note_items' : 'share_link_restricted_note'); } $link_note.html(note); - $copy_link.prop('hidden', ! (access === 'anyone' && single_uid)); }; // -- Dismissal wiring -- - $overlay.on('click', '.share-modal-close', close); + $overlay.on('click', '.share-modal-close, .share-modal-done', close); // Backdrop close goes by where the press STARTED: a drag that begins in // the recipient input (text selection) and releases over the backdrop // registers its click on the overlay, and must not eat the typed name. @@ -542,7 +586,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse if ( $confirm.length ) { const key = $confirm.attr('data-key'); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); return; } close(); @@ -678,6 +722,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const mode = $(this).val(); if ( mode ) set_link_access(mode, this); }); + // The button confirms the copy itself, then goes back to what it was. $overlay.on('click', '.share-modal-copy-link', async function () { if ( ! single_uid ) return; try { @@ -685,14 +730,22 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse { owner: targets[0].owner, uid: single_uid, name: targets[0].name }, window.gui_origin, )); - show_success(i18n('share_link_copied')); + clearTimeout(copied_timer); + $copy_link.addClass('share-modal-copied'); + $copy_link.find('.share-modal-copy-icon').html(checkIcon); + $copy_link.find('.share-modal-copy-label').html(i18n('share_link_copied')); + copied_timer = setTimeout(() => { + $copy_link.removeClass('share-modal-copied'); + $copy_link.find('.share-modal-copy-icon').html(linkIcon); + $copy_link.find('.share-modal-copy-label').html(i18n('share_copy_link')); + }, COPIED_MS); } catch (err) { show_error(error_html(err)); } }); // The uid the link is built on, when the row did not carry it. - if ( allow_manage && ! is_multi && ! single_uid ) { + if ( ! is_multi && ! single_uid ) { puter.fs.stat(targets[0].path).then((stat) => { if ( closed ) return; single_uid = stat?.uid ?? null; @@ -706,6 +759,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // A share on its way keeps the button down, though the field is edited. let sharing = false; + // The level and Share button only show once there is someone to share + // with, and stay while a share is on its way. + const sync_add_actions = () => { + const chosen = picker.recipient(); + $submit.prop('disabled', sharing || ! chosen); + $add_actions.prop('hidden', ! (chosen || sharing)); + }; + // One field for every kind of recipient: a colleague, a whole team, someone // shared with before, or an address typed from scratch. const picker = shareRecipientPicker({ @@ -715,7 +776,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // re-grant what their row already shows. excluded: () => last_groups.map((group) => group.key), onChange: () => { - $submit.prop('disabled', sharing || ! picker.recipient()); + sync_add_actions(); // Choosing again retires a stale success/error message. clear_status(); }, @@ -757,7 +818,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse sharing = false; // Clear only what we sent; a name typed mid-flight shouldn't vanish. if ( $recipient.val().trim() === recipient ) picker.clear(); - $submit.prop('disabled', ! picker.recipient()); + sync_add_actions(); // A pair the backend refused doesn't fail the others, so say how // many items actually landed rather than implying all of them did. const granted = created?.length ?? 0; @@ -788,7 +849,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse } catch (err) { sharing = false; show_error(error_html(err)); - $submit.prop('disabled', ! picker.recipient()); + sync_add_actions(); // Disabling the clicked button dropped focus to ; put it // where the correction happens. $recipient.get(0)?.focus({ preventScroll: true }); @@ -803,6 +864,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const group = group_for(key); const mode = $(this).val(); if ( ! group || ! mode ) return; + if ( mode === REMOVE_OPTION ) { + confirm_revoke(key); + return; + } $(this).prop('disabled', true); try { await grant_access(recipient_of(group), mode, group.directPaths); @@ -841,9 +906,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog(); }); - // -- Revoke, confirmed inline in the row -- - $overlay.on('click', '.share-modal-revoke', function () { - const key = $(this).attr('data-key'); + // -- Revoke, picked from the person's menu and confirmed inline in the row -- + const confirm_revoke = (key) => { const group = group_for(key); if ( ! group ) return; // An invitation is withdrawn, access is taken away; the prompt must @@ -866,11 +930,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // One confirmation at a time: opening a second one restores the first // row. The re-render replaces every row, so re-find this person's - // instead of using the (now detached) clicked button. + // instead of using the (now detached) menu that was changed. if ( $list.find('.share-modal-row-confirm').length ) { render(last_groups); } - $list.find('.share-modal-revoke') + $list.find('.share-modal-row-mode') .filter((_, el) => $(el).attr('data-key') === key) .closest('.share-modal-row') .replaceWith(` @@ -883,12 +947,12 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse `); $list.find('.share-modal-confirm-cancel').trigger('focus'); - }); + }; $overlay.on('click', '.share-modal-confirm-cancel', function () { const key = $(this).closest('.share-modal-row-confirm').attr('data-key'); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); }); $overlay.on('click', '.share-modal-confirm-remove', async function () { @@ -925,7 +989,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse } catch (err) { show_error(error_html(err)); render(last_groups); - focus_list_control('.share-modal-revoke', key) || focus_dialog(); + focus_list_control('.share-modal-row-mode', key) || focus_dialog(); } }); diff --git a/src/gui/src/UI/Dashboard/UIShareModal.test.js b/src/gui/src/UI/Dashboard/UIShareModal.test.js index a8f4c1c81..e8e427293 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.test.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.test.js @@ -124,7 +124,7 @@ describe('UIShareModal removing access', () => { UIShareModal({ path: '/alice/a.txt' }); await settle(); - $('.share-modal-revoke').trigger('click'); + $('.share-modal-row-mode').val('__remove').trigger('change'); $('.share-modal-confirm-remove').trigger('click'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); @@ -133,9 +133,77 @@ describe('UIShareModal removing access', () => { finish(); await settle(); }); + + it('asks before removing, and removes nothing until confirmed', async () => { + puter.fs.getShares = vi.fn(async () => [{ holder: 'bob', mode: 'read', issuer: 'alice' }]); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + + $('.share-modal-row-mode').val('__remove').trigger('change'); + expect(puter.fs.share).not.toHaveBeenCalled(); + expect(puter.fs.unshare).not.toHaveBeenCalled(); + expect($('.share-modal-row-confirm').length).toBe(1); + + $('.share-modal-confirm-cancel').trigger('click'); + expect($('.share-modal-row-confirm').length).toBe(0); + expect($('.share-modal-row-mode').val()).toBe('read'); + + $('.share-modal-row-mode').val('__remove').trigger('change'); + $('.share-modal-confirm-remove').trigger('click'); + await settle(); + expect(puter.fs.unshare).toHaveBeenCalledWith({ paths: ['/alice/a.txt'], recipient: 'bob' }); + }); + + it('offers cancelling an invitation from its menu', async () => { + puter.fs.getShares = vi.fn(async () => [ + { holder: null, recipientEmail: 'carol@example.com', pending: true, mode: 'write', issuer: 'alice' }, + ]); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + + const $menu = $('.share-modal-row-mode'); + expect($menu.length).toBe(1); + expect($menu.find('option[value="__remove"]').text()).toBe(i18n('share_cancel_invite')); + }); +}); + +describe('UIShareModal footer', () => { + it('offers the link for one item even while it is restricted', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + expect($('.share-modal-copy-link').prop('hidden')).toBe(false); + expect($('.share-modal-link-access').val()).toBe('restricted'); + }); + + it('has no link to copy for several items', async () => { + UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] }); + await settle(); + expect($('.share-modal-copy-link').length).toBe(0); + }); + + it('closes on Done', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + $('.share-modal-done').trigger('click'); + expect($('.share-modal-overlay').hasClass('share-modal-show')).toBe(false); + }); }); describe('UIShareModal adding people', () => { + it('shows the level and Share only once someone is entered', async () => { + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(true); + + picked.recipient = { value: 'bob', label: 'bob' }; + picked.onChange(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(false); + + picked.recipient = null; + picked.onChange(); + expect($('.share-modal-add-actions').prop('hidden')).toBe(true); + }); + it('keeps Share down while a share is on its way, though the name is edited', async () => { let finish; puter.fs.share = vi.fn(() => new Promise((resolve) => { diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index a027c98a9..6e723ba56 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2770,6 +2770,35 @@ button.share-modal-title-sub:focus-visible { padding: 16px 18px 18px; } +/* Footer: the status line, then Copy link and Done. Outside the scrolling + body, so a message is in view wherever the list is scrolled to. */ +.share-modal-footer { + flex-shrink: 0; + padding: 12px 18px 14px; + border-top: 1px solid var(--dashboard-border); +} +.share-modal-footer-row { + display: flex; + align-items: center; + gap: 8px; +} +.share-modal-done { + margin-left: auto; +} +.share-modal-copy-link { + gap: 6px; +} +.share-modal-copy-link[hidden] { + display: none; +} +.share-modal-copy-icon { + display: flex; +} +.share-modal-copy-link.share-modal-copied { + border-color: var(--dashboard-success-border); + color: var(--dashboard-success-text); +} + /* Add people. style.css's input[type=text] / select rules match these controls at the SAME specificity, so everything must be spelled out here (twice for focus — input[type=text]:focus sets padding 7px / border 2px). @@ -2778,12 +2807,25 @@ button.share-modal-title-sub:focus-visible { display: flex; gap: 8px; } +.share-modal-add-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + animation: share-modal-reveal 150ms ease-out; +} +.share-modal-add-actions[hidden] { + display: none; +} +@keyframes share-modal-reveal { + from { opacity: 0; transform: translateX(6px); } +} input.share-modal-recipient { -webkit-appearance: none; appearance: none; box-sizing: border-box; flex: 1 1 auto; width: auto; + height: 36px; min-width: 0; /* On narrow screens the placeholder outgrows the field; fade it out with an ellipsis instead of clipping mid-letter. */ @@ -2834,6 +2876,9 @@ select.share-modal-mode, select.share-modal-link-mode { flex-shrink: 0; } +select.share-modal-mode { + height: 36px; +} select.share-modal-mode:focus, select.share-modal-link-access:focus, select.share-modal-link-mode:focus, @@ -2875,56 +2920,75 @@ select.share-modal-row-mode:disabled { } /* General access: the owner's switch between people-only and anyone with - the link, below the people and team controls. The copy button is the team - button's quieter sibling. */ + the link, as one row shaped like a person's — an icon where the avatar + goes, the switch and what it means, and the level on the right. */ .share-modal-general { - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--dashboard-border); + margin-top: 20px; } .share-modal-general .share-modal-heading { - margin: 0 0 8px; + margin: 0 0 6px; } .share-modal-general-row { display: flex; - gap: 8px; + align-items: center; + gap: 10px; + padding: 4px 0; } -select.share-modal-link-access { +.share-modal-general-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--dashboard-hover); + color: var(--dashboard-text-secondary); + transition: background 0.15s, color 0.15s; +} + +.share-modal-general-icon.share-modal-general-icon-open { + background: var(--dashboard-success-background); + color: var(--dashboard-success-text); +} + +.share-modal-general-text { flex: 1 1 auto; min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +/* Reads as the row's title; the padding lets the hover fill reach past the + text while the text itself lines up with the note below. */ +select.share-modal-link-access { + max-width: calc(100% + 8px); + margin-left: -8px; + padding: 3px 24px 3px 8px; + border-color: transparent; + background-color: transparent; + background-position: right 6px center; + font-size: 13.5px; + font-weight: 600; + field-sizing: content; +} +select.share-modal-link-access:focus { + padding: 3px 24px 3px 8px; +} +@media (hover: hover) { + select.share-modal-link-access:hover:not(:disabled) { + background-color: var(--dashboard-hover); + } } .share-modal-general-note { - margin: 8px 0 0; - font-size: 12.5px; - line-height: 1.45; - color: var(--dashboard-text-hint); -} - -.share-modal-copy-link { - width: 100%; - height: 38px; - margin-top: 10px; - padding: 0 16px; - border: 1px solid var(--dashboard-border); - border-radius: 10px; - background: transparent; - font-size: 13.5px; - font-weight: 600; - font-family: inherit; - color: var(--dashboard-text-primary); - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -@media (hover: hover) { - .share-modal-copy-link:hover:not(:disabled) { - background: var(--dashboard-card-background); - border-color: var(--dashboard-text-hint); - } + font-size: 12px; + line-height: 1.4; + color: var(--dashboard-text-muted); } .share-modal-submit { @@ -2932,14 +2996,14 @@ select.share-modal-link-access { align-items: center; justify-content: center; gap: 8px; - width: 100%; - margin-top: 10px; + flex-shrink: 0; + box-sizing: border-box; padding: 0 16px; - height: 38px; + height: 36px; border: 1px solid var(--dashboard-button-primary); - border-radius: 10px; + border-radius: 8px; background: var(--dashboard-button-primary); - font-size: 13.5px; + font-size: 13px; font-weight: 600; font-family: inherit; color: #fff; @@ -3002,16 +3066,16 @@ select.share-modal-link-access { display:none — a live region revealed in the same frame as its text is unreliably announced, so it stays in the a11y tree between messages. */ .share-modal-status { - margin-top: 12px; - padding: 9px 12px; + margin-bottom: 10px; + padding: 8px 12px; border: 1px solid transparent; border-radius: 8px; - font-size: 13px; + font-size: 12.5px; line-height: 1.45; word-break: break-word; } .share-modal-status:empty { - margin-top: 0; + margin-bottom: 0; padding: 0; border-width: 0; } @@ -3028,28 +3092,27 @@ select.share-modal-link-access { /* Access list */ .share-modal-heading { - margin: 18px 0 2px; font-size: 13px; font-weight: 600; color: var(--dashboard-text-secondary); } +.share-modal-list { + margin-top: 10px; +} .share-modal-row { display: flex; align-items: center; gap: 10px; - padding: 9px 0; - border-bottom: 1px solid var(--dashboard-border); -} -.share-modal-row:last-child { - border-bottom: none; + min-height: 32px; + padding: 5px 0; } .share-modal-avatar { flex-shrink: 0; display: flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: 32px; + height: 32px; border-radius: 50%; font-size: 13px; font-weight: 600; @@ -3116,50 +3179,41 @@ select.share-modal-link-access { font-size: 12.5px; color: var(--dashboard-text-secondary); } +select.share-modal-link-mode, select.share-modal-row-mode { flex-shrink: 0; + max-width: 50%; padding: 5px 24px 5px 8px; border-color: transparent; background-color: transparent; font-size: 12.5px; color: var(--dashboard-text-secondary); background-position: right 6px center; + /* Sized to the level on show rather than the longest entry ("Remove + access"), so the chevron sits next to the text. Where unsupported, + the text at least hugs the chevron. */ + field-sizing: content; + text-align-last: right; +} +select.share-modal-row-mode option, +select.share-modal-link-mode option { + text-align: left; +} +select.share-modal-row-mode option.share-modal-option-danger { + color: var(--dashboard-danger-text); } @media (hover: hover) { + select.share-modal-link-mode:hover:not(:disabled), select.share-modal-row-mode:hover:not(:disabled) { background-color: var(--dashboard-hover); color: var(--dashboard-text-primary); } } +select.share-modal-link-mode:focus, select.share-modal-row-mode:focus { padding: 5px 24px 5px 8px; border: 1px solid var(--select-color); } -.share-modal-revoke { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 0; - border: none; - border-radius: 8px; - background: transparent; - color: var(--dashboard-text-secondary); - cursor: pointer; - transition: background 0.15s, color 0.15s; -} -@media (hover: hover) { - .share-modal-revoke:hover:not(:disabled) { - background: var(--dashboard-danger-background); - color: var(--dashboard-danger-text); - } -} -.share-modal-revoke:focus-visible { - outline: none; - box-shadow: 0 0 0 3px var(--select-ring); -} .share-modal-empty { margin: 0; padding: 10px 0; @@ -3226,20 +3280,17 @@ select.share-modal-row-mode:focus { cursor: default; } -/* Touch: every control clears 44px. The icon buttons keep their 30-32px - visuals but hit-test at 44x44 via a centered pseudo-element (the 10px - gap to the row's select and the 18px between rows absorb the overhang - without overlap); everything else grows for real via min-height, which - holds whatever the inherited line-height turns out to be. +/* Touch: every control clears 44px. The close button keeps its 32px + visual but hit-tests at 44x44 via a centered pseudo-element; everything + else grows for real via min-height, which holds whatever the inherited + line-height turns out to be. This block sits below every base rule it overrides — a media query adds no specificity, so source order decides. */ @media (pointer: coarse) { - .share-modal-close, - .share-modal-revoke { + .share-modal-close { position: relative; } - .share-modal-close::before, - .share-modal-revoke::before { + .share-modal-close::before { content: ''; position: absolute; left: 50%; @@ -3269,19 +3320,18 @@ select.share-modal-row-mode:focus { select.share-modal-mode, select.share-modal-mode:focus, select.share-modal-link-access, - select.share-modal-link-access:focus, - select.share-modal-link-mode, - select.share-modal-link-mode:focus { + select.share-modal-link-access:focus { min-height: 44px; } + select.share-modal-link-mode, + select.share-modal-link-mode:focus, select.share-modal-row-mode, select.share-modal-row-mode:focus { min-height: 44px; padding-top: 9px; padding-bottom: 9px; } - .share-modal-submit, - .share-modal-copy-link { + .share-modal-submit { height: 44px; } .share-modal-btn-quiet, @@ -3332,9 +3382,14 @@ select.share-modal-row-mode:focus { transform: translateY(0); } /* The sheet sits flush with the physical bottom edge (viewport-fit= - cover), so the scrollable body clears the home indicator itself. */ - .share-modal-body { - padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + cover), so the footer clears the home indicator itself. */ + .share-modal-footer { + padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + } + /* Copy link and Done split the row rather than stacking. */ + .share-modal-footer-row .dashboard-dialog-btn { + flex: 1 1 0; + min-width: 0; } } @@ -3343,6 +3398,9 @@ select.share-modal-row-mode:focus { .share-modal { transition: none; } + .share-modal-add-actions { + animation: none; + } .share-modal-spinner { animation-duration: 1.6s; }