@lumiose/ui 0.0.0-stage → 0.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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,160 @@
1
+ # OTP Field
2
+
3
+ > A one-time code input, after Base UI's OTPField: one native `<input>` shown as one slot per character. SMS autofill (`autocomplete="one-time-code"`), paste, Backspace, the arrow keys, `maxlength`, `pattern`, `required` and the form's value are the platform's, without JavaScript too, and assistive technology meets one labelled text field. `<lu-otp-field>`'s 1.5 kB script lays the input over the slots, keeps the code to the allowed characters, and reports completion. `bind:value` keeps a signal in sync.
4
+
5
+ ```ts
6
+ import { OTPField } from "@lumiose/ui"; // or "@lumiose/ui/otp-field"
7
+ import "@lumiose/ui/otp-field.css"; // or "@lumiose/ui/styles.css" for every component
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <OTPField.Root length={6}>
15
+ <OTPField.Input />
16
+ <OTPField.Slot />
17
+ <OTPField.Slot />
18
+ <OTPField.Separator />
19
+ <OTPField.Slot />
20
+ </OTPField.Root>
21
+ ```
22
+
23
+ | Part | Renders |
24
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
25
+ | `Root` | `<lu-otp-field length validation-type>`, with `data-complete`, `data-filled` and `data-focused` from its script |
26
+ | `Input` | `<input type="text" autocomplete="one-time-code" inputmode maxlength minlength pattern name>` (`type="password"` with `mask`) |
27
+ | `Slot` | `<div aria-hidden="true" data-slot>`: the next character (a dot with `mask`), `data-filled`, `data-active` at the caret |
28
+ | `Separator` | `<div aria-hidden="true" data-orientation>`: a dash or a gap between slots |
29
+
30
+ Name the field with a [Field](field.md) (`Field.Label`), a `<label for>` the Root's `id`, or an
31
+ `aria-label` on the `Input`.
32
+
33
+ ## API
34
+
35
+ ### OTPField.Root
36
+
37
+ | Prop | Type | Default | |
38
+ | ---------------------------------- | -------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
39
+ | `length` | `number` | | How many characters the code has. Required. |
40
+ | `value` | `string` | | Shows this code when it changes. Pair it with `onValueChange`. |
41
+ | `defaultValue` | `string` | `""` | The code at first; a form reset goes back to it. |
42
+ | `onValueChange` | `(value: string) => void` | | Called as the code changes: typing, a paste, autofill, Backspace. |
43
+ | `bind:value` | `Signal<string>` | | Stands in for `value` and `onValueChange`. |
44
+ | `onValueComplete` | `(value: string) => void` | | Called when the last character is entered. |
45
+ | `onValueInvalid` | `(value: string) => void` | | Called with what was entered when it held characters the field does not allow. |
46
+ | `validationType` | `"numeric" \| "alpha" \| "alphanumeric" \| "none"` | `"numeric"` | The characters the code may hold, ASCII as in Base UI (other scripts' digits and full-width letters are typed as ASCII); spaces are always dropped. Any other value counts as `"numeric"`. |
47
+ | `normalizeValue` | `(value: string) => string` | | Normalizes what is entered before the allowed characters are kept (an island). |
48
+ | `inputMode` | `"numeric" \| "text" \| "tel" \| "none"` | from the type | The virtual keyboard: numeric for digits, text otherwise. |
49
+ | `mask` | `boolean` | `false` | The slots show dots, and the input is a password input, so screen readers do not speak the code. |
50
+ | `autoComplete` | `string` | `"one-time-code"` | The input's `autocomplete`. |
51
+ | `autoSubmit` | `boolean` | `false` | Submits the form when the last character is entered. |
52
+ | `name`, `form` | `string` | | The name the code is submitted under, and the id of its form when it is outside it. |
53
+ | `id` | `string` | generated | The input's id, for a `<label for>`. |
54
+ | `required`, `disabled`, `readOnly` | `boolean` | `false` | As on a native input. |
55
+
56
+ ### The other parts
57
+
58
+ `Input` takes `<input>` props except the code's own (`type`, `value`, `name`, `maxlength`,
59
+ `pattern`, `inputmode`, `autocomplete`, …: the Root's); `placeholder` and `aria-label` pass
60
+ through. `Slot` and `Separator` take `<div>` props. Inside a [Field](field.md), the input takes the
61
+ field's id, name, description and error.
62
+
63
+ **One input, not one per character.** Base UI renders an input per slot. Here one input holds the
64
+ code: autofill of an SMS code works natively (it fills one field), a paste or Backspace needs no
65
+ script, the browser validates the whole code (`pattern`, `minlength`) and submits it, and a screen
66
+ reader meets one field named once instead of six. The slots are a picture of that input, drawn by
67
+ the script; the input lies over them, transparent, so a press on a slot moves the caret there.
68
+
69
+ **Differences from Base UI's OTPField.** `Slot` replaces the per-character `Input`s; the
70
+ `Separator` is visual (`aria-hidden`). `normalizeValue` (`(text) => text.toUpperCase()`, say)
71
+ works in an island, where Root hands it to the element: a server page's script cannot receive a
72
+ function. No `inputRef`, no `render`.
73
+
74
+ ## Example
75
+
76
+ ```tsx check
77
+ import { OTPField } from "@lumiose/ui";
78
+ import { signal } from "lumiose";
79
+
80
+ export function VerifyCode() {
81
+ const code = signal("");
82
+ return (
83
+ <form method="post">
84
+ <label for="code">Verification code</label>
85
+ <OTPField.Root
86
+ id="code"
87
+ name="code"
88
+ length={6}
89
+ required
90
+ bind:value={code}
91
+ class="lu-otp-field"
92
+ >
93
+ <OTPField.Input />
94
+ <OTPField.Slot />
95
+ <OTPField.Slot />
96
+ <OTPField.Slot />
97
+ <OTPField.Separator />
98
+ <OTPField.Slot />
99
+ <OTPField.Slot />
100
+ <OTPField.Slot />
101
+ </OTPField.Root>
102
+ <button type="submit" class="lu-button" disabled={code().length < 6}>
103
+ Verify
104
+ </button>
105
+ </form>
106
+ );
107
+ }
108
+ ```
109
+
110
+ ## Styling
111
+
112
+ **With the default theme**, put `lu-otp-field` on `Root`: a 2.5rem box per character (they shrink
113
+ on narrow screens), the caret's box ringed while the field has focus with a blinking caret in it
114
+ (still where motion is reduced), and a short dash for a separator.
115
+
116
+ **With your own CSS**, style the slots through `[data-slot]`, `[data-filled]` and `[data-active]`,
117
+ the separator through `[data-orientation]`, and the root through `[data-complete]`,
118
+ `[data-focused]` and `:has(input:disabled)`.
119
+
120
+ `@lumiose/ui/otp-field.css` (0.2 kB brotli) lays the input over the slots once the script runs
121
+ (`:defined`), its text, caret and selection transparent, and until then shows the input as a plain
122
+ field and hides the slots.
123
+
124
+ ## Keyboard and focus
125
+
126
+ | Key | Does |
127
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
128
+ | A character | Fills the caret's slot, typing over its character (the ringed slot), as in Base UI. Refused characters do nothing. |
129
+ | Backspace / Delete | Deletes the character before or after the caret, as in any text field. |
130
+ | Arrow Left / Right | Moves the caret, and the ringed slot, one slot. |
131
+ | Home / End | The first slot, or after the last character. |
132
+ | Tab | Moves on: one tab stop. |
133
+
134
+ A paste fills every slot from the caret, its spaces and dashes dropped, even past what
135
+ `maxlength` would cut. An IME composition (Chinese, Japanese, full-width digits) shows in the
136
+ slots as it is; only the committed text is kept to the allowed characters, completes the field and
137
+ auto-submits.
138
+
139
+ ## Without JavaScript, and the element's script
140
+
141
+ The input is a native text field: autofill, typing, paste, `pattern` and `required` validation and
142
+ submission work with no script, and the slots stay hidden. `<lu-otp-field>`'s script (1.5 kB
143
+ brotli) shows the slots, cleans the value as it changes (firing `valueinvalid` for refused input),
144
+ fires `valuecomplete`, auto-submits, and follows a form reset.
145
+
146
+ A numeric code (`validationType="numeric"`, the default) renders `dir="ltr"` on the Root: its
147
+ slots run left to right in a right-to-left page too, as digits read there. Other codes follow the
148
+ page; pass `dir` to choose.
149
+
150
+ ## Browser support
151
+
152
+ `autocomplete="one-time-code"`: Safari (iOS, macOS) and Chrome on Android offer received codes;
153
+ elsewhere the field is a plain one. The transparent input over the slots is 16px, so iOS Safari
154
+ does not zoom into it on focus.
155
+
156
+ ## Related
157
+
158
+ - [Field](field.md): a label, a description and errors for the code.
159
+ - [Input](input.md): a text field without slots.
160
+ - [Form](form.md): submitting the code to a form action.
@@ -0,0 +1,271 @@
1
+ # Popover
2
+
3
+ > A popup anchored to its trigger: a native `popover` opened and closed by the trigger's `popovertarget`, placed with CSS anchor positioning, so it works in server HTML with no JavaScript. A 3.3 kB script moves focus into it and back, dismisses it as Base UI does (not on a right-click), keeps `aria-expanded` current and places it in browsers without anchor positioning. `bind:open` (or `open` and `onOpenChange`) keeps a signal in sync in islands. A trigger can open it on hover (`openOnHover`), and a `modal` popover is a modal `<dialog>` that makes the page behind it inert.
4
+
5
+ ```ts
6
+ import { Popover } from "@lumiose/ui"; // or "@lumiose/ui/popover"
7
+ import "@lumiose/ui/popover.css"; // places the popup next to its trigger
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <Popover.Root>
15
+ <Popover.Trigger />
16
+ <Popover.Popup>
17
+ <Popover.Title />
18
+ <Popover.Description />
19
+ <Popover.Close />
20
+ </Popover.Popup>
21
+ </Popover.Root>
22
+ ```
23
+
24
+ | Part | Renders |
25
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | `Root` | No element. Holds the state and the ids the other parts share. |
27
+ | `Trigger` | `<button type="button" popovertarget="…" aria-haspopup="dialog">`, with an `anchor-name` in its `style`. |
28
+ | `Popup` | `<div popover="auto" role="dialog" data-side="…" data-align="…">`, labelled by its `Title` and described by its `Description`; `popover="manual"` once the script opens it. |
29
+ | `Title` | `<h2>`, which names the popup. |
30
+ | `Description` | `<p>`, which describes it. |
31
+ | `Close` | `<button type="button" popovertarget="…" popovertargetaction="hide">`. |
32
+
33
+ With `modal`, the `Trigger` is a `<button command="show-modal" commandfor="…">`, the `Popup` a
34
+ `<dialog closedby="any" data-side="…" data-align="…">` and the `Close` a
35
+ `<button command="close" commandfor="…">`.
36
+
37
+ The popup is a non-modal dialog, as in Base UI: the page around it stays usable (unless `modal`). A popover may
38
+ contain another `Popover.Root`: the inner popup opens on top, and closing the outer one closes
39
+ both.
40
+
41
+ ## API
42
+
43
+ ### Popover.Root
44
+
45
+ | Prop | Type | Default | |
46
+ | -------------- | ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `open` | `boolean` | | Opens or closes the popover when it changes, in the browser. Server HTML always starts closed: a popover opens only from its trigger or script. |
48
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the popover opens or closes, whatever did it: the trigger, a close button, Escape, a click outside, another popover or `open`. |
49
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`. |
50
+ | `modal` | `boolean \| "trap-focus"` | `false` | A modal popover: the page behind it is inert and does not scroll, focus stays inside, and a press outside or Escape closes it. `"trap-focus"` only keeps Tab inside. |
51
+
52
+ ### Popover.Trigger
53
+
54
+ | Prop | Type | Default | |
55
+ | ------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `openOnHover` | `boolean` | `false` | A mouse or pen resting on the trigger opens the popover too, and it closes once the pointer has left the trigger and the popup. Not for a modal popover. |
57
+ | `delay` | `number` | `300` | With `openOnHover`: how long the pointer rests on the trigger before the popover opens, in ms. |
58
+ | `closeDelay` | `number` | `0` | With `openOnHover`: how long the popover stays open after the pointer leaves, in ms (at least 100 ms to cross the gap to the popup). |
59
+
60
+ A hover opens the popover without moving focus, and a click on the trigger then keeps it open
61
+ until it is dismissed. Touch and the keyboard open it with a click (a tap, Enter or Space), as
62
+ without `openOnHover`. On the way from the trigger to the popup, the pointer may take its time:
63
+ while it moves inside the triangle from where it left the trigger to the popup's near side (Base
64
+ UI's safe polygon), the popover waits; a stop or a turn away lets it close.
65
+
66
+ ### Popover.Popup
67
+
68
+ | Prop | Type | Default | |
69
+ | ------------- | -------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
70
+ | `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end"` | `"bottom"` | The trigger's side the popup goes on. `inline-start` and `inline-end` follow `dir`. |
71
+ | `align` | `"start" \| "center" \| "end"` | `"center"` | How the popup lines up with the trigger along that side. |
72
+ | `sideOffset` | `number` | `0` | The gap between the trigger and the popup, in CSS pixels. |
73
+ | `alignOffset` | `number` | `0` | A shift along the side, in CSS pixels: away from the aligned edge, or right/down when centred. |
74
+
75
+ The popup flips to the opposite side when it does not fit, and goes below (or above) the trigger
76
+ when a `left` or `right` popup fits on neither side. A popup too tall for the room on both sides shrinks to the room beside its trigger
77
+ (below, or above when below has less than 8rem), and scrolls; without anchor positioning the
78
+ script writes that room as `--lu-available-height`, which the default theme's caps use. It hides while its trigger is
79
+ scrolled out of view, of the page or of a scroller around it (`data-anchor-hidden` on the popup,
80
+ where the script runs), and comes back with it.
81
+
82
+ ### The other parts
83
+
84
+ Each part takes the props of the element it renders (`class`, `style`, `aria-*`, `data-*`, event
85
+ handlers, `ref`) and passes them through. Your props override the defaults (`type`,
86
+ `aria-haspopup`, `role`, `aria-labelledby`, `aria-describedby`). A `Title`'s or a `Description`'s
87
+ `id` names it, and the popup points at it; the `Popup` keeps its own `id`, which its trigger points
88
+ at (see [Ids](README.md#ids)), and `popover` and `popovertarget` stay the parts' own. A `style` you pass keeps the anchor name.
89
+
90
+ - **`Trigger`**: `aria-expanded` comes from the browser for `popovertarget`, and from the client
91
+ script once it runs.
92
+ - **`Popup`**: native events work as on any popover: `on:toggle` and `on:beforetoggle`. A popup
93
+ without a `Title` needs an `aria-label`.
94
+
95
+ ## Example
96
+
97
+ ```tsx check
98
+ import { Popover } from "@lumiose/ui";
99
+
100
+ export function Notifications() {
101
+ return (
102
+ <Popover.Root>
103
+ <Popover.Trigger class="lu-button">Notifications</Popover.Trigger>
104
+ <Popover.Popup class="lu-popover" align="start">
105
+ <Popover.Title>Notifications</Popover.Title>
106
+ <Popover.Description>You are all caught up.</Popover.Description>
107
+ <footer>
108
+ <Popover.Close class="lu-button">Close</Popover.Close>
109
+ </footer>
110
+ </Popover.Popup>
111
+ </Popover.Root>
112
+ );
113
+ }
114
+ ```
115
+
116
+ In an island, bound to a signal: `bind:open={open}` is short for `open={open()}` plus
117
+ `onOpenChange={(next) => open.set(next)}`. A popover opened by the signal still anchors to its
118
+ trigger and treats it as its invoker.
119
+
120
+ ```tsx check
121
+ import { Popover } from "@lumiose/ui";
122
+ import { signal } from "lumiose";
123
+
124
+ export function Filters() {
125
+ const open = signal(false);
126
+ return (
127
+ <>
128
+ <Popover.Root bind:open={open}>
129
+ <Popover.Trigger class="lu-button">Filters</Popover.Trigger>
130
+ <Popover.Popup class="lu-popover" side="right" sideOffset={8}>
131
+ <Popover.Title>Filters</Popover.Title>
132
+ </Popover.Popup>
133
+ </Popover.Root>
134
+ <p>{open() ? "Choosing filters…" : "All items"}</p>
135
+ </>
136
+ );
137
+ }
138
+ ```
139
+
140
+ A profile card that opens on hover, and a modal popover with a form:
141
+
142
+ ```tsx check
143
+ import { Popover } from "@lumiose/ui";
144
+
145
+ export function HoverAndModal() {
146
+ return (
147
+ <>
148
+ <Popover.Root>
149
+ <Popover.Trigger class="lu-button" openOnHover delay={200}>
150
+ Ada Lovelace
151
+ </Popover.Trigger>
152
+ <Popover.Popup class="lu-popover" sideOffset={8}>
153
+ <Popover.Title>Ada Lovelace</Popover.Title>
154
+ <Popover.Description>Wrote the first published program.</Popover.Description>
155
+ </Popover.Popup>
156
+ </Popover.Root>
157
+ <Popover.Root modal>
158
+ <Popover.Trigger class="lu-button">Rename</Popover.Trigger>
159
+ <Popover.Popup class="lu-popover" align="start" sideOffset={8}>
160
+ <Popover.Title>Rename the file</Popover.Title>
161
+ <label>
162
+ Name <input class="lu-input" value="report.pdf" />
163
+ </label>
164
+ <footer>
165
+ <Popover.Close class="lu-button">Done</Popover.Close>
166
+ </footer>
167
+ </Popover.Popup>
168
+ </Popover.Root>
169
+ </>
170
+ );
171
+ }
172
+ ```
173
+
174
+ ## Styling
175
+
176
+ **With the default theme**, put `lu-popover` on `Popup`: a surface with the title, the
177
+ description and a `<footer>` styled, a 4px gap to the trigger, and a fade. Its colours come from the
178
+ theme's `--lu-` properties ([UI components](README.md#the-default-theme)).
179
+
180
+ **With your own CSS**, style the popup through `class` and its state: `:popover-open` (`[open]`
181
+ for a modal popover, whose `::backdrop` is transparent until you style it),
182
+ `[data-side]` and `[data-align]` (the requested placement; a flip does not change them), and the
183
+ trigger through `[aria-expanded]`. Two custom properties set the offsets, and the props override
184
+ them: `--lu-side-offset` and `--lu-align-offset`.
185
+
186
+ ```css
187
+ .popover {
188
+ --lu-side-offset: 6px;
189
+ padding: 0.75rem;
190
+ border: 1px solid #d4d4d8;
191
+ border-radius: 0.5rem;
192
+ box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.35);
193
+ }
194
+ ```
195
+
196
+ `@lumiose/ui/popover.css` holds the placement, in the `lu.core` layer: for each `data-side` and
197
+ `data-align`, the CSS anchor positioning that puts the popup next to its trigger, with flips and
198
+ `position-visibility: anchors-visible`. Tooltip shares it.
199
+
200
+ ## Keyboard and focus
201
+
202
+ | Key | Does |
203
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
204
+ | Enter, Space | On the trigger: opens or closes the popover. |
205
+ | Tab | Moves through the popup, then on to the element after the trigger. When the trigger is the last element, focus leaves the popup. |
206
+ | Shift+Tab | From the start of the popup, back to the trigger. The popover stays open. |
207
+ | Escape | Closes the popover on top. With nested popovers, or a popover in a dialog, one Escape closes one layer. An Escape that ends an input method's composition closes nothing. |
208
+
209
+ - **On open**, focus moves to the popup's first tab stop (the checked radio of a group, not an
210
+ item a roving widget takes out of the order), or to the popup itself when it has none. Opened by touch, the popup focuses itself, so no virtual keyboard opens.
211
+ - **On close**, focus returns to the trigger without scrolling, unless a click outside landed on
212
+ another control, which keeps it. Escape and the close buttons return it too.
213
+ - **Tab past the popup** closes it, so it never covers the control that takes focus; Shift+Tab
214
+ back to its trigger keeps it open, and so does focus moving into a toast.
215
+ - **Closing it**, whatever closed it (a bound `open` set to false), closes the popovers, menus and
216
+ dialogs open inside it.
217
+ - **A click outside** closes the popover; a right-click or a second finger does not. A press inside a nested popover does not close its
218
+ parent, and opening another popover closes the first.
219
+ - Popovers opened from a dialog belong inside its `Popup`: everything outside a modal dialog is
220
+ inert.
221
+ - **Opened on hover**, the popover leaves focus where it is, and closing it moves none (unless
222
+ focus had gone into it); it stays open while the pointer is on the trigger or the popup, and
223
+ Escape closes it without moving the pointer (WCAG 1.4.13).
224
+ - **A modal popover** keeps Tab inside it (the page behind it is inert), and Escape, a press
225
+ outside or a close button closes it; focus returns to the trigger.
226
+ - **`modal="trap-focus"`** keeps Tab and Shift+Tab going around the popup's tab stops (a radio
227
+ group is one stop, as a roving toolbar is), and nothing else: the page scrolls, and a press
228
+ outside lands where it is pressed (and closes the popover).
229
+
230
+ ## Without JavaScript, and the client script
231
+
232
+ The trigger, the close buttons, Escape, the click outside and the placement are HTML and CSS
233
+ features: a popover in a server-rendered page works before any script loads, and with none. So
234
+ does a modal popover, through invoker commands and `closedby` (where a browser lacks them,
235
+ [Dialog](dialog.md)'s script, which a modal popover loads, stands in). Opening on hover needs the
236
+ script.
237
+ `open`, `onOpenChange` and `bind:open` need the component to run in an island.
238
+
239
+ A page that renders a `Popover` loads a 3.3 kB (brotli) module once, without the Lumiose runtime;
240
+ islands bundle it. It covers every Popover on the page:
241
+
242
+ - focus on open (the first focusable element, or the popup) and its return on close;
243
+ - dismissal, shared with [Menu](menu.md) and [Combobox](combobox.md): a trigger's click makes the
244
+ popup a manual popover, which the script closes on a click outside (main button only, so a
245
+ right-click or a second finger does not close it, as in Base UI), on Escape and other close
246
+ requests (the Android back gesture), and when another popover opens outside it. Until the
247
+ script loads, the browser's light dismiss closes it on any press outside;
248
+ - `aria-expanded` on the triggers;
249
+ - the placement in browsers without CSS anchor positioning, which it keeps current on scroll and
250
+ resize;
251
+ - opening on hover: the delays, the grace and the safe triangle to cross the gap to the popup,
252
+ and keeping it open after a click on the trigger;
253
+ - `modal="trap-focus"`'s Tab loop.
254
+
255
+ No `Backdrop` part: an open popover is in the top layer, so `.lu-popover::backdrop` (or a modal
256
+ popover's `dialog::backdrop`) styles the layer under it, without an element of its own.
257
+
258
+ ## Browser support
259
+
260
+ - **Popover and `popovertarget`**: every current browser (Baseline 2024).
261
+ - **Invoker commands and `closedby`** (a modal popover): Chrome 135, Firefox 144, Safari 26.2 for
262
+ commands; Chrome 134 and Firefox 141 for `closedby`. Elsewhere Dialog's script stands in.
263
+ - **CSS anchor positioning**: Chrome and Edge 125, Firefox 147, Safari 26. Elsewhere the client
264
+ script places the popup; without JavaScript there it opens in the middle of the viewport.
265
+ - Safari and Firefox do not follow a trigger moved by a CSS `transform`.
266
+
267
+ ## Related
268
+
269
+ - [UI components](README.md): the approach, styling, the default theme and the other components.
270
+ - [Tooltip](tooltip.md): a short tip on hover or focus, with the same placement props.
271
+ - [Dialog](dialog.md): a modal popup.
@@ -0,0 +1,130 @@
1
+ # Preview Card
2
+
3
+ > A card that previews a link's destination while the pointer or keyboard focus rests on the link, after Base UI's PreviewCard. The trigger is the link itself, which works as a link without JavaScript; the card is a manual popover placed with [Popover](popover.md)'s placement and opened with its hover timing by a 2.8 kB script. It is hoverable, persistent and dismissible (WCAG 1.4.13). `bind:open` keeps a signal in sync.
4
+
5
+ ```ts
6
+ import { PreviewCard } from "@lumiose/ui"; // or "@lumiose/ui/preview-card"
7
+ import "@lumiose/ui/preview-card.css"; // places the card next to its link
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <PreviewCard.Root>
15
+ <p>
16
+ … <PreviewCard.Trigger href="…" /> …
17
+ </p>
18
+ <PreviewCard.Popup />
19
+ </PreviewCard.Root>
20
+ ```
21
+
22
+ | Part | Renders |
23
+ | --------- | ---------------------------------------------------------------------------------------------------------- |
24
+ | `Root` | No element. Holds the ids the other parts share. |
25
+ | `Trigger` | `<a href data-preview-card="…">`, with an `anchor-name` in its `style`; `data-popup-open` while it is open |
26
+ | `Popup` | `<div popover="manual" data-side data-align>`, placed next to the link |
27
+
28
+ The card is a `<div>`: put it after the paragraph that holds the link, not inside it (a `<p>`
29
+ cannot hold a `<div>`), with the Root around both.
30
+
31
+ ## API
32
+
33
+ ### PreviewCard.Root
34
+
35
+ | Prop | Type | Default | |
36
+ | -------------- | ------------------------- | ------- | --------------------------------------------------------- |
37
+ | `open` | `boolean` | | Opens or closes the card when it changes, in the browser. |
38
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the card opens or closes, whatever did it. |
39
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`. |
40
+
41
+ ### PreviewCard.Trigger
42
+
43
+ | Prop | Type | Default | |
44
+ | ------------ | -------- | ------- | ------------------------------------------------------------------------------ |
45
+ | `delay` | `number` | `600` | How long the pointer or focus rests on the link before the card opens, in ms. |
46
+ | `closeDelay` | `number` | `300` | How long the card stays after the pointer leaves the link and the card, in ms. |
47
+
48
+ Every other `<a>` prop passes through: `href`, `target`, `class`, `aria-*`.
49
+
50
+ ### PreviewCard.Popup
51
+
52
+ `side`, `align`, `sideOffset` and `alignOffset`, as on [Popover.Popup](popover.md#popoverpopup);
53
+ every other `<div>` prop passes through.
54
+
55
+ **Differences from Base UI's PreviewCard.** No `Portal`, `Positioner` or `Viewport`: the top
56
+ layer and CSS anchor positioning replace them, and the placement props are on `Popup`.
57
+
58
+ - **No `Backdrop`**: the open card is in the top layer, so `.lu-preview-card::backdrop` styles the
59
+ layer under it, without an element.
60
+ - **No `Arrow`**, as in Popover and Tooltip: anchor positioning gives no arrow offset when the card
61
+ shifts to fit. A pointer that follows `data-side` is a `::before` of your CSS.
62
+ - **No detached triggers** (`handle`, `payload`) or `actionsRef`: a card's content is markup
63
+ rendered with it, so each link has its Root and its card, in server HTML too. In an island, a
64
+ signal per link does what a payload does.
65
+
66
+ ## Example
67
+
68
+ ```tsx check
69
+ import { PreviewCard } from "@lumiose/ui";
70
+
71
+ export function SignalsLink() {
72
+ return (
73
+ <PreviewCard.Root>
74
+ <p>
75
+ Lumiose components update through{" "}
76
+ <PreviewCard.Trigger href="/docs/concepts/signals">signals</PreviewCard.Trigger>.
77
+ </p>
78
+ <PreviewCard.Popup class="lu-preview-card">
79
+ <strong>Signals</strong>
80
+ <p>Values that update whatever reads them.</p>
81
+ </PreviewCard.Popup>
82
+ </PreviewCard.Root>
83
+ );
84
+ }
85
+ ```
86
+
87
+ A card is a visual enhancement, as Base UI says: what it shows should also be on the linked page.
88
+
89
+ ## Styling
90
+
91
+ **With the default theme**, put `lu-preview-card` on `Popup`: a surface like a popover's, 8px from
92
+ the link, with a fade (none where motion is reduced).
93
+
94
+ **With your own CSS**, style the card through `class`, `:popover-open`, `[data-side]` and
95
+ `[data-align]`, and the link through `[data-popup-open]`. `--lu-side-offset` and
96
+ `--lu-align-offset` set the offsets.
97
+
98
+ `@lumiose/ui/preview-card.css` is the placement Popover and Tooltip share (placement.css).
99
+
100
+ ## Keyboard and focus
101
+
102
+ | Key | Does |
103
+ | ------ | ----------------------------------------------------------------------------------------------------------- |
104
+ | Tab | Onto the link: the card opens after the delay, focus stays on the link. Again: into the open card, then on. |
105
+ | Escape | Closes the card without moving the pointer; from inside the card, focus goes back to the link. |
106
+ | Enter | Follows the link. |
107
+
108
+ - **Hover** opens the card after the delay; it stays while the pointer is on the link or the card
109
+ (it may cross the gap between them) and closes after the close delay once it left both.
110
+ - **Focus** opens it too; it closes when focus leaves the link and the card. The open card follows
111
+ its link in the focus order (the link is its invoker).
112
+ - **Touch** follows the link: no card opens on a tap.
113
+ - **A press outside** closes it, as Escape does (the dismissal model of Popover and Menu).
114
+
115
+ ## Without JavaScript, and the client script
116
+
117
+ The link is a plain link and the card never opens. A page that renders a `PreviewCard` loads a
118
+ 2.8 kB (brotli) module once (with the hover timing it shares with Popover): hover and focus with
119
+ their delays, dismissal, `data-popup-open`, and the placement in browsers without CSS anchor
120
+ positioning.
121
+
122
+ ## Browser support
123
+
124
+ Popover and CSS anchor positioning as in [Popover](popover.md#browser-support): the script places
125
+ the card where anchor positioning is missing.
126
+
127
+ ## Related
128
+
129
+ - [Popover](popover.md): a popup a click opens, or a hover with `openOnHover`.
130
+ - [Tooltip](tooltip.md): a short text tip on hover or focus.