Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/thin-vans-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@scouterna/ui-webc": minor
---

Add tabs component.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ This project uses [Changesets](https://github.com/changesets/changesets) to mana
## Helpful resources

- [Shadow DOM and accessibility: the trouble with ARIA](https://nolanlawson.com/2022/11/28/shadow-dom-and-accessibility-the-trouble-with-aria/)
- [Shadow DOM and events](https://it.javascript.info/shadow-dom-events)
17 changes: 17 additions & 0 deletions packages/storybook/src/stories/tabs-tab.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ScoutTabsTab } from "@scouterna/ui-react";
import preview from "#.storybook/preview";

const meta = preview.meta({
title: "Interaction/Tabs Tab",
component: ScoutTabsTab,
parameters: {
layout: "centered",
},
});

export default meta;

export const BasicExample = meta.story({
args: {},
render: (args) => <ScoutTabsTab {...args} />,
});
50 changes: 50 additions & 0 deletions packages/storybook/src/stories/tabs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
Meta,
Controls,
Description,
Primary,
Source,
Stories,
Subtitle,
Title,
} from '@storybook/addon-docs/blocks';

import * as TabsStories from './tabs.stories';

<Meta of={TabsStories} />

<Title />
<Subtitle />
<Description />
<Primary />
<Controls />

## Code Examples

### Controlling state

You must manage the state of the active tab yourself by listening to the
`scoutChange` event and updating the `value` prop accordingly.

<Source
language="tsx"
code={`
import { useState } from "react";

const [activeTab, setActiveTab] = useState(0);

return (
<ScoutTabs
value={activeTab}
onScoutChange={(e) => setActiveTab(e.detail.value)}
>
<ScoutTabsTab>Händelser</ScoutTabsTab>
<ScoutTabsTab>Information</ScoutTabsTab>
</ScoutTabs>
);
`}
/>

## Stories

<Stories title={false} />
32 changes: 32 additions & 0 deletions packages/storybook/src/stories/tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ScoutTabs, ScoutTabsTab } from "@scouterna/ui-react";
import { useArgs } from "storybook/internal/preview-api";
import preview from "#.storybook/preview";

const meta = preview.meta({
title: "Interaction/Tabs",
component: ScoutTabs,
parameters: {
layout: "centered",
},
});

export default meta;

export const BasicExample = meta.story({
args: {},
render: (args) => {
const [_, setArgs] = useArgs();

return (
<div style={{ display: "flex", width: "20rem", height: "3.5rem" }}>
<ScoutTabs
{...args}
onScoutChange={(e) => setArgs({ value: e.detail.value })}
>
<ScoutTabsTab>Händelser</ScoutTabsTab>
<ScoutTabsTab>Information</ScoutTabsTab>
</ScoutTabs>
</div>
);
},
});
15 changes: 15 additions & 0 deletions packages/ui-webc/src/components/tabs-tab/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# scout-tabs-tab

<!-- Auto Generated Below -->


## Properties

| Property | Attribute | Description | Type | Default |
| -------- | --------- | ----------- | --------- | ------- |
| `active` | `active` | | `boolean` | `false` |


----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*
44 changes: 44 additions & 0 deletions packages/ui-webc/src/components/tabs-tab/tabs-tab.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
:host {
flex: 1;
display: block;
width: auto;
}

.button-native {
position: relative;
display: block;
width: 100%;
height: 100%;
color: var(--color-gray-600);
background-color: transparent;
border: none;
cursor: pointer;
padding: var(--spacing-1) var(--spacing-1)
calc(var(--spacing-1) + var(--tabs-indicator-height)) var(--spacing-1);
font-weight: 500;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.04em;
}

.inner-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: var(--spacing-2);
background-color: transparent;
}

.button-native:hover {
color: var(--color-text-base);

.inner-container {
background-color: var(--color-background-brand-subtle-hovered);
}
}

:host([data-active]) .button-native {
color: var(--color-text-base);
}
23 changes: 23 additions & 0 deletions packages/ui-webc/src/components/tabs-tab/tabs-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Component, h, Prop } from "@stencil/core";

