(core) Polish ChoiceListEntry drag and drop

Summary:
A green line indicating the insertion point is now shown in the
ChoiceListEntry component when dragging and dropping choices, similar
to the one shown in the choice list cell editor.

Test Plan: Tested manually.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3529
This commit is contained in:
George Gevoian 2022-07-18 10:05:35 -07:00
parent 4b258ae0fa
commit 3e49fe9a50
5 changed files with 122 additions and 44 deletions

View File

@ -44,6 +44,9 @@ export interface ITokenFieldOptions<Token extends IToken> {
// CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text.
tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void;
clipboardToTokens?: (clipboard: DataTransfer) => Token[];
// Defaults to horizontal.
variant?: ITokenFieldVariant;
}
/**
@ -56,6 +59,8 @@ export interface ITokenFieldKeyBindings {
next?: string;
}
export type ITokenFieldVariant = 'horizontal' | 'vertical';
const defaultKeyBindings: Required<ITokenFieldKeyBindings> = {
previous: 'ArrowLeft',
next: 'ArrowRight'
@ -96,6 +101,7 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
private _undoStack: UndoItem[] = [];
private _undoIndex = 0; // The last action done; next to undo.
private _inUndoRedo = false;
private _variant: ITokenFieldVariant = this._options.variant ?? 'horizontal';
constructor(private _options: ITokenFieldOptions<Token>) {
super();
@ -480,10 +486,12 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
const xInitial = startEvent.clientX;
const yInitial = startEvent.clientY;
const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`;
const dragTargetStyle = this._variant === 'horizontal' ? cssDragTarget : cssVerticalDragTarget;
let started = false;
let allTargets: HTMLElement[];
let tokenList: HTMLElement[];
let nextUnselectedToken: HTMLElement|undefined;
const onMove = (ev: MouseEvent) => {
if (!started) {
@ -498,11 +506,17 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// Get a list of all drag targets, and add a CSS class that shows drop location on hover.
allTargets = Array.prototype.filter.call(this._rootElem.children, el => el.matches(dragTargetSelector));
allTargets.forEach(el => el.classList.add(cssDragTarget.className));
allTargets.forEach(el => el.classList.add(dragTargetStyle.className));
// Get a list of element we are dragging, and add a CSS class to show them as dragged.
tokenList = allTargets.filter(el => el.matches('.selected'));
tokenList.forEach(el => el.classList.add('token-dragging'));
// Add a CSS class to the first unselected token after the current selection; we use it for showing
// the drag/drop markers when hovering over a token.
nextUnselectedToken = allTargets.find(el => el.previousElementSibling === tokenList[tokenList.length - 1]);
nextUnselectedToken?.classList.add(dragTargetStyle.className + "-next");
nextUnselectedToken?.style.setProperty('--count', String(tokenList.length));
}
const xOffset = ev.clientX - xInitial;
const yOffset = ev.clientY - yInitial;
@ -519,21 +533,16 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// Restore all style changes.
this._rootElem.classList.remove('token-dragactive');
allTargets.forEach(el => el.classList.remove(cssDragTarget.className));
allTargets.forEach(el => el.classList.remove(dragTargetStyle.className));
tokenList.forEach(el => el.classList.remove('token-dragging'));
tokenList.forEach(el => { el.style.transform = ''; });
nextUnselectedToken?.classList.remove(dragTargetStyle.className + "-next");
// Find the token before which we are inserting the dragged elements. If inserting at the
// end (just before or over the input box), destToken will be undefined.
let index = allTargets.indexOf(ev.target as HTMLElement);
if (index < 0) {
// Sometimes we are at inner input element of the target (when dragging past the last element).
// In this case we need to test the parent.
if (ev.target instanceof HTMLInputElement && ev.target.parentElement) {
index = allTargets.indexOf(ev.target.parentElement);
}
const index = allTargets.findIndex((target) => target.contains(ev.target as Node));
if (index < 0) { return; }
}
const destToken: TokenWrap<Token>|undefined = this._tokens.get()[index];
const selection = this._selection.get();
@ -707,6 +716,37 @@ const cssDragTarget = styled('div', `
}
`);
const cssVerticalDragTarget = styled('div', `
/* This pseudo-element prevents small, flickering height changes when
* dragging the selection over targets. */
&::before {
content: "";
position: absolute;
top: -8px;
bottom: 0px;
left: 0px;
right: 0px;
}
&-next::before {
/* 27.75px is the height of a token. */
top: calc(-27.75px * var(--count, 1) - 8px);
}
&:hover {
transform: translateY(4px);
margin-bottom: 8px;
}
&:hover::after {
content: "";
position: absolute;
background-color: ${colors.lightGreen};
height: 2px;
top: -5px;
bottom: 0px;
left: 0px;
right: 0px;
}
`);
const cssHiddenInput = styled('input', `
left: -10000px;
width: 1px;

