Skip to content
Open
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
27 changes: 27 additions & 0 deletions .changeset/selector-chevron-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@astryxdesign/core': patch
---

[feat] Selector, MultiSelector, ComplexSelector: let the trigger opt out of the chevron

The chevron is a sibling of the trigger button, after the optional clear
button, so a selector with `hasClear` and a value showed both a `×` and a
chevron in the same slot. StyleX has no descendant selectors and
`stylex.when.*` only reads upward, so an `xstyle` on the field could not reach
the icon — there was no supported way to drop it.

`hasChevron` turns it off. It defaults to `true`, so existing selectors render
identically. Only the chevron goes: a status glyph shares that slot and still
appears, and because the chevron is decorative (`aria-hidden`) and sits outside
the button, the accessible name, focus order, and keyboard behaviour are
untouched.

The name matches `DropdownMenu`'s existing `hasChevron`, and avoids colliding
with `indicatorPosition`, which on Selector and MultiSelector already means the
selected mark inside an option row.

```tsx
<Selector hasClear hasChevron={false} value={value} onChange={setValue} />
```

@ernestt
31 changes: 31 additions & 0 deletions packages/core/src/ComplexSelector/ComplexSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ export const docs = {
type: 'ReactNode | IconType',
description: 'Icon displayed at the start of the trigger.',
},
{
name: 'hasChevron',
type: 'boolean',
description:
'Shows the chevron at the end of the trigger. Set false when the trigger content already reads as "this opens something", or when the composed content supplies its own end affordance. The chevron is decorative (aria-hidden) and sits outside the trigger button, so the accessible name, focus order, and keyboard behaviour are unchanged.',
default: 'true',
},
{
name: 'width',
type: 'SizeValue',
Expand Down Expand Up @@ -202,6 +209,28 @@ export const docs = {
},
],
},
examples: [
{
label: 'Trigger without the chevron',
code: `
// ComplexSelector has no built-in clear button, so the end slot holds only
// the chevron. Drop it when the composed content puts its own affordance
// there, or when the trigger already reads as openable on its own.
<ComplexSelector
label="Date range"
value={range}
onChange={setRange}
triggerLabel={formatRange(range)}
variant="ghost"
startIcon="calendar"
hasChevron={false}>
{(value, onChange, close) => (
<RangeCalendar value={value} onChange={onChange} onDone={close} />
)}
</ComplexSelector>
`,
},
],
};

export const docsDense = {
Expand Down Expand Up @@ -271,6 +300,8 @@ export const docsDense = {
triggerLabel: 'Closed trigger label/content.',
variant: 'input for forms; ghost for toolbar triggers.',
startIcon: 'Leading trigger icon.',
hasChevron:
'false => drop the trigger chevron when the trigger supplies its own end affordance. Chevron is aria-hidden and outside the button, so name/focus/keyboard are unchanged. Defaults to true.',
placement: 'Popup placement.',
alignment: 'Popup alignment.',
handleRef: 'Imperative open/close/toggle handle.',
Expand Down
94 changes: 94 additions & 0 deletions packages/core/src/ComplexSelector/ComplexSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,97 @@ describe('ComplexSelector popup theme target', () => {
).not.toBeNull();
});
});

