@ecohouse/ui 0.1.61 → 0.1.63

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 (33) hide show
  1. package/dist/index.cjs +331 -115
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +8 -2
  4. package/dist/index.d.ts +8 -2
  5. package/dist/index.js +332 -117
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/Checkbox/Checkbox.tsx +6 -0
  9. package/src/components/Counter/Counter.test.ts +38 -0
  10. package/src/components/Counter/Counter.tsx +7 -10
  11. package/src/components/Counter/Counter.utils.ts +28 -0
  12. package/src/components/DatePicker/DatePicker.tsx +5 -14
  13. package/src/components/Drawer/Drawer.tsx +15 -12
  14. package/src/components/Modal/Modal.tsx +31 -11
  15. package/src/components/RadioButton/RadioButton.tsx +14 -0
  16. package/src/components/Select/Select.tsx +16 -8
  17. package/src/components/Toggle/Toggle.tsx +6 -0
  18. package/src/components/VerificationCodeInput/VerificationCodeInput.test.ts +74 -12
  19. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +29 -24
  20. package/src/components/VerificationCodeInput/VerificationCodeInput.utils.ts +59 -14
  21. package/src/index.ts +6 -1
  22. package/src/utils/dateUtils.test.ts +72 -0
  23. package/src/utils/dateUtils.ts +30 -2
  24. package/src/utils/focusTrap.test.ts +26 -0
  25. package/src/utils/focusTrap.ts +39 -0
  26. package/src/utils/overlayStack.test.ts +175 -0
  27. package/src/utils/overlayStack.ts +105 -0
  28. package/src/utils/useHydrated.test.ts +14 -0
  29. package/src/utils/useHydrated.ts +14 -0
  30. package/src/utils/useOverlayLayer.ts +41 -0
  31. package/src/utils/useWebFocusTrap.ts +68 -0
  32. package/src/utils/webToggleKey.test.ts +49 -0
  33. package/src/utils/webToggleKey.ts +23 -0
