@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.
@@ -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
@@ -0,0 +1,3 @@
1
+ export * from './TaxonomyController'
2
+ export * from './ThematicTree'
3
+ export * from './ThematicBadgeGroup'