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 };