@uncinq/css-components 1.26.1 → 1.27.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.
@@ -29,6 +29,13 @@
29
29
  width: var(--btn-close-icon-size, var(--icon-size));
30
30
  }
31
31
 
32
+ /* Not a .btn, so the focus ring is restated: the browser's own one can
33
+ vanish on a coloured panel */
34
+ &:focus-visible {
35
+ outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-color-outline);
36
+ outline-offset: var(--focus-outline-offset, 2px);
37
+ }
38
+
32
39
  @media (hover: hover) and (pointer: fine) {
33
40
  &:hover {
34
41
  background-color: var(--btn-close-color-background-hover, var(--btn-control-color-background-hover));
@@ -30,6 +30,13 @@
30
30
  width: var(--btn-menu-icon-size, var(--icon-size));
31
31
  }
32
32
 
33
+ /* Not a .btn, so the focus ring is restated: the browser's own one can
34
+ vanish on a coloured header */
35
+ &:focus-visible {
36
+ outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-color-outline);
37
+ outline-offset: var(--focus-outline-offset, 2px);
38
+ }
39
+
33
40
  @media (hover: hover) and (pointer: fine) {
34
41
  &:hover {
35
42
  background-color: var(--btn-menu-color-background-hover, var(--btn-control-color-background-hover));
@@ -16,8 +16,10 @@
16
16
  * Position variants: .drawer-start | .drawer-end | .drawer-top | .drawer-bottom
17
17
  * Inline variants: .panel-inline-* — see components/panel.css
18
18
  *
19
- * Triggers: .js-dialog-toggle[data-target="#id"]
20
- * Close: .js-dialog-close inside the panel, Escape, or a click outside
19
+ * Triggers: popovertarget="id", or commandfor="id" command="show-modal" for a
20
+ * <dialog> — native, no script needed; see components/panel.css
21
+ * Close: the same with popovertargetaction="hide" or command="close",
22
+ * Escape, or a click outside
21
23
  */
22
24
  @layer components {
23
25
  .drawer {
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * Trigger: .js-dropdown-toggle (nextElementSibling must be .dropdown-menu)
12
12
  *
13
+ * Without JavaScript (scripting: none), the menu opens on hover and on
14
+ * keyboard focus within the .dropdown instead, so its links stay reachable.
15
+ *
13
16
  * Expected HTML:
14
17
  * <div class="dropdown">
15
18
  * <button class="js-dropdown-toggle" aria-expanded="false">Label</button>
@@ -130,6 +133,45 @@
130
133
  }
131
134
 
132
135
 
136
+ /* ── Without JavaScript ──────────────────────────────────────────────
137
+ * No script to toggle .show: the menu follows the pointer and the keyboard
138
+ * focus instead. Scoped to scripting: none, so it never fights the script's
139
+ * own state when there is one. */
140
+
141
+ @media (scripting: none) {
142
+ /* The toggle opens nothing by itself here: it stops covering its item,
143
+ so a link next to it stays clickable */
144
+ .dropdown-toggle::before {
145
+ content: none;
146
+ }
147
+
148
+ /* Closing waits, so the pointer can cross a gap between the item and
149
+ its menu — a mega menu below a header, for instance. The menu has to
150
+ take the pointer meanwhile: once closed, visibility: hidden keeps it
151
+ out of the way. */
152
+ .dropdown > .dropdown-menu {
153
+ pointer-events: auto;
154
+ transition-delay: 0.4s;
155
+ transition-property: opacity, transform, visibility;
156
+ }
157
+
158
+ /* Keyboard focus only: a click leaves focus on the toggle, and the menu
159
+ would stay open once the pointer has gone */
160
+ .dropdown:is(:hover, :has(:focus-visible)) {
161
+ > .dropdown-menu {
162
+ opacity: 1;
163
+ pointer-events: auto;
164
+ transform: none;
165
+ transition-delay: 0s;
166
+ visibility: visible;
167
+ }
168
+
169
+ > .dropdown-toggle::after {
170
+ rotate: 180deg;
171
+ }
172
+ }
173
+ }
174
+
133
175
  /* ── Alignment variants ──────────────────────────────────────────── */
134
176
 
135
177
  .dropdown-menu-end {
@@ -14,8 +14,10 @@
14
14
  *
15
15
  * Per-instance sizing: override --modal-max-width on the element.
16
16
  *
17
- * Triggers: .js-dialog-toggle[data-target="#id"]
18
- * Close: .js-dialog-close inside the panel, Escape, or a click outside
17
+ * Triggers: popovertarget="id", or commandfor="id" command="show-modal" for a
18
+ * <dialog> — native, no script needed; see components/panel.css
19
+ * Close: the same with popovertargetaction="hide" or command="close",
20
+ * Escape, or a click outside
19
21
  */
20
22
  @layer components {
21
23
  .modal {
@@ -7,14 +7,19 @@
7
7
  * drawer.css sticks them to an edge.
8
8
  *
9
9
  * The tag picks the strategy, not the class. Either variant can be:
10
- * - <dialog> opened with showModal() — [open], native ::backdrop, top layer,
11
- * and the browser handles Escape, the focus trap and inertness;
12
- * - any element carrying popover="auto", opened with showPopover() by the
13
- * script you provide — same top layer, same ::backdrop, but no role of its
14
- * own. Use this when the panel becomes a static region of the page above a
15
- * breakpoint (see .panel-inline-*): a <dialog> is exposed as role="dialog"
16
- * even when it is only styled as a sidebar, whereas a script can set that
17
- * role solely while the panel is open.
10
+ * - <dialog> opened as a modal — [open], native ::backdrop, top layer, and the
11
+ * browser handles Escape, the focus trap and inertness;
12
+ * - any element carrying popover="auto" — same top layer, same ::backdrop,
13
+ * but no role of its own. Use this when the panel becomes a static region
14
+ * of the page above a breakpoint (see .panel-inline-*): a <dialog> is
15
+ * exposed as role="dialog" even when it is only styled as a sidebar,
16
+ * whereas a script can set that role solely while the panel is open.
17
+ *
18
+ * Both open without a script, from native invokers on the buttons:
19
+ * popovertarget="x" for a popover, commandfor="x" command="show-modal" for a
20
+ * <dialog>, and their closing forms, popovertargetaction="hide" and
21
+ * command="close". closedby="any" lets a click on a <dialog>'s backdrop close
22
+ * it too. showModal() and showPopover() remain available to a script.
18
23
  *
19
24
  * Most selectors here are :where()-wrapped so they carry no specificity: the
20
25
  * variants and the host components always win without escalating. The
@@ -27,10 +32,11 @@
27
32
  * namespace, so --modal-* and --drawer-* stay the public API.
28
33
  *
29
34
  * Expected HTML:
30
- * <dialog class="modal" id="x" aria-labelledby="xLabel">
35
+ * <button commandfor="x" command="show-modal">...</button>
36
+ * <dialog class="modal" id="x" aria-labelledby="xLabel" closedby="any">
31
37
  * <div class="header">
32
38
  * <p class="title" id="xLabel">...</p>
33
- * <button class="btn-close js-dialog-close"></button>
39
+ * <button class="btn-close" commandfor="x" command="close"></button>
34
40
  * </div>
35
41
  * <div class="content">...</div>
36
42
  * <div class="footer">...</div>
@@ -70,6 +70,14 @@ The script must also keep `aria-expanded` on the toggle in sync with the state.
70
70
 
71
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
72
 
73
+ ## Without JavaScript
74
+
75
+ With scripting disabled, nothing toggles `.show`, so under `@media (scripting: none)` the menu opens on `:hover` of the `.dropdown` and on keyboard focus inside it (`:has(:focus-visible)`) instead, and the chevron turns with it. The links stay reachable with a pointer and with the keyboard. Keyboard focus rather than `:focus-within`: a click leaves the focus on the toggle, and the menu would stay open after the pointer has gone.
76
+
77
+ Two more adjustments apply in that mode. The toggle's `::before` no longer covers the whole item, since the toggle opens nothing by itself: a link next to it stays clickable. And the menu waits 0.4s before closing, so the pointer can cross a gap between the item and its menu, as with a mega menu laid out below a header.
78
+
79
+ The rule never applies when a script runs, so it cannot fight the script's own state. It reaches the menu as a **direct child** of `.dropdown`, like the rest of the contract.
80
+
73
81
  ## Alignment
74
82
 
75
83
  `.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.
@@ -31,4 +31,4 @@ Either variant works both ways, and the choice is an accessibility one rather th
31
31
 
32
32
  ## No JavaScript ships here
33
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.
34
+ `.modal` and `.drawer` open and close without one, from native invokers on their buttons; a script only adds the dialog role, the inert background and the cases the platform leaves out. `.dropdown` expects a script, and falls back to hover and focus when scripting is off. Each page documents the contract it needs.
@@ -13,10 +13,11 @@ There is **no `.panel` class.** The selectors read `:where(.modal, .drawer)`, so
13
13
  ## Structure
14
14
 
15
15
  ```html
16
- <dialog class="modal" id="contact" aria-labelledby="contactLabel">
16
+ <button commandfor="contact" command="show-modal">Contact</button>
17
+ <dialog class="modal" id="contact" aria-labelledby="contactLabel" closedby="any">
17
18
  <div class="header">
18
19
  <p class="title" id="contactLabel">Contact</p>
19
- <button class="btn-close js-dialog-close" aria-label="Close"></button>
20
+ <button class="btn-close" commandfor="contact" command="close" aria-label="Close"></button>
20
21
  </div>
21
22
  <div class="content">...</div>
22
23
  <div class="footer">...</div>
@@ -35,17 +36,17 @@ The panel itself carries **no padding**. It lives on the three sections instead,
35
36
 
36
37
  ## Two ways to open a panel
37
38
 
38
- **The tag picks the strategy, not the class.** Either variant works both ways.
39
+ **The tag picks the strategy, not the class.** Either variant works both ways, and both open **without JavaScript**, from a native invoker on the button. See [The JavaScript contract](#the-javascript-contract).
39
40
 
40
- ### `<dialog>` with `showModal()`
41
+ ### `<dialog>`, opened as a modal
41
42
 
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
+ Opened by `command="show-modal"`, or `showModal()` from a script. 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, and `closedby="any"` adds the click on the backdrop.
43
44
 
44
45
  This is the right choice when the panel is **always** a dialog.
45
46
 
46
- ### `popover="auto"` with `showPopover()`
47
+ ### `popover="auto"`
47
48
 
48
- Same top layer, same native `::backdrop`, but the element carries no implicit role.
49
+ Opened by `popovertarget`, or `showPopover()` from a script. Same top layer, same native `::backdrop`, but the element carries no implicit role.
49
50
 
50
51
  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
 
@@ -133,18 +134,24 @@ If you add a property to the panel rule, neutralise it in all four variants. Tha
133
134
 
134
135
  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
 
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
+ 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 an attribute — `popovertarget`, `commandfor` or a script's `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
 
138
139
  ## The JavaScript contract
139
140
 
140
- This package ships no JavaScript. `.modal` and `.drawer` expect a script that provides:
141
+ This package ships no JavaScript, and a panel does not strictly need any: the browser opens and closes it by itself, from native invokers on the buttons.
141
142
 
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 |
143
+ | Panel | Trigger | Close button |
144
+ | --- | --- | --- |
145
+ | `popover="auto"` | `popovertarget="id"` | `popovertarget="id" popovertargetaction="hide"` |
146
+ | `<dialog>` | `commandfor="id" command="show-modal"` | `commandfor="id" command="close"` |
147
+
148
+ Escape works on both. A click outside closes a popover through light dismiss, and a `<dialog>` once it carries `closedby="any"`. `popovertarget` is Baseline 2024, invoker commands (`commandfor`) Baseline 2025.
149
+
150
+ A script then adds what the platform does not:
146
151
 
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.
152
+ - `role="dialog"` and `aria-modal` on a popover, only while it is open, and an inert background behind it;
153
+ - closing the panel when one of its in-page anchors is followed;
154
+ - opening and closing for markup without native invokers, or for a browser that does not know invoker commands yet, through hooks of its choosing — for instance `.js-dialog-toggle[data-target="#id"]` and `.js-dialog-close`.
148
155
 
149
156
  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
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uncinq/css-components",
3
- "version": "1.26.1",
3
+ "version": "1.27.0",
4
4
  "description": "Framework-agnostic CSS component implementations.",
5
5
  "license": "MIT",
6
6
  "repository": {