Files
ionic-framework/docs/component-guide.md
Brandy Carney b315b0cb29 chore(docs): consolidate the developer resource files into a docs/ directory (#29266)
Start your review here 👉
[docs/README.md](https://github.com/ionic-team/ionic-framework/blob/FW-6107/docs/README.md)

## What is the current behavior?

Documentation files with information on how to contribute, component
implementations, testing, etc. are scattered throughout various folders
in this repository.

## What is the new behavior?

Consolidates the documentation files into a root `docs/` directory for
easier discovery and organization.

`/docs` tree:

```
├── _config.yml
├── component-guide.md
├── CONTRIBUTING.md
├── README.md
├── sass-guidelines.md
├── angular
│   ├── README.md
│   └── testing.md
├── core
│   ├── README.md
│   └── testing
│       ├── README.md
│       ├── api.md
│       ├── best-practices.md
│       ├── preview-changes.md
│       └── usage-instructions.md
├── react
│   ├── README.md
│   └── testing.md
├── react-router
│   ├── README.md
│   └── testing.md
├── vue
│   ├── README.md
│   └── testing.md
└── vue-router
    ├── README.md
    └── testing.md
```

**Migrates the following:**

| Previous Location | New Location |
| ----------------------------------------------------------- |
----------------------------------------- |
| `.github/COMPONENT-GUIDE.md` | `docs/component-guide.md` |
| `.github/CONTRIBUTING.md` | `docs/CONTRIBUTING.md` |
| `core/scripts/README.md` | `docs/core/testing/preview-changes.md` |
| `core/src/utils/test/playwright/docs/api.md` |
`docs/core/testing/api.md` |
| `core/src/utils/test/playwright/docs/best-practices.md` |
`docs/core/testing/best-practices.md` |
| `core/src/utils/test/playwright/docs/README.md` |
`docs/core/testing/README.md` |
| `core/src/utils/test/playwright/docs/usage-instructions.md` |
`docs/core/testing/usage-instructions.md` |
| `packages/angular/test/README.md` | `docs/angular/testing.md` |
| `packages/react-router/test/README.md` |
`docs/react-router/testing.md` |
| `packages/react/test/README.md` | `docs/react/testing.md` |
| `packages/react/test/base/README.md` | `docs/react/testing.md` |
| `packages/vue/test/README.md` | `docs/vue/testing.md` |

**Adds the following:**

| File | Description |
| ----------------------------- |
-----------------------------------------------------------------------
|
| `docs/sass-guidelines.md` | Sass Variable guidelines taken from
`ionic-framework-design-documents` |
| `docs/README.md` | Entry file that should link to all other files |
| `docs/_config.yml` | Config file for use with GitHub pages |
| `docs/core/README.md` | Description of core, links to contributing and
testing |
| `docs/angular/README.md` | Description of angular, links to
contributing and testing |
| `docs/react/README.md` | Description of react, links to contributing
and testing |
| `docs/react-router/README.md` | Description of react-router, links to
contributing and testing |
| `docs/vue/README.md` | Description of vue, links to contributing and
testing |
| `docs/vue-router/README.md` | Description of vue-router, links to
contributing and testing |
| `docs/vue-router/testing.md` | Testing file for vue-router, populated
from vue-router's main README |

**Does not** add any files for `angular-server`. This is because the
README is essentially empty and there is no testing in that directory. I
can add blank files if we want to have something to add to later.

**Does not** migrate the content of the packages' root `README.md`
files. These files are used for their npm package descriptions so we
should not edit them.

## Hosting Documentation

We can (and should) host these files using GitHub Pages. I have
duplicated them in a personal repository to see how this would look:
[docs-consolidation](https://brandyscarney.github.io/docs-consolidation/).

Doing so will require some formatting fixes (see [Sass
Guidelines](https://brandyscarney.github.io/docs-consolidation/sass-guidelines.html#-reusable-values))
so I did not publish them now but we can easily enable GitHub pages by
toggling a setting in this repository.

## Other information

- Verify that no documentation files were missed in the migration
- You can use these commands to search for `*.md` files in a directory:
    - `find core/src -type f -name "*.md" -print`
- `find packages/angular -type f -name "*.md" -not -path
"**/node_modules/*" -print`
- I did add some redirect links in some of the existing markdown files
so they might still exist for that reason
- We should probably break up the contributing + component guide
documentation into smaller files, such as including best practices, but
I wanted to get everything in the same place first
- The contributing has sections on each of the packages that we could
move to that package's docs folder:
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#core

---------

Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
2024-04-08 19:06:26 +00:00

21 KiB

Ionic Component Implementation Guide

Button States

Any component that renders a button should have the following states: disabled, focused, hover, activated. It should also have a Ripple Effect component added for Material Design.

Component Structure

JavaScript

A component that renders a native button should use the following structure:

<Host>
  <button class="button-native">
    <span class="button-inner">
      <slot></slot>
    </span>
  </button>
</Host>

Any other attributes and classes that are included are irrelevant to the button states, but it's important that this structure is followed and the classes above exist. In some cases they may be named something else that makes more sense, such as in item.

CSS

A mixin called button-state() has been added to make it easier to setup the states in each component.

@mixin button-state() {
  @include position(0, 0, 0, 0);

  position: absolute;

  content: "";

  opacity: 0;
}

The following styles should be set for the CSS to work properly. Note that the button-state() mixin is included in the ::after pseudo element of the native button.

.button-native {
  /**
   * All other CSS in this selector is irrelevant to button states
   * but the following are required styles
   */

  position: relative;

  overflow: hidden;
}

.button-native::after {
  @include button-state();
}

.button-inner {
  /**
   * All other CSS in this selector is irrelevant to button states
   * but the following are required styles
   */

  position: relative;

  z-index: 1;
}

Disabled

The disabled state should be set via prop on all components that render a native button. Setting a disabled state will change the opacity or color of the button and remove click events from firing.

JavaScript

The disabled property should be set on the component:

/**
  * If `true`, the user cannot interact with the button.
  */
@Prop({ reflectToAttr: true }) disabled = false;

Then, the render function should add the aria-disabled role to the host, a class that is the element tag name followed by disabled, and pass the disabled attribute to the native button:

render() {
  const { disabled } = this;

  return (
    <Host
      aria-disabled={disabled ? 'true' : null}
      class={{
        'button-disabled': disabled
      }}
    >
      <button disabled={disabled}>
        <slot></slot>
      </button>
    </Host>
  );
}

Note

If the class being added was for ion-back-button it would be back-button-disabled.

CSS

The following CSS at the bare minimum should be added for the disabled class, but it should be styled to match the spec:

:host(.button-disabled) {
  cursor: default;
  opacity: .5;
  pointer-events: none;
}

User Customization

TODO

Focused

The focused state should be enabled for elements with actions when tabbed to via the keyboard. This will only work inside of an ion-app. It usually changes the opacity or background of an element.

Warning

Do not use :focus because that will cause the focus to apply even when an element is tapped (because the element is now focused). Instead, we only want the focus state to be shown when it makes sense which is what the .ion-focusable utility mentioned below does.

Note

The :focus-visible pseudo-class mostly does the same thing as our JavaScript-driven utility. However, it does not work well with Shadow DOM components as the element that receives focus is typically inside of the Shadow DOM, but we usually want to set the :focus-visible state on the host so we can style other parts of the component. Using other combinations such as :has(:focus-visible) does not work because :has does not pierce the Shadow DOM (as that would leak implementation details about the Shadow DOM contents). :focus-within does work with the Shadow DOM, but that has the same problem as :focus that was mentioned before. Unfortunately, a :focus-visible-within pseudo-class does not exist yet.

Important

Make sure the component has the correct component structure before continuing.

JavaScript

The ion-focusable class needs to be set on an element that can be focused:

render() {
  return (
    <Host class="ion-focusable">
      <slot></slot>
    </Host>
  );
}

Once that is done, the element will get the ion-focused class added when the element is tabbed to.

CSS

Components should be written to include the following focused variables for styling:

 /**
   * @prop --color-focused: Color of the button when tabbed to with the keyboard
   * @prop --background-focused: Background of the button when tabbed to with the keyboard
   * @prop --background-focused-opacity: Opacity of the background when tabbed to with the keyboard
   */

Style the ion-focused class based on the spec for that element:

:host(.ion-focused) .button-native {
  color: var(--color-focused);

  &::after {
    background: var(--background-focused);

    opacity: var(--background-focused-opacity);
  }
}

Important

Order matters! Focused should be before the activated and hover states.

User Customization

Setting the focused state on the ::after pseudo-element allows the user to customize the focused state without knowing what the default opacity is set at. A user can customize in the following ways to have a solid red background on focus, or they can leave out --background-focused-opacity and the button will use the default focus opacity to match the spec.

ion-button {
  --background-focused: red;
  --background-focused-opacity: 1;
}

Hover

The hover state happens when a user moves their cursor on top of an element without pressing on it. It should not happen on mobile, only on desktop devices that support hover.

Note

Some Android devices incorrectly report their inputs which can result in certain devices receiving hover events when they should not.

Important

Make sure the component has the correct component structure before continuing.

CSS

Components should be written to include the following hover variables for styling:

 /**
   * @prop --color-hover: Color of the button on hover
   * @prop --background-hover: Background of the button on hover
   * @prop --background-hover-opacity: Opacity of the background on hover
   */

Style the :hover based on the spec for that element:

@media (any-hover: hover) {
  :host(:hover) .button-native {
    color: var(--color-hover);

    &::after {
      background: var(--background-hover);

      opacity: var(--background-hover-opacity);
    }
  }
}

Important

Order matters! Hover should be before the activated state.

User Customization

Setting the hover state on the ::after pseudo-element allows the user to customize the hover state without knowing what the default opacity is set at. A user can customize in the following ways to have a solid red background on hover, or they can leave out --background-hover-opacity and the button will use the default hover opacity to match the spec.

ion-button {
  --background-hover: red;
  --background-hover-opacity: 1;
}

Activated

The activated state should be enabled for elements with actions on "press". It usually changes the opacity or background of an element.

Warning

:active should not be used here as it is not received on mobile Safari unless the element has a touchstart listener (which we don't necessarily want to have to add to every element). From Safari Web Content Guide:

On iOS, mouse events are sent so quickly that the down or active state is never received. Therefore, the :active pseudo state is triggered only when there is a touch event set on the HTML element

Important

Make sure the component has the correct component structure before continuing.

JavaScript

The ion-activatable class needs to be set on an element that can be activated:

render() {
  return (
    <Host class="ion-activatable">
      <slot></slot>
    </Host>
  );
}

Once that is done, the element will get the ion-activated class added on press after a small delay. This delay exists so that the active state does not show up when an activatable element is tapped while scrolling.

In addition to setting that class, ion-activatable-instant can be set in order to have an instant press with no delay:

<Host class="ion-activatable ion-activatable-instant">

CSS

 /**
   * @prop --color-activated: Color of the button when pressed
   * @prop --background-activated: Background of the button when pressed
   * @prop --background-activated-opacity: Opacity of the background when pressed
   */

Style the ion-activated class based on the spec for that element:

:host(.ion-activated) .button-native {
  color: var(--color-activated);

  &::after {
    background: var(--background-activated);

    opacity: var(--background-activated-opacity);
  }
}

Important

Order matters! Activated should be after the focused & hover states.

User Customization

Setting the activated state on the ::after pseudo-element allows the user to customize the activated state without knowing what the default opacity is set at. A user can customize in the following ways to have a solid red background on press, or they can leave out --background-activated-opacity and the button will use the default activated opacity to match the spec.

ion-button {
  --background-activated: red;
  --background-activated-opacity: 1;
}

Ripple Effect

The ripple effect should be added to elements for Material Design. It requires the ion-activatable class to be set on the parent element to work, and relative positioning on the parent.

 render() {
  const mode = getIonMode(this);

return (
  <Host
    class={{
      'ion-activatable': true,
    }}
  >
    <button>
      <slot></slot>
      {mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
    </button>
  </Host>
);

The ripple effect can also accept a different type. By default it is "bounded" which will expand the ripple effect from the click position outwards. To add a ripple effect that always starts in the center of the element and expands in a circle, set the type to "unbounded". An unbounded ripple will exceed the container, so add overflow: hidden to the parent to prevent this.

Make sure to style the ripple effect for that component to accept a color:

ion-ripple-effect {
  color: var(--ripple-color);
}

Example Components

References

Accessibility

Checkbox

Example Components

VoiceOver

In order for VoiceOver to work properly with a checkbox component there must be a native input with type="checkbox", and aria-checked and role="checkbox" must be on the host element. The aria-hidden attribute needs to be added if the checkbox is disabled, preventing iOS users from selecting it:

render() {
  const { checked, disabled } = this;

  return (
    <Host
      aria-checked={`${checked}`}
      aria-hidden={disabled ? 'true' : null}
      role="checkbox"
    >
      <input
        type="checkbox"
      />
      ...
    </Host>
  );
}

NVDA

It is required to have aria-checked on the native input for checked to read properly and disabled to prevent tabbing to the input:

render() {
  const { checked, disabled } = this;

  return (
    <Host
      aria-checked={`${checked}`}
      aria-hidden={disabled ? 'true' : null}
      role="checkbox"
    >
      <input
        type="checkbox"
        aria-checked={`${checked}`}
        disabled={disabled}
      />
      ...
    </Host>
  );
}

Labels

A helper function has been created to get the proper aria-label for the checkbox. This can be imported as getAriaLabel like the following:

const { label, labelId, labelText } = getAriaLabel(el, inputId);

where el and inputId are the following:

export class Checkbox implements ComponentInterface {
  private inputId = `ion-cb-${checkboxIds++}`;

  @Element() el!: HTMLElement;

  ...
}

This can then be added to the Host like the following:

<Host
  aria-labelledby={label ? labelId : null}
  aria-checked={`${checked}`}
  aria-hidden={disabled ? 'true' : null}
  role="checkbox"
>

In addition to that, the checkbox input should have a label added:

<Host
  aria-labelledby={label ? labelId : null}
  aria-checked={`${checked}`}
  aria-hidden={disabled ? 'true' : null}
  role="checkbox"
>
  <label htmlFor={inputId}>
    {labelText}
  </label>
  <input
    type="checkbox"
    aria-checked={`${checked}`}
    disabled={disabled}
    id={inputId}
  />

Hidden Input

A helper function to render a hidden input has been added, it can be added in the render:

renderHiddenInput(true, el, name, (checked ? value : ''), disabled);

This is required for the checkbox to work with forms.

Known Issues

When using VoiceOver on macOS, Chrome will announce the following when you are focused on a checkbox:

currently on a checkbox inside of a checkbox

This is a compromise we have to make in order for it to work with the other screen readers & Safari.

Switch

Example Components

Voiceover

In order for VoiceOver to work properly with a switch component there must be a native input with type="checkbox" and role="switch", and aria-checked and role="switch" must be on the host element. The aria-hidden attribute needs to be added if the switch is disabled, preventing iOS users from selecting it:

render() {
  const { checked, disabled } = this;

  return (
    <Host
      aria-checked={`${checked}`}
      aria-hidden={disabled ? 'true' : null}
      role="switch"
    >
      <input
        type="checkbox"
        role="switch"
      />
      ...
    </Host>
  );
}

NVDA

It is required to have aria-checked on the native input for checked to read properly and disabled to prevent tabbing to the input:

render() {
  const { checked, disabled } = this;

  return (
    <Host
      aria-checked={`${checked}`}
      aria-hidden={disabled ? 'true' : null}
      role="switch"
    >
      <input
        type="checkbox"
        role="switch"
        aria-checked={`${checked}`}
        disabled={disabled}
      />
      ...
    </Host>
  );
}

Labels

A helper function has been created to get the proper aria-label for the switch. This can be imported as getAriaLabel like the following:

const { label, labelId, labelText } = getAriaLabel(el, inputId);

where el and inputId are the following:

export class Toggle implements ComponentInterface {
  private inputId = `ion-tg-${toggleIds++}`;

  @Element() el!: HTMLElement;

  ...
}

This can then be added to the Host like the following:

<Host
  aria-labelledby={label ? labelId : null}
  aria-checked={`${checked}`}
  aria-hidden={disabled ? 'true' : null}
  role="switch"
>

In addition to that, the checkbox input should have a label added:

<Host
  aria-labelledby={label ? labelId : null}
  aria-checked={`${checked}`}
  aria-hidden={disabled ? 'true' : null}
  role="switch"
>
  <label htmlFor={inputId}>
    {labelText}
  </label>
  <input
    type="checkbox"
    role="switch"
    aria-checked={`${checked}`}
    disabled={disabled}
    id={inputId}
  />

Hidden Input

A helper function to render a hidden input has been added, it can be added in the render:

renderHiddenInput(true, el, name, (checked ? value : ''), disabled);

This is required for the switch to work with forms.

Known Issues

When using VoiceOver on macOS or iOS, Chrome will announce the switch as a checked or unchecked checkbox:

You are currently on a switch. To select or deselect this checkbox, press Control-Option-Space.

There is a WebKit bug open for this: https://bugs.webkit.org/show_bug.cgi?id=196354

Accordion

Example Components

NVDA

In order to use the arrow keys to navigate the accordions, users must be in "Focus Mode". Typically, NVDA automatically switches between Browse and Focus modes when inside of a form, but not every accordion needs a form.

You can either wrap your ion-accordion-group in a form, or manually toggle Focus Mode using NVDA's keyboard shortcut.

Rendering Anchor or Button

Certain components can render an <a> or a <button> depending on the presence of an href attribute.

Example Components

Component Structure

JavaScript

In order to implement a component with a dynamic tag type, set the property that it uses to switch between them, we use href:

/**
 * Contains a URL or a URL fragment that the hyperlink points to.
 * If this property is set, an anchor tag will be rendered.
 */
@Prop() href: string | undefined;

Then use that in order to render the tag:

render() {
  const TagType = href === undefined ? 'button' : 'a' as any;

  return (
    <Host>
      <TagType>
        <slot></slot>
      </TagType>
    </Host>
  );
}

If the component can render an <a>, <button> or a <div> add in more properties such as a button attribute in order to check if it should render a button.

Converting Scoped to Shadow

CSS

There will be some CSS issues when converting to shadow. Below are some of the differences.

Targeting host + slotted child

/* IN SCOPED */
:host(.ion-color)::slotted(ion-segment-button)

/* IN SHADOW*/
:host(.ion-color) ::slotted(ion-segment-button)

Targeting host-context + host (with a :not)

/* IN SCOPED */
:host-context(ion-toolbar.ion-color):not(.ion-color) {

/* IN SHADOW */
:host-context(ion-toolbar.ion-color):host(:not(.ion-color))  {

Targeting host-context + host (with a :not) > slotted child

/* IN SCOPED */
:host-context(ion-toolbar:not(.ion-color)):not(.ion-color)::slotted(ion-segment-button) {

/* IN SHADOW*/
:host-context(ion-toolbar:not(.ion-color)):host(:not(.ion-color)) ::slotted(ion-segment-button) {

RTL

When you need to support both LTR and RTL modes, try to avoid using values such as left and right. For certain CSS properties, you can use the appropriate mixin to have this handled for you automatically.

For example, if you wanted transform-origin to be RTL-aware, you would use the transform-origin mixin:

@include transform-origin(start, center);

This would output transform-origin: left center in LTR mode and transform-origin: right center in RTL mode.

These mixins depend on the :host-context pseudo-class when used inside of shadow components, which is not supported in WebKit. As a result, these mixins will not work in Safari for macOS and iOS when applied to shadow components.

To work around this, you should set an RTL class on the host of your component and set your RTL styles by targeting that class:

<Host
class={{
  'my-cmp-rtl': document.dir === 'rtl'
}}
>
 ...
</Host>
:host {
  transform-origin: left center;
}

:host(.my-cmp-rtl) {
  transform-origin: right center;
}