@api-client/ui 0.3.1 → 0.3.3

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 (59) hide show
  1. package/build/src/elements/environment/EnvironmentEditor.d.ts.map +1 -1
  2. package/build/src/elements/environment/EnvironmentEditor.js +8 -6
  3. package/build/src/elements/environment/EnvironmentEditor.js.map +1 -1
  4. package/build/src/elements/har/HarViewer.d.ts.map +1 -1
  5. package/build/src/elements/har/HarViewer.js +13 -15
  6. package/build/src/elements/har/HarViewer.js.map +1 -1
  7. package/build/src/elements/http/RequestEditor.d.ts +2 -1
  8. package/build/src/elements/http/RequestEditor.d.ts.map +1 -1
  9. package/build/src/elements/http/RequestEditor.js +17 -12
  10. package/build/src/elements/http/RequestEditor.js.map +1 -1
  11. package/build/src/elements/http/RequestLog.d.ts.map +1 -1
  12. package/build/src/elements/http/RequestLog.js +34 -8
  13. package/build/src/elements/http/RequestLog.js.map +1 -1
  14. package/build/src/md/button/internals/button.styles.js +4 -4
  15. package/build/src/md/button/internals/button.styles.js.map +1 -1
  16. package/build/src/md/motion/animation.d.ts +5 -3
  17. package/build/src/md/motion/animation.d.ts.map +1 -1
  18. package/build/src/md/motion/animation.js +4 -2
  19. package/build/src/md/motion/animation.js.map +1 -1
  20. package/build/src/md/ripple/internals/ripple.styles.d.ts.map +1 -1
  21. package/build/src/md/ripple/internals/ripple.styles.js +20 -8
  22. package/build/src/md/ripple/internals/ripple.styles.js.map +1 -1
  23. package/build/src/md/tabs/internals/Tab.d.ts +25 -9
  24. package/build/src/md/tabs/internals/Tab.d.ts.map +1 -1
  25. package/build/src/md/tabs/internals/Tab.js +122 -53
  26. package/build/src/md/tabs/internals/Tab.js.map +1 -1
  27. package/build/src/md/tabs/internals/Tab.styles.d.ts.map +1 -1
  28. package/build/src/md/tabs/internals/Tab.styles.js +69 -64
  29. package/build/src/md/tabs/internals/Tab.styles.js.map +1 -1
  30. package/build/src/md/tabs/internals/Tabs.d.ts +52 -54
  31. package/build/src/md/tabs/internals/Tabs.d.ts.map +1 -1
  32. package/build/src/md/tabs/internals/Tabs.js +270 -330
  33. package/build/src/md/tabs/internals/Tabs.js.map +1 -1
  34. package/build/src/md/tabs/internals/Tabs.styles.d.ts.map +1 -1
  35. package/build/src/md/tabs/internals/Tabs.styles.js +13 -17
  36. package/build/src/md/tabs/internals/Tabs.styles.js.map +1 -1
  37. package/build/src/md/text-field/internals/common.styles.d.ts.map +1 -1
  38. package/build/src/md/text-field/internals/common.styles.js +0 -3
  39. package/build/src/md/text-field/internals/common.styles.js.map +1 -1
  40. package/build/src/styles/m3/native.css +270 -0
  41. package/build/src/styles/m3/theme.css +155 -0
  42. package/build/src/styles/m3/tokens.css +512 -0
  43. package/demo/md/tabs/tabs.html +19 -0
  44. package/demo/md/tabs/tabs.ts +133 -83
  45. package/package.json +20 -4
  46. package/scripts/copy-assets.js +21 -0
  47. package/src/elements/environment/EnvironmentEditor.ts +8 -6
  48. package/src/elements/har/HarViewer.ts +13 -15
  49. package/src/elements/http/RequestEditor.ts +18 -13
  50. package/src/elements/http/RequestLog.ts +34 -8
  51. package/src/md/button/internals/button.styles.ts +4 -4
  52. package/src/md/motion/animation.ts +4 -2
  53. package/src/md/ripple/internals/ripple.styles.ts +20 -8
  54. package/src/md/tabs/internals/Tab.styles.ts +69 -64
  55. package/src/md/tabs/internals/Tab.ts +126 -43
  56. package/src/md/tabs/internals/Tabs.styles.ts +13 -17
  57. package/src/md/tabs/internals/Tabs.ts +259 -305
  58. package/src/md/text-field/internals/common.styles.ts +0 -3
  59. package/test/elements/har/HarViewerElement.test.ts +1 -55
