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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ where the error points.
| A `ReactElement`-valued prop renders fine in the visual suite's **Remote** environment but kills a real remote connection with **`DataCloneError: Symbol(react.transitional.element) could not be cloned`** (the host then shows `Remote rendering failed: Timeout reached`) | The prop was generated as a **remote property**, and properties travel through `postMessage`/structured clone, which cannot carry a React element. `remote-dom-react` turns an element-valued prop into a slotted child _only_ when the element declares it as a slot (`Element.remoteSlotDefinitions.has(prop)`), and the generator declares a slot only for props typed exactly `ReactNode` or listed in `@flr-slot-props` (`dev/remote-components-generator/lib/propClassifiers.ts`) | Add `@flr-slot-props <propA>, <propB>` to the component's JSDoc and regenerate — the prop then arrives as a slotted child and any React subtree works, including a raw `<svg>` or a Tabler icon. Verify in `apps/remote-dom-demo` over the iframe connection: the visual suite's Remote environment is **in-process** and never serializes, so it cannot see this class of bug |
| `vitest run --update <file>` rewrites **every** baseline instead of the one file's, and `git status` shows snapshots you never touched | `--update` takes an optional value, so it swallows the positional test filter that follows it. The run then matches all files, updates all of them, and reports the full test count (354, not 2) — the only signal that anything went wrong | Put the filter **before** the flag (`vitest run <file> --update`) or pin the flag's value (`--update=true <file>`). Check `git status` after any `--update` and revert baselines outside your change; a stray one is indistinguishable from an intentional update once committed |
| A baseline for a component you never touched changes in your PR, or a scenario starts failing right after an unrelated PR merged with `update-screenshots` | The `update-screenshots` label runs `pnpm test:visual --update` over the **whole** suite and then `git add -A` — it is not scoped to the PR's diff. Any scenario that is failing or flaky at that moment gets whatever renders then committed as its new truth | Only apply the label when the suite is otherwise green, and read the resulting commit's file list before merging. #2945 (a CodeBlock change) took a tooltip-less frame of a racy `Tooltip` scenario this way and committed it as the `firefox-linux` baseline, contradicting the three beside it and keeping the scheduled run red in both environments (#2985) |
| A browser test that drags the mouse selects only the first character — `Dragging across "…" selected "e" instead of "example.com"` — and passes when the file runs on its own | Every parallel test file shares one page and therefore **one** Playwright cursor. Another file's `click` releases the held button mid-drag, so only the first move extends the selection. Retrying inside the command does not help — the interference comes in bursts that outlast the retries | Put mouse-driven tests in `packages/components/src/tests/mouse/**`; the `browser-mouse` project runs them in a page of their own, one file at a time. Only a native drag creates a real text selection, so `Selection`/`Range` in a synthetic test is no substitute: it ignores `user-select` and would not have caught the missing WebKit prefix |
| Hand-edited `MIGRATION.md` reverts on the next build, or CI fails "Check all generated code is committed" | `MIGRATION.md` is generated from `packages/codemods/src/migrations/<id>/entry.md` | Edit the catalogue entry, run `pnpm nx build codemods`, commit both |

