panelui-native 0.68.0 → 0.71.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 (93) hide show
  1. package/README.md +12 -2
  2. package/lib/module/components/accordion/index.js +28 -9
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +14 -7
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/collapsible/index.js +280 -0
  7. package/lib/module/components/collapsible/index.js.map +1 -0
  8. package/lib/module/components/flow/index.js +66 -3
  9. package/lib/module/components/flow/index.js.map +1 -1
  10. package/lib/module/components/live-line-chart/index.js +103 -9
  11. package/lib/module/components/live-line-chart/index.js.map +1 -1
  12. package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
  13. package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
  14. package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
  15. package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
  16. package/lib/module/components/marquee/index.js +194 -0
  17. package/lib/module/components/marquee/index.js.map +1 -0
  18. package/lib/module/components/meter/index.js +38 -20
  19. package/lib/module/components/meter/index.js.map +1 -1
  20. package/lib/module/components/meter/meter-scale.js +62 -8
  21. package/lib/module/components/meter/meter-scale.js.map +1 -1
  22. package/lib/module/components/planner/index.js +133 -59
  23. package/lib/module/components/planner/index.js.map +1 -1
  24. package/lib/module/components/planner/planner-announcement.js +30 -0
  25. package/lib/module/components/planner/planner-announcement.js.map +1 -0
  26. package/lib/module/components/planner/planner-entries.js +15 -7
  27. package/lib/module/components/planner/planner-entries.js.map +1 -1
  28. package/lib/module/components/planner/planner-grid-navigation.js +33 -0
  29. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
  30. package/lib/module/components/planner/planner-lifecycle.js +45 -0
  31. package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
  32. package/lib/module/components/popover/index.js +2 -2
  33. package/lib/module/components/popover/index.js.map +1 -1
  34. package/lib/module/index.js +2 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/primitives/focus-restoration-store.js +52 -0
  37. package/lib/module/primitives/focus-restoration-store.js.map +1 -0
  38. package/lib/module/primitives/portal.js +36 -2
  39. package/lib/module/primitives/portal.js.map +1 -1
  40. package/lib/typescript/src/components/accordion/index.d.ts +4 -0
  41. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
  44. package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
  45. package/lib/typescript/src/components/flow/index.d.ts +7 -1
  46. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
  48. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
  50. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
  51. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
  52. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
  53. package/lib/typescript/src/components/marquee/index.d.ts +73 -0
  54. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/meter/index.d.ts +7 -4
  56. package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
  58. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
  59. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
  61. package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
  62. package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
  63. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  64. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
  65. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
  66. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
  67. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
  68. package/lib/typescript/src/index.d.ts +2 -0
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
  71. package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
  72. package/lib/typescript/src/primitives/portal.d.ts +4 -0
  73. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  74. package/package.json +36 -3
  75. package/src/components/accordion/index.tsx +31 -8
  76. package/src/components/button/index.tsx +11 -4
  77. package/src/components/collapsible/index.tsx +278 -0
  78. package/src/components/flow/index.tsx +93 -14
  79. package/src/components/live-line-chart/index.tsx +130 -13
  80. package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
  81. package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
  82. package/src/components/marquee/index.tsx +232 -0
  83. package/src/components/meter/index.tsx +49 -26
  84. package/src/components/meter/meter-scale.ts +84 -8
  85. package/src/components/planner/index.tsx +182 -71
  86. package/src/components/planner/planner-announcement.ts +37 -0
  87. package/src/components/planner/planner-entries.ts +16 -7
  88. package/src/components/planner/planner-grid-navigation.ts +45 -0
  89. package/src/components/planner/planner-lifecycle.ts +76 -0
  90. package/src/components/popover/index.tsx +3 -3
  91. package/src/index.ts +13 -0
  92. package/src/primitives/focus-restoration-store.ts +70 -0
  93. package/src/primitives/portal.tsx +34 -2
