@12-apps/ui 4.0.1 → 4.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/package.json +2 -1
- package/src/components/data-display/Alert/Alert.styles.tsx +146 -39
- package/src/components/data-display/Alert/Alert.tsx +3 -14
- package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
- package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
- package/src/components/data-display/DataViews/data-views-types.ts +10 -2
- package/src/components/form/Button/Button.styles.ts +25 -6
- package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
- package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
- package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
- package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
- package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
- package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
- package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
- package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
- package/src/components/form/CategorySelect/CategorySelect.md +134 -0
- package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
- package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
- package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
- package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
- package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
- package/src/components/form/CategorySelect/category-tree.ts +210 -0
- package/src/components/form/CategorySelect/index.ts +19 -0
- package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
- package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
- package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
- package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
- package/src/components/form/total-form/Fields/index.ts +2 -0
- package/src/components/form/total-form/index.ts +2 -2
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildCategoryGroups,
|
|
5
|
+
filterCategoryGroups,
|
|
6
|
+
flattenRows,
|
|
7
|
+
toggleCategoryLeaves,
|
|
8
|
+
toggleLeaf,
|
|
9
|
+
type CategoryRowRef,
|
|
10
|
+
} from './category-tree';
|
|
11
|
+
import type { CategoryGroup, CategorySelectOption } from './CategorySelect.types';
|
|
12
|
+
|
|
13
|
+
/** Which categories are unfolded, and the ways that set changes. */
|
|
14
|
+
interface ExpansionState {
|
|
15
|
+
isExpanded: (categoryId: string) => boolean;
|
|
16
|
+
toggleExpanded: (categoryId: string) => void;
|
|
17
|
+
setAllExpanded: (expanded: boolean) => void;
|
|
18
|
+
expandAll: (groups: CategoryGroup[]) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The expanded set.
|
|
23
|
+
*
|
|
24
|
+
* `searching` short-circuits the lookup instead of mutating the set: a query must
|
|
25
|
+
* reveal every hit, but clearing it has to restore whatever the admin had folded,
|
|
26
|
+
* which an overwrite would have destroyed.
|
|
27
|
+
*/
|
|
28
|
+
function useExpandedCategories(
|
|
29
|
+
allGroups: CategoryGroup[],
|
|
30
|
+
searching: boolean,
|
|
31
|
+
): ExpansionState {
|
|
32
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
|
|
33
|
+
|
|
34
|
+
const isExpanded = useCallback(
|
|
35
|
+
(categoryId: string) => searching || expanded.has(categoryId),
|
|
36
|
+
[searching, expanded],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const toggleExpanded = useCallback((categoryId: string) => {
|
|
40
|
+
setExpanded((current) => {
|
|
41
|
+
const next = new Set(current);
|
|
42
|
+
if (!next.delete(categoryId)) next.add(categoryId);
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const setAllExpanded = useCallback(
|
|
48
|
+
(shouldExpand: boolean) => {
|
|
49
|
+
setExpanded(shouldExpand ? new Set(allGroups.map((g) => g.category.id)) : new Set());
|
|
50
|
+
},
|
|
51
|
+
[allGroups],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const expandAll = useCallback((groups: CategoryGroup[]) => {
|
|
55
|
+
setExpanded(new Set(groups.map((group) => group.category.id)));
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
return { isExpanded, toggleExpanded, setAllExpanded, expandAll };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The pending selection and the two ways a row changes it. */
|
|
62
|
+
interface DraftState {
|
|
63
|
+
draft: ReadonlySet<string>;
|
|
64
|
+
setDraft: (next: ReadonlySet<string>) => void;
|
|
65
|
+
toggleCategory: (group: CategoryGroup) => void;
|
|
66
|
+
toggleSubcategory: (id: string) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function useDraftSelection(initial: readonly string[]): DraftState {
|
|
70
|
+
const [draft, setDraftState] = useState<ReadonlySet<string>>(() => new Set(initial));
|
|
71
|
+
|
|
72
|
+
const toggleCategory = useCallback((group: CategoryGroup) => {
|
|
73
|
+
setDraftState((current) => toggleCategoryLeaves(group, current));
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const toggleSubcategory = useCallback((id: string) => {
|
|
77
|
+
setDraftState((current) => toggleLeaf(id, current));
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const setDraft = useCallback((next: ReadonlySet<string>) => setDraftState(next), []);
|
|
81
|
+
|
|
82
|
+
return { draft, setDraft, toggleCategory, toggleSubcategory };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Everything the panel and trigger need to render and drive one select. */
|
|
86
|
+
export interface CategorySelectState extends ExpansionState, DraftState {
|
|
87
|
+
open: boolean;
|
|
88
|
+
query: string;
|
|
89
|
+
/** Groups after the search filter — what is actually on screen. */
|
|
90
|
+
visibleGroups: CategoryGroup[];
|
|
91
|
+
/** All groups, unfiltered — the basis for chips and "select all". */
|
|
92
|
+
allGroups: CategoryGroup[];
|
|
93
|
+
rows: CategoryRowRef[];
|
|
94
|
+
activeIndex: number;
|
|
95
|
+
setQuery: (next: string) => void;
|
|
96
|
+
setActiveIndex: (next: number) => void;
|
|
97
|
+
openPanel: () => void;
|
|
98
|
+
closePanel: () => void;
|
|
99
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface UseCategorySelectArgs {
|
|
103
|
+
options: CategorySelectOption[];
|
|
104
|
+
/** The applied selection to seed the draft from each time the panel opens. */
|
|
105
|
+
selected: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Panel state for one CategorySelect.
|
|
110
|
+
*
|
|
111
|
+
* The DRAFT is the load-bearing idea. Ticks land here, not on the caller's
|
|
112
|
+
* `value`, so the list behind the panel does not refetch on every click; Apply
|
|
113
|
+
* publishes, dismissing discards. Opening re-seeds the draft from the applied
|
|
114
|
+
* value, which is what makes "cancel" mean cancel.
|
|
115
|
+
*/
|
|
116
|
+
export function useCategorySelect({
|
|
117
|
+
options,
|
|
118
|
+
selected,
|
|
119
|
+
}: UseCategorySelectArgs): CategorySelectState {
|
|
120
|
+
const [open, setOpen] = useState(false);
|
|
121
|
+
const [query, setQueryState] = useState('');
|
|
122
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
123
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
124
|
+
|
|
125
|
+
const allGroups = useMemo(() => buildCategoryGroups(options), [options]);
|
|
126
|
+
const visibleGroups = useMemo(
|
|
127
|
+
() => filterCategoryGroups(allGroups, query),
|
|
128
|
+
[allGroups, query],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const expansion = useExpandedCategories(allGroups, query.trim().length > 0);
|
|
132
|
+
const selection = useDraftSelection(selected);
|
|
133
|
+
const rows = useMemo(
|
|
134
|
+
() => flattenRows(visibleGroups, expansion.isExpanded),
|
|
135
|
+
[visibleGroups, expansion.isExpanded],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const openPanel = useCallback(() => {
|
|
139
|
+
selection.setDraft(new Set(selected));
|
|
140
|
+
expansion.expandAll(allGroups);
|
|
141
|
+
setQueryState('');
|
|
142
|
+
setActiveIndex(-1);
|
|
143
|
+
setOpen(true);
|
|
144
|
+
}, [selected, allGroups, selection, expansion]);
|
|
145
|
+
|
|
146
|
+
const closePanel = useCallback(() => {
|
|
147
|
+
setOpen(false);
|
|
148
|
+
setQueryState('');
|
|
149
|
+
setActiveIndex(-1);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
const setQuery = useCallback((next: string) => {
|
|
153
|
+
setQueryState(next);
|
|
154
|
+
setActiveIndex(-1);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...expansion,
|
|
159
|
+
...selection,
|
|
160
|
+
open,
|
|
161
|
+
query,
|
|
162
|
+
visibleGroups,
|
|
163
|
+
allGroups,
|
|
164
|
+
rows,
|
|
165
|
+
activeIndex,
|
|
166
|
+
setQuery,
|
|
167
|
+
setActiveIndex,
|
|
168
|
+
openPanel,
|
|
169
|
+
closePanel,
|
|
170
|
+
triggerRef,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { CategorySelect } from '../../CategorySelect';
|
|
6
|
+
import type { CategorySelectOption } from '../../CategorySelect';
|
|
7
|
+
import { useFormContext } from '../FormContext';
|
|
8
|
+
|
|
9
|
+
/** Props for the `total-form` {@link CategoryField}. */
|
|
10
|
+
export interface CategoryFieldProps {
|
|
11
|
+
/** Field name; also the key into the form's values/errors. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** The categories to choose from, flat, each carrying its `parentId`. */
|
|
14
|
+
options: CategorySelectOption[];
|
|
15
|
+
/** Optional visible label rendered above the control. */
|
|
16
|
+
label?: string;
|
|
17
|
+
/** Placeholder shown while nothing is chosen. */
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** Lets a top-level category be chosen in its own right (default: `true`). */
|
|
20
|
+
allowParentSelection?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Category picker bound to {@link useFormContext}.
|
|
25
|
+
*
|
|
26
|
+
* The sibling {@link SelectField} flattens a category tree into one list, which
|
|
27
|
+
* is the thing the hierarchical picker exists to stop. Use this wherever the
|
|
28
|
+
* field's options nest.
|
|
29
|
+
*
|
|
30
|
+
* Form values are strings, so "nothing chosen" travels as `""` — mapped to and
|
|
31
|
+
* from the control's `null` here rather than making every caller do it.
|
|
32
|
+
*/
|
|
33
|
+
export function CategoryField({
|
|
34
|
+
name,
|
|
35
|
+
options,
|
|
36
|
+
label,
|
|
37
|
+
placeholder,
|
|
38
|
+
allowParentSelection = true,
|
|
39
|
+
}: CategoryFieldProps): React.ReactElement {
|
|
40
|
+
const { values, errors, setFieldValue } = useFormContext();
|
|
41
|
+
const error = errors[name];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<CategorySelect
|
|
45
|
+
mode="single"
|
|
46
|
+
fullWidth
|
|
47
|
+
label={label}
|
|
48
|
+
placeholder={placeholder}
|
|
49
|
+
options={options}
|
|
50
|
+
allowParentSelection={allowParentSelection}
|
|
51
|
+
value={values[name] ? values[name] : null}
|
|
52
|
+
error={error}
|
|
53
|
+
onChange={(next) => setFieldValue(name, next ?? '')}
|
|
54
|
+
dataTestId={`total-form-field-${name}`}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -8,5 +8,5 @@ export { SubmitButton } from './SubmitButton';
|
|
|
8
8
|
export type { SubmitButtonProps } from './SubmitButton';
|
|
9
9
|
|
|
10
10
|
export * as Fields from './Fields';
|
|
11
|
-
export { TextField, SelectField } from './Fields';
|
|
12
|
-
export type { TextFieldProps, SelectFieldProps } from './Fields';
|
|
11
|
+
export { CategoryField, TextField, SelectField } from './Fields';
|
|
12
|
+
export type { CategoryFieldProps, TextFieldProps, SelectFieldProps } from './Fields';
|