@repobit/dex-system-design 1.0.1 → 2.0.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.
Files changed (136) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +43 -12
  3. package/package.json +4 -2
  4. package/schemas/component-manifest.json +207 -49
  5. package/schemas/manifest-omissions.json +2 -0
  6. package/src/components/accordion/accordion.css.js +12 -1
  7. package/src/components/agent-endpoints/agent-endpoints.js +1 -1
  8. package/src/components/alert/alert.css.js +22 -1
  9. package/src/components/anchor/anchor-nav.css.js +6 -1
  10. package/src/components/annotation/annotation.css.js +6 -1
  11. package/src/components/architecture.stories.js +6 -4
  12. package/src/components/avatar/avatar.js +37 -11
  13. package/src/components/avatar/avatar.stories.js +10 -6
  14. package/src/components/awards/awards-flex.css.js +31 -129
  15. package/src/components/awards/awards-flex.js +66 -116
  16. package/src/components/awards/awards-flex.stories.js +8 -6
  17. package/src/components/back/back.css.js +6 -1
  18. package/src/components/back/back.js +24 -12
  19. package/src/components/back/back.stories.js +3 -3
  20. package/src/components/badge/badge.js +6 -3
  21. package/src/components/badge/badge.stories.js +13 -9
  22. package/src/components/bento-grid/bento-grid.js +1 -1
  23. package/src/components/blockquote/blockquote.css.js +12 -3
  24. package/src/components/blockquote/blockquote.js +12 -5
  25. package/src/components/blockquote/blockquote.stories.js +6 -4
  26. package/src/components/button/button.css.js +46 -25
  27. package/src/components/button/button.js +44 -43
  28. package/src/components/button/button.stories.js +2 -2
  29. package/src/components/caption/caption.css.js +10 -0
  30. package/src/components/caption/caption.stories.js +4 -4
  31. package/src/components/carousel/carousel.css.js +19 -116
  32. package/src/components/carousel/carousel.js +70 -74
  33. package/src/components/carousel/carousel.stories.js +9 -8
  34. package/src/components/checkbox/checkbox.css.js +6 -1
  35. package/src/components/code/code.css.js +6 -1
  36. package/src/components/code/code.js +48 -7
  37. package/src/components/code/code.stories.js +7 -4
  38. package/src/components/divider/divider-horizontal.js +11 -3
  39. package/src/components/divider/divider-vertical.js +12 -5
  40. package/src/components/divider/divider.css.js +4 -1
  41. package/src/components/divider/divider.stories.js +6 -6
  42. package/src/components/dropdown/dropdown.js +67 -13
  43. package/src/components/dropdown/dropdown.stories.js +15 -14
  44. package/src/components/fieldset/fieldset.stories.js +41 -16
  45. package/src/components/footer/footer-nav-menu.css.js +6 -1
  46. package/src/components/footer/footer.css.js +6 -1
  47. package/src/components/header/header-code.js +1 -1
  48. package/src/components/header/header.css.js +9 -4
  49. package/src/components/heading/heading.stories.js +64 -31
  50. package/src/components/highlight/highlight.js +74 -34
  51. package/src/components/highlight/highlight.stories.js +5 -4
  52. package/src/components/icons/icon-element.js +49 -6
  53. package/src/components/icons/icons.stories.js +7 -0
  54. package/src/components/image/image.css.js +9 -98
  55. package/src/components/image/image.js +7 -2
  56. package/src/components/input/custom-form.stories.js +14 -19
  57. package/src/components/input/input-clipboard.css.js +6 -1
  58. package/src/components/input/input-clipboard.js +56 -8
  59. package/src/components/input/input.js +19 -2
  60. package/src/components/label/label.js +87 -1
  61. package/src/components/label/label.stories.js +21 -12
  62. package/src/components/link/link.css.js +52 -3
  63. package/src/components/link/link.js +52 -17
  64. package/src/components/link/link.stories.js +114 -3
  65. package/src/components/list/list-item/list-item.css.js +4 -6
  66. package/src/components/list/list-item/list-item.js +53 -5
  67. package/src/components/list/list-item/list-item.stories.js +22 -0
  68. package/src/components/list/list.js +27 -0
  69. package/src/components/modal/modal.css.js +6 -1
  70. package/src/components/pagination-dots/pagination-dots.css.js +161 -0
  71. package/src/components/pagination-dots/pagination-dots.js +202 -0
  72. package/src/components/pagination-dots/pagination-dots.stories.js +171 -0
  73. package/src/components/paragraph/paragraph.css.js +37 -1
  74. package/src/components/paragraph/paragraph.js +19 -2
  75. package/src/components/paragraph/paragraph.stories.js +40 -6
  76. package/src/components/picture/picture.css.js +8 -67
  77. package/src/components/picture/picture.js +15 -8
  78. package/src/components/pricing-cards/pricing-card.css.js +12 -2
  79. package/src/components/pricing-cards/pricing-card.js +4 -4
  80. package/src/components/progress/progress.js +24 -4
  81. package/src/components/progress/progress.stories.js +8 -6
  82. package/src/components/radio/radio.css.js +6 -1
  83. package/src/components/radio/radio.js +48 -3
  84. package/src/components/radio/radio.stories.js +1 -1
  85. package/src/components/shared/form-field.css.js +30 -4
  86. package/src/components/shared/token-scales.js +16 -0
  87. package/src/components/spinner/spinner.css.js +31 -4
  88. package/src/components/spinner/spinner.js +26 -4
  89. package/src/components/spinner/spinner.stories.js +10 -7
  90. package/src/components/switch/switch.css.js +21 -3
  91. package/src/components/switch/switch.js +8 -1
  92. package/src/components/switch/switch.stories.js +2 -1
  93. package/src/components/tab-list/tab-list.css.js +6 -1
  94. package/src/components/terminal/terminal-cursor.js +11 -0
  95. package/src/components/terminal/terminal-message.js +1 -1
  96. package/src/components/termsOfUse/terms.css.js +6 -1
  97. package/src/components/textarea/textarea.stories.js +23 -0
  98. package/src/components/tooltip/tooltip.css.js +24 -1
  99. package/src/components/tooltip/tooltip.js +135 -5
  100. package/src/components/tooltip/tooltip.stories.js +20 -14
  101. package/src/components/trust-panel/trust-panel.js +2 -2
  102. package/src/components/video/video.js +38 -3
  103. package/src/components/video/video.stories.js +10 -5
  104. package/src/components/video-player/video-player.css.js +6 -1
  105. package/src/tokens/elevation.stories.js +2 -3
  106. package/src/tokens/focus.stories.js +81 -15
  107. package/src/tokens/motion.stories.js +0 -6
  108. package/src/tokens/tokens.css +174 -97
  109. package/src/tokens/tokens.js +174 -97
  110. package/src/tokens/vertical-rhythm.stories.js +1 -1
  111. package/src/stories/Button.js +0 -20
  112. package/src/stories/Button.stories.js +0 -46
  113. package/src/stories/Configure.mdx +0 -364
  114. package/src/stories/Header.js +0 -45
  115. package/src/stories/Header.stories.js +0 -24
  116. package/src/stories/Page.js +0 -62
  117. package/src/stories/Page.stories.js +0 -20
  118. package/src/stories/assets/accessibility.png +0 -0
  119. package/src/stories/assets/accessibility.svg +0 -1
  120. package/src/stories/assets/addon-library.png +0 -0
  121. package/src/stories/assets/assets.png +0 -0
  122. package/src/stories/assets/avif-test-image.avif +0 -0
  123. package/src/stories/assets/context.png +0 -0
  124. package/src/stories/assets/discord.svg +0 -1
  125. package/src/stories/assets/docs.png +0 -0
  126. package/src/stories/assets/figma-plugin.png +0 -0
  127. package/src/stories/assets/github.svg +0 -1
  128. package/src/stories/assets/share.png +0 -0
  129. package/src/stories/assets/styling.png +0 -0
  130. package/src/stories/assets/testing.png +0 -0
  131. package/src/stories/assets/theming.png +0 -0
  132. package/src/stories/assets/tutorials.svg +0 -1
  133. package/src/stories/assets/youtube.svg +0 -1
  134. package/src/stories/button.css +0 -30
  135. package/src/stories/header.css +0 -32
  136. package/src/stories/page.css +0 -68
