@lotics/ui 2.4.1 → 2.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.
Files changed (69) hide show
  1. package/package.json +28 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/skeleton.tsx +47 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
@@ -0,0 +1,50 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+
5
+ export interface LinkButtonProps {
6
+ title: string;
7
+ onPress: () => void;
8
+ /** Defaults to `title`. */
9
+ accessibilityLabel?: string;
10
+ testID?: string;
11
+ }
12
+
13
+ /**
14
+ * A quiet inline action — "Clear", "Select all", "Show more" — styled as a GHOST
15
+ * button: dark medium text with no chrome at rest and a soft rounded hover wash,
16
+ * compact (not the 40px `Button`). The low-emphasis sibling of `Button` for
17
+ * utility actions in a popover footer or a list's select-all row; it never
18
+ * competes with a real/commit button, and reads more premium than an underlined
19
+ * link or a filled/muted Button.
20
+ */
21
+ export function LinkButton(props: LinkButtonProps) {
22
+ const { title, onPress, accessibilityLabel, testID } = props;
23
+ return (
24
+ <PressableHighlight
25
+ testID={testID}
26
+ onPress={onPress}
27
+ accessibilityRole="button"
28
+ accessibilityLabel={accessibilityLabel ?? title}
29
+ userSelect="none"
30
+ style={styles.btn}
31
+ >
32
+ <Text size="sm" weight="medium">
33
+ {title}
34
+ </Text>
35
+ </PressableHighlight>
36
+ );
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ // The hover wash needs horizontal padding, but the negative margin cancels it
41
+ // for POSITIONING — so the text sits flush with the surrounding content's left
42
+ // edge (the option rows above, the editor body) while the wash bleeds outward.
43
+ btn: {
44
+ paddingVertical: 6,
45
+ paddingHorizontal: 8,
46
+ marginHorizontal: -8,
47
+ borderRadius: 8,
48
+ alignSelf: "flex-start",
49
+ },
50
+ });
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 { formatCompactNumber, formatMoney } from "./format_money";
4
5
  import { useMemo } from "react";
5
6
 
6
7
  export type MetricFormat = "currency" | "number" | "percentage" | "none";
@@ -19,6 +20,11 @@ export type MetricSize = "sm" | "md" | "lg" | "hero";
19
20
 
20
21
  export interface MetricProps {
21
22
  value: number | string | null | undefined;
23
+ /** Abbreviate large numeric values for display density: ≥1 tỷ → "1,28 tỷ",
24
+ * ≥1 triệu → "486 tr" (vi convention; other locales fall back to compact
25
+ * notation). For stat strips/cards where the full figure lives in the
26
+ * table below — never for the table itself. */
27
+ compact?: boolean;
22
28
  previousValue?: number | string | null | undefined;
23
29
  format?: MetricFormat;
24
30
  currency?: string;
@@ -59,26 +65,27 @@ export function Metric(props: MetricProps) {
59
65
  value,
60
66
  previousValue,
61
67
  format,
62
- currency = "USD",
68
+ currency = "VND",
63
69
  locale,
64
70
  emptyLabel = "-",
65
71
  tone = "default",
66
72
  size = "md",
73
+ compact = false,
67
74
  } = props;
68
75
 
69
76
  const displayValue = useMemo(() => {
77
+ // vi-VN by default on every branch — "18,4%", not "18.4%". A system
78
+ // locale fallback makes the same dashboard render differently per
79
+ // machine.
80
+ const resolvedLocale = locale ?? "vi-VN";
70
81
  if (value === null || value === undefined) return emptyLabel;
71
- if (format === "currency" && typeof value === "number") {
72
- return value.toLocaleString(locale, { style: "currency", currency });
73
- }
74
- if (format === "number" && typeof value === "number") {
75
- return value.toLocaleString(locale);
76
- }
77
- if (format === "percentage" && typeof value === "number") {
78
- return `${value.toLocaleString(locale)}%`;
79
- }
82
+ if (typeof value !== "number") return String(value);
83
+ if (format === "currency") return formatMoney(value, { locale: resolvedLocale, currency, compact });
84
+ if (compact && Math.abs(value) >= 1_000_000) return formatCompactNumber(value, resolvedLocale);
85
+ if (format === "number") return value.toLocaleString(resolvedLocale);
86
+ if (format === "percentage") return `${value.toLocaleString(resolvedLocale)}%`;
80
87
  return String(value);
81
- }, [value, format, currency, locale, emptyLabel]);
88
+ }, [value, format, currency, locale, emptyLabel, compact]);
82
89
 
