@stack-spot/citric-react 0.25.1 → 0.27.0

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 (52) hide show
  1. package/dist/citric.css +39 -3
  2. package/dist/components/Button.d.ts +1 -1
  3. package/dist/components/Button.js +1 -1
  4. package/dist/components/ButtonLink.d.ts +16 -0
  5. package/dist/components/ButtonLink.d.ts.map +1 -0
  6. package/dist/components/ButtonLink.js +35 -0
  7. package/dist/components/ButtonLink.js.map +1 -0
  8. package/dist/components/CheckboxGroup.d.ts +15 -1
  9. package/dist/components/CheckboxGroup.d.ts.map +1 -1
  10. package/dist/components/CheckboxGroup.js +5 -3
  11. package/dist/components/CheckboxGroup.js.map +1 -1
  12. package/dist/components/CitricComponent.d.ts +1 -1
  13. package/dist/components/CitricComponent.d.ts.map +1 -1
  14. package/dist/components/RadioGroup.d.ts +13 -1
  15. package/dist/components/RadioGroup.d.ts.map +1 -1
  16. package/dist/components/RadioGroup.js +3 -3
  17. package/dist/components/RadioGroup.js.map +1 -1
  18. package/dist/components/Select/MultiSelect.d.ts +62 -0
  19. package/dist/components/Select/MultiSelect.d.ts.map +1 -0
  20. package/dist/components/Select/MultiSelect.js +63 -0
  21. package/dist/components/Select/MultiSelect.js.map +1 -0
  22. package/dist/components/Select/RichSelect.d.ts +1 -1
  23. package/dist/components/Select/RichSelect.d.ts.map +1 -1
  24. package/dist/components/Select/RichSelect.js +14 -112
  25. package/dist/components/Select/RichSelect.js.map +1 -1
  26. package/dist/components/Select/hooks.d.ts +23 -0
  27. package/dist/components/Select/hooks.d.ts.map +1 -0
  28. package/dist/components/Select/hooks.js +114 -0
  29. package/dist/components/Select/hooks.js.map +1 -0
  30. package/dist/components/Select/index.d.ts +1 -1
  31. package/dist/components/Select/index.js +1 -1
  32. package/dist/components/Select/types.d.ts +13 -5
  33. package/dist/components/Select/types.d.ts.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/utils/checkbox.js +1 -1
  39. package/dist/utils/checkbox.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/components/Button.tsx +1 -1
  42. package/src/components/ButtonLink.tsx +45 -0
  43. package/src/components/CheckboxGroup.tsx +18 -1
  44. package/src/components/CitricComponent.ts +1 -1
  45. package/src/components/RadioGroup.tsx +16 -1
  46. package/src/components/Select/MultiSelect.tsx +207 -0
  47. package/src/components/Select/RichSelect.tsx +16 -109
  48. package/src/components/Select/hooks.ts +133 -0
  49. package/src/components/Select/index.tsx +1 -1
  50. package/src/components/Select/types.ts +13 -5
  51. package/src/index.ts +2 -0
  52. package/src/utils/checkbox.ts +1 -1
@@ -5,7 +5,7 @@ export type CitricComponentName = 'alert' | 'avatar' | 'badge' | 'blockquote' |
5
5
  'checkbox-row' | 'divider' | 'field-group' | 'form-group' | 'form' | 'icon-box' | 'input' | 'link' | 'pagination' | 'progress-bar' |
6
6
  'progress-circular' | 'radio' | 'radio-row' | 'rating' | 'select' | 'select-box' | 'skeleton' | 'slider' | 'switch' | 'switch-row' |
7
7
  'table' | 'tabs' | 'accordion' | 'favorite' | 'textarea' | 'avatar-group' | 'labeled-slider' | 'rich-select' | 'tooltip' | 'menu' |
8
- 'circle'
8
+ 'circle' | 'multi-select'
9
9
 
