panelui-native 0.30.0 → 0.32.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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. package/src/utils/date.ts +146 -22
@@ -39,11 +39,12 @@ import {
39
39
  createContext,
40
40
  useCallback,
41
41
  useContext,
42
+ useEffect,
42
43
  useMemo,
43
44
  useRef,
44
45
  useState,
45
46
  } from 'react';
46
- import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
47
+ import { AppState, Pressable, ScrollView, View, type ViewProps } from 'react-native';
47
48
  import { tv } from 'tailwind-variants';
48
49
  import { useCSSVariable } from 'uniwind';
49
50
  import {
@@ -57,17 +58,20 @@ import { cn } from '../../utils/cn';
57
58
  import { Popover } from '../popover';
58
59
  import {
59
60
  addCalendarMonths,
60
- addDays,
61
61
  calendarDayNumber,
62
62
  calendarLongDate,
63
63
  calendarMonthLabel,
64
64
  calendarMonthNames,
65
65
  calendarParts,
66
+ clampDate,
66
67
  isAfter,
67
68
  isBefore,
68
69
  isSameCalendarMonth,
69
70
  isSameDay,
70
71
  isWithin,
72
+ localeWeekStart,
73
+ monthGrid,
74
+ normalizeWeekStart,
71
75
  resolveCalendar,
72
76
  startOfCalendarMonth,
73
77
  startOfDay,
@@ -184,6 +188,35 @@ function useCalendar(component: string): CalendarContextValue {
184
188
  return context;
185
189
  }
186
190
 
191
+ /**
192
+ * Today, kept current.
193
+ *
194
+ * "Today" is not a constant, and a calendar is exactly the kind of thing left
195
+ * on screen across the boundary that changes it — a picker open in a sheet, an
196
+ * app resumed the next morning. Recomputed when the app comes back to the
197
+ * foreground, which is when a stale answer would otherwise become visible, and
198
+ * held as state so the marker actually moves when it does.
199
+ */
200
+ function useToday(): Date {
201
+ const [today, setToday] = useState(() => startOfDay(new Date()));
202
+
203
+ useEffect(() => {
204
+ const refresh = () => {
205
+ const now = startOfDay(new Date());
206
+ // A new object every resume would invalidate every memo below it for a
207
+ // date that has not changed.
208
+ setToday((current) => (current.getTime() === now.getTime() ? current : now));
209
+ };
210
+
211
+ const subscription = AppState.addEventListener('change', (state) => {
212
+ if (state === 'active') refresh();
213
+ });
214
+ return () => subscription.remove();
215
+ }, []);
216
+
217
+ return today;
218
+ }
219
+
187
220
  /** Whether a rule, a list or a span rules this day out. */
188
221
  function isDisabled(
189
222
  date: Date,
@@ -245,8 +278,17 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
245
278
  startMonth?: Date;
246
279
  /** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
247
280
  endMonth?: Date;
248
- /** `0` is Sunday. */
249
- weekStartsOn?: number;
281
+ /**
282
+ * Which day the week's first column is. `0` is Sunday.
283
+ *
284
+ * `auto` asks the locale, which is what most of the world wants — `en-GB` and
285
+ * `fr-FR` start on Monday, and a calendar that says otherwise is wrong in a
286
+ * way its reader notices immediately. It is not the default only because
287
+ * changing an existing calendar's columns out from under it is not something
288
+ * a version bump should do. Out-of-range numbers wrap rather than producing
289
+ * a blank column.
290
+ */
291
+ weekStartsOn?: number | 'auto';
250
292
  /** Draw the neighbouring months' days rather than leaving the cells blank. */
251
293
  showOutsideDays?: boolean;
252
294
  /**
@@ -291,7 +333,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
291
333
  maxDate,
292
334
  startMonth,
293
335
  endMonth,
294
- weekStartsOn = 0,
336
+ weekStartsOn: weekStartsOnProp = 0,
295
337
  showOutsideDays = true,
296
338
  selectOutsideDays = false,
297
339
  bordered = true,
@@ -302,6 +344,16 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
302
344
  // Settled once here rather than at each call site, so every part of the
303
345
  // grid is certainly reading the same calendar.
304
346
  const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
347
+ // Settled once too, and normalised: everything below indexes an array of
348
+ // seven with it, where a negative or out-of-range value is a blank column
349
+ // rather than an error anyone would trace back to here.
350
+ const weekStartsOn = useMemo(
351
+ () =>
352
+ weekStartsOnProp === 'auto'
353
+ ? localeWeekStart(locale)
354
+ : normalizeWeekStart(weekStartsOnProp),
355
+ [weekStartsOnProp, locale]
356
+ );
305
357
  const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
306
358
  defaultSelected
307
359
  );
@@ -328,15 +380,52 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
328
380
  }, []);
329
381
 
330
382
  const [internalMonth, setInternalMonth] = useState(initialMonth);
331
- const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
383
+ /*
384
+ * Memoised on the *instant*, not on the prop. A controlled caller almost
385
+ * always passes a fresh `new Date(…)`, and deriving a new month object from
386
+ * it on every render invalidates the grid memo and the context memo below —
387
+ * so a calendar that has not moved rebuilds forty-two cells per keystroke
388
+ * somewhere else on the screen.
389
+ */
390
+ const controlledMonth = useMemo(
391
+ () => (monthProp ? startOfCalendarMonth(monthProp, system, locale) : undefined),
392
+ // eslint-disable-next-line react-hooks/exhaustive-deps
393
+ [monthProp?.getTime(), system, locale]
394
+ );
395
+ const month = controlledMonth ?? internalMonth;
396
+
397
+ /*
398
+ * How far the calendar may be paged, as the first day of the outermost month
399
+ * either way. `startMonth`/`endMonth` win where they are given, because they
400
+ * are the props about *navigation*; `minDate`/`maxDate` are about which days
401
+ * can be picked, and stand in when the navigation bounds are not stated.
402
+ */
403
+ const monthBounds = useMemo(() => {
404
+ const lower = startMonth ?? minDate;
405
+ const upper = endMonth ?? maxDate;
406
+ return {
407
+ from: lower ? startOfCalendarMonth(lower, system, locale) : undefined,
408
+ to: upper ? startOfCalendarMonth(upper, system, locale) : undefined,
409
+ };
410
+ }, [startMonth, minDate, endMonth, maxDate, system, locale]);
332
411
 
333
412
  const setMonth = useCallback(
334
413
  (next: Date) => {
335
- const settled = startOfCalendarMonth(next, system, locale);
414
+ /*
415
+ * Clamped here rather than only at the arrows. The arrows guard
416
+ * themselves, but the caption's dropdowns and a controlled caller both
417
+ * reach this by other routes — and a month list that offers a year is a
418
+ * poor place to discover that the year was out of bounds.
419
+ */
420
+ const settled = clampDate(
421
+ startOfCalendarMonth(next, system, locale),
422
+ monthBounds.from,
423
+ monthBounds.to
424
+ );
336
425
  if (!monthProp) setInternalMonth(settled);
337
426
  onMonthChange?.(settled);
338
427
  },
339
- [monthProp, onMonthChange, system, locale]
428
+ [monthProp, onMonthChange, system, locale, monthBounds]
340
429
  );
341
430
 
342
431
  const commit = useCallback(
@@ -351,6 +440,14 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
351
440
  (date: Date) => {
352
441
  const day = startOfDay(date);
353
442
 
443
+ /*
444
+ * A tap on a neighbouring month's cell — which only happens with
445
+ * `selectOutsideDays` — commits a date the grid is not showing. Page to
446
+ * it, or the selection lands in a greyed-out cell of the month you are
447
+ * still looking at and reads as having missed.
448
+ */
449
+ if (!isSameCalendarMonth(day, month, system, locale)) setMonth(day);
450
+
354
451
  if (mode === 'multiple') {
355
452
  const current = (value as Date[] | undefined) ?? [];
356
453
  const without = current.filter((picked) => !isSameDay(picked, day));
@@ -379,7 +476,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
379
476
  const current = value as Date | undefined;
380
477
  commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
381
478
  },
382
- [mode, value, commit]
479
+ [mode, value, commit, month, system, locale, setMonth]
383
480
  );
384
481
 
385
482
  const context = useMemo(
@@ -563,10 +660,30 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
563
660
  */
564
661
  const first = startMonth ?? minDate;
565
662
  const last = endMonth ?? maxDate;
663
+ const firstParts = first ? calendarParts(first, system, locale) : null;
664
+ const lastParts = last ? calendarParts(last, system, locale) : null;
566
665
  const bounds = {
567
- from: first ? calendarParts(first, system, locale).year : current.year - 100,
568
- to: last ? calendarParts(last, system, locale).year : current.year + 10,
666
+ from: firstParts ? firstParts.year : current.year - 100,
667
+ to: lastParts ? lastParts.year : current.year + 10,
569
668
  };
669
+ /*
670
+ * The month list is bounded too, and only the year list used to be — so a
671
+ * calendar starting in March 1925 still offered January and February of 1925,
672
+ * and picking one navigated somewhere the calendar had said it would not go.
673
+ * Only the outermost year of each bound loses months; every year between them
674
+ * has all twelve.
675
+ */
676
+ const monthOptions = months
677
+ .map((label, index) => ({ label, month: index + 1 }))
678
+ .filter(({ month }) => {
679
+ if (firstParts && current.year === firstParts.year && month < firstParts.month) {
680
+ return false;
681
+ }
682
+ if (lastParts && current.year === lastParts.year && month > lastParts.month) {
683
+ return false;
684
+ }
685
+ return true;
686
+ });
570
687
  /*
571
688
  * Oldest first, the way a year runs. The list is long — a century by default
572
689
  * — so it is opened scrolled to the year already in the caption rather than
@@ -594,10 +711,10 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
594
711
  <CaptionDropdown
595
712
  label={months[current.month - 1] ?? String(current.month)}
596
713
  accessibilityLabel="Month"
597
- options={months}
598
- active={current.month - 1}
714
+ options={monthOptions.map((option) => option.label)}
715
+ active={monthOptions.findIndex((option) => option.month === current.month)}
599
716
  width={MONTH_LIST_WIDTH}
600
- onSelect={(index) => choose(index - (current.month - 1))}
717
+ onSelect={(index) => choose((monthOptions[index]?.month ?? current.month) - current.month)}
601
718
  />
602
719
  <CaptionDropdown
603
720
  label={String(current.year)}
@@ -787,16 +904,12 @@ function CalendarGrid({
787
904
  * boundaries and the day numbers differ — so the walk itself is the same
788
905
  * either way and only where it starts moves.
789
906
  */
790
- const weeks = useMemo(() => {
791
- const first = startOfCalendarMonth(month, system, locale);
792
- const lead = (first.getDay() - weekStartsOn + 7) % 7;
793
- const start = addDays(first, -lead);
794
- return Array.from({ length: 6 }, (_unusedWeek, week) =>
795
- Array.from({ length: 7 }, (_unusedDay, day) => addDays(start, week * 7 + day))
796
- );
797
- }, [month, weekStartsOn, system, locale]);
907
+ const weeks = useMemo(
908
+ () => monthGrid(month, weekStartsOn, system, locale),
909
+ [month, weekStartsOn, system, locale]
910
+ );
798
911
  const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
799
- const today = startOfDay(new Date());
912
+ const today = useToday();
800
913
 
801
914
  const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
802
915
  const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];
@@ -60,6 +60,7 @@ import Svg, { ClipPath, Defs, G, Line as SvgLine, Rect } from 'react-native-svg'
60
60
  import { useCSSVariable } from 'uniwind';
61
61
  import { Text } from '../../primitives/text';
62
62
  import { cn } from '../../utils/cn';
63
+ import { normalizeWeekStart, startOfDay } from '../../utils/date';
63
64
 
64
65
  const AnimatedRect = Animated.createAnimatedComponent(Rect);
65
66
 
@@ -225,12 +226,15 @@ export function buildHeatmapCalendar(
225
226
  entries: { date: Date; count: number }[],
226
227
  options: { start?: Date; end?: Date; weekStartDay?: number } = {}
227
228
  ): HeatmapColumn[] {
228
- const weekStartDay = options.weekStartDay ?? 0;
229
+ const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);
229
230
  if (!entries.length && !options.start) return [];
230
231
 
231
232
  const times = entries.map((entry) => entry.date.getTime());
232
- const start = options.start ?? new Date(Math.min(...times));
233
- const end = options.end ?? new Date(Math.max(...times));
233
+ // Both bounds at local midnight, and both the same way. Comparing a
234
+ // normalised lower bound against a raw upper one drops the last day whenever
235
+ // the caller's `end` carries a time earlier than the cell being tested.
236
+ const start = startOfDay(options.start ?? new Date(Math.min(...times)));
237
+ const end = startOfDay(options.end ?? new Date(Math.max(...times)));
234
238
 
235
239
  const byDay = new Map<string, number>();
236
240
  for (const entry of entries) {
@@ -240,7 +244,7 @@ export function buildHeatmapCalendar(
240
244
 
241
245
  // Back up to the first day of the week the range starts in, so column 0 is a
242
246
  // whole week and every row lines up with a weekday for the rest of the chart.
243
- const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate());
247
+ const cursor = startOfDay(start);
244
248
  cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));
245
249
 
246
250
  const columns: HeatmapColumn[] = [];
@@ -251,7 +255,7 @@ export function buildHeatmapCalendar(
251
255
  const date = new Date(cursor);
252
256
  // Before the range starts and after it ends the cell exists but has no
253
257
  // reading — `count: 0` and no date, so the tooltip stays quiet on it.
254
- const inRange = date >= startOfDay(start) && date <= end;
258
+ const inRange = date >= start && date <= end;
255
259
  column.push({
256
260
  bin: row,
257
261
  count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,
@@ -269,10 +273,6 @@ export function buildHeatmapCalendar(
269
273
  return columns;
270
274
  }
271
275
 
272
- function startOfDay(date: Date) {
273
- return new Date(date.getFullYear(), date.getMonth(), date.getDate());
274
- }
275
-
276
276
  function dayKey(date: Date) {
277
277
  return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
278
278
  }
@@ -78,6 +78,11 @@ const mapVariants = tv({
78
78
  control: 'h-9 w-9 items-center justify-center active:bg-muted',
79
79
  popup: 'rounded-xl border border-border bg-popover px-3 py-2 shadow-lg',
80
80
  pin: 'h-3.5 w-3.5 rounded-full border-2 border-background bg-primary shadow-md',
81
+ // Opaque enough to read over a landmass fill, with an edge and a shadow so
82
+ // it sits on the map rather than in it. At 85% the basemap's own roads and
83
+ // borders showed through the text.
84
+ label: 'rounded-md border border-border bg-background/95 shadow-sm',
85
+ labelText: 'text-foreground',
81
86
  },
82
87
  variants: {
83
88
  position: {
@@ -90,6 +95,27 @@ const mapVariants = tv({
90
95
  defaultVariants: { position: 'bottom-right' },
91
96
  });
92
97
 
98
+ /**
99
+ * The two knobs on a label's pill.
100
+ *
101
+ * Lookups rather than `tv()` variants because they belong to `Map.Label`, not
102
+ * to `Map` — a variant here would be read off the root and documented as
103
+ * something you could pass to the map itself.
104
+ */
105
+ const labelSize: Record<'sm' | 'md', string> = {
106
+ sm: 'px-1.5 py-px',
107
+ md: 'px-2 py-0.5',
108
+ };
109
+
110
+ const labelTone: Record<'default' | 'muted' | 'primary', { pill: string; text: string }> = {
111
+ default: { pill: '', text: '' },
112
+ muted: { pill: '', text: 'text-muted-foreground' },
113
+ primary: {
114
+ pill: 'border-primary/24 bg-primary',
115
+ text: 'text-primary-foreground',
116
+ },
117
+ };
118
+
93
119
  export type MapControlsPosition =
94
120
  | 'top-left'
95
121
  | 'top-right'
@@ -445,6 +471,13 @@ export interface MapLabelProps {
445
471
  className?: string;
446
472
  /** Which side of the marker the label sits on. */
447
473
  side?: 'top' | 'bottom';
474
+ /** `sm` for a map carrying a lot of them, where the pills start to collide. */
475
+ size?: 'sm' | 'md';
476
+ /**
477
+ * How loud the label is. `muted` for codes and counts that support the map
478
+ * without being its subject; `primary` for the one place being pointed at.
479
+ */
480
+ tone?: 'default' | 'muted' | 'primary';
448
481
  }
449
482
 
450
483
  /**
@@ -460,19 +493,35 @@ export interface MapLabelProps {
460
493
  * without widening the marker — a name is usually far wider than the dot it
461
494
  * belongs to, and a box sized to the name would be anchored by the name.
462
495
  */
463
- function MapLabel({ children, className, side = 'bottom' }: MapLabelProps) {
496
+ function MapLabel({
497
+ children,
498
+ className,
499
+ side = 'bottom',
500
+ size = 'md',
501
+ tone = 'default',
502
+ }: MapLabelProps) {
503
+ const { label, labelText } = mapVariants();
504
+ const shade = labelTone[tone];
505
+
464
506
  return (
465
507
  <View
466
508
  pointerEvents="none"
467
509
  className={cn(
468
510
  'absolute items-center',
469
- side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1'
511
+ // 6pt of clearance rather than 4: the pill has a border now, and at 4
512
+ // its edge and the pin's ring read as one smudged shape.
513
+ side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
470
514
  )}
471
515
  style={{ left: -120, right: -120 }}
472
516
  >
473
- <View className={cn('rounded-md bg-background/85 px-1.5 py-0.5', className)}>
517
+ <View className={label({ className: cn(labelSize[size], shade.pill, className) })}>
474
518
  {textChildren(children, (text) => (
475
- <Text size="xs" weight="medium" numberOfLines={1}>
519
+ <Text
520
+ size="xs"
521
+ weight="medium"
522
+ numberOfLines={1}
523
+ className={labelText({ className: shade.text })}
524
+ >
476
525
  {text}
477
526
  </Text>
478
527
  ))}