diff --git a/packages/spindle-ui/bundlesize.config.json b/packages/spindle-ui/bundlesize.config.json index 09c5941cf..566c9e80c 100644 --- a/packages/spindle-ui/bundlesize.config.json +++ b/packages/spindle-ui/bundlesize.config.json @@ -9,7 +9,7 @@ "maxSize": "1.1 kB" }, { - "path": "./dist/!(Icon|Toast|DropdownMenu|Pagination|Modal|SnackBar|StackNotificationManager)/*.mjs", + "path": "./dist/!(Icon|Toast|DropdownMenu|Pagination|Modal|SnackBar|StackNotificationManager|SegmentedControl)/*.mjs", "maxSize": "1.1 kB" }, { @@ -36,6 +36,10 @@ "path": "./dist/StackNotificationManager/*.mjs", "maxSize": "2.8 kB" }, + { + "path": "./dist/SegmentedControl/*.mjs", + "maxSize": "1.3 kB" + }, { "path": "./dist/!(InlineNotification|Modal|SnackBar)/!(index).css", "maxSize": "1.5 kB" diff --git a/packages/spindle-ui/src/SegmentedControl/SegmentedControl.css b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.css new file mode 100644 index 000000000..85230c9b0 --- /dev/null +++ b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.css @@ -0,0 +1,114 @@ +.spui-SegmentedControl { + align-items: center; + background-color: var(--color-surface-tertiary); + border-radius: 19px; + box-sizing: border-box; + display: grid; + gap: 4px; + padding: 4px; + position: relative; + width: 100%; +} + +.spui-SegmentedControl--large { + border-radius: 24px; +} + +.spui-SegmentedControl--divider { + gap: 9px; +} + +.spui-SegmentedControl-button { + background-color: transparent; + border: none; + border-radius: 15px; + box-sizing: border-box; + color: var(--color-text-medium-emphasis); + cursor: pointer; + height: 100%; + line-height: 1.3; + min-width: 0; + overflow-wrap: break-word; + padding: 4px; + position: relative; + transition: font-weight 0.35s ease, color 0.35s ease, + background-color 0.15s ease; +} + +.spui-SegmentedControl-button[aria-checked='true'] { + color: var(--color-text-high-emphasis); + cursor: inherit; + font-weight: bold; +} + +.spui-SegmentedControl--divider .spui-SegmentedControl-button::before { + background-color: var(--color-border-low-emphasis); + bottom: 50%; + content: ''; + height: 20px; + left: -5px; + position: absolute; + top: 50%; + transform: translateY(-50%); + transition: background-color 0.35s ease; + width: 1px; +} + +.spui-SegmentedControl--divider + .spui-SegmentedControl-button:first-of-type:before { + content: none; +} + +.spui-SegmentedControl-button[aria-checked='true']::before, +.spui-SegmentedControl-button[aria-checked='true'] + + .spui-SegmentedControl-button[aria-checked='false']::before { + background-color: transparent; +} + +.spui-SegmentedControl-button[aria-checked='false']:hover { + background-color: var(--color-surface-tertiary); +} + +.spui-SegmentedControl-button:focus { + outline: 2px solid var(--color-focus-clarity); + outline-offset: 1px; +} + +.spui-SegmentedControl-button[aria-checked='false']:focus { + background-color: var(--color-surface-secondary); +} + +.spui-SegmentedControl-button:not(:focus-visible) { + outline: none; +} + +.spui-SegmentedControl--medium .spui-SegmentedControl-button { + font-size: 0.8125em; + min-height: 30px; +} + +.spui-SegmentedControl--large .spui-SegmentedControl-button { + border-radius: 20px; + font-size: 1em; + min-height: 40px; +} + +.spui-SegmentedControl-indicator { + background-color: var(--color-surface-primary); + bottom: 4px; + /* TODO: replace color with color palette */ + box-shadow: 0px 3.25px 7.75px rgba(8, 18, 26, 0.06); + content: ''; + height: auto; + left: 0; + position: absolute; + top: 4px; + transition: transform 0.35s ease; +} + +@media (prefers-reduced-motion: reduce) { + .spui-SegmentedControl-button, + .spui-SegmentedControl-indicator--ready { + transition: 0.1ms; + } +} diff --git a/packages/spindle-ui/src/SegmentedControl/SegmentedControl.stories.mdx b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.stories.mdx new file mode 100644 index 000000000..75424d0a7 --- /dev/null +++ b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.stories.mdx @@ -0,0 +1,246 @@ +import { Description, Meta, Story, Source } from '@storybook/addon-docs/blocks'; +import { SegmentedControl, SegmentedControlExample } from './SegmentedControl'; +import { actions } from '@storybook/addon-actions'; + +# SegmentedControl + + + SegmentedControlコンポーネントは、ページ内で機能やモードを切り替える際に利用します。 + + + + 基本的にラジオボタンと同様の機能をもっており、単一の項目のみを選択できます。フォーム内での利用を想定しておらず、1つの機能やモードに対して複数の状態を切り替えることが特徴です。 + + + + + + + + +`} +/> + +## 指定できるプロパティ + + + - `options`(必須): + 表示する項目について配列で指定してください。`id`は項目を識別するための値であり、配列内で一意である必要があります。`label`は画面に表示される値です。推奨する最大文字数は10文字です。 + + + - `selectedId`(必須): optionsプロパティに指定した配列に含まれる id + のうち初期選択状態とする項目の id を指定します。selectedId + に一致する項目がない場合、先頭の項目が選択された状態になります。 + + + - `onClick`(任意): + 項目がクリックされたときに追加で行いたい処理がある場合は指定してください。第2引数の`id`には選択された項目のidが渡されます。 + + + - `size`(任意): + SegmentedControlの大きさを指定します。デフォルトは`medium`で、その他にも`large`を指定できます。 + + +## Size + +### Large + + + + + + + +, id: string) => { + console.log(id); + }, + [], +); +return ( + +); +`} +/> + +### Medium + + + + + + + +, id: string) => { + console.log(id); + }, + [], +); +return ( + +); +`} +/> + +## Total + + + optionsプロパティに指定された配列の項目数に依存します。なお、項目数が過剰に多い場合は、SegmentedControlコンポーネントの使用を非推奨とします。 + + +### Total 1 + + + 項目数が1のケースも許容していますが、ユーザは何も操作できないことに注意してください。 + + + + + + + + +### Total 2 + + + 利用用途に応じて、Form/ToggleSwitch の利用も検討してください。 + + + + + + + + +### Total 3 + + + + + + + +### Total 4 + + + + + + + +### Total 5 + + + + + + + +## Wrapped Text + +文字数や画面幅によっては改行されることがあります。 + + + + + + diff --git a/packages/spindle-ui/src/SegmentedControl/SegmentedControl.test.tsx b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.test.tsx new file mode 100644 index 000000000..5b896f202 --- /dev/null +++ b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.test.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; +import { jest } from '@jest/globals'; + +import { SegmentedControl } from './SegmentedControl'; + +const options = [ + { id: 'small', label: '小' }, + { id: 'medium', label: '中' }, + { id: 'large', label: '大' }, +]; + +const useSegmentedControl = (initialSelectedId = options[0].id) => { + const [selectedId, setSelectedId] = useState(initialSelectedId); + + const onClick = (_: React.MouseEvent, id: string) => { + setSelectedId(id); + }; + + return { selectedId, onClick }; +}; + +describe('', () => { + test('If there is no button with matching selectedId, the first button should be selected', async () => { + render(); + + const button0 = screen.getByText(options[0].label); + const button1 = screen.getByText(options[1].label); + const button2 = screen.getByText(options[2].label); + + expect(button0.getAttribute('aria-checked')).toEqual('true'); + expect(button1.getAttribute('aria-checked')).toEqual('false'); + expect(button2.getAttribute('aria-checked')).toEqual('false'); + }); + + test('Buttons other than the first one should be able to be initially selected as well', async () => { + render(); + + const button0 = screen.getByText(options[0].label); + const button1 = screen.getByText(options[1].label); + const button2 = screen.getByText(options[2].label); + + expect(button0.getAttribute('aria-checked')).toEqual('false'); + expect(button1.getAttribute('aria-checked')).toEqual('true'); + expect(button2.getAttribute('aria-checked')).toEqual('false'); + }); + + test('onClick should be called when the button is clicked', async () => { + const onClick = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText(options[1].label)); + expect(onClick).toBeCalled(); + }); + + test('The button clicked should be selected, the other buttons should not be selected', async () => { + const user = userEvent.setup(); + + render(); + + const button0 = screen.getByText(options[0].label); + const button1 = screen.getByText(options[1].label); + const button2 = screen.getByText(options[2].label); + + expect(button0.getAttribute('aria-checked')).toEqual('true'); + await user.click(button1); + expect(button0.getAttribute('aria-checked')).toEqual('false'); + expect(button1.getAttribute('aria-checked')).toEqual('true'); + expect(button2.getAttribute('aria-checked')).toEqual('false'); + }); + + test('Should be able to accept the selected value as a state value', async () => { + const { result } = renderHook(() => useSegmentedControl()); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText(options[0].label)); + expect(result.current.selectedId).toEqual(options[0].id); + + await user.click(screen.getByText(options[1].label)); + expect(result.current.selectedId).toEqual(options[1].id); + + await user.click(screen.getByText(options[2].label)); + expect(result.current.selectedId).toEqual(options[2].id); + }); + + test('a11y', async () => { + const user = userEvent.setup(); + + render(); + + const button0 = screen.getByText(options[0].label); + const button1 = screen.getByText(options[1].label); + const button2 = screen.getByText(options[2].label); + + button0.focus(); + expect(button0).toHaveFocus(); + expect(button1).not.toHaveFocus(); + expect(button2).not.toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + expect(button0).not.toHaveFocus(); + expect(button1).toHaveFocus(); + expect(button2).not.toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); + expect(button0).not.toHaveFocus(); + expect(button1).not.toHaveFocus(); + expect(button2).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(button2.getAttribute('aria-checked')).toEqual('true'); + }); +}); diff --git a/packages/spindle-ui/src/SegmentedControl/SegmentedControl.tsx b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 000000000..4ea7af5f9 --- /dev/null +++ b/packages/spindle-ui/src/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,162 @@ +import React, { + RefObject, + createRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +type Option = { + id: string; + label: string; +}; + +type Size = 'medium' | 'large'; + +type Props = { + selectedId: string; + options: Option[]; + onClick?: (event: React.MouseEvent, id: string) => void; + size?: Size; +}; + +const BLOCK_NAME = 'spui-SegmentedControl'; + +export const SegmentedControl: React.FC = ({ + selectedId: userSelectedId, + options, + onClick, + size = 'medium', +}) => { + const [selectedId, setSelectedId] = useState(userSelectedId); + const indicatorRef = useRef(null); + const buttonsRef = useRef[]>([]); + const selectedIndex = useMemo( + () => options.findIndex((option) => option.id === selectedId), + [options, selectedId], + ); + + options.forEach((_, index) => { + buttonsRef.current[index] = createRef(); + }); + + useEffect(() => { + indicatorRef.current?.style.setProperty( + 'transform', + // 4はgapのpx値 + `translateX(calc(${100 * selectedIndex}% + ${ + (4 + 4) * selectedIndex + 4 + }px))`, + ); + }, [options, selectedIndex]); + + useEffect(() => { + // selectedIdがどの項目にも一致しない場合は最初の項目を選択する + if (!options.some((option) => option.id === userSelectedId)) { + setSelectedId(options[0].id); + } + }, [options, userSelectedId]); + + const handleClick = useCallback( + (e: React.MouseEvent, id: string) => { + setSelectedId(id); + + if (typeof onClick === 'function') { + onClick(e, id); + } + }, + [onClick], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, index: number) => { + const targetButton = buttonsRef.current[index].current; + switch (e.key) { + case 'ArrowUp': + case 'ArrowLeft': { + e.preventDefault(); + const prevButtonRef = + buttonsRef.current[(options.length + index - 1) % options.length]; + const prevButton = prevButtonRef.current; + prevButton?.focus(); + break; + } + case 'ArrowDown': + case 'ArrowRight': { + e.preventDefault(); + const nextButtonRef = + buttonsRef.current[(index + 1) % options.length]; + const nextButton = nextButtonRef.current; + nextButton?.focus(); + break; + } + case 'Enter': + case 'Space': { + // 選択中の項目をEnter/Spaceした際のイベントを無効にする + if ( + targetButton && + targetButton.getAttribute('aria-checked') === 'true' + ) { + e.preventDefault(); + } + break; + } + } + }, + [options.length], + ); + + if (options.length === 0) { + return null; + } + + return ( +
2 && `${BLOCK_NAME}--divider`, + ] + .filter(Boolean) + .join(' ')} + style={{ + gridTemplateColumns: `repeat(${options.length}, 1fr)`, + }} + role="radiogroup" + > +
+ {options.map((option, index) => { + const isSelected = option.id === selectedId; + return ( + + ); + })} +
+ ); +}; diff --git a/packages/spindle-ui/src/SegmentedControl/index.ts b/packages/spindle-ui/src/SegmentedControl/index.ts new file mode 100644 index 000000000..e66d1d3cc --- /dev/null +++ b/packages/spindle-ui/src/SegmentedControl/index.ts @@ -0,0 +1 @@ +export { SegmentedControl } from './SegmentedControl'; diff --git a/packages/spindle-ui/src/index.css b/packages/spindle-ui/src/index.css index b45bb3450..1ee6f4c32 100644 --- a/packages/spindle-ui/src/index.css +++ b/packages/spindle-ui/src/index.css @@ -19,3 +19,4 @@ @import './DropdownMenu/DropdownMenu.css'; @import './Pagination/Pagination.css'; @import './InlineNotification/InlineNotification.css'; +@import './SegmentedControl/SegmentedControl.css'; diff --git a/packages/spindle-ui/src/index.ts b/packages/spindle-ui/src/index.ts index 087aa82af..58157f906 100644 --- a/packages/spindle-ui/src/index.ts +++ b/packages/spindle-ui/src/index.ts @@ -13,3 +13,4 @@ export { Toast } from './Toast'; export { DropdownMenu } from './DropdownMenu'; export { SnackBar } from './SnackBar'; export { InlineNotification } from './InlineNotification'; +export { SegmentedControl } from './SegmentedControl';