@repobit/dex-system-design 1.0.1 → 2.0.1

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 +89 -0
  2. package/README.md +43 -12
  3. package/package.json +6 -4
  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
@@ -21,52 +21,92 @@ class BdHighlightSection extends LitElement {
21
21
  this.as = "h3";
22
22
  }
23
23
 
24
+ /* The headline is built from the light DOM, so a change there -- text set
25
+ after upgrade, children a framework adds later, an edit -- has to render
26
+ again. Rendering never touches the light DOM, so this cannot loop. */
27
+ connectedCallback() {
28
+ super.connectedCallback();
29
+ this._observer = new MutationObserver(() => this.requestUpdate());
30
+ this._observer.observe(this, { childList: true, characterData: true, subtree: true });
31
+ }
32
+
33
+ disconnectedCallback() {
34
+ this._observer?.disconnect();
35
+ super.disconnectedCallback();
36
+ }
37
+
38
+ /* The headline with the phrase marked, or null when it cannot be.
39
+
40
+ This used to split this.textContent and render the halves as plain
41
+ strings, so the moment the phrase matched, every element in the heading
42
+ was gone from the page: an <em>, a <strong>, a <br>, a link -- measured,
43
+ a slotted <a href> stopped existing anywhere in the rendered tree. Now
44
+ the light DOM is copied node for node, markup and all, and only the
45
+ text node holding the first occurrence is split around the mark.
46
+
47
+ A copy, not the consumer's own nodes, so the page's DOM is never
48
+ rearranged under it. The cost is that what renders is a clone: markup,
49
+ attributes and links survive, but a listener a script attached to one of
50
+ those elements does not. A phrase that crosses an element boundary --
51
+ "family <em>member</em>" -- is in no single text node, so it is not
52
+ marked and the content renders through the slot as it is. */
53
+ _markedHeadline(phrase) {
54
+ const copy = document.createDocumentFragment();
55
+ for (const node of this.childNodes) copy.append(node.cloneNode(true));
56
+
57
+ const walker = document.createTreeWalker(copy, NodeFilter.SHOW_TEXT);
58
+ for (let text = walker.nextNode(); text; text = walker.nextNode()) {
59
+ const at = text.data.indexOf(phrase);
60
+ if (at === -1) continue;
61
+ const match = text.splitText(at);
62
+ match.splitText(phrase.length);
63
+ const mark = markTemplate().content.cloneNode(true);
64
+ mark.querySelector(".highlight-text").textContent = phrase;
65
+ match.replaceWith(mark);
66
+ return copy;
67
+ }
68
+ return null;
69
+ }
70
+
24
71
  render() {
25
- const fullText = this.textContent || "";
26
72
  const highlightText = this.highlightText || "";
73
+ const headline = highlightText ? this._markedHeadline(highlightText) : null;
27
74
 
28
75
  const subtitleTemplate = this.subTitle
29
76
  ? html`<bd-p variant="small" class="sub-title">${this.subTitle}</bd-p>`
30
77
  : null;
31
78
 
32
- if (!highlightText || !fullText.includes(highlightText)) {
33
- return html`
34
- ${subtitleTemplate}
35
- <bd-h as="${this.as}" class="headline">
36
- <slot></slot>
37
- </bd-h>
38
- `;
39
- }
40
-
41
- const [before, after] = fullText.split(highlightText);
42
-
43
79
  return html`
44
80
  ${subtitleTemplate}
45
- <bd-h as="${this.as}" class="headline">
46
- ${before}
47
- <span class="highlight"><span class="highlight-anchor"
48
- ><span class="highlight-deco left"
49
- ><svg class="highlight-svg-ellipse-top" viewBox="0 0 8 8">
50
- <ellipse cx="4" cy="4" rx="4" ry="4" />
51
- </svg>
52
- <svg class="highlight-svg-rect" viewBox="0 0 2 42" preserveAspectRatio="none">
53
- <rect x="0" y="0" width="2" height="42" />
54
- </svg></span
55
- ></span
56
- ><span class="highlight-text">${highlightText}</span><span class="highlight-anchor"
57
- ><span class="highlight-deco right"
58
- ><svg class="highlight-svg-rect" viewBox="0 0 2 42" preserveAspectRatio="none">
59
- <rect x="0" y="0" width="2" height="42" />
60
- </svg>
61
- <svg class="highlight-svg-ellipse-bottom" viewBox="0 0 8 8">
62
- <ellipse cx="4" cy="4" rx="4" ry="4" />
63
- </svg></span
64
- ></span></span>
65
- ${after}
66
- </bd-h>
81
+ <bd-h as="${this.as}" class="headline">${headline ?? html`<slot></slot>`}</bd-h>
67
82
  `;
68
83
  }
