@undrr/undrr-mangrove 2.0.0-rc.2 → 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 (124) hide show
  1. package/README.md +11 -6
  2. package/components/BookCard.js +1 -1
  3. package/components/Breadcrumbs.js +2 -2
  4. package/components/Checkbox.js +1 -1
  5. package/components/Chips.js +1 -1
  6. package/components/CopyButton.js +2 -2
  7. package/components/CtaButton.js +1 -1
  8. package/components/Drawer.js +1 -1
  9. package/components/EmbedContainer.js +1 -1
  10. package/components/Footer.js +1 -1
  11. package/components/FormAction.js +1 -1
  12. package/components/FormErrorSummary.js +1 -1
  13. package/components/FormGroup.js +1 -1
  14. package/components/FullWidth.js +1 -1
  15. package/components/Gallery.js +2 -2
  16. package/components/Hero.js +2 -2
  17. package/components/HighlightBox.js +1 -1
  18. package/components/HorizontalBookCard.js +1 -1
  19. package/components/HorizontalCard.js +1 -1
  20. package/components/IconCard.js +1 -1
  21. package/components/Legend.js +1 -1
  22. package/components/Loader.js +1 -1
  23. package/components/MegaMenu.js +2 -2
  24. package/components/Notice.js +2 -2
  25. package/components/PageHeader.js +2 -2
  26. package/components/Pager.js +2 -2
  27. package/components/QuoteHighlight.js +2 -2
  28. package/components/QuoteHighlight.js.LICENSE.txt +2 -0
  29. package/components/Radio.js +1 -1
  30. package/components/Range.js +1 -1
  31. package/components/ScrollContainer.js +2 -2
  32. package/components/SegmentedControl.js +9 -0
  33. package/components/SegmentedControl.js.LICENSE.txt +9 -0
  34. package/components/Select.js +1 -1
  35. package/components/ServiceNotice.js +2 -2
  36. package/components/ShareButtons.js +2 -2
  37. package/components/ShowMore.js +2 -2
  38. package/components/SkipLink.js +1 -1
  39. package/components/StatsCard.js +1 -1
  40. package/components/SyndicationSearchWidget.js +2 -2
  41. package/components/Tab.js +2 -2
  42. package/components/TextCta.js +1 -1
  43. package/components/TextInput.js +1 -1
  44. package/components/Textarea.js +1 -1
  45. package/components/Tree.js +1 -1
  46. package/components/UserFeedback.js +1 -1
  47. package/components/VerticalCard.js +1 -1
  48. package/components/hydrate.js +2 -2
  49. package/css/style-all.css +60 -40
  50. package/css/style-all.css.map +1 -1
  51. package/css/style-delta.css +59 -39
  52. package/css/style-delta.css.map +1 -1
  53. package/css/style-gutenberg.css +16 -7
  54. package/css/style-gutenberg.css.map +1 -1
  55. package/css/style-irp.css +60 -40
  56. package/css/style-irp.css.map +1 -1
  57. package/css/style-mcr.css +59 -39
  58. package/css/style-mcr.css.map +1 -1
  59. package/css/style-preventionweb.css +60 -40
  60. package/css/style-preventionweb.css.map +1 -1
  61. package/css/style.css +59 -39
  62. package/css/style.css.map +1 -1
  63. package/error-pages/401.html +3 -3
  64. package/error-pages/403.html +3 -3
  65. package/error-pages/404.html +4 -4
  66. package/error-pages/429.html +3 -3
  67. package/error-pages/500.html +6 -6
  68. package/error-pages/502.html +3 -3
  69. package/error-pages/503.html +3 -3
  70. package/error-pages/504.html +3 -3
  71. package/error-pages/5xx.html +4 -4
  72. package/error-pages/challenge.html +3 -3
  73. package/error-pages/managed-challenge.html +3 -3
  74. package/js/README.md +31 -25
  75. package/js/copy-button.js +17 -0
  76. package/js/drawer.js +752 -0
  77. package/js/hub-header.js +217 -0
  78. package/js/show-more.js +144 -10
  79. package/js/switch-pending.js +195 -46
  80. package/package.json +8 -3
  81. package/scss/Atom/BaseTypography/Blockquote/blockquote.scss +2 -1
  82. package/scss/Atom/BaseTypography/Cite/cite.scss +2 -1
  83. package/scss/Atom/BaseTypography/Code/code.scss +8 -2
  84. package/scss/Atom/Icons/icons.scss +1 -1
  85. package/scss/Atom/Images/AuthorImage/author-image.scss +16 -1
  86. package/scss/Atom/StatusLabel/status-label.scss +331 -8
  87. package/scss/Atom/Table/table.scss +8 -3
  88. package/scss/Atom/Tag/tag.scss +20 -2
  89. package/scss/Components/Breadcrumbs/breadcrumbs.scss +8 -4
  90. package/scss/Components/Buttons/CtaButton/cta-button.scss +15 -0
  91. package/scss/Components/Cards/Card/card.scss +37 -10
  92. package/scss/Components/Cards/StatsCard/stats-card.scss +2 -10
  93. package/scss/Components/DataViz/Legend/legend.scss +2 -1
  94. package/scss/Components/ErrorPages/error-pages.scss +8 -0
  95. package/scss/Components/Forms/SegmentedControl/segmented-control.scss +412 -0
  96. package/scss/Components/Forms/_form-base.scss +109 -18
  97. package/scss/Components/Hero/hero.scss +296 -57
  98. package/scss/Components/MegaMenu/mega-menu.scss +26 -6
  99. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +3 -1
  100. package/scss/Components/PageHeader/page-header.scss +13 -28
  101. package/scss/Components/Pager/pager.scss +3 -1
  102. package/scss/Components/QuoteHighlight/quote-highlight.scss +6 -1
  103. package/scss/Components/ScrollContainer/scroll-container.scss +11 -0
  104. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +51 -24
  105. package/scss/Components/Tab/tab.scss +1 -1
  106. package/scss/Components/TextCta/text-cta.scss +11 -3
  107. package/scss/Patterns/HubHeader/hub-header.scss +14 -3
  108. package/scss/Utilities/FullWidth/full-width.scss +14 -1
  109. package/scss/assets/scss/_components.scss +1 -0
  110. package/scss/assets/scss/_fonts.scss +11 -0
  111. package/scss/assets/scss/_foundational.scss +7 -0
  112. package/scss/assets/scss/_mixins.scss +32 -0
  113. package/scss/assets/scss/_tokens-data-viz.scss +9 -0
  114. package/scss/assets/scss/_variables.scss +16 -3
  115. package/scss/assets/scss/generated/_tokens-irp.scss +20 -4
  116. package/scss/assets/scss/generated/_tokens-mangrove.scss +6 -0
  117. package/scss/assets/scss/generated/_tokens-preventionweb.scss +20 -4
  118. package/js/__tests__/copy-button.test.js +0 -112
  119. package/js/__tests__/show-more.test.js +0 -80
  120. package/js/__tests__/table-of-contents.test.js +0 -196
  121. package/scss/Components/SyndicationSearchWidget/SyndicationSearchWidget.scss +0 -11
  122. package/scss/Components/TableOfContents/TableOfContents.scss +0 -11
  123. package/scss/Utilities/FullWidth/FullWidth.scss +0 -11
  124. package/scss/Utilities/ShowMore/ShowMore.scss +0 -11
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Segmented control
3
+ *
4
+ * Compact segmented presentation of a radio group: one row of adjoining
5
+ * segments for a small, mutually exclusive choice that changes what a single
6
+ * view shows.
7
+ *
8
+ * Full documentation:
9
+ * https://github.com/unisdr/undrr-mangrove/blob/main/stories/Components/Forms/SegmentedControl/SegmentedControl.mdx
10
+ */
11
+
12
+ // The control is a presentation layer over a native radio group: a fieldset, a
13
+ // legend and one <input type="radio"> per segment, each followed by its own
14
+ // <label>. Nothing here changes the semantics, so arrow-key roving, the
15
+ // "N of M" announcement, form submission and RTL traversal are the browser's
16
+ // and stay correct. That is the whole reason this is not a tablist
17
+ // (unisdr/undrr-mangrove#1188): a tablist promises tab panels, and a view
18
+ // switcher has none.
19
+
20
+ // WHY THIS IS ITS OWN BLOCK AND NOT A .mg-form-check VARIANT
21
+
22
+ // #1188 asked for a presentation variant of Radio. The semantics are indeed
23
+ // identical, and they are reused — this is the same fieldset/legend/radio/label
24
+ // markup a FormGroup + Radio pairing produces. What is not reused is
25
+ // .mg-form-check, and the reason is that it is a round-dot presentation whose
26
+ // every dimension is the opposite of a segment's. A variant class would have to
27
+ // unset, across five rules in _form-base.scss:
28
+
29
+ // .mg-form-check display:flex, min-height:40px, align-items
30
+ // .mg-form-check__input appearance, border(2px), width/height 24px,
31
+ // margin, cursor, :hover border-color,
32
+ // :focus-visible (mg-focus-ring: outline +
33
+ // box-shadow)
34
+ // ...__input--radio background-color, border-radius:100%,
35
+ // box-shadow (the inset dot)
36
+ // ...__input--radio:checked background-color, box-shadow (the dot again)
37
+ // ...__input--radio:checked:focus-visible box-shadow (dot + band)
38
+ // .mg-form-check__label font-size, padding
39
+
40
+ // That is ~21 declarations to undo, and two of them sit outside the BEM nesting
41
+ // at 0,2,0 and 0,3,0 specifically so they win, so the variant would need
42
+ // selectors at least that heavy just to cancel them. Worse, a segment has to
43
+ // move the focus ring from the input to the label: that is not a declaration a
44
+ // variant can add, it is a mixin's output suppressed on one element and
45
+ // re-emitted on another. Twenty-one unsets and a relocated focus ring is a
46
+ // rewrite wearing a modifier's name, so this is its own block.
47
+
48
+ // WHAT THAT COSTS, AND WHAT IS OWED BACK
49
+
50
+ // The deviation is real, so the couplings are written down rather than left to
51
+ // be discovered:
52
+
53
+ // * ERROR / INVALID STATE. Radio has one (--error, aria-invalid, errorText,
54
+ // .mg-form-error) and so does FormGroup; this control has none, and it
55
+ // cannot borrow FormGroup's by nesting, because a fieldset inside a fieldset
56
+ // announces two group names. A consumer needing "you must choose one" has to
57
+ // hand-roll it — which is exactly what unisdr/undrr-mangrove#1187 reported
58
+ // against .mg-switch, and the trap is worse here because __label sets
59
+ // forced-color-adjust: none below, so a hand-rolled red is painted literally
60
+ // in forced colours. If Radio's validation wiring is ever revisited, this
61
+ // control needs the same pass.
62
+ // * DISABLED COLOUR. neutral-400 / neutral-500 are named directly, matching
63
+ // what .mg-form-check__input--disabled and .mg-form-input--disabled do
64
+ // today. A future --mg-color-form-disabled token would have to be routed
65
+ // here by hand.
66
+ // * HELP TEXT / aria-describedby. Not supported. Use a Radio group in a
67
+ // FormGroup when an option needs one.
68
+
69
+ // Two theming hooks, no more. Everything else is a shared token, so a
70
+ // segmented control follows the button, focus-ring and spacing scales of
71
+ // whichever theme wraps it. Each is read with its default where it is used,
72
+ // not declared on the block, so a value set on the control or on any ancestor
73
+ // (a toolbar, a .mg-theme-* container) reaches it.
74
+ $mg-segmented-min-block-size: var(
75
+ --mg-segmented-control-min-block-size,
76
+ #{mg-rem(44)}
77
+ );
78
+ $mg-segmented-radius: var(
79
+ --mg-segmented-control-radius,
80
+ var(--mg-radius-button)
81
+ );
82
+
83
+ .mg-segmented-control {
84
+ border: 0;
85
+ margin: 0 0 var(--mg-spacing-100);
86
+ min-inline-size: 0;
87
+ padding: 0;
88
+
89
+ &__legend {
90
+ @extend %paragraph-font-300;
91
+
92
+ display: block;
93
+ margin-bottom: var(--mg-spacing-25);
94
+ padding: 0;
95
+ }
96
+
97
+ // inline-flex, so the control is as wide as its labels and does not stretch
98
+ // across the page the way its block-level fieldset would. It is also the
99
+ // positioning ancestor for the visually hidden inputs.
100
+
101
+ // It wraps. A row that cannot fit has two ways to go: shrink the segments
102
+ // until the labels break mid-word, or take a second line. Measured at 320
103
+ // CSS px (WCAG 1.4.10 reflow, which is also 1280 at 400%), shrinking gave
104
+ // "Dep / th" and "Frequen / cy"; wrapping keeps every label whole. Only the
105
+ // first and last segment of the whole control are rounded, so a wrapped row
106
+ // ends square, which reads as "continues below" rather than as a second
107
+ // control. Per-row rounding is not expressible here: CSS cannot address the
108
+ // first or last item of a flex line, so it would take JS, which this
109
+ // component does not have.
110
+
111
+ // Two limits of the wrap, both measured, both deliberate:
112
+
113
+ // * THIS APPLIES TO THE DEFAULT LAYOUT ONLY. --full-width below sets
114
+ // `flex: 1 1 0`, which gives every segment a hypothetical main size of 0,
115
+ // so the line never overflows and the row never wraps — it shrinks
116
+ // instead, and past a certain label length the words do break. Measured
117
+ // at 320 CSS px: "Precipitatio / n", "Vulnerabilit / y", "Displaceme /
118
+ // nt". --full-width is for short labels; a narrow row with long ones
119
+ // should stay on the default layout and wrap.
120
+ // * A WRAPPED ROW STARTS 1px EARLY. The -1px border collapse below applies
121
+ // to every segment after the first, whichever line it lands on, so the
122
+ // segment that begins a second row sits 1px outside the control's
123
+ // inline-start edge (measured x=7 against x=8 for the row above). Rows
124
+ // also do not collapse in the block direction, so the seam between two
125
+ // rows is 2px where the seams within a row are 1px. Both need a
126
+ // line-aware selector CSS does not have.
127
+ &__group {
128
+ display: inline-flex;
129
+ flex-wrap: wrap;
130
+ max-inline-size: 100%;
131
+ position: relative;
132
+ }
133
+
134
+ // Hidden from sight, not from anything else. The input keeps its size, its
135
+ // focus, its role and its place in the tab order; only its painting is
136
+ // clipped away, and the label next to it is what the user sees and presses.
137
+ // Never display: none or visibility: hidden here — both take the radio out
138
+ // of the accessibility tree and out of the keyboard's reach.
139
+ &__input {
140
+ appearance: none;
141
+ border: 0;
142
+ clip-path: inset(50%);
143
+ height: 1px;
144
+ margin: -1px;
145
+ overflow: hidden;
146
+ padding: 0;
147
+ position: absolute;
148
+ white-space: nowrap;
149
+ width: 1px;
150
+ }
151
+
152
+ // The segment itself, drawn from the outline button's tokens: same stroke
153
+ // width, same radius, same padding, same type. A consumer who styles this by
154
+ // hand is the drift the issue reported, so nothing here is a bespoke value.
155
+ &__label {
156
+ align-items: center;
157
+ background-color: transparent;
158
+ border: var(--mg-border-width-button) solid
159
+ rgb(var(--mg-color-button-outline-primary));
160
+ color: rgb(var(--mg-color-button-outline-primary));
161
+ cursor: pointer;
162
+ display: inline-flex;
163
+ flex: 0 1 auto;
164
+ font-family: var(--mg-font-family-text);
165
+ font-size: var(--mg-font-size-button);
166
+ font-weight: 600;
167
+ justify-content: center;
168
+ line-height: 1.2;
169
+ min-block-size: $mg-segmented-min-block-size;
170
+ min-inline-size: 0;
171
+ overflow-wrap: anywhere;
172
+ padding: var(--mg-padding-button);
173
+ text-align: center;
174
+
175
+ // Local stacking context only: the focus ring and the selected fill have
176
+ // to sit above the neighbouring segment, whose border this one overlaps.
177
+ // No page-level layer is involved, so a raw integer is correct here.
178
+ position: relative;
179
+ z-index: 0;
180
+
181
+ @media (prefers-reduced-motion: no-preference) {
182
+ transition:
183
+ background-color var(--mg-motion-duration-fast)
184
+ var(--mg-motion-easing-standard),
185
+ border-color var(--mg-motion-duration-fast)
186
+ var(--mg-motion-easing-standard),
187
+ color var(--mg-motion-duration-fast) var(--mg-motion-easing-standard);
188
+ }
189
+ }
190
+
191
+ // Only the ends are rounded, and they are rounded with logical corners, so
192
+ // the row mirrors itself under dir="rtl" with no RTL rule to keep in step.
193
+ &__label:first-of-type {
194
+ border-end-start-radius: $mg-segmented-radius;
195
+ border-start-start-radius: $mg-segmented-radius;
196
+ }
197
+
198
+ &__label:last-of-type {
199
+ border-end-end-radius: $mg-segmented-radius;
200
+ border-start-end-radius: $mg-segmented-radius;
201
+ }
202
+
203
+ // Adjoining, not spaced: every segment after the first pulls back over its
204
+ // neighbour's stroke so the row reads as one control with single-width
205
+ // dividers. margin-inline-start flips with direction on its own.
206
+ &__input:not(:first-of-type) + &__label {
207
+ margin-inline-start: calc(-1 * var(--mg-border-width-button));
208
+ }
209
+
210
+ // Full width: the segments share the control's inline size equally. Useful
211
+ // where the control owns a panel header or a mobile row.
212
+ &--full-width &__group {
213
+ display: flex;
214
+ }
215
+
216
+ &--full-width &__label {
217
+ flex: 1 1 0;
218
+ }
219
+
220
+ // One shared compact size, so a toolbar or a map overlay does not invent its
221
+ // own. It stays above the 24x24 WCAG 2.5.8 minimum; the 44px default is the
222
+ // recommended target and is what an unmodified control gives you.
223
+ &--small {
224
+ --mg-segmented-control-min-block-size: #{mg-rem(36)};
225
+
226
+ .mg-segmented-control__label {
227
+ font-size: var(--mg-font-size-200);
228
+ padding: var(--mg-spacing-50) var(--mg-spacing-100);
229
+ }
230
+ }
231
+ }
232
+
233
+ // States live outside the BEM nesting, in specificity order: hover (lowest),
234
+ // then selected, then disabled, then focus — so focus is visible on any of
235
+ // them and a disabled segment never lights up.
236
+
237
+ // Hover is written on the LABEL, not on `input:hover + label`. Pointing at a
238
+ // label does not put :hover on the input it is for, so the input-side
239
+ // selector would only ever match the pixel the input is clipped to.
240
+
241
+ // THE HOVER FILL IS A KNOWN CONTRAST PROBLEM IN TWO THEMES, AND #1249 IS THE
242
+ // FIX. --mg-color-button-outline-primary--hover and
243
+ // --mg-color-button-background--hover both resolve to orange-900 (#eb752a) in
244
+ // PreventionWeb and IRP, because both themes set interactive-active equal to
245
+ // interactive and had no tonal step of their own to hover to. Against the white
246
+ // label that is 2.95:1 — under SC 1.4.3 — and it applies to BOTH hover states
247
+ // here, selected and unselected. Measured in a browser on this component:
248
+
249
+ // theme selected rest unselected rest hover fill (either)
250
+ // UNDRR #004f91 8.31 #004f91 8.31 #3372a7 5.11 ok
251
+ // PreventionWeb #0a6969 6.49 #0a6969 6.49 #eb752a 2.95 FAILS
252
+ // IRP #0f78bf 4.71 #0f78bf 4.71 #eb752a 2.95 FAILS
253
+ // MCR2030 #591a61 12.03 #591a61 12.03 #6e2677 9.52 ok
254
+ // DELTA #004f91 8.31 #004f91 8.31 #106cb8 ok
255
+
256
+ // tokens-contract.test.js records this pair as an exception under the names
257
+ // "button label on primary background, hover" and "outline primary button
258
+ // label, hover fill" — but those names are cta-button.scss's, and the suite
259
+ // checks token pairs rather than components, so this control's use of the same
260
+ // pair adds no entry and the suite stays green while a second component ships
261
+ // the failure. The exception is a defect record, not a licence.
262
+ // unisdr/undrr-mangrove#1249 re-points both tokens (PW to teal-800 at 5.50,
263
+ // IRP to blue-800 at 6.02) and deletes those entries; this control then clears
264
+ // 1.4.3 in all five themes with no change of its own.
265
+
266
+ // ==> DO NOT MERGE THIS COMPONENT AHEAD OF #1249. <==
267
+
268
+ // Second, smaller point, present in every theme: hover fills an UNSELECTED
269
+ // segment and puts the white label on it, which is the selected treatment. So
270
+ // while the pointer is on an unselected segment the row shows two filled
271
+ // segments, and in UNDRR the only thing separating them is #004f91 against
272
+ // #3372a7 — 1.62:1, under SC 1.4.11's 3:1 for a state indicator. Hovering the
273
+ // SELECTED segment moves it to that same #3372a7, so it briefly looks like any
274
+ // other hovered segment. A tinted hover that keeps the brand ink would leave
275
+ // "filled" meaning "selected" and nothing else; it is not done here because
276
+ // every candidate tint puts an existing token into a contrast role
277
+ // tokens-contract.test.js does not yet cover, in five themes, and that is a
278
+ // design decision rather than a review one.
279
+ .mg-segmented-control__label:hover {
280
+ background-color: rgb(var(--mg-color-button-outline-primary--hover));
281
+ border-color: rgb(var(--mg-color-button-outline-primary--hover));
282
+ color: rgb(var(--mg-color-button));
283
+ z-index: 1;
284
+ }
285
+
286
+ // Selected takes the filled primary button's pair, which every theme has
287
+ // already contrast-checked for its own brand colour.
288
+ .mg-segmented-control__input:checked + .mg-segmented-control__label {
289
+ background-color: var(--mg-color-button-background);
290
+ border-color: var(--mg-color-button-background);
291
+ color: rgb(var(--mg-color-button));
292
+ z-index: 1;
293
+ }
294
+
295
+ .mg-segmented-control__input:checked + .mg-segmented-control__label:hover {
296
+ background-color: var(--mg-color-button-background--hover);
297
+ border-color: var(--mg-color-button-background--hover);
298
+ }
299
+
300
+ // Declared after the selected rules, and one class heavier than each of them,
301
+ // so a disabled segment keeps this treatment while the pointer is over it.
302
+ .mg-segmented-control__input:disabled + .mg-segmented-control__label,
303
+ .mg-segmented-control__input:disabled + .mg-segmented-control__label:hover {
304
+ background-color: transparent;
305
+ border-color: rgb(var(--mg-color-neutral-400));
306
+ color: rgb(var(--mg-color-neutral-400));
307
+ cursor: not-allowed;
308
+ z-index: 0;
309
+ }
310
+
311
+ .mg-segmented-control__input:disabled:checked + .mg-segmented-control__label,
312
+ .mg-segmented-control__input:disabled:checked
313
+ + .mg-segmented-control__label:hover {
314
+ background-color: rgb(var(--mg-color-neutral-500));
315
+ border-color: rgb(var(--mg-color-neutral-500));
316
+ color: rgb(var(--mg-color-neutral-0));
317
+ }
318
+
319
+ // The ring is drawn on the label because the input it belongs to is clipped to
320
+ // a pixel. mg-focus-ring is an outline plus a separator band, and neither is
321
+ // re-declared by any rule above, so a focused segment keeps its ring whether
322
+ // it is selected, hovered or both.
323
+ .mg-segmented-control__input:focus-visible + .mg-segmented-control__label {
324
+ @include mg-focus-ring;
325
+
326
+ z-index: 2;
327
+ }
328
+
329
+ // Forced colours forces every background to Canvas and every foreground to
330
+ // CanvasText, whatever the author wrote — a system colour keyword in an author
331
+ // declaration is forced like any other, which was measured on this component:
332
+ // `background-color: Highlight` on the selected segment came back as Canvas
333
+ // and the row read as three identical, unselected boxes. Selection is the one
334
+ // thing this control has to show, so the segments opt out of forcing and
335
+ // repaint themselves in system colours, exactly as .mg-switch does for its
336
+ // track. Nothing here uses opacity: a faded system colour loses its contrast.
337
+ // Opting out has a cost, and it is the one unisdr/undrr-mangrove#1187 found on
338
+ // the switch: with forcing off, an author colour that still reaches the
339
+ // element is painted literally in a palette that has no such colour. Every
340
+ // selector below therefore carries an extra .mg-segmented-control so that it
341
+ // outranks, or at least ties and follows, the author state rule it replaces —
342
+ // including the :hover variants, which are one class heavier than the state
343
+ // they sit on and would otherwise repaint a segment in brand blue.
344
+ @media (forced-colors: active) {
345
+ .mg-segmented-control .mg-segmented-control__label {
346
+ background-color: Canvas;
347
+ border-color: ButtonText;
348
+ color: ButtonText;
349
+ forced-color-adjust: none;
350
+ }
351
+
352
+ // The Highlight pair, which is what a selected item looks like in every
353
+ // forced-colours palette.
354
+ .mg-segmented-control
355
+ .mg-segmented-control__input:checked
356
+ + .mg-segmented-control__label,
357
+ .mg-segmented-control
358
+ .mg-segmented-control__input:checked
359
+ + .mg-segmented-control__label:hover {
360
+ background-color: Highlight;
361
+ border-color: Highlight;
362
+ color: HighlightText;
363
+ }
364
+
365
+ .mg-segmented-control
366
+ .mg-segmented-control__input:disabled
367
+ + .mg-segmented-control__label,
368
+ .mg-segmented-control
369
+ .mg-segmented-control__input:disabled
370
+ + .mg-segmented-control__label:hover {
371
+ background-color: Canvas;
372
+ border-color: GrayText;
373
+ color: GrayText;
374
+ }
375
+
376
+ // A disabled selection still has to read as the selection, so it keeps a
377
+ // fill and takes GrayText for it.
378
+ .mg-segmented-control
379
+ .mg-segmented-control__input:disabled:checked
380
+ + .mg-segmented-control__label,
381
+ .mg-segmented-control
382
+ .mg-segmented-control__input:disabled:checked
383
+ + .mg-segmented-control__label:hover {
384
+ background-color: GrayText;
385
+ border-color: GrayText;
386
+ color: Canvas;
387
+ }
388
+
389
+ // forced-color-adjust: none above would otherwise keep mg-focus-ring's white
390
+ // separator band, which is an author colour on a system-coloured segment.
391
+ // The band goes and the ring becomes Highlight, drawn outside the segment,
392
+ // where it is legible against a selected segment's fill as well.
393
+ .mg-segmented-control
394
+ .mg-segmented-control__input:focus-visible
395
+ + .mg-segmented-control__label {
396
+ box-shadow: none;
397
+ outline: var(--mg-focus-ring-width) solid Highlight;
398
+ outline-offset: var(--mg-focus-ring-offset);
399
+ }
400
+
401
+ // Hover is a colour-only change, and forced colours leaves no second fill to
402
+ // move to. An inner boundary is the affordance that survives;
403
+ // :not(:focus-visible) on the input keeps the focus outline winning when the
404
+ // pointer is over a focused segment, and :not(:disabled) keeps a disabled
405
+ // segment inert.
406
+ .mg-segmented-control
407
+ .mg-segmented-control__input:not(:disabled, :focus-visible)
408
+ + .mg-segmented-control__label:hover {
409
+ outline: 1px solid ButtonText;
410
+ outline-offset: calc(-1 * var(--mg-border-width-button));
411
+ }
412
+ }
@@ -297,6 +297,43 @@ $mg-switch-ring-gap: var(
297
297
  rgb(var(--mg-color-neutral-300))
298
298
  );
