@smkit/ui
Version:
UI Kit of SberMarketing
246 lines (245 loc) • 8.97 kB
JavaScript
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;
}