UNPKG

@smkit/ui

Version:

UI Kit of SberMarketing

246 lines (245 loc) 8.97 kB
import { get, writable } from 'svelte/store'; import { getContext, setContext } from 'svelte'; import { Types } from './types'; const dateRegex = /^(\d{1,2}[\/\.-]\d{1,2}[\/\.-]\d{4})|(\d{4}[\/\.-]\d{1,2}[\/\.-]\d{1,2})/; export class Table { body = writable(); header = writable(); config = writable(); controls = writable({ sort: { by: null, asc: true }, pinned: [] }); constructor() { this.controls.subscribe((controls) => { // @ts-expect-error sortCallback is async to be able request data this.body.update((data) => { if (!data) return []; const config = get(this.config); const onSort = controls.sort.by?.onSort ?? config?.onSort ?? Table.sort; return onSort(data, controls.sort); }); }); setContext('controls', this.controls); setContext('rendered', { body: this.body, header: this.header, config: this.config }); setContext('self', this); } static cols2rows(cols) { const names = Object.keys(cols); const col = cols[names[0]]; const rows = []; for (const i of col.keys()) { const row = {}; for (const name of names) { row[name] = cols[name][i]; } rows.push(row); } return rows; } static sort(data, controls) { const head = controls.by; if (!head) return data; const order = Array.from(Array(data.length).keys()); const direction = controls.asc ? -1 : 1; order.sort((a, b) => { if (head.type === 'number' || head.type === 'boolean') { return (data[a][head.name] - data[b][head.name]) * direction; } else if (head.type === 'string') { return ((data[a][head.name] ?? '').localeCompare(data[b][head.name] ?? '') * direction); } else if (head.type === 'list') { return ((Number(data[a][head.name]?.length) - Number(data[b][head.name]?.length)) * direction); } else if (head.type === 'date') { return (Date.parse(data[a][head.name]) - Date.parse(data[b][head.name])) * direction; } else { return 0; } }); return order.map((i) => data[i]); } render({ data, columns, config }) { const rows = Array.isArray(data) ? data : Table.cols2rows(data); const headController = new Head(rows, columns, config); const header = headController.header.map((h, i) => headController.prepared({ ...h, index: i }, rows)); const { pinned, notPinned } = this.splitPin(header); const orderedHeader = pinned.concat(notPinned); const body = rows; this.header.set(orderedHeader); this.body.set(body); this.config.set(config ?? {}); } static get stores() { return { ...getContext('rendered'), controls: getContext('controls') }; } static get self() { return getContext('self'); } splitPin(header, updateHead) { const pinned = header.filter((h) => h.pinned && (!updateHead || h.name !== updateHead.name)); const notPinned = header.filter((h) => !h.pinned && (!updateHead || h.name !== updateHead.name)); let offsetLeft = 0; for (const [i, head] of pinned.entries()) { pinned[i].offsetLeft = offsetLeft; offsetLeft += Number(head.width); } return { pinned, notPinned, offsetLeft }; } updatePin(updateHead) { this.header.update((header) => { const { pinned, notPinned, offsetLeft } = this.splitPin(header, updateHead); updateHead.offsetLeft = offsetLeft; updateHead.pinned = !updateHead.pinned; if (updateHead.pinned) { return pinned.concat(updateHead, ...notPinned); } // Return head to the same position notPinned.splice(updateHead.index ?? 0, 0, updateHead); return pinned.concat(...notPinned); }); } } class Head { data; columns; config; constructor(data, columns, config) { this.data = data; this.columns = columns; this.config = config; } titled(head) { if (!head.title) { head.title = head.name; } return head; } sized(head) { if (typeof head?.width === 'string') return head; const minWidth = head?.minWidth ?? this.config?.head?.minWidth ?? 60; const maxWidth = head?.maxWidth ?? this.config?.head?.maxWidth ?? 1000; head.width = head?.width ?? this.config?.head?.width ?? (head.title?.length ?? head.name?.length) * 12 + 10; head.width = (head.width < minWidth ? minWidth : head.width); // set min-width head.width = (head.width ?? 0) > maxWidth ? maxWidth : head.width; // set max-width return head; } typed(head, data) { if (!head.type) { let sample; for (const row of data) { const testSample = row[head.name]; if (testSample !== null && testSample !== undefined) { sample = testSample; break; } } if (Array.isArray(sample)) { head.type = Types.List; } else if (typeof sample === 'boolean') { head.type = Types.Boolean; } else if (typeof sample === 'number') { head.type = Types.Number; } else if (!Number.isNaN(Number(sample))) { //TODO check this head.type = Types.Number; } else if ( // @ts-expect-error handle invalid date new Date(sample) !== 'Invalid Date' && !isNaN(new Date(sample).getTime()) && dateRegex.test(sample)) { head.type = Types.Date; } else if (typeof sample === 'object') { head.type = Types.Object; } else { head.type = Types.String; } } return head; } prepared(head, data) { return this.typed(this.sized(this.titled(head)), data); } get header() { if (!this.columns) { // handle undefined. Use first row of data as head const row = this.data[0]; return Object.keys(row).map((c) => { return { name: c }; }); } if (Array.isArray(this.columns) && this.columns.length) { // Handle string[] and Head[] const sample = this.columns[0]; if (typeof sample === 'string') { // Handle string[] type return this.columns.map((c) => { return { name: c }; }); } else { // We have Head[], just check if it's valid this.columns.map((h) => { if (!h.name) { throw TypeError('columns prop: name is required'); } }); return this.columns; } } if (typeof this.columns === 'object') { return Object.entries(this.columns).map(([k, v]) => { if (typeof v === 'string') { // Handle Record<string, string>. Expected that value is a title name:title return { name: k, title: v }; } else if (v?.title) { // Handle Record<string, Head> where string is name:{other} return { name: k, ...v }; } else { throw TypeError("columns property: can't process variant with Record<string, Head> interface. Values must be { name: {title: string, width?: string | number} };"); } }); } throw TypeError("columns property: can't identify and process format. It must satisfy one of variants string[], Head[], {name: title} or {name: Head}};"); } } export class Cell { static width2css(width) { if (typeof width === 'number') return `${width}px`; if (typeof width === 'string') return width; throw Error(`Unprocessable format of cell width: ${width}`); } } export function getTypeFromString(string) { if (!isNaN(parseFloat(string)) && isFinite(Number(string))) { return Types.Number; } const date = new Date(string); if (!isNaN(date.getTime())) { return Types.Date; } return Types.String; }