From 597f73c73d012ae710780f4ac294a377e4f53735 Mon Sep 17 00:00:00 2001 From: Jake Laderman Date: Tue, 25 Feb 2025 17:06:30 -0500 Subject: [PATCH] feat: SegmentedInput --- src/components/SegmentedInput.tsx | 237 +++++++++++++++++++++++++ src/hooks/useIsFocused.tsx | 34 ++++ src/index.ts | 221 ++++++++++++----------- src/stories/SegmentedInput.stories.tsx | 115 ++++++++++++ 4 files changed, 500 insertions(+), 107 deletions(-) create mode 100644 src/components/SegmentedInput.tsx create mode 100644 src/hooks/useIsFocused.tsx create mode 100644 src/stories/SegmentedInput.stories.tsx diff --git a/src/components/SegmentedInput.tsx b/src/components/SegmentedInput.tsx new file mode 100644 index 00000000..7753c144 --- /dev/null +++ b/src/components/SegmentedInput.tsx @@ -0,0 +1,237 @@ +// NOTE: this should be redesigned at some point to be a fully controlled component +// useImperativeHandle gives an escape hatch if parent needs to change values, but it's super bug prone if not used carefully +import { produce } from 'immer' +import { clamp, inRange } from 'lodash' +import { + ComponentPropsWithoutRef, + Ref, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import useIsFocused from '../hooks/useIsFocused' +import Input from './Input' + +export type Segment = { + length: number + min?: number + max: number + name: string + initialVal?: string +} + +export type SegmentedInputHandle = { + clear: () => void + inputRef: React.RefObject + setValue: (val: string) => void +} + +export type SegmentedInputProps = { + onChange: (value: string) => void + separator: string + segments: Segment[] + ref?: Ref +} & ComponentPropsWithoutRef + +export default function SegmentedInput({ + onChange, + separator, + segments, + ref, + ...props +}: SegmentedInputProps) { + const inputRef = useRef(null) + const [selectedSegIdx, setSelectedSegIdx] = useState(null) + const [segmentVals, setSegmentVals] = useState(() => + segments.map((segment) => segment.initialVal ?? '') + ) + + const displayedValue = useMemo( + () => + segmentVals + .map((val, segNum) => + val === '' + ? segments[segNum].name + : val.padStart(segments[segNum].length, '0') + ) + .join(separator), + [segmentVals, segments, separator] + ) + + // maps the segment number to its indices in the string (not including separators) + const segNumToSelectionRange: { start: number; end: number }[] = + useMemo(() => { + let start = 0 + return segments.map((segment) => { + const range = { start, end: start + segment.length } + start += segment.length + separator.length + return range + }) + }, [segments, separator.length]) + + // maps indices in the string (including separators) to their segment number + // will have some extra entries at the end but that's fine + const cursorPosToSegNum: number[] = useMemo( + () => + segments.flatMap((segInfo, segNum) => + Array(segInfo.length + separator.length).fill(segNum) + ), + [segments, separator.length] + ) + + // select clicked segment on focus (RAF to ensure the cursor position is updated), deselect on blur + const [isFocused, focusHandlers] = useIsFocused({ + onBlur: () => setSelectedSegIdx(null), + onFocus: () => + requestAnimationFrame(() => + setSelectedSegIdx( + cursorPosToSegNum[inputRef.current?.selectionStart ?? 0] + ) + ), + }) + + // highlights the currently selected segment in the input + const applySelection = useCallback(() => { + if (isFocused && selectedSegIdx !== null) { + const { start, end } = segNumToSelectionRange[selectedSegIdx] + inputRef.current?.setSelectionRange(start, end) + } + }, [isFocused, selectedSegIdx, segNumToSelectionRange]) + + // re-highlight when values change so segment stays highlighted (also fires provided onChange callback) + useEffect(() => { + applySelection() + onChange(segmentVals.join(separator)) + }, [applySelection, onChange, segmentVals, separator]) + + const handleClick = () => { + const newIdx = cursorPosToSegNum[inputRef.current?.selectionStart ?? 0] + // keeps segment highlighted + if (newIdx === selectedSegIdx) applySelection() + setSelectedSegIdx(newIdx) + } + + const updateSegment = useCallback( + (segIdx: number, newValue: string) => { + if (!inRange(segIdx, 0, segments.length)) return + + const { length, min = 0, max } = segments[segIdx] + let validatedValue = newValue + + // only validate against min/max when the segment is fully filled + if (validatedValue.length === length) { + const numValue = parseInt(validatedValue) + if (!isNaN(numValue)) + validatedValue = clamp(numValue, min, max).toString() + } + + setSegmentVals( + produce((prev) => { + prev[segIdx] = validatedValue + }) + ) + }, + [segments] + ) + + // handle arrow left/right navigation + const handleSegmentChange = useCallback( + (e: React.KeyboardEvent) => { + if (selectedSegIdx === null) return + + const key = e.key + const direction = key === 'ArrowRight' ? 1 : -1 + const nextIndex = selectedSegIdx + direction + + if (nextIndex >= 0 && nextIndex < segments.length) { + setSelectedSegIdx(nextIndex) + } + }, + [selectedSegIdx, segments.length] + ) + + const handleSegmentValueChange = useCallback( + ({ key }: React.KeyboardEvent) => { + if (selectedSegIdx === null) return + const { min = 0, max, length } = segments[selectedSegIdx] + const curSegVal = segmentVals[selectedSegIdx] + let newVal = '' + + // handle arrow up/down keys + if (key === 'ArrowUp' || key === 'ArrowDown') { + const segValNum = curSegVal === '' ? min : parseInt(curSegVal, 10) + if (isNaN(segValNum)) return + newVal = `${clamp(segValNum + (key === 'ArrowUp' ? 1 : -1), min, max)}` + } + // handle numeric kesy + else if (/^\d$/.test(key)) { + newVal = curSegVal.length < length ? curSegVal + key : key + // auto-advance when the segment is completely filled + if (newVal.length === length && selectedSegIdx < segments.length - 1) + setSelectedSegIdx(selectedSegIdx + 1) + } + updateSegment(selectedSegIdx, newVal) + }, + [selectedSegIdx, segments, segmentVals, updateSegment] + ) + + // handle clearing segment (delete/backspace) + const handleSegmentClear = useCallback(() => { + if (selectedSegIdx === null) return + + updateSegment(selectedSegIdx, '') + }, [selectedSegIdx, updateSegment]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key + if (!(e.ctrlKey || e.metaKey || key === 'Tab')) e.preventDefault() + + switch (key) { + case 'ArrowRight': + case 'ArrowLeft': + handleSegmentChange(e) + break + case 'Backspace': + case 'Delete': + handleSegmentClear() + break + default: + if (/^\d$/.test(key) || key === 'ArrowUp' || key === 'ArrowDown') + handleSegmentValueChange(e) + break + } + }, + [handleSegmentChange, handleSegmentValueChange, handleSegmentClear] + ) + // a little hacky but not the end of the world + useImperativeHandle( + ref, + () => ({ + clear: () => { + setSegmentVals(segments.map(() => '')) + setSelectedSegIdx(0) + }, + setValue: (val: string) => { + setSegmentVals(val.split(separator)) + }, + inputRef, + }), + [segments, separator] + ) + + return ( + + ) +} diff --git a/src/hooks/useIsFocused.tsx b/src/hooks/useIsFocused.tsx new file mode 100644 index 00000000..2290bccb --- /dev/null +++ b/src/hooks/useIsFocused.tsx @@ -0,0 +1,34 @@ +// from https://github.com/rsuite/rsuite/blob/main/src/DateInput/hooks/useIsFocused.ts +import React, { useState, useCallback } from 'react' + +interface FocusEventOptions { + onFocus?: React.FocusEventHandler + onBlur?: React.FocusEventHandler +} + +export function useIsFocused({ + onFocus: onFocusProp, + onBlur: onBlurProp, +}: FocusEventOptions): [boolean, FocusEventOptions] { + const [isFocused, setIsFocused] = useState(false) + + const onFocus = useCallback( + (event: React.FocusEvent) => { + setIsFocused(true) + onFocusProp?.(event) + }, + [onFocusProp] + ) + + const onBlur = useCallback( + (event: React.FocusEvent) => { + setIsFocused(false) + onBlurProp?.(event) + }, + [onBlurProp] + ) + + return [isFocused, { onFocus, onBlur }] +} + +export default useIsFocused diff --git a/src/index.ts b/src/index.ts index 952b3448..f3527cb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,85 +1,108 @@ export { Avatar } from 'honorable' // Icons -export * from './icons' export type { IconProps } from './components/icons/createIcon' +export * from './icons' // PluralLogos export * from './plural-logos' // Components -export type { AccordionProps } from './components/Accordion' export { default as Accordion, AccordionItem } from './components/Accordion' +export type { AccordionProps } from './components/Accordion' +export { AnimatedDiv } from './components/AnimatedDiv' +export { default as AppIcon } from './components/AppIcon' +export { AppList } from './components/AppList' +export type { + AppListProps, + AppMenuAction, + AppProps, +} from './components/AppList' export { default as ArrowScroll } from './components/ArrowScroll' export { default as Banner } from './components/Banner' +export { Breadcrumbs } from './components/Breadcrumbs' export { default as Button } from './components/Button' -export type { CardProps } from './components/Card' -export { default as Card } from './components/Card' -export type { CalloutProps } from './components/Callout' export { default as Callout } from './components/Callout' +export type { CalloutProps } from './components/Callout' +export { default as Card } from './components/Card' +export type { CardProps } from './components/Card' export { default as CatalogCard } from './components/CatalogCard' export { default as Checkbox } from './components/Checkbox' +export { Checklist } from './components/Checklist' +export type { + ChecklistProps, + ChecklistStateProps, +} from './components/Checklist' +export { ChecklistItem } from './components/ChecklistItem' +export type { ChecklistItemProps } from './components/ChecklistItem' export { default as Chip, type ChipProps, - type ChipSize, type ChipSeverity, + type ChipSize, } from './components/Chip' export { default as ChipList } from './components/ChipList' export { default as Code } from './components/Code' export { default as CodeEditor } from './components/CodeEditor' export { default as Codeline } from './components/Codeline' +export { ComboBox } from './components/ComboBox' export { default as ContentCard } from './components/ContentCard' export { default as Date } from './components/Date' export { default as Divider } from './components/Divider' export { default as EmptyState } from './components/EmptyState' -export type { FlexProps } from './components/Flex' export { default as Flex } from './components/Flex' +export type { FlexProps } from './components/Flex' +export { default as Flyover } from './components/Flyover' export { default as FormField } from './components/FormField' +export { default as FormTitle } from './components/FormTitle' export { default as Highlight } from './components/Highlight' -export type { IconFrameProps } from './components/IconFrame' export { default as IconFrame } from './components/IconFrame' +export type { IconFrameProps } from './components/IconFrame' +export { default as InlineCode } from './components/InlineCode' export { default as Input } from './components/Input' export { default as Input2 } from './components/Input2' +export { LightDarkSwitch } from './components/LightDarkSwitch' +export { ListBox } from './components/ListBox' +export { + ListBoxFooter, + ListBoxFooterPlus, + ListBoxItem, + type ListBoxFooterProps as ListBoxFooterPlusProps, + type ListBoxFooterProps, + type ListBoxItemBaseProps, + type ListBoxItemProps, +} from './components/ListBoxItem' +export { default as ListBoxItemChipList } from './components/ListBoxItemChipList' +export { default as LoadingSpinner } from './components/LoadingSpinner' +export { default as LoopingLogo } from './components/LoopingLogo' export { default as Markdown } from './components/Markdown' export { default as Menu } from './components/Menu' export { default as MenuItem } from './components/MenuItem' -export type { PageCardProps } from './components/PageCard' +export { default as Modal } from './components/Modal' +export { ModalWrapper } from './components/ModalWrapper' export { default as PageCard } from './components/PageCard' +export type { PageCardProps } from './components/PageCard' export { default as PageTitle } from './components/PageTitle' export { default as ProgressBar } from './components/ProgressBar' export { default as Prop } from './components/Prop' -export { default as PropWide } from './components/PropWide' export { default as PropsContainer } from './components/PropsContainer' -export { default as UserDetails } from './components/UserDetails' +export { default as PropWide } from './components/PropWide' export { default as Radio } from './components/Radio' export { default as RadioGroup } from './components/RadioGroup' -export { default as AppIcon } from './components/AppIcon' export { default as RepositoryCard } from './components/RepositoryCard' export { default as RepositoryChip } from './components/RepositoryChip' -export { default as StackCard } from './components/StackCard' -export { default as Stepper, type StepperSteps } from './components/Stepper' -export type { SidecarProps } from './components/Sidecar' -export { - default as Sidecar, - SidecarItem, - SidecarButton, -} from './components/Sidecar' -export { default as SubTab } from './components/SubTab' -export { default as Tab } from './components/Tab' -export type { TabListStateProps, TabBaseProps } from './components/TabList' -export { TabList } from './components/TabList' -export { default as TabPanel } from './components/TabPanel' -export { default as Table } from './components/table/Table' -export type { TableProps } from './components/table/tableUtils' -export { default as TipCarousel } from './components/TipCarousel' export { - type ValidationResponse, - default as ValidatedInput, -} from './components/ValidatedInput' -export type { TooltipProps } from './components/Tooltip' -export { default as Tooltip } from './components/Tooltip' -export { default as FormTitle } from './components/FormTitle' + default as SegmentedInput, + type Segment, + type SegmentedInputHandle, + type SegmentedInputProps, +} from './components/SegmentedInput' +export { Select, SelectButton } from './components/Select' +export type { + SelectPropsMultiple, + SelectPropsSingle, +} from './components/Select' +export { SetInert } from './components/SetInert' export { default as Sidebar, SIDEBAR_WIDTH, @@ -87,123 +110,107 @@ export { type SidebarLayout, type SidebarVariant, } from './components/Sidebar' -export { default as SidebarSection } from './components/SidebarSection' export { default as SidebarExpandButton } from './components/SidebarExpandButton' export { - default as SidebarExpandWrapper, SIDEBAR_EXPANDED_WIDTH, + default as SidebarExpandWrapper, } from './components/SidebarExpandWrapper' export { default as SidebarItem } from './components/SidebarItem' -export { default as Modal } from './components/Modal' -export { default as Flyover } from './components/Flyover' -export { ModalWrapper } from './components/ModalWrapper' -export type { - ChecklistProps, - ChecklistStateProps, -} from './components/Checklist' -export { Checklist } from './components/Checklist' -export type { ChecklistItemProps } from './components/ChecklistItem' -export { ChecklistItem } from './components/ChecklistItem' -export { default as InlineCode } from './components/InlineCode' -export * from './components/wizard' -export * from './components/TreeNavigation' -export { ListBox } from './components/ListBox' +export { default as SidebarSection } from './components/SidebarSection' export { - ListBoxItem, - ListBoxFooter, - ListBoxFooterPlus, - type ListBoxItemBaseProps, - type ListBoxItemProps, - type ListBoxFooterProps, - type ListBoxFooterProps as ListBoxFooterPlusProps, -} from './components/ListBoxItem' -export { default as ListBoxItemChipList } from './components/ListBoxItemChipList' -export { Select, SelectButton } from './components/Select' -export type { - SelectPropsSingle, - SelectPropsMultiple, -} from './components/Select' -export { default as LoadingSpinner } from './components/LoadingSpinner' -export { default as LoopingLogo } from './components/LoopingLogo' -export { ComboBox } from './components/ComboBox' -export type { TagMultiSelectProps } from './components/TagMultiSelect' -export { TagMultiSelect } from './components/TagMultiSelect' -export { Toast, GraphQLToast } from './components/Toast' -export { default as WrapWithIf } from './components/WrapWithIf' -export type { - AppProps, - AppListProps, - AppMenuAction, -} from './components/AppList' -export { AppList } from './components/AppList' + default as Sidecar, + SidecarButton, + SidecarItem, +} from './components/Sidecar' +export type { SidecarProps } from './components/Sidecar' export { default as Slider } from './components/Slider' -export { Breadcrumbs } from './components/Breadcrumbs' -export { Switch } from './components/Switch' -export { LightDarkSwitch } from './components/LightDarkSwitch' -export { AnimatedDiv } from './components/AnimatedDiv' export { Spinner } from './components/Spinner' -export { SetInert } from './components/SetInert' +export { default as StackCard } from './components/StackCard' +export { default as Stepper, type StepperSteps } from './components/Stepper' +export { default as SubTab } from './components/SubTab' +export { Switch } from './components/Switch' +export { default as Tab } from './components/Tab' +export { default as Table } from './components/table/Table' +export type { TableProps } from './components/table/tableUtils' +export { TabList } from './components/TabList' +export type { TabBaseProps, TabListStateProps } from './components/TabList' +export { default as TabPanel } from './components/TabPanel' +export { TagMultiSelect } from './components/TagMultiSelect' +export type { TagMultiSelectProps } from './components/TagMultiSelect' export { default as TextSwitch } from './components/TextSwitch' +export { default as TipCarousel } from './components/TipCarousel' +export { GraphQLToast, Toast } from './components/Toast' +export { default as Tooltip } from './components/Tooltip' +export type { TooltipProps } from './components/Tooltip' +export * from './components/TreeNavigation' +export { default as UserDetails } from './components/UserDetails' +export { + default as ValidatedInput, + type ValidationResponse, +} from './components/ValidatedInput' +export * from './components/wizard' +export { default as WrapWithIf } from './components/WrapWithIf' // Hooks +export { useFloatingDropdown } from './hooks/useFloatingDropdown' export { useInert } from './hooks/useInert' +export { useIsFocused } from './hooks/useIsFocused' export { default as usePrevious } from './hooks/usePrevious' -export { default as useUnmount } from './hooks/useUnmount' -export { useFloatingDropdown } from './hooks/useFloatingDropdown' export { default as useResizeObserver } from './hooks/useResizeObserver' +export { default as useUnmount } from './hooks/useUnmount' // Contexts +export { + BreadcrumbsProvider, + useBreadcrumbs, + useSetBreadcrumbs, + type Breadcrumb, +} from './components/contexts/BreadcrumbsContext' +export { ColorModeProvider } from './components/contexts/ColorModeProvider' export { FillLevelContext, FillLevelProvider, - useFillLevel, - toFillLevel, isFillLevel, + toFillLevel, + useFillLevel, type FillLevel, } from './components/contexts/FillLevelContext' export * from './components/contexts/NavigationContext' export * from './components/TreeNavigation' -export { - BreadcrumbsProvider, - useBreadcrumbs, - useSetBreadcrumbs, - type Breadcrumb, -} from './components/contexts/BreadcrumbsContext' -export { ColorModeProvider } from './components/contexts/ColorModeProvider' // Theme +export { default as GlobalStyle } from './GlobalStyle' export { - honorableThemeDark as theme, - honorableThemeLight, honorableThemeDark, + honorableThemeLight, + setThemeColorMode, styledTheme, - styledThemeLight, styledThemeDark, - setThemeColorMode, + styledThemeLight, + honorableThemeDark as theme, useThemeColorMode, } from './theme' -export type { SemanticColorKey, SemanticColorCssVar } from './theme/colors' -export { semanticColorKeys, semanticColorCssVars } from './theme/colors' -export { default as GlobalStyle } from './GlobalStyle' +export { semanticColorCssVars, semanticColorKeys } from './theme/colors' +export type { SemanticColorCssVar, SemanticColorKey } from './theme/colors' // Utils export { default as scrollIntoContainerView } from './utils/scrollIntoContainerView' export * from './utils/urls' // Markdoc -export type { MarkdocContextValue } from './markdoc/MarkdocContext' +export * as markdocComponents from './markdoc/components' +export * as markdocConfig from './markdoc/config' +export * as markdocFunctions from './markdoc/functions' export { - useMarkdocContext, MarkdocContextProvider, + useMarkdocContext, } from './markdoc/MarkdocContext' +export type { MarkdocContextValue } from './markdoc/MarkdocContext' +export * as markdocNodes from './markdoc/nodes' export { getSchema as getRuntimeSchema } from './markdoc/runtimeSchema' +export * as markdocTags from './markdoc/tags' export { default as collectHeadings, type MarkdocHeading, } from './markdoc/utils/collectHeadings' export { getMdContent } from './markdoc/utils/getMdContent' -export * as markdocConfig from './markdoc/config' -export * as markdocFunctions from './markdoc/functions' -export * as markdocNodes from './markdoc/nodes' -export * as markdocTags from './markdoc/tags' -export * as markdocComponents from './markdoc/components' diff --git a/src/stories/SegmentedInput.stories.tsx b/src/stories/SegmentedInput.stories.tsx new file mode 100644 index 00000000..9c41f204 --- /dev/null +++ b/src/stories/SegmentedInput.stories.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import styled from 'styled-components' +import { Segment, SegmentedInput } from '..' + +type StoryProps = { + format: string + separator: string + segments?: Segment[] +} + +const DATE_SEGMENTS = [ + { length: 2, max: 12, name: 'MM', initialVal: '01' }, + { length: 2, min: 1, max: 31, name: 'DD', initialVal: '15' }, + { length: 4, max: 9999, name: 'YYYY', initialVal: '2023' }, +] + +const CUSTOM_SEGMENTS = [ + { length: 3, max: 999, name: 'XXX', initialVal: '' }, + { length: 3, max: 999, name: 'YYY', initialVal: '' }, + { length: 3, max: 999, name: 'ZZZ', initialVal: '' }, +] + +const EMPTY_SEGMENTS = [ + { length: 2, max: 12, name: 'MM' }, + { length: 2, min: 1, max: 31, name: 'DD' }, + { length: 4, max: 9999, name: 'YYYY' }, +] + +export default { + title: 'SegmentedInput', + component: SegmentedInput, + argTypes: { + format: { + options: ['date', 'custom'], + control: { type: 'select' }, + defaultValue: 'date', + }, + separator: { + control: { type: 'text' }, + defaultValue: '/', + }, + }, +} + +const WrapperSC = styled.div(({ theme }) => ({ + maxWidth: '300px', + marginBottom: theme.spacing.large, +})) + +const DemoHeadingSC = styled.h3(({ theme }) => ({ + ...theme.partials.text.subtitle2, + marginBottom: theme.spacing.medium, +})) + +function Template({ format, separator, segments }: StoryProps) { + const [dateValue, setDateValue] = useState('') + const [customValue, setCustomValue] = useState('') + + switch (format) { + case 'date': + return ( + + + Date Input (MM{separator}DD{separator}YYYY) + + +
Value: {dateValue}
+
+ ) + case 'custom': + return ( + + + Custom Format (XXX{separator}YYY{separator}ZZZ) + + +
Value: {customValue}
+
+ ) + default: + return ( + + Empty Input + {}} + separator={separator} + segments={segments || EMPTY_SEGMENTS} + placeholder="Enter date..." + /> + + ) + } +} + +export const DateInput = Template.bind({}) +DateInput.args = { + format: 'date', + separator: '/', + segments: DATE_SEGMENTS, +} + +export const CustomFormatInput = Template.bind({}) +CustomFormatInput.args = { + format: 'custom', + separator: '.', + segments: CUSTOM_SEGMENTS, +}