@furystack/shades-common-components 15.0.4 → 15.1.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.
- package/CHANGELOG.md +49 -0
- package/esm/components/button-group.d.ts +6 -5
- package/esm/components/button-group.d.ts.map +1 -1
- package/esm/components/button-group.js +5 -1
- package/esm/components/button-group.js.map +1 -1
- package/esm/components/button-group.spec.js +5 -0
- package/esm/components/button-group.spec.js.map +1 -1
- package/esm/components/button.d.ts +3 -2
- package/esm/components/button.d.ts.map +1 -1
- package/esm/components/button.js.map +1 -1
- package/esm/components/chip.d.ts +3 -2
- package/esm/components/chip.d.ts.map +1 -1
- package/esm/components/chip.js +6 -1
- package/esm/components/chip.js.map +1 -1
- package/esm/components/chip.spec.js +16 -0
- package/esm/components/chip.spec.js.map +1 -1
- package/esm/components/component-size.d.ts +6 -0
- package/esm/components/component-size.d.ts.map +1 -0
- package/esm/components/component-size.js +2 -0
- package/esm/components/component-size.js.map +1 -0
- package/esm/components/index.d.ts +2 -0
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +2 -0
- package/esm/components/index.js.map +1 -1
- package/esm/components/inputs/checkbox.d.ts +6 -0
- package/esm/components/inputs/checkbox.d.ts.map +1 -1
- package/esm/components/inputs/checkbox.js +47 -0
- package/esm/components/inputs/checkbox.js.map +1 -1
- package/esm/components/inputs/checkbox.spec.js +38 -0
- package/esm/components/inputs/checkbox.spec.js.map +1 -1
- package/esm/components/inputs/input-number.d.ts +6 -0
- package/esm/components/inputs/input-number.d.ts.map +1 -1
- package/esm/components/inputs/input-number.js +26 -0
- package/esm/components/inputs/input-number.js.map +1 -1
- package/esm/components/inputs/input-number.spec.js +38 -0
- package/esm/components/inputs/input-number.spec.js.map +1 -1
- package/esm/components/inputs/input.d.ts +7 -1
- package/esm/components/inputs/input.d.ts.map +1 -1
- package/esm/components/inputs/input.js +24 -1
- package/esm/components/inputs/input.js.map +1 -1
- package/esm/components/inputs/input.spec.js +38 -0
- package/esm/components/inputs/input.spec.js.map +1 -1
- package/esm/components/inputs/radio.d.ts +6 -0
- package/esm/components/inputs/radio.d.ts.map +1 -1
- package/esm/components/inputs/radio.js +37 -0
- package/esm/components/inputs/radio.js.map +1 -1
- package/esm/components/inputs/radio.spec.js +38 -0
- package/esm/components/inputs/radio.spec.js.map +1 -1
- package/esm/components/inputs/select.d.ts +6 -0
- package/esm/components/inputs/select.d.ts.map +1 -1
- package/esm/components/inputs/select.js +24 -0
- package/esm/components/inputs/select.js.map +1 -1
- package/esm/components/inputs/select.spec.js +22 -0
- package/esm/components/inputs/select.spec.js.map +1 -1
- package/esm/components/inputs/switch.d.ts +2 -1
- package/esm/components/inputs/switch.d.ts.map +1 -1
- package/esm/components/inputs/switch.js +14 -1
- package/esm/components/inputs/switch.js.map +1 -1
- package/esm/components/inputs/switch.spec.js +38 -0
- package/esm/components/inputs/switch.spec.js.map +1 -1
- package/esm/components/inputs/text-area.d.ts +6 -0
- package/esm/components/inputs/text-area.d.ts.map +1 -1
- package/esm/components/inputs/text-area.js +17 -0
- package/esm/components/inputs/text-area.js.map +1 -1
- package/esm/components/inputs/text-area.spec.js +38 -0
- package/esm/components/inputs/text-area.spec.js.map +1 -1
- package/esm/components/pagination.d.ts +3 -2
- package/esm/components/pagination.d.ts.map +1 -1
- package/esm/components/pagination.js.map +1 -1
- package/esm/components/rating.d.ts +2 -1
- package/esm/components/rating.d.ts.map +1 -1
- package/esm/components/rating.js.map +1 -1
- package/esm/components/route-breadcrumb.d.ts +46 -0
- package/esm/components/route-breadcrumb.d.ts.map +1 -0
- package/esm/components/route-breadcrumb.js +56 -0
- package/esm/components/route-breadcrumb.js.map +1 -0
- package/esm/components/route-breadcrumb.spec.d.ts +2 -0
- package/esm/components/route-breadcrumb.spec.d.ts.map +1 -0
- package/esm/components/route-breadcrumb.spec.js +186 -0
- package/esm/components/route-breadcrumb.spec.js.map +1 -0
- package/esm/components/timeline.d.ts +4 -0
- package/esm/components/timeline.d.ts.map +1 -1
- package/esm/components/timeline.js +77 -1
- package/esm/components/timeline.js.map +1 -1
- package/esm/components/timeline.spec.js +74 -0
- package/esm/components/timeline.spec.js.map +1 -1
- package/package.json +1 -1
- package/src/components/button-group.spec.tsx +6 -0
- package/src/components/button-group.tsx +10 -4
- package/src/components/button.tsx +2 -1
- package/src/components/chip.spec.tsx +24 -0
- package/src/components/chip.tsx +9 -2
- package/src/components/component-size.ts +5 -0
- package/src/components/index.ts +2 -0
- package/src/components/inputs/checkbox.spec.tsx +42 -0
- package/src/components/inputs/checkbox.tsx +55 -0
- package/src/components/inputs/input-number.spec.tsx +42 -0
- package/src/components/inputs/input-number.tsx +35 -0
- package/src/components/inputs/input.spec.tsx +42 -0
- package/src/components/inputs/input.tsx +34 -2
- package/src/components/inputs/radio.spec.tsx +42 -0
- package/src/components/inputs/radio.tsx +45 -0
- package/src/components/inputs/select.spec.tsx +26 -0
- package/src/components/inputs/select.tsx +32 -0
- package/src/components/inputs/switch.spec.tsx +42 -0
- package/src/components/inputs/switch.tsx +19 -2
- package/src/components/inputs/text-area.spec.tsx +42 -0
- package/src/components/inputs/text-area.tsx +25 -0
- package/src/components/pagination.tsx +2 -1
- package/src/components/rating.tsx +2 -1
- package/src/components/route-breadcrumb.spec.tsx +239 -0
- package/src/components/route-breadcrumb.tsx +83 -0
- package/src/components/timeline.spec.tsx +109 -0
- package/src/components/timeline.tsx +94 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { createComponent, flushUpdates, initializeShadeRoot, RouteMatchService } from '@furystack/shades'
|
|
3
|
+
import type { MatchChainEntry } from '@furystack/shades'
|
|
4
|
+
import { usingAsync } from '@furystack/utils'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { RouteBreadcrumb } from './route-breadcrumb.js'
|
|
7
|
+
|
|
8
|
+
const createMatchChainEntry = (
|
|
9
|
+
path: string,
|
|
10
|
+
title?: string | (({ match, injector }: { match: unknown; injector: unknown }) => Promise<string>),
|
|
11
|
+
): MatchChainEntry =>
|
|
12
|
+
({
|
|
13
|
+
route: { meta: title != null ? { title } : undefined, component: () => <div /> },
|
|
14
|
+
match: { path, params: {} },
|
|
15
|
+
}) as MatchChainEntry
|
|
16
|
+
|
|
17
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
18
|
+
|
|
19
|
+
const flushAsync = async () => {
|
|
20
|
+
await flushUpdates()
|
|
21
|
+
await tick()
|
|
22
|
+
await flushUpdates()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('RouteBreadcrumb', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
28
|
+
})
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
document.body.innerHTML = ''
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('Should render items derived from the match chain', async () => {
|
|
34
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
35
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
36
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
37
|
+
|
|
38
|
+
routeMatchService.currentMatchChain.setValue([
|
|
39
|
+
createMatchChainEntry('/', 'Home'),
|
|
40
|
+
createMatchChainEntry('/users', 'Users'),
|
|
41
|
+
createMatchChainEntry('/profile', 'Profile'),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
initializeShadeRoot({
|
|
45
|
+
injector,
|
|
46
|
+
rootElement,
|
|
47
|
+
jsxElement: <RouteBreadcrumb />,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
await flushAsync()
|
|
51
|
+
|
|
52
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
53
|
+
expect(nav).toBeTruthy()
|
|
54
|
+
expect(nav?.textContent).toContain('Users')
|
|
55
|
+
expect(nav?.textContent).toContain('Profile')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('Should skip the root "/" segment by default', async () => {
|
|
60
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
61
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
62
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
63
|
+
|
|
64
|
+
routeMatchService.currentMatchChain.setValue([
|
|
65
|
+
createMatchChainEntry('/', 'Home'),
|
|
66
|
+
createMatchChainEntry('/users', 'Users'),
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
initializeShadeRoot({
|
|
70
|
+
injector,
|
|
71
|
+
rootElement,
|
|
72
|
+
jsxElement: <RouteBreadcrumb />,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await flushAsync()
|
|
76
|
+
|
|
77
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
78
|
+
expect(nav?.textContent).not.toContain('Home')
|
|
79
|
+
expect(nav?.textContent).toContain('Users')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('Should include root segment when skipRootPath is false', async () => {
|
|
84
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
85
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
86
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
87
|
+
|
|
88
|
+
routeMatchService.currentMatchChain.setValue([
|
|
89
|
+
createMatchChainEntry('/', 'Home'),
|
|
90
|
+
createMatchChainEntry('/users', 'Users'),
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
initializeShadeRoot({
|
|
94
|
+
injector,
|
|
95
|
+
rootElement,
|
|
96
|
+
jsxElement: <RouteBreadcrumb skipRootPath={false} />,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await flushAsync()
|
|
100
|
+
|
|
101
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
102
|
+
expect(nav?.textContent).toContain('Home')
|
|
103
|
+
expect(nav?.textContent).toContain('Users')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('Should resolve async titles', async () => {
|
|
108
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
109
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
110
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
111
|
+
|
|
112
|
+
routeMatchService.currentMatchChain.setValue([createMatchChainEntry('/settings', async () => 'Settings')])
|
|
113
|
+
|
|
114
|
+
initializeShadeRoot({
|
|
115
|
+
injector,
|
|
116
|
+
rootElement,
|
|
117
|
+
jsxElement: <RouteBreadcrumb />,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await flushAsync()
|
|
121
|
+
|
|
122
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
123
|
+
expect(nav?.textContent).toContain('Settings')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('Should skip entries without a title', async () => {
|
|
128
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
129
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
130
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
131
|
+
|
|
132
|
+
routeMatchService.currentMatchChain.setValue([
|
|
133
|
+
createMatchChainEntry('/layout'),
|
|
134
|
+
createMatchChainEntry('/users', 'Users'),
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
initializeShadeRoot({
|
|
138
|
+
injector,
|
|
139
|
+
rootElement,
|
|
140
|
+
jsxElement: <RouteBreadcrumb />,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await flushAsync()
|
|
144
|
+
|
|
145
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
146
|
+
expect(nav?.textContent).toContain('Users')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('Should pass through homeItem prop', async () => {
|
|
151
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
152
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
153
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
154
|
+
|
|
155
|
+
routeMatchService.currentMatchChain.setValue([createMatchChainEntry('/users', 'Users')])
|
|
156
|
+
|
|
157
|
+
initializeShadeRoot({
|
|
158
|
+
injector,
|
|
159
|
+
rootElement,
|
|
160
|
+
jsxElement: <RouteBreadcrumb homeItem={{ path: '/', label: 'Home' }} />,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await flushAsync()
|
|
164
|
+
|
|
165
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
166
|
+
expect(nav?.textContent).toContain('Home')
|
|
167
|
+
expect(nav?.textContent).toContain('Users')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('Should pass through separator prop', async () => {
|
|
172
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
173
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
174
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
175
|
+
|
|
176
|
+
routeMatchService.currentMatchChain.setValue([
|
|
177
|
+
createMatchChainEntry('/users', 'Users'),
|
|
178
|
+
createMatchChainEntry('/profile', 'Profile'),
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
initializeShadeRoot({
|
|
182
|
+
injector,
|
|
183
|
+
rootElement,
|
|
184
|
+
jsxElement: <RouteBreadcrumb separator=" › " />,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await flushAsync()
|
|
188
|
+
|
|
189
|
+
const separator = rootElement.querySelector('[data-separator="true"]')
|
|
190
|
+
expect(separator?.textContent).toBe(' › ')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('Should handle empty match chain gracefully', async () => {
|
|
195
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
196
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
197
|
+
|
|
198
|
+
initializeShadeRoot({
|
|
199
|
+
injector,
|
|
200
|
+
rootElement,
|
|
201
|
+
jsxElement: <RouteBreadcrumb />,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await flushAsync()
|
|
205
|
+
|
|
206
|
+
const nav = rootElement.querySelector('nav[is="shade-breadcrumb"]')
|
|
207
|
+
expect(nav).toBeTruthy()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('Should accumulate paths from nested segments', async () => {
|
|
212
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
213
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
214
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
215
|
+
|
|
216
|
+
routeMatchService.currentMatchChain.setValue([
|
|
217
|
+
createMatchChainEntry('/', 'Home'),
|
|
218
|
+
createMatchChainEntry('/users', 'Users'),
|
|
219
|
+
createMatchChainEntry('/profile', 'Profile'),
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
initializeShadeRoot({
|
|
223
|
+
injector,
|
|
224
|
+
rootElement,
|
|
225
|
+
jsxElement: <RouteBreadcrumb lastItemClickable={true} />,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
await flushAsync()
|
|
229
|
+
|
|
230
|
+
const usersLink = rootElement.querySelector('a[href="/users"]')
|
|
231
|
+
expect(usersLink).toBeTruthy()
|
|
232
|
+
expect(usersLink?.textContent).toBe('Users')
|
|
233
|
+
|
|
234
|
+
const profileLink = rootElement.querySelector('a[href="/users/profile"]')
|
|
235
|
+
expect(profileLink).toBeTruthy()
|
|
236
|
+
expect(profileLink?.textContent).toBe('Profile')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MatchChainEntry, PartialElement } from '@furystack/shades'
|
|
2
|
+
import { createComponent, RouteMatchService, Shade, resolveRouteTitles } from '@furystack/shades'
|
|
3
|
+
|
|
4
|
+
import type { BreadcrumbItem } from './breadcrumb.js'
|
|
5
|
+
import { Breadcrumb } from './breadcrumb.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for the RouteBreadcrumb component.
|
|
9
|
+
*/
|
|
10
|
+
export type RouteBreadcrumbProps = {
|
|
11
|
+
homeItem?: BreadcrumbItem
|
|
12
|
+
separator?: string | JSX.Element
|
|
13
|
+
lastItemClickable?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* When true, the root `'/'` path segment is excluded from the generated items.
|
|
16
|
+
* Typically the root is represented by `homeItem` instead.
|
|
17
|
+
* @default true
|
|
18
|
+
*/
|
|
19
|
+
skipRootPath?: boolean
|
|
20
|
+
} & PartialElement<HTMLElement>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Breadcrumb component that automatically derives its items from the current
|
|
24
|
+
* {@link RouteMatchService} match chain. It resolves route titles from
|
|
25
|
+
* `meta.title` (supporting async resolvers) and accumulates path segments
|
|
26
|
+
* to produce the correct `href` for each breadcrumb link.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <RouteBreadcrumb
|
|
31
|
+
* homeItem={{ path: '/', label: <Icon icon={icons.home} size="small" /> }}
|
|
32
|
+
* separator=" › "
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export const RouteBreadcrumb = Shade<RouteBreadcrumbProps>({
|
|
37
|
+
customElementName: 'shade-route-breadcrumb',
|
|
38
|
+
render: ({ props, injector, useObservable, useState }) => {
|
|
39
|
+
const { skipRootPath = true, ...breadcrumbProps } = props
|
|
40
|
+
|
|
41
|
+
const routeMatchService = injector.getInstance(RouteMatchService)
|
|
42
|
+
|
|
43
|
+
const [resolvedItems, setResolvedItems] = useState<BreadcrumbItem[]>('resolvedItems', [])
|
|
44
|
+
const [initializedRef] = useState<{ current: boolean }>('initialized', { current: false })
|
|
45
|
+
const [generationRef] = useState<{ current: number }>('generation', { current: 0 })
|
|
46
|
+
|
|
47
|
+
const resolveAndSetTitles = async (chain: MatchChainEntry[]) => {
|
|
48
|
+
const generation = ++generationRef.current
|
|
49
|
+
const titles = await resolveRouteTitles(chain, injector)
|
|
50
|
+
|
|
51
|
+
if (generation !== generationRef.current) return
|
|
52
|
+
|
|
53
|
+
const items: BreadcrumbItem[] = []
|
|
54
|
+
let accumulatedPath = ''
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < chain.length; i++) {
|
|
57
|
+
const title = titles[i]
|
|
58
|
+
if (!title) continue
|
|
59
|
+
|
|
60
|
+
const matchPath = chain[i].match.path
|
|
61
|
+
if (skipRootPath && matchPath === '/') continue
|
|
62
|
+
|
|
63
|
+
accumulatedPath += matchPath
|
|
64
|
+
items.push({ path: accumulatedPath, label: title })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setResolvedItems(items)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [matchChain] = useObservable('matchChain', routeMatchService.currentMatchChain, {
|
|
71
|
+
onChange: (chain) => {
|
|
72
|
+
void resolveAndSetTitles(chain)
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (!initializedRef.current) {
|
|
77
|
+
initializedRef.current = true
|
|
78
|
+
void resolveAndSetTitles(matchChain)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return <Breadcrumb items={resolvedItems} {...breadcrumbProps} />
|
|
82
|
+
},
|
|
83
|
+
})
|
|
@@ -196,4 +196,113 @@ describe('Timeline', () => {
|
|
|
196
196
|
const items = timeline.querySelectorAll('shade-timeline-item')
|
|
197
197
|
expect(items.length).toBe(2)
|
|
198
198
|
})
|
|
199
|
+
|
|
200
|
+
describe('orientation', () => {
|
|
201
|
+
it('should not set data-orientation when orientation is not specified (defaults to vertical)', async () => {
|
|
202
|
+
const el = (
|
|
203
|
+
<div>
|
|
204
|
+
<Timeline>
|
|
205
|
+
<TimelineItem>Event</TimelineItem>
|
|
206
|
+
</Timeline>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
210
|
+
timeline.updateComponent()
|
|
211
|
+
await flushUpdates()
|
|
212
|
+
expect(timeline.hasAttribute('data-orientation')).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should not set data-orientation when orientation is "vertical"', async () => {
|
|
216
|
+
const el = (
|
|
217
|
+
<div>
|
|
218
|
+
<Timeline orientation="vertical">
|
|
219
|
+
<TimelineItem>Event</TimelineItem>
|
|
220
|
+
</Timeline>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
224
|
+
timeline.updateComponent()
|
|
225
|
+
await flushUpdates()
|
|
226
|
+
expect(timeline.hasAttribute('data-orientation')).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('should set data-orientation="horizontal" when orientation is "horizontal"', async () => {
|
|
230
|
+
const el = (
|
|
231
|
+
<div>
|
|
232
|
+
<Timeline orientation="horizontal">
|
|
233
|
+
<TimelineItem>Event</TimelineItem>
|
|
234
|
+
</Timeline>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
238
|
+
timeline.updateComponent()
|
|
239
|
+
await flushUpdates()
|
|
240
|
+
expect(timeline.getAttribute('data-orientation')).toBe('horizontal')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should render children in horizontal mode', async () => {
|
|
244
|
+
const el = (
|
|
245
|
+
<div>
|
|
246
|
+
<Timeline orientation="horizontal">
|
|
247
|
+
<TimelineItem>First</TimelineItem>
|
|
248
|
+
<TimelineItem>Second</TimelineItem>
|
|
249
|
+
<TimelineItem>Third</TimelineItem>
|
|
250
|
+
</Timeline>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
254
|
+
timeline.updateComponent()
|
|
255
|
+
await flushUpdates()
|
|
256
|
+
const items = timeline.querySelectorAll('shade-timeline-item')
|
|
257
|
+
expect(items.length).toBe(3)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('should support horizontal with mode="alternate"', async () => {
|
|
261
|
+
const el = (
|
|
262
|
+
<div>
|
|
263
|
+
<Timeline orientation="horizontal" mode="alternate">
|
|
264
|
+
<TimelineItem>First</TimelineItem>
|
|
265
|
+
<TimelineItem>Second</TimelineItem>
|
|
266
|
+
</Timeline>
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
270
|
+
timeline.updateComponent()
|
|
271
|
+
await flushUpdates()
|
|
272
|
+
expect(timeline.getAttribute('data-orientation')).toBe('horizontal')
|
|
273
|
+
expect(timeline.getAttribute('data-mode')).toBe('alternate')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('should support horizontal with mode="right"', async () => {
|
|
277
|
+
const el = (
|
|
278
|
+
<div>
|
|
279
|
+
<Timeline orientation="horizontal" mode="right">
|
|
280
|
+
<TimelineItem>First</TimelineItem>
|
|
281
|
+
</Timeline>
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
285
|
+
timeline.updateComponent()
|
|
286
|
+
await flushUpdates()
|
|
287
|
+
expect(timeline.getAttribute('data-orientation')).toBe('horizontal')
|
|
288
|
+
expect(timeline.getAttribute('data-mode')).toBe('right')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('should add pending item in horizontal mode', async () => {
|
|
292
|
+
const el = (
|
|
293
|
+
<div>
|
|
294
|
+
<Timeline orientation="horizontal" pending="Loading...">
|
|
295
|
+
<TimelineItem>Step 1</TimelineItem>
|
|
296
|
+
<TimelineItem>Step 2</TimelineItem>
|
|
297
|
+
</Timeline>
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
const timeline = el.firstElementChild as JSX.Element
|
|
301
|
+
timeline.updateComponent()
|
|
302
|
+
await flushUpdates()
|
|
303
|
+
const items = timeline.querySelectorAll('shade-timeline-item')
|
|
304
|
+
expect(items.length).toBe(3)
|
|
305
|
+
expect(timeline.getAttribute('data-orientation')).toBe('horizontal')
|
|
306
|
+
})
|
|
307
|
+
})
|
|
199
308
|
})
|
|
@@ -148,6 +148,8 @@ export const TimelineItem = Shade<TimelineItemProps>({
|
|
|
148
148
|
export type TimelineProps = PartialElement<HTMLElement> & {
|
|
149
149
|
/** Layout mode. 'left' places content on the right, 'right' on the left, 'alternate' switches sides. Defaults to 'left'. */
|
|
150
150
|
mode?: 'left' | 'right' | 'alternate'
|
|
151
|
+
/** Orientation of the timeline axis. Defaults to 'vertical'. */
|
|
152
|
+
orientation?: 'vertical' | 'horizontal'
|
|
151
153
|
/** Show a pending indicator on the last item. `true` shows a default "Loading..." text, a string/JSX shows custom content. */
|
|
152
154
|
pending?: boolean | string | JSX.Element
|
|
153
155
|
}
|
|
@@ -201,12 +203,103 @@ export const Timeline = Shade<TimelineProps>({
|
|
|
201
203
|
'&[data-mode="alternate"] > shade-timeline-item:nth-of-type(even) .timeline-dot-column': {
|
|
202
204
|
order: '2',
|
|
203
205
|
},
|
|
206
|
+
|
|
207
|
+
'&[data-orientation="horizontal"]': {
|
|
208
|
+
flexDirection: 'row',
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
'&[data-orientation="horizontal"] > shade-timeline-item': {
|
|
212
|
+
flexDirection: 'column',
|
|
213
|
+
minHeight: 'auto',
|
|
214
|
+
flex: '1',
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
'&[data-orientation="horizontal"] > shade-timeline-item .timeline-label': {
|
|
218
|
+
flex: 'none',
|
|
219
|
+
textAlign: 'center',
|
|
220
|
+
paddingRight: '0',
|
|
221
|
+
paddingBottom: cssVariableTheme.spacing.xs,
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
'&[data-orientation="horizontal"] > shade-timeline-item .timeline-dot-column': {
|
|
225
|
+
flexDirection: 'row',
|
|
226
|
+
width: '100%',
|
|
227
|
+
paddingTop: '0',
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
'&[data-orientation="horizontal"] > shade-timeline-item .timeline-tail': {
|
|
231
|
+
height: '2px',
|
|
232
|
+
width: 'auto',
|
|
233
|
+
flex: '1',
|
|
234
|
+
marginTop: '0',
|
|
235
|
+
marginLeft: cssVariableTheme.spacing.xs,
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
'&[data-orientation="horizontal"] > shade-timeline-item .timeline-tail[data-pending]': {
|
|
239
|
+
borderLeft: 'none',
|
|
240
|
+
borderTop: `2px dashed ${cssVariableTheme.divider}`,
|
|
241
|
+
height: '0',
|
|
242
|
+
width: 'auto',
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
'&[data-orientation="horizontal"] > shade-timeline-item .timeline-content': {
|
|
246
|
+
flex: 'none',
|
|
247
|
+
paddingLeft: '0',
|
|
248
|
+
paddingTop: cssVariableTheme.spacing.sm,
|
|
249
|
+
paddingBottom: '0',
|
|
250
|
+
textAlign: 'center',
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
'&[data-orientation="horizontal"][data-mode="right"] > shade-timeline-item .timeline-label': {
|
|
254
|
+
order: '3',
|
|
255
|
+
paddingBottom: '0',
|
|
256
|
+
paddingLeft: '0',
|
|
257
|
+
paddingTop: cssVariableTheme.spacing.sm,
|
|
258
|
+
textAlign: 'center',
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
'&[data-orientation="horizontal"][data-mode="right"] > shade-timeline-item .timeline-content': {
|
|
262
|
+
order: '1',
|
|
263
|
+
paddingTop: '0',
|
|
264
|
+
paddingRight: '0',
|
|
265
|
+
paddingBottom: cssVariableTheme.spacing.xs,
|
|
266
|
+
textAlign: 'center',
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
'&[data-orientation="horizontal"][data-mode="right"] > shade-timeline-item .timeline-dot-column': {
|
|
270
|
+
order: '2',
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
'&[data-orientation="horizontal"][data-mode="alternate"] > shade-timeline-item:nth-of-type(even) .timeline-label': {
|
|
274
|
+
order: '3',
|
|
275
|
+
paddingRight: '0',
|
|
276
|
+
paddingLeft: '0',
|
|
277
|
+
paddingBottom: '0',
|
|
278
|
+
paddingTop: cssVariableTheme.spacing.sm,
|
|
279
|
+
textAlign: 'center',
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
'&[data-orientation="horizontal"][data-mode="alternate"] > shade-timeline-item:nth-of-type(even) .timeline-content':
|
|
283
|
+
{
|
|
284
|
+
order: '1',
|
|
285
|
+
paddingLeft: '0',
|
|
286
|
+
paddingRight: '0',
|
|
287
|
+
paddingTop: '0',
|
|
288
|
+
paddingBottom: cssVariableTheme.spacing.xs,
|
|
289
|
+
textAlign: 'center',
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
'&[data-orientation="horizontal"][data-mode="alternate"] > shade-timeline-item:nth-of-type(even) .timeline-dot-column':
|
|
293
|
+
{
|
|
294
|
+
order: '2',
|
|
295
|
+
},
|
|
204
296
|
},
|
|
205
297
|
render: ({ props, children, useHostProps }) => {
|
|
206
|
-
const { mode = 'left', pending, style } = props
|
|
298
|
+
const { mode = 'left', orientation = 'vertical', pending, style } = props
|
|
207
299
|
|
|
208
300
|
useHostProps({
|
|
209
301
|
'data-mode': mode,
|
|
302
|
+
...(orientation === 'horizontal' ? { 'data-orientation': 'horizontal' as const } : {}),
|
|
210
303
|
...(style ? { style: style as Record<string, string> } : {}),
|
|
211
304
|
})
|
|
212
305
|
|