@oscarpalmer/oui 0.12.0 → 0.14.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,161 @@
1
+ "use strict";
2
+ const is = require("@oscarpalmer/atoms/is");
3
+ const event = require("@oscarpalmer/toretto/event");
4
+ const attributable = require("./attributable.cjs");
5
+ const floatable = require("./floatable.cjs");
6
+ class Tooltip {
7
+ abortController = new AbortController();
8
+ attributes;
9
+ floatable;
10
+ constructor(anchor, content) {
11
+ this.attributes = content.attributes;
12
+ this.floatable = new floatable.Floatable(anchor, content.element);
13
+ initialize(this);
14
+ }
15
+ destroy() {
16
+ floatable.deactivate(this.floatable, active);
17
+ reset(this.floatable.anchor, this.attributes);
18
+ this.abortController.abort();
19
+ this.floatable.content.remove();
20
+ this.floatable.destroy();
21
+ this.abortController = void 0;
22
+ this.attributes = void 0;
23
+ this.floatable = void 0;
24
+ }
25
+ }
26
+ function addTooltip(anchor) {
27
+ if (tooltips.has(anchor)) {
28
+ return;
29
+ }
30
+ const content = getContent(anchor);
31
+ if (content != null) {
32
+ tooltips.set(anchor, new Tooltip(anchor, content));
33
+ }
34
+ }
35
+ function getContent(anchor) {
36
+ const attributes = {
37
+ ariaDescribedby: anchor.getAttribute("aria-describedby"),
38
+ ariaLabelledBy: anchor.getAttribute("aria-labelledby")
39
+ };
40
+ const wrapper = getWrapper(anchor);
41
+ const labelledElements = getElements(attributes.ariaLabelledBy);
42
+ if (labelledElements.length > 0) {
43
+ anchor.setAttribute("aria-labelledby", wrapper.id);
44
+ for (const element of labelledElements) {
45
+ wrapper.append(element);
46
+ }
47
+ return {
48
+ attributes,
49
+ element: wrapper
50
+ };
51
+ }
52
+ const describedElements = getElements(attributes.ariaDescribedby);
53
+ if (describedElements.length > 0) {
54
+ anchor.setAttribute("aria-describedby", wrapper.id);
55
+ for (const element of describedElements) {
56
+ wrapper.append(element);
57
+ }
58
+ return {
59
+ attributes,
60
+ element: wrapper
61
+ };
62
+ }
63
+ let content = anchor.getAttribute("aria-label");
64
+ if (is.isNullableOrWhitespace(content)) {
65
+ content = anchor.getAttribute("aria-description");
66
+ }
67
+ if (is.isNullableOrWhitespace(content)) {
68
+ return;
69
+ }
70
+ const paragraph = document.createElement("p");
71
+ paragraph.textContent = content;
72
+ wrapper.append(paragraph);
73
+ return {
74
+ attributes,
75
+ element: wrapper
76
+ };
77
+ }
78
+ function getElements(attribute) {
79
+ const ids = attribute?.split(/\s+/) ?? [];
80
+ const elements = [];
81
+ for (const id of ids) {
82
+ if (is.isNullableOrWhitespace(id)) {
83
+ continue;
84
+ }
85
+ const element = document.querySelector(`#${id}`);
86
+ if (element instanceof HTMLElement) {
87
+ const cloned = element.cloneNode(true);
88
+ cloned.hidden = false;
89
+ elements.push(cloned);
90
+ }
91
+ }
92
+ return elements;
93
+ }
94
+ function getWrapper(anchor) {
95
+ const wrapper = document.createElement("div");
96
+ wrapper.setAttribute(`${selector}-content`, "");
97
+ wrapper.className = anchor.getAttribute(`${selector}-class`) ?? "";
98
+ wrapper.hidden = true;
99
+ wrapper.id = `${selector}-${++index}`;
100
+ wrapper.role = "tooltip";
101
+ wrapper.tabIndex = -1;
102
+ return wrapper;
103
+ }
104
+ function initialize(tooltip) {
105
+ const { abortController, floatable: floatable$1 } = tooltip;
106
+ const { anchor, content } = floatable$1;
107
+ anchor.insertAdjacentElement("afterend", content);
108
+ const elements = [anchor, content];
109
+ for (const element of elements) {
110
+ for (const event$1 of activateEvents) {
111
+ event.on(
112
+ element,
113
+ event$1,
114
+ () => {
115
+ floatable.activate(floatable$1, active, 250);
116
+ },
117
+ {
118
+ signal: abortController.signal
119
+ }
120
+ );
121
+ }
122
+ for (const event$1 of deactivateEvents) {
123
+ event.on(
124
+ element,
125
+ event$1,
126
+ () => {
127
+ floatable.deactivate(floatable$1, active, 250);
128
+ },
129
+ {
130
+ signal: abortController.signal
131
+ }
132
+ );
133
+ }
134
+ }
135
+ }
136
+ function removeTooltip(element) {
137
+ tooltips.get(element)?.destroy();
138
+ tooltips.delete(element);
139
+ }
140
+ function reset(anchor, attributes) {
141
+ console.log("reset", anchor, attributes);
142
+ if (attributes.ariaDescribedby == null) {
143
+ anchor.removeAttribute("aria-describedby");
144
+ } else {
145
+ anchor.setAttribute("aria-describedby", attributes.ariaDescribedby);
146
+ }
147
+ }
148
+ const active = /* @__PURE__ */ new Set();
149
+ const activateEvents = ["focus", "mouseenter", "touchstart"];
150
+ const deactivateEvents = ["blur", "mouseleave"];
151
+ const selector = "oui-tooltip";
152
+ const tooltips = /* @__PURE__ */ new WeakMap();
153
+ let index = 0;
154
+ attributable.attributable(selector, addTooltip, removeTooltip);
155
+ event.on(document, "keydown", (event2) => {
156
+ if (event2.key === "Escape") {
157
+ for (const floatable$1 of active) {
158
+ floatable.deactivate(floatable$1, active);
159
+ }
160
+ }
161
+ });
@@ -0,0 +1,160 @@
1
+ import { isNullableOrWhitespace } from "@oscarpalmer/atoms/is";
2
+ import { on } from "@oscarpalmer/toretto/event";
3
+ import { attributable } from "./attributable.js";
4
+ import { deactivate, Floatable, activate } from "./floatable.js";
5
+ class Tooltip {
6
+ abortController = new AbortController();
7
+ attributes;
8
+ floatable;
9
+ constructor(anchor, content) {
10
+ this.attributes = content.attributes;
11
+ this.floatable = new Floatable(anchor, content.element);
12
+ initialize(this);
13
+ }
14
+ destroy() {
15
+ deactivate(this.floatable, active);
16
+ reset(this.floatable.anchor, this.attributes);
17
+ this.abortController.abort();
18
+ this.floatable.content.remove();
19
+ this.floatable.destroy();
20
+ this.abortController = void 0;
21
+ this.attributes = void 0;
22
+ this.floatable = void 0;
23
+ }
24
+ }
25
+ function addTooltip(anchor) {
26
+ if (tooltips.has(anchor)) {
27
+ return;
28
+ }
29
+ const content = getContent(anchor);
30
+ if (content != null) {
31
+ tooltips.set(anchor, new Tooltip(anchor, content));
32
+ }
33
+ }
34
+ function getContent(anchor) {
35
+ const attributes = {
36
+ ariaDescribedby: anchor.getAttribute("aria-describedby"),
37
+ ariaLabelledBy: anchor.getAttribute("aria-labelledby")
38
+ };
39
+ const wrapper = getWrapper(anchor);
40
+ const labelledElements = getElements(attributes.ariaLabelledBy);
41
+ if (labelledElements.length > 0) {
42
+ anchor.setAttribute("aria-labelledby", wrapper.id);
43
+ for (const element of labelledElements) {
44
+ wrapper.append(element);
45
+ }
46
+ return {
47
+ attributes,
48
+ element: wrapper
49
+ };
50
+ }
51
+ const describedElements = getElements(attributes.ariaDescribedby);
52
+ if (describedElements.length > 0) {
53
+ anchor.setAttribute("aria-describedby", wrapper.id);
54
+ for (const element of describedElements) {
55
+ wrapper.append(element);
56
+ }
57
+ return {
58
+ attributes,
59
+ element: wrapper
60
+ };
61
+ }
62
+ let content = anchor.getAttribute("aria-label");
63
+ if (isNullableOrWhitespace(content)) {
64
+ content = anchor.getAttribute("aria-description");
65
+ }
66
+ if (isNullableOrWhitespace(content)) {
67
+ return;
68
+ }
69
+ const paragraph = document.createElement("p");
70
+ paragraph.textContent = content;
71
+ wrapper.append(paragraph);
72
+ return {
73
+ attributes,
74
+ element: wrapper
75
+ };
76
+ }
77
+ function getElements(attribute) {
78
+ const ids = attribute?.split(/\s+/) ?? [];
79
+ const elements = [];
80
+ for (const id of ids) {
81
+ if (isNullableOrWhitespace(id)) {
82
+ continue;
83
+ }
84
+ const element = document.querySelector(`#${id}`);
85
+ if (element instanceof HTMLElement) {
86
+ const cloned = element.cloneNode(true);
87
+ cloned.hidden = false;
88
+ elements.push(cloned);
89
+ }
90
+ }
91
+ return elements;
92
+ }
93
+ function getWrapper(anchor) {
94
+ const wrapper = document.createElement("div");
95
+ wrapper.setAttribute(`${selector}-content`, "");
96
+ wrapper.className = anchor.getAttribute(`${selector}-class`) ?? "";
97
+ wrapper.hidden = true;
98
+ wrapper.id = `${selector}-${++index}`;
99
+ wrapper.role = "tooltip";
100
+ wrapper.tabIndex = -1;
101
+ return wrapper;
102
+ }
103
+ function initialize(tooltip) {
104
+ const { abortController, floatable } = tooltip;
105
+ const { anchor, content } = floatable;
106
+ anchor.insertAdjacentElement("afterend", content);
107
+ const elements = [anchor, content];
108
+ for (const element of elements) {
109
+ for (const event of activateEvents) {
110
+ on(
111
+ element,
112
+ event,
113
+ () => {
114
+ activate(floatable, active, 250);
115
+ },
116
+ {
117
+ signal: abortController.signal
118
+ }
119
+ );
120
+ }
121
+ for (const event of deactivateEvents) {
122
+ on(
123
+ element,
124
+ event,
125
+ () => {
126
+ deactivate(floatable, active, 250);
127
+ },
128
+ {
129
+ signal: abortController.signal
130
+ }
131
+ );
132
+ }
133
+ }
134
+ }
135
+ function removeTooltip(element) {
136
+ tooltips.get(element)?.destroy();
137
+ tooltips.delete(element);
138
+ }
139
+ function reset(anchor, attributes) {
140
+ console.log("reset", anchor, attributes);
141
+ if (attributes.ariaDescribedby == null) {
142
+ anchor.removeAttribute("aria-describedby");
143
+ } else {
144
+ anchor.setAttribute("aria-describedby", attributes.ariaDescribedby);
145
+ }
146
+ }
147
+ const active = /* @__PURE__ */ new Set();
148
+ const activateEvents = ["focus", "mouseenter", "touchstart"];
149
+ const deactivateEvents = ["blur", "mouseleave"];
150
+ const selector = "oui-tooltip";
151
+ const tooltips = /* @__PURE__ */ new WeakMap();
152
+ let index = 0;
153
+ attributable(selector, addTooltip, removeTooltip);
154
+ on(document, "keydown", (event) => {
155
+ if (event.key === "Escape") {
156
+ for (const floatable of active) {
157
+ deactivate(floatable, active);
158
+ }
159
+ }
160
+ });
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",
@@ -22,10 +22,10 @@
22
22
  "postcss-input-range": "^6",
