@lotics/ui 43.3.2 → 43.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.
- package/AGENTS.md +3 -2
- package/MIGRATION.md +93 -0
- package/docs/catalog.md +128 -26
- package/docs/composition.md +380 -9
- package/docs/data_entry.md +25 -0
- package/docs/reviewing.md +477 -0
- package/docs/templates.md +9 -6
- package/examples/tpl_attendance.tsx +0 -1
- package/examples/tpl_item_list.tsx +109 -54
- package/examples/tpl_record.tsx +89 -4
- package/package.json +7 -3
- package/src/avatar.tsx +29 -29
- package/src/avatar.web.tsx +32 -31
- package/src/avatar_props.ts +66 -0
- package/src/avatar_tone.ts +79 -0
- package/src/button.tsx +36 -4
- package/src/checkbox.tsx +4 -1
- package/src/choice_list.tsx +5 -3
- package/src/color_tokens.ts +15 -14
- package/src/composer.tsx +3 -3
- package/src/control_surface.ts +37 -9
- package/src/copy_button.tsx +8 -1
- package/src/counter.tsx +1 -1
- package/src/data_grid.tsx +6 -3
- package/src/date_calendar.tsx +4 -2
- package/src/date_range_filter_field.tsx +5 -1
- package/src/date_segments_field.tsx +2 -2
- package/src/file_drop_target.web.tsx +2 -2
- package/src/file_dropzone.tsx +12 -6
- package/src/file_rows.tsx +22 -2
- package/src/file_thumbnail.tsx +19 -3
- package/src/font_family.ts +1 -1
- package/src/font_family.web.ts +1 -1
- package/src/funnel.tsx +1 -1
- package/src/icon_button.tsx +5 -2
- package/src/index.css +22 -16
- package/src/inline_edit.tsx +9 -10
- package/src/inline_files.tsx +6 -0
- package/src/json_panel.tsx +1 -1
- package/src/kpi_card.tsx +1 -1
- package/src/locale.tsx +1 -1
- package/src/markdown.css +5 -1
- package/src/member_chip.tsx +19 -2
- package/src/metric.tsx +12 -18
- package/src/number_input.tsx +2 -2
- package/src/option_picker.tsx +57 -0
- package/src/picker.tsx +2 -2
- package/src/pressable_row.tsx +15 -5
- package/src/progress_bar.tsx +1 -1
- package/src/radio_picker.tsx +2 -1
- package/src/search_input.tsx +12 -11
- package/src/sort_header.tsx +7 -3
- package/src/stacked_progress_bar.tsx +1 -1
- package/src/step_progress.tsx +5 -2
- package/src/switch.tsx +11 -6
- package/src/table.tsx +148 -16
- package/src/table_fit.ts +12 -0
- package/src/tabs.tsx +13 -1
- package/src/text.css +50 -18
- package/src/text.tsx +37 -40
- package/src/text_input_field.tsx +2 -2
- package/src/text_utils.ts +48 -6
- package/src/theme.ts +13 -0
- package/src/theme.web.ts +49 -0
- package/src/theme_vars.ts +113 -0
- package/src/type_ramp.ts +100 -0
- package/src/theme.tsx +0 -24
- package/src/theme.web.tsx +0 -79
- package/src/theme_context.ts +0 -107
|
@@ -5,6 +5,7 @@ import { Text } from "@lotics/ui/text";
|
|
|
5
5
|
import { colors, solid, type ColorName } from "@lotics/ui/colors";
|
|
6
6
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
7
7
|
import { Alert } from "@lotics/ui/alert";
|
|
8
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
8
9
|
import { Badge } from "@lotics/ui/badge";
|
|
9
10
|
import { Button } from "@lotics/ui/button";
|
|
10
11
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
@@ -60,7 +61,8 @@ import { useChangeSet } from "@lotics/ui/use_change_set";
|
|
|
60
61
|
// Template, Register — THE canonical register, and the ONE work-execution list
|
|
61
62
|
// shape. A flat, paginated, SELECTABLE list of records: one toolbar row (search +
|
|
62
63
|
// consolidated filters LEFT, New CTA RIGHT), a light SummaryLine of the filtered
|
|
63
|
-
// view, a sortable
|
|
64
|
+
// view, a sortable register whose rows are separated by their own height and the
|
|
65
|
+
// hover wash rather than by a rule, where every row has a leading
|
|
64
66
|
// checkbox (a row that can't take the bulk action — here an "Awaiting docs" case —
|
|
65
67
|
// gets a DISABLED one), a per-row PRINT action button, and a ⋯ overflow (press the
|
|
66
68
|
// row = the sequenced workspace Drawer), a select-all band, footer totals +
|
|
@@ -215,59 +217,98 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
|
|
|
215
217
|
ordinal={ordinal}
|
|
216
218
|
selected={selected}
|
|
217
219
|
marked={marked}
|
|
218
|
-
minHeight={56}
|
|
219
220
|
accessibilityLabel={`Open record ${hs.ma} — ${hs.khach}`}
|
|
220
221
|
leading={<CheckboxInput accessibilityLabel={selectable ? `Select ${hs.ma}` : `${hs.ma} — awaiting docs, not ready to export`} checked={marked} disabled={!selectable} onChange={onToggle} />}
|
|
221
222
|
action={<Button title="Print" icon={docFor(hs, daThu).icon} color="secondary" onPress={() => {}} />}
|
|
222
223
|
trailing={<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />}
|
|
223
224
|
>
|
|
224
225
|
<TableCell>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<View style={{
|
|
252
|
-
{/* The
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
226
|
+
{/* THE IDENTITY MARK leads the cell that names the row. A register of
|
|
227
|
+
entities without one is N rows of grey text a reader scans linearly;
|
|
228
|
+
with one, the eye lands on the row it wants by colour and letter
|
|
229
|
+
before it reads a word. `Avatar` derives its hue from the name, so
|
|
230
|
+
the marks differ across rows without anyone configuring anything —
|
|
231
|
+
the failure mode to avoid is N identical marks, which is the largest
|
|
232
|
+
and brightest thing on the row carrying no information at all.
|
|
233
|
+
|
|
234
|
+
It sits in the SUBJECT'S CELL, not the `leading` slot: `leading`
|
|
235
|
+
already holds the selection checkbox, and those two are different
|
|
236
|
+
jobs — one is what the row IS, the other is something you are doing
|
|
237
|
+
to it. Stacking them in one slot reads as a single compound control.
|
|
238
|
+
|
|
239
|
+
`lg` because the customer is what this row is ABOUT. A person who is
|
|
240
|
+
one FIELD among columns (the owner, below) takes `md`; see
|
|
241
|
+
`avatar_size.ts`, which owns that rule.
|
|
242
|
+
|
|
243
|
+
ONE SHAPE for the whole register. `Avatar` takes `square` for an
|
|
244
|
+
organization, but that distinction earns its place only where a
|
|
245
|
+
surface mixes entity TYPES and the reader acts on which is which.
|
|
246
|
+
Here every row is a record, and whether the customer is a company is
|
|
247
|
+
not something the reader does anything with — so a column of mixed
|
|
248
|
+
circles and squares reads as an inconsistency rather than as
|
|
249
|
+
information, which is exactly how it gets reported. */}
|
|
250
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, alignSelf: "stretch" }}>
|
|
251
|
+
<Avatar name={hs.khach} size="lg" />
|
|
252
|
+
<View style={{ gap: 2, flex: 1, minWidth: 0 }}>
|
|
253
|
+
{/* The name takes the row, the thread pins right. A comment count is
|
|
254
|
+
the one thing on this row that is about PEOPLE talking rather than
|
|
255
|
+
the record's own state, so it sits apart from the values below it
|
|
256
|
+
instead of joining them on the supporting line. */}
|
|
257
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
258
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
|
|
259
|
+
{/* Rendered only where a thread EXISTS — a zero on every quiet row is a
|
|
260
|
+
column of noise that trains the eye to skip where the signal appears. */}
|
|
261
|
+
{hs.binhLuan > 0 ? <CommentsButton count={hs.binhLuan} subject={hs.khach} onPress={onOpenComments} /> : null}
|
|
262
|
+
</View>
|
|
263
|
+
{/* Every value on this row is selectable — that is what a register owes
|
|
264
|
+
its reader, and it costs no chrome. The phone gets a CONTROL on top
|
|
265
|
+
because it is the one value copied often enough to earn one (a desk
|
|
266
|
+
pastes it into a message, a rep dials it). The verb rides the value
|
|
267
|
+
inside its own cell: the trailing gutter belongs to the ⋯, and with
|
|
268
|
+
two values in this cell it would have nothing to name.
|
|
269
|
+
|
|
270
|
+
UNCONDITIONAL, and that is the load-bearing part of this shape. A
|
|
271
|
+
row centres its cells, so a supporting line that appears only when
|
|
272
|
+
its field is set makes this cell two heights, and the NAME above it
|
|
273
|
+
then sits at two different offsets down the column while every row
|
|
274
|
+
still measures the same. That is why the values here are ones every
|
|
275
|
+
record carries — a key, a contact — rather than whichever field is
|
|
276
|
+
most interesting: the line has to be honest on every row to be
|
|
277
|
+
reservable at all. */}
|
|
278
|
+
{/* HEIGHT PINNED TO THE xs LINE BOX (18), because this line carries a
|
|
279
|
+
CONTROL. `CopyButton` at `sm` is 24 — sized to sit beside `sm`
|
|
280
|
+
text, per its own doc — so on an `xs` line it made the row 24 and
|
|
281
|
+
`alignItems: "center"` spent 3 of those px above the text. The
|
|
282
|
+
supporting line then started 5px under the name while the Fee
|
|
283
|
+
column's identical primary-over-supporting pair, which carries no
|
|
284
|
+
control, started 2px under its own. Same shape, two treatments,
|
|
285
|
+
and the reason (one of them has a button) is invisible to a
|
|
286
|
+
reader — which is the whole test.
|
|
287
|
+
|
|
288
|
+
Pinning the row to the text's line box lets the 24px hit area
|
|
289
|
+
overflow it without moving the text. Any verb riding a supporting
|
|
290
|
+
line needs this; a control must not resize the line it sits on. */}
|
|
291
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, height: 18 }}>
|
|
292
|
+
{/* The KEY, muted, on the supporting line — at most this, never a
|
|
293
|
+
column and never the row's name.
|
|
294
|
+
|
|
295
|
+
It does NOT shrink. A key is quoted whole or it is not a key: a
|
|
296
|
+
phone number clipped to "(555) 384-7…" is still recognisably a
|
|
297
|
+
number the reader can widen the column to finish, while
|
|
298
|
+
"RC-2026-0…" is unusable — it is the exact string a desk reads
|
|
299
|
+
out. So the fixed-length identifier holds its width and the phone
|
|
300
|
+
gives way, which is also the right order of loss: the phone has a
|
|
301
|
+
Copy control beside it that hands over the full value regardless. */}
|
|
302
|
+
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
|
|
303
|
+
{/* A GAP, not a middot. Two facts of different kinds sat either side
|
|
304
|
+
of a ` · ` here, which is the separator this kit bans outright —
|
|
305
|
+
punctuation claiming a relation it refuses to name, and dropped
|
|
306
|
+
entirely by a screen reader. Spacing separates them without
|
|
307
|
+
asserting anything, and the two are already told apart by shape:
|
|
308
|
+
one is a key, one is a number with a Copy control on it. */}
|
|
309
|
+
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
|
|
310
|
+
<CopyButton value={hs.dienThoai} label="Copy phone number" />
|
|
311
|
+
</View>
|
|
271
312
|
</View>
|
|
272
313
|
</View>
|
|
273
314
|
</TableCell>
|
|
@@ -1199,11 +1240,15 @@ export function TplItemList() {
|
|
|
1199
1240
|
<FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
|
|
1200
1241
|
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
1201
1242
|
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
1202
|
-
{/*
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1243
|
+
{/* The page band — the screen's TYPE ANCHOR before it is a label. Without
|
|
1244
|
+
it this page's largest text is 16px against a 12px floor, and a 1.33x
|
|
1245
|
+
range reads flat however much colour is on it. The title repeating the
|
|
1246
|
+
nav is a real cost and the right trade: one line buys the whole range.
|
|
1247
|
+
What is NOT here is a gloss on the widget ("track each record's status
|
|
1248
|
+
and print the right document for its stage"), which described the
|
|
1249
|
+
control rather than the domain — and what is not here EITHER is the
|
|
1250
|
+
summary, which belongs under the filters that change it. */}
|
|
1251
|
+
<Text size="xxl" weight="semibold">Records</Text>
|
|
1207
1252
|
|
|
1208
1253
|
{/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
|
|
1209
1254
|
A search is present, so status is a dropdown (Select), not pills. */}
|
|
@@ -1273,7 +1318,17 @@ export function TplItemList() {
|
|
|
1273
1318
|
<Button title="Enter data" color="primary" onPress={openIntake} />
|
|
1274
1319
|
</View>
|
|
1275
1320
|
|
|
1276
|
-
{/*
|
|
1321
|
+
{/* THE SUMMARY READS THE FILTERED SET, so it sits BELOW the controls that
|
|
1322
|
+
produce it. These figures move every time the reader touches a chip:
|
|
1323
|
+
put them above, and the screen answers before it has been asked — the
|
|
1324
|
+
reader changes a filter, the numbers change behind their eye, and the
|
|
1325
|
+
causal order the layout states is backwards. Under the toolbar it
|
|
1326
|
+
reads as the result of what was just set, and it is the last thing
|
|
1327
|
+
before the rows it counts.
|
|
1328
|
+
|
|
1329
|
+
It is not a page-band subtitle for the same reason. A subtitle
|
|
1330
|
+
describes the screen; these describe the QUERY, and the query is what
|
|
1331
|
+
the toolbar above just wrote. */}
|
|
1277
1332
|
<SummaryLine
|
|
1278
1333
|
items={[
|
|
1279
1334
|
{ label: "processing", value: dem("dangxuly") },
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -52,7 +52,7 @@ import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select"
|
|
|
52
52
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
53
53
|
import { FormField } from "@lotics/ui/form_field";
|
|
54
54
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
55
|
-
import { FileThumbnail, THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
55
|
+
import { FileThumbnail, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
56
56
|
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
57
57
|
import { Inset } from "@lotics/ui/inset";
|
|
58
58
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
@@ -122,6 +122,87 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
|
122
122
|
// table ONCE (creates + links the sibling; Recall undoes).
|
|
123
123
|
// - Danger zone — destructive lifecycle. Always last.
|
|
124
124
|
//
|
|
125
|
+
//
|
|
126
|
+
// ─── THE RULES THIS TEMPLATE ENCODES ─────────────────────────────────────────
|
|
127
|
+
// Read these before adapting it. Each one shipped WRONG on a real app first,
|
|
128
|
+
// was reported by the person using it, and cost a rebuild. They are not style
|
|
129
|
+
// preferences; they are the difference between a screen that works and a screen
|
|
130
|
+
// that gets called bland, cluttered or inconsistent.
|
|
131
|
+
//
|
|
132
|
+
// 1. A BADGE MEANS STATUS. Nothing else.
|
|
133
|
+
// A lifecycle, a stage, a risk level — something that reads at a glance and
|
|
134
|
+
// changes what you DO. A type, a category, an attribute or a count is NOT a
|
|
135
|
+
// status: an industry, a source, a department, a headcount band, a CITY.
|
|
136
|
+
// Those are plain `Text`. A coloured pill only means "state" for as long as
|
|
137
|
+
// it stays scarce, so one spent on where a company happens to be devalues
|
|
138
|
+
// the one column that IS a state. A field carrying a configured `color` is
|
|
139
|
+
// not consent to paint it — someone set that colour for the one surface that
|
|
140
|
+
// badges, and every other surface still has to decide.
|
|
141
|
+
// Feed pickers with `optionPicker(options)` — text — and add
|
|
142
|
+
// `{ badge: "dot" }` only on a status. Helper defaults must be the SAFE
|
|
143
|
+
// answer, because a badging default reaches every call site in one sweep.
|
|
144
|
+
//
|
|
145
|
+
// 2. ONE VARIANT PER CONCEPT, product-wide. `dot` or `tonal`, chosen once. The
|
|
146
|
+
// same field rendering as a filled pill on one screen and a dot on another is
|
|
147
|
+
// the inconsistency people actually notice. Where a register already carries
|
|
148
|
+
// identity marks, prefer `dot`: two colour systems in one row compete and
|
|
149
|
+
// neither reads as the subject.
|
|
150
|
+
//
|
|
151
|
+
// 3. THE PAGE BAND IS THE TYPE ANCHOR before it is a label. Without it a record
|
|
152
|
+
// or register tops out near 16px over a 12px floor — a 1.33x range that reads
|
|
153
|
+
// flat at ANY amount of colour, because scale is the one hierarchy device
|
|
154
|
+
// colour cannot replace. Accept that the title repeats the nav. Its second
|
|
155
|
+
// line carries LIVE STATE (counts that move as you filter), never a gloss on
|
|
156
|
+
// the widget.
|
|
157
|
+
//
|
|
158
|
+
// 4. A SUPPORTING LINE IS ONE RUNG BELOW ITS PRIMARY, never a fixed size. `md`
|
|
159
|
+
// over `xs` drops 1.33x while the same shape elsewhere drops 1.17x, and the
|
|
160
|
+
// wider pair reads as though its second line SHRANK rather than stepped.
|
|
161
|
+
// Compare the RATIOS across the surface, not the sizes.
|
|
162
|
+
//
|
|
163
|
+
// 5. ACTIONABLE FIRST. Identity and the one editable status go above the fold.
|
|
164
|
+
// An UNBOUNDED section (an activity feed, a comment thread) stacked above a
|
|
165
|
+
// bounded field set does not order them — it BURIES the second, and the
|
|
166
|
+
// burial deepens every time the record is used. Measured on a real record,
|
|
167
|
+
// the fields the reader came to edit began 2,500px down. That is the case
|
|
168
|
+
// where tabs beat one page, against the record-extent rule.
|
|
169
|
+
// Promoting a field means MOVING it: a copy left behind gives one field two
|
|
170
|
+
// editors on one surface, and either could be the one the reader changed.
|
|
171
|
+
//
|
|
172
|
+
// 6. PICK THE CONTROL BY SPECIES, from what already exists. Read
|
|
173
|
+
// `data_entry.md`'s ladder and the `tpl_*` that covers the shape BEFORE
|
|
174
|
+
// building. A binary filter is a `ChipGroup` (every option visible, one
|
|
175
|
+
// press), never a popover holding one checkbox. Grouping picks a DIMENSION
|
|
176
|
+
// via the same control species as the filters beside it, never an icon
|
|
177
|
+
// toggling on/off. A SELECTED control needs a GROUND, not a heavier outline —
|
|
178
|
+
// among white pills a 1px border change is invisible.
|
|
179
|
+
//
|
|
180
|
+
// 7. CLUSTER A TOOLBAR BY QUESTION, and check the gap RATIO. Filters answer
|
|
181
|
+
// *which rows*; view controls answer *how are they arranged*; the CTA answers
|
|
182
|
+
// neither. Uniform gaps make eight controls read as one undifferentiated
|
|
183
|
+
// band; 8px within a cluster and 24px between reads as groups with no lines
|
|
184
|
+
// or boxes added. Long labels are a toolbar defect — the band is a set of
|
|
185
|
+
// handles, not sentences.
|
|
186
|
+
//
|
|
187
|
+
// 8. PICK THE FILE SURFACE BY WHAT IDENTIFIES THE FILE, after counting the real
|
|
188
|
+
// data. Images are identified by CONTENT (grid); documents by NAME (list) —
|
|
189
|
+
// a grid of PDFs is a wall of one grey tile. Press should OPEN a document
|
|
190
|
+
// (`press="open"`); a preview lightbox is a dead end for anything you sign,
|
|
191
|
+
// edit or send. A files field is `InlineFiles` in a `DetailRow`, the same
|
|
192
|
+
// grammar as every other field beside it — never a bespoke block below the
|
|
193
|
+
// table. A surface that can SHOW a file and cannot RECEIVE one is unfinished.
|
|
194
|
+
//
|
|
195
|
+
// 9. AN IDENTITY MARK MUST NOT LOOK THE SAME ON EVERY ROW — that is its entire
|
|
196
|
+
// job. `Avatar` derives its hue from the name; circle for a person, square
|
|
197
|
+
// for an organization. A screen whose subject is an entity and whose marks
|
|
198
|
+
// are all one colour has the largest, brightest element in each row carrying
|
|
199
|
+
// no information.
|
|
200
|
+
//
|
|
201
|
+
// 10. NEVER FORK THE KIT. Hitting a limit means fixing the kit — a component
|
|
202
|
+
// built app-local beside one that nearly fits is a fork that drifts, and the
|
|
203
|
+
// next author inherits both. If a prop is missing, add the prop.
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
//
|
|
125
206
|
// The lifecycle is the HANDOFF CHAIN, and it reads in TWO places by design:
|
|
126
207
|
// `Progress` carries the position and the act that changes it; `Handoff` near
|
|
127
208
|
// the end carries the RESULT — what each handoff created, and when. There is no
|
|
@@ -3609,9 +3690,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3609
3690
|
>
|
|
3610
3691
|
<TableCell>
|
|
3611
3692
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
3612
|
-
{/* ONE square
|
|
3613
|
-
|
|
3614
|
-
|
|
3693
|
+
{/* ONE square slot for every type: an image fills it as a real
|
|
3694
|
+
thumbnail, a document centers its badge in it. The rung is
|
|
3695
|
+
the kit's, not a number picked here — it tracks the row
|
|
3696
|
+
height, and a file row's mark is the same size as a
|
|
3697
|
+
person's in a register, because both answer "which row is
|
|
3698
|
+
this". */}
|
|
3699
|
+
<FileThumbnail file={toDisplay(f)} size={COMPACT_THUMBNAIL_SIZE} onPress={() => openPreview(visibleFiles, fi)} />
|
|
3615
3700
|
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
|
|
3616
3701
|
</View>
|
|
3617
3702
|
</TableCell>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "43.
|
|
3
|
+
"version": "43.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"default": "./src/colors.web.ts"
|
|
14
14
|
},
|
|
15
15
|
"./deadline": "./src/deadline.ts",
|
|
16
|
+
"./option_picker": "./src/option_picker.tsx",
|
|
16
17
|
"./option_badge": "./src/option_badge.tsx",
|
|
17
18
|
"./member_chip": "./src/member_chip.tsx",
|
|
18
19
|
"./member_select": "./src/member_select.tsx",
|
|
@@ -90,8 +91,8 @@
|
|
|
90
91
|
"./locale": "./src/locale.tsx",
|
|
91
92
|
"./spacing": "./src/spacing.ts",
|
|
92
93
|
"./theme": {
|
|
93
|
-
"react-native": "./src/theme.
|
|
94
|
-
"default": "./src/theme.web.
|
|
94
|
+
"react-native": "./src/theme.ts",
|
|
95
|
+
"default": "./src/theme.web.ts"
|
|
95
96
|
},
|
|
96
97
|
"./progress_bar": "./src/progress_bar.tsx",
|
|
97
98
|
"./form_date_picker": "./src/form_date_picker.tsx",
|
|
@@ -272,9 +273,12 @@
|
|
|
272
273
|
"./landmark": "./src/landmark.tsx",
|
|
273
274
|
"./skip_link": "./src/skip_link.tsx",
|
|
274
275
|
"./text_utils": "./src/text_utils.ts",
|
|
276
|
+
"./type_ramp": "./src/type_ramp.ts",
|
|
275
277
|
"./column_filter": "./src/column_filter.tsx",
|
|
276
278
|
"./chip_group": "./src/chip_group.tsx",
|
|
277
279
|
"./avatar_size": "./src/avatar_size.ts",
|
|
280
|
+
"./avatar_tone": "./src/avatar_tone.ts",
|
|
281
|
+
"./avatar_props": "./src/avatar_props.ts",
|
|
278
282
|
"./diff_value": "./src/diff_value.tsx",
|
|
279
283
|
"./diff_mark": "./src/diff_mark.tsx",
|
|
280
284
|
"./use_change_set": "./src/use_change_set.ts",
|
package/src/avatar.tsx
CHANGED
|
@@ -1,33 +1,22 @@
|
|
|
1
|
-
import { Image
|
|
1
|
+
import { Image } from "expo-image";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { View, StyleSheet,
|
|
3
|
+
import { View, StyleSheet, ImageStyle } from "react-native";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
-
import { colors } from "./colors";
|
|
6
5
|
import { useLoticsLocale } from "./locale";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** A rung on the shared avatar scale — never a pixel count. See
|
|
11
|
-
* [`avatar_size`](./avatar_size.ts) for why the number was removed. */
|
|
12
|
-
size?: AvatarSize;
|
|
13
|
-
source?: ImageSource;
|
|
14
|
-
name?: string;
|
|
15
|
-
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
16
|
-
contentFit?: ImageContentFit;
|
|
17
|
-
/**
|
|
18
|
-
* When true, the avatar announces its `name` to assistive tech. Default
|
|
19
|
-
* false because avatars almost always appear adjacent to the name text —
|
|
20
|
-
* announcing the image as well would double-read. Pass `announce` when the
|
|
21
|
-
* avatar is standalone (with no visible name nearby).
|
|
22
|
-
*/
|
|
23
|
-
announce?: boolean;
|
|
24
|
-
}
|
|
6
|
+
import { avatarTone } from "./avatar_tone";
|
|
7
|
+
import { AVATAR_PX, AVATAR_TEXT, avatarInitials } from "./avatar_size";
|
|
8
|
+
import { avatarShapeStyle, type AvatarProps } from "./avatar_props";
|
|
25
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A person or an organization, as an identity MARK — an image when there is
|
|
12
|
+
* one, otherwise initials on the brand accent. Prop contract: `avatar_props`.
|
|
13
|
+
*/
|
|
26
14
|
export function Avatar(props: AvatarProps) {
|
|
27
15
|
const locale = useLoticsLocale();
|
|
28
|
-
const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
16
|
+
const { source, size = "md", shape = "circle", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
29
17
|
const decorative = !announce;
|
|
30
18
|
const px = AVATAR_PX[size];
|
|
19
|
+
const shapeStyle = avatarShapeStyle(shape);
|
|
31
20
|
|
|
32
21
|
if (!source || !source.uri) {
|
|
33
22
|
return (
|
|
@@ -39,12 +28,14 @@ export function Avatar(props: AvatarProps) {
|
|
|
39
28
|
aria-hidden={decorative || undefined}
|
|
40
29
|
style={[
|
|
41
30
|
styles.base,
|
|
42
|
-
// The
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
31
|
+
// The disc's ground is DERIVED FROM THE NAME — see `avatar_tone`. It
|
|
32
|
+
// used to be `colors.accent`, which made every person in a register the
|
|
33
|
+
// same colour and left the row's biggest, brightest element carrying no
|
|
34
|
+
// information. The brand did not lose a home; it moved to the chrome
|
|
35
|
+
// (active tab, selection wash, primary action), where it is shared
|
|
36
|
+
// across apps and learned once.
|
|
37
|
+
{ backgroundColor: avatarTone(name), width: px, height: px },
|
|
38
|
+
shapeStyle,
|
|
48
39
|
style,
|
|
49
40
|
]}
|
|
50
41
|
>
|
|
@@ -70,7 +61,7 @@ export function Avatar(props: AvatarProps) {
|
|
|
70
61
|
alt={decorative ? "" : name}
|
|
71
62
|
accessibilityElementsHidden={decorative}
|
|
72
63
|
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
73
|
-
style={[styles.base, { width: px, height: px }, style as ImageStyle]}
|
|
64
|
+
style={[styles.base, { width: px, height: px }, shapeStyle, style as ImageStyle]}
|
|
74
65
|
source={source}
|
|
75
66
|
contentFit={contentFit}
|
|
76
67
|
cachePolicy="memory-disk"
|
|
@@ -85,5 +76,14 @@ const styles = StyleSheet.create({
|
|
|
85
76
|
justifyContent: "center",
|
|
86
77
|
alignItems: "center",
|
|
87
78
|
userSelect: "none",
|
|
79
|
+
// An INSET hairline, not a border and not a shadow. A flat disc of saturated
|
|
80
|
+
// colour on white is a sticker: nothing relates it to the surface, and at
|
|
81
|
+
// register scale a column of them reads as printed-on rather than sitting
|
|
82
|
+
// there. `inset` keeps the ring INSIDE the box, so the avatar's measured size
|
|
83
|
+
// is unchanged and every layout that budgeted `AVATAR_PX` still fits — an
|
|
84
|
+
// outset border would have grown every disc by 2px and pushed the name beside
|
|
85
|
+
// it. Black at 8% darkens whatever hue is underneath rather than tinting it,
|
|
86
|
+
// so it works across the whole identity palette without a per-hue value.
|
|
87
|
+
...({ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" } as object),
|
|
88
88
|
},
|
|
89
89
|
});
|
package/src/avatar.web.tsx
CHANGED
|
@@ -1,40 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
|
|
1
|
+
import { Image, View, StyleSheet, ImageStyle } from "react-native";
|
|
3
2
|
import { Text } from "./text";
|
|
4
|
-
import { colors } from "./colors";
|
|
5
3
|
import { useLoticsLocale } from "./locale";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/** A rung on the shared avatar scale — never a pixel count. */
|
|
10
|
-
size?: AvatarSize;
|
|
11
|
-
source?: ImageSource;
|
|
12
|
-
name?: string;
|
|
13
|
-
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
14
|
-
contentFit?: ImageContentFit;
|
|
15
|
-
/**
|
|
16
|
-
* When true, the avatar announces its `name` to assistive tech. Default
|
|
17
|
-
* false because avatars almost always appear adjacent to the name text —
|
|
18
|
-
* announcing the image as well would double-read. Pass `announce` when the
|
|
19
|
-
* avatar is standalone (with no visible name nearby).
|
|
20
|
-
*/
|
|
21
|
-
announce?: boolean;
|
|
22
|
-
}
|
|
4
|
+
import { avatarTone } from "./avatar_tone";
|
|
5
|
+
import { AVATAR_PX, AVATAR_TEXT, avatarInitials } from "./avatar_size";
|
|
6
|
+
import { avatarShapeStyle, type AvatarProps } from "./avatar_props";
|
|
23
7
|
|
|
24
8
|
/**
|
|
25
9
|
* Web Avatar. The native `avatar.tsx` renders through `expo-image`, a native
|
|
26
10
|
* module that pulls expo's runtime into the graph and fails to load under
|
|
27
11
|
* pure-web bundlers (Vite dev throws on its CJS interop; vitest can't resolve
|
|
28
12
|
* expo's winter runtime). On web, `react-native`'s `Image` (→ react-native-web
|
|
29
|
-
* → `<img>`) renders the same
|
|
30
|
-
*
|
|
31
|
-
*
|
|
13
|
+
* → `<img>`) renders the same avatar with none of that cost.
|
|
14
|
+
*
|
|
15
|
+
* Its prop surface is NOT declared here — see `avatar_props`, which both
|
|
16
|
+
* variants implement and where expo-image's `ImageSource`/`ImageContentFit`
|
|
17
|
+
* survive as erased type-only imports, so no expo-image module is ever loaded on
|
|
18
|
+
* this path. The interface used to be declared twice, and `tsc` resolves
|
|
19
|
+
* `./avatar` to the NATIVE sibling: a prop added there compiled clean
|
|
20
|
+
* everywhere, and did nothing in the browser.
|
|
32
21
|
*/
|
|
33
22
|
export function Avatar(props: AvatarProps) {
|
|
34
23
|
const locale = useLoticsLocale();
|
|
35
|
-
const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
24
|
+
const { source, size = "md", shape = "circle", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
36
25
|
const decorative = !announce;
|
|
37
26
|
const px = AVATAR_PX[size];
|
|
27
|
+
const shapeStyle = avatarShapeStyle(shape);
|
|
38
28
|
|
|
39
29
|
if (!source || !source.uri) {
|
|
40
30
|
return (
|
|
@@ -46,12 +36,14 @@ export function Avatar(props: AvatarProps) {
|
|
|
46
36
|
aria-hidden={decorative || undefined}
|
|
47
37
|
style={[
|
|
48
38
|
styles.base,
|
|
49
|
-
// The
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
39
|
+
// The disc's ground is DERIVED FROM THE NAME — see `avatar_tone`. It
|
|
40
|
+
// used to be `colors.accent`, which made every person in a register the
|
|
41
|
+
// same colour and left the row's biggest, brightest element carrying no
|
|
42
|
+
// information. The brand did not lose a home; it moved to the chrome
|
|
43
|
+
// (active tab, selection wash, primary action), where it is shared
|
|
44
|
+
// across apps and learned once.
|
|
45
|
+
{ backgroundColor: avatarTone(name), width: px, height: px },
|
|
46
|
+
shapeStyle,
|
|
55
47
|
style,
|
|
56
48
|
]}
|
|
57
49
|
>
|
|
@@ -77,7 +69,7 @@ export function Avatar(props: AvatarProps) {
|
|
|
77
69
|
accessibilityLabel={decorative ? undefined : name}
|
|
78
70
|
accessibilityElementsHidden={decorative}
|
|
79
71
|
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
80
|
-
style={[styles.base, { width: px, height: px }, style as ImageStyle]}
|
|
72
|
+
style={[styles.base, { width: px, height: px }, shapeStyle, style as ImageStyle]}
|
|
81
73
|
source={{ uri: source.uri }}
|
|
82
74
|
resizeMode={contentFit === "contain" ? "contain" : "cover"}
|
|
83
75
|
/>
|
|
@@ -91,5 +83,14 @@ const styles = StyleSheet.create({
|
|
|
91
83
|
justifyContent: "center",
|
|
92
84
|
alignItems: "center",
|
|
93
85
|
userSelect: "none",
|
|
86
|
+
// An INSET hairline, not a border and not a shadow. A flat disc of saturated
|
|
87
|
+
// colour on white is a sticker: nothing relates it to the surface, and at
|
|
88
|
+
// register scale a column of them reads as printed-on rather than sitting
|
|
89
|
+
// there. `inset` keeps the ring INSIDE the box, so the avatar's measured size
|
|
90
|
+
// is unchanged and every layout that budgeted `AVATAR_PX` still fits — an
|
|
91
|
+
// outset border would have grown every disc by 2px and pushed the name beside
|
|
92
|
+
// it. Black at 8% darkens whatever hue is underneath rather than tinting it,
|
|
93
|
+
// so it works across the whole identity palette without a per-hue value.
|
|
94
|
+
...({ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" } as object),
|
|
94
95
|
},
|
|
95
96
|
});
|