@api-client/ui 0.5.24 → 0.5.26

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 (83) hide show
  1. package/.cursor/rules/lit-best-practices.mdc +12 -1
  2. package/.github/instructions/lit-best-practices.instructions.md +2 -0
  3. package/build/src/elements/navigation/internals/AppNavigationElement.d.ts.map +1 -1
  4. package/build/src/elements/navigation/internals/AppNavigationElement.js +1 -6
  5. package/build/src/elements/navigation/internals/AppNavigationElement.js.map +1 -1
  6. package/build/src/elements/navigation/internals/NavigationItem.d.ts +6 -0
  7. package/build/src/elements/navigation/internals/NavigationItem.d.ts.map +1 -1
  8. package/build/src/elements/navigation/internals/NavigationItem.js +26 -7
  9. package/build/src/elements/navigation/internals/NavigationItem.js.map +1 -1
  10. package/build/src/elements/navigation/internals/NavigationItem.styles.d.ts.map +1 -1
  11. package/build/src/elements/navigation/internals/NavigationItem.styles.js +18 -1
  12. package/build/src/elements/navigation/internals/NavigationItem.styles.js.map +1 -1
  13. package/build/src/md/dropdown-list/internals/UiDropdownList.d.ts.map +1 -1
  14. package/build/src/md/dropdown-list/internals/UiDropdownList.js +4 -3
  15. package/build/src/md/dropdown-list/internals/UiDropdownList.js.map +1 -1
  16. package/build/src/md/input/Input.d.ts +8 -4
  17. package/build/src/md/input/Input.d.ts.map +1 -1
  18. package/build/src/md/input/Input.js +8 -36
  19. package/build/src/md/input/Input.js.map +1 -1
  20. package/build/src/md/list/internals/List.d.ts +3 -1
  21. package/build/src/md/list/internals/List.d.ts.map +1 -1
  22. package/build/src/md/list/internals/List.js +9 -4
  23. package/build/src/md/list/internals/List.js.map +1 -1
  24. package/build/src/md/menu/internal/Menu.d.ts +8 -7
  25. package/build/src/md/menu/internal/Menu.d.ts.map +1 -1
  26. package/build/src/md/menu/internal/Menu.js +26 -29
  27. package/build/src/md/menu/internal/Menu.js.map +1 -1
  28. package/build/src/md/select/index.d.ts +4 -0
  29. package/build/src/md/select/index.d.ts.map +1 -0
  30. package/build/src/md/select/index.js +3 -0
  31. package/build/src/md/select/index.js.map +1 -0
  32. package/build/src/md/select/internals/Option.d.ts +125 -0
  33. package/build/src/md/select/internals/Option.d.ts.map +1 -0
  34. package/build/src/md/select/internals/Option.js +242 -0
  35. package/build/src/md/select/internals/Option.js.map +1 -0
  36. package/build/src/md/select/internals/Option.styles.d.ts +3 -0
  37. package/build/src/md/select/internals/Option.styles.d.ts.map +1 -0
  38. package/build/src/md/select/internals/Option.styles.js +139 -0
  39. package/build/src/md/select/internals/Option.styles.js.map +1 -0
  40. package/build/src/md/select/internals/Select.d.ts +250 -0
  41. package/build/src/md/select/internals/Select.d.ts.map +1 -0
  42. package/build/src/md/select/internals/Select.js +606 -0
  43. package/build/src/md/select/internals/Select.js.map +1 -0
  44. package/build/src/md/select/internals/Select.styles.d.ts +3 -0
  45. package/build/src/md/select/internals/Select.styles.d.ts.map +1 -0
  46. package/build/src/md/select/internals/Select.styles.js +22 -0
  47. package/build/src/md/select/internals/Select.styles.js.map +1 -0
  48. package/build/src/md/select/ui-option.d.ts +12 -0
  49. package/build/src/md/select/ui-option.d.ts.map +1 -0
  50. package/build/src/md/select/ui-option.js +29 -0
  51. package/build/src/md/select/ui-option.js.map +1 -0
  52. package/build/src/md/select/ui-select.d.ts +12 -0
  53. package/build/src/md/select/ui-select.d.ts.map +1 -0
  54. package/build/src/md/select/ui-select.js +27 -0
  55. package/build/src/md/select/ui-select.js.map +1 -0
  56. package/build/src/md/text-field/internals/TextField.d.ts.map +1 -1
  57. package/build/src/md/text-field/internals/TextField.js +1 -0
  58. package/build/src/md/text-field/internals/TextField.js.map +1 -1
  59. package/demo/elements/index.html +7 -4
  60. package/demo/elements/navigation/navigation-item.html +45 -0
  61. package/demo/elements/navigation/navigation-item.ts +112 -0
  62. package/demo/md/index.html +2 -0
  63. package/demo/md/inputs/input.ts +4 -0
  64. package/demo/md/select/index.html +16 -0
  65. package/demo/md/select/index.ts +202 -0
  66. package/package.json +1 -1
  67. package/src/elements/navigation/internals/AppNavigationElement.ts +1 -6
  68. package/src/elements/navigation/internals/NavigationItem.styles.ts +18 -1
  69. package/src/elements/navigation/internals/NavigationItem.ts +11 -5
  70. package/src/md/dropdown-list/internals/UiDropdownList.ts +4 -3
  71. package/src/md/input/Input.ts +8 -37
  72. package/src/md/list/internals/List.ts +12 -5
  73. package/src/md/menu/internal/Menu.ts +27 -18
  74. package/src/md/select/index.ts +3 -0
  75. package/src/md/select/internals/Option.styles.ts +139 -0
  76. package/src/md/select/internals/Option.ts +210 -0
  77. package/src/md/select/internals/Select.styles.ts +22 -0
  78. package/src/md/select/internals/Select.ts +534 -0
  79. package/src/md/select/ui-option.ts +18 -0
  80. package/src/md/select/ui-select.ts +17 -0
  81. package/src/md/text-field/internals/TextField.ts +1 -0
  82. package/test/md/menu/SubMenu.test.ts +2 -3
  83. package/test/md/select/Select.test.ts +667 -0