## Where to look next
Expand Down
8 changes: 8 additions & 0 deletions packages/components/dev/vitest/vitest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { type Locator } from "vitest/browser";
declare module "vitest/browser" {
interface BrowserCommands {
setReducedMotion: (value: string) => Promise<void>;
selectTextByDragging: (
selector: string,
overshoot?: number,
) => Promise<void>;
dragMouse: (
from: { x: number; y: number },
to: { x: number; y: number },
) => Promise<void>;
}
interface LocatorSelectors {
getByLocator(locator: string): Locator;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { useIgnoreClickAfterDrag } from "@/lib/hooks/useIgnoreClickAfterDrag";
import { mergeRefs } from "@react-aria/utils";
import type { FC, ReactNode, Ref } from "react";
import * as Aria from "react-aria-components";
import type { FC, ReactNode } from "react";

export type GridListProps = Aria.GridListProps<never> & {
tileMaxWidth: number;
emptyView?: ReactNode;
ref?: Ref<HTMLDivElement>;
};

/** @flr-generate all */
export const GridList: FC<GridListProps> = (props) => {
const { tileMaxWidth, emptyView, ...rest } = props;
const { tileMaxWidth, emptyView, ref, ...rest } = props;
const dragRef = useIgnoreClickAfterDrag<HTMLDivElement>();

return (
<Aria.GridList
{...rest}
ref={mergeRefs(ref, dragRef)}
renderEmptyState={() => emptyView}
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${tileMaxWidth}px, 1fr))`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ declare const classNames: {
readonly topContent: "topContent";
readonly bottomContent: "bottomContent";
readonly heading: "heading";
readonly title: "title";
readonly header: "header";
readonly subTitle: "subTitle";
readonly text: "text";
readonly badge: "badge";
readonly listView: "listView";
readonly columnLayout: "columnLayout";
readonly contentWrapper: "contentWrapper";
readonly title: "title";
readonly action: "action";
readonly avatar: "avatar";
readonly "flow--heading--heading-content": "flow--heading--heading-content";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@
overflow-wrap: anywhere;
}

.title {
user-select: text;
padding-inline: var(--size-px--xs);
margin-inline: calc(var(--size-px--xs) * -1);
}

.header {
user-select: text;
overflow-wrap: anywhere;
display: flex;
gap: var(--list-item--avatar-to-title-spacing);
Expand Down Expand Up @@ -211,7 +218,9 @@

&:has(.checkboxContainer:not(:empty)) {
:global(.flow--heading--heading-content) {
inset-inline-start: calc(var(--list-item--padding) * 2 + var(--icon--size--m));
inset-inline-start: calc(
var(--list-item--padding) * 2 + var(--icon--size--m)
);
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/components/src/components/Table/Table.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@
}
}

/*
* React Aria sets `user-select: none` on the row while pressing; a child with
* its own value does not inherit it.
*/
.cell {
user-select: text;
}

.header {
.column {
border-block-end-width: var(--table--header-border-width);
Expand Down
4 changes: 3 additions & 1 deletion packages/components/src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useIgnoreClickAfterDrag } from "@/lib/hooks/useIgnoreClickAfterDrag";
import type { FC } from "react";
import * as Aria from "react-aria-components";
import clsx from "clsx";
Expand Down Expand Up @@ -33,6 +34,7 @@ export const Table: FC<TableProps> = (props) => {
minWidth,
...rest
} = props;
const dragRef = useIgnoreClickAfterDrag<HTMLDivElement>();

const rootClassName = clsx(
styles.table,
Expand All @@ -42,7 +44,7 @@ export const Table: FC<TableProps> = (props) => {
);

return (
<div className={styles.tableContainer}>
<div className={styles.tableContainer} ref={dragRef}>
<div className={styles.tableScrollArea} style={{ minWidth }}>
<Aria.Table className={rootClassName} {...rest}>
{children}
Expand Down
100 changes: 100 additions & 0 deletions packages/components/src/lib/hooks/useIgnoreClickAfterDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useRef } from "react";

/*
* Far enough that no ordinary click reaches it — a click is not meant to be
* pixel-perfect — and short enough to catch a deliberate drag.
*/
const dragThreshold = 10;

const interactiveElements =
'a[href], button, input, select, textarea, [contenteditable], [role="button"]';

const hasTextSelectionWithin = (element: Element) => {
const selection = element.ownerDocument.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
return element.contains(selection.getRangeAt(0).commonAncestorContainer);
};

/**
* Returns a ref for the element containing pressable items. React Aria triggers
* an item's press from its click event, so the click that ends a mouse drag is
* swallowed in the capture phase — otherwise selecting an item's text would
* activate the item as well, and a drag that missed the text would navigate
* away instead of doing nothing.
*
* A click that changed the text selection is swallowed regardless of distance,
* which covers selecting a single character and selecting by long press on
* touch. A drag that started on an interactive child is left alone, as is any
* click that arrives without a pointer interaction — keyboard, screen reader,
* `element.click()`.
*/
export const useIgnoreClickAfterDrag = <
T extends HTMLElement = HTMLElement,
>() => {
const ref = useRef<T>(null);

useEffect(() => {
const element = ref.current;
if (!element) {
return;
}

const ownerDocument = element.ownerDocument;
let pointerDownPosition: { x: number; y: number } | undefined;
let selectionChanged = false;
let isDrag = false;

const onSelectionChange = () => {
selectionChanged = true;
};

const onPointerDown = (event: PointerEvent) => {
selectionChanged = false;
isDrag = false;
pointerDownPosition =
event.pointerType === "mouse" &&
event.target instanceof Element &&
!event.target.closest(interactiveElements)
? { x: event.clientX, y: event.clientY }
: undefined;
};

const onPointerUp = (event: PointerEvent) => {
isDrag =
!!pointerDownPosition &&
Math.hypot(
event.clientX - pointerDownPosition.x,
event.clientY - pointerDownPosition.y,
) > dragThreshold;
pointerDownPosition = undefined;
};

const onClick = (event: MouseEvent) => {
const changedSelection = selectionChanged;
const draggedFar = isDrag;
selectionChanged = false;
isDrag = false;

if (draggedFar || (changedSelection && hasTextSelectionWithin(element))) {
event.preventDefault();
event.stopPropagation();
}
};

ownerDocument.addEventListener("selectionchange", onSelectionChange);
element.addEventListener("pointerdown", onPointerDown, true);
element.addEventListener("pointerup", onPointerUp, true);
element.addEventListener("click", onClick, true);

return () => {
ownerDocument.removeEventListener("selectionchange", onSelectionChange);
element.removeEventListener("pointerdown", onPointerDown, true);
element.removeEventListener("pointerup", onPointerUp, true);
element.removeEventListener("click", onClick, true);
};
}, []);

return ref;
};
Loading
Loading