10
10
  interface BaseCitricProps extends WithColorScheme, WithColorPalette {
11
11
  component: CitricComponentName,
@@ -63,6 +63,18 @@ export interface BaseRadioGroupProps<T> extends WithColorScheme {
63
63
  * @returns true if the item should be disabled, false otherwise.
64
64
  */
65
65
  isDisabled?: (option: T) => boolean,
66
+ /**
67
+ * The space between options.
68
+ *
69
+ * @default "8px"
70
+ */
71
+ gap?: string,
72
+ /**
73
+ * If set to false, the checkboxes will have tabIndex = -1.
74
+ *
75
+ * @default true
76
+ */
77
+ focusable?: boolean,
66
78
  }
67
79
 
68
80
  export type RadioGroupProps<T> = Omit<React.JSX.IntrinsicElements['div'], 'onChange' | 'children'> & BaseRadioGroupProps<T>
@@ -101,6 +113,8 @@ export const RadioGroup = withRef(
101
113
  renderItem,
102
114
  isDisabled,
103
115
  colorScheme,
116
+ gap = '8px',
117
+ focusable = true,
104
118
  style,
105
119
  ...props
106
120
  }: RadioGroupProps<T>) {
@@ -117,6 +131,7 @@ export const RadioGroup = withRef(
117
131
  checked={value === o || (!isNil(key) && valueKey === key)}
118
132
  onChange={() => onChange?.(o)}
119
133
  disabled={isDisabled?.(o)}
134
+ tabIndex={focusable ? undefined : -1}
120
135
  />
121
136
  return renderItem ? renderItem(radio, o) : (
122
137
  <CitricComponent tag="label" component="radio-row" key={key} colorScheme={colorScheme}>
@@ -126,6 +141,6 @@ export const RadioGroup = withRef(
126
141
  )
127
142
  })
128
143
  }, [options, value, name, colorScheme])
129
- return <Column {...props} style={{ gap: '8px', ...style }}>{items}</Column>
144
+ return <Column {...props} style={{ gap, ...style }}>{items}</Column>
130
145
  },
131
146
  )
