Skip to content

Commit

Permalink
feat: support fixed column widths (#71)
Browse files Browse the repository at this point in the history
* feat: support fixed column widths

* chore: add example for fixed table width

* fix: ignore padding when column width is fixed
  • Loading branch information
mdonnalley authored Jan 6, 2025
1 parent c0aee83 commit dbe7be8
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 42 deletions.
26 changes: 26 additions & 0 deletions examples/fixed-width-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {printTable} from '../src/index.js'

const data = [
{
actual: 'This is a long string of the actual test results. '.repeat(50),
expected: 'This is a long string of the expected test results. '.repeat(10),
result: 'Passed',
test: 'Topic',
},
]

printTable({
columns: [
{key: 'test', width: '10%'},
{key: 'result', width: '10%'},
{key: 'expected', width: '40%'},
{key: 'actual', width: '40%'},
],
data,
headerOptions: {
formatter: 'capitalCase',
},
overflow: 'wrap',
title: 'Fixed-Width Columns',
titleOptions: {bold: true},
})
43 changes: 43 additions & 0 deletions examples/fixed-width-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import ansis from 'ansis'
import terminalLink from 'terminal-link'

import {TableOptions, printTable} from '../src/index.js'

const data = [
{
age: 20,
employed: ansis.bold('true'),
id: terminalLink('36329', 'https://example.com/36329'),
name: 'Alice',
},
{
age: 21,
employed: ansis.bold('true'),
id: terminalLink('49032', 'https://example.com/49032'),
name: ansis.dim('Bob'),
},
{
age: 22,
employed: ansis.bold('false'),
id: terminalLink('51786', 'https://example.com/51786'),
name: 'Charlie',
},
]

const basic: TableOptions<(typeof data)[number]> = {
borderStyle: 'all',
columns: ['id', {key: 'name', name: 'First Name'}, 'age', 'employed'],
data,
headerOptions: {
bold: true,
color: '#905de8',
formatter: 'sentenceCase',
},
sort: {
id: 'desc',
},
verticalAlignment: 'center',
width: '100%',
}

printTable(basic)
42 changes: 8 additions & 34 deletions src/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './types.js'
import {
allKeysInCollection,
determineConfiguredWidth,
determineWidthOfWrappedText,
getColumns,
getHeadings,
Expand All @@ -34,44 +35,14 @@ import {
sortData,
} from './utils.js'

/**
* Determines the configured width based on the provided width value.
* If no width is provided, it returns the width of the current terminal.
* If the provided width is a percentage, it calculates the width based on the percentage of the terminal width.
* If the provided width is a number, it returns the provided width.
* If the calculated width is greater than the terminal width, it returns the terminal width.
*
* @param providedWidth - The width value provided.
* @returns The determined configured width.
*/
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) * columns)
: typeof providedWidth === 'string'
? Number.parseInt(providedWidth, 10)
: providedWidth

if (num > columns) {
return columns
}

return num
}

