@camstack/ui-library 1.2.113 → 1.2.114

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.
@@ -20,6 +20,12 @@ export interface DeviceSelectCandidate {
20
20
  readonly name: string;
21
21
  readonly location?: string | null;
22
22
  readonly features?: readonly string[];
23
+ /** Name of the container this device lives in, when it has one. */
24
+ readonly parentName?: string | null;
25
+ }
26
+ /** A picker option that still knows where its device is. */
27
+ export interface DeviceOption extends ConfigOption {
28
+ readonly location: string | null;
23
29
  }
24
30
  /**
25
31
  * Does a device satisfy the filter?
@@ -37,13 +43,33 @@ export declare function deviceOptionLabel(device: DeviceSelectCandidate): string
37
43
  * (value = the device id as a string). Sorted case-insensitively by label so
38
44
  * the picker order is stable.
39
45
  */
40
- export declare function devicesToOptions(devices: readonly DeviceSelectCandidate[], filter?: DeviceSelectFilter): ConfigOption[];
46
+ /** `Location · type · in Container` — the parts a name alone does not say. */
47
+ export declare function deviceOptionDescription(device: DeviceSelectCandidate): string;
48
+ export declare function devicesToOptions(devices: readonly DeviceSelectCandidate[], filter?: DeviceSelectFilter): DeviceOption[];
49
+ export interface DeviceFacets {
50
+ readonly locations: readonly string[];
51
+ readonly types: readonly string[];
52
+ }
53
+ /** The distinct locations and types on offer — the chips above the search. */
54
+ export declare function deviceFacets(devices: readonly DeviceSelectCandidate[]): DeviceFacets;
55
+ export interface FacetSelection {
56
+ readonly location?: string;
57
+ readonly type?: string;
58
+ }
59
+ /** Every chosen facet must match; an unchosen one matches everything. */
60
+ export declare function matchesFacets(device: DeviceSelectCandidate, facets: FacetSelection): boolean;
61
+ export interface SuggestedPartition<T extends DeviceOption> {
62
+ readonly suggested: readonly T[];
63
+ readonly others: readonly T[];
64
+ }
65
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
66
+ export declare function partitionSuggested<T extends DeviceOption>(options: readonly T[], location: string | null | undefined): SuggestedPartition<T>;
41
67
  /**
42
68
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
43
69
  * matched against the option's `value` + `label` (so a device is found by name,
44
70
  * id, or location). An empty query returns every option in order.
45
71
  */
46
- export declare function filterDeviceOptions(options: readonly ConfigOption[], query: string): ConfigOption[];
72
+ export declare function filterDeviceOptions<T extends ConfigOption>(options: readonly T[], query: string): T[];
47
73
  /**
48
74
  * Resolve the currently-selected values into options IN SELECTION ORDER (so
49
75
  * chips stay stable while searching). A selected value with no matching option
package/dist/index.cjs CHANGED
@@ -6903,6 +6903,7 @@ function intersects(a, b) {
6903
6903
  */
