@cerebruminc/cerebellum 20.0.6 → 20.0.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [20.0.7](https://github.com/cerebruminc/cerebellum/compare/v20.0.6...v20.0.7) (2026-08-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **mantine:** merge Modal and Notification classNames instead of replacing them ([5718033](https://github.com/cerebruminc/cerebellum/commit/57180332c8a1ca206e9e52c9eba4e4f936c5cad7))
9
+ * **mantine:** stop the inline filled-button shadow from outranking its :disabled reset ([dbcdd46](https://github.com/cerebruminc/cerebellum/commit/dbcdd460d81401bbe8ca403e76cf13ae20e1f73d))
10
+ * **notifications:** stop inline styles from blocking the stylesheet rules meant to override them ([0fee4cd](https://github.com/cerebruminc/cerebellum/commit/0fee4cd1212eb62aa82c365cabb4dd1524cdd1b1))
11
+
3
12
  ## [20.0.6](https://github.com/cerebruminc/cerebellum/compare/v20.0.5...v20.0.6) (2026-08-18)
4
13
 
5
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "20.0.6",
3
+ "version": "20.0.7",
4
4
  "description": "Cerebrum's React Component Library",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -52,8 +52,20 @@
52
52
  display: none;
53
53
  }
54
54
 
55
- .root:disabled,
56
- .root[data-disabled] {
55
+ /* The filled variant's glow. The base value lives here, not in the theme's `styles`:
56
+ Mantine applies `styles` inline, and an inline box-shadow would outrank the
57
+ `:disabled` reset below. `--button-shadow` is set in the theme's Button `vars`,
58
+ which Mantine also applies inline -- but a custom property is only a value, so the
59
+ cascade below still decides who gets to use it. Scoped to the filled variant so no
60
+ other variant gains a box-shadow declaration it did not have before. */
61
+ .root[data-variant="filled"] {
62
+ box-shadow: var(--button-shadow, none);
63
+ }
64
+
65
+ /* Doubled so this outranks the rule above regardless of source order, and beats a
66
+ consuming app's own single-class rule too. */
67
+ .root.root:disabled,
68
+ .root.root[data-disabled] {
57
69
  box-shadow: none;
58
70
  }
59
71
 
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @file src/mantine/mantineTheme.test.tsx
3
+ * @summary Guards the theme against inline styles that break the stylesheet.
4
+ * @remarks
5
+ * Mantine applies `styles` and `vars` INLINE — get-style.mjs flat-spreads every
6
+ * resolved slot into one `style` object — and @mantine/emotion is not installed.
7
+ * That fails two ways, and this file guards both:
8
+ *
9
+ * 1. A key that only means something in a stylesheet (`@media`, `:hover`, `::before`,
10
+ * a descendant selector) is silently discarded.
11
+ * 2. A *base* value left in `styles` is silently applied inline, where it outranks
12
+ * the stylesheet rule meant to override it. This is the one that shipped four
13
+ * visual bugs, so it gets the STYLESHEET_OWNED table below.
14
+ *
15
+ * Anything needing a selector or a breakpoint belongs in a component CSS Module,
16
+ * wired through the theme's `classNames`.
17
+ *
18
+ * Both scans run over PROPS_MATRIX, not just each block's defaults. `styles` and
19
+ * `vars` branch on props, so resolving a block once leaves every other branch
20
+ * unscanned — which is how an inline `boxShadow` on `variant="filled"` outranked
21
+ * Button.module.css's `:disabled` reset without any test noticing.
22
+ */
23
+ import { DEFAULT_THEME, MantineProvider, type MantineTheme, Modal, mergeMantineTheme } from "@mantine/core";
24
+ import { render } from "@testing-library/react";
25
+ import modalClasses from "./components/Modal.module.css";
26
+ import { createCustomMantineTheme } from "./mantineTheme";
27
+ import type { Color, NotificationTone, NotificationType, TextVariant } from "./types";
28
+
29
+ const theme: MantineTheme = mergeMantineTheme(DEFAULT_THEME, createCustomMantineTheme());
30
+
31
+ type Props = Record<string, unknown>;
32
+ type Slots = Record<string, Props>;
33
+
34
+ /** The shape this file relies on. Mantine types every one of these as `any`. */
35
+ type ThemeBlock = {
36
+ classNames?: Record<string, string>;
37
+ defaultProps?: Props;
38
+ styles?: (theme: MantineTheme, props: Props) => Slots;
39
+ vars?: (theme: MantineTheme, props: Props) => Slots;
40
+ };
41
+
42
+ const HOOKS = ["styles", "vars"] as const;
43
+
44
+ const componentNames = Object.keys(theme.components);
45
+ const blockFor = (name: string): ThemeBlock => theme.components[name] as ThemeBlock;
46
+
47
+ /** Keys that cannot work as an inline style property. `--custom-props` can, so exempt them. */
48
+ const STYLESHEET_ONLY = /^[@.]|[&:\s>[\]{},]/;
49
+ const isNestedKey = (key: string) => !key.startsWith("--") && STYLESHEET_ONLY.test(key);
50
+
51
+ const nestedKeysIn = (value: unknown, path: string, seen = new WeakSet<object>()): string[] => {
52
+ if (!value || typeof value !== "object" || Array.isArray(value) || seen.has(value)) {
53
+ return [];
54
+ }
55
+ seen.add(value);
56
+
57
+ return Object.entries(value).flatMap(([key, child]) => {
58
+ const here = `${path}.${key}`;
59
+ return isNestedKey(key) ? [here] : nestedKeysIn(child, here, seen);
60
+ });
61
+ };
62
+
63
+ /*
64
+ * Enumerating a union as a Record forces the compiler to flag a member added later,
65
+ * so the matrix below cannot quietly fall behind the types it covers.
66
+ */
67
+ const membersOf = <T extends string>(union: Record<T, true>) => Object.keys(union) as T[];
68
+
69
+ const TEXT_VARIANTS = membersOf<TextVariant>({
70
+ "body-xs": true,
71
+ "body-sm": true,
72
+ "body-md": true,
73
+ "body-lg": true,
74
+ "title-xl": true,
75
+ "title-lg": true,
76
+ "title-md": true,
77
+ "title-sm": true,
78
+ "title-xs": true,
79
+ "title-2xs": true,
80
+ "semibold-sm": true,
81
+ });
82
+ const NOTIFICATION_TYPES = membersOf<NotificationType>({ toast: true, bar: true, dialog: true });
83
+ const NOTIFICATION_TONES = membersOf<NotificationTone>({ positive: true, negative: true, neutral: true });
84
+ const PILL_COLORS = membersOf<Color>({
85
+ aqua: true,
86
+ blue: true,
87
+ brand: true,
88
+ gray: true,
89
+ orange: true,
90
+ peach: true,
91
+ purple: true,
92
+ red: true,
93
+ yellow: true,
94
+ });
95
+
96
+ /** Every block reading `props.size` funnels into the same `getInputVars` branches. */
97
+ const INPUT_SIZES: Props[] = ["xs", "sm", "md", "lg", "xl", undefined].map((size) => ({ size }));
98
+
99
+ /*
100
+ * One entry per theme block whose `styles`/`vars` reads props. Each row is merged
101
+ * over that block's own `defaultProps`, and the block's bare defaults are always
102
+ * scanned too, so a row only has to name what it changes.
103
+ */
104
+ const PROPS_MATRIX: Record<string, Props[]> = {
105
+ Button: [
106
+ ...INPUT_SIZES,
107
+ { variant: "filled" },
108
+ { variant: "filled", color: "gray" },
109
+ { variant: "filled", w: 200 },
110
+ { variant: "light" },
111
+ { variant: "light", color: "gray" },
112
+ { variant: "subtle" },
113
+ { variant: "subtle", color: "gray" },
114
+ { variant: "outline" },
115
+ { variant: "default" },
116
+ { boxed: true },
117
+ { boxed: true, variant: "subtle" },
118
+ ],
119
+ Text: [...TEXT_VARIANTS.map((variant) => ({ variant })), { variant: undefined }, { variant: "not-a-variant" }],
120
+ Notification: [
121
+ ...NOTIFICATION_TYPES.flatMap((notificationType) =>
122
+ NOTIFICATION_TONES.flatMap((tone) => [
123
+ { notificationType, tone },
124
+ { notificationType, tone, icon: "icon" },
125
+ ])
126
+ ),
127
+ { notificationType: undefined, tone: undefined },
128
+ ],
129
+ Pill: [...PILL_COLORS.flatMap((color) => [{ color }, { color, withRemoveButton: true }]), { color: undefined }],
130
+ TextInput: INPUT_SIZES,
131
+ PasswordInput: INPUT_SIZES,
132
+ Textarea: INPUT_SIZES,
133
+ Select: INPUT_SIZES,
134
+ MultiSelect: INPUT_SIZES,
135
+ DateInput: INPUT_SIZES,
136
+ DatePickerInput: INPUT_SIZES,
137
+ Autocomplete: INPUT_SIZES,
138
+ InputBase: INPUT_SIZES,
139
+ };
140
+
141
+ /** A block's defaults, plus every matrix row layered over them. */
142
+ const permutationsFor = (name: string) => {
143
+ const defaults = { ...blockFor(name).defaultProps };
144
+
145
+ return [
146
+ { label: "defaultProps", props: defaults },
147
+ ...(PROPS_MATRIX[name] ?? []).map((row) => ({ label: JSON.stringify(row), props: { ...defaults, ...row } })),
148
+ ];
149
+ };
150
+
151
+ describe("no stylesheet-only keys in inline styles", () => {
152
+ it.each(["@media (max-width: 600px)", "&:hover", ":hover", "::before", "> .thumb", "[data-x] span"])("rejects %s", (key) => {
153
+ expect(isNestedKey(key)).toBe(true);
154
+ });
155
+
156
+ it.each(["padding", "backgroundColor", "--badge-fz"])("allows %s", (key) => {
157
+ expect(isNestedKey(key)).toBe(false);
158
+ });
159
+
160
+ it("finds no stylesheet-only key in any theme block, under any props in the matrix", () => {
161
+ const offenders = componentNames.flatMap((name) => {
162
+ const block = blockFor(name);
163
+
164
+ return HOOKS.flatMap((hook) => {
165
+ const resolve = block[hook];
166
+ if (typeof resolve !== "function") {
167
+ return [];
168
+ }
169
+
170
+ return permutationsFor(name).flatMap(({ label, props }) => nestedKeysIn(resolve(theme, props), `${name}.${hook} [${label}]`));
171
+ });
172
+ });
173
+
174
+ expect(offenders.sort()).toEqual([]);
175
+ });
176
+
177
+ /* A block that reads props but has no matrix row would be scanned once, under its
178
+ defaults, leaving every other branch dark. Fail until someone adds the row. */
179
+ it("has a matrix entry for every block that reads props", () => {
180
+ const unscanned = componentNames.flatMap((name) =>
181
+ HOOKS.filter((hook) => {
182
+ const resolve = blockFor(name)[hook];
183
+ return typeof resolve === "function" && resolve.length >= 2 && !PROPS_MATRIX[name]?.length;
184
+ }).map((hook) => `${name}.${hook} reads props but PROPS_MATRIX has no entry for ${name}`)
185
+ );
186
+
187
+ expect(unscanned.sort()).toEqual([]);
188
+ });
189
+ });
190
+
191
+ /*
192
+ * Each row: this slot's CSS owns these properties, so the theme must not set them.
193
+ * Setting one inline is what killed the Modal's responsive padding, the notification
194
+ * close button's hover, and the filled Button's `:disabled` shadow reset.
195
+ */
196
+ const STYLESHEET_OWNED = [
197
+ { component: "Modal", slot: "header", properties: [/^padding/], owner: "components/Modal.module.css" },
198
+ { component: "Modal", slot: "body", properties: [/^padding/], owner: "components/Modal.module.css" },
199
+ { component: "Button", slot: "root", properties: [/^boxShadow/], owner: "components/Button.module.css" },
200
+ {
201
+ component: "Notification",
202
+ slot: "closeButton",
203
+ properties: ["opacity", /^transition/, /^background/],
204
+ owner: "services/NotificationAction.module.css",
205
+ },
206
+ ];
207
+
208
+ describe("styles the stylesheet owns", () => {
209
+ it.each(STYLESHEET_OWNED)("leaves the $slot slot of $component to $owner", ({ component, slot, properties, owner }) => {
210
+ const block = blockFor(component);
211
+ const offenders = permutationsFor(component).flatMap(({ label, props }) =>
212
+ Object.keys(block.styles?.(theme, props)?.[slot] ?? {})
213
+ .filter((key) => properties.some((p) => (typeof p === "string" ? p === key : p.test(key))))
214
+ .map((key) => `${component}.styles.${slot}.${key} [${label}] is applied inline, outranking ${owner}`)
215
+ );
216
+
217
+ expect(offenders).toEqual([]);
218
+ });
219
+
220
+ /* The CSS is only reachable if the module class is wired to the slot. */
221
+ it.each(STYLESHEET_OWNED)("wires $owner to the $slot slot of $component", ({ component, slot }) => {
222
+ expect(blockFor(component).classNames?.[slot]).toEqual(expect.any(String));
223
+ });
224
+ });
225
+
226
+ /* `defaultProps` goes through a shallow spread in use-props.mjs, so a caller passing
227
+ any `classNames` replaces the theme's wholesale. Top-level `classNames` merges per
228
+ slot instead. This ran for Modal and Notification only; every block needs it. */
229
+ it("keeps classNames out of defaultProps in every theme block", () => {
230
+ const nested = componentNames.filter((name) => blockFor(name).defaultProps?.classNames !== undefined);
231
+
232
+ expect(nested).toEqual([]);
233
+ });
234
+
235
+ /* The regression test for the above: openConfirmModal passes its own classNames,
236
+ which used to replace the theme's outright. */
237
+ it("keeps Modal's module classes when the caller passes classNames too", () => {
238
+ render(
239
+ <MantineProvider theme={createCustomMantineTheme()}>
240
+ <Modal opened onClose={() => {}} title="Confirm" classNames={{ content: "caller-content" }}>
241
+ Body copy
242
+ </Modal>
243
+ </MantineProvider>
244
+ );
245
+
246
+ expect(document.querySelector(".caller-content")).toHaveClass(modalClasses.content);
247
+ expect(document.querySelectorAll(`.${modalClasses.header}`)).toHaveLength(1);
248
+ expect(document.querySelectorAll(`.${modalClasses.body}`)).toHaveLength(1);
249
+ });
@@ -32,8 +32,8 @@ import buttonClasses from "./components/Button.module.css";
32
32
  import modalClasses from "./components/Modal.module.css";
33
33
  import pillClasses from "./components/Pill.module.css";
34
34
  import scrollAreaClasses from "./components/ScrollArea.module.css";
35
- import notificationActionClasses from "./services/NotificationAction.module.css";
36
35
  import notificationClasses from "./services/Notification.module.css";
36
+ import notificationActionClasses from "./services/NotificationAction.module.css";
37
37
 
38
38
  type TonedNotificationProps = NotificationProps &
39
39
  Record<`data-${string}`, unknown> & {
@@ -106,10 +106,8 @@ const getInputVars = (size?: MantineSize | (string & {})) => {
106
106
  wrapper["--input-height-lg"] = "calc(3.4375rem * var(--mantine-scale))"; // 55px
107
107
  wrapper["--input-height-xl"] = "calc(3.75rem * var(--mantine-scale))"; // 60px
108
108
 
109
- wrapper["--input-fz"] =
110
- wrapper[`--input-fz-${resolvedSize}` as keyof typeof wrapper];
111
- wrapper["--input-height"] =
112
- wrapper[`--input-height-${resolvedSize}` as keyof typeof wrapper];
109
+ wrapper["--input-fz"] = wrapper[`--input-fz-${resolvedSize}` as keyof typeof wrapper];
110
+ wrapper["--input-height"] = wrapper[`--input-height-${resolvedSize}` as keyof typeof wrapper];
113
111
 
114
112
  return { wrapper };
115
113
  };
@@ -470,6 +468,7 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
470
468
  | "--button-hover"
471
469
  | "--button-hover-color"
472
470
  | "--button-color"
471
+ | "--button-shadow"
473
472
  | "--button-subtle-bg"
474
473
  | "--button-subtle-underline",
475
474
  string | undefined
@@ -520,6 +519,21 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
520
519
  root["--button-subtle-underline"] = subtleUnderlineColor;
521
520
  }
522
521
 
522
+ /*
523
+ * The filled variant's glow, as a custom property rather than a `styles`
524
+ * entry. `styles` is applied inline, and an inline box-shadow outranks
525
+ * Button.module.css's `:disabled` reset no matter how specific that
526
+ * selector is -- which is why disabled buttons kept glowing. Custom
527
+ * properties do work inline, so this keeps the value colour-derived while
528
+ * leaving the stylesheet able to override it.
529
+ */
530
+ if (props.variant === "filled") {
531
+ const color = props.color || theme.primaryColor;
532
+ const colorScale = theme.colors[color] || theme.colors[theme.primaryColor];
533
+
534
+ root["--button-shadow"] = theme.other.highContrast ? "none" : `0 0 20px -6px ${rgba(colorScale[6], 1)}`;
535
+ }
536
+
523
537
  // gray does not follow the same tone progression as the other colors;
524
538
  // filled variant gets white text via variantColorResolver, so skip it here
525
539
  if (props.color === "gray" && props.variant !== "filled") {
@@ -529,16 +543,14 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
529
543
  return { root };
530
544
  },
531
545
  styles: (theme: MantineTheme, props: ButtonProps) => {
532
- const color = props.color || theme.primaryColor;
533
- const colorScale = theme.colors[color] || theme.colors[theme.primaryColor];
534
- const colorValue = colorScale[6];
535
-
536
546
  return {
547
+ /*
548
+ * No `boxShadow` here. The filled variant's glow is set as
549
+ * `--button-shadow` in `vars` above and applied by Button.module.css,
550
+ * beside the `:disabled` rule that clears it. Setting it here would land
551
+ * inline and that reset could never win.
552
+ */
537
553
  root: {
538
- // Add box-shadow to filled variant buttons using button's color
539
- ...(props.variant === "filled" && {
540
- boxShadow: theme.other.highContrast ? "none" : `0 0 20px -6px ${rgba(colorValue, 1)}`,
541
- }),
542
554
  // Light variant gets a border in high contrast to match SecondaryButton/BoxedButton
543
555
  ...(props.variant === "light" &&
544
556
  theme.other.highContrast && {
@@ -616,12 +628,19 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
616
628
  vars: (_theme: MantineTheme, props: SelectProps) => getInputVars(props.size),
617
629
  },
618
630
  Modal: {
631
+ /*
632
+ * Top level, not inside `defaultProps`. Mantine merges
633
+ * `theme.components.X.classNames` additively per slot, but `defaultProps` goes
634
+ * through a shallow spread in `use-props.mjs`, so a caller passing any
635
+ * `classNames` replaces these wholesale. `openConfirmModal` does exactly that,
636
+ * which is why confirm modals were losing their padding and scrollbar.
637
+ */
638
+ classNames: {
639
+ header: modalClasses.header,
640
+ body: modalClasses.body,
641
+ content: modalClasses.content,
642
+ },
619
643
  defaultProps: {
620
- classNames: {
621
- header: modalClasses.header,
622
- body: modalClasses.body,
623
- content: modalClasses.content,
624
- },
625
644
  withCloseButton: false,
626
645
  radius: "10px",
627
646
  centered: true,
@@ -634,18 +653,18 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
634
653
  color: highContrast ? "#4F6CEA" : "var(--mantine-color-blue-7)",
635
654
  },
636
655
  },
656
+ /*
657
+ * Header/body padding is owned entirely by Modal.module.css, base values
658
+ * included. A base value set here lands inline, and inline `padding` outranks
659
+ * any stylesheet rule without `!important`, so the module's `@media`
660
+ * padding-inline overrides could never apply.
661
+ */
637
662
  styles: (theme: MantineTheme) => ({
638
663
  title: {
639
664
  fontSize: "30px",
640
665
  fontWeight: 600,
641
666
  lineHeight: 1.33333,
642
667
  },
643
- header: {
644
- padding: "49px 71px 0",
645
- },
646
- body: {
647
- padding: "20px 71px 61px",
648
- },
649
668
  close: {
650
669
  position: "absolute",
651
670
  top: "19px",
@@ -766,11 +785,11 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
766
785
  },
767
786
  },
768
787
  Notification: {
769
- defaultProps: {
770
- classNames: {
771
- root: notificationClasses.root,
772
- closeButton: notificationActionClasses.closeButton,
773
- },
788
+ // Top level so a caller's `classNames` merges with these instead of
789
+ // replacing them -- see the Modal block above.
790
+ classNames: {
791
+ root: notificationClasses.root,
792
+ closeButton: notificationActionClasses.closeButton,
774
793
  },
775
794
  vars: (theme: MantineTheme, props: TonedNotificationProps) => {
776
795
  const notificationType = String(props.notificationType || "toast");
@@ -850,10 +869,13 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
850
869
  // Make the button and icon larger
851
870
  "--cb-size": "calc(2rem * var(--mantine-scale))",
852
871
  "--cb-icon-size": "calc(1.5rem * var(--mantine-scale))",
853
- backgroundColor: "transparent",
854
872
  color: theme.colors.gray[8],
855
- opacity: 0.6,
856
- transition: "opacity 350ms ease, background-color 350ms ease",
873
+ /*
874
+ * opacity, transition and background-color belong to
875
+ * NotificationAction.module.css, beside the :hover state. They have to:
876
+ * an inline `opacity: 0.6` here outranks the stylesheet's
877
+ * `:hover { opacity: 1 }` no matter how specific that selector is.
878
+ */
857
879
  ...(isToast && {
858
880
  display: "none",
859
881
  }),
@@ -874,7 +896,7 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
874
896
  icon: React.createElement(
875
897
  "span",
876
898
  { style: { display: "block", width: "12px", height: "12px", position: "relative" } },
877
- React.createElement(X, { fill: "currentColor" }),
899
+ React.createElement(X, { fill: "currentColor" })
878
900
  ),
879
901
  },
880
902
  },
@@ -1,5 +1,7 @@
1
1
  .action.action {
2
+ color: var(--mantine-color-gray-8);
2
3
  text-underline-offset: 5px;
4
+ transition: color 350ms ease;
3
5
  white-space: nowrap;
4
6
  }
5
7
 
@@ -7,6 +9,14 @@
7
9
  color: var(--mantine-color-gray-9);
8
10
  }
9
11
 
12
+ /* Base state lives here, not in the theme's `styles`: Mantine applies `styles`
13
+ inline, and an inline opacity would outrank the :hover rule below. */
14
+ .closeButton.closeButton {
15
+ background-color: transparent;
16
+ opacity: 0.6;
17
+ transition: opacity 350ms ease, background-color 350ms ease;
18
+ }
19
+
10
20
  .closeButton.closeButton:hover {
11
21
  background-color: transparent;
12
22
  opacity: 1;
@@ -64,7 +64,11 @@ export const showBar = (notification: BarNotification, store?: NotificationsStor
64
64
  <Anchor
65
65
  component="button"
66
66
  variant="body-sm"
67
- c="gray.8"
67
+ /*
68
+ * No `c` prop and no `styles.root` colour: both land as inline styles, which
69
+ * outrank any stylesheet rule, so the hover colour would never apply. The
70
+ * colour and its transition live in NotificationAction.module.css.
71
+ */
68
72
  classNames={{ root: classes.action }}
69
73
  onClick={(event) => {
70
74
  buttonClick?.(event);