@api-client/ui 0.5.47 → 0.5.49

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 (89) hide show
  1. package/build/src/elements/data-table/DataTable.d.ts +1 -1
  2. package/build/src/elements/data-table/DataTable.js +3 -3
  3. package/build/src/elements/data-table/DataTable.js.map +1 -1
  4. package/build/src/elements/file-system/internals/Breadcrumbs.d.ts +1 -1
  5. package/build/src/elements/file-system/internals/Breadcrumbs.d.ts.map +1 -1
  6. package/build/src/elements/file-system/internals/Breadcrumbs.js +3 -3
  7. package/build/src/elements/file-system/internals/Breadcrumbs.js.map +1 -1
  8. package/build/src/elements/navigation/internals/NavigationItem.d.ts +2 -0
  9. package/build/src/elements/navigation/internals/NavigationItem.d.ts.map +1 -1
  10. package/build/src/elements/navigation/internals/NavigationItem.js +4 -2
  11. package/build/src/elements/navigation/internals/NavigationItem.js.map +1 -1
  12. package/build/src/elements/user/internals/UserAvatar.d.ts +26 -4
  13. package/build/src/elements/user/internals/UserAvatar.d.ts.map +1 -1
  14. package/build/src/elements/user/internals/UserAvatar.js +125 -12
  15. package/build/src/elements/user/internals/UserAvatar.js.map +1 -1
  16. package/build/src/elements/user/internals/UserAvatar.styles.d.ts.map +1 -1
  17. package/build/src/elements/user/internals/UserAvatar.styles.js +6 -11
  18. package/build/src/elements/user/internals/UserAvatar.styles.js.map +1 -1
  19. package/build/src/md/button/internals/base.d.ts +1 -1
  20. package/build/src/md/button/internals/base.d.ts.map +1 -1
  21. package/build/src/md/button/internals/base.js +2 -2
  22. package/build/src/md/button/internals/base.js.map +1 -1
  23. package/build/src/md/checkbox/internals/CheckboxElement.d.ts +1 -1
  24. package/build/src/md/checkbox/internals/CheckboxElement.d.ts.map +1 -1
  25. package/build/src/md/checkbox/internals/CheckboxElement.js +2 -2
  26. package/build/src/md/checkbox/internals/CheckboxElement.js.map +1 -1
  27. package/build/src/md/collapse/internals/Collapse.d.ts.map +1 -1
  28. package/build/src/md/collapse/internals/Collapse.js +4 -1
  29. package/build/src/md/collapse/internals/Collapse.js.map +1 -1
  30. package/build/src/md/collapse/internals/Collapse.styles.d.ts.map +1 -1
  31. package/build/src/md/collapse/internals/Collapse.styles.js +1 -0
  32. package/build/src/md/collapse/internals/Collapse.styles.js.map +1 -1
  33. package/build/src/md/focus-ring/internals/focus-ring.d.ts +87 -0
  34. package/build/src/md/focus-ring/internals/focus-ring.d.ts.map +1 -0
  35. package/build/src/md/focus-ring/internals/focus-ring.js +206 -0
  36. package/build/src/md/focus-ring/internals/focus-ring.js.map +1 -0
  37. package/build/src/md/focus-ring/internals/focus-ring.styles.d.ts +3 -0
  38. package/build/src/md/focus-ring/internals/focus-ring.styles.d.ts.map +1 -0
  39. package/build/src/md/focus-ring/internals/focus-ring.styles.js +109 -0
  40. package/build/src/md/focus-ring/internals/focus-ring.styles.js.map +1 -0
  41. package/build/src/md/focus-ring/ui-focus-ring.d.ts +42 -0
  42. package/build/src/md/focus-ring/ui-focus-ring.d.ts.map +1 -0
  43. package/build/src/md/focus-ring/ui-focus-ring.js +58 -0
  44. package/build/src/md/focus-ring/ui-focus-ring.js.map +1 -0
  45. package/build/src/md/list/internals/ListItem.d.ts +3 -3
  46. package/build/src/md/list/internals/ListItem.d.ts.map +1 -1
  47. package/build/src/md/list/internals/ListItem.js +7 -6
  48. package/build/src/md/list/internals/ListItem.js.map +1 -1
  49. package/build/src/md/menu/internal/MenuItem.d.ts +0 -2
  50. package/build/src/md/menu/internal/MenuItem.d.ts.map +1 -1
  51. package/build/src/md/menu/internal/MenuItem.js +0 -2
  52. package/build/src/md/menu/internal/MenuItem.js.map +1 -1
  53. package/build/src/md/radio/internals/Radio.styles.js +1 -1
  54. package/build/src/md/radio/internals/Radio.styles.js.map +1 -1
  55. package/build/src/md/radio/internals/RadioElement.d.ts +1 -0
  56. package/build/src/md/radio/internals/RadioElement.d.ts.map +1 -1
  57. package/build/src/md/radio/internals/RadioElement.js +2 -0
  58. package/build/src/md/radio/internals/RadioElement.js.map +1 -1
  59. package/build/src/md/select/internals/Select.d.ts +1 -1
  60. package/build/src/md/select/internals/Select.d.ts.map +1 -1
  61. package/build/src/md/select/internals/Select.js +2 -2
  62. package/build/src/md/select/internals/Select.js.map +1 -1
  63. package/build/src/md/tabs/internals/Tab.d.ts +1 -0
  64. package/build/src/md/tabs/internals/Tab.d.ts.map +1 -1
  65. package/build/src/md/tabs/internals/Tab.js +3 -2
  66. package/build/src/md/tabs/internals/Tab.js.map +1 -1
  67. package/build/src/md/tabs/internals/Tabs.js +4 -4
  68. package/build/src/md/tabs/internals/Tabs.js.map +1 -1
  69. package/build/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +1 -2
  71. package/src/elements/data-table/DataTable.ts +3 -3
  72. package/src/elements/file-system/internals/Breadcrumbs.ts +3 -3
  73. package/src/elements/navigation/internals/NavigationItem.ts +4 -2
  74. package/src/elements/user/internals/UserAvatar.styles.ts +6 -11
  75. package/src/elements/user/internals/UserAvatar.ts +115 -8
  76. package/src/md/button/internals/base.ts +2 -2
  77. package/src/md/checkbox/internals/CheckboxElement.ts +2 -2
  78. package/src/md/collapse/internals/Collapse.styles.ts +1 -0
  79. package/src/md/collapse/internals/Collapse.ts +4 -1
  80. package/src/md/focus-ring/internals/focus-ring.styles.ts +109 -0
  81. package/src/md/focus-ring/internals/focus-ring.ts +184 -0
  82. package/src/md/focus-ring/ui-focus-ring.ts +46 -0
  83. package/src/md/list/internals/ListItem.ts +5 -5
  84. package/src/md/menu/internal/MenuItem.ts +0 -2
  85. package/src/md/radio/internals/Radio.styles.ts +1 -1
  86. package/src/md/radio/internals/RadioElement.ts +2 -0
  87. package/src/md/select/internals/Select.ts +2 -2
  88. package/src/md/tabs/internals/Tab.ts +4 -2
  89. package/src/md/tabs/internals/Tabs.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@api-client/ui",
