import { css, html } from './common/tags'; // hardcoded column and row numbers const styles = css` :host { --cell-height: 1.75rem; --cell-width: 100px; --border-color: #e1e1e1; border: solid 1px var(--border-color); box-sizing: border-box; display: grid; font-family: monospace; grid-template-columns: 50px repeat(var(--column-count), var(--cell-width)); grid-template-rows: repeat(calc(var(--row-count) + 1), var(--cell-height)); position: relative; overflow: scroll; scroll-snap-type: both mandatory; scroll-padding-top: var(--cell-height); scroll-padding-left: 50px; } textarea { background-color: rgba(255, 255, 255, 0.75); grid-column: var(--text-column, 0); grid-row: var(--text-row, 0); z-index: 11; } s-columns { box-shadow: 0px 3px 5px 0px rgba(173, 168, 168, 0.6); display: grid; grid-column: 2 / -1; grid-row: 1; grid-template-columns: subgrid; grid-template-rows: subgrid; position: sticky; top: 0; z-index: 2; } s-rows { box-shadow: 3px 0px 5px 0px rgba(173, 168, 168, 0.4); display: grid; grid-column: 1; grid-row: 2 / -1; grid-template-columns: subgrid; grid-template-rows: subgrid; position: sticky; left: 0; z-index: 2; s-header { font-size: 0.75rem; } } s-header { background-color: #f8f9fa; display: flex; padding: 0.125rem 0.5rem; align-items: center; justify-content: center; &[empty] { box-shadow: 3px 3px 3px 0px rgba(173, 168, 168, 0.4); grid-area: 1; position: sticky; top: 0; left: 0; z-index: 3; } &:state(selected) { background-color: #d3e2fd; font-weight: bold; } } s-body { display: grid; grid-column: 2 / -1; grid-row: 2 / -1; grid-template-columns: subgrid; grid-template-rows: subgrid; } s-columns, s-rows, s-body { background-color: var(--border-color); gap: 1px; } ::slotted(folk-cell) { box-sizing: border-box; align-items: center; background-color: rgb(255, 255, 255); display: flex; padding: 0.25rem; justify-content: start; scroll-snap-align: start; overflow: hidden; } ::slotted(folk-cell[type='number']) { justify-content: end; } ::slotted(folk-cell[readonly]) { color: grey; } ::slotted(folk-cell:hover) { outline: 1px solid #1b73e8; z-index: 5; } ::slotted(folk-cell:focus) { outline: 2px solid #1b73e8; z-index: 4; } `; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; export function getColumnName(index: number) { return alphabet[index % alphabet.length]; } export function getColumnIndex(name: string) { return alphabet.indexOf(name); } export function relativeColumnName(name: string, num: number) { const index = alphabet.indexOf(name); return alphabet[index + num]; } export interface CellTemplate { expression?: string; readonly?: boolean; } export function templateCells(numberOfRows: number, numberOfColumns: number, cells: Record = {}) { const html: string[] = []; for (let i = 0; i < numberOfRows; i += 1) { for (let j = 0; j < numberOfColumns; j += 1) { const column = getColumnName(j); const row = i + 1; const { expression, readonly } = cells[`${column}${row}`] || {}; html.push( `` ); } } return html.join('\n'); } declare global { interface HTMLElementTagNameMap { 'folk-spreadsheet': FolkSpreadsheet; } } export class FolkSpreadsheet extends HTMLElement { static tagName = 'folk-spreadsheet'; static define() { if (customElements.get(this.tagName)) return; // order of registering is important FolkSpreadSheetCell.define(); FolkSpreadsheetHeader.define(); customElements.define(this.tagName, this); } #shadow = this.attachShadow({ mode: 'open' }); #columns = document.createElement('s-columns'); #rows = document.createElement('s-rows'); #body = document.createElement('s-body'); #slot = document.createElement('slot'); #textarea = document.createElement('textarea'); #cellStyles = new CSSStyleSheet(); #editedCell: FolkSpreadSheetCell | null = null; constructor() { super(); this.addEventListener('click', this); this.addEventListener('dblclick', this); this.addEventListener('keydown', this); this.addEventListener('focusin', this); this.addEventListener('focusout', this); this.#shadow.adoptedStyleSheets.push(styles, this.#cellStyles); } connectedCallback() { const header = document.createElement('s-header'); header.setAttribute('empty', ''); this.#textarea.hidden = true; this.#slot.addEventListener('slotchange', this.#onSlotUpdate); this.#body.appendChild(this.#slot); this.#shadow.append(header, this.#columns, this.#rows, this.#body, this.#textarea); } #onSlotUpdate = () => { const columnNames = new Set(); const rowNames = new Set(); const cells = this.querySelectorAll('folk-cell'); cells.forEach((cell) => { columnNames.add(cell.column); rowNames.add(cell.row); }); const columns = Array.from({ length: columnNames.size }).map((_, i) => getColumnName(i)); const rows = Array.from({ length: rowNames.size }).map((_, i) => i + 1); this.#columns.setHTMLUnsafe( columns.map((column) => `${column}`).join('\n') ); this.#rows.setHTMLUnsafe(rows.map((row) => `${row}`).join('\n')); this.#cellStyles.replaceSync(` :host { --column-count: ${columns.length}; --row-count: ${rows.length}; } ${columns .map( (column) => `s-header[column="${column}"], ::slotted(folk-cell[column="${column}"]) { grid-column: ${ getColumnIndex(column) + 1 }; }` ) .join('\n')} ${rows .map((row) => `s-header[row="${row}"], ::slotted(folk-cell[row="${row}"]) { grid-row: ${row}; }`) .join('\n')}`); }; #range = ''; get range() { return this.#range; } set range(range) { this.#range = range; } getCell(column: string, row: number | string): FolkSpreadSheetCell | null { return this.querySelector(`folk-cell[column="${column}"][row="${row}"]`); } handleEvent(event: Event) { switch (event.type) { case 'keydown': { if (!(event instanceof KeyboardEvent)) return; const { target } = event; if (target instanceof FolkSpreadSheetCell) { event.preventDefault(); // dont scroll as we change focus switch (event.code) { case 'ArrowUp': { target.cellAbove?.focus(); return; } case 'ArrowDown': { target.cellBelow?.focus(); return; } case 'ArrowLeft': { target.cellToTheLeft?.focus(); return; } case 'ArrowRight': { target.cellToTheRight?.focus(); return; } case 'Enter': { this.#focusTextarea(target); return; } } return; } const composedTarget = event.composedPath()[0]; if (composedTarget === this.#textarea) { if (event.code === 'Escape' || (event.code === 'Enter' && event.shiftKey)) { // Focusing out of the textarea will clean it up. this.#textarea.blur(); } } return; } case 'dblclick': { if (event.target instanceof FolkSpreadSheetCell) { this.#focusTextarea(event.target); } return; } case 'focusin': { if (event.target instanceof FolkSpreadSheetCell) { this.#getHeader('column', event.target.column).selected = true; this.#getHeader('row', event.target.row).selected = true; this.range = event.target.name; } return; } case 'focusout': { if (event.target instanceof FolkSpreadSheetCell) { this.#getHeader('column', event.target.column).selected = false; this.#getHeader('row', event.target.row).selected = false; this.range = event.target.name; return; } const composedTarget = event.composedPath()[0]; if (composedTarget === this.#textarea) { this.#resetTextarea(); } return; } } } #getHeader(type: 'row' | 'column', value: string | number): FolkSpreadsheetHeader { return this.#shadow.querySelector(`s-header[${type}="${value}"]`)!; } #focusTextarea(cell: FolkSpreadSheetCell) { if (cell.readonly) return; this.#editedCell = cell; const gridColumn = getColumnIndex(cell.column) + 2; const gridRow = cell.row + 1; this.#textarea.style.setProperty('--text-column', `${gridColumn} / ${gridColumn + 3}`); this.#textarea.style.setProperty('--text-row', `${gridRow} / ${gridRow + 3}`); this.#textarea.value = cell.expression; this.#textarea.hidden = false; this.#textarea.focus(); } #resetTextarea() { if (this.#editedCell === null) return; this.#textarea.style.setProperty('--text-column', '0'); this.#textarea.style.setProperty('--text-row', '0'); this.#editedCell.expression = this.#textarea.value; this.#textarea.value = ''; this.#editedCell.focus(); this.#textarea.hidden = true; this.#editedCell = null; } } declare global { interface HTMLElementTagNameMap { 's-header': FolkSpreadsheetHeader; } } export class FolkSpreadsheetHeader extends HTMLElement { static tagName = 's-header'; static define() { if (customElements.get(this.tagName)) return; customElements.define(this.tagName, this); } #internals = this.attachInternals(); #selected = false; get selected() { return this.#selected; } set selected(selected) { this.#selected = selected; if (this.#selected) { this.#internals.states.add('selected'); } else { this.#internals.states.delete('selected'); } } } declare global { interface HTMLElementTagNameMap { 'folk-cell': FolkSpreadSheetCell; } } export class FolkSpreadSheetCell extends HTMLElement { static tagName = 'folk-cell'; static define() { if (customElements.get(this.tagName)) return; customElements.define(this.tagName, this); } connectedCallback() { // this should run after all of the other cells have run this.expression = this.getAttribute('expression') || ''; if (this.tabIndex === -1) { this.tabIndex = 0; } } get type() { return this.getAttribute('type') || ''; } get name() { return `${this.#column}${this.#row}`; } #column = this.getAttribute('column') || ''; get column() { return this.#column; } set column(column) { this.#column = column; } #row = Number(this.getAttribute('row')) || -1; get row() { return this.#row; } set row(row) { this.#row = row; } #expression = ''; #dependencies: ReadonlyArray = []; get dependencies() { return this.#dependencies; } #function = new Function(); get expression(): string { return this.#expression; } set expression(expression: any) { expression = String(expression).trim(); if (expression === this.#expression) return; this.#expression = expression; this.#dependencies.forEach((dep) => dep.removeEventListener('propagate', this)); if (expression === '') return; if (!expression.includes('return ')) { expression = `return ${expression}`; } const argNames: string[] = expression.match(/\$[A-Z]+\d+/g) ?? []; this.#dependencies = Object.freeze( argNames .map((dep) => { const [, column, row] = dep.split(/([A-Z]+)(\d+)/s); return this.#getCell(column, row); }) .filter((cell) => cell !== null) ); this.#dependencies.forEach((dep) => dep.addEventListener('propagate', this)); this.#function = new Function(...argNames, expression); this.#evaluate(); } get readonly() { return this.hasAttribute('readonly'); } set readonly(readonly) { readonly ? this.setAttribute('readonly', '') : this.removeAttribute('readonly'); } #value: any; get value() { return this.#value; } #getCell(column: string, row: number | string): FolkSpreadSheetCell | null { return this.parentElement!.querySelector(`folk-cell[column="${column}"][row="${row}"]`); } get cellAbove() { return this.#getCell(this.#column, this.#row - 1); } get cellBelow() { return this.#getCell(this.#column, this.#row + 1); } get cellToTheLeft() { return this.#getCell(relativeColumnName(this.column, -1), this.#row); } get cellToTheRight() { return this.#getCell(relativeColumnName(this.column, 1), this.#row); } #evaluate() { try { this.#invalidated = false; const args = this.#dependencies.map((dep) => dep.value); const value = this.#function.apply(null, args); this.#value = value; this.textContent = value.toString(); this.dispatchEvent(new Event('propagate', { bubbles: true })); this.setAttribute('type', typeof value); } catch (error) { console.log(error); } } #invalidated = false; handleEvent(event: Event) { switch (event.type) { case 'propagate': { // This deduplicates call similar to a topological sort algorithm. if (this.#invalidated) return; this.#invalidated = true; queueMicrotask(() => this.#evaluate()); return; } } } }