@descope-ui/descope-anchored 3.7.1
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/CHANGELOG.md +12 -0
- package/e2e/descope-anchored.spec.ts +25 -0
- package/package.json +29 -0
- package/project.json +8 -0
- package/src/component/AnchoredClass.js +167 -0
- package/src/component/index.js +5 -0
- package/stories/descope-anchored.stories.js +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
|
|
4
|
+
|
|
5
|
+
## [3.7.1](https://github.com/descope/web-components-ui/compare/web-components-ui-3.7.0...web-components-ui-3.7.1) (2026-05-11)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **descope-attachment:** sync parent layout size ([#1003](https://github.com/descope/web-components-ui/issues/1003)) ([70e9058](https://github.com/descope/web-components-ui/commit/70e905824cb93a9a10f7b85016c9938b33db0ad2))
|
|
11
|
+
|
|
12
|
+
# @descope-ui/descope-anchored
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { getStoryUrl, loopConfig } from 'e2e-utils';
|
|
3
|
+
|
|
4
|
+
const componentAttributes = {
|
|
5
|
+
fullWidth: ['true', 'false'],
|
|
6
|
+
hasAnchor: ['true', 'false'],
|
|
7
|
+
direction: ['ltr', 'rtl'],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const storyName = 'descope-anchored';
|
|
11
|
+
const componentName = 'story-anchor-host';
|
|
12
|
+
|
|
13
|
+
test.describe('layout', () => {
|
|
14
|
+
loopConfig(componentAttributes, (attr, value) => {
|
|
15
|
+
test(`${attr}: ${value}`, async ({ page }) => {
|
|
16
|
+
const component = page.locator(componentName);
|
|
17
|
+
|
|
18
|
+
await page.goto(getStoryUrl(storyName, { [attr]: value }), {
|
|
19
|
+
waitUntil: 'networkidle',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(await component.screenshot()).toMatchSnapshot();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@descope-ui/descope-anchored",
|
|
3
|
+
"version": "3.7.1",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": {
|
|
6
|
+
"import": "./src/component/index.js"
|
|
7
|
+
},
|
|
8
|
+
"./class": {
|
|
9
|
+
"import": "./src/component/AnchoredClass.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@playwright/test": "1.58.2",
|
|
14
|
+
"e2e-utils": "3.7.1",
|
|
15
|
+
"test-assets": "3.7.1",
|
|
16
|
+
"test-drivers": "3.7.1"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@descope-ui/common": "3.7.1",
|
|
20
|
+
"@descope-ui/theme-globals": "3.7.1"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"link-workspace-packages": false
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "echo 'No tests defined' && exit 0",
|
|
27
|
+
"test:e2e": "playwright test"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { componentNameValidationMixin } from '@descope-ui/common/components-mixins';
|
|
2
|
+
import { createBaseClass } from '@descope-ui/common/base-classes';
|
|
3
|
+
import {
|
|
4
|
+
forwardAttrs,
|
|
5
|
+
getComponentName,
|
|
6
|
+
injectStyle,
|
|
7
|
+
} from '@descope-ui/common/components-helpers';
|
|
8
|
+
import { compose } from '@descope-ui/common/utils';
|
|
9
|
+
|
|
10
|
+
export const componentName = getComponentName('anchored');
|
|
11
|
+
|
|
12
|
+
class RawAnchored extends createBaseClass({
|
|
13
|
+
componentName,
|
|
14
|
+
baseSelector: '.anchored-root',
|
|
15
|
+
}) {
|
|
16
|
+
#stretchObserver = null;
|
|
17
|
+
|
|
18
|
+
#directionObserver = null;
|
|
19
|
+
|
|
20
|
+
#hostStretchSheet = null;
|
|
21
|
+
|
|
22
|
+
get #anchor() {
|
|
23
|
+
return this.defaultSlot.assignedElements({ flatten: true })[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get #anchored() {
|
|
27
|
+
return this.shadowRoot
|
|
28
|
+
.querySelector('slot[name="anchored"]')
|
|
29
|
+
?.assignedElements()[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get #outerHost() {
|
|
33
|
+
return this.getRootNode().host;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
super();
|
|
38
|
+
|
|
39
|
+
this.attachShadow({ mode: 'open' }).innerHTML = `
|
|
40
|
+
<div class="anchored-root" part="root">
|
|
41
|
+
<slot></slot>
|
|
42
|
+
<div class="anchored-host" part="anchored">
|
|
43
|
+
<slot name="anchored"></slot>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
this.defaultSlot = this.shadowRoot.querySelector('slot:not([name])');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
init() {
|
|
52
|
+
super.init?.();
|
|
53
|
+
|
|
54
|
+
injectStyle(
|
|
55
|
+
`
|
|
56
|
+
:host {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
position: relative;
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
:host(:not([has-anchor])) {
|
|
63
|
+
display: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.anchored-root {
|
|
67
|
+
position: relative;
|
|
68
|
+
display: flex;
|
|
69
|
+
width: 100%;
|
|
70
|
+
min-width: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Make anchor fill the flex row and allow it to shrink. */
|
|
74
|
+
::slotted(*:not([slot])) {
|
|
75
|
+
flex-grow: 1; /* fill the flex row */
|
|
76
|
+
flex-shrink: 1; /* compress when constrained */
|
|
77
|
+
flex-basis: auto; /* start from the anchor's natural size */
|
|
78
|
+
min-width: 0; /* flex items won't shrink below content size without this */
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Anchored container covers the anchor but is invisible to pointer events. */
|
|
82
|
+
.anchored-host {
|
|
83
|
+
position: absolute;
|
|
84
|
+
inset: 0;
|
|
85
|
+
pointer-events: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Restore interactivity for actual anchored content. */
|
|
89
|
+
::slotted([slot="anchored"]) {
|
|
90
|
+
pointer-events: auto;
|
|
91
|
+
}
|
|
92
|
+
`,
|
|
93
|
+
this,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
this.#syncStretchCSS();
|
|
97
|
+
|
|
98
|
+
this.defaultSlot.addEventListener('slotchange', () => this.#syncAnchor());
|
|
99
|
+
|
|
100
|
+
this.#syncAnchor();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#syncAnchor() {
|
|
104
|
+
this.#onAnchorChanged();
|
|
105
|
+
|
|
106
|
+
this.#stretchObserver = this.#forwardAttr(
|
|
107
|
+
this.#stretchObserver,
|
|
108
|
+
this.#outerHost,
|
|
109
|
+
'stretch',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
this.#directionObserver = this.#forwardAttr(
|
|
113
|
+
this.#directionObserver,
|
|
114
|
+
this.#anchored,
|
|
115
|
+
'st-host-direction',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Injects [stretch] layout rules into the containing component's shadow root (e.g. descope-attachment)
|
|
120
|
+
// so it stretches when the anchor has [stretch]. Replaces the existing rules on subsequent calls.
|
|
121
|
+
#syncStretchCSS() {
|
|
122
|
+
const css = `
|
|
123
|
+
descope-anchored {
|
|
124
|
+
width: 100%; /* fill the outer host so the anchored element spans the full anchor width */
|
|
125
|
+
}
|
|
126
|
+
:host([stretch]) {
|
|
127
|
+
display: inline-flex; /* switch from inline-block so internal children are flex items */
|
|
128
|
+
width: 100%; /* fill non-flex parents */
|
|
129
|
+
flex-grow: 1; /* absorb extra space in flex parents */
|
|
130
|
+
flex-shrink: 0; /* hold full width — shrink:1 would let siblings squeeze it below 100% */
|
|
131
|
+
flex-basis: 100%; /* start at full width before grow/shrink */
|
|
132
|
+
min-width: 0; /* prevent overflow when the outer host is itself inside a constrained flex row */
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
if (this.#hostStretchSheet) {
|
|
137
|
+
this.#hostStretchSheet.replaceSync(css);
|
|
138
|
+
} else if (this.#outerHost) {
|
|
139
|
+
this.#hostStretchSheet = injectStyle(css, this.#outerHost);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Reconnects forwarding to the current anchor. Disconnects the old observer and
|
|
144
|
+
// removes the stale attr from target first — forwardAttrs only sets attrs present
|
|
145
|
+
// on the source, so absent attrs won't be removed automatically.
|
|
146
|
+
#forwardAttr(observer, target, attr) {
|
|
147
|
+
observer?.disconnect();
|
|
148
|
+
target?.removeAttribute(attr);
|
|
149
|
+
if (!this.#anchor || !target) return null;
|
|
150
|
+
return forwardAttrs(this.#anchor, target, { includeAttrs: [attr] });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Prevent stale callbacks from firing on a detached anchor after removal.
|
|
154
|
+
disconnectedCallback() {
|
|
155
|
+
super.disconnectedCallback?.();
|
|
156
|
+
this.#stretchObserver?.disconnect();
|
|
157
|
+
this.#directionObserver?.disconnect();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Track whether anything is slotted, so the host display rule can hide an
|
|
161
|
+
// empty host rather than reserving its layout box.
|
|
162
|
+
#onAnchorChanged() {
|
|
163
|
+
this.toggleAttribute('has-anchor', !!this.#anchor);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const AnchoredClass = compose(componentNameValidationMixin)(RawAnchored);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { directionControl } from '@descope-ui/common/sb-controls';
|
|
2
|
+
import { componentName } from '../src/component';
|
|
3
|
+
|
|
4
|
+
// Storybook's HTML renderer sets container.innerHTML = storyFn(), so stories must
|
|
5
|
+
// return strings. story-anchor-host upgrades by moving its light-DOM children into
|
|
6
|
+
// its own shadow root, giving descope-anchored a real shadow host to live in.
|
|
7
|
+
if (!customElements.get('story-anchor-host')) {
|
|
8
|
+
customElements.define(
|
|
9
|
+
'story-anchor-host',
|
|
10
|
+
class extends HTMLElement {
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
if (this.shadowRoot) return;
|
|
13
|
+
const shadow = this.attachShadow({ mode: 'open' });
|
|
14
|
+
shadow.append(...this.childNodes);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const Template = ({ direction, fullWidth, hasAnchor }) => `
|
|
21
|
+
<story-anchor-host>
|
|
22
|
+
<style nonce="${window.DESCOPE_NONCE}">
|
|
23
|
+
:host {
|
|
24
|
+
display: inline-block;
|
|
25
|
+
background: pink;
|
|
26
|
+
min-height: 100px;
|
|
27
|
+
min-width: 100px;
|
|
28
|
+
}
|
|
29
|
+
.anchored-text {
|
|
30
|
+
position: absolute;
|
|
31
|
+
top: -4px;
|
|
32
|
+
right: -4px;
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
35
|
+
<descope-anchored>
|
|
36
|
+
${hasAnchor ? `<descope-button bordered="true" variant="outline" mode="primary" full-width="${fullWidth}" st-host-direction="${direction}">Click me -</descope-button>` : ''}
|
|
37
|
+
<descope-badge slot="anchored" size="xs" bordered="true" mode="primary" class="anchored-text">anchored -</descope-badge>
|
|
38
|
+
</descope-anchored>
|
|
39
|
+
</story-anchor-host>
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
component: componentName,
|
|
44
|
+
title: 'descope-anchored',
|
|
45
|
+
parameters: {
|
|
46
|
+
controls: { expanded: true },
|
|
47
|
+
},
|
|
48
|
+
argTypes: {
|
|
49
|
+
...directionControl,
|
|
50
|
+
fullWidth: { control: 'boolean' },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const Default = Template.bind({});
|
|
55
|
+
Default.args = {
|
|
56
|
+
direction: 'ltr',
|
|
57
|
+
fullWidth: false,
|
|
58
|
+
hasAnchor: true,
|
|
59
|
+
};
|