Skip to content

Commit 30e9c12

Browse files
committed
feat: add segmented control
1 parent 02a8daf commit 30e9c12

File tree

7 files changed

+280
-4
lines changed

7 files changed

+280
-4
lines changed

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@
33
"**/packages/tailwind-theme/**/*.css": "tailwindcss",
44
"*.css": "css" // Avoid CSS files being detected as Tailwind-flavored CSS
55
},
6-
"editor.defaultFormatter": "biomejs.biome"
6+
"editor.defaultFormatter": "biomejs.biome",
7+
"editor.codeActionsOnSave": {
8+
"source.fixAll.eslint": "never"
9+
}
710
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ScoutSegmentedControl } from "@scouterna/ui-react";
2+
import { useArgs } from "storybook/internal/preview-api";
3+
import preview from "#.storybook/preview";
4+
5+
const meta = preview.meta({
6+
title: "Interaction/Segmented Control",
7+
component: ScoutSegmentedControl,
8+
parameters: {
9+
layout: "centered",
10+
},
11+
});
12+
13+
export default meta;
14+
15+
export const BasicExample = meta.story({
16+
args: {},
17+
render: (args) => {
18+
const [_, setArgs] = useArgs();
19+
20+
return (
21+
<ScoutSegmentedControl
22+
{...args}
23+
onScoutChange={(e) => setArgs({ value: e.detail.value })}
24+
>
25+
<button type="button">Alla</button>
26+
<button type="button">Bokade</button>
27+
</ScoutSegmentedControl>
28+
// <div style={{ display: "flex", width: "20rem", height: "3.5rem" }}>
29+
// </div>
30+
);
31+
},
32+
});
33+
34+
export const Small = BasicExample.extend({
35+
args: {
36+
size: "small",
37+
},
38+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# scout-tabs
2+
3+
<!-- Auto Generated Below -->
4+
5+
6+
## Overview
7+
8+
The segmented control component is used to create a segmented interface. It
9+
manages the state of which segment is active and displays an indicator under
10+
the active segment. Use button elements to define the individual segments.
11+
12+
## Properties
13+
14+
| Property | Attribute | Description | Type | Default |
15+
| -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------- |
16+
| `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"` |
17+
| `value` | `value` | Zero-based index of the currently active segment. | `number` | `0` |
18+
19+
20+
## Events
21+
22+
| Event | Description | Type |
23+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
24+
| `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; }>` |
25+
26+
27+
----------------------------------------------
28+
29+
*Built with [StencilJS](https://stenciljs.com/)*
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
:host {
2+
position: relative;
3+
width: 100%;
4+
display: flex;
5+
height: var(--spacing-10);
6+
border: 1px solid var(--color-gray-300);
7+
border-radius: var(--spacing-2);
8+
font: var(--type-body-md);
9+
padding: 0 var(--indicator-padding);
10+
11+
--indicator-padding: 0.125rem;
12+
--button-padding: var(--spacing-3);
13+
}
14+
15+
:host(.small) {
16+
height: var(--spacing-8);
17+
font: var(--type-body-sm);
18+
19+
--button-padding: var(--spacing-2);
20+
}
21+
22+
.indicator {
23+
position: absolute;
24+
top: 0;
25+
left: 0;
26+
width: 0;
27+
height: 100%;
28+
transition: all 0.2s ease;
29+
pointer-events: none;
30+
z-index: -1;
31+
32+
&::after {
33+
content: "";
34+
position: absolute;
35+
top: var(--indicator-padding);
36+
left: 0;
37+
right: 0;
38+
bottom: var(--indicator-padding);
39+
border-radius: 0.375rem;
40+
background-color: var(--color-background-brand-base);
41+
}
42+
}
43+
44+
::slotted(button) {
45+
flex: 1;
46+
display: flex;
47+
align-items: center;
48+
justify-content: center;
49+
padding: 0 var(--button-padding);
50+
font-size: var(--font-size-2);
51+
color: var(--color-text-default);
52+
background-color: transparent;
53+
border: none;
54+
cursor: pointer;
55+
transition: color 0.15s ease;
56+
57+
&:disabled {
58+
color: var(--color-text-disabled);
59+
cursor: not-allowed;
60+
}
61+
}
62+
63+
::slotted(button[aria-checked="true"]) {
64+
color: var(--color-text-brand-inverse);
65+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {
2+
Component,
3+
type ComponentInterface,
4+
Element,
5+
Event,
6+
type EventEmitter,
7+
Host,
8+
h,
9+
Listen,
10+
Prop,
11+
State,
12+
Watch,
13+
} from "@stencil/core";
14+
15+
export type Size = "small" | "medium";
16+
17+
/**
18+
* The segmented control component is used to create a segmented interface. It
19+
* manages the state of which segment is active and displays an indicator under
20+
* the active segment. Use button elements to define the individual segments.
21+
*/
22+
@Component({
23+
tag: "scout-segmented-control",
24+
styleUrl: "segmented-control.css",
25+
shadow: {
26+
delegatesFocus: true,
27+
},
28+
})
29+
export class ScoutSegmentedControl implements ComponentInterface {
30+
/**
31+
* Size of the input element. Large fields are typically used for prominent
32+
* inputs, such as a top search field on a page, while medium fields are used
33+
* for regular form inputs.
34+
*/
35+
@Prop() size: Size = "medium";
36+
37+
/**
38+
* Zero-based index of the currently active segment.
39+
*/
40+
@Prop()
41+
public value: number = 0;
42+
43+
/**
44+
* Emitted when the active segment changes as a result of a user click.
45+
* The `value` in the event detail is the zero-based index of the newly selected segment.
46+
*/
47+
@Event()
48+
public scoutChange!: EventEmitter<{ value: number }>;
49+
50+
@State()
51+
private widths: number[] = [];
52+
@State()
53+
private lefts: number[] = [];
54+
55+
@State()
56+
private enableAnimations = false;
57+
58+
@Element() el!: HTMLElement;
59+
60+
render() {
61+
const sizeClass = this.size === "small" ? "small" : "";
62+
const noTransitionClass = this.enableAnimations ? "" : "no-transition";
63+
64+
return (
65+
<Host class={`${sizeClass} ${noTransitionClass}`}>
66+
<slot />
67+
{this.getIndicator()}
68+
</Host>
69+
);
70+
}
71+
72+
componentDidLoad() {
73+
this.updateChildrenAttributes();
74+
this.calculateIndicatorSizes();
75+
76+
// This is a hack and it won't work on slow devices, but in most cases it
77+
// prevents the indicator from animating prematurely.
78+
setTimeout(() => {
79+
this.enableAnimations = true;
80+
}, 50);
81+
}
82+
83+
getIndicator() {
84+
const width = this.widths[this.value] || 0;
85+
const left = this.lefts[this.value] || 0;
86+
87+
const indicatorStyle = {
88+
width: `${width}px`,
89+
transform: `translateX(${left}px)`,
90+
};
91+
92+
return <div aria-hidden="true" class="indicator" style={indicatorStyle} />;
93+
}
94+
95+
@Listen("click", { capture: true })
96+
handleClick(event: MouseEvent) {
97+
const target = event.target as HTMLElement;
98+
const buttons = Array.from(this.el.children);
99+
const clickedIndex = buttons.indexOf(target);
100+
101+
if (clickedIndex !== -1 && clickedIndex !== this.value) {
102+
this.scoutChange.emit({ value: clickedIndex });
103+
}
104+
}
105+
106+
@Watch("value")
107+
updateChildrenAttributes() {
108+
Array.from(this.el.children).forEach((child, index) => {
109+
const button = child as HTMLElement;
110+
button.role = "radio";
111+
if (index === this.value) {
112+
button.ariaChecked = "true";
113+
} else {
114+
button.ariaChecked = "false";
115+
}
116+
});
117+
}
118+
119+
@Watch("value")
120+
calculateIndicatorSizes() {
121+
// Get left padding of container
122+
const baseLeft = parseFloat(getComputedStyle(this.el).paddingLeft) || 0;
123+
124+
this.widths = Array.from(this.el.children).map(
125+
(child) => (child as HTMLElement).offsetWidth,
126+
);
127+
this.lefts = this.widths.map(
128+
(_, index) =>
129+
this.widths.slice(0, index).reduce((acc, w) => acc + w, 0) + baseLeft,
130+
);
131+
132+
console.log("Calculated widths:", this.widths);
133+
}
134+
}

packages/ui-webc/src/components/tabs/tabs.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ import {
2828
},
2929
})
3030
export class ScoutTabs implements ComponentInterface {
31-
@Element() el: HTMLElement;
32-
3331
/**
3432
* Zero-based index of the currently active tab.
3533
*/
@@ -41,13 +39,15 @@ export class ScoutTabs implements ComponentInterface {
4139
* The `value` in the event detail is the zero-based index of the newly selected tab.
4240
*/
4341
@Event()
44-
public scoutChange: EventEmitter<{ value: number }>;
42+
public scoutChange!: EventEmitter<{ value: number }>;
4543

4644
@State()
4745
private widths: number[] = [];
4846
@State()
4947
private lefts: number[] = [];
5048

49+
@Element() el!: HTMLElement;
50+
5151
render() {
5252
return (
5353
<Host>

packages/ui-webc/src/global/global.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@
99
font-weight: 400;
1010
font-style: normal;
1111
}
12+
13+
:host(.no-transition),
14+
:host(.no-transition) *,
15+
:host(.no-transition) ::slotted(*) {
16+
/** biome-ignore lint/complexity/noImportantStyles: It's a necessary override */
17+
transition: none !important;
18+
}

0 commit comments

Comments
 (0)