Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/core-internal-entries-lab-tabbar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: Core's internal touch entry re-exports the action-group tracking hook, which gains a no-paint option and press callbacks for Lab's TabBar. A tab-bar string joins Core's message catalog. Apps import nothing new.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/core-internal-touch-entry-lab-animator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: Core's internal touch entry re-exports the motion engine, curve and gesture helpers Lab's touch components use, and the engine's factory is renamed `createMotionEngine`. Apps import nothing new.

@vjeux
4 changes: 4 additions & 0 deletions packages/core/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,10 @@
"defaultMessage": "Maximum value",
"description": "Accessible name of the upper thumb of a range Slider (two thumbs), announced together with the Slider's own label, for example \"Price range, Maximum value, 80\". Never shown on screen."
},
"@astryx.tabBar.badgedItem": {
"defaultMessage": "{label}, {badge}",
"description": "Screen-reader name of a bottom tab bar item that shows a badge (\"Library, 3\"). {label} is the tab's visible name; {badge} is the badge's count or its own label (\"3 unread\"). Translators may reorder the two or change the separator."
},
"@astryx.tabList.label": {
"defaultMessage": "Tabs",
"description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {motionRoleOf} from '../motion/roles';
import {createMotionAnimator, type Animator} from '../motion/animator';
import {createMotionEngine, type Animator} from '../motion/animator';
import {solveSpring, toSpring} from '../motion/spring';
import {cubicBezier} from '../motion/bezier';
import {segmentedControl, springs} from '../motion/uikit';
Expand Down Expand Up @@ -105,12 +105,12 @@ function setup(selected: string, {coarse = true}: {coarse?: boolean} = {}) {
document.head.append(style);
document.body.append(track);
// A finger by default: Reduced Motion fades (design:reduced-motion DR2).
const animator: Animator = createMotionAnimator(null, {
const animator: Animator = createMotionEngine(null, {
now: () => clock,
pseudoElement: '::before',
});
// The ::after: the lifted lens's shadow, or the surface fading out.
const afterAnimator: Animator = createMotionAnimator(null, {
const afterAnimator: Animator = createMotionEngine(null, {
now: () => clock,
pseudoElement: '::after',
initial: {opacity: 0},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
type RefObject,
} from 'react';
import {
createMotionAnimator,
createMotionEngine,
type AnimateOptions,
type Animator,
type MotionCurve,
Expand Down Expand Up @@ -539,7 +539,7 @@ export class SegmentedSelectionMotion {
private readonly after: Animator;

constructor(
private readonly animator: Animator = createMotionAnimator(null, {
private readonly animator: Animator = createMotionEngine(null, {
pseudoElement: '::before',
}),
options: SegmentedSelectionMotionOptions = {},
Expand All @@ -552,7 +552,7 @@ export class SegmentedSelectionMotion {
this.now = options.now ?? defaultNow;
this.after =
options.afterAnimator ??
createMotionAnimator(null, {
createMotionEngine(null, {
pseudoElement: '::after',
initial: {opacity: 0},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Slider/sliderTouchController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer';
import type {PointerKind} from '../gestures/types';
import {isRtlElement} from '../hooks/isRtlElement';
import {
createMotionAnimator,
createMotionEngine,
type Animator,
type MotionCurve,
type MotionHandle,
Expand Down Expand Up @@ -1186,7 +1186,7 @@ export class SliderTouchController {
private animatorFor(element: HTMLElement): Animator {
let animator = this.animators.get(element);
if (animator == null) {
animator = createMotionAnimator(element);
animator = createMotionEngine(element);
this.animators.set(element, animator);
}
return animator;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Switch/useSwitchTouch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ vi.mock('../motion/animator', async importOriginal => {
const actual = await importOriginal<typeof AnimatorModule>();
return {
...actual,
createMotionAnimator: (
createMotionEngine: (
element: Element,
options: {pseudoElement?: string} = {},
) => {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/Switch/useSwitchTouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {GestureTouch} from '../gestures/types';
import {isRtlElement} from '../hooks/isRtlElement';
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
import {
createMotionAnimator,
createMotionEngine,
type Animator,
type MotionCurve,
} from '../motion/animator';
Expand Down Expand Up @@ -282,15 +282,15 @@ export class SwitchTouchController {
return;
}
registerSwitchTable();
this.thumb = createMotionAnimator(elements.thumb);
this.thumb = createMotionEngine(elements.thumb);
// The layer rests clear (its class's opacity 0) while the visuals show
// the value; from here its opacity is the animator's.
this.fade = createMotionAnimator(elements.track, {
this.fade = createMotionEngine(elements.track, {
pseudoElement: '::after',
initial: {opacity: 0},
});
this.fade.set({opacity: 0});
this.shadow = createMotionAnimator(elements.thumb, {
this.shadow = createMotionEngine(elements.thumb, {
pseudoElement: '::after',
initial: {opacity: 0},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/animator/createAnimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import {
createMotionAnimator,
createMotionEngine,
SETTLE_THRESHOLD,
type Animator as MotionAnimator,
type MotionCurve,
Expand Down Expand Up @@ -199,7 +199,7 @@ export function createAttachableAnimator(): {
readonly animator: Animator;
readonly attach: (element: HTMLElement | null) => void;
} {
const motion: MotionAnimator = createMotionAnimator(null);
const motion: MotionAnimator = createMotionEngine(null);
// Calls do nothing while no element is attached, before the ref attaches
// or after it lets go. They also do nothing after destroy, and animate and
// decay then resolve at once.
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/animator/publicSurface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const SRC = path.join(__dirname, '..');

/** Names of the motion module beneath the public animator. */
const INTERNAL = [
'createMotionAnimator',
'createMotionEngine',
'createAttachableAnimator',
'animatorFor',
'coast',
Expand Down Expand Up @@ -67,6 +67,6 @@ describe('the public animator surface', () => {
'utf8',
);
expect(motion).not.toMatch(/export function createAnimator\b/);
expect(motion).toMatch(/export function createMotionAnimator\b/);
expect(motion).toMatch(/export function createMotionEngine\b/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* @position Browser fixture of presentedHitTesting.interaction.browser.spec.ts; test-only
*/

import {createMotionAnimator} from '../../../motion/animator';
import {createMotionEngine} from '../../../motion/animator';
import {presentedOffsetOf} from '../../../motion/presentation';
import {GestureHost} from '../../GestureHost';
import {PanGestureRecognizer} from '../../PanGestureRecognizer';
Expand Down Expand Up @@ -101,7 +101,7 @@ const TRAVEL = 444;
*/
const SPRING = {response: 6, dampingRatio: 1} as const;

const animator = createMotionAnimator(sheet);
const animator = createMotionEngine(sheet);
const drawn = (target: Element): DOMRect =>
Element.prototype.getBoundingClientRect.call(target);

Expand Down
92 changes: 91 additions & 1 deletion packages/core/src/hooks/useActionGroupTracking.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* action is skipped; a second finger or the window losing focus cancels;
* inside a scroller a flick is left to the scroller; a mouse under a fine
* pointer is tracked like a finger; the shared tracker does the tracking;
* no render per move
* a caller's no-paint option and press callbacks; no render per move
* @position jsdom tests of packages/core/src/hooks/useActionGroupTracking.ts
*/

Expand Down Expand Up @@ -313,6 +313,96 @@ describe('useActionGroupTracking', () => {
expect(onDelete).not.toHaveBeenCalled();
});

describe('a group that draws its own highlight (Lab TabBar)', () => {
const nameOf = (element: Element | null) => element?.textContent ?? 'none';

it('paints nothing with paintsHighlight off, and still runs the action it lifts on', () => {
const {cancel, del, onCancel} = mount({
options: {paintsHighlight: false},
});
const f = finger();
f.down(cancel, centerOf(CANCEL));
expect(paintOf(cancel)).toBeNull();
f.move(cancel, centerOf(DELETE));
expect(paintOf(del)).toBeNull();
f.move(cancel, centerOf(CANCEL));
f.up(cancel, centerOf(CANCEL));
expect(paintOf(cancel)).toBeNull();
expect(onCancel).toHaveBeenCalledTimes(1);
});

it('reports the press around the tracker: the action at the begin and after each move, then the lift before its action runs', () => {
const log: string[] = [];
const {cancel} = mount({
options: {
onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`),
onPressMove: (_x, _y, action) => log.push(`move ${nameOf(action)}`),
onPressEnd: action => log.push(`end ${nameOf(action)}`),
onPressCancel: () => log.push('cancel'),
onActivate: action => log.push(`activate ${nameOf(action)}`),
},
});
const f = finger();
f.down(cancel, centerOf(CANCEL));
f.move(cancel, {x: 104, y: 124});
f.move(cancel, centerOf(DELETE));
f.up(cancel, centerOf(DELETE));
expect(log).toEqual([
'begin Cancel',
'move none',
'move Delete',
'end Delete',
'activate Delete',
]);
});

it('reports a press that a second finger ends as a cancel, and runs nothing (family:action-groups FR3)', () => {
const log: string[] = [];
const {cancel, del} = mount({
options: {
onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`),
onPressEnd: () => log.push('end'),
onPressCancel: () => log.push('cancel'),
onActivate: () => log.push('activate'),
},
});
const first = {id: nextTouchId++, ...centerOf(CANCEL)};
const second = {id: nextTouchId++, ...centerOf(DELETE)};
time += 1000;
touch('touchstart', cancel, first, time);
time += 30;
touch('touchstart', del, second, time, [first]);
time += 16;
touch('touchend', del, second, time, [first]);
time += 16;
touch('touchend', cancel, first, time);
expect(log).toEqual(['begin Cancel', 'cancel']);
});

it('reports a press that turning tracking off ends as a cancel, and runs nothing', () => {
const log: string[] = [];
const options: Partial<ActionGroupTrackingOptions> = {
paintsHighlight: false,
onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`),
onPressEnd: () => log.push('end'),
onPressCancel: () => log.push('cancel'),
onActivate: () => log.push('activate'),
};
const view = mount({options});
const f = finger();
f.down(view.cancel, centerOf(CANCEL));
view.rerender(
<Group
onCancel={view.onCancel}
onDelete={view.onDelete}
options={{...options, isEnabled: false}}
/>,
);
f.up(view.cancel, centerOf(CANCEL));
expect(log).toEqual(['begin Cancel', 'cancel']);
});
});

it('ends the gesture with nothing acting when the window loses focus (family:action-groups FR3)', () => {
const {cancel, onCancel} = mount();
const f = finger();
Expand Down
31 changes: 29 additions & 2 deletions packages/core/src/hooks/useActionGroupTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* @output Exports useActionGroupTracking and ActionGroupTrackingOptions
* @position Internal hook, not exported from the hooks entry. A long press
* drives the shared action-group tracker (hooks/actionGroupTracker.ts) for
* a group that a held pointer slides across. AlertDialog consumes it.
* a group that a held pointer slides across. AlertDialog consumes it, and
* Lab components through the internal touch entry.
*
* SYNC: When modified, update useActionGroupTracking.test.tsx
*/
Expand Down Expand Up @@ -49,6 +50,22 @@ export interface ActionGroupTrackingOptions {
manageStyle?: boolean;
/** Runs the action under the finger at the lift. @default its own click */
onActivate?: (item: HTMLElement) => void;
/**
* Paint the tracked action with the press attribute. Off, the group draws
* its own highlight. @default true
*/
paintsHighlight?: boolean;
/** A press began at viewport (x, y) on `action` (null: none). */
onPressBegin?: (x: number, y: number, action: HTMLElement | null) => void;
/** The press moved to viewport (x, y), now on `action` (null: none). */
onPressMove?: (x: number, y: number, action: HTMLElement | null) => void;
/** The lift, before its action runs, with the tracked action (null: none). */
onPressEnd?: (action: HTMLElement | null) => void;
/**
* The press ended with nothing acting: a cancel, a second finger, or
* tracking turned off.
*/
onPressCancel?: () => void;
}

const PRESS = UIKIT_PRESS;
Expand Down Expand Up @@ -98,6 +115,7 @@ export function useActionGroupTracking(
pointerRef.current === 'touch'
? optionsRef.current.highlightExpansion
: 0,
paintsHighlight: optionsRef.current.paintsHighlight,
onActivate: optionsRef.current.onActivate,
// The browser's own click for a press the group ran is taken, so the
// action runs once. The gesture host swallows it for a press it claims.
Expand All @@ -109,11 +127,16 @@ export function useActionGroupTracking(
// Synchronizes the tracker with tracking on and mounted. Turning tracking
// off, or unmounting, drops a tracked press and ends its release fades.
// The host reports no cancellation to a disabled recognizer, and the lift
// that runs an action often closes the owner before its fade ends.
// that runs an action often closes the owner before its fade ends. The
// group hears the dropped press as a cancel, so its own highlight goes out.
useEffect(
() => () => {
const wasTracking = tracker.isTracking;
tracker.cancel();
tracker.dispose();
if (wasTracking) {
optionsRef.current.onPressCancel?.();
}
},
[tracker, isEnabled],
);
Expand Down Expand Up @@ -146,14 +169,17 @@ export function useActionGroupTracking(
case 'began': {
const {x, y} = recognizer.location();
tracker.begin(x, y, {landed: landedRef.current});
optionsRef.current.onPressBegin?.(x, y, tracker.highlighted);
break;
}
case 'changed': {
const {x, y} = recognizer.location();
tracker.move(x, y);
optionsRef.current.onPressMove?.(x, y, tracker.highlighted);
break;
}
case 'ended': {
optionsRef.current.onPressEnd?.(tracker.highlighted);
const doc = ref.current?.ownerDocument;
const focusedBefore = doc?.activeElement ?? null;
const beganOn = tracker.beganOn;
Expand Down Expand Up @@ -182,6 +208,7 @@ export function useActionGroupTracking(
}
case 'cancelled':
tracker.cancel();
optionsRef.current.onPressCancel?.();
break;
case 'possible':
case 'failed':
Expand Down
Loading