Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve UX when creating new items #4089

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion mathesar_ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
-->
<style global lang="scss">
@import 'component-library/styles.scss';
@import 'packages/new-item-highlighter/highlightNewItems.scss';

:root {
/** BASE COLORS **/
Expand Down Expand Up @@ -137,7 +138,8 @@
--modal-z-index: 1;
--dropdown-z-index: 1;
--cell-errors-z-index: 1;
--toast-z-index: 2;
--new-item-highlighter-z-index: 2;
--toast-z-index: 3;
--app-header-z-index: 1;

overflow: hidden;
Expand Down
2 changes: 2 additions & 0 deletions mathesar_ui/src/i18n/languages/en/dict.json
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@
"primary_key_column_cannot_be_moved": "The primary key column cannot be moved.",
"primary_key_help": "A primary key constraint uniquely identifies each record in a table.",
"primary_keys": "Primary Keys",
"privileges_new_items_scroll_hint": "Scroll or click here to see the role.",
"processing_data": "Processing Data",
"prompt_new_password_next_login": "Resetting the password will prompt the user to change their password on their next login.",
"properties": "Properties",
Expand Down Expand Up @@ -522,6 +523,7 @@
"schema_name_already_exists": "A schema with that name already exists.",
"schema_name_cannot_be_empty": "Schema name cannot be empty",
"schema_name_placeholder": "Eg. Personal Finances, Movies",
"schema_new_items_scroll_hint": "Scroll or click here to see the schema.",
"schema_not_found": "Schema not found.",
"schema_ownership_updated_successfully": "Schema ownership has been updated successfully.",
"schema_permissions": "Schema Permissions",
Expand Down
21 changes: 21 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Mathesar Foundation Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 3 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# New Item Highlighter

This is a Svelte action that highlights new items in a list.
12 changes: 12 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** The transition time for the highlight effect, in milliseconds */
export const HIGHLIGHT_TRANSITION_MS = 2 * 1000; // 2 seconds

/** The amount of time in milliseconds before we begin fading out the hint. */
export const HINT_EXPIRATION_START_MS = 10 * 1000; // 10 seconds

/** The time it will take to fade out the hint. */
export const HINT_EXPIRATION_TRANSITION_MS = 3 * 1000; // 3 seconds

/** The time at which we can remove the hint DOM nodes. */
export const HINT_EXPIRATION_END_MS =
HINT_EXPIRATION_START_MS + HINT_EXPIRATION_TRANSITION_MS;
74 changes: 74 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { HIGHLIGHT_TRANSITION_MS, HINT_EXPIRATION_END_MS } from './constants';
import { displayHint } from './hint';
import { getRectCssGeometry, onElementRemoved } from './utils';

function makeHighlighterElement(): HTMLElement {
const effect = document.createElement('div');
effect.className = 'effect';

const highlight = document.createElement('div');
highlight.className = 'new-item-highlighter';
highlight.appendChild(effect);

return highlight;
}

function displayHighlight(target: HTMLElement): void {
const highlight = makeHighlighterElement();
highlight.style.setProperty('--duration', `${HIGHLIGHT_TRANSITION_MS}ms`);
document.body.appendChild(highlight);

function trackPosition() {
if (!target.isConnected) return;
const rect = target.getBoundingClientRect();
Object.assign(highlight.style, getRectCssGeometry(rect));
requestAnimationFrame(trackPosition);
}
trackPosition();

function cleanup() {
highlight.remove();
}

onElementRemoved(target, cleanup);
setTimeout(cleanup, HIGHLIGHT_TRANSITION_MS);
}

export function setupHighlighter(
target: HTMLElement,
options: {
scrollHint?: string;
},
): () => void {
let cleanupHint: (() => void) | undefined;

const intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
displayHighlight(target);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
cleanup();
} else if (options.scrollHint && !cleanupHint) {
cleanupHint = displayHint(target, options.scrollHint);
}
},
{ threshold: [0.5] },
);

function cleanup() {
intersectionObserver.disconnect();
cleanupHint?.();
}

intersectionObserver.observe(target);

onElementRemoved(target, cleanup);

// If the user still hasn't seen the hint or the highlight (i.e. if it's
// scrolled out of view), then we give up and remove them. This means
// `displayHighlight` will never be called.
setTimeout(cleanup, HINT_EXPIRATION_END_MS);

return cleanup;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
body > .new-item-highlighter {
position: absolute;
z-index: var(--new-item-highlighter-z-index, 1000);
pointer-events: none;
--easing: cubic-bezier(0.5, 0, 1, 0.5);

.effect {
position: absolute;
inset: -3rem;
border-radius: 3rem;
background: transparent;
mix-blend-mode: darken;
transition:
background var(--duration) var(--easing),
border-radius var(--duration) var(--easing),
inset var(--duration) var(--easing);
pointer-events: none;
filter: blur(0.2rem);
}

@starting-style {
.effect {
background: rgba(254, 221, 72, 0.3);
border-radius: 0.5rem;
inset: 0;
}
}
}

