@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
package/src/file_thumbnail.tsx
CHANGED
|
@@ -23,8 +23,23 @@ import {
|
|
|
23
23
|
import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
|
|
24
24
|
import { DiffMark, type DiffKind } from "./diff_mark";
|
|
25
25
|
|
|
26
|
+
/** A tile that stands on its own — a grid cell, a card. Big enough to carry a
|
|
27
|
+
* filename under the mark. */
|
|
26
28
|
export const THUMBNAIL_SIZE = 96;
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A tile that RIDES A ROW — the file's identity mark, beside the name rather
|
|
31
|
+
* than under it. It is also the threshold: at or below this, a document renders
|
|
32
|
+
* as a bare badge, because there is no room for a name inside the tile and the
|
|
33
|
+
* row already shows one. Above it, the tile becomes a CARD and prints the
|
|
34
|
+
* filename itself, which beside a row that names the file is the same string
|
|
35
|
+
* twice, the second copy truncated.
|
|
36
|
+
*
|
|
37
|
+
* 40 is the register's identity-mark rung — the same number `AVATAR_PX.lg`
|
|
38
|
+
* uses, for the same reason: it is the mark that says which row this is, and a
|
|
39
|
+
* row is 72px tall (`ROW_HEIGHT`). At 32 it read as a stray glyph with 40px of
|
|
40
|
+
* air around it.
|
|
41
|
+
*/
|
|
42
|
+
export const COMPACT_THUMBNAIL_SIZE = 40;
|
|
28
43
|
|
|
29
44
|
/**
|
|
30
45
|
* Returns the icon name for playable media (video/audio) so a MediaCard can
|
|
@@ -160,8 +175,9 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
160
175
|
const rootStyle =
|
|
161
176
|
size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
|
|
162
177
|
|
|
163
|
-
// A tile
|
|
164
|
-
// it. The row or cell holding
|
|
178
|
+
// A row-sized tile is a badge, not a card: it has no room for a filename, and
|
|
179
|
+
// a corner mark would swallow it. The row or cell holding it carries the
|
|
180
|
+
// change instead.
|
|
165
181
|
const compact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
|
|
166
182
|
const mark = diff === undefined || compact ? null : <DiffCorner kind={diff} />;
|
|
167
183
|
|
package/src/font_family.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `Inter_600SemiBold`), which is the React Native convention and the reason a
|
|
7
7
|
* single family token cannot express the ladder on its own.
|
|
8
8
|
*
|
|
9
|
-
* So the tokens are per-rung, and `
|
|
9
|
+
* So the tokens are per-rung, and `applyLoticsTheme` FANS ONE VALUE OUT
|
|
10
10
|
* across all three when an app sets `bodyFont`. That is what keeps the app-facing
|
|
11
11
|
* surface at one parameter while the kit keeps a rung it can default correctly:
|
|
12
12
|
* unthemed, each rung resolves to its own Inter face; themed, all three resolve
|
package/src/font_family.web.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `Inter_600SemiBold`), which is the React Native convention and the reason a
|
|
7
7
|
* single family token cannot express the ladder on its own.
|
|
8
8
|
*
|
|
9
|
-
* So the tokens are per-rung, and `
|
|
9
|
+
* So the tokens are per-rung, and `applyLoticsTheme` FANS ONE VALUE OUT
|
|
10
10
|
* across all three when an app sets `bodyFont`. That is what keeps the app-facing
|
|
11
11
|
* surface at one parameter while the kit keeps a rung it can default correctly:
|
|
12
12
|
* unthemed, each rung resolves to its own Inter face; themed, all three resolve
|
package/src/funnel.tsx
CHANGED
package/src/icon_button.tsx
CHANGED
|
@@ -139,7 +139,10 @@ const styles = StyleSheet.create({
|
|
|
139
139
|
},
|
|
140
140
|
none: {},
|
|
141
141
|
primary: {
|
|
142
|
-
|
|
142
|
+
// The SAME token `Button` primary uses. These two were near-black by
|
|
143
|
+
// coincidence rather than by contract, so an icon-only action migrated
|
|
144
|
+
// from `Button` kept the colour while silently leaving the theme behind.
|
|
145
|
+
backgroundColor: colors.primary,
|
|
143
146
|
},
|
|
144
147
|
secondary: {
|
|
145
148
|
backgroundColor: colors.zinc[100],
|
|
@@ -153,7 +156,7 @@ const styles = StyleSheet.create({
|
|
|
153
156
|
elevated: {
|
|
154
157
|
backgroundColor: colors.white,
|
|
155
158
|
borderWidth: 1,
|
|
156
|
-
borderColor: colors.
|
|
159
|
+
borderColor: colors.border,
|
|
157
160
|
...({ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.18)" } as object),
|
|
158
161
|
},
|
|
159
162
|
});
|
package/src/index.css
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* There is NO `--font-size-*` ramp here. Twelve such variables used to sit at the
|
|
3
|
+
* top of this file, and they disagreed with the real ramp in `text.css` on the
|
|
4
|
+
* leading of every rung and the size of two — `sm` handed out 14/20 where `Text`
|
|
5
|
+
* renders 14/24. Nothing in this repo or in any app read them, but the shipped
|
|
6
|
+
* catalog offered them for "hand-rolled DOM surfaces", so the one reader who
|
|
7
|
+
* followed the docs got a ramp that silently disagreed with the text beside it.
|
|
8
|
+
*
|
|
9
|
+
* A hand-rolled DOM surface takes `data-text-size="sm"` instead. `text.css`
|
|
10
|
+
* matches that attribute on ANY element, not just what `Text` renders, and it
|
|
11
|
+
* carries the tracking too — which no set of size/line-height variables can.
|
|
12
|
+
*/
|
|
1
13
|
:root {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
--font-size-xl: 24px;
|
|
11
|
-
--font-size-xl-line-height: 30px;
|
|
12
|
-
--font-size-xxl: 32px;
|
|
13
|
-
--font-size-xxl-line-height: 38px;
|
|
14
|
-
|
|
15
|
-
/* Input font size - 16px on mobile to prevent Safari iOS auto-zoom */
|
|
14
|
+
/* Input font size - 16px on mobile to prevent Safari iOS auto-zoom.
|
|
15
|
+
LINE HEIGHT mirrors the `Text` rung at the same size (`text.css`): an input
|
|
16
|
+
is running text and sits at the body rhythm, so 16px takes 26 and 14px takes
|
|
17
|
+
24. These two values are DUPLICATED as `INPUT_LINE_HEIGHT_MOBILE` /
|
|
18
|
+
`INPUT_LINE_HEIGHT_DESKTOP` in `text_utils.ts`, which the auto-grow and
|
|
19
|
+
compact-input math reads — the variable renders, the constant measures, and
|
|
20
|
+
they must agree. Changing one alone leaves a field whose box is computed for
|
|
21
|
+
a rhythm it does not render. */
|
|
16
22
|
--input-font-size: 16px;
|
|
17
|
-
--input-line-height:
|
|
23
|
+
--input-line-height: 26px;
|
|
18
24
|
|
|
19
25
|
--font-weight-regular: 400;
|
|
20
26
|
--font-weight-medium: 500;
|
|
@@ -321,7 +327,7 @@
|
|
|
321
327
|
@media screen and (min-width: 768px) {
|
|
322
328
|
:root {
|
|
323
329
|
--input-font-size: 14px;
|
|
324
|
-
--input-line-height:
|
|
330
|
+
--input-line-height: 24px;
|
|
325
331
|
}
|
|
326
332
|
}
|
|
327
333
|
|
package/src/inline_edit.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { ActivityIndicator } from "./activity_indicator";
|
|
|
7
7
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
8
8
|
import { colors } from "./colors";
|
|
9
9
|
import { FOCUS_RING, CONTROL_RADIUS, CONTROL_PADDING_V, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
10
|
-
import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
|
|
10
|
+
import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
|
|
11
11
|
import { getInteractionModality } from "./interaction_modality";
|
|
12
12
|
import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
13
13
|
import { trackCommit } from "./pending_commits";
|
|
@@ -646,7 +646,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
646
646
|
export const inlineValueTextStyle: TextStyle = {
|
|
647
647
|
...getInputTextStyle(),
|
|
648
648
|
fontFamily: fontFamilyRegular,
|
|
649
|
-
letterSpacing:
|
|
649
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
650
650
|
};
|
|
651
651
|
|
|
652
652
|
// In the view ROW the value text takes the flex slot so the trailing adornment
|
|
@@ -728,16 +728,15 @@ const styles = StyleSheet.create({
|
|
|
728
728
|
viewHovered: {
|
|
729
729
|
borderColor: HOVER_BORDER,
|
|
730
730
|
},
|
|
731
|
-
// Hover on a FRAMED field:
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
731
|
+
// Hover on a FRAMED field: THE SAME SIGNAL, and only that. It used to deepen
|
|
732
|
+
// the border AND tint the surface, because at zinc-400 the border shift was
|
|
733
|
+
// too quiet to carry alone — a real problem solved in the wrong place. Every
|
|
734
|
+
// other input in the kit hovers by border only, so this made inline fields the
|
|
735
|
+
// one control with a second hover language, and a reader crossing a record
|
|
736
|
+
// surface met both. `HOVER_BORDER` is now zinc-500 and carries it unaided;
|
|
737
|
+
// one property means one thing to theme, and nothing to keep in sync.
|
|
738
738
|
viewHoveredFramed: {
|
|
739
739
|
borderColor: HOVER_BORDER,
|
|
740
|
-
backgroundColor: colors.zinc[50],
|
|
741
740
|
},
|
|
742
741
|
placeholder: { color: colors.zinc[400] },
|
|
743
742
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
package/src/inline_files.tsx
CHANGED
|
@@ -85,6 +85,10 @@ export interface InlineFilesProps {
|
|
|
85
85
|
emptyLabel?: string;
|
|
86
86
|
/** Native accept filter, e.g. `"application/pdf,image/*"`. */
|
|
87
87
|
accept?: string;
|
|
88
|
+
/** What pressing a row does — forwarded to `FileRows`. A field holding
|
|
89
|
+
* documents (a contract, a recording) wants `"open"`; a field holding scans
|
|
90
|
+
* wants the default preview. */
|
|
91
|
+
press?: "preview" | "open";
|
|
88
92
|
/** Translated gallery/menu chrome, forwarded to `FileRows`. */
|
|
89
93
|
labels?: Partial<GalleryLabels>;
|
|
90
94
|
/** Credentials mode for the preview fetches — `"include"` for auth-gated
|
|
@@ -122,6 +126,7 @@ export function InlineFiles(props: InlineFilesProps) {
|
|
|
122
126
|
onAdd,
|
|
123
127
|
onRemove,
|
|
124
128
|
onOpenExternal,
|
|
129
|
+
press,
|
|
125
130
|
onDownload,
|
|
126
131
|
multiple = true,
|
|
127
132
|
addLabel,
|
|
@@ -196,6 +201,7 @@ export function InlineFiles(props: InlineFilesProps) {
|
|
|
196
201
|
onError={onError}
|
|
197
202
|
labels={labels}
|
|
198
203
|
credentials={credentials}
|
|
204
|
+
press={press}
|
|
199
205
|
/>
|
|
200
206
|
) : null}
|
|
201
207
|
{blockedReason !== undefined ? (
|
package/src/json_panel.tsx
CHANGED
|
@@ -51,7 +51,7 @@ const styles = StyleSheet.create({
|
|
|
51
51
|
},
|
|
52
52
|
body: {
|
|
53
53
|
borderWidth: 1,
|
|
54
|
-
borderColor: colors.
|
|
54
|
+
borderColor: colors.border,
|
|
55
55
|
borderRadius: 8,
|
|
56
56
|
backgroundColor: colors.white,
|
|
57
57
|
// The ScrollView owns the padding (content inset scrolls with the text); the
|
package/src/kpi_card.tsx
CHANGED
|
@@ -57,7 +57,7 @@ export function KPICard(props: KPICardProps) {
|
|
|
57
57
|
return (
|
|
58
58
|
<View style={[styles.container, style]}>
|
|
59
59
|
<View style={styles.labelRow}>
|
|
60
|
-
<Text size="xs" color="muted"
|
|
60
|
+
<Text size="xs" color="muted" weight="medium">
|
|
61
61
|
{label}
|
|
62
62
|
</Text>
|
|
63
63
|
{info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
|
package/src/locale.tsx
CHANGED
|
@@ -609,7 +609,7 @@ interface LoticsLocaleProviderProps {
|
|
|
609
609
|
|
|
610
610
|
/**
|
|
611
611
|
* App-root provider that supplies localized strings to @lotics/ui primitives —
|
|
612
|
-
* the
|
|
612
|
+
* the kit's one root provider. Wrap your top-level element once and
|
|
613
613
|
* every wired component picks up the pack; no per-instance label props:
|
|
614
614
|
*
|
|
615
615
|
* // src/main.tsx
|
package/src/markdown.css
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
"Helvetica Neue", sans-serif;
|
|
5
5
|
font-size: 14px;
|
|
6
6
|
line-height: 24px;
|
|
7
|
-
|
|
7
|
+
/* The body rung of the curve in `text.css`. Same tightness as the -0.4px this
|
|
8
|
+
carried before, written as an em so it scales with the rung instead of being
|
|
9
|
+
a flat pixel value that drifts at every other size — and now ONE number
|
|
10
|
+
shared with `Text` and the inputs rather than markdown's private one. */
|
|
11
|
+
letter-spacing: -0.006em;
|
|
8
12
|
color: rgba(24, 24, 27, 1);
|
|
9
13
|
}
|
|
10
14
|
|
package/src/metric.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, StyleSheet, type TextStyle } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { getTextColor } from "./text_utils";
|
|
4
5
|
import { formatCompactNumber, formatMoney } from "./format_money";
|
|
5
6
|
import { useMemo } from "react";
|
|
6
7
|
|
|
@@ -41,25 +42,11 @@ const SIZE_TO_TEXT_SIZE: Record<MetricSize, "md" | "lg" | "xl" | "xxl"> = {
|
|
|
41
42
|
hero: "xxl",
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
// Letter-spacing tightens as font-size grows — "designed" not "inflated".
|
|
45
|
-
// Hero gets the tightest tracking (-1.2) to match the 48px display size's
|
|
46
|
-
// expected condensation.
|
|
47
|
-
const SIZE_TO_LETTER_SPACING: Record<MetricSize, number> = {
|
|
48
|
-
sm: 0,
|
|
49
|
-
md: -0.25,
|
|
50
|
-
lg: -0.5,
|
|
51
|
-
hero: -1.2,
|
|
52
|
-
};
|
|
53
45
|
|
|
54
46
|
const TREND_UP = "↑";
|
|
55
47
|
const TREND_DOWN = "↓";
|
|
56
48
|
const TREND_FLAT = "→";
|
|
57
49
|
|
|
58
|
-
const TONE_COLOR: Record<Exclude<MetricTone, "default">, string> = {
|
|
59
|
-
warning: colors.amber[600],
|
|
60
|
-
danger: colors.red[600],
|
|
61
|
-
};
|
|
62
|
-
|
|
63
50
|
export function Metric(props: MetricProps) {
|
|
64
51
|
const {
|
|
65
52
|
value,
|
|
@@ -104,16 +91,29 @@ export function Metric(props: MetricProps) {
|
|
|
104
91
|
size={SIZE_TO_TEXT_SIZE[size]}
|
|
105
92
|
weight="medium"
|
|
106
93
|
style={[
|
|
107
|
-
// Tabular nums prevent the comma/decimal jitter when 1,234 sits
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
94
|
+
// Tabular nums prevent the comma/decimal jitter when 1,234 sits next
|
|
95
|
+
// to 7,890 across cards — proportional glyphs misalign every column.
|
|
96
|
+
//
|
|
97
|
+
// TRACKING IS NOT SET HERE. This carried its own size→tracking table,
|
|
98
|
+
// which was a second copy of the ramp's curve and disagreed with it at
|
|
99
|
+
// every rung — `sm` sat at ZERO while the 16px text beside it tracked
|
|
100
|
+
// -0.48px, so every summary figure in the product was the one number
|
|
101
|
+
// not tracking like its own sentence. `Text` already applies the rung's
|
|
102
|
+
// value from `text.css`; the correct amount of tracking to add here is
|
|
103
|
+
// none. (Tabular figures do not need it reset either — letter-spacing
|
|
104
|
+
// adds a constant to every advance, so a tabular column stays aligned.)
|
|
105
|
+
{ fontVariantNumeric: "tabular-nums" } as TextStyle,
|
|
106
|
+
// THE VALENCE INK COMES FROM `Text`, not a second map here. This kept
|
|
107
|
+
// its own — amber-600 and red-600 — while `getTextColor` answers
|
|
108
|
+
// amber-700 and red-900 for the same two words, so one `tone="warning"`
|
|
109
|
+
// rendered two different ambers depending on whether the number was a
|
|
110
|
+
// `Metric` or the sentence beside it.
|
|
111
|
+
//
|
|
112
|
+
// And the divergence was not cosmetic: amber-600 measures 3.19:1 on
|
|
113
|
+
// white, under the 4.5:1 AA floor for normal text, which is exactly
|
|
114
|
+
// what `getTextColor`'s own comment says the 700 rung exists to clear.
|
|
115
|
+
// A summary figure is the smallest, most glanced-at text on a register.
|
|
116
|
+
tone === "default" ? undefined : { color: getTextColor(tone) },
|
|
117
117
|
]}
|
|
118
118
|
>
|
|
119
119
|
{displayValue}
|
package/src/number_input.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { KeyboardEvent } from "react";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
3
|
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
4
|
-
import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
|
|
4
|
+
import { fontFamilyRegular, inputTextStyleWeb, INPUT_LETTER_SPACING } from "./text_utils";
|
|
5
5
|
import { useFormField } from "./form_field";
|
|
6
6
|
import { useFocusRing } from "./use_focus_ring";
|
|
7
7
|
import { useHover } from "./use_hover";
|
|
@@ -75,7 +75,7 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
75
75
|
backgroundColor: seamless ? "transparent" : colors.background,
|
|
76
76
|
fontFamily: fontFamilyRegular,
|
|
77
77
|
...inputTextStyleWeb,
|
|
78
|
-
letterSpacing:
|
|
78
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
79
79
|
boxShadow: !seamless && focusVisible ? FOCUS_RING : "none",
|
|
80
80
|
outline: "none",
|
|
81
81
|
boxSizing: "border-box",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { OptionBadge, type OptionValue } from "./option_badge";
|
|
3
|
+
import type { PickerOption } from "./picker";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feed a select FIELD's options into any kit picker — `InlineSelect`, `Select`,
|
|
7
|
+
* `OptionList`, `Combobox` — without dropping what the field knows.
|
|
8
|
+
*
|
|
9
|
+
* <InlineSelect {...optionPicker(fields.city?.options ?? [])} … /> // a category
|
|
10
|
+
* <InlineSelect {...optionPicker(fields.status?.options ?? [], { badge: "dot" })} … /> // a status
|
|
11
|
+
*
|
|
12
|
+
* It exists because the obvious call site loses information: a picker takes
|
|
13
|
+
* `{ value, label }`, so every app writes
|
|
14
|
+
* `options.map((o) => ({ value: o.key, label: o.label }))` — and the option's
|
|
15
|
+
* `color` is not in that shape, so it never arrives.
|
|
16
|
+
*
|
|
17
|
+
* **`badge` is OPT-IN, and that is the whole design.** A badge means STATUS —
|
|
18
|
+
* a lifecycle, a stage, a risk level, something that reads at a glance and
|
|
19
|
+
* changes what you do. A type, a category, an attribute or a count is NOT a
|
|
20
|
+
* status: an industry, a source, a department, a headcount band, **a city**.
|
|
21
|
+
* Those render as plain text, and the composition grammar says so in those
|
|
22
|
+
* words. Colouring them anyway is the failure this default prevents — every
|
|
23
|
+
* option in every picker becomes a chip, the vocabulary that made a coloured
|
|
24
|
+
* pill MEAN something is spent on where a company happens to be, and the screen
|
|
25
|
+
* reads as decorated rather than organised. A field carrying a `color` is not
|
|
26
|
+
* consent to paint it; someone configures colours on a field for the one surface
|
|
27
|
+
* that badges it, and every other surface still has to decide.
|
|
28
|
+
*
|
|
29
|
+
* So the safe answer is the default: omit `badge` and you get text, which is
|
|
30
|
+
* correct for the majority of select fields. Pass it where the value is a status
|
|
31
|
+
* and you want it recognised rather than read.
|
|
32
|
+
*
|
|
33
|
+
* `dot` over `tonal` when you do badge: a picker row is a place to READ a set of
|
|
34
|
+
* choices, and a stack of filled pills is heavier than the list describing them.
|
|
35
|
+
*/
|
|
36
|
+
export function optionPicker(
|
|
37
|
+
options: OptionValue[],
|
|
38
|
+
opts?: { badge?: "tonal" | "dot" },
|
|
39
|
+
): {
|
|
40
|
+
options: PickerOption<string, OptionValue>[];
|
|
41
|
+
renderOptionContent?: (option: PickerOption<string, OptionValue>) => React.ReactNode;
|
|
42
|
+
} {
|
|
43
|
+
const badge = opts?.badge;
|
|
44
|
+
return {
|
|
45
|
+
// `key` is what the platform stores and what a write must send back; the
|
|
46
|
+
// label is display text that can be renamed under you. Falling back to the
|
|
47
|
+
// label keeps a hand-built option list working rather than producing an
|
|
48
|
+
// option whose value is `undefined`.
|
|
49
|
+
options: options.map((o) => ({ value: o.key ?? o.label, label: o.label, data: o })),
|
|
50
|
+
// Absent, not a no-op renderer: the pickers fall back to their own label
|
|
51
|
+
// rendering, so an un-badged field looks exactly like every other text value
|
|
52
|
+
// on the surface rather than like a badge that failed to paint.
|
|
53
|
+
renderOptionContent: badge
|
|
54
|
+
? (option) => (option.data ? <OptionBadge value={option.data} variant={badge} /> : null)
|
|
55
|
+
: undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/picker.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
6
|
import { useFocusRing } from "./use_focus_ring";
|
|
7
7
|
import { useHover } from "./use_hover";
|
|
8
|
-
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
8
|
+
import { fontFamilyRegular, getInputTextStyle, INPUT_LETTER_SPACING } from "./text_utils";
|
|
9
9
|
import { Icon } from "./icon";
|
|
10
10
|
import { useLoticsLocale } from "./locale";
|
|
11
11
|
import { useFormField } from "./form_field";
|
|
@@ -188,7 +188,7 @@ const styles = StyleSheet.create({
|
|
|
188
188
|
borderWidth: 1,
|
|
189
189
|
borderColor: colors.border,
|
|
190
190
|
fontFamily: fontFamilyRegular,
|
|
191
|
-
letterSpacing:
|
|
191
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
192
192
|
backgroundColor: "transparent",
|
|
193
193
|
appearance: "none",
|
|
194
194
|
},
|
package/src/pressable_row.tsx
CHANGED
|
@@ -86,11 +86,21 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
86
86
|
styles.row,
|
|
87
87
|
variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
|
|
88
88
|
{
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
89
|
+
// FOUR states, and they must not collide. Selection and hover painted
|
|
90
|
+
// the SAME wash, which made "the row whose record is open" and "the row
|
|
91
|
+
// the pointer happens to be over" indistinguishable — the register's
|
|
92
|
+
// one piece of persistent state, erased by a transient one. Hover is
|
|
93
|
+
// now neutral and lighter, because it answers "you can press this",
|
|
94
|
+
// while selection carries the brand's WASH because it answers "you are
|
|
95
|
+
// here" — the state the accent exists for. `pressed` stays neutral and
|
|
96
|
+
// darkest: a momentary depth cue, not an identity.
|
|
97
|
+
// `marked` shares the wash rather than keeping its own literal blue-50:
|
|
98
|
+
// that hex was the same colour the wash now resolves to, minus the
|
|
99
|
+
// theming, so a branded app painted its ticked rows in a hue it had
|
|
100
|
+
// replaced everywhere else. What separates a ticked row from an open
|
|
101
|
+
// one is the ticked checkbox, which is unmissable; two washes for two
|
|
102
|
+
// orthogonal states could never render anyway on a row that is both.
|
|
103
|
+
backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.accent_wash : hovered ? colors.zinc[50] : undefined,
|
|
94
104
|
},
|
|
95
105
|
style,
|
|
96
106
|
]}
|
package/src/progress_bar.tsx
CHANGED
|
@@ -112,7 +112,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
112
112
|
{title || caption ? (
|
|
113
113
|
<View style={styles.header}>
|
|
114
114
|
{title ? (
|
|
115
|
-
<Text size="xs" color="muted"
|
|
115
|
+
<Text size="xs" color="muted" weight="medium">
|
|
116
116
|
{title}
|
|
117
117
|
</Text>
|
|
118
118
|
) : null}
|
package/src/radio_picker.tsx
CHANGED
|
@@ -131,7 +131,8 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
131
131
|
borderRadius: 999,
|
|
132
132
|
borderWidth: 1,
|
|
133
133
|
borderColor: colors.border,
|
|
134
|
-
|
|
134
|
+
// Selected = the primary token, like every other "this one is on".
|
|
135
|
+
backgroundColor: selected ? colors.primary : colors.zinc["50"],
|
|
135
136
|
justifyContent: "center",
|
|
136
137
|
alignItems: "center",
|
|
137
138
|
}}
|
package/src/search_input.tsx
CHANGED
|
@@ -63,19 +63,20 @@ export function SearchInput(props: SearchInputProps) {
|
|
|
63
63
|
|
|
64
64
|
const styles = StyleSheet.create({
|
|
65
65
|
// Search reads as a view-control by its leading search glyph + white fill —
|
|
66
|
-
// NOT by a distinct shape. It shares the kit-wide `CONTROL_RADIUS
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
66
|
+
// NOT by a distinct shape. It shares the kit-wide `CONTROL_RADIUS`, the 40px
|
|
67
|
+
// control height, AND the resting `colors.border` hairline of every other
|
|
68
|
+
// input and picker, so a toolbar reads as one band of one species.
|
|
69
|
+
//
|
|
70
|
+
// It used to take `zinc-300`, one step heavier, on the argument that the white
|
|
71
|
+
// fill would vanish "on white cards". That defended against the wrong thing:
|
|
72
|
+
// a search input is never alone, it sits in a band beside filters and pickers,
|
|
73
|
+
// and measured in one there was exactly one control with a darker edge than
|
|
74
|
+
// its six neighbours — a difference a reader parses as an accident, not as
|
|
75
|
+
// emphasis. Definition on a white ground is the hairline's job everywhere else
|
|
76
|
+
// in the kit; there is no reason this control is the exception.
|
|
73
77
|
search: {
|
|
74
78
|
borderRadius: CONTROL_RADIUS,
|
|
75
79
|
backgroundColor: colors.white,
|
|
76
|
-
|
|
77
|
-
// emphasis comes from the input's own focus outline, so it stays quiet at
|
|
78
|
-
// rest. zinc-300 keeps it defined on white cards.
|
|
79
|
-
borderColor: colors.zinc[300],
|
|
80
|
+
borderColor: colors.border,
|
|
80
81
|
},
|
|
81
82
|
});
|
package/src/sort_header.tsx
CHANGED
|
@@ -95,10 +95,14 @@ export function SortHeader(props: SortHeaderProps) {
|
|
|
95
95
|
>
|
|
96
96
|
{align === "right" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
|
|
97
97
|
<Text
|
|
98
|
-
size="
|
|
98
|
+
size="sm"
|
|
99
99
|
color={active ? "default" : "muted"}
|
|
100
|
-
weight
|
|
101
|
-
|
|
100
|
+
// ONE weight, sorted or not. The active column is marked by INK (and by
|
|
101
|
+
// its direction chevron), never by stepping up to semibold: semibold is
|
|
102
|
+
// the heading ladder's weight in this system, and a sorted column is a
|
|
103
|
+
// label like the ones beside it, not a heading. A sortable and a plain
|
|
104
|
+
// header share a band and must read as one row of labels.
|
|
105
|
+
weight="medium"
|
|
102
106
|
numberOfLines={1}
|
|
103
107
|
>
|
|
104
108
|
{label}
|
|
@@ -61,7 +61,7 @@ export function StackedProgressBar(props: StackedProgressBarProps) {
|
|
|
61
61
|
<View style={styles.container}>
|
|
62
62
|
<View style={styles.header}>
|
|
63
63
|
{title ? (
|
|
64
|
-
<Text size="xs" color="muted"
|
|
64
|
+
<Text size="xs" color="muted" weight="medium">
|
|
65
65
|
{title}
|
|
66
66
|
</Text>
|
|
67
67
|
) : null}
|
package/src/step_progress.tsx
CHANGED
|
@@ -45,7 +45,10 @@ export interface StepProgressProps {
|
|
|
45
45
|
* stage-countable at card density → this.
|
|
46
46
|
*/
|
|
47
47
|
export function StepProgress(props: StepProgressProps) {
|
|
48
|
-
|
|
48
|
+
// The DEFAULT fill is the primary token — progress is the surface saying "this
|
|
49
|
+
// much is done", the same claim the one filled button makes. Still a prop, so a
|
|
50
|
+
// caller that means a specific hue (a status, a series) passes one.
|
|
51
|
+
const { steps, current, color = colors.primary, title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
|
|
49
52
|
const captionColor = captionTone === "danger" ? "danger" : "muted";
|
|
50
53
|
const names = typeof steps === "number" ? null : steps;
|
|
51
54
|
const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
|
|
@@ -94,7 +97,7 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
94
97
|
return (
|
|
95
98
|
<View style={{ gap: 6, flex: 1 }}>
|
|
96
99
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
|
|
97
|
-
<Text size="xs" color="muted"
|
|
100
|
+
<Text size="xs" color="muted" weight="medium">
|
|
98
101
|
{title}
|
|
99
102
|
</Text>
|
|
100
103
|
<View style={{ flex: 1 }} />
|
package/src/switch.tsx
CHANGED
|
@@ -37,14 +37,17 @@ export function Switch(props: SwitchProps) {
|
|
|
37
37
|
|
|
38
38
|
const content = (
|
|
39
39
|
<Animated.View
|
|
40
|
+
// The ON track is `colors.primary`, applied as a PLAIN style rather than
|
|
41
|
+
// through the interpolation the thumb uses. That split is forced, not a
|
|
42
|
+
// preference: `Animated.interpolate` PARSES its `outputRange` colours in
|
|
43
|
+
// order to tween between them, and on web `colors.primary` is a `var()` —
|
|
44
|
+
// a string no colour parser can read, which would yield garbage no type
|
|
45
|
+
// would catch. So the track's colour switches instantly while the thumb
|
|
46
|
+
// keeps its spring; OFF keeps the literal zinc-200, which was never a
|
|
47
|
+
// token and carries no brand claim.
|
|
40
48
|
style={[
|
|
41
49
|
styles.wrapper,
|
|
42
|
-
{
|
|
43
|
-
backgroundColor: checked.interpolate({
|
|
44
|
-
inputRange: [0, 1],
|
|
45
|
-
outputRange: [colors.zinc["200"], colors.zinc["800"]],
|
|
46
|
-
}),
|
|
47
|
-
},
|
|
50
|
+
{ backgroundColor: value ? colors.primary : colors.zinc["200"] },
|
|
48
51
|
]}
|
|
49
52
|
>
|
|
50
53
|
<Animated.View
|
|
@@ -62,6 +65,8 @@ export function Switch(props: SwitchProps) {
|
|
|
62
65
|
},
|
|
63
66
|
]}
|
|
64
67
|
>
|
|
68
|
+
{/* The tick rides the WHITE thumb, not the track, so it stays neutral
|
|
69
|
+
ink — theming it would put the brand on top of the brand. */}
|
|
65
70
|
{value && <Icon name="check" size={18} color={colors.zinc["900"]} />}
|
|
66
71
|
</Animated.View>
|
|
67
72
|
</Animated.View>
|