@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.
- package/dist/DataGrid.d.ts +38 -0
- package/dist/DataGrid.d.ts.map +1 -0
- package/dist/DataViewContext.d.ts +39 -0
- package/dist/DataViewContext.d.ts.map +1 -0
- package/dist/DataViewKeyProvider.d.ts +8 -0
- package/dist/DataViewKeyProvider.d.ts.map +1 -0
- package/dist/HasManyDataGrid.d.ts +51 -0
- package/dist/HasManyDataGrid.d.ts.map +1 -0
- package/dist/columnLeaf.d.ts +72 -0
- package/dist/columnLeaf.d.ts.map +1 -0
- package/dist/columnTypes.d.ts +35 -0
- package/dist/columnTypes.d.ts.map +1 -0
- package/dist/columns.d.ts +115 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/contextHooks.d.ts +45 -0
- package/dist/contextHooks.d.ts.map +1 -0
- package/dist/createColumn.d.ts +37 -0
- package/dist/createColumn.d.ts.map +1 -0
- package/dist/createRelationColumn.d.ts +57 -0
- package/dist/createRelationColumn.d.ts.map +1 -0
- package/dist/dataAttribute.d.ts +6 -0
- package/dist/dataAttribute.d.ts.map +1 -0
- package/dist/export.d.ts +46 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/filterComponents.d.ts +127 -0
- package/dist/filterComponents.d.ts.map +1 -0
- package/dist/filterContext.d.ts +10 -0
- package/dist/filterContext.d.ts.map +1 -0
- package/dist/filterHooks.d.ts +74 -0
- package/dist/filterHooks.d.ts.map +1 -0
- package/dist/filterScope.d.ts +54 -0
- package/dist/filterScope.d.ts.map +1 -0
- package/dist/filterWrappers.d.ts +66 -0
- package/dist/filterWrappers.d.ts.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/infiniteLoad.d.ts +32 -0
- package/dist/infiniteLoad.d.ts.map +1 -0
- package/dist/loaderComponents.d.ts +75 -0
- package/dist/loaderComponents.d.ts.map +1 -0
- package/dist/markers.d.ts +36 -0
- package/dist/markers.d.ts.map +1 -0
- package/dist/pagingComponents.d.ts +50 -0
- package/dist/pagingComponents.d.ts.map +1 -0
- package/dist/renderCellValue.d.ts +9 -0
- package/dist/renderCellValue.d.ts.map +1 -0
- package/dist/select/MultiSelect.d.ts +27 -0
- package/dist/select/MultiSelect.d.ts.map +1 -0
- package/dist/select/Select.d.ts +27 -0
- package/dist/select/Select.d.ts.map +1 -0
- package/dist/select/SelectDataView.d.ts +46 -0
- package/dist/select/SelectDataView.d.ts.map +1 -0
- package/dist/select/SelectEachValue.d.ts +20 -0
- package/dist/select/SelectEachValue.d.ts.map +1 -0
- package/dist/select/SelectItemTrigger.d.ts +25 -0
- package/dist/select/SelectItemTrigger.d.ts.map +1 -0
- package/dist/select/SelectOption.d.ts +23 -0
- package/dist/select/SelectOption.d.ts.map +1 -0
- package/dist/select/SelectPlaceholder.d.ts +16 -0
- package/dist/select/SelectPlaceholder.d.ts.map +1 -0
- package/dist/select/index.d.ts +9 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/select/selectContext.d.ts +20 -0
- package/dist/select/selectContext.d.ts.map +1 -0
- package/dist/selectionComponents.d.ts +62 -0
- package/dist/selectionComponents.d.ts.map +1 -0
- package/dist/sortingComponents.d.ts +39 -0
- package/dist/sortingComponents.d.ts.map +1 -0
- package/dist/stateStorage.d.ts +20 -0
- package/dist/stateStorage.d.ts.map +1 -0
- package/dist/useDataGridSetup.d.ts +53 -0
- package/dist/useDataGridSetup.d.ts.map +1 -0
- package/dist/useDataView.d.ts +57 -0
- package/dist/useDataView.d.ts.map +1 -0
- package/dist/useDataViewState.d.ts +89 -0
- package/dist/useDataViewState.d.ts.map +1 -0
- package/package.json +33 -0
- package/src/DataGrid.tsx +173 -0
- package/src/DataViewContext.tsx +56 -0
- package/src/DataViewKeyProvider.tsx +16 -0
- package/src/HasManyDataGrid.tsx +319 -0
- package/src/columnLeaf.ts +148 -0
- package/src/columnTypes.ts +160 -0
- package/src/columns.tsx +306 -0
- package/src/contextHooks.ts +157 -0
- package/src/createColumn.ts +128 -0
- package/src/createRelationColumn.tsx +203 -0
- package/src/dataAttribute.ts +7 -0
- package/src/export.tsx +219 -0
- package/src/filterComponents.tsx +617 -0
- package/src/filterContext.ts +27 -0
- package/src/filterHooks.ts +386 -0
- package/src/filterScope.tsx +89 -0
- package/src/filterWrappers.tsx +187 -0
- package/src/index.ts +356 -0
- package/src/infiniteLoad.tsx +188 -0
- package/src/loaderComponents.tsx +239 -0
- package/src/markers.ts +54 -0
- package/src/pagingComponents.tsx +149 -0
- package/src/select/MultiSelect.tsx +88 -0
- package/src/select/Select.tsx +88 -0
- package/src/select/SelectDataView.tsx +231 -0
- package/src/select/SelectEachValue.tsx +34 -0
- package/src/select/SelectItemTrigger.tsx +51 -0
- package/src/select/SelectOption.tsx +40 -0
- package/src/select/SelectPlaceholder.tsx +22 -0
- package/src/select/index.ts +17 -0
- package/src/select/selectContext.ts +82 -0
- package/src/selectionComponents.tsx +160 -0
- package/src/sortingComponents.tsx +104 -0
- package/src/stateStorage.ts +188 -0
- package/src/useDataGridSetup.ts +230 -0
- package/src/useDataView.ts +148 -0
- package/src/useDataViewState.ts +404 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectDataView — provides a DataView context for select option lists.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes the children render function for field selection, loads data
|
|
5
|
+
* from the options entity, and provides a DataView context for rendering.
|
|
6
|
+
* Children are rendered directly inside the DataView — use DataViewEachRow
|
|
7
|
+
* or DataViewLoaderState to iterate items and show loading states.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <Select relation={article.category} options={schema.Category}>
|
|
12
|
+
* <SelectDataView queryField={['name']}>
|
|
13
|
+
* <DataViewEachRow>
|
|
14
|
+
* {(item) => (
|
|
15
|
+
* <SelectOption entity={item}>
|
|
16
|
+
* <SelectItemTrigger entity={item}>
|
|
17
|
+
* <button>{item.name.value}</button>
|
|
18
|
+
* </SelectItemTrigger>
|
|
19
|
+
* </SelectOption>
|
|
20
|
+
* )}
|
|
21
|
+
* </DataViewEachRow>
|
|
22
|
+
* </SelectDataView>
|
|
23
|
+
* </Select>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { memo, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
28
|
+
import type {
|
|
29
|
+
EntityAccessor,
|
|
30
|
+
FilterArtifact,
|
|
31
|
+
FilterHandler,
|
|
32
|
+
OrderDirection,
|
|
33
|
+
SortingDirections,
|
|
34
|
+
} from '@contember/bindx'
|
|
35
|
+
import { SelectionScope, buildQueryFromSelection, createFullTextFilterHandler, FIELD_REF_META } from '@contember/bindx'
|
|
36
|
+
import type { FieldRefBase } from '@contember/bindx'
|
|
37
|
+
import {
|
|
38
|
+
useBindxContext,
|
|
39
|
+
useEntityList,
|
|
40
|
+
createCollectorProxy,
|
|
41
|
+
mergeSelections,
|
|
42
|
+
collectSelection,
|
|
43
|
+
} from '@contember/bindx-react'
|
|
44
|
+
import { useSelectOptions } from './selectContext.js'
|
|
45
|
+
import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js'
|
|
46
|
+
import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js'
|
|
47
|
+
|
|
48
|
+
export interface SelectDataViewProps {
|
|
49
|
+
/** Children rendered inside the DataView context */
|
|
50
|
+
children: ReactNode
|
|
51
|
+
/** Selection definer — called with collector proxy to discover fields */
|
|
52
|
+
selection?: (it: EntityAccessor<object>) => ReactNode
|
|
53
|
+
/** Field(s) to search across */
|
|
54
|
+
queryField?: FieldRefBase<unknown> | FieldRefBase<unknown>[] | string[]
|
|
55
|
+
/** Initial sort order */
|
|
56
|
+
initialSorting?: Partial<Record<string, OrderDirection>>
|
|
57
|
+
/** Static filter for the options list */
|
|
58
|
+
filter?: Record<string, unknown>
|
|
59
|
+
/** Items per page (null = all). Default: 50 */
|
|
60
|
+
itemsPerPage?: number | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const QUERY_FILTER_NAME = '__query'
|
|
64
|
+
|
|
65
|
+
function SelectDataViewImpl({
|
|
66
|
+
children,
|
|
67
|
+
selection: selectionDefiner,
|
|
68
|
+
queryField,
|
|
69
|
+
initialSorting,
|
|
70
|
+
filter: staticFilter,
|
|
71
|
+
itemsPerPage = 50,
|
|
72
|
+
}: SelectDataViewProps): ReactElement | null {
|
|
73
|
+
const options = useSelectOptions()
|
|
74
|
+
const { schema: schemaRegistry } = useBindxContext()
|
|
75
|
+
const entityType = options.$name
|
|
76
|
+
|
|
77
|
+
// ---- Loader state tracking ----
|
|
78
|
+
const hasLoadedOnce = useRef(false)
|
|
79
|
+
const [loaderState, setLoaderState] = useState<DataViewLoaderState>('initial')
|
|
80
|
+
|
|
81
|
+
// ---- Build selection from definer ----
|
|
82
|
+
// Use ref for selectionDefiner to avoid re-running on every render
|
|
83
|
+
// (inline arrow functions create new refs each render)
|
|
84
|
+
const selectionDefinerRef = useRef(selectionDefiner)
|
|
85
|
+
selectionDefinerRef.current = selectionDefiner
|
|
86
|
+
|
|
87
|
+
const { selection, queryKey } = useMemo(() => {
|
|
88
|
+
const scope = new SelectionScope()
|
|
89
|
+
const collector = createCollectorProxy<object>(scope, entityType, schemaRegistry ?? undefined) as unknown as EntityAccessor<object>
|
|
90
|
+
|
|
91
|
+
if (selectionDefinerRef.current) {
|
|
92
|
+
const jsx = selectionDefinerRef.current(collector)
|
|
93
|
+
const jsxSel = collectSelection(jsx)
|
|
94
|
+
const sel = scope.toSelectionMeta()
|
|
95
|
+
mergeSelections(sel, jsxSel)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sel = scope.toSelectionMeta()
|
|
99
|
+
const query = buildQueryFromSelection(sel)
|
|
100
|
+
const key = JSON.stringify({ entityType, query })
|
|
101
|
+
|
|
102
|
+
return { selection: sel, queryKey: key }
|
|
103
|
+
}, [entityType, schemaRegistry])
|
|
104
|
+
|
|
105
|
+
// ---- Filter setup ----
|
|
106
|
+
const filterDefs = useMemo((): ReadonlyMap<string, { handler: FilterHandler<FilterArtifact> }> => {
|
|
107
|
+
const map = new Map<string, { handler: FilterHandler<FilterArtifact> }>()
|
|
108
|
+
|
|
109
|
+
// Resolve query fields: explicit prop → auto-derive from selection scalars
|
|
110
|
+
let resolvedPaths: string[] | undefined
|
|
111
|
+
if (queryField) {
|
|
112
|
+
const fields = Array.isArray(queryField) ? queryField : [queryField]
|
|
113
|
+
resolvedPaths = fields.map(f => typeof f === 'string' ? f : f[FIELD_REF_META].fieldName)
|
|
114
|
+
} else if (selection.fields.size > 0) {
|
|
115
|
+
// Auto-derive: use all scalar fields from selection as searchable fields
|
|
116
|
+
resolvedPaths = []
|
|
117
|
+
for (const [name, field] of selection.fields) {
|
|
118
|
+
if (!field.nested) resolvedPaths.push(name)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (resolvedPaths && resolvedPaths.length > 0) {
|
|
123
|
+
map.set(QUERY_FILTER_NAME, { handler: createFullTextFilterHandler(resolvedPaths) })
|
|
124
|
+
}
|
|
125
|
+
return map
|
|
126
|
+
}, [queryField, selection])
|
|
127
|
+
|
|
128
|
+
const sortableFields = useMemo((): ReadonlySet<string> => new Set(), [])
|
|
129
|
+
|
|
130
|
+
const initialSortingDirs = useMemo((): SortingDirections | undefined => {
|
|
131
|
+
if (!initialSorting) return undefined
|
|
132
|
+
const dirs: Record<string, OrderDirection> = {}
|
|
133
|
+
for (const [field, dir] of Object.entries(initialSorting)) {
|
|
134
|
+
if (dir) dirs[field] = dir
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(dirs).length > 0 ? dirs : undefined
|
|
137
|
+
}, [initialSorting])
|
|
138
|
+
|
|
139
|
+
// ---- State (no persistence for select dropdowns) ----
|
|
140
|
+
const filtering = useFilteringState({ filters: filterDefs })
|
|
141
|
+
const sorting = useSortingState({ sortableFields, initialSorting: initialSortingDirs })
|
|
142
|
+
const paging = usePagingState({ initialItemsPerPage: itemsPerPage })
|
|
143
|
+
const selectionState = useSelectionState({})
|
|
144
|
+
|
|
145
|
+
// ---- Build combined filter ----
|
|
146
|
+
const combinedFilter = useMemo((): Record<string, unknown> | undefined => {
|
|
147
|
+
const parts: Record<string, unknown>[] = []
|
|
148
|
+
if (staticFilter) parts.push(staticFilter)
|
|
149
|
+
if (filtering.resolvedWhere) parts.push(filtering.resolvedWhere)
|
|
150
|
+
if (parts.length === 0) return undefined
|
|
151
|
+
if (parts.length === 1) return parts[0]
|
|
152
|
+
return { and: parts }
|
|
153
|
+
}, [staticFilter, filtering.resolvedWhere])
|
|
154
|
+
|
|
155
|
+
// ---- Load data ----
|
|
156
|
+
const result = useEntityList(options, {
|
|
157
|
+
filter: combinedFilter,
|
|
158
|
+
orderBy: sorting.resolvedOrderBy,
|
|
159
|
+
limit: paging.queryLimit,
|
|
160
|
+
offset: paging.queryOffset,
|
|
161
|
+
selection,
|
|
162
|
+
queryKey,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const items = result.status === 'ready' ? result.items : []
|
|
166
|
+
const itemCount = items.length
|
|
167
|
+
|
|
168
|
+
// Update loader state
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (result.status === 'ready') {
|
|
171
|
+
hasLoadedOnce.current = true
|
|
172
|
+
setLoaderState('loaded')
|
|
173
|
+
} else if (result.status === 'error') {
|
|
174
|
+
setLoaderState('failed')
|
|
175
|
+
} else if (result.status === 'loading') {
|
|
176
|
+
setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial')
|
|
177
|
+
}
|
|
178
|
+
}, [result.status])
|
|
179
|
+
|
|
180
|
+
// Update total count for paging
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (result.status === 'ready' && paging.queryLimit !== undefined && paging.queryOffset !== undefined && itemCount < paging.queryLimit) {
|
|
183
|
+
paging.setTotalCount(paging.queryOffset + itemCount)
|
|
184
|
+
}
|
|
185
|
+
}, [result.status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount])
|
|
186
|
+
|
|
187
|
+
// ---- Reload ----
|
|
188
|
+
const [, setReloadCounter] = useState(0)
|
|
189
|
+
const reload = useCallback((): void => {
|
|
190
|
+
setReloadCounter(c => c + 1)
|
|
191
|
+
}, [])
|
|
192
|
+
|
|
193
|
+
// ---- Row highlighting ----
|
|
194
|
+
const [highlightIndex, setHighlightIndex] = useState<number | null>(null)
|
|
195
|
+
|
|
196
|
+
// Reset highlight when filter/sort changes (not on every items reference change)
|
|
197
|
+
const filterKey = filtering.resolvedWhere ? JSON.stringify(filtering.resolvedWhere) : ''
|
|
198
|
+
const sortKey = sorting.resolvedOrderBy ? JSON.stringify(sorting.resolvedOrderBy) : ''
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
setHighlightIndex(null)
|
|
201
|
+
}, [filterKey, sortKey])
|
|
202
|
+
|
|
203
|
+
const emptyMap = useMemo(() => new Map(), [])
|
|
204
|
+
|
|
205
|
+
const contextValue = useMemo((): DataViewContextValue => ({
|
|
206
|
+
filtering,
|
|
207
|
+
sorting,
|
|
208
|
+
paging,
|
|
209
|
+
selection: selectionState,
|
|
210
|
+
columns: [],
|
|
211
|
+
entityType,
|
|
212
|
+
items,
|
|
213
|
+
itemCount,
|
|
214
|
+
loaderState,
|
|
215
|
+
reload,
|
|
216
|
+
highlightIndex,
|
|
217
|
+
setHighlightIndex,
|
|
218
|
+
selectionMeta: selection,
|
|
219
|
+
toolbarContent: undefined,
|
|
220
|
+
layoutRenders: emptyMap,
|
|
221
|
+
layoutElements: emptyMap,
|
|
222
|
+
}), [filtering, sorting, paging, selectionState, entityType, items, itemCount, loaderState, reload, highlightIndex, selection, emptyMap])
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<DataViewProvider value={contextValue}>
|
|
226
|
+
{children}
|
|
227
|
+
</DataViewProvider>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const SelectDataView = memo(SelectDataViewImpl) as unknown as typeof SelectDataViewImpl
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectEachValue — iterates over currently selected entities.
|
|
3
|
+
*
|
|
4
|
+
* Renders its children once for each selected entity,
|
|
5
|
+
* providing the entity accessor and index.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <SelectEachValue>
|
|
10
|
+
* {(entity) => <span>{entity.name.value}</span>}
|
|
11
|
+
* </SelectEachValue>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { type ReactNode } from 'react'
|
|
16
|
+
import type { EntityAccessor } from '@contember/bindx'
|
|
17
|
+
import { useSelectCurrentEntities } from './selectContext.js'
|
|
18
|
+
|
|
19
|
+
export interface SelectEachValueProps {
|
|
20
|
+
children: (entity: EntityAccessor<object>, index: number) => ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SelectEachValue({ children }: SelectEachValueProps): ReactNode {
|
|
24
|
+
const entities = useSelectCurrentEntities()
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
{entities.map((entity, index) => (
|
|
28
|
+
<React.Fragment key={entity.id}>
|
|
29
|
+
{children(entity, index)}
|
|
30
|
+
</React.Fragment>
|
|
31
|
+
))}
|
|
32
|
+
</>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectItemTrigger — clickable handler for option items.
|
|
3
|
+
*
|
|
4
|
+
* Slot-based: wraps its single child element, adding an onClick handler
|
|
5
|
+
* that calls the select handler with the specified action.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <SelectItemTrigger action="toggle">
|
|
10
|
+
* <button><Field field={it.name} /></button>
|
|
11
|
+
* </SelectItemTrigger>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { forwardRef, type ReactElement, useCallback } from 'react'
|
|
16
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
17
|
+
import { composeEventHandlers } from '@radix-ui/primitive'
|
|
18
|
+
import { useSelectHandleSelect, type SelectAction } from './selectContext.js'
|
|
19
|
+
import type { EntityAccessor } from '@contember/bindx'
|
|
20
|
+
|
|
21
|
+
export interface SelectItemTriggerProps {
|
|
22
|
+
/** Entity accessor for this option */
|
|
23
|
+
entity: EntityAccessor<object>
|
|
24
|
+
/** Action to perform on click. Default: 'toggle' */
|
|
25
|
+
action?: SelectAction
|
|
26
|
+
children: ReactElement
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SelectItemTrigger = forwardRef<HTMLElement, SelectItemTriggerProps>(
|
|
30
|
+
({ entity, action = 'toggle', ...props }, ref) => {
|
|
31
|
+
const handleSelect = useSelectHandleSelect()
|
|
32
|
+
|
|
33
|
+
const handleClick = useCallback(
|
|
34
|
+
(): void => {
|
|
35
|
+
handleSelect(entity, action)
|
|
36
|
+
},
|
|
37
|
+
[entity, action, handleSelect],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const { onClick, ...otherProps } = props as React.HTMLAttributes<HTMLElement> & { children: ReactElement }
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Slot
|
|
44
|
+
ref={ref}
|
|
45
|
+
onClick={composeEventHandlers(onClick, handleClick)}
|
|
46
|
+
{...otherProps}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
SelectItemTrigger.displayName = 'SelectItemTrigger'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectOption — marks an option item with data-selected attribute.
|
|
3
|
+
*
|
|
4
|
+
* Slot-based: wraps its single child element, adding `data-selected`
|
|
5
|
+
* when the current entity is selected.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <SelectOption>
|
|
10
|
+
* <button>Option label</button>
|
|
11
|
+
* </SelectOption>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { forwardRef, type ReactElement } from 'react'
|
|
16
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
17
|
+
import { useSelectIsSelected } from './selectContext.js'
|
|
18
|
+
import { dataAttribute } from '../dataAttribute.js'
|
|
19
|
+
|
|
20
|
+
export interface SelectOptionProps {
|
|
21
|
+
/** Entity accessor for this option (passed automatically by DataViewEachRow) */
|
|
22
|
+
entity: { id: string }
|
|
23
|
+
children: ReactElement
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SelectOption = forwardRef<HTMLElement, SelectOptionProps>(
|
|
27
|
+
({ entity, children, ...rest }, ref) => {
|
|
28
|
+
const isSelected = useSelectIsSelected()
|
|
29
|
+
return (
|
|
30
|
+
<Slot
|
|
31
|
+
ref={ref}
|
|
32
|
+
data-selected={dataAttribute(isSelected({ id: entity.id } as Parameters<typeof isSelected>[0]))}
|
|
33
|
+
{...rest}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</Slot>
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
SelectOption.displayName = 'SelectOption'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelectPlaceholder — renders children only when no entities are selected.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <SelectPlaceholder>
|
|
7
|
+
* <span>Select an option...</span>
|
|
8
|
+
* </SelectPlaceholder>
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
import { useSelectCurrentEntities } from './selectContext.js'
|
|
14
|
+
|
|
15
|
+
export interface SelectPlaceholderProps {
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SelectPlaceholder({ children }: SelectPlaceholderProps): ReactNode {
|
|
20
|
+
const entities = useSelectCurrentEntities()
|
|
21
|
+
return entities.length > 0 ? null : <>{children}</>
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { Select, type SelectProps } from './Select.js'
|
|
2
|
+
export { MultiSelect, type MultiSelectProps } from './MultiSelect.js'
|
|
3
|
+
export { SelectDataView, type SelectDataViewProps } from './SelectDataView.js'
|
|
4
|
+
export { SelectOption, type SelectOptionProps } from './SelectOption.js'
|
|
5
|
+
export { SelectItemTrigger, type SelectItemTriggerProps } from './SelectItemTrigger.js'
|
|
6
|
+
export { SelectEachValue, type SelectEachValueProps } from './SelectEachValue.js'
|
|
7
|
+
export { SelectPlaceholder, type SelectPlaceholderProps } from './SelectPlaceholder.js'
|
|
8
|
+
export {
|
|
9
|
+
useSelectCurrentEntities,
|
|
10
|
+
useSelectIsSelected,
|
|
11
|
+
useSelectHandleSelect,
|
|
12
|
+
useSelectOptions,
|
|
13
|
+
SelectOptionsContext,
|
|
14
|
+
type SelectHandler,
|
|
15
|
+
type SelectAction,
|
|
16
|
+
type SelectEvents,
|
|
17
|
+
} from './selectContext.js'
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select context — provides selection state and handlers to descendant components.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createContext, useContext } from 'react'
|
|
6
|
+
import type { EntityAccessor, EntityDef } from '@contember/bindx'
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export type SelectAction = 'select' | 'unselect' | 'toggle'
|
|
13
|
+
|
|
14
|
+
export type SelectHandler = (entity: EntityAccessor<object>, action?: SelectAction) => void
|
|
15
|
+
|
|
16
|
+
export interface SelectEvents {
|
|
17
|
+
onSelect?: (entity: EntityAccessor<object>) => void
|
|
18
|
+
onUnselect?: (entity: EntityAccessor<object>) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Contexts
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
const SelectCurrentEntitiesContext = createContext<readonly EntityAccessor<object>[] | null>(null)
|
|
26
|
+
SelectCurrentEntitiesContext.displayName = 'SelectCurrentEntitiesContext'
|
|
27
|
+
|
|
28
|
+
const SelectIsSelectedContext = createContext<((entity: EntityAccessor<object>) => boolean) | null>(null)
|
|
29
|
+
SelectIsSelectedContext.displayName = 'SelectIsSelectedContext'
|
|
30
|
+
|
|
31
|
+
const SelectHandleSelectContext = createContext<SelectHandler | null>(null)
|
|
32
|
+
SelectHandleSelectContext.displayName = 'SelectHandleSelectContext'
|
|
33
|
+
|
|
34
|
+
const SelectOptionsContext = createContext<EntityDef | null>(null)
|
|
35
|
+
SelectOptionsContext.displayName = 'SelectOptionsContext'
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Hooks
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export function useSelectCurrentEntities(): readonly EntityAccessor<object>[] {
|
|
42
|
+
const ctx = useContext(SelectCurrentEntitiesContext)
|
|
43
|
+
if (ctx === null) {
|
|
44
|
+
throw new Error('useSelectCurrentEntities must be used within a Select or MultiSelect')
|
|
45
|
+
}
|
|
46
|
+
return ctx
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useSelectIsSelected(): (entity: EntityAccessor<object>) => boolean {
|
|
50
|
+
const ctx = useContext(SelectIsSelectedContext)
|
|
51
|
+
if (ctx === null) {
|
|
52
|
+
throw new Error('useSelectIsSelected must be used within a Select or MultiSelect')
|
|
53
|
+
}
|
|
54
|
+
return ctx
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useSelectHandleSelect(): SelectHandler {
|
|
58
|
+
const ctx = useContext(SelectHandleSelectContext)
|
|
59
|
+
if (ctx === null) {
|
|
60
|
+
throw new Error('useSelectHandleSelect must be used within a Select or MultiSelect')
|
|
61
|
+
}
|
|
62
|
+
return ctx
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useSelectOptions(): EntityDef {
|
|
66
|
+
const ctx = useContext(SelectOptionsContext)
|
|
67
|
+
if (ctx === null) {
|
|
68
|
+
throw new Error('useSelectOptions must be used within a Select or MultiSelect')
|
|
69
|
+
}
|
|
70
|
+
return ctx
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Providers (exported for internal use by Select/MultiSelect)
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
SelectCurrentEntitiesContext,
|
|
79
|
+
SelectIsSelectedContext,
|
|
80
|
+
SelectHandleSelectContext,
|
|
81
|
+
SelectOptionsContext,
|
|
82
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection components — column visibility + layout switching using Radix Slot.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <DataViewVisibilityTrigger name="description">
|
|
7
|
+
* <button>Toggle Description</button>
|
|
8
|
+
* </DataViewVisibilityTrigger>
|
|
9
|
+
*
|
|
10
|
+
* <DataViewElement name="description">
|
|
11
|
+
* <DescriptionColumn />
|
|
12
|
+
* </DataViewElement>
|
|
13
|
+
*
|
|
14
|
+
* <DataViewLayout name="table">
|
|
15
|
+
* <TableView />
|
|
16
|
+
* </DataViewLayout>
|
|
17
|
+
*
|
|
18
|
+
* <DataViewLayoutTrigger name="table">
|
|
19
|
+
* <button>Table View</button>
|
|
20
|
+
* </DataViewLayoutTrigger>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import React, { forwardRef, type ReactElement, 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
|
+
// Column Visibility
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
export interface DataViewVisibilityTriggerProps {
|
|
35
|
+
/** Element name to toggle */
|
|
36
|
+
name: string
|
|
37
|
+
/** Value to set. Can be boolean or function. Default: toggle */
|
|
38
|
+
value?: boolean | ((current: boolean) => boolean)
|
|
39
|
+
/** Fallback visibility when not explicitly set. Default: true */
|
|
40
|
+
fallbackValue?: boolean
|
|
41
|
+
/** Button element */
|
|
42
|
+
children: ReactElement
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DataViewVisibilityTrigger = forwardRef<HTMLButtonElement, DataViewVisibilityTriggerProps>(
|
|
46
|
+
({ name, value, fallbackValue = true, ...props }, ref) => {
|
|
47
|
+
const { selection } = useDataViewContext()
|
|
48
|
+
const isVisible = selection.isVisible(name, fallbackValue)
|
|
49
|
+
|
|
50
|
+
const handleClick = useCallback((): void => {
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
selection.setVisibility(name, !isVisible)
|
|
53
|
+
} else if (typeof value === 'function') {
|
|
54
|
+
selection.setVisibility(name, value(isVisible))
|
|
55
|
+
} else {
|
|
56
|
+
selection.setVisibility(name, value)
|
|
57
|
+
}
|
|
58
|
+
}, [selection, name, value, isVisible])
|
|
59
|
+
|
|
60
|
+
const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Slot
|
|
64
|
+
ref={ref}
|
|
65
|
+
onClick={composeEventHandlers(onClick, handleClick)}
|
|
66
|
+
data-active={dataAttribute(isVisible)}
|
|
67
|
+
data-current={String(isVisible)}
|
|
68
|
+
{...otherProps}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
DataViewVisibilityTrigger.displayName = 'DataViewVisibilityTrigger'
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Conditional Element (visibility-aware)
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
export interface DataViewElementProps {
|
|
80
|
+
/** Element name (used for visibility toggle) */
|
|
81
|
+
name: string
|
|
82
|
+
/** Label for the element (for UI display) */
|
|
83
|
+
label?: React.ReactNode
|
|
84
|
+
/** Default visibility when not explicitly set. Default: true */
|
|
85
|
+
fallback?: boolean
|
|
86
|
+
children: React.ReactNode
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function DataViewElement({
|
|
90
|
+
name,
|
|
91
|
+
fallback = true,
|
|
92
|
+
children,
|
|
93
|
+
}: DataViewElementProps): ReactElement | null {
|
|
94
|
+
const { selection } = useDataViewContext()
|
|
95
|
+
const isVisible = selection.isVisible(name, fallback)
|
|
96
|
+
|
|
97
|
+
if (!isVisible) return null
|
|
98
|
+
return <>{children}</>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @deprecated Use DataViewElement instead */
|
|
102
|
+
export const DataViewIsVisible = DataViewElement
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Layout Switching
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
export interface DataViewLayoutProps {
|
|
109
|
+
/** Layout name */
|
|
110
|
+
name: string
|
|
111
|
+
/** Label for the layout */
|
|
112
|
+
label?: React.ReactNode
|
|
113
|
+
children: React.ReactNode
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function DataViewLayout({
|
|
117
|
+
name,
|
|
118
|
+
children,
|
|
119
|
+
}: DataViewLayoutProps): ReactElement | null {
|
|
120
|
+
const { selection } = useDataViewContext()
|
|
121
|
+
const currentLayout = selection.currentLayout
|
|
122
|
+
const layouts = selection.state.layouts
|
|
123
|
+
const isActive = currentLayout === name || (currentLayout === undefined && layouts[0]?.name === name)
|
|
124
|
+
|
|
125
|
+
if (!isActive) return null
|
|
126
|
+
return <>{children}</>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DataViewLayoutTriggerProps {
|
|
130
|
+
/** Layout name to switch to */
|
|
131
|
+
name: string
|
|
132
|
+
/** Button element */
|
|
133
|
+
children: ReactElement
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const DataViewLayoutTrigger = forwardRef<HTMLButtonElement, DataViewLayoutTriggerProps>(
|
|
137
|
+
({ name, ...props }, ref) => {
|
|
138
|
+
const { selection } = useDataViewContext()
|
|
139
|
+
const currentLayout = selection.currentLayout
|
|
140
|
+
const layouts = selection.state.layouts
|
|
141
|
+
const isActive = currentLayout === name || (currentLayout === undefined && layouts[0]?.name === name)
|
|
142
|
+
|
|
143
|
+
const handleClick = useCallback((): void => {
|
|
144
|
+
selection.setLayout(name)
|
|
145
|
+
}, [selection, name])
|
|
146
|
+
|
|
147
|
+
const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Slot
|
|
151
|
+
ref={ref}
|
|
152
|
+
onClick={composeEventHandlers(onClick, handleClick)}
|
|
153
|
+
data-active={dataAttribute(isActive)}
|
|
154
|
+
data-current={currentLayout ?? layouts[0]?.name ?? 'none'}
|
|
155
|
+
{...otherProps}
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
DataViewLayoutTrigger.displayName = 'DataViewLayoutTrigger'
|