@charcoal-ui/react 2.7.0 → 3.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/components/Button/index.d.ts +1 -1
  2. package/dist/components/Button/index.d.ts.map +1 -1
  3. package/dist/components/DropdownSelector/DropdownPopover.d.ts.map +1 -1
  4. package/dist/components/DropdownSelector/OptionItem.d.ts +7 -0
  5. package/dist/components/DropdownSelector/OptionItem.d.ts.map +1 -0
  6. package/dist/components/DropdownSelector/OptionLi.d.ts +11 -0
  7. package/dist/components/DropdownSelector/OptionLi.d.ts.map +1 -0
  8. package/dist/components/DropdownSelector/index.d.ts +22 -29
  9. package/dist/components/DropdownSelector/index.d.ts.map +1 -1
  10. package/dist/components/DropdownSelector/index.story.d.ts +5 -18
  11. package/dist/components/DropdownSelector/index.story.d.ts.map +1 -1
  12. package/dist/components/DropdownSelector/utils/focusIfHTMLLIElement.d.ts +6 -0
  13. package/dist/components/DropdownSelector/utils/focusIfHTMLLIElement.d.ts.map +1 -0
  14. package/dist/components/DropdownSelector/utils/handleFocusByKeyBoard.d.ts +6 -0
  15. package/dist/components/DropdownSelector/utils/handleFocusByKeyBoard.d.ts.map +1 -0
  16. package/dist/index.cjs.js +265 -313
  17. package/dist/index.cjs.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.esm.js +248 -296
  21. package/dist/index.esm.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/components/Button/index.story.tsx +6 -6
  24. package/src/components/Button/index.tsx +5 -5
  25. package/src/components/DropdownSelector/DropdownPopover.tsx +9 -15
  26. package/src/components/DropdownSelector/OptionItem.tsx +85 -0
  27. package/src/components/DropdownSelector/index.story.tsx +69 -156
  28. package/src/components/DropdownSelector/index.tsx +110 -140
  29. package/src/components/DropdownSelector/utils/focusIfHTMLLIElement.tsx +12 -0
  30. package/src/components/DropdownSelector/utils/handleFocusByKeyBoard.tsx +20 -0
  31. package/src/components/Modal/index.story.tsx +5 -5
  32. package/src/components/Modal/index.tsx +1 -1
  33. package/src/index.ts +6 -1
  34. package/src/components/DropdownSelector/ListBoxSection.tsx +0 -60
  35. package/src/components/DropdownSelector/Listbox.tsx +0 -67
  36. package/src/components/DropdownSelector/Option.tsx +0 -66
@@ -1,156 +1,130 @@
1
- import React, { Key, useMemo, useRef } from 'react'
1
+ import React, { ReactNode, createContext, useRef } from 'react'
2
2
  import styled from 'styled-components'
3
- import { Item, useSelectState } from 'react-stately'
3
+ import { useOverlayTriggerState } from 'react-stately'
4
4
  import { disabledSelector } from '@charcoal-ui/utils'
5
- import { useVisuallyHidden } from '@react-aria/visually-hidden'
6
- import { useSelect, HiddenSelect } from '@react-aria/select'
7
- import { useButton } from '@react-aria/button'
8
- import { SelectProps } from '@react-types/select'
9
- import Listbox, { ListboxProps } from './Listbox'
10
5
  import Icon from '../Icon'
11
6
  import FieldLabel from '../FieldLabel'
12
7
  import { theme } from '../../styled'
13
-
14
- import type { CollectionBase } from '@react-types/shared'
15
- import type { ReactNode } from 'react'
16
8
  import { DropdownPopover } from './DropdownPopover'
17
9
 
