@12-apps/ui 4.0.2 → 5.0.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.
Files changed (27) hide show
  1. package/package.json +2 -1
  2. package/src/components/data-display/DataViews/data-views-category-pill.tsx +98 -0
  3. package/src/components/data-display/DataViews/data-views-filter-panel.tsx +11 -14
  4. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +8 -13
  5. package/src/components/data-display/DataViews/data-views-types.ts +10 -2
  6. package/src/components/form/CategorySelect/CategoryIcons.tsx +52 -0
  7. package/src/components/form/CategorySelect/CategoryPanel.tsx +155 -0
  8. package/src/components/form/CategorySelect/CategoryPanelFoot.tsx +94 -0
  9. package/src/components/form/CategorySelect/CategoryPanelHead.tsx +167 -0
  10. package/src/components/form/CategorySelect/CategoryPanelList.tsx +207 -0
  11. package/src/components/form/CategorySelect/CategoryPanelSurface.tsx +56 -0
  12. package/src/components/form/CategorySelect/CategoryQuickActions.tsx +69 -0
  13. package/src/components/form/CategorySelect/CategoryRows.tsx +211 -0
  14. package/src/components/form/CategorySelect/CategorySelect.md +134 -0
  15. package/src/components/form/CategorySelect/CategorySelect.styles.ts +354 -0
  16. package/src/components/form/CategorySelect/CategorySelect.tsx +183 -0
  17. package/src/components/form/CategorySelect/CategorySelect.types.ts +103 -0
  18. package/src/components/form/CategorySelect/CategoryTrigger.tsx +109 -0
  19. package/src/components/form/CategorySelect/category-keyboard.ts +136 -0
  20. package/src/components/form/CategorySelect/category-tree.ts +210 -0
  21. package/src/components/form/CategorySelect/index.ts +19 -0
  22. package/src/components/form/CategorySelect/useCategoryCommit.ts +101 -0
  23. package/src/components/form/CategorySelect/useCategoryKeyHandler.ts +66 -0
  24. package/src/components/form/CategorySelect/useCategorySelect.ts +172 -0
  25. package/src/components/form/total-form/Fields/CategoryField.tsx +57 -0
  26. package/src/components/form/total-form/Fields/index.ts +2 -0
  27. 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
+ }
@@ -1,3 +1,5 @@
1
+ export { CategoryField } from './CategoryField';
2
+ export type { CategoryFieldProps } from './CategoryField';
1
3
  export { CepField } from './CepField';
2
4
  export type { CepFieldProps, CepFills } from './CepField';
3
5
  export { TextField } from './TextField';
@@ -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';