Skip to content
Merged
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Annotate every endpoint with the resource role of its domain, e.g. `read:alarms`

Access control belongs in the resource implementation, not in the service. Extend `ManagerWebResource` and use its checks rather than reaching for the identity provider: `throwIfNotRealmActiveAndAccessible` and `throwIfRestrictedUser` reject with a 403, and `isRealmActiveAndAccessible` and `isRestrictedUser` are there for conditions that need more than a rejection. Resolve the entity first and return 404 when it is missing, then require access to its realm, and apply restricted user rules in the same place. Service methods take already authorised input and state that in their javadoc with "Callers are responsible for enforcing realm authorization." Alarms and notifications are the reference for this shape; equivalent endpoints across the two must enforce the same way. Flows that never pass through a resource, such as notifications published to the message broker, authorise inside the service instead.

## Spelling

Write code, comments and docs in US English, e.g. `color`, not `colour`.

## UI

Frontend conventions live in `ui/AGENTS.md`. Read it before changing anything under `ui/`.
31 changes: 21 additions & 10 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,31 @@ The date and time picker wrappers set their `i18n` themselves: the date order, c

### Styling the component's shadow-DOM internals

Pick the approach by how much of the base component you need to keep. The base + Lumo styles are keyed off the element's `static get is()`, so a wrapper that does _not_ override `is` still reports the base tag (e.g. `vaadin-checkbox-group`) and inherits the full base + Lumo theme.
**How Vaadin applies styles.** A Vaadin element's shadow root has three layers, later ones winning at equal specificity:

**Small tweaks to an existing component (preferred default).** Do NOT override `is`, since that detaches the element from the base + Lumo styles keyed off `is` and strips the inherited theme/layout entirely. Do NOT use `static get styles()` — ThemableMixin injects those _before_ Lumo, so Lumo wins; and for wrapper classes that don't override `is`, ThemableMixin may not associate the child's `static get styles()` with the finalized element at all. Instead apply styles in one of these ways:
1. Base styles defined in `static get styles()` of the Vaadin component class or our `or-vaadin-*` classes. The Lumo theme drops these when it injects, unless the component sets `includeBaseStyles: true` (badge, slider). Components without `LumoInjectionMixin`, such as `vaadin-dialog`, always keep them.
2. Lumo modules defined through `@media lumo_<name>` blocks are injected per tag as listed in `--_lumo-<tag>-inject-modules`. Lumo defines its modules and lists them this way in `@vaadin/vaadin-lumo-styles/components/<name>.css`. The list in our `ui/component/theme/src/components/or-vaadin-<name>.css` overwrites Lumo's list for that tag, so we include Lumo's declared list and include our own `@media lumo_` blocks. `components/index.css` therefore imports our file after every other file that declares a list for the same tag, including through a Lumo import.
3. Styles registered with ``registerStyles("<tag>", css`…`)`` are added after Lumo to elements whose `is` matches the tag, and are inherited by subclasses. Register them before `customElements.define` runs for that tag, otherwise existing instances are restyled and a console warning is logged. For our own tag that means on module level above the class.

