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.
- package/dist/Components/Button/Button.styles.js +22 -2
- package/dist/Components/DatePicker/CalendarPanel.d.ts +5 -0
- package/dist/Components/DatePicker/CalendarPanel.js +23 -4
- package/dist/Components/DatePicker/DatePickerProps.d.ts +21 -0
- package/dist/Components/DatePicker/RangeDatePicker.js +2 -2
- package/dist/Components/DatePicker/dayStates.d.ts +56 -0
- package/dist/Components/DatePicker/dayStates.js +87 -0
- package/dist/Theme/assertPalette.d.ts +1 -1
- package/dist/Theme/assertPalette.js +3 -2
- package/dist/Theme/chakra/createBrandTheme.js +7 -0
- package/dist/Theme/chakra/focusRing.styles.d.ts +52 -0
- package/dist/Theme/chakra/focusRing.styles.js +46 -0
- package/dist/Theme/tokens/brands/index.js +6 -1
- package/dist/Theme/tokens/brands/zinc/palette.dark.d.ts +1 -0
- package/dist/Theme/tokens/builders/buildDarkPalette.js +6 -1
- package/dist/Theme/tokens/builders/focusRing.d.ts +52 -0
- package/dist/Theme/tokens/builders/focusRing.js +92 -0
- package/dist/Theme/tokens/builders/index.d.ts +1 -0
- package/dist/Theme/tokens/builders/index.js +1 -0
- package/dist/Theme/tokens/builders/outlineRung.d.ts +8 -2
- package/dist/Theme/tokens/builders/outlineRung.js +8 -2
- package/dist/Theme/tokens/types.d.ts +21 -1
- package/dist/esm/Components/Button/Button.styles.js +22 -2
- package/dist/esm/Components/DatePicker/CalendarPanel.js +23 -4
- package/dist/esm/Components/DatePicker/RangeDatePicker.js +2 -2
- package/dist/esm/Components/DatePicker/dayStates.js +79 -0
- package/dist/esm/Theme/assertPalette.js +3 -2
- package/dist/esm/Theme/chakra/createBrandTheme.js +7 -0
- package/dist/esm/Theme/chakra/focusRing.styles.js +40 -0
- package/dist/esm/Theme/tokens/brands/index.js +6 -1
- package/dist/esm/Theme/tokens/builders/buildDarkPalette.js +6 -1
- package/dist/esm/Theme/tokens/builders/focusRing.js +85 -0
- package/dist/esm/Theme/tokens/builders/index.js +1 -0
- package/dist/esm/Theme/tokens/builders/outlineRung.js +8 -2
- package/dist/esm/index.js +10 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +26 -2
- 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
|
|
16
|
-
*
|
|
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
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") }
|
|
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",
|
|
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
|
-
|
|
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 = {
|