diff --git a/.gitignore b/.gitignore index da592f8d..07f7564f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules .DS_Store dist + +\.idea/ diff --git a/example.js b/example.js index 4bd9d03d..ed0d5e80 100644 --- a/example.js +++ b/example.js @@ -13,6 +13,13 @@ let interval; initial: `terkelg`, format: v => `@${v}` }, + { + type: 'date', + name: 'birthday', + message: `What's your birth day?`, + mask: '"Year:" YYYY, "Month:" MM, "Day:" DD \\\\\\\\||// \\Hour: HH, \\Minute: mm, "Seconds:" ss', + validate: date => date > Date.now() ? `Your birth day can't be in the future` : true + }, { type: 'number', name: 'age', diff --git a/lib/dateparts/datepart.js b/lib/dateparts/datepart.js new file mode 100644 index 00000000..62b893bc --- /dev/null +++ b/lib/dateparts/datepart.js @@ -0,0 +1,35 @@ +'use strict'; + +class DatePart { + constructor({token, date, parts, locales}) { + this.token = token; + this.date = date || new Date(); + this.parts = parts || [this]; + this.locales = locales || {}; + } + + up() {} + + down() {} + + next() { + const currentIdx = this.parts.indexOf(this); + return this.parts.find((part, idx) => idx > currentIdx && part instanceof DatePart); + } + + setTo(val) {} + + prev() { + let parts = [].concat(this.parts).reverse(); + const currentIdx = parts.indexOf(this); + return parts.find((part, idx) => idx > currentIdx && part instanceof DatePart); + } + + toString() { + return String(this.date); + } +} + +module.exports = DatePart; + + diff --git a/lib/dateparts/day.js b/lib/dateparts/day.js new file mode 100644 index 00000000..5db84fe1 --- /dev/null +++ b/lib/dateparts/day.js @@ -0,0 +1,42 @@ +'use strict'; + +const DatePart = require('./datepart'); + +const pos = n => { + n = n % 10; + return n === 1 ? 'st' + : n === 2 ? 'nd' + : n === 3 ? 'rd' + : 'th'; +} + +class Day extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setDate(this.date.getDate() + 1); + } + + down() { + this.date.setDate(this.date.getDate() - 1); + } + + setTo(val) { + this.date.setDate(parseInt(val.substr(-2))); + } + + toString() { + let date = this.date.getDate(); + let day = this.date.getDay(); + return this.token === 'DD' ? String(date).padStart(2, '0') + : this.token === 'Do' ? date + pos(date) + : this.token === 'd' ? day + 1 + : this.token === 'ddd' ? this.locales.weekdaysShort[day] + : this.token === 'dddd' ? this.locales.weekdays[day] + : date; + } +} + +module.exports = Day; diff --git a/lib/dateparts/hours.js b/lib/dateparts/hours.js new file mode 100644 index 00000000..171b3d2c --- /dev/null +++ b/lib/dateparts/hours.js @@ -0,0 +1,30 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Hours extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setHours(this.date.getHours() + 1); + } + + down() { + this.date.setHours(this.date.getHours() - 1); + } + + setTo(val) { + this.date.setHours(parseInt(val.substr(-2))); + } + + toString() { + let hours = this.date.getHours(); + if (/h/.test(this.token)) + hours = (hours % 12) || 12; + return this.token.length > 1 ? String(hours).padStart(2, '0') : hours; + } +} + +module.exports = Hours; diff --git a/lib/dateparts/index.js b/lib/dateparts/index.js new file mode 100644 index 00000000..dc0cc953 --- /dev/null +++ b/lib/dateparts/index.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = { + DatePart: require('./datepart'), + Meridiem: require('./meridiem'), + Day: require('./day'), + Hours: require('./hours'), + Milliseconds: require('./milliseconds'), + Minutes: require('./minutes'), + Month: require('./month'), + Seconds: require('./seconds'), + Year: require('./year'), +} diff --git a/lib/dateparts/meridiem.js b/lib/dateparts/meridiem.js new file mode 100644 index 00000000..8488677b --- /dev/null +++ b/lib/dateparts/meridiem.js @@ -0,0 +1,24 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Meridiem extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setHours((this.date.getHours() + 12) % 24); + } + + down() { + this.up(); + } + + toString() { + let meridiem = this.date.getHours() > 12 ? 'pm' : 'am'; + return /\A/.test(this.token) ? meridiem.toUpperCase() : meridiem; + } +} + +module.exports = Meridiem; diff --git a/lib/dateparts/milliseconds.js b/lib/dateparts/milliseconds.js new file mode 100644 index 00000000..89842702 --- /dev/null +++ b/lib/dateparts/milliseconds.js @@ -0,0 +1,28 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Milliseconds extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setMilliseconds(this.date.getMilliseconds() + 1); + } + + down() { + this.date.setMilliseconds(this.date.getMilliseconds() - 1); + } + + setTo(val) { + this.date.setMilliseconds(parseInt(val.substr(-(this.token.length)))); + } + + toString() { + return String(this.date.getMilliseconds()).padStart(4, '0') + .substr(0, this.token.length); + } +} + +module.exports = Milliseconds; diff --git a/lib/dateparts/minutes.js b/lib/dateparts/minutes.js new file mode 100644 index 00000000..aa1d8f7e --- /dev/null +++ b/lib/dateparts/minutes.js @@ -0,0 +1,28 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Minutes extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setMinutes(this.date.getMinutes() + 1); + } + + down() { + this.date.setMinutes(this.date.getMinutes() - 1); + } + + setTo(val) { + this.date.setMinutes(parseInt(val.substr(-2))); + } + + toString() { + let m = this.date.getMinutes(); + return this.token.length > 1 ? String(m).padStart(2, '0') : m; + } +} + +module.exports = Minutes; diff --git a/lib/dateparts/month.js b/lib/dateparts/month.js new file mode 100644 index 00000000..f6564559 --- /dev/null +++ b/lib/dateparts/month.js @@ -0,0 +1,33 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Month extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setMonth(this.date.getMonth() + 1); + } + + down() { + this.date.setMonth(this.date.getMonth() - 1); + } + + setTo(val) { + val = parseInt(val.substr(-2)) - 1; + this.date.setMonth(val < 0 ? 0 : val); + } + + toString() { + let month = this.date.getMonth(); + let tl = this.token.length; + return tl === 2 ? String(month + 1).padStart(2, '0') + : tl === 3 ? this.locales.monthsShort[month] + : tl === 4 ? this.locales.months[month] + : String(month + 1); + } +} + +module.exports = Month; diff --git a/lib/dateparts/seconds.js b/lib/dateparts/seconds.js new file mode 100644 index 00000000..0c1a1a4f --- /dev/null +++ b/lib/dateparts/seconds.js @@ -0,0 +1,28 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Seconds extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setSeconds(this.date.getSeconds() + 1); + } + + down() { + this.date.setSeconds(this.date.getSeconds() - 1); + } + + setTo(val) { + this.date.setSeconds(parseInt(val.substr(-2))); + } + + toString() { + let s = this.date.getSeconds(); + return this.token.length > 1 ? String(s).padStart(2, '0') : s; + } +} + +module.exports = Seconds; diff --git a/lib/dateparts/year.js b/lib/dateparts/year.js new file mode 100644 index 00000000..f068e430 --- /dev/null +++ b/lib/dateparts/year.js @@ -0,0 +1,28 @@ +'use strict'; + +const DatePart = require('./datepart'); + +class Year extends DatePart { + constructor(opts={}) { + super(opts); + } + + up() { + this.date.setFullYear(this.date.getFullYear() + 1); + } + + down() { + this.date.setFullYear(this.date.getFullYear() - 1); + } + + setTo(val) { + this.date.setFullYear(val.substr(-4)); + } + + toString() { + let year = String(this.date.getFullYear()).padStart(4, '0'); + return this.token.length === 2 ? year.substr(-2) : year; + } +} + +module.exports = Year; diff --git a/lib/elements/confirm.js b/lib/elements/confirm.js index 6c776bdb..e5ce738f 100644 --- a/lib/elements/confirm.js +++ b/lib/elements/confirm.js @@ -66,7 +66,7 @@ class ConfirmPrompt extends Prompt { render() { if (this.closed) return; - if (this.first) this.out.write(cursor.hide); + if (this.firstRender) this.out.write(cursor.hide); super.render(); this.out.write( diff --git a/lib/elements/date.js b/lib/elements/date.js new file mode 100644 index 00000000..ad29bc35 --- /dev/null +++ b/lib/elements/date.js @@ -0,0 +1,212 @@ +'use strict'; + +const color = require('kleur'); +const Prompt = require('./prompt'); +const { style, clear, figures, strip } = require('../util'); +const { erase, cursor } = require('sisteransi'); +const { DatePart, Meridiem, Day, Hours, Milliseconds, Minutes, Month, Seconds, Year } = require('../dateparts'); + +const regex = /\\(.)|"((?:\\["\\]|[^"])+)"|(D[Do]?|d{3,4}|d)|(M{1,4})|(YY(?:YY)?)|([aA])|([Hh]{1,2})|(m{1,2})|(s{1,2})|(S{1,4})|./g; +const regexGroups = { + 1: ({token}) => token.replace(/\\(.)/g, '$1'), + 2: (opts) => new Day(opts), // Day // TODO + 3: (opts) => new Month(opts), // Month + 4: (opts) => new Year(opts), // Year + 5: (opts) => new Meridiem(opts), // AM/PM // TODO (special) + 6: (opts) => new Hours(opts), // Hours + 7: (opts) => new Minutes(opts), // Minutes + 8: (opts) => new Seconds(opts), // Seconds + 9: (opts) => new Milliseconds(opts), // Fractional seconds +} + +const dfltLocales = { + months: 'January,February,March,April,May,June,July,August,September,October,November,December'.split(','), + monthsShort: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(','), + weekdays: 'Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday'.split(','), + weekdaysShort: 'Sun,Mon,Tue,Wed,Thu,Fri,Sat'.split(',') +} + +/** + * DatePrompt Base Element + * @param {Object} opts Options + * @param {String} opts.message Message + * @param {Number} [opts.initial] Index of default value + * @param {Stream} [opts.stdin] The Readable stream to listen to + * @param {Stream} [opts.stdout] The Writable stream to write readline data to + * @param {String} [opts.mask] The format mask + */ +class DatePrompt extends Prompt { + constructor(opts={}) { + super(opts); + this.msg = opts.message; + this.cursor = 0; + this.typed = ''; + this.locales = Object.assign(dfltLocales, opts.locales); + this._date = opts.initial || new Date(); + this.errorMsg = opts.error || 'Please Enter A Valid Value'; + this.validator = opts.validate || (() => true); + this.mask = opts.mask || 'YYYY-MM-DD HH:mm:ss'; + this.clear = clear(''); + this.render(); + } + + get value() { + return this.date + } + + get date() { + return this._date; + } + + set date(date) { + if (date) this._date.setTime(date.getTime()); + } + + set mask(mask) { + let result; + this.parts = []; + while(result = regex.exec(mask)) { + let match = result.shift(); + let idx = result.findIndex(gr => gr != null); + this.parts.push(idx in regexGroups + ? regexGroups[idx]({ token: result[idx] || match, date: this.date, parts: this.parts, locales: this.locales }) + : result[idx] || match); + } + + let parts = this.parts.reduce((arr, i) => { + if (typeof i === 'string' && typeof arr[arr.length - 1] === 'string') + arr[arr.length - 1] += i; + else arr.push(i); + return arr; + }, []); + + this.parts.splice(0); + this.parts.push(...parts); + this.reset(); + } + + moveCursor(n) { + this.typed = ''; + this.cursor = n; + this.fire(); + } + + reset() { + this.moveCursor(this.parts.findIndex(p => p instanceof DatePart)); + this.fire(); + this.render(); + } + + abort() { + this.done = this.aborted = true; + this.error = false; + this.fire(); + this.render(); + this.out.write('\n'); + this.close(); + } + + async validate() { + let valid = await this.validator(this.value); + if (typeof valid === 'string') { + this.errorMsg = valid; + valid = false; + } + this.error = !valid; + } + + async submit() { + await this.validate(); + if (this.error) { + this.color = 'red'; + this.fire(); + this.render(); + return; + } + this.done = true; + this.aborted = false; + this.fire(); + this.render(); + this.out.write('\n'); + this.close(); + } + + up() { + this.typed = ''; + this.parts[this.cursor].up(); + this.render(); + } + + down() { + this.typed = ''; + this.parts[this.cursor].down(); + this.render(); + } + + left() { + let prev = this.parts[this.cursor].prev(); + if (prev == null) return this.bell(); + this.moveCursor(this.parts.indexOf(prev)); + this.render(); + } + + right() { + let next = this.parts[this.cursor].next(); + if (next == null) return this.bell(); + this.moveCursor(this.parts.indexOf(next)); + this.render(); + } + + next() { + let next = this.parts[this.cursor].next(); + this.moveCursor(next + ? this.parts.indexOf(next) + : this.parts.findIndex((part) => part instanceof DatePart)); + this.render(); + } + + _(c) { + if (/\d/.test(c)) { + this.typed += c; + this.parts[this.cursor].setTo(this.typed); + this.render(); + } + } + + render() { + if (this.closed) return; + if (this.firstRender) this.out.write(cursor.hide); + else this.out.write(erase.lines(1)); + super.render(); + let clear = erase.line + (this.lines ? erase.down(this.lines) : '') + cursor.to(0); + this.lines = 0; + + let error = ''; + if (this.error) { + let lines = this.errorMsg.split('\n'); + error = lines.reduce((a, l, i) => a + `\n${i ? ` ` : figures.pointerSmall} ${color.red().italic(l)}`, ``); + this.lines = lines.length; + } + + // Print prompt + let prompt = [ + style.symbol(this.done, this.aborted), + color.bold(this.msg), + style.delimiter(false), + this.parts.reduce((arr, p, idx) => + arr.concat(idx === this.cursor + ? color.cyan().underline(p.toString()) + : p), []).join(''), + ].join(' '); + + let position = ''; + if (this.lines) { + position += cursor.up(this.lines); + position += cursor.left+cursor.to(strip(prompt).length); + } + + this.out.write(clear+prompt+error+position); + } +} + +module.exports = DatePrompt; diff --git a/lib/elements/index.js b/lib/elements/index.js index 2091caeb..68d8fc65 100644 --- a/lib/elements/index.js +++ b/lib/elements/index.js @@ -4,6 +4,7 @@ module.exports = { TextPrompt: require('./text'), SelectPrompt: require('./select'), TogglePrompt: require('./toggle'), + DatePrompt: require('./date'), NumberPrompt: require('./number'), MultiselectPrompt: require('./multiselect'), AutocompletePrompt: require('./autocomplete'), diff --git a/lib/elements/multiselect.js b/lib/elements/multiselect.js index 4d62a597..f0fa9ec3 100644 --- a/lib/elements/multiselect.js +++ b/lib/elements/multiselect.js @@ -120,7 +120,7 @@ class MultiselectPrompt extends Prompt { render() { if (this.closed) return; - if (this.first) this.out.write(cursor.hide); + if (this.firstRender) this.out.write(cursor.hide); super.render(); // print prompt diff --git a/lib/elements/number.js b/lib/elements/number.js index 339bdb97..f0a50cf5 100644 --- a/lib/elements/number.js +++ b/lib/elements/number.js @@ -4,7 +4,6 @@ const { cursor, erase } = require('sisteransi'); const { style, clear, figures, strip } = require('../util'); const isNumber = /[0-9]/; -const isValidChar = /\.|-/; const isDef = any => any !== undefined; const round = (number, precision) => { let factor = Math.pow(10, precision); @@ -108,7 +107,7 @@ class NumberPrompt extends Prompt { return; } let x = this.value; - this.value = x !== `` ? x : this.initial + this.value = x !== `` ? x : this.initial; this.done = true; this.aborted = false; this.error = false; @@ -178,7 +177,7 @@ class NumberPrompt extends Prompt { let error = ``; if (this.error) { let lines = this.errorMsg.split(`\n`); - error += lines.reduce((a, l, i) => a += `\n${i ? ` ` : figures.pointerSmall} ${color.red().italic(l)}`, ``); + error += lines.reduce((a, l, i) => a + `\n${i ? ` ` : figures.pointerSmall} ${color.red().italic(l)}`, ``); this.lines = lines.length; } diff --git a/lib/elements/toggle.js b/lib/elements/toggle.js index 124660ae..b542c096 100644 --- a/lib/elements/toggle.js +++ b/lib/elements/toggle.js @@ -94,7 +94,7 @@ class TogglePrompt extends Prompt { render() { if (this.closed) return; - if (this.first) this.out.write(cursor.hide); + if (this.firstRender) this.out.write(cursor.hide); super.render(); this.out.write( diff --git a/lib/prompts.js b/lib/prompts.js index dc60ce77..35b33cee 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -75,6 +75,24 @@ $.invisible = args => { */ $.number = args => toPrompt('NumberPrompt', args); +/** + * Date prompt + * @param {string} args.message Prompt message to display + * @param {number} args.initial Default number value + * @param {function} [args.onState] On state change callback + * @param {number} [args.max] Max value + * @param {number} [args.min] Min value + * @param {string} [args.style="default"] Render style ('default', 'password', 'invisible') + * @param {Boolean} [opts.float=false] Parse input as floats + * @param {Number} [opts.round=2] Round floats to x decimals + * @param {Number} [opts.increment=1] Number to increment by when using arrow-keys + * @param {function} [args.validate] Function to validate user input + * @param {Stream} [args.stdin] The Readable stream to listen to + * @param {Stream} [args.stdout] The Writable stream to write readline data to + * @returns {Promise} Promise with user input + */ +$.date = args => toPrompt('DatePrompt', args); + /** * Classic yes/no prompt * @param {string} args.message Prompt message to display diff --git a/readme.md b/readme.md index 04f39855..d1869e6d 100755 --- a/readme.md +++ b/readme.md @@ -692,8 +692,8 @@ You can overwrite how choices are being filtered by passing your own suggest fun | suggest | `function` | Filter function. Defaults to sort by `title` property. `suggest` should always return a promise. Filters using `title` by default | | limit | `number` | Max number of results to show. Defaults to `10` | | style | `string` | Render style (`default`, `password`, `invisible`, `emoji`). Defaults to `'default'` | -| initial | Default initial value | -| fallback | Fallback message when no match is found. Defaults to `initial` value if provided | +| initial | | Default initial value | +| fallback | | Fallback message when no match is found. Defaults to `initial` value if provided | | onRender | `function` | On render callback. Keyword `this` refers to the current prompt | | onState | `function` | On state change callback. Function signature is an `object` with two propetires: `value` and `aborted` | @@ -704,6 +704,200 @@ const suggestByTitle = (input, choices) => ``` +### date(message, [initial], [warn]) +> Interactive date prompt. + +Use left/right/tab to navigate. Use up/down to change date. + +#### Example +date prompt + +```js +{ + type: 'date', + name: 'value', + message: 'Pick a date', + initial: new Date(1997, 09, 12), + validate: date => date > Date.now() ? 'Not in the future' : true +} +``` + +#### Options +| Param | Type | Description | +| ----- | :--: | ----------- | +| message | `string` | Prompt message to display | +| initial | `date` | Default date | +| locales | `object` | Use to define custom locales. See below for an example. | +| mask | `string` | The format mask of the date. See below for more information.
Default: `YYYY-MM-DD HH:mm:ss` | +| validate | `function` | Receive user input. Should return `true` if the value is valid, and an error message `String` otherwise. If `false` is returned, a default error message is shown | +| onRender | `function` | On render callback. Keyword `this` refers to the current prompt | +| onState | `function` | On state change callback. Function signature is an `object` with two propetires: `value` and `aborted` | + +Default locales: + +```javascript +{ + months: [ + 'January', 'February', 'March', 'April', + 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December' + ], + monthsShort: [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ], + weekdays: [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', + 'Thursday', 'Friday', 'Saturday' + ], + weekdaysShort: [ + 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' + ] +} +``` + +#### Formatting Tokens + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Token

Output

Month
M1 2 ... 11 12
MM01 02 ... 11 12
MMMJan Feb ... Nov Dec
MMMMJanuary February ... November December
Day of Month
D1 2 ... 30 31
Do1st 2nd ... 30th 31st
DD01 02 ... 30 31
Day of Week
d0 1 ... 5 6
dddSun Mon ... Fri Sat
ddddSunday Monday ... Friday Saturday
Year
YY70 71 ... 29 30
YYYY1970 1971 ... 2029 2030
AM/PM
AAM PM
aam pm
Hour
H0 1 ... 22 23
HH00 01 ... 22 23
h1 2 ... 11 12
hh01 02 ... 11 12
Minute
m0 1 ... 58 59
mm00 01 ... 58 59
Second
s0 1 ... 58 59
ss00 01 ... 58 59
Fractional Second
S0 1 ... 8 9
SS0 1 ... 98 99
SSS0 1 ... 998 999
SSSS0 1 ... 9998 9999
+ ![split](https://github.com/terkelg/prompts/raw/master/media/split.png) diff --git a/test/prompts.js b/test/prompts.js index 1a0b6f0e..fc4addd0 100644 --- a/test/prompts.js +++ b/test/prompts.js @@ -13,7 +13,7 @@ test('basics', t => { }); test('prompts', t => { - t.plan(21); + t.plan(23); const types = [ 'text', @@ -25,7 +25,8 @@ test('prompts', t => { 'toggle', 'select', 'multiselect', - 'autocomplete' + 'autocomplete', + 'date' ]; types.forEach(p => {