Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/afraid-readers-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@scouterna/ui-webc": minor
---

Added Segmented Control component.
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"**/packages/tailwind-theme/**/*.css": "tailwindcss",
"*.css": "css" // Avoid CSS files being detected as Tailwind-flavored CSS
},
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
}
}
51 changes: 51 additions & 0 deletions packages/storybook/src/stories/segmented-control.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
Meta,
Controls,
Description,
Primary,
Source,
Stories,
Subtitle,
Title,
} from '@storybook/addon-docs/blocks';

import * as SegmentedControlStories from './segmented-control.stories';

<Meta of={SegmentedControlStories} />

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

## Code Examples

### Controlling state

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

<Source
language="tsx"
code={`
import { useState } from "react";
import { ScoutSegmentedControl } from "@scouterna/ui-react";

const [activeSegment, setActiveSegment] = useState(0);

return (
<ScoutSegmentedControl
value={activeSegment}
onScoutChange={(e) => setActiveSegment(e.detail.value)}
>
<button type="button">Alla</button>
<button type="button">Bokade</button>
</ScoutSegmentedControl>
);
`}
/>

## Stories

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

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

export default meta;

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

return (
<ScoutSegmentedControl
{...args}
onScoutChange={(e) => setArgs({ value: e.detail.value })}
>
<button type="button">Alla</button>
<button type="button">Bokade</button>
</ScoutSegmentedControl>
);
},
});

export const Small = BasicExample.extend({
args: {
size: "small",
},
});
34 changes: 34 additions & 0 deletions packages/ui-webc/src/components/segmented-control/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# scout-segmented-control

<!-- Auto Generated Below -->


## Overview

The segmented control component presents a set of options where exactly one
option is active at a time.

The component displays an indicator under the selected option and emits a
`scoutChange` event when the user picks a different option, so you can update
`value`.

Use button elements as the slotted segment options.

## Properties

| Property | Attribute | Description | Type | Default |
| -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------- |
| `size` | `size` | Size of the input element. Large fields are typically used for prominent inputs, such as a top search field on a page, while medium fields are used for regular form inputs. | `"medium" \| "small"` | `"medium"` |
| `value` | `value` | Zero-based index of the currently active segment. | `number` | `0` |


## Events

| Event | Description | Type |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `scoutChange` | Emitted when the active segment changes as a result of a user click. The `value` in the event detail is the zero-based index of the newly selected segment. | `CustomEvent<{ value: number; }>` |


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

*Built with [StencilJS](https://stenciljs.com/)*
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
:host {
position: relative;
width: 100%;
display: flex;
height: var(--spacing-10);
border: 1px solid var(--color-gray-300);
border-radius: var(--spacing-2);
font: var(--type-body-md);
padding: 0 var(--indicator-padding);

--indicator-padding: 0.125rem;
--button-padding: var(--spacing-3);
}

:host(.small) {
height: var(--spacing-8);
font: var(--type-body-sm);

--button-padding: var(--spacing-2);
}

.indicator {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 100%;
transition: all 0.2s ease;
pointer-events: none;
z-index: 0;

&::after {
content: "";
position: absolute;
top: var(--indicator-padding);
left: 0;
right: 0;
bottom: var(--indicator-padding);
border-radius: 0.375rem;
background-color: var(--color-background-brand-base);
}
}

::slotted(button) {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--button-padding);
font-size: var(--font-size-2);
color: var(--color-text-default);
background-color: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease;
position: relative;
z-index: 1;

&:disabled {
color: var(--color-text-disabled);
cursor: not-allowed;
}
}

::slotted(button[aria-checked="true"]) {
color: var(--color-text-brand-inverse);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
Component,
type ComponentInterface,
Element,
Event,
type EventEmitter,
Host,
h,
Listen,
Prop,
State,
Watch,
} from "@stencil/core";

export type Size = "small" | "medium";

/**
* The segmented control component presents a set of options where exactly one
* option is active at a time.
*
* The component displays an indicator under the selected option and emits a
* `scoutChange` event when the user picks a different option, so you can update
* `value`.
*
* Use button elements as the slotted segment options.
*/
@Component({
tag: "scout-segmented-control",
styleUrl: "segmented-control.css",
shadow: {
delegatesFocus: true,
},
})
export class ScoutSegmentedControl implements ComponentInterface {
/**
* Size of the input element. Large fields are typically used for prominent
* inputs, such as a top search field on a page, while medium fields are used
* for regular form inputs.
Comment on lines +36 to +38
*/
@Prop() size: Size = "medium";

/**
* Zero-based index of the currently active segment.
*/
@Prop()
public value: number = 0;

/**
* Emitted when the active segment changes as a result of a user click.
* The `value` in the event detail is the zero-based index of the newly selected segment.
*/
@Event()
public scoutChange!: EventEmitter<{ value: number }>;

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

@State()
private enableAnimations = false;

@Element() el!: HTMLElement;

render() {
const sizeClass = this.size === "small" ? "small" : "";
const noTransitionClass = this.enableAnimations ? "" : "no-transition";

return (
<Host class={`${sizeClass} ${noTransitionClass}`}>
<slot />
{this.getIndicator()}
</Host>
);
Comment on lines +65 to +74
}

componentDidLoad() {
this.updateChildrenAttributes();
this.calculateIndicatorSizes();

requestAnimationFrame(() => {
this.enableAnimations = true;
});
}
Comment on lines +77 to +84

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 buttons = Array.from(this.el.children);
const clickedIndex = buttons.indexOf(target);

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

@Watch("value")
updateChildrenAttributes() {
Array.from(this.el.children).forEach((child, index) => {
const button = child as HTMLElement;
button.role = "radio";
if (index === this.value) {
button.ariaChecked = "true";
} else {
button.ariaChecked = "false";
}
});
}

@Watch("value")
calculateIndicatorSizes() {
// Get left padding of container
const baseLeft = parseFloat(getComputedStyle(this.el).paddingLeft) || 0;

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) + baseLeft,
);

console.log("Calculated widths:", this.widths);
Comment on lines +134 to +135
}
}
6 changes: 3 additions & 3 deletions packages/ui-webc/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ import {
},
})
export class ScoutTabs implements ComponentInterface {
@Element() el: HTMLElement;

/**
* Zero-based index of the currently active tab.
*/
Expand All @@ -41,13 +39,15 @@ export class ScoutTabs implements ComponentInterface {
* The `value` in the event detail is the zero-based index of the newly selected tab.
*/
@Event()
public scoutChange: EventEmitter<{ value: number }>;
public scoutChange!: EventEmitter<{ value: number }>;

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

@Element() el!: HTMLElement;

render() {
return (
<Host>
Expand Down
7 changes: 7 additions & 0 deletions packages/ui-webc/src/global/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@
font-weight: 400;
font-style: normal;
}

:host(.no-transition),
:host(.no-transition) *,
:host(.no-transition) ::slotted(*) {
/** biome-ignore lint/complexity/noImportantStyles: It's a necessary override */
transition: none !important;
}