3
- "version": "0.5.47",
3
+ "version": "0.5.49",
4
4
  "description": "Internal UI component library for the API Client ecosystem.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "build/src/index.js",
@@ -188,7 +188,6 @@
188
188
  "@codemirror/state": "^6.5.2",
189
189
  "@codemirror/view": "^6.38.0",
190
190
  "@github/relative-time-element": "^4.4.6",
191
- "@material/web": "^2.3.0",
192
191
  "@pawel-up/jexl": "^4.3.0",
193
192
  "@types/har-format": "^1.2.8",
194
193
  "dompurify": "^3.2.5",
@@ -7,7 +7,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'
7
7
  import { dataAttr } from '../../directives/data-attr.js'
8
8
  import { LiveData } from '../../reactive/LiveData.js'
9
9
 
10
- import '@material/web/focus/md-focus-ring.js'
10
+ import '../../md/focus-ring/ui-focus-ring.js'
11
11
  import '../../md/icons/ui-icon.js'
12
12
 
13
13
  /**
@@ -929,7 +929,7 @@ export abstract class DataTable<T extends object> extends LitElement {
929
929
  index = isFocused ? 0 : -1
930
930
  }
931
931
  const ring = !delegateFocus
932
- ? html`<md-focus-ring .inward="${this.overflow ? true : false}"></md-focus-ring>`
932
+ ? html`<ui-focus-ring .inward="${this.overflow ? true : false}"></ui-focus-ring>`
933
933
  : nothing
934
934
  if (isPrimary) {
935
935
  // Lit does not allow dynamic element names so I decided to make a copy.
@@ -1020,7 +1020,7 @@ export abstract class DataTable<T extends object> extends LitElement {
1020
1020
  aria-label="${ifDefined(ariaLabel)}"
1021
1021
  part="header-cell"
1022
1022
  >
1023
- <md-focus-ring .inward="${this.overflow ? true : false}"></md-focus-ring>
1023
+ <ui-focus-ring .inward="${this.overflow ? true : false}"></ui-focus-ring>
1024
1024
  <div class="cell-content">
1025
1025
  ${content}
1026
1026
  ${isSortable && sortIcon ? html`<ui-icon class="sort-icon" role="presentation">${sortIcon}</ui-icon>` : nothing}
@@ -4,7 +4,7 @@ import { LiveData } from '../../../reactive/LiveData.js'
4
4
  import { type FileBreadcrumb } from '@api-client/core/models/store/File.js'
5
5
  import { FolderKind } from '@api-client/core/models/kinds.js'
6
6
 
7
- import '@material/web/focus/md-focus-ring.js'
7
+ import '../../../md/focus-ring/ui-focus-ring.js'
8
8
  import '../../../md/icons/ui-icon.js'
9
9
 
10
10
  /**
@@ -123,7 +123,7 @@ export default class Breadcrumbs extends LitElement {
123
123
  class="link-button"
124
124
  @click="${this.handleLinkClick}"
125
125
  >
126
- <md-focus-ring part="focus-ring"></md-focus-ring>
126
+ <ui-focus-ring part="focus-ring"></ui-focus-ring>
127
127
  ${name}
128
128
  </div>
129
129
  `
@@ -133,7 +133,7 @@ export default class Breadcrumbs extends LitElement {
133
133
  const { key, kind, name } = item
134
134
  return html`
135
135
  <div tabindex="0" role="button" data-key="${key}" data-kind="${kind}" class="action-button" aria-current="page">
136
- <md-focus-ring part="focus-ring"></md-focus-ring>
136
+ <ui-focus-ring part="focus-ring"></ui-focus-ring>
137
137
  ${name}
138
138
  </div>
139
139
  `
@@ -4,6 +4,8 @@ import { classMap } from 'lit/directives/class-map.js'
4
4
  import { when } from 'lit/directives/when.js'
5
5
  import { UiElement } from '../../../md/UiElement.js'
6
6
  import { isDisabled, setDisabled } from '../../../lib/disabled.js'
7
+ import '../../../md/focus-ring/ui-focus-ring.js'
8
+ import '../../../md/ripple/ui-ripple.js'
7
9
 
8
10
  /**
9
11
  * NavigationItem
@@ -120,8 +122,8 @@ export default class NavigationItem extends UiElement {
120
122
  })
121
123
  return html`
122
124
  <button class="${containerClasses}" id="button" ?disabled="${this.disabled}" aria-disabled="${this.disabled}">
123
- <md-focus-ring part="focus-ring" for="button"></md-focus-ring>
124
- <md-ripple></md-ripple>
125
+ <ui-focus-ring part="focus-ring" for="button"></ui-focus-ring>
126
+ <ui-ripple></ui-ripple>
125
127
  ${this.renderIcon()}${when(
126
128
  this.iconOnly,
127
129
  () => nothing,
@@ -44,10 +44,10 @@ export default css`
44
44
  z-index: 0;
45
45
  flex: 1;
46
46
  border-radius: var(--_state-layer-shape);
47
- --md-ripple-hover-color: var(--_hover-state-layer-color);
48
- --md-ripple-hover-opacity: var(--_hover-state-layer-opacity);
49
- --md-ripple-pressed-color: var(--_pressed-state-layer-color);
50
- --md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity);
47
+
48
+ --md-ripple-hover-state-layer-color: var(--md-sys-color-primary);
49
+ --md-ripple-focus-state-layer-color: var(--md-sys-color-primary);
50
+ --md-ripple-pressed-state-layer-color: var(--md-sys-color-on-surface);
51
51
  }
52
52
 
53
53
  .icon-button:hover {
@@ -69,8 +69,9 @@ export default css`
69
69
  --md-focus-ring-shape-end-start: var(--_state-layer-shape);
70
70
  }
71
71
 
72
- md-ripple {
72
+ .ripple {
73
73
  border-radius: var(--_state-layer-shape);
74
+ z-index: 1;
74
75
  }
75
76
 
76
77
  .icon {
@@ -79,12 +80,6 @@ export default css`
79
80
  overflow: hidden;
80
81
  }
81
82
 
82
- .touch {
83
- position: absolute;
84
- height: max(48px, 100%);
85
- width: max(48px, 100%);
86
- }
87
-
88
83
  .user-icon,
89
84
  .avatar-initials {
90
85
  background-color: var(--md-sys-color-primary-container, #9eeffe);
@@ -1,19 +1,31 @@
1
- import { html, TemplateResult, LitElement, PropertyValues, SVGTemplateResult, svg } from 'lit'
2
- import { property, state } from 'lit/decorators.js'
1
+ import { html, TemplateResult, PropertyValues, SVGTemplateResult, svg } from 'lit'
2
+ import { property, query, state } from 'lit/decorators.js'
3
+ import { UiElement } from '../../../md/UiElement.js'
4
+ import { setDisabled } from '../../../lib/disabled.js'
3
5
  import type { IUser } from '@api-client/core/models/store/User.js'
4
- import '@material/web/focus/md-focus-ring.js'
5
- import '@material/web/ripple/ripple.js'
6
+ import type UiRipple from '../../../md/ripple/internals/ripple.js'
7
+ import type { BeginPressConfig, EndPressConfig } from '../../../controllers/ActionController.js'
8
+ import '../../../md/focus-ring/ui-focus-ring.js'
9
+ import '../../../md/ripple/ui-ripple.js'
6
10
 
7
11
  export type AvatarType = 'button' | 'icon'
8
12
 
9
- export default class UserAvatar extends LitElement {
13
+ export default class UserAvatar extends UiElement {
14
+ @query('ui-ripple') accessor ripple: UiRipple | null = null
15
+ /**
16
+ * Whether the avatar is disabled.
17
+ * @attribute
18
+ */
19
+ @property({ type: Boolean, reflect: true }) accessor disabled = false
10
20
  /**
11
21
  * Set with the user. The computed user initials.
22
+ * @attribute
12
23
  */
