@greypan/web-ui 7.0.0 → 8.1.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 (120) hide show
  1. package/README.CN.md +109 -24
  2. package/README.md +149 -63
  3. package/dist/assets/menu-portal.js +1 -1
  4. package/dist/assets/menu-portal.js.map +1 -1
  5. package/dist/assets/selection-control.js +1 -1
  6. package/dist/assets/selection-control.js.map +1 -1
  7. package/dist/components/autocomplete/index.d.ts +17 -5
  8. package/dist/components/autocomplete/index.js +138 -103
  9. package/dist/components/autocomplete/index.js.map +1 -1
  10. package/dist/components/autocomplete/style.js +1 -1
  11. package/dist/components/autocomplete/style.js.map +1 -1
  12. package/dist/components/avatar/index.js +4 -4
  13. package/dist/components/badge/style.js.map +1 -1
  14. package/dist/components/button/index.d.ts +368 -1
  15. package/dist/components/button/index.js +61 -41
  16. package/dist/components/button/index.js.map +1 -1
  17. package/dist/components/button/style.js +1 -1
  18. package/dist/components/button/style.js.map +1 -1
  19. package/dist/components/button-group/index.js +5 -5
  20. package/dist/components/checkbox/index.d.ts +3 -0
  21. package/dist/components/checkbox/index.js +30 -32
  22. package/dist/components/checkbox/index.js.map +1 -1
  23. package/dist/components/checkbox/style.js +1 -1
  24. package/dist/components/checkbox/style.js.map +1 -1
  25. package/dist/components/checkbox-group/index.js +4 -4
  26. package/dist/components/context-menu/index.js +4 -1
  27. package/dist/components/context-menu/index.js.map +1 -1
  28. package/dist/components/dialog/index.js +11 -8
  29. package/dist/components/dialog/index.js.map +1 -1
  30. package/dist/components/drawer/index.js +11 -8
  31. package/dist/components/drawer/index.js.map +1 -1
  32. package/dist/components/dropdown/index.js +4 -1
  33. package/dist/components/dropdown/index.js.map +1 -1
  34. package/dist/components/dropdown-item/style.js +1 -1
  35. package/dist/components/dropdown-item/style.js.map +1 -1
  36. package/dist/components/editable-text/index.d.ts +473 -0
  37. package/dist/components/editable-text/index.js +226 -0
  38. package/dist/components/editable-text/index.js.map +1 -0
  39. package/dist/components/editable-text/style.js +6 -0
  40. package/dist/components/editable-text/style.js.map +1 -0
  41. package/dist/components/icon/index.js +7 -7
  42. package/dist/components/icon/index.js.map +1 -1
  43. package/dist/components/image-preview/index.js +21 -18
  44. package/dist/components/image-preview/index.js.map +1 -1
  45. package/dist/components/index.d.ts +1 -0
  46. package/dist/components/index.js +39 -38
  47. package/dist/components/input/index.d.ts +9 -0
  48. package/dist/components/input/index.js +41 -30
  49. package/dist/components/input/index.js.map +1 -1
  50. package/dist/components/input-number/index.d.ts +1 -0
  51. package/dist/components/input-number/index.js +26 -24
  52. package/dist/components/input-number/index.js.map +1 -1
  53. package/dist/components/layout/index.js +8 -8
  54. package/dist/components/layout/index.js.map +1 -1
  55. package/dist/components/layout/style.js +1 -1
  56. package/dist/components/layout/style.js.map +1 -1
  57. package/dist/components/option/style.js +1 -1
  58. package/dist/components/option/style.js.map +1 -1
  59. package/dist/components/popover/index.js +13 -10
  60. package/dist/components/popover/index.js.map +1 -1
  61. package/dist/components/popover/style.js +1 -1
  62. package/dist/components/popover/style.js.map +1 -1
  63. package/dist/components/radio/index.d.ts +3 -0
  64. package/dist/components/radio/index.js +22 -18
  65. package/dist/components/radio/index.js.map +1 -1
  66. package/dist/components/radio-group/index.js +4 -4
  67. package/dist/components/segmented/index.d.ts +4 -1
  68. package/dist/components/segmented/index.js +42 -29
  69. package/dist/components/segmented/index.js.map +1 -1
  70. package/dist/components/segmented/style.js +1 -1
  71. package/dist/components/segmented/style.js.map +1 -1
  72. package/dist/components/segmented-trigger/style.js +1 -1
  73. package/dist/components/segmented-trigger/style.js.map +1 -1
  74. package/dist/components/select/index.js +35 -32
  75. package/dist/components/select/index.js.map +1 -1
  76. package/dist/components/select/style.js +1 -1
  77. package/dist/components/select/style.js.map +1 -1
  78. package/dist/components/slider/index.js +11 -11
  79. package/dist/components/switch/index.d.ts +1 -0
  80. package/dist/components/switch/index.js +34 -32
  81. package/dist/components/switch/index.js.map +1 -1
  82. package/dist/components/textarea/index.d.ts +7 -1
  83. package/dist/components/textarea/index.js +46 -44
  84. package/dist/components/textarea/index.js.map +1 -1
  85. package/dist/components/textarea/style.js.map +1 -1
  86. package/dist/components/theme/index.d.ts +10 -3
  87. package/dist/components/theme/index.js +133 -93
  88. package/dist/components/theme/index.js.map +1 -1
  89. package/dist/components/theme/style.js +1 -1
  90. package/dist/components/theme/style.js.map +1 -1
  91. package/dist/components/toast/toast.js +7 -7
  92. package/dist/components/toast/toast.js.map +1 -1
  93. package/dist/components/tooltip/index.js +12 -9
  94. package/dist/components/tooltip/index.js.map +1 -1
  95. package/dist/components/tooltip/style.js +1 -1
  96. package/dist/components/tooltip/style.js.map +1 -1
  97. package/dist/icons/generated/akar-icons-check.d.ts +6 -0
  98. package/dist/icons/generated/akar-icons-check.js +10 -0
  99. package/dist/icons/generated/akar-icons-check.js.map +1 -0
  100. package/dist/icons/generated/bi-check.d.ts +6 -0
  101. package/dist/icons/generated/bi-check.js +10 -0
  102. package/dist/icons/generated/bi-check.js.map +1 -0
  103. package/dist/icons/index.d.ts +2 -0
  104. package/dist/icons/index.js +3 -1
  105. package/dist/shared/form-association/field-id.js +9 -0
  106. package/dist/shared/form-association/field-id.js.map +1 -0
  107. package/dist/shared/form-association/index.js +9 -1
  108. package/dist/shared/form-association/index.js.map +1 -1
  109. package/dist/shared/label-emptiness/index.js +45 -0
  110. package/dist/shared/label-emptiness/index.js.map +1 -0
  111. package/dist/shared/option-portal/combobox-shell.js +47 -5
  112. package/dist/shared/option-portal/combobox-shell.js.map +1 -1
  113. package/dist/shared/option-portal/index.js +9 -9
  114. package/dist/shared/overlay/anchored-panel.js +2 -2
  115. package/dist/shared/overlay/anchored-panel.js.map +1 -1
  116. package/dist/shared/overlay/open-overlay.js +38 -22
  117. package/dist/shared/overlay/open-overlay.js.map +1 -1
  118. package/dist/shared/theme/root-sync.js +48 -0
  119. package/dist/shared/theme/root-sync.js.map +1 -0
  120. package/package.json +3 -1
package/README.md CHANGED
@@ -43,7 +43,7 @@ camelCase, Attributes use kebab-case, Events use kebab-case.
43
43
  | Event | kebab-case | `open-change`, `sidebar-collapsed-change` |
44
44
 
45
45
  - **Boolean attributes** follow native HTML presence semantics: absent → `false`, presence → `true`. A framework
46
- binding that writes `disabled="false"` produces the string `"false"`, which is truthy — **bind dynamic booleans as
46
+ binding that writes `disabled="false"` produces the string `"false"`, which is truthy. **Bind dynamic booleans as
47
47
  properties** (camelCase), not attributes, so `false` is written as a real property.
48
48
  - **Vue**: dynamic binding must go to **Properties** with camelCase names (`:sidebarCollapsed="x"`, `:open="x"`).
49
49
  A kebab-case binding like `:sidebar-collapsed="x"` is written as a string attribute and cannot express `false`;
@@ -52,7 +52,7 @@ camelCase, Attributes use kebab-case, Events use kebab-case.
52
52
  `v-model` is supported on value-bearing controls (`web-ui-input`, `web-ui-select`, `web-ui-autocomplete`, …) and
53
53
  compiles to the element `value` property + `input` event.
54
54
  - **React**: React 19 writes DOM properties for custom-element props, so use camelCase props (`open={open}`,
55
- `noScrollLock`, `value={value}`). Never pass complex data (objects, arrays) through attribute strings — bind
55
+ `noScrollLock`, `value={value}`). Never pass complex data (objects, arrays) through attribute strings. Bind
56
56
  them as properties. Kebab-case JSX props on a custom element are written as attributes.
57
57
 
58
58
  ### React