69
84
 
70
85
  }
71
86
 
87
+ /* The mark's own markup: the tint, the phrase, and the bar-and-dot at each
88
+ end. Built once, on first use, and cloned for every headline. No
89
+ whitespace between the pieces, because inline whitespace renders as a
90
+ space beside the phrase. */
91
+ let template;
92
+ const markTemplate = () => {
93
+ if (!template) {
94
+ template = document.createElement("template");
95
+ template.innerHTML = [
96
+ '<span class="highlight">',
97
+ '<span class="highlight-anchor"><span class="highlight-deco left">',
98
+ '<svg class="highlight-svg-ellipse-top" viewBox="0 0 8 8"><ellipse cx="4" cy="4" rx="4" ry="4"/></svg>',
99
+ '<svg class="highlight-svg-rect" viewBox="0 0 2 42" preserveAspectRatio="none"><rect x="0" y="0" width="2" height="42"/></svg>',
100
+ '</span></span>',
101
+ '<span class="highlight-text"></span>',
102
+ '<span class="highlight-anchor"><span class="highlight-deco right">',
103
+ '<svg class="highlight-svg-rect" viewBox="0 0 2 42" preserveAspectRatio="none"><rect x="0" y="0" width="2" height="42"/></svg>',
104
+ '<svg class="highlight-svg-ellipse-bottom" viewBox="0 0 8 8"><ellipse cx="4" cy="4" rx="4" ry="4"/></svg>',
105
+ '</span></span>',
106
+ '</span>'
107
+ ].join("");
108
+ }
109
+ return template;
110
+ };
111
+
72
112
  customElements.define("bd-highlight", BdHighlightSection);
