1
0
Fork 0
mirror of synced 2024-09-17 01:38:40 +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 { Icon } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
const { dispatch, columns } = getContext("sheet") const { dispatch, columns, ui } = getContext("sheet")
const addRow = () => {
ui.actions.blur()
dispatch("add-row")
}
</script> </script>
{#if $columns.length} {#if $columns.length}
<div class="add-component" on:click={() => dispatch("add-row")}> <div class="add-component" on:click={addRow}>
<Icon size="XL" name="Add" /> <Icon size="XL" name="Add" />
</div> </div>
{/if} {/if}

View file

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

View file

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

View file

@ -1,10 +1,26 @@
<script> <script>
import { getContext, onMount } from "svelte" 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") getContext("sheet")
const handleKeyDown = e => { 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) { switch (e.key) {
case "ArrowLeft": case "ArrowLeft":
changeSelectedColumn(-1) changeSelectedColumn(-1)
@ -21,6 +37,9 @@
case "Delete": case "Delete":
deleteSelectedCell() deleteSelectedCell()
break break
case "Enter":
focusSelectedCell()
break;
} }
} }
@ -67,6 +86,10 @@
rows.actions.updateRow(rowId, column, null) rows.actions.updateRow(rowId, column, null)
} }
const focusSelectedCell = () => {
$selectedCellAPI?.focus()
}
onMount(() => { onMount(() => {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
return () => { return () => {

View file

@ -3,8 +3,6 @@
clickOutside, clickOutside,
Menu, Menu,
MenuItem, MenuItem,
Modal,
ModalContent,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
@ -12,8 +10,6 @@
const { selectedCellRow, menu, rows, columns, selectedCellId, stickyColumn } = const { selectedCellRow, menu, rows, columns, selectedCellId, stickyColumn } =
getContext("sheet") getContext("sheet")
let modal
$: style = makeStyle($menu) $: style = makeStyle($menu)
const makeStyle = menu => { const makeStyle = menu => {
@ -43,24 +39,12 @@
{#if $menu.visible} {#if $menu.visible}
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}> <div class="menu" {style} use:clickOutside={() => menu.actions.close()}>
<Menu> <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> <MenuItem icon="Duplicate" on:click={duplicate}>Duplicate row</MenuItem>
</Menu> </Menu>
</div> </div>
{/if} {/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> <style>
.menu { .menu {
z-index: 1; z-index: 1;

View file

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

View file

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

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { Checkbox } from "@budibase/bbui" import { Checkbox } from "@budibase/bbui"
import SheetCell from "./cells/SheetCell.svelte" import SheetCell from "./cells/SheetCell.svelte"
import { getCellRenderer } from "./renderers" import DataCell from "./cells/DataCell.svelte"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte" import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
import HeaderCell from "./cells/HeaderCell.svelte" import HeaderCell from "./cells/HeaderCell.svelte"
@ -113,27 +113,18 @@
{#if $stickyColumn} {#if $stickyColumn}
{@const cellId = `${row._id}-${$stickyColumn.name}`} {@const cellId = `${row._id}-${$stickyColumn.name}`}
<SheetCell <DataCell
rowSelected={rowSelected || containsSelectedRow} rowSelected={rowSelected || containsSelectedRow}
{rowHovered} {rowHovered}
rowIdx={idx} rowIdx={idx}
selected={$selectedCellId === cellId} selected={$selectedCellId === cellId}
selectedUser={$selectedCellMap[cellId]} selectedUser={$selectedCellMap[cellId]}
on:click={() => ($selectedCellId = cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={$stickyColumn.width} width={$stickyColumn.width}
reorderTarget={$reorder.targetColumn === $stickyColumn.name} reorderTarget={$reorder.targetColumn === $stickyColumn.name}
> column={$stickyColumn}
<svelte:component {row}
this={getCellRenderer($stickyColumn)} {cellId}
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>
{/if} {/if}
</div> </div>
{/each} {/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, renderedColumns,
dispatch, dispatch,
config, config,
ui
} = getContext("sheet") } = getContext("sheet")
let anchor let anchor
@ -48,6 +49,7 @@
const onContextMenu = e => { const onContextMenu = e => {
e.preventDefault() e.preventDefault()
ui.actions.blur()
open = !open open = !open
} }
@ -81,7 +83,7 @@
<div <div
class="header-cell" class="header-cell"
class:open class:open
style="flex: 0 0 {column.width}px;" style="flex: 0 0 {column.width}px"
bind:this={anchor} bind:this={anchor}
class:disabled={$isReordering || $isResizing} class:disabled={$isReordering || $isResizing}
class:sorted={sortedBy} class:sorted={sortedBy}

View file

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

View file

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

View file

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

View file

@ -1,19 +1,52 @@
<script> <script>
import { onMount } from "svelte"
export let value export let value
export let selected = false export let selected = false
export let onChange export let onChange
export let type = "text" export let type = "text"
export let readonly = false export let readonly = false
export let api
let input
let focused = false
$: editable = selected && !readonly $: editable = selected && !readonly
const handleChange = e => { const handleChange = e => {
onChange(e.target.value) 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> </script>
{#if editable} {#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} {:else}
<div class="text-cell"> <div class="text-cell">
{value || ""} {value || ""}

View file

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

View file

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

View file

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