@@ -1,4 +1,4 @@
1
- import { html, LitElement, nothing, PropertyValues, TemplateResult } from 'lit'
1
+ import { html, LitElement, PropertyValues, TemplateResult } from 'lit'
2
2
  import { property, query, queryAssignedElements, state } from 'lit/decorators.js'
3
3
  import { ClassInfo, classMap } from 'lit/directives/class-map.js'
4
4
  import { StyleInfo, styleMap } from 'lit/directives/style-map.js'
@@ -11,9 +11,19 @@ import '../../icons/ui-icon.js'
11
11
  export type TabsPriority = 'primary' | 'secondary'
12
12
 
13
13
  export interface TabSelectionDetail {
14
+ /**
15
+ * The selected tab.
16
+ */
14
17
  item: UiTab
18
+ /**
19
+ * The index of the selected tab.
20
+ */
15
21
  index: number
16
- select: string | null
22
+ }
23
+
24
+ export interface SizingInfo {
25
+ left: number
26
+ width: number
17
27
  }
18
28
 
19
29
  /**
@@ -29,61 +39,91 @@ export function calcPercent(w: number, w0: number): number {
29
39
  /**
30
40
  * A container for tabs.
31
41
  *
32
- * @fires select - A non bubbling event when selection change through user interaction.
33
- * The `event.detail` object contains the `item` and `index` properties.
34
- * It also has the `select` property with the value of the `selectedAttribute` on the tab.
42
+ * @fires change - A non bubbling event when selection change through user interaction.
35
43
  */