View File

@ -140,10 +140,14 @@ export function docBreadcrumbs(
];
}
),
editableLabel(
docName, options.docNameSave, testId('bc-doc'), cssEditableName.cls(''),
editableLabel(docName, {
save: options.docNameSave,
inputArgs: [
testId('bc-doc'),
cssEditableName.cls(''),
dom.boolAttr('disabled', options.isDocNameReadOnly || false),
),
],
}),
dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))),
dom.domComputed((use) => {
if (options.isSnapshot && use(options.isSnapshot)) {
@ -175,10 +179,14 @@ export function docBreadcrumbs(
separator(' / ',
testId('bc-separator'),
cssHideForNarrowScreen.cls('')),
editableLabel(
pageName, options.pageNameSave, testId('bc-page'), cssEditableName.cls(''),
editableLabel(pageName, {
save: options.pageNameSave,
inputArgs: [
testId('bc-page'),
cssEditableName.cls(''),
dom.boolAttr('disabled', options.isPageNameReadOnly || false),
dom.cls(cssHideForNarrowScreen.className),
),
],
}),
);
}

View File

@ -64,12 +64,20 @@ enum Status { NORMAL, EDITING, SAVING }
type SaveFunc = (value: string) => Promise<void>;
export interface EditableLabelOptions {
save: SaveFunc;
args?: Array<DomArg<HTMLDivElement>>;
inputArgs?: Array<DomArg<HTMLInputElement>>;
}
/**
* Provides a label that takes in an observable that is set on Enter or loss of focus. Escape
* cancels editing. Label grows in size with typed input. Validation logic (if any) should happen in
* the save function, to reject a value simply throw an error, this will revert to the saved one .
*/
export function editableLabel(label: Observable<string>, save: SaveFunc, ...args: Array<DomArg<HTMLInputElement>>) {
export function editableLabel(label: Observable<string>, options: EditableLabelOptions) {
const {save, args, inputArgs} = options;
let input: HTMLInputElement;
let sizer: HTMLSpanElement;
@ -81,8 +89,9 @@ export function editableLabel(label: Observable<string>, save: SaveFunc, ...args
sizer = cssSizer(label.get()),
input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className),
dom.on('focus', () => input.select()),
...args
...inputArgs ?? [],
),
...args ?? [],
);
}

View File

@ -141,8 +141,10 @@ export class AttachmentsEditor extends NewBaseEditor {
),
dom.maybe(this._selected, selected =>
cssTitle(
cssEditableLabel(selected.filename, (val) => this._renameAttachment(selected, val),
testId('pw-name'))
cssEditableLabel(selected.filename, {
save: (val) => this._renameAttachment(selected, val),
inputArgs: [testId('pw-name')],
}),
)
),
cssFlexExpand(

View File

@ -129,7 +129,8 @@ export class ChoiceListEntry extends Disposable {
keyBindings: {
previous: 'ArrowUp',
next: 'ArrowDown'
}
},
variant: 'vertical',
});
return cssVerticalFlex(
@ -324,8 +325,9 @@ export class ChoiceListEntry extends Disposable {
}));
}
),
editableLabel(choiceText,
rename,
editableLabel(choiceText, {
save: rename,
inputArgs: [
testId('token-label'),
// Don't bubble up keyboard events, use them for editing the text.
// Without this keys like Backspace, or Mod+a will propagate and modify all tokens.
@ -339,7 +341,10 @@ export class ChoiceListEntry extends Disposable {
}),
// Don't bubble up click, as it would change focus.
dom.on('click', stopPropagation),
dom.cls(cssTokenLabel.className)),
dom.cls(cssEditableLabelInput.className),
],
args: [dom.cls(cssEditableLabel.className)],
}),
);
}
}
@ -432,7 +437,6 @@ const cssListRow = styled('div', `
color: ${colors.dark};
background-color: ${colors.mediumGrey};
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
`);
@ -478,6 +482,20 @@ const cssTokenLabel = styled('span', `
overflow: hidden;
`);
const cssEditableLabelInput = styled('input', `
display: inline-block;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssEditableLabel = styled('div', `
margin-left: 6px;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssTokenInput = styled('input', `
padding-top: 4px;
padding-bottom: 4px;
@ -503,7 +521,7 @@ const cssFlex = styled('div', `
`);
const cssColorAndLabel = styled(cssFlex, `
max-width: calc(100% - 16px);
max-width: calc(100% - 20px);
`);
const cssVerticalFlex = styled('div', `
@ -522,6 +540,7 @@ const cssButtonRow = styled('div', `
const cssDeleteButton = styled('div', `
display: inline;
float: right;
margin-left: 4px;
cursor: pointer;
.${cssTokenField.className}.token-dragactive & {
cursor: unset;