@sincpro/web-ui 1.4.0 → 1.5.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.
@@ -1,3 +1,4 @@
1
+ import type { PileFoot } from "../../foundations/grouping.js";
1
2
  import { type MenuGroup } from "../../primitives/menu/Menu.js";
2
3
  import type { LucideIcon } from "lucide-react";
3
4
  import type { ReactNode } from "react";
@@ -127,10 +128,10 @@ export declare function GroupHeader({ field, label, count, detail, depth, open,
127
128
  open?: boolean;
128
129
  onToggle?: () => void;
129
130
  }): import("react").JSX.Element;
130
- /** Under a pile that shows its first page: «Ver 38 más». Indented like the pile's rows. */
131
- export declare function GroupMore({ left, depth, onMore, }: {
132
- /** How many the pile counts that are not here yet. */
133
- left: number;
131
+ /** Under a short pile: «Ver 50 más de 49.950», or «Se muestran 500 de 49.950» when there is
132
+ * no next page. Indented like the pile's rows. */
133
+ export declare function GroupMore({ foot, depth, onMore, }: {
134
+ foot: PileFoot;
134
135
  depth?: number;
135
136
  onMore: () => void;
136
137
  }): import("react").JSX.Element;
@@ -5,6 +5,7 @@ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
5
5
  import { byDensity, useDensity } from "../../foundations/density.js";
6
6
  import { stickyTop } from "../../foundations/sticky.js";
7
7
  import { Menu } from "../../primitives/menu/Menu.js";
8
+ import { PileFootLine } from "../../primitives/table/PileFootLine.js";
8
9
  import { HEADER_TEXT } from "../../primitives/table/Table.js";
9
10
  import { CopyButton } from "../../primitives/value/Copyable.js";
10
11
  import { Avatar } from "../../primitives/value/Person.js";
@@ -137,9 +138,9 @@ export function GroupHeader({ field, label, count, detail, depth = 0, open = tru
137
138
  const mark = (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-1 inset-y-0.5 rounded-control group-hover/head:bg-ink/4" }));
138
139
  return onToggle === undefined ? (_jsx("div", { className: box, style: indent, children: content })) : (_jsxs("button", { type: "button", onClick: onToggle, "aria-expanded": open, style: indent, className: `group/head relative ${box} w-full text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`, children: [mark, content] }));
139
140
  }
140
- /** Under a pile that shows its first page: «Ver 38 más». Indented like the pile's rows. */
141
- export function GroupMore({ left, depth = 0, onMore, }) {
141
+ /** Under a short pile: «Ver 50 más de 49.950», or «Se muestran 500 de 49.950» when there is
142
+ * no next page. Indented like the pile's rows. */
143
+ export function GroupMore({ foot, depth = 0, onMore, }) {
142
144
  const d = useDensity();
143
- const copy = useUiCopy();
144
- return (_jsx("div", { className: `border-b border-line ${byDensity(d, "px-3 py-1", "px-2.5 py-0.5")}`, style: depth === 0 ? undefined : { paddingLeft: `calc(var(--spacing-indent) * ${depth})` }, children: _jsx("button", { type: "button", onClick: onMore, className: "rounded-control text-meta text-primary transition-colors duration-(--duration-instant) hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: fillCopy(copy.table.pileMore, { count: left.toLocaleString(copy.locale) }) }) }));
145
+ return (_jsx("div", { className: `border-b border-line ${byDensity(d, "px-3 py-1", "px-2.5 py-0.5")}`, style: depth === 0 ? undefined : { paddingLeft: `calc(var(--spacing-indent) * ${depth})` }, children: _jsx(PileFootLine, { foot: foot, onMore: onMore }) }));
145
146
  }
@@ -239,6 +239,10 @@ export type UiCopy = {
239
239
  distinct: string;
240
240
  /** Under a pile that holds its first page and counts them all: ask for the rest. */
241
241
  pileMore: string;
242
+ /** The same, when whoever pages says how many one press brings: «Show 50 more of 49,950». */
243
+ pileMoreOf: string;
244
+ /** Under a short pile with no next page: what it shows of what it counts. */
245
+ pileShown: string;
242
246
  };
243
247
  menu: {
244
248
  empty: string;
@@ -205,6 +205,8 @@ export const SPANISH_COPY = {
205
205
  max: "máx {value}",
206
206
  distinct: "{value} distintos",
207
207
  pileMore: "Ver {count} más",
208
+ pileMoreOf: "Ver {count} más de {left}",
209
+ pileShown: "Se muestran {shown} de {count}",
208
210
  },
209
211
  menu: { empty: "Nada coincide." },
210
212
  list: {
@@ -151,6 +151,8 @@ export const ENGLISH_COPY = {
151
151
  max: "max {value}",
152
152
  distinct: "{value} distinct",
153
153
  pileMore: "Show {count} more",
154
+ pileMoreOf: "Show {count} more of {left}",
155
+ pileShown: "Showing {shown} of {count}",
154
156
  },
155
157
  menu: {
156
158
  empty: "Nothing matches.",
@@ -104,6 +104,15 @@ export type Pile<T> = {
104
104
  items: readonly T[];
105
105
  /** El nivel de abajo. Vacío en el más profundo. */
106
106
  piles: readonly Pile<T>[];
107
+ /**
108
+ * What the next «show more» of a short pile brings — said by whoever pages it.
109
+ *
110
+ * `50` reads «Show 50 more of 49,950»: the button says what one press adds, not what is
111
+ * missing. `false` says there is no next page although the count says more (a cap on
112
+ * the rows held, a server that stops paging): the button goes and the pile says how many
113
+ * it shows. Absent, the button counts what is left, as it did before this existed.
114
+ */
115
+ more?: number | false;
107
116
  };
108
117
  /**
109
118
  * La identidad de un NIVEL: el campo y, si es fecha, su grano.
@@ -280,6 +289,25 @@ export declare function itemPath<T>(item: T, levels: readonly GroupLevel[], of:
280
289
  * presentaciones que necesitan la secuencia y no el árbol —la planilla
281
290
  * virtualizada, que cuenta índices— y el que quiera saber qué quedó visible. */
282
291
  export declare function pileItems<T>(piles: readonly Pile<T>[]): readonly T[];
292
+ /**
293
+ * What the foot of a pile offers: the next page, or the word that there is none.
294
+ *
295
+ * Context: every presentation that draws piles —rows, cards, the table, one of the
296
+ * application's own— asks this, so «Show 50 more of 49,950» and «Showing 500 of 49,950»
297
+ * are decided once. `null` when the pile holds every row it counts, or holds piles and not
298
+ * rows. A short pile with no way to page (`more: false`, or no `onMore`) still says so: a
299
+ * header that reads 49,950 over 500 rows is a count the screen silently contradicts.
300
+ */
301
+ export type PileFoot = {
302
+ kind: "more";
303
+ left: number;
304
+ next?: number;
305
+ } | {
306
+ kind: "end";
307
+ shown: number;
308
+ count: number;
309
+ };
310
+ export declare function pileFoot<T>(pile: Pile<T>, canAsk: boolean): PileFoot | null;
283
311
  /** Cuántas filas hay en total. Suma los conteos del primer nivel, que ya
284
312
  * incluyen los de abajo — no recorre las filas, porque puede no tenerlas. */
285
313
  export declare function pileCount<T>(piles: readonly Pile<T>[]): number;
Binary file
@@ -9,7 +9,7 @@ export type { OpenTarget, Size, Surface, Tone } from "./types.js";
9
9
  export { SPANISH_COPY } from "./copy.es.js";
10
10
  export { flatten, loose, pick, pickAll, propsOf, slot, slotOf, type Slotted, } from "./slots.js";
11
11
  export { dateKey, dateLabel, grainName, monthName, } from "./calendar.js";
12
- export { GRAIN_ORDER, type GroupableField, type GroupGrain, type Grouping, type GroupLevel, itemPath, levelKey, moveLevel, normalizeLevels, pathKey, pathLabels, type Pile, pileCount, pileIsOpen, pileItems, pileLabel, type PileLabelOf, type PileOf, pilesFrom, pilesOf, } from "./grouping.js";
12
+ export { GRAIN_ORDER, type GroupableField, type GroupGrain, type Grouping, type GroupLevel, itemPath, levelKey, moveLevel, normalizeLevels, pathKey, pathLabels, type Pile, pileCount, type PileFoot, pileFoot, pileIsOpen, pileItems, pileLabel, type PileLabelOf, type PileOf, pilesFrom, pilesOf, } from "./grouping.js";
13
13
  export { axisLabels, axisSpans, keyStarts, type Pivot, type PivotAxis, type PivotCell, type PivotCrossing, type PivotFold, pivotFrom, pivotIsEmpty, type PivotKey, type PivotLevel, type PivotMargin, type PivotMeasure, pivotOf, type PivotSource, type PivotSpec, } from "./pivot.js";
14
14
  export { downloadPivot, pivotSheet, type PivotSheetOptions, pivotXlsx, } from "./pivotSheet.js";
15
15
  export { STICKY_TOP, stickyReset, stickyTop, useScrollBox, useStickyLayer, } from "./sticky.js";
@@ -23,7 +23,7 @@ export { dateKey, dateLabel, grainName, monthName, } from "./calendar.js";
23
23
  /* LA AGRUPACIÓN — el vocabulario, que hablan las dos capas: la lista arma el
24
24
  árbol de montones y la tabla lo dibuja. Vive acá y no en `contexts/list`
25
25
  porque una primitiva no puede importar de un contexto. */
26
- export { GRAIN_ORDER, itemPath, levelKey, moveLevel, normalizeLevels, pathKey, pathLabels, pileCount, pileIsOpen, pileItems, pileLabel, pilesFrom, pilesOf, } from "./grouping.js";
26
+ export { GRAIN_ORDER, itemPath, levelKey, moveLevel, normalizeLevels, pathKey, pathLabels, pileCount, pileFoot, pileIsOpen, pileItems, pileLabel, pilesFrom, pilesOf, } from "./grouping.js";
27
27
  /* EL PIVOTE — el mismo reparto: el vocabulario acá, la grilla en `primitives`.
28
28
  Que los márgenes vengan de la base y que un agujero no sea un cero son reglas
29
29
  del CONTRATO, no del dibujo, y por eso viven de este lado. */
@@ -1,6 +1,7 @@
1
1
  /**
2
- * A text with the part that matched the search marked — internal to the lists that filter
3
- * as you type (`Picker`, `TagsField`). A `<mark>`, so it is also said as marked text, and
2
+ * A text with the part that matched the search marked — what the lists that filter as you
3
+ * type (`Picker`, `TagsField`) draw in every row, and what an option of the application's own
4
+ * (`renderOption`) uses to say the same. A `<mark>`, so it is also said as marked text, and
4
5
  * bold besides the tint: in forced colors the tint is gone and the weight is what remains.
5
6
  */
6
7
  export declare function MatchedText({ text, query, locale, }: {
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { matchSpans } from "../../foundations/match.js";
3
3
  /**
4
- * A text with the part that matched the search marked — internal to the lists that filter
5
- * as you type (`Picker`, `TagsField`). A `<mark>`, so it is also said as marked text, and
4
+ * A text with the part that matched the search marked — what the lists that filter as you
5
+ * type (`Picker`, `TagsField`) draw in every row, and what an option of the application's own
6
+ * (`renderOption`) uses to say the same. A `<mark>`, so it is also said as marked text, and
6
7
  * bold besides the tint: in forced colors the tint is gone and the weight is what remains.
7
8
  */
8
9
  export function MatchedText({ text, query, locale, }) {
@@ -30,6 +30,14 @@ export interface PickerItem {
30
30
  * lined up in one column, eight customers' debts are read top to bottom. */
31
31
  aside?: ReactNode;
32
32
  }
33
+ /**
34
+ * What a `renderOption` is handed beside the item: whether it is the chosen one, and what was
35
+ * typed — mark it with `MatchedText`, so the row still says why it is listed.
36
+ */
37
+ export type PickerOptionState = {
38
+ selected: boolean;
39
+ query: string;
40
+ };
33
41
  /**
34
42
  * What the list shows and how many are left out of it. Capped at 8 with the footer count,
35
43
  * unless the list loads incrementally — then everything that arrived is listed and the
@@ -94,6 +102,14 @@ export type PickerProps = {
94
102
  /** A page asked for by `onLoadMore` is on its way: a quiet row at the end says so. Unlike
95
103
  * `loading`, the rows already there stay. */
96
104
  loadingMore?: boolean;
105
+ /**
106
+ * The content of an option, for one that does not fit «name + meta + aside» — a product
107
+ * with its photo, a slot of an agenda. The row around it stays the picker's: keys, focus,
108
+ * the search by `name` (and `haystack`), the ✓ of the chosen one. What it draws is also
109
+ * what the option is called, so it should show the name. Data beyond `PickerItem` is looked
110
+ * up by `item.id` in the consumer's own records.
111
+ */
112
+ renderOption?: (item: PickerItem, state: PickerOptionState) => ReactNode;
97
113
  describedBy?: string;
98
114
  invalid?: boolean;
99
115
  disabled?: boolean;
@@ -109,7 +125,7 @@ export declare function Picker({ mode, ...props }: PickerProps): import("react")
109
125
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
110
126
  * can be rendered on the server without a popover, which is how the tests see the rows.
111
127
  */
112
- export declare function PickerOptions({ items, marks, hints, remaining, query, emptyText, onLoadMore, loadingMore, "aria-label": ariaLabel, }: {
128
+ export declare function PickerOptions({ items, marks, hints, remaining, query, emptyText, onLoadMore, loadingMore, renderOption, "aria-label": ariaLabel, }: {
113
129
  items: readonly PickerItem[];
114
130
  marks: boolean;
115
131
  hints: boolean;
@@ -119,6 +135,7 @@ export declare function PickerOptions({ items, marks, hints, remaining, query, e
119
135
  emptyText: string;
120
136
  onLoadMore?: () => void;
121
137
  loadingMore?: boolean;
138
+ renderOption?: (item: PickerItem, state: PickerOptionState) => ReactNode;
122
139
  /** Only when rendered outside the combobox, which otherwise names the list. */
123
140
  "aria-label"?: string;
124
141
  }): import("react").JSX.Element;
@@ -98,7 +98,7 @@ export function Picker({ mode, ...props }) {
98
98
  to be ABOVE the component that asks for it. */
99
99
  return (_jsx(AriaLocale, { children: _jsx(EditablePicker, { ...props }) }));
100
100
  }
101
- function EditablePicker({ id: ownId, items, value, onChange, onSearch, valueLabel, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
101
+ function EditablePicker({ id: ownId, items, value, onChange, onSearch, valueLabel, placeholder, onBrowseAll, onCreate, onCreateAndEdit, onOpen, openTargets = ["modal", "peek", "side", "page"], emptyText, searchPlaceholder, loading = false, loadingText, onLoadMore, loadingMore = false, renderOption, describedBy: ownDescribedBy, invalid: ownInvalid, disabled = false, "aria-label": ariaLabel, }) {
102
102
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
103
103
  id: ownId,
104
104
  describedBy: ownDescribedBy,
@@ -235,14 +235,14 @@ function EditablePicker({ id: ownId, items, value, onChange, onSearch, valueLabe
235
235
  ], onSelect: (to) => onOpen(value, to) }))] })) : null, _jsx(FieldButton, { className: "mr-1", children: _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--size-icon-md)", strokeWidth: 2 }) })] }), _jsxs(Popover, { placement: "bottom start", className: "flex w-(--trigger-width) flex-col overflow-hidden", children: [_jsx(PickerOptions, { items: listed, marks: hasMarks, hints: hasHints, remaining: remaining, query: query, emptyText: loading ? shownLoading : shownEmpty,
236
236
  /* Not while the first page loads: the sentinel of an empty list is in view and
237
237
  would ask for page two before page one arrived. */
238
- onLoadMore: loading ? undefined : onLoadMore, loadingMore: loadingMore }), remaining > 0 && !browsable ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2 border-t border-line bg-ground px-2.5 py-1.5", children: [_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, { count: remaining.toLocaleString(copy.locale) }) }), _jsx("span", { className: "text-micro text-muted", children: copy.picker.refine })] })) : null] })] }));
238
+ onLoadMore: loading ? undefined : onLoadMore, loadingMore: loadingMore, ...(renderOption === undefined ? {} : { renderOption }) }), remaining > 0 && !browsable ? (_jsxs("div", { className: "flex shrink-0 items-center gap-2 border-t border-line bg-ground px-2.5 py-1.5", children: [_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, { count: remaining.toLocaleString(copy.locale) }) }), _jsx("span", { className: "text-micro text-muted", children: copy.picker.refine })] })) : null] })] }));
239
239
  }
240
240
  /**
241
241
  * The list of a `Picker`: its rows, the section headers, the «Buscar más…» row and the loading
242
242
  * row at the end. Internal — not exported from the family — and apart from the combobox so it
243
243
  * can be rendered on the server without a popover, which is how the tests see the rows.
244
244
  */
245
- export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, "aria-label": ariaLabel, }) {
245
+ export function PickerOptions({ items, marks, hints, remaining, query = "", emptyText, onLoadMore, loadingMore = false, renderOption, "aria-label": ariaLabel, }) {
246
246
  const copy = useUiCopy();
247
247
  const entries = useMemo(() => sectioned(items), [items]);
248
248
  /* The actions are one block under the records: one hairline above the first of them. */
@@ -251,14 +251,14 @@ export function PickerOptions({ items, marks, hints, remaining, query = "", empt
251
251
  ? "mt-1 border-t border-line"
252
252
  : ""), children: _jsxs("span", { className: "flex w-full items-center justify-between gap-2", children: [_jsx("span", { className: "min-w-0 truncate font-medium", children: item.name }), item.id !== BROWSE_ALL || remaining === 0 ? null : (_jsx("span", { className: "text-micro tabular-nums text-muted", children: fillCopy(copy.picker.more, {
253
253
  count: remaining.toLocaleString(copy.locale),
254
- }) }))] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints, query: query, locale: copy.locale })) }));
254
+ }) }))] }) })) : (_jsx(ListBoxItem, { id: item.id, textValue: item.name, isDisabled: item.disabled, className: (state) => option({ ...state, hints }), children: ({ isSelected }) => renderOption === undefined ? (_jsx(Row, { item: item, selected: isSelected, marks: marks, hints: hints, query: query, locale: copy.locale })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 flex-1", children: renderOption(item, { selected: isSelected, query }) }), isSelected ? (_jsx(Check, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0 self-center text-primary forced-colors:text-[HighlightText]", strokeWidth: 2.5 })) : null] })) }));
255
255
  return (_jsxs(ListBox, { "aria-label": ariaLabel,
256
256
  /* Nothing matched YET while the next page is coming: «nothing matches» above «loading
257
257
  more…» says two contradicting things, so the empty list says only the second. */
258
258
  renderEmptyState: () => (_jsx("p", { className: "px-3 py-3 text-center text-meta text-muted", children: loadingMore ? copy.picker.loadingMore : emptyText })),
259
259
  /* Incremental, the list is not capped: it scrolls inside its own height and the
260
260
  sentinel at its end is what asks for more. */
261
- className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, dependencies: [query], children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, dependencies: [query], children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
261
+ className: cn("min-h-0 flex-1 overflow-y-auto p-1 outline-0", onLoadMore === undefined ? "" : "max-h-(--size-dropdown)"), children: [_jsx(Collection, { items: entries, dependencies: [query, renderOption], children: (entry) => "items" in entry ? (_jsxs(ListBoxSection, { id: entry.id, className: "not-first:mt-1 not-first:border-t not-first:border-line not-first:pt-1", children: [_jsxs(Header, { className: "flex items-center gap-2 px-2 pt-1 pb-0.5 text-micro font-medium text-muted", children: [marks ? (_jsx("span", { className: "size-(--size-icon-sm) shrink-0", "aria-hidden": true })) : null, entry.section] }), _jsx(Collection, { items: entry.items, dependencies: [query, renderOption], children: renderItem })] })) : (renderItem(entry)) }), onLoadMore === undefined ? null : (_jsx(ListBoxLoadMoreItem, { onLoadMore: onLoadMore, isLoading: loadingMore && items.length > 0, className: "px-3 py-1.5 text-center text-micro text-muted", children: copy.picker.loadingMore }))] }));
262
262
  }