83
90
  const trend = useMemo(() => {
84
91
  if (previousValue === undefined || previousValue === null) return null;
@@ -139,8 +146,10 @@ export function Metric(props: MetricProps) {
139
146
  }
140
147
 
141
148
  const styles = StyleSheet.create({
149
+ // No flex grow/shrink: the value keeps its intrinsic width so that in a
150
+ // wrap-row (KPICard's value + trend chip) the CHIP drops to the next line
151
+ // when space runs out — never the number breaking mid-value.
142
152
  container: {
143
- flex: 1,
144
153
  justifyContent: "center",
145
154
  alignItems: "flex-start",
146
155
  gap: 4,
@@ -2,7 +2,6 @@ import * as React from "react";
2
2
  import { View, StyleSheet } from "react-native";
3
3
  import { IconButton } from "./icon_button";
4
4
  import { Text } from "./text";
5
- import { colors } from "./colors";
6
5
 
7
6
  export interface PaginationProps {
8
7
  /** 0-indexed. */
@@ -22,9 +21,11 @@ export interface PaginationProps {
22
21
  }
23
22
 
24
23
  /**
25
- * Pagination footer: summary on the left, page indicator + arrows on the
26
- * right. Self-containedhas its own border-top so it composes as a footer
27
- * inside DataGrid OR as a standalone control elsewhere.
24
+ * Pagination control: range summary on the left, page indicator + arrows on the
25
+ * right. A pure control it owns no border, background, or padding. The
26
+ * container that frames it (a `CardFooter`, a grid footer, a bordered band)
27
+ * supplies the divider and insets, so the same control composes anywhere without
28
+ * doubling up a border.
28
29
  */
29
30
  export function Pagination(props: PaginationProps): React.ReactNode {
30
31
  const { page, pageSize, rowCount, hasMore, total, loading, onPageChange } = props;
@@ -78,11 +79,6 @@ const styles = StyleSheet.create({
78
79
  flexDirection: "row",
79
80
  alignItems: "center",
80
81
  gap: 12,
81
- paddingVertical: 8,
82
- paddingHorizontal: 12,
83
- borderTopWidth: 1,
84
- borderTopColor: colors.border,
85
- backgroundColor: colors.background,
86
82
  },
87
83
  summary: { flex: 1 },
88
84
  controls: {
package/src/peek.tsx ADDED
@@ -0,0 +1,68 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
4
+ import type { PopoverSide, PopoverAlign } from "./popover";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+
7
+ export interface PeekProps {
8
+ /** The inline reference that becomes the trigger — a customer name, a
9
+ * record id, a member chip. Rendered inside a PressableHighlight (hover
10
+ * wash signals pressability without restyling the text). */
11
+ children: ReactNode;
12
+ /** The instant detail revealed on press — compose Text/Divider/Button
13
+ * rows. Keep it a summary with ONE action to the full record; a peek
14
+ * that needs scrolling wanted to be a screen. */
15
+ content: ReactNode;
16
+ /** Announced name for the trigger ("Hồ sơ KOMASPEC VIỆT NAM"). */
17
+ accessibilityLabel: string;
18
+ side?: PopoverSide;
19
+ align?: PopoverAlign;
20
+ }
21
+
22
+ /**
23
+ * Drill-down for inline references — press a name/id where it appears and
24
+ * get its details in a popover, without leaving the screen. The companion
25
+ * to the count drill-downs (KPIStrip `onPress`, Accordion rows): counts
26
+ * open the records behind a number, Peek opens the record behind a
27
+ * reference. Use it on every entity mention that has more to say —
28
+ * customer names, order ids, member names, linked records.
29
+ */
30
+ export function Peek(props: PeekProps) {
31
+ const { children, content, accessibilityLabel, side = "bottom", align = "start" } = props;
32
+ return (
33
+ <Popover side={side} align={align}>
34
+ <PopoverTrigger>
35
+ <PressableHighlight
36
+ accessibilityRole="button"
37
+ accessibilityLabel={accessibilityLabel}
38
+ style={styles.trigger}
39
+ // 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
40
+ hitSlop={4}
41
+ >
42
+ {children}
43
+ </PressableHighlight>
44
+ </PopoverTrigger>
45
+ <PopoverContent style={styles.content} disableBodyScroll>
46
+ {content}
47
+ </PopoverContent>
48
+ </Popover>
49
+ );
50
+ }
51
+
52
+ const styles = StyleSheet.create({
53
+ // Inline bleed at the 32px chip register (matches Badge scale, fits inside
54
+ // text rows without inflating them); negative margins absorb the extra
55
+ // height so the line's layout never shifts. hitSlop keeps the 40px target.
56
+ trigger: {
57
+ flexDirection: "row",
58
+ alignItems: "center",
59
+ minHeight: 32,
60
+ borderRadius: 8,
61
+ paddingHorizontal: 8,
62
+ marginHorizontal: -8,
63
+ marginVertical: -4,
64
+ },
65
+ content: {
66
+ width: 320,
67
+ },
68
+ });
package/src/picker.tsx CHANGED
@@ -33,6 +33,10 @@ export type PickerOnClose<T extends string, MULTI extends boolean> = MULTI exten
33
33
  export interface PickerProps<T extends string = string, MULTI extends boolean = false> {
34
34
  options?: (PickerOption<T> | undefined | false)[];
35
35
  placeholder?: string;
36
+ /** Accessible name for the control. Required in spirit whenever the picker
37
+ * has no visible label next to it (e.g. an unlabeled toolbar filter) — the
38
+ * selected option's text describes the value, not the control. */
39
+ accessibilityLabel?: string;
36
40
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
37
41
  style?: StyleProp<ViewStyle>;
38
42
  testID?: string;
@@ -70,6 +74,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
70
74
  includeEmptyOption,
71
75
  onValueChange,
72
76
  placeholder,
77
+ accessibilityLabel,
73
78
  style,
74
79
  disabled = false,
75
80
  autoFocus = false,
@@ -105,6 +110,7 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
105
110
  <RNPicker
106
111
  ref={pickerRef}
107
112
  testID={testID}
113
+ accessibilityLabel={accessibilityLabel}
108
114
  // Empty selection maps to "" (the placeholder option's value), never
109
115
  // undefined — otherwise the native <select> goes uncontrolled and a
110
116
  // programmatic reset to null leaves the prior DOM selection in place.
@@ -144,6 +150,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
144
150
  options = [],
145
151
  value,
146
152
  placeholder,
153
+ accessibilityLabel,
147
154
  multi = false as MULTI,
148
155
  renderOptionContent,
149
156
  includeEmptyOption,
@@ -188,6 +195,7 @@ function CustomPicker<T extends string, MULTI extends boolean = false>(
188
195
  renderOptionContent={renderOptionContent}
189
196
  selectedItems={selectedItems}
190
197
  placeholder={placeholder}
198
+ accessibilityLabel={accessibilityLabel}
191
199
  disabled={disabled}
192
200
  />
193
201
  </PopoverTrigger>
@@ -244,6 +252,7 @@ function PickerTrigger<T extends string>({
244
252
  renderOptionContent,
245
253
  selectedItems,
246
254
  placeholder,
255
+ accessibilityLabel,
247
256
  disabled = false,
248
257
  }: {
249
258
  ref?: React.Ref<View>;
@@ -254,6 +263,7 @@ function PickerTrigger<T extends string>({
254
263
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
255
264
  selectedItems: PickerOption<T>[];
256
265
  placeholder?: string;
266
+ accessibilityLabel?: string;
257
267
  disabled?: boolean;
258
268
  }) {
259
269
  const hasSelection = selectedItems.length > 0;
@@ -266,8 +276,10 @@ function PickerTrigger<T extends string>({
266
276
  // the trigger drops out of the tab order and Enter/Space can't open it.
267
277
  // `button` makes it tab-focusable and maps keyboard activation to onPress;
268
278
  // `expanded` announces open/closed to assistive tech. The accessible name
269
- // comes from the visible selection/placeholder text below.
279
+ // comes from `accessibilityLabel` when given (an unlabeled control), else
280
+ // the visible selection/placeholder text below.
270
281
  accessibilityRole="button"
282
+ accessibilityLabel={accessibilityLabel}
271
283
  accessibilityState={{ expanded: open, disabled }}
272
284
  style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
273
285
  onPress={!disabled ? onPress : undefined}
@@ -1,11 +1,11 @@
1
- import { StyleSheet, View, ScrollView, Pressable, TextInput } from "react-native";
1
+ import { StyleSheet, View, ScrollView, TextInput } from "react-native";
2
2
  import { useState, useCallback, useMemo, useRef } from "react";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { Icon } from "./icon";
6
6
  import { Checkbox } from "./checkbox";
7
- import { Spacer } from "./spacer";
8
7
  import { MenuButton } from "./menu_button";
8
+ import { LinkButton } from "./link_button";
9
9
  import { ActivityIndicator } from "./activity_indicator";
10
10
  import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
11
11
  import { useScreenSize } from "./use_screen_size";
@@ -311,17 +311,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
311
311
  </ScrollView>
312
312
  {showLinks && (
313
313
  <View style={styles.selectAllContainer}>
314
- {showSelectAllLink && (
315
- <Pressable onPress={handleSelectAll}>
316
- <Text color="zinc-500">{selectAllLabel}</Text>
317
- </Pressable>
318
- )}
319
- <Spacer horizontal size={16} />
320
- {showDeselectAllLink && (
321
- <Pressable onPress={handleDeselectAll}>
322
- <Text color="zinc-500">{deselectAllLabel}</Text>
323
- </Pressable>
324
- )}
314
+ {showSelectAllLink && <LinkButton title={selectAllLabel} onPress={handleSelectAll} />}
315
+ {showDeselectAllLink && <LinkButton title={deselectAllLabel} onPress={handleDeselectAll} />}
325
316
  </View>
326
317
  )}
327
318
  </View>
@@ -339,9 +330,10 @@ const styles = StyleSheet.create({
339
330
  selectAllContainer: {
340
331
  flexDirection: "row",
341
332
  alignItems: "center",
342
- justifyContent: "space-between",
343
- gap: 4,
344
- paddingVertical: 8,
333
+ gap: 16,
334
+ paddingVertical: 4,
335
+ // Matches a MenuButton option's 8px text inset, so the bleeding-wash
336
+ // LinkButtons line up under the option labels above.
345
337
  paddingHorizontal: 8,
346
338
  borderTopWidth: 1,
347
339
  borderTopColor: colors.border,
package/src/pie_chart.tsx CHANGED
@@ -35,6 +35,8 @@ export interface PieChartProps {
35
35
  showLegend?: boolean;
36
36
  formatNumber?: (n: number) => string;
37
37
  emptyLabel?: string;
38
+ /** Caption under the center total. Pass `null` to hide the center. */
39
+ centerLabel?: string | null;
38
40
  }
39
41
 
40
42
  export function PieChart(props: PieChartProps) {
@@ -44,6 +46,7 @@ export function PieChart(props: PieChartProps) {
44
46
  showLegend = true,
45
47
  formatNumber = defaultFormatNumber,
46
48
  emptyLabel = "No data",
49
+ centerLabel = "Total",
47
50
  } = props;
48
51
 
49
52
  const [isVerticalLayout, setIsVerticalLayout] = useState(false);
@@ -66,7 +69,7 @@ export function PieChart(props: PieChartProps) {
66
69
 
67
70
  const center = size / 2;
68
71
  const outerRadius = size / 2 - 4;
69
- const innerRadius = outerRadius * 0.6;
72
+ const innerRadius = outerRadius * 0.66;
70
73
  const drawn: { path: string; color: string }[] = [];
71
74
  let currentAngle = -Math.PI / 2;
72
75
 
@@ -113,14 +116,25 @@ export function PieChart(props: PieChartProps) {
113
116
  return (
114
117
  <View style={styles.chartContainer} onLayout={handleLayout}>
115
118
  <View style={[styles.chartWrapper, isVerticalLayout && styles.chartWrapperVertical]}>
116
- <View style={styles.pieContainer}>
119
+ <View style={[styles.pieContainer, { width: size, height: size }]}>
117
120
  <Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
118
121
  <G>
119
122
  {pieSlices.map((slice, index) => (
120
- <Path key={index} d={slice.path} fill={slice.color} />
123
+ // white seams separate the slices — segments, not a color wheel
124
+ <Path key={index} d={slice.path} fill={slice.color} stroke={colors.white} strokeWidth={2} />
121
125
  ))}
122
126
  </G>
123
127
  </Svg>
128
+ {centerLabel !== null ? (
129
+ <View style={styles.center} pointerEvents="none">
130
+ <Text size="lg" weight="semibold" tabular>
131
+ {formatNumber(total)}
132
+ </Text>
133
+ <Text size="xs" color="muted">
134
+ {centerLabel}
135
+ </Text>
136
+ </View>
137
+ ) : null}
124
138
  </View>
125
139
  {showLegend && (
126
140
  <View style={[styles.legend, isVerticalLayout && styles.legendVertical]}>
@@ -132,9 +146,9 @@ export function PieChart(props: PieChartProps) {
132
146
  <Text size="sm" numberOfLines={1} weight="medium" style={styles.legendLabel}>
133
147
  {item.label}
134
148
  </Text>
135
- <Text style={styles.legendValue}>{formatNumber(item.value)}</Text>
136
- <Text color="zinc-500" style={styles.legendPercentage}>
137
- ({percentage.toFixed(1)}%)
149
+ <Text size="sm" tabular style={styles.legendValue}>{formatNumber(item.value)}</Text>
150
+ <Text size="sm" color="zinc-500" tabular style={styles.legendPercentage}>
151
+ {percentage.toFixed(0)}%
138
152
  </Text>
139
153
  </View>
140
154
  );
@@ -163,6 +177,13 @@ const styles = StyleSheet.create({
163
177
  },
164
178
  pieContainer: {
165
179
  flexShrink: 0,
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ },
183
+ center: {
184
+ position: "absolute",
185
+ alignItems: "center",
186
+ gap: 0,
166
187
  },
167
188
  legend: {
168
189
  flex: 1,
@@ -187,10 +208,10 @@ const styles = StyleSheet.create({
187
208
  },
188
209
  legendValue: {
189
210
  minWidth: 40,
190
- textAlign: "left",
211
+ textAlign: "right",
191
212
  },
192
213
  legendPercentage: {
193
- minWidth: 48,
214
+ minWidth: 40,
194
215
  textAlign: "right",
195
216
  },
196
217
  });
@@ -1,7 +1,7 @@
1
1
  import { Ref } from "react";
2
- import { colors } from "./colors";
3
2
  import { IconButton } from "./icon_button";
4
3
  import { PressableHighlight } from "./pressable_highlight";
4
+ import { pillSurfaceStyle } from "./control_surface";
5
5
  import { StyleSheet, View } from "react-native";
6
6
 
7
7
  interface PillButtonProps {
@@ -19,11 +19,15 @@ export function PillButton(props: PillButtonProps) {
19
19
  return (
20
20
  <View ref={ref}>
21
21
  {onPress ? (
22
- <PressableHighlight testID={testID} onPress={onPress} style={[styles.pill, onDismiss && styles.pillWithDismiss]}>
22
+ <PressableHighlight
23
+ testID={testID}
24
+ onPress={onPress}
25
+ style={(state) => [pillSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
26
+ >
23
27
  {children}
24
28
  </PressableHighlight>
25
29
  ) : (
26
- <View style={[styles.pill, onDismiss && styles.pillWithDismiss]}>{children}</View>
30
+ <View style={[pillSurfaceStyle({}), styles.pillLayout, onDismiss && styles.pillWithDismiss]}>{children}</View>
27
31
  )}
28
32
  {onDismiss && (
29
33
  <View style={styles.dismissButton}>
@@ -41,15 +45,13 @@ export function PillButton(props: PillButtonProps) {
41
45
  }
42
46
 
43
47
  const styles = StyleSheet.create({
44
- pill: {
48
+ // Surface (height/border/radius/white + hover) comes from pillSurfaceStyle;
49
+ // PillButton owns only its row layout.
50
+ pillLayout: {
45
51
  flexDirection: "row",
46
52
  alignItems: "center",
47
53
  gap: 4,
48
- height: 40,
49
54
  paddingHorizontal: 12,
50
- borderRadius: 999,
51
- borderWidth: 1,
52
- borderColor: colors.border,
53
55
  },
54
56
  pillWithDismiss: {
55
57
  paddingRight: 38,
package/src/popover.tsx CHANGED
@@ -519,7 +519,7 @@ export function PopoverContent(props: PopoverContentProps) {
519
519
  tabIndex={small ? undefined : -1}
520
520
  style={{
521
521
  position: "fixed",
522
- padding: 8,
522
+ padding: 12,
523
523
  borderTopLeftRadius: 16,
524
524
  borderTopRightRadius: 16,
525
525
  borderBottomLeftRadius: small ? 0 : 16,
@@ -595,15 +595,25 @@ export function PopoverContent(props: PopoverContentProps) {
595
595
  export interface PopoverFooterProps {
596
596
  children: React.ReactNode;
597
597
  showDivider?: boolean;
598
+ /** Action Layout rules: the closure/commit action sits RIGHT (`end`,
599
+ * the default); utility-left + commit-right → `space-between`;
600
+ * informational/custom-width footers → `start`. */
601
+ align?: "start" | "end" | "space-between";
598
602
  }
599
603
 
600
604
  export function PopoverFooter(props: PopoverFooterProps) {
601
- const { children, showDivider = true } = props;
605
+ const { children, showDivider = true, align = "end" } = props;
606
+ const justifyContent =
607
+ align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
602
608
 
603
609
  return (
604
- <View>
610
+ // Pull out to the popover's edges (counteract its 12px inset) so the divider
611
+ // spans full width; the action row then re-insets to align with the body.
612
+ <View style={{ marginHorizontal: -12, marginTop: 12 }}>
605
613
  {showDivider && <Divider />}
606
- <View style={{ padding: 8 }}>{children}</View>
614
+ <View style={{ paddingHorizontal: 12, paddingTop: 12, flexDirection: "row", alignItems: "center", gap: 8, justifyContent }}>
615
+ {children}
616
+ </View>
607
617
  </View>
608
618
  );
609
619
  }
@@ -32,6 +32,14 @@ export interface PressableHighlightProps extends PressableProps {
32
32
  * explicitly because the base React Native `PressableProps` type omits it.
33
33
  */
34
34
  onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
35
+ /**
36
+ * Pass "none" on row/card surfaces: a pressable surface is a button, not a
37
+ * text-selection surface — drag jitter on selectable text starts a
38
+ * selection and can swallow the click. Exposed as a prop because RN types
39
+ * only carry `userSelect` on TextStyle, while react-native-web applies it
40
+ * to any element.
41
+ */
42
+ userSelect?: "auto" | "none";
35
43
  }
36
44
 
37
45
  /**
@@ -47,6 +55,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
47
55
  tooltip,
48
56
  tooltipSide = "top",
49
57
  onPress,
58
+ userSelect,
50
59
  ...restPressableProps
51
60
  } = props;
52
61
  const tooltipProps = useTooltip({
@@ -71,7 +80,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
71
80
  const hovered = (state as { hovered?: boolean }).hovered;
72
81
  return [
73
82
  {
74
- ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
83
+ ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
75
84
  },
76
85
  {
77
86
  backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
@@ -0,0 +1,91 @@
1
+ import { ReactNode, useState } from "react";
2
+ import { Pressable, StyleProp, StyleSheet, ViewStyle } from "react-native";
3
+ import { colors } from "./colors";
4
+
5
+ export interface PressableRowProps {
6
+ onPress: () => void;
7
+ /** The open/selected record — paints the persistent highlight. */
8
+ selected?: boolean;
9
+ /** Part of a multi-select set — paints a persistent blue tint, distinct from
10
+ * `selected` (the open record). The hover / open / press wash overrides it, so
11
+ * it's the resting state of a ticked row in a bulk-select register. */
12
+ marked?: boolean;
13
+ /**
14
+ * - "bleed" (default): the standalone REGISTER row — px-20, square wash to
15
+ * the card edges, separated by full-bleed `Divider`s. The one pattern for
16
+ * top-level record lists.
17
+ * - "inset": a rounded, contained row — the wash is a rounded pill pulled in
18
+ * from the edge (radius 8, marginH -8). Lives inside a padded container (an
19
+ * `Accordion`'s drill-down rows, or a gap-separated selectable list) so the
20
+ * wash insets from the card edge instead of bleeding to it. Use when rows are
21
+ * GAP-separated rather than `Divider`-separated.
22
+ */
23
+ variant?: "bleed" | "inset";
24
+ /** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
25
+ * claim their own presses; put the accessible door inside — a plain
26
+ * `Pressable` with role="button" + "Open …" label around the row body. */
27
+ children: ReactNode;
28
+ /** Layout only (gap, minHeight overrides). The surface owns its press
29
+ * states — never pass backgroundColor for hover/selected. */
30
+ style?: StyleProp<ViewStyle>;
31
+ }
32
+
33
+ /**
34
+ * THE pressable-row surface for record lists: a role-less, non-focusable press
35
+ * target whose hover wash spans the ENTIRE row — including over nested controls
36
+ * (CTAs, ⋯, checkboxes) — because it tracks hover via DOM mouseenter/mouseleave
37
+ * rather than Pressability hover (react-native-web releases a parent
38
+ * pressable's hover to the innermost nested pressable, which would stop the
39
+ * wash short of trailing controls, and is why this can't be hand-rolled from
40
+ * `PressableHighlight`). The surface carries no button role (a button must not
41
+ * contain interactive descendants); the row body inside is the accessible door.
42
+ * `variant` picks bleed (registers) or inset (Accordion drill-down rows).
43
+ */
44
+ export function PressableRow(props: PressableRowProps) {
45
+ const { onPress, selected = false, marked = false, variant = "bleed", children, style } = props;
46
+ const [hovered, setHovered] = useState(false);
47
+
48
+ // RN's types don't declare mouse handlers; react-native-web forwards them
49
+ // to the DOM element (same boundary cast PressableHighlight uses).
50
+ const mouseProps = {
51
+ onMouseEnter: () => setHovered(true),
52
+ onMouseLeave: () => setHovered(false),
53
+ } as object;
54
+
55
+ return (
56
+ <Pressable
57
+ onPress={onPress}
58
+ focusable={false}
59
+ {...mouseProps}
60
+ style={({ pressed }) => [
61
+ styles.row,
62
+ variant === "inset" ? styles.inset : styles.bleed,
63
+ {
64
+ backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
65
+ },
66
+ style,
67
+ ]}
68
+ >
69
+ {children}
70
+ </Pressable>
71
+ );
72
+ }
73
+
74
+ const styles = StyleSheet.create({
75
+ row: {
76
+ flexDirection: "row",
77
+ alignItems: "center",
78
+ gap: 12,
79
+ ...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
80
+ },
81
+ // Square, full-bleed — lines up with the Dividers between register rows.
82
+ bleed: {
83
+ paddingHorizontal: 20,
84
+ },
85
+ // Rounded, pulled in from the edge — matches the Accordion's inset rows.
86
+ inset: {
87
+ borderRadius: 8,
88
+ paddingHorizontal: 8,
89
+ marginHorizontal: -8,
90
+ },
91
+ });