describe('ComplexSelector hasChevron', () => {
const chevron = (container: HTMLElement) =>
container.querySelector('.astryx-complex-selector-indicator-icon');

it('renders the chevron by default', () => {
const {container} = render(
<ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
{() => <div>Options</div>}
</ComplexSelector>,
);
expect(chevron(container)).not.toBeNull();
});

it('drops the chevron when hasChevron is false', () => {
const {container} = render(
<ComplexSelector
label="Fruit blend"
value="Apple"
triggerLabel="Apple"
hasChevron={false}>
{() => <div>Options</div>}
</ComplexSelector>,
);
expect(chevron(container)).toBeNull();
});

it('keeps the trigger accessible name and the tab stop unchanged', async () => {
const user = userEvent.setup();

const {unmount} = render(
<ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
{() => <div>Options</div>}
</ComplexSelector>,
);
const before = screen.getByRole('button', {name: 'Fruit blend'});
expect(before).toHaveAccessibleName('Fruit blend');
const beforeText = before.textContent;
unmount();

const {container} = render(
<ComplexSelector
label="Fruit blend"
value="Apple"
triggerLabel="Apple"
hasChevron={false}>
{() => <div>Options</div>}
</ComplexSelector>,
);
expect(chevron(container)).toBeNull();
const trigger = screen.getByRole('button', {name: 'Fruit blend'});
expect(trigger).toHaveAccessibleName('Fruit blend');
expect(trigger.textContent).toBe(beforeText);

// The chevron was never a tab stop — it is a decorative sibling of the
// button, not a control — so the trigger is still the single stop.
await user.tab();
expect(trigger).toHaveFocus();
});

it('is decorative — the chevron is aria-hidden and outside the trigger button', () => {
const {container} = render(
<ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
{() => <div>Options</div>}
</ComplexSelector>,
);
const icon = chevron(container);
expect(icon).toHaveAttribute('aria-hidden', 'true');
expect(
screen.getByRole('button', {name: 'Fruit blend'}).contains(icon),
).toBe(false);
});

it('still opens the popup with the chevron off', async () => {
const user = userEvent.setup();
const {container} = render(
<ComplexSelector
label="Fruit blend"
value="Apple"
triggerLabel="Apple"
hasChevron={false}>
{() => <button type="button">Done</button>}
</ComplexSelector>,
);
expect(chevron(container)).toBeNull();
const trigger = screen.getByRole('button', {name: 'Fruit blend'});
await user.click(trigger);

expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(
screen.getByRole('button', {name: 'Done', ...h}),
).toBeInTheDocument();
});
});
49 changes: 32 additions & 17 deletions packages/core/src/ComplexSelector/ComplexSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,18 @@ export interface ComplexSelectorProps<Value> extends Omit<
variant?: ComplexSelectorVariant;
/** Icon displayed at the start of the trigger. */
startIcon?: ReactNode | IconType;
/**
* Whether to show the chevron at the end of the trigger. Set false when the
* trigger's own content already reads as "this opens something" — an
* affordance the product supplies itself, for instance.
*
* The chevron is decorative (`aria-hidden`) and sits outside the trigger
* button, so dropping it leaves the accessible name, the focus order, and
* the keyboard behaviour untouched.
*
* @default true
*/
hasChevron?: boolean;
/** Width of the field. */
width?: SizeValue;
/** Popup placement. */
Expand Down Expand Up @@ -333,6 +345,7 @@ export function ComplexSelector<Value>({
size = 'md',
variant = 'input',
startIcon,
hasChevron = true,
width,
placement = 'below',
alignment = 'start',
Expand Down Expand Up @@ -514,23 +527,25 @@ export function ComplexSelector<Value>({
<span {...stylex.props(styles.triggerText)}>{triggerContent}</span>
</button>
{isBusy && <Spinner size="sm" />}
<Icon
icon="chevronDown"
size="sm"
color="secondary"
// No wrapper: Icon's own span already provides the 16px box (`sm`)
// and the secondary icon color the wrapper used to set, so the glyph
// IS the trigger's icon element — one node carrying the box, the
// color, the rotation, and the theme target.
xstyle={[
styles.triggerIcon,
styles.triggerIconRotation,
isOpen && styles.triggerIconOpen,
]}
{...themeProps('complex-selector-indicator-icon', {
state: isOpen ? 'expanded' : 'collapsed',
})}
/>
{hasChevron && (
<Icon
icon="chevronDown"
size="sm"
color="secondary"
// No wrapper: Icon's own span already provides the 16px box (`sm`)
// and the secondary icon color the wrapper used to set, so the glyph
// IS the trigger's icon element — one node carrying the box, the
// color, the rotation, and the theme target.
xstyle={[
styles.triggerIcon,
styles.triggerIconRotation,
isOpen && styles.triggerIconOpen,
]}
{...themeProps('complex-selector-indicator-icon', {
state: isOpen ? 'expanded' : 'collapsed',
})}
/>
)}
</div>

{popover.render(content, {
Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/MultiSelector/MultiSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export const docs = {
visualProps: ['variant', 'size', 'status'],
states: ['disabled'],
},
{className: 'astryx-multi-selector-clear-icon', deprecatedFor: 'input-clear-icon'},
{
className: 'astryx-multi-selector-clear-icon',
deprecatedFor: 'input-clear-icon',
},
{className: 'astryx-multi-selector-empty-state'},
{className: 'astryx-multi-selector-search'},
{className: 'astryx-multi-selector-section-heading'},
Expand Down Expand Up @@ -127,6 +130,13 @@ export const docs = {
description:
'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region. The search field has built-in affordances: a leading magnifier icon and, once a query is typed, a trailing clear (✕) button that resets the query and returns focus to the input.',
},
{
name: 'hasChevron',
type: 'boolean',
description:
'Shows the chevron at the end of the trigger. Set false to let the clear button stand alone in that slot — with hasClear, a selector that has values otherwise shows both a × and a chevron. Only the chevron is dropped: a status glyph shares the slot and still appears, and since the chevron is decorative (aria-hidden) and sits outside the trigger button, the accessible name, focus order, and keyboard behaviour are unchanged.',
default: 'true',
},
{
name: 'searchPlaceholder',
type: 'string',
Expand Down Expand Up @@ -265,6 +275,24 @@ export const docs = {
},
],
},
examples: [
{
label: 'Let the clear button replace the chevron',
code: `
// With hasClear alone, a selector that has values shows both a × and a
// chevron in the end slot. hasChevron={false} leaves the × on its own, so
// the one affordance in that slot is the one the user can act on.
<MultiSelector
label="Tags"
options={tags}
value={selectedTags}
onChange={setSelectedTags}
hasClear
hasChevron={false}
/>
`,
},
],
};

/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
Expand All @@ -289,6 +317,8 @@ export const docsZh = {
hasSelectAll: '是否显示全选复选框。',
selectAllLabel: '全选复选框的标签。',
hasSearch: '是否显示用于过滤选项的搜索输入。',
hasChevron:
'是否在触发器末尾显示折叠箭头。设为 false 可让清除按钮独占该位置——配合 hasClear 时,已有选中值的选择器否则会同时显示 × 和箭头。仅去掉箭头:状态图标共用该位置且不受影响;箭头是装饰性的(aria-hidden)且位于触发按钮之外,因此无障碍名称、焦点顺序与键盘行为均不变。',
searchPlaceholder: '搜索输入的占位文本。',
isDisabled: '禁用选择器。',
htmlName:
Expand Down Expand Up @@ -424,6 +454,8 @@ export const docsDense = {
hasSelectAll: 'show select-all checkbox',
selectAllLabel: 'select-all label',
hasSearch: 'show search input',
hasChevron:
"false => drop the trigger chevron so hasClear's × owns the end slot alone. Status glyph unaffected; chevron is aria-hidden and outside the button, so name/focus/keyboard are unchanged. Defaults to true.",
searchPlaceholder: 'search placeholder',
isDisabled: 'disables selector',
htmlName: 'HTML name attr; one hidden input per selected value.',
Expand Down
Loading
Loading