Skip to content

Commit 77bab86

Browse files
committed
[Toolkit] Generalize AttributesDefaultsChecker forbidden-key detection
Match any data-*/aria-* key in attributes.defaults() except data-controller/data-action, instead of an enumerated state-attr denylist, so new state attributes are caught automatically.
1 parent 23c3ead commit 77bab86

1 file changed

Lines changed: 21 additions & 19 deletions

File tree

src/Toolkit/src/Kit/Lint/Checker/AttributesDefaultsChecker.php

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
* present and can neither be silently dropped nor overridden:
2828
*
2929
* - `data-slot` is a structural marker (Shadcn) consumers must never override (checked in every kit);
30-
* - `aria-*`, Stimulus value attributes (`data-<controller>-<key>-value`) and state `data-*`
31-
* (`data-state`, `data-open`, `data-active`, `data-size`, ...) must always be emitted with an
32-
* explicit value (checked only in Tailwind kits, detected by the `tailwind_merge` idiom).
30+
* - every other `data-*`/`aria-*` attribute — ARIA, Stimulus value attributes (`data-<controller>-<key>-value`)
31+
* and state `data-*` (`data-state`, `data-open`, `data-size`, ...) — must be a literal attribute so it is
32+
* always present; only `data-controller`/`data-action` may stay in `defaults()` (checked only in Tailwind
33+
* kits, detected by the `tailwind_merge` idiom).
3334
*
3435
* Non-Tailwind kits (Bootstrap, Common) keep their own idiom (`class` merged inside `defaults()`,
3536
* no `data-slot`) and are therefore only checked against the universal `data-slot` rule.
@@ -45,15 +46,19 @@ final class AttributesDefaultsChecker implements KitCheckerInterface
4546
private const RE_DATA_SLOT_KEY = '/([\'"])data-slot\1\s*:/';
4647

4748
/**
48-
* Keys forbidden inside `defaults()` for Tailwind kits, each capturing the offending key name.
49+
* Captures every `data-*`/`aria-*` key of a `defaults()` dict literal; forbidden ones are those
50+
* not in {@see self::ALLOWED_IN_DEFAULTS}. Keeping this a category match (rather than an
51+
* enumerated list of state attributes) means new state attributes are caught automatically.
52+
*/
53+
private const RE_TAILWIND_KEY = '/([\'"])(?<key>(?:data|aria)-[a-z0-9-]+)\1\s*:/';
54+
55+
/**
56+
* Keys allowed inside `defaults()` for Tailwind kits. `data-slot` is allowed here because it is
57+
* already reported by Rule A, so it is not flagged twice.
4958
*
5059
* @var list<string>
5160
*/
52-
private const RE_STATE_KEYS = [
53-
'/([\'"])(?<key>aria-[a-z-]+)\1\s*:/',
54-
'/([\'"])(?<key>data-[a-z0-9-]+-value)\1\s*:/',
55-
'/([\'"])(?<key>data-(?:state|open|closed|active|disabled|orientation|size|variant|side|selected|checked))\1\s*:/',
56-
];
61+
private const ALLOWED_IN_DEFAULTS = ['data-controller', 'data-action', 'data-slot'];
5762

5863
public function check(Kit $kit): iterable
5964
{
@@ -102,11 +107,11 @@ private function checkFile(string $recipe, string $file, string $contents): iter
102107

103108
// Rule B (Tailwind kits only): identity/state attributes must be rendered directly.
104109
if ($isTailwind) {
105-
foreach ($this->findStateKeys($args) as $key) {
110+
foreach ($this->findForbiddenKeys($args) as $key) {
106111
yield new LintIssue(
107112
severity: LintSeverity::Error,
108113
category: 'component.attributes.state-in-defaults',
109-
message: \sprintf('`%s` must not be defined inside `attributes.defaults()` (line %d): ARIA, Stimulus value (`data-*-value`) and state `data-*` attributes must be rendered directly so they are always present. Keep only `data-controller`/`data-action` (and overridable HTML defaults such as `type`) in `defaults()`.', $key, $line),
114+
message: \sprintf('`%s` must not be defined inside `attributes.defaults()` (line %d): only `data-controller` and `data-action` may live in `defaults()`. Render ARIA, Stimulus value (`data-*-value`) and state `data-*` attributes directly so they are always present.', $key, $line),
110115
recipe: $recipe,
111116
file: $file,
112117
);
@@ -118,17 +123,14 @@ private function checkFile(string $recipe, string $file, string $contents): iter
118123
/**
119124
* @return list<string>
120125
*/
121-
private function findStateKeys(string $args): array
126+
private function findForbiddenKeys(string $args): array
122127
{
123-
$keys = [];
124-
foreach (self::RE_STATE_KEYS as $pattern) {
125-
if (preg_match_all($pattern, $args, $matches)) {
126-
foreach ($matches['key'] as $key) {
127-
$keys[] = $key;
128-
}
129-
}
128+
if (!preg_match_all(self::RE_TAILWIND_KEY, $args, $matches)) {
129+
return [];
130130
}
131131

132+
$keys = array_filter($matches['key'], static fn (string $key): bool => !\in_array($key, self::ALLOWED_IN_DEFAULTS, true));
133+
132134
return array_values(array_unique($keys));
133135
}
134136

0 commit comments

Comments
 (0)