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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Added `cutOff` property to set maximum number of raw Markdown characters to render
- `<Label />`
- `hidden` property: label is not displayed but stays accessible for screen readers and keyboard navigation
- `<FieldItem />`
- label, input element, helper text and message are connected to each other automatically now
- each part without an own `id` gets one based on a unique ID of the field item
- the label refers to the input element via `for`, or via `aria-labelledby` on the input element if the label is not displayed as `label` element
- an already set `for` is only kept if it refers to the ID of the input element of the field item
- helper text and message are referred by the input element via `aria-describedby`
- `input`, `textarea`, `select`, the toggle button of `<Select />` and the editable area of `<CodeEditor />` are supported as input element
- input elements that cannot be referenced by `for`, e.g. the editable area of the code editor, are connected via `aria-labelledby`
- parts that are created after the field item was mounted, e.g. by the code editor, are connected as soon as they exist
- already set `id` values and connections are never overwritten
- ID references created by the field item are removed again if their part is removed from the field item
- `preventAriaAttribution` property: prevents this automatic connection of the field item parts
- new `utils` methods:
- `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value
- new icons:
Expand All @@ -26,6 +38,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Carbon, Codemirror, React-Flow
- minimum node version (`engines.node`) is `18.19.0` now
- the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version
- `<FieldItem />`
- the used `Label` element gets the `eccgui-fielditem__label` class now
- `<StringPreviewContentBlobToggler />`
- `allowedHtmlElementsInPreview` option is set to inline elements on default
- uses now the `Markdown.cutOff` property
Expand Down
139 changes: 138 additions & 1 deletion src/components/Form/FieldItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,32 @@ export interface FieldItemProps extends React.HTMLAttributes<HTMLDivElement>, Te
* Is displayed below the included input element.
*/
messageText?: string;
/**
* Prevent the automatic connection of the field item parts.
* By default, label, input element, helper text and message are connected to each
* other via `for`, `aria-labelledby` and `aria-describedby`.
* Set it to `true` if the using application manages the accessibility attributes itself.
*/
preventAriaAttribution?: boolean;
}

/**
* Input elements that could be connected to the label and the help texts of the field item.
*/
const connectableInputSelectors = [
"input",
"textarea",
"select",
`.${eccgui}-select button`,
`.${eccgui}-codeeditor .cm-content`,
];

/**
* Elements that can be referenced by the `for` attribute of a `label` element.
* Other input elements, e.g. the editable area of the code editor, need to use `aria-labelledby`.
*/
const labelableElements = ["BUTTON", "INPUT", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA"];

/**
* Form element that manages the combination of label, helper texts, input element and feedback messages.
*/
Expand All @@ -43,11 +67,123 @@ export const FieldItem = ({
helperText,
messageText,
intent,
preventAriaAttribution = false,
...otherProps
}: FieldItemProps) => {
const fieldItemRef = React.useRef<HTMLDivElement>(null);
/** unique ID of this field item, used as suffix for the IDs of its parts */
const fieldItemId = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");

const intentClass = intent ? " " + IntentClassNames[intent.toUpperCase()] : "";

const label = <Label {...labelProps} disabled={disabled} />;
/**
* Connect the parts of the field item to each other for accessibility reasons.
* It is done on every update and DOM change because the parts may be replaced, added or removed later on.
* Already existing IDs and connections are never overwritten, they are managed by the using application then.
* Only the ID references created by the field item itself are removed again if their part does not exist anymore.
* It is not done at all if `preventAriaAttribution` is set.
*/
const connectParts = React.useCallback(() => {
const fieldItem = fieldItemRef.current;
if (!fieldItem || preventAriaAttribution) {
return;
}

/** nested field items manage the connections of their own parts */
const ownPart = <T extends HTMLElement>(candidates: NodeListOf<T>): T | undefined =>
Array.from(candidates).find((candidate) => candidate.closest(`.${eccgui}-fielditem`) === fieldItem);

const labelElement = ownPart(fieldItem.querySelectorAll<HTMLElement>(`.${eccgui}-fielditem__label`));
const inputElement = ownPart(
fieldItem.querySelectorAll<HTMLElement>(
connectableInputSelectors.map((selector) => `.${eccgui}-fielditem__inputfields ${selector}`).join(", "),
),
);
const helpElement = ownPart(fieldItem.querySelectorAll<HTMLElement>(`.${eccgui}-fielditem__helpertext`));
const messageElement = ownPart(fieldItem.querySelectorAll<HTMLElement>(`.${eccgui}-fielditem__message`));

const setMissingId = (element: HTMLElement | undefined, id: string) => {
if (element && !element.id) {
element.id = id;
}
};
setMissingId(labelElement, `label_${fieldItemId}`);
setMissingId(inputElement, `input_${fieldItemId}`);
setMissingId(helpElement, `help_${fieldItemId}`);
setMissingId(messageElement, `message_${fieldItemId}`);

if (!inputElement) {
return;
}

/**
* Update a list of ID references, only the IDs created by this field item are removed if their part is gone.
* References set by the using application always stay untouched.
*/
const updateReferences = (attribute: string, parts: [HTMLElement | undefined, string][]) => {
const references = (inputElement.getAttribute(attribute) ?? "").split(" ").filter(Boolean);
parts.forEach(([element, ownId]) => {
if (element) {
if (!references.includes(element.id)) {
references.push(element.id);
}
} else if (references.includes(ownId)) {
references.splice(references.indexOf(ownId), 1);
}
});
if (references.length > 0) {
inputElement.setAttribute(attribute, references.join(" "));
} else {
inputElement.removeAttribute(attribute);
}
};

if (labelElement instanceof HTMLLabelElement && labelableElements.includes(inputElement.tagName)) {
// an already set `for` is only kept if it refers to the ID of the input element of this field item
if (labelElement.getAttribute("for") !== inputElement.id) {
labelElement.setAttribute("for", inputElement.id);
}
} else if (labelElement) {
// labels that are not `label` elements, e.g. of disabled field items, cannot use `for`
// the same is true for input elements that cannot be referenced by `for`
if (!inputElement.getAttribute("aria-labelledby")) {
inputElement.setAttribute("aria-labelledby", labelElement.id);
}
} else {
updateReferences("aria-labelledby", [[undefined, `label_${fieldItemId}`]]);
}

updateReferences("aria-describedby", [
[messageElement, `message_${fieldItemId}`],
[helpElement, `help_${fieldItemId}`],
]);
}, [fieldItemId, preventAriaAttribution]);

React.useEffect(() => {
connectParts();
});

/**
* Some parts are created after the field item was mounted, e.g. the editable area of the code editor,
* and such changes do not trigger an update of the field item itself.
*/
React.useEffect(() => {
const fieldItem = fieldItemRef.current;
if (!fieldItem || preventAriaAttribution) {
return;
}
const partsObserver = new MutationObserver(connectParts);
partsObserver.observe(fieldItem, { childList: true, subtree: true });
return () => partsObserver.disconnect();
}, [connectParts, preventAriaAttribution]);

const label = (
<Label
{...labelProps}
className={`${eccgui}-fielditem__label` + (labelProps?.className ? " " + labelProps.className : "")}
disabled={disabled}
/>
);

const userhelp =
helperText &&
Expand All @@ -69,6 +205,7 @@ export const FieldItem = ({

return (
<div
ref={fieldItemRef}
className={
`${eccgui}-fielditem` +
(className ? " " + className : "") +
Expand Down
4 changes: 3 additions & 1 deletion src/components/Form/form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ form {

.#{$eccgui}-fielditem__inputfields {
&:not(:first-child) {
margin-top: $eccgui-size-block-whitespace * 0.25;
:not(.#{$eccgui}-application__hide--screen) + & {
margin-top: $eccgui-size-block-whitespace * 0.25;
}
}

&:not(:last-child) {
Expand Down
Loading
Loading