@@ -22,10 +22,11 @@ export default {
22
22
  \`\`\`
23
23
 
24
24
  ### Behavior
25
- - The component reads \`this.textContent\` and splits it on \`highlight-text\`
26
- - If \`highlight-text\` is not found in the text, the full slot content is rendered without decoration
27
- - If found, the highlighted word gets a \`<span class="highlight">\` wrapper with decorative SVG elements (ellipses + rectangles) added on both sides
28
- - The right-side decoration is injected via \`updated()\` using direct DOM manipulation
25
+ - The headline keeps its markup: \`<em>\`, \`<strong>\`, \`<br>\` and links render as written, and the first occurrence of \`highlight-text\` is marked where it sits; a later occurrence renders as plain text
26
+ - The headline is a copy of the content, so a script listener attached to an element inside it does not carry over; links still work, because the \`href\` does
27
+ - If \`highlight-text\` is not found, or crosses an element boundary (\`family <em>member</em>\`), the content renders through the slot as it is, without decoration
28
+ - The mark is a \`<span class="highlight">\` with a bar and dot drawn at each end
29
+ - Editing the content after it renders re-renders the headline
29
30
 
30
31
  ### Notes
31
32
  - \`sub-title\` renders a \`bd-p kind="small"\` above the headline, **not** a heading
@@ -1,6 +1,23 @@
1
1
  import { LitElement, css } from "lit";
2
2
  import { iconSize } from "../shared/token-scales.js";
3
3
 
4
+ /* One sheet per distinct size and colour, shared by every icon that asks for
5
+ the same pair: a page of forty check icons adopts one sheet, not forty. */
6
+ const instanceSheets = new Map();
7
+
8
+ const instanceSheet = (size, color) => {
9
+ const key = `${size}|${color}`;
10
+ let sheet = instanceSheets.get(key);
11
+ if (!sheet) {
12
+ sheet = new CSSStyleSheet();
13
+ sheet.replaceSync(
14
+ `:host { --bd-icon-size: ${size}; }` + (color ? ` svg { color: ${color}; }` : "")
15
+ );
16
+ instanceSheets.set(key, sheet);
17
+ }
18
+ return sheet;
19
+ };
20
+
4
21
  /**
5
22
  * Base for the tintable interface icons.
6
23
  *
@@ -47,8 +64,9 @@ export class BdIconElement extends LitElement {
47
64
  static styles = css`
48
65
  :host {
49
66
  display: inline-flex;
50
- /* --bd-icon-size is component-local and always set below, so the
51
- fallback is only reached if a subclass forgets to call super. */
67
+ /* --bd-icon-size is component-local and always set by the instance
68
+ sheet below, so the fallback is only reached if a subclass forgets to
69
+ call super. */
52
70
  height: var(--bd-icon-size, var(--icon-md-size));
53
71
  }
54
72
 
@@ -83,17 +101,42 @@ export class BdIconElement extends LitElement {
83
101
  this.height = null;
84
102
  }
85
103
 
104
+ /* WHY NOT THE HOST'S STYLE ATTRIBUTE.
105
+ This used to write --bd-icon-size and color into this.style on every
106
+ update. The host's style attribute belongs to the page, and with no color
107
+ prop the update removed the colour outright, so
108
+ <bd-check-icon style="color: green"> rendered in the inherited text
109
+ colour, and so did any icon a script coloured after its first render.
110
+ The inline size meant no stylesheet could set --bd-icon-size either.
111
+
112
+ Both now live in a sheet adopted into this shadow root, and the host's
113
+ style attribute is never read or written:
114
+
115
+ size a :host rule. The page's CSS on the element wins over it, as it
116
+ already did for height -- header.css.js sets one on
117
+ .bd-header-signature -- and now does for --bd-icon-size too.
118
+ colour a rule on the svg, so the color prop still beats the page's CSS
119
+ on the host, as the inline colour did. Unset, nothing is
120
+ written and the artwork's currentColor follows the host,
121
+ wherever its colour came from. */
86
122
  willUpdate() {
87
123
  // An explicit pixel size wins, for the callers that still pass one.
88
124
  const px = this.height ?? this.width;
89
125
  const { defaultSize } = this.constructor;
90
126
  // a step first, then an off-scale default, then the caller's pixels
91
127
  const step = iconSize(this.size) || iconSize(defaultSize) || defaultSize;
92
- this.style.setProperty("--bd-icon-size", px == null ? step : `${px}px`);
128
+ const size = Number.isFinite(px) ? `${px}px` : step;
93
129
 
94
130
  // No colour of its own by default: the artwork is currentColor, so it
95
- // follows whatever it sits in.
96
- if (this.color) this.style.color = this.color;
97
- else this.style.removeProperty("color");
131
+ // follows whatever it sits in. A value CSS would not accept as a colour is
132
+ // dropped, as an inline one was -- and it never reaches the sheet's text.
133
+ const color = this.color && CSS.supports("color", this.color) ? this.color : "";
134
+
135
+ this._baseSheets ??= this.renderRoot.adoptedStyleSheets;
136
+ const sheet = instanceSheet(size, color);
137
+ if (this._instanceSheet !== sheet) {
138
+ this._instanceSheet = sheet;
139
+ this.renderRoot.adoptedStyleSheets = [...this._baseSheets, sheet];
140
+ }
98
141
  }
99
142
  }
@@ -81,6 +81,13 @@ per the foundation rule it should be a token reference such as
81
81
  text it sits beside. A raw hex value here is the same drift the tokens exist to
82
82
  prevent.
83
83
 
84
+ ### Styling an icon from the page
85
+ An icon never writes its own \`style\` attribute, so whatever the page puts
86
+ there stays. With no \`color\` prop, \`style="color: …"\` or any stylesheet
87
+ colour on the icon reaches the artwork; the \`color\` prop, when set, beats both.
88
+ A stylesheet can size an icon with \`height\` or \`--bd-icon-size\` on the
89
+ element, and either beats the \`size\` prop.
90
+
84
91
  ### Known inconsistencies
85
92
  - **\`bd-laurel-icon\` keeps an 80px default**, off the scale, which stops at 64.
86
93
  Shrinking the awards wreath by a fifth is a design decision rather than a
@@ -84,112 +84,23 @@ export default css`
84
84
  box-shadow: var(--shadow-xl);
