@trendr/core 0.2.6 → 0.2.8

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.6",
3
+ "version": "0.2.8",
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/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
@@ -125,6 +132,10 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
125
132
  if (itemTop - margin < scrollOffset) scrollOffset = itemTop - margin
126
133
  if (itemBottom + margin > scrollOffset + scrollViewH) scrollOffset = itemBottom + margin - scrollViewH
127
134
  scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
135
+ // hard guarantee: selected item must be visible regardless of margin overshoot
136
+ if (itemTop < scrollOffset) scrollOffset = itemTop
137
+ if (itemBottom > scrollOffset + scrollViewH) scrollOffset = itemBottom - scrollViewH
138
+ scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
128
139
  if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
129
140
  }
130
141
 
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,28 +87,36 @@ 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) {
113
+ event.value = value()
114
+ if (onKeyDown(event)) {
115
+ event.stopPropagation()
116
+ return
117
+ }
118
+ }
119
+
112
120
  resetBlink()
113
121
 
114
122
  const { key, raw, ctrl, meta } = event