pixelize-design-library 2.4.2-beta.27 → 2.4.2-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/Components/Button/Button.styles.js +22 -2
  2. package/dist/Components/DatePicker/CalendarPanel.d.ts +5 -0
  3. package/dist/Components/DatePicker/CalendarPanel.js +23 -4
  4. package/dist/Components/DatePicker/DatePickerProps.d.ts +21 -0
  5. package/dist/Components/DatePicker/RangeDatePicker.js +2 -2
  6. package/dist/Components/DatePicker/dayStates.d.ts +56 -0
  7. package/dist/Components/DatePicker/dayStates.js +87 -0
  8. package/dist/Theme/assertPalette.d.ts +1 -1
  9. package/dist/Theme/assertPalette.js +3 -2
  10. package/dist/Theme/chakra/createBrandTheme.js +7 -0
  11. package/dist/Theme/chakra/focusRing.styles.d.ts +52 -0
  12. package/dist/Theme/chakra/focusRing.styles.js +46 -0
  13. package/dist/Theme/tokens/brands/index.js +6 -1
  14. package/dist/Theme/tokens/brands/zinc/palette.dark.d.ts +1 -0
  15. package/dist/Theme/tokens/builders/buildDarkPalette.js +6 -1
  16. package/dist/Theme/tokens/builders/focusRing.d.ts +52 -0
  17. package/dist/Theme/tokens/builders/focusRing.js +92 -0
  18. package/dist/Theme/tokens/builders/index.d.ts +1 -0
  19. package/dist/Theme/tokens/builders/index.js +1 -0
  20. package/dist/Theme/tokens/builders/outlineRung.d.ts +8 -2
  21. package/dist/Theme/tokens/builders/outlineRung.js +8 -2
  22. package/dist/Theme/tokens/types.d.ts +21 -1
  23. package/dist/esm/Components/Button/Button.styles.js +22 -2
  24. package/dist/esm/Components/DatePicker/CalendarPanel.js +23 -4
  25. package/dist/esm/Components/DatePicker/RangeDatePicker.js +2 -2
  26. package/dist/esm/Components/DatePicker/dayStates.js +79 -0
  27. package/dist/esm/Theme/assertPalette.js +3 -2
  28. package/dist/esm/Theme/chakra/createBrandTheme.js +7 -0
  29. package/dist/esm/Theme/chakra/focusRing.styles.js +40 -0
  30. package/dist/esm/Theme/tokens/brands/index.js +6 -1
  31. package/dist/esm/Theme/tokens/builders/buildDarkPalette.js +6 -1
  32. package/dist/esm/Theme/tokens/builders/focusRing.js +85 -0
  33. package/dist/esm/Theme/tokens/builders/index.js +1 -0
  34. package/dist/esm/Theme/tokens/builders/outlineRung.js +8 -2
  35. package/dist/esm/index.js +10 -0
  36. package/dist/index.d.ts +6 -1
  37. package/dist/index.js +26 -2
  38. package/package.json +1 -1