@Component({
tag: "scout-tabs-tab",
styleUrl: "tabs-tab.css",
shadow: {
delegatesFocus: true,
},
})
export class ScoutTabsTab {
@Prop()
active: boolean = false;

render() {
return (
<button class="button-native" type="button">
<div class="inner-container">
<slot />
</div>
</button>
);
}
}
31 changes: 31 additions & 0 deletions packages/ui-webc/src/components/tabs/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# scout-tabs

<!-- Auto Generated Below -->


## Overview

The tabs component is used to create a tabbed interface. It manages the state
of which tab is active and displays an indicator under the active tab. Use
`ScoutTabsTab` components to define the individual tabs.

Currently there is no support for navigational tabs. Navigation has to be
handled programatically for now.

## Properties

| Property | Attribute | Description | Type | Default |
| -------- | --------- | ----------- | -------- | ------- |
| `value` | `value` | | `number` | `0` |


## Events

| Event | Description | Type |
| ------------- | ----------- | --------------------------------- |
| `scoutChange` | | `CustomEvent<{ value: number; }>` |


----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*
17 changes: 17 additions & 0 deletions packages/ui-webc/src/components/tabs/tabs.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
:host {
position: relative;
width: 100%;
display: flex;
height: var(--spacing-12);
--tabs-indicator-height: 2px;
}

.indicator {
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: var(--tabs-indicator-height);
background-color: var(--color-background-brand-base);
transition: all 0.3s ease;
}
103 changes: 103 additions & 0 deletions packages/ui-webc/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
Component,
type ComponentInterface,
Element,
Event,
type EventEmitter,
Host,
h,
Listen,
Prop,
State,
Watch,
} from "@stencil/core";

/**
* The tabs component is used to create a tabbed interface. It manages the state
* of which tab is active and displays an indicator under the active tab. Use
* `ScoutTabsTab` components to define the individual tabs.
*
* Currently there is no support for navigational tabs. Navigation has to be
* handled programatically for now.
*/
@Component({
tag: "scout-tabs",
styleUrl: "tabs.css",
shadow: {
delegatesFocus: true,
},
})
export class ScoutTabs implements ComponentInterface {
@Element() el: HTMLElement;

@Prop()
public value: number = 0;

@State()
private widths: number[] = [];

@State()
private lefts: number[] = [];

@Event()
public scoutChange: EventEmitter<{ value: number }>;

render() {
return (
<Host>
<slot />
{this.getIndicator()}
</Host>
);
}

componentDidLoad() {
this.updateChildrenClasses();
this.calculateIndicatorSizes();
}

getIndicator() {
const width = this.widths[this.value] || 0;
const left = this.lefts[this.value] || 0;

const indicatorStyle = {
width: `${width}px`,
transform: `translateX(${left}px)`,
};

return <div aria-hidden="true" class="indicator" style={indicatorStyle} />;
}

@Listen("click", { capture: true })
handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const tabs = Array.from(this.el.children);
const clickedIndex = tabs.indexOf(target);

if (clickedIndex !== -1 && clickedIndex !== this.value) {
this.scoutChange.emit({ value: clickedIndex });
}
}

@Watch("value")
updateChildrenClasses() {
Array.from(this.el.children).forEach((child, index) => {
const tab = child as HTMLElement;
if (index === this.value) {
tab.setAttribute("data-active", "true");
} else {
tab.removeAttribute("data-active");
}
});
}

@Watch("value")
calculateIndicatorSizes() {
this.widths = Array.from(this.el.children).map(
(child) => (child as HTMLElement).offsetWidth,
);
this.lefts = this.widths.map((_, index) =>
this.widths.slice(0, index).reduce((acc, w) => acc + w, 0),
);
}
}
4 changes: 2 additions & 2 deletions plop-templates/component/ui-webc/component.tsx.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, h } from "@stencil/core";
import { Component, type ComponentInterface, h } from "@stencil/core";

@Component({
tag: "scout-{{name}}",
Expand All @@ -7,7 +7,7 @@ import { Component, h } from "@stencil/core";
delegatesFocus: true,
},
})
export class Scout{{pascalCase name}} {
export class Scout{{pascalCase name}} implements ComponentInterface {
render() {
return (
<slot />
Expand Down