@lotics/ui 47.1.1 → 47.2.1

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/MIGRATION.md CHANGED
@@ -1,8 +1,47 @@
1
1
  # @lotics/ui — migration notes
2
2
 
3
3
  Breaking changes, newest first — normally per major, plus the rare minor that broke a type
4
- anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
- this file exists only to move an app from one release to the next.
4
+ anyway (recorded under its exact version), **and a minor that changes what an existing call site
5
+ LOOKS LIKE with no code change**. That last kind needs an entry precisely because nothing forces
6
+ the author to read one: their screens shift on an `npm update` and they have nothing to attribute
7
+ it to, so they revert or file a bug. The current contract lives in `AGENTS.md` + `docs/`; this
8
+ file exists only to move an app from one release to the next.
9
+
10
+ ## 47.1.0
11
+
12
+ **No API breaks and no action required — but four things change what an existing screen looks
13
+ like.** All four are fixes to a component asserting something it could not know, so the new
14
+ rendering is the correct one; they are listed so nobody spends an afternoon on them.
15
+
16
+ - **`ProgressBar`'s compact caption is muted at every ratio.** It used to paint `success` at
17
+ ≥ 100%. A meter can mean "done" (a task, an upload) or "used up" (a credit limit, a quota), and
18
+ the same 100% is good in one and bad in the other — so every usage-against-a-cap meter drew a red
19
+ bar with a green number beside it. The verdict now lives only where the caller states it: the
20
+ FILL, via `completeColor`. A task meter that wants a green number renders its own caption
21
+ (`format="none"` plus your text).
22
+ - **`Breakdown`'s value is `medium` and `StackedBarChart`'s label is `regular`.** They draw the same
23
+ row — label left, right-aligned figure, proportion bar — and disagreed about weight, so a card
24
+ placing them side by side made one read as more important for no stated reason. One rule now: the
25
+ label names the series and stays regular, the figure carries the weight, and `semibold` stays
26
+ reserved for selection.
27
+ - **A disabled `MenuButton` now looks disabled.** It blocked the press and announced
28
+ `aria-disabled` while rendering at full ink, so a blocked item was pixel-identical to a live one.
29
+ If you were hiding items rather than disabling them to work around this, you can stop.
30
+ - **`ChargeLines`' narrow quantity slot is 60px (was 48).** `28,9` needed 33px in the 30 available
31
+ and clipped. Slightly less room for the price column in the narrow fork.
32
+
33
+ Two smaller layout corrections in the same release: `Finding`'s readings stretch instead of
34
+ shrink-wrapping past their column, and `RecordSummary`'s identity row wraps rather than letting the
35
+ title shrink while the metric keeps full width.
36
+
37
+ **New, all additive and defaulted to the previous behaviour:** `TableColumn.lead` (declare the
38
+ fixed chrome inside a flexible cell, so the fit's read floor is for the TEXT — pass it wherever you
39
+ put a mark in the subject cell), `MemberChip.showAvatar` (`false` where the member is one field
40
+ among columns rather than the row's subject), `InlineStatic.actions` and `InlineSlot.actions` (a
41
+ verb about a read-only value, on the value's own surface), `ChargeLines.formatQuantity`
42
+ (band-level, like `formatMoney` — without it the kit printed `28.9` in a locale that writes
43
+ `28,9`), `ThumbnailStack.decorative` (silence the marks where the row already names the thing),
44
+ and `FormTextInput` now forwards `optionalLabel`, which it previously accepted and dropped.
6
45
 
7
46
  ## 47.0.0
8
47
 
package/docs/catalog.md CHANGED
@@ -1712,6 +1712,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1712
1712
  - **`column_filter`** — `ColumnFilter` + `columnFilterToConditions` +
1713
1713
  `isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
1714
1714
  register filtering on several columns.
1715
+ - **`filter_band`** — `FilterBand`: the toolbar ROW above a list — search, the `FilterChip`s,
1716
+ and the screen's primary action. **On a small screen the chips collapse into one `Filters (n)`
1717
+ button** that opens a sheet where the same chips render as full-width disclosure rows, one
1718
+ editor open at a time. Laid out inline, a search box plus five chips plus a segmented control
1719
+ is three wrapped rows; on a 390px phone that is most of the viewport spent before the first
1720
+ record. Search and the action never collapse — both are on the shortest path to what someone
1721
+ opened the screen for. Pass `activeCount` so the collapsed button still says the list is
1722
+ filtered, and `onClearAll` to put one clear in the sheet. Compose the band with this rather
1723
+ than a hand-rolled `<View flexDirection="row">`: a hand-rolled band is the one that does not
1724
+ collapse.
1715
1725
  - **`filter_chip`** — `FilterChip` + `selectSummary`: the toolbar filter pill hosting a
1716
1726
  facet (options, a `Slider range`, a `Counter`). It draws NO bottom band of its own — the ×
1717
1727
  on the pill is the clear, and the editor inside brings whatever actions it has, so a
@@ -83,6 +83,15 @@ already has: errors arrive from a validate pass, and editing a field clears that
83
83
 
84
84
  ## A picker over a select FIELD keeps the field's colours — `optionPicker`
85
85
 
86
+ **First, the precondition nobody states: the field's colours have to MEAN something.** A platform
87
+ that auto-assigns option colours does it **by position** — the first option blue, then green,
88
+ amber, red, purple, teal — so a field whose options were created without colours carries a
89
+ sequence nobody chose. Propagate that faithfully and you ship the accident consistently: measured
90
+ on a real workspace, the same rule would have painted an in-transit order **red** and a customer on
91
+ delivery hold **green**, inches from the control that decides whether to keep delivering to them.
92
+ So: set the colours on the FIELD, semantically, then carry them everywhere. Carrying an
93
+ auto-assigned default is worse than plain text, because plain text does not assert anything.
94
+
86
95
  <InlineSelect {...optionPicker(fields.city?.options ?? [])} // a category
87
96
  value={cityKey} onSave={(v) => save({ city: v })} />
88
97
  <InlineSelect {...optionPicker(fields.status?.options ?? [], { badge: "dot" })} // a status
package/docs/reviewing.md CHANGED
@@ -560,6 +560,23 @@ on a defect that does not exist. Walk the surface with real `Tab` presses and re
560
560
  `box-shadow` at each stop; `.focus()` is only good for asking what CAN take focus, never for how it
561
561
  looks when it does.
562
562
 
563
+ ### 9b. One VALUE, one treatment — the cross-shape check
564
+ Probe 1 asks "same shape → same treatment", which groups filter chips with filter chips and table
565
+ cells with table cells, finds each group internally consistent, and passes. The defect it cannot
566
+ see is **one datum reading two ways across shapes**: a stage drawn as a coloured badge in the
567
+ register and as plain text in the filter that selects it, on the same screen.
568
+
569
+ **Collect**, per select field: every surface its value renders on — register cell, filter chip and
570
+ its dropdown, drawer, record field at rest and editing, picker option, diff row, summary line,
571
+ chart legend — and the colour each one paints. **The signature** is one field whose renders
572
+ disagree: some coloured, some plain, or two different colours for one key.
573
+
574
+ Two causes, both silent. The picker's own option shape has no colour member, so the obvious
575
+ adapter drops it and compiles; and a hand-written key→colour map beside the field's own colours is
576
+ a second copy free to disagree — check it against the FIELD, not against itself.
577
+
578
+ → [data_entry.md](./data_entry.md) §"A picker over a select FIELD keeps the field's colours".
579
+
563
580
  ### 10. Microcopy
564
581
  - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
565
582
  refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
package/docs/templates.md CHANGED
@@ -386,8 +386,11 @@ The corollaries, each of which a register is routinely missing:
386
386
  information — a badge that shows nothing for a row missing its invoice is exactly the column
387
387
  that earns its width.
388
388
 
389
- - **One toolbar row** — search + a status `Select` + facet `FilterChip`s LEFT, the New CTA
390
- RIGHT; then a light `SummaryLine` of the filtered view BELOW it, above the rows.
389
+ - **One toolbar row, built with `FilterBand`** — search + a status `Select` + facet `FilterChip`s LEFT,
390
+ the New CTA RIGHT. **Use `FilterBand` rather than a hand-rolled `<View flexDirection="row">`**: on a
391
+ phone it collapses the facets into one `Filters (n)` button and a sheet, and a hand-rolled band is the
392
+ one that does not — inline, this row is three wrapped lines at 390px and eats the screen before the
393
+ first record. Search and the CTA stay on the band at every width; then a light `SummaryLine` of the filtered view BELOW it, above the rows.
391
394
  **Search is ALWAYS the leftmost control**, and the order after it is stated rather than left to
392
395
  the author: search, then the scope the rest of the row filters WITHIN (a site, an account, a
393
396
  period), then the facets. Search is the one control every register carries and the one a reader
@@ -28,6 +28,7 @@ import { Pagination } from "@lotics/ui/pagination";
28
28
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
29
29
  import { SearchInput } from "@lotics/ui/search_input";
30
30
  import { Select } from "@lotics/ui/select";
31
+ import { FilterBand } from "@lotics/ui/filter_band";
31
32
  import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
32
33
  import { ColumnFilter, type ColumnFilterValue } from "@lotics/ui/column_filter";
33
34
  import { OptionList } from "@lotics/ui/option_list";
@@ -1245,21 +1246,25 @@ export function TplItemList() {
1245
1246
 
1246
1247
  {/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
1247
1248
  A search is present, so status is a dropdown (Select), not pills.
1248
-
1249
- `flex-start`, not `center`: the filter block wraps to two or three
1250
- lines on a phone, and centring floats the CTA against the whole
1251
- wrapped block measured landing level with the SECOND row of chips,
1252
- anchored to nothing. The band's top edge is what it belongs to. */}
1253
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
1254
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center", flex: 1 }}>
1255
- <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
1256
- <SearchInput
1257
- placeholder="Search by ID, name, or phone…"
1258
- value={search}
1259
- onChangeText={doiTimKiem}
1260
- accessibilityLabel="Search records"
1261
- />
1262
- </View>
1249
+ `FilterBand` owns the row. On a phone it collapses the filters into a
1250
+ single `Filters (n)` button and a sheet, so search and the CTA stay on
1251
+ one line. This row used to wrap to two or three lines there, which also
1252
+ left the CTA anchored to nothing centred, it floated against the whole
1253
+ wrapped block and landed level with the SECOND row of chips. Collapsing
1254
+ the filters removes the wrap, and the alignment question with it. */}
1255
+ <FilterBand
1256
+ activeCount={[assignee.length > 0, feeStatus != null, feeAmount != null].filter(Boolean).length}
1257
+ onClearAll={() => { setAssignee([]); setFeeStatus(null); setFeeAmount(undefined); resetPaging(); }}
1258
+ action={<Button title="Enter data" color="primary" onPress={openIntake} />}
1259
+ search={
1260
+ <SearchInput
1261
+ placeholder="Search by ID, name, or phone…"
1262
+ value={search}
1263
+ onChangeText={doiTimKiem}
1264
+ accessibilityLabel="Search records"
1265
+ />
1266
+ }
1267
+ >
1263
1268
  <Select
1264
1269
  placeholder="Status"
1265
1270
  options={TABS.map((t) => ({
@@ -1310,11 +1315,7 @@ export function TplItemList() {
1310
1315
  value={feeAmount}
1311
1316
  onChange={(v) => { setFeeAmount(v); resetPaging(); }}
1312
1317
  />
1313
- </View>
1314
- {/* the intake TRIGGER — the register's whole-surface drop/paste opens
1315
- the same dialog (see the FileDropTarget wrapper below) */}
1316
- <Button title="Enter data" color="primary" onPress={openIntake} />
1317
- </View>
1318
+ </FilterBand>
1318
1319
 
1319
1320
  {/* THE SUMMARY READS THE FILTERED SET, so it sits BELOW the controls that
1320
1321
  produce it. These figures move every time the reader touches a chip:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.1.1",
3
+ "version": "47.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -113,6 +113,7 @@
113
113
  "./file_thumbnail": "./src/file_thumbnail.tsx",
114
114
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
115
115
  "./files_editor": "./src/files_editor.tsx",
116
+ "./filter_band": "./src/filter_band.tsx",
116
117
  "./filter_chip": "./src/filter_chip.tsx",
117
118
  "./finding": "./src/finding.tsx",
118
119
  "./floating_action_bar": "./src/floating_action_bar.tsx",
@@ -0,0 +1,103 @@
1
+ import { createContext, useContext, useState, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "./dialog";
5
+ import { SPACE } from "./spacing";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { useScreenSize } from "./use_screen_size";
8
+
9
+ interface FilterBandContextValue {
10
+ /** How a `FilterChip` inside this band should render itself. */
11
+ presentation: "chip" | "row";
12
+ }
13
+
14
+ const FilterBandContext = createContext<FilterBandContextValue | null>(null);
15
+
16
+ /** How the nearest enclosing `FilterBand` wants its filters drawn. `"chip"`
17
+ * outside a band, so a lone `FilterChip` is unaffected. */
18
+ export function useFilterBandPresentation(): "chip" | "row" {
19
+ return useContext(FilterBandContext)?.presentation ?? "chip";
20
+ }
21
+
22
+ export interface FilterBandProps {
23
+ /** The search control. Stays on the band at every width — it is the one
24
+ * filter people reach for without thinking. */
25
+ search?: ReactNode;
26
+ /** The band's primary action. Stays visible at every width, because a screen
27
+ * whose only verb is behind a filter sheet reads as read-only. */
28
+ action?: ReactNode;
29
+ /** How many dimensions are filtering right now — badges the collapsed
30
+ * button, so a narrow screen still says the list is not showing everything. */
31
+ activeCount?: number;
32
+ /** Clears every dimension at once. Rendered inside the sheet when given. */
33
+ onClearAll?: () => void;
34
+ /** The `FilterChip`s. */
35
+ children: ReactNode;
36
+ testID?: string;
37
+ }
38
+
39
+ /**
40
+ * The toolbar row above a list: search, the secondary filter dimensions, and
41
+ * the screen's primary action.
42
+ *
43
+ * **On a narrow screen the dimensions collapse into ONE button.** Laid out
44
+ * inline, a search box plus five chips plus a segmented control is three
45
+ * wrapped rows — on a 390px phone that is most of the viewport spent before a
46
+ * single record, which is what this component exists to prevent. Collapsed, the
47
+ * band is one row: search, a `Filters (n)` button, and the action. The chips
48
+ * move into a sheet and render as full-width rows there, one editor open at a
49
+ * time — the SAME `FilterChip`s, re-presented, so a filter never has two
50
+ * implementations to keep in step.
51
+ *
52
+ * Search and the action never collapse. Both are one tap on the shortest path
53
+ * to what someone opened the screen for, and burying either behind a sheet
54
+ * costs more than the row it saves.
55
+ */
56
+ export function FilterBand(props: FilterBandProps) {
57
+ const { search, action, activeCount = 0, onClearAll, children, testID } = props;
58
+ const t = useLoticsLocale().filterBand;
59
+ const { small } = useScreenSize();
60
+ const [open, setOpen] = useState(false);
61
+
62
+ if (!small) {
63
+ return (
64
+ <View
65
+ testID={testID}
66
+ style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm, flexWrap: "wrap" }}
67
+ >
68
+ {search != null && (
69
+ <View style={{ flexGrow: 1, flexShrink: 1, flexBasis: 220, minWidth: 180, maxWidth: 320 }}>{search}</View>
70
+ )}
71
+ <FilterBandContext.Provider value={{ presentation: "chip" }}>{children}</FilterBandContext.Provider>
72
+ {action != null && <View style={{ marginLeft: "auto" }}>{action}</View>}
73
+ </View>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: SPACE.sm }}>
79
+ {search != null && <View style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>{search}</View>}
80
+ <Button
81
+ color="secondary"
82
+ title={activeCount > 0 ? `${t.filters} (${activeCount})` : t.filters}
83
+ onPress={() => setOpen(true)}
84
+ testID={testID ? `${testID}-filters` : undefined}
85
+ />
86
+ {action}
87
+ <Dialog open={open} onOpenChange={setOpen} width="92%" maxWidth={520}>
88
+ <DialogHeader>
89
+ <DialogHeaderTitle>{t.filters}</DialogHeaderTitle>
90
+ </DialogHeader>
91
+ <DialogScrollArea>
92
+ <FilterBandContext.Provider value={{ presentation: "row" }}>{children}</FilterBandContext.Provider>
93
+ </DialogScrollArea>
94
+ <DialogFooter>
95
+ {onClearAll != null && activeCount > 0 && (
96
+ <Button color="muted" title={t.clearAll} onPress={onClearAll} />
97
+ )}
98
+ <Button title={t.done} onPress={() => setOpen(false)} />
99
+ </DialogFooter>
100
+ </Dialog>
101
+ </View>
102
+ );
103
+ }
@@ -7,6 +7,8 @@ import { Chip } from "./chip";
7
7
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
8
8
  import type { PopoverSide, PopoverAlign } from "./popover";
9
9
  import { useLoticsLocale } from "./locale";
10
+ import { useFilterBandPresentation } from "./filter_band";
11
+ import { Pressable } from "react-native";
10
12
 
11
13
  export interface FilterChipProps {
12
14
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
@@ -103,6 +105,7 @@ export function FilterChip(props: FilterChipProps) {
103
105
  // Own the open state so the editor can close itself via the render-prop
104
106
  // `close`, while still honoring a controlled `open`/`onOpenChange` from the
105
107
  // parent (the Save-footer VALUE-pill case).
108
+ const presentation = useFilterBandPresentation();
106
109
  const [internalOpen, setInternalOpen] = useState(false);
107
110
  const isOpen = open ?? internalOpen;
108
111
  const setOpen = (next: boolean) => {
@@ -110,6 +113,41 @@ export function FilterChip(props: FilterChipProps) {
110
113
  onOpenChange?.(next);
111
114
  };
112
115
 
116
+ // Inside a COLLAPSED `FilterBand` the pill is the wrong shape: a sheet is a
117
+ // vertical list, and a row of pills in it wraps exactly the way the band did
118
+ // on the screen this collapse exists to fix. Same component, same state, same
119
+ // editor — re-presented as a full-width disclosure row.
120
+ if (presentation === "row") {
121
+ return (
122
+ <View style={styles.row}>
123
+ <Pressable
124
+ testID={testID}
125
+ onPress={() => setOpen(!isOpen)}
126
+ accessibilityRole="button"
127
+ aria-expanded={isOpen}
128
+ style={styles.rowHeader}
129
+ >
130
+ <Text size="sm" weight="medium" color="zinc-700">{label}</Text>
131
+ <View style={styles.rowRight}>
132
+ {active ? (
133
+ typeof summary === "string" ? (
134
+ <Text size="sm" numberOfLines={1} style={{ color: colors.accent }}>{summary}</Text>
135
+ ) : (
136
+ summary
137
+ )
138
+ ) : null}
139
+ <Icon name={isOpen ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc["400"]} />
140
+ </View>
141
+ </Pressable>
142
+ {isOpen ? (
143
+ <View style={styles.rowBody}>
144
+ {typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
145
+ </View>
146
+ ) : null}
147
+ </View>
148
+ );
149
+ }
150
+
113
151
  return (
114
152
  <Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
115
153
  <PopoverTrigger>
@@ -164,4 +202,24 @@ const styles = StyleSheet.create({
164
202
  alignItems: "center",
165
203
  gap: 5,
166
204
  },
205
+ row: {
206
+ borderTopWidth: StyleSheet.hairlineWidth,
207
+ borderTopColor: colors.zinc["200"],
208
+ },
209
+ rowHeader: {
210
+ flexDirection: "row",
211
+ alignItems: "center",
212
+ justifyContent: "space-between",
213
+ gap: 12,
214
+ paddingVertical: 14,
215
+ },
216
+ rowRight: {
217
+ flexDirection: "row",
218
+ alignItems: "center",
219
+ gap: 8,
220
+ flexShrink: 1,
221
+ },
222
+ rowBody: {
223
+ paddingBottom: 12,
224
+ },
167
225
  });
package/src/locale.tsx CHANGED
@@ -68,6 +68,7 @@ export interface LoticsLocale {
68
68
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
69
69
  * a call site doesn't pass a dimension-specific `clearLabel`. */
70
70
  filterChip: { clear: string };
71
+ filterBand: { filters: string; clearAll: string; done: string };
71
72
  /** `FloatingActionBar`: the escape that drops the selection. The COUNT's noun is
72
73
  * the call site's `label` (it names the rows), so only the escape lives here. */
73
74
  floatingActionBar: { clear: string };
@@ -348,6 +349,7 @@ export const en: LoticsLocale = {
348
349
  empty: "Nothing scheduled",
349
350
  },
350
351
  filterChip: { clear: "Clear" },
352
+ filterBand: { filters: "Filters", clearAll: "Clear all", done: "Done" },
351
353
  floatingActionBar: { clear: "Clear" },
352
354
  formField: { optional: "Optional" },
353
355
  dangerZone: { title: "Danger zone" },
@@ -567,6 +569,7 @@ export const vi: LoticsLocale = {
567
569
  empty: "Chưa có việc nào",
568
570
  },
569
571
  filterChip: { clear: "Xóa" },
572
+ filterBand: { filters: "Bộ lọc", clearAll: "Xoá lọc", done: "Xong" },
570
573
  floatingActionBar: { clear: "Bỏ chọn" },
571
574
  formField: { optional: "Tùy chọn" },
572
575
  dangerZone: { title: "Vùng nguy hiểm" },