@oslokommune/punkt-react 15.4.6 → 16.0.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 (29) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/index.d.ts +38 -15
  3. package/dist/punkt-react.es.js +12025 -10664
  4. package/dist/punkt-react.umd.js +562 -549
  5. package/package.json +5 -5
  6. package/src/components/accordion/Accordion.test.tsx +3 -2
  7. package/src/components/alert/Alert.test.tsx +2 -1
  8. package/src/components/backlink/BackLink.test.tsx +2 -1
  9. package/src/components/button/Button.test.tsx +4 -3
  10. package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
  11. package/src/components/checkbox/Checkbox.test.tsx +2 -1
  12. package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
  13. package/src/components/combobox/Combobox.core.test.tsx +469 -0
  14. package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
  15. package/src/components/combobox/Combobox.selection.test.tsx +548 -0
  16. package/src/components/combobox/Combobox.tsx +59 -54
  17. package/src/components/combobox/ComboboxInput.tsx +140 -0
  18. package/src/components/combobox/ComboboxTags.tsx +110 -0
  19. package/src/components/combobox/Listbox.tsx +172 -0
  20. package/src/components/combobox/types.ts +145 -0
  21. package/src/components/combobox/useComboboxState.ts +1141 -0
  22. package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
  23. package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
  24. package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
  25. package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
  26. package/src/components/radio/RadioButton.test.tsx +3 -2
  27. package/src/components/searchinput/SearchInput.test.tsx +6 -5
  28. package/src/components/tabs/Tabs.test.tsx +13 -12
  29. package/src/components/tag/Tag.test.tsx +2 -1
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+
3
+ import { findOptionByValue, findOptionIndex } from 'shared-utils/combobox'
4
+ import { PktIcon } from '../icon/Icon'
5
+ import { ComboboxTags, SingleValueDisplay } from './ComboboxTags'
6
+ import type { IComboboxState } from './types'
7
+
8
+ interface IComboboxInputProps {
9
+ state: IComboboxState
10
+ }
11
+
12
+ export const ComboboxInput = ({ state }: IComboboxInputProps) => {
13
+ const {
14
+ id,
15
+ inputId,
16
+ listboxId,
17
+ values,
18
+ options,
19
+ isOpen,
20
+ multiple,
21
+ typeahead,
22
+ allowUserInput,
23
+ tagPlacement,
24
+ placeholder,
25
+ disabled,
26
+ fullwidth,
27
+ hasError,
28
+ label,
29
+ inputFocus,
30
+ inputRef,
31
+ triggerRef,
32
+ handleInputChange,
33
+ handleInputKeydown,
34
+ handleInputFocus,
35
+ handleInputBlur,
36
+ handleInputClick,
37
+ handlePlaceholderClick,
38
+ handleSelectOnlyKeydown,
39
+ name,
40
+ } = state
41
+
42
+ const inputClasses = [
43
+ 'pkt-combobox__input',
44
+ fullwidth && 'pkt-combobox__input--fullwidth',
45
+ isOpen && 'pkt-combobox__input--open',
46
+ hasError && 'pkt-combobox__input--error',
47
+ disabled && 'pkt-combobox__input--disabled',
48
+ ]
49
+ .filter(Boolean)
50
+ .join(' ')
51
+
52
+ const arrowIconClasses = ['pkt-combobox__arrow-icon', isOpen && 'pkt-combobox__arrow-icon--open']
53
+ .filter(Boolean)
54
+ .join(' ')
55
+
56
+ const hasTextInput = typeahead || allowUserInput
57
+ const showPlaceholder =
58
+ !hasTextInput && placeholder && (!values.length || (multiple && tagPlacement === 'outside')) && !inputFocus
59
+
60
+ const showInlineValues = tagPlacement !== 'outside'
61
+
62
+ // Compute aria-activedescendant for select-only mode
63
+ const activeDescendant =
64
+ values[0] && findOptionByValue(options, values[0])
65
+ ? `${listboxId}-${findOptionIndex(options, values[0])}`
66
+ : undefined
67
+
68
+ // Build selection description for screen readers
69
+ const selectionDescription = multiple && values.length > 0 ? `${values.length} valgt` : undefined
70
+
71
+ // Select-only ARIA props (applied to the container div)
72
+ const selectOnlyProps = !hasTextInput
73
+ ? {
74
+ id: `${id}-combobox`,
75
+ role: 'combobox' as const,
76
+ 'aria-expanded': isOpen ? ('true' as const) : ('false' as const),
77
+ 'aria-controls': listboxId,
78
+ 'aria-haspopup': 'listbox' as const,
79
+ 'aria-labelledby': `${id}-combobox-label`,
80
+ 'aria-activedescendant': activeDescendant,
81
+ 'aria-description': selectionDescription,
82
+ tabIndex: disabled ? -1 : 0,
83
+ onKeyDown: handleSelectOnlyKeydown,
84
+ }
85
+ : {
86
+ tabIndex: -1,
87
+ }
88
+
89
+ return (
90
+ <div ref={triggerRef} className={inputClasses} onClick={handleInputClick} {...selectOnlyProps}>
91
+ {showPlaceholder ? (
92
+ <span className="pkt-combobox__placeholder" onClick={handlePlaceholderClick}>
93
+ {placeholder}
94
+ </span>
95
+ ) : showInlineValues ? (
96
+ multiple ? (
97
+ <ComboboxTags state={state} />
98
+ ) : !hasTextInput ? (
99
+ <SingleValueDisplay state={state} />
100
+ ) : null
101
+ ) : null}
102
+
103
+ {hasTextInput ? (
104
+ <div className="pkt-combobox__input-div combobox__input">
105
+ <input
106
+ ref={inputRef}
107
+ type="text"
108
+ id={inputId}
109
+ name={`${name || id}-input`}
110
+ onChange={handleInputChange}
111
+ onKeyDown={handleInputKeydown}
112
+ onFocus={handleInputFocus}
113
+ onBlur={handleInputBlur}
114
+ placeholder={!values.length || (multiple && tagPlacement === 'outside') ? placeholder : undefined}
115
+ autoComplete="off"
116
+ role="combobox"
117
+ aria-expanded={isOpen ? 'true' : 'false'}
118
+ aria-label={label}
119
+ aria-autocomplete={typeahead ? 'both' : allowUserInput ? 'list' : 'none'}
120
+ aria-controls={listboxId}
121
+ aria-activedescendant={activeDescendant}
122
+ aria-description={selectionDescription}
123
+ disabled={disabled}
124
+ />
125
+ </div>
126
+ ) : (
127
+ <input
128
+ ref={inputRef}
129
+ type="hidden"
130
+ id={inputId}
131
+ name={`${name || id}-input`}
132
+ value={values.join(',')}
133
+ readOnly
134
+ />
135
+ )}
136
+
137
+ <PktIcon className={arrowIconClasses} name="chevron-thin-down" aria-hidden="true" />
138
+ </div>
139
+ )
140
+ }
@@ -0,0 +1,110 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type MouseEvent, type KeyboardEvent } from 'react'
4
+ import { findOptionByValue, getOptionDisplayText } from 'shared-utils/combobox'
5
+ import type { TPktComboboxDisplayValue } from 'shared-types/combobox'
6
+ import { PktTag } from '../tag/Tag'
7
+ import type { IComboboxState } from './types'
8
+
9
+ interface IComboboxTagsProps {
10
+ state: IComboboxState
11
+ outside?: boolean
12
+ }
13
+
14
+ export const ComboboxTags = ({ state, outside = false }: IComboboxTagsProps) => {
15
+ const { values, options, disabled, displayValueAs, handleTagRemove, handleTagKeydown, focusedTagIndex } = state
16
+ const tagRefs = useRef<(HTMLButtonElement | null)[]>([])
17
+
18
+ useEffect(() => {
19
+ if (!outside && focusedTagIndex >= 0 && focusedTagIndex < values.length) {
20
+ tagRefs.current[focusedTagIndex]?.focus()
21
+ }
22
+ }, [outside, focusedTagIndex, values.length])
23
+
24
+ if (values.length === 0) return null
25
+
26
+ const renderTag = (val: string, index: number) => {
27
+ const option = findOptionByValue(options, val)
28
+ const tagSkinColor = option?.tagSkinColor
29
+ const displayText = option ? getOptionDisplayText(option, displayValueAs) : val
30
+
31
+ if (disabled) {
32
+ return (
33
+ <li key={val} role="listitem">
34
+ <PktTag skin={tagSkinColor || 'blue-dark'}>
35
+ <span className="pkt-combobox__value">{displayText}</span>
36
+ </PktTag>
37
+ </li>
38
+ )
39
+ }
40
+
41
+ // Outside tags: normal tabbable buttons, no roving tabindex
42
+ if (outside) {
43
+ return (
44
+ <li key={val} role="listitem">
45
+ <PktTag skin={tagSkinColor || 'blue-dark'} closeTag onClose={() => handleTagRemove(val)}>
46
+ <span className="pkt-combobox__value">{displayText}</span>
47
+ </PktTag>
48
+ </li>
49
+ )
50
+ }
51
+
52
+ // Inside tags: roving tabindex, arrow key navigation
53
+ return (
54
+ <li
55
+ key={val}
56
+ role="listitem"
57
+ onClick={(e: MouseEvent) => e.stopPropagation()}
58
+ onMouseDown={(e: MouseEvent) => e.preventDefault()}
59
+ >
60
+ <PktTag
61
+ ref={(el: HTMLButtonElement | null) => {
62
+ tagRefs.current[index] = el
63
+ }}
64
+ skin={tagSkinColor || 'blue-dark'}
65
+ closeTag
66
+ tabIndex={focusedTagIndex === index ? 0 : -1}
67
+ onClose={() => handleTagRemove(val)}
68
+ onKeyDown={(e: KeyboardEvent) => handleTagKeydown(e, index)}
69
+ >
70
+ <span className="pkt-combobox__value">{displayText}</span>
71
+ </PktTag>
72
+ </li>
73
+ )
74
+ }
75
+
76
+ const tags = values.map(renderTag)
77
+
78
+ if (outside) {
79
+ return (
80
+ <div className="pkt-combobox__tags-outside">
81
+ <ul role="list" className="pkt-combobox__tag-list">
82
+ {tags}
83
+ </ul>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ return (
89
+ <ul role="list" className="pkt-combobox__tag-list">
90
+ {tags}
91
+ </ul>
92
+ )
93
+ }
94
+
95
+ interface ISingleValueDisplayProps {
96
+ state: IComboboxState
97
+ }
98
+
99
+ export const SingleValueDisplay = ({ state }: ISingleValueDisplayProps) => {
100
+ const { values, options, editingSingleValue, displayValueAs } = state
101
+
102
+ if (editingSingleValue || values.length === 0) return null
103
+
104
+ const option = findOptionByValue(options, values[0])
105
+ if (!option) return null
106
+
107
+ const displayText = getOptionDisplayText(option, displayValueAs as TPktComboboxDisplayValue)
108
+
109
+ return <span className="pkt-combobox__value">{displayText}</span>
110
+ }
@@ -0,0 +1,172 @@
1
+ 'use client'
2
+
3
+ import type { IComboboxState } from './types'
4
+ import { PktIcon } from '../icon/Icon'
5
+
6
+ interface IListboxProps {
7
+ state: IComboboxState
8
+ }
9
+
10
+ export const Listbox = ({ state }: IListboxProps) => {
11
+ const {
12
+ listboxId,
13
+ label,
14
+ isOpen,
15
+ options,
16
+ filteredOptions,
17
+ multiple,
18
+ disabled,
19
+ maxIsReached,
20
+ maxlength,
21
+ includeSearch,
22
+ allowUserInput,
23
+ addValueText,
24
+ userInfoMessage,
25
+ searchPlaceholder,
26
+ searchValue,
27
+ handleOptionClick,
28
+ handleOptionKeydown,
29
+ handleSearchInput,
30
+ handleSearchKeydown,
31
+ handleNewOptionClick,
32
+ handleNewOptionKeydown,
33
+ listboxRef,
34
+ } = state
35
+
36
+ const selectedCount = filteredOptions.filter((opt) => opt.selected).length
37
+ const showMaxBanner = multiple && selectedCount > 0 && maxlength != null && maxlength > 0
38
+ const showNewOptionBanner = allowUserInput && addValueText && !maxIsReached
39
+ const showUserMessage = !!userInfoMessage
40
+
41
+ const listboxClasses = ['pkt-listbox', isOpen && 'pkt-listbox__open', 'pkt-txt-16-light'].filter(Boolean).join(' ')
42
+
43
+ return (
44
+ <>
45
+ <div
46
+ ref={listboxRef}
47
+ id={listboxId}
48
+ className={listboxClasses}
49
+ role={filteredOptions.length > 0 ? 'listbox' : undefined}
50
+ aria-label={filteredOptions.length > 0 ? `Liste: ${label || ''}` : undefined}
51
+ aria-multiselectable={filteredOptions.length > 0 && multiple ? 'true' : undefined}
52
+ >
53
+ <div className="pkt-listbox__banners">
54
+ {includeSearch && (
55
+ <div className="pkt-listbox__search">
56
+ <span className="pkt-listbox__search-icon">
57
+ <PktIcon name="magnifying-glass-small" />
58
+ </span>
59
+ <input
60
+ className="pkt-txt-16-light"
61
+ type="text"
62
+ aria-label="Søk i listen"
63
+ form=""
64
+ placeholder={searchPlaceholder || 'Søk...'}
65
+ onChange={handleSearchInput}
66
+ onKeyDown={handleSearchKeydown}
67
+ value={searchValue}
68
+ data-type="searchbox"
69
+ disabled={disabled}
70
+ readOnly={disabled}
71
+ role="searchbox"
72
+ />
73
+ </div>
74
+ )}
75
+ {showMaxBanner && (
76
+ <div className="pkt-listbox__banner pkt-listbox__banner--maximum-reached">
77
+ {selectedCount} av maks {maxlength} mulige er valgt.
78
+ </div>
79
+ )}
80
+
81
+ {showUserMessage && (
82
+ <div className="pkt-listbox__banner pkt-listbox__banner--user-message">
83
+ <PktIcon className="pkt-listbox__banner-icon" name="exclamation-mark-circle" />
84
+ {userInfoMessage}
85
+ </div>
86
+ )}
87
+
88
+ {showNewOptionBanner && (
89
+ <div
90
+ className="pkt-listbox__banner pkt-listbox__banner--new-option pkt-listbox__option"
91
+ data-type="new-option"
92
+ data-value={addValueText}
93
+ data-selected="false"
94
+ tabIndex={0}
95
+ onClick={() => handleNewOptionClick(addValueText)}
96
+ onKeyDown={handleNewOptionKeydown}
97
+ >
98
+ <PktIcon className="pkt-listbox__banner-icon" name="plus-sign" />
99
+ Legg til &quot;{addValueText}&quot;
100
+ </div>
101
+ )}
102
+
103
+ {options.length === 0 && filteredOptions.length === 0 && !showUserMessage && (
104
+ <div className="pkt-listbox__banner pkt-listbox__banner--empty">
105
+ <PktIcon className="pkt-listbox__banner-icon" name="exclamation-mark-circle" />
106
+ Tom liste
107
+ </div>
108
+ )}
109
+ </div>
110
+
111
+ <ul className="pkt-listbox__options" role="presentation">
112
+ {filteredOptions.map((option, index) => {
113
+ const optionClasses = [
114
+ 'pkt-listbox__option',
115
+ !multiple && option.selected && 'pkt-listbox__option--selected',
116
+ multiple && 'pkt-listbox__option--checkBox',
117
+ ]
118
+ .filter(Boolean)
119
+ .join(' ')
120
+
121
+ const isOptionDisabled = disabled || option.disabled || (maxIsReached && !option.selected)
122
+
123
+ return (
124
+ <li
125
+ key={`${option.value}-${index}`}
126
+ className={optionClasses}
127
+ onClick={() => handleOptionClick(option.value)}
128
+ onKeyDown={(e) => handleOptionKeydown(e, option.value)}
129
+ aria-selected={option.selected ? 'true' : 'false'}
130
+ tabIndex={isOptionDisabled ? -1 : 0}
131
+ data-index={index}
132
+ data-value={option.value}
133
+ data-selected={option.selected ? 'true' : 'false'}
134
+ data-disabled={isOptionDisabled || undefined}
135
+ aria-disabled={isOptionDisabled ? 'true' : 'false'}
136
+ role="option"
137
+ id={`${listboxId}-${index}`}
138
+ >
139
+ {multiple ? (
140
+ <input
141
+ className="pkt-input-check__input-checkbox"
142
+ type="checkbox"
143
+ tabIndex={-1}
144
+ value={option.value}
145
+ checked={option.selected || false}
146
+ readOnly
147
+ aria-hidden="true"
148
+ disabled={isOptionDisabled}
149
+ />
150
+ ) : option.selected ? (
151
+ <PktIcon name="check-big" />
152
+ ) : null}
153
+
154
+ <span className="pkt-listbox__option-label" id={`${listboxId}-option-label-${index}`}>
155
+ {option.prefix && <span className="pkt-listbox__option-prefix">{option.prefix}</span>}
156
+ {option.label || option.value}
157
+ </span>
158
+
159
+ {option.description && (
160
+ <span className="pkt-listbox__option-description pkt-txt-14-light">{option.description}</span>
161
+ )}
162
+ </li>
163
+ )
164
+ })}
165
+ </ul>
166
+ </div>
167
+ <div aria-live="polite" className="pkt-visually-hidden">
168
+ {userInfoMessage}
169
+ </div>
170
+ </>
171
+ )
172
+ }
@@ -0,0 +1,145 @@
1
+ 'use client'
2
+
3
+ import type {
4
+ ChangeEvent,
5
+ ChangeEventHandler,
6
+ FocusEvent,
7
+ KeyboardEvent,
8
+ MouseEvent as ReactMouseEvent,
9
+ KeyboardEvent as ReactKeyboardEvent,
10
+ ReactNode,
11
+ RefObject,
12
+ } from 'react'
13
+ import type { IPktComboboxOption, TPktComboboxDisplayValue, TPktComboboxTagPlacement } from 'shared-types/combobox'
14
+
15
+ export interface IPktCombobox {
16
+ // Values & options
17
+ value?: string | string[]
18
+ defaultValue?: string | string[]
19
+ options?: IPktComboboxOption[]
20
+ defaultOptions?: IPktComboboxOption[]
21
+
22
+ // Behavior
23
+ multiple?: boolean
24
+ maxlength?: number
25
+ typeahead?: boolean
26
+ includeSearch?: boolean
27
+ allowUserInput?: boolean
28
+ displayValueAs?: TPktComboboxDisplayValue
29
+ tagPlacement?: TPktComboboxTagPlacement
30
+ searchPlaceholder?: string
31
+
32
+ // Form
33
+ name?: string
34
+ id?: string
35
+ disabled?: boolean
36
+ required?: boolean
37
+ placeholder?: string
38
+
39
+ // InputWrapper props
40
+ label?: string
41
+ helptext?: string | ReactNode
42
+ helptextDropdown?: string | ReactNode
43
+ helptextDropdownButton?: string
44
+ hasError?: boolean
45
+ errorMessage?: string | ReactNode
46
+ fullwidth?: boolean
47
+ requiredTag?: boolean
48
+ requiredText?: string
49
+ optionalTag?: boolean
50
+ optionalText?: string
51
+ tagText?: string
52
+ useWrapper?: boolean
53
+
54
+ // Events
55
+ onChange?: ChangeEventHandler<HTMLInputElement>
56
+ onValueChange?: (values: string[]) => void
57
+
58
+ // React
59
+ className?: string
60
+ children?: ReactNode
61
+ }
62
+
63
+ export interface IComboboxState {
64
+ // Identity
65
+ id: string
66
+ inputId: string
67
+ listboxId: string
68
+
69
+ // Values
70
+ values: string[]
71
+ inputValue: string
72
+ searchValue: string
73
+
74
+ // Options
75
+ options: IPktComboboxOption[]
76
+ filteredOptions: IPktComboboxOption[]
77
+
78
+ // UI state
79
+ isOpen: boolean
80
+ userInfoMessage: string
81
+ addValueText: string | null
82
+ maxIsReached: boolean
83
+ editingSingleValue: boolean
84
+ inputFocus: boolean
85
+ focusedTagIndex: number
86
+
87
+ // Props passthrough
88
+ label?: string
89
+ multiple: boolean
90
+ maxlength?: number
91
+ typeahead: boolean
92
+ includeSearch: boolean
93
+ allowUserInput: boolean
94
+ displayValueAs: TPktComboboxDisplayValue
95
+ tagPlacement?: TPktComboboxTagPlacement
96
+ searchPlaceholder?: string
97
+ placeholder?: string
98
+ disabled: boolean
99
+ required: boolean
100
+ fullwidth: boolean
101
+ hasError: boolean
102
+ errorMessage?: string | ReactNode
103
+ helptext?: string | ReactNode
104
+ helptextDropdown?: string | ReactNode
105
+ helptextDropdownButton?: string
106
+ optionalTag: boolean
107
+ optionalText?: string
108
+ requiredTag: boolean
109
+ requiredText?: string
110
+ tagText?: string
111
+ useWrapper: boolean
112
+ name?: string
113
+ className?: string
114
+
115
+ // Derived state
116
+ hasCounter: boolean
117
+
118
+ // Refs
119
+ inputRef: RefObject<HTMLInputElement>
120
+ changeInputRef: RefObject<HTMLInputElement>
121
+ triggerRef: RefObject<HTMLDivElement>
122
+ listboxRef: RefObject<HTMLDivElement>
123
+ wrapperRef: RefObject<HTMLDivElement>
124
+
125
+ // Event handlers
126
+ handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void
127
+ handleInputKeydown: (e: KeyboardEvent<HTMLInputElement>) => void
128
+ handleInputFocus: () => void
129
+ handleInputBlur: () => void
130
+ handleFocusOut: (e: FocusEvent) => void
131
+ handleInputClick: (e: ReactMouseEvent) => void
132
+ handlePlaceholderClick: (e: ReactMouseEvent) => void
133
+ handleSelectOnlyKeydown: (e: ReactKeyboardEvent) => void
134
+ handleOptionClick: (value: string) => void
135
+ handleOptionKeydown: (e: KeyboardEvent<HTMLLIElement>, value: string) => void
136
+ handleTagRemove: (value: string) => void
137
+ handleTagKeydown: (e: ReactKeyboardEvent, index: number) => void
138
+ handleSearchInput: (e: ChangeEvent<HTMLInputElement>) => void
139
+ handleSearchKeydown: (e: KeyboardEvent<HTMLInputElement>) => void
140
+ handleNewOptionClick: (value: string) => void
141
+ handleNewOptionKeydown: (e: KeyboardEvent<HTMLDivElement>) => void
142
+
143
+ // Form integration
144
+ onChange?: ChangeEventHandler<HTMLInputElement>
145
+ }