263
263
  function Row({ item, selected, marks, hints, query, locale, }) {
264
264
  const Icon = item.icon;
@@ -1,6 +1,7 @@
1
1
  import { type FormMode } from "../../foundations/form.js";
2
2
  import type { OpenTarget } from "../../foundations/types.js";
3
3
  import { type TagOption } from "./tagChoices.js";
4
+ import { type ReactNode } from "react";
4
5
  export type TagsFieldProps = {
5
6
  /** The ids of the chosen tags, in the order they were added. */
6
7
  value: readonly string[];
@@ -31,6 +32,15 @@ export type TagsFieldProps = {
31
32
  /** Where a pressed chip opens. A peek by default: a chip is a glance at one of many — «who is
32
33
  * this follower?» — not a place to go and work. */
33
34
  openTarget?: OpenTarget;
35
+ /**
36
+ * The content of an option in the list, for one that does not fit «face + name + meta». The
37
+ * row stays the field's — keys, the search by `label`, Enter that adds — and «Create…» and
38
+ * «Buscar más…» stay the field's too. Mark what was typed with `MatchedText`. The chips are
39
+ * not touched: a chosen tag is read by its name.
40
+ */
41
+ renderOption?: (option: TagOption, state: {
42
+ query: string;
43
+ }) => ReactNode;
34
44
  /** The input's placeholder while no tag is chosen. Defaults to the copy table's «Add…». */
35
45
  placeholder?: string;
36
46
  disabled?: boolean;
@@ -84,7 +84,7 @@ export function TagsField({ mode, ...props }) {
84
84
  to be ABOVE the component that asks for it. */
85
85
  return (_jsx(AriaLocale, { children: _jsx(EditableTagsField, { ...props }) }));
86
86
  }
87
- function EditableTagsField({ value, onChange, options, onCreate, onSearch, onBrowseAll, onOpen, openTarget, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
87
+ function EditableTagsField({ value, onChange, options, onCreate, onSearch, onBrowseAll, onOpen, openTarget, renderOption, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, "aria-label": ariaLabel, }) {
88
88
  const { id, labelledBy, describedBy, invalid } = useFieldIds({
89
89
  id: ownId,
90
90
  describedBy: ownDescribedBy,
@@ -184,7 +184,7 @@ function EditableTagsField({ value, onChange, options, onCreate, onSearch, onBro
184
184
  onChange?.(withoutTag(value, last));
185
185
  }, empty: text === "" }), _jsx(Popover, { triggerRef: boxRef, ...(width === null
186
186
  ? {}
187
- : { style: { "--trigger-width": `${width}px` } }), placement: "bottom start", className: "w-(--trigger-width)", children: _jsx(ListBox, { items: listed, dependencies: [text], className: "max-h-[inherit] overflow-auto p-1 outline-0", children: (item) => (_jsx(ListBoxItem, { id: item.value, textValue: item.label, className: (state) => cn(listOptionStyles(state), item.value === CREATE || item.value === BROWSE ? "text-primary" : ""), children: item.value === CREATE ? (_jsxs(_Fragment, { children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : item.value === BROWSE ? (_jsxs(_Fragment, { children: [_jsx(Search, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : (_jsxs(_Fragment, { children: [item.avatar === undefined ? null : (_jsx(Avatar, { name: item.label, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) })), _jsx("span", { className: "truncate", children: _jsx(MatchedText, { text: item.label, query: text, locale: copy.locale }) })] })) })) }) })] })] }));
187
+ : { style: { "--trigger-width": `${width}px` } }), placement: "bottom start", className: "w-(--trigger-width)", children: _jsx(ListBox, { items: listed, dependencies: [text, renderOption], className: "max-h-[inherit] overflow-auto p-1 outline-0", children: (item) => (_jsx(ListBoxItem, { id: item.value, textValue: item.label, className: (state) => cn(listOptionStyles(state), item.value === CREATE || item.value === BROWSE ? "text-primary" : ""), children: item.value === CREATE ? (_jsxs(_Fragment, { children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : item.value === BROWSE ? (_jsxs(_Fragment, { children: [_jsx(Search, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : renderOption !== undefined ? (_jsx("span", { className: "min-w-0 flex-1", children: renderOption(item, { query: text }) })) : (_jsxs(_Fragment, { children: [item.avatar === undefined ? null : (_jsx(Avatar, { name: item.label, size: "sm", ...(typeof item.avatar === "string" ? { src: item.avatar } : {}) })), _jsxs("span", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("span", { className: "truncate", children: _jsx(MatchedText, { text: item.label, query: text, locale: copy.locale }) }), item.meta === undefined ? null : (_jsx("span", { className: "truncate text-micro tabular-nums text-muted", children: _jsx(MatchedText, { text: item.meta, query: text, locale: copy.locale }) }))] })] })) })) }) })] })] }));
188
188
  }
189
189
  /** The input after the chips. It reads the combobox's state to know whether the keyboard
190
190
  * marked an option — Enter with nothing marked takes the first match. */
@@ -1,3 +1,4 @@
1
- export { Picker, type PickerItem, type PickerProps, } from "./Picker.js";
1
+ export { Picker, type PickerItem, type PickerOptionState, type PickerProps, } from "./Picker.js";
2
+ export { MatchedText } from "./MatchedText.js";
2
3
  export { type TagOption } from "./tagChoices.js";
3
4
  export { TagsField, type TagsFieldProps } from "./TagsField.js";
@@ -1,6 +1,8 @@
1
1
  /* CHOOSING RECORDS — one (`Picker`, the many2one) or several (`TagsField`, the many2many).
2
2
  The replacement for `<select>` when you have to SEE in order to choose. */
3
3
  export { Picker, } from "./Picker.js";
4
+ /* Lo buscado, marcado: para que una opción propia (`renderOption`) lo diga como las demás. */
5
+ export { MatchedText } from "./MatchedText.js";
4
6
  /* El many2many: las fichas elegidas más una entrada que agrega la siguiente. */
5
7
  export {} from "./tagChoices.js";
6
8
  export { TagsField } from "./TagsField.js";
@@ -13,6 +13,13 @@ export interface TagOption {
13
13
  tone?: Tone;
14
14
  /** A person: its face on the chip and in the list — `true` for initials, a URL for a photo. */
15
15
  avatar?: true | string;
16
+ /**
17
+ * The datum that tells two options apart, already written — the NIT of a customer, the
18
+ * email of a follower. Drawn under the name in the list and searched like it; NOT on the
19
+ * chip: a chip is recognised by its name, and a NIT on every chip turns the strip into a
20
+ * table. Same role as `PickerItem.meta`.
21
+ */
22
+ meta?: string;
16
23
  }
17
24
  /** How the typed text is compared — the locale's, handed in by the component. */
18
25
  export type TagMatch = {
@@ -14,10 +14,15 @@ searched = false) {
14
14
  const chosen = new Set(value);
15
15
  const query = text.trim();
16
16
  const available = options.filter((option) => !chosen.has(option.value) &&
17
- (searched || query === "" || match.contains(option.label, query)));
18
- const create = creatable && query !== "" && !options.some((option) => match.same(option.label, query))
19
- ? query
20
- : null;
17
+ (searched ||
18
+ query === "" ||
19
+ match.contains(option.label, query) ||
20
+ (option.meta !== undefined && match.contains(option.meta, query))));
21
+ /* Typing a NIT that one option carries names that option: creating a tag called «1023…»
22
+ next to it is the duplicate this rule exists to prevent. */
23
+ const named = options.some((option) => match.same(option.label, query) ||
24
+ (option.meta !== undefined && match.same(option.meta, query)));
25
+ const create = creatable && query !== "" && !named ? query : null;
21
26
  return { available, create };
22
27
  }
23
28
  /** The value with one more id at the end; the same value when it is already there. */
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
- import { pathKey, pileItems } from "../../foundations/grouping.js";
4
+ import { pathKey, pileFoot, pileItems, } from "../../foundations/grouping.js";
5
5
  import { CellCopy } from "./CellCopy.js";
6
6
  import { CellEditor } from "./CellEditor.js";
7
7
  import { EditRow, firstControl } from "./EditableTable.js";
8
+ import { PileFootLine } from "./PileFootLine.js";
8
9
  import { RowActions } from "./RowActions.js";
9
10
  import { useRowWindow } from "./rowWindow.js";
10
11
  import { SelectBox, Table, Td, Th, THead, Tr, } from "./Table.js";
@@ -140,21 +141,21 @@ function entriesOfPiles(piles, keyOf, collapsed, onToggle, onMore) {
140
141
  if (pile.piles.length > 0) {
141
142
  return [head, ...entriesOfPiles(pile.piles, keyOf, collapsed, onToggle, onMore)];
142
143
  }
143
- const left = pile.count - pile.items.length;
144
+ const foot = pileFoot(pile, onMore !== undefined);
144
145
  return [
145
146
  head,
146
147
  ...pile.items.map((item) => ({ kind: "row", id: keyOf(item), item })),
147
- ...(left > 0 && onMore !== undefined
148
- ? [
148
+ ...(foot === null
149
+ ? []
150
+ : [
149
151
  {
150
152
  kind: "more",
151
153
  id: `\u0000more${key}`,
152
154
  depth: pile.depth,
153
- left,
154
- more: () => onMore(pile),
155
+ foot,
156
+ more: () => onMore?.(pile),
155
157
  },
156
- ]
157
- : []),
158
+ ]),
158
159
  ];
159
160
  });