body > .new-item-highlighter__hint {
position: absolute;
z-index: var(--new-item-highlighter-z-index, 1000);
--background: rgba(0, 0, 0, 0.7);
inset: 0px auto auto 0px;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;

@starting-style {
opacity: 1;
}

.message {
background-color: var(--background);
color: white;
padding: 0.5rem;
border-radius: 0.3rem;
max-width: 15rem;
text-align: center;
cursor: pointer;
}

&:hover {
--background: black;
}

svg {
height: 1.5rem;
width: 1.5rem;
margin-bottom: -1px;
cursor: pointer;

path {
fill: var(--background);
}
}

&.down {
flex-direction: column-reverse;
svg {
transform: rotate(180deg);
margin-bottom: 0;
margin-top: -1px;
}
}
}
54 changes: 54 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/highlightNewItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ActionReturn } from 'svelte/action';

import { setupHighlighter } from './highlight';
import { getNewlyAddedItemsFromMutations } from './utils';

export function highlightNewItems(
container: HTMLElement,
options: {
/**
* The number of milliseconds to wait before setting up highlighting.
*
* This defaults to 2000 (i.e. 2 seconds) to give children time for the
* initial load if necessary, and because in most of the contexts where we
* want to use this, we don't expect the user to be able to perform data
* entry in under 2 seconds.
*
* Set this to 0 to start highlighting immediately. If the children are
* rendered synchronously, then highlighting will still be deferred to new
* items.
*/
wait?: number;
/**
* Pass a string to display a hint to the user when the new item is
* scrolled out of view.
*/
scrollHint?: string;
} = {},
): ActionReturn {
const wait = options.wait ?? 2000;

const cleanupFns: (() => void)[] = [];

function init() {
const mutationObserver = new MutationObserver((mutations) => {
for (const item of getNewlyAddedItemsFromMutations(mutations)) {
cleanupFns.push(setupHighlighter(item, options));
}
});
cleanupFns.push(() => mutationObserver.disconnect());
mutationObserver.observe(container, { childList: true });
}

if (wait) {
setTimeout(init, wait);
} else {
init();
}

return {
destroy() {
cleanupFns.forEach((fn) => fn());
},
};
}
96 changes: 96 additions & 0 deletions mathesar_ui/src/packages/new-item-highlighter/hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
HINT_EXPIRATION_START_MS,
HINT_EXPIRATION_TRANSITION_MS,
} from './constants';
import {
getNearestVerticallyScrollableAncestor,
onElementRemoved,
} from './utils';

function makeArrowElement(): SVGSVGElement {
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
arrow.setAttribute('viewBox', '0 0 400 400');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute(
'd',
'm 358,179.5 c 3.8,-8.8 2,-19 -4.6,-26 L 217.4,9.5 C 212.9,4.7 206.6,2 200,2 193.4,2 187.1,4.7 182.6,9.5 l -136,144 c -6.6,7 -8.4,17.2 -4.6,26 3.8,8.8 12.4,14.5 22,14.5 h 72 V 400 H 264 V 194 h 72 c 9.6,0 18.2,-5.7 22,-14.5 z',
);
arrow.appendChild(path);
return arrow;
}

function makeHintElement(
message: string,
direction: 'up' | 'down',
): HTMLElement {
const hintElement = document.createElement('div');
hintElement.classList.add('new-item-highlighter__hint', direction);
hintElement.style.setProperty(
'transition',
`opacity ${HINT_EXPIRATION_TRANSITION_MS}ms ${HINT_EXPIRATION_START_MS}ms`,
);
const messageElement = document.createElement('div');
messageElement.textContent = message;
messageElement.className = 'message';
hintElement.appendChild(makeArrowElement());
hintElement.appendChild(messageElement);
return hintElement;
}

export function displayHint(target: HTMLElement, message: string): () => void {
const container = getNearestVerticallyScrollableAncestor(target);
if (!container) return () => {};

const containerTop = container.getBoundingClientRect().top;
const targetTop = target.getBoundingClientRect().top;
const direction = targetTop > containerTop ? 'down' : 'up';

const hintElement = makeHintElement(message, direction);
document.body.appendChild(hintElement);
const hintRect = hintElement.getBoundingClientRect();

function positionHint() {
if (!container) return;
const containerRect = container.getBoundingClientRect();
const top =
direction === 'up'
? containerRect.top
: containerRect.bottom - hintRect.height;
const left =
containerRect.left + (containerRect.width - hintRect.width) / 2;
hintElement.style.top = `${top}px`;
hintElement.style.left = `${left}px`;
}

const resizeObserver = new ResizeObserver(positionHint);
resizeObserver.observe(container);
resizeObserver.observe(document.body);

function scrollContainerToTarget() {
if (!container) return;
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const top =
direction === 'up'
? targetRect.top - containerRect.top
: targetRect.bottom - containerRect.bottom;
container.scrollTo({
top,
behavior: 'smooth',
});
}

hintElement.addEventListener('click', scrollContainerToTarget);

function cleanup() {
hintElement.remove();
resizeObserver.disconnect();
document.body.removeEventListener('click', cleanup);
}

onElementRemoved(target, cleanup);
onElementRemoved(container, cleanup);
setTimeout(() => document.body.addEventListener('click', cleanup), 50);

return cleanup;
}
1 change: 1 addition & 0 deletions mathesar_ui/src/packages/new-item-highlighter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { highlightNewItems } from './highlightNewItems';
Loading
Loading