Skip to content

Commit

Permalink
fix: allow users to control tabindex on interactive components (#8166)
Browse files Browse the repository at this point in the history
**Related Issue:** #4970 

## Summary

Updates the interactive component pattern to avoid the need to control
`tabindex` on the host to prevent tabbing into a component's subtree.

This works by introducing a new functional component
(`InteractiveContainer`) that leverages `inert` (prevent tabbing into
its contents) and `display: contents` (allows us to use it as a root
container without affecting layout), which should be the root container
under the host.

Updating components to this pattern should be straightforward as the
styling of the helper component is bundled within the `disabled` mixin
that interactive components already include and `updateHostInteraction`
no longer needs its 2nd parameter. For cases that were setting the 2nd
parameter (`hostIsTabbable`) all they have to do to migrate depends on
the value:

1. `false` (default) – no further changes necessary
2. `true` – set `tabIndex={0}` on the host
3. predicate function – set `tabIndex={myPredicateFunction() ? 0 : -1 }`
on the host
4. `"managed"` – no further changes necessary as owner/parent sets
`tabIndex` on the component already

Any use of `Fragment` can be replaced by `InteractiveContainer`
directly.

2 and 3 might also need to determine the tab index based on `disabled`.
  • Loading branch information
jcfranco authored Dec 20, 2023
1 parent b3d5169 commit b15c052
Show file tree
Hide file tree
Showing 54 changed files with 1,628 additions and 1,462 deletions.
4 changes: 4 additions & 0 deletions packages/calcite-components/src/assets/styles/includes.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
@apply opacity-100;
}
}

.interaction-container {
display: contents;
}
}

