@realmorg/ui 2.0.0-alpha.3 → 2.0.0-alpha.4

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.
package/README.md CHANGED
@@ -5,10 +5,10 @@ About 60 accessible components built with [REALM](https://realm.js.org). Each on
5
5
  Load REALM, the theme and the components from the CDN. There is no build step.
6
6
 
7
7
  ```html
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
9
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
10
- <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
11
- <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/ui.html"></import-element>
8
+ <link rel="stylesheet" href="https://realm.js.org/dist/realm.css">
9
+ <link rel="stylesheet" href="https://realm.js.org/dist/ui/theme.css">
10
+ <script type="module" src="https://realm.js.org/dist/realm.js"></script>
11
+ <import-element from="https://realm.js.org/dist/ui/ui.html"></import-element>
12
12
 
13
13
  <ui-card>
14
14
  <ui-card-header>
@@ -28,8 +28,8 @@ Load REALM, the theme and the components from the CDN. There is no build step.
28
28
  To load only what you use, import single files instead of `ui.html`:
29
29
 
30
30
  ```html
31
- <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
32
- <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/card.html"></import-element>
31
+ <import-element from="https://realm.js.org/dist/ui/components/button.html"></import-element>
32
+ <import-element from="https://realm.js.org/dist/ui/components/card.html"></import-element>
33
33
  ```
34
34
 
35
35
  Each file imports its own dependencies (`sheet.html` loads `dialog.html`), and each file is fetched once.
@@ -89,11 +89,32 @@
89
89
  width: calc(var(--spacing) * 4);
90
90
  height: calc(var(--spacing) * 4);
91
91
  color: var(--muted-foreground);
92
- transition: rotate 0.2s;
92
+ transition: rotate var(--default-transition-duration) var(--default-transition-timing-function);
93
93
  }
94
94
  details[open] svg {
95
95
  rotate: 180deg;
96
96
  }
97
+ details::details-content {
98
+ display: grid;
99
+ grid-template-rows: 0fr;
100
+ transition:
101
+ grid-template-rows var(--default-transition-duration) var(--default-transition-timing-function),
102
+ content-visibility var(--default-transition-duration) allow-discrete;
103
+ }
104
+ details[open]::details-content {
105
+ grid-template-rows: 1fr;
106
+ }
107
+ slot:not([name]) {
108
+ display: block;
109
+ min-height: 0;
110
+ overflow: hidden;
111
+ }
112
+ @media (prefers-reduced-motion: reduce) {
113
+ svg,
114
+ details::details-content {
115
+ transition: none;
116
+ }
117
+ }
97
118
  </style>
98
119
 
99
120
  <details _open="@open" ontoggle="toggled(this)">
@@ -11,6 +11,9 @@
11
11
  popovertargetaction: prop.enum(["toggle", "show", "hide"]),
12
12
  commandfor: prop.string(),
13
13
  command: prop.string(),
14
+ href: prop.string(),
15
+ target: prop.string(),
16
+ rel: prop.string(),
14
17
  });
15
18
 
16
19
  const NATIVE_COMMANDS = "commandForElement" in HTMLButtonElement.prototype;
@@ -75,7 +78,11 @@
75
78
  :host([hidden]) {
76
79
  display: none;
77
80
  }
78
- button {
81
+ is-visible,
82
+ is-hidden {
83
+ display: contents;
84
+ }
85
+ .control {
79
86
  all: unset;
80
87
  box-sizing: border-box;
81
88
  display: inline-flex;
@@ -103,14 +110,15 @@
103
110
  }
104
111
  /* Mixing toward the text colour darkens a light-mode fill and lightens a dark-mode one, so
105
112
  hover never lowers the text contrast. */
106
- button:hover {
113
+ .control:hover {
107
114
  background: color-mix(in oklab, var(--primary) 88%, var(--foreground));
108
115
  }
109
- button:focus-visible {
116
+ .control:focus-visible {
110
117
  border-color: var(--ring);
111
118
  box-shadow: var(--focus-ring);
112
119
  }
113
- button:disabled {
120
+ .control:disabled,
121
+ :host([disabled]) a.control {
114
122
  pointer-events: none;
115
123
  opacity: 0.5;
116
124
  }
@@ -120,75 +128,80 @@
120
128
  flex-shrink: 0;
121
129
  pointer-events: none;
122
130
  }
123
- :host(:state(attr-variant-is-destructive)) button {
131
+ :host(:state(attr-variant-is-destructive)) .control {
124
132
  background: var(--destructive);
125
133
  color: var(--destructive-foreground);
126
134
  }
127
- :host(:state(attr-variant-is-destructive)) button:hover {
135
+ :host(:state(attr-variant-is-destructive)) .control:hover {
128
136
  background: color-mix(in oklab, var(--destructive) 88%, var(--foreground));
129
137
  }
130
- :host(:state(attr-variant-is-outline)) button {
138
+ :host(:state(attr-variant-is-outline)) .control {
131
139
  border-color: var(--border);
132
140
  background: var(--background);
133
141
  color: var(--foreground);
134
142
  box-shadow: var(--shadow-xs);
135
143
  }
136
- :host(:state(attr-variant-is-outline)) button:hover,
137
- :host(:state(attr-variant-is-ghost)) button:hover {
144
+ :host(:state(attr-variant-is-outline)) .control:hover,
145
+ :host(:state(attr-variant-is-ghost)) .control:hover {
138
146
  background: var(--accent);
139
147
  color: var(--accent-foreground);
140
148
  }
141
- :host(:state(attr-variant-is-secondary)) button {
149
+ :host(:state(attr-variant-is-secondary)) .control {
142
150
  background: var(--secondary);
143
151
  color: var(--secondary-foreground);
144
152
  }
145
- :host(:state(attr-variant-is-secondary)) button:hover {
153
+ :host(:state(attr-variant-is-secondary)) .control:hover {
146
154
  background: color-mix(in oklab, var(--secondary) 80%, transparent);
147
155
  }
148
- :host(:state(attr-variant-is-ghost)) button {
156
+ :host(:state(attr-variant-is-ghost)) .control {
149
157
  background: transparent;
150
158
  color: var(--foreground);
151
159
  }
152
- :host(:state(attr-variant-is-link)) button {
160
+ :host(:state(attr-variant-is-link)) .control {
153
161
  background: transparent;
154
162
  color: var(--primary);
155
163
  text-underline-offset: 4px;
156
164
  }
157
- :host(:state(attr-variant-is-link)) button:hover {
165
+ :host(:state(attr-variant-is-link)) .control:hover {
158
166
  text-decoration: underline;
159
167
  }
160
- :host(:state(attr-size-is-xs)) button {
168
+ :host(:state(attr-size-is-xs)) .control {
161
169
  height: calc(var(--spacing) * 6);
162
170
  gap: calc(var(--spacing) * 1);
163
171
  padding: 0 calc(var(--spacing) * 2);
164
172
  font-size: var(--text-xs);
165
173
  }
166
- :host(:state(attr-size-is-sm)) button {
174
+ :host(:state(attr-size-is-sm)) .control {
167
175
  height: calc(var(--spacing) * 8);
168
176
  gap: calc(var(--spacing) * 1.5);
169
177
  padding: 0 calc(var(--spacing) * 3);
170
178
  }
171
- :host(:state(attr-size-is-lg)) button {
179
+ :host(:state(attr-size-is-lg)) .control {
172
180
  height: calc(var(--spacing) * 10);
173
181
  padding: 0 calc(var(--spacing) * 6);
174
182
  }
175
- :host(:is(:state(attr-size-is-icon), :state(attr-size-is-icon-xs), :state(attr-size-is-icon-sm), :state(attr-size-is-icon-lg))) button {
183
+ :host(:is(:state(attr-size-is-icon), :state(attr-size-is-icon-xs), :state(attr-size-is-icon-sm), :state(attr-size-is-icon-lg))) .control {
176
184
  padding: 0;
177
185
  width: calc(var(--spacing) * 9);
178
186
  }
179
- :host(:state(attr-size-is-icon-xs)) button {
187
+ :host(:state(attr-size-is-icon-xs)) .control {
180
188
  width: calc(var(--spacing) * 6);
181
189
  height: calc(var(--spacing) * 6);
182
190
  }
183
- :host(:state(attr-size-is-icon-sm)) button {
191
+ :host(:state(attr-size-is-icon-sm)) .control {
184
192
  width: calc(var(--spacing) * 8);
185
193
  height: calc(var(--spacing) * 8);
186
194
  }
187
- :host(:state(attr-size-is-icon-lg)) button {
195
+ :host(:state(attr-size-is-icon-lg)) .control {
188
196
  width: calc(var(--spacing) * 10);
189
197
  height: calc(var(--spacing) * 10);
190
198
  }
191
199
  </style>
192
200
 
193
- <button type="button" ref="button" part="button" _aria-label="@label" _aria-description="@description" _disabled="@disabled" onclick="activate()"><slot></slot></button>
201
+ <is-visible when="@href">
202
+ <a class="control" ref="link" part="button" _href="@href" _target="@target" _rel="@rel" _aria-label="@label" _aria-description="@description"><slot></slot></a>
203
+ </is-visible>
204
+ <is-hidden when="@href">
205
+ <button class="control" type="button" ref="button" part="button" _aria-label="@label" _aria-description="@description" _disabled="@disabled" onclick="activate()"><slot></slot></button>
206
+ </is-hidden>
194
207
  </custom-element>
@@ -55,6 +55,12 @@
55
55
  combo.query = "";
56
56
  combo.active = Math.max(0, combo.visible.findIndex((item) => item.chosen));
57
57
  refs.list.showPopover();
58
+ // Once open, the list keeps the side it opened on. Re-deciding while the page scrolls makes
59
+ // it jump, because iOS Safari's toolbar changes the viewport height mid-scroll.
60
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
61
+ internals.states.delete("flipped");
62
+ const box = refs.list.getBoundingClientRect();
63
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
58
64
  }
59
65
 
60
66
  function close() {
@@ -159,6 +165,13 @@
159
165
  position-area: bottom span-right;
160
166
  position-try-fallbacks: flip-block;
161
167
  }
168
+ [role="listbox"]:popover-open {
169
+ position-try-fallbacks: none;
170
+ }
171
+ :host(:state(flipped)) [role="listbox"] {
172
+ position-area: top span-right;
173
+ margin: 0 0 calc(var(--spacing) * 1);
174
+ }
162
175
  [role="option"] {
163
176
  display: flex;
164
177
  align-items: center;
@@ -56,8 +56,15 @@
56
56
 
57
57
  const ITEMS = ":is(ui-menu-item, ui-menu-checkbox-item, ui-menu-radio-item):not([disabled])";
58
58
 
59
+ // Once open, the menu keeps the side it opened on. Re-deciding while the page scrolls makes it
60
+ // jump, because iOS Safari's toolbar changes the viewport height mid-scroll.
61
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
59
62
  function opened(event) {
60
- if (event.newState === "open") host.querySelector(ITEMS)?.focus();
63
+ if (event.newState !== "open") return;
64
+ internals.states.delete("flipped");
65
+ const box = host.getBoundingClientRect();
66
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
67
+ host.querySelector(ITEMS)?.focus({ preventScroll: true });
61
68
  }
62
69
 
63
70
  function leave(event) {
@@ -104,6 +111,24 @@
104
111
  position-area: top span-left;
105
112
  margin: 0 0 calc(var(--spacing) * 1);
106
113
  }
114
+ :host(:popover-open) {
115
+ position-try-fallbacks: flip-inline;
116
+ }
117
+ :host(:state(flipped)) {
118
+ position-area: top span-right;
119
+ margin: 0 0 calc(var(--spacing) * 1);
120
+ }
121
+ :host(:state(flipped):state(attr-placement-is-bottom-end)) {
122
+ position-area: top span-left;
123
+ }
124
+ :host(:state(flipped):state(attr-placement-is-top-start)) {
125
+ position-area: bottom span-right;
126
+ margin: calc(var(--spacing) * 1) 0 0;
127
+ }
128
+ :host(:state(flipped):state(attr-placement-is-top-end)) {
129
+ position-area: bottom span-left;
130
+ margin: calc(var(--spacing) * 1) 0 0;
131
+ }
107
132
  </style>
108
133
 
109
134
  <div part="menu" focusgroup="menu block wrap"><slot></slot></div>
@@ -61,11 +61,25 @@
61
61
 
62
62
  <custom-element name="ui-hover-card-content">
63
63
  <script>
64
- defineProps({
64
+ const props = defineProps({
65
65
  placement: prop.enum(["bottom", "top", "left", "right"]).default("bottom"),
66
66
  });
67
67
 
68
+ const BLOCK_SIDES = new Set(["bottom", "top"]);
69
+
70
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
71
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
72
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
73
+ function opened(event) {
74
+ internals.states.delete("flipped");
75
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
76
+ const box = host.getBoundingClientRect();
77
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
78
+ }
79
+
68
80
  attrs.popover ??= "manual";
81
+
82
+ listen("toggle", opened);
69
83
  </script>
70
84
 
71
85
  <style>
@@ -90,12 +104,21 @@
90
104
  position-area: bottom;
91
105
  position-try-fallbacks: flip-block, flip-inline;
92
106
  }
107
+ :host(:popover-open) {
108
+ position-try-fallbacks: flip-inline;
109
+ }
93
110
  :host(:state(attr-width)) {
94
111
  width: calc(var(--spacing) * var(--attr-width));
95
112
  }
96
113
  :host(:state(attr-placement-is-top)) {
97
114
  position-area: top;
98
115
  }
116
+ :host(:state(flipped)) {
117
+ position-area: top;
118
+ }
119
+ :host(:state(flipped):state(attr-placement-is-top)) {
120
+ position-area: bottom;
121
+ }
99
122
  :host(:state(attr-placement-is-left)) {
100
123
  position-area: left;
101
124
  }
@@ -94,8 +94,15 @@
94
94
  <script>
95
95
  const ITEMS = ":is(ui-menu-item, ui-menu-checkbox-item, ui-menu-radio-item):not([disabled])";
96
96
 
97
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
98
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
99
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
97
100
  function opened(event) {
98
- if (event.newState === "open") host.querySelector(ITEMS)?.focus();
101
+ if (event.newState !== "open") return;
102
+ internals.states.delete("flipped");
103
+ const box = host.getBoundingClientRect();
104
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
105
+ host.querySelector(ITEMS)?.focus({ preventScroll: true });
99
106
  }
100
107
 
101
108
  function leave(event) {
@@ -131,6 +138,13 @@
131
138
  position-area: bottom span-right;
132
139
  position-try-fallbacks: flip-block, flip-inline;
133
140
  }
141
+ :host(:popover-open) {
142
+ position-try-fallbacks: flip-inline;
143
+ }
144
+ :host(:state(flipped)) {
145
+ position-area: top span-right;
146
+ margin: 0 0 calc(var(--spacing) * 2) calc(var(--spacing) * -1);
147
+ }
134
148
  </style>
135
149
 
136
150
  <div part="menu" focusgroup="menu block wrap"><slot></slot></div>
@@ -50,11 +50,25 @@
50
50
 
51
51
  <custom-element name="ui-popover-content">
52
52
  <script>
53
- defineProps({
53
+ const props = defineProps({
54
54
  placement: prop.enum(["bottom", "top", "left", "right"]).default("bottom"),
55
55
  });
56
56
 
57
+ const BLOCK_SIDES = new Set(["bottom", "top"]);
58
+
59
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
60
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
61
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
62
+ function opened(event) {
63
+ internals.states.delete("flipped");
64
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
65
+ const box = host.getBoundingClientRect();
66
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
67
+ }
68
+
57
69
  attrs.popover ??= "auto";
70
+
71
+ listen("toggle", opened);
58
72
  </script>
59
73
 
60
74
  <style>
@@ -80,6 +94,7 @@
80
94
  position-try-fallbacks: flip-block, flip-inline;
81
95
  }
82
96
  :host(:popover-open) {
97
+ position-try-fallbacks: flip-inline;
83
98
  display: grid;
84
99
  /* Safari stretches a grid overlay to its max-height unless the rows are packed at the start. */
85
100
  align-content: start;
@@ -91,6 +106,12 @@
91
106
  :host(:state(attr-placement-is-top)) {
92
107
  position-area: top;
93
108
  }
109
+ :host(:state(flipped)) {
110
+ position-area: top;
111
+ }
112
+ :host(:state(flipped):state(attr-placement-is-top)) {
113
+ position-area: bottom;
114
+ }
94
115
  :host(:state(attr-placement-is-left)) {
95
116
  position-area: left;
96
117
  }
@@ -69,11 +69,25 @@
69
69
 
70
70
  <custom-element name="ui-tooltip-content" role="tooltip">
71
71
  <script>
72
- defineProps({
72
+ const props = defineProps({
73
73
  placement: prop.enum(["top", "bottom", "left", "right"]).default("top"),
74
74
  });
75
75
 
76
+ const BLOCK_SIDES = new Set(["top", "bottom"]);
77
+
78
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
79
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
80
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
81
+ function opened(event) {
82
+ internals.states.delete("flipped");
83
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
84
+ const box = host.getBoundingClientRect();
85
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
86
+ }
87
+
76
88
  attrs.popover ??= "manual";
89
+
90
+ listen("toggle", opened);
77
91
  </script>
78
92
 
79
93
  <style>
@@ -100,6 +114,17 @@
100
114
  position-area: bottom;
101
115
  margin: calc(var(--spacing) * 1) 0 0;
102
116
  }
117
+ :host(:popover-open) {
118
+ position-try-fallbacks: none;
119
+ }
120
+ :host(:state(flipped)) {
121
+ position-area: bottom;
122
+ margin: calc(var(--spacing) * 1) 0 0;
123
+ }
124
+ :host(:state(flipped):state(attr-placement-is-bottom)) {
125
+ position-area: top;
126
+ margin: 0 0 calc(var(--spacing) * 1);
127
+ }
103
128
  :host(:state(attr-placement-is-left)) {
104
129
  position-area: left;
105
130
  margin: 0 calc(var(--spacing) * 1) 0 0;
package/dist/ui.html CHANGED
@@ -89,11 +89,32 @@
89
89
  width: calc(var(--spacing) * 4);
90
90
  height: calc(var(--spacing) * 4);
91
91
  color: var(--muted-foreground);
92
- transition: rotate 0.2s;
92
+ transition: rotate var(--default-transition-duration) var(--default-transition-timing-function);
93
93
  }
94
94
  details[open] svg {
95
95
  rotate: 180deg;
96
96
  }
97
+ details::details-content {
98
+ display: grid;
99
+ grid-template-rows: 0fr;
100
+ transition:
101
+ grid-template-rows var(--default-transition-duration) var(--default-transition-timing-function),
102
+ content-visibility var(--default-transition-duration) allow-discrete;
103
+ }
104
+ details[open]::details-content {
105
+ grid-template-rows: 1fr;
106
+ }
107
+ slot:not([name]) {
108
+ display: block;
109
+ min-height: 0;
110
+ overflow: hidden;
111
+ }
112
+ @media (prefers-reduced-motion: reduce) {
113
+ svg,
114
+ details::details-content {
115
+ transition: none;
116
+ }
117
+ }
97
118
  </style>
98
119
 
99
120
  <details _open="@open" ontoggle="toggled(this)">
@@ -1149,6 +1170,9 @@
1149
1170
  popovertargetaction: prop.enum(["toggle", "show", "hide"]),
1150
1171
  commandfor: prop.string(),
1151
1172
  command: prop.string(),
1173
+ href: prop.string(),
1174
+ target: prop.string(),
1175
+ rel: prop.string(),
1152
1176
  });
1153
1177
 
1154
1178
  const NATIVE_COMMANDS = "commandForElement" in HTMLButtonElement.prototype;
@@ -1213,7 +1237,11 @@
1213
1237
  :host([hidden]) {
1214
1238
  display: none;
1215
1239
  }
1216
- button {
1240
+ is-visible,
1241
+ is-hidden {
1242
+ display: contents;
1243
+ }
1244
+ .control {
1217
1245
  all: unset;
1218
1246
  box-sizing: border-box;
1219
1247
  display: inline-flex;
@@ -1241,14 +1269,15 @@
1241
1269
  }
1242
1270
  /* Mixing toward the text colour darkens a light-mode fill and lightens a dark-mode one, so
1243
1271
  hover never lowers the text contrast. */
1244
- button:hover {
1272
+ .control:hover {
1245
1273
  background: color-mix(in oklab, var(--primary) 88%, var(--foreground));
1246
1274
  }
1247
- button:focus-visible {
1275
+ .control:focus-visible {
1248
1276
  border-color: var(--ring);
1249
1277
  box-shadow: var(--focus-ring);
1250
1278
  }
1251
- button:disabled {
1279
+ .control:disabled,
1280
+ :host([disabled]) a.control {
1252
1281
  pointer-events: none;
1253
1282
  opacity: 0.5;
1254
1283
  }
@@ -1258,77 +1287,82 @@
1258
1287
  flex-shrink: 0;
1259
1288
  pointer-events: none;
1260
1289
  }
1261
- :host(:state(attr-variant-is-destructive)) button {
1290
+ :host(:state(attr-variant-is-destructive)) .control {
1262
1291
  background: var(--destructive);
1263
1292
  color: var(--destructive-foreground);
1264
1293
  }
1265
- :host(:state(attr-variant-is-destructive)) button:hover {
1294
+ :host(:state(attr-variant-is-destructive)) .control:hover {
1266
1295
  background: color-mix(in oklab, var(--destructive) 88%, var(--foreground));
1267
1296
  }
1268
- :host(:state(attr-variant-is-outline)) button {
1297
+ :host(:state(attr-variant-is-outline)) .control {
1269
1298
  border-color: var(--border);
1270
1299
  background: var(--background);
1271
1300
  color: var(--foreground);
1272
1301
  box-shadow: var(--shadow-xs);
1273
1302
  }
1274
- :host(:state(attr-variant-is-outline)) button:hover,
1275
- :host(:state(attr-variant-is-ghost)) button:hover {
1303
+ :host(:state(attr-variant-is-outline)) .control:hover,
1304
+ :host(:state(attr-variant-is-ghost)) .control:hover {
1276
1305
  background: var(--accent);
1277
1306
  color: var(--accent-foreground);
1278
1307
  }
1279
- :host(:state(attr-variant-is-secondary)) button {
1308
+ :host(:state(attr-variant-is-secondary)) .control {
1280
1309
  background: var(--secondary);
1281
1310
  color: var(--secondary-foreground);
1282
1311
  }
1283
- :host(:state(attr-variant-is-secondary)) button:hover {
1312
+ :host(:state(attr-variant-is-secondary)) .control:hover {
1284
1313
  background: color-mix(in oklab, var(--secondary) 80%, transparent);
1285
1314
  }
1286
- :host(:state(attr-variant-is-ghost)) button {
1315
+ :host(:state(attr-variant-is-ghost)) .control {
1287
1316
  background: transparent;
1288
1317
  color: var(--foreground);
1289
1318
  }
1290
- :host(:state(attr-variant-is-link)) button {
1319
+ :host(:state(attr-variant-is-link)) .control {
1291
1320
  background: transparent;
1292
1321
  color: var(--primary);
1293
1322
  text-underline-offset: 4px;
1294
1323
  }
1295
- :host(:state(attr-variant-is-link)) button:hover {
1324
+ :host(:state(attr-variant-is-link)) .control:hover {
1296
1325
  text-decoration: underline;
1297
1326
  }
1298
- :host(:state(attr-size-is-xs)) button {
1327
+ :host(:state(attr-size-is-xs)) .control {
1299
1328
  height: calc(var(--spacing) * 6);
1300
1329
  gap: calc(var(--spacing) * 1);
1301
1330
  padding: 0 calc(var(--spacing) * 2);
1302
1331
  font-size: var(--text-xs);
1303
1332
  }
1304
- :host(:state(attr-size-is-sm)) button {
1333
+ :host(:state(attr-size-is-sm)) .control {
1305
1334
  height: calc(var(--spacing) * 8);
1306
1335
  gap: calc(var(--spacing) * 1.5);
1307
1336
  padding: 0 calc(var(--spacing) * 3);
1308
1337
  }
1309
- :host(:state(attr-size-is-lg)) button {
1338
+ :host(:state(attr-size-is-lg)) .control {
1310
1339
  height: calc(var(--spacing) * 10);
1311
1340
  padding: 0 calc(var(--spacing) * 6);
1312
1341
  }
1313
- :host(:is(:state(attr-size-is-icon), :state(attr-size-is-icon-xs), :state(attr-size-is-icon-sm), :state(attr-size-is-icon-lg))) button {
1342
+ :host(:is(:state(attr-size-is-icon), :state(attr-size-is-icon-xs), :state(attr-size-is-icon-sm), :state(attr-size-is-icon-lg))) .control {
1314
1343
  padding: 0;
1315
1344
  width: calc(var(--spacing) * 9);
1316
1345
  }
1317
- :host(:state(attr-size-is-icon-xs)) button {
1346
+ :host(:state(attr-size-is-icon-xs)) .control {
1318
1347
  width: calc(var(--spacing) * 6);
1319
1348
  height: calc(var(--spacing) * 6);
1320
1349
  }
1321
- :host(:state(attr-size-is-icon-sm)) button {
1350
+ :host(:state(attr-size-is-icon-sm)) .control {
1322
1351
  width: calc(var(--spacing) * 8);
1323
1352
  height: calc(var(--spacing) * 8);
1324
1353
  }
1325
- :host(:state(attr-size-is-icon-lg)) button {
1354
+ :host(:state(attr-size-is-icon-lg)) .control {
1326
1355
  width: calc(var(--spacing) * 10);
1327
1356
  height: calc(var(--spacing) * 10);
1328
1357
  }
1329
1358
  </style>
1330
1359
 
1331
- <button type="button" ref="button" part="button" _aria-label="@label" _aria-description="@description" _disabled="@disabled" onclick="activate()"><slot></slot></button>
1360
+ <is-visible when="@href">
1361
+ <a class="control" ref="link" part="button" _href="@href" _target="@target" _rel="@rel" _aria-label="@label" _aria-description="@description"><slot></slot></a>
1362
+ </is-visible>
1363
+ <is-hidden when="@href">
1364
+ <button class="control" type="button" ref="button" part="button" _aria-label="@label" _aria-description="@description" _disabled="@disabled" onclick="activate()"><slot></slot></button>
1365
+ </is-hidden>
1332
1366
  </custom-element>
1333
1367
 
1334
1368
  <custom-element name="ui-calendar" form-associated>
@@ -2337,6 +2371,12 @@
2337
2371
  combo.query = "";
2338
2372
  combo.active = Math.max(0, combo.visible.findIndex((item) => item.chosen));
2339
2373
  refs.list.showPopover();
2374
+ // Once open, the list keeps the side it opened on. Re-deciding while the page scrolls makes
2375
+ // it jump, because iOS Safari's toolbar changes the viewport height mid-scroll.
2376
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
2377
+ internals.states.delete("flipped");
2378
+ const box = refs.list.getBoundingClientRect();
2379
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
2340
2380
  }
2341
2381
 
2342
2382
  function close() {
@@ -2441,6 +2481,13 @@
2441
2481
  position-area: bottom span-right;
2442
2482
  position-try-fallbacks: flip-block;
2443
2483
  }
2484
+ [role="listbox"]:popover-open {
2485
+ position-try-fallbacks: none;
2486
+ }
2487
+ :host(:state(flipped)) [role="listbox"] {
2488
+ position-area: top span-right;
2489
+ margin: 0 0 calc(var(--spacing) * 1);
2490
+ }
2444
2491
  [role="option"] {
2445
2492
  display: flex;
2446
2493
  align-items: center;
@@ -3480,8 +3527,15 @@
3480
3527
 
3481
3528
  const ITEMS = ":is(ui-menu-item, ui-menu-checkbox-item, ui-menu-radio-item):not([disabled])";
3482
3529
 
3530
+ // Once open, the menu keeps the side it opened on. Re-deciding while the page scrolls makes it
3531
+ // jump, because iOS Safari's toolbar changes the viewport height mid-scroll.
3532
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
3483
3533
  function opened(event) {
3484
- if (event.newState === "open") host.querySelector(ITEMS)?.focus();
3534
+ if (event.newState !== "open") return;
3535
+ internals.states.delete("flipped");
3536
+ const box = host.getBoundingClientRect();
3537
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
3538
+ host.querySelector(ITEMS)?.focus({ preventScroll: true });
3485
3539
  }
3486
3540
 
3487
3541
  function leave(event) {
@@ -3528,6 +3582,24 @@
3528
3582
  position-area: top span-left;
3529
3583
  margin: 0 0 calc(var(--spacing) * 1);
3530
3584
  }
3585
+ :host(:popover-open) {
3586
+ position-try-fallbacks: flip-inline;
3587
+ }
3588
+ :host(:state(flipped)) {
3589
+ position-area: top span-right;
3590
+ margin: 0 0 calc(var(--spacing) * 1);
3591
+ }
3592
+ :host(:state(flipped):state(attr-placement-is-bottom-end)) {
3593
+ position-area: top span-left;
3594
+ }
3595
+ :host(:state(flipped):state(attr-placement-is-top-start)) {
3596
+ position-area: bottom span-right;
3597
+ margin: calc(var(--spacing) * 1) 0 0;
3598
+ }
3599
+ :host(:state(flipped):state(attr-placement-is-top-end)) {
3600
+ position-area: bottom span-left;
3601
+ margin: calc(var(--spacing) * 1) 0 0;
3602
+ }
3531
3603
  </style>
3532
3604
 
3533
3605
  <div part="menu" focusgroup="menu block wrap"><slot></slot></div>
@@ -4184,11 +4256,25 @@
4184
4256
 
4185
4257
  <custom-element name="ui-hover-card-content">
4186
4258
  <script>
4187
- defineProps({
4259
+ const props = defineProps({
4188
4260
  placement: prop.enum(["bottom", "top", "left", "right"]).default("bottom"),
4189
4261
  });
4190
4262
 
4263
+ const BLOCK_SIDES = new Set(["bottom", "top"]);
4264
+
4265
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
4266
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
4267
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
4268
+ function opened(event) {
4269
+ internals.states.delete("flipped");
4270
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
4271
+ const box = host.getBoundingClientRect();
4272
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
4273
+ }
4274
+
4191
4275
  attrs.popover ??= "manual";
4276
+
4277
+ listen("toggle", opened);
4192
4278
  </script>
4193
4279
 
4194
4280
  <style>
@@ -4213,12 +4299,21 @@
4213
4299
  position-area: bottom;
4214
4300
  position-try-fallbacks: flip-block, flip-inline;
4215
4301
  }
4302
+ :host(:popover-open) {
4303
+ position-try-fallbacks: flip-inline;
4304
+ }
4216
4305
  :host(:state(attr-width)) {
4217
4306
  width: calc(var(--spacing) * var(--attr-width));
4218
4307
  }
4219
4308
  :host(:state(attr-placement-is-top)) {
4220
4309
  position-area: top;
4221
4310
  }
4311
+ :host(:state(flipped)) {
4312
+ position-area: top;
4313
+ }
4314
+ :host(:state(flipped):state(attr-placement-is-top)) {
4315
+ position-area: bottom;
4316
+ }
4222
4317
  :host(:state(attr-placement-is-left)) {
4223
4318
  position-area: left;
4224
4319
  }
@@ -5012,8 +5107,15 @@
5012
5107
  <script>
5013
5108
  const ITEMS = ":is(ui-menu-item, ui-menu-checkbox-item, ui-menu-radio-item):not([disabled])";
5014
5109
 
5110
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
5111
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
5112
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
5015
5113
  function opened(event) {
5016
- if (event.newState === "open") host.querySelector(ITEMS)?.focus();
5114
+ if (event.newState !== "open") return;
5115
+ internals.states.delete("flipped");
5116
+ const box = host.getBoundingClientRect();
5117
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
5118
+ host.querySelector(ITEMS)?.focus({ preventScroll: true });
5017
5119
  }
5018
5120
 
5019
5121
  function leave(event) {
@@ -5049,6 +5151,13 @@
5049
5151
  position-area: bottom span-right;
5050
5152
  position-try-fallbacks: flip-block, flip-inline;
5051
5153
  }
5154
+ :host(:popover-open) {
5155
+ position-try-fallbacks: flip-inline;
5156
+ }
5157
+ :host(:state(flipped)) {
5158
+ position-area: top span-right;
5159
+ margin: 0 0 calc(var(--spacing) * 2) calc(var(--spacing) * -1);
5160
+ }
5052
5161
  </style>
5053
5162
 
5054
5163
  <div part="menu" focusgroup="menu block wrap"><slot></slot></div>
@@ -5664,11 +5773,25 @@
5664
5773
 
5665
5774
  <custom-element name="ui-popover-content">
5666
5775
  <script>
5667
- defineProps({
5776
+ const props = defineProps({
5668
5777
  placement: prop.enum(["bottom", "top", "left", "right"]).default("bottom"),
5669
5778
  });
5670
5779
 
5780
+ const BLOCK_SIDES = new Set(["bottom", "top"]);
5781
+
5782
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
5783
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
5784
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
5785
+ function opened(event) {
5786
+ internals.states.delete("flipped");
5787
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
5788
+ const box = host.getBoundingClientRect();
5789
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
5790
+ }
5791
+
5671
5792
  attrs.popover ??= "auto";
5793
+
5794
+ listen("toggle", opened);
5672
5795
  </script>
5673
5796
 
5674
5797
  <style>
@@ -5694,6 +5817,7 @@
5694
5817
  position-try-fallbacks: flip-block, flip-inline;
5695
5818
  }
5696
5819
  :host(:popover-open) {
5820
+ position-try-fallbacks: flip-inline;
5697
5821
  display: grid;
5698
5822
  /* Safari stretches a grid overlay to its max-height unless the rows are packed at the start. */
5699
5823
  align-content: start;
@@ -5705,6 +5829,12 @@
5705
5829
  :host(:state(attr-placement-is-top)) {
5706
5830
  position-area: top;
5707
5831
  }
5832
+ :host(:state(flipped)) {
5833
+ position-area: top;
5834
+ }
5835
+ :host(:state(flipped):state(attr-placement-is-top)) {
5836
+ position-area: bottom;
5837
+ }
5708
5838
  :host(:state(attr-placement-is-left)) {
5709
5839
  position-area: left;
5710
5840
  }
@@ -7878,11 +8008,25 @@
7878
8008
 
7879
8009
  <custom-element name="ui-tooltip-content" role="tooltip">
7880
8010
  <script>
7881
- defineProps({
8011
+ const props = defineProps({
7882
8012
  placement: prop.enum(["top", "bottom", "left", "right"]).default("top"),
7883
8013
  });
7884
8014
 
8015
+ const BLOCK_SIDES = new Set(["top", "bottom"]);
8016
+
8017
+ // Once open, it keeps the side it opened on. Re-deciding while the page scrolls makes it jump,
8018
+ // because iOS Safari's toolbar changes the viewport height mid-scroll.
8019
+ // Firefox holds a box that does not fit against the screen edge, so touching it counts too.
8020
+ function opened(event) {
8021
+ internals.states.delete("flipped");
8022
+ if (event.newState !== "open" || !BLOCK_SIDES.has(props.placement)) return;
8023
+ const box = host.getBoundingClientRect();
8024
+ if (box.bottom >= innerHeight || box.top <= 0) internals.states.add("flipped");
8025
+ }
8026
+
7885
8027
  attrs.popover ??= "manual";
8028
+
8029
+ listen("toggle", opened);
7886
8030
  </script>
7887
8031
 
7888
8032
  <style>
@@ -7909,6 +8053,17 @@
7909
8053
  position-area: bottom;
7910
8054
  margin: calc(var(--spacing) * 1) 0 0;
7911
8055
  }
8056
+ :host(:popover-open) {
8057
+ position-try-fallbacks: none;
8058
+ }
8059
+ :host(:state(flipped)) {
8060
+ position-area: bottom;
8061
+ margin: calc(var(--spacing) * 1) 0 0;
8062
+ }
8063
+ :host(:state(flipped):state(attr-placement-is-bottom)) {
8064
+ position-area: top;
8065
+ margin: 0 0 calc(var(--spacing) * 1);
8066
+ }
7912
8067
  :host(:state(attr-placement-is-left)) {
7913
8068
  position-area: left;
7914
8069
  margin: 0 calc(var(--spacing) * 1) 0 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realmorg/ui",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.4",
4
4
  "description": "About 60 accessible UI components built with REALM.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,16 +22,17 @@
22
22
  "devDependencies": {
23
23
  "@vitest/browser-playwright": "5.0.5",
24
24
  "playwright": "1.64.1",
25
+ "typescript": "7.0.2",
25
26
  "vite": "8.3.4",
26
27
  "vitest": "5.0.5",
27
- "@realmorg/realm": "2.0.0-alpha.3",
28
+ "@realmorg/realm": "2.0.0-alpha.5",
28
29
  "tsconfig": "0.0.0"
29
30
  },
30
31
  "publishConfig": {
31
32
  "access": "public"
32
33
  },
33
34
  "peerDependencies": {
34
- "@realmorg/realm": "^2.0.0-alpha.3"
35
+ "@realmorg/realm": "^2.0.0-alpha.5"
35
36
  },
36
37
  "scripts": {
37
38
  "build": "node scripts/bundle.mjs",