36
44
  export default class UiTabs extends LitElement {
37
- items: UiTab[] = []
38
-
39
45
  activeItem: UiTab | null = null
40
46
 
41
47
  previousItem: UiTab | null = null
42
48
 
43
- @queryAssignedElements({ flatten: true })
44
- protected accessor assignedElements!: HTMLElement[]
45
-
46
- @query('.pointer') protected accessor pointer!: HTMLElement
47
-
48
- @query('.content') protected accessor content!: HTMLElement
49
-
50
49
  /**
51
- * The priority of the tabs.
52
- *
53
- * @default primary
54
- * @attribute
50
+ * The currently selected tab, `null` only when there are no tab children.
55
51
  */
56
- @property({ type: String, reflect: true }) accessor priority: TabsPriority = 'primary'
52
+ get activeTab(): UiTab | null {
53
+ return this.tabs.find((tab) => tab.selected) ?? null
54
+ }
55
+
56
+ set activeTab(tab: UiTab | null) {
57
+ // Ignore setting activeTab to null. As long as there are children, one tab
58
+ // must be selected.
59
+ if (tab) {
60
+ this.activateTab(tab)
61
+ }
62
+ }
57
63
 
58
64
  /**
59
- * If true, tabs are scrollable and the tab width is based on the label width.
60
- * @attribute
65
+ * The index of the currently selected tab.
61
66
  */
62
- @property({ type: Boolean }) accessor scrollable: boolean | undefined
67
+ get activeTabIndex() {
68
+ return this.tabs.findIndex((tab) => tab.selected)
69
+ }
63
70
 
64
71
  /**
65
- * The value of the selected tab.
66
- * This is matched with the `aria-controls` of the tab.
67
- * @attribute
72
+ * Sets the active tab by index.
68
73
  */
69
- @property({ type: String }) accessor selected: string | undefined
74
+ set activeTabIndex(index) {
75
+ const activateTabAtIndex = () => {
76
+ const tab = this.tabs[index]
77
+ // Ignore out-of-bound indices.
78
+ if (tab) {
79
+ this.activateTab(tab)
80
+ }
81
+ }
82
+ if (!this.slotElement) {
83
+ // This is needed to support setting the activeTabIndex via a lit property
84
+ // binding.
85
+ //
86
+ // ```ts
87
+ // html`
88
+ // <md-tabs .activeTabIndex=${1}>
89
+ // <md-tab>First</md-tab>
90
+ // <md-tab>Second</md-tab>
91
+ // </md-tabs>
92
+ // `;
93
+ // ```
94
+ //
95
+ // It's needed since lit's rendering lifecycle is asynchronous, and the
96
+ // `<slot>` element hasn't rendered, so `tabs` is empty.
97
+ this.updateComplete.then(activateTabAtIndex)
98
+ return
99
+ }
100
+ activateTabAtIndex()
101
+ }
102
+
103
+ get focusedTab() {
104
+ return this.tabs.find((tab) => tab.matches(':focus-within'))
105
+ }
106
+
107
+ @queryAssignedElements({ flatten: true, selector: 'ui-tab' }) private accessor tabs!: UiTab[]
108
+
109
+ @query('.tabs') private accessor tabsScrollerElement!: HTMLElement
110
+ @query('slot') private accessor slotElement!: HTMLSlotElement
111
+
112
+ @query('.pointer') private accessor pointer!: HTMLElement
70
113
 
71
114
  /**
72
- * The attribute on the `ui-tab` that indicates which value for `selected`
73
- * corresponds to which tab.
115
+ * The priority of the tabs.
74
116
  *
75
- * @default aria-controls
117
+ * @default primary
76
118
  * @attribute
77
119
  */
78
- @property({ type: String }) accessor selectedAttribute = `aria-controls`
79
-
80
- @state() protected accessor pointerStyles: StyleInfo | undefined
120
+ @property({ type: String, reflect: true }) accessor priority: TabsPriority = 'primary'
81
121
 
82
- protected contentScroll?: number
122
+ @state() private accessor pointerStyles: StyleInfo | undefined
83
123
 
84
- @state() protected accessor indicated = false
124
+ @state() private accessor indicated = false
85
125
 
86
- protected observer: IntersectionObserver
126
+ private observer: IntersectionObserver
87
127
 
88
128
  /**
89
129
  * This is set by the intersection observer. Once the tabs are in the view it turns to `true`.
@@ -91,13 +131,23 @@ export default class UiTabs extends LitElement {
91
131
  */
92
132
  @state() accessor isVisible: boolean | undefined
93
133
 
134
+ /**
135
+ * Whether or not to automatically select a tab when it is focused.
136
+ */
137
+ @property({ type: Boolean }) accessor autoActivate = false
138
+
139
+ private readonly internals = (this as HTMLElement).attachInternals()
140
+
94
141
  constructor() {
95
142
  super()
96
-
143
+ this.internals.role = 'tablist'
97
144
  this.observer = new IntersectionObserver(this.intersectionCallback.bind(this), {
98
145
  threshold: 1.0,
99
146
  rootMargin: '0px',
100
147
  })
148
+ this.addEventListener('keydown', this.handleKeydown.bind(this))
149
+ this.addEventListener('keyup', this.handleKeyup.bind(this))
150
+ this.addEventListener('focusout', this.handleFocusout.bind(this))
101
151
  }
102
152
 
103
153
  protected override willUpdate(cp: PropertyValues<this>): void {
@@ -109,10 +159,10 @@ export default class UiTabs extends LitElement {
109
159
 
110
160
  override connectedCallback(): void {
111
161
  super.connectedCallback()
162
+ this.observer.observe(this)
112
163
  if (!this.hasAttribute('role')) {
113
164
  this.setAttribute('role', 'tablist')
114
165
  }
115
- this.observer.observe(this)
116
166
  }
117
167
 
118
168
  override disconnectedCallback(): void {
@@ -125,106 +175,29 @@ export default class UiTabs extends LitElement {
125
175
  this.isVisible = entry.isIntersecting
126
176
  }
127
177
 
128
- protected async updateItems(): Promise<void> {
129
- const elements = this.assignedElements || []
130
- const items = elements.filter(this.isTabItem, this)
131
- this.items = items
132
- if (this.activeItem && !items.includes(this.activeItem)) {
133
- this.activeItem = null
134
- }
135
- await this.updateComplete
136
- this.ensureSelection()
137
- }
138
-
139
178
  /**
140
- * @return Whether the given element is a list item element.
179
+ * Scrolls the toolbar, if overflowing, to the active tab, or the provided
180
+ * tab.
181
+ *
182
+ * @param tabToScrollTo The tab that should be scrolled to. Defaults to the
183
+ * active tab.
184
+ * @return A Promise that resolves after the tab has been scrolled to.
141
185
  */
142
- protected isTabItem(element: Element): element is UiTab {
143
- if (element.nodeType !== Node.ELEMENT_NODE) {
144
- return false
145
- }
146
- return element.localName === 'ui-tab'
147
- }
148
-
149
- protected isSelectable(element: UiTab): boolean {
150
- if (element.disabled) {
151
- return false
152
- }
153
- if (element.hidden && element.hasAttribute('hidden')) {
154
- return false
155
- }
156
- return true
157
- }
158
-
159
- protected ensureSelection(): void {
160
- const { selected, selectedAttribute, activeItem, items } = this
161
- if (!selectedAttribute) {
162
- return
163
- }
164
- const item = items.find((i) => i.getAttribute(selectedAttribute) === selected)
165
- if (item && item === activeItem) {
166
- return
167
- }
168
- this.makeSelection(item)
169
- }
170
-
171
- protected makeSelection(tab?: UiTab, focus = false): void {
172
- const { activeItem } = this
173
- if (activeItem === tab) {
174
- tab.highlight()
175
- return
176
- }
177
- this.previousItem = activeItem
178
- if (activeItem) {
179
- this.deselectItem(activeItem)
180
- }
181
- if (tab) {
182
- this.selectItem(tab, focus)
183
- this.positionPointer(tab, activeItem)
184
- if (this.activeItem) {
185
- // we set this here so we won't notify selection when initializing.
186
- this.notifySelect(tab)
187
- }
188
- }
189
- }
190
-
191
- notifySelect(item: UiTab): void {
192
- const index = this.items.indexOf(item)
193
- if (index === -1) {
186
+ async scrollToTab(tabToScrollTo?: UiTab | null) {
187
+ await this.updateComplete
188
+ const { tabs } = this
189
+ tabToScrollTo ??= this.activeTab
190
+ if (!tabToScrollTo || !tabs.includes(tabToScrollTo) || !this.tabsScrollerElement) {
194
191
  return
195
192
  }
196
- const attrValue = item.getAttribute(this.selectedAttribute)
197
- this.dispatchEvent(
198
- new CustomEvent<TabSelectionDetail>('select', {
199
- detail: {
200
- item,
201
- index,
202
- select: attrValue,
203
- },
204
- })
205
- )
206
- }
207
-
208
- protected selectItem(tab: UiTab, focus = false): void {
209
- this.activeItem = tab
210
- if (this.hasAttribute('tabindex')) {
211
- this.removeAttribute('tabindex')
212
- }
213
- tab.setAttribute('aria-selected', 'true')
214
- tab.setAttribute('tabindex', '0')
215
- tab.selected = true
216
- tab.priority = this.priority
217
- tab.scrollIntoView({ inline: 'nearest', behavior: 'auto', block: 'nearest' })
218
- if (focus) {
219
- tab.focus()
220
- }
221
- }
222
193
 
223
- protected deselectItem(tab: UiTab): void {
224
- tab.setAttribute('aria-selected', 'false')
225
- tab.setAttribute('tabindex', '-1')
226
- tab.selected = false
227
- tab.indicated = false
194
+ // wait for tabs to render.
195
+ await Promise.all(tabs.map((tab) => tab.updateComplete))
196
+ tabToScrollTo.scrollIntoView({
197
+ block: 'nearest',
198
+ inline: 'nearest',
199
+ behavior: !this.focusedTab ? 'instant' : 'auto',
200
+ })
228
201
  }
229
202
 
230
203
  protected handleVisibility(): void {
@@ -236,11 +209,12 @@ export default class UiTabs extends LitElement {
236
209
  }
237
210
 
238
211
  protected async positionPointer(tab: UiTab, old?: UiTab | null): Promise<void> {
239
- const { items, pointer, isVisible } = this
212
+ const { pointer, isVisible } = this
240
213
  if (!isVisible) {
214
+ tab.indicated = true
241
215
  return
242
216
  }
243
- const index = items.indexOf(tab)
217
+ const index = this.tabs.indexOf(tab)
244
218
  if (index < 0 || !pointer) {
245
219
  this.pointerStyles = undefined
246
220
  return
@@ -252,29 +226,31 @@ export default class UiTabs extends LitElement {
252
226
  return
253
227
  }
254
228
  const isPrimary = this.priority === 'primary'
255
- const final = isPrimary ? this.getPrimaryLeft(tab) : this.getSecondaryLeft(tab)
256
- if (this.pointerStyles && this.pointerStyles.left === `${final}px`) {
229
+ const final = this.getTabSizing(tab)
230
+ if (this.pointerStyles && this.pointerStyles.left === `${final.left}px`) {
231
+ tab.indicated = true
257
232
  return
258
233
  }
259
234
 
260
235
  // first position this indicator in the place of the old one.
261
236
  // update the view and then run the animation.
262
237
  this.indicated = true
263
- const left = isPrimary ? this.getPrimaryLeft(old) : this.getSecondaryLeft(old)
264
- this.pointerStyles = { left }
238
+ const starting = this.getTabSizing(old)
239
+ this.pointerStyles = { left: `${starting.left}px`, width: `${starting.width}px` }
265
240
  await this.updateComplete
266
- const frames = isPrimary ? this.getPrimaryKeyframes(left, final) : this.getSecondaryKeyframes(left, final)
241
+ const frames = isPrimary ? this.getPrimaryKeyframes(starting, final) : this.getSecondaryKeyframes(starting, final)
267
242
 
268
243
  if (this.moveAnimation) {
269
244
  this.moveAnimation.cancel()
270
245
  }
271
246
  const moveAnimation = pointer.animate(frames, {
272
- duration: 360,
247
+ duration: 200,
273
248
  iterations: 1,
274
- easing: Easing.DECELERATION,
249
+ easing: Easing.EMPHASIZED_DECELERATE,
275
250
  })
276
251
  const finalStyles: StyleInfo = {
277
- left: `${final}px`,
252
+ left: `${final.left}px`,
253
+ width: `${final.width}px`,
278
254
  }
279
255
  moveAnimation.addEventListener('finish', () => {
280
256
  this.pointerStyles = finalStyles
@@ -290,198 +266,194 @@ export default class UiTabs extends LitElement {
290
266
 
291
267
  protected moveAnimation?: Animation
292
268
 
293
- protected getPrimaryLeft(tab: UiTab): string {
294
- const contentBox = this.content.getBoundingClientRect()
295
- const tabRect = tab.getBoundingClientRect()
296
- const leftFromParent = tabRect.x - contentBox.x
297
- const offset = this.scrollable ? 48 : 0 // left button
298
- const left = leftFromParent + tabRect.width / 2 - 20 + offset
299
- return `${left}px`
300
- }
301
-
302
- protected getSecondaryLeft(tab: UiTab): string {
303
- const contentBox = this.content.getBoundingClientRect()
304
- const tabRect = tab.getBoundingClientRect()
305
- const offset = this.scrollable ? 48 : 0 // left button
306
- const leftFromParent = tabRect.x - contentBox.x + offset
307
- return `${leftFromParent}px`
269
+ protected getTabSizing(tab: UiTab): SizingInfo {
270
+ const contentBox = this.tabsScrollerElement.getBoundingClientRect()
271
+ const sizing = tab.getIndicatorSizing()
272
+ sizing.left = sizing.left - contentBox.x
273
+ return sizing
308
274
  }
309
275
 
310
- protected getPrimaryKeyframes(start: string, final: string): Keyframe[] {
276
+ protected getPrimaryKeyframes(start: SizingInfo, final: SizingInfo): Keyframe[] {
311
277
  return [
312
278
  {
313
- left: start,
314
- width: `40px`,
279
+ left: `${start.left}px`,
280
+ width: `${start.width}px`,
315
281
  },
316
282
  {
317
- width: `80px`,
318
- },
319
- {
320
- left: final,
321
- width: `40px`,
283
+ left: `${final.left}px`,
284
+ width: `${final.width}px`,
322
285
  },
323
286
  ]
324
287
  }
325
288
 
326
- protected getSecondaryKeyframes(start: string, final: string): Keyframe[] {
289
+ protected getSecondaryKeyframes(start: SizingInfo, final: SizingInfo): Keyframe[] {
327
290
  return [
328
291
  {
329
- left: start,
292
+ left: `${start.left}px`,
293
+ width: `${start.width}px`,
330
294
  },
331
295
  {
332
- left: final,
296
+ left: `${final.left}px`,
297
+ width: `${final.width}px`,
333
298
  },
334
299
  ]
335
300
  }
336
301
 
337
- protected contentClickHandler(e: PointerEvent): void {
338
- this.activateFromEvent(e)
339
- }
340
-
341
- protected contentKeyDownHandler(e: KeyboardEvent): void {
342
- if (['Enter', 'Space'].includes(e.code)) {
343
- this.activateFromEvent(e)
302
+ protected async handleTabClick(e: PointerEvent): Promise<void> {
303
+ const tab = e.composedPath().find((el) => isTab(el)) as UiTab | undefined
304
+ // Allow event to bubble
305
+ await new Promise((resolve) => setTimeout(resolve, 0))
306
+ if (e.defaultPrevented || !tab || tab.selected) {
307
+ return
344
308
  }
309
+ this.activateTab(tab)
345
310
  }
346
311
 
347
- protected activateFromEvent(e: Event): void {
348
- const { items } = this
349
- const path = e.composedPath()
350
- let item: UiTab | undefined
351
- while (!item) {
352
- const next = path.shift() as UiTab
353
- if (!next) {
354
- break
312
+ private activateTab(activeTab: UiTab): void {
313
+ const { tabs } = this
314
+ const previousTab = this.activeTab
315
+ if (!tabs.includes(activeTab) || previousTab === activeTab) {
316
+ // Ignore setting activeTab to a tab element that is not a child.
317
+ return
318
+ }
319
+ for (const tab of tabs) {
320
+ tab.selected = tab === activeTab
321
+ }
322
+ if (previousTab) {
323
+ // Don't dispatch a change event if activating a tab when no previous tabs
324
+ // were selected, such as when md-tabs auto-selects the first tab.
325
+ const detail: TabSelectionDetail = {
326
+ item: activeTab,
327
+ index: this.tabs.indexOf(activeTab),
355
328
  }
356
- if (items.includes(next)) {
357
- item = next
329
+ const defaultPrevented = !this.dispatchEvent(
330
+ new CustomEvent('change', { detail, bubbles: false, cancelable: true })
331
+ )
332
+ if (defaultPrevented) {
333
+ for (const tab of tabs) {
334
+ tab.selected = tab === previousTab
335
+ }
336
+ return
358
337
  }
338
+ previousTab.indicated = false
359
339
  }
360
- if (!item) {
361
- return
340
+ activeTab.indicated = false
341
+ this.positionPointer(activeTab, previousTab)
342
+ this.updateFocusableTab(activeTab)
343
+ this.scrollToTab(activeTab)
344
+ }
345
+
346
+ private updateFocusableTab(focusableTab: UiTab) {
347
+ for (const tab of this.tabs) {
348
+ tab.tabIndex = tab === focusableTab ? 0 : -1
362
349
  }
363
- this.makeSelection(item, true)
364
350
  }
365
351
 
366
- protected handleScrollLeft(): void {
367
- const { contentScroll = 0, content } = this
368
- if (contentScroll === 0) {
352
+ private async handleKeydown(event: KeyboardEvent) {
353
+ // Allow event to bubble.
354
+ await new Promise((resolve) => setTimeout(resolve, 0))
355
+ const isLeft = event.key === 'ArrowLeft'
356
+ const isRight = event.key === 'ArrowRight'
357
+ const isHome = event.key === 'Home'
358
+ const isEnd = event.key === 'End'
359
+ // Ignore non-navigation keys
360
+ if (event.defaultPrevented || (!isLeft && !isRight && !isHome && !isEnd)) {
369
361
  return
370
362
  }
371
- let left = contentScroll - 80
372
- if (left < 0) {
373
- left = 0
363
+
364
+ const { tabs } = this
365
+ // Don't try to select another tab if there aren't any.
366
+ if (tabs.length < 2) {
367
+ return
374
368
  }
375
- this.contentScroll = left
376
- content.scrollTo({
377
- behavior: 'smooth',
378
- left,
379
- })
380
- }
381
369
 
382
- protected handleScrollRight(): void {
383
- const { contentScroll = 0, content } = this
384
- let left = contentScroll + 80
385
- if (left + content.clientWidth > content.scrollWidth) {
386
- left = content.scrollWidth - content.clientWidth
370
+ // Prevent default interactions, such as scrolling.
371
+ event.preventDefault()
372
+
373
+ let indexToFocus: number
374
+ if (isHome || isEnd) {
375
+ indexToFocus = isHome ? 0 : tabs.length - 1
376
+ } else {
377
+ // Check if moving forwards or backwards
378
+ const isRtl = getComputedStyle(this).direction === 'rtl'
379
+ const forwards = isRtl ? isLeft : isRight
380
+ const { focusedTab } = this
381
+ if (!focusedTab) {
382
+ // If there is not already a tab focused, select the first or last tab
383
+ // based on the direction we're traveling.
384
+ indexToFocus = forwards ? 0 : tabs.length - 1
385
+ } else {
386
+ const focusedIndex = this.tabs.indexOf(focusedTab)
387
+ indexToFocus = forwards ? focusedIndex + 1 : focusedIndex - 1
388
+ if (indexToFocus >= tabs.length) {
389
+ // Return to start if moving past the last item.
390
+ indexToFocus = 0
391
+ } else if (indexToFocus < 0) {
392
+ // Go to end if moving before the first item.
393
+ indexToFocus = tabs.length - 1
394
+ }
395
+ }
396
+ }
397
+
398
+ const tabToFocus = tabs[indexToFocus]
399
+ tabToFocus.focus()
400
+ if (this.autoActivate) {
401
+ this.activateTab(tabToFocus)
402
+ } else {
403
+ this.updateFocusableTab(tabToFocus)
387
404
  }
388
- this.contentScroll = left
389
- content.scrollTo({
390
- behavior: 'smooth',
391
- left,
392
- })
393
405
  }
394
406
 
395
- protected handleScroll(e: Event): void {
396
- const div = e.target as HTMLElement
397
- this.contentScroll = div.scrollLeft
407
+ // scroll to item on keyup.
408
+ private handleKeyup() {
409
+ this.scrollToTab(this.focusedTab ?? this.activeTab)
398
410
  }
399
411
 
400
- protected handleKeyDown(e: KeyboardEvent): void {
401
- if (e.code === 'ArrowRight') {
402
- e.preventDefault()
403
- const tab = this.activeItem ? this.getNextItem(this.activeItem) : this.items[0]
404
- this.makeSelection(tab, true)
405
- } else if (e.code === 'ArrowLeft') {
406
- e.preventDefault()
407
- const tab = this.activeItem ? this.getPreviousItem(this.activeItem) : this.items[0]
408
- this.makeSelection(tab, true)
412
+ private handleFocusout() {
413
+ // restore focus to selected item when blurring the tab bar.
414
+ if (this.matches(':focus-within')) {
415
+ return
409
416
  }
410
- }
411
417
 
412
- getPreviousItem(item: UiTab): UiTab {
413
- const { items } = this
414
- const curIndex = items.indexOf(item)
415
- if (curIndex < 0) {
416
- return item
418
+ const { activeTab } = this
419
+ if (activeTab) {
420
+ this.updateFocusableTab(activeTab)
417
421
  }
418
- let i = curIndex
419
- let result: HTMLElement | undefined
420
- do {
421
- i--
422
- if (i === curIndex) {
423
- // looped back from the end, no active element to find.
424
- return item
425
- }
426
- const tmp = items[i]
427
- if (!tmp) {
428
- i = items.length
429
- continue
430
- }
431
- if (this.isSelectable(tmp)) {
432
- result = tmp
433
- }
434
- } while (!result)
435
- return (result as UiTab) || item
436
422
  }
437
423
 
438
- getNextItem(item: UiTab): UiTab {
439
- const { items } = this
440
- const curIndex = items.indexOf(item)
441
- if (curIndex < 0) {
442
- return item
443
- }
444
- let i = curIndex
445
- let next: HTMLElement | undefined
446
- do {
447
- i++
448
- if (i === curIndex) {
449
- // looped back from the start, no active element to find.
450
- return item
451
- }
452
- const tmp = items[i]
453
- if (!tmp) {
454
- i = -1
455
- continue
424
+ private handleSlotChange() {
425
+ for (const tab of this.tabs) {
426
+ tab.priority = this.priority
427
+ if (tab.selected) {
428
+ tab.indicated = true
456
429
  }
457
- if (this.isSelectable(tmp)) {
458
- next = tmp
459
- }
460
- } while (!next)
461
- return (next as UiTab) || item
430
+ }
431
+ const firstTab = this.tabs[0]
432
+ if (!this.activeTab && firstTab) {
433
+ // If the active tab was removed, auto-select the first one. There should
434
+ // always be a selected tab while the bar has children.
435
+ this.activateTab(firstTab)
436
+ }
437
+
438
+ // When children shift, ensure the active tab is visible. For example, if
439
+ // many children are added before the active tab, it'd be pushed off screen.
440
+ // This ensures it stays visible.
441
+ this.scrollToTab(this.activeTab)
442
+ if (this.activeTab) {
443
+ this.updateFocusableTab(this.activeTab)
444
+ }
462
445
  }
463
446
 
464
447
  override render(): TemplateResult {
465
- const classes: ClassInfo = {
466
- surface: true,
467
- scrollable: !!this.scrollable,
468
- }
469
448
  return html`
470
- <div class="${classMap(classes)}">
471
- ${this.renderLeftScrollControl()}
472
- <div class="content" @scroll="${this.handleScroll}" @keydown="${this.handleKeyDown}">${this.renderSlot()}</div>
473
- ${this.rightScrollControl()} ${this.renderIndicator()}
474
- <div class="divider"></div>
475
- </div>
449
+ <div class="tabs">${this.renderSlot()}</div>
450
+ ${this.renderIndicator()}
451
+ <ui-divider class="divider"></ui-divider>
476
452
  `
477
453
  }
478
454
 
479
455
  protected renderSlot(): TemplateResult {
480
- return html`<slot
481
- @slotchange="${this.updateItems}"
482
- @click="${this.contentClickHandler}"
483
- @keydown="${this.contentKeyDownHandler}"
484
- ></slot>`
456
+ return html`<slot @slotchange="${this.handleSlotChange}" @click="${this.handleTabClick}"></slot>`
485
457
  }
486
458
 
487
459
  protected renderIndicator(): TemplateResult {
@@ -497,26 +469,8 @@ export default class UiTabs extends LitElement {
497
469
  </div>
498
470
  `
499
471
  }
472
+ }
500
473
 
501
- protected renderLeftScrollControl(): TemplateResult | typeof nothing {
502
- if (!this.scrollable) {
503
- return nothing
504
- }
505
- return html` <div class="scroll-control left">
506
- <ui-icon-button aria-label="Scroll tabs left" title="Scroll tabs left" @click="${this.handleScrollLeft}">
507
- <ui-icon icon="chevronLeft"></ui-icon>
508
- </ui-icon-button>
509
- </div>`
510
- }
511
-
512
- protected rightScrollControl(): TemplateResult | typeof nothing {
513
- if (!this.scrollable) {
514
- return nothing
515
- }
516
- return html`<div class="scroll-control right">
517
- <ui-icon-button aria-label="Scroll tabs right" title="Scroll tabs right" @click="${this.handleScrollRight}">
518
- <ui-icon icon="chevronRight"></ui-icon>
519
- </ui-icon-button>
520
- </div>`
521
- }
474
+ function isTab(element: unknown): element is UiTab {
475
+ return element instanceof UiTab
522
476
  }