Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SC NG Commons

Shared Library concepts for reusable frontend capabilities used by Consumer applications.

## Language

**Testing FAB**:
A floating action button for non-production environments that reveals configurable test and development controls.
_Avoid_: debug menu, dev button, toolbox

**Action Widget**:
A configurable control rendered inside the expanded Testing FAB panel to alter app behavior for testing and development.
_Avoid_: option, toggle, tool

**Select Query Param Widget**:
An Action Widget that presents selectable values and persists the selected value into a specific URL query parameter.
_Avoid_: query toggle, URL switch

**Custom Component Widget**:
An Action Widget that renders a Consumer-provided Angular component inside the Testing FAB panel.
_Avoid_: arbitrary template, inline HTML widget

**URL-Authoritative Selection**:
For Select Query Param Widgets, the URL query parameter value is the source of truth for current selection and restoration across reloads.
_Avoid_: local-only state, hidden persistence

**Opt-in Provisioning**:
The Testing FAB is activated only when a Consumer application explicitly registers it through `provideTestingFab`; there is no automatic environment-based enablement.
Comment thread
mumenthalers marked this conversation as resolved.
_Avoid_: auto-enable, implicit activation

**Consumer-Owned Activation Safety**:
The Consumer application is solely responsible for deciding where Testing FAB is enabled, including preventing production activation.
_Avoid_: library-enforced safety, implicit prod blocking
2 changes: 1 addition & 1 deletion apps/styleguide/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shiftcode/styleguide",
"version": "15.2.0",
"version": "15.2.1-pr87.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useNx": false,
"packages": ["libs/*", "apps/*"],
"version": "15.2.0",
"version": "15.2.1-pr87.0",
"command": {
"version": {
"allowBranch": "*",
Expand Down
27 changes: 27 additions & 0 deletions libs/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,30 @@ Can be customized with css custom properties:
--sc-tooltip-padding: 4px 8px;
}
```

## [testing-fab](./src/lib/testing-fab/testing-fab.component.ts)

Floating action button for non-production testing controls.

```ts
import { provideTestingFab, TestingFabComponent } from '@shiftcode/ngx-components'

bootstrapApplication(AppComponent, {
providers: [
provideTestingFab([
{
id: 'backend-env',
label: 'Backend Environment',
type: 'select-query-param',
queryParam: 'env',
options: [
{ label: 'QA', value: 'qa' },
{ label: 'Preview', value: 'preview' },
],
},
]),
],
})
```

Add `<sc-testing-fab />` once in a root-level template where it should be available.
2 changes: 1 addition & 1 deletion libs/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shiftcode/ngx-components",
"version": "15.2.0",
"version": "15.2.1-pr87.0",
"repository": "https://github.com/shiftcode/sc-ng-commons-public",
"license": "MIT",
"author": "shiftcode GmbH <team@shiftcode.ch>",
Expand Down
23 changes: 23 additions & 0 deletions libs/components/src/lib/testing-fab/initialize-testing-fab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DOCUMENT } from '@angular/common'
import { ApplicationRef, createComponent, EnvironmentInjector, inject } from '@angular/core'

import { TestingFabComponent } from './testing-fab.component'

export function initializeTestingFab() {
const doc = inject(DOCUMENT)

if (!doc.body || doc.body.querySelector('sc-testing-fab')) {
return
}

const appRef = inject(ApplicationRef)
const environmentInjector = inject(EnvironmentInjector)
const host = doc.createElement('sc-testing-fab')
const cmpRef = createComponent(TestingFabComponent, {
hostElement: host,
environmentInjector,
})

appRef.attachView(cmpRef.hostView)
doc.body.appendChild(host)
}
47 changes: 47 additions & 0 deletions libs/components/src/lib/testing-fab/provide-testing-fab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { TestBed } from '@angular/core/testing'
import { DefaultUrlSerializer, Router } from '@angular/router'
import { afterEach, describe, expect, test, vi } from 'vitest'

import { provideTestingFab } from './provide-testing-fab'
import { TESTING_FAB_WIDGETS } from './testing-fab-config.token'
import { TestingFabWidget } from './testing-fab-widget.type'

const serializer = new DefaultUrlSerializer()

function createRouterMock(): Router {
return {
url: '/',
parseUrl: vi.fn<typeof serializer.parse>((value) => serializer.parse(value)),
navigate: vi.fn().mockResolvedValue(true),
} as unknown as Router
}

describe('provideTestingFab', () => {
afterEach(() => {
TestBed.resetTestingModule()
document.body.querySelectorAll('sc-testing-fab').forEach((el) => el.remove())
})

test('supports static widgets config and renders the testing fab', () => {
const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }]

TestBed.configureTestingModule({
providers: [provideTestingFab(widgets), { provide: Router, useValue: createRouterMock() }],
})

expect(TestBed.inject(TESTING_FAB_WIDGETS)).toEqual(widgets)
expect(document.body.querySelectorAll('sc-testing-fab')).toHaveLength(1)
})

test('supports factory widgets config', () => {
const widgets: readonly TestingFabWidget[] = [{ id: 'a1', label: 'Action', type: 'action', action: vi.fn() }]
const factory = vi.fn(() => widgets)

TestBed.configureTestingModule({
providers: [provideTestingFab(factory), { provide: Router, useValue: createRouterMock() }],
})

expect(TestBed.inject(TESTING_FAB_WIDGETS)).toEqual(widgets)
expect(factory).toHaveBeenCalled()
})
})
17 changes: 17 additions & 0 deletions libs/components/src/lib/testing-fab/provide-testing-fab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { EnvironmentProviders, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core'

import { initializeTestingFab } from './initialize-testing-fab'
import { TESTING_FAB_WIDGETS } from './testing-fab-config.token'
import { TestingFabWidget } from './testing-fab-widget.type'

type ValueOrFactory<T> = T | (() => T)

export function provideTestingFab(widgets: ValueOrFactory<readonly TestingFabWidget[]>): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: TESTING_FAB_WIDGETS,
...(typeof widgets === 'function' ? { useFactory: widgets } : { useValue: widgets }),
},
provideEnvironmentInitializer(() => initializeTestingFab()),
])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { InjectionToken } from '@angular/core'

import { TestingFabWidget } from './testing-fab-widget.type'

export const TESTING_FAB_WIDGETS = new InjectionToken<readonly TestingFabWidget[]>('sc.ng.common.testing-fab.widgets', {
factory: () => [],
})
34 changes: 34 additions & 0 deletions libs/components/src/lib/testing-fab/testing-fab-widget.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Type } from '@angular/core'

export type TestingFabWidget =
| TestingFabActionWidget
| TestingFabSelectQueryParamWidget
| TestingFabCustomComponentWidget

interface TestingFabWidgetBase {
id: string
label: string
}

export interface TestingFabActionWidget extends TestingFabWidgetBase {
type: 'action'
buttonLabel?: string
action: () => void
}

export interface TestingFabSelectQueryParamWidget extends TestingFabWidgetBase {
type: 'select-query-param'
queryParam: string
options: readonly TestingFabSelectOption[]
hardReload?: boolean
}

export interface TestingFabSelectOption {
value: string
label: string
}

export interface TestingFabCustomComponentWidget extends TestingFabWidgetBase {
type: 'custom-component'
component: Type<unknown>
}
41 changes: 41 additions & 0 deletions libs/components/src/lib/testing-fab/testing-fab.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@let panelId = 'sc-testing-fab-panel';
<button
type="button"
class="sc-testing-fab__trigger"
aria-label="Toggle testing controls"
[attr.popovertarget]="panelId"
[attr.aria-controls]="panelId"
></button>

<section [id]="panelId" popover="auto" class="sc-testing-fab__panel">
@for (widget of widgets; track widget.id) {
@let widgetId = 'sc-testing-fab-' + widget.id;
<div class="sc-testing-fab__widget">
<label [attr.for]="widgetId" class="sc-testing-fab__widget-label">{{ widget.label }}</label>

@switch (widget.type) {
@case ('action') {
<button type="button" [id]="widgetId" class="sc-testing-fab__button" (click)="widget.action()">
{{ widget.buttonLabel ?? widget.label }}
</button>
}
@case ('select-query-param') {
<select
[id]="widgetId"
class="sc-testing-fab__select"
[value]="getSelectedValue(widget)"
(change)="setQueryParam(widget, $event)"
>
<option value="">--</option>
@for (option of widget.options; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
}
@case ('custom-component') {
<ng-container [ngComponentOutlet]="widget.component" />
}
}
</div>
}
</section>
101 changes: 101 additions & 0 deletions libs/components/src/lib/testing-fab/testing-fab.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
:host {
Comment thread
mumenthalers marked this conversation as resolved.
position: fixed;
right: var(--sc-testing-fab-inset, 16px);
bottom: var(--sc-testing-fab-inset, 16px);
margin: var(--sc-testing-fab-margin, 0);
z-index: 2147483647;
--sc-testing-fab-size: 48px;
--sc-testing-fab-border-radius: 50%;
--sc-testing-fab-background-color: #1d4ed8;
--sc-testing-fab-foreground-color: #fff;
--sc-testing-fab-icon: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCA0NDIgNTc5IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MjsiPjxwYXRoIGQ9Ik0wLDE5Mi45MTdsMTkyLjkxNywtMTkyLjkxN2w5Ni4yNSwwbC0xOTIuOTE3LDE5Mi45MTdsMTkyLjkxNywxOTIuNWwtMTkyLjkxNywxOTIuOTE2bC05Ni4yNSwwbDE5Mi45MTcsLTE5Mi45MTZsLTE5Mi45MTcsLTE5Mi41WiIvPjxwYXRoIGQ9Ik0xNTIuNSwxOTIuOTE3bDE5Mi41LC0xOTIuOTE3bDk2LjY2NywwbC0xOTIuOTE3LDE5Mi45MTdsMTkyLjkxNywxOTIuNWwtOTYuNjY3LDBsLTE5Mi41LC0xOTIuNVoiLz48L3N2Zz4=');
}

.sc-testing-fab__trigger {
width: var(--sc-testing-fab-size);
height: var(--sc-testing-fab-size);
border-radius: var(--sc-testing-fab-border-radius);
border: 0;
background: var(--sc-testing-fab-background-color);
color: var(--sc-testing-fab-foreground-color);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgb(0 0 0 / 40%);
}

.sc-testing-fab__panel {
position: fixed;
right: var(--sc-testing-fab-inset, 16px);
bottom: calc(var(--sc-testing-fab-inset, 16px) + var(--sc-testing-fab-size) + 8px);
margin: var(--sc-testing-fab-margin, 0);
inset: auto;
min-width: 240px;
max-width: min(320px, calc(100vw - 32px));
max-height: min(60vh, 560px);
overflow: auto;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
padding: 12px;
box-shadow: 0 8px 24px rgb(0 0 0 / 25%);
opacity: 0;
transform: translateY(8px) scale(0.98);
transition:
opacity 140ms ease,
transform 140ms ease;
}

.sc-testing-fab__panel:popover-open {
opacity: 1;
transform: translateY(0) scale(1);
}

@starting-style {
.sc-testing-fab__panel:popover-open {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}

.sc-testing-fab__widget {
display: grid;
gap: 8px;
}

.sc-testing-fab__widget + .sc-testing-fab__widget {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}

.sc-testing-fab__widget-label {
font-weight: 600;
font-size: 13px;
}

.sc-testing-fab__select,
.sc-testing-fab__button {
width: 100%;
border: 1px solid #9ca3af;
border-radius: 6px;
padding: 6px 8px;
background: #fff;
color: inherit;
}

.sc-testing-fab__button {
cursor: pointer;
}

.sc-testing-fab__trigger::before {
content: '';
width: 62%;
height: 62%;
background-image: var(--sc-testing-fab-icon);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
Loading
Loading