@@ -0,0 +1,52 @@
1
+ import type { FocusRingTokens, PaletteProps } from "../types";
2
+ /**
3
+ * The nearest re-lighting of `hex` that clears `target` against EVERY backdrop at once.
4
+ *
5
+ * Not `settleAcross`: that folds one `liftToContrast` per surface, and each lift walks AWAY from
6
+ * the surface it was handed. That is correct when every backdrop sits on the same side of the ink
7
+ * (its case — a set of light surfaces, or a set of dark ones), and wrong here, where a focus ring
8
+ * is squeezed BETWEEN a near-white canvas and a mid-dark button fill: the lift against the fill
9
+ * walks straight back across the canvas it had just cleared. Seeding `settleAcross` with
10
+ * `primary[500]` measured 1.08-1.99 on the light canvas for every brand for exactly that reason.
11
+ *
12
+ * Walking outward from the seed and testing every backdrop at each step keeps the result the
13
+ * CLOSEST passing colour to the brand accent, so the ring still reads as brand chrome. Contrast is
14
+ * a function of relative luminance alone, and lightness sweeps luminance monotonically from 0 to 1,
15
+ * so if any colour on this hue clears the set, this finds it.
16
+ */
17
+ export declare function settleBetween(hex: string, backdrops: readonly string[], target?: number): string;
18
+ /** Everything the ring is derived from. A `PaletteProps` satisfies it; so does a brand source. */
19
+ export type FocusRingSource = Pick<PaletteProps, "primary" | "background" | "backgroundColor">;
20
+ /**
21
+ * The three backdrops a focused button's ring is actually drawn against: the page canvas, a card
22
+ * surface, and — because a solid button's ring sits directly against its own fill — `primary[500]`.
23
+ *
24
+ * Deliberately NOT every named surface. `muted`/`accent`/`quaternary` are mid-tone, and a single
25
+ * band asked to clear those AND a dark brand fill has no solution at any hue — contrast is
26
+ * luminance alone, and `skyline`/`storefront` come out with an empty band. That is what the second
27
+ * tone is for: `halo` owns the fill-side adjacency instead.
28
+ */
29
+ export declare const focusRingBackdrops: (source: FocusRingSource) => string[];
30
+ /**
31
+ * The two-tone `:focus-visible` indicator.
32
+ *
33
+ * Chakra's stock ring (`shadows.outline`, a fixed `rgba(66,153,225,.6)`) measured 1.83:1 on the
34
+ * lavender canvas and 1.38:1 on a primary button — a focus indicator you cannot see, on all nine
35
+ * brands. It is one hardcoded blue, so no palette could have saved it.
36
+ *
37
+ * `halo` is the surface tone, drawn as the inner band so the indicator separates from a filled
38
+ * control whatever `colorScheme` painted it. `ring` is the brand accent, walked to the nearest
39
+ * lightness that clears 3:1 on all three backdrops at once — including the halo, so the two tones
40
+ * are always told apart.
41
+ */
42
+ export declare const buildFocusRing: (source: FocusRingSource) => FocusRingTokens;
43
+ /**
44
+ * Re-derives the ring from the FINISHED palette, as the last step of the rung chain.
45
+ *
46
+ * `finalizeLightPalette` and `buildDarkPalette` seed it, but neither is the final word: a brand
47
+ * file may replace `primary` or `background` after the generic derivation runs — `zinc`'s dark
48
+ * palette replaces both, inverting the filled button to near-white — and a ring resolved against
49
+ * the pre-override values is then silently wrong. Same reason `withSurfaceRungs` re-lifts inks the
50
+ * finalizers already derived. `focusRing.test.ts` asserts this is a fixed point.
51
+ */
52
+ export declare const withFocusRing: (palette: PaletteProps) => PaletteProps;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withFocusRing = exports.buildFocusRing = exports.focusRingBackdrops = void 0;
4
+ exports.settleBetween = settleBetween;
5
+ const color_1 = require("./color");
6
+ const outlineRung_1 = require("./outlineRung");
7
+ /** Same lightness granularity `buildDarkPalette` walks its accents at. */
8
+ const LIGHTNESS_STEP = 0.5;
9
+ /**
10
+ * The nearest re-lighting of `hex` that clears `target` against EVERY backdrop at once.
11
+ *
12
+ * Not `settleAcross`: that folds one `liftToContrast` per surface, and each lift walks AWAY from
13
+ * the surface it was handed. That is correct when every backdrop sits on the same side of the ink
14
+ * (its case — a set of light surfaces, or a set of dark ones), and wrong here, where a focus ring
15
+ * is squeezed BETWEEN a near-white canvas and a mid-dark button fill: the lift against the fill
16
+ * walks straight back across the canvas it had just cleared. Seeding `settleAcross` with
17
+ * `primary[500]` measured 1.08-1.99 on the light canvas for every brand for exactly that reason.
18
+ *
19
+ * Walking outward from the seed and testing every backdrop at each step keeps the result the
20
+ * CLOSEST passing colour to the brand accent, so the ring still reads as brand chrome. Contrast is
21
+ * a function of relative luminance alone, and lightness sweeps luminance monotonically from 0 to 1,
22
+ * so if any colour on this hue clears the set, this finds it.
23
+ */
24
+ function settleBetween(hex, backdrops, target = outlineRung_1.AA_NON_TEXT) {
25
+ const clears = (candidate) => backdrops.every((backdrop) => (0, color_1.contrastRatio)(candidate, backdrop) >= target);
26
+ if (clears(hex))
27
+ return hex;
28
+ const { l } = (0, color_1.hexToHsl)(hex);
29
+ for (let step = LIGHTNESS_STEP; step <= 100; step += LIGHTNESS_STEP) {
30
+ const darker = (0, color_1.withLightness)(hex, l - step);
31
+ if (clears(darker))
32
+ return darker;
33
+ const lighter = (0, color_1.withLightness)(hex, l + step);
34
+ if (clears(lighter))
35
+ return lighter;
36
+ }
37
+ // Unreachable for every shipped brand (the gate proves it) — a brand whose fill and canvas are
38
+ // close enough to leave no gap would land here. Take the end that is least bad rather than throw
39
+ // at import time, and let `focusRing.test.ts` be what reports it.
40
+ const ends = [(0, color_1.withLightness)(hex, 0), (0, color_1.withLightness)(hex, 100)];
41
+ const worst = (candidate) => Math.min(...backdrops.map((backdrop) => (0, color_1.contrastRatio)(candidate, backdrop)));
42
+ return worst(ends[0]) >= worst(ends[1]) ? ends[0] : ends[1];
43
+ }
44
+ /**
45
+ * The three backdrops a focused button's ring is actually drawn against: the page canvas, a card
46
+ * surface, and — because a solid button's ring sits directly against its own fill — `primary[500]`.
47
+ *
48
+ * Deliberately NOT every named surface. `muted`/`accent`/`quaternary` are mid-tone, and a single
49
+ * band asked to clear those AND a dark brand fill has no solution at any hue — contrast is
50
+ * luminance alone, and `skyline`/`storefront` come out with an empty band. That is what the second
51
+ * tone is for: `halo` owns the fill-side adjacency instead.
52
+ */
53
+ const focusRingBackdrops = (source) => [
54
+ source.backgroundColor.main,
55
+ source.background[50],
56
+ source.primary[500],
57
+ ];
58
+ exports.focusRingBackdrops = focusRingBackdrops;
59
+ /**
60
+ * The two-tone `:focus-visible` indicator.
61
+ *
62
+ * Chakra's stock ring (`shadows.outline`, a fixed `rgba(66,153,225,.6)`) measured 1.83:1 on the
63
+ * lavender canvas and 1.38:1 on a primary button — a focus indicator you cannot see, on all nine
64
+ * brands. It is one hardcoded blue, so no palette could have saved it.
65
+ *
66
+ * `halo` is the surface tone, drawn as the inner band so the indicator separates from a filled
67
+ * control whatever `colorScheme` painted it. `ring` is the brand accent, walked to the nearest
68
+ * lightness that clears 3:1 on all three backdrops at once — including the halo, so the two tones
69
+ * are always told apart.
70
+ */
71
+ const buildFocusRing = (source) => {
72
+ const halo = settleBetween(source.background[50], [source.primary[500]]);
73
+ return {
74
+ halo,
75
+ ring: settleBetween(source.primary[500], [...(0, exports.focusRingBackdrops)(source), halo]),
76
+ };
77
+ };
78
+ exports.buildFocusRing = buildFocusRing;
79
+ /**
80
+ * Re-derives the ring from the FINISHED palette, as the last step of the rung chain.
81
+ *
82
+ * `finalizeLightPalette` and `buildDarkPalette` seed it, but neither is the final word: a brand
83
+ * file may replace `primary` or `background` after the generic derivation runs — `zinc`'s dark
84
+ * palette replaces both, inverting the filled button to near-white — and a ring resolved against
85
+ * the pre-override values is then silently wrong. Same reason `withSurfaceRungs` re-lifts inks the
86
+ * finalizers already derived. `focusRing.test.ts` asserts this is a fixed point.
87
+ */
88
+ const withFocusRing = (palette) => ({
89
+ ...palette,
90
+ focusRing: (0, exports.buildFocusRing)(palette),
91
+ });
92
+ exports.withFocusRing = withFocusRing;
@@ -3,6 +3,7 @@ export * from "./buildBrandTokens";
3
3
  export * from "./buildDarkPalette";