6904
6904
  function deviceMatchesFilter(device, filter) {
6905
6905
  if (filter?.excludeDeviceId !== void 0 && device.id === filter.excludeDeviceId) return false;
6906
+ if (filter?.excludeTypes !== void 0 && filter.excludeTypes.includes(device.type)) return false;
6906
6907
  const hasCaps = filter?.caps !== void 0 && filter.caps.length > 0;
6907
6908
  const hasTypes = filter?.types !== void 0 && filter.types.length > 0;
6908
6909
  if (!hasCaps && !hasTypes) return true;
@@ -6919,12 +6920,52 @@ function deviceOptionLabel(device) {
6919
6920
  * (value = the device id as a string). Sorted case-insensitively by label so
6920
6921
  * the picker order is stable.
6921
6922
  */
6923
+ /** `Location · type · in Container` — the parts a name alone does not say. */
6924
+ function deviceOptionDescription(device) {
6925
+ const parts = [];
6926
+ if (typeof device.location === "string" && device.location.length > 0) parts.push(device.location);
6927
+ parts.push(device.type);
6928
+ if (typeof device.parentName === "string" && device.parentName.length > 0) parts.push(`in ${device.parentName}`);
6929
+ return parts.join(" · ");
6930
+ }
6922
6931
  function devicesToOptions(devices, filter) {
6923
6932
  return devices.filter((d) => deviceMatchesFilter(d, filter)).map((d) => ({
6924
6933
  value: String(d.id),
6925
- label: deviceOptionLabel(d)
6934
+ label: deviceOptionLabel(d),
6935
+ description: deviceOptionDescription(d),
6936
+ location: typeof d.location === "string" && d.location.length > 0 ? d.location : null
6926
6937
  })).toSorted((a, b) => a.label.localeCompare(b.label));
6927
6938
  }
6939
+ /** The distinct locations and types on offer — the chips above the search. */
6940
+ function deviceFacets(devices) {
6941
+ const locations = /* @__PURE__ */ new Set();
6942
+ const types = /* @__PURE__ */ new Set();
6943
+ for (const d of devices) {
6944
+ if (typeof d.location === "string" && d.location.length > 0) locations.add(d.location);
6945
+ types.add(d.type);
6946
+ }
6947
+ return {
6948
+ locations: [...locations].toSorted((a, b) => a.localeCompare(b)),
6949
+ types: [...types].toSorted((a, b) => a.localeCompare(b))
6950
+ };
6951
+ }
6952
+ /** Every chosen facet must match; an unchosen one matches everything. */
6953
+ function matchesFacets(device, facets) {
6954
+ if (facets.location !== void 0 && device.location !== facets.location) return false;
6955
+ if (facets.type !== void 0 && device.type !== facets.type) return false;
6956
+ return true;
6957
+ }
6958
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
6959
+ function partitionSuggested(options, location) {
6960
+ if (location === null || location === void 0 || location.length === 0) return {
6961
+ suggested: [],
6962
+ others: [...options]
6963
+ };
6964
+ return {
6965
+ suggested: options.filter((o) => o.location === location),
6966
+ others: options.filter((o) => o.location !== location)
6967
+ };
6968
+ }
6928
6969
  /**
6929
6970
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
6930
6971
  * matched against the option's `value` + `label` (so a device is found by name,
@@ -6934,7 +6975,7 @@ function filterDeviceOptions(options, query) {
6934
6975
  const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
6935
6976
  if (tokens.length === 0) return [...options];
6936
6977
  return options.filter((opt) => {
6937
- const hay = `${opt.value} ${opt.label}`.toLowerCase();
6978
+ const hay = `${opt.value} ${opt.label} ${opt.description ?? ""}`.toLowerCase();
6938
6979
  return tokens.every((t) => hay.includes(t));
6939
6980
  });
6940
6981
  }
@@ -6973,21 +7014,61 @@ var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
6973
7014
  /** The compact projection of the listAll rows the device fields consume. */
6974
7015
  function toCandidates(rows) {
6975
7016
  if (!Array.isArray(rows)) return [];
7017
+ const nameById = /* @__PURE__ */ new Map();
7018
+ for (const row of rows) {
7019
+ if (typeof row !== "object" || row === null) continue;
7020
+ const r = row;
7021
+ if (typeof r.id === "number" && typeof r.name === "string") nameById.set(r.id, r.name);
7022
+ }
6976
7023
  const out = [];
6977
7024
  for (const row of rows) {
6978
7025
  if (typeof row !== "object" || row === null) continue;
6979
7026
  const r = row;
6980
7027
  if (typeof r.id !== "number" || typeof r.type !== "string" || typeof r.name !== "string") continue;
7028
+ const parentName = typeof r.parentDeviceId === "number" ? nameById.get(r.parentDeviceId) ?? null : null;
6981
7029
  out.push({
6982
7030
  id: r.id,
6983
7031
  type: r.type,
6984
7032
  name: r.name,
6985
7033
  location: typeof r.location === "string" ? r.location : null,
6986
- features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : []
7034
+ features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : [],
7035
+ parentName
6987
7036
  });
6988
7037
  }
6989
7038
  return out;
6990
7039
  }
7040
+ var FACET_CHIP = "rounded-full border px-1.5 py-0.5 text-[10px] leading-4 transition-colors cursor-pointer";
7041
+ var FACET_CHIP_ON = `${FACET_CHIP} border-primary bg-primary/10 text-primary`;
7042
+ var FACET_CHIP_OFF = `${FACET_CHIP} border-border text-foreground-subtle hover:bg-surface-hover`;
7043
+ var GROUP_HEADING = "px-2.5 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground-subtle";
7044
+ /** One row of facet chips: `All` plus every distinct value. */
7045
+ function FacetRow({ label, values, active, onPick }) {
7046
+ if (values.length < 2) return null;
7047
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7048
+ className: "flex flex-wrap items-center gap-1 mb-1",
7049
+ role: "group",
7050
+ "aria-label": label,
7051
+ children: [
7052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7053
+ className: "text-[10px] text-foreground-subtle mr-0.5",
7054
+ children: label
7055
+ }),
7056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7057
+ type: "button",
7058
+ className: active === void 0 ? FACET_CHIP_ON : FACET_CHIP_OFF,
7059
+ onClick: () => onPick(void 0),
7060
+ children: "All"
7061
+ }),
7062
+ values.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7063
+ type: "button",
7064
+ className: active === v ? FACET_CHIP_ON : FACET_CHIP_OFF,
7065
+ "aria-pressed": active === v,
7066
+ onClick: () => onPick(active === v ? void 0 : v),
7067
+ children: v
7068
+ }, v))
7069
+ ]
7070
+ });
7071
+ }
6991
7072
  function FieldShell({ label, description, required, translationFn, children }) {
6992
7073
  const resolvedLabel = resolveLabel(label, translationFn);
6993
7074
  const resolvedDescription = resolveLabel(description, translationFn);
@@ -7035,7 +7116,16 @@ function DeviceSelectField({ field, value, onChange, disabled, translationFn })
7035
7116
  }