160
161
  }
@@ -385,9 +386,7 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
385
386
  if (entry.kind === "more") {
386
387
  return [
387
388
  gap,
388
- _jsx("tr", { ...windowed, className: "border-b border-line", children: _jsx("td", { colSpan: width, className: "px-3 py-1", style: indent, children: _jsx("button", { type: "button", onClick: entry.more, className: "rounded-control text-meta text-primary transition-colors duration-(--duration-instant) hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: fillCopy(copy.table.pileMore, {
389
- count: entry.left.toLocaleString(copy.locale),
390
- }) }) }) }, entry.id),
389
+ _jsx("tr", { ...windowed, className: "border-b border-line", children: _jsx("td", { colSpan: width, className: "px-3 py-1", style: indent, children: _jsx(PileFootLine, { foot: entry.foot, onMore: entry.more }) }) }, entry.id),
391
390
  ];
392
391
  }
393
392
  const original = entry.item;
@@ -0,0 +1,14 @@
1
+ import type { PileFoot } from "../../foundations/grouping.js";
2
+ /**
3
+ * The foot of a short pile, as words: «Show 50 more of 49,950», or «Showing 500 of 49,950»
4
+ * when there is no next page.
5
+ *
6
+ * Context: the table's row and the list's line both draw it, each in its own frame (a `td`
7
+ * with a `colSpan`, an indented line), so this is only what goes inside. The end is quiet
8
+ * text and not a disabled button: there is nothing to press, and a control that does
9
+ * nothing is the bug this replaced.
10
+ */
11
+ export declare function PileFootLine({ foot, onMore }: {
12
+ foot: PileFoot;
13
+ onMore: () => void;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
+ /**
5
+ * The foot of a short pile, as words: «Show 50 more of 49,950», or «Showing 500 of 49,950»
6
+ * when there is no next page.
7
+ *
8
+ * Context: the table's row and the list's line both draw it, each in its own frame (a `td`
9
+ * with a `colSpan`, an indented line), so this is only what goes inside. The end is quiet
10
+ * text and not a disabled button: there is nothing to press, and a control that does
11
+ * nothing is the bug this replaced.
12
+ */
13
+ export function PileFootLine({ foot, onMore }) {
14
+ const copy = useUiCopy();
15
+ const n = (value) => value.toLocaleString(copy.locale);
16
+ if (foot.kind === "end") {
17
+ return (_jsx("span", { className: "text-meta text-muted", children: fillCopy(copy.table.pileShown, { shown: n(foot.shown), count: n(foot.count) }) }));
18
+ }
19
+ return (_jsx("button", { type: "button", onClick: onMore, className: "rounded-control text-meta text-primary transition-colors duration-(--duration-instant) hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: foot.next === undefined
20
+ ? fillCopy(copy.table.pileMore, { count: n(foot.left) })
21
+ : fillCopy(copy.table.pileMoreOf, { count: n(foot.next), left: n(foot.left) }) }));
22
+ }
@@ -1,5 +1,6 @@
1
1
  export { type Aggregate, type DataColumn, DataTable, type DataTableCreate, type GroupColumn, } from "./DataTable.js";
2
2
  export { EditableTable, type EditAdd, type EditBands, type EditColumn, type EditLayout, sectionLines, type StackPlace, stackPlaces, type TabTarget, tabTarget, } from "./EditableTable.js";
3
+ export { PileFootLine } from "./PileFootLine.js";
3
4
  export { PivotTable, type PivotTableProps, } from "./PivotTable.js";
4
5
  export { type ReportColumn, type ReportRow, type ReportRowKind, ReportTable, } from "./ReportTable.js";
5
6
  export { type RowAction, RowActions } from "./RowActions.js";
@@ -2,6 +2,7 @@
2
2
  wiring to TanStack for grouping and sorting. See the note in `DataTable.tsx`. */
3
3
  export { DataTable, } from "./DataTable.js";
4
4
  export { EditableTable, sectionLines, stackPlaces, tabTarget, } from "./EditableTable.js";
5
+ export { PileFootLine } from "./PileFootLine.js";
5
6
  export { PivotTable, } from "./PivotTable.js";
6
7
  export { ReportTable, } from "./ReportTable.js";
7
8
  export { RowActions } from "./RowActions.js";
@@ -6,7 +6,7 @@ import type { Surface } from "../../foundations/types.js";
6
6
  import type { DataColumn, DataTableCreate } from "../../primitives/table/DataTable.js";
7
7
  import type { RowAction } from "../../primitives/table/RowActions.js";
8
8
  import type { SortState } from "../../primitives/table/Table.js";
9
- import { type ListCalendar, type ListCrumb, type ListEmpty, type ListGantt, type ListGroup, type ListPivot, type ListPresent, type ListReport, type ListSet, type ListSheet, type ListTimeline, type RowOf } from "./types.js";
9
+ import { type CardWays, type ListCalendar, type ListCrumb, type ListEmpty, type ListGantt, type ListGroup, type ListPivot, type ListPresent, type ListReport, type ListSet, type ListSheet, type ListTimeline, type RowOf } from "./types.js";
10
10
  import type { ReactNode } from "react";
11
11
  type BarProps = {
12
12
  children?: ReactNode;
@@ -51,7 +51,8 @@ type TableProps<T extends object> = {
51
51
  * nombre, el `meta` y las fichas. Escribirla igual es declarar que ESTA lista
52
52
  * se puede ver como tablero. */
53
53
  type BoardProps<T> = {
54
- card?: (item: T) => ReactNode;
54
+ /** `ways` carries the list's rule of what a press does — call `ways.press` from the card. */
55
+ card?: (item: T, ways: CardWays) => ReactNode;
55
56
  /** One column per stage, with drag (and Alt + ← / →) to move a card between them. */
56
57
  stages?: BoardStages<T>;
57
58
  };
@@ -16,7 +16,7 @@ import { Breadcrumb } from "../../components/record/Breadcrumb.js";
16
16
  import { RecordTimeline } from "../../components/timeline/RecordTimeline.js";
17
17
  import { useUiCopy } from "../../foundations/copy.js";
18
18
  import { useDensity } from "../../foundations/density.js";
19
- import { pathKey, pileIsOpen, pilesOf, } from "../../foundations/grouping.js";
19
+ import { pathKey, pileFoot, pileIsOpen, pilesOf, } from "../../foundations/grouping.js";
20
20
  import { pivotFrom, pivotIsEmpty } from "../../foundations/pivot.js";
21
21
  import { Button } from "../../primitives/button/Button.js";
22
22
  import { Empty, Failed, Skeleton } from "../../primitives/notice/Notice.js";
@@ -242,6 +242,11 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
242
242
  los decide la vista para la lista entera. */
243
243
  const first = items[0];
244
244
  const firstRow = first === undefined ? undefined : row(first);
245
+ /* The same foot the table draws: «Ver 50 más de 49.950», or «Se muestran 500 de …». */
246
+ function footOf(pile) {
247
+ const foot = pileFoot(pile, group?.onMore !== undefined);
248
+ return foot === null ? null : (_jsx(GroupMore, { foot: foot, depth: pile.depth, onMore: () => group?.onMore?.(pile) }));
249
+ }
245
250
  /** Los montones, con sus filas adentro. Se llama a sí misma porque el árbol
246
251
  * tiene N niveles: un montón dibuja su encabezado y, adentro, o bien los
247
252
  * montones de abajo o bien las filas — nunca las dos cosas. */
@@ -255,10 +260,7 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
255
260
  ? null
256
261
  : pile.piles.length > 0
257
262
  ? renderPiles(pile.piles, nodes)
258
- : nodes(pile.items), open &&
259
- pile.piles.length === 0 &&
260
- pile.items.length < pile.count &&
261
- group?.onMore !== undefined ? (_jsx(GroupMore, { left: pile.count - pile.items.length, depth: pile.depth, onMore: () => group.onMore?.(pile) })) : null] }, key));
263
+ : nodes(pile.items), open ? footOf(pile) : null] }, key));
262
264
  });