- **External `::part()` rule (preferred for part properties).** Add the rule in `ui/component/theme/src/components/or-vaadin-<name>.css` (imported from that dir's `index.css`), keyed on the real tag name: `or-vaadin-<tag>::part(<part>) { … }`. This is a plain browser CSS feature — the global stylesheet pierces the shadow DOM boundary through the exposed part with no ThemableMixin involvement. It scopes cleanly to that tag only and is invisible to the `is`-based lookup.
- **Lumo `@media` module injection (for rules that must live inside the shadow DOM).** Use this only when `::part()` is insufficient — e.g. `:host` pseudo-class selectors, `::slotted`, or pseudo-elements that cannot be reached from outside. Define the rules in a named `@media <module-name>` block in the theme CSS file and add that name to `--_lumo-vaadin-<tag>-inject-modules`. The injector stamps those rules into the shadow DOM _after_ Lumo, so they always win.
- **Slotted native inputs (`<textarea>`, `<input>`) are light DOM, not shadow DOM.** Style them with a normal descendant rule from the surrounding tree (`or-vaadin-<tag> textarea { … }`) or with inline styles from the wrapper class; both win over the base component's `::slotted()` rules in the cascade.
Lumo and `registerStyles` match the element's `static get is()`, while base styles come with the class. From outside, custom properties and `::part()` rules beat the shadow DOM's own rules, and slotted light-DOM children such as native inputs take normal selectors. `test/styling-contract.test.ts` checks the theme and every element the package index exports against these layers.

**A component that fully owns its visuals via `registerStyles()`.** Only when the component re-implements the look from scratch (so it has no inherited theme to preserve) override `is` and register styles from its own module; do NOT use `static get styles()`. Vaadin's `ThemableMixin.finalizeStyles()` injects `static` styles _before_ the Lumo theme injector, so the theme overrides them and nothing appears to apply; styles registered via `registerStyles()` are injected _after_ the injector, so they always win. Recipe (see `or-vaadin-toggle.ts`):
Base styles and registered styles ship with the component code. Our Lumo module lists, `@media lumo_` blocks and `::part()` rules live in `@openremote/theme`. An app without that theme therefore gets the base styles, or Lumo's default lists if it loads Lumo itself, plus our registered styles. The hard-coded fallbacks below keep our own components usable there.

- Call `registerStyles("or-vaadin-<tag>", css\`...\`)`from`@vaadin/vaadin-themable-mixin/register-styles.js` at module top (before the element is finalized).
- Override `static get is() { return "or-vaadin-<tag>"; }`. The base class reports its own tag (e.g. `vaadin-checkbox`), and `registerStyles`/`getStylesForThis()` key off `is`; without this the styles either don't match or leak onto every instance of the base component. (This same override is what detaches the element from the base theme, which is acceptable here because the component re-styles everything itself.)
- Out-specify base rules with `:host(...)` prefixes (e.g. the checkbox base hides its marker with `:host(:not([checked])) [part='checkbox']::after { opacity: 0 }`).
- Give every themed CSS var a hard-coded fallback (e.g. `var(--lumo-primary-color, #47a942)`) so the component also looks correct standalone, with no theme loaded.
**Public API.** Projects may rely on what the custom-elements manifest documents: tags, attributes, properties, events, slots, `--or-*` properties and theme variants, plus the documented Vaadin API of thin wrappers. Do not rename or remove those in one step; keep the old name working with a warning for a release. Shadow structure, internal classes, Lumo module names, `--_*` properties and the parts of our own components are internal, and code that depends on Vaadin internals stays in this repo.

**Choosing the approach.** Use the first that fits:

1. A documented custom property, for a value such as a color, size or spacing.
2. A `::part()` rule in `ui/component/theme/src/components/or-vaadin-<name>.css`, keyed on the real tag.
3. A Lumo module in that file, for rules that must live inside the shadow DOM, such as `:host(...)` states, `::slotted()` or internal elements.
4. A theme variant: a Lumo module scoped by `:host([theme~="<variant>"])`, for one small change to an existing component, such as order, direction or colors. Lumo support for a Vaadin base variant, like `reverse`, keeps the Vaadin name.
5. A new `or-vaadin-<name>` component when the change touches several of those, needs another slot or template, or changes the semantics, as `or-vaadin-toggle` and `or-vaadin-radio-card` do:
- Override `static get is()` and call `registerStyles("or-vaadin-<name>", …)` at module top, before the class is defined.
- Reuse the Lumo look of the parts it keeps by listing their modules for the new tag, and style them through the Vaadin properties those modules read. Without listed modules the Vaadin base styles apply and need out-specifying with `:host(...)`.
- Keep the slots the Vaadin mixins fill and the part names the reused modules target.
- Expose `--or-*` properties with hard-coded fallbacks, and slots, rather than parts.

A look specific to one project stays in that project, built on the above. Do not add `static get styles()` to a wrapper whose Vaadin class drops its base styles under Lumo, and do not call `registerStyles()` for a `vaadin-*` tag, as it reaches every instance and subclass.

### Registering an input type in the input pipeline

Expand Down
34 changes: 34 additions & 0 deletions ui/app/storybook/docs/components/or-vaadin-radio-card.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{/* prettier-ignore-start */}

{/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/}

{/* prettier-ignore-end */}

import { Meta } from "@storybook/addon-docs/blocks";
import * as ComponentStory from "../../../../component/or-vaadin-components/stories/or-vaadin-radio-card.stories"; // stories need relative imports
import { ComponentDocs } from "../../util/componentdocs.mdx";

<Meta title="Components/Radio card" />

<ComponentDocs
story={ComponentStory}
options={{ displayName: "Radio card" }}
elementsjson={ComponentStory.customElements}
/>
34 changes: 34 additions & 0 deletions ui/app/storybook/docs/components/or-vaadin-radio-group.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{/* prettier-ignore-start */}

{/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/}

{/* prettier-ignore-end */}

import { Meta } from "@storybook/addon-docs/blocks";
import * as ComponentStory from "../../../../component/or-vaadin-components/stories/or-vaadin-radio-group.stories"; // stories need relative imports
import { ComponentDocs } from "../../util/componentdocs.mdx";

<Meta title="Components/Radio group" />

<ComponentDocs
story={ComponentStory}
options={{ displayName: "Radio group" }}
elementsjson={ComponentStory.customElements}
/>
6 changes: 3 additions & 3 deletions ui/component/or-vaadin-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ document.body.appendChild(textField);

### Group fields

The group fields `or-vaadin-checkbox-group` and `or-vaadin-radio-group` should use the "native" Vaadin child components, `vaadin-checkbox` and `vaadin-radio-button`, respectively. These are registered when importing the group fields.
The group field `or-vaadin-checkbox-group` takes `vaadin-checkbox` children and `or-vaadin-radio-group` takes `or-vaadin-radio-button` children. These are registered when importing the group fields.

```html
<or-vaadin-checkbox-group>
Expand All @@ -39,8 +39,8 @@ The group fields `or-vaadin-checkbox-group` and `or-vaadin-radio-group` should u
</or-vaadin-checkbox-group>

<or-vaadin-radio-group>
<vaadin-radio-button></vaadin-radio-button>
<vaadin-radio-button></vaadin-radio-button>
<or-vaadin-radio-button></or-vaadin-radio-button>
<or-vaadin-radio-button></or-vaadin-radio-button>
</or-vaadin-radio-group>
```

Expand Down
2 changes: 2 additions & 0 deletions ui/component/or-vaadin-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export * from "./or-vaadin-menu-bar";
export * from "./or-vaadin-multi-select-combo-box";
export * from "./or-vaadin-number-field";
export * from "./or-vaadin-password-field";
export * from "./or-vaadin-radio-button";
export * from "./or-vaadin-radio-card";
export * from "./or-vaadin-radio-group";
export * from "./or-vaadin-select";
export * from "./or-vaadin-slider";
Expand Down
27 changes: 27 additions & 0 deletions ui/component/or-vaadin-components/src/or-vaadin-radio-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { customElement } from "lit/decorators.js";
import { RadioButton } from "@vaadin/radio-group/vaadin-radio-button.js";
import type { OrVaadinComponent } from "./util";
import type { LitElement } from "lit";

@customElement("or-vaadin-radio-button")
export class OrVaadinRadioButton
extends (RadioButton as new () => RadioButton & LitElement)
implements OrVaadinComponent {}
138 changes: 138 additions & 0 deletions ui/component/or-vaadin-components/src/or-vaadin-radio-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { customElement } from "lit/decorators.js";
import { html, type LitElement } from "lit";
import { RadioButton } from "@vaadin/radio-group/vaadin-radio-button.js";
import { registerStyles, css } from "@vaadin/vaadin-themable-mixin/register-styles.js";
import type { OrVaadinComponent } from "./util";

/*
* The radio keeps the Vaadin look, as the theme injects the Lumo radio-button modules for this tag. These styles only
* lay the radio button out as a card and map the `--or-radio-card-*` properties onto the Vaadin ones.
*/
registerStyles(
"or-vaadin-radio-card",
css`
:host {
--_checked-color: var(--or-radio-card-checked-color, var(--lumo-primary-text-color, #47a942));
--vaadin-selection-color: var(--_checked-color);
--vaadin-radio-button-background: var(--or-radio-card-radio-color, var(--lumo-contrast-20pct, #d9dcd9));
--vaadin-radio-button-dot-color: var(--or-radio-card-dot-color, var(--lumo-primary-contrast-color, #ffffff));
--vaadin-radio-button-size: var(--or-radio-card-radio-size, 24px);
/* Lumo draws the dot as a border, so it takes the radius */
--vaadin-radio-button-dot-size: calc(var(--or-radio-card-dot-size, 8px) / 2);
--vaadin-radio-button-label-padding: 0;

position: relative;
display: flex;
align-items: center;
gap: var(--or-radio-card-gap, var(--lumo-space-s, 8px));
box-sizing: border-box;
padding: var(--or-radio-card-padding, var(--lumo-space-m, 16px));
border-radius: var(--or-radio-card-radius, var(--lumo-border-radius-l, 12px));
background: var(--or-radio-card-background, var(--lumo-contrast-5pct, #f2f3f2));
color: var(--or-radio-card-color, var(--lumo-body-text-color, #1f211f));
cursor: var(--lumo-clickable-cursor, pointer);
-webkit-tap-highlight-color: transparent;
transition:
background-color 0.15s,
color 0.15s;
}

:host([checked]) {
background: var(--or-radio-card-checked-background, var(--lumo-primary-color-10pct, #e9f5e8));
color: var(--_checked-color);
}

[part="radio"] {
flex: none;
margin: 0;
}

:host([theme~="reverse"]) [part="radio"] {
order: 1;
}

::slotted([slot="icon"]) {
flex: none;
}

::slotted([slot="label"]) {
flex: 1;
min-width: 0;
}

/* The native input covers the whole card, so a click anywhere on it selects the option */
::slotted(input) {
position: absolute;
inset: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
border: 0;
border-radius: inherit;
opacity: 0;
appearance: none;
cursor: inherit;
}
`
);

/**
* A radio button shown as a card with an optional icon, for use in `<or-vaadin-radio-group>` next to or instead
* of `<or-vaadin-radio-button>`. The whole card selects the option, and the checked card takes the checked colors.
*
* @customElement "or-vaadin-radio-card"
* @slot icon - Icon shown before the label. It takes the card's text color through `currentColor`.
* @slot label - Label content, laid out by the consumer. Use the `label` attribute instead for plain text.
* @attr {string} theme - Set to `reverse` to show the radio at the end of the card instead of the start.
* @fires {CustomEvent} checked-changed - Fired when the `checked` property changes.
* @cssprop --or-radio-card-background - Card background when not checked.
* @cssprop --or-radio-card-color - Text and icon color when not checked.
* @cssprop --or-radio-card-checked-background - Card background when checked.
* @cssprop --or-radio-card-checked-color - Text and icon color, and radio fill, when checked.
* @cssprop --or-radio-card-radio-color - Radio fill when not checked.
* @cssprop --or-radio-card-dot-color - Color of the dot in a checked radio.
* @cssprop --or-radio-card-radio-size - Diameter of the radio.
* @cssprop --or-radio-card-dot-size - Diameter of the dot in a checked radio.
* @cssprop --or-radio-card-gap - Space between the radio, icon and label.
* @cssprop --or-radio-card-padding - Padding of the card.
* @cssprop --or-radio-card-radius - Corner radius of the card.
* @cssprop --or-radio-card-spacing - Space between the cards of an `<or-vaadin-radio-group>`, applied by the theme.
*/
@customElement("or-vaadin-radio-card")
export class OrVaadinRadioCard
extends (RadioButton as new () => RadioButton & LitElement)
implements OrVaadinComponent
{
static get is() {
return "or-vaadin-radio-card";
}

protected override render() {
return html`
<div part="radio" aria-hidden="true"></div>
<slot name="icon"></slot>
<slot name="label"></slot>
<slot name="input"></slot>
`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { customElement } from "lit/decorators.js";
import { RadioGroup } from "@vaadin/radio-group";
import type { OrVaadinComponent } from "./util";
import "./or-vaadin-radio-button";

@customElement("or-vaadin-radio-group")
export class OrVaadinRadioGroup extends RadioGroup implements OrVaadinComponent {}
Loading
Loading