@quatrain/ux-taxonomy 1.0.1
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/README.md +41 -0
- package/dist/TaxonomyController.d.ts +184 -0
- package/dist/TaxonomyController.js +318 -0
- package/dist/ThematicBadgeGroup.d.ts +22 -0
- package/dist/ThematicBadgeGroup.js +29 -0
- package/dist/ThematicTree.d.ts +23 -0
- package/dist/ThematicTree.js +100 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/package.json +45 -0
- package/src/TaxonomyController.test.ts +151 -0
- package/src/TaxonomyController.ts +373 -0
- package/src/ThematicBadgeGroup.tsx +73 -0
- package/src/ThematicTree.tsx +230 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Group, Badge, Text, Box } from '@mantine/core'
|
|
3
|
+
import type { TaxonomyNode } from './TaxonomyController'
|
|
4
|
+
|
|
5
|
+
export interface ThematicBadgeGroupProps {
|
|
6
|
+
/** Available taxonomy nodes / thematics */
|
|
7
|
+
nodes: TaxonomyNode[]
|
|
8
|
+
/** Array of currently selected thematic slugs/ids */
|
|
9
|
+
value: string[]
|
|
10
|
+
/** Callback fired when the selection changes */
|
|
11
|
+
onChange: (selectedIds: string[]) => void
|
|
12
|
+
/** Label displayed above the badges */
|
|
13
|
+
label?: string
|
|
14
|
+
/** Description or subtext */
|
|
15
|
+
description?: string
|
|
16
|
+
/** Custom CSS class */
|
|
17
|
+
className?: string
|
|
18
|
+
/** Custom styles */
|
|
19
|
+
style?: React.CSSProperties
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Interactive badge group component allowing multi-selection of transversal thematics.
|
|
24
|
+
*/
|
|
25
|
+
export const ThematicBadgeGroup: React.FC<ThematicBadgeGroupProps> = ({
|
|
26
|
+
nodes,
|
|
27
|
+
value = [],
|
|
28
|
+
onChange,
|
|
29
|
+
label,
|
|
30
|
+
description,
|
|
31
|
+
className = '',
|
|
32
|
+
style
|
|
33
|
+
}) => {
|
|
34
|
+
const handleToggle = (id: string) => {
|
|
35
|
+
if (value.includes(id)) {
|
|
36
|
+
onChange(value.filter((v) => v !== id))
|
|
37
|
+
} else {
|
|
38
|
+
onChange([...value, id])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Box className={`q-thematic-badge-group ${className}`} style={style}>
|
|
44
|
+
{label && (
|
|
45
|
+
<Text size="sm" fw={500} mb={2}>
|
|
46
|
+
{label}
|
|
47
|
+
</Text>
|
|
48
|
+
)}
|
|
49
|
+
{description && (
|
|
50
|
+
<Text size="xs" c="dimmed" mb="xs">
|
|
51
|
+
{description}
|
|
52
|
+
</Text>
|
|
53
|
+
)}
|
|
54
|
+
<Group gap="xs">
|
|
55
|
+
{nodes.map((node) => {
|
|
56
|
+
const isSelected = value.includes(node.id)
|
|
57
|
+
return (
|
|
58
|
+
<Badge
|
|
59
|
+
key={node.id}
|
|
60
|
+
size="md"
|
|
61
|
+
variant={isSelected ? 'filled' : 'outline'}
|
|
62
|
+
color={node.color || (isSelected ? 'blue' : 'gray')}
|
|
63
|
+
style={{ cursor: 'pointer', userSelect: 'none' }}
|
|
64
|
+
onClick={() => handleToggle(node.id)}
|
|
65
|
+
>
|
|
66
|
+
{node.label}
|
|
67
|
+
</Badge>
|
|
68
|
+
)
|
|
69
|
+
})}
|
|
70
|
+
</Group>
|
|
71
|
+
</Box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Group,
|
|
5
|
+
Text,
|
|
6
|
+
Badge,
|
|
7
|
+
ActionIcon,
|
|
8
|
+
UnstyledButton,
|
|
9
|
+
Stack,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Collapse,
|
|
12
|
+
TextInput
|
|
13
|
+
} from '@mantine/core'
|
|
14
|
+
import {
|
|
15
|
+
IconChevronRight,
|
|
16
|
+
IconChevronDown,
|
|
17
|
+
IconFolder,
|
|
18
|
+
IconFolderOpen,
|
|
19
|
+
IconPlus,
|
|
20
|
+
IconSearch
|
|
21
|
+
} from '@tabler/icons-react'
|
|
22
|
+
import { TaxonomyController, type TaxonomyNode } from './TaxonomyController'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Properties for rendering the ThematicTree component.
|
|
26
|
+
*/
|
|
27
|
+
export interface ThematicTreeProps {
|
|
28
|
+
/** The taxonomy controller managing state */
|
|
29
|
+
controller: TaxonomyController
|
|
30
|
+
/** Optional callback fired when a node is selected */
|
|
31
|
+
onSelect?: (node: TaxonomyNode) => void
|
|
32
|
+
/** Optional callback fired when the 'Add Sub-thematic' button is clicked */
|
|
33
|
+
onAddSubThematic?: (parentNode?: TaxonomyNode) => void
|
|
34
|
+
/** Whether to show a live search filter input on top */
|
|
35
|
+
searchable?: boolean
|
|
36
|
+
/** Custom CSS class */
|
|
37
|
+
className?: string
|
|
38
|
+
/** Custom styles */
|
|
39
|
+
style?: React.CSSProperties
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Tree node item presentation component.
|
|
44
|
+
*/
|
|
45
|
+
interface TreeNodeItemProps {
|
|
46
|
+
node: TaxonomyNode
|
|
47
|
+
level: number
|
|
48
|
+
controller: TaxonomyController
|
|
49
|
+
selectedIds: string[]
|
|
50
|
+
searchQuery: string
|
|
51
|
+
onSelect?: (node: TaxonomyNode) => void
|
|
52
|
+
onAddSubThematic?: (parentNode?: TaxonomyNode) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TreeNodeItem: React.FC<TreeNodeItemProps> = ({
|
|
56
|
+
node,
|
|
57
|
+
level,
|
|
58
|
+
controller,
|
|
59
|
+
selectedIds,
|
|
60
|
+
searchQuery,
|
|
61
|
+
onSelect,
|
|
62
|
+
onAddSubThematic
|
|
63
|
+
}) => {
|
|
64
|
+
const hasChildren = Boolean(node.children && node.children.length > 0)
|
|
65
|
+
const isExpanded = controller.isExpanded(node.id) || Boolean(searchQuery.trim())
|
|
66
|
+
const isSelected = selectedIds.includes(node.id)
|
|
67
|
+
|
|
68
|
+
const matchesSearch = useMemo(() => {
|
|
69
|
+
if (!searchQuery.trim()) return true
|
|
70
|
+
const q = searchQuery.toLowerCase()
|
|
71
|
+
const matchSelf = node.label.toLowerCase().includes(q) || (node.description && node.description.toLowerCase().includes(q))
|
|
72
|
+
return matchSelf
|
|
73
|
+
}, [node, searchQuery])
|
|
74
|
+
|
|
75
|
+
if (!matchesSearch && !hasChildren) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Box style={{ marginLeft: level * 14 }}>
|
|
81
|
+
<Group
|
|
82
|
+
justify="space-between"
|
|
83
|
+
wrap="nowrap"
|
|
84
|
+
p={6}
|
|
85
|
+
style={{
|
|
86
|
+
borderRadius: 6,
|
|
87
|
+
backgroundColor: isSelected ? 'var(--mantine-color-blue-light)' : 'transparent',
|
|
88
|
+
cursor: 'pointer',
|
|
89
|
+
transition: 'background-color 150ms ease'
|
|
90
|
+
}}
|
|
91
|
+
onClick={() => {
|
|
92
|
+
controller.select(node.id)
|
|
93
|
+
if (onSelect) onSelect(node)
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
|
97
|
+
{hasChildren ? (
|
|
98
|
+
<ActionIcon
|
|
99
|
+
size="xs"
|
|
100
|
+
variant="subtle"
|
|
101
|
+
color="gray"
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
e.stopPropagation()
|
|
104
|
+
controller.toggleExpand(node.id)
|
|
105
|
+
}}
|
|
106
|
+
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
107
|
+
>
|
|
108
|
+
{isExpanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
|
109
|
+
</ActionIcon>
|
|
110
|
+
) : (
|
|
111
|
+
<Box style={{ width: 18 }} />
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{hasChildren ? (
|
|
115
|
+
isExpanded ? <IconFolderOpen size={16} color="var(--mantine-color-blue-filled)" /> : <IconFolder size={16} color="gray" />
|
|
116
|
+
) : (
|
|
117
|
+
<IconFolder size={16} color="gray" />
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<Text size="sm" fw={isSelected ? 600 : 400} truncate style={{ flex: 1 }}>
|
|
121
|
+
{node.label}
|
|
122
|
+
</Text>
|
|
123
|
+
</Group>
|
|
124
|
+
|
|
125
|
+
<Group gap={4} wrap="nowrap">
|
|
126
|
+
{node.count !== undefined && (
|
|
127
|
+
<Badge size="xs" variant={isSelected ? 'filled' : 'light'} color={node.color || 'blue'}>
|
|
128
|
+
{node.count}
|
|
129
|
+
</Badge>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{onAddSubThematic && (
|
|
133
|
+
<Tooltip label="Ajouter une sous-thématique" withArrow position="right">
|
|
134
|
+
<ActionIcon
|
|
135
|
+
size="xs"
|
|
136
|
+
variant="subtle"
|
|
137
|
+
color="gray"
|
|
138
|
+
onClick={(e) => {
|
|
139
|
+
e.stopPropagation()
|
|
140
|
+
onAddSubThematic(node)
|
|
141
|
+
}}
|
|
142
|
+
aria-label="Add sub-thematic"
|
|
143
|
+
>
|
|
144
|
+
<IconPlus size={12} />
|
|
145
|
+
</ActionIcon>
|
|
146
|
+
</Tooltip>
|
|
147
|
+
)}
|
|
148
|
+
</Group>
|
|
149
|
+
</Group>
|
|
150
|
+
|
|
151
|
+
{hasChildren && (
|
|
152
|
+
<Collapse in={isExpanded}>
|
|
153
|
+
<Stack gap={2} mt={2}>
|
|
154
|
+
{node.children!.map((child) => (
|
|
155
|
+
<TreeNodeItem
|
|
156
|
+
key={child.id}
|
|
157
|
+
node={child}
|
|
158
|
+
level={level + 1}
|
|
159
|
+
controller={controller}
|
|
160
|
+
selectedIds={selectedIds}
|
|
161
|
+
searchQuery={searchQuery}
|
|
162
|
+
onSelect={onSelect}
|
|
163
|
+
onAddSubThematic={onAddSubThematic}
|
|
164
|
+
/>
|
|
165
|
+
))}
|
|
166
|
+
</Stack>
|
|
167
|
+
</Collapse>
|
|
168
|
+
)}
|
|
169
|
+
</Box>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Tree component rendering interactive taxonomic hierarchies with Mantine styling.
|
|
175
|
+
*/
|
|
176
|
+
export const ThematicTree: React.FC<ThematicTreeProps> = ({
|
|
177
|
+
controller,
|
|
178
|
+
onSelect,
|
|
179
|
+
onAddSubThematic,
|
|
180
|
+
searchable = true,
|
|
181
|
+
className = '',
|
|
182
|
+
style
|
|
183
|
+
}) => {
|
|
184
|
+
const [tree, setTree] = useState<TaxonomyNode[]>(controller.getTree())
|
|
185
|
+
const [selectedIds, setSelectedIds] = useState<string[]>(controller.getSelected())
|
|
186
|
+
const [searchQuery, setSearchQuery] = useState<string>('')
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
return controller.subscribe((updatedTree, updatedSelected) => {
|
|
190
|
+
setTree(updatedTree)
|
|
191
|
+
setSelectedIds(updatedSelected)
|
|
192
|
+
})
|
|
193
|
+
}, [controller])
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Box className={`q-thematic-tree ${className}`} style={style}>
|
|
197
|
+
{searchable && (
|
|
198
|
+
<TextInput
|
|
199
|
+
placeholder="Filtrer les thématiques..."
|
|
200
|
+
size="xs"
|
|
201
|
+
mb="xs"
|
|
202
|
+
leftSection={<IconSearch size={14} />}
|
|
203
|
+
value={searchQuery}
|
|
204
|
+
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<Stack gap={2}>
|
|
209
|
+
{tree.length === 0 ? (
|
|
210
|
+
<Text size="sm" c="dimmed" fs="italic" p="xs">
|
|
211
|
+
Aucune thématique définie.
|
|
212
|
+
</Text>
|
|
213
|
+
) : (
|
|
214
|
+
tree.map((node) => (
|
|
215
|
+
<TreeNodeItem
|
|
216
|
+
key={node.id}
|
|
217
|
+
node={node}
|
|
218
|
+
level={0}
|
|
219
|
+
controller={controller}
|
|
220
|
+
selectedIds={selectedIds}
|
|
221
|
+
searchQuery={searchQuery}
|
|
222
|
+
onSelect={onSelect}
|
|
223
|
+
onAddSubThematic={onAddSubThematic}
|
|
224
|
+
/>
|
|
225
|
+
))
|
|
226
|
+
)}
|
|
227
|
+
</Stack>
|
|
228
|
+
</Box>
|
|
229
|
+
)
|
|
230
|
+
}
|
package/src/index.ts
ADDED