diff --git a/package.json b/package.json index f223a539..7dc6cfd8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "./themes/default.css": "./dist/themes/default.css", "./themes/tailwind-presets/default.js": "./dist/themes/tailwind-presets/default.js", "./Accordion": "./dist/components/Accordion.js", + "./AlertDialog": "./dist/components/AlertDialog.js", "./Avatar": "./dist/components/Avatar.js", + "./AvatarGroup": "./dist/components/AvatarGroup.js", "./Badge": "./dist/components/Badge.js", "./BlockQuote": "./dist/components/BlockQuote.js", "./Button": "./dist/components/Button.js", @@ -22,6 +24,7 @@ "./Link": "./dist/components/Link.js", "./Progress": "./dist/components/Progress.js", "./Quote": "./dist/components/Quote.js", + "./RadioGroup": "./dist/components/RadioGroup.js", "./Separator": "./dist/components/Separator.js", "./Skeleton": "./dist/components/Skeleton.js", "./Strong": "./dist/components/Strong.js", diff --git a/src/components/ui/Accordion/fragments/AccordionItem.tsx b/src/components/ui/Accordion/fragments/AccordionItem.tsx index 2a1141be..4894ec20 100644 --- a/src/components/ui/Accordion/fragments/AccordionItem.tsx +++ b/src/components/ui/Accordion/fragments/AccordionItem.tsx @@ -69,7 +69,6 @@ const AccordionItem: React.FC = ({ children, value, classNam data-state={isOpen ? 'open' : 'closed'} data-rad-ui-batch-element {...shouldAddFocusDataAttribute ? { 'data-rad-ui-focus-element': '' } : {}} - > {children} diff --git a/src/components/ui/Toggle/Toggle.tsx b/src/components/ui/Toggle/Toggle.tsx index 1fcd4369..3ff09ca5 100644 --- a/src/components/ui/Toggle/Toggle.tsx +++ b/src/components/ui/Toggle/Toggle.tsx @@ -2,13 +2,13 @@ import React, { useState } from 'react'; import { customClassSwitcher } from '~/core'; -import ButtonPrimitive from '~/core/primitives/Button'; +import TogglePrimitive from '~/core/primitives/Toggle'; const COMPONENT_NAME = 'Toggle'; export type ToggleProps = { - defaultPressed? : boolean | false ; - pressed : boolean; + defaultPressed?: boolean; + pressed: boolean; customRootClass? : string; disabled? : boolean; children? : React.ReactNode; @@ -18,18 +18,21 @@ export type ToggleProps = { }; const Toggle: React.FC = ({ - defaultPressed, + defaultPressed = false, customRootClass = '', children, className = '', - pressed, + pressed = false, onChange, ...props }) => { + if (typeof pressed !== 'boolean') { + throw new Error('Toggle: pressed prop must be a boolean'); + } const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME); - const [isPressed, setIsPressed] = useState(pressed || defaultPressed); + const [isPressed, setIsPressed] = useState(pressed); const handlePressed = () => { const updatedPressed = !isPressed; @@ -38,15 +41,16 @@ const Toggle: React.FC = ({ }; return ( - - + {...props}> {children} - + ); }; diff --git a/src/components/ui/ToggleGroup/contexts/toggleContext.tsx b/src/components/ui/ToggleGroup/contexts/toggleContext.tsx index 99ce220a..d35ad88e 100644 --- a/src/components/ui/ToggleGroup/contexts/toggleContext.tsx +++ b/src/components/ui/ToggleGroup/contexts/toggleContext.tsx @@ -1,3 +1,9 @@ import { createContext } from 'react'; -export const ToggleContext = createContext({}); +export type ToggleContextType = { + type: 'single' | 'multiple'; + activeToggles: any[]; + setActiveToggles: (toggles: any[]) => void; +}; + +export const ToggleContext = createContext({}); diff --git a/src/components/ui/ToggleGroup/fragments/ToggleGroupRoot.tsx b/src/components/ui/ToggleGroup/fragments/ToggleGroupRoot.tsx index 07545b67..9199a254 100644 --- a/src/components/ui/ToggleGroup/fragments/ToggleGroupRoot.tsx +++ b/src/components/ui/ToggleGroup/fragments/ToggleGroupRoot.tsx @@ -1,25 +1,46 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { customClassSwitcher } from '~/core'; +import { getAllBatchElements, getNextBatchItem, getPrevBatchItem } from '~/core/batches'; import { ToggleContext } from '../contexts/toggleContext'; -const ToggleGroupRoot = ({ type = 'multiple', className = '', customRootClass = '', componentName = '', value = null, children }:any) => { +const ToggleGroupRoot = ({ type = 'multiple', className = '', loop = true, customRootClass = '', componentName = '', value = null, children }:any) => { const rootClass = customClassSwitcher(customRootClass, componentName); - + const toggleGroupRef = useRef(null); // value can be either a string or an array of strings // if its null, then no toggles are active const [activeToggles, setActiveToggles] = useState(value || []); + const nextItem = () => { + const batches = getAllBatchElements(toggleGroupRef?.current); + const nextItem = getNextBatchItem(batches, loop); + if (nextItem) { + nextItem?.focus(); + } + }; + + const previousItem = () => { + const batches = getAllBatchElements(toggleGroupRef?.current); + const prevItem = getPrevBatchItem(batches, loop); + if (prevItem) { + prevItem?.focus(); + } + }; + + const sendValues = { + nextItem, + previousItem, + activeToggles, + setActiveToggles, + type + }; + return ( -
+
+ value={sendValues}> {children}
diff --git a/src/components/ui/ToggleGroup/fragments/ToggleItem.tsx b/src/components/ui/ToggleGroup/fragments/ToggleItem.tsx index d04f3a8c..e5b4f1ae 100644 --- a/src/components/ui/ToggleGroup/fragments/ToggleItem.tsx +++ b/src/components/ui/ToggleGroup/fragments/ToggleItem.tsx @@ -1,27 +1,41 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { ToggleContext } from '../contexts/toggleContext'; -import ButtonPrimitive from '~/core/primitives/Button'; +import TogglePrimitive from '~/core/primitives/Toggle'; -const ToggleItem = ({ children, value = null, ...props }:any) => { - const toggleContext = useContext(ToggleContext); +export type ToggleItemProps = { + children: React.ReactNode; + value: any; + props: any; +}; + +const ToggleItem = ({ children, value = null, ...props }:ToggleItemProps) => { + const { type, activeToggles, setActiveToggles, nextItem, previousItem } = useContext(ToggleContext); + const isActive = activeToggles?.includes(value); + + const [isFocused, setIsFocused] = useState(false); - const type = toggleContext?.type; + const ariaProps:any = {}; + const dataProps:any = {}; - - const isActive = toggleContext?.activeToggles?.includes(value); + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; const handleToggleSelect = () => { - let activeToggleArray = toggleContext?.activeToggles || []; + let activeToggleArray = activeToggles || []; // For Single Case if (type === 'single') { if (isActive) { - - toggleContext?.setActiveToggles([]); + setActiveToggles([]); return; } else { - toggleContext?.setActiveToggles([value]); + setActiveToggles([value]); return; } } @@ -35,21 +49,42 @@ const ToggleItem = ({ children, value = null, ...props }:any) => { } } - toggleContext?.setActiveToggles(activeToggleArray); + setActiveToggles(activeToggleArray); + }; + + const handleKeyDown = (e:any) => { + if (e.key === 'ArrowRight') { + // prevent scrolling when pressing arrow keys + e.preventDefault(); + nextItem(); + } + if (e.key === 'ArrowLeft') { + // prevent scrolling when pressing arrow keys + e.preventDefault(); + previousItem(); + } }; if (isActive) { - props['aria-pressed'] = 'true'; + ariaProps['aria-pressed'] = 'true'; + dataProps['data-active'] = 'true'; } else { - props['aria-pressed'] = 'false'; + ariaProps['aria-pressed'] = 'false'; + dataProps['data-active'] = 'false'; } - return { - handleToggleSelect(); - }} + return {children}; + + >{children}; }; export default ToggleItem; diff --git a/src/core/batches.ts b/src/core/batches.ts index 8c14e967..655e5133 100644 --- a/src/core/batches.ts +++ b/src/core/batches.ts @@ -15,19 +15,25 @@ export const getActiveBatchItem = (batches: NodeList) => { return activeItem as HTMLElement | null; }; -export const getNextBatchItem = (batches: NodeList): Element => { +export const getNextBatchItem = (batches: NodeList, loop = false): Element => { const activeItem = getActiveBatchItem(batches) as HTMLElement | null; - // get the next item, return it if it is not the last item + // Try to get the next sibling element const nextItem = activeItem?.nextElementSibling; + if (nextItem) { return nextItem; } - // if it is the last item, return the last item + if (loop) { + // When at the end and looping is enabled, return the first item + return batches[0] as HTMLElement; + } + + // When at the end and looping is disabled, stay on the last item return batches[batches.length - 1] as HTMLElement; }; -export const getPrevBatchItem = (batches: NodeList) => { +export const getPrevBatchItem = (batches: NodeList, loop = false) => { const activeItem = getActiveBatchItem(batches) as HTMLElement | null; // get the next item, return it if it is not the last item const prevItem = activeItem?.previousElementSibling; @@ -35,6 +41,10 @@ export const getPrevBatchItem = (batches: NodeList) => { return prevItem; } - // if it is the last item, return the last item + if (loop) { + // if it is the last item, return the last item + return batches[batches.length - 1] as HTMLElement; + } + // if it is the last item, return the first item return batches[0] as HTMLElement; }; diff --git a/src/core/primitives/Toggle/contexts/TogglePrimitiveContext.tsx b/src/core/primitives/Toggle/contexts/TogglePrimitiveContext.tsx deleted file mode 100644 index cc4896dd..00000000 --- a/src/core/primitives/Toggle/contexts/TogglePrimitiveContext.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from "react"; - -interface TogglePrimitiveContextType { - isPressed: boolean | undefined; - handlePressed: () => void; - -} -export const TogglePrimitiveContext = createContext({} as TogglePrimitiveContextType) \ No newline at end of file diff --git a/src/core/primitives/Toggle/fragments/TogglePrimitiveRoot.tsx b/src/core/primitives/Toggle/fragments/TogglePrimitiveRoot.tsx deleted file mode 100644 index 54628407..00000000 --- a/src/core/primitives/Toggle/fragments/TogglePrimitiveRoot.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useState } from "react"; - -export interface TogglePrimitiveRootProps { - defaultPressed? : boolean | false; - pressed: boolean; - children?: React.ReactNode; - className?: string; - onChange : (isPressed:boolean) => void; - -} -const TogglePrimitiveRoot = ({children,className='',defaultPressed,pressed,onChange,...props}:TogglePrimitiveRootProps) => { - const [isPressed, setIsPressed] = useState(pressed || defaultPressed); - - const handlePressed = () => { - const updatedPressed = !isPressed; - setIsPressed(updatedPressed); - onChange(updatedPressed) - } - - return {children} - -}; - -export default TogglePrimitiveRoot; \ No newline at end of file diff --git a/src/core/primitives/Toggle/index.tsx b/src/core/primitives/Toggle/index.tsx index 57e65b51..db80f59b 100644 --- a/src/core/primitives/Toggle/index.tsx +++ b/src/core/primitives/Toggle/index.tsx @@ -1,7 +1,65 @@ -import TogglePrimitiveRoot from "./fragments/TogglePrimitiveRoot" +import React, { useState } from 'react'; -const TogglePrimitive = { - Root: TogglePrimitiveRoot, +import Primitive from '~/core/primitives/Primitive'; + +export interface TogglePrimitiveProps { + defaultPressed?: boolean; + pressed?: boolean; + children?: React.ReactNode; + className?: string; + label?: string; + disabled?: boolean; + onPressedChange: (isPressed: boolean) => void; } -export default TogglePrimitive \ No newline at end of file +const TogglePrimitive = ({ + children, + label = '', + defaultPressed = false, + pressed: controlledPressed, + onPressedChange = () => {}, + disabled, + ...props +}: TogglePrimitiveProps) => { + const [uncontrolledPressed, setUncontrolledPressed] = useState(defaultPressed); + + const isControlled = controlledPressed !== undefined; + const isPressed = isControlled ? controlledPressed : uncontrolledPressed; + + const handlePressed = () => { + if (disabled) { + return; + } + + const updatedPressed = !isPressed; + if (!isControlled) { + setUncontrolledPressed(updatedPressed); + } + onPressedChange(updatedPressed); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + // TODO: Should these be handled by the browser? + // Or should we add these functionalities inside the ButtonPrimitive? + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handlePressed(); + } + }; + + const ariaAttributes:any = label ? { 'aria-label': label } : {}; + ariaAttributes['aria-pressed'] = isPressed ? 'true' : 'false'; + ariaAttributes['aria-disabled'] = disabled ? 'true' : 'false'; + + return {children} + ; +}; + +export default TogglePrimitive; diff --git a/src/core/primitives/Toggle/stories/TogglePrimitive.stories.tsx b/src/core/primitives/Toggle/stories/TogglePrimitive.stories.tsx index 3d43f63c..7a7db00a 100644 --- a/src/core/primitives/Toggle/stories/TogglePrimitive.stories.tsx +++ b/src/core/primitives/Toggle/stories/TogglePrimitive.stories.tsx @@ -1,19 +1,28 @@ -import React from "react"; -import TogglePrimitive from "../index"; -import SandboxEditor from "~/components/tools/SandboxEditor/SandboxEditor"; +import React, { useState } from 'react'; +import TogglePrimitive from '../index'; +import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor'; export default { title: 'Primitives/TogglePrimitive', component: TogglePrimitive, - render: (args:any) => - - - - -} + render: (args:any) => { + const [pressed, setPressed] = useState(false); + return + + toggle - {pressed ? 'on' : 'off'} + + ; + } +}; export const All = { args: { - className: '' + className: '' + } +}; + +export const Disabled = { + args: { + disabled: true } -} \ No newline at end of file +}; diff --git a/src/core/primitives/Toggle/tests/TogglePrimitive.test.js b/src/core/primitives/Toggle/tests/TogglePrimitive.test.js new file mode 100644 index 00000000..17d7e3db --- /dev/null +++ b/src/core/primitives/Toggle/tests/TogglePrimitive.test.js @@ -0,0 +1,154 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TogglePrimitive from '../index'; + +describe('TogglePrimitive', () => { + it('renders children correctly', () => { + render(Test Content); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('renders asChild correctly', () => { + const { container } = render(); + expect(container.querySelector('button')).toBeInTheDocument(); + expect(container.querySelector('button')).toHaveTextContent('Click me'); + }); + + it('renders with label correctly', () => { + render(Test Content); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(screen.getByLabelText('Test Label')).toBeInTheDocument(); + }); + + it('renders with defaultPressed true with data-state on correctly', () => { + render(Test Content); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'on'); + }); + + it('renders with defaultPressed false with data-state off correctly', () => { + render(Test Content); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'off'); + }); + + it('renders onPressedChange correctly', () => { + const onPressedChange = jest.fn(); + render(Test Content); + fireEvent.click(screen.getByRole('button')); + expect(onPressedChange).toHaveBeenCalledWith(true); + }); + + it('renders with className correctly', () => { + render(Test Content); + expect(screen.getByText('Test Content')).toHaveClass('test-class'); + }); + + it('renders with disabled correctly', () => { + render(Test Content); + expect(screen.getByRole('button')).toHaveAttribute('disabled'); + }); + + it('renders with correct ARIA attributes', () => { + render(Test Content); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('renders with correct ARIA attributes when disabled', () => { + render(Test Content); + expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders in controlled mode correctly', () => { + const onPressedChange = jest.fn(); + const { rerender } = render( + + Test Content + + ); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'off'); + + // Click should trigger onPressedChange but not change state directly + fireEvent.click(screen.getByRole('button')); + expect(onPressedChange).toHaveBeenCalledWith(true); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'off'); + + // State should only change when pressed prop changes + rerender( + + Test Content + + ); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'on'); + }); + + it('handles multiple clicks correctly in uncontrolled mode', () => { + render(Test Content); + const button = screen.getByRole('button'); + + fireEvent.click(button); + expect(button).toHaveAttribute('data-state', 'on'); + + fireEvent.click(button); + expect(button).toHaveAttribute('data-state', 'off'); + }); + + it('prevents state change when disabled', () => { + const onPressedChange = jest.fn(); + render( + + Test Content + + ); + + fireEvent.click(screen.getByRole('button')); + expect(onPressedChange).not.toHaveBeenCalled(); + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'off'); + }); + + it('maintains controlled state after multiple clicks', () => { + const onPressedChange = jest.fn(); + render( + + Test Content + + ); + const button = screen.getByRole('button'); + + fireEvent.click(button); + fireEvent.click(button); + + expect(onPressedChange).toHaveBeenCalledTimes(2); + expect(button).toHaveAttribute('data-state', 'off'); + }); + + it('handles keyboard interactions correctly', () => { + const onPressedChange = jest.fn(); + render(Test Content); + const button = screen.getByRole('button'); + + // Initial state check + expect(button).toHaveAttribute('data-state', 'off'); + + // Test Space key + fireEvent.keyDown(button, { key: ' ' }); + fireEvent.keyUp(button, { key: ' ' }); + expect(button).toHaveAttribute('data-state', 'on'); + expect(onPressedChange).toHaveBeenCalledWith(true); + + // Test Enter key + fireEvent.keyDown(button, { key: 'Enter' }); + fireEvent.keyUp(button, { key: 'Enter' }); + expect(button).toHaveAttribute('data-state', 'off'); + expect(onPressedChange).toHaveBeenCalledWith(false); + + // Test that other keys don't trigger the toggle + fireEvent.keyDown(button, { key: 'A' }); + expect(button).toHaveAttribute('data-state', 'off'); + expect(onPressedChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/styles/themes/components/toggle-group.scss b/styles/themes/components/toggle-group.scss index 50ca149e..185eb543 100644 --- a/styles/themes/components/toggle-group.scss +++ b/styles/themes/components/toggle-group.scss @@ -1,8 +1,7 @@ .rad-ui-toggle-group { display: inline-flex; - gap:12px; + gap:6px; - button { all: unset; background-color: white; @@ -16,10 +15,11 @@ justify-content: center; margin-left: 1px; border-radius: 4px; + border: 1px solid var(--rad-ui-color-accent-300); - &:focus, &:active { + &:focus-visible { outline: 2px solid var(--rad-ui-color-accent-900); - outline-offset: 4px; + outline-offset: 2px; } &[aria-pressed="true"] { diff --git a/styles/themes/components/toggle.scss b/styles/themes/components/toggle.scss index 5030876e..6476b883 100644 --- a/styles/themes/components/toggle.scss +++ b/styles/themes/components/toggle.scss @@ -19,7 +19,8 @@ } - &:focus { - box-shadow: 0 0 0 2px var(--rad-ui-color-accent-1000); + &:focus-visible { + outline: 2px solid var(--rad-ui-color-accent-900); + outline-offset: 4px; } } \ No newline at end of file