@uncinq/css-components 1.16.1 → 1.16.3

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 (50) hide show
  1. package/README.md +17 -15
  2. package/css/components/alert.css +5 -0
  3. package/css/components/panel.css +8 -6
  4. package/docs/_index.md +20 -16
  5. package/docs/buttons/_index.md +75 -0
  6. package/docs/buttons/btn-close.md +58 -0
  7. package/docs/buttons/btn-filter.md +36 -0
  8. package/docs/buttons/btn-menu.md +39 -0
  9. package/docs/buttons/btn-search.md +40 -0
  10. package/docs/buttons/btn-share.md +33 -0
  11. package/docs/buttons/btn-toc.md +23 -0
  12. package/docs/buttons/btn-toggle-video.md +58 -0
  13. package/docs/buttons/btn.md +95 -0
  14. package/docs/cascade-layers.md +9 -7
  15. package/docs/content/_index.md +39 -0
  16. package/docs/content/alert.md +97 -0
  17. package/docs/content/badge.md +50 -0
  18. package/docs/content/banner.md +43 -0
  19. package/docs/content/card.md +93 -0
  20. package/docs/content/items.md +76 -0
  21. package/docs/content/list.md +41 -0
  22. package/docs/content/media.md +88 -0
  23. package/docs/content/surtitle.md +43 -0
  24. package/docs/embeds/_index.md +25 -0
  25. package/docs/embeds/embed.md +53 -0
  26. package/docs/embeds/map.md +57 -0
  27. package/docs/embeds/video.md +53 -0
  28. package/docs/forms/_index.md +31 -0
  29. package/docs/forms/form-check.md +77 -0
  30. package/docs/forms/form.md +67 -0
  31. package/docs/navigation/_index.md +29 -0
  32. package/docs/navigation/breadcrumb.md +59 -0
  33. package/docs/navigation/nav-accessibility.md +67 -0
  34. package/docs/navigation/nav-title.md +43 -0
  35. package/docs/navigation/nav.md +65 -0
  36. package/docs/navigation/pagination.md +77 -0
  37. package/docs/overlays/_index.md +34 -0
  38. package/docs/overlays/drawer.md +58 -0
  39. package/docs/overlays/dropdown.md +87 -0
  40. package/docs/overlays/modal.md +75 -0
  41. package/docs/overlays/panel.md +159 -0
  42. package/docs/utilities/_index.md +21 -0
  43. package/docs/{utilities.md → utilities/scrollsnap.md} +26 -9
  44. package/package.json +1 -1
  45. package/docs/buttons.md +0 -103
  46. package/docs/content.md +0 -145
  47. package/docs/embeds.md +0 -66
  48. package/docs/forms.md +0 -80
  49. package/docs/navigation.md +0 -93
  50. package/docs/overlays.md +0 -133
