@oscarpalmer/oui 0.12.0 → 0.13.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.
@@ -0,0 +1,202 @@
1
+ import { isNullableOrWhitespace } from "@oscarpalmer/atoms/is";
2
+ import { on } from "@oscarpalmer/toretto/event";
3
+ import { attributable } from "./attributable.js";
4
+ class Tooltip {
5
+ constructor(parent) {
6
+ this.parent = parent;
7
+ this.element = getContent(parent);
8
+ if (this.element != null) {
9
+ parent.insertAdjacentElement("afterend", this.element);
10
+ initialize(this);
11
+ }
12
+ }
13
+ abortController = new AbortController();
14
+ activate;
15
+ active = false;
16
+ deactivate;
17
+ element;
18
+ frame;
19
+ destroy() {
20
+ deactivate(this);
21
+ this.abortController.abort();
22
+ this.abortController = void 0;
23
+ this.activate = void 0;
24
+ this.deactivate = void 0;
25
+ this.element = void 0;
26
+ this.frame = void 0;
27
+ this.parent = void 0;
28
+ }
29
+ }
30
+ function activate(tooltip) {
31
+ stop(tooltip);
32
+ if (tooltip.active) {
33
+ return;
34
+ }
35
+ tooltip.activate = +setTimeout(() => {
36
+ tooltip.active = true;
37
+ active.add(tooltip);
38
+ document.body.append(tooltip.element);
39
+ tooltip.element.hidden = false;
40
+ update(tooltip);
41
+ }, 250);
42
+ }
43
+ function addTooltip(element) {
44
+ if (!tooltips.has(element)) {
45
+ tooltips.set(element, new Tooltip(element));
46
+ }
47
+ }
48
+ function deactivate(tooltip, time) {
49
+ stop(tooltip);
50
+ if (!tooltip.active) {
51
+ return;
52
+ }
53
+ tooltip.deactivate = +setTimeout(() => {
54
+ tooltip.active = false;
55
+ active.delete(tooltip);
56
+ tooltip.element.hidden = true;
57
+ tooltip.parent.insertAdjacentElement("afterend", tooltip.element);
58
+ }, time ?? 250);
59
+ }
60
+ function getContent(parent) {
61
+ const wrapper = getWrapper(parent);
62
+ const ids = (parent.getAttribute("aria-describedby") ?? "").split(/\s+/);
63
+ parent.setAttribute("aria-describedby", wrapper.id);
64
+ const elements = ids.map(
65
+ (id) => isNullableOrWhitespace(id) ? void 0 : document.querySelector(`#${id}`)
66
+ ).filter((element) => element instanceof HTMLElement);
67
+ if (elements.length > 0) {
68
+ for (const element of elements) {
69
+ element.hidden = false;
70
+ wrapper.append(element);
71
+ }
72
+ return wrapper;
73
+ }
74
+ let content = parent.getAttribute("aria-description");
75
+ if (isNullableOrWhitespace(content)) {
76
+ content = parent.getAttribute("aria-label");
77
+ if (isNullableOrWhitespace(content)) {
78
+ return;
79
+ }
80
+ }
81
+ const paragraph = document.createElement("p");
82
+ paragraph.textContent = content;
83
+ wrapper.append(paragraph);
84
+ return wrapper;
85
+ }
86
+ function getTop(parent, content) {
87
+ let top = parent.top - content.height - margin;
88
+ if (top - margin >= 0) {
89
+ return top;
90
+ }
91
+ top = parent.bottom + margin;
92
+ if (top + content.height + margin <= window.innerHeight) {
93
+ return top;
94
+ }
95
+ return parent.top + (parent.height - content.height) / 2;
96
+ }
97
+ function getWrapper(parent) {
98
+ const wrapper = document.createElement("div");
99
+ wrapper.setAttribute(`${selector}-content`, "");
100
+ wrapper.className = parent.getAttribute(`${selector}-class`) ?? "";
101
+ wrapper.hidden = true;
102
+ wrapper.id = `${selector}-${++index}`;
103
+ wrapper.role = "tooltip";
104
+ wrapper.tabIndex = -1;
105
+ return wrapper;
106
+ }
107
+ function getX(parent, content) {
108
+ if (content.width + 2 * margin >= window.innerWidth) {
109
+ return {
110
+ left: margin,
111
+ right: margin
112
+ };
113
+ }
114
+ const left = parent.left + (parent.width - content.width) / 2;
115
+ const right = window.innerWidth - margin;
116
+ if (left < margin) {
117
+ return {
118
+ left: margin
119
+ };
120
+ }
121
+ if (left + content.width > right) {
122
+ return {
123
+ right: margin
124
+ };
125
+ }
126
+ return {
127
+ left
128
+ };
129
+ }
130
+ function initialize(tooltip) {
131
+ const elements = [tooltip.parent, tooltip.element];
132
+ for (const element of elements) {
133
+ for (const event of activateEvents) {
134
+ on(
135
+ element,
136
+ event,
137
+ () => {
138
+ activate(tooltip);
139
+ },
140
+ {
141
+ signal: tooltip.abortController.signal
142
+ }
143
+ );
144
+ }
145
+ for (const event of deactivateEvents) {
146
+ on(
147
+ element,
148
+ event,
149
+ () => {
150
+ deactivate(tooltip);
151
+ },
152
+ {
153
+ signal: tooltip.abortController.signal
154
+ }
155
+ );
156
+ }
157
+ }
158
+ }
159
+ function removeTooltip(element) {
160
+ tooltips.get(element)?.destroy();
161
+ tooltips.delete(element);
162
+ }
163
+ function stop(tooltip) {
164
+ if (tooltip.frame != null) {
165
+ cancelAnimationFrame(tooltip.frame);
166
+ tooltip.frame = void 0;
167
+ }
168
+ if (tooltip.activate != null) {
169
+ clearTimeout(tooltip.activate);
170
+ tooltip.activate = void 0;
171
+ }
172
+ if (tooltip.deactivate != null) {
173
+ clearTimeout(tooltip.deactivate);
174
+ tooltip.deactivate = void 0;
175
+ }
176
+ }
177
+ function update(tooltip) {
178
+ function run() {
179
+ const parentRect = tooltip.parent.getBoundingClientRect();
180
+ const contentRect = tooltip.element.getBoundingClientRect();
181
+ const top = getTop(parentRect, contentRect);
182
+ const { left, right } = getX(parentRect, contentRect);
183
+ tooltip.element.style.inset = `${top}px ${right == null ? "auto" : `${right}px`} auto ${left == null ? "auto" : `${left}px`}`;
184
+ tooltip.frame = requestAnimationFrame(run);
185
+ }
186
+ tooltip.frame = requestAnimationFrame(run);
187
+ }
188
+ const active = /* @__PURE__ */ new Set();
189
+ const activateEvents = ["focus", "mouseenter", "touchstart"];
190
+ const deactivateEvents = ["blur", "mouseleave"];
191
+ const margin = Number.parseInt(getComputedStyle(document.documentElement).fontSize, 10) / 4;
192
+ const selector = "oui-tooltip";
193
+ const tooltips = /* @__PURE__ */ new WeakMap();
194
+ let index = 0;
195
+ attributable(selector, addTooltip, removeTooltip);
196
+ on(document, "keydown", (event) => {
197
+ if (event.key === "Escape") {
198
+ for (const tooltip of active) {
199
+ deactivate(tooltip, 0);
200
+ }
201
+ }
202
+ });
package/package.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "url": "https://oscarpalmer.se"
5
5
  },