@@ -22,16 +22,19 @@ unknown. When you know the proportion done, use \`<bd-progress>\` instead.
22
22
  ### Props
23
23
  | Prop | Values | Default |
24
24
  |---|---|---|
25
- | \`size\` | \`small\`, \`default\`, \`large\` | \`default\` |
25
+ | \`size\` | \`sm\`, \`md\`, \`lg\` — \`small\` / \`default\` / \`large\` still resolve | \`md\` |
26
26
  | \`label\` | the accessible name | \`Loading\` |
27
27
 
28
28
  ### Accessibility
29
- It carries \`role="status"\` with \`aria-label\` from \`label\`, so the wait is
30
- announced politely rather than interrupting. Give it a specific \`label\` where
29
+ \`label\` is the text of a visually hidden \`role="status"\` region, so the wait
30
+ is announced politely rather than interrupting. The text arrives just after
31
+ the region does, which is what lets a screen reader treat it as news, and a
32
+ later change to \`label\` is announced too. Give it a specific \`label\` where
31
33
  you can — "Checking your licence" is more use than the default "Loading".
32
34
 
33
- Under \`prefers-reduced-motion\` it stops spinning and pulses instead, so it
34
- still reads as active without rotating.
35
+ Under \`prefers-reduced-motion\` it stops spinning: the arc stays, still and
36
+ at full contrast, and the disc behind it pulses, so it still reads as active
37
+ without rotating and never fades below 3:1.
35
38
 
36
39
  ### Note on colour
37
40
  The spinner's track and head are fixed at \`--color-neutral-200\` and
@@ -47,9 +50,9 @@ let the two converge.
47
50
  argTypes: {
48
51
  size: {
49
52
  control : { type: 'select' },
50
- options : ['small', 'default', 'large'],
53
+ options : ['sm', 'md', 'lg'],
51
54
  description: 'Diameter of the spinner.',
52
- table : { type: { summary: 'string' }, defaultValue: { summary: "default" }, category: 'Appearance' }
55
+ table : { type: { summary: 'string' }, defaultValue: { summary: 'md' }, category: 'Appearance' }
53
56
  },
54
57
  label: {
55
58
  control : 'text',
@@ -58,10 +58,22 @@ export const switchCSS = css`
58
58
  flex: none;