299
299
 
300
+ // Error follows the rest of the form: the invalid control takes a red
301
+ // boundary and the message is a sibling .mg-form-error. The track's inset is
302
+ // a transparent border (below), so the error is a border-color change, the
303
+ // same declaration .mg-form-input--error makes. Nothing here is a box-shadow
304
+ // or an outline, so the focus ring's outline and separator band survive it.
305
+ $mg-switch-error: var(
306
+ --mg-switch-track-border-color--error,
307
+ rgb(var(--mg-color-red-900))
308
+ );
309
+
310
+ // Geometry hooks. --mg-switch-size is the one number most consumers set: the
311
+ // track's block size. The track's inline size, the inset around the thumb, the
312
+ // thumb and the thumb's travel all derive from it, so resizing a switch never
313
+ // means re-declaring the transform -- which is what broke RTL, where the
314
+ // travel is the same distance the other way. See unisdr/undrr-mangrove#1199.
315
+ // Each part can still be set on its own, and every derived value is written
316
+ // once and reused by the LTR rule, the RTL rule and the forced-colours block.
317
+ $mg-switch-size: var(--mg-switch-size, #{mg-rem(24)});
318
+ $mg-switch-track-block: var(--mg-switch-track-block-size, #{$mg-switch-size});
319
+ $mg-switch-track-inline: var(
320
+ --mg-switch-track-inline-size,
321
+ calc(#{$mg-switch-track-block} * 1.75)
322
+ );
323
+ $mg-switch-inset: var(
324
+ --mg-switch-track-inset,
325
+ calc(#{$mg-switch-track-block} / 12)
326
+ );
327
+ $mg-switch-thumb-size: var(
328
+ --mg-switch-thumb-size,
329
+ calc(#{$mg-switch-track-block} - 2 * #{$mg-switch-inset})
330
+ );
331
+
332
+ // The thumb crosses the track's content box: the track's inline size less its
333
+ // two insets, less the thumb. Interpolated as a string so the LTR rule, the
334
+ // RTL rule and any test read one expression.
335
+ $mg-switch-travel: "#{$mg-switch-track-inline} - #{$mg-switch-thumb-size} - 2 * #{$mg-switch-inset}";
336
+
300
337
  .mg-switch {
301
338
  align-items: center;
302
339
  cursor: pointer;
@@ -326,7 +363,7 @@ $mg-switch-ring-gap: var(
326
363
  background-color: $mg-switch-track-checked;
327
364
 
328
365
  .mg-switch__thumb {
329
- transform: translateX(mg-rem(18));
366
+ transform: translateX(calc(#{$mg-switch-travel}));
330
367
  }
331
368
  }
332
369
 
@@ -374,12 +411,14 @@ $mg-switch-ring-gap: var(
374
411
  );
375
412
  cursor: progress;
376
413
 
414
+ // Both lengths are fractions of the thumb (2px and 4px on a default
415
+ // 20px thumb), so a resized switch keeps the same ring proportions.
377
416
  .mg-switch__thumb::after {
378
- border: mg-rem(2) dotted $mg-switch-ring;
417
+ border: calc(#{$mg-switch-thumb-size} * 0.1) dotted $mg-switch-ring;
379
418
  border-radius: 50%;
380
419
  box-sizing: border-box;
381
420
  content: "";
382
- inset: mg-rem(4);
421
+ inset: calc(#{$mg-switch-thumb-size} * 0.2);
383
422
  position: absolute;
384
423
  }
385
424
 
@@ -410,30 +449,37 @@ $mg-switch-ring-gap: var(
410
449
  cursor: progress;
411
450
  }
412
451
 
452
+ // The inset around the thumb is a transparent border rather than padding:
453
+ // it holds exactly the same box (border-box sizing, same content box, same
454
+ // travel) and gives the error state a border-color to set, the way every
455
+ // other control shows an error. Forced colours colours the same border.
413
456
  &__track {
414
457
  align-items: center;
415
458
  background-color: $mg-switch-track;
416
- border-radius: mg-rem(12);
459
+ block-size: $mg-switch-track-block;
460
+ border: $mg-switch-inset solid transparent;
461
+ border-radius: calc(#{$mg-switch-track-block} / 2);
417
462
  box-sizing: border-box;
418
463
  display: inline-flex;
419
464
  flex-shrink: 0;
420
- height: mg-rem(24);
421
- padding: mg-rem(2);
422
- transition: background-color var(--mg-motion-duration-fast)
423
- var(--mg-motion-easing-standard);
424
- width: mg-rem(42);
465
+ inline-size: $mg-switch-track-inline;
466
+ transition:
467
+ background-color var(--mg-motion-duration-fast)
468
+ var(--mg-motion-easing-standard),
469
+ border-color var(--mg-motion-duration-fast)
470
+ var(--mg-motion-easing-standard);
425
471
  }
426
472
 
427
473
  &__thumb {
428
474
  background-color: $mg-switch-thumb;
475
+ block-size: $mg-switch-thumb-size;
429
476
  border-radius: 50%;
430
477
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
431
478
  display: block;
432
- height: mg-rem(20);
479
+ inline-size: $mg-switch-thumb-size;
433
480
  position: relative;
434
481
  transition: transform var(--mg-motion-duration-fast)
435
482
  var(--mg-motion-easing-standard);
436
- width: mg-rem(20);
437
483
  }
438
484
 
439
485
  &__label {
@@ -441,6 +487,17 @@ $mg-switch-ring-gap: var(
441
487
  font-size: var(--mg-font-size-300);
442
488
  }
443
489
 
490
+ // One shared small size for a toolbar, a table row or a panel header, so
491
+ // each consumer does not invent its own. It sets the size hook, and tightens
492
+ // the row's gap to match the smaller graphic; no length inside the switch is
493
+ // re-declared. The row keeps its 36px min-height, so the target stays large
494
+ // enough even though the graphic is smaller.
495
+ &--small {
496
+ --mg-switch-size: #{mg-rem(18)};
497
+
498
+ gap: var(--mg-spacing-50);
499
+ }
500
+
444
501
  @media (prefers-reduced-motion: reduce) {
445
502
  &__track,
446
503
  &__thumb {
@@ -449,8 +506,25 @@ $mg-switch-ring-gap: var(
449
506
  }
450
507
  }
451
508
 
509
+ // Error, in the three shapes the rest of the library already uses: the
510
+ // aria-invalid the other controls set anyway, the --error element modifier
511
+ // they carry, and a block modifier for markup that can only reach the label,
512
+ // as .mg-switch--pending does. Declared after the checked rule, so an invalid
513
+ // switch keeps its red boundary in either position.
514
+ .mg-switch__input[aria-invalid="true"] + .mg-switch__track,
515
+ .mg-switch__input--error + .mg-switch__track,
516
+ .mg-switch--error .mg-switch__track {
517
+ border-color: $mg-switch-error;
518
+ }
519
+
520
+ // The same distance the other way. Both rules interpolate one expression, so
521
+ // a consumer setting the size hooks gets a correct RTL travel for free and
522
+ // never has a transform of their own to keep in step. The descendant
523
+ // combinator already covers a switch carrying `dir="rtl"` itself, because the
524
+ // label is an ancestor of its own input -- no `.mg-switch[dir="rtl"]` variant
525
+ // is needed, and adding one would only raise this rule above a consumer's.
452
526
  [dir="rtl"] .mg-switch__input:checked + .mg-switch__track .mg-switch__thumb {
453
- transform: translateX(mg-rem(-18));
527
+ transform: translateX(calc(-1 * (#{$mg-switch-travel})));
454
528
  }
455
529
 
456
530
  // Forced colours paint every background as Canvas, which erased the track and
@@ -461,12 +535,12 @@ $mg-switch-ring-gap: var(
461
535
  @media (forced-colors: active) {
462
536
  .mg-switch__track {
463
537
  // A border, not an outline, so the focus outline adds to the boundary
464
- // rather than replacing it. The border takes 1px from the padding, so the
465
- // track stays 42x24 and the thumb keeps its size and travel.
538
+ // rather than replacing it. The track already carries its inset as a
539
+ // transparent border, so forced colours only colours it: the box, the
540
+ // thumb and the travel are untouched at any size.
466
541
  background-color: Canvas;
467
- border: 1px solid CanvasText;
542
+ border-color: CanvasText;
468
543
  forced-color-adjust: none;
469
- padding: 1px;
470
544
  }
471
545
 
472
546
  .mg-switch__thumb {
@@ -547,6 +621,23 @@ $mg-switch-ring-gap: var(
547
621
  }
548
622
  }
549
623
 
624
+ // The track opts out of forcing, so an author red would be painted
625
+ // unchanged here while everything around it adapts -- the bug reported in
626
+ // unisdr/undrr-mangrove#1187. The boundary goes back to a system colour and
627
+ // the error is carried by a dashed edge instead, which survives a palette
628
+ // with no red in it. The .mg-form-error message carries the rest.
629
+ // Each selector carries an extra .mg-switch, as the disabled rules above do,
630
+ // so that all three reach the checked fill's specificity. Without it the two
631
+ // class markups lost `border-color` to the Highlight fill and kept only
632
+ // `border-style`, which painted a dashed Highlight edge on a Highlight
633
+ // track: an invalid checked switch with no visible boundary at all.
634
+ .mg-switch .mg-switch__input[aria-invalid="true"] + .mg-switch__track,
635
+ .mg-switch .mg-switch__input--error + .mg-switch__track,
636
+ .mg-switch.mg-switch--error .mg-switch__track {
637
+ border-color: CanvasText;
638
+ border-style: dashed;
639
+ }
640
+
550
641
  @media (prefers-reduced-motion: no-preference) {
551
642
  // Every ring colour selector above is repeated here, so the gap outranks
552
643
  // or follows each one; without a gap the spin would be invisible.
@@ -596,7 +687,7 @@ $mg-switch-ring-gap: var(
596
687
  // -----------------------------------------------------------------------------
597
688
  .mg-form-error-summary {
598
689
  background-color: rgb(var(--mg-color-red-50));
599
- border-left: 4px solid rgb(var(--mg-color-red-900));
690
+ border-inline-start: 4px solid rgb(var(--mg-color-red-900));
600
691
  margin-bottom: var(--mg-spacing-200);
601
692
  padding: var(--mg-spacing-150);
602
693
 
@@ -617,7 +708,7 @@ $mg-switch-ring-gap: var(
617
708
 
618
709
  &__list {
619
710
  margin: 0;
620
- padding-left: var(--mg-spacing-100);
711
+ padding-inline-start: var(--mg-spacing-100);
621
712
 
622
713
  li {
623
714
  margin-bottom: var(--mg-spacing-50);