6
6
  "dependencies": {
7
- "@oscarpalmer/atoms": "^0.102",
7
+ "@oscarpalmer/atoms": "^0.104",
8
8
  "@oscarpalmer/toretto": "^0.20"
9
9
  },
10
10
  "description": "Styles, components, and utilities for building websites and applications.",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2",
12
+ "@biomejs/biome": "^2.1",
13
13
  "@rollup/plugin-node-resolve": "^16",
14
14
  "@rollup/plugin-typescript": "^12.1",
15
15
  "@types/node": "^24",
@@ -25,7 +25,7 @@
25
25
  "stylelint": "^16.21",
26
26
  "tslib": "^2.8",
27
27
  "typescript": "^5.8",
28
- "vite": "^6.3",
28
+ "vite": "^7",
29
29
  "vitest": "^3.2"
30
30
  },
31
31
  "exports": {
@@ -48,6 +48,16 @@
48
48
  "types": "./types/focus-trap.d.cts",
49
49
  "default": "./dist/js/focus-trap.cjs"
50
50
  }
51
+ },
52
+ "./tooltip": {
53
+ "import": {
54
+ "types": "./types/tooltip.d.ts",
55
+ "default": "./dist/js/tooltip.js"
56
+ },
57
+ "require": {
58
+ "types": "./types/tooltip.d.cts",
59
+ "default": "./dist/js/tooltip.cjs"
60
+ }
51
61
  }
52
62
  },
53
63
  "files": ["dist", "src", "types"],
@@ -75,7 +85,7 @@
75
85
  "build:postcss": "npx postcss ./src/css/*.scss -d ./postcss -u autoprefixer postcss-input-range --parser postcss-scss",
76
86
  "build:js": "npx vite build && npm run types",
77
87
  "clean": "rm -rf ./dist && rm -rf ./types && rm -f ./tsconfig.tsbuildinfo",