263
265
  }
264
266
  const reportRows = useMemo(() => reportRowsOf(piles, columns, row), [piles, columns, row]);
@@ -267,9 +269,14 @@ export function ListView({ items, keyOf, identity, crumbs, embedded = false, sur
267
269
  return nodes(ordered);
268
270
  return renderPiles(piles, nodes);
269
271
  }
272
+ const waysOf = (item) => ({
273
+ ...(press === undefined ? {} : { press: () => press(item) }),
274
+ ...(onPeek === undefined || choosing ? {} : { peek: () => onPeek(item) }),
275
+ ...(onOpen === undefined || choosing ? {} : { open: () => onOpen(item) }),
276
+ });
270
277
  const renderCard = (item) => {
271
278
  if (card !== undefined) {
272
- return _jsx(Fragment, { children: card(item) }, keyOf(item));
279
+ return _jsx(Fragment, { children: card(item, waysOf(item)) }, keyOf(item));
273
280
  }
274
281
  const d = row(item);
275
282
  return (_jsx(RecordCard, { title: d.name, ...(d.tags === undefined ? {} : { tags: d.tags }), ...(d.meta === undefined ? {} : { meta: d.meta }), ...(press === undefined ? {} : { onOpen: () => press(item) }) }, keyOf(item)));
@@ -1,2 +1,2 @@
1
1
  export { List, type ListPresentProps, type ListProps } from "./List.js";
2
- export { type ListCrumb, type ListEmpty, type ListGroup, type ListPivot, type ListPresent, type ListReport, type ListSet, type ListSheet, type RowOf, } from "./types.js";
2
+ export { type CardWays, type ListCrumb, type ListEmpty, type ListGroup, type ListPivot, type ListPresent, type ListReport, type ListSet, type ListSheet, type RowOf, } from "./types.js";
@@ -41,6 +41,20 @@ export type RowOf = {
41
41
  children?: ReactNode;
42
42
  actions?: ReactNode;
43
43
  };
44
+ /**
45
+ * What a press on ONE card does in this list, already bound to its record.
46
+ *
47
+ * Context: a board's own card replaced the built-in one and lost, with it, the rule of what
48
+ * a press does — the application had to pass it by hand. It gets the same three ways as a
49
+ * `List.Present` (`ListSet`), so a card written by the domain answers a click like the
50
+ * list's own: `press` is the rule (choose, look or open, as the list decided); `peek` and
51
+ * `open` are the two ways in, absent while the list is choosing.
52
+ */
53
+ export type CardWays = {
54
+ press?: () => void;
55
+ peek?: () => void;
56
+ open?: () => void;
57
+ };
44
58
  /**
45
59
  * The set as a presentation receives it — what a `List.Present` draws from.
46
60
  *
@@ -286,8 +300,8 @@ export type ListViewProps<T extends object> = {
286
300
  * columns that declare `input` get a control in that row. See `DataTable`'s header.
287
301
  * While a record is being written the table shows even if the set is empty. */
288
302
  create?: DataTableCreate<T>;
289
- /** The board presentation. */
290
- card?: (item: T) => ReactNode;
303
+ /** The board presentation: the card of one record, with what a press on it does. */
304
+ card?: (item: T, ways: CardWays) => ReactNode;
291
305
  /** The board by stages: one column each, cards moved between them. */
292
306
  boardStages?: BoardStages<T>;
293
307
  /** The calendar presentation: when each record happens. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/web-ui",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "An enterprise web design system for business software — ERPs, back offices, management tools — in React and Tailwind: tokens, primitives, components and the two views every management screen is made of.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",