7036
7117
  function DeviceMultiSelectField({ field, value, onChange, disabled, translationFn }) {
7037
7118
  const { data } = useDeviceManagerListAll({});
7038
- const options = (0, react$1.useMemo)(() => devicesToOptions(toCandidates(data), field.deviceFilter), [data, field.deviceFilter]);
7119
+ const smart = field.suggestLocation !== void 0;
7120
+ const [facets, setFacets] = (0, react$1.useState)({});
7121
+ const candidates = (0, react$1.useMemo)(() => toCandidates(data), [data]);
7122
+ const facetValues = (0, react$1.useMemo)(() => deviceFacets(candidates.filter((c) => c.id !== field.deviceFilter?.excludeDeviceId)), [candidates, field.deviceFilter?.excludeDeviceId]);
7123
+ const candidatesInScope = (0, react$1.useMemo)(() => smart ? candidates.filter((c) => matchesFacets(c, facets)) : candidates, [
7124
+ candidates,
7125
+ facets,
7126
+ smart
7127
+ ]);
7128
+ const options = (0, react$1.useMemo)(() => devicesToOptions(candidatesInScope, field.deviceFilter), [candidatesInScope, field.deviceFilter]);
7039
7129
  const selected = Array.isArray(value) ? value.map(String) : [];
7040
7130
  const isDisabled = disabled === true || field.disabled === true;
7041
7131
  const atMax = field.maxItems !== void 0 && selected.length >= field.maxItems;
@@ -7045,7 +7135,13 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7045
7135
  const inputRef = (0, react$1.useRef)(null);
7046
7136
  const blurTimer = (0, react$1.useRef)(null);
7047
7137
  const selectedOptions = (0, react$1.useMemo)(() => selectedDeviceOptions(options, selected), [options, selected]);
7048
- const filtered = (0, react$1.useMemo)(() => filterDeviceOptions(options, query), [options, query]);
7138
+ const searched = (0, react$1.useMemo)(() => filterDeviceOptions(options, query), [options, query]);
7139
+ const partition = (0, react$1.useMemo)(() => partitionSuggested(searched, smart ? field.suggestLocation : null), [
7140
+ searched,
7141
+ smart,
7142
+ field.suggestLocation
7143
+ ]);
7144
+ const filtered = (0, react$1.useMemo)(() => [...partition.suggested, ...partition.others], [partition]);
7049
7145
  const toggle = (optValue) => {
7050
7146
  if (isDisabled) return;
7051
7147
  if (selected.includes(optValue)) onChange(selected.filter((v) => v !== optValue));
@@ -7095,6 +7191,23 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7095
7191
  })]
