Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contextual-help): add contextual help pattern #4285

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/contextual-help/.npmignore
@@ -0,0 +1,2 @@
stories
test
81 changes: 81 additions & 0 deletions packages/contextual-help/README.md
@@ -0,0 +1,81 @@
## Description

A `sp-contextual-help` shows a user extra information about the state of either an adjacent component or an entire view. It explains a high-level topic about an experience and can point users to more information elsewhere.

### Usage

[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/contextual-help?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/contextual-help)
[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/contextual-help?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/contextual-help)

```
yarn add @spectrum-web-components/contextual-help
```

Import the side effectful registration of `<sp-contextual-help>` via:

```
import '@spectrum-web-components/contextual-help/sp-contextual-help.js';
```

When looking to leverage the `ContextualHelp` base class as a type and/or for extension purposes, do so via:

```
import { ContextualHelp } from '@spectrum-web-components/contextual-help';
```

## Example

```html
<sp-contextual-help headline="Permission required">
Your admin must grant you permission before you can create a segment.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Request permission
</sp-link>
</sp-contextual-help>
```

## Help

Use `variant="help"` for helpful content: more detailed, in-depth guidance about a task, UI element, tool, or keyboard shortcuts. This may include an image, video, or link and should be helpful in tone.

```html
<sp-contextual-help headline="What is a segment?" variant="help">
Segments identify who your visitors are, what devices and services they use,
where they navigate from, and much more.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Learn more about segments
</sp-link>
</sp-contextual-help>
```

## Placement

By default a `sp-contextual-help` will render its popover at the `bottom-start` position. This can be customized using the `placement` attribute and supports [all the placement options](http://localhost:8000/components/overlay-trigger/#placement) an `overlay-trigger` component supports.

```html
<sp-contextual-help headline="Permission required" placement="top-start">
Your admin must grant you permission before you can create a segment.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Request permission
</sp-link>
</sp-contextual-help>
```

## Events

`sp-contextual-help` does not fire any events of its own. You can listen, however, for the `sp-open` and `sp-closed` events which are fired when the popover opens or closes.

## Accessibility

Given that the trigger is an icon-only `sp-action-button`, it is important to provide an accessible name for it, so that it can be properly announced by screen readers.
By default, the `sp-contextual-help` uses an `aria-label` property with either "Informations" or "Help" as values, depending on the component's `variant`.
You can customize this using the `label` attribute.
4 changes: 4 additions & 0 deletions packages/contextual-help/exports.json
@@ -0,0 +1,4 @@
{
"./src/*": "./src/*.js",
"./sp-contextual-help.js": "./sp-contextual-help.js"
}
77 changes: 77 additions & 0 deletions packages/contextual-help/package.json
@@ -0,0 +1,77 @@
{
"name": "@spectrum-web-components/contextual-help",
"version": "0.0.1",
"publishConfig": {
"access": "public"
},
"description": "Web component implementation of a Spectrum design ContextualHelp",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/adobe/spectrum-web-components.git",
"directory": "packages/contextual-help"
},
"author": "",
"homepage": "https://adobe.github.io/spectrum-web-components/components/contextual-help",
"bugs": {
"url": "https://github.com/adobe/spectrum-web-components/issues"
},
"main": "src/index.js",
"module": "src/index.js",
"type": "module",
"exports": {
".": {
"development": "./src/index.dev.js",
"default": "./src/index.js"
},
"./package.json": "./package.json",
"./src/ContextualHelp.js": {
"development": "./src/ContextualHelp.dev.js",
"default": "./src/ContextualHelp.js"
},
"./src/contextual-help.css.js": "./src/contextual-help.css.js",
"./src/index.js": {
"development": "./src/index.dev.js",
"default": "./src/index.js"
},
"./sp-contextual-help.js": {
"development": "./sp-contextual-help.dev.js",
"default": "./sp-contextual-help.js"
}
},
"scripts": {
"test": "echo \"Error: run tests from mono-repo root.\" && exit 1"
},
"files": [
"**/*.d.ts",
"**/*.js",
"**/*.js.map",
"custom-elements.json",
"!stories/",
"!test/"
],
"keywords": [
"spectrum css",
"web components",
"lit-element",
"lit-html"
],
"dependencies": {
"@spectrum-web-components/action-button": "^0.42.2",
"@spectrum-web-components/base": "^0.42.2",
"@spectrum-web-components/dialog": "^0.42.2",
"@spectrum-web-components/icons-workflow": "^0.42.2",
"@spectrum-web-components/overlay": "^0.42.2",
"@spectrum-web-components/popover": "^0.42.2"
},
"devDependencies": {
"@spectrum-css/contextualhelp": "^2.1.5"
},
"types": "./src/index.d.ts",
"customElements": "custom-elements.json",
"sideEffects": [
"./sp-*.js",
"./**/*.dev.js",
"./**/*.dev.js"
]
}
20 changes: 20 additions & 0 deletions packages/contextual-help/sp-contextual-help.ts
@@ -0,0 +1,20 @@
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import { ContextualHelp } from './src/ContextualHelp.js';