85
85
  }
86
86
 
87
- /* Border Variants - Keep for additional styling options */
88
- .bd-img.bordered {
89
- border: var(--border-width-2) solid var(--color-border-subtle);
90
- }
91
-
92
- .bd-img.bordered-primary {
93
- border: var(--border-width-2) solid var(--color-border-brand);
94
- }
95
-
96
- .bd-img.bordered-thick {
97
- border: var(--border-width-4) solid var(--color-border-disabled);
98
- }
99
-
100
- /* Responsive Behavior */
101
- .bd-img.responsive {
102
- width: 100%;
103
- height: auto;
104
- }
105
-
106
- /* Aspect Ratio Containers */
107
- .bd-img.aspect-16-9 {
108
- aspect-ratio: 16 / 9;
109
- }
110
-
111
- .bd-img.aspect-4-3 {
112
- aspect-ratio: 4 / 3;
113
- }
114
-
115
- .bd-img.aspect-1-1 {
116
- aspect-ratio: 1 / 1;
117
- }
118
-
119
- /* Hover Effects */
120
- .bd-img.hover-zoom:hover {
121
- transform: scale(1.05);
122
- transition: transform var(--transition-duration-slow) var(--transition-easing-smooth);
123
- }
124
-
125
- .bd-img.hover-shadow:hover {
126
- box-shadow: var(--shadow-lg);
127
- transition: box-shadow var(--transition-duration-slow) var(--transition-easing-smooth);
128
- }
129
-
130
- .bd-img:focus-visible {
131
- /* the one focus ring — see Foundations/Focus */
132
- outline: none;
133
- box-shadow: var(--focus-outline-primary);
134
- }
135
-
136
- /* Loading state */
137
- .bd-img:not([src]) {
138
- background-color: var(--color-surface-muted);
139
- /* no token: the dimension scale stops at 64px */
140
- min-height: 100px;
141
- display: flex;
142
- align-items: center;
143
- justify-content: center;
144
- /* --color-text-muted, not --color-text-disabled. Loading is not disabled,
145
- and the difference is not academic: the disabled text role carries a
146
- contrast exemption -- WCAG 1.4.3 excuses disabled controls from the
147
- 4.5:1 minimum -- and this label claims that exemption without being
148
- entitled to it. It is a sentence a reader is meant to read while they
149
- wait.
150
-
151
- Measured on this ground: neutral-400 on neutral-100 is 2.51:1, which
152
- misses even the 3:1 floor for non-text. --color-text-muted is
153
- neutral-600 at 5.12:1 and is the role this package already uses for
154
- low-emphasis readable text -- caption and form help text both resolve
155
- to it.
156
-
157
- The ground stays --color-surface-muted. Loading is a state, muted is
158
- the state surface, and bd-skeleton -- the package's other loading
159
- affordance -- already sits on it. Only the ink was choosing by value.
160
- See "Choosing a semantic role" in the package README. */
161
- color: var(--color-text-muted);
162
- /* no role: alt text shown when the image has no src -- a glyph/marker/placeholder box, not running
163
- prose. See list-item.css.js's .bd-list-item-marker::before for the
164
- argument. */
165
- font-size: var(--typography-fontSize-sm);
166
- }
167
-
168
- /* The placeholder copy is a local property rather than a string in the
169
- stylesheet: content: "Image loading..." cannot be translated, and this
170
- atom and bd-picture used two different sentences for the same state. */
171
- .bd-img:not([src])::before {
172
- content: var(--bd-img-loading-label, "Loading image");
173
- }
87
+ /* Gone from here, because nothing could reach them: bordered,
88
+ bordered-primary and bordered-thick, responsive, aspect-16-9 / 4-3 /
89
+ 1-1, hover-zoom and hover-shadow had no prop, and the only way to set
90
+ one was to pass its name as fit, which now accepts only the five values
91
+ above. :focus-visible styled an <img>, which never takes focus. And the
92
+ :not([src]) loading placeholder could not appear: the template always
93
+ writes a src attribute, and an <img> renders no ::before even without
94
+ one. bd-picture keeps its own placeholder, which does work. */
174
95
 
