Merge pull request 'ui/onboarding: use css modules' (#5171) from hgl/clan-core:css into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5171
Reviewed-by: brianmcgee <brian@bmcgee.ie>
This commit is contained in:
brianmcgee
2025-09-19 09:56:37 +00:00
28 changed files with 428 additions and 271 deletions

View File

@@ -8,14 +8,14 @@ import * as ButtonStories from "./Button.stories";
Buttons have a simple hierarchy, `primary` or `secondary`, and two sizes, `default` and `s`.
A `Button` can also have a label with a `startIcon`, an `endIcon` or both:
A `Button` can also have a label with a `icon`, an `endIcon` or both:
```tsx
<Button hierarchy="primary">Label</>
<Button hierarchy="secondary" size="s">Label</>
<Button hierarchy="primary" startIcon="Flash">Label</>
<Button hierarchy="primary" icon="Flash">Label</>
<Button hierarchy="primary" size="s" endIcon="Flash">Label</>
<Button hierarchy="primary" startIcon="Flash" endIcon="Flash">Label</>
<Button hierarchy="primary" icon="Flash" endIcon="Flash">Label</>
```
To create a `Button` which is just an icon:

View File

@@ -8,11 +8,11 @@
&.s {
@apply h-[1.625rem] px-3 py-1.5 rounded-[0.125rem];
&:has(> .icon-start):has(> .label) {
&.hasIcon {
@apply pl-2;
}
&:has(> .icon-end):has(> .label) {
&.hasEndIcon {
@apply pr-2;
}
}
@@ -20,11 +20,11 @@
&.xs {
@apply h-[1.125rem] gap-0.5 p-2 rounded-[0.125rem];
&:has(> .icon-start):has(> .label) {
&.hasIcon {
@apply pl-1.5;
}
&:has(> .icon-end):has(> .label) {
&.hasEndIcon {
@apply pr-1.5;
}
}
@@ -63,10 +63,6 @@
&:disabled {
@apply bg-def-acc-3 border-solid border-def-3 fg-def-3 shadow-none;
}
& > .icon {
@apply fg-inv-1;
}
}
&.secondary {
@@ -108,25 +104,21 @@
&:disabled {
@apply bg-def-2 border-solid border-def-2 fg-def-3 shadow-none;
}
& > .icon {
@apply fg-def-1;
&.icon-loading {
color: #0051ff;
}
}
}
&.icon {
&.fit {
@apply w-fit;
}
&.iconOnly {
@apply p-2;
}
&:has(> .icon-start):has(> .label) {
&.hasIcon {
@apply pl-3.5;
}
&:has(> .icon-end):has(> .label) {
&.hasEndIcon {
@apply pr-3.5;
}
@@ -134,11 +126,34 @@
@apply cursor-wait;
}
& > span.typography {
& > .typography {
@apply max-w-full overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.button.in-HostFileInput-horizontal {
@apply max-w-[18rem];
@apply grow;
}
.button.in-TagSelect {
@apply ml-auto;
}
.button.in-UpdateProgress {
@apply mt-3;
}
.button.in-InstallProgress {
@apply mt-3;
}
.button.in-FlashProgress {
@apply mt-2;
}
.button.in-CheckHardware {
@apply gap-3;
}
.button.in-ConfigureService {
@apply ml-auto;
}
/* button group */
.button-group .button:first-child {
border-top-right-radius: 0;

View File

@@ -52,17 +52,12 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
</div>
<div>
<Button data-testid="default-start-icon" {...props} startIcon="Flash">
<Button data-testid="default-start-icon" {...props} icon="Flash">
Label
</Button>
</div>
<div>
<Button
data-testid="small-start-icon"
{...props}
startIcon="Flash"
size="s"
>
<Button data-testid="small-start-icon" {...props} icon="Flash" size="s">
Label
</Button>
</div>
@@ -70,7 +65,7 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
<Button
data-testid="xsmall-start-icon"
{...props}
startIcon="Flash"
icon="Flash"
size="xs"
>
Label
@@ -80,7 +75,7 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
<Button
data-testid="default-disabled-start-icon"
{...props}
startIcon="Flash"
icon="Flash"
disabled={true}
>
Disabled
@@ -90,7 +85,7 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
<Button
data-testid="small-disabled-start-icon"
{...props}
startIcon="Flash"
icon="Flash"
size="s"
disabled={true}
>
@@ -102,7 +97,7 @@ const ButtonExamples: Component<ButtonProps> = (props) => (
<Button
data-testid="xsmall-disabled-start-icon"
{...props}
startIcon="Flash"
icon="Flash"
size="xs"
disabled={true}
>

View File

@@ -1,14 +1,16 @@
import { splitProps, type JSX } from "solid-js";
import { mergeProps, splitProps, type JSX } from "solid-js";
import cx from "classnames";
import { Typography } from "../Typography/Typography";
import { Button as KobalteButton } from "@kobalte/core/button";
import "./Button.css";
import styles from "./Button.module.css";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import { Loader } from "@/src/components/Loader/Loader";
import { getInClasses, joinByDash, keepTruthy } from "@/src/util";
export type Size = "default" | "s" | "xs";
export type Hierarchy = "primary" | "secondary";
export type Elasticity = "default" | "fit";
export type Action = () => Promise<void>;
@@ -19,79 +21,78 @@ export interface ButtonProps
ghost?: boolean;
children?: JSX.Element;
icon?: IconVariant;
startIcon?: IconVariant;
endIcon?: IconVariant;
class?: string;
loading?: boolean;
elasticity?: Elasticity;
in?:
| "HostFileInput-horizontal"
| "TagSelect"
| "UpdateProgress"
| "InstallProgress"
| "FlashProgress"
| "CheckHardware"
| "ConfigureService";
}
const iconSizes: Record<Size, string> = {
default: "1rem",
s: "0.8125rem",
xs: "0.625rem",
};
export const Button = (props: ButtonProps) => {
const [local, other] = splitProps(props, [
const [local, other] = splitProps(
mergeProps(
{ size: "default", hierarchy: "primary", elasticity: "default" } as const,
props,
),
[
"children",
"hierarchy",
"size",
"ghost",
"icon",
"startIcon",
"endIcon",
"class",
"loading",
]);
const size = local.size || "default";
const hierarchy = local.hierarchy || "primary";
const iconSize = iconSizes[local.size || "default"];
const loadingClass =
"w-4 opacity-100 mr-[revert] transition-all duration-500 ease-linear";
const idleClass =
"hidden w-0 opacity-0 top-0 left-0 -mr-2 transition-all duration-500 ease-linear";
"elasticity",
"disabled",
"in",
"onClick",
],
);
return (
<KobalteButton
class={cx(
local.class,
"button", // default button class
size,
hierarchy,
styles.button, // default button class
local.size != "default" && styles[local.size],
styles[local.hierarchy],
local.elasticity != "default" && local.elasticity,
getInClasses(styles, local.in),
{
icon: local.icon,
loading: props.loading,
ghost: local.ghost,
[styles.iconOnly]: local.icon && !local.children,
[styles.hasIcon]: local.icon && local.children,
[styles.hasEndIcon]: local.endIcon && local.children,
[styles.loading]: local.loading,
[styles.ghost]: local.ghost,
},
)}
onClick={props.onClick}
disabled={props.disabled || props.loading}
onClick={local.onClick}
disabled={local.disabled || local.loading}
{...other}
>
<Loader
hierarchy={hierarchy}
class={cx({
[idleClass]: !props.loading,
[loadingClass]: props.loading,
})}
<Loader hierarchy={local.hierarchy} loading={local.loading} in="Button" />
{local.icon && (
<Icon
icon={local.icon}
in={keepTruthy(
"Button",
joinByDash("Button", local.hierarchy),
local.size == "default" ? "" : joinByDash("Button", local.size),
)}
/>
{local.startIcon && (
<Icon icon={local.startIcon} class="icon-start" size={iconSize} />
)}
{local.icon && !local.children && (
<Icon icon={local.icon} class="icon" size={iconSize} />
)}
{local.children && !local.icon && (
{local.children && (
<Typography
class="label"
class={styles.typography}
hierarchy="label"
size={local.size || "default"}
size={local.size}
inverted={local.hierarchy === "primary"}
weight="bold"
tag="span"
@@ -100,8 +101,15 @@ export const Button = (props: ButtonProps) => {
</Typography>
)}
{local.endIcon && (
<Icon icon={local.endIcon} class="icon-end" size={iconSize} />
{local.endIcon && local.children && (
<Icon
icon={local.endIcon}
in={keepTruthy(
"Button",
joinByDash("Button", local.hierarchy),
local.size == "default" ? "" : joinByDash("Button", local.size),
)}
/>
)}
</KobalteButton>
);

View File

@@ -1,11 +1,3 @@
.vertical_button {
@apply w-fit;
}
.horizontal_button {
@apply grow max-w-[18rem];
}
/* Vendored from tooltip */
.tooltipContent {
@apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none;

View File

@@ -85,14 +85,17 @@ export const HostFileInput = (props: HostFileInputProps) => {
<Button
hierarchy="secondary"
size={styleProps.size}
startIcon="Folder"
icon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
class={cx(
styleProps.orientation === "vertical"
? styles.vertical_button
: styles.horizontal_button,
)}
elasticity={
styleProps.orientation === "vertical" ? "fit" : undefined
}
in={
styleProps.orientation == "horizontal"
? `HostFileInput-${styleProps.orientation}`
: undefined
}
>
{props.placeholder || "No Selection"}
</Button>
@@ -115,14 +118,17 @@ export const HostFileInput = (props: HostFileInputProps) => {
</Tooltip.Portal>
<Tooltip.Trigger
as={Button}
class={cx(
props.orientation === "vertical"
? styles.vertical_button
: styles.horizontal_button,
)}
elasticity={
styleProps.orientation === "vertical" ? "fit" : undefined
}
in={
styleProps.orientation == "horizontal"
? `HostFileInput-${styleProps.orientation}`
: undefined
}
hierarchy="secondary"
size={styleProps.size}
startIcon="Folder"
icon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
>

View File

@@ -20,16 +20,6 @@
@apply w-full relative;
}
.icon {
@apply absolute left-1.5;
top: calc(50% - 0.5rem);
&.iconSmall {
@apply left-[0.3125rem] size-[0.75rem];
top: calc(50% - 0.3125rem);
}
}
.input {
@apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1 w-full;
@apply px-[1.625rem] py-1.5 rounded-sm;

View File

@@ -17,6 +17,7 @@ import { Label } from "@/src/components/Form/Label";
import { Orienter } from "@/src/components/Form/Orienter";
import { CollectionNode } from "@kobalte/core";
import styles from "./MachineTags.module.css";
import { keepTruthy } from "@/src/util";
export interface MachineTag {
value: string;
@@ -247,9 +248,10 @@ export const MachineTags = (props: MachineTagsProps) => {
icon="Tag"
color="secondary"
inverted={props.inverted}
class={cx(styles.icon, {
[styles.iconSmall]: props.size == "s",
})}
in={keepTruthy(
"MachineTags",
props.size == "s" && "MachineTags-s",
)}
/>
<Combobox.Input
onKeyDown={onKeyDown}

View File

@@ -0,0 +1,32 @@
.icon.in-Button {
width: 1rem;
height: 1rem;
}
.icon.in-Button-primary {
@apply fg-inv-1;
}
.icon.in-Button-secondary {
@apply fg-def-1;
}
.icon.in-Button-s {
width: 0.8125rem;
height: 0.8125rem;
}
.icon.in-Button-xs {
width: 0.625rem;
height: 0.625rem;
}
.icon.in-WorkflowPanelTitle {
@apply size-9;
}
.icon.in-MachineTags {
@apply absolute left-1.5;
top: calc(50% - 0.5rem);
}
.icon.in-MachineTags-s {
@apply left-[0.3125rem] size-[0.75rem];
top: calc(50% - 0.3125rem);
}
.icon.in-ConfigureRole {
@apply ml-auto;
}

View File

@@ -1,5 +1,5 @@
import cx from "classnames";
import { Component, JSX, splitProps } from "solid-js";
import { Component, JSX, mergeProps, splitProps } from "solid-js";
import Address from "@/icons/address.svg";
import AI from "@/icons/ai.svg";
@@ -55,8 +55,10 @@ import User from "@/icons/user.svg";
import WarningFilled from "@/icons/warning-filled.svg";
import { Dynamic } from "solid-js/web";
import { Color, fgClass } from "../colors";
import styles from "./Icon.module.css";
import { Color } from "../colors";
import colorsStyles from "../colors.module.css";
import { getInClasses } from "@/src/util";
const icons = {
Address,
@@ -119,42 +121,52 @@ const viewBoxes: Partial<Record<IconVariant, string>> = {
ClanIcon: "0 0 72 89",
};
type In =
| "Button"
| "Button-primary"
| "Button-secondary"
| "Button-s"
| "Button-xs"
| "MachineTags"
| "MachineTags-s"
| "ConfigureRole"
// TODO: better name
| "WorkflowPanelTitle";
export interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {
icon: IconVariant;
class?: string;
size?: number | string;
color?: Color;
inverted?: boolean;
in?: In | In[];
}
const Icon: Component<IconProps> = (props) => {
const [local, iconProps] = splitProps(props, [
"icon",
"color",
"class",
"size",
"inverted",
]);
const IconComponent = () => icons[local.icon];
const [local, iconProps] = splitProps(
mergeProps({ color: "primary", size: "1em" } as const, props),
["icon", "color", "size", "inverted", "in"],
);
const component = () => icons[local.icon];
// we need to adjust the view box for certain icons
const viewBox = () => viewBoxes[local.icon] ?? "0 0 48 48";
return IconComponent() ? (
const viewBox = () => viewBoxes[local.icon] || "0 0 48 48";
return (
<Dynamic
component={IconComponent()}
class={cx("icon", local.class, fgClass(local.color, local.inverted), {
inverted: local.inverted,
})}
component={component()}
class={cx(
styles.icon,
colorsStyles[local.color],
getInClasses(styles, local.in),
{
[colorsStyles.inverted]: local.inverted,
},
)}
data-icon-name={local.icon}
width={local.size || "1em"}
height={local.size || "1em"}
width={local.size}
height={local.size}
viewBox={viewBox()}
ref={iconProps.ref}
{...iconProps}
/>
) : undefined;
);
};
export default Icon;

View File

@@ -53,6 +53,16 @@
animation: moveLoaderChild 1.8s ease-in-out infinite;
}
.loader.in-Button {
/* FIXME: using hidden prevents transition from working, but without it, flex
creates a gap for it */
@apply hidden w-0 opacity-0 transition-all duration-500 ease-linear;
&.loading {
@apply block w-4 opacity-100 mr-[revert];
}
}
@keyframes moveLoaderWrapper {
0% {
transform: translate(0%, 0%) rotate(-45deg);

View File

@@ -1,4 +1,5 @@
// Loader.tsx
import { mergeProps } from "solid-js";
import styles from "./Loader.module.css";
import cx from "classnames";
@@ -6,23 +7,28 @@ export type Hierarchy = "primary" | "secondary";
export interface LoaderProps {
hierarchy?: Hierarchy;
class?: string;
size?: "default" | "l" | "xl";
loading?: boolean;
in?: "Button";
}
export const Loader = (props: LoaderProps) => {
const size = () => props.size || "default";
const local = mergeProps(
{ hierarchy: "primary", size: "default", loading: false } as const,
props,
);
return (
<div
class={cx(
styles.loader,
styles[props.hierarchy || "primary"],
props.class,
styles[local.hierarchy],
local.in ? styles[`in-${local.in}` as `in-${typeof local.in}`] : "",
{
[styles.sizeDefault]: size() === "default",
[styles.sizeLarge]: size() === "l",
[styles.sizeExtraLarge]: size() === "xl",
[styles.sizeDefault]: local.size === "default",
[styles.sizeLarge]: local.size === "l",
[styles.sizeExtraLarge]: local.size === "xl",
[styles.loading]: local.loading,
},
)}
>

View File

@@ -189,6 +189,7 @@ export const Multiple: Story = {
// Test with lots of modules
options: machinesAndTags,
placeholder: "Search for Machine or Tags",
values: [],
renderItem: (item: MachineOrTag, opts: ItemRenderOptions) => {
console.log("Rendering item:", item, "opts", opts);
return (
@@ -223,13 +224,14 @@ export const Multiple: Story = {
)}
</Show>
</Combobox.ItemLabel>
<div class="ml-auto">
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
/>
</div>
</div>
);
},
},

View File

@@ -41,8 +41,8 @@ export function TagSelect<T extends { value: unknown }>(
icon="Settings"
hierarchy="primary"
ghost
class="ml-auto"
size="xs"
in="TagSelect"
/>
</div>
<Combobox<T>

View File

@@ -102,7 +102,7 @@ const Machines = () => {
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
icon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine

View File

@@ -98,7 +98,7 @@ export const SidebarHeader = () => {
hierarchy="secondary"
ghost
size="xs"
startIcon="Plus"
icon="Plus"
onClick={() => navigateToOnboarding(navigate, true)}
>
Add

View File

@@ -88,7 +88,7 @@ export function SidebarSectionForm<
<Button
hierarchy="primary"
size="xs"
startIcon="Checkmark"
icon="Checkmark"
ghost
type="submit"
>

View File

@@ -0,0 +1,37 @@
.primary {
@apply fg-def-1;
&.inverted {
@apply fg-inv-1;
}
}
.secondary {
@apply fg-def-2;
&.inverted {
@apply fg-inv-2;
}
}
.tertiary {
@apply fg-def-3;
&.inverted {
@apply fg-inv-3;
}
}
.quaternary {
@apply fg-def-4;
&.inverted {
@apply fg-inv-4;
}
}
.error {
@apply fg-semantic-error-4;
&.inverted {
@apply fg-semantic-error-1;
}
}
.inherit {
@apply text-inherit;
}

View File

@@ -195,7 +195,7 @@ export const ClanSettingsModal = (props: ClanSettingsModalProps) => {
<Button
hierarchy="primary"
size="s"
startIcon="Trash"
icon="Trash"
disabled={removeDisabled()}
onClick={onRemove}
>

View File

@@ -68,7 +68,7 @@ export const ListClansModal = (props: ListClansModalProps) => {
hierarchy="secondary"
ghost
size="s"
startIcon="Plus"
icon="Plus"
onClick={() => {
props.onClose?.();
navigateToOnboarding(navigate, true);

View File

@@ -1,70 +0,0 @@
main#welcome {
@apply absolute top-0 left-0;
@apply flex items-center justify-center;
@apply min-h-screen w-full;
div.background {
.layer-1 {
@apply -z-30;
background:
url("./background.png") 0 -69.032px / 100% 119.049% no-repeat,
url("./background.png") 50% / cover no-repeat;
}
.layer-2 {
@apply -z-20;
background: #103131;
mix-blend-mode: screen;
}
.layer-3 {
@apply -z-10;
background: #749095;
mix-blend-mode: soft-light;
}
.layer-1,
.layer-2,
.layer-3 {
@apply absolute top-0 left-0 w-full h-full;
}
svg[data-logo-name="Clan"] {
@apply w-16;
@apply absolute bottom-28 left-1/2 transform -translate-x-1/2;
}
button.list-clans {
@apply absolute bottom-28 right-0 transform -translate-x-1/2;
}
}
& > div.container {
@apply flex flex-col items-center justify-evenly gap-y-20 size-fit;
& > div.welcome {
@apply flex flex-col w-80 gap-y-6;
& > div.separator {
@apply grid grid-cols-3 grid-rows-1 gap-x-4 items-center;
}
}
& > div.setup {
@apply flex flex-col w-[33rem] gap-y-5;
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
& > div.header {
@apply flex items-center justify-start gap-x-2;
}
form {
@apply flex flex-col gap-y-5;
& > div.form-controls {
@apply flex justify-end pt-6;
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
.onboarding {
@apply absolute top-0 left-0;
@apply flex items-center justify-center;
@apply min-h-screen w-full;
}
.background {
svg[data-logo-name="Clan"] {
@apply w-16;
@apply absolute bottom-28 left-1/2 transform -translate-x-1/2;
}
}
.backgroundLayer1 {
@apply -z-30;
background:
url("./background.png") 0 -69.032px / 100% 119.049% no-repeat,
url("./background.png") 50% / cover no-repeat;
}
.backgroundLayer2 {
@apply -z-20;
background: #103131;
mix-blend-mode: screen;
}
.backgroundLayer3 {
@apply -z-10;
background: #749095;
mix-blend-mode: soft-light;
}
.backgroundLayer1,
.backgroundLayer2,
.backgroundLayer3 {
@apply absolute top-0 left-0 w-full h-full;
}
.listClans {
@apply absolute bottom-28 right-0 transform -translate-x-1/2;
}
.container {
@apply flex flex-col items-center justify-evenly gap-y-20 size-fit;
}
.welcome {
@apply flex flex-col w-80 gap-y-6;
}
.welcomeSeparator {
@apply grid grid-cols-3 grid-rows-1 gap-x-4 items-center;
}
.setup {
@apply flex flex-col w-[33rem] gap-y-5;
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
form {
@apply flex flex-col gap-y-5;
}
}
.setupHeader {
@apply flex items-center justify-start gap-x-2;
}
.setupFormControls {
@apply flex justify-end pt-6;
}

View File

@@ -12,7 +12,7 @@ import {
useNavigate,
useSearchParams,
} from "@solidjs/router";
import "./Onboarding.css";
import styles from "./Onboarding.module.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Alert } from "@/src/components/Alert/Alert";
@@ -66,21 +66,22 @@ const background = (props: { state: State; form: FormStore<SetupForm> }) => {
const [showModal, setShowModal] = createSignal(false);
return (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
<div class="layer-3" />
<div class={styles.background}>
<div class={styles.backgroundLayer1} />
<div class={styles.backgroundLayer2} />
<div class={styles.backgroundLayer3} />
<Logo variant="Clan" inverted />
<div class={styles.listClans}>
<Button
class="list-clans"
hierarchy="primary"
ghost
size="s"
startIcon="Grid"
icon="Grid"
onClick={() => setShowModal(true)}
>
All Clans
</Button>
</div>
<Show when={showModal()}>
<ListClansModal onClose={() => setShowModal(false)} />
</Show>
@@ -113,7 +114,7 @@ const welcome = (props: {
};
return (
<div class="welcome">
<div class={styles.welcome}>
<Typography
hierarchy="headline"
size="xxl"
@@ -144,7 +145,7 @@ const welcome = (props: {
>
Start building
</Button>
<div class="separator">
<div class={styles.welcomeSeparator}>
<Divider orientation="horizontal" />
<Typography
hierarchy="body"
@@ -285,9 +286,9 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
};
return (
<main id="welcome">
<main class={styles.onboarding}>
{background({ form: setupForm, state: state() })}
<div class="container">
<div class={styles.container}>
<Switch>
<Match when={state() === "welcome"}>
{welcome({
@@ -298,8 +299,8 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Match>
<Match when={state() === "setup"}>
<div class="setup">
<div class="header">
<div class={styles.setup}>
<div class={styles.setupHeader}>
<Button
hierarchy="secondary"
ghost={true}
@@ -377,7 +378,7 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
</Field>
</Fieldset>
<div class="form-controls">
<div class={styles.setupFormControls}>
<Button
type="submit"
hierarchy="primary"

View File

@@ -38,3 +38,52 @@ export const removeEmptyStrings = <T>(obj: T): T => {
return obj;
};
// Join truthy values with dashes
// joinByDash("button", "", false, null, "x")'s return type is "button-x"
export const joinByDash = <
T extends readonly (string | null | undefined | false)[],
>(
...args: T
): DashJoined<FilterFalsy<T>> => {
return keepTruthy(...args).join("-") as DashJoined<FilterFalsy<T>>;
};
// Turn a component's "in" attribute value to a list of css module class names
export const getInClasses = <T extends Record<string, string>, U>(
styles: T,
localIn?: U | U[],
): string[] => {
if (!localIn) {
return [""];
}
let localIns: U[];
if (!Array.isArray(localIn)) {
localIns = [localIn];
} else {
localIns = localIn;
}
return localIns.map((localIn) => styles[`in-${localIn}`]);
};
export const keepTruthy = <T>(...items: T[]): FilterFalsy<T[]> =>
items.filter(Boolean) as FilterFalsy<T[]>;
type FilterFalsy<T extends readonly unknown[]> = T extends readonly [
infer Head,
...infer Tail,
]
? Head extends "" | null | undefined | false
? FilterFalsy<Tail>
: [Head, ...FilterFalsy<Tail>]
: [];
type DashJoined<T extends readonly string[]> = T extends readonly [infer First]
? First
: T extends readonly [infer First, ...infer Rest]
? First extends string
? Rest extends readonly string[]
? `${First}-${DashJoined<Rest>}`
: never
: never
: "";

View File

@@ -158,8 +158,9 @@ const UpdateProgress = () => {
</Typography>
<Button
hierarchy="primary"
class="mt-3 w-fit"
elasticity="fit"
size="s"
in="UpdateProgress"
onClick={handleCancel}
>
Cancel
@@ -180,7 +181,7 @@ const UpdateDone = (props: UpdateDoneProps) => {
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
<div class="rounded-full bg-semantic-success-4">
<Icon icon="Checkmark" class="size-9" />
<Icon icon="Checkmark" in="WorkflowPanelTitle" />
</div>
<Typography
hierarchy="title"

View File

@@ -349,8 +349,9 @@ const FlashProgress = () => {
<LoadingBar />
<Button
hierarchy="primary"
class="mt-2 w-fit"
elasticity="fit"
size="s"
in="FlashProgress"
onClick={handleCancel}
>
Cancel
@@ -365,7 +366,7 @@ const FlashDone = () => {
<div class="flex size-full flex-col items-center justify-end bg-inv-4">
<div class="flex size-full max-w-md flex-col items-center justify-center gap-3 pt-6 fg-inv-1">
<div class="rounded-full bg-semantic-success-4">
<Icon icon="Checkmark" class="size-9" />
<Icon icon="Checkmark" in="WorkflowPanelTitle" />
</div>
<Typography
hierarchy="title"

View File

@@ -300,10 +300,10 @@ const CheckHardware = () => {
<Button
disabled={hardwareQuery.isLoading || updatingHardwareReport()}
hierarchy="secondary"
startIcon="Report"
onClick={handleUpdateSummary}
class="flex gap-3"
icon="Report"
loading={hardwareQuery.isFetching || updatingHardwareReport()}
in="CheckHardware"
onClick={handleUpdateSummary}
>
Update hardware report
</Button>
@@ -882,8 +882,9 @@ const InstallProgress = () => {
</Typography>
<Button
hierarchy="primary"
class="mt-3 w-fit"
elasticity="fit"
size="s"
in="InstallProgress"
onClick={handleCancel}
>
Cancel
@@ -904,7 +905,7 @@ const InstallDone = (props: InstallDoneProps) => {
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
<div class="rounded-full bg-semantic-success-4">
<Icon icon="Checkmark" class="size-9" />
<Icon icon="Checkmark" in="WorkflowPanelTitle" />
</div>
<Typography
hierarchy="title"

View File

@@ -219,7 +219,7 @@ const ConfigureService = () => {
color="primary"
ghost
size="s"
class="ml-auto"
in="ConfigureService"
onClick={() => store.close()}
/>
</div>
@@ -419,10 +419,10 @@ const ConfigureRole = () => {
</Show>
</Combobox.ItemLabel>
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
in="ConfigureRole"
/>
</div>
)}