78
- "rollup": "npx rollup -c --watch",
88
+ "rollup": "npx rollup -c",
79
89
  "test": "npx vitest --coverage",
80
90
  "types": "npx tsc && npx dts-bundle-generator --config ./dts.config.cts --silent",
81
91
  "watch:css": "npx sass ./postcss:./dist/css --no-source-map --watch",
@@ -84,5 +94,5 @@
84
94
  },
85
95
  "type": "module",
86
96
  "types": "types/index.d.cts",
87
- "version": "0.12.0"
97
+ "version": "0.13.0"
88
98
  }
@@ -37,11 +37,18 @@
37
37
  :where(&-pill-tiny, &-tag-tiny) {
38
38
  color: color-mix(in oklab, var(--oui-foreground, var(--oui-gray-8)), transparent 12.5%);
39
39
  font-size: .75em;
40
- font-weight: bold;
41
- letter-spacing: .1em;
42
- text-transform: uppercase;
43
40
  }
44
41
 
42
+ :where(&-pill-tiny) {
43
+ padding: .25em .75em;
44
+ }
45
+
46
+ :where(&-tag-tiny) {
47
+ padding: .25em .5em;
48
+ }
49
+
50
+ // Colors
51
+
45
52
  :where(&-pill-blue, &-tag-blue) {
46
53
  --oui-background: var(--oui-blue-0);
47
54
  --oui-foreground: var(--oui-blue-8);
@@ -53,11 +53,24 @@
53
53
  }
54
54
 
55
55
  @mixin shadow($color: rgba(0, 0, 0, 0.05), $inset: transparent) {
56
+ box-shadow:
57
+ inset 0 1px 0 0 $inset,
58
+ 0 0 0 1px $color,
59
+ 0 1px 2px -.5px $color,
60
+ 0 2px 4px -1px $color;
61
+ }
62
+
63
+ @mixin shadowOverlay($color: rgba(0, 0, 0, 0.05), $inset: transparent) {
56
64
  box-shadow:
57
65
  inset 0 1px 0 0 $inset,
58
66
  0 0 0 1px $color,
59
67
  0 1px 2px -.5px $color,
60
68
  0 2px 4px -1px $color,
69
+ 0 4px 8px -2px $color,
70
+ 0 8px 16px -4px $color,
71
+ 0 16px 32px -8px $color,
72
+ 0 32px 64px -16px $color,
73
+ 0 64px 128px -32px $color;
61
74
  }
62
75
 