7096
7192
  }, opt.value))
7097
7193
  }),
7194
+ smart && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetRow, {
7195
+ label: "Location",
7196
+ values: facetValues.locations,
7197
+ active: facets.location,
7198
+ onPick: (location) => setFacets((f) => ({
7199
+ ...f,
7200
+ location
7201
+ }))
7202
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetRow, {
7203
+ label: "Type",
7204
+ values: facetValues.types,
7205
+ active: facets.type,
7206
+ onPick: (type) => setFacets((f) => ({
7207
+ ...f,
7208
+ type
7209
+ }))
7210
+ })] }),
7098
7211
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7099
7212
  className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
7100
7213
  children: [
@@ -7144,7 +7257,12 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7144
7257
  }) : filtered.map((opt, i) => {
7145
7258
  const checked = selected.includes(opt.value);
7146
7259
  const blockedByMax = !checked && atMax;
7147
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7260
+ const suggestedCount = partition.suggested.length;
7261
+ const heading = !smart ? null : i === 0 && suggestedCount > 0 ? `Suggested · ${field.suggestLocation ?? ""}` : i === suggestedCount && suggestedCount > 0 ? "Other devices" : null;
7262
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [heading !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7263
+ className: GROUP_HEADING,
7264
+ children: heading
7265
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7148
7266
  type: "button",
7149
7267
  role: "option",
7150
7268
  "aria-selected": checked,
@@ -7159,11 +7277,17 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7159
7277
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7160
7278
  className: ["flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border", checked ? "border-primary bg-primary text-white" : "border-border"].join(" "),
7161
7279
  children: checked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-2.5 w-2.5" })
7162
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7163
- className: "truncate text-foreground",
7164
- children: opt.label
7280
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7281
+ className: "min-w-0 flex-1",
7282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7283
+ className: "block truncate text-foreground",
7284
+ children: opt.label
7285
+ }), opt.description !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7286
+ className: "block truncate text-[10px] text-foreground-subtle",
7287
+ children: opt.description
7288
+ })]
7165
7289
  })]
7166
- }, opt.value);
7290
+ })] }, opt.value);
7167
7291
  })
7168
7292
  }),
7169
7293
  description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
