panelui-native 0.40.0 → 0.43.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 (92) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/card/index.js +172 -3
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/color-picker/index.js +1395 -0
  5. package/lib/module/components/color-picker/index.js.map +1 -0
  6. package/lib/module/components/combobox/index.js +87 -19
  7. package/lib/module/components/combobox/index.js.map +1 -1
  8. package/lib/module/components/drawer/index.js +13 -4
  9. package/lib/module/components/drawer/index.js.map +1 -1
  10. package/lib/module/components/kpi-chart/index.js +648 -0
  11. package/lib/module/components/kpi-chart/index.js.map +1 -0
  12. package/lib/module/components/map/index.js +72 -12
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +40 -10
  15. package/lib/module/components/menu/index.js.map +1 -1
  16. package/lib/module/components/pagination/index.js +19 -9
  17. package/lib/module/components/pagination/index.js.map +1 -1
  18. package/lib/module/components/panelside/index.js +68 -18
  19. package/lib/module/components/panelside/index.js.map +1 -1
  20. package/lib/module/components/radar-chart/index.js +704 -0
  21. package/lib/module/components/radar-chart/index.js.map +1 -0
  22. package/lib/module/components/section-rail/index.js +32 -7
  23. package/lib/module/components/section-rail/index.js.map +1 -1
  24. package/lib/module/components/slider/index.js +240 -39
  25. package/lib/module/components/slider/index.js.map +1 -1
  26. package/lib/module/components/tabs/index.js +194 -10
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/text-animation/index.js +857 -0
  29. package/lib/module/components/text-animation/index.js.map +1 -0
  30. package/lib/module/icons/index.js +25 -7
  31. package/lib/module/icons/index.js.map +1 -1
  32. package/lib/module/index.js +5 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/theme/use-theme.js +1 -1
  35. package/lib/module/utils/chart.js +44 -0
  36. package/lib/module/utils/chart.js.map +1 -1
  37. package/lib/module/utils/color.js +268 -0
  38. package/lib/module/utils/color.js.map +1 -0
  39. package/lib/typescript/src/components/card/index.d.ts +20 -0
  40. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  42. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  43. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/drawer/index.d.ts +13 -1
  45. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  47. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/map/index.d.ts +37 -2
  49. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/panelside/index.d.ts +50 -4
  53. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  55. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  57. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
  63. package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
  64. package/lib/typescript/src/icons/index.d.ts +6 -0
  65. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  66. package/lib/typescript/src/index.d.ts +8 -3
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/utils/chart.d.ts +21 -0
  69. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/color.d.ts +74 -0
  71. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  72. package/package.json +2 -1
  73. package/src/components/card/index.tsx +227 -2
  74. package/src/components/color-picker/index.tsx +1618 -0
  75. package/src/components/combobox/index.tsx +89 -19
  76. package/src/components/drawer/index.tsx +31 -9
  77. package/src/components/kpi-chart/index.tsx +719 -0
  78. package/src/components/map/index.tsx +158 -14
  79. package/src/components/menu/index.tsx +51 -6
  80. package/src/components/pagination/index.tsx +18 -6
  81. package/src/components/panelside/index.tsx +134 -19
  82. package/src/components/radar-chart/index.tsx +910 -0
  83. package/src/components/section-rail/index.tsx +42 -8
  84. package/src/components/slider/index.tsx +283 -41
  85. package/src/components/tabs/index.tsx +263 -8
  86. package/src/components/text-animation/index.tsx +1093 -0
  87. package/src/icons/index.tsx +23 -4
  88. package/src/index.ts +73 -1
  89. package/src/theme/use-theme.ts +1 -1
  90. package/src/utils/chart.ts +52 -0
  91. package/src/utils/color.ts +246 -0
  92. package/theme.css +125 -112
@@ -128,6 +128,43 @@ function useToken(name: string, fallback: string): string {
128
128
  return typeof value === 'string' && value.length > 0 ? value : fallback;
129
129
  }
130
130
 
