From 40916eed2cc463b9e277c54c12bf2047b1f1b0db Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:51:49 +0200 Subject: [PATCH 01/77] feat(dropdown): TEDI-Ready Dropdown component #94 --- .../dropdown-content.spec.tsx | 38 +++ .../dropdown-content/dropdown-content.tsx | 14 + .../overlays/dropdown/dropdown-context.tsx | 27 ++ .../dropdown-item/dropdown-item.module.scss | 37 +++ .../dropdown-item/dropdown-item.spec.tsx | 78 +++++ .../dropdown/dropdown-item/dropdown-item.tsx | 114 +++++++ .../dropdown-trigger.spec.tsx | 44 +++ .../dropdown-trigger/dropdown-trigger.tsx | 21 ++ .../overlays/dropdown/dropdown.module.scss | 20 ++ .../overlays/dropdown/dropdown.spec.tsx | 103 +++++++ .../overlays/dropdown/dropdown.stories.tsx | 277 ++++++++++++++++++ .../components/overlays/dropdown/dropdown.tsx | 126 ++++++++ .../components/overlays/dropdown/index.ts | 5 + src/tedi/index.ts | 1 + 14 files changed, 905 insertions(+) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-context.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.module.scss create mode 100644 src/tedi/components/overlays/dropdown/dropdown.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.stories.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown.tsx create mode 100644 src/tedi/components/overlays/dropdown/index.ts diff --git a/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx new file mode 100644 index 000000000..7cdaf40dd --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.spec.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react'; + +import { DropdownContent } from './dropdown-content'; + +const mockSetContent = jest.fn(); + +jest.mock('../dropdown-context', () => ({ + useDropdownContext: () => ({ + setContent: mockSetContent, + }), +})); + +describe('DropdownContent', () => { + beforeEach(() => { + mockSetContent.mockClear(); + }); + + it('sets content on mount', () => { + render( + +
Menu content
+
+ ); + + expect(mockSetContent).toHaveBeenCalledWith(expect.objectContaining({ props: { children: 'Menu content' } })); + }); + + it('clears content on unmount', () => { + const { unmount } = render( + +
Menu content
+
+ ); + + unmount(); + expect(mockSetContent).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx new file mode 100644 index 000000000..763a1d94e --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-content/dropdown-content.tsx @@ -0,0 +1,14 @@ +import { ReactNode, useEffect } from 'react'; + +import { useDropdownContext } from '../dropdown-context'; + +export const DropdownContent = ({ children }: { children: ReactNode }) => { + const { setContent } = useDropdownContext(); + + useEffect(() => { + setContent(children); + return () => setContent(null); + }, [children, setContent]); + + return null; +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx new file mode 100644 index 000000000..ffc3ea12a --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -0,0 +1,27 @@ +import { useFloating, useInteractions } from '@floating-ui/react'; +import React, { useContext } from 'react'; + +export type DropdownContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + refs: ReturnType['refs']; + getReferenceProps: ReturnType['getReferenceProps']; + getFloatingProps: ReturnType['getFloatingProps']; + getItemProps: ReturnType['getItemProps']; + listItemsRef: React.MutableRefObject>; + activeIndex: number | null; + setActiveIndex: (index: number | null) => void; + placement?: string; + content: React.ReactNode; + setContent: (content: React.ReactNode) => void; +}; + +export const DropdownContext = React.createContext(null); + +export const useDropdownContext = () => { + const ctx = useContext(DropdownContext); + if (!ctx) { + throw new Error('Dropdown components must be used within '); + } + return ctx; +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss new file mode 100644 index 000000000..75234f337 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -0,0 +1,37 @@ +@use '@tedi-design-system/core/mixins'; + +.tedi-dropdown__item { + @include mixins.button-reset; + + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + padding-left: var(--dropdown-indent, var(--dropdown-item-padding-x)); + color: var(--dropdown-item-default-text); + text-align: left; + border-radius: 0; + + &--active { + color: var(--dropdown-item-active-text); + background-color: var(--dropdown-item-active-background); + } + + &--disabled { + color: var(--color-text-disabled); + pointer-events: none; + background-color: var(--color-bg-disabled); + } + + &:hover { + color: var(--dropdown-item-hover-text); + cursor: pointer; + background-color: var(--dropdown-item-hover-background); + outline: 0; + } + + &:focus-visible { + &:not(.tedi-dropdown__item--disabled) { + cursor: pointer; + outline: 0; + box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 0 0 0 3px var(--general-surface-selected); + } + } +} diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx new file mode 100644 index 000000000..ae2ecec5c --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render } from '@testing-library/react'; + +import { DropdownItem } from './dropdown-item'; + +const mockSetOpen = jest.fn(); +const mockOnClick = jest.fn(); + +jest.mock('../dropdown-context', () => ({ + useDropdownContext: () => ({ + getItemProps: (props: never) => props, + listItemsRef: { current: [] }, + setOpen: mockSetOpen, + activeIndex: 0, + }), +})); + +describe('DropdownItem', () => { + beforeEach(() => { + mockSetOpen.mockClear(); + mockOnClick.mockClear(); + }); + + it('calls onClick and closes dropdown on click', () => { + const { getByText } = render( + + Item + + ); + + fireEvent.click(getByText('Item')); + expect(mockOnClick).toHaveBeenCalled(); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + + it('does not close dropdown when closeOnSelect=false', () => { + const { getByText } = render( + + Item + + ); + + fireEvent.click(getByText('Item')); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + it('does not call onClick when disabled', () => { + const { getByText } = render( + + Item + + ); + + fireEvent.click(getByText('Item')); + expect(mockOnClick).not.toHaveBeenCalled(); + }); + + it('handles Enter key activation', () => { + const { getByText } = render( + + Item + + ); + + fireEvent.keyDown(getByText('Item'), { key: 'Enter' }); + expect(mockOnClick).toHaveBeenCalled(); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + + it('renders div when asChild=true', () => { + const { getByText } = render( + + Child + + ); + + expect(getByText('Child').tagName).toBe('SPAN'); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx new file mode 100644 index 000000000..917217238 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -0,0 +1,114 @@ +import cn from 'classnames'; + +import { useDropdownContext } from '../dropdown-context'; +import styles from './dropdown-item.module.scss'; + +export type DropdownItemProps = { + /** + * The content of the menu item (text, icons, checkbox, etc.) + */ + children: React.ReactNode; + /** + * Called when the item is activated (mouse click or Enter/Space key). + * Receives either a MouseEvent or KeyboardEvent. + */ + onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; + /** + * Disables the item — prevents interaction and applies disabled styling. + * + * @default false + */ + disabled?: boolean; + /** + * Highlights the item visually (e.g. selected language, current sort option). + * Does **not** affect behavior — only styling. + * + * @default false + */ + active?: boolean; + /** + * Required when using keyboard navigation (ArrowUp/ArrowDown). + * Must be a unique, sequential number (0, 1, 2, ...) for each item in the list. + * + * When omitted, the item won't be keyboard-focusable. + */ + index?: number; + /** + * Indentation level (in rem units). Useful for nested / hierarchical menus. + * + * Example: `indent={1}` → adds ~1rem left padding + * + * @default 0 + */ + indent?: number; + /** + * When `true`, renders a plain `
` instead of a ` + + ); + + fireEvent.click(getByText('Open')); + expect(mockGetReferenceProps).toHaveBeenCalled(); + }); + + it('passes ref to setReference', () => { + render( + + + + ); + + const refCall = mockGetReferenceProps.mock.calls[0][0]; + expect(refCall.ref).toBe(mockSetReference); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx new file mode 100644 index 000000000..d98e32ca1 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx @@ -0,0 +1,21 @@ +import { cloneElement, ReactElement } from 'react'; + +import { useDropdownContext } from '../dropdown-context'; + +export type DropdownTriggerProps = { + /** + * The content of the trigger item (button, icon, etc) + */ + children: ReactElement; +}; + +export const DropdownTrigger = ({ children }: DropdownTriggerProps) => { + const { refs, getReferenceProps } = useDropdownContext(); + + return cloneElement( + children, + getReferenceProps({ + ref: refs.setReference, + }) + ); +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss new file mode 100644 index 000000000..45b3385a1 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -0,0 +1,20 @@ +@use '@tedi-design-system/core/mixins'; + +.tedi-dropdown { + z-index: var(--z-index-dropdown); + display: flex; + flex-direction: column; + min-width: 10rem; + pointer-events: none; + background-color: var(--dropdown-item-default-background); + border: 1px solid var(--card-border-primary); + border-radius: var(--form-select-area-radius); + transition: opacity 120ms ease, transform 120ms ease; + transform: translateY(-4px) scale(0.98); + + &[data-state='open'] { + pointer-events: auto; + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx new file mode 100644 index 000000000..1a65d9098 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; + +import { Dropdown, DropdownProps } from './dropdown'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => key, + }), +})); + +const renderDropdown = ( + triggerProps: ComponentProps, + content: React.ReactNode, + dropdownProps?: Omit +) => { + return render( + + + {content} + + ); +}; + +describe('Dropdown component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders Trigger children correctly', () => { + renderDropdown({ children: Open menu }, Item); + const trigger = screen.getByText('Open menu'); + expect(trigger.tagName).toBe('SPAN'); + }); + + it('does not render content by default', () => { + renderDropdown({ children: Open menu }, Item); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('opens dropdown on trigger click', () => { + renderDropdown({ children: Open menu }, Item); + const trigger = screen.getByText('Open menu'); + fireEvent.click(trigger); + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('closes dropdown when item is clicked', () => { + renderDropdown({ children: Open menu }, Item); + fireEvent.click(screen.getByText('Open menu')); + fireEvent.click(screen.getByText('Item')); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('closes dropdown on Tab key press', () => { + renderDropdown({ children: Open menu }, Item); + fireEvent.click(screen.getByText('Open menu')); + const dropdown = screen.getByRole('menu'); + fireEvent.keyDown(dropdown, { key: 'Tab' }); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('renders multiple items', () => { + renderDropdown( + { children: Open menu }, + <> + Item 1 + Item 2 + + ); + + fireEvent.click(screen.getByText('Open menu')); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + }); + + it('marks active item with active class', () => { + renderDropdown( + { children: Open menu }, + <> + Item 1 + + Item 2 + + + ); + + fireEvent.click(screen.getByText('Open menu')); + const activeItem = screen.getByText('Item 2'); + expect(activeItem).toHaveClass('tedi-dropdown__item--active'); + }); + + it('traps focus inside dropdown when modal=true', () => { + renderDropdown({ children: Open menu }, Item, { + modal: true, + }); + + fireEvent.click(screen.getByText('Open menu')); + const item = screen.getByText('Item'); + item.focus(); + expect(document.activeElement).toBe(item); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx new file mode 100644 index 000000000..d75e8a88e --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -0,0 +1,277 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Icon } from '../../base/icon/icon'; +import { Button } from '../../buttons/button/button'; +import Checkbox from '../../form/checkbox/checkbox'; +import Radio from '../../form/radio/radio'; +import { Dropdown } from './dropdown'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: 'TEDI-Ready/Components/Overlay/Dropdown', + component: Dropdown, + subcomponents: { + 'Dropdown.Trigger': Dropdown.Trigger, + 'Dropdown.Content': Dropdown.Content, + 'Dropdown.Item': Dropdown.Item, + } as never, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + console.log('Lisa pöördumine')}> + Access to health data + + console.log('Lisa toetus')}> + Declaration of intent + + Contacts + + + ), +}; + +export const WithActiveItem: Story = { + render: () => { + const [lang, setLang] = React.useState('ENG'); + + return ( + + + + + + + {['EST', 'ENG', 'RUS'].map((l, i) => ( + setLang(l)}> + {l} + + ))} + + + ); + }, +}; + +export const WithCheckbox: Story = { + render: () => { + const [cities, setCities] = React.useState([]); + + const toggle = (value: string, checked?: boolean) => { + setCities((prev) => (checked ? [...prev, value] : prev.filter((v) => v !== value))); + }; + + return ( + + + + + + + + + + + + + + + + + + + + ); + }, +}; + +type City = 'tallinn' | 'tartu' | 'parnu'; +const allCities: City[] = ['tallinn', 'tartu', 'parnu']; + +export const WithIndentedItems: Story = { + render: () => { + const [selected, setSelected] = React.useState([]); + + const allChecked = selected.length === allCities.length; + const noneChecked = selected.length === 0; + const indeterminate = !allChecked && !noneChecked; + + const toggleAll = (_: string, checked?: boolean) => { + setSelected(checked ? allCities : []); + }; + + const toggleOne = (value: string, checked?: boolean) => { + setSelected((prev) => (checked ? [...prev, value as City] : prev.filter((v) => v !== value))); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); + }, +}; + +export const WithRadio: Story = { + render: () => { + const [city, setCity] = React.useState('tallinn'); + + return ( + + + + + + + + setCity(value)} + /> + + + + setCity(value)} + /> + + + + setCity(value)} + /> + + + + ); + }, +}; + +export const WithIcon: Story = { + render: () => ( + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx new file mode 100644 index 000000000..d7dc58234 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -0,0 +1,126 @@ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React from 'react'; + +import { useLabels } from '../../../providers/label-provider'; +import styles from './dropdown.module.scss'; +import { DropdownContent } from './dropdown-content/dropdown-content'; +import { DropdownContext, DropdownContextValue } from './dropdown-context'; +import { DropdownItem } from './dropdown-item/dropdown-item'; +import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; + +export type DropdownProps = { + /** + * Child elements — must include exactly one `Dropdown.Trigger` and one `Dropdown.Content` + */ + children: React.ReactNode; + /** + * When `true`, the dropdown behaves like a modal: + * - Traps focus inside the dropdown + * - Shows a visually hidden "Close" button for screen readers + * - Usually used for menus that require explicit dismissal + * + * @default false + */ + modal?: boolean; +}; + +export const Dropdown = ({ children, modal = false }: DropdownProps) => { + const { getLabel } = useLabels(); + const nodeId = useFloatingNodeId(); + + const listItemsRef = React.useRef>([]); + const [open, setOpen] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(null); + const [content, setContent] = React.useState(null); + + const floating = useFloating({ + placement: 'bottom-start', + nodeId, + open, + onOpenChange: setOpen, + middleware: [flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const { context, refs, x, y, strategy, placement } = floating; + + const interactions = useInteractions([ + useClick(context), + useRole(context, { role: 'menu' }), + useDismiss(context), + useListNavigation(context, { + listRef: listItemsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }), + ]); + + const value: DropdownContextValue = { + open, + setOpen, + refs, + listItemsRef, + activeIndex, + setActiveIndex, + placement, + content, + setContent, + ...interactions, + }; + + return ( + + {children} + + + {open && ( + +
+ {content} +
+
+ )} +
+
+ ); +}; + +Dropdown.Trigger = DropdownTrigger; +Dropdown.Content = DropdownContent; +Dropdown.Item = DropdownItem; diff --git a/src/tedi/components/overlays/dropdown/index.ts b/src/tedi/components/overlays/dropdown/index.ts new file mode 100644 index 000000000..8565a31b5 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/index.ts @@ -0,0 +1,5 @@ +export * from './dropdown'; +export * from './dropdown-context'; +export * from './dropdown-content/dropdown-content'; +export * from './dropdown-trigger/dropdown-trigger'; +export * from './dropdown-item/dropdown-item'; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 2f778ed5e..319dbf905 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -37,6 +37,7 @@ export * from './components/form/select/select'; export * from './components/form/checkbox/checkbox'; export * from './components/overlays/tooltip'; export * from './components/overlays/popover'; +export * from './components/overlays/dropdown'; export * from './components/misc/separator/separator'; export * from './components/misc/print/print'; export * from './components/misc/stretch-content/stretch-content'; From 793bc10baa3f6379c62f8b4779d217bf300ff17c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:55:08 +0200 Subject: [PATCH 02/77] chore: fix stories #94 --- .../overlays/dropdown/dropdown.stories.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index d75e8a88e..88225608f 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; import Checkbox from '../../form/checkbox/checkbox'; import Radio from '../../form/radio/radio'; @@ -257,19 +258,19 @@ export const WithIcon: Story = { - - Download - + + Download + - - Add - + + Add + - - Delete - + + Delete + From a6e6ef2cc7cf11f18226254ce46dd20950179b72 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:52:33 +0200 Subject: [PATCH 03/77] feat(dropdown): add dropdown separator and divided prop + examples #94 --- .../overlays/dropdown/dropdown-context.tsx | 1 + .../dropdown-item/dropdown-item.module.scss | 8 +++ .../dropdown/dropdown-item/dropdown-item.tsx | 3 +- .../dropdown-separator/dropdown-separator.tsx | 5 ++ .../overlays/dropdown/dropdown.stories.tsx | 55 +++++++++++++++++++ .../components/overlays/dropdown/dropdown.tsx | 6 +- 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx index ffc3ea12a..41e9048b7 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -14,6 +14,7 @@ export type DropdownContextValue = { placement?: string; content: React.ReactNode; setContent: (content: React.ReactNode) => void; + divided?: boolean; }; export const DropdownContext = React.createContext(null); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss index 75234f337..48b07fab0 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -20,6 +20,14 @@ background-color: var(--color-bg-disabled); } + &--divided { + border-bottom: 1px solid var(--general-border-primary); + + &:last-child { + border-bottom: none; + } + } + &:hover { color: var(--dropdown-item-hover-text); cursor: pointer; diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 917217238..dfddee9e2 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -72,7 +72,7 @@ export const DropdownItem = ({ asChild = false, closeOnSelect = true, }: DropdownItemProps) => { - const { getItemProps, listItemsRef, setOpen, activeIndex } = useDropdownContext(); + const { getItemProps, listItemsRef, setOpen, activeIndex, divided } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; @@ -90,6 +90,7 @@ export const DropdownItem = ({ className: cn(styles['tedi-dropdown__item'], { [styles['tedi-dropdown__item--active']]: active, [styles['tedi-dropdown__item--disabled']]: disabled, + [styles['tedi-dropdown__item--divided']]: divided, }), onClick(e) { if (asChild || disabled) return; diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx new file mode 100644 index 000000000..39f9bb618 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.tsx @@ -0,0 +1,5 @@ +import Separator from '../../../misc/separator/separator'; + +export const DropdownSeparator = () => { + return ; +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 88225608f..fb431d21d 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -20,6 +20,7 @@ export default { 'Dropdown.Trigger': Dropdown.Trigger, 'Dropdown.Content': Dropdown.Content, 'Dropdown.Item': Dropdown.Item, + 'Dropdown.Separator': Dropdown.Separator, } as never, } as Meta; @@ -276,3 +277,57 @@ export const WithIcon: Story = { ), }; + +export const Divided: Story = { + render: () => ( + + + + + + + Profile + Security + Billing + Log out + + + ), +}; + +export const WithSeparator: Story = { + render: () => ( + + + + + + + + Edit + + + + + Duplicate + + + + + + Archive + + + + + Delete + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index d7dc58234..196e18837 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -20,6 +20,7 @@ import styles from './dropdown.module.scss'; import { DropdownContent } from './dropdown-content/dropdown-content'; import { DropdownContext, DropdownContextValue } from './dropdown-context'; import { DropdownItem } from './dropdown-item/dropdown-item'; +import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; export type DropdownProps = { @@ -36,9 +37,10 @@ export type DropdownProps = { * @default false */ modal?: boolean; + divided?: boolean; }; -export const Dropdown = ({ children, modal = false }: DropdownProps) => { +export const Dropdown = ({ children, modal = false, divided = false }: DropdownProps) => { const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); @@ -80,6 +82,7 @@ export const Dropdown = ({ children, modal = false }: DropdownProps) => { placement, content, setContent, + divided, ...interactions, }; @@ -124,3 +127,4 @@ export const Dropdown = ({ children, modal = false }: DropdownProps) => { Dropdown.Trigger = DropdownTrigger; Dropdown.Content = DropdownContent; Dropdown.Item = DropdownItem; +Dropdown.Separator = DropdownSeparator; From c0d085a949f9d17890d7ccae392099197a772bea Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:01:56 +0200 Subject: [PATCH 04/77] feat(dropdown): tree variant, placement control #94 --- .../overlays/dropdown/dropdown-context.tsx | 1 + .../dropdown-item/dropdown-item.module.scss | 79 ++++++ .../dropdown/dropdown-item/dropdown-item.tsx | 36 ++- .../overlays/dropdown/dropdown.module.scss | 33 ++- .../overlays/dropdown/dropdown.stories.tsx | 242 +++++++++++++++--- .../components/overlays/dropdown/dropdown.tsx | 101 +++++++- 6 files changed, 444 insertions(+), 48 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.tsx index 41e9048b7..f1a456fc8 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.tsx @@ -15,6 +15,7 @@ export type DropdownContextValue = { content: React.ReactNode; setContent: (content: React.ReactNode) => void; divided?: boolean; + variant?: 'default' | 'tree'; }; export const DropdownContext = React.createContext(null); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss index 48b07fab0..43ffab84e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -1,17 +1,32 @@ @use '@tedi-design-system/core/mixins'; +:root { + --tree-trunk-width: 2px; + --tree-branch-width: 12px; + --tree-bullet-size: 8px; + --tree-line-color: var(--navigation-vertical-tree-neutral-default); + --tree-indent-base: 0.75rem; +} + .tedi-dropdown__item { @include mixins.button-reset; + position: relative; padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); padding-left: var(--dropdown-indent, var(--dropdown-item-padding-x)); color: var(--dropdown-item-default-text); text-align: left; border-radius: 0; + transition: all 0.2s ease; &--active { color: var(--dropdown-item-active-text); background-color: var(--dropdown-item-active-background); + + p, + label { + color: var(--dropdown-item-active-text); + } } &--disabled { @@ -28,11 +43,67 @@ } } + &--tree-item { + position: relative; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: calc(((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x))); + width: var(--tree-trunk-width); + content: ''; + background-color: var(--navigation-vertical-tree-neutral-default); + border-radius: 0; + } + + &::after { + position: absolute; + top: 20px; + bottom: 0; + left: calc(((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x))); + width: var(--tree-branch-width); + height: var(--tree-trunk-width); + content: ''; + background-color: var(--navigation-vertical-tree-neutral-default); + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; + } + + &:last-child::before { + height: calc(50% + var(--tree-trunk-width)); + } + } + + &--tree-parent { + &::before { + top: 50%; + } + + &::after { + position: absolute; + top: 50%; + left: calc( + ((var(--dropdown-item-padding-x) / 2) + var(--dropdown-item-padding-x)) - (var(--tree-bullet-size) / 3) + ); + width: var(--tree-bullet-size); + height: var(--tree-bullet-size); + content: ''; + background-color: var(--tree-line-color); + border-radius: 100%; + transform: translateY(-50%); + } + } + &:hover { color: var(--dropdown-item-hover-text); cursor: pointer; background-color: var(--dropdown-item-hover-background); outline: 0; + + p, + label { + color: var(--dropdown-item-hover-text); + } } &:focus-visible { @@ -42,4 +113,12 @@ box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 0 0 0 3px var(--general-surface-selected); } } + + &--indent { + padding-left: calc(var(--dropdown-indent) + var(--dropdown-item-padding-x)); + + &.tedi-dropdown__item--tree-item { + padding-left: calc(var(--dropdown-item-padding-x) + var(--tree-indent-base) + var(--dropdown-indent)); + } + } } diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index dfddee9e2..ddb99b663 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -15,7 +15,6 @@ export type DropdownItemProps = { onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; /** * Disables the item — prevents interaction and applies disabled styling. - * * @default false */ disabled?: boolean; @@ -29,13 +28,11 @@ export type DropdownItemProps = { /** * Required when using keyboard navigation (ArrowUp/ArrowDown). * Must be a unique, sequential number (0, 1, 2, ...) for each item in the list. - * * When omitted, the item won't be keyboard-focusable. */ index?: number; /** * Indentation level (in rem units). Useful for nested / hierarchical menus. - * * Example: `indent={1}` → adds ~1rem left padding * * @default 0 @@ -44,7 +41,6 @@ export type DropdownItemProps = { /** * When `true`, renders a plain `
` instead of a ` + @@ -147,7 +162,9 @@ export const WithIndentedItems: Story = { return ( - + @@ -208,7 +225,9 @@ export const WithRadio: Story = { return ( - + @@ -250,31 +269,155 @@ export const WithRadio: Story = { }, }; -export const WithIcon: Story = { +export const WithIconAndCustomDropdownWidth: Story = { render: () => ( - - - - + + + + + + + + + +
+ Access to health data + +
+
+ +
+ Declaration of intent + +
+
+ +
+ Contacts + +
+
+
+
+ + + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + +
+ ), +}; - - - - Download - - - - - Add - - - - - Delete - - - -
+export const WithDescription: Story = { + render: () => ( + + + + + + + + + +
+ Access to health data + + Doctors will be able to see your health data + +
+
+ +
+ Access to medications and health data + + Doctors will be able to see your medications and health data + +
+
+ +
+ Access to all + + Doctors will be able to see all your information, including declaration of health and other medical + info + +
+
+
+
+ + + + + + + + + +
+ Tallinn + + 3 timeslots available + +
+
+ +
+ Tartu + + 4 timeslots available + +
+
+ +
+ Elva + + 7 timeslots available + +
+
+ +
+ Rakvere + + 3 timeslots available + +
+
+
+
+ +
), }; @@ -297,9 +440,9 @@ export const Divided: Story = { ), }; -export const WithSeparator: Story = { +export const WithSeparatorAndOpensRight: Story = { render: () => ( - + + + + Parent + Child 1 + Child 2 + Child 3 + + + + + + + + + + + Parent + + Child 1 + Child 2 + Child 3 + + + + + ), +}; diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 196e18837..2219ace1a 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -3,6 +3,7 @@ import { flip, FloatingFocusManager, FloatingPortal, + Placement, shift, useClick, useDismiss, @@ -15,6 +16,7 @@ import { import cn from 'classnames'; import React from 'react'; +import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; import styles from './dropdown.module.scss'; import { DropdownContent } from './dropdown-content/dropdown-content'; @@ -23,7 +25,44 @@ import { DropdownItem } from './dropdown-item/dropdown-item'; import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; -export type DropdownProps = { +type DropdownWidth = 'auto' | 'trigger' | 'full' | number | string; + +type DropdownBreakpointProps = { + /** + * When `true` there is a border between the dropdown items + * @default false + */ + divided?: boolean; + /** + * Controls the width of the dropdown menu. + * - `'auto'` – width is determined by content (default) + * - `'trigger'` – matches the width of the trigger element + * - `'full'` – spans the full width of the containing block + * - `number` – fixed width in pixels + * - `string` – any valid CSS width value (e.g. `'16rem'`, `'100%'`) + * @default auto + */ + width?: DropdownWidth; + /** + * Controls where the dropdown is positioned relative to its trigger. + * Accepts any Floating UI placement value, such as: + * `'bottom-start'`, `'bottom-end'`, `'top-start'`, `'right-end'`, etc. + * + * @default bottom-start + */ + placement?: Placement; + /** + * Controls the visual and structural variant of the dropdown. + * - `'default'` – standard flat list of items + * - `'tree'` – hierarchical (tree-style) list with indented items and connector lines + * Tree visuals are only applied when this prop is set to `'tree'`. + * Ignored by default. + * @default 'default' + */ + variant?: 'default' | 'tree'; +}; + +export interface DropdownProps extends BreakpointSupport { /** * Child elements — must include exactly one `Dropdown.Trigger` and one `Dropdown.Content` */ @@ -37,28 +76,63 @@ export type DropdownProps = { * @default false */ modal?: boolean; - divided?: boolean; -}; + /** + * Controlled open state + */ + open?: boolean; + /** + * Uncontrolled default state + */ + defaultOpen?: boolean; + /** + * Change handler (fires for both modes) + */ + onOpenChange?: (open: boolean) => void; +} -export const Dropdown = ({ children, modal = false, divided = false }: DropdownProps) => { +export const Dropdown = (props: DropdownProps) => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { + children, + modal = false, + divided = false, + width = 'auto', + variant = 'default', + open: controlledOpen, + defaultOpen = false, + onOpenChange, + placement = 'bottom-start', + } = getCurrentBreakpointProps(props); const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); const listItemsRef = React.useRef>([]); - const [open, setOpen] = React.useState(false); const [activeIndex, setActiveIndex] = React.useState(null); const [content, setContent] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); + + const open = controlledOpen ?? uncontrolledOpen; + + const setOpen = React.useCallback( + (next: boolean) => { + if (controlledOpen === undefined) { + setUncontrolledOpen(next); + } + onOpenChange?.(next); + }, + [controlledOpen, onOpenChange] + ); const floating = useFloating({ - placement: 'bottom-start', nodeId, open, + placement, onOpenChange: setOpen, middleware: [flip(), shift()], whileElementsMounted: autoUpdate, }); - const { context, refs, x, y, strategy, placement } = floating; + const { context, refs, x, y, strategy } = floating; const interactions = useInteractions([ useClick(context), @@ -83,9 +157,12 @@ export const Dropdown = ({ children, modal = false, divided = false }: DropdownP content, setContent, divided, + variant, ...interactions, }; + const triggerWidth = refs.reference.current?.getBoundingClientRect().width; + return ( {children} @@ -100,11 +177,19 @@ export const Dropdown = ({ children, modal = false, divided = false }: DropdownP
Date: Wed, 25 Feb 2026 13:18:42 +0200 Subject: [PATCH 05/77] feat(separator): match stories with Figma #94 --- .../dropdown-item/dropdown-item.spec.tsx | 2 +- .../dropdown/dropdown-item/dropdown-item.tsx | 54 ++++--- .../overlays/dropdown/dropdown.stories.tsx | 135 +++++++++++++----- .../components/overlays/dropdown/dropdown.tsx | 17 ++- 4 files changed, 148 insertions(+), 60 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx index ae2ecec5c..34ff81a7f 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -61,7 +61,7 @@ describe('DropdownItem', () => { ); - fireEvent.keyDown(getByText('Item'), { key: 'Enter' }); + fireEvent.click(getByText('Item')); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index ddb99b663..0d8beaa64 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -83,6 +83,7 @@ export const DropdownItem = ({ const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; + const isInteractive = asChild && closeOnSelect === false; const getCssVars = (indent?: number): React.CSSProperties => { const cssVars: React.CSSProperties = {}; @@ -95,9 +96,14 @@ export const DropdownItem = ({ return cssVars; }; - return ( - - {children} - - ); + style: getCssVars(indent), + }); + + return {children}; }; diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index f61d168a7..2031b3960 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -6,6 +6,7 @@ import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; import Checkbox from '../../form/checkbox/checkbox'; import Radio from '../../form/radio/radio'; +import { Search } from '../../form/search/search'; import { Col, Row } from '../../layout/grid'; import { Dropdown } from './dropdown'; @@ -40,11 +41,11 @@ export default { type Story = StoryObj; export const Default: Story = { - render: () => ( - + render: (args) => ( + @@ -85,6 +86,58 @@ export const WithActiveItem: Story = { }, }; +export const WithAction: Story = { + render: () => ( + + + + + + + console.log('Lisa pöördumine')}> + Create contact + + console.log('Lisa toetus')}> + Create application + + Create invoice + + + ), +}; + +export const WithIcon: Story = { + render: () => ( + + + + + + + + + Download + + + + + Add + + + + + Delete + + + + + ), +}; + export const WithCheckbox: Story = { render: () => { const [cities, setCities] = React.useState([]); @@ -162,7 +215,7 @@ export const WithIndentedItems: Story = { return ( - @@ -226,7 +279,7 @@ export const WithRadio: Story = { @@ -269,10 +322,10 @@ export const WithRadio: Story = { }, }; -export const WithIconAndCustomDropdownWidth: Story = { +export const CustomWidth: Story = { render: () => ( - + - - - - - - Download - - - - - Add - - - - - Delete - - - - - ), }; @@ -339,8 +365,8 @@ export const WithDescription: Story = { - @@ -377,7 +403,7 @@ export const WithDescription: Story = { @@ -425,7 +451,7 @@ export const Divided: Story = { render: () => ( - @@ -434,7 +460,11 @@ export const Divided: Story = { Profile Security Billing - Log out + + + Log out + + ), @@ -475,6 +505,33 @@ export const WithSeparatorAndOpensRight: Story = { ), }; +export const CustomContent: Story = { + render: () => ( + + + + + + + + + + Lauri Lepp 49504080254 + + + Mart Mardivere 39504080254 + + Madis Mets 39504080254 + Kalle Kaasik 39504080254 + Pille Porgand 49504080254 + Kert Kasemets 39504080254 + + + ), +}; + export const Tree: Story = { render: () => ( diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 2219ace1a..ac2f7aac4 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -42,7 +42,7 @@ type DropdownBreakpointProps = { * - `string` – any valid CSS width value (e.g. `'16rem'`, `'100%'`) * @default auto */ - width?: DropdownWidth; + width?: 'auto' | 'trigger' | 'full' | number | string; /** * Controls where the dropdown is positioned relative to its trigger. * Accepts any Floating UI placement value, such as: @@ -162,6 +162,15 @@ export const Dropdown = (props: DropdownProps) => { }; const triggerWidth = refs.reference.current?.getBoundingClientRect().width; + const containerWidth = React.useMemo(() => { + const ref = refs.reference.current as HTMLElement | null; + if (!ref) return undefined; + + const container = ref.offsetParent as HTMLElement | null; + if (!container) return undefined; + + return container.getBoundingClientRect().width; + }, [refs.reference.current]); return ( @@ -182,8 +191,10 @@ export const Dropdown = (props: DropdownProps) => { position: strategy, left: x ?? 0, top: y ?? 0, - minWidth: - width === 'trigger' + width: + width === 'full' + ? containerWidth + : width === 'trigger' ? triggerWidth : typeof width === 'number' ? `${width}px` From 3ee3091f935bcfa4a2b5bf9d511a47d4847ae163 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:55:06 +0200 Subject: [PATCH 06/77] fix(dropdown): fix stories #94 --- .../overlays/dropdown/dropdown.stories.tsx | 145 +++++++++++++++--- 1 file changed, 124 insertions(+), 21 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 2031b3960..54147cb88 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -8,6 +8,7 @@ import Checkbox from '../../form/checkbox/checkbox'; import Radio from '../../form/radio/radio'; import { Search } from '../../form/search/search'; import { Col, Row } from '../../layout/grid'; +import Separator from '../../misc/separator/separator'; import { Dropdown } from './dropdown'; /** @@ -65,23 +66,45 @@ export const Default: Story = { export const WithActiveItem: Story = { render: () => { const [lang, setLang] = React.useState('ENG'); + const [filter, setFilter] = React.useState('Newest first'); return ( - - - - - - - {['EST', 'ENG', 'RUS'].map((l, i) => ( - setLang(l)}> - {l} - - ))} - - + + + + + + + + + {['EST', 'ENG', 'RUS'].map((l, i) => ( + setLang(l)}> + {l} + + ))} + + + + + + + + + + + {['Newest first', 'Oldest first', 'Application name A–Z', 'Application name Z–A'].map((f, i) => ( + setFilter(f)}> + {f} + + ))} + + + + ); }, }; @@ -518,15 +541,95 @@ export const CustomContent: Story = { - Lauri Lepp 49504080254 + + Lauri Lepp + + 49504080254 + - Mart Mardivere 39504080254 + + Mart Mardivere + + 39504080254 + + + + + Madis Mets + + 39504080254 + + + + + Kalle Kaasik + + 39504080254 + + + + + Pille Porgand + + 49504080254 + + + + + Kert Kasemets + + 39504080254 + - Madis Mets 39504080254 - Kalle Kaasik 39504080254 - Pille Porgand 49504080254 - Kert Kasemets 39504080254 ), From e13e7293ae9840628288ad1985f022b96daf7c86 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:54:46 +0200 Subject: [PATCH 07/77] feat(date-picker): initial commit #24 --- package-lock.json | 66 ++++++ package.json | 1 + .../components/date-field-header.tsx | 59 +++++ .../form/date-field/date-field.module.scss | 85 ++++++++ .../form/date-field/date-field.stories.tsx | 86 ++++++++ .../components/form/date-field/date-field.tsx | 201 ++++++++++++++++++ 6 files changed, 498 insertions(+) create mode 100644 src/tedi/components/form/date-field/components/date-field-header.tsx create mode 100644 src/tedi/components/form/date-field/date-field.module.scss create mode 100644 src/tedi/components/form/date-field/date-field.stories.tsx create mode 100644 src/tedi/components/form/date-field/date-field.tsx diff --git a/package-lock.json b/package-lock.json index 6268efb2f..bca00038b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "lint-staged": "^16.1.6", "next": "^14.1.3", "prettier": "^2.8.8", + "react-day-picker": "^9.13.2", "replace-in-file": "^8.4.0", "rollup-plugin-preserve-directives": "^0.4.0", "rollup-plugin-visualizer": "^6.0.5", @@ -2522,6 +2523,13 @@ "postcss-selector-parser": "^7.0.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "dev": true, + "license": "MIT" + }, "node_modules/@date-io/core": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", @@ -11672,6 +11680,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "dev": true, + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -23895,6 +23928,39 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-day-picker": { + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-day-picker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/react-docgen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", diff --git a/package.json b/package.json index 33819bf54..657055654 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "lint-staged": "^16.1.6", "next": "^14.1.3", "prettier": "^2.8.8", + "react-day-picker": "^9.13.2", "replace-in-file": "^8.4.0", "rollup-plugin-preserve-directives": "^0.4.0", "rollup-plugin-visualizer": "^6.0.5", diff --git a/src/tedi/components/form/date-field/components/date-field-header.tsx b/src/tedi/components/form/date-field/components/date-field-header.tsx new file mode 100644 index 000000000..a03e112eb --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-header.tsx @@ -0,0 +1,59 @@ +import { MonthCaptionProps, useNavigation } from 'react-day-picker'; + +import Button from '../../../buttons/button/button'; + +export function CalendarHeader({ calendarMonth, displayIndex }: MonthCaptionProps) { + const { goToMonth, nextMonth, previousMonth } = useNavigation(); + + const displayMonth = calendarMonth.date; + + const months = Array.from({ length: 12 }, (_, i) => new Date(2025, i, 1).toLocaleString('et-EE', { month: 'long' })); + + const years = Array.from({ length: 10 }, (_, i) => 2021 + i); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss new file mode 100644 index 000000000..686f5f6a3 --- /dev/null +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -0,0 +1,85 @@ +.tedi-date-field { + &__container { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__calendar { + position: relative; + z-index: var(--z-index-dropdown); + min-width: 315px; + padding: var(--card-padding-md-default); + font-family: var(--family-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + table { + width: 100%; + border-spacing: 0; + } + } + + &__day { + button { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + font-size: var(--body-regular-size); + color: var(--general-text-primary); + background: transparent; + border: 0; + border-radius: 9999px; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + cursor: pointer; + border: 1px solid var(--form-datepicker-today-border); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--form-datepicker-today-border); + } + } + + &--selected button { + color: var(--form-datepicker-date-text-selected); + background-color: var(--form-datepicker-date-selected); + border-radius: var(--button-radius-sm); + } + } + + &__day > button { + color: var(--general-text-primary); + background: transparent; + border: 0; + } + + &__head tr th { + border-bottom: 1px solid var(--card-border-primary); + } + + &__caption { + padding: 8px 0; + font-weight: 600; + color: var(--general-text-primary); + } + + &__row { + margin-bottom: 4px; + } + + &__weekday { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + font-weight: 500; + color: var(--general-text-tertiary); + border-bottom: 1px solid var(--card-border-primary); + } + + &__outside-days button { + color: var(--form-datepicker-date-text-muted); + } +} diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx new file mode 100644 index 000000000..08b4daa9c --- /dev/null +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { DateField, DateFieldProps } from './date-field'; + +/** + * React DayPicker based reusable DatePicker component
+ * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: 'Tedi-Ready/Components/Form/DateField', + component: DateField, + parameters: { + controls: { + exclude: [], + }, + }, +} as Meta; + +type Story = StoryObj; + +const Template: StoryFn = (args) => { + const [value, setValue] = useState(); + + return ; +}; + +export const Single: Story = { + render: Template, + args: { + mode: 'single', + placeholder: 'Select a date', + }, +}; + +export const Multiple: Story = { + render: Template, + args: { + mode: 'multiple', + placeholder: 'Select multiple dates', + }, +}; + +export const Range: Story = { + render: Template, + args: { + mode: 'range', + placeholder: 'Select date range', + }, +}; + +export const DisabledWeekends: Story = { + render: Template, + args: { + mode: 'single', + disabled: { dayOfWeek: [0, 6] }, + placeholder: 'Weekdays only', + }, +}; + +const ManualTypingTemplate: StoryFn = (args) => { + const [value, setValue] = useState(); + + const parseEstonianDate = (value: string): Date | undefined => { + const match = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) return undefined; + + const [, dd, mm, yyyy] = match; + const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + + return isNaN(date.getTime()) ? undefined : date; + }; + + return ; +}; + +export const ManualTyping: Story = { + render: ManualTypingTemplate, + args: { + openBehavior: 'button', + placeholder: 'DD.MM.YYYY', + }, +}; diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx new file mode 100644 index 000000000..93da705a2 --- /dev/null +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React, { useEffect, useState } from 'react'; +import { DateRange, DayPicker, Matcher, OnSelectHandler } from 'react-day-picker'; +import { et } from 'react-day-picker/locale'; + +import TextField from '../textfield/textfield'; +import { CalendarHeader } from './components/date-field-header'; +import styles from './date-field.module.scss'; + +export type DateFieldMode = 'single' | 'multiple' | 'range'; +type DateFieldOpenBehavior = 'input' | 'button'; + +export interface DateFieldProps { + mode?: DateFieldMode; + selected?: Date | Date[] | DateRange | undefined; + onSelect?: OnSelectHandler; + disabled?: Matcher | Matcher[]; + placeholder?: string; + className?: string; + formatDate?: (date: Date | Date[] | DateRange | undefined) => string; + showOutsideDays: boolean; + openBehavior?: DateFieldOpenBehavior; + parseDate?: (value: string) => Date | Date[] | DateRange | undefined; + required?: boolean; +} + +export const DateField: React.FC = ({ + mode = 'single', + selected, + onSelect, + disabled, + placeholder = 'Select date', + className, + formatDate, + required, + openBehavior = 'input', + showOutsideDays = true, + parseDate, +}) => { + const [internalValue, setInternalValue] = useState(selected); + + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const isControlled = selected !== undefined; + const value = isControlled ? selected : internalValue; + + useEffect(() => { + setInputValue(formatDate ? formatDate(value) : defaultFormatter(value)); + }, [value]); + + const floating = useFloating({ + open, + onOpenChange: setOpen, + placement: 'bottom-end', + middleware: [offset(4), flip(), shift({ padding: 8 })], + whileElementsMounted: autoUpdate, + }); + + const { refs, context, x, y, strategy } = floating; + const click = useClick(context); + const interactions = useInteractions( + [openBehavior === 'input' ? click : undefined, useDismiss(context), useRole(context, { role: 'dialog' })].filter( + Boolean + ) + ); + + const handleSelect: OnSelectHandler = (date, selectedDay, modifiers, e) => { + if (!isControlled) { + setInternalValue(date); + } + + onSelect?.(date, selectedDay, modifiers, e); + + if (mode === 'single') { + setOpen(false); + } + }; + + const defaultFormatter = (date: Date | Date[] | DateRange | undefined): string => { + if (!date) return ''; + + const fmt = new Intl.DateTimeFormat('et-EE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + if (mode === 'single' && date instanceof Date) { + return fmt.format(date); + } + + if (mode === 'multiple' && Array.isArray(date)) { + return date.map((d) => fmt.format(d)).join(', '); + } + + if (mode === 'range' && 'from' in date && date.from) { + const from = fmt.format(date.from); + if (date.to) { + return `${from} – ${fmt.format(date.to)}`; + } + return from; + } + + return ''; + }; + + // const formattedValue = useMemo(() => (formatDate ? formatDate(value) : defaultFormatter(value)), [value, formatDate]); + + return ( + <> +
+ { + setInputValue(val); + + if (openBehavior === 'button' && parseDate) { + const parsed = parseDate(val); + if (!isControlled) setInternalValue(parsed); + onSelect?.(parsed, undefined as any, {} as any, {} as any); + } + }} + onIconClick={() => setOpen(true)} + onClear={() => { + setInputValue(''); + if (!isControlled) setInternalValue(undefined); + onSelect?.(undefined, undefined as any, {} as any, {} as any); + }} + aria-expanded={open} + /> +
+ + + {open && ( + +
+ <>, + }} + showOutsideDays={showOutsideDays} + classNames={{ + root: styles['tedi-date-field__calendar'], + month_caption: styles['tedi-date-field__caption'], + head: styles['tedi-date-field__head'], + row: styles['tedi-date-field__row'], + day: styles['tedi-date-field__day'], + selected: styles['tedi-date-field__day--selected'], + weekday: styles['tedi-date-field__weekday'], + outside: styles['tedi-date-field__outside-days'], + }} + onSelect={handleSelect as any} + disabled={disabled} + // {...(required ? { required: true } : {})} + /> +
+
+ )} +
+ + ); +}; From ec73cad3735f6a351e78a301a8cdb5049163662d Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:21:11 +0200 Subject: [PATCH 08/77] feat(dropdown): add className props, add box-shadow, remove unused type #94 --- .../dropdown/dropdown-item/dropdown-item.tsx | 7 +++++++ .../overlays/dropdown/dropdown.module.scss | 1 + src/tedi/components/overlays/dropdown/dropdown.tsx | 14 +++++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 0d8beaa64..2db808e5a 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -67,6 +67,11 @@ export type DropdownItemProps = { * @default false */ isParent?: boolean; + /* + * Additional class name(s) to apply to the dropdown item + * @default undefined + */ + className?: string; }; export const DropdownItem = ({ @@ -79,6 +84,7 @@ export const DropdownItem = ({ asChild = false, closeOnSelect = true, isParent = false, + className, }: DropdownItemProps) => { const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); @@ -119,6 +125,7 @@ export const DropdownItem = ({ [styles['tedi-dropdown__item--indent']]: indent, [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, + className, }), onClick(e) { if (disabled) return; diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 0a2f3aa98..1e877da7e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -10,6 +10,7 @@ background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); border-radius: var(--form-select-area-radius); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); transition: opacity 120ms ease, transform 120ms ease; transform: translateY(-4px) scale(0.98); diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index ac2f7aac4..48d22bac9 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -25,8 +25,6 @@ import { DropdownItem } from './dropdown-item/dropdown-item'; import { DropdownSeparator } from './dropdown-separator/dropdown-separator'; import { DropdownTrigger } from './dropdown-trigger/dropdown-trigger'; -type DropdownWidth = 'auto' | 'trigger' | 'full' | number | string; - type DropdownBreakpointProps = { /** * When `true` there is a border between the dropdown items @@ -88,6 +86,11 @@ export interface DropdownProps extends BreakpointSupport void; + /* + * Additional class name(s) to apply to the dropdown container + * @default undefined + */ + className?: string; } export const Dropdown = (props: DropdownProps) => { @@ -102,6 +105,7 @@ export const Dropdown = (props: DropdownProps) => { defaultOpen = false, onOpenChange, placement = 'bottom-start', + className, } = getCurrentBreakpointProps(props); const { getLabel } = useLabels(); const nodeId = useFloatingNodeId(); @@ -186,7 +190,11 @@ export const Dropdown = (props: DropdownProps) => {
Date: Tue, 3 Mar 2026 13:53:42 +0200 Subject: [PATCH 09/77] feat(date-field): new TEDI-Ready DateField component #24 --- .../components/date-field-header.tsx | 59 --- .../date-field-header.module.scss | 35 ++ .../date-field-header.spec.tsx | 170 +++++++ .../date-field-header/date-field-header.tsx | 130 +++++ .../date-field-month-grid.tsx | 76 +++ .../date-field-year-grid.tsx | 79 +++ .../form/date-field/date-field.module.scss | 232 +++++++-- .../form/date-field/date-field.spec.tsx | 145 ++++++ .../form/date-field/date-field.stories.tsx | 177 ++++++- .../components/form/date-field/date-field.tsx | 478 ++++++++++++++---- .../multi-value-field.module.scss | 23 + .../multi-value-field/multi-value-field.tsx | 86 ++++ .../form/textfield/textfield.module.scss | 6 +- .../components/form/textfield/textfield.tsx | 6 + src/tedi/index.ts | 1 + .../providers/label-provider/labels-map.ts | 30 +- 16 files changed, 1505 insertions(+), 228 deletions(-) delete mode 100644 src/tedi/components/form/date-field/components/date-field-header.tsx create mode 100644 src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss create mode 100644 src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx create mode 100644 src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx create mode 100644 src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx create mode 100644 src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx create mode 100644 src/tedi/components/form/date-field/date-field.spec.tsx create mode 100644 src/tedi/components/form/multi-value-field/multi-value-field.module.scss create mode 100644 src/tedi/components/form/multi-value-field/multi-value-field.tsx diff --git a/src/tedi/components/form/date-field/components/date-field-header.tsx b/src/tedi/components/form/date-field/components/date-field-header.tsx deleted file mode 100644 index a03e112eb..000000000 --- a/src/tedi/components/form/date-field/components/date-field-header.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { MonthCaptionProps, useNavigation } from 'react-day-picker'; - -import Button from '../../../buttons/button/button'; - -export function CalendarHeader({ calendarMonth, displayIndex }: MonthCaptionProps) { - const { goToMonth, nextMonth, previousMonth } = useNavigation(); - - const displayMonth = calendarMonth.date; - - const months = Array.from({ length: 12 }, (_, i) => new Date(2025, i, 1).toLocaleString('et-EE', { month: 'long' })); - - const years = Array.from({ length: 10 }, (_, i) => 2021 + i); - - return ( -
- - - - - - - -
- ); -} diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss new file mode 100644 index 000000000..7745c3a5e --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss @@ -0,0 +1,35 @@ +.tedi-date-field__header { + display: flex; + align-items: center; + justify-content: space-between; + text-align: center; +} + +.tedi-date-field__month-year-dropdown { + max-height: 15rem; + overflow-y: auto; +} + +.tedi-date-field__month-year-selector { + display: flex; + gap: var(--layout-grid-gutters-04); + align-items: center; + padding-left: var(--layout-grid-gutters-04); + font-weight: 500; + text-transform: capitalize; + border-radius: var(--button-radius-sm); + + &:hover, + &[aria-expanded='true'] { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + + .tedi-date-field__month-year-caret { + color: var(--button-main-neutral-text-active); + } + } +} + +.tedi-date-field__month-year-caret { + font-size: var(--tedi-size-09); +} diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx new file mode 100644 index 000000000..03a8f3616 --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx @@ -0,0 +1,170 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CalendarHeader } from './date-field-header'; + +import '@testing-library/jest-dom'; + +// Mock external dependencies +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => { + const labels: Record = { + 'pickers.previousMonth': 'Eelmine kuu', + 'pickers.nextMonth': 'Järgmine kuu', + }; + return labels[key] || key; + }, + }), +})); + +const mockGoToMonth = jest.fn(); +const mockNextMonth = new Date(2025, 7, 1); +const mockPreviousMonth = new Date(2025, 5, 1); + +jest.mock('react-day-picker', () => ({ + ...jest.requireActual('react-day-picker'), + useNavigation: () => ({ + goToMonth: mockGoToMonth, + nextMonth: mockNextMonth, + previousMonth: mockPreviousMonth, + }), +})); + +describe('CalendarHeader', () => { + const defaultProps = { + calendarMonth: { + date: new Date(2025, 6, 15), + displayMonth: new Date(2025, 6, 1), + }, + monthYearSelectGrid: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders previous and next month buttons with correct aria-labels', () => { + render(); + + const prevBtn = screen.getByRole('button', { name: 'Eelmine kuu' }); + const nextBtn = screen.getByRole('button', { name: 'Järgmine kuu' }); + + expect(prevBtn).toBeInTheDocument(); + expect(nextBtn).toBeInTheDocument(); + expect(prevBtn).toHaveAttribute('aria-label', 'Eelmine kuu'); + expect(nextBtn).toHaveAttribute('aria-label', 'Järgmine kuu'); + }); + + it('calls goToMonth with previous month when prev button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const prevBtn = screen.getByRole('button', { name: 'Eelmine kuu' }); + await user.click(prevBtn); + + expect(mockGoToMonth).toHaveBeenCalledWith(mockPreviousMonth); + }); + + it('calls goToMonth with next month when next button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const nextBtn = screen.getByRole('button', { name: 'Järgmine kuu' }); + await user.click(nextBtn); + + expect(mockGoToMonth).toHaveBeenCalledWith(mockNextMonth); + }); + + it('renders month and year as plain text + dropdown triggers when monthYearSelectGrid = false', () => { + render(); + + // Should show July 2025 with dropdown carets + expect(screen.getByText('juuli')).toBeInTheDocument(); + expect(screen.getByText('2025')).toBeInTheDocument(); + + // Two caret icons + expect(screen.getAllByTestId('icon-arrow_drop_down')).toHaveLength(2); // assuming Icon has data-testid or recognizable role + + // Dropdowns should exist (but content hidden until open) + expect(screen.getAllByRole('button', { name: /juuli/i })).toHaveLength(1); + expect(screen.getAllByRole('button', { name: '2025' })).toHaveLength(1); + }); + + it('renders clickable month and year buttons (no dropdown) when monthYearSelectGrid = true', () => { + render( + + ); + + const monthBtn = screen.getByRole('button', { name: 'juuli' }); + const yearBtn = screen.getByRole('button', { name: '2025' }); + + expect(monthBtn).toBeInTheDocument(); + expect(yearBtn).toBeInTheDocument(); + + // No Dropdown components should be rendered + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('calls onOpenMonthGrid when month button is clicked in grid mode', async () => { + const onOpenMonthGrid = jest.fn(); + const user = userEvent.setup(); + + render( + + ); + + const monthBtn = screen.getByRole('button', { name: 'juuli' }); + await user.click(monthBtn); + + expect(onOpenMonthGrid).toHaveBeenCalledTimes(1); + }); + + it('calls onOpenYearGrid when year button is clicked in grid mode', async () => { + const onOpenYearGrid = jest.fn(); + const user = userEvent.setup(); + + render( + + ); + + const yearBtn = screen.getByRole('button', { name: '2025' }); + await user.click(yearBtn); + + expect(onOpenYearGrid).toHaveBeenCalledTimes(1); + }); + + it('shows correct month name in Estonian locale', () => { + render( + + ); + + expect(screen.getByText('detsember')).toBeInTheDocument(); + }); + + it('applies correct container class', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('tedi-date-field__header'); + }); +}); diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx new file mode 100644 index 000000000..a3ac1dd33 --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx @@ -0,0 +1,130 @@ +import classNames from 'classnames'; +import { MonthCaptionProps, useNavigation } from 'react-day-picker'; + +import { useLabels } from '../../../../../providers/label-provider'; +import { Icon } from '../../../../base/icon/icon'; +import Button from '../../../../buttons/button/button'; +import { Dropdown } from '../../../../overlays/dropdown'; +import styles from './date-field-header.module.scss'; + +export interface CalendarHeaderProps extends MonthCaptionProps { + /** + * Show month/year selection as grid instead of dropdowns. + * Default is `false` (dropdowns). + */ + monthYearSelectGrid?: boolean; + /* + * Callback for opening month selection grid. Only used if `monthYearSelectGrid` is `true`. + */ + onOpenMonthGrid?: () => void; + /* + * Callback for opening year selection grid. Only used if `monthYearSelectGrid` is `true`. + */ + onOpenYearGrid?: () => void; +} + +export function CalendarHeader({ + calendarMonth, + monthYearSelectGrid, + onOpenMonthGrid, + onOpenYearGrid, +}: CalendarHeaderProps) { + const { getLabel } = useLabels(); + const { goToMonth, nextMonth, previousMonth } = useNavigation(); + + const displayMonth = calendarMonth.date; + const months = Array.from({ length: 12 }, (_, i) => new Date(2025, i, 1).toLocaleString('et-EE', { month: 'long' })); + const years = Array.from({ length: 10 }, (_, i) => 2021 + i); + + return ( +
+ + + {monthYearSelectGrid ? ( + <> + + + + ) : ( + <> + + + + + + {months.map((monthLabel, monthIndex) => ( + goToMonth(new Date(displayMonth.getFullYear(), monthIndex))} + > + {monthLabel} + + ))} + + + + + + + + + {years.map((year) => ( + goToMonth(new Date(year, displayMonth.getMonth()))} + > + {year} + + ))} + + + + )} + + +
+ ); +} diff --git a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx new file mode 100644 index 000000000..7eb4f431d --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx @@ -0,0 +1,76 @@ +import classNames from 'classnames'; + +import { useLabels } from '../../../../../providers/label-provider'; +import { Text } from '../../../../base/typography/text/text'; +import Button from '../../../../buttons/button/button'; +import { Col, Row } from '../../../../layout/grid'; +import styles from '../../date-field.module.scss'; + +export interface MonthGridProps { + /* + * Current month being displayed in the calendar. + */ + currentMonth: Date; + /* + * Callback when a month is selected from the grid. Receives a Date object with the selected month and current year. + */ + onSelectMonth: (date: Date) => void; + /* + * Callback for navigating to a different month in the grid. Receives a Date object with the target month and current year. + */ + onNavigate: (date: Date) => void; +} + +export const MonthGrid = ({ currentMonth, onSelectMonth, onNavigate }: MonthGridProps) => { + const { getLabel } = useLabels(); + const year = currentMonth.getFullYear(); + + const months = Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)); + + return ( +
+
+ + + + {currentMonth.toLocaleString('et-EE', { month: 'short' })} {year} + + + +
+ +
+ + {months.map((date) => ( + + + + ))} + +
+
+ ); +}; diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx new file mode 100644 index 000000000..4fd7eb47d --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx @@ -0,0 +1,79 @@ +import classNames from 'classnames'; + +import { useLabels } from '../../../../../providers/label-provider'; +import { Text } from '../../../../base/typography/text/text'; +import Button from '../../../../buttons/button/button'; +import { Col, Row } from '../../../../layout/grid'; +import styles from '../../date-field.module.scss'; + +export interface YearGridProps { + /* + * Current month being displayed in the calendar. Year will be extracted from this. + */ + currentMonth: Date; + /* + * Callback when a year is selected from the grid. Receives a Date object with the selected year and current month. + */ + onYearChange: (date: Date) => void; + /* + * Callback for navigating to a different year range in the grid. Receives a Date object with the target year and current month. + */ + onBackToMonths: () => void; +} + +export const YearGrid = ({ currentMonth, onYearChange, onBackToMonths }: YearGridProps) => { + const { getLabel } = useLabels(); + const currentYear = currentMonth.getFullYear(); + const startYear = Math.floor(currentYear / 12) * 12; + const years = Array.from({ length: 12 }, (_, i) => startYear + i); + + return ( +
+
+ + + + {startYear} – {startYear + 11} + + + +
+ +
+ + {years.map((year) => ( + + + + ))} + +
+
+ ); +}; diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 686f5f6a3..08bcc5f8a 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -1,15 +1,12 @@ .tedi-date-field { &__container { - display: flex; - flex-direction: column; - gap: 0.5rem; + position: relative; } &__calendar { position: relative; z-index: var(--z-index-dropdown); min-width: 315px; - padding: var(--card-padding-md-default); font-family: var(--family-default); background: var(--card-background-primary); border: 1px solid var(--card-border-primary); @@ -19,56 +16,32 @@ table { width: 100%; border-spacing: 0; + border-collapse: separate; } } - &__day { - button { - width: var(--form-calendar-date-width); - height: var(--form-calendar-date-width); - font-size: var(--body-regular-size); - color: var(--general-text-primary); - background: transparent; - border: 0; - border-radius: 9999px; - transition: all 0.15s ease; - - &:hover:not(:disabled) { - cursor: pointer; - border: 1px solid var(--form-datepicker-today-border); - } - - &:focus { - outline: none; - box-shadow: 0 0 0 2px var(--form-datepicker-today-border); - } - } - - &--selected button { - color: var(--form-datepicker-date-text-selected); - background-color: var(--form-datepicker-date-selected); - border-radius: var(--button-radius-sm); - } + &__months-container { + display: flex; + gap: 8px; } - &__day > button { - color: var(--general-text-primary); - background: transparent; - border: 0; + &__month { + padding: var(--card-padding-md-default); } - &__head tr th { - border-bottom: 1px solid var(--card-border-primary); + &__footer { + padding-top: var(--layout-grid-gutters-08); + margin: 0 var(--card-padding-md-default) var(--card-padding-xs) var(--card-padding-md-default); + border-top: 1px solid var(--general-border-primary); } &__caption { - padding: 8px 0; font-weight: 600; color: var(--general-text-primary); } - &__row { - margin-bottom: 4px; + &__head tr th { + border-bottom: 1px solid var(--card-border-primary); } &__weekday { @@ -79,7 +52,182 @@ border-bottom: 1px solid var(--card-border-primary); } - &__outside-days button { - color: var(--form-datepicker-date-text-muted); + &__day { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + text-align: center; + border-radius: var(--button-radius-sm); + transition: background 0.15s ease; + + button { + all: unset; + display: grid; + place-items: center; + width: 100%; + height: 100%; + font-size: var(--body-regular-size); + color: var(--general-text-primary); + cursor: pointer; + border-radius: inherit; + } + + &:hover:not(.tedi-date-field__disabled) { + color: var(--general-text-primary); + cursor: pointer; + background-color: var(--form-datepicker-date-hover); + } + } + + &__today { + border: 1px solid var(--form-datepicker-today-border); + border-radius: 50%; + + &.tedi-date-field__available-day { + color: var(--form-datepicker-date-text-available); + background: var(--form-datepicker-date-available); + } + } + + &__available-day { + color: var(--form-datepicker-date-text-available); + background: var(--form-datepicker-date-available); + + button { + color: inherit; + } + } + + &__day--selected { + color: var(--form-datepicker-date-text-selected); + background-color: var(--form-datepicker-date-selected); + + button { + color: inherit; + } + } + + &__range-middle { + background-color: var(--form-datepicker-date-active); + border-radius: 0; + + button { + color: var(--general-text-primary); + } + } + + &__range-start { + background-color: var(--form-datepicker-date-selected); + border-radius: var(--button-radius-sm) 0 0 var(--button-radius-sm); + } + + &__range-end { + background-color: var(--form-datepicker-date-selected); + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; + } + + &__outside-days { + button { + color: var(--form-datepicker-date-text-muted); + } + } + + &__disabled { + opacity: 0.6; + + button { + color: var(--general-text-disabled); + cursor: not-allowed; + } + + &:hover { + background: transparent; + } + } + + &__day:has(button:focus-visible) { + outline: 2px solid var(--form-datepicker-today-border); + outline-offset: 2px; + } +} + +.tedi-date-field__input-wrapper { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.tedi-date-field__tags { + position: absolute; + left: 8px; + z-index: 10; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tedi-date-field__textfield button:not([data-name='closing-button']):last-child { + display: flex; + align-items: center; + justify-content: center; + width: var(--button-xs-icon-size); + min-height: var(--form-field-button-height-sm); + max-height: var(--form-field-button-height-sm); + border-radius: var(--button-radius-sm); + + &:hover { + background-color: var(--form-datepicker-date-hover); + } + + span { + color: var(--button-main-neutral-text-default); } } + +.tedi-date-field__multivalue div:last-child { + align-items: start; +} + +.tedi-date-field__picker-grid-container { + position: relative; + z-index: var(--z-index-dropdown); + max-width: 315px; + font-family: var(--family-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); +} + +.tedi-date-field__picker-grid-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + padding: var(--card-padding-md-default); + padding-bottom: 0; +} + +.tedi-date-field__picker-grid { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + padding: var(--card-padding-md-default); +} + +.tedi-date-field__grid-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 2.5rem; + padding: calc(var(--button-md-padding-y) - 1px) var(--button-md-padding-x); + margin: 0; + font-family: var(--family-default); + font-size: var(--button-text-size-default); + color: var(--form-checkbox-radio-card-primary-default-text); + text-align: center; + cursor: pointer; + border: 1px solid var(--form-checkbox-radio-card-secondary-default-border); + border-radius: var(--form-checkbox-radio-card-radius); +} diff --git a/src/tedi/components/form/date-field/date-field.spec.tsx b/src/tedi/components/form/date-field/date-field.spec.tsx new file mode 100644 index 000000000..6457eb85e --- /dev/null +++ b/src/tedi/components/form/date-field/date-field.spec.tsx @@ -0,0 +1,145 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { et } from 'react-day-picker/locale'; + +import { DateField, DateFieldProps } from './date-field'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => key, + }), +})); + +describe('DateField', () => { + const defaultProps: DateFieldProps = { + label: 'Birth date', + mode: 'single', + }; + + it('renders without crashing and shows label', () => { + render(); + expect(screen.getByLabelText('Birth date')).toBeInTheDocument(); + }); + + it('renders TextField in single mode by default', () => { + render(); + const input = screen.getByLabelText('Birth date'); + expect(input).toHaveAttribute('type', 'text'); + expect(input).toHaveClass('tedi-date-field__textfield'); + }); + + it('renders MultiValueField in multiple mode', () => { + render(); + expect(screen.getByLabelText('Birth date')).toBeInTheDocument(); + expect(screen.getByLabelText('Birth date')).toHaveClass('tedi-date-field__multivalue'); + }); + + it('shows placeholder when no value is selected', () => { + render(); + expect(screen.getByPlaceholderText('Pick a date')).toBeInTheDocument(); + }); + + it('displays formatted date when value is provided (single mode)', () => { + const selected = new Date(2024, 5, 15); // 15.06.2024 in et-EE + render(); + + const input = screen.getByLabelText('Birth date'); + expect(input).toHaveValue('15.06.2024'); + }); + + it('displays — formatted range when mode=range', () => { + const range = { from: new Date(2025, 0, 10), to: new Date(2025, 0, 25) }; + render(); + + const input = screen.getByLabelText('Birth date'); + expect(input).toHaveValue('10.01.2025 – 25.01.2025'); + }); + + it('is read-only when readOnly=true', () => { + render(); + const input = screen.getByLabelText('Birth date'); + expect(input).toHaveAttribute('readonly'); + }); + + it('shows required asterisk when required=true', () => { + render(); + const input = screen.getByLabelText('Birth date'); + expect(input).toHaveAttribute('aria-required', 'true'); + // or check for visual asterisk if your TextField renders it + }); + + it('opens calendar when clicking calendar icon (openBehavior=button)', async () => { + const user = userEvent.setup(); + render(); + + const iconButton = screen.getByRole('button', { name: /calendar/i }); + await user.click(iconButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('pickers.yearSelection') || screen.getByText(/January/i)).toBeInTheDocument(); + }); + }); + + it('opens calendar when clicking input (openBehavior=input)', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('Birth date'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); + + it('does not open calendar on input click when readOnly=true', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('Birth date'); + await user.click(input); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // it('closes calendar after selecting date in single mode (default behavior)', async () => { + // const user = userEvent.setup(); + // const handleSelect = jest.fn(); + + // render(); + + // await user.click(screen.getByRole('button', { name: /calendar/i })); + // await waitFor(() => screen.getByRole('dialog')); + // await user.click(screen.getByText('15')); + + // await waitFor(() => { + // expect(handleSelect).toHaveBeenCalledWith( + // expect.any(Date), + // expect.anything(), + // expect.anything(), + // expect.anything() + // ); + // expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // }); + // }); + + // it('applies custom className to root container', () => { + // render(); + // const container = screen.getByLabelText('Birth date').closest('div'); + // expect(container).toHaveClass('my-special-datepicker'); + // }); + + it('uses custom locale when provided', () => { + render(); + }); + + // Add more specific tests as needed, e.g.: + // - multiple mode chip removal + // - range selection (start → end) + // - disabled dates rendering + // - custom formatDate / parseDate + // - month/year grid view switching +}); diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index 08b4daa9c..87c32f1c4 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -1,11 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useState } from 'react'; +import { DateRange } from 'react-day-picker'; +import Button from '../../buttons/button/button'; +import { Col, Row } from '../../layout/grid'; import { DateField, DateFieldProps } from './date-field'; /** * React DayPicker based reusable DatePicker component
+ * React DayPicker ↗
* Figma ↗
* Zeroheight ↗ */ @@ -23,24 +27,63 @@ export default { type Story = StoryObj; const Template: StoryFn = (args) => { - const [value, setValue] = useState(); - - return ; + return ; }; export const Single: Story = { render: Template, args: { mode: 'single', - placeholder: 'Select a date', + label: 'Date', + required: true, }, }; export const Multiple: Story = { - render: Template, + render: (args) => { + const [value, setValue] = useState([]); + + const formatDate = (date: Date | Date[] | DateRange | undefined): string => { + if (!date) return ''; + + const fmt = new Intl.DateTimeFormat('et-EE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + if (date instanceof Date) { + return fmt.format(date); + } + + if (Array.isArray(date)) { + return date.map((d) => fmt.format(d)).join(', '); + } + + if ('from' in date && date.from) { + const from = fmt.format(date.from); + return date.to ? `${from} – ${fmt.format(date.to)}` : from; + } + + return ''; + }; + + return ( + { + if (Array.isArray(selected)) { + setValue(selected); + } else if (selected instanceof Date) { + setValue([selected]); + } else { + setValue([]); + } + }} + formatDate={formatDate} + /> + ); + }, args: { mode: 'multiple', - placeholder: 'Select multiple dates', + label: 'Dates', }, }; @@ -48,7 +91,7 @@ export const Range: Story = { render: Template, args: { mode: 'range', - placeholder: 'Select date range', + label: 'Select date range', }, }; @@ -57,30 +100,116 @@ export const DisabledWeekends: Story = { args: { mode: 'single', disabled: { dayOfWeek: [0, 6] }, - placeholder: 'Weekdays only', + label: 'Weekdays only', }, }; -const ManualTypingTemplate: StoryFn = (args) => { - const [value, setValue] = useState(); - - const parseEstonianDate = (value: string): Date | undefined => { - const match = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); - if (!match) return undefined; - - const [, dd, mm, yyyy] = match; - const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); +export const MultipleMonthsShown: Story = { + render: () => { + return ; + }, +}; - return isNaN(date.getTime()) ? undefined : date; - }; +export const MonthYearSelectGrid: Story = { + render: () => { + return ; + }, +}; - return ; +export const CalendarFooter: Story = { + render: () => { + return ( + + + + + + + + + + + } + /> + + + + + + + + } + /> + + + ); + }, }; -export const ManualTyping: Story = { - render: ManualTypingTemplate, +export const DefaultValueExample: Story = { + render: Template, args: { - openBehavior: 'button', - placeholder: 'DD.MM.YYYY', + mode: 'single', + label: 'Default selected date', + defaultValue: new Date(), }, }; + +export const AvailableDays: Story = { + render: () => { + const availableDays = [ + new Date(new Date().setDate(new Date().getDate() + 4)), + new Date(new Date().setDate(new Date().getDate() + 5)), + new Date(new Date().setDate(new Date().getDate() + 6)), + ]; + + const [selected, setSelected] = useState(); + + return ( + setSelected(date as Date)} + availableDays={availableDays} + /> + ); + }, +}; + +// const ManualTypingTemplate: StoryFn = (args) => { +// const [value, setValue] = useState(); + +// const parseEstonianDate = (value: string): Date | undefined => { +// const match = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); +// if (!match) return undefined; + +// const [, dd, mm, yyyy] = match; +// const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + +// return isNaN(date.getTime()) ? undefined : date; +// }; + +// return ; +// }; + +// export const ManualTyping: Story = { +// render: ManualTypingTemplate, +// args: { +// label: 'DD.MM.YYYY', +// }, +// }; diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index 93da705a2..ba682f856 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { autoUpdate, flip, @@ -13,55 +12,247 @@ import { useRole, } from '@floating-ui/react'; import cn from 'classnames'; -import React, { useEffect, useState } from 'react'; -import { DateRange, DayPicker, Matcher, OnSelectHandler } from 'react-day-picker'; +import React, { useEffect, useMemo, useState } from 'react'; +import { DateRange, DayPicker, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; +import { UnknownType } from 'src/tedi/types/commonTypes'; -import TextField from '../textfield/textfield'; -import { CalendarHeader } from './components/date-field-header'; +import { useLabels } from '../../../providers/label-provider'; +import MultiValueField, { MultiValueFieldProps } from '../multi-value-field/multi-value-field'; +import TextField, { TextFieldProps } from '../textfield/textfield'; +import { CalendarHeader } from './components/date-field-header/date-field-header'; +import { MonthGrid } from './components/date-field-month-grid/date-field-month-grid'; +import { YearGrid } from './components/date-field-year-grid/date-field-year-grid'; import styles from './date-field.module.scss'; export type DateFieldMode = 'single' | 'multiple' | 'range'; -type DateFieldOpenBehavior = 'input' | 'button'; +export type CalendarView = 'days' | 'months' | 'years'; +export type DateFieldOpenBehavior = 'input' | 'button'; -export interface DateFieldProps { +export interface DateFieldProps extends Omit { + /** + * Field label. Required for accessibility. + */ + label: string; + /* + * Determines the selection mode of the calendar. + * - `'single'` (default) – only one date can be selected. The `selected` prop should be a `Date` object or `undefined`. + * - `'multiple'` – multiple individual dates can be selected. The `selected` prop should be an array of `Date` objects. + * - `'range'` – a continuous date range can be selected. The `selected` prop should be an object with `from` and optional `to` properties, both being `Date` objects. + * + * @default single + */ mode?: DateFieldMode; + /* + * The currently selected date(s). The expected type depends on the `mode`: + * - For `mode="single"`, this should be a `Date` object or `undefined`. + * - For `mode="multiple"`, this should be an array of `Date` objects. + * - For `mode="range"`, this should be an object with a `from` property (a `Date` object) and an optional `to` property (also a `Date` object). + */ selected?: Date | Date[] | DateRange | undefined; + /* + * Callback fired when the user selects a date or date range. The exact parameters depend on the `mode`: + * - For `mode="single"`, the callback receives the selected `Date` object (or `undefined` if cleared). + * - For `mode="multiple"`, the callback receives an array of selected `Date` objects. + * - For `mode="range"`, the callback receives an object with `from` and optional `to` properties, both being `Date` objects (or `undefined` if cleared). + */ onSelect?: OnSelectHandler; + /* + * Disable specific dates. Accepts the same matchers as React DayPicker's `disabled` prop. + */ disabled?: Matcher | Matcher[]; + /* + * Input placeholder text when no date is selected. + */ placeholder?: string; + /* + * Additional class name(s) to apply to the component container. + */ className?: string; + /* + * Custom date formatting function. Receives the selected date(s) and should return a string for display in the input field. + * If not provided, a default formatter will be used that formats dates as "dd.MM.yyyy" in the "et-EE" locale. + */ formatDate?: (date: Date | Date[] | DateRange | undefined) => string; - showOutsideDays: boolean; + /* + * Show days from adjacent months in the calendar view. Default is `true`. + * + * @default true + */ + showOutsideDays?: boolean; + /* + * Determines how the calendar popover is opened: + * - `'button'` (default): The calendar opens when the user clicks the calendar icon button. + * - `'input'`: The calendar opens when the user clicks anywhere on the input field, including the calendar icon. + * + * @default button + */ openBehavior?: DateFieldOpenBehavior; + /* + * Custom date parsing function for user input. Receives the input string and should return a `Date`, an array of `Date`s, a `DateRange`, or `undefined` if the input is invalid or cleared. + * If not provided, the component will not allow manual input and will rely solely on the calendar picker for date selection. + */ parseDate?: (value: string) => Date | Date[] | DateRange | undefined; + /* + * Initial month to display when the calendar is opened. If not provided, defaults to the month of the currently selected date or the current month if no date is selected. + */ + initialMonth?: Date; + /* + * When `true`, the date field is marked as required, and the calendar will enforce that a date is selected before allowing the user to close it. Default is `false`. + * + * @default false + */ required?: boolean; + /* + * When `true`, the month and year selection in the calendar header will be displayed as grids instead of dropdowns. Default is `false`. + * + * @default false + */ + monthYearSelectGrid?: boolean; + /* + * The initial view to show when the calendar is opened. Can be one of: + * - `'days'` (default) – shows the calendar with days view + * - `'months'` – shows the month selection grid (if `monthYearSelectGrid` is `true`) or dropdown + * - `'years'` – shows the year selection grid (if `monthYearSelectGrid` is `true`) or dropdown + * @default 'days' + */ + calendarView?: CalendarView; + /* + * The locale object for the calendar, used by React DayPicker. Defaults to Estonian locale. + */ + locale?: Locale; + /* + * The locale code string used for date formatting. Defaults to 'et-EE'. + */ + localeCode?: string; + /* + * When `true`, the calendar popover will automatically close after a date is selected. Default behavior is to close on select only in 'single' mode. + * You can override this behavior by explicitly setting this prop to `true` or `false`. + * + * @default depends on mode (true for 'single', false for 'multiple' and 'range') + */ + closeOnSelect?: boolean; + /* + * Custom footer content to display at the bottom of the calendar popover. Can be used to add action buttons or additional information. + * The footer will be rendered inside the calendar popover, below the calendar grid. + */ + footer?: React.ReactNode; + /** + * Initial value for uncontrolled usage + */ + defaultValue?: Date | Date[] | DateRange; + /** + * Minimum selectable date. Dates before this will be disabled. + * If you want to disable past dates, you can also use the `disablePast` boolean prop. + */ + minDate?: Date; + /* + * Maximum selectable date. Dates after this will be disabled. + * If you want to disable future dates, you can also use the `disableFuture` boolean prop. + */ + maxDate?: Date; + /** + * Disable all past dates. Dates before the current date will be disabled. + */ + disablePast?: boolean; + /* + * Disable all future dates. Dates after the current date will be disabled. + */ + disableFuture?: boolean; + /* + * Disable specific months dynamically. Receives a month `Date` object and should return `true` if that month should be disabled. + */ + shouldDisableMonth?: (month: Date) => boolean; + /* + * Disable specific years dynamically. Receives a year `Date` object and should return `true` if that year should be disabled. + */ + shouldDisableYear?: (year: Date) => boolean; + /* + * When `true`, the input field will be read-only, preventing manual text input. The calendar can still be opened and used for date selection. + * This is useful when you want to allow date selection only through the calendar picker and not allow users to type in dates manually. + * + * @default false + */ + readOnly?: boolean; + /* + * Specify available days. Can be an array of `Date` objects or a function that receives a date and returns `true` if that date is available. + * This is useful for highlighting specific dates as available while keeping other dates enabled. + */ + availableDays?: Date[] | ((date: Date) => boolean); + /* + * Props to pass down to the underlying TextField (in 'single' mode) or MultiValueField (in 'multiple' mode). This allows for additional customization of the input field, such as adding custom styles, attributes, or event handlers. + */ + inputProps?: TextFieldProps | MultiValueFieldProps; } export const DateField: React.FC = ({ mode = 'single', + label, selected, onSelect, disabled, - placeholder = 'Select date', + placeholder, className, formatDate, required, - openBehavior = 'input', + openBehavior = 'button', showOutsideDays = true, parseDate, + monthYearSelectGrid, + calendarView = 'days', + locale = et, + localeCode = 'et-EE', + initialMonth, + closeOnSelect, + footer, + defaultValue, + minDate, + maxDate, + disablePast, + disableFuture, + shouldDisableMonth, + shouldDisableYear, + readOnly, + availableDays, + inputProps, + ...dayPickerProps }) => { - const [internalValue, setInternalValue] = useState(selected); + const { getLabel } = useLabels(); + const [internalValue, setInternalValue] = useState(selected ?? defaultValue); const [open, setOpen] = useState(false); - const [inputValue, setInputValue] = useState(''); + const [view, setView] = useState(calendarView); const isControlled = selected !== undefined; const value = isControlled ? selected : internalValue; + const [currentMonth, setCurrentMonth] = useState(() => { + if (value instanceof Date) return value; + if (initialMonth) return initialMonth; + return new Date(); + }); + useEffect(() => { - setInputValue(formatDate ? formatDate(value) : defaultFormatter(value)); - }, [value]); + if (open) { + setView(calendarView); + } + }, [open, calendarView]); + + useEffect(() => { + if (isControlled) { + setInternalValue(selected); + } + }, [selected, isControlled]); + + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(localeCode, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }), + [localeCode] + ); const floating = useFloating({ open, @@ -73,53 +264,70 @@ export const DateField: React.FC = ({ const { refs, context, x, y, strategy } = floating; const click = useClick(context); - const interactions = useInteractions( - [openBehavior === 'input' ? click : undefined, useDismiss(context), useRole(context, { role: 'dialog' })].filter( - Boolean - ) - ); + const interactions = useInteractions([ + ...(openBehavior === 'input' ? [click] : []), + useDismiss(context), + useRole(context, { role: 'dialog' }), + ]); - const handleSelect: OnSelectHandler = (date, selectedDay, modifiers, e) => { - if (!isControlled) { - setInternalValue(date); - } + const shouldCloseOnSelect = closeOnSelect ?? mode === 'single'; + const handleSelect: OnSelectHandler = (date, selectedDay, modifiers, e) => { + if (!isControlled) setInternalValue(date); onSelect?.(date, selectedDay, modifiers, e); + if (shouldCloseOnSelect) setOpen(false); + }; - if (mode === 'single') { - setOpen(false); + const defaultFormatter = (date?: Date | Date[] | DateRange): string => { + if (!date) return ''; + + if (date instanceof Date) return dateFormatter.format(date); + if (Array.isArray(date)) return date.map((d) => dateFormatter.format(d)).join(', '); + if (date.from) { + const from = dateFormatter.format(date.from); + return date.to ? `${from} – ${dateFormatter.format(date.to)}` : from; } + + return ''; }; - const defaultFormatter = (date: Date | Date[] | DateRange | undefined): string => { - if (!date) return ''; + const formattedDates = + mode === 'multiple' && Array.isArray(value) + ? value.map((d) => (formatDate ? formatDate(d) : defaultFormatter(d))) + : []; - const fmt = new Intl.DateTimeFormat('et-EE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); + const handleInputChange = (val: string) => { + if (!parseDate) return; - if (mode === 'single' && date instanceof Date) { - return fmt.format(date); + const parsed = parseDate(val); + if (!isControlled) setInternalValue(parsed); + if (mode === 'single' && parsed instanceof Date) { + onSelect?.(parsed, parsed, {}, {} as UnknownType); + } else { + onSelect?.(parsed as Date[] | DateRange, parsed as unknown as Date, {}, {} as UnknownType); } - if (mode === 'multiple' && Array.isArray(date)) { - return date.map((d) => fmt.format(d)).join(', '); - } + if (parsed instanceof Date) setCurrentMonth(parsed); + if (shouldCloseOnSelect) setOpen(false); + }; + + const disabledMatchers: Matcher[] = []; - if (mode === 'range' && 'from' in date && date.from) { - const from = fmt.format(date.from); - if (date.to) { - return `${from} – ${fmt.format(date.to)}`; - } - return from; + if (disabled) { + if (Array.isArray(disabled)) { + disabledMatchers.push(...disabled); + } else { + disabledMatchers.push(disabled); } + } - return ''; - }; + if (minDate) disabledMatchers.push({ before: minDate }); + if (maxDate) disabledMatchers.push({ after: maxDate }); + if (disablePast) disabledMatchers.push({ before: new Date() }); + if (disableFuture) disabledMatchers.push({ after: new Date() }); - // const formattedValue = useMemo(() => (formatDate ? formatDate(value) : defaultFormatter(value)), [value, formatDate]); + if (shouldDisableMonth) disabledMatchers.push((date: Date) => shouldDisableMonth(date)); + if (shouldDisableYear) disabledMatchers.push((date: Date) => shouldDisableYear(date)); return ( <> @@ -127,32 +335,46 @@ export const DateField: React.FC = ({ ref={refs.setReference} className={cn(styles['tedi-date-field__container'], className)} {...interactions.getReferenceProps()} + aria-haspopup="dialog" > - { - setInputValue(val); - - if (openBehavior === 'button' && parseDate) { - const parsed = parseDate(val); - if (!isControlled) setInternalValue(parsed); - onSelect?.(parsed, undefined as any, {} as any, {} as any); - } - }} - onIconClick={() => setOpen(true)} - onClear={() => { - setInputValue(''); - if (!isControlled) setInternalValue(undefined); - onSelect?.(undefined, undefined as any, {} as any, {} as any); - }} - aria-expanded={open} - /> + {mode === 'multiple' ? ( + setOpen(true)} + isClearable + required={required} + onChange={(newValues) => { + if (!Array.isArray(value)) return; + const newDates = value.filter((d) => + newValues.includes(formatDate ? formatDate(d) : defaultFormatter(d)) + ); + if (!isControlled) setInternalValue(newDates); + onSelect?.(newDates, {} as UnknownType, {}, {} as UnknownType); + }} + className={cn(styles['tedi-date-field__textfield'], styles['tedi-date-field__multivalue'])} + /> + ) : ( + setOpen(true)} + aria-expanded={open} + onChange={(val) => handleInputChange(val)} + required={required} + className={styles['tedi-date-field__textfield']} + /> + )}
@@ -160,38 +382,94 @@ export const DateField: React.FC = ({
- <>, - }} - showOutsideDays={showOutsideDays} - classNames={{ - root: styles['tedi-date-field__calendar'], - month_caption: styles['tedi-date-field__caption'], - head: styles['tedi-date-field__head'], - row: styles['tedi-date-field__row'], - day: styles['tedi-date-field__day'], - selected: styles['tedi-date-field__day--selected'], - weekday: styles['tedi-date-field__weekday'], - outside: styles['tedi-date-field__outside-days'], - }} - onSelect={handleSelect as any} - disabled={disabled} - // {...(required ? { required: true } : {})} - /> +
+ {view === 'years' && getLabel('pickers.yearSelection')} + {view === 'months' && getLabel('pickers.monthSelection')} +
+ + {view === 'years' && ( + setView('months')} + /> + )} + + {view === 'months' && ( + { + setCurrentMonth(date); + setView('days'); + }} + /> + )} + + {view === 'days' && ( + 0 ? disabledMatchers : undefined} + required={required} + components={{ + MonthCaption: (props) => ( + setView('months')} + onOpenYearGrid={() => setView('years')} + /> + ), + Nav: () => <>, + }} + footer={footer} + classNames={{ + root: styles['tedi-date-field__calendar'], + month_caption: styles['tedi-date-field__caption'], + head: styles['tedi-date-field__head'], + row: styles['tedi-date-field__row'], + day: styles['tedi-date-field__day'], + selected: styles['tedi-date-field__day--selected'], + weekday: styles['tedi-date-field__weekday'], + outside: styles['tedi-date-field__outside-days'], + range_start: styles['tedi-date-field__range-start'], + range_middle: styles['tedi-date-field__range-middle'], + range_end: styles['tedi-date-field__range-end'], + today: styles['tedi-date-field__today'], + disabled: styles['tedi-date-field__disabled'], + month: styles['tedi-date-field__month'], + months: styles['tedi-date-field__months-container'], + footer: styles['tedi-date-field__footer'], + }} + modifiers={{ + available: + availableDays instanceof Function + ? availableDays + : (d) => availableDays?.some((day) => day.toDateString() === d.toDateString()) ?? false, + }} + modifiersClassNames={{ + available: styles['tedi-date-field__available-day'], + }} + onSelect={handleSelect} + /> + )}
)} diff --git a/src/tedi/components/form/multi-value-field/multi-value-field.module.scss b/src/tedi/components/form/multi-value-field/multi-value-field.module.scss new file mode 100644 index 000000000..3cc9d1d72 --- /dev/null +++ b/src/tedi/components/form/multi-value-field/multi-value-field.module.scss @@ -0,0 +1,23 @@ +.tedi-multi-value-field > div { + min-height: var(--form-field-height); + padding: var(--form-field-padding-y-md-default) var(--form-field-padding-x-md-default); + background-color: var(--form-input-background-default); + border: 1px solid var(--form-input-border-default); + border-radius: var(--form-field-radius); +} + +.tedi-multi-value-field__tags { + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-04); +} + +.tedi-multi-value-field__input { + display: none; + width: 1px; + height: 1px; + padding: 0; + background: transparent; + border: none; + outline: none; +} diff --git a/src/tedi/components/form/multi-value-field/multi-value-field.tsx b/src/tedi/components/form/multi-value-field/multi-value-field.tsx new file mode 100644 index 000000000..0a680816a --- /dev/null +++ b/src/tedi/components/form/multi-value-field/multi-value-field.tsx @@ -0,0 +1,86 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Tag } from '../../tags/tag/tag'; +import TextField, { TextFieldProps } from '../textfield/textfield'; +import styles from './multi-value-field.module.scss'; + +export interface MultiValueFieldProps extends Omit { + values?: string[]; + onChange?: (values: string[]) => void; + maxValues?: number; + tagColor?: 'primary' | 'secondary' | 'danger'; +} + +export const MultiValueField: React.FC = ({ + values: externalValues, + onChange, + maxValues, + tagColor = 'primary', + disabled, + className, + ...rest +}) => { + const [internalValues, setInternalValues] = React.useState(externalValues ?? []); + const [inputValue, setInputValue] = React.useState(''); + + const values = externalValues ?? internalValues; + + const updateValues = (newValues: string[]) => { + setInternalValues(newValues); + onChange?.(newValues); + }; + + const addValue = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return; + if (values.includes(trimmed)) return; + if (maxValues && values.length >= maxValues) return; + + updateValues([...values, trimmed]); + setInputValue(''); + }; + + const removeValue = (index: number) => { + const newVals = values.filter((_, i) => i !== index); + updateValues(newVals); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addValue(inputValue); + } + + if (e.key === 'Backspace' && !inputValue && values.length) { + removeValue(values.length - 1); + } + }; + + return ( + updateValues([])} + startSlot={ + values.length > 0 && ( +
+ {values.map((value, index) => ( + removeValue(index)}> + {value} + + ))} +
+ ) + } + /> + ); +}; + +export default MultiValueField; diff --git a/src/tedi/components/form/textfield/textfield.module.scss b/src/tedi/components/form/textfield/textfield.module.scss index fa91f068c..5b76bd13c 100644 --- a/src/tedi/components/form/textfield/textfield.module.scss +++ b/src/tedi/components/form/textfield/textfield.module.scss @@ -59,7 +59,7 @@ $input-padding-right-map: ( .tedi-textfield__input { display: block; width: 100%; - height: var(--form-field-height); + min-height: var(--form-field-height); padding: var(--form-field-padding-y-md-default) var(--form-field-padding-x-md-default); font-family: inherit; font-size: var(--body-regular-size); @@ -192,3 +192,7 @@ div.tedi-textfield__icon-wrapper { .tedi-textfield__feedback-wrapper { display: flex; } + +.tedi-textfield__separator { + max-height: 1.5rem; +} diff --git a/src/tedi/components/form/textfield/textfield.tsx b/src/tedi/components/form/textfield/textfield.tsx index ff1de6a5c..03482ecbc 100644 --- a/src/tedi/components/form/textfield/textfield.tsx +++ b/src/tedi/components/form/textfield/textfield.tsx @@ -129,6 +129,8 @@ export interface TextFieldProps extends BreakpointSupport | React.TextareaHTMLAttributes; + startSlot?: React.ReactNode; + endSlot?: React.ReactNode; } export interface TextFieldForwardRef { @@ -169,6 +171,8 @@ export const TextField = forwardRef((props, input, name, isTextArea, + startSlot, + endSlot, ...rest } = getCurrentBreakpointProps(props) || {}; const { getLabel } = useLabels(); @@ -398,7 +402,9 @@ export const TextField = forwardRef((props, tabIndex={disabled ? -1 : 0} ref={innerRef} > + {startSlot} {renderInputElement} + {endSlot} {isClearable || icon ? renderRightArea : null}
{renderFeedbackWrapper()} diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 319dbf905..d811fdbd1 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -35,6 +35,7 @@ export * from './components/form/file-upload/file-upload'; export * from './components/form/file-dropzone/file-dropzone'; export * from './components/form/select/select'; export * from './components/form/checkbox/checkbox'; +export * from './components/form/date-field/date-field'; export * from './components/overlays/tooltip'; export * from './components/overlays/popover'; export * from './components/overlays/dropdown'; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index e93c30caa..d8fea06e9 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -558,18 +558,44 @@ export const labelsMap = validateDefaultLabels({ }, 'pickers.previousMonth': { description: `Translation for ${muiTranslationsUrl}`, - components: ['Pickers'], + components: ['Pickers', 'DateField'], et: 'Eelmine kuu', en: 'Previous month', ru: 'Прошлый месяц', }, 'pickers.nextMonth': { description: `Translation for ${muiTranslationsUrl}`, - components: ['Pickers'], + components: ['Pickers', 'DateField'], et: 'Järgmine kuu', en: 'Next month', ru: 'Следующий месяц', }, + 'pickers.previousYear': { + description: `Translation for ${muiTranslationsUrl}`, + components: ['Pickers', 'DateField'], + et: 'Eelmine aasta', + en: 'Previous year', + ru: 'Предыдущий год', + }, + 'pickers.nextYear': { + description: `Translation for ${muiTranslationsUrl}`, + components: ['Pickers', 'DateField'], + et: 'Järgmine aasta', + en: 'Next year', + ru: 'Следующий год', + }, + 'pickers.yearSelection': { + components: ['DateField'], + et: 'Aasta valimine', + en: 'Year selection', + ru: 'Выбор года', + }, + 'pickers.monthSelection': { + components: ['DateField'], + et: 'Kuu valimine', + en: 'Month selection', + ru: 'Выбор месяца', + }, 'pickers.openPreviousView': { description: `Translation for ${muiTranslationsUrl}`, components: ['Pickers'], From d778f1cd3b89e5bceebdcaf5b17ec96e3a88ae17 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:45:19 +0200 Subject: [PATCH 10/77] feat(date-field): type error fixes #24 --- .../form/date-field/date-field.module.scss | 46 ++++++++----------- .../form/date-field/date-field.stories.tsx | 46 ++++++++----------- .../components/form/date-field/date-field.tsx | 25 +++++----- 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 08bcc5f8a..05a6c7e21 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -150,37 +150,29 @@ } } -.tedi-date-field__input-wrapper { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; -} - -.tedi-date-field__tags { - position: absolute; - left: 8px; - z-index: 10; - display: flex; - flex-wrap: wrap; - gap: 8px; -} +.tedi-date-field__textfield { + button:not([data-name='closing-button']):last-child { + display: flex; + align-items: center; + justify-content: center; + width: var(--button-xs-icon-size); + min-height: var(--form-field-button-height-sm); + max-height: var(--form-field-button-height-sm); + border-radius: var(--button-radius-sm); -.tedi-date-field__textfield button:not([data-name='closing-button']):last-child { - display: flex; - align-items: center; - justify-content: center; - width: var(--button-xs-icon-size); - min-height: var(--form-field-button-height-sm); - max-height: var(--form-field-button-height-sm); - border-radius: var(--button-radius-sm); + &:hover { + background-color: var(--form-datepicker-date-hover); + } - &:hover { - background-color: var(--form-datepicker-date-hover); + > span { + color: var(--button-main-neutral-text-default); + } } - span { - color: var(--button-main-neutral-text-default); + &[aria-expanded='true'] { + button:not([data-name='closing-button']):last-child { + background-color: var(--form-datepicker-date-hover); + } } } diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index 87c32f1c4..d9d4b5dd5 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useState } from 'react'; import { DateRange } from 'react-day-picker'; @@ -10,18 +9,13 @@ import { DateField, DateFieldProps } from './date-field'; /** * React DayPicker based reusable DatePicker component
* React DayPicker ↗
- * Figma ↗
- * Zeroheight ↗ + * Figma ↗
+ * Zeroheight ↗ */ export default { title: 'Tedi-Ready/Components/Form/DateField', component: DateField, - parameters: { - controls: { - exclude: [], - }, - }, } as Meta; type Story = StoryObj; @@ -191,25 +185,25 @@ export const AvailableDays: Story = { }, }; -// const ManualTypingTemplate: StoryFn = (args) => { -// const [value, setValue] = useState(); - -// const parseEstonianDate = (value: string): Date | undefined => { -// const match = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); -// if (!match) return undefined; +export const ManualTypingTemplate: StoryFn = (args) => { + const [value, setValue] = useState(); -// const [, dd, mm, yyyy] = match; -// const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + const parseEstonianDate = (value: string): Date | undefined => { + const match = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) return undefined; -// return isNaN(date.getTime()) ? undefined : date; -// }; + const [, dd, mm, yyyy] = match; + const date = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); -// return ; -// }; + return isNaN(date.getTime()) ? undefined : date; + }; -// export const ManualTyping: Story = { -// render: ManualTypingTemplate, -// args: { -// label: 'DD.MM.YYYY', -// }, -// }; + return ( + setValue(date as Date | undefined)} + parseDate={parseEstonianDate} + /> + ); +}; diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index ba682f856..191aa0f0e 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -222,6 +222,7 @@ export const DateField: React.FC = ({ const [open, setOpen] = useState(false); const [view, setView] = useState(calendarView); + const [inputValue, setInputValue] = useState(''); const isControlled = selected !== undefined; const value = isControlled ? selected : internalValue; @@ -297,15 +298,15 @@ export const DateField: React.FC = ({ : []; const handleInputChange = (val: string) => { + setInputValue(val); + if (!parseDate) return; const parsed = parseDate(val); + if (!parsed) return; + if (!isControlled) setInternalValue(parsed); - if (mode === 'single' && parsed instanceof Date) { - onSelect?.(parsed, parsed, {}, {} as UnknownType); - } else { - onSelect?.(parsed as Date[] | DateRange, parsed as unknown as Date, {}, {} as UnknownType); - } + onSelect?.(parsed, parsed as Date, {}, {} as UnknownType); if (parsed instanceof Date) setCurrentMonth(parsed); if (shouldCloseOnSelect) setOpen(false); @@ -363,8 +364,8 @@ export const DateField: React.FC = ({ {...(inputProps as TextFieldProps)} id="datepicker-input" label={label} - readOnly={readOnly} - value={formatDate ? formatDate(value) : defaultFormatter(value)} + readOnly={readOnly ?? !parseDate} + value={inputValue || (formatDate ? formatDate(value) : defaultFormatter(value))} placeholder={placeholder} icon="calendar_today" isClearable @@ -415,13 +416,7 @@ export const DateField: React.FC = ({ = ({ ); }; + +export { DateField as DatePicker }; From d5ada60acb7874945cf2f91eebbd965123c63595 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:46:26 +0200 Subject: [PATCH 11/77] feat(date-field): fix examples, add unit tests #24 --- .../date-field-header.module.scss | 11 +- .../date-field-header.spec.tsx | 16 +-- .../date-field-header/date-field-header.tsx | 2 +- .../date-field-month-grid.spec.tsx | 81 ++++++++++++ .../date-field-month-grid.tsx | 72 ++++------- .../date-field-year-grid.spec.tsx | 80 ++++++++++++ .../date-field-year-grid.tsx | 76 ++++-------- .../form/date-field/date-field-grid.tsx | 68 ++++++++++ .../form/date-field/date-field.module.scss | 117 +++++++++++------- .../form/date-field/date-field.spec.tsx | 90 ++++++-------- .../form/date-field/date-field.stories.tsx | 16 ++- .../components/form/date-field/date-field.tsx | 4 +- 12 files changed, 415 insertions(+), 218 deletions(-) create mode 100644 src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx create mode 100644 src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx create mode 100644 src/tedi/components/form/date-field/date-field-grid.tsx diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss index 7745c3a5e..f019836e9 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss @@ -16,10 +16,19 @@ align-items: center; padding-left: var(--layout-grid-gutters-04); font-weight: 500; + color: var(--general-text-primary); text-transform: capitalize; border-radius: var(--button-radius-sm); - &:hover, + &:hover { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + + .tedi-date-field__month-year-caret { + color: var(--button-main-neutral-text-hover); + } + } + &[aria-expanded='true'] { color: var(--button-main-neutral-text-active); background: var(--button-main-neutral-icon-only-background-active); diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx index 03a8f3616..677a07da4 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx @@ -5,7 +5,6 @@ import { CalendarHeader } from './date-field-header'; import '@testing-library/jest-dom'; -// Mock external dependencies jest.mock('../../../../../providers/label-provider', () => ({ useLabels: () => ({ getLabel: (key: string) => { @@ -36,8 +35,10 @@ describe('CalendarHeader', () => { calendarMonth: { date: new Date(2025, 6, 15), displayMonth: new Date(2025, 6, 1), + weeks: [], }, monthYearSelectGrid: false, + displayIndex: 0, }; beforeEach(() => { @@ -79,14 +80,9 @@ describe('CalendarHeader', () => { it('renders month and year as plain text + dropdown triggers when monthYearSelectGrid = false', () => { render(); - // Should show July 2025 with dropdown carets expect(screen.getByText('juuli')).toBeInTheDocument(); expect(screen.getByText('2025')).toBeInTheDocument(); - - // Two caret icons - expect(screen.getAllByTestId('icon-arrow_drop_down')).toHaveLength(2); // assuming Icon has data-testid or recognizable role - - // Dropdowns should exist (but content hidden until open) + expect(screen.getAllByText('arrow_drop_down')).toHaveLength(2); expect(screen.getAllByRole('button', { name: /juuli/i })).toHaveLength(1); expect(screen.getAllByRole('button', { name: '2025' })).toHaveLength(1); }); @@ -106,8 +102,6 @@ describe('CalendarHeader', () => { expect(monthBtn).toBeInTheDocument(); expect(yearBtn).toBeInTheDocument(); - - // No Dropdown components should be rendered expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); @@ -154,8 +148,8 @@ describe('CalendarHeader', () => { ); diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx index a3ac1dd33..97de82b0f 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx +++ b/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx @@ -7,7 +7,7 @@ import Button from '../../../../buttons/button/button'; import { Dropdown } from '../../../../overlays/dropdown'; import styles from './date-field-header.module.scss'; -export interface CalendarHeaderProps extends MonthCaptionProps { +export interface CalendarHeaderProps extends Pick { /** * Show month/year selection as grid instead of dropdowns. * Default is `false` (dropdowns). diff --git a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx new file mode 100644 index 000000000..b397c7f0c --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { MonthGrid } from './date-field-month-grid'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => { + const labels: Record = { + 'pickers.previousMonth': 'Eelmine kuu', + 'pickers.nextMonth': 'Järgmine kuu', + }; + return labels[key] || key; + }, + }), +})); + +describe('MonthGrid component', () => { + const mockOnSelectMonth = jest.fn(); + const mockOnNavigate = jest.fn(); + + const currentMonth = new Date(2025, 6, 15); + + const defaultProps = { + currentMonth, + onSelectMonth: mockOnSelectMonth, + onNavigate: mockOnNavigate, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders header with correct month and year', () => { + render(); + + expect(screen.getByText('juuli 2025')).toBeInTheDocument(); + }); + + it('renders 12 month buttons', () => { + render(); + + const monthButtons = screen + .getAllByRole('button') + .filter((btn) => btn.textContent?.match(/jaan|veebr|märts|apr|mai|juuni|juuli|aug|sept|okt|nov|dets/i)); + + expect(monthButtons).toHaveLength(12); + }); + + it('calls onNavigate with previous month when prev button clicked', async () => { + const user = userEvent.setup(); + render(); + const prevBtn = screen.getByRole('button', { name: 'Eelmine kuu' }); + await user.click(prevBtn); + expect(mockOnNavigate).toHaveBeenCalledWith(new Date(2025, 5, 1)); + }); + + it('calls onNavigate with next month when next button clicked', async () => { + const user = userEvent.setup(); + render(); + const nextBtn = screen.getByRole('button', { name: 'Järgmine kuu' }); + await user.click(nextBtn); + expect(mockOnNavigate).toHaveBeenCalledWith(new Date(2025, 7, 1)); + }); + + it('calls onSelectMonth with correct date when month is clicked', async () => { + const user = userEvent.setup(); + render(); + const augustButton = screen.getByRole('button', { name: /aug/i }); + await user.click(augustButton); + expect(mockOnSelectMonth).toHaveBeenCalledWith(new Date(2025, 7, 1)); + }); + + it('marks current month as selected', () => { + render(); + const selectedButton = screen.getByRole('button', { name: 'juuli' }); + expect(selectedButton).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx index 7eb4f431d..20b53f1b3 100644 --- a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx +++ b/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx @@ -1,10 +1,6 @@ -import classNames from 'classnames'; - import { useLabels } from '../../../../../providers/label-provider'; import { Text } from '../../../../base/typography/text/text'; -import Button from '../../../../buttons/button/button'; -import { Col, Row } from '../../../../layout/grid'; -import styles from '../../date-field.module.scss'; +import { PickerGrid } from '../../date-field-grid'; export interface MonthGridProps { /* @@ -25,52 +21,30 @@ export const MonthGrid = ({ currentMonth, onSelectMonth, onNavigate }: MonthGrid const { getLabel } = useLabels(); const year = currentMonth.getFullYear(); - const months = Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)); + const months = Array.from({ length: 12 }, (_, i) => { + const date = new Date(year, i, 1); - return ( -
-
- + return { + key: i, + value: date, + label: {date.toLocaleString('et-EE', { month: 'short' })}, + isSelected: i === currentMonth.getMonth(), + }; + }); - + return ( + {currentMonth.toLocaleString('et-EE', { month: 'short' })} {year} - - - -
- -
- - {months.map((date) => ( - - - - ))} - -
-
+ + } + prevAriaLabel={getLabel('pickers.previousMonth')} + nextAriaLabel={getLabel('pickers.nextMonth')} + onPrev={() => onNavigate(new Date(year, currentMonth.getMonth() - 1))} + onNext={() => onNavigate(new Date(year, currentMonth.getMonth() + 1))} + items={months} + onSelect={onSelectMonth} + /> ); }; diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx new file mode 100644 index 000000000..eb96a6103 --- /dev/null +++ b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { YearGrid } from './date-field-year-grid'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => { + const labels: Record = { + 'pickers.previousYear': 'Eelmine periood', + 'pickers.nextYear': 'Järgmine periood', + }; + return labels[key] || key; + }, + }), +})); + +describe('YearGrid component', () => { + const mockOnYearChange = jest.fn(); + const mockOnBackToMonths = jest.fn(); + const currentMonth = new Date(2025, 6, 15); + + const defaultProps = { + currentMonth, + onYearChange: mockOnYearChange, + onBackToMonths: mockOnBackToMonths, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correct 12-year range in header', () => { + render(); + expect(screen.getByText('2016 – 2027')).toBeInTheDocument(); + }); + + it('renders 12 year buttons', () => { + render(); + const yearButtons = screen.getAllByRole('button').filter((btn) => /^\d{4}$/.test(btn.textContent || '')); + expect(yearButtons).toHaveLength(12); + }); + + it('marks current year as selected', () => { + render(); + + const selectedYear = screen.getByRole('button', { name: '2025' }); + + expect(selectedYear).toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onYearChange with previous 12-year range when prev clicked', async () => { + const user = userEvent.setup(); + render(); + const prevBtn = screen.getByRole('button', { name: 'Eelmine periood' }); + await user.click(prevBtn); + expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2004, 0)); + }); + + it('calls onYearChange with next 12-year range when next clicked', async () => { + const user = userEvent.setup(); + render(); + const nextBtn = screen.getByRole('button', { name: 'Järgmine periood' }); + await user.click(nextBtn); + expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2028, 0)); + }); + + it('calls onYearChange and onBackToMonths when year selected', async () => { + const user = userEvent.setup(); + render(); + + const year2023 = screen.getByRole('button', { name: '2023' }); + await user.click(year2023); + + expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2023, currentMonth.getMonth())); + expect(mockOnBackToMonths).toHaveBeenCalled(); + }); +}); diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx index 4fd7eb47d..160822bcb 100644 --- a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx +++ b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx @@ -1,10 +1,5 @@ -import classNames from 'classnames'; - import { useLabels } from '../../../../../providers/label-provider'; -import { Text } from '../../../../base/typography/text/text'; -import Button from '../../../../buttons/button/button'; -import { Col, Row } from '../../../../layout/grid'; -import styles from '../../date-field.module.scss'; +import { PickerGrid } from '../../date-field-grid'; export interface YearGridProps { /* @@ -25,55 +20,30 @@ export const YearGrid = ({ currentMonth, onYearChange, onBackToMonths }: YearGri const { getLabel } = useLabels(); const currentYear = currentMonth.getFullYear(); const startYear = Math.floor(currentYear / 12) * 12; - const years = Array.from({ length: 12 }, (_, i) => startYear + i); - - return ( -
-
- - - {startYear} – {startYear + 11} - + const years = Array.from({ length: 12 }, (_, i) => { + const year = startYear + i; - -
+ return { + key: year, + value: year, + label: year, + isSelected: year === currentYear, + }; + }); -
- - {years.map((year) => ( - - - - ))} - -
-
+ return ( + onYearChange(new Date(startYear - 12, 0))} + onNext={() => onYearChange(new Date(startYear + 12, 0))} + items={years} + onSelect={(year) => { + onYearChange(new Date(year, currentMonth.getMonth())); + onBackToMonths(); + }} + /> ); }; diff --git a/src/tedi/components/form/date-field/date-field-grid.tsx b/src/tedi/components/form/date-field/date-field-grid.tsx new file mode 100644 index 000000000..31fac099f --- /dev/null +++ b/src/tedi/components/form/date-field/date-field-grid.tsx @@ -0,0 +1,68 @@ +import classNames from 'classnames'; + +import { Text } from '../../base/typography/text/text'; +import { Button } from '../../buttons/button/button'; +import { Col, Row } from '../../layout/grid'; +import styles from './date-field.module.scss'; + +interface PickerGridItem { + key: React.Key; + value: T; + label: React.ReactNode; + isSelected?: boolean; +} + +interface PickerGridProps { + headerLabel: React.ReactNode; + prevAriaLabel: string; + nextAriaLabel: string; + onPrev: () => void; + onNext: () => void; + items: PickerGridItem[]; + onSelect: (value: T) => void; +} + +export const PickerGrid = ({ + headerLabel, + prevAriaLabel, + nextAriaLabel, + onPrev, + onNext, + items, + onSelect, +}: PickerGridProps) => { + return ( +
+
+ + + {headerLabel} + + +
+ +
+ + {items.map((item) => ( + + + + ))} + +
+
+ ); +}; diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 05a6c7e21..208cc2e35 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -1,17 +1,27 @@ +%tedi-date-field-surface { + position: relative; + z-index: var(--z-index-dropdown); + font-family: var(--family-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); +} + +%tedi-date-field-cell-size { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); +} + .tedi-date-field { &__container { position: relative; } &__calendar { - position: relative; - z-index: var(--z-index-dropdown); + @extend %tedi-date-field-surface; + min-width: 315px; - font-family: var(--family-default); - background: var(--card-background-primary); - border: 1px solid var(--card-border-primary); - border-radius: var(--card-radius-rounded); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); table { width: 100%; @@ -31,30 +41,25 @@ &__footer { padding-top: var(--layout-grid-gutters-08); - margin: 0 var(--card-padding-md-default) var(--card-padding-xs) var(--card-padding-md-default); + margin: 0 var(--card-padding-md-default) var(--card-padding-xs); border-top: 1px solid var(--general-border-primary); } - &__caption { - font-weight: 600; - color: var(--general-text-primary); - } - - &__head tr th { + &__head th { border-bottom: 1px solid var(--card-border-primary); } &__weekday { - width: var(--form-calendar-date-width); - height: var(--form-calendar-date-width); + @extend %tedi-date-field-cell-size; + font-weight: 500; color: var(--general-text-tertiary); border-bottom: 1px solid var(--card-border-primary); } &__day { - width: var(--form-calendar-date-width); - height: var(--form-calendar-date-width); + @extend %tedi-date-field-cell-size; + text-align: center; border-radius: var(--button-radius-sm); transition: background 0.15s ease; @@ -66,9 +71,15 @@ width: 100%; height: 100%; font-size: var(--body-regular-size); + font-weight: 400; color: var(--general-text-primary); cursor: pointer; border-radius: inherit; + + &:focus-visible { + outline: 2px solid var(--form-datepicker-today-border); + outline-offset: 2px; + } } &:hover:not(.tedi-date-field__disabled) { @@ -79,8 +90,10 @@ } &__today { - border: 1px solid var(--form-datepicker-today-border); - border-radius: 50%; + button { + border: 1px solid var(--form-datepicker-today-border); + border-radius: 50%; + } &.tedi-date-field__available-day { color: var(--form-datepicker-date-text-available); @@ -93,7 +106,7 @@ background: var(--form-datepicker-date-available); button { - color: inherit; + all: unset; } } @@ -102,7 +115,7 @@ background-color: var(--form-datepicker-date-selected); button { - color: inherit; + all: unset; } } @@ -131,11 +144,19 @@ } } - &__disabled { - opacity: 0.6; + &__week-number { + @extend %tedi-date-field-cell-size; + + font-weight: 500; + color: var(--general-text-tertiary); + text-align: center; + border-right: 1px solid var(--general-border-primary); + } + + &__disabled:not(.tedi-date-field__outside-days) { + opacity: 0.3; button { - color: var(--general-text-disabled); cursor: not-allowed; } @@ -143,11 +164,6 @@ background: transparent; } } - - &__day:has(button:focus-visible) { - outline: 2px solid var(--form-datepicker-today-border); - outline-offset: 2px; - } } .tedi-date-field__textfield { @@ -180,15 +196,17 @@ align-items: start; } +.tedi-date-field__picker-grid { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + padding: var(--card-padding-md-default); +} + .tedi-date-field__picker-grid-container { - position: relative; - z-index: var(--z-index-dropdown); + @extend %tedi-date-field-surface; + max-width: 315px; - font-family: var(--family-default); - background: var(--card-background-primary); - border: 1px solid var(--card-border-primary); - border-radius: var(--card-radius-rounded); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); } .tedi-date-field__picker-grid-header { @@ -200,13 +218,6 @@ padding-bottom: 0; } -.tedi-date-field__picker-grid { - display: flex; - gap: var(--layout-grid-gutters-08); - align-items: center; - padding: var(--card-padding-md-default); -} - .tedi-date-field__grid-button { display: inline-flex; align-items: center; @@ -214,12 +225,24 @@ width: 100%; min-height: 2.5rem; padding: calc(var(--button-md-padding-y) - 1px) var(--button-md-padding-x); - margin: 0; - font-family: var(--family-default); font-size: var(--button-text-size-default); color: var(--form-checkbox-radio-card-primary-default-text); text-align: center; - cursor: pointer; border: 1px solid var(--form-checkbox-radio-card-secondary-default-border); border-radius: var(--form-checkbox-radio-card-radius); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + cursor: pointer; + border: 1px solid var(--form-checkbox-radio-card-secondary-hover-border); + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + border: var(--general-selected-border-width) solid var(--form-checkbox-radio-card-secondary-selected-border); + + &:hover { + border-width: 2px; + } + } } diff --git a/src/tedi/components/form/date-field/date-field.spec.tsx b/src/tedi/components/form/date-field/date-field.spec.tsx index 6457eb85e..352850766 100644 --- a/src/tedi/components/form/date-field/date-field.spec.tsx +++ b/src/tedi/components/form/date-field/date-field.spec.tsx @@ -12,7 +12,7 @@ jest.mock('../../../providers/label-provider', () => ({ }), })); -describe('DateField', () => { +describe('DateField component', () => { const defaultProps: DateFieldProps = { label: 'Birth date', mode: 'single', @@ -25,15 +25,14 @@ describe('DateField', () => { it('renders TextField in single mode by default', () => { render(); - const input = screen.getByLabelText('Birth date'); - expect(input).toHaveAttribute('type', 'text'); - expect(input).toHaveClass('tedi-date-field__textfield'); + + expect(screen.getByLabelText('Birth date')).toBeInTheDocument(); }); it('renders MultiValueField in multiple mode', () => { render(); + expect(screen.getByLabelText('Birth date')).toBeInTheDocument(); - expect(screen.getByLabelText('Birth date')).toHaveClass('tedi-date-field__multivalue'); }); it('shows placeholder when no value is selected', () => { @@ -63,24 +62,10 @@ describe('DateField', () => { expect(input).toHaveAttribute('readonly'); }); - it('shows required asterisk when required=true', () => { + it('marks field required', () => { render(); - const input = screen.getByLabelText('Birth date'); - expect(input).toHaveAttribute('aria-required', 'true'); - // or check for visual asterisk if your TextField renders it - }); - - it('opens calendar when clicking calendar icon (openBehavior=button)', async () => { - const user = userEvent.setup(); - render(); - const iconButton = screen.getByRole('button', { name: /calendar/i }); - await user.click(iconButton); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('pickers.yearSelection') || screen.getByText(/January/i)).toBeInTheDocument(); - }); + expect(screen.getByRole('textbox', { name: /birth date/i })).toBeRequired(); }); it('opens calendar when clicking input (openBehavior=input)', async () => { @@ -95,47 +80,46 @@ describe('DateField', () => { }); }); - it('does not open calendar on input click when readOnly=true', async () => { + it('closes calendar after selecting date', async () => { const user = userEvent.setup(); - render(); + const onSelect = jest.fn(); - const input = screen.getByLabelText('Birth date'); - await user.click(input); + render(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); + await user.click(screen.getByRole('button')); + const day = await screen.findByText('15'); + await user.click(day); - // it('closes calendar after selecting date in single mode (default behavior)', async () => { - // const user = userEvent.setup(); - // const handleSelect = jest.fn(); - - // render(); - - // await user.click(screen.getByRole('button', { name: /calendar/i })); - // await waitFor(() => screen.getByRole('dialog')); - // await user.click(screen.getByText('15')); - - // await waitFor(() => { - // expect(handleSelect).toHaveBeenCalledWith( - // expect.any(Date), - // expect.anything(), - // expect.anything(), - // expect.anything() - // ); - // expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - // }); - // }); - - // it('applies custom className to root container', () => { - // render(); - // const container = screen.getByLabelText('Birth date').closest('div'); - // expect(container).toHaveClass('my-special-datepicker'); - // }); + await waitFor(() => { + expect(onSelect).toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); it('uses custom locale when provided', () => { render(); }); + it('applies custom className', () => { + render(); + const input = screen.getByLabelText('Birth date'); + expect(input.closest('.tedi-date-field__container')).toHaveClass('my-datepicker'); + }); + + it('parses manual input', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + render( new Date(2024, 0, 1)} onSelect={onSelect} />); + await user.type(screen.getByLabelText('Birth date'), '01.01.2024'); + expect(onSelect).toHaveBeenCalled(); + }); + + it('updates when controlled value changes', () => { + const { rerender } = render(); + rerender(); + expect(screen.getByLabelText('Birth date')).toHaveValue('15.06.2024'); + }); + // Add more specific tests as needed, e.g.: // - multiple mode chip removal // - range selection (start → end) diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index d9d4b5dd5..80525cd76 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -98,6 +98,15 @@ export const DisabledWeekends: Story = { }, }; +export const ShowWeekCount: Story = { + render: Template, + args: { + mode: 'single', + label: 'Weekdays only', + showWeekNumber: true, + }, +}; + export const MultipleMonthsShown: Story = { render: () => { return ; @@ -154,7 +163,7 @@ export const CalendarFooter: Story = { }, }; -export const DefaultValueExample: Story = { +export const DefaultValue: Story = { render: Template, args: { mode: 'single', @@ -185,7 +194,7 @@ export const AvailableDays: Story = { }, }; -export const ManualTypingTemplate: StoryFn = (args) => { +export const ManualTyping: StoryFn = (args) => { const [value, setValue] = useState(); const parseEstonianDate = (value: string): Date | undefined => { @@ -204,6 +213,9 @@ export const ManualTypingTemplate: StoryFn = (args) => { selected={value} onSelect={(date) => setValue(date as Date | undefined)} parseDate={parseEstonianDate} + placeholder="pp.kk.aaaa" + label="Kuupäev" + required /> ); }; diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index 191aa0f0e..ee2dc1e07 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -15,9 +15,9 @@ import cn from 'classnames'; import React, { useEffect, useMemo, useState } from 'react'; import { DateRange, DayPicker, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; -import { UnknownType } from 'src/tedi/types/commonTypes'; import { useLabels } from '../../../providers/label-provider'; +import { UnknownType } from '../../../types/commonTypes'; import MultiValueField, { MultiValueFieldProps } from '../multi-value-field/multi-value-field'; import TextField, { TextFieldProps } from '../textfield/textfield'; import { CalendarHeader } from './components/date-field-header/date-field-header'; @@ -383,6 +383,7 @@ export const DateField: React.FC = ({
= ({ month: styles['tedi-date-field__month'], months: styles['tedi-date-field__months-container'], footer: styles['tedi-date-field__footer'], + week_number: styles['tedi-date-field__week-number'], }} modifiers={{ available: From 095701515c1954963ab7098e5f6a956944b6413e Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:13:53 +0200 Subject: [PATCH 12/77] feat(date-field): states example, css fixes #24 --- .../form/date-field/date-field.module.scss | 40 +++++-- .../form/date-field/date-field.spec.tsx | 8 +- .../form/date-field/date-field.stories.tsx | 113 +++++++++++++++--- .../components/form/date-field/date-field.tsx | 17 ++- 4 files changed, 138 insertions(+), 40 deletions(-) diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 208cc2e35..1fc2b4d5c 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -16,6 +16,7 @@ .tedi-date-field { &__container { position: relative; + width: 100%; } &__calendar { @@ -66,15 +67,15 @@ button { all: unset; - display: grid; - place-items: center; + display: block; width: 100%; height: 100%; + margin-left: -1px; + overflow: hidden; font-size: var(--body-regular-size); font-weight: 400; color: var(--general-text-primary); cursor: pointer; - border-radius: inherit; &:focus-visible { outline: 2px solid var(--form-datepicker-today-border); @@ -106,7 +107,7 @@ background: var(--form-datepicker-date-available); button { - all: unset; + color: inherit; } } @@ -114,8 +115,14 @@ color: var(--form-datepicker-date-text-selected); background-color: var(--form-datepicker-date-selected); + &.tedi-date-field__today { + button { + border-color: var(--form-datepicker-date-text-selected); + } + } + button { - all: unset; + color: inherit; } } @@ -153,10 +160,11 @@ border-right: 1px solid var(--general-border-primary); } - &__disabled:not(.tedi-date-field__outside-days) { + &__disabled { opacity: 0.3; button { + color: var(--general-text-primary); cursor: not-allowed; } @@ -176,18 +184,28 @@ max-height: var(--form-field-button-height-sm); border-radius: var(--button-radius-sm); + > span { + color: var(--button-main-neutral-text-default); + } + &:hover { background-color: var(--form-datepicker-date-hover); } + } - > span { - color: var(--button-main-neutral-text-default); - } + &[aria-expanded='true'] button:not([data-name='closing-button']):last-child { + background-color: var(--form-datepicker-date-hover); } - &[aria-expanded='true'] { + &--disabled { button:not([data-name='closing-button']):last-child { - background-color: var(--form-datepicker-date-hover); + span { + color: var(--button-main-disabled-general-text); + } + + &:hover { + background: none; + } } } } diff --git a/src/tedi/components/form/date-field/date-field.spec.tsx b/src/tedi/components/form/date-field/date-field.spec.tsx index 352850766..6116b35a6 100644 --- a/src/tedi/components/form/date-field/date-field.spec.tsx +++ b/src/tedi/components/form/date-field/date-field.spec.tsx @@ -16,6 +16,7 @@ describe('DateField component', () => { const defaultProps: DateFieldProps = { label: 'Birth date', mode: 'single', + id: 'date-field', }; it('renders without crashing and shows label', () => { @@ -119,11 +120,4 @@ describe('DateField component', () => { rerender(); expect(screen.getByLabelText('Birth date')).toHaveValue('15.06.2024'); }); - - // Add more specific tests as needed, e.g.: - // - multiple mode chip removal - // - range selection (start → end) - // - disabled dates rendering - // - custom formatDate / parseDate - // - month/year grid view switching }); diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index 80525cd76..e691c1e80 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useState } from 'react'; import { DateRange } from 'react-day-picker'; +import { Text } from '../../base/typography/text/text'; import Button from '../../buttons/button/button'; import { Col, Row } from '../../layout/grid'; import { DateField, DateFieldProps } from './date-field'; @@ -24,6 +25,65 @@ const Template: StoryFn = (args) => { return ; }; +const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled']; + +interface TemplateStateProps extends DateFieldProps { + array: typeof stateArray; +} + +const TemplateColumnWithStates: StoryFn = (args) => { + const { array, ...dateFieldProps } = args; + + return ( +
+ {array.map((state, index) => ( + + + {state} + + + + + + ))} + + + Success + + + + + + + + Error + + + + + +
+ ); +}; + export const Single: Story = { render: Template, args: { @@ -33,6 +93,21 @@ export const Single: Story = { }, }; +export const States: StoryObj = { + render: TemplateColumnWithStates, + args: { + array: stateArray, + label: 'Label', + }, + parameters: { + pseudo: { + hover: '#Hover', + focus: '#Focus', + active: '#Active', + }, + }, +}; + export const Multiple: Story = { render: (args) => { const [value, setValue] = useState([]); @@ -109,13 +184,13 @@ export const ShowWeekCount: Story = { export const MultipleMonthsShown: Story = { render: () => { - return ; + return ; }, }; export const MonthYearSelectGrid: Story = { render: () => { - return ; + return ; }, }; @@ -125,19 +200,13 @@ export const CalendarFooter: Story = { - - - - - + + } @@ -145,13 +214,19 @@ export const CalendarFooter: Story = { - - + + + @@ -175,6 +250,7 @@ export const DefaultValue: Story = { export const AvailableDays: Story = { render: () => { const availableDays = [ + new Date(), new Date(new Date().setDate(new Date().getDate() + 4)), new Date(new Date().setDate(new Date().getDate() + 5)), new Date(new Date().setDate(new Date().getDate() + 6)), @@ -189,6 +265,7 @@ export const AvailableDays: Story = { selected={selected} onSelect={(date) => setSelected(date as Date)} availableDays={availableDays} + id="available-days-shown" /> ); }, @@ -213,8 +290,8 @@ export const ManualTyping: StoryFn = (args) => { selected={value} onSelect={(date) => setValue(date as Date | undefined)} parseDate={parseEstonianDate} - placeholder="pp.kk.aaaa" - label="Kuupäev" + placeholder="dd.mm.yyyy" + label="Date" required /> ); diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index ee2dc1e07..30ccc5ddb 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -28,8 +28,14 @@ import styles from './date-field.module.scss'; export type DateFieldMode = 'single' | 'multiple' | 'range'; export type CalendarView = 'days' | 'months' | 'years'; export type DateFieldOpenBehavior = 'input' | 'button'; +type DateTextFieldProps = Omit; +type DateMultiValueFieldProps = Omit; export interface DateFieldProps extends Omit { + /** + * Unique identifier for the date field. + */ + id: string; /** * Field label. Required for accessibility. */ @@ -182,10 +188,11 @@ export interface DateFieldProps extends Omit = ({ + id, mode = 'single', label, selected, @@ -341,7 +348,7 @@ export const DateField: React.FC = ({ {mode === 'multiple' ? ( = ({ ) : ( = ({ aria-expanded={open} onChange={(val) => handleInputChange(val)} required={required} - className={styles['tedi-date-field__textfield']} + className={cn(styles['tedi-date-field__textfield'], { + [styles['tedi-date-field__textfield--disabled']]: inputProps?.disabled, + })} /> )}
From 664a254f96bcd0b6b8667276000e49e6b2a30078 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:33:55 +0200 Subject: [PATCH 13/77] fix(dropdown): focused item indicator fix, fix stories #94 --- .../dropdown-item/dropdown-item.module.scss | 2 +- .../dropdown/dropdown-item/dropdown-item.tsx | 5 +++-- .../overlays/dropdown/dropdown.module.scss | 3 ++- .../overlays/dropdown/dropdown.stories.tsx | 22 +++++++++---------- .../components/overlays/dropdown/dropdown.tsx | 17 ++++++++++++-- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss index 43ffab84e..d4dc6e289 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -110,7 +110,7 @@ &:not(.tedi-dropdown__item--disabled) { cursor: pointer; outline: 0; - box-shadow: 0 0 0 1px var(--dropdown-item-default-background), 0 0 0 3px var(--general-surface-selected); + box-shadow: 0 0 0 1px var(--TEDI-neutral-100), 0 0 0 3px var(--general-surface-selected); } } diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 2db808e5a..0bbb2879b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -149,12 +149,13 @@ export const DropdownItem = ({ if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( 'input[type="checkbox"], input[type="radio"]' ) as HTMLInputElement | null; - input?.click(); + if (input) input.click(); + else onClick?.(e); + if (!asChild && closeOnSelect) setOpen(false); } }, style: getCssVars(indent), diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 1e877da7e..2c9edba93 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -5,7 +5,8 @@ display: flex; flex-direction: column; width: var(--dropdown-min-width, 10rem); - overflow: hidden; + + // overflow: hidden; pointer-events: none; background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 54147cb88..d5561a823 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -165,8 +165,8 @@ export const WithCheckbox: Story = { render: () => { const [cities, setCities] = React.useState([]); - const toggle = (value: string, checked?: boolean) => { - setCities((prev) => (checked ? [...prev, value] : prev.filter((v) => v !== value))); + const toggle = (value: string) => (_value: string, checked: boolean) => { + setCities((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); }; return ( @@ -184,7 +184,7 @@ export const WithCheckbox: Story = { label="Pärnu" value="parnu" checked={cities.includes('parnu')} - onChange={toggle} + onChange={toggle('parnu')} name="" /> @@ -195,7 +195,7 @@ export const WithCheckbox: Story = { label="Tartu" value="tartu" checked={cities.includes('tartu')} - onChange={toggle} + onChange={toggle('tartu')} name="" /> @@ -206,7 +206,7 @@ export const WithCheckbox: Story = { label="Tallinn" value="tallinn" checked={cities.includes('tallinn')} - onChange={toggle} + onChange={toggle('tallinn')} name="" /> @@ -227,12 +227,12 @@ export const WithIndentedItems: Story = { const noneChecked = selected.length === 0; const indeterminate = !allChecked && !noneChecked; - const toggleAll = (_: string, checked?: boolean) => { + const toggleAll = (_: string, checked: boolean) => { setSelected(checked ? allCities : []); }; - const toggleOne = (value: string, checked?: boolean) => { - setSelected((prev) => (checked ? [...prev, value as City] : prev.filter((v) => v !== value))); + const toggleOne = (value: City) => (_: string, checked: boolean) => { + setSelected((prev) => (checked ? [...prev.filter((v) => v !== value), value] : prev.filter((v) => v !== value))); }; return ( @@ -262,7 +262,7 @@ export const WithIndentedItems: Story = { label="Tallinn" value="tallinn" checked={selected.includes('tallinn')} - onChange={toggleOne} + onChange={toggleOne('tallinn')} name="" /> @@ -273,7 +273,7 @@ export const WithIndentedItems: Story = { label="Tartu" value="tartu" checked={selected.includes('tartu')} - onChange={toggleOne} + onChange={toggleOne('tartu')} name="" /> @@ -284,7 +284,7 @@ export const WithIndentedItems: Story = { label="Pärnu" value="parnu" checked={selected.includes('parnu')} - onChange={toggleOne} + onChange={toggleOne('parnu')} name="" /> diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 48d22bac9..23d6656d6 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -14,7 +14,7 @@ import { useRole, } from '@floating-ui/react'; import cn from 'classnames'; -import React from 'react'; +import React, { useEffect } from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -176,6 +176,16 @@ export const Dropdown = (props: DropdownProps) => { return container.getBoundingClientRect().width; }, [refs.reference.current]); + useEffect(() => { + if (open && listItemsRef.current.length > 0) { + const firstEnabledIndex = listItemsRef.current.findIndex((el) => el && !el.disabled); + if (firstEnabledIndex >= 0) { + setActiveIndex(firstEnabledIndex); + listItemsRef.current[firstEnabledIndex]?.focus(); + } + } + }, [open]); + return ( {children} @@ -211,10 +221,13 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (event.key === 'Tab') { + if (!modal && event.key === 'Tab') { setOpen(false); } }, + role: 'menu', + 'aria-orientation': 'vertical', + 'aria-activedescendant': activeIndex !== null ? `dropdown-item-${activeIndex}` : undefined, })} data-placement={placement} data-state={open ? 'open' : 'closed'} From d547f58ec4c686dda9fb7d1b906fcae659203413 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:42:19 +0200 Subject: [PATCH 14/77] fix(dropdown): fix tab targeting on choice items #94 --- .../dropdown/dropdown-item/dropdown-item.module.scss | 2 +- .../components/overlays/dropdown/dropdown.module.scss | 2 -- src/tedi/components/overlays/dropdown/dropdown.tsx | 9 +++++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss index d4dc6e289..4aaa71e88 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -110,7 +110,7 @@ &:not(.tedi-dropdown__item--disabled) { cursor: pointer; outline: 0; - box-shadow: 0 0 0 1px var(--TEDI-neutral-100), 0 0 0 3px var(--general-surface-selected); + box-shadow: 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--general-surface-selected); } } diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 2c9edba93..3e49dabd5 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -5,8 +5,6 @@ display: flex; flex-direction: column; width: var(--dropdown-min-width, 10rem); - - // overflow: hidden; pointer-events: none; background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 23d6656d6..f4b88b706 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -221,8 +221,13 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (!modal && event.key === 'Tab') { - setOpen(false); + if (event.key === 'Tab') { + const floatingEl = refs.floating.current; + const relatedTarget = (event as unknown as KeyboardEvent & { relatedTarget: EventTarget | null }) + .relatedTarget; + if (floatingEl && relatedTarget && !floatingEl.contains(relatedTarget as Node)) { + setOpen(false); + } } }, role: 'menu', From ca094c4d341610fd11264c0ec3c0da8847dc093c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:53:57 +0200 Subject: [PATCH 15/77] fix(dropdown): fix focus scrolling bug #94 --- .../components/overlays/dropdown/dropdown.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index f4b88b706..a71676e0d 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -14,7 +14,7 @@ import { useRole, } from '@floating-ui/react'; import cn from 'classnames'; -import React, { useEffect } from 'react'; +import React from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -176,16 +176,6 @@ export const Dropdown = (props: DropdownProps) => { return container.getBoundingClientRect().width; }, [refs.reference.current]); - useEffect(() => { - if (open && listItemsRef.current.length > 0) { - const firstEnabledIndex = listItemsRef.current.findIndex((el) => el && !el.disabled); - if (firstEnabledIndex >= 0) { - setActiveIndex(firstEnabledIndex); - listItemsRef.current[firstEnabledIndex]?.focus(); - } - } - }, [open]); - return ( {children} @@ -221,13 +211,8 @@ export const Dropdown = (props: DropdownProps) => { : width, }, onKeyDown(event) { - if (event.key === 'Tab') { - const floatingEl = refs.floating.current; - const relatedTarget = (event as unknown as KeyboardEvent & { relatedTarget: EventTarget | null }) - .relatedTarget; - if (floatingEl && relatedTarget && !floatingEl.contains(relatedTarget as Node)) { - setOpen(false); - } + if (!modal && event.key === 'Tab') { + setOpen(false); } }, role: 'menu', From a9483dc66adea4b8fa3f7f48ed6bf59dff3f9209 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:28:44 +0200 Subject: [PATCH 16/77] feat(date-field): fix month and year grid selections when calendarView is set #24 --- .../date-field-year-grid.spec.tsx | 19 ++++--- .../date-field-year-grid.tsx | 23 +++++--- .../components/form/date-field/date-field.tsx | 55 +++++++++++++++---- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx index eb96a6103..a283113d7 100644 --- a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx +++ b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx @@ -18,14 +18,14 @@ jest.mock('../../../../../providers/label-provider', () => ({ })); describe('YearGrid component', () => { - const mockOnYearChange = jest.fn(); - const mockOnBackToMonths = jest.fn(); + const mockOnSelectYear = jest.fn(); + const mockOnNavigate = jest.fn(); const currentMonth = new Date(2025, 6, 15); const defaultProps = { currentMonth, - onYearChange: mockOnYearChange, - onBackToMonths: mockOnBackToMonths, + onSelectYear: mockOnSelectYear, + onNavigate: mockOnNavigate, }; beforeEach(() => { @@ -56,7 +56,7 @@ describe('YearGrid component', () => { render(); const prevBtn = screen.getByRole('button', { name: 'Eelmine periood' }); await user.click(prevBtn); - expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2004, 0)); + expect(mockOnNavigate).toHaveBeenCalledWith(new Date(2004, 0)); }); it('calls onYearChange with next 12-year range when next clicked', async () => { @@ -64,17 +64,18 @@ describe('YearGrid component', () => { render(); const nextBtn = screen.getByRole('button', { name: 'Järgmine periood' }); await user.click(nextBtn); - expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2028, 0)); + expect(mockOnNavigate).toHaveBeenCalledWith(new Date(2028, 0)); }); - it('calls onYearChange and onBackToMonths when year selected', async () => { + it('calls onSelectYear when a year is selected', async () => { const user = userEvent.setup(); render(); const year2023 = screen.getByRole('button', { name: '2023' }); await user.click(year2023); - expect(mockOnYearChange).toHaveBeenCalledWith(new Date(2023, currentMonth.getMonth())); - expect(mockOnBackToMonths).toHaveBeenCalled(); + expect(mockOnSelectYear).toHaveBeenCalledWith(new Date(2023, currentMonth.getMonth(), 1)); + + expect(mockOnNavigate).not.toHaveBeenCalled(); }); }); diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx index 160822bcb..61560ce63 100644 --- a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx +++ b/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx @@ -7,16 +7,22 @@ export interface YearGridProps { */ currentMonth: Date; /* - * Callback when a year is selected from the grid. Receives a Date object with the selected year and current month. + * Callback fired when a year is selected from the grid. + * Receives a Date object representing the selected year. + * The month and day values may be normalized by the parent component + * depending on the active calendar view (e.g. January 1st in year-only mode). */ - onYearChange: (date: Date) => void; + onSelectYear: (date: Date) => void; /* - * Callback for navigating to a different year range in the grid. Receives a Date object with the target year and current month. + * Callback fired when navigating between year ranges in the grid + * (e.g. when clicking the previous or next buttons). + * Receives a Date object representing the first year of the + * newly displayed range. */ - onBackToMonths: () => void; + onNavigate: (date: Date) => void; } -export const YearGrid = ({ currentMonth, onYearChange, onBackToMonths }: YearGridProps) => { +export const YearGrid = ({ currentMonth, onSelectYear, onNavigate }: YearGridProps) => { const { getLabel } = useLabels(); const currentYear = currentMonth.getFullYear(); const startYear = Math.floor(currentYear / 12) * 12; @@ -37,12 +43,11 @@ export const YearGrid = ({ currentMonth, onYearChange, onBackToMonths }: YearGri headerLabel={`${startYear} – ${startYear + 11}`} prevAriaLabel={getLabel('pickers.previousYear')} nextAriaLabel={getLabel('pickers.nextYear')} - onPrev={() => onYearChange(new Date(startYear - 12, 0))} - onNext={() => onYearChange(new Date(startYear + 12, 0))} + onPrev={() => onNavigate(new Date(startYear - 12, 0))} + onNext={() => onNavigate(new Date(startYear + 12, 0))} items={years} onSelect={(year) => { - onYearChange(new Date(year, currentMonth.getMonth())); - onBackToMonths(); + onSelectYear(new Date(year, currentMonth.getMonth(), 1)); }} /> ); diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index 30ccc5ddb..069cc337f 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -252,15 +252,26 @@ export const DateField: React.FC = ({ } }, [selected, isControlled]); - const dateFormatter = useMemo( - () => - new Intl.DateTimeFormat(localeCode, { - day: '2-digit', + const dateFormatter = useMemo(() => { + if (calendarView === 'years') { + return new Intl.DateTimeFormat(localeCode, { + year: 'numeric', + }); + } + + if (calendarView === 'months') { + return new Intl.DateTimeFormat(localeCode, { month: '2-digit', year: 'numeric', - }), - [localeCode] - ); + }); + } + + return new Intl.DateTimeFormat(localeCode, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + }, [localeCode, calendarView]); const floating = useFloating({ open, @@ -299,6 +310,12 @@ export const DateField: React.FC = ({ return ''; }; + const applyValue = (date: Date) => { + if (!isControlled) setInternalValue(date); + onSelect?.(date, date as UnknownType, {}, {} as UnknownType); + if (shouldCloseOnSelect) setOpen(false); + }; + const formattedDates = mode === 'multiple' && Array.isArray(value) ? value.map((d) => (formatDate ? formatDate(d) : defaultFormatter(d))) @@ -403,21 +420,35 @@ export const DateField: React.FC = ({ {view === 'months' && getLabel('pickers.monthSelection')}
- {view === 'years' && ( + {(view === 'years' || calendarView === 'years') && ( setView('months')} + onNavigate={setCurrentMonth} + onSelectYear={(date) => { + setCurrentMonth(date); + + if (calendarView === 'years') { + const normalized = new Date(date.getFullYear(), 0, 1); + applyValue(normalized); + } else { + setView('months'); + } + }} /> )} - {view === 'months' && ( + {(view === 'months' || calendarView === 'months') && ( { setCurrentMonth(date); - setView('days'); + + if (calendarView === 'months') { + applyValue(date); + } else { + setView('days'); + } }} /> )} From 8b0f3de8818fc2c2811d596d31120363d0cfa907 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:35:19 +0200 Subject: [PATCH 17/77] fix(date-field): today button margin issue fix #24 --- src/tedi/components/form/date-field/date-field.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 1fc2b4d5c..6294b289a 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -70,7 +70,6 @@ display: block; width: 100%; height: 100%; - margin-left: -1px; overflow: hidden; font-size: var(--body-regular-size); font-weight: 400; @@ -92,6 +91,7 @@ &__today { button { + margin-left: -1px; border: 1px solid var(--form-datepicker-today-border); border-radius: 50%; } From 4c340250009d3edebaa8415472c200b7aa372d84 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:10:34 +0200 Subject: [PATCH 18/77] fix(dropdown): design review fixes #94 --- .../dropdown-item/dropdown-item.module.scss | 8 + .../overlays/dropdown/dropdown.stories.tsx | 177 +++++++----------- .../components/overlays/dropdown/dropdown.tsx | 3 +- 3 files changed, 78 insertions(+), 110 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss index 4aaa71e88..2862bebc9 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.module.scss @@ -19,6 +19,14 @@ border-radius: 0; transition: all 0.2s ease; + &:first-child { + border-radius: var(--form-select-area-radius) var(--form-select-area-radius) 0 0; + } + + &:last-child { + border-radius: 0 0 var(--form-select-area-radius) var(--form-select-area-radius); + } + &--active { color: var(--dropdown-item-active-text); background-color: var(--dropdown-item-active-background); diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index d5561a823..8c1fc759e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -296,13 +296,19 @@ export const WithIndentedItems: Story = { export const WithRadio: Story = { render: () => { - const [city, setCity] = React.useState('tallinn'); + const [city, setCity] = React.useState<'tallinn' | 'tartu' | 'parnu'>('tallinn'); + + const cities = { + tallinn: 'Tallinn', + tartu: 'Tartu', + parnu: 'Pärnu', + }; return ( @@ -314,7 +320,7 @@ export const WithRadio: Story = { value="tallinn" label="Tallinn" checked={city === 'tallinn'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -325,7 +331,7 @@ export const WithRadio: Story = { value="tartu" label="Tartu" checked={city === 'tartu'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -336,7 +342,7 @@ export const WithRadio: Story = { value="parnu" label="Pärnu" checked={city === 'parnu'} - onChange={(value) => setCity(value)} + onChange={(value) => setCity(value as City)} /> @@ -529,110 +535,63 @@ export const WithSeparatorAndOpensRight: Story = { }; export const CustomContent: Story = { - render: () => ( - - - - - - - - - - - Lauri Lepp - - 49504080254 - - - - - Mart Mardivere - - 39504080254 - - - - - Madis Mets - - 39504080254 - - - - - Kalle Kaasik - - 39504080254 - - - - - Pille Porgand - - 49504080254 - - - - - Kert Kasemets - - 39504080254 - - - - - ), + render: () => { + const [query, setQuery] = React.useState(''); + + const representatives = [ + { name: 'Lauri Lepp', code: '49504080254' }, + { name: 'Mart Mardivere', code: '39504080254' }, + { name: 'Madis Mets', code: '39504080254' }, + { name: 'Kalle Kaasik', code: '39504080254' }, + { name: 'Pille Porgand', code: '49504080254' }, + { name: 'Kert Kasemets', code: '39504080254' }, + ]; + + const filtered = + query.trim() === '' + ? representatives + : representatives.filter( + (rep) => rep.name.toLowerCase().includes(query.toLowerCase()) || rep.code.includes(query) + ); + + return ( + + + + + + + + setQuery(value)} /> + + + {filtered.map((rep, i) => { + const index = i + 1; + + return ( + + + {rep.name} + + {rep.code} + + + ); + })} + + + ); + }, }; export const Tree: Story = { diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index a71676e0d..dcefd512d 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -3,6 +3,7 @@ import { flip, FloatingFocusManager, FloatingPortal, + offset, Placement, shift, useClick, @@ -132,7 +133,7 @@ export const Dropdown = (props: DropdownProps) => { open, placement, onOpenChange: setOpen, - middleware: [flip(), shift()], + middleware: [offset(4), flip(), shift()], whileElementsMounted: autoUpdate, }); From 9ca825045303dc9bf586a65a4521641807190fe2 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:53:08 +0200 Subject: [PATCH 19/77] fix(dropdown): checkbox/radio tab targeting fix #94 --- .../overlays/dropdown/dropdown-item/dropdown-item.tsx | 8 +++++++- src/tedi/components/overlays/dropdown/dropdown.spec.tsx | 8 -------- src/tedi/components/overlays/dropdown/dropdown.tsx | 5 ----- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 0bbb2879b..5ac9f3249 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -104,6 +104,12 @@ export const DropdownItem = ({ const itemProps = isInteractive ? { + ref(node: HTMLElement | null) { + if (typeof index === 'number') { + listItemsRef.current[index] = node as HTMLButtonElement | null; + } + }, + tabIndex: activeIndex === index ? 0 : -1, // ← crucial className: cn(styles['tedi-dropdown__item'], { [styles['tedi-dropdown__item--indent']]: indent, }), @@ -111,7 +117,7 @@ export const DropdownItem = ({ } : getItemProps({ ref(node: HTMLElement) { - if (!asChild && typeof index === 'number') { + if (typeof index === 'number') { listItemsRef.current[index] = node as HTMLButtonElement; } }, diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 1a65d9098..2c773cb22 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -52,14 +52,6 @@ describe('Dropdown component', () => { expect(screen.queryByText('Item')).not.toBeInTheDocument(); }); - it('closes dropdown on Tab key press', () => { - renderDropdown({ children: Open menu }, Item); - fireEvent.click(screen.getByText('Open menu')); - const dropdown = screen.getByRole('menu'); - fireEvent.keyDown(dropdown, { key: 'Tab' }); - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - }); - it('renders multiple items', () => { renderDropdown( { children: Open menu }, diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index dcefd512d..363943e01 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -211,11 +211,6 @@ export const Dropdown = (props: DropdownProps) => { ? undefined : width, }, - onKeyDown(event) { - if (!modal && event.key === 'Tab') { - setOpen(false); - } - }, role: 'menu', 'aria-orientation': 'vertical', 'aria-activedescendant': activeIndex !== null ? `dropdown-item-${activeIndex}` : undefined, From ad8fb494719e71cfc900598b1b33b990f69b517d Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:01:38 +0200 Subject: [PATCH 20/77] fix(dropdown): add deprecated badge to Community component, update Figma links #94 --- src/community/components/dropdown/dropdown.stories.tsx | 5 +++++ src/tedi/components/overlays/dropdown/dropdown.stories.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/community/components/dropdown/dropdown.stories.tsx b/src/community/components/dropdown/dropdown.stories.tsx index aeaa016c5..96e880f46 100644 --- a/src/community/components/dropdown/dropdown.stories.tsx +++ b/src/community/components/dropdown/dropdown.stories.tsx @@ -5,6 +5,11 @@ import { Dropdown, DropdownItem, DropdownProps } from './dropdown'; export default { component: Dropdown, title: 'Community/Dropdown', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, } as Meta; const items: DropdownItem[] = [ diff --git a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx index 8c1fc759e..a9f38438b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.stories.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.stories.tsx @@ -12,7 +12,8 @@ import Separator from '../../misc/separator/separator'; import { Dropdown } from './dropdown'; /** - * Figma ↗
+ * Dropdown Figma ↗
+ * DropdownItem Figma ↗
* Zeroheight ↗ */ From a528d7ca9841dca4397d5eb3391ac5a9c793f55c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:39:00 +0200 Subject: [PATCH 21/77] fix(dropdown): add more test coverage #94 --- package-lock.json | 12 +- package.json | 1 + .../dropdown/dropdown-context.spec.tsx | 134 ++++++++++++++++++ .../dropdown-item/dropdown-item.spec.tsx | 76 +++++++++- .../dropdown-separator.spec.tsx | 38 +++++ .../overlays/dropdown/dropdown.spec.tsx | 89 +++++++++++- 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx create mode 100644 src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx diff --git a/package-lock.json b/package-lock.json index 6268efb2f..871217ebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.3.4", + "baseline-browser-mapping": "^2.10.0", "chromatic": "^13.3.4", "cross-env": "^7.0.3", "dompurify": "^3.3.0", @@ -10204,13 +10205,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", - "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/before-after-hook": { diff --git a/package.json b/package.json index 33819bf54..7f320534d 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.3.4", + "baseline-browser-mapping": "^2.10.0", "chromatic": "^13.3.4", "cross-env": "^7.0.3", "dompurify": "^3.3.0", diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx new file mode 100644 index 000000000..da24ae3bc --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx @@ -0,0 +1,134 @@ +import { render, renderHook } from '@testing-library/react'; +import React from 'react'; + +import { DropdownContext, type DropdownContextValue, useDropdownContext } from './dropdown-context'; + +jest.mock('@floating-ui/react', () => ({ + useFloating: jest.fn(() => ({ + refs: { + setReference: jest.fn(), + setFloating: jest.fn(), + reference: { current: null }, + floating: { current: null }, + }, + x: 0, + y: 0, + strategy: 'absolute', + placement: 'bottom-start', + })), + useInteractions: jest.fn(() => ({ + getReferenceProps: jest.fn((userProps) => ({ ...userProps })), + getFloatingProps: jest.fn((userProps) => ({ ...userProps })), + getItemProps: jest.fn((userProps) => ({ ...userProps })), + })), +})); + +describe('DropdownContext + useDropdownContext', () => { + const mockSetOpen = jest.fn(); + const mockSetActiveIndex = jest.fn(); + const mockSetContent = jest.fn(); + + const mockContextValue: DropdownContextValue = { + open: true, + setOpen: mockSetOpen, + refs: { + setReference: jest.fn(), + setFloating: jest.fn(), + reference: { current: null }, + floating: { current: null }, + domReference: { current: null }, + setPositionReference: jest.fn(), + }, + getReferenceProps: jest.fn(), + getFloatingProps: jest.fn(), + getItemProps: jest.fn(), + listItemsRef: { current: [] }, + activeIndex: 2, + setActiveIndex: mockSetActiveIndex, + placement: 'bottom-start', + content:
Mock content
, + setContent: mockSetContent, + divided: true, + variant: 'tree', + }; + + const wrapperWithContext = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('throws error when useDropdownContext is used outside DropdownContext', () => { + expect(() => { + renderHook(() => useDropdownContext()); + }).toThrow('Dropdown components must be used within '); + }); + + it('returns context value when used inside provider', () => { + const { result } = renderHook(() => useDropdownContext(), { + wrapper: wrapperWithContext, + }); + + expect(result.current).toEqual(mockContextValue); + expect(result.current?.open).toBe(true); + expect(result.current?.activeIndex).toBe(2); + expect(result.current?.variant).toBe('tree'); + expect(result.current?.content).toEqual(
Mock content
); + }); + + it('does not throw when context is provided (smoke test)', () => { + const TestConsumer = () => { + const ctx = useDropdownContext(); + return
{ctx?.open ? 'Open' : 'Closed'}
; + }; + + const { getByTestId } = render(, { + wrapper: wrapperWithContext, + }); + + expect(getByTestId('consumer')).toHaveTextContent('Open'); + }); + + it('context value has all expected keys', () => { + const { result } = renderHook(() => useDropdownContext(), { + wrapper: wrapperWithContext, + }); + + const ctx = result.current; + expect(ctx).toHaveProperty('open'); + expect(ctx).toHaveProperty('setOpen'); + expect(ctx).toHaveProperty('refs'); + expect(ctx).toHaveProperty('getReferenceProps'); + expect(ctx).toHaveProperty('getFloatingProps'); + expect(ctx).toHaveProperty('getItemProps'); + expect(ctx).toHaveProperty('listItemsRef'); + expect(ctx).toHaveProperty('activeIndex'); + expect(ctx).toHaveProperty('setActiveIndex'); + expect(ctx).toHaveProperty('placement'); + expect(ctx).toHaveProperty('content'); + expect(ctx).toHaveProperty('setContent'); + expect(ctx).toHaveProperty('divided'); + expect(ctx).toHaveProperty('variant'); + }); + + it('context value shape matches snapshot', () => { + const { result } = renderHook(() => useDropdownContext(), { + wrapper: wrapperWithContext, + }); + + const { + setOpen, + setActiveIndex, + setContent, + refs, + getReferenceProps, + getFloatingProps, + getItemProps, + listItemsRef, + ...serializable + } = result.current ?? {}; + expect(serializable).toMatchSnapshot(); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx index 34ff81a7f..5fd3db152 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { DropdownItem } from './dropdown-item'; @@ -75,4 +75,78 @@ describe('DropdownItem', () => { expect(getByText('Child').tagName).toBe('SPAN'); }); + + it('renders children directly inside div when asChild=true', () => { + render( + + + + + ); + + const div = screen.getByLabelText('Custom label').closest('div'); + expect(div).toBeInTheDocument(); + expect(div?.tagName).toBe('DIV'); + }); + + it('clicks inner checkbox/radio when wrapper is clicked (closeOnSelect=false)', () => { + const handleChange = jest.fn(); + + render( + + + Label + + ); + + fireEvent.click(screen.getByTestId('inner-input').parentElement!); + expect(handleChange).toHaveBeenCalledTimes(0); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('radio')); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + it('ignores events when disabled (even asChild)', () => { + const handleChange = jest.fn(); + + const { getByText } = render( + + + Disabled item + + ); + + fireEvent.click(getByText('Disabled item')); + expect(handleChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(getByText('Disabled item').closest('div')!, { key: 'Enter' }); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('applies indentation styles when indent is provided', () => { + render( + + Indented + + ); + + const item = screen.getByText('Indented').closest('button'); + expect(item).toHaveStyle('--dropdown-indent: 2rem'); + expect(item).toHaveStyle('--dropdown-indent-level: 2'); + }); + + it('does not register ref when index is undefined', () => { + expect(() => { + render( No index ); + }).not.toThrow(); + }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx new file mode 100644 index 000000000..da3fed201 --- /dev/null +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx @@ -0,0 +1,38 @@ +// src/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx + +import { render, screen } from '@testing-library/react'; + +import { DropdownSeparator } from './dropdown-separator'; + +jest.mock('../../../misc/separator/separator', () => ({ + __esModule: true, + default: jest.fn(({ axis, className, ...props }) => ( +
+ )), +})); + +describe('DropdownSeparator', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('separator')).toBeInTheDocument(); + }); + + it('passes axis="horizontal" to Separator', () => { + render(); + + const separator = screen.getByTestId('separator'); + expect(separator).toHaveAttribute('data-axis', 'horizontal'); + }); + + it('renders a semantic separator (hr)', () => { + render(); + + const separator = screen.getByTestId('separator'); + expect(separator.tagName).toBe('HR'); + }); + + it('matches snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 2c773cb22..0cac7c01a 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -1,7 +1,10 @@ +import * as FloatingUI from '@floating-ui/react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { ComponentProps } from 'react'; +import React, { ComponentProps } from 'react'; +import { UnknownType } from '../../../types/commonTypes'; import { Dropdown, DropdownProps } from './dropdown'; +import styles from './dropdown.module.scss'; jest.mock('../../../providers/label-provider', () => ({ useLabels: () => ({ @@ -92,4 +95,88 @@ describe('Dropdown component', () => { item.focus(); expect(document.activeElement).toBe(item); }); + + it('respects controlled open prop', () => { + const { rerender } = renderDropdown( + { children: Trigger }, + Item, + { open: false } + ); + + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + + rerender( + + +

Trigger

+
+ + Item + +
+ ); + + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('calls onOpenChange in controlled mode but does not change internal state', () => { + const onOpenChange = jest.fn(); + + renderDropdown({ children: Trigger }, Item, { + open: false, + onOpenChange, + }); + + fireEvent.click(screen.getByText('Trigger')); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.queryByText('Item')).not.toBeInTheDocument(); + }); + + it('does not set a valid width when reference is not available yet', () => { + jest.spyOn(FloatingUI, 'useFloating').mockReturnValue({ + refs: { + reference: { current: null }, + floating: { current: null }, + setReference: jest.fn(), + setFloating: jest.fn(), + }, + x: 0, + y: 0, + strategy: 'absolute', + placement: 'bottom-start', + middlewareData: {}, + } as UnknownType); + + renderDropdown({ children: }, Item, { + width: 'trigger', + }); + + fireEvent.click(screen.getByText('Trigger')); + const dropdown = screen.getByRole('menu'); + expect(dropdown).toHaveStyle({ width: '0px' }); + expect(dropdown.style.width).toBe('0px'); + expect(parseFloat(getComputedStyle(dropdown).width)).toBe(0); + }); + + it('applies tree variant class when variant="tree"', () => { + renderDropdown({ children: Menu }, Item, { variant: 'tree' }); + + fireEvent.click(screen.getByText('Menu')); + const dropdownContainer = screen.getByRole('menu'); + expect(dropdownContainer).toHaveClass(styles['tedi-dropdown--tree']); + }); + + it('sets aria-activedescendant when activeIndex is set', () => { + renderDropdown( + { children: Trigger }, + <> + First + Second + , + {} + ); + + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-0'); + }); }); From 31df95d3943ef57cd86da1384e647d065c09eb38 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:32 +0200 Subject: [PATCH 22/77] fix(dropdown): update tests #94 --- .../dropdown/dropdown-context.spec.tsx | 19 ------------------- .../dropdown-separator.spec.tsx | 13 ++++--------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx index da24ae3bc..359ecd9bd 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-context.spec.tsx @@ -112,23 +112,4 @@ describe('DropdownContext + useDropdownContext', () => { expect(ctx).toHaveProperty('divided'); expect(ctx).toHaveProperty('variant'); }); - - it('context value shape matches snapshot', () => { - const { result } = renderHook(() => useDropdownContext(), { - wrapper: wrapperWithContext, - }); - - const { - setOpen, - setActiveIndex, - setContent, - refs, - getReferenceProps, - getFloatingProps, - getItemProps, - listItemsRef, - ...serializable - } = result.current ?? {}; - expect(serializable).toMatchSnapshot(); - }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx index da3fed201..09368f7e8 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-separator/dropdown-separator.spec.tsx @@ -17,11 +17,11 @@ describe('DropdownSeparator', () => { expect(screen.getByTestId('separator')).toBeInTheDocument(); }); - it('passes axis="horizontal" to Separator', () => { + it('renders a horizontal separator', () => { render(); - - const separator = screen.getByTestId('separator'); - expect(separator).toHaveAttribute('data-axis', 'horizontal'); + const sep = screen.getByTestId('separator'); + expect(sep.tagName).toBe('HR'); + expect(sep).toHaveAttribute('data-axis', 'horizontal'); }); it('renders a semantic separator (hr)', () => { @@ -30,9 +30,4 @@ describe('DropdownSeparator', () => { const separator = screen.getByTestId('separator'); expect(separator.tagName).toBe('HR'); }); - - it('matches snapshot', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); }); From 061ac3e276105a6c2e85982245b46bf1def36b0e Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:21:46 +0200 Subject: [PATCH 23/77] fix(dropdown): improve test coverage #94 --- .../dropdown-item/dropdown-item.spec.tsx | 172 +++++++++++++----- .../dropdown/dropdown-item/dropdown-item.tsx | 139 +++++++------- .../overlays/dropdown/dropdown.spec.tsx | 62 +++++++ 3 files changed, 256 insertions(+), 117 deletions(-) diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx index 5fd3db152..e1ee3b8f8 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.spec.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import { UnknownType } from '../../../../types/commonTypes'; import { DropdownItem } from './dropdown-item'; const mockSetOpen = jest.fn(); @@ -7,10 +8,12 @@ const mockOnClick = jest.fn(); jest.mock('../dropdown-context', () => ({ useDropdownContext: () => ({ - getItemProps: (props: never) => props, + getItemProps: (props: UnknownType) => props, listItemsRef: { current: [] }, setOpen: mockSetOpen, activeIndex: 0, + divided: false, + variant: 'default', }), })); @@ -26,30 +29,17 @@ describe('DropdownItem', () => { Item ); - fireEvent.click(getByText('Item')); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); - it('does not close dropdown when closeOnSelect=false', () => { - const { getByText } = render( - - Item - - ); - - fireEvent.click(getByText('Item')); - expect(mockSetOpen).not.toHaveBeenCalled(); - }); - it('does not call onClick when disabled', () => { const { getByText } = render( Item ); - fireEvent.click(getByText('Item')); expect(mockOnClick).not.toHaveBeenCalled(); }); @@ -60,8 +50,8 @@ describe('DropdownItem', () => { Item ); - - fireEvent.click(getByText('Item')); + const item = getByText('Item'); + fireEvent.keyDown(item, { key: 'Enter' }); expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).toHaveBeenCalledWith(false); }); @@ -72,7 +62,6 @@ describe('DropdownItem', () => { Child ); - expect(getByText('Child').tagName).toBe('SPAN'); }); @@ -83,70 +72,157 @@ describe('DropdownItem', () => { ); - const div = screen.getByLabelText('Custom label').closest('div'); expect(div).toBeInTheDocument(); expect(div?.tagName).toBe('DIV'); }); - it('clicks inner checkbox/radio when wrapper is clicked (closeOnSelect=false)', () => { - const handleChange = jest.fn(); + it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => { + render( + + + + ); + fireEvent.click(screen.getByTestId('radio')); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + it('clicks inner input when wrapper is clicked (asChild + closeOnSelect=false)', () => { + const handleChange = jest.fn(); render( - - Label + + Label text ); - - fireEvent.click(screen.getByTestId('inner-input').parentElement!); - expect(handleChange).toHaveBeenCalledTimes(0); + const wrapper = screen.getByText('Label text').closest('div')!; + fireEvent.click(wrapper); + expect(handleChange).toHaveBeenCalledTimes(1); expect(mockSetOpen).not.toHaveBeenCalled(); }); - it('does NOT close dropdown when clicking inner input and closeOnSelect=false', () => { + it('applies indentation styles when indent is provided', () => { render( - - + + Indented ); + const item = screen.getByText('Indented').closest('button'); + expect(item).toHaveStyle('--dropdown-indent: 2rem'); + expect(item).toHaveStyle('--dropdown-indent-level: 2'); + }); - fireEvent.click(screen.getByTestId('radio')); + it('does not register ref when index is undefined', () => { + expect(() => render(No index)).not.toThrow(); + }); + + it('does not close dropdown when closeOnSelect=false (non-asChild)', () => { + render( + + Item + + ); + fireEvent.click(screen.getByText('Item')); + expect(mockOnClick).toHaveBeenCalled(); expect(mockSetOpen).not.toHaveBeenCalled(); }); - it('ignores events when disabled (even asChild)', () => { + it('clicks inner checkbox and does not close when asChild + closeOnSelect=false', () => { const handleChange = jest.fn(); + render( + + + Label text + + ); + const wrapper = screen.getByText('Label text').closest('div')!; + fireEvent.click(wrapper); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); - const { getByText } = render( - - - Disabled item + it('clicks inner radio on Enter key when asChild', () => { + const handleChange = jest.fn(); + render( + + + Radio label ); + const wrapper = screen.getByText('Radio label').closest('div')!; + fireEvent.keyDown(wrapper, { key: 'Enter' }); + expect(handleChange).toHaveBeenCalledTimes(1); + }); - fireEvent.click(getByText('Disabled item')); - expect(handleChange).not.toHaveBeenCalled(); + it('triggers handleClick on non-asChild', () => { + const handle = jest.fn(); + render( + + Clickable + + ); - fireEvent.keyDown(getByText('Disabled item').closest('div')!, { key: 'Enter' }); - expect(handleChange).not.toHaveBeenCalled(); + const item = screen.getByText('Clickable'); + fireEvent.click(item); // triggers handleClick + expect(handle).toHaveBeenCalledTimes(1); }); - it('applies indentation styles when indent is provided', () => { + it('triggers handleKeyDown on space key', () => { + const handle = jest.fn(); render( - - Indented + + SpaceItem ); - const item = screen.getByText('Indented').closest('button'); - expect(item).toHaveStyle('--dropdown-indent: 2rem'); - expect(item).toHaveStyle('--dropdown-indent-level: 2'); + const item = screen.getByText('SpaceItem'); + fireEvent.keyDown(item, { key: ' ' }); // trigger handleKeyDown for space + expect(handle).toHaveBeenCalledTimes(1); }); - it('does not register ref when index is undefined', () => { - expect(() => { - render( No index ); - }).not.toThrow(); + it('triggers inner input click when enabled (asChild)', () => { + const inputChange = jest.fn(); + render( + + + Enabled input + + ); + + const wrapper = screen.getByText('Enabled input').closest('div')!; + fireEvent.click(wrapper); + fireEvent.keyDown(wrapper, { key: 'Enter' }); + + expect(inputChange).toHaveBeenCalledTimes(2); + }); + + it('calls onClick when asChild=true without inner input', () => { + const handle = jest.fn(); + + render( + + Plain child + + ); + + const wrapper = screen.getByText('Plain child').closest('div')!; + fireEvent.click(wrapper); + + expect(handle).toHaveBeenCalledTimes(1); + }); + + it('calls onClick on Enter when asChild=true and no input', () => { + const handle = jest.fn(); + + render( + + Key child + + ); + + const wrapper = screen.getByText('Key child').closest('div')!; + fireEvent.keyDown(wrapper, { key: 'Enter' }); + + expect(handle).toHaveBeenCalledTimes(1); }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx index 5ac9f3249..7d526137b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-item/dropdown-item.tsx @@ -89,83 +89,84 @@ export const DropdownItem = ({ const { getItemProps, listItemsRef, setOpen, activeIndex, divided, variant } = useDropdownContext(); const Component = asChild ? 'div' : 'button'; - const isInteractive = asChild && closeOnSelect === false; const getCssVars = (indent?: number): React.CSSProperties => { - const cssVars: React.CSSProperties = {}; + if (typeof indent !== 'number') return {}; + return { + '--dropdown-indent-level': indent, + '--dropdown-indent': `${indent}rem`, + } as React.CSSProperties; + }; + + const handleClick = (e: React.MouseEvent) => { + if (disabled) return; // stop everything - if (typeof indent === 'number') { - cssVars['--dropdown-indent-level'] = indent; - cssVars['--dropdown-indent'] = `${indent}rem`; + // only trigger inner inputs if not disabled + const input = (e.currentTarget as HTMLElement).querySelector( + 'input[type="checkbox"], input[type="radio"]' + ); + if (input) { + input.click(); + return; } - return cssVars; + if (!asChild) { + onClick?.(e); + if (closeOnSelect) setOpen(false); + } else { + onClick?.(e); + } }; - const itemProps = isInteractive - ? { - ref(node: HTMLElement | null) { - if (typeof index === 'number') { - listItemsRef.current[index] = node as HTMLButtonElement | null; - } - }, - tabIndex: activeIndex === index ? 0 : -1, // ← crucial - className: cn(styles['tedi-dropdown__item'], { - [styles['tedi-dropdown__item--indent']]: indent, - }), - style: getCssVars(indent), + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + const input = (e.currentTarget as HTMLElement).querySelector( + 'input[type="checkbox"], input[type="radio"]' + ); + + if (input) { + input.click(); + } else { + onClick?.(e); } - : getItemProps({ - ref(node: HTMLElement) { - if (typeof index === 'number') { - listItemsRef.current[index] = node as HTMLButtonElement; - } - }, - role: 'menuitem', - disabled: !asChild ? disabled : undefined, - tabIndex: activeIndex === index ? 0 : -1, - className: cn(styles['tedi-dropdown__item'], { - [styles['tedi-dropdown__item--active']]: active, - [styles['tedi-dropdown__item--disabled']]: disabled, - [styles['tedi-dropdown__item--divided']]: divided, - [styles['tedi-dropdown__item--indent']]: indent, - [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, - [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, - className, - }), - onClick(e) { - if (disabled) return; - - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"], input[type="radio"]' - ) as HTMLInputElement | null; - - if (input) { - input.click(); - return; - } - - if (!asChild) { - onClick?.(e); - if (closeOnSelect) setOpen(false); - } - }, - onKeyDown(e) { - if (disabled) return; - - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - const input = (e.currentTarget as HTMLElement).querySelector( - 'input[type="checkbox"], input[type="radio"]' - ) as HTMLInputElement | null; - - if (input) input.click(); - else onClick?.(e); - if (!asChild && closeOnSelect) setOpen(false); - } - }, - style: getCssVars(indent), - }); + + if (!asChild && closeOnSelect) setOpen(false); + } + }; + + const baseProps = { + ref(node: HTMLElement | null) { + if (typeof index === 'number') { + listItemsRef.current[index] = node as HTMLButtonElement | null; + } + }, + tabIndex: activeIndex === index ? 0 : -1, + className: cn(styles['tedi-dropdown__item'], { + [styles['tedi-dropdown__item--active']]: active, + [styles['tedi-dropdown__item--disabled']]: disabled, + [styles['tedi-dropdown__item--divided']]: divided, + [styles['tedi-dropdown__item--indent']]: indent, + [styles['tedi-dropdown__item--tree-item']]: variant === 'tree' && indent, + [styles['tedi-dropdown__item--tree-parent']]: variant === 'tree' && isParent, + className, + }), + style: getCssVars(indent), + onClick: handleClick, + onKeyDown: handleKeyDown, + }; + + const itemProps = + asChild && closeOnSelect === false + ? baseProps + : getItemProps({ + role: 'menuitem', + disabled: !asChild ? disabled : undefined, + ...baseProps, + }); return {children}; }; diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 0cac7c01a..5f08e944e 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -179,4 +179,66 @@ describe('Dropdown component', () => { fireEvent.click(screen.getByText('Trigger')); expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-0'); }); + + it('applies pixel width when width is a number', () => { + renderDropdown({ children: Trigger }, Item, { + width: 300, + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '300px' }); + }); + + it('applies custom string width', () => { + renderDropdown({ children: Trigger }, Item, { + width: '16rem', + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '16rem' }); + }); + + it('does not apply width when width="auto"', () => { + renderDropdown({ children: Trigger }, Item, { + width: 'auto', + }); + + fireEvent.click(screen.getByText('Trigger')); + + const dropdown = screen.getByRole('menu'); + expect(dropdown.style.width).toBe(''); + }); + + it('uses container width when width="full"', () => { + const container = document.createElement('div'); + + jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + width: 500, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get() { + return container; + }, + }); + + renderDropdown({ children: Trigger }, Item, { + width: 'full', + }); + + fireEvent.click(screen.getByText('Trigger')); + + expect(screen.getByRole('menu')).toHaveStyle({ width: '500px' }); + }); }); From 9f5ee48126d5abcc0b95588bcca081eea8b1d2b0 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:19:04 +0200 Subject: [PATCH 24/77] feat(date-field, date-calendar): separate components #24 --- .../date-calendar-header.module.scss} | 12 +- .../date-calendar-header.spec.tsx} | 4 +- .../date-calendar-header.tsx} | 36 +-- .../date-calendar-month-grid.spec.tsx} | 2 +- .../date-calendar-month-grid.tsx} | 2 +- .../date-calendar-year-grid.spec.tsx} | 2 +- .../date-calendar-year-grid.tsx} | 2 +- .../date-calendar-grid.tsx} | 2 +- .../date-calendar/date-calendar.module.scss | 224 ++++++++++++++++++ .../date-calendar/date-calendar.stories.tsx | 113 +++++++++ .../form/date-calendar/date-calendar.tsx | 195 +++++++++++++++ .../form/date-field/date-field.module.scss | 224 +----------------- .../form/date-field/date-field.stories.tsx | 1 - .../components/form/date-field/date-field.tsx | 144 +++-------- 14 files changed, 607 insertions(+), 356 deletions(-) rename src/tedi/components/form/{date-field/components/date-field-header/date-field-header.module.scss => date-calendar/components/date-calendar-header/date-calendar-header.module.scss} (77%) rename src/tedi/components/form/{date-field/components/date-field-header/date-field-header.spec.tsx => date-calendar/components/date-calendar-header/date-calendar-header.spec.tsx} (97%) rename src/tedi/components/form/{date-field/components/date-field-header/date-field-header.tsx => date-calendar/components/date-calendar-header/date-calendar-header.tsx} (73%) rename src/tedi/components/form/{date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx => date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.spec.tsx} (97%) rename src/tedi/components/form/{date-field/components/date-field-month-grid/date-field-month-grid.tsx => date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.tsx} (96%) rename src/tedi/components/form/{date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx => date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.spec.tsx} (97%) rename src/tedi/components/form/{date-field/components/date-field-year-grid/date-field-year-grid.tsx => date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.tsx} (96%) rename src/tedi/components/form/{date-field/date-field-grid.tsx => date-calendar/date-calendar-grid.tsx} (97%) create mode 100644 src/tedi/components/form/date-calendar/date-calendar.module.scss create mode 100644 src/tedi/components/form/date-calendar/date-calendar.stories.tsx create mode 100644 src/tedi/components/form/date-calendar/date-calendar.tsx diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.module.scss similarity index 77% rename from src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss rename to src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.module.scss index f019836e9..f24c58712 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.module.scss +++ b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.module.scss @@ -1,16 +1,16 @@ -.tedi-date-field__header { +.tedi-date-calendar__header { display: flex; align-items: center; justify-content: space-between; text-align: center; } -.tedi-date-field__month-year-dropdown { +.tedi-date-calendar__month-year-dropdown { max-height: 15rem; overflow-y: auto; } -.tedi-date-field__month-year-selector { +.tedi-date-calendar__month-year-selector { display: flex; gap: var(--layout-grid-gutters-04); align-items: center; @@ -24,7 +24,7 @@ color: var(--button-main-neutral-text-hover); background: var(--button-main-neutral-icon-only-background-hover); - .tedi-date-field__month-year-caret { + .tedi-date-calendar__month-year-caret { color: var(--button-main-neutral-text-hover); } } @@ -33,12 +33,12 @@ color: var(--button-main-neutral-text-active); background: var(--button-main-neutral-icon-only-background-active); - .tedi-date-field__month-year-caret { + .tedi-date-calendar__month-year-caret { color: var(--button-main-neutral-text-active); } } } -.tedi-date-field__month-year-caret { +.tedi-date-calendar__month-year-caret { font-size: var(--tedi-size-09); } diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.spec.tsx similarity index 97% rename from src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.spec.tsx index 677a07da4..607404b65 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.spec.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CalendarHeader } from './date-field-header'; +import { CalendarHeader } from './date-calendar-header'; import '@testing-library/jest-dom'; @@ -159,6 +159,6 @@ describe('CalendarHeader', () => { it('applies correct container class', () => { const { container } = render(); - expect(container.firstChild).toHaveClass('tedi-date-field__header'); + expect(container.firstChild).toHaveClass('tedi-date-calendar__header'); }); }); diff --git a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.tsx similarity index 73% rename from src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.tsx index 97de82b0f..fead0b918 100644 --- a/src/tedi/components/form/date-field/components/date-field-header/date-field-header.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-header/date-calendar-header.tsx @@ -5,7 +5,7 @@ import { useLabels } from '../../../../../providers/label-provider'; import { Icon } from '../../../../base/icon/icon'; import Button from '../../../../buttons/button/button'; import { Dropdown } from '../../../../overlays/dropdown'; -import styles from './date-field-header.module.scss'; +import styles from './date-calendar-header.module.scss'; export interface CalendarHeaderProps extends Pick { /** @@ -37,7 +37,7 @@ export function CalendarHeader({ const years = Array.from({ length: 10 }, (_, i) => 2021 + i); return ( -
+
- ) : ( <> - @@ -90,15 +94,19 @@ export function CalendarHeader({ - diff --git a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.spec.tsx similarity index 97% rename from src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.spec.tsx index b397c7f0c..a8f4ba227 100644 --- a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.spec.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MonthGrid } from './date-field-month-grid'; +import { MonthGrid } from './date-calendar-month-grid'; import '@testing-library/jest-dom'; diff --git a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.tsx similarity index 96% rename from src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.tsx index 20b53f1b3..6038fe946 100644 --- a/src/tedi/components/form/date-field/components/date-field-month-grid/date-field-month-grid.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.tsx @@ -1,6 +1,6 @@ import { useLabels } from '../../../../../providers/label-provider'; import { Text } from '../../../../base/typography/text/text'; -import { PickerGrid } from '../../date-field-grid'; +import { PickerGrid } from '../../date-calendar-grid'; export interface MonthGridProps { /* diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.spec.tsx similarity index 97% rename from src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.spec.tsx index a283113d7..827e8c699 100644 --- a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.spec.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { YearGrid } from './date-field-year-grid'; +import { YearGrid } from './date-calendar-year-grid'; import '@testing-library/jest-dom'; diff --git a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx b/src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.tsx similarity index 96% rename from src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx rename to src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.tsx index 61560ce63..3fe51b634 100644 --- a/src/tedi/components/form/date-field/components/date-field-year-grid/date-field-year-grid.tsx +++ b/src/tedi/components/form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.tsx @@ -1,5 +1,5 @@ import { useLabels } from '../../../../../providers/label-provider'; -import { PickerGrid } from '../../date-field-grid'; +import { PickerGrid } from '../../date-calendar-grid'; export interface YearGridProps { /* diff --git a/src/tedi/components/form/date-field/date-field-grid.tsx b/src/tedi/components/form/date-calendar/date-calendar-grid.tsx similarity index 97% rename from src/tedi/components/form/date-field/date-field-grid.tsx rename to src/tedi/components/form/date-calendar/date-calendar-grid.tsx index 31fac099f..51352fc02 100644 --- a/src/tedi/components/form/date-field/date-field-grid.tsx +++ b/src/tedi/components/form/date-calendar/date-calendar-grid.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; import { Col, Row } from '../../layout/grid'; -import styles from './date-field.module.scss'; +import styles from './date-calendar.module.scss'; interface PickerGridItem { key: React.Key; diff --git a/src/tedi/components/form/date-calendar/date-calendar.module.scss b/src/tedi/components/form/date-calendar/date-calendar.module.scss new file mode 100644 index 000000000..5c6d727af --- /dev/null +++ b/src/tedi/components/form/date-calendar/date-calendar.module.scss @@ -0,0 +1,224 @@ +%tedi-date-calendar-surface { + position: relative; + z-index: var(--z-index-dropdown); + font-family: var(--family-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); +} + +%tedi-date-calendar-cell-size { + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); +} + +.tedi-date-calendar { + table { + width: 100%; + border-spacing: 0; + border-collapse: separate; + } + + &__container { + position: relative; + width: 100%; + } + + &__months-container { + display: flex; + gap: 8px; + } + + &__month { + padding: var(--card-padding-md-default); + } + + &__footer { + padding-top: var(--layout-grid-gutters-08); + margin: 0 var(--card-padding-md-default) var(--card-padding-xs); + border-top: 1px solid var(--general-border-primary); + } + + &__head th { + border-bottom: 1px solid var(--card-border-primary); + } + + &__weekday { + @extend %tedi-date-calendar-cell-size; + + font-weight: 500; + color: var(--general-text-tertiary); + border-bottom: 1px solid var(--card-border-primary); + } + + &__day { + @extend %tedi-date-calendar-cell-size; + + text-align: center; + border-radius: var(--button-radius-sm); + transition: background 0.15s ease; + + button { + all: unset; + display: block; + width: 100%; + height: 100%; + overflow: hidden; + font-size: var(--body-regular-size); + font-weight: 400; + color: var(--general-text-primary); + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--form-datepicker-today-border); + outline-offset: 2px; + } + } + + &:hover:not(.tedi-date-calendar__disabled) { + color: var(--general-text-primary); + cursor: pointer; + background-color: var(--form-datepicker-date-hover); + } + } + + &__today { + button { + margin-left: -1px; + border: 1px solid var(--form-datepicker-today-border); + border-radius: 50%; + } + + &.tedi-date-calendar__available-day { + color: var(--form-datepicker-date-text-available); + background: var(--form-datepicker-date-available); + } + } + + &__available-day { + color: var(--form-datepicker-date-text-available); + background: var(--form-datepicker-date-available); + + button { + color: inherit; + } + } + + &__day--selected { + color: var(--form-datepicker-date-text-selected); + background-color: var(--form-datepicker-date-selected); + + &.tedi-date-calendar__today { + button { + border-color: var(--form-datepicker-date-text-selected); + } + } + + button { + color: inherit; + } + } + + &__range-middle { + background-color: var(--form-datepicker-date-active); + border-radius: 0; + + button { + color: var(--general-text-primary); + } + } + + &__range-start { + background-color: var(--form-datepicker-date-selected); + border-radius: var(--button-radius-sm) 0 0 var(--button-radius-sm); + } + + &__range-end { + background-color: var(--form-datepicker-date-selected); + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; + } + + &__outside-days:not(.tedi-date-calendar__day--selected) { + button { + color: var(--form-datepicker-date-text-muted); + } + } + + &__week-number { + @extend %tedi-date-calendar-cell-size; + + font-weight: 500; + color: var(--general-text-tertiary); + text-align: center; + border-right: 1px solid var(--general-border-primary); + } + + &__disabled { + opacity: 0.3; + + button { + color: var(--general-text-primary); + cursor: not-allowed; + } + + &:hover { + background: transparent; + } + } +} + +.tedi-date-calendar__multivalue div:last-child { + align-items: start; +} + +.tedi-date-calendar__picker-grid { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + padding: var(--card-padding-md-default); +} + +.tedi-date-calendar__picker-grid-container { + @extend %tedi-date-calendar-surface; + + max-width: 315px; +} + +.tedi-date-calendar__picker-grid-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + padding: var(--card-padding-md-default); + padding-bottom: 0; +} + +.tedi-date-calendar__grid-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 2.5rem; + padding: calc(var(--button-md-padding-y) - 1px) var(--button-md-padding-x); + font-size: var(--button-text-size-default); + color: var(--form-checkbox-radio-card-primary-default-text); + text-align: center; + border: 1px solid var(--form-checkbox-radio-card-secondary-default-border); + border-radius: var(--form-checkbox-radio-card-radius); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + cursor: pointer; + border: 1px solid var(--form-checkbox-radio-card-secondary-hover-border); + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + border: var(--general-selected-border-width) solid var(--form-checkbox-radio-card-secondary-selected-border); + + &:hover { + border-width: 2px; + } + } +} diff --git a/src/tedi/components/form/date-calendar/date-calendar.stories.tsx b/src/tedi/components/form/date-calendar/date-calendar.stories.tsx new file mode 100644 index 000000000..d6155beb7 --- /dev/null +++ b/src/tedi/components/form/date-calendar/date-calendar.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { et } from 'react-day-picker/locale'; + +import { CalendarView } from '../date-field/date-field'; +import { DateCalendar } from './date-calendar'; + +/** + * React DayPicker based reusable date picker component
+ * React DayPicker ↗
+ * Figma ↗
+ * Zeroheight ↗ + */ + +const meta: Meta = { + title: 'TEDI-Ready/Components/Form/DateCalendar', + component: DateCalendar, +}; + +export default meta; + +type Story = StoryObj; + +export const Default = () => { + const [view, setView] = useState('days'); + + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [value, setValue] = useState(); + + return ( + { + setValue(d as Date); + }} + applyValue={(d) => { + setValue(d); + }} + /> + ); +}; + +export const Multiple: Story = { + render: () => { + const defaultDates = [ + new Date(), + new Date(new Date().setDate(new Date().getDate() + 3)), + new Date(new Date().setDate(new Date().getDate() + 7)), + ]; + + const [dates, setDates] = useState(defaultDates); + const [view, setView] = useState('days'); + const [currentMonth, setCurrentMonth] = useState(new Date()); + + return ( + { + setDates(d as Date[]); + }} + applyValue={(d) => setDates([d as Date])} + /> + ); + }, +}; + +export const Range: Story = { + render: () => { + const today = new Date(); + const defaultRange = { + from: today, + to: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 5), + }; + + const [range, setRange] = useState<{ from: Date; to?: Date }>(defaultRange); + const [view, setView] = useState('days'); + const [currentMonth, setCurrentMonth] = useState(today); + + return ( + { + setRange(d as { from: Date; to?: Date }); + }} + applyValue={(d) => setRange({ from: d as Date, to: range?.to ?? undefined })} + /> + ); + }, +}; diff --git a/src/tedi/components/form/date-calendar/date-calendar.tsx b/src/tedi/components/form/date-calendar/date-calendar.tsx new file mode 100644 index 000000000..d32a3a417 --- /dev/null +++ b/src/tedi/components/form/date-calendar/date-calendar.tsx @@ -0,0 +1,195 @@ +import classNames from 'classnames'; +import React from 'react'; +import { DateRange, DayPicker, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; +import { UnknownType } from 'src/tedi/types/commonTypes'; + +import { CalendarView, DateFieldMode } from '../date-field/date-field'; +import { CalendarHeader } from './components/date-calendar-header/date-calendar-header'; +import { MonthGrid } from './components/date-calendar-month-grid/date-calendar-month-grid'; +import { YearGrid } from './components/date-calendar-year-grid/date-calendar-year-grid'; +import styles from './date-calendar.module.scss'; + +export interface DateCalendarProps extends Omit { + /** + * Current view of the calendar. Can be `'days'`, `'months'`, or `'years'`. + * Controls which calendar grid is displayed. + */ + view: CalendarView; + /** + * The intended calendar view mode. Determines if the calendar initially opens in `'days'`, `'months'`, or `'years'` view. + */ + calendarView: CalendarView; + /** + * The month currently displayed in the calendar. Used to render the correct month grid. + */ + currentMonth: Date; + /** + * Callback to update the `currentMonth` when navigating months/years. + */ + setCurrentMonth: (date: Date) => void; + /** + * Callback to update the current `view` (days, months, years) when the user switches calendar levels. + */ + setView: (view: CalendarView) => void; + /** + * Selection mode of the calendar. Can be `'single'`, `'multiple'`, or `'range'`. + */ + mode: DateFieldMode; + /** + * The currently selected value(s). + * - Single mode: `Date | undefined` + * - Multiple mode: `Date[]` + * - Range mode: `DateRange` (object with `from` and optional `to`) + */ + value: Date | Date[] | DateRange | undefined; + /** + * Locale object for formatting and translating calendar labels (from `react-day-picker`). + */ + locale: Locale; + /** + * Whether to display days from the previous and next months in the current month grid. + * Default is `true`. + */ + showOutsideDays: boolean; + /** + * Array of `Matcher`s or functions to disable specific dates. Used to prevent selection of certain days. + */ + disabledMatchers?: Matcher[]; + /** + * If `true`, a value must be selected before the calendar allows closing. + */ + required?: boolean; + /** + * Array of available dates or a function to dynamically mark dates as available. + * Highlights selectable days without disabling other days. + */ + availableDays?: Date[] | ((date: Date) => boolean); + /** + * Optional footer element to render below the calendar grid, e.g., for action buttons. + */ + footer?: React.ReactNode; + /** + * If `true`, the month/year selection in the calendar header will be displayed as a grid instead of a dropdown. + */ + monthYearSelectGrid?: boolean; + /** + * Callback fired when a date or date range is selected. Receives the selected value, day, modifiers, and event. + */ + handleSelect: OnSelectHandler; + /** + * Callback to apply a selected date from month/year selection or programmatically. + */ + applyValue: (date: Date) => void; + /** + * Optional additional CSS class for the calendar container. + */ + className?: string; +} + +export const DateCalendar = ({ + view, + calendarView, + currentMonth, + setCurrentMonth, + setView, + mode = 'single', + value, + locale, + showOutsideDays, + disabledMatchers, + required, + availableDays, + footer, + monthYearSelectGrid, + handleSelect, + applyValue, + className, + ...dayPickerProps +}: DateCalendarProps) => { + return ( +
+ {(view === 'years' || calendarView === 'years') && ( + { + setCurrentMonth(date); + if (calendarView === 'years') { + applyValue(new Date(date.getFullYear(), 0, 1)); + } else { + setView('months'); + } + }} + /> + )} + + {(view === 'months' || calendarView === 'months') && ( + { + setCurrentMonth(date); + if (calendarView === 'months') { + applyValue(date); + } else { + setView('days'); + } + }} + /> + )} + + {view === 'days' && ( + ( + setView('months')} + onOpenYearGrid={() => setView('years')} + /> + ), + Nav: () => <>, + }} + footer={footer} + classNames={{ + root: classNames(styles['tedi-date-calendar'], className), + month_caption: styles['tedi-date-calendar__caption'], + head: styles['tedi-date-calendar__head'], + row: styles['tedi-date-calendar__row'], + day: styles['tedi-date-calendar__day'], + selected: styles['tedi-date-calendar__day--selected'], + weekday: styles['tedi-date-calendar__weekday'], + outside: styles['tedi-date-calendar__outside-days'], + range_start: styles['tedi-date-calendar__range-start'], + range_middle: styles['tedi-date-calendar__range-middle'], + range_end: styles['tedi-date-calendar__range-end'], + today: styles['tedi-date-calendar__today'], + disabled: styles['tedi-date-calendar__disabled'], + month: styles['tedi-date-calendar__month'], + months: styles['tedi-date-calendar__months-container'], + footer: styles['tedi-date-calendar__footer'], + week_number: styles['tedi-date-calendar__week-number'], + }} + modifiers={{ + available: + availableDays instanceof Function + ? availableDays + : (d) => availableDays?.some((day) => day.toDateString() === d.toDateString()) ?? false, + }} + modifiersClassNames={{ available: styles['tedi-date-calendar__available-day'] }} + onSelect={handleSelect} + /> + )} +
+ ); +}; diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 6294b289a..fa5fa3f0e 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -1,6 +1,7 @@ -%tedi-date-field-surface { +.tedi-date-field__calendar { position: relative; z-index: var(--z-index-dropdown); + min-width: 315px; font-family: var(--family-default); background: var(--card-background-primary); border: 1px solid var(--card-border-primary); @@ -8,172 +9,6 @@ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); } -%tedi-date-field-cell-size { - width: var(--form-calendar-date-width); - height: var(--form-calendar-date-width); -} - -.tedi-date-field { - &__container { - position: relative; - width: 100%; - } - - &__calendar { - @extend %tedi-date-field-surface; - - min-width: 315px; - - table { - width: 100%; - border-spacing: 0; - border-collapse: separate; - } - } - - &__months-container { - display: flex; - gap: 8px; - } - - &__month { - padding: var(--card-padding-md-default); - } - - &__footer { - padding-top: var(--layout-grid-gutters-08); - margin: 0 var(--card-padding-md-default) var(--card-padding-xs); - border-top: 1px solid var(--general-border-primary); - } - - &__head th { - border-bottom: 1px solid var(--card-border-primary); - } - - &__weekday { - @extend %tedi-date-field-cell-size; - - font-weight: 500; - color: var(--general-text-tertiary); - border-bottom: 1px solid var(--card-border-primary); - } - - &__day { - @extend %tedi-date-field-cell-size; - - text-align: center; - border-radius: var(--button-radius-sm); - transition: background 0.15s ease; - - button { - all: unset; - display: block; - width: 100%; - height: 100%; - overflow: hidden; - font-size: var(--body-regular-size); - font-weight: 400; - color: var(--general-text-primary); - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--form-datepicker-today-border); - outline-offset: 2px; - } - } - - &:hover:not(.tedi-date-field__disabled) { - color: var(--general-text-primary); - cursor: pointer; - background-color: var(--form-datepicker-date-hover); - } - } - - &__today { - button { - margin-left: -1px; - border: 1px solid var(--form-datepicker-today-border); - border-radius: 50%; - } - - &.tedi-date-field__available-day { - color: var(--form-datepicker-date-text-available); - background: var(--form-datepicker-date-available); - } - } - - &__available-day { - color: var(--form-datepicker-date-text-available); - background: var(--form-datepicker-date-available); - - button { - color: inherit; - } - } - - &__day--selected { - color: var(--form-datepicker-date-text-selected); - background-color: var(--form-datepicker-date-selected); - - &.tedi-date-field__today { - button { - border-color: var(--form-datepicker-date-text-selected); - } - } - - button { - color: inherit; - } - } - - &__range-middle { - background-color: var(--form-datepicker-date-active); - border-radius: 0; - - button { - color: var(--general-text-primary); - } - } - - &__range-start { - background-color: var(--form-datepicker-date-selected); - border-radius: var(--button-radius-sm) 0 0 var(--button-radius-sm); - } - - &__range-end { - background-color: var(--form-datepicker-date-selected); - border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; - } - - &__outside-days { - button { - color: var(--form-datepicker-date-text-muted); - } - } - - &__week-number { - @extend %tedi-date-field-cell-size; - - font-weight: 500; - color: var(--general-text-tertiary); - text-align: center; - border-right: 1px solid var(--general-border-primary); - } - - &__disabled { - opacity: 0.3; - - button { - color: var(--general-text-primary); - cursor: not-allowed; - } - - &:hover { - background: transparent; - } - } -} - .tedi-date-field__textfield { button:not([data-name='closing-button']):last-child { display: flex; @@ -209,58 +44,3 @@ } } } - -.tedi-date-field__multivalue div:last-child { - align-items: start; -} - -.tedi-date-field__picker-grid { - display: flex; - gap: var(--layout-grid-gutters-08); - align-items: center; - padding: var(--card-padding-md-default); -} - -.tedi-date-field__picker-grid-container { - @extend %tedi-date-field-surface; - - max-width: 315px; -} - -.tedi-date-field__picker-grid-header { - display: flex; - gap: var(--layout-grid-gutters-08); - align-items: center; - justify-content: space-between; - padding: var(--card-padding-md-default); - padding-bottom: 0; -} - -.tedi-date-field__grid-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 2.5rem; - padding: calc(var(--button-md-padding-y) - 1px) var(--button-md-padding-x); - font-size: var(--button-text-size-default); - color: var(--form-checkbox-radio-card-primary-default-text); - text-align: center; - border: 1px solid var(--form-checkbox-radio-card-secondary-default-border); - border-radius: var(--form-checkbox-radio-card-radius); - - &:hover { - color: var(--form-checkbox-radio-card-secondary-hover-text); - cursor: pointer; - border: 1px solid var(--form-checkbox-radio-card-secondary-hover-border); - } - - &--selected { - color: var(--form-checkbox-radio-card-secondary-selected-text); - border: var(--general-selected-border-width) solid var(--form-checkbox-radio-card-secondary-selected-border); - - &:hover { - border-width: 2px; - } - } -} diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index e691c1e80..576cd4e73 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -8,7 +8,6 @@ import { Col, Row } from '../../layout/grid'; import { DateField, DateFieldProps } from './date-field'; /** - * React DayPicker based reusable DatePicker component
* React DayPicker ↗
* Figma ↗
* Zeroheight ↗ diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index 069cc337f..5c219542a 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -13,16 +13,14 @@ import { } from '@floating-ui/react'; import cn from 'classnames'; import React, { useEffect, useMemo, useState } from 'react'; -import { DateRange, DayPicker, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; +import { DateRange, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; import { useLabels } from '../../../providers/label-provider'; import { UnknownType } from '../../../types/commonTypes'; +import { DateCalendar } from '../date-calendar/date-calendar'; import MultiValueField, { MultiValueFieldProps } from '../multi-value-field/multi-value-field'; import TextField, { TextFieldProps } from '../textfield/textfield'; -import { CalendarHeader } from './components/date-field-header/date-field-header'; -import { MonthGrid } from './components/date-field-month-grid/date-field-month-grid'; -import { YearGrid } from './components/date-field-year-grid/date-field-year-grid'; import styles from './date-field.module.scss'; export type DateFieldMode = 'single' | 'multiple' | 'range'; @@ -189,6 +187,7 @@ export interface DateFieldProps extends Omit = ({ @@ -222,6 +221,7 @@ export const DateField: React.FC = ({ readOnly, availableDays, inputProps, + enableCalendar = true, ...dayPickerProps }) => { const { getLabel } = useLabels(); @@ -284,7 +284,7 @@ export const DateField: React.FC = ({ const { refs, context, x, y, strategy } = floating; const click = useClick(context); const interactions = useInteractions([ - ...(openBehavior === 'input' ? [click] : []), + ...(enableCalendar && openBehavior === 'input' ? [click] : []), useDismiss(context), useRole(context, { role: 'dialog' }), ]); @@ -370,7 +370,7 @@ export const DateField: React.FC = ({ values={formattedDates} placeholder={placeholder} icon="calendar_today" - onIconClick={() => setOpen(true)} + onIconClick={() => enableCalendar && setOpen(true)} isClearable required={required} onChange={(newValues) => { @@ -404,113 +404,45 @@ export const DateField: React.FC = ({ )}
- - {open && ( - -
-
- {view === 'years' && getLabel('pickers.yearSelection')} - {view === 'months' && getLabel('pickers.monthSelection')} -
- - {(view === 'years' || calendarView === 'years') && ( - { - setCurrentMonth(date); - - if (calendarView === 'years') { - const normalized = new Date(date.getFullYear(), 0, 1); - applyValue(normalized); - } else { - setView('months'); - } - }} - /> - )} - - {(view === 'months' || calendarView === 'months') && ( - { - setCurrentMonth(date); - - if (calendarView === 'months') { - applyValue(date); - } else { - setView('days'); - } - }} - /> - )} - - {view === 'days' && ( - + {open && ( + +
+ 0 ? disabledMatchers : undefined} + disabledMatchers={disabledMatchers} required={required} - components={{ - MonthCaption: (props) => ( - setView('months')} - onOpenYearGrid={() => setView('years')} - /> - ), - Nav: () => <>, - }} + availableDays={availableDays} footer={footer} - classNames={{ - root: styles['tedi-date-field__calendar'], - month_caption: styles['tedi-date-field__caption'], - head: styles['tedi-date-field__head'], - row: styles['tedi-date-field__row'], - day: styles['tedi-date-field__day'], - selected: styles['tedi-date-field__day--selected'], - weekday: styles['tedi-date-field__weekday'], - outside: styles['tedi-date-field__outside-days'], - range_start: styles['tedi-date-field__range-start'], - range_middle: styles['tedi-date-field__range-middle'], - range_end: styles['tedi-date-field__range-end'], - today: styles['tedi-date-field__today'], - disabled: styles['tedi-date-field__disabled'], - month: styles['tedi-date-field__month'], - months: styles['tedi-date-field__months-container'], - footer: styles['tedi-date-field__footer'], - week_number: styles['tedi-date-field__week-number'], - }} - modifiers={{ - available: - availableDays instanceof Function - ? availableDays - : (d) => availableDays?.some((day) => day.toDateString() === d.toDateString()) ?? false, - }} - modifiersClassNames={{ - available: styles['tedi-date-field__available-day'], - }} - onSelect={handleSelect} + monthYearSelectGrid={monthYearSelectGrid} + handleSelect={handleSelect} + applyValue={applyValue} + className={styles['tedi-date-field__calendar']} /> - )} -
-
- )} - +
+
+ )} +
+ )} ); }; From f6cb4c75e698546ef18789ccc1a0f2c6cdedc808 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:07:13 +0200 Subject: [PATCH 25/77] feat(date-field): design review fixes #24 --- .../calendar/calendar-grid.tsx} | 12 +- .../calendar/calendar.module.scss} | 5 + .../calendar/calendar.stories.tsx} | 18 +- .../calendar/calendar.tsx} | 54 ++++-- .../calendar-header.module.scss} | 0 .../calendar-header/calendar-header.spec.tsx} | 0 .../calendar-header/calendar-header.tsx} | 2 +- .../calendar-month-grid.spec.tsx} | 2 +- .../calendar-month-grid.tsx} | 2 +- .../calendar-year-grid.spec.tsx} | 2 +- .../calendar-year-grid.tsx} | 2 +- .../form/date-field/date-field.module.scss | 14 ++ .../form/date-field/date-field.stories.tsx | 180 +++++++++++++----- .../components/form/date-field/date-field.tsx | 75 ++++++-- .../multi-value-field.module.scss | 7 +- .../multi-value-field/multi-value-field.tsx | 20 +- .../form/textfield/textfield.module.scss | 2 +- 17 files changed, 289 insertions(+), 108 deletions(-) rename src/tedi/components/{form/date-calendar/date-calendar-grid.tsx => content/calendar/calendar-grid.tsx} (76%) rename src/tedi/components/{form/date-calendar/date-calendar.module.scss => content/calendar/calendar.module.scss} (97%) rename src/tedi/components/{form/date-calendar/date-calendar.stories.tsx => content/calendar/calendar.stories.tsx} (89%) rename src/tedi/components/{form/date-calendar/date-calendar.tsx => content/calendar/calendar.tsx} (79%) rename src/tedi/components/{form/date-calendar/components/date-calendar-header/date-calendar-header.module.scss => content/calendar/components/calendar-header/calendar-header.module.scss} (100%) rename src/tedi/components/{form/date-calendar/components/date-calendar-header/date-calendar-header.spec.tsx => content/calendar/components/calendar-header/calendar-header.spec.tsx} (100%) rename src/tedi/components/{form/date-calendar/components/date-calendar-header/date-calendar-header.tsx => content/calendar/components/calendar-header/calendar-header.tsx} (98%) rename src/tedi/components/{form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.spec.tsx => content/calendar/components/calendar-month-grid/calendar-month-grid.spec.tsx} (97%) rename src/tedi/components/{form/date-calendar/components/date-calendar-month-grid/date-calendar-month-grid.tsx => content/calendar/components/calendar-month-grid/calendar-month-grid.tsx} (96%) rename src/tedi/components/{form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.spec.tsx => content/calendar/components/calendar-year-grid/calendar-year-grid.spec.tsx} (97%) rename src/tedi/components/{form/date-calendar/components/date-calendar-year-grid/date-calendar-year-grid.tsx => content/calendar/components/calendar-year-grid/calendar-year-grid.tsx} (96%) diff --git a/src/tedi/components/form/date-calendar/date-calendar-grid.tsx b/src/tedi/components/content/calendar/calendar-grid.tsx similarity index 76% rename from src/tedi/components/form/date-calendar/date-calendar-grid.tsx rename to src/tedi/components/content/calendar/calendar-grid.tsx index 51352fc02..6e47f04b7 100644 --- a/src/tedi/components/form/date-calendar/date-calendar-grid.tsx +++ b/src/tedi/components/content/calendar/calendar-grid.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; import { Col, Row } from '../../layout/grid'; -import styles from './date-calendar.module.scss'; +import styles from './calendar.module.scss'; interface PickerGridItem { key: React.Key; @@ -32,8 +32,8 @@ export const PickerGrid = ({ onSelect, }: PickerGridProps) => { return ( -
-
+
+
@@ -45,15 +45,15 @@ export const PickerGrid = ({
-
+
{items.map((item) => ( + } @@ -215,18 +319,17 @@ export const CalendarFooter: Story = { - - - - - + +
+ + +
} @@ -237,37 +340,26 @@ export const CalendarFooter: Story = { }, }; -export const DefaultValue: Story = { - render: Template, - args: { - mode: 'single', - label: 'Default selected date', - defaultValue: new Date(), - }, -}; - -export const AvailableDays: Story = { +export const CalendarTrigger: Story = { render: () => { - const availableDays = [ - new Date(), - new Date(new Date().setDate(new Date().getDate() + 4)), - new Date(new Date().setDate(new Date().getDate() + 5)), - new Date(new Date().setDate(new Date().getDate() + 6)), - ]; - - const [selected, setSelected] = useState(); - return ( - setSelected(date as Date)} - availableDays={availableDays} - id="available-days-shown" - /> + + + + + + + + ); }, + parameters: { + docs: { + description: { + story: 'calendarTrigger prop allows you to open calendar either on input click or calendar icon', + }, + }, + }, }; export const ManualTyping: StoryFn = (args) => { diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index 5c219542a..16ac9f123 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -4,7 +4,6 @@ import { FloatingFocusManager, FloatingPortal, offset, - shift, useClick, useDismiss, useFloating, @@ -16,16 +15,17 @@ import React, { useEffect, useMemo, useState } from 'react'; import { DateRange, DayPickerProps, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; -import { useLabels } from '../../../providers/label-provider'; import { UnknownType } from '../../../types/commonTypes'; -import { DateCalendar } from '../date-calendar/date-calendar'; +import { Calendar } from '../../content/calendar/calendar'; import MultiValueField, { MultiValueFieldProps } from '../multi-value-field/multi-value-field'; -import TextField, { TextFieldProps } from '../textfield/textfield'; +import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield'; import styles from './date-field.module.scss'; +const CALENDAR_OFFSET = 4; + export type DateFieldMode = 'single' | 'multiple' | 'range'; export type CalendarView = 'days' | 'months' | 'years'; -export type DateFieldOpenBehavior = 'input' | 'button'; +export type DateFieldCalendarTrigger = 'input' | 'button'; type DateTextFieldProps = Omit; type DateMultiValueFieldProps = Omit; @@ -91,7 +91,7 @@ export interface DateFieldProps extends Omit