Skip to content

Commit e123abc

Browse files
authored
Merge pull request #1531 from nrkno/feat/sofie-4074/bread-crumbs
2 parents 5e47cef + 65ad64f commit e123abc

File tree

5 files changed

+409
-2
lines changed

5 files changed

+409
-2
lines changed

packages/shared-lib/src/lib/JSONSchemaUtil.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export enum SchemaFormUIField {
3030
* - object properties. Valid values are 'json'.
3131
* - string properties. Valid values are 'base64-image'.
3232
* - boolean properties. Valid values are 'switch'.
33+
* - array properties with items.type string. Valid values are 'bread-crumbs'.
3334
*/
3435
DisplayType = 'ui:displayType',
3536
/**
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.input-breadcrumbtext {
2+
display: flex;
3+
flex-wrap: wrap;
4+
}
5+
6+
.input-breadcrumbtext-input {
7+
position: absolute;
8+
top: 0;
9+
left: 0;
10+
right: 0;
11+
bottom: 0;
12+
}
13+
14+
.input-breadcrumbtext-sizer {
15+
flex: 0 1;
16+
white-space: pre;
17+
position: relative;
18+
}
19+
20+
.input-breadcrumbtext-sizer::after {
21+
display: inline-block;
22+
content: attr(data-value) ' ';
23+
visibility: hidden;
24+
white-space: pre;
25+
padding: 0.5rem 0.75rem;
26+
letter-spacing: normal;
27+
min-width: 3em;
28+
}
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef } from 'react'
2+
import ClassNames from 'classnames'
3+
import Form from 'react-bootstrap/esm/Form'
4+
import Button from 'react-bootstrap/esm/Button'
5+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
6+
import { faPlus, faXmark } from '@fortawesome/free-solid-svg-icons'
7+
import { useTranslation } from 'react-i18next'
8+
9+
export function splitValueIntoLines(v: string | undefined): string[] {
10+
if (v === undefined || v.length === 0) {
11+
return []
12+
} else {
13+
return v.split('\n').map((i) => i.trimStart())
14+
}
15+
}
16+
export function joinLines(v: string[] | undefined): string {
17+
if (v === undefined || v.length === 0) {
18+
return ''
19+
} else {
20+
return v.join('\n')
21+
}
22+
}
23+
24+
import './BreadCrumbTextInput.scss'
25+
import { getAllAncestors } from '../lib'
26+
27+
interface IBreadCrumbTextInputControlProps {
28+
classNames?: string
29+
modifiedClassName?: string
30+
disabled?: boolean
31+
32+
/** Call handleUpdate on every change, before focus is lost */
33+
updateOnKey?: boolean
34+
35+
value: string[]
36+
handleUpdate: (value: string[]) => void
37+
}
38+
39+
type ReducerActions =
40+
| {
41+
type: 'setValue'
42+
value: string[]
43+
}
44+
| {
45+
type: 'insert'
46+
index?: number
47+
}
48+
| {
49+
type: 'remove'
50+
index: number
51+
selectPrevious?: boolean
52+
}
53+
| {
54+
type: 'set'
55+
index: number
56+
value: string
57+
}
58+
| {
59+
type: 'focus'
60+
index: number
61+
position: 'begin' | 'end'
62+
}
63+
| {
64+
type: 'clearEdit'
65+
}
66+
67+
type InputState = {
68+
value: string[]
69+
editingValue: string[] | null
70+
selectIndex: number | null
71+
selectIndexPosition: 'begin' | 'end' | null
72+
}
73+
74+
export function BreadCrumbTextInput({
75+
classNames,
76+
modifiedClassName,
77+
value,
78+
disabled,
79+
handleUpdate,
80+
updateOnKey,
81+
}: Readonly<IBreadCrumbTextInputControlProps>): JSX.Element {
82+
const { t } = useTranslation()
83+
84+
const [inputState, dispatch] = useReducer(
85+
(state: InputState, action: ReducerActions) => {
86+
const newState = {
87+
...state,
88+
}
89+
switch (action.type) {
90+
case 'setValue': {
91+
newState.value = value
92+
break
93+
}
94+
case 'insert': {
95+
newState.editingValue ??= newState.value ?? []
96+
if (action.index === undefined) {
97+
newState.selectIndex = newState.editingValue.push('') - 1
98+
newState.selectIndexPosition = 'end'
99+
} else {
100+
newState.editingValue.splice(action.index, 0, '')
101+
newState.selectIndex = action.index
102+
newState.selectIndexPosition = 'end'
103+
}
104+
break
105+
}
106+
case 'remove': {
107+
newState.editingValue ??= newState.value ?? []
108+
newState.editingValue.splice(action.index, 1)
109+
if (action.selectPrevious) {
110+
newState.selectIndex = Math.max(0, action.index - 1)
111+
newState.selectIndexPosition = 'end'
112+
} else {
113+
newState.selectIndex = null
114+
newState.selectIndexPosition = 'end'
115+
}
116+
break
117+
}
118+
case 'set': {
119+
newState.editingValue ??= newState.value ?? []
120+
newState.editingValue[action.index] = action.value
121+
break
122+
}
123+
case 'clearEdit': {
124+
newState.editingValue = null
125+
break
126+
}
127+
case 'focus': {
128+
newState.selectIndex = action.index
129+
newState.selectIndexPosition = action.position
130+
}
131+
}
132+
133+
return newState
134+
},
135+
{
136+
editingValue: null,
137+
selectIndex: null,
138+
selectIndexPosition: null,
139+
value,
140+
}
141+
)
142+
143+
useEffect(() => {
144+
dispatch({
145+
type: 'setValue',
146+
value,
147+
})
148+
}, [dispatch, value])
149+
150+
const volatileValue = inputState.editingValue ?? inputState.value
151+
152+
const inputRef = useRef<HTMLDivElement>(null)
153+
154+
const doCommit = useCallback(() => {
155+
if (inputState.editingValue === null) return
156+
157+
handleUpdate(inputState.editingValue.slice())
158+
dispatch({
159+
type: 'clearEdit',
160+
})
161+
}, [inputState])
162+
163+
const handleSegmentDelete = useCallback(
164+
(event: React.MouseEvent<HTMLButtonElement>) => {
165+
const index = parseInt(event.currentTarget.dataset['index'] ?? '')
166+
167+
if (!Number.isFinite(index)) return
168+
169+
dispatch({
170+
type: 'remove',
171+
index,
172+
})
173+
},
174+
[dispatch]
175+
)
176+
177+
const handleSegmentValueChange = useCallback(
178+
(event: React.ChangeEvent<HTMLInputElement>) => {
179+
const index = parseInt(event.target.dataset['index'] ?? '')
180+
181+
if (!Number.isFinite(index)) return
182+
183+
dispatch({
184+
type: 'set',
185+
index,
186+
value: event.target.value,
187+
})
188+
},
189+
[dispatch]
190+
)
191+
192+
const handleKeyDown = useCallback(
193+
(event: React.KeyboardEvent<HTMLInputElement>) => {
194+
const index = parseInt(event.currentTarget.dataset['index'] ?? '')
195+
196+
if (!Number.isFinite(index)) return
197+
198+
if (event.key === 'Enter') {
199+
dispatch({
200+
type: 'insert',
201+
index: index + 1,
202+
})
203+
event.stopPropagation()
204+
205+
delete event.currentTarget.dataset['backspace']
206+
} else if (event.key === 'Backspace') {
207+
if (!event.currentTarget.value) {
208+
if (!event.currentTarget.dataset['backspace']) {
209+
event.currentTarget.dataset['backspace'] = '1'
210+
} else {
211+
const backspaceCount = parseInt(event.currentTarget.dataset['backspace']) || 1
212+
if (backspaceCount > 3) {
213+
dispatch({
214+
type: 'remove',
215+
index,
216+
selectPrevious: true,
217+
})
218+
} else {
219+
event.currentTarget.dataset['backspace'] = `${backspaceCount + 1}`
220+
}
221+
}
222+
}
223+
} else if (event.key === 'ArrowLeft' && event.currentTarget.selectionStart === 0) {
224+
dispatch({
225+
type: 'focus',
226+
index: index - 1,
227+
position: 'end',
228+
})
229+
event.preventDefault()
230+
delete event.currentTarget.dataset['backspace']
231+
} else if (event.key === 'ArrowRight' && event.currentTarget.selectionEnd === event.currentTarget.value.length) {
232+
dispatch({
233+
type: 'focus',
234+
index: index + 1,
235+
position: 'begin',
236+
})
237+
event.preventDefault()
238+
delete event.currentTarget.dataset['backspace']
239+
} else {
240+
delete event.currentTarget.dataset['backspace']
241+
}
242+
243+
if (updateOnKey) {
244+
doCommit()
245+
}
246+
},
247+
[dispatch, doCommit, updateOnKey]
248+
)
249+
250+
const handleSegmentBlur = useCallback(
251+
(event: React.FocusEvent<HTMLInputElement>) => {
252+
delete event.currentTarget.dataset['backspace']
253+
},
254+
[dispatch]
255+
)
256+
257+
const handleBlur = useCallback(
258+
(event: React.FocusEvent<HTMLInputElement>) => {
259+
if (
260+
!event.relatedTarget ||
261+
!(event.relatedTarget instanceof HTMLElement) ||
262+
(inputRef.current && !getAllAncestors(event.relatedTarget).includes(inputRef.current))
263+
) {
264+
doCommit()
265+
}
266+
},
267+
[doCommit]
268+
)
269+
270+
const handleAddSegment = useCallback(() => {
271+
dispatch({
272+
type: 'insert',
273+
})
274+
}, [dispatch])
275+
276+
useLayoutEffect(() => {
277+
if (!inputRef.current) return
278+
if (inputState.selectIndex === null) return
279+
280+
const inputToFocus = inputRef.current.querySelector(
281+
`.input-breadcrumbtext-input[data-index="${inputState.selectIndex}"]`
282+
)
283+
284+
if (!inputToFocus || !(inputToFocus instanceof HTMLInputElement)) return
285+
286+
inputToFocus.focus()
287+
288+
if (inputState.selectIndexPosition === null) return
289+
if (inputState.selectIndexPosition === 'end') {
290+
inputToFocus.selectionStart = inputToFocus.value.length
291+
inputToFocus.selectionEnd = inputToFocus.selectionStart
292+
} else if (inputState.selectIndexPosition === 'begin') {
293+
inputToFocus.selectionStart = 0
294+
inputToFocus.selectionEnd = 0
295+
}
296+
}, [inputState.selectIndex, inputState.selectIndexPosition])
297+
298+
return (
299+
<div className="input-breadcrumbtext" ref={inputRef} tabIndex={0} onBlur={handleBlur}>
300+
{volatileValue.map((value, index, array) => (
301+
<React.Fragment key={`${index}`}>
302+
<div className="input-breadcrumbtext-sizer" data-value={value ?? ''}>
303+
<Form.Control
304+
type="text"
305+
className={ClassNames(
306+
classNames,
307+
inputState.editingValue !== null && modifiedClassName,
308+
'input-breadcrumbtext-input',
309+
{
310+
'is-invalid': value.trim() === '' && index < array.length - 1,
311+
}
312+
)}
313+
value={value}
314+
data-index={`${index}`}
315+
onKeyDown={handleKeyDown}
316+
onBlur={handleSegmentBlur}
317+
onChange={handleSegmentValueChange}
318+
disabled={disabled}
319+
/>
320+
</div>
321+
<Button data-index={`${index}`} variant="outline-secondary" onClick={handleSegmentDelete} disabled={disabled}>
322+
<FontAwesomeIcon icon={faXmark} />
323+
</Button>
324+
</React.Fragment>
325+
))}
326+
<Button onClick={handleAddSegment} variant="outline-secondary" title={t('Add')} disabled={disabled}>
327+
<FontAwesomeIcon icon={faPlus} />
328+
</Button>
329+
</div>
330+
)
331+
}
332+
333+
interface ICombinedMultiLineTextInputControlProps
334+
extends Omit<IBreadCrumbTextInputControlProps, 'value' | 'handleUpdate'> {
335+
value: string
336+
handleUpdate: (value: string) => void
337+
}
338+
export function CombinedMultiLineTextInputControl({
339+
value,
340+
handleUpdate,
341+
...props
342+
}: Readonly<ICombinedMultiLineTextInputControlProps>): JSX.Element {
343+
const valueArray = useMemo(() => splitValueIntoLines(value), [value])
344+
const handleUpdateArray = useCallback((value: string[]) => handleUpdate(joinLines(value)), [handleUpdate])
345+
346+
return <BreadCrumbTextInput {...props} value={valueArray} handleUpdate={handleUpdateArray} />
347+
}

0 commit comments

Comments
 (0)