131
+ /** True for a theme token name, as opposed to a literal colour. */
132
+ function isToken(value: string | undefined): value is string {
133
+ return typeof value === 'string' && value.startsWith('--');
134
+ }
135
+
136
+ /**
137
+ * The same colour at a given alpha, for a ramp built from one hue.
138
+ *
139
+ * Style expressions are handed to the renderer as strings, so this produces
140
+ * `rgba()` rather than reaching for a colour type the layer would not accept.
141
+ */
142
+ function withAlpha(color: string, alpha: number): string {
143
+ if (color.startsWith('#')) {
144
+ const hex = color.slice(1);
145
+ const full =
146
+ hex.length === 3
147
+ ? hex
148
+ .split('')
149
+ .map((c) => c + c)
150
+ .join('')
151
+ : hex.slice(0, 6);
152
+ const r = parseInt(full.slice(0, 2), 16);
153
+ const g = parseInt(full.slice(2, 4), 16);
154
+ const b = parseInt(full.slice(4, 6), 16);
155
+ if (Number.isNaN(r + g + b)) return color;
156
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
157
+ }
158
+
159
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
160
+ if (channels) {
161
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
162
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
163
+ }
164
+
165
+ return color;
166
+ }
167
+
131
168
  interface MapContextValue {
132
169
  mapRef: React.RefObject<MapRef | null>;
133
170
  cameraRef: React.RefObject<CameraRef | null>;
@@ -1025,6 +1062,28 @@ export interface MapHeatmapProps {
1025
1062
  data: unknown;
1026
1063
  /** Feature property to weight each point by. Unweighted when omitted. */
1027
1064
  weight?: string;
1065
+ /**
1066
+ * Base colour of the field — a theme token by name, or a literal. The ramp
1067
+ * is this colour at rising opacity, so density reads as *more of the same
1068
+ * thing* rather than as a change of subject.
1069
+ *
1070
+ * Defaults to `--color-chart-2`, which is a saturated accent in every theme.
1071
+ * It is deliberately not `--color-chart-1`: that is the series colour a chart
1072
+ * is about, and every theme starts it on something close to the foreground —
1073
+ * near-black in a light theme, near-white in a dark one — which over a
1074
+ * basemap is a smudge rather than a measurement.
1075
+ */
1076
+ color?: string;
1077
+ /**
1078
+ * Replace the derived ramp outright, coolest first. For the conventional
1079
+ * heat ramp, where the hue carries the reading as well as the opacity —
1080
+ * worth it when the field sits over varied terrain and one hue at five
1081
+ * opacities stops being separable from what is underneath it.
1082
+ *
1083
+ * The first stop is drawn at the lowest density, the last at the highest.
1084
+ * Density zero stays fully transparent either way.
1085
+ */
1086
+ colors?: string[];
1028
1087
  /**
1029
1088
  * Spread of a single point, in points, at street zoom. Larger blurs more.
1030
1089
  * The drawn radius shrinks as the map zooms out, so a point keeps covering
@@ -1037,6 +1096,12 @@ export interface MapHeatmapProps {
1037
1096
  opacity?: number;
1038
1097
  /** Above this zoom the layer fades out — see the note on the component. */
1039
1098
  maxZoom?: number;
1099
+ /**
1100
+ * Draw the points themselves as the field fades out, coloured from the same
1101
+ * ramp by weight. Without them, zooming past `maxZoom` leaves an empty map:
1102
+ * the layer gets out of the way, and nothing takes its place.
1103
+ */
1104
+ points?: boolean;
1040
1105
  id?: string;
1041
1106
  }
1042
1107
 
@@ -1050,30 +1115,63 @@ export interface MapHeatmapProps {
1050
1115
  *
1051
1116
  * It fades out past `maxZoom` on purpose. Zoomed far enough in, every point is
1052
1117
  * its own island and the blur says less than the points would — so the layer
1053
- * gets out of the way rather than smearing five records across a street.
1118
+ * gets out of the way rather than smearing five records across a street. Set
1119
+ * `points` and the records themselves fade in as it goes, which is the whole
1120
+ * handover: a field while the question is where, marks once it is which.
1054
1121
  *
1055
1122
  * The radius is tied to zoom for the mirror-image reason. Left as a fixed
1056
1123
  * number of screen points it would mean a different distance at every zoom:
1057
1124
  * a blur that reads as a city at street level covers half a continent once
1058
1125
  * the map is pulled out, and a field measured over land ends up sitting in
1059
1126
  * the sea. Scaling it with the projection keeps the claim the same one.
1127
+ *
1128
+ * Intensity is tied to zoom too, and in the other direction. The same points
1129
+ * are packed into fewer pixels the further out the map goes, so a constant
1130
+ * intensity saturates the whole field at world zoom and shows nothing but its
1131
+ * own ceiling.
1060
1132
  */
1061
1133
  function MapHeatmap({
1062
1134
  data,
1063
1135
  weight,
1136
+ color,
1137
+ colors,
1064
1138
  radius = 24,
1065
1139
  intensity = 1,
1066
1140
  opacity = 0.85,
1067
1141
  maxZoom = 15,
1142
+ points = false,
1068
1143
  id = 'heatmap',
1069
1144
  }: MapHeatmapProps) {
1070
- const cool = useToken('--color-muted', 'rgba(0,0,0,0.06)');
1071
- const warm = useToken('--color-chart-1', '#262626');
1072
- const hot = useToken('--color-chart-2', '#666666');
1145
+ const base = useToken(isToken(color) ? color : '--color-chart-2', '#3b82f6');
1146
+ const resolved = (isToken(color) ? base : color) ?? base;
1147
+
1148
+ /*
1149
+ * One colour at rising opacity rather than five colours, which is how every
1150
+ * other ramp in the library is built: a sequential scale is one quantity
1151
+ * getting larger, and five hues say five categories. `colors` is there for
1152
+ * the case where that is not enough.
1153
+ */
1154
+ const ramp = colors?.length
1155
+ ? colors
1156
+ : [
1157
+ withAlpha(resolved, 0.25),
1158
+ withAlpha(resolved, 0.45),
1159
+ withAlpha(resolved, 0.7),
1160
+ withAlpha(resolved, 0.9),
1161
+ resolved,
1162
+ ];
1073
1163
 
1074
1164
  if (!MapLibre) return null;
1075
1165
  const { GeoJSONSource, Layer } = MapLibre;
1076
1166
 
1167
+ // Density zero is transparent rather than the coolest colour, so the map
1168
+ // shows through everywhere nothing was measured instead of the whole
1169
+ // viewport being tinted by the absence of data.
1170
+ const stops = ramp.flatMap((entry, index) => [
1171
+ 0.15 + (0.85 * index) / Math.max(1, ramp.length - 1),
1172
+ entry,
1173
+ ]);
1174
+
1077
1175
  return (
1078
1176
  <GeoJSONSource id={`${id}-source`} data={data}>
1079
1177
  <Layer
@@ -1084,7 +1182,15 @@ function MapHeatmap({
1084
1182
  heatmapWeight: weight
1085
1183
  ? ['interpolate', ['linear'], ['get', weight], 0, 0, 1, 1]
1086
1184
  : 1,
1087
- heatmapIntensity: intensity,
1185
+ heatmapIntensity: [
1186
+ 'interpolate',
1187
+ ['linear'],
1188
+ ['zoom'],
1189
+ 0,
1190
+ intensity * 0.6,
1191
+ maxZoom,
1192
+ intensity * 1.2,
1193
+ ],
1088
1194
  // `radius` is the spread at street zoom; below that it is scaled
1089
1195
  // down so it keeps covering the same ground rather than the same
1090
1196
  // number of pixels. The stops are the projection: each zoom level
@@ -1101,21 +1207,13 @@ function MapHeatmap({
1101
1207
  14,
1102
1208
  radius,
1103
1209
  ],
1104
- // Transparent at zero rather than the coolest colour, so the map
1105
- // shows through everywhere nothing was measured instead of the whole
1106
- // viewport being tinted by the absence of data.
1107
1210
  heatmapColor: [
1108
1211
  'interpolate',
1109
1212
  ['linear'],
1110
1213
  ['heatmap-density'],
1111
1214
  0,
1112
1215
  'rgba(0,0,0,0)',
1113
- 0.2,
1114
- cool,
1115
- 0.6,
1116
- warm,
1117
- 1,
1118
- hot,
1216
+ ...stops,
1119
1217
  ],
1120
1218
  heatmapOpacity: [
1121
1219
  'interpolate',
@@ -1128,6 +1226,52 @@ function MapHeatmap({
1128
1226
  ],
1129
1227
  }}
1130
1228
  />
1229
+
1230
+ {/* The handover, in the two zoom levels the field spends fading: the
1231
+ marks arrive as it leaves, so there is never a zoom with neither. */}
1232
+ {points ? (
1233
+ <Layer
1234
+ id={`${id}-points`}
1235
+ type="circle"
1236
+ minzoom={maxZoom - 2}
1237
+ style={{
1238
+ circleRadius: weight
1239
+ ? ['interpolate', ['linear'], ['get', weight], 0, 3, 1, 9]
1240
+ : 5,
1241
+ circleColor: weight
1242
+ ? [
1243
+ 'interpolate',
1244
+ ['linear'],
1245
+ ['get', weight],
1246
+ 0,
1247
+ ramp[0] as string,
1248
+ 1,
1249
+ ramp[ramp.length - 1] as string,
1250
+ ]
1251
+ : (ramp[ramp.length - 1] as string),
1252
+ circleStrokeWidth: 1,
1253
+ circleStrokeColor: 'rgba(255,255,255,0.75)',
1254
+ circleOpacity: [
1255
+ 'interpolate',
1256
+ ['linear'],
1257
+ ['zoom'],
1258
+ maxZoom - 2,
1259
+ 0,
1260
+ maxZoom,
1261
+ 0.85,
1262
+ ],
1263
+ circleStrokeOpacity: [
1264
+ 'interpolate',
1265
+ ['linear'],
1266
+ ['zoom'],
1267
+ maxZoom - 2,
1268
+ 0,
1269
+ maxZoom,
1270
+ 0.6,
1271
+ ],
1272
+ }}
1273
+ />
1274
+ ) : null}
1131
1275
  </GeoJSONSource>
1132
1276
  );
1133
1277
  }
@@ -57,9 +57,9 @@ import Animated, {
57
57
  } from 'react-native-reanimated';
58
58
  import { tv } from 'tailwind-variants';
59
59
  import { useCSSVariable } from 'uniwind';
60
- import { CheckIcon, ChevronRightIcon } from '../../icons';
60
+ import { Check, ChevronLeft, ChevronRight, Circle } from 'lucide-react-native';
61
61
  import { Text, textChildren } from '../../primitives/text';
62
- import { useDirectionSign } from '../../hooks/use-direction';
62
+ import { useDirection, useDirectionSign } from '../../hooks/use-direction';
63
63
  import { selectionTick } from '../../utils/haptics';
64
64
  import { cn } from '../../utils/cn';
65
65
  import { Popover, type PopoverContentProps, type PopoverProps } from '../popover';
@@ -79,6 +79,19 @@ function useTint(variable: string): string | undefined {
79
79
  return typeof raw === 'string' ? raw : undefined;
80
80
  }
81
81
 
82
+ /**
83
+ * The colour an indicator falls back to before the stylesheet has been read.
84
+ *
85
+ * The glyphs here are drawn by a general-purpose icon set rather than by this
86
+ * library's own, and that set defaults an unset colour to `currentColor` —
87
+ * which React Native cannot resolve and refuses to paint. A neutral mid grey
88
+ * is legible on either a light or a dark panel for the frame or two it lasts.
89
+ */
90
+ const INDICATOR_FALLBACK = '#737373';
91
+
92
+ /** Icon stroke, matched to the weight this library's own glyphs are drawn at. */
93
+ const INDICATOR_STROKE = 2;
94
+
82
95
  /** How long a row takes to light up under a finger, and to let go again. */
83
96
  const PRESS_IN_DURATION = 90;
84
97
  const PRESS_OUT_DURATION = 160;
@@ -544,7 +557,17 @@ function MenuCheckboxItem({
544
557
  // An empty column rather than no column: the mark comes and goes as the
545
558
  // row is toggled, and a row whose label steps sideways each time reads
546
559
  // as a different row rather than the same one in a new state.
547
- icon={checked ? <CheckIcon size={16} color={tint} /> : <MenuIndicatorSpacer />}
560
+ icon={
561
+ checked ? (
562
+ <Check
563
+ size={16}
564
+ strokeWidth={INDICATOR_STROKE}
565
+ color={tint ?? INDICATOR_FALLBACK}
566
+ />
567
+ ) : (
568
+ <MenuIndicatorSpacer />
569
+ )
570
+ }
548
571
  onSelect={() => {
549
572
  onCheckedChange?.(!checked);
550
573
  onSelect?.();
@@ -616,9 +639,22 @@ function MenuRadioItem({
616
639
  icon={
617
640
  selected ? (
618
641
  indicator === 'dot' ? (
619
- <View className="h-2 w-2 rounded-full bg-popover-foreground" />
642
+ // A filled circle, not a bare View: the dot is an indicator like
643
+ // the tick beside it, and drawing one of the two as a rounded box
644
+ // is what makes them optically different sizes at the same nominal
645
+ // one.
646
+ <Circle
647
+ size={8}
648
+ strokeWidth={INDICATOR_STROKE}
649
+ color={tint ?? INDICATOR_FALLBACK}
650
+ fill={tint ?? INDICATOR_FALLBACK}
651
+ />
620
652
  ) : (
621
- <CheckIcon size={16} color={tint} />
653
+ <Check
654
+ size={16}
655
+ strokeWidth={INDICATOR_STROKE}
656
+ color={tint ?? INDICATOR_FALLBACK}
657
+ />
622
658
  )
623
659
  ) : (
624
660
  // Held open, so the options stay in one column as the choice moves
@@ -690,8 +726,13 @@ export interface MenuSubTriggerProps
690
726
  function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
691
727
  const { open, toggle } = useMenuSub('Menu.SubTrigger');
692
728
  const sign = useDirectionSign();
729
+ const direction = useDirection();
693
730
  const reducedMotion = useReducedMotion();
694
731
  const chevronTint = useTint('--color-muted-foreground');
732
+ // The glyph mirrors by being the other glyph. A `scaleX` flip would work on
733
+ // a chevron, but it also flips the rotation below with it — and this one is
734
+ // already being rotated.
735
+ const Chevron = direction === 'rtl' ? ChevronLeft : ChevronRight;
695
736
  const progress = useSharedValue(open ? 1 : 0);
696
737
 
697
738
  useEffect(() => {
@@ -717,7 +758,11 @@ function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuS
717
758
  className={className}
718
759
  trailing={
719
760
  <Animated.View style={chevronStyle}>
720
- <ChevronRightIcon size={16} color={chevronTint} />
761
+ <Chevron
762
+ size={16}
763
+ strokeWidth={INDICATOR_STROKE}
764
+ color={chevronTint ?? INDICATOR_FALLBACK}
765
+ />
721
766
  </Animated.View>
722
767
  }
723
768
  onSelect={() => {
@@ -65,9 +65,10 @@ export type PaginationSize = 'sm' | 'default';
65
65
  /**
66
66
  * What the small size gives back as padding. 44pt is the smallest target a
67
67
  * finger reliably hits, and the row is allowed to get tighter than that only
68
- * because the reach is restored outside the paint.
68
+ * because the reach is restored outside the paint — so this is exactly the
69
+ * difference between the small target's 32pt and that 44.
69
70
  */
70
- const SMALL_HIT_SLOP = { top: 4, bottom: 4, left: 4, right: 4 } as const;
71
+ const SMALL_HIT_SLOP = { top: 6, bottom: 6, left: 6, right: 6 } as const;
71
72
 
72
73
  const paginationVariants = tv({
73
74
  slots: {
@@ -91,9 +92,9 @@ const paginationVariants = tv({
91
92
  },
92
93
  sm: {
93
94
  list: 'gap-0.5',
94
- item: 'h-9 min-w-9 px-1',
95
+ item: 'h-8 min-w-8 px-1',
95
96
  itemLabel: 'text-xs',
96
- ellipsis: 'h-9 w-9',
97
+ ellipsis: 'h-8 w-8',
97
98
  status: 'text-xs',
98
99
  summary: 'text-xs',
99
100
  },
@@ -304,7 +305,18 @@ const PaginationRoot = forwardRef<View, PaginationProps>(
304
305
  // With nothing on the leading edge the controls are the whole row
305
306
  // and centre in it; with something there they part to the two ends,
306
307
  // which is the shape a table footer wants.
307
- className: cn(children ? 'justify-between gap-3' : 'justify-center', className),
308
+ //
309
+ // Clipped, because the run is a fixed number of targets and a
310
+ // narrow enough container cannot hold it. Overflowing, a centred
311
+ // row hangs the same distance past both edges, and the leading
312
+ // number ends up half off the screen with nothing to say why.
313
+ // Inside its own bounds the shortfall at least looks deliberate,
314
+ // and `siblings`/`boundaries` are the knobs that fix it.
315
+ className: cn(
316
+ 'overflow-hidden',
317
+ children ? 'justify-between gap-3' : 'justify-center',
318
+ className
319
+ ),
308
320
  })}
309
321
  {...props}
310
322
  >
@@ -312,7 +324,7 @@ const PaginationRoot = forwardRef<View, PaginationProps>(
312
324
  <PaginationStatus>{text}</PaginationStatus>
313
325
  ))}
314
326
 
315
- <View className={list()}>
327
+ <View className={list({ className: 'max-w-full' })}>
316
328
  {controls ? <PaginationPrevious label={variant === 'simple'} /> : null}
317
329
 
318
330
  {variant === 'numbers'