59
59
  }
60
60
 
61
+ /* The thumb's inset and travel are derived from the two boxes they sit
62
+ between, in px like those boxes. They were --spacing-2 and --spacing-16,
63
+ which are rem, inside a track that is --dimension-* and so is not: at a
64
+ 24px root the thumb rode 3px in and travelled 24px, and finished at 47px
65
+ in a 40px track. A knob has to stay on the track it belongs to at any text
66
+ size, so all four lengths are now one unit. Inset is half the height left
67
+ over; travel is the width left over after both insets: 2px and 16px. */
68
+ .track {
69
+ --bd-switch-inset: calc((var(--dimension-24) - var(--dimension-20)) / 2);
70
+ --bd-switch-travel: calc(var(--dimension-40) - var(--dimension-20) - 2 * var(--bd-switch-inset));
71
+ }
72
+
61
73
  .thumb {
62
74
  position: absolute;
63
- top: var(--spacing-2);
64
- left: var(--spacing-2);
75
+ top: var(--bd-switch-inset);
76
+ left: var(--bd-switch-inset);
65
77
  width: var(--dimension-20);
66
78
  height: var(--dimension-20);
67
79
  /* the knob on the track, white on both tracks: --color-action-on-fill,
@@ -81,13 +93,19 @@ export const switchCSS = css`
81
93
  If a second component ever needs it, that is when it earns a name. */
82
94
  label:hover .track { background: var(--color-neutral-500); }
83
95
  :host([checked]) label:hover .track { background: var(--color-action-primary-hover); }
84
- :host([checked]) .thumb { transform: translateX(var(--spacing-16)); }
96
+ :host([checked]) .thumb { transform: translateX(var(--bd-switch-travel)); }
85
97
 
86
98
  /* the one focus ring — see Foundations/Focus. On the row rather than the
87
99
  track, so the target and the indicator are the same shape here as on
88
100
  bd-checkbox and bd-radio. */
89
101
  label:has(input:focus-visible) {
90
102
  box-shadow: var(--focus-outline-primary);
103
+ /* forced-colors mode does not paint box-shadow, so the ring
104
+ vanishes there; it does paint an outline, in a system colour. This one
105
+ is transparent everywhere else, and the offset lands it on the ring's
106
+ outer band. */
107
+ outline: var(--border-width-2) solid transparent;
108
+ outline-offset: var(--focus-outline-offset);
91
109
  border-radius: var(--focus-outline-radius);
92
110
  }
93
111
 
@@ -31,7 +31,14 @@ export class BdSwitch extends LitElement {
31
31
  ?disabled=${this.disabled}
32
32
  @change=${e => {
33
33
  this.checked = e.target.checked;
34
- this.dispatchEvent(new CustomEvent('bd-change', { detail: this.checked }));
34
+ // bubbles and composed, as every sibling form atom's event is: with
35
+ // neither, it reached a listener on the switch and nothing above
36
+ // it, so a form wrapper delegating bd-change heard nothing
37
+ this.dispatchEvent(new CustomEvent('bd-change', {
38
+ detail : this.checked,
39
+ bubbles : true,
40
+ composed: true
41
+ }));
35
42
  }}
36
43
  />
37
44
  <span class="track"><span class="thumb"></span></span>
@@ -33,7 +33,8 @@ Content can also come from the default slot, which renders after \`label\` — s
33
33
  set one or the other, not both.
34
34
 
35
35
  ### Events
36
- \`bd-change\` — fires on toggle with \`detail\` set to the new boolean.
36
+ \`bd-change\` — fires on toggle with \`detail\` set to the new boolean. It bubbles and
37
+ is composed, so a form wrapper can listen for it on itself.
37
38
 
38
39
  ### Accessibility
39
40
  It is a real \`<input type="checkbox">\` with \`role="switch"\`, inside a
@@ -111,7 +111,12 @@ export const tabListCSS = css`
111
111
 
112
112
  .bd-tab-button:focus-visible {
113
113
  /* the one focus ring — see Foundations/Focus */
114
- outline: none;
114
+ /* forced-colors mode does not paint box-shadow, so the ring
115
+ vanishes there; it does paint an outline, in a system colour. This one
116
+ is transparent everywhere else, and the offset lands it on the ring's
117
+ outer band. */
118
+ outline: var(--border-width-2) solid transparent;
119
+ outline-offset: var(--focus-outline-offset);
115
120
  box-shadow: var(--focus-outline-primary);
116
121
  border-radius: var(--focus-outline-radius);
117
122
  }
@@ -32,6 +32,17 @@ class TerminalCursor extends LitElement {
32
32
  opacity: 0;
33
33
  }
34
34
  }
35
+
36
+ /* A cursor that blinks for as long as the page is open is exactly the
37
+ motion the setting exists for, and this one had no guard: every
38
+ other animated element in the package stops or softens under
39
+ prefers-reduced-motion. Here it stops, and the cursor stays shown --
40
+ a steady block still says "this is where the terminal is". */
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .bd-terminal-cursor {
43
+ animation: none;
44
+ }
45
+ }
35
46
  `
36
47
  ];
37
48
 
@@ -160,7 +160,7 @@ class TerminalMessage extends LitElement {
160
160
  ${this.items && this.items.length
161
161
  ? html`
