@oscarpalmer/oui 0.16.0 → 0.17.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.
@@ -1,3 +1,5 @@
1
+ import { isNullableOrWhitespace } from "@oscarpalmer/atoms/is";
2
+ import { round } from "@oscarpalmer/atoms/math";
1
3
  import { clamp } from "@oscarpalmer/atoms/number";
2
4
  import { on, getPosition } from "@oscarpalmer/toretto/event";
3
5
  import { findAncestor } from "@oscarpalmer/toretto/find";
@@ -24,49 +26,59 @@ class OuiSplitterElement extends HTMLElement {
24
26
  }
25
27
  }
26
28
  }
29
+ let index = 0;
27
30
  class Splitter {
28
31
  constructor(element, panels) {
29
32
  this.element = element;
30
33
  this.panels = panels;
34
+ if (isNullableOrWhitespace(element.id)) {
35
+ element.id = `oui_splitter_${++index}`;
36
+ }
37
+ this.values = getValues(element);
31
38
  this.handle = createHandle();
32
39
  this.rectangle = element.getBoundingClientRect();
33
- this.separator = createSeparator();
40
+ this.separator = createSeparator(this);
34
41
  this.separator.append(this.handle);
35
42
  panels[0].insertAdjacentElement("afterend", this.separator);
36
43
  mapped.set(element, this);
37
44
  mapped.set(this.handle, this);
38
- setSize(this, this.flex.value);
45
+ setSize(this, this.values.now.current);
39
46
  this.types = getTypes(this);
40
47
  if (this.types.horizontal) {
41
48
  this.element.setAttribute("oui-splitter-horizontal", "");
42
49
  }
50
+ setAriaOrientation(this);
51
+ setAriaValue(this);
43
52
  }
44
- flex = {
45
- previous: 0.5,
46
- value: 0.5
47
- };
48
53
  handle;
49
54
  rectangle;
50
55
  separator;
51
56
  types;
57
+ values;
52
58
  }
53
59
  function createHandle() {
54
60
  const handle = document.createElement("span");
55
- handle.tabIndex = 0;
56
- handle.setAttribute("aria-label", "Resize panels");
61
+ handle.setAttribute("aria-hidden", "true");
57
62
  handle.setAttribute("oui-splitter-handle", "");
58
63
  return handle;
59
64
  }
