diff --git a/packages/demo/src/components/demo/segmented-controls.tsx b/packages/demo/src/components/demo/segmented-controls.tsx
new file mode 100644
index 00000000..5bbb8498
--- /dev/null
+++ b/packages/demo/src/components/demo/segmented-controls.tsx
@@ -0,0 +1,85 @@
+import {
+ Badge,
+ SegmentedControls,
+ type SegmentedControlOption,
+ type SegmentedControlsDisplay,
+} from "@eqtylab/equality";
+import { useState } from "react";
+
+export const SegmentedControlsDemo = ({
+ options,
+ defaultValue,
+ display,
+}: {
+ options: SegmentedControlOption[];
+ defaultValue: string;
+ display?: SegmentedControlsDisplay;
+}) => {
+ const [value, setValue] = useState(defaultValue);
+
+ return (
+
+ );
+};
+
+const textOptions: SegmentedControlOption[] = [
+ { value: "day", label: "Day" },
+ { value: "week", label: "Week" },
+ { value: "month", label: "Month" },
+];
+
+export const SegmentedControlsTextDemo = () => (
+
+);
+
+const iconOptions: SegmentedControlOption[] = [
+ { value: "list", label: "List", icon: "List" },
+ { value: "board", label: "Board", icon: "Columns3" },
+ { value: "calendar", label: "Calendar", icon: "Calendar" },
+];
+
+export const SegmentedControlsIconDemo = () => (
+
+);
+
+const suffixOptions: SegmentedControlOption[] = [
+ {
+ value: "all",
+ label: "All",
+ suffix: 128,
+ },
+ {
+ value: "open",
+ label: "Open",
+ suffix: 12,
+ },
+ {
+ value: "closed",
+ label: "Closed",
+ suffix: 116,
+ },
+];
+
+export const SegmentedControlsSuffixDemo = () => (
+
+);
+
+const iconOnlyOptions: SegmentedControlOption[] = [
+ { value: "list", label: "List view", icon: "List" },
+ { value: "table", label: "Table view", icon: "Grid3X3" },
+];
+
+export const SegmentedControlsIconOnlyDemo = () => (
+
+
+
+);
diff --git a/packages/demo/src/components/sidebar.astro b/packages/demo/src/components/sidebar.astro
index b025f473..12aa1c18 100644
--- a/packages/demo/src/components/sidebar.astro
+++ b/packages/demo/src/components/sidebar.astro
@@ -29,6 +29,7 @@ const gettingStartedLinks = [
const componentsLinks = components.map((component: any) => ({
text: component.data.heading,
href: `/components/${component.id}`,
+ deprecated: component.data.deprecated,
}));
---
diff --git a/packages/demo/src/components/ui/nav-list.astro b/packages/demo/src/components/ui/nav-list.astro
index e9d9e69c..954a28bd 100644
--- a/packages/demo/src/components/ui/nav-list.astro
+++ b/packages/demo/src/components/ui/nav-list.astro
@@ -1,9 +1,10 @@
---
import { cn } from "@demo/lib/utils";
+import { Badge } from "@eqtylab/equality";
interface Props {
label: string;
- list: Array<{ text: string; href: string }>;
+ list: Array<{ text: string; href: string; deprecated?: boolean }>;
}
const { label, list } = Astro.props;
@@ -33,7 +34,12 @@ const isActive = (href: string) =>
: "text-text-secondary hover:border-text-primary hover:text-text-primary",
)}
>
- {item.text}
+
+ {item.text}
+ {item.deprecated && (
+ Deprecated
+ )}
+
);
diff --git a/packages/demo/src/components/ui/page-header.astro b/packages/demo/src/components/ui/page-header.astro
index f9949444..d1041319 100644
--- a/packages/demo/src/components/ui/page-header.astro
+++ b/packages/demo/src/components/ui/page-header.astro
@@ -1,13 +1,19 @@
---
import Heading from "@demo/components/ui/heading.astro";
-import { Icon } from "@eqtylab/equality";
+import { Alert, Icon } from "@eqtylab/equality";
import { getEntry } from "astro:content";
+import { createElement } from "react";
-const { heading, description } = Astro.props;
+const { heading, description, deprecated, deprecatedMessage } = Astro.props;
const slug = Astro.url.pathname.replace(/\/$/, "").split("/").pop();
const componentEntry = slug ? await getEntry("components", slug) : undefined;
const markdownHref = componentEntry ? `/components/${slug}.md` : null;
+
+const deprecatedDescription = createElement("span", {
+ className: "[&_a]:underline [&_a]:underline-offset-2",
+ dangerouslySetInnerHTML: { __html: deprecatedMessage ?? "This component is deprecated." },
+});
---
@@ -36,4 +42,9 @@ const markdownHref = componentEntry ? `/components/${slug}.md` : null;
)
}
+ {
+ deprecated && (
+
+ )
+ }
diff --git a/packages/demo/src/content.config.ts b/packages/demo/src/content.config.ts
index 634d7382..e19b0778 100644
--- a/packages/demo/src/content.config.ts
+++ b/packages/demo/src/content.config.ts
@@ -7,6 +7,13 @@ const components = defineCollection({
schema: z.object({
heading: z.string(),
description: z.string().optional(),
+ // Marks a component as deprecated. Renders a warning callout on the docs
+ // page and a warning tag in the sidebar. Pair with the `@deprecated` JSDoc
+ // tag on the component itself in the `ui` package.
+ deprecated: z.boolean().default(false),
+ // Optional message shown in the deprecation callout. Supports inline HTML
+ // (e.g. a link to the replacement component).
+ deprecatedMessage: z.string().optional(),
}),
});
diff --git a/packages/demo/src/content/components/list-or-grid-view-toggle.mdx b/packages/demo/src/content/components/list-or-grid-view-toggle.mdx
index 19474498..90dc35d6 100644
--- a/packages/demo/src/content/components/list-or-grid-view-toggle.mdx
+++ b/packages/demo/src/content/components/list-or-grid-view-toggle.mdx
@@ -2,6 +2,8 @@
layout: "@demo/layouts/mdx-layout.astro"
heading: "List or Grid View Toggle"
description: "List or Grid View Toggle with sizes and variants"
+deprecated: true
+deprecatedMessage: 'Deprecated in favour of Segmented Controls.'
---
import { ListOrGridViewToggle } from "@eqtylab/equality";
@@ -9,16 +11,12 @@ import { ListOrGridViewToggleDemo } from "@demo/components/demo/list-or-grid-vie
## Overview
----
-
The List or Grid View Toggle component is used to switch between list and grid view.
Please adjust the order using the `order` parameter so that the default `viewMode` is always on the left side.
## Usage
----
-
Import the component:
```tsx
@@ -35,8 +33,6 @@ const [viewMode, setViewMode] = useState("grid");
## Examples
----
-
### Default
@@ -51,8 +47,6 @@ const [viewMode, setViewMode] = useState("grid");
## Reordering
----
-
Reorder the order of the buttons using the `order` prop. The default `viewMode` should always be placed on the left side.
```tsx
@@ -65,8 +59,6 @@ Reorder the order of the buttons using the `order` prop. The default `viewMode`
## Props
----
-
| Prop | Type | Default | Description |
| ------------------ | ---------------------------------------- | ------------------ | ----------------------------------------------- |
| `viewMode` | `"grid"` \| `"list"` | Required | Sets the active state. |
diff --git a/packages/demo/src/content/components/segmented-controls.mdx b/packages/demo/src/content/components/segmented-controls.mdx
new file mode 100644
index 00000000..993cd054
--- /dev/null
+++ b/packages/demo/src/content/components/segmented-controls.mdx
@@ -0,0 +1,110 @@
+---
+layout: "@demo/layouts/mdx-layout.astro"
+heading: "Segmented Controls"
+description: "A linear set of two or more segments used to switch between mutually exclusive options or views."
+---
+
+import {
+ SegmentedControlsTextDemo,
+ SegmentedControlsIconDemo,
+ SegmentedControlsSuffixDemo,
+ SegmentedControlsIconOnlyDemo,
+} from "@demo/components/demo/segmented-controls";
+
+## Overview
+
+Segmented Controls present a developer-defined set of two or more mutually exclusive
+options in a single, connected control. They are commonly used to switch between views,
+filters, or modes where a dropdown would be excessive.
+
+Each option supports a prefix `icon`, a required text `label`, and an optional `suffix`
+slot. The currently selected option is highlighted using the primary button colour.
+
+## Usage
+
+Import the component:
+
+```tsx
+import { SegmentedControls } from "@eqtylab/equality";
+```
+
+`options`, `value`, and `onValueChange` are required. `value` and `onValueChange` should be
+controlled by the parent component.
+
+```tsx
+const [view, setView] = useState("day");
+
+;
+```
+
+## Examples
+
+### Text only
+
+
+
+### With prefix icons
+
+Provide an `icon` (a [Lucide](https://lucide.dev) icon name or a React element) to render a
+prefix before the label.
+
+
+
+### With a suffix slot
+
+Use the `suffix` slot to render supplementary content, such as a count `Badge`, after the
+label.
+
+
+
+## Display modes
+
+Like the [Badge](/components/badge) component, the `display` prop controls what is rendered
+inside each segment: `both` (the default), `text-only`, or `icon-only`.
+
+`icon-only` requires **every** option to define an `icon`, and it falls back to `both` if any
+option is missing one. Because `label` is mandatory, it is used as the accessible label
+(`aria-label`) and tooltip for each segment in `icon-only` mode.
+
+### Icon only
+
+
+
+```tsx
+
+```
+
+## Props
+
+| Name | Description | Type | Default | Required |
+| --------------- | ----------------------------------------------------- | -------------------------------- | ------- | -------- |
+| `options` | The set of segments to render. | `SegmentedControlOption[]` | - | ✅ |
+| `value` | The value of the currently selected option. | `string` | - | ✅ |
+| `onValueChange` | Called with the new value when a segment is selected. | `(value: string) => void` | - | ✅ |
+| `display` | Controls what is rendered inside each segment. | `both`, `text-only`, `icon-only` | `both` | ❌ |
+| `className` | Additional CSS classes to apply to the control. | `string` | - | ❌ |
+
+### `SegmentedControlOption`
+
+| Name | Description | Type | Default | Required |
+| -------- | --------------------------------------------------------------- | ------------------------------ | ------- | -------- |
+| `value` | Unique value used to identify the option. | `string` | - | ✅ |
+| `label` | Text label. Also used as the accessible label when `icon-only`. | `string` | - | ✅ |
+| `icon` | Prefix icon: a Lucide icon name or a React element. | `string`, `React.ReactElement` | - | ❌ |
+| `suffix` | Content rendered after the label. | `React.ReactNode` | - | ❌ |
diff --git a/packages/demo/src/layouts/mdx-layout.astro b/packages/demo/src/layouts/mdx-layout.astro
index 437905d5..9815be35 100644
--- a/packages/demo/src/layouts/mdx-layout.astro
+++ b/packages/demo/src/layouts/mdx-layout.astro
@@ -7,16 +7,27 @@ interface Props {
frontmatter?: {
heading?: string;
description?: string;
+ deprecated?: boolean;
+ deprecatedMessage?: string;
};
}
const { frontmatter = {} } = Astro.props;
-const { heading, description } = frontmatter;
+const { heading, description, deprecated, deprecatedMessage } = frontmatter;
---
- {heading && }
+ {
+ heading && (
+
+ )
+ }
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 8c8f4396..c41601df 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -40,6 +40,7 @@ export * from './radio-group/radio-group';
export * from './scroll-area/scroll-area';
export * from './search-bar/search-bar';
export * from './section-heading/section-heading';
+export * from './segmented-controls/segmented-controls';
export * from './select/select';
export * from './separator/separator';
export * from './sheet/sheet';
diff --git a/packages/ui/src/components/list-or-grid-view-toggle/list-or-grid-view-toggle.tsx b/packages/ui/src/components/list-or-grid-view-toggle/list-or-grid-view-toggle.tsx
index fd159b59..dd9f9113 100644
--- a/packages/ui/src/components/list-or-grid-view-toggle/list-or-grid-view-toggle.tsx
+++ b/packages/ui/src/components/list-or-grid-view-toggle/list-or-grid-view-toggle.tsx
@@ -13,6 +13,9 @@ interface ListOrGridViewToggleProps {
className?: string;
}
+/**
+ * @deprecated Use `SegmentedControls` instead.
+ */
const ListOrGridViewToggle = ({
viewMode,
onViewModeChange,
diff --git a/packages/ui/src/components/segmented-controls/segmented-controls.module.css b/packages/ui/src/components/segmented-controls/segmented-controls.module.css
new file mode 100644
index 00000000..d9a95d5c
--- /dev/null
+++ b/packages/ui/src/components/segmented-controls/segmented-controls.module.css
@@ -0,0 +1,48 @@
+@reference '../../theme/theme.module.css';
+
+.segmented-controls {
+ @apply border-button-tertiary-border border;
+ @apply flex rounded-md;
+ @apply w-max overflow-hidden;
+ @apply bg-button-tertiary-fill;
+ @apply relative;
+}
+
+.segment {
+ @apply rounded-none! border-0;
+ @apply z-above relative;
+ @apply flex items-center gap-1.5;
+ @apply h-8 px-3;
+ @apply whitespace-nowrap text-sm font-medium;
+ @apply cursor-pointer bg-transparent;
+}
+
+.segment--icon-only {
+ @apply flex-center size-8 gap-0 px-0;
+}
+
+.segment--active,
+.segment--active .segment-icon {
+ @apply text-button-primary-text;
+}
+
+.segment--inactive,
+.segment--inactive .segment-icon {
+ @apply text-text-secondary transition-colors duration-300;
+}
+
+.segment--inactive:hover,
+.segment--inactive:hover .segment-icon {
+ @apply text-text-primary;
+}
+
+.segment-icon,
+.segment-suffix {
+ @apply flex-center;
+}
+
+.active-segment-indicator {
+ @apply absolute bottom-0 left-0 top-0;
+ @apply bg-button-primary-fill;
+ @apply transition-[transform,width];
+}
diff --git a/packages/ui/src/components/segmented-controls/segmented-controls.tsx b/packages/ui/src/components/segmented-controls/segmented-controls.tsx
new file mode 100644
index 00000000..f04890cd
--- /dev/null
+++ b/packages/ui/src/components/segmented-controls/segmented-controls.tsx
@@ -0,0 +1,113 @@
+import * as React from 'react';
+import { useCallback, useLayoutEffect, useRef, useState } from 'react';
+
+import { Icon } from '@/components/icon/icon';
+import styles from '@/components/segmented-controls/segmented-controls.module.css';
+import { cn } from '@/lib/utils';
+
+export type SegmentedControlsDisplay = 'both' | 'text-only' | 'icon-only';
+
+export interface SegmentedControlOption {
+ /** Unique value used to identify the option. */
+ value: string;
+ /** Text label. Mandatory — used as the visible text and as the accessible label when `display="icon-only"`. */
+ label: string;
+ /** Optional prefix icon: a Lucide icon name or a React element. */
+ icon?: React.ReactElement | string;
+ /** Optional content rendered after the label. */
+ suffix?: React.ReactNode;
+}
+
+export interface SegmentedControlsProps {
+ options: SegmentedControlOption[];
+ value: string;
+ onValueChange: (value: string) => void;
+ /**
+ * Controls what is rendered inside each segment.
+ * `icon-only` requires every option to define an `icon` and falls back to `both` otherwise.
+ */
+ display?: SegmentedControlsDisplay;
+ className?: string;
+}
+
+const SegmentedControls = ({
+ options,
+ value,
+ onValueChange,
+ display = 'both',
+ className,
+}: SegmentedControlsProps) => {
+ const allHaveIcons = options.every((option) => option.icon != null);
+ // icon-only is only valid when every option has an icon, otherwise fall back to showing both.
+ const effectiveDisplay = display === 'icon-only' && !allHaveIcons ? 'both' : display;
+ const isIconOnly = effectiveDisplay === 'icon-only';
+
+ const containerRef = useRef(null);
+ const segmentRefs = useRef>({});
+ const [indicator, setIndicator] = useState<{ left: number; width: number }>({
+ left: 0,
+ width: 0,
+ });
+
+ const updateIndicator = useCallback(() => {
+ const activeSegment = segmentRefs.current[value];
+ if (activeSegment) {
+ setIndicator({ left: activeSegment.offsetLeft, width: activeSegment.offsetWidth });
+ }
+ }, [value]);
+
+ // Reposition the indicator whenever the active value, options, or display mode change.
+ useLayoutEffect(() => {
+ updateIndicator();
+ }, [updateIndicator, options, effectiveDisplay]);
+
+ // Keep the indicator aligned when the control is resized.
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const observer = new ResizeObserver(updateIndicator);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [updateIndicator]);
+
+ return (
+
+ {options.map((option) => {
+ const currentlyActive = option.value === value;
+ const showIcon = option.icon != null && effectiveDisplay !== 'text-only';
+ const showLabel = effectiveDisplay !== 'icon-only';
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export { SegmentedControls };