@contember/bindx-dataview 0.1.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.
Files changed (114) hide show
  1. package/dist/DataGrid.d.ts +38 -0
  2. package/dist/DataGrid.d.ts.map +1 -0
  3. package/dist/DataViewContext.d.ts +39 -0
  4. package/dist/DataViewContext.d.ts.map +1 -0
  5. package/dist/DataViewKeyProvider.d.ts +8 -0
  6. package/dist/DataViewKeyProvider.d.ts.map +1 -0
  7. package/dist/HasManyDataGrid.d.ts +51 -0
  8. package/dist/HasManyDataGrid.d.ts.map +1 -0
  9. package/dist/columnLeaf.d.ts +72 -0
  10. package/dist/columnLeaf.d.ts.map +1 -0
  11. package/dist/columnTypes.d.ts +35 -0
  12. package/dist/columnTypes.d.ts.map +1 -0
  13. package/dist/columns.d.ts +115 -0
  14. package/dist/columns.d.ts.map +1 -0
  15. package/dist/contextHooks.d.ts +45 -0
  16. package/dist/contextHooks.d.ts.map +1 -0
  17. package/dist/createColumn.d.ts +37 -0
  18. package/dist/createColumn.d.ts.map +1 -0
  19. package/dist/createRelationColumn.d.ts +57 -0
  20. package/dist/createRelationColumn.d.ts.map +1 -0
  21. package/dist/dataAttribute.d.ts +6 -0
  22. package/dist/dataAttribute.d.ts.map +1 -0
  23. package/dist/export.d.ts +46 -0
  24. package/dist/export.d.ts.map +1 -0
  25. package/dist/filterComponents.d.ts +127 -0
  26. package/dist/filterComponents.d.ts.map +1 -0
  27. package/dist/filterContext.d.ts +10 -0
  28. package/dist/filterContext.d.ts.map +1 -0
  29. package/dist/filterHooks.d.ts +74 -0
  30. package/dist/filterHooks.d.ts.map +1 -0
  31. package/dist/filterScope.d.ts +54 -0
  32. package/dist/filterScope.d.ts.map +1 -0
  33. package/dist/filterWrappers.d.ts +66 -0
  34. package/dist/filterWrappers.d.ts.map +1 -0
  35. package/dist/index.d.ts +28 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/infiniteLoad.d.ts +32 -0
  38. package/dist/infiniteLoad.d.ts.map +1 -0
  39. package/dist/loaderComponents.d.ts +75 -0
  40. package/dist/loaderComponents.d.ts.map +1 -0
  41. package/dist/markers.d.ts +36 -0
  42. package/dist/markers.d.ts.map +1 -0
  43. package/dist/pagingComponents.d.ts +50 -0
  44. package/dist/pagingComponents.d.ts.map +1 -0
  45. package/dist/renderCellValue.d.ts +9 -0
  46. package/dist/renderCellValue.d.ts.map +1 -0
  47. package/dist/select/MultiSelect.d.ts +27 -0
  48. package/dist/select/MultiSelect.d.ts.map +1 -0
  49. package/dist/select/Select.d.ts +27 -0
  50. package/dist/select/Select.d.ts.map +1 -0
  51. package/dist/select/SelectDataView.d.ts +46 -0
  52. package/dist/select/SelectDataView.d.ts.map +1 -0
  53. package/dist/select/SelectEachValue.d.ts +20 -0
  54. package/dist/select/SelectEachValue.d.ts.map +1 -0
  55. package/dist/select/SelectItemTrigger.d.ts +25 -0
  56. package/dist/select/SelectItemTrigger.d.ts.map +1 -0
  57. package/dist/select/SelectOption.d.ts +23 -0
  58. package/dist/select/SelectOption.d.ts.map +1 -0
  59. package/dist/select/SelectPlaceholder.d.ts +16 -0
  60. package/dist/select/SelectPlaceholder.d.ts.map +1 -0
  61. package/dist/select/index.d.ts +9 -0
  62. package/dist/select/index.d.ts.map +1 -0
  63. package/dist/select/selectContext.d.ts +20 -0
  64. package/dist/select/selectContext.d.ts.map +1 -0
  65. package/dist/selectionComponents.d.ts +62 -0
  66. package/dist/selectionComponents.d.ts.map +1 -0
  67. package/dist/sortingComponents.d.ts +39 -0
  68. package/dist/sortingComponents.d.ts.map +1 -0
  69. package/dist/stateStorage.d.ts +20 -0
  70. package/dist/stateStorage.d.ts.map +1 -0
  71. package/dist/useDataGridSetup.d.ts +53 -0
  72. package/dist/useDataGridSetup.d.ts.map +1 -0
  73. package/dist/useDataView.d.ts +57 -0
  74. package/dist/useDataView.d.ts.map +1 -0
  75. package/dist/useDataViewState.d.ts +89 -0
  76. package/dist/useDataViewState.d.ts.map +1 -0
  77. package/package.json +33 -0
  78. package/src/DataGrid.tsx +173 -0
  79. package/src/DataViewContext.tsx +56 -0
  80. package/src/DataViewKeyProvider.tsx +16 -0
  81. package/src/HasManyDataGrid.tsx +319 -0
  82. package/src/columnLeaf.ts +148 -0
  83. package/src/columnTypes.ts +160 -0
  84. package/src/columns.tsx +306 -0
  85. package/src/contextHooks.ts +157 -0
  86. package/src/createColumn.ts +128 -0
  87. package/src/createRelationColumn.tsx +203 -0
  88. package/src/dataAttribute.ts +7 -0
  89. package/src/export.tsx +219 -0
  90. package/src/filterComponents.tsx +617 -0
  91. package/src/filterContext.ts +27 -0
  92. package/src/filterHooks.ts +386 -0
  93. package/src/filterScope.tsx +89 -0
  94. package/src/filterWrappers.tsx +187 -0
  95. package/src/index.ts +356 -0
  96. package/src/infiniteLoad.tsx +188 -0
  97. package/src/loaderComponents.tsx +239 -0
  98. package/src/markers.ts +54 -0
  99. package/src/pagingComponents.tsx +149 -0
  100. package/src/select/MultiSelect.tsx +88 -0
  101. package/src/select/Select.tsx +88 -0
  102. package/src/select/SelectDataView.tsx +231 -0
  103. package/src/select/SelectEachValue.tsx +34 -0
  104. package/src/select/SelectItemTrigger.tsx +51 -0
  105. package/src/select/SelectOption.tsx +40 -0
  106. package/src/select/SelectPlaceholder.tsx +22 -0
  107. package/src/select/index.ts +17 -0
  108. package/src/select/selectContext.ts +82 -0
  109. package/src/selectionComponents.tsx +160 -0
  110. package/src/sortingComponents.tsx +104 -0
  111. package/src/stateStorage.ts +188 -0
  112. package/src/useDataGridSetup.ts +230 -0
  113. package/src/useDataView.ts +148 -0
  114. package/src/useDataViewState.ts +404 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Loader state, reload trigger, and conditional rendering components using Radix Slot.
