@astryxdesign/core 0.4.2 → 0.4.3
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/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +6 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +71 -30
- 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 +96 -35
- 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/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/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 +6 -4
- package/dist/astryx.umd.js +51 -51
- package/dist/astryx.umd.js.map +4 -4
- package/dist/theme/defineTheme.d.ts +8 -3
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- 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 +2 -2
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +9 -7
- package/src/Banner/Banner.test.tsx +68 -0
- package/src/Banner/Banner.tsx +97 -36
- 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 +153 -32
- package/src/ComplexSelector/index.ts +3 -1
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- 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/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/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +48 -51
- 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
|
@@ -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,15 +29,17 @@ 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,
|
|
@@ -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);
|
|
@@ -319,8 +441,8 @@ export function ComplexSelector<Value>({
|
|
|
319
441
|
|
|
320
442
|
const content = (
|
|
321
443
|
<div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
|
|
322
|
-
{children(optimisticValue, commitValue,
|
|
323
|
-
isOpen
|
|
444
|
+
{children(optimisticValue, commitValue, close, {
|
|
445
|
+
isOpen,
|
|
324
446
|
isBusy,
|
|
325
447
|
triggerId,
|
|
326
448
|
contentId,
|
|
@@ -334,13 +456,10 @@ export function ComplexSelector<Value>({
|
|
|
334
456
|
ref={popover.triggerRef}
|
|
335
457
|
data-testid={testId}
|
|
336
458
|
{...props}
|
|
337
|
-
onClick={composeEventHandlers(onClickProp,
|
|
338
|
-
if (!isDisabled) {
|
|
339
|
-
popover.toggle();
|
|
340
|
-
}
|
|
341
|
-
})}
|
|
459
|
+
onClick={composeEventHandlers(onClickProp, handleTriggerClick)}
|
|
342
460
|
{...mergeProps(
|
|
343
461
|
themeProps('complex-selector', {
|
|
462
|
+
variant,
|
|
344
463
|
size,
|
|
345
464
|
status: status?.type ?? null,
|
|
346
465
|
}),
|
|
@@ -348,12 +467,11 @@ export function ComplexSelector<Value>({
|
|
|
348
467
|
inputWrapperStyles.base,
|
|
349
468
|
styles.triggerContainer,
|
|
350
469
|
styles[size],
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// pointer users too. `focusWithin` here is `:has(:focus-visible)`.
|
|
355
|
-
focusOutlineStyles.focusWithin,
|
|
470
|
+
variant === 'input' && focusOutlineStyles.focusWithin,
|
|
471
|
+
variant === 'ghost' && styles.triggerGhost,
|
|
472
|
+
variant === 'ghost' && focusOutlineStyles.focusWithin,
|
|
356
473
|
isDisabled && inputWrapperStyles.disabled,
|
|
474
|
+
variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
|
|
357
475
|
isDisabled && styles.disabled,
|
|
358
476
|
triggerLabel == null && styles.placeholder,
|
|
359
477
|
xstyle,
|
|
@@ -361,11 +479,14 @@ export function ComplexSelector<Value>({
|
|
|
361
479
|
className,
|
|
362
480
|
style,
|
|
363
481
|
)}>
|
|
482
|
+
{startIcon &&
|
|
483
|
+
renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
|
|
364
484
|
<button
|
|
485
|
+
ref={triggerRef}
|
|
365
486
|
id={triggerId}
|
|
366
487
|
type="button"
|
|
367
488
|
aria-haspopup="dialog"
|
|
368
|
-
aria-expanded={
|
|
489
|
+
aria-expanded={isOpen}
|
|
369
490
|
aria-controls={contentId}
|
|
370
491
|
aria-describedby={ariaDescribedBy}
|
|
371
492
|
aria-labelledby={labelId}
|
|
@@ -374,7 +495,7 @@ export function ComplexSelector<Value>({
|
|
|
374
495
|
aria-busy={isBusy || undefined}
|
|
375
496
|
disabled={isDisabled}
|
|
376
497
|
onKeyDown={event => {
|
|
377
|
-
if (event.key === 'ArrowDown' && !
|
|
498
|
+
if (event.key === 'ArrowDown' && !isOpen && !isDisabled) {
|
|
378
499
|
event.preventDefault();
|
|
379
500
|
popover.show();
|
|
380
501
|
}
|
|
@@ -394,17 +515,17 @@ export function ComplexSelector<Value>({
|
|
|
394
515
|
xstyle={[
|
|
395
516
|
styles.triggerIcon,
|
|
396
517
|
styles.triggerIconRotation,
|
|
397
|
-
|
|
518
|
+
isOpen && styles.triggerIconOpen,
|
|
398
519
|
]}
|
|
399
520
|
{...themeProps('complex-selector-indicator-icon', {
|
|
400
|
-
state:
|
|
521
|
+
state: isOpen ? 'expanded' : 'collapsed',
|
|
401
522
|
})}
|
|
402
523
|
/>
|
|
403
524
|
</div>
|
|
404
525
|
|
|
405
526
|
{popover.render(content, {
|
|
406
527
|
placement,
|
|
407
|
-
alignment
|
|
528
|
+
alignment,
|
|
408
529
|
offset: spacingVars['--spacing-1'],
|
|
409
530
|
xstyle: [styles.popover, layerAnimations[placement]],
|
|
410
531
|
})}
|
|
@@ -431,7 +552,7 @@ export function ComplexSelector<Value>({
|
|
|
431
552
|
}
|
|
432
553
|
: undefined
|
|
433
554
|
}
|
|
434
|
-
statusVariant={
|
|
555
|
+
statusVariant={effectiveStatusVariant}
|
|
435
556
|
labelTooltip={labelTooltip}
|
|
436
557
|
width={width}>
|
|
437
558
|
{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',
|