18
- type LabelProps = {
19
- readonly showLabel?: boolean
20
- readonly label: string
21
- readonly subLabel?: ReactNode
22
- readonly requiredText?: string
10
+ export const DropdownSelectorContext = createContext({
11
+ value: '',
12
+ setValue: (_v: string) => {
13
+ // empty
14
+ },
15
+ })
16
+
17
+ export type DropdownSelectorProps = {
18
+ label: string
19
+ value: string
20
+ disabled?: boolean
21
+ placeholder?: string
22
+ showLabel?: boolean
23
+ invalid?: boolean
24
+ assistiveText?: string
25
+ required?: boolean
26
+ requiredText?: string
27
+ subLabel?: ReactNode
28
+ children?: ReactNode
29
+ onChange: (value: string) => void
23
30
  }
24
31
 
25
- type Empty = Record<string, unknown>
26
- export type DropdownSelectorProps<T extends Empty = Empty> = LabelProps &
27
- Readonly<CollectionBase<T>> & {
28
- readonly id?: string
29
- readonly name?: string
30
- readonly autoComplete?: string
31
- readonly placeholder?: string
32
- readonly className?: string
33
- readonly disabled?: boolean
34
- readonly required?: boolean
35
- readonly invalid?: boolean
36
- readonly assertiveText?: string
37
- readonly value?: Key
38
- readonly defaultValue?: Key
39
- readonly open?: boolean
40
- readonly onOpenChange?: (isOpen?: boolean) => void
41
- readonly onChange?: (key: Key) => void
42
- readonly mode?: ListboxProps<T>['mode']
43
- }
44
-
45
- const DropdownSelector = <T extends Record<string, unknown>>({
46
- open,
47
- className,
48
- label = '',
49
- requiredText = '',
50
- subLabel,
51
- assertiveText,
52
- autoComplete,
53
- invalid = false,
54
- disabled = false,
55
- required = false,
56
- showLabel = false,
57
- mode = 'default',
58
- ...props
59
- }: DropdownSelectorProps<T>) => {
60
- const { visuallyHiddenProps } = useVisuallyHidden()
61
- const triggerRef = useRef<HTMLButtonElement>(null)
62
- const selectProps = useMemo<SelectProps<T>>(
63
- () => ({
64
- ...props,
65
- label,
66
- isOpen: open,
67
- isDisabled: disabled,
68
- isRequired: required,
69
- errorMessage: invalid && assertiveText,
70
- validationState: invalid ? 'invalid' : 'valid',
71
- onSelectionChange: props.onChange,
72
- selectedKey: props.value,
73
- defaultSelectedKey: props.defaultValue,
74
- }),
75
- [assertiveText, disabled, invalid, label, open, props, required]
76
- )
77
- const state = useSelectState<T>(selectProps)
78
-
79
- const {
80
- labelProps,
81
- triggerProps,
82
- valueProps,
83
- menuProps,
84
- errorMessageProps,
85
- descriptionProps,
86
- } = useSelect<T>(selectProps, state, triggerRef)
32
+ export type DropdownSelectorOption = {
33
+ label: string
34
+ id: string
35
+ }
87
36
 
88
- const { buttonProps } = useButton(triggerProps, triggerRef)
37
+ const defaultRequiredText = '*必須'
89
38
 
90
- const hasAssertiveText =
91
- assertiveText !== undefined && assertiveText.length > 0
39
+ export default function DropdownSelector(props: DropdownSelectorProps) {
40
+ const triggerRef = useRef<HTMLButtonElement>(null)
41
+ const state = useOverlayTriggerState({})
42
+
43
+ let preview: ReactNode | undefined
44
+ const childArray = React.Children.toArray(props.children)
45
+ for (let i = 0; i < childArray.length; i++) {
46
+ const child = childArray[i]
47
+ if (React.isValidElement(child) && 'value' in child.props) {
48
+ const find = (child.props as { value: string }).value === props.value
49
+ if (find && 'children' in child.props) {
50
+ preview = (child.props as { children: ReactNode }).children
51
+ break
52
+ }
53
+ }
54
+ }
92
55
 
93
56
  return (
94
- <DropdownSelectorRoot aria-disabled={disabled} className={className}>
95
- <DropdownFieldLabel
96
- label={label}
97
- required={required}
98
- requiredText={requiredText}
99
- subLabel={subLabel}
100
- {...labelProps}
101
- {...(!showLabel ? visuallyHiddenProps : {})}
102
- />
103
- <HiddenSelect
104
- state={state}
105
- triggerRef={triggerRef}
106
- label={label}
107
- name={props.name}
108
- isDisabled={disabled}
109
- autoComplete={autoComplete}
110
- />
111
- <DropdownButtonWrapper>
112
- <DropdownButton {...buttonProps} ref={triggerRef} invalid={invalid}>
113
- <DropdownButtonText {...valueProps}>
114
- {/*
115
- * react-stately の useSelectState から取得される selectedItem の型が常に
116
- * Node<T> であるが runtime では null が帰ってくることがある
117
- */}
118
- {/* eslint-disable-next-line @typescript-eslint/strict-boolean-expressions,@typescript-eslint/no-unnecessary-condition*/}
119
- {state.selectedItem
120
- ? state.selectedItem.rendered
121
- : props.placeholder}
122
- </DropdownButtonText>
123
-
124
- <DropdownButtonIcon name="16/Menu" />
125
- </DropdownButton>
126
- {state.isOpen && (
127
- <DropdownPopover
128
- state={state}
129
- triggerRef={triggerRef}
130
- value={props.value ?? props.defaultValue}
131
- >
132
- <Listbox {...menuProps} state={state} mode={mode} />
133
- </DropdownPopover>
134
- )}
135
- </DropdownButtonWrapper>
136
-
137
- {hasAssertiveText && (
138
- <AssertiveText
139
- invalid={invalid}
140
- {...(invalid ? errorMessageProps : descriptionProps)}
57
+ <DropdownSelectorRoot aria-disabled={props.disabled}>
58
+ {props.showLabel === true && (
59
+ <DropdownFieldLabel
60
+ label={props.label}
61
+ required={props.required}
62
+ requiredText={props.requiredText ?? defaultRequiredText}
63
+ subLabel={props.subLabel}
64
+ />
65
+ )}
66
+ <DropdownButton
67
+ invalid={props.invalid}
68
+ disabled={props.disabled}
69
+ onClick={() => {
70
+ if (props.disabled === true) return
71
+ state.open()
72
+ }}
73
+ ref={triggerRef}
74
+ >
75
+ <DropdownButtonText>
76
+ {props.placeholder !== undefined && preview === undefined
77
+ ? props.placeholder
78
+ : preview}
79
+ </DropdownButtonText>
80
+ <DropdownButtonIcon name="16/Menu" />
81
+ </DropdownButton>
82
+ {state.isOpen && (
83
+ <DropdownPopover
84
+ state={state}
85
+ triggerRef={triggerRef}
86
+ value={props.value}
141
87
  >
142
- {assertiveText}
88
+ <ListboxRoot>
89
+ <DropdownSelectorContext.Provider
90
+ value={{
91
+ value: props.value,
92
+ setValue: (v) => {
93
+ props.onChange(v)
94
+ state.close()
95
+ },
96
+ }}
97
+ >
98
+ {props.children}
99
+ </DropdownSelectorContext.Provider>
100
+ </ListboxRoot>
101
+ </DropdownPopover>
102
+ )}
103
+ {props.assistiveText !== undefined && (
104
+ <AssertiveText invalid={props.invalid}>
105
+ {props.assistiveText}
143
106
  </AssertiveText>
144
107
  )}
145
108
  </DropdownSelectorRoot>
146
109
  )
147
110
  }
148
111
 
149
- export default DropdownSelector
150
- export const DropdownSelectorItem = Item
151
-
112
+ const ListboxRoot = styled.ul`
113
+ padding-left: 0;
114
+ margin: 0;
115
+ box-sizing: border-box;
116
+ list-style: none;
117
+ overflow: auto;
118
+ max-height: inherit;
119
+
120
+ ${theme((o) => [
121
+ o.bg.background1,
122
+ o.border.default,
123
+ o.borderRadius(8),
124
+ o.padding.vertical(8),
125
+ ])}
126
+ `
152
127
  const DropdownSelectorRoot = styled.div`
153
- position: relative;
154
128
  display: inline-block;
155
129
  width: 100%;
156
130
 
@@ -166,11 +140,7 @@ const DropdownFieldLabel = styled(FieldLabel)`
166
140
  ${theme((o) => o.margin.bottom(8))}
167
141
  `
168
142
 
169
- const DropdownButtonWrapper = styled.div`
170
- position: relative;
171
- `
172
-
173
- const DropdownButton = styled.button<{ invalid: boolean }>`
143
+ const DropdownButton = styled.button<{ invalid?: boolean }>`
174
144
  display: flex;
175
145
  justify-content: space-between;
176
146
  align-items: center;
@@ -191,7 +161,7 @@ const DropdownButton = styled.button<{ invalid: boolean }>`
191
161
  o.outline.default.focus,
192
162
  o.bg.surface3,
193
163
  o.borderRadius(4),
194
- invalid && o.outline.assertive,
164
+ invalid === true && o.outline.assertive,
195
165
  ])}
196
166
  `
197
167
 
@@ -205,11 +175,11 @@ const DropdownButtonIcon = styled(Icon)`
205
175
  ${theme((o) => [o.font.text2])}
206
176
  `
207
177
 
208
- const AssertiveText = styled.div<{ invalid: boolean }>`
178
+ const AssertiveText = styled.div<{ invalid?: boolean }>`
209
179
  ${({ invalid }) =>
210
180
  theme((o) => [
211
181
  o.typography(14),
212
182
  o.margin.top(8),
213
- invalid ? o.font.assertive : o.font.text2,
183
+ invalid === true ? o.font.assertive : o.font.text2,
214
184
  ])}
215
185
  `
@@ -0,0 +1,12 @@
1
+ import { handleFocusByKeyBoard } from './handleFocusByKeyBoard'
2
+
3
+ /**
4
+ * li要素ならフォーカスしてスクロールスクロール領域に見えるように親要素をスクロールする
5
+ * @param element
6
+ */
7
+ export function focusIfHTMLLIElement(element: Node | null | undefined) {
8
+ if (element instanceof HTMLLIElement) {
9
+ element.focus({ preventScroll: true })
10
+ handleFocusByKeyBoard(element)
11
+ }
12
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * スクロールスクロール領域に見えるように親要素をスクロールする
3
+ * @param element
4
+ */
5
+
6
+ export function handleFocusByKeyBoard(element: HTMLElement) {
7
+ const parent = element.parentElement
8
+ if (!parent) return
9
+ const rect = element.getBoundingClientRect()
10
+ const parentRect = parent.getBoundingClientRect()
11
+ if (rect.bottom > parentRect.bottom) {
12
+ parent.scrollTo({
13
+ top: parent.scrollTop + rect.bottom - parentRect.bottom,
14
+ })
15
+ } else if (rect.top < parentRect.top) {
16
+ parent.scrollTo({
17
+ top: parent.scrollTop - (parentRect.top - rect.top),
18
+ })
19
+ }
20
+ }
@@ -76,10 +76,10 @@ const DefaultStory = (args: ModalProps) => {
76
76
  </ModalAlign>
77
77
  </ModalVStack>
78
78
  <ModalButtons>
79
- <Button variant="Primary" onClick={() => state.close()} fixed>
79
+ <Button variant="Primary" onClick={() => state.close()} fullWidth>
80
80
  Apply
81
81
  </Button>
82
- <Button onClick={() => state.close()} fixed>
82
+ <Button onClick={() => state.close()} fullWidth>
83
83
  Cancel
84
84
  </Button>
85
85
  </ModalButtons>
@@ -140,10 +140,10 @@ const FullBottomSheetStory = (args: ModalProps) => {
140
140
  </ModalAlign>
141
141
  </ModalVStack>
142
142
  <ModalButtons>
143
- <Button variant="Primary" onClick={() => state.close()} fixed>
143
+ <Button variant="Primary" onClick={() => state.close()} fullWidth>
144
144
  Apply
145
145
  </Button>
146
- <Button onClick={() => state.close()} fixed>
146
+ <Button onClick={() => state.close()} fullWidth>
147
147
  Cancel
148
148
  </Button>
149
149
  </ModalButtons>
@@ -181,7 +181,7 @@ const BottomSheetStory = (args: ModalProps) => {
181
181
  </StyledModalText>
182
182
  </ModalVStack>
183
183
  <ModalButtons>
184
- <Button variant="Danger" onClick={() => state.close()} fixed>
184
+ <Button variant="Danger" onClick={() => state.close()} fullWidth>
185
185
  削除する
186
186
  </Button>
187
187
  <ModalDismissButton>キャンセル</ModalDismissButton>
@@ -263,7 +263,7 @@ export function ModalDismissButton({ children, ...props }: ButtonProps) {
263
263
  }
264
264
 
265
265
  return (
266
- <Button {...props} onClick={close} fixed>
266
+ <Button {...props} onClick={close} fullWidth>
267
267
  {children}
268
268
  </Button>
269
269
  )
package/src/index.ts CHANGED
@@ -52,8 +52,13 @@ export {
52
52
  } from './components/LoadingSpinner'
53
53
  export {
54
54
  default as DropdownSelector,
55
- DropdownSelectorItem,
55
+ type DropdownSelectorOption,
56
+ type DropdownSelectorProps,
56
57
  } from './components/DropdownSelector'
58
+ export {
59
+ OptionItem,
60
+ type OptionItemProps,
61
+ } from './components/DropdownSelector/OptionItem'
57
62
  export {
58
63
  default as SegmentedControl,
59
64
  type SegmentedControlProps,
@@ -1,60 +0,0 @@
1
- import { useListBoxSection } from '@react-aria/listbox'
2
- import { useSeparator } from '@react-aria/separator'
3
- import { Node } from '@react-types/shared'
4
- import React from 'react'
5
- import { ListState } from 'react-stately'
6
- import styled from 'styled-components'
7
- import { Option } from './Option'
8
- import { Divider } from './Divider'
9
- import { theme } from '../../styled'
10
-
11
- export function ListBoxSection<T>(props: {
12
- section: Node<T>
13
- state: ListState<T>
14
- }) {
15
- const { state } = props
16
- const { itemProps, headingProps, groupProps } = useListBoxSection({
17
- heading: props.section.rendered,
18
- 'aria-label': props.section['aria-label'],
19
- })
20
-
21
- const { separatorProps } = useSeparator({
22
- elementType: 'li',
23
- })
24
-
25
- return (
26
- <>
27
- {props.section.key !== props.state.collection.getFirstKey() && (
28
- <Divider {...separatorProps} role="separator" />
29
- )}
30
- <StyledLi {...itemProps}>
31
- {props.section.rendered != null && (
32
- <SectionSpan {...headingProps}>{props.section.rendered}</SectionSpan>
33
- )}
34
- <StyledUl {...groupProps}>
35
- {[...props.section.childNodes].map((node) => (
36
- <Option key={node.key} item={node} state={state} />
37
- ))}
38
- </StyledUl>
39
- </StyledLi>
40
- </>
41
- )
42
- }
43
-
44
- const SectionSpan = styled.span`
45
- ${theme((o) => [
46
- o.font.text3,
47
- o.typography(12).bold,
48
- o.margin.bottom(8).left(16).top(16),
49
- ])}
50
- `
51
-
52
- const StyledUl = styled.ul`
53
- padding-left: 0;
54
- margin: 0;
55
- box-sizing: border-box;
56
- list-style: none;
57
- overflow: hidden;
58
- `
59
-
60
- const StyledLi = styled.li``
@@ -1,67 +0,0 @@
1
- import React, { memo, useRef, Fragment, useMemo } from 'react'
2
- import styled from 'styled-components'
3
- import { ListProps, ListState } from 'react-stately'
4
- import { useListBox } from '@react-aria/listbox'
5
- import { theme } from '../../styled'
6
-
7
- import { ListBoxSection } from './ListBoxSection'
8
- import { Divider } from './Divider'
9
- import { Option } from './Option'
10
-
11
- export type ListMode = 'default' | 'separator'
12
- export type ListboxProps<T> = Omit<ListProps<T>, 'children'> & {
13
- state: ListState<T>
14
- mode?: ListMode
15
- }
16
-
17
- const Listbox = <T,>({
18
- state,
19
- mode = 'default',
20
- ...props
21
- }: ListboxProps<T>) => {
22
- const ref = useRef<HTMLUListElement>(null)
23
-
24
- const { listBoxProps } = useListBox(props, state, ref)
25
- const collection = useMemo(
26
- () =>
27
- [...state.collection].map((node, index, self) => ({
28
- node,
29
- first: index === 0,
30
- last: index === self.length - 1,
31
- })),
32
- [state.collection]
33
- )
34
-
35
- return (
36
- <ListboxRoot ref={ref} {...listBoxProps}>
37
- {collection.map(({ node, last }) => (
38
- <Fragment key={node.key}>
39
- {node.type === 'section' ? (
40
- <ListBoxSection section={node} state={state} />
41
- ) : (
42
- <Option item={node} state={state} mode={mode} />
43
- )}
44
- {!last && mode === 'separator' && <Divider />}
45
- </Fragment>
46
- ))}
47
- </ListboxRoot>
48
- )
49
- }
50
- export default memo(Listbox)
51
-
52
- const ListboxRoot = styled.ul`
53
- padding-left: 0;
54
- margin: 0;
55
- box-sizing: border-box;
56
- list-style: none;
57
- overflow: auto;
58
- max-height: inherit;
59
-
60
- ${theme((o) => [
61
- o.bg.background1,
62
- o.border.default,
63
- o.borderRadius(8),
64
- o.padding.vertical(8),
65
- o.outline.default.focus,
66
- ])}
67
- `
@@ -1,66 +0,0 @@
1
- import React, { useRef } from 'react'
2
- import styled, { css } from 'styled-components'
3
- import { ListState } from 'react-stately'
4
- import { useOption } from '@react-aria/listbox'
5
- import { mergeProps } from '@react-aria/utils'
6
- import { useFocusRing } from '@react-aria/focus'
7
- import { px } from '@charcoal-ui/utils'
8
- import Icon from '../Icon'
9
- import { theme } from '../../styled'
10
- import { Node } from '@react-types/shared'
11
- import { ListMode } from './Listbox'
12
-
13
- type OptionProps<T> = {
14
- item: Node<T>
15
- state: ListState<T>
16
- mode?: ListMode
17
- }
18
-
19
- export function Option<T>({ item, state, mode }: OptionProps<T>) {
20
- const ref = useRef<HTMLLIElement>(null)
21
-
22
- const { optionProps, isSelected } = useOption(item, state, ref)
23
- const { focusProps } = useFocusRing()
24
-
25
- return (
26
- <OptionRoot {...mergeProps(optionProps, focusProps)} ref={ref} mode={mode}>
27
- <OptionCheckIcon name="16/Check" isSelected={isSelected} />
28
- <OptionText>{item.rendered}</OptionText>
29
- </OptionRoot>
30
- )
31
- }
32
-
33
- const OptionRoot = styled.li<{ mode?: ListMode }>`
34
- display: flex;
35
- align-items: center;
36
- gap: ${({ theme }) => px(theme.spacing[4])};
37
- height: 40px;
38
- cursor: pointer;
39
- outline: none;
40
-
41
- ${({ mode }) =>
42
- theme((o) => [
43
- o.padding.horizontal(8),
44
- mode === 'separator' && o.padding.vertical(4),
45
- ])}
46
-
47
- &:focus {
48
- ${theme((o) => [o.bg.surface3])}
49
- }
50
- `
51
-
52
- const OptionCheckIcon = styled(Icon)<{ isSelected: boolean }>`
53
- visibility: hidden;
54
- ${theme((o) => [o.font.text2])}
55
-
56
- ${({ isSelected }) =>
57
- isSelected &&
58
- css`
59
- visibility: visible;
60
- `}
61
- `
62
-
63
- const OptionText = styled.span`
64
- display: block;
65
- ${theme((o) => [o.typography(14), o.font.text2])}
66
- `