@astryxdesign/lab 0.7.0-canary.4ec1f4e → 0.7.0-canary.61639ee

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.
@@ -278,7 +278,7 @@ export function MobileTokenizer({
278
278
  setQuery(next);
279
279
  onChangeQuery?.(next);
280
280
  },
281
- xstyle: styles.filterInput
281
+ controlXstyle: styles.filterInput
282
282
  })
283
283
  }), /*#__PURE__*/_jsxs("div", {
284
284
  ...{
@@ -1 +1 @@
1
- {"version":3,"file":"Tour.d.ts","sourceRoot":"","sources":["../../src/Tour/Tour.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAA4C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAChF,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,SAAS;IACxB;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC/C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,IAAI,CAAC,EACnB,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,WAAmB,EACnB,gBAAwB,GACzB,EAAE,SAAS,+BAuEX;yBA7Ee,IAAI"}
1
+ {"version":3,"file":"Tour.d.ts","sourceRoot":"","sources":["../../src/Tour/Tour.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AACrE,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,SAAS;IACxB;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC/C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,IAAI,CAAC,EACnB,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,WAAmB,EACnB,gBAAwB,GACzB,EAAE,SAAS,+BA0EX;yBAhFe,IAAI"}
package/dist/Tour/Tour.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior)
29
29
  * - /packages/lab/src/Tour/index.ts (exports if types change)
30
30
  */
31
- import { useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { useCallback, useMemo, useState } from 'react';
32
32
  import { TourContext } from "./TourContext.js";
33
33
  import { jsx as _jsx } from "react/jsx-runtime";
34
34
  /**
@@ -68,13 +68,16 @@ export function Tour({
68
68
  };
69
69
  }, []);
70
70
 
71
- // Reset to the first step whenever the tour is (re)started, so a dismissed
72
- // tour begins from the top next time isActive flips back on.
73
- useEffect(() => {
71
+ // Reset to the first step when the tour is deactivated, so a dismissed tour
72
+ // begins from the top next time isActive flips back on. Adjusted during
73
+ // render, so no commit carries the old step past deactivation.
74
+ const [wasActive, setWasActive] = useState(isActive);
75
+ if (!Object.is(wasActive, isActive)) {
76
+ setWasActive(isActive);
74
77
  if (!isActive) {
75
78
  setActiveStepIndex(0);
76
79
  }
77
- }, [isActive]);
80
+ }
78
81
  const stepCount = stepIds.length;
79
82
  const onNext = useCallback(() => {
80
83
  // Runs from a click/imperative call (never during render), so branch on the
@@ -839,7 +839,7 @@ export function TransferList({
839
839
  }),
840
840
  hasClear: true,
841
841
  width: "100%",
842
- xstyle: styles.search,
842
+ controlXstyle: styles.search,
843
843
  onChange: handleSearch
844
844
  })
845
845
  }), /*#__PURE__*/_jsx(VisuallyHidden, {
@@ -12,7 +12,7 @@
12
12
  import { type ReactNode } from 'react';
13
13
  import { type ComplexSelectorProps } from '@astryxdesign/core/ComplexSelector';
14
14
  import { type TransferListProps } from './TransferList';
15
- type TransferListSelectorShellProp = 'label' | 'triggerLabel' | 'isLabelHidden' | 'description' | 'isOptional' | 'isRequired' | 'isDisabled' | 'isLoading' | 'status' | 'statusVariant' | 'labelTooltip' | 'size' | 'width' | 'placement' | 'xstyle' | 'className' | 'style' | 'data-testid';
15
+ type TransferListSelectorShellProp = 'label' | 'triggerLabel' | 'isLabelHidden' | 'description' | 'isOptional' | 'isRequired' | 'isDisabled' | 'isLoading' | 'status' | 'statusVariant' | 'labelTooltip' | 'size' | 'width' | 'placement' | 'controlXstyle' | 'controlClassName' | 'controlStyle' | 'data-testid';
16
16
  type TransferListContentProp = 'options' | 'selectedLabel' | 'availableLabel' | 'hasSearch' | 'searchLabel' | 'searchPlaceholder' | 'isReorderable' | 'hasSelectAll' | 'hasClear' | 'renderOption' | 'selectedEmptyText' | 'availableEmptyText' | 'noResultsText';
17
17
  export type TransferListSelectorCommitBehavior = 'immediate' | 'staged';
18
18
  export type TransferListSelectorProps<T extends string = string> = Pick<ComplexSelectorProps<readonly T[]>, TransferListSelectorShellProp> & Pick<TransferListProps<T>, TransferListContentProp> & {
@@ -1 +1 @@
1
- {"version":3,"file":"TransferListSelector.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferListSelector.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAG/E,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAC;AAI5C,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAsDpE,KAAK,6BAA6B,GAC9B,OAAO,GACP,cAAc,GACd,eAAe,GACf,aAAa,GACb,YAAY,GACZ,YAAY,GACZ,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,eAAe,GACf,cAAc,GACd,MAAM,GACN,OAAO,GACP,WAAW,GACX,QAAQ,GACR,WAAW,GACX,OAAO,GACP,aAAa,CAAC;AAElB,KAAK,uBAAuB,GACxB,SAAS,GACT,eAAe,GACf,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,mBAAmB,GACnB,eAAe,GACf,cAAc,GACd,UAAU,GACV,cAAc,GACd,mBAAmB,GACnB,oBAAoB,GACpB,eAAe,CAAC;AAEpB,MAAM,MAAM,kCAAkC,GAAG,WAAW,GAAG,QAAQ,CAAC;AAExE,MAAM,MAAM,yBAAyB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,IAAI,CACrE,oBAAoB,CAAC,SAAS,CAAC,EAAE,CAAC,EAClC,6BAA6B,CAC9B,GACC,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,uBAAuB,CAAC,GAAG;IACpD,mDAAmD;IACnD,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,4CAA4C;IAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC;IACxC,8DAA8D;IAC9D,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,cAAc,CAAC,EAAE,kCAAkC,CAAC;IACpD,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AA+BJ;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EAC9D,KAAK,EACL,WAAW,EACX,OAAO,EACP,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,cAA4B,EAC5B,YAAY,EACZ,UAAU,EACV,SAAS,EACT,KAAK,EACL,UAAU,EAAE,mBAAmB,EAC/B,WAAW,EAAE,oBAAoB,EACjC,aAAa,EACb,cAAc,EACd,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,EACb,GAAG,aAAa,EACjB,EAAE,yBAAyB,CAAC,CAAC,CAAC,GAAG,SAAS,CAqI1C;yBAhKe,oBAAoB"}
1
+ {"version":3,"file":"TransferListSelector.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferListSelector.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAG/E,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAC;AAI5C,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAsDpE,KAAK,6BAA6B,GAC9B,OAAO,GACP,cAAc,GACd,eAAe,GACf,aAAa,GACb,YAAY,GACZ,YAAY,GACZ,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,eAAe,GACf,cAAc,GACd,MAAM,GACN,OAAO,GACP,WAAW,GACX,eAAe,GACf,kBAAkB,GAClB,cAAc,GACd,aAAa,CAAC;AAElB,KAAK,uBAAuB,GACxB,SAAS,GACT,eAAe,GACf,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,mBAAmB,GACnB,eAAe,GACf,cAAc,GACd,UAAU,GACV,cAAc,GACd,mBAAmB,GACnB,oBAAoB,GACpB,eAAe,CAAC;AAEpB,MAAM,MAAM,kCAAkC,GAAG,WAAW,GAAG,QAAQ,CAAC;AAExE,MAAM,MAAM,yBAAyB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,IAAI,CACrE,oBAAoB,CAAC,SAAS,CAAC,EAAE,CAAC,EAClC,6BAA6B,CAC9B,GACC,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,uBAAuB,CAAC,GAAG;IACpD,mDAAmD;IACnD,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,4CAA4C;IAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC;IACxC,8DAA8D;IAC9D,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,cAAc,CAAC,EAAE,kCAAkC,CAAC;IACpD,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AA+BJ;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EAC9D,KAAK,EACL,WAAW,EACX,OAAO,EACP,KAAK,EACL,QAAQ,EACR,YAAY,EACZ,cAA4B,EAC5B,YAAY,EACZ,UAAU,EACV,SAAS,EACT,KAAK,EACL,UAAU,EAAE,mBAAmB,EAC/B,WAAW,EAAE,oBAAoB,EACjC,aAAa,EACb,cAAc,EACd,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,EACb,GAAG,aAAa,EACjB,EAAE,yBAAyB,CAAC,CAAC,CAAC,GAAG,SAAS,CAkI1C;yBA7Je,oBAAoB"}
@@ -115,24 +115,23 @@ export function TransferListSelector({
115
115
  const applyLabel = applyLabelFromProps ?? t('@astryx.transferListSelector.apply');
116
116
  const cancelLabel = cancelLabelFromProps ?? t('@astryx.transferListSelector.cancel');
117
117
  const [draftValue, setDraftValue] = useState(value);
118
- const previousAppliedValueRef = useRef([...value]);
119
- const previousCommitBehaviorRef = useRef(commitBehavior);
120
118
  const resetDraft = useCallback(() => {
121
119
  setDraftValue([...value]);
122
120
  }, [value]);
123
- useEffect(() => {
124
- const previousAppliedValue = previousAppliedValueRef.current;
125
- previousAppliedValueRef.current = [...value];
126
- if (!areOrderedValuesEqual(previousAppliedValue, value)) {
127
- resetDraft();
128
- }
129
- }, [resetDraft, value]);
130
- useEffect(() => {
131
- if (previousCommitBehaviorRef.current !== commitBehavior) {
132
- resetDraft();
133
- }
134
- previousCommitBehaviorRef.current = commitBehavior;
135
- }, [commitBehavior, resetDraft]);
121
+
122
+ // A changed applied value or commit behavior discards the staged draft.
123
+ // Adjusted during render, so no commit paints the discarded draft.
124
+ const [draftSource, setDraftSource] = useState(() => ({
125
+ value: [...value],
126
+ commitBehavior
127
+ }));
128
+ if (!areOrderedValuesEqual(draftSource.value, value) || !Object.is(draftSource.commitBehavior, commitBehavior)) {
129
+ setDraftSource({
130
+ value: [...value],
131
+ commitBehavior
132
+ });
133
+ setDraftValue([...value]);
134
+ }
136
135
  const resolvedWidth = typeof width === 'number' ? `${width}px` : width ?? DEFAULT_SELECTOR_WIDTH;
137
136
  return /*#__PURE__*/_jsx(ComplexSelector, {
138
137
  ...selectorProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.7.0-canary.4ec1f4e",
3
+ "version": "0.7.0-canary.61639ee",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@stylexjs/stylex": ">=0.10.0",
50
- "@astryxdesign/core": "0.7.0-canary.4ec1f4e",
50
+ "@astryxdesign/core": "0.7.0-canary.61639ee",
51
51
  "react": ">=19.0.0",
52
52
  "react-dom": ">=19.0.0"
53
53
  },
@@ -57,8 +57,8 @@
57
57
  "d3-shape": "^3.2.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@astryxdesign/a11y-spec": "0.7.0-canary.4ec1f4e",
61
- "@astryxdesign/cli": "0.7.0-canary.4ec1f4e",
60
+ "@astryxdesign/a11y-spec": "0.7.0-canary.61639ee",
61
+ "@astryxdesign/cli": "0.7.0-canary.61639ee",
62
62
  "@babel/cli": "^7.29.7",
63
63
  "@babel/core": "^7.29.7",
64
64
  "@babel/preset-react": "^7.29.7",
@@ -417,7 +417,7 @@ export function MobileTokenizer<T extends SearchableItem>({
417
417
  setQuery(next);
418
418
  onChangeQuery?.(next);
419
419
  }}
420
- xstyle={styles.filterInput}
420
+ controlXstyle={styles.filterInput}
421
421
  />
422
422
  </div>
423
423
  <div
@@ -12,7 +12,7 @@
12
12
 
13
13
  import {describe, it, expect, vi, beforeAll} from 'vitest';
14
14
  import {render, screen, fireEvent} from '@testing-library/react';
15
- import {useRef, useState} from 'react';
15
+ import {Profiler, useRef, useState} from 'react';
16
16
  import {Tour} from './Tour';
17
17
  import {TourStep} from './TourStep';
18
18
  import {useTour} from './useTour';
@@ -259,3 +259,55 @@ describe('useTour', () => {
259
259
  expect(last.last).toBe(false);
260
260
  });
261
261
  });
262
+
263
+ describe('Tour reset timing', () => {
264
+ function StepProbe() {
265
+ const tour = useTour();
266
+ return <output data-testid="step-index">{tour?.activeStepIndex}</output>;
267
+ }
268
+
269
+ function Harness({isActive}: {isActive: boolean}) {
270
+ const aRef = useRef<HTMLButtonElement>(null);
271
+ const bRef = useRef<HTMLButtonElement>(null);
272
+ return (
273
+ <>
274
+ <button type="button" ref={aRef}>
275
+ Target A
276
+ </button>
277
+ <button type="button" ref={bRef}>
278
+ Target B
279
+ </button>
280
+ <Tour isActive={isActive} onDismiss={() => {}}>
281
+ <StepProbe />
282
+ <TourStep targetRef={aRef} heading="First">
283
+ First body
284
+ </TourStep>
285
+ <TourStep targetRef={bRef} heading="Second">
286
+ Second body
287
+ </TourStep>
288
+ </Tour>
289
+ </>
290
+ );
291
+ }
292
+
293
+ it('resets to the first step in the same commit that deactivates the tour', () => {
294
+ const frames: string[] = [];
295
+ const onRender = () =>
296
+ frames.push(screen.getByTestId('step-index').textContent ?? '');
297
+ const ui = (isActive: boolean) => (
298
+ <Profiler id="tour" onRender={onRender}>
299
+ <Harness isActive={isActive} />
300
+ </Profiler>
301
+ );
302
+ const {rerender} = render(ui(true));
303
+ fireEvent.click(screen.getByText('Next'));
304
+ expect(screen.getByTestId('step-index')).toHaveTextContent('1');
305
+
306
+ frames.length = 0;
307
+ rerender(ui(false));
308
+
309
+ expect(frames.length).toBeGreaterThan(0);
310
+ expect(frames).not.toContain('1');
311
+ expect(screen.getByTestId('step-index')).toHaveTextContent('0');
312
+ });
313
+ });
package/src/Tour/Tour.tsx CHANGED
@@ -29,7 +29,7 @@
29
29
  * - /packages/lab/src/Tour/index.ts (exports if types change)
30
30
  */
31
31
 
32
- import {useCallback, useEffect, useMemo, useState, type ReactNode} from 'react';
32
+ import {useCallback, useMemo, useState, type ReactNode} from 'react';
33
33
  import {
34
34
  TourContext,
35
35
  type TourDismissSource,
@@ -108,13 +108,16 @@ export function Tour({
108
108
  };
109
109
  }, []);
110
110
 
111
- // Reset to the first step whenever the tour is (re)started, so a dismissed
112
- // tour begins from the top next time isActive flips back on.
113
- useEffect(() => {
111
+ // Reset to the first step when the tour is deactivated, so a dismissed tour
112
+ // begins from the top next time isActive flips back on. Adjusted during
113
+ // render, so no commit carries the old step past deactivation.
114
+ const [wasActive, setWasActive] = useState(isActive);
115
+ if (!Object.is(wasActive, isActive)) {
116
+ setWasActive(isActive);
114
117
  if (!isActive) {
115
118
  setActiveStepIndex(0);
116
119
  }
117
- }, [isActive]);
120
+ }
118
121
 
119
122
  const stepCount = stepIds.length;
120
123
 
@@ -239,24 +239,26 @@ export const docs = {
239
239
  'Optional async action run after each immediate commit or a changed staged Apply; drives optimistic and busy state.',
240
240
  },
241
241
  {
242
- name: 'xstyle',
242
+ name: 'controlXstyle',
243
243
  type: 'StyleXStyles',
244
- description: 'StyleX styles for the external selector field.',
244
+ description:
245
+ "StyleX styles for the external selector's bordered control box.",
245
246
  },
246
247
  {
247
- name: 'className',
248
+ name: 'controlClassName',
248
249
  type: 'string',
249
- description: 'Class name applied to the external selector field.',
250
+ description: "Class name applied to the external selector's control box.",
250
251
  },
251
252
  {
252
- name: 'style',
253
+ name: 'controlStyle',
253
254
  type: 'CSSProperties',
254
- description: 'Inline styles applied to the external selector field.',
255
+ description:
256
+ "Inline styles applied to the external selector's control box.",
255
257
  },
256
258
  {
257
259
  name: 'data-testid',
258
260
  type: 'string',
259
- description: 'Test ID for the selector trigger container.',
261
+ description: 'Test ID for the selector trigger button.',
260
262
  },
261
263
  ],
262
264
  components: [
@@ -1216,7 +1216,7 @@ export function TransferList<T extends string = string>({
1216
1216
  startIcon={<Icon icon="search" size="sm" color="secondary" />}
1217
1217
  hasClear
1218
1218
  width="100%"
1219
- xstyle={styles.search}
1219
+ controlXstyle={styles.search}
1220
1220
  onChange={handleSearch}
1221
1221
  />
1222
1222
  </div>
@@ -9,7 +9,7 @@
9
9
  * SYNC: When TransferListSelector.tsx behavior changes, update this test.
10
10
  */
11
11
 
12
- import {useState} from 'react';
12
+ import {Profiler, useState} from 'react';
13
13
  import {afterAll, beforeEach, describe, expect, it, vi} from 'vitest';
14
14
  import {
15
15
  fireEvent,
@@ -557,10 +557,10 @@ describe('TransferListSelector', () => {
557
557
  expect(
558
558
  screen.getByText('Choose the columns shown in the table.'),
559
559
  ).toBeVisible();
560
- expect(screen.getByTestId('column-selector')).toHaveAttribute(
561
- 'data-size',
562
- 'sm',
563
- );
560
+ // data-testid lands on the trigger button inside the control box.
561
+ expect(
562
+ screen.getByTestId('column-selector').closest('.astryx-complex-selector'),
563
+ ).toHaveAttribute('data-size', 'sm');
564
564
  expect(
565
565
  screen.getByRole('button', {name: 'Table columns'}),
566
566
  ).toHaveTextContent('Customize columns');
@@ -603,3 +603,42 @@ describe('TransferListSelector', () => {
603
603
  );
604
604
  });
605
605
  });
606
+
607
+ describe('TransferListSelector draft reset timing', () => {
608
+ it('replaces a staged draft in the same commit as an external value change', async () => {
609
+ const user = userEvent.setup();
610
+ const frames: boolean[] = [];
611
+ const onRender = () =>
612
+ frames.push(
613
+ screen.queryByRole('button', {name: 'Remove Owner', ...hidden}) != null,
614
+ );
615
+ const ui = (value: string[]) => (
616
+ <Profiler id="selector" onRender={onRender}>
617
+ <TransferListSelector
618
+ label="Visible fields"
619
+ options={OPTIONS}
620
+ value={value}
621
+ onChange={() => {}}
622
+ commitBehavior="staged"
623
+ />
624
+ </Profiler>
625
+ );
626
+ const view = render(ui(['name', 'status']));
627
+ await openSelector();
628
+ await user.click(
629
+ screen.getByRole('button', {name: 'Add Owner', ...hidden}),
630
+ );
631
+ expect(
632
+ screen.getByRole('button', {name: 'Remove Owner', ...hidden}),
633
+ ).toBeInTheDocument();
634
+
635
+ frames.length = 0;
636
+ view.rerender(ui(['name']));
637
+
638
+ expect(frames.length).toBeGreaterThan(0);
639
+ expect(frames).not.toContain(true);
640
+ expect(
641
+ screen.getByRole('button', {name: 'Add Owner', ...hidden}),
642
+ ).toBeInTheDocument();
643
+ });
644
+ });
@@ -93,9 +93,9 @@ type TransferListSelectorShellProp =
93
93
  | 'size'
94
94
  | 'width'
95
95
  | 'placement'
96
- | 'xstyle'
97
- | 'className'
98
- | 'style'
96
+ | 'controlXstyle'
97
+ | 'controlClassName'
98
+ | 'controlStyle'
99
99
  | 'data-testid';
100
100
 
101
101
  type TransferListContentProp =
@@ -217,27 +217,24 @@ export function TransferListSelector<T extends string = string>({
217
217
  const cancelLabel =
218
218
  cancelLabelFromProps ?? t('@astryx.transferListSelector.cancel');
219
219
  const [draftValue, setDraftValue] = useState<readonly T[]>(value);
220
- const previousAppliedValueRef = useRef<readonly T[]>([...value]);
221
- const previousCommitBehaviorRef = useRef(commitBehavior);
222
220
 
223
221
  const resetDraft = useCallback(() => {
224
222
  setDraftValue([...value]);
225
223
  }, [value]);
226
224
 
227
- useEffect(() => {
228
- const previousAppliedValue = previousAppliedValueRef.current;
229
- previousAppliedValueRef.current = [...value];
230
- if (!areOrderedValuesEqual(previousAppliedValue, value)) {
231
- resetDraft();
232
- }
233
- }, [resetDraft, value]);
234
-
235
- useEffect(() => {
236
- if (previousCommitBehaviorRef.current !== commitBehavior) {
237
- resetDraft();
238
- }
239
- previousCommitBehaviorRef.current = commitBehavior;
240
- }, [commitBehavior, resetDraft]);
225
+ // A changed applied value or commit behavior discards the staged draft.
226
+ // Adjusted during render, so no commit paints the discarded draft.
227
+ const [draftSource, setDraftSource] = useState(() => ({
228
+ value: [...value],
229
+ commitBehavior,
230
+ }));
231
+ if (
232
+ !areOrderedValuesEqual(draftSource.value, value) ||
233
+ !Object.is(draftSource.commitBehavior, commitBehavior)
234
+ ) {
235
+ setDraftSource({value: [...value], commitBehavior});
236
+ setDraftValue([...value]);
237
+ }
241
238
 
242
239
  const resolvedWidth =
243
240
  typeof width === 'number'