162
162
  <bd-list class="bd-terminalmsg-list">
163
- ${this.items.map((item) => html`<bd-li variant="bullet" size="sm" color="var(--color-neutral-0)">${item}</bd-li>`)}
163
+ ${this.items.map((item) => html`<bd-li variant="bullet" size="sm" color="inverse">${item}</bd-li>`)}
164
164
  </bd-list>
165
165
  `
166
166
  : nothing}
@@ -12,7 +12,12 @@ export default css`
12
12
 
13
13
  .bd-terms-text-a:focus-visible{
14
14
  /* the one focus ring — see Foundations/Focus */
15
- outline: none;
15
+ /* forced-colors mode does not paint box-shadow, so the ring
16
+ vanishes there; it does paint an outline, in a system colour. This one
17
+ is transparent everywhere else, and the offset lands it on the ring's
18
+ outer band. */
19
+ outline: var(--border-width-2) solid transparent;
20
+ outline-offset: var(--focus-outline-offset);
16
21
  box-shadow: var(--focus-outline-primary);
17
22
  border-radius: var(--focus-outline-radius);
18
23
  }
@@ -119,6 +119,11 @@ The vertical resize handle is left enabled, so a user can grow the field beyond
119
119
  control : 'boolean',
120
120
  description: 'Blocks editing.',
121
121
  table : { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'State' }
122
+ },
123
+ name: {
124
+ control : 'text',
125
+ description: 'Carried on the bd-change detail as { value, name }, for your own submit handling. NOT submitted with a form — the <textarea> is in a shadow root.',
126
+ table : { type: { summary: 'string' }, defaultValue: { summary: "''" }, category: 'Content' }
122
127
  }
123
128
  }
124
129
  };
@@ -127,6 +132,24 @@ export const Default = () => html`
127
132
  <bd-textarea label="What happened?" placeholder="Tell us what happened"></bd-textarea>
128
133
  `;
129
134
 
135
+ /**
136
+ * `name` reaches no form -- the <textarea> is in this element's shadow root --
137
+ * so it is carried on the event instead. Type, then click away: the line
138
+ * below shows the bd-change detail as it arrives.
139
+ */
140
+ export const NameOnTheEvent = {
141
+ name : 'name on the event',
142
+ render: () => html`
143
+ <bd-textarea
144
+ label="What happened?"
145
+ name="incident"
146
+ rows="2"
147
+ @bd-change=${e => { e.currentTarget.nextElementSibling.textContent = `bd-change detail: ${JSON.stringify(e.detail)}`; }}
148
+ ></bd-textarea>
149
+ <p style="font-family: var(--typography-fontFamily-mono); font-size: var(--typography-fontSize-sm)">bd-change detail: (type, then click away)</p>
150
+ `
151
+ };
152
+
130
153
  export const Sizes = {
131
154
  name : 'All Sizes',
132
155
  render: () => html`
@@ -64,8 +64,31 @@ export const tooltipCSS = css`
64
64
  transition: opacity var(--transition-duration-fast) var(--transition-easing-smooth);
65
65
  }
66
66
 
67
+ /* Shown means reachable. WCAG 1.4.13 asks that the pointer can move onto
68
+ content that appeared on hover without it disappearing. With
69
+ pointer-events: none left on, the pointer passed through the bubble to
70
+ whatever lay beneath it, the host lost :hover, and the bubble hid. The
71
+ bubble is in this shadow tree, so hovering it keeps :host(:hover). */
67
72
  :host(:hover) .bubble,
