@volverjs/style 0.1.26 → 0.1.28

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 (51) hide show
  1. package/README.md +12 -0
  2. package/dist/base.css +1 -1
  3. package/dist/components/vv-accordion.css +1 -1
  4. package/dist/components/vv-alert-group.css +1 -1
  5. package/dist/components/vv-alert.css +1 -1
  6. package/dist/components/vv-avatar.css +1 -1
  7. package/dist/components/vv-badge.css +1 -1
  8. package/dist/components/vv-button-group.css +1 -1
  9. package/dist/components/vv-button.css +1 -1
  10. package/dist/components/vv-card.css +1 -1
  11. package/dist/components/vv-checkbox.css +1 -1
  12. package/dist/components/vv-dialog.css +1 -1
  13. package/dist/components/vv-dropdown-option.css +1 -1
  14. package/dist/components/vv-dropdown.css +1 -1
  15. package/dist/components/vv-input-file.css +1 -1
  16. package/dist/components/vv-input-range.css +1 -1
  17. package/dist/components/vv-input-text.css +1 -1
  18. package/dist/components/vv-nav.css +1 -1
  19. package/dist/components/vv-progress.css +1 -1
  20. package/dist/components/vv-radio.css +1 -1
  21. package/dist/components/vv-select.css +1 -1
  22. package/dist/components/vv-textarea.css +1 -1
  23. package/dist/components.css +1 -1
  24. package/dist/export.css +1 -1
  25. package/dist/props/background.css +1 -1
  26. package/dist/props/input.css +1 -1
  27. package/dist/props/typography.css +1 -1
  28. package/dist/props.css +1 -1
  29. package/dist/reset.css +1 -1
  30. package/dist/utilities/backgrounds.css +1 -1
  31. package/dist/utilities/flexbox.css +1 -1
  32. package/dist/utilities/layout.css +1 -1
  33. package/dist/utilities/sizing.css +1 -1
  34. package/dist/utilities/typography.css +1 -1
  35. package/dist/utilities.css +1 -1
  36. package/dist/volver.css +1 -1
  37. package/package.json +8 -1
  38. package/src/_config.scss +6 -1
  39. package/src/settings/_input.scss +8 -0
  40. package/src/settings/components/_vv-accordion.scss +6 -4
  41. package/src/settings/components/_vv-alert.scss +8 -3
  42. package/src/settings/components/_vv-badge.scss +21 -1
  43. package/src/settings/components/_vv-button.scss +12 -5
  44. package/src/settings/components/_vv-checkbox.scss +27 -3
  45. package/src/settings/components/_vv-dropdown-option.scss +4 -1
  46. package/src/settings/components/_vv-input-text.scss +3 -0
  47. package/src/settings/components/_vv-nav.scss +26 -1
  48. package/src/settings/components/_vv-radio.scss +17 -2
  49. package/src/settings/components/_vv-select.scss +5 -0
  50. package/src/tools/mixin-modules/_bem.scss +65 -11
  51. package/src/utilities/flexbox.scss +27 -0
package/package.json CHANGED
@@ -24,10 +24,17 @@
24
24
  "bugs": {
25
25
  "url": "https://github.com/volverjs/style/issues"
26
26
  },
27
- "version": "0.1.26",
27
+ "version": "0.1.28",
28
28
  "engines": {
29
29
  "node": ">= 20.x"
30
30
  },
31
+ "browserslist": [
32
+ "chrome >= 133",
33
+ "edge >= 133",
34
+ "firefox >= 128",
35
+ "safari >= 16.4",
36
+ "ios_saf >= 16.4"
37
+ ],
31
38
  "type": "module",
32
39
  "main": "dist/volver.css",
33
40
  "style": "dist/volver.css",
package/src/_config.scss CHANGED
@@ -22,7 +22,12 @@ $zero-specificity-for-utilities: true !default;
22
22
  // components prefix
23
23
  $components-prefix: vv !default;
24
24
 
