@lotics/ui 43.4.0 → 43.6.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 +395 -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/metric.tsx +24 -24
- 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 +158 -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 +46 -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 +113 -0
- package/src/theme.tsx +0 -24
- package/src/theme.web.tsx +0 -79
- package/src/theme_context.ts +0 -107
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ImageContentFit, ImageSource } from "expo-image";
|
|
2
|
+
import type { StyleProp, ViewStyle, ImageStyle } from "react-native";
|
|
3
|
+
import { CONTROL_RADIUS } from "./control_surface";
|
|
4
|
+
import type { AvatarSize } from "./avatar_size";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE prop surface both `Avatar` variants implement — the native `avatar.tsx`
|
|
8
|
+
* and the web `avatar.web.tsx`.
|
|
9
|
+
*
|
|
10
|
+
* It lives in a third module for the same reason `color_tokens` does: neither
|
|
11
|
+
* variant may own the contract, because only one of them is ever compiled. TypeScript
|
|
12
|
+
* resolves `./avatar` to the NATIVE file, so a prop added there typechecks
|
|
13
|
+
* everywhere while the web build — the one every custom-code app actually runs —
|
|
14
|
+
* silently ignores it. Nothing fails: no error, no warning, and the call site
|
|
15
|
+
* reads as correct. That is exactly how `shape` was added, verified against the
|
|
16
|
+
* types, and rendered as a circle.
|
|
17
|
+
*
|
|
18
|
+
* A shared interface cannot fix which FILE renders, but it does make the two
|
|
19
|
+
* implementations answer to one declaration, so a prop that exists in the type
|
|
20
|
+
* has a compiler-visible home in both. The behavioural half is `avatarShapeStyle`
|
|
21
|
+
* below: derived once here rather than written twice.
|
|
22
|
+
*/
|
|
23
|
+
export interface AvatarProps {
|
|
24
|
+
/** A rung on the shared avatar scale — never a pixel count. See
|
|
25
|
+
* [`avatar_size`](./avatar_size.ts) for why the number was removed. */
|
|
26
|
+
size?: AvatarSize;
|
|
27
|
+
/**
|
|
28
|
+
* WHAT KIND OF THING this identifies — a person (`circle`, the default) or an
|
|
29
|
+
* organization (`square`, a rounded rect at `CONTROL_RADIUS`).
|
|
30
|
+
*
|
|
31
|
+
* It is a real axis, not decoration. A register of companies drawn with the
|
|
32
|
+
* same disc as a register of people asserts they are the same kind of entity,
|
|
33
|
+
* and the reader has nothing but the column header to correct it — so the mark
|
|
34
|
+
* that exists to make a row identifiable at a glance is the one element
|
|
35
|
+
* actively misleading them. The circle/square split is the convention every
|
|
36
|
+
* product carrying both uses, and it costs no colour and no space.
|
|
37
|
+
*
|
|
38
|
+
* Same component rather than a second one, per the kit's mode-is-a-variant
|
|
39
|
+
* rule: the scale, the initials rule, the accent ground, the image path and the
|
|
40
|
+
* a11y contract are all identical, and forking would give an organization its
|
|
41
|
+
* own drifting copy of every one of them.
|
|
42
|
+
*/
|
|
43
|
+
shape?: "circle" | "square";
|
|
44
|
+
source?: ImageSource;
|
|
45
|
+
name?: string;
|
|
46
|
+
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
47
|
+
contentFit?: ImageContentFit;
|
|
48
|
+
/**
|
|
49
|
+
* When true, the avatar announces its `name` to assistive tech. Default
|
|
50
|
+
* false because avatars almost always appear adjacent to the name text —
|
|
51
|
+
* announcing the image as well would double-read. Pass `announce` when the
|
|
52
|
+
* avatar is standalone (with no visible name nearby).
|
|
53
|
+
*/
|
|
54
|
+
announce?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The radius override a `shape` implies, or `null` for the default disc.
|
|
59
|
+
*
|
|
60
|
+
* A square mark takes the SAME radius as every other control-sized box on the
|
|
61
|
+
* page rather than a number picked for this component, so an org mark sitting
|
|
62
|
+
* beside a button, a chip or an input corners identically instead of nearly so.
|
|
63
|
+
*/
|
|
64
|
+
export function avatarShapeStyle(shape: AvatarProps["shape"]): { borderRadius: number } | null {
|
|
65
|
+
return shape === "square" ? { borderRadius: CONTROL_RADIUS } : null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { colors } from "./colors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE IDENTITY PALETTE — the ground an initials avatar is drawn on, derived from
|
|
5
|
+
* the name it shows.
|
|
6
|
+
*
|
|
7
|
+
* Every disc used to be `colors.accent`, on the argument that an avatar is "the
|
|
8
|
+
* one place a member LOOKS like the product". That argument holds for ONE avatar
|
|
9
|
+
* in a header. It fails in the place avatars actually live: a register of twenty
|
|
10
|
+
* people renders twenty identical circles, so the only thing distinguishing one
|
|
11
|
+
* person from another is two letters at 14px — and the largest, most colourful
|
|
12
|
+
* element in the row carries no information at all. A mark whose whole job is
|
|
13
|
+
* making a row identifiable at a glance should not be the one part of it that is
|
|
14
|
+
* the same on every row.
|
|
15
|
+
*
|
|
16
|
+
* Handing the hue to the NAME makes the colour carry identity too: the same
|
|
17
|
+
* person is the same colour on every screen, and a reader learns "Nguyễn Vi is
|
|
18
|
+
* the violet one" without being told. The brand did not lose anything — it moved
|
|
19
|
+
* to the chrome it belongs in (the active tab, the selection wash, the primary
|
|
20
|
+
* action), which is shared across apps and learned once.
|
|
21
|
+
*
|
|
22
|
+
* Contrast is why the set is CURATED rather than "every family":
|
|
23
|
+
* - every entry is AA against white text at the shade named here, which the warm
|
|
24
|
+
* and green families are NOT at 600 (amber-600 measures ~2.9:1) — so those take
|
|
25
|
+
* 700 or stay out;
|
|
26
|
+
* - `red` and `amber` are excluded entirely. They mean danger and waiting
|
|
27
|
+
* everywhere else in this system, and a person is not a warning. A palette that
|
|
28
|
+
* can paint someone red makes every red on the screen ambiguous.
|
|
29
|
+
*
|
|
30
|
+
* The ORDER is interleaved, not spectral, and that is load-bearing. Listed by
|
|
31
|
+
* hue — blue, indigo, violet, purple, fuchsia, pink — six consecutive entries are
|
|
32
|
+
* six shades of the same purple, so any two names hashing to adjacent buckets get
|
|
33
|
+
* near-identical discs. On a real register that is what happened: ten contacts
|
|
34
|
+
* produced six marks in the violet-to-magenta range and the column read as one
|
|
35
|
+
* colour again. Adjacent indices are now far apart in hue, so near-collisions
|
|
36
|
+
* still look distinct.
|
|
37
|
+
*/
|
|
38
|
+
const IDENTITY_TONES: readonly string[] = [
|
|
39
|
+
colors.blue["600"],
|
|
40
|
+
colors.orange["700"],
|
|
41
|
+
colors.violet["600"],
|
|
42
|
+
colors.emerald["700"],
|
|
43
|
+
colors.fuchsia["700"],
|
|
44
|
+
colors.teal["700"],
|
|
45
|
+
colors.indigo["600"],
|
|
46
|
+
colors.pink["600"],
|
|
47
|
+
colors.cyan["700"],
|
|
48
|
+
colors.purple["600"],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The ground for `name`'s initials — stable for a given string, so one person
|
|
53
|
+
* keeps one colour across every screen and every session.
|
|
54
|
+
*
|
|
55
|
+
* The hash MIXES rather than sums, which fixes ANAGRAMS specifically: a plain
|
|
56
|
+
* character sum gives `Hai Dang` and `Dang Hai` the same disc, and reorderings
|
|
57
|
+
* are common in names (family name first or last, with or without a middle
|
|
58
|
+
* name). Multiplying by 31 per character makes position count; `|0` keeps it in
|
|
59
|
+
* int32 so the result is identical on every engine.
|
|
60
|
+
*
|
|
61
|
+
* It is NOT what fixed the clustering seen on a real register — the sum was
|
|
62
|
+
* already producing a healthy spread of buckets there, and the interleaved order
|
|
63
|
+
* above is what made that spread visible. Do not credit the hash for it.
|
|
64
|
+
*
|
|
65
|
+
* Whatever the arithmetic, this value is not STORED anywhere: it is recomputed on
|
|
66
|
+
* every render on every client, and the only thing making one person one colour
|
|
67
|
+
* is that the function is pure and never changes. Editing the hash or reordering
|
|
68
|
+
* `IDENTITY_TONES` repaints every person in the product.
|
|
69
|
+
*
|
|
70
|
+
* An empty or missing name falls to the first entry rather than throwing —
|
|
71
|
+
* `Avatar` already substitutes a locale placeholder, and a nameless avatar is a
|
|
72
|
+
* real state (a member still loading) that must render.
|
|
73
|
+
*/
|
|
74
|
+
export function avatarTone(name: string | undefined): string {
|
|
75
|
+
const s = name ?? "";
|
|
76
|
+
let h = 0;
|
|
77
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
78
|
+
return IDENTITY_TONES[Math.abs(h) % IDENTITY_TONES.length];
|
|
79
|
+
}
|
package/src/button.tsx
CHANGED
|
@@ -165,9 +165,22 @@ export function Button(props: ButtonProps) {
|
|
|
165
165
|
// Subtle depth on the primary: a soft drop shadow lifts it off the
|
|
166
166
|
// surface and a whisper of top highlight + a gentle vertical shade (the
|
|
167
167
|
// app-icon technique) catch the light — premium, not skeuomorphic. The
|
|
168
|
-
// lift stays on hover/press;
|
|
168
|
+
// lift stays on hover/press; the rest gradient is replaced by a flat
|
|
169
|
+
// overlay so the state reads without losing the themed ground.
|
|
169
170
|
isPrimary && { boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.18)" },
|
|
170
|
-
|
|
171
|
+
// A LIGHT overlay, not a zinc one. This painted
|
|
172
|
+
// `linear-gradient(zinc-700 → zinc-900)` — opaque, hard-coded, and drawn
|
|
173
|
+
// ON TOP of the background — so a themed `colors.primary` was resolved
|
|
174
|
+
// correctly underneath and then completely covered up. The button read
|
|
175
|
+
// near-black in every app, and "primary cannot be themed" looked true
|
|
176
|
+
// while the token had been right all along. A translucent white wash
|
|
177
|
+
// reproduces the same lit-from-above sheen over ANY hue, which also
|
|
178
|
+
// means it cannot go stale against a palette change.
|
|
179
|
+
restPrimary && { backgroundImage: "linear-gradient(180deg, rgba(255,255,255,0.14) 0%, rgba(255,255,255,0) 100%)" },
|
|
180
|
+
// Hover/press ride the SAME ground, lifted by a flat overlay, so the
|
|
181
|
+
// brand stays on screen through every state instead of only at rest.
|
|
182
|
+
isPrimary && pressed && { backgroundImage: `linear-gradient(0deg, ${PRIMARY_OVERLAY.press}, ${PRIMARY_OVERLAY.press})` },
|
|
183
|
+
isPrimary && !pressed && hovered && { backgroundImage: `linear-gradient(0deg, ${PRIMARY_OVERLAY.hover}, ${PRIMARY_OVERLAY.hover})` },
|
|
171
184
|
styles.button,
|
|
172
185
|
style,
|
|
173
186
|
alignSelf && { alignSelf },
|
|
@@ -227,6 +240,21 @@ function shade(hex: string, factor: number): string {
|
|
|
227
240
|
return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The primary button's hover / press lift, as a translucent WHITE overlay.
|
|
245
|
+
*
|
|
246
|
+
* Every state of a themeable control has to derive from its token, and on web a
|
|
247
|
+
* token is a `var()` — a string no colour maths can read, so "the same colour a
|
|
248
|
+
* bit lighter" cannot be computed at the call site. An overlay sidesteps that
|
|
249
|
+
* entirely: it lightens whatever is underneath, so one pair of values works for
|
|
250
|
+
* near-black, cobalt, teal or anything an app sets, and it can never go stale
|
|
251
|
+
* against a palette it does not know about.
|
|
252
|
+
*
|
|
253
|
+
* The direction matches what the hard-coded zinc steps did (zinc-900 → 700 →
|
|
254
|
+
* 600): both states LIFT, and press lifts further than hover.
|
|
255
|
+
*/
|
|
256
|
+
const PRIMARY_OVERLAY = { hover: "rgba(255,255,255,0.16)", press: "rgba(255,255,255,0.26)" };
|
|
257
|
+
|
|
230
258
|
function getButtonBackgroundColor(color?: ButtonColor) {
|
|
231
259
|
switch (color) {
|
|
232
260
|
case "primary":
|
|
@@ -259,7 +287,10 @@ function getButtonDisabledBackgroundColor(color?: ButtonColor) {
|
|
|
259
287
|
function getButtonHoverColor(color?: ButtonColor) {
|
|
260
288
|
switch (color) {
|
|
261
289
|
case "primary":
|
|
262
|
-
|
|
290
|
+
// The TOKEN, unchanged — the lift is an overlay, not a lighter zinc. See
|
|
291
|
+
// `PRIMARY_OVERLAY`. Returning zinc-700 here meant a themed button was the
|
|
292
|
+
// brand at rest and grey the instant a pointer touched it.
|
|
293
|
+
return colors.primary;
|
|
263
294
|
case "secondary":
|
|
264
295
|
return colors.zinc["200"];
|
|
265
296
|
case "danger":
|
|
@@ -274,7 +305,8 @@ function getButtonHoverColor(color?: ButtonColor) {
|
|
|
274
305
|
function getButtonPressedColor(color?: ButtonColor) {
|
|
275
306
|
switch (color) {
|
|
276
307
|
case "primary":
|
|
277
|
-
|
|
308
|
+
// The TOKEN, unchanged — see `getButtonHoverColor`.
|
|
309
|
+
return colors.primary;
|
|
278
310
|
case "secondary":
|
|
279
311
|
return colors.zinc["300"];
|
|
280
312
|
case "danger":
|
package/src/checkbox.tsx
CHANGED
|
@@ -25,7 +25,10 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
25
25
|
alignItems: "center",
|
|
26
26
|
borderWidth: 1,
|
|
27
27
|
borderColor: colors.border,
|
|
28
|
-
|
|
28
|
+
// The ticked fill is the app's PRIMARY colour, not a fixed near-black.
|
|
29
|
+
// "on" is the same claim the one filled button makes, so it reads from
|
|
30
|
+
// the same token and a themed app gets a branded tick for free.
|
|
31
|
+
backgroundColor: filled ? colors.primary : colors.background,
|
|
29
32
|
borderRadius: 6,
|
|
30
33
|
}}
|
|
31
34
|
>
|
package/src/choice_list.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
|
8
8
|
import { useHover } from "./use_hover";
|
|
9
9
|
import { CONTROL_RADIUS, CURSOR_DEFAULT, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
|
|
10
10
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
11
|
-
import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
|
|
11
|
+
import { fontFamilyMedium, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
|
|
12
12
|
import { useLoticsLocale } from "./locale";
|
|
13
13
|
|
|
14
14
|
// A typed custom answer must read exactly like a picked option's LABEL (`Text
|
|
@@ -16,7 +16,7 @@ import { useLoticsLocale } from "./locale";
|
|
|
16
16
|
// 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
|
|
17
17
|
// size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
|
|
18
18
|
// and colour to the label, which is where an unstyled input actually diverges from it.
|
|
19
|
-
const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing:
|
|
19
|
+
const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: INPUT_LETTER_SPACING, color: colors.zinc[900] } as const;
|
|
20
20
|
|
|
21
21
|
export interface ChoiceOption {
|
|
22
22
|
label: string;
|
|
@@ -73,7 +73,9 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
|
|
|
73
73
|
) : null}
|
|
74
74
|
</View>
|
|
75
75
|
<View style={{ width: 17, alignItems: "center" }}>
|
|
76
|
-
{
|
|
76
|
+
{/* The tick is a "this is on" mark like a checkbox's fill or a switch's
|
|
77
|
+
track, so it reads the primary token rather than a fixed near-black. */}
|
|
78
|
+
{selected ? <Icon name="check" size={17} color={colors.primary} /> : null}
|
|
77
79
|
</View>
|
|
78
80
|
</Pressable>
|
|
79
81
|
);
|
package/src/color_tokens.ts
CHANGED
|
@@ -315,9 +315,8 @@ export const colors = {
|
|
|
315
315
|
/** A RAISED surface — Card, Drawer, Modal, the things that sit on the page. */
|
|
316
316
|
background: palette.white,
|
|
317
317
|
/**
|
|
318
|
-
* The one brand hue
|
|
319
|
-
*
|
|
320
|
-
* person is rendered as a coloured shape, on every register in every app.
|
|
318
|
+
* The one brand hue. It paints "WHERE YOU ARE" — the active tab's underline,
|
|
319
|
+
* the selected row's ground, an active filter — and IDENTITY chrome.
|
|
321
320
|
*
|
|
322
321
|
* It deliberately does NOT paint interaction chrome. The focus ring is
|
|
323
322
|
* `zinc[900]` and the primary action is near-black because those are the KIT's
|
|
@@ -328,14 +327,13 @@ export const colors = {
|
|
|
328
327
|
*/
|
|
329
328
|
accent: palette.blue["600"],
|
|
330
329
|
/**
|
|
331
|
-
* The PRIMARY action's fill — the one filled button on a surface
|
|
330
|
+
* The PRIMARY action's fill — the one filled button on a surface, and every
|
|
331
|
+
* other "this is on": a ticked checkbox, a switch's track, a selected day.
|
|
332
332
|
*
|
|
333
|
-
* Separate from `accent` because their
|
|
334
|
-
*
|
|
335
|
-
* commit
|
|
336
|
-
*
|
|
337
|
-
* CTA, and would turn this near-black into blue for every app that themes
|
|
338
|
-
* nothing.
|
|
333
|
+
* Separate from `accent` because their jobs differ: an app can own the hue that
|
|
334
|
+
* says WHERE YOU ARE without owning the shape of a COMMIT, and a near-black
|
|
335
|
+
* commit reads under any brand. Folding them would force an app that themes its
|
|
336
|
+
* accent to also repaint every CTA.
|
|
339
337
|
*/
|
|
340
338
|
primary: palette.zinc["900"],
|
|
341
339
|
/**
|
|
@@ -344,13 +342,16 @@ export const colors = {
|
|
|
344
342
|
* It exists as its own token because it cannot be derived where it is used:
|
|
345
343
|
* `withAlpha`/`tint` do string surgery on an `rgba()`, and on web `accent` is a
|
|
346
344
|
* `var()`, so asking for "accent at 7%" at the call site produces garbage no
|
|
347
|
-
* type would catch. `
|
|
345
|
+
* type would catch. `applyLoticsTheme` computes it instead, from the literal
|
|
348
346
|
* hex the app handed it — derivation happens where the literal still exists.
|
|
349
347
|
*
|
|
350
|
-
* The default is
|
|
351
|
-
*
|
|
348
|
+
* The default is DERIVED FROM `accent`'s default, not picked separately: the
|
|
349
|
+
* accent is blue-600, so the wash is blue-50. It was zinc-100, which meant the
|
|
350
|
+
* two halves of one token pair disagreed and every unthemed app got a
|
|
351
|
+
* colourless "you are here"; a wash that contradicts its own accent is not a
|
|
352
|
+
* neutral default, it is a second brand nobody chose.
|
|
352
353
|
*/
|
|
353
|
-
accent_wash: palette.
|
|
354
|
+
accent_wash: palette.blue["50"],
|
|
354
355
|
shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
|
|
355
356
|
};
|
|
356
357
|
|
package/src/composer.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputP
|
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { Text } from "./text";
|
|
6
|
-
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
6
|
+
import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
|
|
7
7
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
8
8
|
import { useLoticsLocale } from "./locale";
|
|
9
9
|
|
|
@@ -220,7 +220,7 @@ export function Composer(props: ComposerProps) {
|
|
|
220
220
|
wrap into clipped lines inside the pill — one line, ellipsized. */}
|
|
221
221
|
{emptyText && placeholder ? (
|
|
222
222
|
<View pointerEvents="none" style={[styles.placeholderOverlay, expanded ? styles.placeholderOverlayExpanded : { paddingHorizontal: 10 }]}>
|
|
223
|
-
<Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing:
|
|
223
|
+
<Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: INPUT_LETTER_SPACING }}>
|
|
224
224
|
{placeholder}
|
|
225
225
|
</Text>
|
|
226
226
|
</View>
|
|
@@ -346,6 +346,6 @@ const styles = StyleSheet.create({
|
|
|
346
346
|
textInput: {
|
|
347
347
|
flex: 1,
|
|
348
348
|
fontFamily: fontFamilyRegular,
|
|
349
|
-
letterSpacing:
|
|
349
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
350
350
|
},
|
|
351
351
|
});
|
package/src/control_surface.ts
CHANGED
|
@@ -106,12 +106,27 @@ export const FOCUS_RING = `0 0 0 ${FOCUS_RING_WIDTH}px ${colors.zinc[900]}`;
|
|
|
106
106
|
*/
|
|
107
107
|
export const CONTROL_TEXT_INSET = 9;
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
|
|
109
|
+
/**
|
|
110
|
+
* THE hover edge for every bordered control — the resting `border` darkens to
|
|
111
|
+
* this. Focus is the ring; hover is a darker border; those are the only two, and
|
|
112
|
+
* a control that adds a third signal of its own breaks the pattern for all of
|
|
113
|
+
* them. Drive it with `useHover` on a TextInput/select or a Pressable's built-in
|
|
114
|
+
* `hovered`, and pair it with `CONTROL_TRANSITION` so the change eases in.
|
|
115
|
+
*
|
|
116
|
+
* zinc-500, not zinc-400. At 400 the shift was zinc-200 → zinc-400 on a 1px
|
|
117
|
+
* line — measurably a change and visually almost nothing, which is exactly why
|
|
118
|
+
* `InlineEdit` had grown a background TINT on top to compensate. That left the
|
|
119
|
+
* kit with two hover languages: most inputs moved their border, one also washed
|
|
120
|
+
* its surface, and a reader crossing a form met both. The answer is one signal
|
|
121
|
+
* carried properly rather than a second signal bolted on, so the border does the
|
|
122
|
+
* work everywhere and nothing tints.
|
|
123
|
+
*
|
|
124
|
+
* It stays NEUTRAL rather than taking the accent: hover means "your pointer is
|
|
125
|
+
* here", which is transient and true of whatever you happen to be over, while
|
|
126
|
+
* the accent means "you are here" — a state the screen holds. Painting both in
|
|
127
|
+
* the brand would make the brand the least informative colour on the page.
|
|
128
|
+
*/
|
|
129
|
+
export const HOVER_BORDER = colors.zinc[500];
|
|
115
130
|
|
|
116
131
|
/**
|
|
117
132
|
* The wash a control paints when it rides a HOVERABLE surface — a register row,
|
|
@@ -154,9 +169,22 @@ export function chipSurfaceStyle(
|
|
|
154
169
|
justifyContent: "center",
|
|
155
170
|
borderRadius: CONTROL_RADIUS,
|
|
156
171
|
borderWidth: 1,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
// A SELECTED chip needs a GROUND, not a heavier outline. This drew
|
|
173
|
+
// zinc-900 at 2px on a white fill — and in a toolbar where every sibling is
|
|
174
|
+
// also a white pill with a 1px border, "which filter is on" came down to a
|
|
175
|
+
// one-pixel colour change that reads as noise at a glance. The accent wash
|
|
176
|
+
// is the same "you are here" ground the active tab and the selected row
|
|
177
|
+
// carry, so one signal means one thing across the product; it also stays
|
|
178
|
+
// clear of the primary button's near-black fill, which a dark chip would
|
|
179
|
+
// have collided with two controls away.
|
|
180
|
+
borderColor: selected ? colors.accent : colors.border,
|
|
181
|
+
backgroundColor: selected
|
|
182
|
+
? colors.accent_wash
|
|
183
|
+
: state.pressed
|
|
184
|
+
? colors.zinc[200]
|
|
185
|
+
: state.hovered
|
|
186
|
+
? colors.zinc[100]
|
|
187
|
+
: colors.white,
|
|
160
188
|
};
|
|
161
189
|
}
|
|
162
190
|
|
package/src/copy_button.tsx
CHANGED
|
@@ -13,7 +13,14 @@ export interface CopyButtonProps {
|
|
|
13
13
|
* the pack's bare verb. */
|
|
14
14
|
label?: string;
|
|
15
15
|
/** `sm` (the default, 24px) sits beside body/sm text — a value in a register
|
|
16
|
-
* cell or a detail row
|
|
16
|
+
* cell or a detail row — because 24 IS the `sm` line box, so the control adds
|
|
17
|
+
* nothing to the line's height. `md` (28px) for a toolbar.
|
|
18
|
+
*
|
|
19
|
+
* On a SMALLER line (an `xs` supporting line, 18px) there is no matching rung,
|
|
20
|
+
* and dropping `sm` on it makes the row 24 — the text then sits 3px lower than
|
|
21
|
+
* the same supporting line one column over. Pin that row's `height` to the
|
|
22
|
+
* text's leading and let this overflow it; see `docs/composition.md`
|
|
23
|
+
* §"The register's own craft" for the rule and `tpl_item_list` for the worked line. */
|
|
17
24
|
size?: "sm" | "md";
|
|
18
25
|
disabled?: boolean;
|
|
19
26
|
testID?: string;
|
package/src/counter.tsx
CHANGED
|
@@ -56,6 +56,6 @@ export function Counter(props: CounterProps) {
|
|
|
56
56
|
|
|
57
57
|
const styles = StyleSheet.create({
|
|
58
58
|
row: { flexDirection: "row", alignItems: "center", gap: 16, alignSelf: "flex-start" },
|
|
59
|
-
btn: { width: 32, height: 32, borderWidth: 1, borderColor: colors.
|
|
59
|
+
btn: { width: 32, height: 32, borderWidth: 1, borderColor: colors.border },
|
|
60
60
|
value: { minWidth: 56 },
|
|
61
61
|
});
|
package/src/data_grid.tsx
CHANGED
|
@@ -150,7 +150,10 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
|
|
|
150
150
|
if (!sortable) {
|
|
151
151
|
return (
|
|
152
152
|
<View style={[styles.sortLabel, style]}>
|
|
153
|
-
|
|
153
|
+
{/* The SAME header treatment as `Table`, which owns the reason —
|
|
154
|
+
`DataGrid` and `Table` are one surface to a reader, so they cannot
|
|
155
|
+
label their columns differently. */}
|
|
156
|
+
<Text size="sm" weight="medium" color="muted" numberOfLines={1}>{label}</Text>
|
|
154
157
|
</View>
|
|
155
158
|
);
|
|
156
159
|
}
|
|
@@ -158,7 +161,8 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
|
|
|
158
161
|
const dirText = active ? (sort?.dir === "asc" ? (labels?.ascending ?? ", ascending") : (labels?.descending ?? ", descending")) : "";
|
|
159
162
|
return (
|
|
160
163
|
<FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, styles.sortLabelPressable, hovered ? styles.sortLabelHover : null, style]}>
|
|
161
|
-
|
|
164
|
+
{/* Active steps up in INK, not weight — see `SortHeader`. */}
|
|
165
|
+
<Text size="sm" weight="medium" color={active ? "default" : "muted"} numberOfLines={1}>{label}</Text>
|
|
162
166
|
{arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
|
|
163
167
|
</FocusRingPressable>
|
|
164
168
|
);
|
|
@@ -181,7 +185,6 @@ const styles = StyleSheet.create({
|
|
|
181
185
|
borderBottomColor: colors.zinc[100],
|
|
182
186
|
marginBottom: 4,
|
|
183
187
|
},
|
|
184
|
-
headLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
|
|
185
188
|
// Padded for the hover pill but zero-width in flow (negative margin), so the label
|
|
186
189
|
// sits on its cell's edge instead of 8px inside it.
|
|
187
190
|
sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
|
package/src/date_calendar.tsx
CHANGED
|
@@ -832,7 +832,9 @@ const styles = StyleSheet.create({
|
|
|
832
832
|
borderRadius: 999,
|
|
833
833
|
},
|
|
834
834
|
dayCircleSelected: {
|
|
835
|
-
|
|
835
|
+
// The picked day is the calendar's one filled mark — the same claim as a
|
|
836
|
+
// ticked checkbox or the primary button, so it reads the same token.
|
|
837
|
+
backgroundColor: colors.primary,
|
|
836
838
|
},
|
|
837
839
|
dayCircleHovered: {
|
|
838
840
|
backgroundColor: colors.zinc["50"],
|
|
@@ -843,7 +845,7 @@ const styles = StyleSheet.create({
|
|
|
843
845
|
width: 4,
|
|
844
846
|
height: 4,
|
|
845
847
|
borderRadius: 2,
|
|
846
|
-
backgroundColor: colors.
|
|
848
|
+
backgroundColor: colors.primary,
|
|
847
849
|
},
|
|
848
850
|
todayDotInverted: {
|
|
849
851
|
backgroundColor: colors.white,
|
|
@@ -132,7 +132,11 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
132
132
|
paddingVertical: 9,
|
|
133
133
|
paddingHorizontal: 12,
|
|
134
134
|
borderWidth: 1,
|
|
135
|
-
|
|
135
|
+
// `colors.border` like every other control — this rested a step
|
|
136
|
+
// heavier for no stated reason, which is the same outlier
|
|
137
|
+
// `SearchInput` was: one control in a band with a darker edge reads
|
|
138
|
+
// as an accident rather than as emphasis.
|
|
139
|
+
borderColor: state.hovered ? HOVER_BORDER : colors.border,
|
|
136
140
|
borderRadius: CONTROL_RADIUS,
|
|
137
141
|
backgroundColor: colors.white,
|
|
138
142
|
...CONTROL_TRANSITION,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "react-native";
|
|
12
12
|
import { colors } from "./colors";
|
|
13
13
|
import { Text } from "./text";
|
|
14
|
-
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
14
|
+
import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
|
|
15
15
|
import {
|
|
16
16
|
SegmentBuffer,
|
|
17
17
|
SegmentLabels,
|
|
@@ -449,7 +449,7 @@ const styles = StyleSheet.create({
|
|
|
449
449
|
segment: {
|
|
450
450
|
...getInputTextStyle(),
|
|
451
451
|
fontFamily: fontFamilyRegular,
|
|
452
|
-
letterSpacing:
|
|
452
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
453
453
|
color: colors.zinc["900"],
|
|
454
454
|
textAlign: "center",
|
|
455
455
|
paddingVertical: 0,
|
|
@@ -112,7 +112,7 @@ const styles = StyleSheet.create({
|
|
|
112
112
|
// box-shadow/background — zero layout impact, so nothing shifts mid-drag.
|
|
113
113
|
dragging: {
|
|
114
114
|
borderRadius: 12,
|
|
115
|
-
backgroundColor: colors.
|
|
116
|
-
boxShadow: `0 0 0 1.5px ${colors.
|
|
115
|
+
backgroundColor: colors.accent_wash,
|
|
116
|
+
boxShadow: `0 0 0 1.5px ${colors.accent}, 0 0 0 6px ${colors.accent_wash}`,
|
|
117
117
|
},
|
|
118
118
|
});
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -99,9 +99,9 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
99
99
|
]}
|
|
100
100
|
>
|
|
101
101
|
<View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
|
|
102
|
-
<Icon name="upload" size={20} color={dragging ? colors.
|
|
102
|
+
<Icon name="upload" size={20} color={dragging ? colors.accent : colors.zinc[500]} />
|
|
103
103
|
</View>
|
|
104
|
-
<Text size="sm" weight="medium" style={dragging ? { color: colors.
|
|
104
|
+
<Text size="sm" weight="medium" style={dragging ? { color: colors.accent } : undefined}>
|
|
105
105
|
{dragging ? dropLabel : label}
|
|
106
106
|
</Text>
|
|
107
107
|
{!dragging && hint ? (
|
|
@@ -133,10 +133,13 @@ const styles = StyleSheet.create({
|
|
|
133
133
|
backgroundColor: colors.zinc[100],
|
|
134
134
|
},
|
|
135
135
|
// The drop invitation: accent border + tinted well — unmistakably "this is
|
|
136
|
-
// where your files land".
|
|
136
|
+
// where your files land". THE TOKEN, not a literal blue: the comment already
|
|
137
|
+
// said "accent" while the value was a fixed hue, so a themed app's most
|
|
138
|
+
// conspicuous state — a whole region lighting up under a dragged file — was
|
|
139
|
+
// the one that stayed brand blue.
|
|
137
140
|
zoneDragging: {
|
|
138
|
-
borderColor: colors.
|
|
139
|
-
backgroundColor: colors.
|
|
141
|
+
borderColor: colors.accent,
|
|
142
|
+
backgroundColor: colors.accent_wash,
|
|
140
143
|
},
|
|
141
144
|
zoneDisabled: {
|
|
142
145
|
opacity: 0.5,
|
|
@@ -149,7 +152,10 @@ const styles = StyleSheet.create({
|
|
|
149
152
|
justifyContent: "center",
|
|
150
153
|
backgroundColor: colors.zinc[100],
|
|
151
154
|
},
|
|
155
|
+
// WHITE on the wash rather than a deeper tint of it. The deeper tint has no
|
|
156
|
+
// token — `withAlpha` cannot derive one from a themed `var()` — and a lifted
|
|
157
|
+
// white disc reads as raised at every hue, which the two-tone did only at one.
|
|
152
158
|
iconWellDragging: {
|
|
153
|
-
backgroundColor: colors.
|
|
159
|
+
backgroundColor: colors.white,
|
|
154
160
|
},
|
|
155
161
|
});
|
package/src/file_rows.tsx
CHANGED
|
@@ -43,6 +43,21 @@ export interface FileRowsProps {
|
|
|
43
43
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
44
44
|
/** Translated chrome — Download / Remove / Open-external / gallery + confirm. */
|
|
45
45
|
labels?: Partial<GalleryLabels>;
|
|
46
|
+
/**
|
|
47
|
+
* WHAT PRESSING A ROW DOES. Default `"preview"`.
|
|
48
|
+
*
|
|
49
|
+
* Not a switch for turning the gallery off — a statement about what the files
|
|
50
|
+
* ARE. A scan, a photo, a screenshot is something you LOOK at and move on
|
|
51
|
+
* from, and a full-screen preview is the whole interaction. A contract, a
|
|
52
|
+
* recording, a spreadsheet is something you WORK on, and a lightbox inside a
|
|
53
|
+
* drawer is a dead end: you cannot sign it, edit it, or send it from there, so
|
|
54
|
+
* every press costs a dismiss before the real act.
|
|
55
|
+
*
|
|
56
|
+
* `"open"` requires `onOpenExternal` — without a way out, the row would be a
|
|
57
|
+
* door onto nothing — and it stops mounting the gallery at all, so a surface
|
|
58
|
+
* that never previews pays nothing for the engines it does not use.
|
|
59
|
+
*/
|
|
60
|
+
press?: "preview" | "open";
|
|
46
61
|
/** Gap between rows. Default 8. */
|
|
47
62
|
gap?: number;
|
|
48
63
|
/** Credentials mode for the gallery's preview fetches — `"include"` for the
|
|
@@ -56,8 +71,11 @@ export interface FileRowsProps {
|
|
|
56
71
|
* record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
|
|
57
72
|
* for bare square tiles use `FileThumbnailGrid`.
|
|
58
73
|
*/
|
|
59
|
-
export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials }: FileRowsProps) {
|
|
74
|
+
export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials, press = "preview" }: FileRowsProps) {
|
|
60
75
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
76
|
+
// `open` without a way out would make every row a door onto nothing, so the
|
|
77
|
+
// preview stays the fallback rather than the press silently doing nothing.
|
|
78
|
+
const opens = press === "open" && onOpenExternal !== undefined;
|
|
61
79
|
const l = { ...useLoticsLocale().gallery, ...labels };
|
|
62
80
|
|
|
63
81
|
if (files.length === 0) return null;
|
|
@@ -96,11 +114,12 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
96
114
|
name={file.filename}
|
|
97
115
|
meta={meta ? meta(file) : undefined}
|
|
98
116
|
mimeType={file.mimeType}
|
|
99
|
-
onPress={() => setActiveIndex(index)}
|
|
117
|
+
onPress={opens ? () => onOpenExternal(file) : () => setActiveIndex(index)}
|
|
100
118
|
trailing={<ActionMenu accessibilityLabel={`${l.actions}: ${file.filename}`} items={items} />}
|
|
101
119
|
/>
|
|
102
120
|
);
|
|
103
121
|
})}
|
|
122
|
+
{opens ? null : (
|
|
104
123
|
<FileGalleryModal
|
|
105
124
|
files={files}
|
|
106
125
|
activeIndex={activeIndex}
|
|
@@ -115,6 +134,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
115
134
|
onError={onError}
|
|
116
135
|
labels={labels}
|
|
117
136
|
/>
|
|
137
|
+
)}
|
|
118
138
|
</View>
|
|
119
139
|
);
|
|
120
140
|
}
|