@astryxdesign/lab 0.7.7-canary.c0929c8 → 0.7.7-canary.c19ca12

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 (31) hide show
  1. package/dist/InfoTip/index.d.ts +21 -3
  2. package/dist/InfoTip/index.d.ts.map +1 -1
  3. package/dist/InfoTip/index.js +22 -2
  4. package/dist/MobilePowerSearch/MobilePowerSearch.d.ts.map +1 -1
  5. package/dist/MobilePowerSearch/MobilePowerSearch.js +5 -4
  6. package/dist/MobileTokenizer/MobileTokenizer.d.ts +1 -1
  7. package/dist/MobileTokenizer/MobileTokenizer.d.ts.map +1 -1
  8. package/dist/MobileTokenizer/MobileTokenizer.js +10 -37
  9. package/dist/lab.css +0 -3
  10. package/package.json +5 -5
  11. package/src/InfoTip/InfoTip.test.tsx +24 -229
  12. package/src/InfoTip/index.ts +28 -3
  13. package/src/MobilePowerSearch/MobilePowerSearch.test.tsx +11 -0
  14. package/src/MobilePowerSearch/MobilePowerSearch.tsx +18 -7
  15. package/src/MobileTokenizer/MobileTokenizer.test.tsx +6 -5
  16. package/src/MobileTokenizer/MobileTokenizer.tsx +25 -51
  17. package/dist/InfoTip/InfoTip.d.ts +0 -58
  18. package/dist/InfoTip/InfoTip.d.ts.map +0 -1
  19. package/dist/InfoTip/InfoTip.js +0 -94
  20. package/dist/internal/MobileSheetConfirmation.d.ts +0 -12
  21. package/dist/internal/MobileSheetConfirmation.d.ts.map +0 -1
  22. package/dist/internal/MobileSheetConfirmation.js +0 -88
  23. package/dist/internal/useDismissSearchOnTouchDrag.d.ts +0 -21
  24. package/dist/internal/useDismissSearchOnTouchDrag.d.ts.map +0 -1
  25. package/dist/internal/useDismissSearchOnTouchDrag.js +0 -43
  26. package/src/InfoTip/InfoTip.doc.mjs +0 -100
  27. package/src/InfoTip/InfoTip.tsx +0 -128
  28. package/src/internal/MobileSheetConfirmation.test.tsx +0 -68
  29. package/src/internal/MobileSheetConfirmation.tsx +0 -97
  30. package/src/internal/useDismissSearchOnTouchDrag.test.tsx +0 -94
  31. package/src/internal/useDismissSearchOnTouchDrag.ts +0 -57
