Repository: sindresorhus/boxen (js)

Issue:
New option: title
Hi, would be ncie to have the possibility to add a title to a box. Like:
```
┌Tasks─────────────┐                                                                                                                                                                                                                                                                                       │Task number 1...                             │
└───────────────  ┘
```

I've already edited the source code to make it possible, I can open a PR.

Pull request description:
Add `title` and `titleAlignment` options
This PR adds a new feature allowing users to add titles to their boxes.
That is a very handful feature for building CLI Tools.

See example below:

```js
const title = 'Beautiful title';
console.log('\n\n' + boxen('This box has a nice title', {title}) + '\n');
console.log('\n\n' + boxen('This box has a centered title', {title, titleAlign: 'center'}) + '\n');

// Output:

┌ Beautiful title ────────┐
│This box has a nice title│
└─────────────────────────┘

┌────── Beautiful title ──────┐
│This box has a centered title│
└─────────────────────────────┘
```

- The titles can be left/center/right aligned. 
- The titles supports custom coloration (such as chalk...).
- Titles are sliced if it were to exceed the terminal's size.
- In this limit, the boxes width are extended to accommodate the title's size as much as possible.

Fixes #56 

Patch:
diff --git a/example.js b/example.js
index bf0d1b0..26bfb51 100644
--- a/example.js
+++ b/example.js
@@ -46,11 +46,16 @@ console.log('\n\n' + boxen(chalk.black('unicorn'), {
 }) + '\n');
 
 const sentences = 'Unbreakable_text_because_it_has_no_spaces '.repeat(5);
-console.log('\n\n' + boxen(sentences, {align: 'left'}) + '\n');
+console.log('\n\n' + boxen(sentences, {textAlignment: 'left'}) + '\n');
 
-console.log('\n\n' + boxen(sentences, {align: 'center'}) + '\n');
+console.log('\n\n' + boxen(sentences, {textAlignment: 'center'}) + '\n');
 
-console.log('\n\n' + boxen(sentences, {align: 'right', padding: {left: 1, right: 1, top: 0, bottom: 0}}) + '\n');
+console.log('\n\n' + boxen(sentences, {textAlignment: 'right', padding: {left: 1, right: 1, top: 0, bottom: 0}}) + '\n');
 
 const longWord = 'x'.repeat(process.stdout.columns + 20);
-console.log('\n\n' + boxen(longWord, {align: 'center'}) + '\n');
+console.log('\n\n' + boxen(longWord, {textAlignment: 'center'}) + '\n');
+
+const title = 'Beautiful title';
+console.log('\n\n' + boxen('This box has a nice title', {title}) + '\n');
+
+console.log('\n\n' + boxen('This box has a centered title', {title, titleAlignment: 'center'}) + '\n');
diff --git a/index.d.ts b/index.d.ts
index 9112414..a99d5db 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -121,8 +121,50 @@ declare namespace boxen {
 		Align the text in the box based on the widest line.
 
 		@default 'left'
+		@deprecated Use `textAlignment` instead.
 		*/
 		readonly align?: 'left' | 'right' | 'center';
+
+		/**
+		Align the text in the box based on the widest line.
+
+		@default 'left'
+		*/
+		readonly textAlignment?: 'left' | 'right' | 'center';
+
+		/**
+		Display a title at the top of the box.
+		If needed, the box will horizontally expand to fit the title.
+
+		@example
+		```
+		console.log(boxen('foo bar', {title: 'example'}));
+		// ┌ example ┐
+		// │foo bar  │
+		// └─────────┘
+		```
+		*/
+		readonly title?: string;
+
+		/**
+		Align the title in the top bar.
+
+		@default 'left'
+
+		@example
+		```
+		console.log(boxen('foo bar foo bar', {title: 'example', textAlignmentTitle: 'center'}));
+		// ┌─── example ───┐
+		// │foo bar foo bar│
+		// └───────────────┘
+
+		console.log(boxen('foo bar foo bar', {title: 'example', textAlignmentTitle: 'right'}));
+		// ┌────── example ┐
+		// │foo bar foo bar│
+		// └───────────────┘
+		```
+		*/
+		readonly titleAlignment?: 'left' | 'right' | 'center';
 	}
 }
 