@@ -0,0 +1,207 @@
1
+ import { listToClass } from '@stack-spot/portal-theme'
2
+ import { useTranslate } from '@stack-spot/portal-translate'
3
+ import { useMemo, useRef, useState } from 'react'
4
+ import { useCheckboxGroupControls } from '../../utils/checkbox'
5
+ import { applyCSSVariable } from '../../utils/css'
6
+ import { defaultRenderKey, defaultRenderLabel } from '../../utils/options'
7
+ import { withRef } from '../../utils/react'
8
+ import { Checkbox } from '../Checkbox'
9
+ import { CheckboxGroup } from '../CheckboxGroup'
10
+ import { CitricComponent } from '../CitricComponent'
11
+ import { Input } from '../Input'
12
+ import { Row } from '../layout'
13
+ import { ProgressCircular } from '../ProgressCircular'
14
+ import { useDisabledEffect, useFocusEffect, useOpenPanelEffect } from './hooks'
15
+ import { RichSelectProps } from './types'
16
+
17
+ /**
18
+ * A component that looks like a Select and behaves like a CheckboxGroup. This is a component that lets the user select multiple options
19
+ * in a list.
20
+ *
21
+ * Differently than then the component Select, this does not render the native select of the browser. Instead, it renders a series of
22
+ * checkboxes.
23
+ *
24
+ * @example
25
+ *
26
+ * ```
27
+ * const options = useMemo(() => [
28
+ * { id: 1, name: 'Option 1' },
29
+ * { id: 2, name: 'Option 2' },
30
+ * { id: 3, name: 'Option 3' },
31
+ * ], [])
32
+ *
33
+ * const [value, setValue] = useState<typeof options>([])
34
+ *
35
+ * return <MultiSelect options={options} renderLabel={o => o.name} renderKey={o => o.id} value={value} setValue={setValue} />
36
+ * ```
37
+ */
38
+ interface BaseMultiSelectProps<T> extends
39
+ Omit<RichSelectProps<T>, 'value' | 'onChange' | 'renderHeader' | 'renderLabel' | 'renderOption' | 'required' | 'onFocus' | 'onBlur'> {
40
+ value: T[],
41
+ onChange: (value: T[]) => void,
42
+ onFocus: (event: React.FocusEvent<HTMLElement, Element>) => void,
43
+ /**
44
+ * A function to render the option in the selectable list.
45
+ *
46
+ * The `renderLabel` function is used if this is not provided.
47
+ * @param value the option.
48
+ * @returns the React Node.
49
+ */
50
+ renderOption?: (option: T) => React.ReactNode,
51
+ /**
52
+ * A function to render the selected options in the header.
53
+ *
54
+ * The `renderOption` function is used if this is not provided.
55
+ * @param value the option.
56
+ * @returns the React Node.
57
+ */
58
+ renderHeader?: (value: T[]) => React.ReactNode,
59
+ /**
60
+ * A function to render the item label.
61
+ * @example
62
+ * `(option) => option.name`
63
+ * @default "the item's toString() result."
64
+ * @param option the item to render.
65
+ * @returns a React Node to render.
66
+ */
67
+ renderLabel?: (option: T) => string,
68
+ /**
69
+ * Whether or not to show a checkbox to select all or remove the selection.
70
+ *
71
+ * @default false
72
+ */
73
+ showSelectAll?: boolean,
74
+ }
75
+
76
+
77
+ export type MultiSelectProps<T> = Omit<React.JSX.IntrinsicElements['div'], 'ref' | 'onChange' | 'onFocus' | 'onBlur'> &
78
+ BaseMultiSelectProps<T>
79
+
80
+ export const MultiSelect = withRef(
81
+ function MultiSelect<T>({
82
+ ref,
83
+ options,
84
+ value = [],
85
+ onChange,
86
+ renderLabel = defaultRenderLabel,
87
+ renderKey = defaultRenderKey,
88
+ disabled,
89
+ loading,
90
+ renderOption,
91
+ renderHeader,
92
+ searchable,
93
+ maxHeight,
94
+ style,
95
+ className,
96
+ showArrow,
97
+ placeholder,
98
+ showSelectAll,
99
+ ...props
100
+ }: MultiSelectProps<T>,
101
+ ) {
102
+ const t = useTranslate(dictionary)
103
+ const _element = useRef<HTMLDivElement | null>(null)
104
+ const element = ref ?? _element
105
+ const [open, setOpen] = useState(false)
106
+ const [focused, setFocused] = useState(false)
107
+ const controls = useCheckboxGroupControls({
108
+ options,
109
+ renderKey,
110
+ initialValue: value,
111
+ onChange,
112
+ applyFilter: (filter, option) => renderLabel(option).toLocaleLowerCase().includes(filter.toLocaleLowerCase()),
113
+ })
114
+
115
+ useOpenPanelEffect({ open, setOpen, setSearch: controls.setFilter, element, searchable })
116
+ useFocusEffect({ element, focused, setFocused, setOpen })
117
+ useDisabledEffect({ disabled, setOpen, setFocused })
118
+
119
+ const header = useMemo(() => {
120
+ if (value.length === 0) return <span className="placeholder">{placeholder}</span>
121
+ const reverse = value.reverse()
122
+ return (
123
+ (renderHeader?.(reverse)
124
+ ?? (renderOption ? <Row gap="4px">{reverse.map(renderOption)}</Row> : <span>{reverse.map(renderLabel).join(', ')}</span>))
125
+ || <span></span>
126
+ )}, [value, placeholder])
127
+
128
+ return (
129
+ <CitricComponent
130
+ tag="div"
131
+ component="multi-select"
132
+ style={maxHeight ? applyCSSVariable(style, 'max-height', `${maxHeight}px`) : style}
133
+ className={listToClass([className, showArrow === false && 'hide-arrow', open && 'open', focused && 'focused'])}
134
+ ref={element}
135
+ aria-busy={loading}
136
+ {...props}
137
+ >
138
+ <header
139
+ onClick={(e) => {
140
+ if (disabled) return
141
+ if (!open) e.stopPropagation()
142
+ setFocused(true)
143
+ setOpen(true)
144
+ }}
145
+ onFocus={() => setFocused(true)}
146
+ aria-label={t.accessibilityHelp}
147
+ tabIndex={0}
148
+ className={renderHeader ? 'custom' : undefined}
149
+ >
150
+ {header}
151
+ {loading && <ProgressCircular size="xs" className="loader" />}
152
+ </header>
153
+ <div className="selection-panel" aria-hidden={!open} {...(open ? {} : { inert: 'true' })}>
154
+ {searchable && <div className="search-bar">
155
+ <div data-citric="field-group" className="auto">
156
+ <i data-citric="icon-box" className="citric-icon outline Search"></i>
157
+ <Input type="search" value={controls.filter} onChange={controls.setFilter} aria-label={t.searchAccessibility} />
158
+ </div>
159
+ </div>}
160
+ {showSelectAll && (
161
+ <Checkbox
162
+ className="select-all"
163
+ onChange={checked => checked ? controls.selectAll() : controls.removeSelection()}
164
+ value={controls.isAllSelected}
165
+ >
166
+ {controls.isAllSelected ? t.removeSelection : t.selectAll}
167
+ </Checkbox>
168
+ )}
169
+ <CheckboxGroup
170
+ className="options"
171
+ gap="0"
172
+ options={controls.options}
173
+ onChange={controls.setValue}
174
+ value={controls.value}
175
+ renderKey={controls.renderKey}
176
+ focusable={false}
177
+ renderItem={(checkbox, option) => (
178
+ <CitricComponent
179
+ component="checkbox-row"
180
+ tag="label"
181
+ className={listToClass(['option', controls.isUnfilteredButChecked(option) && 'unfiltered'])}
182
+ >
183
+ {checkbox}
184
+ {renderOption?.(option) ?? renderLabel(option)}
185
+ </CitricComponent>
186
+ )}
187
+ />
188
+ </div>
189
+ </CitricComponent>
190
+ )
191
+ },
192
+ )
193
+
194
+ const dictionary = {
195
+ en: {
196
+ accessibilityHelp: 'Press the arrow down to select multiple options',
197
+ searchAccessibility: 'Filter the options',
198
+ removeSelection: 'Remove selection',
199
+ selectAll: 'Select all',
200
+ },
201
+ pt: {
202
+ accessibilityHelp: 'Pressione a seta para baixo para selecionar múltiplas opções',
203
+ searchAccessibility: 'Filtre as opções',
204
+ removeSelection: 'Remover seleção',
205
+ selectAll: 'Selecionar todos',
206
+ },
207
+ }
@@ -1,12 +1,13 @@
1
1
  import { listToClass } from '@stack-spot/portal-theme'
