diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 44ae7793..c0ba0d73 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -434,7 +434,8 @@ export const rows: Rows = [ }, { component: 'ClampedList', - status: '🚧 Planned', + status: '✅ Done', + stage: '🔵 experimental', planned: 'Q3 2026', }, { diff --git a/packages/components/src/components/ClampedList/ClampedList.mdx b/packages/components/src/components/ClampedList/ClampedList.mdx new file mode 100644 index 00000000..b9eea85b --- /dev/null +++ b/packages/components/src/components/ClampedList/ClampedList.mdx @@ -0,0 +1,73 @@ +import { + Meta, + Story, + Props, + Status, + Alert, +} from '../../../../../.storybook/components'; + +import * as Stories from './ClampedList.stories'; + + + +# ClampedList + + + +`ClampedList` keeps long collections compact and lets users show or hide the remaining items. +It provides the visible items, and you define how to render them. + +## Import + +```tsx +import { ClampedList } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Visibility threshold + +By default, `ClampedList` shows `10` items. The toggle appears when `6` or more +items remain. If fewer items remain, all items are shown. + +Use `collapsedVisibleCount` and `hiddenThreshold` to change these values. +`hiddenItemCount` is based on the collapsed state, so it stays the same after +expansion. + + + +## Controlled expansion + +Use `isExpanded` with `onExpandedChange` when expansion state is owned by the +application. For an uncontrolled initial state, use `defaultExpanded`. + + + +## Flat lists + +Items can be rendered inline. Use `moreText` and `lessText` for compact toggle +labels that continue the same line as the visible items. + +Use `slotProps.content` and `slotProps.toggle` to customize the content +container and toggle. Set `slotProps.toggle.icon` to `null` to remove the +chevron. Pass an icon or a function that receives `isExpanded` to replace it. + + + +Use a centered dot when a stronger visual separator is needed in a wrapped +multiline list. + + + +## Accessibility + +The rendered collection is wrapped in a `role="group"` container. The native +button references that container with `aria-controls` and exposes the current +state through `aria-expanded`. It supports mouse, touch, Enter, and Space +interactions. diff --git a/packages/components/src/components/ClampedList/ClampedList.module.css b/packages/components/src/components/ClampedList/ClampedList.module.css new file mode 100644 index 00000000..dd8c983b --- /dev/null +++ b/packages/components/src/components/ClampedList/ClampedList.module.css @@ -0,0 +1,7 @@ +.base { + max-inline-size: 100%; +} + +.content { + min-inline-size: 0; +} diff --git a/packages/components/src/components/ClampedList/ClampedList.stories.tsx b/packages/components/src/components/ClampedList/ClampedList.stories.tsx new file mode 100644 index 00000000..ed96e9c7 --- /dev/null +++ b/packages/components/src/components/ClampedList/ClampedList.stories.tsx @@ -0,0 +1,576 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FlexBox } from '../FlexBox'; +import { Link } from '../Link'; +import { Toggle } from '../Toggle'; +import { Typography } from '../Typography'; + +import { ClampedList, type ClampedListProps } from './index'; + +type Item = { + id: number | string; + name: string; + url: string; +}; + +const meta = { + title: 'Components/ClampedList', + component: ClampedList, + parameters: { + layout: 'padded', + }, + tags: ['status:new', 'date:2026-08-14'], +} satisfies Meta; + +export default meta; +type Story = StoryObj>; + +export const Base: Story = { + render: function Render(args) { + const items = [ + { + id: 'T1557', + name: 'Adversary-in-the-Middle', + url: 'https://attack.mitre.org/techniques/T1557/', + }, + { + id: 'T1110', + name: 'Brute Force', + url: 'https://attack.mitre.org/techniques/T1110/', + }, + { + id: 'T1555', + name: 'Credentials from Password Stores', + url: 'https://attack.mitre.org/techniques/T1555/', + }, + { + id: 'T1212', + name: 'Exploitation for Credential Access', + url: 'https://attack.mitre.org/techniques/T1212/', + }, + { + id: 'T1187', + name: 'Forced Authentication', + url: 'https://attack.mitre.org/techniques/T1187/', + }, + { + id: 'T1606', + name: 'Forge Web Credentials', + url: 'https://attack.mitre.org/techniques/T1606/', + }, + { + id: 'T1056', + name: 'Input Capture', + url: 'https://attack.mitre.org/techniques/T1056/', + }, + { + id: 'T1556', + name: 'Modify Authentication Process', + url: 'https://attack.mitre.org/techniques/T1556/', + }, + { + id: 'T1111', + name: 'Multi-Factor Authentication Interception', + url: 'https://attack.mitre.org/techniques/T1111/', + }, + { + id: 'T1621', + name: 'Multi-Factor Authentication Request Generation', + url: 'https://attack.mitre.org/techniques/T1621/', + }, + { + id: 'T1040', + name: 'Network Sniffing', + url: 'https://attack.mitre.org/techniques/T1040/', + }, + { + id: 'T1003', + name: 'OS Credential Dumping', + url: 'https://attack.mitre.org/techniques/T1003/', + }, + { + id: 'T1528', + name: 'Steal Application Access Token', + url: 'https://attack.mitre.org/techniques/T1528/', + }, + { + id: 'T1649', + name: 'Steal or Forge Authentication Certificates', + url: 'https://attack.mitre.org/techniques/T1649/', + }, + { + id: 'T1558', + name: 'Steal or Forge Kerberos Tickets', + url: 'https://attack.mitre.org/techniques/T1558/', + }, + { + id: 'T1539', + name: 'Steal Web Session Cookie', + url: 'https://attack.mitre.org/techniques/T1539/', + }, + { + id: 'T1552', + name: 'Unsecured Credentials', + url: 'https://attack.mitre.org/techniques/T1552/', + }, + ]; + + return ( + + {({ visibleItems }) => ( +
    + {visibleItems.map((item) => ( +
  • + + {item.name} + + {item.id} +
  • + ))} +
+ )} +
+ ); + }, +}; + +export const CustomVisibility: Story = { + render: function Render() { + const items = [ + { + id: 'T1557', + name: 'Adversary-in-the-Middle', + url: 'https://attack.mitre.org/techniques/T1557/', + }, + { + id: 'T1110', + name: 'Brute Force', + url: 'https://attack.mitre.org/techniques/T1110/', + }, + { + id: 'T1555', + name: 'Credentials from Password Stores', + url: 'https://attack.mitre.org/techniques/T1555/', + }, + { + id: 'T1212', + name: 'Exploitation for Credential Access', + url: 'https://attack.mitre.org/techniques/T1212/', + }, + { + id: 'T1187', + name: 'Forced Authentication', + url: 'https://attack.mitre.org/techniques/T1187/', + }, + { + id: 'T1606', + name: 'Forge Web Credentials', + url: 'https://attack.mitre.org/techniques/T1606/', + }, + { + id: 'T1056', + name: 'Input Capture', + url: 'https://attack.mitre.org/techniques/T1056/', + }, + { + id: 'T1556', + name: 'Modify Authentication Process', + url: 'https://attack.mitre.org/techniques/T1556/', + }, + { + id: 'T1111', + name: 'Multi-Factor Authentication Interception', + url: 'https://attack.mitre.org/techniques/T1111/', + }, + { + id: 'T1621', + name: 'Multi-Factor Authentication Request Generation', + url: 'https://attack.mitre.org/techniques/T1621/', + }, + { + id: 'T1040', + name: 'Network Sniffing', + url: 'https://attack.mitre.org/techniques/T1040/', + }, + { + id: 'T1003', + name: 'OS Credential Dumping', + url: 'https://attack.mitre.org/techniques/T1003/', + }, + { + id: 'T1528', + name: 'Steal Application Access Token', + url: 'https://attack.mitre.org/techniques/T1528/', + }, + { + id: 'T1649', + name: 'Steal or Forge Authentication Certificates', + url: 'https://attack.mitre.org/techniques/T1649/', + }, + { + id: 'T1558', + name: 'Steal or Forge Kerberos Tickets', + url: 'https://attack.mitre.org/techniques/T1558/', + }, + { + id: 'T1539', + name: 'Steal Web Session Cookie', + url: 'https://attack.mitre.org/techniques/T1539/', + }, + { + id: 'T1552', + name: 'Unsecured Credentials', + url: 'https://attack.mitre.org/techniques/T1552/', + }, + ]; + + return ( + + {({ visibleItems }) => ( +
    + {visibleItems.map((item) => ( +
  • + + {item.name} + + {item.id} +
  • + ))} +
+ )} +
+ ); + }, +}; + +export const Controlled: Story = { + render: function Render() { + const [isExpanded, setExpanded] = useState(false); + + const items = [ + { + id: 'T1557', + name: 'Adversary-in-the-Middle', + url: 'https://attack.mitre.org/techniques/T1557/', + }, + { + id: 'T1110', + name: 'Brute Force', + url: 'https://attack.mitre.org/techniques/T1110/', + }, + { + id: 'T1555', + name: 'Credentials from Password Stores', + url: 'https://attack.mitre.org/techniques/T1555/', + }, + { + id: 'T1212', + name: 'Exploitation for Credential Access', + url: 'https://attack.mitre.org/techniques/T1212/', + }, + { + id: 'T1187', + name: 'Forced Authentication', + url: 'https://attack.mitre.org/techniques/T1187/', + }, + { + id: 'T1606', + name: 'Forge Web Credentials', + url: 'https://attack.mitre.org/techniques/T1606/', + }, + { + id: 'T1056', + name: 'Input Capture', + url: 'https://attack.mitre.org/techniques/T1056/', + }, + { + id: 'T1556', + name: 'Modify Authentication Process', + url: 'https://attack.mitre.org/techniques/T1556/', + }, + { + id: 'T1111', + name: 'Multi-Factor Authentication Interception', + url: 'https://attack.mitre.org/techniques/T1111/', + }, + { + id: 'T1621', + name: 'Multi-Factor Authentication Request Generation', + url: 'https://attack.mitre.org/techniques/T1621/', + }, + { + id: 'T1040', + name: 'Network Sniffing', + url: 'https://attack.mitre.org/techniques/T1040/', + }, + { + id: 'T1003', + name: 'OS Credential Dumping', + url: 'https://attack.mitre.org/techniques/T1003/', + }, + { + id: 'T1528', + name: 'Steal Application Access Token', + url: 'https://attack.mitre.org/techniques/T1528/', + }, + { + id: 'T1649', + name: 'Steal or Forge Authentication Certificates', + url: 'https://attack.mitre.org/techniques/T1649/', + }, + { + id: 'T1558', + name: 'Steal or Forge Kerberos Tickets', + url: 'https://attack.mitre.org/techniques/T1558/', + }, + { + id: 'T1539', + name: 'Steal Web Session Cookie', + url: 'https://attack.mitre.org/techniques/T1539/', + }, + { + id: 'T1552', + name: 'Unsecured Credentials', + url: 'https://attack.mitre.org/techniques/T1552/', + }, + ]; + + return ( + + + Expanded + + + {({ visibleItems }) => ( +
    + {visibleItems.map((item) => ( +
  • + + {item.name} + + {item.id} +
  • + ))} +
+ )} +
+
+ ); + }, +}; + +export const FlatList: Story = { + render: function Render() { + const items = [ + 'Australia', + 'Austria', + 'Argentina', + 'Belgium', + 'Brazil', + 'United Kingdom', + 'Germany', + 'Greece', + 'Denmark', + 'Egypt', + 'India', + 'Spain', + 'Italy', + 'Canada', + 'Mexico', + 'Netherlands', + 'Norway', + 'Poland', + 'Portugal', + 'Russia', + 'United States', + 'Thailand', + 'Turkey', + 'France', + 'Japan', + ].map((name, index) => ({ id: index, name, url: '#' })); + + return ( + + {({ visibleItems }) => ( +
    + {visibleItems.map((item) => ( +
  • + + {item.name} + {',\u00a0'} + +
  • + ))} +
+ )} +
+ ); + }, +}; + +export const FlatListWithDotSeparators: Story = { + render: function Render() { + const items = [ + 'Australia', + 'Austria', + 'Argentina', + 'Belgium', + 'Brazil', + 'United Kingdom', + 'Germany', + 'Greece', + 'Denmark', + 'Egypt', + 'India', + 'Spain', + 'Italy', + 'Canada', + 'Mexico', + 'Netherlands', + 'Norway', + 'Poland', + 'Portugal', + 'Russia', + 'United States', + 'Thailand', + 'Turkey', + 'France', + 'Japan', + ].map((name, index) => ({ id: index, name, url: '#' })); + + return ( + + {({ visibleItems }) => ( +
    + {visibleItems.map((item) => ( +
  • + + {item.name} + + + + + + +
  • + ))} +
+ )} +
+ ); + }, +}; diff --git a/packages/components/src/components/ClampedList/ClampedList.test.tsx b/packages/components/src/components/ClampedList/ClampedList.test.tsx new file mode 100644 index 00000000..eeb4087a --- /dev/null +++ b/packages/components/src/components/ClampedList/ClampedList.test.tsx @@ -0,0 +1,532 @@ +import { createRef, type ReactNode } from 'react'; + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { List } from '../List'; +import { Provider } from '../Provider'; + +import { + ClampedList, + type ClampedListProps, + type ClampedListState, +} from './index'; + +type Item = { + id: number; + name: string; +}; + +const createItems = (length: number): Item[] => + Array.from({ length }, (_, index) => ({ + id: index + 1, + name: `Item ${index + 1}`, + })); + +function renderItems({ + visibleItems, + hiddenItemCount, + isExpanded, +}: ClampedListState): ReactNode { + return ( +
    + {visibleItems.map((item) => ( +
  • {item.name}
  • + ))} +
+ ); +} + +const getRenderedItems = () => + within(screen.getByTestId('items')).getAllByRole('listitem'); + +describe('ClampedList', () => { + it('renders the default collapsed state and accessible trigger', () => { + render({renderItems}); + + const content = screen.getByRole('group'); + const trigger = screen.getByRole('button', { name: 'Show 7 more' }); + + expect(getRenderedItems()).toHaveLength(10); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-hidden-count', + '7' + ); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-expanded', + 'false' + ); + + expect(content.id).not.toBe(''); + expect(trigger).toHaveAttribute('type', 'button'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveAttribute('aria-controls', content.id); + expect(content).not.toContainElement(trigger); + + expect(trigger.querySelector('svg')?.parentElement).toHaveAttribute( + 'aria-hidden', + 'true' + ); + }); + + it('renders a trigger when the hidden count equals the threshold', () => { + render({renderItems}); + + expect( + screen.getByRole('button', { name: 'Show 6 more' }) + ).toBeInTheDocument(); + + expect(getRenderedItems()).toHaveLength(10); + }); + + it('renders all items without a trigger below the threshold', () => { + render({renderItems}); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(getRenderedItems()).toHaveLength(15); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-hidden-count', + '5' + ); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-expanded', + 'true' + ); + }); + + it('renders an empty collection as expanded without a trigger', () => { + render({renderItems}); + + expect(screen.getByTestId('items')).toBeEmptyDOMElement(); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-hidden-count', + '0' + ); + + expect(screen.getByTestId('items')).toHaveAttribute( + 'data-expanded', + 'true' + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('supports custom counts and non-array iterables', () => { + const items = new Set(createItems(8)); + + render( + + {renderItems} + + ); + + expect(getRenderedItems()).toHaveLength(3); + + expect( + screen.getByRole('button', { name: 'Show 5 more' }) + ).toBeInTheDocument(); + }); + + it('normalizes invalid visibility counts', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { rerender } = render( + + {renderItems} + + ); + + expect( + within(screen.getByTestId('items')).queryAllByRole('listitem') + ).toHaveLength(0); + + expect( + screen.getByRole('button', { name: 'Show 8 more' }) + ).toBeInTheDocument(); + + expect(consoleWarn).toHaveBeenCalledWith( + '[koobiq] ClampedList: the "collapsedVisibleCount" prop must be a non-negative integer. The received value was normalized.' + ); + + expect(consoleWarn).toHaveBeenCalledWith( + '[koobiq] ClampedList: the "hiddenThreshold" prop must be a positive integer. The received value was normalized.' + ); + + rerender( + + {renderItems} + + ); + + expect(getRenderedItems()).toHaveLength(3); + + expect( + screen.getByRole('button', { name: 'Show 7 more' }) + ).toBeInTheDocument(); + + rerender( + + {renderItems} + + ); + + expect(getRenderedItems()).toHaveLength(10); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender( + + {renderItems} + + ); + + expect(getRenderedItems()).toHaveLength(10); + + expect( + screen.getByRole('button', { name: 'Show 7 more' }) + ).toBeInTheDocument(); + + consoleWarn.mockRestore(); + }); + + it('keeps a one-shot iterable materialized while toggling', async () => { + function* generateItems() { + yield* createItems(17); + } + + const user = userEvent.setup(); + + render({renderItems}); + + await user.click(screen.getByRole('button', { name: 'Show 7 more' })); + + expect(getRenderedItems()).toHaveLength(17); + + expect( + screen.getByRole('button', { name: 'Collapse' }) + ).toBeInTheDocument(); + }); + + it('supports defaultExpanded and uncontrolled keyboard toggling', async () => { + const onExpandedChange = vi.fn(); + const user = userEvent.setup(); + + render( + + {renderItems} + + ); + + const trigger = screen.getByRole('button', { name: 'Collapse' }); + + expect(getRenderedItems()).toHaveLength(17); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + trigger.focus(); + await user.keyboard('{Enter}'); + + expect(getRenderedItems()).toHaveLength(10); + + expect(screen.getByRole('button', { name: 'Show 7 more' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + await user.keyboard(' '); + + expect(getRenderedItems()).toHaveLength(17); + + expect(onExpandedChange.mock.calls.map(([value]) => value)).toEqual([ + false, + true, + ]); + }); + + it('supports controlled expansion', async () => { + const onExpandedChange = vi.fn(); + const user = userEvent.setup(); + const items = createItems(17); + + const { rerender } = render( + + {renderItems} + + ); + + await user.click(screen.getByRole('button', { name: 'Show 7 more' })); + + expect(onExpandedChange).toHaveBeenCalledWith(true); + expect(getRenderedItems()).toHaveLength(10); + + expect(screen.getByRole('button', { name: 'Show 7 more' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + rerender( + + {renderItems} + + ); + + expect(getRenderedItems()).toHaveLength(17); + + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + }); + + it('preserves the expansion preference while the trigger is unnecessary', async () => { + const user = userEvent.setup(); + const expandedItems = createItems(17); + + const { rerender } = render( + {renderItems} + ); + + await user.click(screen.getByRole('button', { name: 'Show 7 more' })); + + rerender({renderItems}); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(getRenderedItems()).toHaveLength(12); + + rerender({renderItems}); + + expect(getRenderedItems()).toHaveLength(17); + + expect( + screen.getByRole('button', { name: 'Collapse' }) + ).toBeInTheDocument(); + }); + + it('supports custom toggle labels', async () => { + const user = userEvent.setup(); + + render( + Reveal all items} + lessText={Hide extra items} + > + {renderItems} + + ); + + await user.click(screen.getByRole('button', { name: 'Reveal all items' })); + + expect( + screen.getByRole('button', { name: 'Hide extra items' }) + ).toBeInTheDocument(); + }); + + it('supports hiding the toggle icon', () => { + render( + + {renderItems} + + ); + + expect( + screen.getByRole('button', { name: 'Show 7 more' }).querySelector('svg') + ).not.toBeInTheDocument(); + }); + + it('supports a custom toggle icon', async () => { + const user = userEvent.setup(); + + const toggleIcon = vi.fn((isExpanded: boolean) => ( + {isExpanded ? 'Up' : 'Down'} + )); + + render( + + {renderItems} + + ); + + expect(screen.getByTestId('toggle-icon')).toHaveTextContent('Down'); + + expect(screen.getByTestId('toggle-icon').parentElement).toHaveAttribute( + 'aria-hidden', + 'true' + ); + + await user.click(screen.getByRole('button', { name: 'Show 7 more' })); + + expect(screen.getByTestId('toggle-icon')).toHaveTextContent('Up'); + expect(toggleIcon).toHaveBeenLastCalledWith(true); + }); + + it('supports customizing the content and toggle slots', async () => { + const contentRef = createRef(); + const toggleRef = createRef(); + const onContentClick = vi.fn(); + const onTogglePress = vi.fn(); + const user = userEvent.setup(); + + render( + + {renderItems} + + ); + + const content = screen.getByRole('group'); + const toggle = screen.getByRole('button', { name: 'Show custom list' }); + + expect(contentRef.current).toBe(content); + expect(content).toHaveAttribute('id', 'custom-content'); + expect(content).toHaveClass('custom-content'); + expect(content.style.color).toBe('red'); + expect(content).toHaveAttribute('data-testid', 'custom-content'); + + expect(toggleRef.current).toBe(toggle); + expect(toggle).toHaveAttribute('type', 'button'); + expect(toggle).toHaveAttribute('aria-controls', 'custom-content'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveClass('custom-toggle'); + expect(toggle).toHaveStyle({ inlineSize: '100%' }); + expect(toggle).toHaveAttribute('data-testid', 'custom-toggle'); + + fireEvent.click(content); + expect(onContentClick).toHaveBeenCalledTimes(1); + + await user.click(toggle); + + expect(getRenderedItems()).toHaveLength(17); + expect(onTogglePress).toHaveBeenCalledTimes(1); + + expect( + screen.getByRole('button', { name: 'Collapse custom list' }) + ).toHaveAttribute('aria-expanded', 'true'); + }); + + it('protects managed toggle ARIA attributes from slot overrides', () => { + type ToggleSlotProps = NonNullable< + NonNullable['slotProps']>['toggle'] + >; + + const unsafeToggleProps = { + 'aria-controls': 'wrong-content', + 'aria-expanded': true, + } as unknown as ToggleSlotProps; + + render( + + {renderItems} + + ); + + const content = screen.getByRole('group'); + const toggle = screen.getByRole('button', { name: 'Show 7 more' }); + + expect(toggle).toHaveAttribute('aria-controls', content.id); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + it('uses localized trigger content', () => { + render( + + {renderItems} + + ); + + expect( + screen.getByRole('button', { name: 'Показать ещё 7' }) + ).toBeInTheDocument(); + }); + + it('renders the trigger outside a nested Koobiq List', async () => { + const user = userEvent.setup(); + const items = createItems(17); + + render( + + {({ visibleItems }) => ( + + {(item) => {item.name}} + + )} + + ); + + const content = screen.getByRole('group'); + const listbox = screen.getByRole('listbox', { name: 'Items' }); + const trigger = screen.getByRole('button', { name: 'Show 7 more' }); + + expect(within(listbox).getAllByRole('option')).toHaveLength(10); + expect(content).toContainElement(listbox); + expect(content).not.toContainElement(trigger); + expect(listbox).not.toContainElement(trigger); + + await user.click(trigger); + + expect(within(listbox).getAllByRole('option')).toHaveLength(17); + }); +}); diff --git a/packages/components/src/components/ClampedList/ClampedList.tsx b/packages/components/src/components/ClampedList/ClampedList.tsx new file mode 100644 index 00000000..44963fff --- /dev/null +++ b/packages/components/src/components/ClampedList/ClampedList.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useMemo, type ReactNode } from 'react'; + +import { once } from '@koobiq/logger'; +import { + mergeProps, + useControlledState, + useId, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; + +import s from './ClampedList.module.css'; +import { ClampedListTrigger } from './components'; +import intlMessages from './intl'; +import type { ClampedListProps, ClampedListState } from './types'; + +const DEFAULT_COLLAPSED_VISIBLE_COUNT = 10; +const DEFAULT_HIDDEN_THRESHOLD = 6; + +/** + * ClampedList limits the visible portion of a collection and lets the user + * expand or collapse it without prescribing the collection markup. + */ +export function ClampedList({ + items, + children, + collapsedVisibleCount = DEFAULT_COLLAPSED_VISIBLE_COUNT, + hiddenThreshold = DEFAULT_HIDDEN_THRESHOLD, + isExpanded: isExpandedProp, + defaultExpanded, + onExpandedChange, + moreText, + lessText, + slotProps, +}: ClampedListProps) { + const allItems = useMemo(() => Array.from(items), [items]); + const generatedContentId = useId(); + const contentId = slotProps?.content?.id ?? generatedContentId; + const strings = useLocalizedStringFormatter(intlMessages); + + const normalizedCollapsedVisibleCount = Number.isFinite(collapsedVisibleCount) + ? Math.max(0, Math.trunc(collapsedVisibleCount)) + : DEFAULT_COLLAPSED_VISIBLE_COUNT; + + const normalizedHiddenThreshold = Number.isFinite(hiddenThreshold) + ? Math.max(1, Math.trunc(hiddenThreshold)) + : DEFAULT_HIDDEN_THRESHOLD; + + if ( + process.env.NODE_ENV !== 'production' && + (!Number.isInteger(collapsedVisibleCount) || collapsedVisibleCount < 0) + ) { + once.warn( + 'ClampedList: the "collapsedVisibleCount" prop must be a non-negative integer. The received value was normalized.' + ); + } + + if ( + process.env.NODE_ENV !== 'production' && + (!Number.isInteger(hiddenThreshold) || hiddenThreshold < 1) + ) { + once.warn( + 'ClampedList: the "hiddenThreshold" prop must be a positive integer. The received value was normalized.' + ); + } + + const [preferredExpanded, setPreferredExpanded] = useControlledState( + isExpandedProp, + defaultExpanded ?? false, + onExpandedChange + ); + + const hiddenItemCount = Math.max( + allItems.length - normalizedCollapsedVisibleCount, + 0 + ); + + const hasToggle = + hiddenItemCount > 0 && hiddenItemCount >= normalizedHiddenThreshold; + + const isExpanded = !hasToggle || preferredExpanded; + + const visibleItems = useMemo( + () => + isExpanded + ? allItems + : allItems.slice(0, normalizedCollapsedVisibleCount), + [allItems, isExpanded, normalizedCollapsedVisibleCount] + ); + + const state = useMemo>( + () => ({ visibleItems, hiddenItemCount, isExpanded }), + [visibleItems, hiddenItemCount, isExpanded] + ); + + const onToggle = () => setPreferredExpanded((value) => !value); + + const contentProps = mergeProps( + { className: s.content }, + slotProps?.content, + { id: contentId, role: 'group' } + ); + + const { icon: iconProp, ...toggleSlotProps } = slotProps?.toggle ?? {}; + + let toggleIcon: ReactNode; + + if (iconProp === undefined) { + toggleIcon = isExpanded ? : ; + } else if (typeof iconProp === 'function') { + toggleIcon = iconProp(isExpanded); + } else { + toggleIcon = iconProp; + } + + let toggleContent: ReactNode; + + if (isExpanded) { + toggleContent = lessText ?? strings.format('collapse'); + } else { + toggleContent = + moreText ?? + strings.format('show more', { + count: hiddenItemCount, + }); + } + + const toggleProps = mergeProps( + { + onPress: onToggle, + }, + toggleSlotProps, + { + 'aria-controls': contentId, + 'aria-expanded': isExpanded, + children: toggleContent, + icon: toggleIcon, + } + ); + + return ( +
+
{children(state)}
+ {hasToggle && } +
+ ); +} diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css new file mode 100644 index 00000000..8d5531ab --- /dev/null +++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.module.css @@ -0,0 +1,43 @@ +@import url('../../../../styles/mixins.css'); + +.icon { + display: inline-flex; + flex-shrink: 0; +} + +.base { + gap: var(--kbq-size-xxs); + padding: 0; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + margin-block-start: var(--kbq-size-xxs); + cursor: pointer; + background: none; + color: var(--kbq-foreground-theme); + outline: var(--kbq-size-3xs) solid transparent; + text-decoration: underline; + text-decoration-color: transparent; + transition: + color var(--kbq-transition-default), + outline var(--kbq-transition-default), + text-decoration-color var(--kbq-transition-default); + + @mixin typography text-normal; + + &[data-hovered] { + color: var(--kbq-states-foreground-theme-hover); + text-decoration-color: var(--kbq-line-theme-less); + } + + &[data-pressed] { + color: var(--kbq-states-foreground-theme-active); + text-decoration-color: var(--kbq-line-theme-less); + } + + &[data-focus-visible] { + color: var(--kbq-foreground-theme); + outline-color: var(--kbq-states-line-focus-theme); + } +} diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx new file mode 100644 index 00000000..ccb0f805 --- /dev/null +++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/ClampedListTrigger.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import { + Button as ButtonPrimitive, + composeRenderProps, +} from '@koobiq/react-primitives'; + +import s from './ClampedListTrigger.module.css'; +import type { ClampedListTriggerProps } from './types'; + +export const ClampedListTrigger = forwardRef< + HTMLButtonElement, + ClampedListTriggerProps +>(({ children, className, icon, ...props }, ref) => ( + clsx(s.base, value))} + > + {icon == null || icon === false ? null : ( + + {icon} + + )} + {children} + +)); + +ClampedListTrigger.displayName = 'ClampedListTrigger'; diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts b/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts new file mode 100644 index 00000000..157a1659 --- /dev/null +++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/index.ts @@ -0,0 +1,2 @@ +export * from './ClampedListTrigger'; +export type * from './types'; diff --git a/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts b/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts new file mode 100644 index 00000000..d29db471 --- /dev/null +++ b/packages/components/src/components/ClampedList/components/ClampedListTrigger/types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { ButtonProps } from '@koobiq/react-primitives'; + +export type ClampedListTriggerProps = Omit< + ButtonProps, + 'as' | 'children' | 'type' +> & + DataAttributeProps & { + children: ReactNode; + /** Icon displayed before the toggle content. */ + icon?: ReactNode; + }; diff --git a/packages/components/src/components/ClampedList/components/index.ts b/packages/components/src/components/ClampedList/components/index.ts new file mode 100644 index 00000000..a280bc2e --- /dev/null +++ b/packages/components/src/components/ClampedList/components/index.ts @@ -0,0 +1 @@ +export * from './ClampedListTrigger'; diff --git a/packages/components/src/components/ClampedList/index.ts b/packages/components/src/components/ClampedList/index.ts new file mode 100644 index 00000000..011a6fb4 --- /dev/null +++ b/packages/components/src/components/ClampedList/index.ts @@ -0,0 +1,3 @@ +export * from './ClampedList'; +export * from './types'; +export type { ClampedListTriggerProps } from './components'; diff --git a/packages/components/src/components/ClampedList/intl.ts b/packages/components/src/components/ClampedList/intl.ts new file mode 100644 index 00000000..e6400488 --- /dev/null +++ b/packages/components/src/components/ClampedList/intl.ts @@ -0,0 +1,10 @@ +export default { + 'ru-RU': { + collapse: 'Свернуть', + 'show more': ({ count }: { count: number }) => `Показать ещё ${count}`, + }, + 'en-US': { + collapse: 'Collapse', + 'show more': ({ count }: { count: number }) => `Show ${count} more`, + }, +} as unknown as Record>; diff --git a/packages/components/src/components/ClampedList/types.ts b/packages/components/src/components/ClampedList/types.ts new file mode 100644 index 00000000..9de455cb --- /dev/null +++ b/packages/components/src/components/ClampedList/types.ts @@ -0,0 +1,59 @@ +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +import type { ClampedListTriggerProps } from './components'; + +export type ClampedListState = { + /** Items currently exposed to the render function. */ + visibleItems: T[]; + /** Number of items that would be hidden in the collapsed state. */ + hiddenItemCount: number; + /** Whether all items are currently visible. */ + isExpanded: boolean; +}; + +export type ClampedListProps = { + /** Collection of items managed by the component. */ + items: Iterable; + /** Renders the visible portion of the collection. */ + children: (state: ClampedListState) => ReactNode; + /** + * Maximum number of visible items in the collapsed state. + * @default 10 + */ + collapsedVisibleCount?: number; + /** + * Minimum number of hidden items required to render the toggle. + * @default 6 + */ + hiddenThreshold?: number; + /** Whether all items are visible. */ + isExpanded?: boolean; + /** + * Whether all items are visible by default. + * @default false + */ + defaultExpanded?: boolean; + /** Handler called when the user toggles the expanded state. */ + onExpandedChange?: (isExpanded: boolean) => void; + /** Content displayed in the toggle when the list is collapsed. */ + moreText?: ReactNode; + /** Content displayed in the toggle when the list is expanded. */ + lessText?: ReactNode; + /** The props used for each slot inside. */ + slotProps?: { + content?: Omit, 'children'> & + DataAttributeProps; + toggle?: Omit< + ClampedListTriggerProps, + 'children' | 'icon' | 'aria-controls' | 'aria-expanded' + > & { + /** + * Icon displayed before the toggle content. Pass `null` to hide it or a + * render function to use the current expanded state. + */ + icon?: ReactNode | ((isExpanded: boolean) => ReactNode); + }; + }; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 957979b1..8af9f784 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -12,6 +12,7 @@ export * from './Typography'; export * from './Checkbox'; export * from './CheckboxGroup'; export * from './ClampedText'; +export * from './ClampedList'; export * from './Link'; export * from './Badge'; export * from './Input'; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 5087d68c..9fb1d368 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -14,6 +14,7 @@ "Checkbox", "CheckboxGroup", "ClampedText", + "ClampedList", "Container", "ContentPanel", "DateInput", diff --git a/tools/public_api_guard/components/ClampedList.api.md b/tools/public_api_guard/components/ClampedList.api.md new file mode 100644 index 00000000..1e2e9a6f --- /dev/null +++ b/tools/public_api_guard/components/ClampedList.api.md @@ -0,0 +1,50 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonProps } from '@koobiq/react-primitives'; +import type { ComponentPropsWithRef } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import { JSX } from 'react/jsx-runtime'; +import type { ReactNode } from 'react'; + +// @public +export function ClampedList(input: ClampedListProps): JSX.Element; + +// @public (undocumented) +export type ClampedListProps = { + items: Iterable; + children: (state: ClampedListState) => ReactNode; + collapsedVisibleCount?: number; + hiddenThreshold?: number; + isExpanded?: boolean; + defaultExpanded?: boolean; + onExpandedChange?: (isExpanded: boolean) => void; + moreText?: ReactNode; + lessText?: ReactNode; + slotProps?: { + content?: Omit, 'children'> & DataAttributeProps; + toggle?: Omit & { + icon?: ReactNode | ((isExpanded: boolean) => ReactNode); + }; + }; +}; + +// @public (undocumented) +export type ClampedListState = { + visibleItems: T[]; + hiddenItemCount: number; + isExpanded: boolean; +}; + +// @public (undocumented) +export type ClampedListTriggerProps = Omit & DataAttributeProps & { + children: ReactNode; + icon?: ReactNode; +}; + +// (No @packageDocumentation comment for this package) + +```