@@ -99,9 +99,13 @@ export function dayAccessibilityLabel<T extends PlannerDatedEntry>(
99
99
  if (entries.length === 0) return `${dateLabel}, nothing planned`;
100
100
 
101
101
  const named: string[] = [];
102
+ const seen = new Set<string>();
102
103
  for (const entry of entries) {
103
104
  const label = entry.category ? categoryLabels.get(entry.category) : undefined;
104
- if (label && !named.includes(label)) named.push(label);
105
+ if (label && !seen.has(label)) {
106
+ seen.add(label);
107
+ named.push(label);
108
+ }
105
109
  }
106
110
 
107
111
  const count = `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
@@ -120,18 +124,23 @@ export function dayAccessibilityLabel<T extends PlannerDatedEntry>(
120
124
  * with the week the month happens to start on.
121
125
  */
122
126
  export function summariseMonth<T extends PlannerDatedEntry>(
123
- entries: readonly T[],
127
+ days: Map<number, T[]>,
128
+ grid: readonly (readonly Date[])[],
124
129
  categories: readonly { id: string; label: string }[],
125
130
  isInMonth: (date: Date) => boolean
126
131
  ): { total: number; categories: PlannerCountedCategory[] } {
127
132
  const counts = new Map<string, number>();
128
133
  let total = 0;
129
134
 
130
- for (const entry of entries) {
131
- if (!isInMonth(entry.date)) continue;
132
- total += 1;
133
- if (entry.category) {
134
- counts.set(entry.category, (counts.get(entry.category) ?? 0) + 1);
135
+ for (const week of grid) {
136
+ for (const date of week) {
137
+ if (!isInMonth(date)) continue;
138
+ for (const entry of entriesOn(days, date)) {
139
+ total += 1;
140
+ if (entry.category) {
141
+ counts.set(entry.category, (counts.get(entry.category) ?? 0) + 1);
142
+ }
143
+ }
135
144
  }
136
145
  }
137
146
 
@@ -0,0 +1,45 @@
1
+ export type PlannerGridNavigationKey =
2
+ | 'ArrowLeft'
3
+ | 'ArrowRight'
4
+ | 'ArrowUp'
5
+ | 'ArrowDown'
6
+ | 'Home'
7
+ | 'End';
8
+
9
+ /** Returns the next visible cell for a bounded, row-major month grid. */
10
+ export function plannerGridTarget(
11
+ key: string,
12
+ index: number,
13
+ count: number,
14
+ columns = 7
15
+ ): number | null {
16
+ if (!Number.isInteger(index) || index < 0 || index >= count || columns < 1) {
17
+ return null;
18
+ }
19
+
20
+ let target: number;
21
+ switch (key as PlannerGridNavigationKey) {
22
+ case 'ArrowLeft':
23
+ target = index - 1;
24
+ break;
25
+ case 'ArrowRight':
26
+ target = index + 1;
27
+ break;
28
+ case 'ArrowUp':
29
+ target = index - columns;
30
+ break;
31
+ case 'ArrowDown':
32
+ target = index + columns;
33
+ break;
34
+ case 'Home':
35
+ target = index - (index % columns);
36
+ break;
37
+ case 'End':
38
+ target = index + (columns - 1 - (index % columns));
39
+ break;
40
+ default:
41
+ return null;
42
+ }
43
+
44
+ return target >= 0 && target < count ? target : index;
45
+ }
@@ -0,0 +1,76 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ interface PlannerMonthLifecycleOptions {
4
+ month: Date | undefined;
5
+ defaultMonth: Date | undefined;
6
+ settleMonth: (month: Date) => Date;
7
+ onMonthChange: ((month: Date) => void) | undefined;
8
+ }
9
+
10
+ /** Keeps controlled requests separate from the month a parent has accepted. */
11
+ export function usePlannerMonthLifecycle({
12
+ month: monthProp,
13
+ defaultMonth,
14
+ settleMonth,
15
+ onMonthChange,
16
+ }: PlannerMonthLifecycleOptions): readonly [Date, (month: Date) => void] {
17
+ const [internalMonth, setInternalMonth] = useState(() =>
18
+ settleMonth(defaultMonth ?? new Date())
19
+ );
20
+ const isControlled = monthProp !== undefined;
21
+
22
+ /*
23
+ * Parents commonly create an equivalent Date on every render. Keying this
24
+ * derivation by its instant keeps the month object stable, so an unrelated
25
+ * parent render does not rebuild the grid and its month summary.
26
+ */
27
+ const controlledMonth = useMemo(
28
+ () =>
29
+ monthProp === undefined
30
+ ? undefined
31
+ : settleMonth(monthProp),
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ [monthProp?.getTime(), settleMonth]
34
+ );
35
+ const month = controlledMonth ?? internalMonth;
36
+
37
+ const setMonth = useCallback(
38
+ (next: Date) => {
39
+ const settled = settleMonth(next);
40
+ if (!isControlled) setInternalMonth(settled);
41
+ onMonthChange?.(settled);
42
+ },
43
+ [isControlled, onMonthChange, settleMonth]
44
+ );
45
+
46
+ return [month, setMonth] as const;
47
+ }
48
+
49
+ interface PlannerSelectionLifecycleOptions {
50
+ selected: Date | null | undefined;
51
+ defaultSelected: Date | null;
52
+ onSelectedChange: ((date: Date | null) => void) | undefined;
53
+ }
54
+
55
+ /** Commits locally only when selection is uncontrolled, but always reports a request. */
56
+ export function usePlannerSelectionLifecycle({
57
+ selected: selectedProp,
58
+ defaultSelected,
59
+ onSelectedChange,
60
+ }: PlannerSelectionLifecycleOptions): readonly [
61
+ Date | null,
62
+ (date: Date | null) => void,
63
+ ] {
64
+ const [internalSelected, setInternalSelected] = useState<Date | null>(defaultSelected);
65
+ const isControlled = selectedProp !== undefined;
66
+ const selected = isControlled ? selectedProp : internalSelected;
67
+ const select = useCallback(
68
+ (date: Date | null) => {
69
+ if (!isControlled) setInternalSelected(date);
70
+ onSelectedChange?.(date);
71
+ },
72
+ [isControlled, onSelectedChange]
73
+ );
74
+
75
+ return [selected, select] as const;
76
+ }
@@ -59,7 +59,7 @@ import Animated, {
59
59
  withTiming,
60
60
  } from 'react-native-reanimated';
61
61
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
62
- import { Portal } from '../../primitives/portal';
62
+ import { FocusRestorePortal } from '../../primitives/portal';
63
63
  import { Scrim } from '../../primitives/scrim';
64
64
  import { useBackHandler } from '../../hooks/use-back-handler';
65
65
  import { BottomSheet } from '../bottom-sheet';
@@ -535,7 +535,7 @@ function PopoverContent({
535
535
  if (!open || !trigger) return null;
536
536
 
537
537
  return (
538
- <Portal>
538
+ <FocusRestorePortal>
539
539
  {/* Portal content mounts under PortalHost, outside this provider's
540
540
  subtree — re-provide the context so Popover.Close and Popover.Arrow
541
541
  keep working. */}
@@ -609,7 +609,7 @@ function PopoverContent({
609
609
  </Animated.View>
610
610
  </View>
611
611
  </PopoverContext.Provider>
612
- </Portal>
612
+ </FocusRestorePortal>
613
613
  );
614
614
  }
615
615
 
package/src/index.ts CHANGED
@@ -416,6 +416,14 @@ export {
416
416
  type ChipVariant,
417
417
  type ChipSize,
418
418
  } from './components/chip';
419
+ export {
420
+ Collapsible,
421
+ type CollapsibleProps,
422
+ type CollapsibleTriggerProps,
423
+ type CollapsibleIndicatorProps,
424
+ type CollapsibleContentProps,
425
+ type CollapsibleVariant,
426
+ } from './components/collapsible';
419
427
  export {
420
428
  ColorPicker,
421
429
  type ColorPickerProps,
@@ -728,6 +736,11 @@ export {
728
736
  type MarkerIconProps,
729
737
  type MarkerContentProps,
730
738
  } from './components/marker';
739
+ export {
740
+ Marquee,
741
+ type MarqueeProps,
742
+ type MarqueeDirection,
743
+ } from './components/marquee';
731
744
  export {
732
745
  Menu,
733
746
  type MenuProps,
@@ -0,0 +1,70 @@
1
+ interface FocusDocument {
2
+ activeElement?: FocusTarget | null;
3
+ body?: FocusTarget | null;
4
+ documentElement?: FocusTarget | null;
5
+ }
6
+
7
+ interface FocusTarget {
8
+ disabled?: boolean;
9
+ isConnected?: boolean;
10
+ focus: (options?: { preventScroll?: boolean }) => void;
11
+ getAttribute?: (name: string) => string | null;
12
+ }
13
+
14
+ type RestoreFocus = () => boolean;
15
+
16
+ interface FocusOwner {
17
+ owner: string;
18
+ released: boolean;
19
+ restore: RestoreFocus;
20
+ }
21
+
22
+ /** Restores nested overlays in last-opened order, even when parents close first. */
23
+ export class FocusRestorationStore {
24
+ private owners: FocusOwner[] = [];
25
+
26
+ acquire(owner: string, restore: RestoreFocus): (() => void) {
27
+ const entry = { owner, released: false, restore };
28
+ this.owners.push(entry);
29
+
30
+ return () => {
31
+ if (entry.released) return;
32
+ entry.released = true;
33
+ this.restoreReleasedOwners();
34
+ };
35
+ }
36
+
37
+ private restoreReleasedOwners() {
38
+ const candidates: RestoreFocus[] = [];
39
+ while (this.owners.at(-1)?.released) {
40
+ const entry = this.owners.pop();
41
+ if (entry) candidates.push(entry.restore);
42
+ }
43
+ candidates.some((restore) => restore());
44
+ }
45
+ }
46
+
47
+ /** Captures the current browser focus without evaluating DOM APIs on native. */
48
+ export function captureFocusRestore(
49
+ document = (globalThis as { document?: FocusDocument }).document
50
+ ): RestoreFocus {
51
+ const target = document?.activeElement;
52
+ if (!target || target === document?.body || target === document?.documentElement) {
53
+ return () => false;
54
+ }
55
+
56
+ return () => {
57
+ if (target.isConnected === false || target.disabled) return false;
58
+ if (target.getAttribute?.('aria-disabled') === 'true') return false;
59
+ try {
60
+ target.focus({ preventScroll: true });
61
+ } catch {
62
+ try {
63
+ target.focus();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ return document?.activeElement === target;
69
+ };
70
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type ReactNode,
9
9
  } from 'react';
10
10
  import { ModalIsolationStore } from './modal-isolation-store';
11
+ import { captureFocusRestore, FocusRestorationStore } from './focus-restoration-store';
11
12
 
12
13
  type PortalMap = ReadonlyMap<string, ReactNode>;
13
14
 
@@ -41,16 +42,21 @@ class PortalStore {
41
42
 
42
43
  const PortalContext = createContext<PortalStore | null>(null);
43
44
  const ModalIsolationContext = createContext<ModalIsolationStore | null>(null);
45
+ const FocusRestorationContext = createContext<FocusRestorationStore | null>(null);
44
46
 
45
47
  export function PortalProvider({ children }: { children: ReactNode }) {
46
48
  const portalStoreRef = useRef<PortalStore | null>(null);
47
49
  const modalStoreRef = useRef<ModalIsolationStore | null>(null);
50
+ const focusStoreRef = useRef<FocusRestorationStore | null>(null);
48
51
  portalStoreRef.current ??= new PortalStore();
49
52
  modalStoreRef.current ??= new ModalIsolationStore();
53
+ focusStoreRef.current ??= new FocusRestorationStore();
50
54
  return (
51
55
  <PortalContext.Provider value={portalStoreRef.current}>
52
56
  <ModalIsolationContext.Provider value={modalStoreRef.current}>
53
- {children}
57
+ <FocusRestorationContext.Provider value={focusStoreRef.current}>
58
+ {children}
59
+ </FocusRestorationContext.Provider>
54
60
  </ModalIsolationContext.Provider>
55
61
  </PortalContext.Provider>
56
62
  );
@@ -80,9 +86,27 @@ export function Portal({ children }: { children: ReactNode }) {
80
86
  /** A portal that owns modal focus until it unmounts. */
81
87
  export function ModalPortal({ children }: { children: ReactNode }) {
82
88
  const store = useModalIsolationStore('ModalPortal');
89
+ const focusStore = useFocusRestorationStore('ModalPortal');
83
90
  const owner = useId();
84
91
 
85
- useEffect(() => store.acquire(owner), [owner, store]);
92
+ useEffect(() => {
93
+ const releaseIsolation = store.acquire(owner);
94
+ const releaseFocus = focusStore.acquire(owner, captureFocusRestore());
95
+ return () => {
96
+ releaseIsolation();
97
+ releaseFocus();
98
+ };
99
+ }, [focusStore, owner, store]);
100
+
101
+ return <Portal>{children}</Portal>;
102
+ }
103
+
104
+ /** A non-isolating portal that returns browser focus to what opened it. */
105
+ export function FocusRestorePortal({ children }: { children: ReactNode }) {
106
+ const store = useFocusRestorationStore('FocusRestorePortal');
107
+ const owner = useId();
108
+
109
+ useEffect(() => store.acquire(owner, captureFocusRestore()), [owner, store]);
86
110
 
87
111
  return <Portal>{children}</Portal>;
88
112
  }
@@ -101,6 +125,14 @@ function useModalIsolationStore(component: string): ModalIsolationStore {
101
125
  return store;
102
126
  }
103
127
 
128
+ function useFocusRestorationStore(component: string): FocusRestorationStore {
129
+ const store = useContext(FocusRestorationContext);
130
+ if (!store) {
131
+ throw new Error(`${component} must be used within a <PanelUIProvider>`);
132
+ }
133
+ return store;
134
+ }
135
+
104
136
  /** Mount point for portaled content. PanelUIProvider renders one automatically. */
105
137
  export function PortalHost() {
106
138
  const store = usePortalStore('PortalHost');