@@ -0,0 +1,65 @@
1
+ ---
2
+ isIndex: false
3
+ title: Nav
4
+ description: The base navigation list, vertical by default, whose direction is set by context rather than by a modifier class.
5
+ weight: 1
6
+ icon: compass
7
+ ---
8
+
9
+ The base navigation list, vertical by default.
10
+
11
+ ```html
12
+ <ul class="nav">
13
+ <li><a href="/">Home</a></li>
14
+ <li><a href="/about">About</a></li>
15
+ </ul>
16
+ ```
17
+
18
+ Works on `ul` or `ol`.
19
+
20
+ ## Direction is set by context, not by a modifier
21
+
22
+ There is no `.nav-horizontal`. The direction comes from a token:
23
+
24
+ ```css
25
+ flex-direction: var(--nav-direction, column);
26
+ ```
27
+
28
+ so a header lays its own navigation out horizontally by setting `--nav-direction: row` on it, or `flex-direction` directly. That keeps the component free of layout variants that would only ever be used in one place, and it means the same list is a column in a drawer and a row in a header without a class change.
29
+
30
+ ## Nested lists inherit the layout
31
+
32
+ ```css
33
+ &,
34
+ :is(ul, ol) { display: flex; flex-direction: var(--nav-direction, column); ... }
35
+ ```
36
+
37
+ The rule matches the nav **and** every list inside it, so a submenu needs no second `.nav` class. Note that a nested list inherits `--nav-direction` too, so a horizontal header menu gets horizontal submenus unless the submenu resets it.
38
+
39
+ ## Links
40
+
41
+ `a:where(:not(.btn))` and `li > span` are styled together, so a non-link entry, the current page rendered as text, lines up with the links around it. `.btn` is excluded through `:where()`, which adds no specificity, so a call to action inside a navigation keeps its own styling.
42
+
43
+ The current entry is marked with a class on the link:
44
+
45
+ ```html
46
+ <li><a href="/about" class="active" aria-current="page">About</a></li>
47
+ ```
48
+
49
+ ```css
50
+ &.active { --nav-color-text: var(--nav-color-text-active); }
51
+ ```
52
+
53
+ The class drives the colour; `aria-current` is what a screen reader announces. Write both.
54
+
55
+ ## Highlighted items
56
+
57
+ `.nav-item-highlighted` tightens the line height and pulls the item out to the nav's own inline padding, for an entry rendered as a block rather than as a line of text.
58
+
59
+ ## Dropdowns inside a nav
60
+
61
+ `.dropdown-toggle` is restyled to take `--nav-color-text`, so a [dropdown](../../overlays/dropdown/) in a header menu matches the links beside it. Nothing else about the dropdown changes.
62
+
63
+ ## Inside a panel
64
+
65
+ A `.modal` or `.drawer` publishes `--nav-color-text` from its own text colour, so a navigation inside a panel follows the panel's palette. Going inline with [`.panel-inline-*`](../../overlays/panel/#inline-variants) hands that token back to the page, which is why a drawer menu that becomes a sidebar column changes colour at the breakpoint.
@@ -0,0 +1,77 @@
1
+ ---
2
+ isIndex: false
3
+ title: Pagination
4
+ description: Page navigation, with masked control glyphs and a built-in responsive collapse.
5
+ weight: 5
6
+ icon: three-dots
7
+ ---
8
+
9
+ A page navigation list.
10
+
11
+ ```html
12
+ <nav aria-label="Pagination">
13
+ <ul class="pagination">
14
+ <li class="disabled"><a class="first">First</a></li>
15
+ <li><a class="previous" href="?page=1">Previous</a></li>
16
+ <li class="item active"><a href="?page=2">2</a></li>
17
+ <li class="item item-adjacent"><a href="?page=3">3</a></li>
18
+ <li><a class="next" href="?page=3">Next</a></li>
19
+ <li><a class="last" href="?page=9">Last</a></li>
20
+ </ul>
21
+ </nav>
22
+ ```
23
+
24
+ | Class | On | Role |
25
+ | --- | --- | --- |
26
+ | `.first`, `.previous`, `.next`, `.last` | the `a` | Navigation controls |
27
+ | `.item` | the `li` | A page number |
28
+ | `.item-adjacent` | the `li` | A page number next to the current one |
29
+ | `.active` | the `li` | The current page |
30
+ | `.disabled` | the `li` | An unavailable control |
31
+
32
+ `.active` and `.disabled` are both read as ancestors of the link (`.active a`, `.disabled a`), so putting either on the `li` is what the rules expect.
33
+
34
+ ## The glyphs are shipped
35
+
36
+ Each control draws a mask on `::after` from its own token:
37
+
38
+ ```css
39
+ .next::after { mask: var(--pagination-icon-next) center / var(--pagination-icon-size) no-repeat; }
40
+ ```
41
+
42
+ Four tokens, `--pagination-icon-first`, `-previous`, `-next` and `-last`, sized by `--pagination-icon-size`. The two backwards controls reuse their forwards glyph and rotate it:
43
+
44
+ ```css
45
+ .first::after { mask: var(--pagination-icon-first) ...; rotate: 180deg; }
46
+ .previous::after { mask: var(--pagination-icon-previous) ...; rotate: 180deg; }
47
+ ```
48
+
49
+ So a themed arrow only has to exist in one direction. Because the glyph is a mask over `currentColor`, it follows the link colour through hover, active and disabled without a second declaration.
50
+
51
+ The text inside the control (`First`, `Previous`) sits beside the glyph. Replace it with a `.visually-hidden` span if the design wants icons alone; do not remove it, or the control loses its accessible name.
52
+
53
+ ## The responsive collapse is built in
54
+
55
+ ```css
56
+ @media not (--sm) {
57
+ .first, .last,
58
+ li:has(.first), li:has(.last),
59
+ .item:not(.item-adjacent):not(.active) { display: none; }
60
+ }
61
+ ```
62
+
63
+ Below 768px the paginator drops to *previous, the current page, its neighbours, next*. You do not need to write that rule, and you do not need a second markup path for mobile: render the full paginator and let CSS thin it out.
64
+
65
+ This is what `.item-adjacent` is for. It is the only signal the CSS has about which page numbers are near the current one, so a paginator that omits it collapses to the current page alone on a narrow screen.
66
+
67
+ The `li:has(...)` selectors are there so the list item goes with the control it contains, rather than leaving an empty `li` contributing a gap.
68
+
69
+ ## States
70
+
71
+ `.active a` and `.disabled a` both get `pointer-events: none`, so neither is clickable regardless of whether you render an `href`. The active page takes the `-active` colour pair, and a disabled control takes `--pagination-disabled-opacity`.
72
+
73
+ Note that the hover rule here is **not** behind `@media (hover: hover) and (pointer: fine)`, unlike the rest of the package. On a touch device a tapped page number can keep its hover background until the next paint.
74
+
75
+ ## Accessibility
76
+
77
+ The `<nav>` and its `aria-label` are yours to write; the component styles the list only. Mark the current page with `aria-current="page"` on the link as well as `.active` on the item, so the state is announced and not merely coloured.
@@ -0,0 +1,34 @@
1
+ ---
2
+ isIndex: false
3
+ title: Overlays
4
+ description: The shared panel skeleton behind .modal and .drawer, the two opening strategies, and the .dropdown contract.
5
+ weight: 3
6
+ icon: window-stack
7
+ ---
8
+
9
+ Four files, and a design worth understanding before reading any of them.
10
+
11
+ | Page | Covers |
12
+ | --- | --- |
13
+ | [Panel](panel/) | The shared skeleton, `.panel-inline-*`, `.panel-trigger-*`, the backdrop and the JavaScript contract |
14
+ | [Modal](modal/) | `.modal`, the centred configuration |
15
+ | [Drawer](drawer/) | `.drawer` and its four edge variants |
16
+ | [Dropdown](dropdown/) | `.dropdown`, a contextual menu with its own contract |
17
+
18
+ ## `.modal` and `.drawer` are the same panel
19
+
20
+ Same structure, header and content and footer. Same open and closed contract. Same backdrop. They differ only in where they sit: modal centres them, drawer pins them to an edge.
21
+
22
+ `components/panel.css` owns **every rule**. The two variants are pure configuration, declaring nothing but `--panel-*` tokens.
23
+
24
+ There is **no `.panel` class to apply.** The file targets `:where(.modal, .drawer)` directly, so the skeleton arrives with either variant class. `panel` names the shared concept and the token namespace, not a class.
25
+
26
+ Each variant aliases `--panel-*` from its own namespace, so `--modal-*` and `--drawer-*` remain the public API. Override those.
27
+
28
+ ## The tag picks the strategy, not the class
29
+
30
+ Either variant works both ways, and the choice is an accessibility one rather than a styling one. See [Two ways to open a panel](panel/#two-ways-to-open-a-panel).
31
+
32
+ ## No JavaScript ships here
33
+
34
+ `.modal`, `.drawer` and `.dropdown` all expect a script. The CSS defines the classes and attributes that script must toggle; each page documents the contract it needs.
@@ -0,0 +1,58 @@
1
+ ---
2
+ isIndex: false
3
+ title: Drawer
4
+ description: The edge-pinned panel configuration and its four position variants.
5
+ weight: 3
6
+ icon: layout-sidebar-reverse
7
+ ---
8
+
9
+ A panel sliding in from an edge. Like [`.modal`](../modal/), the class is **pure configuration**: `drawer.css` declares nothing but `--panel-*` tokens, and [`components/panel.css`](../panel/) owns every rule.
10
+
11
+ ```html
12
+ <div class="drawer drawer-end" id="menu" popover="auto" role="dialog" aria-labelledby="menuLabel">
13
+ <div class="header">
14
+ <p class="title" id="menuLabel">Menu</p>
15
+ <button class="btn-close js-dialog-close" aria-label="Close"></button>
16
+ </div>
17
+ <div class="content">
18
+ <ul class="nav">...</ul>
19
+ </div>
20
+ </div>
21
+ ```
22
+
23
+ **A position variant is required.** `.drawer` on its own pins nothing: `--drawer-start` and its siblings have no value until one of the four variants sets them.
24
+
25
+ ## Position variants
26
+
27
+ | Class | Edge | Slides from |
28
+ | --- | --- | --- |
29
+ | `.drawer-start` | Inline start, left in a left-to-right document | `translateX(-100%)` |
30
+ | `.drawer-end` | Inline end | `translateX(100%)` |
31
+ | `.drawer-top` | Top | `translateY(-100%)` |
32
+ | `.drawer-bottom` | Bottom | `translateY(100%)` |
33
+
34
+ Each sets three things: which two insets are `0`, which two are `auto`, and the translate the panel animates from.
35
+
36
+ The start and end variants leave `--panel-inline-size: var(--drawer-width)` and let the block axis stretch. The top and bottom variants swap that, taking `--panel-block-size: var(--drawer-height)` and `--panel-inline-size: auto`.
37
+
38
+ ## Why the margin stays at 0
39
+
40
+ ```css
41
+ /* two edges pinned, the opposite ones auto */
42
+ --panel-inset-block: var(--drawer-top) var(--drawer-bottom);
43
+ --panel-inset-inline: var(--drawer-start) var(--drawer-end);
44
+ ```
45
+
46
+ A `<dialog>` opened with `showModal()` is centred by user agent rules that only apply when the margins are `auto`. The drawer leaves `--panel-margin` at its `0` default precisely so those rules do not fire, which is what lets the same file work on a `<dialog>` and on a popover.
47
+
48
+ ## It only slides, it does not fade
49
+
50
+ Unlike a modal, the drawer sets no `--panel-opacity`, so the token keeps its `1` default. The closed state is a pure translate, and the `@starting-style` block in `panel.css` replays it. That is the whole difference in the two animations.
51
+
52
+ ## The case for `popover` over `<dialog>`
53
+
54
+ A drawer is the usual host for [`.panel-inline-*`](../panel/#inline-variants): a menu, a filters sidebar or a table of contents that is an overlay on mobile and a column on desktop. A `<dialog>` is exposed as `role="dialog"` even while it is styled as a column, so use `popover="auto"` and let your script set the role only while the panel is actually open.
55
+
56
+ ## Sizing
57
+
58
+ `--drawer-width` and `--drawer-height` are the two dimensions. `--panel-max-inline-size` is pinned to `100%`, so a drawer wider than the viewport is clamped rather than pushed off screen.
@@ -0,0 +1,87 @@
1
+ ---
2
+ isIndex: false
3
+ title: Dropdown
4
+ description: A contextual menu positioned against its trigger, with a three-class transition contract.
5
+ weight: 4
6
+ icon: caret-down-square
7
+ ---
8
+
9
+ A contextual menu. Unlike [`.modal`](../modal/) and [`.drawer`](../drawer/), it is not a panel: it is absolutely positioned against its trigger, stays out of the top layer, and has a tighter JavaScript contract because it animates in both directions.
10
+
11
+ ```html
12
+ <div class="dropdown">
13
+ <button class="dropdown-toggle js-dropdown-toggle" aria-expanded="false">Label</button>
14
+ <ul class="dropdown-menu">
15
+ <li><a href="#">Item</a></li>
16
+ <li><hr></li>
17
+ <li><a href="#">Item</a></li>
18
+ </ul>
19
+ </div>
20
+ ```
21
+
22
+ ## Three classes, three jobs
23
+
24
+ | Class | Role |
25
+ | --- | --- |
26
+ | `.dropdown` | The positioning context, `inline-flex` and `position: relative` |
27
+ | `.dropdown-toggle` | The trigger, styled: transparent, with a chevron on `::after` |
28
+ | `.dropdown-menu` | The menu panel, absolutely positioned under the trigger |
29
+
30
+ The trigger needs **both** `.dropdown-toggle` for the styling and `.js-dropdown-toggle` for the script. They are separate on purpose, so a trigger can be a `.btn`, a [`.btn-share`](../../buttons/btn-share/) or a plain button and still be found by the same script.
31
+
32
+ **`.js-dropdown-toggle`'s next element sibling must be the `.dropdown-menu`.** That is how the script pairs the two; there is no `data-target` here.
33
+
34
+ ## Items are styled by element, not by class
35
+
36
+ `.dropdown-menu` styles `li a` directly. There is no `.dropdown-item` rule in this package, and no `.dropdown-divider` rule either: a separator is a plain `<hr>` taking whatever [css-base](../../../css-base/base/) gives it. Adding those classes to your markup is harmless but changes nothing.
37
+
38
+ Links get `white-space: nowrap`, so the menu widens to its longest item rather than wrapping. `--dropdown-min-width` sets the floor.
39
+
40
+ ## The chevron
41
+
42
+ Drawn on `::after` as a mask from `--dropdown-icon`, and rotated by the trigger's own ARIA state:
43
+
44
+ ```css
45
+ &[aria-expanded="true"]::after { rotate: 180deg; }
46
+ ```
47
+
48
+ So the arrow follows `aria-expanded` rather than a state class. If your script forgets to update the attribute, the menu opens with the chevron still pointing down, which makes the omission visible during development rather than only to screen reader users.
49
+
50
+ The trigger also lays a transparent `::before` over its whole positioning context, widening the click target beyond the text.
51
+
52
+ ## The three-class transition contract
53
+
54
+ The script toggles three classes on the **menu**, in this sequence:
55
+
56
+ | Class | Meaning | Effect |
57
+ | --- | --- | --- |
58
+ | `.showing` | Opening transition has started | Visible and clickable, still at its closed opacity and offset |
59
+ | `.show` | Fully open | Opacity 1, translate 0 |
60
+ | `.hiding` | Closing transition has started | Still visible while it animates out |
61
+
62
+ ```css
63
+ &.show, &.showing, &.hiding { pointer-events: auto; visibility: visible; }
64
+ &.show { opacity: 1; transform: translateY(0); }
65
+ ```
66
+
67
+ The three exist so the menu can animate **out** before being removed from the flow. A single `.show` class would make the closing transition impossible: removing it would snap `visibility` to `hidden` on the same frame as the opacity change, and nothing would be seen.
68
+
69
+ The script must also keep `aria-expanded` on the toggle in sync with the state.
70
+
71
+ Transitions are inside `@media (prefers-reduced-motion: no-preference)`, so the three classes still work with motion disabled; they simply take effect instantly.
72
+
73
+ ## Alignment
74
+
75
+ `.dropdown-menu-end` aligns the menu to the inline end of its trigger rather than the start. It is the only variant, and it goes on the menu, not on the container.
76
+
77
+ ```html
78
+ <ul class="dropdown-menu dropdown-menu-end">...</ul>
79
+ ```
80
+
81
+ ## Inside a navigation
82
+
83
+ [`.nav`](../../navigation/nav/) restyles `.dropdown-toggle` to take the navigation's own text colour, so a dropdown in a header menu matches the links beside it without a variant class.
84
+
85
+ ## It uses `z-index`, not the top layer
86
+
87
+ `.dropdown-menu` is positioned with `z-index: var(--dropdown-z-index)` inside its own stacking context. Unlike a panel, it can therefore be clipped by an ancestor with `overflow: hidden` or trapped under a sticky header. If that happens, the fix is the ancestor's overflow, not a higher `z-index`.
@@ -0,0 +1,75 @@
1
+ ---
2
+ isIndex: false
3
+ title: Modal
4
+ description: The centred panel configuration, and how it centres and shrinks to its content at once.
5
+ weight: 2
6
+ icon: window
7
+ ---
8
+
9
+ A centred panel. The class is **pure configuration**: `modal.css` declares nothing but `--panel-*` tokens, and [`components/panel.css`](../panel/) owns every rule.
10
+
11
+ ```html
12
+ <dialog class="modal" id="contact" aria-labelledby="contactLabel">
13
+ <div class="header">
14
+ <p class="title" id="contactLabel">Contact</p>
15
+ <button class="btn-close js-dialog-close" aria-label="Close"></button>
16
+ </div>
17
+ <div class="content">...</div>
18
+ <div class="footer">
19
+ <button class="btn btn-secondary js-dialog-close">Cancel</button>
20
+ <button class="btn" type="submit">Send</button>
21
+ </div>
22
+ </dialog>
23
+ ```
24
+
25
+ Works on a `<dialog>` opened with `showModal()` and on any element carrying `popover="auto"`, opened with `showPopover()`. Reach for the second form only when the panel is not always modal, typically when [`.panel-inline-*`](../panel/#inline-variants) turns it into a region of the page above a breakpoint.
26
+
27
+ ## Sizing
28
+
29
+ Override `--modal-max-width` on the element:
30
+
31
+ ```html
32
+ <dialog class="modal" style="--modal-max-width: 40rem">...</dialog>
33
+ ```
34
+
35
+ or globally, for every modal on the site:
36
+
37
+ ```css
38
+ @layer components {
39
+ .modal { --modal-max-width: 40rem; }
40
+ }
41
+ ```
42
+
43
+ `--modal-max-height`, `--modal-min-height` and `--modal-margin` are the other three sizing tokens. All `--modal-*` names are the public API; `--panel-*` is the internal one.
44
+
45
+ ## Why the height is `fit-content` and not `auto`
46
+
47
+ The centring is done with insets and auto margins:
48
+
49
+ ```css
50
+ --panel-block-size: fit-content;
51
+ --panel-inset-block: 0;
52
+ --panel-inset-inline: 0;
53
+ --panel-margin: auto;
54
+ ```
55
+
56
+ With both block insets set, an `auto` height resolves to fill the gap between them, and the auto margins then collapse to `0`. The panel would neither centre nor shrink to its content. `fit-content` is what keeps both behaviours, and it is the same reason the user agent dialog stylesheet uses it.
57
+
58
+ ## Why there is no mobile breakpoint
59
+
60
+ The maximum is a `min()` rather than a media query:
61
+
62
+ ```css
63
+ --panel-max-block-size: min(var(--modal-max-height), 100% - var(--modal-margin) * 2);
64
+ --panel-max-inline-size: min(var(--modal-max-width), 100% - var(--modal-margin) * 2);
65
+ ```
66
+
67
+ So the panel always keeps `--modal-margin` clear of the viewport edges, whatever the maximum is set to. Raising `--modal-max-width` on a narrow screen changes nothing, which is why a modal needs no responsive variant.
68
+
69
+ ## Opening animation
70
+
71
+ `--panel-opacity: 0` and `--panel-translate: var(--modal-translate)` describe the **closed** state, so a modal both fades and moves on the way in. A drawer leaves the opacity alone and only slides. Both are replayed by the `@starting-style` block in `panel.css`, and both are skipped under `prefers-reduced-motion: reduce`.
72
+
73
+ ## Accessibility
74
+
75
+ Point `aria-labelledby` at the `id` of the title in the header. A `<dialog>` opened with `showModal()` gets the focus trap, Escape and inertness from the browser; the `popover` path gets light dismiss and Escape but no role, which your script has to supply.
@@ -0,0 +1,159 @@
1
+ ---
2
+ isIndex: false
3
+ title: Panel
4
+ description: The shared skeleton behind .modal and .drawer, the two opening strategies, the inline variants and the backdrop.
5
+ weight: 1
6
+ icon: layout-sidebar
7
+ ---
8
+
9
+ `components/panel.css` is the largest file in the package and the only one with no class of its own. It owns every rule behind [`.modal`](../modal/) and [`.drawer`](../drawer/), which contribute nothing but `--panel-*` values.
10
+
11
+ There is **no `.panel` class.** The selectors read `:where(.modal, .drawer)`, so the skeleton arrives with either variant class.
12
+
13
+ ## Structure
14
+
15
+ ```html
16
+ <dialog class="modal" id="contact" aria-labelledby="contactLabel">
17
+ <div class="header">
18
+ <p class="title" id="contactLabel">Contact</p>
19
+ <button class="btn-close js-dialog-close" aria-label="Close"></button>
20
+ </div>
21
+ <div class="content">...</div>
22
+ <div class="footer">...</div>
23
+ </dialog>
24
+ ```
25
+
26
+ All three sections are optional, and all three are matched as **direct children** (`& > .header`).
27
+
28
+ | Section | Behaviour |
29
+ | --- | --- |
30
+ | `.header` | Fixed height, title and close button pushed apart, bottom border |
31
+ | `.content` | Takes the remaining height and scrolls, with `overscroll-behavior: contain` |
32
+ | `.footer` | Fixed height, top border, stacked on mobile and right-aligned from `--sm` up |
33
+
34
+ The panel itself carries **no padding**. It lives on the three sections instead, so that a click on the panel element is always a click on the backdrop rather than on a transparent margin.
35
+
36
+ ## Two ways to open a panel
37
+
38
+ **The tag picks the strategy, not the class.** Either variant works both ways.
39
+
40
+ ### `<dialog>` with `showModal()`
41
+
42
+ The browser handles Escape, the focus trap and making the rest of the page inert. You get `[open]`, the native `::backdrop` and the top layer for free.
43
+
44
+ This is the right choice when the panel is **always** a dialog.
45
+
46
+ ### `popover="auto"` with `showPopover()`
47
+
48
+ Same top layer, same native `::backdrop`, but the element carries no implicit role.
49
+
50
+ Use this when the panel becomes a static region of the page above a breakpoint, which is what the [inline variants](#inline-variants) do. The reason is an accessibility one: a `<dialog>` is exposed as `role="dialog"` even when it is only styled as a sidebar, whereas a script can set that role solely while the panel is actually open.
51
+
52
+ ## How open and closed are expressed
53
+
54
+ `display` is only ever declared on a state selector, never on the panel itself:
55
+
56
+ ```css
57
+ :where(.modal, .drawer):not(:where([open], :popover-open)) { display: none; }
58
+ :where(.modal, .drawer):is([open], :popover-open) { display: flex; opacity: 1; transform: none; }
59
+ ```
60
+
61
+ Declaring `display` on the panel would beat the user agent rule that hides a closed `<dialog>`, and a closed dialog would render.
62
+
63
+ The two selectors differ deliberately. The closed rule uses `:where()` throughout and carries **zero specificity**. The open rule uses `:is()` on the state, giving it 0,1,0, which is what lets it beat the unstyled base rule that comes later in the file on specificity rather than on source order, while still leaving a host component such as `.toc .drawer` (0,2,0) room to override without escalating.
64
+
65
+ Everything else about the closed state lives in tokens: `--panel-opacity` and `--panel-translate` are the values the panel animates **from**, which is why a drawer only slides while a modal also fades.
66
+
67
+ The entry transition needs those values spelled out a second time, because opening is a discrete `display` change:
68
+
69
+ ```css
70
+ @starting-style {
71
+ :where(.modal, .drawer):is([open], :popover-open) {
72
+ opacity: var(--panel-opacity, 1);
73
+ transform: var(--panel-translate, none);
74
+ }
75
+ }
76
+ ```
77
+
78
+ `transition-behavior: allow-discrete` keeps the panel displayed while it transitions out, and keeps a `<dialog>` in the top layer until the transition ends.
79
+
80
+ ## The backdrop
81
+
82
+ Both opening paths put the panel in the top layer, so both get the native `::backdrop`, which inherits from the panel and therefore reads the same `--panel-*` tokens. **No backdrop node is ever injected, and no `z-index` is involved.**
83
+
84
+ This is the reason the class-driven path uses `popover` rather than a plain element. A backdrop placed outside the panel sits in the root stacking context while the panel stays confined to its nearest stacking ancestor, a sticky sidebar wrapper or a sticky header, so the veil paints over the panel and no `z-index` can lift it out. Placing the backdrop next to the panel only moves the problem: it then inherits the same confinement, and anything painted later shows through it. The top layer removes the question entirely, which is exactly what it exists for.
85
+
86
+ Page scroll is locked from the panel's own state, with no class on `<body>` to manage:
87
+
88
+ ```css
89
+ body:has(:where(.modal, .drawer):where([open], :popover-open)) { overflow: hidden; }
90
+ ```
91
+
92
+ ## Inline variants
93
+
94
+ `.panel-inline-*` turns a panel into an ordinary region of the page from a breakpoint upwards, while it stays an overlay below.
95
+
96
+ | Class | Becomes inline at |
97
+ | --- | --- |
98
+ | `.panel-inline-sm` | `--sm`, 768px |
99
+ | `.panel-inline-md` | `--md`, 1024px |
100
+ | `.panel-inline-lg` | `--lg`, 1440px |
101
+ | `.panel-inline-xl` | `--xl`, 1600px |
102
+
103
+ This is the case that calls for `popover="auto"` rather than `<dialog>`. A filters sidebar that is a drawer on mobile and a column on desktop should not announce itself as a dialog while it is a column. **The panel must not be a `<dialog>` when one of these is used.**
104
+
105
+ Above the breakpoint the variant neutralises the whole panel rule: position, insets, size, shadow, overflow, transition, and it hides `.header` and `.footer` while flattening `.content`.
106
+
107
+ ### Colours go too, not just geometry
108
+
109
+ A region of the page carries none of the panel's chrome, so the variant hands five custom properties back to the parent:
110
+
111
+ ```css
112
+ --color-link: inherit;
113
+ --color-link-active: inherit;
114
+ --color-link-hover: inherit;
115
+ --nav-color-text: inherit;
116
+ --nav-color-text-active: inherit;
117
+ ```
118
+
119
+ `inherit` on a custom property is what hands it back, and it has to be done token by token. Resetting the `--panel-color-text-*` tokens they derive from would make them invalid at computed-value time rather than page-valued, and every link would fall back to the surrounding text colour instead of the site's link colour.
120
+
121
+ If you add a property to the panel rule, neutralise it in all four variants. That is the maintenance cost of the pattern, and the reason the block is written once per breakpoint rather than derived.
122
+
123
+ ## Hiding the trigger
124
+
125
+ `.panel-trigger-*` is the companion, applied to the **trigger button** rather than to the panel. It hides the trigger at the breakpoint where the panel stops being an overlay.
126
+
127
+ | Class | Trigger hidden from |
128
+ | --- | --- |
129
+ | `.panel-trigger-sm` | `--sm`, 768px |
130
+ | `.panel-trigger-md` | `--md`, 1024px |
131
+ | `.panel-trigger-lg` | `--lg`, 1440px |
132
+ | `.panel-trigger-xl` | `--xl`, 1600px |
133
+
134
+ Pair the suffixes: a panel carrying `.panel-inline-md` needs its button carrying `.panel-trigger-md`, otherwise a button that opens nothing remains on screen at desktop width.
135
+
136
+ The reason this is a second class rather than something CSS derives on its own is worth knowing, because it looks like a redundancy. Nothing structurally connects a trigger to its panel: the only link is `data-target` matching an `id`, and no selector can compare two attribute values. A positional rule would work only until somebody moved the markup, and a filter button typically lives in the page heading rather than beside the panel it opens. In practice both classes come from the same configuration value, so the pair cannot disagree.
137
+
138
+ ## The JavaScript contract
139
+
140
+ This package ships no JavaScript. `.modal` and `.drawer` expect a script that provides:
141
+
142
+ | Hook | Role |
143
+ | --- | --- |
144
+ | `.js-dialog-toggle[data-target="#id"]` | Opens the panel it points at |
145
+ | `.js-dialog-close` | Any element inside the panel that closes it |
146
+
147
+ Closing must also work on Escape and on a click outside. With `<dialog>` and `showModal()` the browser already does both. With `popover="auto"` the platform handles light dismiss and Escape too, so in practice the script mainly manages the role and the open call.
148
+
149
+ One case CSS cannot cover: a panel carrying a `.panel-inline-*` class that is **open** when the viewport crosses the breakpoint. The script has to close it, because nothing in CSS releases the backdrop, the scroll lock and the inert background.
150
+
151
+ ## Source order, not specificity
152
+
153
+ Most of this file is wrapped in `:where()` and carries no specificity at all, which is what lets `.modal`, `.drawer` and your own overrides win without escalating:
154
+
155
+ ```css
156
+ .modal { border-radius: 0; } /* enough, no !important needed */
157
+ ```
158
+
159
+ The `.panel-inline-*` variants are the exception. They are plain class selectors that must beat `.modal` and `.drawer`, and they do so on **source order**. Alphabetical import order already places `panel.css` after `drawer.css` and `modal.css`; keep it that way if you import files individually. See [Cascade layers](../../cascade-layers/).
@@ -0,0 +1,21 @@
1
+ ---
2
+ isIndex: false
3
+ title: Utilities
4
+ description: The one utility this package ships, and why it is a utility rather than a component rule.
5
+ weight: 8
6
+ icon: tools
7
+ ---
8
+
9
+ One utility, in its own layer.
10
+
11
+ | Page | Class | Purpose |
12
+ | --- | --- | --- |
13
+ | [Scrollsnap](scrollsnap/) | `.scrollsnap` | Turns a grid into a horizontal snap carousel |
14
+
15
+ ## Why it is not a component
16
+
17
+ `.scrollsnap` changes the `grid-template-columns` that a component such as [`.items`](../content/items/) declares for itself in `@layer components`. A later layer beats an earlier one regardless of specificity, so putting it in `@layer utilities` is what lets a single class opt an existing component into carousel behaviour without rewriting the component.
18
+
19
+ That is also the test for anything else that might land here: a utility is a **modifier applied to something else**, not a thing of its own. If it has its own markup, it is a component.
20
+
21
+ `@layer utilities` must come last in your layer declaration for this to hold. See [Cascade layers](../cascade-layers/).
@@ -1,13 +1,11 @@
1
1
  ---
2
2
  isIndex: false
3
- title: Utilities
4
- description: The .scrollsnap utility, which turns a grid into a horizontal snap carousel below a breakpoint.
5
- weight: 8
6
- icon: tools
3
+ title: Scrollsnap
4
+ description: Turns a grid into a horizontal snap carousel below a breakpoint, bleeding into the gutter so the next item peeks in.
5
+ weight: 1
6
+ icon: arrow-left-right
7
7
  ---
8
8
 
9
- ## `.scrollsnap`
10
-
11
9
  Turns a grid into a horizontal carousel with snap points. Items keep their own width, the grid stops wrapping, and the row scrolls sideways, bleeding into the gutter so the next item peeks in.
12
10
 
13
11
  ```html
@@ -34,9 +32,22 @@ This is the one thing to get right, and it reads backwards from most responsive
34
32
 
35
33
  **The suffix names the rung where the carousel STOPS and the grid comes back.** `.scrollsnap-md` is a carousel on mobile and tablet, and a grid from 1024px up.
36
34
 
37
- ## Why it is a utility
35
+ ## How a variant hands the grid back
36
+
37
+ Above its rung, every property is set to `revert-layer` rather than to a second set of values:
38
+
39
+ ```css
40
+ @media (--md) {
41
+ .scrollsnap-md {
42
+ grid-auto-flow: revert-layer;
43
+ grid-template-columns: revert-layer;
44
+ margin-inline: revert-layer;
45
+ /* … */
46
+ }
47
+ }
48
+ ```
38
49
 
39
- It has to win over the `grid-template-columns` that a component such as `.items` declares for itself in `@layer components`. `@layer utilities` comes later in the order, so it wins regardless of specificity. See [Cascade layers](../cascade-layers/).
50
+ `revert-layer` hands the property back to the layer below, which is the component's own grid rule in `@layer components`. So there is no duplicated set of grid values to keep in step: whatever `.items` declares is what comes back, including any override you wrote yourself.
40
51
 
41
52
  ## Item width
42
53
 
@@ -51,7 +62,7 @@ Items take `--items-min-width`, the same value that drives the grid, capped by `
51
62
 
52
63
  ## The bleed, and why margin and padding disagree
53
64
 
54
- The row pulls out to the screen edge using `--container-bleed`, published by `.container` in [css-base](../../css-base/layouts/#the-container-bleed-contract). The property inherits, so it reaches the row from a `.container` however far up it sits.
65
+ The row pulls out to the screen edge using `--container-bleed`, published by `.container` in [css-base](../../../css-base/layouts/#the---container-bleed-contract). The property inherits, so it reaches the row from a `.container` however far up it sits.
55
66
 
56
67
  Outside a container there is no published value, and the two `var()` fallbacks differ **on purpose**, because margin and padding answer different questions:
57
68
 
@@ -65,3 +76,9 @@ Inside a container the two collapse to one value: both read the published bleed,
65
76
  `scroll-margin-inline` follows the **padding**, not the margin. It is what makes an item snap flush with the content edge rather than the padding edge, so it has to cancel the padding whatever the margin does.
66
77
 
67
78
  This is the same reasoning as the `--container-bleed` contract itself, applied to a scroll container. If you write your own full-bleed component, copy the pair rather than picking one fallback for both.
79
+
80
+ ## It needs a build step
81
+
82
+ The four responsive variants are written with `@media (--sm)` and its siblings, which rely on the `@custom-media` rules declared in css-base. No browser implements `@custom-media`, so without [postcss-custom-media](https://www.npmjs.com/package/postcss-custom-media) every `.scrollsnap-*` variant becomes a permanent carousel: the base block applies and the block that hands the grid back is dropped.
83
+
84
+ The failure is silent. See [the package index](../../).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uncinq/css-components",
3
- "version": "1.16.1",
3
+ "version": "1.16.3",
4
4
  "description": "Framework-agnostic CSS component implementations.",
5
5
  "license": "MIT",
6
6
  "repository": {