Skip to content

Commit

Permalink
va-select: add support for optgroup markup (#1340)
Browse files Browse the repository at this point in the history
* support for optgroup + styling

* support for optgroup nodes in combination with individually slooted option nodes

* storybook examples

* unit test coverage
  • Loading branch information
oleksii-morgun authored Sep 23, 2024
1 parent 808245f commit 8d61ae4
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 20 deletions.
34 changes: 34 additions & 0 deletions packages/storybook/stories/va-select-uswds.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,40 @@ ErrorMessage.args = { ...defaultArgs, error: 'There was a problem' };
export const DynamicOptions = Template.bind(null);
DynamicOptions.args = { ...defaultArgs, 'use-add-button': true };

export const OptGroups = Template.bind(null);
OptGroups.args = {
...defaultArgs,
options: [
<optgroup key="1" label="Branches of Service">
<option value="navy">Navy</option>
<option value="army">Army</option>
<option value="marines">Marines</option>
<option value="air-force">Air Force</option>
<option value="coastguard">Coastguard</option>
</optgroup>,
<optgroup key="2" label="Other">
<option value="other">Other</option>
</optgroup>,
],
};

export const OptGroupsWithOptions = Template.bind(null);
OptGroupsWithOptions.args = {
...defaultArgs,
options: [
<optgroup key="1" label="Branches of Service">
<option value="navy">Navy</option>
<option value="army">Army</option>
<option value="marines">Marines</option>
<option value="air-force">Air Force</option>
<option value="coastguard">Coastguard</option>
</optgroup>,
<option key="2" value="other">
Other
</option>,
],
};

export const ReadOnly = InertTemplate.bind(null);
ReadOnly.args = { ...defaultArgs };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,63 @@ describe('va-select', () => {
const span = await page.find('span.usa-error-message');
expect(span).toBeNull()
});

it('renders options within optgroup', async () => {
const page = await newE2EPage();
await page.setContent(`
<va-select label="A label" value="bar">
<optgroup label="Group 1">
<option value="foo">Foo</option>
</optgroup>
<optgroup label="Group 2">
<option value="bar">Bar</option>
</optgroup>
</va-select>
`);
const element = await page.find('va-select');

expect(element).toEqualHtml(`
<va-select label="A label" value="bar" class="hydrated">
<mock:shadow-root>
<label class="usa-label" for="options" part="label">
A label
</label>
<span id="input-error-message" role="alert"></span>
<slot></slot>
<select id="options" part="select" aria-invalid="false" class="usa-select">
<option value="">- Select -</option>
<optgroup label="Group 1">
<option value="foo">Foo</option>
</optgroup>
<optgroup label="Group 2">
<option value="bar">Bar</option>
</optgroup>
</select>
</mock:shadow-root>
<optgroup label="Group 1">
<option value="foo">Foo</option>
</optgroup>
<optgroup label="Group 2">
<option value="bar">Bar</option>
</optgroup>
</va-select>
`);
});

it('selects the correct option within optgroup', async () => {
const page = await newE2EPage();
await page.setContent(`
<va-select label="A label" value="bar">
<optgroup label="Group 1">
<option value="foo">Foo</option>
</optgroup>
<optgroup label="Group 2">
<option value="bar">Bar</option>
</optgroup>
</va-select>
`);
const select = await page.find('va-select >>> select');
const selectValue = await select.getProperty('value');
expect(selectValue).toBe('bar');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
margin-bottom: 0;
}

::slotted(option) {
::slotted(option),
::slotted(optgroup) {
display: none;
}
}
83 changes: 65 additions & 18 deletions packages/web-components/src/components/va-select/va-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class VaSelect {
@Prop() width?: string;

/**
* Whether an error message should be shown - set to false when this component is used inside va-date or va-memorable-date
* Whether an error message should be shown - set to false when this component is used inside va-date or va-memorable-date
* in which the error for the va-select will be rendered outside of va-select
*/
@Prop() showError?: boolean = true;
Expand Down Expand Up @@ -159,13 +159,41 @@ export class VaSelect {
private populateOptions() {
const { value } = this;

this.options = getSlottedNodes(this.el, 'option').map(
(node: HTMLOptionElement) => {
return (
<option value={node.value} selected={value === node.value}>
{node.text}
</option>
);
// Get all slotted nodes
const allNodes = getSlottedNodes(this.el, null);

// Filter nodes to include only <option> and <optgroup>
// supports scenario where <option> may be slotted within <optgroup> as well as <option> directly
// preserving the order of the nodes as they are slotted
const nodes = allNodes.filter((node: Node) => {
const nodeName = node.nodeName.toLowerCase();
return nodeName === 'option' || nodeName === 'optgroup';
});

this.options = nodes.map(
(node: HTMLOptionElement | HTMLOptGroupElement) => {
if (node.nodeName.toLowerCase() === 'optgroup') {
return (
<optgroup label={node.label}>
{Array.from(node.children).map((child: HTMLOptionElement) => {
return (
<option value={child.value} selected={value === child.value}>
{child.text}
</option>
);
})}
</optgroup>
);
} else if (node.nodeName.toLowerCase() === 'option') {
return (
<option
value={(node as HTMLOptionElement).value}
selected={value === (node as HTMLOptionElement).value}
>
{node.textContent}
</option>
);
}
},
);
}
Expand All @@ -176,14 +204,25 @@ export class VaSelect {
}

render() {
const { error, reflectInputError, invalid, label, required, name, hint, messageAriaDescribedby, width, showError } = this;
const {
error,
reflectInputError,
invalid,
label,
required,
name,
hint,
messageAriaDescribedby,
width,
showError,
} = this;

const errorID = 'input-error-message';
const ariaDescribedbyIds =
const ariaDescribedbyIds =
`${messageAriaDescribedby ? 'input-message' : ''} ${
error ? errorID : ''} ${
hint ? 'input-hint' : ''}`.trim() || null; // Null so we don't add the attribute if we have an empty string
error ? errorID : ''
} ${hint ? 'input-hint' : ''}`.trim() || null; // Null so we don't add the attribute if we have an empty string

const labelClass = classnames({
'usa-label': true,
'usa-label--error': error,
Expand All @@ -198,14 +237,20 @@ export class VaSelect {
{label && (
<label htmlFor="options" class={labelClass} part="label">
{label}
{required && <span class="usa-label--required"> {i18next.t('required')}</span>}
{required && (
<span class="usa-label--required"> {i18next.t('required')}</span>
)}
</label>
)}
{hint && <span class="usa-hint" id="input-hint">{hint}</span>}
{hint && (
<span class="usa-hint" id="input-hint">
{hint}
</span>
)}
<span id={errorID} role="alert">
{showError && error && (
<Fragment>
<span class="usa-sr-only">{i18next.t('error')}</span>
<span class="usa-sr-only">{i18next.t('error')}</span>
<span class="usa-error-message">{error}</span>
</Fragment>
)}
Expand All @@ -222,7 +267,9 @@ export class VaSelect {
onChange={e => this.handleChange(e)}
part="select"
>
<option key="0" value="" selected>{i18next.t('select')}</option>
<option key="0" value="" selected>
{i18next.t('select')}
</option>
{this.options}
</select>
{messageAriaDescribedby && (
Expand All @@ -231,6 +278,6 @@ export class VaSelect {
</span>
)}
</Host>
)
);
}
}

0 comments on commit 8d61ae4

Please sign in to comment.