@antadesign/anta 0.3.4 → 0.3.6
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/dist/anta_helpers.d.ts +10 -0
- package/dist/anta_helpers.js +8 -0
- package/dist/components/Calendar.d.ts +3 -2
- package/dist/components/Calendar.js +18 -7
- package/dist/components/Dialog.d.ts +92 -0
- package/dist/components/Dialog.js +71 -0
- package/dist/components/Input.d.ts +3 -0
- package/dist/components/InputAutocomplete.d.ts +69 -0
- package/dist/components/InputAutocomplete.js +95 -0
- package/dist/components/InputDate.d.ts +3 -2
- package/dist/components/InputDate.js +36 -91
- package/dist/components/InputDate.module.css +1 -1
- package/dist/components/InputTime.d.ts +105 -0
- package/dist/components/InputTime.js +110 -0
- package/dist/components/Menu.d.ts +9 -1
- package/dist/components/Menu.js +4 -1
- package/dist/components/MenuItem.d.ts +9 -1
- package/dist/components/MenuItem.js +2 -1
- package/dist/components/Select.d.ts +29 -8
- package/dist/components/Select.js +23 -30
- package/dist/components/Select.module.css +1 -1
- package/dist/components/SelectFaceted.d.ts +190 -0
- package/dist/components/SelectFaceted.js +332 -0
- package/dist/components/SelectFaceted.module.css +1 -0
- package/dist/components/Tabs.d.ts +6 -0
- package/dist/components/Tabs.js +3 -1
- package/dist/components/select-options.d.ts +14 -0
- package/dist/components/select-options.js +31 -0
- package/dist/elements/a-button.css +1 -1
- package/dist/elements/a-calendar.css +1 -1
- package/dist/elements/a-dialog.css +1 -0
- package/dist/elements/a-dialog.d.ts +26 -0
- package/dist/elements/a-dialog.js +386 -0
- package/dist/elements/a-expander.js +2 -2
- package/dist/elements/a-input-time.css +1 -0
- package/dist/elements/a-input-time.d.ts +27 -0
- package/dist/elements/a-input-time.js +857 -0
- package/dist/elements/a-input.css +1 -1
- package/dist/elements/a-input.js +7 -1
- package/dist/elements/a-menu-item.css +1 -1
- package/dist/elements/a-menu.d.ts +5 -2
- package/dist/elements/a-menu.js +42 -7
- package/dist/elements/a-tab.css +1 -1
- package/dist/elements/a-tabs.css +1 -1
- package/dist/elements/a-tooltip.d.ts +17 -0
- package/dist/elements/a-tooltip.js +29 -1
- package/dist/elements/index.d.ts +2 -0
- package/dist/elements/index.js +6 -0
- package/dist/general_types.d.ts +97 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/jsx-runtime.d.ts +3 -1
- package/dist/reset.css +1 -1
- package/dist/tokens.css +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "@antadesign/anta/jsx-runtime";
|
|
2
2
|
import { useState, useId } from "../jsx-runtime";
|
|
3
|
+
import { ISOLATE_HINT } from "../anta_helpers";
|
|
4
|
+
import { normalizeOpt, matchQueryRegex, matchesQuery, highlight } from "./select-options";
|
|
3
5
|
import { Input } from "./Input";
|
|
4
6
|
import { Icon } from "./Icon";
|
|
5
7
|
import { Menu } from "./Menu";
|
|
@@ -8,8 +10,6 @@ import { MenuGroup } from "./MenuGroup";
|
|
|
8
10
|
import { MenuSeparator } from "./MenuSeparator";
|
|
9
11
|
import { Tooltip } from "./Tooltip";
|
|
10
12
|
import styles from "./Select.module.css";
|
|
11
|
-
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/i.test(navigator.userAgent || "");
|
|
12
|
-
const normalize = (o) => typeof o === "string" ? { value: o, label: o } : o;
|
|
13
13
|
function optionsWithSelection(options, values) {
|
|
14
14
|
const set = new Set(values == null ? [] : Array.isArray(values) ? values : [values]);
|
|
15
15
|
const state = (on, total) => on === 0 ? "none" : on === total ? "all" : "some";
|
|
@@ -28,7 +28,7 @@ function optionsWithSelection(options, values) {
|
|
|
28
28
|
const total = children.reduce((n, c) => n + c.total, 0);
|
|
29
29
|
return { node: { ...g, options: children.map((c) => c.node), selectionState: state(on, total) }, on, total };
|
|
30
30
|
}
|
|
31
|
-
const o =
|
|
31
|
+
const o = normalizeOpt(raw);
|
|
32
32
|
const selected = set.has(o.value);
|
|
33
33
|
return { node: { ...o, selected }, on: selected ? 1 : 0, total: 1 };
|
|
34
34
|
});
|
|
@@ -44,6 +44,7 @@ const Select = (props) => {
|
|
|
44
44
|
onValueChange,
|
|
45
45
|
placeholder,
|
|
46
46
|
icon,
|
|
47
|
+
leading,
|
|
47
48
|
label,
|
|
48
49
|
hint,
|
|
49
50
|
size,
|
|
@@ -53,8 +54,10 @@ const Select = (props) => {
|
|
|
53
54
|
disabled,
|
|
54
55
|
toneSelected,
|
|
55
56
|
filter,
|
|
56
|
-
selectAll,
|
|
57
|
+
selectAll = true,
|
|
57
58
|
selectAllLabel = "Select all",
|
|
59
|
+
clearable,
|
|
60
|
+
clearLabel = "Clear",
|
|
58
61
|
renderOption,
|
|
59
62
|
renderIndicator,
|
|
60
63
|
renderTrigger,
|
|
@@ -82,7 +85,7 @@ const Select = (props) => {
|
|
|
82
85
|
if (typeof raw !== "string" && isSubmenu(raw)) collectLeaves(raw.submenu, raw.label, disabled2 || !!raw.disabled, out);
|
|
83
86
|
else if (typeof raw !== "string" && isGroup(raw)) collectLeaves(raw.options, raw.label, disabled2 || !!raw.disabled, out);
|
|
84
87
|
else {
|
|
85
|
-
const o =
|
|
88
|
+
const o = normalizeOpt(raw);
|
|
86
89
|
out.push({ opt: o, disabled: disabled2 || !!o.disabled, group });
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -98,28 +101,11 @@ const Select = (props) => {
|
|
|
98
101
|
const byValue = new Map(allLeaves.map((l) => [l.opt.value, l.opt]));
|
|
99
102
|
const filtering = filter !== void 0 && filter !== false;
|
|
100
103
|
const q = query.trim();
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const matches = (o) => typeof filter === "function" ? filter(o, q) : !queryRe || [o.value, o.label ?? "", o.hint ?? ""].some((s) => queryRe.test(s));
|
|
104
|
+
const queryRe = filtering && typeof filter !== "function" ? matchQueryRegex(q) : null;
|
|
105
|
+
const matches = (o) => typeof filter === "function" ? filter(o, q) : matchesQuery(o, queryRe);
|
|
104
106
|
const prune = typeof filter === "function" || filtering && !!q;
|
|
105
107
|
const visibleLeaves = prune ? allLeaves.filter((l) => matches(l.opt)) : allLeaves;
|
|
106
108
|
const flattening = filtering && !!q;
|
|
107
|
-
const highlight = (text) => {
|
|
108
|
-
if (!queryRe) return text;
|
|
109
|
-
const re = new RegExp(queryRe.source, "gi");
|
|
110
|
-
const out = [];
|
|
111
|
-
let last = 0;
|
|
112
|
-
let m;
|
|
113
|
-
while ((m = re.exec(text)) !== null) {
|
|
114
|
-
if (m.index > last) out.push(text.slice(last, m.index));
|
|
115
|
-
out.push(/* @__PURE__ */ jsx("b", { children: m[0] }, out.length));
|
|
116
|
-
last = m.index + m[0].length;
|
|
117
|
-
if (m[0].length === 0) re.lastIndex++;
|
|
118
|
-
}
|
|
119
|
-
if (out.length === 0) return text;
|
|
120
|
-
if (last < text.length) out.push(text.slice(last));
|
|
121
|
-
return out;
|
|
122
|
-
};
|
|
123
109
|
const selectedValues = Array.isArray(currentRaw) ? currentRaw : currentRaw != null ? [currentRaw] : [];
|
|
124
110
|
const isSelected = (v) => selectedValues.includes(v);
|
|
125
111
|
const selectedOptions = selectedValues.map((v) => byValue.get(v)).filter(Boolean);
|
|
@@ -158,12 +144,17 @@ const Select = (props) => {
|
|
|
158
144
|
if (!controlled) setInternal(next);
|
|
159
145
|
emit?.(next, { all: true, selected: !allSelected });
|
|
160
146
|
};
|
|
147
|
+
const clear = () => {
|
|
148
|
+
const next = multiple ? [] : void 0;
|
|
149
|
+
if (!controlled) setInternal(next);
|
|
150
|
+
emit?.(next, { all: true, selected: false });
|
|
151
|
+
};
|
|
161
152
|
const renderOptionRow = (o, disabled2) => {
|
|
162
153
|
const optState = { value: o.value, selected: isSelected(o.value), disabled: disabled2 };
|
|
163
154
|
const custom = renderOption?.(o, optState);
|
|
164
155
|
const customMark = renderIndicator?.(optState);
|
|
165
156
|
const ariaSelectable = !multiple && mark === "none" ? { role: "menuitemradio", "aria-checked": isSelected(o.value) ? "true" : "false" } : void 0;
|
|
166
|
-
const isolateHint = multiple && selectAll && !disabled2 ?
|
|
157
|
+
const isolateHint = multiple && selectAll && !disabled2 ? ISOLATE_HINT : void 0;
|
|
167
158
|
const tip = o.tooltip ?? isolateHint;
|
|
168
159
|
const hintOnly = o.tooltip == null && isolateHint != null;
|
|
169
160
|
return /* @__PURE__ */ jsxs(
|
|
@@ -173,8 +164,8 @@ const Select = (props) => {
|
|
|
173
164
|
selectionIndicator: menuItemIndicator,
|
|
174
165
|
...ariaSelectable,
|
|
175
166
|
indicator: customMark ?? void 0,
|
|
176
|
-
label: custom ? void 0 :
|
|
177
|
-
hint: custom ? void 0 :
|
|
167
|
+
label: custom ? void 0 : highlight(o.label ?? o.value, queryRe),
|
|
168
|
+
hint: custom ? void 0 : o.hint ? highlight(o.hint, queryRe) : o.hint,
|
|
178
169
|
icon: custom ? void 0 : o.icon,
|
|
179
170
|
tone: o.tone,
|
|
180
171
|
toneSelected,
|
|
@@ -219,7 +210,7 @@ const Select = (props) => {
|
|
|
219
210
|
if (inner.length) out.push(/* @__PURE__ */ jsx(MenuGroup, { label: raw.label, children: inner }, `grp-${i}-${raw.label}`));
|
|
220
211
|
return;
|
|
221
212
|
}
|
|
222
|
-
const o =
|
|
213
|
+
const o = normalizeOpt(raw);
|
|
223
214
|
if (typeof filter === "function" && !matches(o)) return;
|
|
224
215
|
out.push(renderOptionRow(o, disabled2 || !!o.disabled));
|
|
225
216
|
});
|
|
@@ -256,7 +247,7 @@ const Select = (props) => {
|
|
|
256
247
|
readOnly: true,
|
|
257
248
|
dimActions: true,
|
|
258
249
|
disabled,
|
|
259
|
-
leading: icon ? /* @__PURE__ */ jsx(Icon, { shape: icon }) : void 0,
|
|
250
|
+
leading: leading ?? (icon ? /* @__PURE__ */ jsx(Icon, { shape: icon }) : void 0),
|
|
260
251
|
size,
|
|
261
252
|
status,
|
|
262
253
|
statusIcon,
|
|
@@ -329,7 +320,9 @@ const Select = (props) => {
|
|
|
329
320
|
),
|
|
330
321
|
/* @__PURE__ */ jsx(MenuSeparator, {})
|
|
331
322
|
] }),
|
|
332
|
-
visibleLeaves.length === 0 ? renderEmpty?.({ query: q }) : flattening ? renderFlat() : renderTree(options, false)
|
|
323
|
+
visibleLeaves.length === 0 ? renderEmpty?.({ query: q }) : flattening ? renderFlat() : renderTree(options, false),
|
|
324
|
+
clearable && selectedValues.length > 0 && // Pinned in the footer so it never scrolls away in a long / filtered list.
|
|
325
|
+
/* @__PURE__ */ jsx("div", { slot: "footer", className: styles.footer, children: /* @__PURE__ */ jsx(MenuItem, { icon: "x", label: clearLabel, "data-menu-open": "", onSelect: clear }) })
|
|
333
326
|
]
|
|
334
327
|
}
|
|
335
328
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.chevron{margin-inline-end:7px;transition:transform .15s ease}.chevron.open{transform:rotate(180deg)}.filter{padding:4px 4px 0}
|
|
1
|
+
.chevron{margin-inline-end:7px;transition:transform .15s ease}.chevron.open{transform:rotate(180deg)}.filter{padding:4px 4px 0}.footer{padding:var(--menu-padding, 4px);border-top:1px solid var(--border-4)}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { BaseProps } from '../general_types';
|
|
2
|
+
import type { IconShape } from '../elements/a-icon.shapes';
|
|
3
|
+
import type { SelectItem, SelectOption } from './Select';
|
|
4
|
+
interface FacetBase {
|
|
5
|
+
/** The facet's namespace — the key its value is stored under in the value
|
|
6
|
+
* record, and what `onValueChange`'s `attrs.facet` reports. Unique across
|
|
7
|
+
* `facets`. */
|
|
8
|
+
key: string;
|
|
9
|
+
/** The facet's row label in the menu. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Leading icon on the facet's row. */
|
|
12
|
+
icon?: IconShape;
|
|
13
|
+
}
|
|
14
|
+
/** A per-facet option-list filter (like `Select`'s `filter`): `true` uses the
|
|
15
|
+
* built-in case-insensitive substring match on an option's value / label /
|
|
16
|
+
* hint; a function `(option, query) => boolean` does custom matching. */
|
|
17
|
+
export type FacetFilter = boolean | ((option: SelectOption, query: string) => boolean);
|
|
18
|
+
/** Pick **one** option. Value: the chosen option's `value` string (or
|
|
19
|
+
* `undefined` when cleared). Re-picking the selected option clears it. Options
|
|
20
|
+
* are `Select`'s `SelectItem`s — bare strings or `SelectOption`s carrying the
|
|
21
|
+
* same fields (`value`, `label`, `hint`, `icon`, `tone`, `disabled`). */
|
|
22
|
+
export interface SelectFacetSingle extends FacetBase {
|
|
23
|
+
kind: 'single';
|
|
24
|
+
/** The options — bare strings or `SelectOption`s (groups / submenus are
|
|
25
|
+
* flattened to their leaves). */
|
|
26
|
+
options: SelectItem[];
|
|
27
|
+
/** Add a search field atop this facet's flyout that filters its options. */
|
|
28
|
+
filter?: FacetFilter;
|
|
29
|
+
}
|
|
30
|
+
/** Pick **any number** of options. Value: an array of the chosen `value`
|
|
31
|
+
* strings (empty array clears the facet). Options are the same `SelectItem`s
|
|
32
|
+
* as `single`. */
|
|
33
|
+
export interface SelectFacetMultiple extends FacetBase {
|
|
34
|
+
kind: 'multiple';
|
|
35
|
+
/** The options — bare strings or `SelectOption`s (groups / submenus flattened). */
|
|
36
|
+
options: SelectItem[];
|
|
37
|
+
/** Add a search field atop this facet's flyout that filters its options. */
|
|
38
|
+
filter?: FacetFilter;
|
|
39
|
+
/** A "Select all" row that toggles every option. On by default — set `false`
|
|
40
|
+
* to drop it.
|
|
41
|
+
* @defaultValue true */
|
|
42
|
+
selectAll?: boolean;
|
|
43
|
+
/** Label for the `selectAll` row.
|
|
44
|
+
* @defaultValue Select all */
|
|
45
|
+
selectAllLabel?: string;
|
|
46
|
+
}
|
|
47
|
+
/** Free-form substring. Value: the typed string (or `undefined` when empty).
|
|
48
|
+
* Applies on Enter or blur — not on every keystroke. */
|
|
49
|
+
export interface SelectFacetText extends FacetBase {
|
|
50
|
+
kind: 'text';
|
|
51
|
+
/** Placeholder for the text field. */
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Context handed to a `custom` facet's `render`. */
|
|
55
|
+
export interface SelectFacetCustomContext<V> {
|
|
56
|
+
/** The facet's current value (`undefined` when unset). */
|
|
57
|
+
value: V | undefined;
|
|
58
|
+
/** Apply a new value for this facet — pass `undefined` to clear it. */
|
|
59
|
+
onChange: (next: V | undefined) => void;
|
|
60
|
+
/** Close the whole filter menu (e.g. after applying from a custom editor). */
|
|
61
|
+
close: () => void;
|
|
62
|
+
}
|
|
63
|
+
/** Bring your own editor and value type — the escape hatch for anything the
|
|
64
|
+
* built-in kinds don't cover, including object-shaped values (a date range, a
|
|
65
|
+
* numeric comparator, …). */
|
|
66
|
+
export interface SelectFacetCustom<V = unknown> extends FacetBase {
|
|
67
|
+
kind: 'custom';
|
|
68
|
+
/** Render the facet's editor inside its flyout. The returned node stays
|
|
69
|
+
* mounted while the menu is open. */
|
|
70
|
+
render: (ctx: SelectFacetCustomContext<V>) => React.ReactNode;
|
|
71
|
+
/** Reduce an active value to a short summary shown on the facet's row (a
|
|
72
|
+
* string or any node). Called only when the value is set. */
|
|
73
|
+
summary: (value: V) => React.ReactNode;
|
|
74
|
+
}
|
|
75
|
+
/** One facet (dimension) of the filter, discriminated by `kind`. */
|
|
76
|
+
export type SelectFacet = SelectFacetSingle | SelectFacetMultiple | SelectFacetText | SelectFacetCustom;
|
|
77
|
+
/** The filter value: a facet key → that facet's value. Per kind the value is a
|
|
78
|
+
* `string` (single / text), a `string[]` (multiple), or your own `V` (custom).
|
|
79
|
+
* A cleared facet is absent from the record (not a falsy entry). */
|
|
80
|
+
export type SelectFacetedValue = Record<string, unknown>;
|
|
81
|
+
/** Second argument to `onValueChange` — what changed. A single-facet edit
|
|
82
|
+
* carries `facet` / `kind` / the facet's new `value`; the "Clear all" row
|
|
83
|
+
* carries `all: true`. Narrow on `'all' in attrs`. */
|
|
84
|
+
export type SelectFacetedChangeAttrs = {
|
|
85
|
+
/** The facet key that changed. */
|
|
86
|
+
facet: string;
|
|
87
|
+
/** That facet's kind. */
|
|
88
|
+
kind: SelectFacet['kind'];
|
|
89
|
+
/** The facet's new value (`undefined` when the facet was cleared). */
|
|
90
|
+
value: unknown;
|
|
91
|
+
} | {
|
|
92
|
+
/** Marks the change as the "Clear all" row emptying every facet. */
|
|
93
|
+
all: true;
|
|
94
|
+
};
|
|
95
|
+
/** Snapshot passed to `renderTrigger` so a custom trigger can reflect the
|
|
96
|
+
* current filter and open state. */
|
|
97
|
+
export interface SelectFacetedTriggerState {
|
|
98
|
+
/** Whether the menu is open — use it for `aria-expanded` and a chevron. */
|
|
99
|
+
open: boolean;
|
|
100
|
+
/** The whole current value record. */
|
|
101
|
+
value: SelectFacetedValue;
|
|
102
|
+
/** Number of **active facets** (each facet with a value counts once,
|
|
103
|
+
* regardless of how many options it holds) — the default badge count. */
|
|
104
|
+
count: number;
|
|
105
|
+
/** Whether the whole control is disabled. */
|
|
106
|
+
disabled: boolean;
|
|
107
|
+
}
|
|
108
|
+
export interface SelectFacetedProps extends Omit<BaseProps, 'children'> {
|
|
109
|
+
/** The facets (dimensions) to filter across, each with its own editor kind. */
|
|
110
|
+
facets: SelectFacet[];
|
|
111
|
+
/** Controlled value — the facet-keyed record. When provided, the consumer
|
|
112
|
+
* owns state: a pick only *requests* a change via `onValueChange` (reject by
|
|
113
|
+
* not updating). Leave undefined for uncontrolled. */
|
|
114
|
+
value?: SelectFacetedValue;
|
|
115
|
+
/** Initial value for the uncontrolled case (the wrapper then owns it). */
|
|
116
|
+
defaultValue?: SelectFacetedValue;
|
|
117
|
+
/** Fires after any facet changes, with the new full value record and an
|
|
118
|
+
* attrs snapshot of what changed. */
|
|
119
|
+
onValueChange?: (value: SelectFacetedValue, attrs: SelectFacetedChangeAttrs) => void;
|
|
120
|
+
/** Default trigger's button label.
|
|
121
|
+
* @defaultValue Filter */
|
|
122
|
+
label?: string;
|
|
123
|
+
/** Default trigger's leading icon.
|
|
124
|
+
* @defaultValue filter */
|
|
125
|
+
icon?: IconShape;
|
|
126
|
+
/** Default trigger's button size.
|
|
127
|
+
* @defaultValue medium */
|
|
128
|
+
size?: 'small' | 'medium' | 'large';
|
|
129
|
+
/** Default trigger's button priority.
|
|
130
|
+
* @defaultValue secondary */
|
|
131
|
+
priority?: 'primary' | 'secondary';
|
|
132
|
+
/** Disable the whole control. */
|
|
133
|
+
disabled?: boolean;
|
|
134
|
+
/** Tone applied to a selected option row in the facet flyouts (label, selected
|
|
135
|
+
* tint, and the check / checkbox indicator). A named tone or a custom CSS
|
|
136
|
+
* colour, matching `Select`'s `toneSelected`. Defaults to a neutral selection. */
|
|
137
|
+
toneSelected?: 'neutral' | 'brand' | 'info' | 'success' | 'warning' | 'critical' | (string & {});
|
|
138
|
+
/** Add a single search field to the top of the root menu that flattens every
|
|
139
|
+
* `single` / `multiple` facet's options into one list and searches across them
|
|
140
|
+
* — typing "alice" surfaces it under both an Assignee and an Owner facet, each
|
|
141
|
+
* selectable in place. `text` / `custom` facets are reachable when the search is
|
|
142
|
+
* empty. Matching uses each facet's `filter` function if it has one, else the
|
|
143
|
+
* built-in substring match. */
|
|
144
|
+
searchable?: boolean;
|
|
145
|
+
/** Placeholder for the global search field.
|
|
146
|
+
* @defaultValue Filter… */
|
|
147
|
+
searchPlaceholder?: string;
|
|
148
|
+
/** Show the per-facet "Clear" row and the "Clear all" row.
|
|
149
|
+
* @defaultValue true */
|
|
150
|
+
clearable?: boolean;
|
|
151
|
+
/** Label for the "Clear all" row.
|
|
152
|
+
* @defaultValue Clear all */
|
|
153
|
+
clearAllLabel?: string;
|
|
154
|
+
/** Render your own trigger in place of the default `Button`. Receives a
|
|
155
|
+
* `SelectFacetedTriggerState`; **return exactly one focusable element** (the
|
|
156
|
+
* menu anchors to it as its previous sibling and opens it on click). Give it
|
|
157
|
+
* `aria-haspopup="menu"` plus `aria-expanded={state.open}`. The custom trigger
|
|
158
|
+
* owns its own styling — `className` / `style` / other props apply to the
|
|
159
|
+
* *default* trigger only, so put your own on the element you return. */
|
|
160
|
+
renderTrigger?: (state: SelectFacetedTriggerState) => React.ReactNode;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `<SelectFaceted>` — a faceted filter: one trigger opens a menu of facets
|
|
164
|
+
* (dimensions), each a submenu flyout with its own editor. Facet kinds:
|
|
165
|
+
* `'single'` (pick one), `'multiple'` (pick many), `'text'` (free-form
|
|
166
|
+
* substring, applied on Enter / blur), and `'custom'` (bring your own editor +
|
|
167
|
+
* value, including object-shaped values). The value is a
|
|
168
|
+
* `Record<facetKey, value>`, so the same option value under two facets stays
|
|
169
|
+
* distinct.
|
|
170
|
+
*
|
|
171
|
+
* Controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
|
|
172
|
+
* There is no `a-select-faceted` element — this wrapper is the coordinator.
|
|
173
|
+
*
|
|
174
|
+
* Requires `@antadesign/anta/elements` (client-side only).
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```tsx
|
|
178
|
+
* <SelectFaceted
|
|
179
|
+
* facets={[
|
|
180
|
+
* { key: 'assignee', label: 'Assignee', kind: 'multiple', options: people },
|
|
181
|
+
* { key: 'owner', label: 'Owner', kind: 'single', options: people },
|
|
182
|
+
* { key: 'title', label: 'Title', kind: 'text' },
|
|
183
|
+
* ]}
|
|
184
|
+
* value={filters}
|
|
185
|
+
* onValueChange={setFilters}
|
|
186
|
+
* />
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export declare const SelectFaceted: (props: SelectFacetedProps) => any;
|
|
190
|
+
export {};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Fragment, jsx, jsxs } from "@antadesign/anta/jsx-runtime";
|
|
2
|
+
import { useState, useMemo } from "../jsx-runtime";
|
|
3
|
+
import { nativeStateChange, ISOLATE_HINT } from "../anta_helpers";
|
|
4
|
+
import { normalizeOpt, matchQueryRegex, matchesQuery } from "./select-options";
|
|
5
|
+
import { Button } from "./Button";
|
|
6
|
+
import { Menu } from "./Menu";
|
|
7
|
+
import { MenuItem } from "./MenuItem";
|
|
8
|
+
import { MenuGroup } from "./MenuGroup";
|
|
9
|
+
import { MenuSeparator } from "./MenuSeparator";
|
|
10
|
+
import { Tag } from "./Tag";
|
|
11
|
+
import { Input } from "./Input";
|
|
12
|
+
import { Tooltip } from "./Tooltip";
|
|
13
|
+
import styles from "./SelectFaceted.module.css";
|
|
14
|
+
const leavesOf = (items) => {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const it of items) {
|
|
17
|
+
if (typeof it !== "string" && Array.isArray(it.submenu))
|
|
18
|
+
out.push(...leavesOf(it.submenu));
|
|
19
|
+
else if (typeof it !== "string" && Array.isArray(it.options))
|
|
20
|
+
out.push(...leavesOf(it.options));
|
|
21
|
+
else out.push(normalizeOpt(it));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
const isEmpty = (v) => v == null || v === "" || Array.isArray(v) && v.length === 0;
|
|
26
|
+
const defaultMatch = (o, query) => matchesQuery(o, matchQueryRegex(query));
|
|
27
|
+
const SelectFaceted = (props) => {
|
|
28
|
+
const {
|
|
29
|
+
facets,
|
|
30
|
+
value,
|
|
31
|
+
defaultValue,
|
|
32
|
+
onValueChange,
|
|
33
|
+
label = "Filter",
|
|
34
|
+
icon = "filter",
|
|
35
|
+
size,
|
|
36
|
+
priority = "secondary",
|
|
37
|
+
disabled,
|
|
38
|
+
toneSelected,
|
|
39
|
+
searchable,
|
|
40
|
+
searchPlaceholder = "Filter\u2026",
|
|
41
|
+
clearable = true,
|
|
42
|
+
clearAllLabel = "Clear all",
|
|
43
|
+
renderTrigger,
|
|
44
|
+
className,
|
|
45
|
+
style,
|
|
46
|
+
...rest
|
|
47
|
+
} = props;
|
|
48
|
+
const controlled = value !== void 0;
|
|
49
|
+
const [internal, setInternal] = useState(defaultValue ?? {});
|
|
50
|
+
const current = controlled ? value : internal;
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [drafts, setDrafts] = useState({});
|
|
53
|
+
const [seenText, setSeenText] = useState({});
|
|
54
|
+
{
|
|
55
|
+
let nextSeen = null;
|
|
56
|
+
let nextDrafts = drafts;
|
|
57
|
+
for (const f of facets) {
|
|
58
|
+
if (f.kind !== "text") continue;
|
|
59
|
+
if (seenText[f.key] !== current[f.key]) {
|
|
60
|
+
nextSeen = { ...nextSeen ?? seenText, [f.key]: current[f.key] };
|
|
61
|
+
nextDrafts = { ...nextDrafts, [f.key]: current[f.key] ?? "" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (nextSeen) {
|
|
65
|
+
setSeenText(nextSeen);
|
|
66
|
+
setDrafts(nextDrafts);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const [queries, setQueries] = useState({});
|
|
70
|
+
const [rootQuery, setRootQuery] = useState("");
|
|
71
|
+
const [activeIds, setActiveIds] = useState({});
|
|
72
|
+
const onActive = (key) => (e) => {
|
|
73
|
+
const id = nativeStateChange(e).detail?.id ?? null;
|
|
74
|
+
setActiveIds((s) => s[key] === id ? s : { ...s, [key]: id });
|
|
75
|
+
};
|
|
76
|
+
const activeCount = facets.reduce((n, f) => n + (isEmpty(current[f.key]) ? 0 : 1), 0);
|
|
77
|
+
const leavesByKey = useMemo(
|
|
78
|
+
() => new Map(
|
|
79
|
+
facets.map((f) => [
|
|
80
|
+
f.key,
|
|
81
|
+
f.kind === "single" || f.kind === "multiple" ? leavesOf(f.options) : []
|
|
82
|
+
])
|
|
83
|
+
),
|
|
84
|
+
[facets]
|
|
85
|
+
);
|
|
86
|
+
const commit = (next, attrs) => {
|
|
87
|
+
if (!controlled) setInternal(next);
|
|
88
|
+
onValueChange?.(next, attrs);
|
|
89
|
+
};
|
|
90
|
+
const setFacet = (facet, nextValue) => {
|
|
91
|
+
const cleared = isEmpty(nextValue);
|
|
92
|
+
const next = { ...current };
|
|
93
|
+
if (cleared) delete next[facet.key];
|
|
94
|
+
else next[facet.key] = nextValue;
|
|
95
|
+
commit(next, { facet: facet.key, kind: facet.kind, value: cleared ? void 0 : nextValue });
|
|
96
|
+
};
|
|
97
|
+
const clearAll = () => commit({}, { all: true });
|
|
98
|
+
const visibleLeavesOf = (facet) => {
|
|
99
|
+
const leaves = leavesByKey.get(facet.key) ?? [];
|
|
100
|
+
if (!facet.filter) return leaves;
|
|
101
|
+
const q = queries[facet.key] ?? "";
|
|
102
|
+
const match = typeof facet.filter === "function" ? facet.filter : defaultMatch;
|
|
103
|
+
return leaves.filter((o) => match(o, q));
|
|
104
|
+
};
|
|
105
|
+
const filterHeader = (facet) => facet.filter ? /* @__PURE__ */ jsx("div", { slot: "header", "data-menu-open": "", style: { padding: "4px" }, children: /* @__PURE__ */ jsx(
|
|
106
|
+
Input,
|
|
107
|
+
{
|
|
108
|
+
"data-menu-search": "",
|
|
109
|
+
size: "small",
|
|
110
|
+
value: queries[facet.key] ?? "",
|
|
111
|
+
placeholder: "Filter\u2026",
|
|
112
|
+
"aria-label": `Filter ${facet.label}`,
|
|
113
|
+
"aria-autocomplete": "list",
|
|
114
|
+
"aria-activedescendant": activeIds[facet.key] ?? void 0,
|
|
115
|
+
onInput: (e) => setQueries((s) => ({ ...s, [facet.key]: e.currentTarget.value }))
|
|
116
|
+
}
|
|
117
|
+
) }) : null;
|
|
118
|
+
const optionRow = (facet, opt, keyPrefix = "") => {
|
|
119
|
+
const shared = {
|
|
120
|
+
icon: opt.icon,
|
|
121
|
+
label: opt.label ?? opt.value,
|
|
122
|
+
hint: opt.hint,
|
|
123
|
+
tone: opt.tone,
|
|
124
|
+
toneSelected,
|
|
125
|
+
disabled: opt.disabled,
|
|
126
|
+
"data-menu-open": ""
|
|
127
|
+
};
|
|
128
|
+
if (facet.kind === "single") {
|
|
129
|
+
const cur = current[facet.key];
|
|
130
|
+
return /* @__PURE__ */ jsx(
|
|
131
|
+
MenuItem,
|
|
132
|
+
{
|
|
133
|
+
...shared,
|
|
134
|
+
selectionIndicator: "check",
|
|
135
|
+
selected: cur === opt.value,
|
|
136
|
+
onSelect: () => setFacet(facet, cur === opt.value ? void 0 : opt.value)
|
|
137
|
+
},
|
|
138
|
+
`${keyPrefix}${opt.value}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const arr = current[facet.key] ?? [];
|
|
142
|
+
const isolable = facet.selectAll !== false && !opt.disabled;
|
|
143
|
+
const tip = opt.tooltip ?? (isolable ? ISOLATE_HINT : void 0);
|
|
144
|
+
const hintOnly = opt.tooltip == null && isolable;
|
|
145
|
+
return /* @__PURE__ */ jsx(
|
|
146
|
+
MenuItem,
|
|
147
|
+
{
|
|
148
|
+
...shared,
|
|
149
|
+
selectionIndicator: "checkbox",
|
|
150
|
+
selected: arr.includes(opt.value),
|
|
151
|
+
onSelect: (e) => isolable && e?.altKey ? setFacet(facet, [opt.value]) : setFacet(facet, arr.includes(opt.value) ? arr.filter((v) => v !== opt.value) : [...arr, opt.value]),
|
|
152
|
+
children: tip && /* @__PURE__ */ jsx(Tooltip, { ...hintOnly ? { follow: true, delay: 700 } : {}, children: tip })
|
|
153
|
+
},
|
|
154
|
+
`${keyPrefix}${opt.value}`
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
const renderSingle = (facet) => visibleLeavesOf(facet).map((opt) => optionRow(facet, opt));
|
|
158
|
+
const renderMultiple = (facet) => {
|
|
159
|
+
const arr = current[facet.key] ?? [];
|
|
160
|
+
const leaves = visibleLeavesOf(facet);
|
|
161
|
+
const enabled = leaves.filter((o) => !o.disabled).map((o) => o.value);
|
|
162
|
+
const allOn = enabled.length > 0 && enabled.every((v) => arr.includes(v));
|
|
163
|
+
const someOn = enabled.some((v) => arr.includes(v));
|
|
164
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
165
|
+
facet.selectAll !== false && leaves.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
166
|
+
/* @__PURE__ */ jsx(
|
|
167
|
+
MenuItem,
|
|
168
|
+
{
|
|
169
|
+
selectionIndicator: "checkbox",
|
|
170
|
+
selected: allOn,
|
|
171
|
+
indeterminate: someOn && !allOn,
|
|
172
|
+
label: facet.selectAllLabel ?? "Select all",
|
|
173
|
+
"data-menu-open": "",
|
|
174
|
+
onSelect: () => setFacet(
|
|
175
|
+
facet,
|
|
176
|
+
// Toggle only the visible set, preserving picks hidden by the filter.
|
|
177
|
+
allOn ? arr.filter((v) => !enabled.includes(v)) : [.../* @__PURE__ */ new Set([...arr, ...enabled])]
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
),
|
|
181
|
+
/* @__PURE__ */ jsx(MenuSeparator, {})
|
|
182
|
+
] }),
|
|
183
|
+
leaves.map((opt) => optionRow(facet, opt))
|
|
184
|
+
] });
|
|
185
|
+
};
|
|
186
|
+
const renderFlatResults = () => {
|
|
187
|
+
const groups = facets.filter((f) => f.kind === "single" || f.kind === "multiple").map((facet) => {
|
|
188
|
+
const match = typeof facet.filter === "function" ? facet.filter : defaultMatch;
|
|
189
|
+
return { facet, matched: (leavesByKey.get(facet.key) ?? []).filter((o) => match(o, rootQuery)) };
|
|
190
|
+
}).filter((g) => g.matched.length > 0);
|
|
191
|
+
return groups.map(({ facet, matched }) => /* @__PURE__ */ jsx(MenuGroup, { label: facet.label, children: matched.map((opt) => optionRow(facet, opt, `${facet.key}:`)) }, facet.key));
|
|
192
|
+
};
|
|
193
|
+
const renderText = (facet) => {
|
|
194
|
+
const applied = current[facet.key] ?? "";
|
|
195
|
+
const draft = drafts[facet.key] ?? applied;
|
|
196
|
+
const apply = () => {
|
|
197
|
+
const next = draft.trim() || void 0;
|
|
198
|
+
if (next !== (applied || void 0)) setFacet(facet, next);
|
|
199
|
+
};
|
|
200
|
+
return (
|
|
201
|
+
// `data-menu-open` keeps the menu open through typing; the value commits on
|
|
202
|
+
// Enter / blur, not per keystroke.
|
|
203
|
+
/* @__PURE__ */ jsx("div", { "data-menu-open": "", style: { padding: "4px" }, children: /* @__PURE__ */ jsx(
|
|
204
|
+
Input,
|
|
205
|
+
{
|
|
206
|
+
size: "small",
|
|
207
|
+
value: draft,
|
|
208
|
+
placeholder: facet.placeholder,
|
|
209
|
+
"aria-label": facet.label,
|
|
210
|
+
onInput: (e) => setDrafts((d) => ({ ...d, [facet.key]: e.currentTarget.value })),
|
|
211
|
+
onKeyDown: (e) => {
|
|
212
|
+
if (e.key === "Enter") {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
apply();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
onBlur: apply
|
|
218
|
+
}
|
|
219
|
+
) })
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
const renderCustom = (facet) => /* @__PURE__ */ jsx("div", { "data-menu-open": "", children: facet.render({
|
|
223
|
+
value: current[facet.key],
|
|
224
|
+
onChange: (next) => setFacet(facet, next),
|
|
225
|
+
close: () => setOpen(false)
|
|
226
|
+
}) });
|
|
227
|
+
const renderEditor = (facet) => {
|
|
228
|
+
const isOptions = facet.kind === "single" || facet.kind === "multiple";
|
|
229
|
+
const body = facet.kind === "single" ? renderSingle(facet) : facet.kind === "multiple" ? renderMultiple(facet) : facet.kind === "text" ? renderText(facet) : renderCustom(facet);
|
|
230
|
+
const hasValue = !isEmpty(current[facet.key]);
|
|
231
|
+
return /* @__PURE__ */ jsxs(Menu, { onactivedescendant: isOptions && facet.filter ? onActive(facet.key) : void 0, children: [
|
|
232
|
+
isOptions && filterHeader(facet),
|
|
233
|
+
body,
|
|
234
|
+
clearable && hasValue && /* @__PURE__ */ jsx("div", { slot: "footer", className: styles.footer, children: /* @__PURE__ */ jsx(
|
|
235
|
+
MenuItem,
|
|
236
|
+
{
|
|
237
|
+
icon: "x",
|
|
238
|
+
label: "Clear",
|
|
239
|
+
"data-menu-open": "",
|
|
240
|
+
onSelect: () => setFacet(facet, void 0)
|
|
241
|
+
}
|
|
242
|
+
) })
|
|
243
|
+
] });
|
|
244
|
+
};
|
|
245
|
+
const summaryOf = (facet) => {
|
|
246
|
+
const v = current[facet.key];
|
|
247
|
+
if (isEmpty(v)) return null;
|
|
248
|
+
switch (facet.kind) {
|
|
249
|
+
case "single": {
|
|
250
|
+
const opt = (leavesByKey.get(facet.key) ?? []).find((o) => o.value === v);
|
|
251
|
+
return opt?.label ?? String(v);
|
|
252
|
+
}
|
|
253
|
+
case "multiple":
|
|
254
|
+
return String(v.length);
|
|
255
|
+
case "text":
|
|
256
|
+
return String(v);
|
|
257
|
+
case "custom":
|
|
258
|
+
return facet.summary(v);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const triggerState = {
|
|
262
|
+
open,
|
|
263
|
+
value: current,
|
|
264
|
+
count: activeCount,
|
|
265
|
+
disabled: !!disabled
|
|
266
|
+
};
|
|
267
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
268
|
+
renderTrigger ? renderTrigger(triggerState) : /* @__PURE__ */ jsx(
|
|
269
|
+
Button,
|
|
270
|
+
{
|
|
271
|
+
icon,
|
|
272
|
+
label,
|
|
273
|
+
priority,
|
|
274
|
+
size,
|
|
275
|
+
disabled,
|
|
276
|
+
"aria-haspopup": "menu",
|
|
277
|
+
"aria-expanded": open ? "true" : "false",
|
|
278
|
+
className,
|
|
279
|
+
style,
|
|
280
|
+
...rest,
|
|
281
|
+
children: activeCount > 0 && /* @__PURE__ */ jsx(Tag, { size: "small", priority: "primary", tone: "brand", children: String(activeCount) })
|
|
282
|
+
}
|
|
283
|
+
),
|
|
284
|
+
/* @__PURE__ */ jsxs(
|
|
285
|
+
Menu,
|
|
286
|
+
{
|
|
287
|
+
open,
|
|
288
|
+
onStateChange: (_e, { next }) => {
|
|
289
|
+
setOpen(next);
|
|
290
|
+
if (!next) setRootQuery("");
|
|
291
|
+
},
|
|
292
|
+
onactivedescendant: searchable ? onActive("__root__") : void 0,
|
|
293
|
+
children: [
|
|
294
|
+
searchable && // Pinned global search: flattens all options facets while a query is active.
|
|
295
|
+
/* @__PURE__ */ jsx("div", { slot: "header", "data-menu-open": "", className: styles.search, children: /* @__PURE__ */ jsx(
|
|
296
|
+
Input,
|
|
297
|
+
{
|
|
298
|
+
"data-menu-search": "",
|
|
299
|
+
size: "small",
|
|
300
|
+
value: rootQuery,
|
|
301
|
+
placeholder: searchPlaceholder,
|
|
302
|
+
"aria-label": "Filter all facets",
|
|
303
|
+
"aria-autocomplete": "list",
|
|
304
|
+
"aria-activedescendant": activeIds["__root__"] ?? void 0,
|
|
305
|
+
onInput: (e) => setRootQuery(e.currentTarget.value)
|
|
306
|
+
}
|
|
307
|
+
) }),
|
|
308
|
+
searchable && rootQuery.trim() ? renderFlatResults() : facets.map((facet) => /* @__PURE__ */ jsxs(MenuItem, { submenu: true, icon: facet.icon, label: facet.label, children: [
|
|
309
|
+
(() => {
|
|
310
|
+
const s = summaryOf(facet);
|
|
311
|
+
return s != null ? /* @__PURE__ */ jsx(Tag, { size: "small", tone: "brand", children: s }) : null;
|
|
312
|
+
})(),
|
|
313
|
+
renderEditor(facet)
|
|
314
|
+
] }, facet.key)),
|
|
315
|
+
clearable && /* @__PURE__ */ jsx("div", { slot: "footer", className: styles.footer, children: /* @__PURE__ */ jsx(
|
|
316
|
+
MenuItem,
|
|
317
|
+
{
|
|
318
|
+
icon: "filter-x",
|
|
319
|
+
label: clearAllLabel,
|
|
320
|
+
disabled: activeCount === 0,
|
|
321
|
+
"data-menu-open": "",
|
|
322
|
+
onSelect: clearAll
|
|
323
|
+
}
|
|
324
|
+
) })
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
] });
|
|
329
|
+
};
|
|
330
|
+
export {
|
|
331
|
+
SelectFaceted
|
|
332
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.search{padding:var(--menu-padding, 4px)}.footer{padding:var(--menu-padding, 4px);border-top:1px solid var(--border-4)}
|