175
96
  /* Smooth transitions for radius and shadow changes */
176
97
  .bd-img {
177
98
  transition: border-radius var(--transition-duration-normal) var(--transition-easing-smooth), box-shadow var(--transition-duration-normal) var(--transition-easing-smooth);
178
99
  }
179
100
 
180
- /* Performance optimization for transforms */
181
- .bd-img.hover-zoom {
182
- will-change: transform;
183
- }
184
-
185
- /* A hover that scales the image is the category of motion the setting
186
- exists for, and this atom had no guard at all. */
187
101
  @media (prefers-reduced-motion: reduce) {
188
- .bd-img,
189
- .bd-img.hover-zoom:hover,
190
- .bd-img.hover-shadow:hover {
102
+ .bd-img {
191
103
  transition: none;
192
- transform: none;
193
104
  }
194
105
  }
195
106
  }
@@ -5,6 +5,11 @@ import imgCSS from "./image.css.js";
5
5
 
6
6
  // the radius steps image.css.js implements — the full --radius-* scale
7
7
  const RADIUS_STEPS = ['none', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', 'full'];
8
+ /* fit and shadow become class names, so an unknown value used to become a
9
+ class too -- fit="bordered" reached a rule nothing documented. Only the
10
+ values image.css.js implements get through; anything else is the default. */
11
+ const FITS = ['cover', 'contain', 'fill', 'none', 'scale-down'];
12
+ const SHADOWS = ['none', 'sm', 'md', 'lg', 'xl'];
8
13
 
9
14
  export class BdImg extends LitElement {
10
15
  static properties = {
@@ -32,10 +37,10 @@ export class BdImg extends LitElement {
32
37
 
33
38
  render() {
34
39
  const baseClass = 'bd-img';
35
- const fitClass = this.fit || 'cover';
40
+ const fitClass = FITS.includes(this.fit) ? this.fit : 'cover';
36
41
  const radius = RADIUS_STEPS.includes(this.radius) ? this.radius : 'none';
37
42
  const radiusClass = `radius-${radius}`;
38
- const shadowClass = `shadow-${this.shadow || 'none'}`;
43
+ const shadowClass = `shadow-${SHADOWS.includes(this.shadow) ? this.shadow : 'none'}`;
39
44
 
40
45
  const finalClass = `${baseClass} ${fitClass} ${radiusClass} ${shadowClass}`;
41
46
 
@@ -23,7 +23,7 @@ It includes:
23
23
  | State | Description |
24
24
  |---|---|
25
25
  | \`default\` | Idle, no interaction |
26
- | \`focused\` | Active keyboard / pointer focus |
26
+ | \`focused\` | Has keyboard focus — set it to move focus into the field; it reflects whether the field is focused |
27
27
  | \`invalid\` | Failed validation — red border + helper text |
28
28
  | \`validated\` | Passed validation — green border + check icon |
29
29
  | \`disabled\` | Non-interactive, visually muted |
@@ -86,7 +86,8 @@ the events for your own use.
86
86
  \`invalid\` sets \`aria-invalid="true"\`, and when \`tooltip\` is present the input
87
87
  points at it with \`aria-describedby\` — so a screen reader announces both that
88
88
  the field is wrong and why. Put the reason in \`tooltip\`; it doubles as the
89
- help text and the error message.
89
+ help text and the error message. \`bd-custom-dropdown\` and
90
+ \`bd-custom-input-clipboard\` do the same.
90
91
 
91
92
  The required asterisk is \`aria-hidden\`, because the native \`required\`
92
93
  attribute is what actually conveys it.
@@ -129,26 +130,19 @@ everything else is not:
129
130
  | | \`bd-custom-input\` | \`bd-custom-dropdown\` | \`bd-custom-input-clipboard\` |
130
131
  |---|---|---|---|
131
132
  | \`label\`, \`kind\`, \`required\`, \`invalid\`, \`validated\`, \`disabled\`, \`tooltip\` | yes | yes | yes |
132
- | \`value\` | yes | **no** | yes |
133
+ | \`value\` | yes | yes | yes |
133
134
  | \`type\`, \`placeholder\`, \`focused\` | yes | **no** | yes |
134
- | \`name\`, \`inputmode\`, \`autocomplete\`, \`enterkeyhint\` | yes | **no** | **no** |
135
+ | \`name\` | yes | yes | yes |
136
+ | \`inputmode\`, \`autocomplete\`, \`enterkeyhint\` | yes | **no** | **no** |
135
137
  | \`options\` | **no** | yes | **no** |
136
- | emits | \`bd-input\`, \`bd-change\` | **nothing** | \`input\` |
138
+ | emits | \`bd-input\`, \`bd-change\` | \`bd-input\`, \`bd-change\` | \`bd-input\`, \`bd-change\` |
137
139
 
138
140
  Storybook shows one shared control panel for all three, so a knob that does
139
141
  nothing on the story you are looking at is expected — check this table.
140
142
 
141
- Two of those rows are gaps rather than design:
142
-
143
- - **The select emits no event and has no \`value\`.** Choosing an option changes
144
- nothing observable from outside the component, so it cannot currently be read
145
- without reaching into its shadow root. It needs the same \`value\` +
146
- \`bd-input\`/\`bd-change\` treatment the input has.
147
- - **The clipboard field's event is not the input's.** It fires \`input\` — the
148
- native event's own name — with the string as \`detail\` rather than
149
- \`{ value, name }\`, and it is neither \`bubbles\` nor \`composed\`, so it reaches
150
- a listener on the element itself and nothing further up. Renaming it to
151
- \`bd-input\` would align the three, and is a breaking change.
143
+ All three emit the same events with the same \`detail\`, so one form handler
144
+ reads them. The clipboard field used to fire \`input\` — the native event's
145
+ name — with a bare string; a paste fires \`bd-input\` and then \`bd-change\`.
152
146
 
153
147
  ### The clipboard field pastes, it does not copy
154
148
  The button reads the system clipboard with \`navigator.clipboard.readText()\` and
@@ -222,7 +216,7 @@ misses 2.5.5 (AAA, 44px) at every size.
222
216
  },
223
217
  focused: {
224
218
  control : 'boolean',
225
- description: 'Forces the focus ring on; useful for design review in Storybook',
219
+ description: 'Moves keyboard focus into the field, and reflects whether it has focus. The ring is the browser\'s real focus ring.',
226
220
  table : {
227
221
  type : { summary: 'boolean' },
228
222
  defaultValue: { summary: false },
@@ -405,7 +399,8 @@ export const InputDefault = {
405
399
  };
406
400
 
407
401
  /**
408
- * Input with the focused ring forced on — useful for design review without interaction.
402
+ * An input that takes focus when it renders, through the focused prop -- the
403
+ * ring is the real one, because the field really is focused.
409
404
  */
410
405
  export const InputFocused = {
411
406
  name : 'Input — Focused',
@@ -415,7 +410,7 @@ export const InputFocused = {
415
410
  docs: {
416
411
  description: {
417
412
  story:
418
- 'Focus ring applied via the `focused` boolean prop. Demonstrates the active ring style without requiring pointer or keyboard interaction inside the story frame.'
413
+ '`focused` moves keyboard focus into the field when the story renders, so the ring shown is the browser\'s own. Clicking elsewhere clears it, and the prop follows.'
419
414
  }
420
415
  }
421
416
  }
@@ -50,7 +50,12 @@ export default css`
50
50
  shared one; the radius is the default for an element with no radius of its
51
51
  own, so the ring is not a hard rectangle. */
52
52
  .paste-button:focus-visible {
53
- outline: none;
53
+ /* forced-colors mode does not paint box-shadow, so the ring
54
+ vanishes there; it does paint an outline, in a system colour. This one
55
+ is transparent everywhere else, and the offset lands it on the ring's
56
+ outer band. */
57
+ outline: var(--border-width-2) solid transparent;
58
+ outline-offset: var(--focus-outline-offset);
54
59
  box-shadow: var(--focus-outline-primary);
55
60
  border-radius: var(--focus-outline-radius);
56
61
  }
@@ -14,12 +14,14 @@ class CustomInputClipboard extends LitElement {
14
14
  kind : { type: String },
15
15
  placeholder: { type: String },
16
16
  required : { type: Boolean },
17
- focused : { type: Boolean },
17
+ focused : { type: Boolean, reflect: true },
18
18
  tooltip : { type: String },
19
19
  invalid : { type: Boolean },
20
20
  validated : { type: Boolean, reflect: true },
21
21
  disabled : { type: Boolean, reflect: true },
22
- value : { type: String }
22
+ value : { type: String },
23
+ /** Carried on the event detail, as on bd-custom-input. */
24
+ name : { type: String }
23
25
  };
24
26
 
25
27
  constructor() {
@@ -37,13 +39,32 @@ class CustomInputClipboard extends LitElement {
37
39
  this.validated = false;
38
40
  this.disabled = false;
39
41
  this.value = "";
42
+ this.name = "";
43
+ }
44
+
45
+ /* THE EVENTS ARE bd-custom-input's.
46
+ This fired an event named input -- the native event's own name -- with
47
+ the bare string as detail, neither bubbling nor composed. The native
48
+ input from the inner <input> is composed and already leaves the shadow
49
+ root, so a listener on the host heard every keystroke twice, once with a
50
+ detail and once without, and a paste once. Now it is bd-input on every
51
+ change and bd-change on commit, each { value, name }, bubbling and
52
+ composed, as on bd-custom-input: one form handler reads all three fields.
53
+ A paste is a commit as well as a change, so it fires both. */
54
+ _emit(type) {
55
+ this.dispatchEvent(new CustomEvent(type, {
56
+ detail : { value: this.value, name: this.name },
57
+ bubbles : true,
58
+ composed: true
59
+ }));
40
60
  }
41
61
 
42
62
  async pasteFromClipboard() {
43
63
  try {
44
64
  const text = await navigator.clipboard.readText();
45
65
  this.value = text;
46
- this.dispatchEvent(new CustomEvent("input", { detail: text }));
66
+ this._emit("bd-input");
67
+ this._emit("bd-change");
47
68
  } catch (err) {
48
69
  console.error("Clipboard paste failed:", err);
49
70
  }
@@ -51,7 +72,11 @@ class CustomInputClipboard extends LitElement {
51
72
 
52
73
  handleInput(e) {
53
74
  this.value = e.target.value;
54
- this.dispatchEvent(new CustomEvent("input", { detail: this.value }));
75
+ this._emit("bd-input");
76
+ }
77
+
78
+ handleChange() {
79
+ this._emit("bd-change");
55
80
  }
56
81
 
57
82
  willUpdate(changed) {
@@ -61,6 +86,21 @@ class CustomInputClipboard extends LitElement {
61
86
  if (changed.has("size") || changed.has("kind")) this.size = resolveSize(this.size);
62
87
  }
63
88
 
89
+ /* focused is the field's real focus, both ways. Setting it moves keyboard
90
+ focus into the <input> and clearing it blurs the field; focusing or
91
+ leaving the field by hand sets it, and it reflects, so the attribute says
92
+ whether the field has focus. It used to add a .focused class that painted
93
+ the focus ring on a field that did not have focus -- a second, false
94
+ indicator on any page that set it. The ring is now only the browser's
95
+ :focus-visible, which a text field shows whenever it is focused. */
96
+ updated(changed) {
97
+ if (!changed.has('focused')) return;
98
+ const input = this.shadowRoot.querySelector('input');
99
+ const has = this.shadowRoot.activeElement === input;
100
+ if (this.focused && !has) input?.focus();
101
+ if (!this.focused && has) input?.blur();
102
+ }
103
+
64
104
  render() {
65
105
  const inputClass = {
66
106
  sm: "input-sm",
@@ -88,6 +128,10 @@ class CustomInputClipboard extends LitElement {
88
128
  ? "form-help-text--success"
89
129
  : "form-help-text--default";
90
130
 
131
+ // The hint is the field's description, and the state is announced, not
132
+ // only drawn: invalid used to be a red border alone and the hint was never
133
+ // pointed at, so the reason a field was invalid reached a screen reader
134
+ // not at all. Both as bd-custom-input does them.
91
135
  return html`
92
136
  <div class="input-container">
93
137
  ${this.label
@@ -101,15 +145,19 @@ class CustomInputClipboard extends LitElement {
101
145
  <div class="input-with-button">
102
146
  <input
103
147
  id="clipboard-input"
104
- class="form-input-bd ${inputClass} ${this.focused
105
- ? "focused"
106
- : ""} ${inputStateClass}"
148
+ class="form-input-bd ${inputClass} ${inputStateClass}"
107
149
  type="${this.type}"
108
150
  placeholder="${this.placeholder}"
109
151
  .value="${this.value}"
110
152
  ?required="${this.required}"
111
153
  ?disabled="${this.disabled}"
154
+ name="${this.name || nothing}"
155
+ aria-invalid="${this.invalid ? "true" : nothing}"
156
+ aria-describedby="${this.tooltip ? "clipboard-input-help" : nothing}"
157
+ @focus="${() => { this.focused = true; }}"
158
+ @blur="${() => { this.focused = false; }}"
112
159
  @input="${this.handleInput}"
160
+ @change="${this.handleChange}"
113
161
  />
114
162
  <button
115
163
  class="paste-button"
@@ -139,7 +187,7 @@ class CustomInputClipboard extends LitElement {
139
187
  </div>
140
188
 
141
189
  ${this.tooltip
142
- ? html`<div class="form-help-text ${helpTextClass}">
190
+ ? html`<div id="clipboard-input-help" class="form-help-text ${helpTextClass}">
143
191
  ${this.tooltip}
144
192
  </div>`
145
193
  : ""}
@@ -18,7 +18,7 @@ class CustomInput extends LitElement {
18
18
  kind : { type: String },
19
19
  placeholder : { type: String },
20
20
  required : { type: Boolean },
21
- focused : { type: Boolean },
21
+ focused : { type: Boolean, reflect: true },
22
22
  tooltip : { type: String },
23
23
  invalid : { type: Boolean },
24
24
  validated : { type: Boolean, reflect: true },
@@ -86,6 +86,21 @@ class CustomInput extends LitElement {
86
86
  if (changed.has("size") || changed.has("kind")) this.size = resolveSize(this.size);
87
87
  }
88
88
 
89
+ /* focused is the field's real focus, both ways. Setting it moves keyboard
90
+ focus into the <input> and clearing it blurs the field; focusing or
91
+ leaving the field by hand sets it, and it reflects, so the attribute says
92
+ whether the field has focus. It used to add a .focused class that painted
93
+ the focus ring on a field that did not have focus -- a second, false
94
+ indicator on any page that set it. The ring is now only the browser's
95
+ :focus-visible, which a text field shows whenever it is focused. */
96
+ updated(changed) {
97
+ if (!changed.has('focused')) return;
98
+ const input = this.shadowRoot.querySelector('input');
99
+ const has = this.shadowRoot.activeElement === input;
100
+ if (this.focused && !has) input?.focus();
101
+ if (!this.focused && has) input?.blur();
102
+ }
103
+
89
104
  render() {
90
105
  const inputClass = { sm: "input-sm", md: "input-md", lg: "input-lg" }[this.size];
91
106
  const labelClass = { sm: "label-sm", md: "label-md", lg: "label-lg" }[this.size];
@@ -116,7 +131,7 @@ class CustomInput extends LitElement {
116
131
  : nothing}
117
132
  <input
118
133
  id="${INPUT_ID}"
119
- class="form-input-bd ${inputClass} ${this.focused ? "focused" : ""} ${inputStateClass}"
134
+ class="form-input-bd ${inputClass} ${inputStateClass}"
120
135
  type="${this.type}"
121
136
  name="${this.name || nothing}"
122
137
  placeholder="${this.placeholder}"
@@ -128,6 +143,8 @@ class CustomInput extends LitElement {
128
143
  ?disabled="${this.disabled}"
129
144
  aria-invalid="${this.invalid ? "true" : nothing}"
130
145
  aria-describedby="${this.tooltip ? HELP_ID : nothing}"
146
+ @focus="${() => { this.focused = true; }}"
147
+ @blur="${() => { this.focused = false; }}"
131
148
  @input="${this._onInput}"
132
149
  @change="${this._onChange}"
133
150
  />