termcast 1.3.32 → 1.3.34
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/dist/action-utils.d.ts.map +1 -1
- package/dist/action-utils.js +8 -0
- package/dist/action-utils.js.map +1 -1
- package/dist/apis/cache.d.ts +1 -2
- package/dist/apis/cache.d.ts.map +1 -1
- package/dist/apis/cache.js +138 -54
- package/dist/apis/cache.js.map +1 -1
- package/dist/apis/clipboard.d.ts.map +1 -1
- package/dist/apis/clipboard.js +4 -0
- package/dist/apis/clipboard.js.map +1 -1
- package/dist/apis/oauth.d.ts.map +1 -1
- package/dist/apis/oauth.js +31 -4
- package/dist/apis/oauth.js.map +1 -1
- package/dist/build.d.ts +0 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +30 -51
- package/dist/build.js.map +1 -1
- package/dist/cli.js +31 -14
- package/dist/cli.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -1
- package/dist/compile.js.map +1 -1
- package/dist/components/actions.d.ts +14 -0
- package/dist/components/actions.d.ts.map +1 -1
- package/dist/components/actions.js +151 -59
- package/dist/components/actions.js.map +1 -1
- package/dist/components/alert.d.ts.map +1 -1
- package/dist/components/alert.js +6 -5
- package/dist/components/alert.js.map +1 -1
- package/dist/components/animation-tick.d.ts +1 -1
- package/dist/components/animation-tick.js +1 -1
- package/dist/components/animation-tick.js.map +1 -1
- package/dist/components/detail.d.ts +5 -31
- package/dist/components/detail.d.ts.map +1 -1
- package/dist/components/detail.js +36 -52
- package/dist/components/detail.js.map +1 -1
- package/dist/components/dropdown.d.ts +1 -1
- package/dist/components/dropdown.d.ts.map +1 -1
- package/dist/components/dropdown.js +50 -22
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/footer.d.ts.map +1 -1
- package/dist/components/footer.js +19 -18
- package/dist/components/footer.js.map +1 -1
- package/dist/components/form/checkbox.d.ts.map +1 -1
- package/dist/components/form/checkbox.js +12 -11
- package/dist/components/form/checkbox.js.map +1 -1
- package/dist/components/form/date-picker.d.ts.map +1 -1
- package/dist/components/form/date-picker.js +7 -22
- package/dist/components/form/date-picker.js.map +1 -1
- package/dist/components/form/description.d.ts +1 -1
- package/dist/components/form/description.d.ts.map +1 -1
- package/dist/components/form/description.js +6 -5
- package/dist/components/form/description.js.map +1 -1
- package/dist/components/form/dropdown.d.ts.map +1 -1
- package/dist/components/form/dropdown.js +53 -50
- package/dist/components/form/dropdown.js.map +1 -1
- package/dist/components/form/file-autocomplete.d.ts.map +1 -1
- package/dist/components/form/file-autocomplete.js +5 -4
- package/dist/components/form/file-autocomplete.js.map +1 -1
- package/dist/components/form/file-picker.d.ts.map +1 -1
- package/dist/components/form/file-picker.js +23 -22
- package/dist/components/form/file-picker.js.map +1 -1
- package/dist/components/form/form-end.d.ts.map +1 -1
- package/dist/components/form/form-end.js +6 -4
- package/dist/components/form/form-end.js.map +1 -1
- package/dist/components/form/form-field-wrapper.d.ts +15 -0
- package/dist/components/form/form-field-wrapper.d.ts.map +1 -0
- package/dist/components/form/form-field-wrapper.js +29 -0
- package/dist/components/form/form-field-wrapper.js.map +1 -0
- package/dist/components/form/index.d.ts.map +1 -1
- package/dist/components/form/index.js +31 -30
- package/dist/components/form/index.js.map +1 -1
- package/dist/components/form/password-field.d.ts.map +1 -1
- package/dist/components/form/password-field.js +7 -6
- package/dist/components/form/password-field.js.map +1 -1
- package/dist/components/form/separator.d.ts.map +1 -1
- package/dist/components/form/separator.js +3 -2
- package/dist/components/form/separator.js.map +1 -1
- package/dist/components/form/tagpicker.d.ts.map +1 -1
- package/dist/components/form/tagpicker.js +2 -1
- package/dist/components/form/tagpicker.js.map +1 -1
- package/dist/components/form/text-area.d.ts.map +1 -1
- package/dist/components/form/text-area.js +7 -6
- package/dist/components/form/text-area.js.map +1 -1
- package/dist/components/form/text-field.d.ts.map +1 -1
- package/dist/components/form/text-field.js +7 -6
- package/dist/components/form/text-field.js.map +1 -1
- package/dist/components/form/use-form-navigation.d.ts.map +1 -1
- package/dist/components/form/use-form-navigation.js +4 -4
- package/dist/components/form/use-form-navigation.js.map +1 -1
- package/dist/components/form/with-left-border.d.ts +15 -0
- package/dist/components/form/with-left-border.d.ts.map +1 -1
- package/dist/components/form/with-left-border.js +21 -9
- package/dist/components/form/with-left-border.js.map +1 -1
- package/dist/components/icon.d.ts +14 -0
- package/dist/components/icon.d.ts.map +1 -1
- package/dist/components/icon.js +60 -0
- package/dist/components/icon.js.map +1 -1
- package/dist/components/image.d.ts +47 -2
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +46 -7
- package/dist/components/image.js.map +1 -1
- package/dist/components/list.d.ts +5 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +188 -132
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-bar.d.ts.map +1 -1
- package/dist/components/loading-bar.js +4 -3
- package/dist/components/loading-bar.js.map +1 -1
- package/dist/components/metadata.d.ts +70 -0
- package/dist/components/metadata.d.ts.map +1 -0
- package/dist/components/metadata.js +82 -0
- package/dist/components/metadata.js.map +1 -0
- package/dist/components/theme-picker.d.ts.map +1 -1
- package/dist/components/theme-picker.js +3 -2
- package/dist/components/theme-picker.js.map +1 -1
- package/dist/descendants-v2.d.ts +60 -0
- package/dist/descendants-v2.d.ts.map +1 -0
- package/dist/descendants-v2.js +144 -0
- package/dist/descendants-v2.js.map +1 -0
- package/dist/examples/actions-context.d.ts +2 -0
- package/dist/examples/actions-context.d.ts.map +1 -0
- package/dist/examples/actions-context.js +33 -0
- package/dist/examples/actions-context.js.map +1 -0
- package/dist/examples/form-basic.d.ts.map +1 -1
- package/dist/examples/form-basic.js +1 -1
- package/dist/examples/form-basic.js.map +1 -1
- package/dist/examples/form-dropdown.js +1 -1
- package/dist/examples/form-dropdown.js.map +1 -1
- package/dist/examples/internal/custom-action-renderables.d.ts +70 -0
- package/dist/examples/internal/custom-action-renderables.d.ts.map +1 -0
- package/dist/examples/internal/custom-action-renderables.js +163 -0
- package/dist/examples/internal/custom-action-renderables.js.map +1 -0
- package/dist/examples/internal/custom-dropdown.d.ts +99 -0
- package/dist/examples/internal/custom-dropdown.d.ts.map +1 -0
- package/dist/examples/internal/custom-dropdown.js +270 -0
- package/dist/examples/internal/custom-dropdown.js.map +1 -0
- package/dist/examples/internal/custom-renderable-form.d.ts +43 -0
- package/dist/examples/internal/custom-renderable-form.d.ts.map +1 -0
- package/dist/examples/internal/custom-renderable-form.js +284 -0
- package/dist/examples/internal/custom-renderable-form.js.map +1 -0
- package/dist/examples/internal/custom-renderable-list-default-search.d.ts +2 -0
- package/dist/examples/internal/custom-renderable-list-default-search.d.ts.map +1 -0
- package/dist/examples/internal/custom-renderable-list-default-search.js +16 -0
- package/dist/examples/internal/custom-renderable-list-default-search.js.map +1 -0
- package/dist/examples/internal/custom-renderable-list-v2-default-search.d.ts +2 -0
- package/dist/examples/internal/custom-renderable-list-v2-default-search.d.ts.map +1 -0
- package/dist/examples/internal/custom-renderable-list-v2-default-search.js +24 -0
- package/dist/examples/internal/custom-renderable-list-v2-default-search.js.map +1 -0
- package/dist/examples/internal/custom-renderable-list-v2.d.ts +189 -0
- package/dist/examples/internal/custom-renderable-list-v2.d.ts.map +1 -0
- package/dist/examples/internal/custom-renderable-list-v2.js +708 -0
- package/dist/examples/internal/custom-renderable-list-v2.js.map +1 -0
- package/dist/examples/internal/custom-renderable-list.d.ts +72 -0
- package/dist/examples/internal/custom-renderable-list.d.ts.map +1 -0
- package/dist/examples/internal/custom-renderable-list.js +544 -0
- package/dist/examples/internal/custom-renderable-list.js.map +1 -0
- package/dist/examples/internal/rhf-custom-ref.js +5 -4
- package/dist/examples/internal/rhf-custom-ref.js.map +1 -1
- package/dist/examples/internal/scrollbox-with-descendants.js +4 -2
- package/dist/examples/internal/scrollbox-with-descendants.js.map +1 -1
- package/dist/examples/list-controlled-search.d.ts +2 -0
- package/dist/examples/list-controlled-search.d.ts.map +1 -0
- package/dist/examples/list-controlled-search.js +12 -0
- package/dist/examples/list-controlled-search.js.map +1 -0
- package/dist/examples/list-detail-metadata.js +1 -1
- package/dist/examples/list-detail-metadata.js.map +1 -1
- package/dist/examples/simple-image-mask.d.ts +8 -0
- package/dist/examples/simple-image-mask.d.ts.map +1 -0
- package/dist/examples/simple-image-mask.js +12 -0
- package/dist/examples/simple-image-mask.js.map +1 -0
- package/dist/examples/toast-variations.js +1 -1
- package/dist/examples/toast-variations.js.map +1 -1
- package/dist/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +3 -2
- package/dist/extensions/dev.js.map +1 -1
- package/dist/extensions/react-refresh-init.d.ts.map +1 -1
- package/dist/extensions/react-refresh-init.js +4 -3
- package/dist/extensions/react-refresh-init.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/date-picker-widget.d.ts.map +1 -1
- package/dist/internal/date-picker-widget.js +2 -1
- package/dist/internal/date-picker-widget.js.map +1 -1
- package/dist/internal/dialog.d.ts +6 -0
- package/dist/internal/dialog.d.ts.map +1 -1
- package/dist/internal/dialog.js +59 -18
- package/dist/internal/dialog.js.map +1 -1
- package/dist/internal/navigation.d.ts.map +1 -1
- package/dist/internal/navigation.js +8 -1
- package/dist/internal/navigation.js.map +1 -1
- package/dist/internal/offscreen.d.ts +3 -0
- package/dist/internal/offscreen.d.ts.map +1 -1
- package/dist/internal/offscreen.js +5 -0
- package/dist/internal/offscreen.js.map +1 -1
- package/dist/internal/providers.d.ts.map +1 -1
- package/dist/internal/providers.js +20 -3
- package/dist/internal/providers.js.map +1 -1
- package/dist/internal/scrollbox.d.ts.map +1 -1
- package/dist/internal/scrollbox.js +3 -2
- package/dist/internal/scrollbox.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +4 -0
- package/dist/logger.js.map +1 -1
- package/dist/preload.js +5 -17
- package/dist/preload.js.map +1 -1
- package/dist/state.d.ts +4 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +4 -0
- package/dist/state.js.map +1 -1
- package/dist/test-border-overlay.d.ts +2 -0
- package/dist/test-border-overlay.d.ts.map +1 -0
- package/dist/test-border-overlay.js +7 -0
- package/dist/test-border-overlay.js.map +1 -0
- package/dist/test-layout-2.d.ts +2 -0
- package/dist/test-layout-2.d.ts.map +1 -0
- package/dist/test-layout-2.js +5 -0
- package/dist/test-layout-2.js.map +1 -0
- package/dist/test-layout.d.ts +2 -0
- package/dist/test-layout.d.ts.map +1 -0
- package/dist/test-layout.js +7 -0
- package/dist/test-layout.js.map +1 -0
- package/dist/theme.d.ts +1 -2
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +5 -9
- package/dist/theme.js.map +1 -1
- package/dist/utils/run-command.d.ts +1 -1
- package/dist/utils/run-command.d.ts.map +1 -1
- package/dist/utils/run-command.js +27 -7
- package/dist/utils/run-command.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +44 -23
- package/dist/utils.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +24 -4
- package/dist/watcher.js.map +1 -1
- package/package.json +14 -12
- package/src/action-utils.tsx +10 -0
- package/src/apis/cache.test.ts +35 -3
- package/src/apis/cache.tsx +184 -59
- package/src/apis/clipboard.tsx +5 -0
- package/src/apis/oauth.tsx +33 -4
- package/src/build.tsx +35 -58
- package/src/cli.tsx +156 -134
- package/src/compile.tsx +6 -3
- package/src/compile.vitest.tsx +33 -15
- package/src/components/actions.tsx +230 -99
- package/src/components/alert.tsx +11 -10
- package/src/components/animation-tick.tsx +1 -1
- package/src/components/detail.tsx +56 -151
- package/src/components/dropdown.tsx +70 -36
- package/src/components/footer.tsx +58 -33
- package/src/components/form/checkbox.tsx +30 -32
- package/src/components/form/date-picker.tsx +27 -47
- package/src/components/form/description.tsx +19 -18
- package/src/components/form/dropdown.tsx +95 -103
- package/src/components/form/file-autocomplete.tsx +9 -8
- package/src/components/form/file-picker.tsx +46 -46
- package/src/components/form/form-end.tsx +6 -4
- package/src/components/form/index.tsx +38 -48
- package/src/components/form/password-field.tsx +25 -27
- package/src/components/form/separator.tsx +3 -2
- package/src/components/form/tagpicker.tsx +2 -1
- package/src/components/form/text-area.tsx +25 -30
- package/src/components/form/text-field.tsx +25 -27
- package/src/components/form/use-form-navigation.tsx +4 -5
- package/src/components/form/with-left-border.tsx +48 -10
- package/src/components/icon.tsx +69 -0
- package/src/components/image.tsx +60 -7
- package/src/components/list.tsx +270 -202
- package/src/components/loading-bar.tsx +4 -3
- package/src/components/metadata.tsx +217 -0
- package/src/components/theme-picker.tsx +3 -2
- package/src/examples/actions-context.tsx +63 -0
- package/src/examples/actions-context.vitest.tsx +110 -0
- package/src/examples/actions-dialog-layout.vitest.tsx +2 -1
- package/src/examples/file-autocomplete.vitest.tsx +15 -15
- package/src/examples/form-basic.tsx +12 -0
- package/src/examples/form-basic.vitest.tsx +74 -74
- package/src/examples/form-dropdown.tsx +8 -0
- package/src/examples/form-dropdown.vitest.tsx +364 -421
- package/src/examples/form-tagpicker.vitest.tsx +56 -54
- package/src/examples/github.vitest.tsx +252 -0
- package/src/examples/internal/rhf-custom-ref.tsx +16 -15
- package/src/examples/internal/scrollbox-with-descendants.tsx +4 -2
- package/src/examples/internal/simple-dialog.tsx +1 -1
- package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -9
- package/src/examples/list-controlled-search.tsx +28 -0
- package/src/examples/list-controlled-search.vitest.tsx +49 -0
- package/src/examples/list-detail-metadata.tsx +8 -5
- package/src/examples/list-detail-metadata.vitest.tsx +22 -22
- package/src/examples/list-dropdown-default.vitest.tsx +12 -12
- package/src/examples/list-scrollbox.vitest.tsx +52 -38
- package/src/examples/list-with-detail.vitest.tsx +45 -41
- package/src/examples/list-with-dropdown.vitest.tsx +5 -5
- package/src/examples/list-with-sections.vitest.tsx +65 -12
- package/src/examples/list-with-toast.vitest.tsx +4 -4
- package/src/examples/simple-file-picker.vitest.tsx +12 -12
- package/src/examples/simple-grid.vitest.tsx +53 -53
- package/src/examples/simple-image-mask.tsx +58 -0
- package/src/examples/simple-navigation.vitest.tsx +19 -19
- package/src/examples/store.vitest.tsx +1 -1
- package/src/examples/swift-extension.vitest.tsx +4 -2
- package/src/examples/synonyms.vitest.tsx +31 -9
- package/src/examples/toast-action.vitest.tsx +8 -8
- package/src/examples/toast-variations.tsx +1 -1
- package/src/examples/toast-variations.vitest.tsx +69 -134
- package/src/extensions/dev.tsx +3 -2
- package/src/extensions/dev.vitest.tsx +65 -28
- package/src/extensions/react-refresh-init.tsx +4 -3
- package/src/index.tsx +3 -1
- package/src/internal/date-picker-widget.tsx +2 -1
- package/src/internal/dialog.tsx +100 -28
- package/src/internal/navigation.tsx +8 -1
- package/src/internal/offscreen.tsx +10 -0
- package/src/internal/providers.tsx +34 -8
- package/src/internal/scrollbox.tsx +4 -2
- package/src/logger.tsx +4 -0
- package/src/preload.tsx +5 -17
- package/src/state.tsx +12 -0
- package/src/theme.tsx +6 -9
- package/src/utils/run-command.tsx +32 -8
- package/src/utils.tsx +58 -23
- package/src/watcher.tsx +26 -6
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Custom Renderable List V2 - Wrapper Pattern
|
|
4
|
+
*
|
|
5
|
+
* This example uses opentui's extend() to register thin wrapper renderables
|
|
6
|
+
* that handle tracking and visibility, while React handles all UI rendering.
|
|
7
|
+
*
|
|
8
|
+
* ## Architecture
|
|
9
|
+
*
|
|
10
|
+
* CustomListRenderable (custom renderable)
|
|
11
|
+
* ├── owns: filtering logic, navigation, scrolling
|
|
12
|
+
* ├── children redirected to scrollBox
|
|
13
|
+
* │
|
|
14
|
+
* └── CustomListItemWrapperRenderable (thin wrapper)
|
|
15
|
+
* ├── tracks: keywords, onAction, section, visibleIndex, itemId
|
|
16
|
+
* ├── handles: visibility (height: 0 when hidden)
|
|
17
|
+
* └── React children render all UI (indicator, title, subtitle)
|
|
18
|
+
*
|
|
19
|
+
* ## Key Patterns
|
|
20
|
+
*
|
|
21
|
+
* 1. Wrapper renderables self-register via onLifecyclePass (SYNC)
|
|
22
|
+
* 2. All UI in JSX - wrappers only track/hide
|
|
23
|
+
* 3. Zustand store for React state (selectedIndex, visibleCount, searchQuery)
|
|
24
|
+
* 4. Renderable calls zustand.setState to trigger React re-renders
|
|
25
|
+
* 5. Items compare visibleIndex to selectedIndex from zustand
|
|
26
|
+
*
|
|
27
|
+
* ## Phase 1: Bidirectional Selection Sync
|
|
28
|
+
*
|
|
29
|
+
* - `selectedItemId` prop on List controls selection by item id
|
|
30
|
+
* - `onSelectionChange` callback fires when selection changes
|
|
31
|
+
* - Supports both controlled (via prop) and uncontrolled (internal) selection
|
|
32
|
+
*
|
|
33
|
+
* ## Phase 2: Detail Panel Support
|
|
34
|
+
*
|
|
35
|
+
* - `isShowingDetail` prop on List enables detail panel on the right
|
|
36
|
+
* - `detail` prop on Item provides React node to render in detail panel
|
|
37
|
+
* - Detail updates automatically when selection changes
|
|
38
|
+
* - List splits into two columns when detail is shown
|
|
39
|
+
*
|
|
40
|
+
* ## How React Props Work with opentui Renderables
|
|
41
|
+
*
|
|
42
|
+
* React props are applied via direct property assignment, NOT just constructor:
|
|
43
|
+
*
|
|
44
|
+
* 1. `createInstance()` - constructor called (props passed in options)
|
|
45
|
+
* 2. `setInitialProperties()` - iterates props, does `instance[propKey] = propValue`
|
|
46
|
+
* 3. `commitUpdate()` - on re-render, applies changed props via `instance[propKey] = propValue`
|
|
47
|
+
*
|
|
48
|
+
* This means:
|
|
49
|
+
* - Props don't need to be read from constructor options - React sets them after
|
|
50
|
+
* - Simple props (just stored/read later) can be public fields
|
|
51
|
+
* - Props that need side effects on change require setters
|
|
52
|
+
* - Constructor should just create the renderable structure
|
|
53
|
+
*
|
|
54
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
* ## Migration Notes (Lessons from Form conversion)
|
|
56
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
*
|
|
58
|
+
* ### 1. Registration Order ≠ Visual Order
|
|
59
|
+
*
|
|
60
|
+
* IMPORTANT: `onLifecyclePass` is called in tree traversal order, which may NOT
|
|
61
|
+
* match the visual/React render order. For Form, we solved this by sorting
|
|
62
|
+
* fields by y-position instead of registration order:
|
|
63
|
+
*
|
|
64
|
+
* ```typescript
|
|
65
|
+
* private getFieldOrder(): string[] {
|
|
66
|
+
* return Array.from(this.fields.values())
|
|
67
|
+
* .sort((a, b) => (a.elementRef?.y ?? 0) - (b.elementRef?.y ?? 0))
|
|
68
|
+
* .map((f) => f.id)
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* For List, this is less critical since items use visibleIndex from filtering,
|
|
73
|
+
* but be aware if you need stable ordering based on visual position.
|
|
74
|
+
*
|
|
75
|
+
* ### 2. Auto-Focus Pattern
|
|
76
|
+
*
|
|
77
|
+
* Don't auto-focus in registerField() - registration order is unpredictable.
|
|
78
|
+
* Instead, use React useEffect that runs until focus is set:
|
|
79
|
+
*
|
|
80
|
+
* ```typescript
|
|
81
|
+
* useEffect(() => {
|
|
82
|
+
* if (focusedField) return
|
|
83
|
+
* const firstId = formRef.current?.getFirstFieldId()
|
|
84
|
+
* if (firstId) formRef.current?.focusField(firstId)
|
|
85
|
+
* })
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* ### 3. Hybrid Approach - Keep React for Styling
|
|
89
|
+
*
|
|
90
|
+
* The renderable should only handle STATE (registry, focus, scroll).
|
|
91
|
+
* Keep existing React components for UI (e.g., WithLeftBorder for Form).
|
|
92
|
+
* This ensures visual output stays identical - only the wiring changes.
|
|
93
|
+
*
|
|
94
|
+
* ### 4. Focus State Sync to React
|
|
95
|
+
*
|
|
96
|
+
* Form uses `onFocusChange` callback to sync focus to React state:
|
|
97
|
+
*
|
|
98
|
+
* ```typescript
|
|
99
|
+
* // In FormRenderable
|
|
100
|
+
* public onFocusChange?: (fieldId: string | null) => void
|
|
101
|
+
*
|
|
102
|
+
* focusField(id: string) {
|
|
103
|
+
* this._focusedFieldId = id
|
|
104
|
+
* this.onFocusChange?.(id) // Notify React
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* // In Form component
|
|
108
|
+
* const handleFormRef = (ref: FormRenderable | null) => {
|
|
109
|
+
* formRef.current = ref
|
|
110
|
+
* if (ref) ref.onFocusChange = setFocusedField
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* This example uses zustand instead (setState triggers re-render).
|
|
115
|
+
* Both patterns work - zustand is simpler for shared state.
|
|
116
|
+
*
|
|
117
|
+
* ### 5. Legacy Compatibility
|
|
118
|
+
*
|
|
119
|
+
* When migrating, keep old descendants/context during transition:
|
|
120
|
+
*
|
|
121
|
+
* ```typescript
|
|
122
|
+
* // Keep old system for components not yet migrated
|
|
123
|
+
* const { DescendantsProvider, useDescendant } = createDescendants<...>()
|
|
124
|
+
* export { useDescendant } // Still exported for old components
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* ### 6. Field Component Changes
|
|
128
|
+
*
|
|
129
|
+
* Each field component needs minimal changes:
|
|
130
|
+
* - Remove: useFormFieldDescendant() call, elementRef
|
|
131
|
+
* - Add: wrap return in <termcast-form-field-wrapper fieldId={id}>
|
|
132
|
+
* - Keep: all UI code (WithLeftBorder, etc.) unchanged
|
|
133
|
+
*
|
|
134
|
+
* ### 7. Testing Strategy
|
|
135
|
+
*
|
|
136
|
+
* Run existing tests with -u to update snapshots. Visual output should be
|
|
137
|
+
* identical - if tests fail, the wiring is wrong, not the rendering.
|
|
138
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
*/
|
|
140
|
+
import { BoxRenderable, TextRenderable, ScrollBoxRenderable, TextareaRenderable, } from '@opentui/core';
|
|
141
|
+
import { extend, useKeyboard } from '@opentui/react';
|
|
142
|
+
import { useIsInFocus } from 'termcast/src/internal/focus-context';
|
|
143
|
+
import { useStore } from 'termcast/src/state';
|
|
144
|
+
import { useRef, useState } from 'react';
|
|
145
|
+
import { renderWithProviders } from '../../utils';
|
|
146
|
+
import { create } from 'zustand';
|
|
147
|
+
import { useTheme } from 'termcast/src/theme';
|
|
148
|
+
const useCustomListStore = create(() => ({
|
|
149
|
+
selectedIndex: 0,
|
|
150
|
+
visibleCount: 0,
|
|
151
|
+
totalCount: 0,
|
|
152
|
+
searchQuery: '',
|
|
153
|
+
itemStates: {},
|
|
154
|
+
selectedItemId: null,
|
|
155
|
+
isShowingDetail: false,
|
|
156
|
+
currentDetailNode: null,
|
|
157
|
+
}));
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Helper: Find parent of specific type by traversing up
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
function findParent(node, type) {
|
|
163
|
+
let current = node.parent;
|
|
164
|
+
while (current) {
|
|
165
|
+
if (current instanceof type) {
|
|
166
|
+
return current;
|
|
167
|
+
}
|
|
168
|
+
current = current.parent;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
// CustomListItemWrapperRenderable - thin wrapper for tracking/hiding
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
class CustomListItemWrapperRenderable extends BoxRenderable {
|
|
176
|
+
parentList;
|
|
177
|
+
// Props set by React - used for filtering
|
|
178
|
+
keywords;
|
|
179
|
+
onAction;
|
|
180
|
+
itemTitle = '';
|
|
181
|
+
itemSubtitle;
|
|
182
|
+
itemId; // Phase 1: unique id for controlled selection
|
|
183
|
+
detail; // Phase 2: detail panel content
|
|
184
|
+
// Set by parent during refilter
|
|
185
|
+
visibleIndex = -1;
|
|
186
|
+
section;
|
|
187
|
+
constructor(ctx, options) {
|
|
188
|
+
super(ctx, { ...options, flexDirection: 'row', width: '100%' });
|
|
189
|
+
// NO UI creation - React children provide that
|
|
190
|
+
// Self-register with parent list after being added to tree
|
|
191
|
+
this.onLifecyclePass = () => {
|
|
192
|
+
if (!this.parentList) {
|
|
193
|
+
this.parentList = findParent(this, CustomListRenderable);
|
|
194
|
+
this.section = findParent(this, CustomListSectionWrapperRenderable);
|
|
195
|
+
this.parentList?.registerItem(this);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// Example: register click handler directly on renderable
|
|
199
|
+
this.onMouseDown = (event) => {
|
|
200
|
+
console.log('CustomListItemWrapperRenderable clicked:', this.itemTitle, event);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// CustomListSectionWrapperRenderable - thin wrapper for sections
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
class CustomListSectionWrapperRenderable extends BoxRenderable {
|
|
208
|
+
parentList;
|
|
209
|
+
// Props set by React
|
|
210
|
+
sectionTitle;
|
|
211
|
+
constructor(ctx, options) {
|
|
212
|
+
super(ctx, { ...options, flexDirection: 'column', width: '100%' });
|
|
213
|
+
// NO UI creation - React children provide that
|
|
214
|
+
// Self-register with parent list after being added to tree
|
|
215
|
+
this.onLifecyclePass = () => {
|
|
216
|
+
if (!this.parentList) {
|
|
217
|
+
this.parentList = findParent(this, CustomListRenderable);
|
|
218
|
+
this.parentList?.registerSection(this);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// CustomListEmptyViewWrapperRenderable - data holder for empty state
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
class CustomListEmptyViewWrapperRenderable extends BoxRenderable {
|
|
227
|
+
parentList;
|
|
228
|
+
// Props set by React - read by parent for empty state
|
|
229
|
+
emptyTitle = 'No items';
|
|
230
|
+
emptyDescription;
|
|
231
|
+
constructor(ctx, options) {
|
|
232
|
+
// Hidden by default - this is just a data holder
|
|
233
|
+
super(ctx, { ...options, visible: false });
|
|
234
|
+
// Self-register with parent list after being added to tree
|
|
235
|
+
this.onLifecyclePass = () => {
|
|
236
|
+
if (!this.parentList) {
|
|
237
|
+
this.parentList = findParent(this, CustomListRenderable);
|
|
238
|
+
this.parentList?.registerEmptyView(this);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
// CustomListRenderable - parent container with filtering/navigation logic
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
class CustomListRenderable extends BoxRenderable {
|
|
247
|
+
// Registered children (they register themselves via onLifecyclePass)
|
|
248
|
+
registeredItems = new Set();
|
|
249
|
+
registeredSections = new Set();
|
|
250
|
+
emptyView;
|
|
251
|
+
// Internal state
|
|
252
|
+
searchQuery = '';
|
|
253
|
+
// UI components owned by renderable
|
|
254
|
+
scrollBox;
|
|
255
|
+
searchInput;
|
|
256
|
+
statusText;
|
|
257
|
+
// Prop with setter - updates search input placeholder
|
|
258
|
+
_placeholder = 'Search...';
|
|
259
|
+
get placeholder() {
|
|
260
|
+
return this._placeholder;
|
|
261
|
+
}
|
|
262
|
+
set placeholder(value) {
|
|
263
|
+
this._placeholder = value;
|
|
264
|
+
this.searchInput.placeholder = value;
|
|
265
|
+
}
|
|
266
|
+
// Prop with setter - sets initial search query
|
|
267
|
+
_defaultSearchQuery = '';
|
|
268
|
+
get defaultSearchQuery() {
|
|
269
|
+
return this._defaultSearchQuery;
|
|
270
|
+
}
|
|
271
|
+
set defaultSearchQuery(value) {
|
|
272
|
+
if (this._defaultSearchQuery === value)
|
|
273
|
+
return;
|
|
274
|
+
this._defaultSearchQuery = value;
|
|
275
|
+
// Set search input text
|
|
276
|
+
this.searchInput.editBuffer?.setText(value);
|
|
277
|
+
this.searchQuery = value;
|
|
278
|
+
// Refilter will be called after items register
|
|
279
|
+
}
|
|
280
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
281
|
+
// Phase 1: Bidirectional Selection Sync
|
|
282
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// Callback when selection changes - set by React
|
|
284
|
+
onSelectionChange;
|
|
285
|
+
// Controlled selection by item id
|
|
286
|
+
_selectedItemId = null;
|
|
287
|
+
get selectedItemId() {
|
|
288
|
+
return this._selectedItemId;
|
|
289
|
+
}
|
|
290
|
+
set selectedItemId(value) {
|
|
291
|
+
if (this._selectedItemId === value)
|
|
292
|
+
return;
|
|
293
|
+
this._selectedItemId = value;
|
|
294
|
+
// Find item with this id and update selectedIndex
|
|
295
|
+
if (value !== null) {
|
|
296
|
+
const item = this.getAllItems().find((i) => i.itemId === value);
|
|
297
|
+
if (item && item.visibleIndex !== -1) {
|
|
298
|
+
useCustomListStore.setState({
|
|
299
|
+
selectedIndex: item.visibleIndex,
|
|
300
|
+
selectedItemId: value,
|
|
301
|
+
});
|
|
302
|
+
this.scrollToIndex(item.visibleIndex);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Helper to notify selection change
|
|
307
|
+
notifySelectionChange(index) {
|
|
308
|
+
const item = this.getAllItems().find((i) => i.visibleIndex === index);
|
|
309
|
+
const itemId = item?.itemId ?? null;
|
|
310
|
+
useCustomListStore.setState({ selectedItemId: itemId });
|
|
311
|
+
this.onSelectionChange?.(itemId);
|
|
312
|
+
}
|
|
313
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
314
|
+
// Phase 2: Detail Panel Support
|
|
315
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
316
|
+
// Prop with setter - controls whether detail panel is shown
|
|
317
|
+
_isShowingDetail = false;
|
|
318
|
+
get isShowingDetail() {
|
|
319
|
+
return this._isShowingDetail;
|
|
320
|
+
}
|
|
321
|
+
set isShowingDetail(value) {
|
|
322
|
+
if (this._isShowingDetail === value)
|
|
323
|
+
return;
|
|
324
|
+
this._isShowingDetail = value;
|
|
325
|
+
useCustomListStore.setState({ isShowingDetail: value });
|
|
326
|
+
this.updateCurrentDetail();
|
|
327
|
+
}
|
|
328
|
+
// Updates the current detail node in zustand based on selected item
|
|
329
|
+
updateCurrentDetail() {
|
|
330
|
+
if (!this._isShowingDetail) {
|
|
331
|
+
useCustomListStore.setState({ currentDetailNode: null });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const { selectedIndex } = useCustomListStore.getState();
|
|
335
|
+
const item = this.getAllItems().find((i) => i.visibleIndex === selectedIndex);
|
|
336
|
+
useCustomListStore.setState({ currentDetailNode: item?.detail ?? null });
|
|
337
|
+
}
|
|
338
|
+
constructor(ctx, options) {
|
|
339
|
+
super(ctx, { ...options, flexDirection: 'column' });
|
|
340
|
+
// Create search input - React will set placeholder/defaultSearchQuery props after constructor
|
|
341
|
+
this.searchInput = new TextareaRenderable(ctx, {
|
|
342
|
+
placeholder: 'Search...',
|
|
343
|
+
height: 1,
|
|
344
|
+
width: '100%',
|
|
345
|
+
keyBindings: [
|
|
346
|
+
{ name: 'return', action: 'submit' },
|
|
347
|
+
{ name: 'linefeed', action: 'submit' },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
this.searchInput.onContentChange = () => {
|
|
351
|
+
const value = this.searchInput.editBuffer?.getText() || '';
|
|
352
|
+
this.setSearchQuery(value);
|
|
353
|
+
};
|
|
354
|
+
this.searchInput.focus();
|
|
355
|
+
this.scrollBox = new ScrollBoxRenderable(ctx, {
|
|
356
|
+
flexGrow: 1,
|
|
357
|
+
flexDirection: 'column',
|
|
358
|
+
});
|
|
359
|
+
this.statusText = new TextRenderable(ctx, {
|
|
360
|
+
content: '0 items',
|
|
361
|
+
});
|
|
362
|
+
super.add(this.searchInput);
|
|
363
|
+
super.add(this.scrollBox);
|
|
364
|
+
super.add(this.statusText);
|
|
365
|
+
// Example: register key handler directly on renderable (logs all key presses)
|
|
366
|
+
this.onKeyDown = (key) => {
|
|
367
|
+
console.log('CustomListRenderable received key:', key.name, key);
|
|
368
|
+
};
|
|
369
|
+
// Subscribe to zustand store for defaultSearchQuery sync
|
|
370
|
+
// When React sets defaultSearchQuery prop, we need to refilter
|
|
371
|
+
}
|
|
372
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
373
|
+
// Child Management - redirect to scrollBox
|
|
374
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
375
|
+
add(child, index) {
|
|
376
|
+
return this.scrollBox.add(child, index);
|
|
377
|
+
}
|
|
378
|
+
insertBefore(child, anchor) {
|
|
379
|
+
return this.scrollBox.insertBefore(child, anchor);
|
|
380
|
+
}
|
|
381
|
+
remove(id) {
|
|
382
|
+
this.scrollBox.remove(id);
|
|
383
|
+
}
|
|
384
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
385
|
+
// Registration - children call these via onLifecyclePass
|
|
386
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
387
|
+
registerItem(item) {
|
|
388
|
+
this.registeredItems.add(item);
|
|
389
|
+
this.refilter();
|
|
390
|
+
}
|
|
391
|
+
registerSection(section) {
|
|
392
|
+
this.registeredSections.add(section);
|
|
393
|
+
}
|
|
394
|
+
registerEmptyView(emptyView) {
|
|
395
|
+
this.emptyView = emptyView;
|
|
396
|
+
}
|
|
397
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
398
|
+
// Filtering
|
|
399
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
400
|
+
setSearchQuery(query) {
|
|
401
|
+
if (this.searchQuery === query)
|
|
402
|
+
return;
|
|
403
|
+
this.searchQuery = query;
|
|
404
|
+
this.refilter();
|
|
405
|
+
}
|
|
406
|
+
refilter() {
|
|
407
|
+
const query = this.searchQuery.toLowerCase();
|
|
408
|
+
const allItems = this.getAllItems();
|
|
409
|
+
let visibleIndex = 0;
|
|
410
|
+
// Update item visibility and visible indices
|
|
411
|
+
// We use the `visible` prop instead of conditional rendering to keep elements
|
|
412
|
+
// in the tree - this preserves registration order and visibleIndex for navigation
|
|
413
|
+
const itemStates = {};
|
|
414
|
+
for (const item of allItems) {
|
|
415
|
+
const matches = !query || this.scoreItem(item, query) > 0;
|
|
416
|
+
item.visible = matches;
|
|
417
|
+
item.visibleIndex = matches ? visibleIndex++ : -1;
|
|
418
|
+
// Store in itemStates for React to subscribe to
|
|
419
|
+
if (item.itemId) {
|
|
420
|
+
itemStates[item.itemId] = { visibleIndex: item.visibleIndex };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Update section visibility based on their items
|
|
424
|
+
for (const section of this.registeredSections) {
|
|
425
|
+
const sectionItems = allItems.filter((item) => item.section === section);
|
|
426
|
+
const hasVisibleItems = sectionItems.some((item) => item.visible);
|
|
427
|
+
section.visible = hasVisibleItems;
|
|
428
|
+
}
|
|
429
|
+
// Get current selection and clamp it
|
|
430
|
+
const { selectedIndex } = useCustomListStore.getState();
|
|
431
|
+
let newSelectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, visibleIndex - 1)));
|
|
432
|
+
// Phase 1: Apply controlled selection if set
|
|
433
|
+
// This handles the case where selectedItemId is set before items register
|
|
434
|
+
if (this._selectedItemId !== null) {
|
|
435
|
+
const targetItem = allItems.find((i) => i.itemId === this._selectedItemId);
|
|
436
|
+
if (targetItem && targetItem.visibleIndex !== -1) {
|
|
437
|
+
newSelectedIndex = targetItem.visibleIndex;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Update zustand store - triggers React re-render
|
|
441
|
+
// itemStates allows each item to subscribe to its own visibleIndex
|
|
442
|
+
const finalSelectedIndex = visibleIndex > 0 ? newSelectedIndex : 0;
|
|
443
|
+
useCustomListStore.setState({
|
|
444
|
+
searchQuery: this.searchQuery,
|
|
445
|
+
visibleCount: visibleIndex,
|
|
446
|
+
totalCount: allItems.length,
|
|
447
|
+
selectedIndex: finalSelectedIndex,
|
|
448
|
+
selectedItemId: this._selectedItemId,
|
|
449
|
+
itemStates,
|
|
450
|
+
});
|
|
451
|
+
// Update status text (owned by renderable, not React)
|
|
452
|
+
this.updateStatusText(visibleIndex, allItems.length, this.searchQuery);
|
|
453
|
+
// Phase 2: Update detail panel after filtering (handles initial render and filter changes)
|
|
454
|
+
this.updateCurrentDetail();
|
|
455
|
+
}
|
|
456
|
+
scoreItem(item, query) {
|
|
457
|
+
let score = 0;
|
|
458
|
+
const title = item.itemTitle.toLowerCase();
|
|
459
|
+
if (title.includes(query)) {
|
|
460
|
+
score += title.startsWith(query) ? 2 : 1;
|
|
461
|
+
}
|
|
462
|
+
if (item.itemSubtitle?.toLowerCase().includes(query)) {
|
|
463
|
+
score += 0.6;
|
|
464
|
+
}
|
|
465
|
+
for (const kw of item.keywords || []) {
|
|
466
|
+
if (kw.toLowerCase().includes(query)) {
|
|
467
|
+
score += 0.3;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return score;
|
|
471
|
+
}
|
|
472
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
473
|
+
// Helpers - clean stale refs
|
|
474
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
475
|
+
isConnected(node) {
|
|
476
|
+
let current = node.parent;
|
|
477
|
+
while (current) {
|
|
478
|
+
if (current === this.scrollBox || current === this) {
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
current = current.parent;
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
getAllItems() {
|
|
486
|
+
// Clean stale refs (items no longer in tree)
|
|
487
|
+
for (const item of this.registeredItems) {
|
|
488
|
+
if (!this.isConnected(item)) {
|
|
489
|
+
this.registeredItems.delete(item);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return Array.from(this.registeredItems);
|
|
493
|
+
}
|
|
494
|
+
updateStatusText(visibleCount, totalCount, searchQuery) {
|
|
495
|
+
this.statusText.content = searchQuery
|
|
496
|
+
? `${visibleCount} of ${totalCount} items • Searching: "${searchQuery}"`
|
|
497
|
+
: `${visibleCount} of ${totalCount} items`;
|
|
498
|
+
}
|
|
499
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
500
|
+
// Navigation - called by React via ref
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
502
|
+
moveSelection(delta) {
|
|
503
|
+
const { selectedIndex, visibleCount } = useCustomListStore.getState();
|
|
504
|
+
if (visibleCount === 0)
|
|
505
|
+
return;
|
|
506
|
+
const newIndex = (selectedIndex + delta + visibleCount) % visibleCount;
|
|
507
|
+
useCustomListStore.setState({ selectedIndex: newIndex });
|
|
508
|
+
this.scrollToIndex(newIndex);
|
|
509
|
+
// Phase 1: Notify selection change
|
|
510
|
+
this.notifySelectionChange(newIndex);
|
|
511
|
+
// Phase 2: Update detail panel
|
|
512
|
+
this.updateCurrentDetail();
|
|
513
|
+
}
|
|
514
|
+
scrollToIndex(index) {
|
|
515
|
+
const item = this.getAllItems().find((i) => i.visibleIndex === index);
|
|
516
|
+
if (!item)
|
|
517
|
+
return;
|
|
518
|
+
const itemY = item.y;
|
|
519
|
+
const scrollBoxY = this.scrollBox.content.y;
|
|
520
|
+
const viewportHeight = this.scrollBox.viewport?.height || 10;
|
|
521
|
+
const relativeY = itemY - scrollBoxY;
|
|
522
|
+
const targetScrollTop = relativeY - Math.floor(viewportHeight / 2);
|
|
523
|
+
this.scrollBox.scrollTop = Math.max(0, targetScrollTop);
|
|
524
|
+
}
|
|
525
|
+
activateSelected() {
|
|
526
|
+
const { selectedIndex } = useCustomListStore.getState();
|
|
527
|
+
const item = this.getAllItems().find((i) => i.visibleIndex === selectedIndex);
|
|
528
|
+
item?.onAction?.();
|
|
529
|
+
}
|
|
530
|
+
// Get selected item's title - for dialog display
|
|
531
|
+
getSelectedItemTitle() {
|
|
532
|
+
const { selectedIndex } = useCustomListStore.getState();
|
|
533
|
+
const item = this.getAllItems().find((i) => i.visibleIndex === selectedIndex);
|
|
534
|
+
return item?.itemTitle;
|
|
535
|
+
}
|
|
536
|
+
// Get empty view data - for React to render
|
|
537
|
+
getEmptyViewData() {
|
|
538
|
+
if (!this.emptyView)
|
|
539
|
+
return undefined;
|
|
540
|
+
return {
|
|
541
|
+
title: this.emptyView.emptyTitle,
|
|
542
|
+
description: this.emptyView.emptyDescription,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
547
|
+
// Register with opentui
|
|
548
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
549
|
+
extend({
|
|
550
|
+
'custom-list-v2': CustomListRenderable,
|
|
551
|
+
'custom-list-item-wrapper-v2': CustomListItemWrapperRenderable,
|
|
552
|
+
'custom-list-section-wrapper-v2': CustomListSectionWrapperRenderable,
|
|
553
|
+
'custom-list-empty-view-wrapper-v2': CustomListEmptyViewWrapperRenderable,
|
|
554
|
+
});
|
|
555
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
556
|
+
// React Components
|
|
557
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
558
|
+
// Action Dialog - same as v1
|
|
559
|
+
function ActionDialog({ itemTitle }) {
|
|
560
|
+
const inFocus = useIsInFocus();
|
|
561
|
+
useKeyboard((evt) => {
|
|
562
|
+
if (!inFocus)
|
|
563
|
+
return;
|
|
564
|
+
if (evt.name === 'escape') {
|
|
565
|
+
const state = useStore.getState();
|
|
566
|
+
useStore.setState({
|
|
567
|
+
dialogStack: state.dialogStack.slice(0, -1),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
return (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsxs("text", { children: ["Actions for: ", itemTitle || 'No item selected'] }), _jsx("text", { marginTop: 1, children: "Press ESC to close" })] }));
|
|
572
|
+
}
|
|
573
|
+
function CustomList({ children, placeholder, defaultSearchQuery, selectedItemId, onSelectionChange, isShowingDetail }) {
|
|
574
|
+
const listRef = useRef(null);
|
|
575
|
+
const inFocus = useIsInFocus();
|
|
576
|
+
const theme = useTheme();
|
|
577
|
+
// Subscribe to zustand for UI updates
|
|
578
|
+
const visibleCount = useCustomListStore((s) => s.visibleCount);
|
|
579
|
+
const totalCount = useCustomListStore((s) => s.totalCount);
|
|
580
|
+
const searchQuery = useCustomListStore((s) => s.searchQuery);
|
|
581
|
+
// Phase 2: Subscribe to detail panel state
|
|
582
|
+
const currentDetailNode = useCustomListStore((s) => s.currentDetailNode);
|
|
583
|
+
const showingDetail = useCustomListStore((s) => s.isShowingDetail);
|
|
584
|
+
// Get empty view data from renderable
|
|
585
|
+
const emptyViewData = listRef.current?.getEmptyViewData();
|
|
586
|
+
// Keyboard navigation
|
|
587
|
+
useKeyboard((evt) => {
|
|
588
|
+
if (!inFocus || !listRef.current)
|
|
589
|
+
return;
|
|
590
|
+
if (evt.name === 'up')
|
|
591
|
+
listRef.current.moveSelection(-1);
|
|
592
|
+
if (evt.name === 'down')
|
|
593
|
+
listRef.current.moveSelection(1);
|
|
594
|
+
if (evt.name === 'return')
|
|
595
|
+
listRef.current.activateSelected();
|
|
596
|
+
if (evt.name === 'k' && evt.ctrl) {
|
|
597
|
+
const selectedItemTitle = listRef.current.getSelectedItemTitle();
|
|
598
|
+
const state = useStore.getState();
|
|
599
|
+
useStore.setState({
|
|
600
|
+
dialogStack: [
|
|
601
|
+
...state.dialogStack,
|
|
602
|
+
{
|
|
603
|
+
element: _jsx(ActionDialog, { itemTitle: selectedItemTitle }),
|
|
604
|
+
position: 'center',
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
// Phase 2: Wrap list and detail panel in a row layout when detail is shown
|
|
611
|
+
const listContent = (_jsxs("custom-list-v2", { ref: listRef, flexGrow: 1, placeholder: placeholder, defaultSearchQuery: defaultSearchQuery, selectedItemId: selectedItemId, onSelectionChange: onSelectionChange, isShowingDetail: isShowingDetail, children: [children, visibleCount === 0 && totalCount > 0 && (_jsxs("box", { padding: 1, flexDirection: "column", children: [_jsx("text", { children: emptyViewData?.title || `No results for "${searchQuery}"` }), emptyViewData?.description && _jsx("text", { children: emptyViewData.description })] }))] }));
|
|
612
|
+
// Phase 2: When detail panel is enabled, show list on left, detail on right
|
|
613
|
+
if (showingDetail) {
|
|
614
|
+
return (_jsxs("box", { flexDirection: "row", flexGrow: 1, children: [_jsx("box", { width: "50%", flexDirection: "column", children: listContent }), _jsx("text", { flexShrink: 0, children: "\u2502" }), _jsx("box", { width: "50%", flexDirection: "column", paddingLeft: 1, children: currentDetailNode || _jsx("text", { fg: theme.textMuted, children: "No detail available" }) })] }));
|
|
615
|
+
}
|
|
616
|
+
return listContent;
|
|
617
|
+
}
|
|
618
|
+
function CustomListItem({ id, title, subtitle, keywords, onAction, detail }) {
|
|
619
|
+
const wrapperRef = useRef(null);
|
|
620
|
+
const selectedIndex = useCustomListStore((s) => s.selectedIndex);
|
|
621
|
+
// Subscribe to THIS item's state - React re-renders when it changes
|
|
622
|
+
const itemState = useCustomListStore((s) => (id ? s.itemStates[id] : undefined));
|
|
623
|
+
const theme = useTheme();
|
|
624
|
+
// Compare visibleIndex to selectedIndex - using zustand state, not ref
|
|
625
|
+
const isSelected = itemState?.visibleIndex === selectedIndex;
|
|
626
|
+
return (_jsxs("custom-list-item-wrapper-v2", { ref: wrapperRef, keywords: keywords, onAction: onAction, itemTitle: title, itemSubtitle: subtitle, itemId: id, detail: detail, backgroundColor: isSelected ? '#0066cc' : undefined, flexShrink: 0, children: [_jsx("text", { flexShrink: 0, children: isSelected ? '› ' : ' ' }), _jsx("text", { flexShrink: 0, children: title }), subtitle && _jsxs("text", { flexShrink: 0, fg: theme.textMuted, children: [" ", subtitle] })] }));
|
|
627
|
+
}
|
|
628
|
+
function CustomListSection({ title, children }) {
|
|
629
|
+
return (_jsxs("custom-list-section-wrapper-v2", { sectionTitle: title, flexShrink: 0, children: [title && (_jsxs("text", { paddingTop: 1, paddingLeft: 1, flexShrink: 0, children: ["\u2500\u2500 ", title, " \u2500\u2500"] })), children] }));
|
|
630
|
+
}
|
|
631
|
+
function CustomListEmptyView({ title, description }) {
|
|
632
|
+
return _jsx("custom-list-empty-view-wrapper-v2", { emptyTitle: title, emptyDescription: description });
|
|
633
|
+
}
|
|
634
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
635
|
+
// Compound Component
|
|
636
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
637
|
+
CustomList.Item = CustomListItem;
|
|
638
|
+
CustomList.Section = CustomListSection;
|
|
639
|
+
CustomList.EmptyView = CustomListEmptyView;
|
|
640
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
641
|
+
// Example
|
|
642
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
643
|
+
const FRUITS = [
|
|
644
|
+
{ id: 'apple', title: 'Apple', subtitle: 'A red fruit', keywords: ['red'] },
|
|
645
|
+
{ id: 'banana', title: 'Banana', subtitle: 'A yellow fruit', keywords: ['yellow'] },
|
|
646
|
+
{ id: 'date', title: 'Date', subtitle: 'A sweet fruit', keywords: ['sweet'] },
|
|
647
|
+
{ id: 'fig', title: 'Fig', subtitle: 'A small fruit', keywords: ['small'] },
|
|
648
|
+
{ id: 'grape', title: 'Grape', subtitle: 'A vine fruit', keywords: ['vine'] },
|
|
649
|
+
{ id: 'lemon', title: 'Lemon', subtitle: 'A citrus fruit', keywords: ['citrus'] },
|
|
650
|
+
];
|
|
651
|
+
const VEGETABLES = [
|
|
652
|
+
{ id: 'carrot', title: 'Carrot', subtitle: 'An orange vegetable', keywords: ['orange'] },
|
|
653
|
+
{ id: 'eggplant', title: 'Eggplant', subtitle: 'A purple vegetable', keywords: ['purple'] },
|
|
654
|
+
{ id: 'jalapeno', title: 'Jalapeno', subtitle: 'A spicy pepper', keywords: ['spicy'] },
|
|
655
|
+
{ id: 'kale', title: 'Kale', subtitle: 'A superfood', keywords: ['healthy'] },
|
|
656
|
+
];
|
|
657
|
+
// Wrapper component to test tree traversal (items nested in other components)
|
|
658
|
+
function ItemWrapper({ children }) {
|
|
659
|
+
return _jsx("box", { children: children });
|
|
660
|
+
}
|
|
661
|
+
function Example() {
|
|
662
|
+
// Phase 1: Track selected item id in React state
|
|
663
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
664
|
+
const inFocus = useIsInFocus();
|
|
665
|
+
const theme = useTheme();
|
|
666
|
+
// Phase 1: Demonstrate programmatic selection change (bidirectional sync)
|
|
667
|
+
// Press ctrl+1-4 to jump to specific items (ctrl to avoid typing in search)
|
|
668
|
+
useKeyboard((evt) => {
|
|
669
|
+
if (!inFocus)
|
|
670
|
+
return;
|
|
671
|
+
if (evt.ctrl && evt.name === '1') {
|
|
672
|
+
setSelectedId('apple');
|
|
673
|
+
}
|
|
674
|
+
if (evt.ctrl && evt.name === '2') {
|
|
675
|
+
setSelectedId('banana');
|
|
676
|
+
}
|
|
677
|
+
if (evt.ctrl && evt.name === '3') {
|
|
678
|
+
setSelectedId('carrot');
|
|
679
|
+
}
|
|
680
|
+
if (evt.ctrl && evt.name === '4') {
|
|
681
|
+
setSelectedId('kale');
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
// Phase 2: Helper to create detail content for an item
|
|
685
|
+
const createDetail = (item) => (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsxs("text", { children: ["Details for ", item.title] }), _jsx("text", { fg: theme.textMuted, marginTop: 1, children: item.subtitle }), _jsxs("text", { fg: theme.textMuted, marginTop: 1, children: ["Keywords: ", item.keywords.join(', ')] }), _jsxs("text", { fg: theme.textMuted, marginTop: 1, children: ["ID: ", item.id] })] }));
|
|
686
|
+
return (_jsxs("box", { flexDirection: "column", padding: 1, flexGrow: 1, children: [_jsx("text", { marginBottom: 1, children: "Custom Renderable List V2 - Detail Panel" }), _jsxs("text", { fg: theme.textMuted, children: ["Selected: ", selectedId || '(none)'] }), _jsx("text", { fg: theme.textMuted, marginBottom: 1, children: "Press ^1=apple, ^2=banana, ^3=carrot, ^4=kale to jump" }), _jsxs(CustomList, { placeholder: "Search items...", selectedItemId: selectedId, onSelectionChange: (id) => {
|
|
687
|
+
setSelectedId(id);
|
|
688
|
+
}, isShowingDetail: true, children: [_jsx(CustomList.EmptyView, { title: "Nothing found", description: "Try a different search term" }), _jsx(CustomList.Section, { title: "Fruits", children: FRUITS.map((item) => (_jsx(ItemWrapper, { children: _jsx(CustomList.Item, { id: item.id, title: item.title, subtitle: item.subtitle, keywords: item.keywords, detail: createDetail(item), onAction: () => {
|
|
689
|
+
console.log(`Activated: ${item.title} (id: ${item.id})`);
|
|
690
|
+
} }) }, item.id))) }), _jsx(CustomList.Section, { title: "Vegetables", children: VEGETABLES.map((item) => (_jsx(CustomList.Item, { id: item.id, title: item.title, subtitle: item.subtitle, keywords: item.keywords, detail: createDetail(item), onAction: () => {
|
|
691
|
+
console.log(`Activated: ${item.title} (id: ${item.id})`);
|
|
692
|
+
} }, item.id))) })] })] }));
|
|
693
|
+
}
|
|
694
|
+
// Reset store when running as main (for testing isolation)
|
|
695
|
+
if (import.meta.main) {
|
|
696
|
+
useCustomListStore.setState({
|
|
697
|
+
selectedIndex: 0,
|
|
698
|
+
visibleCount: 0,
|
|
699
|
+
totalCount: 0,
|
|
700
|
+
searchQuery: '',
|
|
701
|
+
selectedItemId: null,
|
|
702
|
+
isShowingDetail: false,
|
|
703
|
+
currentDetailNode: null,
|
|
704
|
+
});
|
|
705
|
+
renderWithProviders(_jsx(Example, {}));
|
|
706
|
+
}
|
|
707
|
+
export { CustomList, CustomListItem, CustomListSection, CustomListEmptyView, Example, useCustomListStore };
|
|
708
|
+
//# sourceMappingURL=custom-renderable-list-v2.js.map
|