cli-menu-kit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,8 +24,48 @@ async function showCheckboxMenu(config) {
24
24
  let cursorIndex = 0;
25
25
  const selected = new Set(defaultSelected);
26
26
  const state = (0, terminal_js_1.initTerminal)();
27
- // Extract option values
28
- const optionValues = options.map(opt => typeof opt === 'string' ? opt : opt.label);
27
+ // Separate selectable options from separators
28
+ const selectableIndices = [];
29
+ const optionData = [];
30
+ options.forEach((opt, index) => {
31
+ if (typeof opt === 'object' && 'type' in opt && opt.type === 'separator') {
32
+ optionData.push({ value: '', isSeparator: true, label: opt.label });
33
+ }
34
+ else {
35
+ let value;
36
+ if (typeof opt === 'string') {
37
+ value = opt;
38
+ }
39
+ else if ('value' in opt) {
40
+ value = opt.value ?? opt.label ?? '';
41
+ }
42
+ else {
43
+ value = opt.label ?? '';
44
+ }
45
+ optionData.push({ value, isSeparator: false });
46
+ selectableIndices.push(index);
47
+ }
48
+ });
49
+ // Ensure cursorIndex points to a selectable option
50
+ if (!selectableIndices.includes(cursorIndex)) {
51
+ cursorIndex = selectableIndices[0] || 0;
52
+ }
53
+ // Helper function to get next/previous selectable index
54
+ const getNextSelectableIndex = (currentIndex, direction) => {
55
+ let nextIndex = currentIndex;
56
+ const maxAttempts = options.length;
57
+ let attempts = 0;
58
+ do {
59
+ if (direction === 'up') {
60
+ nextIndex = nextIndex > 0 ? nextIndex - 1 : options.length - 1;
61
+ }
62
+ else {
63
+ nextIndex = nextIndex < options.length - 1 ? nextIndex + 1 : 0;
64
+ }
65
+ attempts++;
66
+ } while (!selectableIndices.includes(nextIndex) && attempts < maxAttempts);
67
+ return selectableIndices.includes(nextIndex) ? nextIndex : currentIndex;
68
+ };
29
69
  // Render function
30
70
  const render = () => {
31
71
  (0, terminal_js_1.clearMenu)(state);
@@ -48,8 +88,14 @@ async function showCheckboxMenu(config) {
48
88
  }
49
89
  break;
50
90
  case 'options':
51
- optionValues.forEach((option, index) => {
52
- (0, renderer_js_1.renderOption)(option, selected.has(index), index === cursorIndex);
91
+ optionData.forEach((item, index) => {
92
+ if (item.isSeparator) {
93
+ // Render section label
94
+ (0, renderer_js_1.renderSectionLabel)(item.label);
95
+ }
96
+ else {
97
+ (0, renderer_js_1.renderOption)(item.value, selected.has(index), index === cursorIndex);
98
+ }
53
99
  lineCount++;
54
100
  });
55
101
  break;
@@ -100,42 +146,54 @@ async function showCheckboxMenu(config) {
100
146
  const indices = Array.from(selected).sort((a, b) => a - b);
101
147
  const values = indices.map(i => {
102
148
  const option = options[i];
103
- return typeof option === 'string' ? option : option.value || option.label;
149
+ if (typeof option === 'string') {
150
+ return option;
151
+ }
152
+ else if ('type' in option && option.type === 'separator') {
153
+ return '';
154
+ }
155
+ else if ('value' in option) {
156
+ return option.value ?? option.label ?? '';
157
+ }
158
+ else {
159
+ return option.label ?? '';
160
+ }
104
161
  });
105
162
  resolve({ indices, values });
106
163
  return;
107
164
  }
108
165
  // Handle Space (toggle selection)
109
166
  if ((0, keyboard_js_1.isSpace)(key)) {
110
- if (selected.has(cursorIndex)) {
111
- selected.delete(cursorIndex);
112
- }
113
- else {
114
- // Check max selections
115
- if (!maxSelections || selected.size < maxSelections) {
116
- selected.add(cursorIndex);
167
+ // Only toggle if cursor is on a selectable item
168
+ if (selectableIndices.includes(cursorIndex)) {
169
+ if (selected.has(cursorIndex)) {
170
+ selected.delete(cursorIndex);
171
+ }
172
+ else {
173
+ // Check max selections
174
+ if (!maxSelections || selected.size < maxSelections) {
175
+ selected.add(cursorIndex);
176
+ }
117
177
  }
178
+ render();
118
179
  }
119
- render();
120
180
  return;
121
181
  }
122
182
  // Handle arrow keys
123
183
  if (key === keyboard_js_1.KEY_CODES.UP) {
124
- cursorIndex = cursorIndex > 0 ? cursorIndex - 1 : options.length - 1;
184
+ cursorIndex = getNextSelectableIndex(cursorIndex, 'up');
125
185
  render();
126
186
  return;
127
187
  }
128
188
  if (key === keyboard_js_1.KEY_CODES.DOWN) {
129
- cursorIndex = cursorIndex < options.length - 1 ? cursorIndex + 1 : 0;
189
+ cursorIndex = getNextSelectableIndex(cursorIndex, 'down');
130
190
  render();
131
191
  return;
132
192
  }
133
193
  // Handle 'A' (select all)
134
194
  if (allowSelectAll && (0, keyboard_js_1.normalizeLetter)(key) === 'a') {
135
- if (!maxSelections || maxSelections >= options.length) {
136
- for (let i = 0; i < options.length; i++) {
137
- selected.add(i);
138
- }
195
+ if (!maxSelections || maxSelections >= selectableIndices.length) {
196
+ selectableIndices.forEach(i => selected.add(i));
139
197
  render();
140
198
  }
141
199
  return;
@@ -143,13 +201,13 @@ async function showCheckboxMenu(config) {
143
201
  // Handle 'I' (invert selection)
144
202
  if (allowInvert && (0, keyboard_js_1.normalizeLetter)(key) === 'i') {
145
203
  const newSelected = new Set();
146
- for (let i = 0; i < options.length; i++) {
204
+ selectableIndices.forEach(i => {
147
205
  if (!selected.has(i)) {
148
206
  if (!maxSelections || newSelected.size < maxSelections) {
149
207
  newSelected.add(i);
150
208
  }
151
209
  }
152
- }
210
+ });
153
211
  selected.clear();
154
212
  newSelected.forEach(i => selected.add(i));
155
213
  render();
@@ -24,8 +24,48 @@ async function showRadioMenu(config) {
24
24
  // Initialize state
25
25
  let selectedIndex = Math.max(0, Math.min(defaultIndex, options.length - 1));
26
26
  const state = (0, terminal_js_1.initTerminal)();
27
- // Extract option values
28
- const optionValues = options.map(opt => typeof opt === 'string' ? opt : opt.label);
27
+ // Separate selectable options from separators
28
+ const selectableIndices = [];
29
+ const optionData = [];
30
+ options.forEach((opt, index) => {
31
+ if (typeof opt === 'object' && 'type' in opt && opt.type === 'separator') {
32
+ optionData.push({ value: '', isSeparator: true, label: opt.label });
33
+ }
34
+ else {
35
+ let value;
36
+ if (typeof opt === 'string') {
37
+ value = opt;
38
+ }
39
+ else if ('value' in opt) {
40
+ value = opt.value ?? opt.label ?? '';
41
+ }
42
+ else {
43
+ value = opt.label ?? '';
44
+ }
45
+ optionData.push({ value, isSeparator: false });
46
+ selectableIndices.push(index);
47
+ }
48
+ });
49
+ // Ensure selectedIndex points to a selectable option
50
+ if (!selectableIndices.includes(selectedIndex)) {
51
+ selectedIndex = selectableIndices[0] || 0;
52
+ }
53
+ // Helper function to get next/previous selectable index
54
+ const getNextSelectableIndex = (currentIndex, direction) => {
55
+ let nextIndex = currentIndex;
56
+ const maxAttempts = options.length;
57
+ let attempts = 0;
58
+ do {
59
+ if (direction === 'up') {
60
+ nextIndex = nextIndex > 0 ? nextIndex - 1 : options.length - 1;
61
+ }
62
+ else {
63
+ nextIndex = nextIndex < options.length - 1 ? nextIndex + 1 : 0;
64
+ }
65
+ attempts++;
66
+ } while (!selectableIndices.includes(nextIndex) && attempts < maxAttempts);
67
+ return selectableIndices.includes(nextIndex) ? nextIndex : currentIndex;
68
+ };
29
69
  // Render function
30
70
  const render = () => {
31
71
  (0, terminal_js_1.clearMenu)(state);
@@ -46,11 +86,18 @@ async function showRadioMenu(config) {
46
86
  }
47
87
  break;
48
88
  case 'options':
49
- optionValues.forEach((option, index) => {
50
- // Extract number prefix if exists
51
- const match = option.match(/^(\d+)\.\s*/);
52
- const prefix = match ? '' : `${index + 1}. `;
53
- (0, renderer_js_1.renderOption)(option, false, index === selectedIndex, prefix);
89
+ optionData.forEach((item, index) => {
90
+ if (item.isSeparator) {
91
+ // Render section label
92
+ (0, renderer_js_1.renderSectionLabel)(item.label);
93
+ }
94
+ else {
95
+ // Extract number prefix if exists
96
+ const match = item.value.match(/^(\d+)\.\s*/);
97
+ const prefix = match ? '' : `${selectableIndices.indexOf(index) + 1}. `;
98
+ // For radio menus, don't show selection indicator (pass undefined instead of false)
99
+ (0, renderer_js_1.renderOption)(item.value, undefined, index === selectedIndex, prefix);
100
+ }
54
101
  lineCount++;
55
102
  });
56
103
  break;
@@ -58,13 +105,15 @@ async function showRadioMenu(config) {
58
105
  if (layout.visible.input) {
59
106
  // Calculate display value (current selection number)
60
107
  let displayValue = '';
61
- const currentOption = optionValues[selectedIndex];
62
- const match = currentOption?.match(/^([^.]+)\./);
63
- if (match) {
64
- displayValue = match[1];
65
- }
66
- else {
67
- displayValue = String(selectedIndex + 1);
108
+ const currentItem = optionData[selectedIndex];
109
+ if (currentItem && !currentItem.isSeparator) {
110
+ const match = currentItem.value.match(/^([^.]+)\./);
111
+ if (match) {
112
+ displayValue = match[1];
113
+ }
114
+ else {
115
+ displayValue = String(selectableIndices.indexOf(selectedIndex) + 1);
116
+ }
68
117
  }
69
118
  (0, renderer_js_1.renderInputPrompt)(prompt, displayValue);
70
119
  lineCount++;
@@ -110,9 +159,19 @@ async function showRadioMenu(config) {
110
159
  (0, terminal_js_1.clearMenu)(state);
111
160
  (0, terminal_js_1.restoreTerminal)(state);
112
161
  const selectedOption = options[selectedIndex];
113
- const value = typeof selectedOption === 'string'
114
- ? selectedOption
115
- : selectedOption.value || selectedOption.label;
162
+ let value;
163
+ if (typeof selectedOption === 'string') {
164
+ value = selectedOption;
165
+ }
166
+ else if ('type' in selectedOption && selectedOption.type === 'separator') {
167
+ value = '';
168
+ }
169
+ else if ('value' in selectedOption) {
170
+ value = selectedOption.value ?? selectedOption.label ?? '';
171
+ }
172
+ else {
173
+ value = selectedOption.label ?? '';
174
+ }
116
175
  resolve({
117
176
  index: selectedIndex,
118
177
  value
@@ -121,20 +180,20 @@ async function showRadioMenu(config) {
121
180
  }
122
181
  // Handle arrow keys
123
182
  if (key === keyboard_js_1.KEY_CODES.UP) {
124
- selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1;
183
+ selectedIndex = getNextSelectableIndex(selectedIndex, 'up');
125
184
  render();
126
185
  return;
127
186
  }
128
187
  if (key === keyboard_js_1.KEY_CODES.DOWN) {
129
- selectedIndex = selectedIndex < options.length - 1 ? selectedIndex + 1 : 0;
188
+ selectedIndex = getNextSelectableIndex(selectedIndex, 'down');
130
189
  render();
131
190
  return;
132
191
  }
133
192
  // Handle number keys
134
193
  if (allowNumberKeys && (0, keyboard_js_1.isNumberKey)(key)) {
135
194
  const num = parseInt(key, 10);
136
- if (num > 0 && num <= options.length) {
137
- selectedIndex = num - 1;
195
+ if (num > 0 && num <= selectableIndices.length) {
196
+ selectedIndex = selectableIndices[num - 1];
138
197
  render();
139
198
  }
140
199
  return;
@@ -142,11 +201,14 @@ async function showRadioMenu(config) {
142
201
  // Handle letter keys
143
202
  if (allowLetterKeys && (0, keyboard_js_1.isLetterKey)(key)) {
144
203
  const letter = (0, keyboard_js_1.normalizeLetter)(key);
145
- const index = optionValues.findIndex(opt => {
146
- const match = opt.match(/^([a-zA-Z])\./i);
204
+ const index = selectableIndices.find(idx => {
205
+ const item = optionData[idx];
206
+ if (item.isSeparator)
207
+ return false;
208
+ const match = item.value.match(/^([a-zA-Z])\./i);
147
209
  return match && match[1].toLowerCase() === letter;
148
210
  });
149
- if (index !== -1) {
211
+ if (index !== undefined) {
150
212
  selectedIndex = index;
151
213
  render();
152
214
  }
@@ -39,6 +39,11 @@ export declare function renderHints(hints: string[]): void;
39
39
  * @param width - Width of separator (default: terminal width)
40
40
  */
41
41
  export declare function renderSeparator(char?: string, width?: number): void;
42
+ /**
43
+ * Render a section label (menu grouping)
44
+ * @param label - Label text (optional)
45
+ */
46
+ export declare function renderSectionLabel(label?: string): void;
42
47
  /**
43
48
  * Render a message with icon
44
49
  * @param type - Message type (success, error, warning, info, question)
@@ -10,6 +10,7 @@ exports.renderOption = renderOption;
10
10
  exports.renderInputPrompt = renderInputPrompt;
11
11
  exports.renderHints = renderHints;
12
12
  exports.renderSeparator = renderSeparator;
13
+ exports.renderSectionLabel = renderSectionLabel;
13
14
  exports.renderMessage = renderMessage;
14
15
  exports.renderProgress = renderProgress;
15
16
  exports.renderBox = renderBox;
@@ -108,6 +109,18 @@ function renderSeparator(char = '─', width) {
108
109
  const sepWidth = width || termWidth;
109
110
  (0, terminal_js_1.writeLine)(char.repeat(sepWidth));
110
111
  }
112
+ /**
113
+ * Render a section label (menu grouping)
114
+ * @param label - Label text (optional)
115
+ */
116
+ function renderSectionLabel(label) {
117
+ if (label) {
118
+ (0, terminal_js_1.writeLine)(` ${colors_js_1.colors.dim}────── ${label} ──────${colors_js_1.colors.reset}`);
119
+ }
120
+ else {
121
+ (0, terminal_js_1.writeLine)('');
122
+ }
123
+ }
111
124
  /**
112
125
  * Render a message with icon
113
126
  * @param type - Message type (success, error, warning, info, question)
@@ -3,11 +3,14 @@
3
3
  */
4
4
  import { MenuLayout } from './layout.types.js';
5
5
  /**
6
- * Menu option (can be string or object with label)
6
+ * Menu option (can be string, object with label, or section header)
7
7
  */
8
8
  export type MenuOption = string | {
9
9
  label: string;
10
10
  value?: string;
11
+ } | {
12
+ type: 'separator';
13
+ label?: string;
11
14
  };
12
15
  /**
13
16
  * Base menu configuration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-menu-kit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A lightweight, customizable CLI menu system with keyboard shortcuts and real-time rendering",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",