customElements.define('sp-contextual-help', ContextualHelp);

declare global {
interface HTMLElementTagNameMap {
'sp-contextual-help': ContextualHelp;
}
}
205 changes: 205 additions & 0 deletions packages/contextual-help/src/ContextualHelp.ts
@@ -0,0 +1,205 @@
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import {
CSSResultArray,
html,
render,
SpectrumElement,
TemplateResult,
} from '@spectrum-web-components/base';
import '@spectrum-web-components/action-button/sp-action-button.js';
import '@spectrum-web-components/overlay/sp-overlay.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-help-outline.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-info-outline.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';
import {
property,
state,
} from '@spectrum-web-components/base/src/decorators.js';
import {
removeSlottableRequest,
SlottableRequestEvent,
} from '@spectrum-web-components/overlay/src/slottable-request-event.js';
import type { Placement } from '@spectrum-web-components/overlay/src/overlay-types.js';
import {
IS_MOBILE,
MatchMediaController,
} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js';

import styles from './contextual-help.css.js';

/**
* Spectrum Contextual help provides additional information about
* the state of either an adjacent component or an entire view.
* @element sp-contextual-help
*
* @slot Text content to display in the popover
* @slot link - link to additional informations
*/
export class ContextualHelp extends SpectrumElement {
protected isMobile = new MatchMediaController(this, IS_MOBILE);

public static override get styles(): CSSResultArray {
return [styles];
}

/**
* Optional title to be displayed inside the popover.
* @param {String} headline
*/
@property()
public headline?: string;

/**
* Provides an accessible name for the action button trigger.
* @param {String} label
*/
@property()
public label?: string;

/**
* The `variant` property applies specific styling on the action button trigger.
* @param {String} variant
*/
@property()
public variant: 'info' | 'help' = 'info';

/**
* @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"}
* @attr
*/
@property({ reflect: true })
public placement?: Placement = 'bottom-start';

/**
* The `offset` property accepts either a single number, to
* define the offset of the Popover along the main axis from
* the action button, or 2-tuple, to define the offset along the
* main axis and the cross axis.
*/
@property({ type: Number })
public offset: number | [number, number] = 0;

@state()
overlayOpen = false;
Rocss marked this conversation as resolved.
Show resolved Hide resolved

private get buttonAriaLabel(): string {
if (this.label) {
return this.label;
} else {
if (this.variant === 'help') {
return 'Help';
}
return 'Informations';
}
}

private renderPopover(event: SlottableRequestEvent): void {
event.stopPropagation();

if (event.data === removeSlottableRequest) {
render(undefined, event.target as HTMLElement);
return;
}

import('@spectrum-web-components/popover/sp-popover.js');

const template = html`
<sp-popover class="popover">
<section>
${this.headline &&
html`
<h2 class="heading">${this.headline}</h2>
Rocss marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user should have control over the heading level leveraged here. They should also have the ability to render HTML into this content.

Likely we want something like:

<div class="heading"> <!-- holds style delivery -->
  <slot name="heading"> <!-- normalize this name to that in other elements -->
    <h2>${this.heading}</h2> <!-- holds default headline level but reverts default heading styles -->
  </slot>
</div>

Or something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually did not use a slot because of the fact that users have too much flexibility on the heading here and can actually use other things besides heading elements, such as p or div. I wanted to kind of enforce this one to be a heading. I can of course replace it with a slot.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really difficult balance to tread. We've been asked in the past by the accessibility team to ensure that patterns like this can accept multiple levels of heading to ensure the content is accessible, even if we haven't always gotten back to the related fixes 🙈. If there were other practical approaches to ensuring default delivery AND supporting varying levels of headlines, any new insight into the conversation that you might have would be greatly appreciated.

strictness vs support vs flexibility 😓

Copy link
Contributor Author

@Rocss Rocss Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a heading slot in the end.
Can we throw a warning if the slotted element is not a heading element (not h1,2,3... or role="heading")?

LE: well actually you can add nested slotted element which has a heading inside....

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of like that as an option, though the work to do so is non-trivial. Might be worth creating an issue to come back to that at a later date?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll create an issue for this. Do you see this being useful for other components as well, such as Dialogs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than a dev mode warning I would like to get it surfaced up to the documentation site to have more visibility

`}
<slot></slot>
<div class="link">
Rocss marked this conversation as resolved.
Show resolved Hide resolved
<slot name="link"></slot>
</div>
</section>
</sp-popover>
`;

render(template, event.target as HTMLElement);
Rocss marked this conversation as resolved.
Show resolved Hide resolved
}

private renderDialog(event: SlottableRequestEvent): void {
event.stopPropagation();

if (event.data === removeSlottableRequest) {
render(undefined, event.target as HTMLElement);
return;
}

import('@spectrum-web-components/dialog/sp-dialog-wrapper.js');
const headlineVisibility = !this.headline ? 'none' : undefined;

const template = html`
<sp-dialog-wrapper
dismissable
underlay
headline=${ifDefined(this.headline)}
headline-visibility=${ifDefined(headlineVisibility)}
>
<slot></slot>
<div class="link">
<slot name="link"></slot>
</div>
</sp-dialog-wrapper>
`;

render(template, event.target as HTMLElement);
}

protected override render(): TemplateResult {
const actualPlacement = this.isMobile.matches
? undefined
: this.placement;

return html`
<sp-action-button
quiet
size="s"
id="trigger"
aria-label=${this.buttonAriaLabel}
?is-active=${this.overlayOpen}
>
${this.variant === 'help'
? html`
<sp-icon-help-outline
slot="icon"
></sp-icon-help-outline>
`
: html`
<sp-icon-info-outline
slot="icon"
></sp-icon-info-outline>
`}
</sp-action-button>
<sp-overlay
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may also want to look at the trigger directive here, as it lists as slightly more performant that leveraging the element.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of this directive. I think it is really cool that is handles the slottable-request event for you.
A thing I did not found straight-forward is the open/close events. Is there a better way to know when it opens/closes events besides adding event listeners on the ContextualHelp class?
I'd need to know of open/close states for the button's active state. Would you consider a good addition to add active attribute on the trigger as a built-in feature of the trigger directive?

Copy link
Contributor

@Westbrook Westbrook Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not even "brand new" quite yet, as we're working through its specifics internally and with some select early clients before we mark it as "for consumption". Some approach to this seems like an important part of this API, but I'm not sure direct manual handling of specific attributes inside of the directive is the right thing to do here.

While not pretty, that best I can think of with the established API is to use specific handing of slottable-request to toggle open which is rendered to active. In API extensions, I'd love to chat more on the possibilities around:

  • adding handleOpen/handleClose callbacks to the options bag
  • advanced used of the trigger directive as a TriggerController that held the open state for you to be able to render with (feels either really smart of trying too hard)
  • other (better) things...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a consumer, the first thing I looked for was a handleOpen and handleClose callback, as slottable-request event is part of the internal works of this directive and I don't know if as a consumer I should be aware of that.

sp-open/sp-close events are fine, but are actually a bit delayed, because of the CSS transitions that have dispatched I guess. So the question with handleOpen / handleClose would be if the timing of calling these callbacks should be on the handleSlottableRequest method, or a bit late, on sp-open and sp-closed.

In my case as you mentioned sp-open/sp-close are a bit late :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sp-open and sp-close are not "correct for this situation.

If we went with callbacks, they would likely happen at slottable-request time, because that represents the outside of the open/close transition, rather than only the end of each transition. But, it could be argued that this belongs at the beginning of each transition, which is when the state actually changes. 🤔

Relying on the state feels nicer than relying on a knowable part of the lifecycle, which is why I like the TriggerController, but there is much to think about here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that best I can think of with the established API is to use specific handing of slottable-request to toggle open which is rendered to active.

Regarding this, I don't think this works, as the event is attached on the sp-overlay, which I don't have access to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. So we can ship once and edit later leveraging <sp-overlay> directly, or we can hold this for an expansion of the directives capabilities. Do you have a preference on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on when exactly would the directive be expanded. If it's more than 1-2 releases, then I'd ship it as it is and come back when this behaviour is available.

trigger="trigger@click"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving to open likely means moving to .triggerElement=... instead and ?open=${this.open} which them means you might want to leverage DependencyManagerController as seen in Picker.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rocss Updates on this? Do you think using DependencyManagerController here would benefit the component?

Copy link
Contributor Author

@Rocss Rocss May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tarun I don't understand why moving to open would mean moving to .triggerElement=... instead.

I tried using the DependencyManagerController but I found out it does not play well with the slottable-request event:
You need to open the sp-overlay in order to register the dependencies, but you can not open it unless the dependencies are registered. (?open=${this.open &&this.dependencyManager.loaded})

placement=${ifDefined(actualPlacement)}
type=${this.isMobile.matches ? 'modal' : 'auto'}
receives-focus="true"
.offset=${this.offset}
@sp-opened=${() => {
Rocss marked this conversation as resolved.
Show resolved Hide resolved
this.overlayOpen = true;
}}
@sp-closed=${() => {
this.overlayOpen = false;
}}
Rocss marked this conversation as resolved.
Show resolved Hide resolved
@slottable-request=${this.isMobile.matches
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to also offer the option for a consumer to lazily render the content of this overlay?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not seem like the kind of component you interact with way too many times, as its content does not change often.
So I am thinking it would be a good practice to lazy load its content. On the other hand, its content is indeed modest. What is your opinion on this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd want it at some point. It is a performance optimization, so I'm OK with excluding it for the initial release, but prefer to take your lead as you're the one actually doing the work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd ship it as it is for a component which theoretically should have just some text inside.

? this.renderDialog
: this.renderPopover}
></sp-overlay>
`;
}
}