60
- function createSeparator() {
65
+ function createSeparator(splitter2) {
61
66
  const separator = document.createElement("div");
67
+ separator.role = "separator";
68
+ separator.tabIndex = 0;
69
+ separator.setAttribute("aria-controls", splitter2.element.id);
70
+ separator.setAttribute("aria-label", "Resize panels");
62
71
  separator.setAttribute("oui-splitter-separator", "");
63
72
  return separator;
64
73
  }
65
- function getFlex(splitter2, value) {
74
+ function getContained(min, value, max) {
75
+ return round(clamp(value, min, max), 6);
76
+ }
77
+ function getSize(splitter2, value) {
66
78
  const { height, left, top, width } = splitter2.rectangle;
67
79
  const offset = value - (splitter2.types.horizontal ? top : left);
68
80
  const fraction = offset / (splitter2.types.horizontal ? height : width);
69
- return clamp(fraction, 0.1, 0.9);
81
+ return getContained(splitter2.values.min, fraction, splitter2.values.max);
70
82
  }
71
83
  function getTypes(splitter2, width, height) {
72
84
  const type = splitter2.element.getAttribute("type")?.trim() ?? "";
@@ -87,14 +99,43 @@ function getTypes(splitter2, width, height) {
87
99
  horizontal: false
88
100
  };
89
101
  }
102
+ function getValue(element, name, defaultValue) {
103
+ const value = element.getAttribute(name);
104
+ if (isNullableOrWhitespace(value)) {
105
+ return defaultValue;
106
+ }
107
+ const parsed = Number.parseFloat(value);
108
+ if (Number.isNaN(parsed)) {
109
+ return defaultValue;
110
+ }
111
+ return parsed < 0 ? defaultValue : parsed > 1 ? parsed / 100 : parsed;
112
+ }
113
+ function getValues(element) {
114
+ const values = {
115
+ max: getValue(element, "max", 0.9),
116
+ min: getValue(element, "min", 0.1),
117
+ now: {
118
+ current: 0.5,
119
+ previous: 0.5
120
+ }
121
+ };
122
+ const value = getContained(
123
+ values.min,
124
+ getValue(element, "size", values.now.current),
125
+ values.max
126
+ );
127
+ values.now.current = value;
128
+ values.now.previous = value;
129
+ return values;
130
+ }
90
131
  function onEnd(reset) {
91
132
  if (splitter != null) {
92
133
  splitter.handle.removeAttribute("oui-splitter-active");
93
134
  splitter.separator.removeAttribute("oui-splitter-active");
94
135
  if (reset) {
95
- setSize(splitter, splitter.flex.previous);
136
+ setSize(splitter, splitter.values.now.previous);
96
137
  } else {
97
- splitter.flex.previous = splitter.flex.value;
138
+ splitter.values.now.previous = splitter.values.now.current;
98
139
  }
99
140
  }
100
141
  splitter = void 0;
@@ -129,11 +170,11 @@ function onMousemove(event) {
129
170
  }
130
171
  const position = getPosition(event);
131
172
  if (position != null) {
132
- splitter.flex.value = getFlex(
173
+ splitter.values.now.current = getSize(
133
174
  splitter,
134
175
  splitter.types.horizontal ? position.y : position.x
135
176
  );
136
- setSize(splitter, splitter.flex.value);
177
+ setSize(splitter, splitter.values.now.current);
137
178
  }
138
179
  }
139
180
  function onMouseup() {
@@ -152,14 +193,18 @@ function onNavigate(event) {
152
193
  return;
153
194
  }
154
195
  if (absoluteKeys.has(event.key)) {
155
- setSize(splitter2, event.key === "End" ? 0.9 : 0.1, true);
196
+ setSize(
197
+ splitter2,
198
+ event.key === "End" ? splitter2.values.max : splitter2.values.min,
199
+ true
200
+ );
156
201
  return;
157
202
  }
158
203
  if (splitter2.types.horizontal && !horizontalKeys.has(event.key) || !splitter2.types.horizontal && !verticalKeys.has(event.key)) {
159
204
  return;
160
205
  }
161
206
  const modifier = negativeKeys.has(event.key) ? -1 : 1;
162
- setSize(splitter2, splitter2.flex.value + modifier * 0.05, true);
207
+ setSize(splitter2, splitter2.values.now.current + modifier * 0.05, true);
163
208
  }
164
209
  function onObservation(entries) {
165
210
  if (frame != null) {
@@ -181,16 +226,32 @@ function onObservation(entries) {
181
226
  } else {
182
227
  splitter2.element.removeAttribute("oui-splitter-horizontal");
183
228
  }
229
+ setAriaOrientation(splitter2);
184
230
  }
185
231
  frame = void 0;
186
232
  });
187
233
  }
234
+ function setAriaOrientation(splitter2) {
235
+ splitter2.separator.setAttribute(
236
+ "aria-orientation",
237
+ splitter2.types.horizontal ? "horizontal" : "vertical"
238
+ );
239
+ }
240
+ function setAriaValue(splitter2) {
241
+ splitter2.separator.setAttribute("aria-valuemax", String(splitter2.values.max));
242
+ splitter2.separator.setAttribute("aria-valuemin", String(splitter2.values.min));
243
+ splitter2.separator.setAttribute(
244
+ "aria-valuenow",
245
+ String(splitter2.values.now.current)
246
+ );
247
+ }
188
248
  function setSize(splitter2, size, previous) {
189
- splitter2.flex.value = size;
249
+ splitter2.values.now.current = size;
190
250
  if (previous) {
191
- splitter2.flex.previous = size;
251
+ splitter2.values.now.previous = size;
192
252
  }
193
253
  splitter2.element.style.setProperty("--oui-splitter-size", `${size}`);
254
+ setAriaValue(splitter2);
194
255
  }
195
256
  const absoluteKeys = /* @__PURE__ */ new Set(["End", "Home"]);
196
257
  const horizontalKeys = /* @__PURE__ */ new Set(["ArrowDown", "ArrowUp"]);
@@ -9,7 +9,13 @@ class Tooltip {
9
9
  floatable;
10
10
  constructor(anchor, content) {
11
11
  this.attributes = content.attributes;
12
- this.floatable = new floatable.Floatable(anchor, content.element);
12
+ this.floatable = new floatable.Floatable(
13
+ anchor,
14
+ content.element,
15
+ `${selector}-position`,
16
+ "vertical",
17
+ true
18
+ );
13
19
  initialize(this);
14
20
  }
15
21
  destroy() {
@@ -8,7 +8,13 @@ class Tooltip {
8
8
  floatable;
9
9
  constructor(anchor, content) {
10
10
  this.attributes = content.attributes;
11
- this.floatable = new Floatable(anchor, content.element);
11
+ this.floatable = new Floatable(
12
+ anchor,
13
+ content.element,
14
+ `${selector}-position`,
15
+ "vertical",
16
+ true
17
+ );
12
18
  initialize(this);
13
19
  }
14
20
  destroy() {
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "@biomejs/biome": "^2.1",
13
13
  "@rollup/plugin-node-resolve": "^16",
14
14
  "@rollup/plugin-typescript": "^12.1",
15
- "@types/node": "^24",
15
+ "@types/node": "^24.1",
16
16
  "@vitest/coverage-istanbul": "^3.2",
17
17
  "autoprefixer": "^10.4",
18
18
  "dts-bundle-generator": "^9.5",
@@ -114,5 +114,5 @@
114
114
  },
115
115
  "type": "module",
116
116
  "types": "types/index.d.cts",
117
- "version": "0.16.0"
117
+ "version": "0.17.0"
118
118
  }
@@ -6,4 +6,8 @@
6
6
  padding: 1rem;
7
7
  background-color: var(--oui-absolute);
8
8
  border-radius: 0.5rem;
9
+
10
+ &:focus {
11
+ outline: none;
12
+ }
9
13
  }
@@ -27,7 +27,7 @@ oui-splitter {
27
27
  :where([oui-splitter-separator]:has([oui-splitter-handle]:focus)),
28
28
  :where([oui-splitter-separator]:has([oui-splitter-handle]:hover)),
29
29
  :where([oui-splitter-separator][oui-splitter-active]) {
30
- background-color: rgba(0, 0, 255, .1);
30
+ background-color: color-mix(in oklab, var(--oui-blue-6), transparent 82.5%);
31
31
  }
32
32
 
33
33
  :where([oui-splitter-handle]) {
@@ -60,12 +60,12 @@ oui-splitter {
60
60
  :where([oui-splitter-handle]:hover),
61
61
  :where([oui-splitter-handle][oui-splitter-active]) {
62
62
  &::after {
63
- background-color: rgba(0, 0, 255, .25);
63
+ background-color: color-mix(in oklab, var(--oui-blue-6), transparent 50%);
64
64
  }
65
65
  }
66
66
 
67
67
  :where([oui-splitter-handle]:focus-visible)::after {
68
- outline-color: rgba(0, 0, 255, .25);
68
+ outline-color: color-mix(in oklab, var(--oui-blue-6), transparent 50%);
69
69
  }
70
70
 
71
71
  oui-splitter[type='horizontal' i],
@@ -1,12 +1,18 @@
1
1
  export class Floatable {
2
2
  activate: number | undefined;
3
+
3
4
  active = false;
5
+
4
6
  deactivate: number | undefined;
7
+
5
8
  frame: DOMHighResTimeStamp | undefined;
6
9
 
7
10
  constructor(
8
11
  public anchor: HTMLElement,
9
12
  public content: HTMLElement,
13
+ readonly attribute: string,
14
+ readonly position: Position,
15
+ readonly preferAbove: boolean,
10
16
  ) {
11
17
  setStyles(content);
12
18
  }
@@ -21,6 +27,28 @@ export class Floatable {
21
27
  }
22
28
  }
23
29
 
30
+ type Position =
31
+ | 'above'
32
+ | 'above-end'
33
+ | 'above-start'
34
+ | 'below'
35
+ | 'below-end'
36
+ | 'below-start'
37
+ | 'end'
38
+ | 'end-bottom'
39
+ | 'end-top'
40
+ | 'horizontal'
41
+ | 'horizontal-bottom'
42
+ | 'horizontal-top'
43
+ | 'start'
44
+ | 'start-bottom'
45
+ | 'start-top'
46
+ | 'vertical'
47
+ | 'vertical-end'
48
+ | 'vertical-start';
49
+
50
+ //
51
+
24
52
  export function activate(
25
53
  floatable: Floatable,
26
54
  active: Set<Floatable>,
@@ -63,48 +91,78 @@ export function deactivate(
63
91
  }
64
92
  }
65
93
 
66
- function getX(
67
- anchor: DOMRect,
68
- content: DOMRect,
69
- ): {left?: number; right?: number} {
70
- if (content.width + 2 * margin >= window.innerWidth) {
71
- return {
72
- left: margin,
73
- right: margin,
74
- };
94
+ function getX(anchor: DOMRect, content: DOMRect, position: Position): number {
95
+ if (position.endsWith('-end')) {
96
+ const end = anchor.right - content.width;
97
+
98
+ return end - margin < 0 ? margin : end;
75
99
  }
76
100
 
77
- const left = anchor.left + (anchor.width - content.width) / 2;
78
- const right = window.innerWidth - margin;
101
+ if (position.endsWith('-start')) {
102
+ const start = anchor.left;
79
103
 
80
- if (left < margin) {
81
- return {
82
- left: margin,
83
- };
104
+ return start + content.width + margin > window.innerWidth
105
+ ? window.innerWidth - content.width - margin
106
+ : start;
84
107
  }
85
108
 
86
- if (left + content.width > right) {
87
- return {
88
- right: margin,
89
- };
109
+ if (position.startsWith('end')) {
110
+ return anchor.right + margin;
90
111
  }
91
112
 
92
- return {
93
- left,
94
- };
113
+ if (position.startsWith('start')) {
114
+ return anchor.left - content.width - margin;
115
+ }
116
+
117
+ if (position.startsWith('horizontal')) {
118
+ if (anchor.right + content.width + margin <= window.innerWidth) {
119
+ return anchor.right + margin;
120
+ }
121
+
122
+ return anchor.left - content.width - margin;
123
+ }
124
+
125
+ return anchor.left + (anchor.width - content.width) / 2;
95
126
  }
96
127
 
97
- function getY(anchor: DOMRect, content: DOMRect): number {
98
- let top = anchor.top - content.height - margin;
128
+ function getY(
129
+ anchor: DOMRect,
130
+ content: DOMRect,
131
+ position: Position,
132
+ preferAbove: boolean,
133
+ ): number {
134
+ if (position.startsWith('above')) {
135
+ return anchor.top - content.height - margin;
136
+ }
137
+
138
+ if (position.startsWith('below')) {
139
+ return anchor.bottom + margin;
140
+ }
141
+
142
+ if (position.endsWith('bottom')) {
143
+ const bottom = anchor.bottom - content.height;
99
144
 
100
- if (top - margin >= 0) {
101
- return top;
145
+ return bottom + margin < 0 ? margin : bottom;
102
146
  }
103
147
 
104
- top = anchor.bottom + margin;
148
+ if (position.endsWith('top')) {
149
+ const top = anchor.top;
150
+
151
+ return top + content.height + margin > window.innerHeight
152
+ ? window.innerHeight - content.height - margin
153
+ : top;
154
+ }
155
+
156
+ if (position.startsWith('vertical')) {
157
+ if (preferAbove && anchor.top - content.height - margin >= 0) {
158
+ return anchor.top - content.height - margin;
159
+ }
160
+
161
+ if (anchor.bottom + content.height + margin <= document.body.clientHeight) {
162
+ return anchor.bottom + margin;
163
+ }
105
164
 
106
- if (top + content.height + margin <= window.innerHeight) {
107
- return top;
165
+ return anchor.top - content.height - margin;
108
166
  }
109
167
 
110
168
  return anchor.top + (anchor.height - content.height) / 2;
@@ -151,17 +209,25 @@ function onDeactivate(
151
209
  }
152
210
 
153
211
  function update(floatable: Floatable): void {
212
+ let position: Position =
213
+ (floatable.anchor.getAttribute(floatable.attribute)?.trim() as Position) ??
214
+ floatable.position;
215
+
216
+ if (!positions.has(position as Position)) {
217
+ position = floatable.position;
218
+ }
219
+
154
220
  function run(): void {
155
- const anchor = floatable.anchor.getBoundingClientRect();
156
- const content = floatable.content.getBoundingClientRect();
221
+ const anchor = floatable.anchor.getBoundingClientRect();
222
+ const content = floatable.content.getBoundingClientRect();
157
223
 
158
- const {left, right} = getX(anchor, content);
159
- const top = getY(anchor, content);
224
+ const left = getX(anchor, content, position);
225
+ const top = getY(anchor, content, position, floatable.preferAbove);
160
226
 
161
- floatable.content.style.inset = `${top}px ${right == null ? 'auto' : `${right}px`} auto ${left == null ? 'auto' : `${left}px`}`;
227
+ floatable.content.style.inset = `${top}px auto auto ${left}px`;
162
228
 
163
- floatable.frame = requestAnimationFrame(run);
164
- }
229
+ floatable.frame = requestAnimationFrame(run);
230
+ }
165
231
 
166
232
  floatable.frame = requestAnimationFrame(run);
167
233
  }
@@ -194,3 +260,24 @@ function stop(floatable: Floatable): void {
194
260
 
195
261
  const margin =
196
262
  Number.parseInt(getComputedStyle(document.documentElement).fontSize, 10) / 2;
263
+
264
+ const positions = new Set<Position>([
265
+ 'above',
266
+ 'above-end',
267
+ 'above-start',
268
+ 'below',
269
+ 'below-end',
270
+ 'below-start',
271
+ 'end',
272
+ 'end-bottom',
273
+ 'end-top',
274
+ 'horizontal',
275
+ 'horizontal-bottom',
276
+ 'horizontal-top',
277
+ 'start',
278
+ 'start-bottom',
279
+ 'start-top',
280
+ 'vertical',
281
+ 'vertical-end',
282
+ 'vertical-start',
283
+ ]);
package/src/js/popover.ts CHANGED
@@ -50,7 +50,14 @@ export class OuiPopoverElement extends HTMLElement {
50
50
  toggle.setAttribute('aria-controls', content.id);
51
51
  toggle.setAttribute('aria-haspopup', 'dialog');
52
52
 
53
- this.#floatable = new Floatable(toggle, content);
53
+ this.#floatable = new Floatable(
54
+ toggle,
55
+ content,
56
+ 'position',
57
+ 'below-start',
58
+ false,
59
+ );
60
+
54
61
  this.#focusTrap = createFocusTrap(content);
55
62
 
56
63
  mapped.set(toggle, this.#floatable);