@kud/ink-ui 0.14.0 → 0.15.0

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.
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - **Selection & navigation** — `Select`, `MultiSelect`, `Tabs`, `Switch`, `Toggle`, `SelectableRow`
20
20
  - **Status & feedback** — `Spinner`, `ProgressBar`, `StatusMessage`, `Alert`, `Badge`, `Toast`
21
21
  - **Layout & chrome** — `Banner`, `Header`, `Panel` (bordered pane, optional focus state), `Columns`, `FooterHints`, `KeyValue`, `LoadingScreen`, `ScrollView`
22
+ - **Behaviour hooks** — `useTabs` and `useListCursor` own the keyboard state that every consumer used to hand-roll, so Tab/Shift+Tab and arrow/vim navigation behave the same everywhere
22
23
  - **Full [@inkjs/ui](https://github.com/vadimdemedes/ink-ui) parity** — every upstream component has an equivalent, plus a dozen more the design system adds on top
23
24
  - **Colourblind-safe by design** — state is signalled by shape, case, and glyph, never colour alone
24
25
  - **Design tokens included** — a shared colour palette (`colors`) and spacing scale (`spacing`) to keep every screen consistent
@@ -84,6 +85,25 @@ const SignUp = () => (
84
85
  )
85
86
  ```
86
87
 
88
+ Components stay presentational and controlled — they take `active`/`value` and render. The behaviour hooks supply the keyboard state to drive them, so the two compose without either forcing the other:
89
+
90
+ ```tsx
91
+ import { Tabs, useTabs, useListCursor } from "@kud/ink-ui"
92
+
93
+ const items = [
94
+ { value: "open", label: "Open", count: 3 },
95
+ { value: "done", label: "Done" },
96
+ ]
97
+
98
+ const Inbox = ({ rows }) => {
99
+ const { active } = useTabs(items) // Tab forward, Shift+Tab back, wraps
100
+ const { cursor } = useListCursor(rows.length) // ↑/↓ and k/j, clamps at the ends
101
+ return <Tabs active={active} items={items} />
102
+ }
103
+ ```
104
+
105
+ Both take `{ isActive }` so a screen with several focus regions can gate them, and `useListCursor` takes `{ wrap }` for genuinely circular lists. `useTabs` wraps by default because a tab bar is a ring; `useListCursor` clamps because a list has ends.
106
+
87
107
  All components accept only the props they need — no theme provider or context required. Design tokens are plain objects:
88
108
 
89
109
  ```ts
package/dist/index.d.ts CHANGED
@@ -26,16 +26,25 @@ type SpinnerProps = {
26
26
  };
27
27
  declare const Spinner: ({ label }: SpinnerProps) => React__default.JSX.Element;
28
28
 
29
+ type ColumnAlign = "left" | "center" | "right";
30
+ type ColumnOverflow = "wrap" | "truncate";
31
+
29
32
  type Column<T extends Record<string, unknown>> = {
30
33
  key: keyof T & string;
31
34
  header: string;
32
35
  width?: number;
36
+ minWidth?: number;
37
+ align?: ColumnAlign;
38
+ overflow?: ColumnOverflow;
33
39
  };
34
40
  type TableProps<T extends Record<string, unknown>> = {
35
41
  data: T[];
36
42
  columns: Column<T>[];
43
+ gap?: number;
44
+ maxWidth?: number;
45
+ headerColor?: string;
37
46
  };
38
- declare const Table: <T extends Record<string, unknown>>({ data, columns, }: TableProps<T>) => React__default.JSX.Element;
47
+ declare const Table: <T extends Record<string, unknown>>({ data, columns, gap, maxWidth, headerColor, }: TableProps<T>) => React__default.JSX.Element;
39
48
 
40
49
  type Hint = [key: string, label: string];
41
50
  type FooterHintsProps = {
@@ -312,4 +321,4 @@ declare const spacing: {
312
321
  };
313
322
  type Spacing = (typeof spacing)[keyof typeof spacing];
314
323
 
315
- export { Alert, Badge, type BadgeVariant, Banner, type Color, type Column, Columns, ConfirmInput, EmailInput, FooterHints, Header, type Hint, type IconMode, KeyValue, LoadingScreen, MultiSelect, type NotifyOptions, OrderedList, Panel, PasswordInput, ProgressBar, ScrollView, Select, type SelectOption, SelectableRow, type Spacing, type Span, Spinner, StatusMessage, type StatusVariant, type StyledLine, Switch, type SwitchValue, type TabItem, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, setIconMode, spacing, useListCursor, useTabs };
324
+ export { Alert, Badge, type BadgeVariant, Banner, type Color, type Column, type ColumnAlign, type ColumnOverflow, Columns, ConfirmInput, EmailInput, FooterHints, Header, type Hint, type IconMode, KeyValue, LoadingScreen, MultiSelect, type NotifyOptions, OrderedList, Panel, PasswordInput, ProgressBar, ScrollView, Select, type SelectOption, SelectableRow, type Spacing, type Span, Spinner, StatusMessage, type StatusVariant, type StyledLine, Switch, type SwitchValue, type TabItem, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, setIconMode, spacing, useListCursor, useTabs };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { Box, Text, useInput, measureElement } from 'ink';
1
+ import { Box, Text, useStdout, useInput, measureElement } from 'ink';
2
2
  import { jsx, jsxs } from 'react/jsx-runtime';
3
3
  import React8, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect } from 'react';
4
4
  import cliSpinners from 'cli-spinners';
5
+ import stringWidth from 'string-width';
5
6
  import { glyphs } from '@kud/glyphs';
6
7
  import { spawn } from 'child_process';
7
8
 
@@ -56,13 +57,100 @@ var Spinner = ({ label }) => {
56
57
  label && /* @__PURE__ */ jsx(Text, { dimColor: true, children: label })
57
58
  ] });
