@likec4/diagram 1.21.1 → 1.22.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 (120) hide show
  1. package/dist/LikeC4Diagram.js +9 -13
  2. package/dist/base/index.d.ts +0 -1
  3. package/dist/base/primitives/element/styles.css.js +1 -1
  4. package/dist/{base → components}/Link.css.js +3 -3
  5. package/dist/components/SearchControl.css.d.ts +2 -0
  6. package/dist/components/SearchControl.css.js +7 -0
  7. package/dist/components/SearchControl.d.ts +5 -0
  8. package/dist/components/SearchControl.js +17 -0
  9. package/dist/context/DiagramFeatures.d.ts +1 -1
  10. package/dist/context/IconRenderer.d.ts +10 -0
  11. package/dist/context/IconRenderer.js +34 -0
  12. package/dist/hooks/useCurrentViewId.js +8 -0
  13. package/dist/hooks/useDiagram.d.ts +108 -72
  14. package/dist/hooks/useDiagram.js +3 -0
  15. package/dist/likec4diagram/DiagramActor.js +13 -2
  16. package/dist/likec4diagram/DiagramUI.js +0 -2
  17. package/dist/likec4diagram/XYFlow.d.ts +6 -1
  18. package/dist/likec4diagram/XYFlow.js +37 -31
  19. package/dist/likec4diagram/custom/edges/RelationshipsDropdownMenu.js +2 -1
  20. package/dist/likec4diagram/state/actorContext.d.ts +620 -468
  21. package/dist/likec4diagram/state/assign.js +1 -1
  22. package/dist/likec4diagram/state/machine.d.ts +120 -74
  23. package/dist/likec4diagram/state/machine.js +32 -12
  24. package/dist/likec4diagram/state/syncManualLayoutActor.js +4 -8
  25. package/dist/likec4diagram/ui/controls/Controls.js +65 -48
  26. package/dist/likec4diagram/ui/controls/NavigationButtons.js +44 -69
  27. package/dist/likec4diagram/ui/controls/ToggleReadonly.d.ts +1 -0
  28. package/dist/likec4diagram/ui/controls/ToggleReadonly.js +29 -0
  29. package/dist/likec4diagram/ui/controls/_shared.js +1 -1
  30. package/dist/likec4diagram/ui/diagram-title/DiagramTitlePanel.js +1 -1
  31. package/dist/likec4diagram/ui/search/ElementsColumn.css.d.ts +15 -0
  32. package/dist/likec4diagram/ui/search/ElementsColumn.css.js +30 -0
  33. package/dist/likec4diagram/ui/search/ElementsColumn.d.ts +1 -0
  34. package/dist/likec4diagram/ui/search/ElementsColumn.js +243 -0
  35. package/dist/likec4diagram/ui/search/LikeC4Search.css.d.ts +8 -0
  36. package/dist/likec4diagram/ui/search/LikeC4Search.css.js +20 -0
  37. package/dist/likec4diagram/ui/search/LikeC4Search.d.ts +1 -0
  38. package/dist/likec4diagram/ui/search/LikeC4Search.js +265 -0
  39. package/dist/likec4diagram/ui/search/SearchInput.d.ts +1 -0
  40. package/dist/likec4diagram/ui/search/SearchInput.js +196 -0
  41. package/dist/likec4diagram/ui/search/ViewsColum.d.ts +9 -0
  42. package/dist/likec4diagram/ui/search/ViewsColum.js +130 -0
  43. package/dist/likec4diagram/ui/search/ViewsColumn.css.d.ts +5 -0
  44. package/dist/likec4diagram/ui/search/ViewsColumn.css.js +12 -0
  45. package/dist/likec4diagram/ui/search/_shared.css.d.ts +16 -0
  46. package/dist/likec4diagram/ui/search/_shared.css.js +5 -0
  47. package/dist/likec4diagram/ui/search/state.d.ts +19 -0
  48. package/dist/likec4diagram/ui/search/state.js +72 -0
  49. package/dist/likec4diagram/ui/search/utils.d.ts +3 -0
  50. package/dist/likec4diagram/ui/search/utils.js +23 -0
  51. package/dist/likec4diagram/useLayoutConstraints.js +4 -5
  52. package/dist/likec4model/useLikeC4ElementsTree.d.ts +1 -0
  53. package/dist/likec4model/useLikeC4ElementsTree.js +1 -0
  54. package/dist/overlays/Overlays.js +9 -9
  55. package/dist/overlays/element-details/ElementDetailsCard.js +20 -13
  56. package/dist/overlays/overlay/Overlay.d.ts +6 -2
  57. package/dist/overlays/overlay/Overlay.js +18 -29
  58. package/dist/overlays/relationship-details/SelectEdge.js +1 -3
  59. package/dist/overlays/relationships-browser/RelationshipsBrowser.js +6 -5
  60. package/dist/overlays/relationships-browser/SelectElement.js +10 -6
  61. package/dist/overlays/relationships-browser/actor.d.ts +2 -2
  62. package/dist/overlays/relationships-browser/actor.js +8 -8
  63. package/dist/overlays/relationships-browser/custom/nodeTypes.js +2 -2
  64. package/dist/style.css +1 -1
  65. package/package.json +13 -13
  66. package/src/LikeC4Diagram.tsx +0 -1
  67. package/src/base/index.ts +0 -2
  68. package/src/base/primitives/element/styles.css.ts +2 -2
  69. package/src/{base → components}/Link.css.ts +3 -3
  70. package/src/components/SearchControl.css.ts +69 -0
  71. package/src/components/SearchControl.tsx +33 -0
  72. package/src/context/DiagramFeatures.tsx +1 -1
  73. package/src/context/IconRenderer.tsx +54 -1
  74. package/src/globals.css.ts +0 -2
  75. package/src/hooks/useDiagram.ts +5 -0
  76. package/src/likec4diagram/DiagramActor.tsx +15 -2
  77. package/src/likec4diagram/DiagramUI.tsx +0 -4
  78. package/src/likec4diagram/XYFlow.tsx +39 -31
  79. package/src/likec4diagram/custom/edges/RelationshipsDropdownMenu.tsx +2 -1
  80. package/src/likec4diagram/state/assign.ts +1 -1
  81. package/src/likec4diagram/state/machine.ts +40 -14
  82. package/src/likec4diagram/state/syncManualLayoutActor.ts +6 -9
  83. package/src/likec4diagram/ui/controls/Controls.tsx +37 -25
  84. package/src/likec4diagram/ui/controls/NavigationButtons.tsx +36 -58
  85. package/src/likec4diagram/ui/controls/ToggleReadonly.tsx +31 -0
  86. package/src/likec4diagram/ui/controls/styles.css.ts +3 -1
  87. package/src/likec4diagram/ui/diagram-title/DiagramTitlePanel.tsx +1 -1
  88. package/src/likec4diagram/ui/search/ElementsColumn.css.ts +119 -0
  89. package/src/likec4diagram/ui/search/ElementsColumn.tsx +291 -0
  90. package/src/likec4diagram/ui/search/LikeC4Search.css.ts +117 -0
  91. package/src/likec4diagram/ui/search/LikeC4Search.tsx +312 -0
  92. package/src/likec4diagram/ui/search/SearchInput.tsx +235 -0
  93. package/src/likec4diagram/ui/search/ViewsColum.tsx +158 -0
  94. package/src/likec4diagram/ui/search/ViewsColumn.css.ts +37 -0
  95. package/src/likec4diagram/ui/search/_shared.css.ts +104 -0
  96. package/src/likec4diagram/ui/search/state.ts +76 -0
  97. package/src/likec4diagram/ui/search/utils.ts +20 -0
  98. package/src/likec4diagram/useLayoutConstraints.ts +4 -5
  99. package/src/likec4model/useLikeC4ElementsTree.ts +4 -4
  100. package/src/overlays/Overlays.tsx +31 -25
  101. package/src/overlays/element-details/ElementDetailsCard.css.ts +4 -4
  102. package/src/overlays/element-details/ElementDetailsCard.tsx +245 -240
  103. package/src/overlays/overlay/Overlay.css.ts +10 -9
  104. package/src/overlays/overlay/Overlay.tsx +38 -26
  105. package/src/overlays/relationship-details/SelectEdge.css.ts +1 -0
  106. package/src/overlays/relationship-details/SelectEdge.tsx +2 -4
  107. package/src/overlays/relationships-browser/RelationshipsBrowser.tsx +10 -5
  108. package/src/overlays/relationships-browser/SelectElement.tsx +88 -82
  109. package/src/overlays/relationships-browser/actor.ts +10 -10
  110. package/src/overlays/relationships-browser/custom/nodeTypes.tsx +2 -2
  111. package/dist/LikeC4Search.css.d.ts +0 -1
  112. package/dist/LikeC4Search.css.js +0 -5
  113. package/dist/LikeC4Search.d.ts +0 -2
  114. package/dist/LikeC4Search.js +0 -88
  115. package/src/LikeC4Search.css.ts +0 -12
  116. package/src/LikeC4Search.tsx +0 -91
  117. /package/dist/{base → components}/Link.css.d.ts +0 -0
  118. /package/dist/{base → components}/Link.d.ts +0 -0
  119. /package/dist/{base → components}/Link.js +0 -0
  120. /package/src/{base → components}/Link.tsx +0 -0
