@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
@@ -2,6 +2,7 @@ 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 listItemCSS from "./list-item.css.js";
5
+ import { color } from "../../shared/token-scales.js";
5
6
  import "../../icons/check-icon.js";
6
7
 
7
8
  // the size steps list-item.css.js actually implements
@@ -10,6 +11,11 @@ const SIZE_STEPS = ['sm', 'md', 'lg'];
10
11
  /** The markers this item can draw. */
11
12
  const MARKERS = ['bullet', 'number', 'check', 'none'];
12
13
 
14
+ /* Whether a sibling is a numbered item, read so it also answers for one
15
+ that has not upgraded yet: its variant is still only an attribute. */
16
+ const isNumbered = el => el.localName === 'bd-li'
17
+ && (el.variant || el.getAttribute('variant') || el.getAttribute('kind')) === 'number';
18
+
13
19
  export class BdListItem extends LitElement {
14
20
  static properties = {
15
21
  variant: { type: String, reflect: true },
@@ -17,7 +23,7 @@ export class BdListItem extends LitElement {
17
23
  kind : { type: String },
18
24
  size : { type: String }, // sm, md, lg
19
25
  icon : { type: String }, // custom icon URL
20
- color : { type: String } // optional
26
+ color : { type: String } // a text role -- see token-scales.js COLOR_ROLES
21
27
  };
22
28
 
23
29
  constructor() {
@@ -36,10 +42,52 @@ export class BdListItem extends LitElement {
36
42
  }
37
43
  }
38
44
 
45
+ /* WHY THE NUMBER IS WORKED OUT HERE.
46
+ The number used to be a CSS counter, incremented on the <li> inside this
47
+ element's shadow root. Every shadow root starts its own counter, so each
48
+ item counted only itself and every numbered list read 1. 1. 1. Now each
49
+ item counts the numbered bd-li before it among its siblings, starting at
50
+ the enclosing bd-list's start.
51
+
52
+ The count depends on the siblings, so an item joining, leaving or
53
+ changing variant asks the others to recount, and bd-list asks its items
54
+ when start changes. */
55
+ get _number() {
56
+ const parent = this.parentElement;
57
+ if (!parent) return 1;
58
+ const start = parent.localName === 'bd-list' && Number.isFinite(parent.start) ? parent.start : 1;
59
+ return start + [...parent.children].filter(isNumbered).indexOf(this);
60
+ }
61
+
62
+ _renumberPeers(parent = this.parentElement) {
63
+ if (!parent) return;
64
+ for (const el of parent.children) if (el !== this && el.localName === 'bd-li') el.requestUpdate?.();
65
+ }
66
+
67
+ connectedCallback() {
68
+ super.connectedCallback();
69
+ this._parent = this.parentElement;
70
+ this._renumberPeers();
71
+ }
72
+
73
+ disconnectedCallback() {
74
+ super.disconnectedCallback();
75
+ this._renumberPeers(this._parent);
76
+ this._parent = null;
77
+ }
78
+
79
+ updated(changed) {
80
+ if (changed.has("variant")) this._renumberPeers();
81
+ }
82
+
39
83
  render() {
40
- const style = this.color
41
- ? `color: ${this.color}; --bd-li-marker-color: ${this.color};`
42
- : "";
84
+ /* color is a text role, resolved the way bd-h resolves it. It used to be
85
+ written into the style attribute as given, so any string at all -- a
86
+ hex, a raw palette var(), a value with a semicolon in it -- reached the
87
+ inline style unchecked, past every token rule. A name that is not a
88
+ role writes nothing, and the item keeps its own colours. */
89
+ const ink = color(this.color);
90
+ const style = ink ? `color: ${ink}; --bd-li-marker-color: ${ink};` : "";
43
91
 
44
92
  return html`
45
93
  <li class="bd-list-item ${this.variant} ${SIZE_STEPS.includes(this.size) ? this.size : 'md'}" style="${style}">
@@ -58,7 +106,7 @@ export class BdListItem extends LitElement {
58
106
  return html`<img src="${this.icon}" alt="" class="bd-list-item-custom-icon" />`;
59
107
  }
60
108
 
61
- return html`<span class="bd-list-item-marker">${
109
+ return html`<span class="bd-list-item-marker" data-number="${this.variant === "number" ? `${this._number}.` : nothing}">${
62
110
  this.variant === "check"
63
111
  ? html`<bd-check-icon size="xs" aria-hidden="true"></bd-check-icon>`
64
112
  : nothing
@@ -1,5 +1,6 @@
1
1
  import { html } from "lit";
2
2
  import "./list-item.js";
3
+ import "../list.js";
3
4
 
4
5
  export default {
5
6
  title : "Atoms/List item",
@@ -88,6 +89,27 @@ export const Number = {
88
89
  `
89
90
  };
90
91
 
92
+ /**
93
+ * Each item counts the numbered bd-li before it, starting at the enclosing
94
+ * bd-list's start. The number was a CSS counter once, and a counter cannot
95
+ * count across shadow roots, so every item read "1." -- a single-item story
96
+ * could not show that, which is why this one has several.
97
+ */
98
+ export const NumberedList = {
99
+ name : "Number — a list of them",
100
+ render: () => html`
101
+ <bd-list type="ordered">
102
+ <bd-li variant="number" size="md">Download the installer</bd-li>
103
+ <bd-li variant="number" size="md">Run it and sign in</bd-li>
104
+ <bd-li variant="number" size="md">Start your first scan</bd-li>
105
+ </bd-list>
106
+ <bd-list type="ordered" start="4">
107
+ <bd-li variant="number" size="md">Continues from four</bd-li>
108
+ <bd-li variant="number" size="md">and five</bd-li>
109
+ </bd-list>
110
+ `
111
+ };
112
+
91
113
  // ─── Check ────────────────────────────────────────────────────────────────────
92
114
 
93
115
  export const Check = {
@@ -3,6 +3,14 @@ import { LitElement, html } from "lit";
3
3
  import "../../tokens/tokens.js";
4
4
  import listCSS from "./list.css.js";
5
5
 
6
+ /* The values list.css.js styles, each with the default it falls back to. */
7
+ const ALLOWED = {
8
+ type : [['ordered', 'unordered'], 'unordered'],
9
+ spacing : [['sm', 'md', 'lg'], 'md'],
10
+ variant : [['default', 'compact', 'spacious'], 'default'],
11
+ orientation: [['vertical', 'horizontal'], 'vertical']
12
+ };
13
+
6
14
  export class BdList extends LitElement {
7
15
  static properties = {
8
16
  type : { type: String, reflect: true }, // 'ordered', 'unordered'
@@ -21,6 +29,25 @@ export class BdList extends LitElement {
21
29
  this.orientation = "vertical";
22
30
  }
23
31
 
32
+ /* Each of these becomes a class and reflects, so an unknown value used to
33
+ land on the element and in the class list as a name nothing styles --
34
+ orientation="row" laid the list out as nothing in particular, and still
35
+ said "row". It now falls back to the default, and the attribute shows
36
+ what is applied. start that is not a number goes back to 1. */
37
+ willUpdate(changed) {
38
+ for (const [prop, [values, fallback]] of Object.entries(ALLOWED)) {
39
+ if (changed.has(prop) && !values.includes(this[prop])) this[prop] = fallback;
40
+ }
41
+ if (changed.has('start') && !Number.isFinite(this.start)) this.start = 1;
42
+ }
43
+
44
+ /* A numbered bd-li counts from start, so it has to recount when start does. */
45
+ updated(changed) {
46
+ if (changed.has("start")) {
47
+ for (const el of this.children) if (el.localName === "bd-li") el.requestUpdate?.();
48
+ }
49
+ }
50
+
24
51
  render() {
25
52
  const classes = `bd-list ${this.spacing} ${this.variant} ${this.orientation}`;
26
53
 
@@ -130,7 +130,12 @@ export default css`
130
130
 
131
131
  .bd-modal-content button:focus-visible {
132
132
  /* the one focus ring — see Foundations/Focus */
133
- outline: none;
133
+ /* forced-colors mode does not paint box-shadow, so the ring
134
+ vanishes there; it does paint an outline, in a system colour. This one
135
+ is transparent everywhere else, and the offset lands it on the ring's
136
+ outer band. */
137
+ outline: var(--border-width-2) solid transparent;
138
+ outline-offset: var(--focus-outline-offset);
134
139
  box-shadow: var(--focus-outline-primary);
135
140
  }
136
141
 
@@ -0,0 +1,161 @@
1
+ import { css } from "lit";
2
+
3
+ /**
4
+ * Styled entirely from src/tokens/tokens.js -- see CLAUDE.md, "The foundation
5
+ * rule".
6
+ *
7
+ * The geometry is dimension, not spacing, on purpose: a dot is a fixed mark
8
+ * and should not grow when a reader enlarges their text. The gap around each
9
+ * dot is spacing, because it is padding, and padding is allowed to grow. So a
10
+ * larger root font makes the targets wider and leaves the dots as drawn.
11
+ */
12
+ export default css`
13
+ :host {
14
+ display: inline-flex;
15
+ }
16
+
17
+ .dots {
18
+ display: flex;
19
+ align-items: center;
20
+ /* Each button carries its own target, so the space between two dots comes
21
+ from the boxes. A gap on top would push them apart without making
22
+ either easier to hit. */
23
+ }
24
+
25
+ /* ---- dots ---------------------------------------------------------------
26
+ The target. 24px tall for WCAG 2.5.8, and the dot plus 8px either side
27
+ wide: 24 for a resting dot, 56 for the current pill. The dot is drawn
28
+ inside it rather than being it. */
29
+ .dot {
30
+ display: grid;
31
+ place-items: center;
32
+ height: var(--dimension-24);
33
+ margin: 0;
34
+ padding: 0 var(--spacing-8);
35
+ border: none;
36
+ background: transparent;
37
+ cursor: pointer;
38
+ }
39
+
40
+ /* The mark is a real element rather than a ::before, because the current
41
+ one holds the progress fill and a Web Animation needs an element to run
42
+ on. */
43
+ .mark {
44
+ position: relative;
45
+ display: block;
46
+ overflow: hidden;
47
+ width: var(--dimension-8);
48
+ height: var(--dimension-8);
49
+ border-radius: var(--radius-full);
50
+ /* no role: a resting slide mark. No semantic role names an inactive
51
+ indicator, and it has to clear the 3:1 WCAG 1.4.11 asks of a control's
52
+ only visual: neutral-500 measures 4.81 on the page and 4.46 on the
53
+ blue-50 info tint the homepage puts it on. The design's 30% neutral-900
54
+ measures 1.94 there; neutral-300, which bd-awards-flex used to draw,
55
+ 2.10; and neutral-450 would clear it but exists for the dark theme only. */
56
+ background: var(--color-neutral-500);
57
+ transition: width var(--transition-duration-normal) var(--transition-easing-ease),
58
+ background var(--transition-duration-normal) var(--transition-easing-ease);
59
+ }
60
+
61
+ .dot:hover .mark {
62
+ /* no role: the same mark under the pointer, one step darker */
63
+ background: var(--color-neutral-600);
64
+ }
65
+
66
+ /* The progress. Scaled rather than sized, so the fill is drawn by the
67
+ compositor and never re-lays the row out. Without autoplay the current
68
+ fill is whole, which is the pill as it always was; with it, the atom's
69
+ Web Animation runs this from 0 to 1 over the delay and the end of the
70
+ animation is the change of slide. */
71
+ .fill {
72
+ position: absolute;
73
+ inset: 0;
74
+ background: var(--indicator-color);
75
+ transform: scaleX(0);
76
+ transform-origin: left center;
77
+ }
78
+
79
+ :host(:dir(rtl)) .fill {
80
+ transform-origin: right center;
81
+ }
82
+
83
+ /* The current slide. The pill is the design's shape. The colour is the
84
+ indicator role, the one every current-item mark in the package shares --
85
+ the tab and nav underlines -- so they cannot drift apart. The design draws
86
+ it neutral-900 as an unbound value; using that would make this the one
87
+ current mark in the system that is not the indicator. */
88
+ .dot[aria-current="true"] {
89
+ cursor: default;
90
+ }
91
+
92
+ /* The current pill is also the autoplay track: the indicator colour at a
93
+ fifth, laid over whatever surface the dots sit on, inside an indicator
94
+ edge. Translucent rather than a colour of its own, so it belongs to every
95
+ ground -- a white track read as a hole on the awards tint and on a band.
96
+ It was the resting grey before that, and the blue fill could not be seen
97
+ on it: blue-500 on neutral-500 is 1.06:1.
98
+ .
99
+ The edge is 2px, drawn as an outline pulled inside the box rather than as
100
+ a border. At 1px it was faint at 1x density, and that is the moment it
101
+ matters most: the start of each slide, when nothing has filled yet and
102
+ the edge alone marks the pill. A 2px border would have taken the 2px out
103
+ of the box and left the fill a 4px sliver; an outline takes no room, and
104
+ it paints above the fill in the fill's own colour, so the fill keeps the
105
+ full 8px and the two merge where they meet. Under a forced palette the
106
+ outline is repainted in a system colour and still draws.
107
+ .
108
+ Measured: the fill against the track is 3.10:1 or better on the page,
109
+ the container, the subtle band and the awards tint; the edge against the
110
+ ground 4.05:1 or better. When the fill is whole the edge and fill are one
111
+ colour, which is the solid pill a dots row without autoplay shows. */
112
+ .dot[aria-current="true"] .mark {
113
+ width: var(--dimension-40);
114
+ background: color-mix(in srgb, var(--indicator-color) 20%, transparent);
115
+ outline: var(--border-width-2) solid var(--indicator-color);
116
+ outline-offset: calc(-1 * var(--border-width-2));
117
+ }
118
+
119
+ .dot[aria-current="true"] .fill {
120
+ transform: scaleX(1);
121
+ }
122
+
123
+ /* ---- focus ------------------------------------------------------------
124
+ the one focus ring -- see Foundations/Focus. It follows the target, not
125
+ the mark, so keyboard focus shows the size the button really is. */
126
+ .dot:focus {
127
+ outline: none;
128
+ }
129
+
130
+ .dot:focus-visible {
131
+ box-shadow: var(--focus-outline-primary);
132
+ /* forced-colors mode does not paint box-shadow, so the ring
133
+ vanishes there; it does paint an outline, in a system colour. This one
134
+ is transparent everywhere else, and the offset lands it on the ring's
135
+ outer band. */
136
+ outline: var(--border-width-2) solid transparent;
137
+ outline-offset: var(--focus-outline-offset);
138
+ border-radius: var(--radius-full);
139
+ }
140
+
141
+ @media (prefers-reduced-motion: reduce) {
142
+ .mark {
143
+ transition: none;
144
+ }
145
+ }
146
+
147
+ /* A forced palette repaints backgrounds, which would erase every dot.
148
+ Borders survive it, so a resting dot is drawn as a ring, and the fill is
149
+ kept out of the repaint and drawn in the highlight colour -- so the
150
+ current pill still shows, and so does the time it has left. */
151
+ @media (forced-colors: active) {
152
+ .mark {
153
+ box-sizing: border-box;
154
+ border: var(--border-width-1) solid currentColor;
155
+ }
156
+ .fill {
157
+ forced-color-adjust: none;
158
+ background: Highlight;
159
+ }
160
+ }
161
+ `;
@@ -0,0 +1,202 @@
1
+ import { LitElement, html, nothing } from "lit";
2
+ // Installs the design tokens into document scope; see src/tokens/tokens.js
3
+ import "../../tokens/tokens.js";
4
+ import paginationDotsCSS from "./pagination-dots.css.js";
5
+
6
+ /**
7
+ * Which of a run of slides is showing, and a way to go to another: the
8
+ * package's one set of slide dots. Drawn in the foundation Figma file as
9
+ * Pagination dot and Pagination dots.
10
+ *
11
+ * bd-awards-flex and bd-carousel-section each used to draw their own -- a dot
12
+ * row that claimed to be a tablist, and a pair of <img role="button"> arrows --
13
+ * and the two had drifted apart on contrast, semantics and names. The carousel
14
+ * that owns the slides stays the owner of the slides: it sets current, and
15
+ * listens for bd-change.
16
+ *
17
+ * SEMANTICS. A group of buttons, the current one marked aria-current. Not a
18
+ * tablist: a tab needs a tabpanel it controls, and this atom does not own or
19
+ * know the panels. Each button is named for what it does ("Go to slide 2"),
20
+ * and the group for what it moves through. Both names are attributes, so a
21
+ * page in another language can say them.
22
+ *
23
+ * TARGET SIZE. A dot is 8px and the design spaces them 8px apart. Clickable at
24
+ * that size they fail WCAG 2.5.8, so each button is a 24px target with the dot
25
+ * drawn inside it. The dots end up 16px apart instead of 8; the part you can
26
+ * hit grows and the dots do not.
27
+ *
28
+ * AUTOPLAY. With autoplay set, the atom advances itself every delay ms and the
29
+ * current pill fills as the time runs: the progress IS the clock, so the fill
30
+ * and the change of slide cannot drift apart. That is why the timer lives here
31
+ * and not in the carousel. Every advance fires bd-change, exactly as a click.
32
+ *
33
+ * Stopping it. WCAG 2.2.2 wants a way to stop anything that moves on its own,
34
+ * and there is no pause button, so the dots are the mechanism: picking one
35
+ * stops autoplay for good (paused), because a visitor who chose a slide has
36
+ * said which one they want to read. The clock also holds -- transiently --
37
+ * while the pointer or focus is on the dots, or while the owner sets held for
38
+ * its own slides. Under prefers-reduced-motion it never starts.
39
+ *
40
+ * count below 2 renders nothing: one page has nothing to move to.
41
+ */
42
+ const MIN_DELAY = 1000;
43
+
44
+ class BdPaginationDots extends LitElement {
45
+ static styles = [paginationDotsCSS];
46
+
47
+ static properties = {
48
+ count : { type: Number },
49
+ current : { type: Number, reflect: true },
50
+ label : { type: String },
51
+ itemLabel: { type: String, attribute: "item-label" },
52
+ autoplay : { type: Boolean, reflect: true },
53
+ delay : { type: Number },
54
+ paused : { type: Boolean, reflect: true },
55
+ held : { type: Boolean },
56
+ _selfHeld: { state: true }
57
+ };
58
+
59
+ constructor() {
60
+ super();
61
+ this.count = 0;
62
+ // 1-based, like the names the buttons announce
63
+ this.current = 1;
64
+ this.label = "Slides";
65
+ this.itemLabel = "Go to slide";
66
+ this.autoplay = false;
67
+ this.delay = 4000;
68
+ this.paused = false;
69
+ this.held = false;
70
+ this._selfHeld = false;
71
+ this._progress = null;
72
+ this._announced = undefined;
73
+ this._onMotionChange = () => { if (this._motionQuery.matches) this.paused = true; };
74
+ }
75
+
76
+ connectedCallback() {
77
+ super.connectedCallback();
78
+ // Watched rather than read once, so turning the setting on mid-visit stops it.
79
+ if (typeof window !== "undefined" && window.matchMedia) {
80
+ this._motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
81
+ if (this._motionQuery.matches) this.paused = true;
82
+ this._motionQuery.addEventListener("change", this._onMotionChange);
83
+ }
84
+ }
85
+
86
+ disconnectedCallback() {
87
+ super.disconnectedCallback();
88
+ this._motionQuery?.removeEventListener("change", this._onMotionChange);
89
+ this._stopProgress();
90
+ }
91
+
92
+ /** count is a whole number of dots, and current always names one of them. */
93
+ willUpdate(changed) {
94
+ if (!changed.has("count") && !changed.has("current")) return;
95
+ const count = Math.max(0, Math.floor(Number(this.count) || 0));
96
+ if (count !== this.count) this.count = count;
97
+ const current = Math.min(Math.max(1, Math.floor(Number(this.current) || 1)), Math.max(1, count));
98
+ if (current !== this.current) this.current = current;
99
+ }
100
+
101
+ updated(changed) {
102
+ // A new slide, a new clock or autoplay switched: the fill starts again.
103
+ if (["count", "current", "autoplay", "delay"].some(k => changed.has(k))) this._startProgress();
104
+ this._syncProgress();
105
+ this._announcePlayState();
106
+ }
107
+
108
+ get _running() {
109
+ return this.autoplay && this.count > 1;
110
+ }
111
+
112
+ get _moving() {
113
+ return this._running && !this.paused && !this.held && !this._selfHeld;
114
+ }
115
+
116
+ /* One Web Animation per slide, on the current pill's fill. Its finish is the
117
+ advance; pausing it is pausing the clock, and the fill holds where it
118
+ stopped rather than snapping back. */
119
+ _startProgress() {
120
+ this._stopProgress();
121
+ if (!this._running) return;
122
+ const fill = this.renderRoot.querySelector('.dot[aria-current="true"] .fill');
123
+ if (!fill?.animate) return;
124
+ const delay = Math.max(MIN_DELAY, Number(this.delay) || 0);
125
+ const progress = fill.animate(
126
+ [{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }],
127
+ { duration: delay, easing: "linear", fill: "forwards" }
128
+ );
129
+ this._progress = progress;
130
+ progress.finished.then(() => {
131
+ if (this._progress !== progress) return;
132
+ this._select(this.current >= this.count ? 1 : this.current + 1);
133
+ }, () => {});
134
+ }
135
+
136
+ _stopProgress() {
137
+ const progress = this._progress;
138
+ this._progress = null;
139
+ progress?.cancel();
140
+ }
141
+
142
+ _syncProgress() {
143
+ const progress = this._progress;
144
+ if (!progress) return;
145
+ if (this._moving && progress.playState === "paused") progress.play();
146
+ else if (!this._moving && progress.playState === "running") progress.pause();
147
+ }
148
+
149
+ /* bd-toggle whenever the slides start or stop moving, for whatever reason.
150
+ An owner needs it for its live region -- slides that change on their own
151
+ must not be announced, and slides that change because the visitor asked
152
+ must be -- and only this atom knows about the holds it places itself.
153
+ Fired once on first render too, so the owner starts in step. */
154
+ _announcePlayState() {
155
+ const state = `${this.paused}|${this._moving}`;
156
+ if (state === this._announced) return;
157
+ this._announced = state;
158
+ this.dispatchEvent(new CustomEvent("bd-toggle", {
159
+ detail : { paused: this.paused, moving: this._moving },
160
+ bubbles : true,
161
+ composed: true
162
+ }));
163
+ }
164
+
165
+ _select(n) {
166
+ if (n === this.current) return;
167
+ this.current = n;
168
+ this.dispatchEvent(new CustomEvent("bd-change", {
169
+ detail : { current: n },
170
+ bubbles : true,
171
+ composed: true
172
+ }));
173
+ }
174
+
175
+ // The visitor's pick: it moves, and it stops autoplay for good.
176
+ _pick(n) {
177
+ if (this._running) this.paused = true;
178
+ this._select(n);
179
+ }
180
+
181
+ render() {
182
+ if (this.count < 2) return nothing;
183
+ const pages = Array.from({ length: this.count }, (_, i) => i + 1);
184
+ return html`<div
185
+ class="dots"
186
+ role="group"
187
+ aria-label="${this.label}"
188
+ @pointerenter=${() => { this._selfHeld = true; }}
189
+ @pointerleave=${() => { this._selfHeld = this.renderRoot.contains(this.renderRoot.activeElement); }}
190
+ @focusin=${() => { this._selfHeld = true; }}
191
+ @focusout=${e => { this._selfHeld = this.renderRoot.contains(e.relatedTarget) || this.matches(":hover"); }}
192
+ >${pages.map(n => html`<button
193
+ type="button"
194
+ class="dot"
195
+ aria-label="${this.itemLabel} ${n}"
196
+ aria-current="${n === this.current ? "true" : nothing}"
197
+ @click=${() => this._pick(n)}
198
+ ><span class="mark"><span class="fill"></span></span></button>`)}</div>`;
199
+ }
200
+ }
201
+
202
+ customElements.define("bd-pagination-dots", BdPaginationDots);