68
- :host(:focus-within) .bubble { opacity: 1; }
73
+ :host(:focus-within) .bubble { opacity: 1; pointer-events: auto; }
74
+
75
+ /* Escape hides it while the pointer or focus is still there; see the note
76
+ on _showing in tooltip.js. Same specificity as the rule above and later,
77
+ so it wins, and it takes pointer events away with the opacity. */
78
+ :host(:hover) .bubble--dismissed,
79
+ :host(:focus-within) .bubble--dismissed { opacity: 0; pointer-events: none; }
80
+
81
+ /* The bubble floats --spacing-8 above the trigger, and the pointer has to
82
+ cross that gap to reach it: this fills it, so the host stays hovered
83
+ across the way. It inherits pointer-events from the bubble, so it catches
84
+ nothing while the bubble is hidden. */
85
+ .bubble::after {
86
+ content: "";
87
+ position: absolute;
88
+ inset-inline: 0;
89
+ top: 100%;
90
+ bottom: calc(-1 * var(--spacing-8));
91
+ }
69
92
 
70
93
  @media (prefers-reduced-motion: reduce) {
71
94
  .bubble { transition: none; }
@@ -1,18 +1,24 @@
1
- import { LitElement, html } from 'lit';
1
+ import { LitElement, html, nothing } from 'lit';
2
2
  // Installs the design tokens into document scope; see src/tokens/tokens.js
3
3
  import '../../tokens/tokens.js';
4
4
  import { tooltipCSS } from './tooltip.css.js';
5
5
 
6
+ let descriptionIds = 0;
7
+
6
8
  export class BdTooltip extends LitElement {
7
9
  static styles = [tooltipCSS];
8
10
 
9
11
  static properties = {
10
- text: { type: String }
12
+ text : { type: String },
13
+ /* Set by Escape and cleared once neither the pointer nor focus is on the
14
+ tooltip any more, so the next hover or focus shows it again. */
15
+ _dismissed: { type: Boolean, state: true }
11
16
  };
12
17
 
13
18
  constructor() {
14
19
  super();
15
20
  this.text = '';
21
+ this._dismissed = false;
16
22
  }
17
23
 
18
24
  /* The bubble is centred on its trigger and is wider than it, so near a
@@ -119,7 +125,31 @@ export class BdTooltip extends LitElement {
119
125
  Both boxes, because they move for different reasons: the host changes
120
126
  when the trigger resizes, the bubble when its own text reflows.
121
127
  ResizeObserver fires once on observe, which is the settled first pass. */
122
- if (typeof ResizeObserver === 'undefined') return;
128
+ this._observe();
129
+ }
130
+
131
+ /* ESCAPE. WCAG 1.4.13 asks that content shown on hover or focus can be
132
+ dismissed without moving the pointer or focus. The pointer may be over the
133
+ trigger while focus is anywhere on the page, so the key is heard on the
134
+ document, and only acted on while this bubble is actually showing.
135
+
136
+ Capture phase, and the event is marked handled with preventDefault rather
137
+ than stopped: a tooltip is the innermost thing that can be open, so its
138
+ Escape should not also close a native <dialog> it sits in, and a dialog's
139
+ cancel is exactly what preventDefault on that keydown withholds.
140
+ Propagation is left alone, as bd-modal leaves it, so a page that wants to
141
+ hear the key still does. */
142
+ get _showing() {
143
+ return !this._dismissed && (this.matches(':hover') || this.matches(':focus-within'));
144
+ }
145
+
146
+ /* Started on first render and again on every reconnect. It used to start
147
+ only in firstUpdated, and disconnectedCallback tears it down, so a tooltip
148
+ moved in the DOM -- removed and re-inserted, which a framework reordering
149
+ a list does -- came back with no observer and never re-placed itself when
150
+ its trigger or text changed size. */
151
+ _observe() {
152
+ if (typeof ResizeObserver === 'undefined' || this._ro) return;
123
153
  this._ro = new ResizeObserver(() => this._place());
124
154
  this._ro.observe(this);
125
155
  const bubble = this.renderRoot?.querySelector('.bubble');
@@ -128,27 +158,127 @@ export class BdTooltip extends LitElement {
128
158
 
129
159
  connectedCallback() {
130
160
  super.connectedCallback();
161
+ // a first connection is handled by firstUpdated, once the bubble exists
162
+ if (this.hasUpdated) this._observe();
131
163
  this._onShow = () => this._place();
132
164
  /* Both reveals the stylesheet already honours, and resize because the
133
165
  measurement is only true for the width it was taken at. */
134
166
  this.addEventListener('mouseenter', this._onShow);
135
167
  this.addEventListener('focusin', this._onShow);
136
168
  window.addEventListener('resize', this._onShow);
169
+
170
+ this._onKeydown = e => {
171
+ if (e.key !== 'Escape' || e.defaultPrevented || !this._showing) return;
172
+ e.preventDefault();
173
+ this._dismissed = true;
174
+ };
175
+ /* Back to normal once the pointer and focus have both gone: a pointer
176
+ leaving while focus stays inside keeps it dismissed, and the reverse. */
177
+ this._onLeave = e => {
178
+ if (!this._dismissed) return;
179
+ const pointerGone = e.type === 'mouseleave' || !this.matches(':hover');
180
+ const focusGone = e.type === 'focusout'
181
+ ? !this.contains(e.relatedTarget) && !this.shadowRoot.contains(e.relatedTarget)
182
+ : !this.matches(':focus-within');
183
+ if (pointerGone && focusGone) this._dismissed = false;
184
+ };
185
+ document.addEventListener('keydown', this._onKeydown, true);
186
+ this.addEventListener('mouseleave', this._onLeave);
187
+ this.addEventListener('focusout', this._onLeave);
137
188
  }
138
189
 
139
190
  disconnectedCallback() {
140
191
  this.removeEventListener('mouseenter', this._onShow);
141
192
  this.removeEventListener('focusin', this._onShow);
142
193
  window.removeEventListener('resize', this._onShow);
194
+ document.removeEventListener('keydown', this._onKeydown, true);
195
+ this.removeEventListener('mouseleave', this._onLeave);
196
+ this.removeEventListener('focusout', this._onLeave);
143
197
  this._ro?.disconnect();
144
198
  this._ro = undefined;
145
199
  super.disconnectedCallback();
146
200
  }
147
201
 
202
+ /* DESCRIBING THE TRIGGER.
203
+ The bubble had role="tooltip" and nothing pointed at it, so a screen
204
+ reader landing on the trigger heard its name and not the tooltip, and
205
+ the bubble's text sat in the page as a stray line. It cannot be pointed
206
+ at directly: the bubble is in this shadow root and the trigger is not,
207
+ and an id reference never crosses that boundary. So the trigger is
208
+ described from its own side:
209
+
210
+ a native control aria-describedby names a hidden copy of the text,
211
+ kept as a child of this element in a hidden slot --
212
+ the same tree as the trigger, so the reference
213
+ resolves
214
+ bd-button, the text goes to its description property, which
215
+ bd-button-link, wires aria-describedby inside its own shadow root,
216
+ bd-link where its real control is
217
+
218
+ Once described, the bubble is hidden from assistive technology so the
219
+ text is not read twice. Any other custom element as trigger keeps the
220
+ bubble exposed and goes undescribed -- its control is out of reach. */
221
+ _describe() {
222
+ this._undescribe();
223
+ const slot = this.shadowRoot?.querySelector('slot');
224
+ const trigger = slot?.assignedElements()[0];
225
+ if (!trigger || !this.text) return;
226
+
227
+ // a custom element that has not upgraded yet has no description to set:
228
+ // come back once it has
229
+ if (trigger.localName.includes('-') && !customElements.get(trigger.localName)) {
230
+ customElements.whenDefined(trigger.localName).then(() => this._describe());
231
+ return;
232
+ }
233
+
234
+ if ('description' in trigger && trigger.localName.includes('-')) {
235
+ trigger.description = this.text;
236
+ this._described = { trigger, kind: 'property' };
237
+ } else if (!trigger.localName.includes('-')) {
238
+ const node = document.createElement('span');
239
+ node.id = `bd-tooltip-description-${++descriptionIds}`;
240
+ node.slot = 'description';
241
+ node.hidden = true;
242
+ node.textContent = this.text;
243
+ this.append(node);
244
+ const ids = (trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
245
+ trigger.setAttribute('aria-describedby', [...ids, node.id].join(' '));
246
+ this._described = { trigger, kind: 'idref', node };
247
+ }
248
+ this.requestUpdate();
249
+ }
250
+
251
+ _undescribe() {
252
+ const d = this._described;
253
+ if (!d) return;
254
+ if (d.kind === 'property') {
255
+ d.trigger.description = '';
256
+ } else {
257
+ const ids = (d.trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(t => t && t !== d.node.id);
258
+ if (ids.length) d.trigger.setAttribute('aria-describedby', ids.join(' '));
259
+ else d.trigger.removeAttribute('aria-describedby');
260
+ d.node.remove();
261
+ }
262
+ this._described = null;
263
+ this.requestUpdate();
264
+ }
265
+
266
+ updated(changed) {
267
+ if (changed.has('text') && this.hasUpdated) this._describe();
268
+ }
269
+
148
270
  render() {
149
271
  return html`
150
- <slot></slot>
151
- <span class="bubble" role="tooltip">${this.text}</span>
272
+ <slot @slotchange=${() => this._describe()}></slot>
273
+ <!-- the trigger's description: hidden, but slotted, because a node
274
+ assigned to no slot is not in the flat tree and Chrome then drops
275
+ an aria-describedby that points at it -->
276
+ <span hidden><slot name="description"></slot></span>
277
+ <span
278
+ class="bubble ${this._dismissed ? 'bubble--dismissed' : ''}"
279
+ role="tooltip"
280
+ aria-hidden="${this._described ? 'true' : nothing}"
281
+ >${this.text}</span>
152
282
  `;
153
283
  }
154
284
  }
@@ -33,23 +33,29 @@ Anything required to complete a task belongs in visible text or in
33
33
  \`<bd-help-text>\`, because a tooltip is unreachable on touch, invisible in
34
34
  print, and gone the moment the pointer leaves.
35
35
 
36
- ### Accessibility caveats
37
- The bubble carries \`role="tooltip"\`, but this element does **not** wire up
38
- \`aria-describedby\` between the trigger and the bubble. A screen reader
39
- therefore has no link from the control to its description, so the tooltip text
40
- may never be announced.
36
+ ### Accessibility
37
+ The trigger is described by the tooltip: a screen reader reads the \`text\`
38
+ after the trigger's name. How depends on the trigger, because the bubble sits
39
+ in this element's shadow root and an \`aria-describedby\` cannot point into it:
41
40
 
42
- Until that is connected, describe the trigger directly as well:
41
+ - **a native control** — \`<button>\`, \`<a>\`, \`<input>\` — gets
42
+ \`aria-describedby\` naming a hidden copy of the text kept inside the
43
+ tooltip, added to any ids it already lists and removed when it leaves;
44
+ - **\`bd-button\`, \`bd-button-link\` and \`bd-link\`** get the text through
45
+ their \`description\` property, which wires the description inside their own
46
+ shadow root, where the real control is;
47
+ - **any other custom element** cannot be reached, so it is not described and
48
+ the bubble stays exposed instead. Prefer a native control, a
49
+ \`bd-button\` or a \`bd-link\` as the trigger.
43
50
 
44
- \`\`\`html
45
- <bd-tooltip text="Blocks trackers">
46
- <bd-button aria-describedby="trackers-hint">What is this?</bd-button>
47
- </bd-tooltip>
48
- <span id="trackers-hint" hidden>Blocks trackers across every browser</span>
49
- \`\`\`
51
+ Once the trigger is described, the bubble itself is hidden from assistive
52
+ technology, so the text is not read twice.
50
53
 
51
- There is also no dismiss-on-Escape and no delay before showing, both of which
52
- WCAG 1.4.13 asks of content that appears on hover.
54
+ The bubble meets WCAG 1.4.13. It is hoverable: the pointer can move from the
55
+ trigger onto the bubble, across the gap between them, and it stays open. It is
56
+ dismissible: Escape hides it without moving the pointer or focus, and it shows
57
+ again on the next hover or focus. That Escape is marked handled, so a native
58
+ \`<dialog>\` the tooltip sits in stays open on the same keypress.
53
59
  `
54
60
  }
55
61
  }
@@ -36,7 +36,7 @@ class TrustPanel extends LitElement {
36
36
  return html`
37
37
  <div class="bd-trustpanel-card">
38
38
  ${card.eyebrow
39
- ? html`<bd-p variant="regular" font-weight="semibold" class="bd-trustpanel-card-eyebrow">${card.eyebrow}</bd-p>`
39
+ ? html`<bd-p variant="eyebrow" class="bd-trustpanel-card-eyebrow">${card.eyebrow}</bd-p>`
40
40
  : nothing}
41
41
 
42
42
  <div class="bd-trustpanel-card-logos">
@@ -66,7 +66,7 @@ class TrustPanel extends LitElement {
66
66
  return html`
67
67
  <div class="bd-ensemble bd-trustpanel-wrapper">
68
68
  ${this.eyebrow
69
- ? html`<bd-p variant="regular" font-weight="semibold" class="bd-trustpanel-eyebrow"><slot name="eyebrow">${this.eyebrow}</slot></bd-p>`
69
+ ? html`<bd-p variant="eyebrow" class="bd-trustpanel-eyebrow"><slot name="eyebrow">${this.eyebrow}</slot></bd-p>`
70
70
  : nothing}
71
71
 
72
72
  ${this.title
@@ -9,9 +9,13 @@ export class BdVideo extends LitElement {
9
9
  static properties = {
10
10
  src : { type: String },
11
11
  poster : { type: String },
12
- controls: { type: Boolean },
12
+ /* On unless told otherwise -- see the story's Notes. A default-on boolean
13
+ cannot be turned off by leaving the attribute out, so the attribute
14
+ reads controls="false" as off; the property takes false as it is. */
15
+ controls: { converter: { fromAttribute: value => value !== 'false' } },
13
16
  muted : { type: Boolean },
14
- loop : { type: Boolean }
17
+ loop : { type: Boolean },
18
+ _media : { state: true }
15
19
  };
16
20
 
17
21
  constructor() {
@@ -21,8 +25,38 @@ export class BdVideo extends LitElement {
21
25
  this.controls = true;
22
26
  this.muted = false;
23
27
  this.loop = false;
28
+ this._media = [];
24
29
  }
25
30
 
31
+ /* WHY THE TRACKS ARE COPIED IN.
32
+ This rendered <video><slot></slot></video>, and a media element builds
33
+ its text tracks and sources only from real <track> and <source> children
34
+ -- a slot is neither, so the captions the story told readers to slot in
35
+ never reached the player. The <track> and <source> children are now
36
+ copied into the <video> itself, and copied again whenever they change. */
37
+ connectedCallback() {
38
+ super.connectedCallback();
39
+ this._collect();
40
+ this._observer = new MutationObserver(() => this._collect());
41
+ this._observer.observe(this, { childList: true, subtree: true, attributes: true });
42
+ }
43
+
44
+ disconnectedCallback() {
45
+ this._observer?.disconnect();
46
+ super.disconnectedCallback();
47
+ }
48
+
49
+ _collect() {
50
+ this._media = [...this.children]
51
+ .filter(el => el.localName === 'track' || el.localName === 'source')
52
+ .map(el => el.cloneNode(true));
53
+ }
54
+
55
+ /* muted is a property, not only an attribute. The attribute is read once,
56
+ when the media loads -- it is what lets a muted video autoplay -- and
57
+ changing it afterwards mutes nothing, so muted = true on a playing video
58
+ left the sound on. Both are set: the attribute for the load, the
59
+ property for every change after it. */
26
60
  render() {
27
61
  return html`
28
62
  <video
@@ -30,9 +64,10 @@ export class BdVideo extends LitElement {
30
64
  poster=${this.poster || nothing}
31
65
  ?controls=${this.controls}
32
66
  ?muted=${this.muted}
67
+ .muted=${this.muted}
33
68
  ?loop=${this.loop}
34
69
  playsinline
35
- ><slot></slot></video>
70
+ >${this._media}</video>
36
71
  `;
37
72
  }
38
73
  }
@@ -29,9 +29,10 @@ export default {
29
29
  | \`muted\` | boolean | \`false\` |
30
30
  | \`loop\` | boolean | \`false\` |
31
31
 
32
- ### Captions go in the slot
33
- Anything slotted is passed through into the \`<video>\`, which is how you supply
34
- \`<track>\` elements. **Recorded video needs captions** — WCAG 1.2.2 at level A —
32
+ ### Captions go inside the element
33
+ \`<track>\` and \`<source>\` children are copied into the \`<video>\` — a media
34
+ element only reads tracks and sources from its own children — and copied again
35
+ when they change. That is how you supply captions. **Recorded video needs captions** — WCAG 1.2.2 at level A —
35
36
  and this element does nothing to add them for you.
36
37
 
37
38
  ### Notes
@@ -41,7 +42,11 @@ screen.
41
42
  \`controls\` defaults to \`true\` on purpose: a video a user cannot pause is a
42
43
  2.2.2 failure the moment it plays for more than five seconds. If you turn
43
44
  controls off, the video must also be \`muted\` and \`loop\` and behave as
44
- decoration.
45
+ decoration. In HTML that is \`controls="false"\`: leaving the attribute out
46
+ cannot turn off a default that is on. From script, \`.controls = false\`.
47
+
48
+ \`muted\` follows the property as well as the attribute, so changing it on a
49
+ playing video takes effect.
45
50
 
46
51
  There is no \`autoplay\` prop, deliberately — autoplay with sound is both
47
52
  disruptive and blocked by most browsers anyway.
@@ -91,7 +96,7 @@ export const Default = () => html`
91
96
  * them.
92
97
  */
93
98
  export const Decorative = () => html`
94
- <bd-video poster="/images/bd-header-img.jpg" ?controls=${false} muted loop></bd-video>
99
+ <bd-video poster="/images/bd-header-img.jpg" controls="false" muted loop></bd-video>
95
100
  `;
96
101
 
97
102
  /**
@@ -124,7 +124,12 @@ export default css`
124
124
  --radius-lg corner and the close button is a circle. */
125
125
  .bd-video-thumb:focus-visible,
126
126
  .bd-video-close:focus-visible {
127
- outline: none;
127
+ /* forced-colors mode does not paint box-shadow, so the ring
128
+ vanishes there; it does paint an outline, in a system colour. This one
129
+ is transparent everywhere else, and the offset lands it on the ring's
130
+ outer band. */
131
+ outline: var(--border-width-2) solid transparent;
132
+ outline-offset: var(--focus-outline-offset);
128
133
  box-shadow: var(--focus-outline-primary);
129
134
  }
130
135
 
@@ -75,15 +75,14 @@ export const Shadows = () => {
75
75
  const surface = all.filter(token => !token.includes('focus'));
76
76
  const rings = all.filter(token => token.includes('focus'));
77
77
 
78
- const ladder = ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl', '--shadow-2xl'];
78
+ const ladder = ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl'];
79
79
 
80
80
  return html`
81
81
  ${elevationStyles}
82
82
  ${docsPage({
83
83
  title: 'Elevation',
84
84
  lede : html`Shadows are the only elevation mechanism in the system — there is no z-index
85
- scale. <code>sm</code> through <code>2xl</code> form a ladder;
86
- <code>--shadow-inner</code> is an inset press state.`,
85
+ scale. <code>sm</code> through <code>xl</code> form a ladder.`,
87
86
  note : referenceNote,
88
87
  children: html`
89
88
  ${refSection({