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

[feat] Selector and MultiSelector: `indicatorPosition` places the selection indicator on either edge of the option row — `start` or `end`, logical, so it follows RTL. Defaults keep today's rendering (`end` for Selector's check, `start` for MultiSelector's checkbox); a start-positioned check reserves its column on every row so labels stay aligned.

@cixzhang
23 changes: 23 additions & 0 deletions apps/storybook/stories/MultiSelector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,3 +577,26 @@ export const ThemedIcons: Story = {
);
},
};

/**
* `indicatorPosition="end"` moves the checkbox to the trailing edge of each
* row. The default is `start`, where the checkbox leads the label as it does in
* CheckboxList.
*/
export const EndIndicatorPosition: Story = {
render: () => {
const [value, setValue] = useState<string[]>(['Name', 'Email']);
return (
// No hasSelectAll: its divider is an unallowed listbox child and fails
// the a11y audit as soon as a story opens the popup (#4994).
<MultiSelector
label="Columns"
options={['Name', 'Email', 'Role', 'Status']}
value={value}
onChange={setValue}
indicatorPosition="end"
isDefaultOpen
/>
);
},
};
24 changes: 24 additions & 0 deletions apps/storybook/stories/Selector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -890,3 +890,27 @@ export const DefaultSelectionIndicator: Story = {
);
},
};

