@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/esm/components/button-group.d.ts +6 -5
  3. package/esm/components/button-group.d.ts.map +1 -1
  4. package/esm/components/button-group.js +5 -1
  5. package/esm/components/button-group.js.map +1 -1
  6. package/esm/components/button-group.spec.js +5 -0
  7. package/esm/components/button-group.spec.js.map +1 -1
  8. package/esm/components/button.d.ts +3 -2
  9. package/esm/components/button.d.ts.map +1 -1
  10. package/esm/components/button.js.map +1 -1
  11. package/esm/components/chip.d.ts +3 -2
  12. package/esm/components/chip.d.ts.map +1 -1
  13. package/esm/components/chip.js +6 -1
  14. package/esm/components/chip.js.map +1 -1
  15. package/esm/components/chip.spec.js +16 -0
  16. package/esm/components/chip.spec.js.map +1 -1
  17. package/esm/components/component-size.d.ts +6 -0
  18. package/esm/components/component-size.d.ts.map +1 -0
  19. package/esm/components/component-size.js +2 -0
  20. package/esm/components/component-size.js.map +1 -0
  21. package/esm/components/index.d.ts +2 -0
  22. package/esm/components/index.d.ts.map +1 -1
  23. package/esm/components/index.js +2 -0
  24. package/esm/components/index.js.map +1 -1
  25. package/esm/components/inputs/checkbox.d.ts +6 -0
  26. package/esm/components/inputs/checkbox.d.ts.map +1 -1
  27. package/esm/components/inputs/checkbox.js +47 -0
  28. package/esm/components/inputs/checkbox.js.map +1 -1
  29. package/esm/components/inputs/checkbox.spec.js +38 -0
  30. package/esm/components/inputs/checkbox.spec.js.map +1 -1
  31. package/esm/components/inputs/input-number.d.ts +6 -0
  32. package/esm/components/inputs/input-number.d.ts.map +1 -1
  33. package/esm/components/inputs/input-number.js +26 -0
  34. package/esm/components/inputs/input-number.js.map +1 -1
  35. package/esm/components/inputs/input-number.spec.js +38 -0
  36. package/esm/components/inputs/input-number.spec.js.map +1 -1
  37. package/esm/components/inputs/input.d.ts +7 -1
  38. package/esm/components/inputs/input.d.ts.map +1 -1
  39. package/esm/components/inputs/input.js +24 -1
  40. package/esm/components/inputs/input.js.map +1 -1
  41. package/esm/components/inputs/input.spec.js +38 -0
  42. package/esm/components/inputs/input.spec.js.map +1 -1
  43. package/esm/components/inputs/radio.d.ts +6 -0
  44. package/esm/components/inputs/radio.d.ts.map +1 -1
  45. package/esm/components/inputs/radio.js +37 -0
  46. package/esm/components/inputs/radio.js.map +1 -1
  47. package/esm/components/inputs/radio.spec.js +38 -0
  48. package/esm/components/inputs/radio.spec.js.map +1 -1
  49. package/esm/components/inputs/select.d.ts +6 -0
  50. package/esm/components/inputs/select.d.ts.map +1 -1
  51. package/esm/components/inputs/select.js +24 -0
  52. package/esm/components/inputs/select.js.map +1 -1
  53. package/esm/components/inputs/select.spec.js +22 -0
  54. package/esm/components/inputs/select.spec.js.map +1 -1
  55. package/esm/components/inputs/switch.d.ts +2 -1
  56. package/esm/components/inputs/switch.d.ts.map +1 -1
  57. package/esm/components/inputs/switch.js +14 -1
  58. package/esm/components/inputs/switch.js.map +1 -1
  59. package/esm/components/inputs/switch.spec.js +38 -0
  60. package/esm/components/inputs/switch.spec.js.map +1 -1
  61. package/esm/components/inputs/text-area.d.ts +6 -0
  62. package/esm/components/inputs/text-area.d.ts.map +1 -1
  63. package/esm/components/inputs/text-area.js +17 -0
  64. package/esm/components/inputs/text-area.js.map +1 -1
  65. package/esm/components/inputs/text-area.spec.js +38 -0
  66. package/esm/components/inputs/text-area.spec.js.map +1 -1
  67. package/esm/components/pagination.d.ts +3 -2
  68. package/esm/components/pagination.d.ts.map +1 -1
  69. package/esm/components/pagination.js.map +1 -1
  70. package/esm/components/rating.d.ts +2 -1
  71. package/esm/components/rating.d.ts.map +1 -1
  72. package/esm/components/rating.js.map +1 -1
  73. package/esm/components/route-breadcrumb.d.ts +46 -0
  74. package/esm/components/route-breadcrumb.d.ts.map +1 -0
  75. package/esm/components/route-breadcrumb.js +56 -0
  76. package/esm/components/route-breadcrumb.js.map +1 -0
  77. package/esm/components/route-breadcrumb.spec.d.ts +2 -0
  78. package/esm/components/route-breadcrumb.spec.d.ts.map +1 -0
  79. package/esm/components/route-breadcrumb.spec.js +186 -0
  80. package/esm/components/route-breadcrumb.spec.js.map +1 -0
  81. package/esm/components/timeline.d.ts +4 -0
  82. package/esm/components/timeline.d.ts.map +1 -1
  83. package/esm/components/timeline.js +77 -1
  84. package/esm/components/timeline.js.map +1 -1
  85. package/esm/components/timeline.spec.js +74 -0
  86. package/esm/components/timeline.spec.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/components/button-group.spec.tsx +6 -0
  89. package/src/components/button-group.tsx +10 -4
  90. package/src/components/button.tsx +2 -1
  91. package/src/components/chip.spec.tsx +24 -0
  92. package/src/components/chip.tsx +9 -2
  93. package/src/components/component-size.ts +5 -0
  94. package/src/components/index.ts +2 -0
  95. package/src/components/inputs/checkbox.spec.tsx +42 -0
  96. package/src/components/inputs/checkbox.tsx +55 -0
  97. package/src/components/inputs/input-number.spec.tsx +42 -0
  98. package/src/components/inputs/input-number.tsx +35 -0
  99. package/src/components/inputs/input.spec.tsx +42 -0
  100. package/src/components/inputs/input.tsx +34 -2
  101. package/src/components/inputs/radio.spec.tsx +42 -0
  102. package/src/components/inputs/radio.tsx +45 -0
  103. package/src/components/inputs/select.spec.tsx +26 -0
  104. package/src/components/inputs/select.tsx +32 -0
  105. package/src/components/inputs/switch.spec.tsx +42 -0
  106. package/src/components/inputs/switch.tsx +19 -2
  107. package/src/components/inputs/text-area.spec.tsx +42 -0
  108. package/src/components/inputs/text-area.tsx +25 -0
  109. package/src/components/pagination.tsx +2 -1
  110. package/src/components/rating.tsx +2 -1
  111. package/src/components/route-breadcrumb.spec.tsx +239 -0
  112. package/src/components/route-breadcrumb.tsx +83 -0
  113. package/src/components/timeline.spec.tsx +109 -0
  114. 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