@@ -0,0 +1,312 @@
1
+ import {
2
+ ActionIcon,
3
+ Box,
4
+ Grid,
5
+ GridCol,
6
+ Group,
7
+ Portal,
8
+ RemoveScroll,
9
+ ScrollArea,
10
+ ScrollAreaAutosize,
11
+ Stack,
12
+ Title,
13
+ } from '@mantine/core'
14
+ import {
15
+ useCallbackRef,
16
+ useDisclosure,
17
+ useFocusReturn,
18
+ useFocusTrap,
19
+ useFocusWithin,
20
+ useHotkeys,
21
+ useMergedRef,
22
+ useWindowEvent,
23
+ } from '@mantine/hooks'
24
+ import { IconX } from '@tabler/icons-react'
25
+ import { AnimatePresence, LayoutGroup, m } from 'framer-motion'
26
+ import { useRef } from 'react'
27
+ import { SearchControl } from '../../../components/SearchControl'
28
+ import { useMantinePortalProps } from '../../../hooks'
29
+ import { ElementsColumn } from './ElementsColumn'
30
+ import * as css from './LikeC4Search.css'
31
+ import { LikeC4SearchInput } from './SearchInput'
32
+ import {
33
+ LikeC4SearchContext,
34
+ setPickView,
35
+ useCloseSearch,
36
+ usePickView,
37
+ wasResetPickView,
38
+ } from './state'
39
+ import { stopAndPrevent } from './utils'
40
+ import { ViewButton, ViewsColumn } from './ViewsColum'
41
+
42
+ export function LikeC4Search() {
43
+ const [searchOpened, searchOps] = useDisclosure(false)
44
+ useHotkeys([
45
+ ['mod+k', () => searchOps.toggle(), {
46
+ preventDefault: true,
47
+ }],
48
+ ['mod+f', () => searchOps.toggle(), {
49
+ preventDefault: true,
50
+ }],
51
+ ])
52
+
53
+ const afterCloseCbRef = useRef<(() => void)>(null)
54
+ const close = useCallbackRef((cb?: () => void) => {
55
+ afterCloseCbRef.current = cb ?? null
56
+ searchOps.close()
57
+ })
58
+ const onExitComplete = useCallbackRef(() => {
59
+ if (afterCloseCbRef.current) {
60
+ afterCloseCbRef.current()
61
+ afterCloseCbRef.current = null
62
+ }
63
+ })
64
+
65
+ const { portalProps } = useMantinePortalProps()
66
+
67
+ return (
68
+ <LikeC4SearchContext value={close}>
69
+ <SearchControl
70
+ onClick={e => {
71
+ e.stopPropagation()
72
+ searchOps.toggle()
73
+ }} />
74
+ <Portal {...portalProps}>
75
+ <RemoveScroll enabled={searchOpened}>
76
+ <AnimatePresence onExitComplete={onExitComplete}>
77
+ {searchOpened && (
78
+ <>
79
+ <m.div
80
+ key={'backdrop'}
81
+ className={css.backdrop}
82
+ initial={{ opacity: 0.7 }}
83
+ animate={{
84
+ opacity: 1,
85
+ transition: {
86
+ duration: 0.1,
87
+ },
88
+ }}
89
+ exit={{
90
+ opacity: 0,
91
+ transition: {
92
+ duration: 0.075,
93
+ },
94
+ }}>
95
+ </m.div>
96
+ <LikeC4SearchOverlay key="overlay" />
97
+ </>
98
+ )}
99
+ </AnimatePresence>
100
+ </RemoveScroll>
101
+ </Portal>
102
+ </LikeC4SearchContext>
103
+ )
104
+ }
105
+
106
+ function LikeC4SearchOverlay() {
107
+ const close = useCloseSearch()
108
+ const focusTrapRef = useFocusTrap()
109
+ const { ref: forcusRef, focused } = useFocusWithin()
110
+
111
+ const ref = useMergedRef(forcusRef, focusTrapRef)
112
+
113
+ useWindowEvent(
114
+ 'keydown',
115
+ (event) => {
116
+ if (event.key === 'Escape') {
117
+ stopAndPrevent(event)
118
+ if (!wasResetPickView()) {
119
+ close()
120
+ }
121
+ return
122
+ }
123
+ },
124
+ )
125
+
126
+ return (
127
+ <m.div
128
+ ref={ref}
129
+ className={css.root}
130
+ // initial={{
131
+ // opacity: 0,
132
+ // // scale: 0.9,
133
+ // translateY: -20,
134
+ // }}
135
+ animate={{
136
+ opacity: 1,
137
+ scale: 1,
138
+ translateY: 0,
139
+ }}
140
+ exit={{
141
+ opacity: 0,
142
+ scale: 0.9,
143
+ transition: {
144
+ duration: 0.1,
145
+ },
146
+ }}
147
+ >
148
+ <Group wrap="nowrap">
149
+ <Box flex={1} px={'sm'}>
150
+ <LikeC4SearchInput />
151
+ </Box>
152
+ <Box flex={'0 0 auto'}>
153
+ <ActionIcon
154
+ size={'lg'}
155
+ variant="default"
156
+ onClick={(e) => {
157
+ e.stopPropagation()
158
+ close()
159
+ }}>
160
+ <IconX />
161
+ </ActionIcon>
162
+ </Box>
163
+ </Group>
164
+ <Grid>
165
+ <GridCol span={6}>
166
+ <Title component={'div'} order={6} c="dimmed" pl="sm">Elements</Title>
167
+ </GridCol>
168
+ <GridCol span={6}>
169
+ <Title component={'div'} order={6} c="dimmed" pl="sm">Views</Title>
170
+ </GridCol>
171
+ </Grid>
172
+ <Grid
173
+ style={{
174
+ containerName: 'likec4-search-elements',
175
+ containerType: 'size',
176
+ overflow: 'hidden',
177
+ flexGrow: 1,
178
+ }}>
179
+ <GridCol span={6}>
180
+ <ScrollArea
181
+ type="hover"
182
+ className={css.scrollArea}
183
+ pr="xs"
184
+ scrollbars="y">
185
+ <AnimatePresence>
186
+ <LayoutGroup id="likec4-search-elements">
187
+ <ElementsColumn />
188
+ </LayoutGroup>
189
+ </AnimatePresence>
190
+ </ScrollArea>
191
+ </GridCol>
192
+ <GridCol span={6}>
193
+ <ScrollArea
194
+ type="hover"
195
+ className={css.scrollArea}
196
+ pr="xs"
197
+ scrollbars="y">
198
+ <AnimatePresence>
199
+ <LayoutGroup id="likec4-search-views">
200
+ <ViewsColumn />
201
+ </LayoutGroup>
202
+ </AnimatePresence>
203
+ </ScrollArea>
204
+ </GridCol>
205
+ </Grid>
206
+ <Box></Box>
207
+ <PickView />
208
+ </m.div>
209
+ )
210
+ }
211
+
212
+ function PickView() {
213
+ const pickview = usePickView()
214
+ // const navigateTo = useCloseSearchAndNavigateTo()
215
+ const focusTrapRef = useFocusTrap(!!pickview)
216
+ useFocusReturn({
217
+ opened: !!pickview,
218
+ })
219
+
220
+ return (
221
+ <AnimatePresence>
222
+ <LayoutGroup id="likec4-search-pickview">
223
+ {pickview && (
224
+ <>
225
+ <m.div
226
+ key="backdrop"
227
+ className={css.pickviewBackdrop}
228
+ onClick={e => {
229
+ e.stopPropagation()
230
+ setPickView(null)
231
+ }}>
232
+ </m.div>
233
+ <m.div
234
+ key="pickview"
235
+ initial={{
236
+ opacity: 0,
237
+ scale: 0.95,
238
+ originY: 0,
239
+ translateX: '-50%',
240
+ translateY: -20,
241
+ }}
242
+ animate={{
243
+ opacity: 1,
244
+ scale: 1,
245
+ translateY: 0,
246
+ }}
247
+ exit={{
248
+ opacity: 0,
249
+ scale: 0.98,
250
+ translateY: -20,
251
+ transition: {
252
+ duration: 0.1,
253
+ },
254
+ }}
255
+ className={css.pickview}
256
+ data-likec4-search-views
257
+ ref={focusTrapRef}>
258
+ <Group px="sm" py="md" justify="space-between">
259
+ <Title order={2} lh={1}>Select view</Title>
260
+ <ActionIcon
261
+ size={'md'}
262
+ variant="default"
263
+ onClick={(e) => {
264
+ e.stopPropagation()
265
+ setPickView(null)
266
+ }}>
267
+ <IconX />
268
+ </ActionIcon>
269
+ </Group>
270
+
271
+ <ScrollAreaAutosize mah={'calc(100vh - 8rem - 50px)'} type="never">
272
+ {pickview.scoped.length > 0 && (
273
+ <Stack gap={'sm'} px={'sm'} className={css.pickviewGroup}>
274
+ <Title order={6} c={'dimmed'}>scoped views of the element</Title>
275
+ {pickview.scoped.map((view, i) => (
276
+ <ViewButton
277
+ key={view.id}
278
+ view={view}
279
+ search={''}
280
+ loop
281
+ mod={{
282
+ autofocus: i === 0,
283
+ }}
284
+ />
285
+ ))}
286
+ </Stack>
287
+ )}
288
+
289
+ {pickview.others.length > 0 && (
290
+ <Stack gap={'sm'} px={'sm'} className={css.pickviewGroup}>
291
+ <Title order={6} c={'dimmed'}>views including this element</Title>
292
+ {pickview.others.map((view, i) => (
293
+ <ViewButton
294
+ key={view.id}
295
+ view={view}
296
+ search={''}
297
+ loop
298
+ mod={{
299
+ autofocus: i === 0 && pickview.scoped.length === 0,
300
+ }}
301
+ />
302
+ ))}
303
+ </Stack>
304
+ )}
305
+ </ScrollAreaAutosize>
306
+ </m.div>
307
+ </>
308
+ )}
309
+ </LayoutGroup>
310
+ </AnimatePresence>
311
+ )
312
+ }
@@ -0,0 +1,235 @@
1
+ import { compareNatural } from '@likec4/core'
2
+ import {
3
+ Combobox,
4
+ ComboboxDropdown,
5
+ ComboboxEmpty,
6
+ ComboboxOption,
7
+ ComboboxOptions,
8
+ ComboboxTarget,
9
+ Input,
10
+ rem,
11
+ ScrollAreaAutosize,
12
+ Text,
13
+ useCombobox,
14
+ } from '@mantine/core'
15
+ import { useDebouncedValue, useFocusWithin, useIsFirstRender, useWindowEvent } from '@mantine/hooks'
16
+ import { usePreviousDistinct } from '@react-hookz/web'
17
+ import { IconSearch } from '@tabler/icons-react'
18
+ import { type ReactNode } from 'react'
19
+ import { isString, keys, only } from 'remeda'
20
+ import { useLikeC4Model } from '../../../likec4model/useLikeC4Model'
21
+ import * as css from './LikeC4Search.css'
22
+ import { setSearch, useIsPickViewActive, useSearch } from './state'
23
+ import { moveFocusToSearchInput, stopAndPrevent } from './utils'
24
+
25
+ function startingWithKind(search: string) {
26
+ return search.match(/^(k|ki|kin|kind|kind:)$/) != null
27
+ }
28
+
29
+ const SEARCH_PREFIXES = ['#', 'kind:']
30
+
31
+ export function LikeC4SearchInput() {
32
+ const isPickViewActive = useIsPickViewActive()
33
+ const likec4model = useLikeC4Model(true)
34
+ const combobox = useCombobox({
35
+ scrollBehavior: 'smooth',
36
+ })
37
+ const { ref, focused } = useFocusWithin<HTMLInputElement>()
38
+ const search = useSearch()
39
+ const previous = usePreviousDistinct(search)
40
+ const isFirstRender = useIsFirstRender()
41
+
42
+ const [isEmptyForSomeTime] = useDebouncedValue(search === '' && !isFirstRender, isString(previous) ? 400 : 1000)
43
+
44
+ useWindowEvent(
45
+ 'keydown',
46
+ (event) => {
47
+ try {
48
+ if (
49
+ !focused && !isPickViewActive && (
50
+ event.key === 'Backspace' ||
51
+ event.key.startsWith('Arrow') ||
52
+ event.key.match(/^\p{L}$/u)
53
+ )
54
+ ) {
55
+ moveFocusToSearchInput()
56
+ }
57
+ } catch (e) {
58
+ console.warn(e)
59
+ }
60
+ },
61
+ )
62
+
63
+ let options = [] as ReactNode[]
64
+ let isExactMatch = false
65
+
66
+ switch (true) {
67
+ case search === '' && (isEmptyForSomeTime || SEARCH_PREFIXES.includes(previous ?? '')): {
68
+ options = SEARCH_PREFIXES.map((prefix) => (
69
+ <ComboboxOption value={prefix} key={prefix}>
70
+ <Text component="span" opacity={.5} mr={4} fz={'sm'}>Search by</Text>
71
+ {prefix}
72
+ </ComboboxOption>
73
+ ))
74
+ break
75
+ }
76
+ case search.startsWith('#'): {
77
+ const searchTag = search.toLocaleLowerCase().slice(1)
78
+ const alloptions = likec4model.allTags().filter((tag) => tag.toLocaleLowerCase().includes(searchTag)).sort(
79
+ compareNatural,
80
+ )
81
+ isExactMatch = only(alloptions)?.toLocaleLowerCase() === searchTag
82
+ if (alloptions.length === 0) {
83
+ options = [
84
+ <ComboboxEmpty key="empty-tags">
85
+ No tags found
86
+ </ComboboxEmpty>,
87
+ ]
88
+ } else {
89
+ options = alloptions.map((tag) => (
90
+ <ComboboxOption value={`#${tag}`} key={tag}>
91
+ <Text component="span" opacity={.5} mr={1} fz={'sm'}>#</Text>
92
+ {tag}
93
+ </ComboboxOption>
94
+ ))
95
+ }
96
+ break
97
+ }
98
+ case search.startsWith('kind:'):
99
+ case startingWithKind(search): {
100
+ const term = search.length > 5 ? search.slice(5).toLocaleLowerCase() : ''
101
+ let alloptions = keys(likec4model.$model.specification.elements)
102
+ if (term) {
103
+ alloptions = alloptions.filter((kind) => kind.toLocaleLowerCase().includes(term))
104
+ isExactMatch = only(alloptions)?.toLocaleLowerCase() === term
105
+ }
106
+ if (alloptions.length === 0) {
107
+ options = [
108
+ <ComboboxEmpty key="empty-kinds">
109
+ No kinds found
110
+ </ComboboxEmpty>,
111
+ ]
112
+ } else {
113
+ options = alloptions.map((kind) => (
114
+ <ComboboxOption value={`kind:${kind}`} key={kind}>
115
+ <Text component="span" opacity={.5} mr={1} fz={'sm'}>kind:</Text>
116
+ {kind}
117
+ </ComboboxOption>
118
+ ))
119
+ }
120
+ break
121
+ }
122
+ }
123
+
124
+ return (
125
+ <Combobox
126
+ onOptionSubmit={(optionValue) => {
127
+ setSearch(optionValue)
128
+ combobox.resetSelectedOption()
129
+ if (!SEARCH_PREFIXES.includes(optionValue)) {
130
+ combobox.closeDropdown()
131
+ // Let react to display filtered elements
132
+ setTimeout(() => {
133
+ document.querySelector<HTMLButtonElement>(`.${css.root} .${css.focusable}`)?.focus()
134
+ }, 50)
135
+ }
136
+ }}
137
+ width={'max-content'}
138
+ position="bottom-start"
139
+ shadow="md"
140
+ offset={{
141
+ mainAxis: 4,
142
+ crossAxis: 50,
143
+ }}
144
+ store={combobox}
145
+ withinPortal={false}
146
+ >
147
+ <ComboboxTarget>
148
+ <Input
149
+ ref={ref}
150
+ id="likec4searchinput"
151
+ placeholder="Search by title, description or start with # or kind:"
152
+ tabIndex={0}
153
+ classNames={{
154
+ input: css.input,
155
+ }}
156
+ size="xl"
157
+ value={search}
158
+ leftSection={<IconSearch style={{ width: rem(20) }} stroke={2} />}
159
+ onChange={(event) => {
160
+ setSearch(event.currentTarget.value)
161
+ combobox.openDropdown()
162
+ combobox.updateSelectedOptionIndex()
163
+ }}
164
+ onClick={() => combobox.openDropdown()}
165
+ onFocus={() => combobox.openDropdown()}
166
+ onBlur={() => combobox.closeDropdown()}
167
+ onKeyDownCapture={(e) => {
168
+ if (e.key === 'Tab') {
169
+ switch (true) {
170
+ case combobox.getSelectedOptionIndex() >= 0: {
171
+ combobox.clickSelectedOption()
172
+ return stopAndPrevent(e)
173
+ }
174
+ case options.length === 1: {
175
+ const firstOption = combobox.selectFirstOption()
176
+ if (firstOption) {
177
+ combobox.clickSelectedOption()
178
+ }
179
+ return stopAndPrevent(e)
180
+ }
181
+ case startingWithKind(search): {
182
+ setSearch('kind:')
183
+ return stopAndPrevent(e)
184
+ }
185
+ }
186
+ return
187
+ }
188
+ if (e.key === 'Backspace' && combobox.dropdownOpened) {
189
+ if (search === 'kind:') {
190
+ setSearch('')
191
+ combobox.resetSelectedOption()
192
+ return stopAndPrevent(e)
193
+ }
194
+ if (search.startsWith('kind:') && isExactMatch) {
195
+ setSearch('kind:')
196
+ combobox.resetSelectedOption()
197
+ return stopAndPrevent(e)
198
+ }
199
+ if (search.startsWith('#') && isExactMatch) {
200
+ setSearch('#')
201
+ combobox.resetSelectedOption()
202
+ return stopAndPrevent(e)
203
+ }
204
+ }
205
+ if (e.key === 'Escape' && combobox.dropdownOpened && options.length > 0) {
206
+ stopAndPrevent(e)
207
+ combobox.closeDropdown()
208
+ return
209
+ }
210
+ if (
211
+ e.key === 'ArrowDown' && (
212
+ !combobox.dropdownOpened ||
213
+ options.length === 0 || isExactMatch ||
214
+ // reached the last option and the search is empty
215
+ (search === '' && combobox.getSelectedOptionIndex() === options.length - 1)
216
+ )
217
+ ) {
218
+ combobox.closeDropdown()
219
+ stopAndPrevent(e)
220
+ document.querySelector<HTMLButtonElement>(`.${css.root} .${css.focusable}`)?.focus()
221
+ return
222
+ }
223
+ }} />
224
+ </ComboboxTarget>
225
+
226
+ <ComboboxDropdown hidden={options.length === 0} style={{ minWidth: 300 }}>
227
+ <ComboboxOptions>
228
+ <ScrollAreaAutosize mah={'min(322px, calc(100cqh - 50px))'} type="scroll">
229
+ {options}
230
+ </ScrollAreaAutosize>
231
+ </ComboboxOptions>
232
+ </ComboboxDropdown>
233
+ </Combobox>
234
+ )
235
+ }
@@ -0,0 +1,158 @@
1
+ import {
2
+ type LikeC4Model,
3
+ type Tag,
4
+ isDeploymentView,
5
+ } from '@likec4/core'
6
+ import {
7
+ type ElementProps,
8
+ type UnstyledButtonProps,
9
+ Badge,
10
+ Box,
11
+ createScopedKeydownHandler,
12
+ Group,
13
+ Highlight,
14
+ Stack,
15
+ ThemeIcon,
16
+ UnstyledButton,
17
+ VisuallyHidden,
18
+ } from '@mantine/core'
19
+ import { IconStack2, IconZoomScan } from '@tabler/icons-react'
20
+ import clsx from 'clsx'
21
+ import { m } from 'framer-motion'
22
+ import { first } from 'remeda'
23
+ import { useCurrentViewId } from '../../../hooks'
24
+ import { useLikeC4Model } from '../../../likec4model/useLikeC4Model'
25
+ import { emptyBoX } from './_shared.css'
26
+ import { useCloseSearchAndNavigateTo, useNormalizedSearch } from './state'
27
+ import { centerY, moveFocusToSearchInput } from './utils'
28
+ import * as css from './ViewsColumn.css'
29
+
30
+ export const NothingFound = () => (
31
+ <Box className={emptyBoX}>
32
+ Nothing found
33
+ </Box>
34
+ )
35
+
36
+ export function ViewsColumn() {
37
+ const search = useNormalizedSearch()
38
+ let views = [...useLikeC4Model(true).views()]
39
+ if (search) {
40
+ if (search.startsWith('kind:')) {
41
+ views = []
42
+ } else {
43
+ views = views.filter(view => {
44
+ if (search.startsWith('#')) {
45
+ return view.tags.some((tag: Tag) => tag.toLocaleLowerCase().includes(search.slice(1)))
46
+ }
47
+ return (view.title ?? '' + view.$view.description ?? '').toLocaleLowerCase().includes(search)
48
+ })
49
+ }
50
+ }
51
+
52
+ return (
53
+ <Stack
54
+ renderRoot={props => <m.div layout {...props} />}
55
+ gap={8}
56
+ data-likec4-search-views
57
+ onKeyDown={(e) => {
58
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
59
+ const maxY = (e.target as HTMLElement).getBoundingClientRect().y
60
+ const elementButtons = [...document.querySelectorAll<HTMLButtonElement>(
61
+ `[data-likec4-search-elements] .likec4-element-button`,
62
+ )]
63
+ let elementButton = elementButtons.length > 1
64
+ ? elementButtons.find((el, i, all) => centerY(el) > maxY || i === all.length - 1)
65
+ : null
66
+ elementButton ??= first(elementButtons)
67
+
68
+ if (elementButton) {
69
+ e.stopPropagation()
70
+ elementButton.focus()
71
+ }
72
+ return
73
+ }
74
+ }}>
75
+ {views.length === 0 && <NothingFound />}
76
+ {views.length > 0 && (
77
+ <VisuallyHidden>
78
+ <UnstyledButton
79
+ data-likec4-view
80
+ tabIndex={-1}
81
+ onFocus={e => {
82
+ e.stopPropagation()
83
+ moveFocusToSearchInput()
84
+ }} />
85
+ </VisuallyHidden>
86
+ )}
87
+ {views.map((view, i) => (
88
+ <m.div layoutId={`@view${view.id}`} key={view.id}>
89
+ <ViewButton
90
+ view={view}
91
+ search={search}
92
+ tabIndex={i === 0 ? 0 : -1}
93
+ />
94
+ </m.div>
95
+ ))}
96
+ </Stack>
97
+ )
98
+ }
99
+
100
+ export function ViewButton(
101
+ { className, view, loop = false, search, ...props }:
102
+ & {
103
+ view: LikeC4Model.View
104
+ search: string
105
+ loop?: boolean
106
+ }
107
+ & UnstyledButtonProps
108
+ & ElementProps<'button'>,
109
+ ) {
110
+ const navigateTo = useCloseSearchAndNavigateTo()
111
+ const currentViewId = useCurrentViewId()
112
+ const isCurrentView = view.id === currentViewId
113
+ return (
114
+ <UnstyledButton
115
+ {...props}
116
+ className={clsx(css.focusable, css.viewButton, className)}
117
+ data-likec4-view={view.id}
118
+ {...isCurrentView && { 'data-disabled': true }}
119
+ onClick={(e) => {
120
+ e.stopPropagation()
121
+ navigateTo(view.id)
122
+ }}
123
+ onKeyDown={createScopedKeydownHandler({
124
+ siblingSelector: '[data-likec4-view]',
125
+ parentSelector: '[data-likec4-search-views]',
126
+ activateOnFocus: false,
127
+ loop,
128
+ orientation: 'vertical',
129
+ onKeyDown: (e) => {
130
+ if (e.nativeEvent.code === 'Space') {
131
+ e.stopPropagation()
132
+ navigateTo(view.id)
133
+ }
134
+ },
135
+ })}>
136
+ <ThemeIcon variant="transparent">
137
+ {isDeploymentView(view)
138
+ ? <IconStack2 stroke={1.8} />
139
+ : <IconZoomScan stroke={1.8} />}
140
+ </ThemeIcon>
141
+ <Box style={{ flexGrow: 1 }}>
142
+ <Group gap={'xs'} wrap="nowrap" align="center">
143
+ <Highlight component="div" highlight={search} className={css.viewTitle}>
144
+ {view.title || 'untitled'}
145
+ </Highlight>
146
+ {isCurrentView && <Badge size="xs" fz={9} radius={'sm'}>current</Badge>}
147
+ </Group>
148
+ <Highlight
149
+ highlight={view.$view.description ? search : ''}
150
+ component="div"
151
+ className={css.viewDescription}
152
+ lineClamp={1}>
153
+ {view.$view.description || 'No description'}
154
+ </Highlight>
155
+ </Box>
156
+ </UnstyledButton>
157
+ )
158
+ }