23
23
  "postcss-scss": "^4",
24
24
  "sass": "^1.89",
25
- "stylelint": "^16.21",
25
+ "stylelint": "^16.22",
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"],
@@ -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.14.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,21 @@
1
+ @use 'mixins.scss' as mixins;
2
+
3
+ :where([oui-tooltip]:is(abbr, b, cite, dfn, code, em, i, kbd, mark, q, samp, small, span, strong, sub, sup, time, var)) {
4
+ text-decoration: underline dotted;
5
+ text-decoration-skip-ink: auto;
6
+ text-underline-offset: 0.25em;
7
+ cursor: help;
8
+ }
9
+
10
+ :where([oui-tooltip-content]) {
11
+ @include mixins.shadowOverlay;
12
+
13
+ max-width: 48ch;
14
+ padding: 0.75em 1em;
15
+ background-color: var(--oui-absolute);
16
+ border-radius: 0.25rem;
17
+ font-size: 0.875rem;
18
+ position: fixed;
19
+ inset: 0 auto auto 0;
20
+ z-index: 10;
21
+ }
@@ -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
+ }
@@ -0,0 +1,168 @@
1
+ export class Floatable {
2
+ activate: number | undefined;
3
+ active = false;
4
+ deactivate: number | undefined;
5
+ frame: DOMHighResTimeStamp | undefined;
6
+
7
+ constructor(
8
+ public anchor: HTMLElement,
9
+ public content: HTMLElement,
10
+ ) {}
11
+
12
+ destroy(): void {
13
+ this.activate = undefined;
14
+ this.deactivate = undefined;
15
+ this.frame = undefined;
16
+
17
+ this.anchor = undefined as never;
18
+ this.content = undefined as never;
19
+ }
20
+ }
21
+
22
+ export function activate(
23
+ floatable: Floatable,
24
+ active: Set<Floatable>,
25
+ time?: number,
26
+ ): void {
27
+ stop(floatable);
28
+
29
+ if (floatable.active) {
30
+ return;
31
+ }
32
+
33
+ if (time == null || time === 0) {
34
+ onActivate(floatable, active);
35
+ } else {
36
+ floatable.activate = +setTimeout(() => {
37
+ onActivate(floatable, active);
38
+ }, time);
39
+ }
40
+ }
41
+
42
+ export function deactivate(
43
+ floatable: Floatable,
44
+ active: Set<Floatable>,
45
+ time?: number,
46
+ ): void {
47
+ stop(floatable);
48
+
49
+ if (!floatable.active) {
50
+ return;
51
+ }
52
+
53
+ if (time == null || time === 0) {
54
+ onDeactivate(floatable, active);
55
+ } else {
56
+ floatable.deactivate = +setTimeout(() => {
57
+ onDeactivate(floatable, active);
58
+ }, time);
59
+ }
60
+ }
61
+
62
+ function getX(
63
+ anchor: DOMRect,
64
+ content: DOMRect,
65
+ ): {left?: number; right?: number} {
66
+ if (content.width + 2 * margin >= window.innerWidth) {
67
+ return {
68
+ left: margin,
69
+ right: margin,
70
+ };
71
+ }
72
+
73
+ const left = anchor.left + (anchor.width - content.width) / 2;
74
+ const right = window.innerWidth - margin;
75
+
76
+ if (left < margin) {
77
+ return {
78
+ left: margin,
79
+ };
80
+ }
81
+
82
+ if (left + content.width > right) {
83
+ return {
84
+ right: margin,
85
+ };
86
+ }
87
+
88
+ return {
89
+ left,
90
+ };
91
+ }
92
+
93
+ function getY(anchor: DOMRect, content: DOMRect): number {
94
+ let top = anchor.top - content.height - margin;
95
+
96
+ if (top - margin >= 0) {
97
+ return top;
98
+ }
99
+
100
+ top = anchor.bottom + margin;
101
+
102
+ if (top + content.height + margin <= window.innerHeight) {
103
+ return top;
104
+ }
105
+
106
+ return anchor.top + (anchor.height - content.height) / 2;
107
+ }
108
+
109
+ function onActivate(floatable: Floatable, active: Set<Floatable>): void {
110
+ floatable.active = true;
111
+
112
+ active.add(floatable);
113
+
114
+ document.body.append(floatable.content);
115
+
116
+ floatable.content.hidden = false;
117
+
118
+ update(floatable);
119
+ }
120
+
121
+ function onDeactivate(floatable: Floatable, active: Set<Floatable>): void {
122
+ floatable.active = false;
123
+
124
+ active.delete(floatable);
125
+
126
+ floatable.content.hidden = true;
127
+
128
+ floatable.anchor.insertAdjacentElement('afterend', floatable.content);
129
+ }
130
+
131
+ function update(floatable: Floatable): void {
132
+ function run(): void {
133
+ const anchor = floatable.anchor.getBoundingClientRect();
134
+ const content = floatable.content.getBoundingClientRect();
135
+
136
+ const {left, right} = getX(anchor, content);
137
+ const top = getY(anchor, content);
138
+
139
+ floatable.content.style.inset = `${top}px ${right == null ? 'auto' : `${right}px`} auto ${left == null ? 'auto' : `${left}px`}`;
140
+
141
+ floatable.frame = requestAnimationFrame(run);
142
+ }
143
+
144
+ floatable.frame = requestAnimationFrame(run);
145
+ }
146
+
147
+ function stop(floatable: Floatable): void {
148
+ if (floatable.frame != null) {
149
+ cancelAnimationFrame(floatable.frame);
150
+ }
151
+
152
+ if (floatable.activate != null) {
153
+ clearTimeout(floatable.activate);
154
+ }
155
+
156
+ if (floatable.deactivate != null) {
157
+ clearTimeout(floatable.deactivate);
158
+ }
159
+
160
+ floatable.activate = undefined;
161
+ floatable.deactivate = undefined;
162
+ floatable.frame = undefined;
163
+ }
164
+
165
+ //
166
+
167
+ const margin =
168
+ Number.parseInt(getComputedStyle(document.documentElement).fontSize, 10) / 4;