@@ -0,0 +1,14 @@
1
+ import { createElement } from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import { useHydrated } from "./useHydrated";
5
+
6
+ function Probe() {
7
+ return createElement("span", null, useHydrated() ? "client" : "server");
8
+ }
9
+
10
+ describe("useHydrated", () => {
11
+ it("is false while rendering on the server, so portals are left out of the SSR HTML", () => {
12
+ expect(renderToString(createElement(Probe))).toBe("<span>server</span>");
13
+ });
14
+ });
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ const subscribe = () => () => {};
4
+ const getClientSnapshot = () => true;
5
+ const getServerSnapshot = () => false;
6
+
7
+ /**
8
+ * `false` during server rendering and hydration, `true` afterwards (and on
9
+ * client-only mounts). Gate browser-only output such as portals on it so the
10
+ * first client render matches the server HTML.
11
+ */
12
+ export function useHydrated(): boolean {
13
+ return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
14
+ }
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { Platform } from "react-native";
3
+ import { getDocumentOverlayStack, type OverlayLayer, type OverlayLayerKind } from "./overlayStack";
4
+
5
+ /**
6
+ * Registers an open overlay on the shared web overlay stack (see
7
+ * `overlayStack.ts`) so Escape closes only the topmost one. No-op on native.
8
+ *
9
+ * The layer is registered once per `active` period; `onEscape` is read from a
10
+ * ref so a new callback identity does not move the layer to the top again.
11
+ * Returns `isTopLayer()` for behaviour that only the topmost layer of this kind
12
+ * should run (e.g. the focus trap of the topmost Modal).
13
+ */
14
+ export function useOverlayLayer(
15
+ kind: OverlayLayerKind,
16
+ active: boolean,
17
+ onEscape: () => void,
18
+ ): () => boolean {
19
+ const onEscapeRef = useRef(onEscape);
20
+ onEscapeRef.current = onEscape;
21
+ const layerRef = useRef<OverlayLayer | null>(null);
22
+
23
+ useEffect(() => {
24
+ if (!active || Platform.OS !== "web" || typeof document === "undefined") return undefined;
25
+
26
+ const layer: OverlayLayer = { kind, onEscape: () => onEscapeRef.current() };
27
+ const stack = getDocumentOverlayStack();
28
+ const remove = stack.push(layer);
29
+ layerRef.current = layer;
30
+
31
+ return () => {
32
+ remove();
33
+ if (layerRef.current === layer) layerRef.current = null;
34
+ };
35
+ }, [active, kind]);
36
+
37
+ return useCallback(() => {
38
+ const layer = layerRef.current;
39
+ return layer !== null && getDocumentOverlayStack().isTop(layer);
40
+ }, []);
41
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, type RefObject } from "react";
2
+ import { Platform } from "react-native";
3
+ import { getTabbableElements, getWrappedFocusIndex } from "./focusTrap";
4
+
5
+ /**
6
+ * Web dialog focus management: moves focus into `containerRef` when it opens
7
+ * (unless content already took it, e.g. via `autoFocus`), keeps Tab and
8
+ * Shift+Tab inside while `isTopLayer()` is true, and returns focus to the
9
+ * previously focused element on close. The container needs `tabIndex={-1}`.
10
+ */
11
+ export function useWebFocusTrap(
12
+ containerRef: RefObject<HTMLElement | null>,
13
+ active: boolean,
14
+ isTopLayer: () => boolean,
15
+ ): void {
16
+ useEffect(() => {
17
+ if (!active || Platform.OS !== "web" || typeof document === "undefined") return undefined;
18
+ const container = containerRef.current;
19
+ if (!container) return undefined;
20
+
21
+ const previouslyFocused =
22
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
23
+ if (!container.contains(document.activeElement)) container.focus({ preventScroll: true });
24
+
25
+ const handleKeyDown = (event: KeyboardEvent) => {
26
+ if (event.key !== "Tab" || event.defaultPrevented || !isTopLayer()) return;
27
+
28
+ const activeElement = document.activeElement;
29
+ // Focus inside another portal (e.g. an app popover) is managed there.
30
+ if (
31
+ activeElement !== null &&
32
+ activeElement !== document.body &&
33
+ !container.contains(activeElement)
34
+ ) {
35
+ return;
36
+ }
37
+
38
+ const tabbables = getTabbableElements(container);
39
+ if (tabbables.length === 0) {
40
+ event.preventDefault();
41
+ container.focus({ preventScroll: true });
42
+ return;
43
+ }
44
+
45
+ const nextIndex = getWrappedFocusIndex(
46
+ tabbables.length,
47
+ tabbables.indexOf(activeElement as HTMLElement),
48
+ event.shiftKey,
49
+ );
50
+ if (nextIndex === null) return;
51
+ event.preventDefault();
52
+ tabbables[nextIndex].focus();
53
+ };
54
+
55
+ document.addEventListener("keydown", handleKeyDown);
56
+ return () => {
57
+ document.removeEventListener("keydown", handleKeyDown);
58
+ // Restore focus unless the app already moved it somewhere else on purpose.
59
+ const current = document.activeElement;
60
+ if (
61
+ previouslyFocused?.isConnected &&
62
+ (current === null || current === document.body || container.contains(current))
63
+ ) {
64
+ previouslyFocused.focus({ preventScroll: true });
65
+ }
66
+ };
67
+ }, [active, containerRef, isTopLayer]);
68
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { handleWebToggleKeyDown, type WebToggleKeyEvent } from "./webToggleKey";
3
+
4
+ const host = {};
5
+
6
+ function keyEvent(overrides: Partial<WebToggleKeyEvent>): WebToggleKeyEvent {
7
+ return { key: " ", target: host, currentTarget: host, preventDefault: vi.fn(), ...overrides };
8
+ }
9
+
10
+ describe("handleWebToggleKeyDown", () => {
11
+ it("toggles on Space and stops the page from scrolling", () => {
12
+ const onToggle = vi.fn();
13
+ const event = keyEvent({});
14
+
15
+ expect(handleWebToggleKeyDown(event, onToggle)).toBe(true);
16
+ expect(onToggle).toHaveBeenCalledTimes(1);
17
+ expect(event.preventDefault).toHaveBeenCalled();
18
+ });
19
+
20
+ it("supports the legacy Spacebar key name", () => {
21
+ const onToggle = vi.fn();
22
+ handleWebToggleKeyDown(keyEvent({ key: "Spacebar" }), onToggle);
23
+ expect(onToggle).toHaveBeenCalledTimes(1);
24
+ });
25
+
26
+ it("does not toggle repeatedly while Space is held down", () => {
27
+ const onToggle = vi.fn();
28
+ const event = keyEvent({ repeat: true });
29
+
30
+ expect(handleWebToggleKeyDown(event, onToggle)).toBe(true);
31
+ expect(onToggle).not.toHaveBeenCalled();
32
+ expect(event.preventDefault).toHaveBeenCalled();
33
+ });
34
+
35
+ it("ignores Space typed into a focusable child", () => {
36
+ const onToggle = vi.fn();
37
+ const event = keyEvent({ target: {} });
38
+
39
+ expect(handleWebToggleKeyDown(event, onToggle)).toBe(false);
40
+ expect(onToggle).not.toHaveBeenCalled();
41
+ expect(event.preventDefault).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("leaves other keys alone (Enter is already handled by react-native-web)", () => {
45
+ const onToggle = vi.fn();
46
+ expect(handleWebToggleKeyDown(keyEvent({ key: "Enter" }), onToggle)).toBe(false);
47
+ expect(onToggle).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -0,0 +1,23 @@
1
+ export interface WebToggleKeyEvent {
2
+ key: string;
3
+ repeat?: boolean;
4
+ target?: unknown;
5
+ currentTarget?: unknown;
6
+ preventDefault: () => void;
7
+ }
8
+
9
+ /**
10
+ * Space activates switches, checkboxes and radios on web. react-native-web only
11
+ * maps Space to `onPress` for `button` roles, so these controls handle it
12
+ * themselves. Ignores key repeat and keys from nested focusable children.
13
+ * Returns whether the event was handled.
14
+ */
15
+ export function handleWebToggleKeyDown(event: WebToggleKeyEvent, onToggle: () => void): boolean {
16
+ const isSpace = event.key === " " || event.key === "Spacebar";
17
+ if (!isSpace || event.target !== event.currentTarget) return false;
18
+
19
+ // Stop the page from scrolling even for a held-down key.
20
+ event.preventDefault();
21
+ if (!event.repeat) onToggle();
22
+ return true;
23
+ }