4
4
  export * from "./chartColorsFromTheme";
5
5
  export * from "./color";
6
+ export * from "./focusRing";
6
7
  export * from "./scrollbar";
7
8
  export * from "./gradientInk";
8
9
  export * from "./placeholderInk";
@@ -19,6 +19,7 @@ __exportStar(require("./buildBrandTokens"), exports);
19
19
  __exportStar(require("./buildDarkPalette"), exports);
20
20
  __exportStar(require("./chartColorsFromTheme"), exports);
21
21
  __exportStar(require("./color"), exports);
22
+ __exportStar(require("./focusRing"), exports);
22
23
  __exportStar(require("./scrollbar"), exports);
23
24
  __exportStar(require("./gradientInk"), exports);
24
25
  __exportStar(require("./placeholderInk"), exports);
@@ -12,8 +12,14 @@ export declare const AA_NON_TEXT = 3;
12
12
  * At 3:1 a container border is text-weight and the UI reads as a wireframe; every mainstream
13
13
  * system draws this line far lighter (Tailwind gray-200 1.24, Chakra gray.200 1.30, Material
14
14
  * outline-variant 1.61). 1.4.11 asks that a control be identifiable, not that its resting
15
- * border carry the whole job: the fill, the label and the focus ring do that, and the focus
16
- * ring is still held to `AA_NON_TEXT`.
15
+ * border carry the whole job: the fill, the label and the focus ring do that.
16
+ *
17
+ * That defence was untrue when it was written. Buttons defined no `_focusVisible` at all, so the
18
+ * focus ring was Chakra's stock `shadows.outline` — a fixed blue measuring 1.38:1 on a primary
19
+ * button — and nothing in the library was held to `AA_NON_TEXT` at the moment of focus. It is
20
+ * true now: `focusRing` (`builders/focusRing.ts`) is derived per brand per mode and gated at 3:1
21
+ * on the canvas, on a card and on the button's own fill by `builders/focusRing.test.ts`. If that
22
+ * gate is ever relaxed, this constant loses its justification with it.
17
23
  */
18
24
  export declare const CHROME_BORDER = 1.5;
19
25
  export declare const CHROME_RULE = 1.31;
@@ -21,8 +21,14 @@ exports.AA_NON_TEXT = 3;
21
21
  * At 3:1 a container border is text-weight and the UI reads as a wireframe; every mainstream
22
22
  * system draws this line far lighter (Tailwind gray-200 1.24, Chakra gray.200 1.30, Material
23
23
  * outline-variant 1.61). 1.4.11 asks that a control be identifiable, not that its resting
24
- * border carry the whole job: the fill, the label and the focus ring do that, and the focus
25
- * ring is still held to `AA_NON_TEXT`.
24
+ * border carry the whole job: the fill, the label and the focus ring do that.
25
+ *
26
+ * That defence was untrue when it was written. Buttons defined no `_focusVisible` at all, so the
27
+ * focus ring was Chakra's stock `shadows.outline` — a fixed blue measuring 1.38:1 on a primary
28
+ * button — and nothing in the library was held to `AA_NON_TEXT` at the moment of focus. It is
29
+ * true now: `focusRing` (`builders/focusRing.ts`) is derived per brand per mode and gated at 3:1
30
+ * on the canvas, on a card and on the button's own fill by `builders/focusRing.test.ts`. If that
31
+ * gate is ever relaxed, this constant loses its justification with it.
26
32
  */
27
33
  exports.CHROME_BORDER = 1.5;
28
34
  // The resting rule between two surfaces — a card edge, a divider, a menu outline. Lighter than
@@ -65,6 +65,24 @@ export type SidebarTokens = {
65
65
  /** Status ink resolved against the RAIL. `semanticText` is canvas-relative, not rail. */
66
66
  semanticText: SemanticTextTokens;
67
67
  };