package/dist/index.js CHANGED
@@ -6879,6 +6879,7 @@ function intersects(a, b) {
6879
6879
  */
6880
6880
  function deviceMatchesFilter(device, filter) {
6881
6881
  if (filter?.excludeDeviceId !== void 0 && device.id === filter.excludeDeviceId) return false;
6882
+ if (filter?.excludeTypes !== void 0 && filter.excludeTypes.includes(device.type)) return false;
6882
6883
  const hasCaps = filter?.caps !== void 0 && filter.caps.length > 0;
6883
6884
  const hasTypes = filter?.types !== void 0 && filter.types.length > 0;
6884
6885
  if (!hasCaps && !hasTypes) return true;
@@ -6895,12 +6896,52 @@ function deviceOptionLabel(device) {
6895
6896
  * (value = the device id as a string). Sorted case-insensitively by label so
6896
6897
  * the picker order is stable.
6897
6898
  */
6899
+ /** `Location · type · in Container` — the parts a name alone does not say. */
6900
+ function deviceOptionDescription(device) {
6901
+ const parts = [];
6902
+ if (typeof device.location === "string" && device.location.length > 0) parts.push(device.location);
6903
+ parts.push(device.type);
6904
+ if (typeof device.parentName === "string" && device.parentName.length > 0) parts.push(`in ${device.parentName}`);
6905
+ return parts.join(" · ");
6906
+ }
6898
6907
  function devicesToOptions(devices, filter) {
6899
6908
  return devices.filter((d) => deviceMatchesFilter(d, filter)).map((d) => ({
6900
6909
  value: String(d.id),
6901
- label: deviceOptionLabel(d)
6910
+ label: deviceOptionLabel(d),
6911
+ description: deviceOptionDescription(d),
6912
+ location: typeof d.location === "string" && d.location.length > 0 ? d.location : null
6902
6913
  })).toSorted((a, b) => a.label.localeCompare(b.label));
6903
6914
  }
6915
+ /** The distinct locations and types on offer — the chips above the search. */
6916
+ function deviceFacets(devices) {
6917
+ const locations = /* @__PURE__ */ new Set();
6918
+ const types = /* @__PURE__ */ new Set();
6919
+ for (const d of devices) {
6920
+ if (typeof d.location === "string" && d.location.length > 0) locations.add(d.location);
6921
+ types.add(d.type);
6922
+ }
6923
+ return {
6924
+ locations: [...locations].toSorted((a, b) => a.localeCompare(b)),
6925
+ types: [...types].toSorted((a, b) => a.localeCompare(b))
6926
+ };
6927
+ }
6928
+ /** Every chosen facet must match; an unchosen one matches everything. */
6929
+ function matchesFacets(device, facets) {
6930
+ if (facets.location !== void 0 && device.location !== facets.location) return false;
6931
+ if (facets.type !== void 0 && device.type !== facets.type) return false;
6932
+ return true;
6933
+ }
6934
+ /** The devices sharing `location` come first — suggested, never pre-linked. */
6935
+ function partitionSuggested(options, location) {
6936
+ if (location === null || location === void 0 || location.length === 0) return {
6937
+ suggested: [],
6938
+ others: [...options]
6939
+ };
6940
+ return {
6941
+ suggested: options.filter((o) => o.location === location),
6942
+ others: options.filter((o) => o.location !== location)
6943
+ };
6944
+ }
6904
6945
  /**
6905
6946
  * Free-text option filter — case-insensitive AND over whitespace tokens, each
6906
6947
  * matched against the option's `value` + `label` (so a device is found by name,
@@ -6910,7 +6951,7 @@ function filterDeviceOptions(options, query) {
6910
6951
  const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
6911
6952
  if (tokens.length === 0) return [...options];
6912
6953
  return options.filter((opt) => {
6913
- const hay = `${opt.value} ${opt.label}`.toLowerCase();
6954
+ const hay = `${opt.value} ${opt.label} ${opt.description ?? ""}`.toLowerCase();
6914
6955
  return tokens.every((t) => hay.includes(t));
6915
6956
  });
6916
6957
  }
@@ -6949,21 +6990,61 @@ var DESC_CLASS$1 = "text-[10px] text-foreground-subtle mt-0.5 break-words";
6949
6990
  /** The compact projection of the listAll rows the device fields consume. */
6950
6991
  function toCandidates(rows) {
6951
6992
  if (!Array.isArray(rows)) return [];
6993
+ const nameById = /* @__PURE__ */ new Map();
6994
+ for (const row of rows) {
6995
+ if (typeof row !== "object" || row === null) continue;
6996
+ const r = row;
6997
+ if (typeof r.id === "number" && typeof r.name === "string") nameById.set(r.id, r.name);
6998
+ }
6952
6999
  const out = [];
6953
7000
  for (const row of rows) {
6954
7001
  if (typeof row !== "object" || row === null) continue;
6955
7002
  const r = row;
6956
7003
  if (typeof r.id !== "number" || typeof r.type !== "string" || typeof r.name !== "string") continue;
7004
+ const parentName = typeof r.parentDeviceId === "number" ? nameById.get(r.parentDeviceId) ?? null : null;
6957
7005
  out.push({
6958
7006
  id: r.id,
6959
7007
  type: r.type,
6960
7008
  name: r.name,
6961
7009
  location: typeof r.location === "string" ? r.location : null,
6962
- features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : []
7010
+ features: Array.isArray(r.features) ? r.features.filter((f) => typeof f === "string") : [],
7011
+ parentName
6963
7012
  });
6964
7013
  }
6965
7014
  return out;
6966
7015
  }
7016
+ var FACET_CHIP = "rounded-full border px-1.5 py-0.5 text-[10px] leading-4 transition-colors cursor-pointer";
7017
+ var FACET_CHIP_ON = `${FACET_CHIP} border-primary bg-primary/10 text-primary`;
7018
+ var FACET_CHIP_OFF = `${FACET_CHIP} border-border text-foreground-subtle hover:bg-surface-hover`;
7019
+ var GROUP_HEADING = "px-2.5 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground-subtle";
7020
+ /** One row of facet chips: `All` plus every distinct value. */
7021
+ function FacetRow({ label, values, active, onPick }) {
7022
+ if (values.length < 2) return null;
7023
+ return /* @__PURE__ */ jsxs("div", {
7024
+ className: "flex flex-wrap items-center gap-1 mb-1",
7025
+ role: "group",
7026
+ "aria-label": label,
7027
+ children: [
7028
+ /* @__PURE__ */ jsx("span", {
7029
+ className: "text-[10px] text-foreground-subtle mr-0.5",
7030
+ children: label
7031
+ }),
7032
+ /* @__PURE__ */ jsx("button", {
7033
+ type: "button",
7034
+ className: active === void 0 ? FACET_CHIP_ON : FACET_CHIP_OFF,
7035
+ onClick: () => onPick(void 0),
7036
+ children: "All"
7037
+ }),
7038
+ values.map((v) => /* @__PURE__ */ jsx("button", {
7039
+ type: "button",
7040
+ className: active === v ? FACET_CHIP_ON : FACET_CHIP_OFF,
7041
+ "aria-pressed": active === v,
7042
+ onClick: () => onPick(active === v ? void 0 : v),
7043
+ children: v
7044
+ }, v))
7045
+ ]
7046
+ });
7047
+ }
6967
7048
  function FieldShell({ label, description, required, translationFn, children }) {
6968
7049
  const resolvedLabel = resolveLabel(label, translationFn);
6969
7050
  const resolvedDescription = resolveLabel(description, translationFn);
@@ -7011,7 +7092,16 @@ function DeviceSelectField({ field, value, onChange, disabled, translationFn })
7011
7092
  }
7012
7093
  function DeviceMultiSelectField({ field, value, onChange, disabled, translationFn }) {
7013
7094
  const { data } = useDeviceManagerListAll({});
7014
- const options = useMemo(() => devicesToOptions(toCandidates(data), field.deviceFilter), [data, field.deviceFilter]);
7095
+ const smart = field.suggestLocation !== void 0;
7096
+ const [facets, setFacets] = useState({});
7097
+ const candidates = useMemo(() => toCandidates(data), [data]);
7098
+ const facetValues = useMemo(() => deviceFacets(candidates.filter((c) => c.id !== field.deviceFilter?.excludeDeviceId)), [candidates, field.deviceFilter?.excludeDeviceId]);
7099
+ const candidatesInScope = useMemo(() => smart ? candidates.filter((c) => matchesFacets(c, facets)) : candidates, [
7100
+ candidates,
7101
+ facets,
7102
+ smart
7103
+ ]);
7104
+ const options = useMemo(() => devicesToOptions(candidatesInScope, field.deviceFilter), [candidatesInScope, field.deviceFilter]);
7015
7105
  const selected = Array.isArray(value) ? value.map(String) : [];
7016
7106
  const isDisabled = disabled === true || field.disabled === true;
7017
7107
  const atMax = field.maxItems !== void 0 && selected.length >= field.maxItems;
@@ -7021,7 +7111,13 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7021
7111
  const inputRef = useRef(null);
7022
7112
  const blurTimer = useRef(null);
7023
7113
  const selectedOptions = useMemo(() => selectedDeviceOptions(options, selected), [options, selected]);
7024
- const filtered = useMemo(() => filterDeviceOptions(options, query), [options, query]);
7114
+ const searched = useMemo(() => filterDeviceOptions(options, query), [options, query]);
7115
+ const partition = useMemo(() => partitionSuggested(searched, smart ? field.suggestLocation : null), [
7116
+ searched,
7117
+ smart,
7118
+ field.suggestLocation
7119
+ ]);
7120
+ const filtered = useMemo(() => [...partition.suggested, ...partition.others], [partition]);
7025
7121
  const toggle = (optValue) => {
7026
7122
  if (isDisabled) return;
7027
7123
  if (selected.includes(optValue)) onChange(selected.filter((v) => v !== optValue));
@@ -7071,6 +7167,23 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7071
7167
  })]
7072
7168
  }, opt.value))