2
2
  import { useTranslate } from '@stack-spot/portal-translate'
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { useCallback, useMemo, useRef, useState } from 'react'
4
4
  import { applyCSSVariable } from '../../utils/css'
5
5
  import { defaultRenderKey, defaultRenderLabel } from '../../utils/options'
6
6
  import { withRef } from '../../utils/react'
7
7
  import { CitricComponent } from '../CitricComponent'
8
8
  import { Input } from '../Input'
9
9
  import { ProgressCircular } from '../ProgressCircular'
10
+ import { useDisabledEffect, useFocusEffect, useOpenPanelEffect } from './hooks'
10
11
  import { SimpleSelect } from './SimpleSelect'
11
12
  import { SelectProps } from './types'
12
13
 
@@ -30,10 +31,11 @@ export const RichSelect = withRef(
30
31
  onFocus,
31
32
  onBlur,
32
33
  showArrow,
34
+ placeholder,
33
35
  ...props
34
36
  }: SelectProps<T> & { type?: 'rich' },
35
37
  ) {
36
- const [search, setSearch] = useState('')
38
+ const [search, setSearch] = useState<string | undefined>('')
37
39
  const _element = useRef<HTMLDivElement | null>(null)
38
40
  const element = ref ?? _element
39
41
  const [open, setOpen] = useState(false)
@@ -46,13 +48,15 @@ export const RichSelect = withRef(
46
48
  }, [])
47
49
 
