Add advanced key handling for spreadsheets and improve blur and focus UX
This commit is contained in:
parent
909118d398
commit
d4a2bcae4f
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<script>
|
||||
import OptionsCell from "./OptionsCell.svelte"
|
||||
|
||||
export let api
|
||||
</script>
|
||||
|
||||
<OptionsCell {...$$props} multi />
|
||||
<OptionsCell bind:api {...$$props} multi />
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<script>
|
||||
import TextCell from "./TextCell.svelte"
|
||||
|
||||
export let api
|
||||
</script>
|
||||
|
||||
<TextCell {...$$props} type="number" />
|
||||
<TextCell bind:api {...$$props} type="number" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 || ""}
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue