@vollowx/seele 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vollowx/seele",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Standard Extensible Elements. A web components library that can be styled and extended freely, pre-providing components in Material Design 3.",
5
5
  "author": "vollowx",
6
6
  "license": "Apache-2.0",
package/src/all.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { M3Autocomplete } from './m3/autocomplete/autocomplete.js';
1
2
  export { M3Button } from './m3/button/common-button.js';
2
3
  export { M3ButtonToggle } from './m3/button/common-button-toggle.js';
3
4
  export { M3IconButton } from './m3/button/icon-button.js';
@@ -0,0 +1,172 @@
1
+ import { _ as _ts_decorate } from "@swc/helpers/_/_ts_decorate";
2
+ import { LitElement, html } from 'lit';
3
+ import { property, query, queryAssignedElements } from 'lit/decorators.js';
4
+ import { InternalsAttached } from './mixins/internals-attached.js';
5
+ import { FocusDelegated } from './mixins/focus-delegated.js';
6
+ const Base = FocusDelegated(InternalsAttached(LitElement));
7
+ /**
8
+ * TODO: Check if manually dispatching input/change events on input is necessary
9
+ */ export class Autocomplete extends Base {
10
+ get $input() {
11
+ return this.inputSlotElements[0];
12
+ }
13
+ render() {
14
+ return html`
15
+ <slot name="input" @slotchange=${this.handleInputSlotChange}></slot>
16
+ ${this.renderMenu()}
17
+ `;
18
+ }
19
+ /**
20
+ * Example content:
21
+ *
22
+ * ```html
23
+ * <your-menu
24
+ * part="menu"
25
+ * id="menu"
26
+ * type="listbox"
27
+ * data-tabindex="-1"
28
+ * .offset=${this.offset}
29
+ * .align=${this.align}
30
+ * .alignStrategy=${this.alignStrategy}
31
+ * no-focus-control
32
+ * ?open=${this.open}
33
+ * @open="${() => (this.open = true)}"
34
+ * @close="${() => (this.open = false)}"
35
+ * @select=${this.handleMenuSelect}
36
+ * >
37
+ * <slot @slotchange=${this.handleItemsSlotChange}></slot>
38
+ * </your-menu>
39
+ * ```
40
+ */ renderMenu() {
41
+ return html``;
42
+ }
43
+ handleInputSlotChange() {
44
+ const input = this.$input;
45
+ if (!input) return;
46
+ const $realInput = this.$input.$inputOrTextarea;
47
+ if ($realInput) {
48
+ $realInput.role = 'combobox';
49
+ $realInput.ariaExpanded = String(this.open);
50
+ $realInput.ariaHasPopup = 'listbox';
51
+ $realInput.ariaAutoComplete = this.mode;
52
+ $realInput.ariaControlsElements = [
53
+ this.$menu
54
+ ];
55
+ input.addEventListener('input', this.handleInput.bind(this));
56
+ input.addEventListener('keydown', this.handleInputKeydown.bind(this));
57
+ input.addEventListener('click', ()=>this.open = !this.open);
58
+ this.$menu.attach($realInput);
59
+ }
60
+ }
61
+ handleItemsSlotChange() {
62
+ // Initial filter based on current input value (if any)
63
+ this.filterOptions(this.$input?.value || '');
64
+ }
65
+ handleInput(event) {
66
+ const inputEl = this.$input.$inputOrTextarea;
67
+ const currentValue = inputEl.value;
68
+ this.open = true;
69
+ // Filter items based on current value
70
+ const firstMatch = this.filterOptions(currentValue);
71
+ // Inline completion logic (mode = both)
72
+ if (this.mode === 'both' && event.inputType !== 'deleteContentBackward') {
73
+ if (firstMatch && currentValue.length > 0) {
74
+ this.applyInlineAutoComplete(inputEl, firstMatch, currentValue);
75
+ }
76
+ }
77
+ }
78
+ applyInlineAutoComplete(inputEl, item, typedValue) {
79
+ const suggestion = item.textContent?.trim() || '';
80
+ if (suggestion.toLowerCase().startsWith(typedValue.toLowerCase())) {
81
+ inputEl.value = suggestion;
82
+ inputEl.setSelectionRange(typedValue.length, suggestion.length);
83
+ }
84
+ }
85
+ filterOptions(searchTerm) {
86
+ if (this.mode === 'none') return null;
87
+ const normalizedSearch = searchTerm.toLowerCase();
88
+ let firstMatch = null;
89
+ this.itemSlotElements.forEach((item)=>{
90
+ const text = (item.textContent || '').toLowerCase().trim();
91
+ const isMatch = text.startsWith(normalizedSearch);
92
+ item.hidden = !isMatch;
93
+ if (isMatch && !firstMatch) {
94
+ firstMatch = item;
95
+ }
96
+ });
97
+ return firstMatch;
98
+ }
99
+ handleInputKeydown(event) {
100
+ if (this.$input?.disabled) return;
101
+ if ([
102
+ 'Enter',
103
+ 'Escape',
104
+ 'ArrowUp',
105
+ 'ArrowDown'
106
+ ].includes(event.key)) {
107
+ const eventClone = new KeyboardEvent(event.type, event);
108
+ eventClone.preventDefault = ()=>event.preventDefault();
109
+ eventClone.stopPropagation = ()=>event.stopPropagation();
110
+ this.$menu.$menu.dispatchEvent(eventClone);
111
+ if (event.key === 'Enter') this.open = false;
112
+ }
113
+ }
114
+ handleMenuSelect(event) {
115
+ const selectedItem = event.detail.item;
116
+ const newValue = selectedItem.getAttribute('value') || selectedItem.textContent?.trim() || '';
117
+ if (this.$input) {
118
+ this.$input.value = newValue;
119
+ }
120
+ this.open = false;
121
+ }
122
+ updated(changed) {
123
+ if (changed.has('open') && this.$input) {
124
+ const $input = this.$input.$inputOrTextarea;
125
+ if ($input) {
126
+ $input.ariaExpanded = String(this.open);
127
+ }
128
+ }
129
+ }
130
+ constructor(...args){
131
+ super(...args), this.open = false, this.offset = 0, this.align = 'bottom-start', this.alignStrategy = 'absolute', this.mode = 'none';
132
+ }
133
+ }
134
+ _ts_decorate([
135
+ property({
136
+ type: Boolean
137
+ })
138
+ ], Autocomplete.prototype, "open", void 0);
139
+ _ts_decorate([
140
+ property({
141
+ type: Number
142
+ })
143
+ ], Autocomplete.prototype, "offset", void 0);
144
+ _ts_decorate([
145
+ property({
146
+ reflect: true
147
+ })
148
+ ], Autocomplete.prototype, "align", void 0);
149
+ _ts_decorate([
150
+ property({
151
+ type: String,
152
+ reflect: true,
153
+ attribute: 'align-strategy'
154
+ })
155
+ ], Autocomplete.prototype, "alignStrategy", void 0);
156
+ _ts_decorate([
157
+ property()
158
+ ], Autocomplete.prototype, "mode", void 0);
159
+ _ts_decorate([
160
+ query('[part="menu"]')
161
+ ], Autocomplete.prototype, "$menu", void 0);
162
+ _ts_decorate([
163
+ queryAssignedElements({
164
+ slot: 'input',
165
+ flatten: true
166
+ })
167
+ ], Autocomplete.prototype, "inputSlotElements", void 0);
168
+ _ts_decorate([
169
+ queryAssignedElements({
170
+ flatten: true
171
+ })
172
+ ], Autocomplete.prototype, "itemSlotElements", void 0);
@@ -1,7 +1,7 @@
1
- import { getIndexByLetter } from '../menu.js';
1
+ import { filterOptions } from '../menu.js';
2
2
  export class ListController {
3
3
  constructor(host, config){
4
- this._focusedIndex = -1;
4
+ this._focusedItem = null;
5
5
  this.searchString = '';
6
6
  this.searchTimeout = null;
7
7
  const { isItem, getPossibleItems, blurItem, focusItem, wrapNavigation } = config;
@@ -18,8 +18,8 @@ export class ListController {
18
18
  return this.getPossibleItems().filter(this.isItem);
19
19
  }
20
20
  get currentIndex() {
21
- const items = this.getPossibleItems().filter(this.isItem);
22
- return items.findIndex((item)=>item.focused) ?? -1;
21
+ if (!this._focusedItem) return -1;
22
+ return this.items.indexOf(this._focusedItem);
23
23
  }
24
24
  handleType(char) {
25
25
  const searchString = this.getSearchString(char);
@@ -53,13 +53,13 @@ export class ListController {
53
53
  }
54
54
  }
55
55
  _focusItem(item) {
56
- if (this._focusedIndex !== -1) this._blurItem(this.items[this._focusedIndex]);
56
+ if (this._focusedItem !== null) this._blurItem(this._focusedItem);
57
57
  this.focusItem(item);
58
- this._focusedIndex = this.items.indexOf(item);
58
+ this._focusedItem = item;
59
59
  }
60
60
  _blurItem(item) {
61
61
  this.blurItem(item);
62
- this._focusedIndex = -1;
62
+ this._focusedItem = null;
63
63
  }
64
64
  focusFirstItem() {
65
65
  this._focusItem(this.items[0]);
@@ -68,24 +68,44 @@ export class ListController {
68
68
  this._focusItem(this.items[this.items.length - 1]);
69
69
  }
70
70
  focusNextItem() {
71
- const count = this.items.length;
71
+ const items = this.items;
72
+ const count = items.length;
72
73
  if (count === 0) return;
73
- let nextIndex = this._focusedIndex + 1;
74
+ let nextIndex = this.currentIndex + 1;
74
75
  if (nextIndex >= count) {
75
- nextIndex = this.wrapNavigation() ? count - 1 : 0;
76
+ nextIndex = this.wrapNavigation() ? 0 : count - 1;
76
77
  }
77
- this._focusItem(this.items[nextIndex]);
78
+ this._focusItem(items[nextIndex]);
78
79
  }
79
80
  focusPreviousItem() {
80
- const count = this.items.length;
81
+ const items = this.items;
82
+ const count = items.length;
81
83
  if (count === 0) return;
82
- let prevIndex = this._focusedIndex - 1;
84
+ let prevIndex = this.currentIndex - 1;
83
85
  if (prevIndex < 0) {
84
- prevIndex = this.wrapNavigation() ? 0 : count - 1;
86
+ prevIndex = this.wrapNavigation() ? count - 1 : 0;
85
87
  }
86
- this._focusItem(this.items[prevIndex]);
88
+ this._focusItem(items[prevIndex]);
87
89
  }
88
90
  handleSlotChange() {
89
- this._focusedIndex = this.currentIndex;
91
+ const items = this.items;
92
+ const index = this.currentIndex;
93
+ this._focusedItem = index >= 0 ? items[index] : null;
94
+ }
95
+ }
96
+ export function getIndexByLetter(options, filter, startIndex = 0) {
97
+ const orderedOptions = [
98
+ ...options.slice(startIndex),
99
+ ...options.slice(0, startIndex)
100
+ ];
101
+ const firstMatch = filterOptions(orderedOptions, filter)[0];
102
+ const allSameLetter = (array)=>array.every((letter)=>letter === array[0]);
103
+ if (firstMatch) {
104
+ return options.indexOf(firstMatch);
105
+ } else if (allSameLetter(filter.split(''))) {
106
+ const matches = filterOptions(orderedOptions, filter[0]);
107
+ return options.indexOf(matches[0]);
108
+ } else {
109
+ return -1;
90
110
  }
91
111
  }
package/src/base/input.js CHANGED
@@ -89,22 +89,22 @@ export class Input extends Base {
89
89
  this.dispatchEvent(newEvent);
90
90
  }
91
91
  syncValidity() {
92
- if (!this.inputOrTextarea) return;
93
- this[internals].setValidity(this.inputOrTextarea.validity, this.inputOrTextarea.validationMessage, this.inputOrTextarea);
92
+ if (!this.$inputOrTextarea) return;
93
+ this[internals].setValidity(this.$inputOrTextarea.validity, this.$inputOrTextarea.validationMessage, this.$inputOrTextarea);
94
94
  }
95
95
  select() {
96
- this.inputOrTextarea?.select();
96
+ this.$inputOrTextarea?.select();
97
97
  }
98
98
  stepUp(n) {
99
- this.inputOrTextarea?.stepUp(n);
99
+ this.$inputOrTextarea?.stepUp(n);
100
100
  this.handleInput({
101
- target: this.inputOrTextarea
101
+ target: this.$inputOrTextarea
102
102
  });
103
103
  }
104
104
  stepDown(n) {
105
- this.inputOrTextarea?.stepDown(n);
105
+ this.$inputOrTextarea?.stepDown(n);
106
106
  this.handleInput({
107
- target: this.inputOrTextarea
107
+ target: this.$inputOrTextarea
108
108
  });
109
109
  }
110
110
  formResetCallback() {
@@ -189,4 +189,4 @@ _ts_decorate([
189
189
  ], Input.prototype, "focused", void 0);
190
190
  _ts_decorate([
191
191
  query('[part~=input]')
192
- ], Input.prototype, "inputOrTextarea", void 0);
192
+ ], Input.prototype, "$inputOrTextarea", void 0);
package/src/base/menu.js CHANGED
@@ -16,8 +16,6 @@ const Base = FocusDelegated(InternalsAttached(Attachable(LitElement)));
16
16
  * @fires {Event} close - Fires when the menu is closed.
17
17
  * @fires {MenuSelectEvent} select - Fires when an item is selected.
18
18
  * @fires {MenuItemFocusEvent} item-focus - Fires when an item is focused
19
- *
20
- * FIXME: aria-activedescendant may not work in and out shadow DOM
21
19
  */ export class Menu extends Base {
22
20
  get $items() {
23
21
  return this.listController.items || [];
@@ -74,6 +72,9 @@ const Base = FocusDelegated(InternalsAttached(Attachable(LitElement)));
74
72
  }
75
73
  });
