diff --git a/package.json b/package.json index ed9f04c..324f117 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "@oclif/core": "^4", "@types/react": "^18.3.3", "change-case": "^5.4.4", + "cli-truncate": "^4.0.0", "ink": "^5.0.1", "natural-orderby": "^3.0.2", "object-hash": "^3.0.0", "react": "^18.3.1", - "strip-ansi": "^7.1.0" + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "devDependencies": { "@commitlint/config-conventional": "^19", diff --git a/src/index.ts b/src/index.ts index 05e6fc6..ba37505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export {makeTable} from './table.js' +export {makeTable, makeTables} from './table.js' +export type {TableProps} from './types.js' diff --git a/src/table.tsx b/src/table.tsx index 1847d2d..1df121c 100644 --- a/src/table.tsx +++ b/src/table.tsx @@ -1,16 +1,19 @@ /* eslint-disable react/prop-types */ +import cliTruncate from 'cli-truncate' import {Box, Text, render} from 'ink' import {sha1} from 'object-hash' import React from 'react' import stripAnsi from 'strip-ansi' +import wrapAnsi from 'wrap-ansi' import {BORDER_SKELETONS} from './skeletons.js' import { CellProps, Column, Config, + ContainerProps, HeaderOptions, Percentage, RowConfig, @@ -18,7 +21,7 @@ import { ScalarDict, TableProps } from './types.js'; -import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData, truncate, wrap} from './utils.js'; +import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData} from './utils.js'; /** * Determines the configured width based on the provided width value. @@ -30,18 +33,18 @@ import {allKeysInCollection, getColumns, getHeadings, intersperse, sortData, tru * @param providedWidth - The width value provided. * @returns The determined configured width. */ -function determineConfiguredWidth(providedWidth: number | Percentage | undefined): number { - if (!providedWidth) return process.stdout.columns +function determineConfiguredWidth(providedWidth: number | Percentage | undefined, columns = process.stdout.columns): number { + if (!providedWidth) return columns const num = typeof providedWidth === 'string' && providedWidth.endsWith('%') - ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * process.stdout.columns) + ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns) : typeof providedWidth === 'string' ? Number.parseInt(providedWidth, 10) : providedWidth - if (num > process.stdout.columns) { - return process.stdout.columns + if (num > columns) { + return columns } return num @@ -59,15 +62,16 @@ function determineWidthToUse(columns: Column[], configuredWidth: number): export function Table(props: TableProps) { const { - align = 'left', borderStyle = 'all', data, filter, + horizontalAlignment = 'left', maxWidth, orientation = 'horizontal', overflow = 'truncate', padding = 1, sort, + verticalAlignment = 'top', } = props const headerOptions = {bold: true, color: 'blue', ...props.headerOptions} satisfies HeaderOptions @@ -86,24 +90,27 @@ export function Table(props: TableProps) { const headings = getHeadings(config) const dataComponent = row({ - align, cell: Cell, + horizontalAlignment, overflow, padding, + props: { + alignItems: verticalAlignment === 'top' ? 'flex-start' : verticalAlignment === 'center' ? 'center' : 'flex-end', + }, skeleton: BORDER_SKELETONS[config.borderStyle].data, }) const footerComponent = row({ - align, cell: Skeleton, + horizontalAlignment, overflow, padding, skeleton: BORDER_SKELETONS[config.borderStyle].footer, }) const headerComponent = row({ - align, cell: Skeleton, + horizontalAlignment, overflow, padding, skeleton: BORDER_SKELETONS[config.borderStyle].header, @@ -111,16 +118,16 @@ export function Table(props: TableProps) { const {headerFooter} = BORDER_SKELETONS[config.borderStyle] const headerFooterComponent = headerFooter ? row({ - align, cell: Skeleton, + horizontalAlignment, overflow, padding, skeleton: headerFooter, }) : () => false const headingComponent = row({ - align, cell: Header, + horizontalAlignment, overflow, padding, props: config.headerOptions, @@ -128,8 +135,8 @@ export function Table(props: TableProps) { }) const separatorComponent = row({ - align, cell: Skeleton, + horizontalAlignment, overflow, padding, skeleton: BORDER_SKELETONS[config.borderStyle].separator, @@ -191,7 +198,7 @@ export function Table(props: TableProps) { */ function row(config: RowConfig): (props: RowProps) => React.ReactNode { // This is a component builder. We return a function. - const {align, overflow, padding, skeleton} = config + const {horizontalAlignment, overflow, padding, skeleton} = config return (props) => { const data = props.columns.map((column, colI) => { @@ -212,8 +219,8 @@ function row(config: RowConfig): (props: RowProps) => R // if the visible length of the value is greater than the column width, truncate or wrap stripAnsi(String(value)).length >= column.width ? overflow === 'wrap' - ? wrap(String(value), column.width - padding * 2, padding) - : truncate(String(value), column.width - (3 + padding * 2)) + ? wrapAnsi(String(value), column.width - padding * 2, {hard: true, trim: true}).replaceAll('\n', `${' '.repeat(padding)}\n${' '.repeat(padding)}`) + : cliTruncate(String(value), column.width - (3 + padding * 2)) : String(value) const spaces = @@ -223,10 +230,10 @@ function row(config: RowConfig): (props: RowProps) => R let marginLeft: number let marginRight: number - if (align === 'left') { + if (horizontalAlignment === 'left') { marginLeft = padding marginRight = spaces - marginLeft - } else if (align === 'center') { + } else if (horizontalAlignment === 'center') { marginLeft = Math.floor(spaces / 2) marginRight = Math.ceil(spaces / 2) } else { @@ -274,7 +281,12 @@ export function Header(props: React.PropsWithChildren) { * Renders a cell in the table. */ export function Cell(props: CellProps) { - return {props.children} + return ( + + {props.children} + + + ) } /** @@ -299,3 +311,31 @@ export function makeTable(options: TableProps): void { const instance = render() instance.unmount() } + +function Container(props: ContainerProps) { + return ( + + {props.children} + + ) +} + +export function makeTables( + tables: {[P in keyof T]: TableProps}, + options?: Omit +): void { + const columns = process.stdout.columns - ((options?.margin ?? 0) * 2) + + const processed = tables.map((table) => ({ + ...table, + // adjust maxWidth to account for margin + maxWidth: determineConfiguredWidth(table.maxWidth, columns) + })) + + const instance = render( + + {processed.map((table) =>
)} + + ) + instance.unmount() +} diff --git a/src/types.ts b/src/types.ts index bf60114..3696f6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,8 @@ export type ScalarDict = { export type CellProps = React.PropsWithChildren<{readonly column: number}> -export type ColumnAlignment = 'left' | 'right' | 'center' +export type HorizontalAlignment = 'left' | 'right' | 'center' +export type VerticalAlignment = 'top' | 'center' | 'bottom' export interface ColumnProps { key: T @@ -114,7 +115,7 @@ export type TableProps = { /** * Align data in columns. Defaults to 'left'. Only applies to horizontal orientation. */ - align?: ColumnAlignment + horizontalAlignment?: HorizontalAlignment /** * Apply a filter to each row in the table. */ @@ -167,6 +168,10 @@ export type TableProps = { * ``` */ orientation?: 'horizontal' | 'vertical' + /** + * Vertical alignment of cell content. Defaults to 'top'. Only applies to horizontal orientation. + */ + verticalAlignment?: VerticalAlignment } export type Config = { @@ -205,7 +210,8 @@ export type RowConfig = { } overflow?: Overflow props?: Record - align?: ColumnAlignment + horizontalAlignment?: HorizontalAlignment + verticalAlignment?: VerticalAlignment } export type RowProps = { @@ -219,3 +225,12 @@ export type Column = { column: keyof T width: number } + +export type ContainerProps = { + readonly alignItems?: 'flex-start' | 'flex-end' | 'center' + readonly children: React.ReactNode + readonly columnGap?: number + readonly direction?: 'row' | 'column' + readonly margin?: number + readonly rowGap?: number +} diff --git a/src/utils.ts b/src/utils.ts index b5a62fd..ae74bcf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -34,29 +34,6 @@ export function sortData(data: T[], sort?: Sort | undef return orderBy(data, identifiers, orders) } -export const truncate = (value: string, length: number) => `${value.slice(0, length)}...` - -// insert new line every x characters -export const wrap = (value: string, position: number, padding: number) => { - const chars = [...value] - const lines = [] - let line = '' - let count = 0 - for (const char of chars) { - if (count === position) { - lines.push(line) - line = '' - count = 0 - } - - line += char - count++ - } - - lines.push(line) - return lines.join(`${' '.repeat(padding)}\n${' '.repeat(padding)}`) -} - export function allKeysInCollection(data: T[]): (keyof T)[] { const keys = new Set() for (const row of data) { diff --git a/test/util.test.ts b/test/util.test.ts index f6e206e..0c46a69 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,6 +1,6 @@ import {config, expect} from 'chai' -import {intersperse, sortData, truncate, wrap} from '../src/utils.js' +import {intersperse, sortData} from '../src/utils.js' config.truncateThreshold = 0 @@ -64,46 +64,3 @@ describe('sortData', () => { expect(sortData(data, sort)).to.deep.equal(expected) }) }) - -describe('truncate', () => { - it('should truncate string', () => { - const value = 'foobar' - const length = 3 - const expected = 'foo...' - expect(truncate(value, length)).to.equal(expected) - }) -}) - -describe('wrap', () => { - it('should wrap string', () => { - const value = 'foobar' - const position = 3 - const padding = 0 - const expected = 'foo\nbar' - expect(wrap(value, position, padding)).to.deep.equal(expected) - }) - - it('should wrap string with padding', () => { - const value = 'foobar' - const position = 3 - const padding = 1 - const expected = 'foo \n bar' - expect(wrap(value, position, padding)).to.deep.equal(expected) - }) - - it('should wrap string multiple times', () => { - const value = 'foobarbaz' - const position = 3 - const padding = 0 - const expected = 'foo\nbar\nbaz' - expect(wrap(value, position, padding)).to.deep.equal(expected) - }) - - it('should wrap string multiple times with padding', () => { - const value = 'foobarbaz' - const position = 3 - const padding = 1 - const expected = 'foo \n bar \n baz' - expect(wrap(value, position, padding)).to.deep.equal(expected) - }) -})