Add the Fluksio look, and make it the one a dashboard starts in
Docs / docs (push) Successful in 48s
Playwright Tests / test-playwright (1, 2) (push) Failing after 16s
Playwright Tests / test-playwright (2, 2) (push) Failing after 13s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 49s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 13s
Docs / docs (push) Successful in 48s
Playwright Tests / test-playwright (1, 2) (push) Failing after 16s
Playwright Tests / test-playwright (2, 2) (push) Failing after 13s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 49s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 13s
Two looks were somebody else's language spoken well, and neither was the product's. A dashboard nobody has dressed yet should look like the rest of the app, so there is now a third set that follows the root DESIGN-GUIDELINES.md to the letter — `--card` surfaces told from the page by a hairline and a low shadow rather than by colour, every control a pill, 16px panels, frosted floating chrome, one slate-blue accent spent on what a person can act on — and it is what `look` means when nothing says otherwise. That also turns the exemption the other way round. The dashboard is still allowed to look unlike the product; it just no longer does so by default. An existing dashboard, which has never named a look, lands on the design it had before any of this. Restraint is the style rather than an omission here: no ripple, no glow, no lift, and a press answered by the colour changing. The one deliberate departure is the selector, which holds its choice in `--primary` rather than the `--accent` the segmented rule asks for — that is a decision about the widget, not about the look, and a control must not change what it signals when the drawing changes. All three sets hold it the same way.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Fluksio: the controls.
|
||||
*
|
||||
* Every control is a pill, and a press is answered by the colour changing and
|
||||
* nothing else — no ripple, no glow, no lift. That restraint is the house
|
||||
* style rather than an omission: the one accent is spent on saying what is
|
||||
* selected, not on saying that something was touched.
|
||||
*
|
||||
* Every one of these is a `ui/core` hook in a different coat — the state, the
|
||||
* keyboard and the `aria-` come from there.
|
||||
*/
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
import type {
|
||||
ButtonProps,
|
||||
InputProps,
|
||||
SegmentedProps,
|
||||
SelectProps,
|
||||
SliderProps,
|
||||
SwitchProps,
|
||||
} from "../core/contract"
|
||||
import { useSegmented, useSliderDrag, useSwitch } from "../core/controls"
|
||||
import { useLook } from "../core/look"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
export function Button({
|
||||
variant = "tonal",
|
||||
pressed,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="fx-button w-full"
|
||||
data-variant={variant}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch(props: SwitchProps) {
|
||||
const { buttonProps } = useSwitch(props)
|
||||
return (
|
||||
<button {...buttonProps} className="fx-switch">
|
||||
<motion.span
|
||||
layout
|
||||
transition={LOOK.fluksio.spring}
|
||||
className="fx-switch-thumb"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks } = useSliderDrag(props)
|
||||
const orientation = props.orientation ?? "horizontal"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"dui-slider fx-slider",
|
||||
orientation === "horizontal" && "w-full",
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
>
|
||||
<div className="dui-slider-body">
|
||||
<span className="dui-slider-rail" aria-hidden>
|
||||
<span className="dui-slider-fill" />
|
||||
</span>
|
||||
{/* The control itself, laid transparent over what is drawn: it keeps
|
||||
the keyboard, the pointer and every `aria-`, and owes the look
|
||||
nothing. */}
|
||||
<input {...inputProps} className="dui-slider-input" />
|
||||
<span className="dui-slider-thumb" aria-hidden />
|
||||
</div>
|
||||
{marks.length > 0 ? (
|
||||
// Decoration: the input itself announces min, max and where it stands.
|
||||
<div aria-hidden className="dui-ticks">
|
||||
{marks.map((mark) => (
|
||||
<span
|
||||
key={mark.percent}
|
||||
className="absolute top-0"
|
||||
// Shifted by its own share of itself: the first label sits flush
|
||||
// left and the last flush right, so neither hangs off the tile.
|
||||
style={{
|
||||
left: `${mark.percent}%`,
|
||||
transform: `translateX(-${mark.percent}%)`,
|
||||
}}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Segmented(props: SegmentedProps) {
|
||||
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
|
||||
const vertical = props.orientation === "vertical"
|
||||
return (
|
||||
<div
|
||||
{...groupProps}
|
||||
data-testid={props.testId}
|
||||
className="fx-segmented"
|
||||
style={
|
||||
vertical
|
||||
? undefined
|
||||
: {
|
||||
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{props.options.map(([value, label], index) => (
|
||||
<button key={value} {...itemProps(index)} className="fx-segment">
|
||||
{index === chosen ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId={thumbId}
|
||||
transition={LOOK.fluksio.spring}
|
||||
className="fx-segment-thumb"
|
||||
/>
|
||||
) : null}
|
||||
<span className="fx-segment-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
}: SelectProps) {
|
||||
// The menu is portalled to `body`, which is outside the canvas — so it
|
||||
// re-states the dashboard's own colours rather than borrowing the app's.
|
||||
const { style } = useLook()
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={value}
|
||||
onValueChange={(selected) => onChange(asOriginal(selected, options))}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className="fx-field justify-between"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<SelectPrimitive.Value placeholder="Choose" />
|
||||
</span>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
style={style}
|
||||
className="fx-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<SelectPrimitive.Viewport>
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={text(option.value)}
|
||||
value={text(option.value)}
|
||||
className="fx-menu-item flex cursor-pointer select-none items-center justify-between gap-2 px-3 py-2 text-sm outline-none"
|
||||
>
|
||||
<SelectPrimitive.ItemText>
|
||||
{option.label ?? text(option.value)}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" aria-hidden />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className="fx-field"
|
||||
value={value}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onCommit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user