@idealyst/components 1.3.4 → 1.3.5
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/package.json +4 -4
- package/src/Menu/Menu.styles.tsx +3 -3
- package/src/Menu/MenuItem.styles.tsx +6 -3
- package/src/Menu/MenuItem.web.tsx +1 -12
- package/src/Table/Table.native.tsx +90 -9
- package/src/Table/Table.styles.tsx +61 -0
- package/src/Table/Table.web.tsx +94 -6
- package/src/Table/types.ts +17 -0
- package/src/examples/TableExamples.tsx +95 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/components",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "Shared component library for React and React Native",
|
|
5
5
|
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/components#readme",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"publish:npm": "npm publish"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@idealyst/theme": "^1.3.
|
|
59
|
+
"@idealyst/theme": "^1.3.5",
|
|
60
60
|
"@mdi/js": ">=7.0.0",
|
|
61
61
|
"@mdi/react": ">=1.0.0",
|
|
62
62
|
"@react-native-vector-icons/common": ">=12.0.0",
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
113
|
"@idealyst/blur": "^1.2.40",
|
|
114
|
-
"@idealyst/theme": "^1.3.
|
|
115
|
-
"@idealyst/tooling": "^1.3.
|
|
114
|
+
"@idealyst/theme": "^1.3.5",
|
|
115
|
+
"@idealyst/tooling": "^1.3.5",
|
|
116
116
|
"@mdi/react": "^1.6.1",
|
|
117
117
|
"@types/react": "^19.1.0",
|
|
118
118
|
"react": "^19.1.0",
|
package/src/Menu/Menu.styles.tsx
CHANGED
|
@@ -57,12 +57,12 @@ export const menuStyles = defineStyle('Menu', (theme: Theme) => ({
|
|
|
57
57
|
flexDirection: 'row' as const,
|
|
58
58
|
alignItems: 'center' as const,
|
|
59
59
|
backgroundColor: 'transparent' as const,
|
|
60
|
-
borderRadius: 4,
|
|
61
|
-
minHeight: 44,
|
|
62
60
|
variants: {
|
|
63
61
|
size: {
|
|
64
62
|
paddingVertical: theme.sizes.$menu.paddingVertical,
|
|
65
63
|
paddingHorizontal: theme.sizes.$menu.paddingHorizontal,
|
|
64
|
+
minHeight: theme.sizes.$menu.minHeight,
|
|
65
|
+
borderRadius: theme.sizes.$menu.borderRadius,
|
|
66
66
|
},
|
|
67
67
|
intent: {
|
|
68
68
|
neutral: {
|
|
@@ -147,13 +147,13 @@ export const menuStyles = defineStyle('Menu', (theme: Theme) => ({
|
|
|
147
147
|
alignItems: 'center' as const,
|
|
148
148
|
justifyContent: 'center' as const,
|
|
149
149
|
flexShrink: 0,
|
|
150
|
-
marginRight: 8,
|
|
151
150
|
color: theme.colors.text.primary,
|
|
152
151
|
variants: {
|
|
153
152
|
size: {
|
|
154
153
|
width: theme.sizes.$menu.iconSize,
|
|
155
154
|
height: theme.sizes.$menu.iconSize,
|
|
156
155
|
fontSize: theme.sizes.$menu.iconSize,
|
|
156
|
+
marginRight: theme.sizes.$menu.iconGap,
|
|
157
157
|
},
|
|
158
158
|
intent: {
|
|
159
159
|
neutral: {},
|
|
@@ -25,12 +25,12 @@ export const menuItemStyles = defineStyle('MenuItem', (theme: Theme) => ({
|
|
|
25
25
|
flexDirection: 'row' as const,
|
|
26
26
|
alignItems: 'center' as const,
|
|
27
27
|
backgroundColor: 'transparent' as const,
|
|
28
|
-
borderRadius: 4,
|
|
29
|
-
minHeight: 44,
|
|
30
28
|
variants: {
|
|
31
29
|
size: {
|
|
32
30
|
paddingVertical: theme.sizes.$menu.paddingVertical,
|
|
33
31
|
paddingHorizontal: theme.sizes.$menu.paddingHorizontal,
|
|
32
|
+
minHeight: theme.sizes.$menu.minHeight,
|
|
33
|
+
borderRadius: theme.sizes.$menu.borderRadius,
|
|
34
34
|
},
|
|
35
35
|
intent: {
|
|
36
36
|
neutral: {
|
|
@@ -110,13 +110,13 @@ export const menuItemStyles = defineStyle('MenuItem', (theme: Theme) => ({
|
|
|
110
110
|
alignItems: 'center' as const,
|
|
111
111
|
justifyContent: 'center' as const,
|
|
112
112
|
flexShrink: 0,
|
|
113
|
-
marginRight: 12,
|
|
114
113
|
color: theme.colors.text.primary,
|
|
115
114
|
variants: {
|
|
116
115
|
size: {
|
|
117
116
|
width: theme.sizes.$menu.iconSize,
|
|
118
117
|
height: theme.sizes.$menu.iconSize,
|
|
119
118
|
fontSize: theme.sizes.$menu.iconSize,
|
|
119
|
+
marginRight: theme.sizes.$menu.iconGap,
|
|
120
120
|
},
|
|
121
121
|
intent: {
|
|
122
122
|
neutral: {},
|
|
@@ -135,6 +135,9 @@ export const menuItemStyles = defineStyle('MenuItem', (theme: Theme) => ({
|
|
|
135
135
|
label: (_props: MenuItemDynamicProps) => ({
|
|
136
136
|
flex: 1,
|
|
137
137
|
color: theme.colors.text.primary,
|
|
138
|
+
_web: {
|
|
139
|
+
whiteSpace: 'nowrap',
|
|
140
|
+
},
|
|
138
141
|
variants: {
|
|
139
142
|
size: {
|
|
140
143
|
fontSize: theme.sizes.$menu.labelFontSize,
|
|
@@ -18,6 +18,7 @@ const MenuItem = forwardRef<IdealystElement, MenuItemProps>(({ item, onPress, si
|
|
|
18
18
|
// Initialize styles with useVariants (for size and disabled)
|
|
19
19
|
menuItemStyles.useVariants({
|
|
20
20
|
size,
|
|
21
|
+
intent: item.intent || 'neutral',
|
|
21
22
|
disabled: Boolean(item.disabled),
|
|
22
23
|
});
|
|
23
24
|
|
|
@@ -54,22 +55,10 @@ const MenuItem = forwardRef<IdealystElement, MenuItemProps>(({ item, onPress, si
|
|
|
54
55
|
// Merge refs
|
|
55
56
|
const mergedRef = useMergeRefs(ref, itemProps.ref);
|
|
56
57
|
|
|
57
|
-
// Button reset styles that must be applied directly
|
|
58
|
-
const buttonResetStyles: React.CSSProperties = {
|
|
59
|
-
display: 'flex',
|
|
60
|
-
width: '100%',
|
|
61
|
-
border: 'none',
|
|
62
|
-
outline: 'none',
|
|
63
|
-
cursor: item.disabled ? 'not-allowed' : 'pointer',
|
|
64
|
-
background: 'transparent',
|
|
65
|
-
textAlign: 'left',
|
|
66
|
-
};
|
|
67
|
-
|
|
68
58
|
return (
|
|
69
59
|
<button
|
|
70
60
|
{...itemProps}
|
|
71
61
|
ref={mergedRef}
|
|
72
|
-
style={buttonResetStyles}
|
|
73
62
|
onClick={(e: React.MouseEvent) => {
|
|
74
63
|
e.preventDefault();
|
|
75
64
|
e.stopPropagation();
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import React, { forwardRef, useMemo, ReactNode } from 'react';
|
|
2
|
-
import { View, ScrollView, Text, TouchableOpacity } from 'react-native';
|
|
1
|
+
import React, { forwardRef, useMemo, useState, useCallback, ReactNode } from 'react';
|
|
2
|
+
import { View, ScrollView, Text, TouchableOpacity, Pressable } from 'react-native';
|
|
3
3
|
import { tableStyles } from './Table.styles';
|
|
4
|
-
import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant } from './types';
|
|
4
|
+
import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant, SortDirection } from './types';
|
|
5
|
+
import type { MenuItem } from '../Menu/types';
|
|
5
6
|
import { getNativeAccessibilityProps } from '../utils/accessibility';
|
|
7
|
+
import Icon from '../Icon/Icon.native';
|
|
8
|
+
import Menu from '../Menu/Menu.native';
|
|
6
9
|
|
|
7
10
|
// ============================================================================
|
|
8
11
|
// Sub-component Props
|
|
@@ -25,6 +28,10 @@ interface THProps {
|
|
|
25
28
|
type?: TableType;
|
|
26
29
|
align?: TableAlignVariant;
|
|
27
30
|
width?: number | string;
|
|
31
|
+
sortable?: boolean;
|
|
32
|
+
sortDirection?: SortDirection;
|
|
33
|
+
onSort?: () => void;
|
|
34
|
+
options?: MenuItem[];
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
interface TDProps {
|
|
@@ -83,29 +90,72 @@ function TH({
|
|
|
83
90
|
type = 'standard',
|
|
84
91
|
align = 'left',
|
|
85
92
|
width,
|
|
93
|
+
sortable,
|
|
94
|
+
sortDirection,
|
|
95
|
+
onSort,
|
|
96
|
+
options,
|
|
86
97
|
}: THProps) {
|
|
98
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
99
|
+
|
|
87
100
|
tableStyles.useVariants({
|
|
88
101
|
size,
|
|
89
102
|
type,
|
|
90
103
|
align,
|
|
104
|
+
sortable: !!sortable,
|
|
105
|
+
sortActive: sortDirection != null,
|
|
91
106
|
});
|
|
92
107
|
|
|
93
108
|
const headerCellStyle = (tableStyles.headerCell as any)({});
|
|
109
|
+
const sortIndicatorStyle = (tableStyles.sortIndicator as any)({ sortActive: sortDirection != null });
|
|
110
|
+
const optionsButtonStyle = (tableStyles.optionsButton as any)({});
|
|
94
111
|
|
|
95
|
-
|
|
112
|
+
const sortIconName = sortDirection === 'asc' ? 'arrow-up' :
|
|
113
|
+
sortDirection === 'desc' ? 'arrow-down' : 'arrow-up-down';
|
|
114
|
+
|
|
115
|
+
const content = (
|
|
96
116
|
<View
|
|
97
117
|
style={[
|
|
98
118
|
headerCellStyle,
|
|
99
119
|
{ width, flex: width ? undefined : 1 },
|
|
100
120
|
]}
|
|
101
121
|
>
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
122
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', flex: 1, gap: 2 }}>
|
|
123
|
+
{typeof children === 'string' ? (
|
|
124
|
+
<Text style={headerCellStyle}>{children}</Text>
|
|
125
|
+
) : (
|
|
126
|
+
children
|
|
127
|
+
)}
|
|
128
|
+
{sortable && (
|
|
129
|
+
<View style={sortIndicatorStyle}>
|
|
130
|
+
<Icon name={sortIconName} size={size} />
|
|
131
|
+
</View>
|
|
132
|
+
)}
|
|
133
|
+
</View>
|
|
134
|
+
{options && options.length > 0 && (
|
|
135
|
+
<Menu
|
|
136
|
+
items={options}
|
|
137
|
+
open={menuOpen}
|
|
138
|
+
onOpenChange={setMenuOpen}
|
|
139
|
+
placement="bottom-start"
|
|
140
|
+
size={size}
|
|
141
|
+
>
|
|
142
|
+
<Pressable style={optionsButtonStyle}>
|
|
143
|
+
<Icon name="dots-vertical" size={size} />
|
|
144
|
+
</Pressable>
|
|
145
|
+
</Menu>
|
|
106
146
|
)}
|
|
107
147
|
</View>
|
|
108
148
|
);
|
|
149
|
+
|
|
150
|
+
if (sortable) {
|
|
151
|
+
return (
|
|
152
|
+
<Pressable onPress={onSort}>
|
|
153
|
+
{content}
|
|
154
|
+
</Pressable>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return content;
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
// ============================================================================
|
|
@@ -193,6 +243,7 @@ function TableInner<T = any>({
|
|
|
193
243
|
size = 'md',
|
|
194
244
|
stickyHeader: _stickyHeader = false,
|
|
195
245
|
onRowPress,
|
|
246
|
+
onSort,
|
|
196
247
|
dividers = false,
|
|
197
248
|
emptyState,
|
|
198
249
|
// Spacing variants from ContainerStyleProps
|
|
@@ -212,6 +263,26 @@ function TableInner<T = any>({
|
|
|
212
263
|
accessibilityRole,
|
|
213
264
|
accessibilityHidden,
|
|
214
265
|
}: TableProps<T>, ref: React.Ref<ScrollView>) {
|
|
266
|
+
// Sort state
|
|
267
|
+
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
268
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
269
|
+
|
|
270
|
+
const handleSort = useCallback((columnKey: string) => {
|
|
271
|
+
let newDir: SortDirection;
|
|
272
|
+
if (sortColumn !== columnKey) {
|
|
273
|
+
newDir = 'asc';
|
|
274
|
+
} else if (sortDirection === 'asc') {
|
|
275
|
+
newDir = 'desc';
|
|
276
|
+
} else {
|
|
277
|
+
setSortColumn(null);
|
|
278
|
+
setSortDirection(null);
|
|
279
|
+
onSort?.(columnKey, null);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
setSortColumn(columnKey);
|
|
283
|
+
setSortDirection(newDir);
|
|
284
|
+
onSort?.(columnKey, newDir);
|
|
285
|
+
}, [sortColumn, sortDirection, onSort]);
|
|
215
286
|
// Generate native accessibility props
|
|
216
287
|
const nativeA11yProps = useMemo(() => {
|
|
217
288
|
return getNativeAccessibilityProps({
|
|
@@ -276,7 +347,17 @@ function TableInner<T = any>({
|
|
|
276
347
|
<View style={theadStyle}>
|
|
277
348
|
<View style={{ flexDirection: 'row' }}>
|
|
278
349
|
{cols.map((column) => (
|
|
279
|
-
<TH
|
|
350
|
+
<TH
|
|
351
|
+
key={column.key}
|
|
352
|
+
size={size}
|
|
353
|
+
type={type}
|
|
354
|
+
align={column.align}
|
|
355
|
+
width={column.width}
|
|
356
|
+
sortable={column.sortable}
|
|
357
|
+
sortDirection={sortColumn === column.key ? sortDirection : undefined}
|
|
358
|
+
onSort={column.sortable ? () => handleSort(column.key) : undefined}
|
|
359
|
+
options={column.options}
|
|
360
|
+
>
|
|
280
361
|
{column.title}
|
|
281
362
|
</TH>
|
|
282
363
|
))}
|
|
@@ -23,6 +23,8 @@ export type TableDynamicProps = {
|
|
|
23
23
|
even?: boolean;
|
|
24
24
|
sticky?: boolean;
|
|
25
25
|
align?: CellAlign;
|
|
26
|
+
sortable?: boolean;
|
|
27
|
+
sortActive?: boolean;
|
|
26
28
|
gap?: ViewStyleSize;
|
|
27
29
|
padding?: ViewStyleSize;
|
|
28
30
|
paddingVertical?: ViewStyleSize;
|
|
@@ -178,6 +180,18 @@ export const tableStyles = defineStyle('Table', (theme: Theme) => ({
|
|
|
178
180
|
fontSize: theme.sizes.$table.fontSize,
|
|
179
181
|
lineHeight: theme.sizes.$table.lineHeight,
|
|
180
182
|
},
|
|
183
|
+
sortable: {
|
|
184
|
+
true: {
|
|
185
|
+
_web: {
|
|
186
|
+
cursor: 'pointer',
|
|
187
|
+
userSelect: 'none',
|
|
188
|
+
transition: 'background-color 0.15s ease',
|
|
189
|
+
_hover: {
|
|
190
|
+
backgroundColor: theme.colors.surface.hover,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
181
195
|
},
|
|
182
196
|
_web: {
|
|
183
197
|
position: 'relative',
|
|
@@ -185,6 +199,53 @@ export const tableStyles = defineStyle('Table', (theme: Theme) => ({
|
|
|
185
199
|
},
|
|
186
200
|
}),
|
|
187
201
|
|
|
202
|
+
sortIndicator: ({ sortActive = false }: TableDynamicProps) => ({
|
|
203
|
+
display: 'flex' as const,
|
|
204
|
+
alignItems: 'center' as const,
|
|
205
|
+
justifyContent: 'center' as const,
|
|
206
|
+
marginLeft: 4,
|
|
207
|
+
opacity: sortActive ? 1 : 0.4,
|
|
208
|
+
color: sortActive ? theme.colors.text.primary : theme.colors.text.tertiary,
|
|
209
|
+
flexShrink: 0,
|
|
210
|
+
_web: {
|
|
211
|
+
transition: 'opacity 0.15s ease, color 0.15s ease',
|
|
212
|
+
},
|
|
213
|
+
variants: {
|
|
214
|
+
size: {
|
|
215
|
+
width: theme.sizes.$table.fontSize,
|
|
216
|
+
height: theme.sizes.$table.fontSize,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
optionsButton: (_props: TableDynamicProps) => ({
|
|
222
|
+
display: 'flex' as const,
|
|
223
|
+
alignItems: 'center' as const,
|
|
224
|
+
justifyContent: 'center' as const,
|
|
225
|
+
marginLeft: 4,
|
|
226
|
+
borderRadius: 4,
|
|
227
|
+
padding: 2,
|
|
228
|
+
opacity: 0.4,
|
|
229
|
+
flexShrink: 0,
|
|
230
|
+
color: theme.colors.text.tertiary,
|
|
231
|
+
_web: {
|
|
232
|
+
cursor: 'pointer',
|
|
233
|
+
border: 'none',
|
|
234
|
+
background: 'transparent',
|
|
235
|
+
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
|
236
|
+
_hover: {
|
|
237
|
+
opacity: 1,
|
|
238
|
+
backgroundColor: theme.colors.surface.hover,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
variants: {
|
|
242
|
+
size: {
|
|
243
|
+
width: theme.sizes.$table.fontSize,
|
|
244
|
+
height: theme.sizes.$table.fontSize,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
|
|
188
249
|
cell: (_props: TableDynamicProps) => ({
|
|
189
250
|
flexDirection: 'row' as const,
|
|
190
251
|
alignItems: 'center' as const,
|
package/src/Table/Table.web.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useMemo, useRef, useCallback, ReactNode } from 'react';
|
|
1
|
+
import { useMemo, useRef, useCallback, useState, ReactNode } from 'react';
|
|
2
2
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
3
|
import { tableStyles } from './Table.styles';
|
|
4
|
-
import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant } from './types';
|
|
4
|
+
import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant, SortDirection } from './types';
|
|
5
|
+
import type { MenuItem } from '../Menu/types';
|
|
5
6
|
import { getWebAriaProps } from '../utils/accessibility';
|
|
7
|
+
import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
|
|
8
|
+
import Menu from '../Menu/Menu.web';
|
|
6
9
|
|
|
7
10
|
// ============================================================================
|
|
8
11
|
// Helpers
|
|
@@ -49,6 +52,10 @@ interface THProps {
|
|
|
49
52
|
onResize?: (width: number) => void;
|
|
50
53
|
minWidth?: number;
|
|
51
54
|
accessibilitySort?: 'ascending' | 'descending' | 'none' | 'other';
|
|
55
|
+
sortable?: boolean;
|
|
56
|
+
sortDirection?: SortDirection;
|
|
57
|
+
onSort?: () => void;
|
|
58
|
+
options?: MenuItem[];
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
interface TDProps {
|
|
@@ -110,16 +117,37 @@ function TH({
|
|
|
110
117
|
onResize,
|
|
111
118
|
minWidth = 50,
|
|
112
119
|
accessibilitySort,
|
|
120
|
+
sortable,
|
|
121
|
+
sortDirection,
|
|
122
|
+
onSort,
|
|
123
|
+
options,
|
|
113
124
|
}: THProps) {
|
|
125
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
126
|
+
|
|
114
127
|
tableStyles.useVariants({
|
|
115
128
|
size,
|
|
116
129
|
type,
|
|
117
130
|
align,
|
|
131
|
+
sortable: !!sortable,
|
|
132
|
+
sortActive: sortDirection != null,
|
|
118
133
|
});
|
|
119
134
|
|
|
120
135
|
const headerCellProps = getWebProps([(tableStyles.headerCell as any)({})]);
|
|
136
|
+
const sortIndicatorProps = getWebProps([(tableStyles.sortIndicator as any)({ sortActive: sortDirection != null })]);
|
|
137
|
+
const optionsButtonProps = getWebProps([(tableStyles.optionsButton as any)({})]);
|
|
121
138
|
const thRef = useRef<HTMLTableCellElement>(null);
|
|
122
139
|
|
|
140
|
+
// Derive aria-sort from sortDirection
|
|
141
|
+
const derivedAriaSort = accessibilitySort ?? (
|
|
142
|
+
sortDirection === 'asc' ? 'ascending' :
|
|
143
|
+
sortDirection === 'desc' ? 'descending' :
|
|
144
|
+
sortable ? 'none' : undefined
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Sort indicator icon name
|
|
148
|
+
const sortIconName = sortDirection === 'asc' ? 'arrow-up' :
|
|
149
|
+
sortDirection === 'desc' ? 'arrow-down' : 'arrow-up-down';
|
|
150
|
+
|
|
123
151
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
124
152
|
e.preventDefault();
|
|
125
153
|
e.stopPropagation();
|
|
@@ -139,12 +167,10 @@ function TH({
|
|
|
139
167
|
document.removeEventListener('pointerup', handlePointerUp);
|
|
140
168
|
const finalWidth = th.getBoundingClientRect().width;
|
|
141
169
|
onResize?.(finalWidth);
|
|
142
|
-
// Remove inline cursor override
|
|
143
170
|
document.body.style.cursor = '';
|
|
144
171
|
document.body.style.userSelect = '';
|
|
145
172
|
};
|
|
146
173
|
|
|
147
|
-
// Prevent text selection and set resize cursor globally during drag
|
|
148
174
|
document.body.style.cursor = 'col-resize';
|
|
149
175
|
document.body.style.userSelect = 'none';
|
|
150
176
|
document.addEventListener('pointermove', handlePointerMove);
|
|
@@ -156,10 +182,47 @@ function TH({
|
|
|
156
182
|
{...headerCellProps}
|
|
157
183
|
ref={thRef}
|
|
158
184
|
scope="col"
|
|
159
|
-
aria-sort={
|
|
185
|
+
aria-sort={derivedAriaSort}
|
|
160
186
|
style={{ width, ...getStickyStyle(sticky, stickyOffset, 11) }}
|
|
187
|
+
onClick={sortable ? onSort : undefined}
|
|
161
188
|
>
|
|
162
|
-
{
|
|
189
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
190
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
191
|
+
{children}
|
|
192
|
+
</span>
|
|
193
|
+
{sortable && (
|
|
194
|
+
<span {...sortIndicatorProps} style={{ display: 'inline-flex', flexShrink: 0 }}>
|
|
195
|
+
<IconSvg name={sortIconName} size="0.85em" aria-label={sortIconName} />
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
{options && options.length > 0 && (
|
|
199
|
+
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', flexShrink: 0 }}>
|
|
200
|
+
<Menu
|
|
201
|
+
items={options}
|
|
202
|
+
open={menuOpen}
|
|
203
|
+
onOpenChange={setMenuOpen}
|
|
204
|
+
placement="bottom-start"
|
|
205
|
+
size={size}
|
|
206
|
+
>
|
|
207
|
+
<button
|
|
208
|
+
{...optionsButtonProps}
|
|
209
|
+
style={{
|
|
210
|
+
background: 'transparent',
|
|
211
|
+
border: 'none',
|
|
212
|
+
cursor: 'pointer',
|
|
213
|
+
display: 'inline-flex',
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
padding: 2,
|
|
217
|
+
}}
|
|
218
|
+
aria-label="Column options"
|
|
219
|
+
>
|
|
220
|
+
<IconSvg name="dots-vertical" size="0.85em" aria-label="dots-vertical" />
|
|
221
|
+
</button>
|
|
222
|
+
</Menu>
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
</span>
|
|
163
226
|
{resizable && (
|
|
164
227
|
<span
|
|
165
228
|
onPointerDown={handlePointerDown}
|
|
@@ -266,6 +329,7 @@ function Table<T = any>({
|
|
|
266
329
|
stickyHeader = false,
|
|
267
330
|
onRowPress,
|
|
268
331
|
onColumnResize,
|
|
332
|
+
onSort,
|
|
269
333
|
dividers = false,
|
|
270
334
|
emptyState,
|
|
271
335
|
// Spacing variants from ContainerStyleProps
|
|
@@ -285,6 +349,26 @@ function Table<T = any>({
|
|
|
285
349
|
accessibilityRole,
|
|
286
350
|
accessibilityHidden,
|
|
287
351
|
}: TableProps<T>) {
|
|
352
|
+
// Sort state
|
|
353
|
+
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
354
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
355
|
+
|
|
356
|
+
const handleSort = useCallback((columnKey: string) => {
|
|
357
|
+
let newDir: SortDirection;
|
|
358
|
+
if (sortColumn !== columnKey) {
|
|
359
|
+
newDir = 'asc';
|
|
360
|
+
} else if (sortDirection === 'asc') {
|
|
361
|
+
newDir = 'desc';
|
|
362
|
+
} else {
|
|
363
|
+
setSortColumn(null);
|
|
364
|
+
setSortDirection(null);
|
|
365
|
+
onSort?.(columnKey, null);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
setSortColumn(columnKey);
|
|
369
|
+
setSortDirection(newDir);
|
|
370
|
+
onSort?.(columnKey, newDir);
|
|
371
|
+
}, [sortColumn, sortDirection, onSort]);
|
|
288
372
|
// Generate ARIA props
|
|
289
373
|
const ariaProps = useMemo(() => {
|
|
290
374
|
return getWebAriaProps({
|
|
@@ -378,6 +462,10 @@ function Table<T = any>({
|
|
|
378
462
|
minWidth={column.minWidth}
|
|
379
463
|
onResize={onColumnResize ? (w) => onColumnResize(column.key, w) : undefined}
|
|
380
464
|
accessibilitySort={column.accessibilitySort}
|
|
465
|
+
sortable={column.sortable}
|
|
466
|
+
sortDirection={sortColumn === column.key ? sortDirection : undefined}
|
|
467
|
+
onSort={column.sortable ? () => handleSort(column.key) : undefined}
|
|
468
|
+
options={column.options}
|
|
381
469
|
>
|
|
382
470
|
{column.title}
|
|
383
471
|
</TH>
|
package/src/Table/types.ts
CHANGED
|
@@ -3,11 +3,13 @@ import type { ReactNode } from 'react';
|
|
|
3
3
|
import { Size } from '@idealyst/theme';
|
|
4
4
|
import { ContainerStyleProps } from '../utils/viewStyleProps';
|
|
5
5
|
import { AccessibilityProps, SortableAccessibilityProps } from '../utils/accessibility';
|
|
6
|
+
import type { MenuItem } from '../Menu/types';
|
|
6
7
|
|
|
7
8
|
// Component-specific type aliases for future extensibility
|
|
8
9
|
export type TableSizeVariant = Size;
|
|
9
10
|
export type TableType = 'standard' | 'striped';
|
|
10
11
|
export type TableAlignVariant = 'left' | 'center' | 'right';
|
|
12
|
+
export type SortDirection = 'asc' | 'desc' | null;
|
|
11
13
|
|
|
12
14
|
export interface TableColumn<T = any> extends SortableAccessibilityProps {
|
|
13
15
|
key: string;
|
|
@@ -32,6 +34,16 @@ export interface TableColumn<T = any> extends SortableAccessibilityProps {
|
|
|
32
34
|
* Minimum width when resizing (default: 50).
|
|
33
35
|
*/
|
|
34
36
|
minWidth?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Enables click-to-sort cycling on this column header.
|
|
39
|
+
* Cycles: unsorted → ascending → descending → unsorted.
|
|
40
|
+
*/
|
|
41
|
+
sortable?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Menu items to show in a column options dropdown.
|
|
44
|
+
* Uses the existing MenuItem type from the Menu component.
|
|
45
|
+
*/
|
|
46
|
+
options?: MenuItem[];
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/**
|
|
@@ -58,6 +70,11 @@ export interface TableProps<T = any> extends ContainerStyleProps, AccessibilityP
|
|
|
58
70
|
* Receives the column key and the new width in pixels.
|
|
59
71
|
*/
|
|
60
72
|
onColumnResize?: (key: string, width: number) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Called when sort state changes via header click.
|
|
75
|
+
* The Table manages sort state internally; the parent handles data ordering.
|
|
76
|
+
*/
|
|
77
|
+
onSort?: (columnKey: string, direction: SortDirection) => void;
|
|
61
78
|
/**
|
|
62
79
|
* Content to display when `data` is empty.
|
|
63
80
|
* Renders in place of the table body.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { Screen, View, Text, Badge, Button } from '@idealyst/components';
|
|
3
3
|
import Table from '../Table';
|
|
4
|
-
import type { TableColumn } from '../Table/types';
|
|
4
|
+
import type { TableColumn, SortDirection } from '../Table/types';
|
|
5
5
|
|
|
6
6
|
interface User {
|
|
7
7
|
id: number;
|
|
@@ -287,9 +287,102 @@ export const TableExamples: React.FC = () => {
|
|
|
287
287
|
No data to display
|
|
288
288
|
</Text>
|
|
289
289
|
</View>
|
|
290
|
+
|
|
291
|
+
<SortableTableExample />
|
|
292
|
+
<OptionsTableExample />
|
|
290
293
|
</View>
|
|
291
294
|
</Screen>
|
|
292
295
|
);
|
|
293
296
|
};
|
|
294
297
|
|
|
298
|
+
const SortableTableExample: React.FC = () => {
|
|
299
|
+
const [sortedProducts, setSortedProducts] = useState([
|
|
300
|
+
{ id: 1, name: 'Laptop', category: 'Electronics', price: 999.99, stock: 15 },
|
|
301
|
+
{ id: 2, name: 'Mouse', category: 'Electronics', price: 29.99, stock: 150 },
|
|
302
|
+
{ id: 3, name: 'Keyboard', category: 'Electronics', price: 79.99, stock: 75 },
|
|
303
|
+
{ id: 4, name: 'Monitor', category: 'Electronics', price: 299.99, stock: 30 },
|
|
304
|
+
{ id: 5, name: 'Desk', category: 'Furniture', price: 399.99, stock: 10 },
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
const handleSort = (columnKey: string, direction: SortDirection) => {
|
|
308
|
+
if (!direction) {
|
|
309
|
+
setSortedProducts([...sortedProducts]);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const sorted = [...sortedProducts].sort((a, b) => {
|
|
313
|
+
const aVal = (a as any)[columnKey];
|
|
314
|
+
const bVal = (b as any)[columnKey];
|
|
315
|
+
if (typeof aVal === 'number') return direction === 'asc' ? aVal - bVal : bVal - aVal;
|
|
316
|
+
return direction === 'asc'
|
|
317
|
+
? String(aVal).localeCompare(String(bVal))
|
|
318
|
+
: String(bVal).localeCompare(String(aVal));
|
|
319
|
+
});
|
|
320
|
+
setSortedProducts(sorted);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const columns: TableColumn<typeof sortedProducts[0]>[] = [
|
|
324
|
+
{ key: 'name', title: 'Product', dataIndex: 'name', sortable: true },
|
|
325
|
+
{ key: 'category', title: 'Category', dataIndex: 'category', width: '150px', sortable: true },
|
|
326
|
+
{ key: 'price', title: 'Price', dataIndex: 'price', width: '120px', align: 'right', sortable: true, render: (price: number) => `$${price.toFixed(2)}` },
|
|
327
|
+
{ key: 'stock', title: 'Stock', dataIndex: 'stock', width: '100px', align: 'center', sortable: true },
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<View gap="md">
|
|
332
|
+
<Text typography="h5">Sortable Table</Text>
|
|
333
|
+
<Table columns={columns} data={sortedProducts} onSort={handleSort} dividers />
|
|
334
|
+
<Text typography="caption" color="secondary">
|
|
335
|
+
Click column headers to cycle sort: unsorted, ascending, descending
|
|
336
|
+
</Text>
|
|
337
|
+
</View>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const OptionsTableExample: React.FC = () => {
|
|
342
|
+
const data = [
|
|
343
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin' },
|
|
344
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User' },
|
|
345
|
+
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'User' },
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
const columns: TableColumn<typeof data[0]>[] = [
|
|
349
|
+
{
|
|
350
|
+
key: 'name',
|
|
351
|
+
title: 'Name',
|
|
352
|
+
dataIndex: 'name',
|
|
353
|
+
sortable: true,
|
|
354
|
+
options: [
|
|
355
|
+
{ id: 'sort-asc', label: 'Sort A-Z', icon: 'sort-ascending', onClick: () => console.log('Sort A-Z') },
|
|
356
|
+
{ id: 'sort-desc', label: 'Sort Z-A', icon: 'sort-descending', onClick: () => console.log('Sort Z-A') },
|
|
357
|
+
{ id: 'sep', label: '', separator: true },
|
|
358
|
+
{ id: 'hide', label: 'Hide Column', icon: 'eye-off', onClick: () => console.log('Hide column') },
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
key: 'email',
|
|
363
|
+
title: 'Email',
|
|
364
|
+
dataIndex: 'email',
|
|
365
|
+
options: [
|
|
366
|
+
{ id: 'copy', label: 'Copy All Emails', icon: 'content-copy', onClick: () => console.log('Copy emails') },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
key: 'role',
|
|
371
|
+
title: 'Role',
|
|
372
|
+
dataIndex: 'role',
|
|
373
|
+
width: '120px',
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<View gap="md">
|
|
379
|
+
<Text typography="h5">Column Options Menu</Text>
|
|
380
|
+
<Table columns={columns} data={data} dividers />
|
|
381
|
+
<Text typography="caption" color="secondary">
|
|
382
|
+
Columns with options show a kebab menu icon in the header
|
|
383
|
+
</Text>
|
|
384
|
+
</View>
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
|
|
295
388
|
export default TableExamples;
|