@@ -90,7 +90,7 @@ function App() {
90
90
 
91
91
  React 19 registers Custom Element events using the suffix of the JSX `on` key unchanged. Event names are
92
92
  case-sensitive: bind `open-change` as `onopen-change`, not `onOpenChange`. Standard `input`, `change`, `focus`,
93
- and `blur` events use React's conventional `onInput`, `onChange`, `onFocus`, and `onBlur` handlers — their
93
+ and `blur` events use React's conventional `onInput`, `onChange`, `onFocus`, and `onBlur` handlers. Their
94
94
  `currentTarget` is typed as the component instance, so `value` and `checked` are read cast-free. The `target`
95
95
  follows React SyntheticEvent semantics (`EventTarget`) and is not narrowed. Kebab-case custom events carry their
96
96
  `CustomEvent` detail typed. Boolean properties follow native HTML semantics: absence is `false` and presence is
@@ -126,7 +126,7 @@ useEffect(() => {
126
126
  In `portal` mode the library physically moves panel content into the overlay Shadow DOM:
127
127
 
128
128
  - React removes nodes through its recorded insertion parent, so a bare conditional child (`{condition && <el/>}`) whose node was already migrated cannot be removed while the panel is open: the commit fails and the node can reappear when the panel closes.
129
- - Conditionally render inside a stable wrapper element instead — React records the wrapper as the insertion parent, so wrapper-local additions and removals keep working while the panel is open and across open/close cycles (example below).
129
+ - Conditionally render inside a stable wrapper element instead. React records the wrapper as the insertion parent, so wrapper-local additions and removals keep working while the panel is open and across open/close cycles (example below).
130
130
  - Bare conditional additions while the panel is open (appending to the host) keep working and migrate into the panel automatically.
131
131
  - Keeping content mounted and toggling visibility remains a valid fallback.
132
132
 
@@ -143,12 +143,12 @@ Portal panels support live rendering for Lit child parts: `${condition ? html`<e
143
143
  automatically, removals and element-to-element swaps work while the panel is open, and on close content is
144
144
  restored to its original anchor position. lit-html child-part markers stay in the host light DOM, so
145
145
  subsequent patches keep anchoring correctly. Boundary: reordering a `repeat()` list while a panel is open is
146
- not supported — lit inserts relative to host part markers while sibling items already live in the panel, the
146
+ not supported. Lit inserts relative to host part markers while sibling items already live in the panel, the
147
147
  same cross-container anchoring boundary as Vue's keyed `v-for`. Apply list reorders while the panel is closed.
148
148
 
149
149
  `web-ui-dropdown` manages menu items through the shared menu portal, which reconciles from both the host and
150
150
  the panel: child-part additions while the panel is open work, but lit cannot reach items that already live in
151
- the panel, so removals and element swaps there are not supported — apply them while the panel is closed.
151
+ the panel, so removals and element swaps there are not supported. Apply them while the panel is closed.
152
152
  `web-ui-context-menu` does not observe host-side insertions and is not covered by these live-rendering
153
153
  guarantees.
154
154
 
@@ -160,7 +160,7 @@ Portal panels support live rendering: content conditionally added or removed whi
160
160
  `v-if` inside `web-ui-select`, `web-ui-autocomplete`, `web-ui-popover`, `web-ui-tooltip`, `web-ui-dropdown`,
161
161
  `web-ui-context-menu`) migrates into the open panel automatically in template order, and on close everything
162
162
  is restored to its original anchor position so subsequent patches keep working. Boundary: splicing or
163
- reordering a keyed `v-for` list while a panel is open is not supported — Vue's keyed children diff resolves
163
+ reordering a keyed `v-for` list while a panel is open is not supported. Vue's keyed children diff resolves
164
164
  insertion anchors against sibling nodes already migrated into the panel, so its patch fails and items can be
165
165
  lost, regardless of compilation path (compiled templates included). Apply such list updates while the panel
166
166
  is closed, or keep to appends/removals at the list tail.
@@ -192,7 +192,7 @@ import '@greypan/web-ui/types/vue'
192
192
  ```
193
193
 
194
194
  Boolean properties must be bound with the **camelCase property name**, not the kebab-case attribute. Vue writes
195
- attribute bindings as strings, and boolean attributes follow presence semantics — so `:sidebar-collapsed="false"`
195
+ attribute bindings as strings, and boolean attributes follow presence semantics, so `:sidebar-collapsed="false"`
196
196
  produces the string `"false"`, which is truthy. Binding the camelCase name (`:sidebarCollapsed="false"`, or the
197
197
  `.prop` modifier) makes Vue write the DOM property directly:
198
198
 
@@ -240,20 +240,20 @@ family by the nature of the interaction. Do not assume `controlled` means the sa
240
240
  **User-originated events only (all `open`-style components: collapse, popover, dropdown, tooltip, dialog, drawer, back-top).**
241
241
  `*-change` events (`open-change`, etc.) fire only for user-originated toggles; assigning the property programmatically
242
242
  (`el.open = true`) or calling an imperative method (`show()`, `close()`, `toggle()`) never emits. Components self-manage
243
- their `open` value unless you coordinate externally — you may observe the event and write it back for your own state, but
243
+ their `open` value unless you coordinate externally. You may observe the event and write it back for your own state, but
244
244
  the component does not require or wait for that write-back.
245
245
 
246
246
  **Request-then-write-back (`controlled` on dialog and drawer only).**
247
247
  With `controlled`, user close actions (Escape, backdrop, built-in close, drag-release) only _request_ `open=false`; the
248
248
  component does not change its own state, and it stays open until the consumer writes `open=false`. This is the modal
249
- confirmation semantics — closing a dialog or drawer is typically an operation that needs consumer approval (unsaved form,
249
+ confirmation semantics. Closing a dialog or drawer is typically an operation that needs consumer approval (unsaved form,
250
250
  confirmation), and unlike instantaneous open/close, it is not safe for the component to self-commit. Only these two modal
251
251
  components use this mode; the instant open/close family (collapse, popover, dropdown, tooltip) does not need it.
252
252
 
253
253
  **Native form-model (input, textarea, input-number, select, checkbox, radio, switch, autocomplete).**
254
254
  The control manages its own value/checked state internally; user interaction flips it immediately and then dispatches
255
255
  native `input`/`change` events. Assigning the property programmatically (`el.checked = true`) overrides the internal
256
- state. This mirrors native `<input type="checkbox">` — there is no `controlled` prop because the value is always readable
256
+ state. This mirrors native `<input type="checkbox">`. There is no `controlled` prop because the value is always readable
257
257
  and writable, and React/Vue layers provide their own controlled wrappers on top.
258
258
 
259
259
  For interactions that need an external round-trip before the flip is durable (e.g. a switch that should only stay on
@@ -269,12 +269,15 @@ The flip-before-approval window is accepted; there is no `controlled` prop for a
269
269
 
270
270
  All form controls participate in native `FormData`, constraint validation, `form.reset()` and browser form-state restoration. The control captures its reset default once, after declarative attributes have been applied on its first connection. Later runtime property updates do not redefine that default. A disabled ancestor `fieldset` disables validation and interaction without mutating the control's public `disabled` property. For checkbox/radio groups, the group is the single submission, reset and restoration owner; managed child controls do not submit or restore an independent state.
271
271
 
272
+ `web-ui-button` is also form-associated so its host can own an outer form and forward `submit`/`reset` activation. It contributes no value to `FormData`.
273
+
272
274
  ## All Components
273
275
 
274
276
  | Category | Component |
275
277
  | ---------------------- | --------------------------------------------------------- |
276
278
  | **Form Controls** | [`<web-ui-input>`](#web-ui-input) |
277
279
  | | [`<web-ui-textarea>`](#web-ui-textarea) |
280
+ | | [`<web-ui-editable-text>`](#web-ui-editable-text) |
278
281
  | | [`<web-ui-input-number>`](#web-ui-input-number) |
279
282
  | | [`<web-ui-select>`](#web-ui-select) |
280
283
  | | [`<web-ui-autocomplete>`](#web-ui-autocomplete) |
@@ -337,6 +340,8 @@ Text input with clearable, prefix/suffix slots.
337
340
 
338
341
  **Events:** `input`, `change`, `focus`, `blur`
339
342
 
343
+ **Methods:** `focus()`, `blur()`, `select()`, which delegate to the internal native input (the host itself is not focusable). `focus()` and `select()` are no-ops while `disabled`; `readonly` still allows focus and selection.
344
+
340
345
  **Slots:** `prefix`, `default`, `suffix`
341
346
 
342
347
  **CSS Custom Properties:**
@@ -370,7 +375,7 @@ Multi-line text input with auto-resize.
370
375
 
371
376
  **Events:** `input`, `change`, `focus`, `blur`
372
377
 
373
- **Methods:** `focus()`, `blur()`, `select()`
378
+ **Methods:** `focus()`, `blur()`, `select()`, which delegate to the internal native textarea. `focus()` and `select()` are no-ops while `disabled`; `readonly` still allows focus and selection.
374
379
 
375
380
  **Slots:** `prefix`, `suffix`
376
381
 
@@ -381,6 +386,42 @@ Multi-line text input with auto-resize.
381
386
  | `--wui-textarea-width` | `200px` | Textarea width |
382
387
  | `--wui-textarea-clear-color` | `var(--wui-color-text-tertiary)` | Clear button color |
383
388
 
389
+ #### `<web-ui-editable-text>`
390
+
391
+ Inline plain-text editor: click the text to edit in place, `Enter` and `blur` commit, `Escape` cancels. The text layer and the editing layer share one box, so entering edit mode does not move a single pixel.
392
+
393
+ | Attribute | Type | Default | Description |
394
+ | ------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
395
+ | `value` | `string` | `''` | Current value; the declarative value captured on first connection becomes the `form.reset()` default; later attribute or property changes do not update it |
396
+ | `placeholder` | `string` | `''` | Text shown while the value is empty |
397
+ | `name` | `string` | `''` | Form field name |
398
+ | `disabled` | `boolean` | `false` | Disabled state; behavior only, no visual dimming |
399
+ | `readonly` | `boolean` | `false` | Read-only state; focus, selection, and copying remain available, but edits cannot commit |
400
+ | `aria-label` | `string` | — | Accessible label |
401
+
402
+ **Events:** `input` (per keystroke), `change` (commit), `cancel` (cancel; shares its name with the native `<dialog>` `cancel`, so it neither bubbles nor crosses shadow boundaries and is dispatched on the component itself only). React has no synthetic event covering `cancel`: listen with `addEventListener('cancel', ...)`
403
+
404
+ **Methods:** `focus()`, `blur()`, `select()`
405
+
406
+ Clicking places the caret at the clicked offset; keyboard focus places it at the end of the text. `Enter` and `blur` both commit the draft and dispatch `change` exactly once; `Enter` does not insert a newline and returns focus to the host, while `blur` leaves the focus wherever the user moved it. `Escape` alone cancels: the value returns to what it was when editing started, `cancel` is dispatched with no `change`, focus returns to the host, and the key is consumed by the editing layer, so an outer overlay (drawer, menu) is not closed by the same press. The `cancel` event neither bubbles nor crosses shadow boundaries and is dispatched on the component itself only: when the component is projected inside an overlay's shadow root, such as a drawer title, it never reaches the overlay's native `cancel` close pipeline, so listen on the component itself. A value that already contains newlines still renders as several lines; only typing `Enter` no longer adds one. An empty value keeps showing the placeholder, and the editing layer always sizes itself to its own content, so an empty or whitespace-only draft still has room for the caret even where the host itself collapses (a flex item with `min-width: 0`, a table cell).
407
+
408
+ `select()` enters edit mode with the whole content selected, and re-selects it when already editing. While `disabled` it does nothing, matching `focus()`. While `readonly`, focus and selection remain available, but input is rejected and leaving edit mode does not dispatch `change`.
409
+
410
+ The host is an inline-level box: it sizes to its content unless a width is set, and grows with wrapped lines. Font, color, text alignment and white space are inherited from the surrounding context, so the component reads as ordinary text until it is edited.
411
+
412
+ The caret follows the shared `--wui-color-accent` semantic token by default; set that token on the host or any ancestor to recolor it. Both layers wrap a long unbroken string (letters and digits with no spaces) at the same points, so a token wider than the box breaks inside the box instead of overflowing it while idle.
413
+
414
+ **CSS Custom Properties:**
415
+
416
+ | Property | Default | Description |
417
+ | ----------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
418
+ | `--wui-editable-text-white-space` | `pre-wrap` | White-space handling of both text layers |
419
+ | `--wui-editable-text-overflow-wrap` | `anywhere` | Overflow-wrap handling of both text layers; the default breaks a long unbroken string inside the box, matching the native editing layer |
420
+
421
+ The shared box comes with two constraints: keep `line-height` at `1` or above, because below that the native editing layer grows taller than its own box and shifts the text by a pixel; and with `nowrap` plus a fixed width, text wider than the box overflows while idle and scrolls inside the box while editing.
422
+
423
+ **Interaction change:** `blur` commits again: both `Enter` and `blur` commit the draft, and only `Escape` cancels, restoring the value from the moment editing started. `Enter` still does not insert a newline. The `cancel` event no longer bubbles: a bubbling custom `cancel` used to travel through the slot into the `<dialog>` inside an outer overlay's shadow root (`composed` made no difference) and was treated as a close request, closing the overlay along with the edit (issue #159). Listen on the component itself.
424
+
384
425
  #### `<web-ui-input-number>`
385
426
 
386
427
  Numeric input with step buttons and keyboard control.
@@ -419,7 +460,7 @@ Select dropdown with option items, keyboard navigation, and portal support.
419
460
 
420
461
  **Events:** `input`, `change`, `open-change` (`CustomEvent<{ open: boolean }>`)
421
462
 
422
- **Slots:** `default` (project `<web-ui-option>` elements), `trigger` (custom trigger content — replaces the default label and chevron area)
463
+ **Slots:** `default` (project `<web-ui-option>` elements), `trigger` (custom trigger content, replacing the default label and chevron area)
423
464
 
424
465
  **Methods:** none
425
466
 
@@ -461,10 +502,26 @@ Editable combobox with input filtering and single option selection.
461
502
 
462
503
  **Events:** `input`, `change`, `focus`, `blur`, `open-change` (`CustomEvent<{ open: boolean }>`)
463
504
 
464
- **Slots:** `default` (project `<web-ui-option>` elements), `empty` (replace the “no matches” state; falls back to “No matches”)
505
+ **Methods:** `focus()`, `blur()`
506
+
507
+ **Slots:** `default` (project `<web-ui-option>` elements), `trigger` (custom trigger content, replacing the default input), `empty` (replace the "no matches" state; falls back to "No matches")
465
508
 
466
509
  Typing filters the option list by label (`contains` or `prefix`, or `none` to disable filtering). Selecting an option fills the input with its label and exposes the option's value via `selected-value`; `change` fires on selection commit. Supports ArrowDown/ArrowUp/Enter/Escape keyboard navigation.
467
510
 
511
+ **Trigger:** the default trigger is an internal `web-ui-input`. Put any editable component in the `trigger` slot to replace it. The wrapper keeps the combobox ARIA and marks itself with `data-custom-trigger`, and the dropdown stays anchored to the trigger element. Programmatic `focus()` / `blur()` delegate to the active trigger: `web-ui-input` and `web-ui-textarea` move focus to their native control, while a custom trigger without its own focus redirection is focused as the host itself.
512
+
513
+ ```html
514
+ <web-ui-autocomplete placeholder="Describe the issue">
515
+ <web-ui-textarea slot="trigger" rows="3"></web-ui-textarea>
516
+ <web-ui-option value="bug" label="Bug"></web-ui-option>
517
+ <web-ui-option value="feature" label="Feature"></web-ui-option>
518
+ </web-ui-autocomplete>
519
+ ```
520
+
521
+ The custom trigger exposes the same contract as the default one: a string `value` for the text, focusable, and the events the component delegates from it (`input`, `click`, `focus`, `blur`). `web-ui-input` and `web-ui-textarea` satisfy it as-is; a custom element is usable when it exposes a string `value` property. The wrapper div is not a tab stop (`tabindex="-1"`): sequential focus belongs to the trigger itself, so a custom trigger has to be focusable for keyboard users to reach the combobox.
522
+
523
+ Multiline triggers (a trigger whose editable element is a `<textarea>`) keep Enter for newlines: Enter never selects the highlighted option and never commits a custom value; close the panel with Escape or blur. Selecting an option still writes its label back to the trigger. A single-line custom trigger keeps the default Enter behavior. ArrowUp/ArrowDown follow the same exception while the panel is open: they move the text caret instead of navigating options, so keyboard option navigation is unavailable in that state. Select with a pointer click. With the panel closed, ArrowDown/ArrowUp still open it.
524
+
468
525
  When `allow-custom-value` is enabled, pressing Enter with no active option and no matching candidate commits the raw input as a custom value and closes the panel. `change` fires and `selected-value` remains empty. The component does not create an option automatically or trim the raw value. Text matching a disabled option cannot bypass the disabled state or derive as a selected option.
469
526
 
470
527
  Use `<div slot="empty">…</div>` for static, non-interactive empty-state content. Portal rendering moves this node into the floating panel and restores it to the host after close.
@@ -531,11 +588,11 @@ Individual checkbox.
531
588
 
532
589
  Uses native `<label>` with `role="checkbox"` and `aria-checked`. Enter/Space keyboard toggle.
533
590
 
534
- **Layout:** the host is an inline-flex box whose height is set by its content, so the inherited page line-height can no longer inflate it or shift the indicator up and down; it aligns against surrounding text with `vertical-align: middle`. The indicator measures `--wui-selection-control-size` (`18px`), and `<web-ui-radio>` shares the same box contract.
591
+ **Layout:** the host is an inline-flex box whose height is set by its content, so the inherited page line-height can no longer inflate it or shift the indicator up and down; it aligns against surrounding text with `vertical-align: middle`. The indicator measures `--wui-selection-control-size` (`18px`), and `<web-ui-radio>` shares the same box contract. The 10px between indicator and label belongs to the label: when the default slot renders nothing — it is empty, or it carries only a visually hidden accessible name such as an `.sr-only` span — the gap collapses and the host is exactly as wide as the indicator. Emptiness is decided from the label's rendered width rather than from the slot's assigned nodes, because a hidden name is assigned content that paints no box, and hiding the label with `display: none` would take that name out of the accessibility tree.
535
592
 
536
- **Check animation:** the checkmark is the control's own stroked path rather than a `<web-ui-icon>` asset, wrapped in `<web-ui-svg-draw-lines>` (with `no-autoplay`, so a control that mounts already checked shows a static check). Checking draws it in left to right at a constant speed over `--wui-duration-trigger` (160ms by default), read from the resolved theme at the time of the toggle so the stroke lands on the same beat as the indicator's background transition; unchecking retracts the same path back to blank instead of only fading out. Neither plays inside a `motion="reduced"` theme scope, where both states switch instantly.
593
+ **Check animation:** the checkmark is a stroked check from `@/icons`, rendered by a nested `<web-ui-icon>` inside `<web-ui-svg-draw-lines>` (with `no-autoplay`, so a control that mounts already checked shows a static check). Checking draws it in from the left tip at a constant speed over `--wui-duration-trigger` (160ms by default), read from the resolved theme at the time of the toggle so the stroke lands on the same beat as the indicator's background transition; unchecking retracts the same path back to blank instead of only fading out. Neither plays inside a `motion="reduced"` theme scope, where both states switch instantly. The stroke color is `--wui-color-on-control`, passed down through `<web-ui-icon>`'s `--wui-icon-color`, because the indicator behind it is `--wui-color-accent` once checked. Two constraints follow from that arrangement: the asset has to stay stroked (`fill: none` + `stroke: currentColor`) — a solid icon animates the dash but paints nothing, so the check would just appear — and it renders at `<web-ui-icon>`'s own default 18px, since the host cannot reach the `<svg>` inside the icon's shadow root to scale it with `--wui-selection-control-size`.
537
594
 
538
- **Idle states:** the unchecked indicator is filled with `--wui-color-surface-control`, the same control surface neutral buttons use, so it stays separable from `--wui-color-page` in dark mode. Hovering anywhere in the trigger row — indicator, gap or slotted label — tints that surface with a 6% state layer over `--wui-color-surface-control`. Hover applies only on `(hover: hover) and (pointer: fine)` devices; there is no pressed state, and checked and disabled controls keep their own surface. `<web-ui-radio>` shares the same states.
595
+ **Idle states:** the unchecked indicator is filled with `--wui-color-surface-control`, the same control surface neutral buttons use, so it stays separable from `--wui-color-page` in dark mode. Hovering over the indicator, the gap or the slotted label inside the trigger row tints that surface with a 6% state layer over `--wui-color-surface-control`. Hover applies only on `(hover: hover) and (pointer: fine)` devices; there is no pressed state, and checked and disabled controls keep their own surface. `<web-ui-radio>` shares the same states.
539
596
 
540
597
  #### `<web-ui-radio>`
541
598
 
@@ -553,7 +610,7 @@ Individual radio button.
553
610
 
554
611
  **Slots:** `default` (label text)
555
612
 
556
- **Layout:** shares the selection-control box contract with `<web-ui-checkbox>` — host height comes from its content, alignment against surrounding text is `vertical-align: middle`, and the indicator measures `--wui-selection-control-size`.
613
+ **Layout:** shares the selection-control box contract with `<web-ui-checkbox>`, including the rule that the indicator-label gap only exists while the label renders something. Host height comes from its content, alignment against surrounding text is `vertical-align: middle`, and the indicator measures `--wui-selection-control-size`.
557
614
 
558
615
  #### `<web-ui-switch>`
559
616
 
@@ -576,25 +633,32 @@ Uses `role="switch"` and `aria-checked`. Pointer events for pressed visual state
576
633
 
577
634
  #### `<web-ui-segmented>`
578
635
 
579
- Segmented control — single-select button group.
636
+ Segmented control: a single-select button group.
580
637
 
581
- | Attribute | Type | Default | Description |
582
- | ---------- | --------- | ------- | ---------------------- |
583
- | `value` | `string` | `''` | Selected trigger value |
584
- | `name` | `string` | `''` | Form field name |
585
- | `disabled` | `boolean` | `false` | Disables all triggers |
586
- | `required` | `boolean` | `false` | Required validation |
638
+ | Attribute | Type | Default | Description |
639
+ | ---------- | ------------------- | ------- | ------------------------------------ |
640
+ | `value` | `string` | `''` | Selected trigger value |
641
+ | `name` | `string` | `''` | Form field name |
642
+ | `disabled` | `boolean` | `false` | Disables all triggers |
643
+ | `required` | `boolean` | `false` | Required validation |
644
+ | `variant` | `inset` \| `raised` | `inset` | Track and resting indicator material |
587
645
 
588
646
  **Events:** `input`, `change`
589
647
 
590
648
  **Slots:** `default` (project `<web-ui-segmented-trigger>` elements)
591
649
 
650
+ **Variants:** `inset` (default, sunken) renders an opaque `--wui-color-surface` track with a constant 1px ring and drop shadow, and a solid `--wui-color-surface-segmented` resting indicator that reads as embedded in the track. In light appearance that token equals `--wui-color-page`, so the track sits flush with the page and the recess reads from the ring and shadow alone; in dark appearance `--wui-color-surface` is the one-step-above-page elevation, so the track keeps lifting off the page. `raised` renders the classic flat `--wui-color-surface-segmented` track (no ring, no shadow) with a solid `--wui-color-surface-selected` resting indicator carrying a soft shadow that floats above the track. In both variants the pressed/dragged indicator is transparent glass, with backdrop blur, a ring fading in over 80ms and a 1.5x scale, easing back to the resting surface on release. Illegal values fall back to `inset`.
651
+
592
652
  Form-associated: integrates with native `<form>` via `ElementInternals`.
593
653
 
594
654
  Manages child trigger `checked` state based on `value`. `disabled` supplies inherited effective disabled state without changing a trigger's own `disabled` property. Setting `value` directly does not dispatch `input`/`change`.
595
655
 
596
656
  Press the selected segment and drag horizontally to slide the indicator; on release it snaps to the nearest option (a fast flick switches by velocity). Touch surfaces declare `touch-action: none` and suppress `touchmove` defaulting during an active drag, so iOS Safari does not interrupt the gesture.
597
657
 
658
+ **Pressed state:** the track is an opaque surface (`--wui-color-surface`, page-colored in light appearance) with no backdrop filter in every state. Its 1px ring and drop shadow never change. The indicator is the layer that turns to glass. At rest it is a solid `--wui-color-surface-segmented` surface with all glass output off. While pressed or dragging it gains backdrop blur, the glass ring fading in over 80ms, and a 1.5x scale over a fully transparent background: the indicator samples its blur entirely from inside the track, and against the previous glass track a fully transparent indicator measured only +0.5 gray levels on a uniform dark backdrop while striped backdrops showed through the sample (std 1.89 gray levels inside the indicator). The opaque track makes that sample uniform (std 0), which is what gives the pressed thumb a perceptible body. No tint and no edge-decoration suppression are needed. The background never fades in. It lands on the first pressed frame. And on release the indicator eases back to the solid gray rest surface over `--wui-duration-press`.
659
+
660
+ **Label color:** every label stays `--wui-color-text-secondary`. Checked or not, `inset` or `raised`, at rest and while a press or drag is active. Selection is carried by the indicator alone; no label takes the accent color.
661
+
598
662
  #### `<web-ui-checkbox-group>`
599
663
 
600
664
  Checkbox group managing multiple selection.
@@ -686,10 +750,11 @@ Styled button with variants and loading state.
686
750
 
687
751
  **Slots:** `prefix`, `default`, `suffix`
688
752
 
689
- `submit` and `reset` do not submit or reset an ancestor form outside the component's Shadow DOM. Use a
690
- form-associated control when external form submission behavior is required.
753
+ `submit` and `reset` forward activation through the host's form owner to its native `<form>` after the composed
754
+ `click` event finishes, so calling `preventDefault()` on that click cancels the action. The inner button still has no
755
+ form owner of its own, and `SubmitEvent.submitter` is therefore `null`. The button contributes no value to `FormData`.
691
756
 
692
- Disabled and loading states prevent `click` events.
757
+ Disabled, loading, and `formDisabled` states—including those caused by an ancestor `fieldset`—prevent `click` events.
693
758
 
694
759
  When `icon` and `loading` are both set, the button renders only the spinner, and the default-slot icon is not
695
760
  projected. The icon-mode geometry stays square by default, except for `full` or an explicit width.
@@ -718,6 +783,14 @@ Button group that manages child button layout and direction.
718
783
 
719
784
  Applies the direction to the grouped button layout without changing child button attributes.
720
785
 
786
+ **CSS Custom Properties:**
787
+
788
+ | Property | Default | Description |
789
+ | ----------------------------------- | ------- | ------------------------------------------------------------------------------- |
790
+ | `--wui-button-group-divider-length` | `20px` | Length of the divider between adjacent buttons; its cross axis always stays 1px |
791
+
792
+ Set it on the group host. The token controls the long edge in both directions, so a horizontal group shortens the divider's height and a vertical group shortens its width.
793
+
721
794
  ---
722
795
 
723
796
  ### Overlay / Modal
@@ -742,7 +815,7 @@ Modal dialog using native `<dialog>` with `showModal()`.
742
815
 
743
816
  Uses native `<dialog>` with `@cancel` prevention. Escape calls `close()` unless `no-escape-close` is present. Click on backdrop closes dialog unless `no-backdrop-close` is present. With `controlled`, both only emit the close request instead.
744
817
 
745
- > **Escape ownership:** Escape is arbitrated by a single shared owner, so one keypress closes only the **innermost** open overlay (popover, select, autocomplete, dropdown, context-menu, drawer and dialog all take part). With a select open inside a drawer, the first Escape closes the select and only the second closes the drawer. Sibling overlays that do not nest fall back to open order, closing the most recently opened one. `image-preview` takes part as well: its native `<dialog>` is registered with the same arbiter, so Escape is decided by layer order; the component's `cancel` handler only vetoes the native instant close, keeping the top layer until the exit transition finishes.
818
+ > **Escape ownership:** Escape is arbitrated by a single shared owner, so one keypress closes only the **innermost** open overlay (popover, select, autocomplete, dropdown, context-menu, drawer and dialog all take part). With a select open inside a drawer, the first Escape closes the select and only the second closes the drawer. Sibling overlays that do not nest fall back to open order, closing the most recently opened one. A panel that is still playing its exit transition stays on screen and still catches the Escape, but any overlay that is still open outranks it, so one keypress still closes exactly one layer. `image-preview` takes part as well: its native `<dialog>` is registered with the same arbiter, so Escape is decided by layer order; the component's `cancel` handler only vetoes the native instant close, keeping the top layer until the exit transition finishes.
746
819
 
747
820
  **CSS Custom Properties:**
748
821
 
@@ -751,7 +824,7 @@ Uses native `<dialog>` with `@cancel` prevention. Escape calls `close()` unless
751
824
  | `--wui-dialog-width` | `360px` | Dialog width |
752
825
  | `--wui-dialog-max-height` | `90vh` | Dialog max height |
753
826
  | `--wui-dialog-overlay-bg` | `var(--wui-color-backdrop)` | Backdrop background |
754
- | `--wui-dialog-bg` | `var(--wui-color-surface-overlay)` | Glass card background, falls back to `rgb(246 246 246 / 0.88)` |
827
+ | `--wui-dialog-bg` | `var(--wui-color-surface-overlay)` | Glass card background, falls back to `rgb(246 246 246 / 0.82)` |
755
828
  | `--wui-dialog-padding` | `20px 24px 24px` | Dialog surface padding |
756
829
  | `--wui-dialog-title-gap` | `16px` | Spacing below the title |
757
830
  | `--wui-dialog-desc-gap` | `24px` | Spacing below the body content |
@@ -778,7 +851,7 @@ Side drawer using native `<dialog>` with closing animation. In non-headless mode
778
851
 
779
852
  **Events:** `open-change` (`CustomEvent<{ open: boolean }>`). With `controlled`, Escape, backdrop, the built-in close button and drag-release only request `open=false`; the drawer remains open until the consumer writes `open=false`. Programmatic APIs (`show()`, `close()`, assigning `open`) stay direct and never emit. If native dialog closure occurs while a request is pending, the drawer restores its open top-layer state and emits the same request. With nested drawers, an inner `open-change` bubbles through the outer root (composed event); distinguish by `event.target`.
780
853
 
781
- **Slots:** `header`, `default`, `footer` — with `headless`, only the `default` slot is rendered.
854
+ **Slots:** `header`, `default`, `footer`. With `headless`, only the `default` slot is rendered.
782
855
 
783
856
  **Methods:** `show()`, `close()`
784
857
 
@@ -791,15 +864,15 @@ When `closable` is set, the built-in close button is positioned at the header's
791
864
  **Drag to close:** With `draggable`, a gray capsule drag bar (4×56px by default, visually centered 10px from the inner edge inside a 20px-thick hit zone) appears on the drawer's inner edge (left edge for `right`, right edge for `left`, bottom edge for `top`, top edge for `bottom`) while open:
792
865
 
793
866
  - Dragging follows the pointer in real time; the backdrop fades proportionally.
794
- - Backdrop click now only honors a genuine tap chain: the press must start on the backdrop itself and the press-to-release travel must stay within the tap magnitude. The click the browser generates for a press–drag–release lands on the common ancestor (`dialog`) regardless of where the press started — pressing on the panel content or on the backdrop itself and releasing over the backdrop used to close the drawer through that click. The component records the pointerdown origin and validates it on click, so such drag releases always rebound. A `detail`-0 click (keyboard / programmatic) never consumes the pointer record.
867
+ - Backdrop click now only honors a genuine tap chain: the press must start on the backdrop itself and the press-to-release travel must stay within the tap magnitude. The click the browser generates for a press–drag–release lands on the common ancestor (`dialog`) regardless of where the press started. Pressing on the panel content or on the backdrop itself and releasing over the backdrop used to close the drawer through that click. The component records the pointerdown origin and validates it on click, so such drag releases always rebound. A `detail`-0 click (keyboard / programmatic) never consumes the pointer record.
795
868
  - Releasing with a net displacement past half the drawer size (floor of 10px), or with a fast closing flick, closes the drawer; otherwise it animates back open. The close direction is placement-aware, and each part of the decision matches Base UI's `useSwipeDismiss`:
796
869
  - Both the displacement origin and the decision clock reset to the first `pointermove`, absorbing the gap between the press and that move; its whole travel is discarded, so a gesture needs at least two moves to accumulate displacement.
797
- - The flick is judged by the **whole gesture's** average velocity — net displacement ÷ gesture duration, divisor floored at 50ms — reaching 500px/s, not by the instantaneous velocity at release. A sliding window only describes the last short stretch of the trace, so under a whole-gesture average sweeping back towards the edge can no longer read as a flick. A zero-length duration yields zero velocity instead of a floored divisor, so an unmeasurable gesture is never read as a flick.
870
+ - The flick is judged by the **whole gesture's** average velocity. Net displacement ÷ gesture duration, divisor floored at 50ms. The flick commits when the average reaches 500px/s, not by the instantaneous velocity at release. A sliding window only describes the last short stretch of the trace, so under a whole-gesture average sweeping back towards the edge can no longer read as a flick. A zero-length duration yields zero velocity instead of a floored divisor, so an unmeasurable gesture is never read as a flick.
798
871
  - Any release whose net displacement isn't in the close direction animates back open.
799
872
  - The distance test also uses the net displacement, so grabbing the drawer mid-rebound and releasing without dragging further never counts as "already past half way".
800
873
  - A withdrawal of 10px or more from the point where the close direction was confirmed cancels the flick, so dragging out into the overscroll and sweeping back never closes. Displacement that has already crossed the distance threshold clears the mark again, so a gesture that is past half way is unaffected by a small retreat. Upstream has the same guard (`cancelledSwipe`), but never reaches it on the drawer path because `DrawerViewport.onRelease` always returns a decision.
801
874
  - Dragging in the opening direction is damped by a square root (`sign(d) · |d| ** 0.5`), matching `applyDirectionalDamping`. The damping applies to the gesture's increment, added on top of the offset the drag started from, and self-limits rather than being capped at a fixed distance.
802
- - The release velocity only shapes the settle transition's duration (180–420ms); the settle itself is a single CSS transition on `transform`, handed back by JS at release — no WAAPI animation is created.
875
+ - The release velocity only shapes the settle transition's duration (180–420ms); the settle itself is a single CSS transition on `transform`, handed back by JS at release. No WAAPI animation is created.
803
876
  - With `controlled`, release past the threshold only emits `open-change(false)`; the drawer holds at the closed position briefly (120ms write-back window) and animates back open if the consumer rejects or misses the write-back.
804
877
  - Drag-to-open is not supported because the closed drawer renders nothing outside the native dialog.
805
878
  - Under `prefers-reduced-motion`, release settles instantly, with no transition.
@@ -885,7 +958,7 @@ Every presentation option defaults to off: with no options passed only the image
885
958
 
886
959
  Pinch-to-zoom is always on and has no option: the stage already owns pointer interaction, so there is nothing for a gesture to conflict with. The first finger still drives pan/swipe as usual and the second finger starts the pinch, which aborts any in-flight pan or swipe. A pinch does not close the preview, and the compatibility `click` that mixed input may synthesize afterwards is swallowed rather than treated as a backdrop click.
887
960
 
888
- Clicking anywhere outside the image, or pressing Escape, closes the preview. The native dialog always exposes the accessible name `图片预览`. With `swipe` on and more than one image, the images live on a single carousel track: the current image and its neighbors sit side by side in slides of equal size, the stage clips the overflow, and the whole track translates with the finger. Releasing past the distance threshold (15% of the stage width, rounded) slides the next image in, as does a fast flick whose sampled velocity is at least 320px/s even below that distance; a flick covering at least 16px whose velocity (windowed sample or whole-gesture average) reaches 160px/s also commits; otherwise it bounces back. With `loop` on the wrap-around neighbor is always present beside the current image, and at the non-looping bounds the strip bounces back instead of crossing the boundary. While it applies, the swipe gesture takes over the drag entirely: at 1x only horizontal movement changes images, and vertical movement is ignored (no pan, no vertical follow). Once zoomed in, dragging always pans on both axes and never changes images. Because panning also works at 1x, `resetZoom()` is offered whenever the image is zoomed _or_ displaced — the reset button in the toolbar is enabled for a panned 1x image too.
961
+ Clicking anywhere outside the image, or pressing Escape, closes the preview. The native dialog always exposes the accessible name `图片预览`. With `swipe` on and more than one image, the images live on a single carousel track: the current image and its neighbors sit side by side in slides of equal size, the stage clips the overflow, and the whole track translates with the finger. Releasing past the distance threshold (15% of the stage width, rounded) slides the next image in, as does a fast flick whose sampled velocity is at least 320px/s even below that distance; a flick covering at least 16px whose velocity (windowed sample or whole-gesture average) reaches 160px/s also commits; otherwise it bounces back. With `loop` on the wrap-around neighbor is always present beside the current image, and at the non-looping bounds the strip bounces back instead of crossing the boundary. While it applies, the swipe gesture takes over the drag entirely: at 1x only horizontal movement changes images, and vertical movement is ignored (no pan, no vertical follow). Once zoomed in, dragging always pans on both axes and never changes images. Because panning also works at 1x, `resetZoom()` is offered whenever the image is zoomed _or_ displaced. The reset button in the toolbar is enabled for a panned 1x image too.
889
962
 
890
963
  A close request does not destroy the native dialog immediately: it stays in the top layer until the exit transition completes, then `dialog.close()` runs, the host is removed from the DOM, and `closed` resolves. Page scroll is locked while open unless `noScrollLock` is `true`.
891
964
 
@@ -929,33 +1002,33 @@ In-flow expand/collapse container with animated height (or width) transition. Si
929
1002
 
930
1003
  The initial `open` attribute settles instantly without playing the animation. Nested collapses are supported.
931
1004
 
932
- **Trigger semantics:** interaction comes from the element you put in the default slot — a native `<button>`, `<web-ui-button>`, or any other interactive element supplies Enter/Space activation and focus natively. The collapse writes `aria-expanded` / `aria-controls` (pointing at its content track) and `aria-disabled` onto the first assigned element in the trigger slot. Use an interactive element as the trigger: a plain `<span>` is clickable but has no keyboard/focus semantics.
1005
+ **Trigger semantics:** interaction comes from the element you put in the default slot. A native `<button>`, `<web-ui-button>`, or any other interactive element supplies Enter/Space activation and focus natively. The collapse writes `aria-expanded` / `aria-controls` (pointing at its content track) and `aria-disabled` onto the first assigned element in the trigger slot. Use an interactive element as the trigger: a plain `<span>` is clickable but has no keyboard/focus semantics.
933
1006
 
934
1007
  **Closed-state semantics:** the consumer's light DOM is never moved or unmounted. Default closed state applies `hidden` to the internal content container; with `keep-mounted` the inner container is marked `inert` while staying measurable inside the collapsed track.
935
1008
 
936
- **`peek` (partial reveal):** setting `peek` makes the closed state reveal the first `peek` of the content along the animation axis (`horizontal` switches it to width) instead of collapsing to zero — the track stays at the content height and the inner container is clamped. It implies `keep-mounted` semantics: the content stays mounted but is `inert`, so the clipped portion is not focusable or clickable. Content shorter than `peek` collapses to its own size rather than leaving blank space. Because a fixed length and an auto height cannot be interpolated by CSS, the two `peek` ↔ expanded directions are driven by explicit pixel lengths (measured once per toggle); every other animation path stays a zero-measurement grid `fr` transition.
1009
+ **`peek` (partial reveal):** setting `peek` makes the closed state reveal the first `peek` of the content along the animation axis (`horizontal` switches it to width) instead of collapsing to zero. The track stays at the content height and the inner container is clamped. It implies `keep-mounted` semantics: the content stays mounted but is `inert`, so the clipped portion is not focusable or clickable. Content shorter than `peek` collapses to its own size rather than leaving blank space. Because a fixed length and an auto height cannot be interpolated by CSS, the two `peek` ↔ expanded directions are driven by explicit pixel lengths (measured once per toggle); every other animation path stays a zero-measurement grid `fr` transition.
937
1010
 
938
1011
  ```html
939
1012
  <web-ui-collapse peek="120px">
940
1013
  <button type="button">Toggle me</button>
941
- <div slot="content">Long content — only the first 120px show while closed</div>
1014
+ <div slot="content">Long content. Only the first 120px show while closed</div>
942
1015
  </web-ui-collapse>
943
1016
  ```
944
1017
 
945
- **Edge fade:** the revealed area ends in an alpha-gradient fade (right edge when `horizontal`) so the clipped portion melts into the background instead of ending in a hard line. The length is derived from `peek` — there is no second attribute to set. Tune it with CSS custom properties instead:
1018
+ **Edge fade:** the revealed area ends in an alpha-gradient fade (right edge when `horizontal`) so the clipped portion melts into the background instead of ending in a hard line. The length is derived from `peek`. There is no second attribute to set. Tune it with CSS custom properties instead:
946
1019
 
947
1020
  | Custom property | Default | Description |
948
1021
  | -------------------------------- | ------------- | --------------------------------------------------------------------- |
949
1022
  | `--wui-collapse-peek-edge-ratio` | `0.4` | Fade length as a fraction of `peek` |
950
1023
  | `--wui-collapse-peek-edge-max` | `112px` | Upper bound, so a large `peek` cannot produce an oversized fade band |
951
1024
  | `--wui-collapse-peek-edge` | — | Explicit fade length; wins over the derived value |
952
- | `--wui-collapse-peek-edge-color` | `transparent` | Fade-stop color (must carry alpha — `mask-image` reads alpha channel) |
1025
+ | `--wui-collapse-peek-edge-color` | `transparent` | Fade-stop color (must carry alpha - `mask-image` reads alpha channel) |
953
1026
 
954
- Set the ratio to `0` to turn the fade off. The derivation is pure CSS `calc(peek * ratio)`, so a `rem`-based `peek` scales the fade with the root font size and nothing has to be measured in JS. Known limitation: a percentage `peek` does not derive to a length, so the fade silently falls back to "none" — set `--wui-collapse-peek-edge` explicitly in that case.
1027
+ Set the ratio to `0` to turn the fade off. The derivation is pure CSS `calc(peek * ratio)`, so a `rem`-based `peek` scales the fade with the root font size and nothing has to be measured in JS. Known limitation: a percentage `peek` does not derive to a length, so the fade silently falls back to "none". Set `--wui-collapse-peek-edge` explicitly in that case.
955
1028
 
956
- The fade is a four-stop ease-out gradient rather than a linear one: the body stays fully opaque to the start of the fade band, drops to 60% alpha within the first 30% of the band, then 26%, then reaches the edge color. A linear gradient hugs 1.0 alpha across the first 40% of the band, so the eye perceives a much shorter fade than declared — front-loading the drop makes the band visibly soft from its very start, nearly doubling the perceived length at the same size.
1029
+ The fade is a four-stop ease-out gradient rather than a linear one: the body stays fully opaque to the start of the fade band, drops to 60% alpha within the first 30% of the band, then 26%, then reaches the edge color. A linear gradient hugs 1.0 alpha across the first 40% of the band, so the eye perceives a much shorter fade than declared. Front-loading the drop makes the band visibly soft from its very start, nearly doubling the perceived length at the same size.
957
1030
 
958
- The fade band is positioned in percentages, relative to the container's **current rendered height** — which is what `max-height` animates. So the band tracks the cut edge frame by frame during the collapse animation without the `mask-image` string ever needing to interpolate. Its length is driven by a registered `<length>` custom property so it can fade in and out with the toggle instead of popping in once the close animation settles.
1031
+ The fade band is positioned in percentages, relative to the container's **current rendered height**, which is what `max-height` animates. So the band tracks the cut edge frame by frame during the collapse animation without the `mask-image` string ever needing to interpolate. Its length is driven by a registered `<length>` custom property so it can fade in and out with the toggle instead of popping in once the close animation settles.
959
1032
 
960
1033
  ```html
961
1034
  <!-- 调强渐隐:比例 0.5 → 120px peek 配 60px 渐隐 -->
@@ -1164,6 +1237,8 @@ Icon renderer using Iconify data objects.
1164
1237
 
1165
1238
  Has `aria-hidden="true"`.
1166
1239
 
1240
+ **Canvas:** the SVG `viewBox` is read from `icon.left`/`icon.top`/`icon.width`/`icon.height`, defaulting to `0` for the offsets and to Iconify's spec default `16` for the sizes. Generated assets always carry their canvas, so the size fallback only matters for raw `IconifyIcon` objects passed in directly.
1241
+
1167
1242
  ```js
1168
1243
  import { lucideLoaderCircle } from '@greypan/web-ui/icons'
1169
1244
  html`<web-ui-icon .icon=${lucideLoaderCircle} spin />`
@@ -1277,6 +1352,8 @@ At `640px` and below, the sidebar becomes a `web-ui-drawer` with its built-in gl
1277
1352
 
1278
1353
  `header-glow` adds a pointer-transparent decorative glow behind header-slot content and the mobile toggle. It is a Header background rather than a foreground layer, so slotted content remains above it. Override its color with `--wui-layout-header-glow-color` (default: `--wui-color-page`). The glow concentration and spread are controlled by the internal variable `--wui-layout-header-glow-height` (default: `150%`); increase for stronger coverage, decrease for a subtler effect. Layout layers are ordered as Header (`10`) < Auxiliary (`20`) < Banner (`30`) < Tabbar (`40`) < Sidebar (`50`).
1279
1354
 
1355
+ The sidebar card surface is `--wui-color-surface-sidebar`. In light mode it keeps its translucent neutral layer; in dark mode it sits one notch above `--wui-color-page`, level with `--wui-color-surface`. It is a separate token because dialog, drawer and toast share `--wui-color-surface-overlay` and may use a different surface.
1356
+
1280
1357
  **CSS Custom Properties:**
1281
1358
 
1282
1359
  | Property | Default | Description |
@@ -1316,7 +1393,7 @@ Role: `button`, keyboard Enter scrolls to top.
1316
1393
 
1317
1394
  #### `<web-ui-svg-draw-lines>`
1318
1395
 
1319
- SVG line drawing animation using `stroke-dashoffset`. Animates geometry in-place — no cloning, no DOM manipulation. Two playback directions: the reveal draws geometry from nothing to fully drawn, `replay({ reverse: true })` retracts it along the same path back to nothing.
1396
+ SVG line drawing animation using `stroke-dashoffset`. Animates geometry in-place. No cloning, no DOM manipulation. Two playback directions: the reveal draws geometry from nothing to fully drawn, `replay({ reverse: true })` retracts it along the same path back to nothing.
1320
1397
 
1321
1398
  | Attribute | Type | Default | Description |
1322
1399
  | ------------- | --------- | ---------- | ------------------------------------------------------ |
@@ -1326,13 +1403,13 @@ SVG line drawing animation using `stroke-dashoffset`. Animates geometry in-place
1326
1403
 
1327
1404
  All attributes are reflected.
1328
1405
 
1329
- **Auto-play:** the first time slotted content settles, one reveal plays by itself. Set `no-autoplay` to leave the geometry as authored and decide when to play — that is how `<web-ui-checkbox>` avoids drawing a checkmark that was already checked on mount.
1406
+ **Auto-play:** the first time slotted content settles, one reveal plays by itself. Set `no-autoplay` to leave the geometry as authored and decide when to play. That is how `<web-ui-checkbox>` avoids drawing a checkmark that was already checked on mount.
1330
1407
 
1331
- **Methods:** `replay(options?: { reverse?: boolean }): Promise<void>` — cancels running animation, re-collects geometry elements from current DOM, and starts a new animation. All targets animate in parallel with the same duration/easing. Resolves when all complete. A reverse run ends on blank and leaves that end state in the DOM: the animation itself is cancelled, while the dash values that hide the stroke stay as inline styles until the next `replay()` replaces or undoes them. A reveal run restores the authored inline styles instead. A retract always starts from the full stroke, so interrupting a reveal draws the whole path back rather than only the part that was visible. When the nearest theme scope uses `motion="reduced"`, or its `motion="system"` mode matches `prefers-reduced-motion: reduce`, no animation starts — but a previous retract's blank is still undone, so the geometry returns to as authored and visibility is left entirely to the consumer's own styles.
1408
+ **Methods:** `replay(options?: { reverse?: boolean }): Promise<void>`. Cancels running animation, re-collects geometry elements from current DOM, and starts a new animation. All targets animate in parallel with the same duration/easing. Resolves when all complete. A reverse run ends on blank and leaves that end state in the DOM: the animation itself is cancelled, while the dash values that hide the stroke stay as inline styles until the next `replay()` replaces or undoes them. A reveal run restores the authored inline styles instead. A retract always starts from the full stroke, so interrupting a reveal draws the whole path back rather than only the part that was visible. When the nearest theme scope uses `motion="reduced"`, or its `motion="system"` mode matches `prefers-reduced-motion: reduce`, no animation starts. But a previous retract's blank is still undone, so the geometry returns to as authored and visibility is left entirely to the consumer's own styles.
1332
1409
 
1333
- **Reverse end state:** the blank a retract leaves behind is inline style on the geometry it animated, including nodes inside nested open shadow roots such as `<web-ui-icon>`. Copying those nodes (`cloneNode`, an `innerHTML` round-trip) carries the blank along, and detaching and re-appending one keeps the stroke hidden — a finished retract is a DOM state rather than a live animation, so nothing is left to restore it. Call `replay()` to draw them again. Because the component reaches into nested open shadow roots, two `<web-ui-svg-draw-lines>` must not cover the same geometry: each records the inline dash values the first time it animates an element, so a blank left by the inner one becomes the outer one's idea of the consumer's own value.
1410
+ **Reverse end state:** the blank a retract leaves behind is inline style on the geometry it animated, including nodes inside nested open shadow roots such as `<web-ui-icon>`. Copying those nodes (`cloneNode`, an `innerHTML` round-trip) carries the blank along, and detaching and re-appending one keeps the stroke hidden. A finished retract is a DOM state rather than a live animation, so nothing is left to restore it. Call `replay()` to draw them again. Because the component reaches into nested open shadow roots, two `<web-ui-svg-draw-lines>` must not cover the same geometry: each records the inline dash values the first time it animates an element, so a blank left by the inner one becomes the outer one's idea of the consumer's own value.
1334
1411
 
1335
- **Slots:** `default` — SVG content to animate. Accepts inline `<svg>` elements (light DOM) as well as components that render an SVG in an open shadow root, such as `<web-ui-icon>`. Closed shadow roots are skipped.
1412
+ **Slots:** `default`. SVG content to animate. Accepts inline `<svg>` elements (light DOM) as well as components that render an SVG in an open shadow root, such as `<web-ui-icon>`. Closed shadow roots are skipped.
1336
1413
 
1337
1414
  Finds `path`, `rect`, `circle`, `line`, `polyline`, `polygon`, `ellipse` elements by recursively traversing the light DOM and all open shadow roots. Paths ending with `Z`/`z` receive a temporary gap fix for proper closing-segment rendering. After a reveal completes or is cancelled, all in-line styles are restored.
1338
1415
 
@@ -1340,22 +1417,30 @@ Finds `path`, `rect`, `circle`, `line`, `polyline`, `polygon`, `ellipse` element
1340
1417
 
1341
1418
  Theme provider defining CSS custom property tokens.
1342
1419
 
1343
- | Attribute | Type | Default | Description |
1344
- | ------------ | --------------------------------- | ---------- | --------------------------------------------- |
1345
- | `appearance` | `'light' \| 'dark' \| 'system'` | `'light'` | Color scheme |
1346
- | `motion` | `'full' \| 'reduced' \| 'system'` | `'system'` | Motion preference for this nested theme scope |
1347
- | `transition` | `boolean` | `false` | Circular reveal on appearance changes |
1420
+ | Attribute | Type | Default | Description |
1421
+ | --------------------- | --------------------------------- | ---------- | ------------------------------------------------------ |
1422
+ | `appearance` | `'light' \| 'dark' \| 'system'` | `'light'` | Color scheme |
1423
+ | `resolved-appearance` | `'light' \| 'dark'` | `'light'` | Resolved color scheme; read-only reflected host output |
1424
+ | `motion` | `'full' \| 'reduced' \| 'system'` | `'system'` | Motion preference for this nested theme scope |
1348
1425
 
1349
- **Methods:** `getOverlayRoot()` — returns this theme-owned overlay root
1426
+ **Methods:** `getOverlayRoot()`. Returns this theme-owned overlay root
1350
1427
 
1351
1428
  **Portal mounting contract:** Every active `<web-ui-theme>` is also the default scoped theme-owned overlay root. Portal-based components without an explicit `overlayContainer` resolve to the nearest active theme's `getOverlayRoot()`; target-less portal calls prefer the root theme's theme-owned overlay root. When no active theme provides a root, they fall back to the global fallback overlay root.
1352
1429
 
1353
1430
  Defines foundation, color, layer, shadow, and motion tokens for its subtree. `motion="system"` follows `prefers-reduced-motion`; use `motion="reduced"` to reduce animation in a scope or `motion="full"` in a nested theme to restore normal token values. System appearance follows `prefers-color-scheme`.
1354
1431
 
1355
- Set the `transition` boolean attribute to animate appearance changes with the View Transitions API. Removing the attribute disables the reveal; framework bindings must write the boolean property when toggling it dynamically. A root theme reveals the whole page; a nested theme reveals only its own capture box. The origin is the last pointer-down position when available, otherwise the theme box or viewport center. Dark-to-light and light-to-dark directions are reversed. Unsupported browsers, reduced-motion scopes, zero durations, and another request already in the same flight fall back to applying the new appearance immediately; `appearance="system"` does not animate OS light/dark changes in this version.
1432
+ `resolved-appearance` is a read-only derived host attribute (also exposed as the `resolvedAppearance` property): it is always `light` or `dark`, mirrors an explicit `appearance` directly, resolves `appearance="system"` from `prefers-color-scheme`, and updates live when the OS flips. A theme without `appearance` remains inactive but reports the default `light`. Because the component owns the attribute and restores external edits, consumers can bind selectors or Tailwind custom variants to `resolved-appearance` without carrying a second theme state.
1433
+
1434
+ Appearance changes are animated with the View Transitions API according to this scope's `motion`: `full` always reveals, `reduced` applies the new appearance immediately with no reveal, and `system` follows `prefers-reduced-motion`. A root theme reveals the whole page; a nested theme reveals only its own capture box. The origin is the last pointer-down position when available, otherwise the theme box or viewport center. The reveal direction follows the resolved appearance, including `appearance="system"` resolving from `prefers-color-scheme`; dark-to-light and light-to-dark directions are reversed. Unsupported browsers, reduced-motion scopes, zero durations, and another request already in the same flight fall back to applying the new appearance immediately; `appearance="system"` does not animate OS light/dark changes in this version.
1435
+
1436
+ During an active reveal, the View Transitions rendering-suppression rules force pointer hit-testing onto the document element, so CSS `pointer-events` overrides cannot keep the underlying page interactive. The theme calls `skipTransition()` only on `pointerdown` or wheel; `pointermove` and `pointerup` never end the reveal, so follow-up movement from the initiating control or ordinary pointer movement cannot interrupt it. The event that triggers the skip is still spec-defined to target the document element, but the disabled hit-test window ends there instead of running for the full reveal duration.
1437
+
1438
+ **Migration from the removed `transition` attribute:** drop it and express the intent with `motion` instead. A theme that carried `transition` keeps the same reveal under the default `motion="system"`; add `motion="full"` to reveal even when the system prefers reduced motion. A theme that did not carry it now reveals by default, so pass `motion="reduced"` to keep appearance changes instant.
1356
1439
 
1357
1440
  The host uses `display: contents` and does not paint any background: the library never draws on the host page, so the embedding application keeps full control of the surface behind the themed subtree. Custom properties still inherit to slotted content reliably.
1358
1441
 
1442
+ **Root page color sync:** the outermost active theme also mirrors its computed `--wui-color-page` onto `document.documentElement` as an inline custom property. That closes the one place the token cannot reach on its own: custom properties stop inheriting at the document element, so a consumer rule such as `body { background: var(--wui-color-page) }` only colors the scrollable area, while the canvas behind an overscroll bounce (macOS rubber-banding) stays at the user-agent background. With the mirrored property the bounce area follows the theme too. The write happens when a theme connects and whenever its appearance changes, including `appearance="system"` following an OS light/dark flip; the value written is the theme's computed value, so an override of `--wui-color-page` on the host is mirrored as it stands. **Not live:** the component does not observe the host's own style changes, so a runtime change to that property on the host (an inline style edit, a class toggle, a host-level media query) reaches the root only at the next write trigger, which is a connect, an `appearance` change to a different value, or an OS light/dark flip; re-assigning `appearance` to the value it already has is not a trigger, because the update is skipped when nothing changed. For a page color that has to change independently of the theme's appearance, own the variable yourself on `:root` or `body` with `!important`, which outranks this inline write, or drive the color through `appearance`. Only the outermost theme writes: nested themes keep their own scope and never touch the root value. Ownership follows connect order and passes to the next connected theme when the outermost one disconnects; when the last theme disconnects the value is deliberately kept rather than removed, so a page that swaps themes does not flash back to the user-agent background in between. The library writes exactly this one custom property on the document element and nothing else there.
1443
+
1359
1444
  **Foundation tokens:**
1360
1445
 
1361
1446
  | Property | Default | Description |
@@ -1409,13 +1494,14 @@ The host uses `display: contents` and does not paint any background: the library
1409
1494
  | `--wui-color-surface-raised` | `#f2f2f7` | `#2c2c2e` | Raised surface |
1410
1495
  | `--wui-color-surface-control` | `#dfdfdf` | `#3a3a3c` | Neutral control surface |
1411
1496
  | `--wui-color-surface-track` | `#e5e5ea` | `#444446` | Slider and switch track surface |
1412
- | `--wui-color-surface-menu` | `rgb(246 246 246 / 0.82)` | `rgb(30 30 32 / 0.92)` | Menu and floating panel surface |
1497
+ | `--wui-color-surface-menu` | `rgb(254 254 254 / 0.76)` | `rgb(49 48 50 / 0.74)` | Menu and floating panel surface |
1413
1498
  | `--wui-color-surface-glass` | `rgb(250 250 250 / 0.34)` | `rgb(44 44 46 / 0.42)` | Liquid glass surface |
1414
1499
  | `--wui-color-surface-glass-hover` | `color-mix(... text 6%, surface-glass)` | `color-mix(... text 6%, surface-glass)` | Full glass hover background |
1415
1500
  | `--wui-color-surface-glass-active` | `color-mix(... text 15%, surface-glass)` | `color-mix(... text 15%, surface-glass)` | Full glass pressed background |
1416
1501
  | `--wui-color-surface-overlay` | `rgb(246 246 246 / 0.82)` | `rgb(32 34 34 / 0.9)` | Translucent overlay surface |
1417
- | `--wui-color-surface-segmented` | `#e5e5ea` | `#3a3a3c` | Segmented track surface |
1418
- | `--wui-color-surface-selected` | `#fff` | `#5c5c5e` | Segmented selected indicator |
1502
+ | `--wui-color-surface-sidebar` | `rgb(233 233 233 / 0.82)` | `rgb(44 44 46 / 0.8)` | Sidebar panel surface |
1503
+ | `--wui-color-surface-segmented` | `#e5e5ea` | `#3a3a3c` | Segmented indicator surface |
1504
+ | `--wui-color-surface-selected` | `#fff` | `#5c5c5e` | Selected surface |
1419
1505
  | `--wui-color-text` | `#1b1b1b` | `#e9eaea` | Primary text |
1420
1506
  | `--wui-color-text-secondary` | `#6a6a6a` | `#a1a1a6` | Secondary text |
1421
1507
  | `--wui-color-text-tertiary` | `color-mix(in srgb, var(--wui-color-text) 35%, transparent)` | `color-mix(in srgb, var(--wui-color-text) 42%, transparent)` | Tertiary text and quiet icons |
@@ -1503,7 +1589,7 @@ Every call returns the final id of that toast, so callers never branch on create
1503
1589
  | `target` | `Element` | — | Used to find nearest theme-owned overlay root |
1504
1590
  | `container` | `HTMLElement` | — | Explicit mount container (highest priority) |
1505
1591
 
1506
- **Upsert semantics** — what a second call with the same `id` does:
1592
+ **Upsert semantics**. What a second call with the same `id` does:
1507
1593
 
1508
1594
  | Target state | Result |
1509
1595
  | ----------------------------- | ------------------------------------------------------------------------------- |
@@ -1511,22 +1597,22 @@ Every call returns the final id of that toast, so callers never branch on create
1511
1597
  | Still queued in the same tick | The queued options are patched and applied on mount; no second toast is created |
1512
1598
  | Closed, or still exiting | A new toast is created; the one leaving finishes its exit animation on its own |
1513
1599
 
1514
- `duration` restarts the countdown only when it is passed explicitly (the one exception is a hover pause: the new value is recorded without starting the timer, and the countdown runs with it once the pointer leaves); `message`, `heading` and `type` are plain properties and leave the timer alone. The `error` shortcut's 5000 ms default is applied when the toast is created, so it does not count as an explicit `duration` — repeated `toast.error(msg, { id })` calls do not reset the countdown. Changing `position` moves the element to the new container and keeps the remaining time (via `moveBefore` where available, with a pause/resume fallback elsewhere); if the countdown already elapsed while the main thread was blocked, the fallback closes the toast on resume instead of leaving it open. `container` and `target` are read from the first call only — moving a toast to a different overlay root is not supported.
1600
+ `duration` restarts the countdown only when it is passed explicitly (the one exception is a hover pause: the new value is recorded without starting the timer, and the countdown runs with it once the pointer leaves); `message`, `heading` and `type` are plain properties and leave the timer alone. The `error` shortcut's 5000 ms default is applied when the toast is created, so it does not count as an explicit `duration`. Repeated `toast.error(msg, { id })` calls do not reset the countdown. Changing `position` moves the element to the new container and keeps the remaining time (via `moveBefore` where available, with a pause/resume fallback elsewhere); if the countdown already elapsed while the main thread was blocked, the fallback closes the toast on resume instead of leaving it open. `container` and `target` are read from the first call only. Moving a toast to a different overlay root is not supported.
1515
1601
 
1516
- **Close semantics** — `toast.close(id)` covers every state a toast can be in, including the two before it becomes visible:
1602
+ **Close semantics.** `toast.close(id)` covers every state a toast can be in, including the two before it becomes visible:
1517
1603
 
1518
1604
  | Target state | Result |
1519
1605
  | ---------------------------------- | --------------------------------------------------------------- |
1520
1606
  | Visible | Plays the exit animation, then emits `toast-close` |
1521
1607
  | Mounted, `show()` has not run yet | No exit animation to play: emits `toast-close` immediately |
1522
- | Still queued in the same microtask | Dropped before it mounts — nothing appears, no event is emitted |
1608
+ | Still queued in the same microtask | Dropped before it mounts - nothing appears, no event is emitted |
1523
1609
  | Already exiting | No-op; that toast finishes its own exit |
1524
1610
 
1525
1611
  `toast.clear()` has the same scope: it drops queued entries and closes everything mounted.
1526
1612
 
1527
1613
  **Events:** `toast-close` (`CustomEvent<{ id: string; reason: 'auto' | 'manual' | 'programmatic' | 'clear' }>`)
1528
1614
 
1529
- Hover pauses the auto-close timer (uses `pointerenter`/`pointerleave`); leaving resumes the **remaining** time instead of restarting the full duration. If the `pointerleave` is missed while paused — the pointer is dragged out of the window, or the element is moved or removed while hovered — a document-level `pointerover`/`pointerout`/`pointerleave` fallback resumes the countdown, so a hovered toast cannot stay on screen forever. Batch-mounts toasts created in the same microtask.
1615
+ Hover pauses the auto-close timer (uses `pointerenter`/`pointerleave`); leaving resumes the **remaining** time instead of restarting the full duration. If the `pointerleave` is missed while paused (the pointer is dragged out of the window, or the element is moved or removed while hovered), a document-level `pointerover`/`pointerout`/`pointerleave` fallback resumes the countdown, so a hovered toast cannot stay on screen forever. Batch-mounts toasts created in the same microtask.
1530
1616
 
1531
1617
  **CSS Custom Properties:**
1532
1618