1
0
Fork 0
mirror of synced 2024-09-08 05:31:47 +12:00

Add advanced key handling for spreadsheets and improve blur and focus UX

This commit is contained in:
Andrew Kingston 2023-03-13 18:45:28 +00:00
parent 909118d398
commit d4a2bcae4f
17 changed files with 211 additions and 82 deletions

View file

@ -2,11 +2,16 @@
import { Icon } from "@budibase/bbui"
import { getContext } from "svelte"
const { dispatch, columns } = getContext("sheet")
const { dispatch, columns, ui } = getContext("sheet")
const addRow = () => {
ui.actions.blur()
dispatch("add-row")
}
</script>
{#if $columns.length}
<div class="add-component" on:click={() => dispatch("add-row")}>
<div class="add-component" on:click={addRow}>
<Icon size="XL" name="Add" />
</div>
{/if}

View file

@ -31,8 +31,8 @@
</script>
{#if selectedRowCount}
<div class="delete-button">
<ActionButton icon="Delete" size="S" on:click={modal.show}>
<div class="delete-button" on:mousedown|stopPropagation={modal.show}>
<ActionButton icon="Delete" size="S">
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
</ActionButton>
</div>

View file

@ -4,7 +4,12 @@
import HeaderCell from "./cells/HeaderCell.svelte"
import { Icon } from "@budibase/bbui"
const { renderedColumns, dispatch, config } = getContext("sheet")
const { renderedColumns, dispatch, config, ui } = getContext("sheet")
const addColumn = () => {
ui.actions.blur()
dispatch("add-column")
}
</script>
<div class="header">
@ -16,7 +21,7 @@
</div>
</SheetScrollWrapper>
{#if $config.allowAddColumns}
<div class="new-column" on:click={() => dispatch("add-column")}>
<div class="new-column" on:click={addColumn}>
<Icon size="S" name="Add" />
</div>
{/if}

View file

@ -1,10 +1,26 @@
<script>
import { getContext, onMount } from "svelte"
import { get } from "svelte/store"
const { rows, selectedCellId, columns, selectedCellRow, stickyColumn } =
const { rows, rand, selectedCellId, columns, selectedCellRow, stickyColumn, selectedCellAPI } =
getContext("sheet")
const handleKeyDown = e => {
const api = get(selectedCellAPI)
// Always capture escape and blur any selected cell
if (e.key === "Escape") {
api?.blur()
}
// Pass the key event to the selected cell and let it decide whether to
// capture it or not
const handled = api?.onKeyDown?.(e)
if (handled) {
return
}
// Handle the key ourselves
switch (e.key) {
case "ArrowLeft":
changeSelectedColumn(-1)
@ -21,6 +37,9 @@
case "Delete":
deleteSelectedCell()
break
case "Enter":
focusSelectedCell()
break;
}
}
@ -67,6 +86,10 @@
rows.actions.updateRow(rowId, column, null)
}
const focusSelectedCell = () => {
$selectedCellAPI?.focus()
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
return () => {

View file

@ -3,8 +3,6 @@
clickOutside,
Menu,
MenuItem,
Modal,
ModalContent,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
@ -12,8 +10,6 @@
const { selectedCellRow, menu, rows, columns, selectedCellId, stickyColumn } =
getContext("sheet")
let modal
$: style = makeStyle($menu)
const makeStyle = menu => {
@ -43,24 +39,12 @@
{#if $menu.visible}
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}>
<Menu>
<MenuItem icon="Delete" on:click={modal.show}>Delete row</MenuItem>
<MenuItem icon="Delete" on:click={deleteRow}>Delete row</MenuItem>
<MenuItem icon="Duplicate" on:click={duplicate}>Duplicate row</MenuItem>
</Menu>
</div>
{/if}
<Modal bind:this={modal}>
<ModalContent
title="Delete row"
confirmText="Continue"
cancelText="Cancel"
onConfirm={deleteRow}
size="M"
>
Are you sure you want to delete this row?
</ModalContent>
</Modal>
<style>
.menu {
z-index: 1;

View file

@ -64,10 +64,10 @@
context = { ...context, ...createRowsStore(context) }
context = { ...context, ...createColumnsStores(context) }
context = { ...context, ...createMaxScrollStores(context) }
context = { ...context, ...createUIStores(context) }
context = { ...context, ...createResizeStores(context) }
context = { ...context, ...createViewportStores(context) }
context = { ...context, ...createReorderStores(context) }
context = { ...context, ...createUIStores(context) }
context = { ...context, ...createUserStores(context) }
context = { ...context, ...createMenuStores(context) }
context = { ...context, ...createPaginationStores(context) }
@ -99,7 +99,6 @@
id="sheet-{rand}"
class:is-resizing={$isResizing}
class:is-reordering={$isReordering}
use:clickOutside={ui.actions.blur}
style="--cell-height:{cellHeight}px;"
>
<div class="controls">
@ -113,7 +112,7 @@
</div>
</div>
{#if $loaded}
<div class="sheet-data">
<div class="sheet-data" use:clickOutside={ui.actions.blur}>
<StickyColumn />
<div class="sheet-main">
<HeaderRow />

View file

@ -1,6 +1,7 @@
<script>
import { getContext } from "svelte"
import SheetCell from "./cells/SheetCell.svelte"
import DataCell from "./cells/DataCell.svelte"
import { getCellRenderer } from "./renderers"
export let row
@ -31,7 +32,7 @@
>
{#each $renderedColumns as column (column.name)}
{@const cellId = `${row._id}-${column.name}`}
<SheetCell
<DataCell
rowSelected={rowSelected || containsSelectedCell}
{rowHovered}
rowIdx={idx}
@ -39,19 +40,11 @@
selectedUser={$selectedCellMap[cellId]}
reorderSource={$reorder.sourceColumn === column.name}
reorderTarget={$reorder.targetColumn === column.name}
on:click={() => ($selectedCellId = cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width}
>
<svelte:component
this={getCellRenderer(column)}
value={row[column.name]}
schema={column.schema}
selected={$selectedCellId === cellId}
onChange={val => rows.actions.updateRow(row._id, column.name, val)}
readonly={column.schema.autocolumn}
/>
</SheetCell>
{cellId}
{column}
{row}
/>
{/each}
</div>

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte"
import { Checkbox } from "@budibase/bbui"
import SheetCell from "./cells/SheetCell.svelte"
import { getCellRenderer } from "./renderers"
import DataCell from "./cells/DataCell.svelte"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
import HeaderCell from "./cells/HeaderCell.svelte"
@ -113,27 +113,18 @@
{#if $stickyColumn}
{@const cellId = `${row._id}-${$stickyColumn.name}`}
<SheetCell
<DataCell
rowSelected={rowSelected || containsSelectedRow}
{rowHovered}
rowIdx={idx}
selected={$selectedCellId === cellId}
selectedUser={$selectedCellMap[cellId]}
on:click={() => ($selectedCellId = cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={$stickyColumn.width}
reorderTarget={$reorder.targetColumn === $stickyColumn.name}
>
<svelte:component
this={getCellRenderer($stickyColumn)}
value={row[$stickyColumn.name]}
schema={$stickyColumn.schema}
selected={$selectedCellId === cellId}
onChange={val =>
rows.actions.updateRow(row._id, $stickyColumn.name, val)}
readonly={$stickyColumn.schema.autocolumn}
/>
</SheetCell>
column={$stickyColumn}
{row}
{cellId}
/>
{/if}
</div>
{/each}

View file

@ -0,0 +1,49 @@
<script>
import { getContext } from "svelte"
import SheetCell from "./SheetCell.svelte"
import { getCellRenderer } from "../renderers"
const { rows, selectedCellId, menu, selectedCellAPI } = getContext("sheet")
export let rowSelected
export let rowHovered
export let rowIdx
export let selected
export let selectedUser
export let reorderSource
export let reorderTarget
export let column
export let row
export let cellId
let api
$: {
if (selected) {
selectedCellAPI.set(api)
}
}
</script>
<SheetCell
{rowSelected}
{rowHovered}
{rowIdx}
{selected}
{selectedUser}
{reorderSource}
{reorderTarget}
on:click={() => selectedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width}
>
<svelte:component
this={getCellRenderer(column)}
bind:api
value={row[column.name]}
schema={column.schema}
{selected}
onChange={val => rows.actions.updateRow(row._id, column.name, val)}
readonly={column.schema.autocolumn}
/>
</SheetCell>

View file

@ -17,6 +17,7 @@
renderedColumns,
dispatch,
config,
ui
} = getContext("sheet")
let anchor
@ -48,6 +49,7 @@
const onContextMenu = e => {
e.preventDefault()
ui.actions.blur()
open = !open
}
@ -81,7 +83,7 @@
<div
class="header-cell"
class:open
style="flex: 0 0 {column.width}px;"
style="flex: 0 0 {column.width}px"
bind:this={anchor}
class:disabled={$isReordering || $isResizing}
class:sorted={sortedBy}

View file

@ -1,5 +1,7 @@
<script>
import OptionsCell from "./OptionsCell.svelte"
export let api
</script>
<OptionsCell {...$$props} multi />
<OptionsCell bind:api {...$$props} multi />

View file

@ -1,5 +1,7 @@
<script>
import TextCell from "./TextCell.svelte"
export let api
</script>
<TextCell {...$$props} type="number" />
<TextCell bind:api {...$$props} type="number" />

View file

@ -1,6 +1,7 @@
<script>
import { Icon } from "@budibase/bbui"
import { getColor } from "../utils"
import { onMount } from "svelte"
export let value
export let schema
@ -8,20 +9,32 @@
export let selected = false
export let multi = false
export let readonly = false
export let api
let open = false
let isOpen = false
let focusedOptionIdx = null
$: options = schema?.constraints?.inclusion || []
$: editable = selected && !readonly
$: values = Array.isArray(value) ? value : [value].filter(x => x != null)
$: unselectedOptions = options.filter(x => !values.includes(x))
$: orderedOptions = values.concat(unselectedOptions)
$: {
// Close when deselected
if (!selected) {
open = false
close()
}
}
const open = () => {
isOpen = true
focusedOptionIdx = 0
}
const close = () => {
isOpen = false
}
const getOptionColor = value => {
const index = value ? options.indexOf(value) : null
return getColor(index)
@ -30,6 +43,7 @@
const toggleOption = option => {
if (!multi) {
onChange(option)
close()
} else {
if (values.includes(option)) {
onChange(values.filter(x => x !== option))
@ -39,23 +53,32 @@
}
}
const toggleOpen = () => {
if (multi) {
open = true
} else {
open = !open
const onKeyDown = e => {
if (!isOpen) {
return false
}
e.preventDefault()
if (e.key === "ArrowDown") {
focusedOptionIdx = Math.min(focusedOptionIdx + 1, options.length - 1)
} else if (e.key === "ArrowUp") {
focusedOptionIdx = Math.max(focusedOptionIdx - 1, 0)
} else if (e.key === "Enter") {
toggleOption(orderedOptions[focusedOptionIdx])
}
return true
}
onMount(() => {
api = {
focus: open,
blur: close,
onKeyDown,
}
})
</script>
<div
class="container"
class:multi
class:editable
class:open
on:click={editable ? toggleOpen : null}
>
<div class="values">
<div class="container" class:multi class:editable class:open>
<div class="values" on:click={editable ? open : null}>
{#each values as val}
{@const color = getOptionColor(val)}
{#if color}
@ -74,11 +97,15 @@
<Icon name="ChevronDown" />
</div>
{/if}
{#if open}
{#if isOpen}
<div class="options" on:wheel={e => e.stopPropagation()}>
{#each values as val}
{#each values as val, idx}
{@const color = getOptionColor(val)}
<div class="option" on:click={() => toggleOption(val)}>
<div
class="option"
on:click={() => toggleOption(val)}
class:focused={focusedOptionIdx === idx}
>
<div class="badge text" style="--color: {color}">
{val}
</div>
@ -88,8 +115,12 @@
/>
</div>
{/each}
{#each unselectedOptions as option}
<div class="option" on:click={() => toggleOption(option)}>
{#each unselectedOptions as option, idx}
<div
class="option"
on:click={() => toggleOption(option)}
class:focused={focusedOptionIdx === values.length + idx}
>
<div class="badge text" style="--color: {getOptionColor(option)}">
{option}
</div>
@ -177,7 +208,8 @@
.option:first-child {
flex: 0 0 calc(var(--cell-height) - 1px);
}
.option:hover {
.option:hover,
.option.focused {
background-color: var(--spectrum-global-color-gray-200);
}
</style>

View file

@ -1,19 +1,52 @@
<script>
import { onMount } from "svelte"
export let value
export let selected = false
export let onChange
export let type = "text"
export let readonly = false
export let api
let input
let focused = false
$: editable = selected && !readonly
const handleChange = e => {
onChange(e.target.value)
}
const onKeyDown = e => {
if (!focused) {
return false
}
if (e.key === "Enter") {
input?.blur()
const event = new KeyboardEvent("keydown", { key: "ArrowDown" })
document.dispatchEvent(event)
}
return true
}
onMount(() => {
api = {
focus: () => input?.focus(),
blur: () => input?.blur(),
onKeyDown,
}
})
</script>
{#if editable}
<input {type} value={value || ""} on:change={handleChange} />
<input
bind:this={input}
on:focus={() => (focused = true)}
on:blur={() => (focused = false)}
{type}
value={value || ""}
on:change={handleChange}
/>
{:else}
<div class="text-cell">
{value || ""}

View file

@ -1,7 +1,7 @@
import { get, writable, derived } from "svelte/store"
export const createReorderStores = context => {
const { columns, scroll, bounds, stickyColumn } = context
const { columns, scroll, bounds, stickyColumn, ui } = context
const reorderInitialState = {
sourceColumn: null,
targetColumn: null,
@ -23,6 +23,7 @@ export const createReorderStores = context => {
const $bounds = get(bounds)
const $scroll = get(scroll)
const $stickyColumn = get(stickyColumn)
ui.actions.blur()
// Generate new breakpoints for the current columns
let breakpoints = $columns.map(col => ({

View file

@ -4,7 +4,7 @@ import { DefaultColumnWidth } from "./columns"
export const MinColumnWidth = 100
export const createResizeStores = context => {
const { columns, stickyColumn } = context
const { columns, stickyColumn, ui } = context
const initialState = {
initialMouseX: null,
initialWidth: null,
@ -20,6 +20,7 @@ export const createResizeStores = context => {
const startResizing = (column, e) => {
// Prevent propagation to stop reordering triggering
e.stopPropagation()
ui.actions.blur()
// Find and cache index
let columnIdx = get(columns).findIndex(col => col.name === column.name)

View file

@ -72,6 +72,13 @@ export const createUIStores = context => {
hoveredRowId.set(null)
}
// Remove selected cell API when no selected cell is present
selectedCellId.subscribe(cell => {
if (!cell && get(selectedCellAPI)) {
selectedCellAPI.set(null)
}
})
return {
selectedCellId,
selectedRows,