@@ -28,6 +28,12 @@ export default css`
28
28
  --_state-layer-padding-end: 24px;
29
29
  }
30
30
 
31
+ :host([iconOnly]) {
32
+ width: 40px;
33
+ --_state-layer-padding-start: 8px;
34
+ --_state-layer-padding-end: 8px;
35
+ }
36
+
31
37
  :host([selected]) {
32
38
  --_state-layer-color: var(--md-sys-color-secondary-container);
33
39
  --_icon-color: var(--md-sys-color-on-secondary-container);
@@ -61,10 +67,15 @@ export default css`
61
67
  --md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity);
62
68
  }
63
69
 
64
- .button.icon {
70
+ .button.icon:not(.button.icon) {
65
71
  --_state-layer-padding-start: 16px;
66
72
  }
67
73
 
74
+ .button.icon-only {
75
+ gap: 0;
76
+ justify-content: center;
77
+ }
78
+
68
79
  .button:hover {
69
80
  color: var(--_hover-icon-color);
70
81
  }
@@ -77,6 +88,12 @@ export default css`
77
88
  color: var(--_pressed-icon-color);
78
89
  }
79
90
 
91
+ .icon {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ }
96
+
80
97
  md-focus-ring {
81
98
  --md-focus-ring-shape-start-start: var(--_state-layer-shape);
82
99
  --md-focus-ring-shape-start-end: var(--_state-layer-shape);
@@ -20,6 +20,12 @@ export default class NavigationItem extends UiElement {
20
20
  * @attribute
21
21
  */
22
22
  @property({ reflect: true, type: Boolean }) accessor selected = false
23
+ /**
24
+ * When set, the navigation item is rendered as an icon-only button.
25
+ * The width of the button is set to 40px.
26
+ * @attribute
27
+ */
28
+ @property({ reflect: true, type: Boolean }) accessor iconOnly = false
23
29
 
24
30
  /**
25
31
  * Determines when the element has an icon in the "icon" slot.
@@ -37,16 +43,16 @@ export default class NavigationItem extends UiElement {
37
43
  protected override render(): TemplateResult {
38
44
  const { pressed = false } = this
39
45
  const containerClasses = classMap({
40
- button: true,
41
- icon: this.hasIcon,
42
- pressed,
46
+ 'button': true,
47
+ 'icon': this.hasIcon,
48
+ 'pressed': pressed,
49
+ 'icon-only': this.iconOnly,
43
50
  })
44
51
  return html`
45
52
  <button class="${containerClasses}" id="button">
46
53
  <md-focus-ring part="focus-ring" for="button"></md-focus-ring>
47
54
  <md-ripple></md-ripple>
48
- ${this.renderIcon()}
49
- <slot></slot>
55
+ ${this.renderIcon()}${this.iconOnly ? '' : html`<slot></slot>`}
50
56
  </button>
51
57
  `
52
58
  }
@@ -368,9 +368,10 @@ export default class UiDropdownList extends LitElement {
368
368
  }
369
369
 
370
370
  protected activate(e: Event): void {
371
- if (e.defaultPrevented) {
372
- return
373
- }
371
+ // if (e.defaultPrevented) {
372
+ // We now cancel the event in the menu list
373
+ // return
374
+ // }
374
375
  const path = e.composedPath()
375
376
  let item: HTMLElement | undefined
376
377
  while (!item) {
@@ -1,11 +1,11 @@
1
- import { html, nothing, PropertyValues, TemplateResult } from 'lit'
1
+ import { html, LitElement, nothing, PropertyValues, TemplateResult } from 'lit'
2
2
  import { property, queryAssignedElements, state } from 'lit/decorators.js'
3
3
  import { classMap } from 'lit/directives/class-map.js'
4
4
  import { ifDefined } from 'lit/directives/if-defined.js'
5
5
  import { SupportedAutocapitalize, SupportedAutocomplete, SupportedInputTypes } from '../../types/input.js'
6
6
  import { ARIAAutoComplete, ARIAExpanded, ARIARole } from '../../types/role.js'
7
- import { isDisabled, setDisabled } from '../../lib/disabled.js'
8
7
  import { UiElement } from '../UiElement.js'
8
+ import { isDisabled, setDisabled } from '../../lib/disabled.js'
9
9
  import '../icon-button/ui-icon-button.js'
10
10
  import '../icons/ui-icon.js'
11
11
 
@@ -16,6 +16,7 @@ const floatTypes = ['date', 'color', 'datetime-local', 'file', 'month', 'time',
16
16
  * @slot suffix - A slot in which to render suffixes of the input
17
17
  */
18
18
  export default abstract class Input extends UiElement {
19
+ static override shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
19
20
  #_userInteracted: boolean
20
21
 
21
22
  // static override shadowRootOptions: ShadowRootInit = {mode: 'open', delegatesFocus: true};
@@ -33,28 +34,20 @@ export default abstract class Input extends UiElement {
33
34
  */
34
35
  @property({ type: String, reflect: true }) accessor type: SupportedInputTypes
35
36
 
36
- /**
37
- * Disables the input.
38
- * @attribute
39
- */
40
37
  get disabled(): boolean {
41
38
  return isDisabled(this)
42
39
  }
43
40
 
41
+ /**
42
+ * When set, the input is a disabled state.
43
+ * @attribute
44
+ */
44
45
  @property({ reflect: true, type: Boolean })
45
46
  set disabled(value: boolean) {
46
47
  const old = isDisabled(this)
47
48
  setDisabled(this, value)
48
49
  this.requestUpdate('disabled', old)
49
50
  this._updateFormValue()
50
- if (value) {
51
- this.dataset.tabindex = this.getAttribute('tabindex') || '0'
52
- this.removeAttribute('tabindex')
53
- } else {
54
- const index = this.dataset.tabindex || '0'
55
- this.setAttribute('tabindex', index)
56
- this.blur()
57
- }
58
51
  }
59
52
 
60
53
  /**
@@ -433,8 +426,6 @@ export default abstract class Input extends UiElement {
433
426
  */
434
427
  #showPassword = false
435
428
 
436
- protected removingTabindex = false
437
-
438
429
  get files(): FileList | null {
439
430
  return this.getNativeValue('files') as FileList | null
440
431
  }
@@ -750,34 +741,14 @@ export default abstract class Input extends UiElement {
750
741
  return
751
742
  }
752
743
  this.focused = true
753
- this.removingTabindex = true
754
- // Note, the input is automatically focused when form is being submitted and
755
- // it won't pass validation. In this case the form will throw an error
756
- // as the input is not focusable. In overall, this still works with forms and validation.
757
- if (this.checkNativeValidity()) {
758
- this.dataset.tabindex = this.getAttribute('tabindex') || '0'
759
- this.removeAttribute('tabindex')
760
- }
761
- input.focus()
762
- this.#_userInteracted = true
763
- setTimeout(() => {
764
- this.removingTabindex = false
765
- }, 1)
766
744
  }
767
745
 
768
746
  protected handleBlur(): void {
769
- if (this.matches(':focus-within') || this.removingTabindex) {
747
+ if (this.matches(':focus-within')) {
770
748
  // Changing focus to another child within the text field, like a button
771
749
  return
772
750
  }
773
751
  this.focused = false
774
- const index = this.dataset.tabindex || '0'
775
- delete this.dataset.tabindex
776
- this.setAttribute('tabindex', index)
777
- }
778
-
779
- private checkNativeValidity(): boolean {
780
- return this.input?.validity.valid ?? true
781
752
  }
782
753
 
783
754
  private checkValidityAndDispatch(): { valid: boolean; canceled: boolean } {
@@ -28,6 +28,8 @@ export interface UiListItemsChange {
28
28
  * The `event.detail` object contains the `item` and `index` properties.
29
29
  * @fires itemschange - Dispatched when the list items change, e.g. when the slot changes.
30
30
  * The `event.detail` object contains the `items` property with the list of items.
31
+ * @fires highlightchange - Dispatched when the highlighted item changes.
32
+ * The `event.detail` object contains the `items` property with the list of items.
31
33
  */
32
34
  export default class UiList extends LitElement {
33
35
  /**
@@ -311,15 +313,21 @@ export default class UiList extends LitElement {
311
313
  this.activateFromEvent(event)
312
314
  }
313
315
 
314
- protected highlightItem(item?: UiListItem | null): void {
316
+ highlightItem(item?: UiListItem | null): void {
317
+ if (this.highlightListItem === item) {
318
+ return
319
+ }
315
320
  if (this.highlightListItem) {
316
321
  this.highlightListItem.classList.remove('highlight')
317
322
  }
318
323
  this.highlightListItem = item || null
319
324
  if (this.highlightListItem) {
320
325
  this.highlightListItem.classList.add('highlight')
321
- this.highlightListItem.scrollIntoView({ block: 'end', inline: 'nearest' })
326
+ this.highlightListItem.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
322
327
  }
328
+ this.dispatchEvent(
329
+ new CustomEvent('highlightchange', { bubbles: false, composed: false, detail: { item: this.highlightListItem } })
330
+ )
323
331
  }
324
332
 
325
333
  /**
@@ -345,9 +353,8 @@ export default class UiList extends LitElement {
345
353
  this.manageSelection(item)
346
354
  item.activate()
347
355
  this.activeListItem = item
348
- if (this.notifySelect(item)) {
349
- e.preventDefault()
350
- }
356
+ e.preventDefault()
357
+ this.notifySelect(item)
351
358
  }
352
359
 
353
360
  /**
@@ -1,5 +1,5 @@
1
1
  import { html, PropertyValues, TemplateResult } from 'lit'
2
- import { property, state, queryAssignedElements } from 'lit/decorators.js'
2
+ import { property, state } from 'lit/decorators.js'
3
3
  import { classMap } from 'lit/directives/class-map.js'
4
4
  import { nanoid } from 'nanoid'
5
5
  import UiList from '../../list/internals/List.js'
@@ -35,11 +35,6 @@ export default class Menu extends UiList {
35
35
  */
36
36
  @state() accessor activeSubMenu: UiSubMenu | null = null
37
37
 
38
- /**
39
- * Assigned menu items from light DOM
40
- */
41
- @queryAssignedElements({ selector: 'ui-menu-item' }) protected accessor assignedMenuItems!: UiMenuItem[]
42
-
43
38
  constructor() {
44
39
  super()
45
40
  this.selector = 'ui-menu-item'
@@ -79,13 +74,28 @@ export default class Menu extends UiList {
79
74
  return result
80
75
  }
81
76
 
77
+ protected queryMenuItems(): UiMenuItem[] {
78
+ const slot = this.shadowRoot?.querySelector('slot')
79
+ if (!slot) return []
80
+ const { selector } = this
81
+ return Array.from(slot.assignedElements({ flatten: true })).filter((el) => el.matches(selector)) as UiMenuItem[]
82
+ }
83
+
84
+ show(): void {
85
+ this.showPopover()
86
+ }
87
+
88
+ hide(): void {
89
+ this.hidePopover()
90
+ }
91
+
82
92
  /**
83
93
  * Shows the menu
84
94
  */
85
- show(): void {
95
+ override showPopover(): void {
86
96
  this.tabIndex = 0 // Make menu focusable
87
97
  this.ariaExpanded = 'true'
88
- this.showPopover()
98
+ super.showPopover()
89
99
  this.open = true
90
100
  this.positionMenu()
91
101
  this.focus()
@@ -95,10 +105,10 @@ export default class Menu extends UiList {
95
105
  /**
96
106
  * Hides the menu
97
107
  */
98
- hide(): void {
108
+ override hidePopover(): void {
99
109
  this.tabIndex = -1
100
110
  this.ariaExpanded = 'false'
101
- this.hidePopover()
111
+ super.hidePopover()
102
112
  this.open = false
103
113
  this.closeSubMenu()
104
114
  this.dispatchEvent(new CustomEvent('close', { bubbles: false, composed: true }))
@@ -215,13 +225,10 @@ export default class Menu extends UiList {
215
225
  subMenu?.addEventListener('select', this.handleSubMenuSelect as EventListener)
216
226
  }
217
227
 
218
- override notifySelect(item: UiListItem, index?: number): boolean {
228
+ override notifySelect(item: UiListItem & { selected?: boolean }, index?: number): boolean {
219
229
  // Handle single selection
220
- if (item instanceof UiMenuItem) {
221
- this.clearSelection()
222
- item.selected = true
223
- }
224
-
230
+ this.clearSelection()
231
+ item.selected = true
225
232
  this.hide()
226
233
  return super.notifySelect(item, index)
227
234
  }
@@ -230,7 +237,8 @@ export default class Menu extends UiList {
230
237
  * Clears selection from all menu items
231
238
  */
232
239
  protected clearSelection(): void {
233
- this.assignedMenuItems.forEach((menuItem) => {
240
+ const items = this.queryMenuItems()
241
+ items.forEach((menuItem) => {
234
242
  menuItem.selected = false
235
243
  })
236
244
  }
@@ -239,7 +247,8 @@ export default class Menu extends UiList {
239
247
  * Gets the currently selected menu item
240
248
  */
241
249
  get selectedItem(): UiMenuItem | null {
242
- return this.assignedMenuItems.find((item) => item.selected) || null
250
+ const items = this.queryMenuItems()
251
+ return items.find((item) => item.selected) || null
243
252
  }
244
253
 
245
254
  /**
@@ -0,0 +1,3 @@
1
+ export { UiSelectElement } from './ui-select.js'
2
+ export { UiOptionElement } from './ui-option.js'
3
+ export type { UiListItemsChange, UiListSelection } from '../list/internals/List.js'
@@ -0,0 +1,139 @@
1
+ import { css } from 'lit'
2
+
3
+ export default css`
4
+ :host {
5
+ display: block;
6
+ height: 56px;
7
+ outline: none;
8
+ cursor: default;
9
+ position: relative;
10
+
11
+ --md-focus-ring-shape-end-end: 0px;
12
+ --md-focus-ring-shape-end-start: 0px;
13
+ --md-focus-ring-shape-start-end: 0px;
14
+ --md-focus-ring-shape-start-start: 0px;
15
+
16
+ color: var(--md-sys-color-on-surface);
17
+ }
18
+
19
+ :host([disabled]) {
20
+ cursor: not-allowed;
21
+ }
22
+
23
+ :host([hidden]) {
24
+ display: none;
25
+ }
26
+
27
+ .surface {
28
+ height: inherit;
29
+ box-sizing: border-box;
30
+
31
+ display: flex;
32
+ align-items: center;
33
+ overflow: hidden;
34
+
35
+ padding: 8px 16px 8px 16px;
36
+ gap: 12px;
37
+ }
38
+
39
+ .surface.selected {
40
+ background-color: var(--md-sys-color-secondary-container);
41
+ color: var(--md-sys-color-on-secondary-container);
42
+ }
43
+
44
+ .ripple {
45
+ z-index: 3;
46
+ }
47
+
48
+ .headline {
49
+ font-family: var(--md-sys-typescale-body-large-font);
50
+ font-weight: var(--md-sys-typescale-body-large-weight);
51
+ font-size: var(--md-sys-typescale-body-large-size);
52
+ letter-spacing: var(--md-sys-typescale-body-large-tracking);
53
+ line-height: var(--md-sys-typescale-body-large-height);
54
+
55
+ overflow: hidden;
56
+ text-overflow: ellipsis;
57
+ white-space: nowrap;
58
+
59
+ display: flex;
60
+ align-items: center;
61
+ }
62
+
63
+ .supporting-text {
64
+ color: var(--md-sys-color-on-surface-variant);
65
+
66
+ font-family: var(--md-sys-typescale-body-medium-font);
67
+ font-weight: var(--md-sys-typescale-body-medium-weight);
68
+ font-size: var(--md-sys-typescale-body-medium-size);
69
+ letter-spacing: var(--md-sys-typescale-body-medium-tracking);
70
+ line-height: var(--md-sys-typescale-body-medium-height);
71
+
72
+ overflow: hidden;
73
+ }
74
+
75
+ .supporting-text ::slotted(*),
76
+ slot[name='end-text']::slotted(*) {
77
+ margin: 0;
78
+ padding: 0;
79
+ overflow: hidden;
80
+ }
81
+
82
+ slot[name='end-text']::slotted(*) {
83
+ margin-right: 8px;
84
+ margin-left: 16px;
85
+ }
86
+
87
+ :host slot[name='end']::slotted(*) {
88
+ color: var(--md-sys-color-on-surface-variant);
89
+ fill: var(--md-sys-color-on-surface-variant);
90
+ margin-left: 16px;
91
+ }
92
+
93
+ :host slot[name='start']::slotted(*) {
94
+ display: block;
95
+ margin-right: 16px;
96
+ }
97
+
98
+ :host([image='icon']) slot[name='start']::slotted(*) {
99
+ width: 24px;
100
+ height: 24px;
101
+ }
102
+
103
+ :host([image='avatar']) slot[name='start']::slotted(*) {
104
+ width: 40px;
105
+ height: 40px;
106
+ }
107
+
108
+ :host([image='image']) slot[name='start']::slotted(*) {
109
+ width: 56px;
110
+ height: 56px;
111
+ }
112
+
113
+ :host([image='video']) slot[name='start']::slotted(*) {
114
+ width: 114px;
115
+ height: 64px;
116
+ }
117
+
118
+ .body {
119
+ flex: 1;
120
+ overflow: hidden;
121
+ display: flex;
122
+ flex-direction: column;
123
+ }
124
+
125
+ .end {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 8px;
129
+ }
130
+
131
+ .selection-icon {
132
+ color: var(--md-sys-color-on-surface);
133
+ }
134
+
135
+ :host(.highlight) .surface {
136
+ background-color: var(--md-sys-color-tertiary-container);
137
+ color: var(--md-sys-color-on-tertiary-container);
138
+ }
139
+ `
@@ -0,0 +1,210 @@
1
+ import { html, PropertyValues, TemplateResult } from 'lit'
2
+ import { property } from 'lit/decorators.js'
3
+ import { classMap } from 'lit/directives/class-map.js'
4
+ import UiListItem from '../../list/internals/ListItem.js'
5
+ import { nanoid } from 'nanoid'
6
+
7
+ /**
8
+ * Material Design 3 Select Option component.
9
+ *
10
+ * The `ui-option` component represents a selectable item within a `ui-select` dropdown.
11
+ * It extends `UiListItem` to provide consistent styling and behavior with other list components.
12
+ *
13
+ * @slot - The option content (label, icon, etc.)
14
+ * @slot start - Content to display at the start of the option (icon, avatar, etc.)
15
+ * @slot end - Content to display at the end of the option
16
+ * @slot supporting-text - Supporting text below the main content
17
+ * @fires select - Dispatched when the option is selected. Contains `{ item: UiOption, value: string }` in detail
18
+ *
19
+ * @example
20
+ * Basic option
21
+ * ```html
22
+ * <ui-option value="apple">Apple</ui-option>
23
+ * ```
24
+ *
25
+ * @example
26
+ * Option with supporting text
27
+ * ```html
28
+ * <ui-option value="john" lines="two">
29
+ * John Doe
30
+ * <span slot="supporting-text">john@example.com</span>
31
+ * </ui-option>
32
+ * ```
33
+ */
34
+ export default class UiOption extends UiListItem {
35
+ /**
36
+ * The value associated with this option. Used to identify the option when selected.
37
+ * This value is what gets set on the parent select element when this option is chosen.
38
+ *
39
+ * @example
40
+ * ```html
41
+ * <ui-option value="apple">Apple</ui-option>
42
+ * <ui-option value="banana">Banana</ui-option>
43
+ * ```
44
+ */
45
+ @property({ type: String, reflect: true }) accessor value: string | undefined
46
+
47
+ /**
48
+ * Whether this option is currently selected. This is typically managed automatically
49
+ * by the parent select component, but can be set manually for styling purposes.
50
+ *
51
+ * @default false
52
+ * @example
53
+ * ```html
54
+ * <ui-option value="apple" selected>Apple</ui-option>
55
+ * ```
56
+ */
57
+ @property({ type: Boolean, reflect: true }) accessor selected = false
58
+
59
+ /**
60
+ * Returns the text representation of this option for display purposes.
61
+ * This method extracts and combines text content from all child nodes,
62
+ * including special handling for ui-icon elements.
63
+ *
64
+ * @readonly
65
+ * @returns {string} The rendered text value of the option
66
+ * @example
67
+ * ```javascript
68
+ * const option = document.querySelector('ui-option');
69
+ * console.log('Option text:', option.renderValue);
70
+ * ```
71
+ */
72
+ get renderValue(): string {
73
+ const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])')
74
+ if (!slot) return this.value || ''
75
+ const nodes = slot.assignedNodes({ flatten: true })
76
+ const content: string[] = []
77
+ for (const node of nodes) {
78
+ if (node.nodeType === Node.TEXT_NODE) {
79
+ content.push(node.textContent || '')
80
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
81
+ const element = node as HTMLElement
82
+ if (element.tagName.toLowerCase() === 'ui-icon') {
83
+ content.push(element.getAttribute('icon') || '')
84
+ } else {
85
+ content.push(element.textContent || '')
86
+ }
87
+ }
88
+ }
89
+ return content.join(' ').trim() || this.value || ''
90
+ }
91
+
92
+ /**
93
+ * Initializes the option when it's connected to the DOM. Sets up ARIA attributes
94
+ * and generates a unique ID if one isn't provided.
95
+ *
96
+ * @protected
97
+ */
98
+ override connectedCallback(): void {
99
+ super.connectedCallback()
100
+ this.setAttribute('role', 'option')
101
+ if (!this.id) {
102
+ this.id = `option-${nanoid(6)}`
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Handles property updates and triggers appropriate side effects.
108
+ * Currently monitors the `selected` property to update selection state.
109
+ *
110
+ * @param {PropertyValues<this>} changedProperties - Map of changed properties
111
+ * @protected
112
+ */
113
+ protected override updated(changedProperties: PropertyValues<this>): void {
114
+ super.updated(changedProperties)
115
+
116
+ if (changedProperties.has('selected')) {
117
+ this.updateSelectionState()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Updates the visual and accessibility state when selection changes.
123
+ * Adds/removes CSS classes and ARIA attributes based on selection state.
124
+ *
125
+ * @protected
126
+ * @example
127
+ * ```javascript
128
+ * // This is called automatically when the selected property changes
129
+ * option.selected = true; // Will trigger updateSelectionState()
130
+ * ```
131
+ */
132
+ protected updateSelectionState(): void {
133
+ if (this.selected) {
134
+ this.classList.add('selected')
135
+ this.setAttribute('aria-selected', 'true')
136
+ } else {
137
+ this.classList.remove('selected')
138
+ this.setAttribute('aria-selected', 'false')
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Handles click events on the option. Prevents default behavior and dispatches
144
+ * a custom 'select' event that the parent select component can listen to.
145
+ *
146
+ * @param {MouseEvent} e - The click event
147
+ * @protected
148
+ * @fires select - Custom event with option details in event.detail
149
+ * @example
150
+ * ```javascript
151
+ * // Listen for option selection
152
+ * option.addEventListener('select', (e) => {
153
+ * console.log('Selected option:', e.detail.item);
154
+ * console.log('Selected value:', e.detail.value);
155
+ * });
156
+ * ```
157
+ */
158
+ override handleClick(e: MouseEvent): void {
159
+ e.preventDefault()
160
+ if (this.disabled) {
161
+ e.stopPropagation()
162
+ return
163
+ }
164
+
165
+ // Let the parent handle the selection
166
+ this.dispatchEvent(
167
+ new CustomEvent('select', {
168
+ detail: {
169
+ item: this,
170
+ value: this.value,
171
+ },
172
+ bubbles: true,
173
+ composed: true,
174
+ })
175
+ )
176
+ }
177
+
178
+ /**
179
+ * Renders the option's template. Creates the main structure with focus ring,
180
+ * ripple effect, and content areas.
181
+ *
182
+ * @returns {TemplateResult} The rendered template
183
+ * @protected
184
+ */
185
+ protected override render(): TemplateResult {
186
+ const surfaceClasses = classMap({
187
+ surface: true,
188
+ selected: this.selected,
189
+ })
190
+
191
+ return html`
192
+ ${this.renderFocusRing()} ${this.renderRipple()}
193
+ <div class="${surfaceClasses}">${this.renderStart()} ${this.renderBody()} ${this.renderEnd()}</div>
194
+ `
195
+ }
196
+
197
+ /**
198
+ * Renders the end section of the option, including the selection check icon
199
+ * when the option is selected.
200
+ */
201
+ protected override renderEnd(): TemplateResult {
202
+ return html`
203
+ <div class="end">
204
+ ${this.selected ? html`<ui-icon icon="check" class="selection-icon"></ui-icon>` : ''}
205
+ <slot name="end" @slotchange=${this.handleSlotChange}></slot>
206
+ <slot name="end-text" class="trailing-supporting-text"></slot>
207
+ </div>
208
+ `
209
+ }
210
+ }