48
50
  const renderedOptions = useMemo(() => {
49
- const items = required ? [] : [<li key="" className="empty" onClick={change(undefined)}>{renderLabel(undefined) || t.empty}</li>]
51
+ const items = required
52
+ ? []
53
+ : [<li key="" className="empty option" onClick={change(undefined)}>{renderLabel(undefined) || t.empty}</li>]
50
54
  options.forEach((o) => {
51
55
  const key = renderKey(o)
52
56
  const label = renderLabel(o)
53
- if (!search.trim() || label.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) {
57
+ if (!search?.trim() || label.toLocaleLowerCase().includes(search?.trim().toLocaleLowerCase())) {
54
58
  items.push(
55
- <li key={key} onClick={change(o)}>
59
+ <li key={key} onClick={change(o)} className="option">
56
60
  {renderOption?.(o) ?? label}
57
61
  </li>,
58
62
  )
@@ -61,108 +65,9 @@ export const RichSelect = withRef(
61
65
  return items
62
66
  }, [options, value, required, search])
63
67
 
64
- /* this runs whenever the selection panel is opened */
65
- useEffect(() => {
66
- if (open) {
67
- setSearch('')
68
- const selectionPanel = element.current?.querySelector('.selection-panel') as HTMLElement | undefined
69
- selectionPanel?.querySelector('ul')?.scrollTo({ top: 0 })
70
- const getCurrent = () => selectionPanel?.querySelector('li.focused') as HTMLElement | undefined
71
- const scrollTo = (li: HTMLElement) => {
72
- const ul = li.closest('ul')
73
- if (!ul) return
74
- const { top: ulTop, height: ulHeight } = ul.getBoundingClientRect()
75
- const { height: liHeight, top: liTop } = li.getBoundingClientRect()
76
- const offset = liTop + ul.scrollTop - ulTop
77
- if ((ul.scrollTop + ulHeight < offset + liHeight) || ul.scrollTop > offset) {
78
- ul.scrollTo({ top: offset })
79
- }
80
- }
81
- /* keyboard and mouse controls */
82
- const listenToMouse = (event: Event) => {
83
- if (!selectionPanel?.contains(event.target as HTMLElement)) {
84
- setOpen(false)
85
- }
86
- }
87
- const listenToKeyboard = (event: KeyboardEvent) => {
88
- const isCharacter = event.key.length === 1
89
- if (['Escape', 'ArrowUp', 'ArrowDown', 'Enter'].includes(event.key) ||
90
- (searchable && (isCharacter || event.key === 'Backspace'))) {
91
- event.preventDefault()
92
- event.stopPropagation()
93
- }
94
- if (event.key === 'Escape') setOpen(false)
95
- if (searchable) {
96
- if (isCharacter) setSearch(v => `${v}${event.key}`)
97
- if (event.key === 'Backspace') setSearch(v => v.substring(0, v.length - 1))
98
- }
99
- if (event.key === 'ArrowDown') {
100
- const current = getCurrent()
101
- const next = (current?.nextElementSibling ?? selectionPanel?.querySelector('li')) as HTMLAreaElement | undefined
102
- if (next) {
103
- current?.classList.remove('focused')
104
- next.classList.add('focused')
105
- scrollTo(next)
106
- }
107
- }
108
- if (event.key === 'ArrowUp') {
109
- const current = getCurrent()
110
- const prev = (current?.previousElementSibling ?? selectionPanel?.querySelector('li:last-child')) as HTMLAreaElement | undefined
111
- if (prev) {
112
- current?.classList.remove('focused')
113
- prev.classList.add('focused')
114
- scrollTo(prev)
115
- }
116
- }
117
- if (event.key === 'Enter') {
118
- setTimeout(() => getCurrent()?.click(), 0)
119
- }
120
- }
121
- // below, we wait 20ms so the same click that opened the select doesn't close it. Removing it will cause problems with selects under
122
- // labels.
123
- setTimeout(() => document.addEventListener('click', listenToMouse), 20)
124
- document.addEventListener('keydown', listenToKeyboard)
125
- return () => {
126
- document.removeEventListener('click', listenToMouse)
127
- document.removeEventListener('keydown', listenToKeyboard)
128
- getCurrent()?.classList.remove('focused')
129
- }
130
- }
131
- }, [open])
132
-
133
- /* this runs whenever the select is focused */
134
- useEffect(() => {
135
- if (focused) {
136
- const listenToMouse = (event: MouseEvent) => {
137
- if (!element.current?.contains(event.target as HTMLElement)) {
138
- setFocused(false)
139
- }
140
- }
141
- const listenToKeyboard = (event: KeyboardEvent) => {
142
- if (['Enter', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
143
- event.preventDefault()
144
- if (!element.current?.classList.contains('open')) setOpen(true)
145
- }
146
- if (event.key === 'Tab') {
147
- setFocused(false)
148
- if (element.current?.classList.contains('open')) setOpen(false)
149
- }
150
- }
151
- document.addEventListener('click', listenToMouse)
152
- document.addEventListener('keydown', listenToKeyboard)
153
- return () => {
154
- document.removeEventListener('click', listenToMouse)
155
- document.removeEventListener('keydown', listenToKeyboard)
156
- }
157
- }
158
- }, [focused])
159
-
160
- useEffect(() => {
161
- if (disabled) {
162
- setOpen(false)
163
- setFocused(false)
164
- }
165
- }, [disabled])
68
+ useOpenPanelEffect({ open, setOpen, setSearch, element, searchable })
69
+ useFocusEffect({ element, focused, setFocused, setOpen })
70
+ useDisabledEffect({ disabled, setOpen, setFocused })
166
71
 
167
72
  return (
168
73
  <CitricComponent
@@ -195,7 +100,9 @@ export const RichSelect = withRef(
195
100
  }}
196
101
  aria-hidden
197
102
  >
198
- {(renderHeader?.(value) ?? renderOption?.(value) ?? renderLabel(value)) || <span></span>}
103
+ {value === undefined
104
+ ? <span className="placeholder">{placeholder}</span>
105
+ : ((renderHeader?.(value) ?? renderOption?.(value) ?? renderLabel(value)) || <span></span>)}
199
106
  {loading && <ProgressCircular size="xs" className="loader" />}
200
107
  </header>
201
108
  <div className="selection-panel" aria-hidden>
@@ -205,7 +112,7 @@ export const RichSelect = withRef(
205
112
  <Input type="search" value={search} onChange={setSearch} tabIndex={-1} />
206
113
  </div>
207
114
  </div>}
208
- <ul>{renderedOptions}</ul>
115
+ <ul className="options">{renderedOptions}</ul>
209
116
  </div>
210
117
  </CitricComponent>
211
118
  )
@@ -0,0 +1,133 @@
1
+ import { useEffect } from 'react'
2
+
3
+ interface OpenPanelEffectParams {
4
+ open: boolean,
5
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>,
6
+ setSearch: React.Dispatch<React.SetStateAction<string | undefined>>,
7
+ element: React.MutableRefObject<HTMLDivElement | null>,
8
+ searchable?: boolean,
9
+ }
10
+
11
+ interface FocusEffectParams {
12
+ element: React.MutableRefObject<HTMLDivElement | null>,
13
+ focused: boolean,
14
+ setFocused: React.Dispatch<React.SetStateAction<boolean>>,
15
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>,
16
+ }
17
+
18
+ interface DisabledEffectParams {
19
+ disabled?: boolean,
20
+ setFocused: React.Dispatch<React.SetStateAction<boolean>>,
21
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>,
22
+ }
23
+
24
+ /* this runs whenever the selection panel is opened */
25
+ export function useOpenPanelEffect({ open, setOpen, setSearch, element, searchable }: OpenPanelEffectParams) {
26
+ useEffect(() => {
27
+ if (open) {
28
+ setSearch('')
29
+ const selectionPanel = element.current?.querySelector('.selection-panel') as HTMLElement | undefined
30
+ selectionPanel?.querySelector('.options')?.scrollTo({ top: 0 })
31
+ const getCurrent = () => selectionPanel?.querySelector('.option.focused') as HTMLElement | undefined
32
+ const scrollTo = (item: HTMLElement) => {
33
+ const list = item.closest('.options')
34
+ if (!list) return
35
+ const { top: listTop, height: listHeight } = list.getBoundingClientRect()
36
+ const { height: itemHeight, top: itemTop } = item.getBoundingClientRect()
37
+ const offset = itemTop + list.scrollTop - listTop
38
+ if ((list.scrollTop + listHeight < offset + itemHeight) || list.scrollTop > offset) {
39
+ list.scrollTo({ top: offset })
40
+ }
41
+ }
42
+ /* keyboard and mouse controls */
43
+ const listenToMouse = (event: Event) => {
44
+ if (!selectionPanel?.contains(event.target as HTMLElement)) {
45
+ setOpen(false)
46
+ }
47
+ }
48
+ const listenToKeyboard = (event: KeyboardEvent) => {
49
+ const isCharacter = event.key.length === 1
50
+ if (['Escape', 'ArrowUp', 'ArrowDown', 'Enter'].includes(event.key) ||
51
+ (searchable && (isCharacter || event.key === 'Backspace'))) {
52
+ event.preventDefault()
53
+ event.stopPropagation()
54
+ }
55
+ if (event.key === 'Escape') setOpen(false)
56
+ if (searchable) {
57
+ if (isCharacter) setSearch(v => `${v}${event.key}`)
58
+ if (event.key === 'Backspace') setSearch(v => v?.substring(0, v.length - 1))
59
+ }
60
+ if (event.key === 'ArrowDown') {
61
+ const current = getCurrent()
62
+ const next = (current?.nextElementSibling ?? selectionPanel?.querySelector('.option')) as HTMLAreaElement | undefined
63
+ if (next) {
64
+ current?.classList.remove('focused')
65
+ next.classList.add('focused')
66
+ scrollTo(next)
67
+ }
68
+ }
69
+ if (event.key === 'ArrowUp') {
70
+ const current = getCurrent()
71
+ const prev = (
72
+ current?.previousElementSibling ?? selectionPanel?.querySelector('.option:last-child')
73
+ ) as HTMLAreaElement | undefined
74
+ if (prev) {
75
+ current?.classList.remove('focused')
76
+ prev.classList.add('focused')
77
+ scrollTo(prev)
78
+ }
79
+ }
80
+ if (event.key === 'Enter') {
81
+ setTimeout(() => getCurrent()?.click(), 0)
82
+ }
83
+ }
84
+ // below, we wait 20ms so the same click that opened the select doesn't close it. Removing it will cause problems with selects under
85
+ // labels.
86
+ setTimeout(() => document.addEventListener('click', listenToMouse), 20)
87
+ document.addEventListener('keydown', listenToKeyboard)
88
+ return () => {
89
+ document.removeEventListener('click', listenToMouse)
90
+ document.removeEventListener('keydown', listenToKeyboard)
91
+ getCurrent()?.classList.remove('focused')
92
+ }
93
+ }
94
+ }, [open])
95
+ }
96
+
97
+ /* this runs whenever the select is focused */
98
+ export function useFocusEffect({ element, focused, setFocused, setOpen }: FocusEffectParams) {
99
+ useEffect(() => {
100
+ if (focused) {
101
+ const listenToMouse = (event: MouseEvent) => {
102
+ if (!element.current?.contains(event.target as HTMLElement)) {
103
+ setFocused(false)
104
+ }
105
+ }
106
+ const listenToKeyboard = (event: KeyboardEvent) => {
107
+ if (['Enter', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
108
+ event.preventDefault()
109
+ if (!element.current?.classList.contains('open')) setOpen(true)
110
+ }
111
+ if (event.key === 'Tab') {
112
+ setFocused(false)
113
+ if (element.current?.classList.contains('open')) setOpen(false)
114
+ }
115
+ }
116
+ document.addEventListener('click', listenToMouse)
117
+ document.addEventListener('keydown', listenToKeyboard)
118
+ return () => {
119
+ document.removeEventListener('click', listenToMouse)
120
+ document.removeEventListener('keydown', listenToKeyboard)
121
+ }
122
+ }
123
+ }, [focused])
124
+ }
125
+
126
+ export function useDisabledEffect({ disabled, setOpen, setFocused }: DisabledEffectParams) {
127
+ useEffect(() => {
128
+ if (disabled) {
129
+ setOpen(false)
130
+ setFocused(false)
131
+ }
132
+ }, [disabled])
133
+ }
@@ -11,7 +11,7 @@ export type * from './types'
11
11
  * The default select from the browser is also rendered when `rich = true`, but it is only accessible when navigating with the keyboard
12
12
  * (accessibility).
13
13
  *
14
- * This Select is searchable! You just need to set `selectable = true`.
14
+ * This Select is searchable! You just need to set `searchable = true`.
15
15
  *
16
16
  * Attention: this component doesn't accept children, instead of manually writing the tag "option", use the property "options".
17
17
  *
@@ -32,7 +32,7 @@ export interface CommonSelectProps<T> extends WithColorScheme {
32
32
  * @param option the item to compute a key for.
33
33
  * @returns a string key.
34
34
  */
35
- renderKey?: (value: T) => string | number | undefined,
35
+ renderKey?: (option: T) => string | number | undefined,
36
36
  /**
37
37
  * Whether or not this input is required. This is also used to figure out if an empty option should be rendered or not.
38
38
  *
@@ -73,6 +73,10 @@ export interface CommonSelectProps<T> extends WithColorScheme {
73
73
  * @default 'rich'
74
74
  */
75
75
  type?: 'simple' | 'rich',
76
+ /**
77
+ * A text to show when no option is selected.
78
+ */
79
+ placeholder?: string,
76
80
  }
77
81
 
78
82
  export interface SimpleSelectProps<T> extends CommonSelectProps<T> {
@@ -92,18 +96,18 @@ export interface RichSelectProps<T> extends CommonSelectProps<T> {
92
96
  * A function to render the option in the selectable list.
93
97
  *
94
98
  * The `renderLabel` function is used if this is not provided.
95
- * @param value the option.
99
+ * @param option the option.
96
100
  * @returns the React Node.
97
101
  */
98
- renderOption?: (value: T | undefined) => React.ReactNode,
102
+ renderOption?: (option: T | undefined) => React.ReactNode,
99
103
  /**
100
104
  * A function to render the selected option in the header.
101
105
  *
102
106
  * The `renderOption` function is used if this is not provided.
103
- * @param value the option.
107
+ * @param option the option.
104
108
  * @returns the React Node.
105
109
  */
106
- renderHeader?: (value: T | undefined) => React.ReactNode,
110
+ renderHeader?: (option: T | undefined) => React.ReactNode,
107
111
  /**
108
112
  * The maximum height of the options list in pixels.
109
113
  *
@@ -116,6 +120,10 @@ export interface RichSelectProps<T> extends CommonSelectProps<T> {
116
120
  */
117
121
  showArrow?: boolean,
118
122
  ref?: React.MutableRefObject<HTMLDivElement>,
123
+ /**
124
+ * Text to show in the header when no option is selected.
125
+ */
126
+ placeholder?: string,
119
127
  }
120
128
 
121
129
  export type BaseSelectProps<T> = SimpleSelectProps<T> | RichSelectProps<T>
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './components/Badge'
7
7
  export * from './components/Blockquote'
8
8
  export * from './components/Breadcrumb'
9
9
  export * from './components/Button'
10
+ export * from './components/ButtonLink'
10
11
  export * from './components/Card'
11
12
  export * from './components/Checkbox'
12
13
  export * from './components/CheckboxGroup'
@@ -36,6 +37,7 @@ export * from './components/ProgressCircular'
36
37
  export * from './components/RadioGroup'
37
38
  export * from './components/Rating'
38
39
  export * from './components/Select'
40
+ export * from './components/Select/MultiSelect'
39
41
  export * from './components/SelectBox'
40
42
  export * from './components/Skeleton'
41
43
  export * from './components/Slider'
@@ -90,7 +90,7 @@ export function useCheckboxGroupControls<T, F = string>(params: CheckboxGroupHoo
90
90
  /**
91
91
  * Apply a new filter.
92
92
  */
93
- setFilter: setFilter,
93
+ setFilter,
94
94
  /**
95
95
  * The options that should be passed to the checkbox group.
96
96
  */