58
59
  };
60
+ var DEFAULT_MIN_WIDTH = 3;
61
+ var widestLine = (value) => value.split("\n").reduce((widest, line) => Math.max(widest, stringWidth(line)), 0);
62
+ var naturalWidth = (column, cells) => cells.reduce(
63
+ (widest, cell) => Math.max(widest, widestLine(cell)),
64
+ widestLine(column.header)
65
+ );
66
+ var floorFor = (column, width) => column.width !== void 0 ? width : Math.min(width, column.minWidth ?? DEFAULT_MIN_WIDTH);
67
+ var widestShrinkable = (widths, floors) => widths.reduce(
68
+ (best, width, index) => width > floors[index] && (best === -1 || width > widths[best]) ? index : best,
69
+ -1
70
+ );
71
+ var shrinkToFit = (widths, floors, excess) => {
72
+ const shrunk = [...widths];
73
+ let remaining = excess;
74
+ while (remaining > 0) {
75
+ const target = widestShrinkable(shrunk, floors);
76
+ if (target === -1) break;
77
+ shrunk[target] = shrunk[target] - 1;
78
+ remaining -= 1;
79
+ }
80
+ return shrunk;
81
+ };
82
+ var resolveColumnWidths = ({
83
+ columns,
84
+ rows,
85
+ gap,
86
+ maxWidth
87
+ }) => {
88
+ const widths = columns.map(
89
+ (column, index) => column.width ?? naturalWidth(
90
+ column,
91
+ rows.map((row) => row[index] ?? "")
92
+ )
93
+ );
94
+ const gaps = gap * Math.max(columns.length - 1, 0);
95
+ const excess = widths.reduce((total, width) => total + width, gaps) - maxWidth;
96
+ return excess > 0 ? shrinkToFit(
97
+ widths,
98
+ columns.map((column, index) => floorFor(column, widths[index])),
99
+ excess
100
+ ) : widths;
101
+ };
102
+ var FALLBACK_TERMINAL_WIDTH = 80;
103
+ var justification = {
104
+ left: "flex-start",
105
+ center: "center",
106
+ right: "flex-end"
107
+ };
108
+ var cellText = (value) => String(value ?? "");
59
109
  var Table = ({
60
110
  data,
61
- columns
62
- }) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
63
- /* @__PURE__ */ jsx(Box, { gap: 2, children: columns.map((col) => /* @__PURE__ */ jsx(Box, { width: col.width, minWidth: col.width, children: /* @__PURE__ */ jsx(Text, { bold: true, color: colors.muted, children: col.header }) }, col.key)) }),
64
- data.map((row, i) => /* @__PURE__ */ jsx(Box, { gap: 2, children: columns.map((col) => /* @__PURE__ */ jsx(Box, { width: col.width, minWidth: col.width, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: String(row[col.key] ?? "") }) }, col.key)) }, i))
65
- ] });
111
+ columns,
112
+ gap = 2,
113
+ maxWidth,
114
+ headerColor = colors.muted
115
+ }) => {
116
+ const { stdout } = useStdout();
117
+ const rows = data.map(
118
+ (row) => columns.map((column) => cellText(row[column.key]))
119
+ );
120
+ const widths = resolveColumnWidths({
121
+ columns,
122
+ rows,
123
+ gap,
124
+ maxWidth: maxWidth ?? stdout?.columns ?? FALLBACK_TERMINAL_WIDTH
125
+ });
126
+ const cell = (column, index, content) => /* @__PURE__ */ jsx(
127
+ Box,
128
+ {
129
+ width: widths[index],
130
+ flexShrink: 0,
131
+ flexGrow: 0,
132
+ justifyContent: justification[column.align ?? "left"],
133
+ children: content
134
+ },
135
+ column.key
136
+ );
137
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
138
+ /* @__PURE__ */ jsx(Box, { gap, children: columns.map(
139
+ (column, index) => cell(
140
+ column,
141
+ index,
142
+ /* @__PURE__ */ jsx(Text, { bold: true, color: headerColor, wrap: "truncate-end", children: column.header })
143
+ )
144
+ ) }),
145
+ rows.map((cells, rowIndex) => /* @__PURE__ */ jsx(Box, { gap, children: columns.map(
146
+ (column, index) => cell(
147
+ column,
148
+ index,
149
+ /* @__PURE__ */ jsx(Text, { wrap: column.overflow === "wrap" ? "wrap" : "truncate-end", children: cells[index] })
150
+ )
151
+ ) }, rowIndex))
152
+ ] });
153
+ };
66
154
  var FooterHints = ({ hints }) => /* @__PURE__ */ jsx(Box, { columnGap: 2, rowGap: 0, flexWrap: "wrap", children: hints.map(([key, label]) => /* @__PURE__ */ jsxs(Box, { children: [
67
155
  /* @__PURE__ */ jsx(Text, { color: "white", children: key }),
68
156
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + label })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/ink-ui",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "React component library for Ink CLIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,7 +32,8 @@
32
32
  "dependencies": {
33
33
  "@kud/glyphs": "0.1.1",
34
34
  "cli-spinners": "3.4.0",
35
- "figures": "6.1.0"
35
+ "figures": "6.1.0",
36
+ "string-width": "8.2.2"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/node": "22.20.1",