/**
* `indicatorPosition="start"` moves the mark to the leading edge, the way a
* native menu marks its chosen row.
*
* The column is reserved on every row, not just the chosen one, so the labels
* stay on one line — the default check draws nothing when unchecked, and
* without the column only the chosen label would be indented.
*/
export const StartIndicatorPosition: Story = {
render: () => {
const [value, setValue] = useState<string | undefined>('Banana');
return (
<Selector
label="Mark at the start"
options={['Apple', 'Banana', 'Cherry']}
value={value}
onChange={setValue}
indicatorPosition="start"
isDefaultOpen
/>
);
},
};
1 change: 1 addition & 0 deletions packages/core/src/Indicator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type {
IndicatorMap,
IndicatorName,
IndicatorNameOfFamily,
IndicatorPosition,
IndicatorProps,
IndicatorRegistry,
IndicatorSize,
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/Indicator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ export type IndicatorState<F extends IndicatorFamily = IndicatorFamily> =
/** Indicator size scale — matches the control sizes of the owning inputs. */
export type IndicatorSize = 'sm' | 'md';

/**
* Which edge of its row an indicator sits on.
*
* Logical, not physical: `start` is the left edge in LTR and the right edge in
* RTL. Owned by the host component, not by the indicator — an indicator draws a
* picture and has no say in where the row puts it — which is why this is a prop
* on the components that lay out rows rather than part of
* {@link IndicatorProps}.
*/
export type IndicatorPosition = 'start' | 'end';

/**
* Props every indicator accepts.
*
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ export const docs = {
description:
'Custom render function for each selectable option in the dropdown. Not called for dividers, sections, or the select-all row.',
},
{
name: 'indicatorPosition',
type: "'start' | 'end'",
description:
'Which edge of the option row carries the checkbox. end pushes it to the far edge of the row, including on the select-all row.',
default: "'start'",
},
{
name: 'width',
type: 'SizeValue',
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2110,3 +2110,72 @@ describe('MultiSelector popup theme target', () => {
expect(popup.querySelector('[role="listbox"]')).not.toBeNull();
});
});

describe('MultiSelector indicatorPosition', () => {
const OPTIONS = ['Apple', 'Banana', 'Cherry'];
const rowFor = (label: string): HTMLElement =>
screen
.getAllByRole('option', {hidden: true})
.find(row => row.textContent?.includes(label))!;

it('draws the checkbox before the label by default', () => {
render(
<MultiSelector
label="Fruit"
options={OPTIONS}
value={['Banana']}
onChange={() => {}}
isDefaultOpen
/>,
);
const row = rowFor('Banana');
const checkbox = row.querySelector('.astryx-checkbox')!;
const label = row.lastElementChild!;
expect(label).toHaveTextContent('Banana');
expect(
label.compareDocumentPosition(checkbox) &
Node.DOCUMENT_POSITION_PRECEDING,
).toBeTruthy();
});

it('draws the checkbox after the label when set to end', () => {
render(
<MultiSelector
label="Fruit"
options={OPTIONS}
value={['Banana']}
onChange={() => {}}
indicatorPosition="end"
isDefaultOpen
/>,
);
const row = rowFor('Banana');
const checkbox = row.querySelector('.astryx-checkbox')!;
const label = row.firstElementChild!;
expect(label).toHaveTextContent('Banana');
expect(
label.compareDocumentPosition(checkbox) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});

it('keeps the select-all row on the same edge as the options', () => {
render(
<MultiSelector
label="Fruit"
options={OPTIONS}
value={['Banana']}
onChange={() => {}}
hasSelectAll
indicatorPosition="end"
isDefaultOpen
/>,
);
const row = rowFor('Select all');
expect(
row.firstElementChild!.compareDocumentPosition(
row.querySelector('.astryx-checkbox')!,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
48 changes: 38 additions & 10 deletions packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {Divider} from '../Divider';
import {Spinner} from '../Spinner';
import {TextInput} from '../TextInput';
import {CheckboxInput} from '../CheckboxInput';
import type {IndicatorPosition} from '../Indicator';
import {Badge} from '../Badge';
import {
colorVars,
Expand Down Expand Up @@ -320,6 +321,14 @@ const styles = stylex.create({
display: 'flex',
flexShrink: 0,
},
// Pushed to the row's far edge rather than sitting against the label, which
// is what an end-positioned control means here. The row is not
// `space-between` (a truncating label plus a trailing control is what wants
// the auto margin), and `renderOption` content is not wrapped in a growing
// span, so the margin has to live on the checkbox itself.
checkboxDecorativeEnd: {
marginInlineStart: 'auto',
},

// Label text for items (rendered outside checkbox for correct click
// behavior). Typography is inherited from the row; this only handles
Expand Down Expand Up @@ -607,6 +616,13 @@ export interface MultiSelectorProps<
*/
renderOption?: (option: MultiSelectorOptionData) => ReactNode;

/**
* Which edge of the option row carries the checkbox.
*
* @default 'start'
*/
indicatorPosition?: IndicatorPosition;

/**
* Whether the dropdown starts open on mount.
* Useful for showcases and previews.
Expand Down Expand Up @@ -692,6 +708,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
triggerDisplay = 'count',
maxBadges = 3,
renderOption,
indicatorPosition = 'start',
isDefaultOpen = false,
'data-testid': testId,
htmlName,
Expand Down Expand Up @@ -1234,6 +1251,24 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
const isPartiallySelected =
isSelectAll && selectAllState === 'indeterminate';

const checkbox = (
<div
inert
{...stylex.props(
styles.checkboxDecorative,
indicatorPosition === 'end' && styles.checkboxDecorativeEnd,
)}>
<CheckboxInput
label=""
isLabelHidden
value={checkboxValue}
onChange={() => {}}
isDisabled={item.disabled}
size={size === 'lg' ? 'md' : size}
/>
</div>
);

return (
<div
key={item.value}
Expand Down Expand Up @@ -1273,28 +1308,21 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
item.disabled && styles.itemDisabled,
),
)}>
<div inert {...stylex.props(styles.checkboxDecorative)}>
<CheckboxInput
label=""
isLabelHidden
value={checkboxValue}
onChange={() => {}}
isDisabled={item.disabled}
size={size === 'lg' ? 'md' : size}
/>
</div>
{indicatorPosition === 'start' && checkbox}
{renderOption && !isSelectAll ? (
renderOption(item)
) : (
<span {...stylex.props(styles.itemLabel)}>
{item.label ?? item.value}
</span>
)}
{indicatorPosition === 'end' && checkbox}
</div>
);
},
[
renderOption,
indicatorPosition,
highlightedIndex,
optimisticValue,
allEnabledSelected,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/Selector/Selector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export const docs = {
description:
'Custom render function for each selectable option in the dropdown. Use this instead of JSX children; dividers and sections are rendered by the selector.',
},
{
name: 'indicatorPosition',
type: "'start' | 'end'",
description:
'Which edge of the option row carries the selected mark. start reserves a mark column ahead of every label so they stay aligned, the way a native menu does; end is the house convention shared with Typeahead and CommandPalette.',
default: "'end'",
},
{
name: 'width',
type: 'SizeValue',
Expand Down
Loading
Loading