Skip to content

Commit

Permalink
Merge pull request #184 from US-CBP/bugfix/form-fields-a11y-202408
Browse files Browse the repository at this point in the history
Bugfix/form fields a11y 202408
  • Loading branch information
dgibson666 authored Aug 19, 2024
2 parents 09bf17b + f45dfc1 commit 45bd280
Show file tree
Hide file tree
Showing 11 changed files with 153 additions and 77 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ The Form Field Wrapper component offers means for applying overlays and button c

### Accessibility

* TBD
* Any slotted buttons should have an accessible label.
* Slotted buttons should also specify `aria-describedby` referencing the form field's label for additional context as long as that label is not identical to the button's accessible label. E.g., a search field labelled as "Search" with a button labelled as "Search" should not use `aria-describedby`.
* Any slotted overlays (not buttons) shall be added to the field's accessible description.

### Specific Pattern Requirements (implemented by application logic)

* Password/Obfuscated field toggle:
* The field should include an `input type="password"` with an attached button showing the `eye` icon.
* Upon activating the button, the icon is toggled to `eye-slash` and the input changed to an appropriate type (usually `type="text"`).
* Numeric Counter field:
* The field should include an `input type="number"` with an unattached decrement and increment buttons per the story.
* The field should include an `input type="number"` or `input type="text"` with an unattached decrement and increment buttons per the story.
* Note: Safari does not restrict non-numeric input for `input type="number"` like Chrome does, so validation is always required regardless of the type specified.
* Clicking either button should increment/decrement by the defined `step` attribute on the input.
* If no step is defined, default it to 1.
* If no value is defined, default it to 0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default {
},
inputType:{
control: 'select',
options: [ "text", "number", "password", "search"] // additional types: email, tel, url, color, range, date, datetime-local, month, week, time
//options: [ "text", "number", "password", "search"]
options: [ "text", "number", "password", "search", "email", "tel", "url", "color", "range", "date", "datetime-local", "month", "week", "time", "file"]
},
error: {
control: 'boolean',
Expand Down Expand Up @@ -127,8 +128,9 @@ const NumericCounterTemplate = ({ label, description, inputType, overlayStart,
fill="outline"
color="secondary"
variant="square"
accessibility-text="Toggle visibility"
aria-controls="${fieldId}"
accessibility-text="Decrement"
controls="${fieldId}"
aria-describedby="${fieldId}-label"
>
<cbp-icon name="minus" size="1rem"></cbp-icon>
</cbp-button>
Expand All @@ -139,8 +141,9 @@ const NumericCounterTemplate = ({ label, description, inputType, overlayStart,
fill="outline"
color="secondary"
variant="square"
accessibility-text="Toggle visibility"
aria-controls="${fieldId}"
accessibility-text="Increment"
controls="${fieldId}"
aria-describedby="${fieldId}-label"
>
<cbp-icon name="plus" size="1rem"></cbp-icon>
</cbp-button>
Expand Down Expand Up @@ -216,7 +219,8 @@ const PasswordTemplate = ({ label, description, inputType, overlayStart, overla
color="secondary"
variant="square"
accessibility-text="Toggle visibility"
aria-controls="${fieldId}"
controls="${fieldId}"
aria-describedby="${fieldId}-label"
>
<cbp-icon name="eye" size="1rem"></cbp-icon>
</cbp-button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,34 @@ import { setCSSProps } from '../../utils/utils';

export class CbpFormFieldWrapper {

private parent: HTMLCbpFormFieldElement;
private formField: any;
private attachedButton: any;

private overlayStart: HTMLElement;
private overlayEnd: HTMLElement;

private overlayStartWidth;
private overlayEndWidth;
private attachedButtonWidth;

@Element() host: HTMLElement;

componentWillLoad() {
// query the DOM for the slotted form field and wire it up for accessibility and attach an event listener to it
this.parent = this.host.closest('cbp-form-field');
this.formField = this.host.querySelector('input,select,textarea');
this.attachedButton = this.host.querySelector('[slot=cbp-form-field-attached-button] cbp-button');
this.overlayStart = this.host.querySelector('[slot=cbp-form-field-overlay-start]');
this.overlayEnd = this.host.querySelector('[slot=cbp-form-field-overlay-end]');
}

componentDidLoad() {
// Calculate the size of the overlays to set the input padding accordingly
// TechDebt: as a first cut, this is not reactive. How reactive does it need to be?
const overlayStart: HTMLElement = this.host.querySelector('[slot="cbp-form-field-overlay-start"]');
this.overlayStartWidth = overlayStart ? overlayStart.offsetWidth + 8 : 0;

const overlayEnd: HTMLElement = this.host.querySelector('[slot="cbp-form-field-overlay-end"]');
this.overlayEndWidth = overlayEnd ? overlayEnd.offsetWidth + 8 : 0;

const attachedButton: HTMLElement = this.host.querySelector('[slot="cbp-form-field-attached-button"]');
this.attachedButtonWidth = attachedButton ? attachedButton.offsetWidth : 0;
this.overlayStartWidth = this.overlayStart ? this.overlayStart.offsetWidth + 8 : 0;
this.overlayEndWidth = this.overlayEnd ? this.overlayEnd.offsetWidth + 8 : 0;
this.attachedButtonWidth = this.attachedButton ? this.attachedButton.offsetWidth : 0;

// Update this with the buttons size
this.overlayEndWidth = this.overlayEndWidth + this.attachedButtonWidth
Expand All @@ -41,6 +52,32 @@ export class CbpFormFieldWrapper {
"--cbp-form-field-overlay-end-width": `${this.overlayEndWidth}px`,
"--cbp-form-field-attached-button-width": `${this.attachedButtonWidth}px`,
});

// Set the IDs on the slotted overlays (if needed) and assign them to the native input's `aria-describedby` attribute.
let overlayids = '';
const overlays = ["overlayStart", "overlayEnd"];
overlays.forEach( (overlay) => {
if (this[overlay]) {
let id = `${overlay}ID`;
if (this[overlay].getAttribute('id')) {
id = this[overlay].getAttribute('id');
}
else {
id = `${this.parent.fieldId}-${overlay}`;
this[overlay].setAttribute('id',`${id}`);
}
overlayids
? overlayids+=` ${id}`
: overlayids=id;
}
});

if (overlayids){
let ariadescribedby = this.formField.getAttribute('aria-describedby');
ariadescribedby
? this.formField.setAttribute('aria-describedby', `${ariadescribedby} ${overlayids}`)
: this.formField.setAttribute('aria-describedby', `${overlayids}`);
}
}

render() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
*
*/
:root{
:root{
--cbp-form-field-color: var(--cbp-color-text-darkest);
--cbp-form-field-color-bg: var(--cbp-color-white);
--cbp-form-field-color-border: var(--cbp-color-interactive-secondary-base);
Expand Down Expand Up @@ -208,6 +208,7 @@ cbp-form-field {
// Safari is lacking in contrast, so these need to be manually set
optgroup {
color: var(--cbp-form-field-color);
background-color: var(--cbp-form-field-color-bg);
font-weight: var(--cbp-font-weight-bold);
}
}
Expand Down Expand Up @@ -244,17 +245,27 @@ cbp-form-field {
--cbp-form-field-color-description-dark: var(--cbp-color-danger-light);
//--cbp-form-field-color-placeholder-dark: var(--cbp-color-text-light);
}

// Auto-filled input background color is set by the user agent stylesheet, so unfortunately there’s no way to override it.
// Instead of styling the background color, use an inner box shadow that covers the background and use -webkit-text-fill-color to set the text color.
input:is(:-webkit-autofill, :autofill),
input:is(:-webkit-autofill, :autofill):focus {
box-shadow: 0 0 0 1000px var(--cbp-form-field-color-bg) inset;
-webkit-text-fill-color: var(--cbp-form-field-color);
}
}


/*
need to work on overriding/styling auto-filled fields:
//https://medium.com/@landonschropp/styling-autocompleted-inputs-in-chrome-and-safari-de83d7bd1d8a
input:-webkit-autofill, input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px white inset;
-webkit-text-fill-color: #333;
}
// This removes the default increment arrows in the number input field
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0; // Margins still appear even if hidden
}
chrome:
input:-internal-autofill-selected {}
input[type=number] {
-moz-appearance: textfield; // Removes the default increment arrows in Firefox
}
*/
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,23 @@ The Form Field component represents a generic, reusable pattern for form fields

### Accessibility

* Overlays may Disabled form controls and buttons are non-interactive and cannot be navigated to using the keyboard and should be used with caution (if at all).
* According to CBP Design System guidance, required fields should indicate "Required" in the field description in plain text.
* The `required` attribute should not be used on the native form field because 1. screen readers would read "required" twice and 2. this attribute triggers browser-based validation, which will not behave consistently with application/custom validation.
* Do not place `aria-required` on the native form field, as screen readers would read "required" twice.
* Disabled form controls and buttons are non-interactive and cannot be navigated to using the keyboard and should be used with caution (if at all).
* Placeholder text should rarely, if ever, be used.
* Especially in forms where the user is expected to enter data, placeholder text with sufficient contrast to the background color may be mistaken as entered input.
* In high contrast mode, placeholder text is displayed exactly the same as entered text, making this issue more likely.
* Placeholder text disappears when the user starts typing in the field, creating more cognitive load than necessary.
* Form input patterns using this component and the optional `cbp-form-field-wrapper` component will be read in the following order by screen readers:
1. Field label
2. Form field
* "Edit" or similar control description (e.g., "Spin Button")
* If aria-invalid, reads "invalid entry"
* "Editable" state
3. Accessible Description (via aria-describedby or title if the former does not exist):
* Description prop
4. Field value

### Additional Notes and Considerations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ Textarea.args = {
};



const SelectTemplate = ({ label, description, fieldId, error, disabled, context, sx }) => {
return `
<cbp-form-field
Expand All @@ -93,20 +92,50 @@ const SelectTemplate = ({ label, description, fieldId, error, disabled, context,
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
<!--
<option value="4">Option 4</option>
<option value="5">Option 5</option>
<option value="6">Option 6</option>
<option value="7">Option 7</option>
<option value="8">Option 8</option>
</select>
</cbp-form-field>
`;
};

export const Select = SelectTemplate.bind({});
Select.args = {};

// For testing: May be commented out later.
const SelectWithOptgroupTemplate = ({ label, description, fieldId, error, disabled, context, sx }) => {
return `
<cbp-form-field
${label ? `label="${label}"` : ''}
${description ? `description="${description}"` : ''}
${fieldId ? `field-id="${fieldId}"` : ''}
${error ? `error` : ''}
${context && context != 'light-inverts' ? `context=${context}` : ''}
${sx ? `sx=${JSON.stringify(sx)}` : ''}
>
<select name="select" ${disabled ? `disabled` : ''}>
<option value=""></option>
<optgroup label="Group A">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</optgroup>
<optgroup label="Group B">
<option value="4">Option 4</option>
<option value="5">Option 5</option>
<option value="6">Option 6</option>
<option value="6">Option 6 has a longer label</option>
<option value="7">Option 7</option>
<option value="8">Option 8</option>
<option value="9">Option 9</option>
</optgroup>
<optgroup label="Group B">
<optgroup label="Group C">
<option value="10">Option 10</option>
<option value="11">Option 11</option>
<option value="12">Option 12</option>
<option value="13">Option 13</option>
<option value="13">Option 13 has a really really really really long label that might truncate. Test on mobile.</option>
<option value="14">Option 14</option>
<option value="15">Option 15</option>
<option value="16">Option 16</option>
Expand All @@ -123,16 +152,14 @@ const SelectTemplate = ({ label, description, fieldId, error, disabled, context,
<option value="27">Option 27</option>
<option value="28">Option 28</option>
</optgroup>
-->
</select>
</cbp-form-field>
`;
};

export const Select = SelectTemplate.bind({});
Select.args = {

};
export const SelectWithOptgroup = SelectWithOptgroupTemplate.bind({});
SelectWithOptgroup.args = {};


/* //For testing purposes only
const MultiSelectTemplate = ({ label, description, fieldId, error, disabled, context, sx }) => {
Expand Down Expand Up @@ -191,33 +218,3 @@ export const MultiSelect = MultiSelectTemplate.bind({});
MultiSelect.args = {
};
*/



const PasswordTemplate = ({ label, description, fieldId, error, readonly, disabled, value, context, sx }) => {
return `
<cbp-form-field
${label ? `label="${label}"` : ''}
${description ? `description="${description}"` : ''}
${fieldId ? `field-id="${fieldId}"` : ''}
${error ? `error` : ''}
${readonly ? `readonly` : ''}
${disabled ? `disabled` : ''}
${context && context != 'light-inverts' ? `context=${context}` : ''}
${sx ? `sx=${JSON.stringify(sx)}` : ''}
>
<cbp-form-field-wrapper>
<input
type="password"
name="hashedinput"
${value ? `value="${value}"` : ''}
/>
</cbp-form-field-wrapper>
</cbp-form-field>
`;
};

export const Password = PasswordTemplate.bind({});
Password.args = {
value: '',
};
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ export class CbpFormField {
this.hasDescription = !!this.description || !!this.host.querySelector('[slot=cbp-form-field-description]');

if (this.formField) {
this.formField.setAttribute('id',`${this.fieldId}`); // TechDebt: this requires more thought for compound inputs
// If the slotted form field has an ID, use it; otherwise, set it.
this.formField.getAttribute('id')
? this.fieldId = this.formField.getAttribute('id')
: this.formField.setAttribute('id', `${this.fieldId}`);
this.hasDescription && this.formField.setAttribute('aria-describedby',`${this.fieldId}-description`);
this.formField.addEventListener('change', this.handleChange());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Tabs are a common UI pattern of progressive disclosure mimicking the real world

### User Interactions

* Setting a tab to be active by default is achieved by setting `selected` on an individual tab component.
* Setting a tab to be active by default is achieved by setting `selected` on an individual `cbp-tab` component.
* If no tab is explicitly set to selected, the first tab will default to active/selected.
* For keyboard navigation, the tab group uses the "roving tabindex" paradigm.
* Only the active/selected tab is reachable by tab/shift+tab.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Tabs.args = {
},
{
name: 'tab3',
label: 'Tab 3',
label: 'Tab 3 is longer',
accessibilityText: '',
color: 'default',
panelContent: 'Tab panel 3 content.',
Expand Down Expand Up @@ -112,6 +112,14 @@ Tabs.args = {
panelContent: 'Tab panel 6 content.',
selected: false,
},
{
name: 'tab7',
label: 'Tab 7',
accessibilityText: '',
color: 'default',
panelContent: 'Tab panel 7 content.',
selected: false,
},
],
accessibilityText: 'Tabs Example',
};
3 changes: 2 additions & 1 deletion packages/web-components/src/stories/byoi.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Furthermore, this concept is inline with recent discussions of "HTML web compone
### Advantages

* Lessens the need to develop a web component or functional component duplicating every native form control.
* Web components duplicating form controls usually only implement a portion of the API because the number of attributes is HUGE and often conditional on other attributes (e.g., `input type="number"` adds more attributes like `min` and `max`, while making others such as `maxlength` invalid). This leaves it up to the consuming developer to improvise when the web component falls short of the native HTML spec, or forces the developer to add an extremely large number of properties to the component to duplicate each of the attributes (e.g., the `input` tag accepts 34 attributes *not counting* global HTML attributes).
* Web components duplicating form controls usually only implement a portion of the API because the number of attributes is HUGE and often conditional on other attributes (e.g., `input type="number"` adds more attributes like `min` and `max`, while making others such as `maxlength` invalid). This leaves it up to the consuming developer to improvise when the web component falls short of the native HTML spec, or forces the developer to add an extremely large number of properties to the component to duplicate each of the attributes (e.g., the `input` tag accepts 34 attributes *not counting* 40+ global HTML and ARIA attributes).
* Direct access to the interactive controls alleviates a common pain point working with web components. Frameworks like Angular, Vue, and HTMX want to place attributes/directives directly on the DOM elements, which is not easily done when those elements are rendered behind an asynchronously loaded web component that has its own internal rendering lifecycle.
* Native HTML elements automatically work with the web platform (e.g., native form post). This is often not the case with custom widgets/components.

### Disadvantages

Expand Down
Loading

0 comments on commit 45bd280

Please sign in to comment.