68
+ /**
69
+ * The two-tone focus indicator. `ring` is the brand-toned band that carries WCAG 1.4.11
70
+ * against whatever is BEHIND the control; `halo` is the inner band drawn between the ring
71
+ * and the control's own fill, so the indicator stays visible on a filled button too.
72
+ *
73
+ * Two tones because one band cannot own both adjacencies. `ring` is resolved against three
74
+ * backdrops (canvas, card, `primary[500]`) and clears all three on every brand — but a button
75
+ * can be filled by any `colorScheme`, and widening the ring to cover the mid-tone named surfaces
76
+ * as well leaves `skyline` and `storefront` with no solution at any hue (contrast is luminance
77
+ * alone, and their bands come out empty). `halo` takes the fill-side adjacency instead, so the
78
+ * pair holds whatever the button is filled with and whatever it sits on.
79
+ */
80
+ export type FocusRingTokens = {
81
+ /** Outer band. >=3:1 against the canvas, a card surface, and the control's own fill. */
82
+ ring: string;
83
+ /** Inner band, in the surface tone. >=3:1 against the fill it hugs and against `ring`. */
84
+ halo: string;
85
+ };
68
86
  export type BoxShadowTokens = {
69
87
  primary: string;
70
88
  error: string;
@@ -102,6 +120,8 @@ export type PaletteProps = {
102
120
  header: ColorScale;
103
121
  placeholder: ColorScale;
104
122
  boxShadow: BoxShadowTokens;
123
+ /** Two-tone `:focus-visible` indicator, resolved to 3:1 (WCAG 1.4.11). Never hand-written. */
124
+ focusRing: FocusRingTokens;
105
125
  sidebar: SidebarTokens;
106
126
  boxborder: ColorScale;
107
127
  border: ColorScale;
@@ -125,7 +145,7 @@ export type PaletteProps = {
125
145
  };
126
146
  /** What a brand writes by hand; every token required, so a gap is a compile error. */
127
147
  /** Derived tokens are excluded — `finalizeLightPalette` resolves them per brand. */
128
- export type BrandPaletteSource = Omit<PaletteProps, "accentText" | "textMuted" | "semanticText" | "sidebar" | "onPrimary" | "onSemantic"> & {
148
+ export type BrandPaletteSource = Omit<PaletteProps, "accentText" | "textMuted" | "semanticText" | "sidebar" | "onPrimary" | "onSemantic" | "focusRing"> & {
129
149
  sidebar: {
130
150
  background: ColorScale;
131
151
  };
@@ -1,6 +1,7 @@
1
1
  import { accentTextOnCanvas, isDarkCanvas, onFilled } from "../../Theme/tokens/builders/accentText.js";
2
2
  import { contrastRatio } from "../../Theme/tokens/builders/color.js";
3
3
  import { resolveGradient, gradientCss } from "../../Theme/tokens/builders/gradientInk.js";
4
+ import { focusVisibleStyles } from "../../Theme/chakra/focusRing.styles.js";
4
5
  /**
5
6
  * Outline/ghost/link text sits ON the canvas, so it needs a rung that stays readable there —
6
7
  * `[500]` is pinned dark enough to carry a white label on the solid variant and measures only
@@ -59,8 +60,19 @@ const darkerFill = (theme, colorScheme, step) => {
59
60
  };
60
61
  /** Label for the solid variant's filled bg — derived from THAT fill, since hover/active swap it. */
61
62
  const labelFor = (theme, fill) => onFilled(fill, theme.colors.white, theme.colors.black);
63
+ /**
64
+ * The two-tone `:focus-visible` ring, from the palette's derived `focusRing` pair.
65
+ *
66
+ * Every variant spreads this. Before it, none of them defined `_focusVisible` at all, so every
67
+ * button in every consuming app fell through to Chakra's stock `shadows.outline` — one fixed blue
68
+ * that measured 1.83:1 on the canvas and 1.38:1 on a primary fill, failing WCAG 1.4.11 (3:1) on
69
+ * all nine brands. `focusRing.test.ts` is the gate that keeps this true.
70
+ */
71
+ const focusRing = (theme) => focusVisibleStyles(theme.colors);
62
72
  export const Button = {
63
- baseStyle: () => ({
73
+ // The ring is repeated on every variant below AND here, so a variant name this file does not
74
+ // define (a consumer's own, or an `IconButton` falling through) still gets an indicator.
75
+ baseStyle: ({ theme }) => ({
64
76
  borderRadius: "lg",
65
77
  fontWeight: 500,
66
78
  fontSize: "1rem",
@@ -69,6 +81,7 @@ export const Button = {
69
81
  opacity: 0.6,
70
82
  cursor: "not-allowed",
71
83
  },
84
+ ...focusRing(theme),
72
85
  }),
73
86
  variants: {
74
87
  solid: ({ theme, colorScheme = "primary" }) => {
@@ -101,6 +114,7 @@ export const Button = {
101
114
  color: hoverLabel,
102
115
  },
103
116
  },
117
+ ...focusRing(theme),
104
118
  };
105
119
  },
106
120
  outline: ({ theme, colorScheme = "primary" }) => {
@@ -130,6 +144,7 @@ export const Button = {
130
144
  // bg: theme.colors[colorScheme][500],
131
145
  color: ink,
132
146
  },
147
+ ...focusRing(theme),
133
148
  };
134
149
  },
135
150
  ghost: ({ theme, colorScheme = "primary" }) => ({
@@ -142,6 +157,7 @@ export const Button = {
142
157
  _active: {
143
158
  bg: onCanvasHoverBg(theme, colorScheme, 24),
144
159
  },
160
+ ...focusRing(theme),
145
161
  }),
146
162
  link: ({ theme, colorScheme = "primary" }) => ({
147
163
  color: onCanvasText(theme, colorScheme),
@@ -154,13 +170,16 @@ export const Button = {
154
170
  _active: {
155
171
  color: onCanvasText(theme, colorScheme),
156
172
  },
173
+ ...focusRing(theme),
157
174
  }),
158
- unstyled: () => ({
175
+ // `unstyled` drops the fill, not the affordance: a focus indicator is not styling.
176
+ unstyled: ({ theme }) => ({
159
177
  bg: "transparent",
160
178
  color: "inherit",
161
179
  _hover: {
162
180
  bg: "transparent",
163
181
  },
182
+ ...focusRing(theme),
164
183
  }),
165
184
  // Theme-based two-tone gradient: each theme defines its own `gradient` {from,to} pair.
166
185
  // Ignores colorScheme; hover/active shift brightness so it works for any pair.
@@ -187,6 +206,7 @@ export const Button = {
187
206
  filter: "brightness(0.92)",
188
207
  _hover: { bgImage: bg, filter: "brightness(0.92)" },
189
208
  },
209
+ ...focusRing(theme),
190
210
  };
191
211
  },
192
212
  },
@@ -5,7 +5,8 @@ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
5
5
  import Button from "../Button/Button.js";
6
6
  import { useCustomTheme } from "../../Theme/useCustomTheme.js";
7
7
  import { onFilled } from "../../Theme/tokens/builders/accentText.js";
8
- export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, tempDate, tempRangeStart, tempRangeEnd, isSameDay, isBefore, isAfter, minDate, maxDate, disablePastDates, disableFutureDates, disableToday, onDaySelect, hoveredDate, onDayHover, renderWeekdays, }) => {
8
+ import { DAY_CLASS, dayAccessibleName, dayStateClassName, resolveDayStateVisual, } from "./dayStates.js";
9
+ export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, tempDate, tempRangeStart, tempRangeEnd, isSameDay, isBefore, isAfter, minDate, maxDate, disablePastDates, disableFutureDates, disableToday, onDaySelect, hoveredDate, onDayHover, renderWeekdays, dayStates, renderDayContent, }) => {
9
10
  const theme = useCustomTheme();
10
11
  const rangeBg = theme.colors.primary.opacity[16];
11
12
  const isViewingCurrentMonth = currentMonth.getFullYear() === today.getFullYear() &&
@@ -45,6 +46,7 @@ export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, t
45
46
  },
46
47
  };
47
48
  const renderDays = () => {
49
+ var _a;
48
50
  const monthStart = startOfMonth(currentMonth);
49
51
  const monthEnd = endOfMonth(currentMonth);
50
52
  const startDate = startOfWeek(monthStart);
@@ -63,12 +65,17 @@ export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, t
63
65
  const isSelected = !!(!isRange && tempDate && isSameDay(thisDay, tempDate));
64
66
  const isTentativeEnd = isEnd && isTentative;
65
67
  const circleSolid = !!(isSelected || (isRange && isStart) || (isEnd && !isTentative));
68
+ // Marking is not disabling: only the `disabled` state key blocks selection, and only
69
+ // because the caller asked for it by name.
70
+ const states = (_a = dayStates === null || dayStates === void 0 ? void 0 : dayStates(thisDay)) !== null && _a !== void 0 ? _a : [];
71
+ const stateVisual = states.length ? resolveDayStateVisual(states, theme.colors) : null;
66
72
  const disabled = !isInCurrentMonth ||
67
73
  !!(minDate && isBefore(thisDay, minDate)) ||
68
74
  !!(maxDate && isAfter(thisDay, maxDate)) ||
69
75
  !!(disablePastDates && isBefore(thisDay, today) && !isSameDay(thisDay, today)) ||
70
76
  !!(disableFutureDates && isAfter(thisDay, today) && !isSameDay(thisDay, today)) ||
71
- !!(disableToday && isToday);
77
+ !!(disableToday && isToday) ||
78
+ !!(stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.blocksSelection);
72
79
  // Connected range band painted on the cell background; start/end use a
73
80
  // half gradient so the band meets the solid day circle cleanly.
74
81
  let cellBg;
@@ -82,6 +89,9 @@ export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, t
82
89
  else if (isEnd)
83
90
  cellBg = `linear-gradient(to right, ${rangeBg} 50%, transparent 50%)`;
84
91
  }
92
+ // Selection feedback outranks a state tint — the band is what tells the user what they picked.
93
+ if (!cellBg && (stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.cellBg))
94
+ cellBg = stateVisual.cellBg;
85
95
  // Solid fill (selected/range-endpoint) needs the ink checked against that
86
96
  // specific fill — `primary[500]` doesn't reliably clear AA for white text on
87
97
  // every brand/mode (same gap the solid Button variant already accounts for).
@@ -102,11 +112,20 @@ export const CalendarPanel = ({ currentMonth, setCurrentMonth, today, isRange, t
102
112
  : isToday
103
113
  ? `1px solid ${theme.colors.primary[300]}`
104
114
  : "1px solid transparent";
105
- days.push(_jsx(Box, { h: "2rem", display: "flex", alignItems: "center", justifyContent: "center", style: cellBg ? { background: cellBg } : undefined, children: _jsx(Box, { as: "button", type: "button", disabled: disabled, onClick: () => !disabled && onDaySelect(new Date(thisDay)), onMouseEnter: () => !disabled && (onDayHover === null || onDayHover === void 0 ? void 0 : onDayHover(new Date(thisDay))), w: "1.75rem", h: "1.75rem", borderRadius: "full", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.8125rem", fontWeight: circleSolid ? 600 : 500, lineHeight: "1", cursor: disabled ? "not-allowed" : "pointer", bg: circleSolid ? solidFill : "transparent", color: circleColor, border: circleBorder, transition: "background 0.12s, color 0.12s", _hover: disabled
115
+ // A filled circle owns its own ink (checked against that fill by `onFilled`), so a state
116
+ // never repaints a selected day — it would undo that contrast guarantee.
117
+ const dayColor = !circleSolid && (stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.color) ? stateVisual.color : circleColor;
118
+ const dayBorder = !circleSolid && (stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.border) ? stateVisual.border : circleBorder;
119
+ const customContent = renderDayContent ? renderDayContent(thisDay, states) : null;
120
+ const markerColor = customContent == null && (stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.markerColor) ? stateVisual.markerColor : null;
121
+ const hasDayOverlay = customContent != null || markerColor != null;
122
+ days.push(_jsx(Box, { h: "2rem", display: "flex", alignItems: "center", justifyContent: "center", style: cellBg ? { background: cellBg } : undefined, children: _jsxs(Box, { as: "button", type: "button", disabled: disabled, className: dayStates ? dayStateClassName(states) : undefined, "aria-label": dayStates ? dayAccessibleName(format(thisDay, "d MMMM yyyy"), states) : undefined, onClick: () => !disabled && onDaySelect(new Date(thisDay)), onMouseEnter: () => !disabled && (onDayHover === null || onDayHover === void 0 ? void 0 : onDayHover(new Date(thisDay))), position: hasDayOverlay ? "relative" : undefined, w: "1.75rem", h: "1.75rem", borderRadius: "full", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.8125rem", fontWeight: circleSolid ? 600 : 500, lineHeight: "1", cursor: disabled ? "not-allowed" : "pointer", bg: circleSolid ? solidFill : "transparent", color: dayColor, textDecoration: (stateVisual === null || stateVisual === void 0 ? void 0 : stateVisual.strikethrough) ? "line-through" : undefined, border: dayBorder, transition: "background 0.12s, color 0.12s", _hover: disabled
106
123
  ? {}
107
124
  : circleSolid
108
125
  ? { bg: solidHoverFill, color: circleHoverColor }
109
- : { bg: rangeBg }, children: format(thisDay, "d") }) }, thisDay.toString()));
126
+ : { bg: rangeBg }, children: [format(thisDay, "d"), hasDayOverlay && (_jsx(Box, { as: "span", className: `${DAY_CLASS}__content`, position: "absolute", left: 0, right: 0, bottom: "0.0625rem", display: "flex", alignItems: "flex-end", justifyContent: "center", lineHeight: "1", fontSize: "0.5rem",
127
+ // The overlay is decoration: clicks belong to the day button under it.
128
+ pointerEvents: "none", children: customContent !== null && customContent !== void 0 ? customContent : (_jsx(Box, { as: "span", className: `${DAY_CLASS}__marker`, w: "0.25rem", h: "0.25rem", borderRadius: "full", bg: markerColor })) }))] }) }, thisDay.toString()));
110
129
  day = addDays(day, 1);
111
130
  }
112
131
  rows.push(_jsx(Grid, { templateColumns: "repeat(7, 1fr)", gap: 0, children: days }, day.toString()));
@@ -19,7 +19,7 @@ const setTimeFrom = (base, time) => {
19
19
  };
20
20
  export const RangeDatePicker = (props) => {
21
21
  var _a, _b, _c, _d;
22
- const { id, name, label, isRequired, isInformation, informationMessage, error, errorMessage, helperText, placeholderText, dateFormat = "dd/MM/yyyy", minDate, maxDate, disableFutureDates, disablePastDates, autoComplete = "off", disabled, width = "100%", disableToday = false, size = "md", } = props;
22
+ const { id, name, label, isRequired, isInformation, informationMessage, error, errorMessage, helperText, placeholderText, dateFormat = "dd/MM/yyyy", minDate, maxDate, disableFutureDates, disablePastDates, autoComplete = "off", disabled, width = "100%", disableToday = false, size = "md", dayStates, renderDayContent, } = props;
23
23
  const theme = useCustomTheme();
24
24
  const sizeKey = typeof size === "string" ? size : "md";
25
25
  const calendarIconPx = getTextInputIconSizePx(sizeKey);
@@ -175,7 +175,7 @@ export const RangeDatePicker = (props) => {
175
175
  }, cursor: "pointer" }), _jsx(InputRightElement, { height: "100%", display: "flex", alignItems: "center", pointerEvents: "auto", style: { cursor: "pointer" }, onClick: () => (isOpen ? onClose() : handleOpen()), children: _jsx(Box, { as: "span", sx: calendarIconWrapperSx, children: _jsx(CalendarIcon, { size: calendarIconPx, color: theme.colors.textMuted }) }) })] }) }) }), _jsx(PopoverContent, { w: "18rem", maxW: "18rem", p: 3, ref: popoverRef, boxShadow: "lg", borderRadius: "xl", bg: theme.colors.background[50], children: _jsxs(PopoverBody, { p: 0, children: [_jsx(Box, { display: "flex", flexWrap: "wrap", gap: 2, mb: 3, children: presets.map((p) => (_jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", sx: { flex: "1 1 auto" }, onClick: () => {
176
176
  const [from, to] = p.getRange();
177
177
  applyPreset(from, to);
178
- }, children: p.label }, p.label))) }), _jsx(CalendarPanel, { currentMonth: currentMonth, setCurrentMonth: (updater) => setCurrentMonthState((prev) => updater(prev)), today: today, isRange: true, tempDate: null, tempRangeStart: tempRangeStart, tempRangeEnd: tempRangeEnd, hoveredDate: hoveredDate, onDayHover: setHoveredDate, isSameDay: isSameDay, isBefore: isBefore, isAfter: isAfter, minDate: minDate, maxDate: maxDate, disablePastDates: disablePastDates, disableFutureDates: disableFutureDates, disableToday: disableToday, onDaySelect: handleDaySelect }), formatHasTime && (_jsxs(Box, { mt: 3, display: "grid", gridTemplateColumns: "1fr", gap: 2, children: [_jsxs(Box, { children: [_jsx(Box, { fontSize: "xs", color: theme.colors.textMuted, mb: 1, children: "Start time" }), shouldUseSelectTimePicker ? (_jsx(TimePicker, { date: startTimeBaseDate, onChange: (updated) => {
178
+ }, children: p.label }, p.label))) }), _jsx(CalendarPanel, { currentMonth: currentMonth, setCurrentMonth: (updater) => setCurrentMonthState((prev) => updater(prev)), today: today, isRange: true, tempDate: null, tempRangeStart: tempRangeStart, tempRangeEnd: tempRangeEnd, hoveredDate: hoveredDate, onDayHover: setHoveredDate, isSameDay: isSameDay, isBefore: isBefore, isAfter: isAfter, minDate: minDate, maxDate: maxDate, disablePastDates: disablePastDates, disableFutureDates: disableFutureDates, disableToday: disableToday, onDaySelect: handleDaySelect, dayStates: dayStates, renderDayContent: renderDayContent }), formatHasTime && (_jsxs(Box, { mt: 3, display: "grid", gridTemplateColumns: "1fr", gap: 2, children: [_jsxs(Box, { children: [_jsx(Box, { fontSize: "xs", color: theme.colors.textMuted, mb: 1, children: "Start time" }), shouldUseSelectTimePicker ? (_jsx(TimePicker, { date: startTimeBaseDate, onChange: (updated) => {
179
179
  if (!tempRangeStart) {
180
180
  setTempRangeStart(updated);
181
181
  return;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The keys with built-in styling, in ascending precedence — when a day carries several, a later
3
+ * key's visual overrides the fields an earlier one set.
4
+ */
5
+ export const DAY_STATE_KEYS = [
6
+ "weekly-off",
7
+ "holiday",
8
+ "optional-holiday",
9
+ "excluded",
10
+ "disabled",
11
+ ];
12
+ /**
13
+ * Token roles, not rungs (see `Theme/THEME-CONTRACT.md`):
14
+ * - a non-working day is a *surface* step down plus muted ink — `backgroundColor.muted` / `textMuted`
15
+ * - a holiday is *status ink*, so `semanticText.<tone>`, never the `semantic.<tone>[500]` fill rung
16
+ * - an optional holiday is the same tone family one step less certain: warning + a dashed ring
17
+ * - an unavailable day is the `disabled` ramp, the only ramp whose job is exactly this
18
+ */
19
+ const VISUALS = {
20
+ "weekly-off": (colors) => ({
21
+ cellBg: colors.backgroundColor.muted,
22
+ color: colors.textMuted,
23
+ }),
24
+ holiday: (colors) => ({
25
+ color: colors.semanticText.info,
26
+ markerColor: colors.semanticText.info,
27
+ }),
28
+ "optional-holiday": (colors) => ({
29
+ color: colors.semanticText.warning,
30
+ markerColor: colors.semanticText.warning,
31
+ border: `1px dashed ${colors.semanticText.warning}`,
32
+ }),
33
+ excluded: (colors) => ({
34
+ color: colors.textMuted,
35
+ strikethrough: true,
36
+ }),
37
+ disabled: (colors) => ({
38
+ color: colors.disabled[500],
39
+ blocksSelection: true,
40
+ }),
41
+ };
42
+ export const isDayStateKey = (key) => DAY_STATE_KEYS.includes(key);
43
+ /** Merges every recognised state in `DAY_STATE_KEYS` order, so precedence never depends on caller order. */
44
+ export const resolveDayStateVisual = (states, colors) => {
45
+ const visual = {};
46
+ for (const key of DAY_STATE_KEYS) {
47
+ if (states.includes(key))
48
+ Object.assign(visual, VISUALS[key](colors));
49
+ }
50
+ return visual;
51
+ };
52
+ /** Base class on every day button once `dayStates` is supplied. Consumers may style off it. */
53
+ export const DAY_CLASS = "pxl-calendar-day";
54
+ const slug = (key) => key
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9]+/g, "-")
58
+ .replace(/^-+|-+$/g, "");
59
+ /** `["Onam", "holiday"]` → `"pxl-calendar-day pxl-calendar-day--onam pxl-calendar-day--holiday"`. */
60
+ export const dayStateClassName = (states) => [
61
+ DAY_CLASS,
62
+ ...states
63
+ .map(slug)
64
+ .filter(Boolean)
65
+ .map((key) => `${DAY_CLASS}--${key}`),
66
+ ].join(" ");
67
+ /** Human phrasing for the accessible name — every key, styled or not, in the caller's own order. */
68
+ export const describeDayStates = (states) => states
69
+ .map((state) => (isDayStateKey(state) ? state.replace(/-/g, " ") : state.trim()))
70
+ .filter(Boolean)
71
+ .join(", ");
72
+ /**
73
+ * The day's accessible name. The date is spelled out because "16" alone tells a screen-reader
74
+ * user nothing, and the states follow it so the marking is heard, not only seen.
75
+ */
76
+ export const dayAccessibleName = (formattedDate, states) => {
77
+ const described = describeDayStates(states);
78
+ return described ? `${formattedDate}, ${described}` : formattedDate;
79
+ };
@@ -2,8 +2,8 @@ const REQUIRED_GROUPS = [
2
2
  "gradient", "primary", "secondary", "tertiary", "transparent", "black", "white",
3
3
  "semantic", "gray", "red", "orange", "yellow", "green", "teal", "blue", "cyan",
4
4
  "purple", "pink", "backgroundColor", "background", "text", "header", "placeholder",
5
- "boxShadow", "sidebar", "boxborder", "border", "table", "disabled", "accentText",
6
- "textMuted", "semanticText", "onPrimary", "onSemantic",
5
+ "boxShadow", "focusRing", "sidebar", "boxborder", "border", "table", "disabled",
6
+ "accentText", "textMuted", "semanticText", "onPrimary", "onSemantic",
7
7
  ];
8
8
  export const allPaletteTokensListed = true;
9
9
  const NESTED = [
@@ -14,6 +14,7 @@ const NESTED = [
14
14
  ]],
15
15
  ["semanticText", ["success", "error", "warning", "info"]],
16
16
  ["onSemantic", ["success", "error", "warning", "info"]],
17
+ ["focusRing", ["ring", "halo"]],
17
18
  ["backgroundColor", ["main", "base"]],
18
19
  ["boxShadow", ["primary", "default"]],
19
20
  ];
@@ -4,6 +4,7 @@ import fonts from "./fonts.web.js";
4
4
  import { componentStyles } from "./componentStyles.js";
5
5
  import { onFilled } from "../tokens/builders/accentText.js";
6
6
  import { globalScrollbarStyles } from "../tokens/builders/scrollbar.js";
7
+ import { focusRingShadow } from "./focusRing.styles.js";
7
8
  /**
8
9
  * Builds a Chakra theme from a pure brand palette.
9
10
  *
@@ -21,6 +22,12 @@ export const createBrandTheme = (palette, options = {}) => {
21
22
  colors: palette,
22
23
  ...fonts,
23
24
  ...common,
25
+ // `shadows.outline` is what Chakra's stock `_focusVisible` reaches for on EVERY component it
26
+ // themes. The primitive ships one hardcoded blue (`rgba(66,153,225,.6)`), which measured
27
+ // 1.83:1 on the lavender canvas and 1.38:1 on a primary button — below 1.4.11's 3:1 on all
28
+ // nine brands, in both modes. A primitive cannot know the palette; this is the first place
29
+ // that does, so the palette-derived ring is bound here and every stock focus state inherits it.
30
+ shadows: { ...common.shadows, outline: focusRingShadow(palette) },
24
31
  };
25
32
  return extendTheme({
26
33
  ...brand,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WEB binding for the `focusRing` token pair. The colours are derived in
3
+ * `tokens/builders/focusRing.ts` (pure, shared with the mobile library); the CSS shape —
4
+ * box-shadow bands, the forced-colors outline — is web-only and lives here.
5
+ */
6
+ /** Inner band, in the surface tone: separates the ring from the control's own fill. */
7
+ export const FOCUS_HALO_WIDTH = 2;
8
+ /** Outer edge of the brand band. The band itself is the 2px between halo and ring. */
9
+ export const FOCUS_RING_WIDTH = 4;
10
+ /**
11
+ * The two-tone ring as a single `box-shadow`. Drawn OUTSIDE the border box, so it costs no
12
+ * layout and never resizes the control — the reason this is a shadow and not an outline.
13
+ */
14
+ export const focusRingShadow = (colors) => `0 0 0 ${FOCUS_HALO_WIDTH}px ${colors.focusRing.halo}, ` +
15
+ `0 0 0 ${FOCUS_RING_WIDTH}px ${colors.focusRing.ring}`;
16
+ /**
17
+ * `:hover`, `:active` and `:focus-visible` all score the same specificity, so which one paints
18
+ * comes down to source order — and in the merged style object Chakra's own Button baseStyle
19
+ * registers `_focusVisible` BEFORE `_hover`. A focused button that is also hovered would then
20
+ * drop the ring for `_hover`'s `boxShadow: "sm"`. Stacking the pseudo-classes raises the
21
+ * specificity above both, so the ring survives whatever else the state repaints.
22
+ */
23
+ export const FOCUS_VISIBLE_OVER_STATE = "&:hover:focus-visible, &:active:focus-visible, &[data-active]:focus-visible";
24
+ /**
25
+ * `:focus-visible`, never `:focus` — a mouse click must not draw a ring.
26
+ *
27
+ * The transparent outline is not decoration: in Windows High Contrast / `forced-colors` mode
28
+ * box-shadows are dropped entirely, and a transparent outline is the one thing the OS repaints
29
+ * in its own focus colour. Without it, forced-colors users get no indicator at all.
30
+ */
31
+ export const focusVisibleRing = (colors) => ({
32
+ outline: "2px solid transparent",
33
+ outlineOffset: "2px",
34
+ boxShadow: focusRingShadow(colors),
35
+ });
36
+ /** Both selectors a control needs to keep the ring on top of its own hover/active styling. */
37
+ export const focusVisibleStyles = (colors) => {
38
+ const ring = focusVisibleRing(colors);
39
+ return { _focusVisible: ring, [FOCUS_VISIBLE_OVER_STATE]: ring };
40
+ };
@@ -22,6 +22,7 @@ import { withSidebarTokens } from "../builders/sidebarAccent.js";
22
22
  import { buildMutedText } from "../builders/mutedText.js";
23
23
  import { buildSemanticText, buildOnSemantic } from "../builders/semanticText.js";
24
24
  import { withPlaceholderRung, withTableHoverRung } from "../builders/resolvedRungs.js";
25
+ import { buildFocusRing, withFocusRing } from "../builders/focusRing.js";
25
26
  import { withSurfaceRungs } from "../builders/surfaceRungs.js";
26
27
  /** Derived, never hand-written: a copied hex goes stale the moment a canvas moves. */
27
28
  export const finalizeLightPalette = (source) => ({
@@ -32,10 +33,14 @@ export const finalizeLightPalette = (source) => ({
32
33
  semanticText: buildSemanticText(source.semantic, source.backgroundColor.main),
33
34
  onPrimary: onFilled(source.primary[500], source.white, source.black),
34
35
  onSemantic: buildOnSemantic(source.semantic, source.white, source.black),
36
+ focusRing: buildFocusRing(source),
35
37
  });
36
38
  export { lavender, meadow, radiant, skyline, slate, emerald, rosewood, storefront, zinc };
37
39
  export { lavenderDark, meadowDark, radiantDark, skylineDark, slateDark, emeraldDark, rosewoodDark, storefrontDark, zincDark, };
38
- const resolveRungs = (palette) => withSurfaceRungs(withTableHoverRung(withPlaceholderRung(withOutlineRung(palette))));
40
+ // `withFocusRing` is outermost: it is the only step that reads `primary` and the surfaces
41
+ // TOGETHER, so it must see both after every earlier step — and after a brand file's own
42
+ // post-derivation overrides (zinc dark replaces `primary` and `background` wholesale).
43
+ const resolveRungs = (palette) => withFocusRing(withSurfaceRungs(withTableHoverRung(withPlaceholderRung(withOutlineRung(palette)))));
39
44
  const lightBrand = (source) => resolveRungs(finalizeLightPalette(source));
40
45
  /** Light palette per brand. `lavender` is the base the other six extend. */
41
46
  export const lightPalettes = {