13
24
  @state() protected accessor userInitials: string | undefined
14
25
 
15
26
  /**
16
27
  * The URL to the user picture.
28
+ * @attribute
17
29
  */
18
30
  @state() protected accessor userPicture: string | undefined
19
31
 
@@ -27,13 +39,102 @@ export default class UserAvatar extends LitElement {
27
39
  */
28
40
  @property({ type: String }) accessor type: AvatarType = 'button'
29
41
 
42
+ constructor() {
43
+ super()
44
+
45
+ this.addEventListener('keydown', this.handleKeyDown.bind(this))
46
+ this.addEventListener('keyup', this.handleKeyUp.bind(this))
47
+ this.addEventListener('pointerenter', this.handlePointerEnter.bind(this))
48
+ this.addEventListener('pointerleave', this.handlePointerLeave.bind(this))
49
+ this.addEventListener('click', this.handleClick.bind(this))
50
+ this.addEventListener('pointerdown', this.handlePointerDown.bind(this))
51
+ this.addEventListener('pointerup', this.handlePointerUp.bind(this))
52
+ this.addEventListener('pointercancel', this.handlePointerCancel.bind(this))
53
+ this.addEventListener('contextmenu', this.handleContextMenu.bind(this))
54
+ }
55
+
30
56
  protected override willUpdate(cp: PropertyValues<this>): void {
31
57
  if (cp.has('user')) {
32
58
  this.handleUserChange(this.user)
33
59
  }
60
+ if (cp.has('disabled')) {
61
+ setDisabled(this, cp.get('disabled'))
62
+ }
34
63
  super.willUpdate(cp)
35
64
  }
36
65
 
66
+ override beginPress(options: BeginPressConfig): void {
67
+ if (!this.shouldHandleInteraction()) {
68
+ return
69
+ }
70
+ super.beginPress(options)
71
+ this.ripple?.beginPress(options.positionEvent)
72
+ }
73
+
74
+ override endPress(options: EndPressConfig): void {
75
+ if (!this.shouldHandleInteraction()) {
76
+ return
77
+ }
78
+ super.endPress(options)
79
+ this.ripple?.endPress()
80
+ }
81
+
82
+ override handleKeyDown(e: KeyboardEvent): void {
83
+ if (!this.shouldHandleInteraction()) {
84
+ e.stopPropagation()
85
+ e.preventDefault()
86
+ return
87
+ }
88
+ if (e.code === 'Tab' || e.shiftKey) return
89
+ // do not prevent default, so that parent elements can handle the key event
90
+ this.beginPress({ positionEvent: e })
91
+ }
92
+
93
+ override handleKeyUp(e: KeyboardEvent): void {
94
+ if (!this.shouldHandleInteraction()) {
95
+ e.stopPropagation()
96
+ e.preventDefault()
97
+ return
98
+ }
99
+
100
+ // do not prevent default, so that parent elements can handle the key event
101
+ this.endPress({ cancelled: false, actionData: { item: this } })
102
+ }
103
+
104
+ override handlePointerEnter(e: PointerEvent): void {
105
+ if (!this.shouldHandleInteraction()) {
106
+ e.stopPropagation()
107
+ e.preventDefault()
108
+ return
109
+ }
110
+ this.ripple?.beginHover(e)
111
+ }
112
+
113
+ override handlePointerLeave(e: PointerEvent): void {
114
+ if (!this.shouldHandleInteraction()) {
115
+ e.stopPropagation()
116
+ e.preventDefault()
117
+ return
118
+ }
119
+ super.handlePointerLeave(e)
120
+
121
+ this.ripple?.endHover()
122
+ }
123
+
124
+ override handleClick(e: MouseEvent): void {
125
+ super.handleClick(e)
126
+ if (this.disabled) {
127
+ e.preventDefault()
128
+ e.stopPropagation()
129
+ return
130
+ }
131
+ this.endPress({ cancelled: false, actionData: { event: e } })
132
+ }
133
+
134
+ protected shouldHandleInteraction(): boolean {
135
+ return !this.disabled && this.type === 'button'
136
+ }
137
+
37
138
  /**
38
139
  * Handles changes to the user property.
39
140
  * @param user The user object
@@ -88,10 +189,8 @@ export default class UserAvatar extends LitElement {
88
189
  protected renderButton(content: TemplateResult): TemplateResult {
89
190
  return html`
90
191
  <button id="button" class="icon-button">
91
- <md-focus-ring part="focus-ring" for="button"></md-focus-ring>
92
- <md-ripple></md-ripple>
192
+ ${this.renderFocusRing()} ${this.renderRipple()}
93
193
  <span role="presentation" class="icon">${content}</span>
94
- <span class="touch"></span>
95
194
  </button>
96
195
  `
97
196
  }
@@ -140,4 +239,12 @@ export default class UserAvatar extends LitElement {
140
239
  </svg>
141
240
  `
142
241
  }
242
+
243
+ protected renderRipple(): TemplateResult {
244
+ return html`<ui-ripple class="ripple" ?disabled="${this.disabled}"></ui-ripple>`
245
+ }
246
+
247
+ protected renderFocusRing(): TemplateResult {
248
+ return html`<ui-focus-ring part="focus-ring" for="button"></ui-focus-ring>`
249
+ }
143
250
  }
@@ -8,7 +8,7 @@ import type UiRipple from '../../ripple/internals/ripple.js'
8
8
  import { findElementInShadowRoots } from '../../../lib/Dom.js'
9
9
 
10
10
  import '../../ripple/ui-ripple.js'
11
- import '@material/web/focus/md-focus-ring.js'
11
+ import '../../focus-ring/ui-focus-ring.js'
12
12
 
13
13
  export type ButtonType = 'submit' | 'reset' | 'button'
14
14
  export type MdButtonShape = 'round' | 'square'
@@ -370,7 +370,7 @@ export default class BaseButton extends UiElement {
370
370
  }
371
371
 
372
372
  protected renderFocusRing(): TemplateResult {
373
- return html`<md-focus-ring part="focus-ring" class="focus-ring" .control="${this as HTMLElement}"></md-focus-ring>`
373
+ return html`<ui-focus-ring part="focus-ring" class="focus-ring" .control="${this as HTMLElement}"></ui-focus-ring>`
374
374
  }
375
375
 
376
376
  protected renderRipple(): TemplateResult {
@@ -7,7 +7,7 @@ import type UiRipple from '../../ripple/internals/ripple.js'
7
7
  import type { BeginPressConfig, EndPressConfig } from '../../../controllers/ActionController.js'
8
8
 
9
9
  import '../../ripple/ui-ripple.js'
10
- import '@material/web/focus/md-focus-ring.js'
10
+ import '../../focus-ring/ui-focus-ring.js'
11
11
 
12
12
  export default class CheckboxElement extends CheckedElement {
13
13
  protected get _icon(): SVGTemplateResult | typeof nothing {
@@ -79,7 +79,7 @@ export default class CheckboxElement extends CheckedElement {
79
79
  pressed,
80
80
  })
81
81
  return html`
82
- <md-focus-ring part="focus-ring" .control="${this as HTMLElement}"></md-focus-ring>
82
+ <ui-focus-ring part="focus-ring" .control="${this as HTMLElement}"></ui-focus-ring>
83
83
  <div class="${containerClasses}">
84
84
  <div class="container"></div>
85
85
  <div class="state"></div>
@@ -5,6 +5,7 @@ export default css`
5
5
  display: block;
6
6
  transition-duration: var(--ui-collapse-transition-duration, 300ms);
7
7
  transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
8
+ transition-property: max-height, max-width;
8
9
  overflow: visible;
9
10
  }
10
11
 
@@ -104,7 +104,7 @@ export default class UiCollapse extends UiElement {
104
104
  */
105
105
  toggle(): void {
106
106
  this.open = !this.open
107
- this.dispatchEvent(new Event('open'))
107
+ this.dispatchEvent(new Event('toggle'))
108
108
  }
109
109
 
110
110
  /**
@@ -128,8 +128,11 @@ export default class UiCollapse extends UiElement {
128
128
  // After the transition is done, _transitionEnd will set the size back to
129
129
  // `auto`.
130
130
  if (sizeValue === '') {
131
+ // Temporarily remove constraint to measure natural size
132
+ const currentMaxDimension = this.style[this.dimensionMax]
131
133
  this.style[this.dimensionMax] = ''
132
134
  sizeValue = this.calcSize()
135
+ this.style[this.dimensionMax] = currentMaxDimension
133
136
  }
134
137
  // Go to startSize without animation.
135
138
 
@@ -0,0 +1,109 @@
1
+ import { css } from 'lit'
2
+
3
+ export default css`
4
+ :host {
5
+ animation-delay: 0s, calc(var(--md-focus-ring-duration, 600ms) * 0.25);
6
+ animation-duration:
7
+ calc(var(--md-focus-ring-duration, 600ms) * 0.25), calc(var(--md-focus-ring-duration, 600ms) * 0.75);
8
+ animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
9
+ box-sizing: border-box;
10
+ color: var(--md-focus-ring-color, var(--md-sys-color-secondary, #625b71));
11
+ display: none;
12
+ pointer-events: none;
13
+ position: absolute;
14
+ }
15
+
16
+ :host([visible]) {
17
+ display: flex;
18
+ }
19
+
20
+ :host(:not([inward])) {
21
+ animation-name: outward-grow, outward-shrink;
22
+ border-end-end-radius: calc(
23
+ var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) +
24
+ var(--md-focus-ring-outward-offset, 2px)
25
+ );
26
+ border-end-start-radius: calc(
27
+ var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) +
28
+ var(--md-focus-ring-outward-offset, 2px)
29
+ );
30
+ border-start-end-radius: calc(
31
+ var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) +
32
+ var(--md-focus-ring-outward-offset, 2px)
33
+ );
34
+ border-start-start-radius: calc(
35
+ var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) +
36
+ var(--md-focus-ring-outward-offset, 2px)
37
+ );
38
+ inset: calc(-1 * var(--md-focus-ring-outward-offset, 2px));
39
+ outline: var(--md-focus-ring-width, 3px) solid currentColor;
40
+ }
41
+
42
+ :host([inward]) {
43
+ animation-name: inward-grow, inward-shrink;
44
+ border-end-end-radius: calc(
45
+ var(--md-focus-ring-shape-end-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(
46
+ --md-focus-ring-inward-offset,
47
+ 0px
48
+ )
49
+ );
50
+ border-end-start-radius: calc(
51
+ var(--md-focus-ring-shape-end-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(
52
+ --md-focus-ring-inward-offset,
53
+ 0px
54
+ )
55
+ );
56
+ border-start-end-radius: calc(
57
+ var(--md-focus-ring-shape-start-end, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(
58
+ --md-focus-ring-inward-offset,
59
+ 0px
60
+ )
61
+ );
62
+ border-start-start-radius: calc(
63
+ var(--md-focus-ring-shape-start-start, var(--md-focus-ring-shape, var(--md-sys-shape-corner-full, 9999px))) - var(
64
+ --md-focus-ring-inward-offset,
65
+ 0px
66
+ )
67
+ );
68
+ border: var(--md-focus-ring-width, 3px) solid currentColor;
69
+ inset: var(--md-focus-ring-inward-offset, 0px);
70
+ }
71
+
72
+ @keyframes outward-grow {
73
+ from {
74
+ outline-width: 0;
75
+ }
76
+
77
+ to {
78
+ outline-width: var(--md-focus-ring-active-width, 8px);
79
+ }
80
+ }
81
+
82
+ @keyframes outward-shrink {
83
+ from {
84
+ outline-width: var(--md-focus-ring-active-width, 8px);
85
+ }
86
+ }
87
+
88
+ @keyframes inward-grow {
89
+ from {
90
+ border-width: 0;
91
+ }
92
+
93
+ to {
94
+ border-width: var(--md-focus-ring-active-width, 8px);
95
+ }
96
+ }
97
+
98
+ @keyframes inward-shrink {
99
+ from {
100
+ border-width: var(--md-focus-ring-active-width, 8px);
101
+ }
102
+ }
103
+
104
+ @media (prefers-reduced-motion) {
105
+ :host {
106
+ animation: none;
107
+ }
108
+ }
109
+ `
@@ -0,0 +1,184 @@
1
+ import { LitElement, type PropertyValues } from 'lit'
2
+ import { property } from 'lit/decorators.js'
3
+
4
+ /**
5
+ * A focus ring component that provides a visible focus indicator.
6
+ * This component automatically manages the visibility of the focus ring
7
+ * based on the focus state of its control element.
8
+ *
9
+ * @fires visibility-changed - Dispatched when the focus ring visibility changes.
10
+ * - `detail`: An object with a `visible` property (boolean).
11
+ */
12
+ export default class UiFocusRing extends LitElement {
13
+ /**
14
+ * The control element that this focus ring is associated with.
15
+ * When the control gains or loses focus, the focus ring will show or hide.
16
+ */
17
+ @property({ type: Object }) accessor control: HTMLElement | undefined
18
+
19
+ /**
20
+ * An element to attach the focus ring to. Defaults to the parent element.
21
+ * This is typically used when the focus ring should be positioned relative
22
+ * to a different element than the control.
23
+ */
24
+ @property({ type: Object }) accessor attach: HTMLElement | undefined
25
+
26
+ /**
27
+ * An ID of an element that this focus ring is for.
28
+ * Alternative to setting the `control` property directly.
29
+ * @attribute
30
+ */
31
+ @property({ type: String }) accessor for: string | undefined
32
+
33
+ /**
34
+ * Whether the focus ring should render inward instead of outward.
35
+ * When true, the focus ring will be positioned inside the control bounds.
36
+ * @attribute
37
+ */
38
+ @property({ type: Boolean, reflect: true }) accessor inward = false
39
+
40
+ /**
41
+ * The internal visibility state of the focus ring.
42
+ */
43
+ @property({ type: Boolean, reflect: true }) accessor visible = false
44
+
45
+ private abortController?: AbortController
46
+
47
+ constructor() {
48
+ super()
49
+ this.addEventListener('visibility-changed', this.onVisibilityChanged as EventListener)
50
+ }
51
+
52
+ override connectedCallback(): void {
53
+ super.connectedCallback()
54
+ this.setupControl()
55
+ }
56
+
57
+ override disconnectedCallback(): void {
58
+ super.disconnectedCallback()
59
+ this.cleanupListeners()
60
+ }
61
+
62
+ protected override willUpdate(changedProperties: PropertyValues<this>): void {
63
+ if (changedProperties.has('for') || changedProperties.has('control')) {
64
+ this.setupControl()
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Sets up the control element and event listeners.
70
+ */
71
+ private setupControl(): void {
72
+ this.cleanupListeners()
73
+
74
+ const control = this.getControl()
75
+ if (!control) return
76
+
77
+ this.abortController = new AbortController()
78
+ const { signal } = this.abortController
79
+
80
+ // Listen for focus and blur events
81
+ control.addEventListener('focus', this.handleFocus, { signal })
82
+ control.addEventListener('blur', this.handleBlur, { signal })
83
+ control.addEventListener('pointerdown', this.handlePointerDown, { signal })
84
+
85
+ // Set initial state
86
+ this.visible = control.matches(':focus-visible')
87
+ }
88
+
89
+ /**
90
+ * Cleans up event listeners.
91
+ */
92
+ private cleanupListeners(): void {
93
+ this.abortController?.abort()
94
+ this.abortController = undefined
95
+ }
96
+
97
+ /**
98
+ * Gets the control element from either the `control` property,
99
+ * the `for` attribute, or defaults to the parent element.
100
+ */
101
+ private getControl(): HTMLElement | null {
102
+ if (this.control) {
103
+ return this.control
104
+ }
105
+
106
+ if (this.for) {
107
+ const root = this.getRootNode() as Document | ShadowRoot
108
+ return root.getElementById(this.for)
109
+ }
110
+
111
+ return this.parentElement
112
+ }
113
+
114
+ /**
115
+ * Handles focus events from the control element.
116
+ */
117
+ private handleFocus = (event: FocusEvent): void => {
118
+ // Only show focus ring for keyboard navigation
119
+ if (this.shouldShowFocusRing(event)) {
120
+ this.show()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handles blur events from the control element.
126
+ */
127
+ private handleBlur = (): void => {
128
+ this.hide()
129
+ }
130
+
131
+ /**
132
+ * Handles pointer down events to hide focus ring during mouse interaction.
133
+ */
134
+ private handlePointerDown = (): void => {
135
+ this.hide()
136
+ }
137
+
138
+ /**
139
+ * Determines if the focus ring should be shown based on the focus event.
140
+ * The focus ring should only show for keyboard navigation, not mouse clicks.
141
+ */
142
+ private shouldShowFocusRing(event: FocusEvent): boolean {
143
+ const control = event.target as HTMLElement
144
+ return control?.matches(':focus-visible') ?? false
145
+ }
146
+
147
+ /**
148
+ * Shows the focus ring.
149
+ */
150
+ show(): void {
151
+ if (this.visible) return
152
+ this.visible = true
153
+ }
154
+
155
+ /**
156
+ * Hides the focus ring.
157
+ */
158
+ hide(): void {
159
+ if (!this.visible) return
160
+ this.visible = false
161
+ }
162
+
163
+ /**
164
+ * Handles the visibility changed event.
165
+ */
166
+ private onVisibilityChanged = (event: Event): void => {
167
+ // Stop propagation to prevent multiple focus rings from interfering
168
+ event.stopPropagation()
169
+ }
170
+
171
+ protected override updated(changedProperties: PropertyValues<this>): void {
172
+ super.updated(changedProperties)
173
+
174
+ if (changedProperties.has('visible')) {
175
+ this.dispatchEvent(
176
+ new CustomEvent('visibility-changed', {
177
+ detail: { visible: this.visible },
178
+ bubbles: false,
179
+ composed: true,
180
+ })
181
+ )
182
+ }
183
+ }
184
+ }