diff --git a/index.js b/index.js
index f62f871..3b98d04 100644
--- a/index.js
+++ b/index.js
@@ -9,7 +9,6 @@ const wrapAnsi = require('wrap-ansi');
 
 const NL = '\n';
 const PAD = ' ';
-const BORDERS_WIDTH = 2;
 
 const terminalColumns = () => {
 	const {env, stdout, stderr} = process;
@@ -75,6 +74,35 @@ const getBorderChars = borderStyle => {
 	return chararacters;
 };
 
+const makeTitle = (text, horizontal, alignement) => {
+	let title = '';
+
+	const textWidth = stringWidth(text);
+
+	switch (alignement) {
+		case 'left':
+			title = text + horizontal.slice(textWidth);
+			break;
+		case 'right':
+			title = horizontal.slice(textWidth) + text;
+			break;
+		default:
+			horizontal = horizontal.slice(textWidth);
+
+			if (horizontal.length % 2 === 1) { // This is needed in case the length is odd
+				horizontal = horizontal.slice(Math.floor(horizontal.length / 2));
+				title = horizontal.slice(1) + text + horizontal; // We reduce the left part of one character to avoid the bar to go beyond its limit
+			} else {
+				horizontal = horizontal.slice(horizontal.length / 2);
+				title = horizontal + text + horizontal;
+			}
+
+			break;
+	}
+
+	return title;
+};
+
 const makeContentText = (text, padding, columns, align) => {
 	text = ansiAlign(text, {align});
 	let lines = text.split(NL);
@@ -158,11 +186,19 @@ module.exports = (text, options) => {
 		padding: 0,
 		borderStyle: 'single',
 		dimBorder: false,
-		align: 'left',
+		textAlignment: 'left',
 		float: 'left',
+		titleAlignment: 'left',
 		...options
 	};
 
+	// This option is deprecated
+	if (options.align) {
+		options.textAlignment = options.align;
+	}
+
+	const BORDERS_WIDTH = 2;
+
 	if (options.borderColor && !isColorValid(options.borderColor)) {
 		throw new Error(`${options.borderColor} is not a valid borderColor`);
 	}
@@ -186,6 +222,18 @@ module.exports = (text, options) => {
 
 	let contentWidth = widestLine(wrapAnsi(text, columns - BORDERS_WIDTH, {hard: true})) + padding.left + padding.right;
 
+	// This prevents the title bar to exceed the console's width
+	let title = options.title && options.title.slice(0, columns - 4 - margin.left - margin.right);
+
+	if (title) {
+		title = ` ${title} `;
+	}
+
+	// Make the box larger to fit a larger title
+	if (stringWidth(title) > contentWidth) {
+		contentWidth = stringWidth(title);
+	}
+
 	if ((margin.left && margin.right) && contentWidth + BORDERS_WIDTH + margin.left + margin.right > columns) {
 		// Let's assume we have margins: left = 3, right = 5, in total = 8
 		const spaceForMargins = columns - contentWidth - BORDERS_WIDTH;
@@ -201,7 +249,7 @@ module.exports = (text, options) => {
 	// Prevent content from exceeding the console's width
 	contentWidth = Math.min(contentWidth, columns - BORDERS_WIDTH - margin.left - margin.right);
 
-	text = makeContentText(text, padding, contentWidth, options.align);
+	text = makeContentText(text, padding, contentWidth, options.textAlignment);
 
 	let marginLeft = PAD.repeat(margin.left);
 
@@ -214,7 +262,7 @@ module.exports = (text, options) => {
 	}
 
 	const horizontal = chars.horizontal.repeat(contentWidth);
-	const top = colorizeBorder(NL.repeat(margin.top) + marginLeft + chars.topLeft + horizontal + chars.topRight);
+	const top = colorizeBorder(NL.repeat(margin.top) + marginLeft + chars.topLeft + (title ? makeTitle(title, horizontal, options.titleAlignment) : horizontal) + chars.topRight);
 	const bottom = colorizeBorder(marginLeft + chars.bottomLeft + horizontal + chars.bottomRight + NL.repeat(margin.bottom));
 	const side = colorizeBorder(chars.vertical);
 
diff --git a/readme.md b/readme.md
index 7780b8a..c7ed496 100644
--- a/readme.md
+++ b/readme.md
@@ -34,6 +34,13 @@ console.log(boxen('unicorn', {padding: 1, margin: 1, borderStyle: 'double'}));
    ╚═════════════╝
 
 */
+
+console.log(boxen('unicorns love rainbows', {title: 'magical', titleAlignment: 'center'}));
+/*
+┌────── magical ───────┐
+│unicorns love rainbows│
+└──────────────────────┘
+*/
 ```
 
 ## API
@@ -127,6 +134,56 @@ Default: `false`
 
 Reduce opacity of the border.
 
+##### title
+
+Type: `string`
+
+Display a title at the top of the box.
+If needed, the box will horizontally expand to fit the title.
+
+Example:
+```js
+console.log(boxen('foo bar', {title: 'example'}));
+/*
+┌ example ┐
+│foo bar  │
+└─────────┘
+*/
+```
+
+##### titleAlignment
+
+Type: `string`\
+Default: `'left'`
+
+Align the title in the top bar.
+
+Values:
+- `'left'`
+```js
+/*
+┌ example ──────┐
+│foo bar foo bar│
+└───────────────┘
+*/
+```
+- `'center'`
+```js
+/*
+┌─── example ───┐
+│foo bar foo bar│
+└───────────────┘
+*/
+```
+- `'right'`
+```js
+/*
+┌────── example ┐
+│foo bar foo bar│
+└───────────────┘
+*/
+```
+
 ##### padding
 
 Type: `number | object`\
@@ -160,7 +217,7 @@ Values: `'black'` `'red'` `'green'` `'yellow'` `'blue'` `'magenta'` `'cyan'` `'w
 
 Color of the background.
 
-##### align
+##### textAlignment
 
 Type: `string`\
 Default: `'left'`\


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 072e175..bf6b370 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -19,6 +19,8 @@ const spacing: Spacing = {
 };
 
 expectType<string>(boxen('unicorns'));
+expectType<string>(boxen('unicorns', {title: 'title'}));
+expectType<string>(boxen('unicorns', {title: 'title', titleAlignment: 'center'}));
 expectType<string>(boxen('unicorns', {borderColor: 'green'}));
 expectType<string>(boxen('unicorns', {borderColor: '#ff0000'}));
 expectType<string>(boxen('unicorns', {borderStyle: 'double'}));
@@ -31,4 +33,4 @@ expectType<string>(boxen('unicorns', {margin: spacing}));
 expectType<string>(boxen('unicorns', {float: 'center'}));
 expectType<string>(boxen('unicorns', {backgroundColor: 'green'}));
 expectType<string>(boxen('unicorns', {backgroundColor: '#ff0000'}));
-expectType<string>(boxen('unicorns', {align: 'right'}));
+expectType<string>(boxen('unicorns', {textAlignment: 'right'}));
diff --git a/test.js b/test.js
index d786357..88f54a3 100644
--- a/test.js
+++ b/test.js
@@ -16,6 +16,101 @@ test('creates a box', t => {
 	`);
 });
 
+test('title option', t => {
+	compare(t, boxen('foo', {title: 'title'}), `
+┌ title ┐
+│foo    │
+└───────┘
+	`);
+});
+
+test('titleAlignment center option', t => {
+	compare(t, boxen('foo bar foo bar', {
+		title: 'title',
+		titleAlignment: 'center'
+	}), `
+┌──── title ────┐
+│foo bar foo bar│
+└───────────────┘
+	`);
+});
+
+test('titleAlignment right option', t => {
+	compare(t, boxen('foo bar foo bar', {
+		title: 'title',
+		titleAlignment: 'right'
+	}), `
+┌──────── title ┐
+│foo bar foo bar│
+└───────────────┘
+	`);
+});
+
+test('title and textAlignment', t => {
+	compare(t, boxen('Hello !\nThis text is on the right\nAmazing!', {
+		title: 'title',
+		textAlignment: 'right'
+	}), `
+┌ title ──────────────────┐
+│                  Hello !│
+│This text is on the right│
+│                 Amazing!│
+└─────────────────────────┘
+	`);
+});
+
+test('box size adapts to title length', t => {
+	compare(t, 	boxen('Hello !\nThis text is on the center\nAmazing!\nIt stretched to the title length.', {
+		title: 'This is a very large title with many words in it',
+		textAlignment: 'center'
+	}), `
+┌ This is a very large title with many words in it ┐
+│                     Hello !                      │
+│           This text is on the center             │
+│                    Amazing!                      │
+│        It stretched to the title length.         │
+└──────────────────────────────────────────────────┘
+	`);
+});
+
+test('title with textAlignment and text padding', t => {
+	compare(t, 	boxen('Hello !\nAll the text here is on the right\nEven the title.\nAmazing padding too ;)', {
+		title: 'This is a title',
+		titleAlignment: 'right',
+		textAlignment: 'right',
+		padding: 2
+	}), `
+┌──────────────────────────── This is a title ┐
+│                                             │
+│                                             │
+│                                Hello !      │
+│      All the text here is on the right      │
+│                        Even the title.      │
+│                 Amazing padding too ;)      │
+│                                             │
+│                                             │
+└─────────────────────────────────────────────┘
+	`);
+});
+
+test('title with textAlignment, padding and margin', t => {
+	compare(t, 	boxen('Hello !\nThis text has padding and margin.\nCentered too !', {
+		title: 'This is a title',
+		titleAlignment: 'center',
+		textAlignment: 'center',
+		margin: 1,
+		padding: 1
+	}), `
+   ┌─────────── This is a title ───────────┐
+   │                                       │
+   │                Hello !                │
+   │   This text has padding and margin.   │
+   │            Centered too !             │
+   │                                       │
+   └───────────────────────────────────────┘
+	`);
+});
+
 test('padding option', t => {
 	compare(t, boxen('foo', {padding: 2}), `
 ┌───────────────┐
@@ -277,10 +372,10 @@ test('throws on unexpected backgroundColor', t => {
 	}, /backgroundColor/);
 });
 
-test('align option `center`', t => {
+test('textAlignment option `center`', t => {
 	const beautifulColor = chalk.magenta('B E A U T I F U L');
 	compare(t, boxen(`Boxes are\n${beautifulColor}\nand beneficial too!`, {
-		align: 'center',
+		textAlignment: 'center',
 		padding: 1
 	}), `
 ┌─────────────────────────┐
@@ -293,9 +388,9 @@ test('align option `center`', t => {
 	`);
 });
 
-test('align option `right`', t => {
+test('textAlignment option `right`', t => {
 	const beautifulColor = chalk.magenta('B E A U T I F U L');
-	compare(t, boxen(`Boxes are\n${beautifulColor}\nand beneficial too!`, {align: 'right'}), `
+	compare(t, boxen(`Boxes are\n${beautifulColor}\nand beneficial too!`, {textAlignment: 'right'}), `
 ┌───────────────────┐
 │          Boxes are│
 │  ${beautifulColor}│
@@ -304,9 +399,9 @@ test('align option `right`', t => {
 	`);
 });
 
-test('align option `left`', t => {
+test('textAlignment option `left`', t => {
 	const beautifulColor = chalk.magenta('B E A U T I F U L');
-	compare(t, boxen(`Boxes are\n${beautifulColor}\nand beneficial too!`, {align: 'left'}), `
+	compare(t, boxen(`Boxes are\n${beautifulColor}\nand beneficial too!`, {textAlignment: 'left'}), `
 ┌───────────────────┐
 │Boxes are          │
 │${beautifulColor}  │
@@ -315,30 +410,30 @@ test('align option `left`', t => {
 	`);
 });
 
-test('align option (left) does not throw when colorized content > columns', t => {
+test('textAlignment option (left) does not throw when colorized content > columns', t => {
 	console.log('process.stdout.columns', process.stdout.columns);
 	const longContent = chalk.green('ab').repeat(process.stdout.columns);
 	t.notThrows(() => {
 		boxen(longContent, {
-			align: 'left'
+			textAlignment: 'left'
 		});
 	});
 });
 
-test('align option (center) does not throw when colorized content > columns', t => {
+test('textAlignment option (center) does not throw when colorized content > columns', t => {
 	const longContent = chalk.green('ab').repeat(process.stdout.columns);
 	t.notThrows(() => {
 		boxen(longContent, {
-			align: 'center'
+			textAlignment: 'center'
 		});
 	});
 });
 
-test('align option (right) does not throw when colorized content > columns', t => {
+test('textAlignment option (right) does not throw when colorized content > columns', t => {
 	const longContent = chalk.green('ab').repeat(process.stdout.columns);
 	t.notThrows(() => {
 		boxen(longContent, {
-			align: 'right'
+			textAlignment: 'right'
 		});
 	});
 });
@@ -455,7 +550,7 @@ test('proportionally decrease margins when there is no space for them', t => {
 test('text is centered after wrapping', t => {
 	const width = process.stdout.columns || 120;
 	const longContent = 'x'.repeat(width - 1);
-	const box = boxen(longContent, {align: 'center'});
+	const box = boxen(longContent, {textAlignment: 'center'});
 
 	t.is(box.length, width * 4);
 
@@ -477,10 +572,10 @@ test('text is centered after wrapping', t => {
 	t.is(lines[2], expected);
 });
 
-test('text is left-aligned after wrapping', t => {
+test('text is left-textAlignmented after wrapping', t => {
 	const width = process.stdout.columns || 120;
 	const longContent = 'x'.repeat(width - 1);
-	const box = boxen(longContent, {align: 'left'});
+	const box = boxen(longContent, {textAlignment: 'left'});
 
 	t.is(box.length, width * 4);
 
@@ -504,7 +599,7 @@ test('text is left-aligned after wrapping', t => {
 test('text is right-aligned after wrapping', t => {
 	const width = process.stdout.columns || 120;
 	const longContent = 'x'.repeat(width - 1);
-	const box = boxen(longContent, {align: 'right'});
+	const box = boxen(longContent, {textAlignment: 'right'});
 
 	t.is(box.length, width * 4);
 
@@ -598,7 +693,7 @@ test('text is centered after wrapping when using words', t => {
 	const width = process.stdout.columns || 120;
 	const sentence = 'x'.repeat(width / 4) + ' ';
 	const longContent = sentence.repeat(4).trim();
-	const box = boxen(longContent, {align: 'center'});
+	const box = boxen(longContent, {textAlignment: 'center'});
 
 	const lines = box.split('\n');
 
@@ -642,7 +737,7 @@ test('text is right-aligned after wrapping when using words', t => {
 	const width = process.stdout.columns || 120;
 	const sentence = 'x'.repeat(width / 4) + ' ';
 	const longContent = sentence.repeat(4).trim();
-	const box = boxen(longContent, {align: 'right'});
+	const box = boxen(longContent, {textAlignment: 'right'});
 
 	const lines = box.split('\n');
 
@@ -665,7 +760,7 @@ test('text is right-aligned after wrapping when using words, with padding', t =>
 	const sentence = 'x'.repeat(width / 4) + ' ';
 	const longContent = sentence.repeat(4).trim();
 	const box = boxen(longContent, {
-		align: 'right',
+		textAlignment: 'right',
 		padding: {left: 1, right: 1, top: 0, bottom: 0}
 	});