25
- // preflight
25
+ // preflight: bare form controls under `.preflight` take the look of their
26
+ // component through `@extend` of its placeholders, the block-level state
27
+ // placeholders included. A `state.disabled` or `state.readonly` override in a
28
+ // component map therefore reaches every bare disabled or readonly control on
29
+ // the page, not only the ones inside a `.vv-*` block, and only the compiled
30
+ // CSS shows it. Overrides on the `element` maps of a state stay in the block.
26
31
  $preflight: true !default;
27
32
 
28
33
  // components names
@@ -59,6 +59,14 @@ $input: (
59
59
  translate: 0 -0.8em,
60
60
  ),
61
61
  ),
62
+ // Block size of a checkbox, a radio or the pill of a switch: the side of
63
+ // the square, and the figure the offset that keeps the control on the
64
+ // first line of a wrapping label is derived from. It was written twice per
65
+ // component, once as `min-width` and once as `height`, and the switch
66
+ // spelled a third value.
67
+ control: (
68
+ size: var(--spacing-20),
69
+ ),
62
70
  range: (
63
71
  accent-color: var(--color-brand),
64
72
  track-color: var(--color-surface-3),
@@ -1,6 +1,8 @@
1
1
  $vv-accordion: (
2
- --accordion-marker-size:
3
- calc(var(--spacing-sm) + var(--leading-normal) * 1em),
2
+ // `1lh` is resolved where the token is read, on the summary, so the reserved
3
+ // space follows the line box of the summary rather than a line height
4
+ // spelled out a second time here.
5
+ --accordion-marker-size: calc(var(--spacing-sm) + 1lh),
4
6
  --accordion-padding-top: var(--spacing-14),
5
7
  --accordion-padding-right: var(--spacing-14),
6
8
  --accordion-padding-bottom: var(--spacing-14),
@@ -57,8 +59,8 @@ $vv-accordion: (
57
59
  position: absolute,
58
60
  [top]: var(--accordion-padding-top),
59
61
  [left]: var(--accordion-padding-left),
60
- width: calc(var(--leading-normal) * 1em),
61
- height: calc(var(--leading-normal) * 1em),
62
+ width: 1lh,
63
+ height: 1lh,
62
64
  background-image: var(--bg-chevron),
63
65
  background-position: center,
64
66
  background-size: 1em,
@@ -8,7 +8,7 @@ $vv-alert: (
8
8
  border-width: var(--border),
9
9
  display: flex,
10
10
  flex-wrap: wrap,
11
- align-items: center,
11
+ align-items: flex-start,
12
12
  gap: var(--spacing-sm),
13
13
  min-width: 0,
14
14
  overflow: hidden,
@@ -16,13 +16,13 @@ $vv-alert: (
16
16
  element: (
17
17
  header: (
18
18
  display: flex,
19
- align-items: center,
19
+ align-items: flex-start,
20
20
  gap: var(--spacing-sm),
21
21
  line-height: var(--leading-normal),
22
22
  color: var(--color-word),
23
23
  overflow: hidden,
24
24
  text-overflow: ellipsis,
25
- flex-shrink: 0,
25
+ min-width: 0,
26
26
  ),
27
27
  title: (
28
28
  min-width: 0,
@@ -53,7 +53,12 @@ $vv-alert: (
53
53
  border-radius: var(--rounded),
54
54
  ),
55
55
  icon: (
56
+ --alert-icon-size: 1.1em,
57
+ [inline-size]: var(--alert-icon-size),
58
+ block-size: auto,
59
+ aspect-ratio: 1,
56
60
  flex-shrink: 0,
61
+ [translate]: 0 calc((1lh - var(--alert-icon-size)) / 2),
57
62
  ),
58
63
  close-mask: (
59
64
  display: none,
@@ -38,10 +38,26 @@ $vv-badge: (
38
38
  ),
39
39
  ),
40
40
  element: (
41
+ icon: (
42
+ // A descendant selector, not a child one: the icon of an action
43
+ // badge sits inside the button. `svg:not(svg *)` leaves the parts
44
+ // of a composite icon to the icon itself.
45
+ _alias: ':where(svg:not(svg *), [data-icon])',
46
+ aspect-ratio: 1,
47
+ inline-size: 1lh,
48
+ block-size: auto,
49
+ flex-shrink: 0,
50
+ background-position: center,
51
+ background-repeat: no-repeat,
52
+ background-size: contain,
53
+ ),
41
54
  button: (
42
55
  _alias: '> button',
43
56
  _combinator: '>',
44
57
  cursor: pointer,
58
+ display: inline-flex,
59
+ align-items: center,
60
+ justify-content: center,
45
61
  padding: 0 0.5em,
46
62
  margin: 0 -0.5em,
47
63
  min-height: 2em,
@@ -58,7 +74,11 @@ $vv-badge: (
58
74
  ),
59
75
  modifier: (
60
76
  action: (
61
- gap: calc(0.5ch + 0.5em),
77
+ // The same gap as a badge that holds its icon directly. The button
78
+ // widens its hit area toward the text with a negative margin and
79
+ // puts the drawing back with an equal padding, so the extra 0.5em
80
+ // this gap used to carry only pushed the icon away from the label.
81
+ gap: 0.5ch,
62
82
  ),
63
83
  sm: (
64
84
  --badge-font-size: 0.6rem,
@@ -49,7 +49,14 @@ $vv-button: (
49
49
  inline-size: 1.1em,
50
50
  margin: 0,
51
51
  flex-shrink: 0,
52
- [min-height]: calc(1em * var(--button-line-height)),
52
+ // The box is 1.1em wide and at least one line tall, so it is not
53
+ // square. An `<svg>` centres itself in it, an icon painted as a
54
+ // background does not: these three are inert for the first and
55
+ // place the second. The `_alias` claims to take both.
56
+ background-position: center,
57
+ background-repeat: no-repeat,
58
+ background-size: contain,
59
+ [min-height]: 1lh,
53
60
  ),
54
61
  label: (
55
62
  white-space: nowrap,
@@ -259,8 +266,8 @@ $vv-button: (
259
266
  element: (
260
267
  icon: (
261
268
  margin-inline: -0.125ch,
262
- block-size: calc(1em * var(--leading-snug)),
263
- inline-size: calc(1em * var(--leading-snug)),
269
+ block-size: 1lh,
270
+ inline-size: 1lh,
264
271
  ),
265
272
  ),
266
273
  ),
@@ -298,8 +305,8 @@ $vv-button: (
298
305
  element: (
299
306
  icon: (
300
307
  margin-inline: -0.125ch,
301
- block-size: calc(1em * var(--leading-snug)),
302
- inline-size: calc(1em * var(--leading-snug)),
308
+ block-size: 1lh,
309
+ inline-size: 1lh,
303
310
  ),
304
311
  ),
305
312
  ),
@@ -24,8 +24,17 @@ $vv-checkbox: (
24
24
  input: (
25
25
  _alias: "> input[type='checkbox']",
26
26
  border: var(--spacing-2) solid var(--input-color),
27
- min-width: var(--spacing-20),
28
- height: var(--spacing-20),
27
+ // Plain declarations, so the size is read on the control: the
28
+ // `switch` modifier overrides the token, and a `var()` inside a
29
+ // generated custom property would have been substituted at the
30
+ // root and frozen at the default.
31
+ [min-width]: var(--input-control-size),
32
+ [height]: var(--input-control-size),
33
+ // The control belongs to the first line of the label, not to the
34
+ // centre of a label that wraps. `align-self` rather than
35
+ // `align-items` on the block, so the hint keeps its own alignment.
36
+ align-self: flex-start,
37
+ [translate]: 0 calc((1lh - var(--input-control-size)) / 2),
29
38
  margin: 0 var(--input-label-gap) 0 0,
30
39
  background-position: center,
31
40
  background-size: var(--text-12) auto,
@@ -45,6 +54,19 @@ $vv-checkbox: (
45
54
  ),
46
55
  ),
47
56
  ),
57
+ label: (
58
+ // Without an element the label is an anonymous flex item, and one
59
+ // whose max-content width does not fit starts a new flex line:
60
+ // a long label dropped below the control instead of sitting next
61
+ // to it. `@volverjs/ui-vue` renders the span this reaches.
62
+ // Only a span without a class of its own, for plain markup: a
63
+ // badge or an icon written next to the label is a span too, and
64
+ // `flex: 1` would stretch it. Anything carrying a class takes
65
+ // `vv-checkbox__label` to be the label.
66
+ _alias: '> span:not([class])',
67
+ flex: 1,
68
+ min-width: 0,
69
+ ),
48
70
  hint: (
49
71
  _alias: '> small',
50
72
  _combinator: '>',
@@ -68,9 +90,11 @@ $vv-checkbox: (
68
90
  switch: (
69
91
  element: (
70
92
  input: (
93
+ --input-control-size: var(--spacing-24),
71
94
  border: 0,
95
+ // Not a plain declaration: this one reads no token of the
96
+ // control, so it keeps its generated custom property.
72
97
  min-width: var(--spacing-44),
73
- height: var(--spacing-24),
74
98
  border-radius: var(--spacing-12),
75
99
  background-color: var(--color-word-5),
76
100
  position: relative,
@@ -3,7 +3,10 @@ $vv-dropdown-option: (
3
3
  min-width: 0,
4
4
  overflow: hidden,
5
5
  gap: var(--spacing-sm),
6
- align-items: flex-end,
6
+ // The hint is smaller than the label: `baseline` is what keeps the two
7
+ // sitting on the same line of text. `flex-end` aligned their bottom edges
8
+ // instead, which drifts with the descenders of the font.
9
+ align-items: baseline,
7
10
  padding: var(--spacing-sm),
8
11
  state: (
9
12
  disabled: (
@@ -25,6 +25,9 @@ $vv-input-text: (
25
25
  display: flex,
26
26
  align-items: center,
27
27
  flex: 1,
28
+ // The wrapper clips its content: the `::after` bars run along the
29
+ // bottom edge and the input never paints outside the box. The
30
+ // `vv-select` wrapper does not clip, on purpose: see its map.
28
31
  overflow: hidden,
29
32
  background-color: var(--input-background-color),
30
33
  will-change: background-color,
@@ -21,6 +21,7 @@ $vv-nav: (
21
21
  _alias: 'li > :is(a, button)',
22
22
  display: flex,
23
23
  align-items: center,
24
+ gap: 0.5ch,
24
25
  will-change: background-color color,
25
26
  transition: var(--transition-all),
26
27
  color: var(--color-word-2),
@@ -40,6 +41,23 @@ $vv-nav: (
40
41
  ),
41
42
  ),
42
43
  ),
44
+ icon: (
45
+ _alias: 'li > :is(a, button) > :where(svg, [data-icon])',
46
+ --nav-icon-size: 1.1em,
47
+ // `align-self` and not `align-items` on the item: the label of a
48
+ // voice can wrap, and the icon belongs to its first line, while
49
+ // everything else in the item (a counter badge, a chevron) stays
50
+ // centred on the whole voice.
51
+ align-self: flex-start,
52
+ flex-shrink: 0,
53
+ [inline-size]: var(--nav-icon-size),
54
+ block-size: auto,
55
+ aspect-ratio: 1,
56
+ background-position: center,
57
+ background-repeat: no-repeat,
58
+ background-size: contain,
59
+ [translate]: 0 calc((1lh - var(--nav-icon-size)) / 2),
60
+ ),
43
61
  separator: (
44
62
  _alias: 'li[role="separator"]',
45
63
  display: block,
@@ -134,7 +152,14 @@ $vv-nav: (
134
152
  flex: 1 1 0%,
135
153
  ),
136
154
  item-label: (
137
- display: block,
155
+ // Still a flex container: as a block it puts the icon of a
156
+ // voice back on the baseline, where `align-self` is inert and
157
+ // the `gap` between the icon and the label is dropped, and the
158
+ // first line offset the icon carries becomes a nudge of its
159
+ // own. `justify-content` centres the icon and the label
160
+ // together, `text-align` the lines of a label that wraps.
161
+ display: flex,
162
+ justify-content: center,
138
163
  width: 100%,
139
164
  text-align: center,
140
165
  ),
@@ -42,13 +42,28 @@ $vv-radio: (
42
42
  ),
43
43
  ),
44
44
  element: (
45
+ label: (
46
+ // See `vv-checkbox`: an anonymous flex item cannot be kept beside
47
+ // the control once the label wraps.
48
+ // Only a span without a class of its own, for plain markup: a
49
+ // badge or an icon written next to the label is a span too, and
50
+ // `flex: 1` would stretch it. Anything carrying a class takes
51
+ // `vv-radio__label` to be the label.
52
+ _alias: '> span:not([class])',
53
+ flex: 1,
54
+ min-width: 0,
55
+ ),
45
56
  input: (
46
57
  _alias: "> input[type='radio']",
47
58
  border-width: var(--spacing-2),
48
59
  border-style: solid,
49
60
  border-color: currentcolor,
50
- min-width: var(--spacing-20),
51
- height: var(--spacing-20),
61
+ // Plain declarations for the same reason as `vv-checkbox`: the
62
+ // size is read on the control, not at the root.
63
+ [min-width]: var(--input-control-size),
64
+ [height]: var(--input-control-size),
65
+ align-self: flex-start,
66
+ [translate]: 0 calc((1lh - var(--input-control-size)) / 2),
52
67
  border-radius: 50%,
53
68
  margin: 0 var(--input-label-gap) 0 0,
54
69
  background-color: transparent,
@@ -26,6 +26,11 @@ $vv-select: (
26
26
  display: flex,
27
27
  align-items: center,
28
28
  flex: 1,
29
+ // No `overflow: hidden` here, unlike the wrapper of `vv-input-text`,
30
+ // `vv-textarea` and `vv-input-file`: this wrapper also hosts the
31
+ // dropdown of a combobox, which a clip would cut off whenever the
32
+ // positioning strategy is not `fixed`. The ellipsis of a long value
33
+ // is clipped by the `value` element instead.
29
34
  background-color: var(--input-background-color),
30
35
  will-change: background-color,
31
36
  transition: var(--transition-colors),
@@ -322,6 +322,10 @@ $-state-suffixes: (
322
322
 
323
323
  @if meta.type-of(map.get($map, $key)) == 'map' {
324
324
  @each $element, $value in map.get($map, $key) {
325
+ // `$block` is the block selector alone, threaded down unchanged
326
+ // from `spread-map-into-bem`: what a state or a transition
327
+ // expands to travels in `$modifier` instead. Passing a selector
328
+ // list here would suffix its last entry alone.
325
329
  $selector: $block + '__' + $element;
326
330
 
327
331
  // combinator
@@ -354,6 +358,10 @@ $-state-suffixes: (
354
358
 
355
359
  @if $alias {
356
360
  @if $modifier {
361
+ // One entry per selector of the modifier, in the same
362
+ // order as `$selector` above: `spread-map-into-states`
363
+ // pairs the two lists by index, so an `_alias` has to be
364
+ // a single selector, not a list of them.
357
365
  $aliases: ();
358
366
 
359
367
  @each $item in selector.parse($modifier) {
@@ -442,15 +450,35 @@ $-state-suffixes: (
442
450
  @each $state, $value in map.get($map, $key) {
443
451
  @if list.index($-valid-states, $state) {
444
452
  $selectors: ();
453
+ $block-items: selector.parse($block);
454
+ $alias-items: ();
455
+
456
+ // `$block` and `$alias` are built side by side by the caller,
457
+ // one entry per selector of the parent, so they are walked by
458
+ // index: the alias belongs to the block item it was nested
459
+ // under. Suffixing the list as a whole would reach its last
460
+ // entry alone and leave the ones before it in the rule bare.
461
+ @if $alias {
462
+ $alias-items: selector.parse($alias);
463
+ }
464
+
465
+ $i: 0;
466
+
467
+ @each $block-item in $block-items {
468
+ $i: $i + 1;
469
+ $alias-item: null;
470
+
471
+ @if $i <= list.length($alias-items) {
472
+ $alias-item: list.nth($alias-items, $i);
473
+ }
445
474
 
446
- @each $block-item in selector.parse($block) {
447
475
  $selectors: list.join(
448
476
  $selectors,
449
477
  ('#{$modifier}#{$block-item}--#{$state}', '#{$modifier}#{$block-item}.#{$state}')
450
478
  );
451
479
 
452
- @if $alias {
453
- $selectors: list.join($selectors, '#{$modifier}#{$alias}.#{$state}');
480
+ @if $alias-item {
481
+ $selectors: list.join($selectors, '#{$modifier}#{$alias-item}.#{$state}');
454
482
  }
455
483
 
456
484
  // Special state handling - use function to get updated selectors
@@ -458,7 +486,7 @@ $-state-suffixes: (
458
486
  $state: $state,
459
487
  $block-item: $block-item,
460
488
  $modifier: $modifier,
461
- $alias: $alias,
489
+ $alias: $alias-item,
462
490
  $selectors: $selectors
463
491
  );
464
492
  }
@@ -558,6 +586,27 @@ $-state-suffixes: (
558
586
  $block: &;
559
587
  }
560
588
 
589
+ // A state and a transition hand their whole selector list down as the
590
+ // modifier, so the block is compounded onto each of its entries: written
591
+ // as one interpolation it would reach the last entry alone and leave the
592
+ // ones before it in the rule unscoped.
593
+ $selector: '#{$modifier}#{$block}';
594
+
595
+ @if $modifier and $modifier != '' and $block {
596
+ $selectors: ();
597
+
598
+ @each $modifier-item in selector.parse($modifier) {
599
+ @each $block-item in selector.parse($block) {
600
+ $selectors: list.join(
601
+ $selectors,
602
+ '#{$modifier-item}#{$block-item}'
603
+ );
604
+ }
605
+ }
606
+
607
+ $selector: list-to-string($selectors);
608
+ }
609
+
561
610
  @if meta.type-of(map.get($map, $key)) == 'map' {
562
611
  @each $breakpoint, $value in map.get($map, $key) {
563
612
  @if string.index($breakpoint, '-') {
@@ -566,7 +615,7 @@ $-state-suffixes: (
566
615
 
567
616
  @if map.has-key($bps, $bp) {
568
617
  @if $operator == 'up' {
569
- @include wrap-with-where($selector: '#{$modifier}#{$block}', $enabled: $zero-specificity) {
618
+ @include wrap-with-where($selector: $selector, $enabled: $zero-specificity) {
570
619
  @include media-breakpoint-up($bp, $bps) {
571
620
  @include spread-map-into-attrs(
572
621
  $original: $original,
@@ -581,7 +630,7 @@ $-state-suffixes: (
581
630
  }
582
631
 
583
632
  @if $operator == 'down' {
584
- @include wrap-with-where($selector: '#{$modifier}#{$block}', $enabled: $zero-specificity) {
633
+ @include wrap-with-where($selector: $selector, $enabled: $zero-specificity) {
585
634
  @include media-breakpoint-down($bp, $bps) {
586
635
  @include spread-map-into-attrs(
587
636
  $original: $original,
@@ -596,7 +645,7 @@ $-state-suffixes: (
596
645
  }
597
646
 
598
647
  @if map.has-key($bps, $operator) {
599
- @include wrap-with-where($selector: '#{$modifier}#{$block}', $enabled: $zero-specificity) {
648
+ @include wrap-with-where($selector: $selector, $enabled: $zero-specificity) {
600
649
  @include media-breakpoint-between($operator, $bp, $bps) {
601
650
  @include spread-map-into-attrs(
602
651
  $original: $original,
@@ -611,7 +660,7 @@ $-state-suffixes: (
611
660
  }
612
661
  }
613
662
  } @else if map.has-key($bps, $breakpoint) {
614
- @include wrap-with-where($selector: '#{$modifier}#{$block}', $enabled: $zero-specificity) {
663
+ @include wrap-with-where($selector: $selector, $enabled: $zero-specificity) {
615
664
  @include media-breakpoint-up($breakpoint, $bps) {
616
665
  @include spread-map-into-attrs(
617
666
  $original: $original,
@@ -765,6 +814,11 @@ $-state-suffixes: (
765
814
  );
766
815
  $alias: map.get($map, 'element', $element, '_alias');
767
816
 
817
+ // The alias is a second selector for the same element, not a
818
+ // descendant of the first, so the list is glued with a comma
819
+ // before it is emitted: interpolating it would join the two
820
+ // with a space and compile them to one selector that matches
821
+ // a `.vv-input-text` nested inside its own element.
768
822
  @if $alias {
769
823
  $element-selector: list.append(
770
824
  $element-selector,
@@ -773,7 +827,7 @@ $-state-suffixes: (
773
827
  }
774
828
 
775
829
  @include wrap-with-where(
776
- $selector: $element-selector,
830
+ $selector: list-to-string($element-selector),
777
831
  $enabled: $zero-specificity
778
832
  ) {
779
833
  @extend %#{$block}-state-#{$state}__#{$element} !optional;
@@ -794,7 +848,7 @@ $-state-suffixes: (
794
848
  }
795
849
 
796
850
  @include wrap-with-where(
797
- $selector: $element-selector-state,
851
+ $selector: list-to-string($element-selector-state),
798
852
  $enabled: $zero-specificity
799
853
  ) {
800
854
  @extend %#{$block}-state-#{$state}-element-#{$element}--#{$element-state}
@@ -816,7 +870,7 @@ $-state-suffixes: (
816
870
  }
817
871
 
818
872
  @include wrap-with-where(
819
- $selector: $element-selector-pseudo,
873
+ $selector: list-to-string($element-selector-pseudo),
820
874
  $enabled: $zero-specificity
821
875
  ) {
822
876
  @extend %#{$block}-state-#{$state}-element-#{$element}--#{$pseudo}
@@ -136,4 +136,31 @@
136
136
  );
137
137
  }
138
138
 
139
+ // Optically aligns an icon with the first line of a wrapping label instead of
140
+ // with the centre of the whole block. `align-self: center` centres the icon on
141
+ // the flex line, which is the whole block once the label wraps, and
142
+ // `align-self: flex-start` drops the half leading of the first line. The offset
143
+ // is half the difference between the line box and the icon, so it holds at any
144
+ // font size and any icon size. The offset itself is never zero: on a label that
145
+ // does not wrap it puts the icon exactly where `align-self: center` would, since
146
+ // the first line is then the whole block, which is why the class is safe to
147
+ // apply unconditionally and needs no breakpoint variant.
148
+ // https://ishadeed.com/article/aligning-list-icons/
149
+ // `--icon-size` is the rendered size of the icon and defaults to the `1em` an
150
+ // inline SVG carries. `transform` and not `translate`, because components of
151
+ // the library move their own elements with `translate` and the two would
152
+ // overwrite each other.
153
+ %self-first-line {
154
+ flex-shrink: 0;
155
+ align-self: flex-start;
156
+ transform: translateY(calc((1lh - var(--icon-size, 1em)) / 2));
157
+ }
158
+
159
+ @include wrap-with-where(
160
+ $selector: '.self-first-line',
161
+ $enabled: $zero-specificity-for-utilities
162
+ ) {
163
+ @extend %self-first-line;
164
+ }
165
+
139
166
  }