From f0ff24d42d61c0ef391b593daf3f1aeb93324668 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Thu, 25 Sep 2025 15:55:01 +0200 Subject: [PATCH 01/19] initial commit --- packages/react/src/index.ts | 1 + packages/react/src/table/index.ts | 14 + packages/react/src/table/table.stories.tsx | 208 +++++++++++++ packages/react/src/table/table.tsx | 333 +++++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 packages/react/src/table/index.ts create mode 100644 packages/react/src/table/table.stories.tsx create mode 100644 packages/react/src/table/table.tsx diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d48d8918d..31f2092a1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,5 +29,6 @@ export * from './tag-group'; export * from './hero'; export * from './carousel'; export * from './tabs'; +export * from './table'; export * from './link'; export * from './link-list'; diff --git a/packages/react/src/table/index.ts b/packages/react/src/table/index.ts new file mode 100644 index 000000000..e84248d42 --- /dev/null +++ b/packages/react/src/table/index.ts @@ -0,0 +1,14 @@ +export { + UNSAFE_Table, + UNSAFE_TableBody, + UNSAFE_TableCell, + UNSAFE_TableColumn, + UNSAFE_TableHeader, + UNSAFE_TableRow, + type UNSAFE_TableBodyProps, + type UNSAFE_TableCellProps, + type UNSAFE_TableColumnProps, + type UNSAFE_TableHeaderProps, + type UNSAFE_TableProps, + type UNSAFE_TableRowProps, +} from './table'; diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx new file mode 100644 index 000000000..8864001ea --- /dev/null +++ b/packages/react/src/table/table.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + UNSAFE_Table as Table, + UNSAFE_TableBody as TableBody, + UNSAFE_TableCell as TableCell, + UNSAFE_TableColumn as TableColumn, + UNSAFE_TableHeader as TableHeader, + UNSAFE_TableRow as TableRow, +} from './table'; + +const meta: Meta = { + title: 'Table', + component: Table, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const sampleData = [ + { + id: '1', + product: 'Sparekonto Egenkapital', + subtitle: 'For OBOS-medlemmer', + rate: '4,70 % per år', + conditions: [ + '1 gebyrfritt uttak per kalendermåned', + 'Uttak utover dette belastes med gebyr på 1,5 % av uttaksbeløpet', + 'Ingen krav om at pengene du sparer opp brukes på bolig', + 'Maksimalt 1 konto', + ], + }, + { + id: '2', + product: 'Sparekonto', + subtitle: 'For OBOS-medlemmer', + rate: '4,07 % per år', + conditions: [ + '24 gebyrfrie uttak per kalendermåned', + 'Uttak utover dette belastes med gebyr på 1,5 % av uttaksbeløpet', + 'Maksimalt 9 kontoer', + ], + }, + { + id: '3', + product: 'Høyrentekonto', + subtitle: 'For OBOS-medlemmer', + rate: '5,00 % per år', + conditions: [ + 'Ubegrenset antall gebyrfrie uttak', + 'Ingen minsteinnskudd', + 'Ingen gebyrer for kontoadministrasjon', + ], + }, + { + id: '4', + product: 'Ungdomskonto', + subtitle: 'For OBOS-medlemmer', + rate: '3,50 % per år', + conditions: [ + '3 gebyrfrie uttak per kalendermåned', + 'For kunder under 30 år', + 'Maksimalt 2 kontoer', + ], + }, + { + id: '5', + product: 'Pensjonskonto', + subtitle: 'For OBOS-medlemmer', + rate: '4,90 % per år', + conditions: [ + 'Ingen gebyrer for uttak', + 'Skattefordeler ved innskudd', + 'Maksimalt 1 konto', + ], + }, + { + id: '6', + product: 'Familiekonto', + subtitle: 'For OBOS-medlemmer', + rate: '4,25 % per år', + conditions: [ + '15 gebyrfrie uttak per kalendermåned', + 'Samarbeid mellom familiemedlemmer', + 'Ingen krav om at innskuddene brukes på bolig', + ], + }, +]; + +export const Default: Story = { + render: () => ( + + + Produkt + Gjeldende rente + Vilkår + + + {sampleData.map((item) => ( + + +
+
{item.product}
+
{item.subtitle}
+
+
+ +
{item.rate}
+
+ +
    + {item.conditions.map((condition) => ( +
  • + + {condition} +
  • + ))} +
+
+
+ ))} +
+
+ ), +}; + +export const Simple: Story = { + render: () => ( + + + Name + Email + Role + + + + John Doe + john@example.com + Administrator + + + Jane Smith + jane@example.com + User + + + Bob Johnson + bob@example.com + Moderator + + +
+ ), +}; + +export const WithScrolling: Story = { + render: () => ( +
+ + + Product Name + Description + Category + Price + Availability + Rating + Actions + + + + Advanced Widget Pro + + A comprehensive solution for all your widget needs + + Electronics + $299.99 + In Stock + 4.8/5 + View Details + + + Smart Device Ultra + + Next-generation smart device with AI capabilities + + Technology + $599.99 + Limited + 4.9/5 + Pre-order + + + Basic Tool Kit + Essential tools for everyday tasks + Tools + $49.99 + In Stock + 4.2/5 + Add to Cart + + +
+
+ ), +}; diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx new file mode 100644 index 000000000..8844d8700 --- /dev/null +++ b/packages/react/src/table/table.tsx @@ -0,0 +1,333 @@ +import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react'; +import { cva, cx } from 'cva'; +import { + type RefAttributes, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { + Cell as RACCell, + type CellProps as RACCellProps, + Column as RACColumn, + type ColumnProps as RACColumnProps, + Row as RACRow, + type RowProps as RACRowProps, + Table as RACTable, + TableBody as RACTableBody, + type TableBodyProps as RACTableBodyProps, + TableHeader as RACTableHeader, + type TableHeaderProps as RACTableHeaderProps, + type TableProps as RACTableProps, +} from 'react-aria-components'; +import { useDebouncedCallback } from 'use-debounce'; + +const tableVariants = cva({ + base: ['relative'], +}); + +interface BaseTableComponentProps { + className?: string; +} + +type TableProps = Omit & + RefAttributes & + BaseTableComponentProps; + +type TableHeaderProps = Omit, 'className'> & + RefAttributes & + BaseTableComponentProps; + +type TableColumnProps = Omit & + RefAttributes & + BaseTableComponentProps & { + children: React.ReactNode; + }; + +type TableBodyProps = Omit, 'className'> & + RefAttributes & + BaseTableComponentProps; + +type TableRowProps = Omit, 'className'> & + RefAttributes & + BaseTableComponentProps; + +type TableCellProps = Omit & + RefAttributes & + BaseTableComponentProps & { + children: React.ReactNode; + }; + +/** + * A container component for displaying tabular data with horizontal scrolling support. + * Follows WCAG 2.1 AA accessibility guidelines. + */ +function Table(props: TableProps) { + const { className, children, ...restProps } = props; + const scrollContainerRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const [scrollPosition, setScrollPosition] = useState('start'); + + const checkScrollOverflow = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + const newCanScrollLeft = scrollLeft > 0; + const newCanScrollRight = scrollLeft < scrollWidth - clientWidth - 1; + + setCanScrollLeft(newCanScrollLeft); + setCanScrollRight(newCanScrollRight); + + // Update scroll position for screen readers + if (scrollLeft === 0) { + setScrollPosition('start'); + } else if (scrollLeft >= scrollWidth - clientWidth - 1) { + setScrollPosition('end'); + } else { + setScrollPosition('middle'); + } + }, []); + + const handleScroll = useCallback((direction: 'left' | 'right') => { + const container = scrollContainerRef.current; + if (!container) return; + + const scrollAmount = container.clientWidth * 0.8; + container.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + }, []); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, direction: 'left' | 'right') => { + // Support Enter and Space for activation (WCAG 2.1.1) + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleScroll(direction); + } + }, + [handleScroll], + ); + + const scrollHandler = useDebouncedCallback(checkScrollOverflow, 100); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollOverflow(); + container.addEventListener('scroll', scrollHandler); + + const resizeObserver = new ResizeObserver(checkScrollOverflow); + resizeObserver.observe(container); + + return () => { + container.removeEventListener('scroll', scrollHandler); + resizeObserver.disconnect(); + }; + }, [checkScrollOverflow, scrollHandler]); + + return ( +
+
+ {/* Screen reader live region for scroll announcements */} +
+ {scrollPosition === 'start' && 'Table at start'} + {scrollPosition === 'middle' && + 'Table scrolled, more content available in both directions'} + {scrollPosition === 'end' && 'Table at end'} +
+ + handleScroll('left')} + onKeyDown={(e) => handleKeyDown(e, 'left')} + canScroll={canScrollLeft} + /> + + handleScroll('right')} + onKeyDown={(e) => handleKeyDown(e, 'right')} + canScroll={canScrollRight} + /> + +
+ + {children} + +
+
+
+ ); +} + +/** + * Navigation button component for table scrolling + * Supports keyboard interaction and proper WCAG compliance + */ +interface NavigationButtonProps { + direction: 'left' | 'right'; + onClick: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; + canScroll: boolean; +} + +function NavigationButton({ + direction, + onClick, + onKeyDown, + canScroll, +}: NavigationButtonProps) { + const Icon = direction === 'left' ? ChevronLeft : ChevronRight; + const position = direction === 'left' ? 'left-2' : 'right-2'; + const ariaLabel = `Scroll table ${direction}`; + + return ( + + ); +} + +/** + * Container for table column headers. + */ +function TableHeader({ className, children, ...restProps }: TableHeaderProps) { + return ( + + {children} + + ); +} +/** + * An individual column header in the table. + * Includes proper ARIA attributes for screen readers. + */ +function TableColumn(props: TableColumnProps) { + const { className, children, ...restProps } = props; + + return ( + + {children} + + ); +} + +/** + * Container for table rows. + */ +function TableBody({ className, children, ...restProps }: TableBodyProps) { + return ( + + {children} + + ); +} + +/** + * An individual row in the table. + * Enhanced with better focus management and hover states. + */ +function TableRow(props: TableRowProps) { + const { className, children, ...restProps } = props; + + return ( + + {children} + + ); +} + +/** + * An individual cell in the table. + * Optimized for readability and accessibility. + */ +function TableCell(props: TableCellProps) { + const { className, children, ...restProps } = props; + + return ( + + {children} + + ); +} + +export { + Table as UNSAFE_Table, + TableBody as UNSAFE_TableBody, + TableCell as UNSAFE_TableCell, + TableColumn as UNSAFE_TableColumn, + TableHeader as UNSAFE_TableHeader, + TableRow as UNSAFE_TableRow, + type TableBodyProps as UNSAFE_TableBodyProps, + type TableCellProps as UNSAFE_TableCellProps, + type TableColumnProps as UNSAFE_TableColumnProps, + type TableHeaderProps as UNSAFE_TableHeaderProps, + type TableProps as UNSAFE_TableProps, + type TableRowProps as UNSAFE_TableRowProps, +}; From 3f044935e93291af5ade7fb1be41d214669a0e02 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Mon, 29 Sep 2025 10:41:14 +0200 Subject: [PATCH 02/19] improve code --- packages/react/src/table/table.stories.tsx | 92 +++++++++++----------- packages/react/src/table/table.tsx | 86 ++++++++------------ 2 files changed, 81 insertions(+), 97 deletions(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index 8864001ea..244019f19 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -129,27 +129,27 @@ export const Default: Story = { export const Simple: Story = { render: () => ( - +
- Name - Email - Role + Navn + E-post + Område - John Doe - john@example.com - Administrator + Kari Hansen + kari.hansen@obos.no + Grünerløkka - Jane Smith - jane@example.com - User + Lars Olsen + lars.olsen@obos.no + Frogner - Bob Johnson - bob@example.com - Moderator + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen
@@ -159,47 +159,47 @@ export const Simple: Story = { export const WithScrolling: Story = { render: () => (
- +
- Product Name - Description - Category - Price - Availability - Rating - Actions + Adresse + Bydel + Type + Pris + Kvadratmeter + Soverom + Felleskost + Status - Advanced Widget Pro - - A comprehensive solution for all your widget needs - - Electronics - $299.99 - In Stock - 4.8/5 - View Details + Trondheimsveien 42A + Grünerløkka + 3-roms + 4 850 000 kr + 75 kvm + 2 + 3 500 kr/mnd + Ledig - Smart Device Ultra - - Next-generation smart device with AI capabilities - - Technology - $599.99 - Limited - 4.9/5 - Pre-order + Frognerveien 15B + Frogner + 4-roms + 7 200 000 kr + 95 kvm + 3 + 4 200 kr/mnd + Reservert - Basic Tool Kit - Essential tools for everyday tasks - Tools - $49.99 - In Stock - 4.2/5 - Add to Cart + Majorstuen gate 8C + Majorstuen + 2-roms + 3 900 000 kr + 55 kvm + 1 + 2 800 kr/mnd + Ledig
diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 8844d8700..d892193bb 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -27,41 +27,40 @@ const tableVariants = cva({ base: ['relative'], }); -interface BaseTableComponentProps { - className?: string; -} - type TableProps = Omit & - RefAttributes & - BaseTableComponentProps; + RefAttributes & { + className?: string; + }; type TableHeaderProps = Omit, 'className'> & - RefAttributes & - BaseTableComponentProps; + RefAttributes & { + className?: string; + }; type TableColumnProps = Omit & - RefAttributes & - BaseTableComponentProps & { + RefAttributes & { + className?: string; children: React.ReactNode; }; type TableBodyProps = Omit, 'className'> & - RefAttributes & - BaseTableComponentProps; + RefAttributes & { + className?: string; + }; type TableRowProps = Omit, 'className'> & - RefAttributes & - BaseTableComponentProps; + RefAttributes & { + className?: string; + }; type TableCellProps = Omit & - RefAttributes & - BaseTableComponentProps & { + RefAttributes & { + className?: string; children: React.ReactNode; }; /** * A container component for displaying tabular data with horizontal scrolling support. - * Follows WCAG 2.1 AA accessibility guidelines. */ function Table(props: TableProps) { const { className, children, ...restProps } = props; @@ -192,8 +191,12 @@ function NavigationButton({ canScroll, }: NavigationButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; - const position = direction === 'left' ? 'left-2' : 'right-2'; + const position = direction === 'left' ? 'left-0' : 'right-0'; const ariaLabel = `Scroll table ${direction}`; + const bg = + direction === 'left' + ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' + : 'bg-[linear-gradient(90deg,transparent,white_calc(10px),white)]'; return ( @@ -239,10 +236,6 @@ function TableHeader({ className, children, ...restProps }: TableHeaderProps) { ); } -/** - * An individual column header in the table. - * Includes proper ARIA attributes for screen readers. - */ function TableColumn(props: TableColumnProps) { const { className, children, ...restProps } = props; @@ -252,7 +245,7 @@ function TableColumn(props: TableColumnProps) { className={cx( className, 'px-4 py-3 text-left font-medium text-black text-sm', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-inset', + 'data-focus-visible:outline-focus-offset', 'min-w-fit whitespace-nowrap', )} > @@ -266,16 +259,12 @@ function TableColumn(props: TableColumnProps) { */ function TableBody({ className, children, ...restProps }: TableBodyProps) { return ( - + {children} ); } -/** - * An individual row in the table. - * Enhanced with better focus management and hover states. - */ function TableRow(props: TableRowProps) { const { className, children, ...restProps } = props; @@ -284,10 +273,9 @@ function TableRow(props: TableRowProps) { {...restProps} className={cx( className, - // Alternating row backgrounds with better contrast ratios - 'odd:bg-white even:bg-blue-lightest/30', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-inset', - 'hover:bg-blue-lightest/50 motion-safe:transition-colors motion-reduce:transition-none', + 'odd:bg-white even:bg-sky-lightest/50', + 'data-focus-visible:outline-focus-offset', + 'motion-safe:transition-colors motion-reduce:transition-none', )} > {children} @@ -295,10 +283,6 @@ function TableRow(props: TableRowProps) { ); } -/** - * An individual cell in the table. - * Optimized for readability and accessibility. - */ function TableCell(props: TableCellProps) { const { className, children, ...restProps } = props; @@ -307,9 +291,9 @@ function TableCell(props: TableCellProps) { {...restProps} className={cx( className, - 'px-4 py-3 text-black text-sm leading-relaxed', // Better line height for readability + 'px-4 py-3 text-black text-sm leading-relaxed', 'min-w-fit whitespace-nowrap', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-inset', + 'data-focus-visible:outline-focus-offset', )} > {children} From 409fa70271a38836b4c8782e3b334ce458df8532 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Mon, 29 Sep 2025 10:45:34 +0200 Subject: [PATCH 03/19] Translate screen reader messages --- packages/react/src/table/table.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index d892193bb..b59b5a862 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -133,15 +133,15 @@ function Table(props: TableProps) { return (
{/* Screen reader live region for scroll announcements */}
- {scrollPosition === 'start' && 'Table at start'} + {scrollPosition === 'start' && 'Tabell ved start'} {scrollPosition === 'middle' && - 'Table scrolled, more content available in both directions'} - {scrollPosition === 'end' && 'Table at end'} + 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} + {scrollPosition === 'end' && 'Tabell ved slutt'}
{children} @@ -192,7 +192,7 @@ function NavigationButton({ }: NavigationButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; const position = direction === 'left' ? 'left-0' : 'right-0'; - const ariaLabel = `Scroll table ${direction}`; + const ariaLabel = `Scroll tabell ${direction === 'left' ? 'til venstre' : 'til høyre'}`; const bg = direction === 'left' ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' From 1ed696302baf1b4300b055e97f4fb2ac0572acb9 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Mon, 29 Sep 2025 14:59:54 +0200 Subject: [PATCH 04/19] Simplify buttons to match tabs --- packages/react/src/table/table.tsx | 45 +++++++----------------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index b59b5a862..1a98bce33 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -101,17 +101,6 @@ function Table(props: TableProps) { }); }, []); - const handleKeyDown = useCallback( - (event: React.KeyboardEvent, direction: 'left' | 'right') => { - // Support Enter and Space for activation (WCAG 2.1.1) - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleScroll(direction); - } - }, - [handleScroll], - ); - const scrollHandler = useDebouncedCallback(checkScrollOverflow, 100); useEffect(() => { @@ -144,17 +133,15 @@ function Table(props: TableProps) { {scrollPosition === 'end' && 'Tabell ved slutt'}
- handleScroll('left')} - onKeyDown={(e) => handleKeyDown(e, 'left')} canScroll={canScrollLeft} /> - handleScroll('right')} - onKeyDown={(e) => handleKeyDown(e, 'right')} canScroll={canScrollRight} /> @@ -174,35 +161,27 @@ function Table(props: TableProps) { } /** - * Navigation button component for table scrolling - * Supports keyboard interaction and proper WCAG compliance + * Scroll button component for table horizontal scrolling + * Simple div-based button for mouse interaction only */ -interface NavigationButtonProps { +interface ScrollButtonProps { direction: 'left' | 'right'; onClick: () => void; - onKeyDown: (event: React.KeyboardEvent) => void; canScroll: boolean; } -function NavigationButton({ - direction, - onClick, - onKeyDown, - canScroll, -}: NavigationButtonProps) { +function ScrollButton({ direction, onClick, canScroll }: ScrollButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; const position = direction === 'left' ? 'left-0' : 'right-0'; - const ariaLabel = `Scroll tabell ${direction === 'left' ? 'til venstre' : 'til høyre'}`; const bg = direction === 'left' ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' : 'bg-[linear-gradient(90deg,transparent,white_calc(10px),white)]'; return ( - + +
); } From 2548366f32c96f6502343aa7f24297a3c700ddad Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 09:22:01 +0200 Subject: [PATCH 05/19] Add zebra variant --- packages/react/src/table/table.stories.tsx | 90 ++++++++++++++++++++++ packages/react/src/table/table.tsx | 37 ++++++--- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index 244019f19..26689bf91 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -206,3 +206,93 @@ export const WithScrolling: Story = { ), }; + +export const ZebraVariant: Story = { + render: () => ( + + + Navn + E-post + Område + Telefon + + + + Kari Hansen + kari.hansen@obos.no + Grünerløkka + +47 123 45 678 + + + Lars Olsen + lars.olsen@obos.no + Frogner + +47 234 56 789 + + + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen + +47 345 67 890 + + + Ola Nordmann + ola.nordmann@obos.no + Sagene + +47 456 78 901 + + + Anne Berger + anne.berger@obos.no + Bislett + +47 567 89 012 + + +
+ ), +}; + +export const DefaultVariant: Story = { + render: () => ( + + + Navn + E-post + Område + Telefon + + + + Kari Hansen + kari.hansen@obos.no + Grünerløkka + +47 123 45 678 + + + Lars Olsen + lars.olsen@obos.no + Frogner + +47 234 56 789 + + + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen + +47 345 67 890 + + + Ola Nordmann + ola.nordmann@obos.no + Sagene + +47 456 78 901 + + + Anne Berger + anne.berger@obos.no + Bislett + +47 567 89 012 + + +
+ ), +}; diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 1a98bce33..5f382542b 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -25,6 +25,25 @@ import { useDebouncedCallback } from 'use-debounce'; const tableVariants = cva({ base: ['relative'], + variants: { + variant: { + default: '', + zebra: '', + }, + }, +}); + +const tableRowVariants = cva({ + base: [ + 'data-focus-visible:outline-focus-offset', + 'motion-safe:transition-colors motion-reduce:transition-none', + ], + variants: { + variant: { + default: '', + zebra: 'odd:bg-white even:bg-sky-lightest', + }, + }, }); type TableProps = Omit & @@ -51,6 +70,11 @@ type TableBodyProps = Omit, 'className'> & type TableRowProps = Omit, 'className'> & RefAttributes & { className?: string; + /** + * Visual variant of the table row + * @default 'default' + */ + variant?: 'default' | 'zebra'; }; type TableCellProps = Omit & @@ -241,18 +265,10 @@ function TableBody({ className, children, ...restProps }: TableBodyProps) { } function TableRow(props: TableRowProps) { - const { className, children, ...restProps } = props; + const { className, children, variant = 'default', ...restProps } = props; return ( - + {children} ); @@ -268,6 +284,7 @@ function TableCell(props: TableCellProps) { className, 'px-4 py-3 text-black text-sm leading-relaxed', 'min-w-fit whitespace-nowrap', + 'align-top', 'data-focus-visible:outline-focus-offset', )} > From be77e026a9318a9a8109c77b97365329d4999deb Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 10:59:00 +0200 Subject: [PATCH 06/19] add zebra example --- packages/react/src/table/table.stories.tsx | 135 +++++++-------------- 1 file changed, 45 insertions(+), 90 deletions(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index 26689bf91..e34ebaba7 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -156,6 +156,51 @@ export const Simple: Story = { ), }; +export const ZebraVariant: Story = { + render: () => ( + + + Navn + E-post + Område + Telefon + + + + Kari Hansen + kari.hansen@obos.no + Grünerløkka + +47 123 45 678 + + + Lars Olsen + lars.olsen@obos.no + Frogner + +47 234 56 789 + + + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen + +47 345 67 890 + + + Ola Nordmann + ola.nordmann@obos.no + Sagene + +47 456 78 901 + + + Anne Berger + anne.berger@obos.no + Bislett + +47 567 89 012 + + +
+ ), +}; + export const WithScrolling: Story = { render: () => (
@@ -206,93 +251,3 @@ export const WithScrolling: Story = {
), }; - -export const ZebraVariant: Story = { - render: () => ( - - - Navn - E-post - Område - Telefon - - - - Kari Hansen - kari.hansen@obos.no - Grünerløkka - +47 123 45 678 - - - Lars Olsen - lars.olsen@obos.no - Frogner - +47 234 56 789 - - - Ingrid Svendsen - ingrid.svendsen@obos.no - Majorstuen - +47 345 67 890 - - - Ola Nordmann - ola.nordmann@obos.no - Sagene - +47 456 78 901 - - - Anne Berger - anne.berger@obos.no - Bislett - +47 567 89 012 - - -
- ), -}; - -export const DefaultVariant: Story = { - render: () => ( - - - Navn - E-post - Område - Telefon - - - - Kari Hansen - kari.hansen@obos.no - Grünerløkka - +47 123 45 678 - - - Lars Olsen - lars.olsen@obos.no - Frogner - +47 234 56 789 - - - Ingrid Svendsen - ingrid.svendsen@obos.no - Majorstuen - +47 345 67 890 - - - Ola Nordmann - ola.nordmann@obos.no - Sagene - +47 456 78 901 - - - Anne Berger - anne.berger@obos.no - Bislett - +47 567 89 012 - - -
- ), -}; From a2899d5281d3c4a5022dd770e067b68b715fc9e2 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 10:59:13 +0200 Subject: [PATCH 07/19] change animation to match tabs --- packages/react/src/table/table.tsx | 35 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 5f382542b..fd4c77031 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -125,7 +125,14 @@ function Table(props: TableProps) { }); }, []); - const scrollHandler = useDebouncedCallback(checkScrollOverflow, 100); + // To control if the animation for the scroll buttons and the scrolling behavior + // This is used to prevent animations from running when the component mounts + // We use a ref here to prevent redundant render cycles and potentially unintended scrolling. + const hasScrollingOccurredRef = useRef(false); + const scrollHandler = useDebouncedCallback(() => { + checkScrollOverflow(); + hasScrollingOccurredRef.current = true; + }, 100); useEffect(() => { const container = scrollContainerRef.current; @@ -148,7 +155,7 @@ function Table(props: TableProps) { className={tableVariants({ className })} aria-label="Datatabell med horisontal scrolling" > -
+
{/* Screen reader live region for scroll announcements */}
{scrollPosition === 'start' && 'Tabell ved start'} @@ -161,12 +168,14 @@ function Table(props: TableProps) { direction="left" onClick={() => handleScroll('left')} canScroll={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurredRef.current} /> handleScroll('right')} canScroll={canScrollRight} + hasScrollingOccurred={hasScrollingOccurredRef.current} />
void; canScroll: boolean; + hasScrollingOccurred: boolean; } -function ScrollButton({ direction, onClick, canScroll }: ScrollButtonProps) { +function ScrollButton({ + direction, + onClick, + canScroll, + hasScrollingOccurred, +}: ScrollButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; - const position = direction === 'left' ? 'left-0' : 'right-0'; + const position = direction === 'left' ? '-left-3' : '-right-3'; const bg = direction === 'left' ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' @@ -210,11 +225,17 @@ function ScrollButton({ direction, onClick, canScroll }: ScrollButtonProps) { '-translate-y-1/2 absolute top-5 z-10', position, 'flex h-11 w-11 items-center justify-center', - 'cursor-pointer text-black transition-all duration-150 ease-out', + 'cursor-pointer text-black', bg, 'hover:bg-white', - 'motion-safe:transition-all motion-reduce:transition-none', - canScroll ? 'visible opacity-100' : 'invisible opacity-0', + // Slide in and out based on scroll position, match duration with the debounce delay of the scrollHandler function + // Wait until user started scrolling until animation is applied, to prevent the animation from running on mount + hasScrollingOccurred && + 'duration-100 ease-in motion-safe:transition-transform', + // Use the same transform pattern as tabs for consistent animation - transforms always apply when not scrollable + direction === 'left' + ? !canScroll && '-translate-x-full pointer-events-none' + : !canScroll && 'pointer-events-none translate-x-full', )} > From f966793f0d9ad73a1b1720a533ce5790594b1fa0 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 12:29:45 +0200 Subject: [PATCH 08/19] wip abstract scroll logic --- packages/react/src/index.ts | 1 + packages/react/src/table/table.tsx | 165 +++++------------- packages/react/src/tabs/tabs.tsx | 132 +++----------- packages/react/src/utils/README.md | 111 ++++++++++++ .../react/src/utils/horizontal-scroll.tsx | 111 ++++++++++++ packages/react/src/utils/index.ts | 1 + 6 files changed, 295 insertions(+), 226 deletions(-) create mode 100644 packages/react/src/utils/README.md create mode 100644 packages/react/src/utils/horizontal-scroll.tsx create mode 100644 packages/react/src/utils/index.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 31f2092a1..7546568e6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -32,3 +32,4 @@ export * from './tabs'; export * from './table'; export * from './link'; export * from './link-list'; +export * from './utils'; diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index fd4c77031..704beb63b 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -1,12 +1,5 @@ -import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react'; import { cva, cx } from 'cva'; -import { - type RefAttributes, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { type RefAttributes, useCallback, useState } from 'react'; import { Cell as RACCell, type CellProps as RACCellProps, @@ -21,7 +14,7 @@ import { type TableHeaderProps as RACTableHeaderProps, type TableProps as RACTableProps, } from 'react-aria-components'; -import { useDebouncedCallback } from 'use-debounce'; +import { ScrollButton, useHorizontalScroll } from '../utils'; const tableVariants = cva({ base: ['relative'], @@ -88,68 +81,40 @@ type TableCellProps = Omit & */ function Table(props: TableProps) { const { className, children, ...restProps } = props; - const scrollContainerRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); const [scrollPosition, setScrollPosition] = useState('start'); - const checkScrollOverflow = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const { scrollLeft, scrollWidth, clientWidth } = container; - const newCanScrollLeft = scrollLeft > 0; - const newCanScrollRight = scrollLeft < scrollWidth - clientWidth - 1; - - setCanScrollLeft(newCanScrollLeft); - setCanScrollRight(newCanScrollRight); - - // Update scroll position for screen readers - if (scrollLeft === 0) { - setScrollPosition('start'); - } else if (scrollLeft >= scrollWidth - clientWidth - 1) { - setScrollPosition('end'); - } else { - setScrollPosition('middle'); - } - }, []); - - const handleScroll = useCallback((direction: 'left' | 'right') => { - const container = scrollContainerRef.current; - if (!container) return; - - const scrollAmount = container.clientWidth * 0.8; - container.scrollBy({ - left: direction === 'left' ? -scrollAmount : scrollAmount, - behavior: 'smooth', - }); - }, []); - - // To control if the animation for the scroll buttons and the scrolling behavior - // This is used to prevent animations from running when the component mounts - // We use a ref here to prevent redundant render cycles and potentially unintended scrolling. - const hasScrollingOccurredRef = useRef(false); - const scrollHandler = useDebouncedCallback(() => { - checkScrollOverflow(); - hasScrollingOccurredRef.current = true; - }, 100); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollOverflow(); - container.addEventListener('scroll', scrollHandler); - - const resizeObserver = new ResizeObserver(checkScrollOverflow); - resizeObserver.observe(container); - - return () => { - container.removeEventListener('scroll', scrollHandler); - resizeObserver.disconnect(); - }; - }, [checkScrollOverflow, scrollHandler]); - + const { + scrollContainerRef, + canScrollLeft, + canScrollRight, + hasScrollingOccurred, + } = useHorizontalScroll(); + + const handleScroll = useCallback( + (direction: 'left' | 'right') => { + const container = scrollContainerRef.current; + if (!container) return; + + const scrollAmount = container.clientWidth * 0.8; + container.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + + // Update scroll position for screen readers after scroll + setTimeout(() => { + const { scrollLeft, scrollWidth, clientWidth } = container; + if (scrollLeft === 0) { + setScrollPosition('start'); + } else if (scrollLeft >= scrollWidth - clientWidth - 1) { + setScrollPosition('end'); + } else { + setScrollPosition('middle'); + } + }, 150); + }, + [scrollContainerRef], + ); return (
handleScroll('left')} - canScroll={canScrollLeft} - hasScrollingOccurred={hasScrollingOccurredRef.current} + isVisible={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" /> handleScroll('right')} - canScroll={canScrollRight} - hasScrollingOccurred={hasScrollingOccurredRef.current} + isVisible={canScrollRight} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" />
void; - canScroll: boolean; - hasScrollingOccurred: boolean; -} - -function ScrollButton({ - direction, - onClick, - canScroll, - hasScrollingOccurred, -}: ScrollButtonProps) { - const Icon = direction === 'left' ? ChevronLeft : ChevronRight; - const position = direction === 'left' ? '-left-3' : '-right-3'; - const bg = - direction === 'left' - ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' - : 'bg-[linear-gradient(90deg,transparent,white_calc(10px),white)]'; - - return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: This button is only for mouse interaction to help users scroll. Keyboard and screen reader users can navigate the table content directly without needing these scroll helpers. -
- -
- ); -} - /** * Container for table column headers. */ diff --git a/packages/react/src/tabs/tabs.tsx b/packages/react/src/tabs/tabs.tsx index 2527eaf2e..f073e31e7 100644 --- a/packages/react/src/tabs/tabs.tsx +++ b/packages/react/src/tabs/tabs.tsx @@ -1,13 +1,5 @@ -import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react'; import { cva, cx } from 'cva'; -import { - type RefAttributes, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import { type RefAttributes, useContext, useEffect } from 'react'; import { Tab as RACTab, TabList as RACTabList, @@ -19,7 +11,7 @@ import { type TabsProps as RACTabsProps, TabListStateContext, } from 'react-aria-components'; -import { useDebouncedCallback } from 'use-debounce'; +import { ScrollButton, useHorizontalScroll } from '../utils'; const tabsVariants = cva({ base: ['grid gap-4'], @@ -107,21 +99,16 @@ type _TabListStateContextType = { * A container component for Tab components within Tabs. */ function TabList({ className, children, ...restProps }: TabListProps) { - const scrollContainerRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - - const checkScrollOverflow = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const { scrollLeft, scrollWidth, clientWidth } = container; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); - }, []); - const state = useContext(TabListStateContext) as _TabListStateContextType; + const { + scrollContainerRef, + canScrollLeft, + canScrollRight, + hasScrollingOccurred, + } = useHorizontalScroll(); + + // Tab-specific navigation logic const prevKey = state?.selectedKey && state?.collection.getKeyBefore(state.selectedKey); const onPrev = prevKey @@ -156,33 +143,6 @@ function TabList({ className, children, ...restProps }: TabListProps) { } }; - // To controll if the animation for the scroll buttons and the scrolling behavior - // This is used to prevent animations from running when the component mounts - // We use a ref here to prevent redundant render cycles and potentially uninteded scrolling. - const hasScrollingOccurredRef = useRef(false); - // Debounce the scroll handler to avoid performance issues with frequent scroll events - const scrollHandler = useDebouncedCallback(() => { - checkScrollOverflow(); - hasScrollingOccurredRef.current = true; - }, 100); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollOverflow(); - - container.addEventListener('scroll', scrollHandler); - - const resizeObserver = new ResizeObserver(checkScrollOverflow); - resizeObserver.observe(container); - - return () => { - container.removeEventListener('scroll', scrollHandler); - resizeObserver.disconnect(); - }; - }, [checkScrollOverflow, scrollHandler]); - // Scroll to the selected tab when the selected key changes // We use the state.selectedItem here instead of just the state.selectedKey, since state.selectedItem is set when the tab list is mounted // This way we can make sure the default selected tab is scrolled into view. @@ -207,7 +167,7 @@ function TabList({ className, children, ...restProps }: TabListProps) { offsetLeft - (containerWidth - selectedTab.clientWidth) / 2; // When the scroll is initiated by the user we want a smooth scroll - if (hasScrollingOccurredRef.current) { + if (hasScrollingOccurred) { // The RAC TabList component prevents us from using scroll snapping, so by using requestAnimationFrame, we can ensure the scroll position is set correctly. // We want the active tab to be centered in the view when navigating with the scroll buttons, selecting a tab with the keyboard, or clicking on a tab. requestAnimationFrame(() => { @@ -223,7 +183,7 @@ function TabList({ className, children, ...restProps }: TabListProps) { behavior: 'instant', }); } - }, [state?.selectedItem]); + }, [state?.selectedItem, hasScrollingOccurred, scrollContainerRef]); return (
@@ -259,57 +219,23 @@ function TabList({ className, children, ...restProps }: TabListProps) { > {children} - {/* Left scroll button */} - { - // biome-ignore lint/a11y/useKeyWithClickEvents: These are just for scrolling, and not necessary for keyboard or screen reader users. They can use the tablist's keyboard navigation pattern to navigate the entire list the same way. -
- -
- } - - {/* Right scroll button */} - { - // biome-ignore lint/a11y/useKeyWithClickEvents: These are just for scrolling, and not necessary for keyboard or screen reader users. They can use the tablist's keyboard navigation pattern to navigate the entire list the same way. -
- -
- } + + +
); } diff --git a/packages/react/src/utils/README.md b/packages/react/src/utils/README.md new file mode 100644 index 000000000..7542a9f1f --- /dev/null +++ b/packages/react/src/utils/README.md @@ -0,0 +1,111 @@ +# Horizontal Scroll Utilities + +A balanced approach with two simple utilities: `useHorizontalScroll` for scroll state detection and `ScrollButton` for the common visual patterns. + +## Hook: useHorizontalScroll + +A lightweight hook that tracks scroll capabilities without imposing any UI decisions. + +**What it provides:** +- `scrollContainerRef` - Ref to attach to your scrollable element +- `canScrollLeft` - Boolean indicating if there's content to scroll left +- `canScrollRight` - Boolean indicating if there's content to scroll right +- `hasScrollingOccurred` - Boolean for managing animations (prevents animation on mount) + +## Component: ScrollButton + +A simple scroll button component that captures the common visual patterns without over-engineering. + +**What it handles:** +- Chevron icons (left/right) +- Standard gradient backgrounds +- Show/hide animations +- Consistent hover states + +**Props:** +- `direction` - 'left' | 'right' +- `onClick` - Click handler function +- `isVisible` - Whether button should be visible +- `hasScrollingOccurred` - For animation timing +- `className` - Custom positioning and sizing +- `iconClassName` - Custom icon styling + +## Usage Examples + +### Basic Implementation +```tsx +import { useHorizontalScroll, ScrollButton } from '@obosbbl/grunnmuren-react'; + +function MyScrollableComponent() { + const { + scrollContainerRef, + canScrollLeft, + canScrollRight, + hasScrollingOccurred, + } = useHorizontalScroll(); + + const handleScroll = (direction: 'left' | 'right') => { + const container = scrollContainerRef.current; + if (!container) return; + + const scrollAmount = container.clientWidth * 0.8; + container.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + }; + + return ( +
+ handleScroll('left')} + isVisible={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurred} + className="absolute left-0 top-1/2 -translate-y-1/2 h-10 w-10" + iconClassName="h-5 w-5" + /> + +
+ {/* Your content */} +
+ + handleScroll('right')} + isVisible={canScrollRight} + hasScrollingOccurred={hasScrollingOccurred} + className="absolute right-0 top-1/2 -translate-y-1/2 h-10 w-10" + iconClassName="h-5 w-5" + /> +
+ ); +} +``` + +### Table-style Positioning +```tsx + handleScroll('left')} + isVisible={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" +/> +``` + +### Tab-style Positioning +```tsx + +``` diff --git a/packages/react/src/utils/horizontal-scroll.tsx b/packages/react/src/utils/horizontal-scroll.tsx new file mode 100644 index 000000000..b8729c03d --- /dev/null +++ b/packages/react/src/utils/horizontal-scroll.tsx @@ -0,0 +1,111 @@ +import { ChevronLeft, ChevronRight } from '@obosbbl/grunnmuren-icons-react'; +import { cx } from 'cva'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +/** + * Simple scroll button component that captures common patterns + * without being overly complex + */ +interface ScrollButtonProps { + direction: 'left' | 'right'; + onClick: () => void; + isVisible: boolean; + hasScrollingOccurred: boolean; + /** Additional classes for positioning and styling */ + className?: string; + /** Custom icon classes */ + iconClassName?: string; +} + +export function ScrollButton({ + direction, + onClick, + isVisible, + hasScrollingOccurred, + className, + iconClassName, +}: ScrollButtonProps) { + const Icon = direction === 'left' ? ChevronLeft : ChevronRight; + + // Default gradient backgrounds + const gradientBg = + direction === 'left' + ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' + : 'bg-[linear-gradient(90deg,transparent,white_calc(10px),white)]'; + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: This button is only for mouse interaction to help users scroll. Keyboard and screen reader users can navigate the content directly without needing these scroll helpers. +
+ +
+ ); +} + +/** + * Simple hook for detecting horizontal scroll capabilities + * Returns scroll state and a ref to attach to your scrollable container + */ +export function useHorizontalScroll() { + const scrollContainerRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const hasScrollingOccurredRef = useRef(false); + + const checkScrollOverflow = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); + }, []); + + const scrollHandler = useDebouncedCallback(() => { + checkScrollOverflow(); + hasScrollingOccurredRef.current = true; + }, 100); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollOverflow(); + container.addEventListener('scroll', scrollHandler); + + const resizeObserver = new ResizeObserver(checkScrollOverflow); + resizeObserver.observe(container); + + return () => { + container.removeEventListener('scroll', scrollHandler); + resizeObserver.disconnect(); + }; + }, [checkScrollOverflow, scrollHandler]); + + return { + scrollContainerRef, + canScrollLeft, + canScrollRight, + hasScrollingOccurred: hasScrollingOccurredRef.current, + }; +} diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts new file mode 100644 index 000000000..93b0e5ffa --- /dev/null +++ b/packages/react/src/utils/index.ts @@ -0,0 +1 @@ +export { useHorizontalScroll, ScrollButton } from './horizontal-scroll'; From 2108ef2894796d4b669140d9be2631130a2afa0e Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 14:54:13 +0200 Subject: [PATCH 09/19] Fix variants --- packages/react/src/table/table.stories.tsx | 15 +-- packages/react/src/table/table.tsx | 112 ++++++++++++--------- 2 files changed, 71 insertions(+), 56 deletions(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index e34ebaba7..586587c26 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -158,7 +158,10 @@ export const Simple: Story = { export const ZebraVariant: Story = { render: () => ( - +
Navn E-post @@ -166,31 +169,31 @@ export const ZebraVariant: Story = { Telefon - + Kari Hansen kari.hansen@obos.no Grünerløkka +47 123 45 678 - + Lars Olsen lars.olsen@obos.no Frogner +47 234 56 789 - + Ingrid Svendsen ingrid.svendsen@obos.no Majorstuen +47 345 67 890 - + Ola Nordmann ola.nordmann@obos.no Sagene +47 456 78 901 - + Anne Berger anne.berger@obos.no Bislett diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 704beb63b..e079d5456 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -1,5 +1,11 @@ import { cva, cx } from 'cva'; -import { type RefAttributes, useCallback, useState } from 'react'; +import { + type RefAttributes, + createContext, + useCallback, + useContext, + useState, +} from 'react'; import { Cell as RACCell, type CellProps as RACCellProps, @@ -39,9 +45,20 @@ const tableRowVariants = cva({ }, }); +// Context for sharing table variant with child components +const TableContext = createContext<{ variant: 'default' | 'zebra' }>({ + variant: 'default', +}); +const useTableContext = () => useContext(TableContext); + type TableProps = Omit & RefAttributes & { className?: string; + /** + * Visual variant of the table + * @default 'default' + */ + variant?: 'default' | 'zebra'; }; type TableHeaderProps = Omit, 'className'> & @@ -63,11 +80,6 @@ type TableBodyProps = Omit, 'className'> & type TableRowProps = Omit, 'className'> & RefAttributes & { className?: string; - /** - * Visual variant of the table row - * @default 'default' - */ - variant?: 'default' | 'zebra'; }; type TableCellProps = Omit & @@ -80,7 +92,7 @@ type TableCellProps = Omit & * A container component for displaying tabular data with horizontal scrolling support. */ function Table(props: TableProps) { - const { className, children, ...restProps } = props; + const { className, children, variant = 'default', ...restProps } = props; const [scrollPosition, setScrollPosition] = useState('start'); const { @@ -116,49 +128,48 @@ function Table(props: TableProps) { [scrollContainerRef], ); return ( -
-
- {/* Screen reader live region for scroll announcements */} -
- {scrollPosition === 'start' && 'Tabell ved start'} - {scrollPosition === 'middle' && - 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} - {scrollPosition === 'end' && 'Tabell ved slutt'} -
+ +
+
+ {/* Screen reader live region for scroll announcements */} +
+ {scrollPosition === 'start' && 'Tabell ved start'} + {scrollPosition === 'middle' && + 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} + {scrollPosition === 'end' && 'Tabell ved slutt'} +
- handleScroll('left')} - isVisible={canScrollLeft} - hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" - /> - - handleScroll('right')} - isVisible={canScrollRight} - hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" - /> - -
- - {children} - -
-
-
+ handleScroll('left')} + isVisible={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" + /> + + handleScroll('right')} + isVisible={canScrollRight} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" + /> + +
+ + {children} + +
+
+
+ ); } @@ -205,7 +216,8 @@ function TableBody({ className, children, ...restProps }: TableBodyProps) { } function TableRow(props: TableRowProps) { - const { className, children, variant = 'default', ...restProps } = props; + const { className, children, ...restProps } = props; + const { variant } = useTableContext(); return ( From a8a8d2b6205131e631bd02df4966a54f6d00d04e Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 15:11:33 +0200 Subject: [PATCH 10/19] remove gray text --- packages/react/src/table/table.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index 586587c26..895deb90e 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -104,7 +104,7 @@ export const Default: Story = {
{item.product}
-
{item.subtitle}
+
{item.subtitle}
From 87f5a0c700088c05e621137fc54a26ed403701e4 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 15:26:21 +0200 Subject: [PATCH 11/19] change context to data-attribute --- packages/react/src/table/table.tsx | 108 ++++++++++++----------------- 1 file changed, 46 insertions(+), 62 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index e079d5456..2710d3d3e 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -1,11 +1,5 @@ import { cva, cx } from 'cva'; -import { - type RefAttributes, - createContext, - useCallback, - useContext, - useState, -} from 'react'; +import { type RefAttributes, useCallback, useState } from 'react'; import { Cell as RACCell, type CellProps as RACCellProps, @@ -36,21 +30,11 @@ const tableRowVariants = cva({ base: [ 'data-focus-visible:outline-focus-offset', 'motion-safe:transition-colors motion-reduce:transition-none', + 'group-data-[variant=zebra]:odd:bg-white', + 'group-data-[variant=zebra]:even:bg-sky-lightest', ], - variants: { - variant: { - default: '', - zebra: 'odd:bg-white even:bg-sky-lightest', - }, - }, }); -// Context for sharing table variant with child components -const TableContext = createContext<{ variant: 'default' | 'zebra' }>({ - variant: 'default', -}); -const useTableContext = () => useContext(TableContext); - type TableProps = Omit & RefAttributes & { className?: string; @@ -128,48 +112,49 @@ function Table(props: TableProps) { [scrollContainerRef], ); return ( - -
-
- {/* Screen reader live region for scroll announcements */} -
- {scrollPosition === 'start' && 'Tabell ved start'} - {scrollPosition === 'middle' && - 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} - {scrollPosition === 'end' && 'Tabell ved slutt'} -
- - handleScroll('left')} - isVisible={canScrollLeft} - hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" - /> - - handleScroll('right')} - isVisible={canScrollRight} - hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" - /> - -
- - {children} - -
+
+
+ {/* Screen reader live region for scroll announcements */} +
+ {scrollPosition === 'start' && 'Tabell ved start'} + {scrollPosition === 'middle' && + 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} + {scrollPosition === 'end' && 'Tabell ved slutt'}
-
- + + handleScroll('left')} + isVisible={canScrollLeft} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" + /> + + handleScroll('right')} + isVisible={canScrollRight} + hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" + /> + +
+ + {children} + +
+
+
); } @@ -217,10 +202,9 @@ function TableBody({ className, children, ...restProps }: TableBodyProps) { function TableRow(props: TableRowProps) { const { className, children, ...restProps } = props; - const { variant } = useTableContext(); return ( - + {children} ); From 97bf69059652276dbb8ea8c29785d22bcdd9a777 Mon Sep 17 00:00:00 2001 From: Gabriel Wallin Date: Tue, 30 Sep 2025 15:28:38 +0200 Subject: [PATCH 12/19] Move data-variant to correct place --- packages/react/src/table/table.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 2710d3d3e..975a5b36d 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -112,10 +112,7 @@ function Table(props: TableProps) { [scrollContainerRef], ); return ( -
+
{/* Screen reader live region for scroll announcements */}
@@ -149,7 +146,11 @@ function Table(props: TableProps) { style={{ WebkitOverflowScrolling: 'touch' }} aria-label="Scrollbart tabellinnhold" > - + {children}
From aeb749751b262b5d460756098e1ad509f9f1166e Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:44:11 +0200 Subject: [PATCH 13/19] fix some css and types --- packages/react/src/table/table.tsx | 30 +++++-------------- .../react/src/utils/horizontal-scroll.tsx | 13 +++----- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 975a5b36d..4c79547e7 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -35,9 +35,8 @@ const tableRowVariants = cva({ ], }); -type TableProps = Omit & +type TableProps = RACTableProps & RefAttributes & { - className?: string; /** * Visual variant of the table * @default 'default' @@ -45,30 +44,21 @@ type TableProps = Omit & variant?: 'default' | 'zebra'; }; -type TableHeaderProps = Omit, 'className'> & - RefAttributes & { - className?: string; - }; +type TableHeaderProps = RACTableHeaderProps & + RefAttributes; -type TableColumnProps = Omit & +type TableColumnProps = RACColumnProps & RefAttributes & { - className?: string; children: React.ReactNode; }; -type TableBodyProps = Omit, 'className'> & - RefAttributes & { - className?: string; - }; +type TableBodyProps = RACTableBodyProps & + RefAttributes; -type TableRowProps = Omit, 'className'> & - RefAttributes & { - className?: string; - }; +type TableRowProps = RACRowProps & RefAttributes; -type TableCellProps = Omit & +type TableCellProps = RACCellProps & RefAttributes & { - className?: string; children: React.ReactNode; }; @@ -127,8 +117,6 @@ function Table(props: TableProps) { onClick={() => handleScroll('left')} isVisible={canScrollLeft} hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -left-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" /> handleScroll('right')} isVisible={canScrollRight} hasScrollingOccurred={hasScrollingOccurred} - className="-translate-y-1/2 -right-3 absolute top-5 z-10 h-11 w-11" - iconClassName="h-5 w-5" />
void; isVisible: boolean; hasScrollingOccurred: boolean; - /** Additional classes for positioning and styling */ - className?: string; - /** Custom icon classes */ - iconClassName?: string; } export function ScrollButton({ @@ -23,8 +19,6 @@ export function ScrollButton({ onClick, isVisible, hasScrollingOccurred, - className, - iconClassName, }: ScrollButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; @@ -53,11 +47,12 @@ export function ScrollButton({ ? !isVisible && '-translate-x-full pointer-events-none' : !isVisible && 'pointer-events-none translate-x-full', - // Custom positioning and styling - className, + direction === 'left' ? '-left-3' : '-right-3', + + '-translate-y-1/2 absolute top-5 z-10 h-11 w-11', )} > - + ); } From 4eb25756d04d395f511e6179583c60c7a26bca22 Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:24:06 +0200 Subject: [PATCH 14/19] add back className and iconClassName --- packages/react/src/table/table.tsx | 4 ++++ packages/react/src/tabs/tabs.tsx | 4 ++-- packages/react/src/utils/horizontal-scroll.tsx | 11 +++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index 4c79547e7..a714fd7d8 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -117,6 +117,8 @@ function Table(props: TableProps) { onClick={() => handleScroll('left')} isVisible={canScrollLeft} hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" /> handleScroll('right')} isVisible={canScrollRight} hasScrollingOccurred={hasScrollingOccurred} + className="-translate-y-1/2 absolute top-5 z-10 h-11 w-11" + iconClassName="h-5 w-5" />
@@ -233,7 +233,7 @@ function TabList({ className, children, ...restProps }: TabListProps) { onClick={onNext} isVisible={canScrollRight} hasScrollingOccurred={hasScrollingOccurred} - className="-right-3 absolute bottom-0.25 size-11" + className="absolute bottom-0.25 size-11" iconClassName="mt-0.25 h-6 w-full text-black" /> diff --git a/packages/react/src/utils/horizontal-scroll.tsx b/packages/react/src/utils/horizontal-scroll.tsx index 7471c64ee..8695210c4 100644 --- a/packages/react/src/utils/horizontal-scroll.tsx +++ b/packages/react/src/utils/horizontal-scroll.tsx @@ -12,6 +12,9 @@ interface ScrollButtonProps { onClick: () => void; isVisible: boolean; hasScrollingOccurred: boolean; + /** Additional classes for positioning and styling */ + className?: string; + iconClassName?: string; } export function ScrollButton({ @@ -19,6 +22,8 @@ export function ScrollButton({ onClick, isVisible, hasScrollingOccurred, + className, + iconClassName, }: ScrollButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; @@ -49,10 +54,12 @@ export function ScrollButton({ direction === 'left' ? '-left-3' : '-right-3', - '-translate-y-1/2 absolute top-5 z-10 h-11 w-11', + // Custom positioning and styling + + className, )} > - + ); } From ff2d7c1a92e113b182a941b5c7b7b775c5ffde65 Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:37:56 +0200 Subject: [PATCH 15/19] remove aria-information, not needed since its a table. remove utils from exporting to users --- packages/react/src/index.ts | 1 - packages/react/src/table/table.tsx | 32 ++++--------------- .../react/src/utils/horizontal-scroll.tsx | 4 ++- packages/react/src/utils/index.ts | 6 +++- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7546568e6..31f2092a1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -32,4 +32,3 @@ export * from './tabs'; export * from './table'; export * from './link'; export * from './link-list'; -export * from './utils'; diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index a714fd7d8..e9c5d0df1 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -1,5 +1,5 @@ import { cva, cx } from 'cva'; -import { type RefAttributes, useCallback, useState } from 'react'; +import { type RefAttributes, useCallback } from 'react'; import { Cell as RACCell, type CellProps as RACCellProps, @@ -14,7 +14,11 @@ import { type TableHeaderProps as RACTableHeaderProps, type TableProps as RACTableProps, } from 'react-aria-components'; -import { ScrollButton, useHorizontalScroll } from '../utils'; +import { + ScrollButton, + type ScrollDirection, + useHorizontalScroll, +} from '../utils'; const tableVariants = cva({ base: ['relative'], @@ -67,7 +71,6 @@ type TableCellProps = RACCellProps & */ function Table(props: TableProps) { const { className, children, variant = 'default', ...restProps } = props; - const [scrollPosition, setScrollPosition] = useState('start'); const { scrollContainerRef, @@ -77,7 +80,7 @@ function Table(props: TableProps) { } = useHorizontalScroll(); const handleScroll = useCallback( - (direction: 'left' | 'right') => { + (direction: ScrollDirection) => { const container = scrollContainerRef.current; if (!container) return; @@ -86,32 +89,12 @@ function Table(props: TableProps) { left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth', }); - - // Update scroll position for screen readers after scroll - setTimeout(() => { - const { scrollLeft, scrollWidth, clientWidth } = container; - if (scrollLeft === 0) { - setScrollPosition('start'); - } else if (scrollLeft >= scrollWidth - clientWidth - 1) { - setScrollPosition('end'); - } else { - setScrollPosition('middle'); - } - }, 150); }, [scrollContainerRef], ); return (
- {/* Screen reader live region for scroll announcements */} -
- {scrollPosition === 'start' && 'Tabell ved start'} - {scrollPosition === 'middle' && - 'Tabell scrollet, mer innhold tilgjengelig i begge retninger'} - {scrollPosition === 'end' && 'Tabell ved slutt'} -
- handleScroll('left')} @@ -134,7 +117,6 @@ function Table(props: TableProps) { ref={scrollContainerRef} className="scrollbar-hidden overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch' }} - aria-label="Scrollbart tabellinnhold" > void; isVisible: boolean; hasScrollingOccurred: boolean; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 93b0e5ffa..b3cca01e3 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -1 +1,5 @@ -export { useHorizontalScroll, ScrollButton } from './horizontal-scroll'; +export { + useHorizontalScroll, + ScrollButton, + type ScrollDirection, +} from './horizontal-scroll'; From 0c7a085781538878fe5db1bf30d70e0cc35ff062 Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:28:49 +0200 Subject: [PATCH 16/19] small rewrite of useHorizontalScroll --- .../react/src/utils/horizontal-scroll.tsx | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/packages/react/src/utils/horizontal-scroll.tsx b/packages/react/src/utils/horizontal-scroll.tsx index 8e1c0ec59..72fd08bc4 100644 --- a/packages/react/src/utils/horizontal-scroll.tsx +++ b/packages/react/src/utils/horizontal-scroll.tsx @@ -29,12 +29,6 @@ export function ScrollButton({ }: ScrollButtonProps) { const Icon = direction === 'left' ? ChevronLeft : ChevronRight; - // Default gradient backgrounds - const gradientBg = - direction === 'left' - ? 'bg-[linear-gradient(90deg,white,white_calc(100%-10px),transparent)]' - : 'bg-[linear-gradient(90deg,transparent,white_calc(10px),white)]'; - return ( // biome-ignore lint/a11y/useKeyWithClickEvents: This button is only for mouse interaction to help users scroll. Keyboard and screen reader users can navigate the content directly without needing these scroll helpers.
@@ -66,50 +59,70 @@ export function ScrollButton({ ); } +interface ScrollState { + canScrollLeft: boolean; + canScrollRight: boolean; + hasScrollingOccurred: boolean; +} + /** * Simple hook for detecting horizontal scroll capabilities * Returns scroll state and a ref to attach to your scrollable container */ export function useHorizontalScroll() { const scrollContainerRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const hasScrollingOccurredRef = useRef(false); + const [scrollState, setScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, + hasScrollingOccurred: false, + }); - const checkScrollOverflow = useCallback(() => { + const updateScrollState = useCallback(() => { const container = scrollContainerRef.current; - if (!container) return; + if (!container) { + return; + } const { scrollLeft, scrollWidth, clientWidth } = container; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); + const isAtStart = scrollLeft <= 0; + const isAtEnd = scrollLeft >= scrollWidth - clientWidth; + + setScrollState((prev) => ({ + canScrollLeft: !isAtStart, + canScrollRight: !isAtEnd, + hasScrollingOccurred: prev.hasScrollingOccurred || scrollLeft > 0, + })); }, []); - const scrollHandler = useDebouncedCallback(() => { - checkScrollOverflow(); - hasScrollingOccurredRef.current = true; - }, 100); + const debouncedUpdateScrollState = useDebouncedCallback( + updateScrollState, + 100, + ); useEffect(() => { const container = scrollContainerRef.current; - if (!container) return; + if (!container) { + return; + } + + // Initial check + updateScrollState(); - checkScrollOverflow(); - container.addEventListener('scroll', scrollHandler); + // Listen for scroll events + container.addEventListener('scroll', debouncedUpdateScrollState); - const resizeObserver = new ResizeObserver(checkScrollOverflow); + // Listen for resize events (content or container size changes) + const resizeObserver = new ResizeObserver(updateScrollState); resizeObserver.observe(container); return () => { - container.removeEventListener('scroll', scrollHandler); + container.removeEventListener('scroll', debouncedUpdateScrollState); resizeObserver.disconnect(); }; - }, [checkScrollOverflow, scrollHandler]); + }, [updateScrollState, debouncedUpdateScrollState]); return { scrollContainerRef, - canScrollLeft, - canScrollRight, - hasScrollingOccurred: hasScrollingOccurredRef.current, + ...scrollState, }; } From ab7feb05a821fa663d402ee0fc786f5da90f363b Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:37:45 +0200 Subject: [PATCH 17/19] add changelog --- .changeset/warm-mammals-wash.md | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .changeset/warm-mammals-wash.md diff --git a/.changeset/warm-mammals-wash.md b/.changeset/warm-mammals-wash.md new file mode 100644 index 000000000..fe7d1a51d --- /dev/null +++ b/.changeset/warm-mammals-wash.md @@ -0,0 +1,49 @@ +--- +"@obosbbl/grunnmuren-react": minor +--- + +New component `Table` component is in beta. + +```ts +import { + UNSAFE_Table as Table, + UNSAFE_TableBody as TableBody, + UNSAFE_TableCell as TableCell, + UNSAFE_TableColumn as TableColumn, + UNSAFE_TableHeader as TableHeader, + UNSAFE_TableRow as TableRow, +} from '@obosbbl/grunnmuren-react'; + +
+ + Produkt + Gjeldende rente + Vilkår + + + + +
+
Sparekonto Egenkapital
+
For OBOS-medlemmer
+
+
+ +
4,70 % per år
+
+ +
    +
  • + + 1 gebyrfritt uttak per kalendermåned +
  • +
  • + + Uttak utover dette belastes med gebyr på 1,5 % av uttaksbeløpet +
  • +
+
+
+
+
+``` \ No newline at end of file From 5128893b41d6d066061f227603ec78b26227ffd5 Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:15:56 +0200 Subject: [PATCH 18/19] qa fixes. zebra -> zebra-striped. aria-label or aria-labelledby is required --- packages/react/src/table/table.stories.tsx | 291 ++++++++++----------- packages/react/src/table/table.tsx | 30 ++- 2 files changed, 157 insertions(+), 164 deletions(-) diff --git a/packages/react/src/table/table.stories.tsx b/packages/react/src/table/table.stories.tsx index 895deb90e..e3348c460 100644 --- a/packages/react/src/table/table.stories.tsx +++ b/packages/react/src/table/table.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Meta } from '@storybook/react-vite'; import { UNSAFE_Table as Table, UNSAFE_TableBody as TableBody, @@ -18,7 +18,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; const sampleData = [ { @@ -90,167 +89,159 @@ const sampleData = [ }, ]; -export const Default: Story = { - render: () => ( - - - Produkt - Gjeldende rente - Vilkår - - - {sampleData.map((item) => ( - - -
-
{item.product}
-
{item.subtitle}
-
-
- -
{item.rate}
-
- -
    - {item.conditions.map((condition) => ( -
  • - - {condition} -
  • - ))} -
-
-
- ))} -
-
- ), -}; - -export const Simple: Story = { - render: () => ( - - - Navn - E-post - Område - - - - Kari Hansen - kari.hansen@obos.no - Grünerløkka - - - Lars Olsen - lars.olsen@obos.no - Frogner +export const Default = () => ( +
+ + Produkt + Gjeldende rente + Vilkår + + + {sampleData.map((item) => ( + + +
+
{item.product}
+
{item.subtitle}
+
+
+ +
{item.rate}
+
+ +
    + {item.conditions.map((condition) => ( +
  • + + {condition} +
  • + ))} +
+
- - Ingrid Svendsen - ingrid.svendsen@obos.no - Majorstuen - -
-
- ), -}; + ))} + + +); + +export const Simple = () => ( + + + Navn + E-post + Område + + + + Kari Hansen + kari.hansen@obos.no + Grünerløkka + + + Lars Olsen + lars.olsen@obos.no + Frogner + + + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen + + +
+); + +export const ZebraVariant = () => ( + + + Navn + E-post + Område + Telefon + + + + Kari Hansen + kari.hansen@obos.no + Grünerløkka + +47 123 45 678 + + + Lars Olsen + lars.olsen@obos.no + Frogner + +47 234 56 789 + + + Ingrid Svendsen + ingrid.svendsen@obos.no + Majorstuen + +47 345 67 890 + + + Ola Nordmann + ola.nordmann@obos.no + Sagene + +47 456 78 901 + + + Anne Berger + anne.berger@obos.no + Bislett + +47 567 89 012 + + +
+); -export const ZebraVariant: Story = { - render: () => ( - +export const WithScrolling = () => ( +
+
- Navn - E-post - Område - Telefon + Adresse + Bydel + Type + Pris + Kvadratmeter + Soverom + Felleskost + Status - Kari Hansen - kari.hansen@obos.no + Trondheimsveien 42A Grünerløkka - +47 123 45 678 + 3-roms + 4 850 000 kr + 75 kvm + 2 + 3 500 kr/mnd + Ledig - Lars Olsen - lars.olsen@obos.no + Frognerveien 15B Frogner - +47 234 56 789 + 4-roms + 7 200 000 kr + 95 kvm + 3 + 4 200 kr/mnd + Reservert - Ingrid Svendsen - ingrid.svendsen@obos.no + Majorstuen gate 8C Majorstuen - +47 345 67 890 - - - Ola Nordmann - ola.nordmann@obos.no - Sagene - +47 456 78 901 - - - Anne Berger - anne.berger@obos.no - Bislett - +47 567 89 012 + 2-roms + 3 900 000 kr + 55 kvm + 1 + 2 800 kr/mnd + Ledig
- ), -}; - -export const WithScrolling: Story = { - render: () => ( -
- - - Adresse - Bydel - Type - Pris - Kvadratmeter - Soverom - Felleskost - Status - - - - Trondheimsveien 42A - Grünerløkka - 3-roms - 4 850 000 kr - 75 kvm - 2 - 3 500 kr/mnd - Ledig - - - Frognerveien 15B - Frogner - 4-roms - 7 200 000 kr - 95 kvm - 3 - 4 200 kr/mnd - Reservert - - - Majorstuen gate 8C - Majorstuen - 2-roms - 3 900 000 kr - 55 kvm - 1 - 2 800 kr/mnd - Ledig - - -
-
- ), -}; +
+); diff --git a/packages/react/src/table/table.tsx b/packages/react/src/table/table.tsx index e9c5d0df1..d8a7d5f4b 100644 --- a/packages/react/src/table/table.tsx +++ b/packages/react/src/table/table.tsx @@ -25,28 +25,30 @@ const tableVariants = cva({ variants: { variant: { default: '', - zebra: '', + 'zebra-striped': '', }, }, }); const tableRowVariants = cva({ base: [ - 'data-focus-visible:outline-focus-offset', - 'motion-safe:transition-colors motion-reduce:transition-none', - 'group-data-[variant=zebra]:odd:bg-white', - 'group-data-[variant=zebra]:even:bg-sky-lightest', + 'data-focus-visible:outline-focus-inset', + 'group-data-[variant=zebra-striped]:odd:bg-white', + 'group-data-[variant=zebra-striped]:even:bg-sky-lightest', ], }); -type TableProps = RACTableProps & +type TableProps = Omit & RefAttributes & { /** * Visual variant of the table * @default 'default' */ - variant?: 'default' | 'zebra'; - }; + variant?: 'default' | 'zebra-striped'; + } & ( + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-label'?: never; 'aria-labelledby': string } + ); type TableHeaderProps = RACTableHeaderProps & RefAttributes; @@ -93,7 +95,7 @@ function Table(props: TableProps) { [scrollContainerRef], ); return ( -
+
-
{children} -
+
-
+ ); } @@ -153,7 +155,7 @@ function TableColumn(props: TableColumnProps) { className={cx( className, 'px-4 py-3 text-left font-medium text-black text-sm', - 'data-focus-visible:outline-focus-offset', + 'data-focus-visible:outline-focus-inset', 'min-w-fit whitespace-nowrap', )} > @@ -194,7 +196,7 @@ function TableCell(props: TableCellProps) { 'px-4 py-3 text-black text-sm leading-relaxed', 'min-w-fit whitespace-nowrap', 'align-top', - 'data-focus-visible:outline-focus-offset', + 'data-focus-visible:outline-focus-inset', )} > {children} From 9ae121205fbcd035b1e0521c5ec69b5928836f98 Mon Sep 17 00:00:00 2001 From: Aulon Mujaj <4094284+aulonm@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:38:37 +0200 Subject: [PATCH 19/19] change from minor to patch --- .changeset/warm-mammals-wash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/warm-mammals-wash.md b/.changeset/warm-mammals-wash.md index fe7d1a51d..f3f7d5025 100644 --- a/.changeset/warm-mammals-wash.md +++ b/.changeset/warm-mammals-wash.md @@ -1,5 +1,5 @@ --- -"@obosbbl/grunnmuren-react": minor +"@obosbbl/grunnmuren-react": patch --- New component `Table` component is in beta.