76
74
  } else {
75
+ if (!this.noFocusControl) {
76
+ this.$menu.ariaActiveDescendantElement = null;
77
+ }
77
78
  this.dispatchEvent(new Event('close', {
78
79
  bubbles: true,
79
80
  composed: true
@@ -189,7 +190,7 @@ const Base = FocusDelegated(InternalsAttached(Attachable(LitElement)));
189
190
  super(...args), this._possibleItemTags = [], this._durations = {
190
191
  show: 0,
191
192
  hide: 0
192
- }, this._scrollPadding = 0, this.type = 'menu', this.open = false, this.quick = false, this.offset = 0, this.align = 'bottom-start', this.alignStrategy = 'absolute', this.keepOpenBlur = false, this.keepOpenClickItem = false, this.keepOpenClickAway = false, this.noListControl = false, this.noFocusControl = false, this.tabIndex = 0, this.$lastFocused = null, this.popoverController = new PopoverController(this, {
193
+ }, this._scrollPadding = 0, this.type = 'menu', this.open = false, this.quick = false, this.offset = 0, this.align = 'bottom-start', this.alignStrategy = 'absolute', this.keepOpenBlur = false, this.keepOpenClickItem = false, this.keepOpenClickAway = false, this.noFocusControl = false, this.tabIndex = 0, this.$lastFocused = null, this.popoverController = new PopoverController(this, {
193
194
  popover: ()=>this.$menu,
194
195
  trigger: ()=>this.$control,
195
196
  positioning: {
@@ -206,14 +207,17 @@ const Base = FocusDelegated(InternalsAttached(Attachable(LitElement)));
206
207
  if (!this.keepOpenClickAway) this.open = false;
207
208
  }
208
209
  }), this.listController = new ListController(this, {
209
- isItem: (item)=>this._possibleItemTags.includes(item.tagName.toLowerCase()) && !item.hasAttribute('disabled'),
210
+ isItem: (item)=>this._possibleItemTags.includes(item.tagName.toLowerCase()) && !item.hasAttribute('disabled') && !item.hidden,
210
211
  getPossibleItems: ()=>this.slotItems,
211
212
  blurItem: (item)=>{
213
+ console.log(item);
212
214
  item.focused = false;
213
215
  },
214
216
  focusItem: (item)=>{
215
217
  item.focused = true;
216
- if (!this.noFocusControl) this.$menu.setAttribute('aria-activedescendant', item.id);
218
+ if (!this.noFocusControl) {
219
+ this.$menu.ariaActiveDescendantElement = item;
220
+ }
217
221
  scrollItemIntoView(this.$menu, item, this._scrollPadding);
218
222
  this.dispatchEvent(new CustomEvent('item-focus', {
219
223
  detail: {
@@ -276,12 +280,6 @@ _ts_decorate([
276
280
  attribute: 'keep-open-click-away'
277
281
  })
278
282
  ], Menu.prototype, "keepOpenClickAway", void 0);
279
- _ts_decorate([
280
- property({
281
- type: Boolean,
282
- attribute: 'no-list-control'
283
- })
284
- ], Menu.prototype, "noListControl", void 0);
285
283
  _ts_decorate([
286
284
  property({
287
285
  type: Boolean,
@@ -361,22 +359,6 @@ export function getActionFromKey(event, menuOpen) {
361
359
  }
362
360
  return undefined;
363
361
  }
364
- export function getIndexByLetter(options, filter, startIndex = 0) {
365
- const orderedOptions = [
366
- ...options.slice(startIndex),
367
- ...options.slice(0, startIndex)
368
- ];
369
- const firstMatch = filterOptions(orderedOptions, filter)[0];
370
- const allSameLetter = (array)=>array.every((letter)=>letter === array[0]);
371
- if (firstMatch) {
372
- return options.indexOf(firstMatch);
373
- } else if (allSameLetter(filter.split(''))) {
374
- const matches = filterOptions(orderedOptions, filter[0]);
375
- return options.indexOf(matches[0]);
376
- } else {
377
- return -1;
378
- }
379
- }
380
362
  export function getUpdatedIndex(currentIndex, maxIndex, action) {
381
363
  const pageSize = 10;
382
364
  switch(action){
@@ -150,6 +150,8 @@ const Base = FormAssociated(FocusDelegated(InternalsAttached(LitElement)));
150
150
  */ handleFieldKeydown(event) {
151
151
  if (this.disabled) return;
152
152
  const eventClone = new KeyboardEvent(event.type, event);
153
+ eventClone.preventDefault = ()=>event.preventDefault();
154
+ eventClone.stopPropagation = ()=>event.stopPropagation();
153
155
  this.$menu.$menu.dispatchEvent(eventClone);
154
156
  }
155
157
  handleMenuSelect(event) {
@@ -0,0 +1,2 @@
1
+ import { css } from 'lit';
2
+ export const autocompleteStyles = css`:host{-webkit-user-select:none;user-select:none;min-width:210px;display:inline-block;position:relative}::slotted([slot=input]){cursor:pointer;outline:none;width:100%}[part=menu]{--md-menu-max-height:300px;min-width:100%}`;
@@ -0,0 +1,35 @@
1
+ import { _ as _ts_decorate } from "@swc/helpers/_/_ts_decorate";
2
+ import { html } from 'lit';
3
+ import { customElement } from 'lit/decorators.js';
4
+ import { Autocomplete } from '../../base/autocomplete.js';
5
+ import { autocompleteStyles } from './autocomplete-styles.css.js';
6
+ export class M3Autocomplete extends Autocomplete {
7
+ static{
8
+ this.styles = [
9
+ autocompleteStyles
10
+ ];
11
+ }
12
+ renderMenu() {
13
+ return html`
14
+ <md-menu
15
+ part="menu"
16
+ id="menu"
17
+ type="listbox"
18
+ data-tabindex="-1"
19
+ .offset=${this.offset}
20
+ .align=${this.align}
21
+ .alignStrategy=${this.alignStrategy}
22
+ no-focus-control
23
+ ?open=${this.open}
24
+ @open="${()=>this.open = true}"
25
+ @close="${()=>this.open = false}"
26
+ @select=${this.handleMenuSelect}
27
+ >
28
+ <slot @slotchange=${this.handleItemsSlotChange}></slot>
29
+ </md-menu>
30
+ `;
31
+ }
32
+ }
33
+ M3Autocomplete = _ts_decorate([
34
+ customElement('md-autocomplete')
35
+ ], M3Autocomplete);
@@ -1,2 +1,2 @@
1
1
  import { css } from 'lit';
2
- export const listItemStyles = css`:host{--md-focus-ring-shape:12px;--md-focus-ring-inward-offset:-3px;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;border-radius:4px;outline:0;display:flex}:host(:first-of-type){border-radius:12px 12px 4px 4px}:host(:last-of-type){border-radius:4px 4px 12px 12px}:host(:state(selected)){--md-item-supporting-text-color:var(--md-sys-color-on-tertiary-container);background-color:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);border-radius:12px}:host(:disabled){cursor:default;opacity:.3;pointer-events:none}md-item,md-item div[slot=container]{border-radius:inherit}`;
2
+ export const listItemStyles = css`:host{--md-focus-ring-shape:12px;--md-focus-ring-inward-offset:-3px;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;user-select:none;border-radius:4px;outline:0;display:flex}:host(:not([hidden]):first-of-type){border-radius:12px 12px 4px 4px}:host(:not([hidden]):last-of-type){border-radius:4px 4px 12px 12px}:host(:state(selected)){--md-item-supporting-text-color:var(--md-sys-color-on-tertiary-container);background-color:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);border-radius:12px}:host(:disabled){cursor:default;opacity:.3;pointer-events:none}md-item,md-item div[slot=container]{border-radius:inherit}`;