diff --git a/apps/docs/src/examples/toggle-group.module.css b/apps/docs/src/examples/toggle-group.module.css new file mode 100644 index 00000000..0e64bbb1 --- /dev/null +++ b/apps/docs/src/examples/toggle-group.module.css @@ -0,0 +1,42 @@ +.toggle-group { + display: flex; + padding: 0.5rem; + gap: 0.5rem; +} + +.toggle-group[data-orientation="vertical"] { + flex-direction: column; +} + +.toggle-group__item { + padding: 0.5rem; + border-radius: 0.5rem; + appearance: none; + user-select: none; + outline: none; + display: flex; + justify-content: center; + align-items: center; + transition-property: background-color, color; + transition-duration: 150ms; + transition-timing-function: ease-in-out; +} + +.toggle-group__item:hover { + background-color: hsl(200 98% 39% / 0.8); + color: hsla(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .toggle-group__item:hover { + background-color: hsl(200 98% 39% / 0.5); +} + +.toggle-group__item[data-pressed] { + background-color: hsl(200 98% 39%); + color: hsla(0 100% 100% / 0.9); +} + +.toggle-group__item:focus-visible { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; +} diff --git a/apps/docs/src/examples/toggle-group.tsx b/apps/docs/src/examples/toggle-group.tsx new file mode 100644 index 00000000..fde1b9f7 --- /dev/null +++ b/apps/docs/src/examples/toggle-group.tsx @@ -0,0 +1,321 @@ +import { ToggleGroup } from "@kobalte/core"; + +import { JSXElement, createSignal } from "solid-js"; +import style from "./toggle-group.module.css"; + +export function BasicExample() { + return ( + + + + + Bold + + + + + + Italic + + + + + + Underline + + + + ); +} + +export function DefaultValueExample() { + return ( + + + + + Bold + + + + + + Italic + + + + + + Underline + + + + ); +} + +export function ControlledExample() { + const [value, setValue] = createSignal("bold"); + + const list: Record = { + bold: {value()}, + italic: {value()}, + underline: {value()}, + }; + + const render = () => { + return list[value()]; + }; + + return ( + <> + + + + + Bold + + + + + + Italic + + + + + + Underline + + + +
+ Your text style is: {render()}. +
+ + ); +} + +export function MultipleSelectionExample() { + return ( + + + + + Bold + + + + + + Italic + + + + + + Underline + + + + ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index cf04179a..43de7b31 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -100,7 +100,6 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Number Field", href: "/docs/core/components/number-field", - status: "new", }, { title: "Pagination", @@ -154,6 +153,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Toggle Button", href: "/docs/core/components/toggle-button", }, + { + title: "Toggle Group", + href: "/docs/core/components/toggle-group", + status: "new", + }, { title: "Tooltip", href: "/docs/core/components/tooltip", diff --git a/apps/docs/src/routes/docs/core/components/toggle-group.mdx b/apps/docs/src/routes/docs/core/components/toggle-group.mdx new file mode 100644 index 00000000..79b53b8c --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/toggle-group.mdx @@ -0,0 +1,253 @@ +import { Kbd, Preview, TabsSnippets } from "../../../../components"; +import { + BasicExample, + ControlledExample, + DefaultValueExample, + MultipleSelectionExample, +} from "../../../../examples/toggle-group"; + +# Toggle Group + +A set of two-state buttons that can be toggled on (pressed) or off (not pressed). + +## Import + +```ts +import { ToggleGroup } from "@kobalte/core"; +``` + +## Features + +- Supports horizontal/vertical orientation. +- Keyboard event support for Space and Enter keys. +- Can be controlled or uncontrolled. + +## Anatomy + +The toggle group consists of: + +- **ToggleGroup.Root:** the root container for a toggle group. + +The toggle item consists of: + +- **ToggleGroup.Item:** the root container for a toggle button. + +```tsx + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + { /* */} + + ```tsx + import {ToggleButton} from "@kobalte/core"; + import {BoldIcon, ItalicIcon, UnderlineIcon} from "some-icon-library"; + import "./style.css"; + + + + + + + + + + + + + ``` + + + + ```css + .toggle-group { + display: flex; + padding: .5rem; + gap: .5rem; + } + + .toggle-group[data-orientation="vertical"] { + flex - direction: column; + } + + .toggle-group__item { + padding: .5rem; + border-radius: .5rem; + appearance: none; + user-select: none; + outline: none; + display: flex; + justify-content: center; + align-items: center; + transition-property: background-color, color; + transition-duration: 150ms; + transition-timing-function: ease-in-out + } + + .toggle-group__item:hover, + .toggle-group__item[data-pressed] { + background - color: hsl(200 98% 39%); + color: white; + } + + .toggle-group__item:focus-visible { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; + } + ``` + + + { /* */} + + + +## Usage + +### Default pressed + +An initial, uncontrolled value can be provided using the `defaultValue` prop. + + + + + +```tsx {0, 7-9} + + + + + + + + + + + +``` + +### Controlled pressed + +The `value` prop can be used to make the pressed state controlled. The `onChange` event is fired when the user toggle the button, and receives the new value. + + + + + +```tsx {3,7} +import { createSignal } from "solid-js"; + +function ControlledExample() { + const [value, setValue] = createSignal("underline"); + + return ( + <> + + ... + +

Your text style is: {value()}.

+ + ); +} +``` + +### Multiple selection + +The `multiple` prop can be used to create a select that allow multi-selection. + + + + + +```tsx {3,8-10,22-23,28,32,39} +import { createSignal } from "solid-js"; + +function MultipleSelectionExample() { + const [values, setValues] = createSignal(["bold", "underline"]); + + return ( + + + + + + + + + + + + ); +} +``` + +## API Reference + +### ToggleGroup.Root + +| Prop | Description | +| :----------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| value | `string \| string[]`
The controlled pressed state of the toggle button. | +| defaultValue | `string \| string[]`
The default pressed state when initially rendered. Useful when you do not need to control the pressed state. | +| onChange | `(value: string \| string[]) => void`
Event handler called when the pressed state of an item changes. | +| multiple | `boolean`
Whether the toggle group allows multi-selection. | +| orientation | `'horizontal' \| 'vertical'`
The orientation of the toggle group. | +| disabled | `boolean`
Whether toggle group should be disabled. | + +| Data attribute | Description | +| :---------------------------- | :----------------------------------------------------- | +| data-orientation='horizontal' | Present when the separator has horizontal orientation. | +| data-orientation='vertical' | Present when the separator has vertical orientation. | + +### ToggleGroup.Item + +| Prop | Description | +| :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `string`
A unique value for the item. | +| disabled | `boolean`
Whether the item is disabled. | +| children | `JSX.Element \| (state: ToggleButtonState) => JSX.Element`
The children of the item. Can be a `JSX.Element` or a _render prop_ for having access to the internal state. | + +| Render Prop | Description | +| :---------- | :---------------------------------------------------------------------------------------- | +| pressed | `Accessor`
Whether the toggle button is on (pressed) or off (not pressed). | + +| Data attribute | Description | +| :---------------------------- | :----------------------------------------------------- | +| data-orientation='horizontal' | Present when the separator has horizontal orientation. | +| data-orientation='vertical' | Present when the separator has vertical orientation. | +| data-disabled | Present when the accordion item is disabled. | +| data-pressed | Present when the toggle button is on (pressed). | + +## Rendered elements + +| Component | Default rendered element | +| :----------------- | :----------------------- | +| `ToggleGroup.Root` | `div` | +| `ToggleGroup.Item` | `button` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-------------------- | :-------------------------------------------------------------------- | +| Tab | Move focus to either the pressed item or the first item in the group. | +| ArrowDown | If orientation is vertical, moves focus to the next item. | +| ArrowRight | If orientation is horizontal, Moves focus to the next item. | +| ArrowUp | If orientation is vertical, moves focus to the previous item. | +| ArrowLeft | If orientation is vertical, moves focus to the previous item. | +| Home | Moves focus to the first item. | +| End | Moves focus to the last item. | +| Enter | Activates/deactivates the item. | +| Space | Activates/deactivates the item. | diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index b4b2803f..8fef543e 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -42,4 +42,5 @@ export * as Tabs from "./tabs"; export * as TextField from "./text-field"; export * as Toast from "./toast"; export * as ToggleButton from "./toggle-button"; +export * as ToggleGroup from "./toggle-group"; export * as Tooltip from "./tooltip"; diff --git a/packages/core/src/toggle-group/index.ts b/packages/core/src/toggle-group/index.ts new file mode 100644 index 00000000..8b79e01a --- /dev/null +++ b/packages/core/src/toggle-group/index.ts @@ -0,0 +1,18 @@ +import type { + ToggleGroupItemOptions, + ToggleGroupItemProps, +} from "./toggle-group-item"; +import { ToggleGroupItem as Item } from "./toggle-group-item"; +import type { + ToggleGroupRootOptions, + ToggleGroupRootProps, +} from "./toggle-group-root"; +import { ToggleGroup as Root } from "./toggle-group-root"; + +export { Item, Root }; +export type { + ToggleGroupItemOptions, + ToggleGroupItemProps, + ToggleGroupRootOptions, + ToggleGroupRootProps, +}; diff --git a/packages/core/src/toggle-group/toggle-group-base.tsx b/packages/core/src/toggle-group/toggle-group-base.tsx new file mode 100644 index 00000000..51587231 --- /dev/null +++ b/packages/core/src/toggle-group/toggle-group-base.tsx @@ -0,0 +1,150 @@ +import { + Orientation, + OverrideComponentProps, + composeEventHandlers, + createGenerateId, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { createSignal, createUniqueId, splitProps } from "solid-js"; +import { useLocale } from "../i18n"; +import { createListState } from "../list"; +import { AsChildProp, Polymorphic } from "../polymorphic"; +import { CollectionItemWithRef } from "../primitives"; +import { createDomCollection } from "../primitives/create-dom-collection"; +import { SelectionMode, createSelectableCollection } from "../selection"; +import { TabsKeyboardDelegate } from "../tabs/tabs-keyboard-delegate"; +import { + ToggleGroupContext, + ToggleGroupContextValue, +} from "./toggle-group-context"; + +export interface ToggleGroupBaseOptions extends AsChildProp { + /** The controlled value of the toggle group. */ + value?: string[]; + + /** + * The value of the select when initially rendered. + * Useful when you do not need to control the value. + */ + defaultValue?: string[]; + + /** Event handler called when the value changes. */ + onChange?: (value: string[]) => void; + + /** The type of selection that is allowed in the toggle group. */ + selectionMode?: Exclude; + + /** Whether the toggle group is disabled. */ + disabled?: boolean; + + /** The axis the toggle group items should align with. */ + orientation?: Orientation; +} + +export interface ToggleGroupBaseProps + extends OverrideComponentProps<"div", ToggleGroupBaseOptions>, + AsChildProp {} + +export const ToggleGroupBase = (props: ToggleGroupBaseProps) => { + let ref: HTMLDivElement | undefined; + + const defaultID = `group-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultID, + selectionMode: "single", + orientation: "horizontal", + }, + props, + ); + + const [local, others] = splitProps(mergedProps, [ + "ref", + "value", + "defaultValue", + "disabled", + "orientation", + "selectionMode", + "onChange", + "onKeyDown", + "onMouseDown", + "onFocusIn", + "onFocusOut", + ]); + + const [items, setItems] = createSignal([]); + + const { DomCollectionProvider } = createDomCollection({ + items, + onItemsChange: setItems, + }); + + const listState = createListState({ + selectedKeys: () => local.value, + defaultSelectedKeys: () => local.defaultValue, + onSelectionChange: (key) => local.onChange?.(Array.from(key)), + disallowEmptySelection: false, + selectionMode: () => local.selectionMode, + dataSource: items, + }); + + const { direction } = useLocale(); + + const delegate = new TabsKeyboardDelegate( + () => context.listState().collection(), + direction, + () => local.orientation!, + ); + + const selectableList = createSelectableCollection( + { + selectionManager: () => listState.selectionManager(), + keyboardDelegate: () => delegate, + disallowEmptySelection: () => + listState.selectionManager().disallowEmptySelection(), + disallowTypeAhead: true, + }, + () => ref, + ); + + const context: ToggleGroupContextValue = { + listState: () => listState, + isDisabled: () => local.disabled ?? false, + isMultiple: () => local.selectionMode === "multiple", + generateId: createGenerateId(() => others.id!), + orientation: () => local.orientation!, + }; + + return ( + + + (ref = el), local.ref)} + tabIndex={!local.disabled ? selectableList.tabIndex() : undefined} + data-orientation={local.orientation} + onKeyDown={composeEventHandlers([ + local.onKeyDown, + selectableList.onKeyDown, + ])} + onMouseDown={composeEventHandlers([ + local.onMouseDown, + selectableList.onMouseDown, + ])} + onFocusIn={composeEventHandlers([ + local.onFocusIn, + selectableList.onFocusIn, + ])} + onFocusOut={composeEventHandlers([ + local.onFocusOut, + selectableList.onFocusOut, + ])} + {...others} + /> + + + ); +}; diff --git a/packages/core/src/toggle-group/toggle-group-context.tsx b/packages/core/src/toggle-group/toggle-group-context.tsx new file mode 100644 index 00000000..c036e858 --- /dev/null +++ b/packages/core/src/toggle-group/toggle-group-context.tsx @@ -0,0 +1,25 @@ +import { Orientation } from "@kobalte/utils"; +import { Accessor, createContext, useContext } from "solid-js"; +import { ListState } from "../list"; + +export interface ToggleGroupContextValue { + isMultiple: Accessor; + isDisabled: Accessor; + listState: Accessor; + generateId: (part: string) => string; + orientation: Accessor; +} + +export const ToggleGroupContext = createContext(); + +export function useToggleGroupContext() { + const context = useContext(ToggleGroupContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useToggleGroupContext` must be used within a `ToggleGroup` component", + ); + } + + return context; +} diff --git a/packages/core/src/toggle-group/toggle-group-item.tsx b/packages/core/src/toggle-group/toggle-group-item.tsx new file mode 100644 index 00000000..2e0e2071 --- /dev/null +++ b/packages/core/src/toggle-group/toggle-group-item.tsx @@ -0,0 +1,115 @@ +import { + OverrideComponentProps, + callHandler, + composeEventHandlers, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { JSX, createUniqueId, splitProps } from "solid-js"; +import { CollectionItemWithRef } from "../primitives"; +import { createDomCollectionItem } from "../primitives/create-dom-collection"; +import { createSelectableItem } from "../selection"; +import * as ToggleButton from "../toggle-button"; +import { useToggleGroupContext } from "./toggle-group-context"; + +export interface ToggleGroupItemOptions + extends Omit< + ToggleButton.ToggleButtonRootOptions, + "pressed" | "defaultPressed" | "onChange" + > { + /** A string value for the toggle group item. All items within a toggle group should use a unique value. */ + value: string; + + onChange?: JSX.ChangeEventHandlerUnion; +} + +export interface ToggleGroupItemProps + extends OverrideComponentProps<"button", ToggleGroupItemOptions> {} + +export const ToggleGroupItem = (props: ToggleGroupItemProps) => { + let ref: HTMLButtonElement | undefined; + + const rootContext = useToggleGroupContext(); + + const defaultID = rootContext.generateId(`item-${createUniqueId()}`); + + const mergedProps = mergeDefaultProps( + { + id: defaultID, + }, + props, + ); + + const [local, others] = splitProps(mergedProps, [ + "ref", + "value", + "disabled", + "onPointerDown", + "onPointerUp", + "onClick", + "onKeyDown", + "onMouseDown", + "onFocus", + "onChange", + ]); + + const selectionManager = () => rootContext.listState().selectionManager(); + + const isDisabled = () => rootContext.isDisabled() || local.disabled; + + createDomCollectionItem({ + getItem: () => ({ + ref: () => ref, + type: "item", + key: local.value, + textValue: "", + disabled: local.disabled! || rootContext.isDisabled(), + }), + }); + + const selectableItem = createSelectableItem( + { + key: () => local.value, + selectionManager: selectionManager, + disabled: local.disabled || rootContext.isDisabled(), + }, + () => ref, + ); + + const onKeyDown: JSX.EventHandlerUnion = (e) => { + // Prevent `Enter` and `Space` default behavior which fires a click event when using a + + )); + + await user.tab(); + + const button = getByTestId("focus-btn"); + + expect(document.activeElement).toBe(button); + }, + ); + + it.skipIf(process.env.GITHUB_ACTIONS)( + "disabled item should be be pressed", + async () => { + const onValueChangeSpy = vi.fn(); + + const { getByRole } = render(() => ( + + + Dogs + + + Cats + + + Dragons + + + )); + + await user.tab(); + + const toggleGroup = getByRole("group"); + const toggles = within(toggleGroup).getAllByTestId("item"); + + expect(document.activeElement).toBe(toggles[0]); + + await user.click(toggles[1]); + + expect(onValueChangeSpy).not.toBeCalled(); + }, + ); +});