3
+ *
4
+ * Usage:
5
+ * ```tsx
6
+ * <DataViewReloadTrigger>
7
+ * <button>Refresh</button>
8
+ * </DataViewReloadTrigger>
9
+ *
10
+ * <DataViewLoaderState loaded={<Table />} refreshing={<Table />} initial={<Loading />} failed={<Error />} />
11
+ *
12
+ * <DataViewEmpty>No results found</DataViewEmpty>
13
+ * <DataViewNonEmpty><Table /></DataViewNonEmpty>
14
+ *
15
+ * <DataViewKeyboardEventHandler>
16
+ * <div tabIndex={0}>...</div>
17
+ * </DataViewKeyboardEventHandler>
18
+ * ```
19
+ */
20
+
21
+ import React, { forwardRef, type ReactElement, useCallback } from 'react'
22
+ import { Slot } from '@radix-ui/react-slot'
23
+ import { composeEventHandlers } from '@radix-ui/primitive'
24
+ import { useDataViewContext, type DataViewItem } from './DataViewContext.js'
25
+ import type { DataViewLoaderState as DataViewLoaderStateType } from './DataViewContext.js'
26
+
27
+ // ============================================================================
28
+ // Reload Trigger
29
+ // ============================================================================
30
+
31
+ export interface DataViewReloadTriggerProps {
32
+ children: ReactElement
33
+ }
34
+
35
+ export const DataViewReloadTrigger = forwardRef<HTMLButtonElement, DataViewReloadTriggerProps>(
36
+ (props, ref) => {
37
+ const { reload, loaderState } = useDataViewContext()
38
+ const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactElement }
39
+
40
+ return (
41
+ <Slot
42
+ ref={ref}
43
+ onClick={composeEventHandlers(onClick, reload)}
44
+ data-state={loaderState}
45
+ {...otherProps}
46
+ />
47
+ )
48
+ },
49
+ )
50
+ DataViewReloadTrigger.displayName = 'DataViewReloadTrigger'
51
+
52
+ // ============================================================================
53
+ // Loader State Switch (Contember: DataViewLoaderState)
54
+ // ============================================================================
55
+
56
+ // ---- DataViewLoaderState (Contember-matching API) ----
57
+ // Boolean-prop conditional wrapper: renders children when state matches any true flag
58
+
59
+ export interface DataViewLoaderStateProps {
60
+ children: React.ReactNode
61
+ /** Render children when data is loaded */
62
+ loaded?: boolean
63
+ /** Render children when refreshing (after initial load) */
64
+ refreshing?: boolean
65
+ /** Render children during initial load */
66
+ initial?: boolean
67
+ /** Render children on failure */
68
+ failed?: boolean
69
+ }
70
+
71
+ export function DataViewLoaderState({
72
+ children,
73
+ ...props
74
+ }: DataViewLoaderStateProps): ReactElement | null {
75
+ const { loaderState } = useDataViewContext()
76
+ return (props as Record<string, boolean | undefined>)[loaderState] ? <>{children}</> : null
77
+ }
78
+
79
+ // ---- DataViewLoaderStateSwitch (bindx convenience API) ----
80
+ // Switch pattern: separate ReactNode per state
81
+
82
+ export interface DataViewLoaderStateSwitchProps {
83
+ /** Render when data is loaded */
84
+ loaded?: React.ReactNode
85
+ /** Render when refreshing (after initial load) */
86
+ refreshing?: React.ReactNode
87
+ /** Render during initial load */
88
+ initial?: React.ReactNode
89
+ /** Render on failure */
90
+ failed?: React.ReactNode
91
+ }
92
+
93
+ export function DataViewLoaderStateSwitch({
94
+ loaded,
95
+ refreshing,
96
+ initial,
97
+ failed,
98
+ }: DataViewLoaderStateSwitchProps): ReactElement {
99
+ const { loaderState } = useDataViewContext()
100
+
101
+ switch (loaderState) {
102
+ case 'loaded':
103
+ return <>{loaded ?? null}</>
104
+ case 'refreshing':
105
+ return <>{refreshing ?? loaded ?? null}</>
106
+ case 'initial':
107
+ return <>{initial ?? null}</>
108
+ case 'failed':
109
+ return <>{failed ?? null}</>
110
+ }
111
+ }
112
+
113
+ // ============================================================================
114
+ // Hooks
115
+ // ============================================================================
116
+
117
+ export function useDataViewLoaderState(): DataViewLoaderStateType {
118
+ return useDataViewContext().loaderState
119
+ }
120
+
121
+ export function useDataViewReload(): () => void {
122
+ return useDataViewContext().reload
123
+ }
124
+
125
+ export function useDataViewHighlightIndex(): number | null {
126
+ return useDataViewContext().highlightIndex
127
+ }
128
+
129
+ // ============================================================================
130
+ // Empty / Non-Empty
131
+ // ============================================================================
132
+
133
+ export interface DataViewEmptyProps {
134
+ children: React.ReactNode
135
+ }
136
+
137
+ export function DataViewEmpty({ children }: DataViewEmptyProps): ReactElement {
138
+ const { loaderState, itemCount } = useDataViewContext()
139
+ if (loaderState !== 'loaded' || itemCount > 0) return <>{null}</>
140
+ return <>{children}</>
141
+ }
142
+
143
+ export interface DataViewNonEmptyProps {
144
+ children: React.ReactNode
145
+ }
146
+
147
+ export function DataViewNonEmpty({ children }: DataViewNonEmptyProps): ReactElement {
148
+ const { loaderState, itemCount } = useDataViewContext()
149
+ if (loaderState !== 'loaded' || itemCount === 0) return <>{null}</>
150
+ return <>{children}</>
151
+ }
152
+
153
+ // ============================================================================
154
+ // Each Row
155
+ // ============================================================================
156
+
157
+ export interface DataViewEachRowProps {
158
+ children: (item: DataViewItem, index: number) => React.ReactNode
159
+ }
160
+
161
+ export function DataViewEachRow({ children }: DataViewEachRowProps): ReactElement {
162
+ const { items } = useDataViewContext()
163
+ return <>{items.map((item, index) => children(item, index))}</>
164
+ }
165
+
166
+ // ============================================================================
167
+ // Highlight Row — Slot-based
168
+ // ============================================================================
169
+
170
+ export interface DataViewHighlightRowProps {
171
+ index: number
172
+ children: ReactElement
173
+ }
174
+
175
+ export const DataViewHighlightRow = forwardRef<HTMLElement, DataViewHighlightRowProps>(
176
+ ({ index, ...props }, ref) => {
177
+ const { highlightIndex, setHighlightIndex } = useDataViewContext()
178
+ const isHighlighted = highlightIndex === index
179
+
180
+ const handleClick = useCallback((): void => {
181
+ setHighlightIndex(index)
182
+ }, [setHighlightIndex, index])
183
+
184
+ const { onClick, ...otherProps } = props as React.HTMLAttributes<HTMLElement> & { children: ReactElement }
185
+
186
+ return (
187
+ <Slot
188
+ ref={ref}
189
+ onClick={composeEventHandlers(onClick, handleClick)}
190
+ data-highlighted={isHighlighted ? '' : undefined}
191
+ {...otherProps}
192
+ />
193
+ )
194
+ },
195
+ )
196
+ DataViewHighlightRow.displayName = 'DataViewHighlightRow'
197
+
198
+ // ============================================================================
199
+ // Keyboard Event Handler — Slot-based
200
+ // ============================================================================
201
+
202
+ export interface DataViewKeyboardEventHandlerProps {
203
+ children: ReactElement
204
+ onSelectHighlighted?: (item: DataViewItem) => void
205
+ }
206
+
207
+ export const DataViewKeyboardEventHandler = forwardRef<HTMLElement, DataViewKeyboardEventHandlerProps>(
208
+ ({ onSelectHighlighted, ...props }, ref) => {
209
+ const { items, highlightIndex, setHighlightIndex } = useDataViewContext()
210
+
211
+ const handleKeyDown = useCallback((e: React.KeyboardEvent): void => {
212
+ if (items.length === 0) return
213
+
214
+ if (e.key === 'ArrowDown') {
215
+ e.preventDefault()
216
+ setHighlightIndex(Math.min((highlightIndex ?? -1) + 1, items.length - 1))
217
+ } else if (e.key === 'ArrowUp') {
218
+ e.preventDefault()
219
+ setHighlightIndex(Math.max((highlightIndex ?? 1) - 1, 0))
220
+ } else if (e.key === 'Escape') {
221
+ setHighlightIndex(null)
222
+ } else if (e.key === 'Enter' && highlightIndex !== null && onSelectHighlighted) {
223
+ const item = items[highlightIndex]
224
+ if (item) onSelectHighlighted(item)
225
+ }
226
+ }, [items, highlightIndex, setHighlightIndex, onSelectHighlighted])
227
+
228
+ const { onKeyDown, ...otherProps } = props as React.HTMLAttributes<HTMLElement> & { children: ReactElement }
229
+
230
+ return (
231
+ <Slot
232
+ ref={ref}
233
+ onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
234
+ {...otherProps}
235
+ />
236
+ )
237
+ },
238
+ )
239
+ DataViewKeyboardEventHandler.displayName = 'DataViewKeyboardEventHandler'
package/src/markers.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Marker components for DataGrid children analysis.
3
+ *
4
+ * These are metadata components — they return null at runtime.
5
+ * Their props are extracted by `analyzeChildren()` during the DataGrid's
6
+ * collection phase and stored in DataViewContext for layout components to consume.
7
+ */
8
+
9
+ import type React from 'react'
10
+ import type { ReactNode } from 'react'
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
12
+ import type { EntityAccessor } from '@contember/bindx'
13
+
14
+ // ============================================================================
15
+ // Toolbar Content Marker
16
+ // ============================================================================
17
+
18
+ export interface DataGridToolbarContentProps {
19
+ readonly children: React.ReactNode
20
+ }
21
+
22
+ /**
23
+ * Marker for toolbar content inside a DataGrid children render function.
24
+ * At analysis time, the children ReactNode is extracted and stored in context.
25
+ * At runtime, returns null (content is rendered by toolbar layout components).
26
+ */
27
+ export function DataGridToolbarContent(_props: DataGridToolbarContentProps): null {
28
+ return null
29
+ }
30
+
31
+ // ============================================================================
32
+ // Layout Marker (replaces DataGridTileLayout)
33
+ // ============================================================================
34
+
35
+ export interface DataGridLayoutProps<T = never> {
36
+ /** Unique name for this layout (e.g. "grid", "rows", "cards") */
37
+ readonly name: string
38
+ /** Label shown in the layout switcher UI (supports ReactNode for icons) */
39
+ readonly label?: ReactNode
40
+ /** Pass the DataGrid's `it` parameter for type inference on the children callback. */
41
+ readonly item?: T
42
+ readonly children: (item: T) => ReactNode
43
+ }
44
+
45
+ /**
46
+ * Marker for a named layout inside a DataGrid children render function.
47
+ * Multiple DataGridLayout markers can coexist with different names.
48
+ * At analysis time, the callback is extracted and called with the collector proxy
49
+ * to discover field selections. At runtime, returns null (callback is stored in
50
+ * context and invoked per-item by layout rendering components).
51
+ */
52
+ export function DataGridLayout<T>(_props: DataGridLayoutProps<T>): null {
53
+ return null
54
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Composable paging UI components using Radix Slot pattern.
3
+ *
4
+ * Usage:
5
+ * ```tsx
6
+ * <DataViewChangePageTrigger page="previous">
7
+ * <button>Previous</button>
8
+ * </DataViewChangePageTrigger>
9
+ *
10
+ * <DataViewChangePageTrigger page="next">
11
+ * <button>Next</button>
12
+ * </DataViewChangePageTrigger>
13
+ *
14
+ * <DataViewSetItemsPerPageTrigger value={25}>
15
+ * <button>25 per page</button>
16
+ * </DataViewSetItemsPerPageTrigger>
17
+ *
18
+ * <DataViewPagingStateView>
19
+ * {({ pageIndex, totalPages }) => <span>Page {pageIndex + 1} of {totalPages}</span>}
20
+ * </DataViewPagingStateView>
21
+ * ```
22
+ */
23
+
24
+ import React, { forwardRef, type ReactElement, type ReactNode, useCallback } from 'react'
25
+ import { Slot } from '@radix-ui/react-slot'
26
+ import { composeEventHandlers } from '@radix-ui/primitive'
27
+ import { useDataViewContext } from './DataViewContext.js'
28
+ import { dataAttribute } from './dataAttribute.js'
29
+
30
+ // ============================================================================
31
+ // DataViewChangePageTrigger
32
+ // ============================================================================
33
+
34
+ export interface DataViewChangePageTriggerProps {
35
+ /** Target page: named shortcut or zero-based page index */
36
+ page: 'first' | 'last' | 'next' | 'previous' | number
37
+ /** Button element */
38
+ children: ReactElement
39
+ }
40
+
41
+ export const DataViewChangePageTrigger = forwardRef<HTMLButtonElement, DataViewChangePageTriggerProps>(
42
+ ({ page, ...props }, ref) => {
43
+ const { paging } = useDataViewContext()
44
+
45
+ const isDisabled = resolveIsDisabled(page, paging.hasPrevious, paging.hasNext, paging.state.pageIndex, paging.info.totalPages)
46
+ const isActive = typeof page === 'number' && page === paging.state.pageIndex
47
+
48
+ const handleClick = useCallback((): void => {
49
+ if (page === 'first') paging.first()
50
+ else if (page === 'last') paging.last()
51
+ else if (page === 'next') paging.next()
52
+ else if (page === 'previous') paging.previous()
53
+ else paging.goTo(page)
54
+ }, [paging, page])
55
+
56
+ const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes<HTMLButtonElement>
57
+
58
+ return (
59
+ <Slot
60
+ ref={ref}
61
+ onClick={composeEventHandlers(onClick, handleClick)}
62
+ disabled={isDisabled}
63
+ data-active={dataAttribute(isActive)}
64
+ data-current={String(paging.state.pageIndex)}
65
+ {...otherProps}
66
+ />
67
+ )
68
+ },
69
+ )
70
+ DataViewChangePageTrigger.displayName = 'DataViewChangePageTrigger'
71
+
72
+ function resolveIsDisabled(
73
+ page: DataViewChangePageTriggerProps['page'],
74
+ hasPrevious: boolean,
75
+ hasNext: boolean,
76
+ currentPageIndex: number,
77
+ totalPages: number | null,
78
+ ): boolean {
79
+ if (page === 'first' || page === 'previous') return !hasPrevious
80
+ if (page === 'next') return !hasNext
81
+ if (page === 'last') return totalPages === null || currentPageIndex >= totalPages - 1
82
+ return page === currentPageIndex
83
+ }
84
+
85
+ // ============================================================================
86
+ // DataViewSetItemsPerPageTrigger
87
+ // ============================================================================
88
+
89
+ export interface DataViewSetItemsPerPageTriggerProps {
90
+ /** Items per page to set. null = show all. */
91
+ value: number | null
92
+ /** Button element */
93
+ children: ReactElement
94
+ }
95
+
96
+ export const DataViewSetItemsPerPageTrigger = forwardRef<HTMLButtonElement, DataViewSetItemsPerPageTriggerProps>(
97
+ ({ value, ...props }, ref) => {
98
+ const { paging } = useDataViewContext()
99
+ const currentItemsPerPage = paging.state.itemsPerPage === 0 ? null : paging.state.itemsPerPage
100
+ const isActive = currentItemsPerPage === value
101
+
102
+ const handleClick = useCallback((): void => {
103
+ paging.setItemsPerPage(value)
104
+ }, [paging, value])
105
+
106
+ const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes<HTMLButtonElement>
107
+
108
+ return (
109
+ <Slot
110
+ ref={ref}
111
+ onClick={composeEventHandlers(onClick, handleClick)}
112
+ data-active={dataAttribute(isActive)}
113
+ {...otherProps}
114
+ />
115
+ )
116
+ },
117
+ )
118
+ DataViewSetItemsPerPageTrigger.displayName = 'DataViewSetItemsPerPageTrigger'
119
+
120
+ // ============================================================================
121
+ // DataViewPagingStateView (Contember name) / DataViewPagingInfo (alias)
122
+ // ============================================================================
123
+
124
+ export interface DataViewPagingInfoProps {
125
+ children: (info: {
126
+ pageIndex: number
127
+ totalPages: number | null
128
+ totalCount: number | null
129
+ itemsPerPage: number
130
+ }) => ReactNode
131
+ }
132
+
133
+ export function DataViewPagingStateView({
134
+ children,
135
+ }: DataViewPagingInfoProps): ReactElement {
136
+ const { paging } = useDataViewContext()
137
+
138
+ return <>{children({
139
+ pageIndex: paging.state.pageIndex,
140
+ totalPages: paging.info.totalPages,
141
+ totalCount: paging.info.totalCount,
142
+ itemsPerPage: paging.state.itemsPerPage,
143
+ })}</>
144
+ }
145
+
146
+ /** Alias for DataViewPagingStateView */
147
+ export const DataViewPagingInfo = DataViewPagingStateView
148
+
149
+ export type DataViewPagingStateViewProps = DataViewPagingInfoProps
@@ -0,0 +1,88 @@
1
+ /**
2
+ * MultiSelect — headless multi-select component for has-many relations.
3
+ *
4
+ * Manages selection state for a has-many relation field and provides
5
+ * context for descendant components (SelectDataView, SelectOption, etc.).
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * <MultiSelect relation={article.tags} options={schema.Tag}>
10
+ * <SelectDataView queryField={['name']}>
11
+ * {it => <SelectOption><SelectItemTrigger><Field field={it.name} /></SelectItemTrigger></SelectOption>}
12
+ * </SelectDataView>
13
+ * </MultiSelect>
14
+ * ```
15
+ */
16
+
17
+ import { type ReactNode, useCallback, useMemo } from 'react'
18
+ import type { EntityAccessor, EntityDef, HasManyRef } from '@contember/bindx'
19
+ import {
20
+ SelectCurrentEntitiesContext,
21
+ SelectHandleSelectContext,
22
+ SelectIsSelectedContext,
23
+ SelectOptionsContext,
24
+ type SelectAction,
25
+ type SelectEvents,
26
+ type SelectHandler,
27
+ } from './selectContext.js'
28
+
29
+ export interface MultiSelectProps extends SelectEvents {
30
+ /** Has-many relation ref from parent entity */
31
+ relation: HasManyRef<object>
32
+ /** Entity definition for the options list */
33
+ options: EntityDef
34
+ children: ReactNode
35
+ }
36
+
37
+ export function MultiSelect({
38
+ relation,
39
+ options,
40
+ children,
41
+ onSelect,
42
+ onUnselect,
43
+ }: MultiSelectProps): ReactNode {
44
+ const items = relation.items
45
+ const selectedIds = useMemo(
46
+ () => new Set(items.map(it => it.id)),
47
+ [items],
48
+ )
49
+
50
+ const entitiesArr = useMemo(
51
+ (): readonly EntityAccessor<object>[] => items as EntityAccessor<object>[],
52
+ [items],
53
+ )
54
+
55
+ const handleSelect = useCallback<SelectHandler>(
56
+ (entity: EntityAccessor<object>, action: SelectAction = 'toggle') => {
57
+ const isCurrentlySelected = selectedIds.has(entity.id)
58
+ if (action === 'toggle') {
59
+ action = isCurrentlySelected ? 'unselect' : 'select'
60
+ }
61
+ if (action === 'unselect' && isCurrentlySelected) {
62
+ relation.disconnect(entity.id)
63
+ onUnselect?.(entity)
64
+ } else if (action === 'select' && !isCurrentlySelected) {
65
+ relation.connect(entity.id)
66
+ onSelect?.(entity)
67
+ }
68
+ },
69
+ [relation, selectedIds, onSelect, onUnselect],
70
+ )
71
+
72
+ const isSelected = useCallback(
73
+ (entity: EntityAccessor<object>): boolean => selectedIds.has(entity.id),
74
+ [selectedIds],
75
+ )
76
+
77
+ return (
78
+ <SelectCurrentEntitiesContext.Provider value={entitiesArr}>
79
+ <SelectIsSelectedContext.Provider value={isSelected}>
80
+ <SelectHandleSelectContext.Provider value={handleSelect}>
81
+ <SelectOptionsContext.Provider value={options}>
82
+ {children}
83
+ </SelectOptionsContext.Provider>
84
+ </SelectHandleSelectContext.Provider>
85
+ </SelectIsSelectedContext.Provider>
86
+ </SelectCurrentEntitiesContext.Provider>
87
+ )
88
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Select — headless single-select component for has-one relations.
3
+ *
4
+ * Manages selection state for a has-one relation field and provides
5
+ * context for descendant components (SelectDataView, SelectOption, etc.).
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * <Select relation={article.category} options={schema.Category}>
10
+ * <SelectDataView queryField={['name']}>
11
+ * ...options rendering...
12
+ * </SelectDataView>
13
+ * </Select>
14
+ * ```
15
+ */
16
+
17
+ import { type ReactNode, useCallback, useMemo } from 'react'
18
+ import type { EntityAccessor, EntityDef, HasOneRef } from '@contember/bindx'
19
+ import { isPlaceholderId } from '@contember/bindx'
20
+ import {
21
+ SelectCurrentEntitiesContext,
22
+ SelectHandleSelectContext,
23
+ SelectIsSelectedContext,
24
+ SelectOptionsContext,
25
+ type SelectAction,
26
+ type SelectEvents,
27
+ type SelectHandler,
28
+ } from './selectContext.js'
29
+
30
+ export interface SelectProps extends SelectEvents {
31
+ /** Has-one relation ref from parent entity */
32
+ relation: HasOneRef<object>
33
+ /** Entity definition for the options list */
34
+ options: EntityDef
35
+ children: ReactNode
36
+ }
37
+
38
+ export function Select({
39
+ relation,
40
+ options,
41
+ children,
42
+ onSelect,
43
+ onUnselect,
44
+ }: SelectProps): ReactNode {
45
+ const entity = relation.$entity
46
+ const entityId = entity.id
47
+ const isConnected = !isPlaceholderId(entityId)
48
+ const currentId = isConnected ? entityId : null
49
+
50
+ const entitiesArr = useMemo(
51
+ (): readonly EntityAccessor<object>[] =>
52
+ isConnected ? [entity as EntityAccessor<object>] : [],
53
+ [isConnected, entity],
54
+ )
55
+
56
+ const handleSelect = useCallback<SelectHandler>(
57
+ (selected: EntityAccessor<object>, action: SelectAction = 'toggle') => {
58
+ if (action === 'toggle') {
59
+ action = selected.id === currentId ? 'unselect' : 'select'
60
+ }
61
+ if (action === 'unselect') {
62
+ relation.$disconnect()
63
+ onUnselect?.(selected)
64
+ } else if (action === 'select') {
65
+ relation.$connect(selected.id)
66
+ onSelect?.(selected)
67
+ }
68
+ },
69
+ [relation, currentId, onSelect, onUnselect],
70
+ )
71
+
72
+ const isSelected = useCallback(
73
+ (entity: EntityAccessor<object>): boolean => entity.id === currentId,
74
+ [currentId],
75
+ )
76
+
77
+ return (
78
+ <SelectCurrentEntitiesContext.Provider value={entitiesArr}>
79
+ <SelectIsSelectedContext.Provider value={isSelected}>
80
+ <SelectHandleSelectContext.Provider value={handleSelect}>
81
+ <SelectOptionsContext.Provider value={options}>
82
+ {children}
83
+ </SelectOptionsContext.Provider>
84
+ </SelectHandleSelectContext.Provider>
85
+ </SelectIsSelectedContext.Provider>
86
+ </SelectCurrentEntitiesContext.Provider>
87
+ )
88
+ }