7073
7169
  }),
7170
+ smart && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FacetRow, {
7171
+ label: "Location",
7172
+ values: facetValues.locations,
7173
+ active: facets.location,
7174
+ onPick: (location) => setFacets((f) => ({
7175
+ ...f,
7176
+ location
7177
+ }))
7178
+ }), /* @__PURE__ */ jsx(FacetRow, {
7179
+ label: "Type",
7180
+ values: facetValues.types,
7181
+ active: facets.type,
7182
+ onPick: (type) => setFacets((f) => ({
7183
+ ...f,
7184
+ type
7185
+ }))
7186
+ })] }),
7074
7187
  /* @__PURE__ */ jsxs("div", {
7075
7188
  className: INPUT_SHELL + (isDisabled ? " opacity-50" : ""),
7076
7189
  children: [
@@ -7120,7 +7233,12 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7120
7233
  }) : filtered.map((opt, i) => {
7121
7234
  const checked = selected.includes(opt.value);
7122
7235
  const blockedByMax = !checked && atMax;
7123
- return /* @__PURE__ */ jsxs("button", {
7236
+ const suggestedCount = partition.suggested.length;
7237
+ const heading = !smart ? null : i === 0 && suggestedCount > 0 ? `Suggested · ${field.suggestLocation ?? ""}` : i === suggestedCount && suggestedCount > 0 ? "Other devices" : null;
7238
+ return /* @__PURE__ */ jsxs("div", { children: [heading !== null && /* @__PURE__ */ jsx("div", {
7239
+ className: GROUP_HEADING,
7240
+ children: heading
7241
+ }), /* @__PURE__ */ jsxs("button", {
7124
7242
  type: "button",
7125
7243
  role: "option",
7126
7244
  "aria-selected": checked,
@@ -7135,11 +7253,17 @@ function DeviceMultiSelectField({ field, value, onChange, disabled, translationF
7135
7253
  children: [/* @__PURE__ */ jsx("span", {
7136
7254
  className: ["flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border", checked ? "border-primary bg-primary text-white" : "border-border"].join(" "),
7137
7255
  children: checked && /* @__PURE__ */ jsx(Check, { className: "h-2.5 w-2.5" })
7138
- }), /* @__PURE__ */ jsx("span", {
7139
- className: "truncate text-foreground",
7140
- children: opt.label
7256
+ }), /* @__PURE__ */ jsxs("span", {
7257
+ className: "min-w-0 flex-1",
7258
+ children: [/* @__PURE__ */ jsx("span", {
7259
+ className: "block truncate text-foreground",
7260
+ children: opt.label
7261
+ }), opt.description !== void 0 && /* @__PURE__ */ jsx("span", {
7262
+ className: "block truncate text-[10px] text-foreground-subtle",
7263
+ children: opt.description
7264
+ })]
7141
7265
  })]
7142
- }, opt.value);
7266
+ })] }, opt.value);
7143
7267
  })
7144
7268
  }),
7145
7269
  description && /* @__PURE__ */ jsx("p", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.113",
3
+ "version": "1.2.114",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",