Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6e3e4e8
new story for tooltip directive with options
anna-lach Sep 12, 2025
6df361b
Merge remote-tracking branch 'origin/main' into fix/2026-tooltip-tool…
anna-lach Sep 15, 2025
14a8830
partially adding options tooltip directive
anna-lach Sep 15, 2025
f5c6923
story with directive with options changed, improved tooltip directive…
anna-lach Sep 16, 2025
438e2c5
Fixing setting tooltip properties and tooltip options in the tooltip …
anna-lach Sep 17, 2025
9261864
Improving directive
anna-lach Sep 17, 2025
a179ad1
directive changes, trying to fix the button-has-label rule to get it …
anna-lach Sep 17, 2025
9352ab8
Merge remote-tracking branch 'origin/main' into fix/2026-tooltip-tool…
anna-lach Sep 18, 2025
e35383c
some tooltip directive changes, unit tests, documentation of tooltip …
anna-lach Sep 18, 2025
497d78e
changes added to the button-has-label rule, tests for the ESLint rule
anna-lach Sep 18, 2025
aa4d83f
small changes
anna-lach Sep 19, 2025
44961b1
small changes
anna-lach Sep 19, 2025
f334a09
changeset
anna-lach Sep 19, 2025
185fd6a
order fixes
anna-lach Sep 19, 2025
5e31bbc
small story change
anna-lach Sep 19, 2025
af26c62
changed button-has-label ruloe (finally working properly not only in …
anna-lach Sep 19, 2025
f2e260a
fixed attribute in the example
anna-lach Sep 19, 2025
c7db303
Merge remote-tracking branch 'origin/main' into fix/2026-tooltip-tool…
anna-lach Sep 19, 2025
3c32931
changes after review, removed context and parentNode options from the…
anna-lach Sep 22, 2025
d0a38e0
tooltip directive documentation updated
anna-lach Sep 22, 2025
a3fdd08
test fixes
anna-lach Sep 22, 2025
a5396e9
Merge remote-tracking branch 'origin/main' into fix/2026-tooltip-tool…
anna-lach Sep 22, 2025
4b3dfad
Update changeset
anna-lach Sep 22, 2025
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
6 changes: 6 additions & 0 deletions .changeset/afraid-tools-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sl-design-system/tooltip': minor
---

Adds support for passing a config object to the tooltip directive (same as `Tooltip.lazy`).
The directive now accepts either a string (content) or a `TooltipConfig` with configurable properties like: `position`, `maxWidth` and `ariaRelation`.
5 changes: 5 additions & 0 deletions .changeset/upset-ghosts-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/eslint-plugin-slds': minor
---

Improved 'button-has-label' rule: it now also accepts the tooltip directive when `ariaRelation: 'label'` is set.
63 changes: 62 additions & 1 deletion packages/components/tooltip/src/tooltip-directive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { expect, fixture } from '@open-wc/testing';
import { Button } from '@sl-design-system/button';
import { type PopoverPosition } from '@sl-design-system/shared';
import { LitElement, type TemplateResult, html } from 'lit';
import { spy, stub } from 'sinon';
import { tooltip } from './tooltip-directive.js';
import { Tooltip } from './tooltip.js';

describe('tooltip()', () => {
it('should create a lazy tooltip on the host element', async () => {
spy(Tooltip, 'lazy');
const lazySpy = spy(Tooltip, 'lazy');

const el = await fixture(html`<div ${tooltip('content')}>Host</div>`);

expect(Tooltip.lazy).to.have.been.calledOnce;
expect(Tooltip.lazy).to.have.been.calledWith(el);

lazySpy.restore();
});

it('should log a warning if the custom element is not defined on the document', async () => {
Expand Down Expand Up @@ -98,4 +101,62 @@ describe('tooltip()', () => {
// Cleanup
el.remove();
});

it('should pass tooltip options via config to the tooltip', async () => {
try {
if (!customElements.get('sl-tooltip')) {
customElements.define('sl-tooltip', Tooltip);
}
} catch {
// empty
}

const lazySpy = spy(Tooltip, 'lazy');

const el: HTMLElement = await fixture(
html`<div ${tooltip('content', { ariaRelation: 'label' })} tabindex="0">Host</div>`
);

// Trigger the lazy tooltip creation
el.focus();

expect(lazySpy).to.have.been.calledOnce;
expect(lazySpy.getCall(0).args[2]).to.deep.equal({ ariaRelation: 'label' });

const tooltipEl = el.nextElementSibling as Tooltip | null;

expect(tooltipEl).to.exist;
expect(el).to.have.attribute('aria-labelledby', tooltipEl?.id);

lazySpy.restore();
});

it('should apply tooltip properties (position, maxWidth) from config to the tooltip', async () => {
try {
if (!customElements.get('sl-tooltip')) {
customElements.define('sl-tooltip', Tooltip);
}
} catch {
// empty
}

const el: HTMLElement = await fixture(html`
<sl-button ${tooltip('Tooltip example', { position: 'bottom' as PopoverPosition, maxWidth: 150 })} tabindex="0"
>Button</sl-button
>
`);

// Trigger the lazy tooltip creation
el.focus();

const tooltipEl = el.nextElementSibling as Tooltip | null;

expect(tooltipEl).to.exist;
expect(tooltipEl?.position).to.equal('bottom');
expect(tooltipEl?.maxWidth).to.equal(150);
expect(tooltipEl).to.have.text('Tooltip example');

// Cleanup
el.remove();
});
});
44 changes: 34 additions & 10 deletions packages/components/tooltip/src/tooltip-directive.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { render } from 'lit';
import { AsyncDirective } from 'lit/async-directive.js';
import { type DirectiveParameters, type ElementPart, directive } from 'lit/directive.js';
import { Tooltip } from './tooltip.js';
import { type ElementPart, directive } from 'lit/directive.js';
import { Tooltip, TooltipOptions } from './tooltip.js';

/** Configuration options for the tooltip directive. */
export type TooltipDirectiveConfig = Partial<TooltipOptions> & TooltipProperties;

/** Tooltip public properties that can be set. */
type TooltipProperties = {
position?: Tooltip['position'];
maxWidth?: number;
};

type TooltipDirectiveParams = [content: unknown, config?: TooltipDirectiveConfig];

/** Provides a Lit directive tooltip that attaches a lazily created Tooltip instance to a host element. */
export class TooltipDirective extends AsyncDirective {
config: TooltipDirectiveConfig = {};
content?: unknown;
part?: ElementPart;
tooltip?: Tooltip | (() => void);
Expand All @@ -22,27 +35,38 @@ export class TooltipDirective extends AsyncDirective {
this.#setup();
}

render(_content: unknown): void {}
render(_content: unknown, _config?: TooltipDirectiveConfig): void {}

renderContent(): void {
render(this.content, this.tooltip as Tooltip, this.part!.options);
}

override update(part: ElementPart, [content]: DirectiveParameters<this>): void {
override update(part: ElementPart, [content, config]: TooltipDirectiveParams): void {
this.content = content;

if (config) {
this.config = { ...this.config, ...config };
}

this.part = part;

this.#setup();
}

#setup(): void {
if (this.part!.element)
this.tooltip ||= Tooltip.lazy(this.part!.element, tooltip => {
if (this.isConnected) {
this.tooltip = tooltip;
this.renderContent();
}
});
this.tooltip ||= Tooltip.lazy(
this.part!.element,
tooltip => {
if (this.isConnected) {
this.tooltip = tooltip;
tooltip.position = this.config.position || 'top';
tooltip.maxWidth = this.config.maxWidth;
this.renderContent();
}
},
{ ariaRelation: this.config.ariaRelation }
);
}
}

Expand Down
29 changes: 29 additions & 0 deletions packages/components/tooltip/src/tooltip.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ export const Directive: Story = {
}
};

export const DirectiveWithOptions: Story = {
render: () => {
return html`
<style>
.container {
display: grid;
height: calc(20rem);
place-items: center;
}
</style>
<p>
This story demonstrates hot to use the tooltip directive with some inline options (custom 'ariaRelation', custom
'position' and 'maxWidth') on a <code>sl-button</code>. The example shows how to add a tooltip directly without
a separate <code>sl-tooltip</code> element.
</p>

<div class="container">
<sl-button
variant="primary"
fill="solid"
${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}
>
<sl-icon name="face-smile" size="lg"></sl-icon>
</sl-button>
</div>
`;
}
};

export const Disabled: Story = {
args: {
example: ({ alignSelf, justifySelf, message }) => html`
Expand Down
55 changes: 36 additions & 19 deletions tools/eslint-plugin-slds/lib/rules/button-has-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,52 @@ export const buttonHasLabel = {
fixable: null,
schema: [],
messages: {
missingText: 'sl-button elements must have text content or aria-label for accessibility'
missingText: 'sl-button elements must have text content or aria-label for accessibility',
mustBeAriaRelationLabel: 'for the tooltip directive ariaRelation must be \'label\''
}
},
create(context) {
return {
TaggedTemplateExpression(node) {
if (isHtmlTaggedTemplate(node, context)) {
const analyzer = TemplateAnalyzer.create(node);
if (!isHtmlTaggedTemplate(node, context)) {
return;
}

// Tooltip with ariaRelation: 'label' variant
const templateSource = context.sourceCode.getText(node),
hasTooltip = /tooltip\s*\(/.test(templateSource),
hasTooltipWithAriaRelationLabel = /tooltip\s*\([^)]*ariaRelation\s*:\s*['"]label['"]/.test(templateSource);

analyzer.traverse({
enterElement(element) {
if (element.name === 'sl-button') {
if (hasTextContent(element) || hasAccessibleName(element)) {
return;
}
const analyzer = TemplateAnalyzer.create(node);

const loc =
analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
context.sourceCode,
) ?? node.loc;
analyzer.traverse({
enterElement(element) {
if (element.name !== 'sl-button') {
return;
}

if (loc) {
context.report({ loc, messageId: 'missingText' });
}
if (
hasTextContent(element) ||
hasAccessibleName(element) ||
hasTooltipWithAriaRelationLabel
) {
return;
}

const loc = analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
context.sourceCode
) || node.loc;

if (loc) {
if (hasTooltip && !hasTooltipWithAriaRelationLabel) {
context.report({ loc, messageId: 'mustBeAriaRelationLabel' })
} else if (!hasAccessibleName(element)) {
context.report({ loc, messageId: 'missingText' });
}
}
});
}
}
});
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ ruleTester.run('button-has-label', buttonHasLabel, {
{ code: "html`<sl-button><slot></slot></sl-button>`;" },
{ code: "html`<sl-button><sl-foo></sl-foo></sl-button>`;" },
{ code: "const template = `<sl-button></sl-button>`;" },
{ code: "html`<div><sl-button>First</sl-button><sl-button>Second</sl-button></div>`;" }
{ code: "html`<div><sl-button>First</sl-button><sl-button>Second</sl-button></div>`;" },
{ code: "html`<sl-button ${tooltip('Toolip example', { ariaRelation: 'label' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;" },
{ code: "html`<sl-button ${tooltip('Tiooltip example', { position: 'bottom-start', ariaRelation: 'label' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;" },
{ code: "html`<sl-button ${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}><sl-icon name='face-smile' size='lg'></sl-icon></sl-button>`;" },
{ code: "html`<sl-button variant=\"primary\" fill=\"solid\" ${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}>\n`;" }
],
invalid: [
{
Expand Down Expand Up @@ -58,6 +62,14 @@ ruleTester.run('button-has-label', buttonHasLabel, {
{
code: "html`<sl-button variant=\"primary\" class=\"my-button\"></sl-button>`;",
errors: [{ messageId: 'missingText' }]
},
{
code: "html`<sl-button ${tooltip('Tip', { position: 'bottom-start' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;",
errors: [{ messageId: 'mustBeAriaRelationLabel' }]
},
{
code: "html`<sl-button ${tooltip('Tip', { ariaRelation: 'sth else but not label', position: 'bottom-start' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;",
errors: [{ messageId: 'mustBeAriaRelationLabel' }]
}
]
});
65 changes: 65 additions & 0 deletions website/src/categories/components/tooltip/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,71 @@ The complete usage example might appear as follows:

</div>

### Add config with the directive

You can also pass a second argument: a **config** object. Use this to control how the tooltip appears.

<div class="ds-code">

```js
// Basic string content + position + max width
html`<sl-button ${tooltip('More info', { position: 'right', maxWidth: 240 })}>Hover me</sl-button>`;

// Use ariaRelation: 'label' when the tooltip should be used as the accessible label (e.g. icon only buttons)
html`<sl-button ${tooltip('Settings', { ariaRelation: 'label' })}><sl-icon name="smile"></sl-icon></sl-button>`;
```

</div>

### Available config options

- **position**: Where the tooltip shows relative to the anchor. One of: `top`, `right`, `bottom`, `left`, `top-start`, `top-end`, `right-start`, `right-end`, `bottom-start`, `bottom-end`, `left-start`, `left-end`. Default: `top`.
- **maxWidth**: A `number` (pixels). The maximum width of the tooltip.
- **ariaRelation**: How the tooltip is linked for screen readers. A `description` (default) uses `aria-describedby`, `label` uses `aria-labelledby` and should be used when the tooltip text is the actual label of the anchor element (like an icon-only button).

If you omit a `config` it just uses its default behaviour. Config options are optional.

</section>

<section>

## Tooltip.lazy helper

`Tooltip.lazy` is a small helper that creates a tooltip only when the user first hovers or focuses the target element.
This avoids unnecessary processing if the user never interacts with it.

Basic shape:

```ts
import { Tooltip } from '@sl-design-system/tooltip';

const cleanup = Tooltip.lazy(targetElement, tooltip => {
// Runs once when the tooltip is actually created
tooltip.textContent = 'Hello there';
});
```

With options:

```ts
Tooltip.lazy(button, tooltip => {
tooltip.textContent = 'Settings';
tooltip.position = 'bottom-start';
},
{
ariaRelation: 'label',
context: shadowRoot,
parentNode: someContainer,
}
);
```

### Available options

- **ariaRelation**: How the tooltip is linked for screen readers. A `description` (default) uses `aria-describedby`, `label` uses `aria-labelledby` and should be used when the tooltip text is the actual label of the anchor element (like an icon-only button).
- **context**: A `Document` or `ShadowRoot` to create the `<sl-tooltip>` element in. If not provided, the tooltip will be created on the target element if it has a `shadowRoot`, or the root node of the target element.
- **parentNode**: A `Node` where the tooltip element should be inserted. This can be useful when you don't want the tooltip to be added next to the anchor element. If not provided, it will be added next to the anchor element.

</section>

{% include "../component-table.njk" %}