diff --git a/README.md b/README.md index 5be4aa7..fe6d9cd 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Call `item.measure()` after scrolling if the order should follow the new positio `ScanProvider` also takes `policy` (`Policy.KEYBOARD`, the default, restarts from the top; `Policy.MENU` stays where it was), `afterSelection` (`continue` or `stop`), `onSelect` and `onError`. If `onActivate` returns a promise, scanning holds until it settles. +Pass `exclusive` to `useScanGroup` or `` for a dialog: while it is mounted, scanning is confined to it. The most recently mounted exclusive group wins. + Ids must be unique across items and groups. ## License diff --git a/package.json b/package.json index 91f3dc6..0a3d26d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchify/scanning", - "version": "0.1.0", + "version": "0.2.0", "description": "Switch scanning for React and React Native, ported from Switchify PC.", "license": "AGPL-3.0-only", "type": "module", diff --git a/src/core/controller.ts b/src/core/controller.ts index 3557ac3..d33e91f 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -29,6 +29,11 @@ export interface ItemRegistration { export interface GroupRegistration { parentId?: string; order?: number; + /** + * While registered, scanning is confined to this group, as for an open dialog. + * The most recently registered exclusive group wins. + */ + exclusive?: boolean; } export type Highlight = @@ -133,6 +138,8 @@ export class ScanController { Pick; private scanner: ItemScanner; private readonly sequences = new Map(); + private readonly exclusive = new Map(); + private exclusiveOrder = 0; private sequence = 0; private active = false; private paused = false; @@ -214,10 +221,16 @@ export class ScanController { sequence: this.sequenceOf(id), }; this.groups.set(id, entry); + if (registration.exclusive) { + if (!this.exclusive.has(id)) this.exclusive.set(id, ++this.exclusiveOrder); + } else { + this.exclusive.delete(id); + } this.contentChanged(); return () => { if (this.groups.get(id) === entry) { this.groups.delete(id); + this.exclusive.delete(id); this.contentChanged(); } }; @@ -386,7 +399,15 @@ export class ScanController { if (seen.has(entry.id)) return []; return [group(entry.id, build(entry.id, new Set(seen).add(entry.id)))]; }); - return build(undefined, new Set()); + let scope: string | undefined; + let latest = -1; + for (const [id, order] of this.exclusive) { + if (this.groups.has(id) && order > latest) { + scope = id; + latest = order; + } + } + return build(scope, scope === undefined ? new Set() : new Set([scope])); } private moving(): boolean { diff --git a/src/react/index.tsx b/src/react/index.tsx index f5b2d03..5dbd9ec 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -189,6 +189,8 @@ export function useScanItem( export interface ScanGroupOptions { order?: number; + /** Confine scanning to this group while it is mounted, as for an open dialog. */ + exclusive?: boolean; } export interface ScanGroupState { @@ -224,14 +226,14 @@ function groupState(key: GroupKey): ScanGroupState { /** Registers a group in the current scope. Wrap its children in ScanGroupScope. */ export function useScanGroup( id: string, - { order }: ScanGroupOptions = {}, + { order, exclusive }: ScanGroupOptions = {}, anchor?: unknown, ): ScanGroupState { const controller = useScanController(); const scope = useContext(ScopeContext); useEffect( - () => controller.registerGroup(id, { parentId: scope.id, order }, anchor), - [controller, id, scope.id, order, anchor], + () => controller.registerGroup(id, { parentId: scope.id, order, exclusive }, anchor), + [controller, id, scope.id, order, exclusive, anchor], ); const read = useCallback( (snapshot: ScanSnapshot): GroupKey => { @@ -261,8 +263,8 @@ export interface ScanGroupProps extends ScanGroupOptions { } /** A group with no element of its own. Use the dom or native wrappers to order groups by layout. */ -export function ScanGroup({ id, order, children }: ScanGroupProps) { - const state = useScanGroup(id, { order }); +export function ScanGroup({ id, order, exclusive, children }: ScanGroupProps) { + const state = useScanGroup(id, { order, exclusive }); return ( {typeof children === "function" ? children(state) : children} ); diff --git a/test/controller.test.ts b/test/controller.test.ts index bda7019..104d907 100644 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -261,3 +261,31 @@ test("a held switch pauses automatic movement until release", () => { clock.advance(500); expect(highlight(controller)).toEqual({ kind: "item", id: "b" }); }); + +test("an exclusive group confines scanning until it is removed", async () => { + const { controller } = setup({ options: { automatic: false } }); + controller.registerItem("page-a"); + controller.registerItem("page-b"); + controller.flush(); + controller.start(); + controller.dispatch("next"); + const closeDialog = controller.registerGroup("dialog", { exclusive: true }); + const dialogItems = [controller.registerItem("ok", { parentId: "dialog" }), controller.registerItem("cancel", { parentId: "dialog" })]; + controller.flush(); + expect(highlight(controller)).toEqual({ kind: "item", id: "ok" }); + controller.dispatch("next"); + controller.dispatch("next"); + expect(highlight(controller)).toEqual({ kind: "item", id: "ok" }); + const closeNested = controller.registerGroup("nested", { exclusive: true }); + const removeInner = controller.registerItem("inner", { parentId: "nested" }); + controller.flush(); + expect(highlight(controller)).toEqual({ kind: "item", id: "inner" }); + removeInner(); + closeNested(); + await Promise.resolve(); + expect(highlight(controller)).toEqual({ kind: "item", id: "ok" }); + dialogItems.forEach((remove) => remove()); + closeDialog(); + await Promise.resolve(); + expect(highlight(controller)).toEqual({ kind: "item", id: "page-a" }); +});