@@ -1,128 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- 'use client';
4
-
5
- /**
6
- * @file InfoTip.tsx
7
- * @input Uses React, stylex, Tooltip + Icon from @astryxdesign/core, color/spacing/radius/duration/ease tokens
8
- * @output Exports InfoTip component, InfoTipProps, InfoTipSize types
9
- * @position Lab experiment (RFC meta/astryx#3349); core implementation consumed by index.ts
10
- *
11
- * SYNC: When modified, update these files to stay in sync:
12
- * - /packages/lab/src/InfoTip/InfoTip.doc.mjs (props table, features)
13
- * - /packages/lab/src/InfoTip/InfoTip.test.tsx (tests for new/changed behavior)
14
- * - /packages/lab/src/InfoTip/index.ts (exports if types change)
15
- */
16
-
17
- import {type ReactNode} from 'react';
18
- import * as stylex from '@stylexjs/stylex';
19
-
20
- import {Icon, type IconSize} from '@astryxdesign/core/Icon';
21
- import {Tooltip} from '@astryxdesign/core/Tooltip';
22
- import {
23
- colorVars,
24
- durationVars,
25
- easeVars,
26
- radiusVars,
27
- spacingVars,
28
- } from '@astryxdesign/core/theme/tokens.stylex';
29
- import {focusOutlineStyles} from '@astryxdesign/core/utils';
30
-
31
- /**
32
- * Size of the info icon. Maps 1:1 to Icon sizes
33
- * (xsm: 12px, sm: 16px, md: 20px, lg: 24px).
34
- */
35
- export type InfoTipSize = IconSize;
36
-
37
- export interface InfoTipProps {
38
- /**
39
- * Content to display in the tooltip.
40
- * Typically short, non-interactive text. Mirrors Tooltip's `content` prop.
41
- */
42
- content: ReactNode;
43
- /**
44
- * Accessible name for the trigger button.
45
- * @default 'More information'
46
- */
47
- label?: string;
48
- /**
49
- * Size of the info icon.
50
- * @default 'sm'
51
- */
52
- size?: InfoTipSize;
53
- }
54
-
55
- const styles = stylex.create({
56
- trigger: {
57
- display: 'inline-flex',
58
- alignItems: 'center',
59
- justifyContent: 'center',
60
- verticalAlign: 'middle',
61
- padding: spacingVars['--spacing-0-5'],
62
- margin: 0,
63
- borderStyle: 'none',
64
- backgroundColor: 'transparent',
65
- borderRadius: radiusVars['--radius-full'],
66
- cursor: {
67
- default: 'pointer',
68
- ':is(:disabled,[aria-disabled="true"])': 'default',
69
- },
70
- color: {
71
- default: colorVars['--color-icon-secondary'],
72
- ':hover:where(:not(:disabled,[aria-disabled="true"]))': {
73
- default: null,
74
- '@media (hover: hover)': colorVars['--color-icon-primary'],
75
- },
76
- },
77
- transitionProperty: 'color',
78
- transitionDuration: durationVars['--duration-fast'],
79
- transitionTimingFunction: easeVars['--ease-standard'],
80
- },
81
- });
82
-
83
- /**
84
- * An inline info-icon help affordance: a small "i" button that reveals a
85
- * tooltip on hover, keyboard focus, and tap. Use it next to labels, values,
86
- * and metrics for permission notes, metric definitions, and field help.
87
- *
88
- * The value over hand-composing Icon inside Tooltip is the pre-wired
89
- * accessible trigger: a real button with an aria-label, Tab-reachable,
90
- * tooltip on hover AND focus, and Escape dismissal.
91
- *
92
- * Composed entirely from core primitives (Tooltip + Icon); the info icon
93
- * resolves from the global icon registry, so themes can override it.
94
- *
95
- * @example
96
- * ```
97
- * <InfoTip content="Editors can change this field; viewers cannot." />
98
- * <InfoTip content="30-day rolling average." label="About this metric" />
99
- * ```
100
- */
101
- export function InfoTip({
102
- content,
103
- label = 'More information',
104
- size = 'sm',
105
- }: InfoTipProps): ReactNode {
106
- // No local Escape handler: the trigger used to stopPropagation the press,
107
- // which hid the tip but left the browser's close watcher to fire `cancel` on
108
- // an enclosing Dialog, so one press took both (#5168). Tooltip's own entry in
109
- // the shared dismissal stack takes it instead.
110
- return (
111
- <Tooltip
112
- content={content}
113
- // The trigger is a real button, so Tooltip's `auto` touch rule would
114
- // give the tap to the control and suppress the tooltip. Here the tooltip
115
- // IS the control's only purpose, so the tap has to open it — otherwise
116
- // an InfoTip's content is unreachable on a phone.
117
- touchTrigger="tap">
118
- <button
119
- type="button"
120
- aria-label={label}
121
- {...stylex.props(focusOutlineStyles.focusVisible, styles.trigger)}>
122
- <Icon icon="info" size={size} />
123
- </button>
124
- </Tooltip>
125
- );
126
- }
127
-
128
- InfoTip.displayName = 'InfoTip';
@@ -1,68 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- import {fireEvent, render, screen, within} from '@testing-library/react';
4
- import {beforeEach, describe, expect, it, vi} from 'vitest';
5
- import {MobileSheetConfirmation} from './MobileSheetConfirmation';
6
-
7
- // jsdom has no native modal presentation; keep the public Dialog path active.
8
- beforeEach(() => {
9
- HTMLDialogElement.prototype.showModal = vi.fn(function (
10
- this: HTMLDialogElement,
11
- ) {
12
- this.setAttribute('open', '');
13
- });
14
- HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
15
- this.removeAttribute('open');
16
- });
17
- window.scrollTo = vi.fn();
18
- });
19
-
20
- function setup() {
21
- const onAction = vi.fn();
22
- const onOpenChange = vi.fn();
23
- render(
24
- <MobileSheetConfirmation
25
- isOpen
26
- onOpenChange={onOpenChange}
27
- title="Discard changes?"
28
- description="The draft will be removed."
29
- actionLabel="Discard"
30
- cancelLabel="Keep editing"
31
- onAction={onAction}
32
- />,
33
- );
34
- const dialog = screen.getByRole('alertdialog', {name: 'Discard changes?'});
35
- return {dialog, onAction, onOpenChange};
36
- }
37
-
38
- describe('MobileSheetConfirmation', () => {
39
- it('keeps the alert-dialog theme target and accessible copy', () => {
40
- const {dialog} = setup();
41
- expect(dialog).toHaveClass('astryx-alert-dialog');
42
- expect(dialog).toHaveAccessibleDescription('The draft will be removed.');
43
- });
44
-
45
- it('stacks full-width large actions with the secondary safe action focused', () => {
46
- const {dialog} = setup();
47
- const [destructive, safe] = within(dialog).getAllByRole('button');
48
- expect(destructive).toHaveAccessibleName('Discard');
49
- expect(destructive).toHaveAttribute('data-variant', 'destructive');
50
- expect(safe).toHaveAccessibleName('Keep editing');
51
- expect(safe).toHaveAttribute('data-variant', 'secondary');
52
- for (const button of [destructive, safe]) {
53
- expect(button).toHaveAttribute('data-size', 'lg');
54
- expect(getComputedStyle(button).width).toBe('100%');
55
- }
56
- expect(getComputedStyle(safe.parentElement!).flexDirection).toBe('column');
57
- expect(safe).toHaveFocus();
58
- });
59
-
60
- it('reports action and safe dismissal independently without committing on cancel', () => {
61
- const {dialog, onAction, onOpenChange} = setup();
62
- fireEvent.click(within(dialog).getByRole('button', {name: 'Keep editing'}));
63
- expect(onOpenChange).toHaveBeenCalledWith(false);
64
- expect(onAction).not.toHaveBeenCalled();
65
- fireEvent.click(within(dialog).getByRole('button', {name: 'Discard'}));
66
- expect(onAction).toHaveBeenCalledTimes(1);
67
- });
68
- });
@@ -1,97 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- 'use client';
4
-
5
- /**
6
- * @file MobileSheetConfirmation.tsx
7
- * @input Public Core Dialog, layout, text and action primitives; caller-owned copy
8
- * @output Private confirmation shared by MobileTokenizer and MobilePowerSearch
9
- * @position Lab-only mobile sheet confirmation owner; not a public export
10
- *
11
- * Core AlertDialog fixes its purpose to form and its safe action to ghost.
12
- * These Lab flows instead allow scrim dismissal and use separated, full-width
13
- * large actions with the secondary safe action focused first. Keep that
14
- * difference here, while preserving the existing alert-dialog theme target.
15
- */
16
-
17
- import {useId} from 'react';
18
- import * as stylex from '@stylexjs/stylex';
19
- import {Button} from '@astryxdesign/core/Button';
20
- import {Dialog} from '@astryxdesign/core/Dialog';
21
- import {Heading} from '@astryxdesign/core/Heading';
22
- import {Layout, LayoutContent, LayoutFooter} from '@astryxdesign/core/Layout';
23
- import {Stack} from '@astryxdesign/core/Stack';
24
- import {Text} from '@astryxdesign/core/Text';
25
- import {themeProps} from '@astryxdesign/core/utils';
26
-
27
- interface MobileSheetConfirmationProps {
28
- isOpen: boolean;
29
- onOpenChange: (isOpen: boolean) => void;
30
- title: string;
31
- description: string;
32
- cancelLabel: string;
33
- actionLabel: string;
34
- onAction: () => void;
35
- }
36
-
37
- const styles = stylex.create({
38
- action: {width: '100%'},
39
- });
40
-
41
- export function MobileSheetConfirmation({
42
- isOpen,
43
- onOpenChange,
44
- title,
45
- description,
46
- cancelLabel,
47
- actionLabel,
48
- onAction,
49
- }: MobileSheetConfirmationProps) {
50
- const titleId = useId();
51
- const descriptionId = useId();
52
- return (
53
- <Dialog
54
- {...themeProps('alert-dialog')}
55
- isOpen={isOpen}
56
- onOpenChange={onOpenChange}
57
- purpose="info"
58
- role="alertdialog"
59
- aria-labelledby={titleId}
60
- aria-describedby={descriptionId}
61
- width={400}>
62
- <Layout
63
- content={
64
- <LayoutContent>
65
- <Heading level={2} id={titleId}>
66
- {title}
67
- </Heading>
68
- <Text type="body" color="secondary" id={descriptionId}>
69
- {description}
70
- </Text>
71
- </LayoutContent>
72
- }
73
- footer={
74
- <LayoutFooter>
75
- <Stack direction="vertical" gap={4} hAlign="stretch">
76
- <Button
77
- label={actionLabel}
78
- variant="destructive"
79
- size="lg"
80
- onClick={onAction}
81
- xstyle={styles.action}
82
- />
83
- <Button
84
- label={cancelLabel}
85
- variant="secondary"
86
- size="lg"
87
- onClick={() => onOpenChange(false)}
88
- data-autofocus
89
- xstyle={styles.action}
90
- />
91
- </Stack>
92
- </LayoutFooter>
93
- }
94
- />
95
- </Dialog>
96
- );
97
- }
@@ -1,94 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- import {useRef} from 'react';
4
- import {fireEvent, render, screen} from '@testing-library/react';
5
- import userEvent from '@testing-library/user-event';
6
- import {describe, expect, it, vi} from 'vitest';
7
- import {useDismissSearchOnTouchDrag} from './useDismissSearchOnTouchDrag';
8
-
9
- function Harness({hasSecondSearch = false}: {hasSecondSearch?: boolean}) {
10
- const firstRef = useRef<HTMLInputElement>(null);
11
- const secondRef = useRef<HTMLInputElement>(null);
12
- const dragProps = useDismissSearchOnTouchDrag(() => [
13
- firstRef.current,
14
- secondRef.current,
15
- ]);
16
- return (
17
- <>
18
- <input ref={firstRef} aria-label="First search" />
19
- {hasSecondSearch && <input ref={secondRef} aria-label="Second search" />}
20
- <input aria-label="Unrelated field" />
21
- <div role="group" aria-label="Results" {...dragProps} />
22
- </>
23
- );
24
- }
25
-
26
- const touches = (clientY: number) => [{clientY}];
27
-
28
- function setup() {
29
- render(<Harness hasSecondSearch />);
30
- const search = screen.getByRole('textbox', {name: 'First search'});
31
- const results = screen.getByRole('group', {name: 'Results'});
32
- search.focus();
33
- return {search, results};
34
- }
35
-
36
- describe('useDismissSearchOnTouchDrag', () => {
37
- it('preserves typing across programmatic result scrolling', async () => {
38
- const {search, results} = setup();
39
- fireEvent.scroll(results, {target: {scrollTop: 300}});
40
- await userEvent.setup().type(search, 'Eng');
41
- fireEvent.scroll(results, {target: {scrollTop: 0}});
42
- expect(search).toHaveFocus();
43
- expect(search).toHaveValue('Eng');
44
- });
45
-
46
- it('blurs a focused owned search once at the direct vertical drag threshold', () => {
47
- const {search, results} = setup();
48
- const blur = vi.spyOn(search, 'blur');
49
- fireEvent.touchStart(results, {touches: touches(100)});
50
- fireEvent.touchMove(results, {touches: touches(107)});
51
- expect(search).toHaveFocus();
52
- fireEvent.touchMove(results, {touches: touches(108)});
53
- expect(search).not.toHaveFocus();
54
- fireEvent.touchMove(results, {touches: touches(120)});
55
- expect(blur).toHaveBeenCalledTimes(1);
56
- });
57
-
58
- it('does not blur on an unowned touch move, tap, or horizontal motion', () => {
59
- const {search, results} = setup();
60
- fireEvent.touchMove(results, {touches: touches(120)});
61
- expect(search).toHaveFocus();
62
- fireEvent.touchStart(results, {touches: [{clientY: 100, clientX: 0}]});
63
- fireEvent.touchMove(results, {touches: [{clientY: 100, clientX: 100}]});
64
- fireEvent.touchEnd(results);
65
- fireEvent.touchMove(results, {touches: touches(120)});
66
- expect(search).toHaveFocus();
67
- });
68
-
69
- it('resets ownership on cancellation and multi-touch without dismissing', () => {
70
- const {search, results} = setup();
71
- fireEvent.touchStart(results, {touches: touches(100)});
72
- fireEvent.touchCancel(results);
73
- fireEvent.touchMove(results, {touches: touches(120)});
74
- expect(search).toHaveFocus();
75
- fireEvent.touchStart(results, {touches: touches(100)});
76
- fireEvent.touchMove(results, {touches: [{clientY: 120}, {clientY: 140}]});
77
- fireEvent.touchMove(results, {touches: touches(150)});
78
- expect(search).toHaveFocus();
79
- });
80
-
81
- it('can dismiss either current owned input, but never an unrelated field', () => {
82
- const {results} = setup();
83
- const secondSearch = screen.getByRole('textbox', {name: 'Second search'});
84
- secondSearch.focus();
85
- fireEvent.touchStart(results, {touches: touches(100)});
86
- fireEvent.touchMove(results, {touches: touches(90)});
87
- expect(secondSearch).not.toHaveFocus();
88
- const unrelated = screen.getByRole('textbox', {name: 'Unrelated field'});
89
- unrelated.focus();
90
- fireEvent.touchStart(results, {touches: touches(100)});
91
- fireEvent.touchMove(results, {touches: touches(120)});
92
- expect(unrelated).toHaveFocus();
93
- });
94
- });
@@ -1,57 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- 'use client';
4
-
5
- /**
6
- * @file useDismissSearchOnTouchDrag.ts
7
- * @input Caller-owned search inputs and touch events from a separate results list
8
- * @output Private touch handlers shared by the Lab mobile search sheets
9
- * @position One owner for results-drag keyboard dismissal; not a public export
10
- *
11
- * A direct vertical touch drag dismisses only a focused caller-owned search.
12
- * Scroll events are deliberately not observed: filtering can clamp scrollTop
13
- * and fire scroll while someone is typing. Small moves, taps and multi-touch
14
- * gestures leave focus alone. The hook acquires no external resource.
15
- */
16
-
17
- import {useRef, type TouchEvent} from 'react';
18
-
19
- const DRAG_THRESHOLD_PX = 8;
20
-
21
- type SearchInput = HTMLInputElement | null | undefined;
22
-
23
- export function useDismissSearchOnTouchDrag(
24
- getSearchInputs: () => readonly SearchInput[],
25
- ) {
26
- const startYRef = useRef<number | null>(null);
27
- const reset = () => {
28
- startYRef.current = null;
29
- };
30
-
31
- return {
32
- onTouchStart: (event: TouchEvent<HTMLElement>) => {
33
- startYRef.current =
34
- event.touches.length === 1 ? event.touches[0].clientY : null;
35
- },
36
- onTouchMove: (event: TouchEvent<HTMLElement>) => {
37
- if (event.touches.length !== 1) {
38
- reset();
39
- return;
40
- }
41
- const startY = startYRef.current;
42
- if (
43
- startY == null ||
44
- Math.abs(event.touches[0].clientY - startY) < DRAG_THRESHOLD_PX
45
- ) {
46
- return;
47
- }
48
- reset();
49
- const focusedSearch = getSearchInputs().find(
50
- input => input != null && input === document.activeElement,
51
- );
52
- focusedSearch?.blur();
53
- },
54
- onTouchEnd: reset,
55
- onTouchCancel: reset,
56
- };
57
- }