@trendr/core 0.2.5 → 0.2.7

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/index.js CHANGED
@@ -23,7 +23,7 @@ export { MillerNav } from './src/miller-nav.js'
23
23
  export { MenuBar } from './src/menubar.js'
24
24
  export { Task } from './src/task.js'
25
25
  export { Shimmer } from './src/shimmer.js'
26
- export { useFocus } from './src/focus.js'
26
+ export { useFocus, useFocusTrap } from './src/focus.js'
27
27
  export { useHotkey } from './src/hotkey.js'
28
28
  export { useToast } from './src/toast.js'
29
29
  export { Fragment } from './src/element.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/focus.js CHANGED
@@ -1,7 +1,36 @@
1
- import { createSignalRaw } from './signal.js'
1
+ import { createSignalRaw, onCleanup } from './signal.js'
2
2
  import { registerHook } from './renderer.js'
3
3
  import { useInput } from './hooks.js'
4
4
 
5
+ let focusTrapStack = []
6
+
7
+ export function useFocusTrap(active) {
8
+ const state = registerHook(() => {
9
+ const id = Symbol()
10
+ onCleanup(() => {
11
+ const idx = focusTrapStack.indexOf(id)
12
+ if (idx >= 0) focusTrapStack.splice(idx, 1)
13
+ })
14
+ return { id, wasActive: false }
15
+ })
16
+
17
+ if (active && !state.wasActive) {
18
+ focusTrapStack.push(state.id)
19
+ state.wasActive = true
20
+ } else if (!active && state.wasActive) {
21
+ const idx = focusTrapStack.indexOf(state.id)
22
+ if (idx >= 0) focusTrapStack.splice(idx, 1)
23
+ state.wasActive = false
24
+ }
25
+
26
+ useInput((event) => {
27
+ if (!active) return
28
+ if (event.key === 'tab' || event.key === 'shift-tab') {
29
+ event.stopPropagation()
30
+ }
31
+ })
32
+ }
33
+
5
34
  export function useFocus({ initial, cycle = 'tab' } = {}) {
6
35
  const state = registerHook(() => {
7
36
  const [current, setCurrent] = createSignalRaw(initial ?? null)
@@ -93,6 +122,7 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
93
122
  const cur = state.current()
94
123
 
95
124
  if (state.stack.length > 0) return
125
+ if (focusTrapStack.length > 0) return
96
126
 
97
127
  if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
98
128
  const idx = findTopLevel(cur)
package/src/input.js CHANGED
@@ -153,7 +153,23 @@ export function createInputHandler(stream) {
153
153
  }
154
154
  }
155
155
 
156
+ function isPaste(data) {
157
+ const s = typeof data === 'string' ? data : data.toString()
158
+ return s.length > 1 && !s.startsWith('\x1b') && (s.includes('\n') || s.includes('\r'))
159
+ }
160
+
156
161
  function onData(data) {
162
+ if (isPaste(data)) {
163
+ const text = (typeof data === 'string' ? data : data.toString()).replace(/\r\n?/g, '\n')
164
+ const event = { key: 'paste', text, ctrl: false, meta: false, shift: false, raw: text }
165
+ event.stopPropagation = () => { event._stopped = true }
166
+ const snapshot = [...keyListeners].reverse()
167
+ for (const fn of snapshot) {
168
+ fn(event)
169
+ if (event._stopped) break
170
+ }
171
+ return
172
+ }
157
173
  for (const key of splitKeys(data)) {
158
174
  dispatch(key)
159
175
  }
package/src/layout.js CHANGED
@@ -259,7 +259,7 @@ function measureChild(child, cs, isRow, availW, availH) {
259
259
  const text = extractText(leaf)
260
260
  const overflow = cs.overflow
261
261
  if (!text) { w = explicitW ?? 0; h = explicitH ?? 1 }
262
- else if (overflow === 'nowrap' || overflow === 'truncate') {
262
+ else if (overflow === 'nowrap' || overflow === 'truncate' || overflow === 'clip') {
263
263
  w = explicitW ?? Math.min(availW, measureText(text))
264
264
  h = explicitH ?? 1
265
265
  } else {
package/src/list.js CHANGED
@@ -1,16 +1,23 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
+ import { registerHook } from './renderer.js'
4
5
 
5
- export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
6
+ export function List({ items, selected: selectedProp, onSelect, onCursorChange, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
6
7
  const { accent = 'cyan' } = useTheme()
7
8
  const [selectedInternal, setSelectedInternal] = createSignal(0)
8
9
  const [scrollState, setScrollState] = createSignal(0)
9
10
  const layout = useLayout()
11
+ const prevCursor = registerHook(() => ({ index: -1 }))
10
12
 
11
13
  const selected = selectedProp ?? selectedInternal()
12
14
  const setSelected = onSelect ?? setSelectedInternal
13
15
 
16
+ if (onCursorChange && selected !== prevCursor.index && items.length > 0) {
17
+ prevCursor.index = selected
18
+ onCursorChange(items[selected], selected)
19
+ }
20
+
14
21
  const viewH = layout.height
15
22
  const contentH = layout.contentHeight ?? 0
16
23
  const headerH = header ? headerHeight : 0
package/src/modal.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { jsx } from '../jsx-runtime.js'
2
2
  import { useInput, useTheme } from './hooks.js'
3
3
  import { registerOverlay } from './renderer.js'
4
+ import { useFocusTrap } from './focus.js'
4
5
 
5
6
  export function Modal({ open, onClose, title, children, width: w = 40, border = 'round' }) {
6
7
  const { accent = 'cyan' } = useTheme()
7
8
 
9
+ useFocusTrap(open)
10
+
8
11
  useInput((event) => {
9
12
  if (!open) return
10
13
  if (event.key === 'escape') {
package/src/pick-list.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useLayout, useTheme, useCursor } from './hooks.js'
4
+ import { registerHook } from './renderer.js'
4
5
  import { List } from './list.js'
5
6
 
6
- export function PickList({ items, onSelect, onCancel, onChange, focused = true, placeholder = 'search...', filter: filterFn, renderItem, maxVisible = 10, scrollbar = false, scrolloff = 0, itemHeight = 1, itemGap = 0, gap = 0, clearOnSelect = false, style: userStyle, cursor: cursorProp }) {
7
+ export function PickList({ items, onSelect, onCancel, onChange, onCursorChange, focused = true, placeholder = 'search...', filter: filterFn, renderItem, maxVisible = 10, scrollbar = false, scrolloff = 0, itemHeight = 1, itemGap = 0, gap = 0, clearOnSelect = false, style: userStyle, cursor: cursorProp }) {
7
8
  const { accent = 'cyan' } = useTheme()
8
9
  const defaults = {
9
10
  borderColor: accent,
@@ -33,6 +34,14 @@ export function PickList({ items, onSelect, onCancel, onChange, focused = true,
33
34
  if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1)
34
35
  if (cursor !== listCursor()) setListCursor(cursor)
35
36
 
37
+ const prevCursor = registerHook(() => ({ index: -1, item: undefined }))
38
+ const cursorItem = filtered.length > 0 ? filtered[cursor] : undefined
39
+ if (onCursorChange && (cursor !== prevCursor.index || cursorItem !== prevCursor.item)) {
40
+ prevCursor.index = cursor
41
+ prevCursor.item = cursorItem
42
+ if (cursorItem !== undefined) onCursorChange(cursorItem, cursor)
43
+ }
44
+
36
45
  function updateText(v, c) {
37
46
  setQuery(v)
38
47
  setTextCursor(c)
package/src/renderer.js CHANGED
@@ -321,7 +321,8 @@ function paintTree(node, buf, clip, offset, prevBuf) {
321
321
  if (!text) return
322
322
 
323
323
  const truncate = style.overflow === 'truncate'
324
- const wrap = style.overflow !== 'nowrap' && !truncate
324
+ const clip = style.overflow === 'clip'
325
+ const wrap = style.overflow !== 'nowrap' && !truncate && !clip
325
326
 
326
327
  if (wrap) {
327
328
  const lines = wordWrap(text, layout.width)
package/src/text-area.js CHANGED
@@ -87,34 +87,46 @@ function ensureVisible(cursorRow, scroll, height, totalLines) {
87
87
  return scroll
88
88
  }
89
89
 
90
- export function TextArea({ onSubmit, onCancel, onChange, placeholder, focused = true, maxHeight = 10, clearOnSubmit = true, cursor: cursorProp, value: valueProp, submitOnEnter = false }) {
90
+ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder, focused = true, maxHeight = 10, clearOnSubmit = true, cursor: cursorProp, value: valueProp, submitOnEnter = false }) {
91
91
  const [value, setValue] = createSignal('')
92
92
  const [cursor, setCursor] = createSignal(0)
93
- const _prev = registerHook(() => ({ value: undefined }))
94
- if (valueProp !== undefined && valueProp !== _prev.value) {
95
- _prev.value = valueProp
93
+ if (valueProp !== undefined && valueProp !== value()) {
96
94
  setValue(valueProp)
97
- setCursor(valueProp.length)
95
+ setCursor(c => Math.min(c, valueProp.length))
98
96
  }
99
97
  const ref = registerHook(() => ({ scroll: 0, goalCol: null }))
100
98
  const layout = useLayout()
101
99
  const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
102
100
 
103
- function update(v, c) {
104
- setValue(v)
101
+ function update(next, c) {
102
+ const prev = value()
103
+ setValue(next)
105
104
  setCursor(c)
106
105
  ref.goalCol = null
107
- if (onChange) onChange(v)
106
+ if (onChange) onChange(next, prev)
108
107
  }
109
108
 
110
109
  useInput((event) => {
111
110
  if (!focused) return
111
+
112
+ if (onKeyDown && onKeyDown(event)) {
113
+ event.stopPropagation()
114
+ return
115
+ }
116
+
112
117
  resetBlink()
113
118
 
114
119
  const { key, raw, ctrl, meta } = event
115
120
  const v = value()
116
121
  const c = cursor()
117
122
 
123
+ if (key === 'paste') {
124
+ const pasted = event.text || ''
125
+ update(v.slice(0, c) + pasted + v.slice(c), c + pasted.length)
126
+ event.stopPropagation()
127
+ return
128
+ }
129
+
118
130
  const isSubmitKey = submitOnEnter
119
131
  ? (key === 'return' && !meta)
120
132
  : (meta && key === '\r')