/**
* Determine the width to use for the table.
*
* This allows us to use the minimum width required to display the table if the configured width is too small.
*/
function determineWidthToUse<T>(columns: Column<T>[], configuredWidth: number): number {
function determineWidthToUse<T>(columns: Column<T>[], maxWidth: number, width: number | undefined): number {
const tableWidth = columns.map((c) => c.width).reduce((a, b) => a + b, 0) + columns.length + 1
return tableWidth < configuredWidth ? configuredWidth : tableWidth
return width ?? (tableWidth < maxWidth ? maxWidth : tableWidth)
}

function determineTruncatePosition(overflow: Overflow): 'start' | 'middle' | 'end' {
Expand Down Expand Up @@ -210,6 +181,7 @@ function setup<T extends Record<string, unknown>>(props: TableOptions<T>) {
sort,
title,
verticalAlignment = 'top',
width,
} = props

const headerOptions = noStyle ? {} : ({bold: true, color: 'blue', ...props.headerOptions} satisfies HeaderOptions)
Expand All @@ -219,13 +191,15 @@ function setup<T extends Record<string, unknown>>(props: TableOptions<T>) {
const titleOptions = noStyle ? {} : props.titleOptions

const processedData = maybeStripAnsi(sortData(filter ? data.filter((row) => filter(row)) : data, sort), noStyle)
const tableWidth = width ? determineConfiguredWidth(width) : undefined
const config: Config<T> = {
borderStyle,
columns: props.columns ?? allKeysInCollection(data),
data: processedData,
headerOptions,
horizontalAlignment,
maxWidth: determineConfiguredWidth(maxWidth),
maxWidth: tableWidth ?? determineConfiguredWidth(maxWidth),
width: tableWidth,
overflow,
padding,
verticalAlignment,
Expand Down Expand Up @@ -317,7 +291,7 @@ export function Table<T extends Record<string, unknown>>(props: TableOptions<T>)
} = setup(props)

return (
<Box flexDirection="column" width={determineWidthToUse(columns, config.maxWidth)}>
<Box flexDirection="column" width={determineWidthToUse(columns, config.maxWidth, config.width)}>
{title && <Text {...titleOptions}>{title}</Text>}
{headerComponent({columns, data: {}, key: 'header'})}
{headingComponent({columns, data: headings, key: 'heading'})}
Expand Down
32 changes: 28 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type CellProps = React.PropsWithChildren<{readonly column: number}>

export type HorizontalAlignment = 'left' | 'right' | 'center'
export type VerticalAlignment = 'top' | 'center' | 'bottom'
export type Percentage = `${number}%`

export type ColumnProps<T> = {
/**
Expand All @@ -27,11 +28,13 @@ export type ColumnProps<T> = {
* Vertical alignment of cell content. Overrides the vertical alignment set in the table.
*/
verticalAlignment?: VerticalAlignment
/**
* Set the width of the column. If not provided, it will default to the width of the content.
*/
width?: Percentage | number
}
export type AllColumnProps<T> = {[K in keyof T]: ColumnProps<K>}[keyof T]

export type Percentage = `${number}%`

type TextOptions = {
color?: SupportedColor
backgroundColor?: SupportedColor
Expand Down Expand Up @@ -107,15 +110,35 @@ export type TableOptions<T extends Record<string, unknown>> = {
*/
padding?: number
/**
* Width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%').
* Maximum width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%').
*
* By default, the table will only take up as much space as it needs to fit the content. If it extends beyond the maximum width,
* it will wrap or truncate the content based on the `overflow` option. In other words, this property allows you to set the width
* at which wrapping or truncation occurs.
*
* If not provided, it will default to the width of the terminal (determined by `process.stdout.columns`).
* If not provided, the maximum width will default to the terminal width.
*
* If you provide a number or percentage that is larger than the terminal width, it will default to the terminal width.
*
* If you provide a number or percentage that is too small to fit the table, it will default to the minimum width of the table.
*/
maxWidth?: Percentage | number
/**
* Exact width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%').
*
* By default, the table will only take up as much space as it needs to fit the content. If you set the `width` option, the table will
* always take up that amount of space, regardless of the content. If the content is too large, it will wrap or truncate based on the
* `overflow` option. If it's too small, it will add empty space evenly across the columns.
*
* Setting this property will override the `maxWidth` option.
*
* If not provided, it will default to the natural width of the table.
*
* If you provide a number or percentage that is larger than the terminal width, it will default to the terminal width.
*
* If you provide a number or percentage that is too small to fit the table, it will default to the minimum width of the table.
*/
width?: Percentage | number
/**
* Overflow behavior for cells. Defaults to 'truncate'.
*/
Expand Down Expand Up @@ -197,6 +220,7 @@ export type Config<T> = {
borderStyle: BorderStyle
horizontalAlignment: HorizontalAlignment
verticalAlignment: VerticalAlignment
width: number | undefined
}

export type RowConfig = {
Expand Down
59 changes: 55 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {orderBy} from 'natural-orderby'
import {env} from 'node:process'
import stripAnsi from 'strip-ansi'

import {Column, ColumnProps, Config, Sort} from './types.js'
import {Column, ColumnProps, Config, Percentage, Sort} from './types.js'

/**
* Intersperses a list of elements with another element.
Expand Down Expand Up @@ -51,8 +51,38 @@ export function determineWidthOfWrappedText(text: string): number {
return lines.reduce((max, line) => Math.max(max, line.length), 0)
}

/**
* Determines the configured width based on the provided width value.
* If no width is provided, it returns the width of the current terminal.
* If the provided width is a percentage, it calculates the width based on the percentage of the terminal width.
* If the provided width is a number, it returns the provided width.
* If the calculated width is greater than the terminal width, it returns the terminal width.
*
* @param providedWidth - The width value provided.
* @returns The determined configured width.
*/
export 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) * columns)
: typeof providedWidth === 'string'
? Number.parseInt(providedWidth, 10)
: providedWidth

if (num > columns) {
return columns
}

return num
}

export function getColumns<T extends Record<string, unknown>>(config: Config<T>, headings: Partial<T>): Column<T>[] {
const {columns, horizontalAlignment, maxWidth, overflow, verticalAlignment} = config
const {columns, horizontalAlignment, maxWidth, overflow, verticalAlignment, width} = config

const widths: Column<T>[] = columns.map((propsOrKey) => {
const props: ColumnProps<keyof T> = typeof propsOrKey === 'object' ? propsOrKey : {key: propsOrKey}
Expand All @@ -64,11 +94,17 @@ export function getColumns<T extends Record<string, unknown>>(config: Config<T>,
const value = data[key]

if (value === undefined || value === null) return 0
// Some terminals don't play nicely with zero-width characters, so we replace them with spaces.
// https://github.com/sindresorhus/terminal-link/issues/18
// https://github.com/Shopify/cli/pull/995
return determineWidthOfWrappedText(stripAnsi(String(value).replaceAll('​', ' ')))
})

const header = String(headings[key]).length
const width = Math.max(...data, header) + padding * 2
// If a column width is provided, use that. Otherwise, use the width of the largest cell in the column.
const columnWidth = props.width
? determineConfiguredWidth(props.width, width ?? maxWidth)
: Math.max(...data, header) + padding * 2

return {
column: key,
Expand All @@ -77,7 +113,7 @@ export function getColumns<T extends Record<string, unknown>>(config: Config<T>,
overflow: props.overflow ?? overflow,
padding,
verticalAlignment: props.verticalAlignment ?? verticalAlignment,
width,
width: columnWidth,
}
})

Expand Down Expand Up @@ -112,6 +148,21 @@ export function getColumns<T extends Record<string, unknown>>(config: Config<T>,
seen.clear()
// At most, reduce the width to the padding + 3
reduceColumnWidths((col) => col.padding * 2 + 3)

// If the table width was provided AND it's greater than the calculated table width, expand the columns to fill the width
if (width && width > tableWidth) {
const extraWidth = width - tableWidth
const extraWidthPerColumn = Math.floor(extraWidth / widths.length)

for (const w of widths) {
w.width += extraWidthPerColumn
// if it's the last column, add all the remaining width
if (w === widths.at(-1)) {
w.width += extraWidth - extraWidthPerColumn * widths.length
}
}
}

return widths
}

Expand Down

0 comments on commit dbe7be8

Please sign in to comment.