mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 13:03:37 +00:00
feat(dashboard): simplify the share modal
- The access level and Share button appear beside the field only once someone is entered, instead of a full-width button that sat disabled. - The access list sits directly under the field. Each person's menu ends with Remove access (or Cancel invitation), replacing the trash column; the inline confirmation stays. - General access is one row: a lock or globe icon, the switch with its explanation beneath, and the level on the right. - A footer holds Copy link and Done. Copy link is offered for any single item, since the link opens it for anyone who has access, and confirms in place. Status messages live in the footer; successes fade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0167a9d10e
commit
241fb7be8a
3 files changed
+335
-145
No files matched your search
@@ -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 = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const chevronIcon = `<svg class="share-modal-chevron" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
|
||||
const linkIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
|
||||
const lockIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>`;
|
||||
const globeIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/></svg>`;
|
||||
const checkIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 12 10 17 19 7"/></svg>`;
|
||||
|
||||
// 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
|
||||
<div class="share-modal-add-row">
|
||||
<input type="text" class="share-modal-recipient" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"
|
||||
placeholder="${i18n('share_add_people')}" aria-label="${i18n('share_add_people')}" />
|
||||
<select class="share-modal-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
|
||||
<span class="share-modal-add-actions" hidden>
|
||||
<select class="share-modal-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
|
||||
<button type="submit" class="share-modal-submit" disabled>
|
||||
<span class="share-modal-spinner" aria-hidden="true"></span>
|
||||
<span class="share-modal-submit-label">${i18n('share')}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" class="share-modal-submit" disabled>
|
||||
<span class="share-modal-spinner" aria-hidden="true"></span>
|
||||
<span class="share-modal-submit-label">${i18n('share')}</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="share-modal-list" role="group" aria-label="${i18n('share_who_has_access')}" aria-busy="true">
|
||||
<div class="share-modal-loading"><span class="share-modal-spinner" aria-hidden="true"></span></div>
|
||||
</div>
|
||||
${allow_manage ? `<div class="share-modal-general">
|
||||
<h3 class="share-modal-heading">${i18n('share_general_access')}</h3>
|
||||
<div class="share-modal-general-row">
|
||||
<select class="share-modal-link-access" aria-label="${i18n('share_general_access')}">
|
||||
<option value="restricted">${i18n('share_link_restricted')}</option>
|
||||
<option value="anyone">${i18n('share_link_anyone')}</option>
|
||||
<option value="mixed" disabled hidden>${i18n('share_access_mixed')}</option>
|
||||
<option value="unchecked" disabled hidden>${i18n('share_link_unchecked')}</option>
|
||||
</select>
|
||||
<span class="share-modal-general-icon" aria-hidden="true">${lockIcon}</span>
|
||||
<span class="share-modal-general-text">
|
||||
<select class="share-modal-link-access" aria-label="${i18n('share_general_access')}">
|
||||
<option value="restricted">${i18n('share_link_restricted')}</option>
|
||||
<option value="anyone">${i18n('share_link_anyone')}</option>
|
||||
<option value="mixed" disabled hidden>${i18n('share_access_mixed')}</option>
|
||||
<option value="unchecked" disabled hidden>${i18n('share_link_unchecked')}</option>
|
||||
</select>
|
||||
<span class="share-modal-general-note">${is_multi ? i18n('share_link_restricted_note_items') : i18n('share_link_restricted_note')}</span>
|
||||
</span>
|
||||
<select class="share-modal-link-mode" aria-label="${i18n('share_access_level')}" hidden>${options_for('read', { allow_manage: false })}</select>
|
||||
</div>
|
||||
<p class="share-modal-general-note">${is_multi ? i18n('share_link_restricted_note_items') : i18n('share_link_restricted_note')}</p>
|
||||
${is_multi ? '' : `<button type="button" class="share-modal-copy-link" hidden>${i18n('share_copy_link')}</button>`}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="share-modal-footer">
|
||||
<div class="share-modal-status" role="status" aria-live="polite"></div>
|
||||
<h3 class="share-modal-heading">${i18n('share_who_has_access')}</h3>
|
||||
<div class="share-modal-list" aria-busy="true">
|
||||
<div class="share-modal-loading"><span class="share-modal-spinner" aria-hidden="true"></span></div>
|
||||
<div class="share-modal-footer-row">
|
||||
${is_multi ? '' : `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet share-modal-copy-link" hidden>
|
||||
<span class="share-modal-copy-icon">${linkIcon}</span>
|
||||
<span class="share-modal-copy-label" aria-live="polite">${i18n('share_copy_link')}</span>
|
||||
</button>`}
|
||||
<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary share-modal-done">${i18n('share_done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 += '</span>';
|
||||
|
||||
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 += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode, { allow_manage })}</select>`;
|
||||
} else {
|
||||
let fixed_mode = group.inheritedMode;
|
||||
if ( group.pending ) fixed_mode = group.pendingMode;
|
||||
else if ( group.directPaths.length ) fixed_mode = group.mode;
|
||||
row += `<span class="share-modal-row-tag">${fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed')}</span>`;
|
||||
}
|
||||
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 += `<button type="button" class="share-modal-revoke" data-key="${key}" title="${label}" aria-label="${aria}">${icons.trash}</button>`;
|
||||
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 })
|
||||
: `<option value="" selected disabled>${fixed_label}</option>`;
|
||||
const remove_label = group.pending ? i18n('share_cancel_invite') : i18n('share_remove_access');
|
||||
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">`
|
||||
+ `${levels}<hr><option class="share-modal-option-danger" value="${REMOVE_OPTION}">${remove_label}</option></select>`;
|
||||
} else {
|
||||
row += `<span class="share-modal-row-tag">${fixed_label}</span>`;
|
||||
}
|
||||
row += '</div>';
|
||||
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 <body>; 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
|
||||
</div>
|
||||
`);
|
||||
$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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+152
-94
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user