63
76
  @mixin visuallyHidden() {
@@ -0,0 +1,14 @@
1
+ @use 'mixins.scss' as mixins;
2
+
3
+ :where([oui-tooltip-content]) {
4
+ @include mixins.shadowOverlay;
5
+
6
+ max-width: 48ch;
7
+ padding: 0.75em 1em;
8
+ background-color: var(--oui-absolute);
9
+ border-radius: 0.25rem;
10
+ font-size: 0.875rem;
11
+ position: fixed;
12
+ inset: 0 auto auto 0;
13
+ z-index: 10;
14
+ }
@@ -0,0 +1,70 @@
1
+ export function attributable(
2
+ attribute: string,
3
+ add: (element: HTMLElement) => void,
4
+ remove: (element: HTMLElement) => void,
5
+ ): void {
6
+ const observer = new MutationObserver(entries => {
7
+ const {length} = entries;
8
+
9
+ for (let index = 0; index < length; index += 1) {
10
+ const entry = entries[index];
11
+
12
+ if (entry.type === 'attributes' && entry.target instanceof HTMLElement) {
13
+ if (entry.target.hasAttribute(attribute)) {
14
+ handleAdded(attribute, [entry.target], add);
15
+ } else {
16
+ handleRemoved([entry.target], remove);
17
+ }
18
+ } else if (entry.type === 'childList') {
19
+ handleAdded(attribute, entry.addedNodes, add);
20
+ handleRemoved(entry.removedNodes, remove);
21
+ }
22
+ }
23
+ });
24
+
25
+ observer.observe(document, {
26
+ attributeFilter: [attribute],
27
+ attributes: true,
28
+ childList: true,
29
+ subtree: true,
30
+ });
31
+
32
+ requestAnimationFrame(() => {
33
+ handleAdded(attribute, document.querySelectorAll(`[${attribute}]`), add);
34
+ });
35
+ }
36
+
37
+ function handleAdded(
38
+ attribute: string,
39
+ nodes: Node[] | NodeList,
40
+ add: (element: HTMLElement) => void,
41
+ ): void {
42
+ const elements = [...nodes].filter(
43
+ node => node instanceof HTMLElement,
44
+ ) as HTMLElement[];
45
+
46
+ const {length} = elements;
47
+
48
+ for (let index = 0; index < length; index += 1) {
49
+ const element = elements[index];
50
+
51
+ if (element.hasAttribute(attribute)) {
52
+ add(element);
53
+ }
54
+ }
55
+ }
56
+
57
+ function handleRemoved(
58
+ nodes: Node[] | NodeList,
59
+ remove: (element: HTMLElement) => void,
60
+ ): void {
61
+ const elements = [...nodes].filter(
62
+ node => node instanceof HTMLElement,
63
+ ) as HTMLElement[];
64
+
65
+ const {length} = elements;
66
+
67
+ for (let index = 0; index < length; index += 1) {
68
+ remove(elements[index]);
69
+ }
70
+ }
@@ -2,6 +2,7 @@ import {clamp} from '@oscarpalmer/atoms/number';
2
2
  import {on} from '@oscarpalmer/toretto/event';
3
3
  import {findAncestor} from '@oscarpalmer/toretto/find';
4
4
  import {getFocusable} from '@oscarpalmer/toretto/focusable';
5
+ import {attributable} from './attributable';
5
6
 
6
7
  class FocusTrap {
7
8
  active = true;
@@ -31,19 +32,9 @@ class FocusTrap {
31
32
  }
32
33
  }
33
34
 
34
- function addFocusTraps(nodes: Node[] | NodeList): void {
35
- const elements = [...nodes].filter(
36
- node => node instanceof HTMLElement,
37
- ) as HTMLElement[];
38
-
39
- const {length} = elements;
40
-
41
- for (let index = 0; index < length; index += 1) {
42
- const element = elements[index];
43
-
44
- if (element.hasAttribute(selector)) {
45
- focusTraps.set(element, new FocusTrap(element));
46
- }
35
+ function addFocusTrap(element: HTMLElement): void {
36
+ if (!focusTraps.has(element)) {
37
+ focusTraps.set(element, new FocusTrap(element));
47
38
  }
48
39
  }
49
40
 
@@ -117,65 +108,26 @@ function onTab(event: KeyboardEvent): void {
117
108
  }
118
109
  }
119
110
 
120
- function removeFocusTraps(nodes: Node[] | NodeList): void {
121
- const elements = [...nodes].filter(
122
- node => node instanceof HTMLElement,
123
- ) as HTMLElement[];
124
-
125
- const {length} = elements;
126
-
127
- for (let index = 0; index < length; index += 1) {
128
- const element = elements[index];
129
-
130
- focusTraps.get(element)?.destroy();
131
- focusTraps.delete(element);
132
- }
111
+ function removeFocusTraps(element: HTMLElement): void {
112
+ focusTraps.get(element)?.destroy();
113
+ focusTraps.delete(element);
133
114
  }
134
115
 
135
116
  const focusTraps = new WeakMap<HTMLElement, FocusTrap>();
136
117
  const inactive = new Set<FocusTrap>();
118
+ const selector = 'oui-focus-trap';
137
119
 
138
- const observer = new MutationObserver(entries => {
139
- const {length} = entries;
140
-
141
- for (let index = 0; index < length; index += 1) {
142
- const entry = entries[index];
120
+ attributable(selector, addFocusTrap, removeFocusTraps);
143
121
 
144
- if (entry.type === 'attributes' && entry.target instanceof HTMLElement) {
145
- if (entry.target.hasAttribute(selector)) {
146
- addFocusTraps([entry.target]);
147
- } else {
148
- removeFocusTraps([entry.target]);
149
- }
150
- } else if (entry.type === 'childList') {
151
- addFocusTraps(entry.addedNodes);
152
- removeFocusTraps(entry.removedNodes);
153
- }
154
- }
122
+ on(document, 'focusout', onFocusOut, {
123
+ capture: true,
155
124
  });
156
125
 
157
- const selector = 'oui-focus-trap';
158
-
159
- observer.observe(document, {
160
- attributeFilter: [selector],
161
- attributes: true,
162
- childList: true,
163
- subtree: true,
126
+ on(document, 'keydown', onTab, {
127
+ capture: true,
128
+ passive: false,
164
129
  });
165
130
 
166
- requestAnimationFrame(() => {
167
- addFocusTraps(document.querySelectorAll(`[${selector}]`));
168
-
169
- on(document, 'focusout', onFocusOut, {
170
- capture: true,
171
- });
172
-
173
- on(document, 'keydown', onTab, {
174
- capture: true,
175
- passive: false,
176
- });
177
-
178
- on(document, 'keydown', onEscape, {
179
- passive: false,
180
- });
131
+ on(document, 'keydown', onEscape, {
132
+ passive: false,
181
133
  });