@reliability-design/web 1.4.4 → 1.4.6

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 (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -270,6 +270,9 @@ export class RelAccordion extends LitElement {
270
270
  }
271
271
  `;
272
272
 
273
+ /** Stable id prefix wiring the header to its panel. */
274
+ private accordionId = `rel-accordion-${Math.random().toString(36).slice(2)}`;
275
+
273
276
  private toggle() {
274
277
  this.open = !this.open;
275
278
  this.dispatchEvent(
@@ -286,9 +289,11 @@ export class RelAccordion extends LitElement {
286
289
  <div part="base" class="accordion">
287
290
  <div
288
291
  part="trigger" class="header"
292
+ id="${this.accordionId}-header"
289
293
  role="button"
290
294
  tabindex="0"
291
295
  aria-expanded="${this.open}"
296
+ aria-controls="${this.accordionId}-body"
292
297
  @click=${this.toggle}
293
298
  @keydown=${(e: KeyboardEvent) => {
294
299
  if (e.key === "Enter" || e.key === " ") {
@@ -315,7 +320,14 @@ export class RelAccordion extends LitElement {
315
320
  </slot>
316
321
  </div>
317
322
  </div>
318
- <div part="content" class="body ${this.open ? "open" : "closed"}">
323
+ <div
324
+ part="content"
325
+ class="body ${this.open ? "open" : "closed"}"
326
+ id="${this.accordionId}-body"
327
+ role="region"
328
+ aria-labelledby="${this.accordionId}-header"
329
+ ?inert=${!this.open}
330
+ >
319
331
  <slot></slot>
320
332
  </div>
321
333
  </div>
@@ -278,7 +278,7 @@ export class RelAlert extends LitElement {
278
278
 
279
279
  render() {
280
280
  return html`
281
- <div part="base" class="rel-alert alert-${this.type}" role="alert">
281
+ <div part="base" class="rel-alert alert-${this.type}" role="alert" aria-atomic="true">
282
282
  <div part="icon" class="rel-icon">${this.iconTemplate}</div>
283
283
 
284
284
  <div part="content" class="rel-content">
@@ -9,6 +9,7 @@ import type {
9
9
  TextFieldVariant,
10
10
  FieldLabelStyle,
11
11
  } from "../rel-textfield/rel-textfield.js";
12
+ import { announce } from "../../utils/announce";
12
13
 
13
14
  /** An item is a plain string or any object read through displayField/valueField. */
14
15
  export type AutocompleteItem = string | Record<string, unknown>;
@@ -246,6 +247,15 @@ export class RelAutocomplete extends LitElement {
246
247
  this.internalValueWrite = false;
247
248
  }
248
249
 
250
+ private wasLoading = false;
251
+
252
+ protected updated() {
253
+ if (this.open && this.isLoading && !this.wasLoading) {
254
+ announce("Loading suggestions");
255
+ }
256
+ this.wasLoading = this.isLoading;
257
+ }
258
+
249
259
  /** Full normalized source list (remote results while searchFn drives, else items). */
250
260
  private get allItems(): NormalizedItem[] {
251
261
  const source = this.searchFn && this.remoteItems !== null ? this.remoteItems : this.items;
@@ -290,13 +300,15 @@ export class RelAutocomplete extends LitElement {
290
300
  // pointerdown and on any outside scroll instead of re-anchoring) ──
291
301
 
292
302
  private addGlobalListeners() {
293
- document.addEventListener("pointerdown", this.onOutsidePointerDown);
303
+ // Capture phase so a pointerdown that another component stops in its own
304
+ // capture listener (e.g. a rel-popover trigger) still closes this panel.
305
+ document.addEventListener("pointerdown", this.onOutsidePointerDown, true);
294
306
  window.addEventListener("resize", this.updatePosition);
295
307
  window.addEventListener("scroll", this.onPageScroll, true);
296
308
  }
297
309
 
298
310
  private removeGlobalListeners() {
299
- document.removeEventListener("pointerdown", this.onOutsidePointerDown);
311
+ document.removeEventListener("pointerdown", this.onOutsidePointerDown, true);
300
312
  window.removeEventListener("resize", this.updatePosition);
301
313
  window.removeEventListener("scroll", this.onPageScroll, true);
302
314
  }
@@ -433,6 +445,17 @@ export class RelAutocomplete extends LitElement {
433
445
  this.debounceTimer = setTimeout(() => this.runSearch(this.inputText), this.debounceMs);
434
446
  } else {
435
447
  this.syncPanel();
448
+ this.announceResults();
449
+ }
450
+ }
451
+
452
+ private announceResults() {
453
+ if (!this.open || this.isLoading) return;
454
+ const n = this.filteredItems.length;
455
+ if (n === 0) {
456
+ if (this.inputText) announce(this.noResultsText);
457
+ } else {
458
+ announce(`${n} result${n === 1 ? "" : "s"} available`);
436
459
  }
437
460
  }
438
461
 
@@ -452,6 +475,7 @@ export class RelAutocomplete extends LitElement {
452
475
  if (seq === this.searchSeq) {
453
476
  this.searching = false;
454
477
  this.syncPanel();
478
+ this.announceResults();
455
479
  }
456
480
  }
457
481
  }
@@ -523,11 +547,13 @@ export class RelAutocomplete extends LitElement {
523
547
  return;
524
548
  }
525
549
  this.highlightedIndex = Math.min(filtered.length - 1, this.highlightedIndex + 1);
550
+ this.announceHighlighted(filtered);
526
551
  break;
527
552
  case "ArrowUp":
528
553
  e.preventDefault();
529
554
  if (!this.open) return;
530
555
  this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
556
+ this.announceHighlighted(filtered);
531
557
  break;
532
558
  case "Enter":
533
559
  if (this.open && this.highlightedIndex >= 0 && filtered[this.highlightedIndex]) {
@@ -552,6 +578,11 @@ export class RelAutocomplete extends LitElement {
552
578
  }
553
579
  }
554
580
 
581
+ private announceHighlighted(filtered: NormalizedItem[]) {
582
+ const item = filtered[this.highlightedIndex];
583
+ if (item) announce(`${item.label}, ${this.highlightedIndex + 1} of ${filtered.length}`);
584
+ }
585
+
555
586
  private onFocusIn() {
556
587
  if (this.disabled || this.readonly) return;
557
588
  if (this.queryLongEnough && (this.filteredItems.length > 0 || this.minSearchLength === 0)) {
@@ -142,6 +142,11 @@ export class RelBadge extends LitElement {
142
142
  return ["badge", `badge-${this.size}`, `badge-${this.variant}`].join(" ");
143
143
  }
144
144
 
145
+ private get computedAriaLabel() {
146
+ if (this.ariaLabel) return this.ariaLabel;
147
+ return this.number ? `${this.number} ${this.suffix}` : `New ${this.suffix}`;
148
+ }
149
+
145
150
  override updated(changedProperties: Map<PropertyKey, unknown>): void {
146
151
  if (changedProperties.has("color")) {
147
152
  if (this.color) {
@@ -162,7 +167,7 @@ export class RelBadge extends LitElement {
162
167
 
163
168
  render() {
164
169
  return html`
165
- <span part="base" class="${this.classes}" aria-label=${this.ariaLabel || `${this.number} ${this.suffix}`}>
170
+ <span part="base" class="${this.classes}" role="status" aria-label=${this.computedAriaLabel}>
166
171
  ${this.variant === "number" ? this.number : null}
167
172
  </span>
168
173
  `;
@@ -137,9 +137,12 @@ export class RelButton extends LitElement {
137
137
  }
138
138
  }
139
139
 
140
- /* Sizes */
140
+ /* Sizes. Min-widths come from the Figma Button master (S/M/L =
141
+ 80/100/120) so short labels like "OK" keep a proper pill shape;
142
+ --rel-button-min-width lets a consumer override per instance. */
141
143
  .size-small {
142
144
  height: 32px;
145
+ min-width: var(--rel-button-min-width, 80px);
143
146
  padding: 0 var(--rel-spacing-sm);
144
147
  font-family: var(--rel-action-md-font-family);
145
148
  font-weight: var(--rel-action-md-font-weight);
@@ -150,6 +153,7 @@ export class RelButton extends LitElement {
150
153
 
151
154
  .size-medium {
152
155
  height: 40px;
156
+ min-width: var(--rel-button-min-width, 100px);
153
157
  gap: var(--rel-spacing-xs);
154
158
  padding: 0 var(--rel-spacing-md);
155
159
  font-family: var(--rel-action-lg-font-family);
@@ -161,6 +165,7 @@ export class RelButton extends LitElement {
161
165
 
162
166
  .size-large {
163
167
  height: 48px;
168
+ min-width: var(--rel-button-min-width, 120px);
164
169
  padding: 0 var(--rel-spacing-lg);
165
170
  font-family: var(--rel-action-xl-font-family);
166
171
  font-weight: var(--rel-action-xl-font-weight);
@@ -267,6 +272,11 @@ export class RelButton extends LitElement {
267
272
  color: var(--rel-action-neutral-label-icons-ghost);
268
273
  }
269
274
 
275
+ /* Figma master: ghost S has a narrower 56px min than primary/outline. */
276
+ .btn-text.size-small {
277
+ min-width: var(--rel-button-min-width, 56px);
278
+ }
279
+
270
280
  .btn-text.tone-critical {
271
281
  color: var(--rel-action-danger-label-icons-ghost);
272
282
  }
@@ -275,6 +285,8 @@ export class RelButton extends LitElement {
275
285
  background: transparent;
276
286
  color: var(--rel-action-neutral-label-icons-link);
277
287
  text-decoration: underline;
288
+ /* Links are inline actions — no minimum footprint in the Figma master. */
289
+ min-width: 0;
278
290
  }
279
291
 
280
292
  button.btn-link:hover:not(:disabled) {
@@ -303,6 +315,7 @@ export class RelButton extends LitElement {
303
315
  /* Icon Only as an independent axis: square button of the current style */
304
316
  .btn-icon-only {
305
317
  padding: 0;
318
+ min-width: 0;
306
319
  }
307
320
 
308
321
  .btn-icon-only.size-small {
@@ -321,6 +334,7 @@ export class RelButton extends LitElement {
321
334
  width: 40px;
322
335
  height: 40px;
323
336
  padding: 0;
337
+ min-width: 0;
324
338
  border-radius: 50%;
325
339
  }
326
340
  .btn-icon.tone-default {
@@ -365,7 +379,9 @@ export class RelButton extends LitElement {
365
379
  .btn-text: ghost gets tone-accent hovers below (this generic grey overlay
366
380
  has higher specificity than the per-tone ghost rules and was overriding
367
381
  them, which is why every ghost hover looked grey). */
368
- button:hover:not(:disabled):not(.btn-primary):not(.btn-default):not(.btn-icon):not(.btn-secondary):not(.btn-static):not(.btn-text) {
382
+ /* Link buttons change label color only (rule above) — the overlay fill
383
+ here would make a hovered link read as a ghost button. */
384
+ button:hover:not(:disabled):not(.btn-primary):not(.btn-default):not(.btn-icon):not(.btn-secondary):not(.btn-static):not(.btn-text):not(.btn-link) {
369
385
  background: var(--rel-action-overlay-hover);
370
386
  }
371
387
 
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
+ import { announce } from "../../utils/announce";
3
4
 
4
5
  interface CalendarDay {
5
6
  date: Date;
@@ -99,6 +100,13 @@ export class RelCalendar extends LitElement {
99
100
  gap: var(--rel-spacing-2xs);
100
101
  }
101
102
 
103
+ .calendar-week {
104
+ grid-column: 1 / -1;
105
+ display: grid;
106
+ grid-template-columns: repeat(7, 1fr);
107
+ gap: var(--rel-spacing-2xs);
108
+ }
109
+
102
110
  .weekday {
103
111
  text-align: center;
104
112
  color: var(--rel-text-placeholder);
@@ -315,6 +323,26 @@ export class RelCalendar extends LitElement {
315
323
  return formatter.format(new Date(this.viewYear, this.viewMonth, 1));
316
324
  }
317
325
 
326
+ private dayLabelFormatter: Intl.DateTimeFormat | null = null;
327
+ private dayLabelLocale = "";
328
+
329
+ private formatDayLabel(date: Date): string {
330
+ if (!this.dayLabelFormatter || this.dayLabelLocale !== this.locale) {
331
+ this.dayLabelLocale = this.locale;
332
+ this.dayLabelFormatter = new Intl.DateTimeFormat(this.locale, {
333
+ weekday: "long",
334
+ month: "long",
335
+ day: "numeric",
336
+ year: "numeric",
337
+ });
338
+ }
339
+ return this.dayLabelFormatter.format(date);
340
+ }
341
+
342
+ private announceMonth() {
343
+ announce(`${this.getMonthName()} ${this.viewYear}`);
344
+ }
345
+
318
346
  private previousMonth() {
319
347
  if (this.viewMonth === 0) {
320
348
  this.viewMonth = 11;
@@ -322,6 +350,7 @@ export class RelCalendar extends LitElement {
322
350
  } else {
323
351
  this.viewMonth--;
324
352
  }
353
+ this.announceMonth();
325
354
  }
326
355
 
327
356
  private nextMonth() {
@@ -331,6 +360,7 @@ export class RelCalendar extends LitElement {
331
360
  } else {
332
361
  this.viewMonth++;
333
362
  }
363
+ this.announceMonth();
334
364
  }
335
365
 
336
366
  private selectDay(day: CalendarDay) {
@@ -385,6 +415,7 @@ export class RelCalendar extends LitElement {
385
415
  e.preventDefault();
386
416
  if (e.shiftKey) {
387
417
  this.viewYear--;
418
+ this.announceMonth();
388
419
  } else {
389
420
  this.previousMonth();
390
421
  }
@@ -393,6 +424,7 @@ export class RelCalendar extends LitElement {
393
424
  e.preventDefault();
394
425
  if (e.shiftKey) {
395
426
  this.viewYear++;
427
+ this.announceMonth();
396
428
  } else {
397
429
  this.nextMonth();
398
430
  }
@@ -426,12 +458,38 @@ export class RelCalendar extends LitElement {
426
458
  }
427
459
  }
428
460
 
461
+ /**
462
+ * Index of the single grid cell carrying tabindex="0". The old expression
463
+ * (`isSelected || (index === 0 && !focusedDate)`) left the grid with ZERO
464
+ * tab stops once the user arrowed around without a selection — Tab could
465
+ * never re-enter the calendar.
466
+ */
467
+ private getRovingIndex(days: CalendarDay[]): number {
468
+ const candidates = [
469
+ this.focusedDate
470
+ ? days.findIndex(
471
+ (d) => d.isCurrentMonth && this.isSameDay(d.date, this.focusedDate!)
472
+ )
473
+ : -1,
474
+ days.findIndex((d) => d.isSelected),
475
+ days.findIndex((d) => d.isCurrentMonth && d.isToday),
476
+ days.findIndex((d) => d.isCurrentMonth && !d.isDisabled),
477
+ ];
478
+ const found = candidates.find((i) => i >= 0);
479
+ return found ?? 0;
480
+ }
481
+
429
482
  render() {
430
483
  const days = this.getCalendarDays();
431
484
  const weekdayLabels = this.getWeekdayLabels();
485
+ const rovingIndex = this.getRovingIndex(days);
486
+ const weeks: CalendarDay[][] = [];
487
+ for (let i = 0; i < days.length; i += 7) {
488
+ weeks.push(days.slice(i, i + 7));
489
+ }
432
490
 
433
491
  return html`
434
- <div role="grid" aria-label="Calendar">
492
+ <div>
435
493
  <div part="header" class="header">
436
494
  <button
437
495
  part="action" class="nav-button"
@@ -461,6 +519,7 @@ export class RelCalendar extends LitElement {
461
519
  </button>
462
520
  </div>
463
521
 
522
+ <div role="grid" aria-label="${this.getMonthName()} ${this.viewYear}">
464
523
  <div part="list" class="calendar-grid" role="row">
465
524
  ${weekdayLabels.map(
466
525
  (label) => html`
@@ -471,26 +530,35 @@ export class RelCalendar extends LitElement {
471
530
  )}
472
531
  </div>
473
532
 
474
- <div part="list" class="calendar-grid">
475
- ${days.map(
476
- (day, index) => html`
477
- <button
478
- part="item" class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
479
- ? "today"
480
- : ""} ${!day.isCurrentMonth ? "other-month" : ""} ${day.isWeekend ? "weekend" : ""}"
481
- role="gridcell"
482
- aria-label="${this.formatISODate(day.date)}"
483
- aria-selected="${day.isSelected}"
484
- ?disabled=${day.isDisabled}
485
- @click=${() => this.selectDay(day)}
486
- @keydown=${(e: KeyboardEvent) => this.handleKeyDown(e, day, index)}
487
- tabindex="${day.isSelected || (index === 0 && !this.focusedDate) ? 0 : -1}"
488
- >
489
- ${day.day}
490
- </button>
533
+ <div part="list" class="calendar-grid" role="rowgroup">
534
+ ${weeks.map(
535
+ (week, weekIndex) => html`
536
+ <div class="calendar-week" role="row">
537
+ ${week.map((day, dayIndex) => {
538
+ const index = weekIndex * 7 + dayIndex;
539
+ return html`
540
+ <button
541
+ part="item" class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
542
+ ? "today"
543
+ : ""} ${!day.isCurrentMonth ? "other-month" : ""} ${day.isWeekend ? "weekend" : ""}"
544
+ role="gridcell"
545
+ aria-label="${this.formatDayLabel(day.date)}"
546
+ aria-selected="${day.isSelected}"
547
+ aria-current=${day.isToday ? "date" : nothing}
548
+ ?disabled=${day.isDisabled}
549
+ @click=${() => this.selectDay(day)}
550
+ @keydown=${(e: KeyboardEvent) => this.handleKeyDown(e, day, index)}
551
+ tabindex="${index === rovingIndex ? 0 : -1}"
552
+ >
553
+ ${day.day}
554
+ </button>
555
+ `;
556
+ })}
557
+ </div>
491
558
  `
492
559
  )}
493
560
  </div>
561
+ </div>
494
562
  </div>
495
563
  `;
496
564
  }
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { announce } from "../../utils/announce";
3
4
 
4
5
  @customElement("rel-carousel")
5
6
  export class RelCarousel extends LitElement {
@@ -41,6 +42,7 @@ export class RelCarousel extends LitElement {
41
42
  // Number of clone slides prepended before the real slides
42
43
  private clonesBefore = 0;
43
44
  private _silentJumping = false;
45
+ private _autoplayTick = false;
44
46
  private _ro?: ResizeObserver;
45
47
 
46
48
  static styles = css`
@@ -251,6 +253,11 @@ export class RelCarousel extends LitElement {
251
253
  "(prefers-reduced-motion: reduce)"
252
254
  ).matches;
253
255
 
256
+ // WCAG 2.2.2: auto-advance must pause while keyboard focus is inside the
257
+ // carousel, not just on mouse hover.
258
+ this.addEventListener("focusin", this.handleFocusIn);
259
+ this.addEventListener("focusout", this.handleFocusOut);
260
+
254
261
  if (this.autoplay) {
255
262
  this.startAutoplay();
256
263
  }
@@ -285,6 +292,8 @@ export class RelCarousel extends LitElement {
285
292
 
286
293
  disconnectedCallback() {
287
294
  super.disconnectedCallback();
295
+ this.removeEventListener("focusin", this.handleFocusIn);
296
+ this.removeEventListener("focusout", this.handleFocusOut);
288
297
  this._ro?.disconnect();
289
298
  this.stopAutoplay();
290
299
  if (this.animationId) {
@@ -300,6 +309,12 @@ export class RelCarousel extends LitElement {
300
309
  const elements = slot.assignedElements();
301
310
  this.slideCount = elements.length;
302
311
 
312
+ elements.forEach((el, i) => {
313
+ el.setAttribute("role", "group");
314
+ el.setAttribute("aria-roledescription", "slide");
315
+ el.setAttribute("aria-label", `${i + 1} of ${elements.length}`);
316
+ });
317
+
303
318
  // Clamp value to valid real-slide range
304
319
  if (this.value >= this.slideCount) this.value = Math.max(0, this.slideCount - 1);
305
320
  if (this.value < 0) this.value = 0;
@@ -390,9 +405,14 @@ export class RelCarousel extends LitElement {
390
405
  this.currentTranslate = translate;
391
406
  this.prevTranslate = translate;
392
407
  this.track!.style.transform = this.axisTransform(translate);
408
+ const fromAutoplay = this._autoplayTick;
393
409
  this.track!.addEventListener(
394
410
  "transitionend",
395
- () => this.silentJump(this.slideCount - 1),
411
+ () => {
412
+ this._autoplayTick = fromAutoplay;
413
+ this.silentJump(this.slideCount - 1);
414
+ this._autoplayTick = false;
415
+ },
396
416
  { once: true }
397
417
  );
398
418
  }
@@ -412,9 +432,14 @@ export class RelCarousel extends LitElement {
412
432
  this.currentTranslate = translate;
413
433
  this.prevTranslate = translate;
414
434
  this.track!.style.transform = this.axisTransform(translate);
435
+ const fromAutoplay = this._autoplayTick;
415
436
  this.track!.addEventListener(
416
437
  "transitionend",
417
- () => this.silentJump(0),
438
+ () => {
439
+ this._autoplayTick = fromAutoplay;
440
+ this.silentJump(0);
441
+ this._autoplayTick = false;
442
+ },
418
443
  { once: true }
419
444
  );
420
445
  }
@@ -445,6 +470,9 @@ export class RelCarousel extends LitElement {
445
470
  }
446
471
 
447
472
  private emitChange() {
473
+ if (!this._autoplayTick && this.slideCount > 0) {
474
+ announce(`Slide ${this.value + 1} of ${this.slideCount}`);
475
+ }
448
476
  this.dispatchEvent(
449
477
  new CustomEvent("rel-change", {
450
478
  detail: { value: this.value },
@@ -459,7 +487,9 @@ export class RelCarousel extends LitElement {
459
487
 
460
488
  this.stopAutoplay();
461
489
  this.autoplayTimer = window.setInterval(() => {
490
+ this._autoplayTick = true;
462
491
  this.goToNext();
492
+ this._autoplayTick = false;
463
493
  }, this.interval);
464
494
  }
465
495
 
@@ -556,6 +586,18 @@ export class RelCarousel extends LitElement {
556
586
  }
557
587
  }
558
588
 
589
+ private handleFocusIn = () => {
590
+ if (this.autoplay) this.stopAutoplay();
591
+ };
592
+
593
+ private handleFocusOut = (e: FocusEvent) => {
594
+ if (!this.autoplay) return;
595
+ const next = e.relatedTarget as Node | null;
596
+ // Resume only once focus has fully left the carousel (light or shadow).
597
+ if (next && (this.contains(next) || this.shadowRoot?.contains(next))) return;
598
+ this.startAutoplay();
599
+ };
600
+
559
601
  private handleMouseEnter() {
560
602
  if (this.autoplay && this.pauseOnHover) {
561
603
  this.stopAutoplay();
@@ -667,6 +709,7 @@ export class RelCarousel extends LitElement {
667
709
  class="dot ${i === this.value ? "active" : ""}"
668
710
  part="dot"
669
711
  aria-label="Go to slide ${i + 1}"
712
+ aria-current="${i === this.value ? "true" : nothing}"
670
713
  @click=${() => this.goToSlide(i)}
671
714
  ></button>
672
715
  `)
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css, type PropertyValues } from "lit";
1
+ import { LitElement, html, css, nothing, type PropertyValues } from "lit";
2
2
  import { customElement, property } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
 
@@ -334,6 +334,9 @@ export class RelCheckbox extends LitElement {
334
334
  <label class="row">
335
335
  <input
336
336
  type="checkbox"
337
+ .checked=${this.status === "checked"}
338
+ .indeterminate=${this.status === "indeterminate"}
339
+ aria-checked="${this.status === "indeterminate" ? "mixed" : nothing}"
337
340
  @click=${this.toggle}
338
341
  @blur=${() => this._onTouched()}
339
342
  ?disabled=${this.state === "disabled"}
@@ -2,6 +2,7 @@ import { LitElement, html, css } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import "../rel-avatar/rel-avatar.js";
4
4
  import "@reliability-design/icons/rel-icon";
5
+ import { announce } from "../../utils/announce";
5
6
 
6
7
  export type ChipTone = "neutral" | "danger" | "warning" | "success" | "info";
7
8
  export type ChipVariant = "fill" | "subtle";
@@ -76,6 +77,13 @@ export class RelChip extends LitElement {
76
77
  transition: background-color 0.15s ease;
77
78
  }
78
79
 
80
+ .chip-action {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: var(--rel-spacing-xs);
84
+ outline: none;
85
+ }
86
+
79
87
  /* ── Size ── */
80
88
  :host([size="lg"]) .chip {
81
89
  height: 32px;
@@ -262,7 +270,7 @@ export class RelChip extends LitElement {
262
270
  outward by strokeWeight — outline-offset must be 0, not a positive
263
271
  gap (a positive offset was the bug: it drew a visible gap that
264
272
  doesn't exist in Figma). ── */
265
- .chip:focus-visible {
273
+ .chip:has(.chip-action:focus-visible) {
266
274
  outline: 1.5px solid var(--rel-chip-focus-ring, var(--rel-border-focus));
267
275
  outline-offset: 1.5px;
268
276
  box-shadow: 0 0 0 1.5px var(--rel-border-focus-inner);
@@ -363,8 +371,37 @@ export class RelChip extends LitElement {
363
371
  composed: true,
364
372
  })
365
373
  );
374
+ announce(`${this.label} removed`);
366
375
  }
367
376
 
377
+ /** The chip body is announced as a button, so it must also act on keys:
378
+ * Enter/Space re-dispatch as a host click (consumers' @click handlers
379
+ * fire), Delete/Backspace remove when removable, and arrow keys ask the
380
+ * container to reorder a draggable chip (`chip-move`) — the keyboard
381
+ * alternative to pointer drag-and-drop. */
382
+ private handleKeydown = (e: KeyboardEvent) => {
383
+ if (e.key === "Enter" || e.key === " ") {
384
+ e.preventDefault();
385
+ this.click();
386
+ return;
387
+ }
388
+ if ((e.key === "Delete" || e.key === "Backspace") && this.removable) {
389
+ e.preventDefault();
390
+ this.handleRemove(e);
391
+ return;
392
+ }
393
+ if (this.draggableChip && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
394
+ e.preventDefault();
395
+ this.dispatchEvent(
396
+ new CustomEvent("chip-move", {
397
+ detail: { direction: e.key === "ArrowLeft" ? -1 : 1 },
398
+ bubbles: true,
399
+ composed: true,
400
+ })
401
+ );
402
+ }
403
+ };
404
+
368
405
  private onDragStart = (e: DragEvent) => {
369
406
  if (!this.draggableChip) return;
370
407
  this.dragging = true;
@@ -404,24 +441,27 @@ export class RelChip extends LitElement {
404
441
  }
405
442
 
406
443
  return html`
407
- <span
408
- part="base" class="${this.classes}"
409
- role="button"
410
- tabindex="0"
411
- aria-label="${this.label}"
412
- >
413
- ${showAvatar
414
- ? html`<rel-avatar type="text" text=${this.avatar} size="xs"></rel-avatar>`
415
- : null}
416
- ${showLeadingIcon
417
- ? html`<rel-icon part="icon" class="chip-icon" name="${this.leadingIcon}"></rel-icon>`
418
- : null}
419
-
420
- <span part="label" class="chip-label">${this.label}</span>
421
-
422
- ${this.trailingIcon
423
- ? html`<rel-icon class="chip-icon" name="${this.trailingIcon}"></rel-icon>`
424
- : null}
444
+ <span part="base" class="${this.classes}">
445
+ <span
446
+ class="chip-action"
447
+ role="button"
448
+ tabindex="0"
449
+ aria-label="${this.label}"
450
+ @keydown=${this.handleKeydown}
451
+ >
452
+ ${showAvatar
453
+ ? html`<rel-avatar type="text" text=${this.avatar} size="xs"></rel-avatar>`
454
+ : null}
455
+ ${showLeadingIcon
456
+ ? html`<rel-icon part="icon" class="chip-icon" name="${this.leadingIcon}"></rel-icon>`
457
+ : null}
458
+
459
+ <span part="label" class="chip-label">${this.label}</span>
460
+
461
+ ${this.trailingIcon
462
+ ? html`<rel-icon class="chip-icon" name="${this.trailingIcon}"></rel-icon>`
463
+ : null}
464
+ </span>
425
465
  ${this.removable
426
466
  ? html`<button
427
467
  class="chip-remove"