Skip to content
Closed
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 docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,12 @@ wheelClass?: string

```ts
class?: string
color?: ColorValue | string
defaultColor?: ColorValue | string
disabled?: boolean
id?: string
mode?: ColorWheelFlowerMode
onChange?: (color: ColorValue) => void
palette?: readonly string[]
```

Expand Down
60 changes: 58 additions & 2 deletions src/components/color-wheel-flower/ColorWheelFlower.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { clsx } from "clsx";
import { twMerge } from "../../lib/twMerge";
import ColorSwatch from "../color-swatch";
import ColorSwatchPicker from "../color-swatch-picker";
import { useColorPickerContext } from "./colorWheelFlowerContext";
import {
type ColorPickerContextType,
useColorPickerContext,
} from "./colorWheelFlowerContext";
import {
createColorFromHsl,
parseColor,
Expand All @@ -31,6 +34,23 @@ export interface ColorWheelFlowerProps {
mode?: ColorWheelFlowerMode;
/** Exactly 31 literal colors, ordered outer ring, middle ring, inner ring, center. */
palette?: readonly string[];
/*
* Standalone use. Inside a `ThemeColorPicker` the surrounding context owns
* the colour and these are ignored; outside one they are the whole state,
* and omitting all of them still renders -- an uncontrolled flower starting
* at white.
*
* They exist because this component is exported from `@pathscale/ui/lab`,
* and an exported component that can only be rendered inside one specific
* parent is a trap. It used to be a crashing one.
*/
/** Controlled selection. A hex, `rgb()` or `hsl()` string, or a parsed value. */
color?: ColorValue | string;
/** Initial selection when uncontrolled. */
defaultColor?: ColorValue | string;
disabled?: boolean;
/** The colour a petal was clicked to choose. */
onChange?: (color: ColorValue) => void;
}

type ColorItem = {
Expand Down Expand Up @@ -173,7 +193,43 @@ function buildColors(palette: readonly string[]): ColorItem[] {
const CENTER_INDEX = LAYOUT.findIndex((l) => l.isCenter);
const ColorWheelFlower: Layout<typeof componentRecipe, ColorWheelFlowerProps> = () => {

const context = useColorPickerContext();
/*
* The surrounding picker if there is one, this component's own props if not.
*
* `useColorPickerContext()` returns `null` outside a `ThemeColorPicker`, and
* the fallback below is what makes that a supported way to use the flower
* rather than a crash. The shapes are identical, so nothing downstream has
* to know which one it got.
*/
const providedContext = useColorPickerContext();

const toColorValue = (
value: ColorValue | string | undefined,
): ColorValue | null =>
value === undefined
? null
: typeof value === "string"
? parseColor(value)
: value;

const WHITE = createColorFromHsl(0, 0, 100, 1);

const [standaloneColor, setStandaloneColor] = createSignal<ColorValue>(
toColorValue(props.defaultColor) ?? WHITE,
);

const context: ColorPickerContextType = providedContext ?? {
// A `color` prop makes it controlled; without one the click below is what
// moves the selection.
color: () => toColorValue(props.color) ?? standaloneColor(),
format: () => "hex" as const,
disabled: () => Boolean(props.disabled),
onChange: (next: ColorValue) => {
if (props.color === undefined) setStandaloneColor(next);
props.onChange?.(next);
},
onFormatChange: () => {},
};

const [selectedIndex, setSelectedIndex] = createSignal<number | null>(null);
const [pulseState, setPulseState] = createSignal<{
Expand Down
39 changes: 28 additions & 11 deletions src/components/color-wheel-flower/colorWheelFlowerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,33 @@ export interface ColorPickerContextType {
onFormatChange: (format: ColorFormat) => void;
}

export const ColorPickerContext = createContext<
ColorPickerContextType | undefined
>(undefined);
/*
* The default is `null`, and it has to be something.
*
* Solid 2 treats `createContext(undefined)` as the *default-less* form: the
* absence of a default is what makes `useContext` throw `ContextNotFoundError`
* outside a provider. This context was declared `createContext<T | undefined>(undefined)`
* and paired with a hook that checked for a missing value and threw a friendly
* message -- a check that could never run, because `useContext` threw first.
* The friendly message also named `ColorPickerContext.Provider`, which Solid 2
* does not have.
*
* What that cost: `ColorWheelFlower` is exported from `@pathscale/ui/lab`, and
* rendering one outside a `ThemeColorPicker` did not degrade or warn. It threw
* during render, which in Solid 2 halts the reactive system for the whole
* page. One component on one route took an entire application down.
*/
export const ColorPickerContext = createContext<ColorPickerContextType | null>(
null,
);

export function useColorPickerContext(): ColorPickerContextType {
const context = useContext(ColorPickerContext);
if (!context) {
throw new Error(
"useColorPickerContext must be used within a ColorPickerContext.Provider",
);
}
return context;
/**
* The surrounding picker's state, or `null` when there is no picker.
*
* Returning `null` rather than throwing is deliberate: a consumer that can
* stand alone decides for itself what to do without a provider, and one that
* genuinely cannot say so in its own words.
*/
export function useColorPickerContext(): ColorPickerContextType | null {
return useContext(ColorPickerContext);
}
37 changes: 37 additions & 0 deletions tests/ps-qa-headless/color-wheel-flower.ron
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Generated from tests/qa-harness/components.ts by tests/qa-harness/generate-checks.ts. Do not edit.
//
// ColorWheelFlower, mounted alone on its own harness page. Outcomes for
// this component share one native host; idempotent preparation keeps each
// outcome reproducible by id against a fresh host as well.
[
(
id: "color-wheel-flower-page-paints",
group: "color-wheel-flower",
what: "the ColorWheelFlower page builds and paints",
open: None,
hover: None,
click: None,
subject: "heading:ColorWheelFlower",
expect: Present,
),
(
id: "color-wheel-flower-renders",
group: "color-wheel-flower",
what: "ColorWheelFlower renders a node of its own",
open: None,
hover: None,
click: None,
subject: "fixture",
expect: Paints,
),
(
id: "color-wheel-flower-paints",
group: "color-wheel-flower",
what: "the ColorWheelFlower reaches the renderer with a box",
open: None,
hover: None,
click: None,
subject: "radio:Reset to neutral",
expect: Present,
),
]
37 changes: 37 additions & 0 deletions tests/ps-qa/color-wheel-flower.ron
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Generated from tests/qa-harness/components.ts by tests/qa-harness/generate-checks.ts. Do not edit.
//
// ColorWheelFlower, mounted alone on its own harness page. Outcomes for
// this component share one native host; idempotent preparation keeps each
// outcome reproducible by id against a fresh host as well.
[
(
id: "color-wheel-flower-page-paints",
group: "color-wheel-flower",
what: "the ColorWheelFlower page builds and paints",
open: None,
hover: None,
click: None,
subject: "heading:ColorWheelFlower",
expect: PaintsNamed,
),
(
id: "color-wheel-flower-renders",
group: "color-wheel-flower",
what: "ColorWheelFlower renders a node of its own",
open: None,
hover: None,
click: None,
subject: "fixture",
expect: Paints,
),
(
id: "color-wheel-flower-paints",
group: "color-wheel-flower",
what: "the ColorWheelFlower reaches the renderer with a box",
open: None,
hover: None,
click: None,
subject: "radio:Reset to neutral",
expect: PaintsNamed,
),
]
30 changes: 30 additions & 0 deletions tests/qa-harness/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,36 @@ export const COMPONENTS: ComponentSpec[] = [
subject: "Color undefined",
subjectRole: "option",
},
/*
* The flower on its own, with no `ThemeColorPicker` around it.
*
* That is the arrangement that used to throw. It is exported from
* `@pathscale/ui/lab`, so a reader can write exactly this, and it read a
* context declared `createContext(undefined)` -- the default-less form in
* Solid 2, which throws `ContextNotFoundError` before the component's own
* "you must use this inside a provider" guard can run. The throw halted the
* reactive system and blanked the page it was on.
*
* `complex-color-wheel` did not cover it: that fixture mounts the flower
* under a wheel that supplies the context, which is the case that always
* worked.
*/
{
id: "color-wheel-flower",
component: "ColorWheelFlower",
kind: "display",
/*
* The centre petal, by name.
*
* Not the component name: the fixture renders that on a labelled wrapper,
* so it is there whether or not the component rendered anything, and a
* check that asserts it passes against a component that threw. This name
* comes from inside the flower, so nothing paints it unless the flower
* built its palette.
*/
subject: "Reset to neutral",
subjectRole: "radio",
},
{
id: "color-wheel",
component: "ColorWheel",
Expand Down
2 changes: 2 additions & 0 deletions tests/qa-harness/generate-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const IMPORT_FORM: Record<string, string> = {
"ConnectionSettings": "named",
"ColorSwatch": "default",
"ColorWheel": "named",
"ColorWheelFlower": "named",
"ComplexColorWheel": "named",
"autosize": "named",
"boundsFromRows": "named",
Expand Down Expand Up @@ -257,6 +258,7 @@ const MODULE_PATHS: Record<string, string> = {
"connection-settings": "components/connection-settings",
"color-swatch": "components/color-swatch",
"color-wheel": "components/color-wheel",
"color-wheel-flower": "components/color-wheel-flower",
"complex-color-wheel": "components/color-wheel",
"composer": "components/composer",
"cookie-consent": "components/immersive-landing",
Expand Down
Loading