// used for host-specific styling when the `disabled` mixin cannot be applied on the host (e.g., `display: contents`)
Expand Down
6 changes: 0 additions & 6 deletions packages/calcite-components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4659,9 +4659,6 @@ export namespace Components {
* Specifies the number of columns the component should span.
*/
"colSpan": number;
/**
* When true, prevents user interaction. Notes: This prop should use the
*/
"disabled": boolean;
/**
* Use this property to override individual strings used by the component.
Expand Down Expand Up @@ -12090,9 +12087,6 @@ declare namespace LocalJSX {
* Specifies the number of columns the component should span.
*/
"colSpan"?: number;
/**
* When true, prevents user interaction. Notes: This prop should use the
*/
"disabled"?: boolean;
/**
* Use this property to override individual strings used by the component.
Expand Down
39 changes: 21 additions & 18 deletions packages/calcite-components/src/components/action/action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
connectInteractive,
disconnectInteractive,
InteractiveComponent,
InteractiveContainer,
updateHostInteraction,
} from "../../utils/interactive";
import {
Expand Down Expand Up @@ -308,24 +309,26 @@ export class Action

return (
<Host>
<button
aria-busy={toAriaBoolean(loading)}
aria-controls={indicator ? indicatorId : null}
aria-disabled={toAriaBoolean(disabled)}
aria-label={ariaLabel}
aria-pressed={toAriaBoolean(active)}
class={buttonClasses}
disabled={disabled}
id={buttonId}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(buttonEl): HTMLButtonElement => (this.buttonEl = buttonEl)}
>
{this.renderIconContainer()}
{this.renderTextContainer()}
{!icon && indicator && <div class={CSS.indicatorWithoutIcon} key="indicator-no-icon" />}
</button>
<slot name={SLOTS.tooltip} onSlotchange={this.handleTooltipSlotChange} />
{this.renderIndicatorText()}
<InteractiveContainer disabled={disabled}>
<button
aria-busy={toAriaBoolean(loading)}
aria-controls={indicator ? indicatorId : null}
aria-disabled={toAriaBoolean(disabled)}
aria-label={ariaLabel}
aria-pressed={toAriaBoolean(active)}
class={buttonClasses}
disabled={disabled}
id={buttonId}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(buttonEl): HTMLButtonElement => (this.buttonEl = buttonEl)}
>
{this.renderIconContainer()}
{this.renderTextContainer()}
{!icon && indicator && <div class={CSS.indicatorWithoutIcon} key="indicator-no-icon" />}
</button>
<slot name={SLOTS.tooltip} onSlotchange={this.handleTooltipSlotChange} />
{this.renderIndicatorText()}
</InteractiveContainer>
</Host>
);
}
Expand Down
37 changes: 20 additions & 17 deletions packages/calcite-components/src/components/block/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
connectInteractive,
disconnectInteractive,
InteractiveComponent,
InteractiveContainer,
updateHostInteraction,
} from "../../utils/interactive";
import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale";
Expand Down Expand Up @@ -372,24 +373,26 @@ export class Block

return (
<Host>
<article
aria-busy={toAriaBoolean(loading)}
class={{
[CSS.container]: true,
}}
>
{headerNode}
<section
aria-labelledby={IDS.toggle}
class={CSS.content}
hidden={!open}
id={IDS.content}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.setTransitionEl}
<InteractiveContainer disabled={this.disabled}>
<article
aria-busy={toAriaBoolean(loading)}
class={{
[CSS.container]: true,
}}
>
{this.renderScrim()}
</section>
</article>
{headerNode}
<section
aria-labelledby={IDS.toggle}
class={CSS.content}
hidden={!open}
id={IDS.content}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.setTransitionEl}
>
{this.renderScrim()}
</section>
</article>
</InteractiveContainer>
</Host>
);
}
Expand Down
61 changes: 32 additions & 29 deletions packages/calcite-components/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
connectInteractive,
disconnectInteractive,
InteractiveComponent,
InteractiveContainer,
updateHostInteraction,
} from "../../utils/interactive";
import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label";
Expand Down Expand Up @@ -272,35 +273,37 @@ export class Button
);

return (
<Tag
aria-disabled={childElType === "a" ? toAriaBoolean(this.disabled || this.loading) : null}
aria-expanded={this.el.getAttribute("aria-expanded")}
aria-label={!this.loading ? getLabelText(this) : this.messages.loading}
aria-live="polite"
class={{
[CSS.buttonPadding]: noStartEndIcons,
[CSS.buttonPaddingShrunk]: !noStartEndIcons,
[CSS.contentSlotted]: this.hasContent,
[CSS.iconStartEmpty]: !this.iconStart,
[CSS.iconEndEmpty]: !this.iconEnd,
}}
disabled={childElType === "button" ? this.disabled || this.loading : null}
href={childElType === "a" && this.href}
name={childElType === "button" && this.name}
onClick={this.handleClick}
rel={childElType === "a" && this.rel}
tabIndex={this.disabled ? -1 : null}
target={childElType === "a" && this.target}
title={this.tooltipText}
type={childElType === "button" && this.type}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.setChildEl}
>
{loaderNode}
{this.iconStart ? iconStartEl : null}
{this.hasContent ? contentEl : null}
{this.iconEnd ? iconEndEl : null}
</Tag>
<InteractiveContainer disabled={this.disabled}>
<Tag
aria-disabled={childElType === "a" ? toAriaBoolean(this.disabled || this.loading) : null}
aria-expanded={this.el.getAttribute("aria-expanded")}
aria-label={!this.loading ? getLabelText(this) : this.messages.loading}
aria-live="polite"
class={{
[CSS.buttonPadding]: noStartEndIcons,
[CSS.buttonPaddingShrunk]: !noStartEndIcons,
[CSS.contentSlotted]: this.hasContent,
[CSS.iconStartEmpty]: !this.iconStart,
[CSS.iconEndEmpty]: !this.iconEnd,
}}
disabled={childElType === "button" ? this.disabled || this.loading : null}
href={childElType === "a" && this.href}
name={childElType === "button" && this.name}
onClick={this.handleClick}
rel={childElType === "a" && this.rel}
tabIndex={this.disabled ? -1 : null}
target={childElType === "a" && this.target}
title={this.tooltipText}
type={childElType === "button" && this.type}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.setChildEl}
>
{loaderNode}
{this.iconStart ? iconStartEl : null}
{this.hasContent ? contentEl : null}
{this.iconEnd ? iconEndEl : null}
</Tag>
</InteractiveContainer>
);
}

Expand Down
37 changes: 20 additions & 17 deletions packages/calcite-components/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
connectInteractive,
disconnectInteractive,
InteractiveComponent,
InteractiveContainer,
updateHostInteraction,
} from "../../utils/interactive";
import { isActivationKey } from "../../utils/key";
Expand Down Expand Up @@ -256,23 +257,25 @@ export class Checkbox
render(): VNode {
return (
<Host onClick={this.clickHandler} onKeyDown={this.keyDownHandler}>
<div
aria-checked={toAriaBoolean(this.checked)}
aria-label={getLabelText(this)}
class="toggle"
onBlur={this.onToggleBlur}
onFocus={this.onToggleFocus}
role="checkbox"
tabIndex={this.disabled ? undefined : 0}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(toggleEl) => (this.toggleEl = toggleEl)}
>
<svg aria-hidden="true" class="check-svg" viewBox="0 0 16 16">
<path d={this.getPath()} />
</svg>
<slot />
</div>
<HiddenFormInputSlot component={this} />
<InteractiveContainer disabled={this.disabled}>
<div
aria-checked={toAriaBoolean(this.checked)}
aria-label={getLabelText(this)}
class="toggle"
onBlur={this.onToggleBlur}
onFocus={this.onToggleFocus}
role="checkbox"
tabIndex={this.disabled ? undefined : 0}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={(toggleEl) => (this.toggleEl = toggleEl)}
>
<svg aria-hidden="true" class="check-svg" viewBox="0 0 16 16">
<path d={this.getPath()} />
</svg>
<slot />
</div>
<HiddenFormInputSlot component={this} />
</InteractiveContainer>
</Host>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
connectInteractive,
disconnectInteractive,
InteractiveComponent,
InteractiveContainer,
updateHostInteraction,
} from "../../utils/interactive";
import { createObserver } from "../../utils/observers";
Expand Down Expand Up @@ -241,19 +242,22 @@ export class ChipGroup implements InteractiveComponent {
render(): VNode {
const role =
this.selectionMode === "none" || this.selectionMode === "multiple" ? "group" : "radiogroup";
const { disabled } = this;

return (
<div
aria-disabled={toAriaBoolean(this.disabled)}
aria-label={this.label}
class="container"
role={role}
>
<slot
onSlotchange={this.updateItems}
ref={(el) => (this.slotRefEl = el as HTMLSlotElement)}
/>
</div>
<InteractiveContainer disabled={disabled}>
<div
aria-disabled={toAriaBoolean(disabled)}
aria-label={this.label}
class="container"
role={role}
>
<slot
onSlotchange={this.updateItems}
ref={(el) => (this.slotRefEl = el as HTMLSlotElement)}
/>
</div>
</InteractiveContainer>
);
}
}
Loading

0 comments on commit b15c052

Please sign in to comment.