@astryxdesign/core 0.4.2 → 0.4.3-canary.551a8de
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.
- package/CHANGELOG.md +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/astryx.css +8 -4
- package/dist/theme/defineTheme.d.ts +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/package.json +4 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/index.ts +2 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -2,17 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file ComplexSelector.test.tsx
|
|
5
|
-
* @input Uses vitest, Testing Library, user-event
|
|
6
|
-
* @output Unit tests for
|
|
7
|
-
* @position Tests; validates
|
|
5
|
+
* @input Uses vitest, Testing Library, user-event, and ComplexSelector
|
|
6
|
+
* @output Unit tests for selection, trigger variants, positioning, and the imperative handle
|
|
7
|
+
* @position Tests; validates the ComplexSelector public interaction contract
|
|
8
8
|
*
|
|
9
9
|
* SYNC: When ComplexSelector.tsx API changes, update these tests.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
|
14
|
+
import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
14
15
|
import userEvent from '@testing-library/user-event';
|
|
15
|
-
import {ComplexSelector} from './ComplexSelector';
|
|
16
|
+
import {ComplexSelector, type ComplexSelectorHandle} from './ComplexSelector';
|
|
17
|
+
|
|
18
|
+
const originalMatches = HTMLElement.prototype.matches;
|
|
19
|
+
|
|
20
|
+
// Mock the Popover API, which jsdom does not implement.
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
23
|
+
this.setAttribute('popover-open', '');
|
|
24
|
+
const event = new Event('toggle');
|
|
25
|
+
Object.defineProperty(event, 'newState', {value: 'open'});
|
|
26
|
+
this.dispatchEvent(event);
|
|
27
|
+
});
|
|
28
|
+
HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
|
|
29
|
+
this.removeAttribute('popover-open');
|
|
30
|
+
const event = new Event('toggle');
|
|
31
|
+
Object.defineProperty(event, 'newState', {value: 'closed'});
|
|
32
|
+
this.dispatchEvent(event);
|
|
33
|
+
});
|
|
34
|
+
Object.defineProperty(HTMLElement.prototype, 'matches', {
|
|
35
|
+
configurable: true,
|
|
36
|
+
value: function (this: HTMLElement, selector: string): boolean {
|
|
37
|
+
if (selector === ':popover-open') {
|
|
38
|
+
return this.hasAttribute('popover-open');
|
|
39
|
+
}
|
|
40
|
+
return originalMatches.call(this, selector);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
16
44
|
|
|
17
45
|
type FruitValue = {
|
|
18
46
|
fruit: 'Apple' | 'Banana';
|
|
@@ -83,6 +111,35 @@ function FruitComplexSelector({
|
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
describe('ComplexSelector', () => {
|
|
114
|
+
it('defaults to md and reflects explicit trigger sizes', () => {
|
|
115
|
+
const {container, rerender} = render(
|
|
116
|
+
<ComplexSelector label="Fruit blend" value="Apple">
|
|
117
|
+
{() => <div>Options</div>}
|
|
118
|
+
</ComplexSelector>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const getSelector = () =>
|
|
122
|
+
container.querySelector('.astryx-complex-selector');
|
|
123
|
+
|
|
124
|
+
expect(getSelector()).toHaveAttribute('data-size', 'md');
|
|
125
|
+
|
|
126
|
+
rerender(
|
|
127
|
+
<ComplexSelector label="Fruit blend" value="Apple" size="sm">
|
|
128
|
+
{() => <div>Options</div>}
|
|
129
|
+
</ComplexSelector>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(getSelector()).toHaveAttribute('data-size', 'sm');
|
|
133
|
+
|
|
134
|
+
rerender(
|
|
135
|
+
<ComplexSelector label="Fruit blend" value="Apple" size="lg">
|
|
136
|
+
{() => <div>Options</div>}
|
|
137
|
+
</ComplexSelector>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(getSelector()).toHaveAttribute('data-size', 'lg');
|
|
141
|
+
});
|
|
142
|
+
|
|
86
143
|
it('renders custom content with value and commits through onChange', async () => {
|
|
87
144
|
const user = userEvent.setup();
|
|
88
145
|
const onChange = vi.fn();
|
|
@@ -174,6 +231,140 @@ describe('ComplexSelector', () => {
|
|
|
174
231
|
await user.click(screen.getByRole('button', {name: 'Done', ...h}));
|
|
175
232
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
176
233
|
});
|
|
234
|
+
|
|
235
|
+
it('renders a ghost toolbar trigger with a start icon', () => {
|
|
236
|
+
const {container} = render(
|
|
237
|
+
<ComplexSelector
|
|
238
|
+
label="View options"
|
|
239
|
+
value={['name']}
|
|
240
|
+
variant="ghost"
|
|
241
|
+
startIcon="viewColumns"
|
|
242
|
+
status={{type: 'warning', message: 'Unsaved changes'}}
|
|
243
|
+
data-testid="view-options">
|
|
244
|
+
{() => <div>Columns</div>}
|
|
245
|
+
</ComplexSelector>,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(container.querySelector('.astryx-complex-selector')).toHaveAttribute(
|
|
249
|
+
'data-variant',
|
|
250
|
+
'ghost',
|
|
251
|
+
);
|
|
252
|
+
expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
|
|
253
|
+
'data-variant',
|
|
254
|
+
'detached',
|
|
255
|
+
);
|
|
256
|
+
expect(
|
|
257
|
+
screen.getByTestId('view-options').querySelectorAll('svg'),
|
|
258
|
+
).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('supports end-aligned popup positioning', () => {
|
|
262
|
+
render(
|
|
263
|
+
<ComplexSelector label="View options" value={[]} alignment="end">
|
|
264
|
+
{() => <div>Columns</div>}
|
|
265
|
+
</ComplexSelector>,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const popover = screen
|
|
269
|
+
.getByRole('dialog', {hidden: true})
|
|
270
|
+
.closest('[popover]');
|
|
271
|
+
expect(popover?.getAttribute('style')).toContain(
|
|
272
|
+
'position-area: self-block-end span-self-inline-start',
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('exposes imperative open, close, toggle, and isOpen via handleRef', async () => {
|
|
277
|
+
const handleRef = React.createRef<ComplexSelectorHandle>();
|
|
278
|
+
render(
|
|
279
|
+
<ComplexSelector label="View options" value={[]} handleRef={handleRef}>
|
|
280
|
+
{() => <button type="button">Apply</button>}
|
|
281
|
+
</ComplexSelector>,
|
|
282
|
+
);
|
|
283
|
+
const trigger = screen.getByRole('button', {name: 'View options'});
|
|
284
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
285
|
+
expect(handleRef.current?.isOpen()).toBe(false);
|
|
286
|
+
|
|
287
|
+
act(() => {
|
|
288
|
+
handleRef.current?.open();
|
|
289
|
+
});
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
292
|
+
});
|
|
293
|
+
expect(handleRef.current?.isOpen()).toBe(true);
|
|
294
|
+
|
|
295
|
+
act(() => {
|
|
296
|
+
handleRef.current?.close();
|
|
297
|
+
});
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
300
|
+
});
|
|
301
|
+
expect(handleRef.current?.isOpen()).toBe(false);
|
|
302
|
+
|
|
303
|
+
act(() => {
|
|
304
|
+
handleRef.current?.toggle();
|
|
305
|
+
});
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
act(() => {
|
|
311
|
+
handleRef.current?.toggle();
|
|
312
|
+
});
|
|
313
|
+
await waitFor(() => {
|
|
314
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('does not open via the imperative handle when disabled', async () => {
|
|
319
|
+
const handleRef = React.createRef<ComplexSelectorHandle>();
|
|
320
|
+
render(
|
|
321
|
+
<ComplexSelector
|
|
322
|
+
label="View options"
|
|
323
|
+
value={[]}
|
|
324
|
+
isDisabled
|
|
325
|
+
handleRef={handleRef}>
|
|
326
|
+
{() => <button type="button">Apply</button>}
|
|
327
|
+
</ComplexSelector>,
|
|
328
|
+
);
|
|
329
|
+
const trigger = screen.getByRole('button', {name: 'View options'});
|
|
330
|
+
|
|
331
|
+
act(() => {
|
|
332
|
+
handleRef.current?.open();
|
|
333
|
+
});
|
|
334
|
+
act(() => {
|
|
335
|
+
handleRef.current?.toggle();
|
|
336
|
+
});
|
|
337
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
338
|
+
expect(handleRef.current?.isOpen()).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('does not reopen from the trigger click that follows light dismiss', async () => {
|
|
342
|
+
const user = userEvent.setup();
|
|
343
|
+
render(
|
|
344
|
+
<ComplexSelector label="View options" value={[]}>
|
|
345
|
+
{() => <button type="button">Apply</button>}
|
|
346
|
+
</ComplexSelector>,
|
|
347
|
+
);
|
|
348
|
+
const trigger = screen.getByRole('button', {name: 'View options'});
|
|
349
|
+
|
|
350
|
+
await user.click(trigger);
|
|
351
|
+
const popover = screen
|
|
352
|
+
.getByRole('dialog', {hidden: true})
|
|
353
|
+
.closest('[popover]');
|
|
354
|
+
expect(popover).not.toBeNull();
|
|
355
|
+
|
|
356
|
+
const closeEvent = new Event('toggle');
|
|
357
|
+
Object.defineProperty(closeEvent, 'newState', {value: 'closed'});
|
|
358
|
+
fireEvent(popover as HTMLElement, closeEvent);
|
|
359
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
360
|
+
|
|
361
|
+
const showCallCount = vi.mocked(HTMLElement.prototype.showPopover).mock
|
|
362
|
+
.calls.length;
|
|
363
|
+
fireEvent.click(trigger);
|
|
364
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(
|
|
365
|
+
showCallCount,
|
|
366
|
+
);
|
|
367
|
+
});
|
|
177
368
|
});
|
|
178
369
|
|
|
179
370
|
describe('ComplexSelector popup theme target', () => {
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file ComplexSelector.tsx
|
|
7
|
-
* @input Uses React, StyleX, Field, usePopover
|
|
8
|
-
* @output Exports
|
|
7
|
+
* @input Uses React, StyleX, Field, Icon slots, Layer positioning, and usePopover
|
|
8
|
+
* @output Exports a rich-selector shell with exact token-sized input and ghost triggers, plus an imperative open/close handle
|
|
9
9
|
* @position Core implementation; consumed by index.ts
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update:
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* - /packages/core/src/ComplexSelector/ComplexSelector.test.tsx
|
|
14
14
|
* - /packages/core/src/ComplexSelector/index.ts
|
|
15
15
|
* - /apps/storybook/stories/ComplexSelector.stories.tsx
|
|
16
|
+
* - /packages/cli/assets/templates/blocks/components/ComplexSelector/ (showcase blocks)
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import React, {
|
|
19
20
|
useCallback,
|
|
20
21
|
useId,
|
|
22
|
+
useImperativeHandle,
|
|
21
23
|
useOptimistic,
|
|
24
|
+
useRef,
|
|
22
25
|
useTransition,
|
|
23
26
|
type ReactNode,
|
|
24
27
|
} from 'react';
|
|
@@ -26,22 +29,24 @@ import * as stylex from '@stylexjs/stylex';
|
|
|
26
29
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
27
30
|
import type {BaseProps} from '../BaseProps';
|
|
28
31
|
import {Field, inputWrapperStyles, type FieldStatusVariant} from '../Field';
|
|
29
|
-
import {Icon} from '../Icon';
|
|
32
|
+
import {Icon, renderIconSlot, type IconType} from '../Icon';
|
|
30
33
|
import {Spinner} from '../Spinner';
|
|
31
34
|
import {useTranslator} from '../i18n';
|
|
32
35
|
import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
36
|
+
import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
|
|
33
37
|
import {usePopover} from '../Popover/usePopover';
|
|
34
38
|
import {
|
|
35
39
|
colorVars,
|
|
36
40
|
durationVars,
|
|
37
41
|
easeVars,
|
|
42
|
+
fontWeightVars,
|
|
38
43
|
radiusVars,
|
|
39
44
|
sizeVars,
|
|
40
45
|
spacingVars,
|
|
41
46
|
typographyVars,
|
|
42
47
|
typeScaleVars,
|
|
43
48
|
} from '../theme/tokens.stylex';
|
|
44
|
-
import {mergeProps} from '../utils';
|
|
49
|
+
import {isRenderable, mergeProps} from '../utils';
|
|
45
50
|
import {composeEventHandlers} from '../utils/composeEventHandlers';
|
|
46
51
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
47
52
|
import type {SizeValue} from '../utils/types';
|
|
@@ -99,6 +104,39 @@ const styles = stylex.create({
|
|
|
99
104
|
placeholder: {
|
|
100
105
|
color: colorVars['--color-text-secondary'],
|
|
101
106
|
},
|
|
107
|
+
triggerGhost: {
|
|
108
|
+
width: 'auto',
|
|
109
|
+
borderWidth: 0,
|
|
110
|
+
backgroundColor: 'transparent',
|
|
111
|
+
backgroundImage: {
|
|
112
|
+
default: null,
|
|
113
|
+
':hover': {
|
|
114
|
+
'@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`,
|
|
115
|
+
},
|
|
116
|
+
':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
|
|
117
|
+
},
|
|
118
|
+
boxShadow: {
|
|
119
|
+
default: 'none',
|
|
120
|
+
':hover:not(:focus-within)': {
|
|
121
|
+
'@media (hover: hover)': 'none',
|
|
122
|
+
},
|
|
123
|
+
':focus-within': 'none',
|
|
124
|
+
},
|
|
125
|
+
fontWeight: fontWeightVars['--font-weight-medium'],
|
|
126
|
+
transitionProperty:
|
|
127
|
+
'background-image, background-color, color, opacity, transform',
|
|
128
|
+
transform: {
|
|
129
|
+
default: 'scale(1)',
|
|
130
|
+
':active': 'scale(0.98)',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
triggerGhostDisabled: {
|
|
134
|
+
backgroundImage: 'none',
|
|
135
|
+
transform: {
|
|
136
|
+
default: 'none',
|
|
137
|
+
':active': 'none',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
102
140
|
// Only what Icon does not already provide: `sm` gives the 16px box and
|
|
103
141
|
// `color="secondary"` the color, but the glyph still must not shrink inside
|
|
104
142
|
// the flex trigger.
|
|
@@ -129,19 +167,21 @@ const styles = stylex.create({
|
|
|
129
167
|
padding: spacingVars['--spacing-3'],
|
|
130
168
|
},
|
|
131
169
|
sm: {
|
|
132
|
-
|
|
170
|
+
height: sizeVars['--size-element-sm'],
|
|
133
171
|
},
|
|
134
172
|
md: {
|
|
135
|
-
|
|
173
|
+
height: sizeVars['--size-element-md'],
|
|
136
174
|
},
|
|
137
175
|
lg: {
|
|
138
|
-
|
|
176
|
+
height: sizeVars['--size-element-lg'],
|
|
139
177
|
},
|
|
140
178
|
disabled: {
|
|
141
179
|
cursor: 'not-allowed',
|
|
142
180
|
},
|
|
143
181
|
});
|
|
144
182
|
|
|
183
|
+
export type ComplexSelectorVariant = 'input' | 'ghost';
|
|
184
|
+
|
|
145
185
|
export type ComplexSelectorSize = 'sm' | 'md' | 'lg';
|
|
146
186
|
|
|
147
187
|
export interface ComplexSelectorRenderState {
|
|
@@ -155,6 +195,25 @@ export interface ComplexSelectorRenderState {
|
|
|
155
195
|
contentId: string;
|
|
156
196
|
}
|
|
157
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Imperative control surface for ComplexSelector, accessed via the `handleRef`
|
|
200
|
+
* prop. Methods drive the same popover machinery as the built-in trigger, so
|
|
201
|
+
* they respect focus restoration, light dismiss, and Escape. Prefer these
|
|
202
|
+
* callbacks over mirroring open state in the parent — the selector owns its
|
|
203
|
+
* visibility, and imperative calls avoid the focus-management pitfalls of
|
|
204
|
+
* syncing an external `isOpen` prop.
|
|
205
|
+
*/
|
|
206
|
+
export interface ComplexSelectorHandle {
|
|
207
|
+
/** Open the selector surface. No-op when disabled or already open. */
|
|
208
|
+
open(): void;
|
|
209
|
+
/** Close the selector surface. Restores focus to the trigger. */
|
|
210
|
+
close(): void;
|
|
211
|
+
/** Toggle the selector surface open or closed. */
|
|
212
|
+
toggle(): void;
|
|
213
|
+
/** Whether the selector surface is currently open. Reads live state. */
|
|
214
|
+
isOpen(): boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
158
217
|
export interface ComplexSelectorStatus {
|
|
159
218
|
type: 'warning' | 'error' | 'success';
|
|
160
219
|
message?: string;
|
|
@@ -203,10 +262,22 @@ export interface ComplexSelectorProps<Value> extends Omit<
|
|
|
203
262
|
labelTooltip?: string;
|
|
204
263
|
/** Trigger and field size. */
|
|
205
264
|
size?: ComplexSelectorSize;
|
|
265
|
+
/** Visual trigger style. Ghost matches toolbar buttons. */
|
|
266
|
+
variant?: ComplexSelectorVariant;
|
|
267
|
+
/** Icon displayed at the start of the trigger. */
|
|
268
|
+
startIcon?: ReactNode | IconType;
|
|
206
269
|
/** Width of the field. */
|
|
207
270
|
width?: SizeValue;
|
|
208
271
|
/** Popup placement. */
|
|
209
|
-
placement?:
|
|
272
|
+
placement?: LayerPlacement;
|
|
273
|
+
/** Popup alignment along the placement axis. */
|
|
274
|
+
alignment?: LayerAlignment;
|
|
275
|
+
/**
|
|
276
|
+
* Imperative handle for programmatic open/close control. Exposes open,
|
|
277
|
+
* close, toggle, and the isOpen query. Use this instead of mirroring open
|
|
278
|
+
* state in the parent — the selector owns its visibility.
|
|
279
|
+
*/
|
|
280
|
+
handleRef?: React.Ref<ComplexSelectorHandle>;
|
|
210
281
|
/** StyleX styles for the popup content container. */
|
|
211
282
|
contentXstyle?: StyleXStyles;
|
|
212
283
|
/** Test ID for the trigger container. */
|
|
@@ -258,8 +329,12 @@ export function ComplexSelector<Value>({
|
|
|
258
329
|
statusVariant = 'attached',
|
|
259
330
|
labelTooltip,
|
|
260
331
|
size = 'md',
|
|
332
|
+
variant = 'input',
|
|
333
|
+
startIcon,
|
|
261
334
|
width,
|
|
262
335
|
placement = 'below',
|
|
336
|
+
alignment = 'start',
|
|
337
|
+
handleRef,
|
|
263
338
|
contentXstyle,
|
|
264
339
|
xstyle,
|
|
265
340
|
className,
|
|
@@ -270,6 +345,10 @@ export function ComplexSelector<Value>({
|
|
|
270
345
|
}: ComplexSelectorProps<Value>) {
|
|
271
346
|
const t = useTranslator();
|
|
272
347
|
const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
|
|
348
|
+
const effectiveStatusVariant =
|
|
349
|
+
variant === 'ghost' && statusVariant === 'attached'
|
|
350
|
+
? 'detached'
|
|
351
|
+
: statusVariant;
|
|
273
352
|
|
|
274
353
|
const triggerId = useId();
|
|
275
354
|
const labelId = useId();
|
|
@@ -284,24 +363,67 @@ export function ComplexSelector<Value>({
|
|
|
284
363
|
.filter((id): id is string => id != null)
|
|
285
364
|
.join(' ') || undefined;
|
|
286
365
|
|
|
366
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
367
|
+
const lastHideTimeRef = useRef(0);
|
|
368
|
+
|
|
287
369
|
const [isPending, startTransition] = useTransition();
|
|
288
370
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
289
371
|
const isBusy = isLoading || isPending;
|
|
290
372
|
|
|
373
|
+
const handlePopoverHide = useCallback(() => {
|
|
374
|
+
lastHideTimeRef.current = Date.now();
|
|
375
|
+
triggerRef.current?.focus();
|
|
376
|
+
}, []);
|
|
377
|
+
|
|
291
378
|
const popover = usePopover({
|
|
292
379
|
dialogLabel: label,
|
|
293
380
|
hasCloseButton: false,
|
|
294
381
|
hasAutoFocus: true,
|
|
295
|
-
// The popup's theme target belongs on the SURFACE — the element painting
|
|
296
|
-
// background, radius and elevation — which `usePopover` owns. Rendered on
|
|
297
|
-
// the content box below it, a theme's background or radius rule paints the
|
|
298
|
-
// wrong box.
|
|
299
382
|
surfaceTarget: 'complex-selector-popup',
|
|
300
|
-
onHide:
|
|
301
|
-
document.getElementById(triggerId)?.focus();
|
|
302
|
-
},
|
|
383
|
+
onHide: handlePopoverHide,
|
|
303
384
|
});
|
|
304
385
|
|
|
386
|
+
const isOpen = popover.isOpen;
|
|
387
|
+
|
|
388
|
+
const handleTriggerClick = useCallback(() => {
|
|
389
|
+
if (isDisabled || Date.now() - lastHideTimeRef.current < 50) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (popover.isOpen) {
|
|
393
|
+
popover.hide();
|
|
394
|
+
} else {
|
|
395
|
+
popover.show();
|
|
396
|
+
}
|
|
397
|
+
}, [isDisabled, popover]);
|
|
398
|
+
|
|
399
|
+
const close = useCallback(() => {
|
|
400
|
+
popover.hide();
|
|
401
|
+
}, [popover]);
|
|
402
|
+
|
|
403
|
+
useImperativeHandle(
|
|
404
|
+
handleRef,
|
|
405
|
+
() => ({
|
|
406
|
+
open: () => {
|
|
407
|
+
if (!isDisabled) {
|
|
408
|
+
popover.show();
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
close: () => popover.hide(),
|
|
412
|
+
toggle: () => {
|
|
413
|
+
if (isDisabled) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (popover.isOpen) {
|
|
417
|
+
popover.hide();
|
|
418
|
+
} else {
|
|
419
|
+
popover.show();
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
isOpen: () => popover.isOpen,
|
|
423
|
+
}),
|
|
424
|
+
[isDisabled, popover],
|
|
425
|
+
);
|
|
426
|
+
|
|
305
427
|
const commitValue = useCallback(
|
|
306
428
|
(nextValue: Value) => {
|
|
307
429
|
onChange?.(nextValue);
|
|
@@ -317,10 +439,15 @@ export function ComplexSelector<Value>({
|
|
|
317
439
|
|
|
318
440
|
const triggerContent = triggerLabel ?? placeholder;
|
|
319
441
|
|
|
442
|
+
const startIconSlot = renderIconSlot(startIcon, {
|
|
443
|
+
size: 'sm',
|
|
444
|
+
color: 'secondary',
|
|
445
|
+
});
|
|
446
|
+
|
|
320
447
|
const content = (
|
|
321
448
|
<div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
|
|
322
|
-
{children(optimisticValue, commitValue,
|
|
323
|
-
isOpen
|
|
449
|
+
{children(optimisticValue, commitValue, close, {
|
|
450
|
+
isOpen,
|
|
324
451
|
isBusy,
|
|
325
452
|
triggerId,
|
|
326
453
|
contentId,
|
|
@@ -334,13 +461,10 @@ export function ComplexSelector<Value>({
|
|
|
334
461
|
ref={popover.triggerRef}
|
|
335
462
|
data-testid={testId}
|
|
336
463
|
{...props}
|
|
337
|
-
onClick={composeEventHandlers(onClickProp,
|
|
338
|
-
if (!isDisabled) {
|
|
339
|
-
popover.toggle();
|
|
340
|
-
}
|
|
341
|
-
})}
|
|
464
|
+
onClick={composeEventHandlers(onClickProp, handleTriggerClick)}
|
|
342
465
|
{...mergeProps(
|
|
343
466
|
themeProps('complex-selector', {
|
|
467
|
+
variant,
|
|
344
468
|
size,
|
|
345
469
|
status: status?.type ?? null,
|
|
346
470
|
}),
|
|
@@ -353,7 +477,9 @@ export function ComplexSelector<Value>({
|
|
|
353
477
|
// matched a mouse click on the trigger and drew the outline for
|
|
354
478
|
// pointer users too. `focusWithin` here is `:has(:focus-visible)`.
|
|
355
479
|
focusOutlineStyles.focusWithin,
|
|
480
|
+
variant === 'ghost' && styles.triggerGhost,
|
|
356
481
|
isDisabled && inputWrapperStyles.disabled,
|
|
482
|
+
variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
|
|
357
483
|
isDisabled && styles.disabled,
|
|
358
484
|
triggerLabel == null && styles.placeholder,
|
|
359
485
|
xstyle,
|
|
@@ -361,11 +487,13 @@ export function ComplexSelector<Value>({
|
|
|
361
487
|
className,
|
|
362
488
|
style,
|
|
363
489
|
)}>
|
|
490
|
+
{isRenderable(startIconSlot) && startIconSlot}
|
|
364
491
|
<button
|
|
492
|
+
ref={triggerRef}
|
|
365
493
|
id={triggerId}
|
|
366
494
|
type="button"
|
|
367
495
|
aria-haspopup="dialog"
|
|
368
|
-
aria-expanded={
|
|
496
|
+
aria-expanded={isOpen}
|
|
369
497
|
aria-controls={contentId}
|
|
370
498
|
aria-describedby={ariaDescribedBy}
|
|
371
499
|
aria-labelledby={labelId}
|
|
@@ -374,7 +502,7 @@ export function ComplexSelector<Value>({
|
|
|
374
502
|
aria-busy={isBusy || undefined}
|
|
375
503
|
disabled={isDisabled}
|
|
376
504
|
onKeyDown={event => {
|
|
377
|
-
if (event.key === 'ArrowDown' && !
|
|
505
|
+
if (event.key === 'ArrowDown' && !isOpen && !isDisabled) {
|
|
378
506
|
event.preventDefault();
|
|
379
507
|
popover.show();
|
|
380
508
|
}
|
|
@@ -394,17 +522,17 @@ export function ComplexSelector<Value>({
|
|
|
394
522
|
xstyle={[
|
|
395
523
|
styles.triggerIcon,
|
|
396
524
|
styles.triggerIconRotation,
|
|
397
|
-
|
|
525
|
+
isOpen && styles.triggerIconOpen,
|
|
398
526
|
]}
|
|
399
527
|
{...themeProps('complex-selector-indicator-icon', {
|
|
400
|
-
state:
|
|
528
|
+
state: isOpen ? 'expanded' : 'collapsed',
|
|
401
529
|
})}
|
|
402
530
|
/>
|
|
403
531
|
</div>
|
|
404
532
|
|
|
405
533
|
{popover.render(content, {
|
|
406
534
|
placement,
|
|
407
|
-
alignment
|
|
535
|
+
alignment,
|
|
408
536
|
offset: spacingVars['--spacing-1'],
|
|
409
537
|
xstyle: [styles.popover, layerAnimations[placement]],
|
|
410
538
|
})}
|
|
@@ -431,7 +559,7 @@ export function ComplexSelector<Value>({
|
|
|
431
559
|
}
|
|
432
560
|
: undefined
|
|
433
561
|
}
|
|
434
|
-
statusVariant={
|
|
562
|
+
statusVariant={effectiveStatusVariant}
|
|
435
563
|
labelTooltip={labelTooltip}
|
|
436
564
|
width={width}>
|
|
437
565
|
{selectorContent}
|
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file index.ts
|
|
7
|
-
* @output Exports ComplexSelector and types
|
|
7
|
+
* @output Exports ComplexSelector and its trigger-size contract types
|
|
8
8
|
* @position Public API entry point
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export {
|
|
12
12
|
ComplexSelector,
|
|
13
|
+
type ComplexSelectorHandle,
|
|
13
14
|
type ComplexSelectorProps,
|
|
14
15
|
type ComplexSelectorRenderState,
|
|
15
16
|
type ComplexSelectorSize,
|
|
16
17
|
type ComplexSelectorStatus,
|
|
18
|
+
type ComplexSelectorVariant,
|
|
17
19
|
} from './ComplexSelector';
|
|
@@ -51,6 +51,48 @@ describe('parseInline', () => {
|
|
|
51
51
|
expect(result).toEqual([{type: 'image', src: 'img.png', alt: 'alt'}]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('rejects javascript: links as plain text (XSS prevention)', () => {
|
|
55
|
+
const result = parseInline('[click](javascript:alert(1))');
|
|
56
|
+
// Should be emitted as plain text, NOT as a link node
|
|
57
|
+
expect(result).toEqual([
|
|
58
|
+
{type: 'text', content: '[click](javascript:alert(1))'},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects javascript: with mixed case and whitespace (XSS prevention)', () => {
|
|
63
|
+
const result = parseInline('[click](JaVaScRiPt:alert(1))');
|
|
64
|
+
expect(result).toEqual([
|
|
65
|
+
{type: 'text', content: '[click](JaVaScRiPt:alert(1))'},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects vbscript: links (XSS prevention)', () => {
|
|
70
|
+
const result = parseInline('[click](vbscript:MsgBox(1))');
|
|
71
|
+
expect(result).toEqual([
|
|
72
|
+
{type: 'text', content: '[click](vbscript:MsgBox(1))'},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects data:text/html image src (XSS prevention)', () => {
|
|
77
|
+
const result = parseInline(
|
|
78
|
+
'</script>)',
|
|
79
|
+
);
|
|
80
|
+
expect(result).toEqual([
|
|
81
|
+
{
|
|
82
|
+
type: 'text',
|
|
83
|
+
content: '</script>)',
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('allows normal http/https links', () => {
|
|
89
|
+
const result = parseInline('[safe](https://example.com)');
|
|
90
|
+
expect(result[0].type).toBe('link');
|
|
91
|
+
if (result[0].type === 'link') {
|
|
92
|
+
expect(result[0].href).toBe('https://example.com');
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
54
96
|
it('parses strikethrough', () => {
|
|
55
97
|
const result = parseInline('~~deleted~~');
|
|
56
98
|
expect(result[0].type).toBe('strikethrough');
|
|
@@ -727,6 +769,17 @@ describe('citation parsing', () => {
|
|
|
727
769
|
});
|
|
728
770
|
});
|
|
729
771
|
|
|
772
|
+
it('rejects <javascript:...> angle-bracket autolinks (XSS prevention)', () => {
|
|
773
|
+
const result = parseInline('see <javascript:alert(1)> ok', {
|
|
774
|
+
autolink: 'gfm',
|
|
775
|
+
});
|
|
776
|
+
// Should NOT produce a link node with javascript: href
|
|
777
|
+
const linkNodes = result.filter(
|
|
778
|
+
(n): n is Extract<typeof n, {type: 'link'}> => n.type === 'link',
|
|
779
|
+
);
|
|
780
|
+
expect(linkNodes).toHaveLength(0);
|
|
781
|
+
});
|
|
782
|
+
|
|
730
783
|
it('parses <email> angle-bracket autolinks', () => {
|
|
731
784
|
const result = parseInline('mail <user@example.com> please', {
|
|
732
785
|
autolink: 'gfm',
|