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

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
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: 1;
--toast-z-index: 3;
--app-header-z-index: 1;

overflow: hidden;
Expand Down
6 changes: 6 additions & 0 deletions mathesar_ui/src/i18n/languages/en/dict.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"cell": "Cell",
"change_password": "Change Password",
"check_for_updates": "Check for Updates",
"child_role_new_items_scroll_hint": "Scroll or click here to see the child role.",
"child_roles": "Child Roles",
"child_roles_count": "{count, plural, one {{count} child role} other {{count} child roles}}",
"child_roles_saved_successfully": "The Child Roles have been successfully saved.",
Expand Down Expand Up @@ -135,6 +136,7 @@
"create_new_database": "Create a New Database",
"create_new_pg_user": "Create a new PostgreSQL user",
"create_new_schema": "Create New Schema",
"create_new_table": "Create New Table",
"create_record_from_search": "Create Record From Search Criteria",
"create_role": "Create Role",
"create_schema": "Create Schema",
Expand All @@ -161,6 +163,7 @@
"database_disconnect_failed": "Unable to disconnect database",
"database_disconnect_success": "The database has been disconnected successfully",
"database_name": "Database Name",
"database_new_items_scroll_hint": "Scroll or click here to see the database.",
"database_not_found": "Database with id [connectionId] is not found.",
"database_ownership_updated_successfully": "Database ownership has been updated successfully.",
"database_permissions": "Database Permissions",
Expand Down Expand Up @@ -447,6 +450,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 +526,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 Expand Up @@ -595,6 +600,7 @@
"table_name": "Table Name",
"table_name_already_exists": "A table with that name already exists.",
"table_name_cannot_be_empty": "The table name cannot be empty.",
"table_new_items_scroll_hint": "Scroll or click here to see the table.",
"table_not_found": "Table not found.",
"table_not_shared": "This table is currently not shared.",
"table_ownership_updated_successfully": "Table ownership has been updated successfully.",
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;
79 changes: 79 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,79 @@
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);

// While the highlight is in effect, we use a requestAnimationFrame loop to
// track the target's position and update the highlight's position
// accordingly.
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;

// Use an IntersectionObserver to detect when the target is in view. When it
// is, display the highlight. If it's not, then display the scroll hint.
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;
}
}
}
59 changes: 59 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,59 @@
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() {
// Use a MutationObserver to watch for new items added to the container
const mutationObserver = new MutationObserver((mutations) => {
// Don't highlight the first item added to the container since it will
// already be easy for the user to identify.
if (container.children.length < 2) return;

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());
},
};
}
Loading
Loading